@@ -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=" goBa ck"
@goBack=" handleBreadcrumbCli ck"
/>
<!-- 批量操作工具栏 -->
<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 : 4 px ">批量下载</span>
</el-button>
<el-button @click=" handleBatchMov e " :disabled=" selectedFiles . length === 0 ">
<el-button v-if=" activeMenu === 'my-share' " type=" warning " @click=" handleBatchCancelShar e " :disabled=" selectedFiles . length === 0 ">
<el-icon><CloseBold /></el-icon>
<span style=" margin - left : 4 px ">批量取消</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 : 4 px ">批量还原</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 : 4 px ">批量移动</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 : 4 px ">批量删除</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 >