diff --git a/.gitignore b/.gitignore index 9e42f4c..b7334b5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ logs/ .env .env.local .gemini-clipboard +.memsearch # Temporary files *.tmp diff --git a/.memsearch/memory/2026-06-03.md b/.memsearch/memory/2026-06-03.md new file mode 100644 index 0000000..59098be --- /dev/null +++ b/.memsearch/memory/2026-06-03.md @@ -0,0 +1,24 @@ + +## Session 14:55 + + +## Session 14:59 + + +## Session 16:54 + + +## Session 17:15 + + +## Session 17:16 + + +## Session 17:20 + + +## Session 17:54 + + +## Session 17:54 + diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 252ce00..8dac427 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,7 +2,7 @@ API v1 路由汇总 """ from fastapi import APIRouter -from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, llm_model_configs +from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, shares, llm_model_configs api_router = APIRouter() @@ -15,6 +15,7 @@ api_router.include_router(notifications.router, prefix="/notifications", tags=[" api_router.include_router(menu.router, prefix="/menu", tags=["权限菜单"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"]) api_router.include_router(preview.router, prefix="/preview", tags=["项目预览"]) +api_router.include_router(shares.router, prefix="/shares", tags=["分享"]) api_router.include_router(role_permissions.router, prefix="/role-permissions", tags=["角色权限管理"]) api_router.include_router(users.router, prefix="/users", tags=["用户管理"]) api_router.include_router(roles.router, prefix="/roles", tags=["角色管理"]) diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index 9545d33..6751102 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -17,6 +17,7 @@ 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.log import OperationLog +from app.models.share import ShareLink from app.schemas.file import ( FileTreeNode, FileSaveRequest, @@ -37,6 +38,51 @@ 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 + + +def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None: + """为文件树节点补充分享状态""" + for node in tree: + node.is_shared = bool(node.isLeaf and node.key in shared_paths) + if node.children: + annotate_shared_files(node.children, shared_paths) @router.get("/{project_id}/tree", response_model=dict) async def get_project_tree( project_id: int, @@ -52,6 +98,37 @@ async def get_project_tree( # 生成目录树 tree = storage_service.generate_tree(project_root) +<<<<<<< HEAD +======= + share_result = await db.execute( + select(ShareLink.file_path).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "file", + ShareLink.status == 1, + ) + ) + shared_paths = { + file_path + for file_path in share_result.scalars().all() + if file_path + } + annotate_shared_files(tree, shared_paths) + + # 获取当前用户角色 + 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 + +>>>>>>> origin/main return success_response(data={ "tree": tree, "user_role": user_role, diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index dbd560f..d6afb82 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -1,21 +1,19 @@ """ 项目管理相关 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 +from sqlalchemy import delete, select, or_ from typing import List +import uuid +import secrets 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.models.share import ShareLink from app.schemas.project import ( ProjectCreate, ProjectUpdate, @@ -23,8 +21,6 @@ from app.schemas.project import ( ProjectMemberAdd, ProjectMemberUpdate, ProjectMemberResponse, - ProjectShareSettings, - ProjectShareInfo, ProjectTransfer, ) from app.schemas.response import success_response @@ -32,19 +28,32 @@ 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 generate_share_code() -> str: + """生成公开分享码""" + return secrets.token_urlsafe(12).replace("-", "").replace("_", "") + + +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), @@ -71,7 +80,11 @@ async def get_my_projects( # 合并结果 all_projects = owned_projects + member_projects - projects_data = [serialize_project(project) for project in all_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) return success_response(data=projects_data) @@ -86,7 +99,11 @@ async def get_owned_projects( select(Project).where(Project.owner_id == current_user.id, Project.status == 1) ) projects = result.scalars().all() - projects_data = [serialize_project(project) for project in projects] + 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) return success_response(data=projects_data) @@ -110,14 +127,12 @@ async def get_shared_projects( projects_data = [] for project, owner, member in projects_with_info: - projects_data.append( - serialize_project( - project, - owner_name=owner.username, - owner_nickname=owner.nickname, - user_role=member.role, - ) - ) + 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) return success_response(data=projects_data) @@ -185,12 +200,24 @@ async def get_project( db: AsyncSession = Depends(get_db) ): """获取项目详情""" - project, _ = await require_project_read_access( - db, - project_id, - current_user, - allow_public=True, - ) + # 查询项目 + 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.visit_count += 1 @@ -209,17 +236,59 @@ async def update_project( db: AsyncSession = Depends(get_db) ): """更新项目信息""" - project = await get_project_or_404(db, 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: raise HTTPException(status_code=403, detail="无权修改该项目") + old_is_public = project.is_public + # 更新字段 update_data = project_in.dict(exclude_unset=True) for field, value in update_data.items(): setattr(project, field, value) + if "is_public" in update_data: + next_is_public = int(update_data.get("is_public") or 0) + if next_is_public == 0: + await db.execute( + delete(ShareLink).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "project", + ) + ) + elif next_is_public == 1: + if old_is_public != 1: + # 重新公开项目时丢弃历史公开链接,生成新的项目分享链接。 + await db.execute( + delete(ShareLink).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "project", + ) + ) + + share_result = await db.execute( + select(ShareLink).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "project", + ShareLink.status == 1, + ) + ) + share = share_result.scalar_one_or_none() + if not share: + db.add(ShareLink( + project_id=project_id, + share_type="project", + share_code=generate_share_code(), + created_by=current_user.id, + )) + await db.commit() await db.refresh(project) @@ -246,7 +315,12 @@ async def transfer_project( db: AsyncSession = Depends(get_db) ): """转移项目所有权""" - project = await get_project_or_404(db, 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: @@ -263,13 +337,25 @@ async def transfer_project( raise HTTPException(status_code=400, detail="不能转移给自己") # 1. 如果新所有者已经是成员,删除成员记录 - existing_member = await get_project_member(db, project_id, new_owner.id) + 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() if existing_member: await db.delete(existing_member) # 2. 将旧所有者添加为管理员成员 # 检查旧所有者是否已经在member表中(理论上owner不在member表中,但为了健壮性检查一下) - if not await get_project_member(db, project_id, current_user.id): + 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(): old_owner_member = ProjectMember( project_id=project_id, user_id=current_user.id, @@ -307,69 +393,6 @@ 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, @@ -378,7 +401,12 @@ async def delete_project( db: AsyncSession = Depends(get_db) ): """删除项目""" - project = await get_project_or_404(db, 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: @@ -439,7 +467,24 @@ async def get_project_members( db: AsyncSession = Depends(get_db) ): """获取项目成员列表""" - await require_project_read_access(db, project_id, current_user) + # 查询项目 + 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="无权访问该项目") # 查询成员列表并关联用户信息 members_result = await db.execute( @@ -474,13 +519,25 @@ async def add_project_member( db: AsyncSession = Depends(get_db) ): """添加项目成员""" - project, _ = await require_project_roles( - db, - project_id, - current_user, - allowed_roles=["admin"], - forbidden_detail="无权添加成员", - ) + # 查询项目 + 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="无权添加成员") # 检查用户是否已是成员 existing_result = await db.execute( @@ -540,13 +597,25 @@ async def remove_project_member( db: AsyncSession = Depends(get_db) ): """删除项目成员""" - project, _ = await require_project_roles( - db, - project_id, - current_user, - allowed_roles=["admin"], - forbidden_detail="无权删除成员", - ) + # 查询项目 + 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="无权删除成员") # 不能删除项目所有者 if user_id == project.owner_id: @@ -581,66 +650,6 @@ async def remove_project_member( return success_response(message="成员删除成功") -@router.get("/{project_id}/share", response_model=dict) -async def get_project_share_info( - project_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """获取项目分享信息""" - project, role = await require_project_read_access(db, project_id, current_user) - is_owner = role == "owner" - - # 构建分享链接 - share_url = f"/preview/{project_id}" - - # 只有项目所有者可以看到实际密码,成员只能知道是否设置了密码 - share_info = ProjectShareInfo( - share_url=share_url, - has_password=bool(project.access_pass), - access_pass=project.access_pass if is_owner else None - ) - - return success_response(data=share_info.dict()) - - -@router.post("/{project_id}/share/settings", response_model=dict) -async def update_share_settings( - project_id: int, - settings: ProjectShareSettings, - request: Request, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) -): - """更新分享设置(设置或取消访问密码)""" - project = await get_project_or_404(db, project_id) - - # 只有项目所有者可以修改分享设置 - if project.owner_id != current_user.id: - raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置") - - # 更新访问密码 - project.access_pass = settings.access_pass - await db.commit() - - # 记录操作日志 - await log_service.log_operation( - db=db, - operation_type=OperationType.UPDATE_SHARE_SETTINGS, - resource_type=ResourceType.SHARE, - user=current_user, - resource_id=project_id, - detail={ - "has_password": bool(settings.access_pass), - "project_name": project.name, - }, - request=request, - ) - - message = "访问密码已取消" if not settings.access_pass else "访问密码已设置" - return success_response(message=message) - - @router.post("/{project_id}/git/pull", response_model=dict) async def git_pull( project_id: int, @@ -651,13 +660,24 @@ async def git_pull( db: AsyncSession = Depends(get_db) ): """执行 Git Pull""" - project, _ = await require_project_roles( - db, - project_id, - current_user, - allowed_roles=["admin", "editor"], - forbidden_detail="无权执行Git操作", - ) + # 查询项目 + 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操作") # 获取Git仓库配置 query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) @@ -724,13 +744,24 @@ async def git_push( db: AsyncSession = Depends(get_db) ): """执行 Git Push""" - project, _ = await require_project_roles( - db, - project_id, - current_user, - allowed_roles=["admin", "editor"], - forbidden_detail="无权执行Git操作", - ) + # 查询项目 + 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操作") # 获取Git仓库配置 query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) diff --git a/backend/app/api/v1/shares.py b/backend/app/api/v1/shares.py new file mode 100644 index 0000000..e303f65 --- /dev/null +++ b/backend/app/api/v1/shares.py @@ -0,0 +1,668 @@ +""" +分享相关 API +""" +from pathlib import Path +from typing import Optional +import mimetypes +import secrets +import re + +from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response +from fastapi.responses import FileResponse, StreamingResponse +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.deps import get_current_user +from app.core.enums import OperationType, ResourceType +from app.core.security import create_access_token, decode_access_token +from app.models.project import Project, ProjectMember +from app.models.share import ShareLink +from app.models.user import User +from app.schemas.project import FileShareCreate +from app.schemas.response import success_response +from app.services.log_service import log_service +from app.services.pdf_service import pdf_service +from app.services.search_service import search_service +from app.services.storage import storage_service + +router = APIRouter() + + +def generate_share_code() -> str: + return secrets.token_urlsafe(12).replace("-", "").replace("_", "") + + +def get_share_cookie_name(share_code: str) -> str: + return f"nd_share_{share_code}" + + +async def get_project_or_404(project_id: int, db: AsyncSession) -> Project: + 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_public_project_for_share_or_404(share: ShareLink, db: AsyncSession) -> Project: + project = await get_project_or_404(share.project_id, db) + if project.is_public != 1: + raise HTTPException(status_code=404, detail="分享不存在或已失效") + return project + + +async def ensure_project_member(project: Project, current_user: User, db: AsyncSession) -> str: + if project.owner_id == current_user.id: + return "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="无权访问该项目") + return member.role + + +async def get_share_by_code_or_404(share_code: str, share_type: Optional[str], db: AsyncSession) -> ShareLink: + query = select(ShareLink).where(ShareLink.share_code == share_code, ShareLink.status == 1) + if share_type: + query = query.where(ShareLink.share_type == share_type) + result = await db.execute(query) + share = result.scalar_one_or_none() + if not share: + raise HTTPException(status_code=404, detail="分享不存在或已失效") + return share + + +def create_share_access_cookie(response: Response, share: ShareLink): + token = create_access_token({"share_code": share.share_code, "scope": "share_access"}) + response.set_cookie( + key=get_share_cookie_name(share.share_code), + value=token, + httponly=True, + samesite="lax", + secure=False, + max_age=60 * 60 * 12, + path="/", + ) + + +def has_valid_share_cookie(request: Request, share: ShareLink) -> bool: + token = request.cookies.get(get_share_cookie_name(share.share_code)) + if not token: + return False + payload = decode_access_token(token) + if not payload: + return False + return payload.get("scope") == "share_access" and payload.get("share_code") == share.share_code + + +def ensure_share_access(share: ShareLink, request: Request, password: Optional[str]): + if not share.access_pass: + return + if password and password == share.access_pass: + return + if has_valid_share_cookie(request, share): + return + raise HTTPException(status_code=403, detail="需要提供正确的访问密码") + + +def rewrite_markdown_assets(content: str, asset_base_url: str) -> str: + pattern = r"/api/v1/files/\d+/assets/([^)\s\"']+)" + replacement = rf"{asset_base_url}/\1" + return re.sub(pattern, replacement, content) + + +def rewrite_markdown_assets_for_pdf(content: str) -> str: + return re.sub(r"/api/v1/files/\d+/assets/", "_assets/", content) + + +def collect_parent_paths(path: str) -> list[str]: + parts = path.split("/") + parents = [] + current = "" + for part in parts[:-1]: + current = f"{current}/{part}" if current else part + parents.append(current) + return parents + + +@router.get("/projects/{project_id}", response_model=dict) +async def get_project_share_info( + project_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + project = await get_project_or_404(project_id, db) + role = await ensure_project_member(project, current_user, db) + is_owner = role == "owner" + + share = None + if project.is_public == 1: + share_result = await db.execute( + select(ShareLink).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "project", + ShareLink.status == 1 + ) + ) + share = share_result.scalar_one_or_none() + if not share: + share = ShareLink( + project_id=project_id, + share_type="project", + share_code=generate_share_code(), + created_by=current_user.id + ) + db.add(share) + await db.commit() + await db.refresh(share) + + return success_response(data={ + "enabled": project.is_public == 1, + "share_url": f"/share/project/{share.share_code}" if share else None, + "has_password": bool(share.access_pass) if share else False, + "access_pass": share.access_pass if share and is_owner else None, + }) + + +@router.post("/projects/{project_id}/settings", response_model=dict) +async def update_project_share_settings( + project_id: int, + settings: dict, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + project = await get_project_or_404(project_id, db) + if project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置") + + if project.is_public != 1: + raise HTTPException(status_code=400, detail="请先开启公开项目,再设置项目分享") + + share_result = await db.execute( + select(ShareLink).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "project", + ShareLink.status == 1 + ) + ) + share = share_result.scalar_one_or_none() + if not share: + share = ShareLink( + project_id=project_id, + share_type="project", + share_code=generate_share_code(), + created_by=current_user.id + ) + db.add(share) + + share.access_pass = settings.get("access_pass") + await db.commit() + + await log_service.log_operation( + db=db, + operation_type=OperationType.UPDATE_SHARE_SETTINGS, + resource_type=ResourceType.SHARE, + user=current_user, + resource_id=project_id, + detail={"share_type": "project", "has_password": bool(share.access_pass)}, + ) + + return success_response(message="项目分享设置已更新") + + +@router.get("/projects/{project_id}/files/share/info", response_model=dict) +async def get_file_share_info( + project_id: int, + file_path: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + project = await get_project_or_404(project_id, db) + role = await ensure_project_member(project, current_user, db) + is_owner = role == "owner" + + share_result = await db.execute( + select(ShareLink).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "file", + ShareLink.file_path == file_path, + ShareLink.status == 1 + ) + ) + share = share_result.scalar_one_or_none() + + if not share: + return success_response(data=None) + + return success_response(data={ + "file_path": file_path, + "share_url": f"/share/file/{share.share_code}", + "has_password": bool(share.access_pass), + "access_pass": share.access_pass if is_owner else None, + }) + + +@router.post("/projects/{project_id}/files/share", response_model=dict) +async def create_or_update_file_share( + project_id: int, + payload: FileShareCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + project = await get_project_or_404(project_id, db) + await ensure_project_member(project, current_user, db) + + file_path = storage_service.get_secure_path(project.storage_key, payload.file_path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在") + + share_result = await db.execute( + select(ShareLink).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "file", + ShareLink.file_path == payload.file_path, + ShareLink.status == 1 + ) + ) + share = share_result.scalar_one_or_none() + + if not share: + share = ShareLink( + project_id=project_id, + share_type="file", + share_code=generate_share_code(), + file_path=payload.file_path, + access_pass=payload.access_pass, + created_by=current_user.id + ) + db.add(share) + action = OperationType.CREATE_SHARE_LINK + else: + share.access_pass = payload.access_pass + action = OperationType.UPDATE_SHARE_SETTINGS + + await db.commit() + await db.refresh(share) + + await log_service.log_operation( + db=db, + operation_type=action, + resource_type=ResourceType.SHARE, + user=current_user, + resource_id=project_id, + detail={"share_type": "file", "file_path": payload.file_path, "has_password": bool(share.access_pass)}, + ) + + return success_response(data={ + "file_path": payload.file_path, + "share_url": f"/share/file/{share.share_code}", + "has_password": bool(share.access_pass), + "access_pass": share.access_pass, + }, message="文件分享已更新") + + +@router.delete("/projects/{project_id}/files/share", response_model=dict) +async def delete_file_share( + project_id: int, + file_path: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + project = await get_project_or_404(project_id, db) + await ensure_project_member(project, current_user, db) + + share_result = await db.execute( + select(ShareLink).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "file", + ShareLink.file_path == file_path, + ) + ) + share = share_result.scalar_one_or_none() + if not share: + raise HTTPException(status_code=404, detail="文件分享不存在") + + await db.execute( + delete(ShareLink).where( + ShareLink.project_id == project_id, + ShareLink.share_type == "file", + ShareLink.file_path == file_path, + ) + ) + await db.commit() + + await log_service.log_operation( + db=db, + operation_type=OperationType.DELETE_SHARE_LINK, + resource_type=ResourceType.SHARE, + user=current_user, + resource_id=project_id, + detail={"share_type": "file", "file_path": file_path}, + ) + + return success_response(message="文件分享已关闭") + + +@router.get("/project/{share_code}/info", response_model=dict) +async def get_project_share_public_info( + share_code: str, + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "project", db) + project = await get_public_project_for_share_or_404(share, db) + return success_response(data={ + "name": project.name, + "description": project.description, + "has_password": bool(share.access_pass), + "share_code": share.share_code, + }) + + +@router.post("/project/{share_code}/verify", response_model=dict) +async def verify_project_share_password( + share_code: str, + password: str = Header(..., alias="X-Access-Password"), + response: Response = None, + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "project", db) + await get_public_project_for_share_or_404(share, db) + if share.access_pass and share.access_pass != password: + raise HTTPException(status_code=403, detail="访问密码错误") + create_share_access_cookie(response, share) + return success_response(message="验证成功") + + +@router.get("/project/{share_code}/tree", response_model=dict) +async def get_project_share_tree( + share_code: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "project", db) + project = await get_public_project_for_share_or_404(share, db) + ensure_share_access(share, request, password) + project_root = storage_service.get_secure_path(project.storage_key) + tree = storage_service.generate_tree(project_root) + return success_response(data=tree) + + +@router.get("/project/{share_code}/search", response_model=dict) +async def search_project_share_documents( + share_code: str, + keyword: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "project", db) + project = await get_public_project_for_share_or_404(share, db) + ensure_share_access(share, request, password) + + if not keyword or not keyword.strip(): + return success_response(data=[]) + + keyword = keyword.strip() + + search_results = [] + existing_paths = set() + + try: + whoosh_results = await search_service.search(keyword, str(project.id), limit=50) + except Exception: + whoosh_results = [] + + for item in whoosh_results: + file_path = item.get("path") + if not file_path: + continue + existing_paths.add(file_path) + search_results.append({ + "file_path": file_path, + "file_name": item.get("title") or Path(file_path).name, + "highlights": item.get("highlights"), + "match_type": "全文检索", + "parent_paths": collect_parent_paths(file_path), + }) + + project_root = storage_service.get_secure_path(project.storage_key) + keyword_lower = keyword.lower() + + try: + files_to_scan = list(project_root.rglob("*.md")) + list(project_root.rglob("*.pdf")) + except Exception: + files_to_scan = [] + + for file_path in files_to_scan: + if "_assets" in file_path.parts: + continue + + relative_path = str(file_path.relative_to(project_root)) + if relative_path in existing_paths: + continue + + if keyword_lower in file_path.name.lower(): + search_results.append({ + "file_path": relative_path, + "file_name": file_path.name, + "match_type": "文件名匹配", + "parent_paths": collect_parent_paths(relative_path), + }) + existing_paths.add(relative_path) + + return success_response(data=search_results[:100]) + + +@router.get("/project/{share_code}/file", response_model=dict) +async def get_project_share_file( + share_code: str, + path: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "project", db) + project = await get_public_project_for_share_or_404(share, db) + ensure_share_access(share, request, password) + file_path = storage_service.get_secure_path(project.storage_key, path) + content = await storage_service.read_file(file_path) + content = rewrite_markdown_assets(content, f"/api/v1/shares/project/{share.share_code}/assets") + return success_response(data={"content": content}) + + +@router.get("/project/{share_code}/document/{path:path}") +async def get_project_share_document( + share_code: str, + path: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "project", db) + project = await get_public_project_for_share_or_404(share, db) + ensure_share_access(share, request, password) + file_path = storage_service.get_secure_path(project.storage_key, path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在") + content_type, _ = mimetypes.guess_type(str(file_path)) + return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name) + + +@router.get("/project/{share_code}/export-pdf") +async def export_project_share_pdf( + share_code: str, + path: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "project", db) + project = await get_public_project_for_share_or_404(share, db) + ensure_share_access(share, request, password) + file_path = storage_service.get_secure_path(project.storage_key, path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在") + content = await storage_service.read_file(file_path) + filename = Path(path).stem + ".pdf" + content = rewrite_markdown_assets_for_pdf(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)) + return StreamingResponse( + pdf_buffer, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + +@router.get("/files/{share_code}/info", response_model=dict) +async def get_file_share_public_info( + share_code: str, + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "file", db) + project = await get_project_or_404(share.project_id, db) + return success_response(data={ + "name": Path(share.file_path).name, + "project_name": project.name, + "file_path": share.file_path, + "has_password": bool(share.access_pass), + "share_code": share.share_code, + }) + + +@router.post("/files/{share_code}/verify", response_model=dict) +async def verify_file_share_password( + share_code: str, + password: str = Header(..., alias="X-Access-Password"), + response: Response = None, + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "file", db) + if share.access_pass and share.access_pass != password: + raise HTTPException(status_code=403, detail="访问密码错误") + create_share_access_cookie(response, share) + return success_response(message="验证成功") + + +@router.get("/files/{share_code}/content", response_model=dict) +async def get_file_share_content( + share_code: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "file", db) + ensure_share_access(share, request, password) + project = await get_project_or_404(share.project_id, db) + file_path = storage_service.get_secure_path(project.storage_key, share.file_path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在") + + if share.file_path.lower().endswith(".pdf"): + return success_response(data={ + "type": "pdf", + "filename": Path(share.file_path).name, + "document_url": f"/api/v1/shares/files/{share.share_code}/document", + }) + + content = await storage_service.read_file(file_path) + content = rewrite_markdown_assets(content, f"/api/v1/shares/files/{share.share_code}/assets") + return success_response(data={ + "type": "markdown", + "filename": Path(share.file_path).name, + "content": content, + }) + + +@router.get("/files/{share_code}/document") +async def get_file_share_document( + share_code: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "file", db) + ensure_share_access(share, request, password) + project = await get_project_or_404(share.project_id, db) + file_path = storage_service.get_secure_path(project.storage_key, share.file_path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在") + content_type, _ = mimetypes.guess_type(str(file_path)) + return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name) + + +@router.get("/files/{share_code}/export-pdf") +async def export_file_share_pdf( + share_code: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "file", db) + ensure_share_access(share, request, password) + project = await get_project_or_404(share.project_id, db) + file_path = storage_service.get_secure_path(project.storage_key, share.file_path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在") + + if share.file_path.lower().endswith(".pdf"): + raise HTTPException(status_code=400, detail="PDF 文件无需导出") + + content = await storage_service.read_file(file_path) + filename = Path(share.file_path).stem + ".pdf" + content = rewrite_markdown_assets(content, f"/api/v1/shares/files/{share.share_code}/assets") + 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)) + return StreamingResponse( + pdf_buffer, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + +@router.get("/files/{share_code}/assets/{subfolder}/{filename}") +async def get_file_share_asset( + share_code: str, + subfolder: str, + filename: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "file", db) + ensure_share_access(share, request, password) + project = await get_project_or_404(share.project_id, db) + asset_path = f"_assets/{subfolder}/{filename}" + file_path = storage_service.get_secure_path(project.storage_key, asset_path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在") + mime_type, _ = mimetypes.guess_type(filename) + return FileResponse(path=str(file_path), filename=filename, media_type=mime_type or "application/octet-stream") + + +@router.get("/project/{share_code}/assets/{subfolder}/{filename}") +async def get_project_share_asset( + share_code: str, + subfolder: str, + filename: str, + request: Request, + password: Optional[str] = Header(None, alias="X-Access-Password"), + db: AsyncSession = Depends(get_db) +): + share = await get_share_by_code_or_404(share_code, "project", db) + ensure_share_access(share, request, password) + project = await get_project_or_404(share.project_id, db) + asset_path = f"_assets/{subfolder}/{filename}" + file_path = storage_service.get_secure_path(project.storage_key, asset_path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在") + mime_type, _ = mimetypes.guess_type(filename) + return FileResponse(path=str(file_path), filename=filename, media_type=mime_type or "application/octet-stream") diff --git a/backend/app/core/enums.py b/backend/app/core/enums.py index 01b2769..37dfcd6 100644 --- a/backend/app/core/enums.py +++ b/backend/app/core/enums.py @@ -31,6 +31,8 @@ class OperationType(str, Enum): # 分享操作 UPDATE_SHARE_SETTINGS = "update_share_settings" + CREATE_SHARE_LINK = "create_share_link" + DELETE_SHARE_LINK = "delete_share_link" # Git操作 GIT_PULL = "git_pull" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 11133d4..e4bd200 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.role import Role, UserRole from app.models.menu import SystemMenu, RoleMenu from app.models.project import Project, ProjectMember, ProjectMemberRole from app.models.document import DocumentMeta +from app.models.share import ShareLink from app.models.log import OperationLog from app.models.mcp_bot import MCPBot from app.models.llm_model_config import LLMModelConfig @@ -22,6 +23,7 @@ __all__ = [ "ProjectMember", "ProjectMemberRole", "DocumentMeta", + "ShareLink", "OperationLog", "MCPBot", "LLMModelConfig", diff --git a/backend/app/models/share.py b/backend/app/models/share.py new file mode 100644 index 0000000..368ffd3 --- /dev/null +++ b/backend/app/models/share.py @@ -0,0 +1,27 @@ +""" +分享链接模型 +""" +from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger +from sqlalchemy.sql import func + +from app.core.database import Base + + +class ShareLink(Base): + """项目分享/文件分享链接""" + + __tablename__ = "share_links" + + id = Column(BigInteger, primary_key=True, autoincrement=True, comment="分享ID") + project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID") + share_type = Column(String(20), nullable=False, index=True, comment="分享类型: project/file") + share_code = Column(String(64), nullable=False, unique=True, index=True, comment="公开分享码") + file_path = Column(String(500), comment="文件路径,仅文件分享使用") + access_pass = Column(String(100), comment="访问密码") + created_by = Column(BigInteger, index=True, comment="创建人ID") + status = Column(SmallInteger, default=1, index=True, comment="状态:0-禁用 1-启用") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" diff --git a/backend/app/schemas/file.py b/backend/app/schemas/file.py index 24b5530..884e180 100644 --- a/backend/app/schemas/file.py +++ b/backend/app/schemas/file.py @@ -10,6 +10,7 @@ class FileTreeNode(BaseModel): title: str = Field(..., description="节点标题(文件/文件夹名)") key: str = Field(..., description="节点唯一键(相对路径)") isLeaf: bool = Field(..., description="是否叶子节点") + is_shared: bool = Field(False, description="当前文件是否已创建分享链接") children: Optional[List['FileTreeNode']] = Field(None, description="子节点") class Config: diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index f8699db..1573adb 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -23,6 +23,7 @@ class ProjectUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) description: Optional[str] = None is_public: Optional[int] = None + public_access_pass: Optional[str] = Field(None, max_length=100) cover_image: Optional[str] = None status: Optional[int] = None @@ -80,18 +81,33 @@ class ProjectMemberResponse(BaseModel): from_attributes = True +class ProjectShareInfo(BaseModel): + """项目公开分享信息响应 Schema""" + enabled: bool = Field(..., description="是否已开启项目公开") + share_url: Optional[str] = Field(None, description="项目分享链接") + has_password: bool = Field(..., description="是否设置了访问密码") + access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)") + + class ProjectShareSettings(BaseModel): """项目分享设置 Schema""" - access_pass: Optional[str] = Field(None, max_length=100, description="访问密码(None表示取消密码)") + access_pass: Optional[str] = Field(None, max_length=100, description="访问密码(None 表示取消密码)") -class ProjectShareInfo(BaseModel): - """项目分享信息响应 Schema""" - share_url: str = Field(..., description="分享链接") +class FileShareCreate(BaseModel): + """文件分享创建/更新 Schema""" + file_path: str = Field(..., min_length=1, description="文件路径") + access_pass: Optional[str] = Field(None, max_length=100, description="访问密码") + + +class FileShareInfo(BaseModel): + """文件分享信息响应 Schema""" + file_path: str = Field(..., description="文件路径") + share_url: str = Field(..., description="文件分享链接") has_password: bool = Field(..., description="是否设置了访问密码") access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)") class ProjectTransfer(BaseModel): """转移项目所有权 Schema""" - new_owner_id: int = Field(..., description="新所有者ID") \ No newline at end of file + new_owner_id: int = Field(..., description="新所有者ID") diff --git a/backend/scripts/create_share_links_table.sql b/backend/scripts/create_share_links_table.sql new file mode 100644 index 0000000..a377fa3 --- /dev/null +++ b/backend/scripts/create_share_links_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS `share_links` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID', + `project_id` BIGINT NOT NULL COMMENT '项目ID', + `share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file', + `share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码', + `file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用', + `access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_share_code` (`share_code`), + INDEX `idx_share_project` (`project_id`, `share_type`, `status`), + INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`), + INDEX `idx_share_created_by` (`created_by`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表'; diff --git a/backend/scripts/init_database.sql b/backend/scripts/init_database.sql index 3c6b00a..0c2d770 100644 --- a/backend/scripts/init_database.sql +++ b/backend/scripts/init_database.sql @@ -182,6 +182,26 @@ CREATE TABLE IF NOT EXISTS `mcp_bots` ( FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials'; +-- 11. 分享链接表 +CREATE TABLE IF NOT EXISTS `share_links` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID', + `project_id` BIGINT NOT NULL COMMENT '项目ID', + `share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file', + `share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码', + `file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用', + `access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码', + `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', + `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_share_code` (`share_code`), + INDEX `idx_share_project` (`project_id`, `share_type`, `status`), + INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`), + INDEX `idx_share_created_by` (`created_by`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表'; + -- 插入初始角色数据 INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES ('超级管理员', 'super_admin', '拥有系统所有权限', 1), diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1603f6e..490939c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,7 +12,8 @@ import DocumentEditor from '@/pages/Document/DocumentEditor' import Dashboard from '@/pages/Dashboard' import Desktop from '@/pages/Desktop' import Constructing from '@/pages/Constructing' -import PreviewPage from '@/pages/Preview/PreviewPage' +import ProjectSharePage from '@/pages/Preview/ProjectSharePage' +import FileSharePage from '@/pages/Preview/FileSharePage' import ProfilePage from '@/pages/Profile/ProfilePage' import Permissions from '@/pages/System/Permissions' import Users from '@/pages/System/Users' @@ -62,8 +63,8 @@ function App() { } /> - {/* 项目预览(公开访问,无需登录) */} - } /> + } /> + } /> {/* 使用共享布局的路由 */} }> diff --git a/frontend/src/api/share.js b/frontend/src/api/share.js index 512e69e..03ffce6 100644 --- a/frontend/src/api/share.js +++ b/frontend/src/api/share.js @@ -1,45 +1,57 @@ /** - * 项目分享和预览相关 API + * 分享相关 API */ import request from '@/utils/request' -/** - * 获取项目分享信息 - */ export function getProjectShareInfo(projectId) { return request({ - url: `/projects/${projectId}/share`, + url: `/shares/projects/${projectId}`, method: 'get', }) } -/** - * 更新分享设置(设置或取消访问密码) - */ -export function updateShareSettings(projectId, data) { +export function updateProjectShareSettings(projectId, data) { return request({ - url: `/projects/${projectId}/share/settings`, + url: `/shares/projects/${projectId}/settings`, method: 'post', data, }) } -/** - * 获取预览项目基本信息(公开访问) - */ -export function getPreviewInfo(projectId) { +export function getFileShareInfo(projectId, filePath) { return request({ - url: `/preview/${projectId}/info`, + url: `/shares/projects/${projectId}/files/share/info`, + method: 'get', + params: { file_path: filePath }, + }) +} + +export function createOrUpdateFileShare(projectId, data) { + return request({ + url: `/shares/projects/${projectId}/files/share`, + method: 'post', + data, + }) +} + +export function deleteFileShare(projectId, filePath) { + return request({ + url: `/shares/projects/${projectId}/files/share`, + method: 'delete', + params: { file_path: filePath }, + }) +} + +export function getProjectSharePublicInfo(shareCode) { + return request({ + url: `/shares/project/${shareCode}/info`, method: 'get', }) } -/** - * 验证访问密码 - */ -export function verifyAccessPassword(projectId, password) { +export function verifyProjectSharePassword(shareCode, password) { return request({ - url: `/preview/${projectId}/verify`, + url: `/shares/project/${shareCode}/verify`, method: 'post', headers: { 'X-Access-Password': password, @@ -47,42 +59,71 @@ export function verifyAccessPassword(projectId, password) { }) } -/** - * 获取预览项目的文档树 - */ -export function getPreviewTree(projectId, password = null) { +export function getProjectShareTree(shareCode, password = null) { return request({ - url: `/preview/${projectId}/tree`, + url: `/shares/project/${shareCode}/tree`, method: 'get', headers: password ? { 'X-Access-Password': password } : {}, }) } -/** - * 获取预览项目的文件内容 - */ -export function getPreviewFile(projectId, path, password = null) { +export function searchProjectShareDocuments(shareCode, keyword, password = null) { return request({ - url: `/preview/${projectId}/file`, + url: `/shares/project/${shareCode}/search`, + method: 'get', + params: { keyword }, + headers: password ? { 'X-Access-Password': password } : {}, + }) +} + +export function getProjectShareFile(shareCode, path, password = null) { + return request({ + url: `/shares/project/${shareCode}/file`, method: 'get', params: { path }, headers: password ? { 'X-Access-Password': password } : {}, }) } -/** - * 获取预览项目的文档文件URL(PDF等) - */ -export function getPreviewDocumentUrl(projectId, path) { - // 将路径的每个部分分别编码,但保留斜杠 +export function getProjectShareDocumentUrl(shareCode, path) { const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/') - return `/api/v1/preview/${projectId}/document/${encodedPath}` + return `/api/v1/shares/project/${shareCode}/document/${encodedPath}` } -/** - * 导出 PDF - */ -export function exportPDF(projectId, path) { +export function exportProjectSharePDF(shareCode, path) { const encodedPath = encodeURIComponent(path) - return `/api/v1/preview/${projectId}/export-pdf?path=${encodedPath}` + return `/api/v1/shares/project/${shareCode}/export-pdf?path=${encodedPath}` +} + +export function getFileSharePublicInfo(shareCode) { + return request({ + url: `/shares/files/${shareCode}/info`, + method: 'get', + }) +} + +export function verifyFileSharePassword(shareCode, password) { + return request({ + url: `/shares/files/${shareCode}/verify`, + method: 'post', + headers: { + 'X-Access-Password': password, + }, + }) +} + +export function getFileShareContent(shareCode, password = null) { + return request({ + url: `/shares/files/${shareCode}/content`, + method: 'get', + headers: password ? { 'X-Access-Password': password } : {}, + }) +} + +export function getFileShareDocumentUrl(shareCode) { + return `/api/v1/shares/files/${shareCode}/document` +} + +export function exportFileSharePDF(shareCode) { + return `/api/v1/shares/files/${shareCode}/export-pdf` } diff --git a/frontend/src/components/FloatingToc/FloatingToc.css b/frontend/src/components/FloatingToc/FloatingToc.css new file mode 100644 index 0000000..05b4625 --- /dev/null +++ b/frontend/src/components/FloatingToc/FloatingToc.css @@ -0,0 +1,211 @@ +.floating-toc { + position: fixed; + top: 50%; + right: 24px; + z-index: 30; + width: 48px; + height: 180px; + transform: translateY(-50%); + outline: none; +} + +.floating-toc-tab { + position: absolute; + top: 0; + right: 0; + width: 48px; + height: 180px; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, var(--text-color-secondary)); + border-radius: 8px; + background: color-mix(in srgb, var(--card-bg) 92%, transparent); + color: var(--text-color-secondary); + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12); + backdrop-filter: blur(12px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + cursor: default; + transition: opacity 0.18s ease, transform 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.floating-toc-tab span { + writing-mode: vertical-rl; + text-orientation: mixed; + font-size: 12px; + line-height: 1; + letter-spacing: 0; + white-space: nowrap; +} + +.floating-toc-panel { + position: absolute; + top: 0; + right: 0; + width: min(420px, calc(100vw - 320px)); + min-width: 300px; + max-height: min(58vh, 460px); + border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--text-color-secondary)); + border-radius: 8px; + background: color-mix(in srgb, var(--card-bg) 96%, transparent); + color: var(--text-color); + box-shadow: 0 22px 48px rgba(15, 23, 42, 0.18); + backdrop-filter: blur(16px); + opacity: 0; + pointer-events: none; + transform: translateX(10px) scale(0.98); + transform-origin: top right; + overflow: hidden; + transition: opacity 0.18s ease, transform 0.18s ease; +} + +.floating-toc:hover .floating-toc-tab, +.floating-toc:focus-within .floating-toc-tab { + opacity: 0; + transform: translateX(8px) scale(0.96); + pointer-events: none; +} + +.floating-toc:hover .floating-toc-panel, +.floating-toc:focus-within .floating-toc-panel { + opacity: 1; + pointer-events: auto; + transform: translateX(0) scale(1); +} + +.floating-toc.floating-toc-dismissed .floating-toc-tab { + opacity: 1; + transform: none; + pointer-events: auto; +} + +.floating-toc.floating-toc-dismissed .floating-toc-panel { + opacity: 0; + pointer-events: none; + transform: translateX(10px) scale(0.98); +} + +.floating-toc-header { + min-height: 48px; + padding: 0 16px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--text-color); + font-size: 14px; + font-weight: 600; +} + +.floating-toc-count { + min-width: 34px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + background: var(--item-hover-bg); + color: var(--text-color-secondary); + font-size: 12px; + font-weight: 600; + line-height: 22px; + text-align: center; +} + +.floating-toc-content { + max-height: calc(min(58vh, 460px) - 49px); + overflow-y: auto; + overflow-x: hidden; + padding: 10px 8px 12px; +} + +.floating-toc-content .ant-anchor { + padding-left: 0; +} + +.floating-toc-content .ant-anchor::before { + display: none; +} + +.floating-toc-content .ant-anchor-ink { + display: none; +} + +.floating-toc-content .ant-anchor-link { + padding: 2px 0; +} + +.floating-toc-content .ant-anchor-link-title { + border-radius: 6px; + color: var(--text-color-secondary); + line-height: 1.45; + transition: background 0.16s ease, color 0.16s ease; +} + +.floating-toc-content .ant-anchor-link-title:hover, +.floating-toc-content .ant-anchor-link-active > .ant-anchor-link-title { + background: var(--item-hover-bg); + color: var(--link-color); +} + +.floating-toc-item { + min-height: 34px; + padding: 7px 8px; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.floating-toc-item-icon { + flex: none; + font-size: 12px; + color: currentColor; + opacity: 0.72; +} + +.floating-toc-item-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.floating-toc-empty { + padding: 28px 12px; + color: var(--text-color-secondary); + text-align: center; + font-size: 13px; +} + +.floating-toc-drawer .ant-drawer-body { + padding: 0; +} + +.floating-toc-drawer .floating-toc-content { + max-height: none; + height: 100%; + padding: 10px 8px 12px; +} + +body.dark .floating-toc-tab, +body.dark .floating-toc-panel { + box-shadow: 0 22px 48px rgba(0, 0, 0, 0.38); +} + +@media (max-width: 1024px) { + .floating-toc { + right: 16px; + } + + .floating-toc-panel { + width: min(360px, calc(100vw - 300px)); + } +} + +@media (max-width: 768px) { + .floating-toc { + display: none; + } +} diff --git a/frontend/src/components/FloatingToc/FloatingToc.jsx b/frontend/src/components/FloatingToc/FloatingToc.jsx new file mode 100644 index 0000000..0ca2f98 --- /dev/null +++ b/frontend/src/components/FloatingToc/FloatingToc.jsx @@ -0,0 +1,117 @@ +import { useState } from 'react' +import { Anchor, Drawer } from 'antd' +import { FileTextOutlined, MenuOutlined } from '@ant-design/icons' +import './FloatingToc.css' + +function buildAnchorItems(items, searchKeyword, renderTitle) { + return items.map((item) => ({ + key: item.key, + href: item.href, + title: ( +
+ + + {renderTitle ? renderTitle(item, searchKeyword) : item.title} + +
+ ), + })) +} + +function TocContent({ items = [], getContainer, searchKeyword = '', renderTitle, onItemClick, onNavigate }) { + const anchorItems = buildAnchorItems(items, searchKeyword, renderTitle) + + return ( +
+ {items.length > 0 ? ( + { + // antd Anchor 的 link 为 { href, title } 对象 + // 虚拟滚动场景下目标标题可能不在 DOM 中,由 onNavigate 接管滚动 + if (onNavigate) { + e.preventDefault() + onNavigate(link?.href) + } + window.setTimeout(() => onItemClick?.(link), 120) + }} + /> + ) : ( +
当前文档无标题
+ )} +
+ ) +} + +export function TocDrawer({ + open, + onClose, + items = [], + getContainer, + searchKeyword = '', + renderTitle, + onNavigate, +}) { + return ( + + + + ) +} + +export default function FloatingToc({ + items = [], + getContainer, + searchKeyword = '', + renderTitle, + onNavigate, + className = '', +}) { + const [dismissed, setDismissed] = useState(false) + + return ( + + ) +} diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.css b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.css new file mode 100644 index 0000000..b647a31 --- /dev/null +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.css @@ -0,0 +1,99 @@ +/* 外框:与 ByteMD 编辑器一致的边框容器 */ +.large-markdown-editor { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + min-width: 0; + width: 100%; + height: 100%; + border: 1px solid var(--border-color); + border-radius: 2px; + overflow: hidden; + box-sizing: border-box; + background: var(--bg-color); +} + +/* 顶部提示栏:复用 ByteMD 工具栏的视觉规范 */ +.large-markdown-editor-header { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-bottom: 1px solid var(--border-color); + background-color: var(--toolbar-bg); + color: var(--text-color-secondary); + font-size: 13px; + box-sizing: border-box; +} + +.large-markdown-editor-header-icon { + color: var(--link-color); +} + +/* 编辑区域 */ +.large-markdown-editor-body { + flex: 1; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.large-markdown-editor-body .CodeMirror { + width: 100%; + height: 100%; + background: var(--bg-color); + color: var(--text-color); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 14px; + line-height: 1.8; +} + +/* 左右内边距对称 */ +.large-markdown-editor-body .CodeMirror-lines { + padding: 12px 0; +} + +.large-markdown-editor-body .CodeMirror pre.CodeMirror-line, +.large-markdown-editor-body .CodeMirror pre.CodeMirror-line-like { + padding: 0 16px; +} + +.large-markdown-editor-body .CodeMirror-cursor { + border-left-color: var(--text-color); +} + +.large-markdown-editor-body .CodeMirror-placeholder { + color: var(--text-color-secondary); +} + +.large-markdown-editor-body .CodeMirror-selected { + background: rgba(22, 119, 255, 0.16); +} + +/* 暗色模式下覆盖 cm-s-default 的浅色配色,提升对比度 */ +body.dark .large-markdown-editor-body .CodeMirror-selected { + background: rgba(22, 119, 255, 0.28); +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-header { + color: #4ea1ff; +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-quote { + color: #6cc070; +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-link { + color: #58a6ff; +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-url { + color: #d2545b; +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-variable-2 { + color: #79b8ff; +} diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx new file mode 100644 index 0000000..c7134b1 --- /dev/null +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx @@ -0,0 +1,90 @@ +import { useEffect, useRef } from 'react' +import { InfoCircleOutlined } from '@ant-design/icons' +import factory from 'codemirror-ssr' +import usePlaceholder from 'codemirror-ssr/addon/display/placeholder.js' +import useContinuelist from 'codemirror-ssr/addon/edit/continuelist.js' +import useOverlay from 'codemirror-ssr/addon/mode/overlay.js' +import useGfm from 'codemirror-ssr/mode/gfm/gfm.js' +import useMarkdown from 'codemirror-ssr/mode/markdown/markdown.js' +import useXml from 'codemirror-ssr/mode/xml/xml.js' +import 'codemirror-ssr/lib/codemirror.css' +import { LARGE_MARKDOWN_NOTICE } from './LargeMarkdownViewer' +import './LargeMarkdownEditor.css' + +// 复用 ByteMD 底层的 codemirror-ssr,为超大文档提供带 Markdown 源码着色的纯文本编辑器。 +// 不引入新依赖,CodeMirror 5 的视口渲染让超大文档编辑保持流畅。 +function createCodeMirror() { + const codemirror = factory() + usePlaceholder(codemirror) + useOverlay(codemirror) + useXml(codemirror) + useMarkdown(codemirror) + useGfm(codemirror) + useContinuelist(codemirror) + return codemirror +} + +export default function LargeMarkdownEditor({ value = '', onChange, placeholder = '' }) { + const containerRef = useRef(null) + const editorRef = useRef(null) + const onChangeRef = useRef(onChange) + + // 保持 onChange 最新,但不因其变化而重建编辑器 + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + useEffect(() => { + if (!containerRef.current) return + + const codemirror = createCodeMirror() + const editor = codemirror(containerRef.current, { + value, + mode: 'gfm', + lineWrapping: true, + lineNumbers: false, + tabSize: 2, + indentUnit: 2, + placeholder, + extraKeys: { + Enter: 'newlineAndIndentContinueMarkdownList', + Tab: 'indentMore', + 'Shift-Tab': 'indentLess', + }, + }) + + editor.on('change', () => { + onChangeRef.current?.(editor.getValue()) + }) + + editorRef.current = editor + + return () => { + // 卸载时清理 CodeMirror 实例的 DOM + const wrapper = editor.getWrapperElement() + wrapper?.parentNode?.removeChild(wrapper) + editorRef.current = null + } + }, []) + + // 外部 value 变化(切换文件、重置)时同步,避免覆盖正在输入的光标位置 + useEffect(() => { + const editor = editorRef.current + if (!editor) return + if (value !== editor.getValue()) { + const cursor = editor.getCursor() + editor.setValue(value) + editor.setCursor(cursor) + } + }, [value]) + + return ( +
+
+ + {LARGE_MARKDOWN_NOTICE} +
+
+
+ ) +} diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css new file mode 100644 index 0000000..635d42b --- /dev/null +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css @@ -0,0 +1,74 @@ +.large-markdown-viewer { + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.large-markdown-notice { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 3; + width: min(920px, calc(100% - 48px)); + pointer-events: none; +} + +.large-markdown-notice .large-markdown-alert { + margin: 0; + pointer-events: auto; +} + +.markdown-body-large { + width: 100%; + height: 100%; + min-height: 0; +} + +.markdown-virtual-list { + height: 100%; + width: 100%; +} + +.markdown-block { + max-width: 920px; + width: calc(100% - 48px); + margin: 0 auto; + padding: 0 0 1px; +} + +/* 首块下移,避免被固定浮动的提示窗遮挡 */ +.markdown-block[data-index='0'] { + padding-top: 64px; +} + +/* 虚拟滚动条更纤细,减少视觉割裂 */ +.markdown-virtual-list::-webkit-scrollbar { + width: 10px; +} + +.markdown-virtual-list::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--text-color-secondary) 28%, transparent); + border-radius: 999px; + border: 3px solid transparent; + background-clip: padding-box; +} + +.markdown-virtual-list::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--text-color-secondary) 44%, transparent); + background-clip: padding-box; +} + +@media (max-width: 768px) { + .markdown-block { + width: calc(100% - 32px); + } +} + +@media (max-width: 480px) { + .markdown-block { + width: calc(100% - 24px); + } +} diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx new file mode 100644 index 0000000..5076589 --- /dev/null +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx @@ -0,0 +1,156 @@ +import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react' +import { Alert } from 'antd' +import { Virtuoso } from 'react-virtuoso' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeRaw from 'rehype-raw' +import rehypeSlug from 'rehype-slug' +import rehypeHighlight from 'rehype-highlight' +import GithubSlugger from 'github-slugger' +import FloatingToc from '@/components/FloatingToc/FloatingToc' +import './LargeMarkdownViewer.css' + +export const LARGE_MARKDOWN_THRESHOLD = 250000 +export const LARGE_MARKDOWN_NOTICE = '此文档内容过长,将采用精简模式显示' +const MAX_TOC_ITEMS = 500 + +export function isLargeMarkdownContent(content = '') { + return content.length > LARGE_MARKDOWN_THRESHOLD +} + +export function MarkdownSizeNotice({ className = '' }) { + return ( +
+ +
+ ) +} + +// 将 markdown 切分为块,并在切分的同时提取标题用于目录导航。 +// 每个块以标题或空行为边界,目录项记录其所在块索引,便于虚拟滚动定位。 +function buildBlocksAndToc(content) { + if (!content) return { blocks: [], tocItems: [] } + + const blocks = [] + const tocItems = [] + const slugger = new GithubSlugger() + const lines = content.split('\n') + let current = [] + let inFence = false + + const flush = () => { + if (current.length > 0) { + blocks.push(current.join('\n')) + current = [] + } + } + + for (const line of lines) { + const isFenceLine = /^\s*(```|~~~)/.test(line) + const headingMatch = inFence ? null : line.match(/^(#{1,6})\s+(.+)$/) + + if (headingMatch) { + flush() + if (tocItems.length < MAX_TOC_ITEMS) { + const key = slugger.slug(headingMatch[2].trim()) + tocItems.push({ + key: `#${key}`, + href: `#${key}`, + title: headingMatch[2].trim(), + level: headingMatch[1].length, + blockIndex: blocks.length, // 该标题即将进入的块的索引 + }) + } + } + + current.push(line) + + if (isFenceLine) { + inFence = !inFence + } + + if (!inFence && line.trim() === '') { + flush() + } + } + + flush() + return { blocks, tocItems } +} + +const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer( + { content, components, onClick, searchKeyword = '', renderTitle }, + ref +) { + const virtuosoRef = useRef(null) + const { blocks: markdownBlocks, tocItems } = useMemo( + () => buildBlocksAndToc(content), + [content] + ) + + const hrefToIndex = useMemo(() => { + const map = new Map() + for (const item of tocItems) { + if (!map.has(item.href)) { + map.set(item.href, item.blockIndex) + } + } + return map + }, [tocItems]) + + const scrollToIndex = (index) => { + virtuosoRef.current?.scrollToIndex({ + index, + align: 'start', + behavior: 'smooth', + }) + } + + useImperativeHandle(ref, () => ({ + scrollToTop: () => scrollToIndex(0), + }), []) + + const handleTocNavigate = (link) => { + const index = hrefToIndex.get(link) + if (typeof index === 'number') { + scrollToIndex(index) + } + } + + return ( +
+ +
+ ( +
+ + {block} + +
+ )} + /> +
+ +
+ ) +}) + +export default LargeMarkdownViewer diff --git a/frontend/src/components/MainLayout/AppHeader.css b/frontend/src/components/MainLayout/AppHeader.css index 6429ab2..2ce81ca 100644 --- a/frontend/src/components/MainLayout/AppHeader.css +++ b/frontend/src/components/MainLayout/AppHeader.css @@ -8,6 +8,7 @@ height: 64px; border-bottom: 1px solid var(--border-color); color: var(--text-color); + overflow: visible; } /* 左侧区域 */ @@ -55,6 +56,50 @@ gap: 16px; } +.workspace-header-brand { + position: relative; + height: 64px; + min-width: 360px; + padding-left: 70px; + padding-right: 36px; + display: inline-flex; + align-items: center; + gap: 14px; +} + +.workspace-header-brand::before { + content: ''; + position: absolute; + top: 0; + width: 1px; + height: 64px; + background: var(--border-color); +} + +.workspace-header-brand::before { + left: 0; +} + +.workspace-brand-icon { + font-size: 22px; + color: #6b7280; +} + +.workspace-brand-title { + font-size: 18px; + font-weight: 600; + color: #4b5563; + white-space: nowrap; +} + +body.dark .workspace-brand-title { + color: var(--text-color); +} + +body.dark .workspace-brand-icon { + color: var(--text-color-secondary); +} + /* Icon Buttons */ .header-icon-btn { display: flex; @@ -151,4 +196,4 @@ body.dark .notification-item.unread:hover { padding: 8px; border-top: 1px solid var(--border-color); text-align: center; -} \ No newline at end of file +} diff --git a/frontend/src/components/MainLayout/AppHeader.jsx b/frontend/src/components/MainLayout/AppHeader.jsx index 09f6f4b..814c68c 100644 --- a/frontend/src/components/MainLayout/AppHeader.jsx +++ b/frontend/src/components/MainLayout/AppHeader.jsx @@ -10,7 +10,7 @@ import { NotificationOutlined, MoonOutlined, SunOutlined, - GlobalOutlined + AppstoreOutlined } from '@ant-design/icons' import useUserStore from '@/stores/userStore' import useNotificationStore from '@/stores/notificationStore' @@ -151,7 +151,12 @@ function AppHeader({ collapsed, onToggle, showLogo = true }) {
)} - {!showLogo &&
} {/* Spacer if left is empty */} + {!showLogo && ( +
+ + NexDocus Workspace +
+ )} {/* 右侧:功能按钮 */}
diff --git a/frontend/src/components/MainLayout/MainLayout.css b/frontend/src/components/MainLayout/MainLayout.css index e71ef55..45ea1a7 100644 --- a/frontend/src/components/MainLayout/MainLayout.css +++ b/frontend/src/components/MainLayout/MainLayout.css @@ -24,4 +24,4 @@ .content-wrapper { padding: 0; min-height: 100%; -} \ No newline at end of file +} diff --git a/frontend/src/components/MainLayout/MainLayout.jsx b/frontend/src/components/MainLayout/MainLayout.jsx index 726c328..2603908 100644 --- a/frontend/src/components/MainLayout/MainLayout.jsx +++ b/frontend/src/components/MainLayout/MainLayout.jsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' +import { useLocation } from 'react-router-dom' import { Layout } from 'antd' import AppSider from './AppSider' import AppHeader from './AppHeader' @@ -6,9 +7,19 @@ import './MainLayout.css' const { Content } = Layout +// 进入项目文档页/编辑页时自动折叠全局侧边栏,给文档内容腾出空间 +const COLLAPSE_PATTERNS = [/^\/projects\/[^/]+\/docs/, /^\/projects\/[^/]+\/editor/] + function MainLayout({ children }) { + const location = useLocation() const [collapsed, setCollapsed] = useState(false) + useEffect(() => { + if (COLLAPSE_PATTERNS.some((pattern) => pattern.test(location.pathname))) { + setCollapsed(true) + } + }, [location.pathname]) + const toggleCollapsed = () => { setCollapsed(!collapsed) } diff --git a/frontend/src/components/MarkdownViewer/MarkdownViewer.jsx b/frontend/src/components/MarkdownViewer/MarkdownViewer.jsx new file mode 100644 index 0000000..e9779d7 --- /dev/null +++ b/frontend/src/components/MarkdownViewer/MarkdownViewer.jsx @@ -0,0 +1,48 @@ +import { forwardRef, useMemo } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeRaw from 'rehype-raw' +import rehypeSlug from 'rehype-slug' +import rehypeHighlight from 'rehype-highlight' + +const MarkdownViewer = forwardRef(function MarkdownViewer( + { + content = '', + components, + onClick, + className = '', + allowRaw = false, + enableHighlight = true, + }, + ref +) { + const rehypePlugins = useMemo(() => { + const plugins = [] + if (allowRaw) { + plugins.push(rehypeRaw) + } + plugins.push(rehypeSlug) + if (enableHighlight) { + plugins.push(rehypeHighlight) + } + return plugins + }, [allowRaw, enableHighlight]) + + return ( +
+ + {content} + +
+ ) +}) + +export default MarkdownViewer diff --git a/frontend/src/components/ModeSwitch/ModeSwitch.css b/frontend/src/components/ModeSwitch/ModeSwitch.css index 3fd7eae..be1c770 100644 --- a/frontend/src/components/ModeSwitch/ModeSwitch.css +++ b/frontend/src/components/ModeSwitch/ModeSwitch.css @@ -57,6 +57,18 @@ opacity: 1; } +.mode-switch-small { + padding: 2px; +} + +.mode-switch-small .mode-switch-option { + min-width: 40px; + height: 24px; + padding: 0 8px; + font-size: 11px; + font-weight: 600; +} + body.dark .mode-switch { background: linear-gradient(180deg, #2e3748 0%, #252d3b 100%); border-color: rgba(137, 156, 186, 0.24); diff --git a/frontend/src/components/ModeSwitch/ModeSwitch.jsx b/frontend/src/components/ModeSwitch/ModeSwitch.jsx index ab63031..c23b4f6 100644 --- a/frontend/src/components/ModeSwitch/ModeSwitch.jsx +++ b/frontend/src/components/ModeSwitch/ModeSwitch.jsx @@ -7,6 +7,7 @@ function ModeSwitch({ editLabel = '编辑', options, ariaLabel = '模式切换', + size = 'default', }) { const finalOptions = options || [ { label: viewLabel, value: 'view' }, @@ -19,7 +20,7 @@ function ModeSwitch({ return (
{logo}
- {/* 折叠按钮 - 悬浮在边缘 */} -
onCollapse && onCollapse(!collapsed)} + aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'} > {collapsed ? : } -
+
{/* 菜单列表区域 */} diff --git a/frontend/src/components/PDFViewer/VirtualPDFViewer.css b/frontend/src/components/PDFViewer/VirtualPDFViewer.css index e95ef79..61fec14 100644 --- a/frontend/src/components/PDFViewer/VirtualPDFViewer.css +++ b/frontend/src/components/PDFViewer/VirtualPDFViewer.css @@ -7,22 +7,56 @@ .pdf-toolbar { display: flex; - justify-content: space-between; + justify-content: flex-end; align-items: center; - padding: 12px 16px; - background: var(--card-bg); - border-bottom: 1px solid var(--border-color); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + gap: 16px; + min-width: 0; + background: transparent; z-index: 10; color: var(--text-color); } +.pdf-toolbar-scale { + min-width: 50px; + text-align: center; + color: var(--text-color); + font-size: 13px; +} + +.pdf-toolbar-divider { + width: 1px; + height: 18px; + background: var(--border-color); + display: inline-block; +} + +.pdf-toolbar-compact .ant-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-color-secondary); +} + +.pdf-toolbar-compact .ant-btn:hover { + background: var(--item-hover-bg); + color: var(--link-color); +} + .pdf-content { flex: 1; overflow: auto; position: relative; } +.virtual-pdf-viewer-container > .pdf-toolbar { + padding: 12px 16px; + background: var(--card-bg); + border-bottom: 1px solid var(--border-color); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + .pdf-virtual-list { background: var(--bg-color-secondary); } @@ -129,4 +163,4 @@ .react-pdf__Document { height: 100%; width: 100%; -} \ No newline at end of file +} diff --git a/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx b/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx index 5e3ed62..9330494 100644 --- a/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx +++ b/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx @@ -1,6 +1,7 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' import { Document, Page, pdfjs } from 'react-pdf' -import { Button, Space, InputNumber, message, Spin } from 'antd' +import { Button, Space, InputNumber, message, Spin, Tooltip } from 'antd' import { ZoomInOutlined, ZoomOutOutlined, @@ -16,7 +17,7 @@ import './VirtualPDFViewer.css' // 配置 PDF.js worker pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs' -function VirtualPDFViewer({ url, filename }) { +function VirtualPDFViewer({ url, filename, toolbarTarget, compactToolbar = false }) { const [numPages, setNumPages] = useState(null) const [scale, setScale] = useState(1.0) const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4 @@ -159,65 +160,90 @@ function VirtualPDFViewer({ url, filename }) { document.body.removeChild(link) } - return ( -
- {/* 工具栏 */} -
- - - - + aria-label="回到顶部" + /> + +
+ ) : ( +
+ + + + {Math.round(scale * 100)}% + + + - + + + + +
+ ) - - - - {Math.round(scale * 100)}% - - - -
+ return ( +
+ {toolbarTarget ? createPortal(toolbar, toolbarTarget) : toolbar} {/* PDF内容区 - 自定义虚拟滚动 */}
diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index ecaac86..157c9a1 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -1,3 +1,5 @@ +@import '@/components/LargeMarkdownViewer/LargeMarkdownViewer.css'; + .document-editor-page { height: calc(100vh - 64px); /* width: calc(100% + 32px); */ @@ -33,20 +35,63 @@ } .sider-header { - padding: 16px 20px; + padding: 12px 10px 12px; border-bottom: 1px solid var(--border-color); display: flex; flex-direction: column; - gap: 12px; + gap: 10px; background: var(--header-bg); } .sider-header h2 { margin: 0; - font-size: 16px; - font-weight: 600; + font-size: 20px; + font-weight: 700; + letter-spacing: 0; color: var(--text-color); - line-height: 1.5; + line-height: 1.1; +} + +.sider-title-row { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.sider-title-row h2 { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-back-button { + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + color: #707480; + font-size: 16px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.project-back-button:hover { + background: rgba(17, 24, 39, 0.06); + color: #2f3440; + transform: translateX(-1px); +} + +.project-back-button:focus-visible { + outline: 2px solid rgba(22, 119, 255, 0.35); + outline-offset: 2px; } .sider-actions { @@ -91,48 +136,20 @@ color: var(--text-color); } -/* 修复Tree组件文档名过长的显示问题 */ -.file-tree .ant-tree-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; -} - -.file-tree .ant-tree-node-content-wrapper { - overflow: hidden; - max-width: 100%; -} - -.file-tree .ant-tree-treenode { - overflow: hidden; -} - -/* 确保Tree节点标题区域不折行 */ -.file-tree .ant-tree-node-content-wrapper .ant-tree-title { - display: inline-block; - max-width: calc(100% - 24px); - /* 预留图标空间 */ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap !important; - vertical-align: middle; -} - -/* 修复文档名过长的显示问题(Menu组件,已废弃但保留兼容) */ .file-tree .ant-menu-title-content { + display: flex; + align-items: center; + min-width: 0; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; - /* Ensure it allows children to fill width */ } /* Increase hit area for context menu */ .tree-node-wrapper { width: 100%; height: 100%; - display: block; + display: flex; + align-items: center; + min-width: 0; user-select: none; padding: 4px 8px; margin: -4px -8px; @@ -141,6 +158,14 @@ color: var(--text-color); } +.tree-node-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* 选中的文件夹样式 */ .file-tree .folder-selected>.ant-menu-submenu-title { background-color: var(--item-hover-bg) !important; @@ -155,8 +180,8 @@ .file-tree .ant-menu-submenu-title { overflow: hidden; display: flex !important; - /* Ensure flex layout for item */ align-items: center; + min-width: 0; } .document-content { @@ -183,8 +208,8 @@ min-height: 57px; border-bottom: 1px solid var(--border-color); display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; background: var(--header-bg); flex-shrink: 0; } @@ -212,8 +237,14 @@ padding: 5px 10px; } -/* Fix for bytemd-react wrapper div */ -.bytemd-wrapper>div { +.bytemd-wrapper.large-file-editor { + position: relative; + display: flex; + flex-direction: column; +} + +/* Fix for bytemd-react wrapper div(仅普通编辑器,超大着色编辑器不受此约束) */ +.bytemd-wrapper:not(.large-file-editor)>div { flex: 1; display: flex; flex-direction: column; @@ -263,6 +294,11 @@ background-color: var(--item-hover-bg); } +/* Keep the TOC entry visible; hide the help sidebar button. */ +.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(2) { + display: none; +} + /* 编辑和预览区域容器 */ .bytemd-body { flex: 1 !important; @@ -277,15 +313,10 @@ background-color: var(--bg-color); } -/* 编辑区域 - 固定50%宽度 */ +/* 编辑区域 - 默认分栏,保留 Bytemd 内联样式对仅编辑/仅预览的控制 */ .bytemd-editor { - width: 50% !important; - flex: 0 0 50% !important; - display: flex !important; - flex-direction: column !important; overflow: hidden; min-height: 0; - max-width: 50% !important; box-sizing: border-box; /* Added for consistent box model */ min-width: 0; @@ -294,16 +325,13 @@ border-right: 1px solid var(--border-color); } -/* 预览区域 - 固定50%宽度 */ +/* 预览区域 - 默认分栏,保留 Bytemd 内联样式对仅编辑/仅预览的控制 */ .bytemd-preview { - width: 50% !important; - flex: 0 0 50% !important; overflow-y: auto !important; overflow-x: hidden !important; padding: 16px; font-size: 14px; line-height: 1.8; - max-width: 50% !important; box-sizing: border-box; /* Added for consistent box model */ min-width: 0; @@ -434,16 +462,10 @@ margin-bottom: 0.25em; } -/* 文件名过长时显示省略号,不折行 */ -.content-header h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 600px; - color: var(--text-color); +.content-header .preview-header-title { + flex: 1; + min-width: 0; + max-width: none; } @media (max-width: 768px) { diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index fcf5556..7fc7e4a 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -4,20 +4,19 @@ import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, S import { FileOutlined, FolderOutlined, - PlusOutlined, + FolderOpenOutlined, DeleteOutlined, EditOutlined, SaveOutlined, FileAddOutlined, FolderAddOutlined, UploadOutlined, + DownloadOutlined, SwapOutlined, - ReloadOutlined, - FileImageOutlined, FilePdfOutlined, FileTextOutlined, UndoOutlined, - CloseOutlined, + ArrowLeftOutlined, } from '@ant-design/icons' import { Editor } from '@bytemd/react' import gfm from '@bytemd/plugin-gfm' @@ -28,16 +27,19 @@ import gemoji from '@bytemd/plugin-gemoji' import 'bytemd/dist/index.css' import 'highlight.js/styles/github.css' import { + getProjectTree, + getFileContent, saveFile, operateFile, uploadFile, importDocuments, uploadDocument, + getDocumentUrl, } from '@/api/file' import Toast from '@/components/Toast/Toast' import ModeSwitch from '@/components/ModeSwitch/ModeSwitch' -import { findNodeByKey } from './documentBrowserUtils' -import useDocumentEditorWorkspace from './useDocumentEditorWorkspace' +import { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer' +import LargeMarkdownEditor from '@/components/LargeMarkdownViewer/LargeMarkdownEditor' import './DocumentEditor.css' const { Sider, Content } = Layout @@ -47,33 +49,11 @@ function DocumentEditor() { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const fileInputRef = useRef(null) - 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 [treeData, setTreeData] = useState([]) + const [selectedFile, setSelectedFile] = useState(null) + const [selectedNode, setSelectedNode] = useState(null) // 当前选中的节点(可能是文件或目录) + const [fileContent, setFileContent] = useState('') + const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [modalVisible, setModalVisible] = useState(false) const [moveModalVisible, setMoveModalVisible] = useState(false) @@ -83,16 +63,43 @@ function DocumentEditor() { const [creationParentPath, setCreationParentPath] = useState('') 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) + const isLargeMarkdown = isLargeMarkdownContent(fileContent) + + const isHeaderPdf = selectedFile?.toLowerCase().endsWith('.pdf') + const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined + const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : '请选择文件' + + const linkTreeData = useMemo(() => { + const markSelectable = (nodes) => nodes.map((node) => { + if (node.children?.length) { + return { + ...node, + selectable: false, + children: markSelectable(node.children), + } + } + return { + ...node, + selectable: true, + } + }) + + return markSelectable(treeData) + }, [treeData]) const navigateWithTransition = (to) => { if (document.startViewTransition) { @@ -102,6 +109,153 @@ function DocumentEditor() { navigate(to) } + const updateSelectedParam = (path, isFile = true) => { + const nextParams = new URLSearchParams(searchParams) + nextParams.delete('file') + nextParams.delete('selected') + + if (path) { + nextParams.set(isFile ? 'file' : 'selected', path) + } + setSearchParams(nextParams, { replace: true }) + } + + const updateFileParam = (filePath) => { + updateSelectedParam(filePath, true) + } + + const buildDocumentUrl = (filePath) => { + const params = new URLSearchParams() + const token = localStorage.getItem('access_token') + + if (token) { + params.set('token', token) + } + + const query = params.toString() + return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}` + } + + const downloadByUrl = (url, filename) => { + 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) + } + + const downloadBlob = (blob, filename) => { + const url = window.URL.createObjectURL(blob) + downloadByUrl(url, filename) + window.URL.revokeObjectURL(url) + } + + const getSelectedDownloadNode = () => { + if (selectedNode) return selectedNode + if (selectedMenuKey) return findNodeByKey(treeData, selectedMenuKey) + if (selectedFile) return findNodeByKey(treeData, selectedFile) + return null + } + + const getDownloadFilename = (path) => path.split('/').filter(Boolean).pop() || 'document' + + const downloadMarkdownFile = () => { + const filename = getDownloadFilename(selectedFile) + downloadBlob(new Blob([fileContent], { type: 'text/markdown;charset=utf-8' }), filename) + } + + const handleDownloadSelectedFile = () => { + const targetNode = getSelectedDownloadNode() + + if (!targetNode) { + Toast.warning('提示', '请先选择文件') + return + } + + if (!targetNode.isLeaf) { + Toast.warning('暂不支持文件夹下载', '请选择 Markdown 或 PDF 文件') + return + } + + const path = targetNode.key + const lowerPath = path.toLowerCase() + + if (!lowerPath.endsWith('.md') && !lowerPath.endsWith('.pdf')) { + Toast.warning('暂不支持该文件类型', '请选择 Markdown 或 PDF 文件') + return + } + + if (lowerPath.endsWith('.md')) { + if (selectedFile === path) { + downloadMarkdownFile() + } else { + getFileContent(projectId, path).then((res) => { + downloadBlob( + new Blob([res.data?.content || ''], { type: 'text/markdown;charset=utf-8' }), + getDownloadFilename(path) + ) + }).catch((error) => { + console.error('Download markdown error:', error) + }) + } + return + } + + downloadByUrl(buildDocumentUrl(path), getDownloadFilename(path)) + } + + const selectFolder = (folderPath, { syncUrl = false } = {}) => { + const targetNode = findNodeByKey(treeData, folderPath) + if (targetNode) { + setSelectedNode(targetNode) + } + setSelectedMenuKey(folderPath) + setSelectedFile(null) + setIsPdfSelected(false) + setFileContent('') + + if (syncUrl) { + updateSelectedParam(folderPath, false) + } + } + + const getModeSwitchTarget = () => { + const params = new URLSearchParams() + + if (selectedFile) { + params.set('file', selectedFile) + } else if (selectedMenuKey) { + const targetNode = findNodeByKey(treeData, selectedMenuKey) + if (targetNode && !targetNode.isLeaf) { + params.set('selected', selectedMenuKey) + } + } else { + const selectedParam = searchParams.get('selected') + if (selectedParam) { + params.set('selected', selectedParam) + } + } + + const query = params.toString() + return `/projects/${projectId}/docs${query ? `?${query}` : ''}` + } + + const encodeMarkdownLinkTarget = (targetPath) => { + if (!targetPath) return targetPath + + return targetPath + .split('/') + .map((part) => { + if (!part || part === '.' || part === '..') { + return part + } + return encodeURIComponent(part) + }) + .join('/') + } + // 插入内链接 const handleInsertLink = () => { if (!linkTarget) { @@ -118,7 +272,7 @@ function DocumentEditor() { const fileName = linkTarget.split('/').pop() // 如果没有选中文字,则使用文件名作为链接文字;否则保留原文字 const linkTitle = selection || fileName - const linkText = `[${linkTitle}](${linkTarget})` + const linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})` editor.replaceSelection(linkText) editor.focus() @@ -128,24 +282,23 @@ function DocumentEditor() { setLinkTarget(null) } - // 在组件挂载后立即计算正确的高度 useEffect(() => { - const calculateHeight = () => { - const windowHeight = window.innerHeight - const newHeight = Math.max(windowHeight - 180, 400) // 最小高度400px - setEditorHeight(newHeight) + 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('加载失败', '加载文件树失败') } - - // 立即执行一次 - calculateHeight() - - // 监听窗口大小变化 - window.addEventListener('resize', calculateHeight) - return () => window.removeEventListener('resize', calculateHeight) - }, []) - - const handleRefresh = async () => { - await refreshCurrentDocument() } const handleClose = () => { @@ -164,6 +317,105 @@ function DocumentEditor() { }) } + // 查找树节点的辅助函数 + const findNodeByKey = (nodes, key) => { + for (const node of nodes) { + if (node.key === key) { + return node + } + if (node.children) { + const found = findNodeByKey(node.children, key) + if (found) return found + } + } + return null + } + + 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) { + setSelectedFile(null) + setIsPdfSelected(false) + setFileContent('') + if (syncUrl) { + updateSelectedParam(key, false) + } + 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') + const selectedParam = searchParams.get('selected') + + if (selectedParam) { + const targetNode = findNodeByKey(treeData, selectedParam) + if (targetNode && !targetNode.isLeaf && selectedParam !== selectedMenuKey) { + selectFolder(selectedParam) + } + return + } + + if (!fileParam) return + if (fileParam === selectedFile) return + + const targetNode = findNodeByKey(treeData, fileParam) + if (!targetNode) return + + openNodeByKey(fileParam, targetNode, false) + }, [treeData, searchParams]) + + // Menu点击处理(适配Menu组件) + const handleMenuClick = async ({ key, domEvent }) => { + const node = findNodeByKey(treeData, key) + if (!node) return + await openNodeByKey(key, node, true) + } + // 重置当前编辑内容(重新从服务器加载) const handleReset = () => { if (!selectedFile) return @@ -172,8 +424,16 @@ function DocumentEditor() { title: '确认重置', content: '确定要重置当前修改吗?所有未保存的更改都将丢失。', onOk: async () => { - await openNodeByKey(selectedFile, null, false) - Toast.success('重置成功', '已恢复至最后保存的版本') + setLoading(true) + try { + const res = await getFileContent(projectId, selectedFile) + setFileContent(res.data.content) + Toast.success('重置成功', '已恢复至最后保存的版本') + } catch (error) { + Toast.error('重置失败', '无法重新加载文件内容') + } finally { + setLoading(false) + } }, }) } @@ -243,7 +503,8 @@ function DocumentEditor() { }) Toast.success('成功', '删除成功') if (selectedFile === path) { - clearSelection() + setSelectedFile(null) + setFileContent('') } fetchTree() } catch (error) { @@ -331,7 +592,8 @@ function DocumentEditor() { // 如果重命名的是当前打开的文件,清空编辑器 if (operationType === 'rename' && selectedFile === rightClickNode) { - clearSelection() + setSelectedFile(null) + setFileContent('') } } catch (error) { console.error('Operation error:', error) @@ -536,7 +798,8 @@ function DocumentEditor() { // 如果移动的是当前打开的文件,清空编辑器 if (selectedFile === rightClickNode) { - clearSelection() + setSelectedFile(null) + setFileContent('') } } catch (error) { console.error('Move error:', error) @@ -744,23 +1007,30 @@ function DocumentEditor() { menu={{ items: getNodeMenuItems(node) }} trigger={['contextMenu']} > -
- {node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title} -
+ +
+ + {node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title} + +
+
) if (!node.isLeaf) { + const isOpen = openKeys.includes(node.key) + const folderIconStyle = isSelected ? { color: '#1890ff' } : undefined // 目录 - 通过className和style控制选中样式 return { key: node.key, label: labelContent, - icon: , + icon: isOpen + ? + : , children: node.children ? convertTreeToMenuItems(node.children) : [], className: isSelected ? 'folder-selected' : '', onTitleClick: () => { - setSelectedNode(node) - setSelectedMenuKey(node.key) + selectFolder(node.key, { syncUrl: true }) }, } } else if (node.title && node.title.endsWith('.md')) { @@ -792,54 +1062,40 @@ function DocumentEditor() { return (
- +
-
-

- {projectName} -

- - +

{projectName}

{ if (mode === 'view' && !modeSwitchingRef.current) { modeSwitchingRef.current = true setModeSwitchValue('view') setTimeout(() => { - const params = new URLSearchParams() - if (selectedFile) { - params.set('file', selectedFile) - } - const query = params.toString() - navigateWithTransition(`/projects/${projectId}/docs${query ? `?${query}` : ''}`) + navigateWithTransition(getModeSwitchTarget()) }, 160) } }} /> - -
@@ -893,7 +1156,10 @@ function DocumentEditor() {
-

{selectedFile || '请选择文件'}

+

+ + {headerLabel} +

+

{projectName}

{/* 只有 owner/admin/editor 可以编辑和Git操作 */} {userRole !== 'viewer' ? ( { if (mode === 'edit' && !modeSwitchingRef.current) { @@ -499,14 +1049,6 @@ function DocumentPage() {
)} - -
@@ -553,10 +1103,41 @@ function DocumentPage() { {/* 右侧内容区 */} -
-

{selectedFile || 'README.md'}

+
+ {(() => { + const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile) + return ( + <> +
+ + + {fileName} + +
+ {viewMode === 'pdf' &&
} + {viewMode === 'markdown' && ( + + + + + )} + + ) + })()}
-
+
{loading ? (
@@ -567,6 +1148,20 @@ function DocumentPage() { + ) : viewMode === 'folder' ? ( +
+ + 已选择文件夹,请从左侧选择 Markdown 或 PDF 文件查看。 +
+ ) : isLargeMarkdown ? ( + } /> ) : (
@@ -581,118 +1176,88 @@ function DocumentPage() { )}
- {/* 浮动按钮组 - 仅在markdown模式显示 */} - {viewMode === 'markdown' && ( - { - if (!selectedFile) return - window.open(buildAuthorizedUrl(getExportPdfUrl(projectId, selectedFile)), '_blank') - }} - /> - )} - {/* 右侧TOC面板 - 仅在markdown模式显示 */} - {viewMode === 'markdown' && !tocCollapsed && ( - -
-

文档索引

-
-
- {tocItems.length > 0 ? ( - contentRef.current} - items={tocItems.map((item) => ({ - key: item.key, - href: item.href, - title: ( -
- - -
- ), - }))} - /> - ) : ( -
当前文档无标题
- )} -
-
+ {viewMode === 'markdown' && !isLargeMarkdown && ( + contentRef.current} + renderTitle={(item, keyword) => } + /> )} - - {/* TOC展开按钮 */} - {tocCollapsed && ( - - )} {/* 分享模态框 */} {/* ... keeping the modal ... */} setShareModalVisible(false)} footer={null} width={500} > - {shareInfo && ( - -
- - - } - /> -
+ +
+ + +
-
- - 访问密码保护 - - -
- - {hasPassword && ( + {shareInfo?.share_url ? ( + <>
- setPassword(e.target.value)} + + + } /> -
- )} -
- )} + +
+ + 访问密码保护 + + +
+ + {hasPassword && ( + + setPassword(e.target.value)} + /> + + + )} + + ) : ( + <> +
+ 当前文件尚未创建独立分享。 +
+ + + + )} + {shareInfo?.share_url && ( + + )} +
) diff --git a/frontend/src/pages/Preview/FileSharePage.jsx b/frontend/src/pages/Preview/FileSharePage.jsx new file mode 100644 index 0000000..a92f22a --- /dev/null +++ b/frontend/src/pages/Preview/FileSharePage.jsx @@ -0,0 +1,323 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Layout, Modal, Input, Spin, Button, Space, Tooltip } from 'antd' +import { LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, MenuOutlined, ArrowLeftOutlined } from '@ant-design/icons' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeHighlight from 'rehype-highlight' +import rehypeSlug from 'rehype-slug' +import 'highlight.js/styles/github.css' +import GithubSlugger from 'github-slugger' +import Toast from '@/components/Toast/Toast' +import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc' +import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' +import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer' +import { + getFileSharePublicInfo, + verifyFileSharePassword, + getFileShareContent, + exportFileSharePDF, +} from '@/api/share' +import './PreviewPage.css' + +const { Content } = Layout + +function FileSharePage() { + const { shareCode } = useParams() + const navigate = useNavigate() + const contentRef = useRef(null) + const largeMarkdownRef = useRef(null) + const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) + const [shareInfo, setShareInfo] = useState(null) + const [contentInfo, setContentInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [isMobile, setIsMobile] = useState(false) + const [tocItems, setTocItems] = useState([]) + const [tocDrawerVisible, setTocDrawerVisible] = useState(false) + const [passwordModalVisible, setPasswordModalVisible] = useState(false) + const [password, setPassword] = useState('') + const markdownContent = contentInfo?.type === 'markdown' ? (contentInfo.content || '') : '' + const isLargeMarkdown = isLargeMarkdownContent(markdownContent) + + useEffect(() => { + loadFileShare() + }, [shareCode]) + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768) + checkMobile() + window.addEventListener('resize', checkMobile) + return () => window.removeEventListener('resize', checkMobile) + }, []) + + useEffect(() => { + if (!markdownContent || isLargeMarkdown) { + setTocItems([]) + return + } + + const slugger = new GithubSlugger() + const headings = [] + markdownContent.split('\n').forEach((line) => { + const match = line.match(/^(#{1,6})\s+(.+)$/) + if (!match) return + const level = match[1].length + const title = match[2].trim() + const key = slugger.slug(title) + headings.push({ key: `#${key}`, href: `#${key}`, title, level }) + }) + setTocItems(headings) + }, [markdownContent, isLargeMarkdown]) + + const handleClose = () => { + if (window.history.length > 1) { + navigate(-1) + return + } + navigate('/') + } + + const loadFileShare = async () => { + setLoading(true) + try { + const infoRes = await getFileSharePublicInfo(shareCode) + setShareInfo(infoRes.data) + + if (infoRes.data.has_password) { + setContentInfo(null) + setPasswordModalVisible(true) + setLoading(false) + return + } + + const contentRes = await getFileShareContent(shareCode) + setContentInfo(contentRes.data) + } catch (error) { + console.error('Load file share error:', error) + Toast.error('加载失败', '分享链接不存在或已失效') + } finally { + setLoading(false) + } + } + + const handleVerifyPassword = async () => { + if (!password.trim()) { + Toast.warning('提示', '请输入访问密码') + return + } + try { + await verifyFileSharePassword(shareCode, password) + const contentRes = await getFileShareContent(shareCode, password) + setContentInfo(contentRes.data) + setPasswordModalVisible(false) + Toast.success('验证成功') + } catch (error) { + Toast.error('访问密码错误') + } + } + + const handleExportPDF = () => { + if (!contentInfo || contentInfo.type === 'pdf') return + window.open(exportFileSharePDF(shareCode), '_blank') + } + + const scrollContentToTop = () => { + if (isLargeMarkdown) { + largeMarkdownRef.current?.scrollToTop() + return + } + + if (contentRef.current) { + contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + } + + const isExternalHref = (href) => { + return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//'))) + } + + const isInternalFileHref = (href) => { + if (!href) return false + if (href.startsWith('#')) return false + if (isExternalHref(href)) return false + const pathOnly = href.split(/[?#]/)[0] + return pathOnly.endsWith('.md') || pathOnly.toLowerCase().endsWith('.pdf') + } + + const handleMarkdownLink = (e, href) => { + if (!isInternalFileHref(href)) return + e.preventDefault() + Toast.error('无法打开内部文件链接', '单文件分享模式不支持跳转到其他内部文件') + } + + const markdownComponents = { + a: ({ node, href, children, ...props }) => { + const isExternal = isExternalHref(href) + return ( +
handleMarkdownLink(e, href)} + target={isExternal ? '_blank' : undefined} + rel={isExternal ? 'noopener noreferrer' : undefined} + {...props} + > + {children} + + ) + }, + } + + const isHeaderPdf = contentInfo?.type === 'pdf' + const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined + const headerLabel = contentInfo?.filename || '文件分享' + + return ( +
+
+ + +
+ +

+ + {headerLabel} +

+ {contentInfo?.type === 'markdown' && ( + isMobile ? ( + + + + + + ) + )} + {contentInfo?.type === 'pdf' &&
} +
+ {loading ? ( +
+ +
加载中...
+
+
+ ) : ( +
+ {contentInfo?.type === 'pdf' ? ( + + ) : isLargeMarkdown ? ( + + ) : ( +
+ + {markdownContent} + +
+ )} +
+ )} + + + + {!isMobile && contentInfo?.type === 'markdown' && !isLargeMarkdown && ( + contentRef.current} + /> + )} + +
+ + setTocDrawerVisible(false)} + items={tocItems} + getContainer={() => contentRef.current} + /> + + 访问验证
} + open={passwordModalVisible} + onOk={handleVerifyPassword} + onCancel={() => setPasswordModalVisible(false)} + okText="验证" + cancelText="取消" + maskClosable={false} + > +
+

