云文件管理系统上传组件优化
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<div class="main-wrapper">
|
||||
<!-- 左侧菜单 -->
|
||||
@@ -17,27 +17,39 @@
|
||||
v-model:viewMode="viewMode"
|
||||
v-model:searchKeyword="searchKeyword"
|
||||
:menu-type="activeMenu"
|
||||
:current-path="getCurrentPathName()"
|
||||
:show-back="folderStack.length > 0"
|
||||
:folder-stack="folderStack"
|
||||
@search="loadFiles"
|
||||
@refresh="loadFiles"
|
||||
@upload="uploadVisible = true"
|
||||
@upload="handleOpenUpload"
|
||||
@newFolder="folderVisible = true"
|
||||
@emptyTrash="handleEmptyTrash"
|
||||
@goBack="goBack"
|
||||
@goBack="handleBreadcrumbClick"
|
||||
/>
|
||||
|
||||
<!-- 批量操作工具栏 -->
|
||||
<div v-if="activeMenu === 'my-files'" class="batch-toolbar">
|
||||
<div v-if="activeMenu === 'my-files' || activeMenu === 'shared-to-me' || activeMenu === 'my-share' || activeMenu === 'trash'" class="batch-toolbar">
|
||||
<el-button-group>
|
||||
<el-button @click="handleBatchDownload" :disabled="selectedFiles.length === 0">
|
||||
<el-button v-if="activeMenu !== 'trash'" @click="handleBatchDownload" :disabled="selectedFiles.length === 0">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span style="margin-left: 4px">批量下载</span>
|
||||
</el-button>
|
||||
<el-button @click="handleBatchMove" :disabled="selectedFiles.length === 0">
|
||||
<el-button v-if="activeMenu === 'my-share'" type="warning" @click="handleBatchCancelShare" :disabled="selectedFiles.length === 0">
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
<span style="margin-left: 4px">批量取消</span>
|
||||
</el-button>
|
||||
<el-button v-if="activeMenu === 'trash'" type="primary" @click="handleBatchRestore" :disabled="selectedFiles.length === 0">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
<span style="margin-left: 4px">批量还原</span>
|
||||
</el-button>
|
||||
<el-button v-if="activeMenu === 'my-files'" type="success" @click="handleBatchMove" :disabled="selectedFiles.length === 0">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span style="margin-left: 4px">批量移动</span>
|
||||
</el-button>
|
||||
<el-button v-if="activeMenu === 'my-files'" type="danger" @click="handleBatchDelete" :disabled="selectedFiles.length === 0">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span style="margin-left: 4px">批量删除</span>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
@@ -64,9 +76,7 @@
|
||||
<div v-for="file in paginatedFiles" :key="file.id" class="file-card" @dblclick="handleRowDblClick(file)">
|
||||
<div class="file-card-main">
|
||||
<div class="file-card-icon">
|
||||
<el-icon :size="36" :color="getFileIconColor(file)">
|
||||
<component :is="getFileIcon(file)" />
|
||||
</el-icon>
|
||||
<FileIcon :icon="getFileIconType(file)" :color="getFileIconColor(file)" :size="36" />
|
||||
</div>
|
||||
<el-tooltip :content="file.name" placement="top" :show-after="300">
|
||||
<div class="file-card-name">{{ file.name }}</div>
|
||||
@@ -119,6 +129,7 @@
|
||||
<UploadDialog
|
||||
v-model="uploadVisible"
|
||||
:uploading="uploading"
|
||||
:remaining-storage="remainingStorage"
|
||||
@upload="handleUpload"
|
||||
/>
|
||||
<FolderDialog
|
||||
@@ -154,10 +165,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Document, Folder, Picture, VideoPlay, Headset, RefreshLeft, Delete, View, Share, Download, CloseBold, Edit, FolderOpened, Close } from '@element-plus/icons-vue'
|
||||
import { RefreshLeft, Delete, View, Share, Download, CloseBold, Edit, FolderOpened, Close } from '@element-plus/icons-vue'
|
||||
import FileSidebar from '@/components/FileSidebar.vue'
|
||||
import FileToolbar from '@/components/FileToolbar.vue'
|
||||
import FileTable from '@/components/FileTable.vue'
|
||||
import FileIcon from '@/components/FileIcon.vue'
|
||||
import UploadDialog from '@/components/UploadDialog.vue'
|
||||
import FolderDialog from '@/components/FolderDialog.vue'
|
||||
import ShareDialog from '@/components/ShareDialog.vue'
|
||||
@@ -168,7 +180,8 @@ import {
|
||||
getFiles, getTrashFiles, getSharedByMe, getSharedByMeFolderFiles, getSharedToMe, getSharedFolderFiles,
|
||||
uploadFiles, downloadFile, deleteFile, restoreFile,
|
||||
deletePermanently, emptyTrash, createFolder,
|
||||
shareFileApi, cancelShare, getFilePreview, renameFile, moveFile, batchDownload, getMovableFolders
|
||||
shareFileApi, cancelShare, getFilePreview, renameFile, moveFile, batchDownload, getMovableFolders,
|
||||
batchCancelShare, batchRestore
|
||||
} from '@/api/file'
|
||||
import { getCurrentUser } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/user'
|
||||
@@ -189,6 +202,14 @@ const folderStack = ref([])
|
||||
// 弹窗
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
const handleOpenUpload = () => {
|
||||
if (remainingStorage.value <= 0) {
|
||||
ElMessage.warning('存储空间已满,无法上传文件')
|
||||
return
|
||||
}
|
||||
uploadVisible.value = true
|
||||
}
|
||||
const folderVisible = ref(false)
|
||||
const shareVisible = ref(false)
|
||||
const currentShareFile = ref(null)
|
||||
@@ -220,6 +241,13 @@ const totalStorage = computed(() => {
|
||||
return (limit / (1024 * 1024 * 1024)).toFixed(0) + ' GB'
|
||||
})
|
||||
|
||||
// 剩余存储空间(字节)
|
||||
const remainingStorage = computed(() => {
|
||||
const limit = userStore.storageLimit || 20 * 1024 * 1024 * 1024
|
||||
const used = userStore.storageUsed || 0
|
||||
return Math.max(0, limit - used)
|
||||
})
|
||||
|
||||
// 刷新存储数据(从后端精确重算)
|
||||
const refreshStorage = async () => {
|
||||
try {
|
||||
@@ -237,22 +265,6 @@ const refreshStorage = async () => {
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const getFileIcon = (file) => {
|
||||
if (file.type === 'folder') return Folder
|
||||
if (file.type === 'image') return Picture
|
||||
if (file.type === 'video') return VideoPlay
|
||||
if (file.type === 'audio') return Headset
|
||||
return Document
|
||||
}
|
||||
|
||||
const getFileIconColor = (file) => {
|
||||
if (file.type === 'folder') return '#f7b32b'
|
||||
if (file.type === 'image') return '#67c23a'
|
||||
if (file.type === 'video') return '#409eff'
|
||||
if (file.type === 'audio') return '#e6a23c'
|
||||
return '#909399'
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (!size) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
@@ -269,7 +281,11 @@ const formatDate = (date) => {
|
||||
const canPreview = (file) => {
|
||||
if (file.type === 'folder') return false
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'md', 'docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt', 'ofd'].includes(ext)
|
||||
// 支持预览的文件类型:图片、PDF、文本、Office(注意:doc 旧格式不支持)
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
|
||||
'pdf',
|
||||
'txt', 'md', 'markdown', 'json', 'xml', 'log', 'js', 'ts', 'vue', 'java', 'py', 'css', 'html', 'htm', 'yaml', 'yml', 'sql',
|
||||
'docx', 'xlsx', 'xls', 'pptx', 'ppt', 'ofd'].includes(ext)
|
||||
}
|
||||
|
||||
// 分页
|
||||
@@ -318,9 +334,14 @@ const handleMenuSelect = (index) => {
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// 进入文件夹
|
||||
// 进入目录
|
||||
const enterFolder = (folder) => {
|
||||
folderStack.value.push({ id: currentFolderId.value, name: folder.name })
|
||||
// 先把当前目录信息压入栈(用于返回)
|
||||
folderStack.value.push({
|
||||
id: folder.id,
|
||||
name: folder.name
|
||||
})
|
||||
// 进入新目录
|
||||
currentFolderId.value = folder.id
|
||||
loadFiles()
|
||||
}
|
||||
@@ -328,8 +349,25 @@ const enterFolder = (folder) => {
|
||||
// 返回上级
|
||||
const goBack = () => {
|
||||
if (!folderStack.value.length) return
|
||||
const prev = folderStack.value.pop()
|
||||
currentFolderId.value = prev.id
|
||||
folderStack.value.pop()
|
||||
// 获取上一个目录的ID
|
||||
const lastFolder = folderStack.value[folderStack.value.length - 1]
|
||||
currentFolderId.value = lastFolder?.id || null
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// 面包屑点击
|
||||
const handleBreadcrumbClick = (index) => {
|
||||
if (index === -1) {
|
||||
// 点击根目录
|
||||
folderStack.value = []
|
||||
currentFolderId.value = null
|
||||
} else {
|
||||
// 点击某个目录,截取到该位置之后
|
||||
folderStack.value = folderStack.value.slice(0, index + 1)
|
||||
// 当前目录设为点击的目录
|
||||
currentFolderId.value = folderStack.value[index]?.id || null
|
||||
}
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
@@ -340,16 +378,6 @@ const getCurrentPathName = () => {
|
||||
}
|
||||
|
||||
const handleUpload = async (fileList) => {
|
||||
// 上传前校验存储空间
|
||||
const remaining = (userStore.storageLimit || 20 * 1024 * 1024 * 1024) - (userStore.storageUsed || 0)
|
||||
const totalSize = fileList.reduce((sum, f) => sum + (f.size || 0), 0)
|
||||
if (totalSize > remaining) {
|
||||
const needGb = (totalSize / (1024 * 1024 * 1024)).toFixed(2)
|
||||
const remainMb = (remaining / (1024 * 1024)).toFixed(2)
|
||||
ElMessage.warning(`存储空间不足,需要 ${needGb} GB,剩余仅 ${remainMb} MB`)
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadFiles(fileList, currentFolderId.value)
|
||||
@@ -378,18 +406,29 @@ const handlePreview = async (file) => {
|
||||
previewFile.value = file
|
||||
previewUrl.value = ''
|
||||
previewContent.value = ''
|
||||
previewVisible.value = true // 先打开弹窗
|
||||
|
||||
try {
|
||||
const blob = await getFilePreview(file.id)
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'].includes(ext)) {
|
||||
|
||||
// 需要用 blob URL 的格式:图片、PDF、Office
|
||||
const blobUrlFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
|
||||
'pdf', 'docx', 'xlsx', 'xls', 'pptx', 'ppt', 'ofd']
|
||||
|
||||
if (blobUrlFormats.includes(ext)) {
|
||||
// 图片、PDF、Office 文件使用 blob URL
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
} else {
|
||||
// 文本文件直接读取内容
|
||||
previewContent.value = await blob.text()
|
||||
console.log('Text content loaded:', previewContent.value?.substring(0, 100))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Preview error:', e)
|
||||
ElMessage.error('预览失败')
|
||||
previewVisible.value = false
|
||||
}
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const handleRename = (file) => {
|
||||
@@ -428,7 +467,9 @@ const handleDelete = async (file) => {
|
||||
ElMessage.success('已移至回收站')
|
||||
loadFiles()
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
// 显示后端返回的错误信息
|
||||
const errorMsg = e.response?.data?.message || e.message || '删除失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +557,14 @@ const handleBatchDownload = async () => {
|
||||
const ids = selectedFiles.value.map(f => f.id)
|
||||
const blob = await batchDownload(ids)
|
||||
|
||||
// 检查返回的是否是错误信息(JSON 格式)
|
||||
if (blob.type && blob.type.includes('application/json')) {
|
||||
const text = await blob.text()
|
||||
const error = JSON.parse(text)
|
||||
ElMessage.error(error.message || '下载失败')
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
@@ -524,7 +573,23 @@ const handleBatchDownload = async () => {
|
||||
URL.revokeObjectURL(url)
|
||||
selectedFiles.value = []
|
||||
} catch (e) {
|
||||
ElMessage.error('下载失败')
|
||||
// 处理 HTTP 错误响应
|
||||
if (e.response && e.response.data) {
|
||||
// 如果是 blob 类型的错误响应
|
||||
if (e.response.data instanceof Blob) {
|
||||
try {
|
||||
const text = await e.response.data.text()
|
||||
const error = JSON.parse(text)
|
||||
ElMessage.error(error.message || '下载失败')
|
||||
} catch {
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(e.response.data.message || '下载失败')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,39 +605,76 @@ const handleBatchMove = async () => {
|
||||
movableFolders.value = res.data || []
|
||||
batchMoveVisible.value = true
|
||||
} catch (e) {
|
||||
ElMessage.error('获取文件夹列表失败')
|
||||
ElMessage.error('获取目录列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmBatchMove = async (targetFolderId) => {
|
||||
// 处理各种选项
|
||||
// Handle target folder ID
|
||||
let finalFolderId = null
|
||||
if (targetFolderId === 'parent') {
|
||||
if (folderStack.value.length > 0) {
|
||||
const parentFolder = folderStack.value[folderStack.value.length - 1]
|
||||
finalFolderId = parentFolder.id
|
||||
} else {
|
||||
finalFolderId = null
|
||||
}
|
||||
} else if (targetFolderId === 'root' || targetFolderId === '' || targetFolderId === null || targetFolderId === undefined) {
|
||||
// 根目录
|
||||
|
||||
if (targetFolderId === 'root' || targetFolderId === '' || targetFolderId === null || targetFolderId === undefined) {
|
||||
finalFolderId = null
|
||||
} else {
|
||||
// 具体文件夹
|
||||
finalFolderId = targetFolderId
|
||||
}
|
||||
|
||||
console.log('targetFolderId:', targetFolderId, 'finalFolderId:', finalFolderId)
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const file of selectedFiles.value) {
|
||||
try {
|
||||
await moveFile(file.id, finalFolderId)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
ElMessage.error("移动文件或目录失败")
|
||||
failCount++
|
||||
console.error('移动失败:', file.name, e)
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success('批量移动完成')
|
||||
if (successCount > 0) {
|
||||
ElMessage.success(`成功移动 ${successCount} 个文件${failCount > 0 ? `,${failCount} 个失败` : ''}`)
|
||||
} else if (failCount > 0) {
|
||||
ElMessage.error('移动失败')
|
||||
}
|
||||
|
||||
selectedFiles.value = []
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedFiles.value.length === 0) {
|
||||
ElMessage.warning('请选择要删除的文件')
|
||||
return
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${selectedFiles.value.length} 个文件吗?`, '提示', { type: 'warning' })
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
const errors = []
|
||||
|
||||
for (const file of selectedFiles.value) {
|
||||
try {
|
||||
await deleteFile(file.id)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
failCount++
|
||||
const errorMsg = e.response?.data?.message || e.message || '删除失败'
|
||||
if (!errors.includes(errorMsg)) {
|
||||
errors.push(errorMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
ElMessage.success(`成功删除 ${successCount} 个文件${failCount > 0 ? `,${failCount} 个失败` : ''}`)
|
||||
} else if (failCount > 0) {
|
||||
ElMessage.error(errors[0] || '删除失败')
|
||||
}
|
||||
|
||||
selectedFiles.value = []
|
||||
loadFiles()
|
||||
}
|
||||
@@ -583,10 +685,90 @@ const handleRowDblClick = (row) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchCancelShare = async () => {
|
||||
if (selectedFiles.value.length === 0) {
|
||||
ElMessage.warning('请选择要取消共享的文件')
|
||||
return
|
||||
}
|
||||
await ElMessageBox.confirm(`确定取消选中 ${selectedFiles.value.length} 个文件的共享吗?`, '提示', { type: 'warning' })
|
||||
try {
|
||||
const ids = selectedFiles.value.map(f => f.id)
|
||||
const res = await batchCancelShare(ids)
|
||||
ElMessage.success('批量取消共享成功')
|
||||
selectedFiles.value = []
|
||||
loadFiles()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchRestore = async () => {
|
||||
if (selectedFiles.value.length === 0) {
|
||||
ElMessage.warning('请选择要还原的文件')
|
||||
return
|
||||
}
|
||||
await ElMessageBox.confirm(`确定还原选中的 ${selectedFiles.value.length} 个文件吗?`, '提示', { type: 'info' })
|
||||
try {
|
||||
const ids = selectedFiles.value.map(f => f.id)
|
||||
const res = await batchRestore(ids)
|
||||
ElMessage.success('批量还原成功')
|
||||
selectedFiles.value = []
|
||||
loadFiles()
|
||||
refreshStorage()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshStorage()
|
||||
loadFiles()
|
||||
})
|
||||
|
||||
// 文件图标类型
|
||||
const getFileIconType = (file) => {
|
||||
if (file.type === 'folder') return 'folder'
|
||||
if (file.type === 'image') return 'image'
|
||||
if (file.type === 'video') return 'video'
|
||||
if (file.type === 'audio') return 'audio'
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (ext === 'pdf') return 'pdf'
|
||||
if (['docx', 'doc'].includes(ext)) return 'word'
|
||||
if (['xlsx', 'xls'].includes(ext)) return 'excel'
|
||||
if (['pptx', 'ppt'].includes(ext)) return 'ppt'
|
||||
if (['js', 'ts', 'vue', 'jsx', 'tsx', 'html', 'css', 'scss', 'sass', 'less', 'json', 'xml'].includes(ext)) return 'code'
|
||||
if (['txt', 'md', 'markdown', 'log', 'sql', 'yaml', 'yml', 'sh', 'bat', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs'].includes(ext)) return 'text'
|
||||
if (ext === 'ofd') return 'pdf'
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'zip'
|
||||
|
||||
return 'document'
|
||||
}
|
||||
|
||||
// 文件图标颜色
|
||||
const getFileIconColor = (file) => {
|
||||
if (file.type === 'folder') return '#f7b32b'
|
||||
if (file.type === 'image') return '#67c23a'
|
||||
if (file.type === 'video') return '#409eff'
|
||||
if (file.type === 'audio') return '#e6a23c'
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (ext === 'pdf') return '#f56c6c'
|
||||
if (['docx', 'doc'].includes(ext)) return '#2b579a'
|
||||
if (['xlsx', 'xls'].includes(ext)) return '#217346'
|
||||
if (['pptx', 'ppt'].includes(ext)) return '#d24726'
|
||||
if (['js', 'ts', 'vue', 'jsx', 'tsx', 'html', 'css', 'scss', 'sass', 'less', 'json', 'xml'].includes(ext)) return '#9c27b0'
|
||||
if (['txt', 'md', 'markdown', 'log', 'yaml', 'yml', 'sh', 'bat'].includes(ext)) return '#606266'
|
||||
if (ext === 'sql') return '#1976d2'
|
||||
if (ext === 'py') return '#3776ab'
|
||||
if (ext === 'java') return '#f89820'
|
||||
if (ext === 'ofd') return '#f56c6c'
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return '#8d6e63'
|
||||
|
||||
return '#909399'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user