@@ -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` 同步计数。
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user