v0.9.7
parent
f140ed9218
commit
fc617cf678
|
|
@ -2,7 +2,7 @@
|
|||
API v1 路由汇总
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications
|
||||
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, shares
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ api_router.include_router(notifications.router, prefix="/notifications", tags=["
|
|||
api_router.include_router(menu.router, prefix="/menu", tags=["权限菜单"])
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"])
|
||||
api_router.include_router(preview.router, prefix="/preview", tags=["项目预览"])
|
||||
api_router.include_router(shares.router, prefix="/shares", tags=["分享"])
|
||||
api_router.include_router(role_permissions.router, prefix="/role-permissions", tags=["角色权限管理"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["用户管理"])
|
||||
api_router.include_router(roles.router, prefix="/roles", tags=["角色管理"])
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from app.core.deps import get_current_user, get_user_from_token_or_query
|
|||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.models.log import OperationLog
|
||||
from app.models.share import ShareLink
|
||||
from app.schemas.file import (
|
||||
FileTreeNode,
|
||||
FileSaveRequest,
|
||||
|
|
@ -73,6 +74,14 @@ async def check_project_access(
|
|||
return project
|
||||
|
||||
|
||||
def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None:
|
||||
"""为文件树节点补充分享状态"""
|
||||
for node in tree:
|
||||
node.is_shared = bool(node.isLeaf and node.key in shared_paths)
|
||||
if node.children:
|
||||
annotate_shared_files(node.children, shared_paths)
|
||||
|
||||
|
||||
@router.get("/{project_id}/tree", response_model=dict)
|
||||
async def get_project_tree(
|
||||
project_id: int,
|
||||
|
|
@ -88,6 +97,20 @@ async def get_project_tree(
|
|||
# 生成目录树
|
||||
tree = storage_service.generate_tree(project_root)
|
||||
|
||||
share_result = await db.execute(
|
||||
select(ShareLink.file_path).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "file",
|
||||
ShareLink.status == 1,
|
||||
)
|
||||
)
|
||||
shared_paths = {
|
||||
file_path
|
||||
for file_path in share_result.scalars().all()
|
||||
if file_path
|
||||
}
|
||||
annotate_shared_files(tree, shared_paths)
|
||||
|
||||
# 获取当前用户角色
|
||||
user_role = "owner" # 默认是所有者
|
||||
if project.owner_id != current_user.id:
|
||||
|
|
@ -722,4 +745,4 @@ async def export_pdf(
|
|||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||
"Content-Type": "application/pdf"
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@
|
|||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy import delete, select, or_
|
||||
from typing import List
|
||||
import uuid
|
||||
import secrets
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.models.git_repo import ProjectGitRepo
|
||||
from app.models.share import ShareLink
|
||||
from app.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
|
|
@ -19,8 +21,6 @@ from app.schemas.project import (
|
|||
ProjectMemberAdd,
|
||||
ProjectMemberUpdate,
|
||||
ProjectMemberResponse,
|
||||
ProjectShareSettings,
|
||||
ProjectShareInfo,
|
||||
ProjectTransfer,
|
||||
)
|
||||
from app.schemas.response import success_response
|
||||
|
|
@ -33,6 +33,11 @@ from app.core.enums import OperationType, ResourceType
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def generate_share_code() -> str:
|
||||
"""生成公开分享码"""
|
||||
return secrets.token_urlsafe(12).replace("-", "").replace("_", "")
|
||||
|
||||
|
||||
def get_document_count(storage_key: str) -> int:
|
||||
"""计算项目中的文档数量(.md 和 .pdf)"""
|
||||
try:
|
||||
|
|
@ -242,11 +247,48 @@ async def update_project(
|
|||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权修改该项目")
|
||||
|
||||
old_is_public = project.is_public
|
||||
|
||||
# 更新字段
|
||||
update_data = project_in.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
|
||||
if "is_public" in update_data:
|
||||
next_is_public = int(update_data.get("is_public") or 0)
|
||||
if next_is_public == 0:
|
||||
await db.execute(
|
||||
delete(ShareLink).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "project",
|
||||
)
|
||||
)
|
||||
elif next_is_public == 1:
|
||||
if old_is_public != 1:
|
||||
# 重新公开项目时丢弃历史公开链接,生成新的项目分享链接。
|
||||
await db.execute(
|
||||
delete(ShareLink).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "project",
|
||||
)
|
||||
)
|
||||
|
||||
share_result = await db.execute(
|
||||
select(ShareLink).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "project",
|
||||
ShareLink.status == 1,
|
||||
)
|
||||
)
|
||||
share = share_result.scalar_one_or_none()
|
||||
if not share:
|
||||
db.add(ShareLink(
|
||||
project_id=project_id,
|
||||
share_type="project",
|
||||
share_code=generate_share_code(),
|
||||
created_by=current_user.id,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
|
|
@ -608,89 +650,6 @@ async def remove_project_member(
|
|||
return success_response(message="成员删除成功")
|
||||
|
||||
|
||||
@router.get("/{project_id}/share", response_model=dict)
|
||||
async def get_project_share_info(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目分享信息"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查是否是项目所有者或成员
|
||||
is_owner = project.owner_id == current_user.id
|
||||
if not is_owner:
|
||||
# 检查是否是项目成员
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
# 构建分享链接
|
||||
share_url = f"/preview/{project_id}"
|
||||
|
||||
# 只有项目所有者可以看到实际密码,成员只能知道是否设置了密码
|
||||
share_info = ProjectShareInfo(
|
||||
share_url=share_url,
|
||||
has_password=bool(project.access_pass),
|
||||
access_pass=project.access_pass if is_owner else None
|
||||
)
|
||||
|
||||
return success_response(data=share_info.dict())
|
||||
|
||||
|
||||
@router.post("/{project_id}/share/settings", response_model=dict)
|
||||
async def update_share_settings(
|
||||
project_id: int,
|
||||
settings: ProjectShareSettings,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新分享设置(设置或取消访问密码)"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 只有项目所有者可以修改分享设置
|
||||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置")
|
||||
|
||||
# 更新访问密码
|
||||
project.access_pass = settings.access_pass
|
||||
await db.commit()
|
||||
|
||||
# 记录操作日志
|
||||
await log_service.log_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.UPDATE_SHARE_SETTINGS,
|
||||
resource_type=ResourceType.SHARE,
|
||||
user=current_user,
|
||||
resource_id=project_id,
|
||||
detail={
|
||||
"has_password": bool(settings.access_pass),
|
||||
"project_name": project.name,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
message = "访问密码已取消" if not settings.access_pass else "访问密码已设置"
|
||||
return success_response(message=message)
|
||||
|
||||
|
||||
@router.post("/{project_id}/git/pull", response_model=dict)
|
||||
async def git_pull(
|
||||
project_id: int,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,667 @@
|
|||
"""
|
||||
分享相关 API
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import mimetypes
|
||||
import secrets
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.enums import OperationType, ResourceType
|
||||
from app.core.security import create_access_token, decode_access_token
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.models.share import ShareLink
|
||||
from app.models.user import User
|
||||
from app.schemas.project import FileShareCreate
|
||||
from app.schemas.response import success_response
|
||||
from app.services.log_service import log_service
|
||||
from app.services.pdf_service import pdf_service
|
||||
from app.services.search_service import search_service
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def generate_share_code() -> str:
|
||||
return secrets.token_urlsafe(12).replace("-", "").replace("_", "")
|
||||
|
||||
|
||||
def get_share_cookie_name(share_code: str) -> str:
|
||||
return f"nd_share_{share_code}"
|
||||
|
||||
|
||||
async def get_project_or_404(project_id: int, db: AsyncSession) -> Project:
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
return project
|
||||
|
||||
|
||||
async def get_public_project_for_share_or_404(share: ShareLink, db: AsyncSession) -> Project:
|
||||
project = await get_project_or_404(share.project_id, db)
|
||||
if project.is_public != 1:
|
||||
raise HTTPException(status_code=404, detail="分享不存在或已失效")
|
||||
return project
|
||||
|
||||
|
||||
async def ensure_project_member(project: Project, current_user: User, db: AsyncSession) -> str:
|
||||
if project.owner_id == current_user.id:
|
||||
return "owner"
|
||||
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project.id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
return member.role
|
||||
|
||||
|
||||
async def get_share_by_code_or_404(share_code: str, share_type: Optional[str], db: AsyncSession) -> ShareLink:
|
||||
query = select(ShareLink).where(ShareLink.share_code == share_code, ShareLink.status == 1)
|
||||
if share_type:
|
||||
query = query.where(ShareLink.share_type == share_type)
|
||||
result = await db.execute(query)
|
||||
share = result.scalar_one_or_none()
|
||||
if not share:
|
||||
raise HTTPException(status_code=404, detail="分享不存在或已失效")
|
||||
return share
|
||||
|
||||
|
||||
def create_share_access_cookie(response: Response, share: ShareLink):
|
||||
token = create_access_token({"share_code": share.share_code, "scope": "share_access"})
|
||||
response.set_cookie(
|
||||
key=get_share_cookie_name(share.share_code),
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
max_age=60 * 60 * 12,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def has_valid_share_cookie(request: Request, share: ShareLink) -> bool:
|
||||
token = request.cookies.get(get_share_cookie_name(share.share_code))
|
||||
if not token:
|
||||
return False
|
||||
payload = decode_access_token(token)
|
||||
if not payload:
|
||||
return False
|
||||
return payload.get("scope") == "share_access" and payload.get("share_code") == share.share_code
|
||||
|
||||
|
||||
def ensure_share_access(share: ShareLink, request: Request, password: Optional[str]):
|
||||
if not share.access_pass:
|
||||
return
|
||||
if password and password == share.access_pass:
|
||||
return
|
||||
if has_valid_share_cookie(request, share):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
|
||||
|
||||
|
||||
def rewrite_markdown_assets(content: str, asset_base_url: str) -> str:
|
||||
pattern = r"/api/v1/files/\d+/assets/([^)\s\"']+)"
|
||||
replacement = rf"{asset_base_url}/\1"
|
||||
return re.sub(pattern, replacement, content)
|
||||
|
||||
|
||||
def rewrite_markdown_assets_for_pdf(content: str) -> str:
|
||||
return re.sub(r"/api/v1/files/\d+/assets/", "_assets/", content)
|
||||
|
||||
|
||||
def collect_parent_paths(path: str) -> list[str]:
|
||||
parts = path.split("/")
|
||||
parents = []
|
||||
current = ""
|
||||
for part in parts[:-1]:
|
||||
current = f"{current}/{part}" if current else part
|
||||
parents.append(current)
|
||||
return parents
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=dict)
|
||||
async def get_project_share_info(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
project = await get_project_or_404(project_id, db)
|
||||
role = await ensure_project_member(project, current_user, db)
|
||||
is_owner = role == "owner"
|
||||
|
||||
share = None
|
||||
if project.is_public == 1:
|
||||
share_result = await db.execute(
|
||||
select(ShareLink).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "project",
|
||||
ShareLink.status == 1
|
||||
)
|
||||
)
|
||||
share = share_result.scalar_one_or_none()
|
||||
if not share:
|
||||
share = ShareLink(
|
||||
project_id=project_id,
|
||||
share_type="project",
|
||||
share_code=generate_share_code(),
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(share)
|
||||
await db.commit()
|
||||
await db.refresh(share)
|
||||
|
||||
return success_response(data={
|
||||
"enabled": project.is_public == 1,
|
||||
"share_url": f"/share/project/{share.share_code}" if share else None,
|
||||
"has_password": bool(share.access_pass) if share else False,
|
||||
"access_pass": share.access_pass if share and is_owner else None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/settings", response_model=dict)
|
||||
async def update_project_share_settings(
|
||||
project_id: int,
|
||||
settings: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
project = await get_project_or_404(project_id, db)
|
||||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置")
|
||||
|
||||
if project.is_public != 1:
|
||||
raise HTTPException(status_code=400, detail="请先开启公开项目,再设置项目分享")
|
||||
|
||||
share_result = await db.execute(
|
||||
select(ShareLink).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "project",
|
||||
ShareLink.status == 1
|
||||
)
|
||||
)
|
||||
share = share_result.scalar_one_or_none()
|
||||
if not share:
|
||||
share = ShareLink(
|
||||
project_id=project_id,
|
||||
share_type="project",
|
||||
share_code=generate_share_code(),
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(share)
|
||||
|
||||
share.access_pass = settings.get("access_pass")
|
||||
await db.commit()
|
||||
|
||||
await log_service.log_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.UPDATE_SHARE_SETTINGS,
|
||||
resource_type=ResourceType.SHARE,
|
||||
user=current_user,
|
||||
resource_id=project_id,
|
||||
detail={"share_type": "project", "has_password": bool(share.access_pass)},
|
||||
)
|
||||
|
||||
return success_response(message="项目分享设置已更新")
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/files/share/info", response_model=dict)
|
||||
async def get_file_share_info(
|
||||
project_id: int,
|
||||
file_path: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
project = await get_project_or_404(project_id, db)
|
||||
role = await ensure_project_member(project, current_user, db)
|
||||
is_owner = role == "owner"
|
||||
|
||||
share_result = await db.execute(
|
||||
select(ShareLink).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "file",
|
||||
ShareLink.file_path == file_path,
|
||||
ShareLink.status == 1
|
||||
)
|
||||
)
|
||||
share = share_result.scalar_one_or_none()
|
||||
|
||||
if not share:
|
||||
return success_response(data=None)
|
||||
|
||||
return success_response(data={
|
||||
"file_path": file_path,
|
||||
"share_url": f"/share/file/{share.share_code}",
|
||||
"has_password": bool(share.access_pass),
|
||||
"access_pass": share.access_pass if is_owner else None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/files/share", response_model=dict)
|
||||
async def create_or_update_file_share(
|
||||
project_id: int,
|
||||
payload: FileShareCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
project = await get_project_or_404(project_id, db)
|
||||
await ensure_project_member(project, current_user, db)
|
||||
|
||||
file_path = storage_service.get_secure_path(project.storage_key, payload.file_path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
share_result = await db.execute(
|
||||
select(ShareLink).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "file",
|
||||
ShareLink.file_path == payload.file_path,
|
||||
ShareLink.status == 1
|
||||
)
|
||||
)
|
||||
share = share_result.scalar_one_or_none()
|
||||
|
||||
if not share:
|
||||
share = ShareLink(
|
||||
project_id=project_id,
|
||||
share_type="file",
|
||||
share_code=generate_share_code(),
|
||||
file_path=payload.file_path,
|
||||
access_pass=payload.access_pass,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(share)
|
||||
action = OperationType.CREATE_SHARE_LINK
|
||||
else:
|
||||
share.access_pass = payload.access_pass
|
||||
action = OperationType.UPDATE_SHARE_SETTINGS
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(share)
|
||||
|
||||
await log_service.log_operation(
|
||||
db=db,
|
||||
operation_type=action,
|
||||
resource_type=ResourceType.SHARE,
|
||||
user=current_user,
|
||||
resource_id=project_id,
|
||||
detail={"share_type": "file", "file_path": payload.file_path, "has_password": bool(share.access_pass)},
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
"file_path": payload.file_path,
|
||||
"share_url": f"/share/file/{share.share_code}",
|
||||
"has_password": bool(share.access_pass),
|
||||
"access_pass": share.access_pass,
|
||||
}, message="文件分享已更新")
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/files/share", response_model=dict)
|
||||
async def delete_file_share(
|
||||
project_id: int,
|
||||
file_path: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
project = await get_project_or_404(project_id, db)
|
||||
await ensure_project_member(project, current_user, db)
|
||||
|
||||
share_result = await db.execute(
|
||||
select(ShareLink).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "file",
|
||||
ShareLink.file_path == file_path,
|
||||
)
|
||||
)
|
||||
share = share_result.scalar_one_or_none()
|
||||
if not share:
|
||||
raise HTTPException(status_code=404, detail="文件分享不存在")
|
||||
|
||||
await db.execute(
|
||||
delete(ShareLink).where(
|
||||
ShareLink.project_id == project_id,
|
||||
ShareLink.share_type == "file",
|
||||
ShareLink.file_path == file_path,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await log_service.log_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.DELETE_SHARE_LINK,
|
||||
resource_type=ResourceType.SHARE,
|
||||
user=current_user,
|
||||
resource_id=project_id,
|
||||
detail={"share_type": "file", "file_path": file_path},
|
||||
)
|
||||
|
||||
return success_response(message="文件分享已关闭")
|
||||
|
||||
|
||||
@router.get("/project/{share_code}/info", response_model=dict)
|
||||
async def get_project_share_public_info(
|
||||
share_code: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "project", db)
|
||||
project = await get_public_project_for_share_or_404(share, db)
|
||||
return success_response(data={
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"has_password": bool(share.access_pass),
|
||||
"share_code": share.share_code,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/project/{share_code}/verify", response_model=dict)
|
||||
async def verify_project_share_password(
|
||||
share_code: str,
|
||||
password: str = Header(..., alias="X-Access-Password"),
|
||||
response: Response = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "project", db)
|
||||
await get_public_project_for_share_or_404(share, db)
|
||||
if share.access_pass and share.access_pass != password:
|
||||
raise HTTPException(status_code=403, detail="访问密码错误")
|
||||
create_share_access_cookie(response, share)
|
||||
return success_response(message="验证成功")
|
||||
|
||||
|
||||
@router.get("/project/{share_code}/tree", response_model=dict)
|
||||
async def get_project_share_tree(
|
||||
share_code: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "project", db)
|
||||
project = await get_public_project_for_share_or_404(share, db)
|
||||
ensure_share_access(share, request, password)
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
tree = storage_service.generate_tree(project_root)
|
||||
return success_response(data=tree)
|
||||
|
||||
|
||||
@router.get("/project/{share_code}/search", response_model=dict)
|
||||
async def search_project_share_documents(
|
||||
share_code: str,
|
||||
keyword: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "project", db)
|
||||
project = await get_public_project_for_share_or_404(share, db)
|
||||
ensure_share_access(share, request, password)
|
||||
|
||||
if not keyword or not keyword.strip():
|
||||
return success_response(data=[])
|
||||
|
||||
keyword = keyword.strip()
|
||||
|
||||
search_results = []
|
||||
existing_paths = set()
|
||||
|
||||
try:
|
||||
whoosh_results = await search_service.search(keyword, str(project.id), limit=50)
|
||||
except Exception:
|
||||
whoosh_results = []
|
||||
|
||||
for item in whoosh_results:
|
||||
file_path = item.get("path")
|
||||
if not file_path:
|
||||
continue
|
||||
existing_paths.add(file_path)
|
||||
search_results.append({
|
||||
"file_path": file_path,
|
||||
"file_name": item.get("title") or Path(file_path).name,
|
||||
"highlights": item.get("highlights"),
|
||||
"match_type": "全文检索",
|
||||
"parent_paths": collect_parent_paths(file_path),
|
||||
})
|
||||
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
keyword_lower = keyword.lower()
|
||||
|
||||
try:
|
||||
files_to_scan = list(project_root.rglob("*.md")) + list(project_root.rglob("*.pdf"))
|
||||
except Exception:
|
||||
files_to_scan = []
|
||||
|
||||
for file_path in files_to_scan:
|
||||
if "_assets" in file_path.parts:
|
||||
continue
|
||||
|
||||
relative_path = str(file_path.relative_to(project_root))
|
||||
if relative_path in existing_paths:
|
||||
continue
|
||||
|
||||
if keyword_lower in file_path.name.lower():
|
||||
search_results.append({
|
||||
"file_path": relative_path,
|
||||
"file_name": file_path.name,
|
||||
"match_type": "文件名匹配",
|
||||
"parent_paths": collect_parent_paths(relative_path),
|
||||
})
|
||||
existing_paths.add(relative_path)
|
||||
|
||||
return success_response(data=search_results[:100])
|
||||
|
||||
|
||||
@router.get("/project/{share_code}/file", response_model=dict)
|
||||
async def get_project_share_file(
|
||||
share_code: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "project", db)
|
||||
project = await get_public_project_for_share_or_404(share, db)
|
||||
ensure_share_access(share, request, password)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
content = await storage_service.read_file(file_path)
|
||||
return success_response(data={"content": content})
|
||||
|
||||
|
||||
@router.get("/project/{share_code}/document/{path:path}")
|
||||
async def get_project_share_document(
|
||||
share_code: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "project", db)
|
||||
project = await get_public_project_for_share_or_404(share, db)
|
||||
ensure_share_access(share, request, password)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name)
|
||||
|
||||
|
||||
@router.get("/project/{share_code}/export-pdf")
|
||||
async def export_project_share_pdf(
|
||||
share_code: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "project", db)
|
||||
project = await get_public_project_for_share_or_404(share, db)
|
||||
ensure_share_access(share, request, password)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
content = await storage_service.read_file(file_path)
|
||||
filename = Path(path).stem + ".pdf"
|
||||
content = rewrite_markdown_assets_for_pdf(content)
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
|
||||
return StreamingResponse(
|
||||
pdf_buffer,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/files/{share_code}/info", response_model=dict)
|
||||
async def get_file_share_public_info(
|
||||
share_code: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "file", db)
|
||||
project = await get_project_or_404(share.project_id, db)
|
||||
return success_response(data={
|
||||
"name": Path(share.file_path).name,
|
||||
"project_name": project.name,
|
||||
"file_path": share.file_path,
|
||||
"has_password": bool(share.access_pass),
|
||||
"share_code": share.share_code,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/files/{share_code}/verify", response_model=dict)
|
||||
async def verify_file_share_password(
|
||||
share_code: str,
|
||||
password: str = Header(..., alias="X-Access-Password"),
|
||||
response: Response = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "file", db)
|
||||
if share.access_pass and share.access_pass != password:
|
||||
raise HTTPException(status_code=403, detail="访问密码错误")
|
||||
create_share_access_cookie(response, share)
|
||||
return success_response(message="验证成功")
|
||||
|
||||
|
||||
@router.get("/files/{share_code}/content", response_model=dict)
|
||||
async def get_file_share_content(
|
||||
share_code: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "file", db)
|
||||
ensure_share_access(share, request, password)
|
||||
project = await get_project_or_404(share.project_id, db)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, share.file_path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
if share.file_path.lower().endswith(".pdf"):
|
||||
return success_response(data={
|
||||
"type": "pdf",
|
||||
"filename": Path(share.file_path).name,
|
||||
"document_url": f"/api/v1/shares/files/{share.share_code}/document",
|
||||
})
|
||||
|
||||
content = await storage_service.read_file(file_path)
|
||||
content = rewrite_markdown_assets_for_pdf(content)
|
||||
return success_response(data={
|
||||
"type": "markdown",
|
||||
"filename": Path(share.file_path).name,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/files/{share_code}/document")
|
||||
async def get_file_share_document(
|
||||
share_code: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "file", db)
|
||||
ensure_share_access(share, request, password)
|
||||
project = await get_project_or_404(share.project_id, db)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, share.file_path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name)
|
||||
|
||||
|
||||
@router.get("/files/{share_code}/export-pdf")
|
||||
async def export_file_share_pdf(
|
||||
share_code: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "file", db)
|
||||
ensure_share_access(share, request, password)
|
||||
project = await get_project_or_404(share.project_id, db)
|
||||
file_path = storage_service.get_secure_path(project.storage_key, share.file_path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
if share.file_path.lower().endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="PDF 文件无需导出")
|
||||
|
||||
content = await storage_service.read_file(file_path)
|
||||
filename = Path(share.file_path).stem + ".pdf"
|
||||
content = rewrite_markdown_assets(content, f"/api/v1/shares/files/{share.share_code}/assets")
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
|
||||
return StreamingResponse(
|
||||
pdf_buffer,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/files/{share_code}/assets/{subfolder}/{filename}")
|
||||
async def get_file_share_asset(
|
||||
share_code: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "file", db)
|
||||
ensure_share_access(share, request, password)
|
||||
project = await get_project_or_404(share.project_id, db)
|
||||
asset_path = f"_assets/{subfolder}/{filename}"
|
||||
file_path = storage_service.get_secure_path(project.storage_key, asset_path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
return FileResponse(path=str(file_path), filename=filename, media_type=mime_type or "application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/project/{share_code}/assets/{subfolder}/{filename}")
|
||||
async def get_project_share_asset(
|
||||
share_code: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
request: Request,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
share = await get_share_by_code_or_404(share_code, "project", db)
|
||||
ensure_share_access(share, request, password)
|
||||
project = await get_project_or_404(share.project_id, db)
|
||||
asset_path = f"_assets/{subfolder}/{filename}"
|
||||
file_path = storage_service.get_secure_path(project.storage_key, asset_path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
return FileResponse(path=str(file_path), filename=filename, media_type=mime_type or "application/octet-stream")
|
||||
|
|
@ -31,6 +31,8 @@ class OperationType(str, Enum):
|
|||
|
||||
# 分享操作
|
||||
UPDATE_SHARE_SETTINGS = "update_share_settings"
|
||||
CREATE_SHARE_LINK = "create_share_link"
|
||||
DELETE_SHARE_LINK = "delete_share_link"
|
||||
|
||||
# Git操作
|
||||
GIT_PULL = "git_pull"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from app.models.role import Role, UserRole
|
|||
from app.models.menu import SystemMenu, RoleMenu
|
||||
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
||||
from app.models.document import DocumentMeta
|
||||
from app.models.share import ShareLink
|
||||
from app.models.log import OperationLog
|
||||
from app.models.mcp_bot import MCPBot
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ __all__ = [
|
|||
"ProjectMember",
|
||||
"ProjectMemberRole",
|
||||
"DocumentMeta",
|
||||
"ShareLink",
|
||||
"OperationLog",
|
||||
"MCPBot",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
"""
|
||||
分享链接模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ShareLink(Base):
|
||||
"""项目分享/文件分享链接"""
|
||||
|
||||
__tablename__ = "share_links"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="分享ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
share_type = Column(String(20), nullable=False, index=True, comment="分享类型: project/file")
|
||||
share_code = Column(String(64), nullable=False, unique=True, index=True, comment="公开分享码")
|
||||
file_path = Column(String(500), comment="文件路径,仅文件分享使用")
|
||||
access_pass = Column(String(100), comment="访问密码")
|
||||
created_by = Column(BigInteger, index=True, comment="创建人ID")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-禁用 1-启用")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ShareLink(id={self.id}, type='{self.share_type}', code='{self.share_code}')>"
|
||||
|
|
@ -10,6 +10,7 @@ class FileTreeNode(BaseModel):
|
|||
title: str = Field(..., description="节点标题(文件/文件夹名)")
|
||||
key: str = Field(..., description="节点唯一键(相对路径)")
|
||||
isLeaf: bool = Field(..., description="是否叶子节点")
|
||||
is_shared: bool = Field(False, description="当前文件是否已创建分享链接")
|
||||
children: Optional[List['FileTreeNode']] = Field(None, description="子节点")
|
||||
|
||||
class Config:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class ProjectUpdate(BaseModel):
|
|||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
is_public: Optional[int] = None
|
||||
public_access_pass: Optional[str] = Field(None, max_length=100)
|
||||
cover_image: Optional[str] = None
|
||||
status: Optional[int] = None
|
||||
|
||||
|
|
@ -80,18 +81,33 @@ class ProjectMemberResponse(BaseModel):
|
|||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectShareInfo(BaseModel):
|
||||
"""项目公开分享信息响应 Schema"""
|
||||
enabled: bool = Field(..., description="是否已开启项目公开")
|
||||
share_url: Optional[str] = Field(None, description="项目分享链接")
|
||||
has_password: bool = Field(..., description="是否设置了访问密码")
|
||||
access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)")
|
||||
|
||||
|
||||
class ProjectShareSettings(BaseModel):
|
||||
"""项目分享设置 Schema"""
|
||||
access_pass: Optional[str] = Field(None, max_length=100, description="访问密码(None表示取消密码)")
|
||||
access_pass: Optional[str] = Field(None, max_length=100, description="访问密码(None 表示取消密码)")
|
||||
|
||||
|
||||
class ProjectShareInfo(BaseModel):
|
||||
"""项目分享信息响应 Schema"""
|
||||
share_url: str = Field(..., description="分享链接")
|
||||
class FileShareCreate(BaseModel):
|
||||
"""文件分享创建/更新 Schema"""
|
||||
file_path: str = Field(..., min_length=1, description="文件路径")
|
||||
access_pass: Optional[str] = Field(None, max_length=100, description="访问密码")
|
||||
|
||||
|
||||
class FileShareInfo(BaseModel):
|
||||
"""文件分享信息响应 Schema"""
|
||||
file_path: str = Field(..., description="文件路径")
|
||||
share_url: str = Field(..., description="文件分享链接")
|
||||
has_password: bool = Field(..., description="是否设置了访问密码")
|
||||
access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)")
|
||||
|
||||
|
||||
class ProjectTransfer(BaseModel):
|
||||
"""转移项目所有权 Schema"""
|
||||
new_owner_id: int = Field(..., description="新所有者ID")
|
||||
new_owner_id: int = Field(..., description="新所有者ID")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
CREATE TABLE IF NOT EXISTS `share_links` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file',
|
||||
`share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码',
|
||||
`file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用',
|
||||
`access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码',
|
||||
`created_by` BIGINT DEFAULT NULL COMMENT '创建人ID',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_share_code` (`share_code`),
|
||||
INDEX `idx_share_project` (`project_id`, `share_type`, `status`),
|
||||
INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`),
|
||||
INDEX `idx_share_created_by` (`created_by`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表';
|
||||
|
|
@ -182,6 +182,26 @@ CREATE TABLE IF NOT EXISTS `mcp_bots` (
|
|||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials';
|
||||
|
||||
-- 11. 分享链接表
|
||||
CREATE TABLE IF NOT EXISTS `share_links` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file',
|
||||
`share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码',
|
||||
`file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用',
|
||||
`access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码',
|
||||
`created_by` BIGINT DEFAULT NULL COMMENT '创建人ID',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_share_code` (`share_code`),
|
||||
INDEX `idx_share_project` (`project_id`, `share_type`, `status`),
|
||||
INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`),
|
||||
INDEX `idx_share_created_by` (`created_by`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表';
|
||||
|
||||
-- 插入初始角色数据
|
||||
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
|
||||
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import DocumentEditor from '@/pages/Document/DocumentEditor'
|
|||
import Dashboard from '@/pages/Dashboard'
|
||||
import Desktop from '@/pages/Desktop'
|
||||
import Constructing from '@/pages/Constructing'
|
||||
import PreviewPage from '@/pages/Preview/PreviewPage'
|
||||
import ProjectSharePage from '@/pages/Preview/ProjectSharePage'
|
||||
import FileSharePage from '@/pages/Preview/FileSharePage'
|
||||
import ProfilePage from '@/pages/Profile/ProfilePage'
|
||||
import Permissions from '@/pages/System/Permissions'
|
||||
import Users from '@/pages/System/Users'
|
||||
|
|
@ -61,8 +62,8 @@ function App() {
|
|||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
{/* 项目预览(公开访问,无需登录) */}
|
||||
<Route path="/preview/:projectId" element={<PreviewPage />} />
|
||||
<Route path="/share/project/:shareCode" element={<ProjectSharePage />} />
|
||||
<Route path="/share/file/:shareCode" element={<FileSharePage />} />
|
||||
|
||||
{/* 使用共享布局的路由 */}
|
||||
<Route element={<ProtectedRoute><LayoutWrapper /></ProtectedRoute>}>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,57 @@
|
|||
/**
|
||||
* 项目分享和预览相关 API
|
||||
* 分享相关 API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取项目分享信息
|
||||
*/
|
||||
export function getProjectShareInfo(projectId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/share`,
|
||||
url: `/shares/projects/${projectId}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分享设置(设置或取消访问密码)
|
||||
*/
|
||||
export function updateShareSettings(projectId, data) {
|
||||
export function updateProjectShareSettings(projectId, data) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/share/settings`,
|
||||
url: `/shares/projects/${projectId}/settings`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目基本信息(公开访问)
|
||||
*/
|
||||
export function getPreviewInfo(projectId) {
|
||||
export function getFileShareInfo(projectId, filePath) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/info`,
|
||||
url: `/shares/projects/${projectId}/files/share/info`,
|
||||
method: 'get',
|
||||
params: { file_path: filePath },
|
||||
})
|
||||
}
|
||||
|
||||
export function createOrUpdateFileShare(projectId, data) {
|
||||
return request({
|
||||
url: `/shares/projects/${projectId}/files/share`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteFileShare(projectId, filePath) {
|
||||
return request({
|
||||
url: `/shares/projects/${projectId}/files/share`,
|
||||
method: 'delete',
|
||||
params: { file_path: filePath },
|
||||
})
|
||||
}
|
||||
|
||||
export function getProjectSharePublicInfo(shareCode) {
|
||||
return request({
|
||||
url: `/shares/project/${shareCode}/info`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证访问密码
|
||||
*/
|
||||
export function verifyAccessPassword(projectId, password) {
|
||||
export function verifyProjectSharePassword(shareCode, password) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/verify`,
|
||||
url: `/shares/project/${shareCode}/verify`,
|
||||
method: 'post',
|
||||
headers: {
|
||||
'X-Access-Password': password,
|
||||
|
|
@ -47,42 +59,71 @@ export function verifyAccessPassword(projectId, password) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目的文档树
|
||||
*/
|
||||
export function getPreviewTree(projectId, password = null) {
|
||||
export function getProjectShareTree(shareCode, password = null) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/tree`,
|
||||
url: `/shares/project/${shareCode}/tree`,
|
||||
method: 'get',
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目的文件内容
|
||||
*/
|
||||
export function getPreviewFile(projectId, path, password = null) {
|
||||
export function searchProjectShareDocuments(shareCode, keyword, password = null) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/file`,
|
||||
url: `/shares/project/${shareCode}/search`,
|
||||
method: 'get',
|
||||
params: { keyword },
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getProjectShareFile(shareCode, path, password = null) {
|
||||
return request({
|
||||
url: `/shares/project/${shareCode}/file`,
|
||||
method: 'get',
|
||||
params: { path },
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目的文档文件URL(PDF等)
|
||||
*/
|
||||
export function getPreviewDocumentUrl(projectId, path) {
|
||||
// 将路径的每个部分分别编码,但保留斜杠
|
||||
export function getProjectShareDocumentUrl(shareCode, path) {
|
||||
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
return `/api/v1/preview/${projectId}/document/${encodedPath}`
|
||||
return `/api/v1/shares/project/${shareCode}/document/${encodedPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 PDF
|
||||
*/
|
||||
export function exportPDF(projectId, path) {
|
||||
export function exportProjectSharePDF(shareCode, path) {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
return `/api/v1/preview/${projectId}/export-pdf?path=${encodedPath}`
|
||||
return `/api/v1/shares/project/${shareCode}/export-pdf?path=${encodedPath}`
|
||||
}
|
||||
|
||||
export function getFileSharePublicInfo(shareCode) {
|
||||
return request({
|
||||
url: `/shares/files/${shareCode}/info`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function verifyFileSharePassword(shareCode, password) {
|
||||
return request({
|
||||
url: `/shares/files/${shareCode}/verify`,
|
||||
method: 'post',
|
||||
headers: {
|
||||
'X-Access-Password': password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getFileShareContent(shareCode, password = null) {
|
||||
return request({
|
||||
url: `/shares/files/${shareCode}/content`,
|
||||
method: 'get',
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getFileShareDocumentUrl(shareCode) {
|
||||
return `/api/v1/shares/files/${shareCode}/document`
|
||||
}
|
||||
|
||||
export function exportFileSharePDF(shareCode) {
|
||||
return `/api/v1/shares/files/${shareCode}/export-pdf`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.mode-switch-small {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.mode-switch-small .mode-switch-option {
|
||||
min-width: 40px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body.dark .mode-switch {
|
||||
background: linear-gradient(180deg, #2e3748 0%, #252d3b 100%);
|
||||
border-color: rgba(137, 156, 186, 0.24);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ function ModeSwitch({
|
|||
editLabel = '编辑',
|
||||
options,
|
||||
ariaLabel = '模式切换',
|
||||
size = 'default',
|
||||
}) {
|
||||
const finalOptions = options || [
|
||||
{ label: viewLabel, value: 'view' },
|
||||
|
|
@ -19,7 +20,7 @@ function ModeSwitch({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="mode-switch"
|
||||
className={`mode-switch mode-switch-${size}`}
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -30,20 +30,63 @@
|
|||
}
|
||||
|
||||
.sider-header {
|
||||
padding: 16px 20px;
|
||||
padding: 12px 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
background: var(--header-bg);
|
||||
}
|
||||
|
||||
.sider-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sider-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sider-title-row h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-back-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: #707480;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.project-back-button:hover {
|
||||
background: rgba(17, 24, 39, 0.06);
|
||||
color: #2f3440;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.project-back-button:focus-visible {
|
||||
outline: 2px solid rgba(22, 119, 255, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sider-actions {
|
||||
|
|
@ -88,48 +131,20 @@
|
|||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 修复Tree组件文档名过长的显示问题 */
|
||||
.file-tree .ant-tree-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.file-tree .ant-tree-node-content-wrapper {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.file-tree .ant-tree-treenode {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 确保Tree节点标题区域不折行 */
|
||||
.file-tree .ant-tree-node-content-wrapper .ant-tree-title {
|
||||
display: inline-block;
|
||||
max-width: calc(100% - 24px);
|
||||
/* 预留图标空间 */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 修复文档名过长的显示问题(Menu组件,已废弃但保留兼容) */
|
||||
.file-tree .ant-menu-title-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
/* Ensure it allows children to fill width */
|
||||
}
|
||||
|
||||
/* Increase hit area for context menu */
|
||||
.tree-node-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
user-select: none;
|
||||
padding: 4px 8px;
|
||||
margin: -4px -8px;
|
||||
|
|
@ -138,6 +153,14 @@
|
|||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tree-node-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 选中的文件夹样式 */
|
||||
.file-tree .folder-selected>.ant-menu-submenu-title {
|
||||
background-color: var(--item-hover-bg) !important;
|
||||
|
|
@ -152,8 +175,8 @@
|
|||
.file-tree .ant-menu-submenu-title {
|
||||
overflow: hidden;
|
||||
display: flex !important;
|
||||
/* Ensure flex layout for item */
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
FilePdfOutlined,
|
||||
FileTextOutlined,
|
||||
UndoOutlined,
|
||||
CloseOutlined,
|
||||
ArrowLeftOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Editor } from '@bytemd/react'
|
||||
import gfm from '@bytemd/plugin-gfm'
|
||||
|
|
@ -912,9 +912,13 @@ function DocumentEditor() {
|
|||
menu={{ items: getNodeMenuItems(node) }}
|
||||
trigger={['contextMenu']}
|
||||
>
|
||||
<div className="tree-node-wrapper">
|
||||
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
|
||||
</div>
|
||||
<Tooltip title={node.title} placement="right">
|
||||
<div className="tree-node-wrapper">
|
||||
<span className="tree-node-text">
|
||||
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
)
|
||||
|
||||
|
|
@ -967,22 +971,21 @@ function DocumentEditor() {
|
|||
className="document-sider"
|
||||
>
|
||||
<div className="sider-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
|
||||
{projectName}
|
||||
</h2>
|
||||
<Tooltip title="关闭">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={handleClose}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className="sider-title-row">
|
||||
<button
|
||||
type="button"
|
||||
className="project-back-button"
|
||||
onClick={handleClose}
|
||||
aria-label="返回项目列表"
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
<h2 title={projectName}>{projectName}</h2>
|
||||
</div>
|
||||
<div className="sider-actions">
|
||||
<div className="mode-actions-row">
|
||||
<ModeSwitch
|
||||
size="small"
|
||||
value={modeSwitchValue}
|
||||
onChange={(mode) => {
|
||||
if (mode === 'view' && !modeSwitchingRef.current) {
|
||||
|
|
|
|||
|
|
@ -26,20 +26,64 @@
|
|||
}
|
||||
|
||||
.docs-sider-header {
|
||||
padding: 16px 20px;
|
||||
padding: 12px 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
background: var(--header-bg);
|
||||
}
|
||||
|
||||
.docs-sider-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
line-height: 1.1;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.docs-sider-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docs-sider-title-row h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-back-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: #707480;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.project-back-button:hover {
|
||||
background: rgba(17, 24, 39, 0.06);
|
||||
color: #2f3440;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.project-back-button:focus-visible {
|
||||
outline: 2px solid rgba(22, 119, 255, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.docs-sider-actions {
|
||||
|
|
@ -84,18 +128,41 @@
|
|||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 修复文档名过长的显示问题 */
|
||||
.docs-menu .ant-menu-title-content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.docs-menu .ant-menu-item,
|
||||
.docs-menu .ant-menu-submenu-title {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.docs-menu .ant-menu-title-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.docs-menu-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docs-menu-label-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.docs-menu-share-icon {
|
||||
color: var(--link-color);
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.docs-content-layout {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
|
||||
import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons'
|
||||
import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, ArrowLeftOutlined, MenuOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
|
|
@ -12,7 +12,7 @@ import Highlighter from 'react-highlight-words'
|
|||
import GithubSlugger from 'github-slugger'
|
||||
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
|
||||
import { gitPull, gitPush, getGitRepos } from '@/api/project'
|
||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
|
||||
|
|
@ -58,6 +58,7 @@ function DocumentPage() {
|
|||
const [viewMode, setViewMode] = useState('markdown')
|
||||
const [gitRepos, setGitRepos] = useState([])
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// 搜索相关状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
|
|
@ -86,6 +87,21 @@ function DocumentPage() {
|
|||
setSearchParams(nextParams, { replace: true })
|
||||
}
|
||||
|
||||
const buildDocumentUrl = (filePath, refreshKey = null) => {
|
||||
const params = new URLSearchParams()
|
||||
const token = localStorage.getItem('access_token')
|
||||
|
||||
if (token) {
|
||||
params.set('token', token)
|
||||
}
|
||||
if (refreshKey) {
|
||||
params.set('refresh', String(refreshKey))
|
||||
}
|
||||
|
||||
const query = params.toString()
|
||||
return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFileTree()
|
||||
}, [projectId])
|
||||
|
|
@ -139,12 +155,7 @@ function DocumentPage() {
|
|||
|
||||
// 处理 PDF 或 Markdown
|
||||
if (fileParam.toLowerCase().endsWith('.pdf')) {
|
||||
let url = getDocumentUrl(projectId, fileParam)
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
url += `?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
setPdfUrl(url)
|
||||
setPdfUrl(buildDocumentUrl(fileParam))
|
||||
setPdfFilename(fileParam.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
} else {
|
||||
|
|
@ -234,7 +245,7 @@ function DocumentPage() {
|
|||
}
|
||||
|
||||
// 加载文件树
|
||||
const loadFileTree = async () => {
|
||||
const loadFileTree = async ({ throwOnError = false } = {}) => {
|
||||
try {
|
||||
const res = await getProjectTree(projectId)
|
||||
const data = res.data || {}
|
||||
|
|
@ -245,8 +256,13 @@ function DocumentPage() {
|
|||
setFileTree(tree)
|
||||
setUserRole(role)
|
||||
setProjectName(name)
|
||||
return tree
|
||||
} catch (error) {
|
||||
console.error('Load file tree error:', error)
|
||||
if (throwOnError) {
|
||||
throw error
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -279,14 +295,21 @@ function DocumentPage() {
|
|||
// 转换文件树为菜单项
|
||||
const convertTreeToMenuItems = (nodes) => {
|
||||
return nodes.map((node) => {
|
||||
// 标题高亮处理 - 取消高亮,仅显示原始标题
|
||||
const titleNode = node.title.replace('.md', '')
|
||||
const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title
|
||||
const labelNode = (
|
||||
<Tooltip title={node.title} placement="right">
|
||||
<span className="docs-menu-label">
|
||||
<span className="docs-menu-label-text">{titleText}</span>
|
||||
{node.is_shared && <ShareAltOutlined className="docs-menu-share-icon" title="已分享" />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
if (!node.isLeaf) {
|
||||
// 目录
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title,
|
||||
label: labelNode,
|
||||
icon: <FolderOutlined />,
|
||||
onTitleClick: () => setSelectedNodeKey(node.key),
|
||||
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
||||
|
|
@ -295,14 +318,14 @@ function DocumentPage() {
|
|||
// Markdown 文件
|
||||
return {
|
||||
key: node.key,
|
||||
label: titleNode,
|
||||
label: labelNode,
|
||||
icon: <FileTextOutlined />,
|
||||
}
|
||||
} else if (node.title && node.title.endsWith('.pdf')) {
|
||||
// PDF 文件
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title,
|
||||
label: labelNode,
|
||||
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
|
||||
}
|
||||
}
|
||||
|
|
@ -367,12 +390,7 @@ function DocumentPage() {
|
|||
// 检查是否是PDF文件
|
||||
if (key.toLowerCase().endsWith('.pdf')) {
|
||||
// 显示PDF - 添加token到URL
|
||||
let url = getDocumentUrl(projectId, key)
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
url += `?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
setPdfUrl(url)
|
||||
setPdfUrl(buildDocumentUrl(key))
|
||||
setPdfFilename(key.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
} else {
|
||||
|
|
@ -468,12 +486,7 @@ function DocumentPage() {
|
|||
|
||||
if (isPdf) {
|
||||
// PDF文件:切换到PDF模式
|
||||
let url = getDocumentUrl(projectId, targetPath)
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
url += `?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
setPdfUrl(url)
|
||||
setPdfUrl(buildDocumentUrl(targetPath))
|
||||
setPdfFilename(targetPath.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
} else {
|
||||
|
|
@ -654,19 +667,33 @@ function DocumentPage() {
|
|||
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await loadFileTree({ throwOnError: true })
|
||||
message.success('已刷新')
|
||||
} catch (error) {
|
||||
console.error('Refresh documents error:', error)
|
||||
message.error('刷新失败')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开分享设置
|
||||
const handleShare = async () => {
|
||||
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
|
||||
if (selectedNode && !selectedNode.isLeaf) {
|
||||
Toast.warning('提示', '当前选中的是文件夹,不能直接分享,请选择具体文件后再试')
|
||||
if (!selectedNode || !selectedNode.isLeaf) {
|
||||
Toast.warning('提示', '请先选择一个文件再分享')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getProjectShareInfo(projectId)
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(res.data.has_password)
|
||||
setPassword(res.data.access_pass || '') // 显示已设置的密码
|
||||
const res = await getFileShareInfo(projectId, selectedNode.key)
|
||||
const nextShareInfo = res.data
|
||||
setShareInfo(nextShareInfo)
|
||||
setHasPassword(Boolean(nextShareInfo?.has_password))
|
||||
setPassword(nextShareInfo?.access_pass || '')
|
||||
setShareModalVisible(true)
|
||||
} catch (error) {
|
||||
console.error('Get share info error:', error)
|
||||
|
|
@ -677,15 +704,7 @@ function DocumentPage() {
|
|||
// 复制分享链接
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareInfo) return
|
||||
|
||||
const shareTargetFile = selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
|
||||
? selectedNodeKey
|
||||
: ''
|
||||
|
||||
let fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
||||
if (shareTargetFile) {
|
||||
fullUrl += `?file=${encodeURIComponent(shareTargetFile)}`
|
||||
}
|
||||
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
|
|
@ -717,15 +736,20 @@ function DocumentPage() {
|
|||
|
||||
// 切换密码保护
|
||||
const handlePasswordToggle = async (checked) => {
|
||||
if (!selectedNodeKey) return
|
||||
|
||||
if (!checked) {
|
||||
// 取消密码
|
||||
try {
|
||||
await updateShareSettings(projectId, { access_pass: null })
|
||||
if (shareInfo?.share_url) {
|
||||
await createOrUpdateFileShare(projectId, { file_path: selectedNodeKey, access_pass: null })
|
||||
} else {
|
||||
await deleteFileShare(projectId, selectedNodeKey)
|
||||
}
|
||||
setHasPassword(false)
|
||||
setPassword('')
|
||||
message.success('已取消访问密码')
|
||||
// 刷新分享信息
|
||||
const res = await getProjectShareInfo(projectId)
|
||||
message.success('已取消文件访问密码')
|
||||
await loadFileTree()
|
||||
const res = await getFileShareInfo(projectId, selectedNodeKey)
|
||||
setShareInfo(res.data)
|
||||
} catch (error) {
|
||||
console.error('Update settings error:', error)
|
||||
|
|
@ -738,20 +762,66 @@ function DocumentPage() {
|
|||
|
||||
// 保存密码
|
||||
const handleSavePassword = async () => {
|
||||
if (!selectedNodeKey) {
|
||||
message.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
message.warning('请输入访问密码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateShareSettings(projectId, { access_pass: password })
|
||||
message.success('访问密码已设置')
|
||||
// 刷新分享信息
|
||||
const res = await getProjectShareInfo(projectId)
|
||||
const res = await createOrUpdateFileShare(projectId, {
|
||||
file_path: selectedNodeKey,
|
||||
access_pass: hasPassword ? password : null,
|
||||
})
|
||||
message.success('文件分享已更新')
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(true)
|
||||
await loadFileTree()
|
||||
const nextInfo = await getFileShareInfo(projectId, selectedNodeKey)
|
||||
setShareInfo(nextInfo.data)
|
||||
setHasPassword(Boolean(nextInfo.data?.has_password))
|
||||
} catch (error) {
|
||||
console.error('Save password error:', error)
|
||||
message.error('设置密码失败')
|
||||
message.error('设置文件分享失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateShare = async () => {
|
||||
if (!selectedNodeKey) {
|
||||
message.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await createOrUpdateFileShare(projectId, {
|
||||
file_path: selectedNodeKey,
|
||||
access_pass: hasPassword ? password : null,
|
||||
})
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(Boolean(res.data?.has_password))
|
||||
await loadFileTree()
|
||||
message.success('文件分享已创建')
|
||||
} catch (error) {
|
||||
console.error('Create file share error:', error)
|
||||
message.error('创建文件分享失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisableShare = async () => {
|
||||
if (!selectedNodeKey) return
|
||||
|
||||
try {
|
||||
await deleteFileShare(projectId, selectedNodeKey)
|
||||
setShareInfo(null)
|
||||
setHasPassword(false)
|
||||
setPassword('')
|
||||
await loadFileTree()
|
||||
message.success('文件分享已关闭')
|
||||
} catch (error) {
|
||||
console.error('Delete file share error:', error)
|
||||
message.error('关闭文件分享失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -837,24 +907,23 @@ function DocumentPage() {
|
|||
{/* 左侧目录 */}
|
||||
<Sider width={280} className="docs-sider" theme="light">
|
||||
<div className="docs-sider-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
|
||||
{projectName}
|
||||
</h2>
|
||||
<Tooltip title="关闭">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={handleClose}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className="docs-sider-title-row">
|
||||
<button
|
||||
type="button"
|
||||
className="project-back-button"
|
||||
onClick={handleClose}
|
||||
aria-label="返回项目列表"
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
<h2 title={projectName}>{projectName}</h2>
|
||||
</div>
|
||||
<div className="docs-sider-actions">
|
||||
<div className="mode-actions-row">
|
||||
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
|
||||
{userRole !== 'viewer' ? (
|
||||
<ModeSwitch
|
||||
size="small"
|
||||
value={modeSwitchValue}
|
||||
onChange={(mode) => {
|
||||
if (mode === 'edit' && !modeSwitchingRef.current) {
|
||||
|
|
@ -878,6 +947,14 @@ function DocumentPage() {
|
|||
onClick={handleShare}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="刷新">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<ReloadOutlined spin={refreshing} />}
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1015,55 +1092,91 @@ function DocumentPage() {
|
|||
{/* 分享模态框 */}
|
||||
{/* ... keeping the modal ... */}
|
||||
<Modal
|
||||
title="分享"
|
||||
title="文件分享"
|
||||
open={shareModalVisible}
|
||||
onCancel={() => setShareModalVisible(false)}
|
||||
footer={null}
|
||||
width={500}
|
||||
>
|
||||
{shareInfo && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
|
||||
{selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf ? '当前文件分享链接' : '项目分享链接'}
|
||||
</label>
|
||||
<Input
|
||||
value={selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
|
||||
? `${window.location.origin}${shareInfo.share_url}?file=${encodeURIComponent(selectedNodeKey)}`
|
||||
: `${window.location.origin}${shareInfo.share_url}`
|
||||
}
|
||||
readOnly
|
||||
addonAfter={
|
||||
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
|
||||
当前文件
|
||||
</label>
|
||||
<Input value={selectedNodeKey || ''} readOnly />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>访问密码保护</span>
|
||||
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{hasPassword && (
|
||||
{shareInfo?.share_url ? (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
|
||||
分享链接
|
||||
</label>
|
||||
<Input
|
||||
value={`${window.location.origin}${shareInfo.share_url}`}
|
||||
readOnly
|
||||
addonAfter={
|
||||
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>访问密码保护</span>
|
||||
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{hasPassword && (
|
||||
<div>
|
||||
<Input.Password
|
||||
placeholder="请输入访问密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSavePassword}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
保存密码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
|
||||
当前文件尚未创建独立分享。文件分享不受项目是否公开影响,分享页只包含该文件本身。
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>访问密码保护</span>
|
||||
<Switch checked={hasPassword} onChange={setHasPassword} />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{hasPassword && (
|
||||
<Input.Password
|
||||
placeholder="请输入访问密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSavePassword}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
保存密码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
)}
|
||||
|
||||
<Button type="primary" onClick={handleCreateShare}>
|
||||
创建文件分享
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{shareInfo?.share_url && (
|
||||
<Button danger onClick={handleDisableShare}>
|
||||
关闭文件分享
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Layout, Button, Modal, Input, Spin, Anchor } from 'antd'
|
||||
import { CloseOutlined, LockOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
|
||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||
import {
|
||||
getFileSharePublicInfo,
|
||||
verifyFileSharePassword,
|
||||
getFileShareContent,
|
||||
exportFileSharePDF,
|
||||
} from '@/api/share'
|
||||
import './PreviewPage.css'
|
||||
|
||||
const { Content, Sider } = Layout
|
||||
|
||||
function FileSharePage() {
|
||||
const { shareCode } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const contentRef = useRef(null)
|
||||
const [shareInfo, setShareInfo] = useState(null)
|
||||
const [contentInfo, setContentInfo] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [tocCollapsed, setTocCollapsed] = useState(false)
|
||||
const [tocItems, setTocItems] = useState([])
|
||||
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadFileShare()
|
||||
}, [shareCode])
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768)
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const markdownContent = contentInfo?.type === 'markdown' ? (contentInfo.content || '') : ''
|
||||
if (!markdownContent) {
|
||||
setTocItems([])
|
||||
return
|
||||
}
|
||||
|
||||
const slugger = new GithubSlugger()
|
||||
const headings = []
|
||||
markdownContent.split('\n').forEach((line) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (!match) return
|
||||
const level = match[1].length
|
||||
const title = match[2].trim()
|
||||
const key = slugger.slug(title)
|
||||
headings.push({ key: `#${key}`, href: `#${key}`, title, level })
|
||||
})
|
||||
setTocItems(headings)
|
||||
}, [contentInfo])
|
||||
|
||||
const handleClose = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1)
|
||||
return
|
||||
}
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
const loadFileShare = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const infoRes = await getFileSharePublicInfo(shareCode)
|
||||
setShareInfo(infoRes.data)
|
||||
|
||||
if (infoRes.data.has_password) {
|
||||
setContentInfo(null)
|
||||
setPasswordModalVisible(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const contentRes = await getFileShareContent(shareCode)
|
||||
setContentInfo(contentRes.data)
|
||||
} catch (error) {
|
||||
console.error('Load file share error:', error)
|
||||
Toast.error('加载失败', '分享链接不存在或已失效')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyPassword = async () => {
|
||||
if (!password.trim()) {
|
||||
Toast.warning('提示', '请输入访问密码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await verifyFileSharePassword(shareCode, password)
|
||||
const contentRes = await getFileShareContent(shareCode, password)
|
||||
setContentInfo(contentRes.data)
|
||||
setPasswordModalVisible(false)
|
||||
Toast.success('验证成功')
|
||||
} catch (error) {
|
||||
Toast.error('访问密码错误')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPDF = () => {
|
||||
if (!contentInfo || contentInfo.type === 'pdf') return
|
||||
window.open(exportFileSharePDF(shareCode), '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preview-page file-share-page">
|
||||
<div className="file-share-shell">
|
||||
<div className="file-share-header">
|
||||
<div className="file-share-meta">
|
||||
<h1>{shareInfo?.name || '文件分享'}</h1>
|
||||
{shareInfo?.project_name && <p>{shareInfo.project_name}</p>}
|
||||
</div>
|
||||
<Button type="text" icon={<CloseOutlined />} onClick={handleClose} className="preview-close-btn" />
|
||||
</div>
|
||||
|
||||
<Layout className="file-share-content-layout">
|
||||
<Content className="file-share-content" ref={contentRef}>
|
||||
{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' : ''}`}>
|
||||
{contentInfo?.type === 'pdf' ? (
|
||||
<VirtualPDFViewer url={contentInfo.document_url} filename={contentInfo.filename} />
|
||||
) : (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug, rehypeHighlight]}>
|
||||
{contentInfo?.content || ''}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentInfo?.type === 'markdown' && (
|
||||
<DocFloatActions
|
||||
scrollRef={contentRef}
|
||||
right={!isMobile && !tocCollapsed ? 280 : 24}
|
||||
onExportPDF={handleExportPDF}
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
|
||||
{!isMobile && contentInfo?.type === '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' }} />
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div className="toc-empty">当前文档无标题</div>
|
||||
)}
|
||||
</div>
|
||||
</Sider>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
{!isMobile && contentInfo?.type === 'markdown' && tocCollapsed && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<MenuUnfoldOutlined />}
|
||||
className="toc-toggle-btn"
|
||||
onClick={() => setTocCollapsed(false)}
|
||||
>
|
||||
文档索引
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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,72 @@
|
|||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.file-share-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-share-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.file-share-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--header-bg);
|
||||
}
|
||||
|
||||
.file-share-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-share-meta h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.file-share-meta p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.file-share-content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.file-share-content-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.preview-close-btn.ant-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.preview-sider-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preview-layout {
|
||||
height: 100%;
|
||||
background: var(--bg-color);
|
||||
|
|
@ -37,26 +103,40 @@
|
|||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-search {
|
||||
padding: 12px 16px 4px;
|
||||
}
|
||||
|
||||
.preview-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: none;
|
||||
background: var(--sider-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 修复文档名过长的显示问题 */
|
||||
.preview-menu .ant-menu-title-content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-menu .ant-menu-item,
|
||||
.preview-menu .ant-menu-submenu-title {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-menu .ant-menu-title-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-menu-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview-content-layout {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
|
@ -288,6 +368,17 @@
|
|||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.mobile-close-btn {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
background: var(--header-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
.preview-content-wrapper {
|
||||
|
|
@ -323,6 +414,10 @@
|
|||
.markdown-body td {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.file-share-header {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板响应式样式 */
|
||||
|
|
@ -368,4 +463,4 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* 打印样式优化已移除,转向后端生成方案 */
|
||||
/* 打印样式优化已移除,转向后端生成方案 */
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
|
|
@ -12,39 +10,38 @@ import 'highlight.js/styles/github.css'
|
|||
import Mark from 'mark.js'
|
||||
import Highlighter from 'react-highlight-words'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl, exportPDF } from '@/api/share'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
|
||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||
import {
|
||||
getProjectSharePublicInfo,
|
||||
getProjectShareTree,
|
||||
searchProjectShareDocuments,
|
||||
getProjectShareFile,
|
||||
verifyProjectSharePassword,
|
||||
getProjectShareDocumentUrl,
|
||||
exportProjectSharePDF,
|
||||
} from '@/api/share'
|
||||
import './PreviewPage.css'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
||||
// 高亮组件 (用于 Tree)
|
||||
const HighlightText = ({ text, keyword }) => {
|
||||
if (!keyword || !text) return text;
|
||||
if (!keyword || !text) return text
|
||||
return (
|
||||
<Highlighter
|
||||
highlightClassName="search-highlight"
|
||||
searchWords={[keyword]}
|
||||
autoEscape={true}
|
||||
autoEscape
|
||||
textToHighlight={text}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewPage() {
|
||||
const { projectId } = useParams()
|
||||
function ProjectSharePage() {
|
||||
const { shareCode } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const handleClose = () => {
|
||||
// 检查是否有历史记录可回退
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1)
|
||||
} else {
|
||||
navigate('/projects')
|
||||
}
|
||||
}
|
||||
const [projectInfo, setProjectInfo] = useState(null)
|
||||
const [fileTree, setFileTree] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState('')
|
||||
|
|
@ -55,134 +52,91 @@ function PreviewPage() {
|
|||
const [tocItems, setTocItems] = useState([])
|
||||
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [accessPassword, setAccessPassword] = useState(null)
|
||||
const [siderCollapsed, setSiderCollapsed] = useState(false)
|
||||
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
|
||||
const [pdfUrl, setPdfUrl] = useState('')
|
||||
const [pdfFilename, setPdfFilename] = useState('')
|
||||
const [viewMode, setViewMode] = useState('markdown')
|
||||
|
||||
// 搜索相关
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const contentRef = useRef(null)
|
||||
const viewerRef = useRef(null)
|
||||
|
||||
// mark.js 高亮
|
||||
useEffect(() => {
|
||||
if (viewerRef.current && viewMode === 'markdown') {
|
||||
const instance = new Mark(viewerRef.current)
|
||||
instance.unmark()
|
||||
|
||||
if (searchKeyword.trim()) {
|
||||
instance.mark(searchKeyword, {
|
||||
element: 'span',
|
||||
className: 'search-highlight',
|
||||
exclude: ['pre', 'code', '.toc-content']
|
||||
})
|
||||
}
|
||||
const handleClose = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1)
|
||||
return
|
||||
}
|
||||
}, [markdownContent, searchKeyword, viewMode])
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
// 检测是否为移动设备
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768)
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadProjectInfo()
|
||||
}, [projectId])
|
||||
if (viewerRef.current && viewMode === 'markdown') {
|
||||
const instance = new Mark(viewerRef.current)
|
||||
instance.unmark()
|
||||
if (searchKeyword.trim()) {
|
||||
instance.mark(searchKeyword, {
|
||||
element: 'span',
|
||||
className: 'search-highlight',
|
||||
exclude: ['pre', 'code', '.toc-content'],
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [markdownContent, searchKeyword, viewMode])
|
||||
|
||||
useEffect(() => {
|
||||
loadProjectInfo()
|
||||
}, [shareCode])
|
||||
|
||||
// 监听 URL 参数变化,处理文件导航和搜索
|
||||
useEffect(() => {
|
||||
if (fileTree.length === 0) return
|
||||
|
||||
const fileParam = searchParams.get('file')
|
||||
const keywordParam = searchParams.get('keyword')
|
||||
|
||||
if (keywordParam && keywordParam !== searchKeyword) {
|
||||
handleSearch(keywordParam)
|
||||
}
|
||||
|
||||
if (fileParam) {
|
||||
if (fileParam !== selectedFile) {
|
||||
// Deep link to file
|
||||
if (fileParam.toLowerCase().endsWith('.pdf')) {
|
||||
let url = getPreviewDocumentUrl(projectId, fileParam)
|
||||
const params = []
|
||||
if (accessPassword) params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) params.push(`token=${encodeURIComponent(token)}`)
|
||||
if (params.length > 0) url += `?${params.join('&')}`
|
||||
|
||||
setSelectedFile(fileParam)
|
||||
setPdfUrl(url)
|
||||
setPdfFilename(fileParam.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
} else {
|
||||
setSelectedFile(fileParam)
|
||||
loadMarkdown(fileParam, accessPassword)
|
||||
setViewMode('markdown')
|
||||
}
|
||||
|
||||
// Expand tree to file
|
||||
const parts = fileParam.split('/')
|
||||
const allParentPaths = []
|
||||
let currentPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
allParentPaths.push(currentPath)
|
||||
}
|
||||
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
|
||||
}
|
||||
} else {
|
||||
if (!selectedFile) {
|
||||
const readmeNode = findReadme(fileTree)
|
||||
if (readmeNode) {
|
||||
setSelectedFile(readmeNode.key)
|
||||
loadMarkdown(readmeNode.key, accessPassword)
|
||||
}
|
||||
openSharedFile(fileParam)
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
const readmeNode = findReadme(fileTree)
|
||||
if (readmeNode) {
|
||||
openSharedFile(readmeNode.key)
|
||||
}
|
||||
}
|
||||
}, [searchParams, fileTree, accessPassword])
|
||||
}, [fileTree, searchParams])
|
||||
|
||||
// 加载项目基本信息
|
||||
const loadProjectInfo = async () => {
|
||||
try {
|
||||
const res = await getPreviewInfo(projectId)
|
||||
const res = await getProjectSharePublicInfo(shareCode)
|
||||
const info = res.data
|
||||
setProjectInfo(info)
|
||||
|
||||
if (info.has_password) {
|
||||
setPasswordModalVisible(true)
|
||||
} else {
|
||||
loadFileTree()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load project info error:', error)
|
||||
Toast.error('加载失败', '项目不存在或已被删除')
|
||||
console.error('Load project share info error:', error)
|
||||
Toast.error('加载失败', '分享链接不存在或已失效')
|
||||
}
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const handleVerifyPassword = async () => {
|
||||
if (!password.trim()) {
|
||||
Toast.warning('提示', '请输入访问密码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyAccessPassword(projectId, password)
|
||||
setAccessPassword(password)
|
||||
await verifyProjectSharePassword(shareCode, password)
|
||||
setPasswordModalVisible(false)
|
||||
loadFileTree(password)
|
||||
Toast.success('验证成功')
|
||||
|
|
@ -191,308 +145,220 @@ function PreviewPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// 加载文件树
|
||||
const loadFileTree = async (pwd = null) => {
|
||||
try {
|
||||
const res = await getPreviewTree(projectId, pwd || accessPassword)
|
||||
const tree = res.data || []
|
||||
setFileTree(tree)
|
||||
const res = await getProjectShareTree(shareCode, pwd)
|
||||
setFileTree(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Load file tree error:', error)
|
||||
console.error('Load share tree error:', error)
|
||||
if (error.response?.status === 403) {
|
||||
Toast.error('访问密码错误或已过期')
|
||||
setPasswordModalVisible(true)
|
||||
} else {
|
||||
Toast.error('加载失败', '目录加载失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = async (value) => {
|
||||
setSearchKeyword(value)
|
||||
if (!value.trim()) {
|
||||
setMatchedFilePaths(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const res = await searchDocuments(value, projectId)
|
||||
const paths = new Set(res.data.map(item => item.file_path))
|
||||
setMatchedFilePaths(paths)
|
||||
|
||||
// 自动展开匹配的节点 (Assuming this comment might be there or not, better context: keysToExpand)
|
||||
const keysToExpand = new Set(openKeys)
|
||||
res.data.forEach(item => {
|
||||
const parts = item.file_path.split('/')
|
||||
let currentPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
keysToExpand.add(currentPath)
|
||||
}
|
||||
})
|
||||
setOpenKeys(Array.from(keysToExpand))
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
Toast.error('搜索失败', '请稍后重试')
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤树
|
||||
const filteredTreeData = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return fileTree
|
||||
|
||||
const loop = (data) => {
|
||||
const result = []
|
||||
for (const node of data) {
|
||||
const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
const contentMatch = matchedFilePaths.has(node.key)
|
||||
|
||||
if (node.children) {
|
||||
const children = loop(node.children)
|
||||
if (children.length > 0 || titleMatch) {
|
||||
result.push({ ...node, children })
|
||||
}
|
||||
} else {
|
||||
if (titleMatch || contentMatch) {
|
||||
result.push(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return loop(fileTree)
|
||||
}, [fileTree, searchKeyword, matchedFilePaths])
|
||||
|
||||
const findReadme = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.title === 'README.md' && node.isLeaf) {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const convertTreeToMenuItems = (nodes) => {
|
||||
return nodes.map((node) => {
|
||||
const labelNode = node.title.replace('.md', '')
|
||||
|
||||
if (!node.isLeaf) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title,
|
||||
icon: <FolderOutlined />,
|
||||
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
||||
}
|
||||
} else if (node.title && node.title.endsWith('.md')) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: labelNode,
|
||||
icon: <FileTextOutlined />,
|
||||
}
|
||||
} else if (node.title && node.title.endsWith('.pdf')) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.title,
|
||||
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
const loadMarkdown = async (filePath, pwd = null) => {
|
||||
setLoading(true)
|
||||
setTocItems([])
|
||||
try {
|
||||
const res = await getPreviewFile(projectId, filePath, pwd || accessPassword)
|
||||
const res = await getProjectShareFile(shareCode, filePath, pwd)
|
||||
setMarkdownContent(res.data?.content || '')
|
||||
|
||||
if (isMobile) {
|
||||
setMobileDrawerVisible(false)
|
||||
}
|
||||
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load markdown error:', error)
|
||||
console.error('Load share markdown error:', error)
|
||||
if (error.response?.status === 403) {
|
||||
Toast.error('访问密码错误或已过期')
|
||||
setPasswordModalVisible(true)
|
||||
} else {
|
||||
Toast.error('加载失败', '文档加载失败,请稍后重试')
|
||||
setMarkdownContent('')
|
||||
Toast.error('加载失败', '文档加载失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (markdownContent) {
|
||||
const slugger = new GithubSlugger()
|
||||
const headings = []
|
||||
const lines = markdownContent.split('\n')
|
||||
const handleSearch = async (value) => {
|
||||
const keyword = value || ''
|
||||
setSearchKeyword(keyword)
|
||||
|
||||
lines.forEach((line) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (match) {
|
||||
const level = match[1].length
|
||||
const title = match[2]
|
||||
// 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致
|
||||
const key = slugger.slug(title)
|
||||
|
||||
headings.push({
|
||||
key: `#${key}`,
|
||||
href: `#${key}`,
|
||||
title,
|
||||
level,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
setTocItems(headings)
|
||||
}
|
||||
}, [markdownContent])
|
||||
|
||||
const resolveRelativePath = (currentPath, relativePath) => {
|
||||
if (relativePath.startsWith('/')) {
|
||||
return relativePath.substring(1)
|
||||
}
|
||||
|
||||
const lastSlashIndex = currentPath.lastIndexOf('/')
|
||||
const currentDir = lastSlashIndex !== -1 ? currentPath.substring(0, lastSlashIndex) : ''
|
||||
|
||||
const parts = relativePath.split('/')
|
||||
const dirParts = currentDir ? currentDir.split('/') : []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '..') {
|
||||
dirParts.pop()
|
||||
} else if (part !== '.' && part !== '') {
|
||||
dirParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return dirParts.join('/')
|
||||
}
|
||||
|
||||
const handleMarkdownLink = (e, href) => {
|
||||
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) {
|
||||
if (!keyword.trim()) {
|
||||
setMatchedFilePaths(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const res = await searchProjectShareDocuments(shareCode, keyword)
|
||||
const paths = new Set((res.data || []).map((item) => item.file_path))
|
||||
setMatchedFilePaths(paths)
|
||||
|
||||
const keysToExpand = new Set(openKeys)
|
||||
;(res.data || []).forEach((item) => {
|
||||
;(item.parent_paths || []).forEach((parentPath) => keysToExpand.add(parentPath))
|
||||
})
|
||||
setOpenKeys(Array.from(keysToExpand))
|
||||
} catch (error) {
|
||||
console.error('Search share documents error:', error)
|
||||
Toast.error('搜索失败', '全文检索暂时不可用')
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!markdownContent) return
|
||||
|
||||
const slugger = new GithubSlugger()
|
||||
const headings = []
|
||||
markdownContent.split('\n').forEach((line) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (!match) return
|
||||
const level = match[1].length
|
||||
const title = match[2]
|
||||
const key = slugger.slug(title)
|
||||
headings.push({ key: `#${key}`, href: `#${key}`, title, level })
|
||||
})
|
||||
setTocItems(headings)
|
||||
}, [markdownContent])
|
||||
|
||||
const findReadme = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.title === 'README.md' && node.isLeaf) return node
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const filteredTreeData = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return fileTree
|
||||
|
||||
const loop = (nodes) => {
|
||||
const result = []
|
||||
for (const node of nodes) {
|
||||
const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
const contentMatch = matchedFilePaths.has(node.key)
|
||||
|
||||
if (node.children?.length) {
|
||||
const children = loop(node.children)
|
||||
if (children.length > 0 || titleMatch) {
|
||||
result.push({ ...node, children })
|
||||
}
|
||||
} else if (titleMatch || contentMatch) {
|
||||
result.push(node)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return loop(fileTree)
|
||||
}, [fileTree, searchKeyword, matchedFilePaths])
|
||||
|
||||
const convertTreeToMenuItems = (nodes) => {
|
||||
return nodes.map((node) => {
|
||||
const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title
|
||||
const labelNode = (
|
||||
<Tooltip title={node.title} placement="right">
|
||||
<span className="preview-menu-label">{titleText}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
if (!node.isLeaf) {
|
||||
return {
|
||||
key: node.key,
|
||||
label: labelNode,
|
||||
icon: <FolderOutlined />,
|
||||
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 openSharedFile = (key) => {
|
||||
setSelectedFile(key)
|
||||
|
||||
const parts = key.split('/')
|
||||
const allParentPaths = []
|
||||
let currentPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
allParentPaths.push(currentPath)
|
||||
}
|
||||
if (allParentPaths.length > 0) {
|
||||
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
|
||||
}
|
||||
|
||||
if (key.toLowerCase().endsWith('.pdf')) {
|
||||
setPdfUrl(getProjectShareDocumentUrl(shareCode, key))
|
||||
setPdfFilename(key.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
return
|
||||
}
|
||||
|
||||
setViewMode('markdown')
|
||||
loadMarkdown(key)
|
||||
}
|
||||
|
||||
const handleMarkdownLink = (e, href) => {
|
||||
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) return
|
||||
const isMd = href.endsWith('.md')
|
||||
const isPdf = href.toLowerCase().endsWith('.pdf')
|
||||
|
||||
if (!isMd && !isPdf) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
let decodedHref = href
|
||||
try {
|
||||
decodedHref = decodeURIComponent(href)
|
||||
} catch (err) {
|
||||
}
|
||||
|
||||
const targetPath = resolveRelativePath(selectedFile, decodedHref)
|
||||
|
||||
const lastSlashIndex = targetPath.lastIndexOf('/')
|
||||
const parentPath = lastSlashIndex !== -1 ? targetPath.substring(0, lastSlashIndex) : ''
|
||||
if (parentPath && !openKeys.includes(parentPath)) {
|
||||
const pathParts = parentPath.split('/')
|
||||
const allParentPaths = []
|
||||
let currentPath = ''
|
||||
for (const part of pathParts) {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
||||
allParentPaths.push(currentPath)
|
||||
}
|
||||
setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
|
||||
}
|
||||
|
||||
handleMenuClick({ key: targetPath })
|
||||
} catch {}
|
||||
openSharedFile(resolveRelativePath(selectedFile, decodedHref))
|
||||
}
|
||||
|
||||
const handleContentClick = (e) => {
|
||||
const target = e.target.closest('a')
|
||||
if (target) {
|
||||
const href = target.getAttribute('href')
|
||||
if (href) {
|
||||
handleMarkdownLink(e, href)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
setSelectedFile(key)
|
||||
|
||||
if (key.toLowerCase().endsWith('.pdf')) {
|
||||
let url = getPreviewDocumentUrl(projectId, key)
|
||||
const params = []
|
||||
|
||||
if (accessPassword) {
|
||||
params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
params.push(`token=${encodeURIComponent(token)}`)
|
||||
}
|
||||
|
||||
if (params.length > 0) {
|
||||
url += `?${params.join('&')}`
|
||||
}
|
||||
|
||||
setPdfUrl(url)
|
||||
setPdfFilename(key.split('/').pop())
|
||||
setViewMode('pdf')
|
||||
} else {
|
||||
setViewMode('markdown')
|
||||
loadMarkdown(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出 PDF 处理
|
||||
const handleExportPDF = () => {
|
||||
if (!selectedFile) return
|
||||
if (viewMode === 'pdf') {
|
||||
// 如果已经是 PDF 文件,直接下载
|
||||
const link = document.createElement('a')
|
||||
link.href = pdfUrl
|
||||
link.download = pdfFilename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} else {
|
||||
// Markdown 文件:使用后端生成 PDF
|
||||
let url = exportPDF(projectId, selectedFile)
|
||||
const params = []
|
||||
|
||||
if (accessPassword) {
|
||||
params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
params.push(`token=${encodeURIComponent(token)}`)
|
||||
}
|
||||
|
||||
if (params.length > 0) {
|
||||
url += (url.includes('?') ? '&' : '?') + params.join('&')
|
||||
}
|
||||
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
}
|
||||
window.open(exportProjectSharePDF(shareCode, selectedFile), '_blank')
|
||||
}
|
||||
|
||||
const menuItems = convertTreeToMenuItems(filteredTreeData)
|
||||
const menuItems = useMemo(() => convertTreeToMenuItems(filteredTreeData), [filteredTreeData])
|
||||
|
||||
return (
|
||||
<div className="preview-page">
|
||||
<Layout className="preview-layout">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
className="mobile-close-btn"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<MenuOutlined />}
|
||||
|
|
@ -502,28 +368,16 @@ function PreviewPage() {
|
|||
目录索引
|
||||
</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>
|
||||
}
|
||||
title={projectInfo?.name || '项目分享'}
|
||||
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>
|
||||
)}
|
||||
{projectInfo?.description && <p className="preview-project-desc">{projectInfo.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div style={{ padding: '0 0 12px' }}>
|
||||
<div className="preview-search">
|
||||
<Input.Search
|
||||
placeholder="搜索文档内容..."
|
||||
allowClear
|
||||
|
|
@ -534,47 +388,31 @@ function PreviewPage() {
|
|||
enterButton
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredTreeData.length > 0 ? (
|
||||
{menuItems.length > 0 ? (
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedFile]}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={setOpenKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
onClick={({ key }) => openSharedFile(key)}
|
||||
className="preview-menu"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
|
||||
</div>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
) : (
|
||||
<Sider
|
||||
width={280}
|
||||
className="preview-sider"
|
||||
theme="light"
|
||||
collapsed={siderCollapsed}
|
||||
collapsedWidth={0}
|
||||
>
|
||||
<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 className="preview-sider-title-row">
|
||||
<h2 style={{ margin: 0 }}>{projectInfo?.name || '项目分享'}</h2>
|
||||
<Button type="text" icon={<CloseOutlined />} onClick={handleClose} className="preview-close-btn" />
|
||||
</div>
|
||||
{projectInfo?.description && (
|
||||
<p className="preview-project-desc">{projectInfo.description}</p>
|
||||
)}
|
||||
{projectInfo?.description && <p className="preview-project-desc">{projectInfo.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div style={{ padding: '12px 16px 4px' }}>
|
||||
<div className="preview-search">
|
||||
<Input.Search
|
||||
placeholder="搜索文档内容..."
|
||||
allowClear
|
||||
|
|
@ -585,20 +423,19 @@ function PreviewPage() {
|
|||
enterButton
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredTreeData.length > 0 ? (
|
||||
{menuItems.length > 0 ? (
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedFile]}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={setOpenKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
onClick={({ key }) => openSharedFile(key)}
|
||||
className="preview-menu"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
|
||||
<div style={{ padding: 20 }}>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
|
||||
</div>
|
||||
)}
|
||||
</Sider>
|
||||
|
|
@ -614,16 +451,16 @@ function PreviewPage() {
|
|||
</Spin>
|
||||
</div>
|
||||
) : viewMode === 'pdf' ? (
|
||||
<VirtualPDFViewer
|
||||
url={pdfUrl}
|
||||
filename={pdfFilename}
|
||||
/>
|
||||
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} />
|
||||
) : (
|
||||
<div className="markdown-body" onClick={handleContentClick} ref={viewerRef}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug, rehypeHighlight]}
|
||||
>
|
||||
<div className="markdown-body" onClick={(e) => {
|
||||
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]}>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
|
@ -643,12 +480,7 @@ function PreviewPage() {
|
|||
<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)}
|
||||
/>
|
||||
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={() => setTocCollapsed(true)} />
|
||||
</div>
|
||||
<div className="toc-content">
|
||||
{tocItems.length > 0 ? (
|
||||
|
|
@ -688,12 +520,7 @@ function PreviewPage() {
|
|||
</Layout>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<LockOutlined />
|
||||
<span>访问验证</span>
|
||||
</div>
|
||||
}
|
||||
title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>}
|
||||
open={passwordModalVisible}
|
||||
onOk={handleVerifyPassword}
|
||||
onCancel={() => setPasswordModalVisible(false)}
|
||||
|
|
@ -702,7 +529,7 @@ function PreviewPage() {
|
|||
maskClosable={false}
|
||||
>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p>该项目需要访问密码,请输入密码后继续浏览。</p>
|
||||
<p>该分享需要访问密码,请输入密码后继续浏览。</p>
|
||||
<Input.Password
|
||||
placeholder="请输入访问密码"
|
||||
value={password}
|
||||
|
|
@ -716,4 +543,4 @@ function PreviewPage() {
|
|||
)
|
||||
}
|
||||
|
||||
export default PreviewPage
|
||||
export default ProjectSharePage
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
|
||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
||||
import { getUserList } from '@/api/users'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import ListActionBar from '@/components/ListActionBar/ListActionBar'
|
||||
|
|
@ -16,7 +16,6 @@ function ProjectList({ type = 'my' }) {
|
|||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editModalVisible, setEditModalVisible] = useState(false)
|
||||
const [gitModalVisible, setGitModalVisible] = useState(false)
|
||||
const [shareModalVisible, setShareModalVisible] = useState(false)
|
||||
const [membersModalVisible, setMembersModalVisible] = useState(false)
|
||||
const [currentProject, setCurrentProject] = useState(null)
|
||||
const [shareInfo, setShareInfo] = useState(null)
|
||||
|
|
@ -149,6 +148,9 @@ function ProjectList({ type = 'my' }) {
|
|||
description: project.description,
|
||||
is_public: project.is_public === 1,
|
||||
})
|
||||
setShareInfo(project.is_public === 1 ? null : { enabled: false, share_url: null, has_password: false, access_pass: null })
|
||||
setHasPassword(false)
|
||||
setPassword('')
|
||||
setEditModalVisible(true)
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +171,59 @@ function ProjectList({ type = 'my' }) {
|
|||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editModalVisible || !currentProject) return
|
||||
|
||||
const isPublic = editForm.getFieldValue('is_public')
|
||||
if (!isPublic) {
|
||||
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
|
||||
return
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await getProjectShareInfo(currentProject.id)
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(res.data.has_password)
|
||||
setPassword(res.data.access_pass || '')
|
||||
} catch (error) {
|
||||
console.error('Get project share info error:', error)
|
||||
}
|
||||
})()
|
||||
}, [editModalVisible, currentProject, editForm])
|
||||
|
||||
const currentEditPublic = Form.useWatch('is_public', editForm)
|
||||
const isPublicEnablePending = editModalVisible && currentEditPublic && currentProject?.is_public !== 1
|
||||
|
||||
useEffect(() => {
|
||||
if (!editModalVisible || !currentProject) return
|
||||
|
||||
if (!currentEditPublic) {
|
||||
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
|
||||
setHasPassword(false)
|
||||
setPassword('')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentProject.is_public !== 1) {
|
||||
setShareInfo(null)
|
||||
setHasPassword(false)
|
||||
setPassword('')
|
||||
return
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await getProjectShareInfo(currentProject.id)
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(res.data.has_password)
|
||||
setPassword(res.data.access_pass || '')
|
||||
} catch (error) {
|
||||
console.error('Refresh project share info error:', error)
|
||||
}
|
||||
})()
|
||||
}, [currentEditPublic, editModalVisible, currentProject, editForm])
|
||||
|
||||
const [gitRepos, setGitRepos] = useState([])
|
||||
const [loadingRepos, setLoadingRepos] = useState(false)
|
||||
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
|
||||
|
|
@ -264,22 +319,6 @@ function ProjectList({ type = 'my' }) {
|
|||
navigate(`/projects/${projectId}/docs`)
|
||||
}
|
||||
|
||||
// 打开分享设置
|
||||
const handleShare = async (e, project) => {
|
||||
e.stopPropagation()
|
||||
setCurrentProject(project)
|
||||
try {
|
||||
const res = await getProjectShareInfo(project.id)
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(res.data.has_password)
|
||||
setPassword('')
|
||||
setShareModalVisible(true)
|
||||
} catch (error) {
|
||||
console.error('Get share info error:', error)
|
||||
message.error('获取分享信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 复制分享链接
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareInfo) return
|
||||
|
|
@ -316,13 +355,11 @@ function ProjectList({ type = 'my' }) {
|
|||
// 切换密码保护
|
||||
const handlePasswordToggle = async (checked) => {
|
||||
if (!checked) {
|
||||
// 取消密码
|
||||
try {
|
||||
await updateShareSettings(currentProject.id, { access_pass: null })
|
||||
await updateProjectShareSettings(currentProject.id, { access_pass: null })
|
||||
setHasPassword(false)
|
||||
setPassword('')
|
||||
message.success('已取消访问密码')
|
||||
// 刷新分享信息
|
||||
const res = await getProjectShareInfo(currentProject.id)
|
||||
setShareInfo(res.data)
|
||||
} catch (error) {
|
||||
|
|
@ -341,9 +378,8 @@ function ProjectList({ type = 'my' }) {
|
|||
return
|
||||
}
|
||||
try {
|
||||
await updateShareSettings(currentProject.id, { access_pass: password })
|
||||
await updateProjectShareSettings(currentProject.id, { access_pass: password })
|
||||
message.success('访问密码已设置')
|
||||
// 刷新分享信息
|
||||
const res = await getProjectShareInfo(currentProject.id)
|
||||
setShareInfo(res.data)
|
||||
setHasPassword(true)
|
||||
|
|
@ -565,11 +601,9 @@ function ProjectList({ type = 'my' }) {
|
|||
actions={type === 'my' ? [
|
||||
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
|
||||
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
|
||||
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
||||
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||||
] : [
|
||||
<EyeOutlined key="view" />,
|
||||
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
||||
]}
|
||||
>
|
||||
{/* 公开项目标识 */}
|
||||
|
|
@ -720,6 +754,46 @@ function ProjectList({ type = 'my' }) {
|
|||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="项目公开分享">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{!editForm.getFieldValue('is_public') ? (
|
||||
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
|
||||
开启“公开项目”后,才可以生成项目分享链接和配置访问密码。
|
||||
</div>
|
||||
) : isPublicEnablePending ? (
|
||||
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
|
||||
保存项目后将自动生成新的项目分享链接。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={shareInfo?.share_url ? `${window.location.origin}${shareInfo.share_url}` : '正在生成分享链接...'}
|
||||
readOnly
|
||||
addonAfter={
|
||||
shareInfo?.share_url ? <CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} /> : null
|
||||
}
|
||||
/>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>访问密码保护</span>
|
||||
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
|
||||
</Space>
|
||||
{hasPassword && (
|
||||
<div>
|
||||
<Input.Password
|
||||
placeholder="请输入访问密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSavePassword} style={{ marginTop: 8 }}>
|
||||
保存密码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
|
|
@ -797,65 +871,6 @@ function ProjectList({ type = 'my' }) {
|
|||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="分享设置"
|
||||
open={shareModalVisible}
|
||||
onCancel={() => setShareModalVisible(false)}
|
||||
footer={null}
|
||||
width={500}
|
||||
>
|
||||
{shareInfo && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>链接:</label>
|
||||
<Input
|
||||
value={`${window.location.origin}${shareInfo.share_url}`}
|
||||
readOnly
|
||||
addonAfter={
|
||||
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 只有在我的项目中才显示密码设置功能 */}
|
||||
{type === 'my' && (
|
||||
<>
|
||||
<div>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>访问密码保护</span>
|
||||
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{hasPassword && (
|
||||
<div>
|
||||
<Input.Password
|
||||
placeholder="请输入访问密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSavePassword}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
保存密码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 参与项目显示提示 */}
|
||||
{type === 'share' && shareInfo.has_password && (
|
||||
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
|
||||
该项目已设置访问密码保护
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="成员管理"
|
||||
open={membersModalVisible}
|
||||
|
|
|
|||
Loading…
Reference in New Issue