This commit is contained in:
2026-08-08 11:51:22 +08:00
parent 152d5273a5
commit 79fb05d0f9
12 changed files with 833 additions and 376 deletions
+258 -40
View File
@@ -15,7 +15,8 @@
</el-tooltip>
<el-tooltip content="刷新" placement="top" :show-after="300">
<el-button class="icon-btn" link @click="fetchFiles">
<el-icon><Refresh /></el-icon>
<el-icon v-if="!loading"><Refresh /></el-icon>
<el-icon v-else class="is-loading"><Loading /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="关闭" placement="top" :show-after="300">
@@ -27,7 +28,7 @@
</div>
<div class="panel-content" @contextmenu.prevent="handlePanelContextMenu">
<div v-if="loading" class="loading-wrapper">
<div v-if="loading && fileTree.length === 0" class="loading-wrapper">
<el-icon class="loading-icon"><Loading /></el-icon>
</div>
<div v-else-if="fileTree.length === 0" class="empty-wrapper">
@@ -45,7 +46,7 @@
<template #default="{ node, data }">
<span class="tree-node" @contextmenu.stop.prevent="handleContextMenu($event, data)">
<el-icon v-if="data.type === 'directory'"><Folder /></el-icon>
<el-icon v-else><Document /></el-icon>
<el-icon v-else :style="{ color: getFileIconInfo(data.name).color }"><component :is="getFileIconInfo(data.name).component" /></el-icon>
<span class="node-label">{{ node.label }}</span>
<el-icon
v-if="data.type === 'file'"
@@ -82,8 +83,8 @@
</div>
<!-- 新建文件/文件夹对话框 -->
<el-dialog v-model="createDialogVisible" :title="createDialogTitle" width="400px" :close-on-click-modal="false">
<el-input v-model="createName" placeholder="请输入名称" @keyup.enter="confirmCreate" />
<el-dialog v-model="createDialogVisible" :title="createDialogTitle" width="400px" :close-on-click-modal="false" @opened="handleCreateOpened">
<el-input ref="createInputRef" v-model="createName" placeholder="请输入名称" @keyup.enter="confirmCreate" />
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmCreate">确定</el-button>
@@ -91,8 +92,8 @@
</el-dialog>
<!-- 重命名对话框 -->
<el-dialog v-model="renameDialogVisible" title="重命名" width="400px" :close-on-click-modal="false">
<el-input v-model="renameName" placeholder="请输入新名称" @keyup.enter="confirmRename" />
<el-dialog v-model="renameDialogVisible" title="重命名" width="400px" :close-on-click-modal="false" @opened="handleRenameOpened">
<el-input ref="renameInputRef" v-model="renameName" placeholder="请输入新名称" @keyup.enter="confirmRename" />
<template #footer>
<el-button @click="renameDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmRename">确定</el-button>
@@ -150,11 +151,183 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, defineComponent, h } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Folder, FolderOpened, Document, Download, Refresh, Upload, Loading } from '@element-plus/icons-vue'
import { listFiles, downloadFile, createFileOrDirectory, uploadFileOrDirectory, deleteFileOrDirectory, renameFile, downloadProject } from '@/api/frontend'
// 类似 lucide-code-xml 的代码图标(Element Plus icons 没有原生 code icon
const CodeXmlIcon = defineComponent({
name: 'CodeXmlIcon',
render() {
return h('svg', {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.75',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
style: 'width: 1em; height: 1em;'
}, [
h('path', { d: 'm18 16 4-4-4-4' }),
h('path', { d: 'm6 8-4 4 4 4' }),
h('path', { d: 'm14.5 4-5 16' })
])
}
})
// 类似 lucide-terminal> + 下划线
const TerminalIcon = defineComponent({
name: 'TerminalIcon',
render() {
return h('svg', {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.75',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
style: 'width: 1em; height: 1em;'
}, [
h('polyline', { points: '4 17 10 11 4 5' }),
h('line', { x1: '12', y1: '19', x2: '20', y2: '19' })
])
}
})
// 类似 lucide-file-text:带三条横线的文件(用于 PDF)
const FileTextIcon = defineComponent({
name: 'FileTextIcon',
render() {
return h('svg', {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.75',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
style: 'width: 1em; height: 1em;'
}, [
h('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
h('path', { d: 'M14 2v4h6' }),
h('path', { d: 'M10 9H8' }),
h('path', { d: 'M16 13H8' }),
h('path', { d: 'M16 17H8' })
])
}
})
// 类似 lucide-file-presentation:带三柱状条的文件(用于 PPT)
const FilePresentationIcon = defineComponent({
name: 'FilePresentationIcon',
render() {
return h('svg', {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.75',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
style: 'width: 1em; height: 1em;'
}, [
h('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
h('path', { d: 'M14 2v4h6' }),
h('path', { d: 'M8 18v-6' }),
h('path', { d: 'M12 18v-6' }),
h('path', { d: 'M16 18v-6' }),
h('path', { d: 'M8 18h8' })
])
}
})
// 通用 SVG 属性(lucide 风格)
const lucideSvgProps = {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.75',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
style: 'width: 1em; height: 1em;'
}
function makeLucideIcon(name, paths) {
return defineComponent({
name,
render() {
return h('svg', lucideSvgProps, paths.map((p) => h(p.tag, p.attrs)))
}
})
}
// lucide-file-image:文件 + 圆 + 山形
const FileImageIcon = makeLucideIcon('FileImageIcon', [
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
{ tag: 'circle', attrs: { cx: '9.5', cy: '13.5', r: '1.5' } },
{ tag: 'path', attrs: { d: 'M16 16l-3-3-4 4' } }
])
// lucide-file-video:文件 + 三角形
const FileVideoIcon = makeLucideIcon('FileVideoIcon', [
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
{ tag: 'path', attrs: { d: 'm10 11 5 3-5 3v-6Z' } }
])
// lucide-file-music:文件 + 音符
const FileMusicIcon = makeLucideIcon('FileMusicIcon', [
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
{ tag: 'circle', attrs: { cx: '8', cy: '18', r: '2' } },
{ tag: 'path', attrs: { d: 'M10 18V8l6-2v8' } }
])
// lucide-file-archive:文件 + 拉链
const FileArchiveIcon = makeLucideIcon('FileArchiveIcon', [
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
{ tag: 'rect', attrs: { x: '10', y: '12', width: '4', height: '6', rx: '1' } }
])
// lucide-file-spreadsheet:文件 + 表格
const FileSpreadsheetIcon = makeLucideIcon('FileSpreadsheetIcon', [
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
{ tag: 'path', attrs: { d: 'M8 13h2' } },
{ tag: 'path', attrs: { d: 'M14 13h2' } },
{ tag: 'path', attrs: { d: 'M8 17h2' } },
{ tag: 'path', attrs: { d: 'M14 17h2' } }
])
// lucide-file-code:文件 + < >
const FileCodeIcon = makeLucideIcon('FileCodeIcon', [
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
{ tag: 'path', attrs: { d: 'm9 18 3-3-3-3' } },
{ tag: 'path', attrs: { d: 'm15 12-3-3 3-3' } }
])
// lucide-file-json:文件 + { }
const FileJsonIcon = makeLucideIcon('FileJsonIcon', [
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
{ tag: 'path', attrs: { d: 'M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1-1-1v-1' } },
{ tag: 'path', attrs: { d: 'M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1 1 1v1' } }
])
// lucide-database:圆柱形数据库
const DatabaseIcon = makeLucideIcon('DatabaseIcon', [
{ tag: 'ellipse', attrs: { cx: '12', cy: '5', rx: '9', ry: '3' } },
{ tag: 'path', attrs: { d: 'M3 5v14a9 3 0 0 0 18 0V5' } },
{ tag: 'path', attrs: { d: 'M3 12a9 3 0 0 0 18 0' } }
])
const props = defineProps({
visible: {
type: Boolean,
@@ -177,6 +350,61 @@ const loading = ref(false)
const fileTree = ref([])
const defaultExpandedKeys = ref([])
// 按文件扩展名分类,返回 icon 组件 + 配色
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp', 'ico'])
const VIDEO_EXTS = new Set(['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'mpeg', 'mpg'])
const AUDIO_EXTS = new Set(['mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a'])
const ARCHIVE_EXTS = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'jar', 'war'])
// Office 文档
const WORD_EXTS = new Set(['doc', 'docx', 'rtf'])
const EXCEL_EXTS = new Set(['xls', 'xlsx', 'csv', 'tsv'])
const PPT_EXTS = new Set(['ppt', 'pptx'])
const PDF_EXTS = new Set(['pdf'])
// 代码(按语言细分)
const WEB_CODE_EXTS = new Set([
'html', 'htm', 'css', 'scss', 'sass', 'less',
'js', 'jsx', 'ts', 'tsx', 'vue', 'svelte', 'mjs', 'cjs'
])
const BACKEND_CODE_EXTS = new Set([
'java', 'go', 'rs',
'c', 'h', 'cpp', 'cc', 'hpp',
'cs', 'swift', 'kt', 'kts', 'scala',
'rb', 'php'
])
const SCRIPT_EXTS = new Set([
'py', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd'
])
const DATA_EXTS = new Set([
'json', 'yaml', 'yml', 'xml', 'toml', 'ini', 'conf', 'properties', 'env'
])
const SQL_EXTS = new Set(['sql', 'graphql', 'gql', 'proto'])
const defaultFileIcon = { component: Document, color: '#94a3b8' }
function getFileIconInfo(fileName) {
if (!fileName) return defaultFileIcon
const ext = fileName.toLowerCase().split('.').pop()
// 多媒体
if (IMAGE_EXTS.has(ext)) return { component: FileImageIcon, color: '#3b82f6' }
if (VIDEO_EXTS.has(ext)) return { component: FileVideoIcon, color: '#a855f7' }
if (AUDIO_EXTS.has(ext)) return { component: FileMusicIcon, color: '#ec4899' }
if (ARCHIVE_EXTS.has(ext)) return { component: FileArchiveIcon, color: '#f59e0b' }
// Office
if (WORD_EXTS.has(ext)) return { component: Document, color: '#2563eb' }
if (EXCEL_EXTS.has(ext)) return { component: FileSpreadsheetIcon, color: '#16a34a' }
if (PPT_EXTS.has(ext)) return { component: FilePresentationIcon, color: '#ea580c' }
if (PDF_EXTS.has(ext)) return { component: FileTextIcon, color: '#dc2626' }
// 代码与数据
if (WEB_CODE_EXTS.has(ext)) return { component: CodeXmlIcon, color: '#10b981' }
if (BACKEND_CODE_EXTS.has(ext)) return { component: FileCodeIcon, color: '#3b82f6' }
if (SCRIPT_EXTS.has(ext)) return { component: TerminalIcon, color: '#eab308' }
if (DATA_EXTS.has(ext)) return { component: FileJsonIcon, color: '#14b8a6' }
if (SQL_EXTS.has(ext)) return { component: DatabaseIcon, color: '#8b5cf6' }
return defaultFileIcon
}
// 右键菜单
const contextMenuVisible = ref(false)
const contextMenuX = ref(0)
@@ -203,6 +431,9 @@ const filePickerRef = ref(null)
const folderPickerRef = ref(null)
const isFolderMode = computed(() => uploadDialogTitle.value === '上传文件夹')
const createInputRef = ref(null)
const renameInputRef = ref(null)
// 上传模式下拉菜单(context-menu 风格)
const uploadMenuVisible = ref(false)
const uploadMenuX = ref(0)
@@ -222,6 +453,7 @@ onUnmounted(() => {
async function fetchFiles() {
loading.value = true
const start = Date.now()
try {
let res = await listFiles(props.workspacePath)
let arr = res
@@ -242,6 +474,10 @@ async function fetchFiles() {
console.error('获取文件列表失败:', error)
fileTree.value = []
} finally {
// 强制最小 loading 时长,避免接口返回过快导致 spinner 一闪而过
const elapsed = Date.now() - start
const remain = 300 - elapsed
if (remain > 0) await new Promise(resolve => setTimeout(resolve, remain))
loading.value = false
}
}
@@ -475,6 +711,15 @@ function handleContextNewFolder() {
createDialogVisible.value = true
}
function handleCreateOpened() {
createInputRef.value?.focus()
}
function handleRenameOpened() {
renameInputRef.value?.focus()
renameInputRef.value?.select()
}
watch(() => props.visible, (val) => {
if (val && fileTree.value.length === 0) {
fetchFiles()
@@ -528,6 +773,11 @@ function handleNodeClick(data) {
color: var(--fe-text-secondary);
}
// 自定义 loading 旋转动画(替换 Element Plus :loading
.is-loading {
animation: rotate 1s linear infinite;
}
.el-icon {
font-size: 16px;
}
@@ -598,38 +848,6 @@ function handleNodeClick(data) {
}
}
.context-menu {
position: fixed;
z-index: 9999;
background: var(--fe-bg-elevated);
border: 1px solid var(--fe-border);
border-radius: 4px;
box-shadow: var(--fe-shadow-md);
padding: 4px 0;
min-width: 120px;
.context-menu-item {
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
&:hover {
background: var(--fe-bg-overlay);
}
&.danger {
color: var(--fe-danger);
}
}
.context-menu-divider {
height: 1px;
background: var(--fe-border);
margin: 4px 0;
}
}
:deep(.el-dropdown-menu__item) {
display: flex;
align-items: center;