v0.9.7
parent
ccb61b00fa
commit
5256d20ac9
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue