2026-05-09 02:45:30 +00:00
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
2026-05-21 09:51:53 +00:00
|
|
|
|
import { Layout, Modal, Input, Spin, Button, Space } from 'antd'
|
|
|
|
|
|
import { CloseOutlined, LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons'
|
2026-05-09 02:45:30 +00:00
|
|
|
|
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'
|
2026-05-21 09:51:53 +00:00
|
|
|
|
import FloatingToc from '@/components/FloatingToc/FloatingToc'
|
2026-05-09 02:45:30 +00:00
|
|
|
|
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
|
|
|
|
|
import {
|
|
|
|
|
|
getFileSharePublicInfo,
|
|
|
|
|
|
verifyFileSharePassword,
|
|
|
|
|
|
getFileShareContent,
|
|
|
|
|
|
exportFileSharePDF,
|
|
|
|
|
|
} from '@/api/share'
|
|
|
|
|
|
import './PreviewPage.css'
|
|
|
|
|
|
|
2026-05-21 09:51:53 +00:00
|
|
|
|
const { Content } = Layout
|
2026-05-09 02:45:30 +00:00
|
|
|
|
|
|
|
|
|
|
function FileSharePage() {
|
|
|
|
|
|
const { shareCode } = useParams()
|
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const contentRef = useRef(null)
|
2026-05-21 09:51:53 +00:00
|
|
|
|
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
2026-05-09 02:45:30 +00:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 09:51:53 +00:00
|
|
|
|
const scrollContentToTop = () => {
|
|
|
|
|
|
if (contentRef.current) {
|
|
|
|
|
|
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 11:59:49 +00:00
|
|
|
|
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 || '文件分享'
|
|
|
|
|
|
|
2026-05-09 02:45:30 +00:00
|
|
|
|
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}>
|
2026-05-10 09:15:24 +00:00
|
|
|
|
<div className="preview-content-header file-share-content-header">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="project-back-button"
|
|
|
|
|
|
onClick={handleClose}
|
|
|
|
|
|
aria-label="关闭文档分享"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CloseOutlined />
|
|
|
|
|
|
</button>
|
2026-05-15 11:59:49 +00:00
|
|
|
|
<h3 className="preview-header-title">
|
|
|
|
|
|
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
|
|
|
|
|
|
<span className="preview-header-text">{headerLabel}</span>
|
|
|
|
|
|
</h3>
|
2026-05-21 09:51:53 +00:00
|
|
|
|
{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} />}
|
2026-05-10 09:15:24 +00:00
|
|
|
|
</div>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
{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' ? (
|
2026-05-21 09:51:53 +00:00
|
|
|
|
<VirtualPDFViewer
|
|
|
|
|
|
url={contentInfo.document_url}
|
|
|
|
|
|
filename={contentInfo.filename}
|
|
|
|
|
|
toolbarTarget={pdfToolbarTarget}
|
|
|
|
|
|
/>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="markdown-body">
|
2026-05-15 11:59:49 +00:00
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
rehypePlugins={[rehypeSlug, rehypeHighlight]}
|
|
|
|
|
|
components={markdownComponents}
|
|
|
|
|
|
>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
{contentInfo?.content || ''}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
</Content>
|
|
|
|
|
|
|
2026-05-21 09:51:53 +00:00
|
|
|
|
{!isMobile && contentInfo?.type === 'markdown' && (
|
|
|
|
|
|
<FloatingToc
|
|
|
|
|
|
items={tocItems}
|
|
|
|
|
|
getContainer={() => contentRef.current}
|
|
|
|
|
|
/>
|
2026-05-09 02:45:30 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</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
|