main
mula.liu 2026-05-09 10:45:30 +08:00
parent f140ed9218
commit fc617cf678
23 changed files with 1974 additions and 811 deletions

View File

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

View File

@ -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.user import User
from app.models.project import Project, ProjectMember from app.models.project import Project, ProjectMember
from app.models.log import OperationLog from app.models.log import OperationLog
from app.models.share import ShareLink
from app.schemas.file import ( from app.schemas.file import (
FileTreeNode, FileTreeNode,
FileSaveRequest, FileSaveRequest,
@ -73,6 +74,14 @@ async def check_project_access(
return project return project
def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None:
"""为文件树节点补充分享状态"""
for node in tree:
node.is_shared = bool(node.isLeaf and node.key in shared_paths)
if node.children:
annotate_shared_files(node.children, shared_paths)
@router.get("/{project_id}/tree", response_model=dict) @router.get("/{project_id}/tree", response_model=dict)
async def get_project_tree( async def get_project_tree(
project_id: int, project_id: int,
@ -88,6 +97,20 @@ async def get_project_tree(
# 生成目录树 # 生成目录树
tree = storage_service.generate_tree(project_root) 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" # 默认是所有者 user_role = "owner" # 默认是所有者
if project.owner_id != current_user.id: 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-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Type": "application/pdf" "Content-Type": "application/pdf"
} }
) )

View File

@ -3,15 +3,17 @@
""" """
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_ from sqlalchemy import delete, select, or_
from typing import List from typing import List
import uuid import uuid
import secrets
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_current_user from app.core.deps import get_current_user
from app.models.user import User from app.models.user import User
from app.models.project import Project, ProjectMember from app.models.project import Project, ProjectMember
from app.models.git_repo import ProjectGitRepo from app.models.git_repo import ProjectGitRepo
from app.models.share import ShareLink
from app.schemas.project import ( from app.schemas.project import (
ProjectCreate, ProjectCreate,
ProjectUpdate, ProjectUpdate,
@ -19,8 +21,6 @@ from app.schemas.project import (
ProjectMemberAdd, ProjectMemberAdd,
ProjectMemberUpdate, ProjectMemberUpdate,
ProjectMemberResponse, ProjectMemberResponse,
ProjectShareSettings,
ProjectShareInfo,
ProjectTransfer, ProjectTransfer,
) )
from app.schemas.response import success_response from app.schemas.response import success_response
@ -33,6 +33,11 @@ from app.core.enums import OperationType, ResourceType
router = APIRouter() router = APIRouter()
def generate_share_code() -> str:
"""生成公开分享码"""
return secrets.token_urlsafe(12).replace("-", "").replace("_", "")
def get_document_count(storage_key: str) -> int: def get_document_count(storage_key: str) -> int:
"""计算项目中的文档数量(.md 和 .pdf""" """计算项目中的文档数量(.md 和 .pdf"""
try: try:
@ -242,11 +247,48 @@ async def update_project(
if project.owner_id != current_user.id: if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="无权修改该项目") raise HTTPException(status_code=403, detail="无权修改该项目")
old_is_public = project.is_public
# 更新字段 # 更新字段
update_data = project_in.dict(exclude_unset=True) update_data = project_in.dict(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(project, field, value) setattr(project, field, value)
if "is_public" in update_data:
next_is_public = int(update_data.get("is_public") or 0)
if next_is_public == 0:
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
)
)
elif next_is_public == 1:
if old_is_public != 1:
# 重新公开项目时丢弃历史公开链接,生成新的项目分享链接。
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
)
)
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
ShareLink.status == 1,
)
)
share = share_result.scalar_one_or_none()
if not share:
db.add(ShareLink(
project_id=project_id,
share_type="project",
share_code=generate_share_code(),
created_by=current_user.id,
))
await db.commit() await db.commit()
await db.refresh(project) await db.refresh(project)
@ -608,89 +650,6 @@ async def remove_project_member(
return success_response(message="成员删除成功") return success_response(message="成员删除成功")
@router.get("/{project_id}/share", response_model=dict)
async def get_project_share_info(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取项目分享信息"""
# 查询项目
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) @router.post("/{project_id}/git/pull", response_model=dict)
async def git_pull( async def git_pull(
project_id: int, project_id: int,

View File

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

View File

@ -31,6 +31,8 @@ class OperationType(str, Enum):
# 分享操作 # 分享操作
UPDATE_SHARE_SETTINGS = "update_share_settings" UPDATE_SHARE_SETTINGS = "update_share_settings"
CREATE_SHARE_LINK = "create_share_link"
DELETE_SHARE_LINK = "delete_share_link"
# Git操作 # Git操作
GIT_PULL = "git_pull" GIT_PULL = "git_pull"

View File

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

View File

@ -0,0 +1,27 @@
"""
分享链接模型
"""
from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger
from sqlalchemy.sql import func
from app.core.database import Base
class ShareLink(Base):
"""项目分享/文件分享链接"""
__tablename__ = "share_links"
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="分享ID")
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
share_type = Column(String(20), nullable=False, index=True, comment="分享类型: project/file")
share_code = Column(String(64), nullable=False, unique=True, index=True, comment="公开分享码")
file_path = Column(String(500), comment="文件路径,仅文件分享使用")
access_pass = Column(String(100), comment="访问密码")
created_by = Column(BigInteger, index=True, comment="创建人ID")
status = Column(SmallInteger, default=1, index=True, comment="状态0-禁用 1-启用")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<ShareLink(id={self.id}, type='{self.share_type}', code='{self.share_code}')>"

View File

@ -10,6 +10,7 @@ class FileTreeNode(BaseModel):
title: str = Field(..., description="节点标题(文件/文件夹名)") title: str = Field(..., description="节点标题(文件/文件夹名)")
key: str = Field(..., description="节点唯一键(相对路径)") key: str = Field(..., description="节点唯一键(相对路径)")
isLeaf: bool = Field(..., description="是否叶子节点") isLeaf: bool = Field(..., description="是否叶子节点")
is_shared: bool = Field(False, description="当前文件是否已创建分享链接")
children: Optional[List['FileTreeNode']] = Field(None, description="子节点") children: Optional[List['FileTreeNode']] = Field(None, description="子节点")
class Config: class Config:

View File

@ -23,6 +23,7 @@ class ProjectUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100) name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None description: Optional[str] = None
is_public: Optional[int] = None is_public: Optional[int] = None
public_access_pass: Optional[str] = Field(None, max_length=100)
cover_image: Optional[str] = None cover_image: Optional[str] = None
status: Optional[int] = None status: Optional[int] = None
@ -80,18 +81,33 @@ class ProjectMemberResponse(BaseModel):
from_attributes = True from_attributes = True
class ProjectShareInfo(BaseModel):
"""项目公开分享信息响应 Schema"""
enabled: bool = Field(..., description="是否已开启项目公开")
share_url: Optional[str] = Field(None, description="项目分享链接")
has_password: bool = Field(..., description="是否设置了访问密码")
access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)")
class ProjectShareSettings(BaseModel): class ProjectShareSettings(BaseModel):
"""项目分享设置 Schema""" """项目分享设置 Schema"""
access_pass: Optional[str] = Field(None, max_length=100, description="访问密码None表示取消密码") access_pass: Optional[str] = Field(None, max_length=100, description="访问密码None 表示取消密码)")
class ProjectShareInfo(BaseModel): class FileShareCreate(BaseModel):
"""项目分享信息响应 Schema""" """文件分享创建/更新 Schema"""
share_url: str = Field(..., description="分享链接") file_path: str = Field(..., min_length=1, description="文件路径")
access_pass: Optional[str] = Field(None, max_length=100, description="访问密码")
class FileShareInfo(BaseModel):
"""文件分享信息响应 Schema"""
file_path: str = Field(..., description="文件路径")
share_url: str = Field(..., description="文件分享链接")
has_password: bool = Field(..., description="是否设置了访问密码") has_password: bool = Field(..., description="是否设置了访问密码")
access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)") access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)")
class ProjectTransfer(BaseModel): class ProjectTransfer(BaseModel):
"""转移项目所有权 Schema""" """转移项目所有权 Schema"""
new_owner_id: int = Field(..., description="新所有者ID") new_owner_id: int = Field(..., description="新所有者ID")

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS `share_links` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file',
`share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码',
`file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用',
`access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码',
`created_by` BIGINT DEFAULT NULL COMMENT '创建人ID',
`status` TINYINT DEFAULT 1 COMMENT '状态0-禁用 1-启用',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_share_code` (`share_code`),
INDEX `idx_share_project` (`project_id`, `share_type`, `status`),
INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`),
INDEX `idx_share_created_by` (`created_by`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表';

View File

@ -182,6 +182,26 @@ CREATE TABLE IF NOT EXISTS `mcp_bots` (
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials';
-- 11. 分享链接表
CREATE TABLE IF NOT EXISTS `share_links` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file',
`share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码',
`file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用',
`access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码',
`created_by` BIGINT DEFAULT NULL COMMENT '创建人ID',
`status` TINYINT DEFAULT 1 COMMENT '状态0-禁用 1-启用',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_share_code` (`share_code`),
INDEX `idx_share_project` (`project_id`, `share_type`, `status`),
INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`),
INDEX `idx_share_created_by` (`created_by`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表';
-- 插入初始角色数据 -- 插入初始角色数据
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
('超级管理员', 'super_admin', '拥有系统所有权限', 1), ('超级管理员', 'super_admin', '拥有系统所有权限', 1),

View File

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

View File

@ -1,45 +1,57 @@
/** /**
* 项目分享和预览相关 API * 分享相关 API
*/ */
import request from '@/utils/request' import request from '@/utils/request'
/**
* 获取项目分享信息
*/
export function getProjectShareInfo(projectId) { export function getProjectShareInfo(projectId) {
return request({ return request({
url: `/projects/${projectId}/share`, url: `/shares/projects/${projectId}`,
method: 'get', method: 'get',
}) })
} }
/** export function updateProjectShareSettings(projectId, data) {
* 更新分享设置设置或取消访问密码
*/
export function updateShareSettings(projectId, data) {
return request({ return request({
url: `/projects/${projectId}/share/settings`, url: `/shares/projects/${projectId}/settings`,
method: 'post', method: 'post',
data, data,
}) })
} }
/** export function getFileShareInfo(projectId, filePath) {
* 获取预览项目基本信息公开访问
*/
export function getPreviewInfo(projectId) {
return request({ return request({
url: `/preview/${projectId}/info`, url: `/shares/projects/${projectId}/files/share/info`,
method: 'get',
params: { file_path: filePath },
})
}
export function createOrUpdateFileShare(projectId, data) {
return request({
url: `/shares/projects/${projectId}/files/share`,
method: 'post',
data,
})
}
export function deleteFileShare(projectId, filePath) {
return request({
url: `/shares/projects/${projectId}/files/share`,
method: 'delete',
params: { file_path: filePath },
})
}
export function getProjectSharePublicInfo(shareCode) {
return request({
url: `/shares/project/${shareCode}/info`,
method: 'get', method: 'get',
}) })
} }
/** export function verifyProjectSharePassword(shareCode, password) {
* 验证访问密码
*/
export function verifyAccessPassword(projectId, password) {
return request({ return request({
url: `/preview/${projectId}/verify`, url: `/shares/project/${shareCode}/verify`,
method: 'post', method: 'post',
headers: { headers: {
'X-Access-Password': password, 'X-Access-Password': password,
@ -47,42 +59,71 @@ export function verifyAccessPassword(projectId, password) {
}) })
} }
/** export function getProjectShareTree(shareCode, password = null) {
* 获取预览项目的文档树
*/
export function getPreviewTree(projectId, password = null) {
return request({ return request({
url: `/preview/${projectId}/tree`, url: `/shares/project/${shareCode}/tree`,
method: 'get', method: 'get',
headers: password ? { 'X-Access-Password': password } : {}, headers: password ? { 'X-Access-Password': password } : {},
}) })
} }
/** export function searchProjectShareDocuments(shareCode, keyword, password = null) {
* 获取预览项目的文件内容
*/
export function getPreviewFile(projectId, path, password = null) {
return request({ return request({
url: `/preview/${projectId}/file`, url: `/shares/project/${shareCode}/search`,
method: 'get',
params: { keyword },
headers: password ? { 'X-Access-Password': password } : {},
})
}
export function getProjectShareFile(shareCode, path, password = null) {
return request({
url: `/shares/project/${shareCode}/file`,
method: 'get', method: 'get',
params: { path }, params: { path },
headers: password ? { 'X-Access-Password': password } : {}, headers: password ? { 'X-Access-Password': password } : {},
}) })
} }
/** export function getProjectShareDocumentUrl(shareCode, path) {
* 获取预览项目的文档文件URLPDF等
*/
export function getPreviewDocumentUrl(projectId, path) {
// 将路径的每个部分分别编码,但保留斜杠
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/') const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
return `/api/v1/preview/${projectId}/document/${encodedPath}` return `/api/v1/shares/project/${shareCode}/document/${encodedPath}`
} }
/** export function exportProjectSharePDF(shareCode, path) {
* 导出 PDF
*/
export function exportPDF(projectId, path) {
const encodedPath = encodeURIComponent(path) const encodedPath = encodeURIComponent(path)
return `/api/v1/preview/${projectId}/export-pdf?path=${encodedPath}` return `/api/v1/shares/project/${shareCode}/export-pdf?path=${encodedPath}`
}
export function getFileSharePublicInfo(shareCode) {
return request({
url: `/shares/files/${shareCode}/info`,
method: 'get',
})
}
export function verifyFileSharePassword(shareCode, password) {
return request({
url: `/shares/files/${shareCode}/verify`,
method: 'post',
headers: {
'X-Access-Password': password,
},
})
}
export function getFileShareContent(shareCode, password = null) {
return request({
url: `/shares/files/${shareCode}/content`,
method: 'get',
headers: password ? { 'X-Access-Password': password } : {},
})
}
export function getFileShareDocumentUrl(shareCode) {
return `/api/v1/shares/files/${shareCode}/document`
}
export function exportFileSharePDF(shareCode) {
return `/api/v1/shares/files/${shareCode}/export-pdf`
} }

View File

@ -57,6 +57,18 @@
opacity: 1; opacity: 1;
} }
.mode-switch-small {
padding: 2px;
}
.mode-switch-small .mode-switch-option {
min-width: 40px;
height: 24px;
padding: 0 8px;
font-size: 11px;
font-weight: 600;
}
body.dark .mode-switch { body.dark .mode-switch {
background: linear-gradient(180deg, #2e3748 0%, #252d3b 100%); background: linear-gradient(180deg, #2e3748 0%, #252d3b 100%);
border-color: rgba(137, 156, 186, 0.24); border-color: rgba(137, 156, 186, 0.24);

View File

@ -7,6 +7,7 @@ function ModeSwitch({
editLabel = '编辑', editLabel = '编辑',
options, options,
ariaLabel = '模式切换', ariaLabel = '模式切换',
size = 'default',
}) { }) {
const finalOptions = options || [ const finalOptions = options || [
{ label: viewLabel, value: 'view' }, { label: viewLabel, value: 'view' },
@ -19,7 +20,7 @@ function ModeSwitch({
return ( return (
<div <div
className="mode-switch" className={`mode-switch mode-switch-${size}`}
role="tablist" role="tablist"
aria-label={ariaLabel} aria-label={ariaLabel}
style={{ style={{

View File

@ -30,20 +30,63 @@
} }
.sider-header { .sider-header {
padding: 16px 20px; padding: 12px 10px 12px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
background: var(--header-bg); background: var(--header-bg);
} }
.sider-header h2 { .sider-header h2 {
margin: 0; margin: 0;
font-size: 16px; font-size: 20px;
font-weight: 600; font-weight: 700;
letter-spacing: 0;
color: var(--text-color); color: var(--text-color);
line-height: 1.5; line-height: 1.1;
}
.sider-title-row {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.sider-title-row h2 {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-back-button {
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: #707480;
font-size: 16px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.project-back-button:hover {
background: rgba(17, 24, 39, 0.06);
color: #2f3440;
transform: translateX(-1px);
}
.project-back-button:focus-visible {
outline: 2px solid rgba(22, 119, 255, 0.35);
outline-offset: 2px;
} }
.sider-actions { .sider-actions {
@ -88,48 +131,20 @@
color: var(--text-color); color: var(--text-color);
} }
/* 修复Tree组件文档名过长的显示问题 */
.file-tree .ant-tree-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.file-tree .ant-tree-node-content-wrapper {
overflow: hidden;
max-width: 100%;
}
.file-tree .ant-tree-treenode {
overflow: hidden;
}
/* 确保Tree节点标题区域不折行 */
.file-tree .ant-tree-node-content-wrapper .ant-tree-title {
display: inline-block;
max-width: calc(100% - 24px);
/* 预留图标空间 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
vertical-align: middle;
}
/* 修复文档名过长的显示问题Menu组件已废弃但保留兼容 */
.file-tree .ant-menu-title-content { .file-tree .ant-menu-title-content {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
/* Ensure it allows children to fill width */
} }
/* Increase hit area for context menu */ /* Increase hit area for context menu */
.tree-node-wrapper { .tree-node-wrapper {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: flex;
align-items: center;
min-width: 0;
user-select: none; user-select: none;
padding: 4px 8px; padding: 4px 8px;
margin: -4px -8px; margin: -4px -8px;
@ -138,6 +153,14 @@
color: var(--text-color); color: var(--text-color);
} }
.tree-node-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 选中的文件夹样式 */ /* 选中的文件夹样式 */
.file-tree .folder-selected>.ant-menu-submenu-title { .file-tree .folder-selected>.ant-menu-submenu-title {
background-color: var(--item-hover-bg) !important; background-color: var(--item-hover-bg) !important;
@ -152,8 +175,8 @@
.file-tree .ant-menu-submenu-title { .file-tree .ant-menu-submenu-title {
overflow: hidden; overflow: hidden;
display: flex !important; display: flex !important;
/* Ensure flex layout for item */
align-items: center; align-items: center;
min-width: 0;
} }
.document-content { .document-content {

View File

@ -17,7 +17,7 @@ import {
FilePdfOutlined, FilePdfOutlined,
FileTextOutlined, FileTextOutlined,
UndoOutlined, UndoOutlined,
CloseOutlined, ArrowLeftOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { Editor } from '@bytemd/react' import { Editor } from '@bytemd/react'
import gfm from '@bytemd/plugin-gfm' import gfm from '@bytemd/plugin-gfm'
@ -912,9 +912,13 @@ function DocumentEditor() {
menu={{ items: getNodeMenuItems(node) }} menu={{ items: getNodeMenuItems(node) }}
trigger={['contextMenu']} trigger={['contextMenu']}
> >
<div className="tree-node-wrapper"> <Tooltip title={node.title} placement="right">
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title} <div className="tree-node-wrapper">
</div> <span className="tree-node-text">
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
</span>
</div>
</Tooltip>
</Dropdown> </Dropdown>
) )
@ -967,22 +971,21 @@ function DocumentEditor() {
className="document-sider" className="document-sider"
> >
<div className="sider-header"> <div className="sider-header">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div className="sider-title-row">
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}> <button
{projectName} type="button"
</h2> className="project-back-button"
<Tooltip title="关闭"> onClick={handleClose}
<Button aria-label="返回项目列表"
type="text" >
icon={<CloseOutlined />} <ArrowLeftOutlined />
onClick={handleClose} </button>
style={{ marginLeft: 8 }} <h2 title={projectName}>{projectName}</h2>
/>
</Tooltip>
</div> </div>
<div className="sider-actions"> <div className="sider-actions">
<div className="mode-actions-row"> <div className="mode-actions-row">
<ModeSwitch <ModeSwitch
size="small"
value={modeSwitchValue} value={modeSwitchValue}
onChange={(mode) => { onChange={(mode) => {
if (mode === 'view' && !modeSwitchingRef.current) { if (mode === 'view' && !modeSwitchingRef.current) {

View File

@ -26,20 +26,64 @@
} }
.docs-sider-header { .docs-sider-header {
padding: 16px 20px; padding: 12px 10px 12px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
background: var(--header-bg); background: var(--header-bg);
} }
.docs-sider-header h2 { .docs-sider-header h2 {
margin: 0; margin: 0;
font-size: 16px; font-size: 20px;
font-weight: 600; font-weight: 700;
letter-spacing: 0;
color: var(--text-color); color: var(--text-color);
line-height: 1.5; line-height: 1.1;
text-transform: none;
}
.docs-sider-title-row {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.docs-sider-title-row h2 {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-back-button {
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: #707480;
font-size: 16px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.project-back-button:hover {
background: rgba(17, 24, 39, 0.06);
color: #2f3440;
transform: translateX(-1px);
}
.project-back-button:focus-visible {
outline: 2px solid rgba(22, 119, 255, 0.35);
outline-offset: 2px;
} }
.docs-sider-actions { .docs-sider-actions {
@ -84,18 +128,41 @@
color: var(--text-color); color: var(--text-color);
} }
/* 修复文档名过长的显示问题 */
.docs-menu .ant-menu-title-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docs-menu .ant-menu-item, .docs-menu .ant-menu-item,
.docs-menu .ant-menu-submenu-title { .docs-menu .ant-menu-submenu-title {
overflow: hidden; overflow: hidden;
} }
.docs-menu .ant-menu-title-content {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
}
.docs-menu-label {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
}
.docs-menu-label-text {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.docs-menu-share-icon {
color: var(--link-color);
font-size: 12px;
flex: none;
opacity: 0.9;
}
.docs-content-layout { .docs-content-layout {
position: relative; position: relative;
height: 100%; height: 100%;

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' 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 { 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 ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
@ -12,7 +12,7 @@ import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file' import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
import { gitPull, gitPush, getGitRepos } from '@/api/project' 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 { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
@ -58,6 +58,7 @@ function DocumentPage() {
const [viewMode, setViewMode] = useState('markdown') const [viewMode, setViewMode] = useState('markdown')
const [gitRepos, setGitRepos] = useState([]) const [gitRepos, setGitRepos] = useState([])
const [projectName, setProjectName] = useState('') const [projectName, setProjectName] = useState('')
const [refreshing, setRefreshing] = useState(false)
// //
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
@ -86,6 +87,21 @@ function DocumentPage() {
setSearchParams(nextParams, { replace: true }) 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(() => { useEffect(() => {
loadFileTree() loadFileTree()
}, [projectId]) }, [projectId])
@ -139,12 +155,7 @@ function DocumentPage() {
// PDF Markdown // PDF Markdown
if (fileParam.toLowerCase().endsWith('.pdf')) { if (fileParam.toLowerCase().endsWith('.pdf')) {
let url = getDocumentUrl(projectId, fileParam) setPdfUrl(buildDocumentUrl(fileParam))
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(fileParam.split('/').pop()) setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf') setViewMode('pdf')
} else { } else {
@ -234,7 +245,7 @@ function DocumentPage() {
} }
// //
const loadFileTree = async () => { const loadFileTree = async ({ throwOnError = false } = {}) => {
try { try {
const res = await getProjectTree(projectId) const res = await getProjectTree(projectId)
const data = res.data || {} const data = res.data || {}
@ -245,8 +256,13 @@ function DocumentPage() {
setFileTree(tree) setFileTree(tree)
setUserRole(role) setUserRole(role)
setProjectName(name) setProjectName(name)
return tree
} catch (error) { } catch (error) {
console.error('Load file tree error:', error) console.error('Load file tree error:', error)
if (throwOnError) {
throw error
}
return []
} }
} }
@ -279,14 +295,21 @@ function DocumentPage() {
// //
const convertTreeToMenuItems = (nodes) => { const convertTreeToMenuItems = (nodes) => {
return nodes.map((node) => { return nodes.map((node) => {
// - const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title
const titleNode = node.title.replace('.md', '') 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) { if (!node.isLeaf) {
// //
return { return {
key: node.key, key: node.key,
label: node.title, label: labelNode,
icon: <FolderOutlined />, icon: <FolderOutlined />,
onTitleClick: () => setSelectedNodeKey(node.key), onTitleClick: () => setSelectedNodeKey(node.key),
children: node.children ? convertTreeToMenuItems(node.children) : [], children: node.children ? convertTreeToMenuItems(node.children) : [],
@ -295,14 +318,14 @@ function DocumentPage() {
// Markdown // Markdown
return { return {
key: node.key, key: node.key,
label: titleNode, label: labelNode,
icon: <FileTextOutlined />, icon: <FileTextOutlined />,
} }
} else if (node.title && node.title.endsWith('.pdf')) { } else if (node.title && node.title.endsWith('.pdf')) {
// PDF // PDF
return { return {
key: node.key, key: node.key,
label: node.title, label: labelNode,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />, icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
} }
} }
@ -367,12 +390,7 @@ function DocumentPage() {
// PDF // PDF
if (key.toLowerCase().endsWith('.pdf')) { if (key.toLowerCase().endsWith('.pdf')) {
// PDF - tokenURL // PDF - tokenURL
let url = getDocumentUrl(projectId, key) setPdfUrl(buildDocumentUrl(key))
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(key.split('/').pop()) setPdfFilename(key.split('/').pop())
setViewMode('pdf') setViewMode('pdf')
} else { } else {
@ -468,12 +486,7 @@ function DocumentPage() {
if (isPdf) { if (isPdf) {
// PDFPDF // PDFPDF
let url = getDocumentUrl(projectId, targetPath) setPdfUrl(buildDocumentUrl(targetPath))
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(targetPath.split('/').pop()) setPdfFilename(targetPath.split('/').pop())
setViewMode('pdf') setViewMode('pdf')
} else { } else {
@ -654,19 +667,33 @@ function DocumentPage() {
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`) 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 handleShare = async () => {
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
if (selectedNode && !selectedNode.isLeaf) { if (!selectedNode || !selectedNode.isLeaf) {
Toast.warning('提示', '当前选中的是文件夹,不能直接分享,请选择具体文件后再试') Toast.warning('提示', '请先选择一个文件再分享')
return return
} }
try { try {
const res = await getProjectShareInfo(projectId) const res = await getFileShareInfo(projectId, selectedNode.key)
setShareInfo(res.data) const nextShareInfo = res.data
setHasPassword(res.data.has_password) setShareInfo(nextShareInfo)
setPassword(res.data.access_pass || '') // setHasPassword(Boolean(nextShareInfo?.has_password))
setPassword(nextShareInfo?.access_pass || '')
setShareModalVisible(true) setShareModalVisible(true)
} catch (error) { } catch (error) {
console.error('Get share info error:', error) console.error('Get share info error:', error)
@ -677,15 +704,7 @@ function DocumentPage() {
// //
const handleCopyLink = async () => { const handleCopyLink = async () => {
if (!shareInfo) return if (!shareInfo) return
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
const shareTargetFile = selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
? selectedNodeKey
: ''
let fullUrl = `${window.location.origin}${shareInfo.share_url}`
if (shareTargetFile) {
fullUrl += `?file=${encodeURIComponent(shareTargetFile)}`
}
try { try {
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
@ -717,15 +736,20 @@ function DocumentPage() {
// //
const handlePasswordToggle = async (checked) => { const handlePasswordToggle = async (checked) => {
if (!selectedNodeKey) return
if (!checked) { if (!checked) {
//
try { 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) setHasPassword(false)
setPassword('') setPassword('')
message.success('已取消访问密码') message.success('已取消文件访问密码')
// await loadFileTree()
const res = await getProjectShareInfo(projectId) const res = await getFileShareInfo(projectId, selectedNodeKey)
setShareInfo(res.data) setShareInfo(res.data)
} catch (error) { } catch (error) {
console.error('Update settings error:', error) console.error('Update settings error:', error)
@ -738,20 +762,66 @@ function DocumentPage() {
// //
const handleSavePassword = async () => { const handleSavePassword = async () => {
if (!selectedNodeKey) {
message.warning('请先选择文件')
return
}
if (!password.trim()) { if (!password.trim()) {
message.warning('请输入访问密码') message.warning('请输入访问密码')
return return
} }
try { try {
await updateShareSettings(projectId, { access_pass: password }) const res = await createOrUpdateFileShare(projectId, {
message.success('访问密码已设置') file_path: selectedNodeKey,
// access_pass: hasPassword ? password : null,
const res = await getProjectShareInfo(projectId) })
message.success('文件分享已更新')
setShareInfo(res.data) setShareInfo(res.data)
setHasPassword(true) await loadFileTree()
const nextInfo = await getFileShareInfo(projectId, selectedNodeKey)
setShareInfo(nextInfo.data)
setHasPassword(Boolean(nextInfo.data?.has_password))
} catch (error) { } catch (error) {
console.error('Save password error:', 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"> <Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header"> <div className="docs-sider-header">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div className="docs-sider-title-row">
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}> <button
{projectName} type="button"
</h2> className="project-back-button"
<Tooltip title="关闭"> onClick={handleClose}
<Button aria-label="返回项目列表"
type="text" >
icon={<CloseOutlined />} <ArrowLeftOutlined />
onClick={handleClose} </button>
style={{ marginLeft: 8 }} <h2 title={projectName}>{projectName}</h2>
/>
</Tooltip>
</div> </div>
<div className="docs-sider-actions"> <div className="docs-sider-actions">
<div className="mode-actions-row"> <div className="mode-actions-row">
{/* 只有 owner/admin/editor 可以编辑和Git操作 */} {/* 只有 owner/admin/editor 可以编辑和Git操作 */}
{userRole !== 'viewer' ? ( {userRole !== 'viewer' ? (
<ModeSwitch <ModeSwitch
size="small"
value={modeSwitchValue} value={modeSwitchValue}
onChange={(mode) => { onChange={(mode) => {
if (mode === 'edit' && !modeSwitchingRef.current) { if (mode === 'edit' && !modeSwitchingRef.current) {
@ -878,6 +947,14 @@ function DocumentPage() {
onClick={handleShare} onClick={handleShare}
/> />
</Tooltip> </Tooltip>
<Tooltip title="刷新">
<Button
size="middle"
icon={<ReloadOutlined spin={refreshing} />}
onClick={handleRefresh}
disabled={refreshing}
/>
</Tooltip>
</Space.Compact> </Space.Compact>
</div> </div>
</div> </div>
@ -1015,55 +1092,91 @@ function DocumentPage() {
{/* 分享模态框 */} {/* 分享模态框 */}
{/* ... keeping the modal ... */} {/* ... keeping the modal ... */}
<Modal <Modal
title="分享" title="文件分享"
open={shareModalVisible} open={shareModalVisible}
onCancel={() => setShareModalVisible(false)} onCancel={() => setShareModalVisible(false)}
footer={null} footer={null}
width={500} width={500}
> >
{shareInfo && ( <Space direction="vertical" style={{ width: '100%' }} size="large">
<Space direction="vertical" style={{ width: '100%' }} size="large"> <div>
<div> <label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}> 当前文件
{selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf ? '当前文件分享链接' : '项目分享链接'} </label>
</label> <Input value={selectedNodeKey || ''} readOnly />
<Input </div>
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>
<div> {shareInfo?.share_url ? (
<Space> <>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
{hasPassword && (
<div> <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 <Input.Password
placeholder="请输入访问密码" placeholder="请输入访问密码"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<Button )}
type="primary"
onClick={handleSavePassword} <Button type="primary" onClick={handleCreateShare}>
style={{ marginTop: 8 }} 创建文件分享
> </Button>
保存密码 </>
</Button> )}
</div> {shareInfo?.share_url && (
)} <Button danger onClick={handleDisableShare}>
</Space> 关闭文件分享
)} </Button>
)}
</Space>
</Modal> </Modal>
</div> </div>
) )

View File

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

View File

@ -4,6 +4,72 @@
background: var(--bg-color); background: var(--bg-color);
} }
.file-share-page {
overflow: hidden;
}
.file-share-shell {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-color);
}
.file-share-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 { .preview-layout {
height: 100%; height: 100%;
background: var(--bg-color); background: var(--bg-color);
@ -37,26 +103,40 @@
line-height: 1.5; line-height: 1.5;
} }
.preview-search {
padding: 12px 16px 4px;
}
.preview-menu { .preview-menu {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
border-right: none; border-right: none;
background: var(--sider-bg); background: var(--sider-bg);
color: var(--text-color); 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-item,
.preview-menu .ant-menu-submenu-title { .preview-menu .ant-menu-submenu-title {
overflow: hidden; overflow: hidden;
} }
.preview-menu .ant-menu-title-content {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
}
.preview-menu-label {
display: block;
width: 100%;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-content-layout { .preview-content-layout {
position: relative; position: relative;
height: 100%; height: 100%;
@ -288,6 +368,17 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); 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) { @media (max-width: 768px) {
.preview-content-wrapper { .preview-content-wrapper {
@ -323,6 +414,10 @@
.markdown-body td { .markdown-body td {
padding: 6px 10px; padding: 6px 10px;
} }
.file-share-header {
padding: 16px;
}
} }
/* 平板响应式样式 */ /* 平板响应式样式 */
@ -368,4 +463,4 @@
} }
} }
/* 打印样式优化已移除,转向后端生成方案 */ /* 打印样式优化已移除,转向后端生成方案 */

View File

@ -1,9 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useSearchParams, useNavigate } from 'react-router-dom' import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd' import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd'
import Toast from '@/components/Toast/Toast' import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
@ -12,39 +10,38 @@ import 'highlight.js/styles/github.css'
import Mark from 'mark.js' import Mark from 'mark.js'
import Highlighter from 'react-highlight-words' import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl, exportPDF } from '@/api/share' import Toast from '@/components/Toast/Toast'
import { searchDocuments } from '@/api/search' import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import {
getProjectSharePublicInfo,
getProjectShareTree,
searchProjectShareDocuments,
getProjectShareFile,
verifyProjectSharePassword,
getProjectShareDocumentUrl,
exportProjectSharePDF,
} from '@/api/share'
import './PreviewPage.css' import './PreviewPage.css'
const { Sider, Content } = Layout const { Sider, Content } = Layout
// ( Tree)
const HighlightText = ({ text, keyword }) => { const HighlightText = ({ text, keyword }) => {
if (!keyword || !text) return text; if (!keyword || !text) return text
return ( return (
<Highlighter <Highlighter
highlightClassName="search-highlight" highlightClassName="search-highlight"
searchWords={[keyword]} searchWords={[keyword]}
autoEscape={true} autoEscape
textToHighlight={text} textToHighlight={text}
/> />
) )
} }
function PreviewPage() { function ProjectSharePage() {
const { projectId } = useParams() const { shareCode } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const handleClose = () => {
// 退
if (window.history.length > 1) {
navigate(-1)
} else {
navigate('/projects')
}
}
const [projectInfo, setProjectInfo] = useState(null) const [projectInfo, setProjectInfo] = useState(null)
const [fileTree, setFileTree] = useState([]) const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('') const [selectedFile, setSelectedFile] = useState('')
@ -55,134 +52,91 @@ function PreviewPage() {
const [tocItems, setTocItems] = useState([]) const [tocItems, setTocItems] = useState([])
const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [accessPassword, setAccessPassword] = useState(null)
const [siderCollapsed, setSiderCollapsed] = useState(false) const [siderCollapsed, setSiderCollapsed] = useState(false)
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false) const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false)
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
const [pdfUrl, setPdfUrl] = useState('') const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('') const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown') const [viewMode, setViewMode] = useState('markdown')
//
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set()) const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const contentRef = useRef(null) const contentRef = useRef(null)
const viewerRef = useRef(null) const viewerRef = useRef(null)
// mark.js const handleClose = () => {
useEffect(() => { if (window.history.length > 1) {
if (viewerRef.current && viewMode === 'markdown') { navigate(-1)
const instance = new Mark(viewerRef.current) return
instance.unmark()
if (searchKeyword.trim()) {
instance.mark(searchKeyword, {
element: 'span',
className: 'search-highlight',
exclude: ['pre', 'code', '.toc-content']
})
}
} }
}, [markdownContent, searchKeyword, viewMode]) navigate('/')
}
//
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => setIsMobile(window.innerWidth < 768)
setIsMobile(window.innerWidth < 768)
}
checkMobile() checkMobile()
window.addEventListener('resize', checkMobile) window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile) return () => window.removeEventListener('resize', checkMobile)
}, []) }, [])
useEffect(() => { useEffect(() => {
loadProjectInfo() if (viewerRef.current && viewMode === 'markdown') {
}, [projectId]) 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(() => { useEffect(() => {
if (fileTree.length === 0) return if (fileTree.length === 0) return
const fileParam = searchParams.get('file') const fileParam = searchParams.get('file')
const keywordParam = searchParams.get('keyword')
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
if (fileParam) { if (fileParam) {
if (fileParam !== selectedFile) { openSharedFile(fileParam)
// Deep link to file return
if (fileParam.toLowerCase().endsWith('.pdf')) { }
let url = getPreviewDocumentUrl(projectId, fileParam)
const params = [] if (!selectedFile) {
if (accessPassword) params.push(`access_pass=${encodeURIComponent(accessPassword)}`) const readmeNode = findReadme(fileTree)
const token = localStorage.getItem('access_token') if (readmeNode) {
if (token) params.push(`token=${encodeURIComponent(token)}`) openSharedFile(readmeNode.key)
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)
}
} }
} }
}, [searchParams, fileTree, accessPassword]) }, [fileTree, searchParams])
//
const loadProjectInfo = async () => { const loadProjectInfo = async () => {
try { try {
const res = await getPreviewInfo(projectId) const res = await getProjectSharePublicInfo(shareCode)
const info = res.data const info = res.data
setProjectInfo(info) setProjectInfo(info)
if (info.has_password) { if (info.has_password) {
setPasswordModalVisible(true) setPasswordModalVisible(true)
} else { } else {
loadFileTree() loadFileTree()
} }
} catch (error) { } catch (error) {
console.error('Load project info error:', error) console.error('Load project share info error:', error)
Toast.error('加载失败', '项目不存在或已被删除') Toast.error('加载失败', '分享链接不存在或已失效')
} }
} }
//
const handleVerifyPassword = async () => { const handleVerifyPassword = async () => {
if (!password.trim()) { if (!password.trim()) {
Toast.warning('提示', '请输入访问密码') Toast.warning('提示', '请输入访问密码')
return return
} }
try { try {
await verifyAccessPassword(projectId, password) await verifyProjectSharePassword(shareCode, password)
setAccessPassword(password)
setPasswordModalVisible(false) setPasswordModalVisible(false)
loadFileTree(password) loadFileTree(password)
Toast.success('验证成功') Toast.success('验证成功')
@ -191,308 +145,220 @@ function PreviewPage() {
} }
} }
//
const loadFileTree = async (pwd = null) => { const loadFileTree = async (pwd = null) => {
try { try {
const res = await getPreviewTree(projectId, pwd || accessPassword) const res = await getProjectShareTree(shareCode, pwd)
const tree = res.data || [] setFileTree(res.data || [])
setFileTree(tree)
} catch (error) { } catch (error) {
console.error('Load file tree error:', error) console.error('Load share tree error:', error)
if (error.response?.status === 403) { if (error.response?.status === 403) {
Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true) 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) => { const loadMarkdown = async (filePath, pwd = null) => {
setLoading(true) setLoading(true)
setTocItems([]) setTocItems([])
try { try {
const res = await getPreviewFile(projectId, filePath, pwd || accessPassword) const res = await getProjectShareFile(shareCode, filePath, pwd)
setMarkdownContent(res.data?.content || '') setMarkdownContent(res.data?.content || '')
if (isMobile) {
setMobileDrawerVisible(false)
}
if (contentRef.current) { if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
} }
} catch (error) { } catch (error) {
console.error('Load markdown error:', error) console.error('Load share markdown error:', error)
if (error.response?.status === 403) { if (error.response?.status === 403) {
Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true) setPasswordModalVisible(true)
} else { } else {
Toast.error('加载失败', '文档加载失败,请稍后重试') Toast.error('加载失败', '文档加载失败')
setMarkdownContent('')
} }
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
useEffect(() => { const handleSearch = async (value) => {
if (markdownContent) { const keyword = value || ''
const slugger = new GithubSlugger() setSearchKeyword(keyword)
const headings = []
const lines = markdownContent.split('\n')
lines.forEach((line) => { if (!keyword.trim()) {
const match = line.match(/^(#{1,6})\s+(.+)$/) setMatchedFilePaths(new Set())
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('#')) {
return 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 isMd = href.endsWith('.md')
const isPdf = href.toLowerCase().endsWith('.pdf') const isPdf = href.toLowerCase().endsWith('.pdf')
if (!isMd && !isPdf) return if (!isMd && !isPdf) return
e.preventDefault() e.preventDefault()
let decodedHref = href let decodedHref = href
try { try {
decodedHref = decodeURIComponent(href) decodedHref = decodeURIComponent(href)
} catch (err) { } catch {}
} openSharedFile(resolveRelativePath(selectedFile, decodedHref))
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 })
} }
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 = () => { const handleExportPDF = () => {
if (!selectedFile) return
if (viewMode === 'pdf') { if (viewMode === 'pdf') {
// PDF
const link = document.createElement('a') const link = document.createElement('a')
link.href = pdfUrl link.href = pdfUrl
link.download = pdfFilename link.download = pdfFilename
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
} else { return
// 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')
} }
window.open(exportProjectSharePDF(shareCode, selectedFile), '_blank')
} }
const menuItems = convertTreeToMenuItems(filteredTreeData) const menuItems = useMemo(() => convertTreeToMenuItems(filteredTreeData), [filteredTreeData])
return ( return (
<div className="preview-page"> <div className="preview-page">
<Layout className="preview-layout"> <Layout className="preview-layout">
{isMobile ? ( {isMobile ? (
<> <>
<Button
type="text"
icon={<CloseOutlined />}
className="mobile-close-btn"
onClick={handleClose}
/>
<Button <Button
type="primary" type="primary"
icon={<MenuOutlined />} icon={<MenuOutlined />}
@ -502,28 +368,16 @@ function PreviewPage() {
目录索引 目录索引
</Button> </Button>
<Drawer <Drawer
title={ title={projectInfo?.name || '项目分享'}
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
<img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} />
<span>{projectInfo?.name || '项目预览'}</span>
</div>
}
placement="left" placement="left"
onClose={() => setMobileDrawerVisible(false)} onClose={() => setMobileDrawerVisible(false)}
open={mobileDrawerVisible} open={mobileDrawerVisible}
width="80%" width="80%"
> >
<div className="preview-sider-header" style={{ padding: '0 0 16px' }}> <div className="preview-sider-header" style={{ padding: '0 0 16px' }}>
{projectInfo?.description && ( {projectInfo?.description && <p className="preview-project-desc">{projectInfo.description}</p>}
<p className="preview-project-desc">{projectInfo.description}</p>
)}
</div> </div>
<div className="preview-search">
{/* 搜索框 */}
<div style={{ padding: '0 0 12px' }}>
<Input.Search <Input.Search
placeholder="搜索文档内容..." placeholder="搜索文档内容..."
allowClear allowClear
@ -534,47 +388,31 @@ function PreviewPage() {
enterButton enterButton
/> />
</div> </div>
{menuItems.length > 0 ? (
{filteredTreeData.length > 0 ? (
<Menu <Menu
mode="inline" mode="inline"
selectedKeys={[selectedFile]} selectedKeys={[selectedFile]}
openKeys={openKeys} openKeys={openKeys}
onOpenChange={setOpenKeys} onOpenChange={setOpenKeys}
items={menuItems} items={menuItems}
onClick={handleMenuClick} onClick={({ key }) => openSharedFile(key)}
className="preview-menu" className="preview-menu"
/> />
) : ( ) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
</div>
)} )}
</Drawer> </Drawer>
</> </>
) : ( ) : (
<Sider <Sider width={280} className="preview-sider" theme="light" collapsed={siderCollapsed} collapsedWidth={0}>
width={280}
className="preview-sider"
theme="light"
collapsed={siderCollapsed}
collapsedWidth={0}
>
<div className="preview-sider-header"> <div className="preview-sider-header">
<div <div className="preview-sider-title-row">
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, cursor: 'pointer' }} <h2 style={{ margin: 0 }}>{projectInfo?.name || '项目分享'}</h2>
onClick={() => navigate('/')} <Button type="text" icon={<CloseOutlined />} onClick={handleClose} className="preview-close-btn" />
>
<img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} />
<h2 style={{ margin: 0 }}>{projectInfo?.name || '项目预览'}</h2>
</div> </div>
{projectInfo?.description && ( {projectInfo?.description && <p className="preview-project-desc">{projectInfo.description}</p>}
<p className="preview-project-desc">{projectInfo.description}</p>
)}
</div> </div>
<div className="preview-search">
{/* 搜索框 */}
<div style={{ padding: '12px 16px 4px' }}>
<Input.Search <Input.Search
placeholder="搜索文档内容..." placeholder="搜索文档内容..."
allowClear allowClear
@ -585,20 +423,19 @@ function PreviewPage() {
enterButton enterButton
/> />
</div> </div>
{menuItems.length > 0 ? (
{filteredTreeData.length > 0 ? (
<Menu <Menu
mode="inline" mode="inline"
selectedKeys={[selectedFile]} selectedKeys={[selectedFile]}
openKeys={openKeys} openKeys={openKeys}
onOpenChange={setOpenKeys} onOpenChange={setOpenKeys}
items={menuItems} items={menuItems}
onClick={handleMenuClick} onClick={({ key }) => openSharedFile(key)}
className="preview-menu" className="preview-menu"
/> />
) : ( ) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}> <div style={{ padding: 20 }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
</div> </div>
)} )}
</Sider> </Sider>
@ -614,16 +451,16 @@ function PreviewPage() {
</Spin> </Spin>
</div> </div>
) : viewMode === 'pdf' ? ( ) : viewMode === 'pdf' ? (
<VirtualPDFViewer <VirtualPDFViewer url={pdfUrl} filename={pdfFilename} />
url={pdfUrl}
filename={pdfFilename}
/>
) : ( ) : (
<div className="markdown-body" onClick={handleContentClick} ref={viewerRef}> <div className="markdown-body" onClick={(e) => {
<ReactMarkdown const target = e.target.closest('a')
remarkPlugins={[remarkGfm]} if (target) {
rehypePlugins={[rehypeSlug, rehypeHighlight]} const href = target.getAttribute('href')
> if (href) handleMarkdownLink(e, href)
}
}} ref={viewerRef}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug, rehypeHighlight]}>
{markdownContent} {markdownContent}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
@ -643,12 +480,7 @@ function PreviewPage() {
<Sider width={250} theme="light" className="preview-toc-sider"> <Sider width={250} theme="light" className="preview-toc-sider">
<div className="toc-header"> <div className="toc-header">
<h3>文档索引</h3> <h3>文档索引</h3>
<Button <Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={() => setTocCollapsed(true)} />
type="text"
size="small"
icon={<MenuFoldOutlined />}
onClick={() => setTocCollapsed(true)}
/>
</div> </div>
<div className="toc-content"> <div className="toc-content">
{tocItems.length > 0 ? ( {tocItems.length > 0 ? (
@ -688,12 +520,7 @@ function PreviewPage() {
</Layout> </Layout>
<Modal <Modal
title={ title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<LockOutlined />
<span>访问验证</span>
</div>
}
open={passwordModalVisible} open={passwordModalVisible}
onOk={handleVerifyPassword} onOk={handleVerifyPassword}
onCancel={() => setPasswordModalVisible(false)} onCancel={() => setPasswordModalVisible(false)}
@ -702,7 +529,7 @@ function PreviewPage() {
maskClosable={false} maskClosable={false}
> >
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<p>项目需要访问密码请输入密码后继续浏览</p> <p>分享需要访问密码请输入密码后继续浏览</p>
<Input.Password <Input.Password
placeholder="请输入访问密码" placeholder="请输入访问密码"
value={password} value={password}
@ -716,4 +543,4 @@ function PreviewPage() {
) )
} }
export default PreviewPage export default ProjectSharePage

View File

@ -1,9 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' 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 { 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 { 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 { getUserList } from '@/api/users'
import { searchDocuments } from '@/api/search' import { searchDocuments } from '@/api/search'
import ListActionBar from '@/components/ListActionBar/ListActionBar' import ListActionBar from '@/components/ListActionBar/ListActionBar'
@ -16,7 +16,6 @@ function ProjectList({ type = 'my' }) {
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
const [editModalVisible, setEditModalVisible] = useState(false) const [editModalVisible, setEditModalVisible] = useState(false)
const [gitModalVisible, setGitModalVisible] = useState(false) const [gitModalVisible, setGitModalVisible] = useState(false)
const [shareModalVisible, setShareModalVisible] = useState(false)
const [membersModalVisible, setMembersModalVisible] = useState(false) const [membersModalVisible, setMembersModalVisible] = useState(false)
const [currentProject, setCurrentProject] = useState(null) const [currentProject, setCurrentProject] = useState(null)
const [shareInfo, setShareInfo] = useState(null) const [shareInfo, setShareInfo] = useState(null)
@ -149,6 +148,9 @@ function ProjectList({ type = 'my' }) {
description: project.description, description: project.description,
is_public: project.is_public === 1, 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) 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 [gitRepos, setGitRepos] = useState([])
const [loadingRepos, setLoadingRepos] = useState(false) const [loadingRepos, setLoadingRepos] = useState(false)
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false) const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
@ -264,22 +319,6 @@ function ProjectList({ type = 'my' }) {
navigate(`/projects/${projectId}/docs`) 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 () => { const handleCopyLink = async () => {
if (!shareInfo) return if (!shareInfo) return
@ -316,13 +355,11 @@ function ProjectList({ type = 'my' }) {
// //
const handlePasswordToggle = async (checked) => { const handlePasswordToggle = async (checked) => {
if (!checked) { if (!checked) {
//
try { try {
await updateShareSettings(currentProject.id, { access_pass: null }) await updateProjectShareSettings(currentProject.id, { access_pass: null })
setHasPassword(false) setHasPassword(false)
setPassword('') setPassword('')
message.success('已取消访问密码') message.success('已取消访问密码')
//
const res = await getProjectShareInfo(currentProject.id) const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data) setShareInfo(res.data)
} catch (error) { } catch (error) {
@ -341,9 +378,8 @@ function ProjectList({ type = 'my' }) {
return return
} }
try { try {
await updateShareSettings(currentProject.id, { access_pass: password }) await updateProjectShareSettings(currentProject.id, { access_pass: password })
message.success('访问密码已设置') message.success('访问密码已设置')
//
const res = await getProjectShareInfo(currentProject.id) const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data) setShareInfo(res.data)
setHasPassword(true) setHasPassword(true)
@ -565,11 +601,9 @@ function ProjectList({ type = 'my' }) {
actions={type === 'my' ? [ actions={type === 'my' ? [
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />, <EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
<GithubOutlined key="git" onClick={(e) => handleGitSettings(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)} />, <TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
] : [ ] : [
<EyeOutlined key="view" />, <EyeOutlined key="view" />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
]} ]}
> >
{/* 公开项目标识 */} {/* 公开项目标识 */}
@ -720,6 +754,46 @@ function ProjectList({ type = 'my' }) {
<Switch /> <Switch />
</Form.Item> </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> <Form.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}> <Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space> <Space>
@ -797,65 +871,6 @@ function ProjectList({ type = 'my' }) {
</Form> </Form>
</Modal> </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 <Modal
title="成员管理" title="成员管理"
open={membersModalVisible} open={membersModalVisible}