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