Compare commits

..

No commits in common. "22f2ce554364a97b81065b65780683deddeeb875" and "e371fb8e975a7ba7f01896e30c9eeef36920f219" have entirely different histories.

13 changed files with 301 additions and 1073 deletions

View File

@ -18,13 +18,6 @@ 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,
@ -42,7 +35,9 @@ async def get_project_git_repos(
# 隐藏 token
data = []
for repo in repos:
data.append(_repo_response(repo))
repo_dict = GitRepoResponse.from_orm(repo).dict()
# repo_dict['token'] = '******' if repo.token else None # 前端可能需要回显或者判断是否有token
data.append(repo_dict)
return success_response(data=data)
@ -85,14 +80,13 @@ async def create_git_repo(
branch=repo_in.branch,
username=repo_in.username,
token=repo_in.token,
is_default=is_default,
sync_path=repo_in.sync_path or ""
is_default=is_default
)
db.add(db_repo)
await db.commit()
await db.refresh(db_repo)
return success_response(data=_repo_response(db_repo), message="Git仓库添加成功")
return success_response(data=GitRepoResponse.from_orm(db_repo).dict(), message="Git仓库添加成功")
@router.put("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)
@ -127,16 +121,13 @@ 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=_repo_response(repo), message="更新成功")
return success_response(data=GitRepoResponse.from_orm(repo).dict(), message="更新成功")
@router.delete("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)

View File

@ -1,8 +1,6 @@
"""
项目管理相关 API
"""
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, or_, func
@ -102,18 +100,6 @@ 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),
@ -141,7 +127,6 @@ 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()
@ -149,7 +134,6 @@ 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)
@ -166,7 +150,6 @@ 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()
@ -174,7 +157,6 @@ 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)
@ -199,7 +181,6 @@ 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:
@ -211,7 +192,6 @@ 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)
@ -730,34 +710,16 @@ 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(sub_path 非空时仅同步指定目录)"""
"""执行 Git Pull"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
@ -793,25 +755,16 @@ 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)
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
force=force
)
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}")
@ -822,18 +775,17 @@ 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, "sub_path": effective_sub_path or None},
detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force},
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}] 的内容已从远程仓库同步更新{scope_desc}。",
content=f"{current_user.nickname or current_user.username} 执行了 Git Pull,项目 [{project.name}] 的内容已从远程仓库同步更新。",
link=f"/projects/{project_id}/docs",
category="project"
)
@ -848,12 +800,10 @@ 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(sub_path 非空时仅推送指定目录的变更)"""
"""执行 Git Push"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
@ -889,25 +839,16 @@ 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)
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
force=force
)
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}")
@ -918,91 +859,8 @@ 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, "sub_path": effective_sub_path or None},
detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force},
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,17 +34,6 @@ 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(
@ -68,7 +57,7 @@ async def migrate_schema() -> None:
added = []
async with engine.begin() as conn:
for table_name, column_name, ddl in CHAT_MESSAGE_COLUMNS + GIT_REPO_COLUMNS:
for table_name, column_name, ddl in CHAT_MESSAGE_COLUMNS:
try:
exists = await _column_exists(conn, table_name, column_name)
except Exception as exc: # noqa: BLE001

View File

@ -20,7 +20,6 @@ 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,7 +13,6 @@ 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):
@ -29,7 +28,6 @@ class GitRepoUpdate(BaseModel):
username: Optional[str] = None
token: Optional[str] = None
is_default: Optional[int] = None
sync_path: Optional[str] = None
class GitRepoResponse(GitRepoBase):
@ -38,7 +36,6 @@ class GitRepoResponse(GitRepoBase):
project_id: int
created_at: datetime
updated_at: datetime
has_token: bool = False
class Config:
from_attributes = True

View File

