From bb323f89e49589cb28b577dfb607ccedb44cc03a Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Thu, 1 Jan 2026 22:41:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=A7pdf=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/files.py | 54 +- backend/app/services/storage.py | 6 +- frontend/package.json | 2 + frontend/src/api/file.js | 1 - frontend/src/api/share.js | 1 - .../src/components/MainLayout/AppHeader.css | 16 +- .../src/components/MainLayout/AppHeader.jsx | 15 +- .../components/PDFViewer/VirtualPDFViewer.css | 130 +++ .../components/PDFViewer/VirtualPDFViewer.jsx | 271 +++++++ .../src/pages/Document/DocumentEditor.css | 109 ++- .../src/pages/Document/DocumentEditor.jsx | 46 +- frontend/src/pages/Document/DocumentPage.css | 53 +- frontend/src/pages/Document/DocumentPage.jsx | 371 ++++----- frontend/src/pages/Preview/PreviewPage.jsx | 4 +- .../src/pages/ProjectList/ProjectList.jsx | 744 +++++++++--------- frontend/yarn.lock | 10 + 16 files changed, 1187 insertions(+), 646 deletions(-) create mode 100644 frontend/src/components/PDFViewer/VirtualPDFViewer.css create mode 100644 frontend/src/components/PDFViewer/VirtualPDFViewer.jsx diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index c383be8..580487b 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -341,16 +341,20 @@ async def upload_document( async def get_document_file( project_id: int, path: str, + request: Request, current_user: User = Depends(get_user_from_token_or_query), db: AsyncSession = Depends(get_db) ): """ - 获取文档文件(PDF等)- 返回文件流 + 获取文档文件(PDF等)- 支持 HTTP Range 请求 Args: project_id: 项目ID path: 文件相对路径(如 "manual.pdf" 或 "docs/guide.pdf") """ + import re + import aiofiles + project = await check_project_access(project_id, current_user, db) # 获取文件路径 @@ -363,15 +367,57 @@ async def get_document_file( content_type, _ = mimetypes.guess_type(str(file_path)) if not content_type: content_type = "application/octet-stream" - - # 返回文件流 + + # 获取文件大小 + file_size = file_path.stat().st_size + + # 检查是否有 Range 请求头 + range_header = request.headers.get("range") + + if range_header: + # 解析 Range 头: bytes=start-end + range_match = re.match(r"bytes=(\d+)-(\d*)", range_header) + if range_match: + start = int(range_match.group(1)) + end = int(range_match.group(2)) if range_match.group(2) else file_size - 1 + end = min(end, file_size - 1) + + # 读取指定范围的文件内容 + async def file_iterator(): + async with aiofiles.open(file_path, 'rb') as f: + await f.seek(start) + chunk_size = 1024 * 1024 # 1MB chunks + remaining = end - start + 1 + while remaining > 0: + chunk = await f.read(min(chunk_size, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + headers = { + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(end - start + 1), + } + + return StreamingResponse( + file_iterator(), + status_code=206, # Partial Content + headers=headers, + media_type=content_type + ) + + # 正常请求,返回完整文件(添加 Accept-Ranges 头) return FileResponse( path=str(file_path), media_type=content_type, - filename=file_path.name + filename=file_path.name, + headers={"Accept-Ranges": "bytes"} ) + @router.get("/{project_id}/assets/{subfolder}/{filename}") async def get_asset_file( project_id: int, diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index e5dea10..a3fcda7 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -309,10 +309,8 @@ class StorageService: detail=f"不支持的文件类型: {file_ext}。允许的类型: {', '.join(allowed_extensions)}" ) - # 生成唯一文件名(保留原始文件名+时间戳) - original_name = Path(file.filename).stem - timestamp = uuid.uuid4().hex[:8] - unique_filename = f"{original_name}_{timestamp}{file_ext}" + # 使用原始文件名(不添加时间戳) + unique_filename = file.filename # 目标路径 if target_dir: diff --git a/frontend/package.json b/frontend/package.json index b7f5d2e..5d498a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,8 @@ "react-markdown": "^9.0.1", "react-pdf": "^10.2.0", "react-router-dom": "^6.20.1", + "react-virtuoso": "^4.18.1", + "react-window": "^2.2.3", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "rehype-slug": "^6.0.0", diff --git a/frontend/src/api/file.js b/frontend/src/api/file.js index 1919b87..86b742d 100644 --- a/frontend/src/api/file.js +++ b/frontend/src/api/file.js @@ -129,4 +129,3 @@ export function getDocumentUrl(projectId, path) { const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/') return `/api/v1/files/${projectId}/document/${encodedPath}` } - diff --git a/frontend/src/api/share.js b/frontend/src/api/share.js index 46c94f4..1a776b0 100644 --- a/frontend/src/api/share.js +++ b/frontend/src/api/share.js @@ -78,4 +78,3 @@ export function getPreviewDocumentUrl(projectId, path) { const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/') return `/api/v1/preview/${projectId}/document/${encodedPath}` } - diff --git a/frontend/src/components/MainLayout/AppHeader.css b/frontend/src/components/MainLayout/AppHeader.css index 9b18ee4..deed13b 100644 --- a/frontend/src/components/MainLayout/AppHeader.css +++ b/frontend/src/components/MainLayout/AppHeader.css @@ -50,8 +50,8 @@ } .trigger:hover { - color: #b8178d; - background: rgba(184, 23, 141, 0.06); + color: #1677ff; + background: rgba(22, 119, 255, 0.08); } /* 右侧区域 */ @@ -83,8 +83,8 @@ } .header-icon:hover { - color: #b8178d; - background: rgba(184, 23, 141, 0.06); + color: #1677ff; + background: rgba(22, 119, 255, 0.08); } .header-link { @@ -100,8 +100,8 @@ } .header-link:hover { - color: #b8178d; - background: rgba(184, 23, 141, 0.06); + color: #1677ff; + background: rgba(22, 119, 255, 0.08); } .user-info { @@ -115,7 +115,7 @@ } .user-info:hover { - background: rgba(184, 23, 141, 0.06); + background: rgba(22, 119, 255, 0.08); } .username { @@ -126,4 +126,4 @@ .ml-1 { margin-left: 4px; -} +} \ No newline at end of file diff --git a/frontend/src/components/MainLayout/AppHeader.jsx b/frontend/src/components/MainLayout/AppHeader.jsx index 5aee4f7..0c38442 100644 --- a/frontend/src/components/MainLayout/AppHeader.jsx +++ b/frontend/src/components/MainLayout/AppHeader.jsx @@ -71,7 +71,7 @@ function AppHeader({ collapsed, onToggle }) {
{/* Logo 区域 */}
-

NEX Docus

+

NEX Docus

{/* 折叠按钮 */} @@ -98,11 +98,16 @@ function AppHeader({ collapsed, onToggle }) { ))} {/* 消息中心 */} - -
+
handleHeaderMenuClick('messages')} + > + -
- + + 消息 +
{/* 用户下拉菜单 */} ({ url }), [url]) + + // Memoize PDF.js options to prevent unnecessary reloads + const pdfOptions = useMemo(() => ({ + cMapUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/cmaps/', + cMapPacked: true, + standardFontDataUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/standard_fonts/', + }), []) + + // 根据 PDF 实际宽高和缩放比例计算页面高度 + const pageHeight = useMemo(() => { + // 计算内容高度:缩放后的 PDF 高度 + 上下 padding (40px) + 页码文字区域 (20px) + return Math.ceil(pdfOriginalSize.height * scale) + 60 + }, [scale, pdfOriginalSize.height]) + + const onDocumentLoadError = (error) => { + console.error('[PDF] Document load error:', error) + message.error('PDF文件加载失败') + } + + + + // Handle scroll to update visible pages + const handleScroll = useCallback(() => { + if (!containerRef.current || !numPages) return + + const container = containerRef.current + const scrollTop = container.scrollTop + const containerHeight = container.clientHeight + + // Calculate which pages are visible + // Add small tolerance (1px) to handle browser scroll precision issues + const pageIndex = scrollTop / pageHeight + let firstVisiblePage = Math.max(1, Math.ceil(pageIndex + 0.001)) + + // Special case: if scrolled to bottom, show last page + const isAtBottom = scrollTop + containerHeight >= container.scrollHeight - 1 + if (isAtBottom) { + firstVisiblePage = numPages + } + + const lastVisiblePage = Math.min(numPages, Math.ceil((scrollTop + containerHeight) / pageHeight)) + + + + // Add buffer pages (2 before and 2 after) + const newVisiblePages = new Set() + for (let i = Math.max(1, firstVisiblePage - 2); i <= Math.min(numPages, lastVisiblePage + 2); i++) { + newVisiblePages.add(i) + } + + setVisiblePages(newVisiblePages) + setCurrentPage(firstVisiblePage) + }, [numPages, pageHeight]) + + const onDocumentLoadSuccess = useCallback(async (pdf) => { + setNumPages(pdf.numPages) + + try { + // 获取第一页的原始尺寸,用于计算初始缩放 + const page = await pdf.getPage(1) + const viewport = page.getViewport({ scale: 1.0 }) + const { width, height } = viewport + setPdfOriginalSize({ width, height }) + + // 自动适应宽度:仅当 PDF 宽度超过容器时才进行缩放 + if (containerRef.current) { + const containerWidth = containerRef.current.clientWidth - 40 // 减去左右内边距 + if (width > containerWidth) { + const autoScale = Math.floor((containerWidth / width) * 10) / 10 // 保留一位小数 + setScale(Math.min(Math.max(autoScale, 0.5), 1.0)) // 限制缩放比例,最高不超 1.0 + } else { + setScale(1.0) // 宽度足够则保持 100% + } + } + } catch (err) { + console.error('Error calculating initial scale:', err) + } + + // Initially show first 3 pages + setVisiblePages(new Set([1, 2, 3])) + + // Trigger scroll calculation after a short delay to ensure DOM is ready + setTimeout(() => { + handleScroll() + }, 200) + }, [handleScroll]) + + // Attach scroll listener + useEffect(() => { + const container = containerRef.current + if (!container) return + + container.addEventListener('scroll', handleScroll) + return () => container.removeEventListener('scroll', handleScroll) + }, [handleScroll, numPages, pageHeight]) + + const zoomIn = () => { + setScale((prev) => Math.min(prev + 0.2, 3.0)) + } + + const zoomOut = () => { + setScale((prev) => Math.max(prev - 0.2, 0.5)) + } + + const handlePageChange = (value) => { + if (value >= 1 && value <= numPages && containerRef.current) { + const scrollTop = (value - 1) * pageHeight + const container = containerRef.current + container.scrollTo({ top: scrollTop, behavior: 'auto' }) + + // Manually trigger handleScroll after scrolling to ensure page number updates + setTimeout(() => { + handleScroll() + }, 50) + } + } + + const scrollToTop = () => { + if (containerRef.current) { + containerRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + } + + return ( +
+ {/* 工具栏 */} +
+ + + + + + + + + + {Math.round(scale * 100)}% + + + +
+ + {/* PDF内容区 - 自定义虚拟滚动 */} +
+ + +
正在加载PDF...
+
+ } + error={
PDF加载失败,请稍后重试
} + options={pdfOptions} + > + {numPages && ( +
+ {Array.from({ length: numPages }, (_, index) => { + const pageNumber = index + 1 + const isVisible = visiblePages.has(pageNumber) + + return ( +
pageRefs.current[pageNumber] = el} + className="pdf-page-wrapper" + style={{ + position: 'absolute', + top: index * pageHeight, + left: 0, + right: 0, + height: pageHeight, + }} + > + {isVisible ? ( + <> + + +
加载第 {pageNumber} 页...
+
+ } + /> +
第 {pageNumber} 页
+ + ) : ( +
+
第 {pageNumber} 页
+
+ )} +
+ ) + })} +
+ )} + +
+ + ) +} + +export default VirtualPDFViewer diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index dd1e6c3..564673c 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -1,6 +1,5 @@ -/* 覆盖MainLayout的content-wrapper padding */ .document-editor-page { - height: calc(90vh); + height: calc(100vh - 64px); /* width: calc(100% + 32px); */ display: flex; } @@ -14,44 +13,84 @@ .document-sider { border-right: 1px solid #f0f0f0; - overflow-y: auto; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; flex-shrink: 0; } .sider-header { - padding: 16px; + padding: 16px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: column; gap: 12px; + background: #fff; } -.sider-header h3 { +.sider-header h2 { margin: 0; font-size: 16px; font-weight: 600; + color: #262626; + line-height: 1.5; } .sider-actions { display: flex; - gap: 4px; - flex-wrap: wrap; + gap: 8px; + align-items: center; } -.sider-actions .ant-btn { +.mode-toggle-btn { + border-radius: 6px !important; + font-weight: 500 !important; + height: 32px !important; + display: flex !important; + align-items: center !important; + padding: 0 12px !important; + border: none !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.mode-toggle-btn.exit-edit { + background: linear-gradient(135deg, #00b96b 0%, #52c41a 100%) !important; + color: white !important; + box-shadow: 0 2px 6px rgba(0, 185, 107, 0.2); +} + +.mode-toggle-btn:hover { + transform: translateY(-1px); + filter: brightness(1.05); +} + +.mode-toggle-btn.exit-edit:hover { + box-shadow: 0 4px 10px rgba(0, 185, 107, 0.3); +} + +.sider-actions .ant-btn:not(.mode-toggle-btn) { + background: #f0f0f0; + border: 1px solid #d9d9d9; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; color: rgba(0, 0, 0, 0.65); - background: #f5f5f5; - border: 1px solid #e8e8e8; } -.sider-actions .ant-btn:hover { - color: #1890ff; - background: #e6f7ff; - border-color: #91d5ff; +.sider-actions .ant-btn:not(.mode-toggle-btn):hover { + background: #fff; + border-color: #d9d9d9; + color: #1677ff; } .file-tree { padding: 8px; + flex: 1; + overflow-y: auto; } /* 修复Tree组件文档名过长的显示问题 */ @@ -74,7 +113,8 @@ /* 确保Tree节点标题区域不折行 */ .file-tree .ant-tree-node-content-wrapper .ant-tree-title { display: inline-block; - max-width: calc(100% - 24px); /* 预留图标空间 */ + max-width: calc(100% - 24px); + /* 预留图标空间 */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap !important; @@ -86,7 +126,8 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - flex: 1; /* Ensure it allows children to fill width */ + flex: 1; + /* Ensure it allows children to fill width */ } /* Increase hit area for context menu */ @@ -102,19 +143,20 @@ } /* 选中的文件夹样式 */ -.file-tree .folder-selected > .ant-menu-submenu-title { +.file-tree .folder-selected>.ant-menu-submenu-title { background-color: #e6f7ff !important; color: #1890ff !important; } -.file-tree .folder-selected > .ant-menu-submenu-title:hover { +.file-tree .folder-selected>.ant-menu-submenu-title:hover { background-color: #bae7ff !important; } .file-tree .ant-menu-item, .file-tree .ant-menu-submenu-title { overflow: hidden; - display: flex !important; /* Ensure flex layout for item */ + display: flex !important; + /* Ensure flex layout for item */ align-items: center; } @@ -160,7 +202,8 @@ .bytemd-wrapper { flex: 1; display: flex; - flex-direction: column; /* Changed to column to force child stretch width */ + flex-direction: column; + /* Changed to column to force child stretch width */ min-height: 0; min-width: 0; overflow: hidden; @@ -170,7 +213,7 @@ } /* Fix for bytemd-react wrapper div */ -.bytemd-wrapper > div { +.bytemd-wrapper>div { flex: 1; display: flex; flex-direction: column; @@ -196,7 +239,8 @@ border: 1px solid #d9d9d9; border-radius: 2px; overflow: hidden; - max-width: none !important; /* Ensure no max-width constraint */ + max-width: none !important; + /* Ensure no max-width constraint */ box-sizing: border-box; } @@ -205,7 +249,8 @@ flex-shrink: 0; border-bottom: 1px solid #d9d9d9; background-color: #fafafa; - box-sizing: border-box; /* Added for consistent box model */ + box-sizing: border-box; + /* Added for consistent box model */ } /* 编辑和预览区域容器 */ @@ -215,8 +260,10 @@ overflow: hidden; min-height: 0; width: 100% !important; - box-sizing: border-box; /* Added for consistent box model */ - min-width: 0; /* Prevent flex item from overflowing */ + box-sizing: border-box; + /* Added for consistent box model */ + min-width: 0; + /* Prevent flex item from overflowing */ } /* 编辑区域 - 固定50%宽度 */ @@ -228,8 +275,10 @@ overflow: hidden; min-height: 0; max-width: 50% !important; - box-sizing: border-box; /* Added for consistent box model */ - min-width: 0; /* Prevent flex item from overflowing */ + box-sizing: border-box; + /* Added for consistent box model */ + min-width: 0; + /* Prevent flex item from overflowing */ } /* 预览区域 - 固定50%宽度 */ @@ -242,8 +291,10 @@ font-size: 14px; line-height: 1.8; max-width: 50% !important; - box-sizing: border-box; /* Added for consistent box model */ - min-width: 0; /* Prevent flex item from overflowing */ + box-sizing: border-box; + /* Added for consistent box model */ + min-width: 0; + /* Prevent flex item from overflowing */ } /* CodeMirror 容器 */ diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index eb0a587..a8ebf62 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -63,6 +63,7 @@ function DocumentEditor() { const [openKeys, setOpenKeys] = useState([]) // Menu组件的展开项 const [uploadProgress, setUploadProgress] = useState(0) // 上传进度 const [uploading, setUploading] = useState(false) // 是否正在上传 + const [fileList, setFileList] = useState([]) // 控制上传文件列表 const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹) const uploadingRef = useRef(false) // 使用ref防止重复上传 const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件 @@ -206,7 +207,7 @@ function DocumentEditor() { // If clicked from context menu (node is passed), use it const targetNode = (node && node.key) ? node : selectedNode const parentPath = getParentPath(targetNode) - + setCreationParentPath(parentPath) setOperationType('create_file') setModalVisible(true) @@ -370,15 +371,19 @@ function DocumentEditor() { return } - const { fileList } = info + const { fileList: currentFileList } = info + + // 立即更新state以显示当前选择的文件 + setFileList(currentFileList) // 过滤出有效文件 - const mdFiles = fileList.filter((f) => f.name.endsWith('.md')) - const pdfFiles = fileList.filter((f) => f.name.toLowerCase().endsWith('.pdf')) + const mdFiles = currentFileList.filter((f) => f.name.endsWith('.md')) + const pdfFiles = currentFileList.filter((f) => f.name.toLowerCase().endsWith('.pdf')) const allFiles = [...mdFiles, ...pdfFiles] if (allFiles.length === 0) { Toast.warning('提示', '请选择.md或.pdf格式的文档') + setFileList([]) // 清空文件列表 return } @@ -457,6 +462,7 @@ function DocumentEditor() { Toast.success('成功', `成功上传 ${successCount} 个文档`) fetchTree() // 清除文件选择 + setFileList([]) if (fileInputRef.current) { fileInputRef.current.value = '' } @@ -776,23 +782,24 @@ function DocumentEditor() { return (
- +
-

项目文档(编辑模式)

+

项目文档

- -
+ {/* 左侧目录 */} + +
+

项目文档

+
+ {/* 只有 owner/admin/editor 可以编辑 */} + {userRole !== 'viewer' && ( + + )} + +
+
+ + + + + {/* 右侧内容区 */} + + +
+ {loading ? ( +
+ +
加载中...
+
+
+ ) : viewMode === 'pdf' ? ( + + ) : ( +
+ ( + handleMarkdownLink(e, href)} + {...props} + > + {children} + + ), + }} + > + {markdownContent} + +
+ )}
- - + {/* 返回顶部按钮 - 仅在markdown模式显示 */} + {viewMode === 'markdown' && ( + } + type="primary" + style={{ right: tocCollapsed ? 24 : 280 }} + onClick={() => { + if (contentRef.current) { + contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + }} + /> + )} + - {/* 右侧内容区 */} - - -
- {loading ? ( -
- -
加载中...
-
-
- ) : viewMode === 'pdf' ? ( - +
+

文档索引

+
+
+ {tocItems.length > 0 ? ( + contentRef.current} + items={tocItems.map((item) => ({ + key: item.key, + href: item.href, + title: ( +
+ + {item.title} +
+ ), + }))} /> ) : ( -
- ( - handleMarkdownLink(e, href)} - {...props} - > - {children} - - ), - }} - > - {markdownContent} - -
+
当前文档无标题
)}
- - {/* 返回顶部按钮 - 仅在markdown模式显示 */} - {viewMode === 'markdown' && ( - } - type="primary" - style={{ right: tocCollapsed ? 24 : 280 }} - onClick={() => { - if (contentRef.current) { - contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) - } - }} - /> - )} - - - {/* 右侧TOC面板 - 仅在markdown模式显示 */} - {viewMode === 'markdown' && !tocCollapsed && ( - -
-

文档索引

-
-
- {tocItems.length > 0 ? ( - contentRef.current} - items={tocItems.map((item) => ({ - key: item.key, - href: item.href, - title: ( -
- - {item.title} -
- ), - }))} - /> - ) : ( -
当前文档无标题
- )} -
-
- )} - - - {/* TOC展开按钮 */} - {tocCollapsed && ( - + )} - {/* 分享模态框 */} - setShareModalVisible(false)} - footer={null} - width={500} - > - {shareInfo && ( - + {/* TOC展开按钮 */} + {tocCollapsed && ( + + )} + + + {/* 分享模态框 */} + setShareModalVisible(false)} + footer={null} + width={500} + > + {shareInfo && ( + +
+ + + } + /> +
+ +
+ + 访问密码保护 + + +
+ + {hasPassword && (
- - - } + setPassword(e.target.value)} /> +
- -
- - 访问密码保护 - - -
- - {hasPassword && ( -
- setPassword(e.target.value)} - /> - -
- )} -
- )} -
-
+ )} + + )} + +
) } diff --git a/frontend/src/pages/Preview/PreviewPage.jsx b/frontend/src/pages/Preview/PreviewPage.jsx index ea6c679..1ff2eea 100644 --- a/frontend/src/pages/Preview/PreviewPage.jsx +++ b/frontend/src/pages/Preview/PreviewPage.jsx @@ -9,7 +9,7 @@ import rehypeSlug from 'rehype-slug' import rehypeHighlight from 'rehype-highlight' import 'highlight.js/styles/github.css' import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share' -import PDFViewer from '@/components/PDFViewer/PDFViewer' +import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import './PreviewPage.css' const { Sider, Content } = Layout @@ -316,7 +316,7 @@ function PreviewPage() {
) : viewMode === 'pdf' ? ( - diff --git a/frontend/src/pages/ProjectList/ProjectList.jsx b/frontend/src/pages/ProjectList/ProjectList.jsx index f93ee41..17c8511 100644 --- a/frontend/src/pages/ProjectList/ProjectList.jsx +++ b/frontend/src/pages/ProjectList/ProjectList.jsx @@ -307,85 +307,85 @@ function ProjectList({ type = 'my' }) { // 过滤项目(仅在未使用全局搜索时进行本地过滤) const filteredProjects = !hasSearched && searchKeyword ? projects.filter((project) => - project.name.toLowerCase().includes(searchKeyword.toLowerCase()) || - (project.description && project.description.toLowerCase().includes(searchKeyword.toLowerCase())) - ) + project.name.toLowerCase().includes(searchKeyword.toLowerCase()) || + (project.description && project.description.toLowerCase().includes(searchKeyword.toLowerCase())) + ) : projects return (
- , - onClick: () => setModalVisible(true), - }, - ] : []} - search={{ - placeholder: '搜索项目或文件...', - value: searchKeyword, - onChange: handleSearchChange, - onSearch: handleSearch, - }} - showRefresh - onRefresh={fetchProjects} - /> + , + onClick: () => setModalVisible(true), + }, + ] : []} + search={{ + placeholder: '搜索项目或文件...', + value: searchKeyword, + onChange: handleSearchChange, + onSearch: handleSearch, + }} + showRefresh + onRefresh={fetchProjects} + /> - {/* 搜索结果 */} - {hasSearched && searchResults.length > 0 && ( -
-
- 找到 {searchResults.length} 个结果 -
- - {searchResults.map((item, index) => ( - - handleSearchResultClick(item)} - > -
- {item.type === 'project' ? ( - - ) : ( - - )} -
- - -

- {item.type === 'project' ? item.project_name : item.file_name} -

- - {item.match_type} - -
- {item.type === 'project' && ( -

- {item.project_description || '暂无描述'} -

- )} - {item.type === 'file' && ( -
-
项目: {item.project_name}
-
路径: {item.file_path}
-
- )} -
-
- - ))} -
+ {/* 搜索结果 */} + {hasSearched && searchResults.length > 0 && ( +
+
+ 找到 {searchResults.length} 个结果
- )} + + {searchResults.map((item, index) => ( + + handleSearchResultClick(item)} + > +
+ {item.type === 'project' ? ( + + ) : ( + + )} +
+ + +

+ {item.type === 'project' ? item.project_name : item.file_name} +

+ + {item.match_type} + +
+ {item.type === 'project' && ( +

+ {item.project_description || '暂无描述'} +

+ )} + {item.type === 'file' && ( +
+
项目: {item.project_name}
+
路径: {item.file_path}
+
+ )} +
+
+ + ))} +
+
+ )} - {/* 正常项目列表 */} - {!hasSearched && ( - + {/* 正常项目列表 */} + {!hasSearched && ( + {filteredProjects.map((project) => ( )} - )} + )} - {/* 搜索无结果提示 */} - {hasSearched && !searching && searchResults.length === 0 && ( -
- -
- )} + {/* 搜索无结果提示 */} + {hasSearched && !searching && searchResults.length === 0 && ( +
+ +
+ )} - { - setModalVisible(false) - form.resetFields() - }} - footer={null} + { + setModalVisible(false) + form.resetFields() + }} + footer={null} + > +
- - - - + + - - - + + + - + + + + + + + +
+ + { + setEditModalVisible(false) + editForm.resetFields() + }} + footer={null} + > +
+ + + + + + + + + + + + + + + - -
-
- - { - setEditModalVisible(false) - editForm.resetFields() - }} - footer={null} - > -
- - - - - - - - - - - - - - - - - - - - - -
-
- - setShareModalVisible(false)} - footer={null} - width={500} - > - {shareInfo && ( - -
- - - } - /> -
- - {/* 只有在我的项目中才显示密码设置功能 */} - {type === 'my' && ( - <> -
- - 访问密码保护 - - -
- - {hasPassword && ( -
- setPassword(e.target.value)} - /> - -
- )} - - )} - - {/* 参与项目显示提示 */} - {type === 'share' && shareInfo.has_password && ( -
- 该项目已设置访问密码保护 -
- )}
- )} -
+ + +
- { - setMembersModalVisible(false) - memberForm.resetFields() - setMembers([]) - setUsers([]) - }} - footer={null} - width={700} - > + setShareModalVisible(false)} + footer={null} + width={500} + > + {shareInfo && (
- {users.length === 0 ? ( -
- {loadingMembers ? '正在加载用户列表...' : '没有可添加的用户'} -
) : null} -
- - - 管理员 - 编辑者 - 查看者 - - - - - -
+ + + } + />
-
-

当前成员

- {members.length === 0 ? ( - - ) : ( - { - if (record.nickname) { - return `${username} (${record.nickname})` - } - return username || `用户ID: ${record.user_id}` - }, - }, - { - title: '角色', - dataIndex: 'role', - key: 'role', - render: (role) => { - const roleMap = { - admin: '管理员', - editor: '编辑者', - viewer: '查看者', - } - return roleMap[role] || role - }, - }, - { - title: '加入时间', - dataIndex: 'joined_at', - key: 'joined_at', - render: (time) => time ? new Date(time).toLocaleString('zh-CN') : '-', - }, - { - title: '操作', - key: 'action', - render: (_, record) => { - const isOwner = record.user_id === currentProject?.owner_id - return ( - - ) - }, - }, - ]} - /> - )} - + {/* 只有在我的项目中才显示密码设置功能 */} + {type === 'my' && ( + <> +
+ + 访问密码保护 + + +
+ + {hasPassword && ( +
+ setPassword(e.target.value)} + /> + +
+ )} + + )} + + {/* 参与项目显示提示 */} + {type === 'share' && shareInfo.has_password && ( +
+ 该项目已设置访问密码保护 +
+ )} - - + )} + + + { + setMembersModalVisible(false) + memberForm.resetFields() + setMembers([]) + setUsers([]) + }} + footer={null} + width={700} + > + +
+ {users.length === 0 ? ( +
+ {loadingMembers ? '正在加载用户列表...' : '没有可添加的用户'} +
) : null} +
+ + + 管理员 + 编辑者 + 查看者 + + + + + + +
+ +
+

