267 lines
8.7 KiB
JavaScript
267 lines
8.7 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
||
import { useNavigate, useParams } from 'react-router-dom'
|
||
import { Layout, Modal, Input, Spin, Button, Space } from 'antd'
|
||
import { CloseOutlined, LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons'
|
||
import ReactMarkdown from 'react-markdown'
|
||
import remarkGfm from 'remark-gfm'
|
||
import rehypeHighlight from 'rehype-highlight'
|
||
import rehypeSlug from 'rehype-slug'
|
||
import 'highlight.js/styles/github.css'
|
||
import GithubSlugger from 'github-slugger'
|
||
import Toast from '@/components/Toast/Toast'
|
||
import FloatingToc from '@/components/FloatingToc/FloatingToc'
|
||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||
import {
|
||
getFileSharePublicInfo,
|
||
verifyFileSharePassword,
|
||
getFileShareContent,
|
||
exportFileSharePDF,
|
||
} from '@/api/share'
|
||
import './PreviewPage.css'
|
||
|
||
const { Content } = Layout
|
||
|
||
function FileSharePage() {
|
||
const { shareCode } = useParams()
|
||
const navigate = useNavigate()
|
||
const contentRef = useRef(null)
|
||
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 [passwordModalVisible, setPasswordModalVisible] = useState(false)
|
||
const [password, setPassword] = useState('')
|
||
|
||
useEffect(() => {
|
||
loadFileShare()
|
||
}, [shareCode])
|
||
|
||
useEffect(() => {
|
||
const checkMobile = () => setIsMobile(window.innerWidth < 768)
|
||
checkMobile()
|
||
window.addEventListener('resize', checkMobile)
|
||
return () => window.removeEventListener('resize', checkMobile)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const markdownContent = contentInfo?.type === 'markdown' ? (contentInfo.content || '') : ''
|
||
if (!markdownContent) {
|
||
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)
|
||
}, [contentInfo])
|
||
|
||
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 (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 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 className="preview-content-header file-share-content-header">
|
||
<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' && (
|
||
<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' : ''}`}>
|
||
{contentInfo?.type === 'pdf' ? (
|
||
<VirtualPDFViewer
|
||
url={contentInfo.document_url}
|
||
filename={contentInfo.filename}
|
||
toolbarTarget={pdfToolbarTarget}
|
||
/>
|
||
) : (
|
||
<div className="markdown-body">
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm]}
|
||
rehypePlugins={[rehypeSlug, rehypeHighlight]}
|
||
components={markdownComponents}
|
||
>
|
||
{contentInfo?.content || ''}
|
||
</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</Content>
|
||
|
||
{!isMobile && contentInfo?.type === 'markdown' && (
|
||
<FloatingToc
|
||
items={tocItems}
|
||
getContainer={() => contentRef.current}
|
||
/>
|
||
)}
|
||
</Layout>
|
||
</div>
|
||
|
||
<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
|