[feat]:[FL-175][文档管理页面一致性优化]

- 统一页面容器结构:使用 flex min-h-0 flex-1 flex-col 布局
- 添加 admin-documents-page-card CSS 类名和样式定义
- 统一表格配置:使用 tableLayout="fixed" 并明确设置列宽
- 改进操作按钮样式:使用 size="small" 普通按钮 + Popconfirm
- 统一 Modal 配置:明确设置 okText/cancelText/confirmLoading
- 统一反馈机制:使用 useToastFeedback hook 替代 App.useApp()
- 统一空状态文案:使用标准的"未找到符合筛选条件的XXX"格式
- 优化表格滚动高度动态计算逻辑
- 添加 deletingDocumentId 状态管理和 rowBusy 逻辑
- 移除未使用的导入和变量
- 所有 TypeScript 类型检查通过

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-21 23:05:56 +08:00
parent e9da7b8599
commit 107c8a58dd
2 changed files with 243 additions and 109 deletions
+223 -109
View File
@@ -2,7 +2,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
App,
Button,
Card,
Col,
@@ -20,22 +19,19 @@ import {
Table,
Tag,
Tree,
Typography,
type CardProps,
} from "antd";
import {
EditOutlined,
DeleteOutlined,
PlusOutlined,
FolderOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type RefAttributes } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
import type { DataNode } from "antd/es/tree";
import { useAuth } from "@/components/auth-provider";
import { useMobileDetection } from "@/hooks/use-mobile-detection";
import { useToastFeedback } from "@/hooks/use-toast-feedback";
import { readApiError } from "@/lib/api";
import type {
Document,
@@ -49,7 +45,6 @@ import type {
} from "@/types/document";
const { TextArea } = Input;
const { Title } = Typography;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
type ChapterFormValues = {
@@ -75,11 +70,9 @@ export default function AdminDocumentsPage() {
const { user, fetchWithAuth, hasPermission } = useAuth();
const queryClient = useQueryClient();
const isMobile = useMobileDetection();
const { message } = App.useApp();
const showSuccess = (msg: string) => message.success(msg);
const showError = (msg: string) => message.error(msg);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [chapterDialogOpen, setChapterDialogOpen] = useState(false);
const [documentDrawerOpen, setDocumentDrawerOpen] = useState(false);
const [editingChapterId, setEditingChapterId] = useState<number | null>(null);
@@ -88,6 +81,8 @@ export default function AdminDocumentsPage() {
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [tableScrollY, setTableScrollY] = useState(DOCUMENTS_TABLE_MIN_SCROLL_Y);
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const pageCardRef = useRef<HTMLDivElement | null>(null);
const [deletingDocumentId, setDeletingDocumentId] = useState<number | null>(null);
const [chapterForm] = Form.useForm<ChapterFormValues>();
const [documentForm] = Form.useForm<DocumentFormValues>();
@@ -126,6 +121,8 @@ export default function AdminDocumentsPage() {
enabled: !!user && canRead,
});
const documents = useMemo(() => documentsData?.items ?? [], [documentsData?.items]);
const refreshData = useCallback(async () => {
await queryClient.invalidateQueries({
predicate: (query) =>
@@ -147,13 +144,15 @@ export default function AdminDocumentsPage() {
return response.json();
},
onSuccess: () => {
showSuccess("章节创建成功");
setSuccess("章节创建成功");
setError("");
refreshData();
setChapterDialogOpen(false);
chapterForm.resetFields();
},
onError: (error: Error) => {
showError(`创建失败: ${error.message}`);
onError: (candidate: Error) => {
setSuccess("");
setError(candidate.message || "创建章节失败");
},
});
@@ -174,14 +173,16 @@ export default function AdminDocumentsPage() {
return response.json();
},
onSuccess: () => {
showSuccess("章节更新成功");
setSuccess("章节更新成功");
setError("");
refreshData();
setChapterDialogOpen(false);
setEditingChapterId(null);
chapterForm.resetFields();
},
onError: (error: Error) => {
showError(`更新失败: ${error.message}`);
onError: (candidate: Error) => {
setSuccess("");
setError(candidate.message || "更新章节失败");
},
});
@@ -193,11 +194,13 @@ export default function AdminDocumentsPage() {
if (!response.ok) throw new Error(await readApiError(response));
},
onSuccess: () => {
showSuccess("章节删除成功");
setSuccess("章节删除成功");
setError("");
refreshData();
},
onError: (error: Error) => {
showError(`删除失败: ${error.message}`);
onError: (candidate: Error) => {
setSuccess("");
setError(candidate.message || "删除章节失败");
},
});
@@ -212,13 +215,15 @@ export default function AdminDocumentsPage() {
return response.json();
},
onSuccess: () => {
showSuccess("文档创建成功");
setSuccess("文档创建成功");
setError("");
refreshData();
setDocumentDrawerOpen(false);
documentForm.resetFields();
},
onError: (error: Error) => {
showError(`创建失败: ${error.message}`);
onError: (candidate: Error) => {
setSuccess("");
setError(candidate.message || "创建文档失败");
},
});
@@ -239,14 +244,16 @@ export default function AdminDocumentsPage() {
return response.json();
},
onSuccess: () => {
showSuccess("文档更新成功");
setSuccess("文档更新成功");
setError("");
refreshData();
setDocumentDrawerOpen(false);
setEditingDocumentId(null);
documentForm.resetFields();
},
onError: (error: Error) => {
showError(`更新失败: ${error.message}`);
onError: (candidate: Error) => {
setSuccess("");
setError(candidate.message || "更新文档失败");
},
});
@@ -257,13 +264,20 @@ export default function AdminDocumentsPage() {
});
if (!response.ok) throw new Error(await readApiError(response));
},
onMutate: (id) => {
setDeletingDocumentId(id);
setError("");
setSuccess("");
},
onSuccess: () => {
showSuccess("文档删除成功");
setSuccess("文档删除成功");
refreshData();
},
onError: (error: Error) => {
showError(`删除失败: ${error.message}`);
onError: (candidate: Error) => {
setSuccess("");
setError(candidate.message || "删除文档失败");
},
onSettled: () => setDeletingDocumentId(null),
});
const handleCreateChapter = useCallback(() => {
@@ -292,7 +306,7 @@ export default function AdminDocumentsPage() {
createChapterMutation.mutate(values);
}
} catch (error) {
console.error("Form validation failed:", error);
// Form validation errors are already shown
}
}, [chapterForm, editingChapterId, updateChapterMutation, createChapterMutation]);
@@ -326,11 +340,11 @@ export default function AdminDocumentsPage() {
createDocumentMutation.mutate(values);
}
} catch (error) {
console.error("Form validation failed:", error);
// Form validation errors are already shown
}
}, [documentForm, editingDocumentId, updateDocumentMutation, createDocumentMutation]);
const convertToTreeData = useCallback((chapters: DocumentChapterTreeItem[]): DataNode[] => {
const convertToTreeData = (chapters: DocumentChapterTreeItem[]): DataNode[] => {
return chapters.map((chapter) => ({
key: `chapter-${chapter.id}`,
title: (
@@ -344,59 +358,7 @@ export default function AdminDocumentsPage() {
),
children: chapter.children ? convertToTreeData(chapter.children) : [],
}));
}, []);
const columns: ColumnsType<Document> = useMemo(() => [
{
title: "标题",
dataIndex: "title",
key: "title",
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (status: string) => (
<Tag color={status === "published" ? "green" : "orange"}>
{status === "published" ? "已发布" : "草稿"}
</Tag>
),
},
{
title: "排序",
dataIndex: "sort_order",
key: "sort_order",
width: 80,
},
{
title: "操作",
key: "action",
width: 150,
render: (_, record) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEditDocument(record)}
disabled={!canManage}
>
</Button>
<Popconfirm
title="确认删除?"
onConfirm={() => deleteDocumentMutation.mutate(record.id)}
disabled={!canManage}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />} disabled={!canManage}>
</Button>
</Popconfirm>
</Space>
),
},
], [canManage, handleEditDocument, deleteDocumentMutation]);
};
const flattenChapters = useCallback((chapters: DocumentChapterTreeItem[]): DocumentChapter[] => {
const result: DocumentChapter[] = [];
@@ -412,34 +374,158 @@ export default function AdminDocumentsPage() {
return result;
}, []);
const queryError =
(documentsData && "error" in documentsData ? String(documentsData) : "");
const anyError = error || queryError;
useToastFeedback({
errorMessage: anyError,
successMessage: success,
clearError: () => setError(""),
clearSuccess: () => setSuccess(""),
});
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 - DOCUMENTS_TABLE_FALLBACK_RESERVE);
if (tableWrapper) {
const wrapperRect = tableWrapper.getBoundingClientRect();
const bodyHeight = tableBody?.getBoundingClientRect().height ?? DOCUMENTS_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 - DOCUMENTS_TABLE_VIEWPORT_GAP);
}
const clampedHeight = Math.max(DOCUMENTS_TABLE_MIN_SCROLL_Y, nextHeight);
setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight));
}, []);
useEffect(() => {
const updateScrollY = () => {
if (!tableScrollAnchorRef.current) {
setTableScrollY(DOCUMENTS_TABLE_MIN_SCROLL_Y);
return;
}
const rect = tableScrollAnchorRef.current.getBoundingClientRect();
const availableHeight = window.innerHeight - rect.top - DOCUMENTS_TABLE_VIEWPORT_GAP;
const computedY = Math.max(availableHeight, DOCUMENTS_TABLE_MIN_SCROLL_Y);
setTableScrollY(computedY);
if (typeof window === "undefined") {
return;
}
window.requestAnimationFrame(updateTableScrollY);
}, [anyError, documents.length, documentsLoading, updateTableScrollY]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const onViewportChange = () => {
window.requestAnimationFrame(updateTableScrollY);
};
updateScrollY();
window.addEventListener("resize", updateScrollY);
return () => window.removeEventListener("resize", updateScrollY);
}, []);
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<Document> = useMemo(() => [
{
title: "标题",
dataIndex: "title",
width: 200,
},
{
title: "状态",
dataIndex: "status",
width: 100,
align: "center",
render: (status: string) => (
<Tag color={status === "published" ? "green" : "orange"}>
{status === "published" ? "已发布" : "草稿"}
</Tag>
),
},
{
title: "排序",
dataIndex: "sort_order",
width: 80,
align: "center",
},
{
title: "操作",
key: "actions",
width: 180,
render: (_value, record) => {
const deleteLoading = deletingDocumentId === record.id;
const rowBusy = deleteLoading;
return (
<Space wrap>
<Button
size="small"
disabled={rowBusy || !canManage}
onClick={() => handleEditDocument(record)}
>
</Button>
<Popconfirm
title={`确认删除文档 ${record.title}`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: deleteLoading }}
onConfirm={() => deleteDocumentMutation.mutate(record.id)}
disabled={rowBusy || !canManage}
>
<Button danger size="small" loading={deleteLoading} disabled={rowBusy || !canManage}>
</Button>
</Popconfirm>
</Space>
);
},
},
], [canManage, handleEditDocument, deleteDocumentMutation, deletingDocumentId]);
if (!user || !canRead) {
return (
<div style={{ padding: "24px" }}>
<Empty description="暂无权限访问" />
<div className="flex min-h-[240px] items-center justify-center">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的文档。"
/>
</div>
);
}
return (
<div style={{ padding: "24px" }}>
<Row gutter={16}>
<div className="flex min-h-0 flex-1 flex-col">
<Row gutter={16} style={{ flex: 1, minHeight: 0 }}>
<Col span={6}>
<AntCard
title="章节目录"
@@ -455,7 +541,8 @@ export default function AdminDocumentsPage() {
</Button>
)
}
style={{ height: "calc(100vh - 140px)", overflow: "auto" }}
style={{ height: "100%", display: "flex", flexDirection: "column" }}
bodyStyle={{ flex: 1, overflow: "auto" }}
>
{treeLoading ? (
<div style={{ textAlign: "center", padding: "24px" }}>
@@ -481,12 +568,17 @@ export default function AdminDocumentsPage() {
}}
/>
) : (
<Empty description="暂无章节" />
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的章节。"
/>
)}
</AntCard>
</Col>
<Col span={18}>
<AntCard
ref={pageCardRef}
className="admin-documents-page-card"
title={selectedChapterId ? "章节文档" : "全部文档"}
extra={
canManage && (
@@ -500,14 +592,34 @@ export default function AdminDocumentsPage() {
)
}
>
<div ref={tableScrollAnchorRef}>
<Table
dataSource={documentsData?.items || []}
columns={columns}
<div
ref={tableScrollAnchorRef}
className="admin-documents-table-anchor"
style={{ "--admin-documents-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
>
<Table<Document>
rowKey="id"
dataSource={documents}
columns={columns}
loading={documentsLoading}
pagination={{ pageSize: 20 }}
tableLayout="fixed"
pagination={{
pageSize: 20,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (total) => `${total}`,
hideOnSinglePage: false,
style: { marginBottom: 0 },
}}
scroll={{ y: tableScrollY }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的文档。"
/>
),
}}
/>
</div>
</AntCard>
@@ -523,6 +635,8 @@ export default function AdminDocumentsPage() {
setEditingChapterId(null);
chapterForm.resetFields();
}}
okText="保存"
cancelText="取消"
confirmLoading={
createChapterMutation.isPending || updateChapterMutation.isPending
}
+20
View File
@@ -561,6 +561,26 @@ body {
padding-top: 12px;
}
.admin-documents-page-card {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.admin-documents-page-card > .ant-card-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
}
.admin-documents-table-anchor .ant-table-body {
min-height: var(--admin-documents-table-body-min-height, 180px);
}
.admin-menus-card-view {
display: flex;
min-height: 0;