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 (
+
+ {/* 工具栏 */}
+
+
+ }
+ onClick={() => handlePageChange(currentPage - 1)}
+ disabled={currentPage <= 1}
+ size="small"
+ />
+
+
+
+
+ }
+ onClick={() => handlePageChange(currentPage + 1)}
+ disabled={currentPage >= (numPages || 0)}
+ size="small"
+ />
+ }
+ onClick={scrollToTop}
+ size="small"
+ disabled={currentPage === 1}
+ >
+ 回到顶部
+
+
+
+
+ } onClick={zoomOut} size="small">
+ 缩小
+
+
+ {Math.round(scale * 100)}%
+
+ } onClick={zoomIn} size="small">
+ 放大
+
+
+
+
+ {/* 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} 页
+ >
+ ) : (
+
+ )}
+
+ )
+ })}
+
+ )}
+
+
+
+ )
+}
+
+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 (
-
+
-
项目文档(编辑模式)
+
项目文档
-
- }
- onClick={() => navigate(`/projects/${projectId}/docs`)}
- />
-
+
}
+ onClick={() => navigate(`/projects/${projectId}/docs`)}
+ >
+ 退出编辑
+