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

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.project import Project, ProjectMember
from app.models.log import OperationLog
from app.models.share import ShareLink
from app.schemas.file import (
FileTreeNode,
FileSaveRequest,
@ -73,6 +74,14 @@ async def check_project_access(
return project
def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None:
"""为文件树节点补充分享状态"""
for node in tree:
node.is_shared = bool(node.isLeaf and node.key in shared_paths)
if node.children:
annotate_shared_files(node.children, shared_paths)
@router.get("/{project_id}/tree", response_model=dict)
async def get_project_tree(
project_id: int,
@ -88,6 +97,20 @@ async def get_project_tree(
# 生成目录树
tree = storage_service.generate_tree(project_root)
share_result = await db.execute(
select(ShareLink.file_path).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.status == 1,
)
)
shared_paths = {
file_path
for file_path in share_result.scalars().all()
if file_path
}
annotate_shared_files(tree, shared_paths)
# 获取当前用户角色
user_role = "owner" # 默认是所有者
if project.owner_id != current_user.id:

View File

@ -3,15 +3,17 @@
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from sqlalchemy import delete, select, or_
from typing import List
import uuid
import secrets
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.models.git_repo import ProjectGitRepo
from app.models.share import ShareLink
from app.schemas.project import (
ProjectCreate,
ProjectUpdate,
@ -19,8 +21,6 @@ from app.schemas.project import (
ProjectMemberAdd,
ProjectMemberUpdate,
ProjectMemberResponse,
ProjectShareSettings,
ProjectShareInfo,
ProjectTransfer,
)
from app.schemas.response import success_response
@ -33,6 +33,11 @@ from app.core.enums import OperationType, ResourceType
router = APIRouter()
def generate_share_code() -> str:
"""生成公开分享码"""
return secrets.token_urlsafe(12).replace("-", "").replace("_", "")
def get_document_count(storage_key: str) -> int:
"""计算项目中的文档数量(.md 和 .pdf"""
try:
@ -242,11 +247,48 @@ async def update_project(
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="无权修改该项目")
old_is_public = project.is_public
# 更新字段
update_data = project_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
if "is_public" in update_data:
next_is_public = int(update_data.get("is_public") or 0)
if next_is_public == 0:
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
)
)
elif next_is_public == 1:
if old_is_public != 1:
# 重新公开项目时丢弃历史公开链接,生成新的项目分享链接。
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
)
)
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
ShareLink.status == 1,
)
)
share = share_result.scalar_one_or_none()
if not share:
db.add(ShareLink(
project_id=project_id,
share_type="project",
share_code=generate_share_code(),
created_by=current_user.id,
))
await db.commit()
await db.refresh(project)
@ -608,89 +650,6 @@ async def remove_project_member(
return success_response(message="成员删除成功")
@router.get("/{project_id}/share", response_model=dict)
async def get_project_share_info(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取项目分享信息"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查是否是项目所有者或成员
is_owner = project.owner_id == current_user.id
if not is_owner:
# 检查是否是项目成员
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权访问该项目")
# 构建分享链接
share_url = f"/preview/{project_id}"
# 只有项目所有者可以看到实际密码,成员只能知道是否设置了密码
share_info = ProjectShareInfo(
share_url=share_url,
has_password=bool(project.access_pass),
access_pass=project.access_pass if is_owner else None
)
return success_response(data=share_info.dict())
@router.post("/{project_id}/share/settings", response_model=dict)
async def update_share_settings(
project_id: int,
settings: ProjectShareSettings,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""更新分享设置(设置或取消访问密码)"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 只有项目所有者可以修改分享设置
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置")
# 更新访问密码
project.access_pass = settings.access_pass
await db.commit()
# 记录操作日志
await log_service.log_operation(
db=db,
operation_type=OperationType.UPDATE_SHARE_SETTINGS,
resource_type=ResourceType.SHARE,
user=current_user,
resource_id=project_id,
detail={
"has_password": bool(settings.access_pass),
"project_name": project.name,
},
request=request,
)
message = "访问密码已取消" if not settings.access_pass else "访问密码已设置"
return success_response(message=message)
@router.post("/{project_id}/git/pull", response_model=dict)
async def git_pull(
project_id: int,

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"
CREATE_SHARE_LINK = "create_share_link"
DELETE_SHARE_LINK = "delete_share_link"
# Git操作
GIT_PULL = "git_pull"

View File

@ -7,6 +7,7 @@ from app.models.role import Role, UserRole
from app.models.menu import SystemMenu, RoleMenu
from app.models.project import Project, ProjectMember, ProjectMemberRole
from app.models.document import DocumentMeta
from app.models.share import ShareLink
from app.models.log import OperationLog
from app.models.mcp_bot import MCPBot
@ -21,6 +22,7 @@ __all__ = [
"ProjectMember",
"ProjectMemberRole",
"DocumentMeta",
"ShareLink",
"OperationLog",
"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="节点标题(文件/文件夹名)")
key: str = Field(..., description="节点唯一键(相对路径)")
isLeaf: bool = Field(..., description="是否叶子节点")
is_shared: bool = Field(False, description="当前文件是否已创建分享链接")
children: Optional[List['FileTreeNode']] = Field(None, description="子节点")
class Config:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons'
import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, ArrowLeftOutlined, MenuOutlined, ReloadOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
@ -12,7 +12,7 @@ import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger'
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
import { gitPull, gitPush, getGitRepos } from '@/api/project'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share'
import { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
@ -58,6 +58,7 @@ function DocumentPage() {
const [viewMode, setViewMode] = useState('markdown')
const [gitRepos, setGitRepos] = useState([])
const [projectName, setProjectName] = useState('')
const [refreshing, setRefreshing] = useState(false)
//
const [searchKeyword, setSearchKeyword] = useState('')
@ -86,6 +87,21 @@ function DocumentPage() {
setSearchParams(nextParams, { replace: true })
}
const buildDocumentUrl = (filePath, refreshKey = null) => {
const params = new URLSearchParams()
const token = localStorage.getItem('access_token')
if (token) {
params.set('token', token)
}
if (refreshKey) {
params.set('refresh', String(refreshKey))
}
const query = params.toString()
return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}`
}
useEffect(() => {
loadFileTree()
}, [projectId])
@ -139,12 +155,7 @@ function DocumentPage() {
// PDF Markdown
if (fileParam.toLowerCase().endsWith('.pdf')) {
let url = getDocumentUrl(projectId, fileParam)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfUrl(buildDocumentUrl(fileParam))
setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf')
} else {
@ -234,7 +245,7 @@ function DocumentPage() {
}
//
const loadFileTree = async () => {
const loadFileTree = async ({ throwOnError = false } = {}) => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
@ -245,8 +256,13 @@ function DocumentPage() {
setFileTree(tree)
setUserRole(role)
setProjectName(name)
return tree
} catch (error) {
console.error('Load file tree error:', error)
if (throwOnError) {
throw error
}
return []
}
}
@ -279,14 +295,21 @@ function DocumentPage() {
//
const convertTreeToMenuItems = (nodes) => {
return nodes.map((node) => {
// -
const titleNode = node.title.replace('.md', '')
const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title
const labelNode = (
<Tooltip title={node.title} placement="right">
<span className="docs-menu-label">
<span className="docs-menu-label-text">{titleText}</span>
{node.is_shared && <ShareAltOutlined className="docs-menu-share-icon" title="已分享" />}
</span>
</Tooltip>
)
if (!node.isLeaf) {
//
return {
key: node.key,
label: node.title,
label: labelNode,
icon: <FolderOutlined />,
onTitleClick: () => setSelectedNodeKey(node.key),
children: node.children ? convertTreeToMenuItems(node.children) : [],
@ -295,14 +318,14 @@ function DocumentPage() {
// Markdown
return {
key: node.key,
label: titleNode,
label: labelNode,
icon: <FileTextOutlined />,
}
} else if (node.title && node.title.endsWith('.pdf')) {
// PDF
return {
key: node.key,
label: node.title,
label: labelNode,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
}
@ -367,12 +390,7 @@ function DocumentPage() {
// PDF
if (key.toLowerCase().endsWith('.pdf')) {
// PDF - tokenURL
let url = getDocumentUrl(projectId, key)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfUrl(buildDocumentUrl(key))
setPdfFilename(key.split('/').pop())
setViewMode('pdf')
} else {
@ -468,12 +486,7 @@ function DocumentPage() {
if (isPdf) {
// PDFPDF
let url = getDocumentUrl(projectId, targetPath)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfUrl(buildDocumentUrl(targetPath))
setPdfFilename(targetPath.split('/').pop())
setViewMode('pdf')
} else {
@ -654,19 +667,33 @@ function DocumentPage() {
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
}
const handleRefresh = async () => {
setRefreshing(true)
try {
await loadFileTree({ throwOnError: true })
message.success('已刷新')
} catch (error) {
console.error('Refresh documents error:', error)
message.error('刷新失败')
} finally {
setRefreshing(false)
}
}
//
const handleShare = async () => {
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
if (selectedNode && !selectedNode.isLeaf) {
Toast.warning('提示', '当前选中的是文件夹,不能直接分享,请选择具体文件后再试')
if (!selectedNode || !selectedNode.isLeaf) {
Toast.warning('提示', '请先选择一个文件再分享')
return
}
try {
const res = await getProjectShareInfo(projectId)
setShareInfo(res.data)
setHasPassword(res.data.has_password)
setPassword(res.data.access_pass || '') //
const res = await getFileShareInfo(projectId, selectedNode.key)
const nextShareInfo = res.data
setShareInfo(nextShareInfo)
setHasPassword(Boolean(nextShareInfo?.has_password))
setPassword(nextShareInfo?.access_pass || '')
setShareModalVisible(true)
} catch (error) {
console.error('Get share info error:', error)
@ -677,15 +704,7 @@ function DocumentPage() {
//
const handleCopyLink = async () => {
if (!shareInfo) return
const shareTargetFile = selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
? selectedNodeKey
: ''
let fullUrl = `${window.location.origin}${shareInfo.share_url}`
if (shareTargetFile) {
fullUrl += `?file=${encodeURIComponent(shareTargetFile)}`
}
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
try {
if (navigator.clipboard && window.isSecureContext) {
@ -717,15 +736,20 @@ function DocumentPage() {
//
const handlePasswordToggle = async (checked) => {
if (!selectedNodeKey) return
if (!checked) {
//
try {
await updateShareSettings(projectId, { access_pass: null })
if (shareInfo?.share_url) {
await createOrUpdateFileShare(projectId, { file_path: selectedNodeKey, access_pass: null })
} else {
await deleteFileShare(projectId, selectedNodeKey)
}
setHasPassword(false)
setPassword('')
message.success('已取消访问密码')
//
const res = await getProjectShareInfo(projectId)
message.success('已取消文件访问密码')
await loadFileTree()
const res = await getFileShareInfo(projectId, selectedNodeKey)
setShareInfo(res.data)
} catch (error) {
console.error('Update settings error:', error)
@ -738,20 +762,66 @@ function DocumentPage() {
//
const handleSavePassword = async () => {
if (!selectedNodeKey) {
message.warning('请先选择文件')
return
}
if (!password.trim()) {
message.warning('请输入访问密码')
return
}
try {
await updateShareSettings(projectId, { access_pass: password })
message.success('访问密码已设置')
//
const res = await getProjectShareInfo(projectId)
const res = await createOrUpdateFileShare(projectId, {
file_path: selectedNodeKey,
access_pass: hasPassword ? password : null,
})
message.success('文件分享已更新')
setShareInfo(res.data)
setHasPassword(true)
await loadFileTree()
const nextInfo = await getFileShareInfo(projectId, selectedNodeKey)
setShareInfo(nextInfo.data)
setHasPassword(Boolean(nextInfo.data?.has_password))
} catch (error) {
console.error('Save password error:', error)
message.error('设置密码失败')
message.error('设置文件分享失败')
}
}
const handleCreateShare = async () => {
if (!selectedNodeKey) {
message.warning('请先选择文件')
return
}
try {
const res = await createOrUpdateFileShare(projectId, {
file_path: selectedNodeKey,
access_pass: hasPassword ? password : null,
})
setShareInfo(res.data)
setHasPassword(Boolean(res.data?.has_password))
await loadFileTree()
message.success('文件分享已创建')
} catch (error) {
console.error('Create file share error:', error)
message.error('创建文件分享失败')
}
}
const handleDisableShare = async () => {
if (!selectedNodeKey) return
try {
await deleteFileShare(projectId, selectedNodeKey)
setShareInfo(null)
setHasPassword(false)
setPassword('')
await loadFileTree()
message.success('文件分享已关闭')
} catch (error) {
console.error('Delete file share error:', error)
message.error('关闭文件分享失败')
}
}
@ -837,24 +907,23 @@ function DocumentPage() {
{/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
{projectName}
</h2>
<Tooltip title="关闭">
<Button
type="text"
icon={<CloseOutlined />}
<div className="docs-sider-title-row">
<button
type="button"
className="project-back-button"
onClick={handleClose}
style={{ marginLeft: 8 }}
/>
</Tooltip>
aria-label="返回项目列表"
>
<ArrowLeftOutlined />
</button>
<h2 title={projectName}>{projectName}</h2>
</div>
<div className="docs-sider-actions">
<div className="mode-actions-row">
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
{userRole !== 'viewer' ? (
<ModeSwitch
size="small"
value={modeSwitchValue}
onChange={(mode) => {
if (mode === 'edit' && !modeSwitchingRef.current) {
@ -878,6 +947,14 @@ function DocumentPage() {
onClick={handleShare}
/>
</Tooltip>
<Tooltip title="刷新">
<Button
size="middle"
icon={<ReloadOutlined spin={refreshing} />}
onClick={handleRefresh}
disabled={refreshing}
/>
</Tooltip>
</Space.Compact>
</div>
</div>
@ -1015,23 +1092,28 @@ function DocumentPage() {
{/* 分享模态框 */}
{/* ... keeping the modal ... */}
<Modal
title="分享"
title="文件分享"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={500}
>
{shareInfo && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
{selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf ? '当前文件分享链接' : '项目分享链接'}
当前文件
</label>
<Input value={selectedNodeKey || ''} readOnly />
</div>
{shareInfo?.share_url ? (
<>
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
分享链接
</label>
<Input
value={selectedNodeKey && findNodeByKey(fileTree, selectedNodeKey)?.isLeaf
? `${window.location.origin}${shareInfo.share_url}?file=${encodeURIComponent(selectedNodeKey)}`
: `${window.location.origin}${shareInfo.share_url}`
}
value={`${window.location.origin}${shareInfo.share_url}`}
readOnly
addonAfter={
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
@ -1062,8 +1144,39 @@ function DocumentPage() {
</Button>
</div>
)}
</>
) : (
<>
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
当前文件尚未创建独立分享文件分享不受项目是否公开影响分享页只包含该文件本身
</div>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={setHasPassword} />
</Space>
</div>
{hasPassword && (
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
)}
<Button type="primary" onClick={handleCreateShare}>
创建文件分享
</Button>
</>
)}
{shareInfo?.share_url && (
<Button danger onClick={handleDisableShare}>
关闭文件分享
</Button>
)}
</Space>
</Modal>
</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);
}
.file-share-page {
overflow: hidden;
}
.file-share-shell {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-color);
}
.file-share-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
border-bottom: 1px solid var(--border-color);
background: var(--header-bg);
}
.file-share-meta {
min-width: 0;
}
.file-share-meta h1 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--text-color);
}
.file-share-meta p {
margin: 4px 0 0;
font-size: 13px;
color: var(--text-color-secondary);
}
.file-share-content {
height: 100%;
overflow-y: auto;
background: var(--bg-color);
}
.file-share-content-layout {
flex: 1;
min-height: 0;
background: var(--bg-color);
}
.preview-close-btn.ant-btn {
width: 36px;
height: 36px;
border-radius: 999px;
flex: none;
}
.preview-sider-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.preview-layout {
height: 100%;
background: var(--bg-color);
@ -37,26 +103,40 @@
line-height: 1.5;
}
.preview-search {
padding: 12px 16px 4px;
}
.preview-menu {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
border-right: none;
background: var(--sider-bg);
color: var(--text-color);
}
/* 修复文档名过长的显示问题 */
.preview-menu .ant-menu-title-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-menu .ant-menu-item,
.preview-menu .ant-menu-submenu-title {
overflow: hidden;
}
.preview-menu .ant-menu-title-content {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
}
.preview-menu-label {
display: block;
width: 100%;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-content-layout {
position: relative;
height: 100%;
@ -288,6 +368,17 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.mobile-close-btn {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
background: var(--header-bg);
border: 1px solid var(--border-color);
border-radius: 999px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* 移动端响应式样式 */
@media (max-width: 768px) {
.preview-content-wrapper {
@ -323,6 +414,10 @@
.markdown-body td {
padding: 6px 10px;
}
.file-share-header {
padding: 16px;
}
}
/* 平板响应式样式 */

View File

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

View File

@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined } from '@ant-design/icons'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined } from '@ant-design/icons'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
import { getUserList } from '@/api/users'
import { searchDocuments } from '@/api/search'
import ListActionBar from '@/components/ListActionBar/ListActionBar'
@ -16,7 +16,6 @@ function ProjectList({ type = 'my' }) {
const [modalVisible, setModalVisible] = useState(false)
const [editModalVisible, setEditModalVisible] = useState(false)
const [gitModalVisible, setGitModalVisible] = useState(false)
const [shareModalVisible, setShareModalVisible] = useState(false)
const [membersModalVisible, setMembersModalVisible] = useState(false)
const [currentProject, setCurrentProject] = useState(null)
const [shareInfo, setShareInfo] = useState(null)
@ -149,6 +148,9 @@ function ProjectList({ type = 'my' }) {
description: project.description,
is_public: project.is_public === 1,
})
setShareInfo(project.is_public === 1 ? null : { enabled: false, share_url: null, has_password: false, access_pass: null })
setHasPassword(false)
setPassword('')
setEditModalVisible(true)
}
@ -169,6 +171,59 @@ function ProjectList({ type = 'my' }) {
}
}
useEffect(() => {
if (!editModalVisible || !currentProject) return
const isPublic = editForm.getFieldValue('is_public')
if (!isPublic) {
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
return
}
;(async () => {
try {
const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data)
setHasPassword(res.data.has_password)
setPassword(res.data.access_pass || '')
} catch (error) {
console.error('Get project share info error:', error)
}
})()
}, [editModalVisible, currentProject, editForm])
const currentEditPublic = Form.useWatch('is_public', editForm)
const isPublicEnablePending = editModalVisible && currentEditPublic && currentProject?.is_public !== 1
useEffect(() => {
if (!editModalVisible || !currentProject) return
if (!currentEditPublic) {
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
setHasPassword(false)
setPassword('')
return
}
if (currentProject.is_public !== 1) {
setShareInfo(null)
setHasPassword(false)
setPassword('')
return
}
;(async () => {
try {
const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data)
setHasPassword(res.data.has_password)
setPassword(res.data.access_pass || '')
} catch (error) {
console.error('Refresh project share info error:', error)
}
})()
}, [currentEditPublic, editModalVisible, currentProject, editForm])
const [gitRepos, setGitRepos] = useState([])
const [loadingRepos, setLoadingRepos] = useState(false)
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
@ -264,22 +319,6 @@ function ProjectList({ type = 'my' }) {
navigate(`/projects/${projectId}/docs`)
}
//
const handleShare = async (e, project) => {
e.stopPropagation()
setCurrentProject(project)
try {
const res = await getProjectShareInfo(project.id)
setShareInfo(res.data)
setHasPassword(res.data.has_password)
setPassword('')
setShareModalVisible(true)
} catch (error) {
console.error('Get share info error:', error)
message.error('获取分享信息失败')
}
}
//
const handleCopyLink = async () => {
if (!shareInfo) return
@ -316,13 +355,11 @@ function ProjectList({ type = 'my' }) {
//
const handlePasswordToggle = async (checked) => {
if (!checked) {
//
try {
await updateShareSettings(currentProject.id, { access_pass: null })
await updateProjectShareSettings(currentProject.id, { access_pass: null })
setHasPassword(false)
setPassword('')
message.success('已取消访问密码')
//
const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data)
} catch (error) {
@ -341,9 +378,8 @@ function ProjectList({ type = 'my' }) {
return
}
try {
await updateShareSettings(currentProject.id, { access_pass: password })
await updateProjectShareSettings(currentProject.id, { access_pass: password })
message.success('访问密码已设置')
//
const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data)
setHasPassword(true)
@ -565,11 +601,9 @@ function ProjectList({ type = 'my' }) {
actions={type === 'my' ? [
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
] : [
<EyeOutlined key="view" />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
]}
>
{/* 公开项目标识 */}
@ -720,6 +754,46 @@ function ProjectList({ type = 'my' }) {
<Switch />
</Form.Item>
<Form.Item label="项目公开分享">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{!editForm.getFieldValue('is_public') ? (
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
开启公开项目才可以生成项目分享链接和配置访问密码
</div>
) : isPublicEnablePending ? (
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
保存项目后将自动生成新的项目分享链接
</div>
) : (
<>
<Input
value={shareInfo?.share_url ? `${window.location.origin}${shareInfo.share_url}` : '正在生成分享链接...'}
readOnly
addonAfter={
shareInfo?.share_url ? <CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} /> : null
}
/>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
{hasPassword && (
<div>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="primary" onClick={handleSavePassword} style={{ marginTop: 8 }}>
保存密码
</Button>
</div>
)}
</>
)}
</Space>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space>
@ -797,65 +871,6 @@ function ProjectList({ type = 'my' }) {
</Form>
</Modal>
<Modal
title="分享设置"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={500}
>
{shareInfo && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>链接</label>
<Input
value={`${window.location.origin}${shareInfo.share_url}`}
readOnly
addonAfter={
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
}
/>
</div>
{/* 只有在我的项目中才显示密码设置功能 */}
{type === 'my' && (
<>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
{hasPassword && (
<div>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="primary"
onClick={handleSavePassword}
style={{ marginTop: 8 }}
>
保存密码
</Button>
</div>
)}
</>
)}
{/* 参与项目显示提示 */}
{type === 'share' && shareInfo.has_password && (
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
该项目已设置访问密码保护
</div>
)}
</Space>
)}
</Modal>
<Modal
title="成员管理"
open={membersModalVisible}