优化菜单管理页表格自适应高度与固定表头

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-01 19:36:50 +08:00
parent 730ef3b0e3
commit 0728cf7edf
2 changed files with 109 additions and 19 deletions
+28
View File
@@ -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` 同步计数。
+81 -19
View File
@@ -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<FilterStatus>("all");
const [sortKey, setSortKey] = useState<SortKey>("sort_order");
const [tableScrollY, setTableScrollY] = useState(420);
const [form] = Form.useForm<MenuFormValues>();
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(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 (
<div className="flex min-h-[240px] items-center justify-center">
@@ -514,24 +575,25 @@ export default function AdminMenusPage() {
</Button>
</Form.Item>
</Form>
<Table<MenuItem>
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: <Empty description="未找到符合筛选条件的菜单项。" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
/>
<div ref={tableScrollAnchorRef}>
<Table<MenuItem>
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: <Empty description="未找到符合筛选条件的菜单项。" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
/>
</div>
</AntCard>
<Modal