该文件分享需要访问密码,请输入密码后继续浏览。

+ setPassword(e.target.value)} + onPressEnter={handleVerifyPassword} + prefix={} + /> +
+ +
+ ) +} + +export default FileSharePage diff --git a/frontend/src/pages/Preview/PreviewPage.css b/frontend/src/pages/Preview/PreviewPage.css index 5827e10..ce1e44a 100644 --- a/frontend/src/pages/Preview/PreviewPage.css +++ b/frontend/src/pages/Preview/PreviewPage.css @@ -4,6 +4,77 @@ background: var(--bg-color); } +.file-share-page { + overflow: hidden; +} + +.file-share-shell { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--bg-color); +} + +.file-share-content-header { + display: flex; + align-items: center; + gap: 14px; +} + +.file-share-content { + height: 100%; + overflow-y: auto; + background: var(--bg-color); +} + +.file-share-content-layout { + flex: 1; + min-height: 0; + background: var(--bg-color); +} + +.preview-close-btn.ant-btn { + width: 36px; + height: 36px; + border-radius: 999px; + flex: none; +} + +.project-back-button { + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + color: #707480; + font-size: 16px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.project-back-button:hover { + background: rgba(17, 24, 39, 0.06); + color: #2f3440; + transform: translateX(-1px); +} + +.project-back-button:focus-visible { + outline: 2px solid rgba(22, 119, 255, 0.35); + outline-offset: 2px; +} + +.preview-sider-title-row { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + .preview-layout { height: 100%; background: var(--bg-color); @@ -19,124 +90,162 @@ } .preview-sider-header { - padding: 16px; + min-height: 57px; + padding: 14px 24px; border-bottom: 1px solid var(--border-color); + background: var(--header-bg); + display: flex; + align-items: center; } .preview-sider-header h2 { - margin: 0 0 8px 0; - font-size: 18px; + margin: 0; + font-size: 16px; font-weight: 600; color: var(--text-color); -} - -.preview-project-desc { - margin: 0; - font-size: 13px; - color: var(--text-color-secondary); - line-height: 1.5; -} - -.preview-menu { flex: 1; - overflow-y: auto; - border-right: none; - background: var(--sider-bg); - color: var(--text-color); -} - -/* 修复文档名过长的显示问题 */ -.preview-menu .ant-menu-title-content { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.preview-search { + padding: 12px 16px 4px; +} + +.preview-menu { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + border-right: none; + background: var(--sider-bg); + color: var(--text-color); +} + .preview-menu .ant-menu-item, .preview-menu .ant-menu-submenu-title { overflow: hidden; } +.preview-menu .ant-menu-title-content { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.preview-menu-label { + display: block; + width: 100%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .preview-content-layout { position: relative; height: 100%; background: var(--bg-color); } -.preview-toc-sider { - border-left: 1px solid var(--border-color); - background: var(--bg-color-secondary) !important; - height: 100%; - overflow: hidden; - display: flex; - flex-direction: column; -} - -.toc-header { - padding: 16px; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; - background: var(--header-bg); -} - -.toc-header h3 { - margin: 0; - font-size: 14px; - font-weight: 600; - color: var(--text-color); -} - -.toc-content { - flex: 1; - overflow-y: auto; - overflow-x: auto; - padding: 16px; -} - -.toc-content .ant-anchor { - padding-left: 0; -} - -.toc-content .ant-anchor-link { - padding: 6px 0; -} - -.toc-content .ant-anchor-link-title { - font-size: 13px; - color: var(--text-color-secondary); - line-height: 1.5; - white-space: nowrap; -} - -.toc-content .ant-anchor-link-active > .ant-anchor-link-title { - color: var(--link-color); - font-weight: 500; -} - -.toc-empty { - color: var(--text-color-secondary); - text-align: center; - margin-top: 40px; - font-size: 13px; -} - -.toc-toggle-btn { - position: fixed; - right: 24px; - top: 50%; - transform: translateY(-50%); - z-index: 100; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - .preview-content { height: 100%; overflow-y: auto; background: var(--bg-color); } +.preview-content-header { + position: sticky; + top: 0; + z-index: 5; + min-height: 57px; + padding: 14px 24px; + border-bottom: 1px solid var(--border-color); + background: var(--header-bg); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.preview-header-leading-actions { + display: inline-flex; + align-items: center; + gap: 4px; + flex: none; +} + +.preview-header-leading-actions .ant-btn, +.preview-compact-actions .ant-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-color-secondary); +} + +.preview-header-leading-actions .ant-btn:hover, +.preview-compact-actions .ant-btn:hover { + background: var(--item-hover-bg); + color: var(--link-color); +} + +.preview-header-title { + display: flex; + align-items: center; + min-width: 0; + flex: 1; + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-color); + overflow: hidden; + white-space: nowrap; + gap: 0; +} + +.preview-header-icon { + font-size: 15px; + color: var(--text-color-secondary); + flex: none; + margin-right: 6px; +} + +.preview-header-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-header-actions { + flex: none; +} + +.pdf-header-toolbar { + min-width: 0; + overflow-x: auto; + overflow-y: hidden; +} + +.pdf-header-toolbar .pdf-toolbar { + white-space: nowrap; +} + +.preview-content-header h3:not(.preview-header-title) { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + .preview-content-wrapper { max-width: 900px; margin: 0 auto; @@ -144,6 +253,13 @@ min-height: 100%; } +.preview-content-wrapper.large-markdown-mode { + max-width: 100%; + height: calc(100% - 57px); + min-height: 0; + padding: 0 0 24px; +} + /* PDF模式下使用全宽 */ .preview-content-wrapper.pdf-mode { max-width: 100%; @@ -279,20 +395,27 @@ margin-bottom: 4px; } -/* 移动端菜单按钮 */ -.mobile-menu-btn { - position: fixed; - top: 16px; - left: 16px; - z-index: 1000; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - /* 移动端响应式样式 */ @media (max-width: 768px) { + .preview-content-header { + padding: 12px 12px; + gap: 8px; + } + + .file-share-content-header { + gap: 8px; + } + .preview-content-wrapper { padding: 16px; - padding-top: 60px; /* 为移动端菜单按钮留出空间 */ + } + + .preview-content-wrapper.large-markdown-mode { + padding: 0 0 16px; + } + + .preview-content-wrapper.pdf-mode { + padding: 0; } .markdown-body { @@ -323,6 +446,10 @@ .markdown-body td { padding: 6px 10px; } + + .file-share-header { + padding: 16px; + } } /* 平板响应式样式 */ @@ -331,10 +458,6 @@ width: 240px !important; } - .preview-toc-sider { - width: 200px !important; - } - .preview-content-wrapper { max-width: 100%; padding: 20px; @@ -347,6 +470,14 @@ padding: 12px; } + .preview-content-wrapper.large-markdown-mode { + padding: 0 0 12px; + } + + .preview-content-wrapper.pdf-mode { + padding: 0; + } + .markdown-body { font-size: 14px; } @@ -368,4 +499,4 @@ } } -/* 打印样式优化已移除,转向后端生成方案 */ \ No newline at end of file +/* 打印样式优化已移除,转向后端生成方案 */ diff --git a/frontend/src/pages/Preview/PreviewPage.jsx b/frontend/src/pages/Preview/PreviewPage.jsx deleted file mode 100644 index 4140dc2..0000000 --- a/frontend/src/pages/Preview/PreviewPage.jsx +++ /dev/null @@ -1,341 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { useParams, useSearchParams, useNavigate } from 'react-router-dom' -import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty } from 'antd' -import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' -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' -import rehypeSlug from 'rehype-slug' -import 'highlight.js/styles/github.css' -import Mark from 'mark.js' -import Highlighter from 'react-highlight-words' -import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' -import usePreviewBrowser from './usePreviewBrowser' -import './PreviewPage.css' - -const { Sider, Content } = Layout - -// 高亮组件 (用于 Tree) -const HighlightText = ({ text, keyword }) => { - if (!keyword || !text) return text; - return ( - - ) -} - -function PreviewPage() { - const { projectId } = useParams() - const navigate = useNavigate() - const [searchParams] = useSearchParams() - const [tocCollapsed, setTocCollapsed] = useState(false) - const [siderCollapsed, setSiderCollapsed] = useState(false) - const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false) - const [isMobile, setIsMobile] = 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(() => { - if (viewerRef.current && viewMode === 'markdown') { - const instance = new Mark(viewerRef.current) - instance.unmark() - - if (searchKeyword.trim()) { - instance.mark(searchKeyword, { - element: 'span', - className: 'search-highlight', - exclude: ['pre', 'code', '.toc-content'] - }) - } - } - }, [markdownContent, searchKeyword, viewMode]) - - // 检测是否为移动设备 - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768) - } - checkMobile() - window.addEventListener('resize', checkMobile) - return () => window.removeEventListener('resize', checkMobile) - }, []) - const handleClose = () => { - if (window.history.length > 1) { - navigate(-1) - return - } - navigate('/projects') - } - - const handleMenuSelect = ({ key }) => { - handleMenuClick({ key }) - if (isMobile) { - setMobileDrawerVisible(false) - } - } - - return ( -
- - {isMobile ? ( - <> - - navigate('/')} - > - logo - {projectInfo?.name || '项目预览'} -
- } - placement="left" - onClose={() => setMobileDrawerVisible(false)} - open={mobileDrawerVisible} - width="80%" - > -
- {projectInfo?.description && ( -

{projectInfo.description}

- )} -
- - {/* 搜索框 */} -
- setSearchKeyword(e.target.value)} - onSearch={handleSearch} - loading={isSearching} - enterButton - /> -
- - {filteredTreeData.length > 0 ? ( - - ) : ( -
- -
- )} - - - ) : ( - -
-
navigate('/')} - > - logo -

