v0.9.9-SP1
parent
f92fff6546
commit
d6ebd62b7c
|
|
@ -18,6 +18,13 @@ from app.core.enums import OperationType
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def _repo_response(repo: ProjectGitRepo) -> dict:
|
||||
data = GitRepoResponse.from_orm(repo).dict()
|
||||
data["token"] = None
|
||||
data["has_token"] = bool(repo.token)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/git-repos", response_model=dict)
|
||||
async def get_project_git_repos(
|
||||
project_id: int,
|
||||
|
|
@ -35,9 +42,7 @@ async def get_project_git_repos(
|
|||
# 隐藏 token
|
||||
data = []
|
||||
for repo in repos:
|
||||
repo_dict = GitRepoResponse.from_orm(repo).dict()
|
||||
# repo_dict['token'] = '******' if repo.token else None # 前端可能需要回显或者判断是否有token
|
||||
data.append(repo_dict)
|
||||
data.append(_repo_response(repo))
|
||||
|
||||
return success_response(data=data)
|
||||
|
||||
|
|
@ -80,13 +85,14 @@ async def create_git_repo(
|
|||
branch=repo_in.branch,
|
||||
username=repo_in.username,
|
||||
token=repo_in.token,
|
||||
is_default=is_default
|
||||
is_default=is_default,
|
||||
sync_path=repo_in.sync_path or ""
|
||||
)
|
||||
db.add(db_repo)
|
||||
await db.commit()
|
||||
await db.refresh(db_repo)
|
||||
|
||||
return success_response(data=GitRepoResponse.from_orm(db_repo).dict(), message="Git仓库添加成功")
|
||||
return success_response(data=_repo_response(db_repo), message="Git仓库添加成功")
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)
|
||||
|
|
@ -121,13 +127,16 @@ async def update_git_repo(
|
|||
)
|
||||
|
||||
update_data = repo_in.dict(exclude_unset=True)
|
||||
# token 不回显;编辑时留空表示继续使用已保存凭据。
|
||||
if not update_data.get("token"):
|
||||
update_data.pop("token", None)
|
||||
for field, value in update_data.items():
|
||||
setattr(repo, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(repo)
|
||||
|
||||
return success_response(data=GitRepoResponse.from_orm(repo).dict(), message="更新成功")
|
||||
return success_response(data=_repo_response(repo), message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""
|
||||
项目管理相关 API
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, or_, func
|
||||
|
|
@ -100,6 +102,18 @@ async def attach_member_counts(db: AsyncSession, projects) -> dict:
|
|||
return dict(result.all())
|
||||
|
||||
|
||||
async def attach_git_repo_counts(db: AsyncSession, projects) -> dict:
|
||||
"""批量查询项目已配置的Git仓库数量,返回 {project_id: count}"""
|
||||
if not projects:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(ProjectGitRepo.project_id, func.count(ProjectGitRepo.id))
|
||||
.where(ProjectGitRepo.project_id.in_([p.id for p in projects]))
|
||||
.group_by(ProjectGitRepo.project_id)
|
||||
)
|
||||
return dict(result.all())
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def get_my_projects(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
|
@ -127,6 +141,7 @@ async def get_my_projects(
|
|||
# 合并结果
|
||||
all_projects = owned_projects + member_projects
|
||||
member_counts = await attach_member_counts(db, all_projects)
|
||||
git_repo_counts = await attach_git_repo_counts(db, all_projects)
|
||||
projects_data = []
|
||||
for p in all_projects:
|
||||
p_dict = ProjectResponse.from_orm(p).dict()
|
||||
|
|
@ -134,6 +149,7 @@ async def get_my_projects(
|
|||
p_dict['doc_count'] = doc_count
|
||||
p_dict['last_activity_at'] = last_activity_at
|
||||
p_dict['member_count'] = member_counts.get(p.id, 0)
|
||||
p_dict['git_repo_count'] = git_repo_counts.get(p.id, 0)
|
||||
projects_data.append(p_dict)
|
||||
|
||||
return success_response(data=projects_data)
|
||||
|
|
@ -150,6 +166,7 @@ async def get_owned_projects(
|
|||
)
|
||||
projects = result.scalars().all()
|
||||
member_counts = await attach_member_counts(db, projects)
|
||||
git_repo_counts = await attach_git_repo_counts(db, projects)
|
||||
projects_data = []
|
||||
for p in projects:
|
||||
p_dict = ProjectResponse.from_orm(p).dict()
|
||||
|
|
@ -157,6 +174,7 @@ async def get_owned_projects(
|
|||
p_dict['doc_count'] = doc_count
|
||||
p_dict['last_activity_at'] = last_activity_at
|
||||
p_dict['member_count'] = member_counts.get(p.id, 0)
|
||||
p_dict['git_repo_count'] = git_repo_counts.get(p.id, 0)
|
||||
projects_data.append(p_dict)
|
||||
return success_response(data=projects_data)
|
||||
|
||||
|
|
@ -181,6 +199,7 @@ async def get_shared_projects(
|
|||
|
||||
projects = [project for project, _, _ in projects_with_info]
|
||||
member_counts = await attach_member_counts(db, projects)
|
||||
git_repo_counts = await attach_git_repo_counts(db, projects)
|
||||
|
||||
projects_data = []
|
||||
for project, owner, member in projects_with_info:
|
||||
|
|
@ -192,6 +211,7 @@ async def get_shared_projects(
|
|||
project_dict['doc_count'] = doc_count
|
||||
project_dict['last_activity_at'] = last_activity_at
|
||||
project_dict['member_count'] = member_counts.get(project.id, 0)
|
||||
project_dict['git_repo_count'] = git_repo_counts.get(project.id, 0)
|
||||
projects_data.append(project_dict)
|
||||
|
||||
return success_response(data=projects_data)
|
||||
|
|
@ -710,16 +730,34 @@ async def remove_project_member(
|
|||
return success_response(message="成员删除成功")
|
||||
|
||||
|
||||
def _resolve_sync_sub_path(
|
||||
sync_scope: str = None, sub_path: str = None, repo_sync_path: str = ""
|
||||
) -> str:
|
||||
"""Resolve the configured default scope separately from a one-off operation scope."""
|
||||
if sync_scope is None:
|
||||
return (sub_path if sub_path is not None else repo_sync_path or "").strip().strip("/")
|
||||
if sync_scope == "all":
|
||||
return ""
|
||||
if sync_scope != "path":
|
||||
raise ValueError("同步范围必须是 all 或 path")
|
||||
resolved = (sub_path or "").strip().strip("/")
|
||||
if not resolved:
|
||||
raise ValueError("请选择要同步的目录")
|
||||
return resolved
|
||||
|
||||
|
||||
@router.post("/{project_id}/git/pull", response_model=dict)
|
||||
async def git_pull(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
repo_id: int = None,
|
||||
force: bool = False,
|
||||
sync_scope: str = None,
|
||||
sub_path: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""执行 Git Pull"""
|
||||
"""执行 Git Pull(sub_path 非空时仅同步指定目录)"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
|
@ -755,16 +793,25 @@ async def git_pull(
|
|||
|
||||
target_repo = repos[0]
|
||||
|
||||
try:
|
||||
effective_sub_path = _resolve_sync_sub_path(sync_scope, sub_path, target_repo.sync_path)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
project_path = storage_service.get_secure_path(project.storage_key)
|
||||
|
||||
success, msg = await git_service.pull(
|
||||
project_path=project_path,
|
||||
repo_url=target_repo.repo_url,
|
||||
branch=target_repo.branch or "main",
|
||||
username=target_repo.username,
|
||||
token=target_repo.token,
|
||||
force=force
|
||||
)
|
||||
try:
|
||||
success, msg = await git_service.pull(
|
||||
project_path=project_path,
|
||||
repo_url=target_repo.repo_url,
|
||||
branch=target_repo.branch or "main",
|
||||
username=target_repo.username,
|
||||
token=target_repo.token,
|
||||
force=force,
|
||||
sub_path=effective_sub_path
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Git Pull失败: {msg}")
|
||||
|
|
@ -775,17 +822,18 @@ async def git_pull(
|
|||
operation_type=OperationType.GIT_PULL,
|
||||
project_id=project_id,
|
||||
user=current_user,
|
||||
detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force},
|
||||
detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force, "sub_path": effective_sub_path or None},
|
||||
request=request,
|
||||
)
|
||||
|
||||
# 发送通知给其他成员
|
||||
scope_desc = f",同步目录 [{effective_sub_path}]" if effective_sub_path else ""
|
||||
await notification_service.notify_project_members(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
exclude_user_id=current_user.id,
|
||||
title=f"项目文档已通过 Git 同步",
|
||||
content=f"{current_user.nickname or current_user.username} 执行了 Git Pull,项目 [{project.name}] 的内容已从远程仓库同步更新。",
|
||||
content=f"{current_user.nickname or current_user.username} 执行了 Git Pull,项目 [{project.name}] 的内容已从远程仓库同步更新{scope_desc}。",
|
||||
link=f"/projects/{project_id}/docs",
|
||||
category="project"
|
||||
)
|
||||
|
|
@ -800,10 +848,12 @@ async def git_push(
|
|||
request: Request,
|
||||
repo_id: int = None,
|
||||
force: bool = False,
|
||||
sync_scope: str = None,
|
||||
sub_path: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""执行 Git Push"""
|
||||
"""执行 Git Push(sub_path 非空时仅推送指定目录的变更)"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
|
@ -839,16 +889,25 @@ async def git_push(
|
|||
|
||||
target_repo = repos[0]
|
||||
|
||||
try:
|
||||
effective_sub_path = _resolve_sync_sub_path(sync_scope, sub_path, target_repo.sync_path)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
project_path = storage_service.get_secure_path(project.storage_key)
|
||||
|
||||
success, msg = await git_service.push(
|
||||
project_path=project_path,
|
||||
repo_url=target_repo.repo_url,
|
||||
branch=target_repo.branch or "main",
|
||||
username=target_repo.username,
|
||||
token=target_repo.token,
|
||||
force=force
|
||||
)
|
||||
try:
|
||||
success, msg = await git_service.push(
|
||||
project_path=project_path,
|
||||
repo_url=target_repo.repo_url,
|
||||
branch=target_repo.branch or "main",
|
||||
username=target_repo.username,
|
||||
token=target_repo.token,
|
||||
force=force,
|
||||
sub_path=effective_sub_path
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Git Push失败: {msg}")
|
||||
|
|
@ -859,8 +918,91 @@ async def git_push(
|
|||
operation_type=OperationType.GIT_PUSH,
|
||||
project_id=project_id,
|
||||
user=current_user,
|
||||
detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force},
|
||||
detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force, "sub_path": effective_sub_path or None},
|
||||
request=request,
|
||||
)
|
||||
|
||||
return success_response(message=f"Git Push 成功 ({target_repo.name})")
|
||||
|
||||
|
||||
def _build_remote_dir_tree(paths):
|
||||
"""Build TreeSelect options where each node's value is its full repository path."""
|
||||
root = {"children": {}}
|
||||
for path in paths:
|
||||
parts = path.split("/")[:-1]
|
||||
node = root
|
||||
for part in parts:
|
||||
node = node["children"].setdefault(part, {"children": {}})
|
||||
|
||||
def to_options(node, prefix=""):
|
||||
options = []
|
||||
for name in sorted(node["children"]):
|
||||
child = node["children"][name]
|
||||
value = f"{prefix}/{name}" if prefix else name
|
||||
options.append({
|
||||
"title": name,
|
||||
"value": value,
|
||||
"children": to_options(child, value),
|
||||
})
|
||||
return options
|
||||
|
||||
return to_options(root)
|
||||
|
||||
|
||||
@router.post("/{project_id}/git/directories", response_model=dict)
|
||||
async def list_git_directories(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
repo_id: int = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""列出指定 Git 仓库远端目录结构(用于选择同步目录)"""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 权限检查:需要是所有者或管理员/编辑者
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role.in_(["admin", "editor"]),
|
||||
)
|
||||
)
|
||||
if not member_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权操作Git仓库")
|
||||
|
||||
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
|
||||
if repo_id:
|
||||
query = query.where(ProjectGitRepo.id == repo_id)
|
||||
else:
|
||||
query = query.order_by(ProjectGitRepo.is_default.desc(), ProjectGitRepo.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
repos = result.scalars().all()
|
||||
if not repos:
|
||||
raise HTTPException(status_code=400, detail="未配置Git仓库")
|
||||
|
||||
target_repo = repos[0]
|
||||
project_path = storage_service.get_secure_path(project.storage_key)
|
||||
|
||||
try:
|
||||
# Git/网络操作是同步阻塞调用,不能直接在 async 事件循环中执行。
|
||||
success, tree_text = await asyncio.to_thread(
|
||||
git_service.list_remote_tree,
|
||||
project_path,
|
||||
target_repo.repo_url,
|
||||
target_repo.branch or "main",
|
||||
target_repo.username,
|
||||
target_repo.token,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"获取远端目录失败: {tree_text}")
|
||||
|
||||
paths = [line for line in tree_text.splitlines() if line.strip()]
|
||||
return success_response(data={"directories": _build_remote_dir_tree(paths), "repo": target_repo.name})
|
||||
|
|
|
|||
|
|
@ -34,6 +34,17 @@ CHAT_MESSAGE_COLUMNS = [
|
|||
),
|
||||
]
|
||||
|
||||
# 项目 Git 仓库:同步目录(空=整个仓库)。
|
||||
# 该列为新增列,需通过幂等 ALTER 补齐,否则存量库查询会报 Unknown column。
|
||||
GIT_REPO_COLUMNS = [
|
||||
(
|
||||
"project_git_repos",
|
||||
"sync_path",
|
||||
"ALTER TABLE project_git_repos ADD COLUMN sync_path VARCHAR(255) DEFAULT '' "
|
||||
"COMMENT '同步目录(空=整个仓库)'",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||
result = await conn.execute(
|
||||
|
|
@ -57,7 +68,7 @@ async def migrate_schema() -> None:
|
|||
|
||||
added = []
|
||||
async with engine.begin() as conn:
|
||||
for table_name, column_name, ddl in CHAT_MESSAGE_COLUMNS:
|
||||
for table_name, column_name, ddl in CHAT_MESSAGE_COLUMNS + GIT_REPO_COLUMNS:
|
||||
try:
|
||||
exists = await _column_exists(conn, table_name, column_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class ProjectGitRepo(Base):
|
|||
username = Column(String(100), comment="Git用户名")
|
||||
token = Column(String(255), comment="Git访问令牌/密码")
|
||||
is_default = Column(SmallInteger, default=0, comment="是否默认仓库")
|
||||
sync_path = Column(String(255), default="", comment="同步目录(空=整个仓库)")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class GitRepoBase(BaseModel):
|
|||
username: Optional[str] = Field(None, description="Git用户名")
|
||||
token: Optional[str] = Field(None, description="Git访问令牌")
|
||||
is_default: int = Field(0, description="是否默认仓库")
|
||||
sync_path: str = Field("", max_length=255, description="同步目录(空=整个仓库)")
|
||||
|
||||
|
||||
class GitRepoCreate(GitRepoBase):
|
||||
|
|
@ -28,6 +29,7 @@ class GitRepoUpdate(BaseModel):
|
|||
username: Optional[str] = None
|
||||
token: Optional[str] = None
|
||||
is_default: Optional[int] = None
|
||||
sync_path: Optional[str] = None
|
||||
|
||||
|
||||
class GitRepoResponse(GitRepoBase):
|
||||
|
|
@ -36,6 +38,7 @@ class GitRepoResponse(GitRepoBase):
|
|||
project_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
has_token: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
|
|||
|
|
@ -1,128 +1,286 @@
|
|||
import subprocess
|
||||
import base64
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import quote_plus
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
|
||||
class GitService:
|
||||
|
||||
def _get_auth_url(self, repo_url: str, username: str = None, token: str = None) -> str:
|
||||
"""
|
||||
Constructs a URL with authentication credentials.
|
||||
Note: This is sensitive, so be careful not to log this URL.
|
||||
"""
|
||||
if not username or not token:
|
||||
return repo_url
|
||||
|
||||
# Encode credentials to handle special characters (e.g. @, :, /)
|
||||
safe_username = quote_plus(username)
|
||||
safe_token = quote_plus(token)
|
||||
|
||||
# Remove scheme if present to insert auth
|
||||
if repo_url.startswith("https://"):
|
||||
url_body = repo_url[8:]
|
||||
return f"https://{safe_username}:{safe_token}@{url_body}"
|
||||
elif repo_url.startswith("http://"):
|
||||
url_body = repo_url[7:]
|
||||
return f"http://{safe_username}:{safe_token}@{url_body}"
|
||||
|
||||
return repo_url
|
||||
"""Run Git synchronization without persisting repository credentials."""
|
||||
|
||||
def _run_command(self, cmd: list, cwd: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
Runs a shell command in the given directory.
|
||||
Returns (success, message).
|
||||
"""
|
||||
def _clean_repo_url(self, repo_url: str) -> str:
|
||||
"""Remove any user info already embedded in an HTTP(S) URL."""
|
||||
parsed = urlsplit(repo_url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return repo_url
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment))
|
||||
|
||||
def _auth_env(self, username: str = None, token: str = None) -> dict:
|
||||
"""Pass HTTPS Basic credentials to Git for this process only."""
|
||||
env = {"GIT_TERMINAL_PROMPT": "0"}
|
||||
if username and token:
|
||||
credential = base64.b64encode(f"{username}:{token}".encode()).decode()
|
||||
env.update({
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "http.extraHeader",
|
||||
"GIT_CONFIG_VALUE_0": f"Authorization: Basic {credential}",
|
||||
})
|
||||
return env
|
||||
|
||||
def _run_command(self, cmd: list, cwd: Path, env: dict = None, timeout: int = 60) -> Tuple[bool, str]:
|
||||
"""Run a command and return its output without exposing process environment."""
|
||||
try:
|
||||
# git operations might take time
|
||||
run_env = os.environ.copy()
|
||||
run_env.update({"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"})
|
||||
if env:
|
||||
run_env.update(env)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False # We handle return code manually
|
||||
encoding="utf-8",
|
||||
env=run_env,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, result.stdout
|
||||
return False, result.stderr.strip() or result.stdout.strip() or "Git command failed"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"Git 命令超时({timeout} 秒):{' '.join(cmd)}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, str(exc)
|
||||
|
||||
def _ensure_git_initialized(self, cwd: Path, repo_url: str) -> Tuple[bool, str]:
|
||||
"""Ensure the project has a Git repository pointing to the credential-free URL."""
|
||||
if not (cwd / ".git").exists():
|
||||
ok, message = self._run_command(["git", "init"], cwd)
|
||||
if not ok:
|
||||
return False, message
|
||||
ok, message = self._run_command(["git", "branch", "-M", "main"], cwd)
|
||||
if not ok:
|
||||
return False, message
|
||||
|
||||
for key, value in (
|
||||
("core.quotepath", "false"),
|
||||
("i18n.logOutputEncoding", "utf-8"),
|
||||
("core.precomposeunicode", "true"),
|
||||
):
|
||||
ok, message = self._run_command(["git", "config", key, value], cwd)
|
||||
if not ok:
|
||||
return False, message
|
||||
|
||||
clean_url = self._clean_repo_url(repo_url)
|
||||
ok, _ = self._run_command(["git", "remote", "get-url", "origin"], cwd)
|
||||
if ok:
|
||||
return self._run_command(["git", "remote", "set-url", "origin", clean_url], cwd)
|
||||
return self._run_command(["git", "remote", "add", "origin", clean_url], cwd)
|
||||
|
||||
def _normalize_sub_path(self, sub_path: str = None) -> str:
|
||||
if not sub_path:
|
||||
return ""
|
||||
normalized = str(sub_path).strip().strip("/")
|
||||
if not normalized:
|
||||
return ""
|
||||
parts = normalized.split("/")
|
||||
if any(part in ("", ".", "..") or part.startswith(".") for part in parts):
|
||||
raise ValueError(f"非法的同步目录: {normalized}")
|
||||
return normalized
|
||||
|
||||
def _ensure_commit_identity(self, cwd: Path) -> Tuple[bool, str]:
|
||||
ok, name = self._run_command(["git", "config", "user.name"], cwd)
|
||||
if not ok or not name.strip():
|
||||
ok, message = self._run_command(["git", "config", "user.name", "NEX Docus"], cwd)
|
||||
if not ok:
|
||||
return False, message
|
||||
ok, email = self._run_command(["git", "config", "user.email"], cwd)
|
||||
if not ok or not email.strip():
|
||||
return self._run_command(
|
||||
["git", "config", "user.email", "noreply@nex-docus.local"], cwd
|
||||
)
|
||||
return True, ""
|
||||
|
||||
def _commit_if_changed(self, cwd: Path, message: str) -> Tuple[bool, str, bool]:
|
||||
ok, status = self._run_command(["git", "status", "--porcelain"], cwd)
|
||||
if not ok:
|
||||
return False, status, False
|
||||
if not status.strip():
|
||||
return True, "", False
|
||||
ok, identity_message = self._ensure_commit_identity(cwd)
|
||||
if not ok:
|
||||
return False, identity_message, False
|
||||
ok, commit_message = self._run_command(["git", "commit", "-m", message], cwd)
|
||||
return ok, commit_message, ok
|
||||
|
||||
def _remote_worktree(self, repo_url: str, branch: str, auth_env: dict):
|
||||
"""Create an isolated worktree at the latest remote branch revision."""
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="nex-docus-git-")
|
||||
root = Path(temp_dir.name)
|
||||
ok, message = self._run_command(["git", "init"], root)
|
||||
if not ok:
|
||||
temp_dir.cleanup()
|
||||
return None, None, message
|
||||
ok, message = self._run_command(
|
||||
["git", "remote", "add", "origin", self._clean_repo_url(repo_url)], root
|
||||
)
|
||||
if not ok:
|
||||
temp_dir.cleanup()
|
||||
return None, None, message
|
||||
ok, message = self._run_command(["git", "fetch", "origin"], root, auth_env)
|
||||
if not ok:
|
||||
temp_dir.cleanup()
|
||||
return None, None, message
|
||||
|
||||
remote_ref = f"origin/{branch}"
|
||||
ok, _ = self._run_command(["git", "rev-parse", "--verify", remote_ref], root)
|
||||
if ok:
|
||||
ok, message = self._run_command(["git", "checkout", "-B", branch, remote_ref], root)
|
||||
else:
|
||||
ok, message = self._run_command(["git", "checkout", "-B", branch], root)
|
||||
if not ok:
|
||||
temp_dir.cleanup()
|
||||
return None, None, message
|
||||
return temp_dir, root, ""
|
||||
|
||||
def _replace_path(self, source_root: Path, target_root: Path, sub_path: str) -> None:
|
||||
source = source_root / sub_path
|
||||
target = target_root / sub_path
|
||||
if target.exists() or target.is_symlink():
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
return False, f"Command failed: {' '.join(cmd)}\nError: {result.stderr}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
target.unlink()
|
||||
if source.exists():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target)
|
||||
else:
|
||||
shutil.copy2(source, target)
|
||||
|
||||
def _ensure_git_initialized(self, cwd: Path, auth_url: str):
|
||||
"""
|
||||
Ensures the directory is a git repository and has the correct remote.
|
||||
"""
|
||||
git_dir = cwd / ".git"
|
||||
if not git_dir.exists():
|
||||
self._run_command(["git", "init"], cwd)
|
||||
self._run_command(["git", "branch", "-M", "main"], cwd) # Default to main
|
||||
|
||||
# Check remote
|
||||
success, output = self._run_command(["git", "remote", "get-url", "origin"], cwd)
|
||||
if not success:
|
||||
# Remote doesn't exist, add it
|
||||
self._run_command(["git", "remote", "add", "origin", auth_url], cwd)
|
||||
else:
|
||||
# Remote exists, update it (in case credentials or URL changed)
|
||||
current_url = output.strip()
|
||||
if current_url != auth_url:
|
||||
self._run_command(["git", "remote", "set-url", "origin", auth_url], cwd)
|
||||
|
||||
async def pull(self, project_path: Path, repo_url: str, branch: str = "main", username: str = None, token: str = None, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Executes git pull.
|
||||
"""
|
||||
def _pull(
|
||||
self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
|
||||
token: str = None, force: bool = False, sub_path: str = None,
|
||||
) -> Tuple[bool, str]:
|
||||
if not project_path.exists():
|
||||
return False, "Project path does not exist"
|
||||
return False, "项目目录不存在"
|
||||
|
||||
auth_url = self._get_auth_url(repo_url, username, token)
|
||||
|
||||
# Ensure git init and remote
|
||||
self._ensure_git_initialized(project_path, auth_url)
|
||||
sub_path = self._normalize_sub_path(sub_path)
|
||||
auth_env = self._auth_env(username, token)
|
||||
if sub_path:
|
||||
if force:
|
||||
return False, "目录级同步不支持强制拉取"
|
||||
temp_dir, remote_root, message = self._remote_worktree(repo_url, branch, auth_env)
|
||||
if not temp_dir:
|
||||
return False, f"获取远端仓库失败: {message}"
|
||||
try:
|
||||
self._replace_path(remote_root, project_path, sub_path)
|
||||
finally:
|
||||
temp_dir.cleanup()
|
||||
return True, f"已从远端同步目录 [{sub_path}]"
|
||||
|
||||
# Fetch first
|
||||
success, msg = self._run_command(["git", "fetch", "origin"], project_path)
|
||||
if not success:
|
||||
return False, f"Fetch failed: {msg}"
|
||||
|
||||
ok, message = self._ensure_git_initialized(project_path, repo_url)
|
||||
if not ok:
|
||||
return False, message
|
||||
ok, message = self._run_command(["git", "fetch", "origin"], project_path, auth_env)
|
||||
if not ok:
|
||||
return False, f"获取远端更新失败: {message}"
|
||||
if force:
|
||||
# Force Reset to remote
|
||||
cmd = ["git", "reset", "--hard", f"origin/{branch}"]
|
||||
else:
|
||||
# Simple pull
|
||||
cmd = ["git", "pull", "origin", branch]
|
||||
|
||||
return self._run_command(cmd, project_path)
|
||||
return self._run_command(["git", "reset", "--hard", f"origin/{branch}"], project_path)
|
||||
return self._run_command(["git", "pull", "--ff-only", "origin", branch], project_path, auth_env)
|
||||
|
||||
async def push(self, project_path: Path, repo_url: str, branch: str = "main", username: str = None, token: str = None, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Executes git push.
|
||||
"""
|
||||
async def pull(self, *args, **kwargs) -> Tuple[bool, str]:
|
||||
"""Run pull outside the async event loop."""
|
||||
return await asyncio.to_thread(self._pull, *args, **kwargs)
|
||||
|
||||
def _push(
|
||||
self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
|
||||
token: str = None, force: bool = False, sub_path: str = None,
|
||||
) -> Tuple[bool, str]:
|
||||
if not project_path.exists():
|
||||
return False, "Project path does not exist"
|
||||
return False, "项目目录不存在"
|
||||
|
||||
auth_url = self._get_auth_url(repo_url, username, token)
|
||||
|
||||
# Ensure git init and remote
|
||||
self._ensure_git_initialized(project_path, auth_url)
|
||||
|
||||
# Add all changes
|
||||
self._run_command(["git", "add", "."], project_path)
|
||||
|
||||
# Commit if changes exist
|
||||
# Check if there are changes to commit
|
||||
status_success, status_output = self._run_command(["git", "status", "--porcelain"], project_path)
|
||||
if status_success and status_output.strip():
|
||||
# Create a commit
|
||||
self._run_command(["git", "commit", "-m", "Update from Nex Docus"], project_path)
|
||||
|
||||
# Push
|
||||
cmd = ["git", "push", "-u", "origin", branch]
|
||||
sub_path = self._normalize_sub_path(sub_path)
|
||||
auth_env = self._auth_env(username, token)
|
||||
if sub_path:
|
||||
if force:
|
||||
return False, "目录级推送不支持强制推送"
|
||||
temp_dir, remote_root, message = self._remote_worktree(repo_url, branch, auth_env)
|
||||
if not temp_dir:
|
||||
return False, f"获取远端仓库失败: {message}"
|
||||
try:
|
||||
self._replace_path(project_path, remote_root, sub_path)
|
||||
ok, message = self._run_command(["git", "add", "-A", "--", sub_path], remote_root)
|
||||
if not ok:
|
||||
return False, f"暂存目录失败: {message}"
|
||||
ok, message, changed = self._commit_if_changed(
|
||||
remote_root, f"Update {sub_path} from NEX Docus"
|
||||
)
|
||||
if not ok:
|
||||
return False, f"提交目录失败: {message}"
|
||||
if not changed:
|
||||
return True, f"目录 [{sub_path}] 没有需要推送的变更"
|
||||
ok, message = self._run_command(
|
||||
["git", "push", "-u", "origin", branch], remote_root, auth_env
|
||||
)
|
||||
if not ok:
|
||||
return False, f"推送被远端拒绝,请重新拉取该目录后再试: {message}"
|
||||
return True, f"已推送目录 [{sub_path}]"
|
||||
finally:
|
||||
temp_dir.cleanup()
|
||||
|
||||
ok, message = self._ensure_git_initialized(project_path, repo_url)
|
||||
if not ok:
|
||||
return False, message
|
||||
ok, message = self._run_command(["git", "fetch", "origin"], project_path, auth_env)
|
||||
if not ok:
|
||||
return False, f"获取远端更新失败: {message}"
|
||||
if not force:
|
||||
ok, _ = self._run_command(
|
||||
["git", "merge-base", "--is-ancestor", f"origin/{branch}", "HEAD"], project_path
|
||||
)
|
||||
if not ok:
|
||||
return False, "远端仓库已有未拉取的更新,请先执行整库拉取后再推送"
|
||||
ok, message = self._run_command(["git", "add", "."], project_path)
|
||||
if not ok:
|
||||
return False, f"暂存项目失败: {message}"
|
||||
ok, message, _ = self._commit_if_changed(project_path, "Update from NEX Docus")
|
||||
if not ok:
|
||||
return False, f"提交项目失败: {message}"
|
||||
command = ["git", "push", "-u", "origin", branch]
|
||||
if force:
|
||||
cmd.append("--force")
|
||||
|
||||
return self._run_command(cmd, project_path)
|
||||
command.append("--force-with-lease")
|
||||
return self._run_command(command, project_path, auth_env)
|
||||
|
||||
async def push(self, *args, **kwargs) -> Tuple[bool, str]:
|
||||
"""Run push outside the async event loop."""
|
||||
return await asyncio.to_thread(self._push, *args, **kwargs)
|
||||
|
||||
def list_remote_tree(
|
||||
self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
|
||||
token: str = None,
|
||||
) -> Tuple[bool, str]:
|
||||
if not project_path.exists():
|
||||
return False, "项目目录不存在"
|
||||
temp_dir, remote_root, message = self._remote_worktree(
|
||||
repo_url, branch, self._auth_env(username, token)
|
||||
)
|
||||
if not temp_dir:
|
||||
return False, f"获取远端仓库失败: {message}"
|
||||
try:
|
||||
return self._run_command(
|
||||
["git", "-c", "core.quotepath=false", "ls-tree", "-r", "--name-only", "HEAD"],
|
||||
remote_root,
|
||||
)
|
||||
finally:
|
||||
temp_dir.cleanup()
|
||||
|
||||
|
||||
git_service = GitService()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
-- 为项目Git仓库表增加“同步目录”字段(空=整个仓库)
|
||||
ALTER TABLE project_git_repos
|
||||
ADD COLUMN sync_path VARCHAR(255) DEFAULT '' COMMENT '同步目录(空=整个仓库)' AFTER is_default;
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.git_service import GitService
|
||||
|
||||
|
||||
def run_git(cwd: Path, *args: str) -> str:
|
||||
result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def commit_file(cwd: Path, relative_path: str, content: str, message: str) -> None:
|
||||
target = cwd / relative_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
run_git(cwd, "add", ".")
|
||||
run_git(cwd, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", message)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_fixture(tmp_path: Path):
|
||||
remote = tmp_path / "remote.git"
|
||||
local = tmp_path / "local"
|
||||
seed = tmp_path / "seed"
|
||||
remote.mkdir()
|
||||
local.mkdir()
|
||||
seed.mkdir()
|
||||
run_git(remote, "init", "--bare", "-b", "main")
|
||||
run_git(seed, "init", "-b", "main")
|
||||
commit_file(seed, "docs/one.md", "one", "initial")
|
||||
commit_file(seed, "other.txt", "other", "other")
|
||||
run_git(seed, "remote", "add", "origin", str(remote))
|
||||
run_git(seed, "push", "-u", "origin", "main")
|
||||
run_git(local, "clone", "-b", "main", str(remote), ".")
|
||||
return remote, local, seed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directory_push_only_changes_selected_directory(git_fixture):
|
||||
remote, local, _ = git_fixture
|
||||
service = GitService()
|
||||
(local / "docs/one.md").write_text("updated", encoding="utf-8")
|
||||
(local / "other.txt").write_text("local-only", encoding="utf-8")
|
||||
|
||||
ok, message = await service.push(local, str(remote), sub_path="docs")
|
||||
|
||||
assert ok, message
|
||||
assert run_git(local, "remote", "get-url", "origin") == str(remote)
|
||||
check = Path(local.parent / "check")
|
||||
run_git(local.parent, "clone", "-b", "main", str(remote), str(check))
|
||||
assert (check / "docs/one.md").read_text(encoding="utf-8") == "updated"
|
||||
assert (check / "other.txt").read_text(encoding="utf-8") == "other"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whole_push_rejects_remote_ahead(git_fixture):
|
||||
remote, local, seed = git_fixture
|
||||
service = GitService()
|
||||
commit_file(seed, "other.txt", "remote-update", "remote update")
|
||||
run_git(seed, "push", "origin", "main")
|
||||
(local / "docs/one.md").write_text("local-update", encoding="utf-8")
|
||||
|
||||
ok, message = await service.push(local, str(remote))
|
||||
|
||||
assert not ok
|
||||
assert "先执行整库拉取" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credentials_are_not_written_to_remote_url(git_fixture):
|
||||
remote, local, _ = git_fixture
|
||||
service = GitService()
|
||||
ok, message = await service.pull(local, str(remote), username="user", token="secret")
|
||||
|
||||
assert ok, message
|
||||
assert "secret" not in run_git(local, "remote", "get-url", "origin")
|
||||
|
|
@ -152,22 +152,22 @@ export function removeProjectMember(projectId, userId) {
|
|||
/**
|
||||
* Git Pull
|
||||
*/
|
||||
export function gitPull(projectId, repoId = null, force = false) {
|
||||
export function gitPull(projectId, repoId = null, force = false, syncScope = null, subPath = '') {
|
||||
return request({
|
||||
url: `/projects/${projectId}/git/pull`,
|
||||
method: 'post',
|
||||
params: { repo_id: repoId, force }
|
||||
params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Git Push
|
||||
*/
|
||||
export function gitPush(projectId, repoId = null, force = false) {
|
||||
export function gitPush(projectId, repoId = null, force = false, syncScope = null, subPath = '') {
|
||||
return request({
|
||||
url: `/projects/${projectId}/git/push`,
|
||||
method: 'post',
|
||||
params: { repo_id: repoId, force }
|
||||
params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined }
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -212,3 +212,14 @@ export function deleteGitRepo(projectId, repoId) {
|
|||
method: 'delete',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Git仓库远端目录结构(用于选择同步目录)
|
||||
*/
|
||||
export function getGitRepoDirectories(projectId, repoId = null) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/git/directories`,
|
||||
method: 'post',
|
||||
params: { repo_id: repoId || undefined },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,78 @@
|
|||
gap: 16px;
|
||||
}
|
||||
|
||||
.docs-breadcrumb-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.docs-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-color);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.docs-breadcrumb-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.docs-breadcrumb-link,
|
||||
.docs-breadcrumb-current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 4px 6px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.docs-breadcrumb-link {
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.docs-breadcrumb-link:hover {
|
||||
background: var(--item-hover-bg);
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
.docs-breadcrumb-link:focus-visible {
|
||||
outline: 2px solid rgba(22, 119, 255, 0.35);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.docs-breadcrumb-current {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.docs-breadcrumb-current span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.docs-breadcrumb-separator {
|
||||
margin: 0 2px;
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.docs-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -285,44 +357,11 @@
|
|||
padding: 4px 0 24px;
|
||||
}
|
||||
|
||||
.docs-folder-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--bg-color-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docs-folder-path {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docs-folder-path-icon {
|
||||
flex: none;
|
||||
color: #faad14;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.docs-folder-path-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.docs-folder-counts {
|
||||
flex: none;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
|
||||
import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch, Select, TreeSelect, Radio, Alert } from 'antd'
|
||||
import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
|
@ -12,7 +12,7 @@ import Highlighter from 'react-highlight-words'
|
|||
import Mark from 'mark.js'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
|
||||
import { gitPull, gitPush, getGitRepos } from '@/api/project'
|
||||
import { gitPull, gitPush, getGitRepos, getGitRepoDirectories } from '@/api/project'
|
||||
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import { markProjectNotificationsRead } from '@/api/notification'
|
||||
|
|
@ -26,6 +26,7 @@ import './DocumentPage.css'
|
|||
|
||||
const { Sider, Content } = Layout
|
||||
const MAX_TOC_ITEMS = 500
|
||||
const ROOT_FOLDER_KEY = '__root__'
|
||||
|
||||
// 高亮渲染组件
|
||||
const HighlightText = ({ text, keyword }) => {
|
||||
|
|
@ -60,6 +61,14 @@ function DocumentPage() {
|
|||
const [pdfFilename, setPdfFilename] = useState('')
|
||||
const [viewMode, setViewMode] = useState('markdown')
|
||||
const [gitRepos, setGitRepos] = useState([])
|
||||
// Git 同步范围弹窗状态
|
||||
const [gitSyncModalVisible, setGitSyncModalVisible] = useState(false)
|
||||
const [gitSyncAction, setGitSyncAction] = useState(null) // 'pull' | 'push'
|
||||
const [gitSyncRepoId, setGitSyncRepoId] = useState(null)
|
||||
const [gitSyncScope, setGitSyncScope] = useState('all') // 'all' | 'dir'
|
||||
const [gitSyncSubPath, setGitSyncSubPath] = useState('')
|
||||
const [gitRemoteDirOptions, setGitRemoteDirOptions] = useState([])
|
||||
const [gitSyncing, setGitSyncing] = useState(false)
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
|
|
@ -77,21 +86,6 @@ function DocumentPage() {
|
|||
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
||||
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
|
||||
|
||||
const getHeaderDisplay = (filePath) => {
|
||||
const resolvedPath = selectedNodeKey || filePath || 'README.md'
|
||||
const fileName = resolvedPath.split('/').filter(Boolean).pop() || 'README.md'
|
||||
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
|
||||
const isFolder = Boolean(selectedNode && !selectedNode.isLeaf)
|
||||
const isPdf = fileName.toLowerCase().endsWith('.pdf')
|
||||
const FileIcon = isFolder ? FolderOutlined : isPdf ? FilePdfOutlined : FileTextOutlined
|
||||
|
||||
return {
|
||||
fileName,
|
||||
FileIcon,
|
||||
isPdf,
|
||||
}
|
||||
}
|
||||
|
||||
const navigateWithTransition = (to) => {
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(() => navigate(to))
|
||||
|
|
@ -148,9 +142,27 @@ function DocumentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const selectRootFolder = ({ syncUrl = true } = {}) => {
|
||||
setSelectedFile('')
|
||||
setSelectedNodeKey(ROOT_FOLDER_KEY)
|
||||
setMarkdownContent('')
|
||||
setTocItems([])
|
||||
setViewMode('folder')
|
||||
setOpenKeys([])
|
||||
|
||||
if (syncUrl) {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
nextParams.delete('file')
|
||||
nextParams.set('selected', ROOT_FOLDER_KEY)
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件夹内的子节点(与左侧文件树保持一致:文件夹 + md/pdf 文档)
|
||||
const getFolderChildren = (folderPath) => {
|
||||
const node = folderPath ? findNodeByKey(fileTree, folderPath) : null
|
||||
const node = folderPath === ROOT_FOLDER_KEY
|
||||
? { key: ROOT_FOLDER_KEY, title: projectName || '根目录', isLeaf: false, children: fileTree }
|
||||
: folderPath ? findNodeByKey(fileTree, folderPath) : null
|
||||
if (!node || node.isLeaf) return { folders: [], files: [], node }
|
||||
const children = node.children || []
|
||||
const folders = children.filter(c => !c.isLeaf)
|
||||
|
|
@ -160,6 +172,91 @@ function DocumentPage() {
|
|||
return { folders, files, node }
|
||||
}
|
||||
|
||||
const getBreadcrumbFolderPath = () => {
|
||||
if (viewMode === 'folder') {
|
||||
return selectedNodeKey === ROOT_FOLDER_KEY ? ROOT_FOLDER_KEY : selectedNodeKey
|
||||
}
|
||||
const selectedPath = selectedFile || selectedNodeKey || ''
|
||||
const lastSlash = selectedPath.lastIndexOf('/')
|
||||
return lastSlash >= 0 ? selectedPath.substring(0, lastSlash) : ROOT_FOLDER_KEY
|
||||
}
|
||||
|
||||
const getBreadcrumbItems = () => {
|
||||
const folderPath = getBreadcrumbFolderPath()
|
||||
const folderParts = folderPath && folderPath !== ROOT_FOLDER_KEY
|
||||
? folderPath.split('/').filter(Boolean)
|
||||
: []
|
||||
const items = [{
|
||||
key: ROOT_FOLDER_KEY,
|
||||
label: projectName || '根目录',
|
||||
icon: <FolderOutlined />,
|
||||
onClick: () => selectRootFolder(),
|
||||
}]
|
||||
|
||||
let currentPath = ''
|
||||
folderParts.forEach((part) => {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
||||
const path = currentPath
|
||||
items.push({
|
||||
key: path,
|
||||
label: part,
|
||||
icon: <FolderOutlined />,
|
||||
onClick: () => selectFolder(path, { syncUrl: true }),
|
||||
})
|
||||
})
|
||||
|
||||
if (viewMode !== 'folder' && selectedFile) {
|
||||
const fileName = selectedFile.split('/').filter(Boolean).pop()
|
||||
const isPdf = fileName.toLowerCase().endsWith('.pdf')
|
||||
items.push({
|
||||
key: selectedFile,
|
||||
label: isPdf ? fileName : fileName.replace(/\.md$/i, ''),
|
||||
icon: isPdf ? <FilePdfOutlined /> : <FileTextOutlined />,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const renderContentBreadcrumb = () => {
|
||||
const items = getBreadcrumbItems()
|
||||
const { folders, files } = getFolderChildren(getBreadcrumbFolderPath())
|
||||
|
||||
return (
|
||||
<div className="docs-breadcrumb-row">
|
||||
<nav className="docs-breadcrumb" aria-label="文档路径">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
const content = (
|
||||
<>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<span className="docs-breadcrumb-segment" key={item.key}>
|
||||
{isLast ? (
|
||||
<span className="docs-breadcrumb-current" aria-current="page">
|
||||
{content}
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="docs-breadcrumb-link" onClick={item.onClick}>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
{!isLast && <span className="docs-breadcrumb-separator" aria-hidden="true">/</span>}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{viewMode === 'folder' && (
|
||||
<div className="docs-folder-counts" aria-label="当前目录统计">
|
||||
{folders.length} 个文件夹 · {files.length} 个文档
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const openDocumentPath = (filePath, { syncUrl = false } = {}) => {
|
||||
setSelectedFile(filePath)
|
||||
setSelectedNodeKey(filePath)
|
||||
|
|
@ -196,6 +293,7 @@ function DocumentPage() {
|
|||
|
||||
useEffect(() => {
|
||||
loadFileTree()
|
||||
loadGitRepos()
|
||||
}, [projectId])
|
||||
|
||||
// 打开项目后,将该项目的未读更新通知标记为已读(与消息通知联动)
|
||||
|
|
@ -246,6 +344,12 @@ function DocumentPage() {
|
|||
|
||||
// 处理文件加载
|
||||
if (selectedParam) {
|
||||
if (selectedParam === ROOT_FOLDER_KEY) {
|
||||
if (selectedNodeKey !== ROOT_FOLDER_KEY) {
|
||||
selectRootFolder({ syncUrl: false })
|
||||
}
|
||||
return
|
||||
}
|
||||
const targetNode = findNodeByKey(fileTree, selectedParam)
|
||||
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) {
|
||||
selectFolder(selectedParam)
|
||||
|
|
@ -737,13 +841,13 @@ function DocumentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleGitPull = async (repoId = null, force = false) => {
|
||||
const handleGitPull = async (repoId = null, force = false, syncScope = null, subPath = '') => {
|
||||
if (gitRepos.length === 0) {
|
||||
message.warning('未配置Git仓库')
|
||||
return
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const res = await gitPull(projectId, repoId, force)
|
||||
const res = await gitPull(projectId, repoId, force, syncScope, subPath)
|
||||
message.success(res.message || 'Git Pull 成功')
|
||||
// Refresh tree
|
||||
loadFileTree()
|
||||
|
|
@ -751,11 +855,13 @@ function DocumentPage() {
|
|||
if (selectedFile) {
|
||||
loadMarkdown(selectedFile)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Git Pull error:', error)
|
||||
const errorMsg = error.response?.data?.detail || 'Git Pull 失败'
|
||||
|
||||
if (!force) {
|
||||
|
||||
// 目录级同步不提供强制重置(强制重置作用于整个仓库,与目录同步语义冲突)
|
||||
if (!force && !subPath) {
|
||||
Modal.confirm({
|
||||
title: 'Git Pull 失败',
|
||||
content: (
|
||||
|
|
@ -772,28 +878,30 @@ function DocumentPage() {
|
|||
okText: '强制重置',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => handleGitPull(repoId, true)
|
||||
onOk: () => handleGitPull(repoId, true, 'all')
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
message.error(errorMsg)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGitPush = async (repoId = null, force = false) => {
|
||||
const handleGitPush = async (repoId = null, force = false, syncScope = null, subPath = '') => {
|
||||
if (gitRepos.length === 0) {
|
||||
message.warning('未配置Git仓库')
|
||||
return
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const res = await gitPush(projectId, repoId, force)
|
||||
const res = await gitPush(projectId, repoId, force, syncScope, subPath)
|
||||
message.success(res.message || 'Git Push 成功')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Git Push error:', error)
|
||||
const errorMsg = error.response?.data?.detail || 'Git Push 失败'
|
||||
|
||||
if (!force) {
|
||||
|
||||
if (!force && !subPath) {
|
||||
Modal.confirm({
|
||||
title: 'Git Push 失败',
|
||||
content: (
|
||||
|
|
@ -810,89 +918,117 @@ function DocumentPage() {
|
|||
okText: '强制推送',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => handleGitPush(repoId, true)
|
||||
onOk: () => handleGitPush(repoId, true, 'all')
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
message.error(errorMsg)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 构建项目目录树选项(仅文件夹,用于选择同步目录)
|
||||
const gitDirTreeData = useMemo(() => {
|
||||
const build = (nodes) => (nodes || [])
|
||||
.filter(n => !n.isLeaf)
|
||||
.map(n => ({
|
||||
title: n.title,
|
||||
value: n.key,
|
||||
children: n.children ? build(n.children) : [],
|
||||
}))
|
||||
return build(fileTree)
|
||||
}, [fileTree])
|
||||
|
||||
// 加载指定仓库的远端目录结构(用于选择同步目录)
|
||||
const loadGitRemoteDirs = (repoId = null) => {
|
||||
getGitRepoDirectories(projectId, repoId)
|
||||
.then((res) => setGitRemoteDirOptions(res.data?.directories || []))
|
||||
.catch(() => setGitRemoteDirOptions([]))
|
||||
}
|
||||
|
||||
// 打开 Git 同步范围弹窗
|
||||
const openGitSyncModal = (action, repoId = null) => {
|
||||
const targetRepo = repoId
|
||||
? gitRepos.find(r => r.id === repoId)
|
||||
: (gitRepos.find(r => r.is_default === 1) || gitRepos[0])
|
||||
setGitSyncAction(action)
|
||||
setGitSyncRepoId(targetRepo?.id || null)
|
||||
setGitSyncScope(targetRepo?.sync_path ? 'dir' : 'all')
|
||||
setGitSyncSubPath(targetRepo?.sync_path || '')
|
||||
setGitSyncModalVisible(true)
|
||||
// 加载该仓库远端目录结构(覆盖本地未拉取时的空树)
|
||||
loadGitRemoteDirs(targetRepo?.id)
|
||||
}
|
||||
|
||||
// 确认执行同步
|
||||
const confirmGitSync = async () => {
|
||||
const subPath = gitSyncScope === 'dir' ? (gitSyncSubPath || '') : ''
|
||||
setGitSyncing(true)
|
||||
try {
|
||||
const syncScope = gitSyncScope === 'dir' ? 'path' : 'all'
|
||||
const success = gitSyncAction === 'pull'
|
||||
? await handleGitPull(gitSyncRepoId || undefined, false, syncScope, subPath)
|
||||
: await handleGitPush(gitSyncRepoId || undefined, false, syncScope, subPath)
|
||||
if (success) {
|
||||
setGitSyncModalVisible(false)
|
||||
}
|
||||
} finally {
|
||||
setGitSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderGitActions = () => {
|
||||
const pullButton = (
|
||||
<Tooltip title="Git Pull(可选择同步目录)">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => openGitSyncModal('pull')}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const pushButton = (
|
||||
<Tooltip title="Git Push(可选择同步目录)">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => openGitSyncModal('push')}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
if (gitRepos.length <= 1) {
|
||||
// 0 或 1 个仓库,显示普通按钮
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Git Pull">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => handleGitPull()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Git Push">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => handleGitPush()}
|
||||
/>
|
||||
</Tooltip>
|
||||
{pullButton}
|
||||
{pushButton}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 多个仓库,显示下拉菜单
|
||||
// 多个仓库,显示下拉菜单(选择仓库后打开同步范围弹窗)
|
||||
const pullItems = gitRepos.map(repo => ({
|
||||
key: repo.id,
|
||||
label: repo.name + (repo.is_default ? ' (默认)' : ''),
|
||||
onClick: () => handleGitPull(repo.id),
|
||||
onClick: () => openGitSyncModal('pull', repo.id),
|
||||
}))
|
||||
|
||||
const pushItems = gitRepos.map(repo => ({
|
||||
key: repo.id,
|
||||
label: repo.name + (repo.is_default ? ' (默认)' : ''),
|
||||
onClick: () => handleGitPush(repo.id),
|
||||
onClick: () => openGitSyncModal('push', repo.id),
|
||||
}))
|
||||
|
||||
if (gitRepos.length <= 1) {
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Git Pull">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => handleGitPull()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Git Push">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => handleGitPush()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown menu={{ items: pullItems }}>
|
||||
<Tooltip title="Git Pull">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{pullButton}
|
||||
</Dropdown>
|
||||
<Dropdown menu={{ items: pushItems }}>
|
||||
<Tooltip title="Git Push">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<CloudUploadOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{pushButton}
|
||||
</Dropdown>
|
||||
</>
|
||||
)
|
||||
|
|
@ -1159,7 +1295,6 @@ function DocumentPage() {
|
|||
>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
<h2 title={projectName}>{projectName}</h2>
|
||||
</div>
|
||||
<div className="docs-sider-actions">
|
||||
<div className="mode-actions-row">
|
||||
|
|
@ -1236,39 +1371,27 @@ function DocumentPage() {
|
|||
{/* 右侧内容区 */}
|
||||
<Layout className="docs-content-layout">
|
||||
<Content className="docs-content" ref={contentRef}>
|
||||
<div className="docs-content-header" title={selectedFile || 'README.md'}>
|
||||
{(() => {
|
||||
const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile)
|
||||
return (
|
||||
<>
|
||||
<div className="docs-header-title">
|
||||
<span className="docs-header-item">
|
||||
<FileIcon className="docs-header-icon" style={isPdf ? { color: '#f5222d' } : undefined} />
|
||||
<span className="docs-header-text">{fileName}</span>
|
||||
</span>
|
||||
</div>
|
||||
{viewMode === 'pdf' && <div className="docs-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
|
||||
{viewMode === 'markdown' && (
|
||||
<Space className="docs-header-actions">
|
||||
<Button
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
onClick={scrollContentToTop}
|
||||
size="small"
|
||||
>
|
||||
回到顶部
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={handleExportMarkdownPDF}
|
||||
size="small"
|
||||
>
|
||||
下载PDF
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<div className="docs-content-header">
|
||||
{renderContentBreadcrumb()}
|
||||
{viewMode === 'pdf' && <div className="docs-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
|
||||
{viewMode === 'markdown' && (
|
||||
<Space className="docs-header-actions">
|
||||
<Button
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
onClick={scrollContentToTop}
|
||||
size="small"
|
||||
>
|
||||
回到顶部
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={handleExportMarkdownPDF}
|
||||
size="small"
|
||||
>
|
||||
下载PDF
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
|
||||
{loading ? (
|
||||
|
|
@ -1297,16 +1420,6 @@ function DocumentPage() {
|
|||
const total = folders.length + files.length
|
||||
return (
|
||||
<div className="docs-folder-view">
|
||||
<div className="docs-folder-toolbar">
|
||||
<div className="docs-folder-path" title={node.key}>
|
||||
<FolderOpenOutlined className="docs-folder-path-icon" />
|
||||
<span className="docs-folder-path-text">{node.key || '根目录'}</span>
|
||||
</div>
|
||||
<span className="docs-folder-counts">
|
||||
{folders.length} 个文件夹 · {files.length} 个文档
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
|
|
@ -1457,6 +1570,104 @@ function DocumentPage() {
|
|||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
{/* Git 同步范围弹窗 */}
|
||||
<Modal
|
||||
title={gitSyncAction === 'push' ? 'Git 推送 - 选择同步范围' : 'Git 拉取 - 选择同步范围'}
|
||||
open={gitSyncModalVisible}
|
||||
onCancel={() => setGitSyncModalVisible(false)}
|
||||
width={460}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setGitSyncModalVisible(false)}>取消</Button>,
|
||||
<Button
|
||||
key="pull"
|
||||
type="primary"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={gitSyncing}
|
||||
disabled={gitSyncAction !== 'pull'}
|
||||
onClick={confirmGitSync}
|
||||
>
|
||||
拉取
|
||||
</Button>,
|
||||
<Button
|
||||
key="push"
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={gitSyncing}
|
||||
disabled={gitSyncAction !== 'push'}
|
||||
onClick={confirmGitSync}
|
||||
>
|
||||
推送
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{gitRepos.length > 1 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>Git 仓库</div>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={gitSyncRepoId}
|
||||
onChange={(id) => {
|
||||
setGitSyncRepoId(id)
|
||||
const repo = gitRepos.find(r => r.id === id)
|
||||
// 切换仓库时重新加载该仓库的远端目录结构
|
||||
loadGitRemoteDirs(id)
|
||||
setGitSyncScope(repo?.sync_path ? 'dir' : 'all')
|
||||
setGitSyncSubPath(repo?.sync_path || '')
|
||||
}}
|
||||
options={gitRepos.map(r => ({
|
||||
value: r.id,
|
||||
label: r.name + (r.is_default === 1 ? '(默认)' : '') + (r.sync_path ? ' · ' + r.sync_path : ''),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>同步范围</div>
|
||||
<Radio.Group
|
||||
value={gitSyncScope}
|
||||
onChange={(e) => setGitSyncScope(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="all">整个仓库</Radio.Button>
|
||||
<Radio.Button value="dir">选择目录</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{gitSyncScope === 'dir' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-color-secondary)', fontSize: 13 }}>
|
||||
仅将所选目录与远程仓库同步(其他目录不受影响)
|
||||
</div>
|
||||
<TreeSelect
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择要同步的项目目录"
|
||||
value={gitSyncSubPath || undefined}
|
||||
onChange={setGitSyncSubPath}
|
||||
treeData={gitRemoteDirOptions.length ? gitRemoteDirOptions : gitDirTreeData}
|
||||
fieldNames={{ label: 'title', value: 'value', children: 'children' }}
|
||||
treeDefaultExpandAll
|
||||
showSearch
|
||||
allowClear
|
||||
filterTreeNode={(inputValue, treeNode) =>
|
||||
String(treeNode.title || '').toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gitSyncAction === 'push' && gitSyncScope === 'dir' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="目录级推送仅提交并推送所选目录的变更,不支持强制推送;若提示历史不一致,请先执行「整个仓库」拉取后再推送。"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,22 @@ body.dark .project-card-title-icon.is-owner {
|
|||
box-shadow: 0 3px 8px rgba(82, 196, 26, 0.25);
|
||||
}
|
||||
|
||||
/* 卡片操作栏 Git 仓库图标:未配置 = 灰色分支图标;已配置 = 实心 GitHub 图标(品牌色区分) */
|
||||
.project-card-git-action-icon {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 16px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.project-card-git-action-icon.configured {
|
||||
color: #24292e;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
body.dark .project-card-git-action-icon.configured {
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.project-card h3 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
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, Progress, Alert, List, Spin, Tooltip } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
|
||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip, TreeSelect } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, BranchesOutlined, GithubOutlined, GithubFilled, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, getGitRepoDirectories, transferProject } from '@/api/project'
|
||||
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
||||
import { getProjectTree } from '@/api/file'
|
||||
import { getUserList } from '@/api/users'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import { getUnreadByProject, markProjectNotificationsRead } from '@/api/notification'
|
||||
|
|
@ -326,8 +327,44 @@ function ProjectList({ type = 'my' }) {
|
|||
const [loadingRepos, setLoadingRepos] = useState(false)
|
||||
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
|
||||
const [editingRepo, setEditingRepo] = useState(null)
|
||||
const [gitDirOptions, setGitDirOptions] = useState([])
|
||||
const [repoForm] = Form.useForm()
|
||||
|
||||
// 构建本地项目目录树选项(仅文件夹,回退用)
|
||||
const buildGitDirOptions = (nodes, prefix = '') => {
|
||||
const options = []
|
||||
for (const node of nodes || []) {
|
||||
if (!node.isLeaf) {
|
||||
options.push({
|
||||
title: `${prefix}${node.title}`,
|
||||
value: node.key,
|
||||
children: node.children ? buildGitDirOptions(node.children, `${prefix}${node.title}/`) : [],
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// 先加载本地目录,避免打开配置时因远端网络请求卡住界面。
|
||||
const loadLocalGitDirOptions = (projectId) => {
|
||||
return getProjectTree(projectId)
|
||||
.then((res) => {
|
||||
const tree = res.data?.tree || res.data || []
|
||||
setGitDirOptions(buildGitDirOptions(tree))
|
||||
})
|
||||
.catch(() => setGitDirOptions([]))
|
||||
}
|
||||
|
||||
// 只有编辑同步目录时才按需查询远端目录;失败时保留本地目录选项。
|
||||
const loadRemoteGitDirOptions = (projectId, repoId = null) => {
|
||||
getGitRepoDirectories(projectId, repoId)
|
||||
.then((res) => {
|
||||
const opts = res.data?.directories || []
|
||||
if (opts.length) setGitDirOptions(opts)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 打开Git设置(仓库列表)
|
||||
const handleGitSettings = (e, project) => {
|
||||
e.stopPropagation()
|
||||
|
|
@ -361,11 +398,15 @@ function ProjectList({ type = 'my' }) {
|
|||
const handleAddRepo = () => {
|
||||
setEditingRepo(null)
|
||||
repoForm.resetFields()
|
||||
setGitDirOptions([])
|
||||
// 如果是第一个仓库,默认设为默认
|
||||
if (gitRepos.length === 0) {
|
||||
repoForm.setFieldsValue({ is_default: 1 })
|
||||
}
|
||||
setGitRepoModalVisible(true)
|
||||
if (currentProject) {
|
||||
loadLocalGitDirOptions(currentProject.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑仓库弹窗
|
||||
|
|
@ -374,8 +415,14 @@ function ProjectList({ type = 'my' }) {
|
|||
repoForm.setFieldsValue({
|
||||
...repo,
|
||||
is_default: repo.is_default === 1,
|
||||
sync_path: repo.sync_path || undefined,
|
||||
})
|
||||
setGitRepoModalVisible(true)
|
||||
// 先用本地目录立即渲染,再后台按需加载远端目录结构。
|
||||
if (currentProject) {
|
||||
loadLocalGitDirOptions(currentProject.id)
|
||||
.then(() => loadRemoteGitDirOptions(currentProject.id, repo.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 删除仓库
|
||||
|
|
@ -402,6 +449,7 @@ function ProjectList({ type = 'my' }) {
|
|||
const data = {
|
||||
...values,
|
||||
is_default: values.is_default ? 1 : 0,
|
||||
sync_path: values.sync_path || '',
|
||||
}
|
||||
|
||||
if (editingRepo) {
|
||||
|
|
@ -719,7 +767,19 @@ function ProjectList({ type = 'my' }) {
|
|||
onClick={() => handleOpenProject(project.id)}
|
||||
actions={type === 'my' ? [
|
||||
<Tooltip key="settings" title="项目设置"><SettingOutlined onClick={(e) => handleEdit(e, project)} /></Tooltip>,
|
||||
<Tooltip key="git" title="Git 仓库"><GithubOutlined onClick={(e) => handleGitSettings(e, project)} /></Tooltip>,
|
||||
<Tooltip key="git" title={(project.git_repo_count || 0) > 0 ? 'Git 仓库(已配置)' : 'Git 仓库'}>
|
||||
{(project.git_repo_count || 0) > 0 ? (
|
||||
<GithubFilled
|
||||
className="project-card-git-action-icon configured"
|
||||
onClick={(e) => handleGitSettings(e, project)}
|
||||
/>
|
||||
) : (
|
||||
<BranchesOutlined
|
||||
className="project-card-git-action-icon"
|
||||
onClick={(e) => handleGitSettings(e, project)}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>,
|
||||
<Tooltip key="kb" title="知识库向量化"><DatabaseOutlined onClick={(e) => handleKnowledge(e, project)} /></Tooltip>,
|
||||
<Tooltip key="members" title="成员管理"><TeamOutlined onClick={(e) => handleMembers(e, project)} /></Tooltip>,
|
||||
] : [
|
||||
|
|
@ -1199,6 +1259,17 @@ function ProjectList({ type = 'my' }) {
|
|||
key: 'branch',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '同步范围',
|
||||
dataIndex: 'sync_path',
|
||||
key: 'sync_path',
|
||||
width: 150,
|
||||
render: (v) => (
|
||||
v
|
||||
? <Tag color="geekblue" style={{ maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis' }} title={v}>{v}</Tag>
|
||||
: <span style={{ color: 'var(--text-color-secondary)' }}>整个仓库</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
|
|
@ -1269,11 +1340,30 @@ function ProjectList({ type = 'my' }) {
|
|||
<Input.Password placeholder="Git访问令牌" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="同步目录"
|
||||
name="sync_path"
|
||||
extra="留空表示同步整个仓库;选择目录后,拉取/推送仅作用于该目录"
|
||||
>
|
||||
<TreeSelect
|
||||
placeholder="整个仓库(留空)"
|
||||
allowClear
|
||||
showSearch
|
||||
treeDefaultExpandAll
|
||||
treeData={gitDirOptions}
|
||||
fieldNames={{ label: 'title', value: 'value', children: 'children' }}
|
||||
filterTreeNode={(inputValue, treeNode) =>
|
||||
String(treeNode.title || '').toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="is_default"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="默认仓库" unCheckedChildren="非默认" />
|
||||
<Switch checkedChildren="默认" unCheckedChildren="非默认" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
|
|
|
|||
Loading…
Reference in New Issue