@@ -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 表格。
|
||||
|
||||
@@ -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() {
|
||||
<Button onClick={() => setSearchKeyword("")}>重置筛选</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div ref={tableScrollAnchorRef} className="mt-4">
|
||||
<div
|
||||
ref={tableScrollAnchorRef}
|
||||
className="admin-roles-table-anchor mt-4"
|
||||
style={{ "--admin-roles-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
|
||||
>
|
||||
<Table<RoleItem>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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<HTMLElement>(".ant-table-wrapper");
|
||||
const tableBody = anchor.querySelector<HTMLElement>(".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<UserPublic> = [
|
||||
{
|
||||
title: "用户 ID",
|
||||
@@ -650,8 +721,12 @@ export default function AdminUsersPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div
|
||||
ref={tableScrollAnchorRef}
|
||||
className="admin-users-table-anchor mt-4"
|
||||
style={{ "--admin-users-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
|
||||
>
|
||||
<Table<UserPublic>
|
||||
className="mt-4"
|
||||
rowKey="id"
|
||||
dataSource={users}
|
||||
columns={columns}
|
||||
@@ -662,11 +737,12 @@ export default function AdminUsersPage() {
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
style: { marginBottom: 0 },
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination({ current: page, pageSize });
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1500 }}
|
||||
scroll={{ x: 1500, y: tableScrollY }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
@@ -676,6 +752,7 @@ export default function AdminUsersPage() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AntCard>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -212,6 +212,14 @@ body {
|
||||
min-height: var(--admin-menus-table-body-min-height, 180px);
|
||||
}
|
||||
|
||||
.admin-users-table-anchor .ant-table-body {
|
||||
min-height: var(--admin-users-table-body-min-height, 180px);
|
||||
}
|
||||
|
||||
.admin-roles-table-anchor .ant-table-body {
|
||||
min-height: var(--admin-roles-table-body-min-height, 180px);
|
||||
}
|
||||
|
||||
.fquiz-row-selected > td {
|
||||
background: var(--fquiz-theme-bg-active) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user