{projectInfo?.name || '项目预览'}

-
- {projectInfo?.description && ( -

{projectInfo.description}

- )} -
- - {/* 搜索框 */} -
- setSearchKeyword(e.target.value)} - onSearch={handleSearch} - loading={isSearching} - enterButton - /> -
- - {filteredTreeData.length > 0 ? ( - - ) : ( -
- -
- )} - - )} - - - -
- {loading ? ( -
- -
加载中...
-
-
- ) : viewMode === 'pdf' ? ( - - ) : ( -
- - {markdownContent} - -
- )} -
- - {viewMode === 'markdown' && ( - - )} -
- - {!isMobile && viewMode === 'markdown' && !tocCollapsed && ( - -
-

文档索引

-
-
- {tocItems.length > 0 ? ( - contentRef.current} - items={tocItems.map((item) => ({ - key: item.key, - href: item.href, - title: ( -
- - -
- ), - }))} - /> - ) : ( -
当前文档无标题
- )} -
-
- )} -
- - {!isMobile && tocCollapsed && ( - - )} - - - - - 访问验证 -
- } - open={passwordModalVisible} - onOk={handleVerifyPassword} - onCancel={() => setPasswordModalVisible(false)} - okText="验证" - cancelText="取消" - maskClosable={false} - > -
-

