☰
基于SpringBoot+Vue的船舶维保管理系统设计与实现
2026/10/21 16:37:12 网站建设 项目流程

直接说结论:如果你正在为航运企业、船管公司或者内河运输船队做信息化改造,这套“船舶维保管理系统”就是目前中小型项目里最稳的组合方案。它用SpringBoot做后端API,Vue做前端界面,MySQL存业务数据,三位一体把船舶维保计划、工单执行、备件库存、故障记录全部串起来,开箱即跑,不需要你自己从零搭架子。

我做这类管理系统做过好几个轮次,从最早的Excel排班,到后来的单机版客户端,再到这套前后端分离的Web系统,最深的感受是:船舶维保不是单纯“到点提醒”那么简单的功能,它涉及多艘船、多个维保周期、多种备件类型、多人协作,业务关系比表面看起来复杂得多。这篇文章不打算讲空泛的设计理念,只讲这套系统真正落地时的技术细节,包括我为什么选SpringBoot+Vue+MySQL的组合、数据库表是怎么设计的、后端几个核心流程怎么实现、前端页面怎么组织,以及部署运行时最容易踩的坑。文末还附了完整的运行步骤,照着做就能把系统跑起来。

1. 船舶维保的业务痛点:为什么Excel和纸质单据撑不住了

先说业务侧的真实情况。大部分中小型船务公司的机务管理还停留在“台账+记忆”模式:每条船的维保计划记在Excel里,维保周期用颜色标注,执行完毕后在本子上打钩;备件进出库靠纸质领用单,库存有没有货取决于仓管员记不记得。这种模式在船舶数量少、维保频率低的时候勉强能用,但只要超过5条船,或者遇到港口国检查要求提供维保记录,问题就会集中爆发。

1.1 传统维保模式的三类典型问题

第一类是计划漏排与错排。每条船的维保项目可能多达十几项,比如主机滑油更换、辅机燃油滤器清洗、救生筏年检、消防设备月检,周期各不相同——有的是每月、有的是每季度、有的是按运行小时数。靠人工在Excel里维护几十行周期数据,一旦漏掉某条船的子项目,设备带病运行的窗口期就会拉长,隐患很大。

第二类是执行状况无追踪。维保工单派下去之后,执行到什么程度、换了哪些备件、谁签字的、有没有遗留问题,全部靠纸质单据流转。快则一周慢则一个月,单据才汇总到机务主管手里。结果就是:问题发生时不知道哪个环节出了岔子,想做数据回溯根本无据可查。

第三类是备件与维保脱节。维保计划排好了,但备件库存是否满足需求没有联动。经常出现的情况是:计划到期了,执行人员去仓库领件,发现某个型号的滤芯上个月已经用完,采购还没下单,工单只能暂停,工期直接延后。

1.2 系统要解决的核心命题

这套系统的目标很明确:用计划驱动执行,用执行反哺数据,用数据支撑备件采购。我把整个业务流程抽象成一条主链路——船舶档案维护、维保计划定义、到期自动生成工单、工单执行并记录备件消耗、执行结果回写计划、故障异常进入故障库。每一个环节都有明确的状态和责任人,所有操作都在系统里留痕。

换句话说,这不是一个“花架子软件”,而是一个真正贴合船舶机务管理流程的业务系统。它适合三类团队使用:一是给自己的船队做信息化改造的船东公司;二是承接第三方船舶管理业务的船管公司;三是正在做课程设计或毕业设计、想找一个完整业务场景练手的开发者。对开发者来说,这个项目的技术栈覆盖了权限认证、CRUD、定时任务、报表统计等常见模块,拿来研究和扩展都很合适。

2. 技术选型:为什么是SpringBoot+Vue+MySQL,而不是其他组合

很多人一上来就纠结微服务、容器化、NoSQL,但做业务管理系统,选型的核心逻辑从来不是“技术新”,而是“团队能维护、需求能落地、成本可控”。这套系统选用SpringBoot+Vue+MySQL,是我基于项目的实际规模和运维条件做的判断。

2.1 后端:SpringBoot的生态红利

SpringBoot在这类系统里的优势不用多说,但我想强调三点实际感受。

