From 75993c1b03ca7a82e1b468fb96a631c0ef9c482d Mon Sep 17 00:00:00 2001 From: chengkml Date: Sat, 2 May 2026 23:21:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8F=82=E6=95=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2=E6=A0=B7=E5=BC=8F=E4=B8=8E=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=AF=B9=E9=BD=90=E8=8F=9C=E5=8D=95=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- memory/2026-05-02.md | 20 ++ web/src/app/admin/system-params/page.tsx | 267 ++++++++++++++++------- 2 files changed, 213 insertions(+), 74 deletions(-) diff --git a/memory/2026-05-02.md b/memory/2026-05-02.md index f92ec44..b6ed5ee 100644 --- a/memory/2026-05-02.md +++ b/memory/2026-05-02.md @@ -240,3 +240,23 @@ - 风险与影响: - 影响面仅 `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 展示,不涉及后端接口或数据结构变更。 diff --git a/web/src/app/admin/system-params/page.tsx b/web/src/app/admin/system-params/page.tsx index 24166dd..c6c4db2 100644 --- a/web/src/app/admin/system-params/page.tsx +++ b/web/src/app/admin/system-params/page.tsx @@ -2,24 +2,24 @@ import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Button, + Empty, Form, Input, Modal, + Popconfirm, Select, - Skeleton, + Spin, Space, Table, Tag, - Typography, - type TableProps, + type TableColumnsType, } from "antd"; import { useAuth } from "@/components/auth-provider"; -import { Card } from "@/components/ui-antd"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; import { readApiError } from "@/lib/api"; import type { SystemParamListResponse, SystemParamSummary } from "@/types/auth"; @@ -53,6 +53,10 @@ const PARAM_STATUS_OPTIONS = [ { label: "已禁用", value: "disabled" }, ] 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() { const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); const queryClient = useQueryClient(); @@ -64,6 +68,9 @@ export default function AdminSystemParamsPage() { const [editorOpen, setEditorOpen] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const [deletingId, setDeletingId] = useState(null); + const [tableScrollY, setTableScrollY] = useState(PARAM_TABLE_MIN_SCROLL_Y); + const tableScrollAnchorRef = useRef(null); const canRead = hasPermission("system_param.read") || 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 listError = listQuery.error instanceof Error ? listQuery.error.message : ""; + const displayError = error || listError; - const columns = useMemo["columns"]>(() => { - const baseColumns: NonNullable["columns"]> = [ + const columns = useMemo>(() => { + const baseColumns: TableColumnsType = [ { title: "ID", dataIndex: "id", key: "id", - width: 90, + width: 110, }, { title: "参数键", dataIndex: "param_key", key: "param_key", - width: 220, - render: (value: string) => {value}, + width: 240, + render: (value: string) => {value}, }, { title: "参数名称", dataIndex: "param_name", key: "param_name", - width: 220, + width: 200, }, { title: "参数值", dataIndex: "param_value", key: "param_value", ellipsis: true, + width: 240, render: (value: string) => value || "-", }, { @@ -276,110 +294,213 @@ export default function AdminSystemParamsPage() { fixed: "right", width: 150, render: (_, record) => ( - + - + + ), }); } 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(".ant-table-wrapper"); + const tableBody = anchor.querySelector(".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) { return ( - - - +
+ +
); } if (!user) { return ( - - - 请先登录后再访问系统参数页面。 - - - +
+

请先登录后再访问参数管理页面。

+ + 返回首页 + +
); } if (!canRead) { return ( - - - - 你没有访问该页面的权限(需要 `system_param.read`)。 - - - - +
+

你没有访问该页面的权限(需要 `system_param.read`)。

+ + 返回首页 + +
); } return ( - - {(error || listError) && } - {success && } +
+ {displayError && ( + {displayError}} + onClose={() => setError("")} + /> + )} + {success && ( + setSuccess("")} + /> + )} - 新建参数 : undefined} - > - - 维护系统级参数键值、状态与说明。 +
+
+

参数列表

+ {canManage ? ( + + ) : null} +
-
+
+ setKeyword(event.target.value)} - placeholder="按参数键 / 名称 / 值筛选" + onChange={(event) => setKeyword(event.currentTarget.value)} + placeholder="按参数键/名称/值筛选" /> + + + value={statusFilter} options={[...STATUS_FILTER_OPTIONS]} onChange={(value) => setStatusFilter(value)} /> -
+ + + + + + +
rowKey="id" loading={listQuery.isFetching} dataSource={items} columns={columns} - pagination={false} - scroll={{ x: 980 }} - locale={{ emptyText: "未找到系统参数。" }} + scroll={{ x: 1120, y: tableScrollY }} + pagination={{ + pageSize: 20, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} + locale={{ + emptyText: , + }} /> - - +
+
{canManage && ( label="状态" name="status"> -
)} -
+ ); }