diff --git a/.gitignore b/.gitignore index d5c161b..9e42f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ logs/ # Temporary files *.tmp *.temp + +# AI +.gemini-clipboard/ diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index b8088e9..252ce00 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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 模型配置"]) diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index 0dc4f41..9545d33 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -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" } - ) \ No newline at end of file + ) diff --git a/backend/app/api/v1/git_repos.py b/backend/app/api/v1/git_repos.py index 70bac2a..2c11299 100644 --- a/backend/app/api/v1/git_repos.py +++ b/backend/app/api/v1/git_repos.py @@ -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() diff --git a/backend/app/api/v1/llm_model_configs.py b/backend/app/api/v1/llm_model_configs.py new file mode 100644 index 0000000..bc854d7 --- /dev/null +++ b/backend/app/api/v1/llm_model_configs.py @@ -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="模型测试成功") diff --git a/backend/app/api/v1/preview.py b/backend/app/api/v1/preview.py index be7bae9..20c70f7 100644 --- a/backend/app/api/v1/preview.py +++ b/backend/app/api/v1/preview.py @@ -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" } ) - diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index 1f628c8..dbd560f 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -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) diff --git a/backend/app/api/v1/search.py b/backend/app/api/v1/search.py index 264a4ea..90c5cc7 100644 --- a/backend/app/api/v1/search.py +++ b/backend/app/api/v1/search.py @@ -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="索引重建任务已启动") \ No newline at end of file + return success_response(message="索引重建任务已启动") diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py index 426d05f..4763cba 100644 --- a/backend/app/mcp/server.py +++ b/backend/app/mcp/server.py @@ -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 { diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 79bf418..11133d4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/llm_model_config.py b/backend/app/models/llm_model_config.py new file mode 100644 index 0000000..b5bb32c --- /dev/null +++ b/backend/app/models/llm_model_config.py @@ -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"" diff --git a/backend/app/services/llm_provider_service.py b/backend/app/services/llm_provider_service.py new file mode 100644 index 0000000..0d39bfd --- /dev/null +++ b/backend/app/services/llm_provider_service.py @@ -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 + diff --git a/backend/app/services/pdf_service.py b/backend/app/services/pdf_service.py index 2cea1d0..6d256e5 100644 --- a/backend/app/services/pdf_service.py +++ b/backend/app/services/pdf_service.py @@ -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 diff --git a/backend/app/services/project_export_service.py b/backend/app/services/project_export_service.py new file mode 100644 index 0000000..6686540 --- /dev/null +++ b/backend/app/services/project_export_service.py @@ -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() diff --git a/backend/app/services/project_file_service.py b/backend/app/services/project_file_service.py new file mode 100644 index 0000000..c8415d2 --- /dev/null +++ b/backend/app/services/project_file_service.py @@ -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() diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py new file mode 100644 index 0000000..fe85ca4 --- /dev/null +++ b/backend/app/services/project_service.py @@ -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 diff --git a/docs/code-structure-audit-2026-04-08.md b/docs/code-structure-audit-2026-04-08.md new file mode 100644 index 0000000..e3f0df1 --- /dev/null +++ b/docs/code-structure-audit-2026-04-08.md @@ -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. 前端将文档浏览页的页面级编排从页面入口中拆出,并统一认证存储边界 + +这两项改动都直接对应结构规范中的高优先级问题,属于低风险、可验证、后续可继续扩展的结构优化。 diff --git a/docs/code-structure-standards.md b/docs/code-structure-standards.md new file mode 100644 index 0000000..7952640 --- /dev/null +++ b/docs/code-structure-standards.md @@ -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 允许的组织方式 + +项目可以采用以下任一方式: + +- 按领域优先组织:`/` +- 按层优先组织:`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. 执行基线 + +后续所有新增功能、重构与代码审计,均以本文档为默认判断依据: + +- 先判断职责边界是否正确 +- 再判断依赖方向是否健康 +- 再判断是否需要拆分或合并 +- 最后才考虑目录美观、文件长短和风格一致性 + +当“看起来更模块化”和“真实可读、可改、可验证”发生冲突时,优先后者。 \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2718ca2..1603f6e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/llmModelConfigs.js b/frontend/src/api/llmModelConfigs.js new file mode 100644 index 0000000..6149849 --- /dev/null +++ b/frontend/src/api/llmModelConfigs.js @@ -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, + }) +} diff --git a/frontend/src/api/project.js b/frontend/src/api/project.js index 6204fb2..a21f62c 100644 --- a/frontend/src/api/project.js +++ b/frontend/src/api/project.js @@ -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, + }) +} + /** * 获取项目成员 */ diff --git a/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css b/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css index bc039f7..cd554aa 100644 --- a/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css +++ b/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css @@ -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; diff --git a/frontend/src/components/BottomHintBar/BottomHintBar.css b/frontend/src/components/BottomHintBar/BottomHintBar.css index 36de3a3..a69ff8c 100644 --- a/frontend/src/components/BottomHintBar/BottomHintBar.css +++ b/frontend/src/components/BottomHintBar/BottomHintBar.css @@ -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); } diff --git a/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css b/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css index 0968427..8a5bae1 100644 --- a/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css +++ b/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css @@ -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; diff --git a/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css b/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css index 334f120..cd15b96 100644 --- a/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css +++ b/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css @@ -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; diff --git a/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css b/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css index eb42451..6b27c0b 100644 --- a/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css +++ b/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css @@ -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; diff --git a/frontend/src/components/DetailDrawer/DetailDrawer.css b/frontend/src/components/DetailDrawer/DetailDrawer.css index c5db2f2..0cf2fee 100644 --- a/frontend/src/components/DetailDrawer/DetailDrawer.css +++ b/frontend/src/components/DetailDrawer/DetailDrawer.css @@ -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) { diff --git a/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css b/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css index 8ea059b..adcd4cb 100644 --- a/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css +++ b/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css @@ -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; diff --git a/frontend/src/components/InfoPanel/InfoPanel.css b/frontend/src/components/InfoPanel/InfoPanel.css index 463adb6..b2f5a3f 100644 --- a/frontend/src/components/InfoPanel/InfoPanel.css +++ b/frontend/src/components/InfoPanel/InfoPanel.css @@ -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 @@ - diff --git a/frontend/src/components/MainLayout/AppSider.css b/frontend/src/components/MainLayout/AppSider.css index f4a98aa..74ee3fe 100644 --- a/frontend/src/components/MainLayout/AppSider.css +++ b/frontend/src/components/MainLayout/AppSider.css @@ -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); } /* 收起状态下的图标放大 */ diff --git a/frontend/src/components/MainLayout/AppSider.jsx b/frontend/src/components/MainLayout/AppSider.jsx index 4b9df79..322ec81 100644 --- a/frontend/src/components/MainLayout/AppSider.jsx +++ b/frontend/src/components/MainLayout/AppSider.jsx @@ -5,6 +5,7 @@ import { DesktopOutlined, GlobalOutlined, CloudServerOutlined, + FileSearchOutlined, UserOutlined, AppstoreOutlined, SettingOutlined, @@ -29,6 +30,7 @@ const iconMap = { DesktopOutlined: , GlobalOutlined: , CloudServerOutlined: , + FileSearchOutlined: , UserOutlined: , AppstoreOutlined: , SettingOutlined: , @@ -42,6 +44,133 @@ const iconMap = { 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] || ) : child.icon + const normalizedChild = normalizeMenuItem(child) + const icon = typeof normalizedChild.icon === 'string' + ? (iconMap[normalizedChild.icon] || ) + : 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] || ) : item.icon + const normalizedItem = normalizeMenuItem(item) + const icon = typeof normalizedItem.icon === 'string' + ? (iconMap[normalizedItem.icon] || ) + : 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) diff --git a/frontend/src/components/PDFViewer/PDFViewer.css b/frontend/src/components/PDFViewer/PDFViewer.css index c850dba..3ae75a2 100644 --- a/frontend/src/components/PDFViewer/PDFViewer.css +++ b/frontend/src/components/PDFViewer/PDFViewer.css @@ -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; } diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx index cd4376f..c910772 100644 --- a/frontend/src/components/ProtectedRoute.jsx +++ b/frontend/src/components/ProtectedRoute.jsx @@ -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}`) diff --git a/frontend/src/components/SideInfoPanel/SideInfoPanel.css b/frontend/src/components/SideInfoPanel/SideInfoPanel.css index f6f38a4..84509d3 100644 --- a/frontend/src/components/SideInfoPanel/SideInfoPanel.css +++ b/frontend/src/components/SideInfoPanel/SideInfoPanel.css @@ -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; diff --git a/frontend/src/components/StatCard/StatCard.css b/frontend/src/components/StatCard/StatCard.css index 509d577..9b62930 100644 --- a/frontend/src/components/StatCard/StatCard.css +++ b/frontend/src/components/StatCard/StatCard.css @@ -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 { diff --git a/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css b/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css index fd6341c..e471b8c 100644 --- a/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css +++ b/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css @@ -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 { diff --git a/frontend/src/index.css b/frontend/src/index.css index 5f226e2..4aab631 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 34eea89..9bf3956 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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 ( - -
-