第一是起步成本极低。内置Tomcat、自动配置、Starter机制,一个空的Web工程从创建到跑起来只需要几分钟。对船舶维保这类业务逻辑集中、并发量不高的系统来说,SpringBoot本身就消除了大量配置负担。

第二是生态配套成熟。持久层我用的是MyBatis-Plus,它对单表CRUD做了很好的封装,wrapper查询、分页插件、逻辑删除都是开箱即用。安全认证用JWT,不需要引入Spring Security那套重量级的过滤器链。定时任务直接用Spring自带的@Scheduled,不用额外接XXL-Job。这一套搭配下来,依赖少、上手快、排查问题也容易。

第三是部署友好。打成一个jar包扔到服务器上就能跑,不需要单独装Tomcat,也几乎不需要改服务器配置。对只有一台云主机的小团队来说,这是最省心的方式。

2.2 前端:Vue+Element的组合拳

前端选择Vue 2(也可以用Vue 3,但需要留意组件库版本匹配),配合Element UI组件库。选择Vue的核心原因在于组件化开发模式与后台管理页面的天然匹配。登录页、导航菜单、维保计划表格、备件库存表单、统计图表,这些页面都有清晰的模块边界,拆成组件之后每个页面只关心自己的数据和交互,维护成本直线下降。

Element UI提供的表格、表单、日期选择器、弹窗、标签等组件,几乎覆盖了这类管理系统90%的UI需求。实测下来,用Element写一个带搜索、分页、行内编辑的维保计划列表页,一个熟练的开发者一两个小时就能完成。加上axios封装和Vue Router路由控制,前端部分不需要引入额外重型框架,整体保持轻量。

2.3 数据库:MySQL完全够用

这套系统的数据量级大概在一个什么水平?假设管理50条船,每条船20个维保项目,历史维保记录累积5年,工单和日志加在一起也就几十万行。这种量级下MySQL无论从性能还是运维角度都绰绰有余。

选MySQL还有另外两个实际考量:一是事务支持可靠,后面讲备件库存扣减时会提到事务的重要性;二是运维门槛低,任何有一点数据库基础的开发都能完成备份和恢复。相比引入PostgreSQL或者MongoDB,在这个场景下并不会有质的提升,反而增加了团队学习和运维的成本。

提示:技术选型不是越强大越好,而是越匹配越好。船舶维保管理系统属于典型的“高内聚、低并发、重逻辑”业务系统,SpringBoot+Vue+MySQL的组合在这个定位下是最不容易出错的方案。

3. 数据结构落地:从业务实体到数据库表设计的完整拆解

一套管理系统的质量,大约七成取决于表结构设计得是否合理。我在设计这套系统的表结构时,反复推演了业务流程,确保每张表都有明确的存在意义,字段不多不少,关系不绕弯。

3.1 核心表结构总览

我把整个系统拆成六大核心实体,对应六张主表:

表名对应业务核心字段一句话说明
sys_user用户与权限login_name、password、role_type(管理员/机务/执行人员)
boat_info船舶档案boat_name、boat_type、tonnage、register_no、status
maintain_plan维保计划boat_id、item_name、cycle_type、cycle_value、last_exec_time、next_exec_time
maintain_order维保工单plan_id、order_no、status、assignee、execute_time、execute_result
spare_part备件物料part_name、part_model、stock_num、warning_line、unit
fault_record故障记录boat_id、fault_desc、fault_time、handle_result、handler

这六张表之间不是一个简单的树状结构,而是一条业务流的横向串联:船舶档案挂在最上层,维保计划绑定到某条船,计划到期生成工单,工单执行消耗备件,执行异常产生故障记录。

3.2 维保计划表的字段设计细节

维保计划表是整套系统的灵魂,字段设计上有很多值得细说的地方。

周期表达我用了两个字段组合:cycle_type和cycle_value。cycle_type取值为MONTH、QUARTER、YEAR、HOUR,分别表示按月、按季、按年、按运行小时数。cycle_value是具体数值,比如每月对应1,每季度对应3。这样设计的好处是:维保项目可以灵活配置周期,而不是在代码里写死“每月多少号执行”。

