Compare commits

...
Author SHA1 Message Date
ouyangxq 9726e5c3ba style(admin): widen the opted-in simulation container dialog
Only widen the dense dialog to 1000px; retain other consumers at 560px.
2026-10-20 18:06:52 +08:00
liuyihe 01ecd63991 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-20 17:25:54 +08:00
liuyihe 3598310237 优化 2026-10-20 17:24:50 +08:00
liuyihe 6b3286fa5a 优化 2026-10-20 16:58:25 +08:00
ouyangxq f155976f52 优化 2026-10-20 16:45:09 +08:00
ouyangxq db32f31cc2 优化 2026-10-20 15:48:27 +08:00
ouyangxq 489b1bcd16 优化 2026-10-20 15:48:23 +08:00
ouyangxq 291535e242 style(admin): 放大订单、费用、测量与库位弹框宽度
- 订单:审核/扣货/合柜报关/同柜组成员 720→880,编辑备注 880→1000,导入旧系统订单 760→880
- 入库费用:新增/编辑费用 880→1000,驳回费用 720→880
- 测量:手动录入箱规 880→1000,确认称重图片 920→1080
- 库位:新增/编辑库位 880→1000
- 同步 InboundFeeDetailManager、LocationsView 的宽度断言
2026-10-20 15:26:18 +08:00
liuyihe 1e80470078 Merge origin/release/test and resolve frontend conflicts 2026-10-20 14:58:58 +08:00
liuyihe 6f7f8e7632 优化 2026-10-20 14:52:06 +08:00
ouyangxq 03b1e7163e 优化 2026-10-20 14:25:01 +08:00
liuyihe 5679b156ca 优化 2026-10-20 14:00:49 +08:00
ouyangxq 3879a47673 docs(admin): align fee-item and measurement guides with current contracts
按现行 OpenAPI 与计费规则修正两处业务偏差,并统一重复规则的维护位置。

- 费用项目:feeCode 允许编辑(OpenAPI 仅约束格式与长度),删除“编码不可变”表述;defaultPrice 改为“带入初值、允许人工修改”,明确为空与 0 的语义及不追改已有费用
- 入库测量:区分平台链路(PlatformInboundMeasurementController -> InboundMeasurementService/MeasurementSubmissionService)与设备上报(MojiaMeasurementOpenApiService)
- 箱规算法与 actualMeasurementRows 口径不再在模块指引重复正文,改为指向详细索引
2026-10-20 13:41:28 +08:00
ouyangxq b34626bd4b docs(admin): add module-level AGENTS indexes for all 33 modules
新增 29 个模块级 AGENTS.md,补齐入口表指向,使 33 个业务模块均可从入口直达页面与接口。