管理员仪表盘

+
+ } + extra={( + + )} + /> - {/* 统计卡片 */} - - - - } - valueStyle={{ color: '#3f8600' }} - /> - - - - - } - valueStyle={{ color: '#1890ff' }} - /> - - - - - - 文档总数 - - - -
- } - value={stats.document_count} - prefix={} - valueStyle={{ color: '#cf1322' }} - /> - - - +
+ } color="green" /> + } color="blue" /> + } color="red" /> +
- {/* 最近用户 */} - - +
+ +
- {/* 最近项目 */} - -
+ +
- + ) } -export default Dashboard \ No newline at end of file +export default Dashboard diff --git a/frontend/src/pages/Desktop.css b/frontend/src/pages/Desktop.css index 205eac2..4614b4d 100644 --- a/frontend/src/pages/Desktop.css +++ b/frontend/src/pages/Desktop.css @@ -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; } } diff --git a/frontend/src/pages/Desktop.jsx b/frontend/src/pages/Desktop.jsx index 315ae96..ae67deb 100644 --- a/frontend/src/pages/Desktop.jsx +++ b/frontend/src/pages/Desktop.jsx @@ -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 ( -
-

个人桌面

+
+ } + /> - - {/* 左侧日历 */} -
- - - - +
+ + + - {/* 右侧活动列表 */} -
- - - {selectedDate.format('YYYY年MM月DD日')} 的文档活动 - - } - > - - {activityLogs.length > 0 ? ( - ( - handleDocumentClick(item)} - className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'} - style={{ cursor: item.file_exists ? 'pointer' : 'default' }} - > - - } - title={ -
- - {item.project_name} + + + {selectedDate.format('YYYY年MM月DD日')} 的文档活动 +
+ )} + > + + {activityLogs.length > 0 ? ( + ( + handleDocumentClick(item)} + className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'} + style={{ cursor: item.file_exists ? 'pointer' : 'default' }} + > + + )} + title={( +
+ + {item.project_name} + + + {item.operation_type} + + {!item.file_exists && ( + + (已失效) - - {item.operation_type} - - {!item.file_exists && ( - - (已失效) - - )} -
- } - description={ -
-
- 文件: - - {item.file_path} - -
- - {new Date(item.created_at).toLocaleTimeString('zh-CN')} + )} +
+ )} + description={( +
+
+ 文件: + + {item.file_path}
- } - /> - - )} - /> - ) : ( - - )} - - - - + + {new Date(item.created_at).toLocaleTimeString('zh-CN')} + +
+ )} + /> +
+ )} + /> + ) : ( + + )} +
+
+ ) } diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index 8e5c79c..ecaac86 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -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; + } +} diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index ae3f63b..fcf5556 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -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 (
- + + +
diff --git a/frontend/src/pages/Document/DocumentPage.css b/frontend/src/pages/Document/DocumentPage.css index 6addb1b..cbd91d3 100644 --- a/frontend/src/pages/Document/DocumentPage.css +++ b/frontend/src/pages/Document/DocumentPage.css @@ -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; + } +} diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index 2c9dad2..e39084f 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -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: , - 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: , - } - } else if (node.title && node.title.endsWith('.pdf')) { - // PDF 文件 - return { - key: node.key, - label: node.title, - icon: , - } - } - 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 (
- + {/* 左侧目录 */}
@@ -870,6 +499,14 @@ function DocumentPage() {
)} + + + ) : null} /> - {/* 搜索结果 */} - {hasSearched && searchResults.length > 0 && ( -
-
- 找到 {searchResults.length} 个结果 -
- - {searchResults.map((item, index) => ( -
- handleSearchResultClick(item)} - > -
- {item.type === 'project' ? ( - - ) : ( - - )} -
- - -

- {item.type === 'project' ? item.project_name : item.file_name} -

- - {item.match_type} - -
- {item.type === 'project' && ( -

- {item.project_description || '暂无描述'} -

- )} - {item.type === 'file' && ( -
-
项目: {item.project_name}
-
路径: {item.file_path}
-
- )} -
-
- - ))} - - - )} + + - {/* 正常项目列表 */} - {!hasSearched && ( - <> - - {paginatedProjects.map((project) => ( - - handleOpenProject(project.id)} - actions={type === 'my' ? [ - handleEdit(e, project)} />, - handleGitSettings(e, project)} />, - handleShare(e, project)} />, - handleMembers(e, project)} />, - ] : [ - , - handleShare(e, project)} />, - ]} - > - {/* 公开项目标识 */} - {project.is_public === 1 && ( -
公开
- )} -
- -
-

