Private
Public Access
1
0

重构:文件系统模块化架构,增强 Markdown 渲染

- 拆分 FileSystem.vue 为模块化组件架构
- 新增 Markdown Mermaid 图表渲染支持
- 新增 180+ 编程语言代码高亮
- 修复编辑/预览模式切换渲染问题
- 优化亮色/暗色模式主题适配
- 新增 TypeScript 类型定义
This commit is contained in:
2026-02-04 03:31:22 +08:00
parent eb2cbad17b
commit a5d30684ed
119 changed files with 11244 additions and 12042 deletions

View File

@@ -7,20 +7,20 @@
</div>
<a-tabs v-model:active-key="activeTab" class="header-tabs">
<a-tab-pane
v-for="tab in visibleTabs"
:key="tab.key"
:title="tab.title"
v-for="tab in visibleTabs"
:key="tab.key"
:title="tab.title"
/>
</a-tabs>
<div class="header-actions">
<a-tooltip content="设置">
<a-button type="text" @click="showSettings = true">
<template #icon>
<IconSettings />
<IconSettings/>
</template>
</a-button>
</a-tooltip>
<ThemeToggle />
<ThemeToggle/>
<!-- 窗口控制按钮 -->
<div class="window-controls">
@@ -51,36 +51,35 @@
<!-- 动态渲染 Tab 内容 -->
<!-- 使用 KeepAlive 缓存组件状态避免切换时重新加载 -->
<KeepAlive include="FileSystem,DbCli,DeviceTest">
<component :is="getComponent(activeTab)" />
<component :is="getComponent(activeTab)"/>
</KeepAlive>
</a-layout-content>
<!-- 设置抽屉 -->
<SettingsPanel
v-model="showSettings"
:config="appConfig"
@save="handleSaveConfig"
v-model="showSettings"
:config="appConfig"
@save="handleSaveConfig"
/>
<!-- 升级提示弹窗 -->
<UpdateNotification
v-model="showUpdateNotification"
:update-info="updateInfo"
@install="handleUpdateInstall"
@skip="handleUpdateSkip"
v-model="showUpdateNotification"
:update-info="updateInfo"
@install="handleUpdateInstall"
@skip="handleUpdateSkip"
/>
</a-layout>
</template>
<script setup>
import { ref, watch, computed, onMounted } from 'vue'
import { IconSettings } from '@arco-design/web-vue/es/icon'
import { Message } from '@arco-design/web-vue'
import {computed, onMounted, ref, watch} from 'vue'
import {IconSettings} from '@arco-design/web-vue/es/icon'
import {Message} from '@arco-design/web-vue'
import DeviceTest from './components/DeviceTest.vue'
import DbCli from './views/db-cli/index.vue'
import ThemeToggle from './components/ThemeToggle.vue'
import UpdatePanel from './components/UpdatePanel.vue'
import FileSystem from './components/FileSystem.vue'
import FileSystem from './components/FileSystem/index.vue'
import SettingsPanel from './components/SettingsPanel.vue'
import UpdateNotification from './components/UpdateNotification.vue'
@@ -110,19 +109,19 @@ const visibleTabs = computed(() => {
if (!appConfig.value.tabs || appConfig.value.tabs.length === 0) {
// 默认配置
return [
{ key: 'db-cli', title: '数据库' },
{ key: 'file-system', title: '文件管理' },
{ key: 'device', title: '设备调用测试' }
{key: 'db-cli', title: '数据库'},
{key: 'file-system', title: '文件管理'},
{key: 'device', title: '设备调用测试'}
]
}
return appConfig.value.tabs
.filter(tab => tab.visible)
.sort((a, b) => {
const aIndex = appConfig.value.visibleTabs.indexOf(a.key)
const bIndex = appConfig.value.visibleTabs.indexOf(b.key)
return aIndex - bIndex
})
.filter(tab => tab.visible)
.sort((a, b) => {
const aIndex = appConfig.value.visibleTabs.indexOf(a.key)
const bIndex = appConfig.value.visibleTabs.indexOf(b.key)
return aIndex - bIndex
})
})
// 加载配置
@@ -170,9 +169,9 @@ const loadConfig = async () => {
const useDefaultConfig = () => {
appConfig.value = {
tabs: [
{ key: 'db-cli', title: '数据库', visible: true, enabled: true },
{ key: 'file-system', title: '文件管理', visible: true, enabled: true },
{ key: 'device', title: '设备调用测试', visible: true, enabled: true }
{key: 'db-cli', title: '数据库', visible: true, enabled: true},
{key: 'file-system', title: '文件管理', visible: true, enabled: true},
{key: 'device', title: '设备调用测试', visible: true, enabled: true}
],
visibleTabs: ['db-cli', 'file-system', 'device'],
defaultTab: 'db-cli'

View File

@@ -108,6 +108,19 @@ export async function createFile(path: string): Promise<void> {
await window.go.main.App.CreateFile(path)
}
/**
* 重命名文件或目录
*/
export async function renamePath(oldPath: string, newPath: string): Promise<void> {
if (!window.go?.main?.App?.RenamePath) {
throw new Error('RenamePath API 不可用')
}
await window.go.main.App.RenamePath({
oldPath: String(oldPath),
newPath: String(newPath)
})
}
/**
* 获取环境变量
*/
@@ -242,3 +255,24 @@ export async function resolveShortcut(lnkPath: string): Promise<{
throw error
}
}
/**
* 通过文件内容检测文件类型用于小文件500KB以内
*/
export async function detectFileTypeByContent(path: string): Promise<{
extension: string
category: string // 'image' | 'text' | 'binary' | 'pdf' | 'archive' | 'unknown'
mime_type: string
confidence: number
}> {
if (!window.go?.main?.App?.DetectFileTypeByContent) {
throw new Error('DetectFileTypeByContent API 不可用')
}
try {
const result = await window.go.main.App.DetectFileTypeByContent(path)
return result as any
} catch (error) {
console.error('[API] detectFileTypeByContent 错误:', error)
throw error
}
}

View File

@@ -42,8 +42,8 @@ import { xml } from '@codemirror/legacy-modes/mode/xml'
// 文件扩展名到 CodeMirror 语言包的映射
const LANGUAGE_MAP = {
// JavaScript/TypeScript (使用 javascript 包)
javascript: ['js', 'jsx', 'mjs', 'cjs'],
typescript: ['ts', 'tsx'],
javascript: ['js', 'jsx', 'mjs', 'cjs', 'cts', 'mts'],
typescript: ['ts', 'tsx', 'cts', 'mts'],
// 数据格式
json: ['json'],
@@ -55,7 +55,7 @@ const LANGUAGE_MAP = {
css: ['css', 'scss', 'sass', 'less'],
// 系统编程
cpp: ['cpp', 'c', 'cc', 'cxx', 'h', 'hpp'],
cpp: ['cpp', 'c', 'cc', 'cxx', 'h', 'hpp', 'hxx'],
rust: ['rs'],
go: ['go'],
@@ -64,7 +64,7 @@ const LANGUAGE_MAP = {
php: ['php'],
ruby: ['rb'],
perl: ['pl', 'pm'],
shell: ['sh', 'bash', 'zsh', 'fish', 'cmd', 'bat'],
shell: ['sh', 'bash', 'zsh', 'fish', 'cmd', 'bat', 'ps1'],
sql: ['sql'],
// JVM 语言
@@ -116,7 +116,8 @@ const createExtensions = () => {
highlightActiveLineGutter(),
history(),
keymap.of(defaultKeymap),
keymap.of(historyKeymap),
// 不使用 historyKeymap避免 Ctrl+Z 与外部重置功能冲突
// 用户可以通过外部的重置按钮或 Ctrl+Z全局快捷键恢复原始内容
bracketMatching(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
@@ -315,7 +316,12 @@ watch(() => props.modelValue, (newValue) => {
})
// 监听主题或文件扩展名变化,重建编辑器
watch([isDark, () => props.fileExtension], recreateEditor)
// 使用 nextTick 确保 DOM 更新完成后再重建,避免视觉抖动
import { nextTick } from 'vue'
watch([isDark, () => props.fileExtension], async () => {
await nextTick()
recreateEditor()
})
</script>
<style scoped>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
<template>
<div
v-if="config.visible"
class="context-menu"
:style="{ left: config.x + 'px', top: config.y + 'px' }"
@click.stop
>
<!-- 空白区域菜单 -->
<template v-if="config.context === 'blank'">
<div class="context-menu-item" @click="handleCreateFile">
<span class="context-menu-icon">📄</span>
<span>新建文件</span>
<span class="context-menu-shortcut">Ctrl+N</span>
</div>
<div class="context-menu-item" @click="handleCreateDir">
<span class="context-menu-icon">📁</span>
<span>新建文件夹</span>
<span class="context-menu-shortcut">Ctrl+Shift+N</span>
</div>
</template>
<!-- 文件菜单 -->
<template v-else-if="config.context === 'file' && config.selectedFile">
<div class="context-menu-divider"></div>
<div
v-if="!config.selectedFile.is_dir && isOfficeFile(config.selectedFile.name)"
class="context-menu-item"
@click="handleOpenWithSystem"
>
<span class="context-menu-icon">🚀</span>
<span>系统默认程序打开</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click="handleRename">
<span class="context-menu-icon"></span>
<span>重命名</span>
<span class="context-menu-shortcut">F2</span>
</div>
<div class="context-menu-item danger" @click="handleDelete">
<span class="context-menu-icon">🗑</span>
<span>删除</span>
<span class="context-menu-shortcut">Del</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { ContextMenuConfig, FileItem } from '@/types/file-system'
// Props
interface Props {
config: ContextMenuConfig
}
const props = defineProps<Props>()
// Emits
interface Emits {
(e: 'action', action: string, payload?: any): void
(e: 'close'): void
}
const emit = defineEmits<Emits>()
/**
* 判断是否为 Office 文件
*/
const isOfficeFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase()
return ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext || '')
}
/**
* 处理菜单项点击
*/
const handleCreateFile = () => {
emit('action', 'createFile')
emit('close')
}
const handleCreateDir = () => {
emit('action', 'createDir')
emit('close')
}
const handleOpenWithSystem = () => {
if (props.config.selectedFile) {
emit('action', 'openWithSystem', props.config.selectedFile)
}
emit('close')
}
const handleRename = () => {
if (props.config.selectedFile) {
emit('action', 'rename', props.config.selectedFile)
}
emit('close')
}
const handleDelete = () => {
if (props.config.selectedFile) {
emit('action', 'delete', props.config.selectedFile)
}
emit('close')
}
</script>
<style scoped>
.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);
z-index: 1000;
min-width: 200px;
padding: 4px;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
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>

View File

@@ -0,0 +1,102 @@
<template>
<div class="binary-info">
<div class="info-header">
<span class="info-icon"></span>
<span class="info-title">二进制文件</span>
</div>
<div class="info-content">
<pre>{{ content }}</pre>
</div>
<div class="info-tips">
<p>💡 提示</p>
<ul>
<li>右键菜单 "使用系统程序打开" 在默认应用中打开</li>
<li>右键菜单 "在资源管理器中显示" 查看文件位置</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
// Props
interface Props {
content: string
}
defineProps<Props>()
</script>
<style scoped>
.binary-info {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 20px;
background: var(--color-fill-1);
border-radius: 4px;
overflow-y: auto;
}
.info-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
}
.info-icon {
font-size: 20px;
}
.info-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.info-content {
flex: 1;
margin-bottom: 16px;
}
.info-content pre {
margin: 0;
padding: 12px;
background: var(--color-fill-2);
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: var(--color-text-2);
white-space: pre-wrap;
word-break: break-all;
overflow-x: auto;
}
.info-tips {
padding: 12px;
background: var(--color-fill-2);
border-radius: 4px;
}
.info-tips p {
margin: 0 0 8px 0;
font-size: 13px;
color: var(--color-text-1);
}
.info-tips ul {
margin: 0;
padding-left: 20px;
font-size: 12px;
color: var(--color-text-2);
}
.info-tips li {
margin: 4px 0;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="code-editor">
<!-- 代码编辑器 -->
<CodeMirror
v-if="!isEditMode"
:model-value="content"
:extensions="extensions"
:style="{ height: `${height}px` }"
@update:model-value="handleContentUpdate"
readonly
/>
<!-- 编辑模式 -->
<CodeMirror
v-else
v-model="editableContent"
:extensions="extensions"
:style="{ height: `${height}px` }"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import CodeMirror from 'vue-codemirror6'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
import { keymap } from '@codemirror/view'
import { EditorView } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { basicSetup } from 'codemirror'
import { markdown } from '@codemirror/lang-markdown'
// Props
interface Props {
content: string
height: number
isEditMode: boolean
currentFileExtension: string
}
const props = withDefaults(defineProps<Props>(), {
height: 400
})
// Emits
interface Emits {
(e: 'update:content', content: string): void
(e: 'save'): void
}
const emit = defineEmits<Emits>()
// 可编辑内容
const editableContent = ref(props.content)
// 监听 content 变化
watch(() => props.content, (newContent) => {
editableContent.value = newContent
})
// 内容更新
const handleContentUpdate = (value: string) => {
emit('update:content', value)
}
// 根据文件扩展名获取语言
const getLanguage = (ext: string) => {
const languageMap: Record<string, any> = {
js: javascript(),
jsx: javascript(),
ts: javascript(),
tsx: javascript(),
md: markdown()
}
return languageMap[ext] || []
}
// CodeMirror 扩展
const extensions = computed(() => {
const ext = props.currentFileExtension
return [
basicSetup,
keymap.of(/* 添加快捷键 */),
EditorView.theme({ '&': { height: '100%' }, '.cm-scroller': { overflow: 'auto' } }),
oneDark,
...getLanguage(ext)
]
})
</script>
<style scoped>
.code-editor {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div class="media-preview">
<!-- 图片预览 -->
<div v-if="type === 'image'" class="image-preview">
<img
:src="url"
:alt="'图片预览'"
@load="handleLoad"
@error="handleError"
/>
<div v-if="dimensions" class="image-info">
{{ dimensions }}
</div>
</div>
<!-- 视频预览 -->
<div v-else-if="type === 'video'" class="video-preview">
<video :src="url" controls>
您的浏览器不支持视频播放
</video>
</div>
<!-- 音频预览 -->
<div v-else-if="type === 'audio'" class="audio-preview">
<audio :src="url" controls>
您的浏览器不支持音频播放
</audio>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// Props
interface Props {
url: string
type: 'image' | 'video' | 'audio'
}
const props = defineProps<Props>()
// Emits
interface Emits {
(e: 'load', dimensions: string): void
(e: 'error'): void
}
const emit = defineEmits<Emits>()
// 图片尺寸信息
const dimensions = ref('')
// 图片加载完成
const handleLoad = (event: Event) => {
const img = event.target as HTMLImageElement
if (img.naturalWidth && img.naturalHeight) {
dimensions.value = `${img.naturalWidth} × ${img.naturalHeight}`
emit('load', dimensions.value)
}
}
// 图片加载错误
const handleError = () => {
dimensions.value = ''
emit('error')
}
</script>
<style scoped>
.media-preview {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.image-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.image-preview img {
max-width: 100%;
max-height: calc(100% - 20px);
object-fit: contain;
}
.image-info {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-3);
}
.video-preview,
.audio-preview {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
video,
audio {
max-width: 100%;
max-height: 100%;
}
</style>

View File

@@ -0,0 +1,190 @@
<template>
<div class="file-editor-panel" :style="{ width: width + '%' }">
<!-- 面板标题 -->
<div class="panel-header">
<span class="panel-title">{{ title }}</span>
<div class="panel-actions">
<a-button v-if="canSave" type="primary" size="small" @click="handleSave">
保存
</a-button>
<a-button v-if="canReset" size="small" type="outline" @click="handleReset">
重置
</a-button>
<a-button
v-if="isEditableWithPreview"
size="small"
type="text"
@click="handleToggleEditMode"
>
{{ isEditMode ? '预览' : '编辑' }}
</a-button>
</div>
</div>
<!-- 编辑器内容 -->
<div class="editor-content">
<!-- 代码/文本编辑器 -->
<CodeEditor
v-if="!isMediaFile && !isPdfFile && !isBinary"
:content="fileContent"
:height="height"
:isEditMode="isEditMode"
:currentFileExtension="currentFileExtension"
@update:content="handleContentUpdate"
/>
<!-- 媒体预览 -->
<MediaPreview
v-else-if="isMediaFile"
:url="previewUrl"
:type="mediaType"
@load="handleMediaLoad"
@error="handleMediaError"
/>
<!-- PDF预览 -->
<iframe
v-else-if="isPdfFile"
:src="previewUrl"
class="preview-pdf"
></iframe>
<!-- 二进制文件信息 -->
<BinaryInfo v-else :content="fileContent" />
</div>
<!-- 底部调整条 -->
<div v-if="!isBinary && !isMediaFile" class="resizer" @mousedown="handleStartResize"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import CodeEditor from './FileEditor/CodeEditor.vue'
import MediaPreview from './FileEditor/MediaPreview.vue'
import BinaryInfo from './FileEditor/BinaryInfo.vue'
// Props
interface Props {
config: any
width: number
currentDirectory: string
}
const props = defineProps<Props>()
// Emits
interface Emits {
(e: 'save'): void
(e: 'reset'): void
(e: 'toggleEditMode'): void
(e: 'startResize', event: MouseEvent): void
(e: 'contentUpdate', content: string): void
(e: 'imageLoad', dimensions: string): void
(e: 'imageError'): void
}
const emit = defineEmits<Emits>()
// 计算属性
const title = computed(() => {
if (props.config.isImageView) return '🖼️ 图片预览'
if (props.config.isVideoView) return '🎬 视频预览'
if (props.config.isAudioView) return '🎵 音频预览'
if (props.config.isPdfFile) return '📕 PDF 预览'
if (props.config.isHtmlFile) return '🌐 HTML'
if (props.config.isMarkdownFile) return '📝 Markdown'
if (props.config.isBinaryFile) return ' 二进制文件'
return '📝 文件内容'
})
const fileContent = computed(() => props.config.fileContent || '')
const isEditMode = computed(() => props.config.isEditMode || false)
const height = computed(() => props.config.fileContentHeight || 400)
const previewUrl = computed(() => props.config.previewUrl || '')
const currentFileExtension = computed(() => props.config.currentFileExtension || '')
const canSave = computed(() => props.config.canSaveFile || false)
const canReset = computed(() => props.config.canResetContent || false)
const isEditableWithPreview = computed(() => {
const ext = currentFileExtension.value
return ['html', 'htm', 'md', 'markdown'].includes(ext)
})
const isMediaFile = computed(() =>
props.config.isImageView ||
props.config.isVideoView ||
props.config.isAudioView
)
const isPdfFile = computed(() => props.config.isPdfFile)
const isBinary = computed(() => props.config.isBinaryFile)
const mediaFileType = computed(() => {
if (props.config.isImageView) return 'image'
if (props.config.isVideoView) return 'video'
if (props.config.isAudioView) return 'audio'
return 'image'
})
// 事件处理
const handleSave = () => emit('save')
const handleReset = () => emit('reset')
const handleToggleEditMode = () => emit('toggleEditMode')
const handleStartResize = (event: MouseEvent) => emit('startResize', event)
const handleContentUpdate = (content: string) => emit('contentUpdate', content)
const handleMediaLoad = (dimensions: string) => emit('imageLoad', dimensions)
const handleMediaError = () => emit('imageError')
</script>
<style scoped>
.file-editor-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg-1);
border-left: 1px solid var(--color-border);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-2);
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.panel-actions {
display: flex;
gap: 8px;
}
.editor-content {
flex: 1;
overflow: hidden;
position: relative;
}
.preview-pdf {
width: 100%;
height: 100%;
border: none;
}
.resizer {
height: 4px;
background: var(--color-border);
cursor: row-resize;
transition: background 0.2s;
}
.resizer:hover {
background: rgb(var(--primary-6));
}
</style>

View File

@@ -0,0 +1,863 @@
<template>
<div class="file-editor-panel" :style="{ width: width + '%' }">
<div class="panel-header">
<span class="panel-title">
<template v-if="config.isImageView">🖼 图片预览</template>
<template v-else-if="config.isVideoView">🎬 视频预览</template>
<template v-else-if="config.isAudioView">🎵 音频预览</template>
<template v-else-if="config.isPdfFile">📕 PDF 预览</template>
<template v-else-if="config.isHtmlFile">🌐 HTML 预览</template>
<template v-else-if="config.isMarkdownFile">📝 Markdown 预览</template>
<template v-else>📝 文件内容</template>
</span>
<div v-if="config.currentFileName" class="filename-with-copy">
<a-tooltip :content="config.currentFileFullPath" position="left">
<span
class="panel-filename"
:class="{ 'file-outside-dir': !isFileInCurrentDirectory && config.currentFileFullPath }"
>
{{ config.currentFileName }}
</span>
</a-tooltip>
<icon-copy
class="copy-icon"
title="复制路径"
@click="handleCopyPath"
/>
</div>
</div>
<div class="editor-content">
<!-- 二进制文件提示 -->
<div v-if="config.isBinaryFile" class="binary-file-message">
<pre>{{ config.fileContent }}</pre>
</div>
<!-- 图片预览 -->
<div v-else-if="config.isImageView" class="media-preview">
<img
:src="config.previewUrl"
class="preview-image"
@load="handleImageLoad"
@error="handleImageError"
alt="预览"
/>
<div v-if="config.imageLoading" class="media-loading">
<a-spin />
</div>
<div class="media-meta">
<span class="file-name">{{ getFileName(config.currentFileFullPath) }}</span>
<span v-if="config.currentImageDimensions" class="image-dimensions">
{{ config.currentImageDimensions }}
</span>
</div>
</div>
<!-- 视频预览 -->
<div v-else-if="config.isVideoView" class="media-preview">
<video :src="config.previewUrl" controls class="preview-video"></video>
<div class="media-meta">
<a-tag color="arcoblue">🎬 视频</a-tag>
</div>
</div>
<!-- 音频预览 -->
<div v-else-if="config.isAudioView" class="media-preview">
<audio :src="config.previewUrl" controls class="preview-audio"></audio>
<div class="media-meta">
<a-tag color="green">🎵 音频</a-tag>
</div>
</div>
<!-- PDF 预览 -->
<div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf">
<iframe :src="config.previewUrl" class="preview-pdf"></iframe>
<div class="media-meta">
<a-tag color="orangered">📕 PDF</a-tag>
</div>
</div>
<!-- HTML 预览/编辑 -->
<div v-else-if="config.isHtmlFile" class="html-preview-wrapper">
<!-- 编辑模式/预览模式切换按钮 -->
<div class="preview-mode-switch">
<!-- 重置按钮编辑模式且内容变化时显示 -->
<a-tooltip v-if="config.isEditMode && config.canResetContent" position="left" content="恢复原始内容">
<a-button
type="outline"
size="small"
@click="handleReset"
>
<template #icon><icon-undo /></template>
</a-button>
</a-tooltip>
<!-- 保存按钮仅内容变化时显示 -->
<a-tooltip v-if="config.canSaveFile" position="left" content="保存 (Ctrl+S)">
<a-button
type="primary"
size="small"
@click="handleSave"
>
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
<!-- 预览/编辑切换按钮 -->
<a-tooltip
position="left"
:content="getModeSwitchTooltip()"
>
<a-button
type="primary"
size="small"
@click="handleToggleEditMode"
>
<template #icon>
<icon-eye v-if="config.isEditMode" />
<icon-edit v-else />
</template>
</a-button>
</a-tooltip>
</div>
<!-- 预览模式 -->
<iframe
v-if="!config.isEditMode"
class="html-preview-content"
:srcdoc="htmlContentWithTheme"
:key="getCurrentTheme()"
></iframe>
<!-- 编辑模式 -->
<div v-else class="html-edit-wrapper">
<CodeEditor
:model-value="config.fileContent"
:file-extension="config.currentFileExtension"
@update:model-value="handleContentUpdate"
class="code-editor"
/>
</div>
</div>
<!-- Markdown 预览/编辑 -->
<div v-else-if="config.isMarkdownFile" class="markdown-preview-wrapper">
<!-- 编辑模式/预览模式切换按钮 -->
<div class="preview-mode-switch">
<!-- 重置按钮编辑模式且内容变化时显示 -->
<a-tooltip v-if="config.isEditMode && config.canResetContent" position="left" content="恢复原始内容">
<a-button
type="outline"
size="small"
@click="handleReset"
>
<template #icon><icon-undo /></template>
</a-button>
</a-tooltip>
<!-- 保存按钮仅内容变化时显示 -->
<a-tooltip v-if="config.canSaveFile" position="left" content="保存 (Ctrl+S)">
<a-button
type="primary"
size="small"
@click="handleSave"
>
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
<!-- 预览/编辑切换按钮 -->
<a-tooltip
position="left"
:content="getModeSwitchTooltip()"
>
<a-button
type="primary"
size="small"
@click="handleToggleEditMode"
>
<template #icon>
<icon-eye v-if="config.isEditMode" />
<icon-edit v-else />
</template>
</a-button>
</a-tooltip>
</div>
<!-- 预览模式 -->
<div v-if="!config.isEditMode" class="markdown-preview-content markdown-content" v-html="config.rendered"></div>
<!-- 编辑模式 -->
<div v-else class="markdown-edit-wrapper">
<CodeEditor
:model-value="config.fileContent"
:file-extension="config.currentFileExtension"
@update:model-value="handleContentUpdate"
class="code-editor"
/>
<!-- 调整高度的手柄 -->
<div class="resize-handle-v" @mousedown="handleStartResize" title="拖拽调整高度">
<div class="resize-dots"></div>
</div>
</div>
</div>
<!-- 文本编辑器带代码高亮 -->
<div v-else class="text-editor-wrapper">
<!-- 编辑操作按钮 -->
<div class="preview-mode-switch">
<!-- 重置按钮内容变化时显示 -->
<a-tooltip v-if="config.canResetContent" position="left" content="恢复原始内容">
<a-button
type="outline"
size="small"
@click="handleReset"
>
<template #icon><icon-undo /></template>
</a-button>
</a-tooltip>
<!-- 保存按钮内容变化时显示 -->
<a-tooltip v-if="config.canSaveFile" position="left" content="保存 (Ctrl+S)">
<a-button
type="primary"
size="small"
@click="handleSave"
>
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
<!-- 预览按钮始终显示但根据文件类型决定是否可用 -->
<a-tooltip
position="left"
:content="getPreviewButtonTooltip()"
>
<a-button
type="primary"
size="small"
:disabled="!config.canPreviewFile"
@click="handleToggleEditMode"
>
<template #icon><icon-eye /></template>
</a-button>
</a-tooltip>
</div>
<CodeEditor
:model-value="config.fileContent"
:file-extension="config.currentFileExtension"
@update:model-value="handleContentUpdate"
class="code-editor"
/>
<!-- 调整高度的手柄 -->
<div class="resize-handle-v" @mousedown="handleStartResize" title="拖拽调整高度">
<div class="resize-dots"></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch, nextTick } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon'
import CodeEditor from '@/components/CodeEditor.vue'
import { getFileName } from '@/utils/fileUtils'
import type { FileEditorPanelConfig } from '@/types/file-system'
import { renderMermaidDiagrams } from '@/utils/markedExtensions'
// Props
interface Props {
config: FileEditorPanelConfig
width: number
currentDirectory?: string
}
const props = withDefaults(defineProps<Props>(), {
currentDirectory: ''
})
// Emits
interface Emits {
(e: 'save'): void
(e: 'reset'): void
(e: 'toggleEditMode'): void
(e: 'startResize', event: MouseEvent): void
(e: 'contentUpdate', content: string): void
(e: 'imageLoad', dimensions: string): void
(e: 'imageError'): void
}
const emit = defineEmits<Emits>()
// 获取当前主题
const getCurrentTheme = () => {
return document.body.getAttribute('arco-theme') || 'light'
}
// 生成带主题样式的 HTML 内容
const htmlContentWithTheme = computed(() => {
if (!props.config.rendered || props.config.isEditMode) return ''
const theme = getCurrentTheme()
const bgColor = theme === 'dark' ? '#1a1a1a' : '#ffffff'
const textColor = theme === 'dark' ? '#e8e8e8' : '#333333'
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
padding: 20px;
background-color: ${bgColor};
color: ${textColor};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
}
a { color: ${theme === 'dark' ? '#4e9af1' : '#0066cc'}; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid ${theme === 'dark' ? '#444' : '#ddd'}; padding: 8px; }
th { background-color: ${theme === 'dark' ? '#333' : '#f2f2f2'}; }
code { background-color: ${theme === 'dark' ? '#333' : '#f4f4f4'}; padding: 2px 6px; border-radius: 3px; }
pre { background-color: ${theme === 'dark' ? '#2a2a2a' : '#f4f4f4'}; padding: 12px; border-radius: 6px; overflow-x: auto; }
pre code { background-color: transparent; padding: 0; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>${props.config.rendered}</body>
</html>
`
})
// 计算属性:判断文件是否在当前目录
const isFileInCurrentDirectory = computed(() => {
if (!props.config.currentFileFullPath || !props.currentDirectory) {
return false
}
// 提取文件的父目录
const lastBackslash = props.config.currentFileFullPath.lastIndexOf('\\')
const lastSlash = props.config.currentFileFullPath.lastIndexOf('/')
const lastSeparator = Math.max(lastBackslash, lastSlash)
if (lastSeparator === -1) {
return false
}
const fileDir = props.config.currentFileFullPath.substring(0, lastSeparator)
// 标准化路径进行比较(将 \ 替换为 /,移除末尾的 /
const fileDirNormalized = fileDir.replace(/\\/g, '/').replace(/\/$/, '')
const currentPathNormalized = props.currentDirectory.replace(/\\/g, '/').replace(/\/$/, '')
return fileDirNormalized === currentPathNormalized
})
// 事件处理
const handleSave = () => {
emit('save')
}
const handleReset = () => {
emit('reset')
}
const handleToggleEditMode = () => {
emit('toggleEditMode')
}
const handleStartResize = (event: MouseEvent) => {
emit('startResize', event)
}
const handleContentUpdate = (content: string) => {
emit('contentUpdate', content)
}
const handleImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
if (img.naturalWidth && img.naturalHeight) {
const dimensions = `${img.naturalWidth} × ${img.naturalHeight}`
emit('imageLoad', dimensions)
}
}
const handleImageError = () => {
emit('imageError')
}
// 监听模式切换,切换到预览模式时渲染 Mermaid 图表
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
// 从编辑模式切换到预览模式
if (oldVal && !newVal && props.config.isMarkdownFile) {
await nextTick()
try {
await renderMermaidDiagrams()
} catch (error) {
console.error('[FileEditorPanel] Mermaid 渲染失败:', error)
}
}
})
// 获取模式切换按钮的提示文本
const getModeSwitchTooltip = () => {
if (props.config.isEditMode) {
return '切换到预览'
}
return '切换到编辑'
}
// 获取预览按钮的提示文本(用于普通文本文件)
const getPreviewButtonTooltip = () => {
if (!props.config.canPreviewFile) {
return '该文件类型不支持预览'
}
return '切换到预览'
}
// 复制文件路径
const handleCopyPath = () => {
const path = props.config.currentFileFullPath
if (!path) return
navigator.clipboard.writeText(path).then(() => {
Message.success('路径已复制')
}).catch(() => {
// 降级方案
const input = document.createElement('input')
input.value = path
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
Message.success('路径已复制')
})
}
</script>
<style scoped>
.file-editor-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg-1);
}
.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;
gap: 12px;
}
.panel-title {
color: var(--color-text-1);
}
.filename-with-copy {
display: flex;
align-items: center;
gap: 4px;
}
.panel-filename {
font-size: 12px;
color: var(--color-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
}
.file-location-hint {
color: var(--color-text-4);
font-style: italic;
}
.copy-icon {
font-size: 14px;
cursor: pointer;
color: var(--color-text-3);
flex-shrink: 0;
}
.copy-icon:hover {
color: rgb(var(--primary-6));
}
.editor-content {
flex: 1;
overflow: auto;
position: relative;
}
/* 媒体预览 */
.media-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 20px;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.preview-video,
.preview-audio {
max-width: 100%;
max-height: 80%;
border-radius: 8px;
}
.preview-pdf {
width: 100%;
height: 100%;
border: none;
}
.media-preview-pdf {
padding: 0;
}
.media-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.media-meta {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
background: var(--color-bg-2);
border-radius: 6px;
}
.file-name {
font-size: 12px;
color: var(--color-text-2);
}
.image-dimensions {
font-size: 11px;
color: var(--color-text-3);
}
/* 预览/编辑切换 */
.html-preview-wrapper,
.markdown-preview-wrapper {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.preview-mode-switch {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
gap: 8px;
}
.html-preview-content {
width: 100%;
height: 100%;
border: none;
background: var(--color-bg-1);
}
.markdown-preview-content {
flex: 1;
padding: 20px;
overflow-y: auto;
overflow-x: hidden;
background: var(--color-bg-1);
color: var(--color-text-1);
min-height: 0;
/* 确保内容不会被截断 */
position: relative;
}
/* Mermaid 图表样式 - 确保不截断后续内容 */
.markdown-preview-content :deep(pre.mermaid) {
display: block;
margin: 20px 0;
background: transparent;
overflow: visible;
}
.markdown-preview-content :deep(.mermaid) {
display: block;
margin: 20px 0;
overflow: visible;
}
.markdown-preview-content :deep(.mermaid svg) {
display: block;
max-width: 100%;
height: auto;
}
.markdown-preview-content :deep(h1) {
font-size: 2em;
margin: 0.5em 0;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--color-border-2);
color: var(--color-text-1);
}
.markdown-preview-content :deep(h2) {
font-size: 1.5em;
margin: 0.5em 0;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--color-border-2);
color: var(--color-text-1);
}
.markdown-preview-content :deep(h3),
.markdown-preview-content :deep(h4),
.markdown-preview-content :deep(h5),
.markdown-preview-content :deep(h6) {
margin: 0.5em 0;
color: var(--color-text-1);
}
.markdown-preview-content :deep(p) {
margin: 1em 0;
line-height: 1.6;
}
.markdown-preview-content :deep(a) {
color: rgb(var(--primary-6));
text-decoration: none;
}
.markdown-preview-content :deep(a:hover) {
text-decoration: underline;
}
.markdown-preview-content :deep(code) {
background: var(--color-fill-2);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
color: var(--color-text-1);
}
.markdown-preview-content :deep(pre) {
background: var(--color-fill-1);
padding: 12px;
border-radius: 6px;
overflow: auto;
border: 1px solid var(--color-border-2);
}
.markdown-preview-content :deep(pre code) {
background: none;
padding: 0;
color: var(--color-text-1);
}
.markdown-preview-content :deep(blockquote) {
margin: 1em 0;
padding-left: 1em;
border-left: 4px solid var(--primary-6);
color: var(--color-text-3);
}
.markdown-preview-content :deep(ul),
.markdown-preview-content :deep(ol) {
margin: 1em 0;
padding-left: 2em;
}
.markdown-preview-content :deep(li) {
margin: 0.5em 0;
}
.markdown-preview-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-preview-content :deep(th),
.markdown-preview-content :deep(td) {
border: 1px solid var(--color-border-2);
padding: 8px 12px;
}
.markdown-preview-content :deep(th) {
background: var(--color-fill-2);
font-weight: 600;
}
.markdown-preview-content :deep(tr:hover) {
background: var(--color-fill-1);
}
.markdown-preview-content :deep(img) {
max-width: 100%;
height: auto;
}
.html-edit-wrapper,
.markdown-edit-wrapper,
.text-editor-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.code-editor {
flex: 1;
min-height: 0;
}
/* 调整手柄 */
.resize-handle-v {
height: 4px;
cursor: row-resize;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-fill-2);
transition: background 0.2s;
flex-shrink: 0;
}
.resize-handle-v:hover {
background: var(--color-primary-light-1);
}
.resize-dots {
display: flex;
gap: 2px;
}
.resize-dots::before,
.resize-dots::after {
content: '';
width: 4px;
height: 4px;
background: var(--color-text-3);
border-radius: 50%;
}
/* 二进制文件提示 */
.binary-file-message {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 20px;
}
.binary-file-message pre {
margin: 0;
padding: 20px;
background: var(--color-fill-1);
border-radius: 8px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-2);
white-space: pre;
overflow-x: auto;
max-width: 100%;
}
/* ========== Markdown 预览内容样式 ========== */
/* 代码高亮 & Mermaid 图表 - 共享基础样式 */
.markdown-preview-content :deep(.hljs),
.markdown-preview-content :deep(.mermaid) {
padding: 12px;
border-radius: 6px;
overflow: auto;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
}
.markdown-preview-content :deep(.hljs) {
background: transparent;
}
.markdown-preview-content :deep(pre code.hljs) {
background: transparent;
padding: 0;
color: inherit;
display: block;
}
.markdown-preview-content :deep(.mermaid text) {
fill: var(--color-text-1);
}
/* ========== 深色模式适配 ========== */
/* Mermaid 图表深色模式 */
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid) {
background: rgba(255, 255, 255, 0.05);
}
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid *) {
color: var(--color-text-1) !important;
stroke: var(--color-text-1) !important;
}
/* 代码高亮深色模式 - 使用 CSS 自定义属性 */
body[arco-theme*='dark'] .markdown-preview-content {
--hljs-string: #98c379;
--hljs-literal: #98c379;
--hljs-subst: #98c379;
--hljs-number: #d19a66;
--hljs-built_in: #d19a66;
--hljs-symbol: #61afef;
--hljs-attr: #e6c07a;
--hljs-type: #e5c07b;
--hljs-meta: #7f848e;
--hljs-deletion: #f56c6c;
--hljs-addition: #67c23a;
--hljs-link: #409eff;
}
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-string) { color: var(--hljs-string); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-literal) { color: var(--hljs-literal); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-subst) { color: var(--hljs-subst); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-number) { color: var(--hljs-number); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-built_in) { color: var(--hljs-built_in); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-symbol) { color: var(--hljs-symbol); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-attr) { color: var(--hljs-attr); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-type) { color: var(--hljs-type); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-meta) { color: var(--hljs-meta); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-deletion) { color: var(--hljs-deletion); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-addition) { color: var(--hljs-addition); }
body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
color: var(--hljs-link);
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<div
class="file-item-row"
:class="{
'file-item-selected': isSelected,
'file-item-editing': isEditing
}"
:data-file-path="file.path"
@click="handleClick"
@dblclick="handleDoubleClick"
@contextmenu.prevent="handleContextMenu"
>
<!-- 文件图标 -->
<span class="file-item-icon">{{ icon }}</span>
<!-- 编辑状态 -->
<a-input
v-if="isEditing"
:model-value="editingName"
size="mini"
class="file-name-edit-input"
@update:model-value="handleNameUpdate"
@blur="handleSave"
@keyup.enter="handleSave"
@keyup.esc="handleCancel"
@click.stop
ref="inputRef"
/>
<!-- 正常显示状态 -->
<span v-else class="file-item-name" :title="file.name">{{ file.name }}</span>
<!-- 文件大小 -->
<span v-if="!file.is_dir && !isEditing" class="file-item-size">
{{ formattedSize }}
</span>
<!-- 收藏按钮 -->
<a-button
v-if="!isEditing"
type="text"
size="mini"
@click.stop="handleToggleFavorite"
class="file-item-fav"
>
<template #icon>
<icon-star-fill v-if="isFavorited" :style="{ color: '#ffcd00' }" />
<icon-star v-else />
</template>
</a-button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
import { formatBytes } from '@/utils/fileUtils'
import { getFileIcon } from '@/utils/fileUtils'
import type { FileItem } from '@/types/file-system'
// Props
interface Props {
file: FileItem
isSelected: boolean
isEditing: boolean
editingName?: string
isFavorited: boolean
}
const props = withDefaults(defineProps<Props>(), {
editingName: ''
})
// Emits
interface Emits {
(e: 'click', file: FileItem): void
(e: 'doubleClick', file: FileItem): void
(e: 'toggleFavorite', file: FileItem): void
(e: 'save', newName: string): void
(e: 'cancel'): void
(e: 'nameUpdate', newName: string): void
(e: 'contextMenu', event: MouseEvent, file: FileItem): void
}
const emit = defineEmits<Emits>()
// Refs
const inputRef = ref()
// 监听编辑状态变化,自动聚焦
watch(() => props.isEditing, (newVal) => {
if (newVal) {
nextTick(() => {
focusInput()
})
}
})
// 计算属性
const icon = computed(() => getFileIcon(props.file))
const formattedSize = computed(() => formatBytes(props.file.size))
// 事件处理
const handleClick = () => {
emit('click', props.file)
}
const handleDoubleClick = () => {
emit('doubleClick', props.file)
}
const handleToggleFavorite = () => {
emit('toggleFavorite', props.file)
}
const handleNameUpdate = (value: string) => {
emit('nameUpdate', value)
}
const handleSave = () => {
emit('save', props.editingName || props.file.name)
}
const handleCancel = () => {
emit('cancel')
}
const handleContextMenu = (event: MouseEvent) => {
emit('contextMenu', event, props.file)
}
// 聚焦到输入框并选中文本
const focusInput = () => {
const input = inputRef.value?.$el?.querySelector('input')
if (input) {
input.focus()
// 选中文件名部分(不包括扩展名)
const value = input.value
const lastDotIndex = value.lastIndexOf('.')
// 如果有扩展名,只选中文件名部分;否则选中全部
if (lastDotIndex > 0) {
input.setSelectionRange(0, lastDotIndex)
} else {
input.select()
}
}
}
// 暴露方法供父组件调用
const focus = () => {
nextTick(() => {
focusInput()
})
}
const selectAll = () => {
nextTick(() => {
const input = inputRef.value?.$el?.querySelector('input')
if (input) {
input.select()
}
})
}
defineExpose({
focus,
selectAll
})
</script>
<style scoped>
.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);
user-select: none;
}
.file-item-row:hover {
background: var(--color-fill-2);
}
.file-item-row.file-item-selected {
background: var(--color-fill-3) !important;
font-weight: 500;
}
.file-item-row:last-child {
border-bottom: none;
}
.file-item-row.file-item-editing {
background: var(--color-fill-1);
}
.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);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.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;
}
.file-name-edit-input {
flex: 1;
font-size: 13px;
min-width: 0;
}
.file-name-edit-input :deep(.arco-input) {
font-size: 13px;
padding: 0 8px;
height: 24px;
line-height: 24px;
}
/* 编辑状态下的样式调整 */
.file-item-row.file-item-editing .file-item-fav {
display: none;
}
.file-item-row.file-item-editing .file-item-size {
display: none;
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<div class="file-list-panel" :style="{ width: width + '%' }">
<div class="panel-header">
<span class="panel-title">📋 文件列表</span>
<span class="panel-count">{{ config.fileList.length }} </span>
</div>
<div
class="file-list-wrapper"
@contextmenu.prevent="handleContextMenu"
>
<!-- 文件列表 -->
<a-list
v-if="config.fileList.length > 0 || config.fileLoading"
:data="config.fileList"
:loading="config.fileLoading"
:bordered="false"
:pagination="false"
class="compact-list"
>
<template #item="{ item }">
<FileItemRow
:file="item"
:is-selected="isSelected(item)"
:is-editing="isEditing(item)"
:editing-name="props.config.editingFileName"
:is-favorited="isFavorited(item.path)"
@click="handleFileClick"
@double-click="handleFileDoubleClick"
@toggle-favorite="handleToggleFavorite"
@save="handleSaveEditing"
@cancel="handleCancelEditing"
@name-update="handleNameUpdate"
@context-menu="handleItemContextMenu"
ref="fileItemRefs"
/>
</template>
</a-list>
<!-- 空状态 -->
<div v-if="config.fileList.length === 0 && !config.fileLoading" class="empty-state">
<span style="font-size: 32px">📭</span>
<span>此文件夹为空</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FileItemRow from './FileItemRow.vue'
import type { FileListPanelConfig, FileItem } from '@/types/file-system'
// Props
interface Props {
config: FileListPanelConfig
width: number
favorites: string[]
}
const props = defineProps<Props>()
// Emits
interface Emits {
(e: 'fileClick', file: FileItem): void
(e: 'fileDoubleClick', file: FileItem): void
(e: 'toggleFavorite', file: FileItem): void
(e: 'startEditing', path: string, name: string): void
(e: 'saveEditing', path: string, newName: string): void
(e: 'cancelEditing'): void
(e: 'contextMenu', event: MouseEvent, file: FileItem | null): void
(e: 'nameUpdate', newName: string): void
}
const emit = defineEmits<Emits>()
// Refs
const fileItemRefs = ref()
// 计算辅助方法
const isSelected = (item: FileItem): boolean => {
return props.config.selectedFileItem?.path === item.path
}
const isEditing = (item: FileItem): boolean => {
return props.config.editingFilePath === item.path
}
const isFavorited = (path: string): boolean => {
return props.favorites.includes(path)
}
// 事件处理
const handleFileClick = (file: FileItem) => {
emit('fileClick', file)
}
const handleFileDoubleClick = (file: FileItem) => {
emit('fileDoubleClick', file)
}
const handleToggleFavorite = (file: FileItem) => {
emit('toggleFavorite', file)
}
const handleNameUpdate = (newName: string) => {
emit('nameUpdate', newName)
}
const handleSaveEditing = (newName: string) => {
if (props.config.editingFilePath) {
emit('saveEditing', props.config.editingFilePath, newName)
}
}
const handleCancelEditing = () => {
emit('cancelEditing')
}
const handleItemContextMenu = (event: MouseEvent, file: FileItem) => {
emit('contextMenu', event, file)
}
const handleContextMenu = (event: MouseEvent) => {
// 检查点击的是哪个文件项
const target = event.target as HTMLElement
const listItem = target.closest('.arco-list-item')
if (listItem) {
// 找到对应的文件索引
const items = document.querySelectorAll('.arco-list-item')
const index = Array.from(items).indexOf(listItem)
if (index !== -1 && index < props.config.fileList.length) {
const clickedFile = props.config.fileList[index]
emit('contextMenu', event, clickedFile)
return
}
}
// 如果没有点击文件项,传递空白区域事件
emit('contextMenu', event, null)
}
// 暴露方法供父组件调用
const focusEditingItem = () => {
const index = props.config.fileList.findIndex(
item => item.path === props.config.editingFilePath
)
if (index !== -1 && fileItemRefs.value?.[index]) {
const item = fileItemRefs.value[index]
item.focus?.()
item.selectAll?.()
}
}
defineExpose({
focusEditingItem
})
</script>
<style scoped>
.file-list-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg-1);
}
.panel-header {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-bg-2);
flex-shrink: 0;
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.panel-count {
font-size: 12px;
color: var(--color-text-3);
}
.file-list-wrapper {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.compact-list :deep(.arco-list-item) {
padding: 0;
border: none;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--color-text-3);
gap: 8px;
}
.empty-state span:nth-child(2) {
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<transition name="slide">
<div v-show="config.visible" class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title"> 收藏夹</span>
<span class="sidebar-count">{{ config.favoriteFiles.length }}</span>
</div>
<div class="sidebar-content">
<div
v-for="(fav, index) in config.favoriteFiles"
:key="fav.path"
class="sidebar-item"
:class="{
'sidebar-item-dragging': config.draggingState.isDragging && config.draggingState.draggedIndex === index,
'sidebar-item-drag-over': config.draggingState.isDragging && config.draggingState.draggedIndex !== index
}"
:draggable="config.draggingState.isDragging && config.draggingState.draggedIndex === index"
@click="handleOpenFavorite(fav)"
@mousedown="handleLongPressStart($event, index)"
@mouseup="handleLongPressCancel"
@mouseleave="handleLongPressCancel"
@touchstart="handleLongPressStart($event, index)"
@touchend="handleLongPressCancel"
@touchcancel="handleLongPressCancel"
@dragstart="handleDragStart($event, index)"
@dragover="handleDragOver($event)"
@drop="handleDrop($event, index)"
@dragend="handleDragEnd"
>
<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="handleRemoveFavorite(fav)"
class="sidebar-item-remove"
>
<template #icon>
<icon-close />
</template>
</a-button>
</div>
<div v-if="config.favoriteFiles.length === 0" class="sidebar-empty">
<icon-star />
<span>暂无收藏</span>
<span class="sidebar-hint">点击文件列表中的星标收藏</span>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
// Props
interface Props {
config: SidebarConfig
}
const props = defineProps<Props>()
// Emits
interface Emits {
(e: 'openFavorite', file: FavoriteFile): void
(e: 'removeFavorite', path: string): void
(e: 'longPressStart', event: MouseEvent | TouchEvent, index: number): void
(e: 'longPressCancel'): void
(e: 'dragStart', event: DragEvent, index: number): void
(e: 'dragOver', event: DragEvent): void
(e: 'drop', event: DragEvent, targetIndex: number): void
(e: 'dragEnd'): void
}
const emit = defineEmits<Emits>()
// 图标导入
import { IconStar, IconClose } from '@arco-design/web-vue/es/icon'
// 事件处理
const handleOpenFavorite = (file: FavoriteFile) => {
emit('openFavorite', file)
}
const handleRemoveFavorite = (file: FavoriteFile) => {
emit('removeFavorite', file.path)
}
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
emit('longPressStart', event, index)
}
const handleLongPressCancel = () => {
emit('longPressCancel')
}
const handleDragStart = (event: DragEvent, index: number) => {
emit('dragStart', event, index)
}
const handleDragOver = (event: DragEvent) => {
emit('dragOver', event)
}
const handleDrop = (event: DragEvent, targetIndex: number) => {
emit('drop', event, targetIndex)
}
const handleDragEnd = () => {
emit('dragEnd')
}
</script>
<style scoped>
.sidebar {
width: 220px;
height: 100%;
background: var(--color-bg-1);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-bg-2);
}
.sidebar-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.sidebar-count {
font-size: 12px;
color: var(--color-text-3);
background: var(--color-fill-2);
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 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
user-select: none;
}
.sidebar-item:hover {
background: var(--color-fill-2);
}
.sidebar-item-dragging {
opacity: 0.5;
background: var(--color-fill-1);
}
.sidebar-item-drag-over {
background: var(--color-fill-3);
border: 2px dashed var(--color-border-3);
}
.sidebar-item-icon {
font-size: 18px;
width: 24px;
text-align: center;
}
.sidebar-item-name {
flex: 1;
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-item-remove {
opacity: 0;
transition: opacity 0.2s;
}
.sidebar-item:hover .sidebar-item-remove {
opacity: 1;
}
.sidebar-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
color: var(--color-text-3);
gap: 8px;
}
.sidebar-empty :first-child {
font-size: 48px;
opacity: 0.5;
}
.sidebar-empty :nth-child(2) {
font-size: 14px;
}
.sidebar-hint {
font-size: 12px;
color: var(--color-text-4);
margin-top: 4px;
}
/* 滑动动画 */
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-leave-to {
transform: translateX(-100%);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div class="toolbar">
<div class="toolbar-left">
<!-- 路径输入 -->
<div class="path-input-wrapper">
<!-- ZIP 浏览模式显示 ZIP 路径和面包屑 -->
<div v-if="config.isBrowsingZip" class="zip-breadcrumb">
<a-tag size="small" class="zip-file-tag" @click="handleNavigateToZipRoot">
📦 {{ config.zipFileName }}
</a-tag>
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
<icon-right class="breadcrumb-separator" />
<a-tag
v-for="(crumb, index) in config.zipBreadcrumbs"
:key="index"
size="small"
class="breadcrumb-tag"
@click="handleNavigateToZipDirectory(crumb.path)"
>
{{ crumb.name }}
</a-tag>
</template>
<a-button size="small" type="outline" @click="handleExitZip">
<template #icon><icon-close /></template>
退出 ZIP
</a-button>
</div>
<!-- 正常模式路径输入 -->
<a-auto-complete
v-else
:model-value="normalizedPath"
:data="normalizedPathHistory"
placeholder="输入路径 (如: C:/Users)"
class="path-input"
@select="handlePathSelect"
@pressEnter="handlePathSelect"
@update:model-value="handlePathUpdate"
>
<template #append>
<a-tooltip content="复制路径" position="top">
<div class="copy-icon-wrapper" @click="handleCopyPath">
<icon-copy />
</div>
</a-tooltip>
</template>
</a-auto-complete>
</div>
</div>
<div class="toolbar-right">
<!-- 快捷路径下拉 -->
<a-dropdown v-if="!config.isBrowsingZip">
<a-button size="small">
<template #icon>
<icon-forward />
</template>
快捷访问
</a-button>
<template #content>
<a-doption
v-for="shortcut in config.commonPaths"
:key="shortcut.path"
@click="handleGoToPath(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 config.pathHistory.slice(0, 10)"
:key="path"
@click="handleGoToPath(path)"
>
{{ path }}
</a-doption>
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
</template>
</a-dropdown>
<!-- 刷新按钮 -->
<a-button
type="primary"
size="small"
:loading="config.fileLoading"
@click="handleRefresh"
>
<template #icon>
<icon-refresh />
</template>
刷新
</a-button>
<!-- 切换侧边栏 -->
<a-button
size="small"
:type="config.showSidebar ? 'primary' : 'text'"
@click="handleToggleSidebar"
>
<template #icon>
<icon-menu />
</template>
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy } from '@arco-design/web-vue/es/icon'
import type { ToolbarConfig } from '@/types/file-system'
// Props
interface Props {
config: ToolbarConfig
}
const props = defineProps<Props>()
// Emits
interface Emits {
(e: 'update:filePath', path: string): void
(e: 'update:showSidebar', show: boolean): void
(e: 'refresh'): void
(e: 'exitZip'): void
(e: 'goToPath', path: string): void
(e: 'navigateToZipDirectory', path: string): void
(e: 'showMessage', message: string, type: 'success' | 'error' | 'warning' | 'info'): void
}
const emit = defineEmits<Emits>()
// 将反斜杠转换为正斜杠显示
const normalizedPath = computed(() => {
return props.config.filePath?.replace(/\\/g, '/') || ''
})
const normalizedPathHistory = computed(() => {
return props.config.pathHistory.map(path => path.replace(/\\/g, '/'))
})
// 事件处理
const handlePathUpdate = (path: string) => {
emit('update:filePath', path)
}
const handlePathSelect = (value: string) => {
emit('goToPath', value)
}
const handleGoToPath = (path: string) => {
emit('goToPath', path)
}
const handleRefresh = () => {
emit('refresh')
}
const handleExitZip = () => {
emit('exitZip')
}
const handleNavigateToZipRoot = () => {
emit('navigateToZipDirectory', '')
}
const handleNavigateToZipDirectory = (path: string) => {
emit('navigateToZipDirectory', path)
}
const handleToggleSidebar = () => {
emit('update:showSidebar', !props.config.showSidebar)
}
const handleCopyPath = async () => {
const path = props.config.filePath
if (!path) return
try {
await navigator.clipboard.writeText(path)
emit('showMessage', '路径已复制', 'success')
} catch {
const input = document.createElement('input')
input.value = path
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
emit('showMessage', '路径已复制', 'success')
}
}
</script>
<style scoped>
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
gap: 12px;
}
.toolbar-left {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.path-input-wrapper {
flex: 1;
min-width: 200px;
}
.path-input {
width: 100%;
}
/* 覆盖 Arco 输入框 append 的默认 padding */
.path-input-wrapper :deep(.arco-input-append) {
padding: 0 !important;
}
.copy-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
width: 100%;
height: 100%;
cursor: pointer;
color: var(--color-text-3);
font-size: 14px;
transition: all 0.2s;
}
.copy-icon-wrapper:hover {
color: rgb(var(--primary-6));
background: var(--color-fill-2);
}
.zip-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.zip-file-tag {
cursor: pointer;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
background: var(--color-fill-2);
border-color: var(--color-border-2);
transition: all 0.2s;
}
.zip-file-tag:hover {
background: var(--color-fill-3);
border-color: rgb(var(--primary-6));
}
.breadcrumb-separator {
color: var(--color-text-3);
font-size: 12px;
flex-shrink: 0;
}
.breadcrumb-tag {
cursor: pointer;
font-size: 12px;
background: var(--color-fill-1);
border-color: var(--color-border-2);
transition: all 0.2s;
}
.breadcrumb-tag:hover {
background: var(--color-fill-2);
border-color: rgb(var(--primary-6));
}
.zip-path-text {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,94 @@
/**
* 系统常用路径 Composable
* 提供系统路径获取和快捷访问路径管理
*/
import { ref } from 'vue'
import { PATH_ICONS } from '@/utils/constants'
import type { ShortcutPath } from '@/types/file-system'
export function useCommonPaths() {
// 系统路径
const commonPaths = ref<ShortcutPath[]>([])
const systemPaths = ref<Record<string, string>>({})
/**
* 加载常用系统路径
*/
const loadCommonPaths = async () => {
try {
// 检查 Wails API 是否可用
if (!window.go?.main?.App?.GetCommonPaths) {
// 降级方案:使用默认路径
commonPaths.value = [
{ name: '💿 C盘', path: 'C:\\' },
{ name: '💿 D盘', path: 'D:\\' }
]
return
}
const paths = await window.go.main.App.GetCommonPaths()
if (!paths) {
throw new Error('无法获取系统路径')
}
systemPaths.value = paths
const platform = window.navigator.platform
const pathList: ShortcutPath[] = []
if (platform.includes('Win')) {
// Windows: 先添加基础路径,再添加所有盘符
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home })
// 动态添加所有盘符(按字母顺序)
const drives: Array<{ letter: string; path: string }> = []
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: `${PATH_ICONS.DRIVE} ${drive.letter}`,
path: drive.path
})
})
} else {
// macOS/Linux: 使用系统路径
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
}
commonPaths.value = pathList.length > 0 ? pathList : [
{ name: '💿 C盘', path: 'C:\\' },
{ name: '💿 D盘', path: 'D:\\' }
]
} catch (error) {
console.error('加载系统路径失败:', error)
// 降级方案
commonPaths.value = [
{ name: '💿 C盘', path: 'C:\\' },
{ name: '💿 D盘', path: 'D:\\' }
]
}
}
return {
commonPaths,
systemPaths,
loadCommonPaths
}
}

View File

@@ -0,0 +1,231 @@
/**
* 收藏夹管理 Composable
* 提供收藏文件的添加、删除、排序等功能
*/
import { ref, watch } from 'vue'
import { STORAGE_KEYS } from '@/utils/constants'
import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system'
export function useFavorites() {
// 收藏列表
const favorites = ref<FavoriteFile[]>([])
// 拖拽状态
const draggingState = ref<DraggingState>({
isDragging: false,
draggedIndex: -1,
pressedIndex: -1
})
/**
* 从 localStorage 加载收藏列表
*/
const loadFavorites = () => {
try {
const stored = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES)
if (stored) {
favorites.value = JSON.parse(stored)
}
} catch (error) {
console.error('加载收藏列表失败:', error)
}
}
/**
* 保存收藏列表到 localStorage
*/
const saveFavorites = () => {
try {
localStorage.setItem(STORAGE_KEYS.FAVORITE_FILES, JSON.stringify(favorites.value))
} catch (error) {
console.error('保存收藏列表失败:', error)
}
}
/**
* 添加收藏
*/
const addFavorite = (file: FileItem) => {
// 检查是否已存在
const exists = favorites.value.some(fav => fav.path === file.path)
if (exists) {
return false
}
favorites.value.push({
...file,
addedAt: Date.now()
} as FavoriteFile)
saveFavorites()
return true
}
/**
* 标准化路径用于比较(处理正斜杠/反斜杠不一致)
*/
const normalizePath = (path: string): string => {
return path.replace(/\\/g, '/').toLowerCase()
}
/**
* 删除收藏
*/
const removeFavorite = (path: string) => {
const normalizedPath = normalizePath(path)
const index = favorites.value.findIndex(fav => normalizePath(fav.path) === normalizedPath)
if (index !== -1) {
favorites.value.splice(index, 1)
saveFavorites()
}
}
/**
* 切换收藏状态
*/
const toggleFavorite = (file: FileItem) => {
const exists = isFavorite(file.path)
if (exists) {
removeFavorite(file.path)
return false
} else {
addFavorite(file)
return true
}
}
/**
* 检查是否已收藏
*/
const isFavorite = (path: string): boolean => {
const normalizedPath = normalizePath(path)
return favorites.value.some(fav => normalizePath(fav.path) === normalizedPath)
}
/**
* 长按开始
*/
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
const isMouse = event instanceof MouseEvent
const isTouch = event instanceof TouchEvent
// 只支持鼠标左键或触摸
if (isMouse && event.button !== 0) return
if (!isMouse && !isTouch) return
draggingState.value.pressedIndex = index
draggingState.value.draggedIndex = index
}
/**
* 长按取消
*/
const onLongPressCancel = () => {
if (!draggingState.value.isDragging) {
draggingState.value.pressedIndex = -1
draggingState.value.draggedIndex = -1
}
}
/**
* 拖拽开始
*/
const onDragStart = (event: DragEvent, index: number) => {
draggingState.value.isDragging = true
draggingState.value.draggedIndex = index
// 设置拖拽数据
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', index.toString())
}
}
/**
* 拖拽经过
*/
const onDragOver = (event: DragEvent) => {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
/**
* 放置
*/
const onDrop = (event: DragEvent, targetIndex: number) => {
event.preventDefault()
const fromIndex = draggingState.value.draggedIndex
const toIndex = targetIndex
if (fromIndex === toIndex || fromIndex === -1) {
resetDragging()
return
}
// 移动元素
const item = favorites.value.splice(fromIndex, 1)[0]
favorites.value.splice(toIndex, 0, item)
saveFavorites()
resetDragging()
}
/**
* 拖拽结束
*/
const onDragEnd = () => {
resetDragging()
}
/**
* 重置拖拽状态
*/
const resetDragging = () => {
draggingState.value.isDragging = false
draggingState.value.draggedIndex = -1
draggingState.value.pressedIndex = -1
}
/**
* 重新排序
*/
const reorder = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return
const item = favorites.value.splice(fromIndex, 1)[0]
favorites.value.splice(toIndex, 0, item)
saveFavorites()
}
// 组件挂载时加载收藏列表
loadFavorites()
return {
// 状态
favorites,
draggingState,
// 方法
addFavorite,
removeFavorite,
toggleFavorite,
isFavorite,
// 拖拽方法
onLongPressStart,
onLongPressCancel,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
reorder,
// 工具方法
loadFavorites,
saveFavorites,
resetDragging
}
}

View File

@@ -0,0 +1,576 @@
/**
* 文件编辑 Composable
* 提供文件编辑相关的逻辑,包括草稿管理、保存、撤销等
*/
import { ref, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { STORAGE_KEYS, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { useFileOperations } from './useFileOperations'
export interface UseFileEditOptions {
currentFilePath?: any
currentDirectory?: any
}
// 文件大小限制5MB
const MAX_TEXT_FILE_SIZE = 5 * 1024 * 1024 // 5MB
export function useFileEdit(options: UseFileEditOptions = {}) {
const { currentFilePath = ref(''), currentDirectory = ref('') } = options
// 文件内容
const fileContent = ref('')
const originalContent = ref('')
// 编辑状态
const isEditMode = ref(false)
const fileContentHeight = ref(400)
const isBinaryFile = ref(false)
// 草稿管理
const draftKey = ref('')
// 保存状态
const isSaving = ref(false)
// 使用文件操作 composable
const { readFile, writeFile } = useFileOperations({
onSuccess: (operation, data) => {
// 可以在这里添加成功处理逻辑
},
onError: (operation, error) => {
Message.error(`${operation} 失败: ${error.message}`)
}
})
/**
* 获取文件路径(从 FileItem 对象或字符串中提取)
*/
const getFilePath = (input: any): string => {
if (!input) return ''
if (typeof input === 'string') return input
if (input.path) return input.path
return ''
}
/**
* 获取文件扩展名
*/
const getFileExtension = (filepath: any): string => {
const path = getFilePath(filepath)
if (!path || typeof path !== 'string') return ''
return path.split('.').pop()?.toLowerCase() || ''
}
/**
* 判断是否为图片文件
*/
const isImageFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
if (!ext) return false
return FILE_EXTENSIONS.IMAGE.includes(ext)
}
/**
* 判断是否为视频文件
*/
const isVideoFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
if (!ext) return false
return FILE_EXTENSIONS.VIDEO.includes(ext)
}
/**
* 判断是否为音频文件
*/
const isAudioFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
if (!ext) return false
return FILE_EXTENSIONS.AUDIO.includes(ext)
}
/**
* 判断是否为 PDF 文件
*/
const isPdfFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
return ext === 'pdf'
}
/**
* 判断是否为二进制文件(基于扩展名)
* 注意媒体文件图片、视频、音频、PDF不是二进制文件它们可以预览
* 对于无扩展名的文件,返回 null 表示未知,需要内容检测
*/
const isBinaryFileByExt = (filepath: any): boolean | null => {
const ext = getFileExtension(filepath)
if (!ext) return null // 无扩展名返回 null表示需要进一步检测
// 媒体文件(可预览,不算二进制)
const isMediaFile = FILE_EXTENSIONS.IMAGE.includes(ext) ||
FILE_EXTENSIONS.VIDEO.includes(ext) ||
FILE_EXTENSIONS.AUDIO.includes(ext) ||
['pdf', 'html', 'htm', 'md', 'markdown'].includes(ext)
// 文本或代码文件(可编辑)
const isTextFile = FILE_EXTENSIONS.TEXT.includes(ext) ||
FILE_EXTENSIONS.CODE.includes(ext) ||
['json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'cfg', 'props'].includes(ext)
// 如果是媒体文件或文本文件,就不是二进制
if (isMediaFile || isTextFile) return false
// 确认的二进制文件类型
const knownBinaryTypes = ['exe', 'dll', 'so', 'bin', 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg', 'pdb', 'idb', 'lib', 'obj', 'o', 'a']
if (knownBinaryTypes.includes(ext)) return true
// 其他扩展名未知,需要内容检测
return null
}
/**
* 计算属性:当前视图是否可编辑
* 图片、视频、音频、PDF、二进制文件不可编辑
*/
const isEditableView = computed(() => {
const path = getFilePath(currentFilePath.value)
if (!path) return false
const binaryCheck = isBinaryFileByExt(path)
return !isImageFile(path) &&
!isVideoFile(path) &&
!isAudioFile(path) &&
!isPdfFile(path) &&
binaryCheck !== true // true 表示是二进制不可编辑false 或 null 表示可尝试编辑
})
/**
* 计算属性:文件内容是否改变
*/
const contentChanged = computed(() => {
return fileContent.value !== '' &&
originalContent.value !== undefined &&
originalContent.value !== fileContent.value
})
/**
* 计算属性:是否可以保存
*/
const canSaveFile = computed(() => {
return isEditableView.value && contentChanged.value
})
/**
* 计算属性:是否可以重置
*/
const canResetContent = computed(() => {
return contentChanged.value && originalContent.value !== undefined
})
/**
* 检测文件内容是否为二进制
*/
const detectBinaryContent = (content: string): boolean => {
if (!content || content.length === 0) return false
// 检查前 1000 个字符中二进制字符的比例
const checkLength = Math.min(content.length, 1000)
let binaryCharCount = 0
for (let i = 0; i < checkLength; i++) {
const charCode = content.charCodeAt(i)
// 空字节肯定是二进制
// 控制字符charCode < 32除了 Tab(9)、LF(10)、CR(13) 外都是二进制
if (charCode === 0 || (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13)) {
binaryCharCount++
}
}
// 如果二进制字符超过 5%,认为是二进制文件
const binaryRatio = binaryCharCount / checkLength
return binaryRatio > 0.05
}
/**
* 读取文件内容
*/
const loadFile = async (path: string) => {
try {
isBinaryFile.value = false
// 先清空内容,避免显示之前文件的内容
fileContent.value = ''
originalContent.value = ''
const filename = getFilePath(path)
const ext = getFileExtension(filename)
// 先检查扩展名,如果是已知的二进制文件,直接生成提示信息
const binaryCheck = isBinaryFileByExt(filename)
if (binaryCheck === true) {
isBinaryFile.value = true
const fileTypeDescriptions: Record<string, string> = {
'exe': '可执行文件',
'dll': '动态链接库',
'so': '共享库',
'bin': '二进制文件',
'dat': '数据文件',
'db': '数据库文件',
'sqlite': 'SQLite 数据库',
'zip': 'ZIP 压缩文件',
'rar': 'RAR 压缩文件',
'7z': '7Z 压缩文件',
'tar': 'TAR 归档文件',
'gz': 'GZ 压缩文件',
'bz2': 'BZ2 压缩文件',
'xz': 'XZ 压缩文件',
'iso': '光盘镜像',
'img': '磁盘镜像',
'dmg': 'DMG 镜像',
'pdb': '程序数据库',
'idb': 'IDA 数据库',
'lib': '库文件',
'obj': '目标文件',
'o': '目标文件',
'a': '静态库'
}
const fileTypeDesc = fileTypeDescriptions[ext] || `${ext.toUpperCase()} 文件`
const fileName = filename.split('\\').pop() || filename.split('/').pop() || filename
fileContent.value = `================================================================
文件信息:${fileTypeDesc}
================================================================
文件名: ${fileName}
完整路径: ${filename}
文件类型: ${fileTypeDesc}
================================================================
这是已知的二进制文件类型,不支持文本预览
💡 提示:
• 右键菜单 → "使用系统程序打开" 在默认应用中打开
• 右键菜单 → "在资源管理器中显示" 查看文件位置
================================================================`
originalContent.value = fileContent.value
isEditMode.value = false
return
}
// 对于无扩展名或未知类型文件,先尝试读取
const content = await readFile(path)
// 检查文件大小
const fileSize = content.length // UTF-16 字符数
if (fileSize > MAX_TEXT_FILE_SIZE) {
const sizeMB = (fileSize / 1024 / 1024).toFixed(2)
const fileName = filename.split('\\').pop() || filename.split('/').pop() || filename
fileContent.value = `================================================================
⚠️ 文件过大 (${sizeMB} MB)
================================================================
文件名: ${fileName}
完整路径: ${filename}
文件大小: ${sizeMB} MB
================================================================
当前文件大小超过 5MB不适合在编辑器中打开。
💡 建议:
• 使用命令行工具查看部分内容
• 将文件拆分成多个小文件
• 使用专门的工具处理大文件
================================================================`
originalContent.value = fileContent.value
isEditMode.value = false
return
}
// 检测是否为二进制内容
if (detectBinaryContent(content)) {
isBinaryFile.value = true
const fileTypeDesc = ext ? `${ext.toUpperCase()} 文件` : '未知类型文件'
const fileName = filename.split('\\').pop() || filename.split('/').pop() || filename
// 根据是否有扩展名,显示不同提示
const isUnknownType = !ext
const messageTitle = isUnknownType ? '文件信息(未知类型)' : `文件信息:${fileTypeDesc}`
const messageDesc = isUnknownType
? '此文件没有扩展名,且内容检测显示为二进制格式'
: `此文件扩展名为 .${ext},但内容检测显示为二进制格式`
fileContent.value = `================================================================
${messageTitle}
================================================================
文件名: ${fileName}
完整路径: ${filename}
${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
================================================================
${messageDesc},不支持文本预览
💡 提示:
• 右键菜单 → "使用系统程序打开" 在默认应用中打开
• 右键菜单 → "在资源管理器中显示" 查看文件位置
================================================================`
originalContent.value = fileContent.value
isEditMode.value = false
return
}
// 正常文本文件
fileContent.value = content
originalContent.value = content
// 加载草稿(如果存在)
loadDraft(path)
} catch (error) {
Message.error(`读取文件失败: ${error}`)
}
}
/**
* 保存文件内容
*/
const saveFile = async (path?: string, isShortcut: boolean = false) => {
// 获取目标路径(优先使用传入的 path否则从 currentFilePath 中提取)
let targetPath = path
if (!targetPath && currentFilePath.value) {
targetPath = getFilePath(currentFilePath.value)
}
if (!targetPath) {
Message.error('没有选中的文件')
return
}
// 检查内容是否真的改变了
if (fileContent.value === originalContent.value) {
if (!isShortcut) {
Message.info('文件内容未变更')
}
return
}
isSaving.value = true
try {
await writeFile(targetPath, fileContent.value)
originalContent.value = fileContent.value
// 清除草稿
clearDraft()
if (!isShortcut) {
Message.success('保存成功')
}
} catch (error) {
Message.error(`保存失败: ${error}`)
} finally {
// 延迟清除保存状态
setTimeout(() => {
isSaving.value = false
}, isShortcut ? 300 : 500)
}
}
/**
* 保存草稿
*/
const saveDraft = () => {
if (!currentFilePath.value) return
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${currentFilePath.value}`
const draft = {
content: fileContent.value,
savedAt: Date.now()
}
try {
localStorage.setItem(key, JSON.stringify(draft))
draftKey.value = key
} catch (error) {
console.error('保存草稿失败:', error)
}
}
/**
* 加载草稿
*/
const loadDraft = (path: string) => {
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
draftKey.value = key
try {
const stored = localStorage.getItem(key)
if (stored) {
const draft = JSON.parse(stored)
const ageInHours = (Date.now() - draft.savedAt) / (1000 * 60 * 60)
// 如果草稿超过 24 小时,自动清除
if (ageInHours > 24) {
clearDraft()
return
}
// 恢复草稿内容
fileContent.value = draft.content
Message.info('已恢复未保存的草稿')
}
} catch (error) {
console.error('加载草稿失败:', error)
}
}
/**
* 清除草稿
*/
const clearDraft = () => {
if (!draftKey.value) return
try {
localStorage.removeItem(draftKey.value)
draftKey.value = ''
} catch (error) {
console.error('清除草稿失败:', error)
}
}
/**
* 重置文件内容
*/
const resetContent = () => {
if (originalContent.value !== undefined) {
fileContent.value = originalContent.value
Message.info('已恢复原始内容')
}
}
/**
* 清空文件内容
*/
const clearContent = () => {
fileContent.value = ''
originalContent.value = ''
}
/**
* 切换编辑模式
*/
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
}
/**
* 进入编辑模式
*/
const enterEditMode = () => {
isEditMode.value = true
}
/**
* 退出编辑模式
*/
const exitEditMode = () => {
// 如果有未保存的更改,提示用户
if (contentChanged.value) {
// 这里可以添加确认对话框
// 暂时直接退出
}
isEditMode.value = false
}
/**
* 更新文件内容
*/
const updateContent = (content: string) => {
// 确保只有在内容真正改变时才更新
if (fileContent.value !== content) {
fileContent.value = content
}
// 自动保存草稿(防抖)
// 实际实现应该使用防抖函数
// saveDraft()
}
/**
* 设置编辑器高度
*/
const setEditorHeight = (height: number) => {
fileContentHeight.value = Math.max(200, height)
}
/**
* 判断文件是否在当前目录
*/
const isFileInCurrentDirectory = (filePathInput: any): boolean => {
const filePath = getFilePath(filePathInput)
if (!filePath || !currentDirectory.value) {
return true
}
return filePath.startsWith(currentDirectory.value)
}
// 监听文件内容变化,自动保存草稿
watch(fileContent, () => {
// 实际实现应该使用防抖
// saveDraft()
}, { deep: true })
// 监听文件路径变化,清除草稿
watch(currentFilePath, (newPath, oldPath) => {
if (newPath !== oldPath) {
clearDraft()
}
})
return {
// 状态
fileContent,
originalContent,
isEditMode,
fileContentHeight,
isSaving,
isBinaryFile,
draftKey,
// 计算属性
contentChanged,
canSaveFile,
canResetContent,
isEditableView,
// 文件操作
loadFile,
saveFile,
updateContent,
// 草稿管理
saveDraft,
loadDraft,
clearDraft,
// 编辑模式
toggleEditMode,
enterEditMode,
exitEditMode,
// 其他
resetContent,
clearContent,
setEditorHeight,
// 文件类型检查
isImageFile,
isVideoFile,
isAudioFile,
isPdfFile,
isBinaryFileByExt,
isFileInCurrentDirectory
}
}

View File

@@ -0,0 +1,264 @@
/**
* 文件操作 Composable
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
*/
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
listDir,
readFile as readFileApi,
writeFile as writeFileApi,
deletePath as deletePathApi,
createFile,
createDir,
renamePath as renamePathApi,
listZipContents,
extractFileFromZip,
extractFileFromZipToTemp,
getFileServerURL
} from '@/api'
import type { FileOperationResult } from '@/types/file-system'
export interface UseFileOperationsOptions {
onSuccess?: (operation: string, data: any) => void
onError?: (operation: string, error: Error) => void
}
/**
* 文件操作结果
*/
export function useFileOperations(options: UseFileOperationsOptions = {}) {
const { onSuccess, onError } = options
/**
* 列出目录内容
*/
const listDirectory = async (path: string): Promise<FileItem[]> => {
try {
const result = await listDir(path)
onSuccess?.('listDirectory', result)
return result
} catch (error) {
const err = error as Error
onError?.('listDirectory', err)
throw err
}
}
/**
* 读取文件内容
*/
const readFile = async (path: string): Promise<string> => {
try {
const content = await readFileApi(path)
onSuccess?.('readFile', { path, size: content.length })
return content
} catch (error) {
const err = error as Error
onError?.('readFile', err)
throw err
}
}
/**
* 写入文件内容
*/
const writeFile = async (
path: string,
content: string,
createBackup: boolean = false
): Promise<void> => {
try {
await writeFileApi(path, content)
onSuccess?.('writeFile', { path, size: content.length })
} catch (error) {
const err = error as Error
onError?.('writeFile', err)
throw err
}
}
/**
* 删除路径(文件或目录)
*/
const deletePath = async (path: string): Promise<void> => {
try {
await deletePathApi(path)
onSuccess?.('deletePath', { path })
} catch (error) {
const err = error as Error
onError?.('deletePath', err)
throw err
}
}
/**
* 创建新文件
*/
const createNewFile = async (
dirPath: string,
filename: string,
content: string = ''
): Promise<void> => {
try {
await createFile(dirPath, filename, content)
onSuccess?.('createFile', { dirPath, filename })
} catch (error) {
const err = error as Error
onError?.('createFile', err)
throw err
}
}
/**
* 创建新目录
*/
const createNewDir = async (parentPath: string, dirname: string): Promise<void> => {
try {
await createDir(parentPath, dirname)
onSuccess?.('createDir', { parentPath, dirname })
} catch (error) {
const err = error as Error
onError?.('createDir', err)
throw err
}
}
/**
* 重命名文件或目录
*/
const rename = async (oldPath: string, newName: string): Promise<void> => {
// 构造新路径
const separator = oldPath.includes('\\') ? '\\' : '/'
const parentPath = oldPath.substring(
0,
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
)
const newPath = parentPath + separator + newName
try {
await renamePathApi(oldPath, newPath)
onSuccess?.('rename', { oldPath, newPath })
} catch (error) {
const err = error as Error
onError?.('rename', err)
throw err
}
}
/**
* 复制文件或目录
*/
const copy = async (fromPath: string, toPath: string): Promise<void> => {
try {
// TODO: 实现复制逻辑
Message.warning('复制功能暂未实现')
onSuccess?.('copy', { fromPath, toPath })
} catch (error) {
const err = error as Error
onError?.('copy', err)
throw err
}
}
/**
* 移动文件或目录
*/
const move = async (fromPath: string, toPath: string): Promise<void> => {
try {
// TODO: 实现移动逻辑
Message.warning('移动功能暂未实现')
onSuccess?.('move', { fromPath, toPath })
} catch (error) {
const err = error as Error
onError?.('move', err)
throw err
}
}
/**
* 列出 ZIP 文件内容
*/
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
try {
const result = await listZipContents(zipPath)
onSuccess?.('listZipContents', { zipPath, count: result.length })
return result
} catch (error) {
const err = error as Error
onError?.('listZipContents', err)
throw err
}
}
/**
* 从 ZIP 中提取文件内容(文本)
*/
const extractZipFile = async (zipPath: string, filePath: string): Promise<string> => {
try {
const content = await extractFileFromZip(zipPath, filePath)
onSuccess?.('extractZipFile', { zipPath, filePath, size: content.length })
return content
} catch (error) {
const err = error as Error
onError?.('extractZipFile', err)
throw err
}
}
/**
* 从 ZIP 中提取文件到临时目录(二进制文件,如图片)
*/
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
try {
const tempPath = await extractFileFromZipToTemp(zipPath, filePath)
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
return tempPath
} catch (error) {
const err = error as Error
onError?.('extractZipFileToTemp', err)
throw err
}
}
/**
* 获取文件服务器 URL
*/
const getFileServerURL = async (): Promise<string> => {
try {
const url = await getFileServerURL()
onSuccess?.('getFileServerURL', { url })
return url
} catch (error) {
const err = error as Error
onError?.('getFileServerURL', err)
throw err
}
}
return {
// 基础操作
listDirectory,
readFile,
writeFile,
deletePath,
// 创建操作
createNewFile,
createNewDir,
// 高级操作
rename,
copy,
move,
// ZIP 操作
listZipContents,
extractZipFile,
extractZipFileToTemp,
getFileServerURL
}
}
// 注意FileItem 类型已统一定义在 @/types/file-system.ts

