重构: 死代码清理 + 拷贝优化 + 滚动条修复
This commit is contained in:
@@ -29,16 +29,22 @@
|
||||
{{ config.currentFileName }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<icon-copy
|
||||
class="copy-icon"
|
||||
title="复制路径"
|
||||
@click="handleCopyPath"
|
||||
/>
|
||||
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="left">
|
||||
<a-button
|
||||
size="mini"
|
||||
type="text"
|
||||
:status="copied ? 'success' : 'normal'"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
<icon-copy v-if="!copied" />
|
||||
<icon-check v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<div class="editor-content thin-dark-scrollbar">
|
||||
<!-- 二进制文件提示 -->
|
||||
<div v-if="config.isBinaryFile" class="binary-file-message">
|
||||
<pre>{{ config.fileContent }}</pre>
|
||||
@@ -271,7 +277,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 预览模式 -->
|
||||
<div v-if="!config.isEditMode" ref="markdownPreviewRef" class="markdown-preview-content markdown-content" v-html="config.rendered"></div>
|
||||
<div v-if="!config.isEditMode" ref="markdownPreviewRef" class="markdown-preview-content markdown-content thin-dark-scrollbar" v-html="config.rendered"></div>
|
||||
|
||||
<!-- 编辑模式 -->
|
||||
<div v-else class="markdown-edit-wrapper">
|
||||
@@ -346,8 +352,9 @@
|
||||
<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, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
|
||||
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconCheck, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileName, escapeHtml } from '@/utils/fileUtils'
|
||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -730,23 +737,11 @@ const getPreviewButtonTooltip = () => {
|
||||
return '切换到预览'
|
||||
}
|
||||
|
||||
// 复制文件路径
|
||||
const handleCopyPath = () => {
|
||||
const path = props.config.currentFileFullPath
|
||||
if (!path) return
|
||||
// 复制文件路径(带状态反馈)
|
||||
const { copied, copy: copyPath, cleanup: copyCleanup } = useClipboardCopy()
|
||||
|
||||
navigator.clipboard.writeText(path).then(() => {
|
||||
Message.success('路径已复制')
|
||||
}).catch(() => {
|
||||
// 降级方案
|
||||
const input = document.createElement('input')
|
||||
input.value = path
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
Message.success('路径已复制')
|
||||
})
|
||||
const handleCopyPath = async () => {
|
||||
await copyPath(props.config.currentFileFullPath)
|
||||
}
|
||||
|
||||
// 处理 Markdown 预览中的本地文件链接点击
|
||||
@@ -810,6 +805,7 @@ onUnmounted(() => {
|
||||
window.removeEventListener('message', handleHtmlIframeMessage)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
copyCleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
:scroll="{ y: 'auto' }"
|
||||
class="file-table"
|
||||
@row-click="handleRowClick"
|
||||
@row-dblclick="handleRowDoubleClick"
|
||||
@row-contextmenu="handleRowContextMenu"
|
||||
/>
|
||||
|
||||
|
||||
@@ -32,10 +32,17 @@
|
||||
@navigate="handleGoToPath"
|
||||
@openFile="handleOpenFile"
|
||||
/>
|
||||
<a-tooltip content="复制路径" position="top">
|
||||
<div class="copy-icon-wrapper" @click="handleCopyPath">
|
||||
<icon-copy />
|
||||
</div>
|
||||
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
|
||||
<a-button
|
||||
size="mini"
|
||||
type="text"
|
||||
:status="copied ? 'success' : 'normal'"
|
||||
class="toolbar-copy-btn"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
<icon-copy v-if="!copied" />
|
||||
<icon-check v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,9 +116,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon'
|
||||
import type { ToolbarConfig } from '@/types/file-system'
|
||||
import PathBreadcrumb from './PathBreadcrumb.vue'
|
||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
@@ -129,7 +137,6 @@ interface Emits {
|
||||
(e: 'goToPath', path: string): void
|
||||
(e: 'openFile', path: string): void
|
||||
(e: 'navigateToZipDirectory', path: string): void
|
||||
(e: 'showMessage', message: string, type: 'success' | 'error' | 'warning' | 'info'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -163,22 +170,10 @@ const handleToggleSidebar = () => {
|
||||
emit('update:showSidebar', !props.config.showSidebar)
|
||||
}
|
||||
|
||||
const handleCopyPath = async () => {
|
||||
const path = props.config.filePath
|
||||
if (!path) return
|
||||
const { copied, copy: copyPath } = useClipboardCopy()
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(path)
|
||||
emit('showMessage', '路径已复制', 'success')
|
||||
} catch {
|
||||
const input = document.createElement('input')
|
||||
input.value = path
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
emit('showMessage', '路径已复制', 'success')
|
||||
}
|
||||
const handleCopyPath = async () => {
|
||||
await copyPath(props.config.filePath)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -233,22 +228,8 @@ const handleCopyPath = async () => {
|
||||
border-color: var(--color-border-2);
|
||||
}
|
||||
|
||||
.copy-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-icon-wrapper:hover {
|
||||
color: rgb(var(--primary-6));
|
||||
background: var(--color-fill-2);
|
||||
.toolbar-copy-btn {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.zip-breadcrumb {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
/**
|
||||
* 拷贝路径 composable(3-tier fallback: Wails native → clipboard API → execCommand)
|
||||
*/
|
||||
export function useClipboardCopy() {
|
||||
const copied = ref(false)
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const copy = async (path: string) => {
|
||||
if (!path || copied.value) return
|
||||
|
||||
try {
|
||||
if (window.runtime?.ClipboardSetText) {
|
||||
await window.runtime.ClipboardSetText(path)
|
||||
} else {
|
||||
await navigator.clipboard.writeText(path)
|
||||
}
|
||||
copied.value = true
|
||||
} catch {
|
||||
try {
|
||||
const input = document.createElement('input')
|
||||
input.style.position = 'fixed'
|
||||
input.style.opacity = '0'
|
||||
input.value = path
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
copied.value = true
|
||||
} catch {
|
||||
Message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
copyTimer = setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (copyTimer) { clearTimeout(copyTimer); copyTimer = null }
|
||||
}
|
||||
|
||||
return { copied, copy, cleanup }
|
||||
}
|
||||
@@ -154,7 +154,6 @@ export default {
|
||||
const isFullscreen = ref(false)
|
||||
const isEditorExpanded = ref(false)
|
||||
const isPreviewExpanded = ref(false)
|
||||
const showPreview = ref(true)
|
||||
const editorWidthPercent = ref(50)
|
||||
const editorContentRef = ref(null)
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
@@ -231,13 +230,10 @@ export default {
|
||||
|
||||
// 切换功能
|
||||
const togglePreview = () => {
|
||||
showPreview.value = !showPreview.value
|
||||
if (showPreview.value) {
|
||||
// 恢复预览时重新调整大小
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}
|
||||
// 预览面板始终显示,保留快捷键兼容性
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
@@ -323,16 +319,6 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
// 导出方法
|
||||
const getMarkdownContent = () => {
|
||||
return markdownContent.value
|
||||
}
|
||||
|
||||
const setMarkdownContent = (content) => {
|
||||
markdownContent.value = content
|
||||
hasChanges.value = content !== lastSavedContent.value
|
||||
}
|
||||
|
||||
return {
|
||||
markdownContent,
|
||||
textarea,
|
||||
@@ -343,13 +329,10 @@ export default {
|
||||
isFullscreen,
|
||||
isEditorExpanded,
|
||||
isPreviewExpanded,
|
||||
showPreview,
|
||||
handleInput,
|
||||
handleKeydown,
|
||||
saveContent,
|
||||
onExportComplete,
|
||||
getMarkdownContent,
|
||||
setMarkdownContent,
|
||||
togglePreview,
|
||||
toggleFullscreen,
|
||||
toggleEditorExpand,
|
||||
|
||||
@@ -16,7 +16,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const isDark = computed(() => theme.value === 'dark')
|
||||
const isLight = computed(() => theme.value === 'light')
|
||||
const tooltipText = computed(() =>
|
||||
isDark.value ? '切换到亮色主题' : '切换到夜间主题'
|
||||
)
|
||||
@@ -53,20 +52,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
applyTheme(newTheme)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置为亮色主题
|
||||
*/
|
||||
const setLightTheme = () => {
|
||||
applyTheme('light')
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置为暗色主题
|
||||
*/
|
||||
const setDarkTheme = () => {
|
||||
applyTheme('dark')
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化主题(应用启动时调用)
|
||||
*/
|
||||
@@ -96,16 +81,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
systemThemeListener = () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理系统主题监听器
|
||||
*/
|
||||
const removeSystemThemeListener = () => {
|
||||
if (systemThemeListener) {
|
||||
systemThemeListener()
|
||||
systemThemeListener = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 返回 ====================
|
||||
return {
|
||||
// 状态
|
||||
@@ -113,14 +88,10 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
|
||||
// 计算属性
|
||||
isDark,
|
||||
isLight,
|
||||
tooltipText,
|
||||
|
||||
// 方法
|
||||
toggleTheme,
|
||||
setLightTheme,
|
||||
setDarkTheme,
|
||||
initTheme,
|
||||
removeSystemThemeListener
|
||||
initTheme
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { formatBytes as formatFileSize } from '@/utils/fileUtils'
|
||||
|
||||
/**
|
||||
* 更新管理 Store
|
||||
@@ -38,14 +39,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (!bytes || bytes < 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
return formatFileSize(bytesPerSecond) + '/s'
|
||||
}
|
||||
@@ -211,6 +204,8 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
downloadProgress.value = 100
|
||||
downloadStatus.value = 'success'
|
||||
|
||||
const fileSize = (data.file_size as number) || 0
|
||||
|
||||
// 系统通知:下载完成
|
||||
try {
|
||||
window.runtime?.SendNotification?.({
|
||||
@@ -218,7 +213,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
body: `更新包下载完成 (${formatFileSize(fileSize)}),正在安装...`
|
||||
})
|
||||
} catch { /* 通知不可用时忽略 */ }
|
||||
const fileSize = (data.file_size as number) || 0
|
||||
progressInfo.value = {
|
||||
speed: 0,
|
||||
downloaded: fileSize,
|
||||
@@ -263,13 +257,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
window.runtime.EventsOff('download-complete')
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭更新提示
|
||||
*/
|
||||
const closeUpdateNotification = () => {
|
||||
showUpdate.value = false
|
||||
}
|
||||
|
||||
// ==================== 返回 ====================
|
||||
return {
|
||||
// 状态
|
||||
@@ -288,7 +275,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
installUpdate,
|
||||
setupEventListeners,
|
||||
removeEventListeners,
|
||||
closeUpdateNotification,
|
||||
formatFileSize,
|
||||
formatSpeed
|
||||
}
|
||||
|
||||
@@ -50,6 +50,29 @@ body {
|
||||
scrollbar-color: var(--color-border-2, rgba(0, 0, 0, 0.1)) transparent;
|
||||
}
|
||||
|
||||
/* 暗色细滚动条(用于编辑器/预览区) */
|
||||
.thin-dark-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border-3) transparent;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-2);
|
||||
}
|
||||
|
||||
/* Highlight.js CSS */
|
||||
.hljs {
|
||||
display: block;
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
*/
|
||||
|
||||
// Core
|
||||
export { EditorView, lineNumbers, highlightActiveLineGutter, keymap, drawSelection, dropCursor } from '@codemirror/view'
|
||||
export { EditorState, Compartment, Facet, StateEffect, StateField } from '@codemirror/state'
|
||||
export { EditorView, lineNumbers, highlightActiveLineGutter, keymap } from '@codemirror/view'
|
||||
export { EditorState, Compartment } from '@codemirror/state'
|
||||
export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting, StreamLanguage } from '@codemirror/language'
|
||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
export { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包
|
||||
|
||||
@@ -61,8 +61,6 @@ export const FILE_EXTENSIONS = {
|
||||
// 视频文件
|
||||
VIDEO_BROWSER: ['mp4', 'webm', 'ogg', 'mov', 'm4v'], // 浏览器原生支持
|
||||
VIDEO_EXTERNAL: ['avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 需要外部播放器(注意:不用 'ts' 避免 TypeScript 冲突)
|
||||
VIDEO: ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 所有视频
|
||||
|
||||
// 音频文件
|
||||
AUDIO: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus', 'webm'],
|
||||
|
||||
@@ -96,9 +94,6 @@ export const FILE_EXTENSIONS = {
|
||||
// 纯文本文件
|
||||
TEXT: ['txt', 'text', 'log', 'md', 'markdown', 'rst', 'adoc', 'tex', 'msg', 'csv', 'tsv'],
|
||||
|
||||
// 标记语言文件(用于特殊预览)
|
||||
MARKUP: ['html', 'htm', 'md', 'markdown'],
|
||||
|
||||
// 数据库文件
|
||||
DATABASE: ['db', 'sqlite', 'mdb', 'accdb'],
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT } from './const
|
||||
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
|
||||
* @type {RegExp}
|
||||
*/
|
||||
export const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
|
||||
/**
|
||||
* 规范化路径分隔符(统一为正斜杠)
|
||||
@@ -93,26 +93,6 @@ export function getFileName(path) {
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
/**
|
||||
* 分割路径为多个部分
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string[]} 路径数组
|
||||
*/
|
||||
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 lastDot = fileName.lastIndexOf('.')
|
||||
return lastDot > 0 ? fileName.substring(0, lastDot) : fileName
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件信息获取对应的图标
|
||||
@@ -177,89 +157,6 @@ export function normalizeFilePath(path, encode = false) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型的友好名称
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件类型名称
|
||||
*
|
||||
* @example
|
||||
* getFileTypeName('image.png') // "PNG图片"
|
||||
* getFileTypeName('document.pdf') // "PDF文档"
|
||||
* getFileTypeName('unknown.xyz') // "XYZ文件"
|
||||
*/
|
||||
export function getFileTypeName(path) {
|
||||
const ext = getExt(path)
|
||||
const extUpper = ext.toUpperCase()
|
||||
|
||||
// 图片
|
||||
if (['JPG', 'JPEG', 'PNG', 'GIF', 'BMP', 'SVG', 'WEBP'].includes(extUpper)) {
|
||||
return `${extUpper}图片`
|
||||
}
|
||||
|
||||
// 视频
|
||||
if (['MP4', 'WEBM', 'AVI', 'MKV'].includes(extUpper)) {
|
||||
return `${extUpper}视频`
|
||||
}
|
||||
|
||||
// 音频
|
||||
if (['MP3', 'WAV', 'FLAC', 'AAC'].includes(extUpper)) {
|
||||
return `${extUpper}音频`
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (extUpper === 'PDF') {
|
||||
return 'PDF文档'
|
||||
}
|
||||
|
||||
// 文档
|
||||
if (['DOC', 'DOCX', 'XLS', 'XLSX', 'PPT', 'PPTX'].includes(extUpper)) {
|
||||
return `${extUpper}文档`
|
||||
}
|
||||
|
||||
// 代码
|
||||
if (['JS', 'TS', 'PY', 'JAVA', 'GO', 'RS', 'CPP'].includes(extUpper)) {
|
||||
return `${extUpper}代码`
|
||||
}
|
||||
|
||||
// 默认返回扩展名
|
||||
return ext ? `${extUpper}文件` : '文件'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否为绝对路径
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean} 是否为绝对路径
|
||||
*
|
||||
* @example
|
||||
* isAbsolutePath('C:\\Users') // true (Windows)
|
||||
* isAbsolutePath('/home/user') // true (Unix)
|
||||
* isAbsolutePath('folder/file') // false
|
||||
*/
|
||||
export function isAbsolutePath(path) {
|
||||
if (!path) return false
|
||||
|
||||
// Windows路径:盘符开头
|
||||
if (/^[A-Za-z]:\\/.test(path)) return true
|
||||
|
||||
// Unix路径:以 / 开头
|
||||
if (path.startsWith('/')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接路径片段
|
||||
* @param {...string} parts - 路径片段
|
||||
* @returns {string} 拼接后的路径
|
||||
*
|
||||
* @example
|
||||
* joinPaths('/home', 'user', 'docs') // "/home/user/docs"
|
||||
* joinPaths('C:\\Users', 'user') // "C:\\Users\\user"
|
||||
*/
|
||||
export function joinPaths(...parts) {
|
||||
return parts.join('/').replace(/\/+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路径使用的分隔符(Windows 反斜杠或 Unix 正斜杠)
|
||||
* @param {string} path - 文件路径
|
||||
@@ -301,24 +198,6 @@ export function getParentPath(path) {
|
||||
return parentPath || '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件名,移除非法字符
|
||||
* @param {string} filename - 原始文件名
|
||||
* @param {string} [replacement='_'] - 替换字符
|
||||
* @returns {string} 清理后的文件名
|
||||
*
|
||||
* @example
|
||||
* sanitizeFileName('file/name.txt') // "file_name.txt"
|
||||
* sanitizeFileName('file:name.txt', '-') // "file-name.txt"
|
||||
*/
|
||||
export function sanitizeFileName(filename, replacement = '_') {
|
||||
if (!filename) return ''
|
||||
|
||||
// Windows不允许的字符: < > : " / \ | ? *
|
||||
const illegalChars = /[<>:"/\\|?*]/g
|
||||
|
||||
return filename.replace(illegalChars, replacement)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表排序:文件夹优先,支持多字段排序
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
:block-node="true"
|
||||
:default-expand-all="false"
|
||||
:show-line="false"
|
||||
:load-more="handleLoadMore"
|
||||
:selected-keys="selectedKeys"
|
||||
:expanded-keys="expandedKeys"
|
||||
@select="handleTreeSelect"
|
||||
@@ -586,7 +585,7 @@ const filteredTreeData = computed(() => {
|
||||
})
|
||||
|
||||
// 搜索历史管理
|
||||
const MAX_HISTORY = 100 // 最多保存10条历史
|
||||
const MAX_HISTORY = 100 // 最多保存100条历史
|
||||
|
||||
const saveSearchHistory = (text: string) => {
|
||||
if (!text.trim()) return
|
||||
@@ -694,22 +693,29 @@ const handleTreeSelect = (keys, info) => {
|
||||
|
||||
// 触发节点选择相关事件
|
||||
emitNodeSelectEvents(nodeData, conn)
|
||||
|
||||
// 选中连接/数据库时自动展开加载子节点(仅在未加载时触发)
|
||||
if (nodeData.type === 'connection' || nodeData.type === 'database') {
|
||||
if (!expandedKeys.value.includes(key)) {
|
||||
expandedKeys.value = [...expandedKeys.value, key]
|
||||
saveToStorage(STORAGE_KEYS.TREE_EXPANDED_KEYS, expandedKeys.value)
|
||||
}
|
||||
// 仅在子节点未加载时才触发展开加载,避免快速切换时的重复请求
|
||||
if (!nodeData.children || nodeData.children.length === 0) {
|
||||
handleNodeExpand(nodeData, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 树节点展开
|
||||
const handleTreeExpand = (keys: string[], info) => {
|
||||
// Arco Design Tree 的 expand 事件参数格式
|
||||
// keys: 展开的节点 key 数组
|
||||
// info: { expanded: boolean, node: TreeNodeData, e: Event }
|
||||
|
||||
// 更新展开状态
|
||||
expandedKeys.value = keys
|
||||
|
||||
|
||||
// 保存到localStorage
|
||||
saveToStorage(STORAGE_KEYS.TREE_EXPANDED_KEYS, keys)
|
||||
|
||||
|
||||
if (!info || !info.node) {
|
||||
// 如果没有 info.node,尝试从 keys 中获取
|
||||
if (keys && keys.length > 0) {
|
||||
const lastExpandedKey = keys[keys.length - 1]
|
||||
const nodeData = findNodeByKey(treeData.value, lastExpandedKey)
|
||||
@@ -720,10 +726,10 @@ const handleTreeExpand = (keys: string[], info) => {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const nodeData = info.node
|
||||
const isExpanded = info.expanded !== false // 默认为展开
|
||||
|
||||
const isExpanded = info.expanded !== false
|
||||
|
||||
if (isExpanded) {
|
||||
handleNodeExpand(nodeData, true)
|
||||
}
|
||||
@@ -732,14 +738,12 @@ const handleTreeExpand = (keys: string[], info) => {
|
||||
// 处理节点展开逻辑
|
||||
const handleNodeExpand = (nodeData, isExpanded) => {
|
||||
if (!isExpanded) return
|
||||
|
||||
|
||||
if (nodeData.type === 'connection') {
|
||||
// 连接节点:加载数据库列表
|
||||
if (!nodeData.children || nodeData.children.length === 0) {
|
||||
loadDatabases(nodeData)
|
||||
}
|
||||
} else if (nodeData.type === 'database') {
|
||||
// 数据库节点:加载表列表
|
||||
if (!nodeData.children || nodeData.children.length === 0) {
|
||||
loadTables(nodeData)
|
||||
}
|
||||
@@ -759,10 +763,22 @@ const handleLoadMore = (nodeData) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 通用加载节点数据的错误处理包装器
|
||||
// 通用加载节点数据的错误处理包装器(带超时保护)
|
||||
const NODE_LOAD_TIMEOUT = 15000 // 15秒超时
|
||||
|
||||
const withLoadingNode = async (nodeKey: string, loader: () => Promise<void>) => {
|
||||
if (loadingNodes.value.has(nodeKey)) return
|
||||
loadingNodes.value.add(nodeKey)
|
||||
|
||||
// 超时保护:防止无限转圈
|
||||
const timer = setTimeout(() => {
|
||||
if (loadingNodes.value.has(nodeKey)) {
|
||||
loadingNodes.value.delete(nodeKey)
|
||||
refreshTreeData()
|
||||
console.warn(`[ConnectionTree] 节点 ${nodeKey} 加载超时 (${NODE_LOAD_TIMEOUT}ms)`)
|
||||
}
|
||||
}, NODE_LOAD_TIMEOUT)
|
||||
|
||||
try {
|
||||
await loader()
|
||||
// 强制触发响应式更新
|
||||
@@ -772,7 +788,9 @@ const withLoadingNode = async (nodeKey: string, loader: () => Promise<void>) =>
|
||||
const loadType = nodeKey.startsWith('conn-') ? 'databases' :
|
||||
nodeKey.startsWith('db-') ? 'tables' : 'keys'
|
||||
Message.error(getLoadFailedTip(error, loadType))
|
||||
console.error(`[ConnectionTree] 节点 ${nodeKey} 加载失败:`, error)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
loadingNodes.value.delete(nodeKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {Message} from '@arco-design/web-vue'
|
||||
import {
|
||||
EditorView, keymap, lineNumbers,
|
||||
EditorState,
|
||||
sql, javascript,
|
||||
defaultKeymap, history, historyKeymap,
|
||||
defaultHighlightStyle, syntaxHighlighting
|
||||
} from '@/utils/codemirrorExports'
|
||||
@@ -55,22 +54,22 @@ const tabPersistence = useTabPersistence()
|
||||
// 数据库类型配置
|
||||
const DB_CONFIG = {
|
||||
mysql: {
|
||||
language: () => sql(),
|
||||
language: async () => (await import('@codemirror/lang-sql')).sql(),
|
||||
defaultContent: 'select 1;',
|
||||
executeText: '执行'
|
||||
},
|
||||
redis: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
language: async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'GET key\nSET key value\nHGET hash field',
|
||||
executeText: '执行命令'
|
||||
},
|
||||
mongo: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
language: async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'db.collection.find({})\n// 示例:db.users.find({name: "John"})',
|
||||
executeText: '执行查询'
|
||||
},
|
||||
mongodb: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
language: async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'db.collection.find({})\n// 示例:db.users.find({name: "John"})',
|
||||
executeText: '执行查询'
|
||||
}
|
||||
@@ -79,7 +78,7 @@ const DB_CONFIG = {
|
||||
// ==================== 工具函数 ====================
|
||||
const getDbType = () => props.currentConnection?.type?.toLowerCase() || 'mysql'
|
||||
const getDbConfig = (dbType = null) => DB_CONFIG[dbType || getDbType()] || DB_CONFIG.mysql
|
||||
const getLanguageMode = (dbType = null) => getDbConfig(dbType).language()
|
||||
const getLanguageMode = async (dbType = null) => getDbConfig(dbType).language()
|
||||
const getDefaultContent = (dbType = null) => getDbConfig(dbType).defaultContent
|
||||
const getExecuteButtonText = () => getDbConfig().executeText
|
||||
|
||||
@@ -91,9 +90,9 @@ let saveTimer = null
|
||||
const lastExecutionTime = ref(null)
|
||||
|
||||
// 创建编辑器扩展
|
||||
const createEditorExtensions = () => {
|
||||
const createEditorExtensions = async () => {
|
||||
const dbType = getDbType()
|
||||
const languageMode = getLanguageMode(dbType)
|
||||
const languageMode = await getLanguageMode(dbType)
|
||||
|
||||
return [
|
||||
EditorState.lineSeparator.of('\n'),
|
||||
@@ -202,7 +201,7 @@ const initEditor = async () => {
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: initialContent,
|
||||
extensions: createEditorExtensions()
|
||||
extensions: await createEditorExtensions()
|
||||
})
|
||||
|
||||
editorView = new EditorView({
|
||||
|
||||
@@ -20,7 +20,6 @@ import { IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
EditorView, lineNumbers,
|
||||
EditorState,
|
||||
sql,
|
||||
defaultHighlightStyle, syntaxHighlighting
|
||||
} from '@/utils/codemirrorExports'
|
||||
|
||||
@@ -68,6 +67,8 @@ const initEditor = async () => {
|
||||
// 检测是否为暗色主题
|
||||
const isDark = document.body.hasAttribute('arco-theme')
|
||||
|
||||
const { sql } = await import('@codemirror/lang-sql')
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: sqlText,
|
||||
extensions: [
|
||||
|
||||
@@ -264,7 +264,11 @@ const sqlPreviewInfo = ref<{
|
||||
// 编辑器/结果区域高度调整
|
||||
const loadEditorAreaHeight = (): number => {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.EDITOR_AREA_HEIGHT)
|
||||
return saved ? Number(saved) : 50
|
||||
if (saved) {
|
||||
const val = Number(saved)
|
||||
if (Number.isFinite(val) && val > 0 && val <= 100) return val
|
||||
}
|
||||
return 50
|
||||
}
|
||||
const editorAreaHeight = ref(loadEditorAreaHeight())
|
||||
const editorAreaPixelHeight = ref<number | null>(null)
|
||||
@@ -320,7 +324,7 @@ const handleEditorResultDividerMouseDown = (e: MouseEvent) => {
|
||||
|
||||
if (!(mainLayoutEl instanceof HTMLElement)) return
|
||||
|
||||
const resizeHandler = createResizeHandler(mainLayoutEl, () => editorAreaHeight.value, {
|
||||
const resizeHandler = createResizeHandler(() => mainLayoutEl, () => editorAreaHeight.value, {
|
||||
minPercent: 20,
|
||||
maxPercent: 80,
|
||||
minPixels: 150,
|
||||
|
||||
@@ -1,35 +1,2 @@
|
||||
export interface ResizeOptions {
|
||||
minPercent?: number
|
||||
maxPercent?: number
|
||||
minPixels?: number
|
||||
onResize?: (percentage: number) => void
|
||||
}
|
||||
|
||||
export function createResizeHandler(
|
||||
container: HTMLElement | null,
|
||||
getInitialPercentage: () => number,
|
||||
options: ResizeOptions = {}
|
||||
): (e: MouseEvent) => void {
|
||||
const { minPercent = 20, maxPercent = 80, minPixels = 150, onResize } = options
|
||||
|
||||
return (e: MouseEvent) => {
|
||||
if (!container) return
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
const percentage = ((moveEvent.clientY - rect.top) / rect.height) * 100
|
||||
const minPercentFromPixels = (minPixels / rect.height) * 100
|
||||
const clamped = Math.max(Math.max(minPercent, minPercentFromPixels), Math.min(maxPercent, percentage))
|
||||
onResize?.(clamped)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
// 保留向后兼容,内部使用通用工具
|
||||
export { createResizeHandler, type ResizeOptions } from '../../../utils/resize'
|
||||
|
||||
Reference in New Issue
Block a user