{project.name}

-

{project.description || '暂无描述'}

-
- 文档数: {project.doc_count || 0} - {type === 'share' && project.owner_name && ( - - 所有者: {project.owner_nickname || project.owner_name} - - )} - {type === 'share' && project.user_role && ( - - 角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'} - - )} -
-
- - ))} - - {filteredProjects.length === 0 && !loading && ( - - - - )} - - {filteredProjects.length > 0 && ( -
- { - if (type === 'page') { - return - } - return originalElement - }} - /> + {hasSearched && searchResults.length > 0 && ( +
+
+ 找到 {searchResults.length} 个结果
- )} - - )} + + {searchResults.map((item, index) => ( +
+ handleSearchResultClick(item)} + > +
+ {item.type === 'project' ? ( + + ) : ( + + )} +
+ + +

+ {item.type === 'project' ? item.project_name : item.file_name} +

+ + {item.match_type} + +
+ {item.type === 'project' && ( +

+ {item.project_description || '暂无描述'} +

+ )} + {item.type === 'file' && ( +
+
项目: {item.project_name}
+
路径: {item.file_path}
+
+ )} +
+
+ + ))} + + + )} - {/* 搜索无结果提示 */} - {hasSearched && !searching && searchResults.length === 0 && ( -
- -
- )} + {!hasSearched && ( + <> + + {paginatedProjects.map((project) => ( + + handleOpenProject(project.id)} + actions={type === 'my' ? [ + handleEdit(e, project)} />, + handleGitSettings(e, project)} />, + handleShare(e, project)} />, + handleMembers(e, project)} />, + ] : [ + , + handleShare(e, project)} />, + ]} + > + {project.is_public === 1 && ( +
公开
+ )} +
+ +
+

