
做公寓报修管理系统这类项目最怕的就是表设计混乱、报修流程拧巴、前后端联调时各说各话。这次我基于SpringBootVueMyBatisMySQL这套组合从零搭了一套公寓报修管理系统不光是提交工单、派单、完工这一条线连角色权限、状态流转、评价反馈、数据统计都梳理清楚了。无论你是拿来做毕业设计、课程作业还是想在公司内部复用一个简化版报修工单流程这套源码的拆解思路和实操过程都值得参考。说实话公寓报修这个场景特别适合做前后端分离练手因为业务链路清晰但不复杂住客能报修、维修工能接单、管理员能派单和看统计。你不需要设计一个庞大的权限体系也不用引入消息队列把SpringBoot、Vue、MyBatis、MySQL这几个核心组件吃透就够了。下面我会从需求拆解、数据库设计、核心代码实现、环境搭建到实战排坑全部过一遍。1. 项目整体设计与技术选型思路1.1 报修系统到底在解决什么问题很多新手拿到报修管理系统这个题目第一反应就是增删改查。确实任何管理系统的底座都是增删改查但如果只做增删改查这个项目写完就废了答辩或面试一问业务逻辑就露馅。报修系统的核心不是登记一条记录而是工单状态机。一套完整的公寓报修流程是这样的住客提交报修单填写房间号、故障类型、故障描述、期望维修时间可上传照片管理员或客服收到工单后进行审核审核通过后派单给指定维修工维修工接单后上门处理填写维修结果和耗材费用住客确认完工并对服务进行评价。整个过程中工单状态至少要经过待审核、待派单、待接单、维修中、待确认、已完成、已取消这几个节点。所以我把系统角色拆成了三种住客端提交报修、查看进度、确认完工、评价、维修工端接单、填写处理结果、查看个人工作量、管理端用户管理、楼栋房间管理、报修审核、派单、维修记录管理、数据统计。三端共用一套前端代码通过登录角色动态渲染菜单和页面这也是Vue项目里值得重点展开的设计点。1.2 技术栈选型为什么是SpringBootVueMyBatisMySQL这套组合放在2025年依然稳不是因为新而是因为每一个环节都有明确的不可替代性。SpringBoot负责后端骨架。它自动配置了DispatcherServlet、DataSource、事务管理器你只需要一个启动类和一个application.yml就能跑起来不用像SSH时代那样配一堆XML。关键是它内嵌了Tomcat打包成jar就能部署这对公寓物业这种没有专门运维的团队来说太友好了。我要特别提醒一点SpringBoot版本不要无脑追新3.x虽然出了挺久但有些老项目的MyBatis Starter兼容性需要额外处理后面我专门说。Vue负责前端。报修管理系统的页面交互不算复杂但组件复用性很强比如报修单卡片、状态标签、分页表格、统计图表这些用Vue的单文件组件拆开之后代码维护起来很舒服。Vue Router做页面跳转Vuex或Pinia做登录态和用户信息的全局管理Axios统一发请求这套模式已经是行业标配了。我用的Vue 3 Vite编译速度快比Vue 2 Webpack的启动体验好很多。MyBatis负责数据库访问。为什么不用MyBatis-Plus直接省事因为报修系统的SQL里有不少需要手写的地方比如按多条件动态查询工单、联表统计维修工工作量、按月统计报修数量这些用MyBatis的动态SQL写起来最直观。而且面试官最爱问MyBatis你对#{}和${}的区别、缓存机制、动态SQL的底层逻辑熟悉了这个项目做完就相当于背完一轮MyBatis核心面试题。MySQL负责数据存储。5.7和8.0我都用过这套系统用5.7完全够。选MySQL不选Oracle或PostgreSQL纯因为社区资料多、安装排查方便、任何一台Windows电脑都能快速部署。1.3 项目目录结构与代码分层后端我采用经典的四层结构这既是Java Web项目的通行做法也是面试官希望看到的结构repair-server/ ├── src/main/java/com/apartment/repair/ │ ├── controller/ # 控制层接收前端请求 │ ├── service/ # 业务层工单状态流转核心逻辑 │ ├── mapper/ # MyBatis数据访问接口 │ ├── entity/ # 实体类对应数据库表 │ ├── common/ # 通用类统一返回结果、异常处理 │ ├── config/ # WebMvc、拦截器配置 │ └── RepairApplication.java # SpringBoot启动类 ├── src/main/resources/ │ ├── mapper/ # MyBatis XML文件 │ ├── static/ # 静态资源本系统未用到 │ └── application.yml # 数据源、端口、MyBatis配置 └── pom.xml前端用Vite脚手架生成的标准结构重点目录是repair-web/ ├── src/ │ ├── api/ # 前端接口请求封装 │ ├── assets/ # 静态资源图片、全局样式 │ ├── components/ # 公共组件 │ ├── router/ # 路由配置 │ ├── store/ # 全局状态管理 │ ├── views/ # 页面组件 │ ├── App.vue # 根组件 │ ├── main.js # 入口文件 │ └── utils/ # token存取、格式化工具 ├── vite.config.js # Vite配置含代理 └── package.json项目结构不复杂但每一层的职责必须清晰。Controler只做参数接收和结果返回业务逻辑全部下沉到Service这样工单状态流转的代码才能被复用而不是每个Controller里都写一遍判断逻辑。2. 数据库设计与MyBatis核心实现2.1 表结构设计工单表是绝对核心报修管理系统的数据库设计不需要像电商系统那样动辄二三十张表。我最终保留了五张核心表用户表、楼栋房间表、报修类型表、报修工单表、评价表。其中报修工单表是绝对的中心所有查询和统计都围绕它展开。用户表设计CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), phone VARCHAR(20), role TINYINT NOT NULL DEFAULT 0 COMMENT 0-住客 1-维修工 2-管理员, room_id INT COMMENT 住客关联房间ID, status TINYINT DEFAULT 1 COMMENT 1-正常 0-禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );密码字段我建议长度设成100因为存储的是BCrypt加密后的密文不是明文。很多新手在这里踩坑密码字段设成varchar(32)结果接入Spring Security或JBCrypt后密码变长直接报错。报修工单表CREATE TABLE t_repair_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 工单编号规则见下文, user_id INT NOT NULL COMMENT 报修人ID, room_id INT NOT NULL COMMENT 房间ID, repair_type_id INT COMMENT 故障类型ID, description VARCHAR(500) COMMENT 故障描述, image_url VARCHAR(200) COMMENT 报修图片, appointment_time DATETIME COMMENT 期望维修时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待审核 1-待派单 2-待接单 3-维修中 4-待确认 5-已完成 6-已取消, assignee_id INT COMMENT 派单维修工ID, apply_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 报修时间, handle_time DATETIME COMMENT 上门时间, finish_time DATETIME COMMENT 完工时间, handle_result VARCHAR(500) COMMENT 维修结果, material_cost DECIMAL(10,2) DEFAULT 0.00 COMMENT 耗材费用, cancel_reason VARCHAR(200) COMMENT 取消原因 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;工单编号order_no不要用自增ID直接暴露给用户我用的规则是BX 年月日 四位随机数例如BX202504010012。这样既有辨识度又不容易被遍历。为什么要单独建t_repair_type表而不是直接存一个字符串因为故障类型将来要用于统计比如水管漏水占了多少比例、电路故障平均处理时长多少。字典表的好处是统计口径统一也方便前端用下拉框动态加载。2.2 MyBatis动态SQL工单查询的必修课报修工单的查询条件非常多按状态查、按时间范围查、按关键字查房间号、报修人、故障内容、按维修工查前端表格还需要分页。这种多条件组合直接用静态SQL会写到手抽筋MyBatis的动态SQL就是干这个的。核心的查询SQL我放在XML里select idselectOrderPage resultTypecom.apartment.repair.entity.RepairOrder SELECT o.*, u.real_name AS userName, r.room_no AS roomNo, rt.type_name AS repairTypeName FROM t_repair_order o LEFT JOIN t_user u ON o.user_id u.id LEFT JOIN t_room r ON o.room_id r.id LEFT JOIN t_repair_type rt ON o.repair_type_id rt.id where if teststatus ! null AND o.status #{status} /if if testkeyword ! null and keyword ! AND (r.room_no LIKE CONCAT(%, #{keyword}, %) OR u.real_name LIKE CONCAT(%, #{keyword}, %) OR o.description LIKE CONCAT(%, #{keyword}, %)) /if if teststartDate ! null and startDate ! AND o.apply_time gt; #{startDate} /if if testendDate ! null and endDate ! AND o.apply_time lt; #{endDate} /if if testassigneeId ! null AND o.assignee_id #{assigneeId} /if /where ORDER BY o.apply_time DESC /select这里有几个细节值得注意。第一status ! null的判断只判断了Status因为前端可能传0待审核如果写成status ! null and status ! status为0时依然满足条件没问题但如果前端传的是字符串0给一个Integer参数MyBatis会自动转换。第二日期比较用的gt;和lt;在XML里不能直接写大于号小于号必须转义很多新手第一次写就卡在这里报SQL语法错误。第三联表查询必须用LEFT JOIN因为工单表里报修人、房间、故障类型都有可能为空比如入住时直接登记信息不全INNER JOIN会把这些历史数据筛掉。分页我用的PageHelper一行代码搞定物理分页不用手写LIMIT。用法很简单在Service层调用查询方法前加一句PageHelper.startPage(pageNum, pageSize)查询后返回的List会被自动包装成PageInfo里面带了total、pages等分页字段前端直接从PageInfo里取值渲染。2.3 MyBatis缓存机制报修系统里二级缓存要慎重既然热词里专门提到了MyBatis缓存这里必须多说几句。MyBatis的一级缓存是SqlSession级别的同一个SqlSession内连续查询同一条SQL第二次走缓存不查数据库。但在SpringBoot中每次Mapper调用都会重新创建和关闭SqlSession所以一级缓存基本上只在一次事务内的多次查询有效跨请求基本用不到。二级缓存是Mapper级别的多个SqlSession共享同一个Mapper的二级缓存。听起来很美好但在报修管理系统这类实时性要求较高的业务场景里二级缓存必须慎用。举个例子住客刚提交了报修工单管理员正在查询待审核列表如果二级缓存还保留着五秒前的旧数据管理员看不到新工单就会误以为没人报修。默认情况下MyBatis的二级缓存也是不开启的我没在项目里启用它这恰恰是大多数业务系统的正确选择。我在这个项目里真正做的缓存优化是报修类型字典数据用Spring Cache Caffeine。故障类型这类数据几乎不变但前端每次打开报修页面都要拉一次用本地缓存设置30秒过期就够了。配置如下Cacheable(value repairTypeCache, key repair_type_list) public ListRepairType getAllTypes() { return repairTypeMapper.selectAll(); }这样既避免了频繁查MySQL又不会因为缓存时间太长导致新增类型看不到。面试时如果你能把这个场景讲清楚比背十道MyBatis缓存面试题都有说服力。3. 前后端核心环节实操3.1 环境准备JDK、Maven、MySQL、Node.js我按自己实际部署过的顺序来写初学者照着来就行。JDK我用的是JDK 8。虽然JDK 17和21已经普及了但为了兼容老项目和平滑部署8依然是最稳妥的选择。下载安装后配置JAVA_HOME环境变量在命令行执行java -version能看到版本号就成功了。Maven下载二进制zip包解压配置MAVEN_HOME和Path然后修改settings.xml里的本地仓库路径建议设到D盘或非系统盘别用默认的C:/Users/用户名/.m2不然C盘空间告急的时候你会后悔的。镜像用阿里云的国内拉依赖速度差距巨大。MySQLWindows系统建议直接下载MySQL Installer勾选MySQL Server 5.7.44热词都搜到了说明很多人卡在这安装时选默认utf8mb4字符集。安装完成后用Navicat或命令行执行show variables like character_set_database;确认编码没有变成latin1。MySQL 8.x也可以但连接驱动和SpringBoot Starter的版本要配套我给这套源码用的是5.7最省心。Node.jsVue 3 Vite需要Node.js 16以上的版本我用的18.x LTS。安装后执行node -v确认版本。Vue项目依赖安装用npm但如果遇到网络问题卡在node-sass或Electron这种二进制包改用cnpm镜像能省大量时间。3.2 创建SpringBoot后端从pom.xml到第一个接口后端项目的入口是pom.xml核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.25/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency /dependencies为什么SpringBoot版本选2.7.18而不是最新的3.x这就是热搜词里springboot版本太高的坑。SpringBoot 3.x强制要求JDK 17MyBatis的mybatis-spring-boot-starter必须要用2.3.x以上的适配版本很多老旧教程还是基于2.x写的照着敲那些反射、拦截器代码会直接编译报错。如果你不是非用3.x不可2.7.18是当前生态兼容性最稳的版本。application.yml配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/apartment_repair?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 servlet: multipart: max-file-size: 5MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.apartment.repair.entity configuration: map-underscore-to-camel-case: true pagehelper: helper-dialect: mysql reasonable: truemap-underscore-to-camel-case设为true是必须的不然数据库里的apply_time字段映射不到Java实体里的applyTime属性查出来的数据全是null。我第一次写项目就卡在这个地方前端表格死活不显示数据后台日志又没有报错最后发现是这个配置漏了。编写第一个接口以登录为例RestController RequestMapping(/api/auth) public class AuthController { Autowired private UserService userService; PostMapping(/login) public ResultUserVO login(RequestBody LoginDTO loginDTO) { UserVO user userService.login(loginDTO); return Result.success(user); } }注意Controller只接收DTO返回给前端的是VO实体类Entity不直接裸露给前端。登录成功后我会生成一个JWT Token存到前端的localStorage里后续请求带上这个Token后端通过拦截器校验实现登录状态管理。这是前后端分离项目最基本的鉴权方案。3.3 创建Vue前端Vite脚手架到路由守卫前端我用的Vue 3 Vite Vue Router Pinia Axios Element Plus。初始化项目npm create vitelatest repair-web -- --template vue cd repair-web npm install npm install axios vue-router4 pinia element-plus npm run devVite初始化项目速度快但有两个点必须手工配置。一个是路径别名在vite.config.js里添加import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : path.resolve(__dirname, src) } }, server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这个代理配置特别关键。开发环境前端跑在3000端口后端跑在8080端口如果不做代理前端请求http://localhost:8080/api/xxx必然会遇到跨域问题。配置了代理之后前端只需要请求/api/xxxVite会自动转发到后端。我用的是/api前缀统一代理所有后端接口的访问路径都带/api前缀这样代理规则清晰、容易维护。路由的配置直接体现三种角色的权限控制思路。我用的是动态路由路由守卫方案// router/index.js const routes [ { path: /login, component: () import(/views/Login.vue) }, { path: /, component: () import(/layout/Home.vue), meta: { requiresAuth: true }, children: [ { path: user, component: () import(/views/admin/UserManage.vue), meta: { roles: [2] } }, { path: order, component: () import(/views/admin/OrderManage.vue), meta: { roles: [2] } }, { path: myOrder, component: () import(/views/tenant/MyOrder.vue), meta: { roles: [0] } }, { path: workerTask, component: () import(/views/worker/WorkerTask.vue), meta: { roles: [1] } } ] } ] router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.roles !to.meta.roles.includes(role)) { next(/login) // 无权限跳回登录 } else { next() } })这个路由守卫是前端权限控制的第一道门登录之后把用户的角色role存到localStorage每次跳转时检查目标路由的meta.roles是否包含当前角色。管理员访问住客页面会被拦下住客访问管理页面也会被拦下。当然前端路由守卫只是体验层面的限制真正的安全校验必须以后端接口为准我在后端拦截器里也做了同样的角色校验双重保险。3.4 后端工单状态流转用Service层统一处理报修工单的状态流转是业务核心我用统一的状态更新方法避免每个Controller各自写一套逻辑导致流转混乱Service public class RepairOrderServiceImpl implements RepairOrderService { Override public Result? updateStatus(OrderStatusDTO dto) { RepairOrder order repairOrderMapper.selectById(dto.getOrderId()); if (order null) { return Result.error(工单不存在); } return switchOrderStatus(order, dto.getTargetStatus(), dto); } private Result? switchOrderStatus(RepairOrder order, int targetStatus, OrderStatusDTO dto) { int current order.getStatus(); switch (targetStatus) { case 1: // 待派单原状态必须为待审核 if (current ! 0) { return Result.error(当前状态不可派单); } order.setStatus(1); break; case 2: // 待接单原状态为待派单且必须指定维修工 if (current ! 1) { return Result.error(当前状态不可接单); } order.setAssigneeId(dto.getAssigneeId()); order.setStatus(2); break; case 3: // 维修中 if (current ! 2) { return Result.error(当前状态不可开始维修); } order.setStatus(3); order.setHandleTime(new Date()); break; case 4: // 待确认 if (current ! 3) { return Result.error(当前状态不可提交完工); } order.setStatus(4); order.setHandleResult(dto.getHandleResult()); order.setMaterialCost(dto.getMaterialCost()); order.setFinishTime(new Date()); break; case 5: // 已完成 if (current ! 4) { return Result.error(当前状态不可确认完成); } order.setStatus(5); break; default: return Result.error(非法状态); } repairOrderMapper.update(order); return Result.success(); } }这个设计叫状态机模式。每个状态转换都有前置条件不满足条件就直接返回错误提示。比如住客还没确认完工管理员强行把工单改成已完成就会被拦截。实际项目中靠这种硬编码的状态判断比让前端自己传状态校验要可靠得多。前端传过来的targetStatus后端必须反查数据库里的当前状态不能信任前端传的任何状态值。另外switch语法我用了Java 7之后的String或int分支代码里用break防止穿透每个case里先判断当前状态再执行转换这就是工单流转不出错的关键。4. 常见问题与排查技巧实录4.1 SpringBoot版本与依赖冲突类问题问题现象项目启动时报Failed to configure a DataSource: url attribute is not specified。原因SpringBoot自动配置机制认为你需要配置数据源但找不到连接信息。要么是你的application.yml配置没生效要么是pom.xml少了数据源依赖后启动时没有Datasource但自动配置还在找。排查方法先检查resources目录下的application.yml是否存在且被正确加载然后检查pom.xml里是否引入了mysql-connector-java最后看启动类上方有没有SpringBootApplication注解。问题现象SpringBoot 3.x项目导入2.x的MyBatis整合代码报ClassNotFoundException或NoSuchMethodError。原因SpringBoot 3.x基于Jakarta EE规范包名从javax.*改成jakarta.*MyBatis Starter也需要3.x版本。老教程里的javax.servlet.http.HttpServletRequest在新项目里直接编译失败。处理方案确定自己的JDK环境JDK 8就老老实实SpringBoot 2.7.xJDK 17以上才考虑SpringBoot 3.x。不要在JDK 8环境下强行升级SpringBoot也不要拿老项目的代码直接往SpringBoot 3.x里灌。4.2 MyBatis映射与SQL问题问题现象查询出的实体属性全是null但数据库里有值。原因大概率是map-underscore-to-camel-case没开数据库字段apply_time映射不到Java属性applyTime。处理方案在application.yml里打开驼峰映射或者写resultMap手动指定字段对应关系。对于简单的查询用mybatis.configuration.map-underscore-to-camel-casetrue最省事。问题现象XML里的SQL报错Cause: java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax。原因第一类是if标签里写了#{keyword}但前端没传导致NULL拼接出错第二类是XML中用了大于号小于号没转义第三类是符号被XML解析器当成标签开头了。处理方案XML中大于号写成gt;小于号写成lt;小于等于写成lt;大于等于写成gt;。动态SQL里的临界条件判断建议数值型参数用! null字符串型参数用! null and ! 双条件。4.3 MySQL安装与连接问题问题现象Windows安装MySQL 5.7.44后用Navicat连接报2059 - Authentication plugin caching_sha2_password。原因MySQL 8.0默认使用caching_sha2_password认证插件5.7的客户端驱动不兼容。如果你装的是8.0需要在配置里把默认认证插件改回mysql_native_password。处理方案连接前执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码;然后在URL参数里加上allowPublicKeyRetrievaltrue。问题现象数据库连接时报socket connection timeout。原因MySQL服务没启动或者防火墙拦了3306端口。处理方案Windows下打开服务管理器找到MySQL57服务确认状态是正在运行。如果启动失败检查my.ini配置文件里的basedir和datadir路径是否指向正确。4.4 前端Vue安装、路由与跨域问题问题现象npm install报Found incompatible module卡在网络下载。处理方案主要发生在node-sass这类二进制依赖上用npm config set registry https://registry.npmmirror.com切换镜像或者直接用cnpm。Vue 3项目用Vite后不太会遇到node-sass但Element Plus这类组件的依赖也可能卡切镜像通常能解决。问题现象访问http://localhost:3000页面白屏控制台报Failed to load tsconfig vue/tsconfig/tsconfig.web.json凡是项目用了TypeScript模板时。原因tsconfig里面引用的vue/tsconfig包没安装完整通常是npm安装时被中断或者包版本冲突。处理方案npm uninstall vue/tsconfig npm install vue/tsconfiglatest然后npm run dev重新启动。问题现象前端页面能打开但接口请求报404。原因请求路径不对或者代理配置没生效。我先确认前端请求URL是不是/api/xxx格式然后看vite.config.js里proxy的target路径是不是http://localhost:8080。排查方法浏览器打开F12 Network看请求的完整URL如果还是3000开头的/api/xxx说明代理没起作用重启Vite开发服务器即可。如果请求已经是8080开头的/api/xxx但返回404就去后端看Controller里配的URL是不是没有/api前缀。4.5 联调与数据一致性问题问题现象报修单提交成功但列表页刷新后数据显示不全。原因报修工单联查了用户表和房间表如果房间信息填得不对LEFT JOIN后room_no为空前端渲染就缺了一块。处理方案前端提交报修时房号直接从登录用户关联的房间信息里带出来不要手动填。后台管理员新建住客账号时也必须关联房间保证数据完整。问题现象管理员修改工单状态为“已完成”但住客端看到的还是“待确认”。原因前后端状态码枚举不一致。后端定义的6个状态码前端的展示映射表也要同步。我在前端用一个statusMap统一管理避免硬编码数字到处写const statusMap { 0: { text: 待审核, type: warning }, 1: { text: 待派单, type: warning }, 2: { text: 待接单, type: info }, 3: { text: 维修中, type: primary }, 4: { text: 待确认, type: warning }, 5: { text: 已完成, type: success }, 6: { text: 已取消, type: info } }一旦发现状态对不上一定是前后端有一边的数字写错了优先检查后端Service里switchOrderStatus的分支和前端statusMap的键值。5. 前端页面与核心交互实现5.1 报修单提交页面住客端最核心的页面就是报修单提交。我用的是Element Plus的Form组件校验规则写在data里。关键交互点是选择故障类型时动态加载图片上传以及期望维修时间不能选过去时间。el-form :modelform :rulesrules refrepairForm label-width80px el-form-item label房间号 proproomNo el-input v-modelform.roomNo disabled / /el-form-item el-form-item label故障类型 proprepairTypeId el-select v-modelform.repairTypeId placeholder请选择故障类型 el-option v-foritem in typeList :keyitem.id :labelitem.typeName :valueitem.id / /el-select /el-form-item el-form-item label期望时间 propappointmentTime el-date-picker v-modelform.appointmentTime typedatetime :picker-optionspickerOptions placeholder选择期望维修时间 / /el-form-item /el-form这里有个很重要的交互细节pickerOptions里设置了disabledDate把今天之前的时间全部禁用避免住客选了一个过去的时间导致管理员派单时工单的期望时间已经在过去影响排序和统计。表单校验规则里故障描述必填且不超过200字期望时间必选这样后台的工单数据质量才有保障。5.2 工单状态的按钮级权限控制报修工单列表是所有人都能看到的但按钮不一样。住客能做的操作是提交报修、取消、确认完工维修工能做的操作是接单、提交完工管理员能做的是审核、派单、强制取消。我在表格里是这样控制按钮显隐的function getActions(row) { const role localStorage.getItem(role) const actions [] if (role 2) { if (row.status 0) actions.push({ label: 审核通过, action: approve }) if (row.status 1) actions.push({ label: 派单, action: assign }) if (row.status ! 5 row.status ! 6) actions.push({ label: 取消, action: cancel }) } else if (role 0) { if (row.status 0 || row.status 4) actions.push({ label: 取消, action: cancel }) if (row.status 4) actions.push({ label: 确认完工, action: confirm }) } else if (role 1) { if (row.status 2) actions.push({ label: 接单, action: accept }) if (row.status 3) actions.push({ label: 提交完工, action: finish }) } return actions }每个操作按钮点击时调用对应的后端接口后端再校验状态机和权限双重保险。前端按钮显隐是给用户体验的后端接口权限校验才是安全底线。比如住客自己发一个确认完工的请求把工单ID传过去如果后端没有校验当前登录用户是不是这个工单的报修人数据就会被篡改所以后端Service里还要加一行判断确认当前用户ID等于工单的user_id才能操作。5.3 数据统计与图表展示管理端首页需要展示待处理工单数、本月报修总数、维修完成率、平均处理时长等核心指标。指标计算我放在后端SQL里聚合而不是前端遍历整个列表去算否则数据量大时页面会卡死。月度报修趋势的SQLselect idselectMonthlyCount resultTypemap SELECT DATE_FORMAT(apply_time, %Y-%m) AS month, COUNT(*) AS count FROM t_repair_order WHERE apply_time gt; DATE_SUB(CURDATE(), INTERVAL 6 MONTH) GROUP BY DATE_FORMAT(apply_time, %Y-%m) ORDER BY month /select前端用ECharts把后端传回来的month和count渲染成柱状图。ECharts在Vue里的用法不复杂先npm install echarts在组件里import * as echarts from echarts然后初始化图表实例。注意组件卸载时要调用echarts.dispose销毁实例否则页面频繁切换时会内存泄漏。6. 项目部署与源码交接中的经验6.1 打包与发布前端打包npm run build打包产物在dist目录里面是纯静态文件。后端打包mvn clean package -DskipTests在target目录生成repair-server-1.0.0.jar。部署时我习惯用Nginx托管前端静态文件并把/api请求反向代理到Java服务server { listen 80; server_name your-domain.com; location / { root /opt/repair-web/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意try_files这行必须加上否则Vue的history路由在刷新页面时会报404。因为前端路由是前端自己解析的后端Nginx不认识/order/123这种路径所以要把所有请求先导回index.html再让Vue Router根据路径渲染对应页面。后端启动命令nohup java -jar repair-server-1.0.0.jar --spring.profiles.activeprod app.log 21 生产环境我单独建一个application-prod.yml里面用环境变量注入数据库密码不把明文密码写进命令行历史里。密码用env方式读spring: datasource: password: ${DB_PASSWORD}启动前先在环境变量里设置DB_PASSWORD这样源码泄露也不会同时泄露数据库密码。6.2 项目源码怎么安全交给甲方或老师热搜词里有vue项目源码怎么发给别人我多说一句。交源码时不要直接发整个压缩包里面带着node_modules目录会有几百MB接收方解压又慢又容易失败。正确做法是删除node_modules和target目录把工程文件压缩成zip接收方拿到后自行npm install和mvn clean package即可。同时附带一份README文档写清楚JDK版本、Node版本、MySQL版本、数据库初始化脚本路径、启动步骤、默认账号密码。这份README决定了对方拿到源码后是半小时跑起来还是折腾一整天。6.3 再多说几句实操心得根据我自己在多个项目里的实操经验这套公寓报修管理系统做完之后我最有成就感的部分不是功能多么齐全而是逻辑闭环。从报修到派单到完工到评价每个环节都有明确的前置条件没有一处流程是可以绕过去的。你如果想把这个项目扩展成毕业设计展示或面试简历亮点可以在现有基础上加一个“维修工时统计”模块或者把评价表拆出来单独做评分分析改动成本不大但展示深度会立刻不一样。最后分享一个小技巧开发时前后端联调一定要第一时间把接口返回结构统一。我的所有接口都走ResultT对象里面有code、message、data三个字段。前端Axios响应拦截里统一判断code 200才走成功回调否则统一弹出错误提示。这样就算后端某个接口报错前端也不会出现一堆杂乱无章的报错弹窗排查问题的时候看每一条接口响应怎么回事心里有数。这个习惯一旦养成你后面做其他前后端分离项目都会顺手很多。