时间推演字段包括last_exec_time和next_exec_time。next_exec_time不是新建计划的时候写死不变的,而是每次工单完成之后根据当前时间加上周期重新计算。计算逻辑后面讲后端时再展开,这里先记住一个原则:维保计划的“下次执行时间”必须动态更新,而不是固定初始值。

执行状态字段用status表达,取值包括NORMAL(计划正常跟随周期)、OVERDUE(已到期未执行)、PAUSED(暂停维保,比如船舶检修停用)。这个字段不存储在表里做实时计算,而是通过定时任务每天扫描一次,将next_exec_time小于当前日期的计划更新为OVERDUE状态,这样列表页的筛选和统计都直接走SQL查询,效率高。

3.3 工单表的“状态机”设计

maintain_order表承担的是执行层面的数据,它的核心字段是status。我设计的状态流转不是随意的,而是严格的线性流转:

PENDING(待执行) -> EXECUTING(执行中) -> COMPLETED(已完成) -> CONFIRMED(已确认) \ / -> CANCELED(已取消)

重点说明一下COMPLETED和CONFIRMED的区别。执行人员做完维保工作后,先提交“完成”,此时系统记录完成时间、执行结果和消耗备件;机务主管再复核一次,确认无误后工单状态变成“已确认”。这一层复核机制,正好对应行业里的“自查+复查”双签制度,避免执行人员自己做了自己说了算。

3.4 数据库设计的几个关键约定

  • 所有表都带物理主键id,类型用BIGINT自增,避免业务字段作为主键带来的更新麻烦。
  • 通用字段统一包含create_time、update_time、deleted(逻辑删除标志)。逻辑删除在这个项目里很有必要,维保记录是审计追踪数据,物理删除会让历史断档。
  • 金额、数量等字段一律用DECIMAL,不用FLOAT或DOUBLE,避免浮点精度问题。备件库存的stock_num就用了DECIMAL(10,2),因为有些备件按桶、按卷计数,不一定是整数。
  • 外键关系只做逻辑关联,不建物理外键。比如maintain_order.plan_id只存储计划ID,不在数据库层声明FOREIGN KEY。这样做的目的在于减少数据库层的写锁压力,也方便以后做分表归档时不用先处理外键约束。
  • 索引设计遵循“查询维度决定索引组合”的原则。maintain_order表给(status, plan_id)建组合索引,因为列表页最常见的查询就是“某个计划下有哪些状态的工单”;maintain_plan表给(next_exec_time, status)建组合索引,因为定时任务每天都要按这两个条件扫表。

4. 后端落地:REST接口、JWT认证、定时任务与库存事务

表结构定下来之后,后端开发其实就变成一件很线性的事:Controller接收请求,Service处理业务逻辑,Mapper访问数据库。我按这个分层逐一说明核心实现。

4.1 工程结构划分

工程结构我采用常规的分层:

com.ship.maintain ├── controller // 接口层,只做参数接收和结果返回 ├── service // 业务层,核心逻辑全部在这里 ├── mapper // 数据访问层,MyBatis-Plus的BaseMapper ├── entity // 数据库实体类 ├── dto // 前后端交互的传输对象 ├── config // 配置类,包括JWT拦截器、跨域配置、定时任务 ├── common // 统一返回结果、异常处理、常量类

这里有个实际经验:entity字段和数据库列名必须对齐,但DTO和entity要分开,不要把数据库实体直接返回给前端。因为数据库里存了密码、逻辑删除位这类字段,直接返回容易出现安全隐患;更重要的是DTO可以按业务需求自由组合字段,比如工单列表返回时同时把boat_name和plan_item_name带进去,这种跨表查询结果在entity里往往没有对应字段,放到DTO里干净利落。

4.2 REST接口设计

接口路径我按照资源名来组织,统一用复数形式:

方法路径说明
POST/api/auth/login登录,返回JWT令牌
GET/api/boats船舶列表(分页+条件查询)
POST/api/boats新增船舶
PUT/api/boats/{id}修改船舶信息
DELETE/api/boats/{id}删除船舶(逻辑删除)
GET/api/plans维保计划列表
POST/api/plans/generate根据周期计算并生成计划
GET/api/orders/overdue超期未完成工单列表
PUT/api/orders/{id}/complete提交工单完成
GET/api/parts/low-stock低库存备件列表
GET/api/dashboard/stats首页统计数据

