Compare commits

...

2 Commits

Author SHA1 Message Date
mula.liu 22f2ce5543 Merge branch 'main' of http://git.unissense.tech/mula/nex_docus 2026-08-24 14:28:14 +08:00
mula.liu d6ebd62b7c v0.9.9-SP1 2026-08-24 12:18:01 +08:00
13 changed files with 1075 additions and 303 deletions

View File

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

View File

@ -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 Pullsub_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 Pushsub_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})

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

View File

@ -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="更新时间")

View File

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

View File

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

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
*/
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 },
})
}

View File

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

View File

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

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);
}
/* 卡片操作栏 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;

View File

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