main
mula.liu 2026-05-15 19:59:49 +08:00
parent ccb61b00fa
commit 5256d20ac9
8 changed files with 306 additions and 54 deletions

View File

@ -203,8 +203,8 @@
min-height: 57px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
background: var(--header-bg);
flex-shrink: 0;
}
@ -454,14 +454,8 @@
margin-bottom: 0.25em;
}
/* 文件名过长时显示省略号,不折行 */
.content-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 600px;
color: var(--text-color);
.content-header .preview-header-title {
flex: 1;
min-width: 0;
max-width: none;
}

View File

@ -79,6 +79,28 @@ function DocumentEditor() {
const editorCtxRef = useRef(null)
const modeSwitchingRef = useRef(false)
const isHeaderPdf = selectedFile?.toLowerCase().endsWith('.pdf')
const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : '请选择文件'
const linkTreeData = useMemo(() => {
const markSelectable = (nodes) => nodes.map((node) => {
if (node.children?.length) {
return {
...node,
selectable: false,
children: markSelectable(node.children),
}
}
return {
...node,
selectable: true,
}
})
return markSelectable(treeData)
}, [treeData])
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => navigate(to))
@ -97,6 +119,20 @@ function DocumentEditor() {
setSearchParams(nextParams, { replace: true })
}
const encodeMarkdownLinkTarget = (targetPath) => {
if (!targetPath) return targetPath
return targetPath
.split('/')
.map((part) => {
if (!part || part === '.' || part === '..') {
return part
}
return encodeURIComponent(part)
})
.join('/')
}
//
const handleInsertLink = () => {
if (!linkTarget) {
@ -113,7 +149,7 @@ function DocumentEditor() {
const fileName = linkTarget.split('/').pop()
// 使
const linkTitle = selection || fileName
const linkText = `[${linkTitle}](${linkTarget})`
const linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})`
editor.replaceSelection(linkText)
editor.focus()
@ -1068,7 +1104,10 @@ function DocumentEditor() {
<Content className="document-content">
<div className="content-header">
<h3>{selectedFile || '请选择文件'}</h3>
<h3 className="preview-header-title">
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span>
</h3>
<Space>
<Button
type="primary"
@ -1187,15 +1226,16 @@ function DocumentEditor() {
style={{ width: '100%' }}
value={linkTarget}
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
treeData={treeData}
treeData={linkTreeData}
placeholder="请选择文件"
treeDefaultExpandAll
onChange={setLinkTarget}
fieldNames={{ label: 'title', value: 'key', children: 'children' }}
showSearch
filterTreeNode={(inputValue, treeNode) => {
return treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
return Boolean(treeNode.isLeaf) && treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
}}
allowClear
/>
</div>
</Modal>

View File

@ -267,15 +267,38 @@
align-items: center;
}
.docs-content-header h3 {
margin: 0;
.docs-header-title {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
gap: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
}
.docs-header-item {
display: inline-flex;
align-items: center;
min-width: 0;
flex: 0 1 auto;
gap: 6px;
}
.docs-header-icon {
font-size: 15px;
color: var(--text-color-secondary);
flex: none;
}
.docs-header-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.docs-content-wrapper {

View File

@ -69,6 +69,19 @@ function DocumentPage() {
const contentRef = useRef(null)
const modeSwitchingRef = useRef(false)
const getHeaderDisplay = (filePath) => {
const resolvedPath = filePath || 'README.md'
const fileName = resolvedPath.split('/').filter(Boolean).pop() || 'README.md'
const isPdf = fileName.toLowerCase().endsWith('.pdf')
const FileIcon = isPdf ? FilePdfOutlined : FileTextOutlined
return {
fileName,
FileIcon,
isPdf,
}
}
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => navigate(to))
@ -422,21 +435,49 @@ function DocumentPage() {
return dirParts.join('/')
}
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('//')))
}
// markdown
const handleMarkdownLink = (e, href) => {
const normalizedHref = normalizeMarkdownHref(href)
//
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
if (!normalizedHref || isExternalHref(normalizedHref)) {
return //
}
//
if (href.startsWith('#')) {
if (normalizedHref.startsWith('#')) {
return //
}
// .md .pdf
const isMd = href.endsWith('.md')
const isPdf = href.toLowerCase().endsWith('.pdf')
const pathOnly = normalizedHref.split(/[?#]/)[0]
const isMd = pathOnly.endsWith('.md')
const isPdf = pathOnly.toLowerCase().endsWith('.pdf')
if (!isMd && !isPdf) {
return //
@ -445,22 +486,14 @@ function DocumentPage() {
//
e.preventDefault()
// href Markdown URL
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch (e) {
// 使
}
//
let targetPath
if (decodedHref.startsWith('.') || decodedHref.startsWith('..')) {
if (pathOnly.startsWith('.') || pathOnly.startsWith('..')) {
//
targetPath = resolveRelativePath(selectedFile, decodedHref)
targetPath = resolveRelativePath(selectedFile, pathOnly)
} else {
//
targetPath = decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref
targetPath = pathOnly.startsWith('/') ? pathOnly.substring(1) : pathOnly
}
//
@ -836,7 +869,7 @@ function DocumentPage() {
if (!searchKeyword) {
return {
a: ({ node, href, children, ...props }) => {
const isExternal = href && (href.startsWith('http') || href.startsWith('//'));
const isExternal = isExternalHref(href);
return (
<a
href={href}
@ -874,7 +907,7 @@ function DocumentPage() {
return {
a: ({ node, href, children, ...props }) => {
const isExternal = href && (href.startsWith('http') || href.startsWith('//'));
const isExternal = isExternalHref(href);
return (
<a
href={href}
@ -994,8 +1027,18 @@ function DocumentPage() {
{/* 右侧内容区 */}
<Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}>
<div className="docs-content-header">
<h3>{selectedFile || 'README.md'}</h3>
<div className="docs-content-header" title={selectedFile || 'README.md'}>
{(() => {
const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile)
return (
<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>
)
})()}
</div>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? (

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Layout, Button, Modal, Input, Spin, Anchor } from 'antd'
import { CloseOutlined, LockOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined } from '@ant-design/icons'
import { CloseOutlined, LockOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FilePdfOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
@ -117,6 +117,45 @@ function FileSharePage() {
window.open(exportFileSharePDF(shareCode), '_blank')
}
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">
@ -131,7 +170,10 @@ function FileSharePage() {
>
<CloseOutlined />
</button>
<h3>{contentInfo?.filename || shareInfo?.name || '文件分享'}</h3>
<h3 className="preview-header-title">
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span>
</h3>
</div>
{loading ? (
<div className="preview-loading">
@ -145,7 +187,11 @@ function FileSharePage() {
<VirtualPDFViewer url={contentInfo.document_url} filename={contentInfo.filename} />
) : (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug, rehypeHighlight]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={markdownComponents}
>
{contentInfo?.content || ''}
</ReactMarkdown>
</div>

View File

@ -247,7 +247,34 @@
align-items: center;
}
.preview-content-header h3 {
.preview-header-title {
display: flex;
align-items: center;
min-width: 0;
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
white-space: nowrap;
gap: 0;
}
.preview-header-icon {
font-size: 15px;
color: var(--text-color-secondary);
flex: none;
margin-right: 6px;
}
.preview-header-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-content-header h3:not(.preview-header-title) {
margin: 0;
font-size: 16px;
font-weight: 600;

View File

@ -181,6 +181,31 @@ function ProjectSharePage() {
}
}
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('//')))
}
const handleSearch = async (value) => {
const keyword = value || ''
setSearchKeyword(keyword)
@ -297,6 +322,14 @@ function ProjectSharePage() {
return dirParts.join('/')
}
const resolveMarkdownTarget = (relativePath) => {
if (!relativePath) return relativePath
if (relativePath.startsWith('.')) {
return resolveRelativePath(selectedFile, relativePath)
}
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath
}
const openSharedFile = (key) => {
setSelectedFile(key)
setSelectedNodeKey(key)
@ -324,16 +357,14 @@ function ProjectSharePage() {
}
const handleMarkdownLink = (e, href) => {
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) return
const isMd = href.endsWith('.md')
const isPdf = href.toLowerCase().endsWith('.pdf')
const normalizedHref = normalizeMarkdownHref(href)
if (!normalizedHref || isExternalHref(normalizedHref) || normalizedHref.startsWith('#')) return
const pathOnly = normalizedHref.split(/[?#]/)[0]
const isMd = pathOnly.endsWith('.md')
const isPdf = pathOnly.toLowerCase().endsWith('.pdf')
if (!isMd && !isPdf) return
e.preventDefault()
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch {}
openSharedFile(resolveRelativePath(selectedFile, decodedHref))
openSharedFile(resolveMarkdownTarget(pathOnly))
}
const handleExportPDF = () => {
@ -355,6 +386,27 @@ function ProjectSharePage() {
[filteredTreeData, openKeys]
)
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 = selectedFile.toLowerCase().endsWith('.pdf')
const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : 'README.md'
return (
<div className="preview-page">
<Layout className="preview-layout">
@ -454,7 +506,10 @@ function ProjectSharePage() {
<Layout className="preview-content-layout">
<Content className="preview-content" ref={contentRef}>
<div className="preview-content-header">
<h3>{selectedFile || 'README.md'}</h3>
<h3 className="preview-header-title">
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span>
</h3>
</div>
<div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? (
@ -467,13 +522,18 @@ function ProjectSharePage() {
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} />
) : (
<div className="markdown-body" onClick={(e) => {
if (e.defaultPrevented) return
const target = e.target.closest('a')
if (target) {
const href = target.getAttribute('href')
if (href) handleMarkdownLink(e, href)
}
}} ref={viewerRef}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug, rehypeHighlight]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={markdownComponents}
>
{markdownContent}
</ReactMarkdown>
</div>

View File

@ -157,14 +157,33 @@ function ProjectList({ type = 'my' }) {
//
const handleUpdateProject = async (values) => {
try {
await updateProject(currentProject.id, {
const shouldKeepOpenForShare = currentProject?.is_public !== 1 && values.is_public
const res = await updateProject(currentProject.id, {
...values,
is_public: values.is_public ? 1 : 0,
})
const updatedProject = res.data || {
...currentProject,
...values,
is_public: values.is_public ? 1 : 0,
}
setCurrentProject(updatedProject)
message.success('项目更新成功')
await fetchProjects()
if (shouldKeepOpenForShare) {
const shareRes = await getProjectShareInfo(currentProject.id)
setShareInfo(shareRes.data)
setHasPassword(shareRes.data.has_password)
setPassword(shareRes.data.access_pass || '')
return
}
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
setHasPassword(false)
setPassword('')
setEditModalVisible(false)
editForm.resetFields()
fetchProjects()
} catch (error) {
console.error('Update project error:', error)
message.error('项目更新失败')
@ -762,7 +781,7 @@ function ProjectList({ type = 'my' }) {
</div>
) : isPublicEnablePending ? (
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
保存项目后将自动生成新的项目分享链接
保存项目后将自动生成项目分享链接
</div>
) : (
<>