From 0728cf7edf615f56e955f19e69356ed9cabd4624 Mon Sep 17 00:00:00 2001 From: chengkml Date: Fri, 1 May 2026 19:36:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=8F=9C=E5=8D=95=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E8=A1=A8=E6=A0=BC=E8=87=AA=E9=80=82=E5=BA=94?= =?UTF-8?q?=E9=AB=98=E5=BA=A6=E4=B8=8E=E5=9B=BA=E5=AE=9A=E8=A1=A8=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- memory/2026-05-01.md | 28 +++++++++ web/src/app/admin/menus/page.tsx | 100 +++++++++++++++++++++++++------ 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/memory/2026-05-01.md b/memory/2026-05-01.md index e0241e1..c5f6d18 100644 --- a/memory/2026-05-01.md +++ b/memory/2026-05-01.md @@ -218,6 +218,34 @@ - `GET /api/v1/users` 新增查询参数: - `keyword`:按 `user_id/email/username` 模糊检索 - `status`:按启用/禁用状态过滤 + +## Work Log - 菜单管理页面表格高度自适应与固定表头(2026-05-01) + +- 背景: + - Issue `FL-142` 需要菜单管理页面表格高度随页面自适应,纵向滚动条仅出现在表格内部,并保持表头固定。 + +- 本次改动(最小闭环): + - 文件:`web/src/app/admin/menus/page.tsx` + - 新增动态高度状态与锚点: + - `tableScrollY`、`tableScrollAnchorRef` + - 常量 `MENU_TABLE_MIN_SCROLL_Y`、`MENU_TABLE_BOTTOM_RESERVE` + - 新增 `updateTableScrollY()`: + - 基于表格锚点 `getBoundingClientRect().top` 和 `window.innerHeight` 计算可用高度 + - 对高度变化做 2px 阈值抑制,避免频繁抖动重渲染 + - 新增监听: + - `window.resize` 触发重算 + - `ResizeObserver` 监听锚点尺寸变化(筛选区换行、布局变化)触发重算 + - 表格滚动改造: + - `scroll` 从 `{ x: 1200 }` 调整为 `{ x: 1200, y: tableScrollY }` + - 让纵向滚动收敛到表格体内,并由 AntD 自动固定表头 + +- 验证: + - 未执行编译/测试(按任务约束:不做编译检查、不安装依赖)。 + - 代码检查确认改动仅涉及菜单管理页单文件逻辑。 + +- 风险与影响: + - 影响范围仅 `web/src/app/admin/menus/page.tsx` 的表格渲染高度计算逻辑。 + - 极端小视口下表格最小高度受 `MENU_TABLE_MIN_SCROLL_Y=220` 限制,避免表格区域塌陷。 - 后端用户服务增强: - 文件:`api/app/services/user_service.py` - `list_users(...)` 支持 `keyword/status` 条件查询并对 `total` 同步计数。 diff --git a/web/src/app/admin/menus/page.tsx b/web/src/app/admin/menus/page.tsx index 98b8ba3..0e8a6ad 100644 --- a/web/src/app/admin/menus/page.tsx +++ b/web/src/app/admin/menus/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, App, @@ -109,6 +109,9 @@ const DEFAULT_FORM_VALUES: MenuFormValues = { component: "", }; +const MENU_TABLE_MIN_SCROLL_Y = 220; +const MENU_TABLE_BOTTOM_RESERVE = 132; + function compareMenuIds(a: string, b: string): number { const aNum = Number(a); const bNum = Number(b); @@ -131,7 +134,9 @@ export default function AdminMenusPage() { const [keyword, setKeyword] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [sortKey, setSortKey] = useState("sort_order"); + const [tableScrollY, setTableScrollY] = useState(420); const [form] = Form.useForm(); + const tableScrollAnchorRef = useRef(null); const canRead = hasPermission("menu.read") || hasPermission("menu.manage"); const canManage = hasPermission("menu.manage"); @@ -413,6 +418,62 @@ export default function AdminMenusPage() { return base; }, [canManage, deletingMenuId, menuNameById, removeMenu, startEdit]); + const updateTableScrollY = useCallback(() => { + if (typeof window === "undefined") { + return; + } + const anchor = tableScrollAnchorRef.current; + if (!anchor) { + return; + } + + const top = anchor.getBoundingClientRect().top; + const nextHeight = Math.max( + MENU_TABLE_MIN_SCROLL_Y, + Math.floor(window.innerHeight - top - MENU_TABLE_BOTTOM_RESERVE), + ); + setTableScrollY((previous) => (Math.abs(previous - nextHeight) <= 2 ? previous : nextHeight)); + }, []); + + useEffect(() => { + updateTableScrollY(); + }, [error, 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) { return (
@@ -514,24 +575,25 @@ export default function AdminMenusPage() { - - - className="mt-4" - rowKey="id" - dataSource={filteredMenus} - columns={columns} - loading={loading} - scroll={{ x: 1200 }} - pagination={{ - pageSize: 20, - showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], - showTotal: (total) => `共 ${total} 条`, - }} - locale={{ - emptyText: , - }} - /> +
+ + className="mt-4" + rowKey="id" + dataSource={filteredMenus} + columns={columns} + loading={loading} + scroll={{ x: 1200, y: tableScrollY }} + pagination={{ + pageSize: 20, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} + locale={{ + emptyText: , + }} + /> +