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

@@ -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