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

958 lines
30 KiB
React
Raw Normal View History

2026-01-23 07:00:03 +00:00
import { useState, useEffect, useRef, useMemo } from 'react'
2026-01-22 11:52:29 +00:00
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
2026-01-23 07:00:03 +00:00
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown, Empty } from 'antd'
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined } 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'
2026-01-23 07:00:03 +00:00
import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger'
2025-12-31 05:44:03 +00:00
import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file'
2026-01-05 10:50:29 +00:00
import { gitPull, gitPush, getGitRepos } from '@/api/project'
2025-12-20 11:18:59 +00:00
import { getProjectShareInfo, updateShareSettings } 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'
2026-01-05 10:50:29 +00:00
import Toast from '@/components/Toast/Toast'
2025-12-20 11:18:59 +00:00
import './DocumentPage.css'
const { Sider, Content } = Layout
2026-01-23 07:00:03 +00:00
// 高亮渲染组件
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 DocumentPage() {
const { projectId } = useParams()
const navigate = useNavigate()
2026-01-22 11:52:29 +00:00
const [searchParams] = useSearchParams()
2025-12-20 11:18:59 +00:00
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('')
2026-01-23 07:00:03 +00:00
const [userRole, setUserRole] = useState('viewer')
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')
2026-01-05 10:50:29 +00:00
const [gitRepos, setGitRepos] = useState([])
2026-01-22 11:52:29 +00:00
const [projectName, setProjectName] = useState('')
2026-01-23 07:00:03 +00:00
// 搜索相关状态
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)
useEffect(() => {
loadFileTree()
}, [projectId])
2026-01-05 10:50:29 +00:00
useEffect(() => {
if (userRole && userRole !== 'viewer') {
loadGitRepos()
}
}, [projectId, userRole])
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)
// 自动展开匹配的节点
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])
2026-01-05 10:50:29 +00:00
const loadGitRepos = async () => {
try {
const res = await getGitRepos(projectId)
setGitRepos(res.data || [])
} catch (error) {
console.error('Load git repos error:', error)
}
}
2025-12-20 11:18:59 +00:00
// 加载文件树
const loadFileTree = async () => {
try {
const res = await getProjectTree(projectId)
2025-12-29 12:53:50 +00:00
const data = res.data || {}
const tree = data.tree || data || [] // 兼容新旧格式
const role = data.user_role || 'viewer'
2026-01-22 11:52:29 +00:00
const name = data.project_name
2025-12-29 12:53:50 +00:00
2025-12-20 11:18:59 +00:00
setFileTree(tree)
2025-12-29 12:53:50 +00:00
setUserRole(role)
2026-01-13 13:21:47 +00:00
setProjectName(name)
2025-12-20 11:18:59 +00:00
2026-01-22 11:52:29 +00:00
// 检查 URL 是否指定了文件
const fileParam = searchParams.get('file')
2026-01-23 07:00:03 +00:00
const keywordParam = searchParams.get('keyword')
if (keywordParam) {
handleSearch(keywordParam)
}
2026-01-22 11:52:29 +00:00
if (fileParam) {
setSelectedFile(fileParam)
// 展开父目录
const parts = fileParam.split('/')
const allParentPaths = []
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]
currentPath = currentPath ? `${currentPath}/${part}` : part
allParentPaths.push(currentPath)
}
if (allParentPaths.length > 0) {
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
}
// 处理 PDF 或 Markdown
if (fileParam.toLowerCase().endsWith('.pdf')) {
let url = getDocumentUrl(projectId, fileParam)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf')
} else {
loadMarkdown(fileParam)
}
} else {
// 默认打开 README.md
const readmeNode = findReadme(tree)
if (readmeNode) {
setSelectedFile(readmeNode.key)
loadMarkdown(readmeNode.key)
}
2025-12-20 11:18:59 +00:00
}
} 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) => {
2026-01-23 07:00:03 +00:00
// 标题高亮处理 - 取消高亮,仅显示原始标题
const titleNode = 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')) {
// Markdown 文件
return {
key: node.key,
2026-01-23 07:00:03 +00:00
label: titleNode,
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')) {
// 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) => {
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) {
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])
// 处理菜单点击
const handleMenuClick = ({ key }) => {
setSelectedFile(key)
2025-12-31 05:44:03 +00:00
// 检查是否是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)
}
2025-12-20 11:18:59 +00:00
}
// 解析相对路径
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) => {
2025-12-25 04:22:35 +00:00
// 检查是否是外部链接
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
return // 外部链接,允许默认行为
}
2025-12-20 11:18:59 +00:00
2025-12-25 04:22:35 +00:00
// 检查是否是锚点链接
if (href.startsWith('#')) {
return // 锚点链接,允许默认行为
2025-12-20 11:18:59 +00:00
}
2025-12-25 04:22:35 +00:00
2025-12-31 05:44:03 +00:00
// 检查是否是文档文件(.md 或 .pdf
const isMd = href.endsWith('.md')
const isPdf = href.toLowerCase().endsWith('.pdf')
2025-12-25 04:22:35 +00:00
2025-12-31 05:44:03 +00:00
if (!isMd && !isPdf) {
return // 不是文档文件,允许默认行为
2025-12-25 04:22:35 +00:00
}
2025-12-31 05:44:03 +00:00
// 阻止默认跳转
e.preventDefault()
2025-12-25 04:22:35 +00:00
// 先解码 href因为 Markdown 中的链接可能已经是 URL 编码的)
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch (e) {
2025-12-31 05:44:03 +00:00
// 解码失败,使用原始值
2025-12-25 04:22:35 +00:00
}
2026-01-06 07:50:05 +00:00
// 解析路径
let targetPath
if (decodedHref.startsWith('.') || decodedHref.startsWith('..')) {
// 真正的相对路径,相对于当前文件
targetPath = resolveRelativePath(selectedFile, decodedHref)
} else {
// 项目内绝对路径(由编辑器生成),相对于项目根目录
targetPath = decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref
}
2025-12-25 04:22:35 +00:00
// 自动展开父目录
2026-01-06 07:50:05 +00:00
const lastSlashIndex = targetPath.lastIndexOf('/')
if (lastSlashIndex !== -1) {
const parentPath = 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])])
2025-12-25 04:22:35 +00:00
}
}
2025-12-31 05:44:03 +00:00
// 选中文件并加载
2025-12-25 04:22:35 +00:00
setSelectedFile(targetPath)
2025-12-31 05:44:03 +00:00
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)
}
2025-12-20 11:18:59 +00:00
}
2026-01-05 10:50:29 +00:00
const handleGitPull = async (repoId = null, force = false) => {
if (gitRepos.length === 0) {
message.warning('未配置Git仓库')
return
}
try {
const res = await gitPull(projectId, repoId, force)
message.success(res.message || 'Git Pull 成功')
// Refresh tree
loadFileTree()
// Reload current file if open
if (selectedFile) {
loadMarkdown(selectedFile)
}
} catch (error) {
console.error('Git Pull error:', error)
const errorMsg = error.response?.data?.detail || 'Git Pull 失败'
if (!force) {
Modal.confirm({
title: 'Git Pull 失败',
content: (
<div>
<p>{errorMsg}</p>
<p style={{ color: 'red', fontWeight: 'bold', marginTop: 8 }}>
是否强制重置到远程版本
</p>
<p style={{ color: '#666', fontSize: 12 }}>
警告这将丢失所有本地未提交的修改
</p>
</div>
),
okText: '强制重置',
okType: 'danger',
cancelText: '取消',
onOk: () => handleGitPull(repoId, true)
})
return
}
message.error(errorMsg)
}
}
const handleGitPush = async (repoId = null, force = false) => {
if (gitRepos.length === 0) {
message.warning('未配置Git仓库')
return
}
try {
const res = await gitPush(projectId, repoId, force)
message.success(res.message || 'Git Push 成功')
} catch (error) {
console.error('Git Push error:', error)
const errorMsg = error.response?.data?.detail || 'Git Push 失败'
if (!force) {
Modal.confirm({
title: 'Git Push 失败',
content: (
<div>
<p>{errorMsg}</p>
<p style={{ color: 'red', fontWeight: 'bold', marginTop: 8 }}>
是否强制推送到远程
</p>
<p style={{ color: '#666', fontSize: 12 }}>
警告这将覆盖远程仓库的修改
</p>
</div>
),
okText: '强制推送',
okType: 'danger',
cancelText: '取消',
onOk: () => handleGitPush(repoId, true)
})
return
}
message.error(errorMsg)
}
}
const renderGitActions = () => {
if (gitRepos.length <= 1) {
// 0 或 1 个仓库,显示普通按钮
return (
2026-01-22 11:52:29 +00:00
<>
2026-01-05 10:50:29 +00:00
<Tooltip title="Git Pull">
<Button
size="middle"
icon={<CloudDownloadOutlined />}
onClick={() => handleGitPull()}
/>
</Tooltip>
<Tooltip title="Git Push">
<Button
size="middle"
icon={<CloudUploadOutlined />}
onClick={() => handleGitPush()}
/>
</Tooltip>
2026-01-22 11:52:29 +00:00
</>
2026-01-05 10:50:29 +00:00
)
}
// 多个仓库,显示下拉菜单
const pullItems = gitRepos.map(repo => ({
key: repo.id,
label: repo.name + (repo.is_default ? ' (默认)' : ''),
onClick: () => handleGitPull(repo.id),
}))
const pushItems = gitRepos.map(repo => ({
key: repo.id,
label: repo.name + (repo.is_default ? ' (默认)' : ''),
onClick: () => handleGitPush(repo.id),
}))
2026-01-22 11:52:29 +00:00
if (gitRepos.length <= 1) {
return (
<>
<Tooltip title="Git Pull">
<Button
size="middle"
icon={<CloudDownloadOutlined />}
onClick={() => handleGitPull()}
/>
</Tooltip>
<Tooltip title="Git Push">
<Button
size="middle"
icon={<CloudUploadOutlined />}
onClick={() => handleGitPush()}
/>
</Tooltip>
</>
)
}
2026-01-05 10:50:29 +00:00
return (
2026-01-22 11:52:29 +00:00
<>
2026-01-05 10:50:29 +00:00
<Dropdown menu={{ items: pullItems }}>
<Tooltip title="Git Pull">
<Button
size="middle"
icon={<CloudDownloadOutlined />}
/>
</Tooltip>
</Dropdown>
<Dropdown menu={{ items: pushItems }}>
<Tooltip title="Git Push">
<Button
size="middle"
icon={<CloudUploadOutlined />}
/>
</Tooltip>
</Dropdown>
2026-01-22 11:52:29 +00:00
</>
2026-01-05 10:50:29 +00:00
)
}
2025-12-20 11:18:59 +00:00
// 进入编辑模式
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('获取分享信息失败')
}
}
// 复制分享链接
2026-01-05 10:50:29 +00:00
const handleCopyLink = async () => {
2025-12-20 11:18:59 +00:00
if (!shareInfo) return
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
2026-01-05 10:50:29 +00:00
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(fullUrl)
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
// Fallback for non-secure contexts or older browsers
const textArea = document.createElement("textarea")
textArea.value = fullUrl
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
textArea.style.top = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (successful) {
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
Toast.error('复制失败', '请手动复制链接')
}
}
} catch (err) {
console.error('Failed to copy:', err)
Toast.error('复制失败', '无法访问剪贴板')
}
2025-12-20 11:18:59 +00:00
}
// 切换密码保护
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('设置密码失败')
}
}
2026-01-23 07:00:03 +00:00
const menuItems = convertTreeToMenuItems(filteredTreeData)
// Markdown 内容高亮处理
// 使用 components 替换文本节点,但这只对直接文本子节点有效
// 对于深层嵌套,我们需要递归或使用 rehype 插件
// 这里使用简单组件替换
const markdownComponents = useMemo(() => {
if (!searchKeyword) {
return {
a: ({ node, href, children, ...props }) => {
const isExternal = href && (href.startsWith('http') || href.startsWith('//'));
return (
<a
href={href}
onClick={(e) => handleMarkdownLink(e, href)}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
{...props}
>
{children}
</a>
);
},
}
}
// 搜索模式下,尝试高亮
// 注意:这可能不完美,但比没有好
const highlightRenderer = (Tag) => ({ node, children, ...props }) => {
// 如果 children 是字符串,高亮
if (typeof children === 'string') {
return <Tag {...props}><HighlightText text={children} keyword={searchKeyword} /></Tag>
}
// 如果是数组,遍历
if (Array.isArray(children)) {
const newChildren = children.map((child, idx) => {
if (typeof child === 'string') {
return <HighlightText key={idx} text={child} keyword={searchKeyword} />
}
return child
})
return <Tag {...props}>{newChildren}</Tag>
}
return <Tag {...props}>{children}</Tag>
}
return {
a: ({ node, href, children, ...props }) => {
const isExternal = href && (href.startsWith('http') || href.startsWith('//'));
return (
<a
href={href}
onClick={(e) => handleMarkdownLink(e, href)}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
{...props}
>
{typeof children === 'string' ? <HighlightText text={children} keyword={searchKeyword} /> : children}
</a>
);
},
p: highlightRenderer('p'),
li: highlightRenderer('li'),
h1: highlightRenderer('h1'),
h2: highlightRenderer('h2'),
h3: highlightRenderer('h3'),
h4: highlightRenderer('h4'),
h5: highlightRenderer('h5'),
h6: highlightRenderer('h6'),
span: highlightRenderer('span'),
td: highlightRenderer('td'),
th: highlightRenderer('th'),
div: highlightRenderer('div'),
}
}, [searchKeyword])
2025-12-20 11:18:59 +00:00
return (
2025-12-29 12:53:50 +00:00
<div className="project-docs-page">
<Layout className="docs-layout">
2026-01-01 14:41:10 +00:00
{/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header">
2026-01-13 13:21:47 +00:00
<h2>{projectName}</h2>
2026-01-01 14:41:10 +00:00
<div className="docs-sider-actions">
2026-01-22 11:52:29 +00:00
<Space size={8}>
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
{userRole !== 'viewer' && (
2026-01-05 10:50:29 +00:00
<Button
type="primary"
2026-01-22 11:52:29 +00:00
size="middle"
2026-01-05 10:50:29 +00:00
className="mode-toggle-btn"
icon={<EditOutlined />}
onClick={handleEdit}
>
2026-01-22 11:52:29 +00:00
编辑
2026-01-05 10:50:29 +00:00
</Button>
2026-01-22 11:52:29 +00:00
)}
<Space.Compact>
{userRole !== 'viewer' && renderGitActions()}
<Tooltip title="分享">
<Button
size="middle"
icon={<ShareAltOutlined />}
onClick={handleShare}
/>
</Tooltip>
</Space.Compact>
</Space>
2026-01-01 14:41:10 +00:00
</div>
</div>
2026-01-23 07:00:03 +00:00
{/* 搜索框 */}
<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="docs-menu"
/>
) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
</div>
)}
2026-01-01 14:41:10 +00:00
</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' ? (
<VirtualPDFViewer
url={pdfUrl}
filename={pdfFilename}
/>
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
2026-01-23 07:00:03 +00:00
components={markdownComponents}
2026-01-01 14:41:10 +00:00
>
{markdownContent}
</ReactMarkdown>
</div>
)}
2025-12-20 11:18:59 +00:00
</div>
2026-01-01 14:41:10 +00:00
{/* 返回顶部按钮 - 仅在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' }} />
2026-01-23 07:00:03 +00:00
<HighlightText text={item.title} keyword={searchKeyword} />
2026-01-01 14:41:10 +00:00
</div>
),
}))}
2025-12-31 05:44:03 +00:00
/>
2025-12-20 11:18:59 +00:00
) : (
2026-01-01 14:41:10 +00:00
<div className="toc-empty">当前文档无标题</div>
2025-12-20 11:18:59 +00:00
)}
</div>
2026-01-01 14:41:10 +00:00
</Sider>
2025-12-20 11:18:59 +00:00
)}
</Layout>
2026-01-01 14:41:10 +00:00
{/* TOC展开按钮 */}
{tocCollapsed && (
<Button
type="primary"
icon={<MenuUnfoldOutlined />}
className="toc-toggle-btn"
onClick={() => setTocCollapsed(false)}
>
文档索引
</Button>
)}
</Layout>
{/* 分享模态框 */}
2026-01-23 07:00:03 +00:00
{/* ... keeping the modal ... */}
2026-01-01 14:41:10 +00:00
<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>
2025-12-20 11:18:59 +00:00
2026-01-01 14:41:10 +00:00
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
{hasPassword && (
2025-12-20 11:18:59 +00:00
<div>
2026-01-01 14:41:10 +00:00
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="primary"
onClick={handleSavePassword}
style={{ marginTop: 8 }}
>
保存密码
</Button>
2025-12-20 11:18:59 +00:00
</div>
2026-01-01 14:41:10 +00:00
)}
</Space>
)}
</Modal>
</div>
2025-12-20 11:18:59 +00:00
)
}
2026-01-23 07:00:03 +00:00
export default DocumentPage