main
mula.liu 2025-12-31 13:44:03 +08:00
parent f5e1e8871a
commit 9a431fc046
20 changed files with 1282 additions and 110 deletions

View File

@ -142,3 +142,228 @@
- 使用 Elasticsearch 全文检索 - 使用 Elasticsearch 全文检索
- 添加文档内容索引到数据库 - 添加文档内容索引到数据库
- 实现增量索引更新 - 实现增量索引更新
---
## Stage 7: PDF文档混合模式
**Goal**: 支持项目中上传PDF文件并在Markdown和浏览模式下查看PDF
### 7.1 后端PDF文件上传支持
**Goal**: 后端支持PDF文件上传并存储到_assets/files目录
**实现内容**:
1. 修改`/files/{project_id}/upload-file`接口支持PDF格式.pdf
2. 扩展文件类型验证,允许`application/pdf`
3. PDF文件存储到`_assets/files/`目录
4. 返回PDF文件的访问路径
**Success Criteria**:
- 可以通过API上传PDF文件
- PDF文件正确存储到_assets/files目录
- 返回可访问的文件路径
- 上传其他格式文件时报错
**Tests**:
- 使用Postman上传PDF文件验证返回路径
- 验证文件确实存储在正确目录
- 尝试上传非法格式,验证错误提示
**Status**: ✅ Completed
---
### 7.2 文件树显示PDF文件
**Goal**: 前端文件树能够识别和显示PDF文件
**实现内容**:
1. 修改`StorageService.generate_tree()`包含_assets/files目录中的PDF文件
2. 前端文件树组件识别.pdf文件
3. PDF文件显示专用图标FilePdfOutlined
4. 文件树节点携带文件类型信息
**Success Criteria**:
- 文件树中显示PDF文件节点
- PDF文件有独特的图标
- 点击PDF文件能触发相应事件
**Tests**:
- 在项目中上传PDF后刷新文件树查看是否显示
- 验证PDF文件图标是否正确
- 点击PDF文件查看是否有响应
**Status**: ✅ Completed
---
### 7.3 Markdown编辑器支持插入PDF链接
**Goal**: 在MD编辑器中可以方便地插入PDF文件链接
**实现内容**:
1. 编辑器工具栏添加"插入PDF"按钮
2. 点击按钮弹出上传对话框
3. 上传成功后自动在光标位置插入Markdown链接`[文件名.pdf](/_assets/files/xxx.pdf)`
4. 支持拖拽PDF文件到编辑器可选
**Success Criteria**:
- 工具栏有"插入PDF"按钮
- 可以上传PDF并自动插入链接
- 插入的链接格式正确
- 编辑器预览时显示PDF链接
**Tests**:
- 点击"插入PDF"按钮,上传文件
- 验证插入的Markdown链接格式
- 保存后重新打开,验证链接是否保留
**Status**: ✅ Completed
---
### 7.4 PDF文件预览功能
**Goal**: 浏览模式下点击PDF链接或文件树中的PDF文件时可以在线预览PDF
**实现内容**:
1. 安装PDF预览库react-pdf
2. 创建PDFViewer组件支持
- 分页显示
- 缩放控制(放大/缩小/适应宽度)
- 页码跳转
- 下载功能
3. 在浏览模式下点击PDF链接时打开预览器
4. 从文件树点击PDF文件时也打开预览器
5. 后端提供PDF文件访问接口权限校验
**Success Criteria**:
- 可以在浏览器中预览PDF文件
- 支持翻页、缩放等基本操作
- 只有有权限的用户可以查看PDF
- PDF预览界面美观易用
**Tests**:
- 在浏览模式下点击PDF链接验证能否打开
- 测试翻页、缩放、下载功能
- 测试权限无权限用户访问PDF时返回403
- 测试不同大小的PDF文件加载速度
**Status**: ✅ Completed
---
### 7.5 搜索集成PDF文件名
**Goal**: 全局搜索时可以搜索到PDF文件名
**实现内容**:
1. 修改`/search/documents`接口添加对PDF文件的搜索
2. 搜索结果中显示PDF文件带"PDF文档"标签
3. 点击搜索结果中的PDF文件时打开预览
**Success Criteria**:
- 搜索PDF文件名能返回结果
- 搜索结果中PDF文件有明确标识
- 点击搜索结果可以打开PDF预览
**Tests**:
- 上传名为"产品说明书.pdf"的文件
- 搜索"说明书",验证是否出现在结果中
- 点击搜索结果验证是否打开PDF预览
**Status**: ✅ Completed
---
## PDF功能技术选型
### PDF预览库对比
**方案1: react-pdf** (推荐)
- 优点:
- 轻量级基于pdf.js封装
- API简单易用
- 支持分页、缩放、搜索
- TypeScript支持良好
- 缺点:
- 需要配置worker
- 大文件可能需要优化
- 安装:`npm install react-pdf pdfjs-dist`
**方案2: @react-pdf-viewer**
- 优点:
- 功能更强大(书签、注释、表单)
- 开箱即用的工具栏
- 插件系统
- 缺点:
- 包体积较大
- 可能功能过剩
**方案3: iframe直接嵌入**
- 优点:
- 最简单,无需额外依赖
- 浏览器原生支持
- 缺点:
- 功能有限,样式难以自定义
- 移动端体验差
### 推荐方案
**使用 react-pdf**,理由:
1. 轻量级,适合当前项目规模
2. 功能足够,易于扩展
3. 社区活跃,文档完善
### 文件存储结构
```
projects/
{storage_key}/
_assets/
images/ # 图片文件(现有)
files/ # PDF等文档文件新增
README.md
其他目录和文件
```
### API设计
**1. 上传PDF文件扩展现有接口**
```
POST /api/v1/files/{project_id}/upload-file
```
请求参数:
- `file`: 文件二进制
- `subfolder`: "files"(新增,默认"images"
返回:
```json
{
"code": 200,
"data": {
"filename": "abc123.pdf",
"original_filename": "产品说明书.pdf",
"path": "_assets/files/abc123.pdf",
"size": 1024000
}
}
```
**2. 访问PDF文件新接口**
```
GET /api/v1/files/{project_id}/asset?path={relative_path}
```
- 权限校验:检查用户是否有项目访问权限
- 返回PDF文件流Content-Type: application/pdf
### Markdown链接格式
```markdown
# 项目文档
查看详细说明:[产品说明书](/_assets/files/abc123.pdf)
```
### 待确认问题
1. **文件大小限制**PDF文件最大允许多少MB建议20MB
2. **是否支持PDF注释/标注**:暂不支持,后续可扩展
3. **PDF缩略图**是否需要在文件树显示PDF缩略图建议暂不支持
4. **PDF搜索文本内容**是否需要搜索PDF内部文字建议暂不支持
5. **并发上传限制**是否限制同时上传的PDF数量
6. **历史版本**PDF文件是否需要版本控制建议暂不支持

View File

@ -289,6 +289,89 @@ async def upload_file(
return success_response(data=file_info, message="文件上传成功") return success_response(data=file_info, message="文件上传成功")
@router.post("/{project_id}/upload-document", response_model=dict)
async def upload_document(
project_id: int,
file: UploadFile = File(...),
target_dir: str = "",
request: Request = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
上传文档文件PDF等到项目目录
Args:
project_id: 项目ID
file: 上传的文件
target_dir: 目标目录相对路径 "docs" "docs/manuals"空字符串表示根目录
"""
project = await check_project_access(project_id, current_user, db, require_write=True)
# 只允许PDF文件
allowed_extensions = [".pdf"]
# 上传文件
file_info = await storage_service.upload_document(
project.storage_key,
file,
target_dir=target_dir,
allowed_extensions=allowed_extensions
)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.UPLOAD_IMAGE, # 复用上传图片的日志类型
project_id=project_id,
file_path=file_info['path'],
user=current_user,
detail={
"original_filename": file_info['original_filename'],
"size": file_info['size'],
"target_dir": target_dir
},
request=request,
)
return success_response(data=file_info, message="文档上传成功")
@router.get("/{project_id}/document/{path:path}")
async def get_document_file(
project_id: int,
path: str,
current_user: User = Depends(get_user_from_token_or_query),
db: AsyncSession = Depends(get_db)
):
"""
获取文档文件PDF等- 返回文件流
Args:
project_id: 项目ID
path: 文件相对路径 "manual.pdf" "docs/guide.pdf"
"""
project = await check_project_access(project_id, current_user, db)
# 获取文件路径
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
# 判断文件类型
content_type, _ = mimetypes.guess_type(str(file_path))
if not content_type:
content_type = "application/octet-stream"
# 返回文件流
return FileResponse(
path=str(file_path),
media_type=content_type,
filename=file_path.name
)
@router.get("/{project_id}/assets/{subfolder}/{filename}") @router.get("/{project_id}/assets/{subfolder}/{filename}")
async def get_asset_file( async def get_asset_file(
project_id: int, project_id: int,

View File

@ -2,12 +2,17 @@
项目预览相关 API支持公开和私密项目 项目预览相关 API支持公开和私密项目
""" """
from fastapi import APIRouter, Depends, HTTPException, Header from fastapi import APIRouter, Depends, HTTPException, Header
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from typing import Optional from typing import Optional
import mimetypes
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_current_user_optional from app.core.deps import get_current_user_optional, security_optional
from app.core.security import decode_access_token
from app.core.redis_client import TokenCache
from app.models.project import Project, ProjectMember from app.models.project import Project, ProjectMember
from app.models.user import User from app.models.user import User
from app.schemas.response import success_response from app.schemas.response import success_response
@ -165,3 +170,64 @@ async def get_preview_file(
content = await storage_service.read_file(file_path) content = await storage_service.read_file(file_path)
return success_response(data={"content": content}) return success_response(data={"content": content})
@router.get("/{project_id}/document/{path:path}")
async def get_preview_document(
project_id: int,
path: str,
password: Optional[str] = Header(None, alias="X-Access-Password"),
access_pass: Optional[str] = None, # 支持密码查询参数
token: Optional[str] = None, # 支持token查询参数
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional),
db: AsyncSession = Depends(get_db)
):
"""获取预览项目的文档文件PDF等- 返回文件流"""
# 获取当前用户支持header或query参数
current_user = None
token_str = None
if credentials:
token_str = credentials.credentials
elif token:
token_str = token
if token_str:
try:
user_id_from_redis = await TokenCache.get_user_id(token_str)
if user_id_from_redis:
payload = decode_access_token(token_str)
if payload:
user_id_str = payload.get("sub")
if user_id_str:
user_id = int(user_id_str)
result = await db.execute(select(User).where(User.id == user_id))
current_user = result.scalar_one_or_none()
except Exception:
pass # 忽略token验证失败继续作为未登录用户
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
# 如果设置了密码需要验证优先使用header其次使用query参数
provided_password = password or access_pass
if project.access_pass:
if not provided_password or project.access_pass != provided_password:
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
# 获取文件
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content_type, _ = mimetypes.guess_type(str(file_path))
return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name)

View File

@ -25,7 +25,7 @@ async def search_documents(
): ):
""" """
文档搜索简化版 文档搜索简化版
搜索范围项目名称项目描述文件名 搜索范围项目名称项目描述文件名支持.md和.pdf
""" """
if not keyword: if not keyword:
return success_response(data=[]) return success_response(data=[])
@ -83,10 +83,12 @@ async def search_documents(
if not project_path.exists() or not project_path.is_dir(): if not project_path.exists() or not project_path.is_dir():
continue continue
# 查找所有 .md 文件 # 查找所有 .md 和 .pdf 文件
md_files = list(project_path.rglob("*.md")) md_files = list(project_path.rglob("*.md"))
pdf_files = list(project_path.rglob("*.pdf"))
all_files = md_files + pdf_files
for file_path in md_files: for file_path in all_files:
# 跳过 _assets 目录中的文件 # 跳过 _assets 目录中的文件
if "_assets" in file_path.parts: if "_assets" in file_path.parts:
continue continue
@ -95,8 +97,11 @@ async def search_documents(
# 获取相对路径 # 获取相对路径
relative_path = str(file_path.relative_to(project_path)) relative_path = str(file_path.relative_to(project_path))
# 获取文件名(不含扩展名) # 获取文件名PDF保留扩展名MD去掉扩展名
file_name = file_path.stem if file_path.suffix.lower() == '.pdf':
file_name = file_path.name # PDF保留完整文件名
else:
file_name = file_path.stem # MD去掉扩展名
# 检查关键词是否在文件名或路径中 # 检查关键词是否在文件名或路径中
if keyword_lower in file_name.lower() or keyword_lower in relative_path.lower(): if keyword_lower in file_name.lower() or keyword_lower in relative_path.lower():

View File

@ -279,6 +279,71 @@ class StorageService:
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}") raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}")
async def upload_document(
self,
storage_key: str,
file: UploadFile,
target_dir: str = "",
allowed_extensions: list = None
) -> dict:
"""
上传文档文件到项目指定目录
Args:
storage_key: 项目 UUID
file: 上传的文件
target_dir: 目标目录相对路径 "docs" "docs/manuals"
allowed_extensions: 允许的文件扩展名列表 [".pdf", ".docx"]
Returns:
dict: 文件信息
Raises:
HTTPException: 上传失败或文件类型不允许
"""
# 验证文件扩展名
file_ext = Path(file.filename).suffix.lower()
if allowed_extensions and file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
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}"
# 目标路径
if target_dir:
target_path = self.get_secure_path(storage_key, target_dir)
else:
target_path = self.get_secure_path(storage_key)
target_path.mkdir(parents=True, exist_ok=True)
file_path = target_path / unique_filename
try:
# 保存文件
async with aiofiles.open(file_path, "wb") as f:
content = await file.read()
await f.write(content)
# 返回文件信息
if target_dir:
relative_path = f"{target_dir}/{unique_filename}"
else:
relative_path = unique_filename
return {
"filename": unique_filename,
"original_filename": file.filename,
"path": relative_path,
"size": len(content),
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}")
# 创建全局实例 # 创建全局实例
storage_service = StorageService() storage_service = StorageService()

View File

@ -21,9 +21,11 @@
"axios": "^1.6.2", "axios": "^1.6.2",
"bytemd": "^1.22.0", "bytemd": "^1.22.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"pdfjs-dist": "5.4.296",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-pdf": "^10.2.0",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",

File diff suppressed because one or more lines are too long

View File

@ -104,3 +104,29 @@ export function exportDirectory(projectId, directoryPath = '') {
}) })
} }
/**
* 上传文档文件PDF等
*/
export function uploadDocument(projectId, file, targetDir = '') {
const formData = new FormData()
formData.append('file', file)
return request({
url: `/files/${projectId}/upload-document?target_dir=${encodeURIComponent(targetDir)}`,
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
/**
* 获取文档文件URLPDF等
*/
export function getDocumentUrl(projectId, path) {
// 将路径的每个部分分别编码,但保留斜杠
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
return `/api/v1/files/${projectId}/document/${encodedPath}`
}

View File

@ -69,3 +69,13 @@ export function getPreviewFile(projectId, path, password = null) {
headers: password ? { 'X-Access-Password': password } : {}, headers: password ? { 'X-Access-Password': password } : {},
}) })
} }
/**
* 获取预览项目的文档文件URLPDF等
*/
export function getPreviewDocumentUrl(projectId, path) {
// 将路径的每个部分分别编码,但保留斜杠
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
return `/api/v1/preview/${projectId}/document/${encodedPath}`
}

View File

@ -19,6 +19,6 @@
} }
.content-wrapper { .content-wrapper {
padding: 16px; padding: 8px;
min-height: 100%; min-height: 100%;
} }

View File

@ -0,0 +1,62 @@
.pdf-viewer-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: #f5f5f5;
flex: 1;
min-height: 0;
}
.pdf-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
}
.pdf-content {
flex: 1;
overflow: auto;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
}
.pdf-content .react-pdf__Document {
display: flex;
justify-content: center;
}
.pdf-content .react-pdf__Page {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
margin-bottom: 20px;
background: #fff;
}
.pdf-content .react-pdf__Page canvas {
max-width: 100%;
height: auto !important;
}
.pdf-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
color: #999;
font-size: 14px;
}
.pdf-error {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
color: #f5222d;
font-size: 14px;
}

View File

@ -0,0 +1,137 @@
import { useState, useMemo } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
import { Button, Space, InputNumber, message, Spin } from 'antd'
import {
LeftOutlined,
RightOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@ant-design/icons'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import './PDFViewer.css'
// PDF.js worker - 使
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
function PDFViewer({ url, filename }) {
const [numPages, setNumPages] = useState(null)
const [pageNumber, setPageNumber] = useState(1)
const [scale, setScale] = useState(1.0)
// 使 useMemo
const fileConfig = useMemo(() => ({ url }), [url])
const onDocumentLoadSuccess = ({ numPages }) => {
setNumPages(numPages)
setPageNumber(1)
}
const onDocumentLoadError = (error) => {
message.error('PDF文件加载失败')
}
const goToPrevPage = () => {
setPageNumber((prev) => Math.max(prev - 1, 1))
}
const goToNextPage = () => {
setPageNumber((prev) => Math.min(prev + 1, numPages))
}
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) {
setPageNumber(value)
}
}
return (
<div className="pdf-viewer-container">
{/* 工具栏 */}
<div className="pdf-toolbar">
<Space>
<Button
icon={<LeftOutlined />}
onClick={goToPrevPage}
disabled={pageNumber <= 1}
size="small"
>
上一页
</Button>
<Space.Compact>
<InputNumber
min={1}
max={numPages || 1}
value={pageNumber}
onChange={handlePageChange}
size="small"
style={{ width: 60 }}
/>
<Button size="small" disabled>
/ {numPages || 0}
</Button>
</Space.Compact>
<Button
icon={<RightOutlined />}
onClick={goToNextPage}
disabled={pageNumber >= numPages}
size="small"
>
下一页
</Button>
</Space>
<Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
缩小
</Button>
<span style={{ minWidth: 50, textAlign: 'center' }}>
{Math.round(scale * 100)}%
</span>
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
放大
</Button>
</Space>
</div>
{/* PDF内容区 */}
<div className="pdf-content">
<Document
file={fileConfig}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={
<div className="pdf-loading">
<Spin size="large" />
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
</div>
}
error={<div className="pdf-error">PDF加载失败请稍后重试</div>}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
loading={
<div className="pdf-loading">
<Spin size="large" />
<div style={{ marginTop: 16 }}>正在渲染页面...</div>
</div>
}
/>
</Document>
</div>
</div>
)
}
export default PDFViewer

View File

@ -1,6 +1,5 @@
/* 覆盖MainLayout的content-wrapper padding */ /* 覆盖MainLayout的content-wrapper padding */
.document-editor-page { .document-editor-page {
margin: -16px;
height: calc(100vh - 64px); height: calc(100vh - 64px);
width: calc(100% + 32px); width: calc(100% + 32px);
display: flex; display: flex;
@ -55,6 +54,56 @@
padding: 8px; padding: 8px;
} }
/* 修复Tree组件文档名过长的显示问题 */
.file-tree .ant-tree-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.file-tree .ant-tree-node-content-wrapper {
overflow: hidden;
max-width: 100%;
}
.file-tree .ant-tree-treenode {
overflow: hidden;
}
/* 确保Tree节点标题区域不折行 */
.file-tree .ant-tree-node-content-wrapper .ant-tree-title {
display: inline-block;
max-width: calc(100% - 24px); /* 预留图标空间 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
vertical-align: middle;
}
/* 修复文档名过长的显示问题Menu组件已废弃但保留兼容 */
.file-tree .ant-menu-title-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1; /* Ensure it allows children to fill width */
}
/* Increase hit area for context menu */
.tree-node-wrapper {
width: 100%;
height: 100%;
display: block;
user-select: none;
}
.file-tree .ant-menu-item,
.file-tree .ant-menu-submenu-title {
overflow: hidden;
display: flex !important; /* Ensure flex layout for item */
align-items: center;
}
.document-content { .document-content {
height: 100%; height: 100%;
width: 100%; width: 100%;

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { Layout, Tree, Button, message, Modal, Input, Space, Tooltip, Dropdown, Upload, Select } from 'antd' import { Layout, Menu, Button, message, Modal, Input, Space, Tooltip, Dropdown, Upload, Select } from 'antd'
import { import {
FileOutlined, FileOutlined,
FolderOutlined, FolderOutlined,
@ -15,6 +15,8 @@ import {
DownloadOutlined, DownloadOutlined,
SwapOutlined, SwapOutlined,
FileImageOutlined, FileImageOutlined,
FilePdfOutlined,
FileTextOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { Editor } from '@bytemd/react' import { Editor } from '@bytemd/react'
import gfm from '@bytemd/plugin-gfm' import gfm from '@bytemd/plugin-gfm'
@ -32,6 +34,7 @@ import {
uploadFile, uploadFile,
importDocuments, importDocuments,
exportDirectory, exportDirectory,
uploadDocument,
} from '@/api/file' } from '@/api/file'
import './DocumentEditor.css' import './DocumentEditor.css'
@ -52,9 +55,11 @@ function DocumentEditor() {
const [operationType, setOperationType] = useState(null) const [operationType, setOperationType] = useState(null)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [rightClickNode, setRightClickNode] = useState(null) const [rightClickNode, setRightClickNode] = useState(null)
const [creationParentPath, setCreationParentPath] = useState('')
const [moveTargetPath, setMoveTargetPath] = useState('') const [moveTargetPath, setMoveTargetPath] = useState('')
const [dirOptions, setDirOptions] = useState([]) const [dirOptions, setDirOptions] = useState([])
const [editorHeight, setEditorHeight] = useState(600) // 600px const [editorHeight, setEditorHeight] = useState(600) // 600px
const [openKeys, setOpenKeys] = useState([]) // Menu
// //
useEffect(() => { useEffect(() => {
@ -83,7 +88,7 @@ function DocumentEditor() {
const tree = data.tree || data || [] // const tree = data.tree || data || [] //
setTreeData(tree) setTreeData(tree)
} catch (error) { } catch (error) {
console.error('Fetch tree error:', error) message.error('加载文件树失败')
} }
} }
@ -93,6 +98,13 @@ function DocumentEditor() {
if (info.node.isLeaf) { if (info.node.isLeaf) {
const filePath = selectedKeys[0] const filePath = selectedKeys[0]
// PDF
if (filePath.toLowerCase().endsWith('.pdf')) {
message.info('PDF文件请在浏览模式下查看')
return
}
setLoading(true) setLoading(true)
try { try {
const res = await getFileContent(projectId, filePath) const res = await getFileContent(projectId, filePath)
@ -106,6 +118,49 @@ function DocumentEditor() {
} }
} }
//
const findNodeByKey = (nodes, key) => {
for (const node of nodes) {
if (node.key === key) {
return node
}
if (node.children) {
const found = findNodeByKey(node.children, key)
if (found) return found
}
}
return null
}
// MenuMenu
const handleMenuClick = async ({ key, domEvent }) => {
const node = findNodeByKey(treeData, key)
if (!node) return
//
setSelectedNode(node)
//
if (node.isLeaf) {
// PDF
if (key.toLowerCase().endsWith('.pdf')) {
message.info('PDF文件请在浏览模式下查看')
return
}
setLoading(true)
try {
const res = await getFileContent(projectId, key)
setSelectedFile(key)
setFileContent(res.data.content)
} catch (error) {
message.error('加载文件失败')
} finally {
setLoading(false)
}
}
}
const handleSaveFile = async () => { const handleSaveFile = async () => {
if (!selectedFile) { if (!selectedFile) {
message.warning('请先选择文件') message.warning('请先选择文件')
@ -126,12 +181,30 @@ function DocumentEditor() {
} }
} }
const handleCreateFile = () => { const getParentPath = (node) => {
if (!node) return ''
if (!node.isLeaf) return node.key
const parts = node.key.split('/')
parts.pop()
return parts.join('/')
}
const handleCreateFile = (node = null) => {
// If clicked from button (node is event or undefined), use selectedNode
// 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') setOperationType('create_file')
setModalVisible(true) setModalVisible(true)
} }
const handleCreateDir = () => { const handleCreateDir = (node = null) => {
const targetNode = (node && node.key) ? node : selectedNode
const parentPath = getParentPath(targetNode)
setCreationParentPath(parentPath)
setOperationType('create_dir') setOperationType('create_dir')
setModalVisible(true) setModalVisible(true)
} }
@ -202,14 +275,8 @@ function DocumentEditor() {
new_path: newPath, new_path: newPath,
} }
} else { } else {
// - // - 使
if (selectedNode && !selectedNode.isLeaf) { path = creationParentPath ? `${creationParentPath}/${newName}` : newName
//
path = `${selectedNode.key}/${newName}`
} else {
//
path = newName
}
// //
const fileExists = checkFileExists(path) const fileExists = checkFileExists(path)
@ -284,13 +351,15 @@ function DocumentEditor() {
fetchTree() fetchTree()
} }
// // MDPDF
const handleImportDocuments = async (info) => { const handleImportDocuments = async (info) => {
const { fileList } = info const { fileList } = info
const mdFiles = fileList.filter((f) => f.name.endsWith('.md')) const mdFiles = fileList.filter((f) => f.name.endsWith('.md'))
const pdfFiles = fileList.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
const allFiles = [...mdFiles, ...pdfFiles]
if (mdFiles.length === 0) { if (allFiles.length === 0) {
message.warning('请选择.md格式的文档') message.warning('请选择.md或.pdf格式的文档')
return return
} }
@ -299,7 +368,7 @@ function DocumentEditor() {
// //
const existingFiles = [] const existingFiles = []
mdFiles.forEach((f) => { allFiles.forEach((f) => {
const filePath = targetPath ? `${targetPath}/${f.name}` : f.name const filePath = targetPath ? `${targetPath}/${f.name}` : f.name
if (checkFileExists(filePath)) { if (checkFileExists(filePath)) {
existingFiles.push(f.name) existingFiles.push(f.name)
@ -323,22 +392,37 @@ function DocumentEditor() {
okText: '覆盖', okText: '覆盖',
cancelText: '取消', cancelText: '取消',
onOk: async () => { onOk: async () => {
await executeImport(mdFiles, targetPath) await executeImport(mdFiles, pdfFiles, targetPath)
}, },
}) })
return return
} }
// //
await executeImport(mdFiles, targetPath) await executeImport(mdFiles, pdfFiles, targetPath)
} }
// //
const executeImport = async (mdFiles, targetPath) => { const executeImport = async (mdFiles, pdfFiles, targetPath) => {
try { try {
let successCount = 0
// MD
if (mdFiles.length > 0) {
const files = mdFiles.map((f) => f.originFileObj) const files = mdFiles.map((f) => f.originFileObj)
await importDocuments(projectId, files, targetPath) await importDocuments(projectId, files, targetPath)
message.success(`成功导入 ${files.length} 个文档`) successCount += files.length
}
// PDF
if (pdfFiles.length > 0) {
for (const pdfFile of pdfFiles) {
await uploadDocument(projectId, pdfFile.originFileObj, targetPath)
successCount++
}
}
message.success(`成功上传 ${successCount} 个文档`)
fetchTree() fetchTree()
// //
if (fileInputRef.current) { if (fileInputRef.current) {
@ -346,7 +430,7 @@ function DocumentEditor() {
} }
} catch (error) { } catch (error) {
console.error('Import error:', error) console.error('Import error:', error)
message.error('导入失败') message.error('上传失败')
} }
} }
@ -461,6 +545,8 @@ function DocumentEditor() {
} }
} }
// PDFPDF
// ByteMD // ByteMD
const plugins = useMemo(() => { const plugins = useMemo(() => {
// //
@ -553,13 +639,32 @@ function DocumentEditor() {
} }
} }
const renderTreeIcon = ({ isLeaf }) => { //
return isLeaf ? <FileOutlined /> : <FolderOutlined /> const getNodeMenuItems = (node) => {
const items = []
//
if (!node.isLeaf) {
items.push(
{
key: 'create_file',
label: '新建文件',
icon: <FileAddOutlined />,
onClick: () => handleCreateFile(node),
},
{
key: 'create_dir',
label: '新建文件夹',
icon: <FolderAddOutlined />,
onClick: () => handleCreateDir(node),
},
{
type: 'divider',
}
)
} }
// items.push(
const getContextMenuItems = (node) => {
const items = [
{ {
key: 'rename', key: 'rename',
label: '重命名', label: '重命名',
@ -578,23 +683,62 @@ function DocumentEditor() {
icon: <DeleteOutlined />, icon: <DeleteOutlined />,
danger: true, danger: true,
onClick: () => handleDelete(node.key), onClick: () => handleDelete(node.key),
}, }
] )
return items return items
} }
// //
const renderTreeTitle = (node) => { const convertTreeToMenuItems = (nodes) => {
return ( return nodes.map((node) => {
// 使Dropdownlabel
// 使 div width: 100%
const labelContent = (
<Dropdown <Dropdown
menu={{ items: getContextMenuItems(node) }} menu={{ items: getNodeMenuItems(node) }}
trigger={['contextMenu']} trigger={['contextMenu']}
> >
<span style={{ userSelect: 'none' }}>{node.title}</span> <div className="tree-node-wrapper">
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
</div>
</Dropdown> </Dropdown>
) )
if (!node.isLeaf) {
//
return {
key: node.key,
label: labelContent,
icon: <FolderOutlined />,
children: node.children ? convertTreeToMenuItems(node.children) : [],
} }
} else if (node.title && node.title.endsWith('.md')) {
// Markdown
return {
key: node.key,
label: labelContent,
icon: <FileTextOutlined />,
}
} else if (node.title && node.title.toLowerCase().endsWith('.pdf')) {
// PDF
return {
key: node.key,
label: labelContent,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
} else {
//
return {
key: node.key,
label: labelContent,
icon: <FileOutlined />,
}
}
}).filter(Boolean)
}
const menuItems = convertTreeToMenuItems(treeData)
return ( return (
<div className="document-editor-page"> <div className="document-editor-page">
@ -605,7 +749,7 @@ function DocumentEditor() {
className="document-sider" className="document-sider"
> >
<div className="sider-header"> <div className="sider-header">
<h3>文档目录</h3> <h3>项目文档编辑模式</h3>
<div className="sider-actions"> <div className="sider-actions">
<Tooltip title="返回浏览"> <Tooltip title="返回浏览">
<Button <Button
@ -633,12 +777,12 @@ function DocumentEditor() {
</Tooltip> </Tooltip>
<Upload <Upload
multiple multiple
accept=".md" accept=".md,.pdf,application/pdf"
showUploadList={false} showUploadList={false}
beforeUpload={() => false} beforeUpload={() => false}
onChange={handleImportDocuments} onChange={handleImportDocuments}
> >
<Tooltip title="导入文档"> <Tooltip title="上传文档">
<Button <Button
type="text" type="text"
size="middle" size="middle"
@ -656,12 +800,13 @@ function DocumentEditor() {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<Tree <Menu
showIcon mode="inline"
icon={renderTreeIcon} selectedKeys={[selectedFile]}
treeData={treeData} openKeys={openKeys}
onSelect={handleSelectFile} onOpenChange={setOpenKeys}
titleRender={renderTreeTitle} items={menuItems}
onClick={handleMenuClick}
className="file-tree" className="file-tree"
/> />
</Sider> </Sider>

View File

@ -60,6 +60,18 @@
border-right: none; border-right: none;
} }
/* 修复文档名过长的显示问题 */
.docs-menu .ant-menu-title-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docs-menu .ant-menu-item,
.docs-menu .ant-menu-submenu-title {
overflow: hidden;
}
.docs-content-layout { .docs-content-layout {
position: relative; position: relative;
height: 100%; height: 100%;
@ -145,6 +157,16 @@
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
min-height: 100%;
}
/* PDF模式下使用全宽 */
.docs-content-wrapper.pdf-mode {
max-width: 100%;
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
} }
.docs-loading { .docs-loading {

View File

@ -1,15 +1,16 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space } from 'antd' import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space } from 'antd'
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, CopyOutlined, LockOutlined } from '@ant-design/icons' import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
import rehypeSlug from 'rehype-slug' import rehypeSlug from 'rehype-slug'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import { getProjectTree, getFileContent } from '@/api/file' import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file'
import { getProjectShareInfo, updateShareSettings } from '@/api/share' import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import PDFViewer from '@/components/PDFViewer/PDFViewer'
import './DocumentPage.css' import './DocumentPage.css'
const { Sider, Content } = Layout const { Sider, Content } = Layout
@ -29,6 +30,10 @@ function DocumentPage() {
const [hasPassword, setHasPassword] = useState(false) const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [userRole, setUserRole] = useState('viewer') // owner/admin/editor/viewer const [userRole, setUserRole] = useState('viewer') // owner/admin/editor/viewer
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
const contentRef = useRef(null) const contentRef = useRef(null)
useEffect(() => { useEffect(() => {
@ -86,6 +91,13 @@ function DocumentPage() {
label: node.title.replace('.md', ''), label: node.title.replace('.md', ''),
icon: <FileTextOutlined />, icon: <FileTextOutlined />,
} }
} else if (node.title && node.title.endsWith('.pdf')) {
// PDF
return {
key: node.key,
label: node.title,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
} }
return null return null
}).filter(Boolean) }).filter(Boolean)
@ -140,8 +152,24 @@ function DocumentPage() {
// //
const handleMenuClick = ({ key }) => { const handleMenuClick = ({ key }) => {
setSelectedFile(key) setSelectedFile(key)
// PDF
if (key.toLowerCase().endsWith('.pdf')) {
// PDF - tokenURL
let url = getDocumentUrl(projectId, key)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(key.split('/').pop())
setViewMode('pdf')
} else {
// Markdown
setViewMode('markdown')
loadMarkdown(key) loadMarkdown(key)
} }
}
// //
const resolveRelativePath = (currentPath, relativePath) => { const resolveRelativePath = (currentPath, relativePath) => {
@ -176,20 +204,23 @@ function DocumentPage() {
return // return //
} }
// // .md .pdf
e.preventDefault() const isMd = href.endsWith('.md')
const isPdf = href.toLowerCase().endsWith('.pdf')
// .md if (!isMd && !isPdf) {
if (!href.endsWith('.md')) { return //
return
} }
//
e.preventDefault()
// href Markdown URL // href Markdown URL
let decodedHref = href let decodedHref = href
try { try {
decodedHref = decodeURIComponent(href) decodedHref = decodeURIComponent(href)
} catch (e) { } catch (e) {
console.warn('href 解码失败,使用原始值:', href) // 使
} }
// //
@ -209,10 +240,25 @@ function DocumentPage() {
setOpenKeys([...new Set([...openKeys, ...allParentPaths])]) setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
} }
// //
setSelectedFile(targetPath) setSelectedFile(targetPath)
if (isPdf) {
// PDFPDF
let url = getDocumentUrl(projectId, targetPath)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(targetPath.split('/').pop())
setViewMode('pdf')
} else {
// Markdown
setViewMode('markdown')
loadMarkdown(targetPath) loadMarkdown(targetPath)
} }
}
// //
const handleEdit = () => { const handleEdit = () => {
@ -289,7 +335,7 @@ function DocumentPage() {
{/* 左侧目录 */} {/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light"> <Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header"> <div className="docs-sider-header">
<h2>项目文档</h2> <h2>项目文档浏览模式</h2>
<div className="docs-sider-actions"> <div className="docs-sider-actions">
{/* 只有 owner/admin/editor 可以编辑 */} {/* 只有 owner/admin/editor 可以编辑 */}
{userRole !== 'viewer' && ( {userRole !== 'viewer' && (
@ -335,11 +381,18 @@ function DocumentPage() {
{/* 右侧内容区 */} {/* 右侧内容区 */}
<Layout className="docs-content-layout"> <Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}> <Content className="docs-content" ref={contentRef}>
<div className="docs-content-wrapper"> <div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? ( {loading ? (
<div className="docs-loading"> <div className="docs-loading">
<Spin size="large" tip="加载中..." /> <Spin size="large">
<div style={{ marginTop: 16 }}>加载中...</div>
</Spin>
</div> </div>
) : viewMode === 'pdf' ? (
<PDFViewer
url={pdfUrl}
filename={pdfFilename}
/>
) : ( ) : (
<div className="markdown-body"> <div className="markdown-body">
<ReactMarkdown <ReactMarkdown
@ -363,7 +416,8 @@ function DocumentPage() {
)} )}
</div> </div>
{/* 返回顶部按钮 */} {/* 返回顶部按钮 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && (
<FloatButton <FloatButton
icon={<VerticalAlignTopOutlined />} icon={<VerticalAlignTopOutlined />}
type="primary" type="primary"
@ -374,10 +428,11 @@ function DocumentPage() {
} }
}} }}
/> />
)}
</Content> </Content>
{/* 右侧TOC面板 */} {/* 右侧TOC面板 - 仅在markdown模式显示 */}
{!tocCollapsed && ( {viewMode === 'markdown' && !tocCollapsed && (
<Sider width={250} theme="light" className="docs-toc-sider"> <Sider width={250} theme="light" className="docs-toc-sider">
<div className="toc-header"> <div className="toc-header">
<h3>文档索引</h3> <h3>文档索引</h3>

View File

@ -42,6 +42,18 @@
border-right: none; border-right: none;
} }
/* 修复文档名过长的显示问题 */
.preview-menu .ant-menu-title-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-menu .ant-menu-item,
.preview-menu .ant-menu-submenu-title {
overflow: hidden;
}
.preview-content-layout { .preview-content-layout {
position: relative; position: relative;
height: 100%; height: 100%;
@ -126,6 +138,16 @@
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
min-height: 100%;
}
/* PDF模式下使用全宽 */
.preview-content-wrapper.pdf-mode {
max-width: 100%;
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
} }
.preview-loading { .preview-loading {

View File

@ -1,14 +1,15 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, message, Drawer, Anchor } from 'antd' import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, message, Drawer, Anchor } from 'antd'
import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, LockOutlined } from '@ant-design/icons' import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
import rehypeSlug from 'rehype-slug' import rehypeSlug from 'rehype-slug'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword } from '@/api/share' import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share'
import PDFViewer from '@/components/PDFViewer/PDFViewer'
import './PreviewPage.css' import './PreviewPage.css'
const { Sider, Content } = Layout const { Sider, Content } = Layout
@ -29,6 +30,10 @@ function PreviewPage() {
const [siderCollapsed, setSiderCollapsed] = useState(false) const [siderCollapsed, setSiderCollapsed] = useState(false)
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false) const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false)
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
const contentRef = useRef(null) const contentRef = useRef(null)
// //
@ -131,6 +136,12 @@ function PreviewPage() {
label: node.title.replace('.md', ''), label: node.title.replace('.md', ''),
icon: <FileTextOutlined />, icon: <FileTextOutlined />,
} }
} else if (node.title && node.title.endsWith('.pdf')) {
return {
key: node.key,
label: node.title,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
} }
return null return null
}).filter(Boolean) }).filter(Boolean)
@ -195,8 +206,37 @@ function PreviewPage() {
// //
const handleMenuClick = ({ key }) => { const handleMenuClick = ({ key }) => {
setSelectedFile(key) setSelectedFile(key)
// PDF
if (key.toLowerCase().endsWith('.pdf')) {
// PDF - 使API
let url = getPreviewDocumentUrl(projectId, key)
const params = []
//
if (accessPassword) {
params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
}
// token
const token = localStorage.getItem('access_token')
if (token) {
params.push(`token=${encodeURIComponent(token)}`)
}
if (params.length > 0) {
url += `?${params.join('&')}`
}
setPdfUrl(url)
setPdfFilename(key.split('/').pop())
setViewMode('pdf')
} else {
// Markdown
setViewMode('markdown')
loadMarkdown(key) loadMarkdown(key)
} }
}
const menuItems = convertTreeToMenuItems(fileTree) const menuItems = convertTreeToMenuItems(fileTree)
@ -268,11 +308,18 @@ function PreviewPage() {
{/* 右侧内容区 */} {/* 右侧内容区 */}
<Layout className="preview-content-layout"> <Layout className="preview-content-layout">
<Content className="preview-content" ref={contentRef}> <Content className="preview-content" ref={contentRef}>
<div className="preview-content-wrapper"> <div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? ( {loading ? (
<div className="preview-loading"> <div className="preview-loading">
<Spin size="large" tip="加载中..." /> <Spin size="large">
<div style={{ marginTop: 16 }}>加载中...</div>
</Spin>
</div> </div>
) : viewMode === 'pdf' ? (
<PDFViewer
url={pdfUrl}
filename={pdfFilename}
/>
) : ( ) : (
<div className="markdown-body"> <div className="markdown-body">
<ReactMarkdown <ReactMarkdown
@ -285,7 +332,8 @@ function PreviewPage() {
)} )}
</div> </div>
{/* 返回顶部按钮 */} {/* 返回顶部按钮 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && (
<FloatButton <FloatButton
icon={<VerticalAlignTopOutlined />} icon={<VerticalAlignTopOutlined />}
type="primary" type="primary"
@ -296,10 +344,11 @@ function PreviewPage() {
} }
}} }}
/> />
)}
</Content> </Content>
{/* 右侧TOC面板仅桌面端显示) */} {/* 右侧TOC面板仅桌面端且markdown模式显示) */}
{!isMobile && !tocCollapsed && ( {!isMobile && viewMode === 'markdown' && !tocCollapsed && (
<Sider width={250} theme="light" className="preview-toc-sider"> <Sider width={250} theme="light" className="preview-toc-sider">
<div className="toc-header"> <div className="toc-header">
<h3>文档索引</h3> <h3>文档索引</h3>

View File

@ -18,15 +18,12 @@ request.interceptors.request.use(
(config) => { (config) => {
// 从 localStorage 获取 token // 从 localStorage 获取 token
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
console.log('[Request] Token from localStorage:', token ? token.substring(0, 20) + '...' : 'null')
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
console.log('[Request] Authorization header set:', config.headers.Authorization.substring(0, 30) + '...')
} }
return config return config
}, },
(error) => { (error) => {
console.error('Request error:', error)
return Promise.reject(error) return Promise.reject(error)
} }
) )
@ -40,7 +37,6 @@ request.interceptors.response.use(
} }
const res = response.data const res = response.data
console.log('[Response] Success:', res)
// 如果返回的状态码不是 200说明有错误 // 如果返回的状态码不是 200说明有错误
if (res.code !== 200) { if (res.code !== 200) {

View File

@ -488,6 +488,78 @@
"@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14" "@jridgewell/sourcemap-codec" "^1.4.14"
"@napi-rs/canvas-android-arm64@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz#4c50ec99916f568ec3109d2ec28ec4f46fff3fa0"
integrity sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==
"@napi-rs/canvas-darwin-arm64@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz#ca7d6448bee00a1e2eaf6fd0601b61132219f93c"
integrity sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==
"@napi-rs/canvas-darwin-x64@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz#846aab350a6ada51478aff8d75c163e5915d600e"
integrity sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==
"@napi-rs/canvas-linux-arm-gnueabihf@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz#c17f688d0131a9831580a72d3d0e12a57fed3571"
integrity sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==
"@napi-rs/canvas-linux-arm64-gnu@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz#57147119bbefcca9c64441e137997855c4fef3c9"
integrity sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==
"@napi-rs/canvas-linux-arm64-musl@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz#4d9fe5ca09ac35fb2b70ee524074ef9613133d08"
integrity sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==
"@napi-rs/canvas-linux-riscv64-gnu@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz#8d7e0fcc1812140afc50ef15e937ab5ffd9b31ac"
integrity sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==
"@napi-rs/canvas-linux-x64-gnu@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz#f0013c14922fd9f66600379fec848a4ac2e5105e"
integrity sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==
"@napi-rs/canvas-linux-x64-musl@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz#52b143c28e5b752fe0eb7d5b73d6754c95117552"
integrity sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==
"@napi-rs/canvas-win32-arm64-msvc@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz#eaca8a65307294071e5c0c66c9ece60c7f9895dc"
integrity sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==
"@napi-rs/canvas-win32-x64-msvc@0.1.88":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz#8bbb4ea013e2386e20cc225bf6f898ba3f32de66"
integrity sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==
"@napi-rs/canvas@^0.1.80":
version "0.1.88"
resolved "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.88.tgz#d76224439324750de02c3455cad755f64d5a6d16"
integrity sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==
optionalDependencies:
"@napi-rs/canvas-android-arm64" "0.1.88"
"@napi-rs/canvas-darwin-arm64" "0.1.88"
"@napi-rs/canvas-darwin-x64" "0.1.88"
"@napi-rs/canvas-linux-arm-gnueabihf" "0.1.88"
"@napi-rs/canvas-linux-arm64-gnu" "0.1.88"
"@napi-rs/canvas-linux-arm64-musl" "0.1.88"
"@napi-rs/canvas-linux-riscv64-gnu" "0.1.88"
"@napi-rs/canvas-linux-x64-gnu" "0.1.88"
"@napi-rs/canvas-linux-x64-musl" "0.1.88"
"@napi-rs/canvas-win32-arm64-msvc" "0.1.88"
"@napi-rs/canvas-win32-x64-msvc" "0.1.88"
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" resolved "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@ -1270,6 +1342,11 @@ classnames@2.x, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classna
resolved "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz" resolved "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz"
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
clsx@^2.0.0:
version "2.1.1"
resolved "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
codemirror-ssr@^0.65.0: codemirror-ssr@^0.65.0:
version "0.65.0" version "0.65.0"
resolved "https://registry.npmmirror.com/codemirror-ssr/-/codemirror-ssr-0.65.0.tgz" resolved "https://registry.npmmirror.com/codemirror-ssr/-/codemirror-ssr-0.65.0.tgz"
@ -1419,7 +1496,7 @@ delayed-stream@~1.0.0:
resolved "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz" resolved "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
dequal@^2.0.0: dequal@^2.0.0, dequal@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz" resolved "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
@ -2713,7 +2790,7 @@ longest-streak@^3.0.0:
resolved "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz" resolved "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz"
integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==
loose-envify@^1.1.0, loose-envify@^1.4.0: loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz" resolved "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@ -2736,6 +2813,16 @@ lru-cache@^5.1.1:
dependencies: dependencies:
yallist "^3.0.2" yallist "^3.0.2"
make-cancellable-promise@^2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz#d582b3ea435205e31653dead33a10bea0696c2fa"
integrity sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==
make-event-props@^2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/make-event-props/-/make-event-props-2.0.0.tgz#41f7a6e96841296d6835aebe94be86c25602f923"
integrity sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==
markdown-table@^3.0.0: markdown-table@^3.0.0:
version "3.0.4" version "3.0.4"
resolved "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz" resolved "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz"
@ -3081,6 +3168,11 @@ mdast-util-to-string@^4.0.0:
dependencies: dependencies:
"@types/mdast" "^4.0.0" "@types/mdast" "^4.0.0"
merge-refs@^2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/merge-refs/-/merge-refs-2.0.0.tgz#0f1a3e902fde05f30f59279ce73d5d82d2f84dfa"
integrity sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==
merge2@^1.3.0: merge2@^1.3.0:
version "1.4.1" version "1.4.1"
resolved "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz" resolved "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz"
@ -3864,6 +3956,13 @@ path-parse@^1.0.7:
resolved "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz" resolved "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
pdfjs-dist@5.4.296:
version "5.4.296"
resolved "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz#b1aa7ded8828f29537bc7cc99c1343c8b3a5d2d6"
integrity sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==
optionalDependencies:
"@napi-rs/canvas" "^0.1.80"
picocolors@^1.1.1: picocolors@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz" resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz"
@ -4371,6 +4470,20 @@ react-markdown@^9.0.1:
unist-util-visit "^5.0.0" unist-util-visit "^5.0.0"
vfile "^6.0.0" vfile "^6.0.0"
react-pdf@^10.2.0:
version "10.2.0"
resolved "https://registry.npmmirror.com/react-pdf/-/react-pdf-10.2.0.tgz#32c7aa301c324daa26bf4c713b99d82b420ebc30"
integrity sha512-zk0DIL31oCh8cuQycM0SJKfwh4Onz0/Nwi6wTOjgtEjWGUY6eM+/vuzvOP3j70qtEULn7m1JtaeGzud1w5fY2Q==
dependencies:
clsx "^2.0.0"
dequal "^2.0.3"
make-cancellable-promise "^2.0.0"
make-event-props "^2.0.0"
merge-refs "^2.0.0"
pdfjs-dist "5.4.296"
tiny-invariant "^1.0.0"
warning "^4.0.0"
react-refresh@^0.17.0: react-refresh@^0.17.0:
version "0.17.0" version "0.17.0"
resolved "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz" resolved "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz"
@ -5014,6 +5127,11 @@ throttle-debounce@^5.0.0, throttle-debounce@^5.0.2:
resolved "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz" resolved "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz"
integrity sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A== integrity sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==
tiny-invariant@^1.0.0:
version "1.3.3"
resolved "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
tinyglobby@^0.2.11: tinyglobby@^0.2.11:
version "0.2.15" version "0.2.15"
resolved "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz" resolved "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz"
@ -5334,6 +5452,13 @@ vite@^5.0.8:
optionalDependencies: optionalDependencies:
fsevents "~2.3.3" fsevents "~2.3.3"
warning@^4.0.0:
version "4.0.3"
resolved "https://registry.npmmirror.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
dependencies:
loose-envify "^1.0.0"
web-namespaces@^2.0.0: web-namespaces@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz" resolved "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz"