Merge pull request 'Release/test 合并上生产' (#2) from release/test into main

Reviewed-on: https://git.buggoglobal.com/html/admin-web/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2026-09-17 11:50:55 +08:00
743 changed files with 92410 additions and 6250 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
.playwright-cli
dist
node_modules
node_modules.broken-by-path
.env.local
npm-debug.log*
pnpm-debug.log*
+11
View File
@@ -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
+4 -1
View File
@@ -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
+102 -36
View File
@@ -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 验收后仍由人工完成页面和截图确认。
+261
View File
@@ -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** 把核心操作放到表格末列或右侧固定列。
+37
View File
@@ -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
Vendored
+176
View File
@@ -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
'''
}
}
}
+26 -18
View File
@@ -23,11 +23,21 @@ pnpm build
pnpm test
```
## 源码结构
- `src/app/`:应用壳层、根路由和启动装配。
- `src/core/`:认证、权限、导航、未保存工作保护等全应用基础能力。
- `src/modules/`:按业务领域组织页面、接口、组件、类型和模块路由。
- `src/shared/`:不依赖具体业务的 HTTP、基础组件和工具。
- `src/prototype/`:尚未进入当前迭代的原型页面与路由,和正式业务代码隔离。
新增业务页面时,在对应 `src/modules/<domain>/` 中就近放置页面、测试和领域能力,并从模块 `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 服务时,接口仍须独立校验登录身份、角色权限和数据范围。
登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。
+22
View File
@@ -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
+20
View File
@@ -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
+19
View File
@@ -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"
+7
View File
@@ -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"
+5 -2
View File
@@ -3,8 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="虫虫出海管理后台" />
<title>虫虫出海管理后台</title>
<meta name="description" content="柏悦物流管理后台" />
<link rel="icon" type="image/png" href="/src/assets/brand/baiyue-favicon.png" />
<title>柏悦物流管理后台</title>
<script src="/runtime-config.js"></script>
</head>
<body>
<div id="app"></div>
@@ -12,3 +14,4 @@
</body>
</html>
+65
View File
@@ -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;
}
}
+5 -1
View File
@@ -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",
+21 -2
View File
@@ -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: {}
+1
View File
@@ -0,0 +1 @@
window.__CHONGCHONG_RUNTIME_CONFIG__ = window.__CHONGCHONG_RUNTIME_CONFIG__ || { clientWebUrl: '' }
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 4 ]; then
echo "用法: $0 <服务名> <镜像仓库> <镜像标签> <Compose 文件>" >&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
@@ -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)
@@ -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/)
})
@@ -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<string, unknown> = {}
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<string, unknown>).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<unknown>) {
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<typeof mount>[0], method: string, path: string, props?: Record<string, unknown>) {
const start = traces.length
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ template: '<div />' }) }] })
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<string, unknown>,
expectedParams: Record<string, unknown>,
) {
const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.exists()).toBe(true)
toolbar.vm.$emit('update:modelValue', { ...model })
await nextTick()
let start = traces.length
toolbar.vm.$emit('search', { ...model })
await waitForTrace('GET', path, start)
const searchRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
expect(searchRequests).toHaveLength(1)
expect(searchRequests[0]?.httpStatus).toBe(200)
expect(searchRequests[0]?.params).toMatchObject(expectedParams)
expect(wrapper.text()).not.toContain('加载失败')
start = traces.length
;(toolbar.vm as unknown as { reset(): void }).reset()
await waitForTrace('GET', path, start)
const resetRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
expect(resetRequests).toHaveLength(1)
expect(resetRequests[0]?.httpStatus).toBe(200)
const resetParams = resetRequests[0]?.params as Record<string, unknown> | undefined
for (const key of Object.keys(expectedParams)) expect(resetParams?.[key] == null || resetParams[key] === '').toBe(true)
}
async function pageRequestCoverage() {
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}`)
})
})
+30
View File
@@ -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)源码复用。
## 契约与验证
- 修改导出名称、字段类型、枚举值或常量语义前,搜索当前应用内全部消费者。
- 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。
- 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。
+5
View File
@@ -0,0 +1,5 @@
# 应用内共享模块
本目录存放当前前端应用多个业务模块共同使用的 TypeScript 类型、常量和轻量工具,通过 `@chongchong/shared: link:./shared` 引用。
该目录必须保留在应用内部,以支持 Admin、Client 分别使用自身目录作为容器构建上下文。它不作为 Java 服务端契约来源;接口字段、状态机、权限和业务规则以 `baiyue-firstleg` Controller、DTO 和 OpenAPI 为准。
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@chongchong/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"types": "./src/index.ts"
}
+155
View File
@@ -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<T> {
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' },
]
+15
View File
@@ -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('<ElConfigProvider :locale="zhCn">')
expect(source).toMatch(/<ElConfigProvider[\s\S]*<RouterView \/>[\s\S]*<\/ElConfigProvider>/)
})
})
+9 -3
View File
@@ -1,4 +1,10 @@
<template>
<RouterView />
</template>
<script setup lang="ts">
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<template>
<ElConfigProvider :locale="zhCn">
<RouterView />
</ElConfigProvider>
</template>
-43
View File
@@ -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',
})
})
})
-121
View File
@@ -1,121 +0,0 @@
import axios, { type InternalAxiosRequestConfig } from 'axios'
export interface ApiEnvelope<T> {
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<string>
let authRefreshHandler: AuthRefreshHandler | null = null
let activeRefresh: Promise<string> | 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<T>(response: ApiEnvelope<T>): 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
}
-117
View File
@@ -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: <T>(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('资质文件地址无效')
})
})
-136
View File
@@ -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<T>(path: string): Promise<T> {
const response = await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(path)
return unwrapApiResponse(response)
}
async function postData<T>(path: string, body: unknown): Promise<T> {
const response = await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(path, body)
return unwrapApiResponse(response)
}
export function listPlatformPermissions() {
return getData<PlatformPermissionResource[]>('/platform/v1/iam/permissions')
}
export function listEnterpriseReviews() {
return getData<EnterpriseReview[]>('/platform/v1/iam/reviews')
}
export function listTenantQualifications(tenantId: number | string) {
return getData<PlatformTenantQualification[]>(`/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<Blob, Blob>(`/platform/v1/files/qualifications/${tenantId}/${encodeURIComponent(fileName)}`, {
responseType: 'blob',
})
}
export function listPlatformRoles() {
return getData<PlatformRoleResource[]>('/platform/v1/iam/roles')
}
export function createPlatformRole(payload: CreatePlatformRoleRequest) {
return postData<PlatformRoleResource>('/platform/v1/iam/roles', payload)
}
export function createPlatformPermission(payload: CreatePlatformPermissionRequest) {
return postData<PlatformPermissionResource>('/platform/v1/iam/permissions', payload)
}
export function approveEnterpriseReview(reviewId: number, comment: string) {
return postData<unknown>(`/platform/v1/iam/reviews/${reviewId}/approve`, { comment })
}
export function rejectEnterpriseReview(reviewId: number, comment: string) {
return postData<unknown>(`/platform/v1/iam/reviews/${reviewId}/reject`, { comment })
}
@@ -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')
})
})
@@ -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)">详情</el-button>', '@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('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
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' })")
})
})
+150
View File
@@ -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: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)" @click="$emit(\'select\', \'/warehouses\')"><slot /></nav>',
}),
'el-sub-menu': {
props: ['index'],
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
},
'el-menu-item': {
props: ['index'],
template: '<a data-kind="item" :data-index="index"><slot name="title" /><slot /></a>',
},
}
describe('AdminSidebarMenu', () => {
function routerPlugin() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<p />' } },
{ path: '/warehouses', component: { template: '<p />' } },
],
})
}
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')
})
})
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter, type NavigationFailure } from 'vue-router'
import type { MenuInstance } from 'element-plus'
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
const props = defineProps<{
items: AdminNavigationNode[]
activePath: string
collapsed: boolean
}>()
const router = useRouter()
const menuRef = ref<MenuInstance>()
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
return nodes.flatMap((node) => node.children?.length
? [node.key, ...getGroupKeys(node.children)]
: [])
}
const groupKeys = computed(() => getGroupKeys(props.items))
const defaultOpeneds: string[] = []
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${groupKeys.value.join('|')}`)
async function restoreActivePath() {
await nextTick()
menuRef.value?.updateActiveIndex(props.activePath)
}
async function navigate(path: string) {
if (!path.startsWith('/') || path === props.activePath) return
let failure: NavigationFailure | void
try {
failure = await router.push(path)
} catch {
await restoreActivePath()
return
}
if (failure) await restoreActivePath()
}
watch(() => props.activePath, restoreActivePath)
</script>
<template>
<el-menu
ref="menuRef"
:key="menuInstanceKey"
class="side-nav-menu"
:default-active="activePath"
:collapse="collapsed"
:default-openeds="defaultOpeneds"
@select="navigate"
>
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" :level="0" />
</el-menu>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
defineOptions({ name: 'AdminSidebarMenuNode' })
defineProps<{
node: AdminNavigationNode
level?: number
}>()
</script>
<template>
<el-sub-menu v-if="node.children?.length" :index="node.key">
<template #title>
<component
v-if="!level"
:is="node.icon"
class="menu-icon"
:style="{ color: node.iconColor }"
:size="20"
:stroke-width="1.9"
/>
<span>{{ node.title }}</span>
</template>
<AdminSidebarMenuNode v-for="child in node.children" :key="child.key" :node="child" :level="(level ?? 0) + 1" />
</el-sub-menu>
<el-menu-item v-else :index="node.path || node.key">
<component
v-if="!level"
:is="node.icon"
class="menu-icon"
:style="{ color: node.iconColor }"
:size="20"
:stroke-width="1.9"
/>
<template #title>{{ node.title }}</template>
</el-menu-item>
</template>
+84
View File
@@ -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<typeof import('element-plus')>()
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: '<p />' }, meta: { requiresAuth: true, title: '客户管理' } },
{ path: '/warehouses', name: 'warehouses', component: { template: '<p />' }, 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<HTMLElement>('.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)
})
})
+262
View File
@@ -0,0 +1,262 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MoreHorizontal, X } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
type CloseCommand = 'current' | 'others' | 'right' | 'all'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const unsaved = useUnsavedWorkStore()
const workspace = useWorkspaceTabsStore()
const tabListRef = ref<HTMLElement>()
const activeIndex = computed(() => workspace.tabs.findIndex((tab) => tab.id === workspace.activeId))
const hasRightTabs = computed(() => activeIndex.value >= 0 && activeIndex.value < workspace.tabs.length - 1)
function blockingEntries(tabIds: readonly string[]) {
return tabIds.flatMap((tabId) => unsaved.findForWorkspace(tabId))
}
async function confirmDiscard(tabIds: readonly string[]) {
const blocking = blockingEntries(tabIds)
if (blocking.length === 0) return true
const labels = [...new Set(blocking.map((entry) => entry.label))]
try {
await ElMessageBox.confirm(
`以下内容尚未保存:${labels.join('、')}。关闭页签后将放弃这些修改。`,
'放弃未保存的修改?',
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
)
return true
} catch {
return false
}
}
async function focusActiveTab() {
await nextTick()
const active = tabListRef.value?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
active?.focus({ preventScroll: true })
active?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
}
async function navigateToFallback(path: string | null) {
const destination = path || access.firstAccessiblePath
if (!destination) {
await router.push('/403')
return
}
if (destination === route.fullPath) {
const candidate = workspaceTabCandidate(route)
if (candidate) workspace.open(candidate, unsaved.dirtyWorkspaceIds)
await focusActiveTab()
return
}
await router.push(destination)
}
async function closeTabs(tabIds: readonly string[]) {
const uniqueIds = [...new Set(tabIds)].filter((id) => workspace.tabs.some((tab) => tab.id === id))
if (uniqueIds.length === 0 || !await confirmDiscard(uniqueIds)) return
const activeWillClose = workspace.activeId !== null && uniqueIds.includes(workspace.activeId)
unsaved.removeWorkspaces(uniqueIds)
const fallback = workspace.removeByIds(uniqueIds)
if (activeWillClose) await navigateToFallback(fallback?.path ?? null)
else await focusActiveTab()
}
async function activateTab(path: string) {
if (path === route.fullPath) return
try {
await router.push(path)
} catch {
ElMessage.error('页签切换失败,请重试')
}
}
function idsForCommand(command: CloseCommand) {
if (command === 'all') return workspace.tabs.map((tab) => tab.id)
if (command === 'others') return workspace.tabs.filter((tab) => tab.id !== workspace.activeId).map((tab) => tab.id)
if (command === 'right') return workspace.tabs.slice(activeIndex.value + 1).map((tab) => tab.id)
return workspace.activeId ? [workspace.activeId] : []
}
function handleCommand(command: CloseCommand) {
void closeTabs(idsForCommand(command))
}
function handleTabKeydown(event: KeyboardEvent, index: number) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
const buttons = [...(tabListRef.value?.querySelectorAll<HTMLElement>('[role="tab"]') ?? [])]
if (buttons.length === 0) return
const direction = event.key === 'ArrowRight' ? 1 : -1
buttons[(index + direction + buttons.length) % buttons.length]?.focus()
}
function handleTabWheel(event: WheelEvent) {
const tabList = tabListRef.value
if (!tabList || tabList.scrollWidth <= tabList.clientWidth) return
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
if (delta === 0) return
const maxScrollLeft = tabList.scrollWidth - tabList.clientWidth
const canScroll = delta < 0 ? tabList.scrollLeft > 0 : tabList.scrollLeft < maxScrollLeft
if (!canScroll) return
event.preventDefault()
tabList.scrollLeft = Math.max(0, Math.min(maxScrollLeft, tabList.scrollLeft + delta))
}
watch(
() => workspace.activeId,
async () => {
await nextTick()
const active = tabListRef.value?.querySelector<HTMLElement>('[aria-selected="true"]')
active?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
},
)
</script>
<template>
<section class="workspace-tabs" aria-label="页面工作区">
<div ref="tabListRef" class="workspace-tab-list" role="tablist" aria-label="已打开页面" @wheel="handleTabWheel">
<div
v-for="(tab, index) in workspace.tabs"
:key="tab.id"
class="workspace-tab"
:class="{ 'is-active': tab.id === workspace.activeId, 'is-dirty': unsaved.dirtyWorkspaceIds.has(tab.id) }"
>
<button
class="workspace-tab-activate"
type="button"
role="tab"
:aria-selected="tab.id === workspace.activeId"
:tabindex="tab.id === workspace.activeId ? 0 : -1"
:title="tab.title"
@click="activateTab(tab.path)"
@keydown="handleTabKeydown($event, index)"
>
<span class="workspace-tab-label">{{ tab.title }}</span>
<span v-if="unsaved.dirtyWorkspaceIds.has(tab.id)" class="workspace-tab-dirty" aria-label="有未保存修改" />
</button>
<button
class="workspace-tab-close"
type="button"
:aria-label="`关闭页签:${tab.title}`"
:title="`关闭 ${tab.title}`"
@click="closeTabs([tab.id])"
><X :size="14" aria-hidden="true" /></button>
</div>
</div>
<el-dropdown trigger="click" placement="bottom-end" @command="handleCommand">
<el-button class="workspace-tab-more" text aria-label="页签管理" title="页签管理">
<MoreHorizontal :size="18" aria-hidden="true" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="current" :disabled="!workspace.activeId">关闭当前</el-dropdown-item>
<el-dropdown-item command="others" :disabled="workspace.tabs.length <= 1">关闭其他</el-dropdown-item>
<el-dropdown-item command="right" :disabled="!hasRightTabs">关闭右侧</el-dropdown-item>
<el-dropdown-item command="all" :disabled="workspace.tabs.length === 0" divided>关闭全部</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</section>
</template>
<style scoped>
.workspace-tabs {
position: sticky;
top: 64px;
z-index: 14;
display: flex;
min-width: 0;
height: 44px;
align-items: stretch;
border-bottom: 1px solid var(--cc-border);
background: #f7f9fc;
box-shadow: 0 5px 14px rgba(23, 42, 70, .045);
}
.workspace-tab-list {
display: flex;
min-width: 0;
flex: 1;
align-items: flex-end;
gap: 4px;
overflow-x: auto;
padding: 6px 8px 0;
overscroll-behavior-inline: contain;
scrollbar-width: none;
-ms-overflow-style: none;
}
.workspace-tab-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
.workspace-tab {
display: flex;
min-width: 126px;
max-width: 220px;
height: 38px;
flex: 0 0 auto;
align-items: center;
border: 1px solid transparent;
border-bottom: 0;
border-radius: 8px 8px 0 0;
color: #526178;
}
.workspace-tab:hover { background: #eef3fa; color: #24344d; }
.workspace-tab.is-active { border-color: var(--cc-border); background: #fff; color: #172033; }
.workspace-tab-activate {
display: flex;
min-width: 0;
height: 100%;
flex: 1;
align-items: center;
gap: 7px;
padding: 0 4px 0 12px;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
}
.workspace-tab-label { overflow: hidden; font-size: 12px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
.workspace-tab-dirty { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: #d97706; }
.workspace-tab-close {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 30px;
place-items: center;
margin-right: 3px;
border: 0;
border-radius: 6px;
background: transparent;
color: #7b8799;
cursor: pointer;
}
.workspace-tab-close:hover { background: #dde6f2; color: #26364d; }
.workspace-tab-activate:focus-visible,
.workspace-tab-close:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--cc-border); color: #526178; }
@media (max-width: 720px) {
.workspace-tabs { top: 64px; height: 48px; }
.workspace-tab-list { align-items: center; padding: 4px 6px; }
.workspace-tab { min-width: 140px; height: 40px; border-bottom: 1px solid transparent; border-radius: 8px; }
.workspace-tab.is-active { border-color: #cbd7e7; }
.workspace-tab-close { width: 34px; height: 34px; flex-basis: 34px; }
.workspace-tab-more { height: 48px; }
}
@media (prefers-reduced-motion: reduce) {
.workspace-tab-list { scroll-behavior: auto; }
}
</style>
+160
View File
@@ -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: '<button class="cached-count" @click="count += 1">{{ count }}</button>',
})
vi.mock('element-plus', async (importOriginal) => {
const actual = await importOriginal<typeof import('element-plus')>()
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: '<p>page</p>' }, 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: '<p>warehouses</p>' }, meta: { title: '仓库管理', requiresAuth: true } },
{ path: '/login', component: { template: '<p>login</p>' } },
],
})
await router.push(path)
await router.isReady()
const wrapper = mount(AppLayout, {
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, WorkspaceTabs: { template: '<div class="workspace-tabs-stub" />' }, 'el-dropdown': { template: '<div><slot /><slot name="dropdown" /></div>' }, 'el-dropdown-menu': { template: '<div><slot /></div>' }, 'el-dropdown-item': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
})
return { wrapper, router }
}
function mockViewport(isMobile: boolean) {
const mediaQuery = {
matches: isMobile,
media: '(max-width: 720px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
} as unknown as MediaQueryList
vi.stubGlobal('matchMedia', vi.fn(() => mediaQuery))
}
describe('AppLayout', () => {
beforeEach(() => {
window.localStorage.clear()
vi.mocked(ElMessageBox.confirm).mockReset()
mockViewport(false)
})
afterEach(() => vi.unstubAllGlobals())
it('reports the desktop navigation expanded and collapsed states', async () => {
const { wrapper } = await mountLayout()
const menuButton = wrapper.get('.shell-menu-button')
expect(menuButton.attributes('aria-label')).toBe('收起导航')
expect(menuButton.attributes('aria-expanded')).toBe('true')
await menuButton.trigger('click')
expect(menuButton.attributes('aria-label')).toBe('展开导航')
expect(menuButton.attributes('aria-expanded')).toBe('false')
})
it('reports the mobile menu closed and open states', async () => {
mockViewport(true)
const { wrapper } = await mountLayout()
const menuButton = wrapper.get('.shell-menu-button')
expect(menuButton.attributes('aria-label')).toBe('打开菜单')
expect(menuButton.attributes('aria-expanded')).toBe('false')
await menuButton.trigger('click')
expect(menuButton.attributes('aria-label')).toBe('关闭菜单')
expect(menuButton.attributes('aria-expanded')).toBe('true')
})
it('closes the mobile menu when returning to the desktop breakpoint and removes the listener', async () => {
mockViewport(true)
const { wrapper } = await mountLayout()
const menuButton = wrapper.get('.shell-menu-button')
const mediaQuery = vi.mocked(window.matchMedia).mock.results[0]?.value as MediaQueryList
const changeHandler = vi.mocked(mediaQuery.addEventListener).mock.calls[0]?.[1] as ((event: MediaQueryListEvent) => void)
await menuButton.trigger('click')
expect(menuButton.attributes('aria-expanded')).toBe('true')
changeHandler({ matches: false } as MediaQueryListEvent)
await wrapper.vm.$nextTick()
expect(menuButton.attributes('aria-label')).toBe('收起导航')
expect(menuButton.attributes('aria-expanded')).toBe('true')
wrapper.unmount()
expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', changeHandler)
})
it('shows the authenticated identity and the localhost full-access control', async () => {
const { wrapper } = await mountLayout()
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
const accessToggle = wrapper.get('[role="switch"]')
expect(accessToggle.text()).toContain('本地全权限')
expect(accessToggle.text()).toContain('已关闭')
expect(accessToggle.attributes('aria-checked')).toBe('false')
await accessToggle.trigger('click')
expect(accessToggle.text()).toContain('已开启')
expect(accessToggle.attributes('aria-checked')).toBe('true')
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('消息管理')
expect(wrapper.text()).toContain('模板管理')
expect(wrapper.text()).toContain('我的消息')
const notificationTrigger = wrapper.get('.notification-trigger')
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
})
it('hides the shell heading when the route opts out', async () => {
const { wrapper } = await mountLayout('/customers')
expect(wrapper.find('.page-heading').exists()).toBe(false)
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
})
it('logs out after confirmation', async () => {
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
const { wrapper, router } = await mountLayout()
await wrapper.get('[aria-label="退出登录"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
})
it('opens business routes as workspace tabs and keeps route component state while switching', async () => {
const { wrapper, router } = await mountLayout('/customers')
expect(wrapper.find('.workspace-tabs-stub').exists()).toBe(true)
await wrapper.get('.cached-count').trigger('click')
expect(wrapper.get('.cached-count').text()).toBe('1')
await router.push('/warehouses')
await flushPromises()
await router.push('/customers')
await flushPromises()
expect(wrapper.get('.cached-count').text()).toBe('1')
})
})
+222
View File
@@ -0,0 +1,222 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, FileText, LogOut, Mail, Menu, ShieldCheck } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
import AdminSidebarMenu from '@/app/components/AdminSidebarMenu.vue'
import WorkspaceTabs from '@/app/components/WorkspaceTabs.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { canAccessWorkspaceRoute, workspaceRouteId, workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
import { MAX_CACHED_WORKSPACE_VIEWS, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const access = useAccessPreviewStore()
const unsaved = useUnsavedWorkStore()
const workspace = useWorkspaceTabsStore()
const mobileViewportQuery = '(max-width: 720px)'
const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const isMobileViewport = ref(false)
let mobileViewportMedia: MediaQueryList | undefined
const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
const menuButtonLabel = computed(() => {
if (isMobileViewport.value) return mobileMenuOpen.value ? '关闭菜单' : '打开菜单'
return collapsed.value ? '展开导航' : '收起导航'
})
const menuExpanded = computed(() => (
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
))
const canOpenNotificationManagement = computed(() => (
access.can('platform:notification:view')
|| access.can('platform:notification:task:list')
|| access.can('platform:notification:template:list')
))
access.initializeAfterLogin()
workspace.initialize(auth.user?.id || auth.user?.account || '')
function syncCurrentRoute() {
const candidate = workspaceTabCandidate(route)
if (candidate) workspace.open(candidate, unsaved.dirtyWorkspaceIds)
}
async function pruneInaccessibleTabs() {
const inaccessible = workspace.tabs.filter((tab) => !canAccessWorkspaceRoute(router.resolve(tab.path), access.permissionCodes))
const discardedDirtyWork = inaccessible.flatMap((tab) => unsaved.findForWorkspace(tab.id))
if (discardedDirtyWork.length) {
await ElMessageBox.alert(
`账号权限已变化,以下未保存内容无法继续编辑:${[...new Set(discardedDirtyWork.map((entry) => entry.label))].join('、')}。相关页签将关闭。`,
'权限已变化',
{ confirmButtonText: '知道了', type: 'warning', customClass: 'admin-density-confirm' },
).catch(() => undefined)
}
const previousActiveId = workspace.activeId
const result = workspace.prune((tab) => canAccessWorkspaceRoute(router.resolve(tab.path), access.permissionCodes))
if (result.removedIds.length === 0) return
unsaved.removeWorkspaces(result.removedIds)
if (!previousActiveId || !result.removedIds.includes(previousActiveId)) return
const destination = result.fallback?.path || access.firstAccessiblePath || '/403'
if (destination !== route.fullPath) await router.replace(destination)
else syncCurrentRoute()
}
async function handleLogout() {
try {
const dirtyLabels = unsaved.entries.filter((entry) => entry.dirty).map((entry) => entry.label)
const dirtyNotice = dirtyLabels.length ? ` 当前还有未保存内容:${[...new Set(dirtyLabels)].join('、')},退出后将丢失。` : ''
await ElMessageBox.confirm(`确定退出管理后台吗?${dirtyNotice}`, '退出登录', {
confirmButtonText: '退出',
customClass: 'admin-density-confirm',
cancelButtonText: '取消',
})
} catch {
return
}
access.reset()
unsaved.clear()
workspace.reset(true)
try {
await auth.logout()
} catch (error) {
const details = getApiErrorDetails(error, '服务器会话撤销失败')
const trackingId = details.requestId || details.traceId
ElMessage.warning(`本地已退出,但服务器会话撤销失败${trackingId ? `(追踪标识:${trackingId})` : ''}`)
}
try {
await router.replace('/login')
} catch {
ElMessage.error('已退出登录,但登录页跳转失败,请刷新页面')
}
}
function handleMenuToggle() {
if (isMobileViewport.value) {
mobileMenuOpen.value = !mobileMenuOpen.value
return
}
collapsed.value = !collapsed.value
}
function syncMobileViewport(event: MediaQueryList | MediaQueryListEvent) {
isMobileViewport.value = event.matches
if (!event.matches) mobileMenuOpen.value = false
}
function openNotificationPage(path: string) { void router.push(path) }
onMounted(() => {
mobileViewportMedia = window.matchMedia(mobileViewportQuery)
syncMobileViewport(mobileViewportMedia)
mobileViewportMedia.addEventListener('change', syncMobileViewport)
})
onBeforeUnmount(() => mobileViewportMedia?.removeEventListener('change', syncMobileViewport))
watch(() => route.fullPath, () => {
mobileMenuOpen.value = false
syncCurrentRoute()
}, { immediate: true })
watch(() => access.permissionSignature, () => { void pruneInaccessibleTabs() }, { immediate: true })
watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId) => {
if (!userId || userId === previousUserId) return
workspace.reset(false)
workspace.initialize(userId)
void pruneInaccessibleTabs().then(syncCurrentRoute)
})
</script>
<template>
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
<aside class="app-sidebar">
<div class="brand-block">
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
</div>
<nav class="side-nav" aria-label="主导航">
<AdminSidebarMenu :items="access.filteredNavigation" :active-path="route.path" :collapsed="menuCollapsed" />
</nav>
</aside>
<div class="app-main">
<header class="app-header" :class="{ 'app-header--without-page-heading': !showPageHeading }">
<button
class="shell-menu-button"
type="button"
:aria-label="menuButtonLabel"
:aria-expanded="menuExpanded"
@click="handleMenuToggle"
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading">
<!-- <h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p> -->
</div>
<div class="header-user">
<button
v-if="access.localFullAccessAvailable"
class="local-full-access-toggle"
type="button"
role="switch"
:aria-checked="access.localFullAccessEnabled"
:title="access.localFullAccessEnabled ? '关闭后恢复服务端权限' : '开启后显示全部前端页面和按钮'"
@click="access.toggleLocalFullAccess"
>
<ShieldCheck :size="17" />
<span>本地全权限</span>
<strong>{{ access.localFullAccessEnabled ? '已开启' : '已关闭' }}</strong>
</button>
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
<button class="notification-trigger" type="button" aria-label="消息中心">
<Bell :size="18" /><span>消息中心</span>
</button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
</div>
</header>
<WorkspaceTabs v-if="workspace.tabs.length" />
<main class="page-container">
<RouterView v-slot="{ Component, route: renderedRoute }">
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
<component
:is="Component"
v-if="renderedRoute.meta.keepAlive !== false"
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
/>
</KeepAlive>
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
</RouterView>
</main>
</div>
</div>
</template>
<style scoped>
.app-header--without-page-heading .header-user { margin-left: auto; }
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid rgba(255, 255, 255, .3); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
.local-full-access-toggle:hover { border-color: rgba(255, 255, 255, .55); background: rgba(255, 255, 255, .22); }
.local-full-access-toggle:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.local-full-access-toggle[aria-checked="true"] { border-color: #86efac; background: rgba(22, 163, 74, .3); }
.local-full-access-toggle strong { color: #dcfce7; font-size: 12px; }
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid rgba(255, 255, 255, .28); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease, transform .2s ease; }
.notification-trigger:hover { border-color: rgba(255, 255, 255, .48); background: rgba(255, 255, 255, .24); transform: translateY(-1px); }
.notification-trigger:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.notification-trigger > svg { flex: 0 0 auto; }
@media (max-width: 720px) { .local-full-access-toggle > span { display: none; } .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
</style>
+19
View File
@@ -0,0 +1,19 @@
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import ForbiddenView from './ForbiddenView.vue'
describe('ForbiddenView', () => {
it('shows server-account guidance and sanitizes the source path', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/403', component: ForbiddenView }] })
await router.push({ path: '/403', query: { from: '//example.com' } })
await router.isReady()
const wrapper = mount(ForbiddenView, { global: { plugins: [pinia, router] } })
expect(wrapper.text()).toContain('当前登录账号无法访问此页面')
expect(wrapper.text()).toContain('来源页面:/')
expect(wrapper.text()).not.toContain('权限预览')
})
})
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ShieldX } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const sourcePath = computed(() => sanitizeInternalPath(route.query.from))
async function returnToAccessiblePage() {
if (access.firstAccessiblePath) await router.replace(access.firstAccessiblePath)
}
</script>
<template>
<section class="forbidden-page" aria-labelledby="forbidden-title">
<div class="forbidden-icon" aria-hidden="true"><ShieldX :size="32" /></div>
<p class="section-kicker">403 · 权限不足</p>
<h2 id="forbidden-title">当前登录账号无法访问此页面</h2>
<p>请联系平台管理员核对账号角色和权限配置。</p>
<p class="forbidden-source">来源页面:{{ sourcePath }}</p>
<el-button v-if="access.firstAccessiblePath" type="primary" @click="returnToAccessiblePage">返回可访问页面</el-button>
<p v-else>当前账号暂无可访问的业务菜单。</p>
</section>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { Cable } from '@lucide/vue'
defineProps<{
moduleName: string
description: string
}>()
</script>
<template>
<section class="surface-panel unavailable-panel" role="status" aria-live="polite">
<Cable :size="30" aria-hidden="true" />
<p class="section-kicker">接口尚未开放</p>
<h2>{{ moduleName }}暂不可用</h2>
<p>{{ description }}</p>
<p class="secondary-text">为避免产生误导,本页面不会展示演示数据,也不会模拟新增、编辑或导入成功。</p>
</section>
</template>
<style scoped>
.unavailable-panel {
display: grid;
justify-items: start;
gap: 10px;
max-width: 720px;
padding: 32px;
}
.unavailable-panel h2,
.unavailable-panel p {
margin: 0;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest'
import { adminNavigation, getNavigationLeaves } from '@/core/navigation/adminNavigation'
import router from './index'
describe('admin router access metadata', () => {
it('uses the root route as a permission-aware entry point and registers the formal dashboard', () => {
const home = router.getRoutes().find((route) => route.name === 'home')
expect(home?.path).toBe('/')
expect(home?.meta.permission).toBeUndefined()
const dashboard = router.getRoutes().find((route) => route.name === 'dashboard')
expect(dashboard?.path).toBe('/dashboard')
expect(dashboard?.meta.permission).toBeUndefined()
})
it('protects warehouse management with its stable list permission', () => {
const warehouseRoute = router.getRoutes().find((route) => route.name === 'warehouses')
expect(warehouseRoute?.path).toBe('/warehouses')
expect(warehouseRoute?.meta.permission).toBe('platform:warehouse:list')
})
it('protects phase-two inbound operations and master data with deployed permissions', () => {
const expected = [
['inbound-orders', '/inbounds', 'platform:firstleg:inbound:list'],
['fba-carrier-rates', '/first-leg-pricing/carrier-rates', 'firstleg:fba-pricing:rate:view'],
['locations', '/master-data/locations', 'platform:location:list'],
['pallets', '/master-data/pallets', 'platform:pallet:list'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
})
it('does not register removed duplicate pricing and FBA placeholder pages', () => {
const removedRouteNames = ['inbound-order-detail', 'fba-customer-rules', 'general-fba', 'general-voyage']
for (const name of removedRouteNames) {
expect(router.getRoutes().some((route) => route.name === name)).toBe(false)
}
})
it('protects express company records with a stable master-data permission', () => {
const route = router.getRoutes().find((item) => item.name === 'express-companies')
expect(route?.path).toBe('/master-data/express-companies')
expect(route?.meta.permission).toBe('platform:express:list')
})
it('keeps smart loading as an order dialog while retaining the single-container deep link', () => {
expect(router.getRoutes().some((item) => item.name === 'global-smart-loading-workbench')).toBe(false)
const detail = router.getRoutes().find((item) => item.name === 'smart-loading-workbench')
expect(detail?.path).toBe('/container-loading/domestic/:containerId/smart-loading')
expect(detail?.meta.permission).toBe('platform:firstleg:smart-loading:list')
})
it('registers the retained phase-three service center routes with stable permissions', () => {
const expected = [
['ocean-bookings', '/ocean-bookings', 'platform:firstleg:ocean-booking:list'],
['trucking', '/trucking', 'platform:firstleg:truck-appointment:list'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
expect(router.getRoutes().find((item) => item.name === 'port-schedules')).toBeUndefined()
expect(router.getRoutes().find((item) => item.name === 'outbound-dispatches')).toBeUndefined()
})
it('protects counterparty records with a stable master-data permission', () => {
const route = router.getRoutes().find((item) => item.name === 'counterparties')
expect(route?.path).toBe('/master-data/counterparties')
expect(route?.meta.permission).toBe('platform:counterparty:list')
})
it('registers the confirmed phase-four routes with stable list permissions', () => {
const expected = [
['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:overseas-warehouse:last-mile-order:list'],
['overseas-inbound-orders', '/overseas-warehouse/inbound-orders', 'platform:overseas-warehouse:inbound-order:list'],
['overseas-dispatches', '/overseas-warehouse/dispatches', 'platform:overseas-warehouse:dispatch:list'],
['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:overseas-warehouse:relabel-order:list'],
['overseas-pallets', '/overseas-warehouse/pallets', 'platform:overseas-warehouse:pallet:list'],
['overseas-inventory', '/overseas-warehouse/inventory', 'platform:overseas-warehouse:inventory:list'],
['overseas-business-purchases', '/overseas-business/purchases', 'platform:overseas-business:purchase:list'],
['overseas-business-trucks', '/overseas-business/trucks', 'platform:overseas-business:truck:list'],
['overseas-business-looseboards', '/overseas-business/looseboards', 'platform:overseas-business:looseboard:list'],
['overseas-master-data-quotes', '/overseas-master-data/quotes', 'platform:overseas-master-data:quote:list'],
['overseas-master-data-exchange-rates', '/overseas-master-data/exchange-rates', 'platform:overseas-master-data:exchange-rate:list'],
['overseas-master-data-vehicles', '/overseas-master-data/vehicles', 'platform:overseas-master-data:vehicle:list'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
})
it('protects the download center with its stable list permission', () => {
const route = router.getRoutes().find((item) => item.name === 'download-center')
expect(route?.path).toBe('/downloads')
expect(route?.meta.permission).toBe('platform:download:list')
})
it('registers message-center routes with the frozen IAM permissions', () => {
const expected = [
['notification-inbox', '/notifications', 'platform:notification:view'],
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
expect(router.getRoutes().find((item) => item.name === 'my-notifications')).toBeUndefined()
})
it('redirects the removed enterprise review route to the protected customer list', () => {
const route = router.getRoutes().find((item) => item.name === 'enterprise-reviews')
expect(route?.path).toBe('/customers/reviews')
expect(route?.redirect).toBe('/customers')
expect(route?.meta.permission).toBe('platform:customer:list')
expect(route?.meta.permissionAnyOf).toContain('platform:customer:review')
})
it('keeps forbidden inside the authenticated layout without a business permission', () => {
const authenticatedLayout = router.options.routes.find((route) => route.path === '/')
const forbidden = authenticatedLayout?.children?.find((route) => route.name === 'forbidden')
const resolved = router.resolve('/403')
expect(authenticatedLayout?.meta?.requiresAuth).toBe(true)
expect(forbidden?.path).toBe('403')
expect(forbidden?.meta?.permission).toBeUndefined()
expect(resolved.matched.some((record) => record.meta.requiresAuth)).toBe(true)
})
it('keeps every registered navigation path and permission aligned with routes', () => {
for (const item of getNavigationLeaves(adminNavigation)) {
const resolved = router.resolve(item.path)
const pageRoute = resolved.matched.at(-1)
expect(pageRoute, `missing route for ${item.path}`).toBeDefined()
expect(pageRoute?.meta.permission, `permission mismatch for ${item.path}`).toBe(item.permission)
}
})
})
+158
View File
@@ -0,0 +1,158 @@
import '@/styles/adminDensity.css'
import { createRouter, createWebHistory } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import AppLayout from '@/app/layouts/AppLayout.vue'
import { decideRouteAccess } from '@/core/routing/accessDecision'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { downloadRoutes } from '@/modules/downloads/routes'
import { inboundRoutes } from '@/modules/inbounds/routes'
import { unifiedOrderRoutes } from '@/modules/unified-orders/routes'
import { fbaPricingRoutes } from '@/modules/fba-pricing/routes'
import { iamRoutes } from '@/modules/iam/routes'
import { masterDataRoutes } from '@/modules/master-data/routes'
import { warehouseResourceRoutes } from '@/modules/warehouse-resources/routes'
import { useAuthStore } from '@/core/auth/auth'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { canAccessWorkspaceRoute, isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace'
import { readStoredActiveWorkspacePath, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import { customerRoutes } from '@/modules/customers/routes'
import { dashboardRoutes } from '@/modules/dashboard/routes'
import { warehouseRoutes } from '@/modules/warehouses/routes'
import { prototypeModuleRoutes } from '@/prototype/routes'
import { oceanBookingRoutes } from '@/modules/ocean-bookings/routes'
import { truckingRoutes } from '@/modules/trucking/routes'
import { feeItemRoutes } from '@/modules/fee-items/routes'
import { containerLoadingRoutes } from '@/modules/container-loading/routes'
import { loadingListRoutes } from '@/modules/loading-lists/routes'
import { financeRoutes } from '@/modules/finance/routes'
import { notificationRoutes } from '@/modules/notifications/routes'
import { domesticContainerRoutes } from '@/modules/domestic-containers/routes'
import { supplierRoutes } from '@/modules/suppliers/routes'
import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/modules/auth/pages/LoginView.vue'),
meta: { title: '登录' },
},
{
path: '/',
component: AppLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'home',
component: () => import('@/app/pages/ModuleView.vue'),
meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面', workspaceTab: false, keepAlive: false },
},
...dashboardRoutes,
...prototypeModuleRoutes,
...oceanBookingRoutes,
...truckingRoutes,
...containerLoadingRoutes,
...loadingListRoutes,
...financeRoutes,
...notificationRoutes,
...domesticContainerRoutes,
...supplierRoutes,
...overseasWarehouseRoutes,
...overseasBusinessRoutes,
...overseasMasterDataRoutes,
...customerRoutes,
...warehouseRoutes,
...unifiedOrderRoutes,
...inboundRoutes,
...fbaPricingRoutes,
...warehouseResourceRoutes,
...feeItemRoutes,
...masterDataRoutes,
...downloadRoutes,
...iamRoutes,
{
path: '403',
name: 'forbidden',
component: () => import('@/app/pages/ForbiddenView.vue'),
meta: { title: '权限不足', description: '当前登录账号没有访问此页面的权限', workspaceTab: false, keepAlive: false },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach(async (to, from) => {
const fromId = workspaceRouteId(from)
if (!fromId || to.fullPath === from.fullPath) return true
const isNonDestructiveWorkspaceSwitch = isWorkspaceRoute(to) && workspaceRouteId(to) !== fromId
if (isNonDestructiveWorkspaceSwitch) return true
const unsaved = useUnsavedWorkStore()
const blocking = unsaved.findForWorkspace(fromId)
if (blocking.length === 0) return true
try {
await ElMessageBox.confirm(
`当前页面还有未保存内容:${[...new Set(blocking.map((entry) => entry.label))].join('、')}。继续后将放弃这些修改。`,
'放弃未保存的修改?',
{ confirmButtonText: '放弃并继续', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
)
unsaved.removeWorkspaces([fromId])
useWorkspaceTabsStore().bumpCacheEpoch(fromId)
return true
} catch {
return false
}
})
router.beforeEach((to) => {
document.title = to.meta.title ? `${to.meta.title} - 柏悦物流管理后台` : '柏悦物流管理后台'
const auth = useAuthStore()
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'home' }
}
if (!auth.isAuthenticated && !to.meta.requiresAuth) return true
const access = useAccessPreviewStore()
if (auth.isAuthenticated) access.initializeAfterLogin()
const alternativePermissions = to.meta.permissionAnyOf ?? []
const requiresRoutePermission = Boolean(to.meta.permission || alternativePermissions.length)
const hasRoutePermission = !requiresRoutePermission || Boolean(
(to.meta.permission && access.can(to.meta.permission))
|| alternativePermissions.some((permission) => access.can(permission)),
)
const decision = decideRouteAccess({
requiresAuth: to.meta.requiresAuth,
isAuthenticated: auth.isAuthenticated,
fullPath: to.fullPath,
permission: to.meta.permission ?? alternativePermissions[0],
hasPermission: hasRoutePermission,
})
if (decision.type === 'login') {
return { name: 'login', query: { redirect: decision.redirect } }
}
if (decision.type === 'forbidden') {
return { name: 'forbidden', query: { from: decision.from }, replace: true }
}
if (to.name === 'home') {
const restoredPath = auth.user?.id ? readStoredActiveWorkspacePath(auth.user.id) : null
if (restoredPath) {
const restoredRoute = router.resolve(restoredPath)
if (canAccessWorkspaceRoute(restoredRoute, access.permissionCodes)) {
return { path: restoredPath, replace: true }
}
}
return access.firstAccessiblePath
? { path: access.firstAccessiblePath, replace: true }
: { name: 'forbidden', replace: true }
}
return true
})
export default router
+2
View File
@@ -4,7 +4,9 @@ declare module 'vue-router' {
interface RouteMeta {
title?: string
description?: string
showPageHeading?: boolean
requiresAuth?: boolean
permission?: string
permissionAnyOf?: string[]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

-10
View File
@@ -1,10 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}
-58
View File
@@ -1,58 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AdminSidebarMenu: typeof import('./components/AdminSidebarMenu.vue')['default']
AdminSidebarMenuNode: typeof import('./components/AdminSidebarMenuNode.vue')['default']
ApiErrorMeta: typeof import('./components/ApiErrorMeta.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTree: typeof import('element-plus/es')['ElTree']
MetricStrip: typeof import('./components/MetricStrip.vue')['default']
RolePreviewSwitcher: typeof import('./components/RolePreviewSwitcher.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StaticBatchImportDialog: typeof import('./components/StaticBatchImportDialog.vue')['default']
StatusBadge: typeof import('./components/StatusBadge.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
-93
View File
@@ -1,93 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/navigation/adminNavigation'
import AdminSidebarMenu from './AdminSidebarMenu.vue'
const stubs = {
'el-menu': {
props: ['defaultActive', 'collapse'],
template: '<nav :data-active="defaultActive" :data-collapsed="String(collapse)"><slot /></nav>',
},
'el-sub-menu': {
props: ['index'],
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
},
'el-menu-item': {
props: ['index'],
template: '<a data-kind="item" :data-index="index"><slot name="title" /><slot /></a>',
},
}
describe('AdminSidebarMenu', () => {
it('renders grouped page entries and forwards the active route', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: adminNavigation, activePath: '/system/users', collapsed: false },
global: { stubs },
})
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
expect(wrapper.findAll('[data-kind="group"] header').map((node) => node.text())).toEqual([
'业务管理',
'基础资料',
'系统管理',
])
expect(wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))).toEqual([
'/customers',
'/customers/reviews',
'/warehouses',
'/orders/first-leg',
'/master-data/express-companies',
'/master-data/counterparties',
'/system/users',
'/system/roles',
'/system/permissions',
])
})
it('forwards desktop collapse state to Element Plus', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: adminNavigation, activePath: '/', collapsed: true },
global: { stubs },
})
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 },
})
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 },
})
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
expect(wrapper.get('[data-kind="item"]').attributes('data-index')).toBe('/tracking')
})
})
-31
View File
@@ -1,31 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AdminNavigationNode } from '@/navigation/adminNavigation'
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
const props = defineProps<{
items: AdminNavigationNode[]
activePath: string
collapsed: boolean
}>()
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
return nodes.flatMap((node) => node.children?.length
? [node.key, ...getGroupKeys(node.children)]
: [])
}
const defaultOpeneds = computed(() => getGroupKeys(props.items))
</script>
<template>
<el-menu
class="side-nav-menu"
:default-active="activePath"
:collapse="collapsed"
:default-openeds="defaultOpeneds"
router
>
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" />
</el-menu>
</template>
-25
View File
@@ -1,25 +0,0 @@
<script setup lang="ts">
import type { AdminNavigationNode } from '@/navigation/adminNavigation'
defineOptions({ name: 'AdminSidebarMenuNode' })
defineProps<{
node: AdminNavigationNode
}>()
</script>
<template>
<el-sub-menu v-if="node.children?.length" :index="node.key">
<template #title>
<component :is="node.icon" :size="19" :stroke-width="1.8" />
<span>{{ node.title }}</span>
</template>
<AdminSidebarMenuNode v-for="child in node.children" :key="child.key" :node="child" />
</el-sub-menu>
<el-menu-item v-else :index="node.path || node.key">
<component :is="node.icon" :size="19" :stroke-width="1.8" />
<template #title>{{ node.title }}</template>
</el-menu-item>
</template>
-38
View File
@@ -1,38 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { Copy } from '@lucide/vue'
import type { ApiErrorDetails } from '@/api/http'
const props = defineProps<{ details: ApiErrorDetails }>()
async function copyIdentifier(value: string, label: string) {
try {
await navigator.clipboard.writeText(value)
ElMessage.success(`${label} 已复制`)
} catch {
ElMessage.error(`${label} 复制失败,请手动选择复制`)
}
}
</script>
<template>
<div v-if="details.code || details.requestId || details.traceId" class="api-error-meta" aria-label="接口错误追踪信息">
<span v-if="details.code" class="api-error-code">{{ details.code }}</span>
<button v-if="details.requestId" type="button" :title="`复制请求 ID:${props.details.requestId}`" @click="copyIdentifier(props.details.requestId!, '请求 ID')">
<span>请求 ID</span><code>{{ props.details.requestId }}</code><Copy :size="13" />
</button>
<button v-if="details.traceId" type="button" :title="`复制 Trace ID:${props.details.traceId}`" @click="copyIdentifier(props.details.traceId!, 'Trace ID')">
<span>Trace ID</span><code>{{ props.details.traceId }}</code><Copy :size="13" />
</button>
</div>
</template>
<style scoped>
.api-error-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
.api-error-code { padding: 3px 7px; border-radius: 4px; background: #fee4e2; color: #912018; font-size: 10px; font-weight: 750; }
.api-error-meta button { display: inline-flex; min-width: 0; max-width: 100%; min-height: 30px; align-items: center; gap: 6px; padding: 4px 7px; border: 1px solid #fecdca; border-radius: 4px; background: #fff; color: #7a271a; cursor: pointer; }
.api-error-meta button:hover { background: #fff5f4; }
.api-error-meta button span { flex: 0 0 auto; font-size: 10px; font-weight: 700; }
.api-error-meta code { min-width: 0; overflow: hidden; color: #b42318; font-family: "Cascadia Code", Consolas, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.api-error-meta svg { flex: 0 0 auto; }
</style>
@@ -1,63 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { Role } from '@/types/admin'
import RolePreviewSwitcher from './RolePreviewSwitcher.vue'
const roles: Role[] = [
{ id: 'R001', name: '超级管理员', code: 'SUPER_ADMIN', description: '', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: [] },
{ id: 'R003', name: '操作员', code: 'OPERATOR', description: '', userCount: 2, dataScope: '本部门数据', status: '启用', permissionIds: [] },
]
const stubs = {
'el-dropdown': {
name: 'ElDropdownStub',
emits: ['command'],
template: '<section data-dropdown><slot /><slot name="dropdown" /></section>',
},
'el-dropdown-menu': { template: '<div data-menu><slot /></div>' },
'el-dropdown-item': {
props: ['command', 'disabled'],
template: '<button data-role :data-command="command" :disabled="disabled"><slot /></button>',
},
}
describe('RolePreviewSwitcher', () => {
it('announces the active role and explains the static preview boundary', () => {
const wrapper = mount(RolePreviewSwitcher, {
props: { roles, currentRole: roles[0] },
global: { stubs },
})
const trigger = wrapper.get('button[aria-label]')
expect(trigger.attributes('aria-label')).toBe('权限预览:超级管理员,数据范围未模拟')
expect(trigger.attributes('title')).toBe('权限预览:超级管理员,数据范围未模拟')
expect(wrapper.text()).toContain('权限预览:超级管理员')
expect(wrapper.text()).toContain('仅预览菜单、页面和操作;数据范围未模拟')
expect(wrapper.find('svg.lucide-user-round-cog').exists()).toBe(true)
})
it('disables the current role and emits a string role id for another selection', async () => {
const wrapper = mount(RolePreviewSwitcher, {
props: { roles, currentRole: roles[0] },
global: { stubs },
})
const items = wrapper.findAll('[data-role]')
expect(items[0].attributes('disabled')).toBeDefined()
expect(items[1].attributes('disabled')).toBeUndefined()
wrapper.getComponent({ name: 'ElDropdownStub' }).vm.$emit('command', 3)
expect(wrapper.emitted('select')).toEqual([['3']])
})
it('labels the trigger when no enabled role is available', () => {
const wrapper = mount(RolePreviewSwitcher, {
props: { roles: [], currentRole: null },
global: { stubs },
})
expect(wrapper.get('button[aria-label]').attributes('aria-label'))
.toBe('权限预览:无可用角色,数据范围未模拟')
})
})
-52
View File
@@ -1,52 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronDown, UserRoundCog } from '@lucide/vue'
import type { Role } from '@/types/admin'
const props = defineProps<{
roles: Role[]
currentRole: Role | null
}>()
const emit = defineEmits<{
select: [roleId: string]
}>()
const roleName = computed(() => props.currentRole?.name ?? '无可用角色')
const accessibleLabel = computed(() => `权限预览:${roleName.value},数据范围未模拟`)
function handleCommand(command: string | number) {
emit('select', String(command))
}
</script>
<template>
<el-dropdown :disabled="roles.length === 0" trigger="click" @command="handleCommand">
<button
class="role-preview-trigger"
type="button"
:aria-label="accessibleLabel"
:title="accessibleLabel"
>
<UserRoundCog :size="18" aria-hidden="true" />
<span class="role-preview-copy">
<strong>权限预览:{{ roleName }}</strong>
<small>仅预览菜单、页面和操作;数据范围未模拟</small>
</span>
<ChevronDown :size="15" aria-hidden="true" />
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="role in roles"
:key="role.id"
:command="role.id"
:disabled="role.id === currentRole?.id"
>
{{ role.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
@@ -1,19 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync('src/components/StaticBatchImportDialog.vue', 'utf8')
describe('StaticBatchImportDialog source contract', () => {
it('only performs safe client-side file checks before the backend endpoint exists', () => {
expect(source).toContain("fileName.endsWith('.xlsx')")
expect(source).toContain('10 * 1024 * 1024')
expect(source).toContain('前端不会解析工作表内容')
expect(source).toContain('等待后端导入接口')
})
it('does not simulate server validation or a successful import', () => {
expect(source).not.toContain('demonstratesFailure')
expect(source).not.toContain('静态演示')
expect(source).not.toContain("emit('imported'")
})
})
-118
View File
@@ -1,118 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Download, FileCheck2, FileSpreadsheet, Upload, XCircle } from '@lucide/vue'
const props = defineProps<{
modelValue: boolean
entityLabel: string
templateUrl: string
templateFileName: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
type ValidationState = 'idle' | 'pending' | 'ready' | 'invalid'
const inputRef = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const validationState = ref<ValidationState>('idle')
const validationMessage = ref('')
const dialogOpen = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
watch(() => props.modelValue, (open) => {
if (open) return
selectedFile.value = null
validationState.value = 'idle'
validationMessage.value = ''
})
function chooseFile() {
inputRef.value?.click()
}
function handleFile(event: Event) {
const input = event.target as HTMLInputElement
selectedFile.value = input.files?.[0] ?? null
validationState.value = selectedFile.value ? 'pending' : 'idle'
validationMessage.value = ''
input.value = ''
}
function validateFile() {
const file = selectedFile.value
if (!file) return
const fileName = file.name.toLowerCase()
if (!fileName.endsWith('.xlsx')) {
validationMessage.value = '仅支持标准模板 .xlsx 文件'
validationState.value = 'invalid'
return
}
if (file.size > 10 * 1024 * 1024) {
validationMessage.value = '文件超过上传上限 10MB'
validationState.value = 'invalid'
return
}
validationMessage.value = '扩展名和文件大小符合上传要求'
validationState.value = 'ready'
}
</script>
<template>
<el-dialog v-model="dialogOpen" :title="`${entityLabel}批量导入`" width="min(680px, 94vw)" class="batch-import-dialog" :close-on-click-modal="false">
<div class="import-guidance">
<FileSpreadsheet :size="22" />
<div><strong>只新增,不更新或覆盖已有档案</strong><span>前端只检查文件类型和大小;模板内容、重复数据及整批事务由后端校验。</span></div>
<a :href="templateUrl" :download="templateFileName"><Download :size="15" />下载标准模板</a>
</div>
<input ref="inputRef" class="visually-hidden" type="file" accept=".xlsx" @change="handleFile" />
<button type="button" class="import-dropzone" @click="chooseFile">
<Upload :size="24" />
<strong>{{ selectedFile ? selectedFile.name : '选择 .xlsx 导入文件' }}</strong>
<span>{{ selectedFile ? `${(selectedFile.size / 1024).toFixed(1)} KB · 点击重新选择` : '仅支持 .xlsx,最大 10MB' }}</span>
</button>
<div v-if="validationState === 'pending'" class="import-result is-pending">
<FileSpreadsheet :size="20" /><div><strong>文件已选择,等待基础检查</strong><span>点击“检查文件”确认扩展名和文件大小;前端不会解析工作表内容。</span></div>
</div>
<div v-else-if="validationState === 'ready'" class="import-result is-passed" role="status">
<FileCheck2 :size="20" /><div><strong>文件基础检查通过</strong><span>{{ validationMessage }}。后端导入接口尚未提供,文件没有上传,也没有写入任何业务数据。</span></div>
</div>
<div v-else-if="validationState === 'invalid'" class="import-result is-failed" role="alert">
<XCircle :size="20" /><div><strong>文件基础检查未通过</strong><span>{{ validationMessage }};当前文件没有上传。</span></div>
</div>
<p class="server-boundary">后端接口接入后,此处将展示上传进度、整批校验结果、逐行错误和错误报告下载;任一行错误时后端必须保证 0 行写入。</p>
<template #footer>
<el-button @click="dialogOpen = false">取消</el-button>
<el-button :disabled="!selectedFile" @click="validateFile">检查文件</el-button>
<el-button type="primary" disabled>等待后端导入接口</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.import-guidance { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; background: #f6f8fb; color: var(--cc-primary); }
.import-guidance > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.import-guidance strong { color: #172033; font-size: 13px; }
.import-guidance span { color: var(--cc-muted); font-size: 11px; line-height: 1.55; }
.import-guidance a { display: inline-flex; align-items: center; gap: 5px; color: var(--cc-primary); font-size: 12px; font-weight: 700; text-decoration: none; }
.import-dropzone { display: flex; width: 100%; min-height: 132px; margin-top: 16px; align-items: center; justify-content: center; border: 1px dashed #aab6c8; background: #fff; color: var(--cc-primary); cursor: pointer; flex-direction: column; gap: 8px; }
.import-dropzone:hover { border-color: var(--cc-primary); background: #f8faff; }
.import-dropzone:focus-visible { outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 2px; }
.import-dropzone strong { color: #172033; font-size: 13px; }
.import-dropzone span { color: var(--cc-muted); font-size: 11px; }
.import-result { display: flex; align-items: center; gap: 10px; margin-top: 14px; padding: 13px 14px; }
.import-result > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.import-result strong { font-size: 13px; }
.import-result span { font-size: 11px; line-height: 1.5; }
.import-result.is-pending { background: #f5f7fa; color: #475467; }
.import-result.is-passed { background: #ecf8f2; color: #137a4b; }
.import-result.is-failed { background: #fff1f1; color: #b42318; }
.server-boundary { margin: 14px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.65; }
.visually-hidden { position: fixed; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 600px) { .import-guidance { grid-template-columns: auto 1fr; } .import-guidance a { grid-column: 2; justify-self: start; } }
</style>
-18
View File
@@ -1,18 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ status: string }>()
const tone = computed(() => {
if (['正常', '启用', '已完成'].includes(props.status)) return 'success'
if (['待审核', '草稿', '待处理'].includes(props.status)) return 'warning'
if (['已受理', '已订舱', '运输中', '处理中'].includes(props.status)) return 'info'
if (['停用', '已取消', '已驳回', '失败'].includes(props.status)) return 'danger'
return 'neutral'
})
</script>
<template>
<span class="status-badge" :class="`is-${tone}`"><i aria-hidden="true" />{{ status }}</span>
</template>
+55
View File
@@ -0,0 +1,55 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { isLocalFullAccessHost, useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
describe('server access store', () => {
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
})
it('uses only the authenticated permission snapshot', () => {
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:warehouse:list']
const access = useAccessPreviewStore()
access.initializeAfterLogin()
expect(access.can('platform:warehouse:list')).toBe(true)
expect(access.can('platform:user:list')).toBe(false)
expect(access.firstAccessiblePath).toBe('/dashboard')
})
it('keeps only the unprotected dashboard when the server snapshot is empty', () => {
const access = useAccessPreviewStore()
expect(access.permissionCodes.size).toBe(0)
expect(access.filteredNavigation).toHaveLength(1)
expect(access.firstAccessiblePath).toBe('/dashboard')
})
it('allows every frontend permission only while the localhost control is enabled', () => {
const access = useAccessPreviewStore()
expect(access.localFullAccessAvailable).toBe(true)
expect(access.can('platform:anything:create')).toBe(false)
access.setLocalFullAccessEnabled(true)
expect(access.can('platform:anything:create')).toBe(true)
expect(access.permissionCodes.has('platform:anything:edit')).toBe(true)
expect(access.filteredNavigation.length).toBeGreaterThan(1)
expect(window.localStorage.getItem('baiyue-admin-local-full-access')).toBe('true')
access.setLocalFullAccessEnabled(false)
expect(access.can('platform:anything:create')).toBe(false)
expect(window.localStorage.getItem('baiyue-admin-local-full-access')).toBeNull()
})
it('recognizes only local preview hosts', () => {
expect(isLocalFullAccessHost('localhost')).toBe(true)
expect(isLocalFullAccessHost('127.0.0.1')).toBe(true)
expect(isLocalFullAccessHost('::1')).toBe(true)
expect(isLocalFullAccessHost('admin.example.com')).toBe(false)
})
})
+82
View File
@@ -0,0 +1,82 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import {
adminNavigation,
filterAdminNavigation,
getFirstNavigationLeaf,
} from '@/core/navigation/adminNavigation'
import { useAuthStore } from '@/core/auth/auth'
const LOCAL_FULL_ACCESS_STORAGE_KEY = 'baiyue-admin-local-full-access'
class LocalFullAccessPermissionSet extends Set<string> {
override has(_permissionCode: string): boolean {
return true
}
}
export function isLocalFullAccessHost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
}
export const useAccessPreviewStore = defineStore('access-preview', () => {
const auth = useAuthStore()
const initialized = ref(false)
const localFullAccessAvailable = import.meta.env.DEV
&& typeof window !== 'undefined'
&& isLocalFullAccessHost(window.location.hostname)
const localFullAccessEnabled = ref(
localFullAccessAvailable
&& window.localStorage.getItem(LOCAL_FULL_ACCESS_STORAGE_KEY) === 'true',
)
const permissionCodes = computed<ReadonlySet<string>>(() => (
localFullAccessEnabled.value
? new LocalFullAccessPermissionSet(auth.permissionCodes)
: auth.permissionCodes
))
const permissionSignature = computed(() => (
localFullAccessEnabled.value
? '__LOCAL_FULL_ACCESS__'
: [...permissionCodes.value].sort().join('|')
))
const filteredNavigation = computed(() => filterAdminNavigation(adminNavigation, permissionCodes.value))
const firstAccessiblePath = computed(() => getFirstNavigationLeaf(filteredNavigation.value)?.path ?? null)
function initializeAfterLogin() {
initialized.value = auth.isAuthenticated
}
function can(permissionCode: string): boolean {
return permissionCodes.value.has(permissionCode)
}
function setLocalFullAccessEnabled(enabled: boolean) {
if (!localFullAccessAvailable) return
localFullAccessEnabled.value = enabled
if (enabled) window.localStorage.setItem(LOCAL_FULL_ACCESS_STORAGE_KEY, 'true')
else window.localStorage.removeItem(LOCAL_FULL_ACCESS_STORAGE_KEY)
}
function toggleLocalFullAccess() {
setLocalFullAccessEnabled(!localFullAccessEnabled.value)
}
function reset() {
initialized.value = false
}
return {
initialized,
localFullAccessAvailable,
localFullAccessEnabled,
permissionCodes,
permissionSignature,
filteredNavigation,
firstAccessiblePath,
initializeAfterLogin,
can,
setLocalFullAccessEnabled,
toggleLocalFullAccess,
reset,
}
})
+260
View File
@@ -0,0 +1,260 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
post: vi.fn(),
listCurrentPlatformPermissionCodes: vi.fn(),
refreshHandler: null as null | (() => Promise<string>),
}))
vi.mock('@/shared/api/http', () => ({
http: { post: mocks.post },
registerAuthRefreshHandler: (handler: () => Promise<string>) => { mocks.refreshHandler = handler },
unwrapApiResponse: <T>(response: { code: string; message: string; data: T }) => {
if (response.code !== '200') throw new Error(response.message)
return response.data
},
getApiErrorDetails: (error: { code?: string; message?: string }) => ({
message: error.message || '接口请求失败',
code: error.code,
}),
}))
vi.mock('./platformAuthorization', () => ({
listCurrentPlatformPermissionCodes: mocks.listCurrentPlatformPermissionCodes,
}))
import { useAuthStore } from './auth'
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
function token(overrides: Record<string, unknown> = {}) {
const claims = {
user_id: '1000001',
username: 'platform_admin',
app_code: 'PLATFORM',
exp: Math.floor(Date.now() / 1000) + 1800,
...overrides,
}
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
return `header.${payload}.signature`
}
function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
return {
code: '200',
message: 'success',
data: {
tokenType: 'Bearer',
accessToken,
expiresIn: 1800,
refreshToken,
refreshExpiresIn: 604800,
overseasAccessToken: 'overseas-token',
overseasExpiresIn: 900,
},
}
}
function domesticOnlyLoginResponse() {
return {
code: '200',
message: 'success',
data: {
tokenType: 'Bearer',
accessToken: token(),
expiresIn: 1800,
refreshToken: 'refresh-domestic',
refreshExpiresIn: 604800,
},
}
}
describe('admin auth session', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', createMemoryStorage())
setActivePinia(createPinia())
mocks.post.mockReset()
mocks.listCurrentPlatformPermissionCodes.mockReset()
mocks.refreshHandler = null
})
afterEach(() => vi.unstubAllGlobals())
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await auth.login('platform_admin', 'password')
expect(mocks.post).toHaveBeenCalledWith('/auth/login', {
loginId: 'platform_admin',
password: 'password',
})
expect(auth.isAuthenticated).toBe(true)
expect(auth.refreshToken).toBe('refresh-domestic')
})
it('accepts serialized long lifetimes from the real authentication response', async () => {
mocks.post.mockResolvedValue({
code: '200',
message: 'success',
data: {
tokenType: 'Bearer',
accessToken: token(),
expiresIn: '7200',
refreshToken: 'refresh-serialized',
refreshExpiresIn: '604800',
overseasAccessToken: null,
overseasExpiresIn: '0',
},
})
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await auth.login('platform_admin', 'password')
expect(auth.isAuthenticated).toBe(true)
expect(auth.refreshToken).toBe('refresh-serialized')
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
})
it('loads permissions from the current authorization endpoint when the JWT only contains identity', async () => {
mocks.post.mockResolvedValue(loginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await auth.login('warehouse_operator', 'password')
expect(auth.isAuthenticated).toBe(true)
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
expect(auth.permissionResources).toEqual([])
expect(localStorage.getItem('cc-admin-permission-codes'))
.toBe('["platform:warehouse:list"]')
})
it('rejects login and clears the session when the current permission snapshot is unavailable', async () => {
mocks.post.mockResolvedValue(loginResponse())
mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' })
const auth = useAuthStore()
await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' })
expect(auth.isAuthenticated).toBe(false)
expect(localStorage.getItem('cc-admin-token')).toBeNull()
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
})
it('revokes an issued token pair when the permission snapshot prevents login completion', async () => {
mocks.post
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce({ code: '200', message: 'success', data: null })
mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' })
const auth = useAuthStore()
await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' })
await Promise.resolve()
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-next' }, {
headers: { Authorization: expect.stringContaining('Bearer header.') },
})
})
it('restores an expired access session with the persisted refresh token', async () => {
localStorage.setItem('cc-admin-token', token({ exp: Math.floor(Date.now() / 1000) - 30 }))
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
mocks.post.mockResolvedValue(loginResponse(token(), 'refresh-rotated'))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await expect(auth.restoreSession()).resolves.toBe(true)
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
expect(auth.refreshToken).toBe('refresh-rotated')
expect(auth.isAuthenticated).toBe(true)
})
it('refreshes a valid persisted session so newly granted permissions appear after reload', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
const refreshedToken = token({
token_version: 2,
})
mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated'))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([
'platform:warehouse:list',
'platform:firstleg:inbound:list',
])
const auth = useAuthStore()
await expect(auth.restoreSession()).resolves.toBe(true)
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
expect([...auth.permissionCodes]).toEqual([
'platform:warehouse:list',
'platform:firstleg:inbound:list',
])
expect(localStorage.getItem('cc-admin-permission-codes'))
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
})
it('reuses a session rotated by another tab without refreshing it again', async () => {
const previousToken = token({ token_version: 1 })
const rotatedToken = token({ token_version: 2 })
localStorage.setItem('cc-admin-token', previousToken)
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
vi.stubGlobal('navigator', {
locks: {
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<void>) => {
localStorage.setItem('cc-admin-token', rotatedToken)
localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated')
localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]')
return callback()
}),
},
})
const auth = useAuthStore()
await expect(auth.refreshSession()).resolves.toBeUndefined()
expect(mocks.post).not.toHaveBeenCalled()
expect(auth.token).toBe(rotatedToken)
expect(auth.refreshToken).toBe('refresh-rotated')
expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list'])
})
it('does not let an in-flight refresh restore a session after logout', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
let resolveRefresh: ((value: ReturnType<typeof loginResponse>) => void) | undefined
mocks.post.mockImplementation((url: string) => {
if (url === '/auth/refresh') {
return new Promise((resolve) => { resolveRefresh = resolve })
}
return Promise.resolve({ code: '200', message: 'success', data: null })
})
const auth = useAuthStore()
const refreshing = auth.refreshSession()
await Promise.resolve()
await auth.logout()
resolveRefresh?.(loginResponse(token(), 'refresh-rotated'))
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
expect(auth.isAuthenticated).toBe(false)
expect(auth.refreshToken).toBe('')
expect(localStorage.getItem('cc-admin-token')).toBeNull()
})
})
+280
View File
@@ -0,0 +1,280 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { CurrentUser } from '@chongchong/shared'
import {
http,
registerAuthRefreshHandler,
unwrapApiResponse,
type ApiEnvelope,
} from '@/shared/api/http'
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
import { clearOverseasAccessToken, replaceOverseasAccessToken } from '@/shared/api/overseasHttp'
import { decodeJwtClaims } from './jwt'
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
import { coordinateCrossTabRefresh } from './crossTabRefresh'
const TOKEN_KEY = 'cc-admin-token'
const REFRESH_TOKEN_KEY = 'cc-admin-refresh-token'
const PERMISSION_CODES_KEY = 'cc-admin-permission-codes'
const REFRESH_LOCK_NAME = 'cc-admin-auth-refresh'
interface LoginResponse {
tokenType: string
accessToken: string
expiresIn: number | string
refreshToken: string
refreshExpiresIn: number | string
overseasAccessToken?: string | null
overseasExpiresIn?: number | string
}
class ExpiredAccessTokenError extends Error {}
class SupersededSessionError extends Error {}
function parseLifetimeSeconds(value: number | string, field: string) {
const normalized = typeof value === 'number'
? value
: /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN
if (!Number.isSafeInteger(normalized) || normalized < 0) {
throw new Error(`登录响应中的 ${field} 格式无效`)
}
return normalized
}
function sessionFromToken(token: string) {
const claims = decodeJwtClaims(token)
if (claims.app_code !== 'PLATFORM') throw new Error('账号不属于当前应用端')
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new ExpiredAccessTokenError('登录状态已过期')
return {
user: {
id: String(claims.user_id ?? ''),
name: claims.username || '平台用户',
account: claims.username || '',
roles: claims.roles ?? [],
} satisfies CurrentUser,
}
}
export const useAuthStore = defineStore('admin-auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const refreshToken = ref(localStorage.getItem(REFRESH_TOKEN_KEY) || '')
const user = ref<CurrentUser | null>(null)
const permissions = ref<string[]>([])
const permissionResources = ref<PlatformPermissionResource[]>([])
const isServerSession = ref(false)
let sessionGeneration = 0
if (token.value) {
try {
const session = sessionFromToken(token.value)
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
if (!Array.isArray(cachedPermissions)) throw new Error('权限缓存格式无效')
user.value = session.user
permissions.value = cachedPermissions.filter((code): code is string => typeof code === 'string')
isServerSession.value = true
} catch (error) {
token.value = ''
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(PERMISSION_CODES_KEY)
if (!(error instanceof ExpiredAccessTokenError) || !refreshToken.value) {
refreshToken.value = ''
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
}
}
const isAuthenticated = computed(() => Boolean(token.value))
const permissionCodes = computed<ReadonlySet<string>>(() => new Set(permissions.value))
function ensureCurrentGeneration(expectedGeneration: number) {
if (expectedGeneration !== sessionGeneration) {
throw new SupersededSessionError('登录会话已被后续操作替代')
}
}
function applyPermissionCodes(codes: string[]) {
permissions.value = codes
localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(codes))
}
async function applySession(result: LoginResponse, expectedGeneration: number) {
const session = sessionFromToken(result.accessToken)
ensureCurrentGeneration(expectedGeneration)
replaceOverseasAccessToken(
result.overseasAccessToken ?? null,
parseLifetimeSeconds(result.overseasExpiresIn ?? 0, 'overseasExpiresIn'),
)
token.value = result.accessToken
refreshToken.value = result.refreshToken
user.value = session.user
isServerSession.value = true
localStorage.setItem(TOKEN_KEY, token.value)
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken.value)
try {
const activePermissionCodes = await listCurrentPlatformPermissionCodes()
ensureCurrentGeneration(expectedGeneration)
permissionResources.value = []
applyPermissionCodes(activePermissionCodes)
} catch (error) {
ensureCurrentGeneration(expectedGeneration)
throw error
}
}
function clearSession() {
sessionGeneration += 1
clearOverseasAccessToken()
token.value = ''
refreshToken.value = ''
user.value = null
permissions.value = []
permissionResources.value = []
isServerSession.value = false
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(PERMISSION_CODES_KEY)
}
function applyStoredSession() {
const storedToken = localStorage.getItem(TOKEN_KEY) || ''
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
if (!storedToken) {
sessionGeneration += 1
token.value = ''
refreshToken.value = ''
user.value = null
permissions.value = []
permissionResources.value = []
isServerSession.value = false
return
}
try {
const session = sessionFromToken(storedToken)
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
if (token.value !== storedToken) clearOverseasAccessToken()
token.value = storedToken
refreshToken.value = storedRefreshToken
user.value = session.user
permissions.value = Array.isArray(cachedPermissions)
? cachedPermissions.filter((code): code is string => typeof code === 'string')
: []
permissionResources.value = []
isServerSession.value = true
} catch {
clearSession()
}
}
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, {
headers: { Authorization: `Bearer ${accessToken}` },
})
}
async function login(account: string, password: string) {
const expectedGeneration = ++sessionGeneration
try {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/login',
{ loginId: account, password },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result, expectedGeneration)
} catch (error) {
if (expectedGeneration === sessionGeneration) {
const issuedAccessToken = token.value
const issuedRefreshToken = refreshToken.value
clearSession()
if (issuedAccessToken && issuedRefreshToken) {
void revokeTokenPair(issuedAccessToken, issuedRefreshToken).catch(() => undefined)
}
}
throw error
}
}
async function refreshSession() {
const observedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || refreshToken.value
if (!observedRefreshToken) throw new Error('刷新令牌不存在,请重新登录')
const expectedGeneration = sessionGeneration
try {
await coordinateCrossTabRefresh({
lockName: REFRESH_LOCK_NAME,
observedRefreshToken,
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
reuseCurrentSession: async () => {
applyStoredSession()
ensureCurrentGeneration(expectedGeneration)
if (!token.value || !refreshToken.value) throw new Error('登录状态已过期')
},
refresh: async (currentRefreshToken) => {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh',
{ refreshToken: currentRefreshToken },
)
ensureCurrentGeneration(expectedGeneration)
if (!localStorage.getItem(REFRESH_TOKEN_KEY)) throw new SupersededSessionError('登录会话已在其他标签页退出')
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
},
})
} catch (error) {
if (expectedGeneration === sessionGeneration) clearSession()
throw error
}
}
async function restoreSession() {
if (isAuthenticated.value) {
if (!refreshToken.value) return true
try {
await refreshSession()
return true
} catch {
return false
}
}
if (!refreshToken.value) return false
try {
await refreshSession()
return true
} catch {
return false
}
}
registerAuthRefreshHandler(async () => {
await refreshSession()
if (!token.value) throw new Error('登录状态刷新失败')
return token.value
})
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if ([TOKEN_KEY, REFRESH_TOKEN_KEY, PERMISSION_CODES_KEY].includes(event.key || '')) applyStoredSession()
})
}
async function logout() {
const accessToken = token.value
const tokenToRevoke = refreshToken.value
clearSession()
if (accessToken && tokenToRevoke) {
await revokeTokenPair(accessToken, tokenToRevoke)
}
}
return {
token,
refreshToken,
user,
permissions,
permissionResources,
isServerSession,
permissionCodes,
isAuthenticated,
login,
refreshSession,
restoreSession,
logout,
}
})
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh'
afterEach(() => vi.unstubAllGlobals())
describe('cross-tab refresh coordination', () => {
it('reuses the rotated session after waiting for another tab', async () => {
let currentRefreshToken = 'refresh-old'
const refresh = vi.fn()
const reuseCurrentSession = vi.fn(async () => 'access-new')
vi.stubGlobal('navigator', {
locks: {
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => {
currentRefreshToken = 'refresh-new'
return callback()
}),
},
})
await expect(coordinateCrossTabRefresh({
lockName: 'admin-refresh',
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => currentRefreshToken,
reuseCurrentSession,
refresh,
})).resolves.toBe('access-new')
expect(reuseCurrentSession).toHaveBeenCalledOnce()
expect(refresh).not.toHaveBeenCalled()
})
it('performs the refresh when the token has not changed', async () => {
const refresh = vi.fn(async () => 'access-new')
vi.stubGlobal('navigator', {
locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => callback()) },
})
await expect(coordinateCrossTabRefresh({
lockName: 'admin-refresh',
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => 'refresh-old',
reuseCurrentSession: vi.fn(),
refresh,
})).resolves.toBe('access-new')
expect(refresh).toHaveBeenCalledWith('refresh-old')
})
it('does not wait for rotation after the shared session was removed', async () => {
const refreshError = new Error('session removed')
const refresh = vi.fn(async () => { throw refreshError })
vi.stubGlobal('navigator', {})
await expect(coordinateCrossTabRefresh({
lockName: 'admin-refresh',
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => '',
reuseCurrentSession: vi.fn(),
refresh,
})).rejects.toBe(refreshError)
expect(refresh).toHaveBeenCalledWith('refresh-old')
})
it('aborts a refresh lock that cannot be acquired', async () => {
vi.stubGlobal('navigator', {
locks: {
request: vi.fn((_name: string, options: LockOptions) => new Promise((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
})),
},
})
await expect(coordinateCrossTabRefresh({
lockName: 'admin-refresh',
lockTimeoutMs: 5,
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => 'refresh-old',
reuseCurrentSession: vi.fn(),
refresh: vi.fn(),
})).rejects.toBeInstanceOf(CrossTabRefreshTimeoutError)
})
})
+56
View File
@@ -0,0 +1,56 @@
export interface CrossTabRefreshOptions<T> {
lockName: string
lockTimeoutMs?: number
observedRefreshToken: string
currentRefreshToken: () => string
reuseCurrentSession: () => Promise<T>
refresh: (refreshToken: string) => Promise<T>
}
export class CrossTabRefreshTimeoutError extends Error {
constructor() {
super('登录状态刷新等待超时,请重新登录')
this.name = 'CrossTabRefreshTimeoutError'
}
}
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
const run = async () => {
const currentRefreshToken = options.currentRefreshToken()
if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) {
return options.reuseCurrentSession()
}
return options.refresh(currentRefreshToken || options.observedRefreshToken)
}
if (typeof navigator === 'undefined' || !navigator.locks) {
try {
return await run()
} catch (error) {
if (!options.currentRefreshToken()) throw error
// Web Locks requires a secure context. On plain HTTP, briefly allow another tab's
// successful rotation to reach shared storage before treating this session as expired.
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 50))
const currentRefreshToken = options.currentRefreshToken()
if (!currentRefreshToken) throw error
if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) {
return options.reuseCurrentSession()
}
}
throw error
}
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
try {
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
} catch (error) {
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
throw error
} finally {
clearTimeout(timeout)
}
}
@@ -0,0 +1,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('platformAuthorization', () => {
beforeEach(() => vi.clearAllMocks())
it('loads the current user permission snapshot', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: ['platform:warehouse:list'] })
await expect(listCurrentPlatformPermissionCodes()).resolves.toEqual(['platform:warehouse:list'])
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/authorization/permissions')
})
})
+8
View File
@@ -0,0 +1,8 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export async function listCurrentPlatformPermissionCodes() {
const response = await http.get<ApiEnvelope<string[]>, ApiEnvelope<string[]>>(
'/platform/v1/iam/authorization/permissions',
)
return unwrapApiResponse(response)
}
+14
View File
@@ -0,0 +1,14 @@
export type AdminRuntimeConfig = {
clientWebUrl?: string
}
declare global {
interface Window {
__CHONGCHONG_RUNTIME_CONFIG__?: AdminRuntimeConfig
}
}
export function getClientWebUrl(): string {
const runtimeUrl = window.__CHONGCHONG_RUNTIME_CONFIG__?.clientWebUrl
return String(runtimeUrl || import.meta.env.VITE_CLIENT_WEB_URL || '').trim()
}
+48
View File
@@ -0,0 +1,48 @@
import { readdirSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const sourceRoot = resolve(process.cwd(), 'src')
function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name)
if (entry.isDirectory()) return sourceFiles(path)
return /\.(ts|vue)$/.test(entry.name) ? [path] : []
})
}
function aliasImports(path: string): string[] {
const source = readFileSync(path, 'utf8')
return [...source.matchAll(/['"](@\/[^'"]+)['"]/g)].map((match) => match[1])
}
describe('source dependency boundaries', () => {
it('keeps app, core, modules and shared dependencies pointing inward', () => {
const violations: string[] = []
for (const path of sourceFiles(sourceRoot)) {
const sourcePath = relative(sourceRoot, path).replaceAll('\\', '/')
const sourceModule = sourcePath.match(/^modules\/([^/]+)\//)?.[1]
for (const dependency of aliasImports(path)) {
if (sourcePath.startsWith('shared/') && /^@\/(app|core|modules|prototype)\//.test(dependency)) {
violations.push(`${sourcePath} -> ${dependency}`)
}
if (sourcePath.startsWith('core/') && /^@\/(app|modules|prototype)\//.test(dependency)) {
violations.push(`${sourcePath} -> ${dependency}`)
}
if (sourceModule && /^@\/(app|prototype)\//.test(dependency)) {
violations.push(`${sourcePath} -> ${dependency}`)
}
const targetModule = dependency.match(/^@\/modules\/([^/]+)(\/.*)?$/)
if (sourceModule && targetModule && targetModule[1] !== sourceModule && targetModule[2]) {
violations.push(`${sourcePath} -> ${dependency}`)
}
}
}
expect(violations).toEqual([])
})
})
+332
View File
@@ -0,0 +1,332 @@
import { describe, expect, it } from 'vitest'
import {
adminNavigation,
filterAdminNavigation,
getFirstNavigationLeaf,
getNavigationLeaves,
type AdminNavigationNode,
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps the latest prototype menu skeleton in source order and groups additions under general', () => {
expect(adminNavigation.map((item) => item.title)).toEqual([
'首页数据看板',
'客户管理',
'国内仓',
'海外派送',
'货柜管理',
'海外业务',
'海外仓',
'客户服务',
'财务管理',
'销售管理',
'通用',
'系统管理',
])
expect(adminNavigation.find((item) => item.key === 'customers')?.children?.map((item) => item.title)).toEqual([
'客户列表',
'海外仓阶梯费用',
])
expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([
'订单列表',
'订单测量',
'测量数据审核',
'预装清单',
'库位管理',
'托盘管理',
'整柜业务',
])
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined()
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
'订舱列表',
'拖车列表',
'送柜报关',
'清关提柜',
])
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
'尾程订单',
'入库单',
'排车列表',
'出库单',
'派送单列表',
'换标单',
'托盘管理',
'库存管理',
'退货库存管理',
'退货箱号管理',
])
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
'产品信息',
'供应商列表',
'快递公司',
'往来单位',
'费用项目',
'汇率管理',
'仓库管理',
'海外仓报价',
'船期价格表',
'车辆列表',
'下载中心',
])
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
'用户管理',
'角色权限',
'租户管理',
'品牌设置',
'菜单权限',
])
})
it('assigns every page a unique path and only shares permissions backed by the API contract', () => {
const leaves = getNavigationLeaves(adminNavigation)
const paths = leaves.map((item) => item.path)
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
expect(paths).toContain('/dashboard')
expect(paths).toContain('/orders')
expect(paths).not.toContain('/orders/first-leg')
expect(paths).not.toContain('/inbounds')
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
expect(paths).toContain('/downloads')
expect(paths).not.toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine']))
expect(paths).toContain('/master-data/fee-items')
expect(paths).toContain('/container-loading/domestic')
expect(paths).toContain('/container-loading/overseas')
expect(paths).not.toContain('/container-loading/smart-loading')
expect(paths).toContain('/loading-plans')
expect(paths).not.toContain('/loading-lists')
expect(paths).not.toContain('/port-schedules')
expect(paths).not.toContain('/customs')
expect(paths).not.toContain('/outbound-dispatches')
expect(paths).not.toContain('/prototype/container-management/container-list')
expect(paths).not.toContain('/prototype/container-management/preload-list')
expect(paths).not.toContain('/prototype/container-management/manifest')
expect(paths).not.toContain('/prototype/general/fee-items')
expect(paths).toEqual(expect.arrayContaining([
'/finance/customer-accounts',
'/finance/customer-arap',
'/finance/receivable-statistics',
'/finance/customer-statements',
'/finance/settlement-receivables',
'/finance/settlement-payables',
]))
expect(paths).not.toEqual(expect.arrayContaining(['/finance/bills', '/finance/receipts', '/finance/costs']))
expect(paths).toContain('/prototype/system-management/brand-settings')
expect(paths).toContain('/system/permissions')
expect(new Set(paths).size).toBe(paths.length)
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
expect(new Set(duplicatedPermissions)).toEqual(new Set())
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
})
it('shows the five formal finance pages plus receivable statistics with real backend permissions', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:finance:customer-account:list',
'platform:finance:customer-arap:list',
'platform:finance:receivable-statistics:list',
'platform:finance:customer-statement:list',
'platform:finance:settlement-ar:list',
'platform:finance:settlement-ap:list',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path).filter((path) => path.startsWith('/finance/'))).toEqual([
'/finance/customer-accounts',
'/finance/customer-arap',
'/finance/receivable-statistics',
'/finance/customer-statements',
'/finance/settlement-receivables',
'/finance/settlement-payables',
])
})
it('shows only the confirmed phase-four pages with their list permissions', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:overseas-warehouse:last-mile-order:list',
'platform:overseas-warehouse:inbound-order:list',
'platform:overseas-warehouse:dispatch:list',
'platform:overseas-warehouse:outbound:list',
'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:relabel-order:list',
'platform:overseas-warehouse:pallet:list',
'platform:overseas-warehouse:inventory:list',
'platform:overseas-business:purchase:list',
'platform:overseas-business:truck:list',
'platform:overseas-business:looseboard:list',
'platform:overseas-master-data:exchange-rate:list',
'platform:overseas-master-data:quote:list',
'platform:overseas-master-data:vehicle:list',
]))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(paths).toEqual(expect.arrayContaining([
'/overseas-warehouse/last-mile-orders',
'/overseas-warehouse/inbound-orders',
'/overseas-warehouse/dispatches',
'/overseas-warehouse/outbounds',
'/overseas-warehouse/delivery-orders',
'/overseas-warehouse/exception-orders',
'/overseas-warehouse/customer-visits',
'/overseas-warehouse/relabel-orders',
'/overseas-warehouse/pallets',
'/overseas-warehouse/inventory',
'/overseas-business/purchases',
'/overseas-business/trucks',
'/overseas-business/looseboards',
'/overseas-master-data/exchange-rates',
'/overseas-master-data/quotes',
'/overseas-master-data/vehicles',
]))
expect(paths).not.toContain('/overseas-warehouse/cartons')
expect(paths).not.toContain('/overseas-warehouse/products')
expect(paths).toEqual(expect.arrayContaining(['/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders']))
})
it('filters protected pages without mutating the source tree', () => {
const sourceSnapshot = JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)
const filtered = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(paths).toContain('/system/permissions')
expect(paths).not.toContain('/prototype/finance/profit-report')
expect(paths).not.toContain('/notifications/messages')
expect(paths).not.toContain('/customers')
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '系统管理'])
expect(filtered).not.toBe(adminNavigation)
expect(filtered[0]).not.toBe(adminNavigation.find((item) => item.key === 'system'))
expect(JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)).toBe(sourceSnapshot)
})
it('keeps hidden prototype nodes out of the live menu while preserving implemented placements', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:inbound:list',
'platform:express:list',
'platform:counterparty:list',
'platform:warehouse:list',
]))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '通用'])
expect(paths).toEqual([
'/dashboard',
'/orders',
'/master-data/express-companies',
'/master-data/counterparties',
'/warehouses',
])
expect(paths).toContain('/dashboard')
expect(paths).not.toContain('/orders/first-leg')
expect(paths).not.toContain('/prototype/finance/profit-report')
})
it('keeps the merged customer entry for existing review-only roles', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set(['platform:customer:review']))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toContain('/customers')
})
it('shows the formal fee item page only with its view permission', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set(['firstleg:fee-item:view']))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
})
it('shows the public supplier list only with the list permission', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
const missing = filterAdminNavigation(adminNavigation, new Set())
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
})
it('shows split container workbenches without a standalone smart-loading menu', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:container:list',
'platform:firstleg:preload:list',
'platform:firstleg:loading-list:list',
'platform:firstleg:terminal-drayage:list',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/loading-plans',
'/container-loading/domestic',
'/container-loading/overseas',
])
})
it('shows only the prototype-backed phase-three service center entries', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:ocean-booking:list',
'platform:firstleg:port-schedule:list',
'platform:firstleg:truck-appointment:list',
'platform:firstleg:customs:list',
'platform:firstleg:outbound-dispatch:list',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/ocean-bookings',
'/trucking',
])
})
it('supports manually hiding a module without removing its route definition', () => {
const filtered = filterAdminNavigation([{ key: 'hidden', title: '隐藏模块', icon: adminNavigation[0].icon, path: '/hidden', hidden: true }], new Set())
expect(filtered).toEqual([])
})
it('keeps directories based on accessible descendants instead of a directory permission', () => {
const nodes: AdminNavigationNode[] = [{
key: 'operations',
title: '运营管理',
icon: adminNavigation[0].icon,
permission: 'directory:denied',
children: [{
key: 'tracking',
title: '轨迹管理',
icon: adminNavigation[0].icon,
path: '/tracking',
permission: 'tracking:list',
}],
}]
const filtered = filterAdminNavigation(nodes, new Set(['tracking:list']))
expect(filtered).toHaveLength(1)
expect(filtered[0].children?.[0].path).toBe('/tracking')
})
it('selects the first fallback leaf in strict depth-first configuration order', () => {
const nodes: AdminNavigationNode[] = [{
key: 'level-one',
title: '一级目录',
icon: adminNavigation[0].icon,
children: [{
key: 'level-two',
title: '二级目录',
icon: adminNavigation[0].icon,
children: [{
key: 'tracking',
title: '轨迹管理',
icon: adminNavigation[0].icon,
path: '/tracking',
permission: 'tracking:list',
}],
}, {
key: 'reports',
title: '报表管理',
icon: adminNavigation[0].icon,
path: '/reports',
permission: 'reports:list',
}],
}]
expect(getNavigationLeaves(nodes).map((item) => item.path)).toEqual(['/tracking', '/reports'])
expect(getFirstNavigationLeaf(nodes)?.path).toBe('/tracking')
})
})
+220
View File
@@ -0,0 +1,220 @@
import type { Component } from 'vue'
import {
Building2,
ContactRound,
ClipboardList,
CircleDollarSign,
Database,
Download,
FileCheck2,
KeyRound,
Settings,
ShieldCheck,
Truck,
Users,
Warehouse as WarehouseIcon,
} from '@lucide/vue'
import {
PrototypeContainerIcon,
PrototypeCustomerIcon,
PrototypeDashboardIcon,
PrototypeDeliveryIcon,
PrototypeDomesticWarehouseIcon,
PrototypeFinanceIcon,
PrototypeGeneralIcon,
PrototypeOverseasBusinessIcon,
PrototypeOverseasWarehouseIcon,
PrototypeSalesIcon,
PrototypeSystemIcon,
} from './prototypeMenuIcons'
export interface AdminNavigationNode {
key: string
title: string
icon: Component
iconColor?: string
path?: string
permission?: string
permissionAnyOf?: string[]
/** Set to true to remove a registered entry from the sidebar without deleting its route. */
hidden?: boolean
children?: AdminNavigationNode[]
}
export interface AdminNavigationLeaf extends AdminNavigationNode {
path: string
permission?: string
children?: never
}
export const adminNavigation: AdminNavigationNode[] = [
{ key: 'dashboard', title: '首页数据看板', icon: PrototypeDashboardIcon, path: '/dashboard' },
{
key: 'customers',
title: '客户管理',
icon: PrototypeCustomerIcon,
children: [
{ key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
{ key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/customers/overseas-storage-fees', permission: 'platform:overseas-storage-fee:list' },
],
},
{
key: 'domestic-warehouse',
title: '国内仓',
icon: PrototypeDomesticWarehouseIcon,
children: [
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
],
},
{
key: 'overseas-delivery',
title: '海外派送',
icon: PrototypeDeliveryIcon,
hidden: true,
children: [
{ key: 'outbound-dispatch', title: '派送单列表', icon: Truck, path: '/prototype/overseas-delivery/dispatch-list' },
{ key: 'outbound-vehicle', title: '排车列表', icon: Truck, path: '/prototype/overseas-delivery/vehicle-scheduling' },
{ key: 'outbound-bol', title: 'BOL', icon: FileCheck2, path: '/prototype/overseas-delivery/bol' },
{ key: 'outbound-selfpick', title: '自提单', icon: ClipboardList, path: '/prototype/overseas-delivery/self-pickup' },
],
},
{
key: 'container-management',
title: '货柜管理',
icon: PrototypeContainerIcon,
children: [
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
{ key: 'domestic-container-workbench', title: '送柜报关', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' },
{ key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list' },
],
},
{
key: 'overseas-business',
title: '海外业务',
icon: PrototypeOverseasBusinessIcon,
children: [
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
],
},
{
key: 'overseas-inventory',
title: '海外仓',
icon: PrototypeOverseasWarehouseIcon,
children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
{ key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
{ key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' },
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
],
},
{
key: 'customer-service',
title: '客户服务',
icon: ContactRound,
children: [
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' },
],
},
{
key: 'finance',
title: '财务管理',
icon: PrototypeFinanceIcon,
children: [
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' },
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' },
{ key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' },
{ key: 'finance-customer-statements', title: '客户对账单', icon: FileCheck2, path: '/finance/customer-statements', permission: 'platform:finance:customer-statement:list' },
{ key: 'finance-settlement-receivables', title: '结算单应收', icon: CircleDollarSign, path: '/finance/settlement-receivables', permission: 'platform:finance:settlement-ar:list' },
{ key: 'finance-settlement-payables', title: '结算单应付', icon: Database, path: '/finance/settlement-payables', permission: 'platform:finance:settlement-ap:list' },
],
},
{
key: 'sales',
title: '销售管理',
icon: PrototypeSalesIcon,
hidden: true,
children: [
{ key: 'sales-visits', title: '拜访记录', icon: Users, path: '/prototype/sales/visit-records' },
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
],
},
{
key: 'general',
title: '通用',
icon: PrototypeGeneralIcon,
children: [
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
{ key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' },
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
{ key: 'overseas-quotes', title: '海外仓报价', icon: CircleDollarSign, path: '/overseas-master-data/quotes', permission: 'platform:overseas-master-data:quote:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
],
},
{
key: 'system',
title: '系统管理',
icon: PrototypeSystemIcon,
children: [
{ key: 'internal-users', title: '用户管理', icon: Users, path: '/system/users', permission: 'platform:user:list' },
{ key: 'roles', title: '角色权限', icon: ShieldCheck, path: '/system/roles', permission: 'platform:role:list' },
{ key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true },
{ key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true },
{ key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' },
],
},
]
export function getNavigationLeaves(nodes: AdminNavigationNode[]): AdminNavigationLeaf[] {
return nodes.flatMap((node) => {
if (node.children?.length) return getNavigationLeaves(node.children)
if (!node.path) return []
return [node as AdminNavigationLeaf]
})
}
export function filterAdminNavigation(
nodes: AdminNavigationNode[],
permissionCodes: ReadonlySet<string>,
): AdminNavigationNode[] {
return nodes.flatMap((node) => {
if (node.hidden) return []
if (node.children?.length) {
const children = filterAdminNavigation(node.children, permissionCodes)
return children.length ? [{ ...node, children }] : []
}
const alternativePermissions = node.permissionAnyOf ?? []
const requiresPermission = Boolean(node.permission || alternativePermissions.length)
const hasPermission = !requiresPermission || Boolean(
(node.permission && permissionCodes.has(node.permission))
|| alternativePermissions.some((permission) => permissionCodes.has(permission)),
)
if (!node.path || !hasPermission) return []
return [{ ...node }]
})
}
export function getFirstNavigationLeaf(nodes: AdminNavigationNode[]): AdminNavigationLeaf | null {
return getNavigationLeaves(nodes)[0] ?? null
}
@@ -0,0 +1,32 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import {
PrototypeCustomerIcon,
PrototypeDashboardIcon,
PrototypeFinanceIcon,
} from './prototypeMenuIcons'
describe('prototype menu icons', () => {
it('renders the prototype shapes at the requested size', () => {
const wrapper = mount(PrototypeDashboardIcon, { props: { size: 22 } })
expect(wrapper.attributes()).toMatchObject({
width: '22',
height: '22',
viewBox: '0 0 24 24',
'aria-hidden': 'true',
})
expect(wrapper.findAll('rect')).toHaveLength(4)
expect(wrapper.findAll('rect').map((node) => node.attributes('fill'))).toEqual([
'#1677ff',
'#1677ff',
'#1677ff',
'#1677ff',
])
})
it('preserves the original menu colors', () => {
expect(mount(PrototypeCustomerIcon).find('path').attributes('fill')).toBe('#52c41a')
expect(mount(PrototypeFinanceIcon).find('circle').attributes('stroke')).toBe('#f5a623')
})
})
+142
View File
@@ -0,0 +1,142 @@
import { defineComponent, h, type Component, type SVGAttributes } from 'vue'
type SvgChild = ReturnType<typeof h>
function createPrototypeMenuIcon(name: string, children: SvgChild[]): Component {
return defineComponent({
name,
inheritAttrs: false,
props: {
size: { type: [Number, String], default: 20 },
},
setup(props, { attrs }) {
return () => h('svg', {
...attrs,
width: props.size,
height: props.size,
viewBox: '0 0 24 24',
fill: 'none',
'aria-hidden': 'true',
} satisfies SVGAttributes, children)
},
})
}
function createOrderDetailIcon(name: string, children: SvgChild[]): Component {
return defineComponent({
name,
inheritAttrs: false,
props: { size: { type: [Number, String], default: 20 } },
setup(props, { attrs }) {
return () => h('svg', {
...attrs,
width: props.size,
height: props.size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': 1.8,
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'aria-hidden': 'true',
} satisfies SVGAttributes, children)
},
})
}
export const OrderBasicInfoIcon = createOrderDetailIcon('OrderBasicInfoIcon', [
h('path', { d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z' }),
h('path', { d: 'm4 7.5 8 4.5 8-4.5M12 12v9' }),
])
export const OrderCargoInfoIcon = createOrderDetailIcon('OrderCargoInfoIcon', [
h('path', { d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z' }),
h('path', { d: 'm4 7.5 8 4.5 8-4.5M12 12v9' }),
h('path', { d: 'M8 5.25 16 9.75' }),
])
export const OrderFeeInfoIcon = createOrderDetailIcon('OrderFeeInfoIcon', [
h('rect', { x: 3, y: 5, width: 18, height: 14, rx: 2 }),
h('path', { d: 'M3 9h18M7 14h4' }),
])
export const OrderHistoryIcon = createOrderDetailIcon('OrderHistoryIcon', [
h('path', { d: 'M3 12a9 9 0 1 0 3-6.7' }),
h('path', { d: 'M3 4v5h5M12 7v5l3 2' }),
])
export const OrderMeasurementIcon = createOrderDetailIcon('OrderMeasurementIcon', [
h('path', { d: 'm4 17 13-13 3 3L7 20H4v-3Z' }),
h('path', { d: 'm13 8 3 3M7 17l-1 1M10 14l-1 1M13 11l-1 1' }),
])
export const OrderOtherIcon = createOrderDetailIcon('OrderOtherIcon', [
h('path', { d: 'M6 3h9l4 4v14H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z' }),
h('path', { d: 'M14 3v5h5' }),
])
export const OrderFulfillmentIcon = createOrderDetailIcon('OrderFulfillmentIcon', [
h('path', { d: 'M4 6h2M4 12h2M4 18h2M9 6h11M9 12h11M9 18h11' }),
h('path', { d: 'm7 11 1.5 1.5L11 10M7 17l1.5 1.5L11 16' }),
])
export const PrototypeDashboardIcon = createPrototypeMenuIcon('PrototypeDashboardIcon', [
h('rect', { x: 3, y: 3, width: 7, height: 8, rx: 1.5, fill: '#1677ff' }),
h('rect', { x: 14, y: 3, width: 7, height: 5, rx: 1.5, fill: '#1677ff', opacity: 0.6 }),
h('rect', { x: 3, y: 14, width: 7, height: 7, rx: 1.5, fill: '#1677ff', opacity: 0.3 }),
h('rect', { x: 14, y: 11, width: 7, height: 10, rx: 1.5, fill: '#1677ff', opacity: 0.8 }),
])
export const PrototypeCustomerIcon = createPrototypeMenuIcon('PrototypeCustomerIcon', [
h('path', { d: 'M3 21h7v-7H3v7zm0-9h7V3H3v9zm11 9h7v-5h-7v5zm0-9h7V3h-7v9z', fill: '#52c41a' }),
])
export const PrototypeDomesticWarehouseIcon = createPrototypeMenuIcon('PrototypeDomesticWarehouseIcon', [
h('path', { d: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2', stroke: '#52c41a', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('path', { d: 'M9 14l2 2 4-4', stroke: '#52c41a', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }),
])
export const PrototypeOverseasWarehouseIcon = createPrototypeMenuIcon('PrototypeOverseasWarehouseIcon', [
h('path', { d: 'M4 12a8 8 0 112.34 5.6', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('path', { d: 'M4 12V7M4 12h5', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('circle', { cx: 12, cy: 12, r: 2.4, fill: '#eb2f96' }),
])
export const PrototypeDeliveryIcon = createPrototypeMenuIcon('PrototypeDeliveryIcon', [
h('path', { d: 'M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z', fill: '#1890ff' }),
])
export const PrototypeServiceIcon = createPrototypeMenuIcon('PrototypeServiceIcon', [
h('path', { d: 'M4 6h16v12H4z', stroke: '#722ed1', 'stroke-width': 2 }),
h('path', { d: 'M4 10h16M8 14h3', stroke: '#722ed1', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeSupplierIcon = createPrototypeMenuIcon('PrototypeSupplierIcon', [
h('path', { d: 'M3 21V9l9-6 9 6v12', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
h('path', { d: 'M9 21v-6h6v6', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
])
export const PrototypeContainerIcon = createPrototypeMenuIcon('PrototypeContainerIcon', [
h('rect', { x: 2, y: 3, width: 20, height: 18, rx: 1, stroke: '#52c41a', 'stroke-width': 2 }),
h('path', { d: 'M2 8h20M8 3v5M16 3v5', stroke: '#52c41a', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeOverseasBusinessIcon', [
h('circle', { cx: 12, cy: 12, r: 9, stroke: '#8c8c8c', 'stroke-width': 1.5 }),
h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
])
export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }),
h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('circle', { cx: 12, cy: 12, r: 2, fill: '#f5a623' }),
])
export const PrototypeSalesIcon = createPrototypeMenuIcon('PrototypeSalesIcon', [
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#1677ff', 'stroke-width': 2 }),
h('path', { d: 'M12 6v12M6 12h12', stroke: '#1677ff', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeGeneralIcon = createPrototypeMenuIcon('PrototypeGeneralIcon', [
h('rect', { x: 3, y: 3, width: 18, height: 18, rx: 2, stroke: '#722ed1', 'stroke-width': 2 }),
h('path', { d: 'M8 12h8M12 8v8', stroke: '#722ed1', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeSystemIcon = createPrototypeMenuIcon('PrototypeSystemIcon', [
h('circle', { cx: 12, cy: 12, r: 3, fill: '#8c8c8c' }),
h('path', { d: 'M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
])
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import {
clearNavigationRecoveryAttempt,
isStaleRouteAssetError,
recoverNavigationError,
type NavigationRecoveryDependencies,
} from './navigationRecovery'
function memoryStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
}
function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
return {
storage: memoryStorage(),
now: () => 1_000,
hasUnsavedWork: () => false,
confirmDiscard: vi.fn(async () => true),
discardUnsavedWork: vi.fn(),
navigate: vi.fn(),
showError: vi.fn(),
...overrides,
} satisfies NavigationRecoveryDependencies
}
describe('navigationRecovery', () => {
it.each([
'Failed to fetch dynamically imported module: /assets/page.js',
'Importing a module script failed',
'ChunkLoadError: Loading chunk 42 failed',
'Loading CSS chunk page failed',
])('recognizes stale deployed route assets: %s', (message) => {
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
})
it('navigates once to the intended route after a stale lazy chunk fails', async () => {
const deps = dependencies()
await expect(recoverNavigationError(
new TypeError('Failed to fetch dynamically imported module'),
'/overseas-warehouse/inventory',
deps,
)).resolves.toBe(true)
expect(deps.discardUnsavedWork).toHaveBeenCalledOnce()
expect(deps.navigate).toHaveBeenCalledWith('/overseas-warehouse/inventory')
expect(deps.showError).not.toHaveBeenCalled()
})
it('asks before discarding dirty work and stays put when the user cancels', async () => {
const deps = dependencies({ hasUnsavedWork: () => true, confirmDiscard: vi.fn(async () => false) })
await recoverNavigationError(new Error('Importing a module script failed'), '/system/users', deps)
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
expect(deps.navigate).not.toHaveBeenCalled()
})
it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
const storage = memoryStorage()
const deps = dependencies({ storage })
const error = new Error('ChunkLoadError: Loading chunk page failed')
await recoverNavigationError(error, '/system/users', deps)
await recoverNavigationError(error, '/system/users', deps)
expect(deps.navigate).toHaveBeenCalledOnce()
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
clearNavigationRecoveryAttempt(storage)
await recoverNavigationError(error, '/system/users', deps)
expect(deps.navigate).toHaveBeenCalledTimes(2)
})
it('reports ordinary router failures without reloading the application', async () => {
const deps = dependencies()
await expect(recoverNavigationError(new Error('Unexpected guard failure'), '/customers', deps)).resolves.toBe(false)
expect(deps.navigate).not.toHaveBeenCalled()
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
})
})
+74
View File
@@ -0,0 +1,74 @@
const RECOVERY_STORAGE_KEY = 'cc-admin-route-asset-recovery:v1'
const RECOVERY_WINDOW_MS = 30_000
const staleAssetErrorPatterns = [
/failed to fetch dynamically imported module/i,
/error loading dynamically imported module/i,
/importing a module script failed/i,
/chunkloaderror/i,
/loading (css )?chunk [\w-]+ failed/i,
]
export interface NavigationRecoveryDependencies {
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
now: () => number
hasUnsavedWork: () => boolean
confirmDiscard: () => Promise<boolean>
discardUnsavedWork: () => void
navigate: (target: string) => void
showError: (message: string) => void
}
interface RecoveryAttempt {
target: string
attemptedAt: number
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
return typeof error === 'string' ? error : ''
}
export function isStaleRouteAssetError(error: unknown) {
const message = errorMessage(error)
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
}
function recentAttempt(dependencies: NavigationRecoveryDependencies, target: string) {
try {
const parsed = JSON.parse(dependencies.storage.getItem(RECOVERY_STORAGE_KEY) || 'null') as RecoveryAttempt | null
return Boolean(parsed
&& parsed.target === target
&& dependencies.now() - parsed.attemptedAt < RECOVERY_WINDOW_MS)
} catch {
return false
}
}
export async function recoverNavigationError(
error: unknown,
target: string,
dependencies: NavigationRecoveryDependencies,
) {
if (!isStaleRouteAssetError(error)) {
dependencies.showError('页面切换失败,请重试')
return false
}
if (recentAttempt(dependencies, target)) {
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
return false
}
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
target,
attemptedAt: dependencies.now(),
} satisfies RecoveryAttempt))
dependencies.discardUnsavedWork()
dependencies.navigate(target)
return true
}
export function clearNavigationRecoveryAttempt(storage: Pick<Storage, 'removeItem'>) {
storage.removeItem(RECOVERY_STORAGE_KEY)
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
describe('unsaved work store', () => {
beforeEach(() => {
@@ -71,6 +71,27 @@ describe('unsaved work store', () => {
}])
})
it('does not let an obsolete component remove or overwrite a reclaimed registration', () => {
const store = useUnsavedWorkStore()
const oldOwner = Symbol('old')
const newOwner = Symbol('new')
store.claim('customer-form', oldOwner)
store.upsert({ key: 'customer-form', label: '旧页面', dirty: true, requiredPermission: null }, oldOwner)
store.claim('customer-form', newOwner)
store.upsert({ key: 'customer-form', label: '新页面', dirty: true, requiredPermission: null }, newOwner)
store.remove('customer-form', oldOwner)
store.release('customer-form', oldOwner)
store.upsert({ key: 'customer-form', label: '过期页面', dirty: false, requiredPermission: null }, oldOwner)
expect(store.entries).toEqual([{
key: 'customer-form',
label: '新页面',
dirty: true,
requiredPermission: null,
}])
})
it('clears every registered entry', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null })
@@ -80,4 +101,16 @@ describe('unsaved work store', () => {
expect(store.entries).toEqual([])
})
it('finds and removes dirty work owned by a workspace tab', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
store.upsert({ key: 'warehouse-form', label: '仓库资料', dirty: true, requiredPermission: null, workspaceTabId: 'warehouses' })
expect(store.findForWorkspace('customers').map((entry) => entry.key)).toEqual(['customer-form'])
expect(store.dirtyWorkspaceIds).toEqual(new Set(['customers', 'warehouses']))
store.removeWorkspaces(['customers'])
expect(store.entries.map((entry) => entry.key)).toEqual(['warehouse-form'])
})
})
+75
View File
@@ -0,0 +1,75 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export interface UnsavedWorkEntry {
key: string
label: string
dirty: boolean
requiredPermission: string | null
workspaceTabId?: string | null
}
export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
const entries = ref<UnsavedWorkEntry[]>([])
const owners = new Map<string, symbol>()
function claim(key: string, owner: symbol) {
owners.set(key, owner)
}
function upsert(entry: UnsavedWorkEntry, owner?: symbol) {
if (owner && owners.get(entry.key) !== owner) return
const index = entries.value.findIndex((item) => item.key === entry.key)
if (index === -1) {
entries.value.push(entry)
return
}
entries.value[index] = entry
}
function remove(key: string, owner?: symbol) {
if (owner && owners.get(key) !== owner) return
if (!owner) owners.delete(key)
const index = entries.value.findIndex((item) => item.key === key)
if (index !== -1) entries.value.splice(index, 1)
}
function release(key: string, owner: symbol) {
if (owners.get(key) !== owner) return
owners.delete(key)
const index = entries.value.findIndex((item) => item.key === key)
if (index !== -1) entries.value.splice(index, 1)
}
function findBlocking(candidatePermissions: ReadonlySet<string>, willLeave: boolean) {
return entries.value.filter((entry) => entry.dirty && (
willLeave
|| (entry.requiredPermission !== null && !candidatePermissions.has(entry.requiredPermission))
))
}
function findForWorkspace(workspaceTabId: string) {
return entries.value.filter((entry) => entry.dirty && entry.workspaceTabId === workspaceTabId)
}
function removeWorkspaces(workspaceTabIds: readonly string[]) {
const ids = new Set(workspaceTabIds)
entries.value
.filter((entry) => entry.workspaceTabId && ids.has(entry.workspaceTabId))
.forEach((entry) => owners.delete(entry.key))
entries.value = entries.value.filter((entry) => !entry.workspaceTabId || !ids.has(entry.workspaceTabId))
}
function clear() {
owners.clear()
entries.value = []
}
const dirtyWorkspaceIds = computed<ReadonlySet<string>>(() => new Set(
entries.value
.filter((entry) => entry.dirty && entry.workspaceTabId)
.map((entry) => entry.workspaceTabId as string),
))
return { entries, dirtyWorkspaceIds, claim, upsert, remove, release, findBlocking, findForWorkspace, removeWorkspaces, clear }
})
@@ -2,8 +2,8 @@ import { computed, defineComponent, nextTick, reactive, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
interface HarnessModel {
name: string
@@ -76,6 +76,7 @@ describe('useUnsavedWorkRegistration', () => {
label: '新增客户',
dirty: true,
requiredPermission: 'customer:update',
workspaceTabId: null,
})
harness.model.name = ''
@@ -1,5 +1,5 @@
import { computed, onScopeDispose, ref, watch, type ComputedRef, type Ref } from 'vue'
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
import { computed, getCurrentInstance, onActivated, onScopeDispose, ref, watch, type ComputedRef, type Ref } from 'vue'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
interface UnsavedWorkRegistrationOptions<T extends object> {
key: string
@@ -13,12 +13,16 @@ export function useUnsavedWorkRegistration<T extends object>(
options: UnsavedWorkRegistrationOptions<T>,
) {
const store = useUnsavedWorkStore()
const routeName = getCurrentInstance()?.proxy?.$route?.name
const workspaceTabId = routeName === undefined || routeName === null ? null : String(routeName)
const owner = Symbol(options.key)
const snapshot = computed(() => JSON.stringify(options.model))
const baseline = ref(snapshot.value)
store.claim(options.key, owner)
function sync() {
if (!options.isOpen.value) {
store.remove(options.key)
store.remove(options.key, owner)
return
}
@@ -27,7 +31,8 @@ export function useUnsavedWorkRegistration<T extends object>(
label: options.label.value,
dirty: snapshot.value !== baseline.value,
requiredPermission: options.requiredPermission.value,
})
workspaceTabId,
}, owner)
}
function captureBaseline() {
@@ -41,7 +46,8 @@ export function useUnsavedWorkRegistration<T extends object>(
{ flush: 'sync' },
)
sync()
onScopeDispose(() => store.remove(options.key))
onActivated(sync)
onScopeDispose(() => store.release(options.key, owner))
return { captureBaseline }
}
+16
View File
@@ -0,0 +1,16 @@
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
description?: string
requiresAuth?: boolean
permission?: string
permissionAnyOf?: string[]
showPageHeading?: boolean
workspaceTab?: boolean
keepAlive?: boolean
}
}
export {}
+37
View File
@@ -0,0 +1,37 @@
import type { RouteLocationNormalizedLoaded, RouteLocationResolved } from 'vue-router'
import type { WorkspaceTabCandidate } from './types'
type WorkspaceRoute = RouteLocationNormalizedLoaded | RouteLocationResolved
export function workspaceRouteId(route: WorkspaceRoute) {
return route.name === undefined || route.name === null ? null : String(route.name)
}
export function isWorkspaceRoute(route: WorkspaceRoute) {
return Boolean(
workspaceRouteId(route)
&& route.meta.workspaceTab !== false
&& route.matched.some((record) => record.meta.requiresAuth),
)
}
export function canAccessWorkspaceRoute(route: WorkspaceRoute, permissions: ReadonlySet<string>) {
if (!isWorkspaceRoute(route)) return false
const permission = typeof route.meta.permission === 'string' ? route.meta.permission : undefined
const alternatives = route.meta.permissionAnyOf ?? []
if (!permission && alternatives.length === 0) return true
return Boolean(
(permission && permissions.has(permission))
|| alternatives.some((code) => permissions.has(code)),
)
}
export function workspaceTabCandidate(route: WorkspaceRoute): WorkspaceTabCandidate | null {
const id = workspaceRouteId(route)
if (!id || !isWorkspaceRoute(route)) return null
return {
id,
title: typeof route.meta.title === 'string' && route.meta.title.trim() ? route.meta.title.trim() : '未命名页面',
path: route.fullPath,
}
}
+17
View File
@@ -0,0 +1,17 @@
export interface WorkspaceTab {
id: string
title: string
path: string
lastVisitedAt: number
}
export interface WorkspaceTabCandidate {
id: string
title: string
path: string
}
export interface WorkspaceTabsSnapshot {
tabs: WorkspaceTab[]
activeId: string | null
}
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { WORKSPACE_TAB_LIMIT, readWorkspaceTabsSnapshot, useWorkspaceTabsStore } from './workspaceTabs'
describe('workspace tabs store', () => {
beforeEach(() => {
sessionStorage.clear()
setActivePinia(createPinia())
vi.restoreAllMocks()
})
it('keeps one tab per route name and restores it only for the same account', () => {
vi.spyOn(Date, 'now').mockReturnValueOnce(10).mockReturnValueOnce(20)
const store = useWorkspaceTabsStore()
store.initialize('user-a')
store.open({ id: 'customers', title: '客户管理', path: '/customers' })
store.open({ id: 'customers', title: '客户详情', path: '/customers/10001' })
expect(store.tabs).toEqual([{ id: 'customers', title: '客户详情', path: '/customers/10001', lastVisitedAt: 20 }])
expect(readWorkspaceTabsSnapshot('user-a').activeId).toBe('customers')
expect(readWorkspaceTabsSnapshot('user-b').tabs).toEqual([])
})
it('evicts the least recently used clean inactive tab at the soft limit', () => {
let now = 0
vi.spyOn(Date, 'now').mockImplementation(() => ++now)
const store = useWorkspaceTabsStore()
store.initialize('user-a')
for (let index = 0; index < WORKSPACE_TAB_LIMIT; index += 1) {
store.open({ id: `route-${index}`, title: `页面 ${index}`, path: `/route-${index}` })
}
store.open({ id: 'route-new', title: '新页面', path: '/route-new' }, new Set(['route-0']))
expect(store.tabs).toHaveLength(WORKSPACE_TAB_LIMIT)
expect(store.tabs.some((tab) => tab.id === 'route-0')).toBe(true)
expect(store.tabs.some((tab) => tab.id === 'route-1')).toBe(false)
expect(store.activeId).toBe('route-new')
})
it('falls back to the most recently visited remaining tab and rotates the closed cache key', () => {
vi.spyOn(Date, 'now').mockReturnValueOnce(10).mockReturnValueOnce(20).mockReturnValueOnce(30)
const store = useWorkspaceTabsStore()
store.initialize('user-a')
store.open({ id: 'customers', title: '客户', path: '/customers' })
store.open({ id: 'warehouses', title: '仓库', path: '/warehouses' })
store.open({ id: 'pallets', title: '托盘', path: '/pallets' })
const previousKey = store.cacheKey('pallets')
const fallback = store.removeByIds(['pallets'])
expect(fallback?.id).toBe('warehouses')
expect(store.activeId).toBe('warehouses')
expect(store.cacheKey('pallets')).not.toBe(previousKey)
})
it('drops corrupt persisted data instead of breaking application startup', () => {
sessionStorage.setItem('cc-admin-workspace-tabs:v1:user-a', '{broken')
expect(readWorkspaceTabsSnapshot('user-a')).toEqual({ tabs: [], activeId: null })
})
})
+147
View File
@@ -0,0 +1,147 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { WorkspaceTab, WorkspaceTabCandidate, WorkspaceTabsSnapshot } from './types'
const STORAGE_PREFIX = 'cc-admin-workspace-tabs:v1'
export const WORKSPACE_TAB_LIMIT = 12
export const MAX_CACHED_WORKSPACE_VIEWS = 24
function storageKey(userId: string) {
return `${STORAGE_PREFIX}:${userId}`
}
function validTab(value: unknown): value is WorkspaceTab {
if (!value || typeof value !== 'object') return false
const tab = value as Partial<WorkspaceTab>
return typeof tab.id === 'string'
&& Boolean(tab.id)
&& typeof tab.title === 'string'
&& typeof tab.path === 'string'
&& tab.path.startsWith('/')
&& typeof tab.lastVisitedAt === 'number'
&& Number.isFinite(tab.lastVisitedAt)
}
export function readWorkspaceTabsSnapshot(userId: string): WorkspaceTabsSnapshot {
if (!userId) return { tabs: [], activeId: null }
try {
const parsed = JSON.parse(sessionStorage.getItem(storageKey(userId)) || 'null') as Partial<WorkspaceTabsSnapshot> | null
if (!parsed || !Array.isArray(parsed.tabs)) return { tabs: [], activeId: null }
const tabs = parsed.tabs.filter(validTab)
const activeId = typeof parsed.activeId === 'string' && tabs.some((tab) => tab.id === parsed.activeId)
? parsed.activeId
: null
return { tabs, activeId }
} catch {
return { tabs: [], activeId: null }
}
}
export function readStoredActiveWorkspacePath(userId: string) {
const snapshot = readWorkspaceTabsSnapshot(userId)
return snapshot.tabs.find((tab) => tab.id === snapshot.activeId)?.path ?? null
}
export const useWorkspaceTabsStore = defineStore('workspace-tabs', () => {
const tabs = ref<WorkspaceTab[]>([])
const activeId = ref<string | null>(null)
const initializedUserId = ref('')
const cacheEpochs = ref<Record<string, number>>({})
const activeTab = computed(() => tabs.value.find((tab) => tab.id === activeId.value) ?? null)
function persist() {
if (!initializedUserId.value) return
sessionStorage.setItem(storageKey(initializedUserId.value), JSON.stringify({
tabs: tabs.value,
activeId: activeId.value,
} satisfies WorkspaceTabsSnapshot))
}
function initialize(userId: string) {
if (!userId || initializedUserId.value === userId) return
initializedUserId.value = userId
const snapshot = readWorkspaceTabsSnapshot(userId)
tabs.value = snapshot.tabs
activeId.value = snapshot.activeId
}
function bumpCacheEpoch(tabId: string) {
cacheEpochs.value = {
...cacheEpochs.value,
[tabId]: (cacheEpochs.value[tabId] ?? 0) + 1,
}
}
function removeByIds(ids: readonly string[]) {
const idSet = new Set(ids)
if (idSet.size === 0) return activeTab.value
const activeWasRemoved = activeId.value !== null && idSet.has(activeId.value)
for (const id of idSet) bumpCacheEpoch(id)
tabs.value = tabs.value.filter((tab) => !idSet.has(tab.id))
if (activeWasRemoved) {
activeId.value = [...tabs.value].sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)[0]?.id ?? null
} else if (activeId.value && !tabs.value.some((tab) => tab.id === activeId.value)) {
activeId.value = null
}
persist()
return activeTab.value
}
function enforceLimit(dirtyTabIds: ReadonlySet<string>) {
while (tabs.value.length > WORKSPACE_TAB_LIMIT) {
const candidate = tabs.value
.filter((tab) => tab.id !== activeId.value && !dirtyTabIds.has(tab.id))
.sort((left, right) => left.lastVisitedAt - right.lastVisitedAt)[0]
if (!candidate) break
removeByIds([candidate.id])
}
}
function open(candidate: WorkspaceTabCandidate, dirtyTabIds: ReadonlySet<string> = new Set()) {
const now = Date.now()
const existing = tabs.value.find((tab) => tab.id === candidate.id)
if (existing) {
existing.title = candidate.title
existing.path = candidate.path
existing.lastVisitedAt = now
} else {
tabs.value.push({ ...candidate, lastVisitedAt: now })
}
activeId.value = candidate.id
enforceLimit(dirtyTabIds)
persist()
}
function prune(keep: (tab: WorkspaceTab) => boolean) {
const removedIds = tabs.value.filter((tab) => !keep(tab)).map((tab) => tab.id)
return { removedIds, fallback: removeByIds(removedIds) }
}
function cacheKey(tabId: string | null) {
if (!tabId) return 'workspace-route'
return `${tabId}:${cacheEpochs.value[tabId] ?? 0}`
}
function reset(removePersisted = false) {
if (removePersisted && initializedUserId.value) sessionStorage.removeItem(storageKey(initializedUserId.value))
tabs.value = []
activeId.value = null
cacheEpochs.value = {}
initializedUserId.value = ''
}
return {
tabs,
activeId,
activeTab,
initializedUserId,
initialize,
open,
removeByIds,
prune,
cacheKey,
bumpCacheEpoch,
reset,
}
})
+75
View File
@@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8')
const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8')
const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8')
const productionCompose = readFileSync(resolve(process.cwd(), 'docker-compose.production.yml'), 'utf8')
const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8')
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
describe('admin nginx deployment config', () => {
it('allows 50 MiB files plus multipart overhead through both bounded API proxies', () => {
expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(2)
expect(config).not.toContain('client_max_body_size 12m;')
expect(config).not.toContain('client_max_body_size 0;')
})
it('prevents the application shell from retaining stale chunk references', () => {
expect(config).toContain('location = /index.html')
expect(config).toContain('Cache-Control "no-cache, no-store, must-revalidate"')
})
it('serves hashed assets immutably and never falls back missing chunks to HTML', () => {
expect(config).toContain('location /assets/')
expect(config).toContain('try_files $uri =404;')
expect(config).toContain('Cache-Control "public, max-age=31536000, immutable"')
})
it('keeps Vue history routes on the application shell without caching them', () => {
expect(config).toContain('try_files $uri $uri/ /index.html;')
expect(config).toContain('Cache-Control "no-cache"')
})
it('proxies overseas API requests to the gateway API prefix', () => {
expect(config).toContain('location /overseas-api/')
expect(config).toContain('rewrite ^/overseas-api/(.*)$ /api/$1 break;')
expect(config).toContain('proxy_pass ${OVERSEAS_API_UPSTREAM};')
})
it('declares an explicit overseas gateway upstream in container deployments', () => {
expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"')
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"')
expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"')
expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.')
expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"')
expect(config).toContain('proxy_set_header Host $proxy_host;')
})
it('keeps local environments on target nodes and uses credential-managed SSH for production', () => {
expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder'")
expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'")
expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d')
expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh')
expect(jenkinsfile).not.toContain('docker save')
expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'")
expect(jenkinsfile).toContain('git rev-parse origin/main')
expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80')
expect(productionCompose).toContain('API_UPSTREAM: "http://gateway:9000"')
expect(productionCompose).toContain('name: firstleg-prod-infra-network')
})
it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => {
expect(dockerfile).toContain('ARG NODE_IMAGE=node:22-alpine')
expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine')
expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build')
expect(dockerfile).toContain('FROM ${NGINX_IMAGE}')
expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'")
expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'")
expect(jenkinsfile).toContain('withCredentials([usernamePassword(')
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
expect(jenkinsfile).toContain('--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
})
})
+11
View File
@@ -0,0 +1,11 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const configSource = readFileSync(resolve(process.cwd(), 'vite.config.ts'), 'utf8')
describe('Vite development server', () => {
it('fails fast when the dedicated admin development port is occupied', () => {
expect(configSource).toMatch(/server:\s*\{\s*strictPort: true,/)
})
})
+1 -1
View File
@@ -8,8 +8,8 @@ declare module 'vue-router' {
interface RouteMeta {
title?: string
description?: string
showPageHeading?: boolean
requiresAuth?: boolean
permission?: string
}
}
-323
View File
@@ -1,323 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAdminDataStore } from '@/stores/adminData'
import { useAuthStore } from '@/stores/auth'
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
import AppLayout from './AppLayout.vue'
vi.mock('element-plus', async (importOriginal) => {
const actual = await importOriginal<typeof import('element-plus')>()
return {
...actual,
ElMessage: { success: vi.fn(), error: vi.fn() },
ElMessageBox: { confirm: vi.fn() },
}
})
const page = { template: '<p>page</p>' }
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
async function mountLayout(path: string) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'test-access-token'
auth.user = { id: '1', name: '平台管理员', account: 'platform_admin', roles: ['PLATFORM_ADMIN'] }
localStorage.setItem('cc-admin-token', auth.token)
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: page, meta: { title: '管理后台' } },
{ path: '/customers', component: page, meta: { title: '客户管理', permission: 'platform:customer:list' } },
{ path: '/warehouses', component: page, meta: { title: '仓库管理', permission: 'platform:warehouse:list' } },
{ path: '/orders/first-leg', component: page, meta: { title: '头程订单', permission: 'platform:order:list' } },
{ path: '/system/users', component: page, meta: { title: '内部用户', permission: 'platform:user:list' } },
{ path: '/403', component: page, meta: { title: '权限不足' } },
{ path: '/login', component: page, meta: { title: '登录' } },
],
})
await router.push(path)
await router.isReady()
const wrapper = mount(AppLayout, {
attachTo: document.body,
global: {
plugins: [pinia, router],
stubs: {
AdminSidebarMenu: { props: ['items'], template: '<nav data-sidebar :data-count="items.length" />' },
RolePreviewSwitcher: {
props: ['roles', 'currentRole'],
emits: ['select'],
template: '<div><span data-preview>{{ currentRole?.name }}</span><button data-switch-r003 @click="$emit(\'select\', \'R003\')">switch R003</button><button data-switch-r005 @click="$emit(\'select\', \'R005\')">switch R005</button></div>',
},
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
},
},
})
await nextTick()
return {
wrapper,
router,
auth,
access: useAccessPreviewStore(),
data: useAdminDataStore(),
unsaved: useUnsavedWorkStore(),
}
}
describe('AppLayout role preview coordination', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', createMemoryStorage())
vi.mocked(ElMessageBox.confirm).mockReset()
vi.mocked(ElMessage.success).mockReset()
vi.mocked(ElMessage.error).mockReset()
document.body.innerHTML = ''
})
it('keeps the login identity visibly separate from the preview role', async () => {
const { wrapper } = await mountLayout('/')
expect(wrapper.get('[aria-label="登录身份:平台管理员(platform_admin)"]').text())
.toBe('登录身份:平台管理员(platform_admin)')
expect(wrapper.get('[data-preview]').text()).toBe('超级管理员')
})
it('commits a role and replaces an inaccessible system route with its first leaf', async () => {
const { wrapper, router, access } = await mountLayout('/system/users')
const replace = vi.spyOn(router, 'replace')
await wrapper.get('[data-switch-r003]').trigger('click')
await flushPromises()
expect(access.currentRole?.id).toBe('R003')
expect(replace).toHaveBeenCalledWith('/warehouses')
expect(router.currentRoute.value.path).toBe('/warehouses')
expect(wrapper.get('[aria-live="polite"]').text()).toContain('操作员')
expect(wrapper.get('[aria-live="polite"]').text()).toContain('仓库管理')
expect(ElMessage.success).toHaveBeenCalledWith('已切换至“操作员”,当前仅为静态权限预览')
expect(document.activeElement).toBe(wrapper.get('h1').element)
expect(wrapper.get('[data-sidebar]').attributes('data-count'))
.toBe(String(access.filteredNavigation.length))
expect(access.filteredNavigation).toHaveLength(1)
})
it('recovers from 403 to the first accessible leaf after a role change', async () => {
const { wrapper, router, access } = await mountLayout('/403')
const replace = vi.spyOn(router, 'replace')
await wrapper.get('[data-switch-r003]').trigger('click')
await flushPromises()
expect(access.currentRole?.id).toBe('R003')
expect(replace).toHaveBeenCalledWith('/warehouses')
})
it('keeps a queried 403 route when the candidate role has no accessible page', async () => {
const { wrapper, router, access, data } = await mountLayout('/403?from=/system/users')
data.roles.find((role) => role.id === 'R003')!.permissionIds = []
const replace = vi.spyOn(router, 'replace')
await wrapper.get('[data-switch-r003]').trigger('click')
await flushPromises()
expect(access.currentRole?.id).toBe('R003')
expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users')
expect(replace).not.toHaveBeenCalled()
expect(wrapper.get('[aria-live="polite"]').text()).toBe('权限预览已切换为操作员')
})
it('keeps role, route and dirty entry unchanged when role switching is cancelled', async () => {
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
const { wrapper, router, access, unsaved } = await mountLayout('/system/users')
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
await wrapper.get('[data-switch-r003]').trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
expect.stringMatching(/切换到操作员.*将离开内部用户.*内部用户编辑表单/),
expect.any(String),
expect.any(Object),
)
expect(access.currentRole?.id).toBe('R001')
expect(router.currentRoute.value.path).toBe('/system/users')
expect(unsaved.entries).toHaveLength(1)
})
it('warns that same-page forms will lose save permission without claiming navigation', async () => {
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
const { wrapper, router, access, unsaved } = await mountLayout('/orders/first-leg')
unsaved.upsert({ key: 'restricted-form', label: '受限订单表单', dirty: true, requiredPermission: 'system:user:write' })
await wrapper.get('[data-switch-r003]').trigger('click')
await flushPromises()
const message = String(vi.mocked(ElMessageBox.confirm).mock.calls[0]?.[0])
expect(message).toContain('切换到操作员')
expect(message).toContain('头程订单')
expect(message).toContain('将失去保存权限')
expect(message).toContain('受限订单表单')
expect(message).not.toContain('将离开头程订单')
expect(access.currentRole?.id).toBe('R001')
expect(router.currentRoute.value.path).toBe('/orders/first-leg')
})
it('commits only after dirty-work confirmation succeeds', async () => {
let resolveConfirmation: (() => void) | undefined
vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => {
resolveConfirmation = () => resolve({} as never)
}))
const { wrapper, access, unsaved } = await mountLayout('/system/users')
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
await wrapper.get('[data-switch-r003]').trigger('click')
await nextTick()
expect(access.currentRole?.id).toBe('R001')
resolveConfirmation?.()
await flushPromises()
expect(access.currentRole?.id).toBe('R003')
})
it('ignores another role selection while the first confirmation is pending', async () => {
let resolveConfirmation: (() => void) | undefined
vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => {
resolveConfirmation = () => resolve({} as never)
}))
const { wrapper, access, unsaved } = await mountLayout('/system/users')
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
await wrapper.get('[data-switch-r003]').trigger('click')
await wrapper.get('[data-switch-r005]').trigger('click')
await nextTick()
expect(ElMessageBox.confirm).toHaveBeenCalledTimes(1)
expect(access.currentRole?.id).toBe('R001')
resolveConfirmation?.()
await flushPromises()
expect(access.currentRole?.id).toBe('R003')
})
it('reconfirms when a new blocker appears while the first confirmation is pending', async () => {
let resolveFirst: (() => void) | undefined
let resolveSecond: (() => void) | undefined
vi.mocked(ElMessageBox.confirm)
.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = () => resolve({} as never) }))
.mockReturnValueOnce(new Promise((resolve) => { resolveSecond = () => resolve({} as never) }))
const { wrapper, access, unsaved } = await mountLayout('/orders/first-leg')
unsaved.upsert({ key: 'first-form', label: '第一张表单', dirty: true, requiredPermission: 'system:user:write' })
await wrapper.get('[data-switch-r003]').trigger('click')
await nextTick()
unsaved.upsert({ key: 'second-form', label: '新增表单', dirty: true, requiredPermission: 'customer:create' })
resolveFirst?.()
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledTimes(2)
expect(String(vi.mocked(ElMessageBox.confirm).mock.calls[1]?.[0])).toContain('新增表单')
expect(access.currentRole?.id).toBe('R001')
resolveSecond?.()
await flushPromises()
expect(access.currentRole?.id).toBe('R003')
})
it('replaces the route when live permission changes make the current page inaccessible', async () => {
const { router, access, data } = await mountLayout('/system/users')
const replace = vi.spyOn(router, 'replace')
const permission = data.permissions.find((item) => item.code === 'platform:user:list')
if (!permission) throw new Error('fixture permission missing')
permission.status = '停用'
await nextTick()
await flushPromises()
expect(access.currentRole?.id).toBe('R001')
expect(replace).toHaveBeenCalledWith('/customers')
expect(document.activeElement).toBe(document.querySelector('h1'))
expect(document.querySelector('[aria-live="polite"]')?.textContent)
.toBe('当前页面权限已变化,已前往客户管理')
})
it('does not replace or announce navigation when live access remains on queried 403', async () => {
const { router, data } = await mountLayout('/403?from=/system/users')
const replace = vi.spyOn(router, 'replace')
data.permissions.forEach((permission) => { permission.status = '停用' })
await nextTick()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users')
expect(replace).not.toHaveBeenCalled()
expect(document.querySelector('[aria-live="polite"]')?.textContent).toBe('')
})
it('rolls back the role and reports an error when correction navigation fails', async () => {
const { wrapper, router, access } = await mountLayout('/system/users')
vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed'))
await wrapper.get('[data-switch-r003]').trigger('click')
await flushPromises()
expect(access.currentRole?.id).toBe('R001')
expect(router.currentRoute.value.path).toBe('/system/users')
expect(ElMessage.error).toHaveBeenCalledWith('权限预览切换失败,已恢复原角色')
expect(ElMessage.success).not.toHaveBeenCalled()
await wrapper.get('[data-switch-r003]').trigger('click')
await flushPromises()
expect(access.currentRole?.id).toBe('R003')
})
it('clears preview and unsaved state before logging out and replacing with login', async () => {
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
const { wrapper, router, auth, access, unsaved } = await mountLayout('/')
unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null })
const calls: string[] = []
const resetAccess = access.reset
const clearUnsaved = unsaved.clear
const logout = auth.logout
vi.spyOn(access, 'reset').mockImplementation(() => { calls.push('access'); resetAccess() })
vi.spyOn(unsaved, 'clear').mockImplementation(() => { calls.push('unsaved'); clearUnsaved() })
vi.spyOn(auth, 'logout').mockImplementation(() => { calls.push('auth'); logout() })
const replace = vi.spyOn(router, 'replace')
await wrapper.get('[aria-label="退出登录"]').trigger('click')
await flushPromises()
expect(calls).toEqual(['access', 'unsaved', 'auth'])
expect(replace).toHaveBeenCalledWith('/login')
})
it('keeps cleared logout state and reports a login navigation failure', async () => {
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
const { wrapper, router, auth, access, unsaved } = await mountLayout('/')
unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null })
vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed'))
await wrapper.get('[aria-label="退出登录"]').trigger('click')
await flushPromises()
expect(auth.isAuthenticated).toBe(false)
expect(access.currentRole).toBeNull()
expect(unsaved.entries).toHaveLength(0)
expect(ElMessage.error).toHaveBeenCalledWith('已退出登录,但登录页跳转失败,请刷新页面')
expect(router.currentRoute.value.path).toBe('/')
expect(router.replace).toHaveBeenCalledTimes(1)
})
})
-265
View File
@@ -1,265 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ChevronLeft,
ChevronRight,
LogOut,
Menu,
} from '@lucide/vue'
import { PRODUCT_NAME } from '@chongchong/shared'
import AdminSidebarMenu from '@/components/AdminSidebarMenu.vue'
import RolePreviewSwitcher from '@/components/RolePreviewSwitcher.vue'
import { resolvePreviewTarget } from '@/router/accessDecision'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAuthStore } from '@/stores/auth'
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const access = useAccessPreviewStore()
const unsaved = useUnsavedWorkStore()
const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const correctingRoute = ref(false)
const roleSwitchPending = ref(false)
const loggingOut = ref(false)
const pageHeading = ref<HTMLHeadingElement | null>(null)
const announcement = ref('')
access.initializeAfterLogin()
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
function currentPermission(): string | undefined {
return typeof route.meta.permission === 'string' ? route.meta.permission : undefined
}
async function replaceAndFocus(path: string) {
await router.replace(path)
await nextTick()
pageHeading.value?.focus()
}
function getRoleSwitchDecision(roleId: string) {
const candidateRole = access.enabledRoles.find((role) => role.id === roleId)
if (!candidateRole) return null
const candidate = access.previewRole(roleId)
const target = resolvePreviewTarget({
currentPath: route.path,
currentPermission: currentPermission(),
candidateCodes: candidate.codes,
firstPath: candidate.firstPath,
})
const willLeave = target !== null && target !== route.path
const blockers = unsaved.findBlocking(candidate.codes, willLeave)
const blockerSignature = blockers
.map((entry) => `${entry.key}:${entry.label}:${entry.requiredPermission ?? ''}`)
.sort()
.join('|')
return {
candidateRole,
target,
willLeave,
blockers,
signature: `${target ?? ''}:${willLeave}:${blockerSignature}`,
}
}
async function confirmRoleSwitch(decision: NonNullable<ReturnType<typeof getRoleSwitchDecision>>) {
const labels = decision.blockers.map((entry) => entry.label).join('、')
const currentPage = typeof route.meta.title === 'string' ? route.meta.title : '当前页面'
const message = decision.willLeave
? `切换到${decision.candidateRole.name}后,将离开${currentPage},并放弃以下未保存内容:${labels}。是否继续?`
: `切换到${decision.candidateRole.name}后,${currentPage}中的以下表单将失去保存权限:${labels}。是否继续?`
try {
await ElMessageBox.confirm(
message,
'未保存内容',
{ confirmButtonText: '放弃并切换', cancelButtonText: '取消', type: 'warning' },
)
return true
} catch {
return false
}
}
async function requestRoleSwitch(roleId: string) {
if (access.currentRole?.id === roleId || roleSwitchPending.value) return
roleSwitchPending.value = true
try {
let decision: NonNullable<ReturnType<typeof getRoleSwitchDecision>>
let confirmedSignature: string | null = null
while (true) {
const refreshedDecision = getRoleSwitchDecision(roleId)
if (!refreshedDecision) return
decision = refreshedDecision
if (decision.blockers.length && decision.signature !== confirmedSignature) {
if (!await confirmRoleSwitch(decision)) return
confirmedSignature = decision.signature
continue
}
break
}
correctingRoute.value = true
const currentPage = typeof route.meta.title === 'string' ? route.meta.title : '当前页面'
const previousRoleId = access.currentRole?.id ?? null
try {
if (!access.commitRole(roleId)) return
const navigated = Boolean(decision.target && decision.target !== route.path)
if (navigated && decision.target) {
try {
await replaceAndFocus(decision.target)
} catch {
const rolledBack = previousRoleId ? access.commitRole(previousRoleId) : false
if (rolledBack) {
ElMessage.error('权限预览切换失败,已恢复原角色')
return
}
const reconciliationTarget = resolvePreviewTarget({
currentPath: route.path,
currentPermission: currentPermission(),
candidateCodes: access.permissionCodes,
firstPath: access.firstAccessiblePath,
})
if (reconciliationTarget && reconciliationTarget !== route.path) {
try {
await replaceAndFocus(reconciliationTarget)
} catch {
// 保留当前路由,错误提示要求用户刷新以重新进入一致状态。
}
}
ElMessage.error('权限预览切换失败,当前权限状态可能已变化,请刷新页面')
return
}
}
const roleName = access.currentRole?.name ?? decision.candidateRole.name
announcement.value = navigated
? `权限预览已切换为${roleName},当前页面:${String(route.meta.title ?? currentPage)}`
: `权限预览已切换为${roleName}`
ElMessage.success(`已切换至“${roleName}”,当前仅为静态权限预览`)
} finally {
correctingRoute.value = false
}
} finally {
roleSwitchPending.value = false
}
}
async function handleLogout() {
try {
await ElMessageBox.confirm('确定退出管理后台吗?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
})
} catch {
return
}
loggingOut.value = true
access.reset()
unsaved.clear()
auth.logout()
try {
await router.replace('/login')
} catch {
ElMessage.error('已退出登录,但登录页跳转失败,请刷新页面')
}
}
watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
watch(
() => `${access.currentRole?.id ?? ''}:${access.permissionSignature}`,
async () => {
if (correctingRoute.value || loggingOut.value) return
const target = resolvePreviewTarget({
currentPath: route.path,
currentPermission: currentPermission(),
candidateCodes: access.permissionCodes,
firstPath: access.firstAccessiblePath,
})
if (!target || target === route.path) return
correctingRoute.value = true
try {
await replaceAndFocus(target)
const targetTitle = String(route.meta.title ?? '可访问页面')
announcement.value = target === '/403'
? `当前权限预览角色没有可访问页面,已前往${targetTitle}`
: `当前页面权限已变化,已前往${targetTitle}`
} finally {
correctingRoute.value = false
}
},
)
</script>
<template>
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
<aside class="app-sidebar">
<div class="brand-block">
<div class="brand-mark">CC</div>
<div v-if="!collapsed" class="brand-copy">
<strong>{{ PRODUCT_NAME }}</strong>
<span>管理后台</span>
</div>
</div>
<nav class="side-nav" aria-label="主导航">
<AdminSidebarMenu :items="access.filteredNavigation" :active-path="route.path" :collapsed="menuCollapsed" />
</nav>
<button class="sidebar-toggle" type="button" @click="collapsed = !collapsed">
<ChevronRight v-if="collapsed" :size="18" />
<ChevronLeft v-else :size="18" />
<span v-if="!collapsed">收起导航</span>
</button>
</aside>
<div class="app-main">
<header class="app-header">
<button class="mobile-menu-button" type="button" aria-label="打开菜单" :aria-expanded="mobileMenuOpen" @click="mobileMenuOpen = true">
<Menu :size="20" />
</button>
<div class="page-heading">
<h1 ref="pageHeading" tabindex="-1">{{ pageTitle }}</h1>
<p v-if="pageDescription">{{ pageDescription }}</p>
</div>
<RolePreviewSwitcher
v-if="!access.usesServerPermissions"
:roles="access.enabledRoles"
:current-role="access.currentRole"
@select="requestRoleSwitch"
/>
<div class="header-user">
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
<div
class="user-copy"
:aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"
>
<strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong>
</div>
<el-button text aria-label="退出登录" @click="handleLogout">
<LogOut :size="18" />
</el-button>
</div>
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ announcement }}</p>
</header>
<main class="page-container">
<RouterView />
</main>
</div>
</div>
</template>
+70 -3
View File
@@ -1,9 +1,76 @@
import { createApp } from 'vue'
import '@/styles/adminDensity.css'
import { computed, createApp } from 'vue'
import 'dayjs/locale/zh-cn'
import { createPinia } from 'pinia'
import { ElMessage, ElMessageBox } from 'element-plus'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import App from './App.vue'
import router from './router'
import router from '@/app/router'
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
import { clearNavigationRecoveryAttempt, recoverNavigationError } from '@/core/routing/navigationRecovery'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import { useAuthStore } from '@/core/auth/auth'
import { registerAuthSessionExpiredHandler } from '@/shared/api/http'
import { UI_PREFERENCE_SCOPE } from '@/shared/utils/uiLayoutPreference'
import './styles/index.css'
createApp(App).use(createPinia()).use(router).mount('#app')
const pinia = createPinia()
const auth = useAuthStore(pinia)
const unsaved = useUnsavedWorkStore(pinia)
const workspace = useWorkspaceTabsStore(pinia)
await auth.restoreSession()
registerAuthSessionExpiredHandler(async () => {
const currentRoute = router.currentRoute.value
if (currentRoute.name === 'login') return
useAccessPreviewStore(pinia).reset()
unsaved.clear()
workspace.reset(true)
const redirect = sanitizeInternalPath(currentRoute.fullPath)
ElMessage.warning('登录状态已过期,请重新登录')
await router.replace({
name: 'login',
query: redirect === '/' ? undefined : { redirect },
})
})
router.onError((error, to) => {
void recoverNavigationError(error, to.fullPath, {
storage: sessionStorage,
now: Date.now,
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
confirmDiscard: async () => {
try {
await ElMessageBox.confirm(
'检测到后台已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
'重新加载页面?',
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
)
return true
} catch {
return false
}
},
discardUnsavedWork: () => unsaved.clear(),
navigate: (target) => window.location.assign(target),
showError: (message) => ElMessage.error(message),
})
})
router.afterEach((_to, _from, failure) => {
if (!failure) clearNavigationRecoveryAttempt(sessionStorage)
})
window.addEventListener('beforeunload', (event) => {
if (!unsaved.entries.some((entry) => entry.dirty)) return
event.preventDefault()
event.returnValue = ''
})
const app = createApp(App)
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
app.use(pinia).use(router).mount('#app')
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getPlatformAuditLog, listPlatformAuditLogs } from './platformAudit'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('platform audit api', () => {
beforeEach(() => vi.clearAllMocks())
it('queries the selected customer and preserves long ids as strings', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: '2082813911786024961', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }], page: 1, size: 20, total: 1, pages: 1 } })
const result = await listPlatformAuditLogs({ accountId: '8', page: 1, size: 20 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs', { params: { accountId: '8', page: 1, size: 20 } })
expect(result.records[0].id).toBe('2082813911786024961')
expect(result.records[0].clientIp).toBe('127.0.0.1')
expect(result.total).toBe(1)
expect(result.pages).toBe(1)
})
it('preserves the server total when the current page has fewer records', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }],
page: 1, size: 20, total: 13, pages: 1,
} })
const result = await listPlatformAuditLogs({ accountId: '8', page: 1, size: 20 })
expect(result.records).toHaveLength(1)
expect(result.total).toBe(13)
expect(result.pages).toBe(1)
})
it('maps audit filters and omits empty query values', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listPlatformAuditLogs({
accountId: '8', page: 1, size: 20, keyword: 'admin', success: false,
occurredFrom: '2026-08-01T00:00:00.000Z', occurredTo: '2026-08-13T23:59:59.999Z',
eventType: '', resourceType: undefined,
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs', { params: {
accountId: '8', page: 1, size: 20, keyword: 'admin', success: false,
occurredFrom: '2026-08-01T00:00:00.000Z', occurredTo: '2026-08-13T23:59:59.999Z',
} })
})
it('loads one audit detail', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00', httpMethod: 'PUT', requestUri: '/tenants/8', durationMs: 12, beforeData: '{}', afterData: '{"name":"new"}' } })
const result = await getPlatformAuditLog('1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs/1')
expect(result.afterData).toBe('{"name":"new"}')
})
})
+107
View File
@@ -0,0 +1,107 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface PlatformAuditLogListItem {
id: string
eventId: string
eventType: string
resourceType: string
resourceId: string | null
description: string | null
accountId: string
operatorTenantId: string
userId: string
username: string | null
appCode: string | null
clientType: string | null
clientIp: string | null
requestId: string | null
traceId: string | null
serviceName: string | null
success: boolean
errorCode: string | null
errorMessage: string | null
occurredAt: string
createdAt: string
}
export interface PlatformAuditLogDetail extends PlatformAuditLogListItem {
httpMethod: string | null
requestUri: string | null
clientIp: string | null
durationMs: number | null
beforeData: string | null
afterData: string | null
}
export interface PlatformAuditLogPage {
records: PlatformAuditLogListItem[]
page: number
size: number
total: number
pages: number
}
export interface PlatformAuditLogQuery {
accountId: string
page?: number
size?: number
operatorUserId?: string
eventType?: string
resourceType?: string
resourceId?: string
success?: boolean
occurredFrom?: string
occurredTo?: string
keyword?: string
}
type RawId = string | number | null
type RawListItem = Omit<PlatformAuditLogListItem, 'id' | 'accountId' | 'operatorTenantId' | 'userId' | 'resourceId'> & {
id: RawId
accountId: RawId
operatorTenantId: RawId
userId: RawId
resourceId: RawId
}
type RawDetail = Omit<PlatformAuditLogDetail, keyof PlatformAuditLogListItem> & RawListItem
type RawPage = Omit<PlatformAuditLogPage, 'records' | 'page' | 'size' | 'total' | 'pages'> & {
records?: RawListItem[]
page: number | string
size: number | string
total: number | string
pages?: number | string
}
const basePath = '/platform/v1/audit/logs'
const id = (value: RawId) => value == null ? '' : String(value)
function normalizeListItem(item: RawListItem): PlatformAuditLogListItem {
return {
...item,
id: id(item.id),
accountId: id(item.accountId),
operatorTenantId: id(item.operatorTenantId),
userId: id(item.userId),
resourceId: item.resourceId == null ? null : String(item.resourceId),
}
}
export async function listPlatformAuditLogs(query: PlatformAuditLogQuery) {
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== null && value !== undefined))
const response = await http.get<ApiEnvelope<RawPage>, ApiEnvelope<RawPage>>(basePath, { params })
const data = unwrapApiResponse(response)
const size = Number(data.size || query.size || 20)
const total = Number(data.total || 0)
return {
records: (data.records || []).map(normalizeListItem),
page: Number(data.page || query.page || 1),
size,
total,
pages: Number(data.pages ?? (total > 0 ? Math.ceil(total / size) : 0)),
} satisfies PlatformAuditLogPage
}
export async function getPlatformAuditLog(idValue: string) {
const response = await http.get<ApiEnvelope<RawDetail>, ApiEnvelope<RawDetail>>(`${basePath}/${encodeURIComponent(idValue)}`)
return normalizeListItem(unwrapApiResponse(response)) as PlatformAuditLogDetail
}
@@ -0,0 +1,134 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { getPlatformAuditLog, listPlatformAuditLogs } from '../api/platformAudit'
import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue'
vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() }))
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
function mountPanel(permissions: string[]) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
return mount(CustomerAuditLogsPanel, { props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [pinia, ElementPlus] } })
}
describe('CustomerAuditLogsPanel', () => {
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
it('queries logs with the current customer id', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 1, size: 20 })
expect(wrapper.text()).toContain('编辑客户')
expect(wrapper.text()).toContain('127.0.0.1')
expect(wrapper.text()).toContain('编辑客户档案')
expect(wrapper.text()).not.toContain('PLATFORM_TENANT_UPDATE')
expect(wrapper.text()).toContain('平台管理员')
expect(wrapper.text()).toContain('客户与权限服务')
})
it('shows inbound audit events in Chinese without wrapping technical codes into the table', async () => {
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [{ ...row, eventType: 'INBOUND_MEASURE_APPROVE', description: '测量审核通过', serviceName: 'firstleg-service' }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
expect(wrapper.text()).toContain('入库测量审核通过')
expect(wrapper.text()).toContain('头程履约服务')
expect(wrapper.text()).not.toContain('INBOUND_MEASURE_APPROVE')
})
it('shows the server total instead of the current page row count', async () => {
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 13, pages: 1 })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
expect(wrapper.text()).toContain('共 13 条客户操作记录')
})
it('applies keyword, result and local date range only after search', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
vi.mocked(listPlatformAuditLogs).mockClear()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { keyword: ' admin ', success: false, occurredRange: ['2026-08-01', '2026-08-13'] })
await wrapper.vm.$nextTick()
expect(listPlatformAuditLogs).not.toHaveBeenCalled()
toolbar.vm.$emit('search', { keyword: ' admin ', success: false, occurredRange: ['2026-08-01', '2026-08-13'] })
await flushPromises()
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
expect(listPlatformAuditLogs).toHaveBeenCalledWith({
accountId: '8', page: 1, size: 20, keyword: 'admin', success: false,
occurredFrom: new Date(2026, 7, 1, 0, 0, 0, 0).toISOString(),
occurredTo: new Date(2026, 7, 13, 23, 59, 59, 999).toISOString(),
})
})
it('resets to all logs and requests once on the first page', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { keyword: 'admin', success: true, occurredRange: ['2026-08-01', '2026-08-13'] })
toolbar.vm.$emit('search', { keyword: 'admin', success: true, occurredRange: ['2026-08-01', '2026-08-13'] })
await flushPromises()
vi.mocked(listPlatformAuditLogs).mockClear()
;(toolbar.vm as unknown as { reset: () => void }).reset()
await flushPromises()
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(QueryToolbar).props('modelValue')).toEqual({ keyword: '', success: '', occurredRange: [] })
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
})
it('does not query without audit permission', async () => {
const wrapper = mountPanel([]); await flushPromises()
expect(listPlatformAuditLogs).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('暂无操作日志查看权限')
})
it('loads detail and formats json snapshots', async () => {
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...row, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
await wrapper.get('button[aria-label="查看操作日志1详情"]').trigger('click'); await flushPromises()
expect(getPlatformAuditLog).toHaveBeenCalledWith('1')
expect(wrapper.text()).toContain('旧名称')
expect(wrapper.text()).toContain('新名称')
expect(wrapper.text()).toContain('入库测量审核通过')
expect(wrapper.text()).toContain('入库测量')
expect(wrapper.text()).toContain('头程履约服务')
expect(wrapper.text()).toContain('更新')
expect(wrapper.find('.audit-detail-grid').exists()).toBe(true)
})
it('loads the selected audit page', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
wrapper.findComponent(QueryToolbar).vm.$emit('search', { keyword: 'tenant', success: true, occurredRange: [] })
await flushPromises()
vi.mocked(listPlatformAuditLogs).mockClear()
;(wrapper.vm as unknown as { page: number }).page = 2
await flushPromises()
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 2, size: 20, keyword: 'tenant', success: true, occurredFrom: undefined, occurredTo: undefined })
})
it('clears draft and applied filters when switching customers', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { keyword: 'draft', success: false, occurredRange: [] })
toolbar.vm.$emit('search', { keyword: 'applied', success: true, occurredRange: [] })
await flushPromises()
vi.mocked(listPlatformAuditLogs).mockClear()
await wrapper.setProps({ customerId: '9' })
await flushPromises()
expect(wrapper.findComponent(QueryToolbar).props('modelValue')).toEqual({ keyword: '', success: '', occurredRange: [] })
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '9', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
})
})

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