这里不逐一展开每个接口的实现代码,重点讲两个我认为最有代表性的业务逻辑。

4.3 维保计划的下次执行时间计算

这是维保计划模块最核心的算法,逻辑不复杂,但容易写错。规则是:每次工单“确认完成”后,以完成时间为基准重新推算下次执行时间。

public Date calculateNextExecuteTime(Date lastExecuteTime, String cycleType, Integer cycleValue) { Calendar calendar = Calendar.getInstance(); calendar.setTime(lastExecuteTime); switch (cycleType) { case "MONTH": calendar.add(Calendar.MONTH, cycleValue); break; case "QUARTER": calendar.add(Calendar.MONTH, cycleValue * 3); break; case "YEAR": calendar.add(Calendar.YEAR, cycleValue); break; case "HOUR": calendar.add(Calendar.HOUR_OF_DAY, cycleValue); break; default: throw new BusinessException("不支持的周期类型: " + cycleType); } return calendar.getTime(); }

这个逻辑的边界情况不少。最典型的是跨月问题:比如上次执行时间是1月31日,按月度周期加一个月,Calendar会自动进位到2月28日或3月1日,取决于闰年和平年。这个行为在业务上可以接受,但如果排的是精确到天数的维保日历,就需要改成“固定每月某日”模式。我在实际项目里两种模式都支持,cycle_type加了一个FIXED_DAY选项,存储当月需要执行的具体日期。

4.4 JWT认证与权限控制

认证方案用了最常用的JWT+拦截器模式,没有引入Spring Security。原因很简单:这个系统只有三种角色(管理员、机务主管、执行人员),权限粒度到“按钮级别”的需求基本没有,用Spring Security反而要写大量配置。

