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
|
||||||
.env.local
|
.env.local
|
||||||
.gemini-clipboard
|
.gemini-clipboard
|
||||||
|
.memsearch
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.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 路由汇总
|
API v1 路由汇总
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter
|
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()
|
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(menu.router, prefix="/menu", tags=["权限菜单"])
|
||||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"])
|
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"])
|
||||||
api_router.include_router(preview.router, prefix="/preview", 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(role_permissions.router, prefix="/role-permissions", tags=["角色权限管理"])
|
||||||
api_router.include_router(users.router, prefix="/users", tags=["用户管理"])
|
api_router.include_router(users.router, prefix="/users", tags=["用户管理"])
|
||||||
api_router.include_router(roles.router, prefix="/roles", 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.core.deps import get_current_user, get_user_from_token_or_query
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.log import OperationLog
|
from app.models.log import OperationLog
|
||||||
|
from app.models.share import ShareLink
|
||||||
from app.schemas.file import (
|
from app.schemas.file import (
|
||||||
FileTreeNode,
|
FileTreeNode,
|
||||||
FileSaveRequest,
|
FileSaveRequest,
|
||||||
|
|
@ -37,6 +38,51 @@ from app.core.enums import OperationType
|
||||||
router = APIRouter()
|
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)
|
@router.get("/{project_id}/tree", response_model=dict)
|
||||||
async def get_project_tree(
|
async def get_project_tree(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
@ -52,6 +98,37 @@ async def get_project_tree(
|
||||||
# 生成目录树
|
# 生成目录树
|
||||||
tree = storage_service.generate_tree(project_root)
|
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={
|
return success_response(data={
|
||||||
"tree": tree,
|
"tree": tree,
|
||||||
"user_role": user_role,
|
"user_role": user_role,
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
"""
|
"""
|
||||||
项目管理相关 API
|
项目管理相关 API
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select, or_
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import uuid
|
||||||
|
import secrets
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.project import Project, ProjectMember
|
from app.models.project import Project, ProjectMember
|
||||||
from app.models.git_repo import ProjectGitRepo
|
from app.models.git_repo import ProjectGitRepo
|
||||||
|
from app.models.share import ShareLink
|
||||||
from app.schemas.project import (
|
from app.schemas.project import (
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
|
|
@ -23,8 +21,6 @@ from app.schemas.project import (
|
||||||
ProjectMemberAdd,
|
ProjectMemberAdd,
|
||||||
ProjectMemberUpdate,
|
ProjectMemberUpdate,
|
||||||
ProjectMemberResponse,
|
ProjectMemberResponse,
|
||||||
ProjectShareSettings,
|
|
||||||
ProjectShareInfo,
|
|
||||||
ProjectTransfer,
|
ProjectTransfer,
|
||||||
)
|
)
|
||||||
from app.schemas.response import success_response
|
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.log_service import log_service
|
||||||
from app.services.git_service import git_service
|
from app.services.git_service import git_service
|
||||||
from app.services.notification_service import notification_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
|
from app.core.enums import OperationType, ResourceType
|
||||||
|
|
||||||
router = APIRouter()
|
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)
|
@router.get("/", response_model=dict)
|
||||||
async def get_my_projects(
|
async def get_my_projects(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
|
@ -71,7 +80,11 @@ async def get_my_projects(
|
||||||
|
|
||||||
# 合并结果
|
# 合并结果
|
||||||
all_projects = owned_projects + member_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)
|
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)
|
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
|
||||||
)
|
)
|
||||||
projects = result.scalars().all()
|
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)
|
return success_response(data=projects_data)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -110,14 +127,12 @@ async def get_shared_projects(
|
||||||
|
|
||||||
projects_data = []
|
projects_data = []
|
||||||
for project, owner, member in projects_with_info:
|
for project, owner, member in projects_with_info:
|
||||||
projects_data.append(
|
project_dict = ProjectResponse.from_orm(project).dict()
|
||||||
serialize_project(
|
project_dict['owner_name'] = owner.username
|
||||||
project,
|
project_dict['owner_nickname'] = owner.nickname
|
||||||
owner_name=owner.username,
|
project_dict['user_role'] = member.role # 添加用户角色
|
||||||
owner_nickname=owner.nickname,
|
project_dict['doc_count'] = get_document_count(project.storage_key)
|
||||||
user_role=member.role,
|
projects_data.append(project_dict)
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return success_response(data=projects_data)
|
return success_response(data=projects_data)
|
||||||
|
|
||||||
|
|
@ -185,12 +200,24 @@ async def get_project(
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取项目详情"""
|
"""获取项目详情"""
|
||||||
project, _ = await require_project_read_access(
|
# 查询项目
|
||||||
db,
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
project_id,
|
project = result.scalar_one_or_none()
|
||||||
current_user,
|
|
||||||
allow_public=True,
|
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
|
project.visit_count += 1
|
||||||
|
|
@ -209,17 +236,59 @@ async def update_project(
|
||||||
db: AsyncSession = Depends(get_db)
|
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:
|
if project.owner_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="无权修改该项目")
|
raise HTTPException(status_code=403, detail="无权修改该项目")
|
||||||
|
|
||||||
|
old_is_public = project.is_public
|
||||||
|
|
||||||
# 更新字段
|
# 更新字段
|
||||||
update_data = project_in.dict(exclude_unset=True)
|
update_data = project_in.dict(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(project, field, value)
|
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.commit()
|
||||||
await db.refresh(project)
|
await db.refresh(project)
|
||||||
|
|
||||||
|
|
@ -246,7 +315,12 @@ async def transfer_project(
|
||||||
db: AsyncSession = Depends(get_db)
|
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:
|
if project.owner_id != current_user.id:
|
||||||
|
|
@ -263,13 +337,25 @@ async def transfer_project(
|
||||||
raise HTTPException(status_code=400, detail="不能转移给自己")
|
raise HTTPException(status_code=400, detail="不能转移给自己")
|
||||||
|
|
||||||
# 1. 如果新所有者已经是成员,删除成员记录
|
# 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:
|
if existing_member:
|
||||||
await db.delete(existing_member)
|
await db.delete(existing_member)
|
||||||
|
|
||||||
# 2. 将旧所有者添加为管理员成员
|
# 2. 将旧所有者添加为管理员成员
|
||||||
# 检查旧所有者是否已经在member表中(理论上owner不在member表中,但为了健壮性检查一下)
|
# 检查旧所有者是否已经在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(
|
old_owner_member = ProjectMember(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
|
@ -307,69 +393,6 @@ async def transfer_project(
|
||||||
return success_response(message="项目所有权转移成功")
|
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)
|
@router.delete("/{project_id}", response_model=dict)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
@ -378,7 +401,12 @@ async def delete_project(
|
||||||
db: AsyncSession = Depends(get_db)
|
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:
|
if project.owner_id != current_user.id:
|
||||||
|
|
@ -439,7 +467,24 @@ async def get_project_members(
|
||||||
db: AsyncSession = Depends(get_db)
|
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(
|
members_result = await db.execute(
|
||||||
|
|
@ -474,13 +519,25 @@ async def add_project_member(
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""添加项目成员"""
|
"""添加项目成员"""
|
||||||
project, _ = await require_project_roles(
|
# 查询项目
|
||||||
db,
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
project_id,
|
project = result.scalar_one_or_none()
|
||||||
current_user,
|
|
||||||
allowed_roles=["admin"],
|
if not project:
|
||||||
forbidden_detail="无权添加成员",
|
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(
|
existing_result = await db.execute(
|
||||||
|
|
@ -540,13 +597,25 @@ async def remove_project_member(
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""删除项目成员"""
|
"""删除项目成员"""
|
||||||
project, _ = await require_project_roles(
|
# 查询项目
|
||||||
db,
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
project_id,
|
project = result.scalar_one_or_none()
|
||||||
current_user,
|
|
||||||
allowed_roles=["admin"],
|
if not project:
|
||||||
forbidden_detail="无权删除成员",
|
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:
|
if user_id == project.owner_id:
|
||||||
|
|
@ -581,66 +650,6 @@ async def remove_project_member(
|
||||||
return success_response(message="成员删除成功")
|
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)
|
@router.post("/{project_id}/git/pull", response_model=dict)
|
||||||
async def git_pull(
|
async def git_pull(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
@ -651,13 +660,24 @@ async def git_pull(
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""执行 Git Pull"""
|
"""执行 Git Pull"""
|
||||||
project, _ = await require_project_roles(
|
# 查询项目
|
||||||
db,
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
project_id,
|
project = result.scalar_one_or_none()
|
||||||
current_user,
|
|
||||||
allowed_roles=["admin", "editor"],
|
if not project:
|
||||||
forbidden_detail="无权执行Git操作",
|
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仓库配置
|
# 获取Git仓库配置
|
||||||
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
|
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
|
||||||
|
|
@ -724,13 +744,24 @@ async def git_push(
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""执行 Git Push"""
|
"""执行 Git Push"""
|
||||||
project, _ = await require_project_roles(
|
# 查询项目
|
||||||
db,
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
project_id,
|
project = result.scalar_one_or_none()
|
||||||
current_user,
|
|
||||||
allowed_roles=["admin", "editor"],
|
if not project:
|
||||||
forbidden_detail="无权执行Git操作",
|
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仓库配置
|
# 获取Git仓库配置
|
||||||
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
|
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"
|
UPDATE_SHARE_SETTINGS = "update_share_settings"
|
||||||
|
CREATE_SHARE_LINK = "create_share_link"
|
||||||
|
DELETE_SHARE_LINK = "delete_share_link"
|
||||||
|
|
||||||
# Git操作
|
# Git操作
|
||||||
GIT_PULL = "git_pull"
|
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.menu import SystemMenu, RoleMenu
|
||||||
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
||||||
from app.models.document import DocumentMeta
|
from app.models.document import DocumentMeta
|
||||||
|
from app.models.share import ShareLink
|
||||||
from app.models.log import OperationLog
|
from app.models.log import OperationLog
|
||||||
from app.models.mcp_bot import MCPBot
|
from app.models.mcp_bot import MCPBot
|
||||||
from app.models.llm_model_config import LLMModelConfig
|
from app.models.llm_model_config import LLMModelConfig
|
||||||
|
|
@ -22,6 +23,7 @@ __all__ = [
|
||||||
"ProjectMember",
|
"ProjectMember",
|
||||||
"ProjectMemberRole",
|
"ProjectMemberRole",
|
||||||
"DocumentMeta",
|
"DocumentMeta",
|
||||||
|
"ShareLink",
|
||||||
"OperationLog",
|
"OperationLog",
|
||||||
"MCPBot",
|
"MCPBot",
|
||||||
"LLMModelConfig",
|
"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="节点标题(文件/文件夹名)")
|
title: str = Field(..., description="节点标题(文件/文件夹名)")
|
||||||
key: str = Field(..., description="节点唯一键(相对路径)")
|
key: str = Field(..., description="节点唯一键(相对路径)")
|
||||||
isLeaf: bool = Field(..., description="是否叶子节点")
|
isLeaf: bool = Field(..., description="是否叶子节点")
|
||||||
|
is_shared: bool = Field(False, description="当前文件是否已创建分享链接")
|
||||||
children: Optional[List['FileTreeNode']] = Field(None, description="子节点")
|
children: Optional[List['FileTreeNode']] = Field(None, description="子节点")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class ProjectUpdate(BaseModel):
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
is_public: Optional[int] = None
|
is_public: Optional[int] = None
|
||||||
|
public_access_pass: Optional[str] = Field(None, max_length=100)
|
||||||
cover_image: Optional[str] = None
|
cover_image: Optional[str] = None
|
||||||
status: Optional[int] = None
|
status: Optional[int] = None
|
||||||
|
|
||||||
|
|
@ -80,14 +81,29 @@ class ProjectMemberResponse(BaseModel):
|
||||||
from_attributes = True
|
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):
|
class ProjectShareSettings(BaseModel):
|
||||||
"""项目分享设置 Schema"""
|
"""项目分享设置 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):
|
class FileShareCreate(BaseModel):
|
||||||
"""项目分享信息响应 Schema"""
|
"""文件分享创建/更新 Schema"""
|
||||||
share_url: str = Field(..., description="分享链接")
|
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="是否设置了访问密码")
|
has_password: bool = Field(..., description="是否设置了访问密码")
|
||||||
access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)")
|
access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials';
|
) 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
|
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
|
||||||
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
|
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import DocumentEditor from '@/pages/Document/DocumentEditor'
|
||||||
import Dashboard from '@/pages/Dashboard'
|
import Dashboard from '@/pages/Dashboard'
|
||||||
import Desktop from '@/pages/Desktop'
|
import Desktop from '@/pages/Desktop'
|
||||||
import Constructing from '@/pages/Constructing'
|
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 ProfilePage from '@/pages/Profile/ProfilePage'
|
||||||
import Permissions from '@/pages/System/Permissions'
|
import Permissions from '@/pages/System/Permissions'
|
||||||
import Users from '@/pages/System/Users'
|
import Users from '@/pages/System/Users'
|
||||||
|
|
@ -62,8 +63,8 @@ function App() {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
{/* 项目预览(公开访问,无需登录) */}
|
<Route path="/share/project/:shareCode" element={<ProjectSharePage />} />
|
||||||
<Route path="/preview/:projectId" element={<PreviewPage />} />
|
<Route path="/share/file/:shareCode" element={<FileSharePage />} />
|
||||||
|
|
||||||
{/* 使用共享布局的路由 */}
|
{/* 使用共享布局的路由 */}
|
||||||
<Route element={<ProtectedRoute><LayoutWrapper /></ProtectedRoute>}>
|
<Route element={<ProtectedRoute><LayoutWrapper /></ProtectedRoute>}>
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,57 @@
|
||||||
/**
|
/**
|
||||||
* 项目分享和预览相关 API
|
* 分享相关 API
|
||||||
*/
|
*/
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取项目分享信息
|
|
||||||
*/
|
|
||||||
export function getProjectShareInfo(projectId) {
|
export function getProjectShareInfo(projectId) {
|
||||||
return request({
|
return request({
|
||||||
url: `/projects/${projectId}/share`,
|
url: `/shares/projects/${projectId}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function updateProjectShareSettings(projectId, data) {
|
||||||
* 更新分享设置(设置或取消访问密码)
|
|
||||||
*/
|
|
||||||
export function updateShareSettings(projectId, data) {
|
|
||||||
return request({
|
return request({
|
||||||
url: `/projects/${projectId}/share/settings`,
|
url: `/shares/projects/${projectId}/settings`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getFileShareInfo(projectId, filePath) {
|
||||||
* 获取预览项目基本信息(公开访问)
|
|
||||||
*/
|
|
||||||
export function getPreviewInfo(projectId) {
|
|
||||||
return request({
|
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',
|
method: 'get',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function verifyProjectSharePassword(shareCode, password) {
|
||||||
* 验证访问密码
|
|
||||||
*/
|
|
||||||
export function verifyAccessPassword(projectId, password) {
|
|
||||||
return request({
|
return request({
|
||||||
url: `/preview/${projectId}/verify`,
|
url: `/shares/project/${shareCode}/verify`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Access-Password': password,
|
'X-Access-Password': password,
|
||||||
|
|
@ -47,42 +59,71 @@ export function verifyAccessPassword(projectId, password) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getProjectShareTree(shareCode, password = null) {
|
||||||
* 获取预览项目的文档树
|
|
||||||
*/
|
|
||||||
export function getPreviewTree(projectId, password = null) {
|
|
||||||
return request({
|
return request({
|
||||||
url: `/preview/${projectId}/tree`,
|
url: `/shares/project/${shareCode}/tree`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
headers: password ? { 'X-Access-Password': password } : {},
|
headers: password ? { 'X-Access-Password': password } : {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function searchProjectShareDocuments(shareCode, keyword, password = null) {
|
||||||
* 获取预览项目的文件内容
|
|
||||||
*/
|
|
||||||
export function getPreviewFile(projectId, path, password = null) {
|
|
||||||
return request({
|
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',
|
method: 'get',
|
||||||
params: { path },
|
params: { path },
|
||||||
headers: password ? { 'X-Access-Password': password } : {},
|
headers: password ? { 'X-Access-Password': password } : {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getProjectShareDocumentUrl(shareCode, path) {
|
||||||
* 获取预览项目的文档文件URL(PDF等)
|
|
||||||
*/
|
|
||||||
export function getPreviewDocumentUrl(projectId, path) {
|
|
||||||
// 将路径的每个部分分别编码,但保留斜杠
|
|
||||||
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||||
return `/api/v1/preview/${projectId}/document/${encodedPath}`
|
return `/api/v1/shares/project/${shareCode}/document/${encodedPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function exportProjectSharePDF(shareCode, path) {
|
||||||
* 导出 PDF
|
|
||||||
*/
|
|
||||||
export function exportPDF(projectId, path) {
|
|
||||||
const encodedPath = encodeURIComponent(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;
|
height: 64px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左侧区域 */
|
/* 左侧区域 */
|
||||||
|
|
@ -55,6 +56,50 @@
|
||||||
gap: 16px;
|
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 */
|
/* Icon Buttons */
|
||||||
.header-icon-btn {
|
.header-icon-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
NotificationOutlined,
|
NotificationOutlined,
|
||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
SunOutlined,
|
SunOutlined,
|
||||||
GlobalOutlined
|
AppstoreOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import useUserStore from '@/stores/userStore'
|
import useUserStore from '@/stores/userStore'
|
||||||
import useNotificationStore from '@/stores/notificationStore'
|
import useNotificationStore from '@/stores/notificationStore'
|
||||||
|
|
@ -151,7 +151,12 @@ function AppHeader({ collapsed, onToggle, showLogo = true }) {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="header-right">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Layout } from 'antd'
|
import { Layout } from 'antd'
|
||||||
import AppSider from './AppSider'
|
import AppSider from './AppSider'
|
||||||
import AppHeader from './AppHeader'
|
import AppHeader from './AppHeader'
|
||||||
|
|
@ -6,9 +7,19 @@ import './MainLayout.css'
|
||||||
|
|
||||||
const { Content } = Layout
|
const { Content } = Layout
|
||||||
|
|
||||||
|
// 进入项目文档页/编辑页时自动折叠全局侧边栏,给文档内容腾出空间
|
||||||
|
const COLLAPSE_PATTERNS = [/^\/projects\/[^/]+\/docs/, /^\/projects\/[^/]+\/editor/]
|
||||||
|
|
||||||
function MainLayout({ children }) {
|
function MainLayout({ children }) {
|
||||||
|
const location = useLocation()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (COLLAPSE_PATTERNS.some((pattern) => pattern.test(location.pathname))) {
|
||||||
|
setCollapsed(true)
|
||||||
|
}
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
const toggleCollapsed = () => {
|
const toggleCollapsed = () => {
|
||||||
setCollapsed(!collapsed)
|
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;
|
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 {
|
body.dark .mode-switch {
|
||||||
background: linear-gradient(180deg, #2e3748 0%, #252d3b 100%);
|
background: linear-gradient(180deg, #2e3748 0%, #252d3b 100%);
|
||||||
border-color: rgba(137, 156, 186, 0.24);
|
border-color: rgba(137, 156, 186, 0.24);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ function ModeSwitch({
|
||||||
editLabel = '编辑',
|
editLabel = '编辑',
|
||||||
options,
|
options,
|
||||||
ariaLabel = '模式切换',
|
ariaLabel = '模式切换',
|
||||||
|
size = 'default',
|
||||||
}) {
|
}) {
|
||||||
const finalOptions = options || [
|
const finalOptions = options || [
|
||||||
{ label: viewLabel, value: 'view' },
|
{ label: viewLabel, value: 'view' },
|
||||||
|
|
@ -19,7 +20,7 @@ function ModeSwitch({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mode-switch"
|
className={`mode-switch mode-switch-${size}`}
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -30,39 +30,31 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapse Trigger */
|
.sidebar-collapse-trigger {
|
||||||
.collapse-trigger {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -14px;
|
right: -13px;
|
||||||
top: 28px;
|
top: 32px;
|
||||||
width: 28px;
|
z-index: 20;
|
||||||
height: 28px;
|
width: 26px;
|
||||||
background: var(--bg-color);
|
height: 26px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 50%;
|
border-radius: 999px;
|
||||||
display: flex;
|
background: var(--header-bg);
|
||||||
|
color: #8a94a6;
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 21, 41, 0.08);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 10;
|
transform: translateY(-50%);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
transition: all 0.2s;
|
||||||
color: var(--text-color-secondary);
|
font-size: 10px;
|
||||||
font-size: 12px;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modern-sidebar:hover .collapse-trigger,
|
.sidebar-collapse-trigger:hover {
|
||||||
.collapse-trigger:focus {
|
color: #1677ff;
|
||||||
opacity: 1;
|
border-color: rgba(22, 119, 255, 0.28);
|
||||||
}
|
background: var(--header-bg);
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Menu Area */
|
/* Menu Area */
|
||||||
|
|
@ -188,6 +180,12 @@
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 折叠状态下给头像留足空间 */
|
||||||
|
.ant-layout-sider-collapsed .user-card {
|
||||||
|
padding: 12px 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { Layout, Avatar, Tooltip, Button } from 'antd';
|
import { Layout, Avatar, Tooltip } from 'antd';
|
||||||
import {
|
import {
|
||||||
MenuUnfoldOutlined,
|
|
||||||
MenuFoldOutlined,
|
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
QuestionCircleOutlined,
|
QuestionCircleOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
LeftOutlined
|
LeftOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import './ModernSidebar.css';
|
import './ModernSidebar.css';
|
||||||
|
|
||||||
|
|
@ -81,13 +79,14 @@ const ModernSidebar = ({
|
||||||
<div className="logo-container">
|
<div className="logo-container">
|
||||||
{logo}
|
{logo}
|
||||||
</div>
|
</div>
|
||||||
{/* 折叠按钮 - 悬浮在边缘 */}
|
<button
|
||||||
<div
|
type="button"
|
||||||
className="collapse-trigger"
|
className="sidebar-collapse-trigger"
|
||||||
onClick={() => onCollapse && onCollapse(!collapsed)}
|
onClick={() => onCollapse && onCollapse(!collapsed)}
|
||||||
|
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||||
>
|
>
|
||||||
{collapsed ? <RightOutlined /> : <LeftOutlined />}
|
{collapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 菜单列表区域 */}
|
{/* 菜单列表区域 */}
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,56 @@
|
||||||
|
|
||||||
.pdf-toolbar {
|
.pdf-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 16px;
|
gap: 16px;
|
||||||
background: var(--card-bg);
|
min-width: 0;
|
||||||
border-bottom: 1px solid var(--border-color);
|
background: transparent;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
color: var(--text-color);
|
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 {
|
.pdf-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
position: relative;
|
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 {
|
.pdf-virtual-list {
|
||||||
background: var(--bg-color-secondary);
|
background: var(--bg-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { Document, Page, pdfjs } from 'react-pdf'
|
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 {
|
import {
|
||||||
ZoomInOutlined,
|
ZoomInOutlined,
|
||||||
ZoomOutOutlined,
|
ZoomOutOutlined,
|
||||||
|
|
@ -16,7 +17,7 @@ import './VirtualPDFViewer.css'
|
||||||
// 配置 PDF.js worker
|
// 配置 PDF.js worker
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
|
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 [numPages, setNumPages] = useState(null)
|
||||||
const [scale, setScale] = useState(1.0)
|
const [scale, setScale] = useState(1.0)
|
||||||
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4
|
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4
|
||||||
|
|
@ -159,65 +160,90 @@ function VirtualPDFViewer({ url, filename }) {
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const toolbar = compactToolbar ? (
|
||||||
<div className="virtual-pdf-viewer-container">
|
<div className="pdf-toolbar pdf-toolbar-compact">
|
||||||
{/* 工具栏 */}
|
<Space size={4}>
|
||||||
<div className="pdf-toolbar">
|
<Tooltip title="回到顶部">
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
icon={<VerticalAlignTopOutlined />}
|
icon={<VerticalAlignTopOutlined />}
|
||||||
onClick={scrollToTop}
|
onClick={scrollToTop}
|
||||||
size="small"
|
size="small"
|
||||||
|
type="text"
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
>
|
aria-label="回到顶部"
|
||||||
回到顶部
|
/>
|
||||||
</Button>
|
</Tooltip>
|
||||||
|
<Tooltip title="下载PDF">
|
||||||
<Button
|
<Button
|
||||||
icon={<CloudDownloadOutlined />}
|
icon={<CloudDownloadOutlined />}
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
type="text"
|
||||||
下载PDF
|
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>
|
</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>
|
return (
|
||||||
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
|
<div className="virtual-pdf-viewer-container">
|
||||||
缩小
|
{toolbarTarget ? createPortal(toolbar, toolbarTarget) : toolbar}
|
||||||
</Button>
|
|
||||||
<span style={{ minWidth: 50, textAlign: 'center' }}>
|
|
||||||
{Math.round(scale * 100)}%
|
|
||||||
</span>
|
|
||||||
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
|
|
||||||
放大
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PDF内容区 - 自定义虚拟滚动 */}
|
{/* PDF内容区 - 自定义虚拟滚动 */}
|
||||||
<div className="pdf-content" ref={containerRef}>
|
<div className="pdf-content" ref={containerRef}>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
@import '@/components/LargeMarkdownViewer/LargeMarkdownViewer.css';
|
||||||
|
|
||||||
.document-editor-page {
|
.document-editor-page {
|
||||||
height: calc(100vh - 64px);
|
height: calc(100vh - 64px);
|
||||||
/* width: calc(100% + 32px); */
|
/* width: calc(100% + 32px); */
|
||||||
|
|
@ -33,20 +35,63 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-header {
|
.sider-header {
|
||||||
padding: 16px 20px;
|
padding: 12px 10px 12px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
background: var(--header-bg);
|
background: var(--header-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-header h2 {
|
.sider-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
color: var(--text-color);
|
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 {
|
.sider-actions {
|
||||||
|
|
@ -91,48 +136,20 @@
|
||||||
color: var(--text-color);
|
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 {
|
.file-tree .ant-menu-title-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex: 1;
|
|
||||||
/* Ensure it allows children to fill width */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Increase hit area for context menu */
|
/* Increase hit area for context menu */
|
||||||
.tree-node-wrapper {
|
.tree-node-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
margin: -4px -8px;
|
margin: -4px -8px;
|
||||||
|
|
@ -141,6 +158,14 @@
|
||||||
color: var(--text-color);
|
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 {
|
.file-tree .folder-selected>.ant-menu-submenu-title {
|
||||||
background-color: var(--item-hover-bg) !important;
|
background-color: var(--item-hover-bg) !important;
|
||||||
|
|
@ -155,8 +180,8 @@
|
||||||
.file-tree .ant-menu-submenu-title {
|
.file-tree .ant-menu-submenu-title {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
/* Ensure flex layout for item */
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-content {
|
.document-content {
|
||||||
|
|
@ -183,8 +208,8 @@
|
||||||
min-height: 57px;
|
min-height: 57px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
background: var(--header-bg);
|
background: var(--header-bg);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -212,8 +237,14 @@
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix for bytemd-react wrapper div */
|
.bytemd-wrapper.large-file-editor {
|
||||||
.bytemd-wrapper>div {
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for bytemd-react wrapper div(仅普通编辑器,超大着色编辑器不受此约束) */
|
||||||
|
.bytemd-wrapper:not(.large-file-editor)>div {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -263,6 +294,11 @@
|
||||||
background-color: var(--item-hover-bg);
|
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 {
|
.bytemd-body {
|
||||||
flex: 1 !important;
|
flex: 1 !important;
|
||||||
|
|
@ -277,15 +313,10 @@
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑区域 - 固定50%宽度 */
|
/* 编辑区域 - 默认分栏,保留 Bytemd 内联样式对仅编辑/仅预览的控制 */
|
||||||
.bytemd-editor {
|
.bytemd-editor {
|
||||||
width: 50% !important;
|
|
||||||
flex: 0 0 50% !important;
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-width: 50% !important;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
/* Added for consistent box model */
|
/* Added for consistent box model */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -294,16 +325,13 @@
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 预览区域 - 固定50%宽度 */
|
/* 预览区域 - 默认分栏,保留 Bytemd 内联样式对仅编辑/仅预览的控制 */
|
||||||
.bytemd-preview {
|
.bytemd-preview {
|
||||||
width: 50% !important;
|
|
||||||
flex: 0 0 50% !important;
|
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
max-width: 50% !important;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
/* Added for consistent box model */
|
/* Added for consistent box model */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -434,16 +462,10 @@
|
||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 文件名过长时显示省略号,不折行 */
|
.content-header .preview-header-title {
|
||||||
.content-header h3 {
|
flex: 1;
|
||||||
margin: 0;
|
min-width: 0;
|
||||||
font-size: 16px;
|
max-width: none;
|
||||||
font-weight: 600;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 600px;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,19 @@ import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, S
|
||||||
import {
|
import {
|
||||||
FileOutlined,
|
FileOutlined,
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
PlusOutlined,
|
FolderOpenOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
FileAddOutlined,
|
FileAddOutlined,
|
||||||
FolderAddOutlined,
|
FolderAddOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
ReloadOutlined,
|
|
||||||
FileImageOutlined,
|
|
||||||
FilePdfOutlined,
|
FilePdfOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
UndoOutlined,
|
UndoOutlined,
|
||||||
CloseOutlined,
|
ArrowLeftOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Editor } from '@bytemd/react'
|
import { Editor } from '@bytemd/react'
|
||||||
import gfm from '@bytemd/plugin-gfm'
|
import gfm from '@bytemd/plugin-gfm'
|
||||||
|
|
@ -28,16 +27,19 @@ import gemoji from '@bytemd/plugin-gemoji'
|
||||||
import 'bytemd/dist/index.css'
|
import 'bytemd/dist/index.css'
|
||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
import {
|
import {
|
||||||
|
getProjectTree,
|
||||||
|
getFileContent,
|
||||||
saveFile,
|
saveFile,
|
||||||
operateFile,
|
operateFile,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
importDocuments,
|
importDocuments,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
|
getDocumentUrl,
|
||||||
} from '@/api/file'
|
} from '@/api/file'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
||||||
import { findNodeByKey } from './documentBrowserUtils'
|
import { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
||||||
import useDocumentEditorWorkspace from './useDocumentEditorWorkspace'
|
import LargeMarkdownEditor from '@/components/LargeMarkdownViewer/LargeMarkdownEditor'
|
||||||
import './DocumentEditor.css'
|
import './DocumentEditor.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
|
@ -47,33 +49,11 @@ function DocumentEditor() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const {
|
const [treeData, setTreeData] = useState([])
|
||||||
treeData,
|
const [selectedFile, setSelectedFile] = useState(null)
|
||||||
selectedFile,
|
const [selectedNode, setSelectedNode] = useState(null) // 当前选中的节点(可能是文件或目录)
|
||||||
selectedNode,
|
const [fileContent, setFileContent] = useState('')
|
||||||
fileContent,
|
const [loading, setLoading] = useState(false)
|
||||||
loading,
|
|
||||||
openKeys,
|
|
||||||
selectedMenuKey,
|
|
||||||
isPdfSelected,
|
|
||||||
refreshing,
|
|
||||||
projectName,
|
|
||||||
userRole,
|
|
||||||
setOpenKeys,
|
|
||||||
setSelectedNode,
|
|
||||||
setSelectedFile,
|
|
||||||
setSelectedMenuKey,
|
|
||||||
setFileContent,
|
|
||||||
fetchTree,
|
|
||||||
clearSelection,
|
|
||||||
openNodeByKey,
|
|
||||||
handleMenuClick,
|
|
||||||
refreshCurrentDocument,
|
|
||||||
} = useDocumentEditorWorkspace({
|
|
||||||
projectId,
|
|
||||||
searchParams,
|
|
||||||
setSearchParams,
|
|
||||||
})
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [modalVisible, setModalVisible] = useState(false)
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
const [moveModalVisible, setMoveModalVisible] = useState(false)
|
const [moveModalVisible, setMoveModalVisible] = useState(false)
|
||||||
|
|
@ -83,16 +63,43 @@ function DocumentEditor() {
|
||||||
const [creationParentPath, setCreationParentPath] = useState('')
|
const [creationParentPath, setCreationParentPath] = useState('')
|
||||||
const [moveTargetPath, setMoveTargetPath] = useState('')
|
const [moveTargetPath, setMoveTargetPath] = useState('')
|
||||||
const [dirOptions, setDirOptions] = useState([])
|
const [dirOptions, setDirOptions] = useState([])
|
||||||
const [editorHeight, setEditorHeight] = useState(600) // 设置初始高度为600px
|
const [openKeys, setOpenKeys] = useState([]) // Menu组件的展开项
|
||||||
const [uploadProgress, setUploadProgress] = useState(0) // 上传进度
|
const [uploadProgress, setUploadProgress] = useState(0) // 上传进度
|
||||||
const [uploading, setUploading] = useState(false) // 是否正在上传
|
const [uploading, setUploading] = useState(false) // 是否正在上传
|
||||||
const [fileList, setFileList] = useState([]) // 控制上传文件列表
|
const [fileList, setFileList] = useState([]) // 控制上传文件列表
|
||||||
|
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
||||||
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
||||||
|
const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件
|
||||||
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
||||||
const [linkTarget, setLinkTarget] = useState(null)
|
const [linkTarget, setLinkTarget] = useState(null)
|
||||||
|
const [projectName, setProjectName] = useState('') // 项目名称
|
||||||
|
const [userRole, setUserRole] = useState('viewer')
|
||||||
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
|
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
|
||||||
const editorCtxRef = useRef(null)
|
const editorCtxRef = useRef(null)
|
||||||
const modeSwitchingRef = useRef(false)
|
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) => {
|
const navigateWithTransition = (to) => {
|
||||||
if (document.startViewTransition) {
|
if (document.startViewTransition) {
|
||||||
|
|
@ -102,6 +109,153 @@ function DocumentEditor() {
|
||||||
navigate(to)
|
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 = () => {
|
const handleInsertLink = () => {
|
||||||
if (!linkTarget) {
|
if (!linkTarget) {
|
||||||
|
|
@ -118,7 +272,7 @@ function DocumentEditor() {
|
||||||
const fileName = linkTarget.split('/').pop()
|
const fileName = linkTarget.split('/').pop()
|
||||||
// 如果没有选中文字,则使用文件名作为链接文字;否则保留原文字
|
// 如果没有选中文字,则使用文件名作为链接文字;否则保留原文字
|
||||||
const linkTitle = selection || fileName
|
const linkTitle = selection || fileName
|
||||||
const linkText = `[${linkTitle}](${linkTarget})`
|
const linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})`
|
||||||
|
|
||||||
editor.replaceSelection(linkText)
|
editor.replaceSelection(linkText)
|
||||||
editor.focus()
|
editor.focus()
|
||||||
|
|
@ -128,24 +282,23 @@ function DocumentEditor() {
|
||||||
setLinkTarget(null)
|
setLinkTarget(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在组件挂载后立即计算正确的高度
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const calculateHeight = () => {
|
fetchTree()
|
||||||
const windowHeight = window.innerHeight
|
}, [projectId])
|
||||||
const newHeight = Math.max(windowHeight - 180, 400) // 最小高度400px
|
|
||||||
setEditorHeight(newHeight)
|
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 = () => {
|
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 = () => {
|
const handleReset = () => {
|
||||||
if (!selectedFile) return
|
if (!selectedFile) return
|
||||||
|
|
@ -172,8 +424,16 @@ function DocumentEditor() {
|
||||||
title: '确认重置',
|
title: '确认重置',
|
||||||
content: '确定要重置当前修改吗?所有未保存的更改都将丢失。',
|
content: '确定要重置当前修改吗?所有未保存的更改都将丢失。',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await openNodeByKey(selectedFile, null, false)
|
setLoading(true)
|
||||||
Toast.success('重置成功', '已恢复至最后保存的版本')
|
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('成功', '删除成功')
|
Toast.success('成功', '删除成功')
|
||||||
if (selectedFile === path) {
|
if (selectedFile === path) {
|
||||||
clearSelection()
|
setSelectedFile(null)
|
||||||
|
setFileContent('')
|
||||||
}
|
}
|
||||||
fetchTree()
|
fetchTree()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -331,7 +592,8 @@ function DocumentEditor() {
|
||||||
|
|
||||||
// 如果重命名的是当前打开的文件,清空编辑器
|
// 如果重命名的是当前打开的文件,清空编辑器
|
||||||
if (operationType === 'rename' && selectedFile === rightClickNode) {
|
if (operationType === 'rename' && selectedFile === rightClickNode) {
|
||||||
clearSelection()
|
setSelectedFile(null)
|
||||||
|
setFileContent('')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Operation error:', error)
|
console.error('Operation error:', error)
|
||||||
|
|
@ -536,7 +798,8 @@ function DocumentEditor() {
|
||||||
|
|
||||||
// 如果移动的是当前打开的文件,清空编辑器
|
// 如果移动的是当前打开的文件,清空编辑器
|
||||||
if (selectedFile === rightClickNode) {
|
if (selectedFile === rightClickNode) {
|
||||||
clearSelection()
|
setSelectedFile(null)
|
||||||
|
setFileContent('')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Move error:', error)
|
console.error('Move error:', error)
|
||||||
|
|
@ -744,23 +1007,30 @@ function DocumentEditor() {
|
||||||
menu={{ items: getNodeMenuItems(node) }}
|
menu={{ items: getNodeMenuItems(node) }}
|
||||||
trigger={['contextMenu']}
|
trigger={['contextMenu']}
|
||||||
>
|
>
|
||||||
<div className="tree-node-wrapper">
|
<Tooltip title={node.title} placement="right">
|
||||||
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
|
<div className="tree-node-wrapper">
|
||||||
</div>
|
<span className="tree-node-text">
|
||||||
|
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!node.isLeaf) {
|
if (!node.isLeaf) {
|
||||||
|
const isOpen = openKeys.includes(node.key)
|
||||||
|
const folderIconStyle = isSelected ? { color: '#1890ff' } : undefined
|
||||||
// 目录 - 通过className和style控制选中样式
|
// 目录 - 通过className和style控制选中样式
|
||||||
return {
|
return {
|
||||||
key: node.key,
|
key: node.key,
|
||||||
label: labelContent,
|
label: labelContent,
|
||||||
icon: <FolderOutlined style={isSelected ? { color: '#1890ff' } : {}} />,
|
icon: isOpen
|
||||||
|
? <FolderOpenOutlined style={folderIconStyle} />
|
||||||
|
: <FolderOutlined style={folderIconStyle} />,
|
||||||
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
||||||
className: isSelected ? 'folder-selected' : '',
|
className: isSelected ? 'folder-selected' : '',
|
||||||
onTitleClick: () => {
|
onTitleClick: () => {
|
||||||
setSelectedNode(node)
|
selectFolder(node.key, { syncUrl: true })
|
||||||
setSelectedMenuKey(node.key)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if (node.title && node.title.endsWith('.md')) {
|
} else if (node.title && node.title.endsWith('.md')) {
|
||||||
|
|
@ -792,54 +1062,40 @@ function DocumentEditor() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="document-editor-page">
|
<div className="document-editor-page">
|
||||||
<Layout className="document-editor-container document-workspace-frame">
|
<Layout className="document-editor-container">
|
||||||
<Sider
|
<Sider
|
||||||
width={280}
|
width={280}
|
||||||
theme="light"
|
theme="light"
|
||||||
className="document-sider"
|
className="document-sider"
|
||||||
>
|
>
|
||||||
<div className="sider-header">
|
<div className="sider-header">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div className="sider-title-row">
|
||||||
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
|
<button
|
||||||
{projectName}
|
type="button"
|
||||||
</h2>
|
className="project-back-button"
|
||||||
<Tooltip title="关闭">
|
onClick={handleClose}
|
||||||
<Button
|
aria-label="返回项目列表"
|
||||||
type="text"
|
>
|
||||||
icon={<CloseOutlined />}
|
<ArrowLeftOutlined />
|
||||||
onClick={handleClose}
|
</button>
|
||||||
style={{ marginLeft: 8 }}
|
<h2 title={projectName}>{projectName}</h2>
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="sider-actions">
|
<div className="sider-actions">
|
||||||
<div className="mode-actions-row">
|
<div className="mode-actions-row">
|
||||||
<ModeSwitch
|
<ModeSwitch
|
||||||
|
size="small"
|
||||||
value={modeSwitchValue}
|
value={modeSwitchValue}
|
||||||
onChange={(mode) => {
|
onChange={(mode) => {
|
||||||
if (mode === 'view' && !modeSwitchingRef.current) {
|
if (mode === 'view' && !modeSwitchingRef.current) {
|
||||||
modeSwitchingRef.current = true
|
modeSwitchingRef.current = true
|
||||||
setModeSwitchValue('view')
|
setModeSwitchValue('view')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const params = new URLSearchParams()
|
navigateWithTransition(getModeSwitchTarget())
|
||||||
if (selectedFile) {
|
|
||||||
params.set('file', selectedFile)
|
|
||||||
}
|
|
||||||
const query = params.toString()
|
|
||||||
navigateWithTransition(`/projects/${projectId}/docs${query ? `?${query}` : ''}`)
|
|
||||||
}, 160)
|
}, 160)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Space.Compact className="mode-actions-group">
|
<Space.Compact className="mode-actions-group">
|
||||||
<Tooltip title="刷新">
|
|
||||||
<Button
|
|
||||||
size="middle"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
loading={refreshing}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="添加文件">
|
<Tooltip title="添加文件">
|
||||||
<Button
|
<Button
|
||||||
size="middle"
|
size="middle"
|
||||||
|
|
@ -869,6 +1125,13 @@ function DocumentEditor() {
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Upload>
|
</Upload>
|
||||||
|
<Tooltip title="下载选中文件">
|
||||||
|
<Button
|
||||||
|
size="middle"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={handleDownloadSelectedFile}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -893,7 +1156,10 @@ function DocumentEditor() {
|
||||||
|
|
||||||
<Content className="document-content">
|
<Content className="document-content">
|
||||||
<div className="content-header">
|
<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>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|
@ -921,23 +1187,30 @@ function DocumentEditor() {
|
||||||
</div>
|
</div>
|
||||||
) : selectedFile ? (
|
) : selectedFile ? (
|
||||||
<div
|
<div
|
||||||
className="bytemd-wrapper"
|
className={`bytemd-wrapper ${isLargeMarkdown ? 'large-file-editor' : ''}`}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Editor
|
{isLargeMarkdown ? (
|
||||||
key={selectedFile}
|
<LargeMarkdownEditor
|
||||||
value={fileContent}
|
value={fileContent}
|
||||||
onChange={(v) => setFileContent(v)}
|
onChange={setFileContent}
|
||||||
plugins={plugins}
|
/>
|
||||||
locale={{
|
) : (
|
||||||
en: {
|
<Editor
|
||||||
'Write': '编辑',
|
key={selectedFile}
|
||||||
'Preview': '预览',
|
value={fileContent}
|
||||||
},
|
onChange={(v) => setFileContent(v)}
|
||||||
}}
|
plugins={plugins}
|
||||||
/>
|
locale={{
|
||||||
|
en: {
|
||||||
|
'Write': '编辑',
|
||||||
|
'Preview': '预览',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-editor">
|
<div className="empty-editor">
|
||||||
|
|
@ -1012,15 +1285,16 @@ function DocumentEditor() {
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={linkTarget}
|
value={linkTarget}
|
||||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||||
treeData={treeData}
|
treeData={linkTreeData}
|
||||||
placeholder="请选择文件"
|
placeholder="请选择文件"
|
||||||
treeDefaultExpandAll
|
treeDefaultExpandAll
|
||||||
onChange={setLinkTarget}
|
onChange={setLinkTarget}
|
||||||
fieldNames={{ label: 'title', value: 'key', children: 'children' }}
|
fieldNames={{ label: 'title', value: 'key', children: 'children' }}
|
||||||
showSearch
|
showSearch
|
||||||
filterTreeNode={(inputValue, treeNode) => {
|
filterTreeNode={(inputValue, treeNode) => {
|
||||||
return treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
|
return Boolean(treeNode.isLeaf) && treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
|
||||||
}}
|
}}
|
||||||
|
allowClear
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -29,20 +29,64 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sider-header {
|
.docs-sider-header {
|
||||||
padding: 16px 20px;
|
padding: 12px 10px 12px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
background: var(--header-bg);
|
background: var(--header-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sider-header h2 {
|
.docs-sider-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
color: var(--text-color);
|
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 {
|
.docs-sider-actions {
|
||||||
|
|
@ -87,94 +131,47 @@
|
||||||
color: var(--text-color);
|
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-item,
|
||||||
.docs-menu .ant-menu-submenu-title {
|
.docs-menu .ant-menu-submenu-title {
|
||||||
overflow: hidden;
|
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 {
|
.docs-content-layout {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg-color);
|
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 {
|
.docs-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -191,17 +188,56 @@
|
||||||
background: var(--header-bg);
|
background: var(--header-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-content-header h3 {
|
.docs-header-title {
|
||||||
margin: 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
gap: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-color);
|
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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.docs-content-wrapper {
|
||||||
|
|
@ -211,6 +247,13 @@
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docs-content-wrapper.large-markdown-mode {
|
||||||
|
max-width: 100%;
|
||||||
|
height: calc(100% - 57px);
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
/* PDF模式下使用全宽 */
|
/* PDF模式下使用全宽 */
|
||||||
.docs-content-wrapper.pdf-mode {
|
.docs-content-wrapper.pdf-mode {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
@ -227,6 +270,16 @@
|
||||||
min-height: 400px;
|
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 {
|
.markdown-body {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6;
|
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);
|
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 {
|
.preview-layout {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
|
|
@ -19,124 +90,162 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-sider-header {
|
.preview-sider-header {
|
||||||
padding: 16px;
|
min-height: 57px;
|
||||||
|
padding: 14px 24px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--header-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-sider-header h2 {
|
.preview-sider-header h2 {
|
||||||
margin: 0 0 8px 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-color);
|
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;
|
flex: 1;
|
||||||
overflow-y: auto;
|
min-width: 0;
|
||||||
border-right: none;
|
|
||||||
background: var(--sider-bg);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 修复文档名过长的显示问题 */
|
|
||||||
.preview-menu .ant-menu-title-content {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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-item,
|
||||||
.preview-menu .ant-menu-submenu-title {
|
.preview-menu .ant-menu-submenu-title {
|
||||||
overflow: hidden;
|
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 {
|
.preview-content-layout {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg-color);
|
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 {
|
.preview-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg-color);
|
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 {
|
.preview-content-wrapper {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
@ -144,6 +253,13 @@
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-content-wrapper.large-markdown-mode {
|
||||||
|
max-width: 100%;
|
||||||
|
height: calc(100% - 57px);
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
/* PDF模式下使用全宽 */
|
/* PDF模式下使用全宽 */
|
||||||
.preview-content-wrapper.pdf-mode {
|
.preview-content-wrapper.pdf-mode {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
@ -279,20 +395,27 @@
|
||||||
margin-bottom: 4px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
|
.preview-content-header {
|
||||||
|
padding: 12px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-share-content-header {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-content-wrapper {
|
.preview-content-wrapper {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
padding-top: 60px; /* 为移动端菜单按钮留出空间 */
|
}
|
||||||
|
|
||||||
|
.preview-content-wrapper.large-markdown-mode {
|
||||||
|
padding: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content-wrapper.pdf-mode {
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
|
|
@ -323,6 +446,10 @@
|
||||||
.markdown-body td {
|
.markdown-body td {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-share-header {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 平板响应式样式 */
|
/* 平板响应式样式 */
|
||||||
|
|
@ -331,10 +458,6 @@
|
||||||
width: 240px !important;
|
width: 240px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-toc-sider {
|
|
||||||
width: 200px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content-wrapper {
|
.preview-content-wrapper {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
@ -347,6 +470,14 @@
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-content-wrapper.large-markdown-mode {
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content-wrapper.pdf-mode {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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