main
mula.liu 2026-04-09 01:03:57 +08:00
parent f140ed9218
commit 2d41a3ba2f
69 changed files with 6162 additions and 2770 deletions

3
.gitignore vendored
View File

@ -30,3 +30,6 @@ logs/
# Temporary files
*.tmp
*.temp
# AI
.gemini-clipboard/

View File

@ -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 模型配置"])

View File

@ -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"
}
)
)

View File

@ -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()

View File

@ -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="模型测试成功")

View File

@ -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"
}
)

View File

@ -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)

View File

@ -12,6 +12,7 @@ from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.services.project_service import require_project_read_access
from app.services.search_service import search_service
from app.services.storage import storage_service
from app.schemas.response import success_response
@ -37,24 +38,12 @@ async def search_documents(
allowed_project_ids = []
if project_id:
# 检查指定项目的访问权限
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查权限
if project.owner_id != current_user.id and project.is_public != 1:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
if not member_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="无权访问该项目")
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
)
allowed_project_ids.append(str(project_id))
else:
# 获取所有可访问的项目
@ -267,4 +256,4 @@ async def rebuild_index(
background_tasks.add_task(rebuild_index_task, db)
return success_response(message="索引重建任务已启动")
return success_response(message="索引重建任务已启动")

View File

@ -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 {

View File

@ -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",
]

View File

@ -0,0 +1,34 @@
"""
LLM 模型配置模型
"""
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, Text, Numeric, Boolean
from sqlalchemy.sql import func
from app.core.database import Base
class LLMModelConfig(Base):
"""大模型配置表模型"""
__tablename__ = "llm_model_config"
config_id = Column(BigInteger, primary_key=True, autoincrement=True, comment="配置ID")
model_code = Column(String(128), nullable=False, unique=True, index=True, comment="模型编码")
model_name = Column(String(255), nullable=False, comment="模型名称")
provider = Column(String(64), comment="模型提供方")
endpoint_url = Column(String(512), comment="接口地址")
api_key = Column(String(512), comment="API Key")
llm_model_name = Column(String(128), nullable=False, comment="模型名称/部署名")
llm_timeout = Column(Integer, nullable=False, default=120, comment="超时时间(秒)")
llm_temperature = Column(Numeric(5, 2), nullable=False, default=0.70, comment="温度")
llm_top_p = Column(Numeric(5, 2), nullable=False, default=0.90, comment="Top P")
llm_max_tokens = Column(Integer, nullable=False, default=2048, comment="最大输出 Token")
llm_system_prompt = Column(Text, comment="系统提示词")
description = Column(String(500), comment="描述")
is_active = Column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
is_default = Column(Boolean, nullable=False, default=False, comment="是否默认")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<LLMModelConfig(config_id={self.config_id}, model_code='{self.model_code}')>"

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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. 前端将文档浏览页的页面级编排从页面入口中拆出,并统一认证存储边界
这两项改动都直接对应结构规范中的高优先级问题,属于低风险、可验证、后续可继续扩展的结构优化。

View File

@ -0,0 +1,499 @@
# 通用代码结构设计规范(强制执行)
本文档定义项目在长期演进中应遵守的结构边界、拆分原则与审计标准。
目标不是机械追求“小文件”“多目录”或“某种固定架构”,而是让任意语言、任意框架、任意部署形态下的代码都满足以下要求:
- 入口清晰
- 依赖方向稳定
- 职责边界明确
- 修改影响面可控
- 新人可顺序读懂
本文档适用于:
- 前端应用
- 后端服务
- CLI / 脚本 / Worker
- SDK / Library
- 单仓或多仓项目
本文档自落地起作为后续开发与重构的默认结构基线。
---
## 1. 核心原则
### 1.1 先划清职责,再决定目录
- 先区分“入口层 / 业务编排层 / 领域规则层 / 基础设施层 / 共享基础层”,再决定是否拆目录、拆文件、拆包。
- 目录结构是职责设计的结果,不是先验答案。
- 同一个团队可以采用不同目录形态,但不能模糊职责边界。
### 1.2 领域内聚优先于机械拆分
- 第一判断标准是“是否仍然属于同一业务主题”,不是“文件还能不能再拆小”。
- 同一主题内的读取、写入、校验、少量派生逻辑,可以保留在同一模块中。
- 如果拆分只会制造更多跳转、隐藏真实依赖、降低顺序可读性,就不应继续拆。
### 1.3 装配层必须薄
- 启动入口、路由入口、页面入口、命令入口都只负责装配。
- 装配层可以做依赖注入、参数收集、状态接线、组件拼装、调用编排入口。
- 装配层不应承载复杂业务规则、数据库细节、文件系统细节、网络细节或长流程状态机。
### 1.4 副作用必须收口
- 数据库访问、文件读写、网络调用、缓存、定时器、浏览器存储、进程环境依赖等,都属于副作用。
- 副作用应集中在可识别的边界模块中不应在页面、视图、路由、DTO、纯工具里四处散落。
- 任何需要 mock、替换、复用或测试隔离的外部依赖都应有明确归属。
### 1.5 依赖方向必须单向
- 默认依赖方向应从外向内:入口层 -> 业务编排层 -> 领域规则层 -> 基础设施实现。
- 共享基础层可以被多个层使用,但不能反向依赖业务实现。
- 低层不能反向引用高层具体实现来“图省事”。
### 1.6 文件大小不是目标,跨职责才是风险
- 行数只作为预警信号,不作为强制拆分指标。
- 真正需要拆分的信号包括:
- 一个模块服务多个业务主题
- 一个模块同时承担输入解析、业务决策、数据访问和展示
- 一个改动常常需要在同一文件中切换多种关注点
- 一个模块需要为不同调用方维持多套语义
### 1.7 重构优先低风险搬运
- 结构重构优先做“职责收口、边界清理、命名校正、依赖下沉/上提”。
- 默认不要在同一轮改动里同时进行:
- 大规模结构调整
- 新功能开发
- 行为修复
- 如果确需并行,必须以最小范围控制风险,并显式验证关键路径。
### 1.8 命名必须体现主题
- 文件、目录、模块、类型、服务名都应直接表达责任。
- 禁止使用模糊命名掩盖职责,例如:
- `misc`
- `helpers2`
- `commonThing`
- `temp_service`
- `manager_new`
---
## 2. 通用分层模型
以下是跨语言可复用的职责模型。项目不要求逐字使用这些目录名,但必须能映射到这些边界。
### 2.1 入口层 / 接口层
典型形态:
- 前端的 `App`、路由入口、页面入口
- 后端的 router / controller / handler
- CLI 的 command / main
- Worker 的 job handler / consumer entry
职责:
- 接收输入
- 做基础参数解析与协议适配
- 调用业务编排层
- 返回结果或渲染输出
禁止:
- 写复杂业务规则
- 直接拼装 SQL / ORM 流程
- 直接进行大段文件系统读写
- 直接进行复杂网络编排
- 在入口层内维护长生命周期状态机
### 2.2 业务编排层 / 用例层
典型形态:
- service
- use case
- action
- controller hook
- page model
- workflow
职责:
- 表达一个明确业务流程
- 协调多个依赖
- 承载事务边界、步骤顺序、状态推进
- 组织权限判断、前置校验、错误分支
要求:
- 一个模块只负责一个业务域或一个稳定子流程
- 可以依赖基础设施接口,但不应把具体协议细节暴露给上层
- 可以包含少量私有 helper但 helper 仅服务当前主题
### 2.3 领域规则层
典型形态:
- domain service
- policy
- rule
- validator
- entity behavior
- pure business helpers
职责:
- 承载稳定的业务规则和领域语义
- 保持尽量纯净、可测试、与外部协议解耦
- 统一业务概念、状态转换、派生计算
要求:
- 不直接访问数据库、网络、文件、浏览器环境
- 不依赖 UI、HTTP、CLI、消息队列等入口协议
### 2.4 基础设施层 / 数据访问层
典型形态:
- repository
- gateway
- API client
- storage adapter
- cache adapter
- filesystem adapter
- persistence implementation
职责:
- 封装外部系统细节
- 处理数据库、缓存、HTTP、对象存储、消息队列、浏览器存储、操作系统能力
- 提供可复用的边界接口
要求:
- 只解决“怎么接外部系统”,不承担业务决策
- 协议转换、序列化、连接管理、重试策略等应在此层收口
### 2.5 共享基础层
典型形态:
- constants
- shared types
- date / string / number helpers
- 通用 UI 基础组件
- 通用错误定义
要求:
- 必须是真正跨域、稳定、低语义耦合的内容
- 不允许把业务逻辑伪装成“common / shared / utils”
- 一旦某模块开始依赖特定业务名词,它就不再是共享基础层
---
## 3. 目录与模块组织规范
### 3.1 允许的组织方式
项目可以采用以下任一方式:
- 按领域优先组织:`<domain>/<entry|application|infra|shared>`
- 按层优先组织:`entry/ application/ domain/ infra/`
- 混合组织:顶层按领域,领域内再分层
- Monorepo 组织:`apps/ packages/ services/ workers/`
允许多种组织方式并存,但必须满足:
- 同一仓库内的同类代码遵循一致的判断逻辑
- 每个模块都能被映射到明确职责层
- 依赖方向清晰、稳定、可审计
### 3.2 推荐的判断方式
当你不确定某段代码应该放哪里时,按顺序判断:
1. 它是在接收输入、渲染输出、还是拼装启动吗
2. 它是在表达一个完整业务流程吗
3. 它是在表达不依赖外部协议的业务规则吗
4. 它是在接数据库、文件、网络、缓存、浏览器或系统能力吗
5. 它真的是跨域共享能力吗
### 3.3 一个模块只能有一个主语义
- 一个模块可以有多个函数,但只能服务一个主职责。
- 如果一个文件既是“页面”又是“API 聚合器”又是“缓存控制器”又是“视图组件”,就已经越界。
- 如果一个 router 文件开始长时间停留在 SQL、文件读写、事务与状态轮询细节上也已经越界。
### 3.4 兼容层与过渡层
- 允许存在短期兼容层、导出层、适配层。
- 兼容层必须被明确标记为过渡用途。
- 禁止长期把新逻辑继续堆回兼容层。
---
## 4. 前端通用规范
本节适用于 Web、桌面端、移动端和前端壳应用不绑定 React / Vue / Svelte 等具体框架。
### 4.1 页面/路由入口必须薄
- 页面文件默认负责页面装配、布局组织、边界兜底。
- 页面可持有少量与页面展示强绑定的状态。
- 当页面开始同时承担以下两项及以上时,应拆出页面级编排模块:
- 多个接口请求
- 轮询或定时器
- 上传/下载流程
- 多个 Drawer / Modal / Sheet 子流程
- 复杂权限判断
- 大量数据清洗与派生
### 4.2 视图组件默认无副作用
- 纯视图组件只接收整理好的 props。
- 纯视图组件默认不直接请求接口、不直接碰浏览器存储、不直接起轮询。
- 如果一个组件必须自带数据流程,它应被明确视为“功能组件”或“场景组件”,而不是伪装成通用组件。
### 4.3 页面级业务流程应集中编排
- 页面相关的请求、轮询、草稿保存、上传进度、权限行为、状态联动,应尽量集中在页面编排层。
- 页面编排层可以是 hook、store、controller、presenter 或 view-model不限定技术名词。
- 不要求为了形式而一律抽 hook只有在页面入口已经承担过多流程时才拆。
### 4.4 前端基础设施应收口
- API client
- 本地存储
- Session / token 持久化
- 浏览器标题、副作用事件、定时器策略
- 配置读取
以上能力应收口在可识别模块中,不应被页面随机复制。
### 4.5 复用原则
- 提炼稳定复用模式,不提炼偶然重复。
- 三处以上重复,优先评估抽取。
- 如果抽取后的接口比原地代码更难理解,不应抽取。
- 不允许制造“只有一个页面使用、但包装层很多”的伪复用。
---
## 5. 后端通用规范
本节适用于 HTTP 服务、RPC 服务、任务处理器和后台作业,不绑定 FastAPI / Spring / NestJS / Gin 等框架。
### 5.1 启动入口必须只做装配
- `main`
- app factory
- bootstrap
- container
这些入口只负责:
- 创建应用实例
- 注册路由/处理器
- 初始化中间件
- 装配依赖
- 生命周期绑定
不应承担:
- 业务规则
- SQL 或 ORM 编排
- 文件系统细节
- 长流程任务控制
### 5.2 Router / Controller / Handler 只做协议转换
允许:
- 接收请求参数
- 基础校验
- 调用用例层
- 将领域错误映射为接口错误
不允许:
- 在 handler 内直接堆大量 SQL
- 一边处理权限,一边处理事务,一边操作文件,一边组装响应模型
- 在 handler 内实现长流程状态机
### 5.3 Service / Use Case 以业务域组织
- 一个 service 文件只负责一个业务域或一个稳定子主题。
- 同域内的查询、写入、校验、少量派生逻辑可以在一起。
- 如果一个 service 同时承担多个主题,应优先拆主题而不是拆技术动作。
### 5.4 数据访问与外部适配要收口
- 数据库查询
- ORM 组装
- 缓存细节
- 第三方 HTTP 调用
- 文件上传下载
- 对象存储
- 队列/任务系统
这些细节应尽量沉到 repository / gateway / adapter / infra 中。
### 5.5 Schema / DTO / Contract 必须纯净
- DTO 只用于定义契约,不应携带数据库、文件系统、网络调用或业务副作用。
- 契约字段演进必须可追踪。
- 避免让数据库模型、接口模型、领域模型长期混成一种结构。
---
## 6. CLI / 脚本 / Worker 规范
- 命令入口只负责解析参数、准备依赖、调用用例。
- 脚本如果会长期保留,必须从“一次性脚本”升级为可读的结构化模块。
- Worker handler 只负责接收消息、提取 payload、调用业务流程、回写状态。
- 重试、幂等、死信、超时策略等运行时策略应有单独归属,不应散落在业务逻辑内部。
---
## 7. 拆分与合并准则
### 7.1 何时应拆分
满足任一项即可考虑拆分:
- 同一模块出现多个业务主题
- 同一模块同时依赖多种外部系统
- 同一模块同时承担输入解析、业务编排、持久化和展示
- 多人修改时经常产生冲突
- 阅读一个改动需要频繁跨越无关上下文
- 相同规则被复制到多个入口
### 7.2 何时不应拆分
- 仍是单一主题
- 代码虽长但顺序可读
- 继续拆只会制造纯转发层
- 继续拆会让调用链更深、定位更慢
- 抽出后接口语义比原代码更含糊
### 7.3 合并也是一种优化
- 如果多个模块只是在相互转发、没有独立语义,应考虑合并。
- 如果拆分之后需要同时打开 4 到 6 个文件才能理解一个简单流程,通常已经过度拆分。
---
## 8. 命名与依赖规则
### 8.1 命名规则
- 名称应表达业务主题或技术边界,不表达情绪和历史包袱。
- 优先使用“对象 + 语义”命名,而不是“抽象 + 序号”命名。
- 避免模糊后缀:
- `handler2`
- `newService`
- `commonUtils`
- `tempPage`
### 8.2 依赖规则
- 上层可以依赖下层抽象,不应依赖下层杂乱细节。
- 共享层不能反向依赖业务层。
- 领域模块之间若需协作,应通过明确用例、接口或边界对象完成,而不是互相穿透内部实现。
---
## 9. 测试与验证要求
### 9.1 结构改动后的默认验证
- 前端结构改动后,至少执行构建或类型校验。
- 后端结构改动后,至少执行语法校验、启动校验或最小测试集。
- 如果改动涉及契约、权限、状态流转、持久化边界,应追加针对性验证。
### 9.2 测试优先级
- 先保关键业务路径
- 再保跨层边界
- 再保复杂状态流转
- 最后补充纯工具覆盖
### 9.3 文档同步要求
以下情况必须同步设计文档或架构说明:
- 新增一层明确职责边界
- 新增一个稳定领域模块模板
- 改变入口层、用例层、基础设施层的责任划分
- 引入新的运行时或新的跨项目复用规范
---
## 10. 评审与审计清单
做结构评审时,默认检查以下问题:
### 10.1 入口层
- 入口是否足够薄
- 是否混入业务规则
- 是否混入外部系统细节
### 10.2 业务编排层
- 是否以业务主题组织
- 是否承担了过多无关流程
- 是否存在纯转发服务
### 10.3 领域规则层
- 是否仍保持纯净
- 是否被框架、HTTP、数据库协议污染
### 10.4 基础设施层
- 副作用是否收口
- 是否把业务规则偷偷塞回 adapter / utils / core
### 10.5 共享层
- 是否真的跨域复用
- 是否把业务逻辑伪装成 common / shared / utils
### 10.6 演进风险
- 新增功能是否沿着既有边界落位
- 兼容层是否在持续变厚
- 是否出现单文件多职责继续膨胀
---
## 11. 禁止事项
- 禁止为了图省事把新逻辑堆回入口层
- 禁止为了“文件更短”制造无语义的纯包装层
- 禁止在 `utils` / `common` / `shared` 中隐藏领域逻辑
- 禁止让页面、路由、handler 直接承载大量持久化或文件系统细节
- 禁止把 schema / DTO / config 当成业务逻辑容器
- 禁止一次改动里同时重写结构、协议、UI 和业务行为,且没有明确验证策略
---
## 12. 执行基线
后续所有新增功能、重构与代码审计,均以本文档为默认判断依据:
- 先判断职责边界是否正确
- 再判断依赖方向是否健康
- 再判断是否需要拆分或合并
- 最后才考虑目录美观、文件长短和风格一致性
当“看起来更模块化”和“真实可读、可改、可验证”发生冲突时,优先后者。

View File

@ -17,6 +17,7 @@ import ProfilePage from '@/pages/Profile/ProfilePage'
import Permissions from '@/pages/System/Permissions'
import Users from '@/pages/System/Users'
import Roles from '@/pages/System/Roles'
import ModelConfigs from '@/pages/System/ModelConfigs'
import SystemLogs from '@/pages/SystemLogs/SystemLogs'
import NotificationList from '@/pages/Notifications/NotificationList'
import ProtectedRoute from '@/components/ProtectedRoute'
@ -80,6 +81,7 @@ function App() {
<Route path="/system/permissions" element={<Permissions />} />
<Route path="/system/users" element={<Users />} />
<Route path="/system/roles" element={<Roles />} />
<Route path="/system/model-configs" element={<ModelConfigs />} />
<Route path="/system/logs" element={<SystemLogs />} />
</Route>

View File

@ -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,
})
}

