添加了项目git仓库管理

main
mula.liu 2026-01-05 18:50:29 +08:00
parent 0521bfdf0b
commit 2af47195c1
14 changed files with 1151 additions and 31 deletions

View File

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

View File

@ -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=["管理员仪表盘"])

View File

@ -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="删除成功")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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访问令牌/密码';

View File

@ -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仓库表';

View File

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

View File

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

View File

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

View File

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