云文件系统初始化

This commit is contained in:
2026-04-01 22:39:11 +08:00
commit 3a20f6e7ed
74 changed files with 8693 additions and 0 deletions

View 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>

View 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>

View 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

View 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>

View 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>

View 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>

View 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>

View 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>