该项目需要访问密码,请输入密码后继续浏览。

- setPassword(e.target.value)} - onPressEnter={handleVerifyPassword} - prefix={} - /> -
- -
- ) -} - -export default PreviewPage diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx new file mode 100644 index 0000000..d6c305e --- /dev/null +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -0,0 +1,687 @@ +import { useState, useEffect, useRef, useMemo } from 'react' +import { useParams, useSearchParams, useNavigate } from 'react-router-dom' +import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Empty, Tooltip, Space } from 'antd' +import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, UnorderedListOutlined, ArrowLeftOutlined } from '@ant-design/icons' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeHighlight from 'rehype-highlight' +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 Toast from '@/components/Toast/Toast' +import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc' +import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' +import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer' +import { + getProjectSharePublicInfo, + getProjectShareTree, + searchProjectShareDocuments, + getProjectShareFile, + verifyProjectSharePassword, + getProjectShareDocumentUrl, + exportProjectSharePDF, +} from '@/api/share' +import './PreviewPage.css' + +const { Sider, Content } = Layout + +const HighlightText = ({ text, keyword }) => { + if (!keyword || !text) return text + return ( + + ) +} + +function ProjectSharePage() { + const { shareCode } = useParams() + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const [projectInfo, setProjectInfo] = useState(null) + 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 [passwordModalVisible, setPasswordModalVisible] = useState(false) + const [password, setPassword] = useState('') + const [siderCollapsed, setSiderCollapsed] = useState(false) + const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false) + const [tocDrawerVisible, setTocDrawerVisible] = useState(false) + const [isMobile, setIsMobile] = 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 largeMarkdownRef = useRef(null) + const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) + const isLargeMarkdown = isLargeMarkdownContent(markdownContent) + + const handleClose = () => { + if (window.history.length > 1) { + navigate(-1) + return + } + navigate('/') + } + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768) + checkMobile() + window.addEventListener('resize', checkMobile) + return () => window.removeEventListener('resize', checkMobile) + }, []) + + useEffect(() => { + if (viewerRef.current && viewMode === 'markdown' && !isLargeMarkdown) { + const instance = new Mark(viewerRef.current) + instance.unmark() + if (searchKeyword.trim()) { + instance.mark(searchKeyword, { + element: 'span', + className: 'search-highlight', + exclude: ['pre', 'code', '.floating-toc'], + }) + } + } + }, [markdownContent, searchKeyword, viewMode, isLargeMarkdown]) + + useEffect(() => { + loadProjectInfo() + }, [shareCode]) + + useEffect(() => { + if (fileTree.length === 0) return + + const fileParam = searchParams.get('file') + if (fileParam) { + openSharedFile(fileParam) + return + } + + if (!selectedFile) { + const readmeNode = findReadme(fileTree) + if (readmeNode) { + openSharedFile(readmeNode.key) + } + } + }, [fileTree, searchParams]) + + const loadProjectInfo = async () => { + try { + const res = await getProjectSharePublicInfo(shareCode) + const info = res.data + setProjectInfo(info) + if (info.has_password) { + setPasswordModalVisible(true) + } else { + loadFileTree() + } + } catch (error) { + console.error('Load project share info error:', error) + Toast.error('加载失败', '分享链接不存在或已失效') + } + } + + const handleVerifyPassword = async () => { + if (!password.trim()) { + Toast.warning('提示', '请输入访问密码') + return + } + try { + await verifyProjectSharePassword(shareCode, password) + setPasswordModalVisible(false) + loadFileTree(password) + Toast.success('验证成功') + } catch (error) { + Toast.error('访问密码错误') + } + } + + const loadFileTree = async (pwd = null) => { + try { + const res = await getProjectShareTree(shareCode, pwd) + setFileTree(res.data || []) + } catch (error) { + console.error('Load share tree error:', error) + if (error.response?.status === 403) { + setPasswordModalVisible(true) + } else { + Toast.error('加载失败', '目录加载失败') + } + } + } + + const loadMarkdown = async (filePath, pwd = null) => { + setLoading(true) + setTocItems([]) + try { + const res = await getProjectShareFile(shareCode, filePath, pwd) + setMarkdownContent(res.data?.content || '') + if (contentRef.current) { + contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + } catch (error) { + console.error('Load share markdown error:', error) + if (error.response?.status === 403) { + setPasswordModalVisible(true) + } else { + Toast.error('加载失败', '文档加载失败') + } + } finally { + setLoading(false) + } + } + + const normalizeMarkdownHref = (href) => { + if (!href) return href + + const [pathPart, hashPart = ''] = href.split('#') + const [rawPath, searchPart = ''] = pathPart.split('?') + + const decodedPath = rawPath + .split('/') + .map((part) => { + try { + return decodeURIComponent(part) + } catch (e) { + return part + } + }) + .join('/') + + const rebuilt = searchPart ? `${decodedPath}?${searchPart}` : decodedPath + return hashPart ? `${rebuilt}#${hashPart}` : rebuilt + } + + const isExternalHref = (href) => { + return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//'))) + } + + const handleSearch = async (value) => { + const keyword = value || '' + setSearchKeyword(keyword) + + if (!keyword.trim()) { + setMatchedFilePaths(new Set()) + return + } + + setIsSearching(true) + try { + const res = await searchProjectShareDocuments(shareCode, keyword) + const paths = new Set((res.data || []).map((item) => item.file_path)) + setMatchedFilePaths(paths) + + const keysToExpand = new Set(openKeys) + ;(res.data || []).forEach((item) => { + ;(item.parent_paths || []).forEach((parentPath) => keysToExpand.add(parentPath)) + }) + setOpenKeys(Array.from(keysToExpand)) + } catch (error) { + console.error('Search share documents error:', error) + Toast.error('搜索失败', '全文检索暂时不可用') + } finally { + setIsSearching(false) + } + } + + useEffect(() => { + if (!markdownContent || isLargeMarkdown) { + setTocItems([]) + return + } + + const slugger = new GithubSlugger() + const headings = [] + markdownContent.split('\n').forEach((line) => { + const match = line.match(/^(#{1,6})\s+(.+)$/) + if (!match) return + const level = match[1].length + const title = match[2] + const key = slugger.slug(title) + headings.push({ key: `#${key}`, href: `#${key}`, title, level }) + }) + setTocItems(headings) + }, [markdownContent, isLargeMarkdown]) + + const findReadme = (nodes) => { + for (const node of nodes) { + if (node.title === 'README.md' && node.isLeaf) return node + } + return null + } + + const filteredTreeData = useMemo(() => { + if (!searchKeyword.trim()) return fileTree + + const loop = (nodes) => { + const result = [] + for (const node of nodes) { + const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase()) + const contentMatch = matchedFilePaths.has(node.key) + + if (node.children?.length) { + 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 convertTreeToMenuItems = (nodes) => { + return nodes.map((node) => { + const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title + const labelNode = ( + + {titleText} + + ) + if (!node.isLeaf) { + const isOpen = openKeys.includes(node.key) + return { + key: node.key, + label: labelNode, + icon: isOpen ? : , + onTitleClick: () => setSelectedNodeKey(node.key), + children: node.children ? convertTreeToMenuItems(node.children) : [], + } + } + if (node.title?.endsWith('.md')) { + return { key: node.key, label: labelNode, icon: } + } + if (node.title?.toLowerCase().endsWith('.pdf')) { + return { key: node.key, label: labelNode, icon: } + } + return null + }).filter(Boolean) + } + + 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 resolveMarkdownTarget = (relativePath) => { + if (!relativePath) return relativePath + if (relativePath.startsWith('.')) { + return resolveRelativePath(selectedFile, relativePath) + } + return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath + } + + const openSharedFile = (key) => { + setSelectedFile(key) + setSelectedNodeKey(key) + + const parts = key.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])]) + } + + if (key.toLowerCase().endsWith('.pdf')) { + setPdfUrl(getProjectShareDocumentUrl(shareCode, key)) + setPdfFilename(key.split('/').pop()) + setViewMode('pdf') + return + } + + setViewMode('markdown') + loadMarkdown(key) + } + + const handleMarkdownLink = (e, href) => { + const normalizedHref = normalizeMarkdownHref(href) + if (!normalizedHref || isExternalHref(normalizedHref) || normalizedHref.startsWith('#')) return + const pathOnly = normalizedHref.split(/[?#]/)[0] + const isMd = pathOnly.endsWith('.md') + const isPdf = pathOnly.toLowerCase().endsWith('.pdf') + if (!isMd && !isPdf) return + e.preventDefault() + openSharedFile(resolveMarkdownTarget(pathOnly)) + } + + const handleExportPDF = () => { + if (!selectedFile) return + 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(exportProjectSharePDF(shareCode, selectedFile), '_blank') + } + + const scrollContentToTop = () => { + if (isLargeMarkdown) { + largeMarkdownRef.current?.scrollToTop() + return + } + + if (contentRef.current) { + contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + } + + const menuItems = useMemo( + () => convertTreeToMenuItems(filteredTreeData), + [filteredTreeData, openKeys] + ) + + const markdownComponents = { + a: ({ node, href, children, ...props }) => { + const isExternal = isExternalHref(href) + return ( + handleMarkdownLink(e, href)} + target={isExternal ? '_blank' : undefined} + rel={isExternal ? 'noopener noreferrer' : undefined} + {...props} + > + {children} + + ) + }, + } + + const isHeaderPdf = selectedFile.toLowerCase().endsWith('.pdf') + const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined + const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : 'README.md' + + return ( +
+ + {isMobile ? ( + <> + setMobileDrawerVisible(false)} + open={mobileDrawerVisible} + width="80%" + > +
+ setSearchKeyword(e.target.value)} + onSearch={handleSearch} + loading={isSearching} + enterButton + /> +
+ {menuItems.length > 0 ? ( + { + openSharedFile(key) + setMobileDrawerVisible(false) + }} + className="preview-menu" + /> + ) : ( + + )} + + + ) : ( + +
+
+ +

{projectInfo?.name || '项目分享'}

+
+
+
+ setSearchKeyword(e.target.value)} + onSearch={handleSearch} + loading={isSearching} + enterButton + /> +
+ {menuItems.length > 0 ? ( + openSharedFile(key)} + className="preview-menu" + /> + ) : ( +
+ +
+ )} + + )} + + + +
+ {isMobile && ( +
+ +
+ )} +