View File

@ -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,
})
}
/**
* 获取项目成员
*/

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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) {

View File

@ -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;

View File

@ -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 @@

View File

@ -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);
}
/* 收起状态下的图标放大 */

View File

@ -5,6 +5,7 @@ import {
DesktopOutlined,
GlobalOutlined,
CloudServerOutlined,
FileSearchOutlined,
UserOutlined,
AppstoreOutlined,
SettingOutlined,
@ -29,6 +30,7 @@ const iconMap = {
DesktopOutlined: <DesktopOutlined />,
GlobalOutlined: <GlobalOutlined />,
CloudServerOutlined: <CloudServerOutlined />,
FileSearchOutlined: <FileSearchOutlined />,
UserOutlined: <UserOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
SettingOutlined: <SettingOutlined />,
@ -42,6 +44,133 @@ const iconMap = {
BookOutlined: <BookOutlined />,
}
const builtInMenuMetaMap = {
dashboard: {
path: '/dashboard',
icon: 'DashboardOutlined',
},
desktop: {
path: '/desktop',
icon: 'DesktopOutlined',
},
'projects:my': {
path: '/projects/my',
icon: 'FolderOutlined',
},
'projects:share': {
path: '/projects/share',
icon: 'TeamOutlined',
},
'knowledge:my': {
path: '/knowledge',
icon: 'ReadOutlined',
},
'system:users': {
path: '/system/users',
icon: 'UserOutlined',
},
'system:roles': {
path: '/system/roles',
icon: 'TeamOutlined',
},
'system:permissions': {
path: '/system/permissions',
icon: 'SafetyOutlined',
},
'system:model-configs': {
path: '/system/model-configs',
icon: 'CloudServerOutlined',
},
'system:logs': {
path: '/system/logs',
icon: 'FileSearchOutlined',
},
}
const resolveBuiltInMenuMeta = (item) => {
const directMeta = builtInMenuMetaMap[item.menu_code]
if (directMeta) {
return directMeta
}
if (item.path === '/dashboard' || item.menu_name === '管理面板') {
return {
path: '/dashboard',
icon: 'DashboardOutlined',
}
}
if (item.path === '/desktop' || item.menu_name === '个人桌面') {
return {
path: '/desktop',
icon: 'DesktopOutlined',
}
}
if (
item.path === '/projects' ||
item.path === '/projects/my' ||
item.menu_name === '我的项目' ||
item.menu_name === '项目空间'
) {
return {
path: '/projects/my',
icon: 'FolderOutlined',
}
}
if (item.path === '/projects/share' || item.menu_name === '参与项目') {
return {
path: '/projects/share',
icon: 'TeamOutlined',
}
}
if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') {
return {
path: '/knowledge',
icon: 'ReadOutlined',
}
}
if (item.path === '/system/users' || item.menu_name === '用户管理') {
return {
path: '/system/users',
icon: 'UserOutlined',
}
}
if (item.path === '/system/roles' || item.menu_name === '角色管理') {
return {
path: '/system/roles',
icon: 'TeamOutlined',
}
}
if (item.path === '/system/permissions' || item.menu_name === '权限管理') {
return {
path: '/system/permissions',
icon: 'SafetyOutlined',
}
}
if (item.path === '/system/model-configs' || item.menu_name === '模型配置') {
return {
path: '/system/model-configs',
icon: 'CloudServerOutlined',
}
}
if (item.path === '/system/logs' || item.menu_name === '系统日志') {
return {
path: '/system/logs',
icon: 'FileSearchOutlined',
}
}
return null
}
function AppSider({ collapsed, onToggle }) {
const navigate = useNavigate()
const location = useLocation()
@ -83,12 +212,15 @@ function AppSider({ collapsed, onToggle }) {
if (validChildren.length > 0) {
//
const groupItems = validChildren.map(child => {
const icon = typeof child.icon === 'string' ? (iconMap[child.icon] || <AppstoreOutlined />) : child.icon
const normalizedChild = normalizeMenuItem(child)
const icon = typeof normalizedChild.icon === 'string'
? (iconMap[normalizedChild.icon] || <AppstoreOutlined />)
: normalizedChild.icon
return {
key: child.menu_code,
label: child.menu_name,
key: normalizedChild.menu_code,
label: normalizedChild.menu_name,
icon: icon,
path: child.path
path: normalizedChild.path
}
})
@ -98,12 +230,15 @@ function AppSider({ collapsed, onToggle }) {
})
} else {
//
const icon = typeof item.icon === 'string' ? (iconMap[item.icon] || <AppstoreOutlined />) : item.icon
const normalizedItem = normalizeMenuItem(item)
const icon = typeof normalizedItem.icon === 'string'
? (iconMap[normalizedItem.icon] || <AppstoreOutlined />)
: normalizedItem.icon
defaultGroup.items.push({
key: item.menu_code,
label: item.menu_name,
key: normalizedItem.menu_code,
label: normalizedItem.menu_name,
icon: icon,
path: item.path
path: normalizedItem.path
})
}
})
@ -116,6 +251,20 @@ function AppSider({ collapsed, onToggle }) {
setMenuGroups(groups)
}
const normalizeMenuItem = (item) => {
const builtInMeta = resolveBuiltInMenuMeta(item)
if (!builtInMeta) {
return item
}
return {
...item,
icon: builtInMeta.icon || item.icon,
path: builtInMeta.path || item.path,
}
}
const handleNavigate = (key, item) => {
if (item.path) {
navigate(item.path)

View File

@ -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;
}

View File

@ -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}`)

View File

@ -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;

View File

@ -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 {

View File

@ -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 {

View File

@ -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;

View File

@ -1,9 +1,12 @@
import { useState, useEffect } from 'react'
import { Card, Row, Col, Statistic, Table, Spin, Button, Tooltip, message } from 'antd'
import { UserOutlined, ProjectOutlined, FileTextOutlined, SyncOutlined } from '@ant-design/icons'
import { Card, Table, Spin, Button, message } from 'antd'
import { UserOutlined, ProjectOutlined, FileTextOutlined, SyncOutlined, DashboardOutlined } from '@ant-design/icons'
import { getDashboardStats } from '@/api/dashboard'
import { rebuildIndex } from '@/api/search'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import Toast from '@/components/Toast/Toast'
import '@/pages/System/AdminPages.css'
function Dashboard() {
const [loading, setLoading] = useState(true)
@ -103,82 +106,40 @@ function Dashboard() {
}
return (
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>管理员仪表盘</h1>
<div className="admin-page">
<PageHeader
title="管理面板"
description="汇总查看用户、项目与文档数据,并执行搜索索引维护操作。"
icon={<DashboardOutlined />}
extra={(
<Button
type="primary"
icon={<SyncOutlined spin={rebuilding} />}
onClick={handleRebuildIndex}
loading={rebuilding}
>
重建索引
</Button>
)}
/>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={8}>
<Card>
<Statistic
title="用户总数"
value={stats.user_count}
prefix={<UserOutlined />}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="项目总数"
value={stats.project_count}
prefix={<ProjectOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>文档总数</span>
<Tooltip title="重建全文搜索索引(扫描所有文档)">
<Button
type="link"
size="small"
style={{ padding: 0, height: 'auto' }}
icon={<SyncOutlined spin={rebuilding} />}
onClick={handleRebuildIndex}
disabled={rebuilding}
>
重建索引
</Button>
</Tooltip>
</div>
}
value={stats.document_count}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
</Row>
<div className="admin-stats">
<StatCard title="用户总数" value={stats.user_count} icon={<UserOutlined />} color="green" />
<StatCard title="项目总数" value={stats.project_count} icon={<ProjectOutlined />} color="blue" />
<StatCard title="文档总数" value={stats.document_count} icon={<FileTextOutlined />} color="red" />
</div>
{/* 最近用户 */}
<Card title="最近创建的用户" style={{ marginBottom: '24px' }}>
<Table
columns={userColumns}
dataSource={recentUsers}
rowKey="id"
pagination={false}
/>
<div className="admin-stack">
<Card title="最近创建的用户" className="admin-card">
<Table columns={userColumns} dataSource={recentUsers} rowKey="id" pagination={false} />
</Card>
{/* 最近项目 */}
<Card title="最近创建的项目" style={{ marginBottom: '24px' }}>
<Table
columns={projectColumns}
dataSource={recentProjects}
rowKey="id"
pagination={false}
/>
<Card title="最近创建的项目" className="admin-card">
<Table columns={projectColumns} dataSource={recentProjects} rowKey="id" pagination={false} />
</Card>
</div>
</div>
)
}
export default Dashboard
export default Dashboard

View File

@ -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;
}
}

View File

@ -1,11 +1,13 @@
import { useState, useEffect } from 'react'
import { Card, Row, Col, Calendar, List, Badge, Empty, Typography, Spin } from 'antd'
import { FileTextOutlined, ClockCircleOutlined } from '@ant-design/icons'
import { Card, Calendar, List, Badge, Empty, Typography, Spin } from 'antd'
import { DesktopOutlined, FileTextOutlined, ClockCircleOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { getDocumentActivityDates, getDocumentActivity } from '@/api/dashboard'
import PageHeader from '@/components/PageHeader/PageHeader'
import Toast from '@/components/Toast/Toast'
import dayjs from 'dayjs'
import './Desktop.css'
import '@/pages/System/AdminPages.css'
const { Text } = Typography
@ -111,99 +113,97 @@ function Desktop() {
}
return (
<div className="desktop-page">
<h1 className="page-title">个人桌面</h1>
<div className="admin-page">
<PageHeader
title="个人桌面"
description="查看文档活动日历与每天的编辑轨迹,快速回到最近处理过的内容。"
icon={<DesktopOutlined />}
/>
<Row gutter={24}>
{/* 左侧日历 */}
<Col xs={24} lg={12}>
<Card className="calendar-card">
<Calendar
fullscreen={false}
value={selectedDate}
onSelect={onSelect}
onPanelChange={onPanelChange}
fullCellRender={dateCellRender}
/>
</Card>
</Col>
<div className="admin-grid-2 desktop-grid">
<Card className="admin-card calendar-card">
<Calendar
fullscreen={false}
value={selectedDate}
onSelect={onSelect}
onPanelChange={onPanelChange}
fullCellRender={dateCellRender}
/>
</Card>
{/* 右侧活动列表 */}
<Col xs={24} lg={12}>
<Card
className="activity-card"
title={
<div>
<FileTextOutlined style={{ marginRight: 8 }} />
{selectedDate.format('YYYY年MM月DD日')} 的文档活动
</div>
}
>
<Spin spinning={loading}>
{activityLogs.length > 0 ? (
<List
dataSource={activityLogs}
renderItem={(item) => (
<List.Item
key={item.id}
onClick={() => handleDocumentClick(item)}
className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'}
style={{ cursor: item.file_exists ? 'pointer' : 'default' }}
>
<List.Item.Meta
avatar={
<ClockCircleOutlined
style={{
fontSize: 20,
color: item.file_exists ? '#1890ff' : '#d9d9d9'
}}
/>
}
title={
<div>
<Text strong style={{ color: item.file_exists ? undefined : '#999' }}>
{item.project_name}
<Card
className="admin-card activity-card"
title={(
<div>
<FileTextOutlined style={{ marginRight: 8 }} />
{selectedDate.format('YYYY年MM月DD日')} 的文档活动
</div>
)}
>
<Spin spinning={loading}>
{activityLogs.length > 0 ? (
<List
dataSource={activityLogs}
renderItem={(item) => (
<List.Item
key={item.id}
onClick={() => handleDocumentClick(item)}
className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'}
style={{ cursor: item.file_exists ? 'pointer' : 'default' }}
>
<List.Item.Meta
avatar={(
<ClockCircleOutlined
style={{
fontSize: 20,
color: item.file_exists ? 'var(--link-color)' : 'var(--border-color-strong)'
}}
/>
)}
title={(
<div>
<Text strong style={{ color: item.file_exists ? undefined : 'var(--text-color-secondary)' }}>
{item.project_name}
</Text>
<Text
type="secondary"
style={{ marginLeft: 8, color: item.file_exists ? undefined : 'var(--text-color-secondary)' }}
>
{item.operation_type}
</Text>
{!item.file_exists && (
<Text type="danger" style={{ marginLeft: 8, fontSize: 12 }}>
(已失效)
</Text>
<Text
type="secondary"
style={{ marginLeft: 8, color: item.file_exists ? undefined : '#bbb' }}
>
{item.operation_type}
</Text>
{!item.file_exists && (
<Text type="danger" style={{ marginLeft: 8, fontSize: 12 }}>
(已失效)
</Text>
)}
</div>
}
description={
<div>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">文件</Text>
<Text code style={{ color: item.file_exists ? undefined : '#999' }}>
{item.file_path}
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(item.created_at).toLocaleTimeString('zh-CN')}
)}
</div>
)}
description={(
<div>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">文件</Text>
<Text code style={{ color: item.file_exists ? undefined : 'var(--text-color-secondary)' }}>
{item.file_path}
</Text>
</div>
}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该日期暂无文档活动记录"
/>
)}
</Spin>
</Card>
</Col>
</Row>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(item.created_at).toLocaleTimeString('zh-CN')}
</Text>
</div>
)}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该日期暂无文档活动记录"
/>
)}
</Spin>
</Card>
</div>
</div>
)
}

View File

@ -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;
}
}

View File

@ -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])
// MenuMenu
const handleMenuClick = async ({ key, domEvent }) => {
const node = findNodeByKey(treeData, key)
if (!node) return
await openNodeByKey(key, node, true)
}
//
const handleReset = () => {
if (!selectedFile) return
@ -293,16 +172,8 @@ function DocumentEditor() {
title: '确认重置',
content: '确定要重置当前修改吗?所有未保存的更改都将丢失。',
onOk: async () => {
setLoading(true)
try {
const res = await getFileContent(projectId, selectedFile)
setFileContent(res.data.content)
Toast.success('重置成功', '已恢复至最后保存的版本')
} catch (error) {
Toast.error('重置失败', '无法重新加载文件内容')
} finally {
setLoading(false)
}
await openNodeByKey(selectedFile, null, false)
Toast.success('重置成功', '已恢复至最后保存的版本')
},
})
}
@ -372,8 +243,7 @@ function DocumentEditor() {
})
Toast.success('成功', '删除成功')
if (selectedFile === path) {
setSelectedFile(null)
setFileContent('')
clearSelection()
}
fetchTree()
} catch (error) {
@ -461,8 +331,7 @@ function DocumentEditor() {
//
if (operationType === 'rename' && selectedFile === rightClickNode) {
setSelectedFile(null)
setFileContent('')
clearSelection()
}
} catch (error) {
console.error('Operation error:', error)
@ -614,42 +483,6 @@ function DocumentEditor() {
}
}
//
const handleExportDirectory = async () => {
//
const directoryPath = selectedNode && !selectedNode.isLeaf ? selectedNode.key : ''
try {
const response = await exportDirectory(projectId, directoryPath)
//
const contentDisposition = response.headers['content-disposition']
let filename = `${directoryPath || 'root'}.zip`
if (contentDisposition) {
const matches = /filename=(.+)/.exec(contentDisposition)
if (matches && matches[1]) {
filename = matches[1]
}
}
// blob URL
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
Toast.success('成功', '导出成功')
} catch (error) {
console.error('Export error:', error)
Toast.error('错误', '导出失败')
}
}
// /
const handleMove = (path) => {
setRightClickNode(path)
@ -703,8 +536,7 @@ function DocumentEditor() {
//
if (selectedFile === rightClickNode) {
setSelectedFile(null)
setFileContent('')
clearSelection()
}
} catch (error) {
console.error('Move error:', error)
@ -960,7 +792,7 @@ function DocumentEditor() {
return (
<div className="document-editor-page">
<Layout className="document-editor-container">
<Layout className="document-editor-container document-workspace-frame">
<Sider
width={280}
theme="light"
@ -1000,6 +832,14 @@ function DocumentEditor() {
}}
/>
<Space.Compact className="mode-actions-group">
<Tooltip title="刷新">
<Button
size="middle"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={refreshing}
/>
</Tooltip>
<Tooltip title="添加文件">
<Button
size="middle"
@ -1029,13 +869,6 @@ function DocumentEditor() {
/>
</Tooltip>
</Upload>
<Tooltip title="导出文档">
<Button
size="middle"
icon={<DownloadOutlined />}
onClick={handleExportDirectory}
/>
</Tooltip>
</Space.Compact>
</div>
</div>

View File

@ -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;
}
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons'
import { Layout, Menu, Spin, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
import { ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, CloseOutlined, ReloadOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
@ -9,15 +9,16 @@ import rehypeSlug from 'rehype-slug'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger'
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
import { getExportPdfUrl } from '@/api/file'
import { gitPull, gitPush, getGitRepos } from '@/api/project'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import { copyText } from '@/utils/browserIO'
import useDocumentBrowser from './useDocumentBrowser'
import { buildAuthorizedUrl } from '@/utils/authStorage'
import './DocumentPage.css'
const { Sider, Content } = Layout
@ -39,34 +40,48 @@ function DocumentPage() {
const { projectId } = useParams()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [selectedNodeKey, setSelectedNodeKey] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [tocCollapsed, setTocCollapsed] = useState(false)
const [tocItems, setTocItems] = useState([])
const [shareModalVisible, setShareModalVisible] = useState(false)
const [shareInfo, setShareInfo] = useState(null)
const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('')
const [userRole, setUserRole] = useState('viewer')
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
const [gitRepos, setGitRepos] = useState([])
const [projectName, setProjectName] = useState('')
//
const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const [modeSwitchValue, setModeSwitchValue] = useState('view')
const [refreshing, setRefreshing] = useState(false)
const contentRef = useRef(null)
const modeSwitchingRef = useRef(false)
const {
filteredTreeData,
menuItems,
selectedFile,
selectedNode,
selectedNodeKey,
markdownContent,
loading,
openKeys,
projectName,
userRole,
pdfFilename,
pdfUrl,
viewMode,
searchKeyword,
isSearching,
tocItems,
setOpenKeys,
setSearchKeyword,
loadFileTree,
loadMarkdown,
refreshCurrentView,
handleMenuClick,
handleMarkdownLink,
handleSearch,
} = useDocumentBrowser({
projectId,
searchParams,
setSearchParams,
contentRef,
})
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
@ -76,20 +91,6 @@ function DocumentPage() {
navigate(to)
}
const updateFileParam = (filePath) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
}
setSearchParams(nextParams, { replace: true })
}
useEffect(() => {
loadFileTree()
}, [projectId])
const handleClose = () => {
Modal.confirm({
title: '确认退出',
@ -106,124 +107,18 @@ function DocumentPage() {
})
}
// URL
useEffect(() => {
//
if (fileTree.length === 0) return
const fileParam = searchParams.get('file')
const keywordParam = searchParams.get('keyword')
//
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
//
if (fileParam) {
if (fileParam !== selectedFile) {
setSelectedFile(fileParam)
setSelectedNodeKey(fileParam)
//
const parts = fileParam.split('/')
const allParentPaths = []
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
allParentPaths.push(currentPath)
}
if (allParentPaths.length > 0) {
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
}
// PDF Markdown
if (fileParam.toLowerCase().endsWith('.pdf')) {
let url = getDocumentUrl(projectId, fileParam)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf')
} else {
loadMarkdown(fileParam)
setViewMode('markdown')
}
}
} else {
// README.md
if (!selectedFile) {
const readmeNode = findReadme(fileTree)
if (readmeNode) {
setSelectedFile(readmeNode.key)
setSelectedNodeKey(readmeNode.key)
updateFileParam(readmeNode.key)
loadMarkdown(readmeNode.key)
}
}
}
}, [searchParams, fileTree])
//
const handleSearch = async (value) => {
setSearchKeyword(value)
if (!value.trim()) {
setMatchedFilePaths(new Set())
return
}
setIsSearching(true)
const handleRefresh = async () => {
setRefreshing(true)
try {
const res = await searchDocuments(value, projectId)
const paths = new Set(res.data.map(item => item.file_path))
setMatchedFilePaths(paths)
//
const keysToExpand = new Set(openKeys)
res.data.forEach(item => {
const parts = item.file_path.split('/')
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
keysToExpand.add(currentPath)
}
})
setOpenKeys(Array.from(keysToExpand))
await refreshCurrentView()
} catch (error) {
console.error('Search error:', error)
console.error('Refresh document page error:', error)
Toast.error('刷新失败', '请稍后重试')
} finally {
setIsSearching(false)
setRefreshing(false)
}
}
//
const filteredTreeData = useMemo(() => {
if (!searchKeyword.trim()) return fileTree
const loop = (data) => {
const result = []
for (const node of data) {
const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase())
const contentMatch = matchedFilePaths.has(node.key)
if (node.children) {
const children = loop(node.children)
if (children.length > 0 || titleMatch) {
result.push({ ...node, children })
}
} else {
if (titleMatch || contentMatch) {
result.push(node)
}
}
}
return result
}
return loop(fileTree)
}, [fileTree, searchKeyword, matchedFilePaths])
const loadGitRepos = async () => {
try {
const res = await getGitRepos(projectId)
@ -233,255 +128,13 @@ function DocumentPage() {
}
}
//
const loadFileTree = async () => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
const tree = data.tree || data || [] //
const role = data.user_role || 'viewer'
const name = data.project_name
setFileTree(tree)
setUserRole(role)
setProjectName(name)
} catch (error) {
console.error('Load file tree error:', error)
}
}
// README.md
const findReadme = (nodes) => {
//
for (const node of nodes) {
if (node.title === 'README.md' && node.isLeaf) {
return node
}
}
return null
}
const findNodeByKey = (nodes, key) => {
for (const node of nodes) {
if (node.key === key) {
return node
}
if (node.children?.length) {
const found = findNodeByKey(node.children, key)
if (found) {
return found
}
}
}
return null
}
//
const convertTreeToMenuItems = (nodes) => {
return nodes.map((node) => {
// -
const titleNode = node.title.replace('.md', '')
if (!node.isLeaf) {
//
return {
key: node.key,
label: node.title,
icon: <FolderOutlined />,
onTitleClick: () => setSelectedNodeKey(node.key),
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
} else if (node.title && node.title.endsWith('.md')) {
// Markdown
return {
key: node.key,
label: titleNode,
icon: <FileTextOutlined />,
}
} else if (node.title && node.title.endsWith('.pdf')) {
// PDF
return {
key: node.key,
label: node.title,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
}
return null
}).filter(Boolean)
}
// markdown
const loadMarkdown = async (filePath) => {
setLoading(true)
setTocItems([]) //
try {
const res = await getFileContent(projectId, filePath)
setMarkdownContent(res.data?.content || '')
//
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
} catch (error) {
console.error('Load markdown error:', error)
setMarkdownContent('# 文档加载失败\n\n无法加载该文档请稍后重试。')
} finally {
setLoading(false)
}
}
// markdown
useEffect(() => {
if (markdownContent) {
const slugger = new GithubSlugger()
const headings = []
const lines = markdownContent.split('\n')
lines.forEach((line) => {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (match) {
const level = match[1].length
const title = match[2]
// 使 github-slugger ID rehype-slug
const key = slugger.slug(title)
headings.push({
key: `#${key}`,
href: `#${key}`,
title,
level,
})
}
})
setTocItems(headings)
if (userRole === 'viewer') {
setGitRepos([])
return
}
}, [markdownContent])
//
const handleMenuClick = ({ key }) => {
setSelectedFile(key)
setSelectedNodeKey(key)
updateFileParam(key)
// PDF
if (key.toLowerCase().endsWith('.pdf')) {
// PDF - tokenURL
let url = getDocumentUrl(projectId, key)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(key.split('/').pop())
setViewMode('pdf')
} else {
// Markdown
setViewMode('markdown')
loadMarkdown(key)
}
}
//
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) {
// PDFPDF
let url = getDocumentUrl(projectId, targetPath)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(targetPath.split('/').pop())
setViewMode('pdf')
} else {
// Markdown
setViewMode('markdown')
loadMarkdown(targetPath)
}
}
loadGitRepos()
}, [projectId, userRole])
const handleGitPull = async (repoId = null, force = false) => {
if (gitRepos.length === 0) {
@ -656,7 +309,6 @@ function DocumentPage() {
//
const handleShare = async () => {
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
if (selectedNode && !selectedNode.isLeaf) {
Toast.warning('提示', '当前选中的是文件夹,不能直接分享,请选择具体文件后再试')
return
@ -674,41 +326,20 @@ function DocumentPage() {
}
}
const shareTargetFile = selectedNode?.isLeaf ? selectedNodeKey : ''
const shareLinkValue = shareInfo
? `${window.location.origin}${shareInfo.share_url}${shareTargetFile ? `?file=${encodeURIComponent(shareTargetFile)}` : ''}`
: ''
//
const handleCopyLink = async () => {
if (!shareInfo) return
const shareTargetFile = selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
? selectedNodeKey
: ''
let fullUrl = `${window.location.origin}${shareInfo.share_url}`
if (shareTargetFile) {
fullUrl += `?file=${encodeURIComponent(shareTargetFile)}`
if (!shareLinkValue) {
return
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(fullUrl)
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
// Fallback for non-secure contexts or older browsers
const textArea = document.createElement("textarea")
textArea.value = fullUrl
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
textArea.style.top = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (successful) {
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
Toast.error('复制失败', '请手动复制链接')
}
}
await copyText(shareLinkValue)
Toast.success('复制成功', '分享链接已复制到剪贴板')
} catch (err) {
console.error('Failed to copy:', err)
Toast.error('复制失败', '无法访问剪贴板')
@ -755,8 +386,6 @@ function DocumentPage() {
}
}
const menuItems = convertTreeToMenuItems(filteredTreeData)
// Markdown
// 使 components
// 使 rehype
@ -829,11 +458,11 @@ function DocumentPage() {
th: highlightRenderer('th'),
div: highlightRenderer('div'),
}
}, [searchKeyword])
}, [searchKeyword, handleMarkdownLink])
return (
<div className="project-docs-page">
<Layout className="docs-layout">
<Layout className="docs-layout document-workspace-frame">
{/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header">
@ -870,6 +499,14 @@ function DocumentPage() {
<div />
)}
<Space.Compact className="mode-actions-group">
<Tooltip title="刷新">
<Button
size="middle"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={refreshing}
/>
</Tooltip>
{userRole !== 'viewer' && renderGitActions()}
<Tooltip title="分享">
<Button
@ -951,12 +588,7 @@ function DocumentPage() {
right={tocCollapsed ? 24 : 280}
onExportPDF={() => {
if (!selectedFile) return
let url = getExportPdfUrl(projectId, selectedFile)
const token = localStorage.getItem('access_token')
if (token) {
url += `&token=${encodeURIComponent(token)}`
}
window.open(url, '_blank')
window.open(buildAuthorizedUrl(getExportPdfUrl(projectId, selectedFile)), '_blank')
}}
/>
)}
@ -1025,13 +657,10 @@ function DocumentPage() {
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
{selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf ? '当前文件分享链接' : '项目分享链接'}
{shareTargetFile ? '当前文件分享链接' : '项目分享链接'}
</label>
<Input
value={selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
? `${window.location.origin}${shareInfo.share_url}?file=${encodeURIComponent(selectedNodeKey)}`
: `${window.location.origin}${shareInfo.share_url}`
}
value={shareLinkValue}
readOnly
addonAfter={
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />

View File

@ -0,0 +1,145 @@
import { FileTextOutlined, FolderOutlined, FilePdfOutlined } from '@ant-design/icons'
import GithubSlugger from 'github-slugger'
export function findRootReadme(nodes) {
return nodes.find((node) => node.title === 'README.md' && node.isLeaf) || null
}
export function findNodeByKey(nodes, key) {
for (const node of nodes) {
if (node.key === key) {
return node
}
if (node.children?.length) {
const found = findNodeByKey(node.children, key)
if (found) {
return found
}
}
}
return null
}
export function collectParentKeys(path) {
const parts = path.split('/')
const parentKeys = []
let currentPath = ''
for (let index = 0; index < parts.length - 1; index += 1) {
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index]
parentKeys.push(currentPath)
}
return parentKeys
}
export function resolveRelativePath(currentPath, relativePath) {
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/'))
const dirParts = currentDir ? currentDir.split('/') : []
relativePath.split('/').forEach((part) => {
if (part === '..') {
dirParts.pop()
return
}
if (part !== '.' && part !== '') {
dirParts.push(part)
}
})
return dirParts.join('/')
}
export function buildTocItems(markdownContent) {
if (!markdownContent) {
return []
}
const slugger = new GithubSlugger()
const headings = []
markdownContent.split('\n').forEach((line) => {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (!match) {
return
}
const title = match[2]
const key = slugger.slug(title)
headings.push({
key: `#${key}`,
href: `#${key}`,
title,
level: match[1].length,
})
})
return headings
}
export function filterTreeByKeyword(nodes, keyword, matchedFilePaths) {
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) {
return nodes
}
const loop = (items) => {
const result = []
items.forEach((node) => {
const titleMatch = node.title.toLowerCase().includes(normalizedKeyword)
const contentMatch = matchedFilePaths.has(node.key)
if (node.children?.length) {
const children = loop(node.children)
if (children.length > 0 || titleMatch) {
result.push({ ...node, children })
}
return
}
if (titleMatch || contentMatch) {
result.push(node)
}
})
return result
}
return loop(nodes)
}
export function convertTreeToMenuItems(nodes) {
return nodes
.map((node) => {
if (!node.isLeaf) {
return {
key: node.key,
label: node.title,
icon: <FolderOutlined />,
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
}
if (node.title?.endsWith('.md')) {
return {
key: node.key,
label: node.title.replace('.md', ''),
icon: <FileTextOutlined />,
}
}
if (node.title?.endsWith('.pdf')) {
return {
key: node.key,
label: node.title,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
}
return null
})
.filter(Boolean)
}

View File

@ -0,0 +1,255 @@
import { useEffect, useMemo, useState } from 'react'
import { getDocumentUrl, getFileContent, getProjectTree } from '@/api/file'
import { searchDocuments } from '@/api/search'
import { buildAuthorizedUrl } from '@/utils/authStorage'
import {
buildTocItems,
collectParentKeys,
convertTreeToMenuItems,
filterTreeByKeyword,
findNodeByKey,
findRootReadme,
resolveRelativePath,
} from './documentBrowserUtils'
function useDocumentBrowser({ projectId, searchParams, setSearchParams, contentRef }) {
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [selectedNodeKey, setSelectedNodeKey] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [tocItems, setTocItems] = useState([])
const [userRole, setUserRole] = useState('viewer')
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
const [projectName, setProjectName] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const updateFileParam = (filePath) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
}
setSearchParams(nextParams, { replace: true })
}
const loadMarkdown = async (filePath) => {
setLoading(true)
setTocItems([])
try {
const res = await getFileContent(projectId, filePath)
setMarkdownContent(res.data?.content || '')
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
} catch (error) {
console.error('Load markdown error:', error)
setMarkdownContent('# 文档加载失败\n\n无法加载该文档请稍后重试。')
} finally {
setLoading(false)
}
}
const openFile = async (filePath, options = {}) => {
const { syncUrl = true } = options
setSelectedFile(filePath)
setSelectedNodeKey(filePath)
setOpenKeys((prev) => [...new Set([...prev, ...collectParentKeys(filePath)])])
if (syncUrl) {
updateFileParam(filePath)
}
if (filePath.toLowerCase().endsWith('.pdf')) {
setPdfUrl(buildAuthorizedUrl(getDocumentUrl(projectId, filePath)))
setPdfFilename(filePath.split('/').pop())
setViewMode('pdf')
return
}
setPdfUrl('')
setPdfFilename('')
setViewMode('markdown')
await loadMarkdown(filePath)
}
const loadFileTree = async () => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
setFileTree(data.tree || data || [])
setUserRole(data.user_role || 'viewer')
setProjectName(data.project_name || '')
} catch (error) {
console.error('Load file tree error:', error)
}
}
const handleSearch = async (value) => {
setSearchKeyword(value)
if (!value.trim()) {
setMatchedFilePaths(new Set())
return
}
setIsSearching(true)
try {
const res = await searchDocuments(value, projectId)
const paths = new Set(res.data.map((item) => item.file_path))
setMatchedFilePaths(paths)
const expandedKeys = new Set(openKeys)
res.data.forEach((item) => {
collectParentKeys(item.file_path).forEach((parentKey) => expandedKeys.add(parentKey))
})
setOpenKeys(Array.from(expandedKeys))
} catch (error) {
console.error('Search error:', error)
} finally {
setIsSearching(false)
}
}
const handleMenuClick = ({ key }) => {
openFile(key)
}
const refreshCurrentView = async () => {
await loadFileTree()
if (!selectedFile) {
return
}
if (selectedFile.toLowerCase().endsWith('.pdf')) {
const baseUrl = getDocumentUrl(projectId, selectedFile)
const separator = baseUrl.includes('?') ? '&' : '?'
setPdfUrl(buildAuthorizedUrl(`${baseUrl}${separator}_=${Date.now()}`))
return
}
await loadMarkdown(selectedFile)
}
const handleMarkdownLink = (event, href) => {
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
return
}
if (href.startsWith('#')) {
return
}
const isMarkdownFile = href.endsWith('.md')
const isPdfFile = href.toLowerCase().endsWith('.pdf')
if (!isMarkdownFile && !isPdfFile) {
return
}
event.preventDefault()
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch (error) {
console.warn('Decode markdown href failed:', error)
}
const targetPath = decodedHref.startsWith('.') || decodedHref.startsWith('..')
? resolveRelativePath(selectedFile, decodedHref)
: (decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref)
openFile(targetPath)
}
useEffect(() => {
loadFileTree()
}, [projectId])
useEffect(() => {
setTocItems(buildTocItems(markdownContent))
}, [markdownContent])
useEffect(() => {
if (fileTree.length === 0) {
return
}
const fileParam = searchParams.get('file')
const keywordParam = searchParams.get('keyword')
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
if (fileParam) {
if (fileParam !== selectedFile) {
openFile(fileParam, { syncUrl: false })
}
return
}
if (!selectedFile) {
const readmeNode = findRootReadme(fileTree)
if (readmeNode) {
openFile(readmeNode.key)
}
}
}, [fileTree, searchParams])
const filteredTreeData = useMemo(
() => filterTreeByKeyword(fileTree, searchKeyword, matchedFilePaths),
[fileTree, searchKeyword, matchedFilePaths]
)
const menuItems = useMemo(
() => convertTreeToMenuItems(filteredTreeData),
[filteredTreeData]
)
const selectedNode = useMemo(
() => findNodeByKey(fileTree, selectedNodeKey),
[fileTree, selectedNodeKey]
)
return {
fileTree,
filteredTreeData,
menuItems,
selectedFile,
selectedNode,
selectedNodeKey,
markdownContent,
loading,
openKeys,
projectName,
projectId,
userRole,
pdfFilename,
pdfUrl,
viewMode,
searchKeyword,
isSearching,
tocItems,
setOpenKeys,
setSearchKeyword,
loadFileTree,
loadMarkdown,
refreshCurrentView,
handleMenuClick,
handleMarkdownLink,
handleSearch,
}
}
export default useDocumentBrowser