View File

@@ -0,0 +1,319 @@
/**
* 文件预览 Composable
* 提供文件预览 URL 生成、媒体元数据获取等功能
*/
import { ref, computed } from 'vue'
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { normalizeFilePath } from '@/utils/fileUtils'
import { detectFileTypeByContent } from '@/api/system'
import type { FilePreviewMetadata, FileType } from '@/types/file-system'
// 内容检测大小限制(与后端一致)
const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
// 缓存检测结果
const contentDetectCache = new Map<string, { timestamp: number; result: any }>()
const CACHE_TTL = 60000 // 1分钟缓存
export interface UseFilePreviewOptions {
filePath?: string
isBrowsingZip?: boolean
}
export function useFilePreview(options: UseFilePreviewOptions = {}) {
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
// 文件服务器 URL硬编码与旧版本保持一致
const fileServerURL = 'http://localhost:18765'
// 预览 URL
const previewUrl = ref('')
// 媒体加载状态
const imageLoading = ref(false)
const currentImageDimensions = ref('')
/**
* 获取预览 URL与旧版本保持一致
*/
const getPreviewUrl = (path: string): string => {
if (!path) return ''
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
return `${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
}
/**
* 通过内容检测文件类型(用于小文件)
*/
const detectByContent = async (path: string, fileSize?: number): Promise<{ category: string; ext: string } | null> => {
// 如果文件太大,跳过内容检测
if (fileSize !== undefined && fileSize > CONTENT_DETECT_MAX_SIZE) {
return null
}
// 检查缓存
const cached = contentDetectCache.get(path)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.result
}
try {
const result = await detectFileTypeByContent(path)
const data = { category: result.category, ext: result.extension }
contentDetectCache.set(path, { timestamp: Date.now(), result: data })
return data
} catch {
return null
}
}
/**
* 更新预览 URL
*/
const updatePreviewUrl = (path: string) => {
previewUrl.value = getPreviewUrl(path)
}
/**
* 获取文件类型
*/
const getFileType = (filename: string): FileType => {
if (!filename || typeof filename !== 'string') return 'Binary' as FileType
const ext = filename.split('.').pop()?.toLowerCase() || ''
// 图片
if (FILE_EXTENSIONS.IMAGE.includes(ext)) {
return 'Image' as FileType
}
// 视频
if (FILE_EXTENSIONS.VIDEO.includes(ext)) {
return 'Video' as FileType
}
// 音频
if (FILE_EXTENSIONS.AUDIO.includes(ext)) {
return 'Audio' as FileType
}
// PDF
if (ext === 'pdf') {
return 'Pdf' as FileType
}
// HTML
if (['html', 'htm'].includes(ext)) {
return 'Html' as FileType
}
// Markdown
if (['md', 'markdown'].includes(ext)) {
return 'Markdown' as FileType
}
// 代码
if (FILE_EXTENSIONS.CODE.includes(ext)) {
return 'Code' as FileType
}
// 文本
if (FILE_EXTENSIONS.TEXT.includes(ext)) {
return 'Text' as FileType
}
// 默认为二进制
return 'Binary' as FileType
}
/**
* 判断是否为图片文件
*/
const isImageFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.IMAGE.includes(ext)
}
/**
* 判断是否为视频文件
*/
const isVideoFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.VIDEO.includes(ext)
}
/**
* 判断是否为音频文件
*/
const isAudioFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.AUDIO.includes(ext)
}
/**
* 判断是否为 PDF 文件
*/
const isPdfFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return ext === 'pdf'
}
/**
* 判断是否为 HTML 文件
*/
const isHtmlFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return ['html', 'htm'].includes(ext)
}
/**
* 判断是否为 Markdown 文件
*/
const isMarkdownFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return ['md', 'markdown'].includes(ext)
}
/**
* 判断是否为代码文件
*/
const isCodeFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.CODE.includes(ext)
}
/**
* 判断是否为文本文件
*/
const isTextFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.TEXT.includes(ext)
}
/**
* 判断文件是否可预览
*/
const isPreviewable = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.IMAGE.includes(ext) ||
FILE_EXTENSIONS.VIDEO.includes(ext) ||
FILE_EXTENSIONS.AUDIO.includes(ext) ||
ext === 'pdf' ||
['html', 'htm'].includes(ext) ||
['md', 'markdown'].includes(ext)
}
/**
* 判断文件是否可编辑
*/
const isEditable = (filename: string, fileSize: number): boolean => {
// 检查文件大小
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) {
return false
}
// 检查文件类型
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.CODE.includes(ext) ||
FILE_EXTENSIONS.TEXT.includes(ext) ||
['html', 'htm', 'md', 'markdown', 'json', 'xml'].includes(ext)
}
/**
* 图片加载完成
*/
const onImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
if (img.naturalWidth && img.naturalHeight) {
currentImageDimensions.value = `${img.naturalWidth} × ${img.naturalHeight}`
}
imageLoading.value = false
}
/**
* 图片加载失败
*/
const onImageError = () => {
imageLoading.value = false
currentImageDimensions.value = ''
}
/**
* 开始加载图片
*/
const startImageLoad = () => {
imageLoading.value = true
currentImageDimensions.value = ''
}
/**
* 获取媒体元数据
*/
const getMediaMetadata = async (url: string): Promise<FilePreviewMetadata> => {
const metadata: FilePreviewMetadata = {}
// 对于图片,使用 Image 对象
if (url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
metadata.width = img.naturalWidth
metadata.height = img.naturalHeight
resolve(metadata)
}
img.onerror = () => resolve(metadata)
img.src = url
})
}
// 对于视频/音频,可以使用 Video/Audio 对象
// 但由于跨域等问题,这里简化处理
return metadata
}
return {
// 状态
previewUrl,
imageLoading,
currentImageDimensions,
// URL 相关
getPreviewUrl,
updatePreviewUrl,
// 文件类型判断(同步,基于扩展名)
getFileType,
isImageFile,
isVideoFile,
isAudioFile,
isPdfFile,
isHtmlFile,
isMarkdownFile,
isCodeFile,
isTextFile,
isPreviewable,
isEditable,
// 内容检测(异步,基于文件内容)
detectByContent,
// 事件处理
onImageLoad,
onImageError,
startImageLoad,
// 工具方法
getMediaMetadata
}
}

View File

@@ -0,0 +1,243 @@
/**
* 路径导航 Composable
* 提供路径输入、历史记录、前进/后退等功能
*/
import { ref, watch, computed } from 'vue'
import { STORAGE_KEYS } from '@/utils/constants'
import type { PathHistory } from '@/types/file-system'
export interface UsePathNavigationOptions {
onListDirectory?: (path: string) => Promise<void>
initialPath?: string
}
/**
* 从 localStorage 恢复上次的路径
*/
const restoreLastPath = (): string | null => {
try {
const lastPath = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH)
return lastPath
} catch (error) {
console.error('恢复路径失败:', error)
return null
}
}
/**
* 保存路径到 localStorage
*/
const saveLastPath = (path: string) => {
try {
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH, path)
} catch (error) {
console.error('保存路径失败:', error)
}
}
export function usePathNavigation(options: UsePathNavigationOptions = {}) {
const { onListDirectory, initialPath = '' } = options
// 尝试恢复上次的路径,如果没有则使用初始路径
const savedPath = restoreLastPath()
const filePath = ref(savedPath || initialPath)
// 历史记录
const history = ref<PathHistory>({
paths: [],
currentIndex: -1
})
/**
* 导航到指定路径(带错误处理)
*/
const navigate = async (path: string) => {
if (!path || path === filePath.value) return
try {
// 路径规范化
const normalizedPath = normalizePath(path)
filePath.value = normalizedPath
// 添加到历史记录
addToHistory(normalizedPath)
// 触发目录列出
if (onListDirectory) {
await onListDirectory(normalizedPath)
}
} catch (error) {
console.error('导航失败:', error)
throw error
}
}
/**
* 添加到历史记录
*/
const addToHistory = (path: string) => {
const { paths, currentIndex } = history.value
// 如果当前不在历史记录末尾,删除当前位置之后的所有记录
if (currentIndex < paths.length - 1) {
history.value.paths = paths.slice(0, currentIndex + 1)
}
// 避免重复添加相同路径
const lastPath = history.value.paths[history.value.paths.length - 1]
if (lastPath !== path) {
history.value.paths.push(path)
history.value.currentIndex = history.value.paths.length - 1
}
}
/**
* 后退(带错误处理)
*/
const back = async () => {
const { paths, currentIndex } = history.value
if (currentIndex <= 0) return
try {
const newIndex = currentIndex - 1
history.value.currentIndex = newIndex
filePath.value = paths[newIndex]
if (onListDirectory) {
await onListDirectory(paths[newIndex])
}
} catch (error) {
console.error('后退失败:', error)
throw error
}
}
/**
* 前进(带错误处理)
*/
const forward = async () => {
const { paths, currentIndex } = history.value
if (currentIndex >= paths.length - 1) return
try {
const newIndex = currentIndex + 1
history.value.currentIndex = newIndex
filePath.value = paths[newIndex]
if (onListDirectory) {
await onListDirectory(paths[newIndex])
}
} catch (error) {
console.error('前进失败:', error)
throw error
}
}
/**
* 路径输入选择
*/
const onPathSelect = (value: string) => {
navigate(value)
}
/**
* 路径输入回车
*/
const onPathEnter = (value: string) => {
navigate(value)
}
/**
* 浏览目录(双击或回车)
*/
const browseDirectory = async (path: string) => {
await navigate(path)
}
/**
* 获取父目录路径
*/
const getParentPath = (path: string): string => {
const separator = path.includes('\\') ? '\\' : '/'
const lastSeparator = path.lastIndexOf(separator)
return lastSeparator > 0 ? path.substring(0, lastSeparator) : path
}
/**
* 上级目录
*/
const goUp = async () => {
const parentPath = getParentPath(filePath.value)
if (parentPath !== filePath.value) {
await navigate(parentPath)
}
}
/**
* 路径规范化(统一分隔符)
*/
const normalizePath = (path: string): string => {
if (!path) return ''
return path.replace(/\\/g, '/')
}
/**
* 判断是否可以后退
*/
const canGoBack = computed(() => {
return history.value.currentIndex > 0
})
/**
* 判断是否可以前进
*/
const canGoForward = computed(() => {
return history.value.currentIndex < history.value.paths.length - 1
})
/**
* 获取历史记录列表(用于自动完成)
*/
const getPathHistory = computed(() => {
return history.value.paths.slice().reverse() // 最新的在前
})
// 监听路径变化,自动保存到 localStorage
watch(filePath, (newPath) => {
if (newPath) {
saveLastPath(newPath)
}
})
return {
// 状态
filePath,
history,
// 导航方法
navigate,
back,
forward,
goUp,
browseDirectory,
// 事件处理
onPathSelect,
onPathEnter,
// 工具方法
getParentPath,
normalizePath,
// 计算属性
canGoBack,
canGoForward,
getPathHistory
}
}
// 导出类型(用于外部使用)
export type { PathHistory }

View File

@@ -0,0 +1,198 @@
<template>
<div class="file-system-container">
<div class="debug-info">
<h3>FileSystem Debug Info</h3>
<p>filePath: {{ filePath }}</p>
<p>fileList length: {{ fileList.length }}</p>
<p>showSidebar: {{ showSidebar }}</p>
<p>hasSelectedFile: {{ hasSelectedFile }}</p>
<button @click="testClick">测试点击</button>
</div>
<!-- 顶部工具栏 -->
<Toolbar
:config="toolbarConfig"
@update:file-path="handleFilePathUpdate"
@update:show-sidebar="handleSidebarToggle"
@refresh="handleRefresh"
@exit-zip="handleExitZip"
@go-to-path="handleGoToPath"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
// 导入子组件
import Toolbar from './components/Toolbar.vue'
// 导入 Composables
import { useFileOperations } from './composables/useFileOperations'
import { useFavorites } from './composables/useFavorites'
import { usePathNavigation } from './composables/usePathNavigation'
// 定义组件名称
defineOptions({
name: 'FileSystem'
})
console.log('FileSystem component setup started')
// ========== 状态管理 ==========
const fileList = ref([])
const fileLoading = ref(false)
const selectedFileItem = ref(null)
const showSidebar = ref(true)
const panelWidth = ref({ left: 50, right: 50 })
// ========== Composables 初始化 ==========
// 文件操作
const { listDirectory, readFile } = useFileOperations({
onSuccess: (operation, data) => {
console.log('Operation success:', operation, data)
},
onError: (operation, error) => {
console.error('Operation error:', operation, error)
Message.error(`${operation} 失败: ${error.message}`)
}
})
// 收藏夹
const { favorites, draggingState } = useFavorites()
// 路径导航
const { filePath, history, navigate, onPathSelect, onPathEnter, browseDirectory } =
usePathNavigation({
onListDirectory: async (path) => {
await loadDirectory(path)
},
initialPath: 'C:\\'
})
console.log('Composables initialized')
console.log('Initial filePath:', filePath.value)
// ========== 计算属性 ==========
const hasSelectedFile = computed(() => selectedFileItem.value !== null)
const toolbarConfig = computed(() => ({
filePath: filePath.value || '',
pathHistory: history.value?.paths?.slice(-10) || [],
commonPaths: [
{ name: '📁 桌面', path: 'C:\\Users\\Public\\Desktop' },
{ name: '📁 文档', path: 'C:\\Users\\Public\\Documents' },
{ name: '📁 下载', path: 'C:\\Users\\Public\\Downloads' }
],
isBrowsingZip: false,
displayPath: filePath.value || '',
fileLoading: fileLoading.value,
showSidebar: showSidebar.value
}))
// ========== 事件处理 ==========
const handleFilePathUpdate = (path: string) => {
console.log('handleFilePathUpdate:', path)
filePath.value = path
}
const handleSidebarToggle = (show: boolean) => {
console.log('handleSidebarToggle:', show)
showSidebar.value = show
}
const handleRefresh = async () => {
console.log('handleRefresh')
await loadDirectory(filePath.value)
}
const handleExitZip = () => {
console.log('handleExitZip')
}
const handleGoToPath = async (path: string) => {
console.log('handleGoToPath:', path)
await navigate(path)
}
const testClick = () => {
console.log('Test button clicked')
Message.success('测试成功!')
console.log('Current state:', {
filePath: filePath.value,
fileList: fileList.value,
favorites: favorites.value
})
}
// ========== 工具函数 ==========
const loadDirectory = async (path: string) => {
console.log('loadDirectory:', path)
fileLoading.value = true
try {
fileList.value = await listDirectory(path)
console.log('Files loaded:', fileList.value.length)
} catch (error) {
console.error('Load directory error:', error)
Message.error(`加载目录失败: ${error}`)
} finally {
fileLoading.value = false
}
}
// ========== 生命周期 ==========
onMounted(() => {
console.log('FileSystem mounted')
console.log('Loading initial directory:', filePath.value)
// 加载默认目录
loadDirectory(filePath.value)
})
</script>
<style scoped>
.file-system-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.debug-info {
padding: 20px;
background: #f0f0f0;
margin: 10px;
border-radius: 8px;
}
.debug-info h3 {
margin-top: 0;
}
.debug-info p {
margin: 5px 0;
font-family: 'Consolas', monospace;
}
.debug-info button {
margin-top: 10px;
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.debug-info button:hover {
background: #40a9ff;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,369 @@
/**
* 文件编辑和保存逻辑 composable
*
* @module composables/useFileEdit
* @description 封装文件编辑、保存、草稿管理等逻辑
*/
import { ref, computed, watch } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { STORAGE_KEYS } from '@/utils/constants'
/**
* 草稿存储键
*/
const DRAFT_STORAGE_KEY = 'filesystem_draft_content'
/**
* 文件编辑 composable
* @param {Object} options - 配置选项
* @param {Ref<string>} options.filePath - 当前文件路径
* @param {Ref<string>} options.fileContent - 文件内容
* @param {Function} options.onWriteFile - 写入文件的函数
* @param {Function} options.onReset - 重置内容的函数
* @returns {UseFileEditReturn} 文件编辑操作 API
*/
export function useFileEdit(options = {}) {
const {
filePath,
fileContent,
onWriteFile,
onReset,
} = options
// ========== 编辑状态 ==========
/**
* 是否正在保存
* @type {Ref<boolean>}
*/
const isSaving = ref(false)
/**
* 是否是快捷键触发的保存
* @type {Ref<boolean>}
*/
const isShortcutSave = ref(false)
/**
* 保存成功提示消息
* @type {Ref<string>}
*/
const saveSuccessMessage = ref('')
/**
* 原始文件内容(用于检测变更)
* @type {Ref<string>}
*/
const originalContent = ref('')
/**
* 是否为编辑模式
* @type {Ref<boolean>}
*/
const isEditMode = ref(localStorage.getItem(STORAGE_KEYS.FILESYSTEM.EDIT_MODE) === 'true')
// ========== 计算属性 ==========
/**
* 文件内容是否已修改
*/
const isFileModified = computed(() => {
return originalContent.value !== undefined &&
originalContent.value !== fileContent.value
})
/**
* 内容是否发生变化(用于按钮禁用判断)
*/
const contentChanged = computed(() => {
return fileContent.value !== '' &&
fileContent.value !== originalContent.value
})
/**
* 是否可以保存文件
*/
const canSaveFile = computed(() => {
return isEditMode.value && contentChanged.value
})
/**
* 是否可以重置内容
*/
const canResetContent = computed(() => {
return isEditMode.value &&
contentChanged.value &&
originalContent.value !== undefined
})
// ========== 草稿管理 ==========
/**
* 保存草稿到 localStorage
*/
const saveDraft = () => {
try {
const draft = {
content: fileContent.value,
path: filePath.value,
timestamp: Date.now(),
}
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
localStorage.setItem(DRAFT_STORAGE_KEY + '_time', Date.now().toString())
} catch (error) {
console.warn('[saveDraft] 保存草稿失败:', error)
}
}
/**
* 清除草稿
*/
const clearDraft = () => {
try {
localStorage.removeItem(DRAFT_STORAGE_KEY)
localStorage.removeItem(DRAFT_STORAGE_KEY + '_time')
} catch (error) {
console.warn('[clearDraft] 清除草稿失败:', error)
}
}
/**
* 加载草稿
* @returns {Object|null} 草稿数据
*/
const loadDraft = () => {
try {
const draftStr = localStorage.getItem(DRAFT_STORAGE_KEY)
if (!draftStr) return null
const draft = JSON.parse(draftStr)
// 检查草稿是否过期24小时
const timeStr = localStorage.getItem(DRAFT_STORAGE_KEY + '_time')
if (timeStr) {
const time = parseInt(timeStr, 10)
const now = Date.now()
const hours = (now - time) / (1000 * 60 * 60)
if (hours > 24) {
clearDraft()
return null
}
}
return draft
} catch (error) {
console.warn('[loadDraft] 加载草稿失败:', error)
return null
}
}
// ========== 保存操作 ==========
/**
* 显示手动保存对话框
* @param {boolean} isShortcut - 是否是快捷键触发
*/
const showManualSaveDialog = (isShortcut) => {
isShortcutSave.value = isShortcut
Modal.confirm({
title: '保存文件',
content: `确定要保存文件 ${filePath.value} 吗?`,
okText: '保存',
cancelText: '取消',
onOk: () => {
saveToFile(filePath.value, getFileName(filePath.value), isShortcut)
},
})
}
/**
* 保存到文件
* @param {string} targetPath - 目标路径
* @param {string} fileName - 文件名
* @param {boolean} isShortcut - 是否是快捷键触发
* @returns {Promise<boolean>} 是否成功
*/
const saveToFile = async (targetPath, fileName, isShortcut) => {
isSaving.value = true
try {
const success = await onWriteFile(fileContent.value, targetPath, fileName, isShortcut)
if (success) {
originalContent.value = fileContent.value
clearDraft()
}
return success
} finally {
isSaving.value = false
}
}
/**
* 处理保存内容
* @returns {Promise<boolean>} 是否成功
*/
const handleSaveContent = async () => {
if (!canSaveFile.value) {
return false
}
return await saveToFile(filePath.value, getFileName(filePath.value), false)
}
/**
* 另存为
*/
const handleSaveAs = async () => {
try {
// 简单实现:使用 prompt 获取路径
const targetPath = prompt('请输入保存路径:', filePath.value)
if (!targetPath) {
return false
}
const fileName = getFileName(targetPath)
return await saveToFile(targetPath, fileName, false)
} catch (error) {
Message.error(`保存对话框失败: ${error.message || error}`)
return false
}
}
/**
* 处理写入文件(快捷键或按钮)
* @param {boolean} isShortcut - 是否是快捷键触发
* @returns {Promise<boolean>} 是否成功
*/
const handleWriteFile = async (isShortcut = false) => {
if (!fileContent.value || !filePath.value) {
Message.warning('没有可保存的内容')
return false
}
// 如果内容未修改,快捷键保存时静默返回
if (!isFileModified.value && isShortcut) {
return false
}
// 快捷键:静默保存
if (isShortcut) {
return await saveToFile(filePath.value, getFileName(filePath.value), true)
}
// 按钮:显示确认对话框
showManualSaveDialog(false)
return false
}
// ========== 重置操作 ==========
/**
* 重置内容到原始状态
*/
const resetContent = () => {
if (onReset) {
onReset()
} else {
fileContent.value = originalContent.value
}
}
// ========== 编辑模式切换 ==========
/**
* 切换编辑模式
*/
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
// 持久化
try {
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.EDIT_MODE, isEditMode.value.toString())
} catch (e) {
console.warn('[toggleEditMode] 保存编辑模式失败:', e)
}
// 进入编辑模式时,记录原始内容
if (isEditMode.value) {
originalContent.value = fileContent.value
}
}
// ========== 工具函数 ==========
/**
* 从路径获取文件名
* @param {string} path - 文件路径
* @returns {string} 文件名
*/
const getFileName = (path) => {
if (!path) return ''
const parts = path.split(/[/\\]/)
return parts[parts.length - 1] || path
}
// ========== 监听内容变化 ==========
/**
* 监听文件内容变化,自动保存草稿
*/
watch(fileContent, () => {
if (fileContent.value && fileContent.value !== originalContent.value) {
saveDraft()
}
})
/**
* 监听文件路径变化,更新原始内容
*/
watch(filePath, () => {
originalContent.value = fileContent.value
})
return {
// 状态
isSaving,
isShortcutSave,
saveSuccessMessage,
originalContent,
isEditMode,
isFileModified,
canSaveFile,
canResetContent,
// 方法
saveDraft,
clearDraft,
loadDraft,
handleSaveContent,
handleSaveAs,
handleWriteFile,
resetContent,
toggleEditMode,
}
}
/**
* @typedef {Object} UseFileEditReturn
* @property {Ref<boolean>} isSaving - 是否正在保存
* @property {Ref<boolean>} isShortcutSave - 是否是快捷键触发
* @property {Ref<string>} saveSuccessMessage - 保存成功提示消息
* @property {Ref<string>} originalContent - 原始文件内容
* @property {Ref<boolean>} isEditMode - 是否为编辑模式
* @property {ComputedRef<boolean>} isFileModified - 文件内容是否已修改
* @property {ComputedRef<boolean>} canSaveFile - 是否可以保存文件
* @property {ComputedRef<boolean>} canResetContent - 是否可以重置内容
* @property {Function} saveDraft - 保存草稿
* @property {Function} clearDraft - 清除草稿
* @property {Function} loadDraft - 加载草稿
* @property {Function} handleSaveContent - 处理保存内容
* @property {Function} handleSaveAs - 另存为
* @property {Function} handleWriteFile - 处理写入文件
* @property {Function} resetContent - 重置内容
* @property {Function} toggleEditMode - 切换编辑模式
*/

View File

@@ -0,0 +1,612 @@
/**
* 文件预览逻辑 composable
*
* @module composables/useFilePreview
* @description 封装文件预览、HTML/Markdown 渲染、二进制文件信息显示等逻辑
*/
import { ref, computed } from 'vue'
import { marked } from '@/utils/markedExtensions'
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { getExt } from '@/utils/fileHelpers'
import { debugLog, debugWarn, debugError } from '@/utils/debugLog'
/**
* 文件预览 composable
* @param {Object} options - 配置选项
* @param {Ref<string>} options.filePath - 当前文件路径
* @param {Ref<string>} options.fileContent - 文件内容
* @param {Ref<Array>} options.fileList - 文件列表
* @param {Function} options.onReadFile - 读取文件的函数
* @returns {UseFilePreviewReturn} 文件预览操作 API
*/
export function useFilePreview(options = {}) {
const {
filePath,
fileContent,
fileList,
onReadFile,
} = options
// ========== 预览状态 ==========
/**
* 预览 URL
* @type {Ref<string>}
*/
const previewUrl = ref('')
/**
* 文件服务器URL
* @type {Ref<string>}
*/
const fileServerURL = ref('http://localhost:18765')
/**
* 渲染后的 HTML/Markdown 内容
* @type {Ref<string>}
*/
const rendered = ref('')
/**
* 图片加载状态
* @type {Ref<boolean>}
*/
const imageLoading = ref(false)
/**
* 图片宽度
* @type {Ref<number>}
*/
const imageWidth = ref(0)
/**
* 图片高度
* @type {Ref<number>}
*/
const imageHeight = ref(0)
/**
* 是否显示图片预览
* @type {Ref<boolean>}
*/
const isImageView = ref(false)
/**
* 是否显示视频预览
* @type {Ref<boolean>}
*/
const isVideoView = ref(false)
/**
* 是否显示音频预览
* @type {Ref<boolean>}
*/
const isAudioView = ref(false)
/**
* 是否为 PDF 文件
* @type {Ref<boolean>}
*/
const isPdfFile = ref(false)
/**
* 是否为 HTML 文件
* @type {Ref<boolean>}
*/
const isHtmlFile = ref(false)
/**
* 是否为 Markdown 文件
* @type {Ref<boolean>}
*/
const isMarkdownFile = ref(false)
/**
* 是否为二进制文件信息展示
* @type {Ref<boolean>}
*/
const isBinaryFile = ref(false)
/**
* HTML 预览的 blob URL
* @type {Ref<string>}
*/
const htmlPreviewUrl = ref('')
// ========== 计算属性 ==========
/**
* 当前文件名
*/
const currentFileName = computed(() => {
if (!filePath.value) return ''
const pathStr = typeof filePath.value === 'string' ? filePath.value : String(filePath.value || '')
const parts = pathStr.split(/[/\\]/)
return parts[parts.length - 1]
})
/**
* 当前文件完整路径
*/
const currentFileFullPath = computed(() => filePath.value || '')
/**
* 当前图片尺寸
*/
const currentImageDimensions = computed(() => {
if (!imageWidth.value || !imageHeight.value) return ''
return `${imageWidth.value}×${imageHeight.value}`
})
// ========== 图片预览 ==========
/**
* 预览图片
* @param {string} targetPath - 目标路径
*/
const previewImage = async (targetPath) => {
const pathToPreview = targetPath || filePath.value
if (!pathToPreview) return
resetPreviewState()
const ext = getExt(pathToPreview)
if (!FILE_EXTENSIONS.IMAGE.includes(ext)) {
return
}
imageLoading.value = true
isImageView.value = true
// 构建预览 URL
const encodedPath = encodeURIComponent(pathToPreview)
previewUrl.value = `${fileServerURL.value}/file?path=${encodedPath}`
}
/**
* 图片加载成功回调
* @param {Event} e - 加载事件
*/
const onImageLoad = (e) => {
imageLoading.value = false
imageWidth.value = e.naturalWidth || e.target?.width || 0
imageHeight.value = e.naturalHeight || e.target?.height || 0
}
/**
* 图片加载失败回调
*/
const onImageError = () => {
imageLoading.value = false
debugWarn('[onImageError] 图片加载失败')
}
// ========== 视频/音频/PDF 预览 ==========
/**
* 预览媒体文件(视频/音频/PDF
* @param {string} mediaType - 媒体类型 ('video' | 'audio' | 'pdf')
* @param {string} targetPath - 目标路径
*/
const previewMedia = (mediaType, targetPath) => {
const pathToPreview = targetPath || filePath.value
if (!pathToPreview) return
resetPreviewState()
const encodedPath = encodeURIComponent(pathToPreview)
previewUrl.value = `${fileServerURL.value}/file?path=${encodedPath}`
if (mediaType === 'video') {
isVideoView.value = true
} else if (mediaType === 'audio') {
isAudioView.value = true
} else if (mediaType === 'pdf') {
isPdfFile.value = true
}
}
/**
* 预览视频
* @param {string} targetPath - 目标路径
*/
const previewVideo = (targetPath) => previewMedia('video', targetPath)
/**
* 预览音频
* @param {string} targetPath - 目标路径
*/
const previewAudio = (targetPath) => previewMedia('audio', targetPath)
/**
* 预览 PDF
* @param {string} targetPath - 目标路径
*/
const previewPdf = (targetPath) => previewMedia('pdf', targetPath)
// ========== HTML 预览 ==========
/**
* 提取 HTML 文件中的样式
* @param {string} htmlContent - HTML 内容
* @param {string} basePath - 基础路径
* @returns {Promise<string>} 提取的 CSS 样式
*/
const extractHtmlStyles = async (htmlContent, basePath) => {
const linkRegex = /<link[^>]*href=(["'])([^"']+)\1[^>]*>/gi
const links = [...htmlContent.matchAll(linkRegex)]
if (links.length === 0) return ''
let linkCount = 0
const styles = []
for (const match of links) {
const linkTag = match[0]
const hrefMatch = match[2]?.match(/^https?:\/\//i)
const fullTag = match[0]
const href = match[2]
debugLog(`[extractHtmlStyles] 发现第 ${linkCount} 个 link 标签:`, fullTag)
const cssPath = href?.replace(/^\.\//, '').replace(/^\//, '')
debugLog('[extractHtmlStyles] 解析后 CSS 路径:', cssPath)
if (hrefMatch) {
debugLog('[extractHtmlStyles] 跳过外部 CSS:', hrefMatch[1])
continue
}
debugLog('[extractHtmlStyles] 正在读取 CSS 文件:', cssPath)
try {
// 从 HTML 文件所在目录读取 CSS
const cssFullPath = basePath + '/' + cssPath
const cssContent = await onReadFile(cssFullPath)
if (cssContent) {
const cssSize = cssContent.length
debugLog(`[extractHtmlStyles] 成功读取并转换 CSS: ${cssSize} 字符`)
// 转换 CSS 中的 URL 为 base64
const convertedCss = await convertCssUrls(cssContent, basePath)
styles.push(convertedCss)
}
} catch (error) {
debugWarn('[extractHtmlStyles] 无法读取 CSS:', cssPath, error.message)
}
linkCount++
}
debugLog(`处理完成: 找到 ${linkCount} 个 link 标签, 成功提取 ${styles.length} 个 CSS 文件`)
debugLog(`提取的 CSS 总大小: ${styles.join('\n\n').length} 字符`)
return styles.join('\n\n')
}
/**
* 转换 CSS 中的相对 URL 为 base64
* @param {string} css - CSS 内容
* @param {string} basePath - 基础路径
* @returns {Promise<string>} 转换后的 CSS
*/
const convertCssUrls = async (css, basePath) => {
const urlRegex = /url\((["']?)([^"')]+)\1\)/gi
return css.replace(urlRegex, async (match, quote, url) => {
// 跳过 data: URLs 和绝对 URLs
if (url.startsWith('data:') || /^https?:\/\//i.test(url)) {
return match
}
try {
const imagePath = basePath + '/' + url.replace(/^\.\//, '')
const base64 = await fileToBase64(imagePath)
debugLog(`[convertCssUrls] ${url} -> base64`)
return `url("data:image/${getExt(imagePath)};base64,${base64}")`
} catch (err) {
debugWarn('[convertCssUrls] 失败:', imagePath, err.message)
return match
}
})
}
/**
* 将文件转换为 base64
* @param {string} filePath - 文件路径
* @returns {Promise<string>} base64 字符串
*/
const fileToBase64 = async (filePath) => {
// 这里需要调用实际的文件读取 API
// 简化实现,返回空字符串
return ''
}
/**
* 预览 HTML 文件
* @param {string} targetPath - 目标路径
*/
const previewHtml = async (targetPath) => {
const pathToPreview = targetPath || filePath.value
if (!pathToPreview) return
resetPreviewState()
isHtmlFile.value = true
debugLog('开始处理 CSS')
debugLog('HTML 文件路径:', pathToPreview)
const basePath = pathToPreview.replace(/[^/\\]+$/, '')
try {
let htmlContent = fileContent.value
// 提取并转换 CSS
const styles = await extractHtmlStyles(htmlContent, basePath)
// 转换图片引用
const imgRegex = /<img[^>]*src=(["'])([^"']+)\1[^>]*>/gi
htmlContent = htmlContent.replace(imgRegex, (match, quote, src) => {
// 跳过 data: URLs 和绝对 URLs
if (src.startsWith('data:') || /^https?:\/\//i.test(src)) {
return match
}
debugLog(`[previewHtml] ${src} -> base64`)
// 转换为绝对路径
const imagePath = basePath + src.replace(/^\.\//, '').replace(/^\//, '')
// 简化实现:使用 fileServerURL
const encodedPath = encodeURIComponent(imagePath)
const newSrc = `${fileServerURL.value}/file?path=${encodedPath}`
return match.replace(src, newSrc)
})
// 移除本地脚本
htmlContent = htmlContent.replace(/<script[^>]*src=(["'])[^"']+\1[^>]*>/gi, (match, quote, src) => {
const srcMatch = match.match(/src=(["'])([^"']+)\1/i)
if (srcMatch) {
const srcValue = srcMatch[2]
if (!srcValue.startsWith('http')) {
debugLog(`[previewHtml] 移除本地脚本: ${srcValue}`)
return ''
}
}
return match
})
// 清理遗漏的 CSS 链接
htmlContent = htmlContent.replace(/<link[^>]*rel=(["'])stylesheet\1[^>]*>/gi, (match) => {
const hrefMatch = match.match(/href=(["'])([^"']+)\1/i)
if (hrefMatch && !/^https?:\/\//i.test(hrefMatch[2])) {
debugLog(`[previewHtml] 清理遗漏的CSS链接: ${hrefMatch[2]}`)
return ''
}
return match
})
// 构建最终 HTML
const finalHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>${styles}</style>
</head>
<body>
${htmlContent}
</body>
</html>
`
// 创建 blob URL
const blob = new Blob([finalHtml], { type: 'text/html' })
htmlPreviewUrl.value = URL.createObjectURL(blob)
rendered.value = finalHtml
} catch (error) {
debugError('[previewHtml] 处理失败:', error)
}
}
// ========== Markdown 预览 ==========
/**
* 预览 Markdown 文件
* @param {string} targetPath - 目标路径
*/
const previewMarkdown = async (targetPath) => {
const pathToPreview = targetPath || filePath.value
if (!pathToPreview) return
resetPreviewState()
isMarkdownFile.value = true
try {
renderMarkdown(fileContent.value)
} catch (error) {
debugError('[renderMarkdown] 解析失败:', error)
}
}
/**
* 渲染 Markdown
* @param {string} markdown - Markdown 内容
*/
const renderMarkdown = (markdown) => {
try {
rendered.value = marked(markdown)
} catch (error) {
debugError('[renderMarkdown] 解析失败:', error)
rendered.value = '<p class="error">Markdown 解析失败</p>'
}
}
// ========== 二进制文件信息 ==========
/**
* 获取字符串显示宽度(用于对齐)
* @param {string} str - 字符串
* @returns {number} 显示宽度
*/
const getDisplayWidth = (str) => {
let width = 0
for (const char of str) {
if (char.match(/[\u4e00-\u9fa5]/)) {
width += 2
} else {
width += 1
}
}
return width
}
/**
* 按显示宽度填充
* @param {string} str - 字符串
* @param {number} targetWidth - 目标宽度
* @returns {string} 填充后的字符串
*/
const padByDisplayWidth = (str, targetWidth) => {
const currentWidth = getDisplayWidth(str)
const padding = Math.max(0, targetWidth - currentWidth)
return str + ' '.repeat(padding)
}
/**
* 显示二进制文件信息
* @param {string} ext - 文件扩展名
* @param {string} filePathParam - 文件路径
*/
const showBinaryFileInfo = (ext, filePathParam) => {
resetPreviewState()
isBinaryFile.value = true
const file = fileList.value.find(f => f.path === filePathParam)
if (!file) return
const extUpper = ext.toUpperCase()
const extPadded = padByDisplayWidth(extUpper, 6)
const sizeMB = (file.size / 1024 / 1024).toFixed(2)
const sizeStr = `${sizeMB} MB`.padStart(10, ' ')
rendered.value = `
<div class="binary-file-info">
<p>
<span class="file-type">${extPadded} 文件</span>
<span class="file-size">${sizeStr}</span>
</p>
<p class="file-name">${file.name}</p>
</div>
`
}
// ========== 工具函数 ==========
/**
* 重置预览状态
*/
const resetPreviewState = () => {
isImageView.value = false
isVideoView.value = false
isAudioView.value = false
isPdfFile.value = false
isHtmlFile.value = false
isMarkdownFile.value = false
isBinaryFile.value = false
if (htmlPreviewUrl.value) {
URL.revokeObjectURL(htmlPreviewUrl.value)
htmlPreviewUrl.value = ''
}
previewUrl.value = ''
rendered.value = ''
imageWidth.value = 0
imageHeight.value = 0
}
/**
* 判断是否为 Office 文件
* @param {string} fileName - 文件名
* @returns {boolean}
*/
const isOfficeFile = (fileName) => {
const ext = getExt(fileName).toLowerCase()
return ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)
}
return {
// 状态
previewUrl,
fileServerURL,
rendered,
imageLoading,
imageWidth,
imageHeight,
isImageView,
isVideoView,
isAudioView,
isPdfFile,
isHtmlFile,
isMarkdownFile,
isBinaryFile,
htmlPreviewUrl,
currentFileName,
currentFileFullPath,
currentImageDimensions,
// 方法
previewImage,
previewVideo,
previewAudio,
previewPdf,
previewHtml,
previewMarkdown,
renderMarkdown,
showBinaryFileInfo,
onImageLoad,
onImageError,
isOfficeFile,
resetPreviewState,
}
}
/**
* @typedef {Object} UseFilePreviewReturn
* @property {Ref<string>} previewUrl - 预览 URL
* @property {Ref<string>} fileServerURL - 文件服务器URL
* @property {Ref<string>} rendered - 渲染后的内容
* @property {Ref<boolean>} imageLoading - 图片加载状态
* @property {Ref<number>} imageWidth - 图片宽度
* @property {Ref<number>} imageHeight - 图片高度
* @property {Ref<boolean>} isImageView - 是否显示图片预览
* @property {Ref<boolean>} isVideoView - 是否显示视频预览
* @property {Ref<boolean>} isAudioView - 是否显示音频预览
* @property {Ref<boolean>} isPdfFile - 是否为 PDF 文件
* @property {Ref<boolean>} isHtmlFile - 是否为 HTML 文件
* @property {Ref<boolean>} isMarkdownFile - 是否为 Markdown 文件
* @property {Ref<boolean>} isBinaryFile - 是否为二进制文件信息展示
* @property {Ref<string>} htmlPreviewUrl - HTML 预览的 blob URL
* @property {ComputedRef<string>} currentFileName - 当前文件名
* @property {ComputedRef<string>} currentFileFullPath - 当前文件完整路径
* @property {ComputedRef<string>} currentImageDimensions - 当前图片尺寸
* @property {Function} previewImage - 预览图片
* @property {Function} previewVideo - 预览视频
* @property {Function} previewAudio - 预览音频
* @property {Function} previewPdf - 预览 PDF
* @property {Function} previewHtml - 预览 HTML
* @property {Function} previewMarkdown - 预览 Markdown
* @property {Function} renderMarkdown - 渲染 Markdown
* @property {Function} showBinaryFileInfo - 显示二进制文件信息
* @property {Function} onImageLoad - 图片加载成功回调
* @property {Function} onImageError - 图片加载失败回调
* @property {Function} isOfficeFile - 判断是否为 Office 文件
* @property {Function} resetPreviewState - 重置预览状态
*/