@ -1,286 +1,128 @@
import base64
import asyncio
import os
import shutil
import subprocess
import tempfile
import os
from pathlib import Path
from typing import Tuple
from urllib.parse import urlsplit, urlunsplit
from typing import Optional, Tuple
from urllib.parse import quote_plus
class GitService:
"""Run Git synchronization without persisting repository credentials."""
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"):
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
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
# Encode credentials to handle special characters (e.g. @, :, /)
safe_username = quote_plus(username)
safe_token = quote_plus(token)
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."""
# 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
def _run_command(self, cmd: list, cwd: Path) -> Tuple[bool, str]:
"""
Runs a shell command in the given directory.
Returns (success, message).
"""
try:
run_env = os.environ.copy()
run_env.update({"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"})
if env:
run_env.update(env)
# git operations might take time
result = subprocess.run(
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
encoding="utf-8",
env=run_env,
timeout=timeout,
check=False,
check=False # We handle return code manually
)
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, ""
return False, f"Command failed: {' '.join(cmd)}\nError: {result.stderr}"
except Exception as e:
return False, str(e)
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)
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:
target.unlink()
if source.exists():
target.parent.mkdir(parents=True, exist_ok=True)
if source.is_dir():
shutil.copytree(source, target)
# 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():
return False, "Project path does not exist"
auth_url = self._get_auth_url(repo_url, username, token)
# Ensure git init and remote
self._ensure_git_initialized(project_path, auth_url)
# Fetch first
success, msg = self._run_command(["git", "fetch", "origin"], project_path)
if not success:
return False, f"Fetch failed: {msg}"
if force:
# Force Reset to remote
cmd = ["git", "reset", "--hard", f"origin/{branch}"]
else:
shutil.copy2(source, target)
# Simple pull
cmd = ["git", "pull", "origin", branch]
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]:
return self._run_command(cmd, project_path)
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.
"""
if not project_path.exists():
return False, "项目目录不存在"
return False, "Project path does not exist"
sub_path = self._normalize_sub_path(sub_path)
auth_env = self._auth_env(username, token)
if sub_path:
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]
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}]"
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:
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 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, "项目目录不存在"
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:
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()
cmd.append("--force")
return self._run_command(cmd, project_path)
git_service = GitService()

View File

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

View File