View File

@ -0,0 +1,168 @@
import { useEffect, useState } from 'react'
import { getFileContent, getProjectTree } from '@/api/file'
import Toast from '@/components/Toast/Toast'
import { collectParentKeys, findNodeByKey } from './documentBrowserUtils'
function useDocumentEditorWorkspace({ projectId, searchParams, setSearchParams }) {
const [treeData, setTreeData] = useState([])
const [selectedFile, setSelectedFile] = useState(null)
const [selectedNode, setSelectedNode] = useState(null)
const [fileContent, setFileContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [selectedMenuKey, setSelectedMenuKey] = useState(null)
const [isPdfSelected, setIsPdfSelected] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [projectName, setProjectName] = useState('')
const [userRole, setUserRole] = useState('viewer')
const updateFileParam = (filePath) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
}
setSearchParams(nextParams, { replace: true })
}
const fetchTree = async () => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
setTreeData(data.tree || data || [])
setProjectName(data.project_name || '')
setUserRole(data.user_role || 'viewer')
} catch (error) {
Toast.error('加载失败', '加载文件树失败')
}
}
const clearSelection = () => {
setSelectedFile(null)
setSelectedNode(null)
setSelectedMenuKey(null)
setFileContent('')
setIsPdfSelected(false)
updateFileParam(null)
}
const loadEditableFile = async (filePath) => {
setLoading(true)
try {
const res = await getFileContent(projectId, filePath)
setSelectedFile(filePath)
setFileContent(res.data.content)
} catch (error) {
Toast.error('加载失败', '加载文件失败')
} finally {
setLoading(false)
}
}
const openNodeByKey = async (key, node = null, syncUrl = false) => {
const targetNode = node || findNodeByKey(treeData, key)
if (!targetNode) {
return
}
setSelectedNode(targetNode)
setSelectedMenuKey(key)
if (!targetNode.isLeaf) {
if (syncUrl) {
updateFileParam(null)
}
return
}
if (syncUrl) {
updateFileParam(key)
}
setOpenKeys((prev) => Array.from(new Set([...prev, ...collectParentKeys(key)])))
if (key.toLowerCase().endsWith('.pdf')) {
setSelectedFile(key)
setIsPdfSelected(true)
setFileContent('')
return
}
setIsPdfSelected(false)
await loadEditableFile(key)
}
const handleMenuClick = async ({ key }) => {
const node = findNodeByKey(treeData, key)
if (!node) {
return
}
await openNodeByKey(key, node, true)
}
const refreshCurrentDocument = async () => {
setRefreshing(true)
try {
await fetchTree()
if (selectedFile && !isPdfSelected) {
await loadEditableFile(selectedFile)
}
} catch (error) {
console.error('Refresh document editor error:', error)
Toast.error('刷新失败', '请稍后重试')
} finally {
setRefreshing(false)
}
}
useEffect(() => {
fetchTree()
}, [projectId])
useEffect(() => {
if (treeData.length === 0) {
return
}
const fileParam = searchParams.get('file')
if (!fileParam || fileParam === selectedFile) {
return
}
const targetNode = findNodeByKey(treeData, fileParam)
if (!targetNode) {
return
}
openNodeByKey(fileParam, targetNode, false)
}, [treeData, searchParams])
return {
treeData,
selectedFile,
selectedNode,
fileContent,
loading,
openKeys,
selectedMenuKey,
isPdfSelected,
refreshing,
projectName,
userRole,
setOpenKeys,
setSelectedNode,
setSelectedFile,
setSelectedMenuKey,
setFileContent,
fetchTree,
clearSelection,
loadEditableFile,
openNodeByKey,
handleMenuClick,
refreshCurrentDocument,
}
}
export default useDocumentEditorWorkspace

