nex_docus/frontend/src/pages/Preview/PreviewPage.jsx

431 lines
13 KiB
React
Raw Normal View History

2025-12-20 11:18:59 +00:00
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, message, Drawer, Anchor } from 'antd'
2025-12-31 05:44:03 +00:00
import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined } from '@ant-design/icons'
2025-12-20 11:18:59 +00:00
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'
2025-12-31 05:44:03 +00:00
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share'
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
function PreviewPage() {
const { projectId } = useParams()
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)
2025-12-31 05:44:03 +00:00
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
2025-12-20 11:18:59 +00:00
const contentRef = useRef(null)
// 检测是否为移动设备
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)
// 默认打开 README.md
const readmeNode = findReadme(tree)
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)
}
}
}
// 查找根目录的 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: <FolderOutlined />,
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
} else if (node.title && node.title.endsWith('.md')) {
return {
key: node.key,
label: node.title.replace('.md', ''),
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)
}
// 加载 markdown 文件
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)
}
}
// 提取 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]
const key = title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
headings.push({
key: `#${key}`,
href: `#${key}`,
title,
level,
})
}
})
setTocItems(headings)
}
}, [markdownContent])
// 处理菜单点击
const handleMenuClick = ({ key }) => {
setSelectedFile(key)
2025-12-31 05:44:03 +00:00
// 检查是否是PDF文件
if (key.toLowerCase().endsWith('.pdf')) {
// 显示PDF - 使用预览API添加必要的参数
let url = getPreviewDocumentUrl(projectId, key)
const params = []
// 如果有密码,添加密码参数
if (accessPassword) {
params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
}
// 如果用户已登录私密项目需要添加token参数
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 {
// 加载Markdown文件
setViewMode('markdown')
loadMarkdown(key)
}
2025-12-20 11:18:59 +00:00
}
const menuItems = convertTreeToMenuItems(fileTree)
// 侧边栏内容
const SiderContent = () => (
<>
<div className="preview-sider-header">
<h2>{projectInfo?.name || '项目预览'}</h2>
{projectInfo?.description && (
<p className="preview-project-desc">{projectInfo.description}</p>
)}
</div>
<Menu
mode="inline"
selectedKeys={[selectedFile]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
className="preview-menu"
/>
</>
)
return (
<div className="preview-page">
<Layout className="preview-layout">
{/* 移动端使用 Drawer桌面端使用 Sider */}
{isMobile ? (
<>
<Button
type="primary"
icon={<MenuOutlined />}
className="mobile-menu-btn"
onClick={() => setMobileDrawerVisible(true)}
>
文档索引
</Button>
<Drawer
title={projectInfo?.name || '项目预览'}
placement="left"
onClose={() => setMobileDrawerVisible(false)}
open={mobileDrawerVisible}
width="80%"
>
<Menu
mode="inline"
selectedKeys={[selectedFile]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
className="preview-menu"
/>
</Drawer>
</>
) : (
<Sider
width={280}
className="preview-sider"
theme="light"
collapsed={siderCollapsed}
collapsedWidth={0}
>
<SiderContent />
</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
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
>
{markdownContent}
</ReactMarkdown>
</div>
)}
</div>
2025-12-31 05:44:03 +00:00
{/* 返回顶部按钮 - 仅在markdown模式显示 */}
{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
{/* 右侧TOC面板仅桌面端且markdown模式显示 */}
{!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' }} />
{item.title}
</div>
),
}))}
/>
) : (
<div className="toc-empty">当前文档无标题</div>
)}
</div>
</Sider>
)}
</Layout>
{/* TOC展开按钮仅桌面端 */}
{!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>
)
}
export default PreviewPage