添加了项目git仓库管理
parent
0521bfdf0b
commit
2af47195c1
|
|
@ -172,6 +172,10 @@ CREATE TABLE `projects` (
|
||||||
`cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图',
|
`cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图',
|
||||||
`sort_order` INT DEFAULT 0 COMMENT '排序号',
|
`sort_order` INT DEFAULT 0 COMMENT '排序号',
|
||||||
`visit_count` 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 '创建时间',
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
UNIQUE KEY `uk_storage_key` (`storage_key`),
|
UNIQUE KEY `uk_storage_key` (`storage_key`),
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
API v1 路由汇总
|
API v1 路由汇总
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter
|
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 = APIRouter()
|
||||||
|
|
||||||
# 注册子路由
|
# 注册子路由
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
|
||||||
api_router.include_router(projects.router, prefix="/projects", 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(files.router, prefix="/files", tags=["文件系统"])
|
||||||
api_router.include_router(menu.router, prefix="/menu", tags=["权限菜单"])
|
api_router.include_router(menu.router, prefix="/menu", tags=["权限菜单"])
|
||||||
api_router.include_router(dashboard.router, prefix="/dashboard", 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.core.deps import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.project import Project, ProjectMember
|
from app.models.project import Project, ProjectMember
|
||||||
|
from app.models.git_repo import ProjectGitRepo
|
||||||
from app.schemas.project import (
|
from app.schemas.project import (
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
|
|
@ -24,6 +25,7 @@ from app.schemas.project import (
|
||||||
from app.schemas.response import success_response
|
from app.schemas.response import success_response
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
from app.services.log_service import log_service
|
from app.services.log_service import log_service
|
||||||
|
from app.services.git_service import git_service
|
||||||
from app.core.enums import OperationType, ResourceType
|
from app.core.enums import OperationType, ResourceType
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -561,3 +563,147 @@ async def update_share_settings(
|
||||||
|
|
||||||
message = "访问密码已取消" if not settings.access_pass else "访问密码已设置"
|
message = "访问密码已取消" if not settings.access_pass else "访问密码已设置"
|
||||||
return success_response(message=message)
|
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"
|
UPDATE_SHARE_SETTINGS = "update_share_settings"
|
||||||
|
|
||||||
|
# Git操作
|
||||||
|
GIT_PULL = "git_pull"
|
||||||
|
GIT_PUSH = "git_push"
|
||||||
|
|
||||||
# 用户操作
|
# 用户操作
|
||||||
USER_LOGIN = "user_login"
|
USER_LOGIN = "user_login"
|
||||||
USER_LOGOUT = "user_logout"
|
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',
|
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 { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
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 {
|
import {
|
||||||
FileOutlined,
|
FileOutlined,
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
|
|
@ -67,6 +67,29 @@ function DocumentEditor() {
|
||||||
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
||||||
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
||||||
const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -590,6 +613,23 @@ function DocumentEditor() {
|
||||||
|
|
||||||
// ByteMD 插件配置
|
// ByteMD 插件配置
|
||||||
const plugins = useMemo(() => {
|
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 = {
|
const uploadImagesPlugin = {
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -626,6 +666,7 @@ function DocumentEditor() {
|
||||||
frontmatter(),
|
frontmatter(),
|
||||||
gemoji(),
|
gemoji(),
|
||||||
uploadImagesPlugin,
|
uploadImagesPlugin,
|
||||||
|
internalLinkPlugin,
|
||||||
]
|
]
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
|
|
||||||
|
|
@ -966,6 +1007,34 @@ function DocumentEditor() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space } from 'antd'
|
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown } from 'antd'
|
||||||
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined } from '@ant-design/icons'
|
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined } from '@ant-design/icons'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeRaw from 'rehype-raw'
|
import rehypeRaw from 'rehype-raw'
|
||||||
|
|
@ -9,8 +9,10 @@ import rehypeSlug from 'rehype-slug'
|
||||||
import rehypeHighlight from 'rehype-highlight'
|
import rehypeHighlight from 'rehype-highlight'
|
||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file'
|
import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file'
|
||||||
|
import { gitPull, gitPush, getGitRepos } from '@/api/project'
|
||||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||||
|
import Toast from '@/components/Toast/Toast'
|
||||||
import './DocumentPage.css'
|
import './DocumentPage.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
|
@ -34,12 +36,28 @@ function DocumentPage() {
|
||||||
const [pdfUrl, setPdfUrl] = useState('')
|
const [pdfUrl, setPdfUrl] = useState('')
|
||||||
const [pdfFilename, setPdfFilename] = useState('')
|
const [pdfFilename, setPdfFilename] = useState('')
|
||||||
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
|
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
|
||||||
|
const [gitRepos, setGitRepos] = useState([])
|
||||||
const contentRef = useRef(null)
|
const contentRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFileTree()
|
loadFileTree()
|
||||||
}, [projectId])
|
}, [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 () => {
|
const loadFileTree = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -134,7 +152,15 @@ function DocumentPage() {
|
||||||
if (match) {
|
if (match) {
|
||||||
const level = match[1].length
|
const level = match[1].length
|
||||||
const title = match[2]
|
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({
|
headings.push({
|
||||||
key: `#${key}`,
|
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 = () => {
|
const handleEdit = () => {
|
||||||
navigate(`/projects/${projectId}/editor`)
|
navigate(`/projects/${projectId}/editor`)
|
||||||
|
|
@ -280,11 +450,36 @@ function DocumentPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制分享链接
|
// 复制分享链接
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = async () => {
|
||||||
if (!shareInfo) return
|
if (!shareInfo) return
|
||||||
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
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">
|
<div className="docs-sider-header">
|
||||||
<h2>项目文档</h2>
|
<h2>项目文档</h2>
|
||||||
<div className="docs-sider-actions">
|
<div className="docs-sider-actions">
|
||||||
{/* 只有 owner/admin/editor 可以编辑 */}
|
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
|
||||||
{userRole !== 'viewer' && (
|
{userRole !== 'viewer' && (
|
||||||
<Button
|
<>
|
||||||
type="primary"
|
<Button
|
||||||
size="small"
|
type="primary"
|
||||||
className="mode-toggle-btn"
|
size="small"
|
||||||
icon={<EditOutlined />}
|
className="mode-toggle-btn"
|
||||||
onClick={handleEdit}
|
icon={<EditOutlined />}
|
||||||
>
|
onClick={handleEdit}
|
||||||
编辑模式
|
>
|
||||||
</Button>
|
编辑模式
|
||||||
|
</Button>
|
||||||
|
{renderGitActions()}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Tooltip title="分享">
|
<Tooltip title="分享">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -357,14 +555,6 @@ function DocumentPage() {
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="设置">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="middle"
|
|
||||||
icon={<SettingOutlined />}
|
|
||||||
onClick={() => message.info('设置功能开发中')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
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 } from 'antd'
|
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 { 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 } from '@/api/project'
|
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo } from '@/api/project'
|
||||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||||
import { getUserList } from '@/api/users'
|
import { getUserList } from '@/api/users'
|
||||||
import { searchDocuments } from '@/api/search'
|
import { searchDocuments } from '@/api/search'
|
||||||
|
|
@ -15,6 +15,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [modalVisible, setModalVisible] = useState(false)
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
const [editModalVisible, setEditModalVisible] = useState(false)
|
const [editModalVisible, setEditModalVisible] = useState(false)
|
||||||
|
const [gitModalVisible, setGitModalVisible] = useState(false)
|
||||||
const [shareModalVisible, setShareModalVisible] = useState(false)
|
const [shareModalVisible, setShareModalVisible] = useState(false)
|
||||||
const [membersModalVisible, setMembersModalVisible] = useState(false)
|
const [membersModalVisible, setMembersModalVisible] = useState(false)
|
||||||
const [currentProject, setCurrentProject] = useState(null)
|
const [currentProject, setCurrentProject] = useState(null)
|
||||||
|
|
@ -30,6 +31,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
const [loadingMembers, setLoadingMembers] = useState(false)
|
const [loadingMembers, setLoadingMembers] = useState(false)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [editForm] = Form.useForm()
|
const [editForm] = Form.useForm()
|
||||||
|
const [gitForm] = Form.useForm()
|
||||||
const [memberForm] = Form.useForm()
|
const [memberForm] = Form.useForm()
|
||||||
const navigate = useNavigate()
|
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) => {
|
const handleOpenProject = (projectId) => {
|
||||||
navigate(`/projects/${projectId}/docs`)
|
navigate(`/projects/${projectId}/docs`)
|
||||||
}
|
}
|
||||||
|
|
@ -139,11 +232,36 @@ function ProjectList({ type = 'my' }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制分享链接
|
// 复制分享链接
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = async () => {
|
||||||
if (!shareInfo) return
|
if (!shareInfo) return
|
||||||
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
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)}
|
onClick={() => handleOpenProject(project.id)}
|
||||||
actions={type === 'my' ? [
|
actions={type === 'my' ? [
|
||||||
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
|
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
|
||||||
|
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
|
||||||
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
|
||||||
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||||||
] : [
|
] : [
|
||||||
|
|
@ -753,6 +872,137 @@ function ProjectList({ type = 'my' }) {
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue