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;
}