import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useSearchParams } from 'react-router-dom' import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, message, Drawer, Anchor, Empty } from 'antd' import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined } from '@ant-design/icons' import { Viewer } from '@bytemd/react' import gfm from '@bytemd/plugin-gfm' import highlight from '@bytemd/plugin-highlight' import breaks from '@bytemd/plugin-breaks' import frontmatter from '@bytemd/plugin-frontmatter' import gemoji from '@bytemd/plugin-gemoji' import 'bytemd/dist/index.css' import rehypeSlug from 'rehype-slug' import 'highlight.js/styles/github.css' import Mark from 'mark.js' import Highlighter from 'react-highlight-words' import GithubSlugger from 'github-slugger' import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share' import { searchDocuments } from '@/api/search' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import './PreviewPage.css' const { Sider, Content } = Layout // 高亮组件 (用于 Tree) const HighlightText = ({ text, keyword }) => { if (!keyword || !text) return text; return ( ) } function PreviewPage() { const { projectId } = useParams() const [searchParams] = useSearchParams() const [projectInfo, setProjectInfo] = useState(null) 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 [passwordModalVisible, setPasswordModalVisible] = useState(false) const [password, setPassword] = useState('') const [accessPassword, setAccessPassword] = useState(null) const [siderCollapsed, setSiderCollapsed] = useState(false) const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false) const [isMobile, setIsMobile] = useState(false) const [pdfViewerVisible, setPdfViewerVisible] = useState(false) const [pdfUrl, setPdfUrl] = useState('') const [pdfFilename, setPdfFilename] = useState('') const [viewMode, setViewMode] = useState('markdown') // 搜索相关 const [searchKeyword, setSearchKeyword] = useState('') const [matchedFilePaths, setMatchedFilePaths] = useState(new Set()) const [isSearching, setIsSearching] = useState(false) const contentRef = useRef(null) const viewerRef = useRef(null) // ByteMD 插件配置 const plugins = useMemo(() => [ gfm(), highlight(), breaks(), frontmatter(), gemoji(), { rehype: (p) => p.use(rehypeSlug) } ], []) // mark.js 高亮 useEffect(() => { if (viewerRef.current && viewMode === 'markdown') { const instance = new Mark(viewerRef.current) instance.unmark() if (searchKeyword.trim()) { instance.mark(searchKeyword, { element: 'span', className: 'search-highlight', exclude: ['pre', 'code', '.toc-content'] }) } } }, [markdownContent, searchKeyword, viewMode]) // 检测是否为移动设备 useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth < 768) } checkMobile() window.addEventListener('resize', checkMobile) return () => window.removeEventListener('resize', checkMobile) }, []) useEffect(() => { loadProjectInfo() }, [projectId]) // 加载项目基本信息 const loadProjectInfo = async () => { try { const res = await getPreviewInfo(projectId) const info = res.data setProjectInfo(info) if (info.has_password) { setPasswordModalVisible(true) } else { loadFileTree() } } catch (error) { console.error('Load project info error:', error) message.error('项目不存在或已被删除') } } // 验证密码 const handleVerifyPassword = async () => { if (!password.trim()) { message.warning('请输入访问密码') return } try { await verifyAccessPassword(projectId, password) setAccessPassword(password) setPasswordModalVisible(false) loadFileTree(password) message.success('验证成功') } catch (error) { message.error('访问密码错误') } } // 加载文件树 const loadFileTree = async (pwd = null) => { try { const res = await getPreviewTree(projectId, pwd || accessPassword) const tree = res.data || [] setFileTree(tree) const readmeNode = findReadme(tree) // Check query params const fileParam = searchParams.get('file') const keywordParam = searchParams.get('keyword') if (keywordParam) { handleSearch(keywordParam) } if (fileParam) { // Deep link to file if (fileParam.toLowerCase().endsWith('.pdf')) { let url = getPreviewDocumentUrl(projectId, fileParam) // ... params logic repeated from handleMenuClick ... // Simplify: just call logic or set state // Since we need token/password logic, let's reuse handleMenuClick logic if possible or copy it. // For simplicity, just set selection and let user click? No, auto load. // Copy logic for PDF url construction const params = [] if (pwd || accessPassword) params.push(`access_pass=${encodeURIComponent(pwd || accessPassword)}`) const token = localStorage.getItem('access_token') if (token) params.push(`token=${encodeURIComponent(token)}`) if (params.length > 0) url += `?${params.join('&')}` setSelectedFile(fileParam) setPdfUrl(url) setPdfFilename(fileParam.split('/').pop()) setViewMode('pdf') } else { setSelectedFile(fileParam) loadMarkdown(fileParam, pwd || accessPassword) } // Expand tree to file const parts = fileParam.split('/') const allParentPaths = [] let currentPath = '' for (let i = 0; i < parts.length - 1; i++) { currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] allParentPaths.push(currentPath) } setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])]) } else if (readmeNode) { setSelectedFile(readmeNode.key) loadMarkdown(readmeNode.key, pwd || accessPassword) } } catch (error) { console.error('Load file tree error:', error) if (error.response?.status === 403) { message.error('访问密码错误或已过期') setPasswordModalVisible(true) } } } // 搜索处理 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) // 自动展开匹配的节点 (Assuming this comment might be there or not, better context: keysToExpand) 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 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) => { const labelNode = node.title.replace('.md', '') 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')) { return { key: node.key, label: labelNode, icon: , } } else if (node.title && node.title.endsWith('.pdf')) { return { key: node.key, label: node.title, icon: , } } return null }).filter(Boolean) } const loadMarkdown = async (filePath, pwd = null) => { setLoading(true) setTocItems([]) try { const res = await getPreviewFile(projectId, filePath, pwd || accessPassword) setMarkdownContent(res.data?.content || '') if (isMobile) { setMobileDrawerVisible(false) } if (contentRef.current) { contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) } } catch (error) { console.error('Load markdown error:', error) if (error.response?.status === 403) { message.error('访问密码错误或已过期') setPasswordModalVisible(true) } else { setMarkdownContent('# 文档加载失败\n\n无法加载该文档,请稍后重试。') } } finally { setLoading(false) } } useEffect(() => { if (markdownContent) { const slugger = new GithubSlugger() 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] // 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致 const key = slugger.slug(title) headings.push({ key: `#${key}`, href: `#${key}`, title, level, }) } }) setTocItems(headings) } }, [markdownContent]) const resolveRelativePath = (currentPath, relativePath) => { if (relativePath.startsWith('/')) { return relativePath.substring(1) } const lastSlashIndex = currentPath.lastIndexOf('/') const currentDir = lastSlashIndex !== -1 ? currentPath.substring(0, lastSlashIndex) : '' 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 handleMarkdownLink = (e, href) => { if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) { return } const isMd = href.endsWith('.md') const isPdf = href.toLowerCase().endsWith('.pdf') if (!isMd && !isPdf) return e.preventDefault() let decodedHref = href try { decodedHref = decodeURIComponent(href) } catch (err) { } const targetPath = resolveRelativePath(selectedFile, decodedHref) const lastSlashIndex = targetPath.lastIndexOf('/') const parentPath = lastSlashIndex !== -1 ? 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])]) } handleMenuClick({ key: targetPath }) } const handleContentClick = (e) => { const target = e.target.closest('a') if (target) { const href = target.getAttribute('href') if (href) { handleMarkdownLink(e, href) } } } const handleMenuClick = ({ key }) => { setSelectedFile(key) if (key.toLowerCase().endsWith('.pdf')) { let url = getPreviewDocumentUrl(projectId, key) const params = [] if (accessPassword) { params.push(`access_pass=${encodeURIComponent(accessPassword)}`) } const token = localStorage.getItem('access_token') if (token) { params.push(`token=${encodeURIComponent(token)}`) } if (params.length > 0) { url += `?${params.join('&')}` } setPdfUrl(url) setPdfFilename(key.split('/').pop()) setViewMode('pdf') } else { setViewMode('markdown') loadMarkdown(key) } } const menuItems = convertTreeToMenuItems(filteredTreeData) return (
{isMobile ? ( <> logo {projectInfo?.name || '项目预览'}
} placement="left" onClose={() => setMobileDrawerVisible(false)} open={mobileDrawerVisible} width="80%" >
{projectInfo?.description && (

{projectInfo.description}

)}
{/* 搜索框 */}
setSearchKeyword(e.target.value)} onSearch={handleSearch} loading={isSearching} enterButton />
{filteredTreeData.length > 0 ? ( ) : (
)} ) : (
logo

{projectInfo?.name || '项目预览'}

{projectInfo?.description && (

{projectInfo.description}

)}
{/* 搜索框 */}
setSearchKeyword(e.target.value)} onSearch={handleSearch} loading={isSearching} enterButton />
{filteredTreeData.length > 0 ? ( ) : (
)} )}
{loading ? (
加载中...
) : viewMode === 'pdf' ? ( ) : (
)}
{viewMode === 'markdown' && ( } type="primary" style={{ right: tocCollapsed || isMobile ? 24 : 280 }} onClick={() => { if (contentRef.current) { contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) } }} /> )}
{!isMobile && viewMode === 'markdown' && !tocCollapsed && (

文档索引

{tocItems.length > 0 ? ( contentRef.current} items={tocItems.map((item) => ({ key: item.key, href: item.href, title: (
), }))} /> ) : (
当前文档无标题
)}
)}
{!isMobile && tocCollapsed && ( )} 访问验证 } open={passwordModalVisible} onOk={handleVerifyPassword} onCancel={() => setPasswordModalVisible(false)} okText="验证" cancelText="取消" maskClosable={false} >

该项目需要访问密码,请输入密码后继续浏览。

setPassword(e.target.value)} onPressEnter={handleVerifyPassword} prefix={} />
) } export default PreviewPage