Merge remote main with local model config features

Merge origin/main which includes:
- New file sharing system (shares module)
- Large markdown viewer improvements
- Preview page refactoring
- FloatingToc component

Preserved from local:
- LLM model configuration support (llm_model_configs)

This merge integrates remote improvements while keeping the local
model configuration functionality for the next phase.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
main
mula.liu 2026-06-23 21:52:09 +08:00
commit 0b0713fc4e
41 changed files with 5298 additions and 1624 deletions

1
.gitignore vendored
View File

@ -26,6 +26,7 @@ logs/
.env
.env.local
.gemini-clipboard
.memsearch
# Temporary files
*.tmp

View File

@ -0,0 +1,24 @@
## Session 14:55
## Session 14:59
## Session 16:54
## Session 17:15
## Session 17:16
## Session 17:20
## Session 17:54
## Session 17:54

View File

@ -2,7 +2,7 @@
API v1 路由汇总
"""
from fastapi import APIRouter
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, llm_model_configs
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, shares, llm_model_configs
api_router = APIRouter()
@ -15,6 +15,7 @@ api_router.include_router(notifications.router, prefix="/notifications", tags=["
api_router.include_router(menu.router, prefix="/menu", tags=["权限菜单"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"])
api_router.include_router(preview.router, prefix="/preview", tags=["项目预览"])
api_router.include_router(shares.router, prefix="/shares", tags=["分享"])
api_router.include_router(role_permissions.router, prefix="/role-permissions", tags=["角色权限管理"])
api_router.include_router(users.router, prefix="/users", tags=["用户管理"])
api_router.include_router(roles.router, prefix="/roles", tags=["角色管理"])

View File

@ -17,6 +17,7 @@ from app.core.database import get_db
from app.core.deps import get_current_user, get_user_from_token_or_query
from app.models.user import User
from app.models.log import OperationLog
from app.models.share import ShareLink
from app.schemas.file import (
FileTreeNode,
FileSaveRequest,
@ -37,6 +38,51 @@ from app.core.enums import OperationType
router = APIRouter()
async def check_project_access(
project_id: int,
current_user: User,
db: AsyncSession,
require_write: bool = False
):
"""检查项目访问权限"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查是否是项目所有者
if project.owner_id == current_user.id:
return project
# 检查是否是项目成员
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member:
if project.is_public == 1 and not require_write:
return project
raise HTTPException(status_code=403, detail="无权访问该项目")
# 如果需要写权限,检查成员角色
if require_write and member.role == "viewer":
raise HTTPException(status_code=403, detail="无写入权限")
return project
def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None:
"""为文件树节点补充分享状态"""
for node in tree:
node.is_shared = bool(node.isLeaf and node.key in shared_paths)
if node.children:
annotate_shared_files(node.children, shared_paths)
@router.get("/{project_id}/tree", response_model=dict)
async def get_project_tree(
project_id: int,
@ -52,6 +98,37 @@ async def get_project_tree(
# 生成目录树
tree = storage_service.generate_tree(project_root)
<<<<<<< HEAD
=======
share_result = await db.execute(
select(ShareLink.file_path).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.status == 1,
)
)
shared_paths = {
file_path
for file_path in share_result.scalars().all()
if file_path
}
annotate_shared_files(tree, shared_paths)
# 获取当前用户角色
user_role = "owner" # 默认是所有者
if project.owner_id != current_user.id:
# 查询成员角色
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if member:
user_role = member.role
>>>>>>> origin/main
return success_response(data={
"tree": tree,
"user_role": user_role,

View File

@ -1,21 +1,19 @@
"""
项目管理相关 API
"""
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import delete, select, or_
from typing import List
import uuid
import secrets
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.models.git_repo import ProjectGitRepo
from app.models.share import ShareLink
from app.schemas.project import (
ProjectCreate,
ProjectUpdate,
@ -23,8 +21,6 @@ from app.schemas.project import (
ProjectMemberAdd,
ProjectMemberUpdate,
ProjectMemberResponse,
ProjectShareSettings,
ProjectShareInfo,
ProjectTransfer,
)
from app.schemas.response import success_response
@ -32,19 +28,32 @@ from app.services.storage import storage_service
from app.services.log_service import log_service
from app.services.git_service import git_service
from app.services.notification_service import notification_service
from app.services.project_export_service import project_export_service
from app.services.project_service import (
get_project_member,
get_project_or_404,
require_project_read_access,
require_project_roles,
serialize_project,
)
from app.core.enums import OperationType, ResourceType
router = APIRouter()
def generate_share_code() -> str:
"""生成公开分享码"""
return secrets.token_urlsafe(12).replace("-", "").replace("_", "")
def get_document_count(storage_key: str) -> int:
"""计算项目中的文档数量(.md 和 .pdf"""
try:
project_path = storage_service.get_secure_path(storage_key)
if not project_path.exists():
return 0
md_count = len(list(project_path.rglob("*.md")))
pdf_count = len(list(project_path.rglob("*.pdf")))
# 排除 _assets 目录下的文件
assets_md = len(list((project_path / "_assets").rglob("*.md"))) if (project_path / "_assets").exists() else 0
assets_pdf = len(list((project_path / "_assets").rglob("*.pdf"))) if (project_path / "_assets").exists() else 0
return md_count + pdf_count - assets_md - assets_pdf
except Exception:
return 0
@router.get("/", response_model=dict)
async def get_my_projects(
current_user: User = Depends(get_current_user),
@ -71,7 +80,11 @@ async def get_my_projects(
# 合并结果
all_projects = owned_projects + member_projects
projects_data = [serialize_project(project) for project in all_projects]
projects_data = []
for p in all_projects:
p_dict = ProjectResponse.from_orm(p).dict()
p_dict['doc_count'] = get_document_count(p.storage_key)
projects_data.append(p_dict)
return success_response(data=projects_data)
@ -86,7 +99,11 @@ async def get_owned_projects(
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
)
projects = result.scalars().all()
projects_data = [serialize_project(project) for project in projects]
projects_data = []
for p in projects:
p_dict = ProjectResponse.from_orm(p).dict()
p_dict['doc_count'] = get_document_count(p.storage_key)
projects_data.append(p_dict)
return success_response(data=projects_data)
@ -110,14 +127,12 @@ async def get_shared_projects(
projects_data = []
for project, owner, member in projects_with_info:
projects_data.append(
serialize_project(
project,
owner_name=owner.username,
owner_nickname=owner.nickname,
user_role=member.role,
)
)
project_dict = ProjectResponse.from_orm(project).dict()
project_dict['owner_name'] = owner.username
project_dict['owner_nickname'] = owner.nickname
project_dict['user_role'] = member.role # 添加用户角色
project_dict['doc_count'] = get_document_count(project.storage_key)
projects_data.append(project_dict)
return success_response(data=projects_data)
@ -185,12 +200,24 @@ async def get_project(
db: AsyncSession = Depends(get_db)
):
"""获取项目详情"""
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
)
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查权限(项目所有者或成员可访问)
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member and project.is_public != 1:
raise HTTPException(status_code=403, detail="无权访问该项目")
# 增加访问次数 (简单计数)
project.visit_count += 1
@ -209,17 +236,59 @@ async def update_project(
db: AsyncSession = Depends(get_db)
):
"""更新项目信息"""
project = await get_project_or_404(db, project_id)
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 只有项目所有者可以更新
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="无权修改该项目")
old_is_public = project.is_public
# 更新字段
update_data = project_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
if "is_public" in update_data:
next_is_public = int(update_data.get("is_public") or 0)
if next_is_public == 0:
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
)
)
elif next_is_public == 1:
if old_is_public != 1:
# 重新公开项目时丢弃历史公开链接,生成新的项目分享链接。
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
)
)
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
ShareLink.status == 1,
)
)
share = share_result.scalar_one_or_none()
if not share:
db.add(ShareLink(
project_id=project_id,
share_type="project",
share_code=generate_share_code(),
created_by=current_user.id,
))
await db.commit()
await db.refresh(project)
@ -246,7 +315,12 @@ async def transfer_project(
db: AsyncSession = Depends(get_db)
):
"""转移项目所有权"""
project = await get_project_or_404(db, project_id)
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 只有项目所有者可以转移
if project.owner_id != current_user.id:
@ -263,13 +337,25 @@ async def transfer_project(
raise HTTPException(status_code=400, detail="不能转移给自己")
# 1. 如果新所有者已经是成员,删除成员记录
existing_member = await get_project_member(db, project_id, new_owner.id)
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == new_owner.id
)
)
existing_member = member_result.scalar_one_or_none()
if existing_member:
await db.delete(existing_member)
# 2. 将旧所有者添加为管理员成员
# 检查旧所有者是否已经在member表中理论上owner不在member表中但为了健壮性检查一下
if not await get_project_member(db, project_id, current_user.id):
old_member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
if not old_member_result.scalar_one_or_none():
old_owner_member = ProjectMember(
project_id=project_id,
user_id=current_user.id,
@ -307,69 +393,6 @@ async def transfer_project(
return success_response(message="项目所有权转移成功")
@router.post("/{project_id}/export", response_model=dict)
async def start_project_export(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""启动项目整体导出任务。"""
project, _ = await require_project_read_access(db, project_id, current_user)
project_root = storage_service.get_secure_path(project.storage_key)
return success_response(
data=await project_export_service.start_export(
project_id,
current_user.id,
project.name,
project_root,
),
message="项目导出任务已开始",
)
@router.get("/{project_id}/export/{task_id}", response_model=dict)
async def get_project_export_status(
project_id: int,
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取项目导出任务状态。"""
await require_project_read_access(db, project_id, current_user)
task = project_export_service.get_owned_task_or_404(project_id, task_id, current_user.id)
return success_response(data=project_export_service.serialize_task(task))
@router.get("/{project_id}/export/{task_id}/download")
async def download_project_export(
project_id: int,
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""下载已完成的项目导出 ZIP。"""
await require_project_read_access(db, project_id, current_user)
task = project_export_service.get_owned_task_or_404(project_id, task_id, current_user.id)
if task["status"] == "failed":
raise HTTPException(status_code=400, detail=task.get("error") or "项目导出失败")
if task["status"] != "completed":
raise HTTPException(status_code=409, detail="导出尚未完成,请稍后再试")
file_path = task.get("file_path")
if not file_path or not Path(file_path).exists():
raise HTTPException(status_code=404, detail="导出文件不存在或已过期")
encoded_filename = quote(task["zip_filename"])
return FileResponse(
path=file_path,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
background=BackgroundTask(project_export_service.cleanup_task, task_id),
)
@router.delete("/{project_id}", response_model=dict)
async def delete_project(
project_id: int,
@ -378,7 +401,12 @@ async def delete_project(
db: AsyncSession = Depends(get_db)
):
"""删除项目"""
project = await get_project_or_404(db, project_id)
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 只有项目所有者可以删除
if project.owner_id != current_user.id:
@ -439,7 +467,24 @@ async def get_project_members(
db: AsyncSession = Depends(get_db)
):
"""获取项目成员列表"""
await require_project_read_access(db, project_id, current_user)
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查权限
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权访问该项目")
# 查询成员列表并关联用户信息
members_result = await db.execute(
@ -474,13 +519,25 @@ async def add_project_member(
db: AsyncSession = Depends(get_db)
):
"""添加项目成员"""
project, _ = await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin"],
forbidden_detail="无权添加成员",
)
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 只有项目所有者和管理员可以添加成员
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
ProjectMember.role == "admin"
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权添加成员")
# 检查用户是否已是成员
existing_result = await db.execute(
@ -540,13 +597,25 @@ async def remove_project_member(
db: AsyncSession = Depends(get_db)
):
"""删除项目成员"""
project, _ = await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin"],
forbidden_detail="无权删除成员",
)
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 只有项目所有者和管理员可以删除成员
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
ProjectMember.role == "admin"
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权删除成员")
# 不能删除项目所有者
if user_id == project.owner_id:
@ -581,66 +650,6 @@ async def remove_project_member(
return success_response(message="成员删除成功")
@router.get("/{project_id}/share", response_model=dict)
async def get_project_share_info(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取项目分享信息"""
project, role = await require_project_read_access(db, project_id, current_user)
is_owner = role == "owner"
# 构建分享链接
share_url = f"/preview/{project_id}"
# 只有项目所有者可以看到实际密码,成员只能知道是否设置了密码
share_info = ProjectShareInfo(
share_url=share_url,
has_password=bool(project.access_pass),
access_pass=project.access_pass if is_owner else None
)
return success_response(data=share_info.dict())
@router.post("/{project_id}/share/settings", response_model=dict)
async def update_share_settings(
project_id: int,
settings: ProjectShareSettings,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""更新分享设置(设置或取消访问密码)"""
project = await get_project_or_404(db, project_id)
# 只有项目所有者可以修改分享设置
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置")
# 更新访问密码
project.access_pass = settings.access_pass
await db.commit()
# 记录操作日志
await log_service.log_operation(
db=db,
operation_type=OperationType.UPDATE_SHARE_SETTINGS,
resource_type=ResourceType.SHARE,
user=current_user,
resource_id=project_id,
detail={
"has_password": bool(settings.access_pass),
"project_name": project.name,
},
request=request,
)
message = "访问密码已取消" if not settings.access_pass else "访问密码已设置"
return success_response(message=message)
@router.post("/{project_id}/git/pull", response_model=dict)
async def git_pull(
project_id: int,
@ -651,13 +660,24 @@ async def git_pull(
db: AsyncSession = Depends(get_db)
):
"""执行 Git Pull"""
project, _ = await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin", "editor"],
forbidden_detail="无权执行Git操作",
)
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 权限检查:需要是所有者或管理员/编辑者
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
ProjectMember.role.in_(['admin', 'editor'])
)
)
if not member_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="无权执行Git操作")
# 获取Git仓库配置
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
@ -724,13 +744,24 @@ async def git_push(
db: AsyncSession = Depends(get_db)
):
"""执行 Git Push"""
project, _ = await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin", "editor"],
forbidden_detail="无权执行Git操作",
)
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 权限检查:需要是所有者或管理员/编辑者
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
ProjectMember.role.in_(['admin', 'editor'])
)
)
if not member_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="无权执行Git操作")
# 获取Git仓库配置
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)

View File

@ -0,0 +1,668 @@
"""
分享相关 API
"""
from pathlib import Path
from typing import Optional
import mimetypes
import secrets
import re
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import get_current_user
from app.core.enums import OperationType, ResourceType
from app.core.security import create_access_token, decode_access_token
from app.models.project import Project, ProjectMember
from app.models.share import ShareLink
from app.models.user import User
from app.schemas.project import FileShareCreate
from app.schemas.response import success_response
from app.services.log_service import log_service
from app.services.pdf_service import pdf_service
from app.services.search_service import search_service
from app.services.storage import storage_service
router = APIRouter()
def generate_share_code() -> str:
return secrets.token_urlsafe(12).replace("-", "").replace("_", "")
def get_share_cookie_name(share_code: str) -> str:
return f"nd_share_{share_code}"
async def get_project_or_404(project_id: int, db: AsyncSession) -> Project:
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
return project
async def get_public_project_for_share_or_404(share: ShareLink, db: AsyncSession) -> Project:
project = await get_project_or_404(share.project_id, db)
if project.is_public != 1:
raise HTTPException(status_code=404, detail="分享不存在或已失效")
return project
async def ensure_project_member(project: Project, current_user: User, db: AsyncSession) -> str:
if project.owner_id == current_user.id:
return "owner"
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project.id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权访问该项目")
return member.role
async def get_share_by_code_or_404(share_code: str, share_type: Optional[str], db: AsyncSession) -> ShareLink:
query = select(ShareLink).where(ShareLink.share_code == share_code, ShareLink.status == 1)
if share_type:
query = query.where(ShareLink.share_type == share_type)
result = await db.execute(query)
share = result.scalar_one_or_none()
if not share:
raise HTTPException(status_code=404, detail="分享不存在或已失效")
return share
def create_share_access_cookie(response: Response, share: ShareLink):
token = create_access_token({"share_code": share.share_code, "scope": "share_access"})
response.set_cookie(
key=get_share_cookie_name(share.share_code),
value=token,
httponly=True,
samesite="lax",
secure=False,
max_age=60 * 60 * 12,
path="/",
)
def has_valid_share_cookie(request: Request, share: ShareLink) -> bool:
token = request.cookies.get(get_share_cookie_name(share.share_code))
if not token:
return False
payload = decode_access_token(token)
if not payload:
return False
return payload.get("scope") == "share_access" and payload.get("share_code") == share.share_code
def ensure_share_access(share: ShareLink, request: Request, password: Optional[str]):
if not share.access_pass:
return
if password and password == share.access_pass:
return
if has_valid_share_cookie(request, share):
return
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
def rewrite_markdown_assets(content: str, asset_base_url: str) -> str:
pattern = r"/api/v1/files/\d+/assets/([^)\s\"']+)"
replacement = rf"{asset_base_url}/\1"
return re.sub(pattern, replacement, content)
def rewrite_markdown_assets_for_pdf(content: str) -> str:
return re.sub(r"/api/v1/files/\d+/assets/", "_assets/", content)
def collect_parent_paths(path: str) -> list[str]:
parts = path.split("/")
parents = []
current = ""
for part in parts[:-1]:
current = f"{current}/{part}" if current else part
parents.append(current)
return parents
@router.get("/projects/{project_id}", response_model=dict)
async def get_project_share_info(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
role = await ensure_project_member(project, current_user, db)
is_owner = role == "owner"
share = None
if project.is_public == 1:
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
ShareLink.status == 1
)
)
share = share_result.scalar_one_or_none()
if not share:
share = ShareLink(
project_id=project_id,
share_type="project",
share_code=generate_share_code(),
created_by=current_user.id
)
db.add(share)
await db.commit()
await db.refresh(share)
return success_response(data={
"enabled": project.is_public == 1,
"share_url": f"/share/project/{share.share_code}" if share else None,
"has_password": bool(share.access_pass) if share else False,
"access_pass": share.access_pass if share and is_owner else None,
})
@router.post("/projects/{project_id}/settings", response_model=dict)
async def update_project_share_settings(
project_id: int,
settings: dict,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置")
if project.is_public != 1:
raise HTTPException(status_code=400, detail="请先开启公开项目,再设置项目分享")
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
ShareLink.status == 1
)
)
share = share_result.scalar_one_or_none()
if not share:
share = ShareLink(
project_id=project_id,
share_type="project",
share_code=generate_share_code(),
created_by=current_user.id
)
db.add(share)
share.access_pass = settings.get("access_pass")
await db.commit()
await log_service.log_operation(
db=db,
operation_type=OperationType.UPDATE_SHARE_SETTINGS,
resource_type=ResourceType.SHARE,
user=current_user,
resource_id=project_id,
detail={"share_type": "project", "has_password": bool(share.access_pass)},
)
return success_response(message="项目分享设置已更新")
@router.get("/projects/{project_id}/files/share/info", response_model=dict)
async def get_file_share_info(
project_id: int,
file_path: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
role = await ensure_project_member(project, current_user, db)
is_owner = role == "owner"
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.file_path == file_path,
ShareLink.status == 1
)
)
share = share_result.scalar_one_or_none()
if not share:
return success_response(data=None)
return success_response(data={
"file_path": file_path,
"share_url": f"/share/file/{share.share_code}",
"has_password": bool(share.access_pass),
"access_pass": share.access_pass if is_owner else None,
})
@router.post("/projects/{project_id}/files/share", response_model=dict)
async def create_or_update_file_share(
project_id: int,
payload: FileShareCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
await ensure_project_member(project, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, payload.file_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.file_path == payload.file_path,
ShareLink.status == 1
)
)
share = share_result.scalar_one_or_none()
if not share:
share = ShareLink(
project_id=project_id,
share_type="file",
share_code=generate_share_code(),
file_path=payload.file_path,
access_pass=payload.access_pass,
created_by=current_user.id
)
db.add(share)
action = OperationType.CREATE_SHARE_LINK
else:
share.access_pass = payload.access_pass
action = OperationType.UPDATE_SHARE_SETTINGS
await db.commit()
await db.refresh(share)
await log_service.log_operation(
db=db,
operation_type=action,
resource_type=ResourceType.SHARE,
user=current_user,
resource_id=project_id,
detail={"share_type": "file", "file_path": payload.file_path, "has_password": bool(share.access_pass)},
)
return success_response(data={
"file_path": payload.file_path,
"share_url": f"/share/file/{share.share_code}",
"has_password": bool(share.access_pass),
"access_pass": share.access_pass,
}, message="文件分享已更新")
@router.delete("/projects/{project_id}/files/share", response_model=dict)
async def delete_file_share(
project_id: int,
file_path: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
await ensure_project_member(project, current_user, db)
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.file_path == file_path,
)
)
share = share_result.scalar_one_or_none()
if not share:
raise HTTPException(status_code=404, detail="文件分享不存在")
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.file_path == file_path,
)
)
await db.commit()
await log_service.log_operation(
db=db,
operation_type=OperationType.DELETE_SHARE_LINK,
resource_type=ResourceType.SHARE,
user=current_user,
resource_id=project_id,
detail={"share_type": "file", "file_path": file_path},
)
return success_response(message="文件分享已关闭")
@router.get("/project/{share_code}/info", response_model=dict)
async def get_project_share_public_info(
share_code: str,
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
return success_response(data={
"name": project.name,
"description": project.description,
"has_password": bool(share.access_pass),
"share_code": share.share_code,
})
@router.post("/project/{share_code}/verify", response_model=dict)
async def verify_project_share_password(
share_code: str,
password: str = Header(..., alias="X-Access-Password"),
response: Response = None,
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
await get_public_project_for_share_or_404(share, db)
if share.access_pass and share.access_pass != password:
raise HTTPException(status_code=403, detail="访问密码错误")
create_share_access_cookie(response, share)
return success_response(message="验证成功")
@router.get("/project/{share_code}/tree", response_model=dict)
async def get_project_share_tree(
share_code: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
project_root = storage_service.get_secure_path(project.storage_key)
tree = storage_service.generate_tree(project_root)
return success_response(data=tree)
@router.get("/project/{share_code}/search", response_model=dict)
async def search_project_share_documents(
share_code: str,
keyword: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
if not keyword or not keyword.strip():
return success_response(data=[])
keyword = keyword.strip()
search_results = []
existing_paths = set()
try:
whoosh_results = await search_service.search(keyword, str(project.id), limit=50)
except Exception:
whoosh_results = []
for item in whoosh_results:
file_path = item.get("path")
if not file_path:
continue
existing_paths.add(file_path)
search_results.append({
"file_path": file_path,
"file_name": item.get("title") or Path(file_path).name,
"highlights": item.get("highlights"),
"match_type": "全文检索",
"parent_paths": collect_parent_paths(file_path),
})
project_root = storage_service.get_secure_path(project.storage_key)
keyword_lower = keyword.lower()
try:
files_to_scan = list(project_root.rglob("*.md")) + list(project_root.rglob("*.pdf"))
except Exception:
files_to_scan = []
for file_path in files_to_scan:
if "_assets" in file_path.parts:
continue
relative_path = str(file_path.relative_to(project_root))
if relative_path in existing_paths:
continue
if keyword_lower in file_path.name.lower():
search_results.append({
"file_path": relative_path,
"file_name": file_path.name,
"match_type": "文件名匹配",
"parent_paths": collect_parent_paths(relative_path),
})
existing_paths.add(relative_path)
return success_response(data=search_results[:100])
@router.get("/project/{share_code}/file", response_model=dict)
async def get_project_share_file(
share_code: str,
path: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
file_path = storage_service.get_secure_path(project.storage_key, path)
content = await storage_service.read_file(file_path)
content = rewrite_markdown_assets(content, f"/api/v1/shares/project/{share.share_code}/assets")
return success_response(data={"content": content})
@router.get("/project/{share_code}/document/{path:path}")
async def get_project_share_document(
share_code: str,
path: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content_type, _ = mimetypes.guess_type(str(file_path))
return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name)
@router.get("/project/{share_code}/export-pdf")
async def export_project_share_pdf(
share_code: str,
path: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content = await storage_service.read_file(file_path)
filename = Path(path).stem + ".pdf"
content = rewrite_markdown_assets_for_pdf(content)
project_root = storage_service.get_secure_path(project.storage_key)
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
return StreamingResponse(
pdf_buffer,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.get("/files/{share_code}/info", response_model=dict)
async def get_file_share_public_info(
share_code: str,
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
project = await get_project_or_404(share.project_id, db)
return success_response(data={
"name": Path(share.file_path).name,
"project_name": project.name,
"file_path": share.file_path,
"has_password": bool(share.access_pass),
"share_code": share.share_code,
})
@router.post("/files/{share_code}/verify", response_model=dict)
async def verify_file_share_password(
share_code: str,
password: str = Header(..., alias="X-Access-Password"),
response: Response = None,
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
if share.access_pass and share.access_pass != password:
raise HTTPException(status_code=403, detail="访问密码错误")
create_share_access_cookie(response, share)
return success_response(message="验证成功")
@router.get("/files/{share_code}/content", response_model=dict)
async def get_file_share_content(
share_code: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
file_path = storage_service.get_secure_path(project.storage_key, share.file_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
if share.file_path.lower().endswith(".pdf"):
return success_response(data={
"type": "pdf",
"filename": Path(share.file_path).name,
"document_url": f"/api/v1/shares/files/{share.share_code}/document",
})
content = await storage_service.read_file(file_path)
content = rewrite_markdown_assets(content, f"/api/v1/shares/files/{share.share_code}/assets")
return success_response(data={
"type": "markdown",
"filename": Path(share.file_path).name,
"content": content,
})
@router.get("/files/{share_code}/document")
async def get_file_share_document(
share_code: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
file_path = storage_service.get_secure_path(project.storage_key, share.file_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content_type, _ = mimetypes.guess_type(str(file_path))
return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name)
@router.get("/files/{share_code}/export-pdf")
async def export_file_share_pdf(
share_code: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
file_path = storage_service.get_secure_path(project.storage_key, share.file_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
if share.file_path.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="PDF 文件无需导出")
content = await storage_service.read_file(file_path)
filename = Path(share.file_path).stem + ".pdf"
content = rewrite_markdown_assets(content, f"/api/v1/shares/files/{share.share_code}/assets")
project_root = storage_service.get_secure_path(project.storage_key)
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
return StreamingResponse(
pdf_buffer,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.get("/files/{share_code}/assets/{subfolder}/{filename}")
async def get_file_share_asset(
share_code: str,
subfolder: str,
filename: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
asset_path = f"_assets/{subfolder}/{filename}"
file_path = storage_service.get_secure_path(project.storage_key, asset_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
mime_type, _ = mimetypes.guess_type(filename)
return FileResponse(path=str(file_path), filename=filename, media_type=mime_type or "application/octet-stream")
@router.get("/project/{share_code}/assets/{subfolder}/{filename}")
async def get_project_share_asset(
share_code: str,
subfolder: str,
filename: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
asset_path = f"_assets/{subfolder}/{filename}"
file_path = storage_service.get_secure_path(project.storage_key, asset_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
mime_type, _ = mimetypes.guess_type(filename)
return FileResponse(path=str(file_path), filename=filename, media_type=mime_type or "application/octet-stream")

View File

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

View File

@ -7,6 +7,7 @@ from app.models.role import Role, UserRole
from app.models.menu import SystemMenu, RoleMenu
from app.models.project import Project, ProjectMember, ProjectMemberRole
from app.models.document import DocumentMeta
from app.models.share import ShareLink
from app.models.log import OperationLog
from app.models.mcp_bot import MCPBot
from app.models.llm_model_config import LLMModelConfig
@ -22,6 +23,7 @@ __all__ = [
"ProjectMember",
"ProjectMemberRole",
"DocumentMeta",
"ShareLink",
"OperationLog",
"MCPBot",
"LLMModelConfig",

View File

@ -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"<ShareLink(id={self.id}, type='{self.share_type}', code='{self.share_code}')>"

View File

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

View File

@ -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")
new_owner_id: int = Field(..., description="新所有者ID")

View File

@ -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='分享链接表';

View File

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

View File

@ -12,7 +12,8 @@ import DocumentEditor from '@/pages/Document/DocumentEditor'
import Dashboard from '@/pages/Dashboard'
import Desktop from '@/pages/Desktop'
import Constructing from '@/pages/Constructing'
import PreviewPage from '@/pages/Preview/PreviewPage'
import ProjectSharePage from '@/pages/Preview/ProjectSharePage'
import FileSharePage from '@/pages/Preview/FileSharePage'
import ProfilePage from '@/pages/Profile/ProfilePage'
import Permissions from '@/pages/System/Permissions'
import Users from '@/pages/System/Users'
@ -62,8 +63,8 @@ function App() {
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
{/* 项目预览(公开访问,无需登录) */}
<Route path="/preview/:projectId" element={<PreviewPage />} />
<Route path="/share/project/:shareCode" element={<ProjectSharePage />} />
<Route path="/share/file/:shareCode" element={<FileSharePage />} />
{/* 使用共享布局的路由 */}
<Route element={<ProtectedRoute><LayoutWrapper /></ProtectedRoute>}>

View File

@ -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 } : {},
})
}
/**
* 获取预览项目的文档文件URLPDF等
*/
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`
}

View File

@ -0,0 +1,211 @@
.floating-toc {
position: fixed;
top: 50%;
right: 24px;
z-index: 30;
width: 48px;
height: 180px;
transform: translateY(-50%);
outline: none;
}
.floating-toc-tab {
position: absolute;
top: 0;
right: 0;
width: 48px;
height: 180px;
border: 1px solid color-mix(in srgb, var(--border-color) 82%, var(--text-color-secondary));
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
color: var(--text-color-secondary);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12);
backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
cursor: default;
transition: opacity 0.18s ease, transform 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.floating-toc-tab span {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 12px;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
}
.floating-toc-panel {
position: absolute;
top: 0;
right: 0;
width: min(420px, calc(100vw - 320px));
min-width: 300px;
max-height: min(58vh, 460px);
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--text-color-secondary));
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 96%, transparent);
color: var(--text-color);
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(16px);
opacity: 0;
pointer-events: none;
transform: translateX(10px) scale(0.98);
transform-origin: top right;
overflow: hidden;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.floating-toc:hover .floating-toc-tab,
.floating-toc:focus-within .floating-toc-tab {
opacity: 0;
transform: translateX(8px) scale(0.96);
pointer-events: none;
}
.floating-toc:hover .floating-toc-panel,
.floating-toc:focus-within .floating-toc-panel {
opacity: 1;
pointer-events: auto;
transform: translateX(0) scale(1);
}
.floating-toc.floating-toc-dismissed .floating-toc-tab {
opacity: 1;
transform: none;
pointer-events: auto;
}
.floating-toc.floating-toc-dismissed .floating-toc-panel {
opacity: 0;
pointer-events: none;
transform: translateX(10px) scale(0.98);
}
.floating-toc-header {
min-height: 48px;
padding: 0 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--text-color);
font-size: 14px;
font-weight: 600;
}
.floating-toc-count {
min-width: 34px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: var(--item-hover-bg);
color: var(--text-color-secondary);
font-size: 12px;
font-weight: 600;
line-height: 22px;
text-align: center;
}
.floating-toc-content {
max-height: calc(min(58vh, 460px) - 49px);
overflow-y: auto;
overflow-x: hidden;
padding: 10px 8px 12px;
}
.floating-toc-content .ant-anchor {
padding-left: 0;
}
.floating-toc-content .ant-anchor::before {
display: none;
}
.floating-toc-content .ant-anchor-ink {
display: none;
}
.floating-toc-content .ant-anchor-link {
padding: 2px 0;
}
.floating-toc-content .ant-anchor-link-title {
border-radius: 6px;
color: var(--text-color-secondary);
line-height: 1.45;
transition: background 0.16s ease, color 0.16s ease;
}
.floating-toc-content .ant-anchor-link-title:hover,
.floating-toc-content .ant-anchor-link-active > .ant-anchor-link-title {
background: var(--item-hover-bg);
color: var(--link-color);
}
.floating-toc-item {
min-height: 34px;
padding: 7px 8px;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.floating-toc-item-icon {
flex: none;
font-size: 12px;
color: currentColor;
opacity: 0.72;
}
.floating-toc-item-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.floating-toc-empty {
padding: 28px 12px;
color: var(--text-color-secondary);
text-align: center;
font-size: 13px;
}
.floating-toc-drawer .ant-drawer-body {
padding: 0;
}
.floating-toc-drawer .floating-toc-content {
max-height: none;
height: 100%;
padding: 10px 8px 12px;
}
body.dark .floating-toc-tab,
body.dark .floating-toc-panel {
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.38);
}
@media (max-width: 1024px) {
.floating-toc {
right: 16px;
}
.floating-toc-panel {
width: min(360px, calc(100vw - 300px));
}
}
@media (max-width: 768px) {
.floating-toc {
display: none;
}
}

View File

@ -0,0 +1,117 @@
import { useState } from 'react'
import { Anchor, Drawer } from 'antd'
import { FileTextOutlined, MenuOutlined } from '@ant-design/icons'
import './FloatingToc.css'
function buildAnchorItems(items, searchKeyword, renderTitle) {
return items.map((item) => ({
key: item.key,
href: item.href,
title: (
<div className="floating-toc-item" style={{ paddingLeft: `${(item.level - 1) * 12}px` }}>
<FileTextOutlined className="floating-toc-item-icon" />
<span className="floating-toc-item-title">
{renderTitle ? renderTitle(item, searchKeyword) : item.title}
</span>
</div>
),
}))
}
function TocContent({ items = [], getContainer, searchKeyword = '', renderTitle, onItemClick, onNavigate }) {
const anchorItems = buildAnchorItems(items, searchKeyword, renderTitle)
return (
<div className="floating-toc-content">
{items.length > 0 ? (
<Anchor
affix={false}
offsetTop={0}
getContainer={getContainer}
items={anchorItems}
onClick={(e, link) => {
// antd Anchor link { href, title }
// DOM onNavigate
if (onNavigate) {
e.preventDefault()
onNavigate(link?.href)
}
window.setTimeout(() => onItemClick?.(link), 120)
}}
/>
) : (
<div className="floating-toc-empty">当前文档无标题</div>
)}
</div>
)
}
export function TocDrawer({
open,
onClose,
items = [],
getContainer,
searchKeyword = '',
renderTitle,
onNavigate,
}) {
return (
<Drawer
title="文档索引"
placement="right"
onClose={onClose}
open={open}
width="82%"
className="floating-toc-drawer"
>
<TocContent
items={items}
getContainer={getContainer}
searchKeyword={searchKeyword}
renderTitle={renderTitle}
onItemClick={onClose}
onNavigate={onNavigate}
/>
</Drawer>
)
}
export default function FloatingToc({
items = [],
getContainer,
searchKeyword = '',
renderTitle,
onNavigate,
className = '',
}) {
const [dismissed, setDismissed] = useState(false)
return (
<aside
className={`floating-toc ${dismissed ? 'floating-toc-dismissed' : ''} ${className}`.trim()}
tabIndex={0}
aria-label="文档索引"
onMouseEnter={() => setDismissed(false)}
onFocus={() => setDismissed(false)}
>
<div className="floating-toc-tab">
<MenuOutlined />
<span>文档索引</span>
</div>
<div className="floating-toc-panel">
<div className="floating-toc-header">
<span>文档索引</span>
{items.length > 0 && <span className="floating-toc-count">{items.length}</span>}
</div>
<TocContent
items={items}
getContainer={getContainer}
searchKeyword={searchKeyword}
renderTitle={renderTitle}
onItemClick={() => setDismissed(true)}
onNavigate={onNavigate}
/>
</div>
</aside>
)
}

View File

@ -0,0 +1,99 @@
/* 外框:与 ByteMD 编辑器一致的边框容器 */
.large-markdown-editor {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
width: 100%;
height: 100%;
border: 1px solid var(--border-color);
border-radius: 2px;
overflow: hidden;
box-sizing: border-box;
background: var(--bg-color);
}
/* 顶部提示栏:复用 ByteMD 工具栏的视觉规范 */
.large-markdown-editor-header {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-bottom: 1px solid var(--border-color);
background-color: var(--toolbar-bg);
color: var(--text-color-secondary);
font-size: 13px;
box-sizing: border-box;
}
.large-markdown-editor-header-icon {
color: var(--link-color);
}
/* 编辑区域 */
.large-markdown-editor-body {
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.large-markdown-editor-body .CodeMirror {
width: 100%;
height: 100%;
background: var(--bg-color);
color: var(--text-color);
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 14px;
line-height: 1.8;
}
/* 左右内边距对称 */
.large-markdown-editor-body .CodeMirror-lines {
padding: 12px 0;
}
.large-markdown-editor-body .CodeMirror pre.CodeMirror-line,
.large-markdown-editor-body .CodeMirror pre.CodeMirror-line-like {
padding: 0 16px;
}
.large-markdown-editor-body .CodeMirror-cursor {
border-left-color: var(--text-color);
}
.large-markdown-editor-body .CodeMirror-placeholder {
color: var(--text-color-secondary);
}
.large-markdown-editor-body .CodeMirror-selected {
background: rgba(22, 119, 255, 0.16);
}
/* 暗色模式下覆盖 cm-s-default 的浅色配色,提升对比度 */
body.dark .large-markdown-editor-body .CodeMirror-selected {
background: rgba(22, 119, 255, 0.28);
}
body.dark .large-markdown-editor-body .cm-s-default .cm-header {
color: #4ea1ff;
}
body.dark .large-markdown-editor-body .cm-s-default .cm-quote {
color: #6cc070;
}
body.dark .large-markdown-editor-body .cm-s-default .cm-link {
color: #58a6ff;
}
body.dark .large-markdown-editor-body .cm-s-default .cm-url {
color: #d2545b;
}
body.dark .large-markdown-editor-body .cm-s-default .cm-variable-2 {
color: #79b8ff;
}

View File

@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react'
import { InfoCircleOutlined } from '@ant-design/icons'
import factory from 'codemirror-ssr'
import usePlaceholder from 'codemirror-ssr/addon/display/placeholder.js'
import useContinuelist from 'codemirror-ssr/addon/edit/continuelist.js'
import useOverlay from 'codemirror-ssr/addon/mode/overlay.js'
import useGfm from 'codemirror-ssr/mode/gfm/gfm.js'
import useMarkdown from 'codemirror-ssr/mode/markdown/markdown.js'
import useXml from 'codemirror-ssr/mode/xml/xml.js'
import 'codemirror-ssr/lib/codemirror.css'
import { LARGE_MARKDOWN_NOTICE } from './LargeMarkdownViewer'
import './LargeMarkdownEditor.css'
// ByteMD codemirror-ssr Markdown
// CodeMirror 5
function createCodeMirror() {
const codemirror = factory()
usePlaceholder(codemirror)
useOverlay(codemirror)
useXml(codemirror)
useMarkdown(codemirror)
useGfm(codemirror)
useContinuelist(codemirror)
return codemirror
}
export default function LargeMarkdownEditor({ value = '', onChange, placeholder = '' }) {
const containerRef = useRef(null)
const editorRef = useRef(null)
const onChangeRef = useRef(onChange)
// onChange
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
useEffect(() => {
if (!containerRef.current) return
const codemirror = createCodeMirror()
const editor = codemirror(containerRef.current, {
value,
mode: 'gfm',
lineWrapping: true,
lineNumbers: false,
tabSize: 2,
indentUnit: 2,
placeholder,
extraKeys: {
Enter: 'newlineAndIndentContinueMarkdownList',
Tab: 'indentMore',
'Shift-Tab': 'indentLess',
},
})
editor.on('change', () => {
onChangeRef.current?.(editor.getValue())
})
editorRef.current = editor
return () => {
// CodeMirror DOM
const wrapper = editor.getWrapperElement()
wrapper?.parentNode?.removeChild(wrapper)
editorRef.current = null
}
}, [])
// value
useEffect(() => {
const editor = editorRef.current
if (!editor) return
if (value !== editor.getValue()) {
const cursor = editor.getCursor()
editor.setValue(value)
editor.setCursor(cursor)
}
}, [value])
return (
<div className="large-markdown-editor">
<div className="large-markdown-editor-header">
<InfoCircleOutlined className="large-markdown-editor-header-icon" />
<span>{LARGE_MARKDOWN_NOTICE}</span>
</div>
<div className="large-markdown-editor-body" ref={containerRef} />
</div>
)
}

View File

@ -0,0 +1,74 @@
.large-markdown-viewer {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.large-markdown-notice {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 3;
width: min(920px, calc(100% - 48px));
pointer-events: none;
}
.large-markdown-notice .large-markdown-alert {
margin: 0;
pointer-events: auto;
}
.markdown-body-large {
width: 100%;
height: 100%;
min-height: 0;
}
.markdown-virtual-list {
height: 100%;
width: 100%;
}
.markdown-block {
max-width: 920px;
width: calc(100% - 48px);
margin: 0 auto;
padding: 0 0 1px;
}
/* 首块下移,避免被固定浮动的提示窗遮挡 */
.markdown-block[data-index='0'] {
padding-top: 64px;
}
/* 虚拟滚动条更纤细,减少视觉割裂 */
.markdown-virtual-list::-webkit-scrollbar {
width: 10px;
}
.markdown-virtual-list::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text-color-secondary) 28%, transparent);
border-radius: 999px;
border: 3px solid transparent;
background-clip: padding-box;
}
.markdown-virtual-list::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--text-color-secondary) 44%, transparent);
background-clip: padding-box;
}
@media (max-width: 768px) {
.markdown-block {
width: calc(100% - 32px);
}
}
@media (max-width: 480px) {
.markdown-block {
width: calc(100% - 24px);
}
}

View File

@ -0,0 +1,156 @@
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
import { Alert } from 'antd'
import { Virtuoso } from 'react-virtuoso'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypeSlug from 'rehype-slug'
import rehypeHighlight from 'rehype-highlight'
import GithubSlugger from 'github-slugger'
import FloatingToc from '@/components/FloatingToc/FloatingToc'
import './LargeMarkdownViewer.css'
export const LARGE_MARKDOWN_THRESHOLD = 250000
export const LARGE_MARKDOWN_NOTICE = '此文档内容过长,将采用精简模式显示'
const MAX_TOC_ITEMS = 500
export function isLargeMarkdownContent(content = '') {
return content.length > LARGE_MARKDOWN_THRESHOLD
}
export function MarkdownSizeNotice({ className = '' }) {
return (
<div className={`large-markdown-notice${className ? ` ${className}` : ''}`}>
<Alert
className="large-markdown-alert"
type="info"
showIcon
message={LARGE_MARKDOWN_NOTICE}
/>
</div>
)
}
// markdown
// 便
function buildBlocksAndToc(content) {
if (!content) return { blocks: [], tocItems: [] }
const blocks = []
const tocItems = []
const slugger = new GithubSlugger()
const lines = content.split('\n')
let current = []
let inFence = false
const flush = () => {
if (current.length > 0) {
blocks.push(current.join('\n'))
current = []
}
}
for (const line of lines) {
const isFenceLine = /^\s*(```|~~~)/.test(line)
const headingMatch = inFence ? null : line.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
flush()
if (tocItems.length < MAX_TOC_ITEMS) {
const key = slugger.slug(headingMatch[2].trim())
tocItems.push({
key: `#${key}`,
href: `#${key}`,
title: headingMatch[2].trim(),
level: headingMatch[1].length,
blockIndex: blocks.length, //
})
}
}
current.push(line)
if (isFenceLine) {
inFence = !inFence
}
if (!inFence && line.trim() === '') {
flush()
}
}
flush()
return { blocks, tocItems }
}
const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer(
{ content, components, onClick, searchKeyword = '', renderTitle },
ref
) {
const virtuosoRef = useRef(null)
const { blocks: markdownBlocks, tocItems } = useMemo(
() => buildBlocksAndToc(content),
[content]
)
const hrefToIndex = useMemo(() => {
const map = new Map()
for (const item of tocItems) {
if (!map.has(item.href)) {
map.set(item.href, item.blockIndex)
}
}
return map
}, [tocItems])
const scrollToIndex = (index) => {
virtuosoRef.current?.scrollToIndex({
index,
align: 'start',
behavior: 'smooth',
})
}
useImperativeHandle(ref, () => ({
scrollToTop: () => scrollToIndex(0),
}), [])
const handleTocNavigate = (link) => {
const index = hrefToIndex.get(link)
if (typeof index === 'number') {
scrollToIndex(index)
}
}
return (
<div className="large-markdown-viewer">
<MarkdownSizeNotice />
<div className="markdown-body markdown-body-large" onClick={onClick}>
<Virtuoso
ref={virtuosoRef}
className="markdown-virtual-list"
data={markdownBlocks}
itemContent={(index, block) => (
<div className="markdown-block" data-index={index}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
components={components}
>
{block}
</ReactMarkdown>
</div>
)}
/>
</div>
<FloatingToc
items={tocItems}
searchKeyword={searchKeyword}
renderTitle={renderTitle}
onNavigate={handleTocNavigate}
/>
</div>
)
})
export default LargeMarkdownViewer

View File

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

View File

@ -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 }) {
</div>
</div>
)}
{!showLogo && <div />} {/* Spacer if left is empty */}
{!showLogo && (
<div className="workspace-header-brand">
<AppstoreOutlined className="workspace-brand-icon" />
<span className="workspace-brand-title">NexDocus Workspace</span>
</div>
)}
{/* 右侧:功能按钮 */}
<div className="header-right">

View File

@ -24,4 +24,4 @@
.content-wrapper {
padding: 0;
min-height: 100%;
}
}

View File

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { Layout } from 'antd'
import AppSider from './AppSider'
import AppHeader from './AppHeader'
@ -6,9 +7,19 @@ import './MainLayout.css'
const { Content } = Layout
// /
const COLLAPSE_PATTERNS = [/^\/projects\/[^/]+\/docs/, /^\/projects\/[^/]+\/editor/]
function MainLayout({ children }) {
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
if (COLLAPSE_PATTERNS.some((pattern) => pattern.test(location.pathname))) {
setCollapsed(true)
}
}, [location.pathname])
const toggleCollapsed = () => {
setCollapsed(!collapsed)
}

View File

@ -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 (
<div
className={`markdown-body${className ? ` ${className}` : ''}`}
onClick={onClick}
ref={ref}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</ReactMarkdown>
</div>
)
})
export default MarkdownViewer

View File

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

View File

@ -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 (
<div
className="mode-switch"
className={`mode-switch mode-switch-${size}`}
role="tablist"
aria-label={ariaLabel}
style={{

View File

@ -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 */
@ -188,6 +180,12 @@
transition: all 0.2s;
}
/* 折叠状态下给头像留足空间 */
.ant-layout-sider-collapsed .user-card {
padding: 12px 4px;
justify-content: center;
}
.user-info {
display: flex;
align-items: center;
@ -228,4 +226,4 @@
.logout-btn:hover {
background-color: var(--border-color);
color: #ef4444; /* Red for logout */
}
}

View File

@ -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 = ({
<div className="logo-container">
{logo}
</div>
{/* 折叠按钮 - 悬浮在边缘 */}
<div
className="collapse-trigger"
<button
type="button"
className="sidebar-collapse-trigger"
onClick={() => onCollapse && onCollapse(!collapsed)}
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
>
{collapsed ? <RightOutlined /> : <LeftOutlined />}
</div>
</button>
</div>
{/* 菜单列表区域 */}

View File

@ -7,22 +7,56 @@
.pdf-toolbar {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
padding: 12px 16px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
gap: 16px;
min-width: 0;
background: transparent;
z-index: 10;
color: var(--text-color);
}
.pdf-toolbar-scale {
min-width: 50px;
text-align: center;
color: var(--text-color);
font-size: 13px;
}
.pdf-toolbar-divider {
width: 1px;
height: 18px;
background: var(--border-color);
display: inline-block;
}
.pdf-toolbar-compact .ant-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-color-secondary);
}
.pdf-toolbar-compact .ant-btn:hover {
background: var(--item-hover-bg);
color: var(--link-color);
}
.pdf-content {
flex: 1;
overflow: auto;
position: relative;
}
.virtual-pdf-viewer-container > .pdf-toolbar {
padding: 12px 16px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.pdf-virtual-list {
background: var(--bg-color-secondary);
}
@ -129,4 +163,4 @@
.react-pdf__Document {
height: 100%;
width: 100%;
}
}

View File

@ -1,6 +1,7 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Document, Page, pdfjs } from 'react-pdf'
import { Button, Space, InputNumber, message, Spin } from 'antd'
import { Button, Space, InputNumber, message, Spin, Tooltip } from 'antd'
import {
ZoomInOutlined,
ZoomOutOutlined,
@ -16,7 +17,7 @@ import './VirtualPDFViewer.css'
// PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
function VirtualPDFViewer({ url, filename }) {
function VirtualPDFViewer({ url, filename, toolbarTarget, compactToolbar = false }) {
const [numPages, setNumPages] = useState(null)
const [scale, setScale] = useState(1.0)
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // A4
@ -159,65 +160,90 @@ function VirtualPDFViewer({ url, filename }) {
document.body.removeChild(link)
}
return (
<div className="virtual-pdf-viewer-container">
{/* 工具栏 */}
<div className="pdf-toolbar">
<Space>
<Button
icon={<LeftOutlined />}
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
size="small"
/>
<Space.Compact>
<InputNumber
min={1}
max={numPages || 1}
value={currentPage}
onChange={handlePageChange}
size="small"
style={{ width: 60 }}
/>
<Button size="small" disabled>
/ {numPages || 0}
</Button>
</Space.Compact>
<Button
icon={<RightOutlined />}
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= (numPages || 0)}
size="small"
/>
const toolbar = compactToolbar ? (
<div className="pdf-toolbar pdf-toolbar-compact">
<Space size={4}>
<Tooltip title="回到顶部">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollToTop}
size="small"
type="text"
disabled={currentPage === 1}
>
回到顶部
</Button>
aria-label="回到顶部"
/>
</Tooltip>
<Tooltip title="下载PDF">
<Button
icon={<CloudDownloadOutlined />}
onClick={handleDownload}
size="small"
>
下载PDF
type="text"
aria-label="下载PDF"
/>
</Tooltip>
</Space>
</div>
) : (
<div className="pdf-toolbar">
<Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
缩小
</Button>
<span className="pdf-toolbar-scale">
{Math.round(scale * 100)}%
</span>
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
放大
</Button>
<Button
icon={<LeftOutlined />}
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
size="small"
/>
<Space.Compact>
<InputNumber
min={1}
max={numPages || 1}
value={currentPage}
onChange={handlePageChange}
size="small"
style={{ width: 60 }}
/>
<Button size="small" disabled>
/ {numPages || 0}
</Button>
</Space>
</Space.Compact>
<Button
icon={<RightOutlined />}
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= (numPages || 0)}
size="small"
/>
<span className="pdf-toolbar-divider" />
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollToTop}
size="small"
disabled={currentPage === 1}
>
回到顶部
</Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={handleDownload}
size="small"
>
下载PDF
</Button>
</Space>
</div>
)
<Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
缩小
</Button>
<span style={{ minWidth: 50, textAlign: 'center' }}>
{Math.round(scale * 100)}%
</span>
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
放大
</Button>
</Space>
</div>
return (
<div className="virtual-pdf-viewer-container">
{toolbarTarget ? createPortal(toolbar, toolbarTarget) : toolbar}
{/* PDF内容区 - 自定义虚拟滚动 */}
<div className="pdf-content" ref={containerRef}>

View File

@ -1,3 +1,5 @@
@import '@/components/LargeMarkdownViewer/LargeMarkdownViewer.css';
.document-editor-page {
height: calc(100vh - 64px);
/* width: calc(100% + 32px); */
@ -33,20 +35,63 @@
}
.sider-header {
padding: 16px 20px;
padding: 12px 10px 12px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
background: var(--header-bg);
}
.sider-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
font-size: 20px;
font-weight: 700;
letter-spacing: 0;
color: var(--text-color);
line-height: 1.5;
line-height: 1.1;
}
.sider-title-row {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.sider-title-row h2 {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-back-button {
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: #707480;
font-size: 16px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.project-back-button:hover {
background: rgba(17, 24, 39, 0.06);
color: #2f3440;
transform: translateX(-1px);
}
.project-back-button:focus-visible {
outline: 2px solid rgba(22, 119, 255, 0.35);
outline-offset: 2px;
}
.sider-actions {
@ -91,48 +136,20 @@
color: var(--text-color);
}
/* 修复Tree组件文档名过长的显示问题 */
.file-tree .ant-tree-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.file-tree .ant-tree-node-content-wrapper {
overflow: hidden;
max-width: 100%;
}
.file-tree .ant-tree-treenode {
overflow: hidden;
}
/* 确保Tree节点标题区域不折行 */
.file-tree .ant-tree-node-content-wrapper .ant-tree-title {
display: inline-block;
max-width: calc(100% - 24px);
/* 预留图标空间 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
vertical-align: middle;
}
/* 修复文档名过长的显示问题Menu组件已废弃但保留兼容 */
.file-tree .ant-menu-title-content {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
/* Ensure it allows children to fill width */
}
/* Increase hit area for context menu */
.tree-node-wrapper {
width: 100%;
height: 100%;
display: block;
display: flex;
align-items: center;
min-width: 0;
user-select: none;
padding: 4px 8px;
margin: -4px -8px;
@ -141,6 +158,14 @@
color: var(--text-color);
}
.tree-node-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 选中的文件夹样式 */
.file-tree .folder-selected>.ant-menu-submenu-title {
background-color: var(--item-hover-bg) !important;
@ -155,8 +180,8 @@
.file-tree .ant-menu-submenu-title {
overflow: hidden;
display: flex !important;
/* Ensure flex layout for item */
align-items: center;
min-width: 0;
}
.document-content {
@ -183,8 +208,8 @@
min-height: 57px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
background: var(--header-bg);
flex-shrink: 0;
}
@ -212,8 +237,14 @@
padding: 5px 10px;
}
/* Fix for bytemd-react wrapper div */
.bytemd-wrapper>div {
.bytemd-wrapper.large-file-editor {
position: relative;
display: flex;
flex-direction: column;
}
/* Fix for bytemd-react wrapper div仅普通编辑器超大着色编辑器不受此约束 */
.bytemd-wrapper:not(.large-file-editor)>div {
flex: 1;
display: flex;
flex-direction: column;
@ -263,6 +294,11 @@
background-color: var(--item-hover-bg);
}
/* Keep the TOC entry visible; hide the help sidebar button. */
.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(2) {
display: none;
}
/* 编辑和预览区域容器 */
.bytemd-body {
flex: 1 !important;
@ -277,15 +313,10 @@
background-color: var(--bg-color);
}
/* 编辑区域 - 固定50%宽度 */
/* 编辑区域 - 默认分栏,保留 Bytemd 内联样式对仅编辑/仅预览的控制 */
.bytemd-editor {
width: 50% !important;
flex: 0 0 50% !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden;
min-height: 0;
max-width: 50% !important;
box-sizing: border-box;
/* Added for consistent box model */
min-width: 0;
@ -294,16 +325,13 @@
border-right: 1px solid var(--border-color);
}
/* 预览区域 - 固定50%宽度 */
/* 预览区域 - 默认分栏,保留 Bytemd 内联样式对仅编辑/仅预览的控制 */
.bytemd-preview {
width: 50% !important;
flex: 0 0 50% !important;
overflow-y: auto !important;
overflow-x: hidden !important;
padding: 16px;
font-size: 14px;
line-height: 1.8;
max-width: 50% !important;
box-sizing: border-box;
/* Added for consistent box model */
min-width: 0;
@ -434,16 +462,10 @@
margin-bottom: 0.25em;
}
/* 文件名过长时显示省略号,不折行 */
.content-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 600px;
color: var(--text-color);
.content-header .preview-header-title {
flex: 1;
min-width: 0;
max-width: none;
}
@media (max-width: 768px) {

View File

@ -4,20 +4,19 @@ import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, S
import {
FileOutlined,
FolderOutlined,
PlusOutlined,
FolderOpenOutlined,
DeleteOutlined,
EditOutlined,
SaveOutlined,
FileAddOutlined,
FolderAddOutlined,
UploadOutlined,
DownloadOutlined,
SwapOutlined,
ReloadOutlined,
FileImageOutlined,
FilePdfOutlined,
FileTextOutlined,
UndoOutlined,
CloseOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons'
import { Editor } from '@bytemd/react'
import gfm from '@bytemd/plugin-gfm'
@ -28,16 +27,19 @@ import gemoji from '@bytemd/plugin-gemoji'
import 'bytemd/dist/index.css'
import 'highlight.js/styles/github.css'
import {
getProjectTree,
getFileContent,
saveFile,
operateFile,
uploadFile,
importDocuments,
uploadDocument,
getDocumentUrl,
} from '@/api/file'
import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import { findNodeByKey } from './documentBrowserUtils'
import useDocumentEditorWorkspace from './useDocumentEditorWorkspace'
import { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import LargeMarkdownEditor from '@/components/LargeMarkdownViewer/LargeMarkdownEditor'
import './DocumentEditor.css'
const { Sider, Content } = Layout
@ -47,33 +49,11 @@ function DocumentEditor() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const fileInputRef = useRef(null)
const {
treeData,
selectedFile,
selectedNode,
fileContent,
loading,
openKeys,
selectedMenuKey,
isPdfSelected,
refreshing,
projectName,
userRole,
setOpenKeys,
setSelectedNode,
setSelectedFile,
setSelectedMenuKey,
setFileContent,
fetchTree,
clearSelection,
openNodeByKey,
handleMenuClick,
refreshCurrentDocument,
} = useDocumentEditorWorkspace({
projectId,
searchParams,
setSearchParams,
})
const [treeData, setTreeData] = useState([])
const [selectedFile, setSelectedFile] = useState(null)
const [selectedNode, setSelectedNode] = useState(null) //
const [fileContent, setFileContent] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [moveModalVisible, setMoveModalVisible] = useState(false)
@ -83,16 +63,43 @@ function DocumentEditor() {
const [creationParentPath, setCreationParentPath] = useState('')
const [moveTargetPath, setMoveTargetPath] = useState('')
const [dirOptions, setDirOptions] = useState([])
const [editorHeight, setEditorHeight] = useState(600) // 600px
const [openKeys, setOpenKeys] = useState([]) // Menu
const [uploadProgress, setUploadProgress] = useState(0) //
const [uploading, setUploading] = useState(false) //
const [fileList, setFileList] = useState([]) //
const [selectedMenuKey, setSelectedMenuKey] = useState(null) //
const uploadingRef = useRef(false) // 使ref
const [isPdfSelected, setIsPdfSelected] = useState(false) // PDF
const [linkModalVisible, setLinkModalVisible] = useState(false)
const [linkTarget, setLinkTarget] = useState(null)
const [projectName, setProjectName] = useState('') //
const [userRole, setUserRole] = useState('viewer')
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
const editorCtxRef = useRef(null)
const modeSwitchingRef = useRef(false)
const isLargeMarkdown = isLargeMarkdownContent(fileContent)
const isHeaderPdf = selectedFile?.toLowerCase().endsWith('.pdf')
const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : '请选择文件'
const linkTreeData = useMemo(() => {
const markSelectable = (nodes) => nodes.map((node) => {
if (node.children?.length) {
return {
...node,
selectable: false,
children: markSelectable(node.children),
}
}
return {
...node,
selectable: true,
}
})
return markSelectable(treeData)
}, [treeData])
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
@ -102,6 +109,153 @@ function DocumentEditor() {
navigate(to)
}
const updateSelectedParam = (path, isFile = true) => {
const nextParams = new URLSearchParams(searchParams)
nextParams.delete('file')
nextParams.delete('selected')
if (path) {
nextParams.set(isFile ? 'file' : 'selected', path)
}
setSearchParams(nextParams, { replace: true })
}
const updateFileParam = (filePath) => {
updateSelectedParam(filePath, true)
}
const buildDocumentUrl = (filePath) => {
const params = new URLSearchParams()
const token = localStorage.getItem('access_token')
if (token) {
params.set('token', token)
}
const query = params.toString()
return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}`
}
const downloadByUrl = (url, filename) => {
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const downloadBlob = (blob, filename) => {
const url = window.URL.createObjectURL(blob)
downloadByUrl(url, filename)
window.URL.revokeObjectURL(url)
}
const getSelectedDownloadNode = () => {
if (selectedNode) return selectedNode
if (selectedMenuKey) return findNodeByKey(treeData, selectedMenuKey)
if (selectedFile) return findNodeByKey(treeData, selectedFile)
return null
}
const getDownloadFilename = (path) => path.split('/').filter(Boolean).pop() || 'document'
const downloadMarkdownFile = () => {
const filename = getDownloadFilename(selectedFile)
downloadBlob(new Blob([fileContent], { type: 'text/markdown;charset=utf-8' }), filename)
}
const handleDownloadSelectedFile = () => {
const targetNode = getSelectedDownloadNode()
if (!targetNode) {
Toast.warning('提示', '请先选择文件')
return
}
if (!targetNode.isLeaf) {
Toast.warning('暂不支持文件夹下载', '请选择 Markdown 或 PDF 文件')
return
}
const path = targetNode.key
const lowerPath = path.toLowerCase()
if (!lowerPath.endsWith('.md') && !lowerPath.endsWith('.pdf')) {
Toast.warning('暂不支持该文件类型', '请选择 Markdown 或 PDF 文件')
return
}
if (lowerPath.endsWith('.md')) {
if (selectedFile === path) {
downloadMarkdownFile()
} else {
getFileContent(projectId, path).then((res) => {
downloadBlob(
new Blob([res.data?.content || ''], { type: 'text/markdown;charset=utf-8' }),
getDownloadFilename(path)
)
}).catch((error) => {
console.error('Download markdown error:', error)
})
}
return
}
downloadByUrl(buildDocumentUrl(path), getDownloadFilename(path))
}
const selectFolder = (folderPath, { syncUrl = false } = {}) => {
const targetNode = findNodeByKey(treeData, folderPath)
if (targetNode) {
setSelectedNode(targetNode)
}
setSelectedMenuKey(folderPath)
setSelectedFile(null)
setIsPdfSelected(false)
setFileContent('')
if (syncUrl) {
updateSelectedParam(folderPath, false)
}
}
const getModeSwitchTarget = () => {
const params = new URLSearchParams()
if (selectedFile) {
params.set('file', selectedFile)
} else if (selectedMenuKey) {
const targetNode = findNodeByKey(treeData, selectedMenuKey)
if (targetNode && !targetNode.isLeaf) {
params.set('selected', selectedMenuKey)
}
} else {
const selectedParam = searchParams.get('selected')
if (selectedParam) {
params.set('selected', selectedParam)
}
}
const query = params.toString()
return `/projects/${projectId}/docs${query ? `?${query}` : ''}`
}
const encodeMarkdownLinkTarget = (targetPath) => {
if (!targetPath) return targetPath
return targetPath
.split('/')
.map((part) => {
if (!part || part === '.' || part === '..') {
return part
}
return encodeURIComponent(part)
})
.join('/')
}
//
const handleInsertLink = () => {
if (!linkTarget) {
@ -118,7 +272,7 @@ function DocumentEditor() {
const fileName = linkTarget.split('/').pop()
// 使
const linkTitle = selection || fileName
const linkText = `[${linkTitle}](${linkTarget})`
const linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})`
editor.replaceSelection(linkText)
editor.focus()
@ -128,24 +282,23 @@ function DocumentEditor() {
setLinkTarget(null)
}
//
useEffect(() => {
const calculateHeight = () => {
const windowHeight = window.innerHeight
const newHeight = Math.max(windowHeight - 180, 400) // 400px
setEditorHeight(newHeight)
fetchTree()
}, [projectId])
const fetchTree = async () => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
const tree = data.tree || data || [] //
const name = data.project_name
const role = data.user_role || 'viewer'
setTreeData(tree)
setProjectName(name)
setUserRole(role)
} catch (error) {
Toast.error('加载失败', '加载文件树失败')
}
//
calculateHeight()
//
window.addEventListener('resize', calculateHeight)
return () => window.removeEventListener('resize', calculateHeight)
}, [])
const handleRefresh = async () => {
await refreshCurrentDocument()
}
const handleClose = () => {
@ -164,6 +317,105 @@ function DocumentEditor() {
})
}
//
const findNodeByKey = (nodes, key) => {
for (const node of nodes) {
if (node.key === key) {
return node
}
if (node.children) {
const found = findNodeByKey(node.children, key)
if (found) return found
}
}
return null
}
const expandParentFolders = (path) => {
const parts = path.split('/')
if (parts.length <= 1) return
const parentKeys = []
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
parentKeys.push(currentPath)
}
setOpenKeys(prev => Array.from(new Set([...prev, ...parentKeys])))
}
const openNodeByKey = async (key, node = null, syncUrl = false) => {
const targetNode = node || findNodeByKey(treeData, key)
if (!targetNode) return
setSelectedNode(targetNode)
setSelectedMenuKey(key)
if (!targetNode.isLeaf) {
setSelectedFile(null)
setIsPdfSelected(false)
setFileContent('')
if (syncUrl) {
updateSelectedParam(key, false)
}
return
}
if (syncUrl) {
updateFileParam(key)
}
expandParentFolders(key)
if (key.toLowerCase().endsWith('.pdf')) {
setSelectedFile(key)
setIsPdfSelected(true)
setFileContent('')
return
}
setIsPdfSelected(false)
setLoading(true)
try {
const res = await getFileContent(projectId, key)
setSelectedFile(key)
setFileContent(res.data.content)
} catch (error) {
Toast.error('加载失败', '加载文件失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (treeData.length === 0) return
const fileParam = searchParams.get('file')
const selectedParam = searchParams.get('selected')
if (selectedParam) {
const targetNode = findNodeByKey(treeData, selectedParam)
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedMenuKey) {
selectFolder(selectedParam)
}
return
}
if (!fileParam) return
if (fileParam === selectedFile) return
const targetNode = findNodeByKey(treeData, fileParam)
if (!targetNode) return
openNodeByKey(fileParam, targetNode, false)
}, [treeData, searchParams])
// MenuMenu
const handleMenuClick = async ({ key, domEvent }) => {
const node = findNodeByKey(treeData, key)
if (!node) return
await openNodeByKey(key, node, true)
}
//
const handleReset = () => {
if (!selectedFile) return
@ -172,8 +424,16 @@ function DocumentEditor() {
title: '确认重置',
content: '确定要重置当前修改吗?所有未保存的更改都将丢失。',
onOk: async () => {
await openNodeByKey(selectedFile, null, false)
Toast.success('重置成功', '已恢复至最后保存的版本')
setLoading(true)
try {
const res = await getFileContent(projectId, selectedFile)
setFileContent(res.data.content)
Toast.success('重置成功', '已恢复至最后保存的版本')
} catch (error) {
Toast.error('重置失败', '无法重新加载文件内容')
} finally {
setLoading(false)
}
},
})
}
@ -243,7 +503,8 @@ function DocumentEditor() {
})
Toast.success('成功', '删除成功')
if (selectedFile === path) {
clearSelection()
setSelectedFile(null)
setFileContent('')
}
fetchTree()
} catch (error) {
@ -331,7 +592,8 @@ function DocumentEditor() {
//
if (operationType === 'rename' && selectedFile === rightClickNode) {
clearSelection()
setSelectedFile(null)
setFileContent('')
}
} catch (error) {
console.error('Operation error:', error)
@ -536,7 +798,8 @@ function DocumentEditor() {
//
if (selectedFile === rightClickNode) {
clearSelection()
setSelectedFile(null)
setFileContent('')
}
} catch (error) {
console.error('Move error:', error)
@ -744,23 +1007,30 @@ function DocumentEditor() {
menu={{ items: getNodeMenuItems(node) }}
trigger={['contextMenu']}
>
<div className="tree-node-wrapper">
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
</div>
<Tooltip title={node.title} placement="right">
<div className="tree-node-wrapper">
<span className="tree-node-text">
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
</span>
</div>
</Tooltip>
</Dropdown>
)
if (!node.isLeaf) {
const isOpen = openKeys.includes(node.key)
const folderIconStyle = isSelected ? { color: '#1890ff' } : undefined
// - classNamestyle
return {
key: node.key,
label: labelContent,
icon: <FolderOutlined style={isSelected ? { color: '#1890ff' } : {}} />,
icon: isOpen
? <FolderOpenOutlined style={folderIconStyle} />
: <FolderOutlined style={folderIconStyle} />,
children: node.children ? convertTreeToMenuItems(node.children) : [],
className: isSelected ? 'folder-selected' : '',
onTitleClick: () => {
setSelectedNode(node)
setSelectedMenuKey(node.key)
selectFolder(node.key, { syncUrl: true })
},
}
} else if (node.title && node.title.endsWith('.md')) {
@ -792,54 +1062,40 @@ function DocumentEditor() {
return (
<div className="document-editor-page">
<Layout className="document-editor-container document-workspace-frame">
<Layout className="document-editor-container">
<Sider
width={280}
theme="light"
className="document-sider"
>
<div className="sider-header">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
{projectName}
</h2>
<Tooltip title="关闭">
<Button
type="text"
icon={<CloseOutlined />}
onClick={handleClose}
style={{ marginLeft: 8 }}
/>
</Tooltip>
<div className="sider-title-row">
<button
type="button"
className="project-back-button"
onClick={handleClose}
aria-label="返回项目列表"
>
<ArrowLeftOutlined />
</button>
<h2 title={projectName}>{projectName}</h2>
</div>
<div className="sider-actions">
<div className="mode-actions-row">
<ModeSwitch
size="small"
value={modeSwitchValue}
onChange={(mode) => {
if (mode === 'view' && !modeSwitchingRef.current) {
modeSwitchingRef.current = true
setModeSwitchValue('view')
setTimeout(() => {
const params = new URLSearchParams()
if (selectedFile) {
params.set('file', selectedFile)
}
const query = params.toString()
navigateWithTransition(`/projects/${projectId}/docs${query ? `?${query}` : ''}`)
navigateWithTransition(getModeSwitchTarget())
}, 160)
}
}}
/>
<Space.Compact className="mode-actions-group">
<Tooltip title="刷新">
<Button
size="middle"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={refreshing}
/>
</Tooltip>
<Tooltip title="添加文件">
<Button
size="middle"
@ -869,6 +1125,13 @@ function DocumentEditor() {
/>
</Tooltip>
</Upload>
<Tooltip title="下载选中文件">
<Button
size="middle"
icon={<DownloadOutlined />}
onClick={handleDownloadSelectedFile}
/>
</Tooltip>
</Space.Compact>
</div>
</div>
@ -893,7 +1156,10 @@ function DocumentEditor() {
<Content className="document-content">
<div className="content-header">
<h3>{selectedFile || '请选择文件'}</h3>
<h3 className="preview-header-title">
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span>
</h3>
<Space>
<Button
type="primary"
@ -921,23 +1187,30 @@ function DocumentEditor() {
</div>
) : selectedFile ? (
<div
className="bytemd-wrapper"
className={`bytemd-wrapper ${isLargeMarkdown ? 'large-file-editor' : ''}`}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
<Editor
key={selectedFile}
value={fileContent}
onChange={(v) => setFileContent(v)}
plugins={plugins}
locale={{
en: {
'Write': '编辑',
'Preview': '预览',
},
}}
/>
{isLargeMarkdown ? (
<LargeMarkdownEditor
value={fileContent}
onChange={setFileContent}
/>
) : (
<Editor
key={selectedFile}
value={fileContent}
onChange={(v) => setFileContent(v)}
plugins={plugins}
locale={{
en: {
'Write': '编辑',
'Preview': '预览',
},
}}
/>
)}
</div>
) : (
<div className="empty-editor">
@ -1012,15 +1285,16 @@ function DocumentEditor() {
style={{ width: '100%' }}
value={linkTarget}
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
treeData={treeData}
treeData={linkTreeData}
placeholder="请选择文件"
treeDefaultExpandAll
onChange={setLinkTarget}
fieldNames={{ label: 'title', value: 'key', children: 'children' }}
showSearch
filterTreeNode={(inputValue, treeNode) => {
return treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
return Boolean(treeNode.isLeaf) && treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
}}
allowClear
/>
</div>
</Modal>

View File

@ -29,20 +29,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 {
@ -87,94 +131,47 @@
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%;
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;
}
.toc-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--header-bg);
}
.toc-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-color);
}
.toc-content {
flex: 1;
overflow-y: auto;
overflow-x: auto;
padding: 16px;
}
.toc-content .ant-anchor {
padding-left: 0;
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;
@ -191,17 +188,56 @@
background: var(--header-bg);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.docs-content-header h3 {
margin: 0;
.docs-header-title {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
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-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 {
@ -211,6 +247,13 @@
min-height: 100%;
}
.docs-content-wrapper.large-markdown-mode {
max-width: 100%;
height: calc(100% - 57px);
min-height: 0;
padding: 0 0 24px;
}
/* PDF模式下使用全宽 */
.docs-content-wrapper.pdf-mode {
max-width: 100%;
@ -227,6 +270,16 @@
min-height: 400px;
}
.docs-folder-placeholder {
min-height: 360px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-color-secondary);
font-size: 14px;
}
.markdown-body {
font-size: 16px;
line-height: 1.6;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,323 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Layout, Modal, Input, Spin, Button, Space, Tooltip } from 'antd'
import { LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, MenuOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeSlug from 'rehype-slug'
import 'highlight.js/styles/github.css'
import GithubSlugger from 'github-slugger'
import Toast from '@/components/Toast/Toast'
import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import {
getFileSharePublicInfo,
verifyFileSharePassword,
getFileShareContent,
exportFileSharePDF,
} from '@/api/share'
import './PreviewPage.css'
const { Content } = Layout
function FileSharePage() {
const { shareCode } = useParams()
const navigate = useNavigate()
const contentRef = useRef(null)
const largeMarkdownRef = useRef(null)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const [shareInfo, setShareInfo] = useState(null)
const [contentInfo, setContentInfo] = useState(null)
const [loading, setLoading] = useState(true)
const [isMobile, setIsMobile] = useState(false)
const [tocItems, setTocItems] = useState([])
const [tocDrawerVisible, setTocDrawerVisible] = useState(false)
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('')
const markdownContent = contentInfo?.type === 'markdown' ? (contentInfo.content || '') : ''
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
useEffect(() => {
loadFileShare()
}, [shareCode])
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
useEffect(() => {
if (!markdownContent || isLargeMarkdown) {
setTocItems([])
return
}
const slugger = new GithubSlugger()
const headings = []
markdownContent.split('\n').forEach((line) => {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (!match) return
const level = match[1].length
const title = match[2].trim()
const key = slugger.slug(title)
headings.push({ key: `#${key}`, href: `#${key}`, title, level })
})
setTocItems(headings)
}, [markdownContent, isLargeMarkdown])
const handleClose = () => {
if (window.history.length > 1) {
navigate(-1)
return
}
navigate('/')
}
const loadFileShare = async () => {
setLoading(true)
try {
const infoRes = await getFileSharePublicInfo(shareCode)
setShareInfo(infoRes.data)
if (infoRes.data.has_password) {
setContentInfo(null)
setPasswordModalVisible(true)
setLoading(false)
return
}
const contentRes = await getFileShareContent(shareCode)
setContentInfo(contentRes.data)
} catch (error) {
console.error('Load file share error:', error)
Toast.error('加载失败', '分享链接不存在或已失效')
} finally {
setLoading(false)
}
}
const handleVerifyPassword = async () => {
if (!password.trim()) {
Toast.warning('提示', '请输入访问密码')
return
}
try {
await verifyFileSharePassword(shareCode, password)
const contentRes = await getFileShareContent(shareCode, password)
setContentInfo(contentRes.data)
setPasswordModalVisible(false)
Toast.success('验证成功')
} catch (error) {
Toast.error('访问密码错误')
}
}
const handleExportPDF = () => {
if (!contentInfo || contentInfo.type === 'pdf') return
window.open(exportFileSharePDF(shareCode), '_blank')
}
const scrollContentToTop = () => {
if (isLargeMarkdown) {
largeMarkdownRef.current?.scrollToTop()
return
}
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const isExternalHref = (href) => {
return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//')))
}
const isInternalFileHref = (href) => {
if (!href) return false
if (href.startsWith('#')) return false
if (isExternalHref(href)) return false
const pathOnly = href.split(/[?#]/)[0]
return pathOnly.endsWith('.md') || pathOnly.toLowerCase().endsWith('.pdf')
}
const handleMarkdownLink = (e, href) => {
if (!isInternalFileHref(href)) return
e.preventDefault()
Toast.error('无法打开内部文件链接', '单文件分享模式不支持跳转到其他内部文件')
}
const markdownComponents = {
a: ({ node, href, children, ...props }) => {
const isExternal = isExternalHref(href)
return (
<a
href={href}
onClick={(e) => handleMarkdownLink(e, href)}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
{...props}
>
{children}
</a>
)
},
}
const isHeaderPdf = contentInfo?.type === 'pdf'
const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
const headerLabel = contentInfo?.filename || '文件分享'
return (
<div className="preview-page file-share-page">
<div className="file-share-shell">
<Layout className="file-share-content-layout">
<Content className="file-share-content" ref={contentRef}>
<div className="preview-content-header file-share-content-header">
<button
type="button"
className="project-back-button"
onClick={handleClose}
aria-label="返回"
>
<ArrowLeftOutlined />
</button>
<h3 className="preview-header-title">
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span>
</h3>
{contentInfo?.type === 'markdown' && (
isMobile ? (
<Space className="preview-header-actions preview-compact-actions" size={4}>
<Tooltip title="回到顶部">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
type="text"
aria-label="回到顶部"
/>
</Tooltip>
<Tooltip title="下载PDF">
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportPDF}
size="small"
type="text"
aria-label="下载PDF"
/>
</Tooltip>
{!isLargeMarkdown && (
<Tooltip title="文档索引">
<Button
icon={<MenuOutlined />}
onClick={() => setTocDrawerVisible(true)}
size="small"
type="text"
aria-label="文档索引"
/>
</Tooltip>
)}
</Space>
) : (
<Space className="preview-header-actions">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
>
回到顶部
</Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportPDF}
size="small"
>
下载PDF
</Button>
</Space>
)
)}
{contentInfo?.type === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
</div>
{loading ? (
<div className="preview-loading">
<Spin size="large">
<div style={{ marginTop: 16 }}>加载中...</div>
</Spin>
</div>
) : (
<div className={`preview-content-wrapper ${contentInfo?.type === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
{contentInfo?.type === 'pdf' ? (
<VirtualPDFViewer
url={contentInfo.document_url}
filename={contentInfo.filename}
toolbarTarget={pdfToolbarTarget}
compactToolbar={isMobile}
/>
) : isLargeMarkdown ? (
<LargeMarkdownViewer
ref={largeMarkdownRef}
content={markdownContent}
components={markdownComponents}
/>
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={markdownComponents}
>
{markdownContent}
</ReactMarkdown>
</div>
)}
</div>
)}
</Content>
{!isMobile && contentInfo?.type === 'markdown' && !isLargeMarkdown && (
<FloatingToc
items={tocItems}
getContainer={() => contentRef.current}
/>
)}
</Layout>
</div>
<TocDrawer
open={tocDrawerVisible}
onClose={() => setTocDrawerVisible(false)}
items={tocItems}
getContainer={() => contentRef.current}
/>
<Modal
title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>}
open={passwordModalVisible}
onOk={handleVerifyPassword}
onCancel={() => setPasswordModalVisible(false)}
okText="验证"
cancelText="取消"
maskClosable={false}
>
<div style={{ marginTop: 16 }}>
<p>该文件分享需要访问密码请输入密码后继续浏览</p>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
onPressEnter={handleVerifyPassword}
prefix={<LockOutlined />}
/>
</div>
</Modal>
</div>
)
}
export default FileSharePage

View File

@ -4,6 +4,77 @@
background: var(--bg-color);
}
.file-share-page {
overflow: hidden;
}
.file-share-shell {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-color);
}
.file-share-content-header {
display: flex;
align-items: center;
gap: 14px;
}
.file-share-content {
height: 100%;
overflow-y: auto;
background: var(--bg-color);
}
.file-share-content-layout {
flex: 1;
min-height: 0;
background: var(--bg-color);
}
.preview-close-btn.ant-btn {
width: 36px;
height: 36px;
border-radius: 999px;
flex: none;
}
.project-back-button {
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: #707480;
font-size: 16px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.project-back-button:hover {
background: rgba(17, 24, 39, 0.06);
color: #2f3440;
transform: translateX(-1px);
}
.project-back-button:focus-visible {
outline: 2px solid rgba(22, 119, 255, 0.35);
outline-offset: 2px;
}
.preview-sider-title-row {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.preview-layout {
height: 100%;
background: var(--bg-color);
@ -19,124 +90,162 @@
}
.preview-sider-header {
padding: 16px;
min-height: 57px;
padding: 14px 24px;
border-bottom: 1px solid var(--border-color);
background: var(--header-bg);
display: flex;
align-items: center;
}
.preview-sider-header h2 {
margin: 0 0 8px 0;
font-size: 18px;
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
}
.preview-project-desc {
margin: 0;
font-size: 13px;
color: var(--text-color-secondary);
line-height: 1.5;
}
.preview-menu {
flex: 1;
overflow-y: auto;
border-right: none;
background: var(--sider-bg);
color: var(--text-color);
}
/* 修复文档名过长的显示问题 */
.preview-menu .ant-menu-title-content {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-search {
padding: 12px 16px 4px;
}
.preview-menu {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
border-right: none;
background: var(--sider-bg);
color: var(--text-color);
}
.preview-menu .ant-menu-item,
.preview-menu .ant-menu-submenu-title {
overflow: hidden;
}
.preview-menu .ant-menu-title-content {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
}
.preview-menu-label {
display: block;
width: 100%;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-content-layout {
position: relative;
height: 100%;
background: var(--bg-color);
}
.preview-toc-sider {
border-left: 1px solid var(--border-color);
background: var(--bg-color-secondary) !important;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.toc-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--header-bg);
}
.toc-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-color);
}
.toc-content {
flex: 1;
overflow-y: auto;
overflow-x: auto;
padding: 16px;
}
.toc-content .ant-anchor {
padding-left: 0;
}
.toc-content .ant-anchor-link {
padding: 6px 0;
}
.toc-content .ant-anchor-link-title {
font-size: 13px;
color: var(--text-color-secondary);
line-height: 1.5;
white-space: nowrap;
}
.toc-content .ant-anchor-link-active > .ant-anchor-link-title {
color: var(--link-color);
font-weight: 500;
}
.toc-empty {
color: var(--text-color-secondary);
text-align: center;
margin-top: 40px;
font-size: 13px;
}
.toc-toggle-btn {
position: fixed;
right: 24px;
top: 50%;
transform: translateY(-50%);
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.preview-content {
height: 100%;
overflow-y: auto;
background: var(--bg-color);
}
.preview-content-header {
position: sticky;
top: 0;
z-index: 5;
min-height: 57px;
padding: 14px 24px;
border-bottom: 1px solid var(--border-color);
background: var(--header-bg);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.preview-header-leading-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex: none;
}
.preview-header-leading-actions .ant-btn,
.preview-compact-actions .ant-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-color-secondary);
}
.preview-header-leading-actions .ant-btn:hover,
.preview-compact-actions .ant-btn:hover {
background: var(--item-hover-bg);
color: var(--link-color);
}
.preview-header-title {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
white-space: nowrap;
gap: 0;
}
.preview-header-icon {
font-size: 15px;
color: var(--text-color-secondary);
flex: none;
margin-right: 6px;
}
.preview-header-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-header-actions {
flex: none;
}
.pdf-header-toolbar {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
}
.pdf-header-toolbar .pdf-toolbar {
white-space: nowrap;
}
.preview-content-header h3:not(.preview-header-title) {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.preview-content-wrapper {
max-width: 900px;
margin: 0 auto;
@ -144,6 +253,13 @@
min-height: 100%;
}
.preview-content-wrapper.large-markdown-mode {
max-width: 100%;
height: calc(100% - 57px);
min-height: 0;
padding: 0 0 24px;
}
/* PDF模式下使用全宽 */
.preview-content-wrapper.pdf-mode {
max-width: 100%;
@ -279,20 +395,27 @@
margin-bottom: 4px;
}
/* 移动端菜单按钮 */
.mobile-menu-btn {
position: fixed;
top: 16px;
left: 16px;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* 移动端响应式样式 */
@media (max-width: 768px) {
.preview-content-header {
padding: 12px 12px;
gap: 8px;
}
.file-share-content-header {
gap: 8px;
}
.preview-content-wrapper {
padding: 16px;
padding-top: 60px; /* 为移动端菜单按钮留出空间 */
}
.preview-content-wrapper.large-markdown-mode {
padding: 0 0 16px;
}
.preview-content-wrapper.pdf-mode {
padding: 0;
}
.markdown-body {
@ -323,6 +446,10 @@
.markdown-body td {
padding: 6px 10px;
}
.file-share-header {
padding: 16px;
}
}
/* 平板响应式样式 */
@ -331,10 +458,6 @@
width: 240px !important;
}
.preview-toc-sider {
width: 200px !important;
}
.preview-content-wrapper {
max-width: 100%;
padding: 20px;
@ -347,6 +470,14 @@
padding: 12px;
}
.preview-content-wrapper.large-markdown-mode {
padding: 0 0 12px;
}
.preview-content-wrapper.pdf-mode {
padding: 0;
}
.markdown-body {
font-size: 14px;
}
@ -368,4 +499,4 @@
}
}
/* 打印样式优化已移除,转向后端生成方案 */
/* 打印样式优化已移除,转向后端生成方案 */

View File

@ -1,341 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty } from 'antd'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
import { MenuFoldOutlined, MenuOutlined, MenuUnfoldOutlined, FileTextOutlined, LockOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeSlug from 'rehype-slug'
import 'highlight.js/styles/github.css'
import Mark from 'mark.js'
import Highlighter from 'react-highlight-words'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import usePreviewBrowser from './usePreviewBrowser'
import './PreviewPage.css'
const { Sider, Content } = Layout
// ( Tree)
const HighlightText = ({ text, keyword }) => {
if (!keyword || !text) return text;
return (
<Highlighter
highlightClassName="search-highlight"
searchWords={[keyword]}
autoEscape={true}
textToHighlight={text}
/>
)
}
function PreviewPage() {
const { projectId } = useParams()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [tocCollapsed, setTocCollapsed] = useState(false)
const [siderCollapsed, setSiderCollapsed] = useState(false)
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const contentRef = useRef(null)
const viewerRef = useRef(null)
const {
projectInfo,
filteredTreeData,
menuItems,
selectedFile,
markdownContent,
loading,
openKeys,
tocItems,
passwordModalVisible,
password,
pdfUrl,
pdfFilename,
viewMode,
searchKeyword,
isSearching,
setOpenKeys,
setPassword,
setSearchKeyword,
setPasswordModalVisible,
handleContentClick,
handleExportPDF,
handleMenuClick,
handleSearch,
handleVerifyPassword,
} = usePreviewBrowser({
projectId,
searchParams,
contentRef,
})
// mark.js
useEffect(() => {
if (viewerRef.current && viewMode === 'markdown') {
const instance = new Mark(viewerRef.current)
instance.unmark()
if (searchKeyword.trim()) {
instance.mark(searchKeyword, {
element: 'span',
className: 'search-highlight',
exclude: ['pre', 'code', '.toc-content']
})
}
}
}, [markdownContent, searchKeyword, viewMode])
//
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
const handleClose = () => {
if (window.history.length > 1) {
navigate(-1)
return
}
navigate('/projects')
}
const handleMenuSelect = ({ key }) => {
handleMenuClick({ key })
if (isMobile) {
setMobileDrawerVisible(false)
}
}
return (
<div className="preview-page">
<Layout className="preview-layout">
{isMobile ? (
<>
<Button
type="primary"
icon={<MenuOutlined />}
className="mobile-menu-btn"
onClick={() => setMobileDrawerVisible(true)}
>
目录索引
</Button>
<Drawer
title={
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
<img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} />
<span>{projectInfo?.name || '项目预览'}</span>
</div>
}
placement="left"
onClose={() => setMobileDrawerVisible(false)}
open={mobileDrawerVisible}
width="80%"
>
<div className="preview-sider-header" style={{ padding: '0 0 16px' }}>
{projectInfo?.description && (
<p className="preview-project-desc">{projectInfo.description}</p>
)}
</div>
{/* 搜索框 */}
<div style={{ padding: '0 0 12px' }}>
<Input.Search
placeholder="搜索文档内容..."
allowClear
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onSearch={handleSearch}
loading={isSearching}
enterButton
/>
</div>
{filteredTreeData.length > 0 ? (
<Menu
mode="inline"
selectedKeys={[selectedFile]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuSelect}
className="preview-menu"
/>
) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
</div>
)}
</Drawer>
</>
) : (
<Sider
width={280}
className="preview-sider"
theme="light"
collapsed={siderCollapsed}
collapsedWidth={0}
>
<div className="preview-sider-header">
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
<img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} />
<h2 style={{ margin: 0 }}>{projectInfo?.name || '项目预览'}</h2>
</div>
{projectInfo?.description && (
<p className="preview-project-desc">{projectInfo.description}</p>
)}
</div>
{/* 搜索框 */}
<div style={{ padding: '12px 16px 4px' }}>
<Input.Search
placeholder="搜索文档内容..."
allowClear
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onSearch={handleSearch}
loading={isSearching}
enterButton
/>
</div>
{filteredTreeData.length > 0 ? (
<Menu
mode="inline"
selectedKeys={[selectedFile]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuSelect}
className="preview-menu"
/>
) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
</div>
)}
</Sider>
)}
<Layout className="preview-content-layout">
<Content className="preview-content" ref={contentRef}>
<div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? (
<div className="preview-loading">
<Spin size="large">
<div style={{ marginTop: 16 }}>加载中...</div>
</Spin>
</div>
) : viewMode === 'pdf' ? (
<VirtualPDFViewer
url={pdfUrl}
filename={pdfFilename}
/>
) : (
<div className="markdown-body" onClick={handleContentClick} ref={viewerRef}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
>
{markdownContent}
</ReactMarkdown>
</div>
)}
</div>
{viewMode === 'markdown' && (
<DocFloatActions
scrollRef={contentRef}
right={!isMobile && !tocCollapsed ? 280 : 24}
onExportPDF={handleExportPDF}
/>
)}
</Content>
{!isMobile && viewMode === 'markdown' && !tocCollapsed && (
<Sider width={250} theme="light" className="preview-toc-sider">
<div className="toc-header">
<h3>文档索引</h3>
<Button
type="text"
size="small"
icon={<MenuFoldOutlined />}
onClick={() => setTocCollapsed(true)}
/>
</div>
<div className="toc-content">
{tocItems.length > 0 ? (
<Anchor
affix={false}
offsetTop={0}
getContainer={() => contentRef.current}
items={tocItems.map((item) => ({
key: item.key,
href: item.href,
title: (
<div style={{ paddingLeft: `${(item.level - 1) * 12}px`, display: 'flex', alignItems: 'center', gap: '4px' }}>
<FileTextOutlined style={{ fontSize: '12px', color: '#8c8c8c' }} />
<HighlightText text={item.title} keyword={searchKeyword} />
</div>
),
}))}
/>
) : (
<div className="toc-empty">当前文档无标题</div>
)}
</div>
</Sider>
)}
</Layout>
{!isMobile && tocCollapsed && (
<Button
type="primary"
icon={<MenuUnfoldOutlined />}
className="toc-toggle-btn"
onClick={() => setTocCollapsed(false)}
>
文档索引
</Button>
)}
</Layout>
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<LockOutlined />
<span>访问验证</span>
</div>
}
open={passwordModalVisible}
onOk={handleVerifyPassword}
onCancel={() => setPasswordModalVisible(false)}
okText="验证"
cancelText="取消"
maskClosable={false}
>
<div style={{ marginTop: 16 }}>
<p>该项目需要访问密码请输入密码后继续浏览</p>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
onPressEnter={handleVerifyPassword}
prefix={<LockOutlined />}
/>
</div>
</Modal>
</div>
)
}
export default PreviewPage

View File

@ -0,0 +1,687 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Empty, Tooltip, Space } from 'antd'
import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, UnorderedListOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeSlug from 'rehype-slug'
import 'highlight.js/styles/github.css'
import Mark from 'mark.js'
import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger'
import Toast from '@/components/Toast/Toast'
import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import {
getProjectSharePublicInfo,
getProjectShareTree,
searchProjectShareDocuments,
getProjectShareFile,
verifyProjectSharePassword,
getProjectShareDocumentUrl,
exportProjectSharePDF,
} from '@/api/share'
import './PreviewPage.css'
const { Sider, Content } = Layout
const HighlightText = ({ text, keyword }) => {
if (!keyword || !text) return text
return (
<Highlighter
highlightClassName="search-highlight"
searchWords={[keyword]}
autoEscape
textToHighlight={text}
/>
)
}
function ProjectSharePage() {
const { shareCode } = useParams()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [projectInfo, setProjectInfo] = useState(null)
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [selectedNodeKey, setSelectedNodeKey] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [tocItems, setTocItems] = useState([])
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('')
const [siderCollapsed, setSiderCollapsed] = useState(false)
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false)
const [tocDrawerVisible, setTocDrawerVisible] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const contentRef = useRef(null)
const viewerRef = useRef(null)
const largeMarkdownRef = useRef(null)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
const handleClose = () => {
if (window.history.length > 1) {
navigate(-1)
return
}
navigate('/')
}
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
useEffect(() => {
if (viewerRef.current && viewMode === 'markdown' && !isLargeMarkdown) {
const instance = new Mark(viewerRef.current)
instance.unmark()
if (searchKeyword.trim()) {
instance.mark(searchKeyword, {
element: 'span',
className: 'search-highlight',
exclude: ['pre', 'code', '.floating-toc'],
})
}
}
}, [markdownContent, searchKeyword, viewMode, isLargeMarkdown])
useEffect(() => {
loadProjectInfo()
}, [shareCode])
useEffect(() => {
if (fileTree.length === 0) return
const fileParam = searchParams.get('file')
if (fileParam) {
openSharedFile(fileParam)
return
}
if (!selectedFile) {
const readmeNode = findReadme(fileTree)
if (readmeNode) {
openSharedFile(readmeNode.key)
}
}
}, [fileTree, searchParams])
const loadProjectInfo = async () => {
try {
const res = await getProjectSharePublicInfo(shareCode)
const info = res.data
setProjectInfo(info)
if (info.has_password) {
setPasswordModalVisible(true)
} else {
loadFileTree()
}
} catch (error) {
console.error('Load project share info error:', error)
Toast.error('加载失败', '分享链接不存在或已失效')
}
}
const handleVerifyPassword = async () => {
if (!password.trim()) {
Toast.warning('提示', '请输入访问密码')
return
}
try {
await verifyProjectSharePassword(shareCode, password)
setPasswordModalVisible(false)
loadFileTree(password)
Toast.success('验证成功')
} catch (error) {
Toast.error('访问密码错误')
}
}
const loadFileTree = async (pwd = null) => {
try {
const res = await getProjectShareTree(shareCode, pwd)
setFileTree(res.data || [])
} catch (error) {
console.error('Load share tree error:', error)
if (error.response?.status === 403) {
setPasswordModalVisible(true)
} else {
Toast.error('加载失败', '目录加载失败')
}
}
}
const loadMarkdown = async (filePath, pwd = null) => {
setLoading(true)
setTocItems([])
try {
const res = await getProjectShareFile(shareCode, filePath, pwd)
setMarkdownContent(res.data?.content || '')
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
} catch (error) {
console.error('Load share markdown error:', error)
if (error.response?.status === 403) {
setPasswordModalVisible(true)
} else {
Toast.error('加载失败', '文档加载失败')
}
} finally {
setLoading(false)
}
}
const normalizeMarkdownHref = (href) => {
if (!href) return href
const [pathPart, hashPart = ''] = href.split('#')
const [rawPath, searchPart = ''] = pathPart.split('?')
const decodedPath = rawPath
.split('/')
.map((part) => {
try {
return decodeURIComponent(part)
} catch (e) {
return part
}
})
.join('/')
const rebuilt = searchPart ? `${decodedPath}?${searchPart}` : decodedPath
return hashPart ? `${rebuilt}#${hashPart}` : rebuilt
}
const isExternalHref = (href) => {
return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//')))
}
const handleSearch = async (value) => {
const keyword = value || ''
setSearchKeyword(keyword)
if (!keyword.trim()) {
setMatchedFilePaths(new Set())
return
}
setIsSearching(true)
try {
const res = await searchProjectShareDocuments(shareCode, keyword)
const paths = new Set((res.data || []).map((item) => item.file_path))
setMatchedFilePaths(paths)
const keysToExpand = new Set(openKeys)
;(res.data || []).forEach((item) => {
;(item.parent_paths || []).forEach((parentPath) => keysToExpand.add(parentPath))
})
setOpenKeys(Array.from(keysToExpand))
} catch (error) {
console.error('Search share documents error:', error)
Toast.error('搜索失败', '全文检索暂时不可用')
} finally {
setIsSearching(false)
}
}
useEffect(() => {
if (!markdownContent || isLargeMarkdown) {
setTocItems([])
return
}
const slugger = new GithubSlugger()
const headings = []
markdownContent.split('\n').forEach((line) => {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (!match) return
const level = match[1].length
const title = match[2]
const key = slugger.slug(title)
headings.push({ key: `#${key}`, href: `#${key}`, title, level })
})
setTocItems(headings)
}, [markdownContent, isLargeMarkdown])
const findReadme = (nodes) => {
for (const node of nodes) {
if (node.title === 'README.md' && node.isLeaf) return node
}
return null
}
const filteredTreeData = useMemo(() => {
if (!searchKeyword.trim()) return fileTree
const loop = (nodes) => {
const result = []
for (const node of nodes) {
const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase())
const contentMatch = matchedFilePaths.has(node.key)
if (node.children?.length) {
const children = loop(node.children)
if (children.length > 0 || titleMatch) {
result.push({ ...node, children })
}
} else if (titleMatch || contentMatch) {
result.push(node)
}
}
return result
}
return loop(fileTree)
}, [fileTree, searchKeyword, matchedFilePaths])
const convertTreeToMenuItems = (nodes) => {
return nodes.map((node) => {
const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title
const labelNode = (
<Tooltip title={node.title} placement="right">
<span className="preview-menu-label">{titleText}</span>
</Tooltip>
)
if (!node.isLeaf) {
const isOpen = openKeys.includes(node.key)
return {
key: node.key,
label: labelNode,
icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />,
onTitleClick: () => setSelectedNodeKey(node.key),
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
}
if (node.title?.endsWith('.md')) {
return { key: node.key, label: labelNode, icon: <FileTextOutlined /> }
}
if (node.title?.toLowerCase().endsWith('.pdf')) {
return { key: node.key, label: labelNode, icon: <FilePdfOutlined style={{ color: '#f5222d' }} /> }
}
return null
}).filter(Boolean)
}
const resolveRelativePath = (currentPath, relativePath) => {
if (relativePath.startsWith('/')) return relativePath.substring(1)
const lastSlashIndex = currentPath.lastIndexOf('/')
const currentDir = lastSlashIndex !== -1 ? currentPath.substring(0, lastSlashIndex) : ''
const parts = relativePath.split('/')
const dirParts = currentDir ? currentDir.split('/') : []
for (const part of parts) {
if (part === '..') dirParts.pop()
else if (part !== '.' && part !== '') dirParts.push(part)
}
return dirParts.join('/')
}
const resolveMarkdownTarget = (relativePath) => {
if (!relativePath) return relativePath
if (relativePath.startsWith('.')) {
return resolveRelativePath(selectedFile, relativePath)
}
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath
}
const openSharedFile = (key) => {
setSelectedFile(key)
setSelectedNodeKey(key)
const parts = key.split('/')
const allParentPaths = []
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
allParentPaths.push(currentPath)
}
if (allParentPaths.length > 0) {
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
}
if (key.toLowerCase().endsWith('.pdf')) {
setPdfUrl(getProjectShareDocumentUrl(shareCode, key))
setPdfFilename(key.split('/').pop())
setViewMode('pdf')
return
}
setViewMode('markdown')
loadMarkdown(key)
}
const handleMarkdownLink = (e, href) => {
const normalizedHref = normalizeMarkdownHref(href)
if (!normalizedHref || isExternalHref(normalizedHref) || normalizedHref.startsWith('#')) return
const pathOnly = normalizedHref.split(/[?#]/)[0]
const isMd = pathOnly.endsWith('.md')
const isPdf = pathOnly.toLowerCase().endsWith('.pdf')
if (!isMd && !isPdf) return
e.preventDefault()
openSharedFile(resolveMarkdownTarget(pathOnly))
}
const handleExportPDF = () => {
if (!selectedFile) return
if (viewMode === 'pdf') {
const link = document.createElement('a')
link.href = pdfUrl
link.download = pdfFilename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
return
}
window.open(exportProjectSharePDF(shareCode, selectedFile), '_blank')
}
const scrollContentToTop = () => {
if (isLargeMarkdown) {
largeMarkdownRef.current?.scrollToTop()
return
}
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const menuItems = useMemo(
() => convertTreeToMenuItems(filteredTreeData),
[filteredTreeData, openKeys]
)
const markdownComponents = {
a: ({ node, href, children, ...props }) => {
const isExternal = isExternalHref(href)
return (
<a
href={href}
onClick={(e) => handleMarkdownLink(e, href)}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
{...props}
>
{children}
</a>
)
},
}
const isHeaderPdf = selectedFile.toLowerCase().endsWith('.pdf')
const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : 'README.md'
return (
<div className="preview-page">
<Layout className="preview-layout">
{isMobile ? (
<>
<Drawer
title={projectInfo?.name || '项目分享'}
placement="left"
onClose={() => setMobileDrawerVisible(false)}
open={mobileDrawerVisible}
width="80%"
>
<div className="preview-search">
<Input.Search
placeholder="搜索文档内容..."
allowClear
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onSearch={handleSearch}
loading={isSearching}
enterButton
/>
</div>
{menuItems.length > 0 ? (
<Menu
mode="inline"
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={({ key }) => {
openSharedFile(key)
setMobileDrawerVisible(false)
}}
className="preview-menu"
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
)}
</Drawer>
</>
) : (
<Sider width={280} className="preview-sider" theme="light" collapsed={siderCollapsed} collapsedWidth={0}>
<div className="preview-sider-header">
<div className="preview-sider-title-row">
<button
type="button"
className="project-back-button"
onClick={handleClose}
aria-label="返回"
>
<ArrowLeftOutlined />
</button>
<h2>{projectInfo?.name || '项目分享'}</h2>
</div>
</div>
<div className="preview-search">
<Input.Search
placeholder="搜索文档内容..."
allowClear
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onSearch={handleSearch}
loading={isSearching}
enterButton
/>
</div>
{menuItems.length > 0 ? (
<Menu
mode="inline"
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={({ key }) => openSharedFile(key)}
className="preview-menu"
/>
) : (
<div style={{ padding: 20 }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
</div>
)}
</Sider>
)}
<Layout className="preview-content-layout">
<Content className="preview-content" ref={contentRef}>
<div className="preview-content-header">
{isMobile && (
<div className="preview-header-leading-actions">
<Tooltip title="返回">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={handleClose}
size="small"
aria-label="返回"
/>
</Tooltip>
<Tooltip title="目录索引">
<Button
type="text"
icon={<UnorderedListOutlined />}
onClick={() => setMobileDrawerVisible(true)}
size="small"
aria-label="目录索引"
/>
</Tooltip>
</div>
)}
<h3 className="preview-header-title">
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span>
</h3>
{viewMode === 'markdown' && (
isMobile ? (
<Space className="preview-header-actions preview-compact-actions" size={4}>
<Tooltip title="回到顶部">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
type="text"
aria-label="回到顶部"
/>
</Tooltip>
<Tooltip title="下载PDF">
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportPDF}
size="small"
type="text"
aria-label="下载PDF"
/>
</Tooltip>
{!isLargeMarkdown && (
<Tooltip title="文档索引">
<Button
icon={<MenuOutlined />}
onClick={() => setTocDrawerVisible(true)}
size="small"
type="text"
aria-label="文档索引"
/>
</Tooltip>
)}
</Space>
) : (
<Space className="preview-header-actions">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
>
回到顶部
</Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportPDF}
size="small"
>
下载PDF
</Button>
</Space>
)
)}
{viewMode === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
</div>
<div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
{loading ? (
<div className="preview-loading">
<Spin size="large">
<div style={{ marginTop: 16 }}>加载中...</div>
</Spin>
</div>
) : viewMode === 'pdf' ? (
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} compactToolbar={isMobile} />
) : isLargeMarkdown ? (
<LargeMarkdownViewer
ref={largeMarkdownRef}
content={markdownContent}
components={markdownComponents}
searchKeyword={searchKeyword}
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
onClick={(e) => {
if (e.defaultPrevented) return
const target = e.target.closest('a')
if (target) {
const href = target.getAttribute('href')
if (href) handleMarkdownLink(e, href)
}
}}
/>
) : (
<div className="markdown-body" onClick={(e) => {
if (e.defaultPrevented) return
const target = e.target.closest('a')
if (target) {
const href = target.getAttribute('href')
if (href) handleMarkdownLink(e, href)
}
}} ref={viewerRef}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={markdownComponents}
>
{markdownContent}
</ReactMarkdown>
</div>
)}
</div>
</Content>
{!isMobile && viewMode === 'markdown' && !isLargeMarkdown && (
<FloatingToc
items={tocItems}
searchKeyword={searchKeyword}
getContainer={() => contentRef.current}
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
/>
)}
</Layout>
</Layout>
<TocDrawer
open={tocDrawerVisible}
onClose={() => setTocDrawerVisible(false)}
items={tocItems}
searchKeyword={searchKeyword}
getContainer={() => contentRef.current}
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
/>
<Modal
title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>}
open={passwordModalVisible}
onOk={handleVerifyPassword}
onCancel={() => setPasswordModalVisible(false)}
okText="验证"
cancelText="取消"
maskClosable={false}
>
<div style={{ marginTop: 16 }}>
<p>该分享需要访问密码请输入密码后继续浏览</p>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
onPressEnter={handleVerifyPassword}
prefix={<LockOutlined />}
/>
</div>
</Modal>
</div>
)
}
export default ProjectSharePage

File diff suppressed because it is too large Load Diff