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 ? (
<>
}
className="mobile-menu-btn"
onClick={() => setMobileDrawerVisible(true)}
>
目录索引
{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 ? (
) : (
)}
>
) : (
{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 && (
文档索引
}
onClick={() => setTocCollapsed(true)}
/>
{tocItems.length > 0 ? (
contentRef.current}
items={tocItems.map((item) => ({
key: item.key,
href: item.href,
title: (
),
}))}
/>
) : (
当前文档无标题
)}
)}
{!isMobile && tocCollapsed && (
}
className="toc-toggle-btn"
onClick={() => setTocCollapsed(false)}
>
文档索引
)}
访问验证
}
open={passwordModalVisible}
onOk={handleVerifyPassword}
onCancel={() => setPasswordModalVisible(false)}
okText="验证"
cancelText="取消"
maskClosable={false}
>
该项目需要访问密码,请输入密码后继续浏览。
setPassword(e.target.value)}
onPressEnter={handleVerifyPassword}
prefix={}
/>
)
}
export default PreviewPage