diff --git a/DATABASE.md b/DATABASE.md index 411f353..a178a5d 100644 --- a/DATABASE.md +++ b/DATABASE.md @@ -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`), diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 32c5d1e..4725a28 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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=["管理员仪表盘"]) diff --git a/backend/app/api/v1/git_repos.py b/backend/app/api/v1/git_repos.py new file mode 100644 index 0000000..70bac2a --- /dev/null +++ b/backend/app/api/v1/git_repos.py @@ -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="删除成功") diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index a7f754c..5dbdaec 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -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})") diff --git a/backend/app/core/enums.py b/backend/app/core/enums.py index 1dd6654..01b2769 100644 --- a/backend/app/core/enums.py +++ b/backend/app/core/enums.py @@ -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" diff --git a/backend/app/models/git_repo.py b/backend/app/models/git_repo.py new file mode 100644 index 0000000..ebf1ca4 --- /dev/null +++ b/backend/app/models/git_repo.py @@ -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"" diff --git a/backend/app/schemas/git_repo.py b/backend/app/schemas/git_repo.py new file mode 100644 index 0000000..4b5bdbd --- /dev/null +++ b/backend/app/schemas/git_repo.py @@ -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 diff --git a/backend/app/services/git_service.py b/backend/app/services/git_service.py new file mode 100644 index 0000000..5fccdb3 --- /dev/null +++ b/backend/app/services/git_service.py @@ -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() diff --git a/backend/scripts/add_git_columns.sql b/backend/scripts/add_git_columns.sql new file mode 100644 index 0000000..da29561 --- /dev/null +++ b/backend/scripts/add_git_columns.sql @@ -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访问令牌/密码'; diff --git a/backend/scripts/create_git_repos_table.sql b/backend/scripts/create_git_repos_table.sql new file mode 100644 index 0000000..eba6bb0 --- /dev/null +++ b/backend/scripts/create_git_repos_table.sql @@ -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仓库表'; diff --git a/frontend/src/api/project.js b/frontend/src/api/project.js index 7f76ca2..b97cad9 100644 --- a/frontend/src/api/project.js +++ b/frontend/src/api/project.js @@ -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', + }) +} diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index a8ebf62..e5bae51 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -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: '', + 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() { /> + + { + setLinkModalVisible(false) + setLinkTarget(null) + }} + > +
+

选择要链接的文件:

+ { + return treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0 + }} + /> +
+
) } diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index 928ca12..3322d72 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -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: ( +
+

{errorMsg}

+

+ 是否强制重置到远程版本? +

+

+ 警告:这将丢失所有本地未提交的修改! +

+
+ ), + 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: ( +
+

{errorMsg}

+

+ 是否强制推送到远程? +

+

+ 警告:这将覆盖远程仓库的修改! +

+
+ ), + okText: '强制推送', + okType: 'danger', + cancelText: '取消', + onOk: () => handleGitPush(repoId, true) + }) + return + } + + message.error(errorMsg) + } + } + + const renderGitActions = () => { + if (gitRepos.length <= 1) { + // 0 或 1 个仓库,显示普通按钮 + return ( + + + + <> + + {renderGitActions()} + )} + + + ( + + {text} + {record.is_default === 1 && 默认} + + ) + }, + { + title: '仓库地址', + dataIndex: 'repo_url', + key: 'repo_url', + ellipsis: true, + }, + { + title: '分支', + dataIndex: 'branch', + key: 'branch', + width: 100, + }, + { + title: '操作', + key: 'action', + width: 150, + render: (_, record) => ( + + + + + ), + }, + ]} + /> + + + { + setGitRepoModalVisible(false) + repoForm.resetFields() + }} + footer={null} + > +
+ + + + + + } /> + + + + + + + + + + + + + + + + + + + + + + + + + +
) }