新增:Markdown编辑器/数据库优化/安全修复
- Markdown 编辑器:实时预览、PDF 导出、独立查看器 - 数据库优化:动态连接池、查询缓存、Redis Pipeline - 窗口置顶功能 - 文件系统增强:右键菜单、编辑器集成、收藏夹重构 - 安全修复:XSS 防护、路径穿越、HTML 注入 - 代码质量:正则预编译、缓存锁优化、死代码清理
This commit is contained in:
@@ -20,6 +20,13 @@
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :content="isPinned ? '取消置顶' : '窗口置顶'">
|
||||
<a-button type="text" :class="{ 'pin-active': isPinned }" @click="handleTogglePin">
|
||||
<template #icon>
|
||||
<IconPushpin :class="{ pinned: isPinned }"/>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<ThemeToggle/>
|
||||
|
||||
<!-- 窗口控制按钮 -->
|
||||
@@ -71,9 +78,10 @@
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||
import {IconSettings} from '@arco-design/web-vue/es/icon'
|
||||
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
|
||||
import MarkdownEditor from './views/markdown-editor/index.vue'
|
||||
import DbCli from './views/db-cli/index.vue'
|
||||
import ThemeToggle from './components/ThemeToggle.vue'
|
||||
import FileSystem from './components/FileSystem/index.vue'
|
||||
@@ -81,7 +89,6 @@ import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import UpdateNotification from './components/UpdateNotification.vue'
|
||||
import {useUpdateStore} from './stores/update'
|
||||
import {useConfigStore} from './stores/config'
|
||||
import {preloadCommonLanguages} from './utils/codeMirrorLoader'
|
||||
|
||||
// 存储键
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||
@@ -91,6 +98,7 @@ const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
||||
const showSettings = ref(false)
|
||||
const isMaximized = ref(false)
|
||||
const isPinned = ref(false)
|
||||
|
||||
// 使用 stores
|
||||
const updateStore = useUpdateStore()
|
||||
@@ -129,21 +137,27 @@ const loadConfig = async () => {
|
||||
const getComponent = (key) => {
|
||||
const components = {
|
||||
'file-system': FileSystem,
|
||||
'db-cli': DbCli
|
||||
'db-cli': DbCli,
|
||||
'markdown-editor': MarkdownEditor
|
||||
}
|
||||
return components[key] || null
|
||||
}
|
||||
|
||||
// 组件挂载时加载配置
|
||||
// 禁止 Ctrl+滚轮缩放
|
||||
const preventZoom = (e: WheelEvent) => {
|
||||
if (e.ctrlKey) e.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
|
||||
// 预加载常用编辑器语言包
|
||||
preloadCommonLanguages()
|
||||
|
||||
// 设置更新事件监听
|
||||
updateStore.setupEventListeners()
|
||||
|
||||
// 禁止 Ctrl+滚轮缩放
|
||||
document.addEventListener('wheel', preventZoom, { passive: false })
|
||||
|
||||
// 延迟检查更新(启动后 3 秒,静默模式)
|
||||
setTimeout(() => {
|
||||
updateStore.checkForUpdates(true)
|
||||
@@ -152,6 +166,7 @@ onMounted(() => {
|
||||
|
||||
// 组件卸载时清理事件监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('wheel', preventZoom)
|
||||
updateStore.removeEventListeners()
|
||||
})
|
||||
|
||||
@@ -166,6 +181,16 @@ const handleMinimize = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
try {
|
||||
if (window.go?.main?.App?.WindowToggleAlwaysOnTop) {
|
||||
isPinned.value = await window.go.main.App.WindowToggleAlwaysOnTop()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换置顶失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMaximize = async () => {
|
||||
try {
|
||||
if (window.go?.main?.App?.WindowMaximize) {
|
||||
@@ -282,6 +307,25 @@ watch(activeTab, (newTab) => {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pin-active {
|
||||
color: rgb(var(--primary-6)) !important;
|
||||
}
|
||||
|
||||
.pin-active :deep(svg) {
|
||||
transform: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.header-actions :deep(.arco-icon-pushpin) {
|
||||
transform: rotate(45deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-actions :deep(.arco-icon-pushpin.pinned) {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-control-btn svg {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
style="cursor: pointer; margin-bottom: 4px"
|
||||
>
|
||||
<template #icon>
|
||||
<span>{{ fav.is_dir ? '📁' : '📄' }}</span>
|
||||
<span>{{ fav.isDir ? '📁' : '📄' }}</span>
|
||||
</template>
|
||||
{{ fav.name }}
|
||||
</a-tag>
|
||||
@@ -504,7 +504,7 @@ const openFavoriteFile = (path) => {
|
||||
addToHistory(path)
|
||||
|
||||
const fav = favoriteFiles.value.find(f => f.path === path)
|
||||
if (fav && fav.is_dir) {
|
||||
if (fav && fav.isDir) {
|
||||
listDirectory()
|
||||
} else {
|
||||
readFile()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="config.visible"
|
||||
ref="menuRef"
|
||||
class="context-menu"
|
||||
:style="{ left: config.x + 'px', top: config.y + 'px' }"
|
||||
:style="menuStyle"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 空白区域菜单 -->
|
||||
@@ -21,6 +22,16 @@
|
||||
|
||||
<!-- 文件菜单 -->
|
||||
<template v-else-if="config.context === 'file' && config.selectedFile">
|
||||
<div class="context-menu-item" @click="handleCreateFile">
|
||||
<span class="context-menu-icon">📄</span>
|
||||
<span>新建文件</span>
|
||||
<span class="context-menu-shortcut">Ctrl+N</span>
|
||||
</div>
|
||||
<div class="context-menu-item" @click="handleCreateDir">
|
||||
<span class="context-menu-icon">📁</span>
|
||||
<span>新建文件夹</span>
|
||||
<span class="context-menu-shortcut">Ctrl+Shift+N</span>
|
||||
</div>
|
||||
<div class="context-menu-divider"></div>
|
||||
<div
|
||||
v-if="!config.selectedFile.is_dir && isOfficeFile(config.selectedFile.name)"
|
||||
@@ -46,9 +57,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import type { ContextMenuConfig, FileItem } from '@/types/file-system'
|
||||
import { isOfficeFile } from '@/utils/fileTypeHelpers'
|
||||
|
||||
const menuRef = ref<HTMLElement>()
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
config: ContextMenuConfig
|
||||
@@ -64,6 +78,26 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
return { left: props.config.x + 'px', top: props.config.y + 'px' }
|
||||
})
|
||||
|
||||
// 位置修正:渲染后检查菜单是否超出视口,自动调整位置
|
||||
watch(() => props.config.visible, (visible) => {
|
||||
if (!visible) return
|
||||
nextTick(() => {
|
||||
const el = menuRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.right > window.innerWidth) {
|
||||
el.style.left = (window.innerWidth - rect.width - 4) + 'px'
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
el.style.top = (window.innerHeight - rect.height - 4) + 'px'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理菜单项点击
|
||||
*/
|
||||
|
||||
@@ -84,8 +84,8 @@ const error = ref('')
|
||||
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||
const style = ref<{ top: string; left: string }>({ top: '0px', left: '0px' })
|
||||
|
||||
const hoverTimer = ref<number | null>(null)
|
||||
const leaveTimer = ref<number | null>(null)
|
||||
const hoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const leaveTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const hoveringMenu = ref(false)
|
||||
|
||||
const menuKey = `menu-${props.item.path}-${props.level}`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="file-editor-panel" :style="{ width: width + '%' }">
|
||||
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
||||
@@ -13,7 +13,14 @@
|
||||
<template v-else-if="config.isCsvFile">📋 CSV 预览</template>
|
||||
<template v-else>📝 文件内容</template>
|
||||
</span>
|
||||
<div v-if="config.currentFileName" class="filename-with-copy">
|
||||
<div class="header-actions">
|
||||
<a-tooltip v-if="config.currentFileName" content="全屏预览 (F11)" position="left">
|
||||
<a-button size="mini" type="text" @click="toggleFullscreen">
|
||||
<icon-fullscreen v-if="!isFullscreen" />
|
||||
<icon-fullscreen-exit v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<div v-if="config.currentFileName" class="filename-with-copy">
|
||||
<a-tooltip :content="config.currentFileFullPath" position="left">
|
||||
<span
|
||||
class="panel-filename"
|
||||
@@ -28,6 +35,7 @@
|
||||
@click="handleCopyPath"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
@@ -194,6 +202,16 @@
|
||||
<template #icon><icon-save /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<!-- PDF 导出按钮(仅在预览模式显示) -->
|
||||
<a-tooltip v-if="!config.isEditMode" position="left" content="导出">
|
||||
<a-button
|
||||
type="outline"
|
||||
size="small"
|
||||
@click="handleExportPDF"
|
||||
>
|
||||
<template #icon><icon-file-pdf /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<!-- 预览/编辑切换按钮 -->
|
||||
<a-tooltip
|
||||
position="left"
|
||||
@@ -288,7 +306,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileName } from '@/utils/fileUtils'
|
||||
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
@@ -309,6 +327,30 @@ const csvPreviewRef = ref<HTMLElement | null>(null)
|
||||
// Markdown 预览容器引用
|
||||
const markdownPreviewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 全屏
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!panelRef.value) return
|
||||
if (!document.fullscreenElement) {
|
||||
panelRef.value.requestFullscreen().then(() => { isFullscreen.value = true })
|
||||
} else {
|
||||
document.exitFullscreen().then(() => { isFullscreen.value = false })
|
||||
}
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'F11' && props.config.currentFileName) {
|
||||
e.preventDefault()
|
||||
toggleFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
config: FileEditorPanelConfig
|
||||
@@ -400,6 +442,165 @@ const handleImageError = () => {
|
||||
emit('imageError')
|
||||
}
|
||||
|
||||
// Markdown PDF 导出处理
|
||||
const handleExportPDF = async () => {
|
||||
try {
|
||||
// 获取 Markdown 预览容器
|
||||
const markdownContent = markdownPreviewRef.value
|
||||
if (!markdownContent) {
|
||||
Message.error('无法获取 Markdown 内容')
|
||||
return
|
||||
}
|
||||
|
||||
// 打开打印窗口
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) {
|
||||
Message.error('无法打开打印窗口,请检查浏览器设置')
|
||||
return
|
||||
}
|
||||
|
||||
// 设置打印样式
|
||||
const style = `
|
||||
<style>
|
||||
@media print {
|
||||
body {
|
||||
font-size: 12pt;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 24pt;
|
||||
margin-bottom: 12pt;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 18pt;
|
||||
margin-bottom: 10pt;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 14pt;
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-bottom: 4pt;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 12pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #ddd;
|
||||
margin: 16px 0;
|
||||
padding: 10px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 优化屏幕显示 */
|
||||
.markdown-content {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
||||
// 构建打印页面
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${props.config.currentFileName || 'Markdown 导出 PDF'}</title>
|
||||
${style}
|
||||
</head>
|
||||
<body>
|
||||
<div class="markdown-content">
|
||||
${markdownContent.innerHTML}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
printWindow.document.close()
|
||||
|
||||
// 延迟一点时间让样式加载完成
|
||||
setTimeout(() => {
|
||||
printWindow.print()
|
||||
// 不关闭窗口,让用户可以手动关闭或继续打印
|
||||
}, 500)
|
||||
|
||||
Message.success('PDF 导出窗口已打开')
|
||||
} catch (error) {
|
||||
console.error('[handleExportPDF] 导出失败:', error)
|
||||
Message.error('PDF 导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听模式切换,切换到预览模式时渲染 Mermaid 图表
|
||||
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
|
||||
// 从编辑模式切换到预览模式
|
||||
@@ -607,9 +808,11 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 iframe 的 postMessage
|
||||
// 监听 iframe 的 postMessage + 全屏事件
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleHtmlIframeMessage)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -617,6 +820,8 @@ onUnmounted(() => {
|
||||
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
|
||||
}
|
||||
window.removeEventListener('message', handleHtmlIframeMessage)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -628,6 +833,12 @@ onUnmounted(() => {
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.file-editor-panel:fullscreen {
|
||||
width: 100vw !important;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -641,8 +852,25 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-header > * {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
/* 仅全屏模式下 header 可拖动窗口 */
|
||||
.file-editor-panel:fullscreen .panel-header {
|
||||
--wails-draggable: drag;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
color: var(--color-text-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filename-with-copy {
|
||||
@@ -1056,16 +1284,61 @@ onUnmounted(() => {
|
||||
fill: var(--color-text-1);
|
||||
}
|
||||
|
||||
/* ========== 深色模式适配 ========== */
|
||||
/* ========== 代码高亮主题色(不依赖 hljs 主题 CSS) ========== */
|
||||
|
||||
/* Mermaid 图表深色模式 */
|
||||
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
/* 亮色模式 - GitHub 配色 */
|
||||
.markdown-preview-content :deep(.hljs) {
|
||||
color: #24292e;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid *) {
|
||||
color: var(--color-text-1) !important;
|
||||
stroke: var(--color-text-1) !important;
|
||||
.markdown-preview-content :deep(.hljs-comment),
|
||||
.markdown-preview-content :deep(.hljs-quote) { color: #6a737d; font-style: italic; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-keyword),
|
||||
.markdown-preview-content :deep(.hljs-selector-tag),
|
||||
.markdown-preview-content :deep(.hljs-subst) { color: #d73a49; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-string),
|
||||
.markdown-preview-content :deep(.hljs-doctag) { color: #032f62; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-number),
|
||||
.markdown-preview-content :deep(.hljs-literal),
|
||||
.markdown-preview-content :deep(.hljs-variable),
|
||||
.markdown-preview-content :deep(.hljs-template-variable),
|
||||
.markdown-preview-content :deep(.hljs-tag .hljs-attr) { color: #005cc5; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-title),
|
||||
.markdown-preview-content :deep(.hljs-section),
|
||||
.markdown-preview-content :deep(.hljs-selector-id) { color: #6f42c1; font-weight: bold; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-type),
|
||||
.markdown-preview-content :deep(.hljs-class .hljs-title) { color: #6f42c1; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-tag .hljs-keyword),
|
||||
.markdown-preview-content :deep(.hljs-tag .hljs-title) { color: #22863a; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-bullet) { color: #e36209; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-symbol) { color: #005cc5; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-built_in),
|
||||
.markdown-preview-content :deep(.hljs-type) { color: #005cc5; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-attr) { color: #e36209; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-meta) { color: #735c0f; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-addition) { color: #22863a; background-color: #f0fff4; }
|
||||
|
||||
.markdown-preview-content :deep(.hljs-deletion) { color: #b31d28; background-color: #ffeef0; }
|
||||
|
||||
/* ========== 深色模式适配 ========== */
|
||||
|
||||
/* Mermaid 图表深色模式 - 使用原生 dark 主题,仅需背景适配 */
|
||||
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 代码高亮深色模式 - 使用 CSS 自定义属性 */
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<span v-else class="file-item-name" :title="file.name">{{ file.name }}</span>
|
||||
|
||||
<!-- 文件大小 -->
|
||||
<span v-if="!file.is_dir && !isEditing" class="file-item-size">
|
||||
<span v-if="!file.isDir && !isEditing" class="file-item-size">
|
||||
{{ formattedSize }}
|
||||
</span>
|
||||
|
||||
@@ -54,8 +54,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
|
||||
import { formatBytes } from '@/utils/fileUtils'
|
||||
import { getFileIcon } from '@/utils/fileUtils'
|
||||
import { formatBytes, getFileIcon } from '@/utils/fileUtils'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
// Props
|
||||
|
||||
@@ -55,9 +55,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, provide, type Ref } from 'vue'
|
||||
import { IconRight, IconFolder, IconFile, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { IconRight, IconFolder } from '@arco-design/web-vue/es/icon'
|
||||
import { listDir } from '@/api/system'
|
||||
import { getParentPath, joinPath, normalizePathSeparators } from '@/utils/pathHelpers'
|
||||
import { sortFileList } from '@/utils/fileUtils'
|
||||
import { useTimeout } from '@/composables/useTimeout'
|
||||
import DropdownItem from './DropdownItem.vue'
|
||||
@@ -118,17 +117,22 @@ const segments = computed<PathSegment[]>(() => {
|
||||
})
|
||||
|
||||
const activeIndex = ref<number | null>(null)
|
||||
const hoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const closeTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const lastLoadedPath = ref('')
|
||||
|
||||
const loadChildren = async (path: string) => {
|
||||
if (path === lastLoadedPath.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const files = await listDir(path)
|
||||
lastLoadedPath.value = path
|
||||
children.value = sortFileList(files.map(f => ({
|
||||
name: f.name,
|
||||
path: f.path,
|
||||
@@ -150,17 +154,22 @@ const resetAndClose = () => {
|
||||
const onHover = (segment: PathSegment, index: number) => {
|
||||
if (index === segments.value.length - 1) return
|
||||
|
||||
delay(() => {
|
||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||
if (closeTimer.value) clearTimeout(closeTimer.value)
|
||||
|
||||
hoverTimer.value = delay(() => {
|
||||
activeIndex.value = index
|
||||
loadChildren(segment.path)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onMenuEnter = () => {
|
||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||
if (closeTimer.value) clearTimeout(closeTimer.value)
|
||||
}
|
||||
|
||||
const onMenuLeave = () => {
|
||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||
closeTimer.value = delay(() => {
|
||||
resetAndClose()
|
||||
}, 100)
|
||||
@@ -184,6 +193,7 @@ const onOpenFile = (path: string) => {
|
||||
watch(() => props.path, () => {
|
||||
activeIndex.value = null
|
||||
children.value = []
|
||||
lastLoadedPath.value = ''
|
||||
openMenus.value = new Map()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="config.fileLoading"
|
||||
@click="handleRefresh"
|
||||
@@ -137,14 +136,6 @@ interface Emits {
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 事件处理
|
||||
const handlePathUpdate = (path: string) => {
|
||||
emit('update:filePath', path)
|
||||
}
|
||||
|
||||
const handlePathSelect = (value: string) => {
|
||||
emit('goToPath', value)
|
||||
}
|
||||
|
||||
const handleGoToPath = (path: string) => {
|
||||
emit('goToPath', path)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
* 提供收藏文件的添加、删除、排序等功能
|
||||
*/
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import { STORAGE_KEYS } from '@/utils/constants'
|
||||
import { ref } from 'vue'
|
||||
import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system'
|
||||
|
||||
export function useFavorites() {
|
||||
@@ -67,13 +69,23 @@ export function useFavorites() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化路径用于比较(Windows 大小写不敏感)
|
||||
*/
|
||||
const normalizePath = (path: string): string => {
|
||||
return path.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加收藏
|
||||
*/
|
||||
const addFavorite = (file: FileItem) => {
|
||||
// 检查是否已存在
|
||||
const exists = favorites.value.some(fav => fav.path === file.path)
|
||||
if (exists) {
|
||||
if (isFavorite(file.path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (favorites.value.length >= DEFAULTS.MAX_FAVORITES_LENGTH) {
|
||||
Message.warning(`收藏夹已满,最多收藏 ${DEFAULTS.MAX_FAVORITES_LENGTH} 项`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -81,17 +93,11 @@ export function useFavorites() {
|
||||
...file,
|
||||
addedAt: Date.now()
|
||||
} as FavoriteFile)
|
||||
sortFavorites()
|
||||
saveFavorites()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化路径用于比较(后端已统一为 /,直接转小写)
|
||||
*/
|
||||
const normalizePath = (path: string): string => {
|
||||
return path.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除收藏
|
||||
*/
|
||||
@@ -108,14 +114,12 @@ export function useFavorites() {
|
||||
* 切换收藏状态
|
||||
*/
|
||||
const toggleFavorite = (file: FileItem) => {
|
||||
const exists = isFavorite(file.path)
|
||||
if (exists) {
|
||||
if (isFavorite(file.path)) {
|
||||
removeFavorite(file.path)
|
||||
return false
|
||||
} else {
|
||||
addFavorite(file)
|
||||
return true
|
||||
}
|
||||
addFavorite(file)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,15 +135,9 @@ export function useFavorites() {
|
||||
*/
|
||||
const togglePin = (path: string) => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
|
||||
const fav = favorites.value.find(f => normalizePath(fav.path) === normalizedPath)
|
||||
if (fav) {
|
||||
if (fav.pinnedAt) {
|
||||
// 取消置顶
|
||||
fav.pinnedAt = undefined
|
||||
} else {
|
||||
// 设置置顶
|
||||
fav.pinnedAt = Date.now()
|
||||
}
|
||||
fav.pinnedAt = fav.pinnedAt ? undefined : Date.now()
|
||||
sortFavorites()
|
||||
saveFavorites()
|
||||
}
|
||||
@@ -150,28 +148,37 @@ export function useFavorites() {
|
||||
*/
|
||||
const isPinned = (path: string): boolean => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
|
||||
const fav = favorites.value.find(f => normalizePath(fav.path) === normalizedPath)
|
||||
return !!fav?.pinnedAt
|
||||
}
|
||||
|
||||
/**
|
||||
* 长按开始
|
||||
* 更新收藏项路径(重命名时使用,保留置顶状态和添加时间)
|
||||
*/
|
||||
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||
const isMouse = event instanceof MouseEvent
|
||||
const isTouch = event instanceof TouchEvent
|
||||
const updateFavoritePath = (oldPath: string, newName: string) => {
|
||||
const normalizedOld = normalizePath(oldPath)
|
||||
const fav = favorites.value.find(f => normalizePath(fav.path) === normalizedOld)
|
||||
if (!fav) return
|
||||
|
||||
// 只支持鼠标左键或触摸
|
||||
if (isMouse && event.button !== 0) return
|
||||
if (!isMouse && !isTouch) return
|
||||
const separator = getPathSeparator(oldPath)
|
||||
const parentPath = oldPath.substring(
|
||||
0,
|
||||
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
||||
)
|
||||
fav.path = parentPath + separator + newName
|
||||
fav.name = newName
|
||||
saveFavorites()
|
||||
}
|
||||
|
||||
// 拖拽方法
|
||||
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||
if (event instanceof MouseEvent && event.button !== 0) return
|
||||
if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) return
|
||||
|
||||
draggingState.value.pressedIndex = index
|
||||
draggingState.value.draggedIndex = index
|
||||
}
|
||||
|
||||
/**
|
||||
* 长按取消
|
||||
*/
|
||||
const onLongPressCancel = () => {
|
||||
if (!draggingState.value.isDragging) {
|
||||
draggingState.value.pressedIndex = -1
|
||||
@@ -179,23 +186,15 @@ export function useFavorites() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽开始
|
||||
*/
|
||||
const onDragStart = (event: DragEvent, index: number) => {
|
||||
draggingState.value.isDragging = true
|
||||
draggingState.value.draggedIndex = index
|
||||
|
||||
// 设置拖拽数据
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', index.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽经过
|
||||
*/
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
@@ -203,81 +202,53 @@ export function useFavorites() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 放置
|
||||
*/
|
||||
const onDrop = (event: DragEvent, targetIndex: number) => {
|
||||
event.preventDefault()
|
||||
|
||||
const fromIndex = draggingState.value.draggedIndex
|
||||
const toIndex = targetIndex
|
||||
|
||||
if (fromIndex === toIndex || fromIndex === -1) {
|
||||
if (fromIndex === targetIndex || fromIndex === -1) {
|
||||
resetDragging()
|
||||
return
|
||||
}
|
||||
|
||||
// 移动元素
|
||||
const item = favorites.value.splice(fromIndex, 1)[0]
|
||||
favorites.value.splice(toIndex, 0, item)
|
||||
favorites.value.splice(targetIndex, 0, item)
|
||||
saveFavorites()
|
||||
|
||||
resetDragging()
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽结束
|
||||
*/
|
||||
const onDragEnd = () => {
|
||||
resetDragging()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置拖拽状态
|
||||
*/
|
||||
const resetDragging = () => {
|
||||
draggingState.value.isDragging = false
|
||||
draggingState.value.draggedIndex = -1
|
||||
draggingState.value.pressedIndex = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新排序
|
||||
*/
|
||||
const reorder = (fromIndex: number, toIndex: number) => {
|
||||
if (fromIndex === toIndex) return
|
||||
|
||||
const item = favorites.value.splice(fromIndex, 1)[0]
|
||||
favorites.value.splice(toIndex, 0, item)
|
||||
saveFavorites()
|
||||
}
|
||||
|
||||
// 组件挂载时加载收藏列表
|
||||
loadFavorites()
|
||||
|
||||
return {
|
||||
// 状态
|
||||
favorites,
|
||||
draggingState,
|
||||
|
||||
// 方法
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
togglePin,
|
||||
isPinned,
|
||||
updateFavoritePath,
|
||||
|
||||
// 拖拽方法
|
||||
onLongPressStart,
|
||||
onLongPressCancel,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
reorder,
|
||||
|
||||
// 工具方法
|
||||
loadFavorites,
|
||||
saveFavorites,
|
||||
resetDragging
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { STORAGE_KEYS, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { getExt } from '@/utils/fileUtils'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isExcelFile, isWordFile, isCsvFile,
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import { useFileOperations } from './useFileOperations'
|
||||
|
||||
export interface UseFileEditOptions {
|
||||
@@ -63,96 +69,29 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*/
|
||||
const getFileExtension = (filepath: any): string => {
|
||||
const path = getFilePath(filepath)
|
||||
if (!path || typeof path !== 'string') return ''
|
||||
return path.split('.').pop()?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为图片文件
|
||||
*/
|
||||
const isImageFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
if (!ext) return false
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视频文件
|
||||
*/
|
||||
const isVideoFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
if (!ext) return false
|
||||
return FILE_EXTENSIONS.VIDEO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
*/
|
||||
const isAudioFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
if (!ext) return false
|
||||
return FILE_EXTENSIONS.AUDIO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 PDF 文件
|
||||
*/
|
||||
const isPdfFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
return ext === 'pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Excel 文件
|
||||
*/
|
||||
const isExcelFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
return ['xlsx', 'xls'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Word 文件
|
||||
*/
|
||||
const isWordFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
return ['docx', 'doc'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 CSV/TSV 文件
|
||||
*/
|
||||
const isCsvFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
return ['csv', 'tsv'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为二进制文件(基于扩展名)
|
||||
* 注意:媒体文件(图片、视频、音频、PDF)不是二进制文件,它们可以预览
|
||||
* 对于无扩展名的文件,返回 null 表示未知,需要内容检测
|
||||
*/
|
||||
const isBinaryFileByExt = (filepath: any): boolean | null => {
|
||||
const ext = getFileExtension(filepath)
|
||||
const path = getFilePath(filepath)
|
||||
const ext = getExt(path)
|
||||
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
||||
|
||||
// 媒体文件(可预览,不算二进制)
|
||||
const isMediaFile = FILE_EXTENSIONS.IMAGE.includes(ext) ||
|
||||
FILE_EXTENSIONS.VIDEO.includes(ext) ||
|
||||
FILE_EXTENSIONS.AUDIO.includes(ext) ||
|
||||
['pdf', 'html', 'htm', 'md', 'markdown'].includes(ext)
|
||||
const isMediaFile = isImageFile(path) ||
|
||||
isVideoFile(path) ||
|
||||
isAudioFile(path) ||
|
||||
isPdfFile(path) ||
|
||||
['html', 'htm', 'md', 'markdown'].includes(ext)
|
||||
|
||||
// Office 文件和 CSV(可预览)
|
||||
const isOfficeFile = ['xlsx', 'xls', 'docx', 'doc', 'csv', 'tsv'].includes(ext)
|
||||
const isOfficeFile = isExcelFile(path) || isWordFile(path) || isCsvFile(path)
|
||||
|
||||
// 文本或代码文件(可编辑)
|
||||
const isTextFile = FILE_EXTENSIONS.TEXT.includes(ext) ||
|
||||
FILE_EXTENSIONS.CODE.includes(ext) ||
|
||||
FILE_EXTENSIONS.CONFIG.includes(ext)
|
||||
const isTextFile = isTextEditable(path) || isConfigFile(path) ||
|
||||
FILE_EXTENSIONS.CODE.includes(ext)
|
||||
|
||||
// 如果是媒体文件、Office 文件或文本文件,就不是二进制
|
||||
if (isMediaFile || isOfficeFile || isTextFile) return false
|
||||
@@ -243,7 +182,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
// 新内容加载完成后会直接替换旧内容
|
||||
|
||||
const filename = getFilePath(path)
|
||||
const ext = getFileExtension(filename)
|
||||
const ext = getExt(filename)
|
||||
|
||||
// Office 文件和 CSV 文件直接读取内容进行预览,跳过二进制检测
|
||||
if (isExcelFile(filename) || isWordFile(filename) || isCsvFile(filename)) {
|
||||
@@ -658,13 +597,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
setEditorHeight,
|
||||
|
||||
// 文件类型检查
|
||||
isImageFile,
|
||||
isVideoFile,
|
||||
isAudioFile,
|
||||
isPdfFile,
|
||||
isExcelFile,
|
||||
isWordFile,
|
||||
isCsvFile,
|
||||
isBinaryFileByExt,
|
||||
isFileInCurrentDirectory
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import {
|
||||
listDir,
|
||||
readFile as readFileApi,
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
createFile,
|
||||
createDir,
|
||||
renamePath as renamePathApi,
|
||||
listZipContents,
|
||||
listZipContents as listZipContentsApi,
|
||||
extractFileFromZip,
|
||||
extractFileFromZipToTemp,
|
||||
getFileServerURL
|
||||
extractFileFromZipToTemp as extractZipToTempApi,
|
||||
getFileServerURL as getFileServerUrlApi
|
||||
} from '@/api'
|
||||
import type { FileOperationResult } from '@/types/file-system'
|
||||
import type { FileItem, FileOperationResult } from '@/types/file-system'
|
||||
|
||||
export interface UseFileOperationsOptions {
|
||||
onSuccess?: (operation: string, data: any) => void
|
||||
@@ -133,7 +133,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
||||
*/
|
||||
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
|
||||
// 构造新路径
|
||||
const separator = oldPath.includes('\\') ? '\\' : '/'
|
||||
const separator = getPathSeparator(oldPath)
|
||||
const parentPath = oldPath.substring(
|
||||
0,
|
||||
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
||||
@@ -186,7 +186,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
||||
*/
|
||||
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
|
||||
try {
|
||||
const result = await listZipContents(zipPath)
|
||||
const result = await listZipContentsApi(zipPath)
|
||||
onSuccess?.('listZipContents', { zipPath, count: result.length })
|
||||
return result
|
||||
} catch (error) {
|
||||
@@ -216,7 +216,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
||||
*/
|
||||
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
|
||||
try {
|
||||
const tempPath = await extractFileFromZipToTemp(zipPath, filePath)
|
||||
const tempPath = await extractZipToTempApi(zipPath, filePath)
|
||||
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
|
||||
return tempPath
|
||||
} catch (error) {
|
||||
@@ -231,7 +231,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
||||
*/
|
||||
const getFileServerURL = async (): Promise<string> => {
|
||||
try {
|
||||
const url = await getFileServerURL()
|
||||
const url = await getFileServerUrlApi()
|
||||
onSuccess?.('getFileServerURL', { url })
|
||||
return url
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
* 提供文件预览 URL 生成、媒体元数据获取等功能
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { normalizeFilePath } from '@/utils/fileUtils'
|
||||
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
||||
import { detectFileTypeByContent } from '@/api/system'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import type { FilePreviewMetadata, FileType } from '@/types/file-system'
|
||||
|
||||
// 内容检测大小限制(与后端一致)
|
||||
@@ -81,159 +86,42 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const getFileType = (filename: string): FileType => {
|
||||
if (!filename || typeof filename !== 'string') return 'Binary' as FileType
|
||||
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
if (isImageFile(filename)) return 'Image' as FileType
|
||||
if (isVideoFile(filename)) return 'Video' as FileType
|
||||
if (isAudioFile(filename)) return 'Audio' as FileType
|
||||
if (isPdfFile(filename)) return 'Pdf' as FileType
|
||||
if (isHtmlFile(filename)) return 'Html' as FileType
|
||||
if (isMarkdownFile(filename)) return 'Markdown' as FileType
|
||||
if (FILE_EXTENSIONS.CODE.includes(getExt(filename))) return 'Code' as FileType
|
||||
if (isConfigFile(filename)) return 'Code' as FileType
|
||||
if (isTextEditable(filename)) return 'Text' as FileType
|
||||
|
||||
// 图片
|
||||
if (FILE_EXTENSIONS.IMAGE.includes(ext)) {
|
||||
return 'Image' as FileType
|
||||
}
|
||||
|
||||
// 视频
|
||||
if (FILE_EXTENSIONS.VIDEO.includes(ext)) {
|
||||
return 'Video' as FileType
|
||||
}
|
||||
|
||||
// 音频
|
||||
if (FILE_EXTENSIONS.AUDIO.includes(ext)) {
|
||||
return 'Audio' as FileType
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (ext === 'pdf') {
|
||||
return 'Pdf' as FileType
|
||||
}
|
||||
|
||||
// HTML
|
||||
if (['html', 'htm'].includes(ext)) {
|
||||
return 'Html' as FileType
|
||||
}
|
||||
|
||||
// Markdown
|
||||
if (['md', 'markdown'].includes(ext)) {
|
||||
return 'Markdown' as FileType
|
||||
}
|
||||
|
||||
// 代码
|
||||
if (FILE_EXTENSIONS.CODE.includes(ext)) {
|
||||
return 'Code' as FileType
|
||||
}
|
||||
|
||||
// 配置文件(返回 Code 类型,因为它们也是可编辑的文本格式)
|
||||
if (FILE_EXTENSIONS.CONFIG.includes(ext)) {
|
||||
return 'Code' as FileType
|
||||
}
|
||||
|
||||
// 文本
|
||||
if (FILE_EXTENSIONS.TEXT.includes(ext)) {
|
||||
return 'Text' as FileType
|
||||
}
|
||||
|
||||
// 默认为二进制
|
||||
return 'Binary' as FileType
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为图片文件
|
||||
*/
|
||||
const isImageFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视频文件
|
||||
*/
|
||||
const isVideoFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.VIDEO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
*/
|
||||
const isAudioFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.AUDIO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 PDF 文件
|
||||
*/
|
||||
const isPdfFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return ext === 'pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 HTML 文件
|
||||
*/
|
||||
const isHtmlFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return ['html', 'htm'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Markdown 文件
|
||||
*/
|
||||
const isMarkdownFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return ['md', 'markdown'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为代码文件
|
||||
*/
|
||||
const isCodeFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.CODE.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为文本文件
|
||||
*/
|
||||
const isTextFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.TEXT.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可预览
|
||||
*/
|
||||
const isPreviewable = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext) ||
|
||||
FILE_EXTENSIONS.VIDEO.includes(ext) ||
|
||||
FILE_EXTENSIONS.AUDIO.includes(ext) ||
|
||||
ext === 'pdf' ||
|
||||
['html', 'htm'].includes(ext) ||
|
||||
['md', 'markdown'].includes(ext)
|
||||
return isPreviewableType(filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可编辑
|
||||
*/
|
||||
const isEditable = (filename: string, fileSize: number): boolean => {
|
||||
// 检查文件大小
|
||||
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
const ext = getExt(filename)
|
||||
return FILE_EXTENSIONS.CODE.includes(ext) ||
|
||||
FILE_EXTENSIONS.TEXT.includes(ext) ||
|
||||
FILE_EXTENSIONS.CONFIG.includes(ext) ||
|
||||
['html', 'htm', 'md', 'markdown'].includes(ext)
|
||||
isTextEditable(filename) ||
|
||||
isConfigFile(filename) ||
|
||||
isHtmlFile(filename) ||
|
||||
isMarkdownFile(filename)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,8 +194,6 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
isPdfFile,
|
||||
isHtmlFile,
|
||||
isMarkdownFile,
|
||||
isCodeFile,
|
||||
isTextFile,
|
||||
isPreviewable,
|
||||
isEditable,
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { STORAGE_KEYS } from '@/utils/constants'
|
||||
import { normalizePathSeparators } from '@/utils/pathHelpers'
|
||||
import { normalizePathSeparators } from '@/utils/fileUtils'
|
||||
import type { PathHistory } from '@/types/file-system'
|
||||
|
||||
export interface UsePathNavigationOptions {
|
||||
|
||||
@@ -99,10 +99,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick, watchEffect } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { marked, renderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
|
||||
// 导入子组件
|
||||
import Toolbar from './components/Toolbar.vue'
|
||||
@@ -121,7 +121,6 @@ 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, isCsvFile } from '@/utils/fileTypeHelpers'
|
||||
import { listDir } from '@/api/system'
|
||||
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS } from '@/utils/constants'
|
||||
@@ -225,7 +224,7 @@ const fileOps = useFileOperations({
|
||||
})
|
||||
|
||||
// 收藏夹
|
||||
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin } = useFavorites()
|
||||
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites()
|
||||
|
||||
// 路径导航
|
||||
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath } =
|
||||
@@ -463,50 +462,31 @@ const handleTogglePin = (path: string) => {
|
||||
}
|
||||
|
||||
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||
// 拖拽开始
|
||||
onLongPressStart(event, index)
|
||||
}
|
||||
|
||||
const handleLongPressCancel = () => {
|
||||
// 拖拽取消
|
||||
onLongPressCancel()
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragEvent, index: number) => {
|
||||
// 拖拽开始
|
||||
onDragStart(event, index)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
// 拖拽经过
|
||||
onDragOver(event)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent, targetIndex: number) => {
|
||||
// 放置
|
||||
onDrop(event, targetIndex)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
// 拖拽结束
|
||||
onDragEnd()
|
||||
}
|
||||
|
||||
// 文件列表事件
|
||||
const handleFileClick = async (file: FileItem) => {
|
||||
// ZIP 浏览模式 - 暂时禁用
|
||||
/*
|
||||
if (false) { // ZIP 浏览模式已禁用
|
||||
await zipBrowser.handleClick(file.path, fileList.value, {
|
||||
selectFile: (f: FileItem) => {
|
||||
selectedFileItem.value = f
|
||||
},
|
||||
isImage: isImageFile,
|
||||
extractAndPreview: extractZipImageAndPreview,
|
||||
extractAndRead: extractZipTextAndRead,
|
||||
loadZipContents: loadZipDirectoryContents,
|
||||
updateFileList: (files: FileItem[]) => {
|
||||
fileList.value = files
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
*/
|
||||
|
||||
// 正常文件系统浏览
|
||||
if (file.isDir) {
|
||||
// 目录:使用 navigate 函数,确保历史记录正确更新
|
||||
@@ -522,25 +502,6 @@ const handleFileDoubleClick = async (file: FileItem) => {
|
||||
if (file.isDir) {
|
||||
await navigate(file.path)
|
||||
} else {
|
||||
// 检查是否为 ZIP 文件 - 暂时禁用
|
||||
/*
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
||||
if (ext === 'zip' && !zipBrowser.isActive.value) {
|
||||
// ZIP 文件:进入 ZIP 浏览模式
|
||||
await zipBrowser.enter(file.path, {
|
||||
saveBeforePath: () => {
|
||||
// 保存当前路径
|
||||
return filePath.value
|
||||
},
|
||||
loadZipContents: loadZipDirectoryContents,
|
||||
updateFileList: (files: FileItem[]) => {
|
||||
fileList.value = files
|
||||
}
|
||||
})
|
||||
} else {
|
||||
selectFile(file.path)
|
||||
}
|
||||
*/
|
||||
selectFile(file.path)
|
||||
}
|
||||
}
|
||||
@@ -573,7 +534,8 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
const trimmedName = newName.trim()
|
||||
|
||||
// 如果名称没有变化,直接返回
|
||||
const oldName = oldPath.substring(oldPath.lastIndexOf('\\') + 1)
|
||||
const lastSep = Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
||||
const oldName = oldPath.substring(lastSep + 1)
|
||||
if (trimmedName === oldName) {
|
||||
editingFilePath.value = ''
|
||||
editingFileName.value = ''
|
||||
@@ -588,7 +550,7 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
}
|
||||
|
||||
// 构造新路径
|
||||
const separator = oldPath.includes('\\') ? '\\' : '/'
|
||||
const separator = getPathSeparator(oldPath)
|
||||
const dirPath = oldPath.substring(0, oldPath.lastIndexOf(separator))
|
||||
const newPath = dirPath + separator + trimmedName
|
||||
|
||||
@@ -649,10 +611,9 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
// 更新文件列表(保留收藏状态)
|
||||
updateFileInList(oldPath, renamedFile)
|
||||
|
||||
// 如果重命名的是收藏的文件,更新收藏夹中的路径
|
||||
// 如果重命名的是收藏的文件,更新收藏夹中的路径(保留置顶状态)
|
||||
if (isFavorite(oldPath)) {
|
||||
removeFav(oldPath)
|
||||
toggleFav(renamedFile)
|
||||
updateFavoritePath(oldPath, trimmedName)
|
||||
}
|
||||
|
||||
Message.success(`✓ 重命名成功: ${trimmedName}`)
|
||||
@@ -669,7 +630,6 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
|
||||
// 针对常见错误提供友好提示
|
||||
if (errorMsg.includes('being used by another process') ||
|
||||
errorMsg.includes('being used by another process') ||
|
||||
errorMsg.includes('被另一个进程占用')) {
|
||||
errorMsg = '文件正在被其他程序使用,请先关闭该文件后重试'
|
||||
if (selectedFileItem.value?.isDir) {
|
||||
@@ -799,11 +759,6 @@ const handleCreateFile = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (false) { // ZIP 浏览模式已禁用
|
||||
Message.warning('ZIP 浏览模式下不支持创建文件')
|
||||
return
|
||||
}
|
||||
|
||||
showInputDialog(
|
||||
UI_TEXT.CREATE_FILE,
|
||||
UI_TEXT.ENTER_FILE_NAME,
|
||||
@@ -831,11 +786,8 @@ const handleCreateFile = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 构建完整路径
|
||||
const fullPath = `${filePath.value}\\${fileName}`
|
||||
|
||||
try {
|
||||
const newFile = await fileOps.createNewFile(fullPath)
|
||||
const newFile = await fileOps.createNewFile(filePath.value, fileName)
|
||||
Message.success(`✓ 文件 "${fileName}" 创建成功`)
|
||||
addFileToList(newFile)
|
||||
} catch (error: any) {
|
||||
@@ -855,11 +807,6 @@ const handleCreateDir = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (false) { // ZIP 浏览模式已禁用
|
||||
Message.warning('ZIP 浏览模式下不支持创建文件夹')
|
||||
return
|
||||
}
|
||||
|
||||
showInputDialog(
|
||||
UI_TEXT.CREATE_FOLDER,
|
||||
UI_TEXT.ENTER_FOLDER_NAME,
|
||||
@@ -887,11 +834,8 @@ const handleCreateDir = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 构建完整路径
|
||||
const fullPath = `${filePath.value}\\${folderName}`
|
||||
|
||||
try {
|
||||
const newDir = await fileOps.createNewDir(fullPath)
|
||||
const newDir = await fileOps.createNewDir(filePath.value, folderName)
|
||||
Message.success(`✓ 文件夹 "${folderName}" 创建成功`)
|
||||
addFileToList(newDir)
|
||||
} catch (error: any) {
|
||||
@@ -1033,7 +977,7 @@ const selectFile = async (path: string) => {
|
||||
name: fileName,
|
||||
isDir: false,
|
||||
size: 0,
|
||||
mod_time: '',
|
||||
modified_time: '',
|
||||
is_favorite: isFavorite(path)
|
||||
}
|
||||
}
|
||||
@@ -1150,7 +1094,7 @@ const loadZipDirectoryContents = async (zipPath: string, currentDir: string): Pr
|
||||
path: f.path,
|
||||
isDir: f.isDir,
|
||||
size: f.size || 0,
|
||||
mod_time: f.mod_time || '',
|
||||
modified_time: f.modified_time || '',
|
||||
is_favorite: false
|
||||
}))
|
||||
|
||||
@@ -1206,7 +1150,8 @@ const startResizeHorizontal = (event: MouseEvent) => {
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX
|
||||
const newLeftWidth = Math.max(200, Math.min(containerRect.width - 200, startLeftWidth + deltaX))
|
||||
const minPx = (DEFAULTS.MIN_PANEL_WIDTH / 100) * containerRect.width
|
||||
const newLeftWidth = Math.max(minPx, Math.min(containerRect.width - minPx, startLeftWidth + deltaX))
|
||||
const newLeftPercent = (newLeftWidth / containerRect.width) * 100
|
||||
|
||||
panelWidth.value = {
|
||||
|
||||
563
web/src/components/MarkdownEditor.vue
Normal file
563
web/src/components/MarkdownEditor.vue
Normal file
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<div class="markdown-editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="title">
|
||||
<icon-file />
|
||||
<span>Markdown 编辑器</span>
|
||||
<a-divider type="vertical" />
|
||||
<a-tooltip content="自动保存已启用">
|
||||
<span class="save-status" :class="{ 'saved': !hasChanges }">
|
||||
{{ hasChanges ? '未保存' : '已保存' }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a-tooltip content="清空内容">
|
||||
<a-button size="small" type="outline" @click="clearContent">
|
||||
<icon-delete />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="全屏编辑">
|
||||
<a-button size="small" type="outline" @click="toggleFullscreen">
|
||||
<icon-expand />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<PdfExportButton @export-complete="onExportComplete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content" :class="{ 'fullscreen': isFullscreen }">
|
||||
<div class="editor-panel" :class="{ 'expanded': isEditorExpanded }">
|
||||
<div class="panel-header">
|
||||
<span>编辑</span>
|
||||
<div class="panel-controls">
|
||||
<a-tooltip content="展开编辑器">
|
||||
<a-button size="small" type="text" @click="toggleEditorExpand">
|
||||
<icon-align-left v-if="!isEditorExpanded" />
|
||||
<icon-shrink v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="markdownContent"
|
||||
class="markdown-textarea"
|
||||
placeholder="在这里输入 Markdown 内容...
|
||||
|
||||
# 标题
|
||||
## 二级标题
|
||||
**粗体** *斜体*
|
||||
|
||||
- 列表项 1
|
||||
- 列表项 2
|
||||
|
||||
\`\`\`javascript
|
||||
console.log('Hello, World!')
|
||||
\`\`\`
|
||||
|
||||
> 引用内容"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resizer" @mousedown="startResize"></div>
|
||||
|
||||
<div class="preview-panel" :class="{ 'expanded': isPreviewExpanded }">
|
||||
<div class="panel-header">
|
||||
<span>预览</span>
|
||||
<div class="panel-controls">
|
||||
<a-tooltip content="展开预览">
|
||||
<a-button size="small" type="text" @click="togglePreviewExpand">
|
||||
<icon-align-left v-if="!isPreviewExpanded" />
|
||||
<icon-shrink v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="刷新预览">
|
||||
<a-button size="small" type="text" @click="renderPreview">
|
||||
<icon-sync />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-wrapper">
|
||||
<MarkdownPreview :content="markdownContent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-footer">
|
||||
<div class="status">
|
||||
<span>{{ wordCount }} 字符 | {{ lineCount }} 行 | {{ readingTime }} 分钟阅读</span>
|
||||
</div>
|
||||
<div class="shortcuts">
|
||||
<a-tooltip content="快捷键: Ctrl + S 保存">
|
||||
<a-button size="small" @click="saveContent" :disabled="!hasChanges">
|
||||
<icon-save />
|
||||
保存
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="快捷键: Ctrl + / 切换预览">
|
||||
<a-button size="small" @click="togglePreview">
|
||||
<icon-eye />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import MarkdownPreview from './MarkdownPreview.vue'
|
||||
import PdfExportButton from './PdfExportButton.vue'
|
||||
import IconFile from '@arco-design/web-vue/es/icon/icon-file'
|
||||
import IconDelete from '@arco-design/web-vue/es/icon/icon-delete'
|
||||
import IconExpand from '@arco-design/web-vue/es/icon/icon-expand'
|
||||
import IconShrink from '@arco-design/web-vue/es/icon/icon-shrink'
|
||||
import IconSync from '@arco-design/web-vue/es/icon/icon-sync'
|
||||
import IconSave from '@arco-design/web-vue/es/icon/icon-save'
|
||||
import IconEye from '@arco-design/web-vue/es/icon/icon-eye'
|
||||
import IconAlignLeft from '@arco-design/web-vue/es/icon/icon-align-left'
|
||||
|
||||
export default {
|
||||
name: 'MarkdownEditor',
|
||||
components: {
|
||||
MarkdownPreview,
|
||||
PdfExportButton,
|
||||
IconFile,
|
||||
IconDelete,
|
||||
IconExpand,
|
||||
IconShrink,
|
||||
IconSync,
|
||||
IconSave,
|
||||
IconEye,
|
||||
IconAlignLeft
|
||||
},
|
||||
emits: ['content-change', 'update:content', 'save'],
|
||||
props: {
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const markdownContent = ref(props.content)
|
||||
const textarea = ref(null)
|
||||
const hasChanges = ref(false)
|
||||
const lastSavedContent = ref('')
|
||||
const isFullscreen = ref(false)
|
||||
const isEditorExpanded = ref(false)
|
||||
const isPreviewExpanded = ref(false)
|
||||
const showPreview = ref(true)
|
||||
|
||||
// 计算属性
|
||||
const wordCount = computed(() => {
|
||||
return markdownContent.value.length
|
||||
})
|
||||
|
||||
const lineCount = computed(() => {
|
||||
return markdownContent.value.split('\n').length
|
||||
})
|
||||
|
||||
const readingTime = computed(() => {
|
||||
// 平均阅读速度:每分钟 200 字符
|
||||
const wordsPerMinute = 200
|
||||
const minutes = Math.ceil(wordCount.value / wordsPerMinute)
|
||||
return minutes
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleInput = () => {
|
||||
hasChanges.value = markdownContent.value !== lastSavedContent.value
|
||||
emit('content-change', markdownContent.value)
|
||||
emit('update:content', markdownContent.value)
|
||||
}
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
// Ctrl + S 保存
|
||||
if (event.ctrlKey && event.key === 's') {
|
||||
event.preventDefault()
|
||||
saveContent()
|
||||
}
|
||||
// Ctrl + / 切换预览
|
||||
if (event.ctrlKey && event.key === '/') {
|
||||
event.preventDefault()
|
||||
togglePreview()
|
||||
}
|
||||
}
|
||||
|
||||
const saveContent = () => {
|
||||
lastSavedContent.value = markdownContent.value
|
||||
hasChanges.value = false
|
||||
emit('save', markdownContent.value)
|
||||
Message.success('内容已保存')
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('u-desk-markdown-content', markdownContent.value)
|
||||
}
|
||||
|
||||
const onExportComplete = () => {
|
||||
Message.success('PDF 导出完成')
|
||||
}
|
||||
|
||||
// 自动调整 textarea 高度
|
||||
const adjustTextareaHeight = () => {
|
||||
if (textarea.value) {
|
||||
textarea.value.style.height = 'auto'
|
||||
textarea.value.style.height = textarea.value.scrollHeight + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口大小调整
|
||||
const startResize = (event) => {
|
||||
if (!showPreview.value) return
|
||||
|
||||
const startX = event.clientX
|
||||
const startWidth = document.querySelector('.editor-panel').offsetWidth
|
||||
const startPreviewWidth = document.querySelector('.preview-panel').offsetWidth
|
||||
|
||||
const doResize = (moveEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX
|
||||
const newEditorWidth = startWidth + deltaX
|
||||
const newPreviewWidth = startPreviewWidth - deltaX
|
||||
|
||||
if (newEditorWidth > 100 && newPreviewWidth > 100) {
|
||||
document.querySelector('.editor-panel').style.width = newEditorWidth + 'px'
|
||||
document.querySelector('.preview-panel').style.width = newPreviewWidth + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
document.removeEventListener('mousemove', doResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', doResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
// 切换功能
|
||||
const togglePreview = () => {
|
||||
showPreview.value = !showPreview.value
|
||||
if (showPreview.value) {
|
||||
// 恢复预览时重新调整大小
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
if (isFullscreen.value) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEditorExpand = () => {
|
||||
isEditorExpanded.value = !isEditorExpanded.value
|
||||
if (isEditorExpanded.value && isPreviewExpanded.value) {
|
||||
isPreviewExpanded.value = false
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}
|
||||
|
||||
const togglePreviewExpand = () => {
|
||||
isPreviewExpanded.value = !isPreviewExpanded.value
|
||||
if (isPreviewExpanded.value && isEditorExpanded.value) {
|
||||
isEditorExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
Modal.confirm({
|
||||
title: '确认清空',
|
||||
content: '确定要清空所有内容吗?此操作不可恢复。',
|
||||
okButtonProps: { status: 'danger' },
|
||||
onOk: () => {
|
||||
markdownContent.value = ''
|
||||
hasChanges.value = true
|
||||
lastSavedContent.value = ''
|
||||
emit('content-change', '')
|
||||
Message.success('内容已清空')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderPreview = () => {
|
||||
// 强制重新渲染预览
|
||||
const previewElement = document.querySelector('.preview-wrapper')
|
||||
if (previewElement) {
|
||||
previewElement.style.opacity = '0'
|
||||
nextTick(() => {
|
||||
previewElement.style.opacity = '1'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 自动保存定时器
|
||||
let autoSaveTimer = null
|
||||
|
||||
// 监听内容变化:自动保存 + 调整高度
|
||||
watch(markdownContent, () => {
|
||||
// 自动保存
|
||||
if (hasChanges.value) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
saveContent()
|
||||
}, 5000)
|
||||
}
|
||||
// 调整高度
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
}
|
||||
})
|
||||
|
||||
// 导出方法
|
||||
const getMarkdownContent = () => {
|
||||
return markdownContent.value
|
||||
}
|
||||
|
||||
const setMarkdownContent = (content) => {
|
||||
markdownContent.value = content
|
||||
hasChanges.value = content !== lastSavedContent.value
|
||||
}
|
||||
|
||||
return {
|
||||
markdownContent,
|
||||
textarea,
|
||||
hasChanges,
|
||||
wordCount,
|
||||
lineCount,
|
||||
readingTime,
|
||||
isFullscreen,
|
||||
isEditorExpanded,
|
||||
isPreviewExpanded,
|
||||
showPreview,
|
||||
handleInput,
|
||||
handleKeydown,
|
||||
saveContent,
|
||||
onExportComplete,
|
||||
getMarkdownContent,
|
||||
setMarkdownContent,
|
||||
startResize,
|
||||
togglePreview,
|
||||
toggleFullscreen,
|
||||
toggleEditorExpand,
|
||||
togglePreviewExpand,
|
||||
clearContent,
|
||||
renderPreview
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-editor-container.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-1);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.save-status {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-warning-light-1);
|
||||
color: var(--color-warning-6);
|
||||
}
|
||||
|
||||
.save-status.saved {
|
||||
background: var(--color-success-light-1);
|
||||
color: var(--color-success-6);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-panel,
|
||||
.preview-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.editor-panel.expanded {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.preview-panel.expanded {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-fill-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.panel-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-wrapper {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
background: var(--color-bg-1);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: var(--color-border);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.resizer:hover {
|
||||
background: var(--color-primary-light-3);
|
||||
}
|
||||
|
||||
.markdown-textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
outline: none;
|
||||
background: var(--color-bg-1);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.markdown-textarea:focus {
|
||||
border-color: var(--color-primary-6);
|
||||
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.2);
|
||||
}
|
||||
|
||||
.markdown-textarea::placeholder {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-bg-1);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.editor-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.resizer {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
cursor: row-resize;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
web/src/components/MarkdownPreview.vue
Normal file
45
web/src/components/MarkdownPreview.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="md-preview">
|
||||
<div v-html="renderedMarkdown" class="markdown-body"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { marked } from '@/utils/markedExtensions'
|
||||
|
||||
function sanitizeHtml(html) {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<script[^>]*>/gi, '')
|
||||
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '')
|
||||
.replace(/javascript\s*:/gi, 'blocked:')
|
||||
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
|
||||
.replace(/<object[\s\S]*?<\/object>/gi, '')
|
||||
.replace(/<embed[^>]*>/gi, '')
|
||||
.replace(/<form[\s\S]*?<\/form>/gi, '')
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MarkdownPreview',
|
||||
props: {
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
renderedMarkdown() {
|
||||
return sanitizeHtml(marked(this.content))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.md-preview {
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
262
web/src/components/PdfExportButton.vue
Normal file
262
web/src/components/PdfExportButton.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<a-tooltip content="导出" position="bottom">
|
||||
<a-button
|
||||
size="small"
|
||||
type="outline"
|
||||
@click="exportPDF"
|
||||
:loading="exporting"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-file-pdf />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
export default {
|
||||
name: 'PdfExportButton',
|
||||
emits: ['export-start', 'export-complete'],
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '文档'
|
||||
},
|
||||
containerSelector: {
|
||||
type: String,
|
||||
default: '.markdown-body'
|
||||
}
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const exporting = ref(false)
|
||||
|
||||
function escapeHtml(str) {
|
||||
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
return str.replace(/[&<>"']/g, c => map[c])
|
||||
}
|
||||
|
||||
function stripScripts(html) {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<script[^>]*>/gi, '')
|
||||
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '')
|
||||
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
|
||||
.replace(/<object[\s\S]*?<\/object>/gi, '')
|
||||
.replace(/<embed[^>]*>/gi, '')
|
||||
}
|
||||
|
||||
const exportPDF = async () => {
|
||||
if (exporting.value) return
|
||||
|
||||
exporting.value = true
|
||||
emit('export-start')
|
||||
|
||||
try {
|
||||
// 获取渲染后的 Markdown 内容
|
||||
const contentElement = document.querySelector(props.containerSelector)
|
||||
|
||||
if (!contentElement) {
|
||||
Message.error('没有可导出的内容')
|
||||
exporting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const htmlContent = stripScripts(contentElement.innerHTML)
|
||||
|
||||
if (!htmlContent || !htmlContent.trim()) {
|
||||
Message.error('内容为空,无法导出')
|
||||
exporting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 打开打印窗口
|
||||
const printWindow = window.open('', '_blank', 'width=800,height=600')
|
||||
|
||||
if (!printWindow) {
|
||||
Message.error('无法打开打印窗口,请检查浏览器弹窗设置')
|
||||
exporting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 写入打印内容
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${escapeHtml(props.title)}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
padding: 40px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
h3 { font-size: 1.25em; }
|
||||
h4 { font-size: 1em; }
|
||||
h5 { font-size: 0.875em; }
|
||||
h6 { font-size: 0.85em; color: #6a737d; }
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 2em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
table th {
|
||||
font-weight: 600;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 15mm;
|
||||
size: A4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${htmlContent}
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
printWindow.document.close()
|
||||
|
||||
// 等待内容加载完成后自动打印
|
||||
let printTriggered = false
|
||||
printWindow.onload = () => {
|
||||
printTriggered = true
|
||||
printWindow.print()
|
||||
}
|
||||
|
||||
// 兼容性处理:如果 onload 未触发
|
||||
setTimeout(() => {
|
||||
if (!printTriggered && printWindow && !printWindow.closed) {
|
||||
printWindow.print()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
Message.success('请在打印对话框中选择"另存为 PDF"')
|
||||
emit('export-complete')
|
||||
|
||||
} catch (error) {
|
||||
console.error('PDF导出失败:', error)
|
||||
Message.error(`PDF导出失败:${error.message || '未知错误'}`)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exporting,
|
||||
exportPDF
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -66,8 +66,8 @@ export function useFavoriteFiles(storageKey, options = {}) {
|
||||
return
|
||||
}
|
||||
favoriteFiles.value.sort((a, b) => {
|
||||
const timeA = a.created_at || 0
|
||||
const timeB = b.created_at || 0
|
||||
const timeA = a.addedAt || 0
|
||||
const timeB = b.addedAt || 0
|
||||
return timeB - timeA // 倒序:最新的在上面
|
||||
})
|
||||
}
|
||||
@@ -106,8 +106,8 @@ export function useFavoriteFiles(storageKey, options = {}) {
|
||||
favoriteFiles.value.push({
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
is_dir: item.is_dir || false,
|
||||
created_at: Date.now(), // 添加时间戳(用于 getSortedFavorites)
|
||||
isDir: item.isDir || false,
|
||||
addedAt: Date.now(),
|
||||
})
|
||||
|
||||
save(favoriteFiles.value) // 直接保存,不重新排序(新项目添加到末尾)
|
||||
@@ -201,8 +201,8 @@ export function useFavoriteFiles(storageKey, options = {}) {
|
||||
const getSortedFavorites = (order = 'desc') => {
|
||||
const sorted = [...favoriteFiles.value]
|
||||
sorted.sort((a, b) => {
|
||||
const timeA = a.created_at || 0
|
||||
const timeB = b.created_at || 0
|
||||
const timeA = a.addedAt || 0
|
||||
const timeB = b.addedAt || 0
|
||||
return order === 'desc' ? timeB - timeA : timeA - timeB
|
||||
})
|
||||
return sorted
|
||||
@@ -255,9 +255,27 @@ export function useFavoriteFiles(storageKey, options = {}) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据(不自动排序,保持用户拖拽的顺序)
|
||||
// 旧字段名 → 新字段名迁移(一次性,迁移后自动回写)
|
||||
const migrateFieldNames = (list) => {
|
||||
if (!Array.isArray(list)) return
|
||||
const map = { is_dir: 'isDir', created_at: 'addedAt' }
|
||||
let changed = false
|
||||
list.forEach(item => {
|
||||
for (const [old, newKey] of Object.entries(map)) {
|
||||
if (old in item) {
|
||||
if (!(newKey in item)) item[newKey] = item[old]
|
||||
delete item[old]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
})
|
||||
if (changed) save(list)
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据并迁移旧字段
|
||||
onMounted(() => {
|
||||
load()
|
||||
migrateFieldNames(favoriteFiles.value)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* LocalStorage composable
|
||||
* 通用的 localStorage 操作
|
||||
*/
|
||||
|
||||
import { watch, type Ref } from 'vue'
|
||||
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
storage: Storage = localStorage
|
||||
): [Ref<T>, (value: T) => void, () => void] {
|
||||
const stored = storage.getItem(key)
|
||||
const value = ref<T>(stored ? JSON.parse(stored) : defaultValue)
|
||||
|
||||
const setValue = (newValue: T) => {
|
||||
value.value = newValue
|
||||
}
|
||||
|
||||
const clearValue = () => {
|
||||
value.value = defaultValue
|
||||
storage.removeItem(key)
|
||||
}
|
||||
|
||||
watch(value, (newValue) => {
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify(newValue))
|
||||
} catch (e) {
|
||||
console.warn(`Failed to save ${key} to localStorage:`, e)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
return [value, setValue, clearValue]
|
||||
}
|
||||
@@ -66,12 +66,20 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const defaultTab = computed(() => appConfig.value.defaultTab)
|
||||
|
||||
// ==================== 核心方法 ====================
|
||||
let _retryCount = 0
|
||||
const MAX_RETRIES = 30 // 最多重试30次(约30秒)
|
||||
|
||||
/**
|
||||
* 加载配置
|
||||
*/
|
||||
const loadConfig = async () => {
|
||||
if (!window.go?.main?.App) {
|
||||
console.warn('Wails 绑定未准备好,1秒后重试')
|
||||
_retryCount++
|
||||
if (_retryCount > MAX_RETRIES) {
|
||||
console.error('Wails 绑定初始化超时,使用默认配置')
|
||||
useDefaultConfig()
|
||||
return
|
||||
}
|
||||
setTimeout(loadConfig, 1000)
|
||||
return
|
||||
}
|
||||
@@ -104,9 +112,10 @@ export const useConfigStore = defineStore('config', () => {
|
||||
appConfig.value = {
|
||||
tabs: [
|
||||
{ key: 'file-system', title: '文件管理', visible: true, enabled: true },
|
||||
{ key: 'db-cli', title: '数据库', visible: true, enabled: true }
|
||||
{ key: 'db-cli', title: '数据库', visible: true, enhanced: true },
|
||||
{ key: 'markdown-editor', title: 'Markdown 编辑器', visible: true, enabled: true }
|
||||
],
|
||||
visibleTabs: ['file-system', 'db-cli'],
|
||||
visibleTabs: ['file-system', 'db-cli', 'markdown-editor'],
|
||||
defaultTab: 'file-system'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,292 @@ body {
|
||||
scrollbar-color: var(--color-border-2, rgba(0, 0, 0, 0.1)) transparent;
|
||||
}
|
||||
|
||||
/* Highlight.js CSS */
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
background: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-addition {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-string,
|
||||
.hljs-meta .hljs-string,
|
||||
.hljs-literal,
|
||||
.hljs-doctag,
|
||||
.hljs-regexp {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.hljs-attribute,
|
||||
.hljs-attr,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-class .hljs-title,
|
||||
.hljs-type {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-subst,
|
||||
.hljs-meta,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-link {
|
||||
color: #735c0f;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-deletion {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.hljs-formula {
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* GitHub 风格的 Markdown 预览样式 */
|
||||
.markdown-body {
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-body h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
.markdown-body h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
.markdown-body h3 { font-size: 1.25em; }
|
||||
.markdown-body h4 { font-size: 1em; }
|
||||
.markdown-body h5 { font-size: 0.875em; }
|
||||
.markdown-body h6 { font-size: 0.85em; color: #6a737d; }
|
||||
|
||||
.markdown-body p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 2em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-body table th,
|
||||
.markdown-body table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
.markdown-body table th {
|
||||
font-weight: 600;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(even) {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
font-size: 12pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 24pt;
|
||||
margin-bottom: 12pt;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 18pt;
|
||||
margin-bottom: 10pt;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 14pt;
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin-bottom: 4pt;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 12pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 4px solid #ddd;
|
||||
margin: 16px 0;
|
||||
padding: 10px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown 标题锚点链接样式 */
|
||||
.heading {
|
||||
position: relative;
|
||||
@@ -83,4 +369,34 @@ body {
|
||||
/* 平滑滚动 */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Tooltip 全局样式 */
|
||||
.arco-tooltip {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.arco-tooltip-content {
|
||||
background: var(--color-bg-5) !important;
|
||||
color: var(--color-text-1) !important;
|
||||
padding: 6px 10px !important;
|
||||
border-radius: 6px !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
|
||||
max-width: 240px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arco-tooltip-content::before {
|
||||
background: var(--color-bg-5) !important;
|
||||
}
|
||||
|
||||
.arco-tooltip-content-white {
|
||||
background: var(--color-bg-1) !important;
|
||||
border: 1px solid var(--color-border-2) !important;
|
||||
color: var(--color-text-1) !important;
|
||||
}
|
||||
|
||||
.arco-tooltip-content-white::before {
|
||||
background: var(--color-bg-1) !important;
|
||||
}
|
||||
@@ -6,8 +6,9 @@
|
||||
import {
|
||||
javascript, json, yaml, html, css,
|
||||
cpp, rust, go, python, php, sql, markdown, java,
|
||||
shell, StreamLanguage
|
||||
shell, powerShell, dart, StreamLanguage
|
||||
} from './codemirrorExports'
|
||||
import { getCmLanguage } from './languageMap'
|
||||
|
||||
const languageCache = new Map()
|
||||
|
||||
@@ -17,14 +18,12 @@ const languageCache = new Map()
|
||||
* @returns {Extension|null} CodeMirror 语言扩展
|
||||
*/
|
||||
export function loadLanguageExtension(language) {
|
||||
// 检查缓存
|
||||
if (languageCache.has(language)) {
|
||||
return languageCache.get(language)
|
||||
}
|
||||
|
||||
let extension = null
|
||||
|
||||
// 使用静态导入的语言包
|
||||
switch (language) {
|
||||
case 'javascript':
|
||||
extension = javascript({ jsx: true })
|
||||
@@ -74,6 +73,12 @@ export function loadLanguageExtension(language) {
|
||||
case 'sh':
|
||||
extension = StreamLanguage.define(shell)
|
||||
break
|
||||
case 'powershell':
|
||||
extension = StreamLanguage.define(powerShell)
|
||||
break
|
||||
case 'dart':
|
||||
extension = StreamLanguage.define(dart)
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -90,34 +95,5 @@ export function loadLanguageExtension(language) {
|
||||
* @returns {string} 语言名称
|
||||
*/
|
||||
export function getLanguageFromExtension(extension) {
|
||||
const ext = extension.toLowerCase()
|
||||
|
||||
const langMap = {
|
||||
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
||||
ts: 'typescript', tsx: 'typescript',
|
||||
json: 'json',
|
||||
yaml: 'yaml', yml: 'yaml',
|
||||
html: 'html', htm: 'html',
|
||||
css: 'css', scss: 'css', sass: 'css', less: 'css',
|
||||
cpp: 'cpp', c: 'c', cc: 'cpp', cxx: 'cpp', h: 'cpp', hpp: 'cpp', hxx: 'cpp',
|
||||
rust: 'rust', rs: 'rust',
|
||||
go: 'go',
|
||||
python: 'python', py: 'python', pyw: 'python',
|
||||
php: 'php',
|
||||
sql: 'sql',
|
||||
markdown: 'markdown', md: 'markdown',
|
||||
java: 'java',
|
||||
sh: 'shell', bash: 'shell', shell: 'shell', zsh: 'shell'
|
||||
}
|
||||
|
||||
return langMap[ext] || 'text'
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载常用语言包
|
||||
* 用于在应用启动时预热缓存
|
||||
*/
|
||||
export function preloadCommonLanguages() {
|
||||
// 现在是同步的,不需要 Promise.all
|
||||
;['javascript', 'json', 'markdown', 'python', 'sql'].forEach(loadLanguageExtension)
|
||||
return getCmLanguage(extension)
|
||||
}
|
||||
|
||||
@@ -25,5 +25,7 @@ export { sql } from '@codemirror/lang-sql'
|
||||
export { markdown } from '@codemirror/lang-markdown'
|
||||
export { java } from '@codemirror/lang-java'
|
||||
|
||||
// Legacy language modes (shell)
|
||||
// Legacy language modes (shell, powershell, dart)
|
||||
export { shell } from '@codemirror/legacy-modes/mode/shell'
|
||||
export { powerShell } from '@codemirror/legacy-modes/mode/powershell'
|
||||
export { dart } from '@codemirror/legacy-modes/mode/clike'
|
||||
|
||||
@@ -73,9 +73,9 @@ export const FILE_EXTENSIONS = {
|
||||
CODE: [
|
||||
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
|
||||
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
|
||||
'scala', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
|
||||
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
|
||||
'flow', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
|
||||
'tex', 'm', 'r', 'matlab', 'latex', 'rst', 'adoc'
|
||||
'm', 'r', 'matlab'
|
||||
],
|
||||
|
||||
// 配置文件(可编辑的文本格式)
|
||||
@@ -154,6 +154,7 @@ export const FILE_ICONS = {
|
||||
RUST: '🦀',
|
||||
PHP: '🐘',
|
||||
RUBY: '💎',
|
||||
DART: '🎯',
|
||||
|
||||
// 数据库
|
||||
DATABASE: '🗄️',
|
||||
@@ -266,6 +267,8 @@ const initIconMap = () => {
|
||||
'gem': FILE_ICONS.RUBY,
|
||||
// SQL
|
||||
'sql': FILE_ICONS.SQL,
|
||||
// Dart
|
||||
'dart': FILE_ICONS.DART,
|
||||
}
|
||||
|
||||
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 文件类型工具函数
|
||||
*/
|
||||
|
||||
import { FILE_EXTENSIONS } from './constants'
|
||||
|
||||
// 获取文件扩展名
|
||||
export const getExt = (path) => {
|
||||
if (!path) return ''
|
||||
const dot = path.lastIndexOf('.')
|
||||
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
if (dot === -1 || dot < slash) return ''
|
||||
return path.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
// 文件类型检查
|
||||
export const isImage = (path) => FILE_EXTENSIONS.IMAGE.includes(getExt(path))
|
||||
export const isVideo = (path) => FILE_EXTENSIONS.VIDEO_BROWSER.includes(getExt(path))
|
||||
export const isAudio = (path) => FILE_EXTENSIONS.AUDIO.includes(getExt(path))
|
||||
export const isPdf = (path) => getExt(path) === 'pdf'
|
||||
export const isHtml = (path) => { const e = getExt(path); return e === 'html' || e === 'htm' }
|
||||
export const isMarkdown = (path) => { const e = getExt(path); return e === 'md' || e === 'markdown' }
|
||||
export const isCode = (path) => FILE_EXTENSIONS.CODE.includes(getExt(path))
|
||||
export const isArchive = (path) => FILE_EXTENSIONS.ARCHIVE.includes(getExt(path))
|
||||
export const isDatabase = (path) => FILE_EXTENSIONS.DATABASE.includes(getExt(path))
|
||||
export const isExecutable = (path) => FILE_EXTENSIONS.EXECUTABLE.includes(getExt(path))
|
||||
|
||||
// 复合检查
|
||||
export const isVideoAny = (path) => {
|
||||
const e = getExt(path)
|
||||
return FILE_EXTENSIONS.VIDEO_BROWSER.includes(e) || FILE_EXTENSIONS.VIDEO_EXTERNAL.includes(e)
|
||||
}
|
||||
|
||||
export const isEditableDoc = (path) => {
|
||||
const e = getExt(path)
|
||||
return FILE_EXTENSIONS.DOCUMENT.includes(e) && e !== 'pdf'
|
||||
}
|
||||
|
||||
export const isBinary = (path) => isVideoAny(path) || isAudio(path) || isArchive(path) || isExecutable(path)
|
||||
export const canPreview = (path) => isImage(path) || isVideo(path) || isAudio(path) || isPdf(path)
|
||||
export const canEdit = (path) => !isBinary(path) && !isImage(path)
|
||||
@@ -2,10 +2,8 @@
|
||||
* Office 文件预览处理器
|
||||
*/
|
||||
|
||||
// 获取文件扩展名(统一方法)
|
||||
function getExt(fileName) {
|
||||
return fileName?.split('.').pop()?.toLowerCase() || ''
|
||||
}
|
||||
import { escapeHtml } from './fileUtils'
|
||||
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
|
||||
|
||||
// 每批加载行数
|
||||
const BATCH_ROWS = 200
|
||||
@@ -37,10 +35,10 @@ export async function previewExcel(file, container) {
|
||||
.excel-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
|
||||
.excel-content{flex:1;overflow:auto;padding:12px}
|
||||
.excel-content table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.excel-content td,.excel-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap}
|
||||
.excel-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;left:0;z-index:2}
|
||||
.excel-content td,.excel-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap;color:var(--color-text-1)}
|
||||
.excel-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;z-index:2}
|
||||
.excel-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
|
||||
.excel-content th.row-num{z-index:3}
|
||||
.excel-content th.row-num{z-index:3;top:0;left:0}
|
||||
.excel-content tr:hover td{background:var(--color-fill-1)}
|
||||
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
|
||||
</style>
|
||||
@@ -61,8 +59,6 @@ export async function previewExcel(file, container) {
|
||||
|
||||
// 渲染表格(带行号)
|
||||
const renderTable = (data, startRow = 0) => {
|
||||
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
let html = '<table><thead><tr><th class="row-num">#</th>'
|
||||
if (data[0]) {
|
||||
data[0].forEach((cell, i) => {
|
||||
@@ -86,7 +82,6 @@ export async function previewExcel(file, container) {
|
||||
|
||||
// 追加行
|
||||
const appendRows = (data, fromRow, toRow) => {
|
||||
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
const tbody = contentEl.querySelector('tbody')
|
||||
if (!tbody) return
|
||||
|
||||
@@ -191,15 +186,8 @@ export async function previewWord(file, container) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// 文件类型判断
|
||||
const OFFICE_EXTS = { xlsx: 1, xls: 1, docx: 1, doc: 1 }
|
||||
const EXCEL_EXTS = { xlsx: 1, xls: 1 }
|
||||
const WORD_EXTS = { docx: 1, doc: 1 }
|
||||
|
||||
export const isOfficeFile = (name) => OFFICE_EXTS[getExt(name)] || false
|
||||
export const isExcelFile = (name) => EXCEL_EXTS[getExt(name)] || false
|
||||
export const isWordFile = (name) => WORD_EXTS[getExt(name)] || false
|
||||
export const isCsvFile = (name) => ['csv', 'tsv'].includes(getExt(name))
|
||||
// 文件类型判断(从 fileTypeHelpers 导入)
|
||||
export { isOfficeFile, isExcelFile, isWordFile, isCsvFile }
|
||||
|
||||
// CSV/TSV 预览处理器(原生实现,支持滚动加载)
|
||||
export async function previewCsv(file, container) {
|
||||
@@ -243,8 +231,6 @@ export async function previewCsv(file, container) {
|
||||
const delimiter = file.name.endsWith('.tsv') ? '\t' : ','
|
||||
const rows = lines.map(line => parseLine(line, delimiter))
|
||||
|
||||
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="csv-preview">
|
||||
<div class="csv-info">📋 ${file.name}</div>
|
||||
@@ -255,10 +241,10 @@ export async function previewCsv(file, container) {
|
||||
.csv-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
|
||||
.csv-content{flex:1;overflow:auto;padding:12px}
|
||||
.csv-content table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.csv-content td,.csv-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap}
|
||||
.csv-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;left:0;z-index:2}
|
||||
.csv-content td,.csv-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap;color:var(--color-text-1)}
|
||||
.csv-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;z-index:2}
|
||||
.csv-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
|
||||
.csv-content th.row-num{z-index:3}
|
||||
.csv-content th.row-num{z-index:3;top:0;left:0}
|
||||
.csv-content tr:hover td{background:var(--color-fill-1)}
|
||||
.csv-content tr:hover .row-num{background:var(--color-fill-2)}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { FILE_EXTENSIONS } from './constants'
|
||||
import { getExt } from './pathHelpers'
|
||||
import { getExt } from './fileUtils'
|
||||
|
||||
/**
|
||||
* 可预览的文件类型(有专门的预览处理)
|
||||
|
||||
@@ -5,8 +5,51 @@
|
||||
* @description 提供文件相关的通用工具函数,避免代码重复
|
||||
*/
|
||||
|
||||
import { normalizePathSeparators } from './pathHelpers.js'
|
||||
import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT, FILE_EXTENSIONS } from './constants'
|
||||
import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT } from './constants'
|
||||
|
||||
/**
|
||||
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
|
||||
* @type {RegExp}
|
||||
*/
|
||||
export const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
|
||||
/**
|
||||
* 规范化路径分隔符(统一为正斜杠)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 规范化后的路径
|
||||
*/
|
||||
export const normalizePathSeparators = (path) => {
|
||||
if (!path) return ''
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义,防止 XSS
|
||||
* @param {string} str - 原始字符串
|
||||
* @returns {string} 转义后的字符串
|
||||
*/
|
||||
export const escapeHtml = (str) => {
|
||||
if (str == null) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名(路径安全)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 扩展名(小写,不含点号)
|
||||
*/
|
||||
export const getExt = (path) => {
|
||||
if (!path) return ''
|
||||
const dot = path.lastIndexOf('.')
|
||||
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
if (dot === -1 || dot < slash) return ''
|
||||
return path.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
@@ -46,34 +89,29 @@ export function formatBytes(bytes) {
|
||||
*/
|
||||
export function getFileName(path) {
|
||||
if (!path) return ''
|
||||
|
||||
// 后端已统一返回 / 路径,直接分割
|
||||
const parts = path.split('/')
|
||||
|
||||
const parts = path.split(PATH_SEPARATOR_REGEX)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径中提取文件扩展名
|
||||
* 分割路径为多个部分
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 扩展名(小写,不含点号)
|
||||
*
|
||||
* @example
|
||||
* getFileExtension('/path/to/file.txt') // "txt"
|
||||
* getFileExtension('/path/to/file.TXT') // "txt"
|
||||
* getFileExtension('/path/to/file') // ""
|
||||
* @returns {string[]} 路径数组
|
||||
*/
|
||||
export function getFileExtension(path) {
|
||||
if (!path) return ''
|
||||
export const splitPath = (path) => {
|
||||
if (!path) return []
|
||||
return path.split(PATH_SEPARATOR_REGEX)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件名(不含扩展名)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件名(不含扩展名)
|
||||
*/
|
||||
export const getFileNameWithoutExt = (path) => {
|
||||
const fileName = getFileName(path)
|
||||
const lastDotIndex = fileName.lastIndexOf('.')
|
||||
|
||||
if (lastDotIndex === -1 || lastDotIndex === fileName.length - 1) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return fileName.substring(lastDotIndex + 1).toLowerCase()
|
||||
const lastDot = fileName.lastIndexOf('.')
|
||||
return lastDot > 0 ? fileName.substring(0, lastDot) : fileName
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,51 +135,12 @@ export function getFileIcon(fileInfo) {
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
const ext = getFileExtension(fileInfo.name)
|
||||
const ext = getExt(fileInfo.name)
|
||||
|
||||
// 从映射表中查找图标
|
||||
return FILE_ICON_MAP.get(ext) || FILE_ICONS.FILE
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为图片
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean} 是否为图片文件
|
||||
*/
|
||||
export function isImageFile(path) {
|
||||
const ext = getFileExtension(path)
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为视频
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean} 是否为视频文件
|
||||
*/
|
||||
export function isVideoFile(path) {
|
||||
const ext = getFileExtension(path)
|
||||
return [...FILE_EXTENSIONS.VIDEO_BROWSER, ...FILE_EXTENSIONS.VIDEO_EXTERNAL].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为音频
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean} 是否为音频文件
|
||||
*/
|
||||
export function isAudioFile(path) {
|
||||
const ext = getFileExtension(path)
|
||||
return FILE_EXTENSIONS.AUDIO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为PDF
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean} 是否为PDF文件
|
||||
*/
|
||||
export function isPdfFile(path) {
|
||||
return getFileExtension(path) === 'pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化文件路径(将反斜杠转换为正斜杠,并进行URL编码)
|
||||
* @param {string} path - 原始路径
|
||||
@@ -189,7 +188,7 @@ export function normalizeFilePath(path, encode = false) {
|
||||
* getFileTypeName('unknown.xyz') // "XYZ文件"
|
||||
*/
|
||||
export function getFileTypeName(path) {
|
||||
const ext = getFileExtension(path)
|
||||
const ext = getExt(path)
|
||||
const extUpper = ext.toUpperCase()
|
||||
|
||||
// 图片
|
||||
@@ -226,23 +225,6 @@ export function getFileTypeName(path) {
|
||||
return ext ? `${extUpper}文件` : '文件'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为二进制文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean} 是否为二进制文件
|
||||
*/
|
||||
export function isBinaryFile(path) {
|
||||
const ext = getFileExtension(path)
|
||||
const binaryExtensions = [
|
||||
'exe', 'dll', 'so', 'dylib', // 可执行文件
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', // 压缩文件
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', // Office文档
|
||||
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'mp3', 'mp4', // 媒体文件
|
||||
'eot', 'ttf', 'otf', 'woff', 'woff2', // 字体文件
|
||||
]
|
||||
return binaryExtensions.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否为绝对路径
|
||||
* @param {string} path - 文件路径
|
||||
@@ -278,6 +260,15 @@ export function joinPaths(...parts) {
|
||||
return parts.join('/').replace(/\/+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路径使用的分隔符(Windows 反斜杠或 Unix 正斜杠)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 分隔符 '\\' 或 '/'
|
||||
*/
|
||||
export function getPathSeparator(path) {
|
||||
return path.includes('\\') ? '\\' : '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父目录路径
|
||||
* @param {string} path - 文件路径
|
||||
@@ -290,14 +281,24 @@ export function joinPaths(...parts) {
|
||||
export function getParentPath(path) {
|
||||
if (!path) return ''
|
||||
|
||||
const normalizedPath = normalizeFilePath(path)
|
||||
const normalizedPath = normalizePathSeparators(path)
|
||||
const lastSlashIndex = normalizedPath.lastIndexOf('/')
|
||||
|
||||
if (lastSlashIndex <= 0) {
|
||||
return '/' // 根目录
|
||||
if (/^[A-Za-z]:$/.test(normalizedPath)) {
|
||||
return normalizedPath + '/'
|
||||
}
|
||||
return normalizedPath || '/'
|
||||
}
|
||||
|
||||
return normalizedPath.substring(0, lastSlashIndex)
|
||||
const parentPath = normalizedPath.substring(0, lastSlashIndex)
|
||||
|
||||
// 盘符根目录下文件:E:/file.txt → E:/
|
||||
if (/^[A-Za-z]:$/.test(parentPath)) {
|
||||
return parentPath + '/'
|
||||
}
|
||||
|
||||
return parentPath || '/'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
129
web/src/utils/languageMap.ts
Normal file
129
web/src/utils/languageMap.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 统一语言映射
|
||||
* 供 highlight.js(Markdown 预览)和 CodeMirror(代码编辑器)共用
|
||||
*/
|
||||
|
||||
/**
|
||||
* 文件扩展名/缩写 → 语言标识符
|
||||
* - hljs: 用于 markedExtensions.ts 的代码块高亮
|
||||
* - cm: 用于 codeMirrorLoader.js 的编辑器语言
|
||||
* 值为 false 表示该扩展名不对应任何编程语言
|
||||
*/
|
||||
const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
|
||||
// === JavaScript / TypeScript ===
|
||||
js: { hljs: 'javascript', cm: 'javascript' },
|
||||
jsx: { hljs: 'javascript', cm: 'javascript' },
|
||||
mjs: { hljs: 'javascript', cm: 'javascript' },
|
||||
cjs: { hljs: 'javascript', cm: 'javascript' },
|
||||
ts: { hljs: 'typescript', cm: 'typescript' },
|
||||
tsx: { hljs: 'typescript', cm: 'typescript' },
|
||||
cts: { hljs: 'typescript', cm: 'typescript' },
|
||||
mts: { hljs: 'typescript', cm: 'typescript' },
|
||||
|
||||
// === Web ===
|
||||
html: { hljs: 'xml', cm: 'html' },
|
||||
htm: { hljs: 'xml', cm: 'html' },
|
||||
css: { hljs: 'css', cm: 'css' },
|
||||
scss: { hljs: 'scss', cm: 'css' },
|
||||
sass: { hljs: 'scss', cm: 'css' },
|
||||
less: { hljs: 'less', cm: 'css' },
|
||||
vue: { hljs: 'xml', cm: 'html' },
|
||||
|
||||
// === 数据格式 ===
|
||||
json: { hljs: 'json', cm: 'json' },
|
||||
xml: { hljs: 'xml', cm: 'html' },
|
||||
yaml: { hljs: 'yaml', cm: 'yaml' },
|
||||
yml: { hljs: 'yaml', cm: 'yaml' },
|
||||
toml: { cm: 'text' },
|
||||
csv: { cm: 'text' },
|
||||
tsv: { cm: 'text' },
|
||||
|
||||
// === C / C++ / 系统编程 ===
|
||||
c: { hljs: 'c', cm: 'cpp' },
|
||||
cpp: { hljs: 'cpp', cm: 'cpp' },
|
||||
cc: { hljs: 'cpp', cm: 'cpp' },
|
||||
cxx: { hljs: 'cpp', cm: 'cpp' },
|
||||
h: { hljs: 'cpp', cm: 'cpp' },
|
||||
hpp: { hljs: 'cpp', cm: 'cpp' },
|
||||
hxx: { hljs: 'cpp', cm: 'cpp' },
|
||||
cs: { hljs: 'csharp', cm: 'text' },
|
||||
swift: { hljs: 'swift', cm: 'text' },
|
||||
kt: { hljs: 'kotlin', cm: 'text' },
|
||||
rs: { hljs: 'rust', cm: 'rust' },
|
||||
go: { hljs: 'go', cm: 'go' },
|
||||
java: { hljs: 'java', cm: 'java' },
|
||||
pch: { hljs: 'cpp', cm: 'cpp' },
|
||||
tcc: { hljs: 'cpp', cm: 'cpp' },
|
||||
|
||||
// === 脚本 ===
|
||||
py: { hljs: 'python', cm: 'python' },
|
||||
pyw: { hljs: 'python', cm: 'python' },
|
||||
rb: { hljs: 'ruby', cm: 'text' },
|
||||
php: { hljs: 'php', cm: 'php' },
|
||||
sh: { hljs: 'bash', cm: 'shell' },
|
||||
bash: { hljs: 'bash', cm: 'shell' },
|
||||
shell: { hljs: 'bash', cm: 'shell' },
|
||||
zsh: { hljs: 'bash', cm: 'shell' },
|
||||
ps1: { hljs: 'powershell', cm: 'powershell' },
|
||||
bat: { hljs: 'dos', cm: 'text' },
|
||||
ahk: { hljs: 'autohotkey', cm: 'text' },
|
||||
lua: { hljs: 'lua', cm: 'text' },
|
||||
r: { hljs: 'r', cm: 'text' },
|
||||
m: { hljs: 'objectivec', cm: 'text' },
|
||||
scala: { hljs: 'scala', cm: 'text' },
|
||||
dart: { hljs: 'dart', cm: 'dart' },
|
||||
|
||||
// === 数据库 / 标记 ===
|
||||
sql: { hljs: 'sql', cm: 'sql' },
|
||||
md: { hljs: 'markdown', cm: 'markdown' },
|
||||
markdown: { hljs: 'markdown', cm: 'markdown' },
|
||||
tex: { hljs: 'latex', cm: 'text' },
|
||||
rst: { hljs: 'plaintext', cm: 'text' },
|
||||
adoc: { hljs: 'plaintext', cm: 'text' },
|
||||
|
||||
// === 构建工具 / 配置 ===
|
||||
dockerfile: { hljs: 'dockerfile', cm: 'text' },
|
||||
makefile: { hljs: 'makefile', cm: 'text' },
|
||||
mk: { hljs: 'makefile', cm: 'text' },
|
||||
cmake: { hljs: 'cmake', cm: 'text' },
|
||||
ini: { hljs: 'ini', cm: 'text' },
|
||||
cfg: { hljs: 'ini', cm: 'text' },
|
||||
conf: { hljs: 'ini', cm: 'text' },
|
||||
env: { cm: 'text' },
|
||||
props: { cm: 'text' },
|
||||
manifest: { cm: 'text' },
|
||||
lock: { cm: 'text' },
|
||||
ignore: { cm: 'text' },
|
||||
|
||||
// === 纯文本 ===
|
||||
txt: { cm: 'text' },
|
||||
text: { cm: 'text' },
|
||||
log: { cm: 'text' },
|
||||
msg: { cm: 'text' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 hljs 语言标识(带别名解析)
|
||||
*/
|
||||
export function getHljsLanguage(langOrExt: string): string {
|
||||
if (!langOrExt) return 'plaintext'
|
||||
const lower = langOrExt.toLowerCase()
|
||||
|
||||
// 先查扩展名映射
|
||||
const mapped = extensionToLanguage[lower]
|
||||
if (mapped?.hljs) return mapped.hljs
|
||||
|
||||
// 再查 hljs 直接注册名
|
||||
if (typeof hljs !== 'undefined' && hljs.getLanguage(lower)) return lower
|
||||
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CodeMirror 语言标识
|
||||
*/
|
||||
export function getCmLanguage(extension: string): string {
|
||||
if (!extension) return 'text'
|
||||
const lower = extension.toLowerCase()
|
||||
return extensionToLanguage[lower]?.cm || 'text'
|
||||
}
|
||||
@@ -1,38 +1,66 @@
|
||||
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'
|
||||
}
|
||||
// 按需导入 common 包不包含的语言
|
||||
import 'highlight.js/lib/languages/powershell'
|
||||
import 'highlight.js/lib/languages/dos'
|
||||
import 'highlight.js/lib/languages/autohotkey'
|
||||
import 'highlight.js/lib/languages/latex'
|
||||
import 'highlight.js/lib/languages/dockerfile'
|
||||
import 'highlight.js/lib/languages/cmake'
|
||||
import 'highlight.js/lib/languages/scala'
|
||||
import 'highlight.js/lib/languages/dart'
|
||||
import { getHljsLanguage } from './languageMap'
|
||||
|
||||
let mermaidInstance: typeof import('mermaid').default | null = null
|
||||
let mermaidTheme: string | null = null
|
||||
|
||||
// 检测当前是否为暗色主题
|
||||
function isDarkTheme(): boolean {
|
||||
if (typeof document === 'undefined') return false
|
||||
return document.body.getAttribute('arco-theme')?.includes('dark') ?? false
|
||||
}
|
||||
|
||||
async function loadMermaid() {
|
||||
if (mermaidInstance) return mermaidInstance
|
||||
const currentTheme = isDarkTheme() ? 'dark' : 'default'
|
||||
|
||||
if (mermaidInstance && mermaidTheme === currentTheme) {
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
try {
|
||||
const mermaid = await import('mermaid')
|
||||
mermaid.default.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
securityLevel: 'loose'
|
||||
theme: currentTheme,
|
||||
securityLevel: 'strict',
|
||||
themeVariables: currentTheme === 'dark' ? {
|
||||
primaryColor: '#165DFF',
|
||||
primaryTextColor: '#ffffff',
|
||||
primaryBorderColor: '#4080FF',
|
||||
lineColor: '#4E5969',
|
||||
secondaryColor: '#0E42D2',
|
||||
tertiaryColor: '#0FC6C2',
|
||||
mainBkg: '#17171A',
|
||||
nodeBorder: '#165DFF',
|
||||
clusterBkg: '#232324',
|
||||
titleColor: '#FFFFFF',
|
||||
edgeLabelBackground: '#232324'
|
||||
} : {
|
||||
primaryColor: '#165DFF',
|
||||
primaryTextColor: '#ffffff',
|
||||
primaryBorderColor: '#4080FF',
|
||||
lineColor: '#86909C',
|
||||
secondaryColor: '#E8F3FF',
|
||||
tertiaryColor: '#722ED1',
|
||||
mainBkg: '#F2F3F5',
|
||||
nodeBorder: '#165DFF',
|
||||
clusterBkg: '#F7F8FA',
|
||||
titleColor: '#1D2129',
|
||||
edgeLabelBackground: '#F2F3F5'
|
||||
}
|
||||
})
|
||||
mermaidTheme = currentTheme
|
||||
mermaidInstance = mermaid.default
|
||||
return mermaidInstance
|
||||
} catch {
|
||||
@@ -47,14 +75,7 @@ renderer.code = function(token: any) {
|
||||
return `<pre class="mermaid">${token.text}</pre>`
|
||||
}
|
||||
|
||||
// 获取语言,支持别名
|
||||
let lang = token.lang || 'plaintext'
|
||||
lang = languageAliases[lang] || lang
|
||||
|
||||
// 检查语言是否支持
|
||||
if (!hljs.getLanguage(lang)) {
|
||||
lang = 'plaintext'
|
||||
}
|
||||
const lang = getHljsLanguage(token.lang)
|
||||
|
||||
const highlighted = hljs.highlight(token.text, { language: lang }).value
|
||||
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
|
||||
@@ -81,34 +102,26 @@ renderer.heading = function(token: any) {
|
||||
// 判断是否为本地文件链接(相对路径或本地绝对路径)
|
||||
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>`
|
||||
}
|
||||
|
||||
@@ -122,5 +135,3 @@ export async function renderMermaidDiagrams() {
|
||||
await mermaid.run()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* 路径处理工具函数
|
||||
*
|
||||
* @module utils/pathHelpers
|
||||
* @description 统一路径分割、文件名获取等操作,避免重复代码
|
||||
*/
|
||||
|
||||
import { getExt as getExtFromFileHelpers } from './fileHelpers'
|
||||
|
||||
// 重新导出 getExt,避免重复定义
|
||||
export const getExt = getExtFromFileHelpers
|
||||
|
||||
/**
|
||||
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
|
||||
* @type {RegExp}
|
||||
*/
|
||||
export const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
|
||||
/**
|
||||
* 分割路径为多个部分
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string[]} 路径数组
|
||||
* @example
|
||||
* splitPath('C:\\Users\\file.txt') // ['C:', 'Users', 'file.txt']
|
||||
* splitPath('/home/user/file.txt') // ['home', 'user', 'file.txt']
|
||||
*/
|
||||
export const splitPath = (path) => {
|
||||
if (!path) return []
|
||||
return path.split(PATH_SEPARATOR_REGEX)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件名(含扩展名)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件名
|
||||
* @example
|
||||
* getFileName('C:\\Users\\file.txt') // 'file.txt'
|
||||
* getFileName('/home/user/file.txt') // 'file.txt'
|
||||
*/
|
||||
export const getFileName = (path) => {
|
||||
if (!path) return ''
|
||||
const parts = splitPath(path)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父目录路径
|
||||
* @param {string} path - 文件或目录路径
|
||||
* @returns {string} 父目录路径
|
||||
* @example
|
||||
* getParentPath('C:\\Users\\file.txt') // 'C:\\Users'
|
||||
* getParentPath('/home/user/file.txt') // '/home/user'
|
||||
* getParentPath('E:/file.txt') // 'E:/'
|
||||
*/
|
||||
export const getParentPath = (path) => {
|
||||
if (!path) return ''
|
||||
|
||||
// 规范化路径分隔符
|
||||
const normalizedPath = path.replace(/\\/g, '/')
|
||||
|
||||
// 查找最后一个分隔符的位置
|
||||
const lastSep = normalizedPath.lastIndexOf('/')
|
||||
|
||||
if (lastSep <= 0) {
|
||||
// 没有分隔符或分隔符在开头,返回根目录(对于盘符情况)
|
||||
if (/^[A-Za-z]:$/.test(normalizedPath)) {
|
||||
return normalizedPath + '/' // E: 转换为 E:/
|
||||
}
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
const parentPath = normalizedPath.substring(0, lastSep)
|
||||
|
||||
// 特殊处理:如果是盘符根目录下的文件(E:/file.txt -> E:/)
|
||||
if (/^[A-Za-z]:$/.test(parentPath)) {
|
||||
return parentPath + '/' // 确保根目录带斜杠
|
||||
}
|
||||
|
||||
return parentPath || '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件名(不含扩展名)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件名(不含扩展名)
|
||||
* @example
|
||||
* getFileNameWithoutExt('file.txt') // 'file'
|
||||
* getFileNameWithoutExt('archive.tar.gz') // 'archive.tar'
|
||||
*/
|
||||
export const getFileNameWithoutExt = (path) => {
|
||||
const fileName = getFileName(path)
|
||||
const lastDot = fileName.lastIndexOf('.')
|
||||
return lastDot > 0 ? fileName.substring(0, lastDot) : fileName
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化路径分隔符(统一为正斜杠)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 规范化后的路径
|
||||
*/
|
||||
export const normalizePathSeparators = (path) => {
|
||||
if (!path) return ''
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接路径片段
|
||||
* @param {...string} parts - 路径片段
|
||||
* @returns {string} 连接后的路径
|
||||
* @example
|
||||
* joinPath('C:', 'Users', 'file.txt') // 'C:/Users/file.txt'
|
||||
*/
|
||||
export const joinPath = (...parts) => {
|
||||
return parts
|
||||
.filter(part => part && part !== '')
|
||||
.map(part => part.replace(/[\/\\]+$/, '').replace(/^[\/\\]+/, ''))
|
||||
.join('/')
|
||||
}
|
||||
220
web/src/views/MarkdownViewer.vue
Normal file
220
web/src/views/MarkdownViewer.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="markdown-viewer-container">
|
||||
<div class="viewer-header">
|
||||
<div class="title">
|
||||
<icon-file-text />
|
||||
<span>{{ currentFile?.name || 'Markdown 文档' }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<PdfExportButton @export-complete="onExportComplete" />
|
||||
<a-button @click="handleBackToList" type="outline">
|
||||
<icon-arrow-left />
|
||||
返回列表
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewer-content">
|
||||
<MarkdownEditor
|
||||
:content="fileContent"
|
||||
@content-change="handleContentChange"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<div class="status-bar">
|
||||
<div class="file-info">
|
||||
<span>{{ currentFile?.path }}</span>
|
||||
</div>
|
||||
<div class="content-info">
|
||||
<span>{{ wordCount }} 字符 | {{ lineCount }} 行</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import MarkdownEditor from '@/components/MarkdownEditor.vue'
|
||||
import PdfExportButton from '@/components/PdfExportButton.vue'
|
||||
import { useFileOperations } from '@/composables/useFileOperations'
|
||||
|
||||
export default {
|
||||
name: 'MarkdownViewer',
|
||||
components: {
|
||||
MarkdownEditor,
|
||||
PdfExportButton
|
||||
},
|
||||
props: {
|
||||
filePath: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['back'],
|
||||
setup(props, { emit }) {
|
||||
const fileOperations = useFileOperations()
|
||||
const fileContent = ref('')
|
||||
const currentFile = ref(null)
|
||||
const hasChanges = ref(false)
|
||||
const lastSavedContent = ref('')
|
||||
|
||||
// 计算属性
|
||||
const wordCount = computed(() => {
|
||||
return fileContent.value.length
|
||||
})
|
||||
|
||||
const lineCount = computed(() => {
|
||||
return fileContent.value.split('\n').length
|
||||
})
|
||||
|
||||
// 方法
|
||||
const loadFile = async () => {
|
||||
try {
|
||||
const response = await fileOperations.readFile(props.filePath)
|
||||
fileContent.value = response.content
|
||||
lastSavedContent.value = response.content
|
||||
hasChanges.value = false
|
||||
|
||||
// 获取文件信息
|
||||
const fileName = props.filePath.split('/').pop() || props.filePath.split('\\').pop()
|
||||
currentFile.value = {
|
||||
name: fileName,
|
||||
path: props.filePath
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(`读取文件失败: ${error?.message ?? String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentChange = (content) => {
|
||||
hasChanges.value = content !== lastSavedContent.value
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await fileOperations.saveFile(props.filePath, fileContent.value)
|
||||
lastSavedContent.value = fileContent.value
|
||||
hasChanges.value = false
|
||||
Message.success('文件已保存')
|
||||
} catch (error) {
|
||||
Message.error(`保存文件失败: ${error?.message ?? String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const onExportComplete = () => {
|
||||
Message.success('PDF 导出完成')
|
||||
}
|
||||
|
||||
const handleBackToList = () => {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadFile()
|
||||
})
|
||||
|
||||
return {
|
||||
fileContent,
|
||||
currentFile,
|
||||
hasChanges,
|
||||
wordCount,
|
||||
lineCount,
|
||||
handleContentChange,
|
||||
handleSave,
|
||||
onExportComplete,
|
||||
handleBackToList
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: white;
|
||||
margin: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
background: white;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
font-family: monospace;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.content-info {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.viewer-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 6px 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -495,6 +495,7 @@ import { Input, Select, Checkbox, InputGroup, Button, Option, Optgroup, Tooltip
|
||||
import MySQLCreate from './MySQLCreate.vue'
|
||||
import { STORAGE_KEYS } from '../constants/storage'
|
||||
import { useResultHistory, type ResultHistoryItem } from '../composables/useResultHistory'
|
||||
import { escapeHtml } from '@/utils/fileUtils'
|
||||
|
||||
// MySQL 数据类型选项
|
||||
const mysqlDataTypeOptions = [
|
||||
@@ -1125,13 +1126,7 @@ const formatJSON = (data: unknown): string => JSON.stringify(data, null, 2)
|
||||
const highlightJSON = (data: unknown): string => {
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
if (!json) return ''
|
||||
|
||||
// 转义 HTML 特殊字符
|
||||
const escapeHtml = (str: string) => str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
|
||||
// 语法高亮正则替换
|
||||
return escapeHtml(json)
|
||||
// 字符串值(双引号包围,不是键名)
|
||||
|
||||
@@ -6,17 +6,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { escapeHtml } from '@/utils/fileUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
|
||||
// 转义 HTML
|
||||
const escapeHtml = (str: string) => str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// JSON 高亮
|
||||
const highlightedJson = computed(() => {
|
||||
const json = JSON.stringify(props.data, null, 2)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 支持 CSV、JSON、Excel 格式
|
||||
*/
|
||||
|
||||
import { escapeHtml } from '@/utils/fileUtils'
|
||||
|
||||
/**
|
||||
* 导出为 CSV
|
||||
*/
|
||||
@@ -183,20 +185,6 @@ function downloadFile(content, filename, mimeType) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, m => map[m])
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制到剪贴板
|
||||
*/
|
||||
|
||||
113
web/src/views/markdown-editor/index.vue
Normal file
113
web/src/views/markdown-editor/index.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="markdown-editor-page">
|
||||
<div class="editor-container">
|
||||
<MarkdownEditor
|
||||
v-model:content="markdownContent"
|
||||
@content-change="handleContentChange"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import MarkdownEditor from '../../components/MarkdownEditor.vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
const markdownContent = ref('')
|
||||
|
||||
// 初始化示例内容
|
||||
const initSampleContent = () => {
|
||||
markdownContent.value = `# 欢迎使用 Markdown 编辑器
|
||||
|
||||
这是一个功能强大的 Markdown 编辑器,支持实时预览和 PDF 导出功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **实时预览** - 输入内容即时显示预览效果
|
||||
- ✅ **语法高亮** - 支持 GitHub 风格的 Markdown 语法
|
||||
- ✅ **PDF 导出** - 一键导出为格式化的 PDF 文档
|
||||
- ✅ **自动保存** - 支持 Ctrl + S 快捷键保存
|
||||
- ✅ **字数统计** - 实时显示字符数和行数
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 基本语法
|
||||
|
||||
\`\`\`markdown
|
||||
# 一级标题
|
||||
## 二级标题
|
||||
### 三级标题
|
||||
|
||||
**粗体文本** 和 *斜体文本*
|
||||
|
||||
- 无序列表项 1
|
||||
- 无序列表项 2
|
||||
- 嵌套列表项 1
|
||||
|
||||
1. 有序列表项 1
|
||||
2. 有序列表项 2
|
||||
|
||||
\`\`\`
|
||||
|
||||
### 代码块
|
||||
|
||||
\`\`\`javascript
|
||||
function hello() {
|
||||
console.log('Hello, World!')
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 表格
|
||||
|
||||
| 列 1 | 列 2 | 列 3 |
|
||||
|------|------|------|
|
||||
| 数据 1 | 数据 2 | 数据 3 |
|
||||
| 数据 4 | 数据 5 | 数据 6 |
|
||||
|
||||
### 引用
|
||||
|
||||
> 这是一个引用示例
|
||||
> 可以包含多行内容
|
||||
|
||||
---
|
||||
|
||||
**开始创作吧!**`
|
||||
}
|
||||
|
||||
const handleContentChange = (content) => {
|
||||
// 内容变化时的处理
|
||||
}
|
||||
|
||||
const handleSave = (content) => {
|
||||
// 保存处理
|
||||
console.log('Content saved:', content)
|
||||
Message.success('内容已保存到本地存储')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 从本地存储加载之前保存的内容
|
||||
const savedContent = localStorage.getItem('u-desk-markdown-content')
|
||||
if (savedContent) {
|
||||
markdownContent.value = savedContent
|
||||
} else {
|
||||
// 没有保存的内容时显示示例内容
|
||||
initSampleContent()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-editor-page {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
16
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
16
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
@@ -28,6 +28,10 @@ export function EmptyRecycleBin():Promise<void>;
|
||||
|
||||
export function ExecuteSQL(arg1:number,arg2:string,arg3:string):Promise<Record<string, any>>;
|
||||
|
||||
export function ExportMarkdownToPDF(arg1:string):Promise<string>;
|
||||
|
||||
export function ExportPDF(arg1:string,arg2:string,arg3:string,arg4:number,arg5:number,arg6:number):Promise<Record<string, any>>;
|
||||
|
||||
export function ExtractFileFromZip(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function ExtractFileFromZipToTemp(arg1:string,arg2:string):Promise<string>;
|
||||
@@ -56,6 +60,12 @@ export function GetIndexes(arg1:number,arg2:string,arg3:string):Promise<Array<Re
|
||||
|
||||
export function GetMemoryInfo():Promise<Record<string, any>>;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export function GetRecycleBinEntries():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function GetResultHistory(arg1:any,arg2:string,arg3:number,arg4:number):Promise<Record<string, any>>;
|
||||
@@ -108,12 +118,16 @@ export function SaveAppConfig(arg1:main.SaveAppConfigRequest):Promise<Record<str
|
||||
|
||||
export function SaveDbConnection(arg1:api.SaveConnectionRequest):Promise<void>;
|
||||
|
||||
|
||||
export function SaveResult(arg1:number,arg2:string,arg3:string,arg4:string,arg5:any,arg6:Array<string>,arg7:number,arg8:number):Promise<Record<string, any>>;
|
||||
|
||||
export function SaveSqlTabs(arg1:Array<Record<string, any>>):Promise<void>;
|
||||
|
||||
export function SelectPDFSaveDirectory():Promise<string>;
|
||||
|
||||
export function SetUpdateConfig(arg1:boolean,arg2:number,arg3:string):Promise<Record<string, any>>;
|
||||
|
||||
|
||||
export function TestDbConnection(arg1:number):Promise<void>;
|
||||
|
||||
export function TestDbConnectionWithParams(arg1:api.TestConnectionRequest):Promise<void>;
|
||||
@@ -130,4 +144,6 @@ export function WindowMaximize():Promise<void>;
|
||||
|
||||
export function WindowMinimize():Promise<void>;
|
||||
|
||||
export function WindowToggleAlwaysOnTop():Promise<boolean>;
|
||||
|
||||
export function WriteFile(arg1:main.WriteFileRequest):Promise<void>;
|
||||
|
||||
@@ -50,6 +50,14 @@ export function ExecuteSQL(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ExecuteSQL'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ExportMarkdownToPDF(arg1) {
|
||||
return window['go']['main']['App']['ExportMarkdownToPDF'](arg1);
|
||||
}
|
||||
|
||||
export function ExportPDF(arg1, arg2, arg3, arg4, arg5, arg6) {
|
||||
return window['go']['main']['App']['ExportPDF'](arg1, arg2, arg3, arg4, arg5, arg6);
|
||||
}
|
||||
|
||||
export function ExtractFileFromZip(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExtractFileFromZip'](arg1, arg2);
|
||||
}
|
||||
@@ -106,6 +114,30 @@ export function GetMemoryInfo() {
|
||||
return window['go']['main']['App']['GetMemoryInfo']();
|
||||
}
|
||||
|
||||
export function GetOpenClawConfig() {
|
||||
return window['go']['main']['App']['GetOpenClawConfig']();
|
||||
}
|
||||
|
||||
export function GetOpenClawModelUsage() {
|
||||
return window['go']['main']['App']['GetOpenClawModelUsage']();
|
||||
}
|
||||
|
||||
export function GetOpenClawSessionHistory(arg1, arg2) {
|
||||
return window['go']['main']['App']['GetOpenClawSessionHistory'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetOpenClawSessions() {
|
||||
return window['go']['main']['App']['GetOpenClawSessions']();
|
||||
}
|
||||
|
||||
export function GetOpenClawStatus() {
|
||||
return window['go']['main']['App']['GetOpenClawStatus']();
|
||||
}
|
||||
|
||||
export function GetOpenClawSystemUsage() {
|
||||
return window['go']['main']['App']['GetOpenClawSystemUsage']();
|
||||
}
|
||||
|
||||
export function GetRecycleBinEntries() {
|
||||
return window['go']['main']['App']['GetRecycleBinEntries']();
|
||||
}
|
||||
@@ -210,6 +242,10 @@ export function SaveDbConnection(arg1) {
|
||||
return window['go']['main']['App']['SaveDbConnection'](arg1);
|
||||
}
|
||||
|
||||
export function SaveOpenClawConfig(arg1) {
|
||||
return window['go']['main']['App']['SaveOpenClawConfig'](arg1);
|
||||
}
|
||||
|
||||
export function SaveResult(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) {
|
||||
return window['go']['main']['App']['SaveResult'](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
|
||||
}
|
||||
@@ -218,10 +254,18 @@ export function SaveSqlTabs(arg1) {
|
||||
return window['go']['main']['App']['SaveSqlTabs'](arg1);
|
||||
}
|
||||
|
||||
export function SelectPDFSaveDirectory() {
|
||||
return window['go']['main']['App']['SelectPDFSaveDirectory']();
|
||||
}
|
||||
|
||||
export function SetUpdateConfig(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['SetUpdateConfig'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SwitchOpenClawSession(arg1) {
|
||||
return window['go']['main']['App']['SwitchOpenClawSession'](arg1);
|
||||
}
|
||||
|
||||
export function TestDbConnection(arg1) {
|
||||
return window['go']['main']['App']['TestDbConnection'](arg1);
|
||||
}
|
||||
@@ -254,6 +298,10 @@ export function WindowMinimize() {
|
||||
return window['go']['main']['App']['WindowMinimize']();
|
||||
}
|
||||
|
||||
export function WindowToggleAlwaysOnTop() {
|
||||
return window['go']['main']['App']['WindowToggleAlwaysOnTop']();
|
||||
}
|
||||
|
||||
export function WriteFile(arg1) {
|
||||
return window['go']['main']['App']['WriteFile'](arg1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user