diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index b8088e9..859f20a 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,7 +2,7 @@ API v1 路由汇总 """ from fastapi import APIRouter -from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications +from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, shares 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 0dc4f41..844a0ca 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -18,6 +18,7 @@ from app.core.deps import get_current_user, get_user_from_token_or_query from app.models.user import User from app.models.project import Project, ProjectMember from app.models.log import OperationLog +from app.models.share import ShareLink from app.schemas.file import ( FileTreeNode, FileSaveRequest, @@ -73,6 +74,14 @@ async def check_project_access( 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, @@ -88,6 +97,20 @@ async def get_project_tree( # 生成目录树 tree = storage_service.generate_tree(project_root) + 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: @@ -722,4 +745,4 @@ async def export_pdf( "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}", "Content-Type": "application/pdf" } - ) \ No newline at end of file + ) diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index 1f628c8..d6afb82 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -3,15 +3,17 @@ """ from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, or_ +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, @@ -19,8 +21,6 @@ from app.schemas.project import ( ProjectMemberAdd, ProjectMemberUpdate, ProjectMemberResponse, - ProjectShareSettings, - ProjectShareInfo, ProjectTransfer, ) from app.schemas.response import success_response @@ -33,6 +33,11 @@ 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: @@ -242,11 +247,48 @@ async def update_project( 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) @@ -608,89 +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) -): - """获取项目分享信息""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 检查是否是项目所有者或成员 - is_owner = project.owner_id == current_user.id - if not is_owner: - # 检查是否是项目成员 - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id - ) - ) - member = member_result.scalar_one_or_none() - if not member: - raise HTTPException(status_code=403, detail="无权访问该项目") - - # 构建分享链接 - 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) -): - """更新分享设置(设置或取消访问密码)""" - # 查询项目 - 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="只有项目所有者可以修改分享设置") - - # 更新访问密码 - 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, diff --git a/backend/app/api/v1/shares.py b/backend/app/api/v1/shares.py new file mode 100644 index 0000000..b5d9c8b --- /dev/null +++ b/backend/app/api/v1/shares.py @@ -0,0 +1,667 @@ +""" +分享相关 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) + 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_for_pdf(content) + 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 79bf418..64439e7 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 @@ -21,6 +22,7 @@ __all__ = [ "ProjectMember", "ProjectMemberRole", "DocumentMeta", + "ShareLink", "OperationLog", "MCPBot", ] 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 2718ca2..e930e43 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' @@ -61,8 +62,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/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 (
.ant-menu-submenu-title { background-color: var(--item-hover-bg) !important; @@ -152,8 +175,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 { diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index ae3f63b..6321324 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -17,7 +17,7 @@ import { FilePdfOutlined, FileTextOutlined, UndoOutlined, - CloseOutlined, + ArrowLeftOutlined, } from '@ant-design/icons' import { Editor } from '@bytemd/react' import gfm from '@bytemd/plugin-gfm' @@ -912,9 +912,13 @@ 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} + +
+
) @@ -967,22 +971,21 @@ function DocumentEditor() { className="document-sider" >
-
-

- {projectName} -

- - +

{projectName}

{ if (mode === 'view' && !modeSwitchingRef.current) { diff --git a/frontend/src/pages/Document/DocumentPage.css b/frontend/src/pages/Document/DocumentPage.css index 6addb1b..ffcf49d 100644 --- a/frontend/src/pages/Document/DocumentPage.css +++ b/frontend/src/pages/Document/DocumentPage.css @@ -26,20 +26,64 @@ } .docs-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); } .docs-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; + text-transform: none; +} + +.docs-sider-title-row { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.docs-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; } .docs-sider-actions { @@ -84,18 +128,41 @@ color: var(--text-color); } -/* 修复文档名过长的显示问题 */ -.docs-menu .ant-menu-title-content { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .docs-menu .ant-menu-item, .docs-menu .ant-menu-submenu-title { overflow: hidden; } +.docs-menu .ant-menu-title-content { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.docs-menu-label { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; +} + +.docs-menu-label-text { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.docs-menu-share-icon { + color: var(--link-color); + font-size: 12px; + flex: none; + opacity: 0.9; +} + .docs-content-layout { position: relative; height: 100%; diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index 2c9dad2..fdcb3cb 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd' -import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons' +import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, ArrowLeftOutlined, MenuOutlined, ReloadOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' @@ -12,7 +12,7 @@ import Highlighter from 'react-highlight-words' import GithubSlugger from 'github-slugger' import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file' import { gitPull, gitPush, getGitRepos } from '@/api/project' -import { getProjectShareInfo, updateShareSettings } from '@/api/share' +import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share' import { searchDocuments } from '@/api/search' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' @@ -58,6 +58,7 @@ function DocumentPage() { const [viewMode, setViewMode] = useState('markdown') const [gitRepos, setGitRepos] = useState([]) const [projectName, setProjectName] = useState('') + const [refreshing, setRefreshing] = useState(false) // 搜索相关状态 const [searchKeyword, setSearchKeyword] = useState('') @@ -86,6 +87,21 @@ function DocumentPage() { setSearchParams(nextParams, { replace: true }) } + const buildDocumentUrl = (filePath, refreshKey = null) => { + const params = new URLSearchParams() + const token = localStorage.getItem('access_token') + + if (token) { + params.set('token', token) + } + if (refreshKey) { + params.set('refresh', String(refreshKey)) + } + + const query = params.toString() + return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}` + } + useEffect(() => { loadFileTree() }, [projectId]) @@ -139,12 +155,7 @@ function DocumentPage() { // 处理 PDF 或 Markdown if (fileParam.toLowerCase().endsWith('.pdf')) { - let url = getDocumentUrl(projectId, fileParam) - const token = localStorage.getItem('access_token') - if (token) { - url += `?token=${encodeURIComponent(token)}` - } - setPdfUrl(url) + setPdfUrl(buildDocumentUrl(fileParam)) setPdfFilename(fileParam.split('/').pop()) setViewMode('pdf') } else { @@ -234,7 +245,7 @@ function DocumentPage() { } // 加载文件树 - const loadFileTree = async () => { + const loadFileTree = async ({ throwOnError = false } = {}) => { try { const res = await getProjectTree(projectId) const data = res.data || {} @@ -245,8 +256,13 @@ function DocumentPage() { setFileTree(tree) setUserRole(role) setProjectName(name) + return tree } catch (error) { console.error('Load file tree error:', error) + if (throwOnError) { + throw error + } + return [] } } @@ -279,14 +295,21 @@ function DocumentPage() { // 转换文件树为菜单项 const convertTreeToMenuItems = (nodes) => { return nodes.map((node) => { - // 标题高亮处理 - 取消高亮,仅显示原始标题 - const titleNode = node.title.replace('.md', '') + const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title + const labelNode = ( + + + {titleText} + {node.is_shared && } + + + ) if (!node.isLeaf) { // 目录 return { key: node.key, - label: node.title, + label: labelNode, icon: , onTitleClick: () => setSelectedNodeKey(node.key), children: node.children ? convertTreeToMenuItems(node.children) : [], @@ -295,14 +318,14 @@ function DocumentPage() { // Markdown 文件 return { key: node.key, - label: titleNode, + label: labelNode, icon: , } } else if (node.title && node.title.endsWith('.pdf')) { // PDF 文件 return { key: node.key, - label: node.title, + label: labelNode, icon: , } } @@ -367,12 +390,7 @@ function DocumentPage() { // 检查是否是PDF文件 if (key.toLowerCase().endsWith('.pdf')) { // 显示PDF - 添加token到URL - let url = getDocumentUrl(projectId, key) - const token = localStorage.getItem('access_token') - if (token) { - url += `?token=${encodeURIComponent(token)}` - } - setPdfUrl(url) + setPdfUrl(buildDocumentUrl(key)) setPdfFilename(key.split('/').pop()) setViewMode('pdf') } else { @@ -468,12 +486,7 @@ function DocumentPage() { if (isPdf) { // PDF文件:切换到PDF模式 - let url = getDocumentUrl(projectId, targetPath) - const token = localStorage.getItem('access_token') - if (token) { - url += `?token=${encodeURIComponent(token)}` - } - setPdfUrl(url) + setPdfUrl(buildDocumentUrl(targetPath)) setPdfFilename(targetPath.split('/').pop()) setViewMode('pdf') } else { @@ -654,19 +667,33 @@ function DocumentPage() { navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`) } + const handleRefresh = async () => { + setRefreshing(true) + try { + await loadFileTree({ throwOnError: true }) + message.success('已刷新') + } catch (error) { + console.error('Refresh documents error:', error) + message.error('刷新失败') + } finally { + setRefreshing(false) + } + } + // 打开分享设置 const handleShare = async () => { const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null - if (selectedNode && !selectedNode.isLeaf) { - Toast.warning('提示', '当前选中的是文件夹,不能直接分享,请选择具体文件后再试') + if (!selectedNode || !selectedNode.isLeaf) { + Toast.warning('提示', '请先选择一个文件再分享') return } try { - const res = await getProjectShareInfo(projectId) - setShareInfo(res.data) - setHasPassword(res.data.has_password) - setPassword(res.data.access_pass || '') // 显示已设置的密码 + const res = await getFileShareInfo(projectId, selectedNode.key) + const nextShareInfo = res.data + setShareInfo(nextShareInfo) + setHasPassword(Boolean(nextShareInfo?.has_password)) + setPassword(nextShareInfo?.access_pass || '') setShareModalVisible(true) } catch (error) { console.error('Get share info error:', error) @@ -677,15 +704,7 @@ function DocumentPage() { // 复制分享链接 const handleCopyLink = async () => { if (!shareInfo) return - - const shareTargetFile = selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf - ? selectedNodeKey - : '' - - let fullUrl = `${window.location.origin}${shareInfo.share_url}` - if (shareTargetFile) { - fullUrl += `?file=${encodeURIComponent(shareTargetFile)}` - } + const fullUrl = `${window.location.origin}${shareInfo.share_url}` try { if (navigator.clipboard && window.isSecureContext) { @@ -717,15 +736,20 @@ function DocumentPage() { // 切换密码保护 const handlePasswordToggle = async (checked) => { + if (!selectedNodeKey) return + if (!checked) { - // 取消密码 try { - await updateShareSettings(projectId, { access_pass: null }) + if (shareInfo?.share_url) { + await createOrUpdateFileShare(projectId, { file_path: selectedNodeKey, access_pass: null }) + } else { + await deleteFileShare(projectId, selectedNodeKey) + } setHasPassword(false) setPassword('') - message.success('已取消访问密码') - // 刷新分享信息 - const res = await getProjectShareInfo(projectId) + message.success('已取消文件访问密码') + await loadFileTree() + const res = await getFileShareInfo(projectId, selectedNodeKey) setShareInfo(res.data) } catch (error) { console.error('Update settings error:', error) @@ -738,20 +762,66 @@ function DocumentPage() { // 保存密码 const handleSavePassword = async () => { + if (!selectedNodeKey) { + message.warning('请先选择文件') + return + } + if (!password.trim()) { message.warning('请输入访问密码') return } try { - await updateShareSettings(projectId, { access_pass: password }) - message.success('访问密码已设置') - // 刷新分享信息 - const res = await getProjectShareInfo(projectId) + const res = await createOrUpdateFileShare(projectId, { + file_path: selectedNodeKey, + access_pass: hasPassword ? password : null, + }) + message.success('文件分享已更新') setShareInfo(res.data) - setHasPassword(true) + await loadFileTree() + const nextInfo = await getFileShareInfo(projectId, selectedNodeKey) + setShareInfo(nextInfo.data) + setHasPassword(Boolean(nextInfo.data?.has_password)) } catch (error) { console.error('Save password error:', error) - message.error('设置密码失败') + message.error('设置文件分享失败') + } + } + + const handleCreateShare = async () => { + if (!selectedNodeKey) { + message.warning('请先选择文件') + return + } + + try { + const res = await createOrUpdateFileShare(projectId, { + file_path: selectedNodeKey, + access_pass: hasPassword ? password : null, + }) + setShareInfo(res.data) + setHasPassword(Boolean(res.data?.has_password)) + await loadFileTree() + message.success('文件分享已创建') + } catch (error) { + console.error('Create file share error:', error) + message.error('创建文件分享失败') + } + } + + const handleDisableShare = async () => { + if (!selectedNodeKey) return + + try { + await deleteFileShare(projectId, selectedNodeKey) + setShareInfo(null) + setHasPassword(false) + setPassword('') + await loadFileTree() + message.success('文件分享已关闭') + } catch (error) { + console.error('Delete file share error:', error) + message.error('关闭文件分享失败') } } @@ -837,24 +907,23 @@ function DocumentPage() { {/* 左侧目录 */}
-
-

- {projectName} -

- - +

{projectName}

{/* 只有 owner/admin/editor 可以编辑和Git操作 */} {userRole !== 'viewer' ? ( { if (mode === 'edit' && !modeSwitchingRef.current) { @@ -878,6 +947,14 @@ function DocumentPage() { onClick={handleShare} /> + +
@@ -1015,55 +1092,91 @@ function DocumentPage() { {/* 分享模态框 */} {/* ... keeping the modal ... */} setShareModalVisible(false)} footer={null} width={500} > - {shareInfo && ( - -
- - - } - /> -
+ +
+ + +
-
- - 访问密码保护 - - -
- - {hasPassword && ( + {shareInfo?.share_url ? ( + <>
+ + + } + /> +
+ +
+ + 访问密码保护 + + +
+ + {hasPassword && ( +
+ 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..15655f2 --- /dev/null +++ b/frontend/src/pages/Preview/FileSharePage.jsx @@ -0,0 +1,229 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Layout, Button, Modal, Input, Spin, Anchor } from 'antd' +import { CloseOutlined, LockOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined } 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 DocFloatActions from '@/components/DocFloatActions/DocFloatActions' +import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' +import { + getFileSharePublicInfo, + verifyFileSharePassword, + getFileShareContent, + exportFileSharePDF, +} from '@/api/share' +import './PreviewPage.css' + +const { Content, Sider } = Layout + +function FileSharePage() { + const { shareCode } = useParams() + const navigate = useNavigate() + const contentRef = useRef(null) + const [shareInfo, setShareInfo] = useState(null) + const [contentInfo, setContentInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [isMobile, setIsMobile] = useState(false) + const [tocCollapsed, setTocCollapsed] = useState(false) + const [tocItems, setTocItems] = useState([]) + const [passwordModalVisible, setPasswordModalVisible] = useState(false) + const [password, setPassword] = useState('') + + useEffect(() => { + loadFileShare() + }, [shareCode]) + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768) + checkMobile() + window.addEventListener('resize', checkMobile) + return () => window.removeEventListener('resize', checkMobile) + }, []) + + useEffect(() => { + const markdownContent = contentInfo?.type === 'markdown' ? (contentInfo.content || '') : '' + if (!markdownContent) { + 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) + }, [contentInfo]) + + 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') + } + + return ( +
+
+
+
+

{shareInfo?.name || '文件分享'}

+ {shareInfo?.project_name &&

{shareInfo.project_name}

} +
+
+ + + + {loading ? ( +
+ +
加载中...
+
+
+ ) : ( +
+ {contentInfo?.type === 'pdf' ? ( + + ) : ( +
+ + {contentInfo?.content || ''} + +
+ )} +
+ )} + + {contentInfo?.type === 'markdown' && ( + + )} +
+ + {!isMobile && contentInfo?.type === 'markdown' && !tocCollapsed && ( + +
+

文档索引

+
+
+ {tocItems.length > 0 ? ( + contentRef.current} + items={tocItems.map((item) => ({ + key: item.key, + href: item.href, + title: ( +
+ + {item.title} +
+ ), + }))} + /> + ) : ( +
当前文档无标题
+ )} +
+
+ )} +
+ + {!isMobile && contentInfo?.type === 'markdown' && tocCollapsed && ( + + )} +
+ + 访问验证
} + 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..962a703 100644 --- a/frontend/src/pages/Preview/PreviewPage.css +++ b/frontend/src/pages/Preview/PreviewPage.css @@ -4,6 +4,72 @@ 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-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 24px; + border-bottom: 1px solid var(--border-color); + background: var(--header-bg); +} + +.file-share-meta { + min-width: 0; +} + +.file-share-meta h1 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: var(--text-color); +} + +.file-share-meta p { + margin: 4px 0 0; + font-size: 13px; + color: var(--text-color-secondary); +} + +.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; +} + +.preview-sider-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + .preview-layout { height: 100%; background: var(--bg-color); @@ -37,26 +103,40 @@ line-height: 1.5; } +.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-title-content { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .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%; @@ -288,6 +368,17 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } +.mobile-close-btn { + position: fixed; + top: 16px; + right: 16px; + z-index: 1000; + background: var(--header-bg); + border: 1px solid var(--border-color); + border-radius: 999px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + /* 移动端响应式样式 */ @media (max-width: 768px) { .preview-content-wrapper { @@ -323,6 +414,10 @@ .markdown-body td { padding: 6px 10px; } + + .file-share-header { + padding: 16px; + } } /* 平板响应式样式 */ @@ -368,4 +463,4 @@ } } -/* 打印样式优化已移除,转向后端生成方案 */ \ No newline at end of file +/* 打印样式优化已移除,转向后端生成方案 */ diff --git a/frontend/src/pages/Preview/PreviewPage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx similarity index 53% rename from frontend/src/pages/Preview/PreviewPage.jsx rename to frontend/src/pages/Preview/ProjectSharePage.jsx index 65a7403..9d7cc94 100644 --- a/frontend/src/pages/Preview/PreviewPage.jsx +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -1,9 +1,7 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useSearchParams, useNavigate } from 'react-router-dom' import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd' -import Toast from '@/components/Toast/Toast' -import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' -import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons' +import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeHighlight from 'rehype-highlight' @@ -12,39 +10,38 @@ import 'highlight.js/styles/github.css' import Mark from 'mark.js' import Highlighter from 'react-highlight-words' import GithubSlugger from 'github-slugger' -import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl, exportPDF } from '@/api/share' -import { searchDocuments } from '@/api/search' +import Toast from '@/components/Toast/Toast' +import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' +import { + getProjectSharePublicInfo, + getProjectShareTree, + searchProjectShareDocuments, + getProjectShareFile, + verifyProjectSharePassword, + getProjectShareDocumentUrl, + exportProjectSharePDF, +} from '@/api/share' import './PreviewPage.css' const { Sider, Content } = Layout -// 高亮组件 (用于 Tree) const HighlightText = ({ text, keyword }) => { - if (!keyword || !text) return text; + if (!keyword || !text) return text return ( ) } -function PreviewPage() { - const { projectId } = useParams() +function ProjectSharePage() { + const { shareCode } = useParams() const navigate = useNavigate() const [searchParams] = useSearchParams() - - const handleClose = () => { - // 检查是否有历史记录可回退 - if (window.history.length > 1) { - navigate(-1) - } else { - navigate('/projects') - } - } const [projectInfo, setProjectInfo] = useState(null) const [fileTree, setFileTree] = useState([]) const [selectedFile, setSelectedFile] = useState('') @@ -55,134 +52,91 @@ function PreviewPage() { const [tocItems, setTocItems] = useState([]) const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [password, setPassword] = useState('') - const [accessPassword, setAccessPassword] = useState(null) const [siderCollapsed, setSiderCollapsed] = useState(false) const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false) const [isMobile, setIsMobile] = useState(false) - const [pdfViewerVisible, setPdfViewerVisible] = useState(false) const [pdfUrl, setPdfUrl] = useState('') const [pdfFilename, setPdfFilename] = useState('') const [viewMode, setViewMode] = useState('markdown') - - // 搜索相关 const [searchKeyword, setSearchKeyword] = useState('') const [matchedFilePaths, setMatchedFilePaths] = useState(new Set()) const [isSearching, setIsSearching] = useState(false) - const contentRef = useRef(null) const viewerRef = useRef(null) - // 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'] - }) - } + const handleClose = () => { + if (window.history.length > 1) { + navigate(-1) + return } - }, [markdownContent, searchKeyword, viewMode]) + navigate('/') + } - // 检测是否为移动设备 useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768) - } + const checkMobile = () => setIsMobile(window.innerWidth < 768) checkMobile() window.addEventListener('resize', checkMobile) return () => window.removeEventListener('resize', checkMobile) }, []) useEffect(() => { - loadProjectInfo() - }, [projectId]) + 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(() => { + loadProjectInfo() + }, [shareCode]) - // 监听 URL 参数变化,处理文件导航和搜索 useEffect(() => { if (fileTree.length === 0) return const fileParam = searchParams.get('file') - const keywordParam = searchParams.get('keyword') - - if (keywordParam && keywordParam !== searchKeyword) { - handleSearch(keywordParam) - } - if (fileParam) { - if (fileParam !== selectedFile) { - // Deep link to file - if (fileParam.toLowerCase().endsWith('.pdf')) { - let url = getPreviewDocumentUrl(projectId, fileParam) - const params = [] - if (accessPassword) params.push(`access_pass=${encodeURIComponent(accessPassword)}`) - const token = localStorage.getItem('access_token') - if (token) params.push(`token=${encodeURIComponent(token)}`) - if (params.length > 0) url += `?${params.join('&')}` - - setSelectedFile(fileParam) - setPdfUrl(url) - setPdfFilename(fileParam.split('/').pop()) - setViewMode('pdf') - } else { - setSelectedFile(fileParam) - loadMarkdown(fileParam, accessPassword) - setViewMode('markdown') - } - - // Expand tree to file - const parts = fileParam.split('/') - const allParentPaths = [] - let currentPath = '' - for (let i = 0; i < parts.length - 1; i++) { - currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] - allParentPaths.push(currentPath) - } - setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])]) - } - } else { - if (!selectedFile) { - const readmeNode = findReadme(fileTree) - if (readmeNode) { - setSelectedFile(readmeNode.key) - loadMarkdown(readmeNode.key, accessPassword) - } + openSharedFile(fileParam) + return + } + + if (!selectedFile) { + const readmeNode = findReadme(fileTree) + if (readmeNode) { + openSharedFile(readmeNode.key) } } - }, [searchParams, fileTree, accessPassword]) + }, [fileTree, searchParams]) - // 加载项目基本信息 const loadProjectInfo = async () => { try { - const res = await getPreviewInfo(projectId) + 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 info error:', error) - Toast.error('加载失败', '项目不存在或已被删除') + console.error('Load project share info error:', error) + Toast.error('加载失败', '分享链接不存在或已失效') } } - // 验证密码 const handleVerifyPassword = async () => { if (!password.trim()) { Toast.warning('提示', '请输入访问密码') return } - try { - await verifyAccessPassword(projectId, password) - setAccessPassword(password) + await verifyProjectSharePassword(shareCode, password) setPasswordModalVisible(false) loadFileTree(password) Toast.success('验证成功') @@ -191,308 +145,220 @@ function PreviewPage() { } } - // 加载文件树 const loadFileTree = async (pwd = null) => { try { - const res = await getPreviewTree(projectId, pwd || accessPassword) - const tree = res.data || [] - setFileTree(tree) + const res = await getProjectShareTree(shareCode, pwd) + setFileTree(res.data || []) } catch (error) { - console.error('Load file tree error:', error) + console.error('Load share tree error:', error) if (error.response?.status === 403) { - Toast.error('访问密码错误或已过期') setPasswordModalVisible(true) + } else { + Toast.error('加载失败', '目录加载失败') } } } - // 搜索处理 - const handleSearch = async (value) => { - setSearchKeyword(value) - if (!value.trim()) { - setMatchedFilePaths(new Set()) - return - } - - setIsSearching(true) - try { - const res = await searchDocuments(value, projectId) - const paths = new Set(res.data.map(item => item.file_path)) - setMatchedFilePaths(paths) - - // 自动展开匹配的节点 (Assuming this comment might be there or not, better context: keysToExpand) - const keysToExpand = new Set(openKeys) - res.data.forEach(item => { - const parts = item.file_path.split('/') - let currentPath = '' - for (let i = 0; i < parts.length - 1; i++) { - currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] - keysToExpand.add(currentPath) - } - }) - setOpenKeys(Array.from(keysToExpand)) - } catch (error) { - console.error('Search error:', error) - Toast.error('搜索失败', '请稍后重试') - } finally { - setIsSearching(false) - } - } - - // 过滤树 - const filteredTreeData = useMemo(() => { - if (!searchKeyword.trim()) return fileTree - - const loop = (data) => { - const result = [] - for (const node of data) { - const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase()) - const contentMatch = matchedFilePaths.has(node.key) - - if (node.children) { - const children = loop(node.children) - if (children.length > 0 || titleMatch) { - result.push({ ...node, children }) - } - } else { - if (titleMatch || contentMatch) { - result.push(node) - } - } - } - return result - } - return loop(fileTree) - }, [fileTree, searchKeyword, matchedFilePaths]) - - const findReadme = (nodes) => { - for (const node of nodes) { - if (node.title === 'README.md' && node.isLeaf) { - return node - } - } - return null - } - - const convertTreeToMenuItems = (nodes) => { - return nodes.map((node) => { - const labelNode = node.title.replace('.md', '') - - if (!node.isLeaf) { - return { - key: node.key, - label: node.title, - icon: , - children: node.children ? convertTreeToMenuItems(node.children) : [], - } - } else if (node.title && node.title.endsWith('.md')) { - return { - key: node.key, - label: labelNode, - icon: , - } - } else if (node.title && node.title.endsWith('.pdf')) { - return { - key: node.key, - label: node.title, - icon: , - } - } - return null - }).filter(Boolean) - } - const loadMarkdown = async (filePath, pwd = null) => { setLoading(true) setTocItems([]) try { - const res = await getPreviewFile(projectId, filePath, pwd || accessPassword) + const res = await getProjectShareFile(shareCode, filePath, pwd) setMarkdownContent(res.data?.content || '') - - if (isMobile) { - setMobileDrawerVisible(false) - } - if (contentRef.current) { contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) } } catch (error) { - console.error('Load markdown error:', error) + console.error('Load share markdown error:', error) if (error.response?.status === 403) { - Toast.error('访问密码错误或已过期') setPasswordModalVisible(true) } else { - Toast.error('加载失败', '文档加载失败,请稍后重试') - setMarkdownContent('') + Toast.error('加载失败', '文档加载失败') } } finally { setLoading(false) } } - useEffect(() => { - if (markdownContent) { - const slugger = new GithubSlugger() - const headings = [] - const lines = markdownContent.split('\n') + const handleSearch = async (value) => { + const keyword = value || '' + setSearchKeyword(keyword) - lines.forEach((line) => { - const match = line.match(/^(#{1,6})\s+(.+)$/) - if (match) { - const level = match[1].length - const title = match[2] - // 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致 - const key = slugger.slug(title) - - headings.push({ - key: `#${key}`, - href: `#${key}`, - title, - level, - }) - } - }) - - setTocItems(headings) - } - }, [markdownContent]) - - const resolveRelativePath = (currentPath, relativePath) => { - if (relativePath.startsWith('/')) { - return relativePath.substring(1) - } - - const lastSlashIndex = currentPath.lastIndexOf('/') - const currentDir = lastSlashIndex !== -1 ? currentPath.substring(0, lastSlashIndex) : '' - - const parts = relativePath.split('/') - const dirParts = currentDir ? currentDir.split('/') : [] - - for (const part of parts) { - if (part === '..') { - dirParts.pop() - } else if (part !== '.' && part !== '') { - dirParts.push(part) - } - } - - return dirParts.join('/') - } - - const handleMarkdownLink = (e, href) => { - if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) { + 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) 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]) + + 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) { + return { + key: node.key, + label: labelNode, + icon: , + 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 openSharedFile = (key) => { + setSelectedFile(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) => { + if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) return const isMd = href.endsWith('.md') const isPdf = href.toLowerCase().endsWith('.pdf') - if (!isMd && !isPdf) return - e.preventDefault() - let decodedHref = href try { decodedHref = decodeURIComponent(href) - } catch (err) { - } - - const targetPath = resolveRelativePath(selectedFile, decodedHref) - - const lastSlashIndex = targetPath.lastIndexOf('/') - const parentPath = lastSlashIndex !== -1 ? targetPath.substring(0, lastSlashIndex) : '' - if (parentPath && !openKeys.includes(parentPath)) { - const pathParts = parentPath.split('/') - const allParentPaths = [] - let currentPath = '' - for (const part of pathParts) { - currentPath = currentPath ? `${currentPath}/${part}` : part - allParentPaths.push(currentPath) - } - setOpenKeys([...new Set([...openKeys, ...allParentPaths])]) - } - - handleMenuClick({ key: targetPath }) + } catch {} + openSharedFile(resolveRelativePath(selectedFile, decodedHref)) } - const handleContentClick = (e) => { - const target = e.target.closest('a') - if (target) { - const href = target.getAttribute('href') - if (href) { - handleMarkdownLink(e, href) - } - } - } - - const handleMenuClick = ({ key }) => { - setSelectedFile(key) - - if (key.toLowerCase().endsWith('.pdf')) { - let url = getPreviewDocumentUrl(projectId, key) - const params = [] - - if (accessPassword) { - params.push(`access_pass=${encodeURIComponent(accessPassword)}`) - } - - const token = localStorage.getItem('access_token') - if (token) { - params.push(`token=${encodeURIComponent(token)}`) - } - - if (params.length > 0) { - url += `?${params.join('&')}` - } - - setPdfUrl(url) - setPdfFilename(key.split('/').pop()) - setViewMode('pdf') - } else { - setViewMode('markdown') - loadMarkdown(key) - } - } - - // 导出 PDF 处理 const handleExportPDF = () => { + if (!selectedFile) return if (viewMode === 'pdf') { - // 如果已经是 PDF 文件,直接下载 const link = document.createElement('a') link.href = pdfUrl link.download = pdfFilename document.body.appendChild(link) link.click() document.body.removeChild(link) - } else { - // Markdown 文件:使用后端生成 PDF - let url = exportPDF(projectId, selectedFile) - const params = [] - - if (accessPassword) { - params.push(`access_pass=${encodeURIComponent(accessPassword)}`) - } - - const token = localStorage.getItem('access_token') - if (token) { - params.push(`token=${encodeURIComponent(token)}`) - } - - if (params.length > 0) { - url += (url.includes('?') ? '&' : '?') + params.join('&') - } - - window.open(url, '_blank') + return } + window.open(exportProjectSharePDF(shareCode, selectedFile), '_blank') } - const menuItems = convertTreeToMenuItems(filteredTreeData) + const menuItems = useMemo(() => convertTreeToMenuItems(filteredTreeData), [filteredTreeData]) return (
{isMobile ? ( <> + navigate('/')} - > - logo - {projectInfo?.name || '项目预览'} -
- } + title={projectInfo?.name || '项目分享'} placement="left" onClose={() => setMobileDrawerVisible(false)} open={mobileDrawerVisible} width="80%" >
- {projectInfo?.description && ( -

{projectInfo.description}

- )} + {projectInfo?.description &&

{projectInfo.description}

}
- - {/* 搜索框 */} -
+
- - {filteredTreeData.length > 0 ? ( + {menuItems.length > 0 ? ( openSharedFile(key)} className="preview-menu" /> ) : ( -
- -
+ )} ) : ( - +
-
navigate('/')} - > - logo -

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

+
+

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

+
- {projectInfo?.description && ( -

{projectInfo.description}

- )} + {projectInfo?.description &&

{projectInfo.description}

}
- - {/* 搜索框 */} -
+
- - {filteredTreeData.length > 0 ? ( + {menuItems.length > 0 ? ( openSharedFile(key)} className="preview-menu" /> ) : ( -
- +
+
)} @@ -614,16 +451,16 @@ function PreviewPage() {
) : viewMode === 'pdf' ? ( - + ) : ( -
- +
{ + const target = e.target.closest('a') + if (target) { + const href = target.getAttribute('href') + if (href) handleMarkdownLink(e, href) + } + }} ref={viewerRef}> + {markdownContent}
@@ -643,12 +480,7 @@ function PreviewPage() {

文档索引

-
{tocItems.length > 0 ? ( @@ -688,12 +520,7 @@ function PreviewPage() { - - 访问验证 -
- } + title={
访问验证
} open={passwordModalVisible} onOk={handleVerifyPassword} onCancel={() => setPasswordModalVisible(false)} @@ -702,7 +529,7 @@ function PreviewPage() { maskClosable={false} >
-

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

+

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

{ + if (!editModalVisible || !currentProject) return + + const isPublic = editForm.getFieldValue('is_public') + if (!isPublic) { + setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null }) + 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, editForm]) + + const currentEditPublic = Form.useWatch('is_public', editForm) + const isPublicEnablePending = editModalVisible && currentEditPublic && currentProject?.is_public !== 1 + + useEffect(() => { + if (!editModalVisible || !currentProject) return + + if (!currentEditPublic) { + setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null }) + setHasPassword(false) + setPassword('') + return + } + + if (currentProject.is_public !== 1) { + setShareInfo(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('Refresh project share info error:', error) + } + })() + }, [currentEditPublic, editModalVisible, currentProject, editForm]) + const [gitRepos, setGitRepos] = useState([]) const [loadingRepos, setLoadingRepos] = useState(false) const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false) @@ -264,22 +319,6 @@ function ProjectList({ type = 'my' }) { navigate(`/projects/${projectId}/docs`) } - // 打开分享设置 - const handleShare = async (e, project) => { - e.stopPropagation() - setCurrentProject(project) - try { - const res = await getProjectShareInfo(project.id) - setShareInfo(res.data) - setHasPassword(res.data.has_password) - setPassword('') - setShareModalVisible(true) - } catch (error) { - console.error('Get share info error:', error) - message.error('获取分享信息失败') - } - } - // 复制分享链接 const handleCopyLink = async () => { if (!shareInfo) return @@ -316,13 +355,11 @@ function ProjectList({ type = 'my' }) { // 切换密码保护 const handlePasswordToggle = async (checked) => { if (!checked) { - // 取消密码 try { - await updateShareSettings(currentProject.id, { access_pass: null }) + await updateProjectShareSettings(currentProject.id, { access_pass: null }) setHasPassword(false) setPassword('') message.success('已取消访问密码') - // 刷新分享信息 const res = await getProjectShareInfo(currentProject.id) setShareInfo(res.data) } catch (error) { @@ -341,9 +378,8 @@ function ProjectList({ type = 'my' }) { return } try { - await updateShareSettings(currentProject.id, { access_pass: password }) + await updateProjectShareSettings(currentProject.id, { access_pass: password }) message.success('访问密码已设置') - // 刷新分享信息 const res = await getProjectShareInfo(currentProject.id) setShareInfo(res.data) setHasPassword(true) @@ -565,11 +601,9 @@ function ProjectList({ type = 'my' }) { actions={type === 'my' ? [ handleEdit(e, project)} />, handleGitSettings(e, project)} />, - handleShare(e, project)} />, handleMembers(e, project)} />, ] : [ , - handleShare(e, project)} />, ]} > {/* 公开项目标识 */} @@ -720,6 +754,46 @@ function ProjectList({ type = 'my' }) { + + + {!editForm.getFieldValue('is_public') ? ( +
+ 开启“公开项目”后,才可以生成项目分享链接和配置访问密码。 +
+ ) : isPublicEnablePending ? ( +
+ 保存项目后将自动生成新的项目分享链接。 +
+ ) : ( + <> + : null + } + /> + + 访问密码保护 + + + {hasPassword && ( +
+ setPassword(e.target.value)} + /> + +
+ )} + + )} +
+
+ @@ -797,65 +871,6 @@ function ProjectList({ type = 'my' }) { - setShareModalVisible(false)} - footer={null} - width={500} - > - {shareInfo && ( - -
- - - } - /> -
- - {/* 只有在我的项目中才显示密码设置功能 */} - {type === 'my' && ( - <> -
- - 访问密码保护 - - -
- - {hasPassword && ( -
- setPassword(e.target.value)} - /> - -
- )} - - )} - - {/* 参与项目显示提示 */} - {type === 'share' && shareInfo.has_password && ( -
- 该项目已设置访问密码保护 -
- )} -
- )} -
-