This commit is contained in:
2026-08-07 13:56:29 +08:00
commit 152d5273a5
306 changed files with 38651 additions and 0 deletions
+708
View File
@@ -0,0 +1,708 @@
<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><Refresh /></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" 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><Document /></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">
<el-input 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">
<el-input 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 } 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'
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([])
// 右键菜单
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 === '上传文件夹')
// 上传模式下拉菜单(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
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.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
}
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);
}
.el-icon {
font-size: 16px;
}
}
}
}
.panel-content {
flex: 1;
overflow: hidden;
position: relative;
.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;
}
}
}
.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;
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>