2026-01-22 10:53:30 +00:00
|
|
|
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
2026-01-23 07:00:03 +00:00
|
|
|
|
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'
|
2026-01-22 10:53:30 +00:00
|
|
|
|
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'
|
2025-12-20 11:18:59 +00:00
|
|
|
|
import rehypeSlug from 'rehype-slug'
|
|
|
|
|
|
import 'highlight.js/styles/github.css'
|
2026-01-23 07:00:03 +00:00
|
|
|
|
import Mark from 'mark.js'
|
|
|
|
|
|
import Highlighter from 'react-highlight-words'
|
|
|
|
|
|
import GithubSlugger from 'github-slugger'
|
2025-12-31 05:44:03 +00:00
|
|
|
|
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share'
|
2026-01-23 07:00:03 +00:00
|
|
|
|
import { searchDocuments } from '@/api/search'
|
2026-01-01 14:41:10 +00:00
|
|
|
|
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
2025-12-20 11:18:59 +00:00
|
|
|
|
import './PreviewPage.css'
|
|
|
|
|
|
|
|
|
|
|
|
const { Sider, Content } = Layout
|
|
|
|
|
|
|
2026-01-23 07:00:03 +00:00
|
|
|
|
// 高亮组件 (用于 Tree)
|
|
|
|
|
|
const HighlightText = ({ text, keyword }) => {
|
|
|
|
|
|
if (!keyword || !text) return text;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Highlighter
|
|
|
|
|
|
highlightClassName="search-highlight"
|
|
|
|
|
|
searchWords={[keyword]}
|
|
|
|
|
|
autoEscape={true}
|
|
|
|
|
|
textToHighlight={text}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
function PreviewPage() {
|
|
|
|
|
|
const { projectId } = useParams()
|
2026-01-23 07:00:03 +00:00
|
|
|
|
const [searchParams] = useSearchParams()
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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('')
|
2026-01-23 07:00:03 +00:00
|
|
|
|
const [accessPassword, setAccessPassword] = useState(null)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const [siderCollapsed, setSiderCollapsed] = useState(false)
|
|
|
|
|
|
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false)
|
|
|
|
|
|
const [isMobile, setIsMobile] = useState(false)
|
2025-12-31 05:44:03 +00:00
|
|
|
|
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
|
|
|
|
|
|
const [pdfUrl, setPdfUrl] = useState('')
|
|
|
|
|
|
const [pdfFilename, setPdfFilename] = useState('')
|
2026-01-23 07:00:03 +00:00
|
|
|
|
const [viewMode, setViewMode] = useState('markdown')
|
|
|
|
|
|
|
|
|
|
|
|
// 搜索相关
|
|
|
|
|
|
const [searchKeyword, setSearchKeyword] = useState('')
|
|
|
|
|
|
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
|
|
|
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const contentRef = useRef(null)
|
2026-01-23 07:00:03 +00:00
|
|
|
|
const viewerRef = useRef(null)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2026-01-22 10:53:30 +00:00
|
|
|
|
// ByteMD 插件配置
|
|
|
|
|
|
const plugins = useMemo(() => [
|
|
|
|
|
|
gfm(),
|
|
|
|
|
|
highlight(),
|
|
|
|
|
|
breaks(),
|
|
|
|
|
|
frontmatter(),
|
|
|
|
|
|
gemoji(),
|
|
|
|
|
|
{
|
|
|
|
|
|
rehype: (p) => p.use(rehypeSlug)
|
|
|
|
|
|
}
|
|
|
|
|
|
], [])
|
|
|
|
|
|
|
2026-01-23 07:00:03 +00:00
|
|
|
|
// 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])
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
// 检测是否为移动设备
|
|
|
|
|
|
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)
|
2026-01-23 07:00:03 +00:00
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 07:00:03 +00:00
|
|
|
|
// 搜索处理
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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) => {
|
2026-01-23 07:00:03 +00:00
|
|
|
|
const labelNode = node.title.replace('.md', '')
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
if (!node.isLeaf) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: node.key,
|
|
|
|
|
|
label: node.title,
|
|
|
|
|
|
icon: <FolderOutlined />,
|
|
|
|
|
|
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (node.title && node.title.endsWith('.md')) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: node.key,
|
2026-01-23 07:00:03 +00:00
|
|
|
|
label: labelNode,
|
2025-12-20 11:18:59 +00:00
|
|
|
|
icon: <FileTextOutlined />,
|
|
|
|
|
|
}
|
2025-12-31 05:44:03 +00:00
|
|
|
|
} else if (node.title && node.title.endsWith('.pdf')) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: node.key,
|
|
|
|
|
|
label: node.title,
|
|
|
|
|
|
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
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) {
|
2026-01-23 07:00:03 +00:00
|
|
|
|
const slugger = new GithubSlugger()
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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]
|
2026-01-23 07:00:03 +00:00
|
|
|
|
// 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致
|
|
|
|
|
|
const key = slugger.slug(title)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
|
|
|
|
|
headings.push({
|
|
|
|
|
|
key: `#${key}`,
|
|
|
|
|
|
href: `#${key}`,
|
|
|
|
|
|
title,
|
|
|
|
|
|
level,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
setTocItems(headings)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [markdownContent])
|
2026-01-23 07:00:03 +00:00
|
|
|
|
|
2026-01-01 15:05:43 +00:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2026-01-22 10:53:30 +00:00
|
|
|
|
const handleContentClick = (e) => {
|
|
|
|
|
|
const target = e.target.closest('a')
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
const href = target.getAttribute('href')
|
|
|
|
|
|
if (href) {
|
|
|
|
|
|
handleMarkdownLink(e, href)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const handleMenuClick = ({ key }) => {
|
|
|
|
|
|
setSelectedFile(key)
|
2025-12-31 05:44:03 +00:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 07:00:03 +00:00
|
|
|
|
const menuItems = convertTreeToMenuItems(filteredTreeData)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="preview-page">
|
|
|
|
|
|
<Layout className="preview-layout">
|
|
|
|
|
|
{isMobile ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
icon={<MenuOutlined />}
|
|
|
|
|
|
className="mobile-menu-btn"
|
|
|
|
|
|
onClick={() => setMobileDrawerVisible(true)}
|
|
|
|
|
|
>
|
2026-01-22 10:53:30 +00:00
|
|
|
|
目录索引
|
2025-12-20 11:18:59 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Drawer
|
2026-01-23 07:00:03 +00:00
|
|
|
|
title={
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
|
<img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} />
|
|
|
|
|
|
<span>{projectInfo?.name || '项目预览'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
placement="left"
|
|
|
|
|
|
onClose={() => setMobileDrawerVisible(false)}
|
|
|
|
|
|
open={mobileDrawerVisible}
|
|
|
|
|
|
width="80%"
|
|
|
|
|
|
>
|
2026-01-23 07:00:03 +00:00
|
|
|
|
<div className="preview-sider-header" style={{ padding: '0 0 16px' }}>
|
|
|
|
|
|
{projectInfo?.description && (
|
|
|
|
|
|
<p className="preview-project-desc">{projectInfo.description}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 搜索框 */}
|
|
|
|
|
|
<div style={{ padding: '0 0 12px' }}>
|
|
|
|
|
|
<Input.Search
|
|
|
|
|
|
placeholder="搜索文档内容..."
|
|
|
|
|
|
allowClear
|
|
|
|
|
|
value={searchKeyword}
|
|
|
|
|
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
|
|
|
|
|
onSearch={handleSearch}
|
|
|
|
|
|
loading={isSearching}
|
|
|
|
|
|
enterButton
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{filteredTreeData.length > 0 ? (
|
|
|
|
|
|
<Menu
|
|
|
|
|
|
mode="inline"
|
|
|
|
|
|
selectedKeys={[selectedFile]}
|
|
|
|
|
|
openKeys={openKeys}
|
|
|
|
|
|
onOpenChange={setOpenKeys}
|
|
|
|
|
|
items={menuItems}
|
|
|
|
|
|
onClick={handleMenuClick}
|
|
|
|
|
|
className="preview-menu"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
|
|
|
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
</Drawer>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Sider
|
|
|
|
|
|
width={280}
|
|
|
|
|
|
className="preview-sider"
|
|
|
|
|
|
theme="light"
|
|
|
|
|
|
collapsed={siderCollapsed}
|
|
|
|
|
|
collapsedWidth={0}
|
|
|
|
|
|
>
|
2026-01-23 07:00:03 +00:00
|
|
|
|
<div className="preview-sider-header">
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
|
|
|
|
<img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} />
|
|
|
|
|
|
<h2 style={{ margin: 0 }}>{projectInfo?.name || '项目预览'}</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{projectInfo?.description && (
|
|
|
|
|
|
<p className="preview-project-desc">{projectInfo.description}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 搜索框 */}
|
|
|
|
|
|
<div style={{ padding: '12px 16px 4px' }}>
|
|
|
|
|
|
<Input.Search
|
|
|
|
|
|
placeholder="搜索文档内容..."
|
|
|
|
|
|
allowClear
|
|
|
|
|
|
value={searchKeyword}
|
|
|
|
|
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
|
|
|
|
|
onSearch={handleSearch}
|
|
|
|
|
|
loading={isSearching}
|
|
|
|
|
|
enterButton
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{filteredTreeData.length > 0 ? (
|
|
|
|
|
|
<Menu
|
|
|
|
|
|
mode="inline"
|
|
|
|
|
|
selectedKeys={[selectedFile]}
|
|
|
|
|
|
openKeys={openKeys}
|
|
|
|
|
|
onOpenChange={setOpenKeys}
|
|
|
|
|
|
items={menuItems}
|
|
|
|
|
|
onClick={handleMenuClick}
|
|
|
|
|
|
className="preview-menu"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
|
|
|
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
</Sider>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Layout className="preview-content-layout">
|
|
|
|
|
|
<Content className="preview-content" ref={contentRef}>
|
2025-12-31 05:44:03 +00:00
|
|
|
|
<div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="preview-loading">
|
2025-12-31 05:44:03 +00:00
|
|
|
|
<Spin size="large">
|
|
|
|
|
|
<div style={{ marginTop: 16 }}>加载中...</div>
|
|
|
|
|
|
</Spin>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
</div>
|
2025-12-31 05:44:03 +00:00
|
|
|
|
) : viewMode === 'pdf' ? (
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<VirtualPDFViewer
|
2025-12-31 05:44:03 +00:00
|
|
|
|
url={pdfUrl}
|
|
|
|
|
|
filename={pdfFilename}
|
|
|
|
|
|
/>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
) : (
|
2026-01-23 07:00:03 +00:00
|
|
|
|
<div className="markdown-body" onClick={handleContentClick} ref={viewerRef}>
|
2026-01-22 10:53:30 +00:00
|
|
|
|
<Viewer
|
|
|
|
|
|
value={markdownContent}
|
|
|
|
|
|
plugins={plugins}
|
|
|
|
|
|
/>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-31 05:44:03 +00:00
|
|
|
|
{viewMode === 'markdown' && (
|
|
|
|
|
|
<FloatButton
|
|
|
|
|
|
icon={<VerticalAlignTopOutlined />}
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
style={{ right: tocCollapsed || isMobile ? 24 : 280 }}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (contentRef.current) {
|
|
|
|
|
|
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
</Content>
|
|
|
|
|
|
|
2025-12-31 05:44:03 +00:00
|
|
|
|
{!isMobile && viewMode === 'markdown' && !tocCollapsed && (
|
2025-12-20 11:18:59 +00:00
|
|
|
|
<Sider width={250} theme="light" className="preview-toc-sider">
|
|
|
|
|
|
<div className="toc-header">
|
|
|
|
|
|
<h3>文档索引</h3>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
icon={<MenuFoldOutlined />}
|
|
|
|
|
|
onClick={() => setTocCollapsed(true)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="toc-content">
|
|
|
|
|
|
{tocItems.length > 0 ? (
|
|
|
|
|
|
<Anchor
|
|
|
|
|
|
affix={false}
|
|
|
|
|
|
offsetTop={0}
|
|
|
|
|
|
getContainer={() => contentRef.current}
|
|
|
|
|
|
items={tocItems.map((item) => ({
|
|
|
|
|
|
key: item.key,
|
|
|
|
|
|
href: item.href,
|
|
|
|
|
|
title: (
|
|
|
|
|
|
<div style={{ paddingLeft: `${(item.level - 1) * 12}px`, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
|
|
|
|
<FileTextOutlined style={{ fontSize: '12px', color: '#8c8c8c' }} />
|
2026-01-23 07:00:03 +00:00
|
|
|
|
<HighlightText text={item.title} keyword={searchKeyword} />
|
2025-12-20 11:18:59 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
}))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="toc-empty">当前文档无标题</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Sider>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
|
|
|
|
|
|
{!isMobile && tocCollapsed && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
icon={<MenuUnfoldOutlined />}
|
|
|
|
|
|
className="toc-toggle-btn"
|
|
|
|
|
|
onClick={() => setTocCollapsed(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
文档索引
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title={
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
|
<LockOutlined />
|
|
|
|
|
|
<span>访问验证</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
open={passwordModalVisible}
|
|
|
|
|
|
onOk={handleVerifyPassword}
|
|
|
|
|
|
onCancel={() => setPasswordModalVisible(false)}
|
|
|
|
|
|
okText="验证"
|
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
|
maskClosable={false}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ marginTop: 16 }}>
|
|
|
|
|
|
<p>该项目需要访问密码,请输入密码后继续浏览。</p>
|
|
|
|
|
|
<Input.Password
|
|
|
|
|
|
placeholder="请输入访问密码"
|
|
|
|
|
|
value={password}
|
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
|
onPressEnter={handleVerifyPassword}
|
|
|
|
|
|
prefix={<LockOutlined />}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 10:53:30 +00:00
|
|
|
|
export default PreviewPage
|