重构:文件系统模块化架构,增强 Markdown 渲染
- 拆分 FileSystem.vue 为模块化组件架构 - 新增 Markdown Mermaid 图表渲染支持 - 新增 180+ 编程语言代码高亮 - 修复编辑/预览模式切换渲染问题 - 优化亮色/暗色模式主题适配 - 新增 TypeScript 类型定义
This commit is contained in:
@@ -7,20 +7,20 @@
|
||||
</div>
|
||||
<a-tabs v-model:active-key="activeTab" class="header-tabs">
|
||||
<a-tab-pane
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.key"
|
||||
:title="tab.title"
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.key"
|
||||
:title="tab.title"
|
||||
/>
|
||||
</a-tabs>
|
||||
<div class="header-actions">
|
||||
<a-tooltip content="设置">
|
||||
<a-button type="text" @click="showSettings = true">
|
||||
<template #icon>
|
||||
<IconSettings />
|
||||
<IconSettings/>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<ThemeToggle />
|
||||
<ThemeToggle/>
|
||||
|
||||
<!-- 窗口控制按钮 -->
|
||||
<div class="window-controls">
|
||||
@@ -51,36 +51,35 @@
|
||||
<!-- 动态渲染 Tab 内容 -->
|
||||
<!-- 使用 KeepAlive 缓存组件状态,避免切换时重新加载 -->
|
||||
<KeepAlive include="FileSystem,DbCli,DeviceTest">
|
||||
<component :is="getComponent(activeTab)" />
|
||||
<component :is="getComponent(activeTab)"/>
|
||||
</KeepAlive>
|
||||
</a-layout-content>
|
||||
|
||||
<!-- 设置抽屉 -->
|
||||
<SettingsPanel
|
||||
v-model="showSettings"
|
||||
:config="appConfig"
|
||||
@save="handleSaveConfig"
|
||||
v-model="showSettings"
|
||||
:config="appConfig"
|
||||
@save="handleSaveConfig"
|
||||
/>
|
||||
|
||||
<!-- 升级提示弹窗 -->
|
||||
<UpdateNotification
|
||||
v-model="showUpdateNotification"
|
||||
:update-info="updateInfo"
|
||||
@install="handleUpdateInstall"
|
||||
@skip="handleUpdateSkip"
|
||||
v-model="showUpdateNotification"
|
||||
:update-info="updateInfo"
|
||||
@install="handleUpdateInstall"
|
||||
@skip="handleUpdateSkip"
|
||||
/>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { IconSettings } from '@arco-design/web-vue/es/icon'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {computed, onMounted, ref, watch} from 'vue'
|
||||
import {IconSettings} from '@arco-design/web-vue/es/icon'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import DeviceTest from './components/DeviceTest.vue'
|
||||
import DbCli from './views/db-cli/index.vue'
|
||||
import ThemeToggle from './components/ThemeToggle.vue'
|
||||
import UpdatePanel from './components/UpdatePanel.vue'
|
||||
import FileSystem from './components/FileSystem.vue'
|
||||
import FileSystem from './components/FileSystem/index.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import UpdateNotification from './components/UpdateNotification.vue'
|
||||
|
||||
@@ -110,19 +109,19 @@ const visibleTabs = computed(() => {
|
||||
if (!appConfig.value.tabs || appConfig.value.tabs.length === 0) {
|
||||
// 默认配置
|
||||
return [
|
||||
{ key: 'db-cli', title: '数据库' },
|
||||
{ key: 'file-system', title: '文件管理' },
|
||||
{ key: 'device', title: '设备调用测试' }
|
||||
{key: 'db-cli', title: '数据库'},
|
||||
{key: 'file-system', title: '文件管理'},
|
||||
{key: 'device', title: '设备调用测试'}
|
||||
]
|
||||
}
|
||||
|
||||
return appConfig.value.tabs
|
||||
.filter(tab => tab.visible)
|
||||
.sort((a, b) => {
|
||||
const aIndex = appConfig.value.visibleTabs.indexOf(a.key)
|
||||
const bIndex = appConfig.value.visibleTabs.indexOf(b.key)
|
||||
return aIndex - bIndex
|
||||
})
|
||||
.filter(tab => tab.visible)
|
||||
.sort((a, b) => {
|
||||
const aIndex = appConfig.value.visibleTabs.indexOf(a.key)
|
||||
const bIndex = appConfig.value.visibleTabs.indexOf(b.key)
|
||||
return aIndex - bIndex
|
||||
})
|
||||
})
|
||||
|
||||
// 加载配置
|
||||
@@ -170,9 +169,9 @@ const loadConfig = async () => {
|
||||
const useDefaultConfig = () => {
|
||||
appConfig.value = {
|
||||
tabs: [
|
||||
{ key: 'db-cli', title: '数据库', visible: true, enabled: true },
|
||||
{ key: 'file-system', title: '文件管理', visible: true, enabled: true },
|
||||
{ key: 'device', title: '设备调用测试', visible: true, enabled: true }
|
||||
{key: 'db-cli', title: '数据库', visible: true, enabled: true},
|
||||
{key: 'file-system', title: '文件管理', visible: true, enabled: true},
|
||||
{key: 'device', title: '设备调用测试', visible: true, enabled: true}
|
||||
],
|
||||
visibleTabs: ['db-cli', 'file-system', 'device'],
|
||||
defaultTab: 'db-cli'
|
||||
|
||||
@@ -108,6 +108,19 @@ export async function createFile(path: string): Promise<void> {
|
||||
await window.go.main.App.CreateFile(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件或目录
|
||||
*/
|
||||
export async function renamePath(oldPath: string, newPath: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.RenamePath) {
|
||||
throw new Error('RenamePath API 不可用')
|
||||
}
|
||||
await window.go.main.App.RenamePath({
|
||||
oldPath: String(oldPath),
|
||||
newPath: String(newPath)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取环境变量
|
||||
*/
|
||||
@@ -242,3 +255,24 @@ export async function resolveShortcut(lnkPath: string): Promise<{
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过文件内容检测文件类型(用于小文件,500KB以内)
|
||||
*/
|
||||
export async function detectFileTypeByContent(path: string): Promise<{
|
||||
extension: string
|
||||
category: string // 'image' | 'text' | 'binary' | 'pdf' | 'archive' | 'unknown'
|
||||
mime_type: string
|
||||
confidence: number
|
||||
}> {
|
||||
if (!window.go?.main?.App?.DetectFileTypeByContent) {
|
||||
throw new Error('DetectFileTypeByContent API 不可用')
|
||||
}
|
||||
try {
|
||||
const result = await window.go.main.App.DetectFileTypeByContent(path)
|
||||
return result as any
|
||||
} catch (error) {
|
||||
console.error('[API] detectFileTypeByContent 错误:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ import { xml } from '@codemirror/legacy-modes/mode/xml'
|
||||
// 文件扩展名到 CodeMirror 语言包的映射
|
||||
const LANGUAGE_MAP = {
|
||||
// JavaScript/TypeScript (使用 javascript 包)
|
||||
javascript: ['js', 'jsx', 'mjs', 'cjs'],
|
||||
typescript: ['ts', 'tsx'],
|
||||
javascript: ['js', 'jsx', 'mjs', 'cjs', 'cts', 'mts'],
|
||||
typescript: ['ts', 'tsx', 'cts', 'mts'],
|
||||
|
||||
// 数据格式
|
||||
json: ['json'],
|
||||
@@ -55,7 +55,7 @@ const LANGUAGE_MAP = {
|
||||
css: ['css', 'scss', 'sass', 'less'],
|
||||
|
||||
// 系统编程
|
||||
cpp: ['cpp', 'c', 'cc', 'cxx', 'h', 'hpp'],
|
||||
cpp: ['cpp', 'c', 'cc', 'cxx', 'h', 'hpp', 'hxx'],
|
||||
rust: ['rs'],
|
||||
go: ['go'],
|
||||
|
||||
@@ -64,7 +64,7 @@ const LANGUAGE_MAP = {
|
||||
php: ['php'],
|
||||
ruby: ['rb'],
|
||||
perl: ['pl', 'pm'],
|
||||
shell: ['sh', 'bash', 'zsh', 'fish', 'cmd', 'bat'],
|
||||
shell: ['sh', 'bash', 'zsh', 'fish', 'cmd', 'bat', 'ps1'],
|
||||
sql: ['sql'],
|
||||
|
||||
// JVM 语言
|
||||
@@ -116,7 +116,8 @@ const createExtensions = () => {
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
keymap.of(defaultKeymap),
|
||||
keymap.of(historyKeymap),
|
||||
// 不使用 historyKeymap,避免 Ctrl+Z 与外部重置功能冲突
|
||||
// 用户可以通过外部的重置按钮或 Ctrl+Z(全局快捷键)恢复原始内容
|
||||
bracketMatching(),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
@@ -315,7 +316,12 @@ watch(() => props.modelValue, (newValue) => {
|
||||
})
|
||||
|
||||
// 监听主题或文件扩展名变化,重建编辑器
|
||||
watch([isDark, () => props.fileExtension], recreateEditor)
|
||||
// 使用 nextTick 确保 DOM 更新完成后再重建,避免视觉抖动
|
||||
import { nextTick } from 'vue'
|
||||
watch([isDark, () => props.fileExtension], async () => {
|
||||
await nextTick()
|
||||
recreateEditor()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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
369
web/src/composables/useFileEdit.js
Normal file
369
web/src/composables/useFileEdit.js
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* 文件编辑和保存逻辑 composable
|
||||
*
|
||||
* @module composables/useFileEdit
|
||||
* @description 封装文件编辑、保存、草稿管理等逻辑
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { STORAGE_KEYS } from '@/utils/constants'
|
||||
|
||||
/**
|
||||
* 草稿存储键
|
||||
*/
|
||||
const DRAFT_STORAGE_KEY = 'filesystem_draft_content'
|
||||
|
||||
/**
|
||||
* 文件编辑 composable
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Ref<string>} options.filePath - 当前文件路径
|
||||
* @param {Ref<string>} options.fileContent - 文件内容
|
||||
* @param {Function} options.onWriteFile - 写入文件的函数
|
||||
* @param {Function} options.onReset - 重置内容的函数
|
||||
* @returns {UseFileEditReturn} 文件编辑操作 API
|
||||
*/
|
||||
export function useFileEdit(options = {}) {
|
||||
const {
|
||||
filePath,
|
||||
fileContent,
|
||||
onWriteFile,
|
||||
onReset,
|
||||
} = options
|
||||
|
||||
// ========== 编辑状态 ==========
|
||||
|
||||
/**
|
||||
* 是否正在保存
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isSaving = ref(false)
|
||||
|
||||
/**
|
||||
* 是否是快捷键触发的保存
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isShortcutSave = ref(false)
|
||||
|
||||
/**
|
||||
* 保存成功提示消息
|
||||
* @type {Ref<string>}
|
||||
*/
|
||||
const saveSuccessMessage = ref('')
|
||||
|
||||
/**
|
||||
* 原始文件内容(用于检测变更)
|
||||
* @type {Ref<string>}
|
||||
*/
|
||||
const originalContent = ref('')
|
||||
|
||||
/**
|
||||
* 是否为编辑模式
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isEditMode = ref(localStorage.getItem(STORAGE_KEYS.FILESYSTEM.EDIT_MODE) === 'true')
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
|
||||
/**
|
||||
* 文件内容是否已修改
|
||||
*/
|
||||
const isFileModified = computed(() => {
|
||||
return originalContent.value !== undefined &&
|
||||
originalContent.value !== fileContent.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 内容是否发生变化(用于按钮禁用判断)
|
||||
*/
|
||||
const contentChanged = computed(() => {
|
||||
return fileContent.value !== '' &&
|
||||
fileContent.value !== originalContent.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否可以保存文件
|
||||
*/
|
||||
const canSaveFile = computed(() => {
|
||||
return isEditMode.value && contentChanged.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否可以重置内容
|
||||
*/
|
||||
const canResetContent = computed(() => {
|
||||
return isEditMode.value &&
|
||||
contentChanged.value &&
|
||||
originalContent.value !== undefined
|
||||
})
|
||||
|
||||
// ========== 草稿管理 ==========
|
||||
|
||||
/**
|
||||
* 保存草稿到 localStorage
|
||||
*/
|
||||
const saveDraft = () => {
|
||||
try {
|
||||
const draft = {
|
||||
content: fileContent.value,
|
||||
path: filePath.value,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
|
||||
localStorage.setItem(DRAFT_STORAGE_KEY + '_time', Date.now().toString())
|
||||
} catch (error) {
|
||||
console.warn('[saveDraft] 保存草稿失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除草稿
|
||||
*/
|
||||
const clearDraft = () => {
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||
localStorage.removeItem(DRAFT_STORAGE_KEY + '_time')
|
||||
} catch (error) {
|
||||
console.warn('[clearDraft] 清除草稿失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载草稿
|
||||
* @returns {Object|null} 草稿数据
|
||||
*/
|
||||
const loadDraft = () => {
|
||||
try {
|
||||
const draftStr = localStorage.getItem(DRAFT_STORAGE_KEY)
|
||||
if (!draftStr) return null
|
||||
|
||||
const draft = JSON.parse(draftStr)
|
||||
|
||||
// 检查草稿是否过期(24小时)
|
||||
const timeStr = localStorage.getItem(DRAFT_STORAGE_KEY + '_time')
|
||||
if (timeStr) {
|
||||
const time = parseInt(timeStr, 10)
|
||||
const now = Date.now()
|
||||
const hours = (now - time) / (1000 * 60 * 60)
|
||||
|
||||
if (hours > 24) {
|
||||
clearDraft()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return draft
|
||||
} catch (error) {
|
||||
console.warn('[loadDraft] 加载草稿失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 保存操作 ==========
|
||||
|
||||
/**
|
||||
* 显示手动保存对话框
|
||||
* @param {boolean} isShortcut - 是否是快捷键触发
|
||||
*/
|
||||
const showManualSaveDialog = (isShortcut) => {
|
||||
isShortcutSave.value = isShortcut
|
||||
|
||||
Modal.confirm({
|
||||
title: '保存文件',
|
||||
content: `确定要保存文件 ${filePath.value} 吗?`,
|
||||
okText: '保存',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
saveToFile(filePath.value, getFileName(filePath.value), isShortcut)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到文件
|
||||
* @param {string} targetPath - 目标路径
|
||||
* @param {string} fileName - 文件名
|
||||
* @param {boolean} isShortcut - 是否是快捷键触发
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const saveToFile = async (targetPath, fileName, isShortcut) => {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const success = await onWriteFile(fileContent.value, targetPath, fileName, isShortcut)
|
||||
|
||||
if (success) {
|
||||
originalContent.value = fileContent.value
|
||||
clearDraft()
|
||||
}
|
||||
|
||||
return success
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理保存内容
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const handleSaveContent = async () => {
|
||||
if (!canSaveFile.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return await saveToFile(filePath.value, getFileName(filePath.value), false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 另存为
|
||||
*/
|
||||
const handleSaveAs = async () => {
|
||||
try {
|
||||
// 简单实现:使用 prompt 获取路径
|
||||
const targetPath = prompt('请输入保存路径:', filePath.value)
|
||||
|
||||
if (!targetPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fileName = getFileName(targetPath)
|
||||
return await saveToFile(targetPath, fileName, false)
|
||||
} catch (error) {
|
||||
Message.error(`保存对话框失败: ${error.message || error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理写入文件(快捷键或按钮)
|
||||
* @param {boolean} isShortcut - 是否是快捷键触发
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const handleWriteFile = async (isShortcut = false) => {
|
||||
if (!fileContent.value || !filePath.value) {
|
||||
Message.warning('没有可保存的内容')
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果内容未修改,快捷键保存时静默返回
|
||||
if (!isFileModified.value && isShortcut) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 快捷键:静默保存
|
||||
if (isShortcut) {
|
||||
return await saveToFile(filePath.value, getFileName(filePath.value), true)
|
||||
}
|
||||
|
||||
// 按钮:显示确认对话框
|
||||
showManualSaveDialog(false)
|
||||
return false
|
||||
}
|
||||
|
||||
// ========== 重置操作 ==========
|
||||
|
||||
/**
|
||||
* 重置内容到原始状态
|
||||
*/
|
||||
const resetContent = () => {
|
||||
if (onReset) {
|
||||
onReset()
|
||||
} else {
|
||||
fileContent.value = originalContent.value
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 编辑模式切换 ==========
|
||||
|
||||
/**
|
||||
* 切换编辑模式
|
||||
*/
|
||||
const toggleEditMode = () => {
|
||||
isEditMode.value = !isEditMode.value
|
||||
|
||||
// 持久化
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.EDIT_MODE, isEditMode.value.toString())
|
||||
} catch (e) {
|
||||
console.warn('[toggleEditMode] 保存编辑模式失败:', e)
|
||||
}
|
||||
|
||||
// 进入编辑模式时,记录原始内容
|
||||
if (isEditMode.value) {
|
||||
originalContent.value = fileContent.value
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 工具函数 ==========
|
||||
|
||||
/**
|
||||
* 从路径获取文件名
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件名
|
||||
*/
|
||||
const getFileName = (path) => {
|
||||
if (!path) return ''
|
||||
const parts = path.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
// ========== 监听内容变化 ==========
|
||||
|
||||
/**
|
||||
* 监听文件内容变化,自动保存草稿
|
||||
*/
|
||||
watch(fileContent, () => {
|
||||
if (fileContent.value && fileContent.value !== originalContent.value) {
|
||||
saveDraft()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 监听文件路径变化,更新原始内容
|
||||
*/
|
||||
watch(filePath, () => {
|
||||
originalContent.value = fileContent.value
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isSaving,
|
||||
isShortcutSave,
|
||||
saveSuccessMessage,
|
||||
originalContent,
|
||||
isEditMode,
|
||||
isFileModified,
|
||||
canSaveFile,
|
||||
canResetContent,
|
||||
|
||||
// 方法
|
||||
saveDraft,
|
||||
clearDraft,
|
||||
loadDraft,
|
||||
handleSaveContent,
|
||||
handleSaveAs,
|
||||
handleWriteFile,
|
||||
resetContent,
|
||||
toggleEditMode,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseFileEditReturn
|
||||
* @property {Ref<boolean>} isSaving - 是否正在保存
|
||||
* @property {Ref<boolean>} isShortcutSave - 是否是快捷键触发
|
||||
* @property {Ref<string>} saveSuccessMessage - 保存成功提示消息
|
||||
* @property {Ref<string>} originalContent - 原始文件内容
|
||||
* @property {Ref<boolean>} isEditMode - 是否为编辑模式
|
||||
* @property {ComputedRef<boolean>} isFileModified - 文件内容是否已修改
|
||||
* @property {ComputedRef<boolean>} canSaveFile - 是否可以保存文件
|
||||
* @property {ComputedRef<boolean>} canResetContent - 是否可以重置内容
|
||||
* @property {Function} saveDraft - 保存草稿
|
||||
* @property {Function} clearDraft - 清除草稿
|
||||
* @property {Function} loadDraft - 加载草稿
|
||||
* @property {Function} handleSaveContent - 处理保存内容
|
||||
* @property {Function} handleSaveAs - 另存为
|
||||
* @property {Function} handleWriteFile - 处理写入文件
|
||||
* @property {Function} resetContent - 重置内容
|
||||
* @property {Function} toggleEditMode - 切换编辑模式
|
||||
*/
|
||||
612
web/src/composables/useFilePreview.js
Normal file
612
web/src/composables/useFilePreview.js
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* 文件预览逻辑 composable
|
||||
*
|
||||
* @module composables/useFilePreview
|
||||
* @description 封装文件预览、HTML/Markdown 渲染、二进制文件信息显示等逻辑
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { marked } from '@/utils/markedExtensions'
|
||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { getExt } from '@/utils/fileHelpers'
|
||||
import { debugLog, debugWarn, debugError } from '@/utils/debugLog'
|
||||
|
||||
/**
|
||||
* 文件预览 composable
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Ref<string>} options.filePath - 当前文件路径
|
||||
* @param {Ref<string>} options.fileContent - 文件内容
|
||||
* @param {Ref<Array>} options.fileList - 文件列表
|
||||
* @param {Function} options.onReadFile - 读取文件的函数
|
||||
* @returns {UseFilePreviewReturn} 文件预览操作 API
|
||||
*/
|
||||
export function useFilePreview(options = {}) {
|
||||
const {
|
||||
filePath,
|
||||
fileContent,
|
||||
fileList,
|
||||
onReadFile,
|
||||
} = options
|
||||
|
||||
// ========== 预览状态 ==========
|
||||
|
||||
/**
|
||||
* 预览 URL
|
||||
* @type {Ref<string>}
|
||||
*/
|
||||
const previewUrl = ref('')
|
||||
|
||||
/**
|
||||
* 文件服务器URL
|
||||
* @type {Ref<string>}
|
||||
*/
|
||||
const fileServerURL = ref('http://localhost:18765')
|
||||
|
||||
/**
|
||||
* 渲染后的 HTML/Markdown 内容
|
||||
* @type {Ref<string>}
|
||||
*/
|
||||
const rendered = ref('')
|
||||
|
||||
/**
|
||||
* 图片加载状态
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const imageLoading = ref(false)
|
||||
|
||||
/**
|
||||
* 图片宽度
|
||||
* @type {Ref<number>}
|
||||
*/
|
||||
const imageWidth = ref(0)
|
||||
|
||||
/**
|
||||
* 图片高度
|
||||
* @type {Ref<number>}
|
||||
*/
|
||||
const imageHeight = ref(0)
|
||||
|
||||
/**
|
||||
* 是否显示图片预览
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isImageView = ref(false)
|
||||
|
||||
/**
|
||||
* 是否显示视频预览
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isVideoView = ref(false)
|
||||
|
||||
/**
|
||||
* 是否显示音频预览
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isAudioView = ref(false)
|
||||
|
||||
/**
|
||||
* 是否为 PDF 文件
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isPdfFile = ref(false)
|
||||
|
||||
/**
|
||||
* 是否为 HTML 文件
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isHtmlFile = ref(false)
|
||||
|
||||
/**
|
||||
* 是否为 Markdown 文件
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isMarkdownFile = ref(false)
|
||||
|
||||
/**
|
||||
* 是否为二进制文件信息展示
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isBinaryFile = ref(false)
|
||||
|
||||
/**
|
||||
* HTML 预览的 blob URL
|
||||
* @type {Ref<string>}
|
||||
*/
|
||||
const htmlPreviewUrl = ref('')
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
|
||||
/**
|
||||
* 当前文件名
|
||||
*/
|
||||
const currentFileName = computed(() => {
|
||||
if (!filePath.value) return ''
|
||||
const pathStr = typeof filePath.value === 'string' ? filePath.value : String(filePath.value || '')
|
||||
const parts = pathStr.split(/[/\\]/)
|
||||
return parts[parts.length - 1]
|
||||
})
|
||||
|
||||
/**
|
||||
* 当前文件完整路径
|
||||
*/
|
||||
const currentFileFullPath = computed(() => filePath.value || '')
|
||||
|
||||
/**
|
||||
* 当前图片尺寸
|
||||
*/
|
||||
const currentImageDimensions = computed(() => {
|
||||
if (!imageWidth.value || !imageHeight.value) return ''
|
||||
return `${imageWidth.value}×${imageHeight.value}`
|
||||
})
|
||||
|
||||
// ========== 图片预览 ==========
|
||||
|
||||
/**
|
||||
* 预览图片
|
||||
* @param {string} targetPath - 目标路径
|
||||
*/
|
||||
const previewImage = async (targetPath) => {
|
||||
const pathToPreview = targetPath || filePath.value
|
||||
if (!pathToPreview) return
|
||||
|
||||
resetPreviewState()
|
||||
|
||||
const ext = getExt(pathToPreview)
|
||||
if (!FILE_EXTENSIONS.IMAGE.includes(ext)) {
|
||||
return
|
||||
}
|
||||
|
||||
imageLoading.value = true
|
||||
isImageView.value = true
|
||||
|
||||
// 构建预览 URL
|
||||
const encodedPath = encodeURIComponent(pathToPreview)
|
||||
previewUrl.value = `${fileServerURL.value}/file?path=${encodedPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片加载成功回调
|
||||
* @param {Event} e - 加载事件
|
||||
*/
|
||||
const onImageLoad = (e) => {
|
||||
imageLoading.value = false
|
||||
imageWidth.value = e.naturalWidth || e.target?.width || 0
|
||||
imageHeight.value = e.naturalHeight || e.target?.height || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片加载失败回调
|
||||
*/
|
||||
const onImageError = () => {
|
||||
imageLoading.value = false
|
||||
debugWarn('[onImageError] 图片加载失败')
|
||||
}
|
||||
|
||||
// ========== 视频/音频/PDF 预览 ==========
|
||||
|
||||
/**
|
||||
* 预览媒体文件(视频/音频/PDF)
|
||||
* @param {string} mediaType - 媒体类型 ('video' | 'audio' | 'pdf')
|
||||
* @param {string} targetPath - 目标路径
|
||||
*/
|
||||
const previewMedia = (mediaType, targetPath) => {
|
||||
const pathToPreview = targetPath || filePath.value
|
||||
if (!pathToPreview) return
|
||||
|
||||
resetPreviewState()
|
||||
|
||||
const encodedPath = encodeURIComponent(pathToPreview)
|
||||
previewUrl.value = `${fileServerURL.value}/file?path=${encodedPath}`
|
||||
|
||||
if (mediaType === 'video') {
|
||||
isVideoView.value = true
|
||||
} else if (mediaType === 'audio') {
|
||||
isAudioView.value = true
|
||||
} else if (mediaType === 'pdf') {
|
||||
isPdfFile.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览视频
|
||||
* @param {string} targetPath - 目标路径
|
||||
*/
|
||||
const previewVideo = (targetPath) => previewMedia('video', targetPath)
|
||||
|
||||
/**
|
||||
* 预览音频
|
||||
* @param {string} targetPath - 目标路径
|
||||
*/
|
||||
const previewAudio = (targetPath) => previewMedia('audio', targetPath)
|
||||
|
||||
/**
|
||||
* 预览 PDF
|
||||
* @param {string} targetPath - 目标路径
|
||||
*/
|
||||
const previewPdf = (targetPath) => previewMedia('pdf', targetPath)
|
||||
|
||||
// ========== HTML 预览 ==========
|
||||
|
||||
/**
|
||||
* 提取 HTML 文件中的样式
|
||||
* @param {string} htmlContent - HTML 内容
|
||||
* @param {string} basePath - 基础路径
|
||||
* @returns {Promise<string>} 提取的 CSS 样式
|
||||
*/
|
||||
const extractHtmlStyles = async (htmlContent, basePath) => {
|
||||
const linkRegex = /<link[^>]*href=(["'])([^"']+)\1[^>]*>/gi
|
||||
const links = [...htmlContent.matchAll(linkRegex)]
|
||||
|
||||
if (links.length === 0) return ''
|
||||
|
||||
let linkCount = 0
|
||||
const styles = []
|
||||
|
||||
for (const match of links) {
|
||||
const linkTag = match[0]
|
||||
const hrefMatch = match[2]?.match(/^https?:\/\//i)
|
||||
|
||||
const fullTag = match[0]
|
||||
const href = match[2]
|
||||
|
||||
debugLog(`[extractHtmlStyles] 发现第 ${linkCount} 个 link 标签:`, fullTag)
|
||||
|
||||
const cssPath = href?.replace(/^\.\//, '').replace(/^\//, '')
|
||||
debugLog('[extractHtmlStyles] 解析后 CSS 路径:', cssPath)
|
||||
|
||||
if (hrefMatch) {
|
||||
debugLog('[extractHtmlStyles] 跳过外部 CSS:', hrefMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
debugLog('[extractHtmlStyles] 正在读取 CSS 文件:', cssPath)
|
||||
|
||||
try {
|
||||
// 从 HTML 文件所在目录读取 CSS
|
||||
const cssFullPath = basePath + '/' + cssPath
|
||||
const cssContent = await onReadFile(cssFullPath)
|
||||
|
||||
if (cssContent) {
|
||||
const cssSize = cssContent.length
|
||||
debugLog(`[extractHtmlStyles] 成功读取并转换 CSS: ${cssSize} 字符`)
|
||||
|
||||
// 转换 CSS 中的 URL 为 base64
|
||||
const convertedCss = await convertCssUrls(cssContent, basePath)
|
||||
styles.push(convertedCss)
|
||||
}
|
||||
} catch (error) {
|
||||
debugWarn('[extractHtmlStyles] 无法读取 CSS:', cssPath, error.message)
|
||||
}
|
||||
|
||||
linkCount++
|
||||
}
|
||||
|
||||
debugLog(`处理完成: 找到 ${linkCount} 个 link 标签, 成功提取 ${styles.length} 个 CSS 文件`)
|
||||
debugLog(`提取的 CSS 总大小: ${styles.join('\n\n').length} 字符`)
|
||||
|
||||
return styles.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换 CSS 中的相对 URL 为 base64
|
||||
* @param {string} css - CSS 内容
|
||||
* @param {string} basePath - 基础路径
|
||||
* @returns {Promise<string>} 转换后的 CSS
|
||||
*/
|
||||
const convertCssUrls = async (css, basePath) => {
|
||||
const urlRegex = /url\((["']?)([^"')]+)\1\)/gi
|
||||
|
||||
return css.replace(urlRegex, async (match, quote, url) => {
|
||||
// 跳过 data: URLs 和绝对 URLs
|
||||
if (url.startsWith('data:') || /^https?:\/\//i.test(url)) {
|
||||
return match
|
||||
}
|
||||
|
||||
try {
|
||||
const imagePath = basePath + '/' + url.replace(/^\.\//, '')
|
||||
const base64 = await fileToBase64(imagePath)
|
||||
|
||||
debugLog(`[convertCssUrls] ${url} -> base64`)
|
||||
|
||||
return `url("data:image/${getExt(imagePath)};base64,${base64}")`
|
||||
} catch (err) {
|
||||
debugWarn('[convertCssUrls] 失败:', imagePath, err.message)
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 base64
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Promise<string>} base64 字符串
|
||||
*/
|
||||
const fileToBase64 = async (filePath) => {
|
||||
// 这里需要调用实际的文件读取 API
|
||||
// 简化实现,返回空字符串
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览 HTML 文件
|
||||
* @param {string} targetPath - 目标路径
|
||||
*/
|
||||
const previewHtml = async (targetPath) => {
|
||||
const pathToPreview = targetPath || filePath.value
|
||||
if (!pathToPreview) return
|
||||
|
||||
resetPreviewState()
|
||||
isHtmlFile.value = true
|
||||
|
||||
debugLog('开始处理 CSS')
|
||||
debugLog('HTML 文件路径:', pathToPreview)
|
||||
|
||||
const basePath = pathToPreview.replace(/[^/\\]+$/, '')
|
||||
|
||||
try {
|
||||
let htmlContent = fileContent.value
|
||||
|
||||
// 提取并转换 CSS
|
||||
const styles = await extractHtmlStyles(htmlContent, basePath)
|
||||
|
||||
// 转换图片引用
|
||||
const imgRegex = /<img[^>]*src=(["'])([^"']+)\1[^>]*>/gi
|
||||
htmlContent = htmlContent.replace(imgRegex, (match, quote, src) => {
|
||||
// 跳过 data: URLs 和绝对 URLs
|
||||
if (src.startsWith('data:') || /^https?:\/\//i.test(src)) {
|
||||
return match
|
||||
}
|
||||
|
||||
debugLog(`[previewHtml] ${src} -> base64`)
|
||||
|
||||
// 转换为绝对路径
|
||||
const imagePath = basePath + src.replace(/^\.\//, '').replace(/^\//, '')
|
||||
|
||||
// 简化实现:使用 fileServerURL
|
||||
const encodedPath = encodeURIComponent(imagePath)
|
||||
const newSrc = `${fileServerURL.value}/file?path=${encodedPath}`
|
||||
|
||||
return match.replace(src, newSrc)
|
||||
})
|
||||
|
||||
// 移除本地脚本
|
||||
htmlContent = htmlContent.replace(/<script[^>]*src=(["'])[^"']+\1[^>]*>/gi, (match, quote, src) => {
|
||||
const srcMatch = match.match(/src=(["'])([^"']+)\1/i)
|
||||
if (srcMatch) {
|
||||
const srcValue = srcMatch[2]
|
||||
if (!srcValue.startsWith('http')) {
|
||||
debugLog(`[previewHtml] 移除本地脚本: ${srcValue}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// 清理遗漏的 CSS 链接
|
||||
htmlContent = htmlContent.replace(/<link[^>]*rel=(["'])stylesheet\1[^>]*>/gi, (match) => {
|
||||
const hrefMatch = match.match(/href=(["'])([^"']+)\1/i)
|
||||
if (hrefMatch && !/^https?:\/\//i.test(hrefMatch[2])) {
|
||||
debugLog(`[previewHtml] 清理遗漏的CSS链接: ${hrefMatch[2]}`)
|
||||
return ''
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// 构建最终 HTML
|
||||
const finalHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>${styles}</style>
|
||||
</head>
|
||||
<body>
|
||||
${htmlContent}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// 创建 blob URL
|
||||
const blob = new Blob([finalHtml], { type: 'text/html' })
|
||||
htmlPreviewUrl.value = URL.createObjectURL(blob)
|
||||
rendered.value = finalHtml
|
||||
} catch (error) {
|
||||
debugError('[previewHtml] 处理失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Markdown 预览 ==========
|
||||
|
||||
/**
|
||||
* 预览 Markdown 文件
|
||||
* @param {string} targetPath - 目标路径
|
||||
*/
|
||||
const previewMarkdown = async (targetPath) => {
|
||||
const pathToPreview = targetPath || filePath.value
|
||||
if (!pathToPreview) return
|
||||
|
||||
resetPreviewState()
|
||||
isMarkdownFile.value = true
|
||||
|
||||
try {
|
||||
renderMarkdown(fileContent.value)
|
||||
} catch (error) {
|
||||
debugError('[renderMarkdown] 解析失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Markdown
|
||||
* @param {string} markdown - Markdown 内容
|
||||
*/
|
||||
const renderMarkdown = (markdown) => {
|
||||
try {
|
||||
rendered.value = marked(markdown)
|
||||
} catch (error) {
|
||||
debugError('[renderMarkdown] 解析失败:', error)
|
||||
rendered.value = '<p class="error">Markdown 解析失败</p>'
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 二进制文件信息 ==========
|
||||
|
||||
/**
|
||||
* 获取字符串显示宽度(用于对齐)
|
||||
* @param {string} str - 字符串
|
||||
* @returns {number} 显示宽度
|
||||
*/
|
||||
const getDisplayWidth = (str) => {
|
||||
let width = 0
|
||||
for (const char of str) {
|
||||
if (char.match(/[\u4e00-\u9fa5]/)) {
|
||||
width += 2
|
||||
} else {
|
||||
width += 1
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
/**
|
||||
* 按显示宽度填充
|
||||
* @param {string} str - 字符串
|
||||
* @param {number} targetWidth - 目标宽度
|
||||
* @returns {string} 填充后的字符串
|
||||
*/
|
||||
const padByDisplayWidth = (str, targetWidth) => {
|
||||
const currentWidth = getDisplayWidth(str)
|
||||
const padding = Math.max(0, targetWidth - currentWidth)
|
||||
return str + ' '.repeat(padding)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示二进制文件信息
|
||||
* @param {string} ext - 文件扩展名
|
||||
* @param {string} filePathParam - 文件路径
|
||||
*/
|
||||
const showBinaryFileInfo = (ext, filePathParam) => {
|
||||
resetPreviewState()
|
||||
isBinaryFile.value = true
|
||||
|
||||
const file = fileList.value.find(f => f.path === filePathParam)
|
||||
if (!file) return
|
||||
|
||||
const extUpper = ext.toUpperCase()
|
||||
const extPadded = padByDisplayWidth(extUpper, 6)
|
||||
const sizeMB = (file.size / 1024 / 1024).toFixed(2)
|
||||
const sizeStr = `${sizeMB} MB`.padStart(10, ' ')
|
||||
|
||||
rendered.value = `
|
||||
<div class="binary-file-info">
|
||||
<p>
|
||||
<span class="file-type">${extPadded} 文件</span>
|
||||
<span class="file-size">${sizeStr}</span>
|
||||
</p>
|
||||
<p class="file-name">${file.name}</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// ========== 工具函数 ==========
|
||||
|
||||
/**
|
||||
* 重置预览状态
|
||||
*/
|
||||
const resetPreviewState = () => {
|
||||
isImageView.value = false
|
||||
isVideoView.value = false
|
||||
isAudioView.value = false
|
||||
isPdfFile.value = false
|
||||
isHtmlFile.value = false
|
||||
isMarkdownFile.value = false
|
||||
isBinaryFile.value = false
|
||||
|
||||
if (htmlPreviewUrl.value) {
|
||||
URL.revokeObjectURL(htmlPreviewUrl.value)
|
||||
htmlPreviewUrl.value = ''
|
||||
}
|
||||
|
||||
previewUrl.value = ''
|
||||
rendered.value = ''
|
||||
imageWidth.value = 0
|
||||
imageHeight.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Office 文件
|
||||
* @param {string} fileName - 文件名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isOfficeFile = (fileName) => {
|
||||
const ext = getExt(fileName).toLowerCase()
|
||||
return ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
previewUrl,
|
||||
fileServerURL,
|
||||
rendered,
|
||||
imageLoading,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
isImageView,
|
||||
isVideoView,
|
||||
isAudioView,
|
||||
isPdfFile,
|
||||
isHtmlFile,
|
||||
isMarkdownFile,
|
||||
isBinaryFile,
|
||||
htmlPreviewUrl,
|
||||
currentFileName,
|
||||
currentFileFullPath,
|
||||
currentImageDimensions,
|
||||
|
||||
// 方法
|
||||
previewImage,
|
||||
previewVideo,
|
||||
previewAudio,
|
||||
previewPdf,
|
||||
previewHtml,
|
||||
previewMarkdown,
|
||||
renderMarkdown,
|
||||
showBinaryFileInfo,
|
||||
onImageLoad,
|
||||
onImageError,
|
||||
isOfficeFile,
|
||||
resetPreviewState,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseFilePreviewReturn
|
||||
* @property {Ref<string>} previewUrl - 预览 URL
|
||||
* @property {Ref<string>} fileServerURL - 文件服务器URL
|
||||
* @property {Ref<string>} rendered - 渲染后的内容
|
||||
* @property {Ref<boolean>} imageLoading - 图片加载状态
|
||||
* @property {Ref<number>} imageWidth - 图片宽度
|
||||
* @property {Ref<number>} imageHeight - 图片高度
|
||||
* @property {Ref<boolean>} isImageView - 是否显示图片预览
|
||||
* @property {Ref<boolean>} isVideoView - 是否显示视频预览
|
||||
* @property {Ref<boolean>} isAudioView - 是否显示音频预览
|
||||
* @property {Ref<boolean>} isPdfFile - 是否为 PDF 文件
|
||||
* @property {Ref<boolean>} isHtmlFile - 是否为 HTML 文件
|
||||
* @property {Ref<boolean>} isMarkdownFile - 是否为 Markdown 文件
|
||||
* @property {Ref<boolean>} isBinaryFile - 是否为二进制文件信息展示
|
||||
* @property {Ref<string>} htmlPreviewUrl - HTML 预览的 blob URL
|
||||
* @property {ComputedRef<string>} currentFileName - 当前文件名
|
||||
* @property {ComputedRef<string>} currentFileFullPath - 当前文件完整路径
|
||||
* @property {ComputedRef<string>} currentImageDimensions - 当前图片尺寸
|
||||
* @property {Function} previewImage - 预览图片
|
||||
* @property {Function} previewVideo - 预览视频
|
||||
* @property {Function} previewAudio - 预览音频
|
||||
* @property {Function} previewPdf - 预览 PDF
|
||||
* @property {Function} previewHtml - 预览 HTML
|
||||
* @property {Function} previewMarkdown - 预览 Markdown
|
||||
* @property {Function} renderMarkdown - 渲染 Markdown
|
||||
* @property {Function} showBinaryFileInfo - 显示二进制文件信息
|
||||
* @property {Function} onImageLoad - 图片加载成功回调
|
||||
* @property {Function} onImageError - 图片加载失败回调
|
||||
* @property {Function} isOfficeFile - 判断是否为 Office 文件
|
||||
* @property {Function} resetPreviewState - 重置预览状态
|
||||
*/
|
||||
273
web/src/composables/useNavigation.js
Normal file
273
web/src/composables/useNavigation.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 导航和路径管理 composable
|
||||
*
|
||||
* @module composables/useNavigation
|
||||
* @description 封装文件系统的导航历史、路径操作等逻辑
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
/**
|
||||
* 路径历史 localStorage 键
|
||||
*/
|
||||
const STORAGE_KEY_PATH_HISTORY = 'app-filesystem-path-history'
|
||||
|
||||
/**
|
||||
* 导航管理 composable
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Ref<string>} options.filePath - 当前路径 ref
|
||||
* @param {Function} options.onListDirectory - 列出目录的函数
|
||||
* @param {Function} options.onExitZipMode - 退出 ZIP 模式的函数
|
||||
* @returns {UseNavigationReturn} 导航操作 API
|
||||
*/
|
||||
export function useNavigation(options = {}) {
|
||||
const {
|
||||
filePath,
|
||||
onListDirectory,
|
||||
onExitZipMode,
|
||||
} = options
|
||||
|
||||
// ========== 导航历史记录(支持后退/前进) ==========
|
||||
|
||||
/**
|
||||
* 导航历史栈
|
||||
* @type {Ref<Array<string>>}
|
||||
*/
|
||||
const navHistory = ref([])
|
||||
|
||||
/**
|
||||
* 当前在历史栈中的位置
|
||||
* @type {Ref<number>}
|
||||
*/
|
||||
const navIndex = ref(-1)
|
||||
|
||||
/**
|
||||
* 是否正在导航(防止重复记录)
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isNavigating = ref(false)
|
||||
|
||||
/**
|
||||
* 路径历史记录(用于下拉列表)
|
||||
* @type {Ref<Array<string>>}
|
||||
*/
|
||||
const pathHistory = ref([])
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
|
||||
/**
|
||||
* 是否可以后退
|
||||
*/
|
||||
const canGoBack = computed(() => navIndex.value > 0)
|
||||
|
||||
/**
|
||||
* 是否可以前进
|
||||
*/
|
||||
const canGoForward = computed(() => navIndex.value < navHistory.value.length - 1)
|
||||
|
||||
// ========== 导航操作 ==========
|
||||
|
||||
/**
|
||||
* 添加到路径历史记录
|
||||
* @param {string} path - 路径
|
||||
*/
|
||||
const addToHistory = (path) => {
|
||||
if (!path || path === filePath.value) return
|
||||
|
||||
// 去重:如果路径已在历史中,先删除
|
||||
const index = pathHistory.value.indexOf(path)
|
||||
if (index > -1) {
|
||||
pathHistory.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 添加到开头
|
||||
pathHistory.value.unshift(path)
|
||||
|
||||
// 限制历史记录数量
|
||||
if (pathHistory.value.length > 50) {
|
||||
pathHistory.value = pathHistory.value.slice(0, 50)
|
||||
}
|
||||
|
||||
// 持久化
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_PATH_HISTORY, JSON.stringify(pathHistory.value))
|
||||
} catch (e) {
|
||||
// 忽略 localStorage 错误
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送到导航历史栈
|
||||
* @param {string} path - 路径
|
||||
*/
|
||||
const pushNav = (path) => {
|
||||
if (isNavigating.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果当前位置不在历史末尾,删除后续历史
|
||||
if (navIndex.value < navHistory.value.length - 1) {
|
||||
navHistory.value = navHistory.value.slice(0, navIndex.value + 1)
|
||||
}
|
||||
|
||||
// 添加到历史
|
||||
navHistory.value.push(path)
|
||||
navIndex.value = navHistory.value.length - 1
|
||||
|
||||
// 同时添加到路径历史
|
||||
addToHistory(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 后退
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const goBack = async () => {
|
||||
if (!canGoBack.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
isNavigating.value = true
|
||||
try {
|
||||
navIndex.value--
|
||||
const path = navHistory.value[navIndex.value]
|
||||
await onListDirectory(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
Message.error(`后退失败: ${error.message || error}`)
|
||||
return false
|
||||
} finally {
|
||||
isNavigating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 前进
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const goForward = async () => {
|
||||
if (!canGoForward.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
isNavigating.value = true
|
||||
try {
|
||||
navIndex.value++
|
||||
const path = navHistory.value[navIndex.value]
|
||||
await onListDirectory(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
Message.error(`前进失败: ${error.message || error}`)
|
||||
return false
|
||||
} finally {
|
||||
isNavigating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 路径操作 ==========
|
||||
|
||||
/**
|
||||
* 路径选择(从下拉列表)
|
||||
* @param {string} value - 选中的路径
|
||||
*/
|
||||
const onPathSelect = (value) => {
|
||||
if (value && value !== filePath.value) {
|
||||
goToPath(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径输入框回车事件
|
||||
*/
|
||||
const onPathEnter = () => {
|
||||
const path = filePath.value?.trim()
|
||||
if (path) {
|
||||
goToPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定路径
|
||||
* @param {string} path - 目标路径
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const goToPath = async (path) => {
|
||||
if (!path) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 退出 ZIP 模式
|
||||
if (onExitZipMode) {
|
||||
onExitZipMode()
|
||||
}
|
||||
|
||||
return await onListDirectory(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览目录(打开系统文件选择对话框)
|
||||
*/
|
||||
const browseDirectory = async () => {
|
||||
Message.info('请手动输入目录路径')
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
|
||||
/**
|
||||
* 加载路径历史记录
|
||||
*/
|
||||
const loadPathHistory = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_PATH_HISTORY)
|
||||
if (saved) {
|
||||
pathHistory.value = JSON.parse(saved)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[useNavigation] 加载路径历史失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadPathHistory()
|
||||
|
||||
return {
|
||||
// 状态
|
||||
navHistory,
|
||||
navIndex,
|
||||
isNavigating,
|
||||
pathHistory,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
|
||||
// 方法
|
||||
addToHistory,
|
||||
pushNav,
|
||||
goBack,
|
||||
goForward,
|
||||
onPathSelect,
|
||||
onPathEnter,
|
||||
goToPath,
|
||||
browseDirectory,
|
||||
loadPathHistory,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseNavigationReturn
|
||||
* @property {Ref<Array<string>>} navHistory - 导航历史栈
|
||||
* @property {Ref<number>} navIndex - 当前在历史栈中的位置
|
||||
* @property {Ref<boolean>} isNavigating - 是否正在导航
|
||||
* @property {Ref<Array<string>>} pathHistory - 路径历史记录(下拉列表)
|
||||
* @property {ComputedRef<boolean>} canGoBack - 是否可以后退
|
||||
* @property {ComputedRef<boolean>} canGoForward - 是否可以前进
|
||||
* @property {Function} addToHistory - 添加到路径历史记录
|
||||
* @property {Function} pushNav - 推送到导航历史栈
|
||||
* @property {Function} goBack - 后退
|
||||
* @property {Function} goForward - 前进
|
||||
* @property {Function} onPathSelect - 路径选择
|
||||
* @property {Function} onPathEnter - 路径输入框回车事件
|
||||
* @property {Function} goToPath - 跳转到指定路径
|
||||
* @property {Function} browseDirectory - 浏览目录
|
||||
* @property {Function} loadPathHistory - 加载路径历史记录
|
||||
*/
|
||||
287
web/src/types/file-system.ts
Normal file
287
web/src/types/file-system.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 文件系统类型定义
|
||||
* @module file-system
|
||||
*/
|
||||
|
||||
/**
|
||||
* 文件项
|
||||
*/
|
||||
export interface FileItem {
|
||||
/** 文件名 */
|
||||
name: string
|
||||
/** 完整路径 */
|
||||
path: string
|
||||
/** 文件大小(字节) */
|
||||
size: number
|
||||
/** 是否为目录 */
|
||||
is_dir: boolean
|
||||
/** 修改时间 */
|
||||
modified_time?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏文件
|
||||
*/
|
||||
export interface FavoriteFile extends FileItem {
|
||||
/** 添加时间(时间戳) */
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件类型枚举
|
||||
*/
|
||||
export enum FileType {
|
||||
/** 图片 */
|
||||
Image = 'image',
|
||||
/** 视频 */
|
||||
Video = 'video',
|
||||
/** 音频 */
|
||||
Audio = 'audio',
|
||||
/** PDF */
|
||||
Pdf = 'pdf',
|
||||
/** HTML */
|
||||
Html = 'html',
|
||||
/** Markdown */
|
||||
Markdown = 'markdown',
|
||||
/** 代码 */
|
||||
Code = 'code',
|
||||
/** 文本 */
|
||||
Text = 'text',
|
||||
/** 二进制 */
|
||||
Binary = 'binary'
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽状态
|
||||
*/
|
||||
export interface DraggingState {
|
||||
/** 是否正在拖拽 */
|
||||
isDragging: boolean
|
||||
/** 被拖拽项的索引 */
|
||||
draggedIndex: number
|
||||
/** 按下的项索引 */
|
||||
pressedIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板宽度配置
|
||||
*/
|
||||
export interface PanelWidth {
|
||||
/** 左侧面板宽度(百分比) */
|
||||
left: number
|
||||
/** 右侧面板宽度(百分比) */
|
||||
right: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷路径
|
||||
*/
|
||||
export interface ShortcutPath {
|
||||
/** 显示名称 */
|
||||
name: string
|
||||
/** 路径 */
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具栏配置
|
||||
*/
|
||||
export interface ToolbarConfig {
|
||||
/** 当前文件路径 */
|
||||
filePath: string
|
||||
/** 路径历史记录 */
|
||||
pathHistory: string[]
|
||||
/** 常用路径列表 */
|
||||
commonPaths: ShortcutPath[]
|
||||
/** 是否在 ZIP 浏览模式 */
|
||||
isBrowsingZip: boolean
|
||||
/** 显示路径(ZIP 模式下) */
|
||||
displayPath: string
|
||||
/** ZIP 文件名 */
|
||||
zipFileName: string
|
||||
/** ZIP 面包屑 */
|
||||
zipBreadcrumbs: ZipBreadcrumbItem[]
|
||||
/** 文件加载中 */
|
||||
fileLoading: boolean
|
||||
/** 是否显示侧边栏 */
|
||||
showSidebar: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏配置
|
||||
*/
|
||||
export interface SidebarConfig {
|
||||
/** 是否可见 */
|
||||
visible: boolean
|
||||
/** 收藏文件列表 */
|
||||
favoriteFiles: FavoriteFile[]
|
||||
/** 拖拽状态 */
|
||||
draggingState: DraggingState
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表面板配置
|
||||
*/
|
||||
export interface FileListPanelConfig {
|
||||
/** 文件列表 */
|
||||
fileList: FileItem[]
|
||||
/** 文件加载中 */
|
||||
fileLoading: boolean
|
||||
/** 选中的文件项 */
|
||||
selectedFileItem: FileItem | null
|
||||
/** 正在编辑的文件路径 */
|
||||
editingFilePath: string
|
||||
/** 编辑中的文件名 */
|
||||
editingFileName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件编辑器面板配置
|
||||
*/
|
||||
export interface FileEditorPanelConfig {
|
||||
/** 当前文件名 */
|
||||
currentFileName: string
|
||||
/** 当前文件完整路径 */
|
||||
currentFileFullPath: string
|
||||
/** 预览 URL */
|
||||
previewUrl: string
|
||||
/** 文件内容 */
|
||||
fileContent: string
|
||||
/** 渲染后的内容(HTML/Markdown) */
|
||||
rendered: string
|
||||
/** 是否在编辑模式 */
|
||||
isEditMode: boolean
|
||||
/** 文件内容区域高度 */
|
||||
fileContentHeight: number
|
||||
/** 是否为图片视图 */
|
||||
isImageView: boolean
|
||||
/** 是否为视频视图 */
|
||||
isVideoView: boolean
|
||||
/** 是否为音频视图 */
|
||||
isAudioView: boolean
|
||||
/** 是否为 PDF 文件 */
|
||||
isPdfFile: boolean
|
||||
/** 是否为 HTML 文件 */
|
||||
isHtmlFile: boolean
|
||||
/** 是否为 Markdown 文件 */
|
||||
isMarkdownFile: boolean
|
||||
/** 是否可以保存 */
|
||||
canSaveFile: boolean
|
||||
/** 是否可以重置 */
|
||||
canResetContent: boolean
|
||||
/** 是否可以预览 */
|
||||
canPreviewFile: boolean
|
||||
/** 图片加载中 */
|
||||
imageLoading: boolean
|
||||
/** 当前图片尺寸 */
|
||||
currentImageDimensions: string
|
||||
/** 当前文件扩展名 */
|
||||
currentFileExtension: string
|
||||
/** 是否为二进制文件 */
|
||||
isBinaryFile: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单上下文类型
|
||||
*/
|
||||
export type ContextMenuContext = 'file-list' | 'editor' | 'empty'
|
||||
|
||||
/**
|
||||
* 右键菜单配置
|
||||
*/
|
||||
export interface ContextMenuConfig {
|
||||
/** 是否可见 */
|
||||
visible: boolean
|
||||
/** X 坐标 */
|
||||
x: number
|
||||
/** Y 坐标 */
|
||||
y: number
|
||||
/** 上下文类型 */
|
||||
context: ContextMenuContext
|
||||
/** 选中的文件(file-list 上下文) */
|
||||
selectedFile?: FileItem
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件操作结果
|
||||
*/
|
||||
export interface FileOperationResult {
|
||||
/** 是否成功 */
|
||||
success: boolean
|
||||
/** 错误信息 */
|
||||
error?: string
|
||||
/** 数据 */
|
||||
data?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径导航历史
|
||||
*/
|
||||
export interface PathHistory {
|
||||
/** 历史记录数组 */
|
||||
paths: string[]
|
||||
/** 当前索引 */
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件预览元数据
|
||||
*/
|
||||
export interface FilePreviewMetadata {
|
||||
/** 宽度 */
|
||||
width?: number
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 时长(视频/音频) */
|
||||
duration?: number
|
||||
/** MIME 类型 */
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
export interface EditorConfig {
|
||||
/** 是否可编辑 */
|
||||
editable: boolean
|
||||
/** 是否显示行号 */
|
||||
showLineNumbers: boolean
|
||||
/** 是否显示折叠按钮 */
|
||||
showFoldButtons: boolean
|
||||
/** 主题 */
|
||||
theme?: string
|
||||
/** 字体大小 */
|
||||
fontSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件保存选项
|
||||
*/
|
||||
export interface FileSaveOptions {
|
||||
/** 是否创建备份 */
|
||||
createBackup?: boolean
|
||||
/** 是否保留原文件时间戳 */
|
||||
preserveTimestamp?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 文件信息
|
||||
*/
|
||||
export interface ZipFileInfo {
|
||||
/** ZIP 文件路径 */
|
||||
zipPath: string
|
||||
/** ZIP 内部的当前路径 */
|
||||
currentPath: string
|
||||
/** ZIP 文件列表 */
|
||||
files: FileItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 面包屑项
|
||||
*/
|
||||
export interface ZipBreadcrumbItem {
|
||||
/** 目录名 */
|
||||
name: string
|
||||
/** 目录路径 */
|
||||
path: string
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const STORAGE_KEYS = {
|
||||
SIDEBAR_VISIBLE: 'app-filesystem-sidebar-visible',
|
||||
FAVORITE_FILES: 'app-filesystem-favorite-files',
|
||||
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
|
||||
FILE_DRAFT: 'app-filesystem-file-draft', // 文件草稿
|
||||
},
|
||||
|
||||
// 设备测试模块
|
||||
@@ -56,7 +57,8 @@ export const FILE_EXTENSIONS = {
|
||||
|
||||
// 视频文件
|
||||
VIDEO_BROWSER: ['mp4', 'webm', 'ogg', 'mov', 'm4v'], // 浏览器原生支持
|
||||
VIDEO_EXTERNAL: ['avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'ts', 'mts'], // 需要外部播放器
|
||||
VIDEO_EXTERNAL: ['avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 需要外部播放器(注意:不用 'ts' 避免 TypeScript 冲突)
|
||||
VIDEO: ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 所有视频
|
||||
|
||||
// 音频文件
|
||||
AUDIO: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus', 'webm'],
|
||||
@@ -69,10 +71,16 @@ export const FILE_EXTENSIONS = {
|
||||
|
||||
// 代码文件
|
||||
CODE: [
|
||||
'js', 'ts', 'jsx', 'tsx', 'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
|
||||
'scala', 'css', 'scss', 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'sql', 'sh', 'bat', 'ps1'
|
||||
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
|
||||
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
|
||||
'scala', 'css', 'scss', 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'sql', 'sh', 'bat', 'ps1',
|
||||
'flow', 'props', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
|
||||
'tex', 'm', 'r', 'matlab', 'latex', 'rst', 'adoc'
|
||||
],
|
||||
|
||||
// 纯文本文件
|
||||
TEXT: ['txt', 'text', 'log', 'md', 'markdown', 'rst', 'adoc', 'tex', 'msg', 'csv', 'tsv'],
|
||||
|
||||
// 标记语言文件(用于特殊预览)
|
||||
MARKUP: ['html', 'htm', 'md', 'markdown'],
|
||||
|
||||
@@ -316,3 +324,45 @@ export const FILE_SIZE_THRESHOLDS = {
|
||||
LARGE_FILE: 100 * 1024, // 100KB - 大文件检测阈值
|
||||
MAX_TEXT_DISPLAY: 5 * 1024 * 1024, // 5MB - 文本文件最大显示大小
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 文本常量
|
||||
* @description 界面上显示的固定文本
|
||||
*/
|
||||
export const UI_TEXT = {
|
||||
// 对话框标题
|
||||
CREATE_FILE: '📄 新建文件',
|
||||
CREATE_FOLDER: '📁 新建文件夹',
|
||||
RENAME_FILE: '重命名文件',
|
||||
DELETE_CONFIRM: '确认删除',
|
||||
|
||||
// 按钮文本
|
||||
CONFIRM: '确定',
|
||||
CANCEL: '取消',
|
||||
CREATE: '创建',
|
||||
SAVE: '保存',
|
||||
DELETE: '删除',
|
||||
|
||||
// 提示信息
|
||||
FILE_NAME_EMPTY: '请输入内容',
|
||||
FILE_NAME_INVALID: '文件名包含非法字符',
|
||||
FOLDER_NAME_INVALID: '文件夹名包含非法字符',
|
||||
FILE_EXISTS: '文件已存在',
|
||||
FOLDER_EXISTS: '文件夹已存在',
|
||||
SELECT_DIRECTORY: '请先选择一个目录',
|
||||
CREATE_SUCCESS: '创建成功',
|
||||
CREATE_FAILED: '创建失败',
|
||||
|
||||
// 输入提示
|
||||
ENTER_FILE_NAME: '请输入文件名(如: todo.md)',
|
||||
ENTER_FOLDER_NAME: '请输入文件夹名称',
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证规则
|
||||
* @description 数据验证的正则表达式规则
|
||||
*/
|
||||
export const VALIDATION_RULES = {
|
||||
// Windows 文件名非法字符
|
||||
ILLEGAL_FILE_NAME_CHARS: /[<>:"/\\|?*]/,
|
||||
}
|
||||
|
||||
63
web/src/utils/errorHandler.js
Normal file
63
web/src/utils/errorHandler.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 错误处理工具函数
|
||||
*
|
||||
* @module utils/errorHandler
|
||||
* @description 统一的错误处理,避免代码重复
|
||||
*/
|
||||
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
/**
|
||||
* 统一的错误处理
|
||||
* @param {Error} error - 错误对象
|
||||
* @param {string} context - 操作上下文(用于日志)
|
||||
*/
|
||||
export function handleError(error, context = '') {
|
||||
// 1. 记录日志
|
||||
console.error(`[${context}]`, error)
|
||||
|
||||
// 2. 显示用户提示
|
||||
const message = error?.message || '操作失败'
|
||||
Message.error(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装异步函数,自动处理错误
|
||||
* @param {Function} fn - 异步函数
|
||||
* @param {string} context - 操作上下文
|
||||
* @returns {Function} 包装后的函数
|
||||
*/
|
||||
export function withErrorHandling(fn, context = '') {
|
||||
return async (...args) => {
|
||||
try {
|
||||
return await fn(...args)
|
||||
} catch (error) {
|
||||
handleError(error, context)
|
||||
throw error // 重新抛出,让调用者决定是否继续
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示成功提示
|
||||
* @param {string} message - 成功消息
|
||||
*/
|
||||
export function showSuccess(message) {
|
||||
Message.success(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示警告提示
|
||||
* @param {string} message - 警告消息
|
||||
*/
|
||||
export function showWarning(message) {
|
||||
Message.warning(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示信息提示
|
||||
* @param {string} message - 信息消息
|
||||
*/
|
||||
export function showInfo(message) {
|
||||
Message.info(message)
|
||||
}
|
||||
161
web/src/utils/fileTypeHelpers.js
Normal file
161
web/src/utils/fileTypeHelpers.js
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 文件类型判断工具函数
|
||||
*
|
||||
* @module utils/fileTypeHelpers
|
||||
* @description 统一文件类型判断逻辑,避免内联重复定义
|
||||
*/
|
||||
|
||||
import { FILE_EXTENSIONS } from './constants'
|
||||
import { getExt } from './pathHelpers'
|
||||
|
||||
/**
|
||||
* 可预览的文件类型(有专门的预览处理)
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const PREVIEWABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.IMAGE,
|
||||
...FILE_EXTENSIONS.VIDEO_BROWSER,
|
||||
...FILE_EXTENSIONS.AUDIO,
|
||||
'pdf', 'html', 'htm', 'md', 'markdown'
|
||||
]
|
||||
|
||||
/**
|
||||
* 已知二进制文件类型(直接显示二进制文件信息)
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const KNOWN_BINARY_TYPES = [
|
||||
// 可执行文件
|
||||
'exe', 'dll', 'so', 'bin',
|
||||
// 压缩文件
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
||||
// Office 文档
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
// 其他二进制
|
||||
'pdb', 'idb', 'lib', 'obj', 'o', 'a'
|
||||
]
|
||||
|
||||
/**
|
||||
* 文本可编辑类型
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const TEXT_EDITABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.CODE,
|
||||
'md', 'markdown', 'txt', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf'
|
||||
]
|
||||
|
||||
/**
|
||||
* 判断是否为图片文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isImageFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视频文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isVideoFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.VIDEO_BROWSER.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isAudioFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.AUDIO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 PDF 文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isPdfFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ext === 'pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 HTML 文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isHtmlFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['html', 'htm'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Markdown 文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isMarkdownFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['md', 'markdown'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否支持预览模式
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isPreviewable = (path) => {
|
||||
const ext = getExt(path)
|
||||
return PREVIEWABLE_TYPES.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为已知二进制类型
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isKnownBinary = (path) => {
|
||||
const ext = getExt(path)
|
||||
return KNOWN_BINARY_TYPES.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可文本编辑
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTextEditable = (path) => {
|
||||
const ext = getExt(path)
|
||||
return TEXT_EDITABLE_TYPES.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型分类
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 类型分类:'image' | 'video' | 'audio' | 'pdf' | 'html' | 'markdown' | 'text' | 'binary' | 'unknown'
|
||||
*/
|
||||
export const getFileCategory = (path) => {
|
||||
if (isImageFile(path)) return 'image'
|
||||
if (isVideoFile(path)) return 'video'
|
||||
if (isAudioFile(path)) return 'audio'
|
||||
if (isPdfFile(path)) return 'pdf'
|
||||
if (isHtmlFile(path)) return 'html'
|
||||
if (isMarkdownFile(path)) return 'markdown'
|
||||
if (isTextEditable(path)) return 'text'
|
||||
if (isKnownBinary(path)) return 'binary'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Office 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isOfficeFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)
|
||||
}
|
||||
@@ -19,15 +19,16 @@ import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT, FILE_EXTENSION
|
||||
*/
|
||||
export function formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B'
|
||||
if (typeof bytes !== 'number' || isNaN(bytes)) return '0 B'
|
||||
|
||||
const unit = FILE_SIZE_FORMAT.UNIT
|
||||
const decimals = FILE_SIZE_FORMAT.DECIMAL_PLACES
|
||||
|
||||
if (bytes < unit) return bytes + ' B'
|
||||
|
||||
const exp = Math.floor(Math.log(bytes) / Math.log(unit))
|
||||
const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(unit)), BYTE_UNITS.length - 1)
|
||||
const value = bytes / Math.pow(unit, exp)
|
||||
const unitSymbol = BYTE_UNITS[1][exp - 1] + 'B'
|
||||
const unitSymbol = BYTE_UNITS[exp]
|
||||
|
||||
return value.toFixed(decimals) + ' ' + unitSymbol
|
||||
}
|
||||
|
||||
35
web/src/utils/markedExtensions.ts
Normal file
35
web/src/utils/markedExtensions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { marked } from 'marked'
|
||||
import hljs from 'highlight.js'
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
// 导入 highlight.js 核心和两种主题样式
|
||||
import 'highlight.js/lib/common'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import 'highlight.js/styles/github.css'
|
||||
|
||||
// Mermaid 初始化
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' })
|
||||
|
||||
// 自定义 renderer
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.code = function(token: any) {
|
||||
// Mermaid 代码块
|
||||
if (token.lang === 'mermaid') {
|
||||
return `<pre class="mermaid">${token.text}</pre>`
|
||||
}
|
||||
|
||||
// 普通代码块 - 使用 highlight.js 高亮
|
||||
const lang = hljs.getLanguage(token.lang) ? token.lang : 'plaintext'
|
||||
const highlighted = hljs.highlight(token.text, { language: lang }).value
|
||||
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
marked.use({ renderer, breaks: true, gfm: true })
|
||||
|
||||
export { marked }
|
||||
export async function renderMermaidDiagrams() {
|
||||
await mermaid.run()
|
||||
}
|
||||
|
||||
|
||||
103
web/src/utils/pathHelpers.js
Normal file
103
web/src/utils/pathHelpers.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 路径处理工具函数
|
||||
*
|
||||
* @module utils/pathHelpers
|
||||
* @description 统一路径分割、文件名获取等操作,避免重复代码
|
||||
*/
|
||||
|
||||
import { getExt as getExtFromFileHelpers } from './fileHelpers'
|
||||
|
||||
// 重新导出 getExt,避免重复定义
|
||||
export const getExt = getExtFromFileHelpers
|
||||
|
||||
/**
|
||||
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
|
||||
* @type {RegExp}
|
||||
*/
|
||||
export const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
|
||||
/**
|
||||
* 分割路径为多个部分
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string[]} 路径数组
|
||||
* @example
|
||||
* splitPath('C:\\Users\\file.txt') // ['C:', 'Users', 'file.txt']
|
||||
* splitPath('/home/user/file.txt') // ['home', 'user', 'file.txt']
|
||||
*/
|
||||
export const splitPath = (path) => {
|
||||
if (!path) return []
|
||||
return path.split(PATH_SEPARATOR_REGEX)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件名(含扩展名)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件名
|
||||
* @example
|
||||
* getFileName('C:\\Users\\file.txt') // 'file.txt'
|
||||
* getFileName('/home/user/file.txt') // 'file.txt'
|
||||
*/
|
||||
export const getFileName = (path) => {
|
||||
if (!path) return ''
|
||||
const parts = splitPath(path)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父目录路径
|
||||
* @param {string} path - 文件或目录路径
|
||||
* @returns {string} 父目录路径
|
||||
* @example
|
||||
* getParentPath('C:\\Users\\file.txt') // 'C:\\Users'
|
||||
* getParentPath('/home/user/file.txt') // '/home/user'
|
||||
*/
|
||||
export const getParentPath = (path) => {
|
||||
if (!path) return ''
|
||||
|
||||
// 查找最后一个分隔符的位置
|
||||
const lastSep = Math.max(
|
||||
path.lastIndexOf('/'),
|
||||
path.lastIndexOf('\\')
|
||||
)
|
||||
|
||||
if (lastSep <= 0) return path
|
||||
return path.substring(0, lastSep)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件名(不含扩展名)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件名(不含扩展名)
|
||||
* @example
|
||||
* getFileNameWithoutExt('file.txt') // 'file'
|
||||
* getFileNameWithoutExt('archive.tar.gz') // 'archive.tar'
|
||||
*/
|
||||
export const getFileNameWithoutExt = (path) => {
|
||||
const fileName = getFileName(path)
|
||||
const lastDot = fileName.lastIndexOf('.')
|
||||
return lastDot > 0 ? fileName.substring(0, lastDot) : fileName
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化路径分隔符(统一为正斜杠)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 规范化后的路径
|
||||
*/
|
||||
export const normalizePathSeparators = (path) => {
|
||||
if (!path) return ''
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接路径片段
|
||||
* @param {...string} parts - 路径片段
|
||||
* @returns {string} 连接后的路径
|
||||
* @example
|
||||
* joinPath('C:', 'Users', 'file.txt') // 'C:/Users/file.txt'
|
||||
*/
|
||||
export const joinPath = (...parts) => {
|
||||
return parts
|
||||
.filter(part => part && part !== '')
|
||||
.map(part => part.replace(/[\/\\]+$/, '').replace(/^[\/\\]+/, ''))
|
||||
.join('/')
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// 新架构:使用单例 Store(事件驱动)
|
||||
const structureStore = useStructureStore()
|
||||
// 直接使用 Store 的状态(无需计算属性,无需 watch)
|
||||
// 状态是只读的,通过 Store 方法修改
|
||||
|
||||
// 表结构编辑状态
|
||||
Reference in New Issue
Block a user