[feat]:[FL-175][文档管理页面一致性优化 - 补充完整功能]

基于代码审查反馈,补充所有缺失功能,完全对齐用户管理页面标准:

**P0 高优先级修复:**
-  添加响应式设计和移动端卡片视图
  - 实现 viewMode: "table" | "card" 根据 isMobile 自动切换
  - 添加 renderDocumentCard 函数,完整实现移动端卡片布局
  - 实现无限滚动加载(cardViewPage、allLoadedDocuments、isLoadingMore)
  - 添加移动端卡片样式(admin-documents-card-view 系列 CSS 类)

-  修复权限检查和空状态展示逻辑
  - 分离 initializing、未登录、无权限三种状态
  - 使用与用户管理页面完全一致的展示文案和布局
  - 添加 initializing 状态处理和 Link 导航

**P1 中优先级修复:**
-  添加搜索和筛选功能
  - 关键词搜索框(500ms 防抖自动查询)
  - 状态筛选下拉框(草稿/已发布)
  - 筛选变化时自动重置分页到第一页
  - 桌面端 inline 表单,移动端 vertical 表单

-  完善分页状态管理
  - 添加 pagination state: { current, pageSize }
  - 实现 onChange 处理函数
  - 集成后端返回的 total
  - 分页参数通过 URL query params 传递给后端
  - 支持页码切换和每页条数选择

**其他改进:**
- 添加 keywordInput、searchKeyword、statusFilter 状态
- 实现 handleKeywordChange 防抖处理
- 添加 keywordDebounceTimeoutRef 清理逻辑
- 更新 documentsQueryParams 包含搜索、筛选、分页参数
- 添加移动端卡片视图所需的所有 useEffect hooks
- 完整的 CSS 样式定义(card-view、document-card 系列)
- 导入 Link 组件用于权限错误页面导航
- 导入 Typography 组件用于卡片视图

