@@ -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 展示,不涉及后端接口或数据结构变更。
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user