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
commit
0b0713fc4e
|
|
@ -26,6 +26,7 @@ logs/
|
|||
.env
|
||||
.env.local
|
||||
.gemini-clipboard
|
||||
.memsearch
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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=["角色管理"])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}')>"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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='分享链接表';
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>}>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,57 @@
|
|||
/**
|
||||
* 项目分享和预览相关 API
|
||||
* 分享相关 API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取项目分享信息
|
||||
*/
|
||||
export function getProjectShareInfo(projectId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/share`,
|
||||
url: `/shares/projects/${projectId}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分享设置(设置或取消访问密码)
|
||||
*/
|
||||
export function updateShareSettings(projectId, data) {
|
||||
export function updateProjectShareSettings(projectId, data) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/share/settings`,
|
||||
url: `/shares/projects/${projectId}/settings`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目基本信息(公开访问)
|
||||
*/
|
||||
export function getPreviewInfo(projectId) {
|
||||
export function getFileShareInfo(projectId, filePath) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/info`,
|
||||
url: `/shares/projects/${projectId}/files/share/info`,
|
||||
method: 'get',
|
||||
params: { file_path: filePath },
|
||||
})
|
||||
}
|
||||
|
||||
export function createOrUpdateFileShare(projectId, data) {
|
||||
return request({
|
||||
url: `/shares/projects/${projectId}/files/share`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteFileShare(projectId, filePath) {
|
||||
return request({
|
||||
url: `/shares/projects/${projectId}/files/share`,
|
||||
method: 'delete',
|
||||
params: { file_path: filePath },
|
||||
})
|
||||
}
|
||||
|
||||
export function getProjectSharePublicInfo(shareCode) {
|
||||
return request({
|
||||
url: `/shares/project/${shareCode}/info`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证访问密码
|
||||
*/
|
||||
export function verifyAccessPassword(projectId, password) {
|
||||
export function verifyProjectSharePassword(shareCode, password) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/verify`,
|
||||
url: `/shares/project/${shareCode}/verify`,
|
||||
method: 'post',
|
||||
headers: {
|
||||
'X-Access-Password': password,
|
||||
|
|
@ -47,42 +59,71 @@ export function verifyAccessPassword(projectId, password) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目的文档树
|
||||
*/
|
||||
export function getPreviewTree(projectId, password = null) {
|
||||
export function getProjectShareTree(shareCode, password = null) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/tree`,
|
||||
url: `/shares/project/${shareCode}/tree`,
|
||||
method: 'get',
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目的文件内容
|
||||
*/
|
||||
export function getPreviewFile(projectId, path, password = null) {
|
||||
export function searchProjectShareDocuments(shareCode, keyword, password = null) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/file`,
|
||||
url: `/shares/project/${shareCode}/search`,
|
||||
method: 'get',
|
||||
params: { keyword },
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getProjectShareFile(shareCode, path, password = null) {
|
||||
return request({
|
||||
url: `/shares/project/${shareCode}/file`,
|
||||
method: 'get',
|
||||
params: { path },
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目的文档文件URL(PDF等)
|
||||
*/
|
||||
export function getPreviewDocumentUrl(projectId, path) {
|
||||
// 将路径的每个部分分别编码,但保留斜杠
|
||||
export function getProjectShareDocumentUrl(shareCode, path) {
|
||||
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
return `/api/v1/preview/${projectId}/document/${encodedPath}`
|
||||
return `/api/v1/shares/project/${shareCode}/document/${encodedPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 PDF
|
||||
*/
|
||||
export function exportPDF(projectId, path) {
|
||||
export function exportProjectSharePDF(shareCode, path) {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
return `/api/v1/preview/${projectId}/export-pdf?path=${encodedPath}`
|
||||
return `/api/v1/shares/project/${shareCode}/export-pdf?path=${encodedPath}`
|
||||
}
|
||||
|
||||
export function getFileSharePublicInfo(shareCode) {
|
||||
return request({
|
||||
url: `/shares/files/${shareCode}/info`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function verifyFileSharePassword(shareCode, password) {
|
||||
return request({
|
||||
url: `/shares/files/${shareCode}/verify`,
|
||||
method: 'post',
|
||||
headers: {
|
||||
'X-Access-Password': password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getFileShareContent(shareCode, password = null) {
|
||||
return request({
|
||||
url: `/shares/files/${shareCode}/content`,
|
||||
method: 'get',
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getFileShareDocumentUrl(shareCode) {
|
||||
return `/api/v1/shares/files/${shareCode}/document`
|
||||
}
|
||||
|
||||
export function exportFileSharePDF(shareCode) {
|
||||
return `/api/v1/shares/files/${shareCode}/export-pdf`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -24,4 +24,4 @@
|
|||
.content-wrapper {
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 菜单列表区域 */}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -4,20 +4,19 @@ import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, S
|
|||
import {
|
||||
FileOutlined,
|
||||
FolderOutlined,
|
||||
PlusOutlined,
|
||||
FolderOpenOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
SaveOutlined,
|
||||
FileAddOutlined,
|
||||
FolderAddOutlined,
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
SwapOutlined,
|
||||
ReloadOutlined,
|
||||
FileImageOutlined,
|
||||
FilePdfOutlined,
|
||||
FileTextOutlined,
|
||||
UndoOutlined,
|
||||
CloseOutlined,
|
||||
ArrowLeftOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Editor } from '@bytemd/react'
|
||||
import gfm from '@bytemd/plugin-gfm'
|
||||
|
|
@ -28,16 +27,19 @@ import gemoji from '@bytemd/plugin-gemoji'
|
|||
import 'bytemd/dist/index.css'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import {
|
||||
getProjectTree,
|
||||
getFileContent,
|
||||
saveFile,
|
||||
operateFile,
|
||||
uploadFile,
|
||||
importDocuments,
|
||||
uploadDocument,
|
||||
getDocumentUrl,
|
||||
} from '@/api/file'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
||||
import { findNodeByKey } from './documentBrowserUtils'
|
||||
import useDocumentEditorWorkspace from './useDocumentEditorWorkspace'
|
||||
import { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
||||
import LargeMarkdownEditor from '@/components/LargeMarkdownViewer/LargeMarkdownEditor'
|
||||
import './DocumentEditor.css'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
|
@ -47,33 +49,11 @@ function DocumentEditor() {
|
|||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const fileInputRef = useRef(null)
|
||||
const {
|
||||
treeData,
|
||||
selectedFile,
|
||||
selectedNode,
|
||||
fileContent,
|
||||
loading,
|
||||
openKeys,
|
||||
selectedMenuKey,
|
||||
isPdfSelected,
|
||||
refreshing,
|
||||
projectName,
|
||||
userRole,
|
||||
setOpenKeys,
|
||||
setSelectedNode,
|
||||
setSelectedFile,
|
||||
setSelectedMenuKey,
|
||||
setFileContent,
|
||||
fetchTree,
|
||||
clearSelection,
|
||||
openNodeByKey,
|
||||
handleMenuClick,
|
||||
refreshCurrentDocument,
|
||||
} = useDocumentEditorWorkspace({
|
||||
projectId,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
})
|
||||
const [treeData, setTreeData] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState(null)
|
||||
const [selectedNode, setSelectedNode] = useState(null) // 当前选中的节点(可能是文件或目录)
|
||||
const [fileContent, setFileContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [moveModalVisible, setMoveModalVisible] = useState(false)
|
||||
|
|
@ -83,16 +63,43 @@ function DocumentEditor() {
|
|||
const [creationParentPath, setCreationParentPath] = useState('')
|
||||
const [moveTargetPath, setMoveTargetPath] = useState('')
|
||||
const [dirOptions, setDirOptions] = useState([])
|
||||
const [editorHeight, setEditorHeight] = useState(600) // 设置初始高度为600px
|
||||
const [openKeys, setOpenKeys] = useState([]) // Menu组件的展开项
|
||||
const [uploadProgress, setUploadProgress] = useState(0) // 上传进度
|
||||
const [uploading, setUploading] = useState(false) // 是否正在上传
|
||||
const [fileList, setFileList] = useState([]) // 控制上传文件列表
|
||||
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
||||
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
||||
const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件
|
||||
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
||||
const [linkTarget, setLinkTarget] = useState(null)
|
||||
const [projectName, setProjectName] = useState('') // 项目名称
|
||||
const [userRole, setUserRole] = useState('viewer')
|
||||
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
|
||||
const editorCtxRef = useRef(null)
|
||||
const modeSwitchingRef = useRef(false)
|
||||
const isLargeMarkdown = isLargeMarkdownContent(fileContent)
|
||||
|
||||
const isHeaderPdf = selectedFile?.toLowerCase().endsWith('.pdf')
|
||||
const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
|
||||
const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : '请选择文件'
|
||||
|
||||
const linkTreeData = useMemo(() => {
|
||||
const markSelectable = (nodes) => nodes.map((node) => {
|
||||
if (node.children?.length) {
|
||||
return {
|
||||
...node,
|
||||
selectable: false,
|
||||
children: markSelectable(node.children),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
selectable: true,
|
||||
}
|
||||
})
|
||||
|
||||
return markSelectable(treeData)
|
||||
}, [treeData])
|
||||
|
||||
const navigateWithTransition = (to) => {
|
||||
if (document.startViewTransition) {
|
||||
|
|
@ -102,6 +109,153 @@ function DocumentEditor() {
|
|||
navigate(to)
|
||||
}
|
||||
|
||||
const updateSelectedParam = (path, isFile = true) => {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
nextParams.delete('file')
|
||||
nextParams.delete('selected')
|
||||
|
||||
if (path) {
|
||||
nextParams.set(isFile ? 'file' : 'selected', path)
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
}
|
||||
|
||||
const updateFileParam = (filePath) => {
|
||||
updateSelectedParam(filePath, true)
|
||||
}
|
||||
|
||||
const buildDocumentUrl = (filePath) => {
|
||||
const params = new URLSearchParams()
|
||||
const token = localStorage.getItem('access_token')
|
||||
|
||||
if (token) {
|
||||
params.set('token', token)
|
||||
}
|
||||
|
||||
const query = params.toString()
|
||||
return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
const downloadByUrl = (url, filename) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const downloadBlob = (blob, filename) => {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
downloadByUrl(url, filename)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const getSelectedDownloadNode = () => {
|
||||
if (selectedNode) return selectedNode
|
||||
if (selectedMenuKey) return findNodeByKey(treeData, selectedMenuKey)
|
||||
if (selectedFile) return findNodeByKey(treeData, selectedFile)
|
||||
return null
|
||||
}
|
||||
|
||||
const getDownloadFilename = (path) => path.split('/').filter(Boolean).pop() || 'document'
|
||||
|
||||
const downloadMarkdownFile = () => {
|
||||
const filename = getDownloadFilename(selectedFile)
|
||||
downloadBlob(new Blob([fileContent], { type: 'text/markdown;charset=utf-8' }), filename)
|
||||
}
|
||||
|
||||
const handleDownloadSelectedFile = () => {
|
||||
const targetNode = getSelectedDownloadNode()
|
||||
|
||||
if (!targetNode) {
|
||||
Toast.warning('提示', '请先选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!targetNode.isLeaf) {
|
||||
Toast.warning('暂不支持文件夹下载', '请选择 Markdown 或 PDF 文件')
|
||||
return
|
||||
}
|
||||
|
||||
const path = targetNode.key
|
||||
const lowerPath = path.toLowerCase()
|
||||
|
||||
if (!lowerPath.endsWith('.md') && !lowerPath.endsWith('.pdf')) {
|
||||
Toast.warning('暂不支持该文件类型', '请选择 Markdown 或 PDF 文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (lowerPath.endsWith('.md')) {
|
||||
if (selectedFile === path) {
|
||||
downloadMarkdownFile()
|
||||
} else {
|
||||
getFileContent(projectId, path).then((res) => {
|
||||
downloadBlob(
|
||||
new Blob([res.data?.content || ''], { type: 'text/markdown;charset=utf-8' }),
|
||||
getDownloadFilename(path)
|
||||
)
|
||||
}).catch((error) => {
|
||||
console.error('Download markdown error:', error)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
downloadByUrl(buildDocumentUrl(path), getDownloadFilename(path))
|
||||
}
|
||||
|
||||
const selectFolder = (folderPath, { syncUrl = false } = {}) => {
|
||||
const targetNode = findNodeByKey(treeData, folderPath)
|
||||
if (targetNode) {
|
||||
setSelectedNode(targetNode)
|
||||
}
|
||||
setSelectedMenuKey(folderPath)
|
||||
setSelectedFile(null)
|
||||
setIsPdfSelected(false)
|
||||
setFileContent('')
|
||||
|
||||
if (syncUrl) {
|
||||
updateSelectedParam(folderPath, false)
|
||||
}
|
||||
}
|
||||
|
||||
const getModeSwitchTarget = () => {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (selectedFile) {
|
||||
params.set('file', selectedFile)
|
||||
} else if (selectedMenuKey) {
|
||||
const targetNode = findNodeByKey(treeData, selectedMenuKey)
|
||||
if (targetNode && !targetNode.isLeaf) {
|
||||
params.set('selected', selectedMenuKey)
|
||||
}
|
||||
} else {
|
||||
const selectedParam = searchParams.get('selected')
|
||||
if (selectedParam) {
|
||||
params.set('selected', selectedParam)
|
||||
}
|
||||
}
|
||||
|
||||
const query = params.toString()
|
||||
return `/projects/${projectId}/docs${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
const encodeMarkdownLinkTarget = (targetPath) => {
|
||||
if (!targetPath) return targetPath
|
||||
|
||||
return targetPath
|
||||
.split('/')
|
||||
.map((part) => {
|
||||
if (!part || part === '.' || part === '..') {
|
||||
return part
|
||||
}
|
||||
return encodeURIComponent(part)
|
||||
})
|
||||
.join('/')
|
||||
}
|
||||
|
||||
// 插入内链接
|
||||
const handleInsertLink = () => {
|
||||
if (!linkTarget) {
|
||||
|
|
@ -118,7 +272,7 @@ function DocumentEditor() {
|
|||
const fileName = linkTarget.split('/').pop()
|
||||
// 如果没有选中文字,则使用文件名作为链接文字;否则保留原文字
|
||||
const linkTitle = selection || fileName
|
||||
const linkText = `[${linkTitle}](${linkTarget})`
|
||||
const linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})`
|
||||
|
||||
editor.replaceSelection(linkText)
|
||||
editor.focus()
|
||||
|
|
@ -128,24 +282,23 @@ function DocumentEditor() {
|
|||
setLinkTarget(null)
|
||||
}
|
||||
|
||||
// 在组件挂载后立即计算正确的高度
|
||||
useEffect(() => {
|
||||
const calculateHeight = () => {
|
||||
const windowHeight = window.innerHeight
|
||||
const newHeight = Math.max(windowHeight - 180, 400) // 最小高度400px
|
||||
setEditorHeight(newHeight)
|
||||
fetchTree()
|
||||
}, [projectId])
|
||||
|
||||
const fetchTree = async () => {
|
||||
try {
|
||||
const res = await getProjectTree(projectId)
|
||||
const data = res.data || {}
|
||||
const tree = data.tree || data || [] // 兼容新旧格式
|
||||
const name = data.project_name
|
||||
const role = data.user_role || 'viewer'
|
||||
setTreeData(tree)
|
||||
setProjectName(name)
|
||||
setUserRole(role)
|
||||
} catch (error) {
|
||||
Toast.error('加载失败', '加载文件树失败')
|
||||
}
|
||||
|
||||
// 立即执行一次
|
||||
calculateHeight()
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', calculateHeight)
|
||||
return () => window.removeEventListener('resize', calculateHeight)
|
||||
}, [])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await refreshCurrentDocument()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -164,6 +317,105 @@ function DocumentEditor() {
|
|||
})
|
||||
}
|
||||
|
||||
// 查找树节点的辅助函数
|
||||
const findNodeByKey = (nodes, key) => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeByKey(node.children, key)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const expandParentFolders = (path) => {
|
||||
const parts = path.split('/')
|
||||
if (parts.length <= 1) return
|
||||
|
||||
const parentKeys = []
|
||||
let currentPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
parentKeys.push(currentPath)
|
||||
}
|
||||
setOpenKeys(prev => Array.from(new Set([...prev, ...parentKeys])))
|
||||
}
|
||||
|
||||
const openNodeByKey = async (key, node = null, syncUrl = false) => {
|
||||
const targetNode = node || findNodeByKey(treeData, key)
|
||||
if (!targetNode) return
|
||||
|
||||
setSelectedNode(targetNode)
|
||||
setSelectedMenuKey(key)
|
||||
|
||||
if (!targetNode.isLeaf) {
|
||||
setSelectedFile(null)
|
||||
setIsPdfSelected(false)
|
||||
setFileContent('')
|
||||
if (syncUrl) {
|
||||
updateSelectedParam(key, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (syncUrl) {
|
||||
updateFileParam(key)
|
||||
}
|
||||
expandParentFolders(key)
|
||||
|
||||
if (key.toLowerCase().endsWith('.pdf')) {
|
||||
setSelectedFile(key)
|
||||
setIsPdfSelected(true)
|
||||
setFileContent('')
|
||||
return
|
||||
}
|
||||
|
||||
setIsPdfSelected(false)
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getFileContent(projectId, key)
|
||||
setSelectedFile(key)
|
||||
setFileContent(res.data.content)
|
||||
} catch (error) {
|
||||
Toast.error('加载失败', '加载文件失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData.length === 0) return
|
||||
|
||||
const fileParam = searchParams.get('file')
|
||||
const selectedParam = searchParams.get('selected')
|
||||
|
||||
if (selectedParam) {
|
||||
const targetNode = findNodeByKey(treeData, selectedParam)
|
||||
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedMenuKey) {
|
||||
selectFolder(selectedParam)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!fileParam) return
|
||||
if (fileParam === selectedFile) return
|
||||
|
||||
const targetNode = findNodeByKey(treeData, fileParam)
|
||||
if (!targetNode) return
|
||||
|
||||
openNodeByKey(fileParam, targetNode, false)
|
||||
}, [treeData, searchParams])
|
||||
|
||||
// Menu点击处理(适配Menu组件)
|
||||
const handleMenuClick = async ({ key, domEvent }) => {
|
||||
const node = findNodeByKey(treeData, key)
|
||||
if (!node) return
|
||||
await openNodeByKey(key, node, true)
|
||||
}
|
||||
|
||||
// 重置当前编辑内容(重新从服务器加载)
|
||||
const handleReset = () => {
|
||||
if (!selectedFile) return
|
||||
|
|
@ -172,8 +424,16 @@ function DocumentEditor() {
|
|||
title: '确认重置',
|
||||
content: '确定要重置当前修改吗?所有未保存的更改都将丢失。',
|
||||
onOk: async () => {
|
||||
await openNodeByKey(selectedFile, null, false)
|
||||
Toast.success('重置成功', '已恢复至最后保存的版本')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getFileContent(projectId, selectedFile)
|
||||
setFileContent(res.data.content)
|
||||
Toast.success('重置成功', '已恢复至最后保存的版本')
|
||||
} catch (error) {
|
||||
Toast.error('重置失败', '无法重新加载文件内容')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -243,7 +503,8 @@ function DocumentEditor() {
|
|||
})
|
||||
Toast.success('成功', '删除成功')
|
||||
if (selectedFile === path) {
|
||||
clearSelection()
|
||||
setSelectedFile(null)
|
||||
setFileContent('')
|
||||
}
|
||||
fetchTree()
|
||||
} catch (error) {
|
||||
|
|
@ -331,7 +592,8 @@ function DocumentEditor() {
|
|||
|
||||
// 如果重命名的是当前打开的文件,清空编辑器
|
||||
if (operationType === 'rename' && selectedFile === rightClickNode) {
|
||||
clearSelection()
|
||||
setSelectedFile(null)
|
||||
setFileContent('')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Operation error:', error)
|
||||
|
|
@ -536,7 +798,8 @@ function DocumentEditor() {
|
|||
|
||||
// 如果移动的是当前打开的文件,清空编辑器
|
||||
if (selectedFile === rightClickNode) {
|
||||
clearSelection()
|
||||
setSelectedFile(null)
|
||||
setFileContent('')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Move error:', error)
|
||||
|
|
@ -744,23 +1007,30 @@ function DocumentEditor() {
|
|||
menu={{ items: getNodeMenuItems(node) }}
|
||||
trigger={['contextMenu']}
|
||||
>
|
||||
<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
|
||||
// 目录 - 通过className和style控制选中样式
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* 打印样式优化已移除,转向后端生成方案 */
|
||||
/* 打印样式优化已移除,转向后端生成方案 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
Loading…
Reference in New Issue