1
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user