Files
system-file/web-vue/src/views/files/index.vue

722 lines
21 KiB
Vue
Raw Normal View History

2026-04-01 22:39:11 +08:00
<template>
<div class="app-layout">
<div class="main-wrapper">
<!-- 左侧菜单 -->
<FileSidebar
:active-menu="activeMenu"
:storage-percent="storagePercent"
:used-storage="usedStorage"
:total-storage="totalStorage"
@select="handleMenuSelect"
/>
<!-- 右侧内容 -->
<div class="content-area">
<!-- 工具栏 -->
<FileToolbar
v-model:viewMode="viewMode"
v-model:searchKeyword="searchKeyword"
:menu-type="activeMenu"
:current-path="getCurrentPathName()"
:show-back="folderStack.length > 0"
@search="loadFiles"
@refresh="loadFiles"
@upload="uploadVisible = true"
@newFolder="folderVisible = true"
@emptyTrash="handleEmptyTrash"
@goBack="goBack"
/>
<!-- 批量操作工具栏 -->
2026-04-01 23:03:55 +08:00
<div v-if="activeMenu === 'my-files'" class="batch-toolbar">
<el-button-group>
<el-button @click="handleBatchDownload" :disabled="selectedFiles.length === 0 || !hasSelectedFiles">
2026-04-01 22:39:11 +08:00
<el-icon><Download /></el-icon>
<span style="margin-left: 4px">批量下载</span>
</el-button>
2026-04-01 23:03:55 +08:00
<el-button @click="handleBatchMove" :disabled="selectedFiles.length === 0">
2026-04-01 22:39:11 +08:00
<el-icon><FolderOpened /></el-icon>
<span style="margin-left: 4px">批量移动</span>
</el-button>
2026-04-01 23:03:55 +08:00
</el-button-group>
2026-04-01 22:39:11 +08:00
</div>
<!-- 文件列表 -->
<div class="file-content">
<FileTable
v-if="viewMode === 'table'"
:files="paginatedFiles"
:loading="loading"
:menu-type="activeMenu"
:in-folder="currentFolderId !== null"
@preview="handlePreview"
@rename="handleRename"
@share="handleShare"
@download="handleDownload"
@delete="handleDelete"
@restore="handleRestore"
@delete-permanent="handleDeletePermanently"
@cancel-share="handleCancelShare"
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
/>
<div v-else class="grid-view">
<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>
</div>
<el-tooltip :content="file.name" placement="top" :show-after="300">
<div class="file-card-name">{{ file.name }}</div>
</el-tooltip>
</div>
<div class="file-card-meta">
<span class="file-card-date">{{ formatDate(file.createTime) }}</span>
<span class="file-card-size">{{ file.type === 'folder' ? '-' : formatSize(file.size) }}</span>
</div>
<div class="file-card-actions">
<template v-if="activeMenu === 'trash'">
<el-button link @click.stop="handleRestore(file)"><el-icon><RefreshLeft /></el-icon><span>还原</span></el-button>
<el-button link type="danger" @click.stop="handleDeletePermanently(file)"><el-icon><Delete /></el-icon><span>删除</span></el-button>
</template>
<template v-else-if="activeMenu === 'my-files'">
<el-button link @click.stop="handlePreview(file)" v-if="canPreview(file)"><el-icon><View /></el-icon><span>预览</span></el-button>
<el-button link @click.stop="handleRename(file)"><el-icon><Edit /></el-icon><span>重命名</span></el-button>
<el-button link @click.stop="handleShare(file)"><el-icon><Share /></el-icon><span>共享</span></el-button>
<el-button link @click.stop="handleDownload(file)" v-if="file.type !== 'folder'"><el-icon><Download /></el-icon><span>下载</span></el-button>
<el-button link type="danger" @click.stop="handleDelete(file)"><el-icon><Delete /></el-icon><span>删除</span></el-button>
</template>
<template v-else-if="activeMenu === 'my-share'">
<el-button link @click.stop="handlePreview(file)" v-if="canPreview(file)"><el-icon><View /></el-icon><span>预览</span></el-button>
<el-button link @click.stop="handleDownload(file)" v-if="file.type !== 'folder'"><el-icon><Download /></el-icon><span>下载</span></el-button>
<el-button link type="warning" @click.stop="handleCancelShare(file)" v-if="!currentFolderId"><el-icon><CloseBold /></el-icon><span>取消共享</span></el-button>
</template>
<template v-else-if="activeMenu === 'shared-to-me'">
<el-button link @click.stop="handlePreview(file)" v-if="canPreview(file)"><el-icon><View /></el-icon><span>预览</span></el-button>
<el-button link @click.stop="handleDownload(file)" v-if="file.type !== 'folder'"><el-icon><Download /></el-icon><span>下载</span></el-button>
</template>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="files.length"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
</div>
</div>
<!-- 弹窗组件 -->
<UploadDialog
v-model="uploadVisible"
:uploading="uploading"
@upload="handleUpload"
/>
<FolderDialog
v-model="folderVisible"
@create="handleCreateFolder"
/>
<ShareDialog
v-model="shareVisible"
:file="currentShareFile"
@share="handleConfirmShare"
/>
<PreviewDialog
v-model="previewVisible"
:preview-file="previewFile"
:preview-url="previewUrl"
:preview-content="previewContent"
/>
<RenameDialog
v-model="renameVisible"
:file="currentRenameFile"
@rename="handleConfirmRename"
/>
</div>
</template>
<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 FileSidebar from '@/components/FileSidebar.vue'
import FileToolbar from '@/components/FileToolbar.vue'
import FileTable from '@/components/FileTable.vue'
import UploadDialog from '@/components/UploadDialog.vue'
import FolderDialog from '@/components/FolderDialog.vue'
import ShareDialog from '@/components/ShareDialog.vue'
import PreviewDialog from '@/components/PreviewDialog.vue'
import RenameDialog from '@/components/RenameDialog.vue'
import {
getFiles, getTrashFiles, getSharedByMe, getSharedByMeFolderFiles, getSharedToMe, getSharedFolderFiles,
uploadFiles, downloadFile, deleteFile, restoreFile,
deletePermanently, emptyTrash, createFolder,
2026-04-01 23:03:55 +08:00
shareFileApi, cancelShare, getFilePreview, renameFile, moveFile, batchDownload, getMovableFolders
2026-04-01 22:39:11 +08:00
} from '@/api/file'
import { getCurrentUser } from '@/api/auth'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
// 状态
const activeMenu = ref('my-files')
const loading = ref(false)
const files = ref([])
const viewMode = ref('table')
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const currentFolderId = ref(null)
const folderStack = ref([])
// 弹窗
const uploadVisible = ref(false)
const uploading = ref(false)
const folderVisible = ref(false)
const shareVisible = ref(false)
const currentShareFile = ref(null)
const previewVisible = ref(false)
const previewFile = ref(null)
const previewUrl = ref('')
const previewContent = ref('')
const renameVisible = ref(false)
const currentRenameFile = ref(null)
const selectedFiles = ref([])
const hasSelectedFiles = computed(() => selectedFiles.value.length > 0)
// 存储 —— 真实数据
const storagePercent = computed(() => {
const limit = userStore.storageLimit || 20 * 1024 * 1024 * 1024
const used = userStore.storageUsed || 0
if (limit <= 0) return 0
return Math.min(Math.round((used / limit) * 100), 100)
})
const usedStorage = computed(() => {
const used = userStore.storageUsed || 0
return used >= 1024 * 1024 * 1024
? (used / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
: (used / (1024 * 1024)).toFixed(2) + ' MB'
})
const totalStorage = computed(() => {
const limit = userStore.storageLimit || 20 * 1024 * 1024 * 1024
return (limit / (1024 * 1024 * 1024)).toFixed(0) + ' GB'
})
// 刷新存储数据(从后端精确重算)
const refreshStorage = async () => {
try {
const res = await getCurrentUser()
const data = res.data
if (data) {
userStore.setUser({
storageUsed: data.storageUsed ?? 0,
storageLimit: data.storageLimit ?? 20 * 1024 * 1024 * 1024
})
}
} catch (e) {
// 静默失败,不影响页面正常使用
}
}
// 辅助函数
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']
let i = 0
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
return size.toFixed(2) + ' ' + units[i]
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
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'].includes(ext)
}
// 分页
const paginatedFiles = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return files.value.slice(start, start + pageSize.value)
})
// 加载文件
const loadFiles = async () => {
loading.value = true
currentPage.value = 1
try {
let res
switch (activeMenu.value) {
case 'trash': res = await getTrashFiles(currentFolderId.value ? { folderId: currentFolderId.value } : {}); break
case 'my-share':
if (currentFolderId.value) {
res = await getSharedByMeFolderFiles(currentFolderId.value)
} else {
res = await getSharedByMe()
}
break
case 'shared-to-me':
if (currentFolderId.value) {
res = await getSharedFolderFiles(currentFolderId.value)
} else {
res = await getSharedToMe()
}
break
default: res = await getFiles({ folderId: currentFolderId.value, keyword: searchKeyword.value })
}
files.value = res.data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleMenuSelect = (index) => {
activeMenu.value = index
searchKeyword.value = ''
currentFolderId.value = null
folderStack.value = []
loadFiles()
}
// 进入文件夹
const enterFolder = (folder) => {
folderStack.value.push({ id: currentFolderId.value, name: folder.name })
currentFolderId.value = folder.id
loadFiles()
}
// 返回上级
const goBack = () => {
if (!folderStack.value.length) return
const prev = folderStack.value.pop()
currentFolderId.value = prev.id
loadFiles()
}
// 获取当前路径名称
const getCurrentPathName = () => {
if (!folderStack.value.length) return '/'
return folderStack.value.map(f => f.name).join(' / ')
}
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)
ElMessage.success('上传成功')
uploadVisible.value = false
loadFiles()
refreshStorage()
} catch (e) {
ElMessage.error('上传失败')
} finally {
uploading.value = false
}
}
const handleCreateFolder = async (name) => {
try {
await createFolder({ name, parentId: currentFolderId.value })
ElMessage.success('创建成功')
loadFiles()
} catch (e) {
ElMessage.error('创建失败')
}
}
const handlePreview = async (file) => {
previewFile.value = file
previewUrl.value = ''
previewContent.value = ''
try {
const blob = await getFilePreview(file.id)
const ext = file.name.split('.').pop()?.toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'].includes(ext)) {
previewUrl.value = URL.createObjectURL(blob)
} else {
previewContent.value = await blob.text()
}
} catch (e) {
ElMessage.error('预览失败')
}
previewVisible.value = true
}
const handleRename = (file) => {
currentRenameFile.value = file
renameVisible.value = true
}
const handleConfirmRename = async ({ id, name }) => {
try {
await renameFile(id, name)
ElMessage.success('重命名成功')
loadFiles()
} catch (e) {
ElMessage.error('重命名失败')
}
}
const handleDownload = async (file) => {
try {
const blob = await downloadFile(file.id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = file.name
a.click()
URL.revokeObjectURL(url)
} catch (e) {
ElMessage.error('下载失败')
}
}
const handleDelete = async (file) => {
await ElMessageBox.confirm("确定删除此文件吗?", '提示', { type: 'warning' })
try {
await deleteFile(file.id)
ElMessage.success('已移至回收站')
loadFiles()
} catch (e) {
ElMessage.error('删除失败')
}
}
const handleRestore = async (file) => {
try {
await restoreFile(file.id)
ElMessage.success('已还原')
loadFiles()
} catch (e) {
ElMessage.error('还原失败')
}
}
const handleDeletePermanently = async (file) => {
await ElMessageBox.confirm("确定彻底删除此文件吗?", '警告', { type: 'error' })
try {
await deletePermanently(file.id)
ElMessage.success('已彻底删除')
loadFiles()
refreshStorage()
} catch (e) {
ElMessage.error('删除失败')
}
}
const handleEmptyTrash = async () => {
await ElMessageBox.confirm('确定清空回收站吗?', '警告', { type: 'error' })
try {
await emptyTrash()
ElMessage.success('已清空回收站')
loadFiles()
refreshStorage()
} catch (e) {
ElMessage.error('操作失败')
}
}
const handleShare = (file) => {
currentShareFile.value = file
shareVisible.value = true
}
const handleConfirmShare = async ({ users, permission }) => {
if (!users.length) { ElMessage.warning('请选择共享用户'); return }
try {
for (const userId of users) {
await shareFileApi(currentShareFile.value.id, { userId, permission })
}
ElMessage.success('共享成功')
loadFiles()
} catch (e) {
ElMessage.error('共享失败')
}
}
const handleCancelShare = async (file) => {
await ElMessageBox.confirm("确定取消共享吗?", '提示', { type: 'warning' })
try {
await cancelShare(file.id)
ElMessage.success('已取消共享')
loadFiles()
} catch (e) {
ElMessage.error('操作失败')
}
}
const handleSelectionChange = (selection) => {
selectedFiles.value = selection
}
const clearSelection = () => {
selectedFiles.value = []
}
const handleBatchDownload = async () => {
const filesToDownload = selectedFiles.value.filter(f => f.type !== 'folder')
2026-04-01 23:03:55 +08:00
if (filesToDownload.length === 0 && selectedFiles.value.length > 0) {
2026-04-01 22:39:11 +08:00
ElMessage.warning('请选择要下载的文件(文件夹不支持批量下载)')
return
}
2026-04-01 23:03:55 +08:00
if (selectedFiles.value.length === 0) {
ElMessage.warning('请选择要下载的文件')
return
2026-04-01 22:39:11 +08:00
}
2026-04-01 23:03:55 +08:00
try {
ElMessage.info('正在打包下载...')
const ids = selectedFiles.value.map(f => f.id)
const blob = await batchDownload(ids)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `download_${new Date().getTime()}.zip`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('下载完成')
selectedFiles.value = []
} catch (e) {
ElMessage.error('下载失败')
}
2026-04-01 22:39:11 +08:00
}
const handleBatchMove = async () => {
if (selectedFiles.value.length === 0) {
ElMessage.warning('请选择要移动的文件')
return
}
2026-04-01 23:03:55 +08:00
// 获取可移动的文件夹列表
2026-04-01 22:39:11 +08:00
try {
const selectedIds = selectedFiles.value.map(f => f.id)
2026-04-01 23:03:55 +08:00
const res = await getMovableFolders(selectedIds)
const folders = res.data || []
2026-04-01 22:39:11 +08:00
// 构建选项
const options = [
{ label: '根目录', value: null },
2026-04-01 23:03:55 +08:00
...folders.map(f => ({ label: f.name, value: f.id }))
2026-04-01 22:39:11 +08:00
]
// 使用 ElMessageBox.prompt 的自定义方式
const { value: targetFolderId } = await ElMessageBox.confirm(
'请选择目标文件夹',
'批量移动',
{
confirmButtonText: '移动',
cancelButtonText: '取消',
type: 'info',
distinguishCancelAndClose: true,
message: h => h('div', [
h('p', '请选择目标文件夹:'),
h('select', {
style: 'width: 100%; padding: 8px; border: 1px solid #dcdfe6; border-radius: 4px;',
onChange: (e) => { window.selectedTargetFolder = e.target.value === 'null' ? null : Number(e.target.value) }
}, options.map(opt => h('option', { value: opt.value === null ? 'null' : opt.value }, opt.label)))
])
}
).then(() => window.selectedTargetFolder)
// 执行移动
for (const file of selectedFiles.value) {
try {
await moveFile(file.id, targetFolderId)
} catch (e) {
ElMessage.error(`移动 ${file.name} 失败`)
}
}
ElMessage.success('批量移动完成')
selectedFiles.value = []
loadFiles()
} catch (e) {
if (e !== 'cancel') {
ElMessage.error('移动失败')
}
}
}
const handleRowDblClick = (row) => {
if (row.type === 'folder') {
enterFolder(row)
}
}
onMounted(async () => {
await refreshStorage()
loadFiles()
})
</script>
<style scoped>
.app-layout {
display: flex;
flex: 1;
overflow: hidden;
background: #f5f7fa;
width: 100%;
}
.main-wrapper {
display: flex;
flex: 1;
overflow: hidden;
}
.content-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.batch-toolbar {
display: flex;
align-items: center;
2026-04-01 23:03:55 +08:00
justify-content: flex-start;
gap: 16px;
2026-04-01 22:39:11 +08:00
padding: 12px 16px;
border-bottom: 1px solid #e4e7ed;
2026-04-01 23:03:55 +08:00
background: #fff;
2026-04-01 22:39:11 +08:00
}
.batch-count {
font-size: 14px;
2026-04-01 23:03:55 +08:00
color: #606266;
2026-04-01 22:39:11 +08:00
}
.batch-actions {
display: flex;
gap: 12px;
}
.file-content {
flex: 1;
overflow: auto;
background: #fff;
}
.grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
padding: 24px;
align-content: start;
}
.file-card {
display: flex;
flex-direction: column;
padding: 24px;
border: 1px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background: #fff;
min-height: 180px;
}
.file-card:hover {
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
}
.file-card-main {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex: 1;
}
.file-card-icon {
flex-shrink: 0;
}
.file-card-name {
font-size: 13px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-card-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #909399;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.file-card-actions {
display: flex;
flex-wrap: nowrap;
gap: 6px;
justify-content: center;
}
.file-card-actions .el-button {
font-size: 12px;
}
.file-card-actions .el-button span {
margin-left: 2px;
}
.pagination-wrapper {
padding: 10px 16px;
background: #fff;
border-top: 1px solid #e4e7ed;
display: flex;
justify-content: flex-end;
}
</style>