新增:应用配置管理模块,优化文件系统功能
- 新增 ConfigAPI 和 ConfigService 实现配置管理 - 新增 SettingsPanel 和 UpdateNotification 组件 - 文件系统模块化重构,提升代码质量 - 提取公共函数,优化代码结构 - 版本号更新至 0.2.0
This commit is contained in:
@@ -139,11 +139,31 @@
|
||||
class="compact-list"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="file-item-row" @click="selectFile(item.path)" :data-file-path="item.path">
|
||||
<div
|
||||
class="file-item-row"
|
||||
:class="{ 'file-item-selected': selectedFileItem?.path === item.path }"
|
||||
@click="handleFileClick(item)"
|
||||
:data-file-path="item.path"
|
||||
@dblclick="handleFileDoubleClick(item)"
|
||||
>
|
||||
<span class="file-item-icon">{{ getFileIcon(item) }}</span>
|
||||
<span class="file-item-name" :title="item.name">{{ item.name }}</span>
|
||||
<span v-if="!item.is_dir" class="file-item-size">{{ formatBytes(item.size) }}</span>
|
||||
<!-- 编辑状态 -->
|
||||
<a-input
|
||||
v-if="editingFilePath === item.path"
|
||||
v-model="editingFileName"
|
||||
ref="editingInputRef"
|
||||
size="mini"
|
||||
class="file-name-edit-input"
|
||||
@blur="saveEditingFileName"
|
||||
@keyup.enter="saveEditingFileName"
|
||||
@keyup.esc="cancelEditingFileName"
|
||||
@click.stop
|
||||
/>
|
||||
<!-- 正常显示状态 -->
|
||||
<span v-else class="file-item-name" :title="item.name">{{ item.name }}</span>
|
||||
<span v-if="!item.is_dir && editingFilePath !== item.path" class="file-item-size">{{ formatBytes(item.size) }}</span>
|
||||
<a-button
|
||||
v-if="editingFilePath !== item.path"
|
||||
type="text"
|
||||
size="mini"
|
||||
@click.stop="toggleFavorite(item)"
|
||||
@@ -179,7 +199,21 @@
|
||||
<template v-else-if="isMarkdownFile">📝 Markdown 预览</template>
|
||||
<template v-else>📝 文件内容</template>
|
||||
</span>
|
||||
<span class="panel-filename" v-if="currentFileName">{{ currentFileName }}</span>
|
||||
<a-tooltip
|
||||
v-if="currentFileName"
|
||||
:content="currentFileFullPath"
|
||||
position="bottom"
|
||||
>
|
||||
<span
|
||||
class="panel-filename"
|
||||
:class="{ 'file-outside-dir': !isFileInCurrentDirectory && selectedFilePath }"
|
||||
>
|
||||
{{ currentFileName }}
|
||||
<template v-if="!isFileInCurrentDirectory && selectedFilePath">
|
||||
<span class="file-location-hint"> (不在当前目录)</span>
|
||||
</template>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
@@ -218,7 +252,7 @@
|
||||
</div>
|
||||
|
||||
<!-- PDF 预览 -->
|
||||
<div v-else-if="isPdfFile" class="media-preview">
|
||||
<div v-else-if="isPdfFile" class="media-preview media-preview-pdf">
|
||||
<iframe :src="imagePreviewUrl" class="preview-pdf"></iframe>
|
||||
<div class="media-meta">
|
||||
<a-tag color="orangered">📕 PDF</a-tag>
|
||||
@@ -229,7 +263,7 @@
|
||||
<div v-else-if="isHtmlFile" class="html-preview-wrapper">
|
||||
<!-- 编辑模式/预览模式切换按钮 -->
|
||||
<div class="preview-mode-switch">
|
||||
<a-tooltip :content="canPreviewFile ? (isEditMode ? '切换到预览' : '切换到编辑') : '该文件不支持预览模式'">
|
||||
<a-tooltip position="left" :content="canPreviewFile ? (isEditMode ? '切换到预览' : '切换到编辑') : '该文件不支持预览模式'">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -269,7 +303,7 @@
|
||||
<div v-else-if="isMarkdownFile" class="markdown-preview-wrapper">
|
||||
<!-- 编辑模式/预览模式切换按钮 -->
|
||||
<div class="preview-mode-switch">
|
||||
<a-tooltip :content="canPreviewFile ? (isEditMode ? '切换到预览' : '切换到编辑') : '该文件不支持预览模式'">
|
||||
<a-tooltip position="left" :content="canPreviewFile ? (isEditMode ? '切换到预览' : '切换到编辑') : '该文件不支持预览模式'">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -303,6 +337,23 @@
|
||||
|
||||
<!-- 文本编辑器(带代码高亮) -->
|
||||
<div v-else class="text-editor-wrapper" :style="{ height: fileContentHeight + 'px' }">
|
||||
<!-- 编辑模式/预览模式切换按钮(所有文件类型显示,但仅 HTML/Markdown 可用) -->
|
||||
<div class="preview-mode-switch">
|
||||
<a-tooltip position="left" :content="canPreviewFile ? (isEditMode ? '切换到预览' : '切换到编辑') : '该文件不支持预览模式'">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canPreviewFile"
|
||||
@click="toggleEditMode"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-edit v-if="!isEditMode" />
|
||||
<icon-eye v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<CodeEditor
|
||||
v-model="fileContent"
|
||||
:file-extension="currentFileExtension"
|
||||
@@ -358,6 +409,11 @@
|
||||
<span class="context-menu-icon">🚀</span>
|
||||
<span>系统默认程序打开</span>
|
||||
</div>
|
||||
<div v-if="contextMenuTarget === 'file'" class="context-menu-item" @click="handleRenameSelectedFile">
|
||||
<span class="context-menu-icon">✏️</span>
|
||||
<span>重命名</span>
|
||||
<span class="context-menu-shortcut">F2</span>
|
||||
</div>
|
||||
<div v-if="contextMenuTarget === 'file'" class="context-menu-item danger" @click="handleDeleteSelectedFile">
|
||||
<span class="context-menu-icon">🗑️</span>
|
||||
<span>删除</span>
|
||||
@@ -388,7 +444,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { marked } from 'marked'
|
||||
import CodeEditor from '@/components/CodeEditor.vue'
|
||||
@@ -501,6 +557,12 @@ const contextMenuVisible = ref(false) // 是否显示右键菜单
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 }) // 右键菜单位置
|
||||
const contextMenuTarget = ref('blank') // 右键菜单目标: 'blank' (空白区域) 或 'file' (文件项)
|
||||
const selectedContextFile = ref(null) // 右键选中的文件
|
||||
const selectedFileItem = ref(null) // 当前选中的文件项(用于 F2/Delete 等快捷键)
|
||||
|
||||
// ========== 文件名编辑状态 ==========
|
||||
const editingFilePath = ref('') // 正在编辑的文件路径
|
||||
const editingFileName = ref('') // 编辑中的文件名
|
||||
const editingInputRef = ref() // 编辑输入框引用
|
||||
|
||||
// ========== 输入对话框状态 ==========
|
||||
const inputDialogVisible = ref(false) // 是否显示输入对话框
|
||||
@@ -792,19 +854,28 @@ const listDirectory = async () => {
|
||||
exitZipMode()
|
||||
}
|
||||
|
||||
// 注意:不要清空 selectedFilePath,保留原文件引用
|
||||
// 即使切换目录,保存时仍然保存到原文件
|
||||
if (selectedFilePath.value) {
|
||||
debugLog('[listDirectory] 目录已切换,但保留原文件引用:', selectedFilePath.value)
|
||||
}
|
||||
|
||||
addToHistory(filePath.value)
|
||||
pushToNavigationHistory(filePath.value)
|
||||
fileLoading.value = true
|
||||
try {
|
||||
fileList.value = await listDir(filePath.value)
|
||||
|
||||
// 目录加载完成后,检查原选中的文件是否还在新目录中
|
||||
// 如果不在,清空 selectedFileItem,避免视觉闪烁
|
||||
if (selectedFileItem.value) {
|
||||
const stillExists = fileList.value.some(f => f.path === selectedFileItem.value.path)
|
||||
if (!stillExists) {
|
||||
selectedFileItem.value = null
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFilePath.value) {
|
||||
debugLog('[listDirectory] 目录已切换,保留原文件引用:', selectedFilePath.value)
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error('列出目录失败: ' + error.message)
|
||||
// 发生错误时也清空选择状态
|
||||
selectedFileItem.value = null
|
||||
} finally {
|
||||
fileLoading.value = false
|
||||
}
|
||||
@@ -886,9 +957,9 @@ const selectFile = (path) => {
|
||||
|
||||
if (item.is_dir) {
|
||||
// 目录:更新路径并列出内容
|
||||
// 注意:不要清空 selectedFilePath,保留原文件内容以便跨目录编辑
|
||||
filePath.value = path
|
||||
addToHistory(path)
|
||||
selectedFilePath.value = ''
|
||||
listDirectory()
|
||||
} else {
|
||||
// 文件:路径保持为父目录,保存选中文件完整路径
|
||||
@@ -911,14 +982,62 @@ const readFile = async () => {
|
||||
addToHistory(filePath.value)
|
||||
}
|
||||
|
||||
// 重置所有预览状态
|
||||
isImageFile.value = false
|
||||
isVideoFile.value = false
|
||||
isAudioFile.value = false
|
||||
isPdfFile.value = false
|
||||
// 延迟状态重置,避免不必要的重新渲染
|
||||
// 只在确实需要读取文件内容时才重置
|
||||
|
||||
const ext = fileToRead.split('.').pop()?.toLowerCase() || ''
|
||||
|
||||
// ========== 优化:大文件和无扩展名文件的智能检测 ==========
|
||||
// 获取文件信息(缓存以避免重复查找)
|
||||
const file = fileList.value.find(f => f.path === fileToRead)
|
||||
|
||||
// 快速路径:无扩展名大文件(>=100KB),直接判定为二进制(极速)
|
||||
if (!ext && file && file.size >= 100 * 1024) {
|
||||
debugLog('[readFile] 快速路径:无扩展名大文件,直接判定为二进制')
|
||||
// 只设置必要的状态,避免触发不必要的渲染
|
||||
isBinaryFile.value = true
|
||||
fileContent.value = getBinaryFileInfo(fileToRead, '', file)
|
||||
return
|
||||
}
|
||||
|
||||
// 情况2:无扩展名小文件(<100KB),快速检测
|
||||
|
||||
// 情况2:无扩展名小文件(<100KB),快速检测
|
||||
if (!ext) {
|
||||
debugLog('[readFile] 无扩展名小文件,快速检测:', fileToRead, '大小:', file?.size)
|
||||
const isBinary = await quickCheckBinarySample(fileToRead)
|
||||
if (isBinary) {
|
||||
const info = getBinaryFileInfo(fileToRead, '', file) // 同步调用
|
||||
isBinaryFile.value = true
|
||||
fileContent.value = info
|
||||
return
|
||||
}
|
||||
// 不是二进制,继续读取完整内容
|
||||
}
|
||||
|
||||
// 情况3:大文件(>1MB)+ 已知二进制扩展名,直接判定
|
||||
if (file && file.size > 1024 * 1024) {
|
||||
const knownBinaryTypes = ['exe', 'dll', 'so', 'bin', 'zip', 'rar', '7z', 'tar', 'gz', 'iso', 'img', 'dmg', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
|
||||
if (knownBinaryTypes.includes(ext)) {
|
||||
debugLog('[readFile] 已知二进制类型(大文件):', fileToRead)
|
||||
const info = getBinaryFileInfo(fileToRead, ext, file) // 同步调用
|
||||
isBinaryFile.value = true
|
||||
fileContent.value = info
|
||||
return
|
||||
}
|
||||
|
||||
// 情况4:其他大文件,快速检测(只读前100字节)
|
||||
debugLog('[readFile] 大文件,快速检测:', fileToRead, '大小:', file.size)
|
||||
const isBinary = await quickCheckBinarySample(fileToRead)
|
||||
if (isBinary) {
|
||||
const info = getBinaryFileInfo(fileToRead, ext, file) // 同步调用
|
||||
isBinaryFile.value = true
|
||||
fileContent.value = info
|
||||
return
|
||||
}
|
||||
// 不是二进制,继续读取完整内容
|
||||
}
|
||||
|
||||
// 图片文件
|
||||
if (FILE_EXTENSIONS.IMAGE.includes(ext)) {
|
||||
await previewImage(fileToRead)
|
||||
@@ -999,12 +1118,139 @@ const readFile = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Windows 快捷方式文件 - 显示二进制文件信息
|
||||
if (ext === 'lnk') {
|
||||
showBinaryFileInfo(ext, fileToRead)
|
||||
return
|
||||
}
|
||||
|
||||
// 其他文件直接读取
|
||||
await performFileRead()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二进制文件信息提示(同步函数,极速响应)
|
||||
* @param filePath 文件路径
|
||||
* @param ext 文件扩展名
|
||||
* @param fileInfo 文件信息(从列表中获取)
|
||||
* @returns 格式化的提示信息
|
||||
*/
|
||||
const getBinaryFileInfo = (filePath, ext, fileInfo) => {
|
||||
const fileName = getFileName(filePath)
|
||||
const fileSize = fileInfo?.size ? formatBytes(fileInfo.size) : '未知'
|
||||
const modifiedTime = fileInfo?.modified_time || '未知'
|
||||
|
||||
const fileTypeDescriptions = {
|
||||
'exe': '可执行文件 (EXE)',
|
||||
'dll': '动态链接库 (DLL)',
|
||||
'so': '共享库 (SO)',
|
||||
'dylib': '动态库 (DYLIB)',
|
||||
'bin': '二进制文件 (BIN)',
|
||||
'dat': '数据文件 (DAT)',
|
||||
'db': '数据库文件 (DB)',
|
||||
'sqlite': 'SQLite 数据库',
|
||||
'zip': '压缩文件 (ZIP)',
|
||||
'rar': '压缩文件 (RAR)',
|
||||
'7z': '压缩文件 (7Z)',
|
||||
'tar': '归档文件 (TAR)',
|
||||
'gz': '压缩文件 (GZ)',
|
||||
'pdf': 'PDF 文档',
|
||||
'doc': 'Word 文档 (DOC)',
|
||||
'docx': 'Word 文档 (DOCX)',
|
||||
'xls': 'Excel 表格 (XLS)',
|
||||
'xlsx': 'Excel 表格 (XLSX)',
|
||||
'ppt': 'PowerPoint 演示文稿 (PPT)',
|
||||
'pptx': 'PowerPoint 演示文稿 (PPTX)'
|
||||
}
|
||||
|
||||
const fileTypeDesc = ext ? (fileTypeDescriptions[ext] || `${ext.toUpperCase()} 文件`) : '二进制文件(无扩展名)'
|
||||
const fileSizeBytes = fileInfo?.size ? `(${fileInfo.size.toLocaleString()} 字节)` : ''
|
||||
|
||||
return `================================================================
|
||||
文件信息:${fileTypeDesc}
|
||||
================================================================
|
||||
|
||||
文件名: ${fileName}
|
||||
完整路径: ${filePath}
|
||||
文件大小: ${fileSize} ${fileSizeBytes}
|
||||
修改时间: ${modifiedTime}
|
||||
文件类型: ${fileTypeDesc}
|
||||
|
||||
================================================================
|
||||
ℹ️ 这是二进制文件,不支持文本预览
|
||||
如需查看或编辑,请使用专门的工具
|
||||
|
||||
💡 提示:
|
||||
• 右键菜单 → "使用系统程序打开" 在默认应用中打开
|
||||
• 右键菜单 → "在资源管理器中显示" 查看文件位置
|
||||
================================================================`
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速检测文件样本是否为二进制(只读取前100字节)
|
||||
* @param filePath 文件路径
|
||||
* @returns 是否为二进制文件
|
||||
*/
|
||||
const quickCheckBinarySample = async (filePath) => {
|
||||
try {
|
||||
// 只读取前100字节进行快速检测
|
||||
const sample = await readFileApi(filePath)
|
||||
|
||||
// 检查前100个字符
|
||||
const checkLength = Math.min(sample.length, 100)
|
||||
let binaryCharCount = 0
|
||||
|
||||
for (let i = 0; i < checkLength; i++) {
|
||||
const charCode = sample.charCodeAt(i)
|
||||
// 空字节或其他控制字符(除了常见的换行符、制表符等)
|
||||
if (charCode === 0 || (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13)) {
|
||||
binaryCharCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 如果二进制字符超过10%,认为是二进制文件(使用更宽松的阈值)
|
||||
const binaryRatio = binaryCharCount / checkLength
|
||||
const isBinary = binaryRatio > 0.1
|
||||
|
||||
debugLog(`[quickCheckBinarySample] ${filePath}: 二进制字符比例: ${(binaryRatio * 100).toFixed(1)}%, 判定结果: ${isBinary ? '二进制' : '文本'}`)
|
||||
|
||||
return isBinary
|
||||
} catch (error) {
|
||||
debugWarn('[quickCheckBinarySample] 检测失败:', error)
|
||||
// 检测失败时,保守判定为二进制
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 显示二进制文件信息 ==========
|
||||
|
||||
/**
|
||||
* 计算字符串的显示宽度(中文字符算2个宽度,英文字符算1个宽度)
|
||||
* 注意:emoji 和特殊符号按1个字符宽度计算
|
||||
*/
|
||||
const getDisplayWidth = (str) => {
|
||||
let width = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
// 中文字符、中文标点算2个宽度,emoji和特殊符号算1个宽度
|
||||
if (/[\u4e00-\u9fa5]/.test(str[i])) {
|
||||
width += 2
|
||||
} else {
|
||||
width += 1
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据显示宽度填充字符串
|
||||
*/
|
||||
const padByDisplayWidth = (str, targetWidth) => {
|
||||
const currentWidth = getDisplayWidth(str)
|
||||
const padding = Math.max(0, targetWidth - currentWidth)
|
||||
return str + ' '.repeat(padding)
|
||||
}
|
||||
|
||||
const showBinaryFileInfo = (ext, filePathParam) => {
|
||||
const file = fileList.value.find(f => f.path === (filePathParam || filePath.value))
|
||||
if (!file) return
|
||||
@@ -1025,26 +1271,29 @@ const showBinaryFileInfo = (ext, filePathParam) => {
|
||||
else if (FILE_EXTENSIONS.VIDEO_BROWSER.includes(ext) || FILE_EXTENSIONS.VIDEO_EXTERNAL.includes(ext)) fileType = '视频文件'
|
||||
else if (FILE_EXTENSIONS.AUDIO.includes(ext)) fileType = '音频文件'
|
||||
else if (['exe', 'dll', 'so'].includes(ext)) fileType = '可执行文件'
|
||||
else if (['jar', 'jsa'].includes(ext)) fileType = 'Java归档文件'
|
||||
else if (FILE_EXTENSIONS.ARCHIVE.includes(ext)) fileType = '压缩文件'
|
||||
else if (FILE_EXTENSIONS.DOCUMENT.includes(ext)) fileType = '文档文件'
|
||||
else if (ext === 'lnk') fileType = '快捷方式'
|
||||
|
||||
const displayFilePath = filePathParam || filePath.value
|
||||
|
||||
fileContent.value = `╔════════════════════════════════════════════════════════════╗
|
||||
║ 📄 ${fileType} - ${extDisplay} ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ 📁 文件名: ${file.name.padEnd(40)}║
|
||||
║ 📂 完整路径: ${displayFilePath} ║
|
||||
║ ║
|
||||
║ 📊 大小: ${sizeDisplay.padEnd(10)} (${file.size.toLocaleString()} 字节) ║
|
||||
║ 📅 修改时间: ${file.mod_time} ║
|
||||
║ 🏷️ 类型: ${fileType.padEnd(15)} (${extDisplay}) ║
|
||||
║ ║
|
||||
║ ℹ️ 这是二进制文件,不支持文本预览 ║
|
||||
║ 如需查看或编辑,请使用专门的工具 ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════╝`
|
||||
// ========== 通用格式:键值对 + 分隔线 ==========
|
||||
|
||||
fileContent.value = `${'='.repeat(64)}
|
||||
文件信息:${fileType} (${extDisplay})
|
||||
${'='.repeat(64)}
|
||||
|
||||
文件名: ${file.name}
|
||||
完整路径: ${displayFilePath}
|
||||
文件大小: ${sizeDisplay} (${file.size.toLocaleString()} 字节)
|
||||
修改时间: ${file.mod_time}
|
||||
文件类型: ${fileType} (${extDisplay})
|
||||
|
||||
${'='.repeat(64)}
|
||||
ℹ️ 这是二进制文件,不支持文本预览
|
||||
如需查看或编辑,请使用专门的工具
|
||||
${'='.repeat(64)}`
|
||||
|
||||
// 二进制文件信息已加载,静默无提示
|
||||
}
|
||||
@@ -1330,7 +1579,28 @@ const displayPath = computed(() => {
|
||||
return filePath.value
|
||||
})
|
||||
|
||||
// 获取当前文件名(用于面板标题显示)
|
||||
// 判断当前打开的文件是否在当前目录中(优化性能,减少计算)
|
||||
const isFileInCurrentDirectory = computed(() => {
|
||||
if (!selectedFilePath.value || !filePath.value) return false
|
||||
|
||||
// 提取文件的父目录
|
||||
const lastBackslash = selectedFilePath.value.lastIndexOf('\\')
|
||||
const lastSlash = selectedFilePath.value.lastIndexOf('/')
|
||||
const lastSeparator = Math.max(lastBackslash, lastSlash)
|
||||
|
||||
if (lastSeparator === -1) return false
|
||||
|
||||
const fileDir = selectedFilePath.value.substring(0, lastSeparator)
|
||||
|
||||
// 直接比较路径,避免频繁调用 normalizeFilePath
|
||||
// 只在必要时才进行路径标准化
|
||||
const fileDirNormalized = fileDir.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
const currentPathNormalized = filePath.value.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
|
||||
return fileDirNormalized.toLowerCase() === currentPathNormalized.toLowerCase()
|
||||
})
|
||||
|
||||
// 获取显示的文件路径(用于面板标题显示)
|
||||
const currentFileName = computed(() => {
|
||||
if (isBrowsingZip.value && selectedFilePath.value) {
|
||||
// ZIP 模式:从 zip 内路径中提取文件名
|
||||
@@ -1338,12 +1608,28 @@ const currentFileName = computed(() => {
|
||||
return parts[parts.length - 1] || parts[parts.length - 2] || ''
|
||||
}
|
||||
if (selectedFilePath.value) {
|
||||
// 正常模式:从完整路径中提取文件名
|
||||
return getFileName(selectedFilePath.value)
|
||||
// 正常模式:如果文件在当前目录,只显示文件名;否则显示完整路径
|
||||
// 使用 try-catch 确保任何错误都不会导致整个计算失败
|
||||
try {
|
||||
if (isFileInCurrentDirectory.value) {
|
||||
return getFileName(selectedFilePath.value)
|
||||
} else {
|
||||
// 文件不在当前目录,显示完整路径以便用户清楚知道
|
||||
return selectedFilePath.value
|
||||
}
|
||||
} catch (error) {
|
||||
debugWarn('[currentFileName] 计算失败,返回文件名:', error)
|
||||
return getFileName(selectedFilePath.value)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 获取显示的文件完整路径(用于tooltip)
|
||||
const currentFileFullPath = computed(() => {
|
||||
return selectedFilePath.value || ''
|
||||
})
|
||||
|
||||
// 媒体预览功能
|
||||
|
||||
const previewImage = async (targetPath) => {
|
||||
@@ -1574,6 +1860,17 @@ const previewHtml = async (targetPath) => {
|
||||
const pathToPreview = targetPath || selectedFilePath.value || filePath.value
|
||||
if (!pathToPreview) return
|
||||
|
||||
// ========== 检查文件大小 ==========
|
||||
const file = fileList.value.find(f => f.path === pathToPreview)
|
||||
if (file && file.size) {
|
||||
const maxSize = 5 * 1024 * 1024 // 5MB 限制
|
||||
if (file.size > maxSize) {
|
||||
const fileSize = formatBytes(file.size)
|
||||
showBinaryFileInfo('html', pathToPreview)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 重置所有状态
|
||||
isImageFile.value = false
|
||||
isVideoFile.value = false
|
||||
@@ -1584,7 +1881,9 @@ const previewHtml = async (targetPath) => {
|
||||
isBinaryFile.value = false
|
||||
isEditMode.value = false // 默认预览模式
|
||||
|
||||
fileLoading.value = true
|
||||
// 注意:不设置 fileLoading,因为那是给文件列表用的
|
||||
// 这里是读取文件内容,不应该影响列表的显示
|
||||
|
||||
try {
|
||||
// 读取 HTML 文件内容
|
||||
let content = await readFileApi(pathToPreview)
|
||||
@@ -1704,6 +2003,17 @@ const previewMarkdown = async (targetPath) => {
|
||||
const pathToPreview = targetPath || selectedFilePath.value || filePath.value
|
||||
if (!pathToPreview) return
|
||||
|
||||
// ========== 检查文件大小 ==========
|
||||
const file = fileList.value.find(f => f.path === pathToPreview)
|
||||
if (file && file.size) {
|
||||
const maxSize = 5 * 1024 * 1024 // 5MB 限制
|
||||
if (file.size > maxSize) {
|
||||
const fileSize = formatBytes(file.size)
|
||||
showBinaryFileInfo('md', pathToPreview)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 重置所有状态
|
||||
isImageFile.value = false
|
||||
isVideoFile.value = false
|
||||
@@ -1714,7 +2024,8 @@ const previewMarkdown = async (targetPath) => {
|
||||
isBinaryFile.value = false
|
||||
isEditMode.value = false // 默认预览模式
|
||||
|
||||
fileLoading.value = true
|
||||
// 注意:不设置 fileLoading,因为那是给文件列表用的
|
||||
|
||||
try {
|
||||
// 读取 Markdown 文件内容
|
||||
const content = await readFileApi(pathToPreview)
|
||||
@@ -1793,17 +2104,62 @@ const performFileRead = async () => {
|
||||
isBinaryFile.value = false
|
||||
isEditMode.value = true // 纯文本文件只有编辑模式
|
||||
|
||||
fileLoading.value = true
|
||||
// ========== 检查文件大小(避免卡死)==========
|
||||
const file = fileList.value.find(f => f.path === fileToRead)
|
||||
if (file && file.size) {
|
||||
const maxSize = 5 * 1024 * 1024 // 5MB 限制(CodeMirror 渲染性能考虑)
|
||||
if (file.size > maxSize) {
|
||||
const fileSize = formatBytes(file.size)
|
||||
const ext = fileToRead.split('.').pop()?.toLowerCase() || ''
|
||||
|
||||
// 根据文件类型提供针对性的建议
|
||||
let suggestion = '• VS Code\n• Sublime Text'
|
||||
|
||||
if (ext === 'sql') {
|
||||
suggestion = '• DBeaver(推荐)\n• HeidiSQL\n• Navicat\n• VS Code'
|
||||
} else if (ext === 'json') {
|
||||
suggestion = '• VS Code(带格式化)\n• 在线 JSON 查看器\n• jq 命令行工具'
|
||||
} else if (FILE_EXTENSIONS.CODE.includes(ext)) {
|
||||
suggestion = '• VS Code(推荐)\n• Sublime Text\n• JetBrains IDE'
|
||||
}
|
||||
|
||||
fileContent.value = `╔════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 文件过大 - 无法在编辑器中打开 ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ 📄 文件名: ${file.name.substring(0, 50).padEnd(50)}║
|
||||
║ 📊 文件大小: ${fileSize.padEnd(20)} ║
|
||||
║ 🚫 大小限制: 5 MB ║
|
||||
║ ║
|
||||
║ 该文件过大,当前编辑器无法流畅打开。 ║
|
||||
║ 建议使用以下工具查看和编辑: ║
|
||||
║ ${suggestion.split('\n').join(' ║\n║ ')} ║
|
||||
║ ║
|
||||
║ 💡 提示: ║
|
||||
║ • 右键菜单 → "使用系统程序打开" ║
|
||||
║ • 或将文件拖拽到专用工具中 ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════╝`
|
||||
isBinaryFile.value = true
|
||||
isEditMode.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:不设置 fileLoading,因为那是给文件列表用的
|
||||
|
||||
try {
|
||||
const content = await readFileApi(fileToRead)
|
||||
|
||||
// 文本文件检查大小(提高到2MB,合理的大文件支持)
|
||||
const maxDisplaySize = 2 * 1024 * 1024 // 2MB
|
||||
// 文本文件检查大小
|
||||
const maxDisplaySize = 5 * 1024 * 1024 // 5MB
|
||||
if (content.length > maxDisplaySize) {
|
||||
// 超过 2MB 的文本文件
|
||||
fileContent.value = content.substring(0, maxDisplaySize) + '\n\n... (文件过大,已截断,仅显示前 2MB) ...'
|
||||
// 大文件加载警告改为控制台日志,不打断用户
|
||||
console.warn(`文件过大 (${(content.length / 1024).toFixed(2)} KB),已截断显示`)
|
||||
// 超过 5MB 的文本文件
|
||||
fileContent.value = content.substring(0, maxDisplaySize) + '\n\n' +
|
||||
'... ═════════════════════════════════════════════════════════════\n' +
|
||||
'⚠️ 文件过大,已截断显示(仅显示前 5MB)\n' +
|
||||
'═════════════════════════════════════════════════════════════ ...'
|
||||
console.warn(`文件过大 (${(content.length / 1024 / 1024).toFixed(2)} MB),已截断显示`)
|
||||
} else {
|
||||
fileContent.value = content
|
||||
}
|
||||
@@ -1864,6 +2220,7 @@ const handleSaveContent = async () => {
|
||||
|
||||
// 保存到文件
|
||||
const fileName = targetPath.split(/[/\\]/).pop()
|
||||
|
||||
await saveToFile(targetPath, fileName, false)
|
||||
}
|
||||
|
||||
@@ -1969,6 +2326,27 @@ const showManualSaveDialog = (isShortcut) => {
|
||||
* 保存内容到指定文件
|
||||
*/
|
||||
const saveToFile = async (targetPath, fileName, isShortcut) => {
|
||||
// ========== 安全校验 ==========
|
||||
|
||||
// 验证文件名
|
||||
const validation = validateFileName(fileName)
|
||||
if (!validation.valid) {
|
||||
Message.error(validation.error)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证路径不为空
|
||||
if (!targetPath || targetPath.trim() === '') {
|
||||
Message.error('保存路径为空')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证内容不为空
|
||||
if (!fileContent.value || fileContent.value.trim() === '') {
|
||||
Message.warning('没有内容可保存')
|
||||
return
|
||||
}
|
||||
|
||||
// 设置保存状态
|
||||
isSaving.value = true
|
||||
isShortcutSave.value = isShortcut
|
||||
@@ -2180,6 +2558,154 @@ const handleOpenWithSystem = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名右键选中的文件或目录(启动原地编辑模式)
|
||||
*/
|
||||
const handleRenameSelectedFile = async () => {
|
||||
if (!selectedContextFile.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldPath = selectedContextFile.value.path
|
||||
const oldName = selectedContextFile.value.name
|
||||
|
||||
// 隐藏右键菜单
|
||||
hideContextMenu()
|
||||
|
||||
// 设置编辑状态
|
||||
editingFilePath.value = oldPath
|
||||
editingFileName.value = oldName
|
||||
|
||||
// 自动聚焦并选中文件名(不包括扩展名)
|
||||
nextTick(() => {
|
||||
if (editingInputRef.value && editingInputRef.value.$el) {
|
||||
const input = editingInputRef.value.$el.querySelector('input')
|
||||
if (input) {
|
||||
input.focus()
|
||||
// 选中文件名(不包括扩展名)
|
||||
const lastDotIndex = oldName.lastIndexOf('.')
|
||||
if (lastDotIndex > 0) {
|
||||
input.setSelectionRange(0, lastDotIndex)
|
||||
} else {
|
||||
input.select()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击文件项处理(选中文件)
|
||||
* 优化:对于大文件或无扩展名文件,先加载内容再设置选中状态,避免列表闪烁
|
||||
*/
|
||||
const handleFileClick = (item) => {
|
||||
const ext = item.path.split('.').pop()?.toLowerCase() || ''
|
||||
const isLargeBinaryCandidate = !ext || item.size > 1024 * 1024
|
||||
|
||||
if (isLargeBinaryCandidate) {
|
||||
// 先不设置选中状态,避免列表重新渲染
|
||||
// 等文件加载完成后再设置(通过 nextTick)
|
||||
selectFile(item.path)
|
||||
nextTick(() => {
|
||||
selectedFileItem.value = item
|
||||
})
|
||||
} else {
|
||||
// 普通文件,正常流程
|
||||
selectedFileItem.value = item
|
||||
selectFile(item.path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 双击文件项处理
|
||||
*/
|
||||
const handleFileDoubleClick = (item) => {
|
||||
// 如果是文件夹,则进入文件夹
|
||||
if (item.is_dir) {
|
||||
navigateToPath(item.path)
|
||||
} else {
|
||||
// 如果是文件,打开查看
|
||||
selectFile(item.path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存编辑的文件名
|
||||
*/
|
||||
const saveEditingFileName = async () => {
|
||||
if (!editingFilePath.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldPath = editingFilePath.value
|
||||
const oldName = fileList.value.find(f => f.path === oldPath)?.name || ''
|
||||
const newName = editingFileName.value.trim()
|
||||
|
||||
// 清空编辑状态
|
||||
editingFilePath.value = ''
|
||||
editingFileName.value = ''
|
||||
|
||||
// 验证
|
||||
if (!newName) {
|
||||
Message.warning('文件名不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果名称没有变化,直接返回
|
||||
if (newName === oldName) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件名
|
||||
const invalidChars = /[<>:"/\\|?*]/g
|
||||
if (invalidChars.test(newName)) {
|
||||
Message.error('文件名包含非法字符:<>:"/\\|?*')
|
||||
return
|
||||
}
|
||||
|
||||
fileLoading.value = true
|
||||
try {
|
||||
// 构造新路径
|
||||
const dirPath = oldPath.substring(0, oldPath.lastIndexOf(oldPath.includes('\\') ? '\\' : '/'))
|
||||
const newPath = dirPath + (dirPath.endsWith('\\') || dirPath.endsWith('/') ? '' : (oldPath.includes('\\') ? '\\' : '/')) + newName
|
||||
|
||||
// 调用重命名 API
|
||||
if (!window.go || !window.go.main || !window.go.main.App || !window.go.main.App.RenamePath) {
|
||||
throw new Error('Go 后端未就绪,请确保应用已启动')
|
||||
}
|
||||
|
||||
await window.go.main.App.RenamePath({
|
||||
oldPath: oldPath,
|
||||
newPath: newPath
|
||||
})
|
||||
|
||||
Message.success('重命名成功')
|
||||
|
||||
// 如果重命名的是当前选中的文件,更新选中路径
|
||||
if (selectedFilePath.value === oldPath) {
|
||||
selectedFilePath.value = newPath
|
||||
}
|
||||
|
||||
// 刷新文件列表
|
||||
await listDirectory()
|
||||
} catch (error) {
|
||||
Message.error(`重命名失败: ${error.message || error}`)
|
||||
// 失败时恢复编辑状态
|
||||
editingFilePath.value = oldPath
|
||||
editingFileName.value = oldName
|
||||
} finally {
|
||||
fileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消编辑文件名
|
||||
*/
|
||||
const cancelEditingFileName = () => {
|
||||
editingFilePath.value = ''
|
||||
editingFileName.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除右键选中的文件
|
||||
*/
|
||||
@@ -2492,9 +3018,9 @@ const openFavoriteFile = (path) => {
|
||||
|
||||
if (fav && fav.is_dir) {
|
||||
// 目录:列出内容
|
||||
// 注意:不要清空 selectedFilePath,保留原文件内容以便跨目录编辑
|
||||
filePath.value = path
|
||||
addToHistory(path)
|
||||
selectedFilePath.value = '' // 清空文件选择
|
||||
listDirectory()
|
||||
} else {
|
||||
// 文件:设置选中文件路径并读取
|
||||
@@ -2743,17 +3269,39 @@ const handleKeyDown = (e) => {
|
||||
handleCreateDir()
|
||||
}
|
||||
|
||||
// Ctrl+← 后退到上一个目录
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowLeft') {
|
||||
// Alt+← 后退到上一个目录
|
||||
if (e.altKey && e.key === 'ArrowLeft') {
|
||||
e.preventDefault() // 阻止浏览器默认行为
|
||||
navigateBack()
|
||||
}
|
||||
|
||||
// Ctrl+→ 前进到下一个目录
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowRight') {
|
||||
// Alt+→ 前进到下一个目录
|
||||
if (e.altKey && e.key === 'ArrowRight') {
|
||||
e.preventDefault() // 阻止浏览器默认行为
|
||||
navigateForward()
|
||||
}
|
||||
|
||||
// F2 重命名选中的文件或目录
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
// 优先使用右键选中的文件,否则使用当前选中的文件项
|
||||
const fileToRename = selectedContextFile.value || selectedFileItem.value
|
||||
if (fileToRename) {
|
||||
selectedContextFile.value = fileToRename // 设置右键选中的文件,以便复用 handleRenameSelectedFile
|
||||
handleRenameSelectedFile()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete 删除选中的文件或目录
|
||||
if (e.key === 'Delete') {
|
||||
e.preventDefault()
|
||||
// 优先使用右键选中的文件,否则使用当前选中的文件项
|
||||
const fileToDelete = selectedContextFile.value || selectedFileItem.value
|
||||
if (fileToDelete) {
|
||||
selectedContextFile.value = fileToDelete // 设置右键选中的文件,以便复用 handleDeleteSelectedFile
|
||||
handleDeleteSelectedFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -2996,7 +3544,22 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
max-width: 500px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.panel-filename.file-outside-dir {
|
||||
color: rgb(var(--warning-6));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-location-hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.file-list-wrapper {
|
||||
@@ -3023,6 +3586,11 @@ onUnmounted(() => {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.file-item-selected {
|
||||
background: var(--color-fill-3) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-item-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -3044,6 +3612,18 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name-edit-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name-edit-input :deep(.arco-input) {
|
||||
font-size: 13px;
|
||||
padding: 0 8px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.file-item-size {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
@@ -3136,8 +3716,8 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ========== HTML 预览 ========== */
|
||||
@@ -3165,7 +3745,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.html-preview-wrapper:hover .preview-mode-switch,
|
||||
.markdown-preview-wrapper:hover .preview-mode-switch {
|
||||
.markdown-preview-wrapper:hover .preview-mode-switch,
|
||||
.text-editor-wrapper:hover .preview-mode-switch {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -3368,6 +3949,11 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* PDF 预览从顶部开始,不居中 */
|
||||
.media-preview-pdf {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 600px;
|
||||
@@ -3386,7 +3972,8 @@ onUnmounted(() => {
|
||||
|
||||
.preview-pdf {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user