import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd' import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } 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 Highlighter from 'react-highlight-words' import GithubSlugger from 'github-slugger' import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file' import { gitPull, gitPush, getGitRepos } from '@/api/project' import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share' import { searchDocuments } from '@/api/search' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import FloatingToc from '@/components/FloatingToc/FloatingToc' import Toast from '@/components/Toast/Toast' import ModeSwitch from '@/components/ModeSwitch/ModeSwitch' import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer' import './DocumentPage.css' const { Sider, Content } = Layout const MAX_TOC_ITEMS = 500 // 高亮渲染组件 const HighlightText = ({ text, keyword }) => { if (!keyword || !text) return text; return ( ) } function DocumentPage() { const { projectId } = useParams() const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const [fileTree, setFileTree] = useState([]) const [selectedFile, setSelectedFile] = useState('') const [selectedNodeKey, setSelectedNodeKey] = useState('') const [markdownContent, setMarkdownContent] = useState('') const [loading, setLoading] = useState(false) const [openKeys, setOpenKeys] = useState([]) 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') const [pdfUrl, setPdfUrl] = useState('') const [pdfFilename, setPdfFilename] = useState('') const [viewMode, setViewMode] = useState('markdown') const [gitRepos, setGitRepos] = useState([]) const [projectName, setProjectName] = useState('') const [refreshing, setRefreshing] = useState(false) // 搜索相关状态 const [searchKeyword, setSearchKeyword] = useState('') const [matchedFilePaths, setMatchedFilePaths] = useState(new Set()) const [isSearching, setIsSearching] = useState(false) const [modeSwitchValue, setModeSwitchValue] = useState('view') const contentRef = useRef(null) const largeMarkdownRef = useRef(null) const modeSwitchingRef = useRef(false) const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) const isLargeMarkdown = isLargeMarkdownContent(markdownContent) const getHeaderDisplay = (filePath) => { const resolvedPath = selectedNodeKey || filePath || 'README.md' const fileName = resolvedPath.split('/').filter(Boolean).pop() || 'README.md' const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null const isFolder = Boolean(selectedNode && !selectedNode.isLeaf) const isPdf = fileName.toLowerCase().endsWith('.pdf') const FileIcon = isFolder ? FolderOutlined : isPdf ? FilePdfOutlined : FileTextOutlined return { fileName, FileIcon, isPdf, } } const navigateWithTransition = (to) => { if (document.startViewTransition) { document.startViewTransition(() => navigate(to)) return } navigate(to) } const updateSelectedParam = (path, isFile = true) => { const nextParams = new URLSearchParams(searchParams) nextParams.delete('file') nextParams.delete('selected') if (path) { nextParams.set(isFile ? 'file' : 'selected', path) } setSearchParams(nextParams, { replace: true }) } const updateFileParam = (filePath) => { updateSelectedParam(filePath, true) } const expandParentFolders = (path) => { const parts = path.split('/') const allParentPaths = [] let currentPath = '' for (let i = 0; i < parts.length - 1; i++) { currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] allParentPaths.push(currentPath) } if (allParentPaths.length > 0) { setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])]) } } const selectFolder = (folderPath, { syncUrl = false } = {}) => { setSelectedFile('') setSelectedNodeKey(folderPath) setMarkdownContent('') setTocItems([]) setViewMode('folder') expandParentFolders(`${folderPath}/placeholder`) if (syncUrl) { updateSelectedParam(folderPath, false) } } const openDocumentPath = (filePath, { syncUrl = false } = {}) => { setSelectedFile(filePath) setSelectedNodeKey(filePath) expandParentFolders(filePath) if (syncUrl) { updateFileParam(filePath) } if (filePath.toLowerCase().endsWith('.pdf')) { setPdfUrl(buildDocumentUrl(filePath)) setPdfFilename(filePath.split('/').pop()) setViewMode('pdf') } else { setViewMode('markdown') loadMarkdown(filePath) } } const buildDocumentUrl = (filePath, refreshKey = null) => { const params = new URLSearchParams() const token = localStorage.getItem('access_token') if (token) { params.set('token', token) } if (refreshKey) { params.set('refresh', String(refreshKey)) } const query = params.toString() return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}` } useEffect(() => { loadFileTree() }, [projectId]) const handleClose = () => { Modal.confirm({ title: '确认退出', content: '确定要退出当前项目页面吗?', okText: '退出', cancelText: '取消', onOk: () => { if (userRole === 'owner') { navigate('/projects/my') } else { navigate('/projects/share') } }, }) } // 监听 URL 参数变化,处理文件导航和搜索 useEffect(() => { // 只有当文件树加载完成后才处理导航,否则无法正确展开目录 if (fileTree.length === 0) return const fileParam = searchParams.get('file') const selectedParam = searchParams.get('selected') const keywordParam = searchParams.get('keyword') // 处理搜索 if (keywordParam && keywordParam !== searchKeyword) { handleSearch(keywordParam) } // 处理文件加载 if (selectedParam) { const targetNode = findNodeByKey(fileTree, selectedParam) if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) { selectFolder(selectedParam) } } else if (fileParam) { const targetNode = findNodeByKey(fileTree, fileParam) if (targetNode && !targetNode.isLeaf) { selectFolder(fileParam, { syncUrl: true }) } else if (fileParam !== selectedFile) { openDocumentPath(fileParam) } } else { // 如果没有指定文件,且当前没有选中文件,默认打开 README.md if (!selectedFile && !selectedNodeKey) { const readmeNode = findReadme(fileTree) if (readmeNode) { openDocumentPath(readmeNode.key, { syncUrl: true }) } } } }, [searchParams, fileTree]) // 处理搜索 const handleSearch = async (value) => { setSearchKeyword(value) if (!value.trim()) { setMatchedFilePaths(new Set()) return } setIsSearching(true) try { const res = await searchDocuments(value, projectId) const paths = new Set(res.data.map(item => item.file_path)) setMatchedFilePaths(paths) // 自动展开匹配的节点 const keysToExpand = new Set(openKeys) res.data.forEach(item => { const parts = item.file_path.split('/') let currentPath = '' for (let i = 0; i < parts.length - 1; i++) { currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] keysToExpand.add(currentPath) } }) setOpenKeys(Array.from(keysToExpand)) } catch (error) { console.error('Search error:', error) } finally { setIsSearching(false) } } // 过滤文件树 const filteredTreeData = useMemo(() => { if (!searchKeyword.trim()) return fileTree const loop = (data) => { const result = [] for (const node of data) { const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase()) const contentMatch = matchedFilePaths.has(node.key) if (node.children) { const children = loop(node.children) if (children.length > 0 || titleMatch) { result.push({ ...node, children }) } } else { if (titleMatch || contentMatch) { result.push(node) } } } return result } return loop(fileTree) }, [fileTree, searchKeyword, matchedFilePaths]) const loadGitRepos = async () => { try { const res = await getGitRepos(projectId) setGitRepos(res.data || []) } catch (error) { console.error('Load git repos error:', error) } } // 加载文件树 const loadFileTree = async ({ throwOnError = false } = {}) => { try { const res = await getProjectTree(projectId) const data = res.data || {} const tree = data.tree || data || [] // 兼容新旧格式 const role = data.user_role || 'viewer' const name = data.project_name setFileTree(tree) setUserRole(role) setProjectName(name) return tree } catch (error) { console.error('Load file tree error:', error) if (throwOnError) { throw error } return [] } } // 查找根目录的 README.md const findReadme = (nodes) => { // 只在根目录查找 for (const node of nodes) { if (node.title === 'README.md' && node.isLeaf) { return node } } return null } const findNodeByKey = (nodes, key) => { for (const node of nodes) { if (node.key === key) { return node } if (node.children?.length) { const found = findNodeByKey(node.children, key) if (found) { return found } } } return null } // 转换文件树为菜单项 const convertTreeToMenuItems = (nodes) => { return nodes.map((node) => { const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title const labelNode = ( {titleText} {node.is_shared && } ) if (!node.isLeaf) { const isOpen = openKeys.includes(node.key) // 目录 return { key: node.key, label: labelNode, icon: isOpen ? : , onTitleClick: () => selectFolder(node.key, { syncUrl: true }), children: node.children ? convertTreeToMenuItems(node.children) : [], } } else if (node.title && node.title.endsWith('.md')) { // Markdown 文件 return { key: node.key, label: labelNode, icon: , } } else if (node.title && node.title.endsWith('.pdf')) { // PDF 文件 return { key: node.key, label: labelNode, 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: 'auto' }) } } catch (error) { console.error('Load markdown error:', error) setMarkdownContent('# 文档加载失败\n\n无法加载该文档,请稍后重试。') } finally { setLoading(false) } } // 提取 markdown 标题生成目录 useEffect(() => { if (markdownContent && !isLargeMarkdown) { let canceled = false const schedule = window.requestIdleCallback || ((cb) => window.setTimeout(cb, 1)) const cancel = window.cancelIdleCallback || window.clearTimeout const taskId = schedule(() => { if (canceled) return const slugger = new GithubSlugger() const headings = [] const lines = markdownContent.split('\n') for (const line of lines) { const match = line.match(/^(#{1,6})\s+(.+)$/) if (match) { const level = match[1].length const title = match[2] // 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致 const key = slugger.slug(title) headings.push({ key: `#${key}`, href: `#${key}`, title, level, }) if (headings.length >= MAX_TOC_ITEMS) { break } } } setTocItems(headings) }) return () => { canceled = true cancel(taskId) } } else { setTocItems([]) } }, [markdownContent, isLargeMarkdown]) // 从知识库引用跳转而来时(URL 带 keyword),文档加载完成后滚动到第一个高亮处 useEffect(() => { if (loading || !searchKeyword || !markdownContent) return if (viewMode !== 'markdown') return let canceled = false const timer = window.setTimeout(() => { if (canceled) return const container = contentRef.current const target = container?.querySelector('.search-highlight') if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'center' }) // 临时强调被引用的位置,短暂后淡出 target.classList.add('cited-highlight-flash') window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400) } }, 260) return () => { canceled = true window.clearTimeout(timer) } }, [loading, markdownContent, searchKeyword, viewMode]) // 处理菜单点击 const handleMenuClick = ({ key }) => { const node = findNodeByKey(fileTree, key) if (!node) return if (!node.isLeaf) { selectFolder(key, { syncUrl: true }) return } openDocumentPath(key, { syncUrl: true }) } const scrollContentToTop = () => { if (isLargeMarkdown) { largeMarkdownRef.current?.scrollToTop() return } if (contentRef.current) { contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) } } const handleExportMarkdownPDF = () => { if (!selectedFile) return let url = getExportPdfUrl(projectId, selectedFile) const token = localStorage.getItem('access_token') if (token) { url += `&token=${encodeURIComponent(token)}` } window.open(url, '_blank') } // 解析相对路径 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('/') } const normalizeMarkdownHref = (href) => { if (!href) return href const [pathPart, hashPart = ''] = href.split('#') const [rawPath, searchPart = ''] = pathPart.split('?') const decodedPath = rawPath .split('/') .map((part) => { try { return decodeURIComponent(part) } catch (e) { return part } }) .join('/') const rebuilt = searchPart ? `${decodedPath}?${searchPart}` : decodedPath return hashPart ? `${rebuilt}#${hashPart}` : rebuilt } const isExternalHref = (href) => { return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//'))) } // 处理markdown内部链接点击 const handleMarkdownLink = (e, href) => { const normalizedHref = normalizeMarkdownHref(href) // 检查是否是外部链接 if (!normalizedHref || isExternalHref(normalizedHref)) { return // 外部链接,允许默认行为 } // 检查是否是锚点链接 if (normalizedHref.startsWith('#')) { return // 锚点链接,允许默认行为 } // 检查是否是文档文件(.md 或 .pdf) const pathOnly = normalizedHref.split(/[?#]/)[0] const isMd = pathOnly.endsWith('.md') const isPdf = pathOnly.toLowerCase().endsWith('.pdf') if (!isMd && !isPdf) { return // 不是文档文件,允许默认行为 } // 阻止默认跳转 e.preventDefault() // 解析路径 let targetPath if (pathOnly.startsWith('.') || pathOnly.startsWith('..')) { // 真正的相对路径,相对于当前文件 targetPath = resolveRelativePath(selectedFile, pathOnly) } else { // 项目内绝对路径(由编辑器生成),相对于项目根目录 targetPath = pathOnly.startsWith('/') ? pathOnly.substring(1) : pathOnly } // 自动展开父目录 const lastSlashIndex = targetPath.lastIndexOf('/') if (lastSlashIndex !== -1) { const parentPath = targetPath.substring(0, lastSlashIndex) 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) setSelectedNodeKey(targetPath) updateFileParam(targetPath) if (isPdf) { // PDF文件:切换到PDF模式 setPdfUrl(buildDocumentUrl(targetPath)) 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 ( <>

