diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index 2b7b02b..de17308 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -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; } diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index e5cbfb2..87a8f9d 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -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() {
-

{selectedFile || '请选择文件'}

+

+ + {headerLabel} +

diff --git a/frontend/src/pages/Document/DocumentPage.css b/frontend/src/pages/Document/DocumentPage.css index ff425ea..f586fbe 100644 --- a/frontend/src/pages/Document/DocumentPage.css +++ b/frontend/src/pages/Document/DocumentPage.css @@ -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 { diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index af1f4d8..36ca8bf 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -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 ( { - const isExternal = href && (href.startsWith('http') || href.startsWith('//')); + const isExternal = isExternalHref(href); return ( -
-

{selectedFile || 'README.md'}

+
+ {(() => { + const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile) + return ( +
+ + + {fileName} + +
+ ) + })()}
{loading ? ( diff --git a/frontend/src/pages/Preview/FileSharePage.jsx b/frontend/src/pages/Preview/FileSharePage.jsx index a511eb0..ed4a9b2 100644 --- a/frontend/src/pages/Preview/FileSharePage.jsx +++ b/frontend/src/pages/Preview/FileSharePage.jsx @@ -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 ( + handleMarkdownLink(e, href)} + target={isExternal ? '_blank' : undefined} + rel={isExternal ? 'noopener noreferrer' : undefined} + {...props} + > + {children} + + ) + }, + } + + const isHeaderPdf = contentInfo?.type === 'pdf' + const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined + const headerLabel = contentInfo?.filename || '文件分享' + return (
@@ -131,7 +170,10 @@ function FileSharePage() { > -

{contentInfo?.filename || shareInfo?.name || '文件分享'}

+

+ + {headerLabel} +

{loading ? (
@@ -145,7 +187,11 @@ function FileSharePage() { ) : (
- + {contentInfo?.content || ''}
diff --git a/frontend/src/pages/Preview/PreviewPage.css b/frontend/src/pages/Preview/PreviewPage.css index a84923b..026df0a 100644 --- a/frontend/src/pages/Preview/PreviewPage.css +++ b/frontend/src/pages/Preview/PreviewPage.css @@ -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; diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx index f708765..c1b8e27 100644 --- a/frontend/src/pages/Preview/ProjectSharePage.jsx +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -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 ( + handleMarkdownLink(e, href)} + target={isExternal ? '_blank' : undefined} + rel={isExternal ? 'noopener noreferrer' : undefined} + {...props} + > + {children} + + ) + }, + } + + const isHeaderPdf = selectedFile.toLowerCase().endsWith('.pdf') + const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined + const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : 'README.md' + return (
@@ -454,7 +506,10 @@ function ProjectSharePage() {
-

{selectedFile || 'README.md'}

+

+ + {headerLabel} +

{loading ? ( @@ -467,13 +522,18 @@ function ProjectSharePage() { ) : (
{ + if (e.defaultPrevented) return const target = e.target.closest('a') if (target) { const href = target.getAttribute('href') if (href) handleMarkdownLink(e, href) } }} ref={viewerRef}> - + {markdownContent}
diff --git a/frontend/src/pages/ProjectList/ProjectList.jsx b/frontend/src/pages/ProjectList/ProjectList.jsx index 5740a93..0cfba40 100644 --- a/frontend/src/pages/ProjectList/ProjectList.jsx +++ b/frontend/src/pages/ProjectList/ProjectList.jsx @@ -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' }) {
) : isPublicEnablePending ? (
- 保存项目后将自动生成新的项目分享链接。 + 保存项目后将自动生成项目分享链接。
) : ( <>