0.9.5
parent
f5e1e8871a
commit
9a431fc046
|
|
@ -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文件是否需要版本控制?建议:暂不支持
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档文件URL(PDF等)
|
||||||
|
*/
|
||||||
|
export function getDocumentUrl(projectId, path) {
|
||||||
|
// 将路径的每个部分分别编码,但保留斜杠
|
||||||
|
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||||
|
return `/api/v1/files/${projectId}/document/${encodedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,3 +69,13 @@ export function getPreviewFile(projectId, path, password = null) {
|
||||||
headers: password ? { 'X-Access-Password': password } : {},
|
headers: password ? { 'X-Access-Password': password } : {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预览项目的文档文件URL(PDF等)
|
||||||
|
*/
|
||||||
|
export function getPreviewDocumentUrl(projectId, path) {
|
||||||
|
// 将路径的每个部分分别编码,但保留斜杠
|
||||||
|
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||||
|
return `/api/v1/preview/${projectId}/document/${encodedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
padding: 16px;
|
padding: 8px;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu点击处理(适配Menu组件)
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导入文档
|
// 上传文档(支持MD和PDF)
|
||||||
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 {
|
||||||
const files = mdFiles.map((f) => f.originFileObj)
|
let successCount = 0
|
||||||
await importDocuments(projectId, files, targetPath)
|
|
||||||
message.success(`成功导入 ${files.length} 个文档`)
|
// 上传MD文件
|
||||||
|
if (mdFiles.length > 0) {
|
||||||
|
const files = mdFiles.map((f) => f.originFileObj)
|
||||||
|
await importDocuments(projectId, files, targetPath)
|
||||||
|
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() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理PDF上传(已移除,PDF应该通过侧边栏上传)
|
||||||
|
|
||||||
// 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 = []
|
||||||
|
|
||||||
// 右键菜单项
|
// 只有目录才显示新建操作
|
||||||
const getContextMenuItems = (node) => {
|
if (!node.isLeaf) {
|
||||||
const items = [
|
items.push(
|
||||||
|
{
|
||||||
|
key: 'create_file',
|
||||||
|
label: '新建文件',
|
||||||
|
icon: <FileAddOutlined />,
|
||||||
|
onClick: () => handleCreateFile(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'create_dir',
|
||||||
|
label: '新建文件夹',
|
||||||
|
icon: <FolderAddOutlined />,
|
||||||
|
onClick: () => handleCreateDir(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(
|
||||||
{
|
{
|
||||||
key: 'rename',
|
key: 'rename',
|
||||||
label: '重命名',
|
label: '重命名',
|
||||||
|
|
@ -578,24 +683,63 @@ 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) => {
|
||||||
<Dropdown
|
// 使用Dropdown包裹label,实现右键菜单
|
||||||
menu={{ items: getContextMenuItems(node) }}
|
// 使用 div 和 width: 100% 增加点击区域
|
||||||
trigger={['contextMenu']}
|
const labelContent = (
|
||||||
>
|
<Dropdown
|
||||||
<span style={{ userSelect: 'none' }}>{node.title}</span>
|
menu={{ items: getNodeMenuItems(node) }}
|
||||||
</Dropdown>
|
trigger={['contextMenu']}
|
||||||
)
|
>
|
||||||
|
<div className="tree-node-wrapper">
|
||||||
|
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
<Layout className="document-editor-container">
|
<Layout className="document-editor-container">
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,7 +152,23 @@ function DocumentPage() {
|
||||||
// 处理菜单点击
|
// 处理菜单点击
|
||||||
const handleMenuClick = ({ key }) => {
|
const handleMenuClick = ({ key }) => {
|
||||||
setSelectedFile(key)
|
setSelectedFile(key)
|
||||||
loadMarkdown(key)
|
|
||||||
|
// 检查是否是PDF文件
|
||||||
|
if (key.toLowerCase().endsWith('.pdf')) {
|
||||||
|
// 显示PDF - 添加token到URL
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析相对路径
|
// 解析相对路径
|
||||||
|
|
@ -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,9 +240,24 @@ function DocumentPage() {
|
||||||
setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
|
setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载目标文件
|
// 选中文件并加载
|
||||||
setSelectedFile(targetPath)
|
setSelectedFile(targetPath)
|
||||||
loadMarkdown(targetPath)
|
|
||||||
|
if (isPdf) {
|
||||||
|
// PDF文件:切换到PDF模式
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进入编辑模式
|
// 进入编辑模式
|
||||||
|
|
@ -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,21 +416,23 @@ function DocumentPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 返回顶部按钮 */}
|
{/* 返回顶部按钮 - 仅在markdown模式显示 */}
|
||||||
<FloatButton
|
{viewMode === 'markdown' && (
|
||||||
icon={<VerticalAlignTopOutlined />}
|
<FloatButton
|
||||||
type="primary"
|
icon={<VerticalAlignTopOutlined />}
|
||||||
style={{ right: tocCollapsed ? 24 : 280 }}
|
type="primary"
|
||||||
onClick={() => {
|
style={{ right: tocCollapsed ? 24 : 280 }}
|
||||||
if (contentRef.current) {
|
onClick={() => {
|
||||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
if (contentRef.current) {
|
||||||
}
|
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,7 +206,36 @@ function PreviewPage() {
|
||||||
// 处理菜单点击
|
// 处理菜单点击
|
||||||
const handleMenuClick = ({ key }) => {
|
const handleMenuClick = ({ key }) => {
|
||||||
setSelectedFile(key)
|
setSelectedFile(key)
|
||||||
loadMarkdown(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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,21 +332,23 @@ function PreviewPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 返回顶部按钮 */}
|
{/* 返回顶部按钮 - 仅在markdown模式显示 */}
|
||||||
<FloatButton
|
{viewMode === 'markdown' && (
|
||||||
icon={<VerticalAlignTopOutlined />}
|
<FloatButton
|
||||||
type="primary"
|
icon={<VerticalAlignTopOutlined />}
|
||||||
style={{ right: tocCollapsed || isMobile ? 24 : 280 }}
|
type="primary"
|
||||||
onClick={() => {
|
style={{ right: tocCollapsed || isMobile ? 24 : 280 }}
|
||||||
if (contentRef.current) {
|
onClick={() => {
|
||||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
if (contentRef.current) {
|
||||||
}
|
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue