一、为什么要做这个系统?
我们每天要传海报、视频、文案,以前靠微信群、U盘、邮箱来回传,问题一大堆。 老板找我:"你搞个系统,把文件管起来。" 于是,我用SpringBoot+MySQL+Vue 搞了个文件共享系统,同时加了用户空间限额。
二、界面效果



三、数据库表设计
一共三张表,简单清晰。
1. 用户表
sql
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. 用户空间配额表
sql
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. 文件信息表
sql
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. 上传文件:先检查空间
java
@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. 删除文件:记得退回空间
java
@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. 工具方法:字节转可读大小
java
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)
前端全部代码
javascript
<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>
完成
六、总结
这个案例可以解决团队三大痛点:
- 文件不丢不乱:有目录、有记录
- 权限清晰:谁传的、谁下载的,一目了然
- 空间可控:不会因为一个人传大文件,影响所有人
如果你团队也有文件管理的烦恼,不妨试试这个方案。