新增:Markdown编辑器/数据库优化/安全修复

- Markdown 编辑器:实时预览、PDF 导出、独立查看器
- 数据库优化:动态连接池、查询缓存、Redis Pipeline
- 窗口置顶功能
- 文件系统增强:右键菜单、编辑器集成、收藏夹重构
- 安全修复:XSS 防护、路径穿越、HTML 注入
- 代码质量:正则预编译、缓存锁优化、死代码清理
This commit is contained in:
lxy
2026-03-31 11:49:25 +08:00
parent 5f94ccf13b
commit e5dbe89a6f
59 changed files with 5289 additions and 1316 deletions
+51 -7
View File
@@ -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;
+2 -2
View File
@@ -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 {
+20 -75
View File
@@ -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
View 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
View 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
View 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
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>
+25 -7
View File
@@ -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 {
-34
View File
@@ -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]
}
+12 -3
View File
@@ -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'
}
}
+316
View File
@@ -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;
}
+9 -33
View File
@@ -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)
}
+3 -1
View File
@@ -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'
+5 -2
View File
@@ -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]))
-41
View File
@@ -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)
+10 -24
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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>
+1 -1
View File
@@ -6,7 +6,7 @@
*/
import { FILE_EXTENSIONS } from './constants'
import { getExt } from './pathHelpers'
import { getExt } from './fileUtils'
/**
* 可预览的文件类型有专门的预览处理
+84 -83
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 获取文件扩展名路径安全
* @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
View File
@@ -0,0 +1,129 @@
/**
*
* highlight.jsMarkdown 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'
}
+51 -40
View File
@@ -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()
}
}
-118
View File
@@ -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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
//
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// JSON
const highlightedJson = computed(() => {
const json = JSON.stringify(props.data, null, 2)
+2 -14
View File
@@ -3,6 +3,8 @@
* 支持 CSVJSONExcel 格式
*/
import { escapeHtml } from '@/utils/fileUtils'
/**
* 导出为 CSV
*/
@@ -183,20 +185,6 @@ function downloadFile(content, filename, mimeType) {
URL.revokeObjectURL(url)
}
/**
* HTML 转义
*/
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, m => map[m])
}
/**
* 复制到剪贴板
*/
+113
View 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
View File
@@ -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>;
+48
View File
@@ -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);
}