优化参数管理页面样式与布局对齐菜单管理

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-02 23:21:18 +08:00
parent 456d7da34d
commit 75993c1b03
2 changed files with 213 additions and 74 deletions
+20
View File
@@ -240,3 +240,23 @@
- 风险与影响: - 风险与影响:
- 影响面仅 `web/src/app/admin/users/page.tsx` 前端展示层,不涉及后端接口、权限和数据结构。 - 影响面仅 `web/src/app/admin/users/page.tsx` 前端展示层,不涉及后端接口、权限和数据结构。
## Work Log - 参数管理页面样式与布局对齐菜单管理(2026-05-02)
- 背景:
- 需求 `FL-169` 要求参考菜单管理页面,优化参数管理页面的样式和布局一致性。
- 本次改动:
- 修改 `web/src/app/admin/system-params/page.tsx`
- 页面主结构改为与菜单管理页一致的卡片容器 + `Form inline` 筛选区 + 表格区布局。
- 筛选区增加“重置筛选”按钮,关键词与状态筛选排布与菜单管理页一致。
- 表格增加分页、空态文案与动态纵向滚动高度(随视口和容器变化自适应)。
- 操作列删除交互由 `Modal.confirm` 调整为 `Popconfirm`,与菜单管理页操作风格对齐。
- 登录态与无权限态改为与菜单管理页一致的居中文案 + 返回首页样式。
- 保持原有参数 CRUD 逻辑与接口不变,仅调整页面展示和交互布局。
- 验证:
- 按任务要求未执行编译/安装类检查;通过代码 diff 人工核对改动范围仅在参数管理页。
- 风险与影响:
- 影响范围仅前端参数管理页 UI 展示,不涉及后端接口或数据结构变更。
+193 -74
View File
@@ -2,24 +2,24 @@
import Link from "next/link"; import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Alert, Alert,
Button, Button,
Empty,
Form, Form,
Input, Input,
Modal, Modal,
Popconfirm,
Select, Select,
Skeleton, Spin,
Space, Space,
Table, Table,
Tag, Tag,
Typography, type TableColumnsType,
type TableProps,
} from "antd"; } from "antd";
import { useAuth } from "@/components/auth-provider"; import { useAuth } from "@/components/auth-provider";
import { Card } from "@/components/ui-antd";
import { useTopicSubscription } from "@/hooks/use-topic-subscription"; import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api"; import { readApiError } from "@/lib/api";
import type { SystemParamListResponse, SystemParamSummary } from "@/types/auth"; import type { SystemParamListResponse, SystemParamSummary } from "@/types/auth";
@@ -53,6 +53,10 @@ const PARAM_STATUS_OPTIONS = [
{ label: "已禁用", value: "disabled" }, { label: "已禁用", value: "disabled" },
] as const satisfies ReadonlyArray<{ label: string; value: FormState["status"] }>; ] as const satisfies ReadonlyArray<{ label: string; value: FormState["status"] }>;
const PARAM_TABLE_MIN_SCROLL_Y = 180;
const PARAM_TABLE_VIEWPORT_GAP = 8;
const PARAM_TABLE_FALLBACK_RESERVE = 220;
export default function AdminSystemParamsPage() { export default function AdminSystemParamsPage() {
const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -64,6 +68,9 @@ export default function AdminSystemParamsPage() {
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const [deletingId, setDeletingId] = useState<number | null>(null);
const [tableScrollY, setTableScrollY] = useState(PARAM_TABLE_MIN_SCROLL_Y);
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const canRead = hasPermission("system_param.read") || hasPermission("system_param.manage"); const canRead = hasPermission("system_param.read") || hasPermission("system_param.manage");
const canManage = hasPermission("system_param.manage"); const canManage = hasPermission("system_param.manage");
@@ -218,35 +225,46 @@ export default function AdminSystemParamsPage() {
}, },
}); });
const removeParam = useCallback(async (item: SystemParamSummary) => {
setDeletingId(item.id);
try {
await deleteMutation.mutateAsync(item);
} finally {
setDeletingId(null);
}
}, [deleteMutation]);
const items = listQuery.data?.items ?? []; const items = listQuery.data?.items ?? [];
const listError = listQuery.error instanceof Error ? listQuery.error.message : ""; const listError = listQuery.error instanceof Error ? listQuery.error.message : "";
const displayError = error || listError;
const columns = useMemo<TableProps<SystemParamSummary>["columns"]>(() => { const columns = useMemo<TableColumnsType<SystemParamSummary>>(() => {
const baseColumns: NonNullable<TableProps<SystemParamSummary>["columns"]> = [ const baseColumns: TableColumnsType<SystemParamSummary> = [
{ {
title: "ID", title: "ID",
dataIndex: "id", dataIndex: "id",
key: "id", key: "id",
width: 90, width: 110,
}, },
{ {
title: "参数键", title: "参数键",
dataIndex: "param_key", dataIndex: "param_key",
key: "param_key", key: "param_key",
width: 220, width: 240,
render: (value: string) => <Typography.Text code>{value}</Typography.Text>, render: (value: string) => <span className="font-mono text-xs">{value}</span>,
}, },
{ {
title: "参数名称", title: "参数名称",
dataIndex: "param_name", dataIndex: "param_name",
key: "param_name", key: "param_name",
width: 220, width: 200,
}, },
{ {
title: "参数值", title: "参数值",
dataIndex: "param_value", dataIndex: "param_value",
key: "param_value", key: "param_value",
ellipsis: true, ellipsis: true,
width: 240,
render: (value: string) => value || "-", render: (value: string) => value || "-",
}, },
{ {
@@ -276,110 +294,213 @@ export default function AdminSystemParamsPage() {
fixed: "right", fixed: "right",
width: 150, width: 150,
render: (_, record) => ( render: (_, record) => (
<Space size={8}> <Space size="small">
<Button size="small" onClick={() => startEdit(record)}> <Button size="small" onClick={() => startEdit(record)}>
</Button> </Button>
<Button <Popconfirm
size="small" title="删除系统参数"
danger description={`确认删除系统参数 ${record.param_key} 吗?`}
loading={deleteMutation.isPending} okText="删除"
onClick={() => { cancelText="取消"
Modal.confirm({ okButtonProps={{ danger: true, loading: deletingId === record.id }}
title: "删除系统参数", onConfirm={() => void removeParam(record)}
content: `确认删除系统参数 ${record.param_key} 吗?`,
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
await deleteMutation.mutateAsync(record);
},
});
}}
> >
<Button size="small" danger loading={deletingId === record.id}>
</Button>
</Button>
</Popconfirm>
</Space> </Space>
), ),
}); });
} }
return baseColumns; return baseColumns;
}, [canManage, deleteMutation.isPending, deleteMutation, startEdit]); }, [canManage, deletingId, removeParam, startEdit]);
const updateTableScrollY = useCallback(() => {
if (typeof window === "undefined") {
return;
}
const anchor = tableScrollAnchorRef.current;
if (!anchor) {
return;
}
const anchorTop = anchor.getBoundingClientRect().top;
const tableWrapper = anchor.querySelector<HTMLElement>(".ant-table-wrapper");
const tableBody = anchor.querySelector<HTMLElement>(".ant-table-body");
let nextHeight = Math.floor(window.innerHeight - anchorTop - PARAM_TABLE_FALLBACK_RESERVE);
if (tableWrapper) {
const wrapperRect = tableWrapper.getBoundingClientRect();
const bodyHeight = tableBody?.getBoundingClientRect().height ?? PARAM_TABLE_MIN_SCROLL_Y;
const nonBodyHeight = Math.max(0, wrapperRect.height - bodyHeight);
const topGap = Math.max(0, wrapperRect.top - anchorTop);
nextHeight = Math.floor(window.innerHeight - anchorTop - topGap - nonBodyHeight - PARAM_TABLE_VIEWPORT_GAP);
}
const clampedHeight = Math.max(PARAM_TABLE_MIN_SCROLL_Y, nextHeight);
setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight));
}, []);
useEffect(() => {
updateTableScrollY();
}, [items.length, listQuery.isFetching, listError, error, success, updateTableScrollY]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const onViewportChange = () => {
window.requestAnimationFrame(updateTableScrollY);
};
window.addEventListener("resize", onViewportChange);
return () => {
window.removeEventListener("resize", onViewportChange);
};
}, [updateTableScrollY]);
useEffect(() => {
if (typeof window === "undefined" || typeof ResizeObserver === "undefined") {
return;
}
const anchor = tableScrollAnchorRef.current;
if (!anchor) {
return;
}
const resizeObserver = new ResizeObserver(() => {
window.requestAnimationFrame(updateTableScrollY);
});
resizeObserver.observe(anchor);
return () => {
resizeObserver.disconnect();
};
}, [updateTableScrollY]);
if (initializing || listQuery.isLoading) { if (initializing || listQuery.isLoading) {
return ( return (
<Card> <div className="flex min-h-[240px] items-center justify-center">
<Skeleton active paragraph={{ rows: 8 }} /> <Spin tip="系统参数加载中..." />
</Card> </div>
); );
} }
if (!user) { if (!user) {
return ( return (
<Card> <main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<Space direction="vertical" size={12}> <p className="text-sm text-[var(--gray-11)]">访</p>
<Typography.Text type="secondary">访</Typography.Text> <Link
<Button> href="/"
<Link href="/"></Link> className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
</Button> >
</Space>
</Card> </Link>
</main>
); );
} }
if (!canRead) { if (!canRead) {
return ( return (
<Card> <main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<Space direction="vertical" size={12}> <p className="text-sm text-[var(--gray-11)]">访 `system_param.read`</p>
<Typography.Text type="secondary"> <Link
访 `system_param.read` href="/"
</Typography.Text> className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
<Button> >
<Link href="/"></Link>
</Button> </Link>
</Space> </main>
</Card>
); );
} }
return ( return (
<Space direction="vertical" size={16} className="w-full"> <div className="space-y-6">
{(error || listError) && <Alert type="error" showIcon message="操作失败" description={error || listError} />} {displayError && (
{success && <Alert type="success" showIcon message="操作成功" description={success} />} <Alert
type="error"
showIcon
closable={Boolean(error)}
message="操作失败"
description={<pre className="mb-0 whitespace-pre-wrap break-words">{displayError}</pre>}
onClose={() => setError("")}
/>
)}
{success && (
<Alert
type="success"
showIcon
closable
message="操作成功"
description={success}
onClose={() => setSuccess("")}
/>
)}
<Card <div className="rounded-xl border border-[var(--gray-6)] bg-[var(--gray-1)] p-4 shadow-sm sm:p-5">
title="系统参数列表" <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
extra={canManage ? <Button type="primary" onClick={startCreate}></Button> : undefined} <h2 className="text-base font-semibold text-[var(--gray-12)]"></h2>
> {canManage ? (
<Space direction="vertical" size={12} className="w-full"> <Button type="primary" onClick={startCreate}>
<Typography.Text type="secondary"></Typography.Text>
</Button>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-2"> <Form layout="inline" style={{ rowGap: 12 }}>
<Form.Item label="关键词" className="min-w-[240px]">
<Input <Input
value={keyword} value={keyword}
allowClear allowClear
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.currentTarget.value)}
placeholder="按参数键 / 名称 / 值筛选" placeholder="按参数键/名称/值筛选"
/> />
</Form.Item>
<Form.Item label="状态" className="min-w-[170px]">
<Select<StatusFilter> <Select<StatusFilter>
value={statusFilter} value={statusFilter}
options={[...STATUS_FILTER_OPTIONS]} options={[...STATUS_FILTER_OPTIONS]}
onChange={(value) => setStatusFilter(value)} onChange={(value) => setStatusFilter(value)}
/> />
</div> </Form.Item>
<Form.Item>
<Button
onClick={() => {
setKeyword("");
setStatusFilter("all");
}}
>
</Button>
</Form.Item>
</Form>
<div ref={tableScrollAnchorRef} className="mt-4">
<Table<SystemParamSummary> <Table<SystemParamSummary>
rowKey="id" rowKey="id"
loading={listQuery.isFetching} loading={listQuery.isFetching}
dataSource={items} dataSource={items}
columns={columns} columns={columns}
pagination={false} scroll={{ x: 1120, y: tableScrollY }}
scroll={{ x: 980 }} pagination={{
locale={{ emptyText: "未找到系统参数。" }} pageSize: 20,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{
emptyText: <Empty description="未找到符合筛选条件的系统参数。" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
/> />
</Space> </div>
</Card> </div>
{canManage && ( {canManage && (
<Modal <Modal
@@ -424,14 +545,12 @@ export default function AdminSystemParamsPage() {
</Form.Item> </Form.Item>
<Form.Item<FormState> label="状态" name="status"> <Form.Item<FormState> label="状态" name="status">
<Select <Select options={[...PARAM_STATUS_OPTIONS]} />
options={[...PARAM_STATUS_OPTIONS]}
/>
</Form.Item> </Form.Item>
</div> </div>
</Form> </Form>
</Modal> </Modal>
)} )}
</Space> </div>
); );
} }