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, 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' 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 function DocumentPage() { const { projectId } = useParams() const navigate = useNavigate() const [fileTree, setFileTree] = useState([]) const [selectedFile, setSelectedFile] = useState('') const [markdownContent, setMarkdownContent] = useState('') const [loading, setLoading] = useState(false) const [openKeys, setOpenKeys] = useState([]) const [tocCollapsed, setTocCollapsed] = useState(false) const [tocItems, setTocItems] = useState([]) const [shareModalVisible, setShareModalVisible] = useState(false) const [shareInfo, setShareInfo] = useState(null) const [hasPassword, setHasPassword] = useState(false) const [password, setPassword] = useState('') const [userRole, setUserRole] = useState('viewer') // 用户角色:owner/admin/editor/viewer const [pdfViewerVisible, setPdfViewerVisible] = useState(false) 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 { const res = await getProjectTree(projectId) const data = res.data || {} const tree = data.tree || data || [] // 兼容新旧格式 const role = data.user_role || 'viewer' setFileTree(tree) setUserRole(role) // 默认打开 README.md const readmeNode = findReadme(tree) if (readmeNode) { setSelectedFile(readmeNode.key) loadMarkdown(readmeNode.key) } } catch (error) { console.error('Load file tree error:', error) } } // 查找根目录的 README.md const findReadme = (nodes) => { // 只在根目录查找 for (const node of nodes) { if (node.title === 'README.md' && node.isLeaf) { return node } } return null } // 转换文件树为菜单项 const convertTreeToMenuItems = (nodes) => { return nodes.map((node) => { if (!node.isLeaf) { // 目录 return { key: node.key, label: node.title, icon: , children: node.children ? convertTreeToMenuItems(node.children) : [], } } else if (node.title && node.title.endsWith('.md')) { // Markdown 文件 return { key: node.key, label: node.title.replace('.md', ''), icon: , } } else if (node.title && node.title.endsWith('.pdf')) { // PDF 文件 return { key: node.key, label: node.title, icon: , } } return null }).filter(Boolean) } // 加载 markdown 文件 const loadMarkdown = async (filePath) => { setLoading(true) setTocItems([]) // 清空旧的目录数据 try { const res = await getFileContent(projectId, filePath) setMarkdownContent(res.data?.content || '') // 滚动到顶部 if (contentRef.current) { contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) } } catch (error) { console.error('Load markdown error:', error) setMarkdownContent('# 文档加载失败\n\n无法加载该文档,请稍后重试。') } finally { setLoading(false) } } // 提取 markdown 标题生成目录 useEffect(() => { if (markdownContent) { const headings = [] const lines = markdownContent.split('\n') lines.forEach((line) => { const match = line.match(/^(#{1,6})\s+(.+)$/) if (match) { const level = match[1].length const title = match[2] // 模拟 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}`, href: `#${key}`, title, level, }) } }) setTocItems(headings) } }, [markdownContent]) // 处理菜单点击 const handleMenuClick = ({ key }) => { setSelectedFile(key) // 检查是否是PDF文件 if (key.toLowerCase().endsWith('.pdf')) { // 显示PDF - 添加token到URL let url = getDocumentUrl(projectId, key) const token = localStorage.getItem('access_token') if (token) { url += `?token=${encodeURIComponent(token)}` } setPdfUrl(url) setPdfFilename(key.split('/').pop()) setViewMode('pdf') } else { // 加载Markdown文件 setViewMode('markdown') loadMarkdown(key) } } // 解析相对路径 const resolveRelativePath = (currentPath, relativePath) => { // 获取当前文件所在目录 const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/')) // 分割相对路径 const parts = relativePath.split('/') const dirParts = currentDir ? currentDir.split('/') : [] // 处理 ../ 和 ./ for (const part of parts) { if (part === '..') { dirParts.pop() } else if (part !== '.' && part !== '') { dirParts.push(part) } } return dirParts.join('/') } // 处理markdown内部链接点击 const handleMarkdownLink = (e, href) => { // 检查是否是外部链接 if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) { return // 外部链接,允许默认行为 } // 检查是否是锚点链接 if (href.startsWith('#')) { return // 锚点链接,允许默认行为 } // 检查是否是文档文件(.md 或 .pdf) const isMd = href.endsWith('.md') const isPdf = href.toLowerCase().endsWith('.pdf') if (!isMd && !isPdf) { return // 不是文档文件,允许默认行为 } // 阻止默认跳转 e.preventDefault() // 先解码 href(因为 Markdown 中的链接可能已经是 URL 编码的) let decodedHref = href try { decodedHref = decodeURIComponent(href) } catch (e) { // 解码失败,使用原始值 } // 解析相对路径 const targetPath = resolveRelativePath(selectedFile, decodedHref) // 自动展开父目录 const parentPath = targetPath.substring(0, targetPath.lastIndexOf('/')) if (parentPath && !openKeys.includes(parentPath)) { // 收集所有父路径 const pathParts = parentPath.split('/') const allParentPaths = [] let currentPath = '' for (const part of pathParts) { currentPath = currentPath ? `${currentPath}/${part}` : part allParentPaths.push(currentPath) } setOpenKeys([...new Set([...openKeys, ...allParentPaths])]) } // 选中文件并加载 setSelectedFile(targetPath) if (isPdf) { // PDF文件:切换到PDF模式 let url = getDocumentUrl(projectId, targetPath) const token = localStorage.getItem('access_token') if (token) { url += `?token=${encodeURIComponent(token)}` } setPdfUrl(url) setPdfFilename(targetPath.split('/').pop()) setViewMode('pdf') } else { // Markdown文件:加载内容 setViewMode('markdown') loadMarkdown(targetPath) } } 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()} )} )} {/* 分享模态框 */} setShareModalVisible(false)} footer={null} width={500} > {shareInfo && (
} />
访问密码保护
{hasPassword && (
setPassword(e.target.value)} />
)}
)}
) } export default DocumentPage