@ -1,78 +0,0 @@
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, syncScope = null, subPath = '') {
export function gitPull(projectId, repoId = null, force = false) {
return request({
url: `/projects/${projectId}/git/pull`,
method: 'post',
params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined }
params: { repo_id: repoId, force }
})
}
/**
* Git Push
*/
export function gitPush(projectId, repoId = null, force = false, syncScope = null, subPath = '') {
export function gitPush(projectId, repoId = null, force = false) {
return request({
url: `/projects/${projectId}/git/push`,
method: 'post',
params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined }
params: { repo_id: repoId, force }
})
}
@ -212,14 +212,3 @@ 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,78 +192,6 @@
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;
@ -357,11 +285,44 @@
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, Select, TreeSelect, Radio, Alert } from 'antd'
import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } 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, getGitRepoDirectories } from '@/api/project'
import { gitPull, gitPush, getGitRepos } from '@/api/project'
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share'
import { searchDocuments } from '@/api/search'
import { markProjectNotificationsRead } from '@/api/notification'
@ -26,7 +26,6 @@ import './DocumentPage.css'
const { Sider, Content } = Layout
const MAX_TOC_ITEMS = 500
const ROOT_FOLDER_KEY = '__root__'
// 高亮渲染组件
const HighlightText = ({ text, keyword }) => {
@ -61,14 +60,6 @@ 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)
@ -86,6 +77,21 @@ 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))
@ -142,27 +148,9 @@ 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 === ROOT_FOLDER_KEY
? { key: ROOT_FOLDER_KEY, title: projectName || '根目录', isLeaf: false, children: fileTree }
: folderPath ? findNodeByKey(fileTree, folderPath) : null
const node = folderPath ? findNodeByKey(fileTree, folderPath) : null
if (!node || node.isLeaf) return { folders: [], files: [], node }
const children = node.children || []
const folders = children.filter(c => !c.isLeaf)
@ -172,91 +160,6 @@ 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)
@ -293,7 +196,6 @@ function DocumentPage() {
useEffect(() => {
loadFileTree()
loadGitRepos()
}, [projectId])
// 打开项目后,将该项目的未读更新通知标记为已读(与消息通知联动)
@ -344,12 +246,6 @@ 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)
@ -841,13 +737,13 @@ function DocumentPage() {
}
}
const handleGitPull = async (repoId = null, force = false, syncScope = null, subPath = '') => {
const handleGitPull = async (repoId = null, force = false) => {
if (gitRepos.length === 0) {
message.warning('未配置Git仓库')
return false
return
}
try {
const res = await gitPull(projectId, repoId, force, syncScope, subPath)
const res = await gitPull(projectId, repoId, force)
message.success(res.message || 'Git Pull 成功')
// Refresh tree
loadFileTree()
@ -855,13 +751,11 @@ 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 && !subPath) {
if (!force) {
Modal.confirm({
title: 'Git Pull 失败',
content: (
@ -878,30 +772,28 @@ function DocumentPage() {
okText: '强制重置',
okType: 'danger',
cancelText: '取消',
onOk: () => handleGitPull(repoId, true, 'all')
onOk: () => handleGitPull(repoId, true)
})
return false
return
}
message.error(errorMsg)
return false
}
}
const handleGitPush = async (repoId = null, force = false, syncScope = null, subPath = '') => {
const handleGitPush = async (repoId = null, force = false) => {
if (gitRepos.length === 0) {
message.warning('未配置Git仓库')
return false
return
}
try {
const res = await gitPush(projectId, repoId, force, syncScope, subPath)
const res = await gitPush(projectId, repoId, force)
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 && !subPath) {
if (!force) {
Modal.confirm({
title: 'Git Push 失败',
content: (
@ -918,117 +810,89 @@ function DocumentPage() {
okText: '强制推送',
okType: 'danger',
cancelText: '取消',
onOk: () => handleGitPush(repoId, true, 'all')
onOk: () => handleGitPush(repoId, true)
})
return false
return
}
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 (
<>
{pullButton}
{pushButton}
<Tooltip title="Git Pull">
<Button
size="middle"
icon={<CloudDownloadOutlined />}
onClick={() => handleGitPull()}
/>
</Tooltip>
<Tooltip title="Git Push">
<Button
size="middle"
icon={<CloudUploadOutlined />}
onClick={() => handleGitPush()}
/>
</Tooltip>
</>
)
}
// 多个仓库,显示下拉菜单(选择仓库后打开同步范围弹窗)
// 多个仓库,显示下拉菜单
const pullItems = gitRepos.map(repo => ({
key: repo.id,
label: repo.name + (repo.is_default ? ' (默认)' : ''),
onClick: () => openGitSyncModal('pull', repo.id),
onClick: () => handleGitPull(repo.id),
}))
const pushItems = gitRepos.map(repo => ({
key: repo.id,
label: repo.name + (repo.is_default ? ' (默认)' : ''),
onClick: () => openGitSyncModal('push', repo.id),
onClick: () => handleGitPush(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 }}>
{pullButton}
<Tooltip title="Git Pull">
<Button
size="middle"
icon={<CloudDownloadOutlined />}
/>
</Tooltip>
</Dropdown>
<Dropdown menu={{ items: pushItems }}>
{pushButton}
<Tooltip title="Git Push">
<Button
size="middle"
icon={<CloudUploadOutlined />}
/>
</Tooltip>
</Dropdown>
</>
)
@ -1295,6 +1159,7 @@ function DocumentPage() {
>
<ArrowLeftOutlined />
</button>
<h2 title={projectName}>{projectName}</h2>
</div>
<div className="docs-sider-actions">
<div className="mode-actions-row">
@ -1371,8 +1236,17 @@ function DocumentPage() {
{/* 右侧内容区 */}
<Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}>
<div className="docs-content-header">
{renderContentBreadcrumb()}
<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">
@ -1392,6 +1266,9 @@ function DocumentPage() {
</Button>
</Space>
)}
</>
)
})()}
</div>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
{loading ? (
@ -1420,6 +1297,16 @@ 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}
@ -1570,104 +1457,6 @@ 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,22 +137,6 @@ 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,10 +1,9 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, 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 { 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 { 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'
@ -327,44 +326,8 @@ 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()
@ -398,15 +361,11 @@ 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)
}
}
// 打开编辑仓库弹窗
@ -415,14 +374,8 @@ 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))
}
}
// 删除仓库
@ -449,7 +402,6 @@ function ProjectList({ type = 'my' }) {
const data = {
...values,
is_default: values.is_default ? 1 : 0,
sync_path: values.sync_path || '',
}
if (editingRepo) {
@ -767,19 +719,7 @@ 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={(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="git" title="Git 仓库"><GithubOutlined 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>,
] : [
@ -1259,17 +1199,6 @@ 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',
@ -1340,30 +1269,11 @@ 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>