934 lines
28 KiB
Vue
934 lines
28 KiB
Vue
<template>
|
||
<div class="file-panel">
|
||
<div class="panel-header">
|
||
<span>本地文件</span>
|
||
<div class="header-actions">
|
||
<el-tooltip content="上传" placement="top" :show-after="300">
|
||
<el-button class="icon-btn" link @click.stop="toggleUploadMenu">
|
||
<el-icon><Upload /></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
<el-tooltip content="下载项目" placement="top" :show-after="300">
|
||
<el-button class="icon-btn" link @click="handleDownloadProject">
|
||
<el-icon><Download /></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
<el-tooltip content="刷新" placement="top" :show-after="300">
|
||
<el-button class="icon-btn" link @click="fetchFiles">
|
||
<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">
|
||
<el-button class="icon-btn" link @click="emit('close')">
|
||
<el-icon><Close /></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-content" @contextmenu.prevent="handlePanelContextMenu">
|
||
<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">
|
||
<span>暂无文件</span>
|
||
</div>
|
||
<el-scrollbar v-else height="calc(100vh - 200px)">
|
||
<el-tree
|
||
:data="fileTree"
|
||
:props="treeProps"
|
||
@node-click="handleNodeClick"
|
||
node-key="path"
|
||
:expand-on-click-node="false"
|
||
:default-expanded-keys="defaultExpandedKeys"
|
||
>
|
||
<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 :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'"
|
||
class="download-icon"
|
||
@click.stop="handleDownload(data)"
|
||
><Download /></el-icon>
|
||
</span>
|
||
</template>
|
||
</el-tree>
|
||
</el-scrollbar>
|
||
</div>
|
||
|
||
<!-- 右键菜单 -->
|
||
<div
|
||
v-show="contextMenuVisible"
|
||
class="context-menu"
|
||
:style="{ top: contextMenuY + 'px', left: contextMenuX + 'px' }"
|
||
>
|
||
<template v-if="contextMenuScope === 'panel'">
|
||
<div class="context-menu-item" @click="handleContextNewFile">新建文件</div>
|
||
<div class="context-menu-item" @click="handleContextNewFolder">新建文件夹</div>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="contextMenuData?.type === 'directory'">
|
||
<div class="context-menu-item" @click="handleContextNewFile">新建文件</div>
|
||
<div class="context-menu-item" @click="handleContextNewFolder">新建文件夹</div>
|
||
<div class="context-menu-item" @click="handleContextDownloadFolder">下载</div>
|
||
</template>
|
||
<div v-else-if="contextMenuData?.type === 'file'" class="context-menu-item" @click="handleContextDownload">下载</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-item" @click="handleContextRename">重命名</div>
|
||
<div class="context-menu-item danger" @click="handleContextDelete">删除</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 新建文件/文件夹对话框 -->
|
||
<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>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 重命名对话框 -->
|
||
<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>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 上传模式下拉菜单(与 .context-menu 视觉一致) -->
|
||
<div
|
||
v-show="uploadMenuVisible"
|
||
class="context-menu upload-menu"
|
||
:style="{ top: uploadMenuY + 'px', left: uploadMenuX + 'px' }"
|
||
>
|
||
<div class="context-menu-item" @click="pickUpload('upload-file')">上传文件</div>
|
||
<div class="context-menu-item" @click="pickUpload('upload-folder')">上传文件夹</div>
|
||
</div>
|
||
|
||
<!-- 上传文件/文件夹对话框 -->
|
||
<input ref="filePickerRef" type="file" multiple style="display:none" @change="onPickerChange" />
|
||
<input ref="folderPickerRef" type="file" webkitdirectory multiple style="display:none" @change="onPickerChange" />
|
||
<el-dialog
|
||
v-model="uploadDialogVisible"
|
||
:title="uploadDialogTitle"
|
||
width="560px"
|
||
:close-on-click-modal="false"
|
||
class="upload-dialog"
|
||
>
|
||
<div class="upload-body">
|
||
<el-button
|
||
type="primary"
|
||
class="picker-btn"
|
||
@click="onPickClick"
|
||
>
|
||
<el-icon class="el-icon--left"><FolderOpened v-if="isFolderMode" /><Upload v-else /></el-icon>
|
||
{{ isFolderMode ? '选择文件夹' : '选择文件' }}
|
||
</el-button>
|
||
|
||
<div class="file-list-container">
|
||
<div v-if="uploadFileList.length === 0" class="empty-tip">未选择文件</div>
|
||
<div
|
||
v-for="(file, index) in uploadFileList"
|
||
:key="index"
|
||
class="file-list-item"
|
||
>
|
||
<span class="path" :title="file._relativePath">{{ file._relativePath }}</span>
|
||
<span class="size">{{ formatSize(file.size) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="confirmUpload">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
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,
|
||
default: false
|
||
},
|
||
workspacePath: {
|
||
type: String,
|
||
default: ''
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['close', 'file-click'])
|
||
|
||
const treeProps = {
|
||
children: 'children',
|
||
label: 'name'
|
||
}
|
||
|
||
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)
|
||
const contextMenuY = ref(0)
|
||
const contextMenuData = ref(null)
|
||
const contextMenuScope = ref('node')
|
||
|
||
// 新建对话框
|
||
const createDialogVisible = ref(false)
|
||
const createName = ref('')
|
||
const createType = ref('file')
|
||
const createDialogTitle = ref('')
|
||
const createTargetPath = ref('/')
|
||
|
||
// 重命名对话框
|
||
const renameDialogVisible = ref(false)
|
||
const renameName = ref('')
|
||
|
||
// 上传对话框
|
||
const uploadDialogVisible = ref(false)
|
||
const uploadDialogTitle = ref('')
|
||
const uploadFileList = ref([])
|
||
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)
|
||
const uploadMenuY = ref(0)
|
||
|
||
// 点击其他区域关闭右键菜单
|
||
function onDocClick() {
|
||
contextMenuVisible.value = false
|
||
}
|
||
onMounted(() => {
|
||
document.addEventListener('click', onDocClick)
|
||
fetchFiles()
|
||
})
|
||
onUnmounted(() => {
|
||
document.removeEventListener('click', onDocClick)
|
||
})
|
||
|
||
async function fetchFiles() {
|
||
loading.value = true
|
||
const start = Date.now()
|
||
try {
|
||
let res = await listFiles(props.workspacePath)
|
||
let arr = res
|
||
if (res && typeof res.msg === 'string') {
|
||
try {
|
||
arr = JSON.parse(res.msg)
|
||
} catch (parseError) {
|
||
// msg 不是 JSON(如 "项目文件请求失败: ..." 这种错误描述),按错误处理
|
||
console.error('解析文件列表响应失败:', parseError, 'raw:', res.msg)
|
||
ElMessage.error(res.msg)
|
||
arr = []
|
||
}
|
||
}
|
||
if (!Array.isArray(arr)) arr = []
|
||
fileTree.value = arr
|
||
defaultExpandedKeys.value = []
|
||
} catch (error) {
|
||
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
|
||
}
|
||
}
|
||
|
||
async function handleDownload(data) {
|
||
try {
|
||
const blob = await downloadFile(props.workspacePath, data.path)
|
||
const downloadUrl = window.URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = downloadUrl
|
||
link.download = data.name
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
window.URL.revokeObjectURL(downloadUrl)
|
||
} catch (error) {
|
||
console.error('下载失败:', error)
|
||
ElMessage.error('下载失败')
|
||
}
|
||
}
|
||
|
||
function handleCreateCommand(command) {
|
||
if (command === 'upload-file') {
|
||
uploadDialogTitle.value = '上传文件'
|
||
uploadFileList.value = []
|
||
uploadDialogVisible.value = true
|
||
} else if (command === 'upload-folder') {
|
||
uploadDialogTitle.value = '上传文件夹'
|
||
uploadFileList.value = []
|
||
uploadDialogVisible.value = true
|
||
}
|
||
}
|
||
|
||
function toggleUploadMenu(e) {
|
||
if (uploadMenuVisible.value) {
|
||
uploadMenuVisible.value = false
|
||
return
|
||
}
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
uploadMenuX.value = rect.left
|
||
uploadMenuY.value = rect.bottom + 4
|
||
uploadMenuVisible.value = true
|
||
}
|
||
|
||
function pickUpload(command) {
|
||
uploadMenuVisible.value = false
|
||
handleCreateCommand(command)
|
||
}
|
||
|
||
function onUploadMenuDocClick(e) {
|
||
if (!uploadMenuVisible.value) return
|
||
if (e.target.closest('.upload-menu')) return
|
||
uploadMenuVisible.value = false
|
||
}
|
||
|
||
watch(uploadMenuVisible, (val) => {
|
||
if (val) {
|
||
setTimeout(() => document.addEventListener('click', onUploadMenuDocClick), 0)
|
||
} else {
|
||
document.removeEventListener('click', onUploadMenuDocClick)
|
||
}
|
||
})
|
||
|
||
async function confirmCreate() {
|
||
if (!createName.value.trim()) {
|
||
ElMessage.warning('请输入名称')
|
||
return
|
||
}
|
||
try {
|
||
await createFileOrDirectory(props.workspacePath, createType.value, createName.value.trim(), createTargetPath.value)
|
||
ElMessage.success('创建成功')
|
||
createDialogVisible.value = false
|
||
fetchFiles()
|
||
} catch (error) {
|
||
console.error('创建失败:', error)
|
||
ElMessage.error('创建失败')
|
||
}
|
||
}
|
||
|
||
function onPickClick() {
|
||
if (isFolderMode.value) {
|
||
folderPickerRef.value?.click()
|
||
} else {
|
||
filePickerRef.value?.click()
|
||
}
|
||
}
|
||
|
||
function onPickerChange(e) {
|
||
const files = Array.from(e.target.files || [])
|
||
uploadFileList.value = files
|
||
uploadFileList.value.forEach(f => {
|
||
f._relativePath = f.webkitRelativePath || f.name
|
||
})
|
||
e.target.value = ''
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
if (bytes === undefined || bytes === null) return ''
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||
}
|
||
|
||
async function confirmUpload() {
|
||
if (uploadFileList.value.length === 0) {
|
||
ElMessage.warning('请选择文件')
|
||
return
|
||
}
|
||
try {
|
||
const files = uploadFileList.value
|
||
const isFolder = uploadDialogTitle.value === '上传文件夹'
|
||
const relativePaths = files.map(f => isFolder ? f._relativePath : f.name)
|
||
await uploadFileOrDirectory(props.workspacePath, '', relativePaths, files)
|
||
ElMessage.success('上传成功')
|
||
uploadDialogVisible.value = false
|
||
uploadFileList.value = []
|
||
fetchFiles()
|
||
} catch (error) {
|
||
console.error('上传失败:', error)
|
||
ElMessage.error('上传失败')
|
||
}
|
||
}
|
||
|
||
async function handleDownloadProject() {
|
||
try {
|
||
const { blob, filename } = await downloadProject(props.workspacePath)
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = filename
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
URL.revokeObjectURL(url)
|
||
} catch (error) {
|
||
console.error('下载失败:', error)
|
||
ElMessage.error('下载失败')
|
||
}
|
||
}
|
||
|
||
async function handleContextDownloadFolder() {
|
||
const data = contextMenuData.value
|
||
contextMenuVisible.value = false
|
||
if (!data) return
|
||
try {
|
||
const { blob, filename } = await downloadProject(data.path)
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = filename
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
URL.revokeObjectURL(url)
|
||
} catch (error) {
|
||
console.error('下载失败:', error)
|
||
ElMessage.error('下载失败')
|
||
}
|
||
}
|
||
|
||
function handleContextMenu(e, data) {
|
||
contextMenuData.value = data
|
||
contextMenuScope.value = 'node'
|
||
contextMenuX.value = e.clientX
|
||
contextMenuY.value = e.clientY
|
||
contextMenuVisible.value = true
|
||
}
|
||
|
||
function handlePanelContextMenu(e) {
|
||
contextMenuData.value = null
|
||
contextMenuScope.value = 'panel'
|
||
contextMenuX.value = e.clientX
|
||
contextMenuY.value = e.clientY
|
||
contextMenuVisible.value = true
|
||
}
|
||
|
||
async function handleContextDownload() {
|
||
contextMenuVisible.value = false
|
||
await handleDownload(contextMenuData.value)
|
||
}
|
||
|
||
function handleContextRename() {
|
||
contextMenuVisible.value = false
|
||
renameName.value = contextMenuData.value.name
|
||
renameDialogVisible.value = true
|
||
}
|
||
|
||
async function handleContextDelete() {
|
||
contextMenuVisible.value = false
|
||
try {
|
||
await deleteFileOrDirectory(props.workspacePath, contextMenuData.value.path, contextMenuData.value.type)
|
||
ElMessage.success('删除成功')
|
||
fetchFiles()
|
||
} catch (error) {
|
||
console.error('删除失败:', error)
|
||
ElMessage.error('删除失败')
|
||
}
|
||
}
|
||
|
||
async function confirmRename() {
|
||
if (!renameName.value.trim()) {
|
||
ElMessage.warning('请输入新名称')
|
||
return
|
||
}
|
||
try {
|
||
await renameFile(props.workspacePath, contextMenuData.value.path, renameName.value.trim())
|
||
ElMessage.success('重命名成功')
|
||
renameDialogVisible.value = false
|
||
fetchFiles()
|
||
} catch (error) {
|
||
console.error('重命名失败:', error)
|
||
ElMessage.error('重命名失败')
|
||
}
|
||
}
|
||
|
||
function handleContextNewFile() {
|
||
contextMenuVisible.value = false
|
||
createType.value = 'file'
|
||
createDialogTitle.value = '新建文件'
|
||
createName.value = ''
|
||
createTargetPath.value = contextMenuScope.value === 'panel' ? '/' : contextMenuData.value.path
|
||
createDialogVisible.value = true
|
||
}
|
||
|
||
function handleContextNewFolder() {
|
||
contextMenuVisible.value = false
|
||
createType.value = 'directory'
|
||
createDialogTitle.value = '新建文件夹'
|
||
createName.value = ''
|
||
createTargetPath.value = contextMenuScope.value === 'panel' ? '/' : contextMenuData.value.path
|
||
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()
|
||
}
|
||
})
|
||
|
||
function handleNodeClick(data) {
|
||
if (data.type === 'file') {
|
||
emit('file-click', data)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.file-panel {
|
||
width: 300px;
|
||
height: 100%;
|
||
background: var(--fe-bg-elevated);
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
|
||
.panel-header {
|
||
padding: 11px 16px;
|
||
border-bottom: 1px solid var(--fe-border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-weight: 600;
|
||
color: var(--fe-text-primary);
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
|
||
.icon-btn {
|
||
padding: 4px 8px;
|
||
width: 32px;
|
||
height: 32px;
|
||
margin-left: 0 !important;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--fe-text-muted);
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
transition: all 0.2s;
|
||
|
||
&:hover {
|
||
background: var(--fe-bg-hover);
|
||
color: var(--fe-text-secondary);
|
||
}
|
||
|
||
// 自定义 loading 旋转动画(替换 Element Plus :loading)
|
||
.is-loading {
|
||
animation: rotate 1s linear infinite;
|
||
}
|
||
|
||
.el-icon {
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.panel-content {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
position: relative;
|
||
|
||
// 浅色主题下 el-scrollbar 滚动区背景设白
|
||
:deep(.el-scrollbar__wrap) {
|
||
body[data-fe-theme="light"] & {
|
||
background: #ffffff;
|
||
}
|
||
}
|
||
|
||
.loading-wrapper,
|
||
.empty-wrapper {
|
||
padding:20px;
|
||
text-align: center;
|
||
color: var(--fe-text-muted);
|
||
font-size: 13px;
|
||
// position: absolute;
|
||
// inset: 0;
|
||
// display: flex;
|
||
// align-items: center;
|
||
// justify-content: center;
|
||
// color: var(--fe-text-muted);
|
||
// font-size: 14px;
|
||
}
|
||
|
||
.loading-icon {
|
||
font-size: 24px;
|
||
animation: rotate 1s linear infinite;
|
||
}
|
||
|
||
@keyframes rotate {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
}
|
||
|
||
.tree-node {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
width: 100%;
|
||
|
||
.node-label {
|
||
flex: 0 1 calc(100% - 100px);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
min-width: 0;
|
||
}
|
||
|
||
.download-icon {
|
||
opacity: 0;
|
||
cursor: pointer;
|
||
color: var(--fe-accent);
|
||
margin-left: 8px;
|
||
transition: opacity 0.2s, color 0.2s;
|
||
|
||
&:hover {
|
||
color: var(--fe-accent);
|
||
}
|
||
}
|
||
|
||
&:hover .download-icon {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
:deep(.el-dropdown-menu__item) {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.context-menu.upload-menu {
|
||
min-width: 100px;
|
||
}
|
||
|
||
.upload-dialog {
|
||
.upload-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.picker-btn {
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.file-list-container {
|
||
border: 1px solid var(--fe-border);
|
||
border-radius: 4px;
|
||
max-height: 240px;
|
||
overflow-y: auto;
|
||
padding: 4px 0;
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
&::-webkit-scrollbar-thumb {
|
||
background: var(--fe-border);
|
||
border-radius: 3px;
|
||
}
|
||
&::-webkit-scrollbar-thumb:hover {
|
||
background: var(--fe-border-strong);
|
||
}
|
||
}
|
||
|
||
.file-list-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--fe-border);
|
||
font-size: 13px;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.path {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-right: 16px;
|
||
color: var(--fe-text-primary);
|
||
}
|
||
|
||
.size {
|
||
color: var(--fe-text-muted);
|
||
font-size: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.empty-tip {
|
||
padding: 24px 16px;
|
||
text-align: center;
|
||
color: var(--fe-text-muted);
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
</style>
|