做装饰工程这一行的朋友应该都有这种体会:项目一多,工地、材料、工人、客户付款全挤在一张Excel表里,每天都在打电话确认进度、翻聊天记录找合同、月底对账对到头疼。我接手过好几个装修公司的管理需求,最后都是同一个结论——必须有一套能把“项目-合同-材料-施工-验收”串起来的管理系统。这套基于SpringBoot+Vue的装饰工程管理系统,就是在这个背景下完整设计和实现的,后端走Java+SpringBoot+MyBatis,数据落MySQL,前端用Vue全家桶,权限用JWT控制,是一个典型的前后端分离项目。这篇文章我会把整套系统的业务拆解、数据库设计、核心接口实现、Vue页面搭建、打包部署和踩坑记录全部写出来,适合正在做毕业设计、想入门前后端分离项目开发,或者想把公司项目管起来的读者参考。
1. 项目概述与业务场景拆解
1.1 装饰工程行业的三个核心痛点
在写系统之前,我先花了大概两周时间泡在几家装饰公司的项目现场,观察他们每天的工作流。装饰工程管理跟一般的企业管理系统有个很大的不同:它的核心对象不是人也不是物资,而是“工地现场”。一个项目从签约到交付,要经历量房、设计、拆改、水电、泥瓦、木工、油漆、安装、验收这么多环节,每个环节又牵扯到材料进场、工人排班、增项变更、客户付款。传统Excel管理模式下,最典型的三个问题:
第一是 进度信息失真 。工地上的实际进度在项目经理脑子里,管理层问起来全靠汇报,往往比实际情况滞后三五天。第二是 材料成本失控 。装修材料的品类巨多,瓷砖、板材、电线、水管、油漆、辅料,每个项目要采购几十次,漏单、重复采购、供货商价格不统一是常事。第三是 付款节点混乱 。装修合同一般约定按工程进度付款,开工付30%、水电验收付30%、竣工验收付35%、尾款5%,但进度节点全靠人工认定,到了付款环节经常扯皮。
这套管理系统在设计之初就锁定了这三个目标:进度可视化、材料精细化、付款节点流程化。围绕这三个目标,再去做模块划分和表结构设计,就不会出现那种“功能一堆但用不起来”的情况。
1.2 系统功能模块与角色权限划分
系统按业务拆成了七个核心模块:项目管理、合同管理、客户管理、材料采购、施工任务、验收结算、统计报表。外加一个系统管理模块负责用户和权限。每个模块在权限上不是平级的,系统里分了四种角色:管理员、项目经理、材料员、财务。
管理员拥有全部权限,能做系统配置和用户分配;项目经理管项目和施工任务,可以创建工地、指派施工队、登记进度、发起验收申请;材料员负责材料采购单的创建、审核进场、核对供货商价格;财务只看合同金额、采购金额和付款记录,做成本核算。这四种角色各自只能看到自己业务范围内的菜单和数据,比如材料员看不到合同毛利率,项目经理改不了付款状态。
这种按角色切功能的权限设计,用前端路由守卫后端拦截器双管齐下实现的,菜单用Vue Router动态生成,接口用SpringBoot拦截器校验JWT里的角色字段。实际开发中很多新人会忽略后端权限,只在前端做菜单隐藏,这是个大坑,因为接口暴露后可以直接被调用,所以后端必须在每个涉及写操作的接口上做角色判断。
1.3 为什么选SpringBoot+Vue这套技术栈
技术选型其实是很多团队纠结的地方,我在这个项目里选择了SpringBoot 2.7 + Vue 2 + Element UI + MySQL 5.7 + MyBatis这套组合,不是因为它最炫,而是因为它最适合这类业务系统的开发节奏。
SpringBoot的起步依赖和自动配置能省掉大量XML配置,一个内嵌Tomcat直接跑jar包,部署到服务器上特别省事。MyBatis作为持久层框架,可以手写SQL控制查询逻辑,装饰工程管理这种业务有大量动态查询条件,比如按项目状态筛选、按时间段统计采购金额,手写SQL的灵活度远高于JPA。MySQL在中小体量业务系统里已经足够,这里最大的表也就是材料采购明细表,单表几十万条数据量级,索引设计合理完全没压力。Vue 2配合Element UI,表格、表单、弹窗、穿梭框这些后台管理页面组件开箱即用,开发速度快。
我后来还在项目里接入了ECharts做统计数据可视化。这块有一个经验:统计图表的SQL不要在前端循环调用接口,而是在后端写一条聚合SQL直接返回分组结果,前端渲染就行,性能差好几倍。
2. 数据库设计与核心表结构
2.1 数据库建模的整体思路
装饰工程管理系统的数据库,花了整个项目大概三分之一的设计时间。表结构不好,后面写接口时全是补丁。我的设计原则是: 核心业务表拆细,字典类数据合并,金额字段一律用DECIMAL,时间字段全部用DATETIME 。
整个库设计了16张表,按业务域分为四组。第一组是系统权限:sys_user、sys_role、sys_user_role;第二组是客户合同:customer、contract;第三组是项目执行:project、project_stage、construction_task、worker、project_progress_log;第四组是材料财务:material、purchase_order、purchase_item、payment_record。另外还有一张sys_dict专门存字典数据,比如项目状态、材料分类、合同类型这些枚举值。
之所以把project_stage单独拆一张表,而不是在project表里建一堆进度字段,是为了让项目的多个阶段可以并行和追溯。一个工地不是严格按照线性流程走的,水电改造的同时可能木工材料已经进场了,阶段表加一个stage_status字段,就能记录每个阶段的独立状态。施工任务表关联项目阶段,材料采购单关联项目,再通过project_id把所有业务串起来。项目的状态流转用一个int类型的current_stage字段记录当前进行到第几个阶段,配合阶段表查询,前端进度条的数据就齐了。
2.2 项目主表与核心关联表详情
project表是整张业务网的枢纽。字段设计上,除了id、project_code、project_name、customer_id、contract_id这些基本字段外,几个关键字段要重点说:
- current_stage :int类型,记录当前阶段编号,从0开始递增,配合字典表映射阶段名称,0=立项、1=拆改、2=水电、3=泥瓦、4=木工、5=油漆、6=安装、7=验收。
- budget_amount :DECIMAL(12,2),项目预算总额,来源于合同金额。
- actual_cost :DECIMAL(12,2),实际成本,由材料采购金额和人工费自动汇总刷新。这里我没有用数据库触发器,而是在业务Service层里写了一个refreshProjectCost()方法,每次采购入库或任务结算后调用一次。
- manager_id :项目经理的用户ID,外键关联sys_user。
- status :tinyint,1进行中、2已停工、3已完工、4已验收。
材料采购这块是两张表,purchase_order主表记单号、项目、材料员、采购日期、总金额、状态,purchase_item子表记单条材料明细的品名、规格、数量、单价、小计。设计购物车式的两表结构,是因为一个采购单通常包含几十种材料,而且需要单独编辑每一条明细。如果没有子表而把材料塞在一个text字段里,后续统计某个材料的累计采购量就完全没法做。
还有个容易被忽略但实际很重要的表是project_progress_log。这个表记录所有项目阶段变更的日志,包括操作人、操作时间、从哪个阶段到哪个阶段、备注。它配合contract表中的payment_plan字段,在财务核对付款条件时,直接查日志来证明“水电阶段已完成”,省去大量扯皮。
2.3 几个容易踩坑的字段设计细节
金额字段一定不要用float或double。 这是MySQL里最经典的坑,浮点数在计算时会有精度丢失,0.1+0.2可能算出0.30000000000000004。合同金额、材料单价、付款金额全部用DECIMAL(12,2),在Java实体类里对应BigDecimal,计算用add/subtract/multiply方法,不要用+和-。
状态字段不要用字符串存中文。 我在初版设计时,项目状态直接存了“进行中”“已完工”,结果后来要扩展“已停工”和“待验收”时,前端下拉框和后端判断全都要改。后来规范为用int存枚举值,显示文案交给字典表或者前端做映射。这是几十张业务表沉淀下来的教训。
逻辑删除统一用deleted字段。 客户和项目信息,删除后可能还需要走审计追溯,所以我所有业务表都加了一个deleted字段,默认0,删除时UPDATE为1。查询列表时MyBatis里默认加一条and deleted=0的条件。实际的DELETE语句在这个系统里几乎不存在。
3. 后端核心实现:SpringBoot + MyBatis
3.1 工程结构与企业级分层
后端的包结构,我习惯按模块分包,而不是单纯按Controller、Service、Mapper纵向分层。也就是说,com.example.decoration下先分customer、contract、project、purchase、task、finance、system这些子包,每个子包里再放controller、service、mapper、entity。这样做的最大好处是,当业务模块多起来后,找代码时不需要在一堆Controller里翻,改动一个模块只需要打开一个目录,清爽很多。
主启动类上的注解只有@SpringBootApplication和@MapperScan("com.example.decoration.**.mapper")。后者要特别注意,如果Mapper接口分散在不同子包里,在启动类上配置好扫描路径,不然会报找不到Bean的错误。这是新手最容易遇到的第一个报错。
配置文件用的是application.yml。数据源配置里,我额外加了这些参数:
spring:
datasource:
url: jdbc:mysql://localhost:3306/decoration?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
这里有两个细节:serverTimezone一定要设成Asia/Shanghai,否则从数据库查出来的时间会比实际时间少8小时;useSSL设为false,本地开发时如果MySQL没有配置SSL证书,不设这一步会报SSL连接警告甚至直接拒绝连接。
3.2 MyBatis多条件动态查询的写法
做管理系统的后端,最考验功底的就是列表查询接口。装饰工程管理系统的项目列表页,搜索条件有项目名称模糊查询、客户名称、项目经理、项目状态、创建时间段,五个条件任意组合。用MyBatis的 标签加 标签就能优雅解决:
<select id="selectProjectList" resultType="com.example.decoration.project.entity.Project">
select p.*, c.customer_name, u.real_name as manager_name
from project p
left join customer c on p.customer_id = c.id and c.deleted = 0
left join sys_user u on p.manager_id = u.id
<where>
p.deleted = 0
<if test="projectName != null and projectName != ''">
and p.project_name like concat('%', #{projectName}, '%')
</if>
<if test="customerName != null and customerName != ''">
and c.customer_name like concat('%', #{customerName}, '%')
</if>
<if test="managerId != null">
and p.manager_id = #{managerId}
</if>
<if test="status != null">
and p.status = #{status}
</if>
<if test="startDate != null">
and p.create_time >= #{startDate}
</if>
<if test="endDate != null">
and p.create_time <= #{endDate}
</if>
</where>
order by p.create_time desc
</select>
注意几个坑:like查询时**concat('%', #{xxx}, '%')**不要直接写'%${xxx}%',后者会有SQL注入风险;时间范围比较时,大于号和小于号在XML里必须转义成>和<,不然XML解析直接报错。所有多表关联字段,写的时候一定带表别名前缀,不然SQL一执行就报字段不明确。
具体到业务代码里,这个查询是配合PageHelper分页插件一起用的,Service里三行:
PageHelper.startPage(pageNum, pageSize);
List<ProjectVO> list = projectMapper.selectProjectList(query);
PageInfo<ProjectVO> pageInfo = new PageInfo<>(list);
return Result.success(pageInfo);
PageHelper的坑在于 startPage必须紧跟第一条查询语句 ,中间不能有任何其他查询操作,否则分页会作用到错误的SQL上。而且它只对紧跟的这条查询生效,所以用完就完,不需要手动清理。
3.3 业务层事务处理与项目状态流转
装饰工程管理系统里事务最典型的使用场景是“材料采购入库”。一个采购单要同时更新三块数据:写purchase_order主表、批量插入purchase_item子表、累加project表的actual_cost。任何一步失败,整个操作都必须回滚,不然就会出现采购单有了但成本没算进去的数据不一致情况。
@Service类上直接加@Transactional(rollbackFor = Exception.class)是最稳的做法。rollbackFor = Exception.class一定要写,因为Spring默认只对RuntimeException回滚,如果业务层抛的是自定义的Exception,不加这个参数事务不会回滚。
项目状态流转的核心逻辑在ProjectServiceImpl里。我把每一段流转拆成独立方法,比如startConstruction()、submitAcceptance()、confirmAcceptance()。以submitAcceptance为例,除了改project表状态,还会插入一条项目进度日志,并且检查当前项目是否满足验收条件——所有stage状态必须是已完成状态。这块我当时也想了很久要不要做成状态机引擎,后来觉得这个项目状态数量有限、流转路径固定,硬编码逻辑更直观,后续要调整规则时改代码更快。
3.4 JWT登录认证与接口鉴权
用户登录成功后,后端生成一个带过期时间的JWT令牌返回给前端。生成过程用了jjwt库,代码简单,但配置里有几个点容易忽略:
String token = Jwts.builder()
.setSubject(String.valueOf(user.getId()))
.claim("username", user.getUsername())
.claim("role", user.getRole())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
密钥secretKey不要硬编码在代码里,我是放在application.yml中通过@Value注入的。过期时间设了12小时,前端在axios的响应拦截器里判断HTTP状态码401,就自动跳转登录页并清除本地存储。
接口鉴权用了SpringBoot的HandlerInterceptor + WebMvcConfigurer注册。自定义的AuthInterceptor里,preHandle方法先判断请求路径是否在白名单内,然后从请求头取Authorization字段,解析JWT,如果解析失败或者过期直接返回401,成功就把userId和role塞进ThreadLocal里供后续Service使用。这里要特别注意拦截器里 放行OPTIONS请求 ,不然前后端分离模式下,浏览器的预检请求会被拦截器挡住,前端会报跨域错误,这个问题排查了我整整一下午。
4. 前端Vue实现与页面交互
4.1 Vue项目结构与路由权限控制
前端用Vue CLI创建的标准项目结构,src下分api、assets、components、router、store、utils、views七个目录。api目录下每个模块建一个JS文件,比如project.js里封装所有项目相关的接口调用;views目录下按菜单模块建页面级组件。整个前端没有用特别复杂的UI框架扩展,Element UI的el-table、el-form、el-dialog、el-tabs完全覆盖了后台管理系统的需求。
路由权限这块是分两步做的。第一步在router/index.js里,把需要登录才能访问的页面,meta字段里加上requiresAuth: true;第二步,在全局前置守卫里加逻辑:
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path === '/login') {
next()
} else if (!token) {
next('/login')
} else {
next()
}
})
角色权限的菜单过滤,我是通过Vuex里存当前用户信息,在侧边栏菜单组件里用v-if判断角色字段来决定渲染哪些菜单项。比如role为'admin'时显示系统管理菜单,role为'material'时只显示材料采购相关菜单。这种在前端隐藏菜单的方式只解决交互体验,真正的数据安全必须靠后端接口鉴权兜底。
4.2 Axios请求封装与拦截器
axios封装我放在utils/request.js里。新建实例时统一设置了baseURL和超时时间,然后加了请求拦截器和响应拦截器。请求拦截器里从localStorage拿到token,设置到请求头的Authorization字段;响应拦截器里做了统一错误处理,HTTP状态码为200且data.code为0时,直接返回data.data给调用方,否则弹出错误提示。这个统一处理完成后,在业务页面里请求一个列表只剩下三行代码:
const { data } = await getProjectList(params)
this.tableData = data.list
开发环境跨域问题,我在vue.config.js里配置了devServer的proxy代理,把/api前缀的请求转发到http://localhost:8080。生产环境通过nginx反向代理同样处理,这样可以避免前端代码里写死后端的IP地址,后续迁移服务器只需要改nginx配置。
4.3 核心页面:项目管理列表与项目进度详情
项目列表页是整个系统使用频率最高的页面。顶部是搜索条件区域,用el-form的inline模式放五个搜索项;中间是el-table,columns包括项目编号、名称、客户、项目经理、预算金额、当前进度、状态、操作按钮。进度列我用el-progress显示当前的阶段进度百分比,状态列用el-tag根据状态值显示不同颜色。表格下方是el-pagination分页组件,因为后端接口已经返回了total、pageNum、pageSize,前端直接绑定即可,切页时重新调用列表接口。
项目进度详情页是这个系统里最有价值的一页,它把project的主表信息、下面的阶段节点、关联的采购记录、施工任务和验收记录全部聚合到一个页面上,用el-steps显示横向步进条,每一步对应一个阶段。点击某个阶段节点,右侧会展示该阶段关联的材料进场记录、任务完成情况、现场备注。这个页面的数据来源不是一个接口,而是后端专门写的一个ProjectDetailVO,在一条SQL里把多张表的数据聚合返回,避免前端在页面里调用五六次接口。
5. 关键难点与排查经验实录
5.1 金额累计精度丢失问题
系统开发到联调阶段,财务同事反馈说项目实际成本和材料采购单总额对不上,差了0.01元。排查后发现原因是我在刷新成本时用了double类型接收,导致精度丢失。解决办法是全部换成BigDecimal,并且在数据库层面把实际成本字段设为DECIMAL(12,2)。这件事也给我提了个醒,涉及金额的系统,从实体类字段、DTO、数据库字段到前端展示,全链路统一用BigDecimal和toFixed(2),任何一环用浮点数都会埋雷。
还有一个小坑是前端展示金额时,如果直接显示0.1之类的值,加上金额单位时偶尔出现0.999999这种数。处理办法是后端在返回给前端前统一设置scale为2,前端不再做任何金额计算,只负责展示。
5.2 MyBatis批量插入与主键回填
材料采购单的明细,一次可能插入几十条。我之前用了循环单条insert的方式,前期数据量小感觉不到慢,后来测试环境造了几千条数据后,一次采购单提交竟然要两三秒。后来改成MyBatis的批量插入:
<insert id="batchInsertItems">
insert into purchase_item(purchase_id, material_name, spec, quantity, unit_price, total_price)
values
<foreach collection="list" item="item" separator=",">
(#{item.purchaseId}, #{item.materialName}, #{item.spec}, #{item.quantity}, #{item.unitPrice}, #{item.totalPrice})
</foreach>
</insert>
批量插入还有个大坑是主键回填。如果子表需要purchase_id,你不能指望foreach批量插入后自动回填主键,因为JDBC的批量操作返回的主键集合可能对不上。我的解决方案是:先插入主表拿到主键id,然后遍历子表列表给每个item设置purchaseId,再批量插入子表。这样虽然多了一步赋值,但数据关系绝对不会错。
5.3 前端时间格式化时区偏移
项目列表页显示的创建时间,一直比数据库里实际时间多了8小时。刚开始我以为是MySQL时区问题,后来查了前端network面板,发现后端返回的JSON里时间字符串是正确的,是JavaScript在解析ISO时间字符串时把UTC时间当成本地时间转换,导致显示偏移。解决办法是在后端全局配置Jackson的日期格式化,把LocalDateTime统一序列化成yyyy-MM-dd HH:mm:ss字符串,前端当普通字符串显示,不做Date对象转换。
5.4 前后端联调的常见报错速查表
| 报错现象 | 根本原因 | 解决办法 |
|---|---|---|
| Access to XMLHttpRequest has been blocked by CORS | 后端未配置跨域或拦截器拦截了OPTIONS | 后端加CorsFilter,拦截器放行OPTIONS请求 |
| 401 Unauthorized | JWT过期或Authorization头未携带 | 检查axios拦截器设置token的逻辑,重复登录刷新token |
| Invalid bound statement (not found) | Mapper接口和XML文件未匹配 | 检查XML文件namespace和接口全限定名一致,确保XML在resources目录下 |
| java.sql.SQLException: Unknown column | 实体类字段与表字段不对应 | 打开MyBatis的mapUnderscoreToCamelCase配置或在XML里写清resultMap |
| Failed to configure a DataSource | 启动类没有排除DataSource自动配置 | 如果是纯后端项目检查yml数据源配置,如果是多数据源需要排除自动配置 |
5.5 权限设计遗漏的越权操作
系统上线前做了一次权限复盘,发现一个严重问题:材料员可以直接调用项目经理才能用的接口。原因是前端菜单虽然按角色渲染了,但后端的拦截器只做了“是否登录”的判断,没有做“是否有权限”的判断。后来我在AuthInterceptor里加了一步,根据请求的URL前缀匹配所需角色,例如/api/project/下的POST请求要求manager或admin角色,/api/purchase/下的写操作要求material或admin角色。同时把所有写操作的URL规范成RESTful风格,按资源模块区分,拦截器的配置就清晰很多。
6. 打包部署与上线维护
6.1 前后端分离部署的正确姿势
前端开发完,执行npm run build,生成dist目录。在服务器上装好nginx,把dist目录里的文件拷到/usr/share/nginx/html/下,nginx配置里做两个关键跳转:第一个是location / 指向前端静态文件入口,第二个是location /api/ 把请求代理到后端服务端口:
server {
listen 80;
server_name your-domain.com;
location / {
root /usr/share/nginx/html;
index index.html;
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 $uri $uri/ /index.html这一行,它的作用是前端路由在history模式下刷新页面时,把请求重新指回index.html,由前端路由接管。不加这行,用户刷新项目详情页会直接404。
后端只需要把项目打成jar包,用java -jar装饰工程管理系统.jar启动。进程守护我用的是systemd,写一个service文件,设置Restart=always,JVM参数根据服务器内存设置,比如-Xms256m -Xmx512m。
6.2 上线后的一些维护心得
系统上线跑了一个月之后,最大的感受是: 装饰工程管理系统真正的价值,不在于把流程线上化,而在于让每个角色的行为留下痕迹。 无论是项目经理登记进度,还是材料员录入采购单,每一笔操作都有时间和操作人。数据沉淀之后,很多管理决策不再靠拍脑袋,比如统计某个项目经理名下所有项目的平均工期,对比哪些采购供应商的价格更优,这些在Excel时代是根本做不到的。
根据我个人的经验,后续这个系统还可以往两个方向扩展:一是接消息通知,项目阶段流转时自动给客户和项目经理推送短信或者站内信;二是移动端适配,现在项目经理和材料员基本都在工地,拿手机比拿电脑多,做一套H5或者小程序端,使用率会翻好几倍。如果你也在做类似的管理系统,建议在数据库设计阶段尽量预留扩展位,别把字段写死,后面加东西会轻松很多。
333




被折叠的 条评论
为什么被折叠?



