简介:本资源是一套面向高校计算机专业本科生的Java毕业设计完整实践方案,聚焦电子资源管理系统的开发与交付,适用于课程设计、毕设开题与中期开发参考。系统采用SSM(Spring+SpringMVC+MyBatis)后端架构与Vue前端框架,实现首页展示、用户管理、电子资源全生命周期管理(含列表、日志、收藏)、个人中心及系统管理等核心模块,覆盖典型Web应用开发全流程。压缩包共446个文件,包含112个Java业务逻辑与DAO层代码、45个Vue组件(含预览中可见的IndexMain、BreadCrumbs等布局与功能组件)、24个JS交互脚本、20个XML配置文件,以及SQL建表语句、批处理脚本(bat)、样式文件与数据库备份等,结构完整、开箱即用,总大小12.81MB。目前已有175人学习下载,提供从环境搭建(JDK1.8/Tomcat7+/MySQL5.7+)、源码编译、数据库初始化到前后端联调的完整支撑材料,含说明文档、论文(LW)、答辩PPT,显著降低毕设实施门槛。
1. 为什么一个「SSM + Vue 的电子资源管理系统」毕业设计,能帮你稳过答辩、拿下高分、甚至反向收割面试官?
不是所有 Java 毕业设计都叫「电子资源管理系统」。它不堆砌炫技功能,但每一块都踩在高校毕设评审的硬指标上:有完整前后端分离架构(SSM 后端 + Vue 前端)、真实业务闭环(资源上传→分类管理→权限控制→在线预览/下载)、可运行可演示的本地环境、带注释的源码、逻辑清晰的说明文档(LW)、结构严谨的答辩 PPT——这五件套,是导师一眼认出“这学生真干了活”的黄金组合。
它不是玩具项目。你能在系统里上传 PDF、PPT、MP4、ZIP 等常见教学资源,按学院/课程/年级打标签,设置教师可上传、学生仅查看、管理员全权限;Vue 前端用 Element UI 实现响应式列表与搜索,SSM 后端用 MyBatis 处理多表关联(用户-角色-资源-日志),Spring MVC 做 REST 接口,Spring AOP 统一日志埋点。这些不是“为了用而用”,而是高校课程设计中真实存在的数据流与权限边界。
适合三类人:
- 大四学生:没实习、没项目经验,急需一个“能跑、能讲、能改、能交”的压舱石项目;
- 转行自学者:想用一个完整系统串联 Java Web 全栈链路,拒绝碎片化教程;
- Java 面试冲刺者:把这套系统吃透,能当场画出请求流程图、说出 MyBatis 一级缓存失效场景、解释 Vue Router 路由守卫怎么拦截未登录访问——比背十道“冒泡排序Java”实在得多。
这不是“抄完就扔”的压缩包,而是一份可延展的技术脚手架:你加个全文检索(集成 Elasticsearch),就是课程资料库升级;接入 CAS 单点登录,就能对接学校统一身份认证;把资源预览从 iframe 嵌入 PDF 改成 PDF.js 渲染,立刻体现工程细节把控力。下面,我们就从零开始,把它真正跑起来、调明白、改得动。
2. 用 SSM 搭建后端服务:从数据库建模到 REST 接口暴露,一步不跳过
2.1 数据库设计:为什么这 5 张表就覆盖了电子资源管理的核心业务?
毕业设计最怕“表建得太多却用不上”。本系统精简为 5 张核心表,全部满足第三范式,且字段命名直白、无歧义,方便你答辩时快速讲清关系:
| 表名 | 主要字段 | 业务含义 | 关键约束 |
|---|---|---|---|
sys_user | id,username,password,real_name,role_id,status | 用户基础信息,含角色绑定 | role_id外键 →sys_role.id |
sys_role | id,role_name,description | 角色定义(如:admin / teacher / student) | — |
resource_info | id,title,file_path,file_size,file_type,upload_time,uploader_id,category_id,download_count | 资源元数据,含物理路径与统计 | uploader_id→sys_user.id;category_id→resource_category.id |
resource_category | id,category_name,parent_id,level | 多级分类(如:计算机/Java/框架/SSM) | parent_id自关联,支持树形展开 |
sys_log | id,user_id,operation,method,params,ip,create_time | 操作日志,用于答辩时展示“系统可观测性” | user_id可为空(匿名下载) |
提示:
file_path存的是相对路径(如/upload/2024/06/abc.pdf),实际文件存在src/main/resources/static/upload/下,避免绝对路径导致部署失败。这是很多同学打包后“资源打不开”的第一坑。
建库 SQL 直接执行(MySQL 5.7+):
-- 创建数据库(字符集必须 utf8mb4,否则中文文件名乱码) CREATE DATABASE IF NOT EXISTS erms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE erms; -- 用户表 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(30), role_id INT NOT NULL, status TINYINT DEFAULT 1 COMMENT '1:启用,0:禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 分类表(支持两级分类,parent_id=0为一级) CREATE TABLE resource_category ( id INT PRIMARY KEY AUTO_INCREMENT, category_name VARCHAR(50) NOT NULL, parent_id INT DEFAULT 0, level TINYINT DEFAULT 1 COMMENT '1:一级,2:二级', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 资源主表 CREATE TABLE resource_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, file_path VARCHAR(300) NOT NULL, file_size BIGINT NOT NULL COMMENT '单位:字节', file_type VARCHAR(50) NOT NULL COMMENT '如 application/pdf', upload_time DATETIME DEFAULT CURRENT_TIMESTAMP, uploader_id BIGINT NOT NULL, category_id INT NOT NULL, download_count INT DEFAULT 0, description TEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;2.2 SSM 三层结构落地:Controller 层怎么写才不被导师问“为什么不用 @RestController”?
SSM 不是三个框架简单拼凑,而是有明确职责边界的协作链。我们以「资源列表查询」为例,拆解每一层的真实写法与设计意图:
① Mapper 接口(DAO 层)——只定义,不实现
// com.erms.mapper.ResourceInfoMapper.java public interface ResourceInfoMapper { // 根据分类ID和关键词分页查资源(MyBatis 动态SQL核心) List<ResourceInfo> selectByCategoryAndKeyword(@Param("categoryId") Integer categoryId, @Param("keyword") String keyword, @Param("offset") Integer offset, @Param("limit") Integer limit); // 统计总数(配合分页) int countByCategoryAndKeyword(@Param("categoryId") Integer categoryId, @Param("keyword") String keyword); }说明:
@Param注解必不可少!否则 MyBatis 无法识别多个参数,报Parameter 'xxx' not found。这是新手高频翻车点。不要省略,哪怕只传一个参数也建议加上,保持风格统一。
② Service 接口与实现(业务逻辑层)——做判断、加事务、调多个Mapper
// com.erms.service.ResourceService.java public interface ResourceService { PageResult<ResourceInfo> listResources(Integer categoryId, String keyword, Integer pageNum, Integer pageSize); } // com.erms.service.impl.ResourceServiceImpl.java @Service public class ResourceServiceImpl implements ResourceService { @Autowired private ResourceInfoMapper resourceInfoMapper; @Override @Transactional(readOnly = true) // 明确声明只读,提升数据库性能 public PageResult<ResourceInfo> listResources(Integer categoryId, String keyword, Integer pageNum, Integer pageSize) { // 1. 参数校验(毕业设计必须体现健壮性) if (pageNum == null || pageNum < 1) pageNum = 1; if (pageSize == null || pageSize < 10) pageSize = 10; if (pageSize > 100) pageSize = 100; // 防止恶意 limit 1000000 // 2. 计算分页偏移量(避免前端传错 offset) int offset = (pageNum - 1) * pageSize; // 3. 查询数据 List<ResourceInfo> list = resourceInfoMapper.selectByCategoryAndKeyword(categoryId, keyword, offset, pageSize); int total = resourceInfoMapper.countByCategoryAndKeyword(categoryId, keyword); // 4. 封装分页结果(自定义 PageResult 类,比 PageHelper 更透明) return new PageResult<>(list, total, pageNum, pageSize); } }说明:
PageResult是一个 POJO 类,含List<T> list,long total,int pageNum,int pageSize,int pages字段。不要直接返回 PageHelper 的 Page 对象——导师会质疑“你懂分页原理吗?还是只会套壳?” 自己封装,答辩时能说清offset = (pageNum-1)*pageSize的推导过程。
③ Controller 层(表现层)——接收 HTTP 请求,返回 JSON
// com.erms.controller.ResourceController.java @RestController @RequestMapping("/api/resource") public class ResourceController { @Autowired private ResourceService resourceService; /** * 获取资源列表(GET /api/resource/list?categoryId=1&keyword=java&pageNum=1&pageSize=10) * 注意:这里用 @RequestParam 显式接收,比 @RequestBody 更符合 RESTful 语义 */ @GetMapping("/list") public Result<PageResult<ResourceInfo>> listResources( @RequestParam(required = false) Integer categoryId, @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { PageResult<ResourceInfo> pageResult = resourceService.listResources(categoryId, keyword, pageNum, pageSize); return Result.success(pageResult); // Result 是统一封装的响应体,含 code/msg/data } }说明:
@RestController是@Controller + @ResponseBody的组合注解,必须用它,而不是@Controller。否则返回对象不会自动转 JSON,前端收不到数据——这是本地调试时“接口返回空白”的头号原因。Result.success()返回格式为{"code":200,"msg":"success","data":{...}},Vue 前端可直接解构response.data。
3. Vue 前端工程搭建与核心页面实现:从环境配置到在线预览 PDF
3.1 Vue 2.6 + Element UI 环境初始化:为什么不用 Vue 3?因为毕业设计要“稳”
当前主流是 Vue 3,但本系统采用 Vue 2.6.14 + Element UI 2.15.14 组合,理由很务实:
- 兼容性好:Element UI 官方对 Vue 2 的文档、示例、社区答疑最全,遇到问题搜“element ui vue2 upload”立刻出答案;
- 学习成本低:Vue 2 的 Options API(data/methods/computed)比 Vue 3 的 Composition API 更易理解,答辩时画流程图更直观;
- 生态成熟:
vue-router 3.x、vuex 3.x、axios全部稳定,不会出现“npm install 后一堆 peer dependency warning”。
初始化命令(Node.js ≥ 14):
# 1. 全局安装 vue-cli(如果未安装) npm install -g @vue/cli@4.5.15 # 2. 创建 Vue 2 项目(关键:指定 --preset vue2) vue create erms-frontend --preset vue2 # 3. 进入项目,安装 Element UI 和 axios cd erms-frontend npm install element-ui@2.15.14 axios@0.21.4 # 4. 在 main.js 中全局引入 Element UI // src/main.js import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' Vue.use(ElementUI) // 必须放在 new Vue() 之前 new Vue({ router, store, render: h => h(App) }).$mount('#app')3.2 资源列表页(ResourceList.vue):如何用 Element Table 实现带搜索、分页、状态标签的表格?
这是系统最核心的页面,也是答辩演示必播片段。代码需体现“真实业务感”,而非纯技术 Demo:
<!-- src/views/resource/ResourceList.vue --> <template> <div class="resource-list"> <!-- 搜索区域 --> <el-card class="box-card" shadow="never"> <div slot="header" class="clearfix"> <span>资源检索</span> </div> <el-form :inline="true" :model="searchForm" size="small"> <el-form-item label="分类"> <el-select v-model="searchForm.categoryId" placeholder="全部分类" clearable> <el-option v-for="item in categoryOptions" :key="item.id" :label="item.categoryName" :value="item.id"> </el-option> </el-select> </el-form-item> <el-form-item label="关键词"> <el-input v-model="searchForm.keyword" placeholder="输入标题或描述" clearable></el-input> </el-form-item> <el-form-item> <el-button type="primary" @click="handleSearch">搜索</el-button> <el-button @click="resetSearch">重置</el-button> </el-form-item> </el-form> </el-card> <!-- 数据表格 --> <el-card class="box-card" style="margin-top: 15px;"> <div slot="header" class="clearfix"> <span>资源列表</span> <el-button style="float: right; padding: 3px 0" type="text" @click="refreshList">刷新</el-button> </div> <el-table :data="tableData" stripe border style="width: 100%"> <el-table-column prop="title" label="资源标题" width="300"></el-table-column> <el-table-column prop="categoryName" label="所属分类" width="180"></el-table-column> <el-table-column prop="uploaderName" label="上传人" width="120"></el-table-column> <el-table-column prop="fileSize" label="大小" width="120"> <template slot-scope="{row}"> {{ formatFileSize(row.fileSize) }} </template> </el-table-column> <el-table-column prop="uploadTime" label="上传时间" width="160"></el-table-column> <el-table-column prop="downloadCount" label="下载次数" width="100"></el-table-column> <el-table-column label="操作" width="180"> <template slot-scope="{row}"> <!-- PDF/DOCX/PPTX 文件支持在线预览 --> <el-button v-if="['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'].includes(row.fileType)" size="mini" type="primary" @click="handlePreview(row)"> 预览 </el-button> <!-- 其他文件提供下载 --> <el-button v-else size="mini" type="success" @click="handleDownload(row)"> 下载 </el-button> <!-- 管理员/教师可编辑 --> <el-button v-if="isEditable" size="mini" type="info" @click="handleEdit(row)"> 编辑 </el-button> </template> </el-table-column> </el-table> <!-- 分页器 --> <div class="pagination-container"> <el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="currentPage" :page-sizes="[10, 20, 50, 100]" :page-size="pageSize" layout="total, sizes, prev, pager, next, jumper" :total="total"> </el-pagination> </div> </el-card> </div> </template> <script> import { getResourceList } from '@/api/resource' import { getCategoryList } from '@/api/category' import { formatFileSize } from '@/utils/file' export default { name: 'ResourceList', data() { return { searchForm: { categoryId: null, keyword: '' }, tableData: [], categoryOptions: [], // 分类下拉选项 currentPage: 1, pageSize: 10, total: 0, isEditable: false // 根据登录用户角色动态控制 } }, created() { this.loadCategories() this.loadResources() this.checkPermission() }, methods: { // 加载分类列表 async loadCategories() { try { const res = await getCategoryList() this.categoryOptions = res.data } catch (err) { this.$message.error('加载分类失败:' + err.message) } }, // 加载资源列表 async loadResources() { try { const params = { categoryId: this.searchForm.categoryId, keyword: this.searchForm.keyword, pageNum: this.currentPage, pageSize: this.pageSize } const res = await getResourceList(params) this.tableData = res.data.list this.total = res.data.total } catch (err) { this.$message.error('加载资源失败:' + err.message) } }, // 检查当前用户是否具备编辑权限(简化版:登录后从 localStorage 读角色) checkPermission() { const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}') this.isEditable = ['admin', 'teacher'].includes(userInfo.role) }, // 搜索 handleSearch() { this.currentPage = 1 this.loadResources() }, resetSearch() { this.searchForm = { categoryId: null, keyword: '' } this.handleSearch() }, // 分页相关 handleSizeChange(val) { this.pageSize = val this.currentPage = 1 this.loadResources() }, handleCurrentChange(val) { this.currentPage = val this.loadResources() }, // 预览PDF等文件(关键:用 iframe 嵌入后端静态资源路径) handlePreview(row) { const url = `/static${row.filePath}` // 注意:后端 Spring Boot 默认 static 目录映射为 /static window.open(url, '_blank') }, // 下载文件 handleDownload(row) { const link = document.createElement('a') link.href = `/static${row.filePath}` link.download = row.title + this.getFileExt(row.fileType) document.body.appendChild(link) link.click() document.body.removeChild(link) }, getFileExt(fileType) { const extMap = { 'application/pdf': '.pdf', 'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', 'application/vnd.ms-powerpoint': '.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx', 'application/zip': '.zip', 'application/x-rar-compressed': '.rar' } return extMap[fileType] || '.bin' }, refreshList() { this.loadResources() } }, filters: { formatFileSize } } </script>说明:
handlePreview方法直接window.open('/static/xxx.pdf'),依赖 Spring Boot 的静态资源默认配置(spring.resources.static-locations=classpath:/static/)。不要用 axios 下载再 blob URL 预览——增加复杂度,且 PDF.js 需额外集成;毕业设计阶段,iframe 嵌入最稳妥。getFileExt是根据file_type(MIME Type)映射文件扩展名,解决后端未保存原始文件名的问题。这是真实业务中常见的“元数据不全”场景,体现你考虑了数据完整性。- 所有 API 调用都封装在
@/api/目录下,如getResourceList,内部使用axios并统一处理 baseURL 和 token(若需登录态)。
4. 前后端联调与部署:从本地启动到 jar 包运行,绕开 90% 的环境坑
4.1 启动顺序与端口规划:为什么必须先启后端再启前端?
这是一个极易被忽视的“启动时序陷阱”。很多同学npm run serve启动 Vue,发现接口 404,第一反应是“后端没写好”,其实是跨域和路径没配对。
标准启动流程(务必严格遵守):
启动后端(Spring Boot):
cd erms-backend mvn spring-boot:run # 默认端口 8080,访问 http://localhost:8080/api/resource/list 应返回 JSON配置前端代理(开发时解决跨域):
在erms-frontend/vue.config.js中添加:module.exports = { devServer: { port: 8081, // 前端开发端口,避开后端 8080 proxy: { '/api': { target: 'http://localhost:8080', // 代理到后端 changeOrigin: true, pathRewrite: { '^/api': '/api' // 保持前缀不变 } } } } }说明:这样
axios.get('/api/resource/list')实际请求http://localhost:8080/api/resource/list,浏览器地址栏仍是http://localhost:8081,无跨域报错。切勿在 axios baseURL 写死http://localhost:8080——上线后要改,且本地开发时 Chrome 会报 CORS。启动前端(Vue):
cd erms-frontend npm run serve # 访问 http://localhost:8081,此时所有接口走代理,数据正常渲染
4.2 打包部署:如何生成可运行的 jar 包,并让 Vue 静态资源被 Spring Boot 正确服务?
毕业设计交付物要求“双可运行”:既能在 IDEA 里调试,也能打包成独立 jar 运行。关键在两点:
- 后端打包:
mvn clean package -Dmaven.test.skip=true生成target/erms-backend-1.0.jar; - 前端构建:
npm run build生成dist/目录,将dist/下所有文件(含index.html,js/,css/)复制到erms-backend/src/main/resources/static/下。
为什么放
static/?因为 Spring Boot 默认将classpath:/static/映射为根路径/。这样http://localhost:8080/就能访问 Vue 的index.html,http://localhost:8080/js/app.xxx.js能正确加载。
严禁把dist/整个目录扔进static/!必须把dist/内容(index.html,js/,css/,img/)平铺到static/下,否则路径错乱。
验证 jar 包是否成功:
# 运行 jar(确保 8080 端口空闲) java -jar target/erms-backend-1.0.jar # 浏览器访问 # http://localhost:8080/ → Vue 前端首页 # http://localhost:8080/api/resource/list → 后端接口 # http://localhost:8080/static/upload/test.pdf → 上传的 PDF 文件(若已上传)5. 避坑指南:那些让答辩前夜崩溃的 5 个真实问题,及血泪解决方案
5.1 现象:前端页面空白,控制台报Failed to load resource: the server responded with a status of 404 ()
原因:
- 最常见:
vue.config.js代理配置错误,比如target写成http://localhost:8080/(末尾斜杠导致路径拼接异常); - 或
axios请求路径写成/api//resource/list(双斜杠); - 或后端 Controller 的
@RequestMapping("/api/resource")和前端axios.get('/api/resource/list')前缀不一致。
解决:
- 打开浏览器开发者工具 → Network 标签页 → 点击一个失败请求 → 看Headers中的
Request URL是什么; - 如果是
http://localhost:8081/api/resource/list,说明代理没生效,检查vue.config.js; - 如果是
http://localhost:8080/api/resource/list但返回 404,说明后端没启动或路径写错,用curl http://localhost:8080/api/resource/list直接测试后端。
5.2 现象:上传文件后,数据库file_path字段存的是/upload/2024/06/xxx.pdf,但访问http://localhost:8080/static/upload/2024/06/xxx.pdf报 404
原因:
- Spring Boot 默认静态资源路径是
classpath:/static/、classpath:/public/、classpath:/resources/、classpath:/META-INF/resources/,不包含upload/目录; - 你把文件物理存到了
src/main/resources/static/upload/,但 Spring Boot 启动时只扫描static/下的启动时已存在的文件,运行时创建的子目录(如upload/2024/06/)不会被自动映射。
解决:
- 方案一(推荐):在
application.yml中显式配置上传目录为静态资源路径:
然后把文件上传到项目根目录下的spring: resources: static-locations: classpath:/static/,file:./upload/upload/文件夹(./upload/2024/06/xxx.pdf),这样http://localhost:8080/upload/2024/06/xxx.pdf就能访问。 - 方案二:用
@Controller写一个文件下载接口,通过ResponseEntity<Resource>返回FileSystemResource,绕过静态资源限制。但毕业设计没必要,增加复杂度。
5.3 现象:登录后,前端localStorage存了userInfo,但刷新页面就丢失登录态,菜单栏变回游客视图
原因:
- Vue Router 的路由守卫(
router.beforeEach)里,只检查了localStorage.getItem('token'),但没检查userInfo是否有效; - 或
userInfo存的是字符串,取出来没JSON.parse就直接用,导致userInfo.role是undefined。
解决:
- 在
router/index.js的全局前置守卫中,必须解析并校验:router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const userInfoStr = localStorage.getItem('userInfo') const userInfo = userInfoStr ? JSON.parse(userInfoStr) : null if (to.meta.requiresAuth && (!token || !userInfo)) { next('/login') } else { next() } }) - 登录成功后,
localStorage.setItem('userInfo', JSON.stringify(res.data.userInfo)),确保存的是字符串。
5.4 现象:MyBatis 查询返回null,但数据库明明有数据;或#{}取值为空,${}才有值
原因:
#{}是预编译占位符(安全),${}是字符串拼接(危险);- 当你用
@Param("categoryId")传参,但 XML 中写<if test="categoryId != null and categoryId != ''">,而categoryId是Integer类型,!= ''永远为false(类型不匹配),导致条件未生效,SQL 变成WHERE 1=1,查出所有数据或空数据。
解决:
- XML 中判空只用
!= null:<if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%')) </if> - 永远不要对
Integer用!= '',对String用!= null即可(MyBatis 3.4+ 会自动处理空字符串)。
5.5 现象:答辩现场演示时,点击“预览 PDF”弹出新窗口,但显示 “Failed to load PDF document”
原因:
- PDF 文件本身损坏(上传时网络中断);
- 或 MIME Type 错误:后端存的
file_type是text/plain,但实际是 PDF,浏览器拒绝渲染; - 或路径含中文:
/static/upload/实验报告.pdf,某些浏览器对 URL 中中文编码处理不一致。
解决:
- 上传时用
MultipartFile.getContentType()获取真实 MIME Type,不要依赖文件扩展名; - 文件名存储用 UUID 重命名(如
a1b2c3d4.pdf),file_path存重命名后的路径,title字段存原始文件名; - 预览时用
window.open('/static/' + row.filePath),确保路径纯英文、无空格、无特殊符号。
6. 答辩加分技巧:3 个让导师眼前一亮的“小而深”改造点
6.1 给资源列表加“热度排序”:用下载次数 + 时间衰减,让优质资源浮出水面
毕业设计常被诟病“功能平庸”。一个简单的排序优化,就能体现你对业务的理解深度。不改后端 SQL,只在 Controller 层加一行:
// ResourceController.java @GetMapping("/list") public Result<PageResult<ResourceInfo>> listResources(...) { PageResult<ResourceInfo> pageResult = resourceService.listResources(...); // ✅ 新增:按热度排序(下载数 × 权重,权重 = 1 / (天数 + 1)) List<ResourceInfo> sortedList = pageResult.getList().stream() .sorted((a, b) -> { long now = System.currentTimeMillis(); long daysA = (now - a.getUploadTime().getTime()) / (1000 * 60 * 60 * 24) + 1; long daysB = (now - b.getUploadTime().getTime()) / (1000 * 60 * 60 * 24) + 1; double heatA = a.getDownloadCount() * 1.0 / daysA; double heatB = b.getDownloadCount() * 1.0 / daysB; return Double.compare(heatB, heatA); // 降序 }) .collect(Collectors.toList()); pageResult.setList(sortedList); return Result.success(pageResult); }说明:这个公式叫“时间衰减热度”,意思是:一个 100 次下载的 1 天前资源,热度 = 100/2 = 50;一个 200 次下载的 10 天前资源,热度 = 200/11 ≈ 18.18。它让新上传的优质资源有机会曝光,比单纯按
download_count DESC更合理。答辩时说一句:“我参考了知乎热榜的排序逻辑”,导师会点头。
6.2 Vue 前端加“防抖搜索”:避免用户每敲一个字就发请求
当前handleSearch是手动点击触发,但你可以升级为输入框实时搜索(加防抖)。修改ResourceList.vue:
<el-input v-model="searchForm.keyword" placeholder="输入标题或描述" clearable @input="debouncedSearch"> <!-- 改为 @input 触发 --> </el-input>// 在 methods 下新增 methods: { // 防抖函数(使用 Lodash,或自己写) debouncedSearch: _.debounce(function() { this.currentPage = 1 this.loadResources() }, 500), // 500ms 内连续输入,只执行最后一次 }说明:
_.debounce需要npm install lodash。效果是:用户输入“java ssm”,不会发 3 次请求(j / ja / jav / java ...),而是在停顿 500ms 后发一次keyword=java ssm。这体现你关注用户体验和服务器负载,不是“能跑就行”。
6.3 用 Logback 实现操作日志入库 + 查看:把“系统有日志”变成“日志能查、能筛、能导出”
很多系统只打印System.out.println,答辩时被问“日志在哪看?”就卡壳。我们用 Logback + AOP,把关键操作(上传、下载、删除)自动记入sys_log表:
① 定义自定义注解
// com.erms.annotation.LogOperation.java @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LogOperation { String value() default ""; // 操作描述,如 "上传资源" }② 编写 AOP 切面
// com.erms.aspect.LogAspect.java @Aspect @Component public class LogAspect { @Autowired private SysLogMapper sysLogMapper; @Around("@annotation(logOperation)") public Object recordLog(ProceedingJoinPoint joinPoint, LogOperation <p> <a href="https://download.csdn.net/download/sinat_26552841/87739999" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>