v0.9.9-SP1

main
mula.liu 2026-08-24 12:18:01 +08:00
parent f92fff6546
commit d6ebd62b7c
13 changed files with 1075 additions and 303 deletions

View File

@ -18,6 +18,13 @@ from app.core.enums import OperationType
router = APIRouter() 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) @router.get("/projects/{project_id}/git-repos", response_model=dict)
async def get_project_git_repos( async def get_project_git_repos(
project_id: int, project_id: int,
@ -35,9 +42,7 @@ async def get_project_git_repos(
# 隐藏 token # 隐藏 token
data = [] data = []
for repo in repos: for repo in repos:
repo_dict = GitRepoResponse.from_orm(repo).dict() data.append(_repo_response(repo))
# repo_dict['token'] = '******' if repo.token else None # 前端可能需要回显或者判断是否有token
data.append(repo_dict)
return success_response(data=data) return success_response(data=data)
@ -80,13 +85,14 @@ async def create_git_repo(
branch=repo_in.branch, branch=repo_in.branch,
username=repo_in.username, username=repo_in.username,
token=repo_in.token, token=repo_in.token,
is_default=is_default is_default=is_default,
sync_path=repo_in.sync_path or ""
) )
db.add(db_repo) db.add(db_repo)
await db.commit() await db.commit()
await db.refresh(db_repo) 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) @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) 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(): for field, value in update_data.items():
setattr(repo, field, value) setattr(repo, field, value)
await db.commit() await db.commit()
await db.refresh(repo) 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) @router.delete("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)

View File

@ -1,6 +1,8 @@
""" """
项目管理相关 API 项目管理相关 API
""" """
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, or_, func from sqlalchemy import delete, select, or_, func
@ -100,6 +102,18 @@ async def attach_member_counts(db: AsyncSession, projects) -> dict:
return dict(result.all()) 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) @router.get("/", response_model=dict)
async def get_my_projects( async def get_my_projects(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@ -127,6 +141,7 @@ async def get_my_projects(
# 合并结果 # 合并结果
all_projects = owned_projects + member_projects all_projects = owned_projects + member_projects
member_counts = await attach_member_counts(db, all_projects) member_counts = await attach_member_counts(db, all_projects)
git_repo_counts = await attach_git_repo_counts(db, all_projects)
projects_data = [] projects_data = []
for p in all_projects: for p in all_projects:
p_dict = ProjectResponse.from_orm(p).dict() p_dict = ProjectResponse.from_orm(p).dict()
@ -134,6 +149,7 @@ async def get_my_projects(
p_dict['doc_count'] = doc_count p_dict['doc_count'] = doc_count
p_dict['last_activity_at'] = last_activity_at p_dict['last_activity_at'] = last_activity_at
p_dict['member_count'] = member_counts.get(p.id, 0) 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) projects_data.append(p_dict)
return success_response(data=projects_data) return success_response(data=projects_data)
@ -150,6 +166,7 @@ async def get_owned_projects(
) )
projects = result.scalars().all() projects = result.scalars().all()
member_counts = await attach_member_counts(db, projects) member_counts = await attach_member_counts(db, projects)
git_repo_counts = await attach_git_repo_counts(db, projects)
projects_data = [] projects_data = []
for p in projects: for p in projects:
p_dict = ProjectResponse.from_orm(p).dict() p_dict = ProjectResponse.from_orm(p).dict()
@ -157,6 +174,7 @@ async def get_owned_projects(
p_dict['doc_count'] = doc_count p_dict['doc_count'] = doc_count
p_dict['last_activity_at'] = last_activity_at p_dict['last_activity_at'] = last_activity_at
p_dict['member_count'] = member_counts.get(p.id, 0) 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) projects_data.append(p_dict)
return success_response(data=projects_data) return success_response(data=projects_data)
@ -181,6 +199,7 @@ async def get_shared_projects(
projects = [project for project, _, _ in projects_with_info] projects = [project for project, _, _ in projects_with_info]
member_counts = await attach_member_counts(db, projects) member_counts = await attach_member_counts(db, projects)
git_repo_counts = await attach_git_repo_counts(db, projects)
projects_data = [] projects_data = []
for project, owner, member in projects_with_info: 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['doc_count'] = doc_count
project_dict['last_activity_at'] = last_activity_at project_dict['last_activity_at'] = last_activity_at
project_dict['member_count'] = member_counts.get(project.id, 0) 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) projects_data.append(project_dict)
return success_response(data=projects_data) return success_response(data=projects_data)
@ -710,16 +730,34 @@ async def remove_project_member(
return success_response(message="成员删除成功") 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) @router.post("/{project_id}/git/pull", response_model=dict)
async def git_pull( async def git_pull(
project_id: int, project_id: int,
request: Request, request: Request,
repo_id: int = None, repo_id: int = None,
force: bool = False, force: bool = False,
sync_scope: str = None,
sub_path: str = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""执行 Git Pull""" """执行 Git Pullsub_path 非空时仅同步指定目录)"""
# 查询项目 # 查询项目
result = await db.execute(select(Project).where(Project.id == project_id)) result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
@ -755,16 +793,25 @@ async def git_pull(
target_repo = repos[0] 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) project_path = storage_service.get_secure_path(project.storage_key)
success, msg = await git_service.pull( try:
project_path=project_path, success, msg = await git_service.pull(
repo_url=target_repo.repo_url, project_path=project_path,
branch=target_repo.branch or "main", repo_url=target_repo.repo_url,
username=target_repo.username, branch=target_repo.branch or "main",
token=target_repo.token, username=target_repo.username,
force=force 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: if not success:
raise HTTPException(status_code=500, detail=f"Git Pull失败: {msg}") raise HTTPException(status_code=500, detail=f"Git Pull失败: {msg}")
@ -775,17 +822,18 @@ async def git_pull(
operation_type=OperationType.GIT_PULL, operation_type=OperationType.GIT_PULL,
project_id=project_id, project_id=project_id,
user=current_user, 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, request=request,
) )
# 发送通知给其他成员 # 发送通知给其他成员
scope_desc = f",同步目录 [{effective_sub_path}]" if effective_sub_path else ""
await notification_service.notify_project_members( await notification_service.notify_project_members(
db=db, db=db,
project_id=project_id, project_id=project_id,
exclude_user_id=current_user.id, exclude_user_id=current_user.id,
title=f"项目文档已通过 Git 同步", 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", link=f"/projects/{project_id}/docs",
category="project" category="project"
) )
@ -800,10 +848,12 @@ async def git_push(
request: Request, request: Request,
repo_id: int = None, repo_id: int = None,
force: bool = False, force: bool = False,
sync_scope: str = None,
sub_path: str = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""执行 Git Push""" """执行 Git Pushsub_path 非空时仅推送指定目录的变更)"""
# 查询项目 # 查询项目
result = await db.execute(select(Project).where(Project.id == project_id)) result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
@ -839,16 +889,25 @@ async def git_push(
target_repo = repos[0] 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) project_path = storage_service.get_secure_path(project.storage_key)
success, msg = await git_service.push( try:
project_path=project_path, success, msg = await git_service.push(
repo_url=target_repo.repo_url, project_path=project_path,
branch=target_repo.branch or "main", repo_url=target_repo.repo_url,
username=target_repo.username, branch=target_repo.branch or "main",
token=target_repo.token, username=target_repo.username,
force=force 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: if not success:
raise HTTPException(status_code=500, detail=f"Git Push失败: {msg}") raise HTTPException(status_code=500, detail=f"Git Push失败: {msg}")
@ -859,8 +918,91 @@ async def git_push(
operation_type=OperationType.GIT_PUSH, operation_type=OperationType.GIT_PUSH,
project_id=project_id, project_id=project_id,
user=current_user, 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, request=request,
) )
return success_response(message=f"Git Push 成功 ({target_repo.name})") 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})

View File

@ -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: async def _column_exists(conn, table_name: str, column_name: str) -> bool:
result = await conn.execute( result = await conn.execute(
@ -57,7 +68,7 @@ async def migrate_schema() -> None:
added = [] added = []
async with engine.begin() as conn: 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: try:
exists = await _column_exists(conn, table_name, column_name) exists = await _column_exists(conn, table_name, column_name)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001

View File

@ -20,6 +20,7 @@ class ProjectGitRepo(Base):
username = Column(String(100), comment="Git用户名") username = Column(String(100), comment="Git用户名")
token = Column(String(255), comment="Git访问令牌/密码") token = Column(String(255), comment="Git访问令牌/密码")
is_default = Column(SmallInteger, default=0, comment="是否默认仓库") is_default = Column(SmallInteger, default=0, comment="是否默认仓库")
sync_path = Column(String(255), default="", comment="同步目录(空=整个仓库)")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间") created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")

View File

@ -13,6 +13,7 @@ class GitRepoBase(BaseModel):
username: Optional[str] = Field(None, description="Git用户名") username: Optional[str] = Field(None, description="Git用户名")
token: Optional[str] = Field(None, description="Git访问令牌") token: Optional[str] = Field(None, description="Git访问令牌")
is_default: int = Field(0, description="是否默认仓库") is_default: int = Field(0, description="是否默认仓库")
sync_path: str = Field("", max_length=255, description="同步目录(空=整个仓库)")
class GitRepoCreate(GitRepoBase): class GitRepoCreate(GitRepoBase):
@ -28,6 +29,7 @@ class GitRepoUpdate(BaseModel):
username: Optional[str] = None username: Optional[str] = None
token: Optional[str] = None token: Optional[str] = None
is_default: Optional[int] = None is_default: Optional[int] = None
sync_path: Optional[str] = None
class GitRepoResponse(GitRepoBase): class GitRepoResponse(GitRepoBase):
@ -36,6 +38,7 @@ class GitRepoResponse(GitRepoBase):
project_id: int project_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
has_token: bool = False
class Config: class Config:
from_attributes = True from_attributes = True

View File

@ -1,128 +1,286 @@
import subprocess import base64
import asyncio
import os import os
import shutil
import subprocess
import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Tuple
from urllib.parse import quote_plus from urllib.parse import urlsplit, urlunsplit
class GitService: class GitService:
"""Run Git synchronization without persisting repository credentials."""
def _get_auth_url(self, repo_url: str, username: str = None, token: str = None) -> str: def _clean_repo_url(self, repo_url: str) -> str:
""" """Remove any user info already embedded in an HTTP(S) URL."""
Constructs a URL with authentication credentials. parsed = urlsplit(repo_url)
Note: This is sensitive, so be careful not to log this URL. if parsed.scheme not in ("http", "https"):
"""
if not username or not token:
return repo_url 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))
# Encode credentials to handle special characters (e.g. @, :, /) def _auth_env(self, username: str = None, token: str = None) -> dict:
safe_username = quote_plus(username) """Pass HTTPS Basic credentials to Git for this process only."""
safe_token = quote_plus(token) 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
# Remove scheme if present to insert auth def _run_command(self, cmd: list, cwd: Path, env: dict = None, timeout: int = 60) -> Tuple[bool, str]:
if repo_url.startswith("https://"): """Run a command and return its output without exposing process environment."""
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
def _run_command(self, cmd: list, cwd: Path) -> Tuple[bool, str]:
"""
Runs a shell command in the given directory.
Returns (success, message).
"""
try: 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( result = subprocess.run(
cmd, cmd,
cwd=str(cwd), cwd=str(cwd),
capture_output=True, capture_output=True,
text=True, text=True,
check=False # We handle return code manually encoding="utf-8",
env=run_env,
timeout=timeout,
check=False,
) )
if result.returncode == 0: if result.returncode == 0:
return True, result.stdout 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: else:
return False, f"Command failed: {' '.join(cmd)}\nError: {result.stderr}" target.unlink()
except Exception as e: if source.exists():
return False, str(e) 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): def _pull(
""" self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
Ensures the directory is a git repository and has the correct remote. token: str = None, force: bool = False, sub_path: str = None,
""" ) -> Tuple[bool, str]:
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.
"""
if not project_path.exists(): if not project_path.exists():
return False, "Project path does not exist" return False, "项目目录不存在"
auth_url = self._get_auth_url(repo_url, username, token) sub_path = self._normalize_sub_path(sub_path)
auth_env = self._auth_env(username, token)
# Ensure git init and remote if sub_path:
self._ensure_git_initialized(project_path, auth_url) if force:
return False, "目录级同步不支持强制拉取"
# Fetch first temp_dir, remote_root, message = self._remote_worktree(repo_url, branch, auth_env)
success, msg = self._run_command(["git", "fetch", "origin"], project_path) if not temp_dir:
if not success: return False, f"获取远端仓库失败: {message}"
return False, f"Fetch failed: {msg}" try:
self._replace_path(remote_root, project_path, sub_path)
finally:
temp_dir.cleanup()
return True, f"已从远端同步目录 [{sub_path}]"
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: if force:
# Force Reset to remote return self._run_command(["git", "reset", "--hard", f"origin/{branch}"], project_path)
cmd = ["git", "reset", "--hard", f"origin/{branch}"] return self._run_command(["git", "pull", "--ff-only", "origin", branch], project_path, auth_env)
else:
# Simple pull
cmd = ["git", "pull", "origin", branch]
return self._run_command(cmd, project_path) 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)
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]: def _push(
""" self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
Executes git push. token: str = None, force: bool = False, sub_path: str = None,
""" ) -> Tuple[bool, str]:
if not project_path.exists(): if not project_path.exists():
return False, "Project path does not exist" return False, "项目目录不存在"
auth_url = self._get_auth_url(repo_url, username, token) 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()
# Ensure git init and remote ok, message = self._ensure_git_initialized(project_path, repo_url)
self._ensure_git_initialized(project_path, auth_url) if not ok:
return False, message
# Add all changes ok, message = self._run_command(["git", "fetch", "origin"], project_path, auth_env)
self._run_command(["git", "add", "."], project_path) if not ok:
return False, f"获取远端更新失败: {message}"
# Commit if changes exist if not force:
# Check if there are changes to commit ok, _ = self._run_command(
status_success, status_output = self._run_command(["git", "status", "--porcelain"], project_path) ["git", "merge-base", "--is-ancestor", f"origin/{branch}", "HEAD"], project_path
if status_success and status_output.strip(): )
# Create a commit if not ok:
self._run_command(["git", "commit", "-m", "Update from Nex Docus"], project_path) return False, "远端仓库已有未拉取的更新,请先执行整库拉取后再推送"
ok, message = self._run_command(["git", "add", "."], project_path)
# Push if not ok:
cmd = ["git", "push", "-u", "origin", branch] 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: if force:
cmd.append("--force") 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()
return self._run_command(cmd, project_path)
git_service = GitService() git_service = GitService()

View File

@ -0,0 +1,3 @@
-- 为项目Git仓库表增加“同步目录”字段空=整个仓库)
ALTER TABLE project_git_repos
ADD COLUMN sync_path VARCHAR(255) DEFAULT '' COMMENT '同步目录(空=整个仓库)' AFTER is_default;

View File

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

View File

@ -152,22 +152,22 @@ export function removeProjectMember(projectId, userId) {
/** /**
* Git Pull * Git Pull
*/ */
export function gitPull(projectId, repoId = null, force = false) { export function gitPull(projectId, repoId = null, force = false, syncScope = null, subPath = '') {
return request({ return request({
url: `/projects/${projectId}/git/pull`, url: `/projects/${projectId}/git/pull`,
method: 'post', method: 'post',
params: { repo_id: repoId, force } params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined }
}) })
} }
/** /**
* Git Push * Git Push
*/ */
export function gitPush(projectId, repoId = null, force = false) { export function gitPush(projectId, repoId = null, force = false, syncScope = null, subPath = '') {
return request({ return request({
url: `/projects/${projectId}/git/push`, url: `/projects/${projectId}/git/push`,
method: 'post', 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', method: 'delete',
}) })
} }
/**
* 获取Git仓库远端目录结构用于选择同步目录
*/
export function getGitRepoDirectories(projectId, repoId = null) {
return request({
url: `/projects/${projectId}/git/directories`,
method: 'post',
params: { repo_id: repoId || undefined },
})
}

View File

@ -192,6 +192,78 @@
gap: 16px; 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 { .docs-header-title {
display: flex; display: flex;
align-items: center; align-items: center;
@ -285,44 +357,11 @@
padding: 4px 0 24px; 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 { .docs-folder-counts {
flex: none; flex: none;
color: var(--text-color-secondary);
font-size: 14px;
font-weight: 400;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, 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 { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@ -12,7 +12,7 @@ import Highlighter from 'react-highlight-words'
import Mark from 'mark.js' import Mark from 'mark.js'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file' import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
import { gitPull, gitPush, getGitRepos } from '@/api/project' import { gitPull, gitPush, getGitRepos, getGitRepoDirectories } from '@/api/project'
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share' import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share'
import { searchDocuments } from '@/api/search' import { searchDocuments } from '@/api/search'
import { markProjectNotificationsRead } from '@/api/notification' import { markProjectNotificationsRead } from '@/api/notification'
@ -26,6 +26,7 @@ import './DocumentPage.css'
const { Sider, Content } = Layout const { Sider, Content } = Layout
const MAX_TOC_ITEMS = 500 const MAX_TOC_ITEMS = 500
const ROOT_FOLDER_KEY = '__root__'
// //
const HighlightText = ({ text, keyword }) => { const HighlightText = ({ text, keyword }) => {
@ -60,6 +61,14 @@ function DocumentPage() {
const [pdfFilename, setPdfFilename] = useState('') const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown') const [viewMode, setViewMode] = useState('markdown')
const [gitRepos, setGitRepos] = useState([]) 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 [projectName, setProjectName] = useState('')
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
@ -77,21 +86,6 @@ function DocumentPage() {
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const isLargeMarkdown = isLargeMarkdownContent(markdownContent) 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) => { const navigateWithTransition = (to) => {
if (document.startViewTransition) { if (document.startViewTransition) {
document.startViewTransition(() => navigate(to)) 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 // + md/pdf
const getFolderChildren = (folderPath) => { 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 } if (!node || node.isLeaf) return { folders: [], files: [], node }
const children = node.children || [] const children = node.children || []
const folders = children.filter(c => !c.isLeaf) const folders = children.filter(c => !c.isLeaf)
@ -160,6 +172,91 @@ function DocumentPage() {
return { folders, files, node } 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 } = {}) => { const openDocumentPath = (filePath, { syncUrl = false } = {}) => {
setSelectedFile(filePath) setSelectedFile(filePath)
setSelectedNodeKey(filePath) setSelectedNodeKey(filePath)
@ -196,6 +293,7 @@ function DocumentPage() {
useEffect(() => { useEffect(() => {
loadFileTree() loadFileTree()
loadGitRepos()
}, [projectId]) }, [projectId])
// //
@ -246,6 +344,12 @@ function DocumentPage() {
// //
if (selectedParam) { if (selectedParam) {
if (selectedParam === ROOT_FOLDER_KEY) {
if (selectedNodeKey !== ROOT_FOLDER_KEY) {
selectRootFolder({ syncUrl: false })
}
return
}
const targetNode = findNodeByKey(fileTree, selectedParam) const targetNode = findNodeByKey(fileTree, selectedParam)
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) { if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) {
selectFolder(selectedParam) 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) { if (gitRepos.length === 0) {
message.warning('未配置Git仓库') message.warning('未配置Git仓库')
return return false
} }
try { try {
const res = await gitPull(projectId, repoId, force) const res = await gitPull(projectId, repoId, force, syncScope, subPath)
message.success(res.message || 'Git Pull 成功') message.success(res.message || 'Git Pull 成功')
// Refresh tree // Refresh tree
loadFileTree() loadFileTree()
@ -751,11 +855,13 @@ function DocumentPage() {
if (selectedFile) { if (selectedFile) {
loadMarkdown(selectedFile) loadMarkdown(selectedFile)
} }
return true
} catch (error) { } catch (error) {
console.error('Git Pull error:', error) console.error('Git Pull error:', error)
const errorMsg = error.response?.data?.detail || 'Git Pull 失败' const errorMsg = error.response?.data?.detail || 'Git Pull 失败'
if (!force) { //
if (!force && !subPath) {
Modal.confirm({ Modal.confirm({
title: 'Git Pull 失败', title: 'Git Pull 失败',
content: ( content: (
@ -772,28 +878,30 @@ function DocumentPage() {
okText: '强制重置', okText: '强制重置',
okType: 'danger', okType: 'danger',
cancelText: '取消', cancelText: '取消',
onOk: () => handleGitPull(repoId, true) onOk: () => handleGitPull(repoId, true, 'all')
}) })
return return false
} }
message.error(errorMsg) 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) { if (gitRepos.length === 0) {
message.warning('未配置Git仓库') message.warning('未配置Git仓库')
return return false
} }
try { try {
const res = await gitPush(projectId, repoId, force) const res = await gitPush(projectId, repoId, force, syncScope, subPath)
message.success(res.message || 'Git Push 成功') message.success(res.message || 'Git Push 成功')
return true
} catch (error) { } catch (error) {
console.error('Git Push error:', error) console.error('Git Push error:', error)
const errorMsg = error.response?.data?.detail || 'Git Push 失败' const errorMsg = error.response?.data?.detail || 'Git Push 失败'
if (!force) { if (!force && !subPath) {
Modal.confirm({ Modal.confirm({
title: 'Git Push 失败', title: 'Git Push 失败',
content: ( content: (
@ -810,89 +918,117 @@ function DocumentPage() {
okText: '强制推送', okText: '强制推送',
okType: 'danger', okType: 'danger',
cancelText: '取消', cancelText: '取消',
onOk: () => handleGitPush(repoId, true) onOk: () => handleGitPush(repoId, true, 'all')
}) })
return return false
} }
message.error(errorMsg) 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 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) { if (gitRepos.length <= 1) {
// 0 1 // 0 1
return ( return (
<> <>
<Tooltip title="Git Pull"> {pullButton}
<Button {pushButton}
size="middle"
icon={<CloudDownloadOutlined />}
onClick={() => handleGitPull()}
/>
</Tooltip>
<Tooltip title="Git Push">
<Button
size="middle"
icon={<CloudUploadOutlined />}
onClick={() => handleGitPush()}
/>
</Tooltip>
</> </>
) )
} }
// //
const pullItems = gitRepos.map(repo => ({ const pullItems = gitRepos.map(repo => ({
key: repo.id, key: repo.id,
label: repo.name + (repo.is_default ? ' (默认)' : ''), label: repo.name + (repo.is_default ? ' (默认)' : ''),
onClick: () => handleGitPull(repo.id), onClick: () => openGitSyncModal('pull', repo.id),
})) }))
const pushItems = gitRepos.map(repo => ({ const pushItems = gitRepos.map(repo => ({
key: repo.id, key: repo.id,
label: repo.name + (repo.is_default ? ' (默认)' : ''), 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 ( return (
<> <>
<Dropdown menu={{ items: pullItems }}> <Dropdown menu={{ items: pullItems }}>
<Tooltip title="Git Pull"> {pullButton}
<Button
size="middle"
icon={<CloudDownloadOutlined />}
/>
</Tooltip>
</Dropdown> </Dropdown>
<Dropdown menu={{ items: pushItems }}> <Dropdown menu={{ items: pushItems }}>
<Tooltip title="Git Push"> {pushButton}
<Button
size="middle"
icon={<CloudUploadOutlined />}
/>
</Tooltip>
</Dropdown> </Dropdown>
</> </>
) )
@ -1159,7 +1295,6 @@ function DocumentPage() {
> >
<ArrowLeftOutlined /> <ArrowLeftOutlined />
</button> </button>
<h2 title={projectName}>{projectName}</h2>
</div> </div>
<div className="docs-sider-actions"> <div className="docs-sider-actions">
<div className="mode-actions-row"> <div className="mode-actions-row">
@ -1236,39 +1371,27 @@ function DocumentPage() {
{/* 右侧内容区 */} {/* 右侧内容区 */}
<Layout className="docs-content-layout"> <Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}> <Content className="docs-content" ref={contentRef}>
<div className="docs-content-header" title={selectedFile || 'README.md'}> <div className="docs-content-header">
{(() => { {renderContentBreadcrumb()}
const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile) {viewMode === 'pdf' && <div className="docs-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
return ( {viewMode === 'markdown' && (
<> <Space className="docs-header-actions">
<div className="docs-header-title"> <Button
<span className="docs-header-item"> icon={<VerticalAlignTopOutlined />}
<FileIcon className="docs-header-icon" style={isPdf ? { color: '#f5222d' } : undefined} /> onClick={scrollContentToTop}
<span className="docs-header-text">{fileName}</span> size="small"
</span> >
</div> 回到顶部
{viewMode === 'pdf' && <div className="docs-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />} </Button>
{viewMode === 'markdown' && ( <Button
<Space className="docs-header-actions"> icon={<CloudDownloadOutlined />}
<Button onClick={handleExportMarkdownPDF}
icon={<VerticalAlignTopOutlined />} size="small"
onClick={scrollContentToTop} >
size="small" 下载PDF
> </Button>
回到顶部 </Space>
</Button> )}
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportMarkdownPDF}
size="small"
>
下载PDF
</Button>
</Space>
)}
</>
)
})()}
</div> </div>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}> <div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
{loading ? ( {loading ? (
@ -1297,16 +1420,6 @@ function DocumentPage() {
const total = folders.length + files.length const total = folders.length + files.length
return ( return (
<div className="docs-folder-view"> <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 ? ( {total === 0 ? (
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
@ -1457,6 +1570,104 @@ function DocumentPage() {
)} )}
</Space> </Space>
</Modal> </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> </div>
) )
} }

View File

@ -137,6 +137,22 @@ body.dark .project-card-title-icon.is-owner {
box-shadow: 0 3px 8px rgba(82, 196, 26, 0.25); 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 { .project-card h3 {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

@ -1,9 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip } from 'antd' 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, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } from '@ant-design/icons' 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, transferProject } from '@/api/project' 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 { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
import { getProjectTree } from '@/api/file'
import { getUserList } from '@/api/users' import { getUserList } from '@/api/users'
import { searchDocuments } from '@/api/search' import { searchDocuments } from '@/api/search'
import { getUnreadByProject, markProjectNotificationsRead } from '@/api/notification' import { getUnreadByProject, markProjectNotificationsRead } from '@/api/notification'
@ -326,8 +327,44 @@ function ProjectList({ type = 'my' }) {
const [loadingRepos, setLoadingRepos] = useState(false) const [loadingRepos, setLoadingRepos] = useState(false)
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false) const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
const [editingRepo, setEditingRepo] = useState(null) const [editingRepo, setEditingRepo] = useState(null)
const [gitDirOptions, setGitDirOptions] = useState([])
const [repoForm] = Form.useForm() 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 // Git
const handleGitSettings = (e, project) => { const handleGitSettings = (e, project) => {
e.stopPropagation() e.stopPropagation()
@ -361,11 +398,15 @@ function ProjectList({ type = 'my' }) {
const handleAddRepo = () => { const handleAddRepo = () => {
setEditingRepo(null) setEditingRepo(null)
repoForm.resetFields() repoForm.resetFields()
setGitDirOptions([])
// //
if (gitRepos.length === 0) { if (gitRepos.length === 0) {
repoForm.setFieldsValue({ is_default: 1 }) repoForm.setFieldsValue({ is_default: 1 })
} }
setGitRepoModalVisible(true) setGitRepoModalVisible(true)
if (currentProject) {
loadLocalGitDirOptions(currentProject.id)
}
} }
// //
@ -374,8 +415,14 @@ function ProjectList({ type = 'my' }) {
repoForm.setFieldsValue({ repoForm.setFieldsValue({
...repo, ...repo,
is_default: repo.is_default === 1, is_default: repo.is_default === 1,
sync_path: repo.sync_path || undefined,
}) })
setGitRepoModalVisible(true) setGitRepoModalVisible(true)
//
if (currentProject) {
loadLocalGitDirOptions(currentProject.id)
.then(() => loadRemoteGitDirOptions(currentProject.id, repo.id))
}
} }
// //
@ -402,6 +449,7 @@ function ProjectList({ type = 'my' }) {
const data = { const data = {
...values, ...values,
is_default: values.is_default ? 1 : 0, is_default: values.is_default ? 1 : 0,
sync_path: values.sync_path || '',
} }
if (editingRepo) { if (editingRepo) {
@ -719,7 +767,19 @@ function ProjectList({ type = 'my' }) {
onClick={() => handleOpenProject(project.id)} onClick={() => handleOpenProject(project.id)}
actions={type === 'my' ? [ actions={type === 'my' ? [
<Tooltip key="settings" title="项目设置"><SettingOutlined onClick={(e) => handleEdit(e, project)} /></Tooltip>, <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="kb" title="知识库向量化"><DatabaseOutlined onClick={(e) => handleKnowledge(e, project)} /></Tooltip>,
<Tooltip key="members" title="成员管理"><TeamOutlined onClick={(e) => handleMembers(e, project)} /></Tooltip>, <Tooltip key="members" title="成员管理"><TeamOutlined onClick={(e) => handleMembers(e, project)} /></Tooltip>,
] : [ ] : [
@ -1199,6 +1259,17 @@ function ProjectList({ type = 'my' }) {
key: 'branch', key: 'branch',
width: 100, 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: '操作', title: '操作',
key: 'action', key: 'action',
@ -1269,11 +1340,30 @@ function ProjectList({ type = 'my' }) {
<Input.Password placeholder="Git访问令牌" /> <Input.Password placeholder="Git访问令牌" />
</Form.Item> </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 <Form.Item
name="is_default" name="is_default"
valuePropName="checked" valuePropName="checked"
> >
<Switch checkedChildren="默认仓库" unCheckedChildren="非默认" /> <Switch checkedChildren="默认" unCheckedChildren="非默认" />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>