重构:文件系统模块化架构,增强 Markdown 渲染
- 拆分 FileSystem.vue 为模块化组件架构 - 新增 Markdown Mermaid 图表渲染支持 - 新增 180+ 编程语言代码高亮 - 修复编辑/预览模式切换渲染问题 - 优化亮色/暗色模式主题适配 - 新增 TypeScript 类型定义
This commit is contained in:
166
web/src/components/FileSystem/components/ContextMenu.vue
Normal file
166
web/src/components/FileSystem/components/ContextMenu.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
190
web/src/components/FileSystem/components/FileEditorPanel.new.vue
Normal file
190
web/src/components/FileSystem/components/FileEditorPanel.new.vue
Normal 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>
|
||||
863
web/src/components/FileSystem/components/FileEditorPanel.vue
Normal file
863
web/src/components/FileSystem/components/FileEditorPanel.vue
Normal 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>
|
||||
256
web/src/components/FileSystem/components/FileItemRow.vue
Normal file
256
web/src/components/FileSystem/components/FileItemRow.vue
Normal 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>
|
||||
215
web/src/components/FileSystem/components/FileListPanel.vue
Normal file
215
web/src/components/FileSystem/components/FileListPanel.vue
Normal 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>
|
||||
244
web/src/components/FileSystem/components/Sidebar.vue
Normal file
244
web/src/components/FileSystem/components/Sidebar.vue
Normal 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>
|
||||
309
web/src/components/FileSystem/components/Toolbar.vue
Normal file
309
web/src/components/FileSystem/components/Toolbar.vue
Normal 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>
|
||||
94
web/src/components/FileSystem/composables/useCommonPaths.ts
Normal file
94
web/src/components/FileSystem/composables/useCommonPaths.ts
Normal 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
|
||||
}
|
||||
}
|
||||
231
web/src/components/FileSystem/composables/useFavorites.ts
Normal file
231
web/src/components/FileSystem/composables/useFavorites.ts
Normal 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
|
||||
}
|
||||
}
|
||||
576
web/src/components/FileSystem/composables/useFileEdit.ts
Normal file
576
web/src/components/FileSystem/composables/useFileEdit.ts
Normal 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
|
||||
}
|
||||
}
|
||||
264
web/src/components/FileSystem/composables/useFileOperations.ts
Normal file
264
web/src/components/FileSystem/composables/useFileOperations.ts
Normal 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
|
||||
319
web/src/components/FileSystem/composables/useFilePreview.ts
Normal file
319
web/src/components/FileSystem/composables/useFilePreview.ts
Normal 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
|
||||
}
|
||||
}
|
||||
243
web/src/components/FileSystem/composables/usePathNavigation.ts
Normal file
243
web/src/components/FileSystem/composables/usePathNavigation.ts
Normal 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 }
|
||||
198
web/src/components/FileSystem/index-simple.vue
Normal file
198
web/src/components/FileSystem/index-simple.vue
Normal 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>
|
||||
1288
web/src/components/FileSystem/index.vue
Normal file
1288
web/src/components/FileSystem/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user