添加了项目git仓库管理
parent
0521bfdf0b
commit
2af47195c1
|
|
@ -172,6 +172,10 @@ CREATE TABLE `projects` (
|
|||
`cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图',
|
||||
`sort_order` INT DEFAULT 0 COMMENT '排序号',
|
||||
`visit_count` INT DEFAULT 0 COMMENT '访问次数',
|
||||
`git_repo_url` VARCHAR(255) DEFAULT NULL COMMENT 'Git仓库地址',
|
||||
`git_branch` VARCHAR(50) DEFAULT 'main' COMMENT 'Git分支',
|
||||
`git_username` VARCHAR(100) DEFAULT NULL COMMENT 'Git用户名',
|
||||
`git_token` VARCHAR(255) DEFAULT NULL COMMENT 'Git访问令牌/密码',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_storage_key` (`storage_key`),
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
API v1 路由汇总
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs
|
||||
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# 注册子路由
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
|
||||
api_router.include_router(projects.router, prefix="/projects", tags=["项目管理"])
|
||||
api_router.include_router(git_repos.router, tags=["Git仓库管理"]) # git_repos internal paths start with /projects/{id}/git-repos
|
||||
api_router.include_router(files.router, prefix="/files", tags=["文件系统"])
|
||||
api_router.include_router(menu.router, prefix="/menu", tags=["权限菜单"])
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
"""
|
||||
项目Git仓库管理 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func
|
||||
from typing import List
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.models.git_repo import ProjectGitRepo
|
||||
from app.schemas.git_repo import GitRepoCreate, GitRepoUpdate, GitRepoResponse
|
||||
from app.schemas.response import success_response
|
||||
from app.services.log_service import log_service
|
||||
from app.core.enums import OperationType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def check_project_permission(db: AsyncSession, project_id: int, user_id: int, required_roles: list = None):
|
||||
"""检查项目权限"""
|
||||
# 查询项目
|
||||
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 == user_id:
|
||||
return project
|
||||
|
||||
# 如果指定了角色要求
|
||||
if required_roles:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
ProjectMember.role.in_(required_roles)
|
||||
)
|
||||
)
|
||||
if not member_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权执行此操作")
|
||||
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/git-repos", response_model=dict)
|
||||
async def get_project_git_repos(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目的Git仓库列表"""
|
||||
# 检查权限(查看权限即可)
|
||||
# 这里稍微放宽一点,只要能访问项目就能看Git配置?
|
||||
# 为了安全,还是限制为成员
|
||||
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor', 'viewer'])
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id).order_by(ProjectGitRepo.created_at)
|
||||
)
|
||||
repos = result.scalars().all()
|
||||
|
||||
# 隐藏 token
|
||||
data = []
|
||||
for repo in repos:
|
||||
repo_dict = GitRepoResponse.from_orm(repo).dict()
|
||||
# repo_dict['token'] = '******' if repo.token else None # 前端可能需要回显或者判断是否有token
|
||||
data.append(repo_dict)
|
||||
|
||||
return success_response(data=data)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/git-repos", response_model=dict)
|
||||
async def create_git_repo(
|
||||
project_id: int,
|
||||
repo_in: GitRepoCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""添加Git仓库"""
|
||||
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor'])
|
||||
|
||||
# 如果是设为默认,先取消其他默认
|
||||
if repo_in.is_default:
|
||||
await db.execute(
|
||||
update(ProjectGitRepo)
|
||||
.where(ProjectGitRepo.project_id == project_id)
|
||||
.values(is_default=0)
|
||||
)
|
||||
|
||||
# 检查是否是第一个仓库,如果是,强制设为默认
|
||||
result = await db.execute(select(func.count()).select_from(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id))
|
||||
count = result.scalar()
|
||||
is_default = repo_in.is_default
|
||||
if count == 0:
|
||||
is_default = 1
|
||||
|
||||
db_repo = ProjectGitRepo(
|
||||
project_id=project_id,
|
||||
name=repo_in.name,
|
||||
repo_url=repo_in.repo_url,
|
||||
branch=repo_in.branch,
|
||||
username=repo_in.username,
|
||||
token=repo_in.token,
|
||||
is_default=is_default
|
||||
)
|
||||
db.add(db_repo)
|
||||
await db.commit()
|
||||
await db.refresh(db_repo)
|
||||
|
||||
return success_response(data=GitRepoResponse.from_orm(db_repo).dict(), message="Git仓库添加成功")
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)
|
||||
async def update_git_repo(
|
||||
project_id: int,
|
||||
repo_id: int,
|
||||
repo_in: GitRepoUpdate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新Git仓库"""
|
||||
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor'])
|
||||
|
||||
result = await db.execute(select(ProjectGitRepo).where(ProjectGitRepo.id == repo_id, ProjectGitRepo.project_id == project_id))
|
||||
repo = result.scalar_one_or_none()
|
||||
|
||||
if not repo:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
|
||||
# 如果设为默认,取消其他默认
|
||||
if repo_in.is_default == 1:
|
||||
await db.execute(
|
||||
update(ProjectGitRepo)
|
||||
.where(ProjectGitRepo.project_id == project_id)
|
||||
.values(is_default=0)
|
||||
)
|
||||
|
||||
update_data = repo_in.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(repo, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(repo)
|
||||
|
||||
return success_response(data=GitRepoResponse.from_orm(repo).dict(), message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)
|
||||
async def delete_git_repo(
|
||||
project_id: int,
|
||||
repo_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除Git仓库"""
|
||||
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor'])
|
||||
|
||||
result = await db.execute(select(ProjectGitRepo).where(ProjectGitRepo.id == repo_id, ProjectGitRepo.project_id == project_id))
|
||||
repo = result.scalar_one_or_none()
|
||||
|
||||
if not repo:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
|
||||
await db.delete(repo)
|
||||
await db.commit()
|
||||
|
||||
return success_response(message="删除成功")
|
||||
|
|
@ -11,6 +11,7 @@ from app.core.database import get_db
|
|||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.models.git_repo import ProjectGitRepo
|
||||
from app.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
|
|
@ -24,6 +25,7 @@ from app.schemas.project import (
|
|||
from app.schemas.response import success_response
|
||||
from app.services.storage import storage_service
|
||||
from app.services.log_service import log_service
|
||||
from app.services.git_service import git_service
|
||||
from app.core.enums import OperationType, ResourceType
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -561,3 +563,147 @@ async def update_share_settings(
|
|||
|
||||
message = "访问密码已取消" if not settings.access_pass else "访问密码已设置"
|
||||
return success_response(message=message)
|
||||
|
||||
|
||||
@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,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""执行 Git Pull"""
|
||||
# 查询项目
|
||||
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操作")
|
||||
|
||||
# 获取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)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Git Pull失败: {msg}")
|
||||
|
||||
# 记录日志
|
||||
await log_service.log_project_operation(
|
||||
db=db,
|
||||
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},
|
||||
request=request,
|
||||
)
|
||||
|
||||
return success_response(message=f"Git Pull 成功 ({target_repo.name})")
|
||||
|
||||
|
||||
@router.post("/{project_id}/git/push", response_model=dict)
|
||||
async def git_push(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
repo_id: int = None,
|
||||
force: bool = False,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""执行 Git Push"""
|
||||
# 查询项目
|
||||
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操作")
|
||||
|
||||
# 获取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)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Git Push失败: {msg}")
|
||||
|
||||
# 记录日志
|
||||
await log_service.log_project_operation(
|
||||
db=db,
|
||||
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},
|
||||
request=request,
|
||||
)
|
||||
|
||||
return success_response(message=f"Git Push 成功 ({target_repo.name})")
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ class OperationType(str, Enum):
|
|||
# 分享操作
|
||||
UPDATE_SHARE_SETTINGS = "update_share_settings"
|
||||
|
||||
# Git操作
|
||||
GIT_PULL = "git_pull"
|
||||
GIT_PUSH = "git_push"
|
||||
|
||||
# 用户操作
|
||||
USER_LOGIN = "user_login"
|
||||
USER_LOGOUT = "user_logout"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
"""
|
||||
项目Git仓库模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ProjectGitRepo(Base):
|
||||
"""项目Git仓库表模型"""
|
||||
|
||||
__tablename__ = "project_git_repos"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="ID")
|
||||
project_id = Column(BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True, comment="项目ID")
|
||||
name = Column(String(50), nullable=False, comment="仓库别名")
|
||||
repo_url = Column(String(255), nullable=False, comment="Git仓库地址")
|
||||
branch = Column(String(50), default="main", comment="Git分支")
|
||||
username = Column(String(100), comment="Git用户名")
|
||||
token = Column(String(255), comment="Git访问令牌/密码")
|
||||
is_default = Column(SmallInteger, default=0, comment="是否默认仓库")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
# 关系
|
||||
# project = relationship("Project", back_populates="git_repos")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProjectGitRepo(id={self.id}, name='{self.name}', repo_url='{self.repo_url}')>"
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"""
|
||||
Git仓库相关的 Pydantic Schema
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class GitRepoBase(BaseModel):
|
||||
name: str = Field(..., max_length=50, description="仓库别名")
|
||||
repo_url: str = Field(..., max_length=255, description="Git仓库地址")
|
||||
branch: str = Field("main", max_length=50, description="Git分支")
|
||||
username: Optional[str] = Field(None, description="Git用户名")
|
||||
token: Optional[str] = Field(None, description="Git访问令牌")
|
||||
is_default: int = Field(0, description="是否默认仓库")
|
||||
|
||||
|
||||
class GitRepoCreate(GitRepoBase):
|
||||
"""创建Git仓库 Schema"""
|
||||
pass
|
||||
|
||||
|
||||
class GitRepoUpdate(BaseModel):
|
||||
"""更新Git仓库 Schema"""
|
||||
name: Optional[str] = None
|
||||
repo_url: Optional[str] = None
|
||||
branch: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
token: Optional[str] = None
|
||||
is_default: Optional[int] = None
|
||||
|
||||
|
||||
class GitRepoResponse(GitRepoBase):
|
||||
"""Git仓库响应 Schema"""
|
||||
id: int
|
||||
project_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
class GitService:
|
||||
|
||||
def _get_auth_url(self, repo_url: str, username: str = None, token: str = None) -> str:
|
||||
"""
|
||||
Constructs a URL with authentication credentials.
|
||||
Note: This is sensitive, so be careful not to log this URL.
|
||||
"""
|
||||
if not username or not token:
|
||||
return repo_url
|
||||
|
||||
# Encode credentials to handle special characters (e.g. @, :, /)
|
||||
safe_username = quote_plus(username)
|
||||
safe_token = quote_plus(token)
|
||||
|
||||
# Remove scheme if present to insert auth
|
||||
if repo_url.startswith("https://"):
|
||||
url_body = repo_url[8:]
|
||||
return f"https://{safe_username}:{safe_token}@{url_body}"
|
||||
elif repo_url.startswith("http://"):
|
||||
url_body = repo_url[7:]
|
||||
return f"http://{safe_username}:{safe_token}@{url_body}"
|
||||
|
||||
return repo_url
|
||||
|
||||
def _run_command(self, cmd: list, cwd: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
Runs a shell command in the given directory.
|
||||
Returns (success, message).
|
||||
"""
|
||||
try:
|
||||
# git operations might take time
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False # We handle return code manually
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, result.stdout
|
||||
else:
|
||||
return False, f"Command failed: {' '.join(cmd)}\nError: {result.stderr}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _ensure_git_initialized(self, cwd: Path, auth_url: str):
|
||||
"""
|
||||
Ensures the directory is a git repository and has the correct remote.
|
||||
"""
|
||||
git_dir = cwd / ".git"
|
||||
if not git_dir.exists():
|
||||
self._run_command(["git", "init"], cwd)
|
||||
self._run_command(["git", "branch", "-M", "main"], cwd) # Default to main
|
||||
|
||||
# Check remote
|
||||
success, output = self._run_command(["git", "remote", "get-url", "origin"], cwd)
|
||||
if not success:
|
||||
# Remote doesn't exist, add it
|
||||
self._run_command(["git", "remote", "add", "origin", auth_url], cwd)
|
||||
else:
|
||||
# Remote exists, update it (in case credentials or URL changed)
|
||||
current_url = output.strip()
|
||||
if current_url != auth_url:
|
||||
self._run_command(["git", "remote", "set-url", "origin", auth_url], cwd)
|
||||
|
||||
async def pull(self, project_path: Path, repo_url: str, branch: str = "main", username: str = None, token: str = None, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Executes git pull.
|
||||
"""
|
||||
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:
|
||||
# Simple pull
|
||||
cmd = ["git", "pull", "origin", branch]
|
||||
|
||||
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, "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)
|
||||
|
||||
# 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:
|
||||
cmd.append("--force")
|
||||
|
||||
return self._run_command(cmd, project_path)
|
||||
|
||||
git_service = GitService()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE projects
|
||||
ADD COLUMN git_repo_url VARCHAR(255) COMMENT 'Git仓库地址',
|
||||
ADD COLUMN git_branch VARCHAR(50) DEFAULT 'main' COMMENT 'Git分支',
|
||||
ADD COLUMN git_username VARCHAR(100) COMMENT 'Git用户名',
|
||||
ADD COLUMN git_token VARCHAR(255) COMMENT 'Git访问令牌/密码';
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE `project_git_repos` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`name` VARCHAR(50) NOT NULL COMMENT '仓库别名',
|
||||
`repo_url` VARCHAR(255) NOT NULL COMMENT 'Git仓库地址',
|
||||
`branch` VARCHAR(50) DEFAULT 'main' COMMENT 'Git分支',
|
||||
`username` VARCHAR(100) DEFAULT NULL COMMENT 'Git用户名',
|
||||
`token` VARCHAR(255) DEFAULT NULL COMMENT 'Git访问令牌/密码',
|
||||
`is_default` TINYINT DEFAULT 0 COMMENT '是否默认仓库',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_project_id` (`project_id`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目Git仓库表';
|
||||
|
|
@ -105,3 +105,67 @@ export function removeProjectMember(projectId, userId) {
|
|||
method: 'delete',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Git Pull
|
||||
*/
|
||||
export function gitPull(projectId, repoId = null, force = false) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/git/pull`,
|
||||
method: 'post',
|
||||
params: { repo_id: repoId, force }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Git Push
|
||||
*/
|
||||
export function gitPush(projectId, repoId = null, force = false) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/git/push`,
|
||||
method: 'post',
|
||||
params: { repo_id: repoId, force }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Git仓库列表
|
||||
*/
|
||||
export function getGitRepos(projectId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/git-repos`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Git仓库
|
||||
*/
|
||||
export function createGitRepo(projectId, data) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/git-repos`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Git仓库
|
||||
*/
|
||||
export function updateGitRepo(projectId, repoId, data) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/git-repos/${repoId}`,
|
||||
method: 'put',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Git仓库
|
||||
*/
|
||||
export function deleteGitRepo(projectId, repoId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/git-repos/${repoId}`,
|
||||
method: 'delete',
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, Select, Progress } from 'antd'
|
||||
import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, Select, Progress, TreeSelect } from 'antd'
|
||||
import {
|
||||
FileOutlined,
|
||||
FolderOutlined,
|
||||
|
|
@ -67,6 +67,29 @@ function DocumentEditor() {
|
|||
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
||||
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
||||
const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件
|
||||
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
||||
const [linkTarget, setLinkTarget] = useState(null)
|
||||
const editorCtxRef = useRef(null)
|
||||
|
||||
// 插入内链接
|
||||
const handleInsertLink = () => {
|
||||
if (!linkTarget) {
|
||||
Toast.warning('提示', '请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 简单的从路径获取文件名
|
||||
const fileName = linkTarget.split('/').pop()
|
||||
const linkText = `[${fileName}](${linkTarget})`
|
||||
|
||||
if (editorCtxRef.current && editorCtxRef.current.editor) {
|
||||
editorCtxRef.current.editor.replaceSelection(linkText)
|
||||
editorCtxRef.current.editor.focus()
|
||||
}
|
||||
|
||||
setLinkModalVisible(false)
|
||||
setLinkTarget(null)
|
||||
}
|
||||
|
||||
// 在组件挂载后立即计算正确的高度
|
||||
useEffect(() => {
|
||||
|
|
@ -590,6 +613,23 @@ function DocumentEditor() {
|
|||
|
||||
// ByteMD 插件配置
|
||||
const plugins = useMemo(() => {
|
||||
// 内链接插件
|
||||
const internalLinkPlugin = {
|
||||
actions: [
|
||||
{
|
||||
title: '内链接',
|
||||
icon: '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10.59 13.41c.41.39.41 1.03 0 1.42-.39.39-1.03.39-1.42 0a5.003 5.003 0 0 1 0-7.07l3.54-3.54a5.003 5.003 0 0 1 7.07 0 5.003 5.003 0 0 1 0 7.07l-1.49 1.49c.01-.82-.12-1.64-.4-2.42l.47-.48a2.982 2.982 0 0 0 0-4.24 2.982 2.982 0 0 0-4.24 0l-3.53 3.53a2.98 2.98 0 0 0 0 4.24zm2.82-4.24c-.41-.39-.41-1.03 0-1.42a1 1 0 0 1 1.42 0 5.003 5.003 0 0 1 0 7.07l-3.54 3.54a5.003 5.003 0 0 1-7.07 0 5.003 5.003 0 0 1 0-7.07l1.49-1.49c-.01.82.12 1.64.4 2.43l-.47.47a2.982 2.982 0 0 0 0 4.24 2.982 2.982 0 0 0 4.24 0l3.53-3.53a2.98 2.98 0 0 0 0-4.24.973.973 0 0 1 0-1.42z"/></svg>',
|
||||
handler: {
|
||||
type: 'action',
|
||||
click: (ctx) => {
|
||||
editorCtxRef.current = ctx
|
||||
setLinkModalVisible(true)
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// 自定义图片上传插件
|
||||
const uploadImagesPlugin = {
|
||||
actions: [
|
||||
|
|
@ -626,6 +666,7 @@ function DocumentEditor() {
|
|||
frontmatter(),
|
||||
gemoji(),
|
||||
uploadImagesPlugin,
|
||||
internalLinkPlugin,
|
||||
]
|
||||
}, [projectId])
|
||||
|
||||
|
|
@ -966,6 +1007,34 @@ function DocumentEditor() {
|
|||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="插入内链接"
|
||||
open={linkModalVisible}
|
||||
onOk={handleInsertLink}
|
||||
onCancel={() => {
|
||||
setLinkModalVisible(false)
|
||||
setLinkTarget(null)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p>选择要链接的文件:</p>
|
||||
<TreeSelect
|
||||
style={{ width: '100%' }}
|
||||
value={linkTarget}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
treeData={treeData}
|
||||
placeholder="请选择文件"
|
||||
treeDefaultExpandAll
|
||||
onChange={setLinkTarget}
|
||||
fieldNames={{ label: 'title', value: 'key', children: 'children' }}
|
||||
showSearch
|
||||
filterTreeNode={(inputValue, treeNode) => {
|
||||
return treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space } from 'antd'
|
||||
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined } from '@ant-design/icons'
|
||||
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown } from 'antd'
|
||||
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
|
|
@ -9,8 +9,10 @@ import rehypeSlug from 'rehype-slug'
|
|||
import rehypeHighlight from 'rehype-highlight'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file'
|
||||
import { gitPull, gitPush, getGitRepos } from '@/api/project'
|
||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import './DocumentPage.css'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
|
@ -34,12 +36,28 @@ function DocumentPage() {
|
|||
const [pdfUrl, setPdfUrl] = useState('')
|
||||
const [pdfFilename, setPdfFilename] = useState('')
|
||||
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
|
||||
const [gitRepos, setGitRepos] = useState([])
|
||||
const contentRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadFileTree()
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (userRole && userRole !== 'viewer') {
|
||||
loadGitRepos()
|
||||
}
|
||||
}, [projectId, userRole])
|
||||
|
||||
const loadGitRepos = async () => {
|
||||
try {
|
||||
const res = await getGitRepos(projectId)
|
||||
setGitRepos(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Load git repos error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载文件树
|
||||
const loadFileTree = async () => {
|
||||
try {
|
||||
|
|
@ -134,7 +152,15 @@ function DocumentPage() {
|
|||
if (match) {
|
||||
const level = match[1].length
|
||||
const title = match[2]
|
||||
const key = title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
// 模拟 rehype-slug/github-slugger 的 ID 生成规则
|
||||
const key = title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-') // 空格转连字符
|
||||
.replace(/[^\w\-\u4e00-\u9fa5]+/g, '') // 移除非单词字符(保留中文、数字、字母、下划线、连字符)
|
||||
.replace(/\-\-+/g, '-') // 合并重复连字符
|
||||
.replace(/^-+/, '') // 去除头部连字符
|
||||
.replace(/-+$/, '') // 去除尾部连字符
|
||||
|
||||
headings.push({
|
||||
key: `#${key}`,
|
||||
|
|
@ -260,6 +286,150 @@ function DocumentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleGitPull = async (repoId = null, force = false) => {
|
||||
if (gitRepos.length === 0) {
|
||||
message.warning('未配置Git仓库')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await gitPull(projectId, repoId, force)
|
||||
message.success(res.message || 'Git Pull 成功')
|
||||
// Refresh tree
|
||||
loadFileTree()
|
||||
// Reload current file if open
|
||||
if (selectedFile) {
|
||||
loadMarkdown(selectedFile)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Git Pull error:', error)
|
||||
const errorMsg = error.response?.data?.detail || 'Git Pull 失败'
|
||||
|
||||
if (!force) {
|
||||
Modal.confirm({
|
||||
title: 'Git Pull 失败',
|
||||
content: (
|
||||
<div>
|
||||
<p>{errorMsg}</p>
|
||||
<p style={{ color: 'red', fontWeight: 'bold', marginTop: 8 }}>
|
||||
是否强制重置到远程版本?
|
||||
</p>
|
||||
<p style={{ color: '#666', fontSize: 12 }}>
|
||||
警告:这将丢失所有本地未提交的修改!
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '强制重置',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => handleGitPull(repoId, true)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGitPush = async (repoId = null, force = false) => {
|
||||
if (gitRepos.length === 0) {
|
||||
message.warning('未配置Git仓库')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await gitPush(projectId, repoId, force)
|
||||
message.success(res.message || 'Git Push 成功')
|
||||
} catch (error) {
|
||||
console.error('Git Push error:', error)
|
||||
const errorMsg = error.response?.data?.detail || 'Git Push 失败'
|
||||
|
||||
if (!force) {
|
||||
Modal.confirm({
|
||||
title: 'Git Push 失败',
|
||||
content: (
|
||||
<div>
|
||||
<p>{errorMsg}</p>
|
||||
<p style={{ color: 'red', fontWeight: 'bold', marginTop: 8 }}>
|
||||
是否强制推送到远程?
|
||||
</p>
|
||||
<p style={{ color: '#666', fontSize: 12 }}>
|
||||
警告:这将覆盖远程仓库的修改!
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '强制推送',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => handleGitPush(repoId, true)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
const renderGitActions = () => {
|
||||
if (gitRepos.length <= 1) {
|
||||
// 0 或 1 个仓库,显示普通按钮
|
||||
return (
|
||||
<Space.Compact>
|
||||
<Tooltip title="Git Pull">
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => handleGitPull()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Git Push">
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => handleGitPush()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
)
|
||||
}
|
||||
|
||||
// 多个仓库,显示下拉菜单
|
||||
const pullItems = gitRepos.map(repo => ({
|
||||
key: repo.id,
|
||||
label: repo.name + (repo.is_default ? ' (默认)' : ''),
|
||||
onClick: () => handleGitPull(repo.id),
|
||||
}))
|
||||
|
||||
const pushItems = gitRepos.map(repo => ({
|
||||
key: repo.id,
|
||||
label: repo.name + (repo.is_default ? ' (默认)' : ''),
|
||||
onClick: () => handleGitPush(repo.id),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Space.Compact>
|
||||
<Dropdown menu={{ items: pullItems }}>
|
||||
<Tooltip title="Git Pull">
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<Dropdown menu={{ items: pushItems }}>
|
||||
<Tooltip title="Git Push">
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
icon={<CloudUploadOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
</Space.Compact>
|
||||
)
|
||||
}
|
||||
|
||||
// 进入编辑模式
|
||||
const handleEdit = () => {
|
||||
navigate(`/projects/${projectId}/editor`)
|
||||
|
|
@ -280,11 +450,36 @@ function DocumentPage() {
|
|||
}
|
||||
|
||||
// 复制分享链接
|
||||
const handleCopyLink = () => {
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareInfo) return
|
||||
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
||||
navigator.clipboard.writeText(fullUrl)
|
||||
message.success('分享链接已复制')
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(fullUrl)
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} else {
|
||||
// Fallback for non-secure contexts or older browsers
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = fullUrl
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-9999px"
|
||||
textArea.style.top = "0"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const successful = document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
if (successful) {
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} else {
|
||||
Toast.error('复制失败', '请手动复制链接')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
Toast.error('复制失败', '无法访问剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换密码保护
|
||||
|
|
@ -337,17 +532,20 @@ function DocumentPage() {
|
|||
<div className="docs-sider-header">
|
||||
<h2>项目文档</h2>
|
||||
<div className="docs-sider-actions">
|
||||
{/* 只有 owner/admin/editor 可以编辑 */}
|
||||
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
|
||||
{userRole !== 'viewer' && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="mode-toggle-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={handleEdit}
|
||||
>
|
||||
编辑模式
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="mode-toggle-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={handleEdit}
|
||||
>
|
||||
编辑模式
|
||||
</Button>
|
||||
{renderGitActions()}
|
||||
</>
|
||||
)}
|
||||
<Tooltip title="分享">
|
||||
<Button
|
||||
|
|
@ -357,14 +555,6 @@ function DocumentPage() {
|
|||
onClick={handleShare}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="设置">
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => message.info('设置功能开发中')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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 } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember } from '@/api/project'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo } from '@/api/project'
|
||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||
import { getUserList } from '@/api/users'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
|
|
@ -15,6 +15,7 @@ function ProjectList({ type = 'my' }) {
|
|||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editModalVisible, setEditModalVisible] = useState(false)
|
||||
const [gitModalVisible, setGitModalVisible] = useState(false)
|
||||
const [shareModalVisible, setShareModalVisible] = useState(false)
|
||||
const [membersModalVisible, setMembersModalVisible] = useState(false)
|
||||
const [currentProject, setCurrentProject] = useState(null)
|
||||
|
|
@ -30,6 +31,7 @@ function ProjectList({ type = 'my' }) {
|
|||
const [loadingMembers, setLoadingMembers] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [gitForm] = Form.useForm()
|
||||
const [memberForm] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
|
@ -118,6 +120,97 @@ function ProjectList({ type = 'my' }) {
|
|||
}
|
||||
}
|
||||
|
||||
const [gitRepos, setGitRepos] = useState([])
|
||||
const [loadingRepos, setLoadingRepos] = useState(false)
|
||||
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
|
||||
const [editingRepo, setEditingRepo] = useState(null)
|
||||
const [repoForm] = Form.useForm()
|
||||
|
||||
// 打开Git设置(仓库列表)
|
||||
const handleGitSettings = (e, project) => {
|
||||
e.stopPropagation()
|
||||
setCurrentProject(project)
|
||||
setGitModalVisible(true)
|
||||
fetchGitRepos(project.id)
|
||||
}
|
||||
|
||||
// 加载Git仓库列表
|
||||
const fetchGitRepos = async (projectId) => {
|
||||
setLoadingRepos(true)
|
||||
try {
|
||||
const res = await getGitRepos(projectId)
|
||||
setGitRepos(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Fetch git repos error:', error)
|
||||
message.error('加载Git仓库失败')
|
||||
} finally {
|
||||
setLoadingRepos(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开添加仓库弹窗
|
||||
const handleAddRepo = () => {
|
||||
setEditingRepo(null)
|
||||
repoForm.resetFields()
|
||||
// 如果是第一个仓库,默认设为默认
|
||||
if (gitRepos.length === 0) {
|
||||
repoForm.setFieldsValue({ is_default: 1 })
|
||||
}
|
||||
setGitRepoModalVisible(true)
|
||||
}
|
||||
|
||||
// 打开编辑仓库弹窗
|
||||
const handleEditRepo = (repo) => {
|
||||
setEditingRepo(repo)
|
||||
repoForm.setFieldsValue({
|
||||
...repo,
|
||||
is_default: repo.is_default === 1,
|
||||
})
|
||||
setGitRepoModalVisible(true)
|
||||
}
|
||||
|
||||
// 删除仓库
|
||||
const handleDeleteRepo = (repoId) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个Git仓库配置吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteGitRepo(currentProject.id, repoId)
|
||||
message.success('删除成功')
|
||||
fetchGitRepos(currentProject.id)
|
||||
} catch (error) {
|
||||
console.error('Delete repo error:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 保存仓库(新增/更新)
|
||||
const handleSaveRepo = async (values) => {
|
||||
try {
|
||||
const data = {
|
||||
...values,
|
||||
is_default: values.is_default ? 1 : 0,
|
||||
}
|
||||
|
||||
if (editingRepo) {
|
||||
await updateGitRepo(currentProject.id, editingRepo.id, data)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createGitRepo(currentProject.id, data)
|
||||
message.success('添加成功')
|
||||
}
|
||||
|
||||
setGitRepoModalVisible(false)
|
||||
fetchGitRepos(currentProject.id)
|
||||
} catch (error) {
|
||||
console.error('Save repo error:', error)
|
||||
message.error(editingRepo ? '更新失败' : '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenProject = (projectId) => {
|
||||
navigate(`/projects/${projectId}/docs`)
|
||||
}
|
||||
|
|
@ -139,11 +232,36 @@ function ProjectList({ type = 'my' }) {
|
|||
}
|
||||
|
||||
// 复制分享链接
|
||||
const handleCopyLink = () => {
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareInfo) return
|
||||
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
||||
navigator.clipboard.writeText(fullUrl)
|
||||
message.success('分享链接已复制')
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(fullUrl)
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} else {
|
||||
// Fallback for non-secure contexts or older browsers
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = fullUrl
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-9999px"
|
||||
textArea.style.top = "0"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const successful = document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
if (successful) {
|
||||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||||
} else {
|
||||
Toast.error('复制失败', '请手动复制链接')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
Toast.error('复制失败', '无法访问剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换密码保护
|
||||
|
|
@ -394,6 +512,7 @@ function ProjectList({ type = 'my' }) {
|
|||
onClick={() => handleOpenProject(project.id)}
|
||||
actions={type === 'my' ? [
|
||||
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
|
||||
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
|
||||
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
||||
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||||
] : [
|
||||
|
|
@ -753,6 +872,137 @@ function ProjectList({ type = 'my' }) {
|
|||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="Git仓库管理"
|
||||
open={gitModalVisible}
|
||||
onCancel={() => {
|
||||
setGitModalVisible(false)
|
||||
setGitRepos([])
|
||||
}}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddRepo}>
|
||||
添加仓库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
dataSource={gitRepos}
|
||||
rowKey="id"
|
||||
loading={loadingRepos}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '别名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
{text}
|
||||
{record.is_default === 1 && <Tag color="blue">默认</Tag>}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '仓库地址',
|
||||
dataIndex: 'repo_url',
|
||||
key: 'repo_url',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '分支',
|
||||
dataIndex: 'branch',
|
||||
key: 'branch',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEditRepo(record)}>编辑</Button>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => handleDeleteRepo(record.id)}>删除</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingRepo ? "编辑仓库" : "添加仓库"}
|
||||
open={gitRepoModalVisible}
|
||||
onCancel={() => {
|
||||
setGitRepoModalVisible(false)
|
||||
repoForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
form={repoForm}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveRepo}
|
||||
>
|
||||
<Form.Item
|
||||
label="仓库别名"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入别名(如:Origin, Backup)' }]}
|
||||
>
|
||||
<Input placeholder="例如:Origin" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Git仓库地址"
|
||||
name="repo_url"
|
||||
rules={[{ required: true, message: '请输入Git仓库地址' }, { type: 'url', message: '请输入有效的URL' }]}
|
||||
extra="支持 HTTPS 协议"
|
||||
>
|
||||
<Input placeholder="https://github.com/username/repo.git" prefix={<GithubOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="分支"
|
||||
name="branch"
|
||||
initialValue="main"
|
||||
>
|
||||
<Input placeholder="main" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
name="username"
|
||||
extra="私有仓库需填写"
|
||||
>
|
||||
<Input placeholder="Git用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Token/密码"
|
||||
name="token"
|
||||
extra="推荐使用 Personal Access Token"
|
||||
>
|
||||
<Input.Password placeholder="Git访问令牌" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="is_default"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="默认仓库" unCheckedChildren="非默认" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setGitRepoModalVisible(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">保存</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue