nex_docus/frontend/src/pages/Document/DocumentPage.jsx

536 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space } from 'antd'
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined } 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 { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import PDFViewer from '@/components/PDFViewer/PDFViewer'
import './DocumentPage.css'
const { Sider, Content } = Layout
function DocumentPage() {
const { projectId } = useParams()
const navigate = useNavigate()
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 [shareModalVisible, setShareModalVisible] = useState(false)
const [shareInfo, setShareInfo] = useState(null)
const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('')
const [userRole, setUserRole] = useState('viewer') // 用户角色owner/admin/editor/viewer
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
const contentRef = useRef(null)
useEffect(() => {
loadFileTree()
}, [projectId])
// 加载文件树
const loadFileTree = async () => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
const tree = data.tree || data || [] // 兼容新旧格式
const role = data.user_role || 'viewer'
setFileTree(tree)
setUserRole(role)
// 默认打开 README.md
const readmeNode = findReadme(tree)
if (readmeNode) {
setSelectedFile(readmeNode.key)
loadMarkdown(readmeNode.key)
}
} catch (error) {
console.error('Load file tree error:', error)
}
}
// 查找根目录的 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')) {
// Markdown 文件
return {
key: node.key,
label: node.title.replace('.md', ''),
icon: <FileTextOutlined />,
}
} else if (node.title && node.title.endsWith('.pdf')) {
// PDF 文件
return {
key: node.key,
label: node.title,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
}
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: 'smooth' })
}
} catch (error) {
console.error('Load markdown error:', error)
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)
// 检查是否是PDF文件
if (key.toLowerCase().endsWith('.pdf')) {
// 显示PDF - 添加token到URL
let url = getDocumentUrl(projectId, key)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(key.split('/').pop())
setViewMode('pdf')
} else {
// 加载Markdown文件
setViewMode('markdown')
loadMarkdown(key)
}
}
// 解析相对路径
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('/')
}
// 处理markdown内部链接点击
const handleMarkdownLink = (e, href) => {
// 检查是否是外部链接
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
return // 外部链接,允许默认行为
}
// 检查是否是锚点链接
if (href.startsWith('#')) {
return // 锚点链接,允许默认行为
}
// 检查是否是文档文件(.md 或 .pdf
const isMd = href.endsWith('.md')
const isPdf = href.toLowerCase().endsWith('.pdf')
if (!isMd && !isPdf) {
return // 不是文档文件,允许默认行为
}
// 阻止默认跳转
e.preventDefault()
// 先解码 href因为 Markdown 中的链接可能已经是 URL 编码的)
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch (e) {
// 解码失败,使用原始值
}
// 解析相对路径
const targetPath = resolveRelativePath(selectedFile, decodedHref)
// 自动展开父目录
const parentPath = targetPath.substring(0, targetPath.lastIndexOf('/'))
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)
if (isPdf) {
// PDF文件切换到PDF模式
let url = getDocumentUrl(projectId, targetPath)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(targetPath.split('/').pop())
setViewMode('pdf')
} else {
// Markdown文件加载内容
setViewMode('markdown')
loadMarkdown(targetPath)
}
}
// 进入编辑模式
const handleEdit = () => {
navigate(`/projects/${projectId}/editor`)
}
// 打开分享设置
const handleShare = async () => {
try {
const res = await getProjectShareInfo(projectId)
setShareInfo(res.data)
setHasPassword(res.data.has_password)
setPassword(res.data.access_pass || '') // 显示已设置的密码
setShareModalVisible(true)
} catch (error) {
console.error('Get share info error:', error)
message.error('获取分享信息失败')
}
}
// 复制分享链接
const handleCopyLink = () => {
if (!shareInfo) return
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
navigator.clipboard.writeText(fullUrl)
message.success('分享链接已复制')
}
// 切换密码保护
const handlePasswordToggle = async (checked) => {
if (!checked) {
// 取消密码
try {
await updateShareSettings(projectId, { access_pass: null })
setHasPassword(false)
setPassword('')
message.success('已取消访问密码')
// 刷新分享信息
const res = await getProjectShareInfo(projectId)
setShareInfo(res.data)
} catch (error) {
console.error('Update settings error:', error)
message.error('操作失败')
}
} else {
setHasPassword(true)
}
}
// 保存密码
const handleSavePassword = async () => {
if (!password.trim()) {
message.warning('请输入访问密码')
return
}
try {
await updateShareSettings(projectId, { access_pass: password })
message.success('访问密码已设置')
// 刷新分享信息
const res = await getProjectShareInfo(projectId)
setShareInfo(res.data)
setHasPassword(true)
} catch (error) {
console.error('Save password error:', error)
message.error('设置密码失败')
}
}
const menuItems = convertTreeToMenuItems(fileTree)
return (
<div className="project-docs-page">
<Layout className="docs-layout">
{/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header">
<h2>项目文档浏览模式</h2>
<div className="docs-sider-actions">
{/* 只有 owner/admin/editor 可以编辑 */}
{userRole !== 'viewer' && (
<Tooltip title="编辑模式">
<Button
type="link"
size="middle"
icon={<EditOutlined />}
onClick={handleEdit}
/>
</Tooltip>
)}
<Tooltip title="分享">
<Button
type="text"
size="middle"
icon={<ShareAltOutlined />}
onClick={handleShare}
/>
</Tooltip>
<Tooltip title="设置">
<Button
type="text"
size="middle"
icon={<SettingOutlined />}
onClick={() => message.info('设置功能开发中')}
/>
</Tooltip>
</div>
</div>
<Menu
mode="inline"
selectedKeys={[selectedFile]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
className="docs-menu"
/>
</Sider>
{/* 右侧内容区 */}
<Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? (
<div className="docs-loading">
<Spin size="large">
<div style={{ marginTop: 16 }}>加载中...</div>
</Spin>
</div>
) : viewMode === 'pdf' ? (
<PDFViewer
url={pdfUrl}
filename={pdfFilename}
/>
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
components={{
a: ({ node, href, children, ...props }) => (
<a
href={href}
onClick={(e) => handleMarkdownLink(e, href)}
{...props}
>
{children}
</a>
),
}}
>
{markdownContent}
</ReactMarkdown>
</div>
)}
</div>
{/* 返回顶部按钮 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && (
<FloatButton
icon={<VerticalAlignTopOutlined />}
type="primary"
style={{ right: tocCollapsed ? 24 : 280 }}
onClick={() => {
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}}
/>
)}
</Content>
{/* 右侧TOC面板 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && !tocCollapsed && (
<Sider width={250} theme="light" className="docs-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展开按钮 */}
{tocCollapsed && (
<Button
type="primary"
icon={<MenuUnfoldOutlined />}
className="toc-toggle-btn"
onClick={() => setTocCollapsed(false)}
>
文档索引
</Button>
)}
</Layout>
{/* 分享模态框 */}
<Modal
title="分享项目"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={500}
>
{shareInfo && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>分享链接</label>
<Input
value={`${window.location.origin}${shareInfo.share_url}`}
readOnly
addonAfter={
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
}
/>
</div>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
{hasPassword && (
<div>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="primary"
onClick={handleSavePassword}
style={{ marginTop: 8 }}
>
保存密码
</Button>
</div>
)}
</Space>
)}
</Modal>
</div>
)
}
export default DocumentPage