0.9.5
parent
f5e1e8871a
commit
9a431fc046
|
|
@ -142,3 +142,228 @@
|
|||
- 使用 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="文件上传成功")
|
||||
|
||||
|
||||
@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}")
|
||||
async def get_asset_file(
|
||||
project_id: int,
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
项目预览相关 API(支持公开和私密项目)
|
||||
"""
|
||||
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 import select
|
||||
from typing import Optional
|
||||
import mimetypes
|
||||
|
||||
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.user import User
|
||||
from app.schemas.response import success_response
|
||||
|
|
@ -165,3 +170,64 @@ async def get_preview_file(
|
|||
content = await storage_service.read_file(file_path)
|
||||
|
||||
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:
|
||||
return success_response(data=[])
|
||||
|
|
@ -83,10 +83,12 @@ async def search_documents(
|
|||
if not project_path.exists() or not project_path.is_dir():
|
||||
continue
|
||||
|
||||
# 查找所有 .md 文件
|
||||
# 查找所有 .md 和 .pdf 文件
|
||||
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 目录中的文件
|
||||
if "_assets" in file_path.parts:
|
||||
continue
|
||||
|
|
@ -95,8 +97,11 @@ async def search_documents(
|
|||
# 获取相对路径
|
||||
relative_path = str(file_path.relative_to(project_path))
|
||||
|
||||
# 获取文件名(不含扩展名)
|
||||
file_name = file_path.stem
|
||||
# 获取文件名(PDF保留扩展名,MD去掉扩展名)
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -279,6 +279,71 @@ class StorageService:
|
|||
except Exception as 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()
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@
|
|||
"axios": "^1.6.2",
|
||||
"bytemd": "^1.22.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-pdf": "^10.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"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 } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目的文档文件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 {
|
||||
padding: 16px;
|
||||
padding: 8px;
|
||||
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 */
|
||||
.document-editor-page {
|
||||
margin: -16px;
|
||||
height: calc(100vh - 64px);
|
||||
width: calc(100% + 32px);
|
||||
display: flex;
|
||||
|
|
@ -55,6 +54,56 @@
|
|||
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 {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
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 {
|
||||
FileOutlined,
|
||||
FolderOutlined,
|
||||
|
|
@ -15,6 +15,8 @@ import {
|
|||
DownloadOutlined,
|
||||
SwapOutlined,
|
||||
FileImageOutlined,
|
||||
FilePdfOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Editor } from '@bytemd/react'
|
||||
import gfm from '@bytemd/plugin-gfm'
|
||||
|
|
@ -32,6 +34,7 @@ import {
|
|||
uploadFile,
|
||||
importDocuments,
|
||||
exportDirectory,
|
||||
uploadDocument,
|
||||
} from '@/api/file'
|
||||
import './DocumentEditor.css'
|
||||
|
||||
|
|
@ -52,9 +55,11 @@ function DocumentEditor() {
|
|||
const [operationType, setOperationType] = useState(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [rightClickNode, setRightClickNode] = useState(null)
|
||||
const [creationParentPath, setCreationParentPath] = useState('')
|
||||
const [moveTargetPath, setMoveTargetPath] = useState('')
|
||||
const [dirOptions, setDirOptions] = useState([])
|
||||
const [editorHeight, setEditorHeight] = useState(600) // 设置初始高度为600px
|
||||
const [openKeys, setOpenKeys] = useState([]) // Menu组件的展开项
|
||||
|
||||
// 在组件挂载后立即计算正确的高度
|
||||
useEffect(() => {
|
||||
|
|
@ -83,7 +88,7 @@ function DocumentEditor() {
|
|||
const tree = data.tree || data || [] // 兼容新旧格式
|
||||
setTreeData(tree)
|
||||
} catch (error) {
|
||||
console.error('Fetch tree error:', error)
|
||||
message.error('加载文件树失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +98,13 @@ function DocumentEditor() {
|
|||
|
||||
if (info.node.isLeaf) {
|
||||
const filePath = selectedKeys[0]
|
||||
|
||||
// 检查是否是PDF文件
|
||||
if (filePath.toLowerCase().endsWith('.pdf')) {
|
||||
message.info('PDF文件请在浏览模式下查看')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
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 () => {
|
||||
if (!selectedFile) {
|
||||
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')
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleCreateDir = () => {
|
||||
const handleCreateDir = (node = null) => {
|
||||
const targetNode = (node && node.key) ? node : selectedNode
|
||||
const parentPath = getParentPath(targetNode)
|
||||
|
||||
setCreationParentPath(parentPath)
|
||||
setOperationType('create_dir')
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
|
@ -202,14 +275,8 @@ function DocumentEditor() {
|
|||
new_path: newPath,
|
||||
}
|
||||
} else {
|
||||
// 创建操作 - 如果选中了目录节点,在该目录下创建
|
||||
if (selectedNode && !selectedNode.isLeaf) {
|
||||
// 在选中的目录下创建
|
||||
path = `${selectedNode.key}/${newName}`
|
||||
} else {
|
||||
// 在根目录创建
|
||||
path = newName
|
||||
}
|
||||
// 创建操作 - 使用预先计算的父目录路径
|
||||
path = creationParentPath ? `${creationParentPath}/${newName}` : newName
|
||||
|
||||
// 检查文件是否已存在
|
||||
const fileExists = checkFileExists(path)
|
||||
|
|
@ -284,13 +351,15 @@ function DocumentEditor() {
|
|||
fetchTree()
|
||||
}
|
||||
|
||||
// 导入文档
|
||||
// 上传文档(支持MD和PDF)
|
||||
const handleImportDocuments = async (info) => {
|
||||
const { fileList } = info
|
||||
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) {
|
||||
message.warning('请选择.md格式的文档')
|
||||
if (allFiles.length === 0) {
|
||||
message.warning('请选择.md或.pdf格式的文档')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -299,7 +368,7 @@ function DocumentEditor() {
|
|||
|
||||
// 检查是否有重名文件
|
||||
const existingFiles = []
|
||||
mdFiles.forEach((f) => {
|
||||
allFiles.forEach((f) => {
|
||||
const filePath = targetPath ? `${targetPath}/${f.name}` : f.name
|
||||
if (checkFileExists(filePath)) {
|
||||
existingFiles.push(f.name)
|
||||
|
|
@ -323,22 +392,37 @@ function DocumentEditor() {
|
|||
okText: '覆盖',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await executeImport(mdFiles, targetPath)
|
||||
await executeImport(mdFiles, pdfFiles, targetPath)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 没有重名文件,直接导入
|
||||
await executeImport(mdFiles, targetPath)
|
||||
await executeImport(mdFiles, pdfFiles, targetPath)
|
||||
}
|
||||
|
||||
// 执行导入操作
|
||||
const executeImport = async (mdFiles, targetPath) => {
|
||||
const executeImport = async (mdFiles, pdfFiles, targetPath) => {
|
||||
try {
|
||||
const files = mdFiles.map((f) => f.originFileObj)
|
||||
await importDocuments(projectId, files, targetPath)
|
||||
message.success(`成功导入 ${files.length} 个文档`)
|
||||
let successCount = 0
|
||||
|
||||
// 上传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()
|
||||
// 清除文件选择
|
||||
if (fileInputRef.current) {
|
||||
|
|
@ -346,7 +430,7 @@ function DocumentEditor() {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error)
|
||||
message.error('导入失败')
|
||||
message.error('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -461,6 +545,8 @@ function DocumentEditor() {
|
|||
}
|
||||
}
|
||||
|
||||
// 处理PDF上传(已移除,PDF应该通过侧边栏上传)
|
||||
|
||||
// ByteMD 插件配置
|
||||
const plugins = useMemo(() => {
|
||||
// 自定义图片上传插件
|
||||
|
|
@ -553,13 +639,32 @@ function DocumentEditor() {
|
|||
}
|
||||
}
|
||||
|
||||
const renderTreeIcon = ({ isLeaf }) => {
|
||||
return isLeaf ? <FileOutlined /> : <FolderOutlined />
|
||||
}
|
||||
// 获取指定节点的右键菜单项
|
||||
const getNodeMenuItems = (node) => {
|
||||
const items = []
|
||||
|
||||
// 右键菜单项
|
||||
const getContextMenuItems = (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(
|
||||
{
|
||||
key: 'rename',
|
||||
label: '重命名',
|
||||
|
|
@ -578,24 +683,63 @@ function DocumentEditor() {
|
|||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDelete(node.key),
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// 渲染树节点标题
|
||||
const renderTreeTitle = (node) => {
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items: getContextMenuItems(node) }}
|
||||
trigger={['contextMenu']}
|
||||
>
|
||||
<span style={{ userSelect: 'none' }}>{node.title}</span>
|
||||
</Dropdown>
|
||||
)
|
||||
// 转换文件树为菜单项
|
||||
const convertTreeToMenuItems = (nodes) => {
|
||||
return nodes.map((node) => {
|
||||
// 使用Dropdown包裹label,实现右键菜单
|
||||
// 使用 div 和 width: 100% 增加点击区域
|
||||
const labelContent = (
|
||||
<Dropdown
|
||||
menu={{ items: getNodeMenuItems(node) }}
|
||||
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 (
|
||||
<div className="document-editor-page">
|
||||
<Layout className="document-editor-container">
|
||||
|
|
@ -605,7 +749,7 @@ function DocumentEditor() {
|
|||
className="document-sider"
|
||||
>
|
||||
<div className="sider-header">
|
||||
<h3>文档目录</h3>
|
||||
<h3>项目文档(编辑模式)</h3>
|
||||
<div className="sider-actions">
|
||||
<Tooltip title="返回浏览">
|
||||
<Button
|
||||
|
|
@ -633,12 +777,12 @@ function DocumentEditor() {
|
|||
</Tooltip>
|
||||
<Upload
|
||||
multiple
|
||||
accept=".md"
|
||||
accept=".md,.pdf,application/pdf"
|
||||
showUploadList={false}
|
||||
beforeUpload={() => false}
|
||||
onChange={handleImportDocuments}
|
||||
>
|
||||
<Tooltip title="导入文档">
|
||||
<Tooltip title="上传文档">
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
|
|
@ -656,12 +800,13 @@ function DocumentEditor() {
|
|||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Tree
|
||||
showIcon
|
||||
icon={renderTreeIcon}
|
||||
treeData={treeData}
|
||||
onSelect={handleSelectFile}
|
||||
titleRender={renderTreeTitle}
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedFile]}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={setOpenKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="file-tree"
|
||||
/>
|
||||
</Sider>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,18 @@
|
|||
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 {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
|
@ -145,6 +157,16 @@
|
|||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
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 remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
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 PDFViewer from '@/components/PDFViewer/PDFViewer'
|
||||
import './DocumentPage.css'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
|
@ -29,6 +30,10 @@ function DocumentPage() {
|
|||
const [hasPassword, setHasPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -86,6 +91,13 @@ function DocumentPage() {
|
|||
label: node.title.replace('.md', ''),
|
||||
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
|
||||
}).filter(Boolean)
|
||||
|
|
@ -140,7 +152,23 @@ function DocumentPage() {
|
|||
// 处理菜单点击
|
||||
const handleMenuClick = ({ 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 // 锚点链接,允许默认行为
|
||||
}
|
||||
|
||||
// 其他所有链接都视为内部文档链接,阻止默认跳转
|
||||
e.preventDefault()
|
||||
// 检查是否是文档文件(.md 或 .pdf)
|
||||
const isMd = href.endsWith('.md')
|
||||
const isPdf = href.toLowerCase().endsWith('.pdf')
|
||||
|
||||
// 如果不是 .md 文件,忽略
|
||||
if (!href.endsWith('.md')) {
|
||||
return
|
||||
if (!isMd && !isPdf) {
|
||||
return // 不是文档文件,允许默认行为
|
||||
}
|
||||
|
||||
// 阻止默认跳转
|
||||
e.preventDefault()
|
||||
|
||||
// 先解码 href(因为 Markdown 中的链接可能已经是 URL 编码的)
|
||||
let decodedHref = href
|
||||
try {
|
||||
decodedHref = decodeURIComponent(href)
|
||||
} catch (e) {
|
||||
console.warn('href 解码失败,使用原始值:', href)
|
||||
// 解码失败,使用原始值
|
||||
}
|
||||
|
||||
// 解析相对路径
|
||||
|
|
@ -209,9 +240,24 @@ function DocumentPage() {
|
|||
setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
|
||||
}
|
||||
|
||||
// 加载目标文件
|
||||
// 选中文件并加载
|
||||
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">
|
||||
<div className="docs-sider-header">
|
||||
<h2>项目文档</h2>
|
||||
<h2>项目文档(浏览模式)</h2>
|
||||
<div className="docs-sider-actions">
|
||||
{/* 只有 owner/admin/editor 可以编辑 */}
|
||||
{userRole !== 'viewer' && (
|
||||
|
|
@ -335,11 +381,18 @@ function DocumentPage() {
|
|||
{/* 右侧内容区 */}
|
||||
<Layout className="docs-content-layout">
|
||||
<Content className="docs-content" ref={contentRef}>
|
||||
<div className="docs-content-wrapper">
|
||||
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
|
||||
{loading ? (
|
||||
<div className="docs-loading">
|
||||
<Spin size="large" tip="加载中..." />
|
||||
<Spin size="large">
|
||||
<div style={{ marginTop: 16 }}>加载中...</div>
|
||||
</Spin>
|
||||
</div>
|
||||
) : viewMode === 'pdf' ? (
|
||||
<PDFViewer
|
||||
url={pdfUrl}
|
||||
filename={pdfFilename}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
|
|
@ -363,21 +416,23 @@ function DocumentPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 返回顶部按钮 */}
|
||||
<FloatButton
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
type="primary"
|
||||
style={{ right: tocCollapsed ? 24 : 280 }}
|
||||
onClick={() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* 返回顶部按钮 - 仅在markdown模式显示 */}
|
||||
{viewMode === 'markdown' && (
|
||||
<FloatButton
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
type="primary"
|
||||
style={{ right: tocCollapsed ? 24 : 280 }}
|
||||
onClick={() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
|
||||
{/* 右侧TOC面板 */}
|
||||
{!tocCollapsed && (
|
||||
{/* 右侧TOC面板 - 仅在markdown模式显示 */}
|
||||
{viewMode === 'markdown' && !tocCollapsed && (
|
||||
<Sider width={250} theme="light" className="docs-toc-sider">
|
||||
<div className="toc-header">
|
||||
<h3>文档索引</h3>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,18 @@
|
|||
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 {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
|
@ -126,6 +138,16 @@
|
|||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
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 remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
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'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
|
@ -29,6 +30,10 @@ function PreviewPage() {
|
|||
const [siderCollapsed, setSiderCollapsed] = useState(false)
|
||||
const [mobileDrawerVisible, setMobileDrawerVisible] = 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)
|
||||
|
||||
// 检测是否为移动设备
|
||||
|
|
@ -131,6 +136,12 @@ function PreviewPage() {
|
|||
label: node.title.replace('.md', ''),
|
||||
icon: <FileTextOutlined />,
|
||||
}
|
||||
} else if (node.title && node.title.endsWith('.pdf')) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title,
|
||||
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter(Boolean)
|
||||
|
|
@ -195,7 +206,36 @@ function PreviewPage() {
|
|||
// 处理菜单点击
|
||||
const handleMenuClick = ({ 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)
|
||||
|
|
@ -268,11 +308,18 @@ function PreviewPage() {
|
|||
{/* 右侧内容区 */}
|
||||
<Layout className="preview-content-layout">
|
||||
<Content className="preview-content" ref={contentRef}>
|
||||
<div className="preview-content-wrapper">
|
||||
<div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
|
||||
{loading ? (
|
||||
<div className="preview-loading">
|
||||
<Spin size="large" tip="加载中..." />
|
||||
<Spin size="large">
|
||||
<div style={{ marginTop: 16 }}>加载中...</div>
|
||||
</Spin>
|
||||
</div>
|
||||
) : viewMode === 'pdf' ? (
|
||||
<PDFViewer
|
||||
url={pdfUrl}
|
||||
filename={pdfFilename}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
|
|
@ -285,21 +332,23 @@ function PreviewPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 返回顶部按钮 */}
|
||||
<FloatButton
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
type="primary"
|
||||
style={{ right: tocCollapsed || isMobile ? 24 : 280 }}
|
||||
onClick={() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* 返回顶部按钮 - 仅在markdown模式显示 */}
|
||||
{viewMode === 'markdown' && (
|
||||
<FloatButton
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
type="primary"
|
||||
style={{ right: tocCollapsed || isMobile ? 24 : 280 }}
|
||||
onClick={() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
|
||||
{/* 右侧TOC面板(仅桌面端显示) */}
|
||||
{!isMobile && !tocCollapsed && (
|
||||
{/* 右侧TOC面板(仅桌面端且markdown模式显示) */}
|
||||
{!isMobile && viewMode === 'markdown' && !tocCollapsed && (
|
||||
<Sider width={250} theme="light" className="preview-toc-sider">
|
||||
<div className="toc-header">
|
||||
<h3>文档索引</h3>
|
||||
|
|
|
|||
|
|
@ -18,15 +18,12 @@ request.interceptors.request.use(
|
|||
(config) => {
|
||||
// 从 localStorage 获取 token
|
||||
const token = localStorage.getItem('access_token')
|
||||
console.log('[Request] Token from localStorage:', token ? token.substring(0, 20) + '...' : 'null')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
console.log('[Request] Authorization header set:', config.headers.Authorization.substring(0, 30) + '...')
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('Request error:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
|
@ -40,7 +37,6 @@ request.interceptors.response.use(
|
|||
}
|
||||
|
||||
const res = response.data
|
||||
console.log('[Response] Success:', res)
|
||||
|
||||
// 如果返回的状态码不是 200,说明有错误
|
||||
if (res.code !== 200) {
|
||||
|
|
|
|||
|
|
@ -488,6 +488,78 @@
|
|||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@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":
|
||||
version "2.1.5"
|
||||
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"
|
||||
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:
|
||||
version "0.65.0"
|
||||
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"
|
||||
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
|
||||
|
||||
dequal@^2.0.0:
|
||||
dequal@^2.0.0, dequal@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz"
|
||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||
|
|
@ -2736,6 +2813,16 @@ lru-cache@^5.1.1:
|
|||
dependencies:
|
||||
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:
|
||||
version "3.0.4"
|
||||
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:
|
||||
"@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:
|
||||
version "1.4.1"
|
||||
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"
|
||||
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:
|
||||
version "1.1.1"
|
||||
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"
|
||||
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:
|
||||
version "0.17.0"
|
||||
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"
|
||||
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:
|
||||
version "0.2.15"
|
||||
resolved "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz"
|
||||
|
|
@ -5334,6 +5452,13 @@ vite@^5.0.8:
|
|||
optionalDependencies:
|
||||
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:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz"
|
||||
|
|
|
|||
Loading…
Reference in New Issue