
1. 码头货柜管理的业务本质这个系统到底在管什么先说个容易被技术同学忽略的点。很多人拿到码头船只货柜管理系统这类项目第一反应是看技术栈、看代码结构但真正决定这个系统能不能拿去交付、能不能在企业里跑起来的是业务模型理得清不清楚。码头货柜管理本质上是一个围绕箱子的状态流转系统。一艘船靠岸上面可能带着上千个标准集装箱TEU这些箱子有一部分要卸下来堆到堆场有一部分只是中转还要有一部分需要装上另一艘船运走。整个码头运营的核心就是要搞清楚每一个箱子此时此刻在什么位置、处于什么状态、下一步将要被谁以什么方式处理掉。所以我在梳理这个项目时首先做的不是建表而是把所有业务角色和它们的动作列了一遍角色核心动作关心的数据船务调度员录入船期、安排泊位、确认靠离港船名、航次、ETA/ETD、泊位中控操作员分配堆场位置、指挥龙门吊装卸箱号、堆位、设备任务闸口管理员审核集卡进闸提柜、还柜箱号、车牌、集装箱状态商务/计费人员计费、费用结算箱型、操作类型、费用标准堆场管理员盘库、查库存、处理异常堆场区域、贝位、箱状态看到这张表你就明白这个系统的表结构肯定不会只是简单的增删改查。它需要船只档案、船期计划、集装箱动态信息、堆场库位、设备调度、费用结算等多组业务模块协同。标题里写的是企业级这个企业级的分量首先体现在业务模型的多维度和状态流转的严谨性上。这个系统适合谁参考如果你正在做毕业设计、正在接外包项目或者公司内部需要一个通用的仓储物流管理底座这套架构和代码结构很值得拆开看一遍。它不是那种只有一个登录页面加一张用户表的玩具项目而是真正把船、箱、场、车几个对象串起来的完整业务闭环。2. 技术选型的来龙去脉为什么偏偏是这四件套标题里锁定了SpringBoot Vue MyBatis MySQL这是国内中小型企业管理系统的黄金组合。但黄金组合不意味着无脑选每一层选型背后都有明确的原因。2.1 后端SpringBoot解决的是开发效率问题码头管理系统这类企业内部项目往往不是一个大团队花三年打磨的超级平台而是几个人的小团队在有限时间内交付可用系统。SpringBoot的价值在于它把Spring家族的配置复杂度压缩到了极低。你不需要像传统SSH项目那样写一堆XML配置一个spring-boot-starter-web依赖加上SpringBootApplication注解一个可运行的Web服务就起来了。对于做码头货柜管理这种业务逻辑密集、技术复杂度中等的项目SpringBoot可以让团队把80%的精力花在业务实现上而不是环境配置上。再说生态。SpringBoot的starter机制几乎覆盖了所有日常需要——操作数据库有spring-boot-starter-jdbc、集成第三方工具有各种现成starter。后面项目里要接消息队列、要加定时任务、要对接第三方物流平台都能快速找到社区方案。这是选型时一个很重要的隐性收益不会因为框架本身成为项目进度的瓶颈。2.2 持久层MyBatis的灵活SQL是复杂查询的救命稻草这里我要多说几句因为在很多讨论里MyBatis和JPA之争经常变成信仰之争但放在码头货柜管理这个场景里MyBatis是明显更合适的选择。原因很简单这个系统的查询复杂度和动态性远超普通CRUD。举几个具体场景查一个箱子可能要根据箱号精确查也可能要根据船名航次箱型状态时间范围做组合模糊查而且每个条件可空查堆场库存要按贝位Bay、排Row、层Tier三级维度汇总统计查费用明细要关联船期表、操作记录表、计费规则表做多表聚合这些查询用JPA写要么写出一长串方法名findByShipNameAndVoyageNoAndContainerTypeAndStatus...要么用Query写JPQL动态条件拼接非常痛苦。而MyBatis的if动态SQL完美契合这类场景XML里写出来的SQL就是DBA能直接看懂、拿过去就能优化的原生SQL。还有一个常被忽略的点MyBatis对复杂结果集的映射能力。一个集装箱可能有多次操作记录、关联多个费用单用MyBatis的collection和association标签可以很优雅地完成嵌套结果映射。这在码头系统的详情页里是高频需求。resultMap idContainerDetailMap typecom.port.entity.Container id propertyid columnctr_id/ result propertycontainerNo columncontainer_no/ result propertyshipId columnship_id/ result propertystatus columnstatus/ collection propertyoperations ofTypecom.port.entity.Operation id propertyid columnop_id/ result propertyopType columnop_type/ result propertyopTime columnop_time/ /collection /resultMap2.3 前端Vue的组件化思维匹配复杂交互页面码头货柜管理系统的前端页面有个特点信息密度高、操作频繁、状态多。比如堆场总览图一屏可能要显示几百个箱位每个箱位有不同颜色表示不同状态再比如船期计划甘特图需要按时间轴拖拽调整泊位计划。这类页面用传统jQuery写代码很快会变成一团乱麻。Vue的双向绑定和组件化设计让我可以把一个堆场图拆成堆位格子组件、箱信息弹窗组件、状态筛选组件每个组件各管各的状态和事件互不干扰。这样单个页面的复杂度被分解了多人协作时也可以各自负责不同组件不冲突。Vue生态里配套的Vue Router和Vuex/Pinia对于权限路由和全局状态管理比如当前登录用户、当前选中的船只提供了完整方案不需要自己造轮子。实测下来一个中等复杂度的码头管理前端按组件拆分后开发效率至少提升30%。2.4 数据库MySQL够用且成本可控企业级系统动不动就提Oracle或者国产数据库但落实到码头货柜管理这样的数据量级上——一天几千条操作记录、一年百万级流水——MySQL加上合理设计完全扛得住。选MySQL主要是三点考虑部署维护成本低不需要专职DBAInnoDB引擎的事务和行级锁足够支撑货物操作这种高并发短事务生态工具丰富从备份到监控到读写分离都有成熟方案当然表结构设计上要为未来的数据增长留好余地比如箱操作流水表按时间做分区这一点下面章节详细说。3. 数据库建模实战船、箱、场三张主表怎么设计才扛得住业务这套系统能不能真正跑起来数据库设计占七成功劳。我建表时的思路是围绕集装箱这个核心对象展开船和堆场是它的位置属性。3.1 集装箱主表状态机是关键先看最核心的container_info集装箱信息表。CREATE TABLE container_info ( id bigint(20) NOT NULL AUTO_INCREMENT, container_no varchar(20) NOT NULL COMMENT 箱号全球唯一, container_type varchar(10) DEFAULT NULL COMMENT 箱型20GP/40GP/40HQ/45HQ, size_type tinyint(4) DEFAULT NULL COMMENT 尺寸20/40/45, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0在船 1在堆场 2在集卡 3已离场 4维修中, current_position_type tinyint(4) DEFAULT NULL COMMENT 当前位置类型1船上 2堆场 3集卡 4闸口, current_position_no varchar(50) DEFAULT NULL COMMENT 当前位置编号贝位号或车牌号, ship_id bigint(20) DEFAULT NULL COMMENT 当前所在船只ID, voyage_no varchar(30) DEFAULT NULL COMMENT 航次, inbound_bill_no varchar(50) DEFAULT NULL COMMENT 进口提单号, outbound_bill_no varchar(50) DEFAULT NULL COMMENT 出口提单号, release_status tinyint(4) DEFAULT 0 COMMENT 放行状态0未放行 1已放行, customs_status tinyint(4) DEFAULT 0 COMMENT 海关状态0未申报 1已申报 2已放行, seal_no varchar(50) DEFAULT NULL COMMENT 铅封号, weight decimal(10,2) DEFAULT NULL COMMENT 毛重(kg), create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_container_no (container_no), KEY idx_ship_status (ship_id, status), KEY idx_position (current_position_type, current_position_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT集装箱信息表;这张表设计时有几个关键决策第一container_no设置唯一索引。箱号是集装箱在全球范围内的唯一身份证ISO 6346标准所有业务操作都通过箱号关联唯一索引从数据库层面杜绝重复数据。第二状态字段我用status表示业务状态用current_position_type表示物理位置。刚开始设计时我差点把这两个合并成一个字段后来发现不行一个箱子可能状态是在船但物理位置上已经被吊机放到堆场边缘等待集卡状态是已放行的箱子也可能还堆在堆场没提走。状态和位置是两个维度的信息拆开才能真正支撑灵活的查询和统计。第三预留了release_status和customs_status。这是做企业级系统时容易忽略的——码头业务和海关、报关行有大量交互一个箱子没放行是绝对不能拉走的。这两个字段虽然初期可能用不到但设计时预留后面对接时不至于改表结构。3.2 船期表以航次为维度做计划ship_schedule船期表的设计核心是以航次Voyage为唯一业务标识而不是以船为标识。因为同一条船在不同时间来港装载计划完全不同。CREATE TABLE ship_schedule ( id bigint(20) NOT NULL AUTO_INCREMENT, ship_id bigint(20) NOT NULL COMMENT 船ID, ship_name varchar(50) NOT NULL COMMENT 船名, voyage_no varchar(30) NOT NULL COMMENT 航次号, direction tinyint(4) DEFAULT NULL COMMENT 进出口方向1进口 2出口, eta datetime DEFAULT NULL COMMENT 预计到港时间, etd datetime DEFAULT NULL COMMENT 预计离港时间, actual_berth_time datetime DEFAULT NULL COMMENT 实际靠泊时间, berth_no varchar(20) DEFAULT NULL COMMENT 泊位编号, status tinyint(4) DEFAULT 0 COMMENT 状态0计划中 1已靠泊 2作业中 3已离港, unload_count int(11) DEFAULT 0 COMMENT 计划卸箱数, load_count int(11) DEFAULT 0 COMMENT 计划装箱数, create_by varchar(30) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_voyage (voyage_no, direction) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT船期计划表;一个容易踩坑的点航次号在不同船公司、不同方向上可能重复。比如COSCO01航次可能同时存在进口和出口两条记录。所以唯一索引必须是(voyage_no, direction)的组合否则数据插入直接报错。3.3 堆场库位表与操作流水表堆场库位我用三层结构yard_area堆场区域-yard_slot贝位-container_info.current_position_no具体位置。CREATE TABLE yard_slot ( id bigint(20) NOT NULL AUTO_INCREMENT, area_code varchar(10) NOT NULL COMMENT 区域A区/B区/冷链区/危品区, bay_no varchar(10) NOT NULL COMMENT 贝位01-99, row_no varchar(10) DEFAULT NULL COMMENT 排, tier_no varchar(10) DEFAULT NULL COMMENT 层, slot_status tinyint(4) DEFAULT 0 COMMENT 0空闲 1占用 2预留 3维修, current_container_no varchar(20) DEFAULT NULL COMMENT 当前存放的箱号, slot_type varchar(10) DEFAULT NULL COMMENT 适用箱型, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_slot (area_code, bay_no, row_no, tier_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT堆场贝位表;贝位的唯一约束是(area_code, bay_no, row_no, tier_no)这对应物理世界中一个具体的箱子位置。为什么拆四个字段而不是用一个字符串AB01-02-03因为业务上经常需要按区域统计容量、按贝位筛选箱子拆开后写SQL做分组聚合非常方便。操作流水表则是记录箱子每一次动作的日志表CREATE TABLE container_operation_log ( id bigint(20) NOT NULL AUTO_INCREMENT, container_no varchar(20) NOT NULL, op_type varchar(20) NOT NULL COMMENT 操作类型卸船/装船/进闸/出闸/移场/维修, from_position varchar(50) DEFAULT NULL COMMENT 来源位置, to_position varchar(50) DEFAULT NULL COMMENT 目标位置, operator_id bigint(20) DEFAULT NULL, operator_name varchar(30) DEFAULT NULL, op_time datetime DEFAULT CURRENT_TIMESTAMP, remark varchar(255) DEFAULT NULL, PRIMARY KEY (id), KEY idx_container_time (container_no, op_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT集装箱操作流水表;流水表写入是高频操作查询也频繁所以我把联合索引设为(container_no, op_time)——查一个箱子的所有操作轨迹时走索引一次就能扫完不用回表。这套设计的灵感其实来自电商订单系统的状态机设计把当前状态放在主表、把状态变迁放在流水表两者配合才能回答现在在哪以及之前经历了什么两个问题。4. SpringBoot后端核心业务逻辑的落地姿势数据库模型确定之后后端编码的核心就是把业务规则翻译成代码逻辑。这一部分我挑几个最有代表性的功能拆开讲。4.1 卸船作业的完整业务闭环卸船是整个系统最核心的业务动作之一它涉及多个表的联动更新Service public class UnloadService { Transactional(rollbackFor Exception.class) public void unloadContainer(UnloadRequest request) { // 1. 校验船舶状态必须是已靠泊且作业中 ShipSchedule schedule shipScheduleMapper.selectById(request.getScheduleId()); if (schedule null || schedule.getStatus() ! 2) { throw new BizException(船舶不在作业状态无法卸船); } // 2. 校验箱号是否在本次船期的计划列表中 ContainerInfo container containerMapper.selectByContainerNo(request.getContainerNo()); if (container null) { throw new BizException(箱号不存在请确认是否已录入卸船清单); } // 3. 更新集装箱状态和当前位置 container.setStatus(1); // 在堆场 container.setCurrentPositionType(2); // 堆场 container.setCurrentPositionNo(request.getTargetSlot()); container.setShipId(null); // 已离开船 containerMapper.updateById(container); // 4. 更新堆场贝位状态 YardSlot slot yardSlotMapper.selectByPosition(request.getTargetSlot()); slot.setSlotStatus(1); // 占用 slot.setCurrentContainerNo(request.getContainerNo()); yardSlotMapper.updateById(slot); // 5. 写入操作流水 ContainerOperationLog log new ContainerOperationLog(); log.setContainerNo(request.getContainerNo()); log.setOpType(卸船); log.setFromPosition(船- schedule.getVoyageNo()); log.setToPosition(request.getTargetSlot()); log.setOperatorId(SecurityUtils.getUserId()); operationLogMapper.insert(log); } }这段代码里有几个值得细说的设计点事务边界是整个卸船动作的完整逻辑单元。第3、4、5步必须在一个事务里如果箱子状态更新成功但贝位更新失败会出现箱子状态在堆场但堆场贝位却显示空闲的数据不一致找问题找到哭。加Transactional(rollbackFor Exception.class)任何一步抛异常整体回滚这是数据一致性的第一道防线。为什么第2步用selectByContainerNo而不是直接用前端传的id这是安全考虑。前端传id有可能被篡改但箱号是业务语义明确的标识。我在这个项目里统一约定所有前端能影响数据归属的请求后端都重新通过业务字段查询校验不直接信任前端传的id。4.2 MyBatis动态SQL处理多条件组合查询堆场库存查询页大概是整个系统SQL最复杂的模块。用户可能按照箱号、船名、航次、箱型、状态、时间范围任意组合筛选还要分页。用MyBatis动态SQL写出来select idselectContainerPage resultTypecom.port.vo.ContainerVO SELECT ci.*, ss.ship_name, ss.voyage_no FROM container_info ci LEFT JOIN ship_schedule ss ON ci.ship_id ss.id AND ci.status 0 where if testquery.containerNo ! null and query.containerNo ! AND ci.container_no LIKE CONCAT(%, #{query.containerNo}, %) /if if testquery.shipName ! null and query.shipName ! AND (ss.ship_name LIKE CONCAT(%, #{query.shipName}, %) OR ci.container_no IN ( SELECT container_no FROM container_info WHERE ship_id IN (SELECT id FROM ship_schedule WHERE ship_name LIKE CONCAT(%, #{query.shipName}, %)) )) /if if testquery.containerType ! null and query.containerType ! AND ci.container_type #{query.containerType} /if if testquery.status ! null AND ci.status #{query.status} /if if testquery.startTime ! null AND ci.create_time gt; #{query.startTime} /if if testquery.endTime ! null AND ci.create_time lt; #{query.endTime} /if /where ORDER BY ci.create_time DESC LIMIT #{offset}, #{limit} /select这里有个细节很多人会忽略where标签自动处理了第一个条件前面的AND但如果你在where里写的是if组合当所有条件都为空时where会聪明地去掉整个WHERE子句。这比手动拼接WHERE 11要优雅得多。另外一个实战经验动态查询如果条件特别多、表关联特别复杂我一般会在分页查询之前先做一个count查询拿总数而count查询的SQL和列表查询的SQL要保持完全相同的WHERE条件。所以我在Mapper接口里会写两个方法一个查列表带LIMIT一个查总数不带LIMIT但复用的同一个SQL片段sql idcontainerQueryCondition where !-- 完全相同的一组if判断 -- /where /sql select idselectContainerPage resultType... SELECT ci.*, ss.ship_name FROM container_info ci LEFT JOIN ship_schedule ss ON ci.ship_id ss.id AND ci.status 0 include refidcontainerQueryCondition/ ORDER BY ci.create_time DESC LIMIT #{offset}, #{limit} /select select idcountContainerPage resultTypelong SELECT COUNT(*) FROM container_info ci LEFT JOIN ship_schedule ss ON ci.ship_id ss.id AND ci.status 0 include refidcontainerQueryCondition/ /select用sql片段抽离公共条件是MyBatis实战中保持SQL一致性和可维护性的核心技巧否则你要改一个查询条件忘了同步改count查询分页总数和列表就永远对不上。4.3 并发控制防止两个操作员分配同一个贝位码头现场操作是多人并行的两个中控员可能同时安排箱子进堆场系统必须保证同一个贝位不会被分配两次。这个问题在数据库层面有几种解法方案一悲观锁SELECT ... FOR UPDATEYardSlot slot yardSlotMapper.selectByPositionForUpdate(request.getTargetSlot()); if (slot.getSlotStatus() ! 0) { throw new BizException(该贝位已被占用); }方案二乐观锁版本号UPDATE yard_slot SET slot_status 1, current_container_no ?, version version 1 WHERE id ? AND version #{oldVersion} AND slot_status 0方案三条件更新在SQL层面直接做状态判断int rows yardSlotMapper.occupySlot(request.getTargetSlot(), request.getContainerNo()); if (rows 0) { throw new BizException(该贝位已被占用请刷新后重试); }我在项目里推荐方案三因为它在并发量级适中的场景下最简单高效——一条UPDATE语句带WHERE slot_status 0条件数据库的行锁天然保证了只有一个事务能更新成功影响行数为0就说明被抢占了。不需要额外引入锁机制也不用处理版本号冲突重试。UPDATE yard_slot SET slot_status 1, current_container_no #{containerNo}, update_time NOW() WHERE area_code #{areaCode} AND bay_no #{bayNo} AND row_no #{rowNo} AND tier_no #{tierNo} AND slot_status 0条件更新方案有个前提必须确认affected rows为1才算成功。MyBatis的update方法返回的是int这里直接判断即可。这是我从库存扣减场景里学到的通用经验——用一条带条件的原子UPDATE解决并发竞争远比先SELECT再UPDATE安全得多。5. Vue前端从登录鉴权到堆场看板的实现思路后端接口设计得再好前端页面难用整个系统在用户眼里就是不好用。码头作业现场的大哥大姐们可没耐心学习复杂的交互逻辑他们要的是一屏看到所有关键信息、三步之内完成关键操作。5.1 路由权限控制不同角色看到不同菜单系统有船务调度、中控操作员、闸口管理员、商务计费四个主要角色菜单和页面权限必须隔离。我的实现思路是基于Vue Router的beforeEach钩子做动态路由router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token) { // 未登录只能去登录页 if (to.path /login) { next() } else { next(/login) } return } // 已登录但没拉取过用户信息先拉取后动态添加路由 if (!store.state.userInfo) { store.dispatch(fetchUserInfo).then(() { // 根据角色过滤出可访问的路由 const accessRoutes filterRoutes(store.state.userInfo.role) router.addRoutes(accessRoutes) next({ ...to, replace: true }) }) return } next() })动态路由的实现细节我在前端维护了一份完整路由表每个路由配置了meta.roles登录后根据当前角色过滤。一个容易踩的坑是刷新页面后动态路由丢失——因为Vue实例已经创建addRoutes之后重新进页面路由不存在。解决方法是把用户信息和权限列表持久化到localStorage刷新时重新拉取再addRoutes。5.2 堆场总览页用组件化拆解高密度信息堆场总览是整个系统最直观的页面需求是按区域显示贝位分布每个贝位一个色块绿色是空闲、红色是占用、黄色是预留鼠标悬浮显示箱号点击弹出箱信息抽屉。这个页面如果用传统方式在mounted里一次性拉全量数据渲染几百个贝位DOM节点会让页面卡顿操作员频繁查询会加重后端压力。我采用的方案列表虚加载只渲染可视区域内的贝位滚动时动态加载从后端只拉摘要数据详情通过点击时单独请求组件拆分如下template div classyard-overview div v-forarea in areas :keyarea.code classarea-block h4区域 {{ area.code }}/h4 div classbay-grid YardSlotComponent v-forslot in area.slots :keyslot.id :slot-dataslot clickopenDetail(slot) / /div /div /div /templateYardSlotComponent内部用computed根据slot.slotStatus计算颜色样式这样数据更新时视图自动响应不需要手动操作DOM class。5.3 与后端联调时最容易忽略的问题这个项目前后端联调阶段我总结了一些非常实用的避坑经验跨域问题。开发环境下前端跑在8080端口、后端跑在8085端口直接请求会被浏览器拦截。我在后端做了全局CORS配置而不是在前端配proxy。原因是生产环境通常用Nginx反代前后端同域开发环境单独加proxy反而容易造成开发环境正常、生产环境忘记配置的尴尬。后端CORS配置是一劳永逸的。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .maxAge(3600); } }时间格式化不一致。MySQL里是datetimeSpringBoot返回给前端默认是UTC格式的2024-06-01T12:00:00.00000:00要显示成2024-06-01 20:00还得前端转一次。我直接在Jackson配置里统一了格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8大字段序列化性能。集装箱详情页要返回操作流水list和费用list如果直接返回嵌套对象JSON串可能几百KB前端渲染也会卡。我的做法是在VO层做裁剪详情页需要什么字段就返回什么字段不偷懒直接返回Entity。6. 部署落地与源码解读从下载到跑通的完整指南标题里写了完整版但拿到源码跑不起来的情况我见得太多了。这个项目在本地跑通的完整过程值得详细记录。6.1 环境准备清单软件版本要求用途JDK1.8编译运行SpringBoot后端Maven3.6依赖管理Node.js14构建Vue前端MySQL5.7 / 8.0业务数据库Redis5.0根据源码是否使用缓存、会话我在部署时遇到最多的坑有三个MySQL 8.0的驱动问题。如果你本机装的是MySQL 8.0pom.xml里的驱动必须用com.mysql.cj.jdbc.Driver8.0以下的驱动类com.mysql.jdbc.Driver在新版本里已经移除了。同时连接串要带上serverTimezoneAsia/Shanghai否则报时区错误。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/port_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_passwordallowPublicKeyRetrievaltrue这个参数。MySQL 8.0默认使用caching_sha2_password认证客户端首次连接时如果没配置SSL会有如下报错Public Key Retrieval is not allowed。加上allowPublicKeyRetrievaltrue能解决。前端npm install卡住。这个几乎每个人都会碰到网络原因导致依赖下载超时。解决方式是切换npm镜像源npm config set registry https://registry.npmmirror.com安装依赖后开发环境下执行npm run dev启动Vite开发服务器默认端口5173生产构建执行npm run build产物在dist目录。6.2 源码目录结构导读拿到源码后建议按照下面的路径顺序阅读理解会顺畅很多port-management/ ├── backend/ # SpringBoot后端 │ ├── src/main/java/com/port/ │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务逻辑层 │ │ ├── mapper/ # MyBatis Mapper接口 │ │ ├── entity/ # 数据库实体 │ │ ├── vo/ # 视图对象返回给前端 │ │ ├── dto/ # 请求参数对象 │ │ ├── config/ # 配置类CORS、拦截器、安全 │ │ └── common/ # 统一返回体、异常处理等 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML文件 │ └── application.yml ├── frontend/ # Vue前端 │ ├── src/ │ │ ├── views/ # 页面级组件 │ │ ├── components/ # 公共组件如堆位格子 │ │ ├── router/ # 路由配置 │ │ ├── store/ # 全局状态 │ │ └── api/ # 接口请求封装 └── sql/ # 数据库初始化脚本 └── init.sql阅读顺序建议init.sql-entity-mapper-service-controller- 前端api-views。先建立数据模型认知再理解接口怎么处理数据最后看页面怎么调接口整个链路贯穿下来才算真正掌握了项目。我的一个习惯拿到源码先全局搜TODO和FIXME。很多开源项目会把不完善的地方标注出来这些往往是理解项目设计意图的关键线索。6.3 从开发到生产性能优化与常见故障排查项目跑通只是第一步真正做到企业可用还有一些优化工作不可或缺。数据库连接池参数调优。SpringBoot默认的HikariCP配置比较保守对标码头这种高峰时段集中操作场景可以做如下调整spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000慢SQL日志开启。如果发现某个页面加载慢先看是不是SQL问题。在application.yml里配mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.port.mapper: debug这样MyBatis会在控制台打印完整SQL和参数排查问题效率翻倍。定时任务清理日志表。操作流水表增长很快建议写一个定时任务定期把90天前的流水迁移到历史表或者直接按月份做分区表ALTER TABLE container_operation_log PARTITION BY RANGE (TO_DAYS(op_time)) ( PARTITION p202401 VALUES LESS THAN (TO_DAYS(2024-02-01)), PARTITION p202402 VALUES LESS THAN (TO_DAYS(2024-03-01)), PARTITION p_future VALUES LESS THAN MAXVALUE )分区之后按时间范围查询只需要扫描对应分区性能提升非常明显。注意分区字段必须是主键的一部分所以这张表的主键需要调整为(id, op_time)联合主键。一个诡异的502排查案例。生产环境部署后偶尔出现Nginx返回502。排查发现是Nginx的proxy_read_timeout默认60秒而后端某个查询超时被中断。原因是接口里有个统计报表做了多张大表的全量聚合数据多的时候跑了几十秒。最终方案是优化SQL加索引 把同步查询改为异步预生成报表 调整超时时间。三个措施一起上问题彻底解决。7. 实测中的那些意外情况与应对经验客观说这个系统在开发过程中遇到的最棘手的问题不在技术本身而在于业务规则的边界情况。分享几个典型的意外场景业务场景一卸船时发现实际箱数和船图不一致。系统里录入了1000个箱子实际卸下来发现只有998个少了2个。这种时候是继续卸还是停下来最终的方案是系统支持卸船差异功能操作员可以单独标记某个计划内箱子未到等整船作业完成后生成差异报告。这种设计在需求文档里永远不会写但现场一定会遇到提前设计好比塞后补丁要稳妥得多。业务场景二同一个箱子被两个操作员同时操作。堆场移位和提柜动作并发时可能出现状态更新的覆盖。我们最终在修改集装箱状态的所有Service方法里统一加了一个前置校验比较当前库里的status和上次前端拿到的status不一致就提示容器状态已被其他操作修改请刷新后重试。这就是乐观锁在业务层的应用。业务场景三半夜来了加急船但IT负责人不在。这时候系统稳定性就非常重要了。我的经验是核心操作卸船、装船、进闸、出闸绝对不能因为单个非核心模块出错而整体崩溃。所以我在Service层做降级处理——比如费用计算模块的异常抛出在Facade层被捕获不影响卸船主流程只是记录error log等白天人工处理。这些经验是我做多个管理系统沉淀下来的也是企业级和demo之间的本质差距企业级系统不要求功能炫酷但要求边界情况可控、异常状态可追踪、核心流程不中断。最后再分享一个使用技巧拿到这个完整源码后不要急着改代码。先把init.sql里的数据跑起来用自带的测试账号登录把船期创建 - 卸船计划录入 - 卸船操作 - 堆场查询 - 提柜出闸这条完整链路走一遍。当你能用自己的话解释每一步背后数据库里发生了什么变化时这套系统的架构思想就真正变成你自己的了。