404 lines
14 KiB
JavaScript
404 lines
14 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
||
import { useNavigate, useParams } from 'react-router-dom'
|
||
import { Layout, Modal, Input, Spin, Button, Space, Tooltip } from 'antd'
|
||
import { LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons'
|
||
import { Viewer } from '@bytemd/react'
|
||
import 'bytemd/dist/index.css'
|
||
import 'highlight.js/styles/github.css'
|
||
import GithubSlugger from 'github-slugger'
|
||
import Toast from '@/components/Toast/Toast'
|
||
import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
|
||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
||
import { MARKDOWN_VIEWER_PLUGINS } from '@/utils/markdownViewer'
|
||
import {
|
||
getFileSharePublicInfo,
|
||
verifyFileSharePassword,
|
||
getFileShareContent,
|
||
exportFileSharePDF,
|
||
} from '@/api/share'
|
||
import './PreviewPage.css'
|
||
|
||
const { Content } = Layout
|
||
|
||
// 查找内容区实际可滚动的容器(普通 MD:主内容区;PDF/大文档:内部滚动容器)
|
||
const findScrollableEl = (root) => {
|
||
if (!root) return null
|
||
if (root.scrollHeight > root.clientHeight + 1) return root
|
||
const inner = root.querySelector('.pdf-content, .markdown-virtual-list')
|
||
if (inner && inner.scrollHeight > inner.clientHeight + 1) return inner
|
||
return null
|
||
}
|
||
|
||
function FileSharePage() {
|
||
const { shareCode } = useParams()
|
||
const navigate = useNavigate()
|
||
const contentRef = useRef(null)
|
||
const largeMarkdownRef = useRef(null)
|
||
const [headerVisible, setHeaderVisible] = useState(false)
|
||
const headerHoveringRef = useRef(false)
|
||
const headerHideTimerRef = useRef(null)
|
||
const clearHeaderHideTimer = () => {
|
||
if (headerHideTimerRef.current) {
|
||
clearTimeout(headerHideTimerRef.current)
|
||
headerHideTimerRef.current = null
|
||
}
|
||
}
|
||
// 延迟隐藏 Header(鼠标悬停时保持显示)
|
||
const scheduleHeaderHide = () => {
|
||
clearHeaderHideTimer()
|
||
if (headerHoveringRef.current) return
|
||
headerHideTimerRef.current = setTimeout(() => setHeaderVisible(false), 1200)
|
||
}
|
||
// 鼠标进入 Header:取消隐藏计时,保持显示
|
||
const handleHeaderMouseEnter = () => {
|
||
headerHoveringRef.current = true
|
||
clearHeaderHideTimer()
|
||
}
|
||
// 鼠标离开 Header:若已滚动则重新计时隐藏
|
||
const handleHeaderMouseLeave = () => {
|
||
headerHoveringRef.current = false
|
||
const root = contentRef.current
|
||
const target = root ? findScrollableEl(root) : null
|
||
if (target && target.scrollTop > 8) scheduleHeaderHide()
|
||
}
|
||
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
||
const [shareInfo, setShareInfo] = useState(null)
|
||
const [contentInfo, setContentInfo] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [isMobile, setIsMobile] = useState(false)
|
||
const [tocItems, setTocItems] = useState([])
|
||
const [tocDrawerVisible, setTocDrawerVisible] = useState(false)
|
||
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
|
||
const [password, setPassword] = useState('')
|
||
const markdownContent = contentInfo?.type === 'markdown' ? (contentInfo.content || '') : ''
|
||
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
|
||
|
||
useEffect(() => {
|
||
loadFileShare()
|
||
}, [shareCode])
|
||
|
||
useEffect(() => {
|
||
const checkMobile = () => setIsMobile(window.innerWidth < 768)
|
||
checkMobile()
|
||
window.addEventListener('resize', checkMobile)
|
||
return () => window.removeEventListener('resize', checkMobile)
|
||
}, [])
|
||
|
||
// 内容区 Header:仅在滚动时显示,滚动停止后延迟隐藏;鼠标悬停时保持显示
|
||
useEffect(() => {
|
||
const root = contentRef.current
|
||
if (!root) return
|
||
const sync = (scrollEl = null) => {
|
||
const target = scrollEl || findScrollableEl(root)
|
||
if (!target) {
|
||
clearHeaderHideTimer()
|
||
setHeaderVisible(true)
|
||
return
|
||
}
|
||
if (target.scrollTop > 8) {
|
||
setHeaderVisible(true)
|
||
scheduleHeaderHide()
|
||
} else {
|
||
clearHeaderHideTimer()
|
||
setHeaderVisible(false)
|
||
}
|
||
}
|
||
const onScroll = (e) => sync(e.target)
|
||
root.addEventListener('scroll', onScroll, true)
|
||
sync()
|
||
return () => {
|
||
root.removeEventListener('scroll', onScroll, true)
|
||
clearHeaderHideTimer()
|
||
}
|
||
}, [contentInfo?.type, isLargeMarkdown, loading])
|
||
|
||
useEffect(() => {
|
||
if (!markdownContent || isLargeMarkdown) {
|
||
setTocItems([])
|
||
return
|
||
}
|
||
|
||
const slugger = new GithubSlugger()
|
||
const headings = []
|
||
markdownContent.split('\n').forEach((line) => {
|
||
const match = line.match(/^(#{1,6})\s+(.+)$/)
|
||
if (!match) return
|
||
const level = match[1].length
|
||
const title = match[2].trim()
|
||
const key = slugger.slug(title)
|
||
headings.push({ key: `#${key}`, href: `#${key}`, title, level })
|
||
})
|
||
setTocItems(headings)
|
||
}, [markdownContent, isLargeMarkdown])
|
||
|
||
const handleClose = () => {
|
||
if (window.history.length > 1) {
|
||
navigate(-1)
|
||
return
|
||
}
|
||
navigate('/')
|
||
}
|
||
|
||
const loadFileShare = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const infoRes = await getFileSharePublicInfo(shareCode)
|
||
setShareInfo(infoRes.data)
|
||
|
||
if (infoRes.data.has_password) {
|
||
setContentInfo(null)
|
||
setPasswordModalVisible(true)
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
const contentRes = await getFileShareContent(shareCode)
|
||
setContentInfo(contentRes.data)
|
||
} catch (error) {
|
||
console.error('Load file share error:', error)
|
||
Toast.error('加载失败', '分享链接不存在或已失效')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleVerifyPassword = async () => {
|
||
if (!password.trim()) {
|
||
Toast.warning('提示', '请输入访问密码')
|
||
return
|
||
}
|
||
try {
|
||
await verifyFileSharePassword(shareCode, password)
|
||
const contentRes = await getFileShareContent(shareCode, password)
|
||
setContentInfo(contentRes.data)
|
||
setPasswordModalVisible(false)
|
||
Toast.success('验证成功')
|
||
} catch (error) {
|
||
Toast.error('访问密码错误')
|
||
}
|
||
}
|
||
|
||
const handleExportPDF = () => {
|
||
if (!contentInfo || contentInfo.type === 'pdf') return
|
||
window.open(exportFileSharePDF(shareCode), '_blank')
|
||
}
|
||
|
||
const scrollContentToTop = () => {
|
||
if (isLargeMarkdown) {
|
||
largeMarkdownRef.current?.scrollToTop()
|
||
return
|
||
}
|
||
|
||
if (contentRef.current) {
|
||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||
}
|
||
}
|
||
|
||
const isExternalHref = (href) => {
|
||
return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//')))
|
||
}
|
||
|
||
const isInternalFileHref = (href) => {
|
||
if (!href) return false
|
||
if (href.startsWith('#')) return false
|
||
if (isExternalHref(href)) return false
|
||
const pathOnly = href.split(/[?#]/)[0]
|
||
return pathOnly.endsWith('.md') || pathOnly.toLowerCase().endsWith('.pdf')
|
||
}
|
||
|
||
const handleMarkdownLink = (e, href) => {
|
||
if (!isInternalFileHref(href)) return
|
||
e.preventDefault()
|
||
Toast.error('无法打开内部文件链接', '单文件分享模式不支持跳转到其他内部文件')
|
||
}
|
||
|
||
const handleMarkdownContentClick = (event) => {
|
||
const target = event.target
|
||
const anchor = target instanceof Element ? target.closest('a') : null
|
||
if (!anchor) return
|
||
|
||
const href = anchor.getAttribute('href')
|
||
if (!href) return
|
||
|
||
if (isExternalHref(href)) {
|
||
event.preventDefault()
|
||
window.open(href, '_blank', 'noopener,noreferrer')
|
||
return
|
||
}
|
||
|
||
handleMarkdownLink(event, href)
|
||
}
|
||
|
||
const markdownComponents = {
|
||
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 isHeaderPdf = contentInfo?.type === 'pdf'
|
||
const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
|
||
const headerLabel = contentInfo?.filename || '文件分享'
|
||
|
||
return (
|
||
<div className="preview-page file-share-page">
|
||
<div className="file-share-shell">
|
||
<Layout className="file-share-content-layout">
|
||
<Content className="file-share-content" ref={contentRef}>
|
||
<div
|
||
onMouseEnter={handleHeaderMouseEnter}
|
||
onMouseLeave={handleHeaderMouseLeave}
|
||
className={`preview-content-header file-share-content-header${headerVisible ? '' : ' preview-header-hidden'}`}>
|
||
<button
|
||
type="button"
|
||
className="project-back-button"
|
||
onClick={handleClose}
|
||
aria-label="关闭"
|
||
>
|
||
<CloseOutlined />
|
||
</button>
|
||
<h3 className="preview-header-title">
|
||
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
|
||
<span className="preview-header-text">{headerLabel}</span>
|
||
</h3>
|
||
{contentInfo?.type === 'markdown' && (
|
||
isMobile ? (
|
||
<Space className="preview-header-actions preview-compact-actions" size={4}>
|
||
<Tooltip title="回到顶部">
|
||
<Button
|
||
icon={<VerticalAlignTopOutlined />}
|
||
onClick={scrollContentToTop}
|
||
size="small"
|
||
type="text"
|
||
aria-label="回到顶部"
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="下载PDF">
|
||
<Button
|
||
icon={<CloudDownloadOutlined />}
|
||
onClick={handleExportPDF}
|
||
size="small"
|
||
type="text"
|
||
aria-label="下载PDF"
|
||
/>
|
||
</Tooltip>
|
||
{!isLargeMarkdown && (
|
||
<Tooltip title="文档索引">
|
||
<Button
|
||
icon={<MenuOutlined />}
|
||
onClick={() => setTocDrawerVisible(true)}
|
||
size="small"
|
||
type="text"
|
||
aria-label="文档索引"
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
</Space>
|
||
) : (
|
||
<Space className="preview-header-actions">
|
||
<Button
|
||
icon={<VerticalAlignTopOutlined />}
|
||
onClick={scrollContentToTop}
|
||
size="small"
|
||
>
|
||
回到顶部
|
||
</Button>
|
||
<Button
|
||
icon={<CloudDownloadOutlined />}
|
||
onClick={handleExportPDF}
|
||
size="small"
|
||
>
|
||
下载PDF
|
||
</Button>
|
||
</Space>
|
||
)
|
||
)}
|
||
{contentInfo?.type === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
|
||
</div>
|
||
{loading ? (
|
||
<div className="preview-loading">
|
||
<Spin size="large">
|
||
<div style={{ marginTop: 16 }}>加载中...</div>
|
||
</Spin>
|
||
</div>
|
||
) : (
|
||
<div className={`preview-content-wrapper ${contentInfo?.type === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
|
||
{contentInfo?.type === 'pdf' ? (
|
||
<VirtualPDFViewer
|
||
url={contentInfo.document_url}
|
||
filename={contentInfo.filename}
|
||
toolbarTarget={pdfToolbarTarget}
|
||
compactToolbar={isMobile}
|
||
/>
|
||
) : isLargeMarkdown ? (
|
||
<LargeMarkdownViewer
|
||
ref={largeMarkdownRef}
|
||
content={markdownContent}
|
||
components={markdownComponents}
|
||
/>
|
||
) : (
|
||
<div className="bytemd-viewer-wrapper" onClick={handleMarkdownContentClick}>
|
||
<Viewer
|
||
value={markdownContent}
|
||
plugins={MARKDOWN_VIEWER_PLUGINS}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</Content>
|
||
|
||
{!isMobile && contentInfo?.type === 'markdown' && !isLargeMarkdown && (
|
||
<FloatingToc
|
||
items={tocItems}
|
||
getContainer={() => contentRef.current}
|
||
/>
|
||
)}
|
||
</Layout>
|
||
</div>
|
||
|
||
<TocDrawer
|
||
open={tocDrawerVisible}
|
||
onClose={() => setTocDrawerVisible(false)}
|
||
items={tocItems}
|
||
getContainer={() => contentRef.current}
|
||
/>
|
||
|
||
<Modal
|
||
title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>}
|
||
open={passwordModalVisible}
|
||
onOk={handleVerifyPassword}
|
||
onCancel={() => setPasswordModalVisible(false)}
|
||
okText="验证"
|
||
cancelText="取消"
|
||
maskClosable={false}
|
||
>
|
||
<div style={{ marginTop: 16 }}>
|
||
<p>该文件分享需要访问密码,请输入密码后继续浏览。</p>
|
||
<Input.Password
|
||
placeholder="请输入访问密码"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
onPressEnter={handleVerifyPassword}
|
||
prefix={<LockOutlined />}
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default FileSharePage
|