v0.9.7
parent
f140ed9218
commit
2d41a3ba2f
|
|
@ -30,3 +30,6 @@ logs/
|
|||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# AI
|
||||
.gemini-clipboard/
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
API v1 路由汇总
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications
|
||||
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, llm_model_configs
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
|
@ -20,3 +20,4 @@ api_router.include_router(users.router, prefix="/users", tags=["用户管理"])
|
|||
api_router.include_router(roles.router, prefix="/roles", tags=["角色管理"])
|
||||
api_router.include_router(search.router, prefix="/search", tags=["文档搜索"])
|
||||
api_router.include_router(logs.router, prefix="/logs", tags=["系统日志"])
|
||||
api_router.include_router(llm_model_configs.router, prefix="/llm-model-configs", tags=["LLM 模型配置"])
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
import os
|
||||
import zipfile
|
||||
|
|
@ -12,11 +11,11 @@ import io
|
|||
import mimetypes
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, get_user_from_token_or_query
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.models.log import OperationLog
|
||||
from app.schemas.file import (
|
||||
FileTreeNode,
|
||||
|
|
@ -25,54 +24,19 @@ from app.schemas.file import (
|
|||
FileUploadResponse,
|
||||
)
|
||||
from app.schemas.response import success_response
|
||||
from app.services.project_file_service import project_file_service
|
||||
from app.services.storage import storage_service
|
||||
from app.services.log_service import log_service
|
||||
from app.services.notification_service import notification_service
|
||||
from app.services.search_service import search_service
|
||||
from app.services.project_service import (
|
||||
get_project_or_404,
|
||||
require_project_read_access,
|
||||
require_project_write_access,
|
||||
)
|
||||
from app.core.enums import OperationType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def check_project_access(
|
||||
project_id: int,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
require_write: bool = False
|
||||
):
|
||||
"""检查项目访问权限"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
|
||||
# 检查是否是项目所有者
|
||||
if project.owner_id == current_user.id:
|
||||
return project
|
||||
|
||||
# 检查是否是项目成员
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
if project.is_public == 1 and not require_write:
|
||||
return project
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
# 如果需要写权限,检查成员角色
|
||||
if require_write and member.role == "viewer":
|
||||
raise HTTPException(status_code=403, detail="无写入权限")
|
||||
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/{project_id}/tree", response_model=dict)
|
||||
async def get_project_tree(
|
||||
project_id: int,
|
||||
|
|
@ -80,7 +44,7 @@ async def get_project_tree(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目目录树"""
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
project, user_role = await require_project_read_access(db, project_id, current_user)
|
||||
|
||||
# 获取项目根目录
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
|
|
@ -88,20 +52,6 @@ async def get_project_tree(
|
|||
# 生成目录树
|
||||
tree = storage_service.generate_tree(project_root)
|
||||
|
||||
# 获取当前用户角色
|
||||
user_role = "owner" # 默认是所有者
|
||||
if project.owner_id != current_user.id:
|
||||
# 查询成员角色
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if member:
|
||||
user_role = member.role
|
||||
|
||||
return success_response(data={
|
||||
"tree": tree,
|
||||
"user_role": user_role,
|
||||
|
|
@ -118,7 +68,7 @@ async def get_file_content(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取文件内容"""
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
project, _ = await require_project_read_access(db, project_id, current_user)
|
||||
|
||||
# 获取文件路径
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
|
|
@ -138,43 +88,17 @@ async def save_file(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""保存文件内容"""
|
||||
project = await check_project_access(project_id, current_user, db, require_write=True)
|
||||
|
||||
# 获取文件路径
|
||||
file_path = storage_service.get_secure_path(project.storage_key, file_data.path)
|
||||
|
||||
# 写入文件内容
|
||||
await storage_service.write_file(file_path, file_data.content)
|
||||
|
||||
# 更新搜索索引 (仅限 Markdown)
|
||||
if file_data.path.endswith('.md'):
|
||||
file_title = Path(file_data.path).stem
|
||||
await search_service.update_doc(project_id, file_data.path, file_title, file_data.content)
|
||||
|
||||
# 记录操作日志
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.SAVE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=file_data.path,
|
||||
user=current_user,
|
||||
detail={"content_length": len(file_data.content)},
|
||||
project, _ = await require_project_write_access(db, project_id, current_user)
|
||||
message = await project_file_service.save_file(
|
||||
db,
|
||||
project_id,
|
||||
project,
|
||||
file_data.path,
|
||||
file_data.content,
|
||||
current_user,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# 发送通知给其他成员
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title=f"项目文档更新",
|
||||
content=f"项目 [{project.name}] 中的文档 [{file_data.path}] 已被 {current_user.nickname or current_user.username} 更新。",
|
||||
link=f"/projects/{project_id}/docs?file={file_data.path}",
|
||||
category="project"
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return success_response(message="文件保存成功")
|
||||
return success_response(message=message)
|
||||
|
||||
|
||||
@router.post("/{project_id}/file/operate", response_model=dict)
|
||||
|
|
@ -186,185 +110,19 @@ async def operate_file(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""文件操作(重命名、删除、创建目录、创建文件、移动)"""
|
||||
project = await check_project_access(project_id, current_user, db, require_write=True)
|
||||
|
||||
# 获取当前路径
|
||||
current_path = storage_service.get_secure_path(project.storage_key, operation.path)
|
||||
|
||||
if operation.action == "delete":
|
||||
# 删除文件或文件夹
|
||||
await storage_service.delete_file(current_path)
|
||||
|
||||
# 删除索引
|
||||
if operation.path.endswith('.md'):
|
||||
await search_service.remove_doc(project_id, operation.path)
|
||||
|
||||
# 记录日志
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.DELETE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=operation.path,
|
||||
user=current_user,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# 发送通知
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title=f"项目文档删除",
|
||||
content=f"项目 [{project.name}] 中的文档/目录 [{operation.path}] 已被 {current_user.nickname or current_user.username} 删除。",
|
||||
category="project"
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(message="删除成功")
|
||||
|
||||
elif operation.action == "rename":
|
||||
# 重命名
|
||||
if not operation.new_path:
|
||||
raise HTTPException(status_code=400, detail="缺少新路径参数")
|
||||
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
|
||||
await storage_service.rename_file(current_path, new_path)
|
||||
|
||||
# 更新索引 (删除旧的,添加新的 - 如果内容未变也需要重新读取内容吗?
|
||||
# 优化:Whoosh 更新需要内容。我们可以尝试读取文件内容。
|
||||
# 如果是目录重命名,比较复杂,暂时忽略目录重命名的递归索引更新,或者后续实现重建索引功能)
|
||||
if operation.path.endswith('.md') and operation.new_path.endswith('.md'):
|
||||
# 简单处理:读取新文件内容并更新索引
|
||||
try:
|
||||
content = await storage_service.read_file(new_path)
|
||||
file_title = Path(operation.new_path).stem
|
||||
await search_service.remove_doc(project_id, operation.path)
|
||||
await search_service.update_doc(project_id, operation.new_path, file_title, content)
|
||||
except Exception as e:
|
||||
# 忽略索引更新错误
|
||||
pass
|
||||
|
||||
# 记录日志
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.RENAME_FILE,
|
||||
project_id=project_id,
|
||||
file_path=operation.path,
|
||||
user=current_user,
|
||||
detail={"new_path": operation.new_path},
|
||||
request=request,
|
||||
)
|
||||
|
||||
# 发送通知
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title=f"项目文档重命名",
|
||||
content=f"项目 [{project.name}] 中的文档 [{operation.path}] 已被重命名为 [{operation.new_path}]。",
|
||||
category="project"
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(message="重命名成功")
|
||||
|
||||
elif operation.action == "move":
|
||||
# 移动文件或文件夹
|
||||
if not operation.new_path:
|
||||
raise HTTPException(status_code=400, detail="缺少目标路径参数")
|
||||
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
|
||||
await storage_service.rename_file(current_path, new_path)
|
||||
|
||||
# 更新索引
|
||||
if operation.path.endswith('.md') and operation.new_path.endswith('.md'):
|
||||
try:
|
||||
content = await storage_service.read_file(new_path)
|
||||
file_title = Path(operation.new_path).stem
|
||||
await search_service.remove_doc(project_id, operation.path)
|
||||
await search_service.update_doc(project_id, operation.new_path, file_title, content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 记录日志
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.MOVE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=operation.path,
|
||||
user=current_user,
|
||||
detail={"new_path": operation.new_path},
|
||||
request=request,
|
||||
)
|
||||
|
||||
# 发送通知
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title=f"项目文档移动",
|
||||
content=f"项目 [{project.name}] 中的文档 [{operation.path}] 已移动到 [{operation.new_path}]。",
|
||||
category="project"
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(message="移动成功")
|
||||
|
||||
elif operation.action == "create_dir":
|
||||
# 创建目录
|
||||
await storage_service.create_directory(current_path)
|
||||
# 记录日志
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.CREATE_DIR,
|
||||
project_id=project_id,
|
||||
file_path=operation.path,
|
||||
user=current_user,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# 发送通知
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title=f"创建新目录",
|
||||
content=f"{current_user.nickname or current_user.username} 在项目 [{project.name}] 中创建了新目录 [{operation.path}]。",
|
||||
category="project"
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(message="目录创建成功")
|
||||
|
||||
elif operation.action == "create_file":
|
||||
# 创建文件
|
||||
content = operation.content or ""
|
||||
await storage_service.write_file(current_path, content)
|
||||
|
||||
# 更新索引
|
||||
if operation.path.endswith('.md'):
|
||||
file_title = Path(operation.path).stem
|
||||
await search_service.update_doc(project_id, operation.path, file_title, content)
|
||||
|
||||
# 记录日志
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.CREATE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=operation.path,
|
||||
user=current_user,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# 发送通知
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title=f"创建新文档",
|
||||
content=f"{current_user.nickname or current_user.username} 在项目 [{project.name}] 中创建了新文档 [{operation.path}]。",
|
||||
link=f"/projects/{project_id}/docs?file={operation.path}",
|
||||
category="project"
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(message="文件创建成功")
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="不支持的操作类型")
|
||||
project, _ = await require_project_write_access(db, project_id, current_user)
|
||||
message = await project_file_service.operate_file(
|
||||
db,
|
||||
project_id,
|
||||
project,
|
||||
operation.action,
|
||||
operation.path,
|
||||
current_user,
|
||||
new_path=operation.new_path,
|
||||
content=operation.content,
|
||||
request=request,
|
||||
)
|
||||
return success_response(message=message)
|
||||
|
||||
|
||||
@router.post("/{project_id}/upload", response_model=dict)
|
||||
|
|
@ -377,7 +135,7 @@ async def upload_file(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""上传文件(图片/附件)"""
|
||||
project = await check_project_access(project_id, current_user, db, require_write=True)
|
||||
project, _ = await require_project_write_access(db, project_id, current_user)
|
||||
|
||||
# 上传文件
|
||||
file_info = await storage_service.upload_file(
|
||||
|
|
@ -418,7 +176,7 @@ async def upload_document(
|
|||
"""
|
||||
上传文档文件(PDF等)到项目目录
|
||||
"""
|
||||
project = await check_project_access(project_id, current_user, db, require_write=True)
|
||||
project, _ = await require_project_write_access(db, project_id, current_user)
|
||||
|
||||
# 只允许PDF文件
|
||||
allowed_extensions = [".pdf"]
|
||||
|
|
@ -463,7 +221,7 @@ async def get_document_file(
|
|||
import re
|
||||
import aiofiles
|
||||
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
project, _ = await require_project_read_access(db, project_id, current_user)
|
||||
|
||||
# 获取文件路径
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
|
|
@ -534,12 +292,7 @@ async def get_asset_file(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取资源文件(公开访问,支持分享)"""
|
||||
# 验证项目是否存在
|
||||
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="项目不存在")
|
||||
project = await get_project_or_404(db, project_id)
|
||||
|
||||
# 获取文件路径
|
||||
asset_path = f"_assets/{subfolder}/{filename}"
|
||||
|
|
@ -571,7 +324,7 @@ async def import_documents(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""批量导入Markdown文档"""
|
||||
project = await check_project_access(project_id, current_user, db, require_write=True)
|
||||
project, _ = await require_project_write_access(db, project_id, current_user)
|
||||
|
||||
# 验证所有文件都是.md格式
|
||||
for file in files:
|
||||
|
|
@ -636,12 +389,12 @@ async def export_directory(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""导出目录为ZIP包"""
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
project, _ = await require_project_read_access(db, project_id, current_user)
|
||||
|
||||
# 获取目标目录路径
|
||||
source_dir = storage_service.get_secure_path(project.storage_key, directory_path)
|
||||
|
||||
if not source_dir.exists():
|
||||
if not source_dir.exists() or not source_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail="目录不存在")
|
||||
|
||||
# 创建ZIP文件在内存中
|
||||
|
|
@ -661,7 +414,13 @@ async def export_directory(
|
|||
zip_buffer.seek(0)
|
||||
|
||||
# 生成ZIP文件名
|
||||
zip_filename = f"{project.name}_{directory_path.replace('/', '_') if directory_path else 'root'}.zip"
|
||||
safe_project_name = project.name.replace("/", "_").replace("\\", "_")
|
||||
zip_filename = (
|
||||
f"{safe_project_name}.zip"
|
||||
if not directory_path
|
||||
else f"{safe_project_name}_{directory_path.replace('/', '_')}.zip"
|
||||
)
|
||||
encoded_zip_filename = quote(zip_filename)
|
||||
|
||||
# 记录日志
|
||||
await log_service.log_file_operation(
|
||||
|
|
@ -681,7 +440,7 @@ async def export_directory(
|
|||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={zip_filename}"
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_zip_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -696,10 +455,9 @@ async def export_pdf(
|
|||
):
|
||||
"""已登录用户导出 Markdown 为 PDF"""
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
from app.services.pdf_service import pdf_service
|
||||
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
project, _ = await require_project_read_access(db, project_id, current_user)
|
||||
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
|
|
@ -712,7 +470,10 @@ async def export_pdf(
|
|||
content = re.sub(r'/api/v1/files/\d+/assets/', '_assets/', content)
|
||||
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
|
||||
try:
|
||||
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
encoded_filename = quote(filename)
|
||||
return StreamingResponse(
|
||||
|
|
@ -722,4 +483,4 @@ async def export_pdf(
|
|||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||
"Content-Type": "application/pdf"
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,49 +4,20 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func
|
||||
from typing import List
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.models.git_repo import ProjectGitRepo
|
||||
from app.schemas.git_repo import GitRepoCreate, GitRepoUpdate, GitRepoResponse
|
||||
from app.schemas.response import success_response
|
||||
from app.services.log_service import log_service
|
||||
from app.services.project_service import require_project_read_access, require_project_roles
|
||||
from app.core.enums import OperationType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def check_project_permission(db: AsyncSession, project_id: int, user_id: int, required_roles: list = None):
|
||||
"""检查项目权限"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
|
||||
# 如果是所有者,直接通过
|
||||
if project.owner_id == user_id:
|
||||
return project
|
||||
|
||||
# 如果指定了角色要求
|
||||
if required_roles:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
ProjectMember.role.in_(required_roles)
|
||||
)
|
||||
)
|
||||
if not member_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权执行此操作")
|
||||
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/git-repos", response_model=dict)
|
||||
async def get_project_git_repos(
|
||||
project_id: int,
|
||||
|
|
@ -54,10 +25,7 @@ async def get_project_git_repos(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目的Git仓库列表"""
|
||||
# 检查权限(查看权限即可)
|
||||
# 这里稍微放宽一点,只要能访问项目就能看Git配置?
|
||||
# 为了安全,还是限制为成员
|
||||
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor', 'viewer'])
|
||||
await require_project_read_access(db, project_id, current_user)
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id).order_by(ProjectGitRepo.created_at)
|
||||
|
|
@ -83,7 +51,12 @@ async def create_git_repo(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""添加Git仓库"""
|
||||
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor'])
|
||||
await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=["admin", "editor"],
|
||||
)
|
||||
|
||||
# 如果是设为默认,先取消其他默认
|
||||
if repo_in.is_default:
|
||||
|
|
@ -126,7 +99,12 @@ async def update_git_repo(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新Git仓库"""
|
||||
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor'])
|
||||
await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=["admin", "editor"],
|
||||
)
|
||||
|
||||
result = await db.execute(select(ProjectGitRepo).where(ProjectGitRepo.id == repo_id, ProjectGitRepo.project_id == project_id))
|
||||
repo = result.scalar_one_or_none()
|
||||
|
|
@ -160,7 +138,12 @@ async def delete_git_repo(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除Git仓库"""
|
||||
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor'])
|
||||
await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=["admin", "editor"],
|
||||
)
|
||||
|
||||
result = await db.execute(select(ProjectGitRepo).where(ProjectGitRepo.id == repo_id, ProjectGitRepo.project_id == project_id))
|
||||
repo = result.scalar_one_or_none()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,412 @@
|
|||
"""
|
||||
LLM 模型配置 API
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.llm_model_config import LLMModelConfig
|
||||
from app.models.user import User
|
||||
from app.schemas.response import success_response
|
||||
from app.services.llm_provider_service import LLMProviderService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LLMModelConfigUpsertRequest(BaseModel):
|
||||
"""模型配置新增/编辑请求"""
|
||||
|
||||
model_code: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
provider: str = Field(..., min_length=1, max_length=64)
|
||||
endpoint_url: Optional[str] = Field(None, max_length=512)
|
||||
api_key: Optional[str] = Field(None, max_length=512)
|
||||
llm_model_name: str = Field(..., min_length=1, max_length=128)
|
||||
llm_timeout: int = Field(120, ge=5, le=600)
|
||||
llm_temperature: float = Field(0.70, ge=0, le=2)
|
||||
llm_top_p: float = Field(0.90, ge=0, le=1)
|
||||
llm_max_tokens: int = Field(2048, ge=1, le=32768)
|
||||
llm_system_prompt: Optional[str] = None
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
|
||||
@field_validator(
|
||||
"model_code",
|
||||
"model_name",
|
||||
"provider",
|
||||
"endpoint_url",
|
||||
"api_key",
|
||||
"llm_model_name",
|
||||
"llm_system_prompt",
|
||||
"description",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def strip_string_fields(cls, value):
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
return value or None
|
||||
return value
|
||||
|
||||
|
||||
class LLMModelConfigTestRequest(LLMModelConfigUpsertRequest):
|
||||
"""模型测试请求"""
|
||||
|
||||
|
||||
def serialize_model_config(config: LLMModelConfig, include_api_key: bool = False) -> dict:
|
||||
"""序列化模型配置"""
|
||||
api_key = config.api_key or ""
|
||||
data = {
|
||||
"config_id": config.config_id,
|
||||
"model_code": config.model_code,
|
||||
"model_name": config.model_name,
|
||||
"provider": config.provider,
|
||||
"endpoint_url": config.endpoint_url,
|
||||
"llm_model_name": config.llm_model_name,
|
||||
"llm_timeout": config.llm_timeout,
|
||||
"llm_temperature": float(config.llm_temperature or 0),
|
||||
"llm_top_p": float(config.llm_top_p or 0),
|
||||
"llm_max_tokens": config.llm_max_tokens,
|
||||
"llm_system_prompt": config.llm_system_prompt,
|
||||
"description": config.description,
|
||||
"is_active": bool(config.is_active),
|
||||
"is_default": bool(config.is_default),
|
||||
"has_api_key": bool(api_key),
|
||||
"api_key_masked": mask_api_key(api_key),
|
||||
"created_at": config.created_at.isoformat() if config.created_at else None,
|
||||
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
|
||||
}
|
||||
if include_api_key:
|
||||
data["api_key"] = api_key
|
||||
return data
|
||||
|
||||
|
||||
def mask_api_key(api_key: str) -> str:
|
||||
"""脱敏 API Key"""
|
||||
if not api_key:
|
||||
return ""
|
||||
if len(api_key) <= 8:
|
||||
return "*" * len(api_key)
|
||||
return f"{api_key[:4]}{'*' * (len(api_key) - 8)}{api_key[-4:]}"
|
||||
|
||||
|
||||
def normalize_payload(payload: LLMModelConfigUpsertRequest) -> dict:
|
||||
"""补齐自动生成字段"""
|
||||
data = payload.model_dump()
|
||||
provider = data["provider"]
|
||||
llm_model_name = data["llm_model_name"]
|
||||
data["model_name"] = data.get("model_name") or LLMProviderService.build_model_name(provider, llm_model_name)
|
||||
data["model_code"] = data.get("model_code") or LLMProviderService.build_model_code(provider, llm_model_name)
|
||||
data["endpoint_url"] = data.get("endpoint_url") or LLMProviderService.get_default_endpoint_url(provider)
|
||||
if data["is_default"]:
|
||||
data["is_active"] = True
|
||||
data["llm_temperature"] = Decimal(str(data["llm_temperature"]))
|
||||
data["llm_top_p"] = Decimal(str(data["llm_top_p"]))
|
||||
return data
|
||||
|
||||
|
||||
async def ensure_default_config(db: AsyncSession, preferred_config_id: Optional[int] = None):
|
||||
"""确保始终存在一个默认启用模型"""
|
||||
default_result = await db.execute(
|
||||
select(LLMModelConfig.config_id).where(
|
||||
LLMModelConfig.is_default == True,
|
||||
LLMModelConfig.is_active == True,
|
||||
)
|
||||
)
|
||||
if default_result.scalar_one_or_none():
|
||||
return
|
||||
|
||||
candidate_id = None
|
||||
if preferred_config_id:
|
||||
candidate_result = await db.execute(
|
||||
select(LLMModelConfig.config_id).where(
|
||||
LLMModelConfig.config_id == preferred_config_id,
|
||||
LLMModelConfig.is_active == True,
|
||||
)
|
||||
)
|
||||
candidate_id = candidate_result.scalar_one_or_none()
|
||||
|
||||
if candidate_id is None:
|
||||
fallback_result = await db.execute(
|
||||
select(LLMModelConfig.config_id)
|
||||
.where(LLMModelConfig.is_active == True)
|
||||
.order_by(LLMModelConfig.updated_at.desc(), LLMModelConfig.config_id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
candidate_id = fallback_result.scalar_one_or_none()
|
||||
|
||||
if candidate_id is None:
|
||||
return
|
||||
|
||||
await db.execute(update(LLMModelConfig).values(is_default=False))
|
||||
await db.execute(
|
||||
update(LLMModelConfig)
|
||||
.where(LLMModelConfig.config_id == candidate_id)
|
||||
.values(is_default=True)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers", response_model=dict)
|
||||
async def get_provider_catalog(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取模型提供方目录"""
|
||||
return success_response(data=LLMProviderService.get_provider_catalog())
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def get_llm_model_configs(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = Query(None, description="搜索关键词(模型名称、编码、模型名)"),
|
||||
provider: Optional[str] = Query(None, description="提供方筛选"),
|
||||
is_active: Optional[bool] = Query(None, description="启用状态筛选"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取模型配置列表"""
|
||||
|
||||
conditions = []
|
||||
if keyword:
|
||||
conditions.append(
|
||||
or_(
|
||||
LLMModelConfig.model_name.like(f"%{keyword}%"),
|
||||
LLMModelConfig.model_code.like(f"%{keyword}%"),
|
||||
LLMModelConfig.llm_model_name.like(f"%{keyword}%"),
|
||||
)
|
||||
)
|
||||
if provider:
|
||||
conditions.append(LLMModelConfig.provider == provider)
|
||||
if is_active is not None:
|
||||
conditions.append(LLMModelConfig.is_active == is_active)
|
||||
|
||||
count_query = select(func.count(LLMModelConfig.config_id))
|
||||
if conditions:
|
||||
count_query = count_query.where(*conditions)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = select(LLMModelConfig).order_by(
|
||||
LLMModelConfig.is_default.desc(),
|
||||
LLMModelConfig.updated_at.desc(),
|
||||
LLMModelConfig.config_id.desc(),
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(*conditions)
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await db.execute(query)
|
||||
configs = result.scalars().all()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": [serialize_model_config(item) for item in configs],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{config_id}", response_model=dict)
|
||||
async def get_llm_model_config_detail(
|
||||
config_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取模型配置详情"""
|
||||
result = await db.execute(
|
||||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||||
|
||||
return success_response(data=serialize_model_config(config, include_api_key=True))
|
||||
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
async def create_llm_model_config(
|
||||
request_data: LLMModelConfigUpsertRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建模型配置"""
|
||||
payload = normalize_payload(request_data)
|
||||
|
||||
existing_code_result = await db.execute(
|
||||
select(LLMModelConfig).where(LLMModelConfig.model_code == payload["model_code"])
|
||||
)
|
||||
if existing_code_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="模型编码已存在")
|
||||
|
||||
new_config = LLMModelConfig(**payload)
|
||||
db.add(new_config)
|
||||
await db.flush()
|
||||
|
||||
if payload["is_default"]:
|
||||
await db.execute(
|
||||
update(LLMModelConfig)
|
||||
.where(LLMModelConfig.config_id != new_config.config_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
await ensure_default_config(db, preferred_config_id=new_config.config_id)
|
||||
await db.commit()
|
||||
await db.refresh(new_config)
|
||||
|
||||
return success_response(
|
||||
data=serialize_model_config(new_config),
|
||||
message="模型配置创建成功",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{config_id}", response_model=dict)
|
||||
async def update_llm_model_config(
|
||||
config_id: int,
|
||||
request_data: LLMModelConfigUpsertRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新模型配置"""
|
||||
result = await db.execute(
|
||||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||||
|
||||
payload = normalize_payload(request_data)
|
||||
existing_code_result = await db.execute(
|
||||
select(LLMModelConfig).where(
|
||||
LLMModelConfig.model_code == payload["model_code"],
|
||||
LLMModelConfig.config_id != config_id,
|
||||
)
|
||||
)
|
||||
if existing_code_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="模型编码已被其他配置使用")
|
||||
|
||||
for key, value in payload.items():
|
||||
setattr(config, key, value)
|
||||
|
||||
await db.flush()
|
||||
|
||||
if config.is_default:
|
||||
await db.execute(
|
||||
update(LLMModelConfig)
|
||||
.where(LLMModelConfig.config_id != config.config_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
await ensure_default_config(db, preferred_config_id=config.config_id)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
return success_response(
|
||||
data=serialize_model_config(config),
|
||||
message="模型配置更新成功",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{config_id}/status", response_model=dict)
|
||||
async def update_llm_model_config_status(
|
||||
config_id: int,
|
||||
is_active: bool = Query(..., description="是否启用"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新模型配置启用状态"""
|
||||
result = await db.execute(
|
||||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||||
|
||||
config.is_active = is_active
|
||||
if not is_active and config.is_default:
|
||||
config.is_default = False
|
||||
|
||||
await db.flush()
|
||||
await ensure_default_config(db)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
return success_response(
|
||||
data=serialize_model_config(config),
|
||||
message="模型状态更新成功",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{config_id}/default", response_model=dict)
|
||||
async def set_default_llm_model_config(
|
||||
config_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设为默认模型配置"""
|
||||
result = await db.execute(
|
||||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||||
|
||||
config.is_active = True
|
||||
config.is_default = True
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
update(LLMModelConfig)
|
||||
.where(LLMModelConfig.config_id != config.config_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
return success_response(
|
||||
data=serialize_model_config(config),
|
||||
message="默认模型切换成功",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{config_id}", response_model=dict)
|
||||
async def delete_llm_model_config(
|
||||
config_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除模型配置"""
|
||||
result = await db.execute(
|
||||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||||
|
||||
was_default = bool(config.is_default)
|
||||
await db.delete(config)
|
||||
await db.flush()
|
||||
|
||||
if was_default:
|
||||
await ensure_default_config(db)
|
||||
|
||||
await db.commit()
|
||||
return success_response(message="模型配置删除成功")
|
||||
|
||||
|
||||
@router.post("/test", response_model=dict)
|
||||
async def test_llm_model_config(
|
||||
request_data: LLMModelConfigTestRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""测试模型连接"""
|
||||
payload = normalize_payload(request_data)
|
||||
test_result = await LLMProviderService.test_model_connection(payload)
|
||||
return success_response(data=test_result, message="模型测试成功")
|
||||
|
|
@ -14,46 +14,21 @@ from app.core.database import get_db
|
|||
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
|
||||
from app.services.project_service import (
|
||||
get_project_or_404,
|
||||
require_project_read_access,
|
||||
)
|
||||
from app.services.storage import storage_service
|
||||
from app.services.pdf_service import pdf_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def check_preview_access(
|
||||
project: Project,
|
||||
current_user: Optional[User],
|
||||
db: AsyncSession
|
||||
):
|
||||
"""检查预览访问权限"""
|
||||
# 公开项目:任何人都可以访问
|
||||
if project.is_public == 1:
|
||||
return True
|
||||
|
||||
# 私密项目:必须是项目成员
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="私密项目需要登录才能访问")
|
||||
|
||||
# 检查是否是项目所有者
|
||||
if project.owner_id == current_user.id:
|
||||
return True
|
||||
|
||||
# 检查是否是项目成员
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project.id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权访问该私密项目")
|
||||
|
||||
return True
|
||||
def verify_project_password(project, provided_password: Optional[str]) -> None:
|
||||
"""校验预览密码。"""
|
||||
if project.access_pass and project.access_pass != provided_password:
|
||||
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
|
||||
|
||||
|
||||
@router.get("/{project_id}/info", response_model=dict)
|
||||
|
|
@ -63,15 +38,14 @@ async def get_preview_info(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取预览项目基本信息"""
|
||||
# 查询项目
|
||||
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)
|
||||
project, _ = await require_project_read_access(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allow_public=True,
|
||||
unauthenticated_detail="私密项目需要登录才能访问",
|
||||
forbidden_detail="无权访问该私密项目",
|
||||
)
|
||||
|
||||
# 返回基本信息
|
||||
info = {
|
||||
|
|
@ -93,15 +67,14 @@ async def verify_access_password(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""验证访问密码"""
|
||||
# 查询项目
|
||||
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)
|
||||
project, _ = await require_project_read_access(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allow_public=True,
|
||||
unauthenticated_detail="私密项目需要登录才能访问",
|
||||
forbidden_detail="无权访问该私密项目",
|
||||
)
|
||||
|
||||
# 验证密码
|
||||
if not project.access_pass:
|
||||
|
|
@ -121,20 +94,15 @@ async def get_preview_tree(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取预览项目的文档树"""
|
||||
# 查询项目
|
||||
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)
|
||||
|
||||
# 如果设置了密码,需要验证
|
||||
if project.access_pass:
|
||||
if not password or project.access_pass != password:
|
||||
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
|
||||
project, _ = await require_project_read_access(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allow_public=True,
|
||||
unauthenticated_detail="私密项目需要登录才能访问",
|
||||
forbidden_detail="无权访问该私密项目",
|
||||
)
|
||||
verify_project_password(project, password)
|
||||
|
||||
# 获取文档树
|
||||
project_path = storage_service.get_secure_path(project.storage_key)
|
||||
|
|
@ -152,20 +120,15 @@ async def get_preview_file(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取预览项目的文件内容"""
|
||||
# 查询项目
|
||||
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)
|
||||
|
||||
# 如果设置了密码,需要验证
|
||||
if project.access_pass:
|
||||
if not password or project.access_pass != password:
|
||||
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
|
||||
project, _ = await require_project_read_access(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allow_public=True,
|
||||
unauthenticated_detail="私密项目需要登录才能访问",
|
||||
forbidden_detail="无权访问该私密项目",
|
||||
)
|
||||
verify_project_password(project, password)
|
||||
|
||||
# 获取文件内容
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
|
|
@ -208,21 +171,18 @@ async def get_preview_document(
|
|||
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)
|
||||
project, _ = await require_project_read_access(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allow_public=True,
|
||||
unauthenticated_detail="私密项目需要登录才能访问",
|
||||
forbidden_detail="无权访问该私密项目",
|
||||
)
|
||||
|
||||
# 如果设置了密码,需要验证(优先使用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="需要提供正确的访问密码")
|
||||
verify_project_password(project, provided_password)
|
||||
|
||||
# 获取文件
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
|
|
@ -245,6 +205,8 @@ async def export_preview_pdf(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""导出预览项目的文档为 PDF"""
|
||||
from app.services.pdf_service import pdf_service
|
||||
|
||||
# 获取当前用户(支持header或query参数)
|
||||
current_user = None
|
||||
token_str = None
|
||||
|
|
@ -268,21 +230,18 @@ async def export_preview_pdf(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# 查询项目
|
||||
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)
|
||||
project, _ = await require_project_read_access(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allow_public=True,
|
||||
unauthenticated_detail="私密项目需要登录才能访问",
|
||||
forbidden_detail="无权访问该私密项目",
|
||||
)
|
||||
|
||||
# 验证密码
|
||||
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="需要提供正确的访问密码")
|
||||
verify_project_password(project, provided_password)
|
||||
|
||||
# 获取文件
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
|
|
@ -300,7 +259,10 @@ async def export_preview_pdf(
|
|||
|
||||
# 生成 PDF 字节流,传入项目根目录作为 base_url
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
|
||||
try:
|
||||
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
# 中文文件名需要 RFC 5987 编码
|
||||
from urllib.parse import quote
|
||||
|
|
@ -314,4 +276,3 @@ async def export_preview_pdf(
|
|||
"Content-Type": "application/pdf"
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
"""
|
||||
项目管理相关 API
|
||||
"""
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.background import BackgroundTask
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
|
|
@ -28,27 +32,19 @@ from app.services.storage import storage_service
|
|||
from app.services.log_service import log_service
|
||||
from app.services.git_service import git_service
|
||||
from app.services.notification_service import notification_service
|
||||
from app.services.project_export_service import project_export_service
|
||||
from app.services.project_service import (
|
||||
get_project_member,
|
||||
get_project_or_404,
|
||||
require_project_read_access,
|
||||
require_project_roles,
|
||||
serialize_project,
|
||||
)
|
||||
from app.core.enums import OperationType, ResourceType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_document_count(storage_key: str) -> int:
|
||||
"""计算项目中的文档数量(.md 和 .pdf)"""
|
||||
try:
|
||||
project_path = storage_service.get_secure_path(storage_key)
|
||||
if not project_path.exists():
|
||||
return 0
|
||||
md_count = len(list(project_path.rglob("*.md")))
|
||||
pdf_count = len(list(project_path.rglob("*.pdf")))
|
||||
# 排除 _assets 目录下的文件
|
||||
assets_md = len(list((project_path / "_assets").rglob("*.md"))) if (project_path / "_assets").exists() else 0
|
||||
assets_pdf = len(list((project_path / "_assets").rglob("*.pdf"))) if (project_path / "_assets").exists() else 0
|
||||
return md_count + pdf_count - assets_md - assets_pdf
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def get_my_projects(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
|
@ -75,11 +71,7 @@ async def get_my_projects(
|
|||
|
||||
# 合并结果
|
||||
all_projects = owned_projects + member_projects
|
||||
projects_data = []
|
||||
for p in all_projects:
|
||||
p_dict = ProjectResponse.from_orm(p).dict()
|
||||
p_dict['doc_count'] = get_document_count(p.storage_key)
|
||||
projects_data.append(p_dict)
|
||||
projects_data = [serialize_project(project) for project in all_projects]
|
||||
|
||||
return success_response(data=projects_data)
|
||||
|
||||
|
|
@ -94,11 +86,7 @@ async def get_owned_projects(
|
|||
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
projects_data = []
|
||||
for p in projects:
|
||||
p_dict = ProjectResponse.from_orm(p).dict()
|
||||
p_dict['doc_count'] = get_document_count(p.storage_key)
|
||||
projects_data.append(p_dict)
|
||||
projects_data = [serialize_project(project) for project in projects]
|
||||
return success_response(data=projects_data)
|
||||
|
||||
|
||||
|
|
@ -122,12 +110,14 @@ async def get_shared_projects(
|
|||
|
||||
projects_data = []
|
||||
for project, owner, member in projects_with_info:
|
||||
project_dict = ProjectResponse.from_orm(project).dict()
|
||||
project_dict['owner_name'] = owner.username
|
||||
project_dict['owner_nickname'] = owner.nickname
|
||||
project_dict['user_role'] = member.role # 添加用户角色
|
||||
project_dict['doc_count'] = get_document_count(project.storage_key)
|
||||
projects_data.append(project_dict)
|
||||
projects_data.append(
|
||||
serialize_project(
|
||||
project,
|
||||
owner_name=owner.username,
|
||||
owner_nickname=owner.nickname,
|
||||
user_role=member.role,
|
||||
)
|
||||
)
|
||||
|
||||
return success_response(data=projects_data)
|
||||
|
||||
|
|
@ -195,24 +185,12 @@ async def get_project(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目详情"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
|
||||
# 检查权限(项目所有者或成员可访问)
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member and project.is_public != 1:
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
project, _ = await require_project_read_access(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allow_public=True,
|
||||
)
|
||||
|
||||
# 增加访问次数 (简单计数)
|
||||
project.visit_count += 1
|
||||
|
|
@ -231,12 +209,7 @@ async def update_project(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新项目信息"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
project = await get_project_or_404(db, project_id)
|
||||
|
||||
# 只有项目所有者可以更新
|
||||
if project.owner_id != current_user.id:
|
||||
|
|
@ -273,12 +246,7 @@ async def transfer_project(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""转移项目所有权"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
project = await get_project_or_404(db, project_id)
|
||||
|
||||
# 只有项目所有者可以转移
|
||||
if project.owner_id != current_user.id:
|
||||
|
|
@ -295,25 +263,13 @@ async def transfer_project(
|
|||
raise HTTPException(status_code=400, detail="不能转移给自己")
|
||||
|
||||
# 1. 如果新所有者已经是成员,删除成员记录
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == new_owner.id
|
||||
)
|
||||
)
|
||||
existing_member = member_result.scalar_one_or_none()
|
||||
existing_member = await get_project_member(db, project_id, new_owner.id)
|
||||
if existing_member:
|
||||
await db.delete(existing_member)
|
||||
|
||||
# 2. 将旧所有者添加为管理员成员
|
||||
# 检查旧所有者是否已经在member表中(理论上owner不在member表中,但为了健壮性检查一下)
|
||||
old_member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
if not old_member_result.scalar_one_or_none():
|
||||
if not await get_project_member(db, project_id, current_user.id):
|
||||
old_owner_member = ProjectMember(
|
||||
project_id=project_id,
|
||||
user_id=current_user.id,
|
||||
|
|
@ -351,6 +307,69 @@ async def transfer_project(
|
|||
return success_response(message="项目所有权转移成功")
|
||||
|
||||
|
||||
@router.post("/{project_id}/export", response_model=dict)
|
||||
async def start_project_export(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""启动项目整体导出任务。"""
|
||||
project, _ = await require_project_read_access(db, project_id, current_user)
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
return success_response(
|
||||
data=await project_export_service.start_export(
|
||||
project_id,
|
||||
current_user.id,
|
||||
project.name,
|
||||
project_root,
|
||||
),
|
||||
message="项目导出任务已开始",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/export/{task_id}", response_model=dict)
|
||||
async def get_project_export_status(
|
||||
project_id: int,
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目导出任务状态。"""
|
||||
await require_project_read_access(db, project_id, current_user)
|
||||
task = project_export_service.get_owned_task_or_404(project_id, task_id, current_user.id)
|
||||
return success_response(data=project_export_service.serialize_task(task))
|
||||
|
||||
|
||||
@router.get("/{project_id}/export/{task_id}/download")
|
||||
async def download_project_export(
|
||||
project_id: int,
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""下载已完成的项目导出 ZIP。"""
|
||||
await require_project_read_access(db, project_id, current_user)
|
||||
task = project_export_service.get_owned_task_or_404(project_id, task_id, current_user.id)
|
||||
|
||||
if task["status"] == "failed":
|
||||
raise HTTPException(status_code=400, detail=task.get("error") or "项目导出失败")
|
||||
|
||||
if task["status"] != "completed":
|
||||
raise HTTPException(status_code=409, detail="导出尚未完成,请稍后再试")
|
||||
|
||||
file_path = task.get("file_path")
|
||||
if not file_path or not Path(file_path).exists():
|
||||
raise HTTPException(status_code=404, detail="导出文件不存在或已过期")
|
||||
|
||||
encoded_filename = quote(task["zip_filename"])
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
|
||||
background=BackgroundTask(project_export_service.cleanup_task, task_id),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", response_model=dict)
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
|
|
@ -359,12 +378,7 @@ async def delete_project(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除项目"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
project = await get_project_or_404(db, project_id)
|
||||
|
||||
# 只有项目所有者可以删除
|
||||
if project.owner_id != current_user.id:
|
||||
|
|
@ -425,24 +439,7 @@ async def get_project_members(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目成员列表"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
|
||||
# 检查权限
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
await require_project_read_access(db, project_id, current_user)
|
||||
|
||||
# 查询成员列表并关联用户信息
|
||||
members_result = await db.execute(
|
||||
|
|
@ -477,25 +474,13 @@ async def add_project_member(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""添加项目成员"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
|
||||
# 只有项目所有者和管理员可以添加成员
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role == "admin"
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权添加成员")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=["admin"],
|
||||
forbidden_detail="无权添加成员",
|
||||
)
|
||||
|
||||
# 检查用户是否已是成员
|
||||
existing_result = await db.execute(
|
||||
|
|
@ -555,25 +540,13 @@ async def remove_project_member(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除项目成员"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
|
||||
# 只有项目所有者和管理员可以删除成员
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role == "admin"
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权删除成员")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=["admin"],
|
||||
forbidden_detail="无权删除成员",
|
||||
)
|
||||
|
||||
# 不能删除项目所有者
|
||||
if user_id == project.owner_id:
|
||||
|
|
@ -615,26 +588,8 @@ async def get_project_share_info(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目分享信息"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
|
||||
# 检查是否是项目所有者或成员
|
||||
is_owner = project.owner_id == current_user.id
|
||||
if not is_owner:
|
||||
# 检查是否是项目成员
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
project, role = await require_project_read_access(db, project_id, current_user)
|
||||
is_owner = role == "owner"
|
||||
|
||||
# 构建分享链接
|
||||
share_url = f"/preview/{project_id}"
|
||||
|
|
@ -658,12 +613,7 @@ async def update_share_settings(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新分享设置(设置或取消访问密码)"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
project = await get_project_or_404(db, project_id)
|
||||
|
||||
# 只有项目所有者可以修改分享设置
|
||||
if project.owner_id != current_user.id:
|
||||
|
|
@ -701,24 +651,13 @@ async def git_pull(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""执行 Git Pull"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
|
||||
# 权限检查:需要是所有者或管理员/编辑者
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role.in_(['admin', 'editor'])
|
||||
)
|
||||
)
|
||||
if not member_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权执行Git操作")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=["admin", "editor"],
|
||||
forbidden_detail="无权执行Git操作",
|
||||
)
|
||||
|
||||
# 获取Git仓库配置
|
||||
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
|
||||
|
|
@ -785,24 +724,13 @@ async def git_push(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""执行 Git Push"""
|
||||
# 查询项目
|
||||
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="项目不存在")
|
||||
|
||||
# 权限检查:需要是所有者或管理员/编辑者
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role.in_(['admin', 'editor'])
|
||||
)
|
||||
)
|
||||
if not member_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权执行Git操作")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=["admin", "editor"],
|
||||
forbidden_detail="无权执行Git操作",
|
||||
)
|
||||
|
||||
# 获取Git仓库配置
|
||||
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from app.core.database import get_db
|
|||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.services.project_service import require_project_read_access
|
||||
from app.services.search_service import search_service
|
||||
from app.services.storage import storage_service
|
||||
from app.schemas.response import success_response
|
||||
|
|
@ -37,24 +38,12 @@ async def search_documents(
|
|||
allowed_project_ids = []
|
||||
|
||||
if project_id:
|
||||
# 检查指定项目的访问权限
|
||||
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="项目不存在")
|
||||
|
||||
# 检查权限
|
||||
if project.owner_id != current_user.id and project.is_public != 1:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
if not member_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
project, _ = await require_project_read_access(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allow_public=True,
|
||||
)
|
||||
allowed_project_ids.append(str(project_id))
|
||||
else:
|
||||
# 获取所有可访问的项目
|
||||
|
|
@ -267,4 +256,4 @@ async def rebuild_index(
|
|||
|
||||
background_tasks.add_task(rebuild_index_task, db)
|
||||
|
||||
return success_response(message="索引重建任务已启动")
|
||||
return success_response(message="索引重建任务已启动")
|
||||
|
|
|
|||
|
|
@ -21,14 +21,14 @@ from app.models.mcp_bot import MCPBot
|
|||
from app.models.project import Project, ProjectMember
|
||||
from app.models.user import User
|
||||
from app.schemas.project import ProjectResponse
|
||||
from app.services.notification_service import notification_service
|
||||
from app.services.search_service import search_service
|
||||
from app.services.project_file_service import project_file_service
|
||||
from app.services.storage import storage_service
|
||||
from app.services.log_service import log_service
|
||||
from app.api.v1.projects import get_document_count
|
||||
from app.api.v1.files import check_project_access
|
||||
from app.services.project_service import (
|
||||
count_project_documents,
|
||||
require_project_read_access,
|
||||
require_project_write_access,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.enums import OperationType
|
||||
from app.mcp.context import MCPRequestContext, current_mcp_request
|
||||
|
||||
|
||||
|
|
@ -59,7 +59,8 @@ async def _get_current_user(db) -> User:
|
|||
|
||||
|
||||
async def _get_project_with_write_access(project_id: int, current_user: User, db):
|
||||
return await check_project_access(project_id, current_user, db, require_write=True)
|
||||
project, _ = await require_project_write_access(db, project_id, current_user)
|
||||
return project
|
||||
|
||||
|
||||
def _ensure_file_exists(file_path: Path, path: str) -> None:
|
||||
|
|
@ -73,17 +74,6 @@ def _ensure_file_not_exists(file_path: Path, path: str) -> None:
|
|||
if file_path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"文件已存在: {path}")
|
||||
|
||||
|
||||
async def _update_markdown_index(project_id: int, path: str, content: str) -> None:
|
||||
if path.endswith(".md"):
|
||||
await search_service.update_doc(project_id, path, Path(path).stem, content)
|
||||
|
||||
|
||||
async def _remove_markdown_index(project_id: int, path: str) -> None:
|
||||
if path.endswith(".md"):
|
||||
await search_service.remove_doc(project_id, path)
|
||||
|
||||
|
||||
if mcp is not None:
|
||||
@mcp.tool(name="list_created_projects", description="Get projects created by the authenticated user.")
|
||||
async def list_created_projects(keyword: str = "", limit: int = 100) -> List[Dict[str, Any]]:
|
||||
|
|
@ -98,7 +88,7 @@ if mcp is not None:
|
|||
keyword_lower = keyword.strip().lower()
|
||||
for project in projects:
|
||||
project_dict = ProjectResponse.from_orm(project).dict()
|
||||
project_dict["doc_count"] = get_document_count(project.storage_key)
|
||||
project_dict["doc_count"] = count_project_documents(project.storage_key)
|
||||
if keyword_lower:
|
||||
haystack = f"{project.name} {project.description or ''}".lower()
|
||||
if keyword_lower not in haystack:
|
||||
|
|
@ -112,22 +102,10 @@ if mcp is not None:
|
|||
async def get_project_tree(project_id: int) -> Dict[str, Any]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
current_user = await _get_current_user(db)
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
project, user_role = await require_project_read_access(db, project_id, current_user)
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
tree = storage_service.generate_tree(project_root)
|
||||
|
||||
user_role = "owner"
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if member:
|
||||
user_role = member.role
|
||||
|
||||
return {
|
||||
"tree": [item.model_dump() for item in tree],
|
||||
"user_role": user_role,
|
||||
|
|
@ -140,7 +118,7 @@ if mcp is not None:
|
|||
async def get_file(project_id: int, path: str) -> Dict[str, Any]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
current_user = await _get_current_user(db)
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
project, _ = await require_project_read_access(db, project_id, current_user)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
_ensure_file_exists(file_path, path)
|
||||
content = await storage_service.read_file(file_path)
|
||||
|
|
@ -154,30 +132,15 @@ if mcp is not None:
|
|||
project = await _get_project_with_write_access(project_id, current_user, db)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
_ensure_file_not_exists(file_path, path)
|
||||
await storage_service.write_file(file_path, content)
|
||||
await _update_markdown_index(project_id, path, content)
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.CREATE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=path,
|
||||
user=current_user,
|
||||
detail={"content_length": len(content), "source": "mcp"},
|
||||
request=None,
|
||||
)
|
||||
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title="项目文档创建",
|
||||
content=(
|
||||
f"项目 [{project.name}] 中的文档 [{path}] "
|
||||
f"已被 {current_user.nickname or current_user.username} 通过 MCP 创建。"
|
||||
),
|
||||
link=f"/projects/{project_id}/docs?file={path}",
|
||||
category="project",
|
||||
await project_file_service.operate_file(
|
||||
db,
|
||||
project_id,
|
||||
project,
|
||||
"create_file",
|
||||
path,
|
||||
current_user,
|
||||
content=content,
|
||||
source="mcp",
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
@ -194,30 +157,14 @@ if mcp is not None:
|
|||
project = await _get_project_with_write_access(project_id, current_user, db)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
_ensure_file_exists(file_path, path)
|
||||
await storage_service.write_file(file_path, content)
|
||||
await _update_markdown_index(project_id, path, content)
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.SAVE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=path,
|
||||
user=current_user,
|
||||
detail={"content_length": len(content), "source": "mcp"},
|
||||
request=None,
|
||||
)
|
||||
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title="项目文档更新",
|
||||
content=(
|
||||
f"项目 [{project.name}] 中的文档 [{path}] "
|
||||
f"已被 {current_user.nickname or current_user.username} 通过 MCP 更新。"
|
||||
),
|
||||
link=f"/projects/{project_id}/docs?file={path}",
|
||||
category="project",
|
||||
await project_file_service.save_file(
|
||||
db,
|
||||
project_id,
|
||||
project,
|
||||
path,
|
||||
content,
|
||||
current_user,
|
||||
source="mcp",
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
@ -234,29 +181,14 @@ if mcp is not None:
|
|||
project = await _get_project_with_write_access(project_id, current_user, db)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
_ensure_file_exists(file_path, path)
|
||||
await storage_service.delete_file(file_path)
|
||||
await _remove_markdown_index(project_id, path)
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.DELETE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=path,
|
||||
user=current_user,
|
||||
detail={"source": "mcp"},
|
||||
request=None,
|
||||
)
|
||||
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title="项目文档删除",
|
||||
content=(
|
||||
f"项目 [{project.name}] 中的文档 [{path}] "
|
||||
f"已被 {current_user.nickname or current_user.username} 通过 MCP 删除。"
|
||||
),
|
||||
category="project",
|
||||
await project_file_service.operate_file(
|
||||
db,
|
||||
project_id,
|
||||
project,
|
||||
"delete",
|
||||
path,
|
||||
current_user,
|
||||
source="mcp",
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from app.models.project import Project, ProjectMember, ProjectMemberRole
|
|||
from app.models.document import DocumentMeta
|
||||
from app.models.log import OperationLog
|
||||
from app.models.mcp_bot import MCPBot
|
||||
from app.models.llm_model_config import LLMModelConfig
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
|
|
@ -23,4 +24,5 @@ __all__ = [
|
|||
"DocumentMeta",
|
||||
"OperationLog",
|
||||
"MCPBot",
|
||||
"LLMModelConfig",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
"""
|
||||
LLM 模型配置模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, Text, Numeric, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class LLMModelConfig(Base):
|
||||
"""大模型配置表模型"""
|
||||
|
||||
__tablename__ = "llm_model_config"
|
||||
|
||||
config_id = Column(BigInteger, primary_key=True, autoincrement=True, comment="配置ID")
|
||||
model_code = Column(String(128), nullable=False, unique=True, index=True, comment="模型编码")
|
||||
model_name = Column(String(255), nullable=False, comment="模型名称")
|
||||
provider = Column(String(64), comment="模型提供方")
|
||||
endpoint_url = Column(String(512), comment="接口地址")
|
||||
api_key = Column(String(512), comment="API Key")
|
||||
llm_model_name = Column(String(128), nullable=False, comment="模型名称/部署名")
|
||||
llm_timeout = Column(Integer, nullable=False, default=120, comment="超时时间(秒)")
|
||||
llm_temperature = Column(Numeric(5, 2), nullable=False, default=0.70, comment="温度")
|
||||
llm_top_p = Column(Numeric(5, 2), nullable=False, default=0.90, comment="Top P")
|
||||
llm_max_tokens = Column(Integer, nullable=False, default=2048, comment="最大输出 Token")
|
||||
llm_system_prompt = Column(Text, comment="系统提示词")
|
||||
description = Column(String(500), comment="描述")
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
|
||||
is_default = Column(Boolean, nullable=False, default=False, comment="是否默认")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LLMModelConfig(config_id={self.config_id}, model_code='{self.model_code}')>"
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
"""
|
||||
LLM 提供方测试服务
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
PROVIDER_CATALOG: List[Dict[str, str]] = [
|
||||
{
|
||||
"value": "openai",
|
||||
"label": "OpenAI",
|
||||
"default_endpoint_url": "https://api.openai.com/v1",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "deepseek",
|
||||
"label": "DeepSeek",
|
||||
"default_endpoint_url": "https://api.deepseek.com/v1",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "anthropic",
|
||||
"label": "Anthropic",
|
||||
"default_endpoint_url": "https://api.anthropic.com",
|
||||
"protocol": "anthropic",
|
||||
},
|
||||
{
|
||||
"value": "gemini",
|
||||
"label": "Google Gemini",
|
||||
"default_endpoint_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"protocol": "gemini",
|
||||
},
|
||||
{
|
||||
"value": "dashscope",
|
||||
"label": "阿里百炼",
|
||||
"default_endpoint_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "zhipu",
|
||||
"label": "智谱 AI",
|
||||
"default_endpoint_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "moonshot",
|
||||
"label": "Moonshot AI",
|
||||
"default_endpoint_url": "https://api.moonshot.cn/v1",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "groq",
|
||||
"label": "Groq",
|
||||
"default_endpoint_url": "https://api.groq.com/openai/v1",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "openrouter",
|
||||
"label": "OpenRouter",
|
||||
"default_endpoint_url": "https://openrouter.ai/api/v1",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "siliconflow",
|
||||
"label": "SiliconFlow",
|
||||
"default_endpoint_url": "https://api.siliconflow.cn/v1",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "ollama",
|
||||
"label": "Ollama",
|
||||
"default_endpoint_url": "http://localhost:11434/v1",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "ark",
|
||||
"label": "火山方舟",
|
||||
"default_endpoint_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "custom",
|
||||
"label": "自定义兼容接口",
|
||||
"default_endpoint_url": "",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
]
|
||||
|
||||
PROVIDER_MAP = {item["value"]: item for item in PROVIDER_CATALOG}
|
||||
|
||||
|
||||
class LLMProviderService:
|
||||
"""LLM 提供方测试服务"""
|
||||
|
||||
@staticmethod
|
||||
def get_provider_catalog() -> List[Dict[str, str]]:
|
||||
return PROVIDER_CATALOG
|
||||
|
||||
@staticmethod
|
||||
def get_provider_label(provider: Optional[str]) -> str:
|
||||
if not provider:
|
||||
return "自定义模型"
|
||||
return PROVIDER_MAP.get(provider, {}).get("label", provider)
|
||||
|
||||
@staticmethod
|
||||
def get_default_endpoint_url(provider: Optional[str]) -> str:
|
||||
if not provider:
|
||||
return ""
|
||||
return PROVIDER_MAP.get(provider, {}).get("default_endpoint_url", "")
|
||||
|
||||
@classmethod
|
||||
def build_model_name(cls, provider: Optional[str], llm_model_name: str) -> str:
|
||||
model_name = (llm_model_name or "").strip()
|
||||
label = cls.get_provider_label(provider)
|
||||
if not model_name:
|
||||
return label
|
||||
return f"{label} {model_name}"
|
||||
|
||||
@staticmethod
|
||||
def build_model_code(provider: Optional[str], llm_model_name: str) -> str:
|
||||
provider_part = (provider or "custom").strip().lower()
|
||||
model_part = (llm_model_name or "").strip().lower()
|
||||
sanitized = []
|
||||
previous_is_separator = False
|
||||
for char in model_part:
|
||||
if char.isalnum():
|
||||
sanitized.append(char)
|
||||
previous_is_separator = False
|
||||
else:
|
||||
if not previous_is_separator:
|
||||
sanitized.append("_")
|
||||
previous_is_separator = True
|
||||
|
||||
model_slug = "".join(sanitized).strip("_") or "model"
|
||||
return f"llm_{provider_part}_{model_slug}"
|
||||
|
||||
@classmethod
|
||||
async def test_model_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
provider = payload.get("provider")
|
||||
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
|
||||
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
|
||||
llm_model_name = (payload.get("llm_model_name") or "").strip()
|
||||
api_key = (payload.get("api_key") or "").strip()
|
||||
|
||||
if not endpoint_url:
|
||||
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
|
||||
if not llm_model_name:
|
||||
raise ValueError("缺少模型名称,请填写 llm_model_name")
|
||||
if provider not in {"ollama"} and not api_key:
|
||||
raise ValueError("缺少 API Key,请填写后再测试")
|
||||
|
||||
timeout = int(payload.get("llm_timeout") or 120)
|
||||
temperature = float(payload.get("llm_temperature") or 0.7)
|
||||
top_p = float(payload.get("llm_top_p") or 0.9)
|
||||
max_tokens = int(payload.get("llm_max_tokens") or 2048)
|
||||
system_prompt = (payload.get("llm_system_prompt") or "").strip()
|
||||
|
||||
started_at = time.perf_counter()
|
||||
preview = await asyncio.to_thread(
|
||||
cls._send_test_request,
|
||||
provider_meta.get("protocol", "openai_compatible"),
|
||||
provider,
|
||||
endpoint_url,
|
||||
api_key,
|
||||
llm_model_name,
|
||||
timeout,
|
||||
temperature,
|
||||
top_p,
|
||||
max_tokens,
|
||||
system_prompt,
|
||||
)
|
||||
latency_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"endpoint_url": endpoint_url,
|
||||
"llm_model_name": llm_model_name,
|
||||
"latency_ms": latency_ms,
|
||||
"preview": preview[:200],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _send_test_request(
|
||||
cls,
|
||||
protocol: str,
|
||||
provider: Optional[str],
|
||||
endpoint_url: str,
|
||||
api_key: str,
|
||||
llm_model_name: str,
|
||||
timeout: int,
|
||||
temperature: float,
|
||||
top_p: float,
|
||||
max_tokens: int,
|
||||
system_prompt: str,
|
||||
) -> str:
|
||||
if protocol == "anthropic":
|
||||
return cls._test_anthropic(
|
||||
endpoint_url,
|
||||
api_key,
|
||||
llm_model_name,
|
||||
timeout,
|
||||
temperature,
|
||||
top_p,
|
||||
max_tokens,
|
||||
system_prompt,
|
||||
)
|
||||
|
||||
if protocol == "gemini":
|
||||
return cls._test_gemini(
|
||||
endpoint_url,
|
||||
api_key,
|
||||
llm_model_name,
|
||||
timeout,
|
||||
temperature,
|
||||
top_p,
|
||||
max_tokens,
|
||||
system_prompt,
|
||||
)
|
||||
|
||||
return cls._test_openai_compatible(
|
||||
provider,
|
||||
endpoint_url,
|
||||
api_key,
|
||||
llm_model_name,
|
||||
timeout,
|
||||
temperature,
|
||||
top_p,
|
||||
max_tokens,
|
||||
system_prompt,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _test_openai_compatible(
|
||||
cls,
|
||||
provider: Optional[str],
|
||||
endpoint_url: str,
|
||||
api_key: str,
|
||||
llm_model_name: str,
|
||||
timeout: int,
|
||||
temperature: float,
|
||||
top_p: float,
|
||||
max_tokens: int,
|
||||
system_prompt: str,
|
||||
) -> str:
|
||||
url = cls._join_endpoint(endpoint_url, "/chat/completions")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
payload = {
|
||||
"model": llm_model_name,
|
||||
"messages": cls._build_openai_messages(system_prompt),
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
"max_tokens": min(max_tokens, 256),
|
||||
"stream": False,
|
||||
}
|
||||
response = cls._request_json(url, headers, payload, timeout)
|
||||
choices = response.get("choices") or []
|
||||
if not choices:
|
||||
raise ValueError("测试请求已发送,但未收到模型返回内容")
|
||||
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = "".join(
|
||||
item.get("text", "") if isinstance(item, dict) else str(item)
|
||||
for item in content
|
||||
)
|
||||
content = str(content).strip()
|
||||
if not content:
|
||||
raise ValueError("模型返回成功,但内容为空")
|
||||
return content
|
||||
|
||||
@classmethod
|
||||
def _test_anthropic(
|
||||
cls,
|
||||
endpoint_url: str,
|
||||
api_key: str,
|
||||
llm_model_name: str,
|
||||
timeout: int,
|
||||
temperature: float,
|
||||
top_p: float,
|
||||
max_tokens: int,
|
||||
system_prompt: str,
|
||||
) -> str:
|
||||
url = cls._join_endpoint(endpoint_url, "/v1/messages")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
payload = {
|
||||
"model": llm_model_name,
|
||||
"max_tokens": min(max_tokens, 256),
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "请只回复“连接测试成功”。",
|
||||
}
|
||||
],
|
||||
}
|
||||
if system_prompt:
|
||||
payload["system"] = system_prompt
|
||||
|
||||
response = cls._request_json(url, headers, payload, timeout)
|
||||
content = response.get("content") or []
|
||||
texts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
texts.append(item.get("text", ""))
|
||||
preview = "".join(texts).strip()
|
||||
if not preview:
|
||||
raise ValueError("模型返回成功,但内容为空")
|
||||
return preview
|
||||
|
||||
@classmethod
|
||||
def _test_gemini(
|
||||
cls,
|
||||
endpoint_url: str,
|
||||
api_key: str,
|
||||
llm_model_name: str,
|
||||
timeout: int,
|
||||
temperature: float,
|
||||
top_p: float,
|
||||
max_tokens: int,
|
||||
system_prompt: str,
|
||||
) -> str:
|
||||
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
|
||||
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
|
||||
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [{"text": "请只回复“连接测试成功”。"}],
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"temperature": temperature,
|
||||
"topP": top_p,
|
||||
"maxOutputTokens": min(max_tokens, 256),
|
||||
},
|
||||
}
|
||||
if system_prompt:
|
||||
payload["systemInstruction"] = {
|
||||
"parts": [{"text": system_prompt}],
|
||||
}
|
||||
|
||||
response = cls._request_json(url, headers, payload, timeout)
|
||||
candidates = response.get("candidates") or []
|
||||
if not candidates:
|
||||
raise ValueError("测试请求已发送,但未收到模型返回内容")
|
||||
|
||||
parts = candidates[0].get("content", {}).get("parts", [])
|
||||
preview = "".join(
|
||||
part.get("text", "") for part in parts if isinstance(part, dict)
|
||||
).strip()
|
||||
if not preview:
|
||||
raise ValueError("模型返回成功,但内容为空")
|
||||
return preview
|
||||
|
||||
@staticmethod
|
||||
def _build_openai_messages(system_prompt: str) -> List[Dict[str, str]]:
|
||||
messages: List[Dict[str, str]] = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": "请只回复“连接测试成功”。"})
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _join_endpoint(base_url: str, suffix: str) -> str:
|
||||
normalized = base_url.rstrip("/")
|
||||
if normalized.endswith(suffix):
|
||||
return normalized
|
||||
return f"{normalized}{suffix}"
|
||||
|
||||
@classmethod
|
||||
def _request_json(
|
||||
cls,
|
||||
url: str,
|
||||
headers: Dict[str, str],
|
||||
payload: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
if not body:
|
||||
return {}
|
||||
return json.loads(body)
|
||||
except urllib.error.HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="ignore")
|
||||
message = cls._extract_error_message(error_body) or error_body[:300] or str(exc)
|
||||
raise ValueError(f"模型测试失败(HTTP {exc.code}):{message}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
reason = exc.reason
|
||||
if isinstance(reason, socket.timeout):
|
||||
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
|
||||
raise ValueError(f"模型测试失败:{reason}") from exc
|
||||
except socket.timeout as exc:
|
||||
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("模型服务返回了无法解析的响应,请检查接口地址是否正确") from exc
|
||||
|
||||
@staticmethod
|
||||
def _extract_error_message(error_body: str) -> Optional[str]:
|
||||
if not error_body:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(error_body)
|
||||
except json.JSONDecodeError:
|
||||
return error_body.strip()
|
||||
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("error"), dict):
|
||||
return payload["error"].get("message") or payload["error"].get("type")
|
||||
return payload.get("message") or payload.get("detail")
|
||||
|
||||
return None
|
||||
|
||||
|
|
@ -1,14 +1,59 @@
|
|||
"""
|
||||
PDF 生成服务 - 基于 WeasyPrint + 系统字体
|
||||
"""
|
||||
import markdown
|
||||
from weasyprint import HTML, CSS
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PDFService:
|
||||
def __init__(self):
|
||||
self.markdown = None
|
||||
self.html_class = None
|
||||
self.css_class = None
|
||||
self.font_config = None
|
||||
|
||||
def _configure_macos_library_path(self):
|
||||
"""为 macOS 上的 Homebrew 动态库补充兜底搜索路径。"""
|
||||
if platform.system() != "Darwin":
|
||||
return
|
||||
|
||||
candidate_paths = [
|
||||
Path("/opt/homebrew/lib"),
|
||||
Path("/usr/local/lib"),
|
||||
]
|
||||
existing_paths = [
|
||||
path for path in os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "").split(":") if path
|
||||
]
|
||||
|
||||
for candidate in candidate_paths:
|
||||
candidate_str = str(candidate)
|
||||
if candidate.exists() and candidate_str not in existing_paths:
|
||||
existing_paths.append(candidate_str)
|
||||
|
||||
if existing_paths:
|
||||
os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join(existing_paths)
|
||||
|
||||
def _ensure_dependencies(self):
|
||||
"""按需加载 PDF 依赖,避免系统库缺失时影响整个服务启动。"""
|
||||
if self.markdown and self.html_class and self.css_class and self.font_config:
|
||||
return
|
||||
|
||||
try:
|
||||
self._configure_macos_library_path()
|
||||
import markdown
|
||||
from weasyprint import HTML, CSS
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"PDF 导出依赖不可用,请先安装 backend/requirements.txt 中的 Python 依赖,"
|
||||
"并为 WeasyPrint 安装系统库(如 glib、pango、cairo)。"
|
||||
) from exc
|
||||
|
||||
self.markdown = markdown
|
||||
self.html_class = HTML
|
||||
self.css_class = CSS
|
||||
self.font_config = FontConfiguration()
|
||||
|
||||
def get_css(self):
|
||||
|
|
@ -82,7 +127,9 @@ class PDFService:
|
|||
|
||||
async def md_to_pdf(self, md_content: str, title: str = "Document", base_url: str = None) -> io.BytesIO:
|
||||
"""将 Markdown 转换为 PDF 字节流"""
|
||||
html_content = markdown.markdown(
|
||||
self._ensure_dependencies()
|
||||
|
||||
html_content = self.markdown.markdown(
|
||||
md_content,
|
||||
extensions=['extra', 'codehilite', 'toc', 'tables']
|
||||
)
|
||||
|
|
@ -101,8 +148,8 @@ class PDFService:
|
|||
"""
|
||||
|
||||
pdf_buffer = io.BytesIO()
|
||||
css = CSS(string=self.get_css(), font_config=self.font_config)
|
||||
HTML(string=full_html, base_url=base_url).write_pdf(
|
||||
css = self.css_class(string=self.get_css(), font_config=self.font_config)
|
||||
self.html_class(string=full_html, base_url=base_url).write_pdf(
|
||||
pdf_buffer,
|
||||
stylesheets=[css],
|
||||
font_config=self.font_config
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
"""
|
||||
项目导出业务服务
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.storage import storage_service
|
||||
|
||||
|
||||
class ProjectExportService:
|
||||
"""管理项目导出任务及其文件打包流程。"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 3600):
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self._tasks: dict[str, dict] = {}
|
||||
self._tasks_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _build_zip_filename(project_name: str) -> str:
|
||||
safe_project_name = project_name.replace("/", "_").replace("\\", "_")
|
||||
return f"{safe_project_name}.zip"
|
||||
|
||||
@staticmethod
|
||||
def _serialize_task(task: dict) -> dict:
|
||||
total_files = task.get("total_files", 0) or 0
|
||||
processed_files = task.get("processed_files", 0) or 0
|
||||
progress = task.get("progress")
|
||||
if progress is None:
|
||||
progress = int(processed_files * 100 / total_files) if total_files else 0
|
||||
|
||||
return {
|
||||
"task_id": task["task_id"],
|
||||
"project_id": task["project_id"],
|
||||
"status": task["status"],
|
||||
"message": task.get("message", ""),
|
||||
"progress": progress,
|
||||
"processed_files": processed_files,
|
||||
"total_files": total_files,
|
||||
"file_count": total_files,
|
||||
"zip_filename": task["zip_filename"],
|
||||
"error": task.get("error"),
|
||||
"created_at": task.get("created_at"),
|
||||
"completed_at": task.get("completed_at"),
|
||||
}
|
||||
|
||||
def _remove_task(self, task_id: str) -> None:
|
||||
with self._tasks_lock:
|
||||
task = self._tasks.pop(task_id, None)
|
||||
|
||||
if not task:
|
||||
return
|
||||
|
||||
file_path = task.get("file_path")
|
||||
if file_path:
|
||||
try:
|
||||
Path(file_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cleanup_expired_tasks(self) -> None:
|
||||
now = time.time()
|
||||
expired_task_ids = []
|
||||
|
||||
with self._tasks_lock:
|
||||
for task_id, task in self._tasks.items():
|
||||
created_at = task.get("created_at", now)
|
||||
completed_at = task.get("completed_at")
|
||||
if completed_at and now - completed_at > self.ttl_seconds:
|
||||
expired_task_ids.append(task_id)
|
||||
elif task.get("status") == "failed" and now - created_at > self.ttl_seconds:
|
||||
expired_task_ids.append(task_id)
|
||||
|
||||
for task_id in expired_task_ids:
|
||||
self._remove_task(task_id)
|
||||
|
||||
def _update_task(self, task_id: str, **fields) -> None:
|
||||
with self._tasks_lock:
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
task.update(fields)
|
||||
|
||||
def get_task_or_404(self, task_id: str) -> dict:
|
||||
self.cleanup_expired_tasks()
|
||||
|
||||
with self._tasks_lock:
|
||||
task = self._tasks.get(task_id)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="导出任务不存在或已过期")
|
||||
|
||||
return task
|
||||
|
||||
def get_owned_task_or_404(self, project_id: int, task_id: str, user_id: int) -> dict:
|
||||
task = self.get_task_or_404(task_id)
|
||||
if task["project_id"] != project_id or task["user_id"] != user_id:
|
||||
raise HTTPException(status_code=404, detail="导出任务不存在")
|
||||
return task
|
||||
|
||||
def cleanup_task(self, task_id: str) -> None:
|
||||
self._remove_task(task_id)
|
||||
|
||||
def serialize_task(self, task: dict) -> dict:
|
||||
return self._serialize_task(task)
|
||||
|
||||
def _run_export_task(self, task_id: str, source_dir: Path) -> None:
|
||||
try:
|
||||
self._update_task(
|
||||
task_id,
|
||||
status="scanning",
|
||||
progress=0,
|
||||
message="正在统计导出文件...",
|
||||
processed_files=0,
|
||||
total_files=0,
|
||||
)
|
||||
|
||||
files = [file_path for file_path in source_dir.rglob("*") if file_path.is_file()]
|
||||
total_files = len(files)
|
||||
|
||||
storage_service.temp_root.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = storage_service.temp_root / f"{task_id}.zip"
|
||||
|
||||
self._update_task(
|
||||
task_id,
|
||||
status="zipping",
|
||||
progress=0 if total_files else 100,
|
||||
message="正在打包项目文件...",
|
||||
total_files=total_files,
|
||||
file_path=str(zip_path),
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for index, file_path in enumerate(files, start=1):
|
||||
arcname = file_path.relative_to(source_dir)
|
||||
zip_file.write(file_path, arcname)
|
||||
progress = int(index * 100 / total_files) if total_files else 100
|
||||
self._update_task(
|
||||
task_id,
|
||||
processed_files=index,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
self._update_task(
|
||||
task_id,
|
||||
status="completed",
|
||||
progress=100,
|
||||
message="项目导出已完成",
|
||||
completed_at=time.time(),
|
||||
)
|
||||
except Exception as exc:
|
||||
self._update_task(
|
||||
task_id,
|
||||
status="failed",
|
||||
message="项目导出失败",
|
||||
error=str(exc),
|
||||
completed_at=time.time(),
|
||||
)
|
||||
task = self.get_task_or_404(task_id)
|
||||
file_path = task.get("file_path")
|
||||
if file_path:
|
||||
try:
|
||||
Path(file_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
self._update_task(task_id, file_path=None)
|
||||
|
||||
async def start_export(self, project_id: int, user_id: int, project_name: str, source_dir: Path) -> dict:
|
||||
if not source_dir.exists() or not source_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail="项目目录不存在")
|
||||
|
||||
self.cleanup_expired_tasks()
|
||||
|
||||
task_id = uuid.uuid4().hex
|
||||
task = {
|
||||
"task_id": task_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id,
|
||||
"status": "pending",
|
||||
"message": "导出任务已创建",
|
||||
"progress": 0,
|
||||
"processed_files": 0,
|
||||
"total_files": 0,
|
||||
"zip_filename": self._build_zip_filename(project_name),
|
||||
"file_path": None,
|
||||
"error": None,
|
||||
"created_at": time.time(),
|
||||
"completed_at": None,
|
||||
}
|
||||
|
||||
with self._tasks_lock:
|
||||
self._tasks[task_id] = task
|
||||
|
||||
asyncio.create_task(asyncio.to_thread(self._run_export_task, task_id, source_dir))
|
||||
return self._serialize_task(task)
|
||||
|
||||
|
||||
project_export_service = ProjectExportService()
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
"""
|
||||
项目文件业务服务
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.enums import OperationType
|
||||
from app.models.project import Project
|
||||
from app.models.user import User
|
||||
from app.services.log_service import log_service
|
||||
from app.services.notification_service import notification_service
|
||||
from app.services.search_service import search_service
|
||||
from app.services.storage import storage_service
|
||||
|
||||
|
||||
class ProjectFileService:
|
||||
"""收口项目文件写入、副作用联动和通知流程。"""
|
||||
|
||||
@staticmethod
|
||||
def _actor_name(user: User) -> str:
|
||||
return user.nickname or user.username
|
||||
|
||||
@staticmethod
|
||||
def _source_suffix(source: str) -> str:
|
||||
return " 通过 MCP" if source == "mcp" else ""
|
||||
|
||||
@staticmethod
|
||||
def _detail_with_source(detail: Optional[dict], source: str) -> Optional[dict]:
|
||||
merged = dict(detail or {})
|
||||
if source != "http":
|
||||
merged["source"] = source
|
||||
return merged or None
|
||||
|
||||
@staticmethod
|
||||
async def _update_markdown_index(project_id: int, path: str, content: str) -> None:
|
||||
if path.endswith(".md"):
|
||||
await search_service.update_doc(project_id, path, Path(path).stem, content)
|
||||
|
||||
@staticmethod
|
||||
async def _remove_markdown_index(project_id: int, path: str) -> None:
|
||||
if path.endswith(".md"):
|
||||
await search_service.remove_doc(project_id, path)
|
||||
|
||||
@staticmethod
|
||||
async def _sync_markdown_index_for_move(
|
||||
project_id: int,
|
||||
old_path: str,
|
||||
new_path: str,
|
||||
new_file_path: Path,
|
||||
) -> None:
|
||||
if not old_path.endswith(".md") or not new_path.endswith(".md"):
|
||||
return
|
||||
|
||||
try:
|
||||
content = await storage_service.read_file(new_file_path)
|
||||
await search_service.remove_doc(project_id, old_path)
|
||||
await search_service.update_doc(project_id, new_path, Path(new_path).stem, content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def save_file(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
project: Project,
|
||||
path: str,
|
||||
content: str,
|
||||
user: User,
|
||||
*,
|
||||
request: Optional[Request] = None,
|
||||
source: str = "http",
|
||||
) -> str:
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
await storage_service.write_file(file_path, content)
|
||||
await self._update_markdown_index(project_id, path, content)
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.SAVE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=path,
|
||||
user=user,
|
||||
detail=self._detail_with_source({"content_length": len(content)}, source),
|
||||
request=request,
|
||||
)
|
||||
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=user.id,
|
||||
title="项目文档更新",
|
||||
content=(
|
||||
f"项目 [{project.name}] 中的文档 [{path}] "
|
||||
f"已被 {self._actor_name(user)}{self._source_suffix(source)} 更新。"
|
||||
),
|
||||
link=f"/projects/{project_id}/docs?file={path}",
|
||||
category="project",
|
||||
)
|
||||
await db.commit()
|
||||
return "文件保存成功" if source == "http" else "文件更新成功"
|
||||
|
||||
async def operate_file(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
project: Project,
|
||||
action: str,
|
||||
path: str,
|
||||
user: User,
|
||||
*,
|
||||
new_path: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
request: Optional[Request] = None,
|
||||
source: str = "http",
|
||||
) -> str:
|
||||
current_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
|
||||
if action == "delete":
|
||||
await storage_service.delete_file(current_path)
|
||||
await self._remove_markdown_index(project_id, path)
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.DELETE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=path,
|
||||
user=user,
|
||||
detail=self._detail_with_source(None, source),
|
||||
request=request,
|
||||
)
|
||||
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=user.id,
|
||||
title="项目文档删除",
|
||||
content=(
|
||||
f"项目 [{project.name}] 中的文档 [{path}] "
|
||||
f"已被 {self._actor_name(user)}{self._source_suffix(source)} 删除。"
|
||||
),
|
||||
category="project",
|
||||
)
|
||||
await db.commit()
|
||||
return "删除成功" if source == "http" else "文件删除成功"
|
||||
|
||||
if action in {"rename", "move"}:
|
||||
if not new_path:
|
||||
detail = "缺少新路径参数" if action == "rename" else "缺少目标路径参数"
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
|
||||
destination_path = storage_service.get_secure_path(project.storage_key, new_path)
|
||||
await storage_service.rename_file(current_path, destination_path)
|
||||
await self._sync_markdown_index_for_move(project_id, path, new_path, destination_path)
|
||||
|
||||
operation_type = (
|
||||
OperationType.RENAME_FILE if action == "rename" else OperationType.MOVE_FILE
|
||||
)
|
||||
notification_title = "项目文档重命名" if action == "rename" else "项目文档移动"
|
||||
notification_action = "重命名为" if action == "rename" else "移动到"
|
||||
success_message = "重命名成功" if action == "rename" else "移动成功"
|
||||
mcp_message = "文件重命名成功" if action == "rename" else "文件移动成功"
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=operation_type,
|
||||
project_id=project_id,
|
||||
file_path=path,
|
||||
user=user,
|
||||
detail=self._detail_with_source({"new_path": new_path}, source),
|
||||
request=request,
|
||||
)
|
||||
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=user.id,
|
||||
title=notification_title,
|
||||
content=(
|
||||
f"项目 [{project.name}] 中的文档 [{path}] "
|
||||
f"已被 {self._actor_name(user)}{self._source_suffix(source)} {notification_action} [{new_path}]。"
|
||||
),
|
||||
category="project",
|
||||
)
|
||||
await db.commit()
|
||||
return success_message if source == "http" else mcp_message
|
||||
|
||||
if action == "create_dir":
|
||||
await storage_service.create_directory(current_path)
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.CREATE_DIR,
|
||||
project_id=project_id,
|
||||
file_path=path,
|
||||
user=user,
|
||||
detail=self._detail_with_source(None, source),
|
||||
request=request,
|
||||
)
|
||||
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=user.id,
|
||||
title="创建新目录",
|
||||
content=(
|
||||
f"{self._actor_name(user)}{self._source_suffix(source)} "
|
||||
f"在项目 [{project.name}] 中创建了新目录 [{path}]。"
|
||||
),
|
||||
category="project",
|
||||
)
|
||||
await db.commit()
|
||||
return "目录创建成功"
|
||||
|
||||
if action == "create_file":
|
||||
file_content = content or ""
|
||||
await storage_service.write_file(current_path, file_content)
|
||||
await self._update_markdown_index(project_id, path, file_content)
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.CREATE_FILE,
|
||||
project_id=project_id,
|
||||
file_path=path,
|
||||
user=user,
|
||||
detail=self._detail_with_source(
|
||||
{"content_length": len(file_content)},
|
||||
source,
|
||||
),
|
||||
request=request,
|
||||
)
|
||||
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=user.id,
|
||||
title="创建新文档",
|
||||
content=(
|
||||
f"{self._actor_name(user)}{self._source_suffix(source)} "
|
||||
f"在项目 [{project.name}] 中创建了新文档 [{path}]。"
|
||||
),
|
||||
link=f"/projects/{project_id}/docs?file={path}",
|
||||
category="project",
|
||||
)
|
||||
await db.commit()
|
||||
return "文件创建成功"
|
||||
|
||||
raise HTTPException(status_code=400, detail="不支持的操作类型")
|
||||
|
||||
|
||||
project_file_service = ProjectFileService()
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
"""
|
||||
项目域相关服务
|
||||
"""
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
||||
from app.models.user import User
|
||||
from app.schemas.project import ProjectResponse
|
||||
from app.services.storage import storage_service
|
||||
|
||||
OWNER_ROLE = "owner"
|
||||
PUBLIC_ROLE = "public"
|
||||
|
||||
|
||||
async def get_project_or_404(db: AsyncSession, project_id: int) -> Project:
|
||||
"""获取项目,不存在时抛出 404。"""
|
||||
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="项目不存在")
|
||||
return project
|
||||
|
||||
|
||||
async def get_project_member(
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
user_id: int,
|
||||
) -> Optional[ProjectMember]:
|
||||
"""查询项目成员记录。"""
|
||||
result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_project_role(
|
||||
db: AsyncSession,
|
||||
project: Project,
|
||||
current_user: Optional[User],
|
||||
) -> Optional[str]:
|
||||
"""解析用户在项目中的角色。"""
|
||||
if not current_user:
|
||||
return None
|
||||
|
||||
if project.owner_id == current_user.id:
|
||||
return OWNER_ROLE
|
||||
|
||||
member = await get_project_member(db, project.id, current_user.id)
|
||||
return member.role if member else None
|
||||
|
||||
|
||||
async def require_project_read_access(
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
current_user: Optional[User],
|
||||
*,
|
||||
allow_public: bool = False,
|
||||
unauthenticated_detail: str = "请先登录",
|
||||
forbidden_detail: str = "无权访问该项目",
|
||||
) -> tuple[Project, str]:
|
||||
"""校验项目读取权限。"""
|
||||
project = await get_project_or_404(db, project_id)
|
||||
role = await get_project_role(db, project, current_user)
|
||||
|
||||
if role:
|
||||
return project, role
|
||||
|
||||
if allow_public and project.is_public == 1:
|
||||
return project, PUBLIC_ROLE
|
||||
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail=unauthenticated_detail)
|
||||
|
||||
raise HTTPException(status_code=403, detail=forbidden_detail)
|
||||
|
||||
|
||||
async def require_project_roles(
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
current_user: Optional[User],
|
||||
*,
|
||||
allowed_roles: Sequence[str],
|
||||
allow_public: bool = False,
|
||||
unauthenticated_detail: str = "请先登录",
|
||||
forbidden_detail: str = "无权执行此操作",
|
||||
) -> tuple[Project, str]:
|
||||
"""校验项目角色权限。owner 始终视为通过。"""
|
||||
project, role = await require_project_read_access(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allow_public=allow_public,
|
||||
unauthenticated_detail=unauthenticated_detail,
|
||||
forbidden_detail=forbidden_detail,
|
||||
)
|
||||
|
||||
if role in {OWNER_ROLE, *allowed_roles}:
|
||||
return project, role
|
||||
|
||||
raise HTTPException(status_code=403, detail=forbidden_detail)
|
||||
|
||||
|
||||
async def require_project_write_access(
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
current_user: User,
|
||||
) -> tuple[Project, str]:
|
||||
"""校验项目写权限。"""
|
||||
return await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=[
|
||||
ProjectMemberRole.ADMIN.value,
|
||||
ProjectMemberRole.EDITOR.value,
|
||||
],
|
||||
forbidden_detail="无写入权限",
|
||||
)
|
||||
|
||||
|
||||
def count_project_documents(storage_key: str) -> int:
|
||||
"""统计项目中可见文档数量。"""
|
||||
try:
|
||||
project_path = storage_service.get_secure_path(storage_key)
|
||||
if not project_path.exists():
|
||||
return 0
|
||||
|
||||
md_count = len(list(project_path.rglob("*.md")))
|
||||
pdf_count = len(list(project_path.rglob("*.pdf")))
|
||||
|
||||
assets_dir = project_path / "_assets"
|
||||
assets_md = len(list(assets_dir.rglob("*.md"))) if assets_dir.exists() else 0
|
||||
assets_pdf = len(list(assets_dir.rglob("*.pdf"))) if assets_dir.exists() else 0
|
||||
return md_count + pdf_count - assets_md - assets_pdf
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def serialize_project(project: Project, **extra_fields) -> dict:
|
||||
"""序列化项目,并补充文档统计。"""
|
||||
project_data = ProjectResponse.from_orm(project).dict()
|
||||
project_data["doc_count"] = count_project_documents(project.storage_key)
|
||||
project_data.update(extra_fields)
|
||||
return project_data
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
# 代码结构审计与优化记录(2026-04-08)
|
||||
|
||||
本文依据 [`docs/code-structure-standards.md`](./code-structure-standards.md) 对当前仓库进行前后端结构审计,并记录本轮已落地的优化项。
|
||||
|
||||
## 1. 审计范围
|
||||
|
||||
- 后端:`backend/app/api/v1`、`backend/app/services`
|
||||
- 前端:`frontend/src/pages`、`frontend/src/utils`、`frontend/src/components`
|
||||
|
||||
## 2. 主要结构问题
|
||||
|
||||
### 2.1 后端 Router 过厚
|
||||
|
||||
以下问题在多个 router 中重复出现:
|
||||
|
||||
- 项目存在性校验
|
||||
- 读权限/写权限/成员角色判断
|
||||
- 项目文档数量统计
|
||||
- 公开项目与私密项目访问分支
|
||||
|
||||
受影响较明显的文件:
|
||||
|
||||
- `backend/app/api/v1/projects.py`
|
||||
- `backend/app/api/v1/files.py`
|
||||
- `backend/app/api/v1/preview.py`
|
||||
- `backend/app/api/v1/search.py`
|
||||
- `backend/app/api/v1/git_repos.py`
|
||||
|
||||
这类问题违反了“Router 只做协议转换”和“副作用与规则应收口”的要求,导致:
|
||||
|
||||
- 权限规则难以统一演进
|
||||
- 同一类修改影响多个入口
|
||||
- 新增接口时容易复制旧逻辑继续膨胀
|
||||
|
||||
### 2.2 前端文档页承担过多页面级编排
|
||||
|
||||
`frontend/src/pages/Document/DocumentPage.jsx` 在审计前同时承担了:
|
||||
|
||||
- 文件树加载
|
||||
- URL deep link 同步
|
||||
- 搜索与节点展开
|
||||
- Markdown 文件加载
|
||||
- PDF URL 组装
|
||||
- Markdown 内链解析
|
||||
- TOC 生成
|
||||
- 页面展示渲染
|
||||
|
||||
这已经明显超过“页面入口必须薄”的建议范围,属于页面入口和页面编排层混杂。
|
||||
|
||||
### 2.3 前端认证存储边界分散
|
||||
|
||||
`access_token` 与登录态清理逻辑分散在:
|
||||
|
||||
- `request.js`
|
||||
- `ProtectedRoute.jsx`
|
||||
- `userStore.js`
|
||||
- 多个页面文件
|
||||
|
||||
这违背了“前端基础设施应收口”的要求,也增加了未来切换 token 策略时的修改面。
|
||||
|
||||
## 3. 本轮已落地优化
|
||||
|
||||
### 3.1 后端新增项目域服务
|
||||
|
||||
新增:
|
||||
|
||||
- `backend/app/services/project_service.py`
|
||||
|
||||
收口能力:
|
||||
|
||||
- 项目存在性查询
|
||||
- 项目成员查询
|
||||
- 项目角色解析
|
||||
- 项目读权限校验
|
||||
- 项目角色权限校验
|
||||
- 项目写权限校验
|
||||
- 项目文档数量统计
|
||||
- 项目序列化补充 `doc_count`
|
||||
|
||||
### 3.2 后端 Router 瘦身
|
||||
|
||||
已切换到项目域服务的文件:
|
||||
|
||||
- `backend/app/api/v1/projects.py`
|
||||
- `backend/app/api/v1/files.py`
|
||||
- `backend/app/api/v1/preview.py`
|
||||
- `backend/app/api/v1/search.py`
|
||||
- `backend/app/api/v1/git_repos.py`
|
||||
|
||||
效果:
|
||||
|
||||
- 权限判断不再散落在每个 handler 内
|
||||
- `projects.py` 的列表序列化不再自己统计文档数
|
||||
- `preview.py` 的公开/私密访问逻辑集中复用
|
||||
- `files.py` 的读写权限边界更明确
|
||||
|
||||
### 3.3 前端抽离文档页编排层
|
||||
|
||||
新增:
|
||||
|
||||
- `frontend/src/pages/Document/useDocumentBrowser.js`
|
||||
- `frontend/src/pages/Document/documentBrowserUtils.jsx`
|
||||
|
||||
抽离后的职责:
|
||||
|
||||
- 文件树加载与状态维护
|
||||
- URL 参数驱动的文档打开
|
||||
- Markdown/PDF 切换
|
||||
- 搜索与节点展开
|
||||
- TOC 生成
|
||||
- Markdown 内链解析
|
||||
|
||||
`frontend/src/pages/Document/DocumentPage.jsx` 现在主要保留:
|
||||
|
||||
- 页面布局
|
||||
- Git 操作 UI
|
||||
- 分享设置 UI
|
||||
- Markdown 渲染输出
|
||||
|
||||
### 3.4 前端认证存储收口
|
||||
|
||||
新增:
|
||||
|
||||
- `frontend/src/utils/authStorage.js`
|
||||
|
||||
已接入:
|
||||
|
||||
- `frontend/src/utils/request.js`
|
||||
- `frontend/src/components/ProtectedRoute.jsx`
|
||||
- `frontend/src/stores/userStore.js`
|
||||
- `frontend/src/pages/Preview/PreviewPage.jsx`
|
||||
- `frontend/src/pages/Document/DocumentPage.jsx`
|
||||
|
||||
### 3.5 继续下沉编辑页与浏览器副作用
|
||||
|
||||
新增:
|
||||
|
||||
- `frontend/src/pages/Document/useDocumentEditorWorkspace.js`
|
||||
- `frontend/src/utils/browserIO.js`
|
||||
- `frontend/src/pages/ProjectList/useProjectExport.js`
|
||||
- `frontend/src/pages/ProjectList/useProjectShare.js`
|
||||
- `frontend/src/pages/ProjectList/useProjectCollaboration.js`
|
||||
- `frontend/src/pages/ProjectList/useProjectGitRepos.js`
|
||||
|
||||
本轮继续优化后:
|
||||
|
||||
- `DocumentEditor.jsx` 中的树加载、URL 同步、文件打开、刷新流程已下沉到工作区 hook
|
||||
- 项目导出、文件名解析、复制链接等浏览器能力已从页面中抽出为通用工具
|
||||
- `ProjectList.jsx` 与 `DocumentPage.jsx` 不再自己维护复制降级实现
|
||||
- `ProjectList.jsx` 中的导出轮询、分享设置、成员协作、Git 仓库管理已各自收口为稳定子流程
|
||||
|
||||
### 3.6 补齐后端文件用例与预览页编排层
|
||||
|
||||
新增:
|
||||
|
||||
- `backend/app/services/project_export_service.py`
|
||||
- `backend/app/services/project_file_service.py`
|
||||
- `frontend/src/pages/Preview/usePreviewBrowser.js`
|
||||
- `frontend/src/pages/Preview/previewBrowserUtils.js`
|
||||
|
||||
本轮继续优化后:
|
||||
|
||||
- `projects.py` 中的导出任务状态管理、过期清理与 ZIP 打包流程已下沉到独立导出服务
|
||||
- `files.py` 的保存、创建、删除、重命名、移动流程已通过项目文件服务统一编排
|
||||
- `backend/app/mcp/server.py` 不再复制文件写入后的索引、日志、通知逻辑,改为复用同一文件用例服务
|
||||
- `PreviewPage.jsx` 中的密码校验、目录树加载、文档切换、搜索、TOC 与 PDF/Markdown 模式切换已下沉到页面级 hook
|
||||
|
||||
## 4. 验证结果
|
||||
|
||||
已执行:
|
||||
|
||||
1. `python3 -m py_compile backend/app/services/project_service.py backend/app/api/v1/projects.py backend/app/api/v1/files.py backend/app/api/v1/preview.py backend/app/api/v1/search.py backend/app/api/v1/git_repos.py`
|
||||
2. `npm run build`(在 `frontend/` 下)
|
||||
|
||||
结果:
|
||||
|
||||
- 后端语法校验通过
|
||||
- 前端生产构建通过
|
||||
- 仍存在 Vite 默认的大包告警,属于性能优化项,不影响本轮结构改造正确性
|
||||
|
||||
## 5. 剩余建议
|
||||
|
||||
以下问题已在审计中确认,但未在本轮一并处理,以控制风险:
|
||||
|
||||
### 5.1 前端仍有超大页面待继续下沉
|
||||
|
||||
重点文件:
|
||||
|
||||
- `frontend/src/pages/Document/DocumentEditor.jsx`
|
||||
- `frontend/src/pages/ProjectList/ProjectList.jsx`
|
||||
- `frontend/src/pages/Preview/PreviewPage.jsx` 已完成一轮下沉,但页面内仍保留部分响应式布局与渲染分支,可后续继续压薄
|
||||
|
||||
建议方向:
|
||||
|
||||
- 将上传/导出/重命名/移动等流程拆为页面级 controller 或 hook
|
||||
- 将下载、文件名解析、Blob 导出等浏览器副作用进一步沉到可复用基础模块
|
||||
|
||||
### 5.2 前端构建产物体积偏大
|
||||
|
||||
构建结果中仍存在明显的大 chunk 警告,建议后续评估:
|
||||
|
||||
- 文档页与编辑页按路由拆包
|
||||
- PDF/Markdown 编辑器相关库延迟加载
|
||||
- 搜索与预览相关能力按场景拆分
|
||||
|
||||
### 5.3 后端入口层仍有剩余热点
|
||||
|
||||
虽然项目导出和文件编排已下沉,但以下热点仍值得继续优化:
|
||||
|
||||
- `backend/app/api/v1/projects.py` 仍包含较多成员管理与分享配置流程,可继续按主题拆出更明确的 use case
|
||||
- `backend/app/api/v1/files.py` 仍承载上传、导入、导出等多类文件主题,后续可继续按“编辑文件 / 传输文件 / 读取文档”拆分
|
||||
|
||||
## 6. 结论
|
||||
|
||||
本轮优化重点完成了两件事:
|
||||
|
||||
1. 后端将重复的项目访问规则从 router 中收口到项目域服务
|
||||
2. 前端将文档浏览页的页面级编排从页面入口中拆出,并统一认证存储边界
|
||||
|
||||
这两项改动都直接对应结构规范中的高优先级问题,属于低风险、可验证、后续可继续扩展的结构优化。
|
||||
|
|
@ -0,0 +1,499 @@
|
|||
# 通用代码结构设计规范(强制执行)
|
||||
|
||||
本文档定义项目在长期演进中应遵守的结构边界、拆分原则与审计标准。
|
||||
|
||||
目标不是机械追求“小文件”“多目录”或“某种固定架构”,而是让任意语言、任意框架、任意部署形态下的代码都满足以下要求:
|
||||
|
||||
- 入口清晰
|
||||
- 依赖方向稳定
|
||||
- 职责边界明确
|
||||
- 修改影响面可控
|
||||
- 新人可顺序读懂
|
||||
|
||||
本文档适用于:
|
||||
|
||||
- 前端应用
|
||||
- 后端服务
|
||||
- CLI / 脚本 / Worker
|
||||
- SDK / Library
|
||||
- 单仓或多仓项目
|
||||
|
||||
本文档自落地起作为后续开发与重构的默认结构基线。
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心原则
|
||||
|
||||
### 1.1 先划清职责,再决定目录
|
||||
|
||||
- 先区分“入口层 / 业务编排层 / 领域规则层 / 基础设施层 / 共享基础层”,再决定是否拆目录、拆文件、拆包。
|
||||
- 目录结构是职责设计的结果,不是先验答案。
|
||||
- 同一个团队可以采用不同目录形态,但不能模糊职责边界。
|
||||
|
||||
### 1.2 领域内聚优先于机械拆分
|
||||
|
||||
- 第一判断标准是“是否仍然属于同一业务主题”,不是“文件还能不能再拆小”。
|
||||
- 同一主题内的读取、写入、校验、少量派生逻辑,可以保留在同一模块中。
|
||||
- 如果拆分只会制造更多跳转、隐藏真实依赖、降低顺序可读性,就不应继续拆。
|
||||
|
||||
### 1.3 装配层必须薄
|
||||
|
||||
- 启动入口、路由入口、页面入口、命令入口都只负责装配。
|
||||
- 装配层可以做依赖注入、参数收集、状态接线、组件拼装、调用编排入口。
|
||||
- 装配层不应承载复杂业务规则、数据库细节、文件系统细节、网络细节或长流程状态机。
|
||||
|
||||
### 1.4 副作用必须收口
|
||||
|
||||
- 数据库访问、文件读写、网络调用、缓存、定时器、浏览器存储、进程环境依赖等,都属于副作用。
|
||||
- 副作用应集中在可识别的边界模块中,不应在页面、视图、路由、DTO、纯工具里四处散落。
|
||||
- 任何需要 mock、替换、复用或测试隔离的外部依赖,都应有明确归属。
|
||||
|
||||
### 1.5 依赖方向必须单向
|
||||
|
||||
- 默认依赖方向应从外向内:入口层 -> 业务编排层 -> 领域规则层 -> 基础设施实现。
|
||||
- 共享基础层可以被多个层使用,但不能反向依赖业务实现。
|
||||
- 低层不能反向引用高层具体实现来“图省事”。
|
||||
|
||||
### 1.6 文件大小不是目标,跨职责才是风险
|
||||
|
||||
- 行数只作为预警信号,不作为强制拆分指标。
|
||||
- 真正需要拆分的信号包括:
|
||||
- 一个模块服务多个业务主题
|
||||
- 一个模块同时承担输入解析、业务决策、数据访问和展示
|
||||
- 一个改动常常需要在同一文件中切换多种关注点
|
||||
- 一个模块需要为不同调用方维持多套语义
|
||||
|
||||
### 1.7 重构优先低风险搬运
|
||||
|
||||
- 结构重构优先做“职责收口、边界清理、命名校正、依赖下沉/上提”。
|
||||
- 默认不要在同一轮改动里同时进行:
|
||||
- 大规模结构调整
|
||||
- 新功能开发
|
||||
- 行为修复
|
||||
- 如果确需并行,必须以最小范围控制风险,并显式验证关键路径。
|
||||
|
||||
### 1.8 命名必须体现主题
|
||||
|
||||
- 文件、目录、模块、类型、服务名都应直接表达责任。
|
||||
- 禁止使用模糊命名掩盖职责,例如:
|
||||
- `misc`
|
||||
- `helpers2`
|
||||
- `commonThing`
|
||||
- `temp_service`
|
||||
- `manager_new`
|
||||
|
||||
---
|
||||
|
||||
## 2. 通用分层模型
|
||||
|
||||
以下是跨语言可复用的职责模型。项目不要求逐字使用这些目录名,但必须能映射到这些边界。
|
||||
|
||||
### 2.1 入口层 / 接口层
|
||||
|
||||
典型形态:
|
||||
|
||||
- 前端的 `App`、路由入口、页面入口
|
||||
- 后端的 router / controller / handler
|
||||
- CLI 的 command / main
|
||||
- Worker 的 job handler / consumer entry
|
||||
|
||||
职责:
|
||||
|
||||
- 接收输入
|
||||
- 做基础参数解析与协议适配
|
||||
- 调用业务编排层
|
||||
- 返回结果或渲染输出
|
||||
|
||||
禁止:
|
||||
|
||||
- 写复杂业务规则
|
||||
- 直接拼装 SQL / ORM 流程
|
||||
- 直接进行大段文件系统读写
|
||||
- 直接进行复杂网络编排
|
||||
- 在入口层内维护长生命周期状态机
|
||||
|
||||
### 2.2 业务编排层 / 用例层
|
||||
|
||||
典型形态:
|
||||
|
||||
- service
|
||||
- use case
|
||||
- action
|
||||
- controller hook
|
||||
- page model
|
||||
- workflow
|
||||
|
||||
职责:
|
||||
|
||||
- 表达一个明确业务流程
|
||||
- 协调多个依赖
|
||||
- 承载事务边界、步骤顺序、状态推进
|
||||
- 组织权限判断、前置校验、错误分支
|
||||
|
||||
要求:
|
||||
|
||||
- 一个模块只负责一个业务域或一个稳定子流程
|
||||
- 可以依赖基础设施接口,但不应把具体协议细节暴露给上层
|
||||
- 可以包含少量私有 helper,但 helper 仅服务当前主题
|
||||
|
||||
### 2.3 领域规则层
|
||||
|
||||
典型形态:
|
||||
|
||||
- domain service
|
||||
- policy
|
||||
- rule
|
||||
- validator
|
||||
- entity behavior
|
||||
- pure business helpers
|
||||
|
||||
职责:
|
||||
|
||||
- 承载稳定的业务规则和领域语义
|
||||
- 保持尽量纯净、可测试、与外部协议解耦
|
||||
- 统一业务概念、状态转换、派生计算
|
||||
|
||||
要求:
|
||||
|
||||
- 不直接访问数据库、网络、文件、浏览器环境
|
||||
- 不依赖 UI、HTTP、CLI、消息队列等入口协议
|
||||
|
||||
### 2.4 基础设施层 / 数据访问层
|
||||
|
||||
典型形态:
|
||||
|
||||
- repository
|
||||
- gateway
|
||||
- API client
|
||||
- storage adapter
|
||||
- cache adapter
|
||||
- filesystem adapter
|
||||
- persistence implementation
|
||||
|
||||
职责:
|
||||
|
||||
- 封装外部系统细节
|
||||
- 处理数据库、缓存、HTTP、对象存储、消息队列、浏览器存储、操作系统能力
|
||||
- 提供可复用的边界接口
|
||||
|
||||
要求:
|
||||
|
||||
- 只解决“怎么接外部系统”,不承担业务决策
|
||||
- 协议转换、序列化、连接管理、重试策略等应在此层收口
|
||||
|
||||
### 2.5 共享基础层
|
||||
|
||||
典型形态:
|
||||
|
||||
- constants
|
||||
- shared types
|
||||
- date / string / number helpers
|
||||
- 通用 UI 基础组件
|
||||
- 通用错误定义
|
||||
|
||||
要求:
|
||||
|
||||
- 必须是真正跨域、稳定、低语义耦合的内容
|
||||
- 不允许把业务逻辑伪装成“common / shared / utils”
|
||||
- 一旦某模块开始依赖特定业务名词,它就不再是共享基础层
|
||||
|
||||
---
|
||||
|
||||
## 3. 目录与模块组织规范
|
||||
|
||||
### 3.1 允许的组织方式
|
||||
|
||||
项目可以采用以下任一方式:
|
||||
|
||||
- 按领域优先组织:`<domain>/<entry|application|infra|shared>`
|
||||
- 按层优先组织:`entry/ application/ domain/ infra/`
|
||||
- 混合组织:顶层按领域,领域内再分层
|
||||
- Monorepo 组织:`apps/ packages/ services/ workers/`
|
||||
|
||||
允许多种组织方式并存,但必须满足:
|
||||
|
||||
- 同一仓库内的同类代码遵循一致的判断逻辑
|
||||
- 每个模块都能被映射到明确职责层
|
||||
- 依赖方向清晰、稳定、可审计
|
||||
|
||||
### 3.2 推荐的判断方式
|
||||
|
||||
当你不确定某段代码应该放哪里时,按顺序判断:
|
||||
|
||||
1. 它是在接收输入、渲染输出、还是拼装启动吗
|
||||
2. 它是在表达一个完整业务流程吗
|
||||
3. 它是在表达不依赖外部协议的业务规则吗
|
||||
4. 它是在接数据库、文件、网络、缓存、浏览器或系统能力吗
|
||||
5. 它真的是跨域共享能力吗
|
||||
|
||||
### 3.3 一个模块只能有一个主语义
|
||||
|
||||
- 一个模块可以有多个函数,但只能服务一个主职责。
|
||||
- 如果一个文件既是“页面”又是“API 聚合器”又是“缓存控制器”又是“视图组件”,就已经越界。
|
||||
- 如果一个 router 文件开始长时间停留在 SQL、文件读写、事务与状态轮询细节上,也已经越界。
|
||||
|
||||
### 3.4 兼容层与过渡层
|
||||
|
||||
- 允许存在短期兼容层、导出层、适配层。
|
||||
- 兼容层必须被明确标记为过渡用途。
|
||||
- 禁止长期把新逻辑继续堆回兼容层。
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端通用规范
|
||||
|
||||
本节适用于 Web、桌面端、移动端和前端壳应用,不绑定 React / Vue / Svelte 等具体框架。
|
||||
|
||||
### 4.1 页面/路由入口必须薄
|
||||
|
||||
- 页面文件默认负责页面装配、布局组织、边界兜底。
|
||||
- 页面可持有少量与页面展示强绑定的状态。
|
||||
- 当页面开始同时承担以下两项及以上时,应拆出页面级编排模块:
|
||||
- 多个接口请求
|
||||
- 轮询或定时器
|
||||
- 上传/下载流程
|
||||
- 多个 Drawer / Modal / Sheet 子流程
|
||||
- 复杂权限判断
|
||||
- 大量数据清洗与派生
|
||||
|
||||
### 4.2 视图组件默认无副作用
|
||||
|
||||
- 纯视图组件只接收整理好的 props。
|
||||
- 纯视图组件默认不直接请求接口、不直接碰浏览器存储、不直接起轮询。
|
||||
- 如果一个组件必须自带数据流程,它应被明确视为“功能组件”或“场景组件”,而不是伪装成通用组件。
|
||||
|
||||
### 4.3 页面级业务流程应集中编排
|
||||
|
||||
- 页面相关的请求、轮询、草稿保存、上传进度、权限行为、状态联动,应尽量集中在页面编排层。
|
||||
- 页面编排层可以是 hook、store、controller、presenter 或 view-model,不限定技术名词。
|
||||
- 不要求为了形式而一律抽 hook;只有在页面入口已经承担过多流程时才拆。
|
||||
|
||||
### 4.4 前端基础设施应收口
|
||||
|
||||
- API client
|
||||
- 本地存储
|
||||
- Session / token 持久化
|
||||
- 浏览器标题、副作用事件、定时器策略
|
||||
- 配置读取
|
||||
|
||||
以上能力应收口在可识别模块中,不应被页面随机复制。
|
||||
|
||||
### 4.5 复用原则
|
||||
|
||||
- 提炼稳定复用模式,不提炼偶然重复。
|
||||
- 三处以上重复,优先评估抽取。
|
||||
- 如果抽取后的接口比原地代码更难理解,不应抽取。
|
||||
- 不允许制造“只有一个页面使用、但包装层很多”的伪复用。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端通用规范
|
||||
|
||||
本节适用于 HTTP 服务、RPC 服务、任务处理器和后台作业,不绑定 FastAPI / Spring / NestJS / Gin 等框架。
|
||||
|
||||
### 5.1 启动入口必须只做装配
|
||||
|
||||
- `main`
|
||||
- app factory
|
||||
- bootstrap
|
||||
- container
|
||||
|
||||
这些入口只负责:
|
||||
|
||||
- 创建应用实例
|
||||
- 注册路由/处理器
|
||||
- 初始化中间件
|
||||
- 装配依赖
|
||||
- 生命周期绑定
|
||||
|
||||
不应承担:
|
||||
|
||||
- 业务规则
|
||||
- SQL 或 ORM 编排
|
||||
- 文件系统细节
|
||||
- 长流程任务控制
|
||||
|
||||
### 5.2 Router / Controller / Handler 只做协议转换
|
||||
|
||||
允许:
|
||||
|
||||
- 接收请求参数
|
||||
- 基础校验
|
||||
- 调用用例层
|
||||
- 将领域错误映射为接口错误
|
||||
|
||||
不允许:
|
||||
|
||||
- 在 handler 内直接堆大量 SQL
|
||||
- 一边处理权限,一边处理事务,一边操作文件,一边组装响应模型
|
||||
- 在 handler 内实现长流程状态机
|
||||
|
||||
### 5.3 Service / Use Case 以业务域组织
|
||||
|
||||
- 一个 service 文件只负责一个业务域或一个稳定子主题。
|
||||
- 同域内的查询、写入、校验、少量派生逻辑可以在一起。
|
||||
- 如果一个 service 同时承担多个主题,应优先拆主题而不是拆技术动作。
|
||||
|
||||
### 5.4 数据访问与外部适配要收口
|
||||
|
||||
- 数据库查询
|
||||
- ORM 组装
|
||||
- 缓存细节
|
||||
- 第三方 HTTP 调用
|
||||
- 文件上传下载
|
||||
- 对象存储
|
||||
- 队列/任务系统
|
||||
|
||||
这些细节应尽量沉到 repository / gateway / adapter / infra 中。
|
||||
|
||||
### 5.5 Schema / DTO / Contract 必须纯净
|
||||
|
||||
- DTO 只用于定义契约,不应携带数据库、文件系统、网络调用或业务副作用。
|
||||
- 契约字段演进必须可追踪。
|
||||
- 避免让数据库模型、接口模型、领域模型长期混成一种结构。
|
||||
|
||||
---
|
||||
|
||||
## 6. CLI / 脚本 / Worker 规范
|
||||
|
||||
- 命令入口只负责解析参数、准备依赖、调用用例。
|
||||
- 脚本如果会长期保留,必须从“一次性脚本”升级为可读的结构化模块。
|
||||
- Worker handler 只负责接收消息、提取 payload、调用业务流程、回写状态。
|
||||
- 重试、幂等、死信、超时策略等运行时策略应有单独归属,不应散落在业务逻辑内部。
|
||||
|
||||
---
|
||||
|
||||
## 7. 拆分与合并准则
|
||||
|
||||
### 7.1 何时应拆分
|
||||
|
||||
满足任一项即可考虑拆分:
|
||||
|
||||
- 同一模块出现多个业务主题
|
||||
- 同一模块同时依赖多种外部系统
|
||||
- 同一模块同时承担输入解析、业务编排、持久化和展示
|
||||
- 多人修改时经常产生冲突
|
||||
- 阅读一个改动需要频繁跨越无关上下文
|
||||
- 相同规则被复制到多个入口
|
||||
|
||||
### 7.2 何时不应拆分
|
||||
|
||||
- 仍是单一主题
|
||||
- 代码虽长但顺序可读
|
||||
- 继续拆只会制造纯转发层
|
||||
- 继续拆会让调用链更深、定位更慢
|
||||
- 抽出后接口语义比原代码更含糊
|
||||
|
||||
### 7.3 合并也是一种优化
|
||||
|
||||
- 如果多个模块只是在相互转发、没有独立语义,应考虑合并。
|
||||
- 如果拆分之后需要同时打开 4 到 6 个文件才能理解一个简单流程,通常已经过度拆分。
|
||||
|
||||
---
|
||||
|
||||
## 8. 命名与依赖规则
|
||||
|
||||
### 8.1 命名规则
|
||||
|
||||
- 名称应表达业务主题或技术边界,不表达情绪和历史包袱。
|
||||
- 优先使用“对象 + 语义”命名,而不是“抽象 + 序号”命名。
|
||||
- 避免模糊后缀:
|
||||
- `handler2`
|
||||
- `newService`
|
||||
- `commonUtils`
|
||||
- `tempPage`
|
||||
|
||||
### 8.2 依赖规则
|
||||
|
||||
- 上层可以依赖下层抽象,不应依赖下层杂乱细节。
|
||||
- 共享层不能反向依赖业务层。
|
||||
- 领域模块之间若需协作,应通过明确用例、接口或边界对象完成,而不是互相穿透内部实现。
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试与验证要求
|
||||
|
||||
### 9.1 结构改动后的默认验证
|
||||
|
||||
- 前端结构改动后,至少执行构建或类型校验。
|
||||
- 后端结构改动后,至少执行语法校验、启动校验或最小测试集。
|
||||
- 如果改动涉及契约、权限、状态流转、持久化边界,应追加针对性验证。
|
||||
|
||||
### 9.2 测试优先级
|
||||
|
||||
- 先保关键业务路径
|
||||
- 再保跨层边界
|
||||
- 再保复杂状态流转
|
||||
- 最后补充纯工具覆盖
|
||||
|
||||
### 9.3 文档同步要求
|
||||
|
||||
以下情况必须同步设计文档或架构说明:
|
||||
|
||||
- 新增一层明确职责边界
|
||||
- 新增一个稳定领域模块模板
|
||||
- 改变入口层、用例层、基础设施层的责任划分
|
||||
- 引入新的运行时或新的跨项目复用规范
|
||||
|
||||
---
|
||||
|
||||
## 10. 评审与审计清单
|
||||
|
||||
做结构评审时,默认检查以下问题:
|
||||
|
||||
### 10.1 入口层
|
||||
|
||||
- 入口是否足够薄
|
||||
- 是否混入业务规则
|
||||
- 是否混入外部系统细节
|
||||
|
||||
### 10.2 业务编排层
|
||||
|
||||
- 是否以业务主题组织
|
||||
- 是否承担了过多无关流程
|
||||
- 是否存在纯转发服务
|
||||
|
||||
### 10.3 领域规则层
|
||||
|
||||
- 是否仍保持纯净
|
||||
- 是否被框架、HTTP、数据库协议污染
|
||||
|
||||
### 10.4 基础设施层
|
||||
|
||||
- 副作用是否收口
|
||||
- 是否把业务规则偷偷塞回 adapter / utils / core
|
||||
|
||||
### 10.5 共享层
|
||||
|
||||
- 是否真的跨域复用
|
||||
- 是否把业务逻辑伪装成 common / shared / utils
|
||||
|
||||
### 10.6 演进风险
|
||||
|
||||
- 新增功能是否沿着既有边界落位
|
||||
- 兼容层是否在持续变厚
|
||||
- 是否出现单文件多职责继续膨胀
|
||||
|
||||
---
|
||||
|
||||
## 11. 禁止事项
|
||||
|
||||
- 禁止为了图省事把新逻辑堆回入口层
|
||||
- 禁止为了“文件更短”制造无语义的纯包装层
|
||||
- 禁止在 `utils` / `common` / `shared` 中隐藏领域逻辑
|
||||
- 禁止让页面、路由、handler 直接承载大量持久化或文件系统细节
|
||||
- 禁止把 schema / DTO / config 当成业务逻辑容器
|
||||
- 禁止一次改动里同时重写结构、协议、UI 和业务行为,且没有明确验证策略
|
||||
|
||||
---
|
||||
|
||||
## 12. 执行基线
|
||||
|
||||
后续所有新增功能、重构与代码审计,均以本文档为默认判断依据:
|
||||
|
||||
- 先判断职责边界是否正确
|
||||
- 再判断依赖方向是否健康
|
||||
- 再判断是否需要拆分或合并
|
||||
- 最后才考虑目录美观、文件长短和风格一致性
|
||||
|
||||
当“看起来更模块化”和“真实可读、可改、可验证”发生冲突时,优先后者。
|
||||
|
|
@ -17,6 +17,7 @@ import ProfilePage from '@/pages/Profile/ProfilePage'
|
|||
import Permissions from '@/pages/System/Permissions'
|
||||
import Users from '@/pages/System/Users'
|
||||
import Roles from '@/pages/System/Roles'
|
||||
import ModelConfigs from '@/pages/System/ModelConfigs'
|
||||
import SystemLogs from '@/pages/SystemLogs/SystemLogs'
|
||||
import NotificationList from '@/pages/Notifications/NotificationList'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
|
|
@ -80,6 +81,7 @@ function App() {
|
|||
<Route path="/system/permissions" element={<Permissions />} />
|
||||
<Route path="/system/users" element={<Users />} />
|
||||
<Route path="/system/roles" element={<Roles />} />
|
||||
<Route path="/system/model-configs" element={<ModelConfigs />} />
|
||||
<Route path="/system/logs" element={<SystemLogs />} />
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
export function getLLMProviderCatalog() {
|
||||
return request({
|
||||
url: '/llm-model-configs/providers',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function getLLMModelConfigs(params) {
|
||||
return request({
|
||||
url: '/llm-model-configs/',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getLLMModelConfigDetail(configId) {
|
||||
return request({
|
||||
url: `/llm-model-configs/${configId}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function createLLMModelConfig(data) {
|
||||
return request({
|
||||
url: '/llm-model-configs/',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateLLMModelConfig(configId, data) {
|
||||
return request({
|
||||
url: `/llm-model-configs/${configId}`,
|
||||
method: 'put',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateLLMModelConfigStatus(configId, isActive) {
|
||||
return request({
|
||||
url: `/llm-model-configs/${configId}/status`,
|
||||
method: 'put',
|
||||
params: { is_active: isActive },
|
||||
})
|
||||
}
|
||||
|
||||
export function setDefaultLLMModelConfig(configId) {
|
||||
return request({
|
||||
url: `/llm-model-configs/${configId}/default`,
|
||||
method: 'put',
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteLLMModelConfig(configId) {
|
||||
return request({
|
||||
url: `/llm-model-configs/${configId}`,
|
||||
method: 'delete',
|
||||
})
|
||||
}
|
||||
|
||||
export function testLLMModelConfig(data) {
|
||||
return request({
|
||||
url: '/llm-model-configs/test',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
|
@ -86,6 +86,38 @@ export function transferProject(projectId, newOwnerId) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动项目导出任务
|
||||
*/
|
||||
export function startProjectExport(projectId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/export`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目导出任务状态
|
||||
*/
|
||||
export function getProjectExportStatus(projectId, taskId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/export/${taskId}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载项目导出 ZIP
|
||||
*/
|
||||
export function downloadProjectExport(projectId, taskId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/export/${taskId}/download`,
|
||||
method: 'get',
|
||||
responseType: 'blob',
|
||||
timeout: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目成员
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* 帮助面板样式 */
|
||||
.action-help-panel .ant-drawer-header {
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.help-panel-title {
|
||||
|
|
@ -160,7 +160,7 @@
|
|||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border: 1px solid var(--border-color-strong);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 12px;
|
||||
|
|
@ -179,7 +179,7 @@
|
|||
.help-action-item {
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border: 1px solid #f0f0f0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
|
@ -213,7 +213,7 @@
|
|||
.help-action-item-shortcut {
|
||||
padding: 2px 6px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #d9d9d9;
|
||||
border: 1px solid var(--border-color-strong);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
/* 主题样式 */
|
||||
.bottom-hint-bar-light {
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.bottom-hint-bar-dark {
|
||||
|
|
@ -168,7 +168,7 @@
|
|||
|
||||
.bottom-hint-bar-light .shortcut-kbd {
|
||||
background: #f0f0f0;
|
||||
border-color: #d9d9d9;
|
||||
border-color: var(--border-color-strong);
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
|
@ -194,7 +194,7 @@
|
|||
|
||||
.bottom-hint-bar-light .hint-bar-close {
|
||||
background: #f0f0f0;
|
||||
border-color: #d9d9d9;
|
||||
border-color: var(--border-color-strong);
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
/* 引导弹窗样式 */
|
||||
.button-guide-modal .ant-modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
|
|
@ -152,7 +152,7 @@
|
|||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.guide-footer-item {
|
||||
|
|
@ -170,7 +170,7 @@
|
|||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border: 1px solid var(--border-color-strong);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 11px;
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
/* 引导弹窗样式 */
|
||||
.button-guide-modal .ant-modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
|
|
@ -199,7 +199,7 @@
|
|||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.guide-footer-item {
|
||||
|
|
@ -217,7 +217,7 @@
|
|||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border: 1px solid var(--border-color-strong);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 11px;
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hover-card-title-wrapper {
|
||||
|
|
@ -144,7 +144,7 @@
|
|||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.footer-label {
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border: 1px solid var(--border-color-strong);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 11px;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs-nav::before) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-drawer-tabs :global(.ant-tabs-tab) {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
.info-panel > :global(.ant-row) {
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-panel-item {
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
.info-panel-actions {
|
||||
padding: 24px 32px;
|
||||
background: linear-gradient(to bottom, #fafafa 0%, #f5f5f5 100%);
|
||||
border-top: 2px solid #e8e8e8;
|
||||
border-top: 2px solid var(--border-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
|
@ -93,4 +93,3 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
.app-sider {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: #fafafa;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
background: var(--bg-color-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
.sider-menu {
|
||||
border-right: none;
|
||||
padding-top: 8px;
|
||||
background: #fafafa;
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
/* 收起状态下的图标放大 */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
DesktopOutlined,
|
||||
GlobalOutlined,
|
||||
CloudServerOutlined,
|
||||
FileSearchOutlined,
|
||||
UserOutlined,
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
|
|
@ -29,6 +30,7 @@ const iconMap = {
|
|||
DesktopOutlined: <DesktopOutlined />,
|
||||
GlobalOutlined: <GlobalOutlined />,
|
||||
CloudServerOutlined: <CloudServerOutlined />,
|
||||
FileSearchOutlined: <FileSearchOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
|
|
@ -42,6 +44,133 @@ const iconMap = {
|
|||
BookOutlined: <BookOutlined />,
|
||||
}
|
||||
|
||||
const builtInMenuMetaMap = {
|
||||
dashboard: {
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
},
|
||||
desktop: {
|
||||
path: '/desktop',
|
||||
icon: 'DesktopOutlined',
|
||||
},
|
||||
'projects:my': {
|
||||
path: '/projects/my',
|
||||
icon: 'FolderOutlined',
|
||||
},
|
||||
'projects:share': {
|
||||
path: '/projects/share',
|
||||
icon: 'TeamOutlined',
|
||||
},
|
||||
'knowledge:my': {
|
||||
path: '/knowledge',
|
||||
icon: 'ReadOutlined',
|
||||
},
|
||||
'system:users': {
|
||||
path: '/system/users',
|
||||
icon: 'UserOutlined',
|
||||
},
|
||||
'system:roles': {
|
||||
path: '/system/roles',
|
||||
icon: 'TeamOutlined',
|
||||
},
|
||||
'system:permissions': {
|
||||
path: '/system/permissions',
|
||||
icon: 'SafetyOutlined',
|
||||
},
|
||||
'system:model-configs': {
|
||||
path: '/system/model-configs',
|
||||
icon: 'CloudServerOutlined',
|
||||
},
|
||||
'system:logs': {
|
||||
path: '/system/logs',
|
||||
icon: 'FileSearchOutlined',
|
||||
},
|
||||
}
|
||||
|
||||
const resolveBuiltInMenuMeta = (item) => {
|
||||
const directMeta = builtInMenuMetaMap[item.menu_code]
|
||||
if (directMeta) {
|
||||
return directMeta
|
||||
}
|
||||
|
||||
if (item.path === '/dashboard' || item.menu_name === '管理面板') {
|
||||
return {
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.path === '/desktop' || item.menu_name === '个人桌面') {
|
||||
return {
|
||||
path: '/desktop',
|
||||
icon: 'DesktopOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
item.path === '/projects' ||
|
||||
item.path === '/projects/my' ||
|
||||
item.menu_name === '我的项目' ||
|
||||
item.menu_name === '项目空间'
|
||||
) {
|
||||
return {
|
||||
path: '/projects/my',
|
||||
icon: 'FolderOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.path === '/projects/share' || item.menu_name === '参与项目') {
|
||||
return {
|
||||
path: '/projects/share',
|
||||
icon: 'TeamOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') {
|
||||
return {
|
||||
path: '/knowledge',
|
||||
icon: 'ReadOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.path === '/system/users' || item.menu_name === '用户管理') {
|
||||
return {
|
||||
path: '/system/users',
|
||||
icon: 'UserOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.path === '/system/roles' || item.menu_name === '角色管理') {
|
||||
return {
|
||||
path: '/system/roles',
|
||||
icon: 'TeamOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.path === '/system/permissions' || item.menu_name === '权限管理') {
|
||||
return {
|
||||
path: '/system/permissions',
|
||||
icon: 'SafetyOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.path === '/system/model-configs' || item.menu_name === '模型配置') {
|
||||
return {
|
||||
path: '/system/model-configs',
|
||||
icon: 'CloudServerOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.path === '/system/logs' || item.menu_name === '系统日志') {
|
||||
return {
|
||||
path: '/system/logs',
|
||||
icon: 'FileSearchOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function AppSider({ collapsed, onToggle }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
|
@ -83,12 +212,15 @@ function AppSider({ collapsed, onToggle }) {
|
|||
if (validChildren.length > 0) {
|
||||
// 一级菜单作为组标题
|
||||
const groupItems = validChildren.map(child => {
|
||||
const icon = typeof child.icon === 'string' ? (iconMap[child.icon] || <AppstoreOutlined />) : child.icon
|
||||
const normalizedChild = normalizeMenuItem(child)
|
||||
const icon = typeof normalizedChild.icon === 'string'
|
||||
? (iconMap[normalizedChild.icon] || <AppstoreOutlined />)
|
||||
: normalizedChild.icon
|
||||
return {
|
||||
key: child.menu_code,
|
||||
label: child.menu_name,
|
||||
key: normalizedChild.menu_code,
|
||||
label: normalizedChild.menu_name,
|
||||
icon: icon,
|
||||
path: child.path
|
||||
path: normalizedChild.path
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -98,12 +230,15 @@ function AppSider({ collapsed, onToggle }) {
|
|||
})
|
||||
} else {
|
||||
// 一级菜单是叶子节点,放入默认组
|
||||
const icon = typeof item.icon === 'string' ? (iconMap[item.icon] || <AppstoreOutlined />) : item.icon
|
||||
const normalizedItem = normalizeMenuItem(item)
|
||||
const icon = typeof normalizedItem.icon === 'string'
|
||||
? (iconMap[normalizedItem.icon] || <AppstoreOutlined />)
|
||||
: normalizedItem.icon
|
||||
defaultGroup.items.push({
|
||||
key: item.menu_code,
|
||||
label: item.menu_name,
|
||||
key: normalizedItem.menu_code,
|
||||
label: normalizedItem.menu_name,
|
||||
icon: icon,
|
||||
path: item.path
|
||||
path: normalizedItem.path
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -116,6 +251,20 @@ function AppSider({ collapsed, onToggle }) {
|
|||
setMenuGroups(groups)
|
||||
}
|
||||
|
||||
const normalizeMenuItem = (item) => {
|
||||
const builtInMeta = resolveBuiltInMenuMeta(item)
|
||||
|
||||
if (!builtInMeta) {
|
||||
return item
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
icon: builtInMeta.icon || item.icon,
|
||||
path: builtInMeta.path || item.path,
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigate = (key, item) => {
|
||||
if (item.path) {
|
||||
navigate(item.path)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { getAccessToken } from '@/utils/authStorage'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const location = useLocation()
|
||||
const token = localStorage.getItem('access_token')
|
||||
const token = getAccessToken()
|
||||
|
||||
if (!token) {
|
||||
const returnTo = encodeURIComponent(`${location.pathname}${location.search}${location.hash}`)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: #d9d9d9;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--border-color-strong);
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
/* 一列布局(默认) */
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
|
||||
.stat-card-title {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +79,7 @@
|
|||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* 趋势指示器 */
|
||||
|
|
@ -95,12 +95,12 @@
|
|||
|
||||
.stat-card-trend.trend-up {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
background: rgba(82, 196, 26, 0.12);
|
||||
}
|
||||
|
||||
.stat-card-trend.trend-down {
|
||||
color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
background: rgba(255, 77, 79, 0.12);
|
||||
}
|
||||
|
||||
.stat-card-trend svg {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border: 1px dashed var(--border-color-strong);
|
||||
}
|
||||
|
||||
.tree-filter-tag {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
--bg-color-secondary: #fafafa;
|
||||
--text-color: #000000e0;
|
||||
--text-color-secondary: #00000073;
|
||||
--border-color: #f0f0f0;
|
||||
--border-color: #d9dde3;
|
||||
--border-color-strong: #c4cad3;
|
||||
--header-bg: #fff;
|
||||
--sider-bg: #fff;
|
||||
--item-hover-bg: #f5f5f5;
|
||||
|
|
@ -11,12 +12,13 @@
|
|||
|
||||
/* Markdown & Editor Specific */
|
||||
--code-bg: #f6f8fa;
|
||||
--table-border-color: #dfe2e5;
|
||||
--table-border-color: #cfd6de;
|
||||
--table-header-bg: #f6f8fa;
|
||||
--blockquote-border-color: #dfe2e5;
|
||||
--blockquote-border-color: #cfd6de;
|
||||
--blockquote-text-color: #6a737d;
|
||||
--toolbar-bg: #fafafa;
|
||||
--link-color: #1677ff;
|
||||
--panel-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
body.dark {
|
||||
|
|
@ -24,7 +26,8 @@ body.dark {
|
|||
--bg-color-secondary: #1f1f1f;
|
||||
--text-color: #ffffffd9;
|
||||
--text-color-secondary: #ffffff73;
|
||||
--border-color: #303030;
|
||||
--border-color: #454545;
|
||||
--border-color-strong: #5a5a5a;
|
||||
--header-bg: #141414;
|
||||
--sider-bg: #141414;
|
||||
--item-hover-bg: #1f1f1f;
|
||||
|
|
@ -32,12 +35,13 @@ body.dark {
|
|||
|
||||
/* Markdown & Editor Specific Dark */
|
||||
--code-bg: #2d2d2d;
|
||||
--table-border-color: #303030;
|
||||
--table-border-color: #4d4d4d;
|
||||
--table-header-bg: #1f1f1f;
|
||||
--blockquote-border-color: #303030;
|
||||
--blockquote-border-color: #4d4d4d;
|
||||
--blockquote-text-color: #8b949e;
|
||||
--toolbar-bg: #1f1f1f;
|
||||
--link-color: #177ddc;
|
||||
--panel-shadow: 0 10px 24px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -45,6 +49,14 @@ body {
|
|||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.document-workspace-frame {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--panel-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-highlight {
|
||||
background-color: #ffd54f !important;
|
||||
color: black !important;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Card, Row, Col, Statistic, Table, Spin, Button, Tooltip, message } from 'antd'
|
||||
import { UserOutlined, ProjectOutlined, FileTextOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { Card, Table, Spin, Button, message } from 'antd'
|
||||
import { UserOutlined, ProjectOutlined, FileTextOutlined, SyncOutlined, DashboardOutlined } from '@ant-design/icons'
|
||||
import { getDashboardStats } from '@/api/dashboard'
|
||||
import { rebuildIndex } from '@/api/search'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader'
|
||||
import StatCard from '@/components/StatCard/StatCard'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import '@/pages/System/AdminPages.css'
|
||||
|
||||
function Dashboard() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -103,82 +106,40 @@ function Dashboard() {
|
|||
}
|
||||
|
||||
return (
|
||||
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>管理员仪表盘</h1>
|
||||
<div className="admin-page">
|
||||
<PageHeader
|
||||
title="管理面板"
|
||||
description="汇总查看用户、项目与文档数据,并执行搜索索引维护操作。"
|
||||
icon={<DashboardOutlined />}
|
||||
extra={(
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined spin={rebuilding} />}
|
||||
onClick={handleRebuildIndex}
|
||||
loading={rebuilding}
|
||||
>
|
||||
重建索引
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} style={{ marginBottom: '24px' }}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="用户总数"
|
||||
value={stats.user_count}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="项目总数"
|
||||
value={stats.project_count}
|
||||
prefix={<ProjectOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>文档总数</span>
|
||||
<Tooltip title="重建全文搜索索引(扫描所有文档)">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
icon={<SyncOutlined spin={rebuilding} />}
|
||||
onClick={handleRebuildIndex}
|
||||
disabled={rebuilding}
|
||||
>
|
||||
重建索引
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
value={stats.document_count}
|
||||
prefix={<FileTextOutlined />}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="admin-stats">
|
||||
<StatCard title="用户总数" value={stats.user_count} icon={<UserOutlined />} color="green" />
|
||||
<StatCard title="项目总数" value={stats.project_count} icon={<ProjectOutlined />} color="blue" />
|
||||
<StatCard title="文档总数" value={stats.document_count} icon={<FileTextOutlined />} color="red" />
|
||||
</div>
|
||||
|
||||
{/* 最近用户 */}
|
||||
<Card title="最近创建的用户" style={{ marginBottom: '24px' }}>
|
||||
<Table
|
||||
columns={userColumns}
|
||||
dataSource={recentUsers}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
/>
|
||||
<div className="admin-stack">
|
||||
<Card title="最近创建的用户" className="admin-card">
|
||||
<Table columns={userColumns} dataSource={recentUsers} rowKey="id" pagination={false} />
|
||||
</Card>
|
||||
|
||||
{/* 最近项目 */}
|
||||
<Card title="最近创建的项目" style={{ marginBottom: '24px' }}>
|
||||
<Table
|
||||
columns={projectColumns}
|
||||
dataSource={recentProjects}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
/>
|
||||
<Card title="最近创建的项目" className="admin-card">
|
||||
<Table columns={projectColumns} dataSource={recentProjects} rowKey="id" pagination={false} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
export default Dashboard
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
.desktop-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin-bottom: 24px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
.desktop-grid {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 日历卡片 */
|
||||
|
|
@ -83,13 +77,12 @@
|
|||
}
|
||||
|
||||
.activity-item-disabled:hover {
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--bg-color-secondary);
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
body.dark .activity-item-clickable:hover {
|
||||
background-color: rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
|
@ -106,6 +99,6 @@ body.dark .activity-item-disabled:hover {
|
|||
@media (max-width: 992px) {
|
||||
.calendar-card,
|
||||
.activity-card {
|
||||
margin-bottom: 24px;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Card, Row, Col, Calendar, List, Badge, Empty, Typography, Spin } from 'antd'
|
||||
import { FileTextOutlined, ClockCircleOutlined } from '@ant-design/icons'
|
||||
import { Card, Calendar, List, Badge, Empty, Typography, Spin } from 'antd'
|
||||
import { DesktopOutlined, FileTextOutlined, ClockCircleOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getDocumentActivityDates, getDocumentActivity } from '@/api/dashboard'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import dayjs from 'dayjs'
|
||||
import './Desktop.css'
|
||||
import '@/pages/System/AdminPages.css'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
|
|
@ -111,99 +113,97 @@ function Desktop() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="desktop-page">
|
||||
<h1 className="page-title">个人桌面</h1>
|
||||
<div className="admin-page">
|
||||
<PageHeader
|
||||
title="个人桌面"
|
||||
description="查看文档活动日历与每天的编辑轨迹,快速回到最近处理过的内容。"
|
||||
icon={<DesktopOutlined />}
|
||||
/>
|
||||
|
||||
<Row gutter={24}>
|
||||
{/* 左侧日历 */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card className="calendar-card">
|
||||
<Calendar
|
||||
fullscreen={false}
|
||||
value={selectedDate}
|
||||
onSelect={onSelect}
|
||||
onPanelChange={onPanelChange}
|
||||
fullCellRender={dateCellRender}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<div className="admin-grid-2 desktop-grid">
|
||||
<Card className="admin-card calendar-card">
|
||||
<Calendar
|
||||
fullscreen={false}
|
||||
value={selectedDate}
|
||||
onSelect={onSelect}
|
||||
onPanelChange={onPanelChange}
|
||||
fullCellRender={dateCellRender}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 右侧活动列表 */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
className="activity-card"
|
||||
title={
|
||||
<div>
|
||||
<FileTextOutlined style={{ marginRight: 8 }} />
|
||||
{selectedDate.format('YYYY年MM月DD日')} 的文档活动
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{activityLogs.length > 0 ? (
|
||||
<List
|
||||
dataSource={activityLogs}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'}
|
||||
style={{ cursor: item.file_exists ? 'pointer' : 'default' }}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<ClockCircleOutlined
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: item.file_exists ? '#1890ff' : '#d9d9d9'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<div>
|
||||
<Text strong style={{ color: item.file_exists ? undefined : '#999' }}>
|
||||
{item.project_name}
|
||||
<Card
|
||||
className="admin-card activity-card"
|
||||
title={(
|
||||
<div>
|
||||
<FileTextOutlined style={{ marginRight: 8 }} />
|
||||
{selectedDate.format('YYYY年MM月DD日')} 的文档活动
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{activityLogs.length > 0 ? (
|
||||
<List
|
||||
dataSource={activityLogs}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'}
|
||||
style={{ cursor: item.file_exists ? 'pointer' : 'default' }}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={(
|
||||
<ClockCircleOutlined
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: item.file_exists ? 'var(--link-color)' : 'var(--border-color-strong)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
title={(
|
||||
<div>
|
||||
<Text strong style={{ color: item.file_exists ? undefined : 'var(--text-color-secondary)' }}>
|
||||
{item.project_name}
|
||||
</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ marginLeft: 8, color: item.file_exists ? undefined : 'var(--text-color-secondary)' }}
|
||||
>
|
||||
{item.operation_type}
|
||||
</Text>
|
||||
{!item.file_exists && (
|
||||
<Text type="danger" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
(已失效)
|
||||
</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ marginLeft: 8, color: item.file_exists ? undefined : '#bbb' }}
|
||||
>
|
||||
{item.operation_type}
|
||||
</Text>
|
||||
{!item.file_exists && (
|
||||
<Text type="danger" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
(已失效)
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text type="secondary">文件:</Text>
|
||||
<Text code style={{ color: item.file_exists ? undefined : '#999' }}>
|
||||
{item.file_path}
|
||||
</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{new Date(item.created_at).toLocaleTimeString('zh-CN')}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
description={(
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text type="secondary">文件:</Text>
|
||||
<Text code style={{ color: item.file_exists ? undefined : 'var(--text-color-secondary)' }}>
|
||||
{item.file_path}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="该日期暂无文档活动记录"
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{new Date(item.created_at).toLocaleTimeString('zh-CN')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="该日期暂无文档活动记录"
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
height: calc(100vh - 64px);
|
||||
/* width: calc(100% + 32px); */
|
||||
display: flex;
|
||||
margin: -16px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
|
|
@ -442,3 +445,9 @@
|
|||
max-width: 600px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.document-editor-page {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import {
|
|||
FileAddOutlined,
|
||||
FolderAddOutlined,
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
SwapOutlined,
|
||||
ReloadOutlined,
|
||||
FileImageOutlined,
|
||||
FilePdfOutlined,
|
||||
FileTextOutlined,
|
||||
|
|
@ -28,17 +28,16 @@ import gemoji from '@bytemd/plugin-gemoji'
|
|||
import 'bytemd/dist/index.css'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import {
|
||||
getProjectTree,
|
||||
getFileContent,
|
||||
saveFile,
|
||||
operateFile,
|
||||
uploadFile,
|
||||
importDocuments,
|
||||
exportDirectory,
|
||||
uploadDocument,
|
||||
} from '@/api/file'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
||||
import { findNodeByKey } from './documentBrowserUtils'
|
||||
import useDocumentEditorWorkspace from './useDocumentEditorWorkspace'
|
||||
import './DocumentEditor.css'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
|
@ -48,11 +47,33 @@ function DocumentEditor() {
|
|||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const fileInputRef = useRef(null)
|
||||
const [treeData, setTreeData] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState(null)
|
||||
const [selectedNode, setSelectedNode] = useState(null) // 当前选中的节点(可能是文件或目录)
|
||||
const [fileContent, setFileContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const {
|
||||
treeData,
|
||||
selectedFile,
|
||||
selectedNode,
|
||||
fileContent,
|
||||
loading,
|
||||
openKeys,
|
||||
selectedMenuKey,
|
||||
isPdfSelected,
|
||||
refreshing,
|
||||
projectName,
|
||||
userRole,
|
||||
setOpenKeys,
|
||||
setSelectedNode,
|
||||
setSelectedFile,
|
||||
setSelectedMenuKey,
|
||||
setFileContent,
|
||||
fetchTree,
|
||||
clearSelection,
|
||||
openNodeByKey,
|
||||
handleMenuClick,
|
||||
refreshCurrentDocument,
|
||||
} = useDocumentEditorWorkspace({
|
||||
projectId,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [moveModalVisible, setMoveModalVisible] = useState(false)
|
||||
|
|
@ -63,17 +84,12 @@ function DocumentEditor() {
|
|||
const [moveTargetPath, setMoveTargetPath] = useState('')
|
||||
const [dirOptions, setDirOptions] = useState([])
|
||||
const [editorHeight, setEditorHeight] = useState(600) // 设置初始高度为600px
|
||||
const [openKeys, setOpenKeys] = useState([]) // Menu组件的展开项
|
||||
const [uploadProgress, setUploadProgress] = useState(0) // 上传进度
|
||||
const [uploading, setUploading] = useState(false) // 是否正在上传
|
||||
const [fileList, setFileList] = useState([]) // 控制上传文件列表
|
||||
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
||||
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
||||
const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件
|
||||
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
||||
const [linkTarget, setLinkTarget] = useState(null)
|
||||
const [projectName, setProjectName] = useState('') // 项目名称
|
||||
const [userRole, setUserRole] = useState('viewer')
|
||||
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
|
||||
const editorCtxRef = useRef(null)
|
||||
const modeSwitchingRef = useRef(false)
|
||||
|
|
@ -86,16 +102,6 @@ function DocumentEditor() {
|
|||
navigate(to)
|
||||
}
|
||||
|
||||
const updateFileParam = (filePath) => {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
if (filePath) {
|
||||
nextParams.set('file', filePath)
|
||||
} else {
|
||||
nextParams.delete('file')
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
}
|
||||
|
||||
// 插入内链接
|
||||
const handleInsertLink = () => {
|
||||
if (!linkTarget) {
|
||||
|
|
@ -138,23 +144,8 @@ function DocumentEditor() {
|
|||
return () => window.removeEventListener('resize', calculateHeight)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTree()
|
||||
}, [projectId])
|
||||
|
||||
const fetchTree = async () => {
|
||||
try {
|
||||
const res = await getProjectTree(projectId)
|
||||
const data = res.data || {}
|
||||
const tree = data.tree || data || [] // 兼容新旧格式
|
||||
const name = data.project_name
|
||||
const role = data.user_role || 'viewer'
|
||||
setTreeData(tree)
|
||||
setProjectName(name)
|
||||
setUserRole(role)
|
||||
} catch (error) {
|
||||
Toast.error('加载失败', '加载文件树失败')
|
||||
}
|
||||
const handleRefresh = async () => {
|
||||
await refreshCurrentDocument()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -173,118 +164,6 @@ function DocumentEditor() {
|
|||
})
|
||||
}
|
||||
|
||||
const handleSelectFile = async (selectedKeys, info) => {
|
||||
// 记录选中的节点(无论是文件还是目录)
|
||||
setSelectedNode(info.node)
|
||||
|
||||
if (info.node.isLeaf) {
|
||||
const filePath = selectedKeys[0]
|
||||
|
||||
// 检查是否是PDF文件
|
||||
if (filePath.toLowerCase().endsWith('.pdf')) {
|
||||
Toast.info('提示', 'PDF文件请在浏览模式下查看')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getFileContent(projectId, filePath)
|
||||
setSelectedFile(filePath)
|
||||
setFileContent(res.data.content)
|
||||
} catch (error) {
|
||||
// 错误已通过request interceptor处理
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找树节点的辅助函数
|
||||
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
|
||||
}
|
||||
|
||||
const expandParentFolders = (path) => {
|
||||
const parts = path.split('/')
|
||||
if (parts.length <= 1) return
|
||||
|
||||
const parentKeys = []
|
||||
let currentPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
parentKeys.push(currentPath)
|
||||
}
|
||||
setOpenKeys(prev => Array.from(new Set([...prev, ...parentKeys])))
|
||||
}
|
||||
|
||||
const openNodeByKey = async (key, node = null, syncUrl = false) => {
|
||||
const targetNode = node || findNodeByKey(treeData, key)
|
||||
if (!targetNode) return
|
||||
|
||||
setSelectedNode(targetNode)
|
||||
setSelectedMenuKey(key)
|
||||
|
||||
if (!targetNode.isLeaf) {
|
||||
if (syncUrl) {
|
||||
updateFileParam(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (syncUrl) {
|
||||
updateFileParam(key)
|
||||
}
|
||||
expandParentFolders(key)
|
||||
|
||||
if (key.toLowerCase().endsWith('.pdf')) {
|
||||
setSelectedFile(key)
|
||||
setIsPdfSelected(true)
|
||||
setFileContent('')
|
||||
return
|
||||
}
|
||||
|
||||
setIsPdfSelected(false)
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getFileContent(projectId, key)
|
||||
setSelectedFile(key)
|
||||
setFileContent(res.data.content)
|
||||
} catch (error) {
|
||||
Toast.error('加载失败', '加载文件失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData.length === 0) return
|
||||
|
||||
const fileParam = searchParams.get('file')
|
||||
if (!fileParam) return
|
||||
if (fileParam === selectedFile) return
|
||||
|
||||
const targetNode = findNodeByKey(treeData, fileParam)
|
||||
if (!targetNode) return
|
||||
|
||||
openNodeByKey(fileParam, targetNode, false)
|
||||
}, [treeData, searchParams])
|
||||
|
||||
// Menu点击处理(适配Menu组件)
|
||||
const handleMenuClick = async ({ key, domEvent }) => {
|
||||
const node = findNodeByKey(treeData, key)
|
||||
if (!node) return
|
||||
await openNodeByKey(key, node, true)
|
||||
}
|
||||
|
||||
// 重置当前编辑内容(重新从服务器加载)
|
||||
const handleReset = () => {
|
||||
if (!selectedFile) return
|
||||
|
|
@ -293,16 +172,8 @@ function DocumentEditor() {
|
|||
title: '确认重置',
|
||||
content: '确定要重置当前修改吗?所有未保存的更改都将丢失。',
|
||||
onOk: async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getFileContent(projectId, selectedFile)
|
||||
setFileContent(res.data.content)
|
||||
Toast.success('重置成功', '已恢复至最后保存的版本')
|
||||
} catch (error) {
|
||||
Toast.error('重置失败', '无法重新加载文件内容')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
await openNodeByKey(selectedFile, null, false)
|
||||
Toast.success('重置成功', '已恢复至最后保存的版本')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -372,8 +243,7 @@ function DocumentEditor() {
|
|||
})
|
||||
Toast.success('成功', '删除成功')
|
||||
if (selectedFile === path) {
|
||||
setSelectedFile(null)
|
||||
setFileContent('')
|
||||
clearSelection()
|
||||
}
|
||||
fetchTree()
|
||||
} catch (error) {
|
||||
|
|
@ -461,8 +331,7 @@ function DocumentEditor() {
|
|||
|
||||
// 如果重命名的是当前打开的文件,清空编辑器
|
||||
if (operationType === 'rename' && selectedFile === rightClickNode) {
|
||||
setSelectedFile(null)
|
||||
setFileContent('')
|
||||
clearSelection()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Operation error:', error)
|
||||
|
|
@ -614,42 +483,6 @@ function DocumentEditor() {
|
|||
}
|
||||
}
|
||||
|
||||
// 导出目录
|
||||
const handleExportDirectory = async () => {
|
||||
// 如果选中了目录,导出该目录;否则导出整个项目
|
||||
const directoryPath = selectedNode && !selectedNode.isLeaf ? selectedNode.key : ''
|
||||
|
||||
try {
|
||||
const response = await exportDirectory(projectId, directoryPath)
|
||||
|
||||
// 从响应头中提取文件名
|
||||
const contentDisposition = response.headers['content-disposition']
|
||||
let filename = `${directoryPath || 'root'}.zip`
|
||||
if (contentDisposition) {
|
||||
const matches = /filename=(.+)/.exec(contentDisposition)
|
||||
if (matches && matches[1]) {
|
||||
filename = matches[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 创建blob URL并触发下载
|
||||
const url = window.URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
Toast.success('成功', '导出成功')
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
Toast.error('错误', '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 移动文件/目录
|
||||
const handleMove = (path) => {
|
||||
setRightClickNode(path)
|
||||
|
|
@ -703,8 +536,7 @@ function DocumentEditor() {
|
|||
|
||||
// 如果移动的是当前打开的文件,清空编辑器
|
||||
if (selectedFile === rightClickNode) {
|
||||
setSelectedFile(null)
|
||||
setFileContent('')
|
||||
clearSelection()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Move error:', error)
|
||||
|
|
@ -960,7 +792,7 @@ function DocumentEditor() {
|
|||
|
||||
return (
|
||||
<div className="document-editor-page">
|
||||
<Layout className="document-editor-container">
|
||||
<Layout className="document-editor-container document-workspace-frame">
|
||||
<Sider
|
||||
width={280}
|
||||
theme="light"
|
||||
|
|
@ -1000,6 +832,14 @@ function DocumentEditor() {
|
|||
}}
|
||||
/>
|
||||
<Space.Compact className="mode-actions-group">
|
||||
<Tooltip title="刷新">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="添加文件">
|
||||
<Button
|
||||
size="middle"
|
||||
|
|
@ -1029,13 +869,6 @@ function DocumentEditor() {
|
|||
/>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
<Tooltip title="导出文档">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExportDirectory}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
.project-docs-page {
|
||||
height: calc(100vh - 64px);
|
||||
margin: -16px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
|
@ -342,3 +345,9 @@
|
|||
.markdown-body li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.project-docs-page {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
|
||||
import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons'
|
||||
import { Layout, Menu, Spin, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
|
||||
import { ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, CloseOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
|
|
@ -9,15 +9,16 @@ import rehypeSlug from 'rehype-slug'
|
|||
import rehypeHighlight from 'rehype-highlight'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import Highlighter from 'react-highlight-words'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
|
||||
import { getExportPdfUrl } from '@/api/file'
|
||||
import { gitPull, gitPush, getGitRepos } from '@/api/project'
|
||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
||||
import { copyText } from '@/utils/browserIO'
|
||||
import useDocumentBrowser from './useDocumentBrowser'
|
||||
import { buildAuthorizedUrl } from '@/utils/authStorage'
|
||||
import './DocumentPage.css'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
|
@ -39,34 +40,48 @@ function DocumentPage() {
|
|||
const { projectId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [fileTree, setFileTree] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState('')
|
||||
const [selectedNodeKey, setSelectedNodeKey] = useState('')
|
||||
const [markdownContent, setMarkdownContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [openKeys, setOpenKeys] = useState([])
|
||||
const [tocCollapsed, setTocCollapsed] = useState(false)
|
||||
const [tocItems, setTocItems] = useState([])
|
||||
const [shareModalVisible, setShareModalVisible] = useState(false)
|
||||
const [shareInfo, setShareInfo] = useState(null)
|
||||
const [hasPassword, setHasPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [userRole, setUserRole] = useState('viewer')
|
||||
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
|
||||
const [pdfUrl, setPdfUrl] = useState('')
|
||||
const [pdfFilename, setPdfFilename] = useState('')
|
||||
const [viewMode, setViewMode] = useState('markdown')
|
||||
const [gitRepos, setGitRepos] = useState([])
|
||||
const [projectName, setProjectName] = useState('')
|
||||
|
||||
// 搜索相关状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [modeSwitchValue, setModeSwitchValue] = useState('view')
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const contentRef = useRef(null)
|
||||
const modeSwitchingRef = useRef(false)
|
||||
const {
|
||||
filteredTreeData,
|
||||
menuItems,
|
||||
selectedFile,
|
||||
selectedNode,
|
||||
selectedNodeKey,
|
||||
markdownContent,
|
||||
loading,
|
||||
openKeys,
|
||||
projectName,
|
||||
userRole,
|
||||
pdfFilename,
|
||||
pdfUrl,
|
||||
viewMode,
|
||||
searchKeyword,
|
||||
isSearching,
|
||||
tocItems,
|
||||
setOpenKeys,
|
||||
setSearchKeyword,
|
||||
loadFileTree,
|
||||
loadMarkdown,
|
||||
refreshCurrentView,
|
||||
handleMenuClick,
|
||||
handleMarkdownLink,
|
||||
handleSearch,
|
||||
} = useDocumentBrowser({
|
||||
projectId,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
contentRef,
|
||||
})
|
||||
|
||||
const navigateWithTransition = (to) => {
|
||||
if (document.startViewTransition) {
|
||||
|
|
@ -76,20 +91,6 @@ function DocumentPage() {
|
|||
navigate(to)
|
||||
}
|
||||
|
||||
const updateFileParam = (filePath) => {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
if (filePath) {
|
||||
nextParams.set('file', filePath)
|
||||
} else {
|
||||
nextParams.delete('file')
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFileTree()
|
||||
}, [projectId])
|
||||
|
||||
const handleClose = () => {
|
||||
Modal.confirm({
|
||||
title: '确认退出',
|
||||
|
|
@ -106,124 +107,18 @@ function DocumentPage() {
|
|||
})
|
||||
}
|
||||
|
||||
// 监听 URL 参数变化,处理文件导航和搜索
|
||||
useEffect(() => {
|
||||
// 只有当文件树加载完成后才处理导航,否则无法正确展开目录
|
||||
if (fileTree.length === 0) return
|
||||
|
||||
const fileParam = searchParams.get('file')
|
||||
const keywordParam = searchParams.get('keyword')
|
||||
|
||||
// 处理搜索
|
||||
if (keywordParam && keywordParam !== searchKeyword) {
|
||||
handleSearch(keywordParam)
|
||||
}
|
||||
|
||||
// 处理文件加载
|
||||
if (fileParam) {
|
||||
if (fileParam !== selectedFile) {
|
||||
setSelectedFile(fileParam)
|
||||
setSelectedNodeKey(fileParam)
|
||||
|
||||
// 展开父目录
|
||||
const parts = fileParam.split('/')
|
||||
const allParentPaths = []
|
||||
let currentPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
allParentPaths.push(currentPath)
|
||||
}
|
||||
if (allParentPaths.length > 0) {
|
||||
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
|
||||
}
|
||||
|
||||
// 处理 PDF 或 Markdown
|
||||
if (fileParam.toLowerCase().endsWith('.pdf')) {
|
||||
let url = getDocumentUrl(projectId, fileParam)
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
url += `?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
setPdfUrl(url)
|
||||
setPdfFilename(fileParam.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
} else {
|
||||
loadMarkdown(fileParam)
|
||||
setViewMode('markdown')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果没有指定文件,且当前没有选中文件,默认打开 README.md
|
||||
if (!selectedFile) {
|
||||
const readmeNode = findReadme(fileTree)
|
||||
if (readmeNode) {
|
||||
setSelectedFile(readmeNode.key)
|
||||
setSelectedNodeKey(readmeNode.key)
|
||||
updateFileParam(readmeNode.key)
|
||||
loadMarkdown(readmeNode.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [searchParams, fileTree])
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = async (value) => {
|
||||
setSearchKeyword(value)
|
||||
if (!value.trim()) {
|
||||
setMatchedFilePaths(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const res = await searchDocuments(value, projectId)
|
||||
const paths = new Set(res.data.map(item => item.file_path))
|
||||
setMatchedFilePaths(paths)
|
||||
|
||||
// 自动展开匹配的节点
|
||||
const keysToExpand = new Set(openKeys)
|
||||
res.data.forEach(item => {
|
||||
const parts = item.file_path.split('/')
|
||||
let currentPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
keysToExpand.add(currentPath)
|
||||
}
|
||||
})
|
||||
setOpenKeys(Array.from(keysToExpand))
|
||||
await refreshCurrentView()
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
console.error('Refresh document page error:', error)
|
||||
Toast.error('刷新失败', '请稍后重试')
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤文件树
|
||||
const filteredTreeData = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return fileTree
|
||||
|
||||
const loop = (data) => {
|
||||
const result = []
|
||||
for (const node of data) {
|
||||
const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
const contentMatch = matchedFilePaths.has(node.key)
|
||||
|
||||
if (node.children) {
|
||||
const children = loop(node.children)
|
||||
if (children.length > 0 || titleMatch) {
|
||||
result.push({ ...node, children })
|
||||
}
|
||||
} else {
|
||||
if (titleMatch || contentMatch) {
|
||||
result.push(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return loop(fileTree)
|
||||
}, [fileTree, searchKeyword, matchedFilePaths])
|
||||
|
||||
const loadGitRepos = async () => {
|
||||
try {
|
||||
const res = await getGitRepos(projectId)
|
||||
|
|
@ -233,255 +128,13 @@ function DocumentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// 加载文件树
|
||||
const loadFileTree = async () => {
|
||||
try {
|
||||
const res = await getProjectTree(projectId)
|
||||
const data = res.data || {}
|
||||
const tree = data.tree || data || [] // 兼容新旧格式
|
||||
const role = data.user_role || 'viewer'
|
||||
const name = data.project_name
|
||||
|
||||
setFileTree(tree)
|
||||
setUserRole(role)
|
||||
setProjectName(name)
|
||||
} catch (error) {
|
||||
console.error('Load file tree error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查找根目录的 README.md
|
||||
const findReadme = (nodes) => {
|
||||
// 只在根目录查找
|
||||
for (const node of nodes) {
|
||||
if (node.title === 'README.md' && node.isLeaf) {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const findNodeByKey = (nodes, key) => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key) {
|
||||
return node
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const found = findNodeByKey(node.children, key)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 转换文件树为菜单项
|
||||
const convertTreeToMenuItems = (nodes) => {
|
||||
return nodes.map((node) => {
|
||||
// 标题高亮处理 - 取消高亮,仅显示原始标题
|
||||
const titleNode = node.title.replace('.md', '')
|
||||
|
||||
if (!node.isLeaf) {
|
||||
// 目录
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title,
|
||||
icon: <FolderOutlined />,
|
||||
onTitleClick: () => setSelectedNodeKey(node.key),
|
||||
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
||||
}
|
||||
} else if (node.title && node.title.endsWith('.md')) {
|
||||
// Markdown 文件
|
||||
return {
|
||||
key: node.key,
|
||||
label: titleNode,
|
||||
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)
|
||||
}
|
||||
|
||||
// 加载 markdown 文件
|
||||
const loadMarkdown = async (filePath) => {
|
||||
setLoading(true)
|
||||
setTocItems([]) // 清空旧的目录数据
|
||||
try {
|
||||
const res = await getFileContent(projectId, filePath)
|
||||
setMarkdownContent(res.data?.content || '')
|
||||
|
||||
// 滚动到顶部
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load markdown error:', error)
|
||||
setMarkdownContent('# 文档加载失败\n\n无法加载该文档,请稍后重试。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 markdown 标题生成目录
|
||||
useEffect(() => {
|
||||
if (markdownContent) {
|
||||
const slugger = new GithubSlugger()
|
||||
const headings = []
|
||||
const lines = markdownContent.split('\n')
|
||||
|
||||
lines.forEach((line) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (match) {
|
||||
const level = match[1].length
|
||||
const title = match[2]
|
||||
// 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致
|
||||
const key = slugger.slug(title)
|
||||
|
||||
headings.push({
|
||||
key: `#${key}`,
|
||||
href: `#${key}`,
|
||||
title,
|
||||
level,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
setTocItems(headings)
|
||||
if (userRole === 'viewer') {
|
||||
setGitRepos([])
|
||||
return
|
||||
}
|
||||
}, [markdownContent])
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
setSelectedFile(key)
|
||||
setSelectedNodeKey(key)
|
||||
updateFileParam(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析相对路径
|
||||
const resolveRelativePath = (currentPath, relativePath) => {
|
||||
// 获取当前文件所在目录
|
||||
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/'))
|
||||
|
||||
// 分割相对路径
|
||||
const parts = relativePath.split('/')
|
||||
const dirParts = currentDir ? currentDir.split('/') : []
|
||||
|
||||
// 处理 ../ 和 ./
|
||||
for (const part of parts) {
|
||||
if (part === '..') {
|
||||
dirParts.pop()
|
||||
} else if (part !== '.' && part !== '') {
|
||||
dirParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return dirParts.join('/')
|
||||
}
|
||||
|
||||
// 处理markdown内部链接点击
|
||||
const handleMarkdownLink = (e, href) => {
|
||||
// 检查是否是外部链接
|
||||
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
|
||||
return // 外部链接,允许默认行为
|
||||
}
|
||||
|
||||
// 检查是否是锚点链接
|
||||
if (href.startsWith('#')) {
|
||||
return // 锚点链接,允许默认行为
|
||||
}
|
||||
|
||||
// 检查是否是文档文件(.md 或 .pdf)
|
||||
const isMd = href.endsWith('.md')
|
||||
const isPdf = href.toLowerCase().endsWith('.pdf')
|
||||
|
||||
if (!isMd && !isPdf) {
|
||||
return // 不是文档文件,允许默认行为
|
||||
}
|
||||
|
||||
// 阻止默认跳转
|
||||
e.preventDefault()
|
||||
|
||||
// 先解码 href(因为 Markdown 中的链接可能已经是 URL 编码的)
|
||||
let decodedHref = href
|
||||
try {
|
||||
decodedHref = decodeURIComponent(href)
|
||||
} catch (e) {
|
||||
// 解码失败,使用原始值
|
||||
}
|
||||
|
||||
// 解析路径
|
||||
let targetPath
|
||||
if (decodedHref.startsWith('.') || decodedHref.startsWith('..')) {
|
||||
// 真正的相对路径,相对于当前文件
|
||||
targetPath = resolveRelativePath(selectedFile, decodedHref)
|
||||
} else {
|
||||
// 项目内绝对路径(由编辑器生成),相对于项目根目录
|
||||
targetPath = decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref
|
||||
}
|
||||
|
||||
// 自动展开父目录
|
||||
const lastSlashIndex = targetPath.lastIndexOf('/')
|
||||
if (lastSlashIndex !== -1) {
|
||||
const parentPath = targetPath.substring(0, lastSlashIndex)
|
||||
if (parentPath && !openKeys.includes(parentPath)) {
|
||||
// 收集所有父路径
|
||||
const pathParts = parentPath.split('/')
|
||||
const allParentPaths = []
|
||||
let currentPath = ''
|
||||
for (const part of pathParts) {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
||||
allParentPaths.push(currentPath)
|
||||
}
|
||||
setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
|
||||
}
|
||||
}
|
||||
|
||||
// 选中文件并加载
|
||||
setSelectedFile(targetPath)
|
||||
setSelectedNodeKey(targetPath)
|
||||
updateFileParam(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)
|
||||
}
|
||||
}
|
||||
loadGitRepos()
|
||||
}, [projectId, userRole])
|
||||
|
||||
const handleGitPull = async (repoId = null, force = false) => {
|
||||
if (gitRepos.length === 0) {
|
||||
|
|
@ -656,7 +309,6 @@ function DocumentPage() {
|
|||
|
||||
// 打开分享设置
|
||||
const handleShare = async () => {
|
||||
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
|
||||
if (selectedNode && !selectedNode.isLeaf) {
|
||||
Toast.warning('提示', '当前选中的是文件夹,不能直接分享,请选择具体文件后再试')
|
||||
return
|
||||
|
|
@ -674,41 +326,20 @@ function DocumentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const shareTargetFile = selectedNode?.isLeaf ? selectedNodeKey : ''
|
||||
const shareLinkValue = shareInfo
|
||||
? `${window.location.origin}${shareInfo.share_url}${shareTargetFile ? `?file=${encodeURIComponent(shareTargetFile)}` : ''}`
|
||||
: ''
|
||||
|
||||
// 复制分享链接
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareInfo) return
|
||||
|
||||
const shareTargetFile = selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
|
||||
? selectedNodeKey
|
||||
: ''
|
||||
|
||||
let fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
||||
if (shareTargetFile) {
|
||||
fullUrl += `?file=${encodeURIComponent(shareTargetFile)}`
|
||||
if (!shareLinkValue) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(fullUrl)
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} else {
|
||||
// Fallback for non-secure contexts or older browsers
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = fullUrl
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-9999px"
|
||||
textArea.style.top = "0"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const successful = document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
if (successful) {
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} else {
|
||||
Toast.error('复制失败', '请手动复制链接')
|
||||
}
|
||||
}
|
||||
await copyText(shareLinkValue)
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
Toast.error('复制失败', '无法访问剪贴板')
|
||||
|
|
@ -755,8 +386,6 @@ function DocumentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const menuItems = convertTreeToMenuItems(filteredTreeData)
|
||||
|
||||
// Markdown 内容高亮处理
|
||||
// 使用 components 替换文本节点,但这只对直接文本子节点有效
|
||||
// 对于深层嵌套,我们需要递归或使用 rehype 插件
|
||||
|
|
@ -829,11 +458,11 @@ function DocumentPage() {
|
|||
th: highlightRenderer('th'),
|
||||
div: highlightRenderer('div'),
|
||||
}
|
||||
}, [searchKeyword])
|
||||
}, [searchKeyword, handleMarkdownLink])
|
||||
|
||||
return (
|
||||
<div className="project-docs-page">
|
||||
<Layout className="docs-layout">
|
||||
<Layout className="docs-layout document-workspace-frame">
|
||||
{/* 左侧目录 */}
|
||||
<Sider width={280} className="docs-sider" theme="light">
|
||||
<div className="docs-sider-header">
|
||||
|
|
@ -870,6 +499,14 @@ function DocumentPage() {
|
|||
<div />
|
||||
)}
|
||||
<Space.Compact className="mode-actions-group">
|
||||
<Tooltip title="刷新">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
/>
|
||||
</Tooltip>
|
||||
{userRole !== 'viewer' && renderGitActions()}
|
||||
<Tooltip title="分享">
|
||||
<Button
|
||||
|
|
@ -951,12 +588,7 @@ function DocumentPage() {
|
|||
right={tocCollapsed ? 24 : 280}
|
||||
onExportPDF={() => {
|
||||
if (!selectedFile) return
|
||||
let url = getExportPdfUrl(projectId, selectedFile)
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
url += `&token=${encodeURIComponent(token)}`
|
||||
}
|
||||
window.open(url, '_blank')
|
||||
window.open(buildAuthorizedUrl(getExportPdfUrl(projectId, selectedFile)), '_blank')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1025,13 +657,10 @@ function DocumentPage() {
|
|||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
|
||||
{selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf ? '当前文件分享链接' : '项目分享链接'}
|
||||
{shareTargetFile ? '当前文件分享链接' : '项目分享链接'}
|
||||
</label>
|
||||
<Input
|
||||
value={selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
|
||||
? `${window.location.origin}${shareInfo.share_url}?file=${encodeURIComponent(selectedNodeKey)}`
|
||||
: `${window.location.origin}${shareInfo.share_url}`
|
||||
}
|
||||
value={shareLinkValue}
|
||||
readOnly
|
||||
addonAfter={
|
||||
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
import { FileTextOutlined, FolderOutlined, FilePdfOutlined } from '@ant-design/icons'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
|
||||
export function findRootReadme(nodes) {
|
||||
return nodes.find((node) => node.title === 'README.md' && node.isLeaf) || null
|
||||
}
|
||||
|
||||
export function findNodeByKey(nodes, key) {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key) {
|
||||
return node
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const found = findNodeByKey(node.children, key)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function collectParentKeys(path) {
|
||||
const parts = path.split('/')
|
||||
const parentKeys = []
|
||||
let currentPath = ''
|
||||
|
||||
for (let index = 0; index < parts.length - 1; index += 1) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index]
|
||||
parentKeys.push(currentPath)
|
||||
}
|
||||
|
||||
return parentKeys
|
||||
}
|
||||
|
||||
export function resolveRelativePath(currentPath, relativePath) {
|
||||
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/'))
|
||||
const dirParts = currentDir ? currentDir.split('/') : []
|
||||
|
||||
relativePath.split('/').forEach((part) => {
|
||||
if (part === '..') {
|
||||
dirParts.pop()
|
||||
return
|
||||
}
|
||||
|
||||
if (part !== '.' && part !== '') {
|
||||
dirParts.push(part)
|
||||
}
|
||||
})
|
||||
|
||||
return dirParts.join('/')
|
||||
}
|
||||
|
||||
export function buildTocItems(markdownContent) {
|
||||
if (!markdownContent) {
|
||||
return []
|
||||
}
|
||||
|
||||
const slugger = new GithubSlugger()
|
||||
const headings = []
|
||||
|
||||
markdownContent.split('\n').forEach((line) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (!match) {
|
||||
return
|
||||
}
|
||||
|
||||
const title = match[2]
|
||||
const key = slugger.slug(title)
|
||||
|
||||
headings.push({
|
||||
key: `#${key}`,
|
||||
href: `#${key}`,
|
||||
title,
|
||||
level: match[1].length,
|
||||
})
|
||||
})
|
||||
|
||||
return headings
|
||||
}
|
||||
|
||||
export function filterTreeByKeyword(nodes, keyword, matchedFilePaths) {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
if (!normalizedKeyword) {
|
||||
return nodes
|
||||
}
|
||||
|
||||
const loop = (items) => {
|
||||
const result = []
|
||||
|
||||
items.forEach((node) => {
|
||||
const titleMatch = node.title.toLowerCase().includes(normalizedKeyword)
|
||||
const contentMatch = matchedFilePaths.has(node.key)
|
||||
|
||||
if (node.children?.length) {
|
||||
const children = loop(node.children)
|
||||
if (children.length > 0 || titleMatch) {
|
||||
result.push({ ...node, children })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (titleMatch || contentMatch) {
|
||||
result.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return loop(nodes)
|
||||
}
|
||||
|
||||
export function convertTreeToMenuItems(nodes) {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
if (!node.isLeaf) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title,
|
||||
icon: <FolderOutlined />,
|
||||
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (node.title?.endsWith('.md')) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title.replace('.md', ''),
|
||||
icon: <FileTextOutlined />,
|
||||
}
|
||||
}
|
||||
|
||||
if (node.title?.endsWith('.pdf')) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title,
|
||||
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getDocumentUrl, getFileContent, getProjectTree } from '@/api/file'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import { buildAuthorizedUrl } from '@/utils/authStorage'
|
||||
import {
|
||||
buildTocItems,
|
||||
collectParentKeys,
|
||||
convertTreeToMenuItems,
|
||||
filterTreeByKeyword,
|
||||
findNodeByKey,
|
||||
findRootReadme,
|
||||
resolveRelativePath,
|
||||
} from './documentBrowserUtils'
|
||||
|
||||
function useDocumentBrowser({ projectId, searchParams, setSearchParams, contentRef }) {
|
||||
const [fileTree, setFileTree] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState('')
|
||||
const [selectedNodeKey, setSelectedNodeKey] = useState('')
|
||||
const [markdownContent, setMarkdownContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [openKeys, setOpenKeys] = useState([])
|
||||
const [tocItems, setTocItems] = useState([])
|
||||
const [userRole, setUserRole] = useState('viewer')
|
||||
const [pdfUrl, setPdfUrl] = useState('')
|
||||
const [pdfFilename, setPdfFilename] = useState('')
|
||||
const [viewMode, setViewMode] = useState('markdown')
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const updateFileParam = (filePath) => {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
if (filePath) {
|
||||
nextParams.set('file', filePath)
|
||||
} else {
|
||||
nextParams.delete('file')
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
}
|
||||
|
||||
const loadMarkdown = async (filePath) => {
|
||||
setLoading(true)
|
||||
setTocItems([])
|
||||
|
||||
try {
|
||||
const res = await getFileContent(projectId, filePath)
|
||||
setMarkdownContent(res.data?.content || '')
|
||||
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load markdown error:', error)
|
||||
setMarkdownContent('# 文档加载失败\n\n无法加载该文档,请稍后重试。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openFile = async (filePath, options = {}) => {
|
||||
const { syncUrl = true } = options
|
||||
|
||||
setSelectedFile(filePath)
|
||||
setSelectedNodeKey(filePath)
|
||||
setOpenKeys((prev) => [...new Set([...prev, ...collectParentKeys(filePath)])])
|
||||
|
||||
if (syncUrl) {
|
||||
updateFileParam(filePath)
|
||||
}
|
||||
|
||||
if (filePath.toLowerCase().endsWith('.pdf')) {
|
||||
setPdfUrl(buildAuthorizedUrl(getDocumentUrl(projectId, filePath)))
|
||||
setPdfFilename(filePath.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
return
|
||||
}
|
||||
|
||||
setPdfUrl('')
|
||||
setPdfFilename('')
|
||||
setViewMode('markdown')
|
||||
await loadMarkdown(filePath)
|
||||
}
|
||||
|
||||
const loadFileTree = async () => {
|
||||
try {
|
||||
const res = await getProjectTree(projectId)
|
||||
const data = res.data || {}
|
||||
setFileTree(data.tree || data || [])
|
||||
setUserRole(data.user_role || 'viewer')
|
||||
setProjectName(data.project_name || '')
|
||||
} catch (error) {
|
||||
console.error('Load file tree error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async (value) => {
|
||||
setSearchKeyword(value)
|
||||
|
||||
if (!value.trim()) {
|
||||
setMatchedFilePaths(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const res = await searchDocuments(value, projectId)
|
||||
const paths = new Set(res.data.map((item) => item.file_path))
|
||||
setMatchedFilePaths(paths)
|
||||
|
||||
const expandedKeys = new Set(openKeys)
|
||||
res.data.forEach((item) => {
|
||||
collectParentKeys(item.file_path).forEach((parentKey) => expandedKeys.add(parentKey))
|
||||
})
|
||||
setOpenKeys(Array.from(expandedKeys))
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
openFile(key)
|
||||
}
|
||||
|
||||
const refreshCurrentView = async () => {
|
||||
await loadFileTree()
|
||||
|
||||
if (!selectedFile) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedFile.toLowerCase().endsWith('.pdf')) {
|
||||
const baseUrl = getDocumentUrl(projectId, selectedFile)
|
||||
const separator = baseUrl.includes('?') ? '&' : '?'
|
||||
setPdfUrl(buildAuthorizedUrl(`${baseUrl}${separator}_=${Date.now()}`))
|
||||
return
|
||||
}
|
||||
|
||||
await loadMarkdown(selectedFile)
|
||||
}
|
||||
|
||||
const handleMarkdownLink = (event, href) => {
|
||||
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return
|
||||
}
|
||||
|
||||
const isMarkdownFile = href.endsWith('.md')
|
||||
const isPdfFile = href.toLowerCase().endsWith('.pdf')
|
||||
if (!isMarkdownFile && !isPdfFile) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
let decodedHref = href
|
||||
try {
|
||||
decodedHref = decodeURIComponent(href)
|
||||
} catch (error) {
|
||||
console.warn('Decode markdown href failed:', error)
|
||||
}
|
||||
|
||||
const targetPath = decodedHref.startsWith('.') || decodedHref.startsWith('..')
|
||||
? resolveRelativePath(selectedFile, decodedHref)
|
||||
: (decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref)
|
||||
|
||||
openFile(targetPath)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFileTree()
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
setTocItems(buildTocItems(markdownContent))
|
||||
}, [markdownContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTree.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileParam = searchParams.get('file')
|
||||
const keywordParam = searchParams.get('keyword')
|
||||
|
||||
if (keywordParam && keywordParam !== searchKeyword) {
|
||||
handleSearch(keywordParam)
|
||||
}
|
||||
|
||||
if (fileParam) {
|
||||
if (fileParam !== selectedFile) {
|
||||
openFile(fileParam, { syncUrl: false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
const readmeNode = findRootReadme(fileTree)
|
||||
if (readmeNode) {
|
||||
openFile(readmeNode.key)
|
||||
}
|
||||
}
|
||||
}, [fileTree, searchParams])
|
||||
|
||||
const filteredTreeData = useMemo(
|
||||
() => filterTreeByKeyword(fileTree, searchKeyword, matchedFilePaths),
|
||||
[fileTree, searchKeyword, matchedFilePaths]
|
||||
)
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => convertTreeToMenuItems(filteredTreeData),
|
||||
[filteredTreeData]
|
||||
)
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => findNodeByKey(fileTree, selectedNodeKey),
|
||||
[fileTree, selectedNodeKey]
|
||||
)
|
||||
|
||||
return {
|
||||
fileTree,
|
||||
filteredTreeData,
|
||||
menuItems,
|
||||
selectedFile,
|
||||
selectedNode,
|
||||
selectedNodeKey,
|
||||
markdownContent,
|
||||
loading,
|
||||
openKeys,
|
||||
projectName,
|
||||
projectId,
|
||||
userRole,
|
||||
pdfFilename,
|
||||
pdfUrl,
|
||||
viewMode,
|
||||
searchKeyword,
|
||||
isSearching,
|
||||
tocItems,
|
||||
setOpenKeys,
|
||||
setSearchKeyword,
|
||||
loadFileTree,
|
||||
loadMarkdown,
|
||||
refreshCurrentView,
|
||||
handleMenuClick,
|
||||
handleMarkdownLink,
|
||||
handleSearch,
|
||||
}
|
||||
}
|
||||
|
||||
export default useDocumentBrowser
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { getFileContent, getProjectTree } from '@/api/file'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import { collectParentKeys, findNodeByKey } from './documentBrowserUtils'
|
||||
|
||||
function useDocumentEditorWorkspace({ projectId, searchParams, setSearchParams }) {
|
||||
const [treeData, setTreeData] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState(null)
|
||||
const [selectedNode, setSelectedNode] = useState(null)
|
||||
const [fileContent, setFileContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [openKeys, setOpenKeys] = useState([])
|
||||
const [selectedMenuKey, setSelectedMenuKey] = useState(null)
|
||||
const [isPdfSelected, setIsPdfSelected] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [userRole, setUserRole] = useState('viewer')
|
||||
|
||||
const updateFileParam = (filePath) => {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
if (filePath) {
|
||||
nextParams.set('file', filePath)
|
||||
} else {
|
||||
nextParams.delete('file')
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
}
|
||||
|
||||
const fetchTree = async () => {
|
||||
try {
|
||||
const res = await getProjectTree(projectId)
|
||||
const data = res.data || {}
|
||||
setTreeData(data.tree || data || [])
|
||||
setProjectName(data.project_name || '')
|
||||
setUserRole(data.user_role || 'viewer')
|
||||
} catch (error) {
|
||||
Toast.error('加载失败', '加载文件树失败')
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedFile(null)
|
||||
setSelectedNode(null)
|
||||
setSelectedMenuKey(null)
|
||||
setFileContent('')
|
||||
setIsPdfSelected(false)
|
||||
updateFileParam(null)
|
||||
}
|
||||
|
||||
const loadEditableFile = async (filePath) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getFileContent(projectId, filePath)
|
||||
setSelectedFile(filePath)
|
||||
setFileContent(res.data.content)
|
||||
} catch (error) {
|
||||
Toast.error('加载失败', '加载文件失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openNodeByKey = async (key, node = null, syncUrl = false) => {
|
||||
const targetNode = node || findNodeByKey(treeData, key)
|
||||
if (!targetNode) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedNode(targetNode)
|
||||
setSelectedMenuKey(key)
|
||||
|
||||
if (!targetNode.isLeaf) {
|
||||
if (syncUrl) {
|
||||
updateFileParam(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (syncUrl) {
|
||||
updateFileParam(key)
|
||||
}
|
||||
|
||||
setOpenKeys((prev) => Array.from(new Set([...prev, ...collectParentKeys(key)])))
|
||||
|
||||
if (key.toLowerCase().endsWith('.pdf')) {
|
||||
setSelectedFile(key)
|
||||
setIsPdfSelected(true)
|
||||
setFileContent('')
|
||||
return
|
||||
}
|
||||
|
||||
setIsPdfSelected(false)
|
||||
await loadEditableFile(key)
|
||||
}
|
||||
|
||||
const handleMenuClick = async ({ key }) => {
|
||||
const node = findNodeByKey(treeData, key)
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
await openNodeByKey(key, node, true)
|
||||
}
|
||||
|
||||
const refreshCurrentDocument = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await fetchTree()
|
||||
|
||||
if (selectedFile && !isPdfSelected) {
|
||||
await loadEditableFile(selectedFile)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Refresh document editor error:', error)
|
||||
Toast.error('刷新失败', '请稍后重试')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTree()
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileParam = searchParams.get('file')
|
||||
if (!fileParam || fileParam === selectedFile) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetNode = findNodeByKey(treeData, fileParam)
|
||||
if (!targetNode) {
|
||||
return
|
||||
}
|
||||
|
||||
openNodeByKey(fileParam, targetNode, false)
|
||||
}, [treeData, searchParams])
|
||||
|
||||
return {
|
||||
treeData,
|
||||
selectedFile,
|
||||
selectedNode,
|
||||
fileContent,
|
||||
loading,
|
||||
openKeys,
|
||||
selectedMenuKey,
|
||||
isPdfSelected,
|
||||
refreshing,
|
||||
projectName,
|
||||
userRole,
|
||||
setOpenKeys,
|
||||
setSelectedNode,
|
||||
setSelectedFile,
|
||||
setSelectedMenuKey,
|
||||
setFileContent,
|
||||
fetchTree,
|
||||
clearSelection,
|
||||
loadEditableFile,
|
||||
openNodeByKey,
|
||||
handleMenuClick,
|
||||
refreshCurrentDocument,
|
||||
}
|
||||
}
|
||||
|
||||
export default useDocumentEditorWorkspace
|
||||
|
|
@ -153,14 +153,14 @@
|
|||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.login-form-container .ant-input-affix-wrapper:hover,
|
||||
.login-form-container .ant-input:hover {
|
||||
background: #fff;
|
||||
border-color: #d9d9d9;
|
||||
border-color: var(--border-color-strong);
|
||||
}
|
||||
|
||||
.login-form-container .ant-input-affix-wrapper-focused,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty } from 'antd'
|
||||
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
|
||||
import { MenuFoldOutlined, MenuOutlined, MenuUnfoldOutlined, FileTextOutlined, LockOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
|
|
@ -11,10 +10,8 @@ import rehypeSlug from 'rehype-slug'
|
|||
import 'highlight.js/styles/github.css'
|
||||
import Mark from 'mark.js'
|
||||
import Highlighter from 'react-highlight-words'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl, exportPDF } from '@/api/share'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||
import usePreviewBrowser from './usePreviewBrowser'
|
||||
import './PreviewPage.css'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
|
@ -36,41 +33,42 @@ function PreviewPage() {
|
|||
const { projectId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const handleClose = () => {
|
||||
// 检查是否有历史记录可回退
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1)
|
||||
} else {
|
||||
navigate('/projects')
|
||||
}
|
||||
}
|
||||
const [projectInfo, setProjectInfo] = useState(null)
|
||||
const [fileTree, setFileTree] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState('')
|
||||
const [markdownContent, setMarkdownContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [openKeys, setOpenKeys] = useState([])
|
||||
const [tocCollapsed, setTocCollapsed] = useState(false)
|
||||
const [tocItems, setTocItems] = useState([])
|
||||
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [accessPassword, setAccessPassword] = useState(null)
|
||||
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')
|
||||
|
||||
// 搜索相关
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const contentRef = useRef(null)
|
||||
const viewerRef = useRef(null)
|
||||
const {
|
||||
projectInfo,
|
||||
filteredTreeData,
|
||||
menuItems,
|
||||
selectedFile,
|
||||
markdownContent,
|
||||
loading,
|
||||
openKeys,
|
||||
tocItems,
|
||||
passwordModalVisible,
|
||||
password,
|
||||
pdfUrl,
|
||||
pdfFilename,
|
||||
viewMode,
|
||||
searchKeyword,
|
||||
isSearching,
|
||||
setOpenKeys,
|
||||
setPassword,
|
||||
setSearchKeyword,
|
||||
setPasswordModalVisible,
|
||||
handleContentClick,
|
||||
handleExportPDF,
|
||||
handleMenuClick,
|
||||
handleSearch,
|
||||
handleVerifyPassword,
|
||||
} = usePreviewBrowser({
|
||||
projectId,
|
||||
searchParams,
|
||||
contentRef,
|
||||
})
|
||||
|
||||
// mark.js 高亮
|
||||
useEffect(() => {
|
||||
|
|
@ -97,397 +95,21 @@ function PreviewPage() {
|
|||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadProjectInfo()
|
||||
}, [projectId])
|
||||
|
||||
// 监听 URL 参数变化,处理文件导航和搜索
|
||||
useEffect(() => {
|
||||
if (fileTree.length === 0) return
|
||||
|
||||
const fileParam = searchParams.get('file')
|
||||
const keywordParam = searchParams.get('keyword')
|
||||
|
||||
if (keywordParam && keywordParam !== searchKeyword) {
|
||||
handleSearch(keywordParam)
|
||||
}
|
||||
|
||||
if (fileParam) {
|
||||
if (fileParam !== selectedFile) {
|
||||
// Deep link to file
|
||||
if (fileParam.toLowerCase().endsWith('.pdf')) {
|
||||
let url = getPreviewDocumentUrl(projectId, fileParam)
|
||||
const params = []
|
||||
if (accessPassword) params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) params.push(`token=${encodeURIComponent(token)}`)
|
||||
if (params.length > 0) url += `?${params.join('&')}`
|
||||
|
||||
setSelectedFile(fileParam)
|
||||
setPdfUrl(url)
|
||||
setPdfFilename(fileParam.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
} else {
|
||||
setSelectedFile(fileParam)
|
||||
loadMarkdown(fileParam, accessPassword)
|
||||
setViewMode('markdown')
|
||||
}
|
||||
|
||||
// Expand tree to file
|
||||
const parts = fileParam.split('/')
|
||||
const allParentPaths = []
|
||||
let currentPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
allParentPaths.push(currentPath)
|
||||
}
|
||||
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
|
||||
}
|
||||
} else {
|
||||
if (!selectedFile) {
|
||||
const readmeNode = findReadme(fileTree)
|
||||
if (readmeNode) {
|
||||
setSelectedFile(readmeNode.key)
|
||||
loadMarkdown(readmeNode.key, accessPassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [searchParams, fileTree, accessPassword])
|
||||
|
||||
// 加载项目基本信息
|
||||
const loadProjectInfo = async () => {
|
||||
try {
|
||||
const res = await getPreviewInfo(projectId)
|
||||
const info = res.data
|
||||
setProjectInfo(info)
|
||||
|
||||
if (info.has_password) {
|
||||
setPasswordModalVisible(true)
|
||||
} else {
|
||||
loadFileTree()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load project info error:', error)
|
||||
Toast.error('加载失败', '项目不存在或已被删除')
|
||||
}
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const handleVerifyPassword = async () => {
|
||||
if (!password.trim()) {
|
||||
Toast.warning('提示', '请输入访问密码')
|
||||
const handleClose = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyAccessPassword(projectId, password)
|
||||
setAccessPassword(password)
|
||||
setPasswordModalVisible(false)
|
||||
loadFileTree(password)
|
||||
Toast.success('验证成功')
|
||||
} catch (error) {
|
||||
Toast.error('访问密码错误')
|
||||
}
|
||||
navigate('/projects')
|
||||
}
|
||||
|
||||
// 加载文件树
|
||||
const loadFileTree = async (pwd = null) => {
|
||||
try {
|
||||
const res = await getPreviewTree(projectId, pwd || accessPassword)
|
||||
const tree = res.data || []
|
||||
setFileTree(tree)
|
||||
} catch (error) {
|
||||
console.error('Load file tree error:', error)
|
||||
if (error.response?.status === 403) {
|
||||
Toast.error('访问密码错误或已过期')
|
||||
setPasswordModalVisible(true)
|
||||
}
|
||||
const handleMenuSelect = ({ key }) => {
|
||||
handleMenuClick({ key })
|
||||
if (isMobile) {
|
||||
setMobileDrawerVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = async (value) => {
|
||||
setSearchKeyword(value)
|
||||
if (!value.trim()) {
|
||||
setMatchedFilePaths(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const res = await searchDocuments(value, projectId)
|
||||
const paths = new Set(res.data.map(item => item.file_path))
|
||||
setMatchedFilePaths(paths)
|
||||
|
||||
// 自动展开匹配的节点 (Assuming this comment might be there or not, better context: keysToExpand)
|
||||
const keysToExpand = new Set(openKeys)
|
||||
res.data.forEach(item => {
|
||||
const parts = item.file_path.split('/')
|
||||
let currentPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
keysToExpand.add(currentPath)
|
||||
}
|
||||
})
|
||||
setOpenKeys(Array.from(keysToExpand))
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
Toast.error('搜索失败', '请稍后重试')
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤树
|
||||
const filteredTreeData = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return fileTree
|
||||
|
||||
const loop = (data) => {
|
||||
const result = []
|
||||
for (const node of data) {
|
||||
const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
const contentMatch = matchedFilePaths.has(node.key)
|
||||
|
||||
if (node.children) {
|
||||
const children = loop(node.children)
|
||||
if (children.length > 0 || titleMatch) {
|
||||
result.push({ ...node, children })
|
||||
}
|
||||
} else {
|
||||
if (titleMatch || contentMatch) {
|
||||
result.push(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return loop(fileTree)
|
||||
}, [fileTree, searchKeyword, matchedFilePaths])
|
||||
|
||||
const findReadme = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.title === 'README.md' && node.isLeaf) {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const convertTreeToMenuItems = (nodes) => {
|
||||
return nodes.map((node) => {
|
||||
const labelNode = node.title.replace('.md', '')
|
||||
|
||||
if (!node.isLeaf) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title,
|
||||
icon: <FolderOutlined />,
|
||||
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
||||
}
|
||||
} else if (node.title && node.title.endsWith('.md')) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: labelNode,
|
||||
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)
|
||||
}
|
||||
|
||||
const loadMarkdown = async (filePath, pwd = null) => {
|
||||
setLoading(true)
|
||||
setTocItems([])
|
||||
try {
|
||||
const res = await getPreviewFile(projectId, filePath, pwd || accessPassword)
|
||||
setMarkdownContent(res.data?.content || '')
|
||||
|
||||
if (isMobile) {
|
||||
setMobileDrawerVisible(false)
|
||||
}
|
||||
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load markdown error:', error)
|
||||
if (error.response?.status === 403) {
|
||||
Toast.error('访问密码错误或已过期')
|
||||
setPasswordModalVisible(true)
|
||||
} else {
|
||||
Toast.error('加载失败', '文档加载失败,请稍后重试')
|
||||
setMarkdownContent('')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (markdownContent) {
|
||||
const slugger = new GithubSlugger()
|
||||
const headings = []
|
||||
const lines = markdownContent.split('\n')
|
||||
|
||||
lines.forEach((line) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (match) {
|
||||
const level = match[1].length
|
||||
const title = match[2]
|
||||
// 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致
|
||||
const key = slugger.slug(title)
|
||||
|
||||
headings.push({
|
||||
key: `#${key}`,
|
||||
href: `#${key}`,
|
||||
title,
|
||||
level,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
setTocItems(headings)
|
||||
}
|
||||
}, [markdownContent])
|
||||
|
||||
const resolveRelativePath = (currentPath, relativePath) => {
|
||||
if (relativePath.startsWith('/')) {
|
||||
return relativePath.substring(1)
|
||||
}
|
||||
|
||||
const lastSlashIndex = currentPath.lastIndexOf('/')
|
||||
const currentDir = lastSlashIndex !== -1 ? currentPath.substring(0, lastSlashIndex) : ''
|
||||
|
||||
const parts = relativePath.split('/')
|
||||
const dirParts = currentDir ? currentDir.split('/') : []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '..') {
|
||||
dirParts.pop()
|
||||
} else if (part !== '.' && part !== '') {
|
||||
dirParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return dirParts.join('/')
|
||||
}
|
||||
|
||||
const handleMarkdownLink = (e, href) => {
|
||||
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) {
|
||||
return
|
||||
}
|
||||
|
||||
const isMd = href.endsWith('.md')
|
||||
const isPdf = href.toLowerCase().endsWith('.pdf')
|
||||
|
||||
if (!isMd && !isPdf) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
let decodedHref = href
|
||||
try {
|
||||
decodedHref = decodeURIComponent(href)
|
||||
} catch (err) {
|
||||
}
|
||||
|
||||
const targetPath = resolveRelativePath(selectedFile, decodedHref)
|
||||
|
||||
const lastSlashIndex = targetPath.lastIndexOf('/')
|
||||
const parentPath = lastSlashIndex !== -1 ? targetPath.substring(0, lastSlashIndex) : ''
|
||||
if (parentPath && !openKeys.includes(parentPath)) {
|
||||
const pathParts = parentPath.split('/')
|
||||
const allParentPaths = []
|
||||
let currentPath = ''
|
||||
for (const part of pathParts) {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
||||
allParentPaths.push(currentPath)
|
||||
}
|
||||
setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
|
||||
}
|
||||
|
||||
handleMenuClick({ key: targetPath })
|
||||
}
|
||||
|
||||
const handleContentClick = (e) => {
|
||||
const target = e.target.closest('a')
|
||||
if (target) {
|
||||
const href = target.getAttribute('href')
|
||||
if (href) {
|
||||
handleMarkdownLink(e, href)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
setSelectedFile(key)
|
||||
|
||||
if (key.toLowerCase().endsWith('.pdf')) {
|
||||
let url = getPreviewDocumentUrl(projectId, key)
|
||||
const params = []
|
||||
|
||||
if (accessPassword) {
|
||||
params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
|
||||
}
|
||||
|
||||
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 {
|
||||
setViewMode('markdown')
|
||||
loadMarkdown(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出 PDF 处理
|
||||
const handleExportPDF = () => {
|
||||
if (viewMode === 'pdf') {
|
||||
// 如果已经是 PDF 文件,直接下载
|
||||
const link = document.createElement('a')
|
||||
link.href = pdfUrl
|
||||
link.download = pdfFilename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} else {
|
||||
// Markdown 文件:使用后端生成 PDF
|
||||
let url = exportPDF(projectId, selectedFile)
|
||||
const params = []
|
||||
|
||||
if (accessPassword) {
|
||||
params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
params.push(`token=${encodeURIComponent(token)}`)
|
||||
}
|
||||
|
||||
if (params.length > 0) {
|
||||
url += (url.includes('?') ? '&' : '?') + params.join('&')
|
||||
}
|
||||
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = convertTreeToMenuItems(filteredTreeData)
|
||||
|
||||
return (
|
||||
<div className="preview-page">
|
||||
<Layout className="preview-layout">
|
||||
|
|
@ -542,7 +164,7 @@ function PreviewPage() {
|
|||
openKeys={openKeys}
|
||||
onOpenChange={setOpenKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
onClick={handleMenuSelect}
|
||||
className="preview-menu"
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -593,7 +215,7 @@ function PreviewPage() {
|
|||
openKeys={openKeys}
|
||||
onOpenChange={setOpenKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
onClick={handleMenuSelect}
|
||||
className="preview-menu"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
buildTocItems,
|
||||
collectParentKeys,
|
||||
convertTreeToMenuItems,
|
||||
filterTreeByKeyword,
|
||||
findRootReadme,
|
||||
resolveRelativePath,
|
||||
} from '../Document/documentBrowserUtils'
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import {
|
||||
exportPDF,
|
||||
getPreviewDocumentUrl,
|
||||
getPreviewFile,
|
||||
getPreviewInfo,
|
||||
getPreviewTree,
|
||||
verifyAccessPassword,
|
||||
} from '@/api/share'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import { buildAuthorizedUrl } from '@/utils/authStorage'
|
||||
import {
|
||||
buildTocItems,
|
||||
collectParentKeys,
|
||||
convertTreeToMenuItems,
|
||||
filterTreeByKeyword,
|
||||
findRootReadme,
|
||||
resolveRelativePath,
|
||||
} from './previewBrowserUtils'
|
||||
|
||||
function usePreviewBrowser({ projectId, searchParams, contentRef }) {
|
||||
const [projectInfo, setProjectInfo] = useState(null)
|
||||
const [fileTree, setFileTree] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState('')
|
||||
const [markdownContent, setMarkdownContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [openKeys, setOpenKeys] = useState([])
|
||||
const [tocItems, setTocItems] = useState([])
|
||||
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [accessPassword, setAccessPassword] = useState(null)
|
||||
const [pdfUrl, setPdfUrl] = useState('')
|
||||
const [pdfFilename, setPdfFilename] = useState('')
|
||||
const [viewMode, setViewMode] = useState('markdown')
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const buildPreviewUrl = (url) => buildAuthorizedUrl(url, { access_pass: accessPassword })
|
||||
|
||||
const loadMarkdown = async (filePath, pwd = null) => {
|
||||
setLoading(true)
|
||||
setTocItems([])
|
||||
|
||||
try {
|
||||
const res = await getPreviewFile(projectId, filePath, pwd || accessPassword)
|
||||
setMarkdownContent(res.data?.content || '')
|
||||
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load markdown error:', error)
|
||||
if (error.response?.status === 403) {
|
||||
Toast.error('访问密码错误或已过期')
|
||||
setPasswordModalVisible(true)
|
||||
} else {
|
||||
Toast.error('加载失败', '文档加载失败,请稍后重试')
|
||||
setMarkdownContent('')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openFile = async (filePath, pwd = null) => {
|
||||
setSelectedFile(filePath)
|
||||
setOpenKeys((prev) => [...new Set([...prev, ...collectParentKeys(filePath)])])
|
||||
|
||||
if (filePath.toLowerCase().endsWith('.pdf')) {
|
||||
setPdfUrl(buildPreviewUrl(getPreviewDocumentUrl(projectId, filePath)))
|
||||
setPdfFilename(filePath.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
return
|
||||
}
|
||||
|
||||
setViewMode('markdown')
|
||||
await loadMarkdown(filePath, pwd)
|
||||
}
|
||||
|
||||
const loadFileTree = async (pwd = null) => {
|
||||
try {
|
||||
const res = await getPreviewTree(projectId, pwd || accessPassword)
|
||||
setFileTree(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Load file tree error:', error)
|
||||
if (error.response?.status === 403) {
|
||||
Toast.error('访问密码错误或已过期')
|
||||
setPasswordModalVisible(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadProjectInfo = async () => {
|
||||
try {
|
||||
const res = await getPreviewInfo(projectId)
|
||||
const info = res.data
|
||||
setProjectInfo(info)
|
||||
|
||||
if (info.has_password) {
|
||||
setPasswordModalVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
loadFileTree()
|
||||
} catch (error) {
|
||||
console.error('Load project info error:', error)
|
||||
Toast.error('加载失败', '项目不存在或已被删除')
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyPassword = async () => {
|
||||
if (!password.trim()) {
|
||||
Toast.warning('提示', '请输入访问密码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyAccessPassword(projectId, password)
|
||||
setAccessPassword(password)
|
||||
setPasswordModalVisible(false)
|
||||
loadFileTree(password)
|
||||
Toast.success('验证成功')
|
||||
} catch (error) {
|
||||
Toast.error('访问密码错误')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async (value) => {
|
||||
setSearchKeyword(value)
|
||||
|
||||
if (!value.trim()) {
|
||||
setMatchedFilePaths(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const res = await searchDocuments(value, projectId)
|
||||
const paths = new Set(res.data.map((item) => item.file_path))
|
||||
setMatchedFilePaths(paths)
|
||||
|
||||
const expandedKeys = new Set(openKeys)
|
||||
res.data.forEach((item) => {
|
||||
collectParentKeys(item.file_path).forEach((parentKey) => expandedKeys.add(parentKey))
|
||||
})
|
||||
setOpenKeys(Array.from(expandedKeys))
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
Toast.error('搜索失败', '请稍后重试')
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
openFile(key)
|
||||
}
|
||||
|
||||
const handleMarkdownLink = (event, href) => {
|
||||
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) {
|
||||
return
|
||||
}
|
||||
|
||||
const isMarkdownFile = href.endsWith('.md')
|
||||
const isPdfFile = href.toLowerCase().endsWith('.pdf')
|
||||
if (!isMarkdownFile && !isPdfFile) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
let decodedHref = href
|
||||
try {
|
||||
decodedHref = decodeURIComponent(href)
|
||||
} catch (error) {
|
||||
console.warn('Decode markdown href failed:', error)
|
||||
}
|
||||
|
||||
const targetPath = decodedHref.startsWith('.') || decodedHref.startsWith('..')
|
||||
? resolveRelativePath(selectedFile, decodedHref)
|
||||
: (decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref)
|
||||
setOpenKeys((prev) => [...new Set([...prev, ...collectParentKeys(targetPath)])])
|
||||
handleMenuClick({ key: targetPath })
|
||||
}
|
||||
|
||||
const handleContentClick = (event) => {
|
||||
const target = event.target.closest('a')
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
const href = target.getAttribute('href')
|
||||
if (href) {
|
||||
handleMarkdownLink(event, href)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPDF = () => {
|
||||
if (viewMode === 'pdf') {
|
||||
const link = document.createElement('a')
|
||||
link.href = pdfUrl
|
||||
link.download = pdfFilename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
return
|
||||
}
|
||||
|
||||
window.open(buildPreviewUrl(exportPDF(projectId, selectedFile)), '_blank')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProjectInfo()
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
setTocItems(buildTocItems(markdownContent))
|
||||
}, [markdownContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTree.length === 0) {
|
||||
return
|
||||
}
|
||||
if (projectInfo?.has_password && !accessPassword) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileParam = searchParams.get('file')
|
||||
const keywordParam = searchParams.get('keyword')
|
||||
|
||||
if (keywordParam && keywordParam !== searchKeyword) {
|
||||
handleSearch(keywordParam)
|
||||
}
|
||||
|
||||
if (fileParam) {
|
||||
if (fileParam !== selectedFile) {
|
||||
openFile(fileParam, accessPassword)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
const readmeNode = findRootReadme(fileTree)
|
||||
if (readmeNode) {
|
||||
openFile(readmeNode.key, accessPassword)
|
||||
}
|
||||
}
|
||||
}, [searchParams, fileTree, accessPassword, projectInfo])
|
||||
|
||||
const filteredTreeData = useMemo(
|
||||
() => filterTreeByKeyword(fileTree, searchKeyword, matchedFilePaths),
|
||||
[fileTree, searchKeyword, matchedFilePaths]
|
||||
)
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => convertTreeToMenuItems(filteredTreeData),
|
||||
[filteredTreeData]
|
||||
)
|
||||
|
||||
return {
|
||||
projectInfo,
|
||||
filteredTreeData,
|
||||
menuItems,
|
||||
selectedFile,
|
||||
markdownContent,
|
||||
loading,
|
||||
openKeys,
|
||||
tocItems,
|
||||
passwordModalVisible,
|
||||
password,
|
||||
pdfUrl,
|
||||
pdfFilename,
|
||||
viewMode,
|
||||
searchKeyword,
|
||||
isSearching,
|
||||
setOpenKeys,
|
||||
setPassword,
|
||||
setSearchKeyword,
|
||||
setPasswordModalVisible,
|
||||
handleContentClick,
|
||||
handleExportPDF,
|
||||
handleMenuClick,
|
||||
handleSearch,
|
||||
handleVerifyPassword,
|
||||
}
|
||||
}
|
||||
|
||||
export default usePreviewBrowser
|
||||
|
|
@ -1,19 +1,23 @@
|
|||
.project-list-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.project-list-header {
|
||||
.project-list-card .ant-card-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.project-list-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
.project-results-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-results-summary {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.project-search-file-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.project-card {
|
||||
|
|
@ -28,7 +32,7 @@
|
|||
|
||||
.project-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
.project-card-public-badge {
|
||||
|
|
@ -76,6 +80,16 @@
|
|||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.project-empty-state {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.project-list-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 圆点分页指示器样式 */
|
||||
.dot-pagination.ant-pagination {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,43 +1,85 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
|
||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||
import { getUserList } from '@/api/users'
|
||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, SwapOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject } from '@/api/project'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import ListActionBar from '@/components/ListActionBar/ListActionBar'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import useProjectExport from './useProjectExport'
|
||||
import useProjectShare from './useProjectShare'
|
||||
import useProjectCollaboration from './useProjectCollaboration'
|
||||
import useProjectGitRepos from './useProjectGitRepos'
|
||||
import './ProjectList.css'
|
||||
import '@/pages/System/AdminPages.css'
|
||||
|
||||
function ProjectList({ type = 'my' }) {
|
||||
const [projects, setProjects] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editModalVisible, setEditModalVisible] = useState(false)
|
||||
const [gitModalVisible, setGitModalVisible] = useState(false)
|
||||
const [shareModalVisible, setShareModalVisible] = useState(false)
|
||||
const [membersModalVisible, setMembersModalVisible] = useState(false)
|
||||
const [currentProject, setCurrentProject] = useState(null)
|
||||
const [shareInfo, setShareInfo] = useState(null)
|
||||
const [hasPassword, setHasPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
const [members, setMembers] = useState([])
|
||||
const [users, setUsers] = useState([])
|
||||
const [loadingMembers, setLoadingMembers] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [gitForm] = Form.useForm()
|
||||
const [memberForm] = Form.useForm()
|
||||
const [transferModalVisible, setTransferModalVisible] = useState(false)
|
||||
const [transferForm] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const pageSize = 8
|
||||
const {
|
||||
exportModalVisible,
|
||||
exportingProject,
|
||||
exportTask,
|
||||
closeExportModal,
|
||||
exportProject,
|
||||
} = useProjectExport()
|
||||
const {
|
||||
shareModalVisible,
|
||||
shareInfo,
|
||||
hasPassword,
|
||||
password,
|
||||
setPassword,
|
||||
openShareModal,
|
||||
closeShareModal,
|
||||
copyShareLink,
|
||||
togglePassword,
|
||||
savePassword,
|
||||
showPasswordControls,
|
||||
} = useProjectShare({ type })
|
||||
const {
|
||||
membersModalVisible,
|
||||
members,
|
||||
users,
|
||||
loadingMembers,
|
||||
transferModalVisible,
|
||||
loadTransferCandidates,
|
||||
submitTransfer,
|
||||
closeTransferModal,
|
||||
openMembersModal,
|
||||
closeMembersModal,
|
||||
addMember,
|
||||
removeMember,
|
||||
} = useProjectCollaboration()
|
||||
const [repoForm] = Form.useForm()
|
||||
const {
|
||||
gitRepos,
|
||||
loadingRepos,
|
||||
gitModalVisible,
|
||||
gitRepoModalVisible,
|
||||
editingRepo,
|
||||
openGitSettings,
|
||||
closeGitSettings,
|
||||
openAddRepoModal,
|
||||
openEditRepoModal,
|
||||
closeGitRepoModal,
|
||||
deleteRepo,
|
||||
saveRepo,
|
||||
} = useProjectGitRepos()
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
|
|
@ -51,43 +93,27 @@ function ProjectList({ type = 'my' }) {
|
|||
// ... (fetchProjects code)
|
||||
|
||||
const handleOpenTransfer = async () => {
|
||||
setLoadingMembers(true)
|
||||
setTransferModalVisible(true)
|
||||
try {
|
||||
// 获取用户列表 (排除自己)
|
||||
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
|
||||
const allUsers = res.data || []
|
||||
// 过滤掉当前所有者(也就是自己,虽然API也会校验)
|
||||
setUsers(allUsers.filter(u => u.id !== currentProject.owner_id))
|
||||
} catch (error) {
|
||||
message.error('加载用户列表失败')
|
||||
} finally {
|
||||
setLoadingMembers(false)
|
||||
}
|
||||
if (!currentProject) return
|
||||
await loadTransferCandidates(currentProject)
|
||||
}
|
||||
|
||||
const handleTransfer = async (values) => {
|
||||
Modal.confirm({
|
||||
title: '确认转移',
|
||||
content: '确定要将项目所有权转移给该用户吗?转移后您将变为管理员,无法再删除项目或转移所有权。',
|
||||
okText: '确认转移',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await transferProject(currentProject.id, values.new_owner_id)
|
||||
message.success('项目所有权已转移')
|
||||
setTransferModalVisible(false)
|
||||
setEditModalVisible(false)
|
||||
transferForm.resetFields()
|
||||
fetchProjects()
|
||||
} catch (error) {
|
||||
console.error('Transfer error:', error)
|
||||
message.error('转移失败: ' + (error.response?.data?.detail || error.message))
|
||||
}
|
||||
}
|
||||
if (!currentProject?.id) return
|
||||
await submitTransfer({
|
||||
projectId: currentProject.id,
|
||||
newOwnerId: values.new_owner_id,
|
||||
onCompleted: () => {
|
||||
setEditModalVisible(false)
|
||||
transferForm.resetFields()
|
||||
fetchProjects()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleExportProject = async () => {
|
||||
await exportProject(currentProject)
|
||||
}
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
|
|
@ -169,94 +195,35 @@ function ProjectList({ type = 'my' }) {
|
|||
}
|
||||
}
|
||||
|
||||
const [gitRepos, setGitRepos] = useState([])
|
||||
const [loadingRepos, setLoadingRepos] = useState(false)
|
||||
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
|
||||
const [editingRepo, setEditingRepo] = useState(null)
|
||||
const [repoForm] = Form.useForm()
|
||||
|
||||
// 打开Git设置(仓库列表)
|
||||
const handleGitSettings = (e, project) => {
|
||||
const handleGitSettings = async (e, project) => {
|
||||
e.stopPropagation()
|
||||
setCurrentProject(project)
|
||||
setGitModalVisible(true)
|
||||
fetchGitRepos(project.id)
|
||||
}
|
||||
|
||||
// 加载Git仓库列表
|
||||
const fetchGitRepos = async (projectId) => {
|
||||
setLoadingRepos(true)
|
||||
try {
|
||||
const res = await getGitRepos(projectId)
|
||||
setGitRepos(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Fetch git repos error:', error)
|
||||
message.error('加载Git仓库失败')
|
||||
} finally {
|
||||
setLoadingRepos(false)
|
||||
}
|
||||
await openGitSettings(project.id)
|
||||
}
|
||||
|
||||
// 打开添加仓库弹窗
|
||||
const handleAddRepo = () => {
|
||||
setEditingRepo(null)
|
||||
repoForm.resetFields()
|
||||
// 如果是第一个仓库,默认设为默认
|
||||
if (gitRepos.length === 0) {
|
||||
repoForm.setFieldsValue({ is_default: 1 })
|
||||
}
|
||||
setGitRepoModalVisible(true)
|
||||
openAddRepoModal({ repoForm, gitRepos })
|
||||
}
|
||||
|
||||
// 打开编辑仓库弹窗
|
||||
const handleEditRepo = (repo) => {
|
||||
setEditingRepo(repo)
|
||||
repoForm.setFieldsValue({
|
||||
...repo,
|
||||
is_default: repo.is_default === 1,
|
||||
})
|
||||
setGitRepoModalVisible(true)
|
||||
openEditRepoModal({ repo, repoForm })
|
||||
}
|
||||
|
||||
// 删除仓库
|
||||
const handleDeleteRepo = (repoId) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个Git仓库配置吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteGitRepo(currentProject.id, repoId)
|
||||
message.success('删除成功')
|
||||
fetchGitRepos(currentProject.id)
|
||||
} catch (error) {
|
||||
console.error('Delete repo error:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
if (!currentProject?.id) return
|
||||
deleteRepo({ projectId: currentProject.id, repoId })
|
||||
}
|
||||
|
||||
// 保存仓库(新增/更新)
|
||||
const handleSaveRepo = async (values) => {
|
||||
try {
|
||||
const data = {
|
||||
...values,
|
||||
is_default: values.is_default ? 1 : 0,
|
||||
}
|
||||
|
||||
if (editingRepo) {
|
||||
await updateGitRepo(currentProject.id, editingRepo.id, data)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createGitRepo(currentProject.id, data)
|
||||
message.success('添加成功')
|
||||
}
|
||||
|
||||
setGitRepoModalVisible(false)
|
||||
fetchGitRepos(currentProject.id)
|
||||
} catch (error) {
|
||||
console.error('Save repo error:', error)
|
||||
message.error(editingRepo ? '更新失败' : '添加失败')
|
||||
if (!currentProject?.id) return
|
||||
const succeeded = await saveRepo({ projectId: currentProject.id, values })
|
||||
if (succeeded) {
|
||||
closeGitRepoModal({ repoForm })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,161 +235,46 @@ function ProjectList({ type = 'my' }) {
|
|||
const handleShare = async (e, project) => {
|
||||
e.stopPropagation()
|
||||
setCurrentProject(project)
|
||||
try {
|
||||
const res = await getProjectShareInfo(project.id)
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(res.data.has_password)
|
||||
setPassword('')
|
||||
setShareModalVisible(true)
|
||||
} catch (error) {
|
||||
console.error('Get share info error:', error)
|
||||
message.error('获取分享信息失败')
|
||||
}
|
||||
await openShareModal(project)
|
||||
}
|
||||
|
||||
// 复制分享链接
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareInfo) return
|
||||
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(fullUrl)
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} else {
|
||||
// Fallback for non-secure contexts or older browsers
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = fullUrl
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-9999px"
|
||||
textArea.style.top = "0"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const successful = document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
if (successful) {
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} else {
|
||||
Toast.error('复制失败', '请手动复制链接')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
Toast.error('复制失败', '无法访问剪贴板')
|
||||
}
|
||||
await copyShareLink()
|
||||
}
|
||||
|
||||
// 切换密码保护
|
||||
const handlePasswordToggle = async (checked) => {
|
||||
if (!checked) {
|
||||
// 取消密码
|
||||
try {
|
||||
await updateShareSettings(currentProject.id, { access_pass: null })
|
||||
setHasPassword(false)
|
||||
setPassword('')
|
||||
message.success('已取消访问密码')
|
||||
// 刷新分享信息
|
||||
const res = await getProjectShareInfo(currentProject.id)
|
||||
setShareInfo(res.data)
|
||||
} catch (error) {
|
||||
console.error('Update settings error:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
} else {
|
||||
setHasPassword(true)
|
||||
}
|
||||
if (!currentProject?.id) return
|
||||
await togglePassword(currentProject.id, checked)
|
||||
}
|
||||
|
||||
// 保存密码
|
||||
const handleSavePassword = async () => {
|
||||
if (!password.trim()) {
|
||||
message.warning('请输入访问密码')
|
||||
if (!currentProject?.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateShareSettings(currentProject.id, { access_pass: password })
|
||||
message.success('访问密码已设置')
|
||||
// 刷新分享信息
|
||||
const res = await getProjectShareInfo(currentProject.id)
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(true)
|
||||
} catch (error) {
|
||||
console.error('Save password error:', error)
|
||||
message.error('设置密码失败')
|
||||
}
|
||||
await savePassword(currentProject.id)
|
||||
}
|
||||
|
||||
// 打开成员管理
|
||||
const handleMembers = async (e, project) => {
|
||||
e.stopPropagation()
|
||||
setCurrentProject(project)
|
||||
setMembersModalVisible(true)
|
||||
setLoadingMembers(true)
|
||||
|
||||
try {
|
||||
// 并行加载成员列表和用户列表(只获取普通用户 role_id=3)
|
||||
const [membersRes, usersRes] = await Promise.all([
|
||||
getProjectMembers(project.id),
|
||||
getUserList({ page: 1, page_size: 100, status: 1, role_id: 3 })
|
||||
])
|
||||
|
||||
console.log('Members Response:', membersRes)
|
||||
console.log('Users Response:', usersRes)
|
||||
|
||||
const membersData = membersRes.data || []
|
||||
// 后端返回格式: { code: 200, message: "success", data: [...], total, page, page_size }
|
||||
const usersData = Array.isArray(usersRes.data) ? usersRes.data : []
|
||||
|
||||
console.log('Setting members:', membersData)
|
||||
console.log('Setting users:', usersData)
|
||||
|
||||
setMembers(membersData)
|
||||
setUsers(usersData)
|
||||
} catch (error) {
|
||||
console.error('Get members error:', error)
|
||||
console.error('Error details:', error.response)
|
||||
message.error('获取数据失败: ' + (error.response?.data?.detail || error.message))
|
||||
} finally {
|
||||
setLoadingMembers(false)
|
||||
}
|
||||
await openMembersModal(project)
|
||||
}
|
||||
|
||||
// 添加成员
|
||||
const handleAddMember = async (values) => {
|
||||
try {
|
||||
await addProjectMember(currentProject.id, values)
|
||||
message.success('成员添加成功')
|
||||
memberForm.resetFields()
|
||||
// 刷新成员列表(带用户名信息)
|
||||
const res = await getProjectMembers(currentProject.id)
|
||||
setMembers(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Add member error:', error)
|
||||
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
if (!currentProject?.id) return
|
||||
await addMember(currentProject.id, values)
|
||||
memberForm.resetFields()
|
||||
}
|
||||
|
||||
// 删除成员
|
||||
const handleRemoveMember = async (userId) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个成员吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await removeProjectMember(currentProject.id, userId)
|
||||
message.success('成员删除成功')
|
||||
// 刷新成员列表(带用户名信息)
|
||||
const res = await getProjectMembers(currentProject.id)
|
||||
setMembers(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Remove member error:', error)
|
||||
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
},
|
||||
})
|
||||
if (!currentProject?.id) return
|
||||
await removeMember(currentProject.id, userId)
|
||||
}
|
||||
|
||||
// 处理搜索输入变化
|
||||
|
|
@ -480,157 +332,180 @@ function ProjectList({ type = 'my' }) {
|
|||
: projects
|
||||
|
||||
const paginatedProjects = filteredProjects.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
const pageConfig = {
|
||||
my: {
|
||||
title: '我的项目',
|
||||
description: '管理你创建的项目,支持搜索、协作、分享与整体导出。',
|
||||
icon: <FolderOutlined />,
|
||||
},
|
||||
share: {
|
||||
title: '参与项目',
|
||||
description: '查看与你协作的项目、当前角色权限以及可访问的文档内容。',
|
||||
icon: <TeamOutlined />,
|
||||
},
|
||||
all: {
|
||||
title: '项目列表',
|
||||
description: '统一浏览当前可访问的全部项目。',
|
||||
icon: <FolderOutlined />,
|
||||
},
|
||||
}[type] || {
|
||||
title: '项目列表',
|
||||
description: '统一浏览当前可访问的全部项目。',
|
||||
icon: <FolderOutlined />,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-list-container">
|
||||
<ListActionBar
|
||||
actions={type === 'my' ? [
|
||||
{
|
||||
key: 'create',
|
||||
label: '创建项目',
|
||||
type: 'primary',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => setModalVisible(true),
|
||||
},
|
||||
] : []}
|
||||
search={{
|
||||
placeholder: '搜索项目或文件...',
|
||||
value: searchKeyword,
|
||||
onChange: handleSearchChange,
|
||||
onSearch: handleSearch,
|
||||
}}
|
||||
showRefresh
|
||||
onRefresh={fetchProjects}
|
||||
<div className="admin-page">
|
||||
<PageHeader
|
||||
title={pageConfig.title}
|
||||
description={pageConfig.description}
|
||||
icon={pageConfig.icon}
|
||||
extra={type === 'my' ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
|
||||
创建项目
|
||||
</Button>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
{/* 搜索结果 */}
|
||||
{hasSearched && searchResults.length > 0 && (
|
||||
<div style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, color: '#666' }}>
|
||||
找到 {searchResults.length} 个结果
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{searchResults.map((item, index) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={`${item.type}-${item.project_id}-${index}`}>
|
||||
<Card
|
||||
hoverable
|
||||
className="project-card"
|
||||
onClick={() => handleSearchResultClick(item)}
|
||||
>
|
||||
<div className="project-card-icon">
|
||||
{item.type === 'project' ? (
|
||||
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||||
) : (
|
||||
<FileOutlined style={{ fontSize: 48, color: '#52c41a' }} />
|
||||
)}
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
{item.type === 'project' ? item.project_name : item.file_name}
|
||||
</h3>
|
||||
<Tag color={item.type === 'project' ? 'blue' : 'green'}>
|
||||
{item.match_type}
|
||||
</Tag>
|
||||
</Space>
|
||||
{item.type === 'project' && (
|
||||
<p className="project-description">
|
||||
{item.project_description || '暂无描述'}
|
||||
</p>
|
||||
)}
|
||||
{item.type === 'file' && (
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
<div>项目: {item.project_name}</div>
|
||||
<div>路径: {item.file_path}</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
<Card className="admin-card project-list-card">
|
||||
<ListActionBar
|
||||
search={{
|
||||
placeholder: '搜索项目或文件...',
|
||||
value: searchKeyword,
|
||||
onChange: handleSearchChange,
|
||||
onSearch: handleSearch,
|
||||
}}
|
||||
showRefresh
|
||||
onRefresh={fetchProjects}
|
||||
/>
|
||||
|
||||
{/* 正常项目列表 */}
|
||||
{!hasSearched && (
|
||||
<>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
{paginatedProjects.map((project) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
|
||||
<Card
|
||||
hoverable
|
||||
className="project-card"
|
||||
onClick={() => handleOpenProject(project.id)}
|
||||
actions={type === 'my' ? [
|
||||
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
|
||||
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
|
||||
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
||||
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||||
] : [
|
||||
<EyeOutlined key="view" />,
|
||||
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
||||
]}
|
||||
>
|
||||
{/* 公开项目标识 */}
|
||||
{project.is_public === 1 && (
|
||||
<div className="project-card-public-badge">公开</div>
|
||||
)}
|
||||
<div className="project-card-icon">
|
||||
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||||
</div>
|
||||
<h3>{project.name}</h3>
|
||||
<p className="project-description">{project.description || '暂无描述'}</p>
|
||||
<div className="project-meta">
|
||||
<span>文档数: {project.doc_count || 0}</span>
|
||||
{type === 'share' && project.owner_name && (
|
||||
<span style={{ marginLeft: 12 }}>
|
||||
所有者: {project.owner_nickname || project.owner_name}
|
||||
</span>
|
||||
)}
|
||||
{type === 'share' && project.user_role && (
|
||||
<span style={{ marginLeft: 12 }}>
|
||||
角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
{filteredProjects.length === 0 && !loading && (
|
||||
<Col span={24}>
|
||||
<Empty description={type === 'my' ? "还没有项目,创建一个开始吧" : "还没有参与的项目"} />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
{filteredProjects.length > 0 && (
|
||||
<div style={{ marginTop: 24, display: 'flex', justifyContent: 'center' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredProjects.length}
|
||||
onChange={setCurrentPage}
|
||||
showSizeChanger={false}
|
||||
className="dot-pagination"
|
||||
itemRender={(page, type, originalElement) => {
|
||||
if (type === 'page') {
|
||||
return <span className="pagination-dot" />
|
||||
}
|
||||
return originalElement
|
||||
}}
|
||||
/>
|
||||
{hasSearched && searchResults.length > 0 && (
|
||||
<div className="project-results-section">
|
||||
<div className="project-results-summary">
|
||||
找到 {searchResults.length} 个结果
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Row gutter={[16, 16]}>
|
||||
{searchResults.map((item, index) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={`${item.type}-${item.project_id}-${index}`}>
|
||||
<Card
|
||||
hoverable
|
||||
className="project-card"
|
||||
onClick={() => handleSearchResultClick(item)}
|
||||
>
|
||||
<div className="project-card-icon">
|
||||
{item.type === 'project' ? (
|
||||
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||||
) : (
|
||||
<FileOutlined style={{ fontSize: 48, color: '#52c41a' }} />
|
||||
)}
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
{item.type === 'project' ? item.project_name : item.file_name}
|
||||
</h3>
|
||||
<Tag color={item.type === 'project' ? 'blue' : 'green'}>
|
||||
{item.match_type}
|
||||
</Tag>
|
||||
</Space>
|
||||
{item.type === 'project' && (
|
||||
<p className="project-description">
|
||||
{item.project_description || '暂无描述'}
|
||||
</p>
|
||||
)}
|
||||
{item.type === 'file' && (
|
||||
<div className="project-search-file-meta">
|
||||
<div>项目: {item.project_name}</div>
|
||||
<div>路径: {item.file_path}</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索无结果提示 */}
|
||||
{hasSearched && !searching && searchResults.length === 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Empty description={`没有找到包含 "${searchKeyword}" 的项目或文件`} />
|
||||
</div>
|
||||
)}
|
||||
{!hasSearched && (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
{paginatedProjects.map((project) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
|
||||
<Card
|
||||
hoverable
|
||||
className="project-card"
|
||||
onClick={() => handleOpenProject(project.id)}
|
||||
actions={type === 'my' ? [
|
||||
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
|
||||
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
|
||||
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
||||
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||||
] : [
|
||||
<EyeOutlined key="view" />,
|
||||
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
||||
]}
|
||||
>
|
||||
{project.is_public === 1 && (
|
||||
<div className="project-card-public-badge">公开</div>
|
||||
)}
|
||||
<div className="project-card-icon">
|
||||
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||||
</div>
|
||||
<h3>{project.name}</h3>
|
||||
<p className="project-description">{project.description || '暂无描述'}</p>
|
||||
<div className="project-meta">
|
||||
<span>文档数: {project.doc_count || 0}</span>
|
||||
{type === 'share' && project.owner_name && (
|
||||
<span style={{ marginLeft: 12 }}>
|
||||
所有者: {project.owner_nickname || project.owner_name}
|
||||
</span>
|
||||
)}
|
||||
{type === 'share' && project.user_role && (
|
||||
<span style={{ marginLeft: 12 }}>
|
||||
角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
{filteredProjects.length === 0 && !loading && (
|
||||
<Col span={24}>
|
||||
<div className="project-empty-state">
|
||||
<Empty description={type === 'my' ? '还没有项目,创建一个开始吧' : '还没有参与的项目'} />
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
{filteredProjects.length > 0 && (
|
||||
<div className="project-list-pagination">
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredProjects.length}
|
||||
onChange={setCurrentPage}
|
||||
showSizeChanger={false}
|
||||
className="dot-pagination"
|
||||
itemRender={(page, type, originalElement) => {
|
||||
if (type === 'page') {
|
||||
return <span className="pagination-dot" />
|
||||
}
|
||||
return originalElement
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasSearched && !searching && searchResults.length === 0 && (
|
||||
<div className="project-empty-state">
|
||||
<Empty description={`没有找到包含 "${searchKeyword}" 的项目或文件`} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="创建新项目"
|
||||
|
|
@ -732,14 +607,21 @@ function ProjectList({ type = 'my' }) {
|
|||
handleDeleteProject(currentProject.id)
|
||||
}}
|
||||
>
|
||||
删除项目
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<SwapOutlined />}
|
||||
onClick={handleOpenTransfer}
|
||||
>
|
||||
转移所有权
|
||||
转移
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExportProject}
|
||||
loading={exportingProject}
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
|
|
@ -758,11 +640,47 @@ function ProjectList({ type = 'my' }) {
|
|||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="导出项目"
|
||||
open={exportModalVisible}
|
||||
onCancel={closeExportModal}
|
||||
footer={
|
||||
exportingProject
|
||||
? null
|
||||
: [
|
||||
<Button
|
||||
key="close"
|
||||
onClick={closeExportModal}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
closable={!exportingProject}
|
||||
maskClosable={!exportingProject}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<div>{exportTask?.message || '正在准备导出任务...'}</div>
|
||||
<Progress
|
||||
percent={Math.max(0, Math.min(exportTask?.progress || 0, 100))}
|
||||
status={exportTask?.status === 'failed' ? 'exception' : exportingProject ? 'active' : 'success'}
|
||||
/>
|
||||
<div style={{ color: '#666', fontSize: 13 }}>
|
||||
已处理 {exportTask?.processed_files || 0} / {exportTask?.total_files || 0} 个文件
|
||||
</div>
|
||||
{exportTask?.status === 'failed' && (
|
||||
<div style={{ color: '#ff4d4f', fontSize: 13 }}>
|
||||
{exportTask?.error || '项目导出失败'}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="转移项目所有权"
|
||||
open={transferModalVisible}
|
||||
onCancel={() => {
|
||||
setTransferModalVisible(false)
|
||||
closeTransferModal()
|
||||
transferForm.resetFields()
|
||||
}}
|
||||
onOk={() => transferForm.submit()}
|
||||
|
|
@ -800,7 +718,7 @@ function ProjectList({ type = 'my' }) {
|
|||
<Modal
|
||||
title="分享设置"
|
||||
open={shareModalVisible}
|
||||
onCancel={() => setShareModalVisible(false)}
|
||||
onCancel={closeShareModal}
|
||||
footer={null}
|
||||
width={500}
|
||||
>
|
||||
|
|
@ -818,7 +736,7 @@ function ProjectList({ type = 'my' }) {
|
|||
</div>
|
||||
|
||||
{/* 只有在我的项目中才显示密码设置功能 */}
|
||||
{type === 'my' && (
|
||||
{showPasswordControls && (
|
||||
<>
|
||||
<div>
|
||||
<Space>
|
||||
|
|
@ -860,10 +778,8 @@ function ProjectList({ type = 'my' }) {
|
|||
title="成员管理"
|
||||
open={membersModalVisible}
|
||||
onCancel={() => {
|
||||
setMembersModalVisible(false)
|
||||
closeMembersModal()
|
||||
memberForm.resetFields()
|
||||
setMembers([])
|
||||
setUsers([])
|
||||
}}
|
||||
footer={null}
|
||||
width={700}
|
||||
|
|
@ -995,10 +911,7 @@ function ProjectList({ type = 'my' }) {
|
|||
<Modal
|
||||
title="Git仓库管理"
|
||||
open={gitModalVisible}
|
||||
onCancel={() => {
|
||||
setGitModalVisible(false)
|
||||
setGitRepos([])
|
||||
}}
|
||||
onCancel={closeGitSettings}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
|
|
@ -1055,10 +968,7 @@ function ProjectList({ type = 'my' }) {
|
|||
<Modal
|
||||
title={editingRepo ? "编辑仓库" : "添加仓库"}
|
||||
open={gitRepoModalVisible}
|
||||
onCancel={() => {
|
||||
setGitRepoModalVisible(false)
|
||||
repoForm.resetFields()
|
||||
}}
|
||||
onCancel={() => closeGitRepoModal({ repoForm })}
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
|
|
@ -1116,7 +1026,7 @@ function ProjectList({ type = 'my' }) {
|
|||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setGitRepoModalVisible(false)}>取消</Button>
|
||||
<Button onClick={() => closeGitRepoModal({ repoForm })}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">保存</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { useState } from 'react'
|
||||
import { message, Modal } from 'antd'
|
||||
import { addProjectMember, getProjectMembers, removeProjectMember, transferProject } from '@/api/project'
|
||||
import { getUserList } from '@/api/users'
|
||||
|
||||
function useProjectCollaboration() {
|
||||
const [membersModalVisible, setMembersModalVisible] = useState(false)
|
||||
const [members, setMembers] = useState([])
|
||||
const [users, setUsers] = useState([])
|
||||
const [loadingMembers, setLoadingMembers] = useState(false)
|
||||
const [transferModalVisible, setTransferModalVisible] = useState(false)
|
||||
|
||||
const loadTransferCandidates = async (project) => {
|
||||
setLoadingMembers(true)
|
||||
setTransferModalVisible(true)
|
||||
try {
|
||||
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
|
||||
const allUsers = res.data || []
|
||||
setUsers(allUsers.filter((user) => user.id !== project.owner_id))
|
||||
} catch (error) {
|
||||
message.error('加载用户列表失败')
|
||||
} finally {
|
||||
setLoadingMembers(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitTransfer = async ({ projectId, newOwnerId, onCompleted }) => {
|
||||
Modal.confirm({
|
||||
title: '确认转移',
|
||||
content: '确定要将项目所有权转移给该用户吗?转移后您将变为管理员,无法再删除项目或转移所有权。',
|
||||
okText: '确认转移',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await transferProject(projectId, newOwnerId)
|
||||
message.success('项目所有权已转移')
|
||||
setTransferModalVisible(false)
|
||||
onCompleted?.()
|
||||
} catch (error) {
|
||||
console.error('Transfer error:', error)
|
||||
message.error(`转移失败: ${error.response?.data?.detail || error.message}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const closeTransferModal = () => {
|
||||
setTransferModalVisible(false)
|
||||
}
|
||||
|
||||
const openMembersModal = async (project) => {
|
||||
setMembersModalVisible(true)
|
||||
setLoadingMembers(true)
|
||||
|
||||
try {
|
||||
const [membersRes, usersRes] = await Promise.all([
|
||||
getProjectMembers(project.id),
|
||||
getUserList({ page: 1, page_size: 100, status: 1, role_id: 3 }),
|
||||
])
|
||||
|
||||
setMembers(membersRes.data || [])
|
||||
setUsers(Array.isArray(usersRes.data) ? usersRes.data : [])
|
||||
} catch (error) {
|
||||
console.error('Get members error:', error)
|
||||
console.error('Error details:', error.response)
|
||||
message.error(`获取数据失败: ${error.response?.data?.detail || error.message}`)
|
||||
} finally {
|
||||
setLoadingMembers(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeMembersModal = () => {
|
||||
setMembersModalVisible(false)
|
||||
setMembers([])
|
||||
setUsers([])
|
||||
}
|
||||
|
||||
const addMember = async (projectId, values) => {
|
||||
try {
|
||||
await addProjectMember(projectId, values)
|
||||
message.success('成员添加成功')
|
||||
const res = await getProjectMembers(projectId)
|
||||
setMembers(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Add member error:', error)
|
||||
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
const removeMember = async (projectId, userId) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个成员吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await removeProjectMember(projectId, userId)
|
||||
message.success('成员删除成功')
|
||||
const res = await getProjectMembers(projectId)
|
||||
setMembers(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Remove member error:', error)
|
||||
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
membersModalVisible,
|
||||
members,
|
||||
users,
|
||||
loadingMembers,
|
||||
transferModalVisible,
|
||||
loadTransferCandidates,
|
||||
submitTransfer,
|
||||
closeTransferModal,
|
||||
openMembersModal,
|
||||
closeMembersModal,
|
||||
addMember,
|
||||
removeMember,
|
||||
}
|
||||
}
|
||||
|
||||
export default useProjectCollaboration
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadProjectExport, getProjectExportStatus, startProjectExport } from '@/api/project'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import { extractFilenameFromDisposition, triggerBlobDownload } from '@/utils/browserIO'
|
||||
|
||||
function useProjectExport() {
|
||||
const [exportModalVisible, setExportModalVisible] = useState(false)
|
||||
const [exportingProject, setExportingProject] = useState(false)
|
||||
const [exportTask, setExportTask] = useState(null)
|
||||
const exportPollingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
exportPollingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetExportState = () => {
|
||||
setExportModalVisible(false)
|
||||
setExportTask(null)
|
||||
}
|
||||
|
||||
const closeExportModal = () => {
|
||||
if (exportingProject) {
|
||||
return
|
||||
}
|
||||
resetExportState()
|
||||
}
|
||||
|
||||
const extractBlobErrorMessage = async (error, fallback) => {
|
||||
const blob = error?.response?.data
|
||||
if (!(blob instanceof Blob)) {
|
||||
return error?.response?.data?.detail || error?.message || fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await blob.text()
|
||||
const data = JSON.parse(text)
|
||||
return data?.detail || data?.message || fallback
|
||||
} catch (parseError) {
|
||||
console.error('Parse export error blob failed:', parseError)
|
||||
return error?.message || fallback
|
||||
}
|
||||
}
|
||||
|
||||
const exportProject = async (project) => {
|
||||
if (!project?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setExportModalVisible(true)
|
||||
setExportingProject(true)
|
||||
setExportTask({
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
processed_files: 0,
|
||||
total_files: 0,
|
||||
message: '正在创建导出任务...',
|
||||
zip_filename: `${project.name}.zip`,
|
||||
})
|
||||
|
||||
const startRes = await startProjectExport(project.id)
|
||||
const task = startRes.data
|
||||
setExportTask(task)
|
||||
exportPollingRef.current = true
|
||||
|
||||
while (exportPollingRef.current) {
|
||||
const statusRes = await getProjectExportStatus(project.id, task.task_id)
|
||||
const nextTask = statusRes.data
|
||||
setExportTask(nextTask)
|
||||
|
||||
if (nextTask.status === 'completed') {
|
||||
const downloadRes = await downloadProjectExport(project.id, task.task_id)
|
||||
const contentDisposition = downloadRes.headers['content-disposition']
|
||||
const fallbackName = nextTask.zip_filename || `${project.name}.zip`
|
||||
const filename = extractFilenameFromDisposition(contentDisposition, fallbackName)
|
||||
|
||||
triggerBlobDownload(downloadRes.data, filename)
|
||||
Toast.success('导出成功', '项目压缩包已开始下载')
|
||||
break
|
||||
}
|
||||
|
||||
if (nextTask.status === 'failed') {
|
||||
throw new Error(nextTask.error || nextTask.message || '项目导出失败')
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
}
|
||||
|
||||
resetExportState()
|
||||
} catch (error) {
|
||||
console.error('Export project error:', error)
|
||||
const errorMessage = await extractBlobErrorMessage(error, '项目导出失败')
|
||||
setExportTask((prev) => ({
|
||||
...prev,
|
||||
status: 'failed',
|
||||
message: errorMessage,
|
||||
error: errorMessage,
|
||||
}))
|
||||
Toast.error('导出失败', errorMessage)
|
||||
} finally {
|
||||
exportPollingRef.current = false
|
||||
setExportingProject(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exportModalVisible,
|
||||
exportingProject,
|
||||
exportTask,
|
||||
closeExportModal,
|
||||
exportProject,
|
||||
}
|
||||
}
|
||||
|
||||
export default useProjectExport
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { useState } from 'react'
|
||||
import { message, Modal } from 'antd'
|
||||
import { createGitRepo, deleteGitRepo, getGitRepos, updateGitRepo } from '@/api/project'
|
||||
|
||||
function useProjectGitRepos() {
|
||||
const [gitRepos, setGitRepos] = useState([])
|
||||
const [loadingRepos, setLoadingRepos] = useState(false)
|
||||
const [gitModalVisible, setGitModalVisible] = useState(false)
|
||||
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
|
||||
const [editingRepo, setEditingRepo] = useState(null)
|
||||
|
||||
const fetchGitRepos = async (projectId) => {
|
||||
setLoadingRepos(true)
|
||||
try {
|
||||
const res = await getGitRepos(projectId)
|
||||
setGitRepos(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Fetch git repos error:', error)
|
||||
message.error('加载Git仓库失败')
|
||||
} finally {
|
||||
setLoadingRepos(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openGitSettings = async (projectId) => {
|
||||
setGitModalVisible(true)
|
||||
await fetchGitRepos(projectId)
|
||||
}
|
||||
|
||||
const closeGitSettings = () => {
|
||||
setGitModalVisible(false)
|
||||
setGitRepos([])
|
||||
}
|
||||
|
||||
const openAddRepoModal = ({ repoForm, gitRepos }) => {
|
||||
setEditingRepo(null)
|
||||
repoForm.resetFields()
|
||||
if (gitRepos.length === 0) {
|
||||
repoForm.setFieldsValue({ is_default: 1 })
|
||||
}
|
||||
setGitRepoModalVisible(true)
|
||||
}
|
||||
|
||||
const openEditRepoModal = ({ repo, repoForm }) => {
|
||||
setEditingRepo(repo)
|
||||
repoForm.setFieldsValue({
|
||||
...repo,
|
||||
is_default: repo.is_default === 1,
|
||||
})
|
||||
setGitRepoModalVisible(true)
|
||||
}
|
||||
|
||||
const closeGitRepoModal = ({ repoForm }) => {
|
||||
setGitRepoModalVisible(false)
|
||||
repoForm.resetFields()
|
||||
}
|
||||
|
||||
const deleteRepo = async ({ projectId, repoId }) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个Git仓库配置吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteGitRepo(projectId, repoId)
|
||||
message.success('删除成功')
|
||||
await fetchGitRepos(projectId)
|
||||
} catch (error) {
|
||||
console.error('Delete repo error:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const saveRepo = async ({ projectId, values }) => {
|
||||
try {
|
||||
const data = {
|
||||
...values,
|
||||
is_default: values.is_default ? 1 : 0,
|
||||
}
|
||||
|
||||
if (editingRepo) {
|
||||
await updateGitRepo(projectId, editingRepo.id, data)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createGitRepo(projectId, data)
|
||||
message.success('添加成功')
|
||||
}
|
||||
|
||||
await fetchGitRepos(projectId)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save repo error:', error)
|
||||
message.error(editingRepo ? '更新失败' : '添加失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gitRepos,
|
||||
loadingRepos,
|
||||
gitModalVisible,
|
||||
gitRepoModalVisible,
|
||||
editingRepo,
|
||||
fetchGitRepos,
|
||||
openGitSettings,
|
||||
closeGitSettings,
|
||||
openAddRepoModal,
|
||||
openEditRepoModal,
|
||||
closeGitRepoModal,
|
||||
deleteRepo,
|
||||
saveRepo,
|
||||
}
|
||||
}
|
||||
|
||||
export default useProjectGitRepos
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { useState } from 'react'
|
||||
import { message } from 'antd'
|
||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import { copyText } from '@/utils/browserIO'
|
||||
|
||||
function useProjectShare({ type }) {
|
||||
const [shareModalVisible, setShareModalVisible] = useState(false)
|
||||
const [shareInfo, setShareInfo] = useState(null)
|
||||
const [hasPassword, setHasPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const openShareModal = async (project) => {
|
||||
try {
|
||||
const res = await getProjectShareInfo(project.id)
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(res.data.has_password)
|
||||
setPassword('')
|
||||
setShareModalVisible(true)
|
||||
} catch (error) {
|
||||
console.error('Get share info error:', error)
|
||||
message.error('获取分享信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
const closeShareModal = () => {
|
||||
setShareModalVisible(false)
|
||||
}
|
||||
|
||||
const refreshShareInfo = async (projectId) => {
|
||||
const res = await getProjectShareInfo(projectId)
|
||||
setShareInfo(res.data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
const copyShareLink = async () => {
|
||||
if (!shareInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await copyText(`${window.location.origin}${shareInfo.share_url}`)
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
Toast.error('复制失败', '无法访问剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
const togglePassword = async (projectId, checked) => {
|
||||
if (!checked) {
|
||||
try {
|
||||
await updateShareSettings(projectId, { access_pass: null })
|
||||
setHasPassword(false)
|
||||
setPassword('')
|
||||
message.success('已取消访问密码')
|
||||
await refreshShareInfo(projectId)
|
||||
} catch (error) {
|
||||
console.error('Update settings error:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setHasPassword(true)
|
||||
}
|
||||
|
||||
const savePassword = async (projectId) => {
|
||||
if (!password.trim()) {
|
||||
message.warning('请输入访问密码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateShareSettings(projectId, { access_pass: password })
|
||||
message.success('访问密码已设置')
|
||||
await refreshShareInfo(projectId)
|
||||
setHasPassword(true)
|
||||
} catch (error) {
|
||||
console.error('Save password error:', error)
|
||||
message.error('设置密码失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shareModalVisible,
|
||||
shareInfo,
|
||||
hasPassword,
|
||||
password,
|
||||
setPassword,
|
||||
openShareModal,
|
||||
closeShareModal,
|
||||
copyShareLink,
|
||||
togglePassword,
|
||||
savePassword,
|
||||
showPasswordControls: type === 'my',
|
||||
}
|
||||
}
|
||||
|
||||
export default useProjectShare
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
.admin-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-stats.admin-stats-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.admin-card .ant-card-head {
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-toolbar-left,
|
||||
.admin-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-hint {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color-secondary);
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.admin-hint strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.admin-inline-code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-detail-cell {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.admin-detail-cell > div {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-modal .ant-modal-content {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-modal .ant-modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.admin-stats.admin-stats-4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.admin-stats,
|
||||
.admin-stats.admin-stats-4,
|
||||
.admin-grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
.model-config-card .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.model-config-table :global(.ant-table-cell) {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.model-config-table.list-table-container {
|
||||
height: auto;
|
||||
min-height: 626px;
|
||||
}
|
||||
|
||||
.model-config-actions {
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-config-actions :global(.ant-btn) {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.model-config-code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.model-config-modal-hint {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: linear-gradient(180deg, rgba(22, 119, 255, 0.06) 0%, rgba(22, 119, 255, 0.02) 100%);
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.model-config-modal-hint strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.model-config-test-result {
|
||||
line-height: 1.8;
|
||||
color: var(--text-color);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.model-config-modal .ant-modal-content {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.model-config-modal .ant-modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,698 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'antd'
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
ExperimentOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
createLLMModelConfig,
|
||||
deleteLLMModelConfig,
|
||||
getLLMModelConfigDetail,
|
||||
getLLMModelConfigs,
|
||||
getLLMProviderCatalog,
|
||||
testLLMModelConfig,
|
||||
updateLLMModelConfig,
|
||||
updateLLMModelConfigStatus,
|
||||
} from '@/api/llmModelConfigs'
|
||||
import ListTable from '@/components/ListTable/ListTable'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import './ModelConfigs.css'
|
||||
import '@/pages/System/AdminPages.css'
|
||||
|
||||
const { Search, TextArea } = Input
|
||||
|
||||
function buildModelCode(provider, llmModelName) {
|
||||
const providerPart = (provider || 'custom').trim().toLowerCase()
|
||||
const modelPart = (llmModelName || '').trim().toLowerCase()
|
||||
const sanitized = []
|
||||
let previousSeparator = false
|
||||
|
||||
for (const char of modelPart) {
|
||||
if (/[a-z0-9]/.test(char)) {
|
||||
sanitized.push(char)
|
||||
previousSeparator = false
|
||||
} else if (!previousSeparator) {
|
||||
sanitized.push('_')
|
||||
previousSeparator = true
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = sanitized.join('').replace(/^_+|_+$/g, '') || 'model'
|
||||
return `llm_${providerPart}_${suffix}`
|
||||
}
|
||||
|
||||
function buildModelName(providerMeta, provider, llmModelName) {
|
||||
const label = providerMeta?.label || provider || '自定义模型'
|
||||
const modelPart = (llmModelName || '').trim()
|
||||
if (!modelPart) {
|
||||
return label
|
||||
}
|
||||
return `${label} ${modelPart}`
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function ModelConfigs() {
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [providerFilter, setProviderFilter] = useState(undefined)
|
||||
const [statusFilter, setStatusFilter] = useState(undefined)
|
||||
const [configs, setConfigs] = useState([])
|
||||
const [providerCatalog, setProviderCatalog] = useState([])
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingConfigId, setEditingConfigId] = useState(null)
|
||||
const [autoFillFlags, setAutoFillFlags] = useState({
|
||||
endpointUrl: true,
|
||||
modelName: true,
|
||||
modelCode: true,
|
||||
})
|
||||
|
||||
const providerValue = Form.useWatch('provider', form)
|
||||
const llmModelNameValue = Form.useWatch('llm_model_name', form)
|
||||
|
||||
useEffect(() => {
|
||||
loadProviderCatalog()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs()
|
||||
}, [page, pageSize, keyword, providerFilter, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalVisible || !providerValue) {
|
||||
return
|
||||
}
|
||||
|
||||
const providerMeta = providerCatalog.find((item) => item.value === providerValue)
|
||||
if (!providerMeta) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentValues = form.getFieldsValue()
|
||||
const nextValues = {}
|
||||
|
||||
if (autoFillFlags.endpointUrl) {
|
||||
const nextEndpointUrl = providerMeta.default_endpoint_url || ''
|
||||
if (nextEndpointUrl !== currentValues.endpoint_url) {
|
||||
nextValues.endpoint_url = nextEndpointUrl
|
||||
}
|
||||
}
|
||||
|
||||
if (llmModelNameValue) {
|
||||
if (autoFillFlags.modelName) {
|
||||
const nextModelName = buildModelName(providerMeta, providerValue, llmModelNameValue)
|
||||
if (nextModelName !== currentValues.model_name) {
|
||||
nextValues.model_name = nextModelName
|
||||
}
|
||||
}
|
||||
|
||||
if (autoFillFlags.modelCode) {
|
||||
const nextModelCode = buildModelCode(providerValue, llmModelNameValue)
|
||||
if (nextModelCode !== currentValues.model_code) {
|
||||
nextValues.model_code = nextModelCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(nextValues).length > 0) {
|
||||
form.setFieldsValue(nextValues)
|
||||
}
|
||||
}, [modalVisible, providerValue, llmModelNameValue, providerCatalog, autoFillFlags, form])
|
||||
|
||||
const loadProviderCatalog = async () => {
|
||||
try {
|
||||
const res = await getLLMProviderCatalog()
|
||||
setProviderCatalog(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Load LLM provider catalog error:', error)
|
||||
Toast.error('加载提供方列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (keyword) params.keyword = keyword
|
||||
if (providerFilter) params.provider = providerFilter
|
||||
if (statusFilter !== undefined) params.is_active = statusFilter
|
||||
|
||||
const res = await getLLMModelConfigs(params)
|
||||
setConfigs(res.data || [])
|
||||
setTotal(res.total || 0)
|
||||
} catch (error) {
|
||||
console.error('Load llm model configs error:', error)
|
||||
Toast.error('加载模型配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getProviderMeta = (provider) => providerCatalog.find((item) => item.value === provider)
|
||||
|
||||
const openCreateModal = () => {
|
||||
const defaultProvider = providerCatalog[0]?.value || 'openai'
|
||||
const defaultEndpointUrl = getProviderMeta(defaultProvider)?.default_endpoint_url || ''
|
||||
setEditingConfigId(null)
|
||||
setAutoFillFlags({
|
||||
endpointUrl: true,
|
||||
modelName: true,
|
||||
modelCode: true,
|
||||
})
|
||||
form.setFieldsValue({
|
||||
provider: defaultProvider,
|
||||
endpoint_url: defaultEndpointUrl,
|
||||
llm_timeout: 120,
|
||||
llm_temperature: 0.7,
|
||||
llm_top_p: 0.9,
|
||||
llm_max_tokens: 2048,
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
description: '',
|
||||
llm_system_prompt: '',
|
||||
model_name: '',
|
||||
model_code: '',
|
||||
llm_model_name: '',
|
||||
api_key: '',
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const openEditModal = async (record) => {
|
||||
try {
|
||||
const res = await getLLMModelConfigDetail(record.config_id)
|
||||
const detail = res.data
|
||||
const providerMeta = getProviderMeta(detail.provider)
|
||||
|
||||
setEditingConfigId(record.config_id)
|
||||
setAutoFillFlags({
|
||||
endpointUrl: !detail.endpoint_url || detail.endpoint_url === (providerMeta?.default_endpoint_url || ''),
|
||||
modelName: !detail.model_name || detail.model_name === buildModelName(providerMeta, detail.provider, detail.llm_model_name),
|
||||
modelCode: !detail.model_code || detail.model_code === buildModelCode(detail.provider, detail.llm_model_name),
|
||||
})
|
||||
form.setFieldsValue({
|
||||
...detail,
|
||||
api_key: detail.api_key || '',
|
||||
})
|
||||
setModalVisible(true)
|
||||
} catch (error) {
|
||||
console.error('Load llm model config detail error:', error)
|
||||
Toast.error('加载模型配置详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setModalVisible(false)
|
||||
setEditingConfigId(null)
|
||||
form.resetFields()
|
||||
}
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
if (editingConfigId) {
|
||||
await updateLLMModelConfig(editingConfigId, values)
|
||||
Toast.success('模型配置更新成功')
|
||||
} else {
|
||||
await createLLMModelConfig(values)
|
||||
Toast.success('模型配置创建成功')
|
||||
}
|
||||
closeModal()
|
||||
loadConfigs()
|
||||
} catch (error) {
|
||||
Toast.error(error.response?.data?.detail || '保存模型配置失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (record) => {
|
||||
try {
|
||||
await deleteLLMModelConfig(record.config_id)
|
||||
Toast.success('模型配置删除成功')
|
||||
loadConfigs()
|
||||
} catch (error) {
|
||||
Toast.error(error.response?.data?.detail || '删除模型配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (record, checked) => {
|
||||
try {
|
||||
await updateLLMModelConfigStatus(record.config_id, checked)
|
||||
Toast.success(checked ? '模型已启用' : '模型已停用')
|
||||
loadConfigs()
|
||||
} catch (error) {
|
||||
Toast.error(error.response?.data?.detail || '更新状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
const showTestResult = (result) => {
|
||||
Modal.info({
|
||||
title: '模型测试成功',
|
||||
width: 620,
|
||||
content: (
|
||||
<div className="model-config-test-result">
|
||||
<div>提供方:{getProviderMeta(result.provider)?.label || result.provider}</div>
|
||||
<div>模型:{result.llm_model_name}</div>
|
||||
<div>延迟:{result.latency_ms} ms</div>
|
||||
<div>返回预览:{result.preview || '模型已成功返回,但未提取到文本内容。'}</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const handleFormTest = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setTesting(true)
|
||||
const res = await testLLMModelConfig(values)
|
||||
showTestResult(res.data)
|
||||
} catch (error) {
|
||||
if (error?.errorFields) {
|
||||
return
|
||||
}
|
||||
Toast.error(error.response?.data?.detail || '模型测试失败')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '模型名称',
|
||||
dataIndex: 'model_name',
|
||||
key: 'model_name',
|
||||
width: 240,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{record.model_name}</span>
|
||||
<span className="model-config-code">{record.model_code}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提供方',
|
||||
dataIndex: 'provider',
|
||||
key: 'provider',
|
||||
width: 140,
|
||||
render: (provider) => (
|
||||
<Tag color="blue">{getProviderMeta(provider)?.label || provider || '-'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '模型标识',
|
||||
dataIndex: 'llm_model_name',
|
||||
key: 'llm_model_name',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: 'Base URL',
|
||||
dataIndex: 'endpoint_url',
|
||||
key: 'endpoint_url',
|
||||
ellipsis: true,
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
key: 'is_active',
|
||||
width: 100,
|
||||
render: (value, record) => (
|
||||
<Switch
|
||||
checked={value}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
onChange={(checked) => handleStatusChange(record, checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '默认',
|
||||
dataIndex: 'is_default',
|
||||
key: 'is_default',
|
||||
width: 90,
|
||||
render: (value) => (
|
||||
value ? <Tag color="gold">默认</Tag> : <Tag>否</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 180,
|
||||
render: (value) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 170,
|
||||
render: (_, record) => (
|
||||
<Space size="small" className="model-config-actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditModal(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该模型配置?"
|
||||
description="删除后将无法恢复。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<PageHeader
|
||||
title="模型配置"
|
||||
description="管理系统可用的大模型提供方、默认接口地址、参数模板与连通性测试。"
|
||||
icon={<CloudServerOutlined />}
|
||||
extra={(
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
新增模型
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Card className="admin-card model-config-card">
|
||||
<div className="admin-toolbar">
|
||||
<div className="admin-toolbar-left">
|
||||
<Search
|
||||
allowClear
|
||||
placeholder="搜索模型名称、编码或模型标识"
|
||||
value={keyword}
|
||||
style={{ width: 320 }}
|
||||
onChange={(event) => {
|
||||
setPage(1)
|
||||
setKeyword(event.target.value)
|
||||
}}
|
||||
onSearch={(value) => {
|
||||
setPage(1)
|
||||
setKeyword(value)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="筛选提供方"
|
||||
style={{ width: 180 }}
|
||||
value={providerFilter}
|
||||
options={providerCatalog.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setPage(1)
|
||||
setProviderFilter(value)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="筛选状态"
|
||||
style={{ width: 140 }}
|
||||
value={statusFilter}
|
||||
options={[
|
||||
{ label: '启用', value: true },
|
||||
{ label: '停用', value: false },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setPage(1)
|
||||
setStatusFilter(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-toolbar-right">
|
||||
<Button icon={<ReloadOutlined />} onClick={loadConfigs}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListTable
|
||||
rowKey="config_id"
|
||||
className="model-config-table"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={configs}
|
||||
scroll={{ x: 1280 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingConfigId ? '编辑模型配置' : '新增模型配置'}
|
||||
open={modalVisible}
|
||||
width={860}
|
||||
className="model-config-modal"
|
||||
style={{ maxWidth: 'calc(100vw - 32px)' }}
|
||||
destroyOnClose
|
||||
onCancel={closeModal}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
okText={editingConfigId ? '保存修改' : '创建'}
|
||||
cancelText="取消"
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'calc(80vh - 180px)',
|
||||
overflowY: 'auto',
|
||||
paddingRight: 8,
|
||||
},
|
||||
}}
|
||||
footer={(_, { OkBtn, CancelBtn }) => (
|
||||
<>
|
||||
<CancelBtn />
|
||||
<Button
|
||||
icon={<ExperimentOutlined />}
|
||||
loading={testing}
|
||||
onClick={handleFormTest}
|
||||
>
|
||||
测试模型
|
||||
</Button>
|
||||
<OkBtn />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="model-config-modal-hint">
|
||||
<strong>自动生成规则:</strong> 选择提供方后会自动带出默认 `base_url`;填写模型标识后会自动生成“模型名称”和“模型编码”。
|
||||
如果你手动改过这些字段,后续就不会再被自动覆盖。
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{
|
||||
llm_timeout: 120,
|
||||
llm_temperature: 0.7,
|
||||
llm_top_p: 0.9,
|
||||
llm_max_tokens: 2048,
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||
<Form.Item
|
||||
label="提供方"
|
||||
name="provider"
|
||||
rules={[{ required: true, message: '请选择模型提供方' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="请选择模型提供方"
|
||||
optionFilterProp="label"
|
||||
options={providerCatalog.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Base URL"
|
||||
name="endpoint_url"
|
||||
rules={[{ required: true, message: '请输入 base_url' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="选择提供方后自动带出,也可以手动覆盖"
|
||||
onChange={() => {
|
||||
setAutoFillFlags((current) => ({ ...current, endpointUrl: false }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space style={{ width: '100%' }} size={16} align="start">
|
||||
<Form.Item
|
||||
label="模型标识"
|
||||
name="llm_model_name"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入模型标识或部署名' }]}
|
||||
>
|
||||
<Input placeholder="如 gpt-4.1-mini / qwen3.6-plus / claude-3-5-sonnet-latest" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="请求超时(秒)"
|
||||
name="llm_timeout"
|
||||
style={{ width: 180 }}
|
||||
rules={[{ required: true, message: '请输入超时时间' }]}
|
||||
>
|
||||
<InputNumber min={5} max={600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space style={{ width: '100%' }} size={16} align="start">
|
||||
<Form.Item
|
||||
label="模型名称"
|
||||
name="model_name"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入模型名称' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="会根据提供方和模型标识自动生成"
|
||||
onChange={() => {
|
||||
setAutoFillFlags((current) => ({ ...current, modelName: false }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="模型编码"
|
||||
name="model_code"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入模型编码' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="会自动生成,支持手动调整"
|
||||
onChange={() => {
|
||||
setAutoFillFlags((current) => ({ ...current, modelCode: false }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item label="API Key" name="api_key">
|
||||
<Input.Password
|
||||
placeholder="支持留空后稍后补齐;测试非 Ollama 模型时建议填写"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ width: '100%' }} size={16} align="start">
|
||||
<Form.Item
|
||||
label="Temperature"
|
||||
name="llm_temperature"
|
||||
style={{ width: '33%' }}
|
||||
rules={[{ required: true, message: '请输入 temperature' }]}
|
||||
>
|
||||
<InputNumber min={0} max={2} step={0.05} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Top P"
|
||||
name="llm_top_p"
|
||||
style={{ width: '33%' }}
|
||||
rules={[{ required: true, message: '请输入 top_p' }]}
|
||||
>
|
||||
<InputNumber min={0} max={1} step={0.05} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Max Tokens"
|
||||
name="llm_max_tokens"
|
||||
style={{ width: '33%' }}
|
||||
rules={[{ required: true, message: '请输入 max_tokens' }]}
|
||||
>
|
||||
<InputNumber min={1} max={32768} step={128} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item label="系统提示词" name="llm_system_prompt">
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="可选。测试模型时会作为 system prompt 一并发送。"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea rows={2} placeholder="可填写用途、场景、适用业务等说明" />
|
||||
</Form.Item>
|
||||
|
||||
<Space size={24}>
|
||||
<Form.Item label="启用状态" name="is_active" valuePropName="checked">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="设为默认" name="is_default" valuePropName="checked">
|
||||
<Switch checkedChildren="默认" unCheckedChildren="普通" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelConfigs
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Tree, Button, Card, Row, Col, Tag, Space, message } from 'antd'
|
||||
import { SafetyOutlined, SaveOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
||||
import { Tree, Button, Card, Tag, Space } from 'antd'
|
||||
import { SafetyOutlined, SaveOutlined, CheckCircleOutlined, CloseCircleOutlined, AppstoreOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
getAllRoles,
|
||||
getMenuTree,
|
||||
|
|
@ -8,7 +8,10 @@ import {
|
|||
updateRolePermissions,
|
||||
} from '@/api/rolePermissions'
|
||||
import ListTable from '@/components/ListTable/ListTable'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader'
|
||||
import StatCard from '@/components/StatCard/StatCard'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import '@/pages/System/AdminPages.css'
|
||||
|
||||
function Permissions() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -151,85 +154,84 @@ function Permissions() {
|
|||
},
|
||||
]
|
||||
|
||||
const editableRoles = roles.filter((role) => role.is_system !== 1).length
|
||||
const flattenTreeCount = (nodes) => nodes.reduce((count, node) => {
|
||||
const childrenCount = node.children ? flattenTreeCount(node.children) : 0
|
||||
return count + 1 + childrenCount
|
||||
}, 0)
|
||||
const menuCount = flattenTreeCount(menuTree)
|
||||
|
||||
return (
|
||||
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600, color: 'var(--text-color)' }}>
|
||||
<SafetyOutlined style={{ marginRight: '8px' }} />
|
||||
角色权限管理
|
||||
</h1>
|
||||
<div className="admin-page">
|
||||
<PageHeader
|
||||
title="权限管理"
|
||||
description="为角色配置菜单和功能权限,系统角色默认只读。"
|
||||
icon={<SafetyOutlined />}
|
||||
/>
|
||||
|
||||
<Row gutter={16}>
|
||||
{/* 左侧:功能权限树 */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title="功能权限树"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!selectedRole}
|
||||
>
|
||||
保存权限
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{selectedRole ? (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-color-secondary)', borderRadius: 4 }}>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>当前角色:</span>
|
||||
<Tag color="blue">{selectedRole.role_name}</Tag>
|
||||
{selectedRole.is_system === 1 && <Tag color="orange">系统角色(只读)</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '12px',
|
||||
background: 'var(--bg-color-secondary)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--text-color-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
}}
|
||||
>
|
||||
请从右侧选择一个角色来查看和编辑权限
|
||||
</div>
|
||||
)}
|
||||
<Tree
|
||||
checkable
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={setExpandedKeys}
|
||||
checkedKeys={checkedKeys}
|
||||
onCheck={handleCheck}
|
||||
treeData={menuTree}
|
||||
disabled={!selectedRole || selectedRole.is_system === 1}
|
||||
style={{ minHeight: 400 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 右侧:角色列表 */}
|
||||
<Col span={12}>
|
||||
<Card title="角色列表">
|
||||
<ListTable
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ y: 600 }}
|
||||
onRowClick={handleRoleClick}
|
||||
selectedRow={selectedRole}
|
||||
onSelectionChange={() => {}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="admin-stats">
|
||||
<StatCard title="角色总数" value={roles.length} icon={<SafetyOutlined />} color="blue" />
|
||||
<StatCard title="可编辑角色" value={editableRoles} icon={<CheckCircleOutlined />} color="green" />
|
||||
<StatCard title="权限节点数" value={menuCount} icon={<AppstoreOutlined />} color="orange" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="admin-grid-2">
|
||||
<Card
|
||||
title="功能权限树"
|
||||
className="admin-card"
|
||||
extra={(
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!selectedRole}
|
||||
>
|
||||
保存权限
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{selectedRole ? (
|
||||
<div className="admin-hint">
|
||||
<Space>
|
||||
<span><strong>当前角色:</strong></span>
|
||||
<Tag color="blue">{selectedRole.role_name}</Tag>
|
||||
{selectedRole.is_system === 1 && <Tag color="orange">系统角色(只读)</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-hint">
|
||||
请先从右侧角色列表中选择一个角色,再查看或编辑权限。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tree
|
||||
checkable
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={setExpandedKeys}
|
||||
checkedKeys={checkedKeys}
|
||||
onCheck={handleCheck}
|
||||
treeData={menuTree}
|
||||
disabled={!selectedRole || selectedRole.is_system === 1}
|
||||
style={{ minHeight: 480 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="角色列表" className="admin-card">
|
||||
<ListTable
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ y: 600 }}
|
||||
onRowClick={handleRoleClick}
|
||||
selectedRow={selectedRole}
|
||||
onSelectionChange={() => {}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import {
|
|||
Space,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined,
|
||||
|
|
@ -19,7 +17,8 @@ import {
|
|||
TeamOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
SearchOutlined,
|
||||
SafetyOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
getRoleList,
|
||||
|
|
@ -29,7 +28,12 @@ import {
|
|||
getRoleUsers,
|
||||
} from '@/api/roles'
|
||||
import ListTable from '@/components/ListTable/ListTable'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader'
|
||||
import StatCard from '@/components/StatCard/StatCard'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import '@/pages/System/AdminPages.css'
|
||||
|
||||
const { Search } = Input
|
||||
|
||||
function Roles() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -312,70 +316,94 @@ function Roles() {
|
|||
},
|
||||
]
|
||||
|
||||
const enabledRoles = roles.filter((role) => role.status === 1).length
|
||||
const systemRoles = roles.filter((role) => role.is_system === 1).length
|
||||
|
||||
return (
|
||||
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600, color: 'var(--text-color)' }}>角色管理</h1>
|
||||
<div className="admin-page">
|
||||
<PageHeader
|
||||
title="角色管理"
|
||||
description="维护系统角色、角色编码与用户归属关系。"
|
||||
icon={<TeamOutlined />}
|
||||
extra={(
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
|
||||
新增角色
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 搜索和操作栏 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Input
|
||||
placeholder="搜索角色名称、编码"
|
||||
prefix={<SearchOutlined />}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
style={{ width: '100%' }}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
>
|
||||
<Select.Option value={1}>启用</Select.Option>
|
||||
<Select.Option value={0}>禁用</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={10} style={{ textAlign: 'right' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
|
||||
新增角色
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
<div className="admin-stats">
|
||||
<StatCard title="角色总数" value={total} icon={<SafetyOutlined />} color="blue" />
|
||||
<StatCard title="当前页启用" value={enabledRoles} icon={<CheckCircleOutlined />} color="green" />
|
||||
<StatCard title="系统角色数" value={systemRoles} icon={<TeamOutlined />} color="orange" />
|
||||
</div>
|
||||
|
||||
{/* 角色列表 */}
|
||||
<Card>
|
||||
<ListTable
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
onSelectionChange={() => {}}
|
||||
/>
|
||||
</Card>
|
||||
<Card className="admin-card">
|
||||
<div className="admin-toolbar">
|
||||
<div className="admin-toolbar-left">
|
||||
<Search
|
||||
allowClear
|
||||
placeholder="搜索角色名称、编码"
|
||||
value={keyword}
|
||||
style={{ width: 320 }}
|
||||
onChange={(event) => {
|
||||
setPage(1)
|
||||
setKeyword(event.target.value)
|
||||
}}
|
||||
onSearch={(value) => {
|
||||
setPage(1)
|
||||
setKeyword(value)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="筛选状态"
|
||||
style={{ width: 140 }}
|
||||
value={statusFilter}
|
||||
onChange={(value) => {
|
||||
setPage(1)
|
||||
setStatusFilter(value ?? null)
|
||||
}}
|
||||
options={[
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-toolbar-right">
|
||||
<Button icon={<ReloadOutlined />} onClick={loadRoles}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListTable
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
onSelectionChange={() => {}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 创建角色对话框 */}
|
||||
<Modal
|
||||
title="新增角色"
|
||||
open={createModalVisible}
|
||||
className="admin-modal"
|
||||
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
|
||||
onCancel={() => {
|
||||
setCreateModalVisible(false)
|
||||
createForm.resetFields()
|
||||
|
|
@ -416,6 +444,8 @@ function Roles() {
|
|||
<Modal
|
||||
title="编辑角色"
|
||||
open={editModalVisible}
|
||||
className="admin-modal"
|
||||
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false)
|
||||
editForm.resetFields()
|
||||
|
|
@ -456,6 +486,7 @@ function Roles() {
|
|||
<Modal
|
||||
title={`角色用户列表 - ${currentRole?.role_name || ''}`}
|
||||
open={usersModalVisible}
|
||||
className="admin-modal"
|
||||
onCancel={() => {
|
||||
setUsersModalVisible(false)
|
||||
setRoleUsers([])
|
||||
|
|
@ -463,6 +494,7 @@ function Roles() {
|
|||
}}
|
||||
footer={null}
|
||||
width={1000}
|
||||
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
|
||||
>
|
||||
<ListTable
|
||||
columns={userColumns}
|
||||
|
|
@ -483,8 +515,7 @@ function Roles() {
|
|||
onSelectionChange={() => {}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import {
|
|||
Popconfirm,
|
||||
Switch,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined,
|
||||
|
|
@ -19,9 +17,10 @@ import {
|
|||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
TeamOutlined,
|
||||
SearchOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
UserOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
getUserList,
|
||||
|
|
@ -34,7 +33,12 @@ import {
|
|||
} from '@/api/users'
|
||||
import { getAllRoles } from '@/api/rolePermissions'
|
||||
import ListTable from '@/components/ListTable/ListTable'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader'
|
||||
import StatCard from '@/components/StatCard/StatCard'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import '@/pages/System/AdminPages.css'
|
||||
|
||||
const { Search } = Input
|
||||
|
||||
function Users() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -57,9 +61,12 @@ function Users() {
|
|||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
loadRoles()
|
||||
}, [page, pageSize, keyword, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles()
|
||||
}, [])
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
|
@ -297,70 +304,93 @@ function Users() {
|
|||
},
|
||||
]
|
||||
|
||||
const activeUsers = users.filter((user) => user.status === 1).length
|
||||
|
||||
return (
|
||||
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600, color: 'var(--text-color)' }}>用户管理</h1>
|
||||
<div className="admin-page">
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
description="统一管理系统用户、账号状态、角色分配与密码重置。"
|
||||
icon={<UserOutlined />}
|
||||
extra={(
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
|
||||
新增用户
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 搜索和操作栏 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Input
|
||||
placeholder="搜索用户名、昵称、邮箱"
|
||||
prefix={<SearchOutlined />}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
style={{ width: '100%' }}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
>
|
||||
<Select.Option value={1}>启用</Select.Option>
|
||||
<Select.Option value={0}>停用</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={10} style={{ textAlign: 'right' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
|
||||
新增用户
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
<div className="admin-stats">
|
||||
<StatCard title="用户总数" value={total} icon={<UserOutlined />} color="blue" />
|
||||
<StatCard title="当前页启用" value={activeUsers} icon={<CheckCircleOutlined />} color="green" />
|
||||
<StatCard title="可分配角色数" value={roles.length} icon={<TeamOutlined />} color="orange" />
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<Card>
|
||||
<ListTable
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
onSelectionChange={() => {}}
|
||||
/>
|
||||
</Card>
|
||||
<Card className="admin-card">
|
||||
<div className="admin-toolbar">
|
||||
<div className="admin-toolbar-left">
|
||||
<Search
|
||||
allowClear
|
||||
placeholder="搜索用户名、昵称、邮箱"
|
||||
value={keyword}
|
||||
style={{ width: 320 }}
|
||||
onChange={(event) => {
|
||||
setPage(1)
|
||||
setKeyword(event.target.value)
|
||||
}}
|
||||
onSearch={(value) => {
|
||||
setPage(1)
|
||||
setKeyword(value)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="筛选状态"
|
||||
style={{ width: 140 }}
|
||||
value={statusFilter}
|
||||
onChange={(value) => {
|
||||
setPage(1)
|
||||
setStatusFilter(value ?? null)
|
||||
}}
|
||||
options={[
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-toolbar-right">
|
||||
<Button icon={<ReloadOutlined />} onClick={loadUsers}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListTable
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
onSelectionChange={() => {}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 创建用户对话框 */}
|
||||
<Modal
|
||||
title="新增用户"
|
||||
open={createModalVisible}
|
||||
className="admin-modal"
|
||||
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
|
||||
onCancel={() => {
|
||||
setCreateModalVisible(false)
|
||||
createForm.resetFields()
|
||||
|
|
@ -412,6 +442,8 @@ function Users() {
|
|||
<Modal
|
||||
title="编辑用户"
|
||||
open={editModalVisible}
|
||||
className="admin-modal"
|
||||
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false)
|
||||
editForm.resetFields()
|
||||
|
|
@ -439,6 +471,8 @@ function Users() {
|
|||
<Modal
|
||||
title="分配角色"
|
||||
open={rolesModalVisible}
|
||||
className="admin-modal"
|
||||
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
|
||||
onCancel={() => {
|
||||
setRolesModalVisible(false)
|
||||
rolesForm.resetFields()
|
||||
|
|
@ -457,8 +491,7 @@ function Users() {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
.system-logs-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.system-logs-container h2 {
|
||||
margin-bottom: 24px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Table, Card, Select, DatePicker, Space, Button, Tag, Statistic, Row, Col, Input, message } from 'antd'
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { Table, Card, Select, DatePicker, Space, Button, Tag, Input, message } from 'antd'
|
||||
import { ReloadOutlined, SearchOutlined, FileSearchOutlined, CheckCircleOutlined, FolderOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||
import { getOperationLogs, getLogStats } from '@/api/logs'
|
||||
import { getUserList } from '@/api/users'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader'
|
||||
import StatCard from '@/components/StatCard/StatCard'
|
||||
import dayjs from 'dayjs'
|
||||
import './SystemLogs.css'
|
||||
import '@/pages/System/AdminPages.css'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const { Option } = Select
|
||||
|
|
@ -88,7 +91,7 @@ function SystemLogs() {
|
|||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
const fetchLogs = async (page = 1, pageSize = 20) => {
|
||||
const fetchLogs = async (page = 1, pageSize = 20, nextFilters = filters) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = {
|
||||
|
|
@ -97,23 +100,23 @@ function SystemLogs() {
|
|||
}
|
||||
|
||||
// 只添加有值的过滤条件
|
||||
if (filters.operation_type) {
|
||||
params.operation_type = filters.operation_type
|
||||
if (nextFilters.operation_type) {
|
||||
params.operation_type = nextFilters.operation_type
|
||||
}
|
||||
if (filters.resource_type) {
|
||||
params.resource_type = filters.resource_type
|
||||
if (nextFilters.resource_type) {
|
||||
params.resource_type = nextFilters.resource_type
|
||||
}
|
||||
if (filters.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
if (nextFilters.user_id) {
|
||||
params.user_id = nextFilters.user_id
|
||||
}
|
||||
if (filters.project_id) {
|
||||
params.project_id = filters.project_id
|
||||
if (nextFilters.project_id) {
|
||||
params.project_id = nextFilters.project_id
|
||||
}
|
||||
|
||||
// 处理日期范围
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.start_date = filters.dateRange[0].format('YYYY-MM-DD')
|
||||
params.end_date = filters.dateRange[1].format('YYYY-MM-DD')
|
||||
if (nextFilters.dateRange && nextFilters.dateRange.length === 2) {
|
||||
params.start_date = nextFilters.dateRange[0].format('YYYY-MM-DD')
|
||||
params.end_date = nextFilters.dateRange[1].format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const res = await getOperationLogs(params)
|
||||
|
|
@ -143,10 +146,8 @@ function SystemLogs() {
|
|||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
|
||||
console.log('Fetch users response:', res)
|
||||
// 后端返回格式: { code: 200, message: "success", data: [...], total, page, page_size }
|
||||
const usersData = Array.isArray(res.data) ? res.data : []
|
||||
console.log('Users data:', usersData)
|
||||
setUsers(usersData)
|
||||
} catch (error) {
|
||||
console.error('Fetch users error:', error)
|
||||
|
|
@ -165,20 +166,19 @@ function SystemLogs() {
|
|||
delete cleanFilters.project_id
|
||||
}
|
||||
setFilters(cleanFilters)
|
||||
fetchLogs(1, pagination.pageSize)
|
||||
fetchLogs(1, pagination.pageSize, cleanFilters)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({
|
||||
const resetFilters = {
|
||||
operation_type: undefined,
|
||||
resource_type: undefined,
|
||||
user_id: undefined,
|
||||
project_id: undefined,
|
||||
dateRange: null,
|
||||
})
|
||||
setTimeout(() => {
|
||||
fetchLogs(1, pagination.pageSize)
|
||||
}, 0)
|
||||
}
|
||||
setFilters(resetFilters)
|
||||
fetchLogs(1, pagination.pageSize, resetFilters)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
|
|
@ -228,9 +228,9 @@ function SystemLogs() {
|
|||
try {
|
||||
const parsed = JSON.parse(detail)
|
||||
return (
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
<div className="admin-detail-cell">
|
||||
{Object.entries(parsed).map(([key, value]) => (
|
||||
<div key={key} style={{ fontSize: 12 }}>
|
||||
<div key={key}>
|
||||
<strong>{key}:</strong> {JSON.stringify(value)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -267,45 +267,38 @@ function SystemLogs() {
|
|||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="system-logs-container">
|
||||
<h2>系统日志</h2>
|
||||
const projectOperationCount = (
|
||||
(stats?.operation_stats?.create_project || 0) +
|
||||
(stats?.operation_stats?.update_project || 0) +
|
||||
(stats?.operation_stats?.delete_project || 0)
|
||||
)
|
||||
|
||||
const fileOperationCount = (
|
||||
(stats?.operation_stats?.create_file || 0) +
|
||||
(stats?.operation_stats?.save_file || 0) +
|
||||
(stats?.operation_stats?.delete_file || 0)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="admin-page system-logs-container">
|
||||
<PageHeader
|
||||
title="系统日志"
|
||||
description="按用户、资源、时间维度筛选系统操作日志与行为统计。"
|
||||
icon={<FileSearchOutlined />}
|
||||
/>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="总日志数" value={stats.total_count} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="今日操作" value={stats.today_count} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="项目操作"
|
||||
value={stats.operation_stats?.create_project + stats.operation_stats?.update_project + stats.operation_stats?.delete_project || 0}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="文件操作"
|
||||
value={stats.operation_stats?.create_file + stats.operation_stats?.save_file + stats.operation_stats?.delete_file || 0}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="admin-stats admin-stats-4">
|
||||
<StatCard title="总日志数" value={stats.total_count} icon={<FileSearchOutlined />} color="blue" />
|
||||
<StatCard title="今日操作" value={stats.today_count} icon={<CheckCircleOutlined />} color="green" />
|
||||
<StatCard title="项目操作" value={projectOperationCount} icon={<FolderOutlined />} color="orange" />
|
||||
<StatCard title="文件操作" value={fileOperationCount} icon={<FileTextOutlined />} color="red" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space wrap size="middle">
|
||||
<Card className="admin-card">
|
||||
<div className="admin-toolbar">
|
||||
<div className="admin-toolbar-left">
|
||||
<Select
|
||||
placeholder="操作类型"
|
||||
style={{ width: 160 }}
|
||||
|
|
@ -366,11 +359,9 @@ function SystemLogs() {
|
|||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { clearAuthStorage } from '@/utils/authStorage'
|
||||
|
||||
const useUserStore = create(
|
||||
persist(
|
||||
|
|
@ -14,8 +15,7 @@ const useUserStore = create(
|
|||
setToken: (token) => set({ token }),
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user_info')
|
||||
clearAuthStorage()
|
||||
set({ user: null, token: null })
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
const ACCESS_TOKEN_KEY = 'access_token'
|
||||
const USER_INFO_KEY = 'user_info'
|
||||
|
||||
export function getAccessToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function clearAuthStorage() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
}
|
||||
|
||||
export function buildAuthorizedUrl(url, extraParams = {}) {
|
||||
const params = new URLSearchParams()
|
||||
const token = getAccessToken()
|
||||
|
||||
if (token) {
|
||||
params.set('token', token)
|
||||
}
|
||||
|
||||
Object.entries(extraParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
const query = params.toString()
|
||||
if (!query) {
|
||||
return url
|
||||
}
|
||||
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
return `${url}${separator}${query}`
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
export async function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
textArea.style.top = '0'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
const successful = document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
|
||||
if (!successful) {
|
||||
throw new Error('Copy command failed')
|
||||
}
|
||||
}
|
||||
|
||||
export function extractFilenameFromDisposition(contentDisposition, fallback) {
|
||||
if (!contentDisposition) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1])
|
||||
} catch (error) {
|
||||
console.warn('Decode filename* failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const basicMatch = /filename="?([^";]+)"?/i.exec(contentDisposition)
|
||||
if (basicMatch?.[1]) {
|
||||
return basicMatch[1]
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function triggerBlobDownload(blob, filename) {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import axios from 'axios'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import { clearAuthStorage, getAccessToken } from '@/utils/authStorage'
|
||||
|
||||
let isHandlingUnauthorized = false
|
||||
|
||||
|
|
@ -24,8 +25,7 @@ const request = axios.create({
|
|||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
// 从 localStorage 获取 token
|
||||
const token = localStorage.getItem('access_token')
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
|
@ -54,8 +54,7 @@ request.interceptors.response.use(
|
|||
isHandlingUnauthorized = true
|
||||
Toast.muteErrors(2500)
|
||||
Toast.error('认证失败', res.message || '未登录或登录已过期', 3, { force: true })
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user_info')
|
||||
clearAuthStorage()
|
||||
setTimeout(() => {
|
||||
redirectToLoginWithReturnTo()
|
||||
}, 600)
|
||||
|
|
@ -83,8 +82,7 @@ request.interceptors.response.use(
|
|||
isHandlingUnauthorized = true
|
||||
Toast.muteErrors(2500)
|
||||
Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true })
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user_info')
|
||||
clearAuthStorage()
|
||||
setTimeout(() => {
|
||||
redirectToLoginWithReturnTo()
|
||||
}, 600) // 延迟一小段时间,让提示先展示
|
||||
|
|
|
|||
Loading…
Reference in New Issue