{projectName}

{/* 只有 owner/admin/editor 可以编辑和Git操作 */} {userRole !== 'viewer' ? ( { if (mode === 'edit' && !modeSwitchingRef.current) { modeSwitchingRef.current = true setModeSwitchValue('edit') setTimeout(() => { handleEdit() }, 160) } }} /> ) : (
)} {userRole !== 'viewer' && renderGitActions()}
{/* 搜索框 */}
setSearchKeyword(e.target.value)} onSearch={handleSearch} loading={isSearching} enterButton />
{filteredTreeData.length > 0 ? ( ) : (
)} {/* 右侧内容区 */}
{(() => { const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile) return ( <>
{fileName}
{viewMode === 'pdf' &&
} {viewMode === 'markdown' && ( )} ) })()}
{loading ? (
加载中...
) : viewMode === 'pdf' ? ( ) : viewMode === 'folder' ? (
已选择文件夹,请从左侧选择 Markdown 或 PDF 文件查看。
) : isLargeMarkdown ? ( } /> ) : (
{markdownContent}
)}
{viewMode === 'markdown' && !isLargeMarkdown && ( contentRef.current} renderTitle={(item, keyword) => } /> )} {/* 分享模态框 */} {/* ... keeping the modal ... */} setShareModalVisible(false)} footer={null} width={500} >
{shareInfo?.share_url ? ( <>
} />
访问密码保护
{hasPassword && ( setPassword(e.target.value)} /> )} ) : ( <>
当前文件尚未创建独立分享。
)} {shareInfo?.share_url && ( )}
) } export default DocumentPage