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-05-21 09:51:53 +00:00
|
|
|
|
import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
|
2026-06-16 13:09:15 +00:00
|
|
|
|
import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } 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'
|
2026-08-07 08:31:54 +00:00
|
|
|
|
import Mark from 'mark.js'
|
2026-01-23 07:00:03 +00:00
|
|
|
|
import GithubSlugger from 'github-slugger'
|
2026-02-26 11:19:29 +00:00
|
|
|
|
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
|
2026-01-05 10:50:29 +00:00
|
|
|
|
import { gitPull, gitPush, getGitRepos } from '@/api/project'
|
2026-05-09 02:45:30 +00:00
|
|
|
|
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } 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-05-21 09:51:53 +00:00
|
|
|
|
import FloatingToc from '@/components/FloatingToc/FloatingToc'
|
2026-01-05 10:50:29 +00:00
|
|
|
|
import Toast from '@/components/Toast/Toast'
|
2026-03-11 07:27:52 +00:00
|
|
|
|
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
2026-06-16 13:09:15 +00:00
|
|
|
|
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
2025-12-20 11:18:59 +00:00
|
|
|
|
import './DocumentPage.css'
|
|
|
|
|
|
|
|
|
|
|
|
const { Sider, Content } = Layout
|
2026-06-16 13:09:15 +00:00
|
|
|
|
const MAX_TOC_ITEMS = 500
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
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-03-11 07:27:52 +00:00
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const [fileTree, setFileTree] = useState([])
|
|
|
|
|
|
const [selectedFile, setSelectedFile] = useState('')
|
2026-03-19 07:43:55 +00:00
|
|
|
|
const [selectedNodeKey, setSelectedNodeKey] = useState('')
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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('')
|
2026-01-23 07:00:03 +00:00
|
|
|
|
const [userRole, setUserRole] = useState('viewer')
|
2025-12-31 05:44:03 +00:00
|
|
|
|
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-05-09 02:45:30 +00:00
|
|
|
|
const [refreshing, setRefreshing] = useState(false)
|
2026-01-23 07:00:03 +00:00
|
|
|
|
|
|
|
|
|
|
// 搜索相关状态
|
|
|
|
|
|
const [searchKeyword, setSearchKeyword] = useState('')
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 知识库引用跳转专用:只高亮定位,不触发全文搜索、不过滤文件树
|
|
|
|
|
|
const [highlightKeyword, setHighlightKeyword] = useState('')
|
2026-01-23 07:00:03 +00:00
|
|
|
|
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
|
|
|
|
|
const [isSearching, setIsSearching] = useState(false)
|
2026-03-11 07:27:52 +00:00
|
|
|
|
const [modeSwitchValue, setModeSwitchValue] = useState('view')
|
2026-01-23 07:00:03 +00:00
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const contentRef = useRef(null)
|
2026-06-16 13:09:15 +00:00
|
|
|
|
const largeMarkdownRef = useRef(null)
|
2026-03-11 07:27:52 +00:00
|
|
|
|
const modeSwitchingRef = useRef(false)
|
2026-05-21 09:51:53 +00:00
|
|
|
|
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
2026-06-16 13:09:15 +00:00
|
|
|
|
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
|
2026-03-11 07:27:52 +00:00
|
|
|
|
|
2026-05-15 11:59:49 +00:00
|
|
|
|
const getHeaderDisplay = (filePath) => {
|
2026-06-16 13:09:15 +00:00
|
|
|
|
const resolvedPath = selectedNodeKey || filePath || 'README.md'
|
2026-05-15 11:59:49 +00:00
|
|
|
|
const fileName = resolvedPath.split('/').filter(Boolean).pop() || 'README.md'
|
2026-06-16 13:09:15 +00:00
|
|
|
|
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
|
|
|
|
|
|
const isFolder = Boolean(selectedNode && !selectedNode.isLeaf)
|
2026-05-15 11:59:49 +00:00
|
|
|
|
const isPdf = fileName.toLowerCase().endsWith('.pdf')
|
2026-06-16 13:09:15 +00:00
|
|
|
|
const FileIcon = isFolder ? FolderOutlined : isPdf ? FilePdfOutlined : FileTextOutlined
|
2026-05-15 11:59:49 +00:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
fileName,
|
|
|
|
|
|
FileIcon,
|
|
|
|
|
|
isPdf,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 07:27:52 +00:00
|
|
|
|
const navigateWithTransition = (to) => {
|
|
|
|
|
|
if (document.startViewTransition) {
|
|
|
|
|
|
document.startViewTransition(() => navigate(to))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
navigate(to)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 13:09:15 +00:00
|
|
|
|
const updateSelectedParam = (path, isFile = true) => {
|
2026-03-11 07:27:52 +00:00
|
|
|
|
const nextParams = new URLSearchParams(searchParams)
|
2026-06-16 13:09:15 +00:00
|
|
|
|
nextParams.delete('file')
|
|
|
|
|
|
nextParams.delete('selected')
|
|
|
|
|
|
|
|
|
|
|
|
if (path) {
|
|
|
|
|
|
nextParams.set(isFile ? 'file' : 'selected', path)
|
2026-03-11 07:27:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
setSearchParams(nextParams, { replace: true })
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2026-06-16 13:09:15 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 02:45:30 +00:00
|
|
|
|
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}` : ''}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadFileTree()
|
|
|
|
|
|
}, [projectId])
|
|
|
|
|
|
|
2026-02-02 10:55:43 +00:00
|
|
|
|
const handleClose = () => {
|
2026-03-11 07:27:52 +00:00
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: '确认退出',
|
|
|
|
|
|
content: '确定要退出当前项目页面吗?',
|
|
|
|
|
|
okText: '退出',
|
|
|
|
|
|
cancelText: '取消',
|
|
|
|
|
|
onOk: () => {
|
|
|
|
|
|
if (userRole === 'owner') {
|
|
|
|
|
|
navigate('/projects/my')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
navigate('/projects/share')
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
2026-02-02 10:55:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-28 11:55:01 +00:00
|
|
|
|
// 监听 URL 参数变化,处理文件导航和搜索
|
2026-01-05 10:50:29 +00:00
|
|
|
|
useEffect(() => {
|
2026-01-28 11:55:01 +00:00
|
|
|
|
// 只有当文件树加载完成后才处理导航,否则无法正确展开目录
|
|
|
|
|
|
if (fileTree.length === 0) return
|
|
|
|
|
|
|
|
|
|
|
|
const fileParam = searchParams.get('file')
|
2026-06-16 13:09:15 +00:00
|
|
|
|
const selectedParam = searchParams.get('selected')
|
2026-01-28 11:55:01 +00:00
|
|
|
|
const keywordParam = searchParams.get('keyword')
|
2026-08-07 08:31:54 +00:00
|
|
|
|
const highlightParam = searchParams.get('hl')
|
|
|
|
|
|
|
|
|
|
|
|
// 引用跳转:仅设置高亮关键词,交由渲染层高亮并滚动到命中位置
|
|
|
|
|
|
if (highlightParam && highlightParam !== highlightKeyword) {
|
|
|
|
|
|
setHighlightKeyword(highlightParam)
|
|
|
|
|
|
} else if (!highlightParam && highlightKeyword) {
|
|
|
|
|
|
setHighlightKeyword('')
|
|
|
|
|
|
}
|
2026-01-28 11:55:01 +00:00
|
|
|
|
|
|
|
|
|
|
// 处理搜索
|
|
|
|
|
|
if (keywordParam && keywordParam !== searchKeyword) {
|
|
|
|
|
|
handleSearch(keywordParam)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理文件加载
|
2026-06-16 13:09:15 +00:00
|
|
|
|
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)
|
2026-01-28 11:55:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 如果没有指定文件,且当前没有选中文件,默认打开 README.md
|
2026-06-16 13:09:15 +00:00
|
|
|
|
if (!selectedFile && !selectedNodeKey) {
|
2026-01-28 11:55:01 +00:00
|
|
|
|
const readmeNode = findReadme(fileTree)
|
|
|
|
|
|
if (readmeNode) {
|
2026-06-16 13:09:15 +00:00
|
|
|
|
openDocumentPath(readmeNode.key, { syncUrl: true })
|
2026-01-28 11:55:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-05 10:50:29 +00:00
|
|
|
|
}
|
2026-01-28 11:55:01 +00:00
|
|
|
|
}, [searchParams, fileTree])
|
2026-01-05 10:50:29 +00:00
|
|
|
|
|
2026-01-23 07:00:03 +00:00
|
|
|
|
// 处理搜索
|
|
|
|
|
|
const handleSearch = async (value) => {
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 先更新搜索状态,任何清理逻辑失败都不能阻断搜索
|
2026-01-23 07:00:03 +00:00
|
|
|
|
setSearchKeyword(value)
|
2026-08-07 08:31:54 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-01-23 07:00:03 +00:00
|
|
|
|
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
|
|
|
|
// 加载文件树
|
2026-05-09 02:45:30 +00:00
|
|
|
|
const loadFileTree = async ({ throwOnError = false } = {}) => {
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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)
|
2026-05-09 02:45:30 +00:00
|
|
|
|
return tree
|
2025-12-20 11:18:59 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Load file tree error:', error)
|
2026-05-09 02:45:30 +00:00
|
|
|
|
if (throwOnError) {
|
|
|
|
|
|
throw error
|
|
|
|
|
|
}
|
|
|
|
|
|
return []
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 查找根目录的 README.md
|
|
|
|
|
|
const findReadme = (nodes) => {
|
|
|
|
|
|
// 只在根目录查找
|
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
|
if (node.title === 'README.md' && node.isLeaf) {
|
|
|
|
|
|
return node
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 07:43:55 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
// 转换文件树为菜单项
|
|
|
|
|
|
const convertTreeToMenuItems = (nodes) => {
|
|
|
|
|
|
return nodes.map((node) => {
|
2026-05-09 02:45:30 +00:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
2026-01-23 07:00:03 +00:00
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
if (!node.isLeaf) {
|
2026-05-10 05:35:54 +00:00
|
|
|
|
const isOpen = openKeys.includes(node.key)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
// 目录
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: node.key,
|
2026-05-09 02:45:30 +00:00
|
|
|
|
label: labelNode,
|
2026-05-10 05:35:54 +00:00
|
|
|
|
icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />,
|
2026-06-16 13:09:15 +00:00
|
|
|
|
onTitleClick: () => selectFolder(node.key, { syncUrl: true }),
|
2025-12-20 11:18:59 +00:00
|
|
|
|
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (node.title && node.title.endsWith('.md')) {
|
|
|
|
|
|
// Markdown 文件
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: node.key,
|
2026-05-09 02:45:30 +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')) {
|
|
|
|
|
|
// PDF 文件
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: node.key,
|
2026-05-09 02:45:30 +00:00
|
|
|
|
label: labelNode,
|
2025-12-31 05:44:03 +00:00
|
|
|
|
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) {
|
2026-06-16 13:09:15 +00:00
|
|
|
|
contentRef.current.scrollTo({ top: 0, behavior: 'auto' })
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Load markdown error:', error)
|
|
|
|
|
|
setMarkdownContent('# 文档加载失败\n\n无法加载该文档,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 提取 markdown 标题生成目录
|
|
|
|
|
|
useEffect(() => {
|
2026-06-16 13:09:15 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
|
2026-06-16 13:09:15 +00:00
|
|
|
|
for (const line of lines) {
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-06-16 13:09:15 +00:00
|
|
|
|
|
|
|
|
|
|
if (headings.length >= MAX_TOC_ITEMS) {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
2026-06-16 13:09:15 +00:00
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
|
|
|
|
|
setTocItems(headings)
|
2026-06-16 13:09:15 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
canceled = true
|
|
|
|
|
|
cancel(taskId)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTocItems([])
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
2026-06-16 13:09:15 +00:00
|
|
|
|
}, [markdownContent, isLargeMarkdown])
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 搜索关键词命中后,文档加载完成滚动到第一个高亮处
|
2026-07-24 07:37:22 +00:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 知识库引用跳转(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])
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
// 处理菜单点击
|
|
|
|
|
|
const handleMenuClick = ({ key }) => {
|
2026-06-16 13:09:15 +00:00
|
|
|
|
const node = findNodeByKey(fileTree, key)
|
|
|
|
|
|
if (!node) return
|
|
|
|
|
|
|
|
|
|
|
|
if (!node.isLeaf) {
|
|
|
|
|
|
selectFolder(key, { syncUrl: true })
|
|
|
|
|
|
return
|
2025-12-31 05:44:03 +00:00
|
|
|
|
}
|
2026-06-16 13:09:15 +00:00
|
|
|
|
|
|
|
|
|
|
openDocumentPath(key, { syncUrl: true })
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 09:51:53 +00:00
|
|
|
|
const scrollContentToTop = () => {
|
2026-06-16 13:09:15 +00:00
|
|
|
|
if (isLargeMarkdown) {
|
|
|
|
|
|
largeMarkdownRef.current?.scrollToTop()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 09:51:53 +00:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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('/')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 11:59:49 +00:00
|
|
|
|
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('//')))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
// 处理markdown内部链接点击
|
|
|
|
|
|
const handleMarkdownLink = (e, href) => {
|
2026-05-15 11:59:49 +00:00
|
|
|
|
const normalizedHref = normalizeMarkdownHref(href)
|
|
|
|
|
|
|
2025-12-25 04:22:35 +00:00
|
|
|
|
// 检查是否是外部链接
|
2026-05-15 11:59:49 +00:00
|
|
|
|
if (!normalizedHref || isExternalHref(normalizedHref)) {
|
2025-12-25 04:22:35 +00:00
|
|
|
|
return // 外部链接,允许默认行为
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2025-12-25 04:22:35 +00:00
|
|
|
|
// 检查是否是锚点链接
|
2026-05-15 11:59:49 +00:00
|
|
|
|
if (normalizedHref.startsWith('#')) {
|
2025-12-25 04:22:35 +00:00
|
|
|
|
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)
|
2026-05-15 11:59:49 +00:00
|
|
|
|
const pathOnly = normalizedHref.split(/[?#]/)[0]
|
|
|
|
|
|
const isMd = pathOnly.endsWith('.md')
|
|
|
|
|
|
const isPdf = pathOnly.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()
|
|
|
|
|
|
|
2026-01-06 07:50:05 +00:00
|
|
|
|
// 解析路径
|
|
|
|
|
|
let targetPath
|
2026-05-15 11:59:49 +00:00
|
|
|
|
if (pathOnly.startsWith('.') || pathOnly.startsWith('..')) {
|
2026-01-06 07:50:05 +00:00
|
|
|
|
// 真正的相对路径,相对于当前文件
|
2026-05-15 11:59:49 +00:00
|
|
|
|
targetPath = resolveRelativePath(selectedFile, pathOnly)
|
2026-01-06 07:50:05 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
// 项目内绝对路径(由编辑器生成),相对于项目根目录
|
2026-05-15 11:59:49 +00:00
|
|
|
|
targetPath = pathOnly.startsWith('/') ? pathOnly.substring(1) : pathOnly
|
2026-01-06 07:50:05 +00:00
|
|
|
|
}
|
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)
|
2026-03-19 07:43:55 +00:00
|
|
|
|
setSelectedNodeKey(targetPath)
|
2026-03-11 07:27:52 +00:00
|
|
|
|
updateFileParam(targetPath)
|
2025-12-31 05:44:03 +00:00
|
|
|
|
|
|
|
|
|
|
if (isPdf) {
|
|
|
|
|
|
// PDF文件:切换到PDF模式
|
2026-05-09 02:45:30 +00:00
|
|
|
|
setPdfUrl(buildDocumentUrl(targetPath))
|
2025-12-31 05:44:03 +00:00
|
|
|
|
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>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
<p style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>
|
2026-01-05 10:50:29 +00:00
|
|
|
|
警告:这将丢失所有本地未提交的修改!
|
|
|
|
|
|
</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>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
<p style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>
|
2026-01-05 10:50:29 +00:00
|
|
|
|
警告:这将覆盖远程仓库的修改!
|
|
|
|
|
|
</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 = () => {
|
2026-03-11 07:27:52 +00:00
|
|
|
|
const params = new URLSearchParams()
|
|
|
|
|
|
if (selectedFile) {
|
|
|
|
|
|
params.set('file', selectedFile)
|
2026-06-16 13:09:15 +00:00
|
|
|
|
} else if (selectedNodeKey) {
|
|
|
|
|
|
params.set('selected', selectedNodeKey)
|
2026-03-11 07:27:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
const query = params.toString()
|
|
|
|
|
|
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 02:45:30 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
// 打开分享设置
|
|
|
|
|
|
const handleShare = async () => {
|
2026-03-19 07:43:55 +00:00
|
|
|
|
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
|
2026-05-09 02:45:30 +00:00
|
|
|
|
if (!selectedNode || !selectedNode.isLeaf) {
|
|
|
|
|
|
Toast.warning('提示', '请先选择一个文件再分享')
|
2026-03-19 07:43:55 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
try {
|
2026-05-09 02:45:30 +00:00
|
|
|
|
const res = await getFileShareInfo(projectId, selectedNode.key)
|
|
|
|
|
|
const nextShareInfo = res.data
|
|
|
|
|
|
setShareInfo(nextShareInfo)
|
|
|
|
|
|
setHasPassword(Boolean(nextShareInfo?.has_password))
|
|
|
|
|
|
setPassword(nextShareInfo?.access_pass || '')
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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
|
2026-05-09 02:45:30 +00:00
|
|
|
|
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) => {
|
2026-05-09 02:45:30 +00:00
|
|
|
|
if (!selectedNodeKey) return
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
if (!checked) {
|
|
|
|
|
|
try {
|
2026-05-09 02:45:30 +00:00
|
|
|
|
if (shareInfo?.share_url) {
|
|
|
|
|
|
await createOrUpdateFileShare(projectId, { file_path: selectedNodeKey, access_pass: null })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await deleteFileShare(projectId, selectedNodeKey)
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
setHasPassword(false)
|
|
|
|
|
|
setPassword('')
|
2026-05-09 02:45:30 +00:00
|
|
|
|
message.success('已取消文件访问密码')
|
|
|
|
|
|
await loadFileTree()
|
|
|
|
|
|
const res = await getFileShareInfo(projectId, selectedNodeKey)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
setShareInfo(res.data)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Update settings error:', error)
|
|
|
|
|
|
message.error('操作失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setHasPassword(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 保存密码
|
|
|
|
|
|
const handleSavePassword = async () => {
|
2026-05-09 02:45:30 +00:00
|
|
|
|
if (!selectedNodeKey) {
|
|
|
|
|
|
message.warning('请先选择文件')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
if (!password.trim()) {
|
|
|
|
|
|
message.warning('请输入访问密码')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-05-09 02:45:30 +00:00
|
|
|
|
const res = await createOrUpdateFileShare(projectId, {
|
|
|
|
|
|
file_path: selectedNodeKey,
|
|
|
|
|
|
access_pass: hasPassword ? password : null,
|
|
|
|
|
|
})
|
|
|
|
|
|
message.success('文件分享已更新')
|
2025-12-20 11:18:59 +00:00
|
|
|
|
setShareInfo(res.data)
|
2026-05-09 02:45:30 +00:00
|
|
|
|
await loadFileTree()
|
|
|
|
|
|
const nextInfo = await getFileShareInfo(projectId, selectedNodeKey)
|
|
|
|
|
|
setShareInfo(nextInfo.data)
|
|
|
|
|
|
setHasPassword(Boolean(nextInfo.data?.has_password))
|
2025-12-20 11:18:59 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Save password error:', error)
|
2026-05-09 02:45:30 +00:00
|
|
|
|
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('关闭文件分享失败')
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 }) => {
|
2026-05-15 11:59:49 +00:00
|
|
|
|
const isExternal = isExternalHref(href);
|
2026-01-23 07:00:03 +00:00
|
|
|
|
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 }) => {
|
2026-05-15 11:59:49 +00:00
|
|
|
|
const isExternal = isExternalHref(href);
|
2026-01-23 07:00:03 +00:00
|
|
|
|
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-05-09 02:45:30 +00:00
|
|
|
|
<div className="docs-sider-title-row">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="project-back-button"
|
|
|
|
|
|
onClick={handleClose}
|
|
|
|
|
|
aria-label="返回项目列表"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ArrowLeftOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<h2 title={projectName}>{projectName}</h2>
|
2026-02-02 10:55:43 +00:00
|
|
|
|
</div>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<div className="docs-sider-actions">
|
2026-03-11 07:27:52 +00:00
|
|
|
|
<div className="mode-actions-row">
|
2026-01-22 11:52:29 +00:00
|
|
|
|
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
|
2026-03-11 07:27:52 +00:00
|
|
|
|
{userRole !== 'viewer' ? (
|
|
|
|
|
|
<ModeSwitch
|
2026-05-09 02:45:30 +00:00
|
|
|
|
size="small"
|
2026-03-11 07:27:52 +00:00
|
|
|
|
value={modeSwitchValue}
|
|
|
|
|
|
onChange={(mode) => {
|
|
|
|
|
|
if (mode === 'edit' && !modeSwitchingRef.current) {
|
|
|
|
|
|
modeSwitchingRef.current = true
|
|
|
|
|
|
setModeSwitchValue('edit')
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
handleEdit()
|
|
|
|
|
|
}, 160)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div />
|
2026-01-22 11:52:29 +00:00
|
|
|
|
)}
|
2026-03-11 07:27:52 +00:00
|
|
|
|
<Space.Compact className="mode-actions-group">
|
2026-01-22 11:52:29 +00:00
|
|
|
|
{userRole !== 'viewer' && renderGitActions()}
|
|
|
|
|
|
<Tooltip title="分享">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="middle"
|
|
|
|
|
|
icon={<ShareAltOutlined />}
|
|
|
|
|
|
onClick={handleShare}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
<Tooltip title="刷新">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="middle"
|
|
|
|
|
|
icon={<ReloadOutlined spin={refreshing} />}
|
|
|
|
|
|
onClick={handleRefresh}
|
|
|
|
|
|
disabled={refreshing}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
2026-01-22 11:52:29 +00:00
|
|
|
|
</Space.Compact>
|
2026-03-11 07:27:52 +00:00
|
|
|
|
</div>
|
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"
|
2026-03-19 07:43:55 +00:00
|
|
|
|
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
|
2026-01-23 07:00:03 +00:00
|
|
|
|
openKeys={openKeys}
|
|
|
|
|
|
onOpenChange={setOpenKeys}
|
|
|
|
|
|
items={menuItems}
|
|
|
|
|
|
onClick={handleMenuClick}
|
|
|
|
|
|
className="docs-menu"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-08-05 14:18:25 +00:00
|
|
|
|
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-color-secondary)' }}>
|
2026-01-23 07:00:03 +00:00
|
|
|
|
<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}>
|
2026-05-15 11:59:49 +00:00
|
|
|
|
<div className="docs-content-header" title={selectedFile || 'README.md'}>
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile)
|
|
|
|
|
|
return (
|
2026-05-21 09:51:53 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-05-15 11:59:49 +00:00
|
|
|
|
)
|
|
|
|
|
|
})()}
|
2026-03-11 07:27:52 +00:00
|
|
|
|
</div>
|
2026-06-16 13:09:15 +00:00
|
|
|
|
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="docs-loading">
|
|
|
|
|
|
<Spin size="large">
|
|
|
|
|
|
<div style={{ marginTop: 16 }}>加载中...</div>
|
|
|
|
|
|
</Spin>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : viewMode === 'pdf' ? (
|
|
|
|
|
|
<VirtualPDFViewer
|
|
|
|
|
|
url={pdfUrl}
|
|
|
|
|
|
filename={pdfFilename}
|
2026-05-21 09:51:53 +00:00
|
|
|
|
toolbarTarget={pdfToolbarTarget}
|
2026-01-01 14:41:10 +00:00
|
|
|
|
/>
|
2026-06-16 13:09:15 +00:00
|
|
|
|
) : viewMode === 'folder' ? (
|
|
|
|
|
|
<div className="docs-folder-placeholder">
|
|
|
|
|
|
<FolderOpenOutlined />
|
|
|
|
|
|
<span>已选择文件夹,请从左侧选择 Markdown 或 PDF 文件查看。</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : isLargeMarkdown ? (
|
|
|
|
|
|
<LargeMarkdownViewer
|
|
|
|
|
|
ref={largeMarkdownRef}
|
|
|
|
|
|
content={markdownContent}
|
|
|
|
|
|
components={markdownComponents}
|
2026-06-22 06:05:10 +00:00
|
|
|
|
searchKeyword={searchKeyword}
|
|
|
|
|
|
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
|
2026-06-16 13:09:15 +00:00
|
|
|
|
/>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<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
|
|
|
|
</Content>
|
|
|
|
|
|
|
2026-06-16 13:09:15 +00:00
|
|
|
|
{viewMode === 'markdown' && !isLargeMarkdown && (
|
2026-05-21 09:51:53 +00:00
|
|
|
|
<FloatingToc
|
|
|
|
|
|
items={tocItems}
|
|
|
|
|
|
searchKeyword={searchKeyword}
|
|
|
|
|
|
getContainer={() => contentRef.current}
|
|
|
|
|
|
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
|
|
|
|
|
|
/>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</Layout>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
</Layout>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 分享模态框 */}
|
2026-01-23 07:00:03 +00:00
|
|
|
|
{/* ... keeping the modal ... */}
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<Modal
|
2026-05-09 02:45:30 +00:00
|
|
|
|
title="文件分享"
|
2026-01-01 14:41:10 +00:00
|
|
|
|
open={shareModalVisible}
|
|
|
|
|
|
onCancel={() => setShareModalVisible(false)}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
width={500}
|
|
|
|
|
|
>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
|
|
|
|
|
|
当前文件
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<Input value={selectedNodeKey || ''} readOnly />
|
|
|
|
|
|
</div>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2026-05-09 02:45:30 +00:00
|
|
|
|
{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>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
<div>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
<Space>
|
|
|
|
|
|
<span style={{ fontWeight: 500 }}>访问密码保护</span>
|
|
|
|
|
|
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{hasPassword && (
|
2026-06-03 09:08:06 +00:00
|
|
|
|
<Space.Compact style={{ width: '100%' }}>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
<Input.Password
|
|
|
|
|
|
placeholder="请输入访问密码"
|
|
|
|
|
|
value={password}
|
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
|
/>
|
2026-06-03 09:08:06 +00:00
|
|
|
|
<Button type="primary" onClick={handleSavePassword}>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
保存密码
|
|
|
|
|
|
</Button>
|
2026-06-03 09:08:06 +00:00
|
|
|
|
</Space.Compact>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
<div style={{ color: 'var(--text-color-secondary)', lineHeight: 1.7 }}>
|
2026-06-03 09:08:06 +00:00
|
|
|
|
当前文件尚未创建独立分享。
|
2026-05-09 02:45:30 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Button type="primary" onClick={handleCreateShare}>
|
|
|
|
|
|
创建文件分享
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{shareInfo?.share_url && (
|
|
|
|
|
|
<Button danger onClick={handleDisableShare}>
|
|
|
|
|
|
关闭文件分享
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Space>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
</Modal>
|
|
|
|
|
|
</div>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 07:27:52 +00:00
|
|
|
|
export default DocumentPage
|