+ + {headerLabel} +

+ {viewMode === 'markdown' && ( + isMobile ? ( + + + + + + ) + )} + {viewMode === 'pdf' &&
} +
+
+ {loading ? ( +
+ +
加载中...
+
+
+ ) : viewMode === 'pdf' ? ( + + ) : isLargeMarkdown ? ( + } + onClick={(e) => { + if (e.defaultPrevented) return + const target = e.target.closest('a') + if (target) { + const href = target.getAttribute('href') + if (href) handleMarkdownLink(e, href) + } + }} + /> + ) : ( +
{ + if (e.defaultPrevented) return + const target = e.target.closest('a') + if (target) { + const href = target.getAttribute('href') + if (href) handleMarkdownLink(e, href) + } + }} ref={viewerRef}> + + {markdownContent} + +
+ )} +
+ + + + {!isMobile && viewMode === 'markdown' && !isLargeMarkdown && ( + contentRef.current} + renderTitle={(item, keyword) => } + /> + )} + + + + setTocDrawerVisible(false)} + items={tocItems} + searchKeyword={searchKeyword} + getContainer={() => contentRef.current} + renderTitle={(item, keyword) => } + /> + + 访问验证
} + open={passwordModalVisible} + onOk={handleVerifyPassword} + onCancel={() => setPasswordModalVisible(false)} + okText="验证" + cancelText="取消" + maskClosable={false} + > +
+

