基于SpringBoot+MySQL+Vue实现文件共享系统
作者:刘大华
我们每天要传海报、视频、文案,以前靠微信群、U盘、邮箱来回传,问题一大堆, 于是,我用SpringBoot+MySQL+Vue搞了个文件共享系统,同时加了用户空间限额,本文通过代码示例介绍的非常详细,需要的朋友可以参考下
一、为什么要做这个系统?
我们每天要传海报、视频、文案,以前靠微信群、U盘、邮箱来回传,问题一大堆。 老板找我:“你搞个系统,把文件管起来。” 于是,我用SpringBoot+MySQL+Vue搞了个文件共享系统,同时加了用户空间限额。
二、界面效果
三、数据库表设计
一共三张表,简单清晰。
1. 用户表
CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL COMMENT '用户名', password VARCHAR(100) NOT NULL COMMENT '密码', role VARCHAR(20) DEFAULT 'user' COMMENT '角色: user, designer, admin', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );
2. 用户空间配额表
CREATE TABLE user_quota ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNIQUE NOT NULL COMMENT '用户ID', total_quota BIGINT DEFAULT 5368709120 COMMENT '总空间(字节),默认5G', used_quota BIGINT DEFAULT 0 COMMENT '已用空间(字节)', FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE );
注:5368709120 = 5 * 1024 * 1024 * 1024(5G)
3. 文件信息表
CREATE TABLE file_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, filename VARCHAR(200) NOT NULL COMMENT '原始文件名', path VARCHAR(500) NOT NULL COMMENT '服务器存储路径', file_size BIGINT NOT NULL COMMENT '文件大小(字节)', user_id BIGINT NOT NULL COMMENT '上传者ID', folder_id BIGINT DEFAULT 0 COMMENT '所属文件夹', upload_time DATETIME DEFAULT CURRENT_TIMESTAMP, download_count INT DEFAULT 0 COMMENT '下载次数', remark VARCHAR(200) COMMENT '备注', FOREIGN KEY (user_id) REFERENCES user(id) );
四、后端实现(主要代码流程)
1. 上传文件:先检查空间
@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file, @RequestHeader("UserId") Long userId) { // 1. 获取用户空间配额 UserQuota quota = userQuotaMapper.findByUserId(userId); long fileSize = file.getSize(); // 2. 检查空间是否足够 if (quota.getUsedQuota() + fileSize > quota.getTotalQuota()) { return Result.error("空间不足!当前可用:" + formatSize(quota.getTotalQuota() - quota.getUsedQuota())); } // 3. 保存文件到磁盘 String uploadDir = "D:/uploads/" + userId + "/"; File dir = new File(uploadDir); if (!dir.exists()) dir.mkdirs(); String filePath = uploadDir + file.getOriginalFilename(); File dest = new File(filePath); file.transferTo(dest); // 4. 更新已用空间 quota.setUsedQuota(quota.getUsedQuota() + fileSize); userQuotaMapper.update(quota); // 5. 记录文件信息 FileInfo fileInfo = new FileInfo(); fileInfo.setFilename(file.getOriginalFilename()); fileInfo.setPath(filePath); fileInfo.setFileSize(fileSize); fileInfo.setUserId(userId); fileInfo.setUploadTime(new Date()); fileInfoMapper.insert(fileInfo); return Result.success("上传成功"); }
2. 删除文件:记得退回空间
@DeleteMapping("/delete/{id}") public Result delete(@PathVariable Long id, @RequestHeader("UserId") Long userId) { FileInfo file = fileInfoMapper.findById(id); if (file == null || !file.getUserId().equals(userId)) { return Result.error("文件不存在或无权限"); } // 删除文件 new File(file.getPath()).delete(); // 退回空间 UserQuota quota = userQuotaMapper.findByUserId(userId); quota.setUsedQuota(quota.getUsedQuota() - file.getFileSize()); userQuotaMapper.update(quota); // 删除数据库记录 fileInfoMapper.delete(id); return Result.success("删除成功"); }
3. 工具方法:字节转可读大小
public static String formatSize(long bytes) { if (bytes < 1024) return bytes + " B"; else if (bytes < 1048576) return String.format("%.2f KB", bytes / 1024.0); else if (bytes < 1073741824) return String.format("%.2f MB", bytes / 1048576.0); else return String.format("%.2f GB", bytes / 1073741824.0); }
五、前端实现(Vue3+Element UI)
前端全部代码
<template> <div class="file-system-container"> <!-- 顶部信息栏 --> <div class="top-info-bar"> <div class="user-greeting"> <el-avatar :size="32" :src="userAvatar">{{ userInitial }}</el-avatar> <span class="greeting-text">你好,{{ username }}</span> </div> <div class="space-usage" :class="{ 'warning': spacePercentage > 80, 'danger': spacePercentage > 95 }"> <div class="progress-text"> <span>{{ spacePercentage }}%</span> <span>{{ formatSize(usedSpace) }} / {{ formatSize(totalSpace) }}</span> </div> <el-progress :percentage="spacePercentage" :stroke-width="12" :color="progressColor" /> </div> <div class="action-buttons"> <el-button type="primary" @click="showUploadDialog"> <el-icon><Upload /></el-icon>上传文件 </el-button> <el-button v-if="isAdmin" type="success" @click="showQuotaDialog"> <el-icon><SetUp /></el-icon>管理配额 </el-button> </div> </div> <!-- 主内容区 --> <div class="main-content"> <!-- 左侧文件夹树 --> <div class="folder-tree"> <h3>文件夹</h3> <el-tree :data="folders" :props="defaultProps" @node-click="handleFolderSelect" highlight-current :expand-on-click-node="false" default-expand-all > <template #default="{ node, data }"> <div class="folder-node"> <el-icon><Folder /></el-icon> <span>{{ node.label }}</span> <span class="folder-count">({{ data.fileCount || 0 }})</span> </div> </template> </el-tree> </div> <!-- 右侧文件列表 --> <div class="file-list"> <div class="file-list-header"> <h3>{{ currentFolder.name || '全部文件' }}</h3> <div class="file-search"> <el-input v-model="searchQuery" placeholder="搜索文件..." prefix-icon="Search" clearable /> </div> </div> <el-table :data="filteredFiles" style="width: 100%" v-loading="loading" :empty-text="emptyText" > <el-table-column label="文件名" min-width="240"> <template #default="scope"> <div class="file-name-cell"> <el-icon :size="20" class="file-icon"> <component :is="getFileIcon(scope.row.filename)"/> </el-icon> <span>{{ scope.row.filename }}</span> </div> </template> </el-table-column> <el-table-column prop="uploadTime" label="上传时间" width="180" /> <el-table-column prop="fileSize" label="大小" width="120"> <template #default="scope"> {{ formatSize(scope.row.fileSize) }} </template> </el-table-column> <el-table-column prop="downloadCount" label="下载次数" width="100" /> <el-table-column label="操作" width="200" fixed="right"> <template #default="scope"> <el-button size="small" @click="downloadFile(scope.row)"> <el-icon><Download /></el-icon>下载 </el-button> <el-button size="small" type="danger" @click="confirmDelete(scope.row)"> <el-icon><Delete /></el-icon>删除 </el-button> </template> </el-table-column> </el-table> <div class="pagination-container"> <el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" :total="totalFiles" @size-change="handleSizeChange" @current-change="handleCurrentChange" /> </div> </div> </div> <!-- 上传文件对话框 --> <el-dialog v-model="uploadDialogVisible" title="上传文件" width="500px" > <el-form :model="uploadForm" label-width="80px"> <el-form-item label="文件夹"> <el-select v-model="uploadForm.folderId" placeholder="选择文件夹"> <el-option v-for="folder in flatFolders" :key="folder.id" :label="folder.name" :value="folder.id" /> </el-select> </el-form-item> <el-form-item label="文件"> <el-upload class="upload-demo" drag action="#" :http-request="customUpload" :before-upload="beforeUpload" :file-list="uploadForm.fileList" multiple > <el-icon class="el-icon--upload"><upload-filled /></el-icon> <div class="el-upload__text"> 拖拽文件到此处或 <em>点击上传</em> </div> <template #tip> <div class="el-upload__tip"> 可用空间: {{ formatSize(availableSpace) }} </div> </template> </el-upload> </el-form-item> <el-form-item label="备注"> <el-input v-model="uploadForm.remark" type="textarea" :rows="2" placeholder="可选备注信息" /> </el-form-item> </el-form> <template #footer> <span class="dialog-footer"> <el-button @click="uploadDialogVisible = false">取消</el-button> <el-button type="primary" @click="submitUpload" :loading="uploading"> 上传 </el-button> </span> </template> </el-dialog> <!-- 配额管理对话框 --> <el-dialog v-model="quotaDialogVisible" title="空间配额管理" width="600px" > <el-table :data="userQuotas" style="width: 100%"> <el-table-column prop="username" label="用户名" /> <el-table-column label="已用空间"> <template #default="scope"> {{ formatSize(scope.row.usedQuota) }} </template> </el-table-column> <el-table-column label="总空间"> <template #default="scope"> <el-input-number v-model="scope.row.totalQuotaGB" :min="1" :max="100" size="small" @change="updateQuota(scope.row)" style="width: 90px;" /> GB </template> </el-table-column> <el-table-column label="使用率"> <template #default="scope"> <el-progress :percentage="Math.round((scope.row.usedQuota / (scope.row.totalQuota || 1)) * 100)" :color="getQuotaColor(scope.row.usedQuota, scope.row.totalQuota)" /> </template> </el-table-column> </el-table> </el-dialog> </div> </template> <script setup> import { ref, computed, onMounted, reactive } from 'vue' import { Folder, Document, Picture, VideoPlay, Download, Upload, Delete, Search, SetUp, UploadFilled } from '@element-plus/icons-vue' import { ElMessage, ElMessageBox } from 'element-plus' // 用户信息 const username = ref('小王') const userAvatar = ref('') const userInitial = computed(() => username.value.charAt(0)) const isAdmin = ref(true) // 空间使用情况 const totalSpace = ref(10 * 1024 * 1024 * 1024) // 10GB const usedSpace = ref(9.2 * 1024 * 1024 * 1024) // 9.2GB const availableSpace = computed(() => totalSpace.value - usedSpace.value) const spacePercentage = computed(() => Math.round((usedSpace.value / totalSpace.value) * 100)) const progressColor = computed(() => { if (spacePercentage.value > 95) return '#F56C6C' if (spacePercentage.value > 80) return '#E6A23C' return '#67C23A' }) // 文件夹数据 const folders = ref([ { id: 1, label: '我的文件', fileCount: 12, children: [ { id: 11, label: '设计稿', fileCount: 5 }, { id: 12, label: '文档', fileCount: 7 } ] }, { id: 2, label: '共享文件', fileCount: 8, children: [ { id: 21, label: '项目资料', fileCount: 3 }, { id: 22, label: '薪资', fileCount: 5 } ] } ]) const flatFolders = computed(() => { const result = [] const flatten = (items, prefix = '') => { items.forEach(item => { result.push({ id: item.id, name: prefix + item.label }) if (item.children) { flatten(item.children, prefix + item.label + '/') } }) } flatten(folders.value) return result }) const defaultProps = { children: 'children', label: 'label' } // 当前选中的文件夹 const currentFolder = ref({}) // 文件列表 const files = ref([ { id: 1, filename: '设计方案.docx', uploadTime: '2025-08-20 14:30', fileSize: 2.5 * 1024 * 1024, downloadCount: 5, folderId: 11 }, { id: 2, filename: '产品海报.png', uploadTime: '2025-08-21 09:15', fileSize: 8.7 * 1024 * 1024, downloadCount: 12, folderId: 11 }, { id: 3, filename: '宣传视频.mp4', uploadTime: '2025-08-22 16:45', fileSize: 256 * 1024 * 1024, downloadCount: 8, folderId: 11 }, { id: 4, filename: '会议纪要.pdf', uploadTime: '2025-08-23 11:20', fileSize: 1.2 * 1024 * 1024, downloadCount: 15, folderId: 12 }, { id: 5, filename: '8月工资条.xlsx', uploadTime: '2025-08-15 10:00', fileSize: 0.5 * 1024 * 1024, downloadCount: 25, folderId: 22 } ]) // 分页 const currentPage = ref(1) const pageSize = ref(10) const totalFiles = ref(files.value.length) // 搜索 const searchQuery = ref('') const loading = ref(false) const emptyText = ref('暂无文件') // 过滤后的文件列表 const filteredFiles = computed(() => { let result = files.value // 按文件夹过滤 if (currentFolder.value.id) { result = result.filter(file => file.folderId === currentFolder.value.id) } // 按搜索关键词过滤 if (searchQuery.value) { result = result.filter(file => file.filename.toLowerCase().includes(searchQuery.value.toLowerCase()) ) } return result }) // 上传相关 const uploadDialogVisible = ref(false) const uploading = ref(false) const uploadForm = reactive({ folderId: null, fileList: [], remark: '' }) // 配额管理 const quotaDialogVisible = ref(false) const userQuotas = ref([ { userId: 1, username: '小王', usedQuota: 9.2 * 1024 * 1024 * 1024, totalQuota: 10 * 1024 * 1024 * 1024, totalQuotaGB: 10 }, { userId: 2, username: '小李', usedQuota: 1.2 * 1024 * 1024 * 1024, totalQuota: 5 * 1024 * 1024 * 1024, totalQuotaGB: 5 }, { userId: 3, username: '小张', usedQuota: 3.8 * 1024 * 1024 * 1024, totalQuota: 5 * 1024 * 1024 * 1024, totalQuotaGB: 5 } ]) // 方法 const handleFolderSelect = (data) => { currentFolder.value = { id: data.id, name: data.label } } const handleSizeChange = (val) => { pageSize.value = val } const handleCurrentChange = (val) => { currentPage.value = val } const getFileIcon = (filename) => { const ext = filename.split('.').pop().toLowerCase() if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) { return Picture } else if (['mp4', 'avi', 'mov', 'wmv', 'flv'].includes(ext)) { return VideoPlay } else { return Document } } const formatSize = (bytes) => { if (bytes < 1024) return bytes + ' B' else if (bytes < 1048576) return (bytes / 1024).toFixed(2) + ' KB' else if (bytes < 1073741824) return (bytes / 1048576).toFixed(2) + ' MB' else return (bytes / 1073741824).toFixed(2) + ' GB' } const showUploadDialog = () => { uploadDialogVisible.value = true uploadForm.folderId = currentFolder.value.id || null } const beforeUpload = (file) => { // 检查文件大小是否超过可用空间 if (file.size > availableSpace.value) { ElMessage.error(`文件大小超过可用空间!当前可用:${formatSize(availableSpace.value)}`) return false } return true } const customUpload = ({ file }) => { // 这里只是模拟添加到上传列表,实际项目中会发送到服务器 uploadForm.fileList.push(file) } const submitUpload = () => { if (uploadForm.fileList.length === 0) { ElMessage.warning('请选择要上传的文件') return } uploading.value = true // 模拟上传过程 setTimeout(() => { // 模拟添加文件到列表 const newFiles = uploadForm.fileList.map((file, index) => { return { id: files.value.length + index + 1, filename: file.name, uploadTime: new Date().toLocaleString(), fileSize: file.size, downloadCount: 0, folderId: uploadForm.folderId } }) files.value = [...files.value, ...newFiles] // 更新已用空间 const totalUploadSize = uploadForm.fileList.reduce((sum, file) => sum + file.size, 0) usedSpace.value += totalUploadSize // 重置表单 uploadForm.fileList = [] uploadForm.remark = '' uploadDialogVisible.value = false uploading.value = false ElMessage.success(`成功上传 ${newFiles.length} 个文件`) }, 1500) } const downloadFile = (file) => { // 模拟下载过程 ElMessage.success(`开始下载: ${file.filename}`) // 更新下载次数 const index = files.value.findIndex(f => f.id === file.id) if (index !== -1) { files.value[index].downloadCount++ } } const confirmDelete = (file) => { ElMessageBox.confirm( `确定要删除文件 "${file.filename}" 吗?`, '删除确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' } ).then(() => { // 删除文件 const index = files.value.findIndex(f => f.id === file.id) if (index !== -1) { // 更新可用空间 usedSpace.value -= files.value[index].fileSize // 从列表中移除 files.value.splice(index, 1) ElMessage.success('文件已删除') } }).catch(() => { // 取消删除 }) } const showQuotaDialog = () => { quotaDialogVisible.value = true } const updateQuota = (user) => { // 转换GB到字节 user.totalQuota = user.totalQuotaGB * 1024 * 1024 * 1024 ElMessage.success(`已更新 ${user.username} 的空间配额为 ${user.totalQuotaGB}GB`) } const getQuotaColor = (used, total) => { const percentage = (used / total) * 100 if (percentage > 95) return '#F56C6C' if (percentage > 80) return '#E6A23C' return '#67C23A' } onMounted(() => { // 模拟加载数据 loading.value = true setTimeout(() => { loading.value = false }, 500) }) </script> <style scoped> .file-system-container { display: flex; flex-direction: column; height: 100%; background-color: #f5f7fa; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); padding: 20px; } .top-info-bar { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background-color: #fff; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); } .user-greeting { display: flex; align-items: center; gap: 10px; } .greeting-text { font-size: 16px; font-weight: 500; } .space-usage { flex: 1; margin: 0 30px; max-width: 400px; } .progress-text { display: flex; justify-content: space-between; margin-bottom: 5px; font-size: 14px; color: #606266; } .space-usage.warning :deep(.el-progress-bar__inner) { background-color: #E6A23C; } .space-usage.danger :deep(.el-progress-bar__inner) { background-color: #F56C6C; } .main-content { display: flex; flex: 1; gap: 20px; overflow: hidden; } .folder-tree { width: 250px; background-color: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); overflow-y: auto; } .folder-tree h3 { margin-top: 0; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid #ebeef5; } .folder-node { display: flex; align-items: center; gap: 5px; } .folder-count { font-size: 12px; color: #909399; margin-left: 5px; } .file-list { flex: 1; background-color: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; overflow: hidden; } .file-list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid #ebeef5; } .file-list-header h3 { margin: 0; } .file-search { width: 250px; } .file-name-cell { display: flex; align-items: center; gap: 8px; } .file-icon { color: #909399; } .pagination-container { margin-top: 20px; display: flex; justify-content: flex-end; } :deep(.el-upload-dragger) { width: 100%; } :deep(.el-upload__tip) { color: #67C23A; font-weight: bold; } </style>
完成!
六、总结
这个案例可以解决团队三大痛点:
- 文件不丢不乱:有目录、有记录
- 权限清晰:谁传的、谁下载的,一目了然
- 空间可控:不会因为一个人传大文件,影响所有人
如果你团队也有文件管理的烦恼,不妨试试这个方案。
以上就是基于SpringBoot+MySQL+Vue实现文件共享系统的详细内容,更多关于SpringBoot Vue文件共享的资料请关注脚本之家其它相关文章!