- 每个模块指引统一为:路径基准 / 页面与入口表 / 关键边界 / 验证入口
- 入口表 35 行改为指向 modules 下的 AGENTS.md,补齐费用项目、审计日志、尺寸配置、装柜照片等缺失词条
- 详细索引最长行从 1315 字符压缩到 699 字符,规则正文移入新增的“货柜工作台规则”小节
- 仅索引文档,未改动业务代码
2026-10-20 12:45:00 +08:00
liuyihe 7005228b52 优化 2026-10-20 11:54:36 +08:00
liuyihe 836e210b09 Merge origin/release/test and preserve local frontend changes 2026-10-20 11:48:38 +08:00
liuyihe 41a498657c 优化 2026-10-20 11:33:39 +08:00
ouyangxq f4224e5d3a fix(admin): present dispatch vehicle statuses and scoped notices 2026-10-20 10:17:17 +08:00
ouyangxq 3267679758 style(admin): unify dialog layouts and dense readonly presentation 2026-10-20 10:11:31 +08:00
ouyangxq 7ff7c9914f Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-19 17:56:41 +08:00
ouyangxq 5c28cd7c79 优化 2026-10-19 17:56:31 +08:00
liuyihe d2f2ecf57a 优化 2026-10-19 17:07:55 +08:00
ouyangxq 5a46198bf0 feat(appointments): add FBA creation and destination filtering 2026-10-19 17:02:27 +08:00
liuyihe ada663128a 优化 2026-10-19 15:59:33 +08:00
liuyihe 17cb1373e1 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-19 15:59:17 +08:00
liuyihe 29f887496a 优化 2026-10-19 15:59:14 +08:00
ouyangxq b92e5379bc feat(transport): align admin demand form with client and select customer separately 2026-10-19 15:50:39 +08:00
ouyangxq 767c6b7589 feat(looseboard): align platform route contacts and required times 2026-10-19 15:11:15 +08:00
ouyangxq f7f6637c45 feat(trucking): align platform truck contacts vehicle and service times 2026-10-19 14:38:47 +08:00
ouyangxq 05cf82dbbc feat(appointments): restore simplified four-type calendar 2026-10-19 12:20:13 +08:00
liuyihe 8ae1e5d0d4 优化 2026-10-19 10:23:38 +08:00
ouyangxq 4bb21641d9 fix(appointments): resume existing batch dispatch after refresh 2026-10-19 08:44:26 +08:00
ouyangxq 67d917010d fix(appointments): handle synchronization and transport continuation 2026-10-18 22:29:06 +08:00
ouyangxq a5a1976924 fix(dispatch): align dispatch actions and native return workflows 2026-10-18 16:37:37 +08:00
liuyihe 4806f88d7c 优化 2026-10-16 18:25:49 +08:00
liuyihe 2705fdbc49 优化 2026-10-16 17:43:36 +08:00
liuyihe ce7f6cee02 优化 2026-10-16 16:52:43 +08:00
liuyihe fbee53c9de 优化 2026-10-16 16:19:22 +08:00
ouyangxq 45f662ecf2 feat(appointment): complete customer receiving and trip continuation workflows 2026-10-16 15:41:56 +08:00
ouyangxq 148085286d fix(delivery): show complete delivery numbers and clarify pickup routing 2026-10-15 21:40:39 +08:00
ouyangxq f6638ca6c6 fix(delivery): display and filter all pickup fulfillment methods 2026-10-15 21:05:58 +08:00
ouyangxq baee81f459 fix(appointment): improve multiline action button spacing 2026-10-15 19:36:13 +08:00
ouyangxq 9f51392754 fix(appointment): restore overlay panel and compact calendar controls 2026-10-15 19:23:16 +08:00
liuyihe 8dc0d2d3b8 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-15 18:25:24 +08:00
liuyihe 538970797d 优化 2026-10-15 18:25:23 +08:00
ouyangxq 60378fea9b fix(auth): preserve exact account IDs when decoding JWT claims 2026-10-15 18:08:30 +08:00
ouyangxq f9193a3564 fix(appointment): show readable source and preview blockers 2026-10-15 17:33:36 +08:00
ouyangxq db1e9ee27b fix(appointment): type direct-unload visibility callback 2026-10-15 17:25:01 +08:00
ouyangxq b769d2479c feat(appointment): integrate work calendar and original business actions 2026-10-15 17:11:03 +08:00
liuyihe af2e1481a6 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-15 15:45:18 +08:00
liuyihe 092690791d 优化 2026-10-15 15:45:16 +08:00
ouyangxq 9a10fb6657 feat(selfop): submit turnover exports to backend download center 2026-10-15 14:59:44 +08:00
ouyangxq 68284ad2bf fix(selfop): enable visible turnover rate sorting and formula help 2026-10-15 14:31:24 +08:00
ouyangxq 5e8c17c82f feat(selfop): expose reconstructed inventory evidence in turnover and export 2026-10-15 13:30:38 +08:00
ouyangxq 50b06dca10 feat(selfop): multi-owner turnover filters, CSV export and percentage 2026-10-15 11:14:35 +08:00
ouyangxq 11e348a29f feat(selfop): simplify turnover diagnostics and add zero stock filter 2026-10-15 10:29:43 +08:00
ouyangxq 08fac66fe1 feat(selfop): add volume ranking and refine turnover presentation 2026-10-14 18:19:27 +08:00
ouyangxq 8663a829e2 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-14 17:51:25 +08:00
ouyangxq 2975ba5a7f feat(selfop): show customer and SKU volume in inventory turnover 2026-10-14 17:48:53 +08:00
liuyihe 2ade6bfa3c 优化 2026-10-14 17:40:58 +08:00
ouyangxq 46364d3f89 feat(selfop): show customer names and filter sales and turnover 2026-10-14 16:15:40 +08:00
ouyangxq 2a9c23ebc6 feat(self-operated): add transit column and streamline dashboard ranking explanations 2026-10-14 14:09:24 +08:00
ouyangxq adfb1a5cb3 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-14 14:01:01 +08:00
ouyangxq ff9ca74ba4 fix(self-operated): clarify turnover chart values and coverage threshold 2026-10-14 14:00:35 +08:00
liuyihe cb38114db9 优化 2026-10-14 13:53:15 +08:00
ouyangxq e7411eb03f feat(self-operated): unify completed-day ranges and analytics sorting 2026-10-14 12:39:16 +08:00
liuyihe 6b30e35d07 优化 2026-10-14 12:14:29 +08:00
liuyihe 0830d7a3a3 优化 2026-10-14 11:43:32 +08:00
ouyangxq 6a905419fc fix(self-operated): render dashboard KPIs with omitted unknown values 2026-10-14 11:22:59 +08:00
ouyangxq d8a4160bc7 fix(self-operated): clarify today's unavailable sales and tooltip precision 2026-10-14 11:18:47 +08:00
ouyangxq e0f54edcc9 优化 2026-10-14 11:02:56 +08:00
ouyangxq 3f728cd0b2 fix(selfop): render inventory turnover cells and data status 2026-10-14 09:23:29 +08:00
ouyangxq 3291d34fb8 fix(selfop): label order processing independently of detail requests 2026-10-14 08:57:21 +08:00
ouyangxq 02a9d72ef8 fix: preserve turnover deep-link snapshots and clarify data status 2026-10-14 01:52:20 +08:00
ouyangxq 2cf9129606 优化 2026-10-14 00:07:05 +08:00
ouyangxq 4c4a3db1eb feat(self-operated): add charts and continuous collection status 2026-10-13 20:44:31 +08:00
ouyangxq 698ca34529 fix(self-operated): follow selected dates in hot rankings 2026-10-13 18:47:56 +08:00
ouyangxq 133654327d refactor(self-operated): simplify collection account monitor 2026-10-13 17:29:45 +08:00
liuyihe 260409c071 优化样式 2026-10-13 17:14:56 +08:00
ouyangxq c7d9af0165 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-13 16:54:14 +08:00
ouyangxq bda0743520 feat: focus collection page on account activity and progress 2026-10-13 16:52:09 +08:00
liuyihe 50df363f86 优化 2026-10-13 16:49:08 +08:00
liuyihe c61ae525e1 优化 2026-10-13 14:47:06 +08:00
liuyihe 8bf7b20b89 优化 2026-10-13 13:46:56 +08:00
ouyangxq 1de73b5f1c fix: keep analytics date ranges together and apply atomically 2026-10-13 13:43:16 +08:00
ouyangxq 6bdaff3fdf fix(selfop): distinguish preparation progress from warehouse tasks 2026-10-13 12:50:29 +08:00
ouyangxq 9dcd991ddb feat(selfop): select inventory sales window and cutoff date 2026-10-13 12:36:45 +08:00
ouyangxq 58df0ed9e3 fix(selfop): preserve warehouse navigation and refine overview presentation 2026-10-13 11:06:20 +08:00
ouyangxq b73dc0d410 feat(selfop): add operating dashboard and preserve drilldown context 2026-10-13 00:45:41 +08:00
ouyangxq cd3a32f372 fix(selfop): show inventory observation time range 2026-10-13 00:10:54 +08:00
ouyangxq 600fdcb4b4 fix(selfop): unify collection layout and show details in dialogs 2026-10-12 22:06:45 +08:00
ouyangxq 57c50e6876 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-12 21:37:13 +08:00
ouyangxq 449d0c4965 refactor(self-operated): focus analytics pages on SKU metrics 2026-10-12 21:36:24 +08:00
liuyihe 2186f1f3a6 优化 2026-10-12 21:19:07 +08:00
ouyangxq 533273769d feat: add platform self-operated sales inventory and collection views 2026-10-12 20:33:39 +08:00
liuyihe cc4856ceac 优化 2026-10-12 18:01:41 +08:00
liuyihe 3f6d74dee1 优化 2026-10-12 13:43:21 +08:00
ouyangxq 92bdd32f86 fix(appointments): unify calendar layout and cabinet numbers 2026-10-12 11:45:00 +08:00
ouyangxq 20b4d1425b fix(appointments): ignore stale impact history responses 2026-10-11 21:09:30 +08:00
ouyangxq 43611ecc55 fix: avoid duplicate inquiry fetch on capacity refresh 2026-10-11 19:33:25 +08:00
ouyangxq f97cdad353 fix: show inquiry return text on dispatch detail 2026-10-11 16:11:58 +08:00
ouyangxq 315cb8e663 fix: align appointment viewing and fleet follow-up navigation 2026-10-11 15:33:09 +08:00
ouyangxq ee174f8265 fix: close appointment cancellation bypass and clarify verified cargo 2026-10-11 14:14:49 +08:00
ouyangxq 240aa5d6d0 fix: require appointment cancellation reason and expose history 2026-10-11 13:27:39 +08:00
ouyangxq d77abbef7c fix: align fleet controls and protect appointment retries 2026-10-11 13:07:52 +08:00
ouyangxq 1edb65df77 fix(fleet): fit receipt pallet space controls within dialog 2026-10-11 12:33:02 +08:00
ouyangxq baa69e66a1 fix(fleet): keep capacity fields readable in publish dialog 2026-10-11 11:17:08 +08:00
ouyangxq d7f2d09b88 fix(procurement): align correction actions with shipment state 2026-10-10 19:52:33 +08:00
ouyangxq e124fbcf68 fix(fleet): reflect bound booking in receipt progress 2026-10-10 19:04:09 +08:00
ouyangxq ebff6f8167 fix(appointments): show authoritative timezone and business source 2026-10-10 18:38:08 +08:00
ouyangxq 7f39adf9f5 fix(fleet): lock destination in readonly inquiry dialog 2026-10-10 18:15:39 +08:00
ouyangxq 67bd3e6e53 fix(appointments): show full candidate business identifiers 2026-10-10 17:36:36 +08:00
liuyihe c0155b8293 优化 2026-10-10 16:58:21 +08:00
liuyihe 175d8936b9 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-10 16:12:43 +08:00
liuyihe 081f79a9a1 优化 2026-10-10 16:12:38 +08:00
ouyangxq e404fe1049 fix(appointments): prioritize calendar and collapse data notices 2026-10-10 16:08:25 +08:00
liuyihe c69115f1b7 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-10 15:45:22 +08:00
liuyihe a564850e23 优化 2026-10-10 15:45:20 +08:00
ouyangxq 453549ece1 style(appointments): simplify calendar toolbar and remove redundant heading 2026-10-10 15:38:44 +08:00
ouyangxq be2a754342 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-10 15:34:07 +08:00
ouyangxq 0cb8a5c2b2 fix: align appointment procurement and carpool workflows with agreed views 2026-10-10 15:33:10 +08:00
liuyihe 40cccee266 优化优化 2026-10-10 14:58:15 +08:00
liuyihe 1abc831df1 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-10 14:42:43 +08:00
liuyihe b20c3ac104 优化 2026-10-10 14:42:41 +08:00
ouyangxq 172cb05c78 feat(appointments): select external cargo in shared planning workspace 2026-10-10 14:03:43 +08:00
ouyangxq 2bf561bafe fix(procurement): preserve correction drafts separately from pending commands 2026-10-10 14:01:11 +08:00
ouyangxq 0ca12de2f4 fix: show overnight appointments on each covered calendar day 2026-10-10 12:40:52 +08:00
ouyangxq e3038edd38 fix(appointments): let linked external cargo join shared arrangements 2026-10-10 12:12:10 +08:00
ouyangxq b14d22b55e feat(appointments): project trip plans by origin time zone 2026-10-10 12:01:12 +08:00
ouyangxq 871c8c021b feat(appointments): show pickup appointments with source scope warnings 2026-10-10 11:43:49 +08:00
ouyangxq 33e7e1e41b fix(warehouses): select region time zones instead of UTC offsets 2026-10-10 11:02:51 +08:00
ouyangxq 320c2e4471 fix(appointments): type cargo checkbox value for CI builds 2026-10-10 10:48:25 +08:00
ouyangxq 901431f17a feat(appointments): simplify planning and preserve source warehouse intent 2026-10-10 10:44:07 +08:00
ouyangxq fb062b5ec4 fix(procurement): normalize integer wire fields before commands 2026-10-10 08:48:15 +08:00
ouyangxq 84488adf12 feat(appointment): add shared container followup workspace 2026-10-10 08:42:03 +08:00
ouyangxq b835e52bcb fix(admin): make dispatch business numbers readable 2026-10-10 08:21:03 +08:00
ouyangxq 214b58b642 fix(procurement): render file download actions 2026-10-10 08:11:13 +08:00
ouyangxq df4d3dfd66 fix(admin): render appointment table actions and history 2026-10-10 08:05:21 +08:00
ouyangxq 302cc50a4b feat(procurement): add sample fulfillment operation workspace 2026-10-10 02:19:39 +08:00
ouyangxq 2f23e30e20 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-09 22:24:54 +08:00
ouyangxq e1457a8e57 feat(admin): streamline appointments and fleet intake 2026-10-09 22:05:51 +08:00
liuyihe f047dce10c 优化 2026-10-09 20:39:29 +08:00
liuyihe ebaa265993 优化 2026-10-09 18:03:34 +08:00
ouyangxq 88dc4852c6 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-09 17:36:33 +08:00
ouyangxq 3df3f874db 优化 2026-10-09 17:36:32 +08:00
liuyihe 692beff782 优化 2026-10-09 17:27:47 +08:00
ouyangxq 7a9e91b7ce Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-09 16:12:00 +08:00
ouyangxq 00a186b37c feat(lastleg): expose commercial source and booking references 2026-10-09 16:11:59 +08:00
ouyangxq 826d444afa Merge simplified fleet publication into TEST release 2026-10-09 15:32:03 +08:00
ouyangxq 3a917324f1 feat(fleet): publish capacity from a single guided dialog 2026-10-09 15:31:57 +08:00
liuyihe 6475b7b12d 优化 2026-10-09 15:25:19 +08:00
ouyangxq f405452c37 优化 2026-10-09 13:33:57 +08:00
ouyangxq 7500f37846 优化 2026-10-09 12:39:57 +08:00
ouyangxq 73c29f5264 fix: prevent calendar stretching with day details 2026-10-09 12:09:50 +08:00
ouyangxq 8a2f3378f6 fix: keep appointment calendar day previews compact 2026-10-09 12:03:25 +08:00
ouyangxq 8bf0e2260e fix: distinguish appointment deletion errors 2026-10-09 11:59:29 +08:00
liuyihe 9c4512db6a 优化 2026-10-09 11:48:40 +08:00
ouyangxq 9ab8795008 优化 2026-10-09 11:47:24 +08:00
ouyangxq 9ed5448fe6 优化 2026-10-09 11:43:38 +08:00
ouyangxq 2caeb12024 fix: align appointment calendar dates at top of cells 2026-10-09 11:31:08 +08:00
ouyangxq 6e669a1377 优化 2026-10-09 11:11:20 +08:00
ouyangxq 2998c62099 feat: add appointment calendar and cross-customer booking 2026-10-09 11:01:41 +08:00
liuyihe c90a191d83 优化 2026-10-08 20:25:25 +08:00
liuyihe 115461683b 优化 2026-10-08 20:16:19 +08:00
liuyihe 9c70eab606 优化 2026-10-08 18:13:43 +08:00
ouyangxq 5946bdfcf7 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-08 17:08:04 +08:00
ouyangxq 7b0df1db37 优化 2026-10-08 17:08:03 +08:00
liuyihe 73af3affac restore overseas warehouse navigation entries 2026-10-08 15:52:38 +08:00
liuyihe 3c3a7fcd55 merge admin frontend changes 2026-10-08 15:45:26 +08:00
liuyihe 8fa397bb5e 优化 2026-10-08 15:34:19 +08:00
ouyangxq cc37a42298 fix(overseas): restore first-leg before and after sailing tabs 2026-10-08 15:12:49 +08:00
ouyangxq 6551b703cb fix(fleet): align capacity availability and dispatch review navigation 2026-10-08 14:11:54 +08:00
ouyangxq f24b592705 fix(admin): restore loading list detail page navigation 2026-10-08 13:59:20 +08:00
ouyangxq d7911ad09d fix(admin): restore manifest menu and overseas container access 2026-10-08 11:16:58 +08:00
ouyangxq 080042d941 test(fleet): verify offer-scoped quote selection and pricing 2026-10-07 23:43:14 +08:00
ouyangxq 9ff2720721 fix(admin): keep fleet arrangement references fully readable 2026-10-07 22:45:49 +08:00
ouyangxq e4e78796a1 fix(orders): display unknown pooled freight facts without defaults 2026-10-07 19:44:42 +08:00
ouyangxq ba6d0ff13f fix(fleet): guard workspace navigation with unsaved pool drafts 2026-10-07 18:53:06 +08:00
ouyangxq 2bc095ecac fix(fleet): preserve drafts before dialog close and respect dashboard permissions 2026-10-07 18:21:03 +08:00
ouyangxq 2477667c27 feat(fleet): add internal sales capacity pool workflow 2026-10-07 16:46:57 +08:00
ouyangxq 04e1d7dbfa fix(admin): unify fleet driver trip and operation list layouts 2026-10-07 12:24:21 +08:00
ouyangxq ca7f6aa414 fix(deploy): support isolated temporary production release 2026-10-07 11:09:41 +08:00
ouyangxq 899978e53d test(admin): cover late driver detail and history responses 2026-10-05 18:39:25 +08:00
ouyangxq cb5f0d1bec feat(fleet): show authoritative trip receivables and receipts on demand 2026-10-05 11:58:16 +08:00
ouyangxq 110c2e0be2 fix(transfer): format optional relabel summary in order detail 2026-10-05 07:28:36 +08:00
ouyangxq 93f193c5d6 feat(transfer): expose stock outbound and durable receipt recovery UI 2026-10-05 06:16:21 +08:00
ouyangxq 578df0f927 fix(overseas): restore pending resource confirmation from original command 2026-10-05 05:40:21 +08:00
ouyangxq 28eb43893a fix(dispatch): submit and preserve independent loading groups 2026-10-05 03:30:06 +08:00
ouyangxq 058b0778a2 fix(overseas): reuse original resource command after reopening conversion 2026-10-05 02:13:24 +08:00
ouyangxq ea24496b11 fix(overseas): normalize transfer inventory quantities at API boundary 2026-10-04 22:26:36 +08:00
ouyangxq 939cc11d7b feat(overseas): select stock transfer sources and follow relabel 2026-10-04 21:19:11 +08:00
ouyangxq d3a2671fd7 fix(auth): revoke expired admin sessions without restoring login 2026-10-04 19:41:45 +08:00
ouyangxq cc973c72c0 fix(overseas): expose permission-gated cargo discard review 2026-10-04 16:57:16 +08:00
ouyangxq 1d73a40a07 fix(overseas): synchronize receipt input accessibility with command lock 2026-10-04 12:43:07 +08:00
ouyangxq a8ff0ef1de feat(overseas): show return receipt and redispatch history 2026-10-04 10:48:38 +08:00
ouyangxq 2c2e19c4ea fix(fleet): mount measured controls after record loads 2026-10-04 10:07:45 +08:00
ouyangxq caf9fd89d4 fix(fleet): show contained operation-record tab panels 2026-10-04 01:48:59 +08:00
ouyangxq f4d58e669b fix(drivers): isolate cached detail requests and reject absent route IDs 2026-10-04 01:02:28 +08:00
ouyangxq 5c4a381d87 fix(drivers): refresh cached list and summary when returning from details 2026-10-03 23:44:35 +08:00
ouyangxq 9ec1fb549e fix(delivery): preserve nullable signed quantity in admin contract 2026-10-03 23:10:44 +08:00
ouyangxq 6df6f6dabc fix(fleet): refresh negotiated price controls when reopening booking 2026-10-03 20:37:03 +08:00
ouyangxq 60dd86ab88 fix(fleet): leave unknown vehicle fuel consumption empty 2026-10-03 19:41:55 +08:00
ouyangxq 049f895b44 fix(fleet): ignore stale vehicle list responses 2026-10-03 19:04:31 +08:00
ouyangxq 102194460c fix(fleet): type exception target selection handlers 2026-10-03 18:55:06 +08:00
ouyangxq 4dc497c8ca feat(fleet): complete exception processing and trip evidence interfaces 2026-10-03 16:40:36 +08:00
ouyangxq 9fa73f50d6 fix(dispatch): preserve return sources when restoring prearrangement drafts 2026-10-03 15:57:53 +08:00
ouyangxq 6a135c3982 fix(fleet): distinguish signed returned and remaining trip cargo 2026-10-03 15:00:10 +08:00
ouyangxq 5641ea0f7e feat(fleet): select versioned sales quotes and isolate cost drafts 2026-10-03 13:20:35 +08:00
ouyangxq 3440f9dd11 fix(fleet): preserve retry intent and restore inquiry editing 2026-10-03 13:00:32 +08:00
ouyangxq 0b5094dd8f feat(fleet): connect operations quotes costs and handover workspaces 2026-10-03 12:43:25 +08:00
ouyangxq 863ad5249c fix: keep schedule form within the dialog body 2026-10-03 11:42:26 +08:00
ouyangxq 8bae21dea2 fix: restore original-container sailing schedule editing 2026-10-03 11:14:11 +08:00
ouyangxq 1a52cc0817 fix(fleet): normalize nullable maintenance fields before editing 2026-10-03 10:56:47 +08:00
ouyangxq fa82710e6b fix(fleet): clarify maintenance record remark hint 2026-10-03 10:54:05 +08:00
ouyangxq 8c462cef17 fix(fleet): label missing delivery customer names 2026-10-03 10:33:34 +08:00
ouyangxq f7b84ea887 feat(fleet): show vehicle delivery and reassignment records 2026-10-03 10:12:55 +08:00
ouyangxq 94b8b8b31c fix(fleet): fit booking action column to trip stage 2026-10-03 06:27:30 +08:00
ouyangxq 22dcae616c fix(fleet): show placeholders for unassigned trip fields 2026-10-03 05:02:39 +08:00
ouyangxq dc35288a1b feat(fleet): integrate trip management and fee coverage workflows 2026-10-03 04:35:55 +08:00
ouyangxq bca08f8749 fix(orders): send version and idempotency key for platform cancellation 2026-10-02 23:16:19 +08:00
ouyangxq 678bccd4d1 fix(admin): distinguish linked bookings from missing channel names 2026-10-02 13:39:15 +08:00
ouyangxq f50850f001 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-30 14:27:41 +08:00
ouyangxq 8c51e9c1cf 优化 2026-09-30 14:01:58 +08:00
liuyihe 34a9861681 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-29 21:01:41 +08:00
liuyihe f8bddeeaf4 merge admin changes into release/test 2026-09-29 21:01:37 +08:00
ouyangxq f14e1a2ea3 接入双PDA独立H5测试入口 2026-09-29 20:54:00 +08:00
liuyihe 7edc07d235 优化 2026-09-29 20:52:21 +08:00
ouyangxq a3005d1c02 优化 2026-09-29 18:47:41 +08:00
ouyangxq 2bf4353b1b 优化 2026-09-29 17:56:00 +08:00
ouyangxq 4ead51a5fe fix: record customs results in Shanghai business time 2026-09-29 15:45:37 +08:00
ouyangxq ae13afd3d7 Merge remote-tracking branch 'origin/release/test' into hotfix 2026-09-29 13:50:05 +08:00
ouyangxq 6bada2aca9 fix(admin): clarify customs results and apply dialog density 2026-09-29 13:50:05 +08:00
ouyangxq 1324cf16ce feat(admin): support manual customs exception reasons 2026-09-29 04:20:58 +01:00
ouyangxq 584b74e5e1 test(admin): active price book sailing channels 2026-09-29 04:18:46 +01:00
ouyangxq 5279fc774a fix(admin): load sailing channels from active price book 2026-09-29 04:17:43 +01:00
ouyangxq 8fe256372c fix(admin): separate sailing search and actions 2026-09-29 03:58:24 +01:00
ouyangxq ea35a12f8b fix(admin): separate sailing search and actions 2026-09-29 03:58:14 +01:00
ouyangxq f89a04ad2f fix(admin): improve calendar toolbar breakpoint 2026-09-29 03:52:50 +01:00
ouyangxq 5ad8c5c462 fix(admin): improve calendar toolbar breakpoint 2026-09-29 03:52:40 +01:00
ouyangxq ab5a64c6f7 fix(admin): compact sailing calendar toolbar 2026-09-29 03:49:09 +01:00
ouyangxq a903020610 fix(admin): compact sailing calendar toolbar 2026-09-29 03:48:49 +01:00
ouyangxq d7489a20be fix(admin): type sailing warehouse change handler 2026-09-28 17:11:47 +01:00
ouyangxq 365a7f7bc4 fix(admin): type sailing warehouse change handler 2026-09-28 17:11:10 +01:00
ouyangxq 0798143bad fix(admin): reuse master data in sailing forms 2026-09-28 17:09:46 +01:00
ouyangxq 8c0daaa5db fix(admin): reuse master data in sailing forms 2026-09-28 17:05:47 +01:00
liuyihe 86601fd45f Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-28 17:26:28 +08:00
liuyihe e1b2f5155a 优化提交 2026-09-28 17:26:26 +08:00
admin db920660bc Merge pull request 'Release/test' (#11) from release/test into main
Reviewed-on: #11
2026-09-28 15:59:28 +08:00
ouyangxq e962577eca fix(admin): align test contracts and sailing integrations 2026-09-28 15:24:33 +08:00
ouyangxq 730defabca feat(customs): show broker submission state in admin 2026-09-28 13:21:59 +08:00
ouyangxq fad49a07a8 fix(admin): preserve sailing metadata and generation choices 2026-09-28 12:30:23 +08:00
ouyangxq ffe91a1936 feat(admin): show customs receipt history 2026-09-28 09:49:53 +08:00
ouyangxq 5cc1512a28 fix(admin): advance sailing copy selection 2026-09-27 23:41:28 +08:00
ouyangxq 3cf676cbbd fix(admin): clarify weekly sailing defaults 2026-09-27 22:58:22 +08:00
ouyangxq 5a90503542 fix(admin): start weekly generation from today 2026-09-27 22:50:58 +08:00
ouyangxq 62c9eb58be feat(admin): improve sailing cutoff and weekly generation 2026-09-27 22:45:41 +08:00
ouyangxq 524a23b488 feat(admin): show adjacent sailing merge reference 2026-09-27 22:27:28 +08:00
ouyangxq 517aad5e5e fix(admin): clarify sailing display data sources 2026-09-27 22:18:55 +08:00
ouyangxq a34feddd4a feat(admin): narrow sailing calendar to read-only display 2026-09-27 22:07:54 +08:00
ouyangxq 855d8c0d5d fix(admin): create declaration batch after broker handoff 2026-09-27 20:48:19 +08:00
ouyangxq 29cb0bd57a feat(admin): add formal customs result actions 2026-09-27 20:41:39 +08:00
ouyangxq 45e1e9b509 fix(admin): send template pagination params 2026-09-27 19:12:17 +08:00
ouyangxq ec6de70e67 feat(admin): add sailing calendar management 2026-09-27 18:55:19 +08:00
ouyangxq 4a0dd3b597 fix(admin): align customs document workflow status 2026-09-26 17:31:29 +08:00
ouyangxq 47bd39d2d5 feat(admin): gate final customs upload on broker submission 2026-09-26 14:22:45 +08:00
ouyangxq d2e063ea3f feat(admin): manage customs receipt and final files 2026-09-26 13:28:48 +08:00
ouyangxq 391369a3d0 优化 2026-09-24 17:02:36 +08:00
ouyangxq 945a3d3230 完善退税报关回执交互 2026-09-24 16:29:31 +08:00
ouyangxq 4e37dc3330 优化 2026-09-24 14:07:48 +08:00
liuyihe 3815eab85e Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-24 08:55:55 +08:00
ouyangxq 2934a795ad 优化 2026-09-24 08:54:28 +08:00
ouyangxq ff0f388daf 优化 2026-09-23 18:26:09 +08:00
ouyangxq c25aaaca53 优化 2026-09-23 17:47:54 +08:00
ouyangxq 9da23280e9 Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-23 17:46:08 +08:00
ouyangxq 6b7a4a097d 优化 2026-09-23 17:46:01 +08:00
liuyihe 60eff4f8ab Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-23 11:25:37 +08:00
liuyihe 409d8b3f57 贴标优化0923 2026-09-23 11:25:23 +08:00
ouyangxq 10e20f896a 优化 2026-09-23 09:59:46 +08:00
ouyangxq db92d1dfe1 优化 2026-09-23 08:59:17 +08:00
ouyangxq 841741dc83 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-22 14:27:18 +08:00
ouyangxq 5035e02357 Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-22 14:26:53 +08:00
ouyangxq 4dd1b83a6b 优化 2026-09-22 14:26:49 +08:00
liuyihe f0ee25ca18 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-22 13:52:10 +08:00
liuyihe fed257a720 优化 2026-09-22 13:51:58 +08:00
ouyangxq 33fc30911e feat: add lingxing connection test action 2026-09-22 11:44:07 +08:00
ouyangxq 4391a69c7d feat: show lingxing sync switch status 2026-09-22 11:33:08 +08:00
liuyihe 3b37249c47 合并 release/test 管理前端冲突 2026-09-22 09:42:02 +08:00
ouyangxq 358ad38335 feat: add overseas warehouse location management 2026-09-22 09:38:56 +08:00
liuyihe 2e2f3ed2d2 修改提交 2026-09-22 08:47:43 +08:00
ouyangxq 3cb8b5b70e fix: keep mojia sku sync statuses to three states 2026-09-21 23:32:12 +08:00
ouyangxq 12dc5dfd82 feat: add mojia sku sync admin page 2026-09-21 23:19:17 +08:00
admin e93d00b79e Merge pull request 'Release/test' (#10) from release/test into main
Reviewed-on: #10
2026-09-21 11:36:04 +08:00
ouyangxq e6771e8e86 优化 2026-09-21 11:17:11 +08:00
ouyangxq 3e8fa3c5d7 fix: allow manual FBA quote when rate is missing 2026-09-21 10:44:14 +08:00
admin 73dd411763 Merge pull request 'fix: keep Admin BaoTa proxy stable' (#9) from release/test into main 2026-09-20 23:41:52 +08:00
ouyangxq b9e5bf3fec fix: preserve stable Admin proxy port 2026-09-20 23:39:39 +08:00
admin b529430ea2 Merge pull request 'feat: production blue-green deployment for Admin' (#8) from release/test into main
Reviewed-on: #8
2026-09-20 22:23:33 +08:00
ouyangxq 3f78e1d087 优化 2026-09-20 22:13:56 +08:00
ouyangxq 7586f9d783 优化 2026-09-20 22:04:59 +08:00
ouyangxq 1cc8ae44c5 优化 2026-09-20 21:52:12 +08:00
ouyangxq 6e0144d7cd feat: add production blue-green frontend deployment 2026-09-20 21:44:56 +08:00
ouyangxq 592f01eb1f 优化 2026-09-20 19:57:51 +08:00
admin f24ed30ae8 Merge pull request 'Release/test' (#7) from release/test into main
Reviewed-on: #7
2026-09-20 19:49:08 +08:00
ouyangxq db9e496b35 优化 2026-09-20 19:45:27 +08:00
ouyangxq 2e9e8819b6 优化 2026-09-20 19:33:53 +08:00
ouyangxq 6bcfb6ba0b 优化 2026-09-20 18:25:17 +08:00
liuyihe be0f712dd6 Merge remote-tracking branch 'origin/release/test' into release/test 2026-09-20 15:51:48 +08:00
liuyihe aefcd2565f fix admin frontend build after branch merge 2026-09-20 15:50:15 +08:00
ouyangxq 22853336b6 优化 2026-09-20 15:47:56 +08:00
liuyihe 940010dcc2 merge release test changes and preserve local frontend updates 2026-09-20 15:23:26 +08:00
ouyangxq 53c887d353 优化 2026-09-20 15:03:28 +08:00
ouyangxq 158ea8c8ea 优化 2026-09-20 14:43:31 +08:00
ouyangxq 810b4e11f5 优化 2026-09-20 14:26:55 +08:00
ouyangxq 60a41af3ac 优化 2026-09-20 14:22:12 +08:00
admin 19a64fbaa5 Merge pull request '优化' (#6) from release/test into main
Reviewed-on: #6
2026-09-20 10:34:26 +08:00
ouyangxq 1305753b71 优化 2026-09-20 10:06:23 +08:00
admin ec4b31a46f Merge pull request '优化' (#5) from release/test into main
Reviewed-on: #5
2026-09-20 09:20:19 +08:00
ouyangxq e94915ced3 优化 2026-09-20 09:17:56 +08:00
admin 3fff8c8ea0 Merge pull request 'Release/test' (#4) from release/test into main
Reviewed-on: #4
2026-09-19 22:57:39 +08:00
ouyangxq ad8e1fd2c2 优化 2026-09-19 22:54:22 +08:00
ouyangxq 3054096995 优化 2026-09-19 17:30:31 +08:00
ouyangxq 3475cdfe49 优化 2026-09-19 17:02:17 +08:00
ouyangxq 7e3cd93425 优化 2026-09-18 18:50:53 +08:00
admin 92ec1e11b1 Merge pull request 'Release/test' (#3) from release/test into main
Reviewed-on: #3
2026-09-18 18:03:16 +08:00
ouyangxq 1dd139c1c6 优化 2026-09-18 18:02:33 +08:00
ouyangxq 24fa3d1c9d 优化 2026-09-18 16:48:40 +08:00
ouyangxq e10d0534ae fix: show dropship outbound measurements 2026-09-18 15:23:43 +08:00
ouyangxq f64dedfb52 fix: resolve dispatch weight type duplication 2026-09-18 15:03:19 +08:00
ouyangxq b51a847306 test: cover overseas row actions 2026-09-18 14:59:14 +08:00
ouyangxq 0ab2302720 fix: display measurements throughout overseas fulfillment 2026-09-18 14:59:14 +08:00
wujinliang d77d145405 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-18 11:41:38 +08:00
wujinliang 451663b6ff 1 2026-09-18 11:41:27 +08:00
ouyangxq efba5d9f14 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-18 09:59:13 +08:00
ouyangxq 84e768f41a 优化 2026-09-18 09:59:12 +08:00
wujinliang f2d5b2d8e4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-18 09:15:51 +08:00
wujinliang 9f1ea75504 1 2026-09-18 09:15:40 +08:00
liuyihe 579a4b7353 Merge release/test into dev 2026-09-17 16:42:02 +08:00
liuyihe b6a839a3c6 优化 2026-09-17 16:34:36 +08:00
ouyangxq dbbaa44f92 优化 2026-09-17 16:31:01 +08:00
liuyihe 9c9cb91593 优化 2026-09-17 13:36:31 +08:00
admin e665877737 Merge pull request 'Release/test 合并上生产' (#2) from release/test into main
Reviewed-on: #2
2026-09-17 11:50:55 +08:00
ouyangxq d54f857929 ci: 增加 Admin 生产部署流程 2026-09-17 11:49:00 +08:00
ouyangxq b78cea3caf 优化 2026-09-16 19:32:58 +08:00
ouyangxq 5b6d369e65 优化 2026-09-16 17:49:23 +08:00
ouyangxq 61c8cf13f7 优化 2026-09-16 16:17:41 +08:00
ouyangxq f0d10389d0 优化 2026-09-16 15:21:01 +08:00
ouyangxq 61562e59f0 优化 2026-09-16 11:43:04 +08:00
ouyangxq 78a0d7916c 优化 2026-09-16 11:06:36 +08:00
ouyangxq d75e8381b6 fix: clarify whole-pallet box and SKU counts 2026-09-16 10:18:55 +08:00
ouyangxq 343a5ea260 补齐海外出库状态展示 2026-09-15 21:05:09 +08:00
ouyangxq 203366c662 优化 2026-09-15 20:52:05 +08:00
ouyangxq 93a13e5eb9 优化 2026-09-15 19:34:55 +08:00
ouyangxq a7db793c95 优化 2026-09-15 18:17:17 +08:00
ouyangxq 55576a7c84 fix(admin): unify inbound fee refresh controls 2026-09-15 18:16:09 +08:00
ouyangxq ccc72be4b9 优化 2026-09-15 17:46:44 +08:00
ouyangxq 14e31d4679 优化 2026-09-15 16:39:46 +08:00
ouyangxq c78ecbef31 优化 2026-09-15 16:11:20 +08:00
ouyangxq 7d5bee80b8 优化 2026-09-15 15:53:09 +08:00
ouyangxq bed46a58d8 优化 2026-09-15 15:53:03 +08:00
liuyihe 5d00618c53 Merge release/test into dev 2026-09-15 15:36:26 +08:00
ouyangxq 9fb43ff2d3 优化 2026-09-15 15:28:57 +08:00
ouyangxq 71ca2253c9 优化 2026-09-15 15:26:20 +08:00
ouyangxq e6d9474df1 优化 2026-09-15 15:25:56 +08:00
ouyangxq 2e57098ad9 优化 2026-09-15 15:25:11 +08:00
ouyangxq 5d2465d1ea 优化 2026-09-15 15:24:59 +08:00
liuyihe ea30e3f37a 优化 2026-09-15 15:20:28 +08:00
ouyangxq 90f97e846f 优化 2026-09-15 15:01:01 +08:00
ouyangxq ac6b9ef9eb 优化 2026-09-15 14:30:50 +08:00
ouyangxq a311ec41c3 优化 2026-09-15 14:18:23 +08:00
ouyangxq fb41713930 优化 2026-09-15 13:46:20 +08:00
ouyangxq bd207b8d88 fix: show completed orders after overseas delivery 2026-09-15 13:12:24 +08:00
ouyangxq 47f2bfdd23 优化 2026-09-15 12:35:46 +08:00
ouyangxq ca90b0bc75 fix: limit dispatch vehicle choices 2026-09-15 12:17:20 +08:00
ouyangxq 785194209b fix: remove manual pallet carton mark input 2026-09-15 12:08:07 +08:00
ouyangxq ff25847b02 优化 2026-09-15 11:54:19 +08:00
ouyangxq a807071ed2 优化 2026-09-15 10:38:03 +08:00
ouyangxq fb316e4d5a fix(admin): unify dropship pallet print action styling 2026-09-15 10:19:05 +08:00
ouyangxq 4bf66d9571 优化 2026-09-15 09:58:15 +08:00
ouyangxq 3f998bdb88 优化 2026-09-14 20:58:37 +08:00
ouyangxq bd6d54ac33 优化 2026-09-14 20:21:02 +08:00
ouyangxq 349e8f60df 优化 2026-09-14 20:03:27 +08:00
ouyangxq 66f49b97e5 优化 2026-09-14 18:34:34 +08:00
ouyangxq 25825685c3 优化 2026-09-14 17:54:21 +08:00
ouyangxq 7e1b9b4cee feat: add desktop dropship pallet printing 2026-09-14 17:52:00 +08:00
ouyangxq 22c755b02a fix: scope smart loading recommendation requests 2026-09-14 17:22:08 +08:00
liuyihe 01cbe69eb4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-14 17:07:27 +08:00
liuyihe 604420f07a Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-14 17:06:56 +08:00
ouyangxq 7e29e6c25c 优化 2026-09-14 17:06:55 +08:00
ouyangxq 9e28e10e5f feat: edit collaboration remarks across order loading 2026-09-14 16:56:11 +08:00
liuyihe c56f60d4ef 优化 2026-09-14 16:51:35 +08:00
ouyangxq 33520e8fd3 fix: lock dropship inbound plan pallet count 2026-09-14 16:38:51 +08:00
ouyangxq e3baf69db5 优化 2026-09-14 15:52:00 +08:00
ouyangxq 877fcc2740 fix(customers): close editor after successful save 2026-09-14 15:38:29 +08:00
ouyangxq b0de9c026f 优化 2026-09-14 15:33:35 +08:00
ouyangxq 23d956ba15 优化 2026-09-14 14:44:01 +08:00
ouyangxq afa49bf1f1 fix: remove sealed smart-loading rows by pallet 2026-09-14 14:35:30 +08:00
ouyangxq 85b3606eca 优化 2026-09-14 13:48:15 +08:00
ouyangxq ba31dd0563 优化 2026-09-14 12:00:54 +08:00
ouyangxq 1da976ecb6 Merge origin/dev into release/test preserving current workflows 2026-09-14 11:05:47 +08:00
ouyangxq bd12b0a594 1 2026-09-14 10:27:47 +08:00
ouyangxq 78f31fb9a3 fix: 修正海外出库前端质量门禁 2026-09-14 10:22:27 +08:00
ouyangxq 89dc3bf671 优化 2026-09-14 10:02:39 +08:00
liuyihe a0e46a05e5 优化 2026-09-13 21:10:05 +08:00
ouyangxq 8bc86c8567 优化 2026-09-12 11:45:57 +08:00
ouyangxq 497530d6a9 优化 2026-09-11 18:44:01 +08:00
ouyangxq b9330a9674 优化 2026-09-11 17:27:30 +08:00
ouyangxq ce399e4153 优化 2026-09-11 17:20:58 +08:00
ouyangxq 2334bd846a 优化 2026-09-11 17:01:59 +08:00
ouyangxq 737dd41ff1 优化 2026-09-11 16:17:21 +08:00
ouyangxq 80a4b3ab5e fix: clarify automatic pallet supplementation 2026-09-11 16:14:14 +08:00
ouyangxq 3ea1e279c6 fix: show cross-plan merged pallet ownership 2026-09-11 16:11:31 +08:00
ouyangxq 6d26634b1b feat: clarify overseas pallet supplementation 2026-09-11 15:39:47 +08:00
ouyangxq b673e22cb7 fix: validate dropship pallet estimate against cartons 2026-09-11 12:26:45 +08:00
ouyangxq e0b1334d2d feat: add expected pallet count for dropship receiving 2026-09-11 12:12:41 +08:00
ouyangxq 385c5d9e9e 优化 2026-09-11 11:51:33 +08:00
ouyangxq 6b7536b95c 优化 2026-09-11 11:19:45 +08:00
ouyangxq 23e991722c 优化 2026-09-11 11:15:04 +08:00
ouyangxq f702a819fa fix: complete overseas receiving detail facts 2026-09-11 10:32:29 +08:00
ouyangxq d92a883f8a fix: open dispatch BOL as PDF 2026-09-11 10:09:14 +08:00
ouyangxq b7b9835bf9 fix: wait for customer dimension initialization 2026-09-11 02:16:51 +08:00
ouyangxq 2817ca1d3f fix: isolate BOL print layout 2026-09-10 23:30:16 +08:00
ouyangxq 950967f101 feat: align finance account and overseas actions 2026-09-10 22:43:10 +08:00
ouyangxq b6b91297ae fix(customers): wait for billing defaults before loading 2026-09-10 22:12:27 +08:00
ouyangxq 781d25de08 feat: add dispatch BOL printing 2026-09-10 21:22:06 +08:00
ouyangxq 14907551d7 优化 2026-09-10 20:38:06 +08:00
ouyangxq 219fd3009f 优化 2026-09-10 20:31:17 +08:00
ouyangxq 5a165add2c Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-10 20:26:57 +08:00
ouyangxq b57ea2d628 feat(admin): use smart-loading groups for simulations 2026-09-10 19:25:11 +08:00
ouyangxq 27c4ec759c 优化 2026-09-10 17:13:08 +08:00
ouyangxq 5f1a7f04f1 fix: clarify automatic pallet generation 2026-09-10 17:05:48 +08:00
ouyangxq 0ed913a114 fix: expose manual plans awaiting pallets 2026-09-10 16:33:43 +08:00
ouyangxq 4382351569 fix: show pallets across warehouses by default 2026-09-10 16:03:01 +08:00
ouyangxq dc1cbfcf4a fix: align inbound pallet plans with preview 2026-09-10 15:40:55 +08:00
liuyihe 19fed894d9 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-10 15:33:55 +08:00
liuyihe aaca62f461 优化 2026-09-10 15:33:54 +08:00
ouyangxq a2f1ecc6b9 fix(admin): default manual node times to now 2026-09-10 15:23:39 +08:00
ouyangxq 568f2dea8a fix(admin): remove duplicate loading list heading 2026-09-10 14:41:47 +08:00
ouyangxq 84a194db80 优化 2026-09-10 14:17:08 +08:00
ouyangxq c3d69d74c2 优化 2026-09-10 14:16:20 +08:00
ouyangxq 33b79edd18 优化 2026-09-10 14:15:44 +08:00
ouyangxq 2460278cd9 优化 2026-09-10 14:14:24 +08:00
ouyangxq 60734cbb03 fix(admin): show empty customs state for unloaded plan 2026-09-10 14:02:37 +08:00
ouyangxq 10fa397013 优化 2026-09-10 12:18:39 +08:00
ouyangxq 20e4da5bb5 fix(admin): allow manual container nodes without tracking 2026-09-10 12:17:54 +08:00
ouyangxq e3b2552d8d 优化 2026-09-10 11:55:58 +08:00
ouyangxq ae7d8fb22e 优化 2026-09-10 11:52:30 +08:00
ouyangxq 911c6acad2 test(admin): cover outer dimension item update 2026-09-10 11:42:14 +08:00
ouyangxq 8c9f4468af 优化 2026-09-10 11:38:38 +08:00
ouyangxq 9b5b324fe8 优化 2026-09-10 11:17:45 +08:00
ouyangxq 6a038c89ca fix(admin): update submitted measurement calculation strategy 2026-09-10 11:02:52 +08:00
ouyangxq fc0226d4cb 优化 2026-09-10 10:22:48 +08:00
ouyangxq 503a4bac89 优化 2026-09-10 10:10:04 +08:00
ouyangxq 57c987da88 优化 2026-09-09 20:53:30 +08:00
ouyangxq 8b862da084 fix(admin): lock submitted measurement rule editing 2026-09-09 20:36:49 +08:00
ouyangxq f5a43c1cfe test: verify pallet label internal code dialog 2026-09-09 18:35:35 +08:00
ouyangxq 114def1c11 优化 2026-09-09 18:29:47 +08:00
ouyangxq 108ad5c7b5 feat: show FBA internal code on pallet labels 2026-09-09 18:20:05 +08:00
ouyangxq ae01e066ca 优化 2026-09-09 17:58:23 +08:00
ouyangxq 2fd9c5fcc7 优化 2026-09-09 17:42:19 +08:00
ouyangxq a47a7e9767 feat: add pallet label printing 2026-09-09 17:27:00 +08:00
ouyangxq f58c433fa4 fix: complete container and overseas fee contracts 2026-09-09 17:17:05 +08:00
ouyangxq c5cd39becc Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/overseas-warehouse/api/dispatches.ts
#	src/modules/overseas-warehouse/pages/DispatchesView.vue
2026-09-09 16:50:22 +08:00
ouyangxq 8f6d4fc03e 补齐财务费用原币与尾程录入 2026-09-09 16:37:36 +08:00
ouyangxq 51841fc608 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/container-loading/pages/ContainersView.vue
2026-09-09 15:42:42 +08:00
ouyangxq a869467efb 优化 2026-09-09 15:26:43 +08:00
ouyangxq a2515119b7 优化 2026-09-09 14:32:04 +08:00
ouyangxq f62a3175f7 优化 2026-09-09 13:54:43 +08:00
ouyangxq 08a1f1e5e2 优化 2026-09-09 13:51:14 +08:00
liuyihe e428002898 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-09 13:35:45 +08:00
liuyihe 28d97d2221 优化提交 2026-09-09 13:35:43 +08:00
ouyangxq 41924a438c 优化 2026-09-09 13:33:34 +08:00
ouyangxq 7c34b214af Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts
#	src/modules/container-loading/pages/ContainersView.test.ts
2026-09-09 13:02:53 +08:00
ouyangxq 2dd777d74a fix(admin): load outbound facts when access becomes ready 2026-09-09 13:00:45 +08:00
ouyangxq c83d03e775 优化 2026-09-09 12:50:39 +08:00
ouyangxq 4ccad3bc3c fix(admin): consume booking link actions when cached pages reactivate 2026-09-09 12:15:24 +08:00
ouyangxq d6a69ea157 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-09 12:13:29 +08:00
ouyangxq 7b75f40e28 优化 2026-09-09 11:55:10 +08:00
ouyangxq fa3a199e78 fix(admin): integrate workbench contracts and dropship review states 2026-09-09 11:50:42 +08:00
ouyangxq 87fc08b06e fix(ocean-booking): make customer optional through release 2026-09-09 11:21:13 +08:00
ouyangxq 89d237e129 fix(delivery): version status commands and manage three document categories 2026-09-09 11:17:12 +08:00
ouyangxq 239733a33e fix(container): refresh workbench after empty return 2026-09-09 10:53:37 +08:00
ouyangxq 229489271d 优化 2026-09-09 10:26:17 +08:00
ouyangxq 1249263b94 优化 2026-09-09 10:18:12 +08:00
ouyangxq e643e43828 fix(container): hide provider framework errors 2026-09-09 01:20:39 +08:00
ouyangxq 64340036a3 fix: satisfy container fee frontend contracts 2026-09-09 00:49:13 +08:00
ouyangxq cf8727a809 feat: confirm container inbound plans 2026-09-09 00:44:07 +08:00
ouyangxq 695db084e2 feat: preview container inbound plans 2026-09-08 23:57:12 +08:00
ouyangxq 5068c13c37 fix: keep container fee entries visible 2026-09-08 23:23:47 +08:00
ouyangxq 43604b951d fix: keep container overview mounted 2026-09-08 22:35:07 +08:00
ouyangxq 9a61f57f9e fix: scope overseas container fees 2026-09-08 22:30:04 +08:00
ouyangxq 653ce1c511 feat: separate container fee workbenches 2026-09-08 22:03:03 +08:00
ouyangxq 9570e0fd18 perf: use container finance aggregate 2026-09-08 21:09:25 +08:00
ouyangxq f25839c4b5 perf: defer container workbench data loading 2026-09-08 20:55:30 +08:00
liuyihe d55cfb3737 merge origin/dev and resolve frontend conflicts 2026-09-08 20:33:45 +08:00
liuyihe c3ae936caa 优化 2026-09-08 20:27:47 +08:00
ouyangxq 8910e88563 feat: split domestic and overseas container workbenches 2026-09-08 19:57:42 +08:00
ouyangxq 6b43583cad test(admin): stabilize merged source contracts 2026-09-08 18:45:00 +08:00
ouyangxq 2f571c6eda Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/loading-lists/api/platformLoadingLists.test.ts
2026-09-08 18:37:23 +08:00
ouyangxq 296a8246ad 优化 2026-09-08 18:16:33 +08:00
ouyangxq 34f0ec4980 fix(overseas): label private delivery fee 2026-09-08 18:02:56 +08:00
ouyangxq cf612ce11e fix(overseas): show inventory business reference 2026-09-08 17:56:17 +08:00
ouyangxq 44420919f6 feat: 支持客户专属头程报价 2026-09-08 17:43:04 +08:00
ouyangxq dd9b8f7a63 feat: 完善头程审核与计费信息 2026-09-08 17:38:09 +08:00
ouyangxq 7ba81007a8 feat: support dropship pallet loading 2026-09-08 17:24:49 +08:00
ouyangxq 2dc357e55b Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	AGENTS.md
2026-09-08 17:03:10 +08:00
ouyangxq cb7bb90eb6 fix(overseas): keep inbound actions on one line 2026-09-08 16:04:53 +08:00
ouyangxq cbc5521958 fix(overseas): separate receiving and FBA warehouses 2026-09-08 14:15:28 +08:00
ouyangxq fbdb7b6c01 优化 2026-09-08 14:07:39 +08:00
ouyangxq ce7422a506 fix(overseas): clarify last-mile search fields 2026-09-08 13:08:54 +08:00
ouyangxq 2b987bdc3d fix(overseas): align all-container workbench 2026-09-08 11:50:32 +08:00
ouyangxq 8d8df2ab81 fix(overseas): keep receiving warehouse action visible 2026-09-08 11:45:30 +08:00
ouyangxq 463b054a0a feat(overseas): manage last-mile order controls 2026-09-08 10:54:36 +08:00
ouyangxq 74d883a443 feat: add tail order controls and terminal drayage UI 2026-09-07 23:58:18 +08:00
ouyangxq 36d34b7d1d fix(overseas): keep tail-leg business codes on one line 2026-09-07 21:06:39 +08:00
ouyangxq d4dc81a932 fix(overseas): preserve container progress results 2026-09-07 20:45:31 +08:00
ouyangxq e3a37d6ad0 fix(overseas): show outbound pallet details 2026-09-07 20:29:31 +08:00
ouyangxq 86ba48c415 fix(overseas): harden receiving warehouse change 2026-09-07 20:25:14 +08:00
ouyangxq 36278517dd 优化 2026-09-07 19:59:34 +08:00
ouyangxq fdaf98499f fix: optimize table overflow and action widths 2026-09-07 19:04:00 +08:00
ouyangxq dbd51b7fc8 fix: block unsafe container plan actions 2026-09-07 16:36:21 +08:00
ouyangxq 8ccfb9eea4 优化 2026-09-07 15:34:48 +08:00
ouyangxq d488e9ab40 优化 2026-09-07 15:07:39 +08:00
ouyangxq 189d1eae11 feat: add inbound hold evidence controls 2026-09-07 12:21:44 +08:00
ouyangxq 60f430ced8 perf: deduplicate order product image requests 2026-09-07 11:39:55 +08:00
ouyangxq 3b15c4b597 feat: add inbound hold controls 2026-09-07 11:20:43 +08:00
ouyangxq 116f5dc494 test(overseas): cover list search interactions 2026-09-07 11:15:37 +08:00
ouyangxq 33e72927f9 fix(overseas): stabilize last-mile search tabs 2026-09-07 11:01:00 +08:00
ouyangxq 657dd7c6f5 fix(finance): group secondary account actions 2026-09-07 10:11:23 +08:00
ouyangxq eda8dba8bc 优化 2026-09-07 10:04:08 +08:00
liuyihe b31c37951f 优化 2026-09-06 18:11:35 +08:00
liuyihe 12e63b1dcb Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-06 15:53:50 +08:00
liuyihe 8029c235e4 优化 2026-09-06 15:53:48 +08:00
ouyangxq d310f374b4 fix: hide internal procurement operator ids 2026-09-05 23:40:38 +08:00
ouyangxq fedfdd2db9 fix: load overall overseas pallet summary 2026-09-05 23:06:47 +08:00
ouyangxq ccf16b823b feat: show overseas funding and pallet summaries 2026-09-05 22:26:37 +08:00
ouyangxq 92b24cc9a6 feat(finance): manage customer credit limits 2026-09-05 11:54:55 +08:00
ouyangxq fb4cab6a0e fix(finance): avoid duplicate account detail loads 2026-09-05 09:14:06 +08:00
ouyangxq 68fca1bd9f feat(finance): manage customer wallet balances 2026-09-05 08:34:40 +08:00
ouyangxq 48731b227b 优化 2026-09-04 18:47:43 +08:00
ouyangxq 39ade43a72 fix: localize overseas inventory ledger 2026-09-04 17:46:15 +08:00
ouyangxq bc6629d400 fix: align loading and weight acceptance 2026-09-04 16:28:44 +08:00
ouyangxq 871d0a56b9 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	AGENTS.md
#	src/modules/container-loading/pages/GlobalSmartLoadingView.vue
#	src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
2026-09-04 16:21:32 +08:00
ouyangxq 6a88d21f10 fix: compact smart loading text 2026-09-04 15:54:36 +08:00
ouyangxq ac96249f7d 优化 2026-09-04 15:19:48 +08:00
ouyangxq 3858475c5c Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-04 15:06:41 +08:00
ouyangxq 0f47212477 优化 2026-09-04 15:06:39 +08:00
liuyihe 1bc58284a0 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-04 14:50:17 +08:00
liuyihe bd63c356a6 优化 2026-09-04 14:50:15 +08:00
ouyangxq 420f4a6c66 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-04 14:49:38 +08:00
ouyangxq 6292cc4130 优化 2026-09-04 14:49:36 +08:00
liuyihe 3e59898b09 优化 2026-09-04 14:48:47 +08:00
ouyangxq 2fc84d249c 优化 2026-09-04 14:43:34 +08:00
ouyangxq e4ba4cbb2c Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-04 14:41:31 +08:00
ouyangxq 44eb8d17f2 优化 2026-09-04 14:41:29 +08:00
liuyihe 7db1d12aed 优化 2026-09-04 14:38:08 +08:00
liuyihe 01925fc134 优化 2026-09-04 14:36:11 +08:00
liuyihe 86fab2c24e Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-04 14:31:11 +08:00
liuyihe dd47f3841c 优化 2026-09-04 14:31:09 +08:00
ouyangxq 688eddfa77 优化 2026-09-04 14:17:36 +08:00
ouyangxq 8460cbe123 优化 2026-09-04 14:05:10 +08:00
ouyangxq ea035628aa 优化 2026-09-04 14:04:57 +08:00
ouyangxq 7d29f3d7ea docs: align application scope and merge contracts 2026-09-04 12:41:58 +08:00
ouyangxq 864a3c3cc7 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/container-loading/pages/GlobalSmartLoadingView.vue
#	src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
#	src/modules/domestic-containers/pages/DomesticContainersView.vue
2026-09-04 12:35:34 +08:00
ouyangxq 4eda56acf1 优化 2026-09-04 12:18:39 +08:00
ouyangxq 72520225d9 Merge remote-tracking branch 'origin/release/test' into release/test 2026-09-04 12:03:17 +08:00
ouyangxq 249499cf14 fix: harden domestic container forms 2026-09-04 12:02:38 +08:00
liuyihe 093c89c058 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-04 11:50:35 +08:00
liuyihe d5662230d5 优化 2026-09-04 11:50:34 +08:00
liuyihe e390b88b6c 优化 2026-09-04 11:50:22 +08:00
ouyangxq 3cd4f4f7b3 优化 2026-09-04 11:35:24 +08:00
ouyangxq 7f7c84d404 优化 2026-09-04 10:51:31 +08:00
ouyangxq 7451a713e5 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/container-loading/pages/GlobalSmartLoadingView.vue
#	src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
2026-09-04 10:37:12 +08:00
ouyangxq 2435aac81d 优化 2026-09-04 10:12:41 +08:00
ouyangxq 2d14568caf perf(admin): streamline smart loading workbench 2026-09-04 09:57:39 +08:00
ouyangxq 475b2a08e2 修复智能装柜订单状态多选 2026-09-03 22:15:25 +08:00
ouyangxq 38b435a7ac 优化 2026-09-03 21:52:28 +08:00
ouyangxq de4b719a52 优化 2026-09-03 21:50:28 +08:00
ouyangxq 1db4f01e95 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 21:25:58 +08:00
ouyangxq c20ee82def 优化 2026-09-03 21:25:56 +08:00
ouyangxq bc44cbac46 fix: preserve confirmed fee guard after test sync 2026-09-03 21:23:35 +08:00
ouyangxq 2a185c8c9e Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 21:17:55 +08:00
liuyihe d5ebe6c7e8 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 21:09:42 +08:00
liuyihe a825060a45 优化 2026-09-03 21:09:40 +08:00
ouyangxq 60fcc58e30 fix: guard IAM status rendering 2026-09-03 20:30:58 +08:00
ouyangxq d111694185 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 20:28:49 +08:00
ouyangxq 3ac2dc807f 优化 2026-09-03 20:24:54 +08:00
ouyangxq e94ae9b2b7 fix: guard supplier status rendering 2026-09-03 20:20:21 +08:00
ouyangxq 3fd7768e28 fix: complete admin input hints 2026-09-03 19:29:38 +08:00
ouyangxq d90a5db1c4 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	AGENTS.md
2026-09-03 18:22:05 +08:00
ouyangxq 15beb497c1 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 18:05:40 +08:00
ouyangxq e2b0102fdb 优化 2026-09-03 18:05:38 +08:00
liuyihe 8f77ad333b Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 17:49:03 +08:00
liuyihe 4ae5598c2d 优化 2026-09-03 17:49:01 +08:00
ouyangxq 393d32bcd9 fix(admin): polish phase five overseas pages 2026-09-03 17:17:11 +08:00
ouyangxq a9bfa69e67 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 17:14:42 +08:00
ouyangxq 7633700e66 优化 2026-09-03 16:48:25 +08:00
ouyangxq 749bcce438 test: align unified order compact column contract 2026-09-03 15:23:55 +08:00
ouyangxq f81bf8576a Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 15:13:41 +08:00
liuyihe afb534d4ee 优化 2026-09-03 15:10:26 +08:00
liuyihe 65a470cdac Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 15:05:32 +08:00
liuyihe 664d4844c3 优化 2026-09-03 15:05:30 +08:00
ouyangxq 3cde5b5881 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 15:00:59 +08:00
ouyangxq 764b4cc2e6 优化 2026-09-03 15:00:58 +08:00
liuyihe 0bf4f5781a Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 14:30:41 +08:00
liuyihe 677a666d9b 优化 2026-09-03 14:30:39 +08:00
ouyangxq a25b4ed0f7 优化 2026-09-03 14:12:42 +08:00
ouyangxq f50ea81cbc Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 13:49:42 +08:00
ouyangxq 31dc0ec0ef 优化 2026-09-03 13:49:40 +08:00
ouyangxq 52b1fce8c1 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 12:02:00 +08:00
ouyangxq 7175f04609 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 12:00:52 +08:00
liuyihe 50ca6a6269 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 12:00:08 +08:00
liuyihe ce1075b9b8 优化 2026-09-03 12:00:07 +08:00
ouyangxq aeafb9a6f1 优化 2026-09-03 11:54:20 +08:00
ouyangxq 3f397a87a2 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 11:53:57 +08:00
ouyangxq d4873aa627 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 11:47:57 +08:00
ouyangxq d8b59ecca5 优化 2026-09-03 11:47:32 +08:00
ouyangxq f50debc5f7 同步测量与入仓工作区测试契约 2026-09-03 11:41:45 +08:00
ouyangxq a09016835c Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 11:41:24 +08:00
liuyihe dd146a09b1 优化 2026-09-03 11:39:22 +08:00
ouyangxq 7ec9add043 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 11:34:59 +08:00
ouyangxq 246cbc115e Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 11:13:38 +08:00
ouyangxq 2f7c122c44 优化 2026-09-03 11:13:28 +08:00
ouyangxq b7985f4a6d Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	AGENTS.md
2026-09-03 11:02:21 +08:00
liuyihe 4f65948347 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 10:46:15 +08:00
liuyihe 176f815519 提交 2026-09-03 10:46:07 +08:00
ouyangxq 9b3664c532 优化 2026-09-03 10:41:09 +08:00
ouyangxq bbe8b73cd4 feat(downloads): expose pending export retry 2026-09-03 10:39:55 +08:00
ouyangxq 3c68ef4ddf 优化 2026-09-03 10:27:31 +08:00
ouyangxq 6270573095 优化 2026-09-02 20:49:19 +08:00
ouyangxq 9377a4ead3 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-02 20:32:42 +08:00
ouyangxq f0801c2774 docs: register finance allocation routes 2026-09-02 20:32:42 +08:00
ouyangxq 913a8df7f3 修正财务客户名称展示 2026-09-02 19:42:27 +08:00
ouyangxq f281268efe 优化 2026-09-02 18:03:45 +08:00
ouyangxq 490fa4e2e5 完善柜级费用分摊与财务来源展示 2026-09-02 17:07:03 +08:00
ouyangxq d4b0462a5a test(overseas): restore phase five acceptance baseline 2026-09-02 15:43:11 +08:00
ouyangxq e31ba2c5a5 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/overseas-warehouse/pages/DispatchesView.vue
#	src/modules/overseas-warehouse/pages/OutboundsView.vue
#	src/modules/overseas-warehouse/pages/PalletDetailView.vue
2026-09-02 15:23:05 +08:00
ouyangxq 602674573c 优化 2026-09-02 15:15:47 +08:00
ouyangxq b3e6971ea8 优化 2026-09-02 14:50:52 +08:00
ouyangxq b5b4b41b99 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue
#	src/modules/overseas-warehouse/pages/DispatchesView.vue
#	src/modules/overseas-warehouse/pages/OutboundsView.vue
2026-09-02 14:47:03 +08:00
ouyangxq 637b202559 优化 2026-09-02 14:39:34 +08:00
ouyangxq fa84291551 fix(dispatch): allow transfer before departure 2026-09-02 14:02:04 +08:00
ouyangxq d49bb5a655 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 14:01:40 +08:00
ouyangxq f75e08c1bb Merge remote-tracking branch 'origin/release/test' into dev 2026-09-02 14:01:30 +08:00
ouyangxq 0708b4a262 test(admin): align preload workflow contract 2026-09-02 12:19:48 +08:00
ouyangxq 776c0e2ebb 优化 2026-09-02 11:59:59 +08:00
liuyihe eecf6d8f8a Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-02 10:43:45 +08:00
liuyihe 28acf1174a 优化 2026-09-02 10:43:43 +08:00
ouyangxq 490b685f3a Merge remote-tracking branch 'origin/release/test' into dev 2026-09-02 10:34:07 +08:00
ouyangxq bbb9c40c50 feat(admin): edit domestic order declarations 2026-09-02 10:24:07 +08:00
ouyangxq 132d0c22d7 test(admin): cover optional legal-person qualification 2026-09-02 10:12:52 +08:00
ouyangxq 4cdaa1d1ad 优化 2026-09-02 10:05:49 +08:00
ouyangxq b6b43b6b0a 优化 2026-09-02 10:04:47 +08:00
ouyangxq 19eea79836 优化 2026-09-02 10:04:24 +08:00
ouyangxq a14e39893c 优化 2026-09-01 21:09:39 +08:00
ouyangxq 2f26760135 fix(admin): open measurement detail after review 2026-09-01 20:49:04 +08:00
ouyangxq 73b229bb95 优化 2026-09-01 20:41:30 +08:00
ouyangxq a9570a4dae 优化 2026-09-01 20:18:13 +08:00
ouyangxq 29b937989a 优化 2026-09-01 20:07:40 +08:00
ouyangxq a5d4b7d54a test: cover phase 5 overseas admin flows 2026-09-01 19:57:36 +08:00
ouyangxq 440cae9c6d fix: send canonical login identifier 2026-09-01 19:39:17 +08:00
ouyangxq 4ad8537113 test(admin): cover booking-scoped trucking 2026-09-01 16:35:40 +08:00
ouyangxq 5706645032 优化 2026-09-01 16:18:56 +08:00
ouyangxq 9fcea1ca1c refactor(admin): scope truck orders to bookings 2026-09-01 16:14:16 +08:00
ouyangxq adb077a714 优化 2026-09-01 15:46:46 +08:00
ouyangxq c59608ff2c 优化 2026-09-01 15:43:19 +08:00
ouyangxq 0b676ddb74 feat(admin): allow late container number entry 2026-09-01 15:24:37 +08:00
ouyangxq 519ce1dc96 优化 2026-09-01 14:46:47 +08:00
ouyangxq 76604da31b 优化 2026-09-01 14:27:22 +08:00
ouyangxq e42d765f5c feat: complete phase 5 overseas fulfillment admin 2026-09-01 14:23:53 +08:00
ouyangxq b4c29e2226 fix(admin): compact query toolbar fields 2026-09-01 11:53:54 +08:00
ouyangxq 041452f2ff fix(admin): reuse active truck appointments 2026-09-01 11:31:15 +08:00
ouyangxq e3ecba7133 fix(admin): align loading history and pallet lookup 2026-09-01 11:10:22 +08:00
ouyangxq 4711bcc4e6 fix(admin): preserve tail-leg references 2026-09-01 11:03:45 +08:00
ouyangxq 0cf4a93f9c feat(admin): simplify truck payable currency 2026-09-01 10:36:49 +08:00
ouyangxq 62a016a2f8 优化 2026-09-01 10:29:17 +08:00
ouyangxq 96467b9468 Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-01 10:21:57 +08:00
ouyangxq be687f8ded Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-01 10:18:48 +08:00
ouyangxq 8161066657 fix(admin): label supplier payment ledger types 2026-09-01 10:14:38 +08:00
liuyihe c0d6a4eb47 优化 2026-09-01 10:00:05 +08:00
ouyangxq 66c6dfdadc feat(admin): remove customer from truck appointments 2026-08-31 21:05:03 +08:00
ouyangxq 49babd731a test(admin): harden booking document workflows 2026-08-31 21:05:03 +08:00
ouyangxq 623dfce255 fix(admin): clarify finance operating funds 2026-08-31 20:37:08 +08:00
ouyangxq 4d5fd5cfe6 fix(admin): preserve booking load trace details 2026-08-31 20:14:26 +08:00
ouyangxq 09579ea285 fix(admin): align booking files with upload contract 2026-08-31 19:59:17 +08:00
ouyangxq a759eb1b6b feat(admin): bind booking documents and truck containers 2026-08-31 19:54:37 +08:00
ouyangxq 0f4f635686 优化 2026-08-31 18:55:15 +08:00
ouyangxq c7ba96457a fix(admin): replace completed preload workspace tab 2026-08-31 17:05:13 +08:00
ouyangxq 3cae7e4eb5 fix(admin): freeze released booking selectors 2026-08-31 16:56:20 +08:00
ouyangxq 11f8b7856e 优化 2026-08-31 16:44:38 +08:00
ouyangxq 83374ba6cc fix(admin): keep co-load validation action scoped 2026-08-31 16:37:36 +08:00
ouyangxq 3add5dc5b8 fix(admin): resolve preload detail lifecycle 2026-08-31 15:58:38 +08:00
ouyangxq 1a81a5a2b6 fix(admin): validate co-load routes by business code 2026-08-31 15:50:05 +08:00
ouyangxq afbee83b51 style(admin): align list typography with orders 2026-08-31 15:36:02 +08:00
ouyangxq cd290b8dc6 feat(admin): validate and cancel co-load groups 2026-08-31 14:54:57 +08:00
ouyangxq dc1027804e fix(admin): save dirty ocean booking before release 2026-08-31 14:52:38 +08:00
ouyangxq 4bf937ffd3 fix(admin): move preload menu under domestic warehouse 2026-08-31 14:52:01 +08:00
liuyihe d0770517dc 优化 2026-08-31 14:42:28 +08:00
ouyangxq 66ea73e766 fix(admin): expose canonical booking link from container detail 2026-08-31 12:40:01 +08:00
ouyangxq 6ec0a0a028 feat(admin): streamline container identity workflows 2026-08-31 12:12:51 +08:00
liuyihe f7328e7c60 优化 2026-08-31 10:58:12 +08:00
ouyangxq 50c3a7e1ba 优化 2026-08-31 10:48:23 +08:00
ouyangxq 0b9d328bdf test(admin): align inbound relationship dialog consumers 2026-08-31 10:45:53 +08:00
ouyangxq 1548d444c6 优化 2026-08-31 10:31:53 +08:00
ouyangxq b4d5fb1170 优化 2026-08-31 10:17:06 +08:00
ouyangxq 0e828cd44a 优化 2026-08-31 10:09:17 +08:00
liuyihe 462d02efd5 优化 2026-08-30 18:50:16 +08:00
ouyangxq abdea48e8d refactor(admin): use dialog for inbound relationships 2026-08-30 18:21:12 +08:00
ouyangxq 8c1dc50cf3 fix(admin): clarify unassigned overseas warehouse 2026-08-30 18:15:50 +08:00
ouyangxq 2e313172d1 fix(admin): contain relationship drawer body 2026-08-30 18:12:16 +08:00
ouyangxq 06eb745f8b fix(admin): contain inbound relationship drawer status 2026-08-30 18:05:37 +08:00
ouyangxq 0c002c1ca1 fix(admin): load inbound relationships on drawer mount 2026-08-30 18:01:28 +08:00
ouyangxq cee0464e69 feat(admin): show inbound business relationships 2026-08-30 17:46:45 +08:00
ouyangxq ae52141952 优化 2026-08-30 17:43:03 +08:00
liuyihe 9d3862b215 优化 2026-08-30 16:40:03 +08:00
ouyangxq de1b956d43 fix(admin): align coload groups with inbound fragments 2026-08-30 15:49:01 +08:00
ouyangxq c92f3ce2b9 fix(admin): enforce coload groups in global loading 2026-08-29 11:41:57 +08:00
ouyangxq 1aa2dfcdff feat(admin): support indivisible coload groups 2026-08-29 10:57:01 +08:00
ouyangxq aee562d998 fix(admin): expose container customs downloads 2026-08-29 10:19:35 +08:00
ouyangxq 5e617f0c16 fix(admin): raise bounded API upload limit 2026-08-28 22:12:36 +08:00
ouyangxq de81a16ceb 优化 2026-08-28 17:10:16 +08:00
ouyangxq 813763a02a 优化 2026-08-28 16:54:02 +08:00
liuyihe ea1d06448b 恢复尾程订单合托关联展示 2026-08-28 16:30:33 +08:00
liuyihe f7b8159d30 移除尾程订单合托标记功能 2026-08-28 15:47:13 +08:00
liuyihe 0044c01ed0 修正合托关联展示和取消条件 2026-08-28 15:32:18 +08:00
liuyihe d4fc74e601 确保尾程列表显示合托字段 2026-08-28 15:24:13 +08:00
liuyihe f6cd60ec61 完善尾程订单合托关联与取消操作 2026-08-28 15:15:31 +08:00
liuyihe 94a5dfedf6 调整尾程订单合托批量标记 2026-08-28 14:52:30 +08:00
liuyihe 3525e434a4 优化 2026-08-28 14:44:53 +08:00
liuyihe 632fc0bdfa 解决海外仓前端提交冲突 2026-08-28 14:41:40 +08:00
liuyihe ae48f46c9f 优化 2026-08-28 14:33:57 +08:00
ouyangxq 94f003153d 优化 2026-08-28 13:58:52 +08:00
ouyangxq 251645d2d8 优化 2026-08-28 13:54:44 +08:00
ouyangxq 67a1278554 优化 2026-08-28 10:03:37 +08:00
ouyangxq d45e2b4358 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-08-28 09:19:26 +08:00
ouyangxq 4687f17d09 优化 2026-08-28 09:19:24 +08:00
wujinliang 8f447e58f4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-08-27 21:48:31 +08:00
wujinliang b47323edf3 1 2026-08-27 21:48:15 +08:00
ouyangxq 44d05e77cb 优化 2026-08-27 17:49:05 +08:00
ouyangxq 929f50dc55 优化 2026-08-27 17:16:38 +08:00
ouyangxq 0181656b4f 优化 2026-08-27 16:42:32 +08:00
ouyangxq a90d5fbe39 优化 2026-08-27 16:34:32 +08:00
ouyangxq 690f386bff fix(admin): unblock tailleg inbound planning 2026-08-27 15:56:13 +08:00
ouyangxq 00045a9764 优化 2026-08-27 15:02:41 +08:00
ouyangxq 24575223a0 优化 2026-08-27 13:50:41 +08:00
ouyangxq 550d8add97 优化 2026-08-27 11:38:59 +08:00
ouyangxq 82f0479928 优化 2026-08-27 11:08:03 +08:00
ouyangxq 506d310211 优化 2026-08-27 10:44:12 +08:00
ouyangxq f7afcb6cdc feat(admin): filter booking agents by carrier 2026-08-27 10:33:43 +08:00
ouyangxq 3e3c7e237a fix(admin): align supplier carrier master data 2026-08-27 10:12:20 +08:00
ouyangxq 29bb641524 chore: remove tracked dependency cache 2026-08-26 20:51:27 +08:00
ouyangxq d92af5559e Merge remote-tracking branch 'origin/dev' into release/test 2026-08-26 20:50:24 +08:00
ouyangxq 38031b0fa0 优化 2026-08-26 20:39:55 +08:00
wujinliang 9eec68f7c5 1 2026-08-26 18:13:12 +08:00
ouyangxq 7b50e92baf 优化 2026-08-26 17:29:11 +08:00
ouyangxq 64f4e8ee34 1 2026-08-26 17:25:14 +08:00
ouyangxq 0ce42a01e2 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev 2026-08-26 17:24:57 +08:00
ouyangxq a546ee4c3b Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into dev 2026-08-26 15:59:05 +08:00
ouyangxq 6d4748c62a 优化 2026-08-26 15:59:01 +08:00
liuyihe 525c56fdb1 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev
# Conflicts:
#	src/modules/overseas-warehouse/pages/OutboundsView.vue
2026-08-26 15:53:45 +08:00
liuyihe fac3153472 优化 2026-08-26 15:48:34 +08:00
ouyangxq b907881740 fix(admin): prevent duplicate actual carrier nodes 2026-08-26 15:41:08 +08:00
wujinliang a67f6300f8 1 2026-08-26 15:32:22 +08:00
ouyangxq b8948fdff0 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-26 14:49:19 +08:00
ouyangxq 2563ad7be3 优化 2026-08-26 14:48:48 +08:00
wujinliang 627b9602d1 1 2026-08-26 14:33:13 +08:00
ouyangxq 5daaf1a5fa 优化 2026-08-26 13:58:39 +08:00
ouyangxq a3d04eaad8 fix(admin): consolidate container exception history 2026-08-26 13:48:55 +08:00
ouyangxq d67544af5e fix(admin): refresh container document versions 2026-08-26 13:06:00 +08:00
ouyangxq 9585c21f14 fix(admin): render container documents as table 2026-08-26 13:00:51 +08:00
ouyangxq 4a7a5ae5b6 feat(admin): complete customs from container detail 2026-08-26 12:46:46 +08:00
ouyangxq 4088e36f74 fix(admin): compact container departure gates 2026-08-26 12:36:01 +08:00
ouyangxq e8c41bca2d feat(admin): confirm container departure from detail 2026-08-26 12:04:50 +08:00
ouyangxq 8307510fc3 fix(admin): load picker warehouses from domestic master 2026-08-26 11:42:46 +08:00
ouyangxq 011195012b Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-26 11:11:16 +08:00
ouyangxq f6d1febea8 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-26 11:10:49 +08:00
ouyangxq c38e538a95 优化 2026-08-26 11:10:48 +08:00
liuyihe d03f7b3f5d 优化 2026-08-26 11:10:22 +08:00
ouyangxq 53c92d4d37 优化 2026-08-26 09:14:04 +08:00
wujinliang 57de615717 1 2026-08-25 22:08:59 +08:00
liuyihe dcbb6d249f Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-25 20:57:52 +08:00
liuyihe 966fbd1880 优化 2026-08-25 20:57:51 +08:00
1038 changed files with 85698 additions and 14086 deletions
+3 -1
View File
@@ -3,6 +3,8 @@
dist
node_modules
node_modules.broken-by-path
.env.local
.env
.env.*
!.env.example
npm-debug.log*
pnpm-debug.log*
+9 -5
View File
@@ -1,9 +1,13 @@
VITE_API_BASE_URL=/api
# 按本机联调环境填写国内 Gateway origin,不提交真实环境地址。
# 按本机联调环境填写统一 Gateway origin,不提交真实环境地址。
API_PROXY_TARGET=http://localhost:9000
# 海外业务浏览器请求使用独立代理,代理会将 /overseas-api 重写为海外 Gateway 的 /api。
VITE_OVERSEAS_API_BASE_URL=/overseas-api
# 按本机联调环境填写海外 Gateway origin,不提交真实环境地址。
OVERSEAS_API_PROXY_TARGET=http://localhost:9300
# Local Vite fallback. Container deployments should use CLIENT_WEB_URL instead.
VITE_CLIENT_WEB_URL=http://localhost:5174
# Test container deployment (Jenkins environment or untracked .env).
# API_UPSTREAM: unified Gateway origin, no /api suffix.
# TEST_NETWORK: shared test Docker network.
API_UPSTREAM=http://gateway:9000
TEST_NETWORK=firstleg-test-network
IMAGE_TAG=
CLIENT_WEB_URL=
+1
View File
@@ -7,6 +7,7 @@ dist/
!.env.example
.playwright-cli/
.idea/
.impeccable/
.codex-temp/
.codex-tmp/
+62 -44
View File
@@ -3,9 +3,12 @@
## 快速读取顺序
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
2. 页面、布局或交互任务同时读取 `../../docs/Admin-Client前端原型还原验收规则.md` 和后台原型 `../../docs/悦/货代SAAS平台后台/AGENTS.md`。
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;四期任务先读 `../../docs/四期/README.md`。
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。
2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。
Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。
普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
## 项目定位与技术基线
@@ -28,58 +31,70 @@
- 依赖方向为 `app → modules/core/shared`、`modules → core/shared`、`core → shared`、`shared → 无上层依赖`。模块间只通过对方公开入口或契约协作,不引用其他模块的页面和内部目录。
- 路由级页面放 `modules/*/pages/`,模块路由放 `modules/*/routes.ts` 并由 `app/router/index.ts` 装配;新增模块同步维护 `core/navigation/adminNavigation.ts`。
- 领域接口放 `modules/*/api/`;国内、海外公共客户端分别为 `shared/api/http.ts`、`shared/api/overseasHttp.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内、海外浏览器前缀;开发代理上游只由 Vite 读取。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
- 领域接口放 `modules/*/api/`;全部业务共用 `shared/api/http.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
- `VITE_API_BASE_URL` 控制唯一浏览器前缀;`API_PROXY_TARGET` 由 Vite 读取并指向统一 Gateway。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
## 模块快速路由
| 业务 | Admin 入口 | 联查 |
|---|---|---|
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`file/`、`firstleg/customerstatistics/` |
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
| 海外 PDA 拣货员与实际收货仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | `WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId`;候选来自 `modules/warehouses/` 的实际海外仓,服务端约束在 `iam/`,Claim 与实时校验联查 `common-security/`、`lastleg-service/security/` |
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance |
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` |
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
| 三期订舱、出仓前船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`;出仓后节点归四期 |
| 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` |
| 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
| 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
| 四期合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
| 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;十节点贯通到港、卸船、清关、提柜与还柜,标准见 `../../docs/四期/船节点标准化契约.md` |
按短表定位后按需查 [业务细则](docs/business-index.md);涉及业务行为变更时必须核对对应细则及最近模块指引。从应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md`,只读命中条款。
全量验收按业务从 `../../docs/一期/`、`二期/`、`三期/`、`财务管理/` 选择最小契约;四期开发先读 `../../docs/四期/README.md`。进度、排期或验收状态问题再读取进度表和报告。
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。
## 四期海外模块内部快速索引
| 业务 | 首个入口 |
|---|---|
| 登录、会话与统一 Token | `modules/auth/AGENTS.md`、`core/auth/` |
| 启动与懒加载资源恢复 | `src/main.ts`、`core/routing/navigationRecovery.ts` |
| 工作台与指标 | `modules/dashboard/AGENTS.md` |
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` |
| 船期日历 | `modules/sailing-calendar/AGENTS.md` |
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/AGENTS.md`、`modules/overseas-warehouse/` |
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts`、`createManualInboundQuote` |
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/AGENTS.md` |
| 客户、企业审核、客户统计 | `modules/customers/AGENTS.md` |
| 供应商、国内整柜 | `modules/suppliers/AGENTS.md`、`modules/domestic-containers/AGENTS.md` |
| 平台仓库与仓储资源 | `modules/warehouses/AGENTS.md`、`modules/warehouse-resources/AGENTS.md` |
| 平台用户、RBAC、导航 | `modules/iam/AGENTS.md`、`core/access/` |
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` |
| 消息中心 | `modules/notifications/AGENTS.md` |
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/AGENTS.md`、`modules/first-leg-orders/` |
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`modules/unified-orders/api/unifiedOrders.ts` |
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts` |
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | `modules/inbounds/AGENTS.md`、`modules/dimension-config/AGENTS.md` |
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/DropshipPalletPrintPanel.vue` |
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、`modules/inbounds/core/api.ts` |
| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/AGENTS.md`、`modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue` |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/AGENTS.md` |
| 货柜双工作台、排柜、预装/装柜清单、装柜照片与证据、真实柜号后补与整柜报关 | `modules/container-loading/AGENTS.md`、`modules/loading-lists/AGENTS.md` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`modules/unified-orders/pages/UnifiedOrdersView.vue` |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue`、`modules/container-loading/pages/GlobalSmartLoadingView.vue` |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/AGENTS.md` |
| 订舱级拖车预约 | `modules/trucking/AGENTS.md` |
| 目的港码头拖车与还空预警 | `modules/container-loading/AGENTS.md` |
| 历史报关案件、报关回执与国内出仓 | `modules/customs/AGENTS.md`、`modules/outbound-dispatch/AGENTS.md` |
| 主数据、下载、退避任务重试 | `modules/master-data/AGENTS.md`、`modules/downloads/AGENTS.md` |
| 费用项目、计费科目 | `modules/fee-items/AGENTS.md` |
| 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` |
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` |
| 海外采购、卡车、散板 | `modules/overseas-business/AGENTS.md` |
| 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` |
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` |
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` |
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` |
| 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---|
| 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
| 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
## 海外模块内部快速索引(海外仓全流程)
- 三个海外模块统一使用 `shared/api/overseasHttp.ts`;页面是否已接真实接口先查对应 `api/` 和挂载测试,不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替当前代码与服务端契约。
海外仓作业先读 `src/modules/overseas-warehouse/AGENTS.md`;采购、车队等页面/API 联查按需查 `docs/business-index.md` 同名节。
## 业务、安全与共享装配
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
- 新建客户时,公司联系电话同时初始化首个管理员登录手机号;创建后公司联系电话只属于企业资料,与首个管理员、成员及平台用户登录手机号独立。编辑客户资料不得提交账号手机号变更,账号手机号改动统一走用户管理并由 IAM/Auth 撤销对应会话。
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
- 平台用户选择 `WAREHOUSE_PICKER` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
@@ -88,10 +103,13 @@
## 开发验收身份
- 开发环境人工验收身份为 `platform_admin`。密码从未提交的 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取;缺失时再向用户索取,不写入仓库、日志或快照。
- 由人工操作真实页面并截图,AI 核对加载、空态、错误态、权限和桌面布局;缺少截图时标记“待人工截图确认”,不自行批量取证。
- 视觉验收由人工操作真实页面并截图,AI 核对受影响的状态和桌面布局;缺少截图标记“待人工截图确认”,不自行批量取证。自动检查单独报告“自动检查通过/失败”;纯逻辑且无视觉变化标记“无需视觉验收”,不能据此声称真实页面验收通过。
## 命令与质量门禁
- 国内仓、客户管理密度默认值统一维护在 `src/styles/adminDensityTokens.css`,通用按钮/页签/描述表/弹框规则统一维护在 `adminDensity.css`;页面只选择公共类与语义参数、保留业务布局,不复制通用外观或在页面重新定义 `--admin-*` 默认值。修改前沿实际引用读取模块 CSS,接管后删除冲突旧声明。
- 修改密度样式或接入消费者时更新并运行 `src/styles/adminDensity.test.ts` 维护合同;不得为通过检查删除权限、业务提交或错误处理断言。未接入菜单通过显式密度类隔离。
在 `apps/admin-web` 执行:
```bash
@@ -102,7 +120,7 @@ pnpm typecheck
pnpm build
```
- 普通变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`。当前没有 lint 脚本,不声称 lint 通过。
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
Vendored
+39 -84
View File
@@ -1,107 +1,62 @@
pipeline {
agent {
label "${env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'}"
}
environment {
DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml'
DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'
REGISTRY_HOST = '172.31.198.7:10082'
REGISTRY_CREDENTIALS = 'gitea-registry-token'
NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'
NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'
}
agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" }
options {
timestamps()
disableConcurrentBuilds()
}
stages {
stage('确认提交版本') {
stage('检查测试发布参数') {
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
script {
env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false'
env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
if (env.JOB_BASE_NAME?.endsWith('-dev')) {
error('此流水线仅支持 test 和本轮授权的 prod 灰度')
}
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
}
sh 'git log -1 --oneline'
sh '''set -eu
export API_UPSTREAM="${API_UPSTREAM:-http://gateway:9000}"
export TEST_NETWORK="${TEST_NETWORK:-firstleg-test-network}"
: "${CLIENT_WEB_URL:?CLIENT_WEB_URL 未设置}"
docker network inspect "$TEST_NETWORK" >/dev/null
docker compose -f docker-compose.test.yml config --quiet
'''
}
}
stage('构建并部署测试前端') {
when { expression { env.IS_DEVELOPMENT != 'true' } }
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
sh '''
set -eu
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
rm -rf "$DOCKER_CONFIG"
mkdir -p "$DOCKER_CONFIG"
cleanup_registry() {
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
rm -rf "$DOCKER_CONFIG"
}
trap cleanup_registry EXIT HUP INT TERM
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
docker compose -f docker-compose.test.yml build --pull=false
docker compose -f docker-compose.test.yml up -d --remove-orphans
'''
}
}
}
stage('构建并部署开发前端') {
when { expression { env.IS_DEVELOPMENT == 'true' } }
steps {
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
sh '''
set -eu
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
rm -rf "$DOCKER_CONFIG"
mkdir -p "$DOCKER_CONFIG"
cleanup_registry() {
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
rm -rf "$DOCKER_CONFIG"
}
trap cleanup_registry EXIT HUP INT TERM
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
image="admin-web-development:${SOURCE_TAG}"
docker build \
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
--build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \
-t "$image" .
export IMAGE_TAG="$SOURCE_TAG"
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz
'''
}
}
}
stage('部署检查') {
when { expression { env.IS_DEVELOPMENT != 'true' } }
steps {
sh '''
set -eu
docker compose -f docker-compose.test.yml ps
sh '''set -eu
docker compose -f docker-compose.test.yml build --pull=false
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
'''
}
}
stage('发布临时生产灰度') {
when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
lock(resource: 'firstleg-production-admin-web-deploy') {
withCredentials([
usernamePassword(credentialsId: 'gitea-registry-token', usernameVariable: 'BUILD_REGISTRY_USER', passwordVariable: 'BUILD_REGISTRY_PASSWORD'),
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
]) {
sh 'sh scripts/deploy-production.sh'
}
}
}
}
}
post {
failure {
sh '''
if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then
docker logs --tail=100 admin-web-development || true
else
docker compose -f docker-compose.test.yml logs --tail=100 || true
fi
'''
script {
if (!env.JOB_BASE_NAME?.endsWith('-prod')) {
sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true'
}
}
}
}
}
+20 -8
View File
@@ -10,7 +10,7 @@ pnpm install
pnpm dev
```
生产构建:
发布资源构建:
```bash
pnpm typecheck
@@ -23,6 +23,14 @@ pnpm build
pnpm test
```
## 发布后的页面资源恢复
`src/core/routing/navigationRecovery.ts` 统一处理路由懒加载、Vite JS/CSS 预加载和 Vue 异步组件的资源加载失败。无未保存编辑时自动重新加载目标页面,保留路径、查询参数和页签;有未保存编辑时先确认,取消后继续编辑。普通接口错误和业务运行错误不触发刷新。
刷新记录写入当前标签页的 `sessionStorage`,30 秒内最多自动刷新一次,跨路由成功、重定向和整页刷新仍然保留,防止循环刷新。存储不可用或恢复后仍失败时提示手动刷新。`nginx/default.conf.template` 已对 HTML 禁用缓存,并让缺失的 `/assets/` 文件返回真实 404;使用额外 CDN 时也应保持 HTML 每次重新验证,参见 [Vite 资源加载错误处理](https://vite.dev/guide/build#load-error-handling)。
该机制随新版前端生效,已经打开且尚未加载本次改动的旧标签页需要先刷新一次。发布验收可在旧版页面保持打开的情况下发布新版,再打开尚未访问过的页面或异步弹窗,确认自动恢复以及未保存编辑的取消/确认行为。
## 源码结构
- `src/app/`:应用壳层、根路由和启动装配。
@@ -35,7 +43,7 @@ pnpm test
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
首页数据看板已作为无权限门槛的默认入口恢复,当前按照后台原型展示明确标注的静态演示数据,不请求业务接口;后续接入真实统计契约时应整体替换演示数据。历史通用占位页仍保留在 `src/prototype/`,但看板本身已迁入正式业务模块。一期 Admin 其他正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。
首页数据看板已作为无权限门槛的默认入口恢复,当前按照后台原型展示明确标注的静态演示数据,不请求业务接口;后续接入真实统计契约时应整体替换演示数据。历史通用占位页仍保留在 `src/prototype/`,但看板本身已迁入正式业务模块。订单与客户 Admin 其他正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。
后台一级菜单及原型内子菜单严格按最新版原型排序;原型未定义位置的现有功能统一归入“通用”。未接入的原型菜单保留为隐藏节点,待对应业务契约和权限码接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;内部用户管理只维护平台员工账号,企业租户成员由商户成员边界管理。运行时不再提供本地演示业务数据或静态角色预览。
@@ -46,18 +54,18 @@ pnpm test
```dotenv
VITE_API_BASE_URL=/api
API_PROXY_TARGET=http://localhost:9000
VITE_OVERSEAS_API_BASE_URL=/overseas-api
OVERSEAS_API_PROXY_TARGET=http://localhost:9300
VITE_CLIENT_WEB_URL=http://localhost:5174
```
国内业务继续使用 `http` 和 `/api` 代理;四期海外业务统一使用 `overseasHttp` 和 `/overseas-api` 代理。Vite 会把 `/overseas-api/platform/...` 重写为海外 Gateway 的 `/api/platform/...`。海外 Token 的签发、缓存和会话刷新由统一认证层维护,业务模块不得读取 Token、手拼 `Authorization` 或复用国内 `http`。
全部业务使用共享 `http` 和同源 `/api`,通过统一 Gateway 访问。Access/Refresh Token 与并发刷新由认证层维护,业务模块不得读取 Token 或手拼 `Authorization`。
切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。
本地工具只连接同一测试环境:在未提交 `.env.local` 配置 `API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`;不提供业务服务直连或独立海外代理。
客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时设置容器环境变量 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。
唯一部署入口是 `docker-compose.test.yml` 和 test Jenkins Job。发布前在 Job/未提交环境文件配置 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`CLIENT_WEB_URL`(客户工作台地址)及 `IMAGE_TAG`。可选 `HOST_PORT` 默认 9001,`NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE` 可指定已授权拉取的基础镜像;私有镜像仓库认证由构建节点配置,不在源码存放凭据。Jenkins 从当前提交生成 IMAGE_TAG,不再按 Job 后缀切换 dev/prod。宿主机反向代理连接 loopback 端口。
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。订单与客户真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。
@@ -66,3 +74,7 @@ VITE_CLIENT_WEB_URL=http://localhost:5174
真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。
登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。
同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。
TEST 入口同时转发两个独立 PDA 静态应用:`/pda-domestic/` 到共享网络中的 `pda-domestic-h5-test:80`,`/pda-overseas/` 到 `pda-overseas-h5-test:80`。各自构建、镜像和静态文件由对应 PDA 项目维护,Admin 不打包 PDA 源码。请求时解析 Docker DNS,PDA 未启动不会阻止 Admin 启动。国内 H5 的 `/gateway-api/` 在此转换为同一 `API_UPSTREAM` 的 `/api/`,海外 H5 直接使用既有 `/api/`;所有业务认证与仓绑定仍由统一 Gateway 和服务端校验。发布时先启动 PDA 容器,再验收两个路径的 HTML、资产、登录、权限与缺失资产 404。H5 通过不能替代 Android 真机扫码验收。
-22
View File
@@ -1,22 +0,0 @@
services:
admin-web:
image: admin-web-development:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: admin-web-development
restart: unless-stopped
ports:
- "127.0.0.1:9001:80"
environment:
CLIENT_WEB_URL: "http://8.163.49.32:9002"
API_UPSTREAM: "http://firstleg-development-gateway:9000"
OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"
networks:
- development-backend
- development-overseas-backend
networks:
development-backend:
external: true
name: firstleg-development-network
development-overseas-backend:
external: true
name: firstleg-development-overseas-network
+23
View File
@@ -0,0 +1,23 @@
name: firstleg-prod-admin-web
services:
admin-web:
image: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}"
container_name: firstleg-prod-admin-web
restart: unless-stopped
ports:
- "127.0.0.1:${HOST_PORT:-29011}:80"
environment:
CLIENT_WEB_URL: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}"
API_UPSTREAM: "http://gateway:9000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
- backend
networks:
backend:
external: true
name: firstleg-production-network
+17 -10
View File
@@ -1,3 +1,4 @@
name: firstleg-test-admin-web
services:
admin-web:
build:
@@ -6,17 +7,23 @@ services:
args:
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: admin-web-test:local
image: admin-web-test:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: admin-web-test
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
ports:
- "${HOST_PORT:-9001}:80"
extra_hosts:
- "host.docker.internal:host-gateway"
- "127.0.0.1:${HOST_PORT:-9001}:80"
environment:
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://8.163.30.128:9002}"
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。
# 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或
# 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。
API_UPSTREAM: "http://host.docker.internal:9000"
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
CLIENT_WEB_URL: "${CLIENT_WEB_URL:?CLIENT_WEB_URL is required}"
API_UPSTREAM: "${API_UPSTREAM:-http://gateway:9000}"
networks:
- test-backend
networks:
test-backend:
external: true
name: "${TEST_NETWORK:-firstleg-test-network}"
+70
View File
@@ -0,0 +1,70 @@
# Admin业务详细索引
本文件保存当前页面/API 入口及业务规则,是[应用指引](../AGENTS.md)的按需补充。涉及字段、状态、权限、金额、资源或作业恢复时必须读取受影响条款,不作为普通定位任务的全文首读。
正文代码路径以工作区 `apps/admin-web/` 应用根起算,源码包缩写与裸文件名沿对应条目的目录解释;明确标注“工作区”的路径从工作区根起算。Markdown 相对链接以本文件所在的 `docs/` 为起点。已知文件直接定位;行为变更同时核对最近模块指引及服务端合同。
原条款在本文件维护,主 AGENTS 仅保留短入口和通用边界;不要将新业务细节复制回主指引。
## 模块快速路由
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
| 业务 | Admin 入口 | 联查 |
|---|---|---|
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
## 货柜工作台规则
- 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/`
## 海外模块内部快速索引(海外仓全流程)
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
| 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---|
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
+2
View File
@@ -13,3 +13,5 @@
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+35 -8
View File
@@ -6,6 +6,21 @@ server {
root /usr/share/nginx/html;
index index.html;
# Resolve independent PDA containers at request time; their restart must not block Admin startup.
resolver 127.0.0.11 valid=10s ipv6=off;
location = /pda-domestic { return 308 /pda-domestic/; }
location = /pda-overseas { return 308 /pda-overseas/; }
location ^~ /pda-domestic/ {
set $pda_domestic_origin http://pda-domestic-h5-test:80;
proxy_pass $pda_domestic_origin$request_uri;
proxy_set_header Host $proxy_host;
}
location ^~ /pda-overseas/ {
set $pda_overseas_origin http://pda-overseas-h5-test:80;
proxy_pass $pda_overseas_origin$request_uri;
proxy_set_header Host $proxy_host;
}
# Docker 容器运行正常的检查地址,供 Jenkins 使用。
location = /healthz {
default_type text/plain;
@@ -21,27 +36,39 @@ server {
# 前端代码中的 API 前缀为 /api;保持原始路径转给 Gateway。
location /api/ {
client_max_body_size 12m;
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
location /overseas-api/ {
client_max_body_size 12m;
rewrite ^/overseas-api/(.*)$ /api/$1 break;
proxy_pass ${OVERSEAS_API_UPSTREAM};
# 墨家测量设备使用独立 OpenAPI 前缀,直接转发到统一 Gateway。
# 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。
location /openapi/ {
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Domestic PDA keeps its H5 module-safe prefix; authorization still belongs to the same Gateway.
location /gateway-api/ {
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM}/api/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# HTML must be revalidated on every visit so a new deployment cannot keep
# referencing hashed chunks that were removed with the previous image.
location = /index.html {
-43
View File
@@ -1,43 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsc" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../typescript/bin/tsc" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsc
-12
View File
@@ -1,12 +0,0 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsc" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\typescript\bin\tsc" %*
)
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
} else {
& "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../typescript/bin/tsc" $args
} else {
& "node$exe" "$basedir/../typescript/bin/tsc" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
-43
View File
@@ -1,43 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsserver" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../typescript/bin/tsserver" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsserver
-12
View File
@@ -1,12 +0,0 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsserver" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\typescript\bin\tsserver" %*
)
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
} else {
& "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../typescript/bin/tsserver" $args
} else {
& "node$exe" "$basedir/../typescript/bin/tsserver" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
-43
View File
@@ -1,43 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../vite/bin/vite.js" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../vite/bin/vite.js" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../vite/bin/vite.js" "$@"
else
exec node "$basedir/../vite/bin/vite.js" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vite/bin/vite.js
-12
View File
@@ -1,12 +0,0 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\vite\bin\vite.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\vite\bin\vite.js" %*
)
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
} else {
& "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../vite/bin/vite.js" $args
} else {
& "node$exe" "$basedir/../vite/bin/vite.js" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
-43
View File
@@ -1,43 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../vitest/vitest.mjs" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../vitest/vitest.mjs" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../vitest/vitest.mjs" "$@"
else
exec node "$basedir/../vitest/vitest.mjs" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vitest/vitest.mjs
-12
View File
@@ -1,12 +0,0 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\vitest\vitest.mjs" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\vitest\vitest.mjs" %*
)
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
} else {
& "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../vitest/vitest.mjs" $args
} else {
& "node$exe" "$basedir/../vitest/vitest.mjs" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
-43
View File
@@ -1,43 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
else
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vue-tsc/bin/vue-tsc.js
-12
View File
@@ -1,12 +0,0 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
)
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
} else {
& "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
} else {
& "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
-743
View File
@@ -1,743 +0,0 @@
{
"hoistedDependencies": {
"undici-types@7.18.2": {
"undici-types": "private"
},
"@rolldown/pluginutils@1.0.1": {
"@rolldown/pluginutils": "private"
},
"@vue/compiler-dom@3.5.40": {
"@vue/compiler-dom": "private"
},
"js-beautify@1.15.4": {
"js-beautify": "private"
},
"vue-component-type-helpers@3.3.8": {
"vue-component-type-helpers": "private"
},
"@vue/server-renderer@3.5.40": {
"@vue/server-renderer": "private"
},
"follow-redirects@1.16.0(debug@4.4.3)": {
"follow-redirects": "private"
},
"form-data@4.0.6": {
"form-data": "private"
},
"https-proxy-agent@5.0.1": {
"https-proxy-agent": "private"
},
"proxy-from-env@2.1.0": {
"proxy-from-env": "private"
},
"@ctrl/tinycolor@4.2.0": {
"@ctrl/tinycolor": "private"
},
"@element-plus/icons-vue@2.3.2(vue@3.5.40(typescript@5.9.3))": {
"@element-plus/icons-vue": "private"
},
"@floating-ui/dom@1.8.0": {
"@floating-ui/dom": "private"
},
"@sxzz/popperjs-es@2.11.8": {
"@popperjs/core": "private"
},
"@types/lodash@4.17.24": {
"@types/lodash": "private"
},
"@types/lodash-es@4.17.12": {
"@types/lodash-es": "private"
},
"@vueuse/core@14.3.0(vue@3.5.40(typescript@5.9.3))": {
"@vueuse/core": "private"
},
"async-validator@4.2.5": {
"async-validator": "private"
},
"dayjs@1.11.21": {
"dayjs": "private"
},
"lodash@4.18.1": {
"lodash": "private"
},
"lodash-es@4.18.1": {
"lodash-es": "private"
},
"lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1)": {
"lodash-unified": "private"
},
"memoize-one@6.0.0": {
"memoize-one": "private"
},
"normalize-wheel-es@1.2.0": {
"normalize-wheel-es": "private"
},
"@asamuzakjp/css-color@5.1.11": {
"@asamuzakjp/css-color": "private"
},
"@asamuzakjp/dom-selector@7.1.1": {
"@asamuzakjp/dom-selector": "private"
},
"@bramus/specificity@2.4.2": {
"@bramus/specificity": "private"
},
"@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)": {
"@csstools/css-syntax-patches-for-csstree": "private"
},
"@exodus/bytes@1.15.1": {
"@exodus/bytes": "private"
},
"css-tree@3.2.1": {
"css-tree": "private"
},
"data-urls@7.0.0": {
"data-urls": "private"
},
"decimal.js@10.6.0": {
"decimal.js": "private"
},
"html-encoding-sniffer@6.0.0": {
"html-encoding-sniffer": "private"
},
"is-potential-custom-element-name@1.0.1": {
"is-potential-custom-element-name": "private"
},
"lru-cache@11.5.2": {
"lru-cache": "private"
},
"parse5@8.0.1": {
"parse5": "private"
},
"saxes@6.0.0": {
"saxes": "private"
},
"symbol-tree@3.2.4": {
"symbol-tree": "private"
},
"tough-cookie@6.0.2": {
"tough-cookie": "private"
},
"undici@7.29.0": {
"undici": "private"
},
"w3c-xmlserializer@5.0.0": {
"w3c-xmlserializer": "private"
},
"webidl-conversions@8.0.1": {
"webidl-conversions": "private"
},
"whatwg-mimetype@5.0.0": {
"whatwg-mimetype": "private"
},
"whatwg-url@16.0.1": {
"whatwg-url": "private"
},
"xml-name-validator@5.0.0": {
"xml-name-validator": "private"
},
"@vue/devtools-api@8.2.1": {
"@vue/devtools-api": "private"
},
"nostics@1.2.0": {
"nostics": "private"
},
"local-pkg@1.2.1": {
"local-pkg": "private"
},
"magic-string@0.30.21": {
"magic-string": "private"
},
"picomatch@4.0.5": {
"picomatch": "private"
},
"unimport@5.7.0": {
"unimport": "private"
},
"unplugin@2.3.11": {
"unplugin": "private"
},
"unplugin-utils@0.3.2": {
"unplugin-utils": "private"
},
"chokidar@5.0.0": {
"chokidar": "private"
},
"mlly@1.8.2": {
"mlly": "private"
},
"obug@2.1.4": {
"obug": "private"
},
"tinyglobby@0.2.17": {
"tinyglobby": "private"
},
"lightningcss@1.33.0": {
"lightningcss": "private"
},
"postcss@8.5.23": {
"postcss": "private"
},
"rolldown@1.1.5": {
"rolldown": "private"
},
"yaml@2.9.0": {
"yaml": "private"
},
"@vitest/expect@4.1.10": {
"@vitest/expect": "private"
},
"@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))": {
"@vitest/mocker": "private"
},
"@vitest/pretty-format@4.1.10": {
"@vitest/pretty-format": "private"
},
"@vitest/runner@4.1.10": {
"@vitest/runner": "private"
},
"@vitest/snapshot@4.1.10": {
"@vitest/snapshot": "private"
},
"@vitest/spy@4.1.10": {
"@vitest/spy": "private"
},
"@vitest/utils@4.1.10": {
"@vitest/utils": "private"
},
"es-module-lexer@2.3.1": {
"es-module-lexer": "private"
},
"expect-type@1.4.0": {
"expect-type": "private"
},
"pathe@2.0.3": {
"pathe": "private"
},
"std-env@4.2.0": {
"std-env": "private"
},
"tinybench@2.9.0": {
"tinybench": "private"
},
"tinyexec@1.2.4": {
"tinyexec": "private"
},
"tinyrainbow@3.1.0": {
"tinyrainbow": "private"
},
"why-is-node-running@2.3.0": {
"why-is-node-running": "private"
},
"@babel/generator@8.0.0": {
"@babel/generator": "private"
},
"@vue-macros/common@3.1.4(vue@3.5.40(typescript@5.9.3))": {
"@vue-macros/common": "private"
},
"ast-walker-scope@0.9.0": {
"ast-walker-scope": "private"
},
"json5@2.2.3": {
"json5": "private"
},
"muggle-string@0.4.1": {
"muggle-string": "private"
},
"scule@1.3.0": {
"scule": "private"
},
"@vue/compiler-sfc@3.5.40": {
"@vue/compiler-sfc": "private"
},
"@volar/typescript@2.4.28": {
"@volar/typescript": "private"
},
"@vue/language-core@3.3.8": {
"@vue/language-core": "private"
},
"@vue/runtime-dom@3.5.40": {
"@vue/runtime-dom": "private"
},
"@vue/shared@3.5.40": {
"@vue/shared": "private"
},
"@asamuzakjp/generational-cache@1.0.1": {
"@asamuzakjp/generational-cache": "private"
},
"@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
"@csstools/css-calc": "private"
},
"@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
"@csstools/css-color-parser": "private"
},
"@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)": {
"@csstools/css-parser-algorithms": "private"
},
"@csstools/css-tokenizer@4.0.0": {
"@csstools/css-tokenizer": "private"
},
"@asamuzakjp/nwsapi@2.3.9": {
"@asamuzakjp/nwsapi": "private"
},
"bidi-js@1.0.3": {
"bidi-js": "private"
},
"@babel/parser@8.0.4": {
"@babel/parser": "private"
},
"@babel/types@8.0.4": {
"@babel/types": "private"
},
"@jridgewell/gen-mapping@0.3.13": {
"@jridgewell/gen-mapping": "private"
},
"@jridgewell/trace-mapping@0.3.31": {
"@jridgewell/trace-mapping": "private"
},
"@types/jsesc@2.5.1": {
"@types/jsesc": "private"
},
"jsesc@3.1.0": {
"jsesc": "private"
},
"@floating-ui/core@1.8.0": {
"@floating-ui/core": "private"
},
"@floating-ui/utils@0.2.12": {
"@floating-ui/utils": "private"
},
"@standard-schema/spec@1.1.0": {
"@standard-schema/spec": "private"
},
"@types/chai@5.2.3": {
"@types/chai": "private"
},
"chai@6.2.2": {
"chai": "private"
},
"estree-walker@3.0.3": {
"estree-walker": "private"
},
"convert-source-map@2.0.0": {
"convert-source-map": "private"
},
"@volar/language-core@2.4.28": {
"@volar/language-core": "private"
},
"path-browserify@1.0.1": {
"path-browserify": "private"
},
"vscode-uri@3.1.0": {
"vscode-uri": "private"
},
"@vue/compiler-core@3.5.40": {
"@vue/compiler-core": "private"
},
"@vue/compiler-ssr@3.5.40": {
"@vue/compiler-ssr": "private"
},
"source-map-js@1.2.1": {
"source-map-js": "private"
},
"@vue/devtools-kit@8.2.1": {
"@vue/devtools-kit": "private"
},
"alien-signals@3.2.1": {
"alien-signals": "private"
},
"@vue/reactivity@3.5.40": {
"@vue/reactivity": "private"
},
"@vue/runtime-core@3.5.40": {
"@vue/runtime-core": "private"
},
"csstype@3.2.3": {
"csstype": "private"
},
"ast-kit@2.2.0": {
"ast-kit": "private"
},
"magic-string-ast@1.0.3": {
"magic-string-ast": "private"
},
"@types/web-bluetooth@0.0.21": {
"@types/web-bluetooth": "private"
},
"@vueuse/metadata@14.3.0": {
"@vueuse/metadata": "private"
},
"@vueuse/shared@14.3.0(vue@3.5.40(typescript@5.9.3))": {
"@vueuse/shared": "private"
},
"readdirp@5.0.0": {
"readdirp": "private"
},
"mdn-data@2.27.1": {
"mdn-data": "private"
},
"debug@4.4.3": {
"debug": "private"
},
"asynckit@0.4.0": {
"asynckit": "private"
},
"combined-stream@1.0.8": {
"combined-stream": "private"
},
"es-set-tostringtag@2.1.0": {
"es-set-tostringtag": "private"
},
"hasown@2.0.4": {
"hasown": "private"
},
"mime-types@2.1.35": {
"mime-types": "private"
},
"agent-base@6.0.2": {
"agent-base": "private"
},
"config-chain@1.1.13": {
"config-chain": "private"
},
"editorconfig@1.0.7": {
"editorconfig": "private"
},
"glob@10.5.0": {
"glob": "private"
},
"js-cookie@3.0.8": {
"js-cookie": "private"
},
"nopt@7.2.1": {
"nopt": "private"
},
"detect-libc@2.1.2": {
"detect-libc": "private"
},
"lightningcss-win32-x64-msvc@1.33.0": {
"lightningcss-win32-x64-msvc": "private"
},
"pkg-types@2.3.1": {
"pkg-types": "private"
},
"quansync@0.2.11": {
"quansync": "private"
},
"@jridgewell/sourcemap-codec@1.5.5": {
"@jridgewell/sourcemap-codec": "private"
},
"acorn@8.17.0": {
"acorn": "private"
},
"ufo@1.6.4": {
"ufo": "private"
},
"entities@8.0.0": {
"entities": "private"
},
"nanoid@3.3.16": {
"nanoid": "private"
},
"picocolors@1.1.1": {
"picocolors": "private"
},
"@oxc-project/types@0.139.0": {
"@oxc-project/types": "private"
},
"@rolldown/binding-win32-x64-msvc@1.1.5": {
"@rolldown/binding-win32-x64-msvc": "private"
},
"xmlchars@2.2.0": {
"xmlchars": "private"
},
"fdir@6.5.0(picomatch@4.0.5)": {
"fdir": "private"
},
"tldts@7.4.9": {
"tldts": "private"
},
"escape-string-regexp@5.0.0": {
"escape-string-regexp": "private"
},
"strip-literal@3.1.0": {
"strip-literal": "private"
},
"@jridgewell/remapping@2.3.5": {
"@jridgewell/remapping": "private"
},
"webpack-virtual-modules@0.6.2": {
"webpack-virtual-modules": "private"
},
"tr46@6.0.0": {
"tr46": "private"
},
"siginfo@2.0.0": {
"siginfo": "private"
},
"stackback@0.0.2": {
"stackback": "private"
},
"@babel/helper-string-parser@8.0.0": {
"@babel/helper-string-parser": "private"
},
"@babel/helper-validator-identifier@8.0.4": {
"@babel/helper-validator-identifier": "private"
},
"@csstools/color-helpers@6.1.0": {
"@csstools/color-helpers": "private"
},
"@types/deep-eql@4.0.2": {
"@types/deep-eql": "private"
},
"assertion-error@2.0.1": {
"assertion-error": "private"
},
"@volar/source-map@2.4.28": {
"@volar/source-map": "private"
},
"@vue/devtools-shared@8.2.1": {
"@vue/devtools-shared": "private"
},
"birpc@2.9.0": {
"birpc": "private"
},
"hookable@5.5.3": {
"hookable": "private"
},
"perfect-debounce@2.1.0": {
"perfect-debounce": "private"
},
"require-from-string@2.0.2": {
"require-from-string": "private"
},
"delayed-stream@1.0.0": {
"delayed-stream": "private"
},
"ini@1.3.8": {
"ini": "private"
},
"proto-list@1.2.4": {
"proto-list": "private"
},
"ms@2.1.3": {
"ms": "private"
},
"@one-ini/wasm@0.1.1": {
"@one-ini/wasm": "private"
},
"commander@10.0.1": {
"commander": "private"
},
"minimatch@9.0.9": {
"minimatch": "private"
},
"semver@7.8.5": {
"semver": "private"
},
"es-errors@1.3.0": {
"es-errors": "private"
},
"get-intrinsic@1.3.0": {
"get-intrinsic": "private"
},
"has-tostringtag@1.0.2": {
"has-tostringtag": "private"
},
"@types/estree@1.0.9": {
"@types/estree": "private"
},
"foreground-child@3.3.1": {
"foreground-child": "private"
},
"jackspeak@3.4.3": {
"jackspeak": "private"
},
"minipass@7.1.3": {
"minipass": "private"
},
"package-json-from-dist@1.0.1": {
"package-json-from-dist": "private"
},
"path-scurry@1.11.1": {
"path-scurry": "private"
},
"function-bind@1.1.2": {
"function-bind": "private"
},
"mime-db@1.52.0": {
"mime-db": "private"
},
"abbrev@2.0.0": {
"abbrev": "private"
},
"confbox@0.1.8": {
"confbox": "private"
},
"exsolve@1.1.0": {
"exsolve": "private"
},
"js-tokens@9.0.1": {
"js-tokens": "private"
},
"tldts-core@7.4.9": {
"tldts-core": "private"
},
"punycode@2.3.1": {
"punycode": "private"
},
"@jridgewell/resolve-uri@3.1.2": {
"@jridgewell/resolve-uri": "private"
},
"cross-spawn@7.0.6": {
"cross-spawn": "private"
},
"signal-exit@4.1.0": {
"signal-exit": "private"
},
"call-bind-apply-helpers@1.0.2": {
"call-bind-apply-helpers": "private"
},
"es-define-property@1.0.1": {
"es-define-property": "private"
},
"es-object-atoms@1.1.2": {
"es-object-atoms": "private"
},
"get-proto@1.0.1": {
"get-proto": "private"
},
"gopd@1.2.0": {
"gopd": "private"
},
"has-symbols@1.1.0": {
"has-symbols": "private"
},
"math-intrinsics@1.1.0": {
"math-intrinsics": "private"
},
"@isaacs/cliui@8.0.2": {
"@isaacs/cliui": "private"
},
"@pkgjs/parseargs@0.11.0": {
"@pkgjs/parseargs": "private"
},
"brace-expansion@2.1.2": {
"brace-expansion": "private"
},
"string-width@5.1.2": {
"string-width": "private"
},
"string-width@4.2.3": {
"string-width-cjs": "private"
},
"strip-ansi@7.2.0": {
"strip-ansi": "private"
},
"strip-ansi@6.0.1": {
"strip-ansi-cjs": "private"
},
"wrap-ansi@8.1.0": {
"wrap-ansi": "private"
},
"wrap-ansi@7.0.0": {
"wrap-ansi-cjs": "private"
},
"balanced-match@1.0.2": {
"balanced-match": "private"
},
"path-key@3.1.1": {
"path-key": "private"
},
"shebang-command@2.0.0": {
"shebang-command": "private"
},
"which@2.0.2": {
"which": "private"
},
"dunder-proto@1.0.1": {
"dunder-proto": "private"
},
"shebang-regex@3.0.0": {
"shebang-regex": "private"
},
"emoji-regex@8.0.0": {
"emoji-regex": "private"
},
"is-fullwidth-code-point@3.0.0": {
"is-fullwidth-code-point": "private"
},
"eastasianwidth@0.2.0": {
"eastasianwidth": "private"
},
"ansi-regex@5.0.1": {
"ansi-regex": "private"
},
"isexe@2.0.0": {
"isexe": "private"
},
"ansi-styles@4.3.0": {
"ansi-styles": "private"
},
"color-convert@2.0.1": {
"color-convert": "private"
},
"color-name@1.1.4": {
"color-name": "private"
}
},
"hoistPattern": [
"*"
],
"included": {
"dependencies": true,
"devDependencies": true,
"optionalDependencies": true
},
"injectedDeps": {},
"layoutVersion": 5,
"nodeLinker": "isolated",
"packageManager": "pnpm@11.17.0",
"pendingBuilds": [],
"publicHoistPattern": [],
"prunedAt": "Thu, 20 Aug 2026 06:47:32 GMT",
"registries": {
"default": "https://registry.npmjs.org/",
"@jsr": "https://npm.jsr.io/"
},
"skipped": [
"@emnapi/core@1.11.1",
"@emnapi/runtime@1.11.1",
"@emnapi/wasi-threads@1.2.2",
"@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)",
"@rolldown/binding-android-arm64@1.1.5",
"@rolldown/binding-darwin-arm64@1.1.5",
"@rolldown/binding-darwin-x64@1.1.5",
"@rolldown/binding-freebsd-x64@1.1.5",
"@rolldown/binding-linux-arm-gnueabihf@1.1.5",
"@rolldown/binding-linux-arm64-gnu@1.1.5",
"@rolldown/binding-linux-arm64-musl@1.1.5",
"@rolldown/binding-linux-ppc64-gnu@1.1.5",
"@rolldown/binding-linux-s390x-gnu@1.1.5",
"@rolldown/binding-linux-x64-gnu@1.1.5",
"@rolldown/binding-linux-x64-musl@1.1.5",
"@rolldown/binding-openharmony-arm64@1.1.5",
"@rolldown/binding-wasm32-wasi@1.1.5",
"@rolldown/binding-win32-arm64-msvc@1.1.5",
"@tybys/wasm-util@0.10.3",
"fsevents@2.3.3",
"lightningcss-android-arm64@1.33.0",
"lightningcss-darwin-arm64@1.33.0",
"lightningcss-darwin-x64@1.33.0",
"lightningcss-freebsd-x64@1.33.0",
"lightningcss-linux-arm-gnueabihf@1.33.0",
"lightningcss-linux-arm64-gnu@1.33.0",
"lightningcss-linux-arm64-musl@1.33.0",
"lightningcss-linux-x64-gnu@1.33.0",
"lightningcss-linux-x64-musl@1.33.0",
"lightningcss-win32-arm64-msvc@1.33.0",
"tslib@2.8.1"
],
"storeDir": "C:\\Users\\BY-OY\\AppData\\Local\\pnpm\\store\\v11",
"virtualStoreDir": "C:\\Users\\BY-OY\\Desktop\\code\\chongchong-global-dev\\apps\\admin-web\\node_modules\\.pnpm",
"virtualStoreDirMaxLength": 60
}
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
{
"lastValidatedTimestamp": 1787208452958,
"projects": {},
"pnpmfiles": [],
"settings": {
"autoInstallPeers": true,
"dedupeDirectDeps": false,
"dedupeInjectedDeps": true,
"dedupePeerDependents": true,
"dedupePeers": false,
"dev": true,
"excludeLinksFromLockfile": false,
"hoistPattern": [
"*"
],
"hoistWorkspacePackages": true,
"injectWorkspacePackages": false,
"linkWorkspacePackages": false,
"minimumReleaseAge": 1440,
"minimumReleaseAgeIgnoreMissingTime": true,
"nodeLinker": "isolated",
"optional": true,
"peersSuffixMaxLength": 1000,
"preferWorkspacePackages": false,
"production": true,
"publicHoistPattern": []
},
"filteredInstall": false
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

@@ -0,0 +1,92 @@
{
"date": "2026-10-09",
"testOnly": true,
"deployment": {
"admin": {
"build": 536,
"result": "SUCCESS",
"commit": "73c29f5"
},
"firstleg": {
"build": 488,
"result": "SUCCESS",
"commit": "e94271706ce7"
},
"lastleg": {
"build": 234,
"result": "SUCCESS",
"commit": "7fd7699f6e83"
}
},
"databaseReset": {
"tables": [
"ll_inbound_fba_appointment",
"ll_inbound_fba_appointment_item"
],
"beforeCounts": [
49,
59
],
"afterResetCounts": [
0,
0
]
},
"httpPassed": [
"five_customer_one_appointment",
"same_command_replay",
"duplicate_cargo_conflict",
"update_version",
"stale_update_conflict",
"stale_delete_conflict",
"delete_and_calendar_absence",
"rebook_after_delete",
"two_new_customer_accounts_10_negative_isolation_checks",
"nonempty_customer_75_rows_all_owned",
"nonempty_customer_forged_filter_stays_scoped",
"own_plan_detail_visible_other_plan_hidden",
"existing_customer_platform_appointment_forbidden",
"browser_deleted_appointment_absence_http_confirmed"
],
"uiPassed": [
"1920x1080_month",
"five_customer_detail",
"edit_saved",
"week_view",
"candidate_existing_appointment_disabled",
"two_customer_selection",
"cross_page_selection_retained",
"create_saved",
"final_month_top_alignment",
"final_week_top_alignment",
"delete_confirmation_and_active_dispatch_rejection",
"month_week_three_previews_233_total_title",
"browser_successful_delete_two_to_one"
],
"initialCalendarRequests": [
{
"type": "appointments",
"count": 1,
"durationMs": 59
},
{
"type": "arrivals",
"count": 1,
"durationMs": 106
}
],
"pending": [],
"screenshots": [
"month-five-customers.png",
"week-five-customers.png",
"final-month.png",
"compact-month.png",
"compact-week.png",
"delete-success.png"
],
"scope": {
"admin": "appointment calendar and inbound appointment consumer",
"client": "no page or code change; merchant inbound API positive and negative isolation verified",
"remainingSample": "QA-UI-20261009-2CUSTOMERS"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

@@ -0,0 +1 @@
{"repo": "html/admin-web", "number": 18, "url": "http://8.163.30.128:10082/html/admin-web/pulls/18", "head": "73c29f5264e46f648713c58b469a8c7bd335cd9c", "branch": "release/appointment-gray-20261009"}
@@ -0,0 +1 @@
f9dd30b8b9c6b756c4502e4efc33df4ddb59e566
@@ -0,0 +1 @@
{"repo": "java-project/baiyue-firstleg", "number": 25, "url": "http://8.163.30.128:10082/java-project/baiyue-firstleg/pulls/25", "head": "7fd7699f6e83e77e487aceff914d160cf42376a0", "branch": "release/appointment-gray-20261009"}
@@ -0,0 +1 @@
f60f44793f289f7612b561f1c470e093f67f4e32
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

@@ -0,0 +1,30 @@
[
{
"name": "permissions",
"http": 200,
"code": "200",
"count": 358,
"elapsedMs": 115
},
{
"name": "arrivals",
"http": 200,
"code": "200",
"count": 10,
"elapsedMs": 297
},
{
"name": "appointments",
"http": 200,
"code": "200",
"count": 0,
"elapsedMs": 287
},
{
"name": "inboundCandidates",
"http": 200,
"code": "200",
"count": 10,
"elapsedMs": 221
}
]
@@ -0,0 +1 @@
[{"job": "baiyue-firstleg-prod-lastleg-service", "queue": "https://jenkins.buggoglobal.com/queue/item/7294/", "number": 13, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "baiyue-firstleg-prod-firstleg", "queue": "https://jenkins.buggoglobal.com/queue/item/7295/", "number": 14, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "admin-web-prod", "queue": "https://jenkins.buggoglobal.com/queue/item/7300/", "number": 26, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}]
@@ -0,0 +1,32 @@
{
"scope": [
"admin-web",
"firstleg",
"lastleg-service"
],
"databaseAuthorized": true,
"schemaPreflight": {
"appointmentRows": 0,
"itemRows": 0,
"permissionConflicts": 0,
"adminRoleCount": 1,
"affectedAdminSubjects": 1
},
"targets": {
"backend": "7fd7699f6",
"admin": "73c29f5"
},
"sourceEquivalence": {
"backendMergeTreeMatchesTestedSource": true,
"adminMergeTreeMatchesTestedSource": true
},
"sqlExecuted": true,
"deployed": true,
"pending": null,
"releaseBranchesDeleted": [
"backend",
"admin"
],
"browserVerified": true,
"grayWriteCrudRetested": false
}
@@ -0,0 +1,35 @@
-- REVIEW ONLY: gray appointment schema and platform administrator permissions.
-- Execute only with explicit gray SQL authorization, after verifying both appointment tables remain empty.
-- No DROP TABLE, DELETE, TRUNCATE, or business-data backfill.
-- DDL auto-commits; stop immediately on any error. Do not use mysql --force.
USE baiyue_lastleg;
ALTER TABLE ll_inbound_fba_appointment
DROP INDEX uk_ll_fba_appointment_no,
DROP INDEX idx_ll_fba_appointment_list,
DROP COLUMN customer_id,
ADD COLUMN appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空' AFTER appointment_at,
ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '并发版本' AFTER appointment_end_at,
ADD UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
ADD KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code);
ALTER TABLE ll_inbound_fba_appointment_item
ADD COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID' AFTER appointment_id,
ADD KEY idx_ll_fba_appointment_item_cargo (line_id, appointment_id);
USE baiyue_iam;
START TRANSACTION;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0),
(5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0);
INSERT INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r CROSS JOIN iam_permission p
WHERE r.id=4000001 AND r.tenant_id=0 AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
AND p.id IN (5107280,5107281);
INSERT INTO iam_security_subject_version (auth_user_id,permission_version,updated_at)
SELECT DISTINCT u.auth_user_id,1,NOW(3)
FROM iam_membership_role mr
JOIN iam_membership m ON m.id=mr.membership_id AND m.tenant_id=mr.tenant_id
JOIN iam_user u ON u.id=m.user_id
WHERE mr.tenant_id=0 AND mr.role_id=4000001 AND m.deleted=0 AND u.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1,updated_at=NOW(3);
COMMIT;
@@ -0,0 +1,5 @@
ll_inbound_fba_appointment 13
ll_inbound_fba_appointment_item 9
platform:appointment:list
platform:appointment:delete
2
+7 -3
View File
@@ -5,20 +5,24 @@
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"build": "vue-tsc --noEmit -p tsconfig.app.json && vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
"typecheck:all": "vue-tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test:phase4:development": "node scripts/phase4-development-frontend-acceptance.mjs",
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
"preview": "vite preview --port 4173"
},
"dependencies": {
"@chongchong/shared": "link:./shared",
"@lucide/vue": "^1.27.0",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"sortablejs": "^1.15.7",
"vue": "^3.5.40",
"vue-echarts": "^8.3.2",
"vue-router": "^5.2.0"
},
"devDependencies": {
+40
View File
@@ -17,6 +17,12 @@ importers:
axios:
specifier: ^1.18.1
version: 1.18.1(debug@4.4.3)
dayjs:
specifier: ^1.11.21
version: 1.11.21
echarts:
specifier: ^6.1.0
version: 6.1.0
element-plus:
specifier: ^2.14.3
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
@@ -29,6 +35,9 @@ importers:
vue:
specifier: ^3.5.40
version: 3.5.40(typescript@5.9.3)
vue-echarts:
specifier: ^8.3.2
version: 8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3))
vue-router:
specifier: ^5.2.0
version: 5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
@@ -638,6 +647,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
editorconfig@1.0.7:
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
engines: {node: '>=14'}
@@ -1147,6 +1159,9 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1322,6 +1337,12 @@ packages:
vue-component-type-helpers@3.3.8:
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==}
vue-echarts@8.3.2:
resolution: {integrity: sha512-FbJD49OZZZv1I8aclHQ+gcQxU+Y9nNWQMuod1sewE7/YjGQUJAaeVcf6WrSfQDBRPke+WoVsUtdRFIqPOsRrnw==}
peerDependencies:
echarts: ^6.0.0
vue: ^3.3.0
vue-router@5.2.0:
resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==}
peerDependencies:
@@ -1403,6 +1424,9 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
snapshots:
'@asamuzakjp/css-color@5.1.11':
@@ -1957,6 +1981,11 @@ snapshots:
eastasianwidth@0.2.0: {}
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
editorconfig@1.0.7:
dependencies:
'@one-ini/wasm': 0.1.1
@@ -2433,6 +2462,8 @@ snapshots:
dependencies:
punycode: 2.3.1
tslib@2.3.0: {}
tslib@2.8.1:
optional: true
@@ -2560,6 +2591,11 @@ snapshots:
vue-component-type-helpers@3.3.8: {}
vue-echarts@8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3)):
dependencies:
echarts: 6.1.0
vue: 3.5.40(typescript@5.9.3)
vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)):
dependencies:
'@babel/generator': 8.0.0
@@ -2655,3 +2691,7 @@ snapshots:
xmlchars@2.2.0: {}
yaml@2.9.0: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
set -eu
set +x
: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}"
: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}"
: "${TARGET_SSH_KEY:?}"
: "${TARGET_SSH_USER:?}"
: "${TARGET_SSH_HOST:?}"
: "${TARGET_SSH_KNOWN_HOSTS:?}"
: "${BUILD_REGISTRY_USER:?}"
: "${BUILD_REGISTRY_PASSWORD:?}"
: "${REGISTRY_USER:?}"
: "${REGISTRY_PASSWORD:?}"
case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac
# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters.
printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$'
printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$'
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
IMAGE_TAG=$(git rev-parse --short=12 HEAD)
registry=${PRODUCTION_IMAGE_REPOSITORY%%/*}
image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG"
TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST"
remote_dir=/data/firstleg/deploy/gray-admin-web
run_ssh() {
ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@"
}
run_scp() {
scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@"
}
export DOCKER_CONFIG
DOCKER_CONFIG=$(mktemp -d)
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
trap cleanup EXIT HUP INT TERM
printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry"
docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
--build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
--build-arg VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" .
docker push "$image"
run_ssh install -d -m 750 "$remote_dir"
run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml"
# The remote env file contains only image coordinates and public web origin, never credentials.
printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \
"$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'"
# Registry credentials exist only in an isolated temporary Docker config, removed after pull.
printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$'
run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh"
printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image"
run_ssh sh -s -- "$remote_dir" <<'REMOTE'
set -eu
cd "$1"
docker network inspect firstleg-production-network >/dev/null
docker compose --env-file release.env -f compose.yml config --quiet
docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120
docker compose --env-file release.env -f compose.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
REMOTE
@@ -1,4 +1,3 @@
import { existsSync, readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
function option(name) {
@@ -15,22 +14,13 @@ function normalizeOrigin(value, name) {
return url.origin
}
function acceptancePassword() {
if (process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD) return process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
const file = 'C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env'
if (!existsSync(file)) return ''
const match = readFileSync(file, 'utf8').match(/^CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD=(.*)$/m)
return match?.[1]?.trim() || ''
}
const environment = option('--environment')
if (environment !== 'development') throw new Error('本入口仅允许显式指定 --environment development')
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_DEVELOPMENT_GATEWAY, '--gateway')
const overseasGateway = normalizeOrigin(option('--overseas-gateway') || process.env.ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY, '--overseas-gateway')
const password = acceptancePassword()
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD 或本机开发验收凭据文件')
if (environment !== 'test') throw new Error('本入口仅允许显式指定 --environment test')
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_TEST_GATEWAY, '--gateway')
const password = process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD')
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], {
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-test.config.ts'], {
cwd: process.cwd(),
stdio: 'inherit',
shell: process.platform === 'win32',
@@ -39,10 +29,8 @@ const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.p
ADMIN_PHASE4_LIVE: '1',
ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin',
ADMIN_ACCEPTANCE_PASSWORD: password,
ADMIN_DEVELOPMENT_GATEWAY: gateway,
ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY: overseasGateway,
ADMIN_TEST_GATEWAY: gateway,
VITE_API_BASE_URL: `${gateway}/api`,
VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`,
},
})
if (result.error) throw result.error
@@ -2,14 +2,14 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const runner = readFileSync(new URL('./phase4-development-frontend-acceptance.mjs', import.meta.url), 'utf8')
const suite = readFileSync(new URL('./phase4-development-frontend.acceptance.ts', import.meta.url), 'utf8')
const runner = readFileSync(new URL('./phase4-test-frontend-acceptance.mjs', import.meta.url), 'utf8')
const suite = readFileSync(new URL('./phase4-test-frontend.acceptance.ts', import.meta.url), 'utf8')
test('development runner requires an explicit development environment and gateways', () => {
test('test runner requires an explicit test environment and unified gateway', () => {
assert.match(runner, /--environment/)
assert.match(runner, /environment !== 'development'/)
assert.match(runner, /environment !== 'test'/)
assert.match(runner, /--gateway/)
assert.match(runner, /--overseas-gateway/)
assert.doesNotMatch(runner, /--overseas-gateway|VITE_OVERSEAS/)
assert.match(runner, /shell: process\.platform === 'win32'/)
assert.match(runner, /if \(result\.error\) throw result\.error/)
assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/)
@@ -17,7 +17,7 @@ test('development runner requires an explicit development environment and gatewa
test('live suite uses production auth clients and domain adapters', () => {
assert.match(suite, /useAuthStore/)
assert.match(suite, /clearOverseasAccessToken/)
assert.match(suite, /refreshAuthenticatedSession/)
assert.match(suite, /createQuote/)
assert.match(suite, /createUsTruckOrder/)
assert.match(suite, /createLocalLooseboardOrder/)
@@ -8,7 +8,7 @@ import { defineComponent, nextTick } from 'vue'
import { mount, type VueWrapper } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { http, ApiResponseError } from '@/shared/api/http'
import { clearOverseasAccessToken, overseasHttp } from '@/shared/api/overseasHttp'
import { refreshAuthenticatedSession } from '@/shared/api/http'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam/api/platformIam'
import {
@@ -37,8 +37,15 @@ import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersV
import InventoryView from '@/modules/overseas-warehouse/pages/InventoryView.vue'
import DispatchesView from '@/modules/overseas-warehouse/pages/DispatchesView.vue'
import RelabelOrdersView from '@/modules/overseas-warehouse/pages/RelabelOrdersView.vue'
import OutboundsView from '@/modules/overseas-warehouse/pages/OutboundsView.vue'
import DeliveryOrdersView from '@/modules/overseas-warehouse/pages/DeliveryOrdersView.vue'
import ExceptionOrdersView from '@/modules/overseas-warehouse/pages/ExceptionOrdersView.vue'
import CustomerVisitsView from '@/modules/overseas-warehouse/pages/CustomerVisitsView.vue'
import InventoryTransactionsView from '@/modules/overseas-warehouse/pages/InventoryTransactionsView.vue'
import PalletsView from '@/modules/overseas-warehouse/pages/PalletsView.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
type Region = 'domestic' | 'overseas'
type Region = 'unified'
interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean }
interface CoverageGap { area: string; reason: string }
@@ -143,7 +150,7 @@ async function masterDataLifecycle() {
}
async function cleanupTransport(path: string, customerId: string, version: number) {
await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
await http.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
}
async function overseasBusinessLifecycle() {
const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE')
@@ -238,32 +245,135 @@ async function mountPage(component: Parameters<typeof mount>[0], method: string,
await waitForTrace(method, path, start)
await nextTick()
expect(wrapper.text()).not.toContain('服务暂时不可用')
return wrapper
}
async function exerciseListSearch(
wrapper: VueWrapper,
path: string,
model: Record<string, unknown>,
expectedParams: Record<string, unknown>,
) {
const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.exists()).toBe(true)
toolbar.vm.$emit('update:modelValue', { ...model })
await nextTick()
let start = traces.length
toolbar.vm.$emit('search', { ...model })
await waitForTrace('GET', path, start)
const searchRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
expect(searchRequests).toHaveLength(1)
expect(searchRequests[0]?.httpStatus).toBe(200)
expect(searchRequests[0]?.params).toMatchObject(expectedParams)
expect(wrapper.text()).not.toContain('加载失败')
start = traces.length
;(toolbar.vm as unknown as { reset(): void }).reset()
await waitForTrace('GET', path, start)
const resetRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
expect(resetRequests).toHaveLength(1)
expect(resetRequests[0]?.httpStatus).toBe(200)
const resetParams = resetRequests[0]?.params as Record<string, unknown> | undefined
for (const key of Object.keys(expectedParams)) expect(resetParams?.[key] == null || resetParams[key] === '').toBe(true)
}
async function pageRequestCoverage() {
await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes')
await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements')
await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' })
await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' })
await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders')
await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans')
await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances')
await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches')
await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications')
const quotes = await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes')
await exerciseListSearch(quotes, '/platform/v1/last-leg/master-data/overseas-quotes',
{ keyword: `${marker}-NO-MATCH`, warehouseId: '', providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' },
{ keyword: `${marker}-NO-MATCH`, providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' })
const purchases = await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements')
await exerciseListSearch(purchases, '/platform/v1/last-leg/overseas-procurements',
{ orderNo: `${marker}-NO-MATCH`, customerId: '', purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` },
{ orderNo: `${marker}-NO-MATCH`, purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` })
const trucks = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' })
await exerciseListSearch(trucks, '/platform/v1/last-leg/us-truck-orders',
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` },
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` })
const looseboards = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' })
await exerciseListSearch(looseboards, '/platform/v1/last-leg/local-looseboard-orders',
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` },
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` })
const lastMile = await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders')
await exerciseListSearch(lastMile, '/platform/v1/last-leg/orders',
{ orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, status: 'DELIVERED', abnormal: 'true', detained: 'false', createdAt: ['2098-01-01', '2098-01-02'] },
{ orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, currentStatus: 'DELIVERED', abnormal: true, detained: false, createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' })
let start = traces.length
await lastMile.findAll('.el-radio-button').find(item => item.text() === '待分配')!.trigger('click')
await waitForTrace('GET', '/platform/v1/last-leg/orders', start)
start = traces.length
await lastMile.findAll('.el-radio-button').find(item => item.text().startsWith('柜子'))!.trigger('click')
await waitForTrace('GET', '/platform/v1/last-leg/orders/container-summaries', start)
start = traces.length
await lastMile.findAll('.el-radio-button').find(item => item.text() === '全部')!.trigger('click')
await waitForTrace('GET', '/platform/v1/last-leg/orders', start)
const inbounds = await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans')
await exerciseListSearch(inbounds, '/platform/v1/last-leg/inbound-plans',
{ orderNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouse: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', inboundDate: ['2098-01-01', '2098-01-02'] },
{ planNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouseCode: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' })
const inventory = await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances')
await exerciseListSearch(inventory, '/platform/v1/last-leg/inventory/balances',
{ warehouseId: '', owner: `${marker}-OWNER`, sku: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, location: `${marker}-LOCATION`, status: 'AVAILABLE' },
{ customerName: `${marker}-OWNER`, skuCode: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, warehouseLocationCode: `${marker}-LOCATION`, inventoryStatus: 'AVAILABLE' })
const dispatches = await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches')
await exerciseListSearch(dispatches, '/platform/v1/last-leg/dispatches',
{ dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` },
{ dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` })
const relabels = await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications')
await exerciseListSearch(relabels, '/platform/v1/last-leg/relabel-applications',
{ relabelNo: `${marker}-RELABEL`, sku: `${marker}-SKU`, status: 'CANCELLED' },
{ relabelNo: `${marker}-RELABEL`, skuCode: `${marker}-SKU`, status: 'CANCELLED' })
const outbounds = await mountPage(OutboundsView, 'GET', '/platform/v1/last-leg/outbound-orders')
await exerciseListSearch(outbounds, '/platform/v1/last-leg/outbound-orders',
{ documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' },
{ documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' })
const deliveries = await mountPage(DeliveryOrdersView, 'GET', '/platform/v1/last-leg/delivery-orders')
await exerciseListSearch(deliveries, '/platform/v1/last-leg/delivery-orders',
{ documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, warehouseId: '', status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedAt: ['2098-01-01', '2098-01-02'] },
{ documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedFrom: '2098-01-01T00:00:00', plannedTo: '2098-01-02T23:59:59' })
const exceptions = await mountPage(ExceptionOrdersView, 'GET', '/platform/v1/last-leg/exception-orders')
await exerciseListSearch(exceptions, '/platform/v1/last-leg/exception-orders',
{ keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' },
{ keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' })
const visits = await mountPage(CustomerVisitsView, 'GET', '/platform/v1/last-leg/customer-visits')
await exerciseListSearch(visits, '/platform/v1/last-leg/customer-visits',
{ keyword: `${marker}-VISIT`, status: 'COMPLETED', completedAt: ['2098-01-01', '2098-01-02'] },
{ keyword: `${marker}-VISIT`, status: 'COMPLETED', completedFrom: '2098-01-01T00:00:00', completedTo: '2098-01-02T23:59:59' })
const ledger = await mountPage(InventoryTransactionsView, 'GET', '/platform/v1/last-leg/inventory/ledger')
await exerciseListSearch(ledger, '/platform/v1/last-leg/inventory/ledger',
{ occurredDate: ['2098-01-01', '2098-01-02'], customerName: `${marker}-CUSTOMER`, warehouse: `${marker}-WAREHOUSE`, sku: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND` },
{ customerName: `${marker}-CUSTOMER`, destinationWarehouseCode: `${marker}-WAREHOUSE`, skuCode: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND`, occurredFrom: '2098-01-01T00:00:00', occurredTo: '2098-01-02T23:59:59' })
const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/overseas-warehouse-resources/pallets')
await exerciseListSearch(pallets, '/platform/v1/overseas-warehouse-resources/pallets',
{ warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' },
{ keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' })
}
describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => {
describe.skipIf(!enabled).sequential('phase4 test frontend acceptance', () => {
beforeAll(async () => {
Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) })
globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} }
installTracingAdapter(http, 'domestic'); installTracingAdapter(overseasHttp, 'overseas')
installTracingAdapter(http, 'unified')
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(pinia)
await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '')
expect(localStorage.getItem('cc-admin-token')).toBeTruthy()
clearOverseasAccessToken()
await refreshAuthenticatedSession()
await listVehicles({ page: 1, size: 1 })
expect(traces.some(item => item.region === 'domestic' && item.path === '/auth/refresh')).toBe(true)
expect(traces.some(item => item.region === 'overseas' && item.authorization)).toBe(true)
expect(traces.some(item => item.region === 'unified' && item.path === '/auth/refresh')).toBe(true)
expect(traces.some(item => item.region === 'unified' && item.authorization)).toBe(true)
})
it('runs marker-owned adapter lifecycles and real page requests', async () => {
await masterDataLifecycle()
@@ -278,7 +388,7 @@ describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', (
const reportDir = resolve(process.cwd(), 'reports')
mkdirSync(reportDir, { recursive: true })
const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources }
const file = resolve(reportDir, `phase4-frontend-development-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
const file = resolve(reportDir, `phase4-frontend-test-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.info(`PHASE4_FRONTEND_REPORT=${file}`)
})
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
set +x
export DOCKER_CONFIG
DOCKER_CONFIG=$(mktemp -d)
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
trap cleanup EXIT HUP INT TERM
docker login -u "$1" --password-stdin "$2"
docker pull "$3"
+5 -5
View File
@@ -13,15 +13,15 @@
- 服务端字段变化先核对 Controller、DTO 和 OpenAPI,再更新本应用的类型、API 适配层和测试;本包不反向定义服务端行为。
- 避免建立宽泛的万能类型或引入重量级依赖。
## 一期/二期/三期共享模型索引
## 订单与客户/入库测量与计费/国内货柜履约共享模型索引
- 一期共享消费者:`customers`、`iam`、`warehouses`、`master-data`、`first-leg-orders`;稳定导出集中在 `src/index.ts`(用户、导航、响应、仓库摘要、订单状态)。
- 二期共享消费者:`inbounds`、`warehouse-resources`、`fba-pricing`、`dimension-config`、`unified-orders`;单模块使用的测量、费用、托盘、库位类型留在模块内,只有真实跨模块复用才新增导出。
- 三期仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Admin 模块复用时才新增共享导出;先读 `../../../docs/三期/三期开工门禁.md`。订单、柜次、证据和报关的单模块类型仍留在所属模块,不在 shared 预留猜测字段。
- 订单与客户共享消费者:`customers`、`iam`、`warehouses`、`master-data`、`first-leg-orders`;稳定导出集中在 `src/index.ts`(用户、导航、响应、仓库摘要、订单状态)。
- 入库测量与计费共享消费者:`inbounds`、`warehouse-resources`、`fba-pricing`、`dimension-config`、`unified-orders`;单模块使用的测量、费用、托盘、库位类型留在模块内,只有真实跨模块复用才新增导出。
- 国内货柜履约仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Admin 模块复用时才新增共享导出;先读 `../../../docs/国内货柜履约/国内货柜履约开工门禁.md`。订单、柜次、证据和报关的单模块类型仍留在所属模块,不在 shared 预留猜测字段。
定位规则:先在 `src/index.ts` 确认公共导出,再搜索 `src/` 消费者;后端字段或状态有疑问时回到对应 Controller/DTO/OpenAPI,不在 shared 中猜测业务规则。
阶段依据(路径相对本文件所在目录):一期 `../../../docs/一期/`,二期 `../../../docs/二期/`,三期 `../../../docs/三期/`;共享包不保存阶段进度,也不承载跨应用(Client)源码复用。
阶段依据(路径相对本文件所在目录):订单与客户 `../../../docs/订单与客户/`,入库测量与计费 `../../../docs/入库测量与计费/`,国内货柜履约 `../../../docs/国内货柜履约/`;共享包不保存阶段进度,也不承载跨应用(Client)源码复用。
## 契约与验证
+3 -2
View File
@@ -31,7 +31,7 @@ describe('domestic outbound list responsive contracts', () => {
const content = source(path)
const columns = [...content.matchAll(/\{[^{}]*(?:key|prop): '[^']+'[^{}]*label: '[^']+'[^{}]*\}/g)]
.map((match) => match[0])
.filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'"))
.filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'") && !column.includes('priority:') && !column.includes("key: 'view'"))
expect(columns.length).toBeGreaterThan(1)
for (const column of columns) expect(column).toMatch(/\b(?:width|minWidth):\s*\d+/)
})
@@ -42,7 +42,8 @@ describe('domestic outbound list responsive contracts', () => {
const listLayout = source('src/shared/components/ListPageLayout.vue')
expect(queryToolbar).toContain('@media (max-width: 720px)')
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; }')
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; flex: 0 0 auto; }')
expect(queryToolbar).toContain('.query-toolbar__control { width: 100%; flex: 0 0 auto; }')
expect(queryToolbar).toContain('grid-template-columns: repeat(auto-fit, minmax(120px, 1fr))')
expect(dataTable).toContain('@media (max-width: 720px)')
expect(dataTable).toContain('flex-direction: column')
+2 -2
View File
@@ -6,7 +6,7 @@ const pages = [
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:list', '@retry="loadRows"'],
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView', '@retry="load"'],
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', "{{ row.status === 'DRAFT' ? '编辑' : '查看' }}", '@retry="load"'],
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', '<Eye :size="15" />详情', '@retry="load"'],
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', '@click="openDetail(row as TruckAppointmentResource)">详情</el-button>', '@retry="load"'],
] as const
function source(path: string) {
@@ -30,7 +30,7 @@ describe('domestic outbound list state contracts', () => {
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute))"')
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + \':\' + JSON.stringify(renderedRoute.params)"')
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
})
+5 -2
View File
@@ -60,12 +60,15 @@ describe('AdminSidebarMenu', () => {
const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))
expect(groupTitles).toContain('客户管理')
expect(groupTitles).toContain('供应商中心')
expect(groupTitles).not.toContain('供应商中心')
expect(groupTitles).toContain('通用')
expect(groupTitles).toContain('系统管理')
expect(groupTitles).not.toContain('预约管理')
expect(groupTitles).not.toContain('财务管理')
expect(paths).not.toContain('/finance/customer-accounts')
expect(paths).toContain('/dashboard')
expect(paths).toContain('/orders')
expect(paths).toContain('/master-data/express-companies')
expect(paths).not.toContain('/master-data/express-companies')
expect(paths).not.toContain('/orders/first-leg')
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
expect(paths).toContain('/downloads')
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -32,7 +33,7 @@ async function confirmDiscard(tabIds: readonly string[]) {
await ElMessageBox.confirm(
`以下内容尚未保存:${labels.join('、')}。关闭页签后将放弃这些修改。`,
'放弃未保存的修改?',
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning' },
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
)
return true
} catch {
+1 -1
View File
@@ -125,7 +125,7 @@ describe('AppLayout', () => {
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('消息管理')
expect(wrapper.text()).toContain('模板管理')
expect(wrapper.text()).not.toContain('我的消息')
expect(wrapper.text()).toContain('我的消息')
const notificationTrigger = wrapper.get('.notification-trigger')
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
})
+7 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -37,7 +38,8 @@ const menuExpanded = computed(() => (
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
))
const canOpenNotificationManagement = computed(() => (
access.can('platform:notification:task:list')
access.can('platform:notification:view')
|| access.can('platform:notification:task:list')
|| access.can('platform:notification:template:list')
))
@@ -56,7 +58,7 @@ async function pruneInaccessibleTabs() {
await ElMessageBox.alert(
`账号权限已变化,以下未保存内容无法继续编辑:${[...new Set(discardedDirtyWork.map((entry) => entry.label))].join('、')}。相关页签将关闭。`,
'权限已变化',
{ confirmButtonText: '知道了', type: 'warning' },
{ confirmButtonText: '知道了', type: 'warning', customClass: 'admin-density-confirm' },
).catch(() => undefined)
}
const previousActiveId = workspace.activeId
@@ -75,6 +77,7 @@ async function handleLogout() {
const dirtyNotice = dirtyLabels.length ? ` 当前还有未保存内容:${[...new Set(dirtyLabels)].join('、')},退出后将丢失。` : ''
await ElMessageBox.confirm(`确定退出管理后台吗?${dirtyNotice}`, '退出登录', {
confirmButtonText: '退出',
customClass: 'admin-density-confirm',
cancelButtonText: '取消',
})
} catch {
@@ -177,6 +180,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<Bell :size="18" /><span>消息中心</span>
</button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
</el-dropdown-menu></template>
@@ -193,7 +197,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<component
:is="Component"
v-if="renderedRoute.meta.keepAlive !== false"
:key="workspace.cacheKey(workspaceRouteId(renderedRoute))"
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
/>
</KeepAlive>
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
+2 -1
View File
@@ -53,7 +53,7 @@ describe('admin router access metadata', () => {
it('keeps smart loading as an order dialog while retaining the single-container deep link', () => {
expect(router.getRoutes().some((item) => item.name === 'global-smart-loading-workbench')).toBe(false)
const detail = router.getRoutes().find((item) => item.name === 'smart-loading-workbench')
expect(detail?.path).toBe('/container-loading/:containerId/smart-loading')
expect(detail?.path).toBe('/container-loading/domestic/:containerId/smart-loading')
expect(detail?.meta.permission).toBe('platform:firstleg:smart-loading:list')
})
@@ -110,6 +110,7 @@ describe('admin router access metadata', () => {
it('registers message-center routes with the frozen IAM permissions', () => {
const expected = [
['notification-inbox', '/notifications', 'platform:notification:view'],
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
]
+10 -1
View File
@@ -1,3 +1,4 @@
import '@/styles/adminDensity.css'
import { createRouter, createWebHistory } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import AppLayout from '@/app/layouts/AppLayout.vue'
@@ -30,6 +31,10 @@ import { supplierRoutes } from '@/modules/suppliers/routes'
import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes'
import { selfOperatedRoutes } from '@/modules/self-operated/routes'
const router = createRouter({
history: createWebHistory(),
@@ -74,6 +79,10 @@ const router = createRouter({
...masterDataRoutes,
...downloadRoutes,
...iamRoutes,
...pdaReleaseRoutes,
...sailingCalendarRoutes,
...appointmentCalendarRoutes,
...selfOperatedRoutes,
{
path: '403',
name: 'forbidden',
@@ -99,7 +108,7 @@ router.beforeEach(async (to, from) => {
await ElMessageBox.confirm(
`当前页面还有未保存内容:${[...new Set(blocking.map((entry) => entry.label))].join('、')}。继续后将放弃这些修改。`,
'放弃未保存的修改?',
{ confirmButtonText: '放弃并继续', cancelButtonText: '继续编辑', type: 'warning' },
{ confirmButtonText: '放弃并继续', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
)
unsaved.removeWorkspaces([fromId])
useWorkspaceTabsStore().bumpCacheEpoch(fromId)
+193 -24
View File
@@ -1,5 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
post: vi.fn(),
@@ -60,13 +60,11 @@ function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
expiresIn: 1800,
refreshToken,
refreshExpiresIn: 604800,
overseasAccessToken: 'overseas-token',
overseasExpiresIn: 900,
},
}
}
function domesticOnlyLoginResponse() {
function unifiedLoginResponse() {
return {
code: '200',
message: 'success',
@@ -89,13 +87,19 @@ describe('admin auth session', () => {
mocks.refreshHandler = null
})
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
afterEach(() => vi.unstubAllGlobals())
it('accepts the unified access and refresh token response', async () => {
mocks.post.mockResolvedValue(unifiedLoginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await auth.login('platform_admin', 'password')
expect(mocks.post).toHaveBeenCalledWith('/auth/login', {
loginId: 'platform_admin',
password: 'password',
})
expect(auth.isAuthenticated).toBe(true)
expect(auth.refreshToken).toBe('refresh-domestic')
})
@@ -110,8 +114,6 @@ describe('admin auth session', () => {
expiresIn: '7200',
refreshToken: 'refresh-serialized',
refreshExpiresIn: '604800',
overseasAccessToken: null,
overseasExpiresIn: '0',
},
})
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
@@ -178,29 +180,43 @@ describe('admin auth session', () => {
expect(auth.isAuthenticated).toBe(true)
})
it('refreshes a valid persisted session so newly granted permissions appear after reload', async () => {
it('reuses a valid persisted session without blocking reload on refresh', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
const refreshedToken = token({
token_version: 2,
})
mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated'))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([
'platform:warehouse:list',
'platform:firstleg:inbound:list',
])
const auth = useAuthStore()
await expect(auth.restoreSession()).resolves.toBe(true)
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
expect([...auth.permissionCodes]).toEqual([
'platform:warehouse:list',
'platform:firstleg:inbound:list',
])
expect(localStorage.getItem('cc-admin-permission-codes'))
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
expect(mocks.post).not.toHaveBeenCalled()
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
expect(localStorage.getItem('cc-admin-permission-codes')).toBe('["platform:warehouse:list"]')
})
it('reuses a session rotated by another tab without refreshing it again', async () => {
const previousToken = token({ token_version: 1 })
const rotatedToken = token({ token_version: 2 })
localStorage.setItem('cc-admin-token', previousToken)
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
vi.stubGlobal('navigator', {
locks: {
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<void>) => {
localStorage.setItem('cc-admin-token', rotatedToken)
localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated')
localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]')
return callback()
}),
},
})
const auth = useAuthStore()
await expect(auth.refreshSession()).resolves.toBeUndefined()
expect(mocks.post).not.toHaveBeenCalled()
expect(auth.token).toBe(rotatedToken)
expect(auth.refreshToken).toBe('refresh-rotated')
expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list'])
})
it('does not let an in-flight refresh restore a session after logout', async () => {
@@ -225,4 +241,157 @@ describe('admin auth session', () => {
expect(auth.refreshToken).toBe('')
expect(localStorage.getItem('cc-admin-token')).toBeNull()
})
it('rejects an old refresh after another tab has switched to a different account', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'admin-refresh')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
let finishRefresh!: (value: ReturnType<typeof loginResponse>) => void
mocks.post.mockImplementation(() => new Promise(resolve => { finishRefresh = resolve }))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const listeners = vi.spyOn(window, 'addEventListener')
const auth = useAuthStore()
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
listeners.mockRestore()
const refreshing = auth.refreshSession()
await Promise.resolve()
const salesToken = token({ user_id: '2000002', username: 'sales' })
localStorage.setItem('cc-admin-token', salesToken)
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
expect(auth.user?.account).toBe('sales')
finishRefresh(loginResponse(token(), 'admin-rotated'))
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
expect(auth.user?.account).toBe('sales')
expect(auth.token).toBe(salesToken)
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
})
it('discards late permission results after another tab switches accounts without clearing the new session', async () => {
mocks.post.mockResolvedValue(loginResponse())
let finishPermissions!: (value: string[]) => void
let permissionsStarted!: () => void
const started = new Promise<void>(resolve => { permissionsStarted = resolve })
mocks.listCurrentPlatformPermissionCodes.mockImplementation(() => {
permissionsStarted()
return new Promise(resolve => { finishPermissions = resolve })
})
const listeners = vi.spyOn(window, 'addEventListener')
const auth = useAuthStore()
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
listeners.mockRestore()
const login = auth.login('platform_admin', 'password')
await started
const salesToken = token({ user_id: '2000002', username: 'sales' })
localStorage.setItem('cc-admin-token', salesToken)
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
finishPermissions(['platform:warehouse:list'])
await expect(login).rejects.toThrow('登录会话已被后续操作替代')
expect(auth.user?.account).toBe('sales')
expect(auth.token).toBe(salesToken)
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
expect(mocks.post).toHaveBeenCalledTimes(1)
})
it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => {
const previous = token()
const rotated = token({ token_version: 2 })
localStorage.setItem('cc-admin-token', previous)
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } })
.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated'))
.mockResolvedValueOnce({ code: '200', data: null })
const auth = useAuthStore()
await expect(auth.logout()).resolves.toBeUndefined()
expect(mocks.post).toHaveBeenCalledTimes(3)
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/refresh', { refreshToken: 'refresh-old' })
expect(mocks.post).toHaveBeenNthCalledWith(3, '/auth/logout', { refreshToken: 'refresh-rotated' }, {
headers: { Authorization: `Bearer ${rotated}` },
})
expect(auth.isAuthenticated).toBe(false)
expect(localStorage.getItem('cc-admin-token')).toBeNull()
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
expect(mocks.listCurrentPlatformPermissionCodes).not.toHaveBeenCalled()
})
it('revokes a persisted refresh session when startup already removed its expired access token', async () => {
localStorage.setItem('cc-admin-token', token({ exp: 1 }))
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
const rotated = token()
mocks.post.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated'))
.mockResolvedValueOnce({ code: '200', data: null })
const auth = useAuthStore()
expect(auth.token).toBe('')
await auth.logout()
expect(mocks.post).toHaveBeenCalledTimes(2)
expect(mocks.post).toHaveBeenNthCalledWith(1, '/auth/refresh', { refreshToken: 'refresh-old' })
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-rotated' }, {
headers: { Authorization: `Bearer ${rotated}` },
})
expect(auth.isAuthenticated).toBe(false)
})
it('leaves a subsequent login intact while finishing revocation of the previous session', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
let finishRefresh: ((value: ReturnType<typeof loginResponse>) => void) | undefined
const started = new Promise<void>(resolve => {
mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } })
.mockImplementationOnce(() => { resolve(); return new Promise(done => { finishRefresh = done }) })
})
const auth = useAuthStore()
const loggingOut = auth.logout()
// Attach immediately so a regression rejecting early does not become an unhandled rejection.
const result = loggingOut.then(() => null, error => error)
await Promise.race([started, loggingOut.catch(() => undefined)])
expect(finishRefresh).toBeTypeOf('function')
const newAccess = token({ user_id: '1000002', username: 'second' })
mocks.post.mockResolvedValueOnce(loginResponse(newAccess, 'refresh-new-login'))
.mockResolvedValueOnce({ code: '200', data: null })
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
await auth.login('second', 'password')
const revocationAccess = token({ token_version: 3 })
finishRefresh?.(loginResponse(revocationAccess, 'refresh-revocation-only'))
expect(await result).toBeNull()
expect(auth.token).toBe(newAccess)
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('refresh-new-login')
expect(mocks.post).toHaveBeenLastCalledWith('/auth/logout', { refreshToken: 'refresh-revocation-only' }, {
headers: { Authorization: `Bearer ${revocationAccess}` },
})
})
it('does not refresh or retry an ambiguous logout transport failure', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
const failure = { isAxiosError: true, response: { status: 503 } }
mocks.post.mockRejectedValue(failure)
const auth = useAuthStore()
await expect(auth.logout()).rejects.toBe(failure)
expect(mocks.post).toHaveBeenCalledTimes(1)
expect(auth.isAuthenticated).toBe(false)
})
it.each(['refresh', 'second-logout'])('stops after a %s rejection and never restores the logged-out session', async stage => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
const unauthorized = { isAxiosError: true, response: { status: 401 } }
mocks.post.mockRejectedValueOnce(unauthorized)
if (stage === 'second-logout') mocks.post.mockResolvedValueOnce(loginResponse())
mocks.post.mockRejectedValueOnce(unauthorized)
const auth = useAuthStore()
await expect(auth.logout()).rejects.toBe(unauthorized)
expect(mocks.post).toHaveBeenCalledTimes(stage === 'refresh' ? 2 : 3)
expect(localStorage.getItem('cc-admin-token')).toBeNull()
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
})
})
+38 -31
View File
@@ -1,5 +1,6 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { isAxiosError } from 'axios'
import type { CurrentUser } from '@chongchong/shared'
import {
http,
@@ -8,7 +9,6 @@ import {
type ApiEnvelope,
} from '@/shared/api/http'
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
import { clearOverseasAccessToken, replaceOverseasAccessToken } from '@/shared/api/overseasHttp'
import { decodeJwtClaims } from './jwt'
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
import { coordinateCrossTabRefresh } from './crossTabRefresh'
@@ -24,8 +24,6 @@ interface LoginResponse {
expiresIn: number | string
refreshToken: string
refreshExpiresIn: number | string
overseasAccessToken?: string | null
overseasExpiresIn?: number | string
}
class ExpiredAccessTokenError extends Error {}
@@ -100,10 +98,6 @@ export const useAuthStore = defineStore('admin-auth', () => {
async function applySession(result: LoginResponse, expectedGeneration: number) {
const session = sessionFromToken(result.accessToken)
ensureCurrentGeneration(expectedGeneration)
replaceOverseasAccessToken(
result.overseasAccessToken ?? null,
parseLifetimeSeconds(result.overseasExpiresIn ?? 0, 'overseasExpiresIn'),
)
token.value = result.accessToken
refreshToken.value = result.refreshToken
user.value = session.user
@@ -123,7 +117,6 @@ export const useAuthStore = defineStore('admin-auth', () => {
function clearSession() {
sessionGeneration += 1
clearOverseasAccessToken()
token.value = ''
refreshToken.value = ''
user.value = null
@@ -151,7 +144,10 @@ export const useAuthStore = defineStore('admin-auth', () => {
try {
const session = sessionFromToken(storedToken)
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
if (token.value !== storedToken) clearOverseasAccessToken()
// A different account in another tab supersedes this tab's pending login/refresh responses.
if (user.value?.id !== session.user.id || user.value?.account !== session.user.account) {
sessionGeneration += 1
}
token.value = storedToken
refreshToken.value = storedRefreshToken
user.value = session.user
@@ -166,9 +162,29 @@ export const useAuthStore = defineStore('admin-auth', () => {
}
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, {
headers: { Authorization: `Bearer ${accessToken}` },
})
async function revoke(access: string, refresh: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/auth/logout', { refreshToken: refresh }, { headers: { Authorization: `Bearer ${access}` } },
)
unwrapApiResponse(response)
}
if (accessToken) {
try {
await revoke(accessToken, refreshTokenToRevoke)
return
} catch (error) {
if (!isAxiosError(error) || error.response?.status !== 401) throw error
}
}
// Logout has already cleared local state. Refresh only this captured pair
// for revocation; never apply it to this tab or overwrite a subsequent login.
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh', { refreshToken: refreshTokenToRevoke },
)
const rotated = unwrapApiResponse<LoginResponse>(response)
sessionFromToken(rotated.accessToken)
if (!rotated.refreshToken) throw new Error('退出时刷新响应缺少会话凭据')
await revoke(rotated.accessToken, rotated.refreshToken)
}
async function login(account: string, password: string) {
@@ -176,7 +192,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
try {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/login',
{ username: account, password },
{ loginId: account, password },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result, expectedGeneration)
@@ -203,14 +219,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
observedRefreshToken,
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
reuseCurrentSession: async () => {
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh',
{ refreshToken: storedRefreshToken },
)
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
applyStoredSession()
ensureCurrentGeneration(expectedGeneration)
if (!token.value || !refreshToken.value) throw new Error('登录状态已过期')
},
refresh: async (currentRefreshToken) => {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
@@ -229,15 +240,11 @@ export const useAuthStore = defineStore('admin-auth', () => {
}
async function restoreSession() {
if (isAuthenticated.value) {
if (!refreshToken.value) return true
try {
await refreshSession()
return true
} catch {
return false
}
}
// The constructor has already validated a persisted access token. Reusing
// a still-valid token lets the app mount immediately; refresh only remains
// necessary after the access token has expired and the constructor has
// retained the persisted refresh token for recovery.
if (isAuthenticated.value) return true
if (!refreshToken.value) return false
try {
await refreshSession()
@@ -263,7 +270,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
const accessToken = token.value
const tokenToRevoke = refreshToken.value
clearSession()
if (accessToken && tokenToRevoke) {
if (tokenToRevoke) {
await revokeTokenPair(accessToken, tokenToRevoke)
}
}
+22 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { coordinateCrossTabRefresh } from './crossTabRefresh'
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh'
afterEach(() => vi.unstubAllGlobals())
@@ -10,7 +10,7 @@ describe('cross-tab refresh coordination', () => {
const reuseCurrentSession = vi.fn(async () => 'access-new')
vi.stubGlobal('navigator', {
locks: {
request: vi.fn(async (_name: string, callback: () => Promise<string>) => {
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => {
currentRefreshToken = 'refresh-new'
return callback()
}),
@@ -32,7 +32,7 @@ describe('cross-tab refresh coordination', () => {
it('performs the refresh when the token has not changed', async () => {
const refresh = vi.fn(async () => 'access-new')
vi.stubGlobal('navigator', {
locks: { request: vi.fn(async (_name: string, callback: () => Promise<string>) => callback()) },
locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => callback()) },
})
await expect(coordinateCrossTabRefresh({
@@ -61,4 +61,23 @@ describe('cross-tab refresh coordination', () => {
expect(refresh).toHaveBeenCalledWith('refresh-old')
})
it('aborts a refresh lock that cannot be acquired', async () => {
vi.stubGlobal('navigator', {
locks: {
request: vi.fn((_name: string, options: LockOptions) => new Promise((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
})),
},
})
await expect(coordinateCrossTabRefresh({
lockName: 'admin-refresh',
lockTimeoutMs: 5,
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => 'refresh-old',
reuseCurrentSession: vi.fn(),
refresh: vi.fn(),
})).rejects.toBeInstanceOf(CrossTabRefreshTimeoutError)
})
})
+20 -1
View File
@@ -1,11 +1,21 @@
export interface CrossTabRefreshOptions<T> {
lockName: string
lockTimeoutMs?: number
observedRefreshToken: string
currentRefreshToken: () => string
reuseCurrentSession: () => Promise<T>
refresh: (refreshToken: string) => Promise<T>
}
export class CrossTabRefreshTimeoutError extends Error {
constructor() {
super('登录状态刷新等待超时,请重新登录')
this.name = 'CrossTabRefreshTimeoutError'
}
}
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
const run = async () => {
const currentRefreshToken = options.currentRefreshToken()
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
throw error
}
}
return navigator.locks.request(options.lockName, run)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
try {
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
} catch (error) {
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
throw error
} finally {
clearTimeout(timeout)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { decodeJwtClaims } from './jwt'
function token(payload: string) {
return `header.${Buffer.from(payload).toString('base64url')}.signature`
}
describe('JWT claim decoding', () => {
it.each(['2110664514823360515', '"2110664514823360515"'])('preserves the exact account ID encoded as %s', userId => {
const claims = decodeJwtClaims(token(`{"user_id":${userId},"app_code":"PLATFORM","exp":1792060000,"iat":1792058200}`))
expect(claims.user_id).toBe('2110664514823360515')
expect(claims.exp).toBe(1792060000)
expect(claims).toHaveProperty('iat', 1792058200)
})
it('retains safe numeric IDs and ordinary claims', () => {
expect(decodeJwtClaims(token('{"user_id":123,"username":"operator","roles":["ADMIN"],"exp":1}')))
.toEqual({ user_id: 123, username: 'operator', roles: ['ADMIN'], exp: 1 })
})
it.each(['', 'header..signature'])('rejects a missing payload: %s', value => {
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 格式无效')
})
it.each(['header.%.signature', token('{"user_id":2110664514823360515,')])('rejects malformed encoding or JSON: %s', value => {
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 无法解析')
})
})
+3 -1
View File
@@ -1,3 +1,5 @@
import { parseJsonWithSafeIntegers } from '@chongchong/shared'
export interface PlatformJwtClaims {
user_id?: string | number
username?: string
@@ -19,7 +21,7 @@ export function decodeJwtClaims(token: string): PlatformJwtClaims {
const payload = token.split('.')[1]
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
try {
return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims
return parseJsonWithSafeIntegers(decodeBase64Url(payload)) as PlatformJwtClaims
} catch {
throw new Error('登录响应中的 accessToken 无法解析')
}
+60 -72
View File
@@ -8,71 +8,49 @@ import {
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps the latest prototype menu skeleton in source order and groups additions under general', () => {
expect(adminNavigation.map((item) => item.title)).toEqual([
'首页数据看板',
'客户管理',
'国内仓',
'海外派送',
'供应商中心',
'货柜管理',
'海外业务',
'海外仓',
'财务管理',
'销售管理',
'通用',
'系统管理',
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/owned-business'])
const writeOnly = filterAdminNavigation(adminNavigation, new Set([
'platform:fleet:owned-business:edit', 'platform:fleet:owned-business:upload',
]))
expect(getNavigationLeaves(writeOnly).some(node => node.path === '/overseas-master-data/owned-business')).toBe(false)
})
it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list']))
expect(getNavigationLeaves(read).map(item => item.path)).toEqual([
'/dashboard', '/self-operated/overview', '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection',
])
expect(adminNavigation.find((item) => item.key === 'customers')?.children?.map((item) => item.title)).toEqual([
'客户列表',
'海外仓阶梯费用',
])
expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([
'订单列表',
'订单测量',
'测量数据审核',
'库位管理',
'整柜业务',
])
expect(adminNavigation.find((item) => item.key === 'service-center')).toBeUndefined()
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
'供应商列表',
'快递公司',
'往来单位',
])
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
'订舱列表',
'拖车列表',
'货柜列表',
'预装清单',
'装柜清单',
])
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
'尾程订单',
'入库单',
'排车列表',
'换标单',
'托盘管理',
'库存管理',
])
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
'产品信息',
'费用项目',
'汇率管理',
'仓库管理',
'海外仓报价',
'船期价格表',
'车辆列表',
'下载中心',
const sync = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:sync']))
expect(getNavigationLeaves(sync).map(item => item.path)).toEqual(['/dashboard'])
})
it('offers the common container menu to overseas-only operators', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list']))
expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic'])
})
it('matches the latest prototype sidebar using only implemented menu entries', () => {
// 新版.html sidebar (1404–1539), ordered by its data-menu CSS rules (45–56).
const allPermissions = new Set(getNavigationLeaves(adminNavigation).flatMap(node => node.permission ? [node.permission] : []))
const visible = filterAdminNavigation(adminNavigation, allPermissions)
expect(visible.map(node => ({ title: node.title, children: node.children?.map(child => child.title) ?? [] }))).toEqual([
{ title: '首页数据看板', children: [] },
{ title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] },
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '预约管理', children: ['预约列表'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
'用户管理',
'角色权限',
'租户管理',
'品牌设置',
'菜单权限',
])
})
it('assigns every page a unique path and only shares permissions backed by the API contract', () => {
@@ -88,7 +66,8 @@ describe('adminNavigation', () => {
expect(paths).toContain('/downloads')
expect(paths).not.toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine']))
expect(paths).toContain('/master-data/fee-items')
expect(paths).toContain('/container-loading')
expect(paths).toContain('/container-loading/domestic')
expect(paths).toContain('/container-loading/overseas')
expect(paths).not.toContain('/container-loading/smart-loading')
expect(paths).toContain('/loading-plans')
expect(paths).toContain('/loading-lists')
@@ -112,7 +91,7 @@ describe('adminNavigation', () => {
expect(paths).toContain('/system/permissions')
expect(new Set(paths).size).toBe(paths.length)
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
expect(new Set(duplicatedPermissions)).toEqual(new Set())
expect(new Set(duplicatedPermissions)).toEqual(new Set(['platform:self-operated-analytics:list']))
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
})
@@ -141,6 +120,10 @@ describe('adminNavigation', () => {
'platform:overseas-warehouse:last-mile-order:list',
'platform:overseas-warehouse:inbound-order:list',
'platform:overseas-warehouse:dispatch:list',
'platform:overseas-warehouse:outbound:list',
'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:relabel-order:list',
'platform:overseas-warehouse:pallet:list',
'platform:overseas-warehouse:inventory:list',
@@ -157,6 +140,9 @@ describe('adminNavigation', () => {
'/overseas-warehouse/last-mile-orders',
'/overseas-warehouse/inbound-orders',
'/overseas-warehouse/dispatches',
'/overseas-warehouse/outbounds',
'/overseas-warehouse/delivery-orders',
'/overseas-warehouse/exception-orders',
'/overseas-warehouse/relabel-orders',
'/overseas-warehouse/pallets',
'/overseas-warehouse/inventory',
@@ -169,7 +155,7 @@ describe('adminNavigation', () => {
]))
expect(paths).not.toContain('/overseas-warehouse/cartons')
expect(paths).not.toContain('/overseas-warehouse/products')
expect(paths.some((path) => path.includes('outbound') || path.includes('delivery'))).toBe(false)
expect(paths).toEqual(expect.arrayContaining(['/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders']))
})
it('filters protected pages without mutating the source tree', () => {
@@ -196,12 +182,10 @@ describe('adminNavigation', () => {
]))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '供应商中心', '通用'])
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内头程', '通用'])
expect(paths).toEqual([
'/dashboard',
'/orders',
'/master-data/express-companies',
'/master-data/counterparties',
'/warehouses',
])
expect(paths).toContain('/dashboard')
@@ -221,26 +205,30 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
})
it('shows the public supplier list only with the list permission', () => {
it('shows the restored supplier entry under general only with its list permission', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
const missing = filterAdminNavigation(adminNavigation, new Set())
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
expect(getNavigationLeaves(filterAdminNavigation(adminNavigation, new Set(['platform:supplier:create', 'platform:supplier:edit']))).map(item => item.path)).toEqual(['/dashboard'])
})
it('shows container list and downstream lists without a standalone smart-loading menu', () => {
it('shows split container workbenches without a standalone smart-loading menu', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:container:list',
'platform:firstleg:preload:list',
'platform:firstleg:loading-list:list',
'platform:firstleg:terminal-drayage:list',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/container-loading',
'/loading-plans',
'/container-loading/domestic',
'/loading-lists',
'/loading-plans',
])
})
+71 -25
View File
@@ -1,6 +1,7 @@
import type { Component } from 'vue'
import {
Building2,
CalendarDays,
ContactRound,
ClipboardList,
CircleDollarSign,
@@ -16,6 +17,7 @@ import {
} from '@lucide/vue'
import {
PrototypeContainerIcon,
PrototypeCustomerServiceIcon,
PrototypeCustomerIcon,
PrototypeDashboardIcon,
PrototypeDeliveryIcon,
@@ -25,8 +27,8 @@ import {
PrototypeOverseasBusinessIcon,
PrototypeOverseasWarehouseIcon,
PrototypeSalesIcon,
PrototypeSupplierIcon,
PrototypeSystemIcon,
PrototypeVehicleIcon,
} from './prototypeMenuIcons'
export interface AdminNavigationNode {
@@ -61,14 +63,16 @@ export const adminNavigation: AdminNavigationNode[] = [
},
{
key: 'domestic-warehouse',
title: '国内仓',
title: '国内头程',
icon: PrototypeDomesticWarehouseIcon,
children: [
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
{ key: 'sailing-calendar', title: '船期管理', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' },
],
},
{
@@ -83,16 +87,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'outbound-selfpick', title: '自提单', icon: ClipboardList, path: '/prototype/overseas-delivery/self-pickup' },
],
},
{
key: 'supplier-center',
title: '供应商中心',
icon: PrototypeSupplierIcon,
children: [
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
],
},
{
key: 'container-management',
title: '货柜管理',
@@ -100,9 +94,49 @@ export const adminNavigation: AdminNavigationNode[] = [
children: [
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list', hidden: true },
],
},
{
key: 'overseas-inventory',
title: '海外尾程',
icon: PrototypeOverseasWarehouseIcon,
children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
{ key: 'fba-cargo-discard', title: '废弃单', icon: ClipboardList, path: '/overseas-warehouse/cargo-discards', permission: 'platform:overseas-warehouse:discard:list' },
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
{ key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' },
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
],
},
{
key: 'appointment-management',
title: '预约管理',
icon: CalendarDays,
children: [
{ key: 'appointment-calendar', title: '预约列表', icon: CalendarDays, path: '/appointment-calendar', permission: 'platform:appointment:list' },
],
},
{
key: 'vehicle-management',
title: '车辆管理',
icon: PrototypeVehicleIcon,
children: [
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
],
},
{
@@ -113,19 +147,16 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
],
},
{
key: 'overseas-inventory',
title: '海外仓',
icon: PrototypeOverseasWarehouseIcon,
key: 'customer-service',
title: '客户服务',
icon: PrototypeCustomerServiceIcon,
children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true },
],
},
{
@@ -151,19 +182,33 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
],
},
{
key: 'self-operated',
title: '海外仓',
icon: PrototypeSalesIcon,
children: [
{ key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' },
{ key: 'self-operated-sales', title: '销量分析', icon: ClipboardList, path: '/self-operated/sales', permission: 'platform:self-operated-analytics:list' },
{ key: 'self-operated-inventory', title: '库存分析', icon: WarehouseIcon, path: '/self-operated/inventory', permission: 'platform:self-operated-analytics:list' },
{ key: 'self-operated-collection', title: '数据采集', icon: Database, path: '/self-operated/collection', permission: 'platform:self-operated-analytics:list' },
],
},
{
key: 'general',
title: '通用',
icon: PrototypeGeneralIcon,
children: [
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
{ key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' },
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
{ key: 'overseas-quotes', title: '海外仓报价', icon: CircleDollarSign, path: '/overseas-master-data/quotes', permission: 'platform:overseas-master-data:quote:list' },
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
],
},
{
@@ -176,6 +221,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true },
{ key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true },
{ key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' },
{ key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list', hidden: true },
],
},
]
+12
View File
@@ -120,6 +120,18 @@ export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeO
h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
])
export const PrototypeVehicleIcon = createPrototypeMenuIcon('PrototypeVehicleIcon', [
h('path', { d: 'M3 6h12v11H3zM15 10h3l3 3v4h-6z', stroke: '#149b8f', 'stroke-width': 1.8, 'stroke-linejoin': 'round' }),
h('circle', { cx: 7, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }),
h('circle', { cx: 18, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }),
])
export const PrototypeCustomerServiceIcon = createPrototypeMenuIcon('PrototypeCustomerServiceIcon', [
h('path', { d: 'M4 13v-2a8 8 0 0116 0v2', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('path', { d: 'M4 13a2 2 0 012-2h1v6H6a2 2 0 01-2-2v-2zM20 13a2 2 0 00-2-2h-1v6h1a2 2 0 002-2v-2z', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
h('path', { d: 'M17 17c0 2-2 3-5 3', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }),
h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }),
+108 -5
View File
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import {
clearNavigationRecoveryAttempt,
createNavigationRecoveryHandler,
installAssetRecoveryListeners,
isStaleRouteAssetError,
recoverNavigationError,
reloadNavigationTarget,
type NavigationRecoveryDependencies,
} from './navigationRecovery'
@@ -29,11 +31,30 @@ function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
}
describe('navigationRecovery', () => {
it.each(['/orders?page=3#details', '/orders?page=3#goods'])(
'forces a full reload for an async component failure on the current document: %s', (target) => {
const location = { href: 'https://admin.example.com/orders?page=3#details', reload: vi.fn(), assign: vi.fn() }
const history = { state: { position: 2 }, replaceState: vi.fn() }
reloadNavigationTarget({ location, history } as unknown as Pick<Window, 'location' | 'history'>, target)
expect(location.reload).toHaveBeenCalledOnce()
expect(location.assign).not.toHaveBeenCalled()
expect(history.replaceState).toHaveBeenCalledWith(history.state, '', `https://admin.example.com${target}`)
},
)
it('loads the intended new route through a document navigation', () => {
const location = { href: 'https://admin.example.com/dashboard', reload: vi.fn(), assign: vi.fn() }
reloadNavigationTarget({ location } as unknown as Pick<Window, 'location' | 'history'>, '/orders?page=3#details')
expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details')
expect(location.reload).not.toHaveBeenCalled()
})
it.each([
'Failed to fetch dynamically imported module: /assets/page.js',
'Importing a module script failed',
'ChunkLoadError: Loading chunk 42 failed',
'Loading CSS chunk page failed',
'Unable to preload CSS for /assets/page-123.css',
])('recognizes stale deployed route assets: %s', (message) => {
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
})
@@ -58,7 +79,7 @@ describe('navigationRecovery', () => {
expect(deps.navigate).not.toHaveBeenCalled()
})
it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
it('keeps the cooldown across page reloads and redirects until 30 seconds have elapsed', async () => {
const storage = memoryStorage()
const deps = dependencies({ storage })
const error = new Error('ChunkLoadError: Loading chunk page failed')
@@ -66,9 +87,12 @@ describe('navigationRecovery', () => {
await recoverNavigationError(error, '/system/users', deps)
expect(deps.navigate).toHaveBeenCalledOnce()
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
clearNavigationRecoveryAttempt(storage)
await recoverNavigationError(error, '/system/users', deps)
expect(deps.navigate).toHaveBeenCalledTimes(2)
const reloaded = dependencies({ storage })
await recoverNavigationError(error, '/system/redirect', reloaded)
expect(reloaded.navigate).not.toHaveBeenCalled()
reloaded.now = () => 31_000
await recoverNavigationError(error, '/system/users', reloaded)
expect(reloaded.navigate).toHaveBeenCalledOnce()
})
it('reports ordinary router failures without reloading the application', async () => {
@@ -77,4 +101,83 @@ describe('navigationRecovery', () => {
expect(deps.navigate).not.toHaveBeenCalled()
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
})
it.each(['getItem', 'setItem'] as const)('does not refresh or discard work if storage %s fails', async (method) => {
const storage = memoryStorage()
storage[method] = () => { throw new Error('Storage unavailable') }
const deps = dependencies({ storage })
await expect(recoverNavigationError('Importing a module script failed', '/orders', deps)).resolves.toBe(false)
expect(deps.navigate).not.toHaveBeenCalled()
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
expect(deps.showError).toHaveBeenCalledWith('无法记录自动刷新次数,请手动刷新页面')
})
it('preserves the target query and hash after the user confirms discarding work', async () => {
const deps = dependencies({ hasUnsavedWork: () => true })
const target = '/orders?status=NEW&page=3#details'
await recoverNavigationError('Unable to preload CSS for /assets/page.css', target, deps)
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
expect(deps.navigate).toHaveBeenCalledWith(target)
})
it('coalesces errors while confirmation is open and permits retry after cancellation', async () => {
let finishConfirmation!: (value: boolean) => void
const deps = dependencies({
hasUnsavedWork: () => true,
confirmDiscard: vi.fn(() => new Promise<boolean>((resolve) => { finishConfirmation = resolve })),
})
const recover = createNavigationRecoveryHandler(deps)
const cachedError = new Error('Importing a module script failed')
const first = recover(cachedError, '/orders')
const duplicate = recover(new Error('Unable to preload CSS for /assets/page.css'), '/orders')
await Promise.resolve()
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
finishConfirmation(false)
await Promise.all([first, duplicate])
expect(deps.navigate).not.toHaveBeenCalled()
deps.confirmDiscard = vi.fn(async () => true)
await recover(cachedError, '/orders')
expect(deps.navigate).toHaveBeenCalledOnce()
})
it('deduplicates the same error reported by Vite and the router', async () => {
const deps = dependencies()
const recover = createNavigationRecoveryHandler(deps)
const error = new Error('Importing a module script failed')
await recover(error, '/orders')
await recover(error, '/orders')
expect(deps.navigate).toHaveBeenCalledOnce()
expect(deps.showError).not.toHaveBeenCalled()
})
it('recovers Vite JS/CSS and uncaught import failures but leaves ordinary application errors alone', async () => {
const deps = dependencies()
const recover = createNavigationRecoveryHandler(deps)
const removeListeners = installAssetRecoveryListeners(window, () => '/orders?page=2', recover)
try {
const ordinary = Object.assign(new Event('vite:preloadError', { cancelable: true }), {
payload: new Error('Cannot read properties of undefined'),
})
window.dispatchEvent(ordinary)
const apiError = Object.assign(new Event('unhandledrejection', { cancelable: true }), {
reason: new Error('Request failed with status code 500'),
})
window.dispatchEvent(apiError)
await Promise.resolve()
expect(deps.navigate).not.toHaveBeenCalled()
expect(apiError.defaultPrevented).toBe(false)
const error = new Error('Unable to preload CSS for /assets/page.css')
const preload = Object.assign(new Event('vite:preloadError', { cancelable: true }), { payload: error })
window.dispatchEvent(preload)
expect(preload.defaultPrevented).toBe(false)
const rejection = Object.assign(new Event('unhandledrejection', { cancelable: true }), { reason: error })
window.dispatchEvent(rejection)
expect(rejection.defaultPrevented).toBe(true)
await recover(error, '/orders?page=2')
expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/orders?page=2')
} finally {
removeListeners()
}
})
})
+85 -20
View File
@@ -7,10 +7,11 @@ const staleAssetErrorPatterns = [
/importing a module script failed/i,
/chunkloaderror/i,
/loading (css )?chunk [\w-]+ failed/i,
/unable to preload css for/i,
]
export interface NavigationRecoveryDependencies {
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
storage: Pick<Storage, 'getItem' | 'setItem'>
now: () => number
hasUnsavedWork: () => boolean
confirmDiscard: () => Promise<boolean>
@@ -34,17 +35,6 @@ export function isStaleRouteAssetError(error: unknown) {
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
}
function recentAttempt(dependencies: NavigationRecoveryDependencies, target: string) {
try {
const parsed = JSON.parse(dependencies.storage.getItem(RECOVERY_STORAGE_KEY) || 'null') as RecoveryAttempt | null
return Boolean(parsed
&& parsed.target === target
&& dependencies.now() - parsed.attemptedAt < RECOVERY_WINDOW_MS)
} catch {
return false
}
}
export async function recoverNavigationError(
error: unknown,
target: string,
@@ -54,21 +44,96 @@ export async function recoverNavigationError(
dependencies.showError('页面切换失败,请重试')
return false
}
if (recentAttempt(dependencies, target)) {
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
// Keep this marker across successful routing and full reloads. A redirect to
// another route must not bypass the cooldown either.
try {
const stored = dependencies.storage.getItem(RECOVERY_STORAGE_KEY)
let previous: RecoveryAttempt | null = null
try { previous = JSON.parse(stored || 'null') } catch { /* Replace malformed records. */ }
if (previous && Number.isFinite(previous.attemptedAt)
&& dependencies.now() - previous.attemptedAt < RECOVERY_WINDOW_MS) {
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
return false
}
} catch {
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
return false
}
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
target,
attemptedAt: dependencies.now(),
} satisfies RecoveryAttempt))
try {
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
target,
attemptedAt: dependencies.now(),
} satisfies RecoveryAttempt))
} catch {
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
return false
}
dependencies.discardUnsavedWork()
dependencies.navigate(target)
return true
}
export function clearNavigationRecoveryAttempt(storage: Pick<Storage, 'removeItem'>) {
storage.removeItem(RECOVERY_STORAGE_KEY)
export function createNavigationRecoveryHandler(dependencies: NavigationRecoveryDependencies) {
let handledErrors = new WeakSet<object>()
let pending: Promise<boolean> | undefined
const guardedDependencies = {
...dependencies,
confirmDiscard: async () => {
const confirmed = await dependencies.confirmDiscard()
// Some importers cache the rejected promise/error. Cancelling must not
// prevent a later explicit retry from asking again.
if (!confirmed) handledErrors = new WeakSet<object>()
return confirmed
},
}
return (error: unknown, target: string): Promise<boolean> => {
if (typeof error === 'object' && error !== null) {
if (handledErrors.has(error)) return pending ?? Promise.resolve(true)
handledErrors.add(error)
}
if (pending) return pending
// Defer execution so simultaneous router, Vite and Vue errors share one
// confirmation/reload, even when there is no unsaved form.
pending = Promise.resolve().then(() => recoverNavigationError(error, target, guardedDependencies))
.finally(() => { pending = undefined })
return pending
}
}
export function reloadNavigationTarget(browser: Pick<Window, 'location' | 'history'>, target: string) {
const current = new URL(browser.location.href)
const destination = new URL(target, current)
if (destination.pathname === current.pathname && destination.search === current.search) {
// Assigning a URL that only changes the fragment does not reload assets.
browser.history.replaceState(browser.history.state, '', destination.href)
browser.location.reload()
} else {
browser.location.assign(destination.href)
}
}
export function installAssetRecoveryListeners(
browser: Window,
target: () => string,
recover: (error: unknown, target: string) => Promise<boolean>,
) {
const onPreloadError = (event: Event) => {
const error = (event as Event & { payload?: unknown }).payload
if (isStaleRouteAssetError(error)) void recover(error, target())
// Let Vite reject the import: swallowing it would make Vue Router treat
// an undefined module as a successful navigation, including on cancel.
}
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
if (!isStaleRouteAssetError(event.reason)) return
event.preventDefault()
void recover(event.reason, target())
}
browser.addEventListener('vite:preloadError', onPreloadError)
browser.addEventListener('unhandledrejection', onUnhandledRejection)
return () => {
browser.removeEventListener('vite:preloadError', onPreloadError)
browser.removeEventListener('unhandledrejection', onUnhandledRejection)
}
}
+65 -51
View File
@@ -1,62 +1,76 @@
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { test } from 'vitest'
const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8')
const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8')
const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8')
const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8')
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
const root = process.cwd()
const config = readFileSync(resolve(root, 'nginx/default.conf.template'), 'utf8')
const compose = readFileSync(resolve(root, 'docker-compose.test.yml'), 'utf8')
const jenkins = readFileSync(resolve(root, 'Jenkinsfile'), 'utf8')
const vite = readFileSync(resolve(root, 'vite.config.ts'), 'utf8')
describe('admin nginx deployment config', () => {
it('allows qualification uploads with multipart overhead through the API proxy', () => {
expect(config).toContain('client_max_body_size 12m;')
})
test('all business traffic uses one Gateway without rewriting API paths', () => {
assert.match(config, /location \/api\//)
assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/)
assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/)
assert.match(config, /client_max_body_size 55m;/)
assert.match(config, /proxy_set_header Host \$proxy_host;/)
assert.doesNotMatch(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/)
})
it('prevents the application shell from retaining stale chunk references', () => {
expect(config).toContain('location = /index.html')
expect(config).toContain('Cache-Control "no-cache, no-store, must-revalidate"')
})
test('test deployment requires an explicit shared network, Gateway and version', () => {
for (const key of ['IMAGE_TAG']) {
assert.ok(compose.includes('${' + key + ':?'))
}
assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}'))
assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}'))
assert.match(compose, /external: true/)
assert.match(compose, /127\.0\.0\.1:/)
assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/)
assert.match(jenkins, /'java-builder' : 'java-test'/)
assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/)
assert.match(jenkins, /docker network inspect/)
assert.match(jenkins, /scripts\/deploy-production\.sh/)
assert.match(jenkins, /firstleg-production-ssh/)
it('serves hashed assets immutably and never falls back missing chunks to HTML', () => {
expect(config).toContain('location /assets/')
expect(config).toContain('try_files $uri =404;')
expect(config).toContain('Cache-Control "public, max-age=31536000, immutable"')
})
})
it('keeps Vue history routes on the application shell without caching them', () => {
expect(config).toContain('try_files $uri $uri/ /index.html;')
expect(config).toContain('Cache-Control "no-cache"')
})
test('application shell is revalidated and missing chunks remain 404', () => {
assert.match(config, /location = \/index\.html/)
assert.match(config, /no-store/)
assert.match(config, /max-age=31536000, immutable/)
assert.match(config, /try_files \$uri =404;/)
assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/)
})
it('proxies overseas API requests to the gateway API prefix', () => {
expect(config).toContain('location /overseas-api/')
expect(config).toContain('rewrite ^/overseas-api/(.*)$ /api/$1 break;')
expect(config).toContain('proxy_pass ${OVERSEAS_API_UPSTREAM};')
})
test('PDA static apps keep independent paths and share the authenticated Gateway', () => {
assert.match(config, /resolver 127\.0\.0\.11 valid=10s ipv6=off;/)
for (const app of ['domestic', 'overseas']) {
assert.ok(config.includes(`location = /pda-${app} { return 308 /pda-${app}/; }`))
assert.ok(config.includes(`location ^~ /pda-${app}/ {`))
assert.ok(config.includes(`set $pda_${app}_origin http://pda-${app}-h5-test:80;`))
assert.ok(config.includes(`proxy_pass $pda_${app}_origin$request_uri;`))
}
assert.match(config, /location \/gateway-api\/\s*\{[^}]*client_max_body_size 55m;[^}]*proxy_pass \$\{API_UPSTREAM\}\/api\//)
assert.doesNotMatch(config, /proxy_pass.*(?:auth:|lastleg-service:|firstleg-service:)/)
})
it('declares an explicit overseas gateway upstream in container deployments', () => {
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
})
it('runs each deployment on its target node without controller-local SSH files', () => {
expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'")
expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'")
expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d')
expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh')
expect(jenkinsfile).not.toContain('docker save')
expect(jenkinsfile).not.toMatch(/\bssh\b/)
})
test('container build excludes local environment values and waits for Nginx health', () => {
const ignore = readFileSync(resolve(root, '.dockerignore'), 'utf8')
assert.match(ignore, /^\.env$/m)
assert.match(ignore, /^\.env\.\*$/m)
assert.match(compose, /healthcheck:/)
assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/)
assert.match(jenkins, /up -d --wait --wait-timeout 120/)
})
it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => {
expect(dockerfile).toContain('ARG NODE_IMAGE=node:22-alpine')
expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine')
expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build')
expect(dockerfile).toContain('FROM ${NGINX_IMAGE}')
expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'")
expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'")
expect(jenkinsfile).toContain('withCredentials([usernamePassword(')
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
})
test('gray production is isolated from TEST and routes to the single Gateway', () => {
const production = readFileSync(resolve(root, 'docker-compose.production.yml'), 'utf8')
assert.match(production, /name: firstleg-production-network/)
assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/)
assert.match(production, /127\.0\.0\.1:/)
assert.match(production, /healthcheck:/)
assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/)
assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/)
})
+40 -25
View File
@@ -1,4 +1,6 @@
import '@/styles/adminDensity.css'
import { computed, createApp } from 'vue'
import 'dayjs/locale/zh-cn'
import { createPinia } from 'pinia'
import { ElMessage, ElMessageBox } from 'element-plus'
import 'element-plus/es/components/message/style/css'
@@ -6,7 +8,7 @@ import 'element-plus/es/components/message-box/style/css'
import App from './App.vue'
import router from '@/app/router'
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
import { clearNavigationRecoveryAttempt, recoverNavigationError } from '@/core/routing/navigationRecovery'
import { createNavigationRecoveryHandler, installAssetRecoveryListeners, isStaleRouteAssetError, reloadNavigationTarget } from '@/core/routing/navigationRecovery'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
@@ -20,8 +22,6 @@ const auth = useAuthStore(pinia)
const unsaved = useUnsavedWorkStore(pinia)
const workspace = useWorkspaceTabsStore(pinia)
await auth.restoreSession()
registerAuthSessionExpiredHandler(async () => {
const currentRoute = router.currentRoute.value
if (currentRoute.name === 'login') return
@@ -37,31 +37,41 @@ registerAuthSessionExpiredHandler(async () => {
})
})
const recoverAssetError = createNavigationRecoveryHandler({
storage: {
getItem: (key) => window.sessionStorage.getItem(key),
setItem: (key, value) => window.sessionStorage.setItem(key, value),
},
now: Date.now,
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
confirmDiscard: async () => {
try {
await ElMessageBox.confirm(
'页面资源加载失败,可能已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
'重新加载页面?',
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
)
return true
} catch {
return false
}
},
discardUnsavedWork: () => unsaved.clear(),
navigate: (target) => reloadNavigationTarget(window, target),
showError: (message) => ElMessage.error(message),
})
let pendingRoute: string | undefined
const recoveryTarget = () => pendingRoute
?? `${window.location.pathname}${window.location.search}${window.location.hash}`
router.beforeEach((to) => { pendingRoute = to.fullPath })
router.onError((error, to) => {
void recoverNavigationError(error, to.fullPath, {
storage: sessionStorage,
now: Date.now,
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
confirmDiscard: async () => {
try {
await ElMessageBox.confirm(
'检测到后台已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
'重新加载页面?',
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning' },
)
return true
} catch {
return false
}
},
discardUnsavedWork: () => unsaved.clear(),
navigate: (target) => window.location.assign(target),
showError: (message) => ElMessage.error(message),
})
void recoverAssetError(error, to.fullPath)
if (pendingRoute === to.fullPath) pendingRoute = undefined
})
router.afterEach((_to, _from, failure) => {
if (!failure) clearNavigationRecoveryAttempt(sessionStorage)
router.afterEach((to) => {
if (pendingRoute === to.fullPath) pendingRoute = undefined
})
installAssetRecoveryListeners(window, recoveryTarget, recoverAssetError)
window.addEventListener('beforeunload', (event) => {
if (!unsaved.entries.some((entry) => entry.dirty)) return
@@ -70,5 +80,10 @@ window.addEventListener('beforeunload', (event) => {
})
const app = createApp(App)
app.config.errorHandler = (error) => {
if (isStaleRouteAssetError(error)) void recoverAssetError(error, recoveryTarget())
else console.error(error)
}
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
await auth.restoreSession()
app.use(pinia).use(router).mount('#app')
@@ -0,0 +1,33 @@
# Admin 预约日历模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/appointment-calendar/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,预约规则见 `../../../docs/business-index.md`;工作日历正式规则在**工作区** `docs/海外仓业务/预约工作日历V13变更说明.md`。
- 本模块扁平存放页面、面板与 API,无 `pages/`、`components/` 分层:`*.vue` 多为面板,`*Api.ts` 为对应接口,`calendar.ts`/`schedule.ts`/`tripCalendar.ts` 为日历与航段计算,`useWorkDraft.ts`/`useWorkSyncRead.ts` 为草稿与只读同步。
- 后端只查 `lastleg-service/`(`inboundplan/`、`appointmentplanning/`);到港事实只读 `firstleg/` 的船节点,纯页面与文案不读后端。
## 页面与入口
| 目标 | 入口 | API 与后端 |
|---|---|---|
| 预约列表与日历主页面 | `AppointmentCalendarView.vue`;`appointment-calendar` | `api.ts`、`calendar.ts`;`lastleg-service/inboundplan/` |
| 工作日历、车次与送仓待办 | `WorkCalendarPanel.vue`、`TripAppointmentPanel.vue` | `workApi.ts`、`tripCalendar.ts`;`lastleg-service/appointmentplanning/` |
| 新建/批量预约、多客户共车 | `AppointmentCreatePanel.vue`、`AppointmentBatchForm.vue`、`WorkSharedTripForm.vue` | `workApi.ts`、`batchApi.ts` |
| 货物验证与入仓码关系 | `AppointmentCargoPanel.vue`、`AppointmentCargoTabs.vue`、`AppointmentInboundCodes.vue`、`CargoVerificationForm.vue` | `cargoVerificationApi.ts` |
| 改期、取消与取消记录 | `AppointmentImpactDialog.vue`、`AppointmentCancellationDialog.vue`、`AppointmentCancellationHistory.vue`、`AppointmentCancellationRecords.vue` | `impactApi.ts` |
| 派送安排与分批后送 | `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue` | `workApi.ts`;`lastleg-service/delivery/` |
| 收货范围与收货计划 | `ReceivingScope.vue`、`WorkReceivingPanel.vue`、`WorkReceiptPlans.vue` | `receivingApi.ts` |
| 外部候选取货与跟进 | `AppointmentExternalCandidates.vue`、`AppointmentFollowupDialog.vue` | `planningApi.ts`、`workApi.ts` |
## 关键边界
- 同仓跨客户共用一张预约,客户归属在货物明细,不为每个客户拆预约;同车多客户按 V13 规则处理。
- 到港事实只读 Firstleg 船节点,送仓与排车归 Lastleg;不得用预计到港替代计划到仓,也不得反向写船节点。
- 改期、取消、重新预约必须按服务端影响范围与版本提交,取消记录只追加,不改写历史。
- 未定日期事项进入待办跟进,不伪造日期;分批后送的批次关系以服务端返回为准。
- 面板之间共享草稿状态,`useWorkDraft.ts` 是唯一草稿入口;刷新时用 `useWorkSyncRead.ts` 合并服务端事实并保留本机未提交输入。
## 验证
- 定向:`pnpm test src/modules/appointment-calendar/<目标测试>`;日历与批次规则变更同时核对 V13 规则文档与 `lastleg-service` 合同。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
@@ -0,0 +1,183 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
import { getAppointmentBatch, repartitionAppointments } from './batchApi'
import { getWorkItems } from './workApi'
import { ApiResponseError } from '@/shared/api/http'
import { batchContext } from './batch.test-support'
import { workItem } from './work.test-support'
import type { LeaveGuard } from './useWorkDraft'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn(), repartitionAppointments: vi.fn() }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkItems: vi.fn() }))
let guard: LeaveGuard
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true, CargoVerificationForm: true,
} } }) }
beforeEach(() => {
vi.clearAllMocks(); vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
vi.mocked(repartitionAppointments).mockResolvedValue({ replacedBatchKeys: ['FBA_APPOINTMENT:1'], replacedAppointmentNos: ['AP-001'], appointments: [batchContext('AP-NEW-1', 'FBA_APPOINTMENT:11').appointment, batchContext('AP-NEW-2', 'FBA_APPOINTMENT:12').appointment] })
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ batchKey: 'FBA_APPOINTMENT:2' })], page: 1, size: 8, total: 1, pages: 1 })
})
it('submits two conservative allocations without fake confirmation times and renders replacement business numbers', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
await wrapper.findAll('textarea')[0]!.setValue('分两次送仓'); await wrapper.get('form').trigger('submit'); await flushPromises()
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
expect(request.originals).toEqual([{ appointmentNo: 'AP-001', version: 3 }])
expect(request.batches.map(row => row.plannedDate)).toEqual([null, null])
expect(request.batches[0]!.allocations.map(row => row.packageCount)).toEqual([50, 30])
expect(request.batches[1]!.allocations.map(row => row.packageCount)).toEqual([20])
expect(request).not.toHaveProperty('appointmentAt')
expect(wrapper.text()).toContain('AP-NEW-1'); expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('splits only the entered 60-box later batch into 40/20, never reallocating its original 160-box source', async () => {
const context = batchContext('AP-LATER-60', 'FBA_APPOINTMENT:1')
context.appointment.items = [{ ...context.appointment.items[0]!, packageCount: 60 }]
context.sources = [{ ...context.sources[0]!, packageCount: 60, originalPackages: 160 }]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
await wrapper.findAll('textarea')[0]!.setValue('本批40先安排,20待排')
await wrapper.get('form').trigger('submit'); await flushPromises()
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
expect(request.originals).toEqual([{ appointmentNo: 'AP-LATER-60', version: 3 }])
expect(request.batches.map(batch => batch.allocations.map(row => row.packageCount))).toEqual([[40], [20]])
expect(request.batches.flatMap(batch => batch.allocations).every(row => row.customerId === 'customer-a' && row.sourceKey === 'ORDER:1')).toBe(true)
wrapper.unmount()
})
it('locks an unknown outcome and retries the same immutable repartition command', async () => {
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new Error('unknown outcome'))
const wrapper = render(); await flushPromises()
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('分批')
await wrapper.get('form').trigger('submit'); await flushPromises()
const first = vi.mocked(repartitionAppointments).mock.calls[0]![0]
expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(repartitionAppointments).toHaveBeenNthCalledWith(2, first); wrapper.unmount()
})
it('keeps edited allocations after a version conflict and requires a fresh explicit submit', async () => {
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
const wrapper = render(); await flushPromises()
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('保留分批原因')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(repartitionAppointments).toHaveBeenCalledTimes(1)
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('20')
expect((wrapper.findAll('textarea')[0]!.element as HTMLTextAreaElement).value).toBe('保留分批原因'); wrapper.unmount()
})
it('shows full appointment cargo when confirmed or replaced batches have no repartition sources', async () => {
const context = batchContext(); context.sources = []; context.allowedActions = []; context.blockReasons = ['原批次已有正式确认']; context.appointment.appointmentStatus = 'SUCCESS'
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
expect(wrapper.get('[aria-label="原批次货物快照"]').text()).toContain('IN-A')
expect(wrapper.text()).toContain('FBA-B'); expect(wrapper.text()).toContain('当前批次无分批资格')
await wrapper.get('form').trigger('submit'); expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads a candidate context before merging and preserves both original versions', async () => {
const other = batchContext('AP-002', 'FBA_APPOINTMENT:2'); other.appointment.version = 6
vi.mocked(getAppointmentBatch).mockImplementation(async key => key === 'FBA_APPOINTMENT:2' ? other : batchContext())
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '同仓合并安排')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '核对并选择')!.trigger('click'); await flushPromises()
await wrapper.findAll('textarea')[0]!.setValue('同仓合并'); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(repartitionAppointments).toHaveBeenCalledWith(expect.objectContaining({ originals: [{ appointmentNo: 'AP-001', version: 3 }, { appointmentNo: 'AP-002', version: 6 }], batches: [expect.objectContaining({ allocations: [expect.objectContaining({ customerId: 'customer-a', packageCount: 140 }), expect.objectContaining({ customerId: 'customer-b', packageCount: 60 })] })] }))
wrapper.unmount()
})
it('shows every terminal branch with only the original customer/source/destination quantities and opens the real batch', async () => {
const context = batchContext()
const source = context.appointment.items[0]!
context.appointment.items = [source]
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2', 'FBA_APPOINTMENT:3']
context.appointment.replacementAppointmentNos = ['AP-INTERMEDIATE', 'AP-20']
context.sources = []; context.allowedActions = []
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:4').appointment
forty.appointmentStatus = 'SUCCESS'
forty.items = [
{ ...source, packageCount: 40 },
{ ...source, id: 'foreign-customer', customerId: 'other-customer', inboundCode: 'FOREIGN-CUSTOMER', packageCount: 900 },
{ ...source, id: 'foreign-source', sourceKey: 'ORDER:OTHER', inboundCode: 'FOREIGN-SOURCE', packageCount: 800 },
{ ...source, id: 'foreign-destination', destinationKey: 'FBA:OTHER', inboundCode: 'FOREIGN-DESTINATION', packageCount: 700 },
]
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:3').appointment
twenty.appointmentStatus = 'CANCELLED'; twenty.items = [{ ...source, packageCount: 20 }]
context.currentBatches = [forty, twenty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.findAll('article')).toHaveLength(2)
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
expect(destinations.text()).toContain('已取消,分配保留')
expect(destinations.text()).not.toContain('FOREIGN-'); expect(destinations.text()).not.toContain('AP-INTERMEDIATE')
expect(destinations.text()).not.toContain('FBA_APPOINTMENT:')
await destinations.findAll('button').find(button => button.text() === '查看本批预约')!.trigger('click')
expect(wrapper.emitted('appointment')).toEqual([['AP-40']])
await destinations.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:4')
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
})
it('does not create zero-quantity destinations or convert unknown quantities to zero', async () => {
const context = batchContext()
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
context.sources = []; context.allowedActions = []
const unknown = batchContext('AP-UNKNOWN', 'FBA_APPOINTMENT:2').appointment
unknown.items = [{ ...context.appointment.items[0]!, packageCount: null }]
const empty = batchContext('AP-EMPTY', 'FBA_APPOINTMENT:3').appointment
empty.items = [{ ...context.appointment.items[0]!, packageCount: 0 }]
context.currentBatches = [unknown, empty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.findAll('article')).toHaveLength(1)
expect(destinations.text()).toContain('箱数待核实'); expect(destinations.text()).not.toContain('0 箱')
expect(destinations.text()).not.toContain('AP-EMPTY'); wrapper.unmount()
})
it('does not fall back to historical replacement links when current destinations are empty', async () => {
const context = batchContext()
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
context.appointment.replacementAppointmentNos = ['AP-OLD-REPLACEMENT']
context.sources = []; context.allowedActions = []; context.currentBatches = []
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.findAll('article')).toHaveLength(0)
expect(destinations.text()).toContain('原快照不代表当前待办')
expect(destinations.text()).not.toContain('AP-OLD-REPLACEMENT'); wrapper.unmount()
})
it('keeps every 40/20 destination after the entire customer has left the original current items', async () => {
const context = batchContext(), originalItem = context.appointment.items[0]!
context.appointment.items = [context.appointment.items[1]!]
context.sources = [context.sources[1]!]
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment
forty.items = [{ ...originalItem, packageCount: 40 }, { ...originalItem, id: 'unrelated', sourceKey: 'ORDER:OTHER', inboundCode: 'IN-UNRELATED', packageCount: 999 }]
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
twenty.items = [{ ...originalItem, packageCount: 20 }]
context.currentBatches = [context.appointment, forty, twenty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
expect(destinations.text()).not.toContain('IN-UNRELATED')
expect(wrapper.findAll('[aria-label="来源1分出箱数"]')).toHaveLength(1)
expect((wrapper.get('[aria-label="来源1分出箱数"]').attributes('max'))).toBe('30')
wrapper.unmount()
})
it('retries a new destination without exposing the prior batch or retaining its allocation inputs', async () => {
const original = batchContext()
original.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']; original.sources = []; original.allowedActions = []
const current = batchContext('AP-CURRENT', 'FBA_APPOINTMENT:2')
current.appointment.plannedDate = '2026-10-22'
original.currentBatches = [current.appointment]
vi.mocked(getAppointmentBatch).mockResolvedValueOnce(original).mockRejectedValueOnce(new Error('新批次暂不可用')).mockResolvedValue(current)
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('AP-CURRENT')
expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0')
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2')
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getAppointmentBatch, repartitionAppointments, allocation, allocationKey, mergeBatchSources, splitBatch, type FbaAppointmentBatchContext, type RepartitionFbaAppointmentsRequest, type RepartitionFbaAppointmentsResponse } from './batchApi'
import { getWorkItems, type WorkPage } from './workApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
import CargoVerificationForm from './CargoVerificationForm.vue'
import type { CargoVerificationSource } from './cargoVerificationApi'
const props = defineProps<{ batchKey: string }>()
const emit = defineEmits<{ updated: []; appointment: [appointmentNo: string] }>()
const access = useAccessPreviewStore(), activeKey = ref(props.batchKey), context = ref<FbaAppointmentBatchContext | null>(null), other = ref<FbaAppointmentBatchContext | null>(null)
const mode = ref<'split' | 'merge'>('split'), loading = ref(false), saving = ref(false), ready = ref(false)
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<RepartitionFbaAppointmentsRequest | null>(null), result = ref<RepartitionFbaAppointmentsResponse | null>(null)
const form = reactive({ firstDate: '', nextDate: '', reason: '', remark: '', moved: {} as Record<string, number | string> })
const baseline = ref(JSON.stringify(form)), verification = ref<CargoVerificationSource | null>(null)
const canLeave = useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
const candidates = ref<WorkPage | null>(null), candidatePage = ref(1), keyword = ref(''), candidateLoading = ref(false), candidateError = ref<ApiErrorDetails | null>(null)
let candidateRequest = 0
let initializedKey: string | null = null
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
const replaced = computed(() => !!context.value?.appointment.replacementBatchKeys?.length || !!context.value?.appointment.replacementAppointmentNos?.length)
const hasCurrentDestinations = computed(() => replaced.value || context.value?.currentBatches.some(batch => batch.batchKey !== context.value?.appointment.batchKey))
const currentDestinations = computed(() => {
if (!context.value) return []
const sourceIdentity = (item: FbaAppointmentBatchContext['appointment']['items'][number]) => JSON.stringify([item.customerId, item.sourceKey, item.destinationKey])
const originalSources = new Set(context.value.originalSourceRefs.map(allocationKey))
return context.value.currentBatches.map(appointment => ({ appointment, items: appointment.items.filter(item => item.destinationKey && originalSources.has(sourceIdentity(item)) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
})
function bookingStatus(status: string) { return ({ PENDING: '待预约', SUCCESS: '已确认预约', FAILED: '未约成', CANCELLED: '已取消,分配保留' } as Record<string, string>)[status] || '预约状态待核实' }
async function openAppointment(number: string) { if (await canLeave()) emit('appointment', number) }
const canSubmit = computed(() => canWrite.value && ready.value && !loading.value && !error.value && !!context.value?.allowedActions.includes(mode.value === 'split' ? 'SPLIT' : 'MERGE') && (mode.value !== 'merge' || !!other.value?.allowedActions.includes('MERGE')))
async function load(preserve = false) {
loading.value = true; ready.value = false; error.value = null
try {
context.value = await getAppointmentBatch(activeKey.value)
if (other.value?.appointment.batchKey) other.value = await getAppointmentBatch(other.value.appointment.batchKey)
if (!preserve || initializedKey !== activeKey.value) {
Object.assign(form, { firstDate: context.value.appointment.plannedDate || '', nextDate: '', reason: '', remark: '', moved: Object.fromEntries(context.value.sources.map(source => [allocationKey(source), 0])) })
baseline.value = JSON.stringify(form)
initializedKey = activeKey.value
}
ready.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '批次资料读取失败') }
finally { loading.value = false }
}
async function searchCandidates(page = 1) {
const request = ++candidateRequest; candidatePage.value = page; candidates.value = null; candidateError.value = null; candidateLoading.value = true
try { const rows = await getWorkItems({ view: 'ALL', types: ['DELIVERY'], keyword: keyword.value.trim() || undefined, page, size: 8 }); if (request === candidateRequest) candidates.value = rows }
catch (cause) { if (request === candidateRequest) candidateError.value = getApiErrorDetails(cause, '可合并工作事项读取失败') }
finally { if (request === candidateRequest) candidateLoading.value = false }
}
async function chooseOther(key: string) {
if (!context.value || pending.value || saving.value) return
candidateLoading.value = true; candidateError.value = null
try {
const selected = await getAppointmentBatch(key)
if (!selected.allowedActions.includes('MERGE')) throw new Error(selected.blockReasons.join(';') || '该批次当前不允许合并')
mergeBatchSources([context.value, selected]); other.value = selected
} catch (cause) { candidateError.value = getApiErrorDetails(cause, '批次不能合并') }
finally { candidateLoading.value = false }
}
async function changeMode(next: 'split' | 'merge') {
if (!await canLeave()) return
mode.value = next; other.value = null; result.value = null; await load()
if (next === 'merge') await searchCandidates()
}
function sourceLabel(source: CargoVerificationSource) {
const item = context.value?.appointment.items.find(item => item.customerId === source.customerId && item.sourceKey === source.sourceKey)
return item?.inboundCode || '原票业务号未提供'
}
async function verify(source: CargoVerificationSource) { if (await canLeave()) { baseline.value = JSON.stringify(form); verification.value = source } }
async function verified() { verification.value = null; await load(); emit('updated') }
async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; context.value = null; result.value = null; other.value = null; mode.value = 'split'; await load() }
async function save() {
if (!context.value || !canSubmit.value || saving.value) return
if (!pending.value) {
try {
if (!form.reason.trim()) { ElMessage.warning('请填写分批或合并原因'); return }
const contexts = mode.value === 'merge' && other.value ? [context.value, other.value] : [context.value]
const allocations = mode.value === 'split' ? splitBatch(context.value, form.moved) : [mergeBatchSources(contexts).map(source => allocation(source, Number(source.packageCount)))]
pending.value = { originals: contexts.map(row => ({ appointmentNo: row.appointment.appointmentNo, version: row.appointment.version })), batches: allocations.map((rows, index) => ({ plannedDate: (index === 0 ? form.firstDate : form.nextDate) || null, remark: form.remark.trim() || null, allocations: rows })), reason: form.reason.trim(), idempotencyKey: crypto.randomUUID() }
} catch (cause) { saveError.value = getApiErrorDetails(cause, '请核对逐票分配数量'); return }
}
saving.value = true; saveError.value = null
try {
result.value = await repartitionAppointments(pending.value); pending.value = null; baseline.value = JSON.stringify(form)
ElMessage.success(mode.value === 'split' ? '已分为两个待安排批次' : '已合并为一个待安排批次'); emit('updated')
} catch (cause) { saveError.value = getApiErrorDetails(cause, '调整结果待确认,请重试本次提交'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
finally { saving.value = false }
}
onMounted(() => load())
</script>
<template>
<section class="work-form" aria-label="预约分批合并">
<CargoVerificationForm v-if="verification" :key="`${verification.customerId}:${verification.sourceKey}`" :source="verification" @saved="verified" />
<template v-else>
<h3>{{ context?.appointment.appointmentNo || '预约批次' }} · 分批与合并</h3>
<LoadErrorState v-if="error" title="批次资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在核对批次、来源和核实版本…</p>
<template v-if="result"><p>原批次 {{ result.replacedAppointmentNos.join('、') }} 已保留历史并被替代。</p><div v-for="appointment in result.appointments" :key="appointment.appointmentNo" class="batch-result"><strong>{{ appointment.appointmentNo }}</strong><span>{{ appointment.plannedDate || '日期未定' }} · 待安排</span><el-button @click="emit('appointment', appointment.appointmentNo)">登记安排</el-button><el-button v-if="appointment.batchKey" @click="openBatch(appointment.batchKey)">继续分批</el-button></div></template>
<template v-else-if="context">
<p>{{ context.appointment.fbaWarehouseCode }} · {{ context.appointment.items.length }} 条货物来源;客户与原来源逐票保留。</p>
<template v-if="hasCurrentDestinations">
<p v-if="replaced">该批次已被替代,原货物快照仅供追溯。{{ context.appointment.repartitionReason }}</p>
<p v-else>本批仍有保留货物,以下同时列出服务端返回的当前后送去向。</p>
<section aria-label="当前货物去向">
<h4>当前货物去向</h4><p>以下仅展示原批次客户、来源及目的仓对应的当前分配;合并批次可能还含其他货物,进入完整批次后可查看。</p>
<article v-for="destination in currentDestinations" :key="destination.appointment.batchKey" class="allocation-row">
<strong>{{ destination.appointment.appointmentNo }}</strong><p>{{ bookingStatus(destination.appointment.appointmentStatus) }} · {{ destination.appointment.plannedDate || '日期未定' }}</p>
<div v-for="item in destination.items" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · {{ item.fbaWarehouseCode }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div>
<el-button :disabled="loading || !!error || saving || !!pending" @click="openAppointment(destination.appointment.appointmentNo)">查看本批预约</el-button>
<el-button v-if="destination.appointment.batchKey" :disabled="loading || !!error || saving || !!pending" @click="openBatch(destination.appointment.batchKey)">查看完整批次</el-button>
</article>
<p v-if="!currentDestinations.length && !loading && !error">当前没有与原货物范围匹配的分配;原快照不代表当前待办。</p>
</section>
</template>
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
<form @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<input v-model="form.firstDate" type="date"></label><label v-if="mode === 'split'">第二批目标日期(可不填)<input v-model="form.nextDate" type="date"></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></form>
</template>
</template>
</section>
</template>
<style scoped>.allocation-row, .batch-result, .batch-candidate { padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--cc-border); overflow-wrap: anywhere; }.batch-modes, .batch-result, .batch-candidate { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }.batch-candidate > span { flex: 1; }</style>
@@ -0,0 +1,135 @@
import ElementPlus from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
import { listTripCalendar } from './tripCalendar'
import { createSourceAppointment, listPlanningCargo, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
import { batchContext } from './batch.test-support'
import type { ScheduleEvent } from './schedule'
import { getWorkDetail, type WorkAction } from './workApi'
import { workDetail, workItem } from './work.test-support'
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => { vi.unstubAllGlobals() })
const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true }
const event: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740998', orderId: '9007199254740998', customerId: '9007199254740997', customerName: '预约长客户名称'.repeat(12), inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国长仓库名称'.repeat(12), destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: cargo.originWarehouseName, destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
beforeEach(() => {
vi.clearAllMocks(); routing.denied.clear(); routing.query = { date: '2026-10-19' }
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
})
async function open() {
const wrapper = mount(AppointmentCalendarView, { global: { plugins: [ElementPlus], stubs: {
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', template: '<section data-calendar />' },
} } })
await flushPromises()
return wrapper
}
async function create(wrapper: Awaited<ReturnType<typeof open>>) {
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '新增FBA送仓预约')!
const content = creationBody()
await content.get('input[type="checkbox"]').setValue(true)
await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises()
return dialog
}
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
it('keeps the arrival context and typed local time across native fullscreen and submits the original version', async () => {
const wrapper = await open()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
expect(dialog.props('width')).toBe('520px')
expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓')
expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId)
await dialog.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((dialog.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
})
it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
await dialog.get('input').setValue('2026-10-22T09:30')
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(dialog.get('input').attributes('disabled')).toBeDefined()
expect(dialog.text()).toContain('上次提交结果尚未确认')
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果')
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
})
it('keeps complete long cargo names accessible in the selectable table', async () => {
const wrapper = await open()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
const names = wrapper.findAllComponents(LineClampText).map(item => item.props('content'))
expect(names).toContain(cargo.customerName); expect(names).toContain(cargo.originWarehouseName)
expect(wrapper.getComponent({ name: 'DataTable' }).props('maxHeight')).toBe('')
expect(wrapper.text()).not.toMatch(/900719925474099[7-9]/)
expect(resolvePlanningSource).not.toHaveBeenCalled()
})
it('preserves real FBA fields and entered values when expanding the create dialog', async () => {
const wrapper = await open(); const dialog = await create(wrapper)
const fields = dialog.getComponent({ name: 'FbaAppointmentFields' })
expect(fields.props('continuous')).toBe(true)
const date = fields.findAllComponents({ name: 'ElDatePicker' })[0]!
date.vm.$emit('update:modelValue', '2026-10-25'); await flushPromises()
await creationBody().get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(date.props('modelValue')).toBe('2026-10-25')
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) }))
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
expect(creationBody().text()).toContain('AP-001')
})
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
const wrapper = await open(); await create(wrapper)
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
const receipt = creationBody().get('[aria-label="预约保存回执"]')
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
})
it('does not open or request creation data without its original write permission', async () => {
routing.denied.add('platform:overseas-warehouse:inbound-order:fba-appointment')
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await flushPromises()
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title') === '新增FBA送仓预约')).toBe(false)
})
it('uses the same real continuous form through the original work detail deep link', async () => {
routing.query.workItem = 'work-delivery-1'
const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }] } }
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }))
const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises()
const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!)
await content.get('.primary-next-action').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true)
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) }))
expect(content.text()).toContain('本次保存已完成')
})
@@ -0,0 +1,80 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
import { scheduleTypes, type ScheduleEvent } from './schedule'
const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] })
const render = (events: ScheduleEvent[] = []) => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-10-19', events },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
describe('four-type appointment calendar', () => {
it('shows all four types by default with a calendar and persistent daily detail', () => {
const wrapper = render(scheduleTypes.map(type => event(type)))
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
expect(wrapper.findAll('.calendar-day')).toHaveLength(35)
expect(wrapper.find('[data-date="2026-11-01"]').exists()).toBe(true)
expect(wrapper.find('[data-date="2026-11-02"]').exists()).toBe(false)
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
})
it('filters locally without requesting another range', async () => {
const wrapper = render([event('arrival'), event('dispatch')])
await wrapper.get('[aria-label="安排类型"] input').setValue(false)
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
await wrapper.get('[aria-label="搜索当前日历"]').setValue('missing')
expect(wrapper.text()).toContain('当天暂无安排')
expect(wrapper.emitted('range')).toBeUndefined()
})
it('offers creation in the header only when authorized', async () => {
const wrapper = render()
expect(wrapper.findAll('button').some(button => button.text() === '新增预约')).toBe(false)
await wrapper.setProps({ canCreate: true })
await wrapper.findAll('.calendar-controls button').find(button => button.text() === '新增预约')!.trigger('click')
expect(wrapper.emitted('create')).toEqual([[]])
expect(wrapper.emitted('range')).toBeUndefined()
wrapper.unmount()
})
it('changes the selected date locally and requests one range per mode or period change', async () => {
const wrapper = render()
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
expect(wrapper.emitted('range')).toBeUndefined()
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('2026-10-20')
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-10-19', to: '2026-10-26' }]])
await wrapper.get('[aria-label="下一周"]').trigger('click')
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-10-26', to: '2026-11-02' }])
})
it('limits previews to three while retaining every event in the daily details', () => {
const wrapper = render(Array.from({ length: 23 }, (_, index) => event('arrival', index)))
expect(wrapper.get('[data-date="2026-10-19"]').findAll('.event-chip')).toHaveLength(3)
expect(wrapper.get('.event-more').text()).toBe('还有 20 条')
expect(wrapper.findAll('.detail-event')).toHaveLength(23)
})
it('keeps undated records outside date cells and exposes them explicitly', async () => {
const wrapper = render([{ ...event('warehouse'), date: null }])
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
await wrapper.get('.undated-button').trigger('click')
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('日期未定')
})
it('retains business links and exposes editing only with authoritative eligibility', async () => {
const row = { ...event('warehouse'), sourcePath: '/container-loading/overseas/1', canEdit: true }
const wrapper = render([row])
await wrapper.get('.detail-event footer').findAll('button')[0]!.trigger('click')
await wrapper.get('.detail-event footer').findAll('button')[1]!.trigger('click')
expect(wrapper.emitted('open')).toEqual([[row]])
expect(wrapper.emitted('edit')).toEqual([[row]])
await wrapper.setProps({ events: [{ ...row, canEdit: false, sourcePath: undefined }] })
expect(wrapper.find('.detail-event footer').exists()).toBe(false)
})
it('distinguishes loading and partial failure from a complete empty day', async () => {
const wrapper = render()
await wrapper.setProps({ loading: true })
expect(wrapper.text()).toContain('正在读取安排')
expect(wrapper.text()).not.toContain('当天暂无安排')
await wrapper.setProps({ loading: false, incomplete: true })
expect(wrapper.text()).toContain('部分数据不可用')
expect(wrapper.text()).not.toContain('当天暂无安排')
})
})
@@ -0,0 +1,118 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode; canCreate?: boolean }>(),
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month', canCreate: false })
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent]; create: [] }>()
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
const visibleEvents = computed(() => props.events.filter(event => types.value.includes(event.type) &&
`${event.title} ${event.fields.map(field => field.value || '').join(' ')}`.toLowerCase().includes(keyword.value.trim().toLowerCase())))
const grouped = computed(() => {
const result = new Map<string, ScheduleEvent[]>()
for (const event of visibleEvents.value) {
const date = event.date || 'undated'
const rows = result.get(date) || []; rows.push(event); result.set(date, rows)
}
for (const rows of result.values()) rows.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
return result
})
const selectedEvents = computed(() => grouped.value.get(undated.value ? 'undated' : selectedDate.value) || [])
const heading = computed(() => mode.value === 'month' ? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月` : `${dates.value[0]} — ${dates.value.at(-1)}`)
function select(date: string) {
const before = calendarRange(selectedDate.value, mode.value)
selectedDate.value = date; undated.value = false
const after = calendarRange(date, mode.value)
emit('select', date, mode.value)
if (before.from !== after.from || before.to !== after.to) emit('range', after)
}
function changeMode(next: CalendarMode) {
if (mode.value === next) return
mode.value = next; emit('select', selectedDate.value, next); emit('range', calendarRange(selectedDate.value, next))
}
</script>
<template>
<section class="appointment-calendar admin-density" :aria-busy="loading">
<div class="calendar-main">
<header class="calendar-toolbar">
<div class="calendar-label">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<strong>预约日历</strong>
</div>
<div class="calendar-controls">
<el-button v-if="canCreate" type="primary" @click="emit('create')"><Plus :size="16" aria-hidden="true" />新增预约</el-button>
<div class="view-toggle" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
<strong class="calendar-period">{{ heading }}</strong>
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
<el-button @click="select(dateKey(new Date()))">今天</el-button>
</div>
</header>
<div class="calendar-options">
<div class="calendar-layers" role="group" aria-label="安排类型"><label v-for="type in scheduleTypes" :key="type" class="legend" :class="type"><input v-model="types" type="checkbox" :value="type">{{ scheduleLabels[type] }}</label></div>
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input v-model="keyword" type="search" maxlength="128" placeholder="柜号、车次号、仓库" aria-label="搜索当前日历"></label>
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
</div>
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
<div class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
<button v-for="date in dates" :key="date" type="button" class="calendar-day" :data-date="date"
:class="{ selected: !undated && selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
:aria-label="`${date},${loading ? '加载中' : `${(grouped.get(date) || []).length} 项安排`}`" :aria-pressed="!undated && selectedDate === date" @click="select(date)">
<span class="day-number">{{ Number(date.slice(8)) }}</span>
<span v-if="!loading" class="day-events"><span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="event.title">{{ event.title }}</span><span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span></span>
</button>
</div>
</div>
<aside class="calendar-detail" aria-label="当天安排">
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
<div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
<footer v-if="event.sourcePath || event.canEdit"><el-button v-if="event.sourcePath" link type="primary" :disabled="loading" @click="emit('open', event)">{{ event.type === 'dispatch' ? '查看排车' : '查看货柜' }}</el-button><el-button v-if="event.canEdit" link type="primary" :disabled="loading" @click="emit('edit', event)">调整计划到仓</el-button></footer>
</article>
</div>
</aside>
</section>
</template>
<style scoped>
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: var(--el-text-color-regular); }
.calendar-main, .calendar-detail { background: var(--admin-surface); border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
.calendar-label > svg { color: #617ce9; }.calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-options, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.legend { display: inline-flex; align-items: center; gap: var(--admin-space-1); font-size: var(--admin-font-caption); cursor: pointer; color: var(--event-fg); }.legend input { accent-color: var(--event-fg); }
.calendar-search { margin-left: auto; }.calendar-search input { box-sizing: border-box; width: 190px; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); font: inherit; }
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
.view-toggle button, .nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
.view-toggle button[aria-pressed='true'], .undated-button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 600; }.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
.calendar-period { font-size: var(--admin-font-section); white-space: nowrap; }
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; }
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }.calendar-day:hover { border-color: #bac8ed; }.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
button:focus-visible, input:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
.day-number { font-size: var(--admin-font-caption); font-weight: 600; }.day-events { display: flex; flex-direction: column; gap: var(--admin-space-1); margin-top: var(--admin-space-2); }
.event-chip { display: block; padding: 2px 6px; line-height: 18px; border-radius: var(--admin-radius); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--admin-font-caption); }
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }.warehouse { --event-bg: #e8f8f1; --event-fg: #18845f; }.return { --event-bg: #fff4e3; --event-fg: #995a10; }.dispatch { --event-bg: #f2edfa; --event-fg: #725199; }
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }.event-more, .empty-day { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: #617ce9; }
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); font-size: var(--admin-font-caption); }.event-type { padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); }
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }.event-subtitle { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }.detail-event dt { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.detail-event dd { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-body); overflow-wrap: anywhere; }
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid #edf0f6; padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (max-width: 1100px) { .appointment-calendar { grid-template-columns: minmax(0, 1fr); } }
</style>
@@ -0,0 +1,159 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
import { listTripCalendar } from './tripCalendar'
import type { ScheduleEvent } from './schedule'
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
export function renderSchedule() {
return mount(AppointmentCalendarView, { global: { stubs: {
AdminPage: { template: '<main><slot /></main>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
ElForm: { template: '<form><slot /></form>' },
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
ElInput: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], template: '<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)">' },
AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '<section data-create-panel />' },
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
} } })
}
beforeEach(() => {
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
})
describe('simplified appointment entry', () => {
it('opens creation lazily with the original read and write permissions and refreshes after creation', async () => {
const wrapper = renderSchedule(); await flushPromises()
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('canCreate')).toBe(true)
calendar.vm.$emit('create'); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCreatePanel' })
expect(panel.props('canWrite')).toBe(true)
vi.mocked(getCalendarSources).mockClear()
panel.vm.$emit('created'); await flushPromises()
expect(getCalendarSources).toHaveBeenCalledTimes(1)
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
wrapper.unmount()
})
it.each(['platform:overseas-warehouse:inbound-order:fba-appointment', 'platform:overseas-warehouse:last-mile-order:list'])('does not open creation without %s', async permission => {
routing.denied.add(permission)
const wrapper = renderSchedule(); await flushPromises()
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('canCreate')).toBe(false)
calendar.vm.$emit('create'); await flushPromises()
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
wrapper.unmount()
})
it('keeps unsaved input when the operator declines to leave', async () => {
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input').setValue('2026-10-22T09:30')
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalled()
expect(wrapper.find('[data-dialog]').exists()).toBe(true)
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
wrapper.unmount()
})
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
const wrapper = renderSchedule(); await flushPromises()
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-02' }, ['1'], expect.any(AbortSignal))
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
wrapper.unmount()
})
it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => {
vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline'))
const wrapper = renderSchedule(); await flushPromises()
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival'])
expect(wrapper.text()).toContain('计划到仓读取失败')
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true)
wrapper.unmount()
})
it('does not request unreadable domains and describes the incomplete scope', async () => {
routing.denied.add('platform:overseas-warehouse:dispatch:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
const wrapper = renderSchedule(); await flushPromises()
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少排车读取权限')
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
wrapper.unmount()
})
it('ignores stale calendar responses when the date range changes', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof getCalendarSources>>) => void
vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises()
resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises()
expect(wrapper.text()).not.toContain('缺少还柜读取权限')
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
wrapper.unmount()
})
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('提交结果尚未确认')
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
expect(await guard()).toBe(false)
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
wrapper.unmount()
})
it('requires an explicit reread after a conflict and retains the entered date', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String))
wrapper.unmount()
})
it('keeps the selected date and view in the source return link', async () => {
const wrapper = renderSchedule(); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
panel.vm.$emit('select', '2026-10-21', 'week')
panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
routing.denied.add('platform:fleet:trip:list')
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [{ sourceType: 'DISPATCH', dispatchNo: 'DS/1', status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-21T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [], incompleteReasons: [] }], unresolvedRecords: [] })
const wrapper = renderSchedule(); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
expect(listTripCalendar).toHaveBeenCalledOnce()
expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1')
panel.vm.$emit('select', '2026-10-21', 'week')
panel.vm.$emit('open', event); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
})
@@ -0,0 +1,205 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { isAxiosError } from 'axios'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
import { calendarRange, dateKey, type CalendarMode } from './calendar'
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
import type { PlanningSourceLookup } from './planningApi'
import type { WorkAction } from './workApi'
import { appointmentExternalTarget } from './workNavigation'
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
const AppointmentCreatePanel = defineAsyncComponent(() => import('./AppointmentCreatePanel.vue'))
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const creating = ref(false)
const canCreate = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && access.can('platform:overseas-warehouse:last-mile-order:list'))
function initialDate() {
const value = typeof route.query.date === 'string' ? route.query.date : ''
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
}
const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mode === 'week' ? 'week' : 'month')
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
let generation = 0, controller: AbortController | null = null
async function load(next = range.value) {
range.value = next
const request = ++generation; controller?.abort(); controller = new AbortController()
const signal = controller.signal
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
await Promise.allSettled([
(async () => {
let result: CalendarSources | null = null
try {
result = await getCalendarSources(next, signal)
if (request !== generation) return
sources.value = result
if (!result.emptyReturnsReadable) notices.value.push('缺少还柜读取权限,当前未显示还柜安排。')
} catch (cause) { fail('到港与还柜读取失败', cause) }
if (request !== generation) return
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) {
notices.value.push('缺少尾程订单读取权限,当前未显示计划到仓。'); return
}
try {
const rows = await getWarehouseArrivals(next, [...new Set(result?.containers.map(row => row.containerBookingId) || [])], signal)
if (request === generation) warehouses.value = rows
} catch (cause) { fail('计划到仓读取失败', cause) }
})(),
(async () => {
if (!access.can('platform:overseas-warehouse:dispatch:list')) { notices.value.push('缺少排车读取权限,当前未显示排车安排。'); return }
try {
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
if (request !== generation) return
trips.value = result.events
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`)
} catch (cause) { fail('排车读取失败', cause) }
})(),
])
if (request === generation) loading.value = false
}
function select(date: string, nextMode: CalendarMode) { selectedDate.value = date; mode.value = nextMode }
async function openSource(event: ScheduleEvent) {
if (!event.sourcePath || !await canLeave()) return
const context = { date: selectedDate.value, mode: mode.value }
await router.replace({ query: context })
await router.push(appointmentExternalTarget(event.sourcePath, context))
}
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
function edit(event: ScheduleEvent) {
if (!event.canEdit || !event.warehouse) return
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
}
async function save() {
const row = editor.value?.warehouse
if (!row || saving.value || conflict.value || !plannedAt.value) return
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
key: `calendar-arrival-${crypto.randomUUID()}` }
saving.value = true; saveError.value = null
try {
await saveWarehouseArrival(pending.id, pending.body, pending.key)
pending = null; unknownResult.value = false; editor.value = null
ElMessage.success('计划到仓已保存')
await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
unknownResult.value = !rejectedCommand(cause)
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
if (!unknownResult.value) pending = null
} finally { saving.value = false }
}
async function rereadEditor() {
const row = editor.value?.warehouse
if (!row) return
saving.value = true
try {
const rows = await getWarehouseArrivals(range.value, [row.sourceContainerBookingId])
const latest = rows.find(value => value.sourceContainerBookingId === row.sourceContainerBookingId)
if (!latest?.canEdit) { saveError.value = { message: '该柜已不能调整计划到仓,请关闭并查看最新货柜记录。' }; return }
editor.value = { ...editor.value!, warehouse: latest }; conflict.value = false; saveError.value = null
} catch (cause) { saveError.value = getApiErrorDetails(cause, '最新计划读取失败') }
finally { saving.value = false }
}
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() {
if (saving.value || unknownResult.value) return false
for (const guard of guards) if (!await guard()) return false
if (editor.value && plannedAt.value !== originalAt.value) {
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm' }) }
catch { return false }
}
return true
}
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
async function openCreate() { if (canCreate.value && await canLeave()) creating.value = true }
async function closeCreate(done: () => void) { if (await canLeave()) { done(); creating.value = false } }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
// 仅承接原业务仍在使用的精确来源/事项深链,不恢复旧全局待办、分页和筛选协议。
const workKey = ref(''), origins = ref<string[]>([]), sourceLink = ref<PlanningSourceLookup | null>(null), initialAction = ref<WorkAction | null>(null)
watch(() => [route.query.workItem, route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
workKey.value = typeof route.query.workItem === 'string' ? route.query.workItem : ''
const customerId = typeof route.query.customerId === 'string' ? route.query.customerId : ''
const destinationKey = typeof route.query.destinationKey === 'string' ? route.query.destinationKey : undefined
sourceLink.value = customerId && typeof route.query.sourceKey === 'string' ? { customerId, sourceKey: route.query.sourceKey, destinationKey }
: customerId && typeof route.query.orderId === 'string' ? { customerId, orderId: route.query.orderId, destinationKey } : null
origins.value = []
initialAction.value = null
}, { immediate: true })
async function openWork(key: string, action?: WorkAction) {
if (await canLeave()) { if (workKey.value) origins.value.push(workKey.value); workKey.value = key; initialAction.value = action || null }
}
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
async function closeBusiness(done: () => void) {
if (!await canLeave()) return
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
}
async function external(path: string) {
if (!await canLeave()) return
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
}
onMounted(() => { void load() })
onActivated(() => { const returning = !active.value; active.value = true; if (returning) void load() })
onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
onBeforeUnmount(() => { ++generation; controller?.abort() })
</script>
<template>
<AdminPage class="admin-density appointment-schedule" spacing="compact">
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" :can-create="canCreate" @create="openCreate" @range="load" @select="select" @open="openSource" @edit="edit" />
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
</AppDialog>
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
<div v-if="editor" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving || unknownResult">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label><span>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></span></template><el-input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" /></el-form-item></td></tr></tbody></table>
</el-form>
</section>
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
</div>
</AppDialog>
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
</AppDialog>
</AdminPage>
</template>
<style scoped>
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
</style>
@@ -0,0 +1,53 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
const access = vi.hoisted(() => ({ canCancel: true }))
vi.mock('@/core/access/accessPreview', () => ({useAccessPreviewStore: () => ({can: () => access.canCancel})}))
vi.mock('vue-router', () => ({onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn()}))
vi.mock('./api', () => ({deleteAppointment: vi.fn(),listAppointmentCancellations: vi.fn(),listAppointmentCancellationRecords: vi.fn()}))
const record = {appointmentNo:'AP-CANCEL',version:1,cancelReason:null,cancelledAt:'2026-10-11T01:00:00',snapshot:{fbaWarehouseCode:'ABE3',appointmentStatus:'CANCELLED',appointmentRemark:'原备注保留',plannedDate:null,plannedAt:null,plannedEndAt:null,confirmationNo:'EXT-1',appointmentAt:'2026-10-15T09:00:00',appointmentEndAt:null,timezone:'America/New_York',selectedCount:1}}
function dialog() {
return mount(AppointmentCancellationDialog,{props:{appointmentNo:'AP-CANCEL',version:0,selectedCount:1},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose','showConfirm'],template:'<section><slot/><button v-if="showConfirm" data-save @click="$emit(\'confirm\')">取消</button></section>'}}}})
}
describe('appointment cancellation',()=>{
beforeEach(()=>{vi.resetAllMocks();access.canCancel=true})
it('requires a reason and retries the same reason and version after an unknown result',async()=>{
vi.mocked(deleteAppointment).mockRejectedValueOnce(new Error('lost')).mockResolvedValueOnce(undefined)
const wrapper=dialog();await flushPromises()
await wrapper.get('[data-save]').trigger('click');expect(deleteAppointment).not.toHaveBeenCalled()
await wrapper.get('textarea').setValue(' 仓库取消时段 ')
await wrapper.get('[data-save]').trigger('click');await flushPromises()
expect(deleteAppointment).toHaveBeenCalledWith('AP-CANCEL',0,'仓库取消时段')
const done=vi.fn();wrapper.getComponent({name:'AppDialog'}).props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
expect(wrapper.get('textarea').attributes('disabled')).toBeDefined()
await wrapper.get('[data-save]').trigger('click');await flushPromises()
expect(deleteAppointment).toHaveBeenNthCalledWith(2,'AP-CANCEL',0,'仓库取消时段')
expect(wrapper.emitted('cancelled')).toHaveLength(1);wrapper.unmount()
})
it('does not expose or invoke cancellation without its independent permission',async()=>{
access.canCancel=false;const wrapper=dialog();await flushPromises()
expect(wrapper.find('[data-save]').exists()).toBe(false)
wrapper.getComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
expect(deleteAppointment).not.toHaveBeenCalled();wrapper.unmount()
})
it('reads history once per page and keeps missing historical reason explicit',async()=>{
vi.mocked(listAppointmentCancellations).mockResolvedValue({records:[record],total:11})
const wrapper=mount(AppointmentCancellationHistory,{props:{appointmentNo:'AP-CANCEL'},global:{plugins:[ElementPlus]}});await flushPromises()
expect(listAppointmentCancellations).toHaveBeenCalledExactlyOnceWith('AP-CANCEL',1,10)
expect(wrapper.text()).toContain('历史原因未记录');expect(wrapper.text()).toContain('原备注保留')
wrapper.getComponent({name:'DataTable'}).vm.$emit('update:page',2);await flushPromises()
expect(listAppointmentCancellations).toHaveBeenLastCalledWith('AP-CANCEL',2,10);wrapper.unmount()
})
it('discovers persisted cancellations with one server page and opens by business number',async()=>{
vi.mocked(listAppointmentCancellationRecords).mockResolvedValue({records:[{appointmentNo:'AP-CANCEL',version:1,cancelReason:'实际原因',cancelledAt:record.cancelledAt,fbaWarehouseCode:'ABE3',selectedCount:1}],total:1})
const wrapper=mount(AppointmentCancellationRecords,{global:{plugins:[ElementPlus]}});await flushPromises()
expect(listAppointmentCancellationRecords).toHaveBeenCalledExactlyOnceWith(1,10)
expect(listAppointmentCancellations).not.toHaveBeenCalled()
await wrapper.get('.el-table__body-wrapper .el-table__row').findAll('button').find(b=>b.text()==='查看')!.trigger('click')
expect(wrapper.emitted('view')).toEqual([['AP-CANCEL']]);wrapper.unmount()
})
})
@@ -0,0 +1,17 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
vi.mock('@/shared/api/http',()=>({http:{get:vi.fn(),delete:vi.fn()},unwrapApiResponse:(result:{data:unknown})=>result.data}))
beforeEach(()=>vi.resetAllMocks())
it('sends the required cancellation body without the old reasonless query shape',async()=>{
vi.mocked(http.delete).mockResolvedValue({data:undefined})
await deleteAppointment('AP-TEST',3,'仓库取消时段')
expect(http.delete).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP-TEST',{data:{version:3,cancelReason:'仓库取消时段'}})
})
it('uses paged cancellation reads and the original business number for detail history',async()=>{
vi.mocked(http.get).mockResolvedValue({data:{records:[],total:'12'}})
expect((await listAppointmentCancellationRecords(2,10)).total).toBe(12)
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations',{params:{page:2,size:10}})
await listAppointmentCancellations('AP/TEST',1,10)
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP%2FTEST/cancellations',{params:{page:1,size:10}})
})
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { inject, onBeforeUnmount, ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { deleteAppointment } from './api'
const props = defineProps<{ appointmentNo: string; version: number; selectedCount: number }>()
const emit = defineEmits<{ close: []; cancelled: [] }>()
const access = useAccessPreviewStore()
const reason = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
const pending = ref<{number: string; version: number; reason: string} | null>(null)
function canLeave() {
if (saving.value || pending.value) { ElMessage.warning('请先核实本次取消结果,再关闭或切换'); return false }
return true
}
function beforeClose(done: () => void) { if (canLeave()) done() }
function close() { if (canLeave()) emit('close') }
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregister = register?.(canLeave)
onBeforeUnmount(() => unregister?.())
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
async function save() {
if (!access.can('platform:appointment:delete') || saving.value) return
if (!pending.value && !reason.value.trim()) { ElMessage.warning('请填写实际取消原因'); return }
pending.value ||= {number: props.appointmentNo, version: props.version, reason: reason.value.trim()}
saving.value = true; error.value = null
try {
await deleteAppointment(pending.value.number, pending.value.version, pending.value.reason)
pending.value = null; emit('cancelled')
} catch (cause) {
error.value = getApiErrorDetails(cause, '取消结果待核实,请重试本次取消')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null
} finally { saving.value = false }
}
</script>
<template>
<AppDialog :model-value="true" title="取消预约" width="min(600px, calc(100vw - 48px))" class="admin-density-dialog" confirm-type="danger" :confirm-text="pending ? '重试本次取消' : '确认取消预约'" :show-confirm="access.can('platform:appointment:delete')" :confirm-loading="saving" :before-close="beforeClose" @update:model-value="close" @confirm="save">
<p>{{ appointmentNo }} · {{ selectedCount }} 票货物</p>
<p>取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。</p>
<LoadErrorState v-if="error" title="取消未完成" :message="error.message" :show-retry="false" />
<el-form label-position="top" :disabled="saving || !!pending">
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit /></el-form-item>
</el-form>
</AppDialog>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted, ref, onBeforeUnmount } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listAppointmentCancellations, type AppointmentCancellation } from './api'
const props = defineProps<{ appointmentNo: string }>()
const page = ref(1), size = ref(10), total = ref(0), rows = ref<AppointmentCancellation[]>([])
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let request = 0
const columns: DataTableColumn[] = [
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:170,singleLine:false},
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:220,singleLine:false},
{key:'snapshot',label:'取消时记录',slotName:'snapshot',minWidth:260,singleLine:false},
]
async function load() {
const current = ++request; loading.value = true; error.value = null
try { const result = await listAppointmentCancellations(props.appointmentNo,page.value,size.value); if(current===request) {rows.value=result.records;total.value=result.total} }
catch(cause) { if(current===request) error.value=getApiErrorDetails(cause,'取消历史加载失败') }
finally { if(current===request) loading.value=false }
}
onMounted(load)
onBeforeUnmount(()=>++request)
</script>
<template>
<LoadErrorState v-if="error" title="取消历史加载失败" :message="error.message" @retry="load" />
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" row-key="version" @update:page="load" @update:page-size="page=1;load()">
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
<template #snapshot="{row}"><details><summary>{{ row.snapshot.fbaWarehouseCode }} · {{ row.snapshot.selectedCount }} 票 · 查看取消时记录</summary><p>原业务备注:{{ row.snapshot.appointmentRemark || '—' }}</p><p>原计划:{{ row.snapshot.plannedAt || row.snapshot.plannedDate || '待定' }}<span v-if="row.snapshot.plannedEndAt"> — {{ row.snapshot.plannedEndAt }}</span></p><p>原确认:{{ row.snapshot.appointmentAt || '未确认' }}<span v-if="row.snapshot.appointmentEndAt"> — {{ row.snapshot.appointmentEndAt }}</span> · {{ row.snapshot.timezone || '时区待核实' }}</p><p>外部确认号:{{ row.snapshot.confirmationNo || '—' }}</p></details></template>
</DataTable>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { onMounted, ref, onBeforeUnmount } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listAppointmentCancellationRecords, type AppointmentCancellationSummary } from './api'
const emit = defineEmits<{ view: [appointmentNo: string] }>()
const page=ref(1),size=ref(10),total=ref(0),rows=ref<AppointmentCancellationSummary[]>([])
const loading=ref(false),error=ref<ApiErrorDetails|null>(null)
let request=0
const columns: DataTableColumn[]=[
{key:'actions',label:'操作',slotName:'actions',width:90,fixed:'left'},
{prop:'appointmentNo',label:'预约单号',minWidth:220,singleLine:false},
{prop:'fbaWarehouseCode',label:'目的仓',width:100},
{prop:'selectedCount',label:'票数',width:80},
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:180,singleLine:false},
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:240,singleLine:false},
]
async function load() {
const current=++request;loading.value=true;error.value=null
try {const result=await listAppointmentCancellationRecords(page.value,size.value);if(current===request){rows.value=result.records;total.value=result.total}}
catch(cause){if(current===request)error.value=getApiErrorDetails(cause,'取消记录加载失败')}
finally{if(current===request)loading.value=false}
}
onMounted(load)
onBeforeUnmount(()=>++request)
</script>
<template>
<LoadErrorState v-if="error" title="取消记录加载失败" :message="error.message" @retry="load" />
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="(row: object) => `${(row as AppointmentCancellationSummary).appointmentNo}:${(row as AppointmentCancellationSummary).version}`" @update:page="load" @update:page-size="page=1;load()">
<template #actions="{row}"><el-button @click="emit('view',row.appointmentNo)">查看</el-button></template>
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
</DataTable>
</template>
@@ -0,0 +1,67 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import { listPlanningCargo } from './planningApi'
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
function render(attach = false) {
return mount(AppointmentCargoPanel, { attachTo: attach ? document.body : undefined, global: { stubs: {
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters"/><slot name="notices"/><button data-next @click="$emit(\'update:page\',2)">下一页</button></div>' },
QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '<button data-search @click="$emit(\'search\')">查询</button>' },
LoadErrorState: true, ApiErrorMeta: true, RowActions: true, AppointmentExternalCandidates: { name: 'AppointmentExternalCandidates', template: '<section class="integrated-query-group"><slot name="query-tabs"/></section>' },
} } })
}
describe('appointment planning cargo query', () => {
beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) })
it('switches cargo tabs by click and keyboard without reloading first-leg cargo', async () => {
const wrapper = render(true); await flushPromises()
const tabs = () => wrapper.findAll('[role="tab"]')
expect(tabs().map(tab => tab.text())).toEqual(['头程货物', '外接货物'])
expect(wrapper.find('.integrated-query-group [role="tablist"]').exists()).toBe(true)
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
await tabs()[1]!.trigger('click')
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
expect(wrapper.findComponent({ name: 'AppointmentExternalCandidates' }).exists()).toBe(true)
await tabs()[1]!.trigger('keydown', { key: 'ArrowRight' })
await flushPromises()
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
expect(document.activeElement).toBe(tabs()[0]!.element)
expect(tabs()[1]!.attributes('tabindex')).toBe('-1')
await tabs()[0]!.trigger('keydown', { key: 'End' })
await flushPromises()
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
expect(document.activeElement).toBe(tabs()[1]!.element)
await tabs()[1]!.trigger('keydown', { key: 'Home' })
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('uses one server-page request per entry, page change and reset', async () => {
const wrapper = render(); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
await wrapper.get('[data-search]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledTimes(3)
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
})
it('shows a source failure instead of an authoritative empty list', async () => {
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('目录不可用'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
})
it('discards a late previous page and uses the new server total', async () => {
let resolveOld!: (value: typeof empty) => void
vi.mocked(listPlanningCargo).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = render()
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ ...empty, page: 2, total: 21, pages: 2 })
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
resolveOld({ ...empty, total: 100, pages: 5 }); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(21)
expect(vi.mocked(listPlanningCargo).mock.calls[0]![1]!.aborted).toBe(true)
})
})
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { inject, computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
import AppDialog from '@/shared/components/AppDialog.vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue'
import AppointmentCargoTabs from './AppointmentCargoTabs.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const router = useRouter()
const access = useAccessPreviewStore()
const props = defineProps<{ externalSource?: PlanningSource | null }>()
const emit = defineEmits<{ created: []; sourceAccepted: [] }>()
const arrangement = reactive(createAppointmentForm())
const creating = ref(false), createError = ref<ApiErrorDetails | null>(null)
const pendingCreate = ref<SourceAppointmentPayload | null>(null)
const following = ref<ResolvedPlanningSource | null>(null)
const candidateMode = ref<'firstleg' | 'external'>('firstleg')
const externalRevision = ref(0)
const followupError = ref<ApiErrorDetails | null>(null)
type Selection = Pick<PlanningSource, 'sourceKey' | 'customerId' | 'originWarehouseId' | 'destinationWarehouseCode' | 'inboundCode'> & { bookingNo?: string | null }
const selected = ref<Record<string, Selection>>({})
const selectedRows = computed(() => Object.values(selected.value))
const externalRows = computed(() => selectedRows.value.filter(row => row.sourceKey.startsWith('BOOKING:')))
const batchPreview = ref<PlanningBatchPreview | null>(null)
const batchLoading = ref(false), batchError = ref<ApiErrorDetails | null>(null)
let batchRequest = 0
function addSource(source: PlanningSource) {
if (!access.can(overseasWarehousePermissions.inboundOrderAppointment) || batchLoading.value || creating.value) return false
if (!source.canFollowup) { ElMessage.warning('当前来源尚不能安排预约'); return false }
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== source.originWarehouseId || anchor.destinationWarehouseCode !== source.destinationWarehouseCode)) {
ElMessage.warning('请先完成或清空当前合批选择;不同起运仓或目的仓不能混入'); return false
}
if (!selected.value[source.sourceKey] && selectedRows.value.length >= 100) { ElMessage.warning('每批最多选择100票'); return false }
selected.value[source.sourceKey] = source; batchError.value = null
return true
}
function acceptExternalSource() {
if (props.externalSource && addSource(props.externalSource)) emit('sourceAccepted')
}
function arrangeFollowing() {
if (following.value && !following.value.coverage.appointments.length && addSource(following.value.source)) following.value = null
}
function selectable(row: PlanningCargo) {
const anchor = selectedRows.value[0]
return !row.detained && !row.mixedDestinations && !row.hasActiveAppointment && (!anchor || (anchor.originWarehouseId === row.originWarehouseId && anchor.destinationWarehouseCode === row.destinationWarehouseCode))
}
function toggle(row: PlanningCargo, checked: unknown) {
batchError.value = null
if (checked && selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row
else delete selected.value[row.sourceKey]
}
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
async function previewBatch() {
if (!selectedRows.value.length || batchLoading.value || creating.value || pendingCreate.value) return
const current = ++batchRequest
batchLoading.value = true; batchError.value = null
try {
const result = await previewPlanningBatch(selectedRows.value.map(row => ({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })))
if (current === batchRequest) { batchPreview.value = result; Object.assign(arrangement, createAppointmentForm()); pendingCreate.value = null; createError.value = null }
} catch (cause) { if (current === batchRequest) batchError.value = getApiErrorDetails(cause, '所选货物暂不能合批') }
finally { if (current === batchRequest) batchLoading.value = false }
}
async function saveArrangement() {
if (!batchPreview.value || creating.value || !access.can(overseasWarehousePermissions.inboundOrderAppointment)) return
const error = appointmentFormError(arrangement)
if (!pendingCreate.value && error) { ElMessage.warning(error); return }
pendingCreate.value ||= { originWarehouseId: batchPreview.value.group.originWarehouseId, fbaWarehouseCode: batchPreview.value.destinationWarehouseCode, sources: batchPreview.value.sources.map(row => ({sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: row.destinationKey})), details: appointmentPayload(arrangement), idempotencyKey: crypto.randomUUID() }
creating.value = true; createError.value = null
try {
const result = await createSourceAppointment(pendingCreate.value)
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
} catch (cause) {
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
} finally { creating.value = false }
}
let followupRequest = 0
const query = ref<QueryModel>({ keyword: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 }]
const page = ref(1), size = ref(20), total = ref(0)
const rows = ref<PlanningCargo[]>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let request = 0
let controller: AbortController | undefined
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' },
{ key: 'select', label: '选择', slotName: 'select', width: 65 },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
{ prop: 'customerName', label: '客户', minWidth: 150 },
{ prop: 'containerNo', label: '柜号', minWidth: 150 },
{ prop: 'originWarehouseName', label: '接仓点', minWidth: 140 },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '预报箱数', width: 100 },
{ prop: 'receivedPackages', label: '实收箱数', width: 100 },
{ prop: 'estimatedArrivalAt', label: '来源预计到港', minWidth: 170 },
{ prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 180, slotName: 'warehouseEta', singleLine: false },
{ key: 'progress', label: '当前事项', slotName: 'progress', minWidth: 170 },
]
async function load(reset = false) {
if (reset) page.value = 1
const current = ++request
controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listPlanningCargo({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
if (request !== current) return
rows.value = result.records; total.value = result.total
} catch (cause) {
if (request === current) { error.value = getApiErrorDetails(cause, '已开船货物加载失败'); rows.value = []; total.value = 0 }
} finally { if (request === current) loading.value = false }
}
function source(row: PlanningCargo) {
return router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderId }, query: { customerId: row.customerId } })
}
async function openFollowup(row: Pick<PlanningCargo, 'sourceKey' | 'customerId' | 'destinationWarehouseCode'>) {
const request = ++followupRequest
followupError.value = null
try {
const result = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })
if (request === followupRequest) following.value = result
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
}
function canLeave() {
if (creating.value || pendingCreate.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
return true
}
function closeBatch() { if (canLeave()) batchPreview.value = null }
function beforeClose(done: () => void) { if (canLeave()) done() }
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
onBeforeUnmount(() => unregisterLeaveGuard?.())
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
onMounted(() => load())
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() })
</script>
<template>
<AppointmentExternalCandidates v-if="candidateMode === 'external'" :key="externalRevision" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :selected-keys="Object.keys(selected)" :busy="batchLoading || creating" @select="addSource" @followup="openFollowup($event.source)">
<template #query-tabs><AppointmentCargoTabs v-model="candidateMode" /></template>
<template #toolbar-actions>
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
</template>
<template #notices>
<details v-if="selectedRows.length" class="external-selection"><summary>已选 {{ selectedRows.length }} 票(切换货源保留选择)</summary>
<div class="external-selection-list"><div v-for="row in selectedRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
</details>
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false" />
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
</template>
</AppointmentExternalCandidates>
<DataTable v-show="candidateMode === 'firstleg'" v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="sourceKey" @update:page="load()" @update:page-size="load(true)">
<template #toolbar-start>已选 {{ selectedRows.length }} 票,可跨客户选择同一起运仓、同一目的仓货物</template>
<template #toolbar-actions>
<el-button :disabled="loading || batchLoading" @click="selectPage">选择本页可合批货物</el-button>
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
</template>
<template #filters>
<section class="integrated-query-group" aria-label="头程货物类型与筛选">
<AppointmentCargoTabs v-if="candidateMode === 'firstleg'" v-model="candidateMode" />
<QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" />
</section>
</template>
<template #notices>
<div v-if="externalSource" class="cargo-note">
待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }}
<el-button v-if="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :disabled="batchLoading || creating" @click="acceptExternalSource">加入本次选择</el-button>
</div>
<details v-if="externalRows.length" class="external-selection">
<summary>已选外接货 {{ externalRows.length }} 票(可与同仓头程货合批)</summary>
<div class="external-selection-list"><div v-for="row in externalRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
</details>
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false"><template #details><ApiErrorMeta :details="batchError" /></template></LoadErrorState>
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
<LoadErrorState v-if="error" title="已开船货物加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-else class="cargo-note">来源预计时间供筹划核对,确认预约前请核对最新船期与货物备妥情况。</p>
</template>
<template #actions="{ row }"><RowActions :actions="[{ key: 'followup', label: '跟进' }, { key: 'source', label: '查看来源' }]" text-actions @action="key => key === 'followup' ? openFollowup(row as PlanningCargo) : source(row as PlanningCargo)" /></template>
<template #select="{ row }"><el-checkbox :model-value="!!selected[row.sourceKey]" :aria-label="`选择 ${row.inboundCode || '未关联入仓码'}`" :disabled="batchLoading || (!selected[row.sourceKey] && (!selectable(row as PlanningCargo) || selectedRows.length >= 100))" @change="(value: boolean | string | number) => toggle(row as PlanningCargo, value)" /></template>
<template #warehouseEta="{ row }"><span>{{ row.estimatedWarehouseAt?.replace('T', ' ') || '—' }}</span><div v-if="row.estimatedWarehouseAt" class="cargo-note">来源时区未明确</div></template>
<template #progress="{ row }">
<span v-if="row.detained">暂扣,待处理</span>
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
<span v-else-if="row.hasActiveAppointment">已有预约</span>
<span v-else>{{ row.hasInboundPlan ? '已建入仓计划,待安排' : '尚未建入仓计划' }}</span>
</template>
</DataTable>
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
<p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p>
<LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
<FbaAppointmentFields :timezone="batchPreview.destinationTimezone" :model="arrangement" :disabled="creating || !!pendingCreate" />
</AppDialog>
</template>
<style scoped>
.cargo-note { margin: var(--admin-space-3) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.external-selection { margin: var(--admin-space-3) 0; }
.external-selection-list { max-height: 216px; overflow-y: auto; }
.external-selection-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { nextTick } from 'vue'
const mode = defineModel<'firstleg' | 'external'>({ required: true })
const tabs = [{ key: 'firstleg', label: '头程货物' }, { key: 'external', label: '外接货物' }] as const
async function handleKeydown(event: KeyboardEvent, index: number) {
let next: number
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = tabs.length - 1
else return
event.preventDefault()
const key = tabs[next]!.key
mode.value = key
await nextTick()
document.getElementById(`appointment-cargo-tab-${key}`)?.focus()
}
</script>
<template>
<div class="density-status-tabs" role="tablist" aria-label="预约货源类型">
<button v-for="(tab, index) in tabs" :id="`appointment-cargo-tab-${tab.key}`" :key="tab.key" type="button" role="tab"
:aria-selected="mode === tab.key" :tabindex="mode === tab.key ? 0 : -1"
:class="{ 'is-active': mode === tab.key }"
@click="mode = tab.key" @keydown="handleKeydown($event, index)">{{ tab.label }}</button>
</div>
</template>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlanningContainers, resolvePlanningContainer, type PlanningContainer, type ContainerFollowupContext } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
import { listImpacts, refreshImpacts, impactStatus } from './impactApi'
const impactReady = ref(false), impactError = ref<ApiErrorDetails | null>(null)
const impactId = ref<string | null>(null), refreshing = ref(false), onlyChanges = ref(false)
const access = useAccessPreviewStore()
const query = ref<QueryModel>({ keyword: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '整柜', placeholder: '柜号或入仓码', width: 300 }]
const rows = ref<PlanningContainer[]>([]), page = ref(1), size = ref(20), total = ref(0)
const loading = ref(false), opening = ref(false)
const error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
const context = ref<ContainerFollowupContext | null>(null)
const changedCount = computed(() => rows.value.filter(row => row.impact?.status === 'OPEN').length)
const displayedRows = computed(() => onlyChanges.value ? rows.value.filter(row => row.impact?.status === 'OPEN') : rows.value)
async function refreshForecasts() {
refreshing.value = true; contextError.value = null
try { await refreshImpacts({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }); await load() }
catch (cause) { contextError.value = getApiErrorDetails(cause, '船期核对未完成,原记录保留') }
finally { refreshing.value = false }
}
let request = 0, contextRequest = 0, controller: AbortController | undefined
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 200, fixed: 'left' },
{ prop: 'containerNo', label: '集装箱号', minWidth: 170 },
{ prop: 'orderCount', label: '美国范围票数', width: 120 },
{ prop: 'customerCount', label: '客户数', width: 90 },
{ key: 'packages', label: '预报箱数', slotName: 'packages', width: 130 },
{ key: 'arrival', label: '来源预计到港', slotName: 'arrival', minWidth: 200 },
{ key: 'receiving', label: '接仓点及跟进', slotName: 'receiving', minWidth: 280, singleLine: false },
{ key: 'impact', label: '船期跟进', slotName: 'impact', minWidth: 160 },
{ key: 'owner', label: '负责人', slotName: 'owner', minWidth: 110 },
{ key: 'scope', label: '范围核对', slotName: 'scope', minWidth: 200 },
]
async function load(reset = false) {
if (reset) page.value = 1
const current = ++request
controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null; impactReady.value = false; impactError.value = null
try {
const params = { page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }
const [cargo, impacts] = await Promise.allSettled([listPlanningContainers(params, controller.signal), listImpacts(params, controller.signal)])
if (current !== request) return
if (cargo.status === 'rejected') throw cargo.reason
rows.value = cargo.value.records; total.value = cargo.value.total
if (impacts.status === 'fulfilled') {
const states = new Map(impacts.value.records.map(row => [row.container.containerBookingId, row.impact]))
rows.value = rows.value.map(row => ({ ...row, impact: states.get(row.containerBookingId) || null }))
impactReady.value = true
} else { onlyChanges.value = false; impactError.value = getApiErrorDetails(impacts.reason, '船期跟进暂不可用') }
} catch (cause) { if (current === request) { error.value = getApiErrorDetails(cause, '整柜货源加载失败'); rows.value = []; total.value = 0 } }
finally { if (current === request) loading.value = false }
}
async function follow(row: PlanningContainer, warehouseId?: string) {
const current = ++contextRequest
contextError.value = null; opening.value = true
try {
const result = await resolvePlanningContainer(row.containerBookingId, warehouseId)
if (current === contextRequest) context.value = result
} catch (cause) { if (current === contextRequest) contextError.value = getApiErrorDetails(cause, '整柜范围核对失败') }
finally { if (current === contextRequest) opening.value = false }
}
const displayTime = (value: string | null) => value?.replace('T', ' ') || '—'
onMounted(() => load())
onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
</script>
<template>
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="displayedRows" :total="total" :loading="loading || refreshing" :results-visible="!error" row-key="containerBookingId" @update:page="load()" @update:page-size="load(true)">
<template #filters><QueryToolbar v-model="query" ariaLabel="提柜与到仓查询" :fields="fields" :loading="loading" @search="load(true)">
<template #actions>
<el-button v-if="impactReady" :type="onlyChanges ? 'primary' : 'default'" @click="onlyChanges = !onlyChanges">本页待处理 {{ changedCount }}</el-button>
<el-button v-if="impactReady && access.can('platform:appointment:followup')" :loading="refreshing" :disabled="loading" @click="refreshForecasts">核对船期</el-button>
</template>
</QueryToolbar></template>
<template #notices>
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
</template>
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button><el-button v-if="row.impact" link type="primary" @click="impactId = row.containerBookingId">{{ row.impact.status === 'OPEN' ? '处理船期变化' : '核对记录' }}</el-button></template>
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
<template #arrival="{ row }">{{ displayTime(row.earliestArrivalAt) }}<span v-if="row.latestArrivalAt && row.latestArrivalAt !== row.earliestArrivalAt"> 至 {{ displayTime(row.latestArrivalAt) }}</span></template>
<template #receiving="{ row }"><div v-for="warehouse in row.warehouses" :key="warehouse.warehouseId" class="receiving-row"><span>{{ warehouse.warehouseName }} · {{ warehouse.orderCount }} 票</span><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer, warehouse.warehouseId)">接仓跟进</el-button></div></template>
<template #impact="{ row }">{{ row.impact ? impactStatus[row.impact.status] : (impactReady ? '未核对' : '暂不可用') }}</template>
<template #owner="{ row }">{{ row.impact?.ownerName || '未认领' }}</template>
<template #scope="{ row }">{{ row.incompleteScope ? '有未明确或非美国接仓点,待核对' : '接仓点已核实' }}</template>
</DataTable>
<AppointmentImpactDialog v-if="impactId" :container-id="impactId" :can-write="access.can('platform:appointment:followup')" @close="impactId = null" @saved="load()" />
<AppointmentFollowupDialog v-if="context" :container="context" :can-write="access.can('platform:appointment:followup')" @close="context = null" />
</template>
<style scoped>
.container-help { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.container-help summary { cursor: pointer; }
.receiving-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
</style>
@@ -0,0 +1,133 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import AppointmentCreatePanel from './AppointmentCreatePanel.vue'
import { createSourceAppointment, listPlanningCargo, listPlanningDestinations, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
import type { QueryField } from '@/shared/components/QueryToolbar.vue'
import { batchContext } from './batch.test-support'
import type { LeaveGuard } from './useWorkDraft'
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), listPlanningDestinations: vi.fn(), resolvePlanningSource: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740999', orderId: '9007199254740999', customerId: '9007199254740998', customerName: '测试客户', inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
const guards = new Set<LeaveGuard>()
function render(canWrite = true) {
return mount(AppointmentCreatePanel, { props: { canWrite, canLeave: async () => { for (const guard of guards) if (!await guard()) return false; return true } }, global: {
provide: { 'appointment-work-leave-guard': (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) } },
stubs: {
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters" /><slot name="toolbar-actions" /><slot name="notices" /><div v-for="row in data" :key="row.sourceKey"><span>{{ row.inboundCode }}</span><slot name="select" :row="row" /><slot name="eligibility" :row="row" /></div><button data-page @click="$emit(\'update:page\', 2)">下一页</button></div>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], template: '<button data-query @click="$emit(\'search\')">查询</button>' },
ElCheckbox: { props: ['modelValue', 'disabled'], emits: ['change'], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'change\', $event.target.checked)">' },
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot /></button>' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<input data-date type="date" v-model="model.plannedDate" :disabled="disabled">' },
LoadErrorState: { props: ['message'], emits: ['retry'], template: '<p role="alert">{{ message }}<button data-retry @click="$emit(\'retry\')">重试</button><slot name="details" /></p>' }, ApiErrorMeta: true,
},
} })
}
async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
await flushPromises()
}
beforeEach(() => {
vi.clearAllMocks(); guards.clear()
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 21, page: 1, size: 20, pages: 2 })
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
vi.mocked(listPlanningDestinations).mockResolvedValue(['ABE4','ONT8'])
})
it('loads searchable destination options lazily and keeps the submitted filter across pages until reset', async () => {
const wrapper = render(); await flushPromises()
const toolbar = wrapper.getComponent({name:'QueryToolbar'})
const destination = () => (toolbar.props('fields') as QueryField[]).find(field=>field.key==='destination')!
expect(destination().props?.filterable).toBe(true)
expect(listPlanningDestinations).not.toHaveBeenCalled()
const open = destination().props!.onVisibleChange as (visible:boolean)=>void
open(true); await flushPromises()
expect(destination().options).toEqual([{label:'ABE4',value:'ABE4'},{label:'ONT8',value:'ONT8'}])
open(true); await flushPromises(); expect(listPlanningDestinations).toHaveBeenCalledTimes(1)
toolbar.vm.$emit('update:modelValue',{keyword:'IN-TEST',destination:'ABE4'})
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:2,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
toolbar.vm.$emit('update:modelValue',{keyword:'',destination:undefined})
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:undefined},expect.any(AbortSignal))
wrapper.unmount()
})
it('reports a destination directory failure and allows retry without hiding loaded cargo', async () => {
vi.mocked(listPlanningDestinations).mockRejectedValueOnce(new Error('offline'))
const wrapper = render(); await flushPromises()
const field = (wrapper.getComponent({name:'QueryToolbar'}).props('fields') as QueryField[]).find(field=>field.key==='destination')!
;(field.props!.onVisibleChange as (visible:boolean)=>void)(true); await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true)
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
expect(listPlanningDestinations).toHaveBeenCalledTimes(2)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
wrapper.unmount()
})
it('does not read or create per-row details until the operator selects cargo and proceeds', async () => {
const wrapper = render(); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
expect(resolvePlanningSource).not.toHaveBeenCalled(); expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
await selectAndPrepare(wrapper)
expect(resolvePlanningSource).toHaveBeenCalledWith({ sourceKey: cargo.sourceKey, customerId: cargo.customerId })
expect(previewPlanningBatch).toHaveBeenCalledWith([{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }])
expect(wrapper.text()).not.toContain(cargo.orderId)
wrapper.unmount(); expect(guards.size).toBe(0)
})
it('submits the real form, shows the saved appointment, and prevents another submit of the same completed form', async () => {
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
await wrapper.get('[data-date]').setValue('2026-10-25')
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null }), idempotencyKey: expect.any(String) }))
expect(wrapper.emitted('created')).toEqual([[batchContext().appointment]])
expect(wrapper.get('[role="status"]').text()).toContain(batchContext().appointment.appointmentNo)
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
expect(wrapper.find('[data-date]').exists()).toBe(false)
expect(wrapper.get('[aria-label="预约保存回执"]').text()).toContain('等待对方确认')
expect(wrapper.get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
wrapper.unmount()
})
it('preserves the original request and blocks returning or closing after an unknown creation result', async () => {
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '返回选择货物')!.trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(false)
expect((await Promise.all([...guards].map(guard => guard()))).includes(false)).toBe(true)
await wrapper.findAll('button').find(button => button.text() === '重试本次创建')!.trigger('click'); await flushPromises()
expect(vi.mocked(createSourceAppointment).mock.calls[1]).toEqual(vi.mocked(createSourceAppointment).mock.calls[0])
wrapper.unmount()
})
it('disables blocked cargo and keeps selected cargo across pages', async () => {
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ records: [cargo, { ...cargo, sourceKey: 'ORDER:blocked', detained: true }], total: 21, page: 1, size: 20, pages: 2 })
const wrapper = render(); await flushPromises()
expect(wrapper.findAll('input[type="checkbox"]')[1]!.attributes('disabled')).toBeDefined()
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
expect(wrapper.findAll('button').some(button => button.text() === '下一步:填写预约(1)')).toBe(true)
wrapper.unmount()
})
it('reports source failures and does not offer a write path without permission', async () => {
const readonly = render(false); await flushPromises()
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount()
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('unavailable'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
wrapper.unmount()
})
it('ignores a source resolution that arrives after the dialog is closed', async () => {
let complete!: (value: Awaited<ReturnType<typeof resolvePlanningSource>>) => void
vi.mocked(resolvePlanningSource).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
wrapper.unmount(); complete({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }); await flushPromises()
expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
})
@@ -0,0 +1,160 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
import type { SourceReference } from './workApi'
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
let destinationsRequest = 0, destinationsController: AbortController | null = null
const fields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
options: destinations.value.map(code => ({ label: code, value: code })),
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
if (visible && !destinationsLoaded.value) void loadDestinations()
} } },
])
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
const selectedRows = computed(() => Object.values(selected.value))
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
const columns: DataTableColumn[] = [
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
{ prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' },
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '箱数', width: 80 },
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
]
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
function reason(row: PlanningCargo) {
if (row.detained) return '货物暂扣中'
if (row.mixedDestinations) return '多个目的地,需先核对'
if (row.hasActiveAppointment) return '已有有效预约'
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
return ''
}
function toggle(row: PlanningCargo, checked: boolean | string | number) {
if (!props.canWrite || resolving.value) return
if (!checked) delete selected.value[key(row)]
else if (!reason(row)) selected.value[key(row)] = row
selectionError.value = null
}
async function load(nextPage = page.value) {
if (!props.canWrite) return
page.value = nextPage
const current = ++generation; controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
if (current === generation) { rows.value = result.records; total.value = result.total }
} catch (cause) {
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
} finally { if (current === generation) loading.value = false }
}
function search() {
keyword.value = String(query.value.keyword || '').trim()
destination.value = String(query.value.destination || '').trim()
void load(1)
}
async function loadDestinations() {
if (!props.canWrite || destinationsLoading.value) return
const current = ++destinationsRequest
destinationsController?.abort(); destinationsController = new AbortController()
destinationsLoading.value = true; destinationError.value = null
try {
const result = await listPlanningDestinations(destinationsController.signal)
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
} catch (cause) {
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
}
async function prepare() {
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
resolving.value = true; selectionError.value = null
try {
const sources: SourceReference[] = []
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
for (const row of snapshot) {
if (current !== selectionGeneration) return
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
if (current !== selectionGeneration) return
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
}
if (current === selectionGeneration) references.value = sources
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
finally { if (current === selectionGeneration) resolving.value = false }
}
async function back() { if (await props.canLeave()) references.value = null }
function saved(appointment: FbaAppointmentDto) {
created.value = appointment; references.value = null; selected.value = {}
emit('created', appointment)
}
async function again() {
if (!await props.canLeave()) return
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
await load()
}
onMounted(() => { void load() })
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
</script>
<template>
<section class="density-dialog-sections" aria-label="新增FBA送仓预约">
<template v-if="created">
<section class="density-dialog-section" data-tone="summary" aria-label="预约保存回执">
<h3 class="density-dialog-section__title" role="status">预约已保存:{{ created.appointmentNo || '预约编号未返回' }}</h3>
<table class="density-table"><tbody>
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
<tr><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
</tbody></table>
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
</section>
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
</template>
<template v-else-if="references">
<div class="density-actions"><el-button @click="back">返回选择货物</el-button></div>
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
</template>
<template v-else>
<section class="density-dialog-section" data-tone="source" aria-label="选择预约货物">
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" max-height="" empty-text="暂无可预约货物" @update:page="load($event)">
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
<template #toolbar-actions><InlineHelp title="选货说明"><p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p></InlineHelp><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
<template #customer="{ row }"><LineClampText :content="row.customerName" /></template>
<template #origin="{ row }"><LineClampText :content="row.originWarehouseName" /></template>
</DataTable>
</section>
</template>
</section>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listExternalAppointmentCandidates, type ExternalAppointmentCandidate, type PlanningSource } from './planningApi'
defineProps<{ canArrange: boolean; selectedKeys: string[]; busy: boolean }>()
const emit = defineEmits<{ select: [source: PlanningSource]; followup: [row: ExternalAppointmentCandidate] }>()
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
const rows = ref<ExternalAppointmentCandidate[]>([]), error = ref<ApiErrorDetails | null>(null)
const query = ref<QueryModel>({ keyword: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '外接货', placeholder: '登记号、车次号、客户、来货号', width: 300 }]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 160, fixed: 'left' },
{ key: 'booking', label: '原登记号', slotName: 'booking', minWidth: 190 },
{ prop: 'customerName', label: '客户', minWidth: 130 },
{ prop: 'tripNo', label: '车次号', minWidth: 190 },
{ key: 'origin', label: '接仓点', slotName: 'origin', minWidth: 140 },
{ key: 'destination', label: '目的FBA仓', slotName: 'destination', width: 110 },
{ key: 'packages', label: '箱数', slotName: 'packages', width: 90 },
{ key: 'coverage', label: '预约情况', slotName: 'coverage', minWidth: 180 },
]
let request = 0
let controller: AbortController | undefined
async function load(reset = false) {
if (reset) page.value = 1
const current = ++request
controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listExternalAppointmentCandidates({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
if (current === request) { rows.value = result.records; total.value = result.total }
} catch (cause) {
if (current === request) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '外接候选加载失败') }
} finally { if (current === request) loading.value = false }
}
onMounted(() => load())
onBeforeUnmount(() => { ++request; controller?.abort() })
</script>
<template>
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" @update:page="load()" @update:page-size="load(true)">
<template #toolbar-actions><slot name="toolbar-actions" /></template>
<template #filters>
<section class="integrated-query-group" aria-label="外接货物类型与筛选">
<slot name="query-tabs" />
<QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" />
</section>
</template>
<template #notices>
<slot name="notices" />
<LoadErrorState v-if="error" title="外接候选加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-else>已确认承运的美国FBA货物,来货前后保留同一原登记;箱数未知可先保存待确认安排。</p>
</template>
<template #actions="{ row }"><template v-if="row.source && row.coverage">
<el-button v-if="canArrange" link type="primary" :disabled="busy || selectedKeys.includes(row.source.sourceKey) || !row.source.canFollowup || row.coverage.appointments.length > 0" @click="emit('select', row.source)">{{ selectedKeys.includes(row.source.sourceKey) ? '已加入' : '加入合批' }}</el-button>
<el-button link type="primary" @click="emit('followup', row as ExternalAppointmentCandidate)">跟进</el-button>
</template></template>
<template #booking="{ row }">{{ row.source?.bookingNo || '—' }}</template>
<template #origin="{ row }">{{ row.source?.originWarehouseName || '—' }}</template>
<template #destination="{ row }">{{ row.source?.destinationWarehouseCode || '—' }}</template>
<template #packages="{ row }">{{ row.source?.packages ?? '待核实' }}</template>
<template #coverage="{ row }"><span v-if="row.source && row.coverage">{{ row.coverage.appointments.length ? (row.coverage.changeReviewRequired ? '已有预约,待复核' : '已有预约') : (row.source.canFollowup ? '待安排' : row.source.blockReasons.join(';')) }}</span></template>
</DataTable>
</template>
@@ -0,0 +1,80 @@
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type PlanningSource, type ContainerFollowupContext } from './planningApi'
vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn(), getContainerFollowups: vi.fn(), saveContainerFollowup: vi.fn() }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
const history = { history: { records: [], page: 1, size: 10, total: 0, pages: 0 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 } }
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', canFollowup: true, blockReasons: [], orderId: '1', inboundCode: 'IN-1', bookingNo: null, containerNo: null, originWarehouseId: 'US-1', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null }
function render(canWrite = true, container?: ContainerFollowupContext) {
return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
AppDialog: { name: 'AppDialog', props: ['confirmDisabled', 'beforeClose'], template: '<div><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<label><slot/></label>' },
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ElSelect: true, ElOption: true, ElDatePicker: true,
DataTable: true, LoadErrorState: true, ApiErrorMeta: true,
} } })
}
describe('appointment followup recovery', () => {
beforeEach(() => {
vi.resetAllMocks(); vi.mocked(getFollowups).mockResolvedValue(history)
vi.mocked(getContainerFollowups).mockResolvedValue(history)
vi.mocked(saveFollowup).mockResolvedValue({ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '已联系', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' })
})
it.each([null, '100'])('saves the correct container stage for warehouse %s without customer-scoped calls', async warehouseId => {
const container = { containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId, warehouseName: warehouseId ? '洛杉矶仓' : null, canFollowup: true, blockReasons: [] }
const wrapper = render(true, container); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('同柜统一联系')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(saveContainerFollowup).toHaveBeenCalledWith(container, expect.objectContaining({ stage: warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT', expectedVersion: 0, result: '同柜统一联系' }))
expect(getContainerFollowups).toHaveBeenCalledTimes(2)
expect(getFollowups).not.toHaveBeenCalled()
expect(saveFollowup).not.toHaveBeenCalled()
wrapper.unmount()
})
it('describes FBA followup independently of batch action visibility', async () => {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('FBA收货跟进')
expect(wrapper.text()).not.toContain('同柜多客户共用提柜')
wrapper.unmount()
})
it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(saveFollowup).toHaveBeenCalledWith('ORDER:1', 'A', expect.objectContaining({ stage: 'FBA_RECEIVING', expectedVersion: 0, result: '已联系,等待回复', followupDate: null }))
const body = vi.mocked(saveFollowup).mock.calls[0]![2]
expect(body).not.toHaveProperty('appointmentNo'); expect(body).not.toHaveProperty('driverId'); expect(body).not.toHaveProperty('operatorId')
expect(getFollowups).toHaveBeenCalledTimes(2)
})
it('retries an unknown outcome with the exact same body and persisted command key', async () => {
vi.mocked(saveFollowup).mockRejectedValueOnce(new Error('network'))
const wrapper = render(); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const first = vi.mocked(saveFollowup).mock.calls[0]![2]
await wrapper.get('[data-close]').trigger('click')
expect(wrapper.emitted('close')).toBeUndefined()
const done = vi.fn()
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
expect(done).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
expect(guard()).toBe(false)
}
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first)
expect(first.idempotencyKey).toBeTruthy()
})
it('does not allow a write without write permission or a successfully loaded version', async () => {
const readonly = render(false); await flushPromises()
expect(readonly.get('[data-save]').attributes('disabled')).toBeDefined()
vi.mocked(getFollowups).mockRejectedValueOnce(new Error('unavailable'))
const failed = render(); await flushPromises()
expect(failed.get('[data-save]').attributes('disabled')).toBeDefined()
expect(saveFollowup).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { inject, computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type ContainerFollowupContext, type PlanningSource, type AppointmentCoverage, type FollowupRecord, type FollowupStage, type SaveFollowup } from './planningApi'
const props = defineProps<({ source: PlanningSource; coverage: AppointmentCoverage; container?: never } | { container: ContainerFollowupContext; source?: never; coverage?: never }) & { canWrite: boolean; canArrange?: boolean }>()
const emit = defineEmits<{ close: []; arrange: [] }>()
const stages: Record<FollowupStage, string> = { DRAYAGE_CONTACT: '拖车联系', TERMINAL_APPOINTMENT: '码头约号', WAREHOUSE_RECEIVING: '接仓跟进', FBA_RECEIVING: 'FBA收货跟进' }
const coverageNeeds: Record<string, string> = {
DESTINATION: '目的仓与原安排不一致,请核对。',
DESTINATION_UNVERIFIED: '来源目的地资料待核实,尚不能判断是否发生变更。',
CARRIER_CONFIRMATION: '尚未确认承运,请先在原登记完成确认。',
REAL_CARGO_MATERIALS: '尚未关联实际货物资料,可先保存待确认安排;正式确认前需补齐资料。',
CARGO_QUANTITY_REVIEW: '货量或重复关联需要核对。',
RECEIVING_APPOINTMENT: '尚未安排收货预约。',
UNCOVERED_CARGO: '部分货物尚未纳入收货预约。',
RECEIVING_CONFIRMATION: '尚未取得全部货物的收货确认。',
APPOINTMENT_TIMEZONE_UNVERIFIED: '原预约未记录时区,请核实收货地点当地时间。',
FBA_SHIPMENT_REFERENCES: 'FBA货件资料尚未齐全,请补充货件号或追踪号。',
SOURCE_CHANGED_REVIEW: '货源资料更新晚于预约,请核对是否需要改约。',
}
const allowedStages = computed(() => props.container ? props.container.warehouseId ? { WAREHOUSE_RECEIVING: stages.WAREHOUSE_RECEIVING } : { DRAYAGE_CONTACT: stages.DRAYAGE_CONTACT, TERMINAL_APPOINTMENT: stages.TERMINAL_APPOINTMENT } : { FBA_RECEIVING: stages.FBA_RECEIVING })
const canFollowup = computed(() => props.container?.canFollowup ?? props.source?.canFollowup ?? false)
const blockReasons = computed(() => props.container?.blockReasons ?? props.source?.blockReasons ?? [])
const title = computed(() => props.container ? `${props.container.containerNo || '未回传柜号'} · ${props.container.warehouseName || '提柜'}` : props.source?.inboundCode || props.source?.bookingNo || '未关联入仓码')
const form = reactive({ stage: (props.container ? props.container.warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT' : 'FBA_RECEIVING') as FollowupStage, contactedParty: '', result: '', nextAction: '', followupDate: '' })
const rows = ref<FollowupRecord[]>([])
const versions = ref<Record<FollowupStage, number>>({ DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 })
const page = ref(1), size = ref(10), total = ref(0)
const loading = ref(false), saving = ref(false), ready = ref(false)
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const pending = ref<SaveFollowup | null>(null)
let generation = 0
const columns: DataTableColumn[] = [
{ key: 'stage', label: '事项', slotName: 'stage', width: 120 },
{ prop: 'contactedParty', label: '联系对象', minWidth: 130 },
{ prop: 'result', label: '联系结果', minWidth: 200 },
{ prop: 'nextAction', label: '下一步', minWidth: 150 },
{ prop: 'followupDate', label: '下次跟进日', width: 120 },
{ prop: 'operatorName', label: '跟进人', minWidth: 110 },
]
function rowKey(row: object) { const value = row as FollowupRecord; return `${value.stage}:${value.version}` }
async function load() {
const request = ++generation
loading.value = true; error.value = null; ready.value = false
try {
const result = props.container ? await getContainerFollowups(props.container, page.value, size.value) : await getFollowups(props.source!.sourceKey, props.source!.customerId, page.value, size.value)
if (request !== generation) return
rows.value = result.history.records; total.value = result.history.total; versions.value = result.versions; ready.value = true
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '跟进记录加载失败') }
finally { if (request === generation) loading.value = false }
}
async function save() {
if (!props.canWrite || !canFollowup.value || saving.value || !ready.value) return
if (!pending.value && (!form.result.trim() || (form.followupDate && !form.nextAction.trim()))) {
ElMessage.warning('请填写联系结果;设置下次跟进日时需填写下一步安排'); return
}
pending.value ||= { stage: form.stage, expectedVersion: versions.value[form.stage], idempotencyKey: crypto.randomUUID(),
contactedParty: form.contactedParty.trim() || undefined, result: form.result.trim(), nextAction: form.nextAction.trim() || undefined, followupDate: form.followupDate || null }
saving.value = true; saveError.value = null
try {
if (props.container) await saveContainerFollowup(props.container, pending.value)
else await saveFollowup(props.source!.sourceKey, props.source!.customerId, pending.value)
pending.value = null; form.result = ''; form.nextAction = ''; form.followupDate = ''; page.value = 1
ElMessage.success('跟进记录已保存'); await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() }
} finally { saving.value = false }
}
function canLeave() {
if (saving.value || pending.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
return true
}
function close() { if (canLeave()) emit('close') }
function arrange() { if (canLeave()) emit('arrange') }
function beforeClose(done: () => void) { if (canLeave()) done() }
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
onBeforeUnmount(() => unregisterLeaveGuard?.())
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
onMounted(load)
onBeforeUnmount(() => { ++generation })
</script>
<template>
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${title}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !canFollowup || !ready || loading" @confirm="save" @update:model-value="close" :before-close="beforeClose">
<p v-if="source">{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
<el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving || !!pending" @click="arrange">加入预约合批</el-button>
<p v-if="container">同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p>
<p v-else>本条记录用于此货源的FBA收货跟进;保存联系不自动确认预约或推进收货。</p>
<LoadErrorState v-if="error" title="跟进记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="saveError" title="跟进保存未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-for="reason in blockReasons" :key="reason">{{ reason }}</p>
<p v-if="coverage?.changeReviewRequired">来源资料有变化,请核对原预约与运输安排。</p>
<p v-if="coverage?.appointments.length">已关联预约:{{ coverage.appointments.map(item => item.appointmentNo).join('、') }}</p>
<p v-else-if="coverage">尚未关联收货预约;联系记录保存后仍需登记对方正式确认。</p>
<ul v-if="coverage?.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
<el-form v-if="canWrite && canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in allowedStages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" /></el-form-item>
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
</el-form>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
</DataTable>
</AppDialog>
</template>
<style scoped>
.followup-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-6); }
.wide { grid-column: 1 / -1; }
.followup-form :deep(.el-select), .followup-form :deep(.el-date-editor) { width: 100%; }
</style>

Some files were not shown because too many files have changed in this diff Show More