View File

@ -153,14 +153,14 @@
border-radius: 8px;
padding: 12px 16px;
background: #fafafa;
border: 1px solid #f0f0f0;
border: 1px solid var(--border-color);
transition: all 0.3s;
}
.login-form-container .ant-input-affix-wrapper:hover,
.login-form-container .ant-input:hover {
background: #fff;
border-color: #d9d9d9;
border-color: var(--border-color-strong);
}
.login-form-container .ant-input-affix-wrapper-focused,

View File

@ -1,9 +1,8 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd'
import Toast from '@/components/Toast/Toast'
import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty } from 'antd'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
import { MenuFoldOutlined, MenuOutlined, MenuUnfoldOutlined, FileTextOutlined, LockOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
@ -11,10 +10,8 @@ import rehypeSlug from 'rehype-slug'
import 'highlight.js/styles/github.css'
import Mark from 'mark.js'
import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger'
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl, exportPDF } from '@/api/share'
import { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import usePreviewBrowser from './usePreviewBrowser'
import './PreviewPage.css'
const { Sider, Content } = Layout
@ -36,41 +33,42 @@ function PreviewPage() {
const { projectId } = useParams()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const handleClose = () => {
// 退
if (window.history.length > 1) {
navigate(-1)
} else {
navigate('/projects')
}
}
const [projectInfo, setProjectInfo] = useState(null)
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [tocCollapsed, setTocCollapsed] = useState(false)
const [tocItems, setTocItems] = useState([])
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('')
const [accessPassword, setAccessPassword] = useState(null)
const [siderCollapsed, setSiderCollapsed] = useState(false)
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
//
const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const contentRef = useRef(null)
const viewerRef = useRef(null)
const {
projectInfo,
filteredTreeData,
menuItems,
selectedFile,
markdownContent,
loading,
openKeys,
tocItems,
passwordModalVisible,
password,
pdfUrl,
pdfFilename,
viewMode,
searchKeyword,
isSearching,
setOpenKeys,
setPassword,
setSearchKeyword,
setPasswordModalVisible,
handleContentClick,
handleExportPDF,
handleMenuClick,
handleSearch,
handleVerifyPassword,
} = usePreviewBrowser({
projectId,
searchParams,
contentRef,
})
// mark.js
useEffect(() => {
@ -97,397 +95,21 @@ function PreviewPage() {
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
useEffect(() => {
loadProjectInfo()
}, [projectId])
// URL
useEffect(() => {
if (fileTree.length === 0) return
const fileParam = searchParams.get('file')
const keywordParam = searchParams.get('keyword')
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
if (fileParam) {
if (fileParam !== selectedFile) {
// Deep link to file
if (fileParam.toLowerCase().endsWith('.pdf')) {
let url = getPreviewDocumentUrl(projectId, fileParam)
const params = []
if (accessPassword) params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
const token = localStorage.getItem('access_token')
if (token) params.push(`token=${encodeURIComponent(token)}`)
if (params.length > 0) url += `?${params.join('&')}`
setSelectedFile(fileParam)
setPdfUrl(url)
setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf')
} else {
setSelectedFile(fileParam)
loadMarkdown(fileParam, accessPassword)
setViewMode('markdown')
}
// Expand tree to file
const parts = fileParam.split('/')
const allParentPaths = []
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
allParentPaths.push(currentPath)
}
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
}
} else {
if (!selectedFile) {
const readmeNode = findReadme(fileTree)
if (readmeNode) {
setSelectedFile(readmeNode.key)
loadMarkdown(readmeNode.key, accessPassword)
}
}
}
}, [searchParams, fileTree, accessPassword])
//
const loadProjectInfo = async () => {
try {
const res = await getPreviewInfo(projectId)
const info = res.data
setProjectInfo(info)
if (info.has_password) {
setPasswordModalVisible(true)
} else {
loadFileTree()
}
} catch (error) {
console.error('Load project info error:', error)
Toast.error('加载失败', '项目不存在或已被删除')
}
}
//
const handleVerifyPassword = async () => {
if (!password.trim()) {
Toast.warning('提示', '请输入访问密码')
const handleClose = () => {
if (window.history.length > 1) {
navigate(-1)
return
}
try {
await verifyAccessPassword(projectId, password)
setAccessPassword(password)
setPasswordModalVisible(false)
loadFileTree(password)
Toast.success('验证成功')
} catch (error) {
Toast.error('访问密码错误')
}
navigate('/projects')
}
//
const loadFileTree = async (pwd = null) => {
try {
const res = await getPreviewTree(projectId, pwd || accessPassword)
const tree = res.data || []
setFileTree(tree)
} catch (error) {
console.error('Load file tree error:', error)
if (error.response?.status === 403) {
Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true)
}
const handleMenuSelect = ({ key }) => {
handleMenuClick({ key })
if (isMobile) {
setMobileDrawerVisible(false)
}
}
//
const handleSearch = async (value) => {
setSearchKeyword(value)
if (!value.trim()) {
setMatchedFilePaths(new Set())
return
}
setIsSearching(true)
try {
const res = await searchDocuments(value, projectId)
const paths = new Set(res.data.map(item => item.file_path))
setMatchedFilePaths(paths)
// (Assuming this comment might be there or not, better context: keysToExpand)
const keysToExpand = new Set(openKeys)
res.data.forEach(item => {
const parts = item.file_path.split('/')
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
keysToExpand.add(currentPath)
}
})
setOpenKeys(Array.from(keysToExpand))
} catch (error) {
console.error('Search error:', error)
Toast.error('搜索失败', '请稍后重试')
} finally {
setIsSearching(false)
}
}
//
const filteredTreeData = useMemo(() => {
if (!searchKeyword.trim()) return fileTree
const loop = (data) => {
const result = []
for (const node of data) {
const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase())
const contentMatch = matchedFilePaths.has(node.key)
if (node.children) {
const children = loop(node.children)
if (children.length > 0 || titleMatch) {
result.push({ ...node, children })
}
} else {
if (titleMatch || contentMatch) {
result.push(node)
}
}
}
return result
}
return loop(fileTree)
}, [fileTree, searchKeyword, matchedFilePaths])
const findReadme = (nodes) => {
for (const node of nodes) {
if (node.title === 'README.md' && node.isLeaf) {
return node
}
}
return null
}
const convertTreeToMenuItems = (nodes) => {
return nodes.map((node) => {
const labelNode = node.title.replace('.md', '')
if (!node.isLeaf) {
return {
key: node.key,
label: node.title,
icon: <FolderOutlined />,
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
} else if (node.title && node.title.endsWith('.md')) {
return {
key: node.key,
label: labelNode,
icon: <FileTextOutlined />,
}
} else if (node.title && node.title.endsWith('.pdf')) {
return {
key: node.key,
label: node.title,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
}
return null
}).filter(Boolean)
}
const loadMarkdown = async (filePath, pwd = null) => {
setLoading(true)
setTocItems([])
try {
const res = await getPreviewFile(projectId, filePath, pwd || accessPassword)
setMarkdownContent(res.data?.content || '')
if (isMobile) {
setMobileDrawerVisible(false)
}
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
} catch (error) {
console.error('Load markdown error:', error)
if (error.response?.status === 403) {
Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true)
} else {
Toast.error('加载失败', '文档加载失败,请稍后重试')
setMarkdownContent('')
}
} finally {
setLoading(false)
}
}
useEffect(() => {
if (markdownContent) {
const slugger = new GithubSlugger()
const headings = []
const lines = markdownContent.split('\n')
lines.forEach((line) => {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (match) {
const level = match[1].length
const title = match[2]
// 使 github-slugger ID rehype-slug
const key = slugger.slug(title)
headings.push({
key: `#${key}`,
href: `#${key}`,
title,
level,
})
}
})
setTocItems(headings)
}
}, [markdownContent])
const resolveRelativePath = (currentPath, relativePath) => {
if (relativePath.startsWith('/')) {
return relativePath.substring(1)
}
const lastSlashIndex = currentPath.lastIndexOf('/')
const currentDir = lastSlashIndex !== -1 ? currentPath.substring(0, lastSlashIndex) : ''
const parts = relativePath.split('/')
const dirParts = currentDir ? currentDir.split('/') : []
for (const part of parts) {
if (part === '..') {
dirParts.pop()
} else if (part !== '.' && part !== '') {
dirParts.push(part)
}
}
return dirParts.join('/')
}
const handleMarkdownLink = (e, href) => {
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) {
return
}
const isMd = href.endsWith('.md')
const isPdf = href.toLowerCase().endsWith('.pdf')
if (!isMd && !isPdf) return
e.preventDefault()
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch (err) {
}
const targetPath = resolveRelativePath(selectedFile, decodedHref)
const lastSlashIndex = targetPath.lastIndexOf('/')
const parentPath = lastSlashIndex !== -1 ? targetPath.substring(0, lastSlashIndex) : ''
if (parentPath && !openKeys.includes(parentPath)) {
const pathParts = parentPath.split('/')
const allParentPaths = []
let currentPath = ''
for (const part of pathParts) {
currentPath = currentPath ? `${currentPath}/${part}` : part
allParentPaths.push(currentPath)
}
setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
}
handleMenuClick({ key: targetPath })
}
const handleContentClick = (e) => {
const target = e.target.closest('a')
if (target) {
const href = target.getAttribute('href')
if (href) {
handleMarkdownLink(e, href)
}
}
}
const handleMenuClick = ({ key }) => {
setSelectedFile(key)
if (key.toLowerCase().endsWith('.pdf')) {
let url = getPreviewDocumentUrl(projectId, key)
const params = []
if (accessPassword) {
params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
}
const token = localStorage.getItem('access_token')
if (token) {
params.push(`token=${encodeURIComponent(token)}`)
}
if (params.length > 0) {
url += `?${params.join('&')}`
}
setPdfUrl(url)
setPdfFilename(key.split('/').pop())
setViewMode('pdf')
} else {
setViewMode('markdown')
loadMarkdown(key)
}
}
// PDF
const handleExportPDF = () => {
if (viewMode === 'pdf') {
// PDF
const link = document.createElement('a')
link.href = pdfUrl
link.download = pdfFilename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} else {
// Markdown 使 PDF
let url = exportPDF(projectId, selectedFile)
const params = []
if (accessPassword) {
params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
}
const token = localStorage.getItem('access_token')
if (token) {
params.push(`token=${encodeURIComponent(token)}`)
}
if (params.length > 0) {
url += (url.includes('?') ? '&' : '?') + params.join('&')
}
window.open(url, '_blank')
}
}
const menuItems = convertTreeToMenuItems(filteredTreeData)
return (
<div className="preview-page">
<Layout className="preview-layout">
@ -542,7 +164,7 @@ function PreviewPage() {
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
onClick={handleMenuSelect}
className="preview-menu"
/>
) : (
@ -593,7 +215,7 @@ function PreviewPage() {
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
onClick={handleMenuSelect}
className="preview-menu"
/>
) : (

View File

@ -0,0 +1,8 @@
export {
buildTocItems,
collectParentKeys,
convertTreeToMenuItems,
filterTreeByKeyword,
findRootReadme,
resolveRelativePath,
} from '../Document/documentBrowserUtils'

View File

@ -0,0 +1,290 @@
import { useEffect, useMemo, useState } from 'react'
import Toast from '@/components/Toast/Toast'
import {
exportPDF,
getPreviewDocumentUrl,
getPreviewFile,
getPreviewInfo,
getPreviewTree,
verifyAccessPassword,
} from '@/api/share'
import { searchDocuments } from '@/api/search'
import { buildAuthorizedUrl } from '@/utils/authStorage'
import {
buildTocItems,
collectParentKeys,
convertTreeToMenuItems,
filterTreeByKeyword,
findRootReadme,
resolveRelativePath,
} from './previewBrowserUtils'
function usePreviewBrowser({ projectId, searchParams, contentRef }) {
const [projectInfo, setProjectInfo] = useState(null)
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [tocItems, setTocItems] = useState([])
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('')
const [accessPassword, setAccessPassword] = useState(null)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const buildPreviewUrl = (url) => buildAuthorizedUrl(url, { access_pass: accessPassword })
const loadMarkdown = async (filePath, pwd = null) => {
setLoading(true)
setTocItems([])
try {
const res = await getPreviewFile(projectId, filePath, pwd || accessPassword)
setMarkdownContent(res.data?.content || '')
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
} catch (error) {
console.error('Load markdown error:', error)
if (error.response?.status === 403) {
Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true)
} else {
Toast.error('加载失败', '文档加载失败,请稍后重试')
setMarkdownContent('')
}
} finally {
setLoading(false)
}
}
const openFile = async (filePath, pwd = null) => {
setSelectedFile(filePath)
setOpenKeys((prev) => [...new Set([...prev, ...collectParentKeys(filePath)])])
if (filePath.toLowerCase().endsWith('.pdf')) {
setPdfUrl(buildPreviewUrl(getPreviewDocumentUrl(projectId, filePath)))
setPdfFilename(filePath.split('/').pop())
setViewMode('pdf')
return
}
setViewMode('markdown')
await loadMarkdown(filePath, pwd)
}
const loadFileTree = async (pwd = null) => {
try {
const res = await getPreviewTree(projectId, pwd || accessPassword)
setFileTree(res.data || [])
} catch (error) {
console.error('Load file tree error:', error)
if (error.response?.status === 403) {
Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true)
}
}
}
const loadProjectInfo = async () => {
try {
const res = await getPreviewInfo(projectId)
const info = res.data
setProjectInfo(info)
if (info.has_password) {
setPasswordModalVisible(true)
return
}
loadFileTree()
} catch (error) {
console.error('Load project info error:', error)
Toast.error('加载失败', '项目不存在或已被删除')
}
}
const handleVerifyPassword = async () => {
if (!password.trim()) {
Toast.warning('提示', '请输入访问密码')
return
}
try {
await verifyAccessPassword(projectId, password)
setAccessPassword(password)
setPasswordModalVisible(false)
loadFileTree(password)
Toast.success('验证成功')
} catch (error) {
Toast.error('访问密码错误')
}
}
const handleSearch = async (value) => {
setSearchKeyword(value)
if (!value.trim()) {
setMatchedFilePaths(new Set())
return
}
setIsSearching(true)
try {
const res = await searchDocuments(value, projectId)
const paths = new Set(res.data.map((item) => item.file_path))
setMatchedFilePaths(paths)
const expandedKeys = new Set(openKeys)
res.data.forEach((item) => {
collectParentKeys(item.file_path).forEach((parentKey) => expandedKeys.add(parentKey))
})
setOpenKeys(Array.from(expandedKeys))
} catch (error) {
console.error('Search error:', error)
Toast.error('搜索失败', '请稍后重试')
} finally {
setIsSearching(false)
}
}
const handleMenuClick = ({ key }) => {
openFile(key)
}
const handleMarkdownLink = (event, href) => {
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) {
return
}
const isMarkdownFile = href.endsWith('.md')
const isPdfFile = href.toLowerCase().endsWith('.pdf')
if (!isMarkdownFile && !isPdfFile) {
return
}
event.preventDefault()
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch (error) {
console.warn('Decode markdown href failed:', error)
}
const targetPath = decodedHref.startsWith('.') || decodedHref.startsWith('..')
? resolveRelativePath(selectedFile, decodedHref)
: (decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref)
setOpenKeys((prev) => [...new Set([...prev, ...collectParentKeys(targetPath)])])
handleMenuClick({ key: targetPath })
}
const handleContentClick = (event) => {
const target = event.target.closest('a')
if (!target) {
return
}
const href = target.getAttribute('href')
if (href) {
handleMarkdownLink(event, href)
}
}
const handleExportPDF = () => {
if (viewMode === 'pdf') {
const link = document.createElement('a')
link.href = pdfUrl
link.download = pdfFilename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
return
}
window.open(buildPreviewUrl(exportPDF(projectId, selectedFile)), '_blank')
}
useEffect(() => {
loadProjectInfo()
}, [projectId])
useEffect(() => {
setTocItems(buildTocItems(markdownContent))
}, [markdownContent])
useEffect(() => {
if (fileTree.length === 0) {
return
}
if (projectInfo?.has_password && !accessPassword) {
return
}
const fileParam = searchParams.get('file')
const keywordParam = searchParams.get('keyword')
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
if (fileParam) {
if (fileParam !== selectedFile) {
openFile(fileParam, accessPassword)
}
return
}
if (!selectedFile) {
const readmeNode = findRootReadme(fileTree)
if (readmeNode) {
openFile(readmeNode.key, accessPassword)
}
}
}, [searchParams, fileTree, accessPassword, projectInfo])
const filteredTreeData = useMemo(
() => filterTreeByKeyword(fileTree, searchKeyword, matchedFilePaths),
[fileTree, searchKeyword, matchedFilePaths]
)
const menuItems = useMemo(
() => convertTreeToMenuItems(filteredTreeData),
[filteredTreeData]
)
return {
projectInfo,
filteredTreeData,
menuItems,
selectedFile,
markdownContent,
loading,
openKeys,
tocItems,
passwordModalVisible,
password,
pdfUrl,
pdfFilename,
viewMode,
searchKeyword,
isSearching,
setOpenKeys,
setPassword,
setSearchKeyword,
setPasswordModalVisible,
handleContentClick,
handleExportPDF,
handleMenuClick,
handleSearch,
handleVerifyPassword,
}
}
export default usePreviewBrowser

View File

@ -1,19 +1,23 @@
.project-list-container {
padding: 24px;
}
.project-list-header {
.project-list-card .ant-card-body {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-direction: column;
gap: 16px;
}
.project-list-header h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--text-color);
.project-results-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.project-results-summary {
color: var(--text-color-secondary);
font-size: 14px;
}
.project-search-file-meta {
font-size: 12px;
color: var(--text-color-secondary);
}
.project-card {
@ -28,7 +32,7 @@
.project-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: var(--panel-shadow);
}
.project-card-public-badge {
@ -76,6 +80,16 @@
color: var(--text-color-secondary);
}
.project-empty-state {
padding: 12px 0;
}
.project-list-pagination {
display: flex;
justify-content: center;
margin-top: 8px;
}
/* 圆点分页指示器样式 */
.dot-pagination.ant-pagination {
display: flex;

View File

@ -1,43 +1,85 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined } from '@ant-design/icons'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import { getUserList } from '@/api/users'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, SwapOutlined, DownloadOutlined } from '@ant-design/icons'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject } from '@/api/project'
import { searchDocuments } from '@/api/search'
import ListActionBar from '@/components/ListActionBar/ListActionBar'
import PageHeader from '@/components/PageHeader/PageHeader'
import Toast from '@/components/Toast/Toast'
import useProjectExport from './useProjectExport'
import useProjectShare from './useProjectShare'
import useProjectCollaboration from './useProjectCollaboration'
import useProjectGitRepos from './useProjectGitRepos'
import './ProjectList.css'
import '@/pages/System/AdminPages.css'
function ProjectList({ type = 'my' }) {
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editModalVisible, setEditModalVisible] = useState(false)
const [gitModalVisible, setGitModalVisible] = useState(false)
const [shareModalVisible, setShareModalVisible] = useState(false)
const [membersModalVisible, setMembersModalVisible] = useState(false)
const [currentProject, setCurrentProject] = useState(null)
const [shareInfo, setShareInfo] = useState(null)
const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const [members, setMembers] = useState([])
const [users, setUsers] = useState([])
const [loadingMembers, setLoadingMembers] = useState(false)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [gitForm] = Form.useForm()
const [memberForm] = Form.useForm()
const [transferModalVisible, setTransferModalVisible] = useState(false)
const [transferForm] = Form.useForm()
const navigate = useNavigate()
const [currentPage, setCurrentPage] = useState(1)
const pageSize = 8
const {
exportModalVisible,
exportingProject,
exportTask,
closeExportModal,
exportProject,
} = useProjectExport()
const {
shareModalVisible,
shareInfo,
hasPassword,
password,
setPassword,
openShareModal,
closeShareModal,
copyShareLink,
togglePassword,
savePassword,
showPasswordControls,
} = useProjectShare({ type })
const {
membersModalVisible,
members,
users,
loadingMembers,
transferModalVisible,
loadTransferCandidates,
submitTransfer,
closeTransferModal,
openMembersModal,
closeMembersModal,
addMember,
removeMember,
} = useProjectCollaboration()
const [repoForm] = Form.useForm()
const {
gitRepos,
loadingRepos,
gitModalVisible,
gitRepoModalVisible,
editingRepo,
openGitSettings,
closeGitSettings,
openAddRepoModal,
openEditRepoModal,
closeGitRepoModal,
deleteRepo,
saveRepo,
} = useProjectGitRepos()
useEffect(() => {
fetchProjects()
@ -51,43 +93,27 @@ function ProjectList({ type = 'my' }) {
// ... (fetchProjects code)
const handleOpenTransfer = async () => {
setLoadingMembers(true)
setTransferModalVisible(true)
try {
// ()
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
const allUsers = res.data || []
// API
setUsers(allUsers.filter(u => u.id !== currentProject.owner_id))
} catch (error) {
message.error('加载用户列表失败')
} finally {
setLoadingMembers(false)
}
if (!currentProject) return
await loadTransferCandidates(currentProject)
}
const handleTransfer = async (values) => {
Modal.confirm({
title: '确认转移',
content: '确定要将项目所有权转移给该用户吗?转移后您将变为管理员,无法再删除项目或转移所有权。',
okText: '确认转移',
okType: 'danger',
onOk: async () => {
try {
await transferProject(currentProject.id, values.new_owner_id)
message.success('项目所有权已转移')
setTransferModalVisible(false)
setEditModalVisible(false)
transferForm.resetFields()
fetchProjects()
} catch (error) {
console.error('Transfer error:', error)
message.error('转移失败: ' + (error.response?.data?.detail || error.message))
}
}
if (!currentProject?.id) return
await submitTransfer({
projectId: currentProject.id,
newOwnerId: values.new_owner_id,
onCompleted: () => {
setEditModalVisible(false)
transferForm.resetFields()
fetchProjects()
},
})
}
const handleExportProject = async () => {
await exportProject(currentProject)
}
const fetchProjects = async () => {
setLoading(true)
try {
@ -169,94 +195,35 @@ function ProjectList({ type = 'my' }) {
}
}
const [gitRepos, setGitRepos] = useState([])
const [loadingRepos, setLoadingRepos] = useState(false)
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
const [editingRepo, setEditingRepo] = useState(null)
const [repoForm] = Form.useForm()
// Git
const handleGitSettings = (e, project) => {
const handleGitSettings = async (e, project) => {
e.stopPropagation()
setCurrentProject(project)
setGitModalVisible(true)
fetchGitRepos(project.id)
}
// Git
const fetchGitRepos = async (projectId) => {
setLoadingRepos(true)
try {
const res = await getGitRepos(projectId)
setGitRepos(res.data || [])
} catch (error) {
console.error('Fetch git repos error:', error)
message.error('加载Git仓库失败')
} finally {
setLoadingRepos(false)
}
await openGitSettings(project.id)
}
//
const handleAddRepo = () => {
setEditingRepo(null)
repoForm.resetFields()
//
if (gitRepos.length === 0) {
repoForm.setFieldsValue({ is_default: 1 })
}
setGitRepoModalVisible(true)
openAddRepoModal({ repoForm, gitRepos })
}
//
const handleEditRepo = (repo) => {
setEditingRepo(repo)
repoForm.setFieldsValue({
...repo,
is_default: repo.is_default === 1,
})
setGitRepoModalVisible(true)
openEditRepoModal({ repo, repoForm })
}
//
const handleDeleteRepo = (repoId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个Git仓库配置吗',
onOk: async () => {
try {
await deleteGitRepo(currentProject.id, repoId)
message.success('删除成功')
fetchGitRepos(currentProject.id)
} catch (error) {
console.error('Delete repo error:', error)
message.error('删除失败')
}
},
})
if (!currentProject?.id) return
deleteRepo({ projectId: currentProject.id, repoId })
}
// /
const handleSaveRepo = async (values) => {
try {
const data = {
...values,
is_default: values.is_default ? 1 : 0,
}
if (editingRepo) {
await updateGitRepo(currentProject.id, editingRepo.id, data)
message.success('更新成功')
} else {
await createGitRepo(currentProject.id, data)
message.success('添加成功')
}
setGitRepoModalVisible(false)
fetchGitRepos(currentProject.id)
} catch (error) {
console.error('Save repo error:', error)
message.error(editingRepo ? '更新失败' : '添加失败')
if (!currentProject?.id) return
const succeeded = await saveRepo({ projectId: currentProject.id, values })
if (succeeded) {
closeGitRepoModal({ repoForm })
}
}
@ -268,161 +235,46 @@ function ProjectList({ type = 'my' }) {
const handleShare = async (e, project) => {
e.stopPropagation()
setCurrentProject(project)
try {
const res = await getProjectShareInfo(project.id)
setShareInfo(res.data)
setHasPassword(res.data.has_password)
setPassword('')
setShareModalVisible(true)
} catch (error) {
console.error('Get share info error:', error)
message.error('获取分享信息失败')
}
await openShareModal(project)
}
//
const handleCopyLink = async () => {
if (!shareInfo) return
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(fullUrl)
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
// Fallback for non-secure contexts or older browsers
const textArea = document.createElement("textarea")
textArea.value = fullUrl
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
textArea.style.top = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (successful) {
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
Toast.error('复制失败', '请手动复制链接')
}
}
} catch (err) {
console.error('Failed to copy:', err)
Toast.error('复制失败', '无法访问剪贴板')
}
await copyShareLink()
}
//
const handlePasswordToggle = async (checked) => {
if (!checked) {
//
try {
await updateShareSettings(currentProject.id, { access_pass: null })
setHasPassword(false)
setPassword('')
message.success('已取消访问密码')
//
const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data)
} catch (error) {
console.error('Update settings error:', error)
message.error('操作失败')
}
} else {
setHasPassword(true)
}
if (!currentProject?.id) return
await togglePassword(currentProject.id, checked)
}
//
const handleSavePassword = async () => {
if (!password.trim()) {
message.warning('请输入访问密码')
if (!currentProject?.id) {
return
}
try {
await updateShareSettings(currentProject.id, { access_pass: password })
message.success('访问密码已设置')
//
const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data)
setHasPassword(true)
} catch (error) {
console.error('Save password error:', error)
message.error('设置密码失败')
}
await savePassword(currentProject.id)
}
//
const handleMembers = async (e, project) => {
e.stopPropagation()
setCurrentProject(project)
setMembersModalVisible(true)
setLoadingMembers(true)
try {
// role_id=3
const [membersRes, usersRes] = await Promise.all([
getProjectMembers(project.id),
getUserList({ page: 1, page_size: 100, status: 1, role_id: 3 })
])
console.log('Members Response:', membersRes)
console.log('Users Response:', usersRes)
const membersData = membersRes.data || []
// : { code: 200, message: "success", data: [...], total, page, page_size }
const usersData = Array.isArray(usersRes.data) ? usersRes.data : []
console.log('Setting members:', membersData)
console.log('Setting users:', usersData)
setMembers(membersData)
setUsers(usersData)
} catch (error) {
console.error('Get members error:', error)
console.error('Error details:', error.response)
message.error('获取数据失败: ' + (error.response?.data?.detail || error.message))
} finally {
setLoadingMembers(false)
}
await openMembersModal(project)
}
//
const handleAddMember = async (values) => {
try {
await addProjectMember(currentProject.id, values)
message.success('成员添加成功')
memberForm.resetFields()
//
const res = await getProjectMembers(currentProject.id)
setMembers(res.data || [])
} catch (error) {
console.error('Add member error:', error)
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
message.error(errorMsg)
}
if (!currentProject?.id) return
await addMember(currentProject.id, values)
memberForm.resetFields()
}
//
const handleRemoveMember = async (userId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个成员吗?',
onOk: async () => {
try {
await removeProjectMember(currentProject.id, userId)
message.success('成员删除成功')
//
const res = await getProjectMembers(currentProject.id)
setMembers(res.data || [])
} catch (error) {
console.error('Remove member error:', error)
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
message.error(errorMsg)
}
},
})
if (!currentProject?.id) return
await removeMember(currentProject.id, userId)
}
//
@ -480,157 +332,180 @@ function ProjectList({ type = 'my' }) {
: projects
const paginatedProjects = filteredProjects.slice((currentPage - 1) * pageSize, currentPage * pageSize)
const pageConfig = {
my: {
title: '我的项目',
description: '管理你创建的项目,支持搜索、协作、分享与整体导出。',
icon: <FolderOutlined />,
},
share: {
title: '参与项目',
description: '查看与你协作的项目、当前角色权限以及可访问的文档内容。',
icon: <TeamOutlined />,
},
all: {
title: '项目列表',
description: '统一浏览当前可访问的全部项目。',
icon: <FolderOutlined />,
},
}[type] || {
title: '项目列表',
description: '统一浏览当前可访问的全部项目。',
icon: <FolderOutlined />,
}
return (
<div className="project-list-container">
<ListActionBar
actions={type === 'my' ? [
{
key: 'create',
label: '创建项目',
type: 'primary',
icon: <PlusOutlined />,
onClick: () => setModalVisible(true),
},
] : []}
search={{
placeholder: '搜索项目或文件...',
value: searchKeyword,
onChange: handleSearchChange,
onSearch: handleSearch,
}}
showRefresh
onRefresh={fetchProjects}
<div className="admin-page">
<PageHeader
title={pageConfig.title}
description={pageConfig.description}
icon={pageConfig.icon}
extra={type === 'my' ? (
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
创建项目
</Button>
) : null}
/>
{/* 搜索结果 */}
{hasSearched && searchResults.length > 0 && (
<div style={{ marginTop: 16, marginBottom: 16 }}>
<div style={{ marginBottom: 8, color: '#666' }}>
找到 {searchResults.length} 个结果
</div>
<Row gutter={[16, 16]}>
{searchResults.map((item, index) => (
<Col xs={24} sm={12} md={8} lg={6} key={`${item.type}-${item.project_id}-${index}`}>
<Card
hoverable
className="project-card"
onClick={() => handleSearchResultClick(item)}
>
<div className="project-card-icon">
{item.type === 'project' ? (
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
) : (
<FileOutlined style={{ fontSize: 48, color: '#52c41a' }} />
)}
</div>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<h3 style={{ margin: 0 }}>
{item.type === 'project' ? item.project_name : item.file_name}
</h3>
<Tag color={item.type === 'project' ? 'blue' : 'green'}>
{item.match_type}
</Tag>
</Space>
{item.type === 'project' && (
<p className="project-description">
{item.project_description || '暂无描述'}
</p>
)}
{item.type === 'file' && (
<div style={{ fontSize: 12, color: '#666' }}>
<div>项目: {item.project_name}</div>
<div>路径: {item.file_path}</div>
</div>
)}
</Space>
</Card>
</Col>
))}
</Row>
</div>
)}
<Card className="admin-card project-list-card">
<ListActionBar
search={{
placeholder: '搜索项目或文件...',
value: searchKeyword,
onChange: handleSearchChange,
onSearch: handleSearch,
}}
showRefresh
onRefresh={fetchProjects}
/>
{/* 正常项目列表 */}
{!hasSearched && (
<>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
{paginatedProjects.map((project) => (
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
<Card
hoverable
className="project-card"
onClick={() => handleOpenProject(project.id)}
actions={type === 'my' ? [
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
] : [
<EyeOutlined key="view" />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
]}
>
{/* 公开项目标识 */}
{project.is_public === 1 && (
<div className="project-card-public-badge">公开</div>
)}
<div className="project-card-icon">
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
</div>
<h3>{project.name}</h3>
<p className="project-description">{project.description || '暂无描述'}</p>
<div className="project-meta">
<span>文档数: {project.doc_count || 0}</span>
{type === 'share' && project.owner_name && (
<span style={{ marginLeft: 12 }}>
所有者: {project.owner_nickname || project.owner_name}
</span>
)}
{type === 'share' && project.user_role && (
<span style={{ marginLeft: 12 }}>
角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'}
</span>
)}
</div>
</Card>
</Col>
))}
{filteredProjects.length === 0 && !loading && (
<Col span={24}>
<Empty description={type === 'my' ? "还没有项目,创建一个开始吧" : "还没有参与的项目"} />
</Col>
)}
</Row>
{filteredProjects.length > 0 && (
<div style={{ marginTop: 24, display: 'flex', justifyContent: 'center' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={filteredProjects.length}
onChange={setCurrentPage}
showSizeChanger={false}
className="dot-pagination"
itemRender={(page, type, originalElement) => {
if (type === 'page') {
return <span className="pagination-dot" />
}
return originalElement
}}
/>
{hasSearched && searchResults.length > 0 && (
<div className="project-results-section">
<div className="project-results-summary">
找到 {searchResults.length} 个结果
</div>
)}
</>
)}
<Row gutter={[16, 16]}>
{searchResults.map((item, index) => (
<Col xs={24} sm={12} md={8} lg={6} key={`${item.type}-${item.project_id}-${index}`}>
<Card
hoverable
className="project-card"
onClick={() => handleSearchResultClick(item)}
>
<div className="project-card-icon">
{item.type === 'project' ? (
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
) : (
<FileOutlined style={{ fontSize: 48, color: '#52c41a' }} />
)}
</div>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<h3 style={{ margin: 0 }}>
{item.type === 'project' ? item.project_name : item.file_name}
</h3>
<Tag color={item.type === 'project' ? 'blue' : 'green'}>
{item.match_type}
</Tag>
</Space>
{item.type === 'project' && (
<p className="project-description">
{item.project_description || '暂无描述'}
</p>
)}
{item.type === 'file' && (
<div className="project-search-file-meta">
<div>项目: {item.project_name}</div>
<div>路径: {item.file_path}</div>
</div>
)}
</Space>
</Card>
</Col>
))}
</Row>
</div>
)}
{/* 搜索无结果提示 */}
{hasSearched && !searching && searchResults.length === 0 && (
<div style={{ marginTop: 16 }}>
<Empty description={`没有找到包含 "${searchKeyword}" 的项目或文件`} />
</div>
)}
{!hasSearched && (
<>
<Row gutter={[16, 16]}>
{paginatedProjects.map((project) => (
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
<Card
hoverable
className="project-card"
onClick={() => handleOpenProject(project.id)}
actions={type === 'my' ? [
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
] : [
<EyeOutlined key="view" />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
]}
>
{project.is_public === 1 && (
<div className="project-card-public-badge">公开</div>
)}
<div className="project-card-icon">
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
</div>
<h3>{project.name}</h3>
<p className="project-description">{project.description || '暂无描述'}</p>
<div className="project-meta">
<span>文档数: {project.doc_count || 0}</span>
{type === 'share' && project.owner_name && (
<span style={{ marginLeft: 12 }}>
所有者: {project.owner_nickname || project.owner_name}
</span>
)}
{type === 'share' && project.user_role && (
<span style={{ marginLeft: 12 }}>
角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'}
</span>
)}
</div>
</Card>
</Col>
))}
{filteredProjects.length === 0 && !loading && (
<Col span={24}>
<div className="project-empty-state">
<Empty description={type === 'my' ? '还没有项目,创建一个开始吧' : '还没有参与的项目'} />
</div>
</Col>
)}
</Row>
{filteredProjects.length > 0 && (
<div className="project-list-pagination">
<Pagination
current={currentPage}
pageSize={pageSize}
total={filteredProjects.length}
onChange={setCurrentPage}
showSizeChanger={false}
className="dot-pagination"
itemRender={(page, type, originalElement) => {
if (type === 'page') {
return <span className="pagination-dot" />
}
return originalElement
}}
/>
</div>
)}
</>
)}
{hasSearched && !searching && searchResults.length === 0 && (
<div className="project-empty-state">
<Empty description={`没有找到包含 "${searchKeyword}" 的项目或文件`} />
</div>
)}
</Card>
<Modal
title="创建新项目"
@ -732,14 +607,21 @@ function ProjectList({ type = 'my' }) {
handleDeleteProject(currentProject.id)
}}
>
删除项目
删除
</Button>
<Button
danger
icon={<SwapOutlined />}
onClick={handleOpenTransfer}
>
转移所有权
转移
</Button>
<Button
icon={<DownloadOutlined />}
onClick={handleExportProject}
loading={exportingProject}
>
导出
</Button>
</Space>
<Space>
@ -758,11 +640,47 @@ function ProjectList({ type = 'my' }) {
</Form>
</Modal>
<Modal
title="导出项目"
open={exportModalVisible}
onCancel={closeExportModal}
footer={
exportingProject
? null
: [
<Button
key="close"
onClick={closeExportModal}
>
关闭
</Button>,
]
}
closable={!exportingProject}
maskClosable={!exportingProject}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>{exportTask?.message || '正在准备导出任务...'}</div>
<Progress
percent={Math.max(0, Math.min(exportTask?.progress || 0, 100))}
status={exportTask?.status === 'failed' ? 'exception' : exportingProject ? 'active' : 'success'}
/>
<div style={{ color: '#666', fontSize: 13 }}>
已处理 {exportTask?.processed_files || 0} / {exportTask?.total_files || 0} 个文件
</div>
{exportTask?.status === 'failed' && (
<div style={{ color: '#ff4d4f', fontSize: 13 }}>
{exportTask?.error || '项目导出失败'}
</div>
)}
</Space>
</Modal>
<Modal
title="转移项目所有权"
open={transferModalVisible}
onCancel={() => {
setTransferModalVisible(false)
closeTransferModal()
transferForm.resetFields()
}}
onOk={() => transferForm.submit()}
@ -800,7 +718,7 @@ function ProjectList({ type = 'my' }) {
<Modal
title="分享设置"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
onCancel={closeShareModal}
footer={null}
width={500}
>
@ -818,7 +736,7 @@ function ProjectList({ type = 'my' }) {
</div>
{/* 只有在我的项目中才显示密码设置功能 */}
{type === 'my' && (
{showPasswordControls && (
<>
<div>
<Space>
@ -860,10 +778,8 @@ function ProjectList({ type = 'my' }) {
title="成员管理"
open={membersModalVisible}
onCancel={() => {
setMembersModalVisible(false)
closeMembersModal()
memberForm.resetFields()
setMembers([])
setUsers([])
}}
footer={null}
width={700}
@ -995,10 +911,7 @@ function ProjectList({ type = 'my' }) {
<Modal
title="Git仓库管理"
open={gitModalVisible}
onCancel={() => {
setGitModalVisible(false)
setGitRepos([])
}}
onCancel={closeGitSettings}
footer={null}
width={800}
>
@ -1055,10 +968,7 @@ function ProjectList({ type = 'my' }) {
<Modal
title={editingRepo ? "编辑仓库" : "添加仓库"}
open={gitRepoModalVisible}
onCancel={() => {
setGitRepoModalVisible(false)
repoForm.resetFields()
}}
onCancel={() => closeGitRepoModal({ repoForm })}
footer={null}
>
<Form
@ -1116,7 +1026,7 @@ function ProjectList({ type = 'my' }) {
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setGitRepoModalVisible(false)}>取消</Button>
<Button onClick={() => closeGitRepoModal({ repoForm })}>取消</Button>
<Button type="primary" htmlType="submit">保存</Button>
</Space>
</Form.Item>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,698 @@
import { useEffect, useState } from 'react'
import {
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Tag,
} from 'antd'
import {
CloudServerOutlined,
ExperimentOutlined,
PlusOutlined,
ReloadOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons'
import {
createLLMModelConfig,
deleteLLMModelConfig,
getLLMModelConfigDetail,
getLLMModelConfigs,
getLLMProviderCatalog,
testLLMModelConfig,
updateLLMModelConfig,
updateLLMModelConfigStatus,
} from '@/api/llmModelConfigs'
import ListTable from '@/components/ListTable/ListTable'
import PageHeader from '@/components/PageHeader/PageHeader'
import Toast from '@/components/Toast/Toast'
import './ModelConfigs.css'
import '@/pages/System/AdminPages.css'
const { Search, TextArea } = Input
function buildModelCode(provider, llmModelName) {
const providerPart = (provider || 'custom').trim().toLowerCase()
const modelPart = (llmModelName || '').trim().toLowerCase()
const sanitized = []
let previousSeparator = false
for (const char of modelPart) {
if (/[a-z0-9]/.test(char)) {
sanitized.push(char)
previousSeparator = false
} else if (!previousSeparator) {
sanitized.push('_')
previousSeparator = true
}
}
const suffix = sanitized.join('').replace(/^_+|_+$/g, '') || 'model'
return `llm_${providerPart}_${suffix}`
}
function buildModelName(providerMeta, provider, llmModelName) {
const label = providerMeta?.label || provider || '自定义模型'
const modelPart = (llmModelName || '').trim()
if (!modelPart) {
return label
}
return `${label} ${modelPart}`
}
function formatDateTime(value) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN')
}
function ModelConfigs() {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [testing, setTesting] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [providerFilter, setProviderFilter] = useState(undefined)
const [statusFilter, setStatusFilter] = useState(undefined)
const [configs, setConfigs] = useState([])
const [providerCatalog, setProviderCatalog] = useState([])
const [modalVisible, setModalVisible] = useState(false)
const [editingConfigId, setEditingConfigId] = useState(null)
const [autoFillFlags, setAutoFillFlags] = useState({
endpointUrl: true,
modelName: true,
modelCode: true,
})
const providerValue = Form.useWatch('provider', form)
const llmModelNameValue = Form.useWatch('llm_model_name', form)
useEffect(() => {
loadProviderCatalog()
}, [])
useEffect(() => {
loadConfigs()
}, [page, pageSize, keyword, providerFilter, statusFilter])
useEffect(() => {
if (!modalVisible || !providerValue) {
return
}
const providerMeta = providerCatalog.find((item) => item.value === providerValue)
if (!providerMeta) {
return
}
const currentValues = form.getFieldsValue()
const nextValues = {}
if (autoFillFlags.endpointUrl) {
const nextEndpointUrl = providerMeta.default_endpoint_url || ''
if (nextEndpointUrl !== currentValues.endpoint_url) {
nextValues.endpoint_url = nextEndpointUrl
}
}
if (llmModelNameValue) {
if (autoFillFlags.modelName) {
const nextModelName = buildModelName(providerMeta, providerValue, llmModelNameValue)
if (nextModelName !== currentValues.model_name) {
nextValues.model_name = nextModelName
}
}
if (autoFillFlags.modelCode) {
const nextModelCode = buildModelCode(providerValue, llmModelNameValue)
if (nextModelCode !== currentValues.model_code) {
nextValues.model_code = nextModelCode
}
}
}
if (Object.keys(nextValues).length > 0) {
form.setFieldsValue(nextValues)
}
}, [modalVisible, providerValue, llmModelNameValue, providerCatalog, autoFillFlags, form])
const loadProviderCatalog = async () => {
try {
const res = await getLLMProviderCatalog()
setProviderCatalog(res.data || [])
} catch (error) {
console.error('Load LLM provider catalog error:', error)
Toast.error('加载提供方列表失败')
}
}
const loadConfigs = async () => {
try {
setLoading(true)
const params = {
page,
page_size: pageSize,
}
if (keyword) params.keyword = keyword
if (providerFilter) params.provider = providerFilter
if (statusFilter !== undefined) params.is_active = statusFilter
const res = await getLLMModelConfigs(params)
setConfigs(res.data || [])
setTotal(res.total || 0)
} catch (error) {
console.error('Load llm model configs error:', error)
Toast.error('加载模型配置失败')
} finally {
setLoading(false)
}
}
const getProviderMeta = (provider) => providerCatalog.find((item) => item.value === provider)
const openCreateModal = () => {
const defaultProvider = providerCatalog[0]?.value || 'openai'
const defaultEndpointUrl = getProviderMeta(defaultProvider)?.default_endpoint_url || ''
setEditingConfigId(null)
setAutoFillFlags({
endpointUrl: true,
modelName: true,
modelCode: true,
})
form.setFieldsValue({
provider: defaultProvider,
endpoint_url: defaultEndpointUrl,
llm_timeout: 120,
llm_temperature: 0.7,
llm_top_p: 0.9,
llm_max_tokens: 2048,
is_active: true,
is_default: false,
description: '',
llm_system_prompt: '',
model_name: '',
model_code: '',
llm_model_name: '',
api_key: '',
})
setModalVisible(true)
}
const openEditModal = async (record) => {
try {
const res = await getLLMModelConfigDetail(record.config_id)
const detail = res.data
const providerMeta = getProviderMeta(detail.provider)
setEditingConfigId(record.config_id)
setAutoFillFlags({
endpointUrl: !detail.endpoint_url || detail.endpoint_url === (providerMeta?.default_endpoint_url || ''),
modelName: !detail.model_name || detail.model_name === buildModelName(providerMeta, detail.provider, detail.llm_model_name),
modelCode: !detail.model_code || detail.model_code === buildModelCode(detail.provider, detail.llm_model_name),
})
form.setFieldsValue({
...detail,
api_key: detail.api_key || '',
})
setModalVisible(true)
} catch (error) {
console.error('Load llm model config detail error:', error)
Toast.error('加载模型配置详情失败')
}
}
const closeModal = () => {
setModalVisible(false)
setEditingConfigId(null)
form.resetFields()
}
const handleSubmit = async (values) => {
try {
setSubmitting(true)
if (editingConfigId) {
await updateLLMModelConfig(editingConfigId, values)
Toast.success('模型配置更新成功')
} else {
await createLLMModelConfig(values)
Toast.success('模型配置创建成功')
}
closeModal()
loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '保存模型配置失败')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (record) => {
try {
await deleteLLMModelConfig(record.config_id)
Toast.success('模型配置删除成功')
loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '删除模型配置失败')
}
}
const handleStatusChange = async (record, checked) => {
try {
await updateLLMModelConfigStatus(record.config_id, checked)
Toast.success(checked ? '模型已启用' : '模型已停用')
loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '更新状态失败')
}
}
const showTestResult = (result) => {
Modal.info({
title: '模型测试成功',
width: 620,
content: (
<div className="model-config-test-result">
<div>提供方{getProviderMeta(result.provider)?.label || result.provider}</div>
<div>模型{result.llm_model_name}</div>
<div>延迟{result.latency_ms} ms</div>
<div>返回预览{result.preview || '模型已成功返回,但未提取到文本内容。'}</div>
</div>
),
})
}
const handleFormTest = async () => {
try {
const values = await form.validateFields()
setTesting(true)
const res = await testLLMModelConfig(values)
showTestResult(res.data)
} catch (error) {
if (error?.errorFields) {
return
}
Toast.error(error.response?.data?.detail || '模型测试失败')
} finally {
setTesting(false)
}
}
const columns = [
{
title: '模型名称',
dataIndex: 'model_name',
key: 'model_name',
width: 240,
render: (_, record) => (
<Space direction="vertical" size={2}>
<span>{record.model_name}</span>
<span className="model-config-code">{record.model_code}</span>
</Space>
),
},
{
title: '提供方',
dataIndex: 'provider',
key: 'provider',
width: 140,
render: (provider) => (
<Tag color="blue">{getProviderMeta(provider)?.label || provider || '-'}</Tag>
),
},
{
title: '模型标识',
dataIndex: 'llm_model_name',
key: 'llm_model_name',
width: 180,
},
{
title: 'Base URL',
dataIndex: 'endpoint_url',
key: 'endpoint_url',
ellipsis: true,
render: (value) => value || '-',
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 100,
render: (value, record) => (
<Switch
checked={value}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => handleStatusChange(record, checked)}
/>
),
},
{
title: '默认',
dataIndex: 'is_default',
key: 'is_default',
width: 90,
render: (value) => (
value ? <Tag color="gold">默认</Tag> : <Tag></Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 180,
render: (value) => formatDateTime(value),
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: 170,
render: (_, record) => (
<Space size="small" className="model-config-actions">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
>
编辑
</Button>
<Popconfirm
title="确认删除该模型配置?"
description="删除后将无法恢复。"
okText="删除"
cancelText="取消"
onConfirm={() => handleDelete(record)}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
删除
</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div className="admin-page">
<PageHeader
title="模型配置"
description="管理系统可用的大模型提供方、默认接口地址、参数模板与连通性测试。"
icon={<CloudServerOutlined />}
extra={(
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
>
新增模型
</Button>
)}
/>
<Card className="admin-card model-config-card">
<div className="admin-toolbar">
<div className="admin-toolbar-left">
<Search
allowClear
placeholder="搜索模型名称、编码或模型标识"
value={keyword}
style={{ width: 320 }}
onChange={(event) => {
setPage(1)
setKeyword(event.target.value)
}}
onSearch={(value) => {
setPage(1)
setKeyword(value)
}}
/>
<Select
allowClear
placeholder="筛选提供方"
style={{ width: 180 }}
value={providerFilter}
options={providerCatalog.map((item) => ({
label: item.label,
value: item.value,
}))}
onChange={(value) => {
setPage(1)
setProviderFilter(value)
}}
/>
<Select
allowClear
placeholder="筛选状态"
style={{ width: 140 }}
value={statusFilter}
options={[
{ label: '启用', value: true },
{ label: '停用', value: false },
]}
onChange={(value) => {
setPage(1)
setStatusFilter(value)
}}
/>
</div>
<div className="admin-toolbar-right">
<Button icon={<ReloadOutlined />} onClick={loadConfigs}>
刷新
</Button>
</div>
</div>
<ListTable
rowKey="config_id"
className="model-config-table"
loading={loading}
columns={columns}
dataSource={configs}
scroll={{ x: 1280 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
showTotal: (value) => `${value}`,
}}
/>
</Card>
<Modal
title={editingConfigId ? '编辑模型配置' : '新增模型配置'}
open={modalVisible}
width={860}
className="model-config-modal"
style={{ maxWidth: 'calc(100vw - 32px)' }}
destroyOnClose
onCancel={closeModal}
onOk={() => form.submit()}
confirmLoading={submitting}
okText={editingConfigId ? '保存修改' : '创建'}
cancelText="取消"
styles={{
body: {
maxHeight: 'calc(80vh - 180px)',
overflowY: 'auto',
paddingRight: 8,
},
}}
footer={(_, { OkBtn, CancelBtn }) => (
<>
<CancelBtn />
<Button
icon={<ExperimentOutlined />}
loading={testing}
onClick={handleFormTest}
>
测试模型
</Button>
<OkBtn />
</>
)}
>
<div className="model-config-modal-hint">
<strong>自动生成规则</strong> 选择提供方后会自动带出默认 `base_url`填写模型标识后会自动生成模型名称模型编码
如果你手动改过这些字段后续就不会再被自动覆盖
</div>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
llm_timeout: 120,
llm_temperature: 0.7,
llm_top_p: 0.9,
llm_max_tokens: 2048,
is_active: true,
is_default: false,
}}
>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Form.Item
label="提供方"
name="provider"
rules={[{ required: true, message: '请选择模型提供方' }]}
>
<Select
showSearch
placeholder="请选择模型提供方"
optionFilterProp="label"
options={providerCatalog.map((item) => ({
label: item.label,
value: item.value,
}))}
/>
</Form.Item>
<Form.Item
label="Base URL"
name="endpoint_url"
rules={[{ required: true, message: '请输入 base_url' }]}
>
<Input
placeholder="选择提供方后自动带出,也可以手动覆盖"
onChange={() => {
setAutoFillFlags((current) => ({ ...current, endpointUrl: false }))
}}
/>
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item
label="模型标识"
name="llm_model_name"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型标识或部署名' }]}
>
<Input placeholder="如 gpt-4.1-mini / qwen3.6-plus / claude-3-5-sonnet-latest" />
</Form.Item>
<Form.Item
label="请求超时(秒)"
name="llm_timeout"
style={{ width: 180 }}
rules={[{ required: true, message: '请输入超时时间' }]}
>
<InputNumber min={5} max={600} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item
label="模型名称"
name="model_name"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型名称' }]}
>
<Input
placeholder="会根据提供方和模型标识自动生成"
onChange={() => {
setAutoFillFlags((current) => ({ ...current, modelName: false }))
}}
/>
</Form.Item>
<Form.Item
label="模型编码"
name="model_code"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型编码' }]}
>
<Input
placeholder="会自动生成,支持手动调整"
onChange={() => {
setAutoFillFlags((current) => ({ ...current, modelCode: false }))
}}
/>
</Form.Item>
</Space>
<Form.Item label="API Key" name="api_key">
<Input.Password
placeholder="支持留空后稍后补齐;测试非 Ollama 模型时建议填写"
autoComplete="new-password"
/>
</Form.Item>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item
label="Temperature"
name="llm_temperature"
style={{ width: '33%' }}
rules={[{ required: true, message: '请输入 temperature' }]}
>
<InputNumber min={0} max={2} step={0.05} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="Top P"
name="llm_top_p"
style={{ width: '33%' }}
rules={[{ required: true, message: '请输入 top_p' }]}
>
<InputNumber min={0} max={1} step={0.05} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="Max Tokens"
name="llm_max_tokens"
style={{ width: '33%' }}
rules={[{ required: true, message: '请输入 max_tokens' }]}
>
<InputNumber min={1} max={32768} step={128} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Form.Item label="系统提示词" name="llm_system_prompt">
<TextArea
rows={4}
placeholder="可选。测试模型时会作为 system prompt 一并发送。"
/>
</Form.Item>
<Form.Item label="描述" name="description">
<TextArea rows={2} placeholder="可填写用途、场景、适用业务等说明" />
</Form.Item>
<Space size={24}>
<Form.Item label="启用状态" name="is_active" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
<Form.Item label="设为默认" name="is_default" valuePropName="checked">
<Switch checkedChildren="默认" unCheckedChildren="普通" />
</Form.Item>
</Space>
</Form>
</Modal>
</div>
)
}
export default ModelConfigs

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { Tree, Button, Card, Row, Col, Tag, Space, message } from 'antd'
import { SafetyOutlined, SaveOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
import { Tree, Button, Card, Tag, Space } from 'antd'
import { SafetyOutlined, SaveOutlined, CheckCircleOutlined, CloseCircleOutlined, AppstoreOutlined } from '@ant-design/icons'
import {
getAllRoles,
getMenuTree,
@ -8,7 +8,10 @@ import {
updateRolePermissions,
} from '@/api/rolePermissions'
import ListTable from '@/components/ListTable/ListTable'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import Toast from '@/components/Toast/Toast'
import '@/pages/System/AdminPages.css'
function Permissions() {
const [loading, setLoading] = useState(false)
@ -151,85 +154,84 @@ function Permissions() {
},
]
const editableRoles = roles.filter((role) => role.is_system !== 1).length
const flattenTreeCount = (nodes) => nodes.reduce((count, node) => {
const childrenCount = node.children ? flattenTreeCount(node.children) : 0
return count + 1 + childrenCount
}, 0)
const menuCount = flattenTreeCount(menuTree)
return (
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600, color: 'var(--text-color)' }}>
<SafetyOutlined style={{ marginRight: '8px' }} />
角色权限管理
</h1>
<div className="admin-page">
<PageHeader
title="权限管理"
description="为角色配置菜单和功能权限,系统角色默认只读。"
icon={<SafetyOutlined />}
/>
<Row gutter={16}>
{/* 左侧:功能权限树 */}
<Col span={12}>
<Card
title="功能权限树"
extra={
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
disabled={!selectedRole}
>
保存权限
</Button>
}
>
{selectedRole ? (
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-color-secondary)', borderRadius: 4 }}>
<Space>
<span style={{ fontWeight: 500 }}>当前角色</span>
<Tag color="blue">{selectedRole.role_name}</Tag>
{selectedRole.is_system === 1 && <Tag color="orange">系统角色只读</Tag>}
</Space>
</div>
) : (
<div
style={{
marginBottom: 16,
padding: '12px',
background: 'var(--bg-color-secondary)',
borderRadius: 4,
color: 'var(--text-color-secondary)',
border: '1px solid var(--border-color)',
}}
>
请从右侧选择一个角色来查看和编辑权限
</div>
)}
<Tree
checkable
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
checkedKeys={checkedKeys}
onCheck={handleCheck}
treeData={menuTree}
disabled={!selectedRole || selectedRole.is_system === 1}
style={{ minHeight: 400 }}
/>
</Card>
</Col>
{/* 右侧:角色列表 */}
<Col span={12}>
<Card title="角色列表">
<ListTable
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ y: 600 }}
onRowClick={handleRoleClick}
selectedRow={selectedRole}
onSelectionChange={() => {}}
/>
</Card>
</Col>
</Row>
<div className="admin-stats">
<StatCard title="角色总数" value={roles.length} icon={<SafetyOutlined />} color="blue" />
<StatCard title="可编辑角色" value={editableRoles} icon={<CheckCircleOutlined />} color="green" />
<StatCard title="权限节点数" value={menuCount} icon={<AppstoreOutlined />} color="orange" />
</div>
<div className="admin-grid-2">
<Card
title="功能权限树"
className="admin-card"
extra={(
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
disabled={!selectedRole}
>
保存权限
</Button>
)}
>
{selectedRole ? (
<div className="admin-hint">
<Space>
<span><strong>当前角色</strong></span>
<Tag color="blue">{selectedRole.role_name}</Tag>
{selectedRole.is_system === 1 && <Tag color="orange">系统角色只读</Tag>}
</Space>
</div>
) : (
<div className="admin-hint">
请先从右侧角色列表中选择一个角色再查看或编辑权限
</div>
)}
<Tree
checkable
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
checkedKeys={checkedKeys}
onCheck={handleCheck}
treeData={menuTree}
disabled={!selectedRole || selectedRole.is_system === 1}
style={{ minHeight: 480 }}
/>
</Card>
<Card title="角色列表" className="admin-card">
<ListTable
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ y: 600 }}
onRowClick={handleRoleClick}
selectedRow={selectedRole}
onSelectionChange={() => {}}
/>
</Card>
</div>
</div>
)
}

View File

@ -9,8 +9,6 @@ import {
Space,
Popconfirm,
Card,
Row,
Col,
} from 'antd'
import {
PlusOutlined,
@ -19,7 +17,8 @@ import {
TeamOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SearchOutlined,
SafetyOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import {
getRoleList,
@ -29,7 +28,12 @@ import {
getRoleUsers,
} from '@/api/roles'
import ListTable from '@/components/ListTable/ListTable'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import Toast from '@/components/Toast/Toast'
import '@/pages/System/AdminPages.css'
const { Search } = Input
function Roles() {
const [loading, setLoading] = useState(false)
@ -312,70 +316,94 @@ function Roles() {
},
]
const enabledRoles = roles.filter((role) => role.status === 1).length
const systemRoles = roles.filter((role) => role.is_system === 1).length
return (
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600, color: 'var(--text-color)' }}>角色管理</h1>
<div className="admin-page">
<PageHeader
title="角色管理"
description="维护系统角色、角色编码与用户归属关系。"
icon={<TeamOutlined />}
extra={(
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
新增角色
</Button>
)}
/>
{/* 搜索和操作栏 */}
<Card style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={8}>
<Input
placeholder="搜索角色名称、编码"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
/>
</Col>
<Col span={6}>
<Select
placeholder="筛选状态"
style={{ width: '100%' }}
value={statusFilter}
onChange={setStatusFilter}
allowClear
>
<Select.Option value={1}>启用</Select.Option>
<Select.Option value={0}>禁用</Select.Option>
</Select>
</Col>
<Col span={10} style={{ textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
新增角色
</Button>
</Col>
</Row>
</Card>
<div className="admin-stats">
<StatCard title="角色总数" value={total} icon={<SafetyOutlined />} color="blue" />
<StatCard title="当前页启用" value={enabledRoles} icon={<CheckCircleOutlined />} color="green" />
<StatCard title="系统角色数" value={systemRoles} icon={<TeamOutlined />} color="orange" />
</div>
{/* 角色列表 */}
<Card>
<ListTable
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page)
setPageSize(pageSize)
},
}}
onSelectionChange={() => {}}
/>
</Card>
<Card className="admin-card">
<div className="admin-toolbar">
<div className="admin-toolbar-left">
<Search
allowClear
placeholder="搜索角色名称、编码"
value={keyword}
style={{ width: 320 }}
onChange={(event) => {
setPage(1)
setKeyword(event.target.value)
}}
onSearch={(value) => {
setPage(1)
setKeyword(value)
}}
/>
<Select
allowClear
placeholder="筛选状态"
style={{ width: 140 }}
value={statusFilter}
onChange={(value) => {
setPage(1)
setStatusFilter(value ?? null)
}}
options={[
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
]}
/>
</div>
<div className="admin-toolbar-right">
<Button icon={<ReloadOutlined />} onClick={loadRoles}>
刷新
</Button>
</div>
</div>
<ListTable
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (value) => `${value}`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
onSelectionChange={() => {}}
/>
</Card>
{/* 创建角色对话框 */}
<Modal
title="新增角色"
open={createModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setCreateModalVisible(false)
createForm.resetFields()
@ -416,6 +444,8 @@ function Roles() {
<Modal
title="编辑角色"
open={editModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setEditModalVisible(false)
editForm.resetFields()
@ -456,6 +486,7 @@ function Roles() {
<Modal
title={`角色用户列表 - ${currentRole?.role_name || ''}`}
open={usersModalVisible}
className="admin-modal"
onCancel={() => {
setUsersModalVisible(false)
setRoleUsers([])
@ -463,6 +494,7 @@ function Roles() {
}}
footer={null}
width={1000}
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
>
<ListTable
columns={userColumns}
@ -483,8 +515,7 @@ function Roles() {
onSelectionChange={() => {}}
/>
</Modal>
</div>
</div>
)
}

View File

@ -10,8 +10,6 @@ import {
Popconfirm,
Switch,
Card,
Row,
Col,
} from 'antd'
import {
PlusOutlined,
@ -19,9 +17,10 @@ import {
DeleteOutlined,
KeyOutlined,
TeamOutlined,
SearchOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
UserOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import {
getUserList,
@ -34,7 +33,12 @@ import {
} from '@/api/users'
import { getAllRoles } from '@/api/rolePermissions'
import ListTable from '@/components/ListTable/ListTable'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import Toast from '@/components/Toast/Toast'
import '@/pages/System/AdminPages.css'
const { Search } = Input
function Users() {
const [loading, setLoading] = useState(false)
@ -57,9 +61,12 @@ function Users() {
useEffect(() => {
loadUsers()
loadRoles()
}, [page, pageSize, keyword, statusFilter])
useEffect(() => {
loadRoles()
}, [])
const loadUsers = async () => {
try {
setLoading(true)
@ -297,70 +304,93 @@ function Users() {
},
]
const activeUsers = users.filter((user) => user.status === 1).length
return (
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600, color: 'var(--text-color)' }}>用户管理</h1>
<div className="admin-page">
<PageHeader
title="用户管理"
description="统一管理系统用户、账号状态、角色分配与密码重置。"
icon={<UserOutlined />}
extra={(
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
新增用户
</Button>
)}
/>
{/* 搜索和操作栏 */}
<Card style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={8}>
<Input
placeholder="搜索用户名、昵称、邮箱"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
/>
</Col>
<Col span={6}>
<Select
placeholder="筛选状态"
style={{ width: '100%' }}
value={statusFilter}
onChange={setStatusFilter}
allowClear
>
<Select.Option value={1}>启用</Select.Option>
<Select.Option value={0}>停用</Select.Option>
</Select>
</Col>
<Col span={10} style={{ textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
新增用户
</Button>
</Col>
</Row>
</Card>
<div className="admin-stats">
<StatCard title="用户总数" value={total} icon={<UserOutlined />} color="blue" />
<StatCard title="当前页启用" value={activeUsers} icon={<CheckCircleOutlined />} color="green" />
<StatCard title="可分配角色数" value={roles.length} icon={<TeamOutlined />} color="orange" />
</div>
{/* 用户列表 */}
<Card>
<ListTable
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page)
setPageSize(pageSize)
},
}}
onSelectionChange={() => {}}
/>
</Card>
<Card className="admin-card">
<div className="admin-toolbar">
<div className="admin-toolbar-left">
<Search
allowClear
placeholder="搜索用户名、昵称、邮箱"
value={keyword}
style={{ width: 320 }}
onChange={(event) => {
setPage(1)
setKeyword(event.target.value)
}}
onSearch={(value) => {
setPage(1)
setKeyword(value)
}}
/>
<Select
allowClear
placeholder="筛选状态"
style={{ width: 140 }}
value={statusFilter}
onChange={(value) => {
setPage(1)
setStatusFilter(value ?? null)
}}
options={[
{ label: '启用', value: 1 },
{ label: '停用', value: 0 },
]}
/>
</div>
<div className="admin-toolbar-right">
<Button icon={<ReloadOutlined />} onClick={loadUsers}>
刷新
</Button>
</div>
</div>
<ListTable
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (value) => `${value}`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
onSelectionChange={() => {}}
/>
</Card>
{/* 创建用户对话框 */}
<Modal
title="新增用户"
open={createModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setCreateModalVisible(false)
createForm.resetFields()
@ -412,6 +442,8 @@ function Users() {
<Modal
title="编辑用户"
open={editModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setEditModalVisible(false)
editForm.resetFields()
@ -439,6 +471,8 @@ function Users() {
<Modal
title="分配角色"
open={rolesModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setRolesModalVisible(false)
rolesForm.resetFields()
@ -457,8 +491,7 @@ function Users() {
</Form.Item>
</Form>
</Modal>
</div>
</div>
)
}

View File

@ -1,9 +1,3 @@
.system-logs-container {
padding: 24px;
}
.system-logs-container h2 {
margin-bottom: 24px;
font-size: 24px;
font-weight: 600;
min-width: 0;
}

View File

@ -1,10 +1,13 @@
import { useState, useEffect } from 'react'
import { Table, Card, Select, DatePicker, Space, Button, Tag, Statistic, Row, Col, Input, message } from 'antd'
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { Table, Card, Select, DatePicker, Space, Button, Tag, Input, message } from 'antd'
import { ReloadOutlined, SearchOutlined, FileSearchOutlined, CheckCircleOutlined, FolderOutlined, FileTextOutlined } from '@ant-design/icons'
import { getOperationLogs, getLogStats } from '@/api/logs'
import { getUserList } from '@/api/users'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import dayjs from 'dayjs'
import './SystemLogs.css'
import '@/pages/System/AdminPages.css'
const { RangePicker } = DatePicker
const { Option } = Select
@ -88,7 +91,7 @@ function SystemLogs() {
fetchUsers()
}, [])
const fetchLogs = async (page = 1, pageSize = 20) => {
const fetchLogs = async (page = 1, pageSize = 20, nextFilters = filters) => {
setLoading(true)
try {
const params = {
@ -97,23 +100,23 @@ function SystemLogs() {
}
//
if (filters.operation_type) {
params.operation_type = filters.operation_type
if (nextFilters.operation_type) {
params.operation_type = nextFilters.operation_type
}
if (filters.resource_type) {
params.resource_type = filters.resource_type
if (nextFilters.resource_type) {
params.resource_type = nextFilters.resource_type
}
if (filters.user_id) {
params.user_id = filters.user_id
if (nextFilters.user_id) {
params.user_id = nextFilters.user_id
}
if (filters.project_id) {
params.project_id = filters.project_id
if (nextFilters.project_id) {
params.project_id = nextFilters.project_id
}
//
if (filters.dateRange && filters.dateRange.length === 2) {
params.start_date = filters.dateRange[0].format('YYYY-MM-DD')
params.end_date = filters.dateRange[1].format('YYYY-MM-DD')
if (nextFilters.dateRange && nextFilters.dateRange.length === 2) {
params.start_date = nextFilters.dateRange[0].format('YYYY-MM-DD')
params.end_date = nextFilters.dateRange[1].format('YYYY-MM-DD')
}
const res = await getOperationLogs(params)
@ -143,10 +146,8 @@ function SystemLogs() {
const fetchUsers = async () => {
try {
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
console.log('Fetch users response:', res)
// : { code: 200, message: "success", data: [...], total, page, page_size }
const usersData = Array.isArray(res.data) ? res.data : []
console.log('Users data:', usersData)
setUsers(usersData)
} catch (error) {
console.error('Fetch users error:', error)
@ -165,20 +166,19 @@ function SystemLogs() {
delete cleanFilters.project_id
}
setFilters(cleanFilters)
fetchLogs(1, pagination.pageSize)
fetchLogs(1, pagination.pageSize, cleanFilters)
}
const handleReset = () => {
setFilters({
const resetFilters = {
operation_type: undefined,
resource_type: undefined,
user_id: undefined,
project_id: undefined,
dateRange: null,
})
setTimeout(() => {
fetchLogs(1, pagination.pageSize)
}, 0)
}
setFilters(resetFilters)
fetchLogs(1, pagination.pageSize, resetFilters)
}
const columns = [
@ -228,9 +228,9 @@ function SystemLogs() {
try {
const parsed = JSON.parse(detail)
return (
<div style={{ maxWidth: 300 }}>
<div className="admin-detail-cell">
{Object.entries(parsed).map(([key, value]) => (
<div key={key} style={{ fontSize: 12 }}>
<div key={key}>
<strong>{key}:</strong> {JSON.stringify(value)}
</div>
))}
@ -267,45 +267,38 @@ function SystemLogs() {
},
]
return (
<div className="system-logs-container">
<h2>系统日志</h2>
const projectOperationCount = (
(stats?.operation_stats?.create_project || 0) +
(stats?.operation_stats?.update_project || 0) +
(stats?.operation_stats?.delete_project || 0)
)
const fileOperationCount = (
(stats?.operation_stats?.create_file || 0) +
(stats?.operation_stats?.save_file || 0) +
(stats?.operation_stats?.delete_file || 0)
)
return (
<div className="admin-page system-logs-container">
<PageHeader
title="系统日志"
description="按用户、资源、时间维度筛选系统操作日志与行为统计。"
icon={<FileSearchOutlined />}
/>
{/* 统计信息 */}
{stats && (
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="总日志数" value={stats.total_count} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="今日操作" value={stats.today_count} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="项目操作"
value={stats.operation_stats?.create_project + stats.operation_stats?.update_project + stats.operation_stats?.delete_project || 0}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="文件操作"
value={stats.operation_stats?.create_file + stats.operation_stats?.save_file + stats.operation_stats?.delete_file || 0}
/>
</Card>
</Col>
</Row>
<div className="admin-stats admin-stats-4">
<StatCard title="总日志数" value={stats.total_count} icon={<FileSearchOutlined />} color="blue" />
<StatCard title="今日操作" value={stats.today_count} icon={<CheckCircleOutlined />} color="green" />
<StatCard title="项目操作" value={projectOperationCount} icon={<FolderOutlined />} color="orange" />
<StatCard title="文件操作" value={fileOperationCount} icon={<FileTextOutlined />} color="red" />
</div>
)}
{/* 筛选条件 */}
<Card style={{ marginBottom: 16 }}>
<Space wrap size="middle">
<Card className="admin-card">
<div className="admin-toolbar">
<div className="admin-toolbar-left">
<Select
placeholder="操作类型"
style={{ width: 160 }}
@ -366,11 +359,9 @@ function SystemLogs() {
<Button icon={<ReloadOutlined />} onClick={handleReset}>
重置
</Button>
</Space>
</Card>
</div>
</div>
{/* 日志列表 */}
<Card>
<Table
columns={columns}
dataSource={logs}

View File

@ -3,6 +3,7 @@
*/
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { clearAuthStorage } from '@/utils/authStorage'
const useUserStore = create(
persist(
@ -14,8 +15,7 @@ const useUserStore = create(
setToken: (token) => set({ token }),
logout: () => {
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
clearAuthStorage()
set({ user: null, token: null })
},

View File

@ -0,0 +1,34 @@
const ACCESS_TOKEN_KEY = 'access_token'
const USER_INFO_KEY = 'user_info'
export function getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_KEY)
}
export function clearAuthStorage() {
localStorage.removeItem(ACCESS_TOKEN_KEY)
localStorage.removeItem(USER_INFO_KEY)
}
export function buildAuthorizedUrl(url, extraParams = {}) {
const params = new URLSearchParams()
const token = getAccessToken()
if (token) {
params.set('token', token)
}
Object.entries(extraParams).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.set(key, value)
}
})
const query = params.toString()
if (!query) {
return url
}
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}${query}`
}

View File

@ -0,0 +1,56 @@
export async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return
}
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
textArea.style.top = '0'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (!successful) {
throw new Error('Copy command failed')
}
}
export function extractFilenameFromDisposition(contentDisposition, fallback) {
if (!contentDisposition) {
return fallback
}
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch (error) {
console.warn('Decode filename* failed:', error)
}
}
const basicMatch = /filename="?([^";]+)"?/i.exec(contentDisposition)
if (basicMatch?.[1]) {
return basicMatch[1]
}
return fallback
}
export function triggerBlobDownload(blob, filename) {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}

View File

@ -3,6 +3,7 @@
*/
import axios from 'axios'
import Toast from '@/components/Toast/Toast'
import { clearAuthStorage, getAccessToken } from '@/utils/authStorage'
let isHandlingUnauthorized = false
@ -24,8 +25,7 @@ const request = axios.create({
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 从 localStorage 获取 token
const token = localStorage.getItem('access_token')
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@ -54,8 +54,7 @@ request.interceptors.response.use(
isHandlingUnauthorized = true
Toast.muteErrors(2500)
Toast.error('认证失败', res.message || '未登录或登录已过期', 3, { force: true })
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
clearAuthStorage()
setTimeout(() => {
redirectToLoginWithReturnTo()
}, 600)
@ -83,8 +82,7 @@ request.interceptors.response.use(
isHandlingUnauthorized = true
Toast.muteErrors(2500)
Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true })
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
clearAuthStorage()
setTimeout(() => {
redirectToLoginWithReturnTo()
}, 600) // 延迟一小段时间,让提示先展示