云文件系统初始化
This commit is contained in:
30
web-vue/src/views/desktop/index.vue
Normal file
30
web-vue/src/views/desktop/index.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="desktop-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<TopNavbar />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TopNavbar from '@/components/TopNavbar.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
728
web-vue/src/views/files/index.vue
Normal file
728
web-vue/src/views/files/index.vue
Normal file
@@ -0,0 +1,728 @@
|
||||
<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"
|
||||
/>
|
||||
|
||||
<!-- 批量操作工具栏 -->
|
||||
<div v-if="selectedFiles.length > 0 && activeMenu === 'my-files'" class="batch-toolbar">
|
||||
<span class="batch-count">已选择 {{ selectedFiles.length }} 项</span>
|
||||
<div class="batch-actions">
|
||||
<el-button type="primary" @click="handleBatchDownload" :disabled="!hasSelectedFiles">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span style="margin-left: 4px">批量下载</span>
|
||||
</el-button>
|
||||
<el-button type="success" @click="handleBatchMove" :disabled="!hasSelectedFiles">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span style="margin-left: 4px">批量移动</span>
|
||||
</el-button>
|
||||
<el-button @click="clearSelection">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span style="margin-left: 4px">取消选择</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</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,
|
||||
shareFileApi, cancelShare, getFilePreview, renameFile, moveFile
|
||||
} 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')
|
||||
if (filesToDownload.length === 0) {
|
||||
ElMessage.warning('请选择要下载的文件(文件夹不支持批量下载)')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info(`开始下载 ${filesToDownload.length} 个文件...`)
|
||||
|
||||
for (const file of filesToDownload) {
|
||||
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)
|
||||
// 添加小延迟避免浏览器阻塞
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
} catch (e) {
|
||||
ElMessage.error(`下载 ${file.name} 失败`)
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success('批量下载完成')
|
||||
selectedFiles.value = []
|
||||
}
|
||||
|
||||
const handleBatchMove = async () => {
|
||||
if (selectedFiles.value.length === 0) {
|
||||
ElMessage.warning('请选择要移动的文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户的文件夹列表
|
||||
try {
|
||||
const res = await getFiles({ folderId: null })
|
||||
const allFiles = res.data || []
|
||||
const folders = allFiles.filter(f => f.type === 'folder' && f.isFolder === 1)
|
||||
|
||||
// 过滤掉选中的文件夹本身(不能移动到自己)
|
||||
const selectedIds = selectedFiles.value.map(f => f.id)
|
||||
const availableFolders = folders.filter(f => !selectedIds.includes(f.id))
|
||||
|
||||
// 构建选项
|
||||
const options = [
|
||||
{ label: '根目录', value: null },
|
||||
...availableFolders.map(f => ({ label: f.name, value: f.id }))
|
||||
]
|
||||
|
||||
// 使用 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;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #f0f9ff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.batch-count {
|
||||
font-size: 14px;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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>
|
||||
46
web-vue/src/views/login/LoginBg.svg
Normal file
46
web-vue/src/views/login/LoginBg.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- 云朵 -->
|
||||
<g fill="#fff" opacity="0.08">
|
||||
<ellipse cx="150" cy="100" rx="80" ry="40" />
|
||||
<ellipse cx="200" cy="120" rx="60" ry="35" />
|
||||
<ellipse cx="100" cy="120" rx="60" ry="35" />
|
||||
<ellipse cx="650" cy="180" rx="100" ry="50" />
|
||||
<ellipse cx="720" cy="200" rx="70" ry="40" />
|
||||
<ellipse cx="580" cy="200" rx="70" ry="40" />
|
||||
</g>
|
||||
<!-- 文件夹图标 -->
|
||||
<g transform="translate(300, 200)" opacity="0.15">
|
||||
<rect x="0" y="20" width="200" height="140" rx="10" fill="#fff"/>
|
||||
<rect x="0" y="0" width="80" height="40" rx="8" fill="#fff"/>
|
||||
<rect x="20" y="60" width="160" height="80" rx="6" fill="#fff" opacity="0.5"/>
|
||||
</g>
|
||||
<!-- 文件图标 -->
|
||||
<g transform="translate(150, 350)" opacity="0.1">
|
||||
<rect x="0" y="0" width="100" height="130" rx="8" fill="#fff"/>
|
||||
<polygon points="70,0 100,30 70,30" fill="#667eea"/>
|
||||
<rect x="15" y="50" width="70" height="8" rx="4" fill="#667eea" opacity="0.5"/>
|
||||
<rect x="15" y="70" width="50" height="8" rx="4" fill="#667eea" opacity="0.5"/>
|
||||
<rect x="15" y="90" width="60" height="8" rx="4" fill="#667eea" opacity="0.5"/>
|
||||
</g>
|
||||
<g transform="translate(550, 380)" opacity="0.1">
|
||||
<rect x="0" y="0" width="100" height="130" rx="8" fill="#fff"/>
|
||||
<polygon points="70,0 100,30 70,30" fill="#764ba2"/>
|
||||
<rect x="15" y="50" width="70" height="8" rx="4" fill="#764ba2" opacity="0.5"/>
|
||||
<rect x="15" y="70" width="50" height="8" rx="4" fill="#764ba2" opacity="0.5"/>
|
||||
<rect x="15" y="90" width="60" height="8" rx="4" fill="#764ba2" opacity="0.5"/>
|
||||
</g>
|
||||
<!-- 连接线 -->
|
||||
<g stroke="#fff" stroke-width="2" opacity="0.1" fill="none">
|
||||
<path d="M250 270 Q400 350 550 380" />
|
||||
<path d="M200 415 Q375 350 550 380" />
|
||||
</g>
|
||||
<!-- 装饰圆点 -->
|
||||
<g fill="#fff" opacity="0.15">
|
||||
<circle cx="80" cy="250" r="6"/>
|
||||
<circle cx="120" cy="280" r="4"/>
|
||||
<circle cx="700" cy="300" r="5"/>
|
||||
<circle cx="740" cy="260" r="3"/>
|
||||
<circle cx="400" cy="500" r="8"/>
|
||||
<circle cx="450" cy="520" r="5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
78
web-vue/src/views/login/LoginForm.vue
Normal file
78
web-vue/src/views/login/LoginForm.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入账号"
|
||||
clearable
|
||||
>
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
>
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
native-type="submit"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon><Right /></el-icon>
|
||||
<span style="margin-left: 4px">登录</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, Right } from '@element-plus/icons-vue'
|
||||
import { login } from '@/api/auth'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({ username: '', password: '' })
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid || loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login(form)
|
||||
// res.data = { token, user }
|
||||
emit('success', res.data)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '账号或密码错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-tips {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
</style>
|
||||
115
web-vue/src/views/login/RegisterForm.vue
Normal file
115
web-vue/src/views/login/RegisterForm.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleRegister">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入账号(3-20个字符)"
|
||||
clearable
|
||||
>
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="nickname">
|
||||
<el-input
|
||||
v-model="form.nickname"
|
||||
placeholder="请输入昵称"
|
||||
clearable
|
||||
>
|
||||
<template #prefix><el-icon><UserFilled /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
>
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请确认密码"
|
||||
show-password
|
||||
>
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
native-type="submit"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
<span style="margin-left: 4px">注册</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, UserFilled, Lock, Check } from '@element-plus/icons-vue'
|
||||
import { register } from '@/api/auth'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
nickname: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const validateConfirmPassword = (rule, value, callback) => {
|
||||
if (value !== form.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '账号长度 3-20 个字符', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度 6-20 个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await register({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
nickname: form.nickname || form.username
|
||||
})
|
||||
ElMessage.success('注册成功,请登录')
|
||||
emit('success')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '注册失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
254
web-vue/src/views/login/index.vue
Normal file
254
web-vue/src/views/login/index.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 左侧 60% -->
|
||||
<div class="login-left">
|
||||
<!-- SVG 背景图案 -->
|
||||
<div class="svg-bg">
|
||||
<img src="./LoginBg.svg" alt="" />
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="left-content">
|
||||
<div class="logo-wrapper">
|
||||
<el-icon :size="80" color="#fff"><FolderOpened /></el-icon>
|
||||
</div>
|
||||
<h1 class="left-title">云文件管理系统</h1>
|
||||
<p class="left-desc">安全、高效、便捷的文件存储与共享平台</p>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item">
|
||||
<el-icon><Check /></el-icon>
|
||||
<span>20GB 超大存储空间</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon><Check /></el-icon>
|
||||
<span>文件实时共享协作</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon><Check /></el-icon>
|
||||
<span>多端同步随时访问</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧 40% -->
|
||||
<div class="login-right">
|
||||
<div class="login-card">
|
||||
<!-- 标题 -->
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">{{ isLogin ? '欢迎登录' : '账号注册' }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<LoginForm v-if="isLogin" @success="onLoginSuccess" />
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<RegisterForm v-else @success="onRegisterSuccess" />
|
||||
|
||||
<!-- 底部切换 -->
|
||||
<div class="card-footer">
|
||||
<span>{{ isLogin ? '还没有账号?' : '已有账号?' }}</span>
|
||||
<el-button link type="primary" @click="toggleMode">
|
||||
{{ isLogin ? '立即注册' : '立即登录' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { FolderOpened, Check } from '@element-plus/icons-vue'
|
||||
import LoginForm from './LoginForm.vue'
|
||||
import RegisterForm from './RegisterForm.vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isLogin = ref(true)
|
||||
|
||||
const toggleMode = () => {
|
||||
isLogin.value = !isLogin.value
|
||||
}
|
||||
|
||||
const onLoginSuccess = (data) => {
|
||||
userStore.setToken(data.token)
|
||||
userStore.setUser(data.user)
|
||||
router.push('/desktop')
|
||||
}
|
||||
|
||||
const onRegisterSuccess = () => {
|
||||
isLogin.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 左侧 60% */
|
||||
.login-left {
|
||||
width: 60%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* SVG 背景 */
|
||||
.svg-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.svg-bg img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.left-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.left-title {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.left-desc {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 右侧 40% */
|
||||
.login-right {
|
||||
width: 40%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(145deg, #e8f5e9 0%, #c8e6c9 100%);
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-right::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(76, 175, 80, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, rgba(139, 195, 74, 0.08) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 40px 36px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(76, 175, 80, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(76, 175, 80, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #4caf50, #8bc34a);
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #2e7d32;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 14px;
|
||||
color: #689f38;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(76, 175, 80, 0.15);
|
||||
font-size: 14px;
|
||||
color: #558b2f;
|
||||
}
|
||||
</style>
|
||||
73
web-vue/src/views/not-found/index.vue
Normal file
73
web-vue/src/views/not-found/index.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<div class="not-found-content">
|
||||
<el-icon :size="120" color="#c0c4cc"><Warning /></el-icon>
|
||||
<h1 class="title">404</h1>
|
||||
<p class="desc">抱歉,您访问的页面不存在</p>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span style="margin-left: 4px">返回上一页</span>
|
||||
</el-button>
|
||||
<el-button @click="goHome">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span style="margin-left: 4px">回到首页</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Warning, ArrowLeft, HomeFilled } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/desktop/files')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 72px;
|
||||
font-weight: 700;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
143
web-vue/src/views/preview/index.vue
Normal file
143
web-vue/src/views/preview/index.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="preview-page">
|
||||
<div class="preview-header">
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
<span class="file-name">{{ file?.name }}</span>
|
||||
<el-button type="primary" @click="handleDownload">下载</el-button>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<img v-if="isImage" :src="previewUrl" class="preview-image" />
|
||||
<iframe v-else-if="isPdf" :src="previewUrl" class="preview-iframe" />
|
||||
<pre v-else-if="isText" class="preview-text">{{ content }}</pre>
|
||||
<div v-else class="preview-unsupported">
|
||||
<el-icon :size="64"><Document /></el-icon>
|
||||
<p>此文件类型暂不支持预览</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Document } from '@element-plus/icons-vue'
|
||||
import { getFilePreview, downloadFile } from '@/api/file'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const file = ref(null)
|
||||
const content = ref('')
|
||||
|
||||
const previewUrl = computed(() => `/api/files/${route.params.id}/preview?token=${userStore.token}`)
|
||||
|
||||
const isImage = computed(() => {
|
||||
if (!file.value) return false
|
||||
const ext = file.value.name.split('.').pop()?.toLowerCase()
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)
|
||||
})
|
||||
|
||||
const isPdf = computed(() => {
|
||||
if (!file.value) return false
|
||||
return file.value.name.toLowerCase().endsWith('.pdf')
|
||||
})
|
||||
|
||||
const isText = computed(() => {
|
||||
if (!file.value) return false
|
||||
const ext = file.value.name.split('.').pop()?.toLowerCase()
|
||||
return ['txt', 'md', 'json', 'xml', 'log', 'js', 'ts', 'vue', 'java', 'py', 'css', 'html'].includes(ext)
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const res = await downloadFile(route.params.id)
|
||||
const blob = new Blob([res])
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = file.value?.name || 'file'
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
console.error('下载失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getFilePreview(route.params.id)
|
||||
file.value = res.file
|
||||
if (res.content) {
|
||||
content.value = res.content
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载失败', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-page {
|
||||
min-height: 100vh;
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
height: calc(100vh - 100px);
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow: auto;
|
||||
background: #16213e;
|
||||
color: #e2e8f0;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.preview-unsupported {
|
||||
text-align: center;
|
||||
color: #a0aec0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user