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

1373 lines
43 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, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } 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 Highlighter from 'react-highlight-words'
import Mark from 'mark.js'
import GithubSlugger from 'github-slugger'
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
import { gitPull, gitPush, getGitRepos } from '@/api/project'
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share'
import { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import FloatingToc from '@/components/FloatingToc/FloatingToc'
import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import './DocumentPage.css'
const { Sider, Content } = Layout
const MAX_TOC_ITEMS = 500
// 高亮渲染组件
const HighlightText = ({ text, keyword }) => {
if (!keyword || !text) return text;
return (
<Highlighter
highlightClassName="search-highlight"
searchWords={[keyword]}
autoEscape={true}
textToHighlight={text}
/>
)
}
function DocumentPage() {
const { projectId } = useParams()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [selectedNodeKey, setSelectedNodeKey] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
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')
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
const [gitRepos, setGitRepos] = useState([])
const [projectName, setProjectName] = useState('')
const [refreshing, setRefreshing] = useState(false)
// 搜索相关状态
const [searchKeyword, setSearchKeyword] = useState('')
// 知识库引用跳转专用:只高亮定位,不触发全文搜索、不过滤文件树
const [highlightKeyword, setHighlightKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const [modeSwitchValue, setModeSwitchValue] = useState('view')
const contentRef = useRef(null)
const largeMarkdownRef = useRef(null)
const modeSwitchingRef = useRef(false)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
const getHeaderDisplay = (filePath) => {
const resolvedPath = selectedNodeKey || filePath || 'README.md'
const fileName = resolvedPath.split('/').filter(Boolean).pop() || 'README.md'
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
const isFolder = Boolean(selectedNode && !selectedNode.isLeaf)
const isPdf = fileName.toLowerCase().endsWith('.pdf')
const FileIcon = isFolder ? FolderOutlined : isPdf ? FilePdfOutlined : FileTextOutlined
return {
fileName,
FileIcon,
isPdf,
}
}
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => navigate(to))
return
}
navigate(to)
}
const updateSelectedParam = (path, isFile = true) => {
const nextParams = new URLSearchParams(searchParams)
nextParams.delete('file')
nextParams.delete('selected')
if (path) {
nextParams.set(isFile ? 'file' : 'selected', path)
}
setSearchParams(nextParams, { replace: true })
}
const updateFileParam = (filePath) => {
updateSelectedParam(filePath, true)
}
const expandParentFolders = (path) => {
const parts = path.split('/')
const allParentPaths = []
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
allParentPaths.push(currentPath)
}
if (allParentPaths.length > 0) {
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
}
}
const selectFolder = (folderPath, { syncUrl = false } = {}) => {
setSelectedFile('')
setSelectedNodeKey(folderPath)
setMarkdownContent('')
setTocItems([])
setViewMode('folder')
expandParentFolders(`${folderPath}/placeholder`)
if (syncUrl) {
updateSelectedParam(folderPath, false)
}
}
const openDocumentPath = (filePath, { syncUrl = false } = {}) => {
setSelectedFile(filePath)
setSelectedNodeKey(filePath)
expandParentFolders(filePath)
if (syncUrl) {
updateFileParam(filePath)
}
if (filePath.toLowerCase().endsWith('.pdf')) {
setPdfUrl(buildDocumentUrl(filePath))
setPdfFilename(filePath.split('/').pop())
setViewMode('pdf')
} else {
setViewMode('markdown')
loadMarkdown(filePath)
}
}
const buildDocumentUrl = (filePath, refreshKey = null) => {
const params = new URLSearchParams()
const token = localStorage.getItem('access_token')
if (token) {
params.set('token', token)
}
if (refreshKey) {
params.set('refresh', String(refreshKey))
}
const query = params.toString()
return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}`
}
useEffect(() => {
loadFileTree()
}, [projectId])
const handleClose = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出当前项目页面吗?',
okText: '退出',
cancelText: '取消',
onOk: () => {
if (userRole === 'owner') {
navigate('/projects/my')
} else {
navigate('/projects/share')
}
},
})
}
// 监听 URL 参数变化,处理文件导航和搜索
useEffect(() => {
// 只有当文件树加载完成后才处理导航,否则无法正确展开目录
if (fileTree.length === 0) return
const fileParam = searchParams.get('file')
const selectedParam = searchParams.get('selected')
const keywordParam = searchParams.get('keyword')
const highlightParam = searchParams.get('hl')
// 引用跳转:仅设置高亮关键词,交由渲染层高亮并滚动到命中位置
if (highlightParam && highlightParam !== highlightKeyword) {
setHighlightKeyword(highlightParam)
} else if (!highlightParam && highlightKeyword) {
setHighlightKeyword('')
}
// 处理搜索
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
// 处理文件加载
if (selectedParam) {
const targetNode = findNodeByKey(fileTree, selectedParam)
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) {
selectFolder(selectedParam)
}
} else if (fileParam) {
const targetNode = findNodeByKey(fileTree, fileParam)
if (targetNode && !targetNode.isLeaf) {
selectFolder(fileParam, { syncUrl: true })
} else if (fileParam !== selectedFile) {
openDocumentPath(fileParam)
}
} else {
// 如果没有指定文件,且当前没有选中文件,默认打开 README.md
if (!selectedFile && !selectedNodeKey) {
const readmeNode = findReadme(fileTree)
if (readmeNode) {
openDocumentPath(readmeNode.key, { syncUrl: true })
}
}
}
}, [searchParams, fileTree])
// 处理搜索
const handleSearch = async (value) => {
// 先更新搜索状态,任何清理逻辑失败都不能阻断搜索
setSearchKeyword(value)
setHighlightKeyword('')
try {
// 清理引用跳转的 mark.js 高亮
if (contentRef.current) {
new Mark(contentRef.current).unmark({ className: 'citation-highlight' })
}
// 手动搜索时移除引用跳转定位参数,避免后续导航重新触发旧高亮
if (searchParams.get('hl')) {
const nextParams = new URLSearchParams(searchParams)
nextParams.delete('hl')
setSearchParams(nextParams, { replace: true })
}
} catch (error) {
console.warn('清理引用高亮失败:', error)
}
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])
const loadGitRepos = async () => {
try {
const res = await getGitRepos(projectId)
setGitRepos(res.data || [])
} catch (error) {
console.error('Load git repos error:', error)
}
}
// 加载文件树
const loadFileTree = async ({ throwOnError = false } = {}) => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
const tree = data.tree || data || [] // 兼容新旧格式
const role = data.user_role || 'viewer'
const name = data.project_name
setFileTree(tree)
setUserRole(role)
setProjectName(name)
return tree
} catch (error) {
console.error('Load file tree error:', error)
if (throwOnError) {
throw error
}
return []
}
}
// 查找根目录的 README.md
const findReadme = (nodes) => {
// 只在根目录查找
for (const node of nodes) {
if (node.title === 'README.md' && node.isLeaf) {
return node
}
}
return null
}
const findNodeByKey = (nodes, key) => {
for (const node of nodes) {
if (node.key === key) {
return node
}
if (node.children?.length) {
const found = findNodeByKey(node.children, key)
if (found) {
return found
}
}
}
return null
}
// 转换文件树为菜单项
const convertTreeToMenuItems = (nodes) => {
return nodes.map((node) => {
const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title
const labelNode = (
<Tooltip title={node.title} placement="right">
<span className="docs-menu-label">
<span className="docs-menu-label-text">{titleText}</span>
{node.is_shared && <ShareAltOutlined className="docs-menu-share-icon" title="已分享" />}
</span>
</Tooltip>
)
if (!node.isLeaf) {
const isOpen = openKeys.includes(node.key)
// 目录
return {
key: node.key,
label: labelNode,
icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />,
onTitleClick: () => selectFolder(node.key, { syncUrl: true }),
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
} else if (node.title && node.title.endsWith('.md')) {
// Markdown 文件
return {
key: node.key,
label: labelNode,
icon: <FileTextOutlined />,
}
} else if (node.title && node.title.endsWith('.pdf')) {
// PDF 文件
return {
key: node.key,
label: labelNode,
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: 'auto' })
}
} catch (error) {
console.error('Load markdown error:', error)
setMarkdownContent('# 文档加载失败\n\n无法加载该文档请稍后重试。')
} finally {
setLoading(false)
}
}
// 提取 markdown 标题生成目录
useEffect(() => {
if (markdownContent && !isLargeMarkdown) {
let canceled = false
const schedule = window.requestIdleCallback || ((cb) => window.setTimeout(cb, 1))
const cancel = window.cancelIdleCallback || window.clearTimeout
const taskId = schedule(() => {
if (canceled) return
const slugger = new GithubSlugger()
const headings = []
const lines = markdownContent.split('\n')
for (const line of lines) {
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,
})
if (headings.length >= MAX_TOC_ITEMS) {
break
}
}
}
setTocItems(headings)
})
return () => {
canceled = true
cancel(taskId)
}
} else {
setTocItems([])
}
}, [markdownContent, isLargeMarkdown])
// 搜索关键词命中后,文档加载完成滚动到第一个高亮处
useEffect(() => {
if (loading || !searchKeyword || !markdownContent) return
if (viewMode !== 'markdown') return
let canceled = false
const timer = window.setTimeout(() => {
if (canceled) return
const container = contentRef.current
const target = container?.querySelector('.search-highlight')
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 临时强调被引用的位置,短暂后淡出
target.classList.add('cited-highlight-flash')
window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400)
}
}, 260)
return () => {
canceled = true
window.clearTimeout(timer)
}
}, [loading, markdownContent, searchKeyword, viewMode])
// 知识库引用跳转URL 带 hl用 mark.js 跨文本节点高亮并滚动定位。
// 引文含 markdown 语法时,渲染后的 DOM 文本与原文不一致,且关键词可能
// 跨行内元素边界,逐节点高亮匹配不上,因此统一走 mark.js。
useEffect(() => {
if (!highlightKeyword || loading || !markdownContent || viewMode !== 'markdown') {
// hl 被清除或文档未就绪时同步清理 mark.js 残留
if (!isLargeMarkdown && contentRef.current) {
try {
new Mark(contentRef.current).unmark({ className: 'citation-highlight' })
} catch (error) {
console.warn('清理引用高亮失败:', error)
}
}
return
}
// 超大文档走虚拟滚动,定位到包含关键词的文档块
if (isLargeMarkdown) {
largeMarkdownRef.current?.scrollToKeyword?.(highlightKeyword)
return
}
const container = contentRef.current
if (!container) return
let canceled = false
const timer = window.setTimeout(() => {
if (canceled) return
try {
const instance = new Mark(container)
instance.unmark({ className: 'citation-highlight' })
instance.mark(highlightKeyword, {
className: 'citation-highlight',
separateWordSearch: false,
acrossElements: true,
done: () => {
const target = container.querySelector('.citation-highlight')
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('cited-highlight-flash')
window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400)
}
},
})
} catch (error) {
console.warn('引用高亮失败:', error)
}
}, 260)
return () => {
canceled = true
window.clearTimeout(timer)
}
}, [loading, highlightKeyword, markdownContent, viewMode, isLargeMarkdown])
// 处理菜单点击
const handleMenuClick = ({ key }) => {
const node = findNodeByKey(fileTree, key)
if (!node) return
if (!node.isLeaf) {
selectFolder(key, { syncUrl: true })
return
}
openDocumentPath(key, { syncUrl: true })
}
const scrollContentToTop = () => {
if (isLargeMarkdown) {
largeMarkdownRef.current?.scrollToTop()
return
}
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const handleExportMarkdownPDF = () => {
if (!selectedFile) return
let url = getExportPdfUrl(projectId, selectedFile)
const token = localStorage.getItem('access_token')
if (token) {
url += `&token=${encodeURIComponent(token)}`
}
window.open(url, '_blank')
}
// 解析相对路径
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('/')
}
const normalizeMarkdownHref = (href) => {
if (!href) return href
const [pathPart, hashPart = ''] = href.split('#')
const [rawPath, searchPart = ''] = pathPart.split('?')
const decodedPath = rawPath
.split('/')
.map((part) => {
try {
return decodeURIComponent(part)
} catch (e) {
return part
}
})
.join('/')
const rebuilt = searchPart ? `${decodedPath}?${searchPart}` : decodedPath
return hashPart ? `${rebuilt}#${hashPart}` : rebuilt
}
const isExternalHref = (href) => {
return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//')))
}
// 处理markdown内部链接点击
const handleMarkdownLink = (e, href) => {
const normalizedHref = normalizeMarkdownHref(href)
// 检查是否是外部链接
if (!normalizedHref || isExternalHref(normalizedHref)) {
return // 外部链接,允许默认行为
}
// 检查是否是锚点链接
if (normalizedHref.startsWith('#')) {
return // 锚点链接,允许默认行为
}
// 检查是否是文档文件(.md 或 .pdf
const pathOnly = normalizedHref.split(/[?#]/)[0]
const isMd = pathOnly.endsWith('.md')
const isPdf = pathOnly.toLowerCase().endsWith('.pdf')
if (!isMd && !isPdf) {
return // 不是文档文件,允许默认行为
}
// 阻止默认跳转
e.preventDefault()
// 解析路径
let targetPath
if (pathOnly.startsWith('.') || pathOnly.startsWith('..')) {
// 真正的相对路径,相对于当前文件
targetPath = resolveRelativePath(selectedFile, pathOnly)
} else {
// 项目内绝对路径(由编辑器生成),相对于项目根目录
targetPath = pathOnly.startsWith('/') ? pathOnly.substring(1) : pathOnly
}
// 自动展开父目录
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])])
}
}
// 选中文件并加载
setSelectedFile(targetPath)
setSelectedNodeKey(targetPath)
updateFileParam(targetPath)
if (isPdf) {
// PDF文件切换到PDF模式
setPdfUrl(buildDocumentUrl(targetPath))
setPdfFilename(targetPath.split('/').pop())
setViewMode('pdf')
} else {
// Markdown文件加载内容
setViewMode('markdown')
loadMarkdown(targetPath)
}
}
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: 'var(--text-color-secondary)', 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: 'var(--text-color-secondary)', 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 (
<>
<Tooltip title="Git Pull">
<Button
size="middle"
icon={<CloudDownloadOutlined />}
onClick={() => handleGitPull()}
/>
</Tooltip>
<Tooltip title="Git Push">
<Button
size="middle"
icon={<CloudUploadOutlined />}
onClick={() => handleGitPush()}
/>
</Tooltip>
</>
)
}
// 多个仓库,显示下拉菜单
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),
}))
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>
</>
)
}
return (
<>
<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>
</>
)
}
// 进入编辑模式
const handleEdit = () => {
const params = new URLSearchParams()
if (selectedFile) {
params.set('file', selectedFile)
} else if (selectedNodeKey) {
params.set('selected', selectedNodeKey)
}
const query = params.toString()
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
}
const handleRefresh = async () => {
setRefreshing(true)
try {
await loadFileTree({ throwOnError: true })
message.success('已刷新')
} catch (error) {
console.error('Refresh documents error:', error)
message.error('刷新失败')
} finally {
setRefreshing(false)
}
}
// 打开分享设置
const handleShare = async () => {
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
if (!selectedNode || !selectedNode.isLeaf) {
Toast.warning('提示', '请先选择一个文件再分享')
return
}
try {
const res = await getFileShareInfo(projectId, selectedNode.key)
const nextShareInfo = res.data
setShareInfo(nextShareInfo)
setHasPassword(Boolean(nextShareInfo?.has_password))
setPassword(nextShareInfo?.access_pass || '')
setShareModalVisible(true)
} catch (error) {
console.error('Get share info error:', error)
message.error('获取分享信息失败')
}
}
// 复制分享链接
const handleCopyLink = async () => {
if (!shareInfo) return
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
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('复制失败', '无法访问剪贴板')
}
}
// 切换密码保护
const handlePasswordToggle = async (checked) => {
if (!selectedNodeKey) return
if (!checked) {
try {
if (shareInfo?.share_url) {
await createOrUpdateFileShare(projectId, { file_path: selectedNodeKey, access_pass: null })
} else {
await deleteFileShare(projectId, selectedNodeKey)
}
setHasPassword(false)
setPassword('')
message.success('已取消文件访问密码')
await loadFileTree()
const res = await getFileShareInfo(projectId, selectedNodeKey)
setShareInfo(res.data)
} catch (error) {
console.error('Update settings error:', error)
message.error('操作失败')
}
} else {
setHasPassword(true)
}
}
// 保存密码
const handleSavePassword = async () => {
if (!selectedNodeKey) {
message.warning('请先选择文件')
return
}
if (!password.trim()) {
message.warning('请输入访问密码')
return
}
try {
const res = await createOrUpdateFileShare(projectId, {
file_path: selectedNodeKey,
access_pass: hasPassword ? password : null,
})
message.success('文件分享已更新')
setShareInfo(res.data)
await loadFileTree()
const nextInfo = await getFileShareInfo(projectId, selectedNodeKey)
setShareInfo(nextInfo.data)
setHasPassword(Boolean(nextInfo.data?.has_password))
} catch (error) {
console.error('Save password error:', error)
message.error('设置文件分享失败')
}
}
const handleCreateShare = async () => {
if (!selectedNodeKey) {
message.warning('请先选择文件')
return
}
try {
const res = await createOrUpdateFileShare(projectId, {
file_path: selectedNodeKey,
access_pass: hasPassword ? password : null,
})
setShareInfo(res.data)
setHasPassword(Boolean(res.data?.has_password))
await loadFileTree()
message.success('文件分享已创建')
} catch (error) {
console.error('Create file share error:', error)
message.error('创建文件分享失败')
}
}
const handleDisableShare = async () => {
if (!selectedNodeKey) return
try {
await deleteFileShare(projectId, selectedNodeKey)
setShareInfo(null)
setHasPassword(false)
setPassword('')
await loadFileTree()
message.success('文件分享已关闭')
} catch (error) {
console.error('Delete file share error:', error)
message.error('关闭文件分享失败')
}
}
const menuItems = convertTreeToMenuItems(filteredTreeData)
// Markdown 内容高亮处理
// 使用 components 替换文本节点,但这只对直接文本子节点有效
// 对于深层嵌套,我们需要递归或使用 rehype 插件
// 这里使用简单组件替换
const markdownComponents = useMemo(() => {
if (!searchKeyword) {
return {
a: ({ node, href, children, ...props }) => {
const isExternal = isExternalHref(href);
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 = isExternalHref(href);
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])
return (
<div className="project-docs-page">
<Layout className="docs-layout">
{/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header">
<div className="docs-sider-title-row">
<button
type="button"
className="project-back-button"
onClick={handleClose}
aria-label="返回项目列表"
>
<ArrowLeftOutlined />
</button>
<h2 title={projectName}>{projectName}</h2>
</div>
<div className="docs-sider-actions">
<div className="mode-actions-row">
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
{userRole !== 'viewer' ? (
<ModeSwitch
size="small"
value={modeSwitchValue}
onChange={(mode) => {
if (mode === 'edit' && !modeSwitchingRef.current) {
modeSwitchingRef.current = true
setModeSwitchValue('edit')
setTimeout(() => {
handleEdit()
}, 160)
}
}}
/>
) : (
<div />
)}
<Space.Compact className="mode-actions-group">
{userRole !== 'viewer' && renderGitActions()}
<Tooltip title="分享">
<Button
size="middle"
icon={<ShareAltOutlined />}
onClick={handleShare}
/>
</Tooltip>
<Tooltip title="刷新">
<Button
size="middle"
icon={<ReloadOutlined spin={refreshing} />}
onClick={handleRefresh}
disabled={refreshing}
/>
</Tooltip>
</Space.Compact>
</div>
</div>
</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={selectedNodeKey ? [selectedNodeKey] : []}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
className="docs-menu"
/>
) : (
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-color-secondary)' }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
</div>
)}
</Sider>
{/* 右侧内容区 */}
<Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}>
<div className="docs-content-header" title={selectedFile || 'README.md'}>
{(() => {
const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile)
return (
<>
<div className="docs-header-title">
<span className="docs-header-item">
<FileIcon className="docs-header-icon" style={isPdf ? { color: '#f5222d' } : undefined} />
<span className="docs-header-text">{fileName}</span>
</span>
</div>
{viewMode === 'pdf' && <div className="docs-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
{viewMode === 'markdown' && (
<Space className="docs-header-actions">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
>
回到顶部
</Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportMarkdownPDF}
size="small"
>
下载PDF
</Button>
</Space>
)}
</>
)
})()}
</div>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
{loading ? (
<div className="docs-loading">
<Spin size="large">
<div style={{ marginTop: 16 }}>加载中...</div>
</Spin>
</div>
) : viewMode === 'pdf' ? (
<VirtualPDFViewer
url={pdfUrl}
filename={pdfFilename}
toolbarTarget={pdfToolbarTarget}
/>
) : viewMode === 'folder' ? (
<div className="docs-folder-placeholder">
<FolderOpenOutlined />
<span>已选择文件夹请从左侧选择 Markdown PDF 文件查看</span>
</div>
) : isLargeMarkdown ? (
<LargeMarkdownViewer
ref={largeMarkdownRef}
content={markdownContent}
components={markdownComponents}
searchKeyword={searchKeyword}
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
/>
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
components={markdownComponents}
>
{markdownContent}
</ReactMarkdown>
</div>
)}
</div>
</Content>
{viewMode === 'markdown' && !isLargeMarkdown && (
<FloatingToc
items={tocItems}
searchKeyword={searchKeyword}
getContainer={() => contentRef.current}
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
/>
)}
</Layout>
</Layout>
{/* 分享模态框 */}
{/* ... keeping the modal ... */}
<Modal
title="文件分享"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={500}
>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
当前文件
</label>
<Input value={selectedNodeKey || ''} readOnly />
</div>
{shareInfo?.share_url ? (
<>
<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 && (
<Space.Compact style={{ width: '100%' }}>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="primary" onClick={handleSavePassword}>
保存密码
</Button>
</Space.Compact>
)}
</>
) : (
<>
<div style={{ color: 'var(--text-color-secondary)', lineHeight: 1.7 }}>
当前文件尚未创建独立分享
</div>
<Button type="primary" onClick={handleCreateShare}>
创建文件分享
</Button>
</>
)}
{shareInfo?.share_url && (
<Button danger onClick={handleDisableShare}>
关闭文件分享
</Button>
)}
</Space>
</Modal>
</div>
)
}
export default DocumentPage