SpringBoot+MySQL+Vue实现文件共享系统

一、为什么要做这个系统?

我们每天要传海报、视频、文案,以前靠微信群、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>

完成

六、总结

这个案例可以解决团队三大痛点:

  1. 文件不丢不乱:有目录、有记录
  2. 权限清晰:谁传的、谁下载的,一目了然
  3. 空间可控:不会因为一个人传大文件,影响所有人

如果你团队也有文件管理的烦恼,不妨试试这个方案。

相关推荐
袁煦丞3 分钟前
SimpleMindMap私有部署团队脑力风暴:cpolar内网穿透实验室第401个成功挑战
前端·程序员·远程工作
架构师沉默9 分钟前
Java 开发者别忽略 return!这 11 种写法你写对了吗?
java·后端·架构
li理10 分钟前
鸿蒙 Next 布局开发实战:6 大核心布局组件全解析
前端
EndingCoder11 分钟前
React 19 与 Next.js:利用最新 React 功能
前端·javascript·后端·react.js·前端框架·全栈·next.js
li理13 分钟前
鸿蒙 Next 布局大师课:从像素级控制到多端适配的实战指南
前端
RainbowJie116 分钟前
Gemini CLI 与 MCP 服务器:释放本地工具的强大潜力
java·服务器·spring boot·后端·python·单元测试·maven
前端赵哈哈17 分钟前
Vite 图片压缩的 4 种有效方法
前端·vue.js·vite
Nicholas6824 分钟前
flutter滚动视图之ScrollView源码解析(五)
前端
电商API大数据接口开发Cris26 分钟前
Go 语言并发采集淘宝商品数据:利用 API 实现高性能抓取
前端·数据挖掘·api
ITMan彪叔26 分钟前
Nodejs打包 Webpack 中 __dirname 的正确配置与行为解析
javascript·后端