- 简化计算属性,删除重复代码 - 优化文件扩展名获取逻辑 - 新增文件工具函数库 fileHelpers.js - 增强 CodeEditor 语法高亮(支持 30+ 语言) - 修复 Office 文档文件服务器访问权限 - 添加特殊文件名支持(Dockerfile、Makefile 等)
4242 lines
121 KiB
Vue
4242 lines
121 KiB
Vue
<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>
|