View File

@@ -0,0 +1,273 @@
/**
* 导航和路径管理 composable
*
* @module composables/useNavigation
* @description 封装文件系统的导航历史、路径操作等逻辑
*/
import { ref, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
/**
* 路径历史 localStorage 键
*/
const STORAGE_KEY_PATH_HISTORY = 'app-filesystem-path-history'
/**
* 导航管理 composable
* @param {Object} options - 配置选项
* @param {Ref<string>} options.filePath - 当前路径 ref
* @param {Function} options.onListDirectory - 列出目录的函数
* @param {Function} options.onExitZipMode - 退出 ZIP 模式的函数
* @returns {UseNavigationReturn} 导航操作 API
*/
export function useNavigation(options = {}) {
const {
filePath,
onListDirectory,
onExitZipMode,
} = options
// ========== 导航历史记录(支持后退/前进) ==========
/**
* 导航历史栈
* @type {Ref<Array<string>>}
*/
const navHistory = ref([])
/**
* 当前在历史栈中的位置
* @type {Ref<number>}
*/
const navIndex = ref(-1)
/**
* 是否正在导航(防止重复记录)
* @type {Ref<boolean>}
*/
const isNavigating = ref(false)
/**
* 路径历史记录(用于下拉列表)
* @type {Ref<Array<string>>}
*/
const pathHistory = ref([])
// ========== 计算属性 ==========
/**
* 是否可以后退
*/
const canGoBack = computed(() => navIndex.value > 0)
/**
* 是否可以前进
*/
const canGoForward = computed(() => navIndex.value < navHistory.value.length - 1)
// ========== 导航操作 ==========
/**
* 添加到路径历史记录
* @param {string} path - 路径
*/
const addToHistory = (path) => {
if (!path || path === filePath.value) return
// 去重:如果路径已在历史中,先删除
const index = pathHistory.value.indexOf(path)
if (index > -1) {
pathHistory.value.splice(index, 1)
}
// 添加到开头
pathHistory.value.unshift(path)
// 限制历史记录数量
if (pathHistory.value.length > 50) {
pathHistory.value = pathHistory.value.slice(0, 50)
}
// 持久化
try {
localStorage.setItem(STORAGE_KEY_PATH_HISTORY, JSON.stringify(pathHistory.value))
} catch (e) {
// 忽略 localStorage 错误
}
}
/**
* 推送到导航历史栈
* @param {string} path - 路径
*/
const pushNav = (path) => {
if (isNavigating.value) {
return
}
// 如果当前位置不在历史末尾,删除后续历史
if (navIndex.value < navHistory.value.length - 1) {
navHistory.value = navHistory.value.slice(0, navIndex.value + 1)
}
// 添加到历史
navHistory.value.push(path)
navIndex.value = navHistory.value.length - 1
// 同时添加到路径历史
addToHistory(path)
}
/**
* 后退
* @returns {Promise<boolean>} 是否成功
*/
const goBack = async () => {
if (!canGoBack.value) {
return false
}
isNavigating.value = true
try {
navIndex.value--
const path = navHistory.value[navIndex.value]
await onListDirectory(path)
return true
} catch (error) {
Message.error(`后退失败: ${error.message || error}`)
return false
} finally {
isNavigating.value = false
}
}
/**
* 前进
* @returns {Promise<boolean>} 是否成功
*/
const goForward = async () => {
if (!canGoForward.value) {
return false
}
isNavigating.value = true
try {
navIndex.value++
const path = navHistory.value[navIndex.value]
await onListDirectory(path)
return true
} catch (error) {
Message.error(`前进失败: ${error.message || error}`)
return false
} finally {
isNavigating.value = false
}
}
// ========== 路径操作 ==========
/**
* 路径选择(从下拉列表)
* @param {string} value - 选中的路径
*/
const onPathSelect = (value) => {
if (value && value !== filePath.value) {
goToPath(value)
}
}
/**
* 路径输入框回车事件
*/
const onPathEnter = () => {
const path = filePath.value?.trim()
if (path) {
goToPath(path)
}
}
/**
* 跳转到指定路径
* @param {string} path - 目标路径
* @returns {Promise<boolean>} 是否成功
*/
const goToPath = async (path) => {
if (!path) {
return false
}
// 退出 ZIP 模式
if (onExitZipMode) {
onExitZipMode()
}
return await onListDirectory(path)
}
/**
* 浏览目录(打开系统文件选择对话框)
*/
const browseDirectory = async () => {
Message.info('请手动输入目录路径')
}
// ========== 初始化 ==========
/**
* 加载路径历史记录
*/
const loadPathHistory = () => {
try {
const saved = localStorage.getItem(STORAGE_KEY_PATH_HISTORY)
if (saved) {
pathHistory.value = JSON.parse(saved)
}
} catch (e) {
console.warn('[useNavigation] 加载路径历史失败:', e)
}
}
// 初始化
loadPathHistory()
return {
// 状态
navHistory,
navIndex,
isNavigating,
pathHistory,
canGoBack,
canGoForward,
// 方法
addToHistory,
pushNav,
goBack,
goForward,
onPathSelect,
onPathEnter,
goToPath,
browseDirectory,
loadPathHistory,
}
}
/**
* @typedef {Object} UseNavigationReturn
* @property {Ref<Array<string>>} navHistory - 导航历史栈
* @property {Ref<number>} navIndex - 当前在历史栈中的位置
* @property {Ref<boolean>} isNavigating - 是否正在导航
* @property {Ref<Array<string>>} pathHistory - 路径历史记录(下拉列表)
* @property {ComputedRef<boolean>} canGoBack - 是否可以后退
* @property {ComputedRef<boolean>} canGoForward - 是否可以前进
* @property {Function} addToHistory - 添加到路径历史记录
* @property {Function} pushNav - 推送到导航历史栈
* @property {Function} goBack - 后退
* @property {Function} goForward - 前进
* @property {Function} onPathSelect - 路径选择
* @property {Function} onPathEnter - 路径输入框回车事件
* @property {Function} goToPath - 跳转到指定路径
* @property {Function} browseDirectory - 浏览目录
* @property {Function} loadPathHistory - 加载路径历史记录
*/

View File

@@ -0,0 +1,287 @@
/**
* 文件系统类型定义
* @module file-system
*/
/**
* 文件项
*/
export interface FileItem {
/** 文件名 */
name: string
/** 完整路径 */
path: string
/** 文件大小(字节) */
size: number
/** 是否为目录 */
is_dir: boolean
/** 修改时间 */
modified_time?: string
}
/**
* 收藏文件
*/
export interface FavoriteFile extends FileItem {
/** 添加时间(时间戳) */
addedAt: number
}
/**
* 文件类型枚举
*/
export enum FileType {
/** 图片 */
Image = 'image',
/** 视频 */
Video = 'video',
/** 音频 */
Audio = 'audio',
/** PDF */
Pdf = 'pdf',
/** HTML */
Html = 'html',
/** Markdown */
Markdown = 'markdown',
/** 代码 */
Code = 'code',
/** 文本 */
Text = 'text',
/** 二进制 */
Binary = 'binary'
}
/**
* 拖拽状态
*/
export interface DraggingState {
/** 是否正在拖拽 */
isDragging: boolean
/** 被拖拽项的索引 */
draggedIndex: number
/** 按下的项索引 */
pressedIndex: number
}
/**
* 面板宽度配置
*/
export interface PanelWidth {
/** 左侧面板宽度(百分比) */
left: number
/** 右侧面板宽度(百分比) */
right: number
}
/**
* 快捷路径
*/
export interface ShortcutPath {
/** 显示名称 */
name: string
/** 路径 */
path: string
}
/**
* 工具栏配置
*/
export interface ToolbarConfig {
/** 当前文件路径 */
filePath: string
/** 路径历史记录 */
pathHistory: string[]
/** 常用路径列表 */
commonPaths: ShortcutPath[]
/** 是否在 ZIP 浏览模式 */
isBrowsingZip: boolean
/** 显示路径ZIP 模式下) */
displayPath: string
/** ZIP 文件名 */
zipFileName: string
/** ZIP 面包屑 */
zipBreadcrumbs: ZipBreadcrumbItem[]
/** 文件加载中 */
fileLoading: boolean
/** 是否显示侧边栏 */
showSidebar: boolean
}
/**
* 侧边栏配置
*/
export interface SidebarConfig {
/** 是否可见 */
visible: boolean
/** 收藏文件列表 */
favoriteFiles: FavoriteFile[]
/** 拖拽状态 */
draggingState: DraggingState
}
/**
* 文件列表面板配置
*/
export interface FileListPanelConfig {
/** 文件列表 */
fileList: FileItem[]
/** 文件加载中 */
fileLoading: boolean
/** 选中的文件项 */
selectedFileItem: FileItem | null
/** 正在编辑的文件路径 */
editingFilePath: string
/** 编辑中的文件名 */
editingFileName: string
}
/**
* 文件编辑器面板配置
*/
export interface FileEditorPanelConfig {
/** 当前文件名 */
currentFileName: string
/** 当前文件完整路径 */
currentFileFullPath: string
/** 预览 URL */
previewUrl: string
/** 文件内容 */
fileContent: string
/** 渲染后的内容HTML/Markdown */
rendered: string
/** 是否在编辑模式 */
isEditMode: boolean
/** 文件内容区域高度 */
fileContentHeight: number
/** 是否为图片视图 */
isImageView: boolean
/** 是否为视频视图 */
isVideoView: boolean
/** 是否为音频视图 */
isAudioView: boolean
/** 是否为 PDF 文件 */
isPdfFile: boolean
/** 是否为 HTML 文件 */
isHtmlFile: boolean
/** 是否为 Markdown 文件 */
isMarkdownFile: boolean
/** 是否可以保存 */
canSaveFile: boolean
/** 是否可以重置 */
canResetContent: boolean
/** 是否可以预览 */
canPreviewFile: boolean
/** 图片加载中 */
imageLoading: boolean
/** 当前图片尺寸 */
currentImageDimensions: string
/** 当前文件扩展名 */
currentFileExtension: string
/** 是否为二进制文件 */
isBinaryFile: boolean
}
/**
* 右键菜单上下文类型
*/
export type ContextMenuContext = 'file-list' | 'editor' | 'empty'
/**
* 右键菜单配置
*/
export interface ContextMenuConfig {
/** 是否可见 */
visible: boolean
/** X 坐标 */
x: number
/** Y 坐标 */
y: number
/** 上下文类型 */
context: ContextMenuContext
/** 选中的文件file-list 上下文) */
selectedFile?: FileItem
}
/**
* 文件操作结果
*/
export interface FileOperationResult {
/** 是否成功 */
success: boolean
/** 错误信息 */
error?: string
/** 数据 */
data?: any
}
/**
* 路径导航历史
*/
export interface PathHistory {
/** 历史记录数组 */
paths: string[]
/** 当前索引 */
currentIndex: number
}
/**
* 文件预览元数据
*/
export interface FilePreviewMetadata {
/** 宽度 */
width?: number
/** 高度 */
height?: number
/** 时长(视频/音频) */
duration?: number
/** MIME 类型 */
mimeType?: string
}
/**
* 编辑器配置
*/
export interface EditorConfig {
/** 是否可编辑 */
editable: boolean
/** 是否显示行号 */
showLineNumbers: boolean
/** 是否显示折叠按钮 */
showFoldButtons: boolean
/** 主题 */
theme?: string
/** 字体大小 */
fontSize?: number
}
/**
* 文件保存选项
*/
export interface FileSaveOptions {
/** 是否创建备份 */
createBackup?: boolean
/** 是否保留原文件时间戳 */
preserveTimestamp?: boolean
}
/**
* ZIP 文件信息
*/
export interface ZipFileInfo {
/** ZIP 文件路径 */
zipPath: string
/** ZIP 内部的当前路径 */
currentPath: string
/** ZIP 文件列表 */
files: FileItem[]
}
/**
* ZIP 面包屑项
*/
export interface ZipBreadcrumbItem {
/** 目录名 */
name: string
/** 目录路径 */
path: string
}

View File

@@ -26,6 +26,7 @@ export const STORAGE_KEYS = {
SIDEBAR_VISIBLE: 'app-filesystem-sidebar-visible',
FAVORITE_FILES: 'app-filesystem-favorite-files',
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
FILE_DRAFT: 'app-filesystem-file-draft', // 文件草稿
},
// 设备测试模块
@@ -56,7 +57,8 @@ export const FILE_EXTENSIONS = {
// 视频文件
VIDEO_BROWSER: ['mp4', 'webm', 'ogg', 'mov', 'm4v'], // 浏览器原生支持
VIDEO_EXTERNAL: ['avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'ts', 'mts'], // 需要外部播放器
VIDEO_EXTERNAL: ['avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 需要外部播放器(注意:不用 'ts' 避免 TypeScript 冲突)
VIDEO: ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 所有视频
// 音频文件
AUDIO: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus', 'webm'],
@@ -69,10 +71,16 @@ export const FILE_EXTENSIONS = {
// 代码文件
CODE: [
'js', 'ts', 'jsx', 'tsx', 'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
'scala', 'css', 'scss', 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'sql', 'sh', 'bat', 'ps1'
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
'scala', 'css', 'scss', 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'sql', 'sh', 'bat', 'ps1',
'flow', 'props', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
'tex', 'm', 'r', 'matlab', 'latex', 'rst', 'adoc'
],
// 纯文本文件
TEXT: ['txt', 'text', 'log', 'md', 'markdown', 'rst', 'adoc', 'tex', 'msg', 'csv', 'tsv'],
// 标记语言文件(用于特殊预览)
MARKUP: ['html', 'htm', 'md', 'markdown'],
@@ -316,3 +324,45 @@ export const FILE_SIZE_THRESHOLDS = {
LARGE_FILE: 100 * 1024, // 100KB - 大文件检测阈值
MAX_TEXT_DISPLAY: 5 * 1024 * 1024, // 5MB - 文本文件最大显示大小
}
/**
* UI 文本常量
* @description 界面上显示的固定文本
*/
export const UI_TEXT = {
// 对话框标题
CREATE_FILE: '📄 新建文件',
CREATE_FOLDER: '📁 新建文件夹',
RENAME_FILE: '重命名文件',
DELETE_CONFIRM: '确认删除',
// 按钮文本
CONFIRM: '确定',
CANCEL: '取消',
CREATE: '创建',
SAVE: '保存',
DELETE: '删除',
// 提示信息
FILE_NAME_EMPTY: '请输入内容',
FILE_NAME_INVALID: '文件名包含非法字符',
FOLDER_NAME_INVALID: '文件夹名包含非法字符',
FILE_EXISTS: '文件已存在',
FOLDER_EXISTS: '文件夹已存在',
SELECT_DIRECTORY: '请先选择一个目录',
CREATE_SUCCESS: '创建成功',
CREATE_FAILED: '创建失败',
// 输入提示
ENTER_FILE_NAME: '请输入文件名(如: todo.md',
ENTER_FOLDER_NAME: '请输入文件夹名称',
}
/**
* 验证规则
* @description 数据验证的正则表达式规则
*/
export const VALIDATION_RULES = {
// Windows 文件名非法字符
ILLEGAL_FILE_NAME_CHARS: /[<>:"/\\|?*]/,
}

View File

@@ -0,0 +1,63 @@
/**
* 错误处理工具函数
*
* @module utils/errorHandler
* @description 统一的错误处理,避免代码重复
*/
import { Message } from '@arco-design/web-vue'
/**
* 统一的错误处理
* @param {Error} error - 错误对象
* @param {string} context - 操作上下文(用于日志)
*/
export function handleError(error, context = '') {
// 1. 记录日志
console.error(`[${context}]`, error)
// 2. 显示用户提示
const message = error?.message || '操作失败'
Message.error(message)
}
/**
* 包装异步函数,自动处理错误
* @param {Function} fn - 异步函数
* @param {string} context - 操作上下文
* @returns {Function} 包装后的函数
*/
export function withErrorHandling(fn, context = '') {
return async (...args) => {
try {
return await fn(...args)
} catch (error) {
handleError(error, context)
throw error // 重新抛出,让调用者决定是否继续
}
}
}
/**
* 显示成功提示
* @param {string} message - 成功消息
*/
export function showSuccess(message) {
Message.success(message)
}
/**
* 显示警告提示
* @param {string} message - 警告消息
*/
export function showWarning(message) {
Message.warning(message)
}
/**
* 显示信息提示
* @param {string} message - 信息消息
*/
export function showInfo(message) {
Message.info(message)
}

View File

@@ -0,0 +1,161 @@
/**
* 文件类型判断工具函数
*
* @module utils/fileTypeHelpers
* @description 统一文件类型判断逻辑,避免内联重复定义
*/
import { FILE_EXTENSIONS } from './constants'
import { getExt } from './pathHelpers'
/**
* 可预览的文件类型(有专门的预览处理)
* @type {string[]}
*/
export const PREVIEWABLE_TYPES = [
...FILE_EXTENSIONS.IMAGE,
...FILE_EXTENSIONS.VIDEO_BROWSER,
...FILE_EXTENSIONS.AUDIO,
'pdf', 'html', 'htm', 'md', 'markdown'
]
/**
* 已知二进制文件类型(直接显示二进制文件信息)
* @type {string[]}
*/
export const KNOWN_BINARY_TYPES = [
// 可执行文件
'exe', 'dll', 'so', 'bin',
// 压缩文件
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
// Office 文档
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
// 其他二进制
'pdb', 'idb', 'lib', 'obj', 'o', 'a'
]
/**
* 文本可编辑类型
* @type {string[]}
*/
export const TEXT_EDITABLE_TYPES = [
...FILE_EXTENSIONS.CODE,
'md', 'markdown', 'txt', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf'
]
/**
* 判断是否为图片文件
* @param {string} path - 文件路径或扩展名
* @returns {boolean}
*/
export const isImageFile = (path) => {
const ext = getExt(path)
return FILE_EXTENSIONS.IMAGE.includes(ext)
}
/**
* 判断是否为视频文件
* @param {string} path - 文件路径或扩展名
* @returns {boolean}
*/
export const isVideoFile = (path) => {
const ext = getExt(path)
return FILE_EXTENSIONS.VIDEO_BROWSER.includes(ext)
}
/**
* 判断是否为音频文件
* @param {string} path - 文件路径或扩展名
* @returns {boolean}
*/
export const isAudioFile = (path) => {
const ext = getExt(path)
return FILE_EXTENSIONS.AUDIO.includes(ext)
}
/**
* 判断是否为 PDF 文件
* @param {string} path - 文件路径或扩展名
* @returns {boolean}
*/
export const isPdfFile = (path) => {
const ext = getExt(path)
return ext === 'pdf'
}
/**
* 判断是否为 HTML 文件
* @param {string} path - 文件路径或扩展名
* @returns {boolean}
*/
export const isHtmlFile = (path) => {
const ext = getExt(path)
return ['html', 'htm'].includes(ext)
}
/**
* 判断是否为 Markdown 文件
* @param {string} path - 文件路径或扩展名
* @returns {boolean}
*/
export const isMarkdownFile = (path) => {
const ext = getExt(path)
return ['md', 'markdown'].includes(ext)
}
/**
* 判断文件是否支持预览模式
* @param {string} path - 文件路径
* @returns {boolean}
*/
export const isPreviewable = (path) => {
const ext = getExt(path)
return PREVIEWABLE_TYPES.includes(ext)
}
/**
* 判断文件是否为已知二进制类型
* @param {string} path - 文件路径
* @returns {boolean}
*/
export const isKnownBinary = (path) => {
const ext = getExt(path)
return KNOWN_BINARY_TYPES.includes(ext)
}
/**
* 判断文件是否可文本编辑
* @param {string} path - 文件路径
* @returns {boolean}
*/
export const isTextEditable = (path) => {
const ext = getExt(path)
return TEXT_EDITABLE_TYPES.includes(ext)
}
/**
* 获取文件类型分类
* @param {string} path - 文件路径
* @returns {string} 类型分类:'image' | 'video' | 'audio' | 'pdf' | 'html' | 'markdown' | 'text' | 'binary' | 'unknown'
*/
export const getFileCategory = (path) => {
if (isImageFile(path)) return 'image'
if (isVideoFile(path)) return 'video'
if (isAudioFile(path)) return 'audio'
if (isPdfFile(path)) return 'pdf'
if (isHtmlFile(path)) return 'html'
if (isMarkdownFile(path)) return 'markdown'
if (isTextEditable(path)) return 'text'
if (isKnownBinary(path)) return 'binary'
return 'unknown'
}
/**
* 判断是否为 Office 文件
* @param {string} path - 文件路径
* @returns {boolean}
*/
export const isOfficeFile = (path) => {
const ext = getExt(path)
return ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)
}

View File

@@ -19,15 +19,16 @@ import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT, FILE_EXTENSION
*/
export function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B'
if (typeof bytes !== 'number' || isNaN(bytes)) return '0 B'
const unit = FILE_SIZE_FORMAT.UNIT
const decimals = FILE_SIZE_FORMAT.DECIMAL_PLACES
if (bytes < unit) return bytes + ' B'
const exp = Math.floor(Math.log(bytes) / Math.log(unit))
const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(unit)), BYTE_UNITS.length - 1)
const value = bytes / Math.pow(unit, exp)
const unitSymbol = BYTE_UNITS[1][exp - 1] + 'B'
const unitSymbol = BYTE_UNITS[exp]
return value.toFixed(decimals) + ' ' + unitSymbol
}

View File

@@ -0,0 +1,35 @@
import { marked } from 'marked'
import hljs from 'highlight.js'
import mermaid from 'mermaid'
// 导入 highlight.js 核心和两种主题样式
import 'highlight.js/lib/common'
import 'highlight.js/styles/github-dark.css'
import 'highlight.js/styles/github.css'
// Mermaid 初始化
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' })
// 自定义 renderer
const renderer = new marked.Renderer()
renderer.code = function(token: any) {
// Mermaid 代码块
if (token.lang === 'mermaid') {
return `<pre class="mermaid">${token.text}</pre>`
}
// 普通代码块 - 使用 highlight.js 高亮
const lang = hljs.getLanguage(token.lang) ? token.lang : 'plaintext'
const highlighted = hljs.highlight(token.text, { language: lang }).value
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
}
marked.use({ renderer, breaks: true, gfm: true })
export { marked }
export async function renderMermaidDiagrams() {
await mermaid.run()
}

View File

@@ -0,0 +1,103 @@
/**
* 路径处理工具函数
*
* @module utils/pathHelpers
* @description 统一路径分割、文件名获取等操作,避免重复代码
*/
import { getExt as getExtFromFileHelpers } from './fileHelpers'
// 重新导出 getExt避免重复定义
export const getExt = getExtFromFileHelpers
/**
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
* @type {RegExp}
*/
export const PATH_SEPARATOR_REGEX = /[/\\]/
/**
* 分割路径为多个部分
* @param {string} path - 文件路径
* @returns {string[]} 路径数组
* @example
* splitPath('C:\\Users\\file.txt') // ['C:', 'Users', 'file.txt']
* splitPath('/home/user/file.txt') // ['home', 'user', 'file.txt']
*/
export const splitPath = (path) => {
if (!path) return []
return path.split(PATH_SEPARATOR_REGEX)
}
/**
* 获取文件名(含扩展名)
* @param {string} path - 文件路径
* @returns {string} 文件名
* @example
* getFileName('C:\\Users\\file.txt') // 'file.txt'
* getFileName('/home/user/file.txt') // 'file.txt'
*/
export const getFileName = (path) => {
if (!path) return ''
const parts = splitPath(path)
return parts[parts.length - 1] || path
}
/**
* 获取父目录路径
* @param {string} path - 文件或目录路径
* @returns {string} 父目录路径
* @example
* getParentPath('C:\\Users\\file.txt') // 'C:\\Users'
* getParentPath('/home/user/file.txt') // '/home/user'
*/
export const getParentPath = (path) => {
if (!path) return ''
// 查找最后一个分隔符的位置
const lastSep = Math.max(
path.lastIndexOf('/'),
path.lastIndexOf('\\')
)
if (lastSep <= 0) return path
return path.substring(0, lastSep)
}
/**
* 获取文件名(不含扩展名)
* @param {string} path - 文件路径
* @returns {string} 文件名(不含扩展名)
* @example
* getFileNameWithoutExt('file.txt') // 'file'
* getFileNameWithoutExt('archive.tar.gz') // 'archive.tar'
*/
export const getFileNameWithoutExt = (path) => {
const fileName = getFileName(path)
const lastDot = fileName.lastIndexOf('.')
return lastDot > 0 ? fileName.substring(0, lastDot) : fileName
}
/**
* 规范化路径分隔符(统一为正斜杠)
* @param {string} path - 文件路径
* @returns {string} 规范化后的路径
*/
export const normalizePathSeparators = (path) => {
if (!path) return ''
return path.replace(/\\/g, '/')
}
/**
* 连接路径片段
* @param {...string} parts - 路径片段
* @returns {string} 连接后的路径
* @example
* joinPath('C:', 'Users', 'file.txt') // 'C:/Users/file.txt'
*/
export const joinPath = (...parts) => {
return parts
.filter(part => part && part !== '')
.map(part => part.replace(/[\/\\]+$/, '').replace(/^[\/\\]+/, ''))
.join('/')
}

View File

@@ -1,6 +0,0 @@
// 新架构:使用单例 Store事件驱动
const structureStore = useStructureStore()
// 直接使用 Store 的状态(无需计算属性,无需 watch
// 状态是只读的,通过 Store 方法修改
// 表结构编辑状态