Private
Public Access
1
0

新增:Markdown 本地文件链接支持 + Shell 语法高亮

Markdown 预览增强:
- 支持点击本地文件链接(相对路径)打开对应文件
- 支持链接文本中的加粗/斜体等内联语法
- 锚点链接保持页面内跳转,外部链接新窗口打开

代码高亮增强:
- 添加 sh/bash/shell 语言别名映射
- 安装 @codemirror/legacy-modes 支持 .sh 文件语法高亮
This commit is contained in:
2026-02-27 18:09:27 +08:00
parent a6f99e0c49
commit c5e6ff3ba6
7 changed files with 268 additions and 73 deletions

10
web/package-lock.json generated
View File

@@ -24,6 +24,7 @@
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.3",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.8",
@@ -393,6 +394,15 @@
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.2",
"resolved": "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
"integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.2.tgz",

View File

@@ -24,6 +24,7 @@
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.3",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.8",

View File

@@ -10,6 +10,7 @@
<template v-else-if="config.isMarkdownFile">📝 Markdown 预览</template>
<template v-else-if="config.isExcelFile">📊 Excel 预览</template>
<template v-else-if="config.isWordFile">📄 Word 预览</template>
<template v-else-if="config.isCsvFile">📋 CSV 预览</template>
<template v-else>📝 文件内容</template>
</span>
<div v-if="config.currentFileName" class="filename-with-copy">
@@ -99,6 +100,16 @@
</div>
</div>
<!-- CSV 预览 -->
<div v-else-if="config.isCsvFile" class="office-preview">
<div class="office-preview-container" ref="csvPreviewRef">
<a-spin v-if="config.officeLoading" :loading="true" tip="加载中...">
<div class="loading-placeholder"></div>
</a-spin>
<a-alert v-else-if="config.officeError" type="error" :message="config.officeError" />
</div>
</div>
<!-- HTML 预览/编辑 -->
<div v-else-if="config.isHtmlFile" class="html-preview-wrapper">
<!-- 编辑模式/预览模式切换按钮 -->
@@ -203,7 +214,7 @@
</div>
<!-- 预览模式 -->
<div v-if="!config.isEditMode" class="markdown-preview-content markdown-content" v-html="config.rendered"></div>
<div v-if="!config.isEditMode" ref="markdownPreviewRef" class="markdown-preview-content markdown-content" v-html="config.rendered"></div>
<!-- 编辑模式 -->
<div v-else class="markdown-edit-wrapper">
@@ -276,14 +287,13 @@
</template>
<script setup lang="ts">
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted } from 'vue'
import { computed, watch, nextTick, defineAsyncComponent, ref, onUnmounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon'
import { getFileName } from '@/utils/fileUtils'
import type { FileEditorPanelConfig } from '@/types/file-system'
import { renderMermaidDiagrams } from '@/utils/markedExtensions'
import { previewExcel, previewWord } from '@/utils/filePreviewHandlers'
import { getFileServerURL } from '@/api/system'
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
// 异步加载 CodeEditor 组件,减少初始包大小
const AsyncCodeEditor = defineAsyncComponent({
@@ -295,6 +305,10 @@ const AsyncCodeEditor = defineAsyncComponent({
// Office 预览容器引用
const excelPreviewRef = ref<HTMLElement | null>(null)
const wordPreviewRef = ref<HTMLElement | null>(null)
const csvPreviewRef = ref<HTMLElement | null>(null)
// Markdown 预览容器引用
const markdownPreviewRef = ref<HTMLElement | null>(null)
// Props
interface Props {
@@ -316,6 +330,7 @@ interface Emits {
(e: 'contentUpdate', content: string): void
(e: 'imageLoad', dimensions: string): void
(e: 'imageError'): void
(e: 'openLocalFile', link: string): void
}
const emit = defineEmits<Emits>()
@@ -437,63 +452,56 @@ watch(() => props.config.isEditMode, async (newVal, oldVal) => {
})
// 监听 Excel 文件变化,触发预览
watch(() => [props.config.isExcelFile, props.config.currentFileFullPath], async ([isExcel, filePath]) => {
watch(() => [props.config.isExcelFile, props.config.currentFileFullPath] as const, async ([isExcel, filePath]) => {
if (isExcel && filePath && excelPreviewRef.value) {
await loadExcelPreview(filePath)
}
}, { immediate: true })
// 监听 Word 文件变化,触发预览
watch(() => [props.config.isWordFile, props.config.currentFileFullPath], async ([isWord, filePath]) => {
watch(() => [props.config.isWordFile, props.config.currentFileFullPath] as const, async ([isWord, filePath]) => {
if (isWord && filePath && wordPreviewRef.value) {
await loadWordPreview(filePath)
}
}, { immediate: true })
// Excel 预览加载
// 监听 CSV 文件变化,触发预览
watch(() => [props.config.isCsvFile, props.config.currentFileFullPath] as const, async ([isCsv, filePath]) => {
if (isCsv && filePath) {
await nextTick()
if (csvPreviewRef.value) {
await loadCsvPreview(filePath)
}
}
}, { immediate: true })
// Excel 预览加载(直接使用本地文件服务器,秒开)
const loadExcelPreview = async (filePath: string) => {
if (!excelPreviewRef.value) return
try {
// 清空容器
excelPreviewRef.value.innerHTML = ''
// 使用本地文件服务器获取文件内容
const serverURL = await getFileServerURL()
console.log('[loadExcelPreview] 文件路径:', filePath)
console.log('[loadExcelPreview] 服务器 URL:', serverURL)
// Windows 路径处理:将反斜杠替换为正斜杠
const normalizedPath = filePath.replace(/\\/g, '/')
console.log('[loadExcelPreview] 规范化路径:', normalizedPath)
const encodedPath = encodeURIComponent(normalizedPath)
const fullURL = `${serverURL}/localfs/${encodedPath}`
console.log('[loadExcelPreview] 完整 URL:', fullURL)
const response = await fetch(fullURL)
console.log('[loadExcelPreview] 响应状态:', response.status)
if (!response.ok) throw new Error(`无法读取文件 (HTTP ${response.status})`)
excelPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
// 直接从本地文件服务器获取(不走 base64
const fileUrl = props.config.previewUrl
const response = await fetch(fileUrl)
const blob = await response.blob()
console.log('[loadExcelPreview] Blob 大小:', blob.size)
const file = new File([blob], getFileName(filePath), { type: 'application/vnd.ms-excel' })
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
const result = await previewExcel(file, excelPreviewRef.value)
if (!result.success) {
throw new Error(result.error || '预览失败')
}
} catch (error) {
console.error('[FileEditorPanel] Excel 预览失败:', error)
excelPreviewRef.value.innerHTML = `
<div class="preview-error">
<p>❌ Excel 预览失败</p>
<p class="error-detail">${error.message}</p>
<p class="error-hint">💡 提示:尝试使用外部应用打开文件</p>
</div>
`
console.error('[loadExcelPreview] 错误:', error)
if (excelPreviewRef.value) {
excelPreviewRef.value.innerHTML = `
<div class="preview-error">
<p>❌ Excel 预览失败</p>
<p class="error-detail">${error?.message || '未知错误'}</p>
</div>
`
}
}
}
@@ -502,45 +510,56 @@ const loadWordPreview = async (filePath: string) => {
if (!wordPreviewRef.value) return
try {
// 清空容器
wordPreviewRef.value.innerHTML = ''
// 使用本地文件服务器获取文件内容
const serverURL = await getFileServerURL()
console.log('[loadWordPreview] 文件路径:', filePath)
console.log('[loadWordPreview] 服务器 URL:', serverURL)
// Windows 路径处理:将反斜杠替换为正斜杠
const normalizedPath = filePath.replace(/\\/g, '/')
console.log('[loadWordPreview] 规范化路径:', normalizedPath)
const encodedPath = encodeURIComponent(normalizedPath)
const fullURL = `${serverURL}/localfs/${encodedPath}`
console.log('[loadWordPreview] 完整 URL:', fullURL)
const response = await fetch(fullURL)
console.log('[loadWordPreview] 响应状态:', response.status)
if (!response.ok) throw new Error(`无法读取文件 (HTTP ${response.status})`)
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
const fileUrl = props.config.previewUrl
const response = await fetch(fileUrl)
const blob = await response.blob()
console.log('[loadWordPreview] Blob 大小:', blob.size)
const file = new File([blob], getFileName(filePath), { type: 'application/vnd.ms-word' })
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
const result = await previewWord(file, wordPreviewRef.value)
if (!result.success) {
throw new Error(result.error || '预览失败')
}
} catch (error) {
console.error('[FileEditorPanel] Word 预览失败:', error)
wordPreviewRef.value.innerHTML = `
<div class="preview-error">
<p>❌ Word 预览失败</p>
<p class="error-detail">${error.message}</p>
<p class="error-hint">💡 提示:尝试使用外部应用打开文件</p>
</div>
`
console.error('[loadWordPreview] 错误:', error)
if (wordPreviewRef.value) {
wordPreviewRef.value.innerHTML = `
<div class="preview-error">
<p>❌ Word 预览失败</p>
<p class="error-detail">${error?.message || '未知错误'}</p>
</div>
`
}
}
}
// CSV 预览加载
const loadCsvPreview = async (filePath: string) => {
if (!csvPreviewRef.value) return
try {
csvPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
const fileUrl = props.config.previewUrl
const response = await fetch(fileUrl)
const blob = await response.blob()
const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
const result = await previewCsv(file, csvPreviewRef.value)
if (!result.success) {
throw new Error(result.error || '预览失败')
}
} catch (error) {
console.error('[loadCsvPreview] 错误:', error)
if (csvPreviewRef.value) {
csvPreviewRef.value.innerHTML = `
<div class="preview-error">
<p>❌ CSV 预览失败</p>
<p class="error-detail">${error?.message || String(error)}</p>
</div>
`
}
}
}
@@ -578,6 +597,40 @@ const handleCopyPath = () => {
Message.success('路径已复制')
})
}
// 处理 Markdown 预览中的本地文件链接点击
const handleMarkdownLinkClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
const link = target.closest('a[data-local-link]') as HTMLAnchorElement | null
if (link) {
event.preventDefault()
event.stopPropagation()
const localLink = link.getAttribute('data-local-link')
if (localLink) {
emit('openLocalFile', localLink)
}
}
}
// 监听预览区域的变化,添加/移除事件监听
watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode], [oldRefVal]) => {
// 移除旧的监听器
if (oldRefVal) {
oldRefVal.removeEventListener('click', handleMarkdownLinkClick)
}
// 添加新的监听器(仅在预览模式且有 DOM 元素时)
if (refVal && !isEditMode) {
refVal.addEventListener('click', handleMarkdownLinkClick)
}
}, { immediate: true })
onUnmounted(() => {
if (markdownPreviewRef.value) {
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
}
})
</script>
<style scoped>
@@ -798,6 +851,18 @@ const handleCopyPath = () => {
text-decoration: underline;
}
/* 本地文件链接样式 */
.markdown-preview-content :deep(a.local-file-link) {
color: rgb(var(--primary-6));
cursor: pointer;
border-bottom: 1px dashed rgb(var(--primary-6));
text-decoration: none;
}
.markdown-preview-content :deep(a.local-file-link:hover) {
background-color: rgba(var(--primary-6), 0.1);
}
.markdown-preview-content :deep(code) {
background: var(--color-fill-2);
padding: 2px 6px;

View File

@@ -63,6 +63,7 @@
@content-update="handleContentUpdate"
@image-load="handleImageLoad"
@image-error="handleImageError"
@open-local-file="handleOpenLocalFile"
/>
</div>
</div>
@@ -120,7 +121,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
// 导入工具函数
import { getFileName, sortFileList } from '@/utils/fileUtils'
import { getParentPath } from '@/utils/pathHelpers'
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile } from '@/utils/fileTypeHelpers'
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
import { listDir } from '@/api/system'
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS } from '@/utils/constants'
@@ -321,6 +322,7 @@ const fileEditorPanelConfig = computed(() => {
isMarkdownFile: isMarkdownFile(currentFileName),
isExcelFile: isExcelFile(currentFileName),
isWordFile: isWordFile(currentFileName),
isCsvFile: isCsvFile(currentFileName),
officeLoading: false,
officeError: null,
canSaveFile: canSaveFile.value,
@@ -381,6 +383,54 @@ const handleOpenFile = async (path: string) => {
}
}
// 处理 Markdown 预览中的本地文件链接点击
const handleOpenLocalFile = async (link: string) => {
if (!link) return
try {
let targetPath = link
// 如果是相对路径,基于当前 MD 文件所在目录解析
if (!link.match(/^[A-Za-z]:/) && !link.startsWith('/')) {
// 使用当前预览文件的完整路径
const currentFilePath = fileEditorPanelConfig.value.currentFileFullPath
if (currentFilePath) {
// 获取当前文件所在目录
const currentDir = getParentPath(currentFilePath)
// 解析相对路径
targetPath = resolveRelativePath(currentDir, link)
}
}
// 调用打开文件处理
await handleOpenFile(targetPath)
} catch (error) {
console.error('打开本地文件链接失败:', error)
Message.error(`无法打开文件: ${link}`)
}
}
// 解析相对路径为绝对路径
const resolveRelativePath = (basePath: string, relativePath: string): string => {
// 统一使用 / 作为分隔符
const base = basePath.replace(/\\/g, '/')
const relative = relativePath.replace(/\\/g, '/')
// 处理 ./ 和 ../
const parts = base.split('/')
const segments = relative.split('/')
for (const segment of segments) {
if (segment === '..') {
parts.pop()
} else if (segment !== '.' && segment !== '') {
parts.push(segment)
}
}
return parts.join('/')
}
const handleNavigateToZipDirectory = async (path: string) => {
// 暂时不处理 ZIP
}

View File

@@ -5,7 +5,8 @@
import {
javascript, json, yaml, html, css,
cpp, rust, go, python, php, sql, markdown, java
cpp, rust, go, python, php, sql, markdown, java,
shell, StreamLanguage
} from './codemirrorExports'
const languageCache = new Map()
@@ -68,6 +69,11 @@ export function loadLanguageExtension(language) {
case 'java':
extension = java()
break
case 'shell':
case 'bash':
case 'sh':
extension = StreamLanguage.define(shell)
break
default:
return null
}
@@ -100,7 +106,8 @@ export function getLanguageFromExtension(extension) {
php: 'php',
sql: 'sql',
markdown: 'markdown', md: 'markdown',
java: 'java'
java: 'java',
sh: 'shell', bash: 'shell', shell: 'shell', zsh: 'shell'
}
return langMap[ext] || 'text'

View File

@@ -24,3 +24,6 @@ export { php } from '@codemirror/lang-php'
export { sql } from '@codemirror/lang-sql'
export { markdown } from '@codemirror/lang-markdown'
export { java } from '@codemirror/lang-java'
// Legacy language modes (shell)
export { shell } from '@codemirror/legacy-modes/mode/shell'

View File

@@ -1,9 +1,26 @@
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/lib/common'
// 额外导入 common 包不包含的语言
import 'highlight.js/lib/languages/bash'
import 'highlight.js/lib/languages/go'
import 'highlight.js/styles/github-dark.css'
import 'highlight.js/styles/github.css'
// 语言别名映射sh -> bash 等)
const languageAliases: Record<string, string> = {
'sh': 'bash',
'shell': 'bash',
'zsh': 'bash',
'ksh': 'bash',
'ts': 'typescript',
'js': 'javascript',
'py': 'python',
'rb': 'ruby',
'yml': 'yaml',
'md': 'markdown'
}
let mermaidInstance: typeof import('mermaid').default | null = null
async function loadMermaid() {
@@ -30,7 +47,15 @@ renderer.code = function(token: any) {
return `<pre class="mermaid">${token.text}</pre>`
}
const lang = hljs.getLanguage(token.lang) ? token.lang : 'plaintext'
// 获取语言,支持别名
let lang = token.lang || 'plaintext'
lang = languageAliases[lang] || lang
// 检查语言是否支持
if (!hljs.getLanguage(lang)) {
lang = 'plaintext'
}
const highlighted = hljs.highlight(token.text, { language: lang }).value
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
}
@@ -53,6 +78,40 @@ renderer.heading = function(token: any) {
</h${depth}>`
}
// 判断是否为本地文件链接(相对路径或本地绝对路径)
const isLocalFileLink = (href: string): boolean => {
if (!href) return false
// 排除 http/https/ftp/mailto 等外部链接
if (/^(https?|ftp|mailto|tel|data):/i.test(href)) return false
// 排除锚点链接
if (href.startsWith('#')) return false
// 相对路径或本地路径(如 ./file.md, ../file.md, /path/to/file, C:\path\file
return true
}
// 自定义链接渲染器 - 支持本地文件链接
renderer.link = function(token: any) {
const href = token.href || ''
// 解析链接文本中的内联元素(如加粗、斜体等)
const text = this.parser.parseInline(token.tokens) || token.text || ''
const title = token.title || ''
const titleAttr = title ? ` title="${title}"` : ''
// 锚点链接 - 保持原样,页面内跳转
if (href.startsWith('#')) {
return `<a href="${href}"${titleAttr}>${text}</a>`
}
// 判断是否为本地文件链接
if (isLocalFileLink(href)) {
return `<a href="javascript:void(0)" data-local-link="${href}" class="local-file-link"${titleAttr}>${text}</a>`
}
// 外部链接使用默认行为
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
}
marked.use({ renderer, breaks: true, gfm: true })
export { marked }