From 9c907efb3e463d6559118beed340fbef4e5c2af4 Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sun, 21 Jun 2026 23:14:36 +0800 Subject: [PATCH] =?UTF-8?q?[feat]:[FL-175][=E6=96=87=E6=A1=A3=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2=E4=B8=80=E8=87=B4=E6=80=A7=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20-=20=E8=A1=A5=E5=85=85=E5=AE=8C=E6=95=B4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于代码审查反馈,补充所有缺失功能,完全对齐用户管理页面标准: **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 Co-authored-by: multica-agent --- web/src/app/admin/documents/page.tsx | 351 ++++++++++++++++++++++++--- web/src/app/globals.css | 50 ++++ 2 files changed, 367 insertions(+), 34 deletions(-) diff --git a/web/src/app/admin/documents/page.tsx b/web/src/app/admin/documents/page.tsx index 465db2f..65b0930 100644 --- a/web/src/app/admin/documents/page.tsx +++ b/web/src/app/admin/documents/page.tsx @@ -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>; @@ -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(null); const pageCardRef = useRef(null); const [deletingDocumentId, setDeletingDocumentId] = useState(null); + const [keywordInput, setKeywordInput] = useState(""); + const [searchKeyword, setSearchKeyword] = useState(""); + const [statusFilter, setStatusFilter] = useState<"draft" | "published" | undefined>(undefined); + const keywordDebounceTimeoutRef = useRef(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([]); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [chapterForm] = Form.useForm(); const [documentForm] = Form.useForm(); 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(".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 = 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 ( + + + {doc.title} + + + {doc.status === "published" ? "已发布" : "草稿"} + + + } + extra={ + + + deleteDocumentMutation.mutate(doc.id)} + disabled={rowBusy || !canManage} + > + + + + } + > + +
+ 排序 + {doc.sort_order} +
+ {doc.content && ( +
+ 内容 + + {doc.content.substring(0, 100)} + {doc.content.length > 100 && "..."} + +
+ )} +
+
+ ); + }; + + if (initializing) { return (
- +
); } + if (!user) { + return ( +
+

请先登录后再访问文档管理页面。

+ + 返回首页 + +
+ ); + } + + if (!canRead) { + return ( +
+

你没有访问该页面的权限(需要 `document.read`)。

+ + 返回首页 + +
+ ); + } + return (
@@ -592,36 +789,122 @@ export default function AdminDocumentsPage() { ) } > -
- - 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" ? ( +
+ + handleKeywordChange(event.target.value)} + /> + +
+ ) : ( +
+ + handleKeywordChange(event.target.value)} + /> + + + + + value={statusFilter} + allowClear + placeholder="全部" + options={[ + { value: "draft", label: "草稿" }, + { value: "published", label: "已发布" }, + ]} + onChange={(value) => { + setStatusFilter(value); + setPagination((prev) => ({ ...prev, current: 1 })); + setCardViewPage(1); + setAllLoadedDocuments([]); + }} + /> + +
+ )} + + {viewMode === "table" ? ( +
+ + 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: ( + + ), + }} + /> +
+ ) : ( +
+ {documentsLoading && allLoadedDocuments.length === 0 ? ( +
+ +
+ ) : allLoadedDocuments.length === 0 ? ( +
- ), - }} - /> -
+
+ ) : ( +
+ + {allLoadedDocuments.map((doc) => ( + + {renderDocumentCard(doc)} + + ))} + + {isLoadingMore && ( +
+ +
+ )} + {allLoadedDocuments.length >= (documentsData?.total ?? 0) && allLoadedDocuments.length > 0 && ( +
+ + 已加载全部 {allLoadedDocuments.length} 条数据 + +
+ )} +
+ )} +
+ )}
diff --git a/web/src/app/globals.css b/web/src/app/globals.css index d683bc6..aafa7d8 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -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;