diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b59e8c23 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.playwright-cli +dist +node_modules +node_modules.broken-by-path +.env.local +npm-debug.log* +pnpm-debug.log* diff --git a/.env.example b/.env.example index a39d43e3..ffaf65f9 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,13 @@ VITE_API_BASE_URL=/api +# 按本机联调环境填写国内 Gateway origin,不提交真实环境地址。 API_PROXY_TARGET=http://localhost:9000 +# 可选:本地分别直连 Auth、IAM、Warehouse;未设置时复用 API_PROXY_TARGET。 +# AUTH_API_PROXY_TARGET=http://localhost:9001 +# IAM_API_PROXY_TARGET=http://localhost:9002 +# WAREHOUSE_API_PROXY_TARGET=http://localhost:9003 +# 海外业务浏览器请求使用独立代理,代理会将 /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 diff --git a/.gitignore b/.gitignore index e2bc4c31..5bcdd98b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,13 +7,16 @@ dist/ !.env.example .playwright-cli/ +.idea/ .impeccable/ .codex-temp/ .codex-tmp/ .logs/ outputs/ +reports/ +components.d.ts +auto-imports.d.ts *.log .DS_Store Thumbs.db - diff --git a/AGENTS.md b/AGENTS.md index baddfbda..47159940 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,50 +1,115 @@ # Admin Web Instructions -## 项目定位 +## 快速读取顺序 -- 本项目是虫虫出海内部运营与管理后台,仅服务平台员工。 -- 当前范围包括工作台、客户管理、头程订单、内部用户、角色管理和菜单权限。 -- 不在本项目承载企业客户自助流程、品牌官网、微信小程序、独立 APP 或仓库 PDA 作业。 +1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。 +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`。 -## 技术基线 +## 项目定位与技术基线 -- 使用 Vue 3、TypeScript、Vite、Vue Router、Pinia、Element Plus、Axios 和 Lucide Vue。 -- TypeScript 保持严格模式;不要使用 `any` 绕过业务模型,优先从 `@chongchong/shared` 复用跨项目类型。 -- 使用现有 `@` 别名引用 `src/`。保持 ES Module 和现有无分号代码风格。 -- Element Plus 组件使用现有自动导入配置;`ref`、`computed` 等 Vue API 必须显式导入。`src/auto-imports.d.ts` 与 `src/components.d.ts` 属于生成声明,不手工维护业务内容。 +- 本项目是虫虫出海内部运营后台,仅服务平台员工;不承载企业客户自助、官网、小程序、独立 APP 或 PDA 作业。 +- 使用 Vue 3、TypeScript 严格模式、Vite、Vue Router、Pinia、Element Plus、Axios 和 Lucide Vue;保持 ES Module、现有无分号风格和 `@` 对 `src/` 的别名。 +- 不用 `any` 绕过业务模型。模块专属类型留在模块,本应用多模块稳定复用的类型放 `src/shared/`;服务端契约以 Controller/OpenAPI 为准。 +- Element Plus 使用现有自动导入;Vue API 显式导入。`src/auto-imports.d.ts`、`src/components.d.ts` 是生成声明,不手工维护业务内容。 - 不更换框架、路由、状态管理、组件库或构建工具,除非任务明确要求并完成影响评估。 -## 目录与职责 +## 目录与依赖方向 -- `src/views/`:路由级页面和业务编排;复杂、可复用的展示或交互下沉到组件。 -- `src/components/`:无路由依赖的复用组件,输入输出应有明确类型。 -- `src/layouts/`:应用壳层、导航和全局页面结构。 -- `src/router/`:路由、标题、登录保护和权限元数据;新增模块时同步维护导航与路由配置。 -- `src/stores/`:跨页面状态和业务动作;仅页面局部使用的状态留在组件内。 -- `src/api/`:HTTP 客户端和接口适配;页面与组件不得散落创建 Axios 实例。 -- `VITE_API_BASE_URL` 控制浏览器请求前缀,`API_PROXY_TARGET` 控制 Vite 开发代理上游;后者只由开发服务器读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。 -- `src/types/`:后台专属类型;跨应用共享的稳定契约放到 `packages/shared`。 -- `src/styles/`:设计令牌和全局样式;优先复用现有 `--cc-*` 变量和组件样式。 +| 目录 | 职责 | +|---|---| +| `src/app/` | 应用装配、布局、根路由、全局守卫和启动;不承载业务 | +| `src/core/` | 认证、访问控制、导航、路由安全等全应用能力;不得依赖业务模块 | +| `src/modules/` | 稳定业务领域的页面、API、类型、组件和模块路由;新增业务页面不放根级 `views/` | +| `src/shared/` | 无业务归属的 HTTP、组件和工具;只有真实跨模块复用才上移 | +| `src/prototype/` | 未纳入当前迭代的原型实现;正式模块不得依赖 | +| `src/styles/` | 设计令牌和全局样式,优先复用 `--cc-*` 变量 | -## 业务与安全约束 +- 依赖方向为 `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`,不提交真实地址或凭据。 -- 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。 -- 认证统一经过 `src/stores/auth.ts` 和 `src/api/http.ts`;不要在页面中复制令牌读写或手工拼装 `Authorization`。 -- 当前登录和部分数据仍可能是模拟实现。接入 `services/api` 时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。 -- 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。 -- 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。 +## 模块快速路由 -## UI 与交互 +表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。 -- 延续现有后台视觉体系:克制的企业级界面、清晰的信息层级和高密度但可扫描的数据展示。 -- 优先使用 Element Plus 与现有复用组件;图标使用 Lucide,不用表情符号充当功能图标。 -- 表格、筛选、抽屉和表单应按实际语义覆盖适用的加载、空数据、错误、禁用和无权限状态,不为不适用的组件机械增加状态。 -- 保持键盘可操作、可见焦点、表单标签和足够的颜色对比度;不要只用颜色表达状态。 -- 新页面至少适配现有 320px 最小宽度策略,并检查侧栏、表格、抽屉和表单在窄屏下的退化方式。 +| 业务 | Admin 入口 | 联查 | +|---|---|---| +| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | +| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | +| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | +| 客户、企业审核、客户统计 | `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;图片读取归 `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` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | +| 合柜报关 / 同柜不可拆组 | `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-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` | +| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 | +| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 | -## 本地命令与质量门禁 +全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外仓业务开发先读 `../../docs/海外仓业务/README.md`。进度、排期或验收状态问题再读取进度表和报告。 -在 `apps/admin-web` 目录执行: +散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。 + +## 海外模块内部快速索引(海外仓全流程) + +- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `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/` | + +## 业务、安全与共享装配 + +- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。 +- 新建客户时,公司联系电话同时初始化首个管理员登录手机号;创建后公司联系电话只属于企业资料,与首个管理员、成员及平台用户登录手机号独立。编辑客户资料不得提交账号手机号变更,账号手机号改动统一走用户管理并由 IAM/Auth 撤销对应会话。 +- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。 +- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。 +- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。 +- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。 +- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。 +- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 +- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。 +- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。 +- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。 +- 页面和交互必须服从对应原型及验收规则;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。 + +## 开发验收身份 + +- 开发环境人工验收身份为 `platform_admin`。密码从未提交的 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取;缺失时再向用户索取,不写入仓库、日志或快照。 +- 由人工操作真实页面并截图,AI 核对加载、空态、错误态、权限和桌面布局;缺少截图时标记“待人工截图确认”,不自行批量取证。 + +## 命令与质量门禁 + +- 国内仓、客户管理密度默认值统一维护在 `src/styles/adminDensityTokens.css`,通用按钮/页签/描述表/弹框规则统一维护在 `adminDensity.css`;页面只选择公共类与语义参数、保留业务布局,不复制通用外观或在页面重新定义 `--admin-*` 默认值。修改前沿实际引用读取模块 CSS,接管后删除冲突旧声明。 +- 修改密度样式或接入消费者时更新并运行 `src/styles/adminDensity.test.ts` 维护合同;不得为通过检查删除权限、业务提交或错误处理断言。未接入菜单通过显式密度类隔离。 + +在 `apps/admin-web` 执行: ```bash pnpm install @@ -54,6 +119,7 @@ pnpm typecheck pnpm build ``` -- 日常变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`,后者也会执行类型检查。 -- 当前项目未声明 lint 脚本,不要声称 lint 已通过。若任务新增或变更质量脚本,应同时更新 `package.json`、`README.md` 和本文件。 -- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目的 `package.json` 与锁文件。 +- 普通变更至少运行 `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 验收后仍由人工完成页面和截图确认。 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..8fb8fa52 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,261 @@ +--- +name: "虫虫出海 Admin" +description: "克制、清晰且高密度可扫描的企业级运营工作台" +colors: + primary: "#667eea" + primary-hover: "#5366c8" + primary-soft: "#eaf1ff" + work-canvas: "#f4f6f8" + surface: "#ffffff" + surface-muted: "#f8fafc" + text: "#172033" + text-secondary: "#475467" + muted: "#667085" + muted-light: "#98a2b3" + border: "#e3e8ef" + border-subtle: "#edf0f4" + success: "#067647" + success-soft: "#ecfdf3" + warning: "#b54708" + warning-soft: "#fffaeb" + danger: "#b42318" + danger-soft: "#fef3f2" + info: "#3538cd" + info-soft: "#eff4ff" +typography: + headline: + fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif' + fontSize: "20px" + fontWeight: 700 + lineHeight: 1.35 + letterSpacing: "0" + title: + fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif' + fontSize: "16px" + fontWeight: 700 + lineHeight: 1.4 + letterSpacing: "0" + body: + fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif' + fontSize: "13px" + fontWeight: 400 + lineHeight: 1.5 + letterSpacing: "0" + label: + fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif' + fontSize: "12px" + fontWeight: 650 + lineHeight: 1.5 + letterSpacing: "0" + supporting: + fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif' + fontSize: "11px" + fontWeight: 400 + lineHeight: 1.5 + letterSpacing: "0" +rounded: + xs: "3px" + sm: "4px" + md: "6px" + panel: "8px" + nav: "10px" + accent: "12px" + pill: "999px" + circle: "50%" +spacing: + xxs: "4px" + xs: "8px" + sm: "10px" + md: "12px" + lg: "16px" + xl: "20px" + xxl: "24px" + xxxl: "32px" +components: + dashboard-tab-selected: + backgroundColor: "{colors.primary}" + textColor: "{colors.surface}" + typography: "{typography.label}" + rounded: "{rounded.panel}" + padding: "0 12px" + height: "36px" + dashboard-button-outline: + backgroundColor: "{colors.surface}" + textColor: "{colors.text-secondary}" + typography: "{typography.label}" + rounded: "{rounded.panel}" + padding: "0 13px" + height: "36px" + surface-panel: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.panel}" + padding: "20px" + status-badge: + backgroundColor: "{colors.success-soft}" + textColor: "{colors.success}" + typography: "{typography.supporting}" + rounded: "{rounded.pill}" + padding: "2px 8px" + height: "24px" +--- + +# Design System: 虫虫出海 Admin + +## Overview + +**Creative North Star: "冷静的运营工作面"** + +虫虫出海 Admin 是面向平台员工的高密度运营工作台。界面以冷灰工作面承接连续任务,以白色边框面板划分可操作区域,以柏悦蓝标记主操作、焦点和当前选中项;视觉表达克制,优先让字段、状态和操作顺序一眼可扫。 + +系统不依赖装饰性大色块制造层级。面板边界、浅层级色、紧凑字号和稳定间距共同组织信息;状态色只承担分类、进度、告警和错误反馈,不替代文字标签。壳层可以建立明确品牌存在感,业务内容区保持安静、可复用、可扩展。 + +**Key Characteristics:** + +- 冷灰画布与白色面板构成稳定工作面。 +- 柏悦蓝只承载主操作、焦点和选中态。 +- 12–13px 信息层与 16–20px 标题层形成紧凑层级。 +- 边框和浅色填充负责大多数深度,阴影保持稀缺。 +- 桌面端追求密度,窄屏端保证 44px 级触控与可横向退化的数据表格。 + +## Colors + +主色清晰但不过量;中性冷灰负责内容秩序,语义色只在需要判断状态时出现。 + +### Primary + +- **柏悦蓝**:用于主操作、当前导航、页内选中态、关键链接和可见焦点。 +- **柏悦蓝悬停色**:用于白底描边操作的悬停文字与边框反馈。 +- **柏悦蓝浅底**:用于图标容器、头像底和弱强调背景,不作为大面积业务面板底色。 + +### Neutral + +- **冷灰工作面**:应用画布与全屏看板背景,使白色业务面板保持清晰边界。 +- **白色业务面**:卡片、表格、筛选栏、抽屉和控件的默认承载面。 +- **浅层级灰**:表头、说明块和次级区域背景。 +- **墨色正文**:标题、主值和核心业务文本。 +- **次级墨灰**:表头、次级数据和操作文案。 +- **说明灰**:帮助文本、辅助标签、时间与空值提示。 +- **冷灰边线**:面板、分区和表格结构的主要深度手段。 + +### Secondary + +- **成功绿 / 浅成功绿**:已完成、启用和正向状态。 +- **告警橙 / 浅告警橙**:待处理、临近期限和需关注状态。 +- **危险红 / 浅危险红**:错误、失败、停用和高风险状态。 +- **信息靛蓝 / 浅信息靛蓝**:信息型状态和非主操作分类。 + +### Named Rules + +**The One Primary Rule.** 柏悦蓝是唯一的全局交互主色;图表分类色和语义色不得升级为第二主色。 + +**The Status Has Words Rule.** 状态色必须与状态文字、图标或结构共同出现,绝不只靠颜色传达业务含义。 + +## Typography + +**Display Font:** PingFang SC(回退至 Microsoft YaHei、Segoe UI、sans-serif) +**Body Font:** PingFang SC(回退至 Microsoft YaHei、Segoe UI、sans-serif) +**Label/Mono Font:** 业务标签沿用正文栈;权限码与接口值使用 Cascadia Code(回退至 Consolas、monospace) + +**Character:** 统一的系统无衬线字栈保证中文字段紧凑清晰,不使用展示型字体制造品牌感。层级主要来自字号、字重和颜色,而不是大幅字距或全大写。 + +### Hierarchy + +- **Headline**(700,20px,1.35):页面级标题、详情页标题和空状态主标题。 +- **Title**(700,16px,1.4):面板标题、表格区标题和主要分区标题。 +- **Body**(400,13px,1.5):业务内容、表单值和操作说明。 +- **Label**(650,12px,1.5):表头、筛选标签、按钮和强调型元数据。 +- **Supporting**(400,11px,1.5):帮助文本、时间、附注、次级值和状态胶囊。 + +### Named Rules + +**The Scan Before Drama Rule.** 业务页标题不以超大字号争夺空间;20px 页面标题、16px 分区标题与 12–13px 内容层已经构成完整层级。 + +## Layout + +应用壳层在桌面端使用固定 240px 侧栏、64px 顶部栏和 20px 内容边距;折叠侧栏为 76px。业务页面以 16px 垂直节奏堆叠,数据看板使用 14–18px 卡片间距,面板常用 16–24px 内边距。筛选栏、指标条、表格面板和抽屉分区通过一致边框对齐,避免每个模块自行发明容器结构。 + +960px 以下优先减少多列网格;720px 以下侧栏变为遮罩抽屉,页面边距收至 14–18px,筛选控件纵向铺满,抽屉占满宽度,操作按钮保证可触控高度;420px 以下指标条转为单列或受控双列。表格保留语义列和首列操作,通过水平滚动退化,不把关键操作固定到最右侧。最低 320px 宽度下仍保持内容可访问。 + +**The Operation First Rule.** 业务数据表格的“操作”列始终是第一列;布局收窄时保留该顺序。 + +## Elevation & Depth + +系统采用“平面为主、壳层有深度”的混合策略。业务面板默认依靠冷灰画布、白色表面、1px 边框和浅层级填充建立关系;常驻阴影集中在固定侧栏、粘性顶部栏、头像与粘性操作区,卡片阴影只在明确悬停状态出现。 + +### Shadow Vocabulary + +- **侧栏壳层**(`4px 0 32px rgba(0,0,0,.08)`):固定侧栏与内容工作面的分界。 +- **顶部栏壳层**(`0 8px 32px rgba(0,0,0,.15), 0 2px 8px rgba(0,0,0,.1)`):粘性顶部栏的结构性悬浮。 +- **头像状态**(`0 4px 12px rgba(0,0,0,.15)`):顶部栏内小面积实体的清晰分离。 +- **卡片悬停**(`0 6px 16px rgba(23,42,70,.08)`):仅用于可点击卡片的悬停反馈。 +- **粘性底栏**(`0 -8px 22px rgba(15,23,42,.05)`):需要在长表单中保持可见的操作区。 + +### Named Rules + +**The Flat Business Surface Rule.** 普通卡片、表格和筛选面板静止时不加阴影;边框与层级色已经足够。 + +## Shapes + +业务面板和主要交互以轻度圆角为主:8px 是卡片、面板、页内按钮和多数复合控件的主轮廓;6px 用于说明块和紧凑内部容器,10–12px 只用于导航或更具身份感的壳层元素。状态胶囊使用完全圆角,头像、数据图例点和进度点使用圆形。结构边线保持 1px,不使用厚描边装饰内容区。 + +## Components + +### Buttons + +- **Shape:** 页内按钮和分段选择使用轻度圆角(8px);Element Plus 通用按钮保留其既有组件轮廓。 +- **Primary:** 柏悦蓝底配白字,用于查询、提交和当前选中项;同一区域避免并列多个主按钮。 +- **Hover / Focus:** 描边按钮悬停时转为柏悦蓝系边框与文字;全局键盘焦点使用 3px 半透明蓝色轮廓并保留外偏移。 +- **Secondary / Ghost:** 白底冷灰描边承载重置、返回、全屏和低优先级操作;禁用态使用浅灰底、浅灰字并移除可点击光标。 + +### Chips + +- **Style:** 状态胶囊最小高度 24px,左右 8px 内边距,11px 半粗文字;默认使用中性灰,成功、告警、危险与信息状态使用各自的浅底深字组合。 +- **State:** 胶囊用于呈现状态,不替代按钮;可筛选标签的选中态仍回到柏悦蓝交互语义。 + +### Cards / Containers + +- **Corner Style:** 白色业务面板采用 8px 圆角。 +- **Background:** 业务内容用白色,表头和内部次级区域用浅层级灰。 +- **Shadow Strategy:** 默认无阴影,遵守 Elevation & Depth 中的平面业务面规则。 +- **Border:** 1px 冷灰边线;表格内部使用更浅的分隔线。 +- **Internal Padding:** 常规面板 20px,紧凑工具栏 14–16px,抽屉分区 22–24px。 + +### Inputs / Fields + +- **Style:** 复用 Element Plus 输入、选择和日期控件;筛选字段桌面端常用 260–360px,白底、细边框、紧凑高度。 +- **Focus:** 使用柏悦蓝焦点边框和全局可见焦点轮廓,不以阴影替代键盘焦点。 +- **Error / Disabled:** 错误保留具体文案与请求元信息;禁用态降低对比并保持不可操作语义。 + +### Navigation + +- **Style:** 桌面侧栏 240px,折叠为 76px;一级项高 46px,子项高 42px。默认文字为中性灰,悬停以浅蓝渐变和轻微横移反馈,当前项使用柏悦蓝文字、加粗字重与左侧内嵌标记。720px 以下转换为遮罩式侧栏,不压缩正文到不可读宽度。 + +### Data Tables + +- **Style:** 表头高 44px,12px 半粗文字与浅灰底;单元格垂直内边距 12px,主值 13px、次值 11px。 +- **Structure:** 操作列在第一列;表格面板裁切外轮廓,表尾用顶边线连接分页和结果统计。 +- **Responsive:** 窄屏保留水平滚动与 44px 操作触控高度,不隐藏关键字段而制造上下文丢失。 + +### Dashboard Panels + +- **Style:** 指标卡和图表面板沿用 8px 白色边框面板。图表分类色只在图形、图例点和数据标记中出现;面板标题、轴标签和说明仍使用全局中性色层级。 +- **State:** 全屏模式回到冷灰工作面;数据未接入时必须明确标注为静态演示,不把占位数据伪装为实时数据。 + +## Do's and Don'ts + +### Do: + +- **Do** 优先复用 `--cc-primary`、`--cc-border`、`--cc-muted` 和既有白色面板结构。 +- **Do** 用 1px 边线、浅层级灰和稳定间距组织密集数据。 +- **Do** 为加载、空数据、错误、禁用和无权限状态保留清晰文字与可恢复操作。 +- **Do** 在 720px 以下检查侧栏、筛选栏、表格、抽屉与按钮的触控和滚动退化。 +- **Do** 使用 Lucide 图标,并为纯图形操作提供文本或无障碍名称。 + +### Don't: + +- **Don't** 在普通业务面板上批量添加阴影、玻璃效果或装饰渐变。 +- **Don't** 把图表分类色或状态色用于主操作、导航选中态或大面积背景。 +- **Don't** 用表情符号充当功能图标,也不要只靠颜色表达状态。 +- **Don't** 用超大标题、宽字距或低密度留白牺牲运营页面的扫描效率。 +- **Don't** 把核心操作放到表格末列或右侧固定列。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8c6e73e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# 构建 Vue 静态文件。CI 通过构建参数切换到内网基础镜像, +# 本地开发仍可使用 Docker Hub 默认值。 +ARG NODE_IMAGE=node:22-alpine +ARG NGINX_IMAGE=nginx:1.27-alpine +FROM ${NODE_IMAGE} AS build + +WORKDIR /app + +# Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。 +ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com +ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com +RUN corepack enable + +COPY package.json pnpm-lock.yaml ./ +COPY shared/package.json ./shared/package.json +# package.json 和 lock 文件未变化时,这一层会由 Docker 自动复用, +# 因此不会重复下载 pnpm 依赖。 +RUN pnpm config set registry https://registry.npmmirror.com && \ + pnpm install --frozen-lockfile + +COPY . . +ARG VITE_CLIENT_WEB_URL +ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL} +# Docker 构建时同机还会运行多项服务,限制 Vitest 并发以避免测试超时。 +# pnpm 会直接把脚本名后的参数传给 Vitest;额外的 `--` 会让 Vitest +# 把 maxWorkers 当作位置参数,导致并发限制不生效。 +RUN pnpm build + +# 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。 +FROM ${NGINX_IMAGE} + +COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template +COPY docker/runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh +RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 00000000..0dec6ca3 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,176 @@ +pipeline { + agent { + label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (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' + PRODUCTION_IMAGE_REPOSITORY = 'git.buggoglobal.com/html/admin-web' + } + + options { + timestamps() + disableConcurrentBuilds() + } + + stages { + stage('确认提交版本') { + steps { + script { + env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false' + env.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false' + if (env.IS_PRODUCTION == 'true') { + sh ''' + set -eu + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { + echo "生产部署只允许 origin/main 当前提交;请先合并代码到 main" >&2 + exit 1 + } + ''' + } + env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() + } + sh 'git log -1 --oneline' + } + } + + stage('构建并部署测试前端') { + when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != '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" + 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_PRODUCTION == '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 git.buggoglobal.com >/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 git.buggoglobal.com + image="$PRODUCTION_IMAGE_REPOSITORY:$SOURCE_TAG" + docker build \ + --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ + --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ + --build-arg VITE_CLIENT_WEB_URL=https://app.buggoglobal.com/ \ + -t "$image" . + docker push "$image" + ''' + } + } + } + + stage('确认部署生产前端') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + input message: "确认将 main 提交 ${env.SOURCE_TAG} 部署到 admin.buggoglobal.com?", ok: '确认部署' + } + } + + stage('部署生产前端') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + lock(resource: 'firstleg-production-admin-web-deploy', inversePrecedence: true) { + withCredentials([ + 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') + ]) { + withEnv([ + 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/frontend', + 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST + ]) { + sh "sh scripts/deploy-production.sh admin-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml" + } + } + } + } + } + + 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="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" \ + -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' && env.IS_PRODUCTION != 'true' } } + steps { + sh ''' + set -eu + docker compose -f docker-compose.test.yml ps + docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz + ''' + } + } + } + + post { + failure { + sh ''' + if [ "${IS_PRODUCTION:-false}" = "true" ]; then + echo "生产部署失败,请查看上方远程部署日志" + elif [ "${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 + ''' + } + } +} diff --git a/README.md b/README.md index be666787..ab44a2fa 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,21 @@ pnpm build pnpm test ``` +## 源码结构 + +- `src/app/`:应用壳层、根路由和启动装配。 +- `src/core/`:认证、权限、导航、未保存工作保护等全应用基础能力。 +- `src/modules/`:按业务领域组织页面、接口、组件、类型和模块路由。 +- `src/shared/`:不依赖具体业务的 HTTP、基础组件和工具。 +- `src/prototype/`:尚未进入当前迭代的原型页面与路由,和正式业务代码隔离。 + +新增业务页面时,在对应 `src/modules//` 中就近放置页面、测试和领域能力,并从模块 `routes.ts` 导出路由片段;根路由只负责装配,菜单仍由 `src/core/navigation/adminNavigation.ts` 集中维护。跨模块调用必须经过模块根入口公开的 API,不能引用对方内部目录。 + 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 -工作台已从一期路由与菜单移除;登录后按当前角色进入第一个有权限的业务页面。一期 Admin 路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限。 +首页数据看板已作为无权限门槛的默认入口恢复,当前按照后台原型展示明确标注的静态演示数据,不请求业务接口;后续接入真实统计契约时应整体替换演示数据。历史通用占位页仍保留在 `src/prototype/`,但看板本身已迁入正式业务模块。订单与客户 Admin 其他正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。 -后台菜单按“业务管理”和“系统管理”分组。企业审核、平台角色、菜单及按钮权限资源已接入真实 IAM 接口;客户档案、基础资料和部分系统管理页面仍使用 Pinia 模拟数据,不请求尚未确定的 Java 业务接口。 +后台一级菜单及原型内子菜单严格按最新版原型排序;原型未定义位置的现有功能统一归入“通用”。未接入的原型菜单保留为隐藏节点,待对应业务契约和权限码接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;内部用户管理只维护平台员工账号,企业租户成员由商户成员边界管理。运行时不再提供本地演示业务数据或静态角色预览。 ## 本地 Gateway 联调 @@ -35,26 +45,24 @@ pnpm test ```dotenv VITE_API_BASE_URL=/api -API_PROXY_TARGET=http://192.168.110.149:9000 +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 ``` -认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。平台角色调用 `/roles`,权限资源调用 `/permissions`;接口未提供的编辑、删除、启停和已有授权回显不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 +国内业务继续使用 `http` 和 `/api` 代理;海外业务统一使用 `overseasHttp` 和 `/overseas-api` 代理。Vite 会把 `/overseas-api/platform/...` 重写为海外 Gateway 的 `/api/platform/...`。海外 Token 的签发、缓存和会话刷新由统一认证层维护,业务模块不得读取 Token、手拼 `Authorization` 或复用国内 `http`。 -仓库管理当前维护义乌、深圳、青岛、上海、宁波、厦门等平台国内收货仓,支持筛选、新增、编辑和启停。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业,快照仍为 Pinia 模拟数据,服务端持久化待接入。 +切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。 -快递公司和往来单位一期静态维护已完成:支持筛选、详情、新增、编辑、启停、唯一校验、标准模板下载和导入文件基础检查;往来单位支持六类多选角色、联系人和完整地址。两类主数据只导入不导出,导入只新增,任一行错误整批失败。工作表内容、模板版本、重复数据和整批事务必须由后端解析校验;接口提供前,前端不模拟校验或导入成功。头程订单可维护一组快递公司和快递单号;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。上述业务数据仍使用 Pinia,等待后端新架构明确契约后接入。 +客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时为 Admin 容器设置当前环境的 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。开发、测试和生产分别在各自部署任务中注入自己的 Client 根地址,禁止复用其他环境的域名。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。部署后可读取 `/runtime-config.js` 核对最终生效值。 + +认证请求使用 `/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 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。 + +头程订单列表、详情、状态历史、内部备注和快递信息已经接入真实平台 Order API;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。快递公司已经接入 Warehouse Service 的真实分页查询、详情、新增、编辑、启停、标准模板下载和 XLSX 整批导入接口,按租户与平台权限访问;订单履约接口当前仍按公司名称和快递单号保存。平台往来单位已经接入 Warehouse Service 的真实分页、新增、编辑和启停接口,当前后端类型契约为客户、供应商及客户/供应商双重类型;批量导入接口尚未提供,不模拟导入成功。内部用户管理仅维护平台员工账号,已接入服务端分页查询、关键词与状态筛选、新增、编辑、平台角色调整、启停、最后登录展示和密码重置接口;账号凭据仍由 Auth Service 独占保存。 真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。 -平台联调账号和密码只在 Vite 开发模式下预填,联调提示也只在开发模式显示。生产登录表单默认留空;生产构建产物不得包含本地联调账号、密码或开发提示文案。 - -## 静态权限预览 - -管理后台支持在顶栏切换本地角色,预览该角色可见的菜单、可访问页面和可用操作。预览角色保存在浏览器本地,刷新后继续保留;退出登录时清除,下次登录重新使用默认角色。 - -该功能仅用于静态页面和交互验收: - -- 预览角色和演示业务数据来自本地 Pinia;真实登录会切换到服务端权限,并让已接入页面调用 Java 业务 API。 -- 当前不模拟角色的 `dataScope` 数据范围。 -- 菜单隐藏、页面拦截和操作禁用不能替代服务端鉴权。 -- 后续接入 Java 服务时,接口仍须独立校验登录身份、角色权限和数据范围。 +登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。 diff --git a/docker-compose.development.yml b/docker-compose.development.yml new file mode 100644 index 00000000..a677b495 --- /dev/null +++ b/docker-compose.development.yml @@ -0,0 +1,22 @@ +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: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" + 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 diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 00000000..580f3a59 --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,20 @@ +name: firstleg-prod-admin-web + +services: + admin-web: + image: ${IMAGE_REPOSITORY:?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:-9001}:80" + environment: + CLIENT_WEB_URL: "https://app.buggoglobal.com/" + API_UPSTREAM: "http://gateway:9000" + OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300" + networks: + - infra-network + +networks: + infra-network: + external: true + name: firstleg-prod-infra-network diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 00000000..f522117c --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,19 @@ +services: + admin-web: + build: + context: . + dockerfile: Dockerfile + args: + NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" + NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" + image: admin-web-test:local + container_name: admin-web-test + restart: unless-stopped + ports: + - "${HOST_PORT:-9001}:80" + environment: + # Runtime URL used by the secure customer-workbench handoff. Each + # environment may override it without rebuilding the Admin image. + CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}" + API_UPSTREAM: "http://test-api-cn.buggoglobal.com" + OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com" diff --git a/docker/runtime-config.sh b/docker/runtime-config.sh new file mode 100644 index 00000000..9b699f84 --- /dev/null +++ b/docker/runtime-config.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +runtime_file=/usr/share/nginx/html/runtime-config.js +client_web_url=${CLIENT_WEB_URL:-} +escaped_url=$(printf '%s' "$client_web_url" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r/\\r/g; s/\n/\\n/g') +printf "window.__CHONGCHONG_RUNTIME_CONFIG__ = { clientWebUrl: \"%s\" }\n" "$escaped_url" > "$runtime_file" diff --git a/index.html b/index.html index 02879339..be740a3a 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,10 @@ - - 虫虫出海管理后台 + + + 柏悦物流管理后台 +
@@ -12,3 +14,4 @@ + diff --git a/nginx/default.conf.template b/nginx/default.conf.template new file mode 100644 index 00000000..3688b5f7 --- /dev/null +++ b/nginx/default.conf.template @@ -0,0 +1,65 @@ +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Docker 容器运行正常的检查地址,供 Jenkins 使用。 + location = /healthz { + default_type text/plain; + return 200 "ok\n"; + } + + location = /runtime-config.js { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + try_files $uri =404; + } + + # 前端代码中的 API 前缀为 /api;保持原始路径转给 Gateway。 + location /api/ { + client_max_body_size 55m; + proxy_pass ${API_UPSTREAM}; + 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; + } + + # 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。 + location /overseas-api/ { + client_max_body_size 55m; + rewrite ^/overseas-api/(.*)$ /api/$1 break; + proxy_pass ${OVERSEAS_API_UPSTREAM}; + 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 { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + } + + # Vite assets are content-hashed and safe to cache permanently. Missing + # chunks must remain a real 404 instead of falling back to index.html. + location /assets/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + # Vue Router 的 history 路由刷新时仍返回 index.html。 + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache" always; + } +} diff --git a/package.json b/package.json index 00477f9f..c5494404 100644 --- a/package.json +++ b/package.json @@ -8,19 +8,23 @@ "build": "vue-tsc --noEmit && vite build", "typecheck": "vue-tsc --noEmit", "test": "vitest run", + "test:phase4:development": "node scripts/phase4-development-frontend-acceptance.mjs", "preview": "vite preview --port 4173" }, "dependencies": { - "@chongchong/shared": "link:../../packages/shared", + "@chongchong/shared": "link:./shared", "@lucide/vue": "^1.27.0", "axios": "^1.18.1", + "dayjs": "^1.11.21", "element-plus": "^2.14.3", "pinia": "^4.0.2", + "sortablejs": "^1.15.7", "vue": "^3.5.40", "vue-router": "^5.2.0" }, "devDependencies": { "@types/node": "^24.10.0", + "@types/sortablejs": "^1.15.9", "@vitejs/plugin-vue": "^6.0.8", "@vue/test-utils": "^2.4.11", "jsdom": "^29.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb5157c9..88a48763 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,20 +9,26 @@ importers: .: dependencies: '@chongchong/shared': - specifier: link:../../packages/shared - version: link:../../packages/shared + specifier: link:./shared + version: link:./shared '@lucide/vue': specifier: ^1.27.0 version: 1.27.0(vue@3.5.40(typescript@5.9.3)) axios: specifier: ^1.18.1 version: 1.18.1(debug@4.4.3) + dayjs: + specifier: ^1.11.21 + version: 1.11.21 element-plus: specifier: ^2.14.3 version: 2.14.3(vue@3.5.40(typescript@5.9.3)) pinia: specifier: ^4.0.2 version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)) + sortablejs: + specifier: ^1.15.7 + version: 1.15.7 vue: specifier: ^3.5.40 version: 3.5.40(typescript@5.9.3) @@ -33,6 +39,9 @@ importers: '@types/node': specifier: ^24.10.0 version: 24.13.3 + '@types/sortablejs': + specifier: ^1.15.9 + version: 1.15.9 '@vitejs/plugin-vue': specifier: ^6.0.8 version: 6.0.8(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) @@ -361,6 +370,9 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -1073,6 +1085,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sortablejs@1.15.7: + resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1638,6 +1653,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/sortablejs@1.15.9': {} + '@types/web-bluetooth@0.0.21': {} '@vitejs/plugin-vue@6.0.8(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))': @@ -2360,6 +2377,8 @@ snapshots: signal-exit@4.1.0: {} + sortablejs@1.15.7: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} diff --git a/public/runtime-config.js b/public/runtime-config.js new file mode 100644 index 00000000..9a482f5d --- /dev/null +++ b/public/runtime-config.js @@ -0,0 +1 @@ +window.__CHONGCHONG_RUNTIME_CONFIG__ = window.__CHONGCHONG_RUNTIME_CONFIG__ || { clientWebUrl: '' } diff --git a/public/templates/counterparties-import-template.xlsx b/public/templates/counterparties-import-template.xlsx deleted file mode 100644 index 3e5a8bc9..00000000 Binary files a/public/templates/counterparties-import-template.xlsx and /dev/null differ diff --git a/public/templates/express-companies-import-template.xlsx b/public/templates/express-companies-import-template.xlsx deleted file mode 100644 index 5b2d267f..00000000 Binary files a/public/templates/express-companies-import-template.xlsx and /dev/null differ diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 00000000..821a7116 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 4 ]; then + echo "用法: $0 <服务名> <镜像仓库> <镜像标签> " >&2 + exit 2 +fi + +SERVICE="$1" +IMAGE_REPOSITORY="$2" +IMAGE_TAG="$3" +COMPOSE_FILE="$4" +IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG" +REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" +SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" +SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" +SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" +REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" +: "${REGISTRY_USER:?REGISTRY_USER 未设置}" +: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" + +run_ssh() { + ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@" +} + +run_scp() { + scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" +} + +run_ssh install -d -m 750 "$REMOTE_DIR" +REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml" +run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp" +run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE' +set -eu +compose_tmp="$1" +compose_target="$2" +chmod 644 "$compose_tmp" +mv -f "$compose_tmp" "$compose_target" +REMOTE_PREPARE + +printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" +trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM +run_ssh docker pull "$IMAGE" + +run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY' +set -eu +remote_dir="$1" +service="$2" +image_repository="$3" +image_tag="$4" +compose_file="docker-compose.$service.yml" +project_name="firstleg-prod-$service" +cd "$remote_dir" +export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag" + +compose() { + docker compose --project-name "$project_name" -f "$compose_file" "$@" +} + +container_id="$(compose ps --all --quiet "$service" | head -n 1)" +previous_image="" +if [ -n "$container_id" ]; then + previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")" +fi + +if compose up -d --no-deps --pull never "$service" && \ + compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then + echo "生产前端部署成功: $image_repository:$image_tag" + compose ps "$service" + exit 0 +fi + +echo "生产前端健康检查失败,准备回滚" >&2 +compose logs --tail=100 "$service" >&2 || true +case "$previous_image" in + "$image_repository:"*) ;; + '') echo "首次部署无旧镜像可回滚" >&2; exit 1 ;; + *) echo "旧镜像不属于当前应用,拒绝回滚: $previous_image" >&2; exit 1 ;; +esac +previous_tag="${previous_image#"$image_repository:"}" +if [ "$previous_tag" = "$image_tag" ]; then + echo "旧镜像与新镜像一致,无法回滚" >&2 + exit 1 +fi +docker pull "$previous_image" +export IMAGE_TAG="$previous_tag" +compose up -d --no-deps --pull never "$service" +compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true +echo "已回滚至: $previous_image" >&2 +exit 1 +REMOTE_DEPLOY diff --git a/scripts/phase4-development-frontend-acceptance.mjs b/scripts/phase4-development-frontend-acceptance.mjs new file mode 100644 index 00000000..7321b557 --- /dev/null +++ b/scripts/phase4-development-frontend-acceptance.mjs @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' + +function option(name) { + const index = process.argv.indexOf(name) + return index >= 0 ? process.argv[index + 1] : '' +} + +function normalizeOrigin(value, name) { + if (!value) throw new Error(`缺少 ${name}`) + const url = new URL(value) + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/') { + throw new Error(`${name} 必须是无账号、无路径的 HTTP(S) origin`) + } + 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 或本机开发验收凭据文件') + +const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], { + cwd: process.cwd(), + stdio: 'inherit', + shell: process.platform === 'win32', + env: { + ...process.env, + 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, + VITE_API_BASE_URL: `${gateway}/api`, + VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`, + }, +}) +if (result.error) throw result.error +process.exit(result.status ?? 1) diff --git a/scripts/phase4-development-frontend-contract.test.mjs b/scripts/phase4-development-frontend-contract.test.mjs new file mode 100644 index 00000000..04f14f9e --- /dev/null +++ b/scripts/phase4-development-frontend-contract.test.mjs @@ -0,0 +1,34 @@ +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') + +test('development runner requires an explicit development environment and gateways', () => { + assert.match(runner, /--environment/) + assert.match(runner, /environment !== 'development'/) + assert.match(runner, /--gateway/) + assert.match(runner, /--overseas-gateway/) + assert.match(runner, /shell: process\.platform === 'win32'/) + assert.match(runner, /if \(result\.error\) throw result\.error/) + assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/) +}) + +test('live suite uses production auth clients and domain adapters', () => { + assert.match(suite, /useAuthStore/) + assert.match(suite, /clearOverseasAccessToken/) + assert.match(suite, /createQuote/) + assert.match(suite, /createUsTruckOrder/) + assert.match(suite, /createLocalLooseboardOrder/) + assert.match(suite, /listLastMileOrders/) + assert.match(suite, /listDispatchCandidates/) + assert.match(suite, /listCarrierScheduleQueries/) +}) + +test('live suite owns marker resources and redacts credentials from trace reports', () => { + assert.match(suite, /ADMIN-FRONT-P4-/) + assert.match(suite, /password\|token\|authorization/i) + assert.match(suite, /createdResources/) + assert.match(suite, /coverageGaps/) +}) diff --git a/scripts/phase4-development-frontend.acceptance.ts b/scripts/phase4-development-frontend.acceptance.ts new file mode 100644 index 00000000..2a9bb088 --- /dev/null +++ b/scripts/phase4-development-frontend.acceptance.ts @@ -0,0 +1,395 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios' +import { mkdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { createPinia, setActivePinia } from 'pinia' +import { createMemoryHistory, createRouter } from 'vue-router' +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 { useAuthStore } from '@/core/auth/auth' +import { listPlatformTenants } from '@/modules/iam/api/platformIam' +import { + changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle, + exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getQuote, getVehicle, listExchangeRates, + listQuoteWarehouseOptions, listQuotes, listVehicles, updateExchangeRate, updateQuote, updateVehicle, +} from '@/modules/overseas-master-data/api/overseasMasterData' +import { + createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, + listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases, + listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateUsTruckOrder, +} from '@/modules/overseas-business/api/platformOverseasBusiness' +import { listLastMileOrders, getLastMileOrder } from '@/modules/overseas-warehouse/api/lastMileOrders' +import { listInboundOrders } from '@/modules/overseas-warehouse/api/inboundOrders' +import { getInboundPlan } from '@/modules/overseas-warehouse/api/inboundPlans' +import { listDispatchCandidates, listDispatchDrafts, listDispatches } from '@/modules/overseas-warehouse/api/dispatches' +import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '@/modules/overseas-warehouse/api/inventory' +import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '@/modules/overseas-warehouse/api/pallets' +import { listRelabelOrders } from '@/modules/overseas-warehouse/api/relabelOrders' +import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '@/modules/port-schedules/api/platformCarrierScheduleQueries' +import OverseasQuotesView from '@/modules/overseas-master-data/pages/OverseasQuotesView.vue' +import PurchaseOrdersView from '@/modules/overseas-business/pages/PurchaseOrdersView.vue' +import TransportOrdersView from '@/modules/overseas-business/pages/TransportOrdersView.vue' +import LastMileOrdersView from '@/modules/overseas-warehouse/pages/LastMileOrdersView.vue' +import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersView.vue' +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' +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 } + +const enabled = process.env.ADMIN_PHASE4_LIVE === '1' +const marker = `ADMIN-FRONT-P4-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}` +const traces: TraceStep[] = [] +const coverageGaps: CoverageGap[] = [] +const createdResources: Record = {} +const wrappers: VueWrapper[] = [] + +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [key, /password|token|authorization/i.test(key) ? '[REDACTED]' : redact(item)])) +} +function parsed(value: unknown) { + if (typeof value !== 'string') return value + try { return JSON.parse(value) } catch { return value } +} +function envelope(value: unknown) { + const body = parsed(value) as { code?: unknown; requestId?: unknown; traceId?: unknown } | null + return body && typeof body === 'object' ? body : null +} +function installTracingAdapter(client: AxiosInstance, region: Region) { + const nodeAdapter = axios.getAdapter('http') + client.defaults.adapter = async (config: InternalAxiosRequestConfig) => { + try { + const response = await nodeAdapter(config) + const body = envelope(response.data) + traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: response.status, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) }) + return response + } catch (error) { + if (axios.isAxiosError(error)) { + const body = envelope(error.response?.data) + traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: error.response?.status || 0, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) }) + } + throw error + } + } +} +function gap(area: string, reason: string) { coverageGaps.push({ area, reason }) } +function numericId(value: unknown) { return /^[1-9]\d*$/.test(String(value ?? '')) } +function expectPage(value: { records: unknown[]; page: number; size: number; total: number; pages: number }) { + expect(Array.isArray(value.records)).toBe(true); expect(value.page).toBeGreaterThanOrEqual(1); expect(value.size).toBeGreaterThan(0); expect(value.total).toBeGreaterThanOrEqual(value.records.length); expect(value.pages).toBeGreaterThanOrEqual(0) +} +async function expectConflict(action: () => Promise) { + await expect(action()).rejects.toSatisfy((error: unknown) => { + if (error instanceof ApiResponseError) return error.code.endsWith('409') + if (!axios.isAxiosError(error)) return false + const response = parsed(error.response?.data) as { code?: unknown } | undefined + return error.response?.status === 409 || String(response?.code || '').endsWith('409') + }) +} +function futureDate(days: number) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10) } + +async function masterDataLifecycle() { + const options = await listQuoteWarehouseOptions() + const quotePage = await listQuotes({ page: 1, size: 20 }) + expectPage(quotePage) + if (options.length) { + const quote = await createQuote({ name: `${marker} 报价`, warehouseId: options[0].warehouseId, providerName: `${marker} Provider`, currency: 'USD', effectiveStart: '2098-01-01', effectiveEnd: '2098-12-31', versionCode: marker.slice(-32), warehouseFees: [{ item: 'Storage QA', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: 2.5, remark: marker }], transitPrices: [], remark: marker }) + const id = quote.summary.id + createdResources.quote = id + let detail = await getQuote(id) + const stale = detail.summary.recordVersion + detail = await updateQuote(id, { recordVersion: stale, name: `${marker} 报价-编辑`, providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: `${marker} edited` }) + await expectConflict(() => updateQuote(id, { recordVersion: stale, name: 'STALE', providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: marker })) + detail = await changeQuoteStatus(id, 'ACTIVE', detail.summary.recordVersion) + expect(detail.summary.status).toBe('ACTIVE') + expect((await exportQuotes({ keyword: marker })).size).toBeGreaterThan(0) + detail = await changeQuoteStatus(id, 'INACTIVE', detail.summary.recordVersion) + expect(detail.summary.status).toBe('INACTIVE') + } else gap('海外报价', 'warehouse-options 为空,未猜测仓库 ID,跳过报价写生命周期') + + let exchangeMonth = '' + for (let index = 0; index < 24 && !exchangeMonth; index += 1) { + const month = `${2097 + Math.floor(index / 12)}-${String(index % 12 + 1).padStart(2, '0')}` + const page = await listExchangeRates({ page: 1, size: 20, monthFrom: month, monthTo: month }) + if (!page.total) exchangeMonth = month + } + if (!exchangeMonth) throw new Error('安全汇率月份池已占满') + let rate = await createExchangeRate({ month: exchangeMonth, usdToCnyAverage: 7.123456, remark: marker }) + createdResources.exchangeRate = exchangeMonth + const staleRate = rate.recordVersion + rate = await updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.234567, remark: `${marker} edited` }) + await expectConflict(() => updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.9, remark: 'STALE' })) + rate = await getExchangeRate(exchangeMonth) + rate = await changeExchangeRateStatus(exchangeMonth, 'INACTIVE', rate.recordVersion, `${marker} cleanup`) + expect(rate.status).toBe('INACTIVE') + expect((await exportExchangeRates({ monthFrom: exchangeMonth, monthTo: exchangeMonth })).size).toBeGreaterThan(0) + + let vehicle = await createVehicle({ plateNumber: `QA-${marker.slice(-18)}`, model: 'Phase4 Front QA', fuelConsumptionPer100Km: 18.5, mileageKm: 100, driverOrDispatchCompany: marker, phone: '+1-213-555-0188', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: marker }) + createdResources.vehicle = vehicle.id + const staleVehicle = vehicle.recordVersion + vehicle = await updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'Phase4 Front QA Edited', fuelConsumptionPer100Km: 18.75, mileageKm: 101, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: `${marker} edited` }) + await expectConflict(() => updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'STALE', fuelConsumptionPer100Km: 19, mileageKm: 102, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: 'STALE' })) + vehicle = await getVehicle(vehicle.id) + vehicle = await changeVehicleStatus(vehicle.id, 'INACTIVE', vehicle.recordVersion) + expect(vehicle.status).toBe('INACTIVE') + expect((await exportVehicles({ keyword: vehicle.plateNumber })).size).toBeGreaterThan(0) + expectPage(await listVehicles({ page: 1, size: 20, keyword: marker })) +} + +async function cleanupTransport(path: string, customerId: string, version: number) { + await overseasHttp.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') + const active = customers.find(item => numericId(item.id)) + if (!active) { gap('卡车/散板', 'IAM 无 ACTIVE 客户'); return } + const customerId = String(active.id) + expect(Array.isArray(await listOverseasPurchaseCustomerOptions())).toBe(true) + expect(Array.isArray(await listOverseasPurchaseWarehouseOptions())).toBe(true) + expectPage(await listOverseasPurchases({ page: 1, size: 20 })) + const common = { origin: `${marker}-ORIGIN`, originAddress: 'Long Beach', destination: `${marker}-DEST`, destinationAddress: 'Ontario', containerType: '40HQ', packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, currency: 'USD', contactName: '四期前端验收', contactInfo: '13800000000', requestedDate: futureDate(7), remark: marker } + let truck = await createUsTruckOrder(customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, idempotencyKey: `${marker}-truck` }) + createdResources.truck = truck.orderNo + try { + truck = await getUsTruckOrder(truck.orderNo, customerId) + const stale = truck.version + truck = await updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: `${marker}-DEST-EDIT`, version: stale }) + await expectConflict(() => updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: 'STALE', version: stale })) + } finally { + truck = await getUsTruckOrder(truck.orderNo, customerId) + if (truck.status === 'PENDING_REVIEW') await cleanupTransport(`/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(truck.orderNo)}`, customerId, truck.version) + } + expectPage(await listUsTruckOrders({ page: 1, size: 20, customerId })) + + let loose = await createLocalLooseboardOrder(customerId, { transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', ...common, idempotencyKey: `${marker}-looseboard` }) + createdResources.looseboard = loose.orderNo + try { + loose = await getLocalLooseboardOrder(loose.orderNo, customerId) + const stale = loose.version + loose = await updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: `${marker}-DEST-EDIT`, version: stale }) + await expectConflict(() => updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: 'STALE', version: stale })) + } finally { + loose = await getLocalLooseboardOrder(loose.orderNo, customerId) + if (loose.status === 'PENDING_ACCEPTANCE') await cleanupTransport(`/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(loose.orderNo)}`, customerId, loose.version) + } + expectPage(await listLocalLooseboardOrders({ page: 1, size: 20, customerId })) +} + +async function overseasWarehouseCoverage() { + const lastMile = await listLastMileOrders({ page: 1, size: 20 }); expectPage(lastMile) + if (lastMile.records[0]) await getLastMileOrder(String(lastMile.records[0].id)) + const inbound = await listInboundOrders({ page: 1, size: 20 }); expectPage(inbound) + if (inbound.records[0]) await getInboundPlan(inbound.records[0].planNo, inbound.records[0].customerId) + expectPage(await listDispatches({ page: 1, size: 20 })) + expect(Array.isArray(await listDispatchCandidates({}))).toBe(true) + expect(Array.isArray(await listDispatchDrafts())).toBe(true) + const inventoryQuery = { page: 1, size: 20 } + const balances = await listInventoryBalances(inventoryQuery); expectPage(balances) + await getInventorySummary(inventoryQuery) + await listInventoryLedger({ page: 1, size: 20 }) + if (balances.records[0]) await listInventoryPositions(String(balances.records[0].id), 1, 20) + expectPage(await listRelabelOrders({ page: 1, size: 20 })) + const warehouses = (await listCustomerWarehouses()).records.filter(item => item.status === 'ACTIVE') + if (!warehouses.length) { gap('公共托盘', '实际海外仓为空,跳过前端托盘写生命周期'); return } + const warehouse = warehouses[0] + const payload = { palletCode: `QA-${marker.slice(-20)}`, palletType: 'STANDARD', fbaWarehouseCode: 'ONT8', capacityCbm: 2.5, unitPrice: 10, status: 'IDLE', remark: marker } + const palletId = await createPallet(warehouse.id, payload) + createdResources.pallet = palletId + try { + const pallet = await getPallet(warehouse.id, palletId) + await updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 2.75, remark: `${marker} edited`, version: pallet.version }) + await expectConflict(() => updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 3, remark: 'STALE', version: pallet.version })) + expectPage(await listPallets(warehouse.id, { page: 1, size: 20, keyword: payload.palletCode })) + } + finally { + const current = await getPallet(warehouse.id, palletId) + if (current.status !== 'SCRAPPED') await updatePallet(warehouse.id, palletId, { ...payload, status: 'SCRAPPED', remark: `${marker} cleanup`, version: current.version }) + } + gap('FBA预约/智能预排/换标新增', '没有本轮 marker 入仓计划和托盘候选;为避免操作历史数据,本轮只自动覆盖列表、详情、候选和草稿查询') +} + +async function carrierCoverage() { + const page = await listCarrierScheduleQueries({ page: 1, size: 20 }) + expectPage(page) + if (page.records[0]) await getCarrierScheduleQuery(page.records[0].query.id) + else gap('船节点人工补录', '环境无真实查询任务;未创建或修改历史任务') +} + +async function waitForTrace(method: string, path: string, start: number) { + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + if (traces.slice(start).some(item => item.method === method && item.path.startsWith(path))) return + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error(`页面未发出 ${method} ${path}`) +} +async function mountPage(component: Parameters[0], method: string, path: string, props?: Record) { + const start = traces.length + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ template: '
' }) }] }) + await router.push('/'); await router.isReady() + const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [ElementPlus, router, setActivePinia(createPinia())] } }) + wrappers.push(wrapper) + await waitForTrace(method, path, start) + await nextTick() + expect(wrapper.text()).not.toContain('服务暂时不可用') + return wrapper +} +async function exerciseListSearch( + wrapper: VueWrapper, + path: string, + model: Record, + expectedParams: Record, +) { + 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 | undefined + for (const key of Object.keys(expectedParams)) expect(resetParams?.[key] == null || resetParams[key] === '').toBe(true) + } +async function pageRequestCoverage() { + 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/warehouse-admin/pallets') + await exerciseListSearch(pallets, '/platform/v1/warehouse-admin/pallets', + { warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' }, + { keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' }) +} + +describe.skipIf(!enabled).sequential('phase4 development 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') + 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 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) + }) + it('runs marker-owned adapter lifecycles and real page requests', async () => { + await masterDataLifecycle() + await overseasBusinessLifecycle() + await overseasWarehouseCoverage() + await carrierCoverage() + await pageRequestCoverage() + expect(traces.length).toBeGreaterThan(20) + }) + afterAll(() => { + wrappers.splice(0).forEach(wrapper => wrapper.unmount()) + 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`) + writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + console.info(`PHASE4_FRONTEND_REPORT=${file}`) + }) +}) diff --git a/shared/AGENTS.md b/shared/AGENTS.md new file mode 100644 index 00000000..2421437e --- /dev/null +++ b/shared/AGENTS.md @@ -0,0 +1,30 @@ +# 应用内 Shared Package Instructions + +## 项目定位 + +- 本包只服务当前前端应用,提供多个业务模块共同使用的 TypeScript 类型、常量和轻量工具。 +- 依赖保持为 `@chongchong/shared: link:./shared`,确保当前应用目录可作为独立容器构建上下文。 +- 不承担 Admin、Client 之间的源码复用;跨应用字段需要分别映射同一份 Java Controller/OpenAPI 契约,不能通过目录外链接耦合两个部署单元。 + +## 公共 API 与依赖边界 + +- `src/index.ts` 是当前唯一公共入口;模块专属页面状态、UI 组件和临时业务逻辑留在对应模块。 +- 共享类型保持前端平台无关,不编码鉴权结论;租户隔离、权限、状态机和数据合法性由 `baiyue-firstleg` Java 服务端最终保证。 +- 服务端字段变化先核对 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 预留猜测字段。 + +定位规则:先在 `src/index.ts` 确认公共导出,再搜索 `src/` 消费者;后端字段或状态有疑问时回到对应 Controller/DTO/OpenAPI,不在 shared 中猜测业务规则。 + +阶段依据(路径相对本文件所在目录):订单与客户 `../../../docs/订单与客户/`,入库测量与计费 `../../../docs/入库测量与计费/`,国内货柜履约 `../../../docs/国内货柜履约/`;共享包不保存阶段进度,也不承载跨应用(Client)源码复用。 + +## 契约与验证 + +- 修改导出名称、字段类型、枚举值或常量语义前,搜索当前应用内全部消费者。 +- 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。 +- 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。 diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 00000000..fdc627de --- /dev/null +++ b/shared/README.md @@ -0,0 +1,5 @@ +# 应用内共享模块 + +本目录存放当前前端应用多个业务模块共同使用的 TypeScript 类型、常量和轻量工具,通过 `@chongchong/shared: link:./shared` 引用。 + +该目录必须保留在应用内部,以支持 Admin、Client 分别使用自身目录作为容器构建上下文。它不作为 Java 服务端契约来源;接口字段、状态机、权限和业务规则以 `baiyue-firstleg` Controller、DTO 和 OpenAPI 为准。 diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 00000000..5e802c2d --- /dev/null +++ b/shared/package.json @@ -0,0 +1,11 @@ +{ + "name": "@chongchong/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "types": "./src/index.ts" +} + diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 00000000..5bf0aef9 --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,155 @@ +export const PRODUCT_NAME = '虫虫出海' + +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER) + +function quoteUnsafeIntegerTokens(source: string) { + let output = '' + let index = 0 + let inString = false + let escaped = false + + while (index < source.length) { + const character = source[index] + + if (inString) { + output += character + if (escaped) escaped = false + else if (character === '\\') escaped = true + else if (character === '"') inString = false + index += 1 + continue + } + + if (character === '"') { + inString = true + output += character + index += 1 + continue + } + + if (character === '-' || (character >= '0' && character <= '9')) { + const token = source.slice(index).match(/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/)?.[0] + if (token) { + const isInteger = !token.includes('.') && !/[eE]/.test(token) + if (isInteger && (BigInt(token) > MAX_SAFE_INTEGER || BigInt(token) < -MAX_SAFE_INTEGER)) output += JSON.stringify(token) + else output += token + index += token.length + continue + } + } + + output += character + index += 1 + } + + return output +} + +export function parseJsonWithSafeIntegers(source: string) { + return JSON.parse(quoteUnsafeIntegerTokens(source)) as unknown +} + +export function parseApiJsonResponse(data: unknown) { + if (typeof data !== 'string') return data + const trimmed = data.trim() + if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return data + try { + return parseJsonWithSafeIntegers(data) + } catch { + return data + } +} + +export type AppCode = 'admin' | 'client' + +export interface CurrentUser { + id: string + name: string + account: string + tenantName?: string + roles: string[] +} + +export interface NavigationItem { + path: string + title: string + permission?: string +} + +export interface ApiResponse { + code: number + message: string + data: T +} + +export const FIRST_LEG_ORDER_STATUSES = [ + { code: 'DRAFT', label: '草稿' }, + { code: 'PENDING_REVIEW', label: '待审核' }, + { code: 'ACCEPTED', label: '已受理' }, + { code: 'BOOKED', label: '已订舱' }, + { code: 'IN_TRANSIT', label: '运输中' }, + { code: 'COMPLETED', label: '已完成' }, + { code: 'CANCELLED', label: '已取消' }, +] as const + +export type FirstLegOrderStatus = (typeof FIRST_LEG_ORDER_STATUSES)[number]['code'] +export type FirstLegOrderStatusLabel = (typeof FIRST_LEG_ORDER_STATUSES)[number]['label'] + +export const FIRST_LEG_TRANSPORT_MODES = [ + { code: 'FCL', label: '海运整箱 FCL' }, + { code: 'LCL', label: '海运拼箱 LCL' }, + { code: 'AIR', label: '空运' }, + { code: 'ROAD', label: '陆运' }, +] as const + +export type FirstLegTransportMode = (typeof FIRST_LEG_TRANSPORT_MODES)[number]['code'] +export type FirstLegTransportModeLabel = (typeof FIRST_LEG_TRANSPORT_MODES)[number]['label'] + +export type PlatformWarehouseStatus = 'ACTIVE' | 'INACTIVE' + +export interface PlatformReceivingWarehouse { + id: string + code: string + name: string + province: string + city: string + address: string + contactName: string + contactPhone: string + businessHours: string + status: PlatformWarehouseStatus + remark: string + updatedAt: string +} + +export interface PlatformReceivingWarehouseSnapshot { + id: string + code: string + name: string + province: string + city: string + address: string +} + +export function createPlatformReceivingWarehouseSnapshot( + warehouse: PlatformReceivingWarehouse, +): PlatformReceivingWarehouseSnapshot { + return { + id: warehouse.id, + code: warehouse.code, + name: warehouse.name, + province: warehouse.province, + city: warehouse.city, + address: warehouse.address, + } +} + +// API 接入前供 Admin 与 Client 静态原型共用,不能作为服务端真实仓库数据。 +export const DEMO_PLATFORM_RECEIVING_WAREHOUSES: readonly PlatformReceivingWarehouse[] = [ + { id: 'WH001', code: 'CN-YW-01', name: '义乌仓', province: '浙江省', city: '义乌市', address: '北苑街道演示物流园 1 号库', contactName: '仓库客服', contactPhone: '0579-****001', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '支持普通货物收货。', updatedAt: '2026-07-28 10:20' }, + { id: 'WH002', code: 'CN-SZ-01', name: '深圳仓', province: '广东省', city: '深圳市', address: '盐田区演示物流园 2 号库', contactName: '仓库客服', contactPhone: '0755-****002', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '华南区域主要收货仓。', updatedAt: '2026-07-28 10:20' }, + { id: 'WH003', code: 'CN-QD-01', name: '青岛仓', province: '山东省', city: '青岛市', address: '黄岛区演示物流园 3 号库', contactName: '仓库客服', contactPhone: '0532-****003', businessHours: '周一至周五 09:00-17:30', status: 'ACTIVE', remark: '', updatedAt: '2026-07-28 10:20' }, + { id: 'WH004', code: 'CN-SH-01', name: '上海仓', province: '上海市', city: '上海市', address: '浦东新区演示物流园 4 号库', contactName: '仓库客服', contactPhone: '021-****004', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '华东区域综合收货仓。', updatedAt: '2026-07-28 10:20' }, + { id: 'WH005', code: 'CN-NB-01', name: '宁波仓', province: '浙江省', city: '宁波市', address: '北仑区演示物流园 5 号库', contactName: '仓库客服', contactPhone: '0574-****005', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '临近宁波港。', updatedAt: '2026-07-28 10:20' }, + { id: 'WH006', code: 'CN-XM-01', name: '厦门仓', province: '福建省', city: '厦门市', address: '湖里区演示物流园 6 号库', contactName: '仓库客服', contactPhone: '0592-****006', businessHours: '周一至周五 09:00-17:30', status: 'INACTIVE', remark: '静态演示:暂停接收新订单。', updatedAt: '2026-07-28 10:20' }, +] diff --git a/src/App.test.ts b/src/App.test.ts new file mode 100644 index 00000000..7f423a1d --- /dev/null +++ b/src/App.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import zhCn from 'element-plus/es/locale/lang/zh-cn' + +describe('Admin Element Plus locale', () => { + it('provides simplified Chinese globally', () => { + const source = readFileSync(join(process.cwd(), 'src/App.vue'), 'utf8') + + expect(zhCn.name).toBe('zh-cn') + expect(source).toContain("import zhCn from 'element-plus/es/locale/lang/zh-cn'") + expect(source).toContain('') + expect(source).toMatch(/[\s\S]*<\/ElConfigProvider>/) + }) +}) diff --git a/src/App.vue b/src/App.vue index 4be89653..1210025e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,4 +1,10 @@ - + + diff --git a/src/api/http.test.ts b/src/api/http.test.ts deleted file mode 100644 index cbee3a30..00000000 --- a/src/api/http.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import axios, { AxiosHeaders } from 'axios' -import { describe, expect, it } from 'vitest' -import { ApiResponseError, getApiErrorDetails, unwrapApiResponse } from './http' - -describe('API error diagnostics', () => { - it('keeps business request and trace identifiers when unwrapping fails', () => { - expect(() => unwrapApiResponse({ - code: 'A0403', - message: '无权限', - data: null, - requestId: 'request-1', - traceId: 'trace-1', - })).toThrow(ApiResponseError) - - try { - unwrapApiResponse({ code: 'A0403', message: '无权限', data: null, requestId: 'request-1', traceId: 'trace-1' }) - } catch (error) { - expect(getApiErrorDetails(error)).toEqual({ - message: '无权限', - code: 'A0403', - requestId: 'request-1', - traceId: 'trace-1', - }) - } - }) - - it('extracts identifiers from an HTTP error envelope', () => { - const error = new axios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, { - status: 503, - statusText: 'Service Unavailable', - headers: {}, - config: { headers: new AxiosHeaders() }, - data: { code: 'S0503', message: '服务不可用', requestId: 'request-2', traceId: 'trace-2' }, - }) - - expect(getApiErrorDetails(error)).toEqual({ - message: '服务不可用', - code: 'S0503', - requestId: 'request-2', - traceId: 'trace-2', - }) - }) -}) diff --git a/src/api/http.ts b/src/api/http.ts deleted file mode 100644 index 87552097..00000000 --- a/src/api/http.ts +++ /dev/null @@ -1,121 +0,0 @@ -import axios, { type InternalAxiosRequestConfig } from 'axios' - -export interface ApiEnvelope { - code: string - message: string - data: T - requestId?: string | null - traceId?: string | null - timestamp?: string -} - -export class ApiResponseError extends Error { - readonly code: string - readonly status?: number - readonly details?: unknown - readonly requestId?: string - readonly traceId?: string - - constructor(message: string, code: string, status?: number, details?: unknown, requestId?: string | null, traceId?: string | null) { - super(message) - this.name = 'ApiResponseError' - this.code = code - this.status = status - this.details = details - this.requestId = requestId || undefined - this.traceId = traceId || undefined - } -} - -export interface ApiErrorDetails { - message: string - code?: string - requestId?: string - traceId?: string -} - -export const http = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || '/api', - timeout: 15_000, -}) - -type RetryableRequestConfig = InternalAxiosRequestConfig & { _authRetry?: boolean } -type AuthRefreshHandler = () => Promise - -let authRefreshHandler: AuthRefreshHandler | null = null -let activeRefresh: Promise | null = null - -export function registerAuthRefreshHandler(handler: AuthRefreshHandler) { - authRefreshHandler = handler -} - -http.interceptors.request.use((config) => { - const token = localStorage.getItem('cc-admin-token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config -}) - -http.interceptors.response.use( - (response) => response.data, - async (error) => { - const config = error.config as RetryableRequestConfig | undefined - const isAuthEndpoint = typeof config?.url === 'string' && config.url.startsWith('/auth/') - if (error.response?.status !== 401 || !config || config._authRetry || isAuthEndpoint || !authRefreshHandler) { - return Promise.reject(error) - } - - config._authRetry = true - try { - if (!activeRefresh) { - activeRefresh = authRefreshHandler().finally(() => { activeRefresh = null }) - } - const nextToken = await activeRefresh - config.headers.Authorization = `Bearer ${nextToken}` - return http.request(config) - } catch { - return Promise.reject(error) - } - }, -) - -export function unwrapApiResponse(response: ApiEnvelope): T { - if (response?.code !== '0') { - throw new ApiResponseError( - response?.message || '接口请求失败', - response?.code || 'UNKNOWN', - undefined, - response?.data, - response?.requestId, - response?.traceId, - ) - } - return response.data -} - -export function getApiErrorDetails(error: unknown, fallback = '接口请求失败'): ApiErrorDetails { - if (error instanceof ApiResponseError) { - return { message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId } - } - if (axios.isAxiosError(error)) { - const response = error.response?.data as { - code?: string - message?: string - error?: string - requestId?: string | null - traceId?: string | null - } | undefined - return { - message: response?.message || response?.error || fallback, - code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined), - requestId: response?.requestId || undefined, - traceId: response?.traceId || undefined, - } - } - return { message: error instanceof Error ? error.message : fallback } -} - -export function getApiErrorMessage(error: unknown, fallback = '接口请求失败'): string { - return getApiErrorDetails(error, fallback).message -} diff --git a/src/api/platformIam.test.ts b/src/api/platformIam.test.ts deleted file mode 100644 index f76601d8..00000000 --- a/src/api/platformIam.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { http } from '@/api/http' -import { - createPlatformPermission, - createPlatformRole, - downloadTenantQualification, - listTenantQualifications, - listPlatformPermissions, - listPlatformRoles, - qualificationFileName, - type PlatformPermissionResource, - type PlatformRoleResource, -} from './platformIam' - -vi.mock('@/api/http', () => ({ - http: { get: vi.fn(), post: vi.fn() }, - unwrapApiResponse: (response: { data: T }) => response.data, -})) - -const role: PlatformRoleResource = { - id: 4000002, - tenantId: 0, - appCode: 'PLATFORM', - roleCode: 'CUSTOMER_REVIEWER', - roleName: '企业审核员', - roleType: 'SYSTEM', - status: 'ACTIVE', - description: '企业资料与入驻审核', -} - -const permission: PlatformPermissionResource = { - id: 5100010, - tenantId: 0, - appCode: 'PLATFORM', - permissionType: 'MENU', - parentId: 5100000, - permissionCode: 'platform:customer:list', - permissionName: '企业客户', - routePath: '/customers', - httpMethod: null, - sortNo: 10, - status: 'ACTIVE', -} - -describe('platform IAM roles', () => { - beforeEach(() => { - vi.mocked(http.get).mockReset() - vi.mocked(http.post).mockReset() - }) - - it('loads roles from the platform IAM prefix', async () => { - vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [role] }) - - await expect(listPlatformRoles()).resolves.toEqual([role]) - expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/roles') - }) - - it('creates a platform role with the documented payload', async () => { - const payload = { - roleCode: 'CUSTOMER_REVIEWER', - roleName: '企业审核员', - description: '企业资料与入驻审核', - } - vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: role }) - - await expect(createPlatformRole(payload)).resolves.toEqual(role) - expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/roles', payload) - }) - - it('loads and creates permission resources under the platform IAM prefix', async () => { - vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [permission] }) - vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: permission }) - const payload = { - type: 'MENU' as const, - code: permission.permissionCode, - name: permission.permissionName, - parentId: permission.parentId, - routePath: permission.routePath, - httpMethod: null, - sortNo: permission.sortNo, - } - - await expect(listPlatformPermissions()).resolves.toEqual([permission]) - await expect(createPlatformPermission(payload)).resolves.toEqual(permission) - expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/permissions') - expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/permissions', payload) - }) - - it('loads qualifications for the selected tenant', async () => { - const qualification = { - id: 12, - tenantId: 8, - qualificationType: 'BUSINESS_LICENSE', - certificateNo: '91440300XXXXXXXXXX', - documentUrl: '/api/merchant/v1/files/qualifications/license.png', - status: 'PENDING' as const, - reviewComment: null, - } - vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [qualification] }) - - await expect(listTenantQualifications(8)).resolves.toEqual([qualification]) - expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/tenants/8/qualifications') - }) - - it('downloads a qualification with the platform file endpoint and a safe filename', async () => { - const blob = new Blob(['image'], { type: 'image/png' }) - vi.mocked(http.get).mockResolvedValue(blob) - - expect(qualificationFileName('/api/merchant/v1/files/qualifications/%E8%90%A5%E4%B8%9A%E6%89%A7%E7%85%A7.png?token=ignored')).toBe('营业执照.png') - await expect(downloadTenantQualification(8, '/api/merchant/v1/files/qualifications/%E8%90%A5%E4%B8%9A%E6%89%A7%E7%85%A7.png')).resolves.toBe(blob) - expect(http.get).toHaveBeenCalledWith( - '/platform/v1/files/qualifications/8/%E8%90%A5%E4%B8%9A%E6%89%A7%E7%85%A7.png', - { responseType: 'blob' }, - ) - expect(() => qualificationFileName('/api/merchant/v1/files/qualifications/%2Fetc%2Fpasswd')).toThrow('资质文件地址无效') - }) -}) diff --git a/src/api/platformIam.ts b/src/api/platformIam.ts deleted file mode 100644 index 96fcaeeb..00000000 --- a/src/api/platformIam.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http' - -export interface PlatformPermissionResource { - id: number - tenantId: number - appCode: 'PLATFORM' - permissionType: 'MENU' | 'BUTTON' - parentId: number - permissionCode: string - permissionName: string - routePath: string | null - httpMethod: string | null - sortNo: number - status: 'ACTIVE' | 'INACTIVE' -} - -export interface EnterpriseReview { - id: number - tenantId: number - status: 'PENDING' | 'APPROVED' | 'REJECTED' - tenantName?: string | null - tenantCode?: string | null - qualificationId?: number | null - qualificationType?: string | null - certificateNo?: string | null - documentUrl?: string | null - contactName?: string | null - contactPhone?: string | null - contactEmail?: string | null - submittedAt?: string | null - createdAt?: string | null - reviewComment?: string | null - [key: string]: unknown -} - -export interface PlatformTenantQualification { - id: number | string - tenantId: number | string - qualificationType: 'BUSINESS_LICENSE' | string - certificateNo: string | null - documentUrl: string - status: 'PENDING' | 'APPROVED' | 'REJECTED' - reviewComment: string | null -} - -export interface PlatformRoleResource { - id: number - tenantId: number - appCode: 'PLATFORM' - roleCode: string - roleName: string - roleType: 'SYSTEM' | 'CUSTOM' | string - status: 'ACTIVE' | 'INACTIVE' | string - description: string | null -} - -export interface CreatePlatformRoleRequest { - roleCode: string - roleName: string - description: string -} - -export interface CreatePlatformPermissionRequest { - type: 'MENU' | 'BUTTON' - code: string - name: string - parentId: number - routePath: string | null - httpMethod: null - sortNo: number -} - -async function getData(path: string): Promise { - const response = await http.get, ApiEnvelope>(path) - return unwrapApiResponse(response) -} - -async function postData(path: string, body: unknown): Promise { - const response = await http.post, ApiEnvelope>(path, body) - return unwrapApiResponse(response) -} - -export function listPlatformPermissions() { - return getData('/platform/v1/iam/permissions') -} - -export function listEnterpriseReviews() { - return getData('/platform/v1/iam/reviews') -} - -export function listTenantQualifications(tenantId: number | string) { - return getData(`/platform/v1/iam/tenants/${tenantId}/qualifications`) -} - -export function qualificationFileName(documentUrl: string) { - const path = documentUrl.split(/[?#]/, 1)[0] - const encodedName = path.split('/').filter(Boolean).at(-1) - if (!encodedName) throw new Error('资质文件地址无效') - let fileName: string - try { - fileName = decodeURIComponent(encodedName) - } catch { - throw new Error('资质文件地址无效') - } - if (!fileName || fileName === '.' || fileName === '..' || /[\\/]/.test(fileName)) { - throw new Error('资质文件地址无效') - } - return fileName -} - -export function downloadTenantQualification(tenantId: number | string, documentUrl: string) { - const fileName = qualificationFileName(documentUrl) - return http.get(`/platform/v1/files/qualifications/${tenantId}/${encodeURIComponent(fileName)}`, { - responseType: 'blob', - }) -} - -export function listPlatformRoles() { - return getData('/platform/v1/iam/roles') -} - -export function createPlatformRole(payload: CreatePlatformRoleRequest) { - return postData('/platform/v1/iam/roles', payload) -} - -export function createPlatformPermission(payload: CreatePlatformPermissionRequest) { - return postData('/platform/v1/iam/permissions', payload) -} - -export function approveEnterpriseReview(reviewId: number, comment: string) { - return postData(`/platform/v1/iam/reviews/${reviewId}/approve`, { comment }) -} - -export function rejectEnterpriseReview(reviewId: number, comment: string) { - return postData(`/platform/v1/iam/reviews/${reviewId}/reject`, { comment }) -} diff --git a/src/app/DomesticOutboundListLayout.test.ts b/src/app/DomesticOutboundListLayout.test.ts new file mode 100644 index 00000000..56b5c209 --- /dev/null +++ b/src/app/DomesticOutboundListLayout.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const pages = [ + ['src/modules/container-loading/pages/ContainersView.vue', "key: 'cabinetNo'"], + ['src/modules/loading-lists/components/LoadingListsView.vue', "key: 'preloadNo'"], + ['src/modules/ocean-bookings/pages/OceanBookingsView.vue', "prop: 'bookingNo'"], + ['src/modules/port-schedules/pages/PortSchedulesView.vue', "prop: 'schedule.scheduleNo'"], + ['src/modules/trucking/pages/TruckAppointmentsView.vue', "key: 'appointmentNo'"], + ['src/modules/customs/pages/CustomsCasesView.vue', "prop: 'customsNo'"], + ['src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue', "prop: 'inboundCode'"], +] as const + +function source(path: string) { + return readFileSync(resolve(process.cwd(), path), 'utf8') +} + +describe('domestic outbound list responsive contracts', () => { + it.each(pages)('%s keeps the operation column first and fixed left', (path, firstBusinessColumn) => { + const content = source(path) + const firstAction = content.indexOf("label: '操作'") + expect(firstAction).toBeGreaterThan(-1) + expect(firstAction).toBeLessThan(content.indexOf(firstBusinessColumn, firstAction)) + const actionObjects = [...content.matchAll(/\{[^{}]*label: '操作'[^{}]*\}/g)].map((match) => match[0]) + expect(actionObjects.length).toBeGreaterThan(0) + for (const column of actionObjects) expect(column).toContain("fixed: 'left'") + }) + + it.each(pages)('%s gives every business column a stable width constraint', (path) => { + 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'")) + expect(columns.length).toBeGreaterThan(1) + for (const column of columns) expect(column).toMatch(/\b(?:width|minWidth):\s*\d+/) + }) + + it('keeps shared 320px degradation rules for filters, actions and pagination', () => { + const queryToolbar = source('src/shared/components/QueryToolbar.vue') + const dataTable = source('src/shared/components/DataTable.vue') + const listLayout = source('src/shared/components/ListPageLayout.vue') + + expect(queryToolbar).toContain('@media (max-width: 720px)') + 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') + expect(dataTable).toContain('.data-table__page-buttons { flex-wrap: wrap; }') + expect(listLayout).toContain('min-width: 0') + }) +}) diff --git a/src/app/DomesticOutboundListStates.test.ts b/src/app/DomesticOutboundListStates.test.ts new file mode 100644 index 00000000..360dc95b --- /dev/null +++ b/src/app/DomesticOutboundListStates.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +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)', '@click="openDetail(row as TruckAppointmentResource)">详情', '@retry="load"'], +] as const + +function source(path: string) { + return readFileSync(resolve(process.cwd(), path), 'utf8') +} + +describe('domestic outbound list state contracts', () => { + it.each(pages)('%s exposes loading, error, empty, translated status, detail action and retry states', (path, loading, error, empty, status, detailAction, retry) => { + const content = source(path) + expect(content).toContain(loading) + expect(content).toContain(error) + expect(content).toContain(empty) + expect(content).toContain(status) + expect(content).toContain(detailAction) + expect(content).toContain(retry) + }) + + it('keeps list component state when route details return to the cached workspace list', () => { + const layout = source('src/app/layouts/AppLayout.vue') + const oceanDetail = source('src/modules/ocean-bookings/pages/OceanBookingDetailView.vue') + const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue') + + expect(layout).toContain('') + 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' })") + }) +}) diff --git a/src/app/components/AdminSidebarMenu.test.ts b/src/app/components/AdminSidebarMenu.test.ts new file mode 100644 index 00000000..0c1babf0 --- /dev/null +++ b/src/app/components/AdminSidebarMenu.test.ts @@ -0,0 +1,150 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { createMemoryHistory, createRouter } from 'vue-router' +import { describe, expect, it } from 'vitest' +import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation' +import AdminSidebarMenu from './AdminSidebarMenu.vue' + +const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([ + 'platform:customer:list', + 'platform:warehouse:list', + 'platform:firstleg:inbound:list', + 'platform:location:list', + 'platform:pallet:list', + 'firstleg:fba-pricing:rate:view', + 'platform:express:list', + 'platform:counterparty:list', + 'platform:download:list', + 'platform:user:list', + 'platform:role:list', + 'platform:permission:list', +])) + +const stubs = { + 'el-menu': defineComponent({ + props: ['defaultActive', 'defaultOpeneds', 'collapse'], + emits: ['select'], + methods: { updateActiveIndex() {} }, + template: '', + }), + 'el-sub-menu': { + props: ['index'], + template: '
', + }, + 'el-menu-item': { + props: ['index'], + template: '', + }, +} + +describe('AdminSidebarMenu', () => { + function routerPlugin() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '

' } }, + { path: '/warehouses', component: { template: '

' } }, + ], + }) + } + + it('renders the visible phase-one modules and forwards the active route', () => { + const wrapper = mount(AdminSidebarMenu, { + props: { items: visibleNavigation, activePath: '/system/users', collapsed: false }, + global: { stubs, plugins: [routerPlugin()] }, + }) + + expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users') + expect(wrapper.get('nav').attributes('data-openeds')).toBe('[]') + const groupTitles = wrapper.findAll('[data-kind="group"] header').map((node) => node.text()) + const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index')) + + expect(groupTitles).toContain('客户管理') + expect(groupTitles).not.toContain('供应商中心') + expect(groupTitles).toContain('通用') + expect(groupTitles).toContain('系统管理') + expect(groupTitles).not.toContain('财务管理') + expect(paths).toContain('/dashboard') + expect(paths).toContain('/orders') + expect(paths).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') + expect(paths).not.toContain('/prototype/reports/overview') + expect(paths).toContain('/system/permissions') + }) + + it('forwards desktop collapse state to Element Plus', () => { + const wrapper = mount(AdminSidebarMenu, { + props: { items: visibleNavigation, activePath: '/', collapsed: true }, + global: { stubs, plugins: [routerPlugin()] }, + }) + + expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true') + }) + + it('renders only entries included in the filtered navigation input', () => { + const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list'])) + const wrapper = mount(AdminSidebarMenu, { + props: { items, activePath: '/', collapsed: false }, + global: { stubs, plugins: [routerPlugin()] }, + }) + + expect(wrapper.text()).toContain('菜单权限') + expect(wrapper.text()).toContain('首页数据看板') + expect(wrapper.text()).not.toContain('客户列表') + }) + + it('recursively renders three navigation levels', () => { + const items: AdminNavigationNode[] = [{ + key: 'operations', + title: '运营管理', + icon: adminNavigation[0].icon, + children: [{ + key: 'shipment', + title: '运输管理', + icon: adminNavigation[0].icon, + children: [{ + key: 'tracking', + title: '轨迹管理', + icon: adminNavigation[0].icon, + path: '/tracking', + permission: 'tracking:list', + }], + }], + }] + const wrapper = mount(AdminSidebarMenu, { + props: { items, activePath: '/tracking', collapsed: false }, + global: { stubs, plugins: [routerPlugin()] }, + }) + + expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2) + expect(wrapper.get('[data-kind="item"]').attributes('data-index')).toBe('/tracking') + }) + + it('recreates the menu when asynchronously loaded groups change', async () => { + const wrapper = mount(AdminSidebarMenu, { + props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false }, + global: { stubs, plugins: [routerPlugin()] }, + }) + const initialMenu = wrapper.get('nav').element + + await wrapper.setProps({ items: visibleNavigation }) + + expect(wrapper.get('nav').element).not.toBe(initialMenu) + expect(wrapper.text()).toContain('系统管理') + }) + + it('navigates explicitly when a leaf menu item is selected', async () => { + const router = routerPlugin() + await router.push('/') + const wrapper = mount(AdminSidebarMenu, { + props: { items: visibleNavigation, activePath: '/', collapsed: false }, + global: { stubs, plugins: [router] }, + }) + + await wrapper.get('nav').trigger('click') + await flushPromises() + expect(router.currentRoute.value.path).toBe('/warehouses') + }) +}) diff --git a/src/app/components/AdminSidebarMenu.vue b/src/app/components/AdminSidebarMenu.vue new file mode 100644 index 00000000..b34e9ec6 --- /dev/null +++ b/src/app/components/AdminSidebarMenu.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/app/components/AdminSidebarMenuNode.vue b/src/app/components/AdminSidebarMenuNode.vue new file mode 100644 index 00000000..ab50c0da --- /dev/null +++ b/src/app/components/AdminSidebarMenuNode.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/app/components/WorkspaceTabs.test.ts b/src/app/components/WorkspaceTabs.test.ts new file mode 100644 index 00000000..5b663870 --- /dev/null +++ b/src/app/components/WorkspaceTabs.test.ts @@ -0,0 +1,84 @@ +import ElementPlus, { ElMessageBox } from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAuthStore } from '@/core/auth/auth' +import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' +import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs' +import WorkspaceTabs from './WorkspaceTabs.vue' + +vi.mock('element-plus', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, ElMessage: { error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } } +}) + +async function mountTabs() { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'token' + auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] } + auth.permissions = ['platform:customer:list', 'platform:warehouse:list'] + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/customers', name: 'customers', component: { template: '

' }, meta: { requiresAuth: true, title: '客户管理' } }, + { path: '/warehouses', name: 'warehouses', component: { template: '

' }, meta: { requiresAuth: true, title: '仓库管理' } }, + ], + }) + await router.push('/customers') + await router.isReady() + const workspace = useWorkspaceTabsStore() + workspace.initialize('1') + workspace.open({ id: 'warehouses', title: '仓库管理', path: '/warehouses' }) + workspace.open({ id: 'customers', title: '客户管理', path: '/customers' }) + const wrapper = mount(WorkspaceTabs, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }) + return { wrapper, router, workspace, unsaved: useUnsavedWorkStore() } +} + +describe('WorkspaceTabs', () => { + beforeEach(() => { + sessionStorage.clear() + vi.mocked(ElMessageBox.confirm).mockReset() + }) + afterEach(() => { document.body.innerHTML = '' }) + + it('keeps a dirty tab open when discard confirmation is cancelled', async () => { + const { wrapper, workspace, unsaved } = await mountTabs() + unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' }) + vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel') + + await wrapper.get('[aria-label="关闭页签:客户管理"]').trigger('click') + await flushPromises() + + expect(workspace.tabs.map((tab) => tab.id)).toContain('customers') + expect(wrapper.find('.workspace-tab.is-dirty').exists()).toBe(true) + }) + + it('closes confirmed dirty work and activates the most recent remaining tab', async () => { + const { wrapper, router, workspace, unsaved } = await mountTabs() + unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' }) + vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never) + + await wrapper.get('[aria-label="关闭页签:客户管理"]').trigger('click') + await flushPromises() + + expect(workspace.tabs.map((tab) => tab.id)).toEqual(['warehouses']) + expect(router.currentRoute.value.path).toBe('/warehouses') + expect(unsaved.findForWorkspace('customers')).toEqual([]) + }) + + it('uses the mouse wheel to scroll an overflowing tab list horizontally', async () => { + const { wrapper } = await mountTabs() + const tabList = wrapper.get('.workspace-tab-list') + Object.defineProperties(tabList.element, { + clientWidth: { configurable: true, value: 320 }, + scrollWidth: { configurable: true, value: 720 }, + }) + + await tabList.trigger('wheel', { deltaY: 120 }) + + expect(tabList.element.scrollLeft).toBe(120) + }) +}) diff --git a/src/app/components/WorkspaceTabs.vue b/src/app/components/WorkspaceTabs.vue new file mode 100644 index 00000000..1dc761e1 --- /dev/null +++ b/src/app/components/WorkspaceTabs.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/src/app/layouts/AppLayout.test.ts b/src/app/layouts/AppLayout.test.ts new file mode 100644 index 00000000..8a3f58f6 --- /dev/null +++ b/src/app/layouts/AppLayout.test.ts @@ -0,0 +1,160 @@ +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent, ref } from 'vue' +import { createMemoryHistory, createRouter } from 'vue-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ElMessageBox } from 'element-plus' +import { useAuthStore } from '@/core/auth/auth' +import AppLayout from './AppLayout.vue' + +const CachedPage = defineComponent({ + setup() { + const count = ref(0) + return { count } + }, + template: '', +}) + +vi.mock('element-plus', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } } +}) + +async function mountLayout(path = '/') { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'token' + auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] } + auth.permissions = ['platform:warehouse:list', 'platform:notification:task:list', 'platform:notification:template:list'] + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '

page

' }, meta: { requiresAuth: true, workspaceTab: false, keepAlive: false } }, + { path: '/customers', name: 'customers', component: CachedPage, meta: { title: '客户管理', showPageHeading: false, requiresAuth: true } }, + { path: '/warehouses', name: 'warehouses', component: { template: '

warehouses

' }, meta: { title: '仓库管理', requiresAuth: true } }, + { path: '/login', component: { template: '

login

' } }, + ], + }) + await router.push(path) + await router.isReady() + const wrapper = mount(AppLayout, { + global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '