diff --git a/memory/2026-05-03.md b/memory/2026-05-03.md index 71889ed..6453e96 100644 --- a/memory/2026-05-03.md +++ b/memory/2026-05-03.md @@ -516,3 +516,26 @@ - 风险与影响: - 影响面仅 `/admin/syslog` 前端展示层。 - 由于 `/admin/diary` 目前目录不存在,本次变更仅作用于系统日志页面,不影响其它管理页面。 + +## Work Log - 用户/角色管理页表格防坍塌(2026-05-03) + +- 背景: + - 在菜单管理页完成“筛选少量数据时表格高度不坍塌”后,新增需求要求将同类体验同步到用户管理与角色管理页面。 + +- 本次改动(最小闭环): + - 文件:`web/src/app/admin/users/page.tsx` + - 新增与菜单/角色页一致的表格动态高度计算(`tableScrollY` + `ResizeObserver` + `resize` 监听)。 + - 表格滚动由 `scroll={{ x: 1500 }}` 调整为 `scroll={{ x: 1500, y: tableScrollY }}`。 + - 表格外层新增 `admin-users-table-anchor`,并通过 CSS 变量注入动态 `min-height`。 + - 分页补齐 `style: { marginBottom: 0 }`,统一底部留白策略。 + - 文件:`web/src/app/admin/roles/page.tsx` + - 在已有动态 `scroll.y` 基础上,表格外层新增 `admin-roles-table-anchor`,并注入动态 `min-height` CSS 变量。 + - 文件:`web/src/app/globals.css` + - 新增两条局部样式: + - `.admin-users-table-anchor .ant-table-body` + - `.admin-roles-table-anchor .ant-table-body` + - 两者均使用页面注入变量作为 `min-height`,确保筛选后数据较少时表格区域不坍塌。 + +- 风险与影响: + - 影响范围限定在 `/admin/users` 与 `/admin/roles` 页面样式行为,不涉及接口、权限与数据写入链路。 + - 样式作用域使用页面专用 class,避免影响其他 AntD 表格。 diff --git a/web/src/app/admin/roles/page.tsx b/web/src/app/admin/roles/page.tsx index 7cce8d7..384764d 100644 --- a/web/src/app/admin/roles/page.tsx +++ b/web/src/app/admin/roles/page.tsx @@ -21,7 +21,7 @@ import { type CardProps, } from "antd"; import type { ColumnsType } from "antd/es/table"; -import type { ComponentType } from "react"; +import type { CSSProperties, ComponentType } from "react"; import { useAuth } from "@/components/auth-provider"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; @@ -451,7 +451,11 @@ export default function AdminRolesPage() { -
+
rowKey="id" columns={columns} diff --git a/web/src/app/admin/users/page.tsx b/web/src/app/admin/users/page.tsx index fe35d29..bcd86c4 100644 --- a/web/src/app/admin/users/page.tsx +++ b/web/src/app/admin/users/page.tsx @@ -21,7 +21,7 @@ import { } from "antd"; import type { ColumnsType } from "antd/es/table"; import Link from "next/link"; -import { useCallback, useMemo, useState, type ComponentType } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react"; import { useAuth } from "@/components/auth-provider"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; @@ -57,6 +57,10 @@ function statusLabel(status: string): string { return status || "-"; } +const USERS_TABLE_MIN_SCROLL_Y = 180; +const USERS_TABLE_VIEWPORT_GAP = 40; +const USERS_TABLE_FALLBACK_RESERVE = 220; + export default function AdminUsersPage() { const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); const queryClient = useQueryClient(); @@ -76,6 +80,8 @@ export default function AdminUsersPage() { const [searchKeyword, setSearchKeyword] = useState(""); const [statusFilter, setStatusFilter] = useState<"all" | "active" | "disabled">("all"); const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); + const [tableScrollY, setTableScrollY] = useState(USERS_TABLE_MIN_SCROLL_Y); + const tableScrollAnchorRef = useRef(null); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); @@ -429,6 +435,71 @@ export default function AdminUsersPage() { || (rolesQuery.error instanceof Error ? rolesQuery.error.message : ""); const anyError = error || queryError; + 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 - USERS_TABLE_FALLBACK_RESERVE); + if (tableWrapper) { + const wrapperRect = tableWrapper.getBoundingClientRect(); + const bodyHeight = tableBody?.getBoundingClientRect().height ?? USERS_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 - USERS_TABLE_VIEWPORT_GAP); + } + + const clampedHeight = Math.max(USERS_TABLE_MIN_SCROLL_Y, nextHeight); + setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight)); + }, []); + + useEffect(() => { + updateTableScrollY(); + }, [anyError, pagination.current, pagination.pageSize, users.length, usersQuery.isFetching, 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]); + const columns: ColumnsType = [ { title: "用户 ID", @@ -650,32 +721,38 @@ export default function AdminUsersPage() { - - className="mt-4" - rowKey="id" - dataSource={users} - columns={columns} - pagination={{ - current: pagination.current, - pageSize: pagination.pageSize, - total: usersQuery.data?.total ?? 0, - showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], - showTotal: (total) => `共 ${total} 条`, - onChange: (page, pageSize) => { - setPagination({ current: page, pageSize }); - }, - }} - scroll={{ x: 1500 }} - locale={{ - emptyText: ( - - ), - }} - /> +
+ + rowKey="id" + dataSource={users} + columns={columns} + pagination={{ + current: pagination.current, + pageSize: pagination.pageSize, + total: usersQuery.data?.total ?? 0, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (total) => `共 ${total} 条`, + style: { marginBottom: 0 }, + onChange: (page, pageSize) => { + setPagination({ current: page, pageSize }); + }, + }} + scroll={{ x: 1500, y: tableScrollY }} + locale={{ + emptyText: ( + + ), + }} + /> +
td { background: var(--fquiz-theme-bg-active) !important; }