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

View File

@ -79,6 +79,28 @@ function DocumentEditor() {
const editorCtxRef = useRef(null) const editorCtxRef = useRef(null)
const modeSwitchingRef = useRef(false) 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) => { const navigateWithTransition = (to) => {
if (document.startViewTransition) { if (document.startViewTransition) {
document.startViewTransition(() => navigate(to)) document.startViewTransition(() => navigate(to))
@ -97,6 +119,20 @@ function DocumentEditor() {
setSearchParams(nextParams, { replace: true }) 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 = () => { const handleInsertLink = () => {
if (!linkTarget) { if (!linkTarget) {
@ -113,7 +149,7 @@ function DocumentEditor() {
const fileName = linkTarget.split('/').pop() const fileName = linkTarget.split('/').pop()
// 使 // 使
const linkTitle = selection || fileName const linkTitle = selection || fileName
const linkText = `[${linkTitle}](${linkTarget})` const linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})`
editor.replaceSelection(linkText) editor.replaceSelection(linkText)
editor.focus() editor.focus()
@ -1068,7 +1104,10 @@ function DocumentEditor() {
<Content className="document-content"> <Content className="document-content">
<div className="content-header"> <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> <Space>
<Button <Button
type="primary" type="primary"
@ -1187,15 +1226,16 @@ function DocumentEditor() {
style={{ width: '100%' }} style={{ width: '100%' }}
value={linkTarget} value={linkTarget}
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }} dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
treeData={treeData} treeData={linkTreeData}
placeholder="请选择文件" placeholder="请选择文件"
treeDefaultExpandAll treeDefaultExpandAll
onChange={setLinkTarget} onChange={setLinkTarget}
fieldNames={{ label: 'title', value: 'key', children: 'children' }} fieldNames={{ label: 'title', value: 'key', children: 'children' }}
showSearch showSearch
filterTreeNode={(inputValue, treeNode) => { filterTreeNode={(inputValue, treeNode) => {
return treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0 return Boolean(treeNode.isLeaf) && treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
}} }}
allowClear
/> />
</div> </div>
</Modal> </Modal>

View File

@ -267,15 +267,38 @@
align-items: center; align-items: center;
} }
.docs-content-header h3 { .docs-header-title {
margin: 0; display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
gap: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: var(--text-color); 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; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 100%;
} }
.docs-content-wrapper { .docs-content-wrapper {

View File

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

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Layout, Button, Modal, Input, Spin, Anchor } from 'antd' 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 ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
@ -117,6 +117,45 @@ function FileSharePage() {
window.open(exportFileSharePDF(shareCode), '_blank') 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 ( return (
<div className="preview-page file-share-page"> <div className="preview-page file-share-page">
<div className="file-share-shell"> <div className="file-share-shell">
@ -131,7 +170,10 @@ function FileSharePage() {
> >
<CloseOutlined /> <CloseOutlined />
</button> </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> </div>
{loading ? ( {loading ? (
<div className="preview-loading"> <div className="preview-loading">
@ -145,7 +187,11 @@ function FileSharePage() {
<VirtualPDFViewer url={contentInfo.document_url} filename={contentInfo.filename} /> <VirtualPDFViewer url={contentInfo.document_url} filename={contentInfo.filename} />
) : ( ) : (
<div className="markdown-body"> <div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug, rehypeHighlight]}> <ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={markdownComponents}
>
{contentInfo?.content || ''} {contentInfo?.content || ''}
</ReactMarkdown> </ReactMarkdown>
</div> </div>

View File

@ -247,7 +247,34 @@
align-items: center; 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; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; 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 handleSearch = async (value) => {
const keyword = value || '' const keyword = value || ''
setSearchKeyword(keyword) setSearchKeyword(keyword)
@ -297,6 +322,14 @@ function ProjectSharePage() {
return dirParts.join('/') 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) => { const openSharedFile = (key) => {
setSelectedFile(key) setSelectedFile(key)
setSelectedNodeKey(key) setSelectedNodeKey(key)
@ -324,16 +357,14 @@ function ProjectSharePage() {
} }
const handleMarkdownLink = (e, href) => { const handleMarkdownLink = (e, href) => {
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) return const normalizedHref = normalizeMarkdownHref(href)
const isMd = href.endsWith('.md') if (!normalizedHref || isExternalHref(normalizedHref) || normalizedHref.startsWith('#')) return
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 if (!isMd && !isPdf) return
e.preventDefault() e.preventDefault()
let decodedHref = href openSharedFile(resolveMarkdownTarget(pathOnly))
try {
decodedHref = decodeURIComponent(href)
} catch {}
openSharedFile(resolveRelativePath(selectedFile, decodedHref))
} }
const handleExportPDF = () => { const handleExportPDF = () => {
@ -355,6 +386,27 @@ function ProjectSharePage() {
[filteredTreeData, openKeys] [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 ( return (
<div className="preview-page"> <div className="preview-page">
<Layout className="preview-layout"> <Layout className="preview-layout">
@ -454,7 +506,10 @@ function ProjectSharePage() {
<Layout className="preview-content-layout"> <Layout className="preview-content-layout">
<Content className="preview-content" ref={contentRef}> <Content className="preview-content" ref={contentRef}>
<div className="preview-content-header"> <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>
<div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}> <div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? ( {loading ? (
@ -467,13 +522,18 @@ function ProjectSharePage() {
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} /> <VirtualPDFViewer url={pdfUrl} filename={pdfFilename} />
) : ( ) : (
<div className="markdown-body" onClick={(e) => { <div className="markdown-body" onClick={(e) => {
if (e.defaultPrevented) return
const target = e.target.closest('a') const target = e.target.closest('a')
if (target) { if (target) {
const href = target.getAttribute('href') const href = target.getAttribute('href')
if (href) handleMarkdownLink(e, href) if (href) handleMarkdownLink(e, href)
} }
}} ref={viewerRef}> }} ref={viewerRef}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug, rehypeHighlight]}> <ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={markdownComponents}
>
{markdownContent} {markdownContent}
</ReactMarkdown> </ReactMarkdown>
</div> </div>

View File

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