JWT实现分为三步:

  1. 登录成功后用用户ID、角色和过期时间生成一个token,密钥存储在配置文件中。
  2. 写一个AuthInterceptor,继承HandlerInterceptorAdapter,在preHandle里从请求头取token,解析成功后把用户信息放入ThreadLocal。
  3. 在WebMvcConfigurer里注册拦截器,定义放行路径(比如登录接口)和拦截路径(其余所有/api/**)。

权限控制的做法很简单但实用:在注解或代码里判断当前用户的角色类型,比如执行人员不能调用“确认工单”的接口,管理员才能访问用户管理模块。

@GetMapping("/api/orders/confirm") public Result confirmOrder(@RequestParam Long orderId) { User currentUser = UserContext.getCurrentUser(); if (!"ADMIN".equals(currentUser.getRole()) && !"SUPERVISOR".equals(currentUser.getRole())) { return Result.error(403, "该操作需要机务主管及以上权限"); } // 业务处理 }

4.5 定时任务实现

定时任务有两个核心场景:每天扫描到期维保计划并生成待执行工单,以及备件库存低于预警线时生成补货提醒。我用Spring Boot自带的@Scheduled注解实现,没有引入额外任务调度框架。

@Scheduled(cron = "0 30 2 * * ?") public void checkOverduePlan() { List<MaintainPlan> overduePlans = planMapper.selectList( new LambdaQueryWrapper<MaintainPlan>() .eq(MaintainPlan::getStatus, "NORMAL") .le(MaintainPlan::getNextExecuteTime, new Date()) ); for (MaintainPlan plan : overduePlans) { // 更新计划状态为OVERDUE // 生成一条新的待执行工单 // 插入一条消息通知给该船的机务主管 } }

定时任务有几个细节容易踩坑,我后面会专门讲。这里先记住一个原则:定时任务要做幂等处理,也就是同一时刻只允许一个任务实例执行。我加了@Scheduled配合分布式锁的轻量实现(用MySQL的select for update),避免集群部署时任务重复执行。

4.6 工单完成时的事务处理

工单完成不是一个简单的“更新状态”操作,它同时涉及三件事:

  1. 将工单状态更新为COMPLETED。
  2. 按工单关联的备件明细扣减库存。
  3. 重算对应维保计划的下次执行时间并更新状态。

这三件事必须放在同一个事务里,任何一个失败都要整体回滚,否则会出现“工单标记完成了但库存没扣”这类脏数据。

@Transactional(rollbackFor = Exception.class) public void completeOrder(Long orderId, List<OrderPartDetail> partDetails) { // 1. 校验工单状态必须为EXECUTING // 2. 扣减备件库存(带乐观锁) // 3. 更新工单状态、写入完成时间 // 4. 重算计划的下次执行时间 }

库存扣减用了乐观锁机制,spare_part表增加version字段,SQL更新语句加上WHERE id = ? AND version = ?,防止并发情况下两个请求都读到相同的库存数量而导致超扣。

5. 前端实现要点:页面结构、API封装与状态管理

后端逻辑再完善,前端交互跟不上也一样不落地。这套系统前端的技术路线是Vue全家桶,我按“基础设施、核心页面、状态管理”三个层次说明。

5.1 工程目录与基础设施

前端工程目录结构比较清晰:

src ├── api/ // 按模块封装的请求接口 │ ├── auth.js │ ├── plan.js │ ├── order.js │ └── part.js ├── assets/ // 静态资源 ├── components/ // 通用组件(上传、富文本、分页封装) ├── layout/ // 主布局(侧边栏 + 顶栏 + 内容区) ├── router/ // 路由配置 ├── store/ // Vuex状态管理 ├── views/ // 页面视图 │ ├── login/ │ ├── boat/ │ ├── plan/ │ ├── order/ │ ├── part/ │ └── dashboard/ └── utils/ // axios封装、日期工具等

接口封装统一放在api目录,每个页面对应一个JS文件。比如维保计划模块的接口封装:

// api/plan.js import request from '@/utils/request' export function getPlanList(params) { return request({ url: '/plans', method: 'get', params }) } export function generatePlan(data) { return request({ url: '/plans/generate', method: 'post', data }) }

utils/request.js是基于axios的实例封装,主要做了三件事:统一加请求头token、统一处理HTTP错误码、统一拦截401跳转登录页。

5.2 登录与路由守卫

登录页面的逻辑比较标准:提交表单到/api/auth/login,成功后把token存到localStorage,同时把用户信息存到Vuex。路由守卫在beforeEach里做检查:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { next() } })

这里有一个容易忽略的点:路由守卫只能控制页面可见性,不能代替后端接口校验。也就是说,即使用户绕过路由守卫直接访问API,后端拦截器也会因为缺少token而拒绝请求。前端守卫是体验层面的,后端校验才是安全层面的,两者缺一不可。

5.3 维保计划列表页的组件组织方式

以维保计划列表页为例,我把页面拆成了四个子组件:

  • PlanSearchBar.vue:搜索区和筛选区(船舶名称、周期类型、状态)。
  • PlanTable.vue:展示维保计划的数据表格,包含“执行”、“编辑”、“暂停”操作按钮。
  • PlanFormDialog.vue:新增/编辑维保计划的弹窗表单。
  • PlanDetailDrawer.vue:点击行时弹出右侧抽屉展示维保工单的历史记录。

这四个组件之间的通信全部由父页面Plan.vue控制。父组件的职责是:调用接口获取列表数据,把数据传给表格组件;收到子组件触发的事件后,调用相应接口并刷新列表。

这个拆分思路的好处是:每个组件职责单一,后续如果要加“批量导出”功能,只要在父组件顶部加一个按钮、调用一个导出接口即可,完全不用改表格组件内部逻辑。

5.4 数据统计面板的实现

Dashboard统计页面用的是ECharts图表库,我做了三个核心图表:

  • 各船维保完成率柱状图:统计每条船已完成工单数占全部工单数的比例。
  • 近12个月维保工单趋势折线图:按月统计工单创建数量。
  • 备件库存预警水平条形图:将低于预警线的备件按缺口数量从大到小排列。

图表的共同点是数据来源统一从后端/api/dashboard/stats接口获取,接口一次返回所有图表所需的数据结构,前端不再单独发多个请求。这样做的原因是减少前端对接口的依赖,后端组装好数据,前端只用做渲染,后续如果调整统计口径,只改后端一处就行。

6. 从源码到运行:环境准备、数据库初始化与部署配置

到了这一步,工程代码已经完整,剩下的是把它跑起来。我按服务器环境为Linux(CentOS 7+或Ubuntu 20.04均可)、本地开发机为Windows来写这一节。整条链路可以分为四个阶段。

6.1 环境准备清单

组件版本要求用途
JDK1.8+,推荐1.8或11运行SpringBoot后端
Maven3.6+后端依赖管理和打包
Node.js14+,推荐16 LTS前端开发与构建
npm/yarnnpm 6+前端依赖安装
MySQL5.7+,推荐8.0数据存储
Nginx1.18+前端部署与反向代理

各组件安装命令不再赘述,网上资料很多。让我着重提醒版本选择的两个坑:JDK不要用17以上的新版本跑SpringBoot 2.x的工程,部分依赖会不兼容;MySQL 8.0的默认认证插件是caching_sha2_password,如果后端连接驱动版本太老会报认证错误,建议后端使用MySQL Connector/J 8.0以上的驱动。

6.2 数据库初始化

数据库脚本一般包含三部分内容:建库、建表、初始化数据。初始化数据最重要的是插入管理员账号和若干测试船舶数据,方便登录后立刻看到页面效果。

-- 创建数据库,指定utf8mb4编码 CREATE DATABASE IF NOT EXISTS ship_maintain DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE ship_maintain; -- 用户表 CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `login_name` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL, `real_name` VARCHAR(50) NOT NULL, `role_type` VARCHAR(20) NOT NULL COMMENT 'ADMIN/SUPERVISOR/OPERATOR', `status` TINYINT DEFAULT 1, `create_time` DATETIME DEFAULT NULL, `update_time` DATETIME DEFAULT NULL, `deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 维保计划表 CREATE TABLE `maintain_plan` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `boat_id` BIGINT NOT NULL, `item_name` VARCHAR(100) NOT NULL, `cycle_type` VARCHAR(20) NOT NULL COMMENT 'MONTH/QUARTER/YEAR/FIXED_DAY', `cycle_value` INT NOT NULL, `last_exec_time` DATETIME DEFAULT NULL, `next_exec_time` DATETIME NOT NULL, `status` VARCHAR(20) DEFAULT 'NORMAL', `create_time` DATETIME DEFAULT NULL, `update_time` DATETIME DEFAULT NULL, `deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_next_exec_status` (`next_exec_time`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

数据库脚本可以用Navicat执行,也可以在命令行直接source导入。初始化数据中管理员密码我建议存MD5加密后的值,登录接口做同样的编码比对,避免明文密码直接入库。

6.3 后端配置与启动

后端配置文件application.yml是部署的关键所在,需要重点检查数据源、时区、JWT密钥三处配置。

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ship_maintain?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key-change-in-production expire-hours: 24

其中serverTimezone=Asia/Shanghai和allowPublicKeyRetrieval=true这两个参数非常关键。前者防止数据库时间与服务器本地时间相差8小时,后者解决MySQL 8.0非SSL连接时可能出现的公钥检索报错。

配置完成后,在工程根目录执行:

mvn clean package -DskipTests java -jar target/ship-maintain-system.jar

看到Started ShipMaintainApplication日志输出即启动成功。

6.4 前端构建与Nginx部署

前端开发调试阶段直接执行:

npm install npm run dev

生产环境需要先构建再部署:

npm run build

构建产物会输出到dist目录,把整个dist文件夹上传到服务器,然后在Nginx配置里做两个核心设定:root指向dist目录,将/api路径反向代理到后端8080端口。

server { listen 80; server_name your-domain.com; root /var/www/ship-maintain/dist; 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; } location / { try_files $uri $uri/ /index.html; } }

try_files $uri $uri/ /index.html这一行的作用是处理前端路由的刷新问题:因为Vue Router用的是history模式,直接访问/plan路径时Nginx会先找对应文件,找不到就回退到index.html,由前端路由接管页面渲染。

部署完成后访问http://your-domain.com,用初始化的管理员账号登录,整个系统就正式跑起来了。

7. 这套系统运行过程中,我踩过且值得你避开的坑

最后一部分写实操中的经验教训,全是真实遇到过的。这些内容不会出现在官方文档里,但对实际运行体验影响很大。

7.1 时区问题会让定时任务“看起来没执行”

第一次部署后我发现一个问题:每天早上定时任务应该扫描到期计划并生成工单,但列表里总有一些昨天已经到期的计划没有生成工单。排查过程花了不少时间,最终确认是时区不一致导致的。

后端配置的serverTimezone=Asia/Shanghai只解决了JDBC层面的时区问题,但定时任务比较的时间用的new Date()取的是服务器系统时间,如果服务器系统的时区是UTC,而数据库存的时间是北京时间,两者之间就会差8小时。解决方法是:在启动脚本里显式指定JVM时区:

java -jar -Duser.timezone=Asia/Shanghai ship-maintain-system.jar

经历过这次之后,我在所有涉及时间比较的代码里都统一使用一个时间工具类,禁止散落各处直接new Date()。

7.2 定时任务幂等:没有分布式锁就会重复生成工单

早期版本没有考虑幂等,只在定时任务方法上加了个synchronized。单机部署时没问题,但后来为了可靠性做双节点部署,问题就来了:两个节点同时触发扫描任务,同一批到期计划被处理了两次,数据库里出现了重复工单。

最终方案是用MySQL数据库锁实现分布式锁。具体做法:新建一张sys_lock表,里面存任务名和锁状态;定时任务执行前先对匹配的记录执行SELECT ... FOR UPDATE,拿到锁才继续执行,执行完事务提交释放锁。这个方案代码量小、行为可靠,足够这个量级的项目使用。

7.3 状态流转校验不严导致工单数据错乱

工单从EXECUTING直接跳到了COMPLETED,但执行人员并没有提交过完成操作——这是用Postman直接调用接口绕过前端按钮导致的。后端接口里如果没有做前置状态校验,任何人都可以直接改工单状态。

吃一堑长一智,我最终给所有状态流转操作加了统一的校验逻辑:在Service层写了一个validateOrderStatusChange(orderId, expectedStatus)方法,每次流转前先查当前状态,与期望状态不一致直接抛异常。数据库端也加了CHECK约束兜底,双保险。

7.4 备件库存并发扣减引发超卖

多艘船的维保工单同时完成,执行人员打钩时并发扣减同一种备件库存,早期因为先查询再更新,两个请求如果同时读到库存为10,各扣5件,理论上库存应变为0,但实际却变成了5或者10,导致数据错乱。

最终用两个手段解决:一是利用MySQL的行级锁,把扣减语句写成原子操作——UPDATE spare_part SET stock_num = stock_num - #{count}, version = version + 1 WHERE id = #{id} AND stock_num >= #{count},在SQL层面就比较库存是否足够;二是在Service层加乐观锁重试机制,更新影响行数为0时说明库存不足或版本冲突,给前端返回明确提示。

7.5 前端大列表渲染卡顿

维保工单历史数据积累到数万条以后,Element UI表格一次性渲染全部数据会明显卡顿,一次翻页操作耗时超过一秒。分析下来发现原因有两个:表格没有开启虚拟滚动;后端接口虽然做了分页,但前端又把全部数据一次性拉了下来。

修复方案是双管齐下:后端接口严格使用MyBatis-Plus分页,前端用el-pagination配合@size-change和@current-change事件,切页时才请求对应页码数据;同时表格开启el-table的:max-height属性限制可视高度。改完之后,哪怕数据量到十万行,页面交互依然流畅。

8. 写在最后的实践心得

回看这套系统的整个开发过程,我想分享一个做业务管理系统最有价值的经验:不要一上来就写代码,花最多的时间在业务理解和表结构设计上,后面写代码的时间其实很少。这个项目里,先花了两三天把所有维保流程和各角色操作边界理清楚,表结构设计反复改了四版,最后一口气写后端只用了不到一周。反过来,如果一上来就敲代码,中途推翻重来的时间和精力成本至少翻倍。

如果你打算在本地把系统跑起来,建议从最小闭环开始:先初始化数据库,用管理员账号登录,然后新增一条船舶和一份维保计划,手动把“下次执行时间”改到昨天,再等定时任务自动生成工单。看到工单出现在列表里并完成一次“执行→完成→确认”的全流程操作后,你就对这套系统有了完整感知。后续还可以在这个基础上扩展:对接企业微信或钉钉通知、增加维保报告导出的Word模板、接入物联网传感器数据来做预测性维护,这些都是这个系统未来可以延展的方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询