Files
agent-frontend-web/src/views/frontend/components/FilePanel.vue
T
2026-08-08 17:28:57 +08:00

934 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>