该分享需要访问密码,请输入密码后继续浏览。

+ setPassword(e.target.value)} + onPressEnter={handleVerifyPassword} + prefix={} + /> +
+ +
+ ) +} + +export default ProjectSharePage diff --git a/frontend/src/pages/ProjectList/ProjectList.jsx b/frontend/src/pages/ProjectList/ProjectList.jsx index a0db28b..9647641 100644 --- a/frontend/src/pages/ProjectList/ProjectList.jsx +++ b/frontend/src/pages/ProjectList/ProjectList.jsx @@ -1,85 +1,42 @@ 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, 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 { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination } from 'antd' +import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined } from '@ant-design/icons' +import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project' +import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share' +import { getUserList } from '@/api/users' 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 [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() @@ -93,27 +50,43 @@ function ProjectList({ type = 'my' }) { // ... (fetchProjects code) const handleOpenTransfer = async () => { - if (!currentProject) return - await loadTransferCandidates(currentProject) + 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) + } } const handleTransfer = async (values) => { - if (!currentProject?.id) return - await submitTransfer({ - projectId: currentProject.id, - newOwnerId: values.new_owner_id, - onCompleted: () => { - setEditModalVisible(false) - transferForm.resetFields() - fetchProjects() - }, + 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)) + } + } }) } - const handleExportProject = async () => { - await exportProject(currentProject) - } - const fetchProjects = async () => { setLoading(true) try { @@ -173,57 +146,176 @@ function ProjectList({ type = 'my' }) { editForm.setFieldsValue({ name: project.name, description: project.description, - is_public: project.is_public === 1, }) + setShareInfo(null) + setHasPassword(false) + setPassword('') setEditModalVisible(true) } - // 更新项目 + // 切换公开/私有(立即生效) + const handlePublicToggle = async (checked) => { + if (!currentProject) return + try { + const res = await updateProject(currentProject.id, { + is_public: checked ? 1 : 0, + }) + const updatedProject = res.data || { + ...currentProject, + is_public: checked ? 1 : 0, + } + setCurrentProject(updatedProject) + message.success(checked ? '项目已设为公开' : '项目已设为私有') + await fetchProjects() + + if (checked) { + const shareRes = await getProjectShareInfo(currentProject.id) + setShareInfo(shareRes.data) + setHasPassword(shareRes.data.has_password) + setPassword(shareRes.data.access_pass || '') + } else { + setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null }) + setHasPassword(false) + setPassword('') + } + } catch (error) { + console.error('Toggle public error:', error) + message.error('操作失败') + } + } + + // 更新项目(仅名称和描述) const handleUpdateProject = async (values) => { try { - await updateProject(currentProject.id, { - ...values, - is_public: values.is_public ? 1 : 0, + const res = await updateProject(currentProject.id, { + name: values.name, + description: values.description, }) + const updatedProject = res.data || { + ...currentProject, + ...values, + } + setCurrentProject(updatedProject) message.success('项目更新成功') + await fetchProjects() setEditModalVisible(false) editForm.resetFields() - fetchProjects() } catch (error) { console.error('Update project error:', error) message.error('项目更新失败') } } + useEffect(() => { + if (!editModalVisible || !currentProject) return + + if (currentProject.is_public !== 1) { + setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null }) + setHasPassword(false) + setPassword('') + return + } + + ;(async () => { + try { + const res = await getProjectShareInfo(currentProject.id) + setShareInfo(res.data) + setHasPassword(res.data.has_password) + setPassword(res.data.access_pass || '') + } catch (error) { + console.error('Get project share info error:', error) + } + })() + }, [editModalVisible, currentProject]) + + 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 = async (e, project) => { + const handleGitSettings = (e, project) => { e.stopPropagation() setCurrentProject(project) - await openGitSettings(project.id) + 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) + } } // 打开添加仓库弹窗 const handleAddRepo = () => { - openAddRepoModal({ repoForm, gitRepos }) + setEditingRepo(null) + repoForm.resetFields() + // 如果是第一个仓库,默认设为默认 + if (gitRepos.length === 0) { + repoForm.setFieldsValue({ is_default: 1 }) + } + setGitRepoModalVisible(true) } // 打开编辑仓库弹窗 const handleEditRepo = (repo) => { - openEditRepoModal({ repo, repoForm }) + setEditingRepo(repo) + repoForm.setFieldsValue({ + ...repo, + is_default: repo.is_default === 1, + }) + setGitRepoModalVisible(true) } // 删除仓库 const handleDeleteRepo = (repoId) => { - if (!currentProject?.id) return - deleteRepo({ projectId: currentProject.id, 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('删除失败') + } + }, + }) } // 保存仓库(新增/更新) const handleSaveRepo = async (values) => { - if (!currentProject?.id) return - const succeeded = await saveRepo({ projectId: currentProject.id, values }) - if (succeeded) { - closeGitRepoModal({ repoForm }) + 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 ? '更新失败' : '添加失败') } } @@ -231,50 +323,146 @@ function ProjectList({ type = 'my' }) { navigate(`/projects/${projectId}/docs`) } - // 打开分享设置 - const handleShare = async (e, project) => { - e.stopPropagation() - setCurrentProject(project) - await openShareModal(project) - } - // 复制分享链接 const handleCopyLink = async () => { - await copyShareLink() + 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('复制失败', '无法访问剪贴板') + } } // 切换密码保护 const handlePasswordToggle = async (checked) => { - if (!currentProject?.id) return - await togglePassword(currentProject.id, checked) + if (!checked) { + try { + await updateProjectShareSettings(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) + } } // 保存密码 const handleSavePassword = async () => { - if (!currentProject?.id) { + if (!password.trim()) { + message.warning('请输入访问密码') return } - await savePassword(currentProject.id) + try { + await updateProjectShareSettings(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('设置密码失败') + } } // 打开成员管理 const handleMembers = async (e, project) => { e.stopPropagation() setCurrentProject(project) - await openMembersModal(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) + } } // 添加成员 const handleAddMember = async (values) => { - if (!currentProject?.id) return - await addMember(currentProject.id, values) - memberForm.resetFields() + 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) + } } // 删除成员 const handleRemoveMember = async (userId) => { - if (!currentProject?.id) return - await removeMember(currentProject.id, 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) + } + }, + }) } // 处理搜索输入变化 @@ -332,180 +520,155 @@ function ProjectList({ type = 'my' }) { : projects const paginatedProjects = filteredProjects.slice((currentPage - 1) * pageSize, currentPage * pageSize) - const pageConfig = { - my: { - title: '我的项目', - description: '管理你创建的项目,支持搜索、协作、分享与整体导出。', - icon: , - }, - share: { - title: '参与项目', - description: '查看与你协作的项目、当前角色权限以及可访问的文档内容。', - icon: , - }, - all: { - title: '项目列表', - description: '统一浏览当前可访问的全部项目。', - icon: , - }, - }[type] || { - title: '项目列表', - description: '统一浏览当前可访问的全部项目。', - icon: , - } return ( -
- } onClick={() => setModalVisible(true)}> - 创建项目 - - ) : null} +
+ , + onClick: () => setModalVisible(true), + }, + ] : []} + search={{ + placeholder: '搜索项目或文件...', + value: searchKeyword, + onChange: handleSearchChange, + onSearch: handleSearch, + }} + showRefresh + onRefresh={fetchProjects} /> - - - - {hasSearched && searchResults.length > 0 && ( -
-
- 找到 {searchResults.length} 个结果 -
- - {searchResults.map((item, index) => ( - - handleSearchResultClick(item)} - > -
- {item.type === 'project' ? ( - - ) : ( - - )} -
- - -

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

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

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

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

{project.name}

-

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

-
- 文档数: {project.doc_count || 0} - {type === 'share' && project.owner_name && ( - - 所有者: {project.owner_nickname || project.owner_name} - - )} - {type === 'share' && project.user_role && ( - - 角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'} - - )} -
-
- - ))} - - {filteredProjects.length === 0 && !loading && ( - -
- + ) : ( + + )}
- - )} -
- {filteredProjects.length > 0 && ( -
- { - if (type === 'page') { - return - } - return originalElement - }} - /> -
- )} - - )} + + +

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

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

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

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

