Spring Boot+Vue实现毕业论文选题系统:从状态机设计到Redis分布式锁实践
1. 项目全景论文选题系统到底在解决什么问题毕业季最让人头大的就是选题流程线下填表、Excel收集、微信私聊确认数据乱、易冲突、审批慢。做“springbootvue毕业论文选题管理系统”本质就是把选题这个业务流搬到线上用一套标准化的流程管住“教师出题、学生选题、教师确认、管理员审核”这几个核心环节。项目非常适合作为Java后端Vue前端的综合实战例子覆盖了权限管理、业务状态流转、数据库设计、文件上传、前后端分离部署等高频技能点面完试或写完毕设都能拿出完整作品。这套系统的核心价值不在页面炫而是在“防冲突、可追踪、流程严格”这三件事上。简单说系统要在同一时间、同一角色操作下保证“一个题目不能被多个学生同时选中”保证“学生选题之后不能随意重复提交”保证“教师能清晰看到谁选了、什么时候选的、最终状态是什么”。从技术实现上这需要后端有严谨的数据校验和事务逻辑前端有顺畅的状态反馈而不是简单堆几张增删改查页面。我这次做的是标准前后端分离架构。后端用Spring Boot提供RESTful API前端用Vue3工程化开发数据库用MySQL存业务数据中间还接了Redis做缓存和分布式锁来防并发选题冲突。整条开发链路从建表、写接口、调页面到打包部署基本模拟了真实企业项目的完整流程。适合对Java和Vue都有基础、但想用真实业务把技术串起来的同学参考也适合作为毕业设计的核心代码框架。整个系统的角色分三类学生端、教师端、管理员端。学生端负责浏览题目、选择题目、查看选题结果和个人信息教师端负责申报题目、审核学生的选题申请、录入课题资料管理员端负责账号管理、题目审核、流程总览、系统参数配置。每类角色进入系统后看到的功能菜单完全不同后端会通过Spring Security JWT统一做登录认证和角色授权前端配合动态路由做页面级控制。2. 技术选型与整体架构设计为什么是Spring Boot Vue而不是其他组合后端选Spring Boot最直接的原因就是生态太成熟社区资料多遇到问题基本都能搜到答案。Spring Boot对嵌入Tomcat、自动装配、约定大于配置的支持让项目从零搭建到跑起来只需要几分钟。重点是它的Spring Security和Spring Data JPA/MyBatis Plus可以无缝配合做登录鉴权和数据库操作非常顺手适合做这种典型的管理系统。数据库层面不用考虑太高深的分库分表单库单表加合理索引就够用。MySQL MyBatis Plus这个组合是主流MyBatis Plus的单表操作几乎不用写SQL复杂查询可以自己写XML代码量少还容易维护。Redis不是必须但对于“选题瞬间并发很高”的场景非常有用比如几千人同时开放选题给同一个题目加分布式锁能有效避免超选。如果只是毕业设计演示可以用ReentrantLock代替但做分布式锁能体现你理解并发场景。前端选Vue3而不是Vue2因为组合式API写起来更清爽逻辑复用也方便。配套组件库我用的是Element Plus界面规范和组件丰富度都很高表单、表格、弹窗、流程提示都比较省心。有人会纠结是不是用React或Vite替代VueCLI这里给个建议如果你是学生或刚接触企业级项目Vue3 Vite Element Plus是当前最稳妥的路线上手快资料全后续找工作简历上也有话可写。整体架构上我采用前后端完全分离的方式。后端只暴露API同一个服务同时服务Web端管理后台和未来可能的移动端前端通过Axios统一处理请求和响应。后端项目分成controller、service、mapper、entity、config几个标准包前端分成views、router、store、api、components几个标准目录将来加功能很好找文件也方便多人协作。使用Nginx作为统一入口同时扮演静态文件服务器和反向代理的角色前端构建出来的dist目录直接托管接口通过 /api 前缀转发到Spring Boot服务。这样部署的时候只需要一台云服务器既能节省成本又能模拟生产环境的真实配置。整体部署结构大致为浏览器 - Nginx前端静态资源 反向代理 - Spring Boot业务API - MySQL / Redis。3. 核心业务模块数据表设计与状态流转3.1 功能需求拆解三种角色要做的事从业务功能上系统可以拆成账号模块、题目模块、选题模块、审核模块、通知模块、统计模块。账号模块管三类角色的基础信息和登录态题目模块管教师申报题目、管理员审核题目选题模块是核心处理学生选择、教师确认、结果记录通知模块负责学生选题通过或被拒绝的站内信统计模块做班级选题人数、题目热度等基础报表。选题流程的状态是整个系统的灵魂。我把它设计成了一条明确的链路题目在申报后先进入待审核状态管理员审核通过后题目变为可选状态学生选择题目后题目变为待教师确认教师同意选题最终变成已通过教师拒绝题目会回到可选状态学生可以重新选择。每个状态只能按顺序流转不许跳转这样日志记录起来才清晰。3.2 数据库建模五张核心表搞定主要业务我用五张核心表来支撑整个系统分别是用户表、角色表、题目表、选题记录表、审核日志表。用户表比较简单字段有id、username、password、real_name、role_type、teacher_id关联教师信息、department_id等。题目表包括id、title、description、teacher_id、category、max_students可选人数上限、selected_count、status、publish_time等字段。选题记录表是核心包含id、student_id、topic_id、status、apply_time、approve_time、reject_reason、unique_key等。这里最值得注意的设计是加了一个唯一约束union(student_id)对学生选定的未完成状态做限制保证同一时间一个学生只能有一条进行中的选题记录。同时在开发时给正常通过的状态留下一个索引避免统计时扫全表。实际上可以建一个业务唯一索引uk_student_active (student_id, status)然后通过代码保证同一学生只能有一条活动记录由于MySQL的物化查询有时候做不到完美的部分索引我在创建表时增加了一个冗余字段is_active用(student_id, is_active)做联合唯一索引这样更稳。CREATE TABLE select_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL, topic_id BIGINT NOT NULL, status TINYINT NOT NULL COMMENT 1-待确认 2-已通过 3-已拒绝 4-已取消, is_active TINYINT DEFAULT 1, apply_time DATETIME NOT NULL, approve_time DATETIME DEFAULT NULL, reject_reason VARCHAR(255), UNIQUE KEY uk_student_active (student_id, is_active), KEY idx_topic_status (topic_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;审核日志表用于追踪每次状态变更。字段包括operation_type、operator_id、target_type、target_id、before_status、after_status、operate_time和remark。这个表比单纯状态字段更可靠出了问题能追溯是谁在什么时候改了什么也方便管理员在后台做审计审查。3.3 状态机设计为什么不让前端随便改状态状态机听起来很高大上其实就是一个能明确“当前状态”和“可执行动作”的规则。我在后端用一个枚举类TopicStatus管理状态码用Map枚举了每个状态允许的转换行为。举例来说题目状态“待审核”只能允许管理员执行“审核通过”或“审核拒绝”学生能操作的只有状态为“可选”的题目正在“待教师确认”的题目学生不能再次点击选择教师也只能执行“确认通过”或“驳回申请”。这样做最大好处是避免业务逻辑散落到前端或者service的各个if判断里。前端只是发起一个动作请求后端基于当前状态做一次校验校验不通过就直接抛业务异常前端拿到错误提示展示给用户。很多新手最容易犯的错就是过于信任前端传来的status字段直接把状态改成前端传的值这是秒级事故现场我的经验是后端永远自己查库拿到最新的状态做判断不信任客户端任何传值。4. 后端落地Spring Boot中的鉴权、事务与文件处理4.1 项目结构参考一个能落地的包划分方案后端项目我习惯按模块分包不一定严格按三层分层但对于这种管理系统按技术层次分更直观。常用的结构是controller、service、mapper、entity、config、common、security、task。controller里只放请求参数校验和结果封装service里写业务逻辑mapper里放SQLcommon里放统一返回类、异常处理类、工具类。security放JWT过滤器、登录处理逻辑config放Redis配置、跨域配置、Minio配置等。写代码前先约定好统一的返回结构我定义了一个Result类包含code、message、data字段。所有接口统一返回Result.success(data)或者Result.fail(code, message)前端根据code判断是否成功。这样做的好处是前端处理逻辑统一不需要每个接口单独做异常判断后端遇到业务异常时通过全局异常处理器统一返回格式。不要小看这个基础约定它是后续所有接口开发的前提。4.2 登录鉴权方案Spring Security JWT Redis登出黑名单登录这块我用Spring Security做认证框架JWT作为令牌载体Redis用来存储令牌黑名单和用户权限缓存。JWT本身是无状态的服务端不需要保存会话记录但这带来一个问题如果用户要主动登出或者管理员封禁用户JWT在过期前依然有效。解决办法是把登出的JWT token的jti唯一标识存到Redis里过期时间设置为JWT剩余有效期每次请求时先检查该jti是否在黑名单中如果在就直接拒绝。这里有个实用经验不要让JWT负载中塞太多东西只放userId、userType、jti这三个核心字段其他信息比如username、角色列表每次请求通过userId从Redis或数据库读。用户信息缓存我在Redis里存一个JSON字符串key是user:info:{userId}有效期设为两小时如果用户修改了资料就主动删除缓存。这么做虽然多了一次Redis读取但换来的是用户数据实时性整体性能已经足够。核心登录代码如下简洁但完整public LoginResult login(LoginRequest req) { UsernamePasswordAuthenticationToken authenticationToken new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword()); Authentication authentication authenticationManager.authenticate(authenticationToken); SecurityContextHolder.getContext().setAuthentication(authentication); LoginUser loginUser (LoginUser) authentication.getPrincipal(); String jti UUID.randomUUID().toString().replace(-, ); String token JwtUtil.createToken(loginUser.getUser().getId(), loginUser.getUser().getRoleType(), jti); redisTemplate.opsForValue().set(user:info: loginUser.getUser().getId(), JSON.toJSONString(loginUser), 2, TimeUnit.HOURS); return new LoginResult(token, loginUser.getUser().getRoleType()); }Spring Security稍微费点劲的是过滤器链配置。/api/open 开头的接口匿名可访问比如登录接口其他接口全部要求认证。另外通过自定义AccessDeniedHandler统一返回403 JSON数据避免默认跳转HTML页面这样前端才能拿到可读的错误信息。我花了两小时才把这个细节打磨好新手很容易在这块踩坑。4.3 选题冲突检测事务与分布式锁的正确用法选题场景最大的技术难点就是并发冲突。举个例子某个题目最多可选5人当第5个人和第6个人同时点击选择时如果代码只是先查selectedCount再update极大概率会出现超选的脏数据。解决办法有两个层次第一层是数据库层面加行锁比如SELECT ... FOR UPDATE锁住题目记录第二层是Redis分布式锁用tryLock锁住这个题目ID让同一时刻只有一个人能进入选择逻辑。我的做法是在后续实现中用Redis分布式锁 数据库唯一索引双重保底。原理是先用RedisLock保证同一个student在同一时刻只能发起一次申请串行化再用is_active唯一索引保证同一个人只能有一条进行中的选题记录在业务处理里再更新题目的selected_count。这个设计测试下来比较稳哪怕极端情况下Redis锁过期了数据库唯一索引仍然会挡住重复提交把脏数据概率降到几乎为零。public void selectTopic(Long studentId, Long topicId) { String lockKey topic:select: topicId; boolean locked redisLock.tryLock(lockKey, 3, 10, TimeUnit.SECONDS); if (!locked) { throw new BusinessException(500, 当前选题人数较多请稍后重试); } try { Topic topic topicMapper.selectByIdForUpdate(topicId); if (topic.getStatus() ! TOPIC_STATUS_OPEN) { throw new BusinessException(500, 题目不在可选状态); } if (topic.getSelectedCount() topic.getMaxStudents()) { throw new BusinessException(500, 题目已选满); } // 创建选题记录并更新字段借助数据库唯一索引兜底 SelectRecord record new SelectRecord(studentId, topicId, STATUS_PENDING, true); selectRecordMapper.insert(record); topicMapper.increaseSelectedCount(topicId); } finally { redisLock.unlock(lockKey); } }事务方面不能只依赖分布式锁还需要把整个过程放在同一个事务里。我使用Transactional(rollbackFor Exception.class)包裹整个核心业务方法。这里有个细节值得记住锁必须在事务外面获取然后在事务内执行业务最后在finally里释放锁。如果把tryLock放在事务内部持有锁的时间会更长而且一旦事务还没提交锁就释放其他线程可能读到旧数据并发问题就回来了。4.4 文件上传接入Minio的踩坑与封装经验论文系统免不了要上传开题报告、任务书、论文初稿和答辩PPT早期我用本机存储把文件直接放在服务器目录迁移和备份都很麻烦。后来我改用Minio这是一个兼容Amazon S3协议的开源对象存储系统部署简单社区热度很高。Spring Boot集成Minio其实不复杂先引入minio依赖然后在配置文件里定义endpoint、accessKey、secretKey和bucketName。建立一个MinioConfig类注册MinioClient的Bean再写一个StorageService封装上传、下载、删除、获取预签名URL的方法。预签名URL特别实用比如给前端返回一个临时下载地址有效期设成5分钟既能避免泄露文件真实存储路径又能控制访问权限。public String upload(byte[] data, String fileName, String contentType) { String objectName UUID.randomUUID().toString().replace(-, ) - fileName; client.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(new ByteArrayInputStream(data), data.length, -1) .contentType(contentType) .build()); return objectName; }Minio部署极简下个二进制包或者直接docker run就能跑起来。要注意几个坑bucket需要提前在控制台创建代码里如果没有创建bucket的逻辑首次上传会直接报错endpoint不要写localhost如果前端是浏览器直接访问Minio域名写127.0.0.1会被浏览器拦截因为Minio控制台地址和API地址可能不同上传文件大小要提前在Spring Boot中设置spring.servlet.multipart.max-file-size否则默认1MB会直接报文件超限。4.5 环境配置Vite代理与后端CORS的取舍前后端分离开发时跨域是每个人都会遇到的问题。我的策略是开发阶段完全靠前端Vite代理绕开跨域就是让前端把 /api 开头的请求转发到后端地址这样浏览器看到的请求是同源的后端不需要做任何CORS配置。生产阶段用Nginx做反向代理也天然规避了跨域问题。这种做法最省心也不会有CORS配置放错导致的安全风险。如果你偏好后端主动开启CORS可以用一个WebMvcConfigurer配置类统一设置allowedOriginPatterns。必须注意不能直接用*匹配所有来源的同时还要携带凭证浏览器会直接拒绝。我之前就因为偷懒写成了*结果带Cookie的登录请求一直失败排查了一下午。建议只把前端的真实地址加进去同时开启allowCredentials(true)这样最安全也最符合实际。5. 前端实现Vue3 Element Plus实战要点5.1 Vue项目初始化从Vite构建到目录约定前端项目我推荐用Vite构建命令npm create vuelatest即可。Vite冷启动速度快、热更新反馈快和Webpack相比体验是质的提升。初始化完成后把目录整理成src/api、src/router、src/store、src/views、src/components、src/utils。api目录按业务模块拆文件比如topic.js、user.js、selectRecord.js每个文件用函数导出对应的接口请求利用封装好的request实例发请求。开发中用到的第三方库不多axios、sass、pinia、vue-router是必须的。图标库直接用Element Plus内置的不需要额外引入图标库减轻包体积。如果需要富文本编辑器推荐wangeditor或者quill要看你前端基础选wangeditor会更亲切。5.2 动态路由不同角色进入系统后看到不同菜单因为系统有三类角色我不希望学生进后台还能看到教师审核菜单所以我使用动态路由的方式登录成功后根据后端返回的角色和权限列表动态添加路由和生成侧边栏菜单。具体思路是router中可以定义基础路由login、home等再定义两个路由map一个是所有学生可见的页面一个是教师可见的页面管理员整合两者再附加用户管理页面。const routeMap { student: [ { path: /topic/list, component: () import(/views/student/TopicList.vue), meta: { title: 选题大厅 } }, { path: /topic/mine, component: () import(/views/student/MyTopic.vue), meta: { title: 我的选题 } } ], teacher: [ { path: /topic/manage, component: () import(/views/teacher/TopicManage.vue), meta: { title: 题目管理 } }, { path: /student/apply, component: () import(/views/teacher/StudentApply.vue), meta: { title: 学生申请审核 } } ] }这样做有两个明显的好处一是菜单和数据不会向无权限用户展示二是有效减小了打包体积。不过要注意在路由守卫里判断当前用户权限和路由的关系如果发现访问了不存在的路由直接重定向到403页面同时防止通过f12手动修改路由拿到敏感数据后端接口权限依旧要做校验。5.3 关键页面组件拆解杜绝重复代码的封装方式我用Element Plus做UI最大的感受是它的表单组件功能完备但同时写法冗长。我封装了一个ProTable组件专门用来做列表页。通过传入columns和api接口自动生成搜索栏、表格、分页和刷新按钮后端只需要返回{ list, total }结构即可。这个组件在题目管理、学生管理、审核日志中都复用到了节省了大量重复代码。弹窗表单我用一个DialogForm组件封装接收schema配置和表单初始值内部完成后端交互。这里的一个重点技巧是表格刷新时机弹窗关闭后再刷新列表而不是每次修改状态就刷新这样才能保证数据一致性且操作流畅。列表页里我会缓存搜索条件用户翻页和点击查询后依然保留之前的条件不会因为等待刷新丢失参数。5.4 接口请求封装统一处理错误、登录态与加载状态axios封装是前端项目的老生常谈但很多人容易忽略细节。我在request.js里设置了baseURL为/api利用拦截器统一把token加到请求头响应拦截器统一判断code字段。如果code等于401说明token过期直接跳转登录页并清空本地缓存代码等于500且message存在就调用UI的Message显示错误提示。service.interceptors.response.use( response { const res response.data if (res.code 0) { return res.data } if (res.code 401) { localStorage.clear() router.push(/login) return Promise.reject(new Error(res.message)) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )加载状态也建议统一管理我写了一个独立的loading变量在store里发起请求时置true请求结束时置false然后在大表格容器上绑定v-loading。比每个页面都自己维护loading状态要省心很多。整套系统跑下来这种封装能占50%的开发效率提升值得花时间做好。6. 部署上线后端打包、前端构建与Nginx配置6.1 后端部署Maven打包与JVM启动参数后端我用Maven管理依赖打包命令是mvn clean package -DskipTests产出的是一个jar包。部署时直接java -jar target/system-0.0.1.jar运行。生产环境不能像本地一样把数据库密码明文写在application.yml里尤其是提交到代码仓库要小心。我一般用Jasypt加密敏感配置或者把密码写到环境变量里Spring Boot用${DB_PASSWORD}占位符读取。如果你用的是阿里云或腾讯云服务器内存控制在1G2G就能跑起来JVM参数我建议-Xms256m -Xmx512m -XX:HeapDumpOnOutOfMemoryError设置堆最小值和最大值相同避免动态扩容导致性能抖动。启动之后记得用curl测试健康检查接口确认服务正常后再切流量。6.2 前端构建环境变量与publicPath设置前端构建命令是npm run build默认输出到dist目录。生产环境的API地址应该和环境变量相关我在.env.production里设置VITE_API_BASE_URL/api这样所有axios请求都走相对路径再由Nginx代理到后端。如果直接把前端构建产物放到后端服务里通过Spring Boot的static目录托管则完全不需要处理跨域也能省一个Nginx但我不推荐这么做原因是一来动静不分二来前端页面更新要重新打包后端后续维护成本高。publicPath默认是根路径如果部署在域名或者子域名下一般没问题。如果你打算部署在https://yourdomain.com/system/这样一个子路径下就需要把Vite的base配置成/system/否则静态资源会全部404。这是不少人部署失败的第一原因输入网址后页面白屏但接口又正常多半是publicPath没调好。6.3 一个能直接抄作业的Nginx配置生产环境我最推荐把前端dist目录复制到服务器例如/var/www/system然后用Nginx做静态服务并代理API。一个最简可用的配置大致如下server { listen 80; server_name yourdomain.com; root /var/www/system; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }这个配置的核心是try_files $uri $uri/ /index.html让vue-router的history模式生效保证访问/topic/manage这类路由时都返回index.html再由前端路由接管渲染。如果用的是hash模式不需要这段重写但URL会带个#号我还是推荐history模式美观也符合实际项目习惯。6.4 HTTPS与性能细节别忽略这些部署附加项部署上线后我一直建议顺手把HTTPS做了可以直接用云厂商的免费证书也可以后续再配置自动续期。Nginx里加上SSL证书路径然后做一个301跳转把80端口请求转到443这样更安全。HTTP/2协议自动就会生效能带来一些性能提升。前端静态资源还需要做缓存策略为index.html设置no-cache因为每次发布内容可能变化不能使用缓存为其他静态文件设置较长缓存时间比如location ~* \.(js|css|png|jpg)$ { expires 7d; }。这样能明显减少用户重复加载的流量和时间。后端API接口层面也可以给一些不常变化的查询接口加上Spring Cache或者Redis缓存比如系统参数查询、院系列表这些减少数据库压力。7. 常见问题与排查技巧实录7.1 速查表五个高频问题的一线解决方案我在做这个项目的过程中整理了以下五个最容易踩的坑。列成表格方便大家对照每一条都是实际解决过的不是凭空想象。问题现象根本原因排查思路解决方案登录接口一直返回403Spring Security默认开启CSRF或未放行登录URL查看Security配置中的permitAll是否覆盖了登录路径显式放行/api/auth/login并关闭csrf前端请求跨域失败后端CORS配置错误或Vite代理未生效打开浏览器Network看失败请求的响应头开发用Vite代理生产用Nginx代理后端关闭CORS选题并发时出现超选查询和更新在事务外且没有锁压测时观察selected_count是否超过max_students使用Redis分布式锁 数据库唯一索引双保险上传大文件报错Spring Boot默认单文件1MB限制查看后端日志中的MultipartException配置multipart.max-file-size 和 max-request-size打包部署后前端白屏publicPath配置错误或路由模式没有回退打开开发者工具看Network中资源请求状态调整base配置Nginx加try_files回退7.2 印象深刻的一次线上故障JWT过期时间与角色更新的纠缠系统跑了一段时间后我发现管理员修改了某个学生的角色但该学生登录后依然显示旧角色操作新功能的入口也消失不了。刚开始我以为前端路由缓存问题清了浏览器缓存仍然复现后来才想到是Redis里存了用户权限信息并且有效期仍是2小时角色虽然改了但Redis缓存没有及时删除。解决办法很简单修改用户角色的接口里主动调用删除缓存的逻辑并给用户发一条“你的权限已更新请重新登录”的站内信。从此之后再也没出现过权限更新不同步的问题。这个故障给我最大的启发是任何缓存只要存在就必须考虑数据一致性。零散地删缓存容易漏最好统一在业务操作保存成功后做一次invokeCacheClear方法。简单项目没有消息队列用同步删除就够了别为了解耦硬上一套MQ徒增复杂度。7.3 另外一个容易忽略的坑Vue动态路由刷新后404动态路由在页面刷新时因为路由被动态添加的在全局守卫里如果没有重新拉取用户权限并再次执行router.addRoute刷新页面就会出现404或空白。这个问题非常经典几乎每个做动态路由的人都会遇到。我的处理方式是每次刷新后在路由守卫里判断store中是否存在用户角色如果不存在就从后端拉取用户信息然后同步调用addRoute生成动态路由再放行。注意addRoute之后要使用next({ ...to, replace: true })重新进入一次路由否则刚添加的路由还不会立即生效。还有一个细节是为了给刷新后的页面定位当前菜单最好把当前路由的path和菜单数据的关键项做匹配。我通常会在动态路由的meta里放一个menuKey然后根据当前路由的meta去侧边栏组件中设置高亮这样比通过path字符串匹配更可靠也避免了父级菜单自动展开逻辑出bug。8. 一些个人的实操心得与最终建议这套系统前后端加起来大概是一个半月的工作量但在写代码前我建议先把数据模型和状态机定义清楚这是整个项目的地基。地基打好了后面的接口开发会非常顺畅地基没打牢中途改表结构会让你怀疑人生。前端部分我最大的经验是不要急着堆页面先封装好统一组件和API请求层。等你做到第三个页面时再回头看第一个页面的代码一定会感慨封装的好处。后端部分则是要先搞定权限框架和统一返回结构这两个基础模块不做好其他功能写起来会很痛苦。如果你是在做毕业设计或者准备求职项目建议不要只停留在“跑起来”的阶段可以把系统部署到云服务器上再用Git管理代码写一份README包含架构图和部署步骤。面试时直接演示线上地址和代码仓库比贴一堆截图有说服力得多。如果你想让这个系统继续深入我建议可以扩展这几个方向第一给管理员加一个简单的数据看板用ECharts展示选题进度和题目分布第二把教师的审核操作接入消息通知除了站内信还能对接邮件第三引入Minio和M3U8视频流能力让学生提交论文答辩视频后可以在线播放不用下载到本地。每个方向涉及的技能都能延伸出很多面试聊资但核心是先把这版做扎实再去追求扩展。