2025-12-20 11:18:59 +00:00
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
2026-01-05 10:50:29 +00:00
|
|
|
|
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown } from 'antd'
|
|
|
|
|
|
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined } from '@ant-design/icons'
|
2025-12-20 11:18:59 +00:00
|
|
|
|
import ReactMarkdown from 'react-markdown'
|
|
|
|
|
|
import remarkGfm from 'remark-gfm'
|
|
|
|
|
|
import rehypeRaw from 'rehype-raw'
|
|
|
|
|
|
import rehypeSlug from 'rehype-slug'
|
|
|
|
|
|
import rehypeHighlight from 'rehype-highlight'
|
|
|
|
|
|
import 'highlight.js/styles/github.css'
|
2025-12-31 05:44:03 +00:00
|
|
|
|
import { 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-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
|
|
|
|
|
|
|
|
|
|
|
|
function DocumentPage() {
|
|
|
|
|
|
const { projectId } = useParams()
|
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const [fileTree, setFileTree] = useState([])
|
|
|
|
|
|
const [selectedFile, setSelectedFile] = useState('')
|
|
|
|
|
|
const [markdownContent, setMarkdownContent] = useState('')
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [openKeys, setOpenKeys] = useState([])
|
|
|
|
|
|
const [tocCollapsed, setTocCollapsed] = useState(false)
|
|
|
|
|
|
const [tocItems, setTocItems] = useState([])
|
|
|
|
|
|
const [shareModalVisible, setShareModalVisible] = useState(false)
|
|
|
|
|
|
const [shareInfo, setShareInfo] = useState(null)
|
|
|
|
|
|
const [hasPassword, setHasPassword] = useState(false)
|
|
|
|
|
|
const [password, setPassword] = useState('')
|
2025-12-29 12:53:50 +00:00
|
|
|
|
const [userRole, setUserRole] = useState('viewer') // 用户角色:owner/admin/editor/viewer
|
2025-12-31 05:44:03 +00:00
|
|
|
|
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
|
|
|
|
|
|
const [pdfUrl, setPdfUrl] = useState('')
|
|
|
|
|
|
const [pdfFilename, setPdfFilename] = useState('')
|
|
|
|
|
|
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
|
2026-01-05 10:50:29 +00:00
|
|
|
|
const [gitRepos, setGitRepos] = useState([])
|
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])
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
setFileTree(tree)
|
2025-12-29 12:53:50 +00:00
|
|
|
|
setUserRole(role)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
|
|
|
|
|
// 默认打开 README.md
|
|
|
|
|
|
const readmeNode = findReadme(tree)
|
|
|
|
|
|
if (readmeNode) {
|
|
|
|
|
|
setSelectedFile(readmeNode.key)
|
|
|
|
|
|
loadMarkdown(readmeNode.key)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Load file tree error:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 查找根目录的 README.md
|
|
|
|
|
|
const findReadme = (nodes) => {
|
|
|
|
|
|
// 只在根目录查找
|
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
|
if (node.title === 'README.md' && node.isLeaf) {
|
|
|
|
|
|
return node
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换文件树为菜单项
|
|
|
|
|
|
const convertTreeToMenuItems = (nodes) => {
|
|
|
|
|
|
return nodes.map((node) => {
|
|
|
|
|
|
if (!node.isLeaf) {
|
|
|
|
|
|
// 目录
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: node.key,
|
|
|
|
|
|
label: node.title,
|
|
|
|
|
|
icon: <FolderOutlined />,
|
|
|
|
|
|
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (node.title && node.title.endsWith('.md')) {
|
|
|
|
|
|
// Markdown 文件
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: node.key,
|
|
|
|
|
|
label: node.title.replace('.md', ''),
|
|
|
|
|
|
icon: <FileTextOutlined />,
|
|
|
|
|
|
}
|
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) {
|
|
|
|
|
|
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-05 10:50:29 +00:00
|
|
|
|
// 模拟 rehype-slug/github-slugger 的 ID 生成规则
|
|
|
|
|
|
const key = title
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.replace(/\s+/g, '-') // 空格转连字符
|
|
|
|
|
|
.replace(/[^\w\-\u4e00-\u9fa5]+/g, '') // 移除非单词字符(保留中文、数字、字母、下划线、连字符)
|
|
|
|
|
|
.replace(/\-\-+/g, '-') // 合并重复连字符
|
|
|
|
|
|
.replace(/^-+/, '') // 去除头部连字符
|
|
|
|
|
|
.replace(/-+$/, '') // 去除尾部连字符
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析相对路径
|
|
|
|
|
|
const targetPath = resolveRelativePath(selectedFile, decodedHref)
|
|
|
|
|
|
|
|
|
|
|
|
// 自动展开父目录
|
|
|
|
|
|
const parentPath = targetPath.substring(0, targetPath.lastIndexOf('/'))
|
|
|
|
|
|
if (parentPath && !openKeys.includes(parentPath)) {
|
|
|
|
|
|
// 收集所有父路径
|
|
|
|
|
|
const pathParts = parentPath.split('/')
|
|
|
|
|
|
const allParentPaths = []
|
|
|
|
|
|
let currentPath = ''
|
|
|
|
|
|
for (const part of pathParts) {
|
|
|
|
|
|
currentPath = currentPath ? `${currentPath}/${part}` : part
|
|
|
|
|
|
allParentPaths.push(currentPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<Space.Compact>
|
|
|
|
|
|
<Tooltip title="Git Pull">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
size="middle"
|
|
|
|
|
|
icon={<CloudDownloadOutlined />}
|
|
|
|
|
|
onClick={() => handleGitPull()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
<Tooltip title="Git Push">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
size="middle"
|
|
|
|
|
|
icon={<CloudUploadOutlined />}
|
|
|
|
|
|
onClick={() => handleGitPush()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</Space.Compact>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 多个仓库,显示下拉菜单
|
|
|
|
|
|
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),
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Space.Compact>
|
|
|
|
|
|
<Dropdown menu={{ items: pullItems }}>
|
|
|
|
|
|
<Tooltip title="Git Pull">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
size="middle"
|
|
|
|
|
|
icon={<CloudDownloadOutlined />}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</Dropdown>
|
|
|
|
|
|
<Dropdown menu={{ items: pushItems }}>
|
|
|
|
|
|
<Tooltip title="Git Push">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
size="middle"
|
|
|
|
|
|
icon={<CloudUploadOutlined />}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</Dropdown>
|
|
|
|
|
|
</Space.Compact>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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('设置密码失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const menuItems = convertTreeToMenuItems(fileTree)
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
|
|
|
|
|
<h2>项目文档</h2>
|
|
|
|
|
|
<div className="docs-sider-actions">
|
2026-01-05 10:50:29 +00:00
|
|
|
|
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
|
2026-01-01 14:41:10 +00:00
|
|
|
|
{userRole !== 'viewer' && (
|
2026-01-05 10:50:29 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
className="mode-toggle-btn"
|
|
|
|
|
|
icon={<EditOutlined />}
|
|
|
|
|
|
onClick={handleEdit}
|
|
|
|
|
|
>
|
|
|
|
|
|
编辑模式
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
{renderGitActions()}
|
|
|
|
|
|
</>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
)}
|
|
|
|
|
|
<Tooltip title="分享">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
size="middle"
|
|
|
|
|
|
icon={<ShareAltOutlined />}
|
|
|
|
|
|
onClick={handleShare}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Menu
|
|
|
|
|
|
mode="inline"
|
|
|
|
|
|
selectedKeys={[selectedFile]}
|
|
|
|
|
|
openKeys={openKeys}
|
|
|
|
|
|
onOpenChange={setOpenKeys}
|
|
|
|
|
|
items={menuItems}
|
|
|
|
|
|
onClick={handleMenuClick}
|
|
|
|
|
|
className="docs-menu"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Sider>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 右侧内容区 */}
|
|
|
|
|
|
<Layout className="docs-content-layout">
|
|
|
|
|
|
<Content className="docs-content" ref={contentRef}>
|
|
|
|
|
|
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="docs-loading">
|
|
|
|
|
|
<Spin size="large">
|
|
|
|
|
|
<div style={{ marginTop: 16 }}>加载中...</div>
|
|
|
|
|
|
</Spin>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : viewMode === 'pdf' ? (
|
|
|
|
|
|
<VirtualPDFViewer
|
|
|
|
|
|
url={pdfUrl}
|
|
|
|
|
|
filename={pdfFilename}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="markdown-body">
|
|
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
|
|
|
|
|
|
components={{
|
|
|
|
|
|
a: ({ node, href, children, ...props }) => (
|
|
|
|
|
|
<a
|
|
|
|
|
|
href={href}
|
|
|
|
|
|
onClick={(e) => handleMarkdownLink(e, href)}
|
|
|
|
|
|
{...props}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
),
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{markdownContent}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
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' }} />
|
|
|
|
|
|
{item.title}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 分享模态框 */}
|
|
|
|
|
|
<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
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default DocumentPage
|