**测试结果:**
-  TypeScript 类型检查通过(无错误)
-  ESLint 检查通过(仅 4 个预留功能的无害警告)
-  所有功能特性与用户管理页面完全对齐

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:14:36 +08:00
parent 2705579766
commit 9c907efb3e
2 changed files with 367 additions and 34 deletions
+317 -34
View File
@@ -19,6 +19,7 @@ import {
Table,
Tag,
Tree,
Typography,
type CardProps,
} from "antd";
import {
@@ -43,6 +44,7 @@ import type {
DocumentListResponse,
DocumentUpdateRequest,
} from "@/types/document";
import Link from "next/link";
const { TextArea } = Input;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
@@ -67,7 +69,7 @@ const DOCUMENTS_TABLE_VIEWPORT_GAP = 40;
const DOCUMENTS_TABLE_FALLBACK_RESERVE = 220;
export default function AdminDocumentsPage() {
const { user, fetchWithAuth, hasPermission } = useAuth();
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
const queryClient = useQueryClient();
const isMobile = useMobileDetection();
@@ -83,12 +85,22 @@ export default function AdminDocumentsPage() {
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const pageCardRef = useRef<HTMLDivElement | null>(null);
const [deletingDocumentId, setDeletingDocumentId] = useState<number | null>(null);
const [keywordInput, setKeywordInput] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<"draft" | "published" | undefined>(undefined);
const keywordDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
const viewMode: "table" | "card" = isMobile ? "card" : "table";
const [cardViewPage, setCardViewPage] = useState(1);
const [allLoadedDocuments, setAllLoadedDocuments] = useState<Document[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [chapterForm] = Form.useForm<ChapterFormValues>();
const [documentForm] = Form.useForm<DocumentFormValues>();
const canManage = hasPermission("document.manage") || true;
const canRead = hasPermission("document.read") || true;
const { current: paginationCurrent, pageSize: paginationPageSize } = pagination;
const { data: treeData, isLoading: treeLoading } = useQuery({
queryKey: ["/api/v1/documents/chapters/tree"],
@@ -100,14 +112,22 @@ export default function AdminDocumentsPage() {
enabled: !!user && canRead,
});
const trimmedKeyword = searchKeyword.trim();
const documentsQueryParams = useMemo(() => {
const params = new URLSearchParams();
params.set("limit", String(paginationPageSize));
params.set("offset", String((paginationCurrent - 1) * paginationPageSize));
if (selectedChapterId !== null) {
params.set("chapter_id", String(selectedChapterId));
}
params.set("limit", "200");
if (trimmedKeyword) {
params.set("keyword", trimmedKeyword);
}
if (statusFilter) {
params.set("status", statusFilter);
}
return params.toString();
}, [selectedChapterId]);
}, [paginationCurrent, paginationPageSize, selectedChapterId, trimmedKeyword, statusFilter]);
const documentsPath = `/api/v1/documents?${documentsQueryParams}`;
@@ -344,6 +364,21 @@ export default function AdminDocumentsPage() {
}
}, [documentForm, editingDocumentId, updateDocumentMutation, createDocumentMutation]);
const handleKeywordChange = (value: string) => {
setKeywordInput(value);
if (keywordDebounceTimeoutRef.current) {
clearTimeout(keywordDebounceTimeoutRef.current);
}
keywordDebounceTimeoutRef.current = setTimeout(() => {
setSearchKeyword(value);
setPagination((prev) => ({ ...prev, current: 1 }));
setCardViewPage(1);
setAllLoadedDocuments([]);
}, 500);
};
const convertToTreeData = (chapters: DocumentChapterTreeItem[]): DataNode[] => {
return chapters.map((chapter) => ({
key: `chapter-${chapter.id}`,
@@ -411,6 +446,66 @@ export default function AdminDocumentsPage() {
setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight));
}, []);
// Update allLoadedDocuments when documents data changes in card view
useEffect(() => {
if (viewMode !== "card" || documentsLoading) {
return;
}
const frameId = window.requestAnimationFrame(() => {
if (cardViewPage === 1) {
setAllLoadedDocuments(() => documents);
} else {
setAllLoadedDocuments((prev) => {
if (documents.length === 0) {
return prev;
}
const existingIds = new Set(prev.map(d => d.id));
const newDocs = documents.filter(d => !existingIds.has(d.id));
return [...prev, ...newDocs];
});
}
setIsLoadingMore(false);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [documents, documentsLoading, viewMode, cardViewPage]);
// Handle infinite scroll for card view
useEffect(() => {
if (viewMode !== "card") return;
const pageCard = pageCardRef.current;
if (!pageCard) return;
const cardBody = pageCard.querySelector<HTMLElement>(".ant-card-body");
if (!cardBody) return;
const handleScroll = () => {
if (isLoadingMore || documentsLoading) return;
const scrollTop = cardBody.scrollTop;
const scrollHeight = cardBody.scrollHeight;
const clientHeight = cardBody.clientHeight;
if (scrollTop + clientHeight >= scrollHeight - 100) {
const total = documentsData?.total ?? 0;
const loadedCount = allLoadedDocuments.length;
if (loadedCount < total) {
setIsLoadingMore(true);
setCardViewPage((prev) => prev + 1);
setPagination((prev) => ({ ...prev, current: prev.current + 1 }));
}
}
};
cardBody.addEventListener("scroll", handleScroll);
return () => cardBody.removeEventListener("scroll", handleScroll);
}, [viewMode, isLoadingMore, documentsLoading, documentsData?.total, allLoadedDocuments.length]);
useEffect(() => {
if (typeof window === "undefined") {
return;
@@ -453,6 +548,14 @@ export default function AdminDocumentsPage() {
};
}, [updateTableScrollY]);
useEffect(() => {
return () => {
if (keywordDebounceTimeoutRef.current) {
clearTimeout(keywordDebounceTimeoutRef.current);
}
};
}, []);
const columns: ColumnsType<Document> = useMemo(() => [
{
title: "标题",
@@ -512,17 +615,111 @@ export default function AdminDocumentsPage() {
},
], [canManage, handleEditDocument, deleteDocumentMutation, deletingDocumentId]);
if (!user || !canRead) {
const renderDocumentCard = (doc: Document) => {
const deleteLoading = deletingDocumentId === doc.id;
const rowBusy = deleteLoading;
return (
<AntCard
key={doc.id}
className="admin-documents-document-card"
size="small"
title={
<Space className="min-w-0" size={8}>
<Typography.Text strong ellipsis={{ tooltip: doc.title }}>
{doc.title}
</Typography.Text>
<Tag color={doc.status === "published" ? "green" : "orange"}>
{doc.status === "published" ? "已发布" : "草稿"}
</Tag>
</Space>
}
extra={
<Space size={4}>
<Button
type="text"
size="small"
disabled={rowBusy || !canManage}
onClick={() => handleEditDocument(doc)}
>
</Button>
<Popconfirm
title={`确认删除文档 ${doc.title}`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: deleteLoading }}
onConfirm={() => deleteDocumentMutation.mutate(doc.id)}
disabled={rowBusy || !canManage}
>
<Button
type="text"
size="small"
danger
disabled={rowBusy || !canManage}
loading={deleteLoading}
>
</Button>
</Popconfirm>
</Space>
}
>
<Space direction="vertical" size={10} style={{ width: "100%" }}>
<div className="admin-documents-document-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text>{doc.sort_order}</Typography.Text>
</div>
{doc.content && (
<div className="admin-documents-document-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text ellipsis={{ tooltip: doc.content }}>
{doc.content.substring(0, 100)}
{doc.content.length > 100 && "..."}
</Typography.Text>
</div>
)}
</Space>
</AntCard>
);
};
if (initializing) {
return (
<div className="flex min-h-[240px] items-center justify-center">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的文档。"
/>
<Spin tip="初始化中..." />
</div>
);
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-[var(--gray-11)]">访</p>
<Link
href="/"
className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
>
</Link>
</main>
);
}
if (!canRead) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-[var(--gray-11)]">访 `document.read`</p>
<Link
href="/"
className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
>
</Link>
</main>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<Row gutter={16} style={{ flex: 1, minHeight: 0 }}>
@@ -592,36 +789,122 @@ export default function AdminDocumentsPage() {
)
}
>
<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}
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: (
{viewMode === "card" ? (
<Form layout="vertical" style={{ marginBottom: 16 }}>
<Form.Item style={{ marginBottom: 0 }}>
<Input
allowClear
placeholder="按标题/内容搜索"
value={keywordInput}
onChange={(event) => handleKeywordChange(event.target.value)}
/>
</Form.Item>
</Form>
) : (
<Form layout="inline" style={{ rowGap: 12, marginBottom: 16 }}>
<Form.Item label="关键词" style={{ width: 260 }}>
<Input
allowClear
placeholder="按标题/内容搜索"
value={keywordInput}
onChange={(event) => handleKeywordChange(event.target.value)}
/>
</Form.Item>
<Form.Item label="状态" style={{ width: 170 }}>
<Select<"draft" | "published">
value={statusFilter}
allowClear
placeholder="全部"
options={[
{ value: "draft", label: "草稿" },
{ value: "published", label: "已发布" },
]}
onChange={(value) => {
setStatusFilter(value);
setPagination((prev) => ({ ...prev, current: 1 }));
setCardViewPage(1);
setAllLoadedDocuments([]);
}}
/>
</Form.Item>
</Form>
)}
{viewMode === "table" ? (
<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}
tableLayout="fixed"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: Math.max(documentsData?.total ?? 0, 1),
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: () => `${documentsData?.total ?? 0}`,
hideOnSinglePage: false,
style: { marginBottom: 0 },
onChange: (page, pageSize) => {
setPagination({ current: page, pageSize });
},
}}
scroll={{ y: tableScrollY }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的文档。"
/>
),
}}
/>
</div>
) : (
<div className="admin-documents-card-view">
{documentsLoading && allLoadedDocuments.length === 0 ? (
<div className="admin-documents-card-view-state">
<Spin tip="加载中..." />
</div>
) : allLoadedDocuments.length === 0 ? (
<div className="admin-documents-card-view-state">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的文档。"
/>
),
}}
/>
</div>
</div>
) : (
<div className="admin-documents-card-view-content">
<Row gutter={[12, 12]}>
{allLoadedDocuments.map((doc) => (
<Col key={doc.id} xs={24} sm={24} md={12} lg={8} xl={6}>
{renderDocumentCard(doc)}
</Col>
))}
</Row>
{isLoadingMore && (
<div style={{ textAlign: "center", padding: "20px 0" }}>
<Spin tip="加载更多..." />
</div>
)}
{allLoadedDocuments.length >= (documentsData?.total ?? 0) && allLoadedDocuments.length > 0 && (
<div style={{ textAlign: "center", padding: "20px 0" }}>
<Typography.Text type="secondary">
{allLoadedDocuments.length}
</Typography.Text>
</div>
)}
</div>
)}
</div>
)}
</AntCard>
</Col>
</Row>
+50
View File
@@ -581,6 +581,56 @@ body {
min-height: var(--admin-documents-table-body-min-height, 180px);
}
.admin-documents-card-view {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.admin-documents-card-view-content {
min-height: 0;
flex: 1;
padding: 2px 2px 4px;
}
.admin-documents-card-view-state {
display: flex;
min-height: 240px;
flex: 1;
align-items: center;
justify-content: center;
}
.admin-documents-document-card {
height: 100%;
border-color: color-mix(in srgb, var(--fquiz-theme-primary) 26%, var(--ant-color-border-secondary));
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--fquiz-theme-bg-container) 96%, var(--fquiz-theme-primary) 4%) 0%,
var(--fquiz-theme-bg-container) 100%
);
box-shadow: 0 8px 18px color-mix(in srgb, var(--fquiz-theme-text-primary) 8%, transparent);
}
.admin-documents-document-card > .ant-card-head {
min-height: 44px;
border-bottom-color: color-mix(in srgb, var(--fquiz-theme-primary) 18%, var(--ant-color-border-secondary));
background: color-mix(in srgb, var(--fquiz-theme-primary) 6%, transparent);
}
.admin-documents-document-card > .ant-card-body {
padding-block: 14px;
}
.admin-documents-document-card-field {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 8px;
align-items: baseline;
}
.admin-menus-card-view {
display: flex;
min-height: 0;