From fc617cf678c9e91664d1a74967f5a7b62a4856f5 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Sat, 9 May 2026 10:45:30 +0800 Subject: [PATCH 01/13] v0.9.7 --- backend/app/api/v1/__init__.py | 3 +- backend/app/api/v1/files.py | 25 +- backend/app/api/v1/projects.py | 131 ++-- backend/app/api/v1/shares.py | 667 ++++++++++++++++++ backend/app/core/enums.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/share.py | 27 + backend/app/schemas/file.py | 1 + backend/app/schemas/project.py | 26 +- backend/scripts/create_share_links_table.sql | 18 + backend/scripts/init_database.sql | 20 + frontend/src/App.jsx | 7 +- frontend/src/api/share.js | 123 ++-- .../src/components/ModeSwitch/ModeSwitch.css | 12 + .../src/components/ModeSwitch/ModeSwitch.jsx | 3 +- .../src/pages/Document/DocumentEditor.css | 103 +-- .../src/pages/Document/DocumentEditor.jsx | 35 +- frontend/src/pages/Document/DocumentPage.css | 91 ++- frontend/src/pages/Document/DocumentPage.jsx | 315 ++++++--- frontend/src/pages/Preview/FileSharePage.jsx | 229 ++++++ frontend/src/pages/Preview/PreviewPage.css | 111 ++- .../{PreviewPage.jsx => ProjectSharePage.jsx} | 649 +++++++---------- .../src/pages/ProjectList/ProjectList.jsx | 185 ++--- 23 files changed, 1974 insertions(+), 811 deletions(-) create mode 100644 backend/app/api/v1/shares.py create mode 100644 backend/app/models/share.py create mode 100644 backend/scripts/create_share_links_table.sql create mode 100644 frontend/src/pages/Preview/FileSharePage.jsx rename frontend/src/pages/Preview/{PreviewPage.jsx => ProjectSharePage.jsx} (53%) 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 && ( -
- 该项目已设置访问密码保护 -
- )} -
- )} -
- Date: Sat, 9 May 2026 14:20:38 +0800 Subject: [PATCH 02/13] v0.9.7 --- frontend/src/pages/Document/DocumentPage.css | 10 ++++++++++ frontend/src/pages/Preview/PreviewPage.css | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/frontend/src/pages/Document/DocumentPage.css b/frontend/src/pages/Document/DocumentPage.css index ffcf49d..ff425ea 100644 --- a/frontend/src/pages/Document/DocumentPage.css +++ b/frontend/src/pages/Document/DocumentPage.css @@ -178,6 +178,13 @@ flex-direction: column; } +.docs-toc-sider .ant-layout-sider-children { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + .toc-header { padding: 16px; border-bottom: 1px solid var(--border-color); @@ -196,12 +203,15 @@ .toc-content { flex: 1; + min-height: 0; overflow-y: auto; overflow-x: auto; padding: 16px; } .toc-content .ant-anchor { + display: block; + min-height: max-content; padding-left: 0; padding-bottom: 65px; /* 给Anchor组件添加底部内边距,避免最后一项被遮挡 */ diff --git a/frontend/src/pages/Preview/PreviewPage.css b/frontend/src/pages/Preview/PreviewPage.css index 962a703..852169a 100644 --- a/frontend/src/pages/Preview/PreviewPage.css +++ b/frontend/src/pages/Preview/PreviewPage.css @@ -152,6 +152,13 @@ flex-direction: column; } +.preview-toc-sider .ant-layout-sider-children { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + .toc-header { padding: 16px; border-bottom: 1px solid var(--border-color); @@ -170,13 +177,17 @@ .toc-content { flex: 1; + min-height: 0; overflow-y: auto; overflow-x: auto; padding: 16px; } .toc-content .ant-anchor { + display: block; + min-height: max-content; padding-left: 0; + padding-bottom: 65px; } .toc-content .ant-anchor-link { From e09f94b0436e10741ec062badcfa0413daee770f Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Sun, 10 May 2026 13:35:54 +0800 Subject: [PATCH 03/13] v0.9.7 --- frontend/src/pages/Document/DocumentEditor.jsx | 7 ++++++- frontend/src/pages/Document/DocumentPage.jsx | 5 +++-- frontend/src/pages/Preview/ProjectSharePage.jsx | 17 ++++++++++++----- frontend/src/pages/ProjectList/ProjectList.jsx | 4 ++-- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index 6321324..e5cbfb2 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -4,6 +4,7 @@ import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, S import { FileOutlined, FolderOutlined, + FolderOpenOutlined, PlusOutlined, DeleteOutlined, EditOutlined, @@ -923,11 +924,15 @@ function DocumentEditor() { ) 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: () => { diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index fdcb3cb..af1f4d8 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, ArrowLeftOutlined, MenuOutlined, ReloadOutlined } from '@ant-design/icons' +import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, 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' @@ -306,11 +306,12 @@ function DocumentPage() { ) if (!node.isLeaf) { + const isOpen = openKeys.includes(node.key) // 目录 return { key: node.key, label: labelNode, - icon: , + icon: isOpen ? : , onTitleClick: () => setSelectedNodeKey(node.key), children: node.children ? convertTreeToMenuItems(node.children) : [], } diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx index 9d7cc94..8c56a05 100644 --- a/frontend/src/pages/Preview/ProjectSharePage.jsx +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -1,7 +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 { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons' +import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeHighlight from 'rehype-highlight' @@ -45,6 +45,7 @@ function ProjectSharePage() { 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([]) @@ -264,10 +265,12 @@ function ProjectSharePage() { ) if (!node.isLeaf) { + const isOpen = openKeys.includes(node.key) return { key: node.key, label: labelNode, - icon: , + icon: isOpen ? : , + onTitleClick: () => setSelectedNodeKey(node.key), children: node.children ? convertTreeToMenuItems(node.children) : [], } } @@ -296,6 +299,7 @@ function ProjectSharePage() { const openSharedFile = (key) => { setSelectedFile(key) + setSelectedNodeKey(key) const parts = key.split('/') const allParentPaths = [] @@ -346,7 +350,10 @@ function ProjectSharePage() { window.open(exportProjectSharePDF(shareCode, selectedFile), '_blank') } - const menuItems = useMemo(() => convertTreeToMenuItems(filteredTreeData), [filteredTreeData]) + const menuItems = useMemo( + () => convertTreeToMenuItems(filteredTreeData), + [filteredTreeData, openKeys] + ) return (
@@ -391,7 +398,7 @@ function ProjectSharePage() { {menuItems.length > 0 ? ( 0 ? ( handleOpenProject(project.id)} actions={type === 'my' ? [ - handleEdit(e, project)} />, + handleEdit(e, project)} />, handleGitSettings(e, project)} />, handleMembers(e, project)} />, ] : [ From 1fe3508ec6247dce6400a7c1319701245c6e0da0 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Sun, 10 May 2026 17:15:24 +0800 Subject: [PATCH 04/13] v0.9.7 --- frontend/src/pages/Preview/FileSharePage.jsx | 19 ++-- frontend/src/pages/Preview/PreviewPage.css | 100 ++++++++++++------ .../src/pages/Preview/ProjectSharePage.jsx | 18 ++-- 3 files changed, 88 insertions(+), 49 deletions(-) diff --git a/frontend/src/pages/Preview/FileSharePage.jsx b/frontend/src/pages/Preview/FileSharePage.jsx index 15655f2..a511eb0 100644 --- a/frontend/src/pages/Preview/FileSharePage.jsx +++ b/frontend/src/pages/Preview/FileSharePage.jsx @@ -120,16 +120,19 @@ function FileSharePage() { return (
-
-
-

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

- {shareInfo?.project_name &&

{shareInfo.project_name}

} -
-
- +
+ +

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

+
{loading ? (
diff --git a/frontend/src/pages/Preview/PreviewPage.css b/frontend/src/pages/Preview/PreviewPage.css index 852169a..a84923b 100644 --- a/frontend/src/pages/Preview/PreviewPage.css +++ b/frontend/src/pages/Preview/PreviewPage.css @@ -16,31 +16,10 @@ background: var(--bg-color); } -.file-share-header { +.file-share-content-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); + gap: 14px; } .file-share-content { @@ -62,12 +41,38 @@ 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; - justify-content: space-between; gap: 12px; - margin-bottom: 8px; + min-width: 0; } .preview-layout { @@ -85,22 +90,24 @@ } .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; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .preview-search { @@ -228,6 +235,29 @@ 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; +} + +.preview-content-header h3 { + 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; diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx index 8c56a05..f708765 100644 --- a/frontend/src/pages/Preview/ProjectSharePage.jsx +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -381,9 +381,6 @@ function ProjectSharePage() { open={mobileDrawerVisible} width="80%" > -
- {projectInfo?.description &&

{projectInfo.description}

} -
-

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

- +

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

- {projectInfo?.description &&

{projectInfo.description}

}
+
+

{selectedFile || 'README.md'}

+
{loading ? (
From ccb61b00fa5758229e29975e03760b6fb7a13242 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Mon, 11 May 2026 09:48:19 +0800 Subject: [PATCH 05/13] v0.9.7 --- backend/app/api/v1/shares.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/api/v1/shares.py b/backend/app/api/v1/shares.py index b5d9c8b..e303f65 100644 --- a/backend/app/api/v1/shares.py +++ b/backend/app/api/v1/shares.py @@ -474,6 +474,7 @@ async def get_project_share_file( 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}) @@ -573,7 +574,7 @@ async def get_file_share_content( }) content = await storage_service.read_file(file_path) - content = rewrite_markdown_assets_for_pdf(content) + 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, From 5256d20ac9ede9cef178229871f5c78df7b4c006 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Fri, 15 May 2026 19:59:49 +0800 Subject: [PATCH 06/13] v0.9.7 --- .../src/pages/Document/DocumentEditor.css | 16 ++-- .../src/pages/Document/DocumentEditor.jsx | 48 ++++++++++- frontend/src/pages/Document/DocumentPage.css | 29 ++++++- frontend/src/pages/Document/DocumentPage.jsx | 81 ++++++++++++++----- frontend/src/pages/Preview/FileSharePage.jsx | 52 +++++++++++- frontend/src/pages/Preview/PreviewPage.css | 29 ++++++- .../src/pages/Preview/ProjectSharePage.jsx | 80 +++++++++++++++--- .../src/pages/ProjectList/ProjectList.jsx | 25 +++++- 8 files changed, 306 insertions(+), 54 deletions(-) diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index 2b7b02b..de17308 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -203,8 +203,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; } @@ -454,14 +454,8 @@ 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; } diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index e5cbfb2..87a8f9d 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -79,6 +79,28 @@ function DocumentEditor() { const editorCtxRef = useRef(null) const modeSwitchingRef = useRef(false) + 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) { document.startViewTransition(() => navigate(to)) @@ -97,6 +119,20 @@ function DocumentEditor() { setSearchParams(nextParams, { replace: true }) } + 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) { @@ -113,7 +149,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() @@ -1068,7 +1104,10 @@ function DocumentEditor() {
-

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

+

+ + {headerLabel} +

diff --git a/frontend/src/pages/Document/DocumentPage.css b/frontend/src/pages/Document/DocumentPage.css index ff425ea..f586fbe 100644 --- a/frontend/src/pages/Document/DocumentPage.css +++ b/frontend/src/pages/Document/DocumentPage.css @@ -267,15 +267,38 @@ align-items: center; } -.docs-content-header h3 { - margin: 0; +.docs-header-title { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + gap: 0; font-size: 16px; font-weight: 600; color: var(--text-color); +} + +.docs-header-item { + display: inline-flex; + align-items: center; + min-width: 0; + flex: 0 1 auto; + gap: 6px; +} + +.docs-header-icon { + font-size: 15px; + color: var(--text-color-secondary); + flex: none; +} + +.docs-header-text { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 100%; } .docs-content-wrapper { diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index af1f4d8..36ca8bf 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -69,6 +69,19 @@ function DocumentPage() { const contentRef = useRef(null) const modeSwitchingRef = useRef(false) + const getHeaderDisplay = (filePath) => { + const resolvedPath = filePath || 'README.md' + const fileName = resolvedPath.split('/').filter(Boolean).pop() || 'README.md' + const isPdf = fileName.toLowerCase().endsWith('.pdf') + const FileIcon = isPdf ? FilePdfOutlined : FileTextOutlined + + return { + fileName, + FileIcon, + isPdf, + } + } + const navigateWithTransition = (to) => { if (document.startViewTransition) { document.startViewTransition(() => navigate(to)) @@ -422,21 +435,49 @@ function DocumentPage() { return dirParts.join('/') } + 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('//'))) + } + // 处理markdown内部链接点击 const handleMarkdownLink = (e, href) => { + const normalizedHref = normalizeMarkdownHref(href) + // 检查是否是外部链接 - if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) { + if (!normalizedHref || isExternalHref(normalizedHref)) { return // 外部链接,允许默认行为 } // 检查是否是锚点链接 - if (href.startsWith('#')) { + if (normalizedHref.startsWith('#')) { return // 锚点链接,允许默认行为 } // 检查是否是文档文件(.md 或 .pdf) - const isMd = href.endsWith('.md') - const isPdf = href.toLowerCase().endsWith('.pdf') + const pathOnly = normalizedHref.split(/[?#]/)[0] + const isMd = pathOnly.endsWith('.md') + const isPdf = pathOnly.toLowerCase().endsWith('.pdf') if (!isMd && !isPdf) { return // 不是文档文件,允许默认行为 @@ -445,22 +486,14 @@ function DocumentPage() { // 阻止默认跳转 e.preventDefault() - // 先解码 href(因为 Markdown 中的链接可能已经是 URL 编码的) - let decodedHref = href - try { - decodedHref = decodeURIComponent(href) - } catch (e) { - // 解码失败,使用原始值 - } - // 解析路径 let targetPath - if (decodedHref.startsWith('.') || decodedHref.startsWith('..')) { + if (pathOnly.startsWith('.') || pathOnly.startsWith('..')) { // 真正的相对路径,相对于当前文件 - targetPath = resolveRelativePath(selectedFile, decodedHref) + targetPath = resolveRelativePath(selectedFile, pathOnly) } else { // 项目内绝对路径(由编辑器生成),相对于项目根目录 - targetPath = decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref + targetPath = pathOnly.startsWith('/') ? pathOnly.substring(1) : pathOnly } // 自动展开父目录 @@ -836,7 +869,7 @@ function DocumentPage() { if (!searchKeyword) { return { a: ({ node, href, children, ...props }) => { - const isExternal = href && (href.startsWith('http') || href.startsWith('//')); + const isExternal = isExternalHref(href); return ( { - const isExternal = href && (href.startsWith('http') || href.startsWith('//')); + const isExternal = isExternalHref(href); return ( -
-

{selectedFile || 'README.md'}

+
+ {(() => { + const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile) + return ( +
+ + + {fileName} + +
+ ) + })()}
{loading ? ( diff --git a/frontend/src/pages/Preview/FileSharePage.jsx b/frontend/src/pages/Preview/FileSharePage.jsx index a511eb0..ed4a9b2 100644 --- a/frontend/src/pages/Preview/FileSharePage.jsx +++ b/frontend/src/pages/Preview/FileSharePage.jsx @@ -1,7 +1,7 @@ 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 { CloseOutlined, LockOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FilePdfOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeHighlight from 'rehype-highlight' @@ -117,6 +117,45 @@ function FileSharePage() { window.open(exportFileSharePDF(shareCode), '_blank') } + 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 (
@@ -131,7 +170,10 @@ function FileSharePage() { > -

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

+

+ + {headerLabel} +

{loading ? (
@@ -145,7 +187,11 @@ function FileSharePage() { ) : (
- + {contentInfo?.content || ''}
diff --git a/frontend/src/pages/Preview/PreviewPage.css b/frontend/src/pages/Preview/PreviewPage.css index a84923b..026df0a 100644 --- a/frontend/src/pages/Preview/PreviewPage.css +++ b/frontend/src/pages/Preview/PreviewPage.css @@ -247,7 +247,34 @@ align-items: center; } -.preview-content-header h3 { +.preview-header-title { + display: flex; + align-items: center; + min-width: 0; + 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-content-header h3:not(.preview-header-title) { margin: 0; font-size: 16px; font-weight: 600; diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx index f708765..c1b8e27 100644 --- a/frontend/src/pages/Preview/ProjectSharePage.jsx +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -181,6 +181,31 @@ function ProjectSharePage() { } } + 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) @@ -297,6 +322,14 @@ function ProjectSharePage() { 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) @@ -324,16 +357,14 @@ function ProjectSharePage() { } 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') + 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() - let decodedHref = href - try { - decodedHref = decodeURIComponent(href) - } catch {} - openSharedFile(resolveRelativePath(selectedFile, decodedHref)) + openSharedFile(resolveMarkdownTarget(pathOnly)) } const handleExportPDF = () => { @@ -355,6 +386,27 @@ function ProjectSharePage() { [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 (
@@ -454,7 +506,10 @@ function ProjectSharePage() {
-

{selectedFile || 'README.md'}

+

+ + {headerLabel} +

{loading ? ( @@ -467,13 +522,18 @@ function ProjectSharePage() { ) : (
{ + 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}
diff --git a/frontend/src/pages/ProjectList/ProjectList.jsx b/frontend/src/pages/ProjectList/ProjectList.jsx index 5740a93..0cfba40 100644 --- a/frontend/src/pages/ProjectList/ProjectList.jsx +++ b/frontend/src/pages/ProjectList/ProjectList.jsx @@ -157,14 +157,33 @@ function ProjectList({ type = 'my' }) { // 更新项目 const handleUpdateProject = async (values) => { try { - await updateProject(currentProject.id, { + const shouldKeepOpenForShare = currentProject?.is_public !== 1 && values.is_public + const res = await updateProject(currentProject.id, { ...values, is_public: values.is_public ? 1 : 0, }) + const updatedProject = res.data || { + ...currentProject, + ...values, + is_public: values.is_public ? 1 : 0, + } + setCurrentProject(updatedProject) message.success('项目更新成功') + await fetchProjects() + + if (shouldKeepOpenForShare) { + const shareRes = await getProjectShareInfo(currentProject.id) + setShareInfo(shareRes.data) + setHasPassword(shareRes.data.has_password) + setPassword(shareRes.data.access_pass || '') + return + } + + setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null }) + setHasPassword(false) + setPassword('') setEditModalVisible(false) editForm.resetFields() - fetchProjects() } catch (error) { console.error('Update project error:', error) message.error('项目更新失败') @@ -762,7 +781,7 @@ function ProjectList({ type = 'my' }) {
) : isPublicEnablePending ? (
- 保存项目后将自动生成新的项目分享链接。 + 保存项目后将自动生成项目分享链接。
) : ( <> From 9f395a10acb8d8f7d73f9de96567b091d04adb81 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Thu, 21 May 2026 17:51:53 +0800 Subject: [PATCH 07/13] =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BA=86=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E9=A1=B5=E9=9D=A2=E7=9A=84=E5=B7=A5=E5=85=B7=E6=A0=8F?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/FloatingToc/FloatingToc.css | 189 ++++++++++++++++++ .../components/FloatingToc/FloatingToc.jsx | 55 +++++ .../components/PDFViewer/VirtualPDFViewer.css | 32 ++- .../components/PDFViewer/VirtualPDFViewer.jsx | 118 +++++------ .../src/pages/Document/DocumentEditor.css | 18 +- frontend/src/pages/Document/DocumentPage.css | 98 ++------- frontend/src/pages/Document/DocumentPage.jsx | 127 +++++------- frontend/src/pages/Preview/FileSharePage.jsx | 92 ++++----- frontend/src/pages/Preview/PreviewPage.css | 100 ++------- .../src/pages/Preview/ProjectSharePage.jsx | 90 ++++----- 10 files changed, 505 insertions(+), 414 deletions(-) create mode 100644 frontend/src/components/FloatingToc/FloatingToc.css create mode 100644 frontend/src/components/FloatingToc/FloatingToc.jsx diff --git a/frontend/src/components/FloatingToc/FloatingToc.css b/frontend/src/components/FloatingToc/FloatingToc.css new file mode 100644 index 0000000..188573f --- /dev/null +++ b/frontend/src/components/FloatingToc/FloatingToc.css @@ -0,0 +1,189 @@ +.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-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; +} + +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..ea56046 --- /dev/null +++ b/frontend/src/components/FloatingToc/FloatingToc.jsx @@ -0,0 +1,55 @@ +import { Anchor } from 'antd' +import { FileTextOutlined, MenuOutlined } from '@ant-design/icons' +import './FloatingToc.css' + +export default function FloatingToc({ + items = [], + getContainer, + searchKeyword = '', + renderTitle, + className = '', +}) { + const anchorItems = items.map((item) => ({ + key: item.key, + href: item.href, + title: ( +
+ + + {renderTitle ? renderTitle(item, searchKeyword) : item.title} + +
+ ), + })) + + return ( + + ) +} diff --git a/frontend/src/components/PDFViewer/VirtualPDFViewer.css b/frontend/src/components/PDFViewer/VirtualPDFViewer.css index e95ef79..7230ccd 100644 --- a/frontend/src/components/PDFViewer/VirtualPDFViewer.css +++ b/frontend/src/components/PDFViewer/VirtualPDFViewer.css @@ -7,22 +7,42 @@ .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-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 +149,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..60b1558 100644 --- a/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx +++ b/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx @@ -1,4 +1,5 @@ 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 { @@ -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 }) { const [numPages, setNumPages] = useState(null) const [scale, setScale] = useState(1.0) const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4 @@ -159,65 +160,66 @@ function VirtualPDFViewer({ url, filename }) { document.body.removeChild(link) } + const toolbar = ( +
+ + + + {Math.round(scale * 100)}% + + + + + + + +
+ ) + return (
- {/* 工具栏 */} -
- - - - - - - - - - - {Math.round(scale * 100)}% - - - -
+ {toolbarTarget ? createPortal(toolbar, toolbarTarget) : toolbar} {/* PDF内容区 - 自定义虚拟滚动 */}
diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index de17308..df66b10 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -283,6 +283,12 @@ background-color: var(--item-hover-bg); } +/* The fixed editor layout does not use Bytemd's built-in sidebar modes. */ +.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(1), +.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(2) { + display: none; +} + /* 编辑和预览区域容器 */ .bytemd-body { flex: 1 !important; @@ -297,15 +303,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; @@ -314,16 +315,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; diff --git a/frontend/src/pages/Document/DocumentPage.css b/frontend/src/pages/Document/DocumentPage.css index f586fbe..257db55 100644 --- a/frontend/src/pages/Document/DocumentPage.css +++ b/frontend/src/pages/Document/DocumentPage.css @@ -169,86 +169,6 @@ background: var(--bg-color); } -.docs-toc-sider { - border-left: 1px solid var(--border-color); - background: var(--bg-color-secondary) !important; - height: 100%; - overflow: hidden; - display: flex; - flex-direction: column; -} - -.docs-toc-sider .ant-layout-sider-children { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; -} - -.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; - min-height: 0; - overflow-y: auto; - overflow-x: auto; - padding: 16px; -} - -.toc-content .ant-anchor { - display: block; - min-height: max-content; - padding-left: 0; - padding-bottom: 65px; - /* 给Anchor组件添加底部内边距,避免最后一项被遮挡 */ -} - -.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); -} - .docs-content { height: 100%; overflow-y: auto; @@ -265,13 +185,15 @@ background: var(--header-bg); display: flex; align-items: center; + justify-content: space-between; + gap: 16px; } .docs-header-title { display: flex; align-items: center; min-width: 0; - max-width: 100%; + flex: 1; overflow: hidden; white-space: nowrap; gap: 0; @@ -301,6 +223,20 @@ white-space: nowrap; } +.docs-header-actions { + flex: none; +} + +.pdf-header-toolbar { + min-width: 0; + overflow-x: auto; + overflow-y: hidden; +} + +.pdf-header-toolbar .pdf-toolbar { + white-space: nowrap; +} + .docs-content-wrapper { max-width: 900px; margin: 0 auto; diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index 36ca8bf..2e37434 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, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, ArrowLeftOutlined, MenuOutlined, ReloadOutlined } from '@ant-design/icons' +import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd' +import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' @@ -15,7 +15,7 @@ import { gitPull, gitPush, getGitRepos } from '@/api/project' import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share' import { searchDocuments } from '@/api/search' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' -import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' +import FloatingToc from '@/components/FloatingToc/FloatingToc' import Toast from '@/components/Toast/Toast' import ModeSwitch from '@/components/ModeSwitch/ModeSwitch' import './DocumentPage.css' @@ -45,7 +45,6 @@ function DocumentPage() { const [markdownContent, setMarkdownContent] = useState('') const [loading, setLoading] = useState(false) const [openKeys, setOpenKeys] = useState([]) - const [tocCollapsed, setTocCollapsed] = useState(false) const [tocItems, setTocItems] = useState([]) const [shareModalVisible, setShareModalVisible] = useState(false) const [shareInfo, setShareInfo] = useState(null) @@ -68,6 +67,7 @@ function DocumentPage() { const contentRef = useRef(null) const modeSwitchingRef = useRef(false) + const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) const getHeaderDisplay = (filePath) => { const resolvedPath = filePath || 'README.md' @@ -414,6 +414,22 @@ function DocumentPage() { } } + const scrollContentToTop = () => { + if (contentRef.current) { + contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + } + + const handleExportMarkdownPDF = () => { + if (!selectedFile) return + let url = getExportPdfUrl(projectId, selectedFile) + const token = localStorage.getItem('access_token') + if (token) { + url += `&token=${encodeURIComponent(token)}` + } + window.open(url, '_blank') + } + // 解析相对路径 const resolveRelativePath = (currentPath, relativePath) => { // 获取当前文件所在目录 @@ -1031,12 +1047,33 @@ function DocumentPage() { {(() => { const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile) return ( -
- - - {fileName} - -
+ <> +
+ + + {fileName} + +
+ {viewMode === 'pdf' &&
} + {viewMode === 'markdown' && ( + + + + + )} + ) })()}
@@ -1051,6 +1088,7 @@ function DocumentPage() { ) : (
@@ -1065,72 +1103,17 @@ function DocumentPage() { )}
- {/* 浮动按钮组 - 仅在markdown模式显示 */} - {viewMode === 'markdown' && ( - { - if (!selectedFile) return - let url = getExportPdfUrl(projectId, selectedFile) - const token = localStorage.getItem('access_token') - if (token) { - url += `&token=${encodeURIComponent(token)}` - } - window.open(url, '_blank') - }} - /> - )} - {/* 右侧TOC面板 - 仅在markdown模式显示 */} - {viewMode === 'markdown' && !tocCollapsed && ( - -
-

文档索引

-
-
- {tocItems.length > 0 ? ( - contentRef.current} - items={tocItems.map((item) => ({ - key: item.key, - href: item.href, - title: ( -
- - -
- ), - }))} - /> - ) : ( -
当前文档无标题
- )} -
-
+ {viewMode === 'markdown' && ( + contentRef.current} + renderTitle={(item, keyword) => } + /> )} - - {/* TOC展开按钮 */} - {tocCollapsed && ( - - )} {/* 分享模态框 */} diff --git a/frontend/src/pages/Preview/FileSharePage.jsx b/frontend/src/pages/Preview/FileSharePage.jsx index ed4a9b2..38c8e9d 100644 --- a/frontend/src/pages/Preview/FileSharePage.jsx +++ b/frontend/src/pages/Preview/FileSharePage.jsx @@ -1,7 +1,7 @@ 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, FilePdfOutlined } from '@ant-design/icons' +import { Layout, Modal, Input, Spin, Button, Space } from 'antd' +import { CloseOutlined, LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeHighlight from 'rehype-highlight' @@ -9,7 +9,7 @@ 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 FloatingToc from '@/components/FloatingToc/FloatingToc' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import { getFileSharePublicInfo, @@ -19,17 +19,17 @@ import { } from '@/api/share' import './PreviewPage.css' -const { Content, Sider } = Layout +const { Content } = Layout function FileSharePage() { const { shareCode } = useParams() const navigate = useNavigate() const contentRef = 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 [tocCollapsed, setTocCollapsed] = useState(false) const [tocItems, setTocItems] = useState([]) const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [password, setPassword] = useState('') @@ -117,6 +117,12 @@ function FileSharePage() { window.open(exportFileSharePDF(shareCode), '_blank') } + const scrollContentToTop = () => { + 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('//'))) } @@ -174,6 +180,25 @@ function FileSharePage() { {headerLabel} + {contentInfo?.type === 'markdown' && ( + + + + + )} + {contentInfo?.type === 'pdf' &&
}
{loading ? (
@@ -184,7 +209,11 @@ function FileSharePage() { ) : (
{contentInfo?.type === 'pdf' ? ( - + ) : (
)} - {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' && ( + contentRef.current} + /> )} - - {!isMobile && contentInfo?.type === 'markdown' && tocCollapsed && ( - - )}
.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; @@ -245,12 +166,15 @@ background: var(--header-bg); display: flex; align-items: center; + justify-content: space-between; + gap: 16px; } .preview-header-title { display: flex; align-items: center; min-width: 0; + flex: 1; margin: 0; font-size: 16px; font-weight: 600; @@ -274,6 +198,20 @@ 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; @@ -494,10 +432,6 @@ width: 240px !important; } - .preview-toc-sider { - width: 200px !important; - } - .preview-content-wrapper { max-width: 100%; padding: 20px; diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx index c1b8e27..08ced1e 100644 --- a/frontend/src/pages/Preview/ProjectSharePage.jsx +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -1,7 +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 { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons' +import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Empty, Tooltip, Space } from 'antd' +import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeHighlight from 'rehype-highlight' @@ -11,7 +11,7 @@ import Mark from 'mark.js' import Highlighter from 'react-highlight-words' import GithubSlugger from 'github-slugger' import Toast from '@/components/Toast/Toast' -import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' +import FloatingToc from '@/components/FloatingToc/FloatingToc' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import { getProjectSharePublicInfo, @@ -49,7 +49,6 @@ function ProjectSharePage() { const [markdownContent, setMarkdownContent] = useState('') const [loading, setLoading] = useState(false) const [openKeys, setOpenKeys] = useState([]) - const [tocCollapsed, setTocCollapsed] = useState(false) const [tocItems, setTocItems] = useState([]) const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [password, setPassword] = useState('') @@ -64,6 +63,7 @@ function ProjectSharePage() { const [isSearching, setIsSearching] = useState(false) const contentRef = useRef(null) const viewerRef = useRef(null) + const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) const handleClose = () => { if (window.history.length > 1) { @@ -88,7 +88,7 @@ function ProjectSharePage() { instance.mark(searchKeyword, { element: 'span', className: 'search-highlight', - exclude: ['pre', 'code', '.toc-content'], + exclude: ['pre', 'code', '.floating-toc'], }) } } @@ -381,6 +381,12 @@ function ProjectSharePage() { window.open(exportProjectSharePDF(shareCode, selectedFile), '_blank') } + const scrollContentToTop = () => { + if (contentRef.current) { + contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + } + const menuItems = useMemo( () => convertTreeToMenuItems(filteredTreeData), [filteredTreeData, openKeys] @@ -510,6 +516,25 @@ function ProjectSharePage() { {headerLabel} + {viewMode === 'markdown' && ( + + + + + )} + {viewMode === 'pdf' &&
}
{loading ? ( @@ -519,7 +544,7 @@ function ProjectSharePage() {
) : viewMode === 'pdf' ? ( - + ) : (
{ if (e.defaultPrevented) return @@ -540,56 +565,17 @@ function ProjectSharePage() { )}
- {viewMode === 'markdown' && ( - - )} - {!isMobile && viewMode === 'markdown' && !tocCollapsed && ( - -
-

文档索引

-
-
- {tocItems.length > 0 ? ( - contentRef.current} - items={tocItems.map((item) => ({ - key: item.key, - href: item.href, - title: ( -
- - -
- ), - }))} - /> - ) : ( -
当前文档无标题
- )} -
-
+ {!isMobile && viewMode === 'markdown' && ( + contentRef.current} + renderTitle={(item, keyword) => } + /> )} - - {!isMobile && tocCollapsed && ( - - )} Date: Fri, 22 May 2026 10:31:21 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BA=86=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E9=A1=B5=E9=9D=A2=E7=9A=84=E5=B7=A5=E5=85=B7=E6=A0=8F?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/MainLayout/AppHeader.css | 47 ++++++++++++++++++- .../src/components/MainLayout/AppHeader.jsx | 9 +++- .../src/components/MainLayout/MainLayout.css | 2 +- .../ModernSidebar/ModernSidebar.css | 46 ++++++++---------- .../ModernSidebar/ModernSidebar.jsx | 17 ++++--- 5 files changed, 81 insertions(+), 40 deletions(-) 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/ModernSidebar/ModernSidebar.css b/frontend/src/components/ModernSidebar/ModernSidebar.css index 43e8d18..9b16b0d 100644 --- a/frontend/src/components/ModernSidebar/ModernSidebar.css +++ b/frontend/src/components/ModernSidebar/ModernSidebar.css @@ -30,39 +30,31 @@ white-space: nowrap; } -/* Collapse Trigger */ -.collapse-trigger { +.sidebar-collapse-trigger { position: absolute; - right: -14px; - top: 28px; - width: 28px; - height: 28px; - background: var(--bg-color); + right: -13px; + top: 32px; + z-index: 20; + width: 26px; + height: 26px; border: 1px solid var(--border-color); - border-radius: 50%; - display: flex; + border-radius: 999px; + background: var(--header-bg); + color: #8a94a6; + display: inline-flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(0, 21, 41, 0.08); cursor: pointer; - z-index: 10; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); - color: var(--text-color-secondary); - font-size: 12px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - opacity: 0; + transform: translateY(-50%); + transition: all 0.2s; + font-size: 10px; } -.modern-sidebar:hover .collapse-trigger, -.collapse-trigger:focus { - opacity: 1; -} - -.collapse-trigger:hover { - color: #fff; - background: #1677ff; - border-color: #1677ff; - box-shadow: 0 4px 12px rgba(22, 119, 255, 0.35); - transform: scale(1.1); +.sidebar-collapse-trigger:hover { + color: #1677ff; + border-color: rgba(22, 119, 255, 0.28); + background: var(--header-bg); } /* Menu Area */ @@ -228,4 +220,4 @@ .logout-btn:hover { background-color: var(--border-color); color: #ef4444; /* Red for logout */ -} \ No newline at end of file +} diff --git a/frontend/src/components/ModernSidebar/ModernSidebar.jsx b/frontend/src/components/ModernSidebar/ModernSidebar.jsx index d4ce841..8f0a9a1 100644 --- a/frontend/src/components/ModernSidebar/ModernSidebar.jsx +++ b/frontend/src/components/ModernSidebar/ModernSidebar.jsx @@ -1,12 +1,10 @@ -import React, { useState } from 'react'; -import { Layout, Avatar, Tooltip, Button } from 'antd'; +import React from 'react'; +import { Layout, Avatar, Tooltip } from 'antd'; import { - MenuUnfoldOutlined, - MenuFoldOutlined, LogoutOutlined, QuestionCircleOutlined, RightOutlined, - LeftOutlined + LeftOutlined, } from '@ant-design/icons'; import './ModernSidebar.css'; @@ -81,13 +79,14 @@ const ModernSidebar = ({
{logo}
- {/* 折叠按钮 - 悬浮在边缘 */} -
onCollapse && onCollapse(!collapsed)} + aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'} > {collapsed ? : } -
+
{/* 菜单列表区域 */} From fd10178367f75c0abd73df759b9fa991f4c7afc2 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 27 May 2026 19:07:23 +0800 Subject: [PATCH 09/13] v0.9.7 --- .../components/FloatingToc/FloatingToc.css | 22 ++++ .../components/FloatingToc/FloatingToc.jsx | 96 ++++++++++---- .../components/PDFViewer/VirtualPDFViewer.css | 14 ++ .../components/PDFViewer/VirtualPDFViewer.jsx | 30 ++++- .../src/pages/Document/DocumentEditor.css | 3 +- frontend/src/pages/Preview/FileSharePage.jsx | 79 +++++++++--- frontend/src/pages/Preview/PreviewPage.css | 61 ++++++--- .../src/pages/Preview/ProjectSharePage.jsx | 121 +++++++++++++----- 8 files changed, 325 insertions(+), 101 deletions(-) diff --git a/frontend/src/components/FloatingToc/FloatingToc.css b/frontend/src/components/FloatingToc/FloatingToc.css index 188573f..05b4625 100644 --- a/frontend/src/components/FloatingToc/FloatingToc.css +++ b/frontend/src/components/FloatingToc/FloatingToc.css @@ -74,6 +74,18 @@ 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; @@ -167,6 +179,16 @@ 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); diff --git a/frontend/src/components/FloatingToc/FloatingToc.jsx b/frontend/src/components/FloatingToc/FloatingToc.jsx index ea56046..58dd1b5 100644 --- a/frontend/src/components/FloatingToc/FloatingToc.jsx +++ b/frontend/src/components/FloatingToc/FloatingToc.jsx @@ -1,15 +1,10 @@ -import { Anchor } from 'antd' +import { useState } from 'react' +import { Anchor, Drawer } from 'antd' import { FileTextOutlined, MenuOutlined } from '@ant-design/icons' import './FloatingToc.css' -export default function FloatingToc({ - items = [], - getContainer, - searchKeyword = '', - renderTitle, - className = '', -}) { - const anchorItems = items.map((item) => ({ +function buildAnchorItems(items, searchKeyword, renderTitle) { + return items.map((item) => ({ key: item.key, href: item.href, title: ( @@ -21,12 +16,74 @@ export default function FloatingToc({
), })) +} + +function TocContent({ items = [], getContainer, searchKeyword = '', renderTitle, onItemClick }) { + const anchorItems = buildAnchorItems(items, searchKeyword, renderTitle) + + return ( +
+ {items.length > 0 ? ( + { + window.setTimeout(() => onItemClick?.(link), 120) + }} + /> + ) : ( +
当前文档无标题
+ )} +
+ ) +} + +export function TocDrawer({ + open, + onClose, + items = [], + getContainer, + searchKeyword = '', + renderTitle, +}) { + return ( + + + + ) +} + +export default function FloatingToc({ + items = [], + getContainer, + searchKeyword = '', + renderTitle, + className = '', +}) { + const [dismissed, setDismissed] = useState(false) return (
) diff --git a/frontend/src/components/PDFViewer/VirtualPDFViewer.css b/frontend/src/components/PDFViewer/VirtualPDFViewer.css index 7230ccd..61fec14 100644 --- a/frontend/src/components/PDFViewer/VirtualPDFViewer.css +++ b/frontend/src/components/PDFViewer/VirtualPDFViewer.css @@ -30,6 +30,20 @@ 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; diff --git a/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx b/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx index 60b1558..9330494 100644 --- a/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx +++ b/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx @@ -1,7 +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, @@ -17,7 +17,7 @@ import './VirtualPDFViewer.css' // 配置 PDF.js worker pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs' -function VirtualPDFViewer({ url, filename, toolbarTarget }) { +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 @@ -160,7 +160,31 @@ function VirtualPDFViewer({ url, filename, toolbarTarget }) { document.body.removeChild(link) } - const toolbar = ( + const toolbar = compactToolbar ? ( +
+ + +
+ ) : (
- - + isMobile ? ( + + + + + + ) )} {contentInfo?.type === 'pdf' &&
}
@@ -213,6 +246,7 @@ function FileSharePage() { url={contentInfo.document_url} filename={contentInfo.filename} toolbarTarget={pdfToolbarTarget} + compactToolbar={isMobile} /> ) : (
@@ -239,6 +273,13 @@ function FileSharePage() {
+ setTocDrawerVisible(false)} + items={tocItems} + getContainer={() => contentRef.current} + /> + 访问验证
} open={passwordModalVisible} diff --git a/frontend/src/pages/Preview/PreviewPage.css b/frontend/src/pages/Preview/PreviewPage.css index 165b717..4c37db0 100644 --- a/frontend/src/pages/Preview/PreviewPage.css +++ b/frontend/src/pages/Preview/PreviewPage.css @@ -170,6 +170,29 @@ 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; @@ -365,31 +388,23 @@ 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); -} - -.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-header { + padding: 12px 12px; + gap: 8px; + } + + .file-share-content-header { + gap: 8px; + } + .preview-content-wrapper { padding: 16px; - padding-top: 60px; /* 为移动端菜单按钮留出空间 */ + } + + .preview-content-wrapper.pdf-mode { + padding: 0; } .markdown-body { @@ -444,6 +459,10 @@ padding: 12px; } + .preview-content-wrapper.pdf-mode { + padding: 0; + } + .markdown-body { font-size: 14px; } diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx index 08ced1e..8f9d0d4 100644 --- a/frontend/src/pages/Preview/ProjectSharePage.jsx +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -1,7 +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, Empty, Tooltip, Space } from 'antd' -import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons' +import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, UnorderedListOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeHighlight from 'rehype-highlight' @@ -11,7 +11,7 @@ import Mark from 'mark.js' import Highlighter from 'react-highlight-words' import GithubSlugger from 'github-slugger' import Toast from '@/components/Toast/Toast' -import FloatingToc from '@/components/FloatingToc/FloatingToc' +import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import { getProjectSharePublicInfo, @@ -54,6 +54,7 @@ function ProjectSharePage() { 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('') @@ -418,20 +419,6 @@ function ProjectSharePage() { {isMobile ? ( <> - openSharedFile(key)} + onClick={({ key }) => { + openSharedFile(key) + setMobileDrawerVisible(false) + }} className="preview-menu" /> ) : ( @@ -512,27 +502,81 @@ function ProjectSharePage() {
+ {isMobile && ( +
+ +
+ )}

{headerLabel}

{viewMode === 'markdown' && ( - - - - + isMobile ? ( + + + + + + ) )} {viewMode === 'pdf' &&
}
@@ -544,7 +588,7 @@ function ProjectSharePage() {
) : viewMode === 'pdf' ? ( - + ) : (
{ if (e.defaultPrevented) return @@ -578,6 +622,15 @@ function ProjectSharePage() { + setTocDrawerVisible(false)} + items={tocItems} + searchKeyword={searchKeyword} + getContainer={() => contentRef.current} + renderTitle={(item, keyword) => } + /> + 访问验证
} open={passwordModalVisible} From c0a4798fc6fc9d2ee75fb12a2389bee22944f56f Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 3 Jun 2026 15:04:49 +0800 Subject: [PATCH 10/13] v0.9.7 --- .memsearch/memory/2026-06-03.md | 6 ++++++ frontend/src/pages/Preview/FileSharePage.jsx | 6 +++--- frontend/src/pages/Preview/ProjectSharePage.jsx | 12 ++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 .memsearch/memory/2026-06-03.md diff --git a/.memsearch/memory/2026-06-03.md b/.memsearch/memory/2026-06-03.md new file mode 100644 index 0000000..307b486 --- /dev/null +++ b/.memsearch/memory/2026-06-03.md @@ -0,0 +1,6 @@ + +## Session 14:55 + + +## Session 14:59 + diff --git a/frontend/src/pages/Preview/FileSharePage.jsx b/frontend/src/pages/Preview/FileSharePage.jsx index 55f4d37..8428955 100644 --- a/frontend/src/pages/Preview/FileSharePage.jsx +++ b/frontend/src/pages/Preview/FileSharePage.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Layout, Modal, Input, Spin, Button, Space, Tooltip } from 'antd' -import { CloseOutlined, LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, MenuOutlined } from '@ant-design/icons' +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' @@ -173,9 +173,9 @@ function FileSharePage() { type="button" className="project-back-button" onClick={handleClose} - aria-label="关闭文档分享" + aria-label="返回" > - +

diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx index 8f9d0d4..fef9f7f 100644 --- a/frontend/src/pages/Preview/ProjectSharePage.jsx +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -1,7 +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, Empty, Tooltip, Space } from 'antd' -import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, UnorderedListOutlined } from '@ant-design/icons' +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' @@ -463,9 +463,9 @@ function ProjectSharePage() { type="button" className="project-back-button" onClick={handleClose} - aria-label="关闭项目分享" + aria-label="返回" > - +

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

@@ -504,13 +504,13 @@ function ProjectSharePage() {
{isMobile && (
- +
{hasPassword && ( -
+ setPassword(e.target.value)} /> - -
+ )} ) : ( <>
- 当前文件尚未创建独立分享。文件分享不受项目是否公开影响,分享页只包含该文件本身。 + 当前文件尚未创建独立分享。
-
- - 访问密码保护 - - -
- - {hasPassword && ( - setPassword(e.target.value)} - /> - )} - diff --git a/frontend/src/pages/ProjectList/ProjectList.jsx b/frontend/src/pages/ProjectList/ProjectList.jsx index 0cfba40..9647641 100644 --- a/frontend/src/pages/ProjectList/ProjectList.jsx +++ b/frontend/src/pages/ProjectList/ProjectList.jsx @@ -146,42 +146,58 @@ function ProjectList({ type = 'my' }) { editForm.setFieldsValue({ name: project.name, description: project.description, - is_public: project.is_public === 1, }) - setShareInfo(project.is_public === 1 ? null : { enabled: false, share_url: null, has_password: false, access_pass: null }) + setShareInfo(null) setHasPassword(false) setPassword('') setEditModalVisible(true) } - // 更新项目 - const handleUpdateProject = async (values) => { + // 切换公开/私有(立即生效) + const handlePublicToggle = async (checked) => { + if (!currentProject) return try { - const shouldKeepOpenForShare = currentProject?.is_public !== 1 && values.is_public const res = await updateProject(currentProject.id, { - ...values, - is_public: values.is_public ? 1 : 0, + is_public: checked ? 1 : 0, }) const updatedProject = res.data || { ...currentProject, - ...values, - is_public: values.is_public ? 1 : 0, + is_public: checked ? 1 : 0, } setCurrentProject(updatedProject) - message.success('项目更新成功') + message.success(checked ? '项目已设为公开' : '项目已设为私有') await fetchProjects() - if (shouldKeepOpenForShare) { + if (checked) { const shareRes = await getProjectShareInfo(currentProject.id) setShareInfo(shareRes.data) setHasPassword(shareRes.data.has_password) setPassword(shareRes.data.access_pass || '') - return + } 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('操作失败') + } + } - setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null }) - setHasPassword(false) - setPassword('') + // 更新项目(仅名称和描述) + const handleUpdateProject = async (values) => { + try { + 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() } catch (error) { @@ -193,9 +209,10 @@ function ProjectList({ type = 'my' }) { useEffect(() => { if (!editModalVisible || !currentProject) return - const isPublic = editForm.getFieldValue('is_public') - if (!isPublic) { + if (currentProject.is_public !== 1) { setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null }) + setHasPassword(false) + setPassword('') return } @@ -209,39 +226,7 @@ function ProjectList({ type = 'my' }) { 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]) + }, [editModalVisible, currentProject]) const [gitRepos, setGitRepos] = useState([]) const [loadingRepos, setLoadingRepos] = useState(false) @@ -765,25 +750,20 @@ function ProjectList({ type = 'my' }) { /> - - + + - {!editForm.getFieldValue('is_public') ? ( + {currentProject?.is_public !== 1 ? (
- 开启“公开项目”后,才可以生成项目分享链接和配置访问密码。 + 开启“公开项目”后,可以生成项目分享链接和访问密码。
- ) : isPublicEnablePending ? ( -
- 保存项目后将自动生成项目分享链接。 -
- ) : ( + ) : shareInfo ? ( <>
{hasPassword && ( -
+ setPassword(e.target.value)} /> - -
+ )} - )} + ) : null}
From 2bb7db0d562e700331a1c2412e47972ff06e597b Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Tue, 16 Jun 2026 21:09:15 +0800 Subject: [PATCH 12/13] =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BA=86=E5=AF=B9?= =?UTF-8?q?=E8=B6=85=E5=A4=A7md=E6=96=87=E6=A1=A3=E7=9A=84=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .memsearch/memory/2026-06-03.md | 15 + .../LargeMarkdownViewer.css | 54 ++++ .../LargeMarkdownViewer.jsx | 107 +++++++ .../MarkdownViewer/MarkdownViewer.jsx | 48 +++ .../src/pages/Document/DocumentEditor.css | 41 +++ .../src/pages/Document/DocumentEditor.jsx | 285 +++++++++++------- frontend/src/pages/Document/DocumentPage.css | 17 ++ frontend/src/pages/Document/DocumentPage.jsx | 193 ++++++++---- frontend/src/pages/Preview/FileSharePage.jsx | 46 ++- frontend/src/pages/Preview/PreviewPage.css | 15 + .../src/pages/Preview/ProjectSharePage.jsx | 57 +++- 12 files changed, 677 insertions(+), 202 deletions(-) create mode 100644 frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css create mode 100644 frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx create mode 100644 frontend/src/components/MarkdownViewer/MarkdownViewer.jsx diff --git a/.gitignore b/.gitignore index d5c161b..498ebe8 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 index c806275..59098be 100644 --- a/.memsearch/memory/2026-06-03.md +++ b/.memsearch/memory/2026-06-03.md @@ -7,3 +7,18 @@ ## Session 16:54 + +## Session 17:15 + + +## Session 17:16 + + +## Session 17:20 + + +## Session 17:54 + + +## Session 17:54 + diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css new file mode 100644 index 0000000..5a8befc --- /dev/null +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css @@ -0,0 +1,54 @@ +.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; +} + +@media (max-width: 768px) { + .large-markdown-notice, + .markdown-block { + width: calc(100% - 32px); + } +} + +@media (max-width: 480px) { + .large-markdown-notice, + .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..7d08a3a --- /dev/null +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx @@ -0,0 +1,107 @@ +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 './LargeMarkdownViewer.css' + +export const LARGE_MARKDOWN_THRESHOLD = 250000 +export const LARGE_MARKDOWN_NOTICE = '此文档内容过长,将采用精简模式显示' + +export function isLargeMarkdownContent(content = '') { + return content.length > LARGE_MARKDOWN_THRESHOLD +} + +export function MarkdownSizeNotice({ className = '' }) { + return ( +
+ +
+ ) +} + +function splitMarkdownIntoBlocks(content) { + if (!content) return [] + + const blocks = [] + 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 isHeading = /^(#{1,6})\s+/.test(line) + + if (!inFence && isHeading) { + flush() + } + + current.push(line) + + if (isFenceLine) { + inFence = !inFence + } + + if (!inFence && line.trim() === '') { + flush() + } + } + + flush() + return blocks +} + +const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer({ content, components, onClick }, ref) { + const virtuosoRef = useRef(null) + const markdownBlocks = useMemo(() => splitMarkdownIntoBlocks(content), [content]) + + useImperativeHandle(ref, () => ({ + scrollToTop: () => { + virtuosoRef.current?.scrollToIndex({ + index: 0, + align: 'start', + behavior: 'smooth', + }) + }, + }), []) + + return ( +
+ +
+ ( +
+ + {block} + +
+ )} + /> +
+
+ ) +}) + +export default LargeMarkdownViewer 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/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index 6ebef86..e9ae3a1 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); */ @@ -232,6 +234,15 @@ padding: 5px 10px; } +.bytemd-wrapper.large-file-editor { + position: relative; + padding: 0; +} + +.large-file-editor-notice { + top: 12px; +} + /* Fix for bytemd-react wrapper div */ .bytemd-wrapper>div { flex: 1; @@ -242,6 +253,36 @@ min-width: 0; } +.bytemd-wrapper.large-file-editor>.large-markdown-notice { + flex: none !important; + display: block !important; + width: min(920px, calc(100% - 48px)) !important; + height: auto !important; + min-height: 0 !important; +} + +.large-markdown-textarea { + flex: 1; + width: 100%; + height: 100%; + min-height: 0; + resize: none; + border: 1px solid var(--border-color); + border-radius: 2px; + padding: 64px 16px 16px; + background: var(--bg-color); + color: var(--text-color); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 14px; + line-height: 1.8; + outline: none; +} + +.large-markdown-textarea:focus { + border-color: #1677ff; + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15); +} + .empty-editor { height: 100%; display: flex; diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index 87a8f9d..7b3348f 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -5,7 +5,6 @@ import { FileOutlined, FolderOutlined, FolderOpenOutlined, - PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined, @@ -14,7 +13,6 @@ import { UploadOutlined, DownloadOutlined, SwapOutlined, - FileImageOutlined, FilePdfOutlined, FileTextOutlined, UndoOutlined, @@ -35,11 +33,12 @@ import { operateFile, uploadFile, importDocuments, - exportDirectory, uploadDocument, + getDocumentUrl, } from '@/api/file' import Toast from '@/components/Toast/Toast' import ModeSwitch from '@/components/ModeSwitch/ModeSwitch' +import { MarkdownSizeNotice, isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer' import './DocumentEditor.css' const { Sider, Content } = Layout @@ -63,7 +62,6 @@ 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) // 是否正在上传 @@ -78,6 +76,7 @@ function DocumentEditor() { 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 @@ -109,16 +108,139 @@ function DocumentEditor() { navigate(to) } - const updateFileParam = (filePath) => { + const updateSelectedParam = (path, isFile = true) => { const nextParams = new URLSearchParams(searchParams) - if (filePath) { - nextParams.set('file', filePath) - } else { - nextParams.delete('file') + 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 @@ -159,22 +281,6 @@ function DocumentEditor() { setLinkTarget(null) } - // 在组件挂载后立即计算正确的高度 - useEffect(() => { - const calculateHeight = () => { - const windowHeight = window.innerHeight - const newHeight = Math.max(windowHeight - 180, 400) // 最小高度400px - setEditorHeight(newHeight) - } - - // 立即执行一次 - calculateHeight() - - // 监听窗口大小变化 - window.addEventListener('resize', calculateHeight) - return () => window.removeEventListener('resize', calculateHeight) - }, []) - useEffect(() => { fetchTree() }, [projectId]) @@ -210,32 +316,6 @@ function DocumentEditor() { }) } - const handleSelectFile = async (selectedKeys, info) => { - // 记录选中的节点(无论是文件还是目录) - setSelectedNode(info.node) - - if (info.node.isLeaf) { - const filePath = selectedKeys[0] - - // 检查是否是PDF文件 - if (filePath.toLowerCase().endsWith('.pdf')) { - Toast.info('提示', 'PDF文件请在浏览模式下查看') - return - } - - setLoading(true) - try { - const res = await getFileContent(projectId, filePath) - setSelectedFile(filePath) - setFileContent(res.data.content) - } catch (error) { - // 错误已通过request interceptor处理 - } finally { - setLoading(false) - } - } - } - // 查找树节点的辅助函数 const findNodeByKey = (nodes, key) => { for (const node of nodes) { @@ -271,8 +351,11 @@ function DocumentEditor() { setSelectedMenuKey(key) if (!targetNode.isLeaf) { + setSelectedFile(null) + setIsPdfSelected(false) + setFileContent('') if (syncUrl) { - updateFileParam(null) + updateSelectedParam(key, false) } return } @@ -306,6 +389,16 @@ function DocumentEditor() { 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 @@ -651,42 +744,6 @@ function DocumentEditor() { } } - // 导出目录 - const handleExportDirectory = async () => { - // 如果选中了目录,导出该目录;否则导出整个项目 - const directoryPath = selectedNode && !selectedNode.isLeaf ? selectedNode.key : '' - - try { - const response = await exportDirectory(projectId, directoryPath) - - // 从响应头中提取文件名 - const contentDisposition = response.headers['content-disposition'] - let filename = `${directoryPath || 'root'}.zip` - if (contentDisposition) { - const matches = /filename=(.+)/.exec(contentDisposition) - if (matches && matches[1]) { - filename = matches[1] - } - } - - // 创建blob URL并触发下载 - const url = window.URL.createObjectURL(response.data) - const link = document.createElement('a') - link.href = url - link.download = filename - link.style.display = 'none' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - window.URL.revokeObjectURL(url) - - Toast.success('成功', '导出成功') - } catch (error) { - console.error('Export error:', error) - Toast.error('错误', '导出失败') - } - } - // 移动文件/目录 const handleMove = (path) => { setRightClickNode(path) @@ -972,8 +1029,7 @@ function DocumentEditor() { 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')) { @@ -1033,12 +1089,7 @@ function DocumentEditor() { 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) } }} @@ -1073,11 +1124,11 @@ function DocumentEditor() { /> - +
) : selectedFile ? (
e.preventDefault()} > - setFileContent(v)} - plugins={plugins} - locale={{ - en: { - 'Write': '编辑', - 'Preview': '预览', - }, - }} - /> + {isLargeMarkdown ? ( + <> + +