{project.name}

+

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

+
+ 文档数: {project.doc_count || 0} + {type === 'share' && project.owner_name && ( + + 所有者: {project.owner_nickname || project.owner_name} + + )} + {type === 'share' && project.user_role && ( + + 角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'} + + )} +
+
+ + ))} + + {filteredProjects.length === 0 && !loading && ( + + + + )} +
+ {filteredProjects.length > 0 && ( +
+ { + if (type === 'page') { + return + } + return originalElement + }} + /> +
+ )} + + )} + + {/* 搜索无结果提示 */} + {hasSearched && !searching && searchResults.length === 0 && ( +
+ +
+ )} - - + + + + + + + {currentProject?.is_public !== 1 ? ( +
+ 开启“公开项目”后,可以生成项目分享链接和访问密码。 +
+ ) : shareInfo ? ( + <> + : null + } + /> + + 访问密码保护 + + + {hasPassword && ( + + setPassword(e.target.value)} + /> + + + )} + + ) : null} +
@@ -607,21 +805,14 @@ function ProjectList({ type = 'my' }) { handleDeleteProject(currentProject.id) }} > - 删除 + 删除项目 - @@ -640,47 +831,11 @@ function ProjectList({ type = 'my' }) {
- - 关闭 - , - ] - } - closable={!exportingProject} - maskClosable={!exportingProject} - > - -
{exportTask?.message || '正在准备导出任务...'}
- -
- 已处理 {exportTask?.processed_files || 0} / {exportTask?.total_files || 0} 个文件 -
- {exportTask?.status === 'failed' && ( -
- {exportTask?.error || '项目导出失败'} -
- )} -
-
- { - closeTransferModal() + setTransferModalVisible(false) transferForm.resetFields() }} onOk={() => transferForm.submit()} @@ -715,71 +870,14 @@ function ProjectList({ type = 'my' }) { - - {shareInfo && ( - -
- - - } - /> -
- - {/* 只有在我的项目中才显示密码设置功能 */} - {showPasswordControls && ( - <> -
- - 访问密码保护 - - -
- - {hasPassword && ( -
- setPassword(e.target.value)} - /> - -
- )} - - )} - - {/* 参与项目显示提示 */} - {type === 'share' && shareInfo.has_password && ( -
- 该项目已设置访问密码保护 -
- )} -
- )} -
- { - closeMembersModal() + setMembersModalVisible(false) memberForm.resetFields() + setMembers([]) + setUsers([]) }} footer={null} width={700} @@ -911,7 +1009,10 @@ function ProjectList({ type = 'my' }) { { + setGitModalVisible(false) + setGitRepos([]) + }} footer={null} width={800} > @@ -968,7 +1069,10 @@ function ProjectList({ type = 'my' }) { closeGitRepoModal({ repoForm })} + onCancel={() => { + setGitRepoModalVisible(false) + repoForm.resetFields() + }} footer={null} >
- +