当前成员

+ {members.length === 0 ? ( + + ) : ( +
{ + if (record.nickname) { + return `${username} (${record.nickname})` + } + return username || `用户ID: ${record.user_id}` + }, + }, + { + title: '角色', + dataIndex: 'role', + key: 'role', + render: (role) => { + const roleMap = { + admin: '管理员', + editor: '编辑者', + viewer: '查看者', + } + return roleMap[role] || role + }, + }, + { + title: '加入时间', + dataIndex: 'joined_at', + key: 'joined_at', + render: (time) => time ? new Date(time).toLocaleString('zh-CN') : '-', + }, + { + title: '操作', + key: 'action', + render: (_, record) => { + const isOwner = record.user_id === currentProject?.owner_id + return ( + + ) + }, + }, + ]} + /> + )} + + + + ) } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 9ef6554..f08d83a 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4504,6 +4504,16 @@ react-router@6.30.2: dependencies: "@remix-run/router" "1.23.1" +react-virtuoso@^4.18.1: + version "4.18.1" + resolved "https://registry.npmmirror.com/react-virtuoso/-/react-virtuoso-4.18.1.tgz#3eb7078f2739a31b96c723374019e587deeb6ebc" + integrity sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA== + +react-window@^2.2.3: + version "2.2.3" + resolved "https://registry.npmmirror.com/react-window/-/react-window-2.2.3.tgz#f8ffdddbb612ccd3e1314b59fce79af85d3f15e3" + integrity sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ== + react@^18.2.0: version "18.3.1" resolved "https://registry.npmmirror.com/react/-/react-18.3.1.tgz"