{project.name}

+

{project.description || '暂无描述'}

+
+ 文档数: {project.doc_count || 0} + {type === 'share' && project.owner_name && ( + + 所有者: {project.owner_nickname || project.owner_name} + + )} + {type === 'share' && project.user_role && ( + + 角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'} + + )} +
+
+ + ))} + + {filteredProjects.length === 0 && !loading && ( + +
+ +
+ + )} + + {filteredProjects.length > 0 && ( +
+ { + if (type === 'page') { + return + } + return originalElement + }} + /> +
+ )} + + )} + + {hasSearched && !searching && searchResults.length === 0 && ( +
+ +
+ )} + - 删除项目 + 删除 + @@ -758,11 +640,47 @@ function ProjectList({ type = 'my' }) { + + 关闭 + , + ] + } + closable={!exportingProject} + maskClosable={!exportingProject} + > + +
{exportTask?.message || '正在准备导出任务...'}
+ +
+ 已处理 {exportTask?.processed_files || 0} / {exportTask?.total_files || 0} 个文件 +
+ {exportTask?.status === 'failed' && ( +
+ {exportTask?.error || '项目导出失败'} +
+ )} +
+
+ { - setTransferModalVisible(false) + closeTransferModal() transferForm.resetFields() }} onOk={() => transferForm.submit()} @@ -800,7 +718,7 @@ function ProjectList({ type = 'my' }) { setShareModalVisible(false)} + onCancel={closeShareModal} footer={null} width={500} > @@ -818,7 +736,7 @@ function ProjectList({ type = 'my' }) { {/* 只有在我的项目中才显示密码设置功能 */} - {type === 'my' && ( + {showPasswordControls && ( <>
@@ -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' }) { { - setGitModalVisible(false) - setGitRepos([]) - }} + onCancel={closeGitSettings} footer={null} width={800} > @@ -1055,10 +968,7 @@ function ProjectList({ type = 'my' }) { { - setGitRepoModalVisible(false) - repoForm.resetFields() - }} + onCancel={() => closeGitRepoModal({ repoForm })} footer={null} >
- + diff --git a/frontend/src/pages/ProjectList/useProjectCollaboration.js b/frontend/src/pages/ProjectList/useProjectCollaboration.js new file mode 100644 index 0000000..4f857e4 --- /dev/null +++ b/frontend/src/pages/ProjectList/useProjectCollaboration.js @@ -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 diff --git a/frontend/src/pages/ProjectList/useProjectExport.js b/frontend/src/pages/ProjectList/useProjectExport.js new file mode 100644 index 0000000..61e7713 --- /dev/null +++ b/frontend/src/pages/ProjectList/useProjectExport.js @@ -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 diff --git a/frontend/src/pages/ProjectList/useProjectGitRepos.js b/frontend/src/pages/ProjectList/useProjectGitRepos.js new file mode 100644 index 0000000..f7021b9 --- /dev/null +++ b/frontend/src/pages/ProjectList/useProjectGitRepos.js @@ -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 diff --git a/frontend/src/pages/ProjectList/useProjectShare.js b/frontend/src/pages/ProjectList/useProjectShare.js new file mode 100644 index 0000000..6f48d08 --- /dev/null +++ b/frontend/src/pages/ProjectList/useProjectShare.js @@ -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 diff --git a/frontend/src/pages/System/AdminPages.css b/frontend/src/pages/System/AdminPages.css new file mode 100644 index 0000000..8441fa2 --- /dev/null +++ b/frontend/src/pages/System/AdminPages.css @@ -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; + } +} diff --git a/frontend/src/pages/System/ModelConfigs.css b/frontend/src/pages/System/ModelConfigs.css new file mode 100644 index 0000000..a7aa0da --- /dev/null +++ b/frontend/src/pages/System/ModelConfigs.css @@ -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; +} diff --git a/frontend/src/pages/System/ModelConfigs.jsx b/frontend/src/pages/System/ModelConfigs.jsx new file mode 100644 index 0000000..75d278a --- /dev/null +++ b/frontend/src/pages/System/ModelConfigs.jsx @@ -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: ( +
+
提供方:{getProviderMeta(result.provider)?.label || result.provider}
+
模型:{result.llm_model_name}
+
延迟:{result.latency_ms} ms
+
返回预览:{result.preview || '模型已成功返回,但未提取到文本内容。'}
+
+ ), + }) + } + + 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) => ( + + {record.model_name} + {record.model_code} + + ), + }, + { + title: '提供方', + dataIndex: 'provider', + key: 'provider', + width: 140, + render: (provider) => ( + {getProviderMeta(provider)?.label || provider || '-'} + ), + }, + { + 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) => ( + handleStatusChange(record, checked)} + /> + ), + }, + { + title: '默认', + dataIndex: 'is_default', + key: 'is_default', + width: 90, + render: (value) => ( + value ? 默认 : + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + key: 'updated_at', + width: 180, + render: (value) => formatDateTime(value), + }, + { + title: '操作', + key: 'action', + fixed: 'right', + width: 170, + render: (_, record) => ( + + + handleDelete(record)} + > + + + + ), + }, + ] + + return ( +
+ } + extra={( + + )} + /> + + +
+
+ { + setPage(1) + setKeyword(event.target.value) + }} + onSearch={(value) => { + setPage(1) + setKeyword(value) + }} + /> + { + setPage(1) + setStatusFilter(value) + }} + /> +
+ +
+ +
+
+ + { + setPage(nextPage) + setPageSize(nextPageSize) + }, + showTotal: (value) => `共 ${value} 条`, + }} + /> +
+ + form.submit()} + confirmLoading={submitting} + okText={editingConfigId ? '保存修改' : '创建'} + cancelText="取消" + styles={{ + body: { + maxHeight: 'calc(80vh - 180px)', + overflowY: 'auto', + paddingRight: 8, + }, + }} + footer={(_, { OkBtn, CancelBtn }) => ( + <> + + + + + )} + > +
+ 自动生成规则: 选择提供方后会自动带出默认 `base_url`;填写模型标识后会自动生成“模型名称”和“模型编码”。 + 如果你手动改过这些字段,后续就不会再被自动覆盖。 +
+ + + + + { + setAutoFillFlags((current) => ({ ...current, endpointUrl: false })) + }} + /> + + + + + + + + + + + + + + + + { + setAutoFillFlags((current) => ({ ...current, modelName: false })) + }} + /> + + + + { + setAutoFillFlags((current) => ({ ...current, modelCode: false })) + }} + /> + + + + + + + + + + + + + + + + + + + + + + +