Private
Public Access
1
0
Files
u-desk/web/src/components/FileSystem.vue
绝尘 eb2cbad17b 优化:代码质量提升,修复重复逻辑和语法高亮支持
- 简化计算属性,删除重复代码
- 优化文件扩展名获取逻辑
- 新增文件工具函数库 fileHelpers.js
- 增强 CodeEditor 语法高亮(支持 30+ 语言)
- 修复 Office 文档文件服务器访问权限
- 添加特殊文件名支持(Dockerfile、Makefile 等)
2026-01-30 02:29:51 +08:00

4242 lines
121 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="file-system-container">
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<!-- 路径输入 -->
<div class="path-input-wrapper">
<!-- ZIP 浏览模式显示 ZIP 路径和退出按钮 -->
<div v-if="isBrowsingZip" class="zip-breadcrumb">
<span class="zip-path-text">{{ displayPath }}</span>
<a-button size="small" type="outline" @click="exitZipMode">
<template #icon><icon-close /></template>
退出 ZIP
</a-button>
</div>
<!-- 正常模式路径输入 -->
<a-auto-complete
v-else
v-model="filePath"
:data="pathHistory"
placeholder="输入路径 (如: C:\Users)"
class="path-input"
@select="onPathSelect"
@pressEnter="onPathEnter"
/>
</div>
</div>
<div class="toolbar-right">
<!-- 快捷路径下拉 -->
<a-dropdown v-if="!isBrowsingZip">
<a-button size="small">
<template #icon>
<icon-forward />
</template>
快捷访问
</a-button>
<template #content>
<a-doption v-for="shortcut in commonPaths" :key="shortcut.path" @click="goToPath(shortcut.path)">
<template #icon>{{ shortcut.name.split(' ')[0] }}</template>
{{ shortcut.name.substring(2) }}
</a-doption>
</template>
</a-dropdown>
<!-- 历史记录下拉 -->
<a-dropdown>
<a-button size="small">
<template #icon>
<icon-history />
</template>
历史
</a-button>
<template #content>
<a-doption v-for="path in pathHistory.slice(0, 10)" :key="path" @click="goToPath(path)">
{{ path }}
</a-doption>
<a-doption v-if="pathHistory.length === 0" disabled>暂无历史</a-doption>
</template>
</a-dropdown>
<!-- 刷新按钮 -->
<a-button type="primary" size="small" @click="listDirectory" :loading="fileLoading">
<template #icon>
<icon-refresh />
</template>
刷新
</a-button>
<!-- 切换侧边栏 -->
<a-button
size="small"
:type="showSidebar ? 'primary' : 'text'"
@click="showSidebar = !showSidebar"
>
<template #icon>
<icon-menu />
</template>
</a-button>
</div>
</div>
<!-- 主内容区 -->
<div class="main-content">
<!-- 左侧收藏夹侧边栏 -->
<transition name="slide">
<div v-show="showSidebar" class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title"> 收藏夹</span>
<span class="sidebar-count">{{ favoriteFiles.length }}</span>
</div>
<div class="sidebar-content">
<div
v-for="(fav, index) in favoriteFiles"
:key="fav.path"
class="sidebar-item"
:class="{
'sidebar-item-dragging': draggingState.isDragging && draggingState.draggedIndex === index,
'sidebar-item-drag-over': draggingState.isDragging && draggingState.draggedIndex !== index
}"
:draggable="draggingState.isDragging && draggingState.draggedIndex === index"
@click="openFavoriteFile(fav.path)"
@mousedown="onLongPressStart($event, index)"
@mouseup="onLongPressCancel"
@mouseleave="onLongPressCancel"
@touchstart="onLongPressStart($event, index)"
@touchend="onLongPressCancel"
@touchcancel="onLongPressCancel"
@dragstart="onDragStart($event, index)"
@dragover="onDragOver($event)"
@drop="onDrop($event, index)"
@dragend="onDragEnd"
>
<span class="sidebar-item-icon">{{ fav.is_dir ? '📁' : '📄' }}</span>
<span class="sidebar-item-name" :title="fav.name">{{ fav.name }}</span>
<a-button
type="text"
size="mini"
@click.stop="removeFavorite(fav.path)"
class="sidebar-item-remove"
>
<template #icon>
<icon-close />
</template>
</a-button>
</div>
<div v-if="favoriteFiles.length === 0" class="sidebar-empty">
<icon-star />
<span>暂无收藏</span>
<span class="sidebar-hint">点击文件列表中的星标收藏</span>
</div>
</div>
</div>
</transition>
<!-- 文件列表和编辑器区域 -->
<div class="file-workspace">
<!-- 文件列表面板 -->
<div class="file-list-panel" :style="{ width: panelWidth.left + '%' }">
<div class="panel-header">
<span class="panel-title">📋 文件列表</span>
<span class="panel-count">{{ fileList.length }} </span>
</div>
<div
class="file-list-wrapper"
@contextmenu.prevent="handleFileListContextMenu"
>
<a-list
:data="fileList"
:loading="fileLoading"
:bordered="false"
:pagination="false"
class="compact-list"
>
<template #item="{ item }">
<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>
<!-- 编辑状态 -->
<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)"
class="file-item-fav"
>
<template #icon>
<icon-star-fill v-if="isFavorite(item.path)" :style="{ color: '#ffcd00' }" />
<icon-star v-else />
</template>
</a-button>
</div>
</template>
</a-list>
<div v-if="fileList.length === 0 && !fileLoading" class="empty-state">
<span style="font-size: 32px">📭</span>
<span>此文件夹为空</span>
</div>
</div>
</div>
<!-- 分隔条 -->
<div class="resizer" @mousedown="startResizeHorizontal"></div>
<!-- 文件内容编辑器面板 -->
<div class="file-editor-panel" :style="{ width: panelWidth.right + '%' }">
<div class="panel-header">
<span class="panel-title">
<template v-if="isImageView">🖼 图片预览</template>
<template v-else-if="isVideoView">🎬 视频预览</template>
<template v-else-if="isAudioView">🎵 音频预览</template>
<template v-else-if="isPdfFile">📕 PDF 预览</template>
<template v-else-if="isHtmlFile">🌐 HTML 预览</template>
<template v-else-if="isMarkdownFile">📝 Markdown 预览</template>
<template v-else>📝 文件内容</template>
</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">
<!-- 图片预览 -->
<div v-if="isImageView" class="media-preview">
<img
:src="previewUrl"
class="preview-image"
@load="onImageLoad"
@error="onImageError"
alt="预览"
/>
<div v-if="imageLoading" class="media-loading">
<a-spin />
</div>
<div class="media-meta">
<span class="file-name">{{ getFileName(filePath.value) }}</span>
<span v-if="currentImageDimensions" class="image-dimensions">{{ currentImageDimensions }}</span>
</div>
</div>
<!-- 视频预览 -->
<div v-else-if="isVideoView" class="media-preview">
<video :src="previewUrl" controls class="preview-video"></video>
<div class="media-meta">
<a-tag color="arcoblue">🎬 视频</a-tag>
</div>
</div>
<!-- 音频预览 -->
<div v-else-if="isAudioView" class="media-preview">
<audio :src="previewUrl" controls class="preview-audio"></audio>
<div class="media-meta">
<a-tag color="green">🎵 音频</a-tag>
</div>
</div>
<!-- PDF 预览 -->
<div v-else-if="isPdfFile" class="media-preview media-preview-pdf">
<iframe :src="previewUrl" class="preview-pdf"></iframe>
<div class="media-meta">
<a-tag color="orangered">📕 PDF</a-tag>
</div>
</div>
<!-- HTML 预览/编辑 -->
<div v-else-if="isHtmlFile" class="html-preview-wrapper">
<!-- 编辑模式/预览模式切换按钮 -->
<div class="preview-mode-switch">
<!-- 保存按钮 -->
<a-tooltip v-if="canSaveFile" position="left" content="保存 (Ctrl+S)">
<a-button
type="primary"
size="small"
@click="handleSaveContent"
>
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
<!-- 编辑/预览切换 -->
<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>
<!-- 预览模式 -->
<iframe
v-if="!isEditMode"
class="html-preview-content"
:srcdoc="rendered"
></iframe>
<!-- 编辑模式 -->
<div v-else class="html-edit-wrapper" :style="{ height: fileContentHeight + 'px' }">
<CodeEditor
v-model="fileContent"
:file-extension="currentFileExtension"
class="code-editor"
/>
<!-- 调整高度的手柄 -->
<div class="resize-handle-v" @mousedown="startResize" title="拖拽调整高度">
<div class="resize-dots"></div>
</div>
</div>
</div>
<!-- Markdown 预览/编辑 -->
<div v-else-if="isMarkdownFile" class="markdown-preview-wrapper">
<!-- 编辑模式/预览模式切换按钮 -->
<div class="preview-mode-switch">
<!-- 保存按钮 -->
<a-tooltip v-if="canSaveFile" position="left" content="保存 (Ctrl+S)">
<a-button
type="primary"
size="small"
@click="handleSaveContent"
>
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
<!-- 编辑/预览切换 -->
<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>
<!-- 预览模式 -->
<div v-if="!isEditMode" class="markdown-preview-content markdown-content" v-html="rendered"></div>
<!-- 编辑模式 -->
<div v-else class="markdown-edit-wrapper" :style="{ height: fileContentHeight + 'px' }">
<CodeEditor
v-model="fileContent"
:file-extension="currentFileExtension"
class="code-editor"
/>
<!-- 调整高度的手柄 -->
<div class="resize-handle-v" @mousedown="startResize" title="拖拽调整高度">
<div class="resize-dots"></div>
</div>
</div>
</div>
<!-- 文本编辑器带代码高亮 -->
<div v-else class="text-editor-wrapper" :style="{ height: fileContentHeight + 'px' }">
<!-- 编辑模式/预览模式切换按钮 -->
<div class="preview-mode-switch">
<!-- 保存按钮 -->
<a-tooltip v-if="canSaveFile" position="left" content="保存 (Ctrl+S)">
<a-button
type="primary"
size="small"
@click="handleSaveContent"
>
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
<!-- 编辑/预览切换 -->
<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"
class="code-editor"
/>
<!-- 调整高度的手柄 -->
<div class="resize-handle-v" @mousedown="startResize" title="拖拽调整高度">
<div class="resize-dots"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenuVisible"
class="context-menu"
:style="{ left: contextMenuPosition.x + 'px', top: contextMenuPosition.y + 'px' }"
@click.stop
>
<div v-if="contextMenuTarget === 'blank'" class="context-menu-item" @click="handleCreateFile">
<span class="context-menu-icon">📄</span>
<span>新建文件</span>
<span class="context-menu-shortcut">Ctrl+N</span>
</div>
<div v-if="contextMenuTarget === 'blank'" 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 v-if="contextMenuTarget === 'file'" class="context-menu-divider"></div>
<div v-if="contextMenuTarget === 'file' && selectedContextFile && !selectedContextFile.is_dir && isOfficeFile(selectedContextFile.name)" class="context-menu-item" @click="handleOpenWithSystem">
<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>
<span class="context-menu-shortcut">Del</span>
</div>
</div>
<!-- 输入对话框 -->
<a-modal
v-model:visible="inputDialogVisible"
:title="inputDialogTitle"
:ok-text="inputDialogOkText"
:cancel-text="inputDialogCancelText"
@ok="handleInputDialogConfirm"
@cancel="inputDialogVisible = false"
:mask-closable="false"
>
<a-input
v-model="inputDialogValue"
:placeholder="inputDialogPlaceholder"
@keyup.enter="handleInputDialogConfirm"
ref="inputDialogInputRef"
:max-length="255"
show-word-limit
/>
</a-modal>
</div>
</template>
<script setup>
// 定义组件名称,用于 KeepAlive 缓存
defineOptions({
name: 'FileSystem'
})
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'
import {
IconFolder,
IconForward,
IconStarFill,
IconStar,
IconFile,
IconSave,
IconDelete,
IconEraser,
IconRefresh,
IconMenu,
IconClose,
IconHistory,
IconEye,
IconEdit
} from '@arco-design/web-vue/es/icon'
import {
listDir,
readFile as readFileApi,
writeFile as writeFileApi,
deletePath as deletePathApi,
createFile,
createDir,
listZipContents,
extractFileFromZip,
extractFileFromZipToTemp,
openPath,
getFileServerURL
} from '@/api'
// 导入公共工具函数和常量
import { STORAGE_KEYS, FILE_EXTENSIONS, DEFAULTS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import {
formatBytes,
getFileName,
getFileIcon,
normalizeFilePath
} from '@/utils/fileUtils'
import {
getExt,
isImage,
isVideo,
isAudio,
isVideoAny,
isPdf,
isHtml,
isMarkdown,
isCode,
isArchive,
isExecutable,
isDatabase,
isEditableDoc
} from '@/utils/fileHelpers'
// 导入调试日志工具
import { debugLog, debugWarn, debugError } from '@/utils/debugLog'
// 导入 composables
import { useFileOperations } from '@/composables/useFileOperations'
import { useFavoriteFiles } from '@/composables/useFavoriteFiles'
import { useLocalStorage } from '@/composables/useLocalStorage'
// 导入 Wails runtime
import { BrowserOpenURL } from '@/wailsjs/wailsjs/runtime/runtime'
// ========== 使用 Composables ==========
// 文件操作
const {
filePath,
fileContent,
fileList,
fileLoading,
listDirectory: listDirBase,
readFile: readFileBase,
writeFile,
deleteFile: deleteFileBase,
} = useFileOperations({
onSuccess: (operation, data) => {
debugLog(`${operation} 成功:`, data)
},
onError: (operation, error) => {
console.error(`[FileSystem] ${operation} 失败:`, error)
}
})
// 收藏功能
const {
favoriteFiles,
isFavorite,
toggleFavorite,
removeFavorite,
reorderFavorites,
} = useFavoriteFiles(STORAGE_KEYS.FILESYSTEM.FAVORITE_FILES)
// localStorage管理
const { storedValue: fileContentHeight } = useLocalStorage(
STORAGE_KEYS.FILESYSTEM.FILE_CONTENT_HEIGHT,
DEFAULTS.DEFAULT_CONTENT_HEIGHT
)
const { storedValue: showSidebar } = useLocalStorage(
STORAGE_KEYS.FILESYSTEM.SIDEBAR_VISIBLE,
false
)
const { storedValue: panelWidth } = useLocalStorage(
STORAGE_KEYS.FILESYSTEM.PANEL_WIDTH,
{ left: 50, right: 50 }
)
const { storedValue: pathHistory } = useLocalStorage(
STORAGE_KEYS.FILESYSTEM.PATH_HISTORY,
[]
)
// ========== Zip 文件浏览状态 ==========
const currentZipPath = ref('') // 当前浏览的 zip 文件路径
const currentZipDirectory = ref('') // 当前在 zip 中的目录路径
const isBrowsingZip = ref(false) // 是否正在浏览 zip 文件
const pathBeforeZip = ref('') // 进入 zip 之前的原始路径
const selectedFilePath = ref('') // 当前选中的文件完整路径(用于读取文件)
// ========== 右键菜单状态 ==========
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) // 是否显示输入对话框
const inputDialogTitle = ref('') // 输入对话框标题
const inputDialogOkText = ref('确定') // 确定按钮文本
const inputDialogCancelText = ref('取消') // 取消按钮文本
const inputDialogPlaceholder = ref('') // 输入框占位符
const inputDialogValue = ref('') // 输入框的值
const inputDialogInputRef = ref() // 输入框引用
const inputDialogCallback = ref(null) // 输入对话框确认回调
// ========== 保存状态管理 ==========
const isSaving = ref(false) // 是否正在保存
const isShortcutSave = ref(false) // 是否是快捷键触发
const saveSuccessMessage = ref('') // 保存成功提示消息
const originalContent = ref('') // 原始文件内容(用于检测变更)
// ========== 草稿自动保存 ==========
const DRAFT_STORAGE_KEY = 'filesystem_draft_content' // localStorage 键
/**
* 保存草稿到 localStorage
*/
const saveDraft = () => {
if (fileContent.value && fileContent.value.trim() !== '') {
try {
localStorage.setItem(DRAFT_STORAGE_KEY, fileContent.value)
localStorage.setItem(DRAFT_STORAGE_KEY + '_time', new Date().toISOString())
} catch (error) {
console.warn('[saveDraft] 保存草稿失败:', error)
}
} else {
// 内容为空时,清除草稿
clearDraft()
}
}
/**
* 清除草稿
*/
const clearDraft = () => {
try {
localStorage.removeItem(DRAFT_STORAGE_KEY)
localStorage.removeItem(DRAFT_STORAGE_KEY + '_time')
} catch (error) {
console.warn('[clearDraft] 清除草稿失败:', error)
}
}
/**
* 加载草稿
*/
const loadDraft = () => {
try {
const draft = localStorage.getItem(DRAFT_STORAGE_KEY)
if (draft && draft.trim() !== '') {
const draftTime = localStorage.getItem(DRAFT_STORAGE_KEY + '_time')
if (draftTime) {
const time = new Date(draftTime)
const now = new Date()
const hoursDiff = (now - time) / (1000 * 60 * 60)
// 如果草稿不超过 24 小时,自动恢复
if (hoursDiff < 24) {
fileContent.value = draft
originalContent.value = draft
Message.info({
content: `📝 已恢复草稿内容(${time.toLocaleString()}`,
duration: 2000,
position: 'bottom'
})
} else {
// 草稿过期,清除
clearDraft()
}
}
}
} catch (error) {
console.warn('[loadDraft] 加载草稿失败:', error)
}
}
// 监听内容变化,自动保存草稿
watch(fileContent, () => {
// 只有在草稿模式下(未选择文件)才自动保存到缓存
if (!selectedFilePath.value) {
saveDraft()
}
}, { debounce: 1000 }) // 防抖1秒后才保存
// ========== 媒体预览相关状态 ==========
const previewUrl = ref('')
const fileServerURL = ref('http://localhost:18765') // 本地文件服务器URL
const rendered = ref('') // 渲染后的 HTML/Markdown 内容
const imageLoading = ref(false)
const imageWidth = ref(0)
const imageHeight = ref(0)
const isImageView = ref(false) // 是否显示图片预览
const isVideoView = ref(false) // 是否显示视频预览
const isAudioView = ref(false) // 是否显示音频预览
const isPdfFile = ref(false)
const isHtmlFile = ref(false) // HTML 预览
const isMarkdownFile = ref(false) // Markdown 预览
const isBinaryFile = ref(false) // 是否为二进制文件信息展示
// 从 localStorage 读取上次的编辑模式选择,默认为预览模式
const isEditMode = ref(localStorage.getItem(STORAGE_KEYS.FILESYSTEM.EDIT_MODE) === 'true')
const htmlPreviewUrl = ref('') // HTML 预览的 blob URL
// ========== 系统路径功能FileSystem.vue 特有) ==========
const commonPaths = ref([])
const systemPaths = ref({})
// 加载常用系统路径
const loadCommonPaths = async () => {
try {
// 检查 Wails 是否准备好
if (!window.go || !window.go.main || !window.go.main.App || !window.go.main.App.GetCommonPaths) {
console.warn('Wails API 未就绪,使用默认路径')
commonPaths.value = [
{ name: '💿 C盘', path: 'C:\\' },
{ name: '💿 D盘', path: 'D:\\' }
]
return
}
const paths = await window.go.main.App.GetCommonPaths()
systemPaths.value = paths
const platform = window.navigator.platform
if (platform.includes('Win')) {
// 基础路径
const pathList = [
{ name: '🖥️ 桌面', path: paths.desktop },
{ name: '📁 文档', path: paths.documents },
{ name: '📥 下载', path: paths.downloads },
{ name: '💾 用户目录', path: paths.home }
]
// 动态添加所有盘符(按字母顺序)
const drives = []
for (const key in paths) {
if (key.startsWith('root_')) {
const driveLetter = key.substring(5)
drives.push({
letter: driveLetter,
path: paths[key]
})
}
}
drives.sort((a, b) => a.letter.localeCompare(b.letter))
// 添加盘符到路径列表
drives.forEach(drive => {
pathList.push({
name: `💿 ${drive.letter}`,
path: drive.path
})
})
commonPaths.value = pathList
} else {
commonPaths.value = [
{ name: '🖥️ 桌面', path: paths.desktop },
{ name: '📁 文档', path: paths.documents },
{ name: '📥 下载', path: paths.downloads },
{ name: '🏠 主目录', path: paths.home },
{ name: '📂 根目录', path: '/' }
]
}
} catch (error) {
// 降级方案:使用默认路径
commonPaths.value = [
{ name: '💿 C盘', path: 'C:\\' },
{ name: '💿 D盘', path: 'D:\\' }
]
}
}
// ========== 路径历史记录 ==========
const addToHistory = (path) => {
if (!path || path.trim() === '') return
const index = pathHistory.value.indexOf(path)
if (index > -1) {
pathHistory.value.splice(index, 1)
}
pathHistory.value.unshift(path)
if (pathHistory.value.length > 20) {
pathHistory.value = pathHistory.value.slice(0, 20)
}
}
// ========== 导航历史记录(支持后退/前进) ==========
const navHistory = ref([]) // 导航历史栈
const navIndex = ref(-1) // 当前在历史栈中的位置
const isNavigating = ref(false) // 是否正在导航(防止重复记录)
/**
* 添加路径到导航历史
* @param {string} path - 要添加的路径
*/
const pushNav = (path) => {
if (!path || path.trim() === '') return
if (isNavigating.value) return // 如果正在导航,不重复记录
// 如果当前位置不在历史栈末尾,删除当前位置之后的所有记录
if (navIndex.value < navHistory.value.length - 1) {
navHistory.value = navHistory.value.slice(0, navIndex.value + 1)
}
// 如果新路径与当前路径不同,才添加
const currentPath = navHistory.value[navIndex.value]
if (currentPath !== path) {
navHistory.value.push(path)
navIndex.value = navHistory.value.length - 1
debugLog('导航历史:', navHistory.value, '当前位置:', navIndex.value)
}
}
/**
* 后退到上一个目录
*/
const goBack = async () => {
if (navIndex.value > 0) {
isNavigating.value = true
navIndex.value--
const path = navHistory.value[navIndex.value]
debugLog('后退到:', path, '位置:', navIndex.value)
filePath.value = path
await listDirectory()
isNavigating.value = false
} else {
Message.info('已经是最早的记录了')
}
}
/**
* 前进到下一个目录
*/
const goForward = async () => {
if (navIndex.value < navHistory.value.length - 1) {
isNavigating.value = true
navIndex.value++
const path = navHistory.value[navIndex.value]
debugLog('前进到:', path, '位置:', navIndex.value)
filePath.value = path
await listDirectory()
isNavigating.value = false
} else {
Message.info('已经是最新的记录了')
}
}
/**
* 检查是否可以后退
*/
const canGoBack = computed(() => navIndex.value > 0)
/**
* 检查是否可以前进
*/
const canGoForward = computed(() => navIndex.value < navHistory.value.length - 1)
// ========== 列出目录(重写以添加历史记录) ==========
const listDirectory = async () => {
if (!filePath.value) return
// 如果当前在 ZIP 浏览模式,自动退出
// 但只在明确请求列出普通目录时才退出,避免误触发
if (isBrowsingZip.value && filePath.value !== pathBeforeZip.value) {
debugLog('检测到路径切换,退出 ZIP 模式')
exitZipMode()
}
addToHistory(filePath.value)
pushNav(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
}
}
// ========== 路径操作 ==========
const onPathSelect = (value) => {
if (!value) return
filePath.value = value
listDirectory()
}
const onPathEnter = () => {
if (!filePath.value) return
listDirectory()
}
const goToPath = (path) => {
if (!path) return
// 如果当前在 ZIP 浏览模式,自动退出
if (isBrowsingZip.value) {
debugLog('自动退出 ZIP 模式')
exitZipMode()
}
filePath.value = path
listDirectory()
}
const browseDirectory = () => {
const path = prompt('请输入目录路径(例如: C:\\Users 或 /home/user')
if (path) {
// 如果当前在 ZIP 浏览模式,自动退出
if (isBrowsingZip.value) {
debugLog('自动退出 ZIP 模式')
exitZipMode()
}
filePath.value = path
listDirectory()
}
}
// ========== 文件选择(智能判断文件/目录) ==========
const selectFile = (path) => {
if (!path) return
const item = fileList.value.find(f => f.path === path)
// 如果正在浏览 zip 文件
if (isBrowsingZip.value) {
if (!item) return
if (item.is_dir) {
// 在 zip 中进入子目录
currentZipDirectory.value = path
listZipDirectory()
} else {
// 读取 zip 中的文件
selectedFilePath.value = path
readZipFile(path)
}
return
}
// 正常文件系统浏览
// 如果 fileList 为空或找不到该文件,尝试读取
if (!item) {
// 无法判断文件类型,默认作为文件读取
selectedFilePath.value = path
const parentPath = path.substring(0, path.lastIndexOf('\\')) || path.substring(0, path.lastIndexOf('/'))
filePath.value = parentPath || path
readFile()
return
}
if (item.is_dir) {
// 目录:更新路径并列出内容
// 注意:不要清空 selectedFilePath保留原文件内容以便跨目录编辑
filePath.value = path
addToHistory(path)
listDirectory()
} else {
// 文件:路径保持为父目录,保存选中文件完整路径
const parentPath = path.substring(0, path.lastIndexOf('\\')) || path.substring(0, path.lastIndexOf('/'))
filePath.value = parentPath || path
selectedFilePath.value = path
readFile()
}
}
// ========== 文件读取(重写以支持媒体预览) ==========
const readFile = async () => {
// ========== 1. 准备阶段 ==========
const fileToRead = selectedFilePath.value || filePath.value
if (!fileToRead) return
// 只添加目录路径到历史记录
if (filePath.value && filePath.value !== fileToRead) {
addToHistory(filePath.value)
}
const ext = fileToRead.split('.').pop()?.toLowerCase() || ''
const file = fileList.value.find(f => f.path === fileToRead)
// ========== 2. 配置常量 ==========
// 可预览类型:有专门的预览处理函数
const previewableTypes = [
...FILE_EXTENSIONS.IMAGE,
...FILE_EXTENSIONS.VIDEO_BROWSER,
...FILE_EXTENSIONS.AUDIO,
'pdf', 'html', 'htm', 'md', 'markdown'
]
// 已知二进制类型:直接显示二进制文件信息
const knownBinaryTypes = [
'exe', 'dll', 'so', 'bin',
'zip', 'rar', '7z', 'tar', 'gz', 'iso', 'img', 'dmg',
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'
]
// ========== 3. 快速路径:无扩展名文件 ==========
if (!ext) {
if (file && file.size >= FILE_SIZE_THRESHOLDS.LARGE_FILE) {
// 无扩展名大文件:直接判定为二进制
debugLog('[readFile] 无扩展名大文件,直接判定为二进制')
isBinaryFile.value = true
fileContent.value = getBinaryFileInfo(fileToRead, '', file)
return
} else {
// 无扩展名小文件:快速检测
debugLog('[readFile] 无扩展名小文件,快速检测:', fileToRead, '大小:', file?.size)
const isBinary = await quickCheckBinarySample(fileToRead)
if (isBinary) {
isBinaryFile.value = true
fileContent.value = getBinaryFileInfo(fileToRead, '', file)
return
}
// 不是二进制,继续读取完整内容
}
}
// ========== 4. 大文件智能检测 ==========
if (file && file.size >= FILE_SIZE_THRESHOLDS.LARGE_FILE) {
if (!previewableTypes.includes(ext)) {
// 不是可预览类型,需要进行二进制检测
if (knownBinaryTypes.includes(ext)) {
// 已知二进制类型:直接判定
debugLog('[readFile] 已知二进制类型(大文件):', fileToRead)
isBinaryFile.value = true
fileContent.value = getBinaryFileInfo(fileToRead, ext, file)
return
} else {
// 未知类型:快速检测
debugLog('[readFile] 大文件,快速检测:', fileToRead, '大小:', file.size)
const isBinary = await quickCheckBinarySample(fileToRead)
if (isBinary) {
isBinaryFile.value = true
fileContent.value = getBinaryFileInfo(fileToRead, ext, file)
return
}
// 不是二进制,继续读取完整内容
}
} else {
debugLog('[readFile] 可预览的大文件类型,跳过二进制检测:', fileToRead)
}
}
// ========== 5. 按处理方式分发 ==========
// 5.1 可预览类型(使用专门的预览函数)
if (isImage(fileToRead)) {
await previewImage(fileToRead)
return
}
if (isVideo(fileToRead)) {
await previewVideo(fileToRead)
return
}
if (isAudio(fileToRead)) {
await previewAudio(fileToRead)
return
}
if (isPdf(fileToRead)) {
await previewPdf(fileToRead)
return
}
if (isHtml(fileToRead)) {
await previewHtml(fileToRead)
return
}
if (isMarkdown(fileToRead)) {
await previewMarkdown(fileToRead)
return
}
// 5.2 显示二进制信息的类型
if (FILE_EXTENSIONS.VIDEO_EXTERNAL.includes(ext)) {
showBinaryFileInfo(ext, fileToRead)
return
}
if (isExecutable(fileToRead)) {
showBinaryFileInfo(ext, fileToRead)
return
}
if (isEditableDoc(fileToRead)) {
// 可编辑的文档文件,继续作为文本读取
}
if (isDatabase(fileToRead)) {
showBinaryFileInfo(ext, fileToRead)
return
}
if (ext === 'lnk') {
showBinaryFileInfo(ext, fileToRead)
return
}
// 5.3 压缩文件特殊处理
if (isArchive(fileToRead)) {
if (ext === 'zip') {
// zip 文件:进入 zip 浏览模式
selectedFilePath.value = fileToRead
await enterZipMode()
return
} else {
// 其他压缩文件:显示信息
showBinaryFileInfo(ext, fileToRead)
return
}
}
// 5.4 文本文件(包括 txt 和其他未分类文件)
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
// 重置所有文件类型标志
isImageView.value = false
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
isBinaryFile.value = true
const extDisplay = ext.toUpperCase()
const sizeDisplay = formatBytes(file.size)
// 使用工具函数判断文件类型
const filePath = filePathParam || filePath.value
let fileType = '二进制文件'
if (isImage(filePath)) fileType = '图片文件'
else if (isVideoAny(filePath)) fileType = '视频文件'
else if (isAudio(filePath)) fileType = '音频文件'
else if (['exe', 'dll', 'so'].includes(ext)) fileType = '可执行文件'
else if (['jar', 'jsa'].includes(ext)) fileType = 'Java归档文件'
else if (isArchive(filePath)) fileType = '压缩文件'
else if (FILE_EXTENSIONS.DOCUMENT.includes(getExt(filePath))) fileType = '文档文件'
else if (ext === 'lnk') fileType = '快捷方式'
const displayFilePath = filePathParam || filePath.value
// ========== 通用格式:键值对 + 分隔线 ==========
fileContent.value = `${'='.repeat(64)}
文件信息:${fileType} (${extDisplay})
${'='.repeat(64)}
文件名: ${file.name}
完整路径: ${displayFilePath}
文件大小: ${sizeDisplay} (${file.size.toLocaleString()} 字节)
修改时间: ${file.mod_time}
文件类型: ${fileType} (${extDisplay})
${'='.repeat(64)}
这是二进制文件,不支持文本预览
如需查看或编辑,请使用专门的工具
${'='.repeat(64)}`
// 二进制文件信息已加载,静默无提示
}
// ========== Zip 文件浏览功能 ==========
// 进入 zip 浏览模式
const enterZipMode = async () => {
// 使用选中的 ZIP 文件完整路径
const zipFilePath = selectedFilePath.value
if (!zipFilePath) {
console.error('[enterZipMode] ZIP 文件路径为空')
return
}
debugLog('准备进入 ZIP 模式:', zipFilePath)
try {
// 保存进入 zip 之前的原始路径
pathBeforeZip.value = filePath.value
// 设置 zip 浏览状态
currentZipPath.value = zipFilePath
currentZipDirectory.value = ''
isBrowsingZip.value = true
debugLog('ZIP 状态已设置:', {
currentZipPath: currentZipPath.value,
pathBeforeZip: pathBeforeZip.value,
isBrowsingZip: isBrowsingZip.value
})
// 列出 zip 根目录内容
await listZipDirectory()
// 成功进入 ZIP 模式,静默无提示
} catch (error) {
console.error('[enterZipMode] 进入 ZIP 模式失败:', error)
Message.error('进入 ZIP 模式失败: ' + error.message)
// 失败时重置状态
exitZipMode()
}
}
// 列出 zip 目录内容
const listZipDirectory = async () => {
if (!currentZipPath.value) {
console.error('[listZipDirectory] ZIP 路径为空')
return
}
fileLoading.value = true
try {
debugLog('开始列出 ZIP 内容:', {
zipPath: currentZipPath.value,
currentDir: currentZipDirectory.value
})
// 获取所有 zip 内容
const allFiles = await listZipContents(currentZipPath.value)
debugLog('获取到文件数量:', allFiles.length)
if (!allFiles || !Array.isArray(allFiles)) {
throw new Error('ZIP 内容格式无效')
}
// 如果当前在子目录中,过滤出该目录的文件
let filteredFiles = allFiles
if (currentZipDirectory.value) {
debugLog('过滤子目录:', currentZipDirectory.value)
// 规范化当前目录路径(移除尾部斜杠)
const normalizedDir = currentZipDirectory.value.replace(/\\/g, '/').replace(/\/+$/, '')
// 过滤出当前目录的直接子文件和子目录
filteredFiles = allFiles.filter(f => {
if (!f.path) return false
// 规范化路径(统一使用 /
const normalizedPath = f.path.replace(/\\/g, '/')
// 获取文件所在目录
const fileDir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'))
debugLog('检查文件:', normalizedPath, '所在目录:', fileDir, '目标目录:', normalizedDir)
return fileDir === normalizedDir
})
debugLog('过滤后文件数量:', filteredFiles.length)
// 为子目录中的文件,只显示文件名部分
filteredFiles = filteredFiles.map(f => {
const normalizedPath = f.path.replace(/\\/g, '/')
const name = normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1) || f.name
return {
...f,
name: name,
path: f.path // 保持原始路径用于点击
}
})
}
fileList.value = filteredFiles
debugLog('最终文件列表:', filteredFiles.length, '项')
} catch (error) {
console.error('[listZipDirectory] 列出 ZIP 内容失败:', error)
const errorMsg = error?.message || error?.error || (typeof error === 'string' ? error : error?.toString?.()) || '未知错误'
console.error('[listZipDirectory] 错误详情:', errorMsg)
Message.error('列出 ZIP 内容失败: ' + errorMsg)
} finally {
fileLoading.value = false
}
}
// 读取 zip 中的文件
const readZipFile = async (zipFilePath) => {
if (!currentZipPath.value || !zipFilePath) {
console.error('[readZipFile] 参数缺失:', { currentZipPath: currentZipPath.value, zipFilePath })
return
}
addToHistory(`${currentZipPath.value} :: ${zipFilePath}`)
// 重置所有预览状态
isImageView.value = false
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
isBinaryFile.value = false
// 获取文件扩展名
const ext = zipFilePath.split('.').pop()?.toLowerCase() || ''
debugLog('文件路径:', zipFilePath)
debugLog('文件扩展名:', ext)
fileLoading.value = true
try {
// 根据文件类型选择处理方式
if (isImage(zipFilePath)) {
console.log('[readZipFile] 检测到图片文件,提取到临时目录')
// 图片文件:提取到临时目录,然后使用文件路径显示
const tempFilePath = await extractFileFromZipToTemp(currentZipPath.value, zipFilePath)
console.log('[readZipFile] 提取成功,临时文件路径:', tempFilePath)
// 使用临时文件路径直接显示图片(通过文件服务器)
selectedFilePath.value = tempFilePath
previewUrl.value = `${fileServerURL.value}/localfs/${normalizeFilePath(tempFilePath, true)}`
isImageView.value = true
// 图片加载成功,静默无提示
} else if (isHtml(zipFilePath) || isMarkdown(zipFilePath)) {
console.log('[readZipFile] 检测到 HTML/Markdown 文件,处理图片引用')
let content = await extractFileFromZip(currentZipPath.value, zipFilePath)
const htmlDir = zipFilePath.substring(0, zipFilePath.lastIndexOf('/')) || ''
// 查找并替换图片引用
const imgRegex = /<img([^>]*)(src=["']([^"']+)["'])([^>]*)>/gi
const images = []
let match
while ((match = imgRegex.exec(content)) !== null) {
const src = match[3]
if (!src.startsWith('data:') && !src.startsWith('http')) {
images.push({ src, fullMatch: match[0] })
}
}
console.log('[readZipFile] 找到图片引用:', images.length, '个')
for (const img of images) {
try {
let imgPath = img.src.startsWith('/') ? img.src : (htmlDir ? `${htmlDir}/${img.src}` : img.src)
imgPath = imgPath.replace(/^\.\//, '').replace(/\/\.\//g, '/').replace(/\/$/, '')
const tempImgPath = await extractFileFromZipToTemp(currentZipPath.value, imgPath)
const imgUrl = `${fileServerURL.value}/localfs/${normalizeFilePath(tempImgPath, true)}`
content = content.replace(img.fullMatch, img.fullMatch.replace(img.src, imgUrl))
} catch (err) {
console.error('[readZipFile] 提取图片失败:', img.src, err)
}
}
// 显示内容
const maxSize = 2 * 1024 * 1024
if (content.length > maxSize) {
content = content.substring(0, maxSize) + '\n\n... (文件过大,已截断) ...'
console.warn(`ZIP文件过大 (${(content.length / 1024).toFixed(2)} KB)`)
}
if (isHtml(zipFilePath)) {
isHtmlFile.value = true
isEditMode.value = false
fileContent.value = content
rendered.value = content
} else {
isMarkdownFile.value = true
fileContent.value = content
}
} else {
console.log('[readZipFile] 不是图片文件,读取文本内容')
const content = await extractFileFromZip(currentZipPath.value, zipFilePath)
const maxSize = 2 * 1024 * 1024
fileContent.value = content.length > maxSize
? content.substring(0, maxSize) + '\n\n... (文件过大,已截断) ...'
: content
}
} catch (error) {
console.error('[readZipFile] 读取失败:', error)
const errorMsg = error?.message || error?.error || error?.toString() || '未知错误'
Message.error('读取 ZIP 文件失败: ' + errorMsg)
} finally {
fileLoading.value = false
}
}
// 获取 MIME 类型
const getMimeType = (ext) => {
const mimeTypes = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'webp': 'image/webp',
'bmp': 'image/bmp',
'ico': 'image/x-icon'
}
return mimeTypes[ext] || 'application/octet-stream'
}
// 退出 zip 浏览模式
const exitZipMode = () => {
isBrowsingZip.value = false
currentZipPath.value = ''
currentZipDirectory.value = ''
// 恢复到进入 zip 之前的路径
if (pathBeforeZip.value) {
filePath.value = pathBeforeZip.value
listDirectory()
}
}
// 获取当前显示的路径(用于界面显示)
const displayPath = computed(() => {
if (isBrowsingZip.value) {
if (currentZipDirectory.value) {
return `📦 ${currentZipPath.value} [${currentZipDirectory.value}]`
}
return `📦 ${currentZipPath.value}`
}
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 内路径中提取文件名
const parts = selectedFilePath.value.split('/')
return parts[parts.length - 1] || parts[parts.length - 2] || ''
}
if (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) => {
const pathToPreview = targetPath || selectedFilePath.value || filePath.value
if (!pathToPreview) return
isImageView.value = true
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
isBinaryFile.value = false
try {
previewUrl.value = `${fileServerURL.value}/localfs/${normalizeFilePath(pathToPreview, true)}`
} catch (error) {
Message.error('图片加载失败: ' + error.message)
isImageView.value = false
imageLoading.value = false
}
}
const previewMedia = (mediaType, targetPath) => {
const pathToPreview = targetPath || selectedFilePath.value || filePath.value
if (!pathToPreview) return
isImageView.value = false
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
switch (mediaType) {
case 'video':
isVideoView.value = true
break
case 'audio':
isAudioView.value = true
break
case 'pdf':
isPdfFile.value = true
break
}
imageLoading.value = false
previewUrl.value = `${fileServerURL.value}/localfs/${normalizeFilePath(pathToPreview, true)}`
}
const previewVideo = (targetPath) => previewMedia('video', targetPath)
const previewAudio = (targetPath) => previewMedia('audio', targetPath)
const previewPdf = (targetPath) => previewMedia('pdf', targetPath)
// 判断是否为 Office 文档
const isOfficeFile = (fileName) => {
if (!fileName) return false
return isEditableDoc(fileName)
}
// 提取 HTML 中的 CSS 样式并内联,同时转换资源路径
const extractHtmlStyles = async (htmlContent, basePath) => {
if (!htmlContent) return { processedHtml: htmlContent, styles: '' }
debugLog('开始处理 CSS')
debugLog('HTML 文件路径:', basePath)
const sep = basePath.includes('\\') ? '\\' : '/'
const htmlDir = basePath.substring(0, Math.max(basePath.lastIndexOf('\\'), basePath.lastIndexOf('/'))) || basePath
// 解析相对路径
const resolvePath = (base, relPath) => {
if (relPath.match(/^[a-zA-Z]:\\/)) return relPath
if (relPath.startsWith('/')) return base + relPath.replace(/\//g, sep)
// 处理 ../ 和 ./
const parts = relPath.replace(/\//g, sep).split(sep)
const resolved = []
for (const p of parts) {
if (p === '..') resolved.pop()
else if (p !== '.') resolved.push(p)
}
return base + sep + resolved.join(sep)
}
// 文件转 base64
const fileToBase64 = async (filePath) => {
try {
const content = await readFileApi(filePath)
const ext = getExt(filePath)
const mimes = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon' }
return `data:${mimes[ext] || 'image/png'};base64,${btoa(content)}`
} catch (err) {
console.warn('[fileToBase64] 失败:', filePath, err.message)
return null
}
}
// 转换 CSS 中的 url()
const convertCssUrls = async (cssContent, cssFilePath) => {
const cssDir = cssFilePath.substring(0, Math.max(cssFilePath.lastIndexOf('\\'), cssFilePath.lastIndexOf('/')))
// 移除 @import
cssContent = cssContent.replace(/@import\s+(?:url\()?["']?([^"')]+)["']?\)?;?/gi, (match, path) => {
return path.startsWith('http') ? match : `/* @import "${path}" 已移除 */`
})
// 转换 url()
const urls = []
let m
const urlRegex = /url\(["']?([^"')]+)["']?\)/gi
while ((m = urlRegex.exec(cssContent)) !== null) {
if (!m[1].startsWith('data:') && !m[1].startsWith('http')) {
urls.push(m[1])
}
}
for (const u of urls) {
const fullPath = resolvePath(cssDir, u)
const base64 = await fileToBase64(fullPath)
if (base64) {
console.log(`[convertCssUrls] ${u} -> base64`)
cssContent = cssContent.replace(`url("${u}")`, base64).replace(`url('${u}')`, base64).replace(`url(${u})`, base64)
}
}
return cssContent
}
const styles = []
// 更宽松的link标签匹配匹配包含rel="stylesheet"或rel='stylesheet'的link标签
const linkRegex = /<link\s+(?:[^>]*?\s+)?rel\s*=\s*["']stylesheet["'][^>]*>/gi
const hrefRegex = /href\s*=\s*["']([^"']+)["']/i
let match
let linkCount = 0
while ((match = linkRegex.exec(htmlContent)) !== null) {
linkCount++
const linkTag = match[0]
const hrefMatch = linkTag.match(hrefRegex)
console.log(`[extractHtmlStyles] 发现第 ${linkCount} 个 link 标签:`, linkTag)
if (hrefMatch && hrefMatch[1]) {
let cssPath = resolvePath(htmlDir, hrefMatch[1])
console.log('[extractHtmlStyles] 解析后 CSS 路径:', cssPath)
if (hrefMatch[1].startsWith('http://') || hrefMatch[1].startsWith('https://')) {
console.log('[extractHtmlStyles] 跳过外部 CSS:', hrefMatch[1])
continue
}
try {
console.log('[extractHtmlStyles] 正在读取 CSS 文件:', cssPath)
let cssContent = await readFileApi(cssPath)
// 转换 CSS 中的 url() 为 base64
cssContent = await convertCssUrls(cssContent, cssPath)
const cssSize = cssContent.length
console.log(`[extractHtmlStyles] 成功读取并转换 CSS: ${cssSize} 字符`)
styles.push(`/* 从 ${cssPath} 提取 */\n${cssContent}`)
htmlContent = htmlContent.replace(linkTag, '')
} catch (error) {
console.warn('[extractHtmlStyles] 无法读取 CSS:', cssPath, error.message)
// 失败时也移除link标签避免404错误
htmlContent = htmlContent.replace(linkTag, `<!-- CSS加载失败: ${cssPath} -->`)
}
}
}
debugLog(`处理完成: 找到 ${linkCount} 个 link 标签, 成功提取 ${styles.length} 个 CSS 文件`)
debugLog(`提取的 CSS 总大小: ${styles.join('\n\n').length} 字符`)
return { processedHtml: htmlContent, styles: styles.join('\n\n'), htmlDir, resolvePath, fileToBase64 }
}
// HTML 预览
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
}
}
// 重置所有状态
isImageView.value = false
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
isMarkdownFile.value = false
isHtmlFile.value = true
isBinaryFile.value = false
isEditMode.value = false // 默认预览模式
// 注意:不设置 fileLoading因为那是给文件列表用的
// 这里是读取文件内容,不应该影响列表的显示
try {
// 读取 HTML 文件内容
let content = await readFileApi(pathToPreview)
// 提取并内联 CSS 样式,获取路径解析函数和 base64 转换函数
const { processedHtml, styles, resolvePath, fileToBase64, htmlDir } = await extractHtmlStyles(content, pathToPreview)
// 转换 HTML 中的图片路径为 base64
const imgRegex = /<img([^>]*)(src=["']([^"']+)["'])([^>]*)>/gi
const images = []
let match
while ((match = imgRegex.exec(processedHtml)) !== null) {
const src = match[3]
if (!src.startsWith('data:') && !src.startsWith('http://') && !src.startsWith('https://')) {
images.push({ src, fullMatch: match[0] })
}
}
let html = processedHtml
// 转换所有图片
for (const img of images) {
const fullPath = resolvePath(htmlDir, img.src)
const base64 = await fileToBase64(fullPath)
if (base64) {
console.log(`[previewHtml] ${img.src} -> base64 (${base64.length} 字符)`)
html = html.replace(img.fullMatch, img.fullMatch.replace(img.src, base64))
}
}
// 移除或注释外部脚本
html = html.replace(/<script([^>]*)(src=["']([^"']+)["'])([^>]*)>/gi, (match, before, srcAttr, src, after) => {
if (src.startsWith('http://') || src.startsWith('https://')) {
return match // 保留外部脚本
}
console.log(`[previewHtml] 移除本地脚本: ${src}`)
return `<!-- 本地脚本已移除: ${src} -->`
})
// 🔥 最终清理:移除所有剩余的外部资源引用(防止遗漏)
// 移除所有剩余的本地CSS链接
html = html.replace(/<link\s+(?:[^>]*?\s+)?rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
const hrefMatch = match.match(/href\s*=\s*["']([^"']+)["']/i)
if (hrefMatch && hrefMatch[1] && !hrefMatch[1].startsWith('http://') && !hrefMatch[1].startsWith('https://')) {
console.log(`[previewHtml] 清理遗漏的CSS链接: ${hrefMatch[1]}`)
return `<!-- CSS链接已移除: ${hrefMatch[1]} -->`
}
return match // 保留外部CSS
})
// 检查是否是完整的 HTML 文档
const hasDoctype = html.trim().toLowerCase().startsWith('<!doctype')
const hasHtmlTag = /<html/i.test(html)
const hasHeadTag = /<head/i.test(html)
const hasBodyTag = /<body/i.test(html)
// 如果已经是完整的 HTML 文档,保持原样,只添加内联样式
if (hasDoctype || (hasHtmlTag && hasHeadTag && hasBodyTag)) {
// 在 </head> 之前插入样式
if (html.includes('</head>')) {
html = html.replace(
'</head>',
` <style>
/* 从本地文件提取的样式 */
${styles}
</style>
</head>`
)
} else if (html.includes('<head>')) {
html = html.replace(
/<head>/i,
`<head>
<style>
/* 从本地文件提取的样式 */
${styles}
</style>`
)
}
// 在 </body> 之前插入链接拦截脚本
if (html.includes('</body>')) {
const linkScript = '<script>(function(){document.addEventListener("click",function(e){const t=e.target.closest("a");if(t&&t.href){e.preventDefault();window.parent.postMessage({type:"open-link",url:t.href},"*")}},true)})()<\/script>'
html = html.replace('</body>', linkScript + '</body>')
}
} else {
// 不是完整文档,构建完整文档
const linkScript = '<script>(function(){document.addEventListener("click",function(e){const t=e.target.closest("a");if(t&&t.href){e.preventDefault();window.parent.postMessage({type:"open-link",url:t.href},"*")}},true)})()<\/script>'
html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${getFileName(pathToPreview)}</title>
<style>
/* 基础样式 */
* { box-sizing: border-box; }
body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Microsoft YaHei', sans-serif; line-height: 1.6; }
img { max-width: 100%; height: auto; }
/* 从本地文件提取的样式 */
${styles}
</style>
</head>
<body>
${html}
${linkScript}
</body>
</html>`
}
// 存储原始内容到 fileContent 用于编辑
fileContent.value = await readFileApi(pathToPreview)
// 保存原始内容,用于检测修改
originalContent.value = fileContent.value
// 渲染处理后的 HTML 内容
rendered.value = html
} catch (error) {
Message.error('读取 HTML 文件失败: ' + error.message)
isHtmlFile.value = false
} finally {
fileLoading.value = false
}
}
// Markdown 预览
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
}
}
// 重置所有状态
isImageView.value = false
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
isHtmlFile.value = false
isMarkdownFile.value = true
isBinaryFile.value = false
isEditMode.value = false // 默认预览模式
// 注意:不设置 fileLoading因为那是给文件列表用的
try {
// 读取 Markdown 文件内容
const content = await readFileApi(pathToPreview)
// 存储原始 Markdown 内容到 fileContent 用于编辑
fileContent.value = content
// 保存原始内容,用于检测修改
originalContent.value = content
// 转换 Markdown 为 HTML
rendered.value = renderMarkdown(content)
} catch (error) {
Message.error('读取 Markdown 文件失败: ' + error.message)
isMarkdownFile.value = false
} finally {
fileLoading.value = false
}
}
// 使用 marked 库渲染 Markdown
const renderMarkdown = (markdown) => {
if (!markdown) return ''
try {
// 配置 marked 选项
marked.setOptions({
breaks: true, // 支持 GFM 换行
gfm: true, // 启用 GitHub Flavored Markdown
headerIds: false, // 不生成标题 ID
mangle: false // 不转义邮箱地址
})
// 使用 marked 解析
const html = marked.parse(markdown)
return '<div class="markdown-content">' + html + '</div>'
} catch (error) {
console.error('[renderMarkdown] 解析失败:', error)
return '<div class="markdown-content"><p>Markdown 解析失败: ' + error.message + '</p></div>'
}
}
const onImageLoad = (e) => {
imageLoading.value = false
imageWidth.value = e.target.naturalWidth
imageHeight.value = e.target.naturalHeight
}
const onImageError = () => {
imageLoading.value = false
isImageView.value = false
Message.error('图片加载失败,可能是格式不支持或文件损坏')
}
const currentImageDimensions = computed(() => {
if (!imageWidth.value || !imageHeight.value) return ''
return `${imageWidth.value}×${imageHeight.value}`
})
const performFileRead = async () => {
const fileToRead = selectedFilePath.value || filePath.value
if (!fileToRead) return
isImageView.value = false
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
isHtmlFile.value = false // 纯文本文件不是 HTML
isMarkdownFile.value = false // 纯文本文件不是 Markdown
isBinaryFile.value = false
isEditMode.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 (isCode(filePath.value)) {
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)
// 文本文件检查大小
const maxDisplaySize = 5 * 1024 * 1024 // 5MB
if (content.length > maxDisplaySize) {
// 超过 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
}
// 保存原始内容,用于检测修改
originalContent.value = fileContent.value
// 移除明显的成功提示,保持界面简洁
} catch (error) {
Message.error('读取文件失败: ' + error.message)
} finally {
fileLoading.value = false
}
}
// ========== 安全的文件操作(修复路径定位问题) ==========
/**
* 保存内容处理(草稿/文件统一处理)
*/
const handleSaveContent = async () => {
// 检查是否有内容可保存
if (!fileContent.value || fileContent.value.trim() === '') {
Message.warning('没有内容可保存')
return
}
const targetPath = selectedFilePath.value
if (!targetPath) {
// 草稿模式:手动触发草稿保存
saveDraft()
Message.success({
content: '✓ 内容已保存到缓存(草稿)',
duration: 1500,
position: 'bottom'
})
return
}
// 文件模式:保存到文件
// 验证路径是否为目录
if (fileList.value.some(f => f.path === targetPath && f.is_dir)) {
Message.error(`"${targetPath}" 是目录,不能保存`)
return
}
// 检查内容是否已修改
if (!isFileModified.value) {
const fileName = targetPath.split(/[/\\]/).pop()
Message.info({
content: `📝 ${fileName} 未修改,无需保存`,
duration: 1500,
position: 'bottom'
})
return
}
// 保存到文件
const fileName = targetPath.split(/[/\\]/).pop()
await saveToFile(targetPath, fileName, false)
}
/**
* 另存为(草稿模式下保存到文件)
*/
const handleSaveAs = async () => {
// 检查是否有内容可保存
if (!fileContent.value || fileContent.value.trim() === '') {
Message.warning('没有内容可保存')
return
}
try {
// 检查是否支持 runtime API
if (!window.runtime || !window.runtime.DialogSave) {
// 如果不支持,回退到手动输入
showManualSaveDialog(false)
return
}
// 显示文件保存对话框
const defaultDir = filePath.value || ''
const defaultFileName = 'untitled.txt'
const savePath = await window.runtime.DialogSave({
title: '另存为',
defaultFilename: defaultFileName,
defaultDirectory: defaultDir,
canCreateDirectories: true
})
if (!savePath) {
// 用户取消了保存
return
}
// 验证文件名
const fileName = savePath.split(/[/\\]/).pop()
const validation = validateFileName(fileName)
if (!validation.valid) {
Message.error(validation.error)
return
}
// 保存文件
await saveToFile(savePath, fileName, false)
// 清除草稿(因为已经保存到文件了)
clearDraft()
} catch (error) {
console.error('[handleSaveAs] 保存对话框失败:', error)
Message.error(`打开保存对话框失败: ${error.message || error}`)
}
}
/**
* 保存文件处理 - 支持新建文件和保存到已有文件
* @param {boolean} [isShortcut=false] - 是否是快捷键触发
*/
const handleWriteFile = async (isShortcut = false) => {
// 直接调用 handleSaveContent
await handleSaveContent()
}
/**
* 手动输入保存路径(当对话框不可用时的回退方案)
*/
const showManualSaveDialog = (isShortcut) => {
const defaultDir = filePath.value || 'C:\\'
showInputDialog(
'💾 保存新文件',
`请输入文件路径(例如: ${defaultDir}\\filename.txt`,
async (inputPath) => {
// 验证路径
if (!inputPath || inputPath.trim() === '') {
Message.error('请输入文件路径')
return
}
// 验证文件名
const fileName = inputPath.split(/[/\\]/).pop()
const validation = validateFileName(fileName)
if (!validation.valid) {
Message.error(validation.error)
// 重新显示对话框
setTimeout(() => {
inputDialogVisible.value = true
inputDialogValue.value = inputPath
}, 100)
return
}
// 保存文件
await saveToFile(inputPath, fileName, 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
try {
// 保存文件(传递两个独立的参数)
await writeFileApi(targetPath, fileContent.value)
// 保存成功提示
Message.success({
content: `✓ 文件 "${fileName}" 保存成功`,
duration: 1500,
position: 'bottom'
})
// 更新状态
selectedFilePath.value = targetPath
originalContent.value = fileContent.value
// 如果保存到当前目录,刷新文件列表
const fileDir = targetPath.substring(0, Math.max(
targetPath.lastIndexOf('\\'),
targetPath.lastIndexOf('/')
))
if (fileDir === filePath.value) {
await listDirectory()
}
} catch (error) {
Message.error(`保存失败: ${error.message || error}`)
} finally {
// 延迟清除保存状态,让用户看到按钮状态变化
setTimeout(() => {
isSaving.value = false
}, isShortcut ? 300 : 500)
}
}
/**
* 删除文件处理 - 始终删除选中的文件
*/
const handleDeleteFile = async () => {
// 必须有 selectedFilePath 才能删除
const targetPath = selectedFilePath.value
if (!targetPath) {
Message.error('未选择文件,无法删除。请先从左侧文件列表中选择一个文件。')
return
}
// 查找目标文件信息
const targetItem = fileList.value.find(f => f.path === targetPath)
const isDirectory = targetItem?.is_dir || false
const fileName = targetItem?.name || targetPath
// 根据类型显示不同的确认信息
const confirmMessage = isDirectory
? `⚠️ 确定要删除整个目录吗?\n\n${fileName}\n\n此操作将删除目录及其所有内容不可恢复`
: `确定要删除文件吗?\n\n${fileName}\n\n此操作不可恢复`
Modal.confirm({
title: isDirectory ? '⚠️ 危险操作:删除目录' : '确认删除',
content: confirmMessage,
okText: '确定删除',
cancelText: '取消',
onOk: async () => {
fileLoading.value = true
try {
await deletePathApi(targetPath)
Message.success('删除成功')
// 清空编辑器内容
selectedFilePath.value = ''
fileContent.value = ''
isImageView.value = false
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
isHtmlFile.value = false
isMarkdownFile.value = false
isBinaryFile.value = false
previewUrl.value = ''
rendered.value = ''
// 刷新文件列表
if (filePath.value) {
await listDirectory()
}
} catch (error) {
Message.error('删除失败: ' + error.message)
} finally {
fileLoading.value = false
}
}
})
}
// ========== 右键菜单处理 ==========
/**
* 显示输入对话框
* @param {string} title - 对话框标题
* @param {string} placeholder - 输入框占位符
* @param {function} callback - 确认回调函数,接收输入值作为参数
* @param {string} okText - 确定按钮文本,默认为"确定"
*/
const showInputDialog = (title, placeholder, callback, okText = '确定') => {
// 隐藏右键菜单
hideContextMenu()
inputDialogTitle.value = title
inputDialogPlaceholder.value = placeholder
inputDialogOkText.value = okText
inputDialogValue.value = ''
inputDialogCallback.value = callback
inputDialogVisible.value = true
// 自动聚焦到输入框
setTimeout(() => {
inputDialogInputRef.value?.focus()
}, 100)
}
/**
* 处理输入对话框确认
*/
const handleInputDialogConfirm = () => {
const value = inputDialogValue.value?.trim()
if (!value) {
Message.warning('请输入内容')
return
}
// 调用回调函数
if (inputDialogCallback.value) {
inputDialogCallback.value(value)
}
// 关闭对话框
inputDialogVisible.value = false
}
/**
* 处理文件列表区域的右键菜单(统一处理)
*/
const handleFileListContextMenu = (event) => {
// 隐藏已显示的菜单
hideContextMenu()
// 检查是否点击在文件项上
const fileItemRow = event.target.closest('.file-item-row')
if (fileItemRow) {
// 点击在文件项上:显示删除选项
const filePath = fileItemRow.getAttribute('data-file-path')
const file = fileList.value.find(f => f.path === filePath)
if (file) {
contextMenuTarget.value = 'file'
selectedContextFile.value = file
} else {
contextMenuTarget.value = 'blank'
selectedContextFile.value = null
}
} else {
// 点击在空白区域:只显示新建选项
contextMenuTarget.value = 'blank'
selectedContextFile.value = null
}
// 设置菜单位置
contextMenuPosition.value = {
x: event.clientX,
y: event.clientY
}
// 显示菜单
contextMenuVisible.value = true
}
/**
* 隐藏右键菜单
*/
const hideContextMenu = () => {
contextMenuVisible.value = false
contextMenuTarget.value = 'blank'
selectedContextFile.value = null
}
/**
* 使用系统默认程序打开右键选中的文件
*/
const handleOpenWithSystem = async () => {
if (!selectedContextFile.value) {
return
}
const targetPath = selectedContextFile.value.path
const fileName = selectedContextFile.value.name
// 隐藏右键菜单
hideContextMenu()
try {
await openPath(targetPath)
Message.success(`已使用系统默认程序打开: ${fileName}`)
} catch (error) {
Message.error('打开文件失败: ' + error.message)
}
}
/**
* 重命名右键选中的文件或目录(启动原地编辑模式)
*/
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) {
goToPath(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 = ''
}
/**
* 删除右键选中的文件
*/
const handleDeleteSelectedFile = async () => {
if (!selectedContextFile.value) {
return
}
const targetPath = selectedContextFile.value.path
const fileName = selectedContextFile.value.name
const isDirectory = selectedContextFile.value.is_dir
// 根据类型显示不同的确认信息
const confirmMessage = isDirectory
? `⚠️ 确定要删除整个目录吗?\n\n${fileName}\n\n此操作将删除目录及其所有内容不可恢复`
: `确定要删除文件吗?\n\n${fileName}\n\n此操作不可恢复`
// 隐藏右键菜单
hideContextMenu()
// 使用 Modal.confirm 进行确认
Modal.confirm({
title: isDirectory ? '⚠️ 危险操作:删除目录' : '确认删除',
content: confirmMessage,
okText: '确定删除',
cancelText: '取消',
onOk: async () => {
fileLoading.value = true
try {
await deletePathApi(targetPath)
Message.success('删除成功')
// 如果删除的是当前选中的文件,清空编辑器
if (selectedFilePath.value === targetPath) {
selectedFilePath.value = ''
fileContent.value = ''
isImageView.value = false
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
isHtmlFile.value = false
isMarkdownFile.value = false
isBinaryFile.value = false
previewUrl.value = ''
rendered.value = ''
}
// 刷新文件列表
await listDirectory()
} catch (error) {
Message.error(`删除失败: ${error.message || error}`)
} finally {
fileLoading.value = false
}
}
})
}
// ========== 创建文件夹 ==========
/**
* 创建文件夹处理
*/
const handleCreateDir = () => {
// 检查当前是否在有效的目录中
if (!filePath.value) {
Message.error('请先选择一个目录')
return
}
// 如果正在浏览 ZIP 文件,不允许创建文件夹
if (isBrowsingZip.value) {
Message.warning('ZIP 浏览模式下不支持创建文件夹')
return
}
showInputDialog(
'📁 新建文件夹',
'请输入文件夹名称',
async (folderName) => {
// 验证文件夹名称
const validation = validateFileName(folderName)
if (!validation.valid) {
Message.error(validation.error)
// 重新显示对话框(因为验证失败)
setTimeout(() => {
inputDialogVisible.value = true
inputDialogValue.value = folderName
}, 100)
return
}
// 检查是否已存在同名文件夹
const existingFolder = fileList.value.find(f =>
f.name === folderName && f.is_dir
)
if (existingFolder) {
Message.error(`文件夹 "${folderName}" 已存在`)
// 重新显示对话框(因为文件已存在)
setTimeout(() => {
inputDialogVisible.value = true
inputDialogValue.value = folderName
}, 100)
return
}
// 构建完整路径
const fullPath = `${filePath.value}\\${folderName}`
try {
await createDir(fullPath)
Message.success({
content: `✓ 文件夹 "${folderName}" 创建成功`,
duration: 1500,
position: 'bottom'
})
// 刷新文件列表
await listDirectory()
} catch (error) {
Message.error(`创建文件夹失败: ${error.message || error}`)
}
},
'创建'
)
}
// ========== 创建文件 ==========
/**
* 创建文件处理
*/
const handleCreateFile = () => {
// 检查当前是否在有效的目录中
if (!filePath.value) {
Message.error('请先选择一个目录')
return
}
// 如果正在浏览 ZIP 文件,不允许创建文件
if (isBrowsingZip.value) {
Message.warning('ZIP 浏览模式下不支持创建文件')
return
}
showInputDialog(
'📄 新建文件',
'请输入文件名(如: todo.md',
async (fileName) => {
// 验证文件名
const validation = validateFileName(fileName)
if (!validation.valid) {
Message.error(validation.error)
// 重新显示对话框(因为验证失败)
setTimeout(() => {
inputDialogVisible.value = true
inputDialogValue.value = fileName
}, 100)
return
}
// 检查是否已存在同名文件
const existingFile = fileList.value.find(f =>
f.name === fileName && !f.is_dir
)
if (existingFile) {
Message.error(`文件 "${fileName}" 已存在`)
// 重新显示对话框(因为文件已存在)
setTimeout(() => {
inputDialogVisible.value = true
inputDialogValue.value = fileName
}, 100)
return
}
// 构建完整路径
const fullPath = `${filePath.value}\\${fileName}`
try {
await createFile(fullPath)
Message.success({
content: `✓ 文件 "${fileName}" 创建成功`,
duration: 1500,
position: 'bottom'
})
// 刷新文件列表
await listDirectory()
// 自动打开新创建的文件进行编辑
selectedFilePath.value = fullPath
fileContent.value = ''
originalContent.value = ''
// 根据文件类型设置预览模式
const ext = fileName.split('.').pop()?.toLowerCase() || ''
if (ext === 'html' || ext === 'htm') {
isHtmlFile.value = true
isMarkdownFile.value = false
isEditMode.value = true // 创建新文件默认进入编辑模式
rendered.value = ''
} else if (ext === 'md' || ext === 'markdown') {
isMarkdownFile.value = true
isHtmlFile.value = false
isEditMode.value = true // 创建新文件默认进入编辑模式
rendered.value = ''
} else {
// 文本文件
isHtmlFile.value = false
isMarkdownFile.value = false
}
} catch (error) {
Message.error(`创建文件失败: ${error.message || error}`)
}
},
'创建'
)
}
// ========== 文件名验证 ==========
/**
* 验证文件名/文件夹名是否合法
* @param {string} name - 文件名
* @returns {Object} { valid: boolean, error: string }
*/
const validateFileName = (name) => {
if (!name || name.trim() === '') {
return { valid: false, error: '名称不能为空' }
}
// Windows 文件名非法字符: \ / : * ? " < > |
const illegalChars = /[\\/:*?"<>|]/
if (illegalChars.test(name)) {
return { valid: false, error: '文件名不能包含以下字符: \\ / : * ? " < > |' }
}
// Windows 保留文件名
const reservedNames = [
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
]
const nameWithoutExt = name.split('.')[0].toUpperCase()
if (reservedNames.includes(nameWithoutExt)) {
return { valid: false, error: `"${name}" 是系统保留文件名,请使用其他名称` }
}
// 文件名长度限制Windows 限制为 260 个字符)
if (name.length > 255) {
return { valid: false, error: '文件名过长(最大 255 个字符)' }
}
// 文件名不能以空格或点结尾
if (name.endsWith(' ') || name.endsWith('.')) {
return { valid: false, error: '文件名不能以空格或点结尾' }
}
return { valid: true }
}
// ========== 删除文件(重写以处理历史记录) - 已废弃,使用 handleDeleteFile ==========
const deleteFile = async () => {
if (!filePath.value) return
Modal.confirm({
title: '确认删除',
content: `确定要删除 ${filePath.value} 吗?此操作不可恢复!`,
onOk: async () => {
fileLoading.value = true
try {
await deletePathApi(filePath.value)
Message.success('删除成功')
filePath.value = ''
fileContent.value = ''
fileList.value = []
if (pathHistory.value.length > 0) {
filePath.value = pathHistory.value[0]
listDirectory()
}
} catch (error) {
Message.error('删除失败: ' + error.message)
} finally {
fileLoading.value = false
}
}
})
}
// ========== 重置内容 ==========
/**
* 重置内容到原始文件内容
*/
const resetContent = () => {
if (originalContent.value !== undefined) {
fileContent.value = originalContent.value
Message.success('内容已重置到原始状态')
} else {
Message.warning('没有可重置的原始内容')
}
}
// ========== 打开收藏的文件 ==========
const openFavoriteFile = (path) => {
const fav = favoriteFiles.value.find(f => f.path === path)
if (fav && fav.is_dir) {
// 目录:列出内容
// 注意:不要清空 selectedFilePath保留原文件内容以便跨目录编辑
filePath.value = path
addToHistory(path)
listDirectory()
} else {
// 文件:设置选中文件路径并读取
// 提取父目录
const parentPath = path.substring(0, Math.max(
path.lastIndexOf('\\'),
path.lastIndexOf('/')
))
filePath.value = parentPath || path.substring(0, path.lastIndexOf(/[/\\]/))
selectedFilePath.value = path // 设置选中文件路径
addToHistory(parentPath || path)
readFile()
}
}
// ========== 收藏夹拖拽排序 ==========
// 拖拽状态
const draggingState = ref({
isDragging: false,
draggedIndex: -1,
draggedItem: null,
})
// 长按定时器
const longPressTimer = ref(null)
const LONG_PRESS_DURATION = 500 // 500ms 长按
// 开始长按
const onLongPressStart = (event, index) => {
// 只响应左键或触摸
if (event.type === 'mousedown' && event.button !== 0) {
return
}
longPressTimer.value = setTimeout(() => {
// 长按触发,开始拖拽
draggingState.value = {
isDragging: true,
draggedIndex: index,
draggedItem: favoriteFiles.value[index],
}
Message.info('开始拖拽排序')
}, LONG_PRESS_DURATION)
}
// 取消长按
const onLongPressCancel = () => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
// 拖拽开始
const onDragStart = (event, index) => {
if (!draggingState.value.isDragging) {
event.preventDefault()
return
}
// 设置拖拽数据
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', index.toString())
// 设置拖拽图像(可选)
event.dataTransfer.setDragImage(event.target, 0, 0)
}
// 拖拽经过
const onDragOver = (event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
// 放置
const onDrop = (event, targetIndex) => {
event.preventDefault()
if (!draggingState.value.isDragging) {
return
}
const fromIndex = draggingState.value.draggedIndex
if (fromIndex === -1 || fromIndex === targetIndex) {
// 重置拖拽状态
draggingState.value = {
isDragging: false,
draggedIndex: -1,
draggedItem: null,
}
return
}
// 执行重排序
const success = reorderFavorites(fromIndex, targetIndex)
if (success) {
Message.success('排序已更新')
}
// 重置拖拽状态
draggingState.value = {
isDragging: false,
draggedIndex: -1,
draggedItem: null,
}
}
// 拖拽结束
const onDragEnd = () => {
// 重置拖拽状态
draggingState.value = {
isDragging: false,
draggedIndex: -1,
draggedItem: null,
}
}
// ========== 拖拽调整高度 ==========
const startResize = (e) => {
const startY = e.clientY
const startHeight = fileContentHeight.value
const onMouseMove = (moveEvent) => {
const deltaY = moveEvent.clientY - startY
const newHeight = startHeight + deltaY
if (newHeight >= 150 && newHeight <= 800) {
fileContentHeight.value = newHeight
}
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.FILE_CONTENT_HEIGHT, fileContentHeight.value.toString())
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// ========== 水平拖拽调整面板宽度 ==========
// ========== 计算属性:按钮显示控制 ==========
// 获取当前文件扩展名(用于代码高亮)
const currentFileExtension = computed(() => {
const path = selectedFilePath.value || filePath.value
if (!path) return ''
// 特殊文件名映射(无扩展名)
const fileName = path.split(/[/\\]/).pop()?.toLowerCase() || ''
const specialFiles = {
'dockerfile': 'dockerfile',
'containerfile': 'dockerfile',
'makefile': 'makefile',
'cmakelists.txt': 'cmake',
'.gitignore': 'gitignore',
'.env': 'properties',
}
if (specialFiles[fileName]) return specialFiles[fileName]
// 普通文件:使用 getExt 函数
return getExt(path)
})
// 判断当前文件是否支持预览模式HTML 和 Markdown 支持)
const canPreviewFile = computed(() => {
return isHtmlFile.value || isMarkdownFile.value
})
// 检查是否为可编辑的视图(非预览模式)
const isEditableView = computed(() => {
return !isImageView.value &&
!isVideoView.value &&
!isAudioView.value &&
!isPdfFile.value &&
!isBinaryFile.value
})
// 检查文件内容是否已修改(包括新文件)
const isFileModified = computed(() => {
// 检查是否有内容
const hasContent = fileContent.value !== '' && fileContent.value.trim() !== ''
// 情况1已选择文件内容已修改
const hasModified = selectedFilePath.value && fileContent.value !== originalContent.value
// 情况2未选择文件但有新内容新建文件
const isNewFile = !selectedFilePath.value && hasContent
return isEditableView.value && (hasModified || isNewFile)
})
// 检查内容是否已修改(提取公共逻辑)
const contentChanged = computed(() => {
return fileContent.value !== '' &&
fileContent.value !== originalContent.value
})
// 是否可以保存文件
const canSaveFile = computed(() => isEditableView.value && contentChanged.value)
// 是否可以重置内容
const canResetContent = computed(() =>
isEditableView.value &&
contentChanged.value &&
originalContent.value !== undefined
)
// ========== 水平拖拽调整面板宽度 ==========
const startResizeHorizontal = (e) => {
console.log('[startResizeHorizontal] 开始拖拽')
const container = e.target.closest('.file-workspace')
if (!container) {
console.error('[startResizeHorizontal] 找不到容器')
return
}
const startX = e.clientX
const containerWidth = container.offsetWidth
const startLeftWidth = (panelWidth.value.left / 100) * containerWidth
console.log('[startResizeHorizontal] 初始状态:', { startX, containerWidth, startLeftWidth })
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX
const newLeftWidth = startLeftWidth + deltaX
const newLeftPercent = (newLeftWidth / containerWidth) * 100
if (newLeftPercent >= 20 && newLeftPercent <= 80) {
panelWidth.value.left = newLeftPercent
panelWidth.value.right = 100 - newLeftPercent
}
}
const onMouseUp = () => {
console.log('[startResizeHorizontal] 结束拖拽, 最终宽度:', panelWidth.value)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
localStorage.setItem(
STORAGE_KEYS.FILESYSTEM.PANEL_WIDTH,
JSON.stringify(panelWidth.value)
)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// ========== 编辑/预览模式切换 ==========
// 切换编辑模式
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
debugLog('切换编辑模式:', isEditMode.value ? '编辑' : '预览')
}
// 监听编辑模式变化,保存到 localStorage 并重新渲染内容
watch(isEditMode, (newMode) => {
// 保存到 localStorage
try {
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.EDIT_MODE, String(newMode))
} catch (error) {
console.error('[FileSystem] 保存编辑模式失败:', error)
}
// 当从编辑模式切换到预览模式时,重新渲染内容
if (!newMode) {
if (isHtmlFile.value) {
// HTML: 直接渲染 fileContent
rendered.value = fileContent.value
} else if (isMarkdownFile.value) {
// Markdown: 重新转换
rendered.value = renderMarkdown(fileContent.value)
}
}
})
// ========== 初始化 ==========
// 键盘快捷键处理
const handleKeyDown = (e) => {
// F5 刷新文件列表
if (e.key === 'F5') {
e.preventDefault() // 阻止浏览器默认刷新行为
if (filePath.value) {
listDirectory()
}
}
// Ctrl+Shift+C/D/E/F/G/H 快速打开对应盘符
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
const driveLetter = e.key.toUpperCase()
if (['C', 'D', 'E', 'F', 'G', 'H'].includes(driveLetter)) {
e.preventDefault() // 阻止浏览器默认行为
const drivePath = `${driveLetter}:\\`
filePath.value = drivePath
listDirectory()
}
}
// Ctrl+B 切换收藏夹侧边栏
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault() // 阻止浏览器默认行为
showSidebar.value = !showSidebar.value
}
// Ctrl+S 保存
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault() // 阻止浏览器默认保存行为
// 只有在可以保存的情况下才执行保存(标记为快捷键操作)
if (canSaveFile.value) {
handleWriteFile(true) // 传递 true 表示是快捷键触发
} else {
Message.warning('当前文件不支持保存')
}
}
// Ctrl+N 新建文件
if ((e.ctrlKey || e.metaKey) && e.key === 'n' && !e.shiftKey) {
e.preventDefault() // 阻止浏览器默认新建窗口行为
handleCreateFile()
}
// Ctrl+Shift+N 新建文件夹
if ((e.ctrlKey || e.metaKey) && e.key === 'n' && e.shiftKey) {
e.preventDefault() // 阻止浏览器默认行为
handleCreateDir()
}
// Alt+← 后退到上一个目录
if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault() // 阻止浏览器默认行为
goBack()
}
// Alt+→ 前进到下一个目录
if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault() // 阻止浏览器默认行为
goForward()
}
// 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()
}
}
}
// 处理iframe中的链接点击
const handleLinkClick = (e) => {
if (e.data && e.data.type === 'open-link' && e.data.url) {
debugLog('[handleLinkClick] 收到链接点击:', e.data.url)
// 在系统默认浏览器中打开链接
try {
BrowserOpenURL(e.data.url)
} catch (error) {
console.error('[handleLinkClick] 打开链接失败:', error)
// 降级处理使用window.open
window.open(e.data.url, '_blank')
}
}
}
onMounted(() => {
// 获取文件服务器URL
getFileServerURL().then(url => {
fileServerURL.value = url
console.log('[FileSystem] 文件服务器URL:', url)
}).catch(err => {
console.warn('[FileSystem] 获取文件服务器URL失败使用默认值:', err)
})
loadCommonPaths()
// 加载草稿内容
loadDraft()
// 恢复上次访问的路径
if (filePath.value) {
listDirectory()
}
// 添加键盘事件监听
window.addEventListener('keydown', handleKeyDown)
// 添加点击事件监听(关闭右键菜单)
window.addEventListener('click', hideContextMenu)
// 添加滚动事件监听(关闭右键菜单)
window.addEventListener('scroll', hideContextMenu, true)
// 添加消息监听处理iframe中的链接点击
window.addEventListener('message', handleLinkClick)
})
onUnmounted(() => {
// 移除键盘事件监听
window.removeEventListener('keydown', handleKeyDown)
// 移除点击事件监听
window.removeEventListener('click', hideContextMenu)
// 移除滚动事件监听
window.removeEventListener('scroll', hideContextMenu, true)
// 移除消息监听
window.removeEventListener('message', handleLinkClick)
})
</script>
<style scoped>
/* ========== 容器布局 ========== */
.file-system-container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg-1);
gap: 0;
}
/* ========== 顶部工具栏 ========== */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
gap: 12px;
height: 48px;
flex-shrink: 0;
}
.toolbar-left {
flex: 1;
min-width: 0;
}
.path-input-wrapper {
width: 100%;
}
.path-input {
width: 100%;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ========== 主内容区 ========== */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
/* ========== 侧边栏 ========== */
.sidebar {
width: 220px;
background: var(--color-fill-1);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-enter-active, .sidebar-leave-active {
transition: all 0.3s ease;
}
.sidebar-enter-from, .sidebar-leave-to {
width: 0;
opacity: 0;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-bottom: 1px solid var(--color-border);
font-size: 13px;
font-weight: 500;
}
.sidebar-title {
color: var(--color-text-1);
}
.sidebar-count {
font-size: 12px;
color: var(--color-text-3);
background: var(--color-fill-3);
padding: 2px 8px;
border-radius: 10px;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
margin-bottom: 2px;
}
.sidebar-item:hover {
background: var(--color-fill-2);
}
.sidebar-item-icon {
font-size: 16px;
flex-shrink: 0;
}
.sidebar-item-name {
flex: 1;
font-size: 13px;
color: var(--color-text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-item-remove {
opacity: 0;
transition: opacity 0.2s;
}
.sidebar-item:hover .sidebar-item-remove {
opacity: 1;
}
/* 拖拽样式 */
.sidebar-item-dragging {
opacity: 0.5;
background: var(--color-fill-3);
cursor: grabbing !important;
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.sidebar-item-drag-over {
border: 2px dashed var(--color-primary-light-3);
background: var(--color-fill-1);
}
/* 防止拖拽时显示删除按钮 */
.sidebar-item-dragging .sidebar-item-remove {
opacity: 0 !important;
}
.sidebar-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--color-text-3);
text-align: center;
gap: 8px;
}
.sidebar-hint {
font-size: 12px;
color: var(--color-text-4);
margin-top: 4px;
}
/* ========== 工作区 ========== */
.file-workspace {
display: flex;
flex: 1;
overflow: hidden;
min-width: 0;
}
/* ========== 文件列表面板 ========== */
.file-list-panel {
display: flex;
flex-direction: column;
/* 宽度通过内联样式动态绑定 */
min-width: 200px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--color-fill-1);
border-bottom: 1px solid var(--color-border);
font-size: 13px;
font-weight: 500;
flex-shrink: 0;
}
.panel-title {
color: var(--color-text-1);
}
.panel-count, .panel-filename {
font-size: 12px;
color: var(--color-text-3);
}
.panel-warning {
font-size: 12px;
color: #f53f3f;
background: var(--color-danger-light-1);
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.panel-filename {
font-weight: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
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 {
flex: 1;
overflow-y: auto;
}
/* 紧凑列表样式 */
.compact-list :deep(.arco-list) {
padding: 0;
}
.file-item-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid var(--color-border-2);
}
.file-item-row:hover {
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;
}
.file-item-icon {
font-size: 16px;
flex-shrink: 0;
width: 20px;
text-align: center;
}
.file-item-name {
flex: 1;
font-size: 13px;
color: var(--color-text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
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);
flex-shrink: 0;
}
.file-item-fav {
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s;
}
.file-item-row:hover .file-item-fav {
opacity: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--color-text-3);
gap: 8px;
}
/* ========== 分隔条 ========== */
.resizer {
width: 12px;
background: transparent;
cursor: col-resize;
flex-shrink: 0;
position: relative;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
margin: 0 -6px;
/* 关键:确保所有鼠标事件都能被捕获 */
pointer-events: auto;
}
.resizer::before {
content: '';
position: absolute;
width: 4px;
height: 100%;
background: var(--color-border-2);
border-left: 1px solid var(--color-border-2);
border-right: 1px solid var(--color-border-2);
transition: all 0.2s;
pointer-events: none; /* 让伪元素不阻止鼠标事件 */
}
.resizer:hover::before {
background: var(--color-fill-2);
border-color: var(--color-primary-light-4);
}
.resizer::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 40px;
background: var(--color-border-3);
border-radius: 1px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.resizer:hover::after {
opacity: 1;
}
/* ========== 文件编辑器面板 ========== */
.file-editor-panel {
display: flex;
flex-direction: column;
/* 宽度通过内联样式动态绑定 */
min-width: 200px;
overflow: hidden;
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 4px;
gap: 4px;
}
/* ========== HTML 预览 ========== */
.html-preview-wrapper {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.preview-mode-switch {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
gap: 4px;
background: var(--color-bg-2);
border-radius: 4px;
padding: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
opacity: 0.3;
transition: opacity 0.2s;
}
.html-preview-wrapper:hover .preview-mode-switch,
.markdown-preview-wrapper:hover .preview-mode-switch,
.text-editor-wrapper:hover .preview-mode-switch {
opacity: 1;
}
.html-preview-content {
flex: 1;
width: 100%;
height: 100%;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: #fff;
}
.html-edit-wrapper {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
}
.code-editor {
flex: 1;
height: 100%;
}
/* ========== Markdown 预览 ========== */
.markdown-preview-wrapper {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.markdown-preview-content {
flex: 1;
overflow: auto;
background: var(--color-bg-2);
border: 1px solid var(--color-border-2);
border-radius: 4px;
padding: 8px;
line-height: 1.6;
}
.markdown-edit-wrapper {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
}
/* Markdown 内容基础样式 */
.markdown-content {
color: var(--color-text-1);
line-height: 1.6;
}
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3),
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-content :deep(h1) {
font-size: 2em;
border-bottom: 1px solid var(--color-border-2);
padding-bottom: 8px;
}
.markdown-content :deep(h2) {
font-size: 1.5em;
border-bottom: 1px solid var(--color-border-2);
padding-bottom: 6px;
}
.markdown-content :deep(p) {
margin-bottom: 16px;
}
.markdown-content :deep(code) {
background: var(--color-fill-3);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.markdown-content :deep(pre) {
background: var(--color-fill-3);
padding: 12px;
border-radius: 4px;
overflow-x: auto;
margin-bottom: 16px;
}
.markdown-content :deep(pre code) {
background: none;
padding: 0;
}
.markdown-content :deep(blockquote) {
border-left: 4px solid var(--color-border-3);
padding-left: 16px;
margin: 16px 0;
color: var(--color-text-3);
}
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
margin-bottom: 16px;
padding-left: 24px;
}
.markdown-content :deep(li) {
margin-bottom: 4px;
}
.markdown-content :deep(a) {
color: var(--color-primary-6);
text-decoration: none;
}
.markdown-content :deep(a:hover) {
text-decoration: underline;
}
.markdown-content :deep(img) {
max-width: 100%;
height: auto;
}
.markdown-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
}
.markdown-content :deep(th),
.markdown-content :deep(td) {
border: 1px solid var(--color-border-2);
padding: 8px 12px;
}
.markdown-content :deep(th) {
background: var(--color-fill-2);
font-weight: 600;
}
.markdown-content :deep(hr) {
border: none;
border-top: 1px solid var(--color-border-2);
margin: 24px 0;
}
.markdown-content :deep(strong) {
font-weight: 600;
}
.markdown-content :deep(em) {
font-style: italic;
}
.markdown-content :deep(del) {
text-decoration: line-through;
color: var(--color-text-3);
}
/* 移除列表项的默认样式,使用自定义 */
.markdown-content :deep(ul) {
list-style-type: disc;
}
.markdown-content :deep(ol) {
list-style-type: decimal;
}
.markdown-content :deep(ul ul) {
list-style-type: circle;
}
.markdown-content :deep(ul ul ul) {
list-style-type: square;
}
/* ========== 媒体预览 ========== */
.media-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
background: var(--color-fill-1);
border-radius: 8px;
overflow: hidden;
position: relative;
}
/* PDF 预览从顶部开始,不居中 */
.media-preview-pdf {
justify-content: flex-start;
}
.preview-image {
max-width: 100%;
max-height: 600px;
object-fit: contain;
}
.preview-video {
max-width: 100%;
max-height: 500px;
}
.preview-audio {
width: 100%;
margin-top: 20px;
}
.preview-pdf {
width: 100%;
height: 100%;
min-height: 500px;
border: none;
border-radius: 4px;
}
.media-loading {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.media-meta {
display: flex;
align-items: center;
gap: 8px;
/*padding: 8px;*/
font-size: 12px;
color: var(--color-text-3);
width: 100%;
justify-content: center;
}
.media-meta .file-name {
font-weight: 500;
color: var(--color-text-2);
}
.media-meta .image-dimensions {
padding: 2px 8px;
background: var(--color-fill-2);
border-radius: 4px;
font-family: monospace;
}
/* ========== 文本编辑器 ========== */
.text-editor-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 200px;
position: relative;
border: 1px solid var(--color-border);
border-radius: 4px;
overflow: hidden;
}
.code-editor {
flex: 1;
height: 100%;
border: none;
}
.resize-handle-v {
height: 6px;
background: var(--color-fill-2);
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
flex-shrink: 0;
}
.resize-handle-v:hover {
background: var(--color-fill-3);
}
.resize-dots {
width: 40px;
height: 3px;
background: repeating-linear-gradient(
90deg,
var(--color-border-3),
var(--color-border-3) 3px,
transparent 3px,
transparent 6px
);
border-radius: 2px;
}
/* ========== 编辑器工具栏 ========== */
.editor-toolbar {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ========== 滚动条优化 ========== */
:deep(.arco-scrollbar-track) {
background: transparent;
}
:deep(.arco-scrollbar-bar) {
background: var(--color-fill-3);
border-radius: 3px;
}
:deep(.arco-scrollbar-bar:hover) {
background: var(--color-fill-4);
}
/* ========== ZIP 面包屑导航 ========== */
.zip-breadcrumb {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 4px 0;
}
.zip-path-text {
flex: 1;
font-size: 13px;
color: var(--color-text-2);
background: var(--color-fill-2);
padding: 6px 12px;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'Consolas', 'Monaco', monospace;
}
/* ========== 右键菜单 ========== */
.context-menu {
position: fixed;
background: var(--color-bg-2);
border: 1px solid var(--color-border-2);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
min-width: 200px;
z-index: 9999;
font-size: 13px;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.15s;
user-select: none;
color: var(--color-text-1);
}
.context-menu-item:hover {
background: var(--color-fill-2);
}
.context-menu-item.danger {
color: rgb(var(--danger-6));
}
.context-menu-item.danger:hover {
background: rgb(var(--danger-1));
}
.context-menu-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.context-menu-shortcut {
margin-left: auto;
font-size: 11px;
color: var(--color-text-3);
background: var(--color-fill-2);
padding: 2px 6px;
border-radius: 3px;
}
.context-menu-divider {
height: 1px;
background: var(--color-border-2);
margin: 4px 0;
}
</style>