diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0241e51 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.playwright-cli +.impeccable +dist +node_modules +node_modules.broken-by-path +.env.local +npm-debug.log* +pnpm-debug.log* diff --git a/.env.example b/.env.example index 17b31d0..6144c14 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ VITE_API_BASE_URL=/api +VITE_OVERSEAS_API_BASE_URL=/overseas-api +OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300 API_PROXY_TARGET=http://127.0.0.1:9000 +VITE_ADMIN_WEB_ORIGIN=http://localhost:5173 diff --git a/.gitignore b/.gitignore index e2bc4c3..9b7adcb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ node_modules.broken-by-path/ dist/ +tmp/ .env .env.* @@ -16,4 +17,3 @@ outputs/ *.log .DS_Store Thumbs.db - diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/client-web.iml b/.idea/client-web.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/client-web.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..07115cd --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e8c26cd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a7f2244..ce0f91d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,8 @@ ## 项目定位 - 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。 -- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单。 +- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。 +- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/跨区域认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。 - 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。 ## 技术基线 @@ -16,30 +17,72 @@ ## 目录与职责 -- `src/views/`:路由级页面和业务流程入口;复用交互拆入组件。 +- `src/modules/`:按业务域纵向组织页面、路由、业务组件、API、store、类型、数据适配和业务工具。新增业务页面必须进入对应模块,不再在 `src/views/` 堆放。 +- `src/modules//routes.ts`:作为模块 manifest 同时声明页面路由、导航元数据和发布状态;`src/modules/registry.ts` 统一组装模块,`src/router/index.ts` 只负责应用壳层和全局守卫。 +- manifest 中 `release: 'active'` 的页面才会进入路由和导航;占位页面必须标记为 `release: 'prototype'`,保留源码但不可通过 URL 访问。接口、权限资源和产品范围确认后才能改为 `active`。 +- `src/components/common/`:跨两个及以上业务模块实际复用的通用组件;仅被一个模块使用的组件保留在该模块内。 - `src/layouts/`:租户工作台壳层、导航和全局页面结构。 - `src/router/`:公开页、受保护路由、页面标题和权限元数据;导航与路由必须同步。 -- `src/stores/`:认证、当前租户和跨页面业务状态;局部表单状态留在页面或组件内。 -- `src/api/`:统一处理基础地址、超时、认证头、响应解包和错误映射;页面不得直接创建独立 Axios 客户端。 -- `VITE_API_BASE_URL` 控制浏览器请求前缀,`API_PROXY_TARGET` 控制 Vite 开发代理上游;后者只由开发服务器读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。 +- 模块内 `store/`:该业务跨页面状态;局部表单状态仍留在页面或组件内。跨模块使用 store 时只能导入其公开 store 文件,不得读取模块内部页面或组件实现。 +- `src/api/`:只保留 HTTP、会话令牌、响应类型等全局请求基础设施;业务接口放入对应模块的 `api/`。页面不得直接创建独立 Axios 客户端。 +- 模块之间不得通过相对路径或内部目录穿透;确需协作时只能从目标模块的 `public.ts` 导入。模块内稳定契约放在 `contracts.ts`,稳定的跨应用模型仍应上移到 `@chongchong/shared`。 +- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内和海外浏览器前缀;`API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 只由 Vite 开发代理读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。 - `src/styles/`:全局设计令牌和布局;优先复用现有 `--primary`、`--navy`、`--border`、`--muted`。 -- 跨应用稳定模型放到 `packages/shared`,客户工作台专属类型保留在本项目。 +- 本应用多个模块稳定复用的前端模型放到应用内 `shared/`,客户工作台专属类型保留在对应业务模块。不要链接应用目录之外的 TypeScript 包,服务端契约以 `baiyue-firstleg` Controller/OpenAPI 为准。 + +## 开发环境样式验收 + +- 开发环境验收使用本轮动态创建并已审核通过的客户管理员,或由用户显式提供的客户账号,不依赖固定业务客户或固定账号。 +- 账号与密码不写入仓库;从本机未提交环境变量或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取。若显式验收凭据不存在,再向用户索取。 +- 该身份仅适用于开发环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;开发环境变更时只更新本机凭据。 +- 由人工使用该身份进入真实页面,按 `1920×1080` 视口和 `100%` 浏览器缩放完成操作与截图,再把截图发送给 AI 核对加载、空态、错误态、权限态和桌面布局;AI 默认不主动调用截图或视觉类技能取证。 + +## 模块快速查询 + +退货预报与签收结果:`src/modules/overseas-warehouse/views/ReturnOrdersView.vue`、`api/returnOrders.ts`;菜单仍名为“退货换标”,正式路由 `return-relabel` 已使用独立页面,不再由 `WarehouseServiceView.vue` 承载。客户退货库存/箱号共用 `views/ReturnInventoryView.vue`、`api/returnInventory.ts`,`return-cartons` 传 `cartonOnly=true`。Backend 为 Lastleg `returnreceipt/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。 + +先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。 + +| 业务 | 模块入口 | 联查模块或服务端 | +|---|---|---| +| 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常旋转的国内/海外 Token 会话统一持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token | +| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换。前端角色树到服务端权限的映射统一维护在 `contracts.ts` 的 `FRONTEND_TO_MERCHANT_PERMISSION_CODES`,海外入仓、采购、本土卡车和散板权限必须显式进入该映射 | +| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` | +| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` | +| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,独立计费重弹框 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。所有订单行均可进入计费重弹框,尚未推送时展示明确空态;只有具备编辑权限且服务端返回当前 `SENT` 费用时才可二次确认并提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 | +| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` | +| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | +| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 | +| 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 | +| 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;采购创建的 `customerName` 只取当前认证会话企业名称,不允许自由输入或冒用其他客户。Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 | +| 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID | +| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标申请必须提交正数 CNY `chargeAmount`,换标类型为只换 SKU、只换箱唛、SKU+箱唛。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以旧箱唛连接旧/新 SKU 和旧箱唛/新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID | ## 租户、认证与数据安全 +- 换标申请必须提交正数 CNY `chargeAmount`;入口为 `src/modules/overseas-warehouse/views/WarehouseServiceView.vue` 与 `api/warehouseServices.ts`,金额由服务端再次校验,资金状态以服务端返回为准,不在客户端模拟冻结成功。 + - 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。 - 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。 -- 认证统一经过 `src/stores/auth.ts` 和 `src/api/http.ts`;不要在页面中复制令牌逻辑或持久化密码、敏感资质内容。 +- 注册时登录手机号同时初始化公司联系电话;注册完成后两者独立维护。企业资料只修改 `contactPhone`,个人资料或成员管理中的登录手机号由 IAM/Auth 同步并撤销该账号现有会话,不得在前端互相回填或联动提交。 +- 认证统一经过 `src/modules/auth/store/auth.ts`、`src/api/http.ts`、`src/api/session.ts`;国内请求使用国内 Access Token,海外请求统一由 `src/api/overseasHttp.ts` 读取短时海外 Token。启动时 Access Token 已过期但 Refresh Token 仍存在,必须先尝试恢复会话;并发或跨标签页 401 共用一次刷新编排,每个原请求最多重放一次,只有明确 401/会话失效才清理 Token 并保留原路径跳转登录页。登录页“记住账号”只保存登录 ID,不改变 Access/Refresh Token 的持久化与续期策略;页面不要复制令牌逻辑或持久化密码、敏感资质内容。 - 当前认证仍包含模拟数据。接入真实 API 后移除对应模拟分支,生产环境不得回退为模拟成功。 +- 头程订单统一使用当前正式读写契约;不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API、历史部分响应兼容或其他新旧并跑逻辑。触及该链路时按根无兼容规则同步删除生产分支、类型、文案、Mock 和测试,不以现有代码或测试仍覆盖旧路径作为保留依据。 - 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。 ## UI 与交互 +- 修改已有 Client 原型页面前先读 `../../docs/Admin-Client前端原型还原验收规则.md`,按页面和操作形成原型核对矩阵;本节维护 Client 的实现约束,不复制跨前端验收文档全文。 +- 已向客户讲解或确认过的产品原型是页面视觉与布局实现的直接事实来源。字段顺序、字段分组、字段摆放、列宽、控件宽高、对齐方式、换行方式、区块层级、上下左右边距、内边距、间隙、留白、圆角、边框、阴影、背景色、文字颜色和字号均必须按原型还原,不得只参考大致风格后自行重新排版。 +- 原型中的按钮位置、顺序、尺寸、圆角、边框、背景色、文字色、图标、悬停态、激活态、禁用态和危险语义必须直接按原型实现;不得擅自改为文字链接、不同颜色、不同按钮类型、折叠菜单或自创交互效果,也不得增加原型没有的上浮、缩放、渐变线、装饰图标或其他视觉元素。 +- 原型已经给出明确图标、字符图标、图片资源或按钮内图标时,直接使用原型中的同一方案和资源,不得以 Lucide、Element Plus 默认图标、手绘图标或个人偏好替换。仅当原型没有定义对应图标时,才按本节的默认图标语义补齐。 +- 上述原型还原规则优先于本文件中“延续现有视觉体系”、默认组件样式和默认图标库等一般性规则。只有用户当前明确要求、阶段范围、真实字段长度、响应式、可访问性或接口事实与原型冲突时才允许做最小调整,并须在修改前说明具体差异、原因和影响范围;不得以技术实现方便为由偏离原型。 +- 详情、新增、编辑、审核等操作采用独立页面、居中弹框、侧滑面板或其他承载方式,必须逐页面按对应原型确认,不得全局写死为某一种容器。原型为独立页面时使用正式路由和页面布局;原型为居中弹框时使用模态弹框;只有原型明确为侧滑面板时才使用抽屉。不得因为已有公共组件或改动方便,把原型中的独立页面改成弹框、把弹框改成抽屉,或反向替换。 - 延续现有客户工作台的可信、简洁和服务型视觉语言,与内部管理后台保持品牌关联但不照搬高密度管理界面。 - 优先使用 Element Plus 和 Lucide 图标;不使用表情符号代替功能图标,不引入第二套组件库。 - 业务页按实际场景覆盖适用的加载、空数据、失败、无权限和数据过期状态;危险操作需要确认和明确结果。 - 保持键盘可达、可见焦点、语义化标签、足够对比度和清晰错误提示。 -- 响应式变更至少检查 320px、720px 和桌面布局;避免只能横向滚动才能完成核心任务。 +- 当前样式开发与验收只处理 `1920×1080` 桌面视口,不检查或整改 `720px`、`320px` 及其他窄屏;这些视口不作为测试、截图或交付门禁。保留现有响应式实现,不为当前阶段主动删除,用户恢复多视口验收后再统一处理。 ## 本地命令与质量门禁 @@ -56,3 +99,21 @@ pnpm build - 日常变更至少运行 `pnpm typecheck`;交付前运行 `pnpm build`。 - 当前测试使用 Node.js 内置测试运行器,执行 `pnpm test`;项目仍未声明 lint 脚本,不得声称 lint 已通过。 - 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目依赖清单和锁文件。 + +## 全量验收脚本 + +- Client 全量验收唯一入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;入库测量与计费专项场景才单独执行 `client-phase2-test-env-acceptance.mjs`,不替代完整入口。 +- 入口通过测试环境 HTTP 验证注册/审核、客户隔离、订单、入库计费、文件、财务和权限链路,会创建或修改测试数据,必须获得当前对话明确授权。 +- `pnpm test/typecheck` 和 `frontend-full-regression.ps1` 仅是本地质量检查,不代表 Client 服务已完成全量验收。 +- HTTP 验收通过后,由人工使用开发验收账号逐页检查真实接口调用、客户可见字段、原型、权限、错误态、空态和 `1920×1080` 桌面布局,并将截图发送给 AI 分析;未收到人工截图时标记为待人工确认,不追加 AI 截图或窄屏浏览器验收。 + +## Client 全量验收快速索引 + +当前按客户侧全量业务闭环验收。业务域文档用于核对功能来源和验收证据,不限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。 + +| 验收域 | Client 范围与代码入口 | 验收首读 | +|---|---|---| +| 客户基础与订单 | 登录/注册、企业资料与资质、成员/角色权限、工作台和订单 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/`、`order-management/`;按客户可见契约核对权限、状态、幂等和错误处理 | +| 履约、海外仓与财务 | 入库计费、柜次进度、船期、海外仓贴标/转运/废弃/退货换标、消息和客户账单 | `src/modules/first-leg/`、`overseas-warehouse/`、`finance/`、`notifications/`;按服务端权限核对客户可见字段、客户隔离和只读/写入边界 | + +客户侧边界:仍以服务端权限和客户可见字段为准;PDA、平台后台操作和内部证据不因全量验收自动暴露到 Client。财务按 `../../docs/财务管理/` 与 `src/modules/finance/routes.ts` 定位。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77cd790 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# 构建 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 自动复用。 +RUN pnpm config set registry https://registry.npmmirror.com && \ + pnpm install --frozen-lockfile + +COPY . . +ARG VITE_ADMIN_WEB_ORIGIN +ENV VITE_ADMIN_WEB_ORIGIN=${VITE_ADMIN_WEB_ORIGIN} +RUN pnpm build + +# 运行阶段仅保留构建产物与 Nginx。 +FROM ${NGINX_IMAGE} + +COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..3b003a9 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,176 @@ +pipeline { + agent { + label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}" + } + + environment { + DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml' + DEVELOPMENT_COMPOSE_PROJECT = 'client-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/client-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_ADMIN_WEB_ORIGIN=https://admin.buggoglobal.com \ + -t "$image" . + docker push "$image" + ''' + } + } + } + + stage('确认部署生产前端') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + input message: "确认将 main 提交 ${env.SOURCE_TAG} 部署到 app.buggoglobal.com?", ok: '确认部署' + } + } + + stage('部署生产前端') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + lock(resource: 'firstleg-production-client-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 client-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="client-web-development:${SOURCE_TAG}" + + docker build \ + --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ + --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ + --build-arg VITE_ADMIN_WEB_ORIGIN=http://8.163.49.32:9001 \ + -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 client-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 client-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 client-web-development || true + else + docker compose -f docker-compose.test.yml logs --tail=100 || true + fi + ''' + } + } +} diff --git a/README.md b/README.md index e840607..74d02aa 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,28 @@ pnpm build pnpm test ``` +## 源码组织 + +业务代码按领域放在 `src/modules//`。每个模块可以包含 `views/`、`components/`、`api/`、`store/`、`types/`、`data/`、`utils/`、`contracts.ts`、`public.ts` 和 `routes.ts`,只创建实际需要的目录。 + +模块的 `routes.ts` 是路由与导航共用的 manifest。正式页面标记 `release: 'active'`;尚未接入接口、权限或未进入当前范围的占位页面标记 `release: 'prototype'`,不会被注册为可访问路由。`src/modules/registry.ts` 负责统一装配模块,根路由不再逐页维护配置。 + +跨模块调用只能经过目标模块的 `public.ts`;模块内部 API、store、组件和工具不作为跨模块接口。租户状态、权限映射等稳定模块契约放在 `contracts.ts`。 + +`src/components/common/` 只存放真实跨模块复用的组件,`src/api/` 只存放 HTTP、Token 会话和统一响应等基础设施。模块私有能力不要提前上移到全局目录。 + 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 -一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到 `http://127.0.0.1:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 +订单与客户路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限、头程订单、海外采购、美国本土卡、美本土散板以及海外仓入库订单。跨境下单支持真实列表、详情和新增,海外入库支持当前客户列表与详情;均经海外 Gateway 的客户接口访问,客户范围只取 Token,不提交客户 ID。开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 -认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限取 JWT 声明与后端启用权限资源的交集。前端权限仅用于导航和交互控制,服务端仍须按用户、租户和权限编码完成最终授权。 +认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。 -本地联调账号提示和默认用户名只在 Vite 开发模式下启用,URL 显式传入的登录用户名仍优先。生产登录表单默认留空,生产构建产物不得包含本地联调账号或开发提示文案。 +登录表单在开发和生产环境默认留空;用户主动选择“记住账号”后只回填登录账号、不保存密码,也不改变 Access/Refresh Token 的持久化和刷新策略;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。 -“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除虽在回归报告中通过,但路径尚未进入当前 Gateway OpenAPI,前端继续保持禁用。 +“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `roleIds` 统计。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除分别调用 `PUT /merchant/v1/iam/roles/{roleId}`、`PUT /merchant/v1/iam/roles/{roleId}/status` 和 `DELETE /merchant/v1/iam/roles/{roleId}`,内置角色及仍有关联成员的角色继续受保护。 -企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照上传使用文件服务,仅接受 JPG、PNG 且不超过 5MB,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。 +企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照、法人身份证为必填资质,货代资质证书和银行开户许可证为选填资质,均使用文件服务分别上传,接受 PDF、JPG、PNG 且不超过 5MB;两项必填资质齐全后可显式提交审核,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。 -头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。收发货单位通过 `/merchant/v1/partners/candidates` 按类型读取启用候选并保存订单快照,不再使用演示单位兜底;快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 +头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地订单与客户验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 -联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://192.168.110.149:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。 +联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。 diff --git a/docker-compose.development.yml b/docker-compose.development.yml new file mode 100644 index 0000000..d52a739 --- /dev/null +++ b/docker-compose.development.yml @@ -0,0 +1,21 @@ +services: + client-web: + image: client-web-development:${IMAGE_TAG:?IMAGE_TAG is required} + container_name: client-web-development + restart: unless-stopped + ports: + - "127.0.0.1:9002:80" + environment: + API_UPSTREAM: "http://firstleg-development-gateway:9000" + OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300" + networks: + - development-backend + - development-overseas-backend + +networks: + development-backend: + external: true + name: firstleg-development-network + development-overseas-backend: + external: true + name: firstleg-development-overseas-network diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..167b779 --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,19 @@ +name: firstleg-prod-client-web + +services: + client-web: + image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required} + container_name: firstleg-prod-client-web + restart: unless-stopped + ports: + - "127.0.0.1:${HOST_PORT:-9002}:80" + environment: + API_UPSTREAM: "http://gateway:9000" + OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300" + networks: + - infra-network + +networks: + infra-network: + external: true + name: firstleg-prod-infra-network diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..c3d40ea --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,17 @@ +services: + client-web: + build: + context: . + dockerfile: Dockerfile + args: + VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-https://test-admin.buggoglobal.com}" + NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" + NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" + image: client-web-test:local + container_name: client-web-test + restart: unless-stopped + ports: + - "${HOST_PORT:-9002}:80" + environment: + API_UPSTREAM: "http://test-api-cn.buggoglobal.com" + OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com" diff --git a/index.html b/index.html index be4f977..33f7886 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,9 @@ - - 虫虫出海客户工作台 + + + 柏悦物流客户工作台
@@ -12,3 +13,4 @@ + diff --git a/nginx/default.conf.template b/nginx/default.conf.template new file mode 100644 index 0000000..68ea42b --- /dev/null +++ b/nginx/default.conf.template @@ -0,0 +1,55 @@ +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location = /healthz { + default_type text/plain; + return 200 "ok\n"; + } + + # SPA 入口每次重新校验,避免发布后浏览器继续运行旧资源清单。 + location = /index.html { + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + } + + # Vite 产物文件名带内容哈希,可安全长期缓存。 + location /assets/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + # 前端统一请求 /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;浏览器前缀只在此处改写一次为 /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; + } + + # 支持 Vue Router history 路由直接刷新。 + location / { + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + try_files $uri $uri/ /index.html; + } +} diff --git a/package.json b/package.json index f5e7a83..e2ca4b9 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "preview": "vite preview --port 4174" }, "dependencies": { - "@chongchong/shared": "link:../../packages/shared", + "@chongchong/shared": "link:./shared", "axios": "^1.18.1", + "dayjs": "^1.11.21", "element-plus": "^2.14.3", "@lucide/vue": "^1.27.0", "pinia": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 115db38..54dd71e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,14 +9,17 @@ 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)) diff --git a/public/customer-recharge-qr.svg b/public/customer-recharge-qr.svg new file mode 100644 index 0000000..2e087a1 --- /dev/null +++ b/public/customer-recharge-qr.svg @@ -0,0 +1,12 @@ + + 开发环境示例收款二维码 + 仅用于开发流程验收,不包含真实收款信息 + + + + + + + 开发示例 + 不可付款 + diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 0000000..821a711 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 4 ]; then + echo "用法: $0 <服务名> <镜像仓库> <镜像标签> " >&2 + exit 2 +fi + +SERVICE="$1" +IMAGE_REPOSITORY="$2" +IMAGE_TAG="$3" +COMPOSE_FILE="$4" +IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG" +REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" +SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" +SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" +SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" +REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" +: "${REGISTRY_USER:?REGISTRY_USER 未设置}" +: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" + +run_ssh() { + ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@" +} + +run_scp() { + scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" +} + +run_ssh install -d -m 750 "$REMOTE_DIR" +REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml" +run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp" +run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE' +set -eu +compose_tmp="$1" +compose_target="$2" +chmod 644 "$compose_tmp" +mv -f "$compose_tmp" "$compose_target" +REMOTE_PREPARE + +printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" +trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM +run_ssh docker pull "$IMAGE" + +run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY' +set -eu +remote_dir="$1" +service="$2" +image_repository="$3" +image_tag="$4" +compose_file="docker-compose.$service.yml" +project_name="firstleg-prod-$service" +cd "$remote_dir" +export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag" + +compose() { + docker compose --project-name "$project_name" -f "$compose_file" "$@" +} + +container_id="$(compose ps --all --quiet "$service" | head -n 1)" +previous_image="" +if [ -n "$container_id" ]; then + previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")" +fi + +if compose up -d --no-deps --pull never "$service" && \ + compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then + echo "生产前端部署成功: $image_repository:$image_tag" + compose ps "$service" + exit 0 +fi + +echo "生产前端健康检查失败,准备回滚" >&2 +compose logs --tail=100 "$service" >&2 || true +case "$previous_image" in + "$image_repository:"*) ;; + '') echo "首次部署无旧镜像可回滚" >&2; exit 1 ;; + *) echo "旧镜像不属于当前应用,拒绝回滚: $previous_image" >&2; exit 1 ;; +esac +previous_tag="${previous_image#"$image_repository:"}" +if [ "$previous_tag" = "$image_tag" ]; then + echo "旧镜像与新镜像一致,无法回滚" >&2 + exit 1 +fi +docker pull "$previous_image" +export IMAGE_TAG="$previous_tag" +compose up -d --no-deps --pull never "$service" +compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true +echo "已回滚至: $previous_image" >&2 +exit 1 +REMOTE_DEPLOY diff --git a/shared/AGENTS.md b/shared/AGENTS.md new file mode 100644 index 0000000..e6bf65b --- /dev/null +++ b/shared/AGENTS.md @@ -0,0 +1,30 @@ +# 应用内 Shared Package Instructions + +## 项目定位 + +- 本包只服务当前前端应用,提供多个业务模块共同使用的 TypeScript 类型、常量和轻量工具。 +- 依赖保持为 `@chongchong/shared: link:./shared`,确保当前应用目录可作为独立容器构建上下文。 +- 不承担 Admin、Client 之间的源码复用;跨应用字段需要分别映射同一份 Java Controller/OpenAPI 契约,不能通过目录外链接耦合两个部署单元。 + +## 公共 API 与依赖边界 + +- `src/index.ts` 是当前唯一公共入口;模块专属页面状态、UI 组件和临时业务逻辑留在对应模块。 +- 共享类型保持前端平台无关,不编码鉴权结论;租户隔离、权限、状态机和数据合法性由 `baiyue-firstleg` Java 服务端最终保证。 +- 服务端字段变化先核对 Controller、DTO 和 OpenAPI,再更新本应用的类型、API 适配层和测试;本包不反向定义服务端行为。 +- 避免建立宽泛的万能类型或引入重量级依赖。 + +## 契约与验证 + +- 修改导出名称、字段类型、枚举值或常量语义前,搜索当前应用内全部消费者。 +- 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。 +- 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。 + +## 订单与客户/入库测量与计费/国内货柜履约契约索引 + +- 订单与客户共享模型服务客户认证、企业资料/成员权限和头程订单;修改订单状态、仓库摘要、资质或权限枚举前,先核对阶段契约并搜索 `src/modules/` 全部消费者。 +- 入库测量与计费仅承接客户可见的到仓与计费结果。`WAIT_SPLIT + APPROVED + CONFIRMED` 为当前终点;内部测量明细、备注和文件权限不得通过共享类型绕过。 +- 国内货柜履约仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Client 模块复用时才新增共享导出;先读 `../../../docs/国内货柜履约/国内货柜履约开工门禁.md`。只建客户可见的柜次、进度、授权回执和船期类型,不通过 shared 暴露照片、锁码或后台内部资料。 + +定位规则:从 `src/index.ts` 的导出开始,反查模块 `api`、store 和 `contracts.ts` 消费者;字段含义不明确时回到服务端 DTO/OpenAPI,不在 shared 中扩展业务语义。 + +业务域文档入口(路径相对本文件所在目录):订单与客户 `../../../docs/订单与客户/README.md`;入库测量与计费 `../../../docs/入库测量与计费/README.md` 与 `../../../docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`;国内货柜履约 `../../../docs/国内货柜履约/README.md`、`../../../docs/国内货柜履约/国内货柜履约开发规划.md` 和 `../../../docs/国内货柜履约/国内货柜履约需求确认表.md`。范围与契约以正式确认和设计文档为准,原型只作补充。 diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 0000000..fdc627d --- /dev/null +++ b/shared/README.md @@ -0,0 +1,5 @@ +# 应用内共享模块 + +本目录存放当前前端应用多个业务模块共同使用的 TypeScript 类型、常量和轻量工具,通过 `@chongchong/shared: link:./shared` 引用。 + +该目录必须保留在应用内部,以支持 Admin、Client 分别使用自身目录作为容器构建上下文。它不作为 Java 服务端契约来源;接口字段、状态机、权限和业务规则以 `baiyue-firstleg` Controller、DTO 和 OpenAPI 为准。 diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..5e802c2 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,11 @@ +{ + "name": "@chongchong/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "types": "./src/index.ts" +} + diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..a1a89da --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,157 @@ +export const PRODUCT_NAME = '虫虫出海' + +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER) + +function quoteUnsafeIntegerTokens(source: string) { + let output = '' + let index = 0 + let inString = false + let escaped = false + + while (index < source.length) { + const character = source[index] + + if (inString) { + output += character + if (escaped) escaped = false + else if (character === '\\') escaped = true + else if (character === '"') inString = false + index += 1 + continue + } + + if (character === '"') { + inString = true + output += character + index += 1 + continue + } + + if (character === '-' || (character >= '0' && character <= '9')) { + const token = source.slice(index).match(/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/)?.[0] + if (token) { + const isInteger = !token.includes('.') && !/[eE]/.test(token) + if (isInteger && (BigInt(token) > MAX_SAFE_INTEGER || BigInt(token) < -MAX_SAFE_INTEGER)) output += JSON.stringify(token) + else output += token + index += token.length + continue + } + } + + output += character + index += 1 + } + + return output +} + +export function parseJsonWithSafeIntegers(source: string) { + return JSON.parse(quoteUnsafeIntegerTokens(source)) as unknown +} + +export function parseApiJsonResponse(data: unknown) { + if (typeof data !== 'string') return data + const trimmed = data.trim() + if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return data + try { + return parseJsonWithSafeIntegers(data) + } catch { + return data + } +} + +export type AppCode = 'admin' | 'client' + +export interface CurrentUser { + id: string + name: string + account: string + tenantName?: string + roles: string[] +} + +export interface NavigationItem { + path: string + title: string + permission?: string +} + +export interface ApiResponse { + code: number + message: string + data: T +} + +export const FIRST_LEG_ORDER_STATUSES = [ + { code: 'DRAFT', label: '草稿' }, + { code: 'PENDING_REVIEW', label: '待审核' }, + { code: 'WAIT_MEASURE', label: '待测量数据' }, + { code: 'WAIT_PALLETIZE', label: '待组托' }, + { code: 'WAIT_SPLIT', label: '待分柜' }, + { code: 'LOADING', label: '装柜中' }, + { code: 'LOADED', label: '装柜完成' }, + { code: 'DELIVERED', label: '派送完成' }, + { code: 'COMPLETED', label: '已完成' }, +] as const + +export type FirstLegOrderStatus = (typeof FIRST_LEG_ORDER_STATUSES)[number]['code'] +export type FirstLegOrderStatusLabel = (typeof FIRST_LEG_ORDER_STATUSES)[number]['label'] + +export const FIRST_LEG_TRANSPORT_MODES = [ + { code: 'FCL', label: '海运整箱 FCL' }, + { code: 'LCL', label: '海运拼箱 LCL' }, + { code: 'AIR', label: '空运' }, + { code: 'ROAD', label: '陆运' }, +] as const + +export type FirstLegTransportMode = (typeof FIRST_LEG_TRANSPORT_MODES)[number]['code'] +export type FirstLegTransportModeLabel = (typeof FIRST_LEG_TRANSPORT_MODES)[number]['label'] + +export type PlatformWarehouseStatus = 'ACTIVE' | 'INACTIVE' + +export interface PlatformReceivingWarehouse { + id: string + code: string + name: string + province: string + city: string + address: string + contactName: string + contactPhone: string + businessHours: string + status: PlatformWarehouseStatus + remark: string + updatedAt: string +} + +export interface PlatformReceivingWarehouseSnapshot { + id: string + code: string + name: string + province: string + city: string + address: string +} + +export function createPlatformReceivingWarehouseSnapshot( + warehouse: PlatformReceivingWarehouse, +): PlatformReceivingWarehouseSnapshot { + return { + id: warehouse.id, + code: warehouse.code, + name: warehouse.name, + province: warehouse.province, + city: warehouse.city, + address: warehouse.address, + } +} + +// API 接入前供 Admin 与 Client 静态原型共用,不能作为服务端真实仓库数据。 +export const DEMO_PLATFORM_RECEIVING_WAREHOUSES: readonly PlatformReceivingWarehouse[] = [ + { id: 'WH001', code: 'CN-YW-01', name: '义乌仓', province: '浙江省', city: '义乌市', address: '北苑街道演示物流园 1 号库', contactName: '仓库客服', contactPhone: '0579-****001', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '支持普通货物收货。', updatedAt: '2026-07-28 10:20' }, + { id: 'WH002', code: 'CN-SZ-01', name: '深圳仓', province: '广东省', city: '深圳市', address: '盐田区演示物流园 2 号库', contactName: '仓库客服', contactPhone: '0755-****002', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '华南区域主要收货仓。', updatedAt: '2026-07-28 10:20' }, + { id: 'WH003', code: 'CN-QD-01', name: '青岛仓', province: '山东省', city: '青岛市', address: '黄岛区演示物流园 3 号库', contactName: '仓库客服', contactPhone: '0532-****003', businessHours: '周一至周五 09:00-17:30', status: 'ACTIVE', remark: '', updatedAt: '2026-07-28 10:20' }, + { id: 'WH004', code: 'CN-SH-01', name: '上海仓', province: '上海市', city: '上海市', address: '浦东新区演示物流园 4 号库', contactName: '仓库客服', contactPhone: '021-****004', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '华东区域综合收货仓。', updatedAt: '2026-07-28 10:20' }, + { id: 'WH005', code: 'CN-NB-01', name: '宁波仓', province: '浙江省', city: '宁波市', address: '北仑区演示物流园 5 号库', contactName: '仓库客服', contactPhone: '0574-****005', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '临近宁波港。', updatedAt: '2026-07-28 10:20' }, + { id: 'WH006', code: 'CN-XM-01', name: '厦门仓', province: '福建省', city: '厦门市', address: '湖里区演示物流园 6 号库', contactName: '仓库客服', contactPhone: '0592-****006', businessHours: '周一至周五 09:00-17:30', status: 'INACTIVE', remark: '静态演示:暂停接收新订单。', updatedAt: '2026-07-28 10:20' }, +] diff --git a/src/App.vue b/src/App.vue index 4be8965..77311e0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,4 +1,53 @@ - + + + diff --git a/src/api/crossTabRefresh.test.mjs b/src/api/crossTabRefresh.test.mjs new file mode 100644 index 0000000..200c354 --- /dev/null +++ b/src/api/crossTabRefresh.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh.ts' + +test('reuses tokens rotated by another browser tab while waiting for the refresh lock', async () => { + let currentRefreshToken = 'refresh-old' + let refreshCalls = 0 + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + async request(_name, _options, callback) { + currentRefreshToken = 'refresh-new' + return callback() + }, + }, + }, + }) + + try { + const result = await coordinateCrossTabRefresh({ + lockName: 'client-refresh', + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => currentRefreshToken, + reuseCurrentSession: async () => 'access-new', + refresh: async () => { + refreshCalls += 1 + return 'unexpected' + }, + }) + + assert.equal(result, 'access-new') + assert.equal(refreshCalls, 0) + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) + } +}) + +test('refreshes normally when this tab owns the current refresh token', async () => { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { locks: { request: async (_name, _options, callback) => callback() } }, + }) + + try { + const result = await coordinateCrossTabRefresh({ + lockName: 'client-refresh', + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => 'refresh-old', + reuseCurrentSession: async () => 'unexpected', + refresh: async (refreshToken) => `access-for-${refreshToken}`, + }) + + assert.equal(result, 'access-for-refresh-old') + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) + } +}) + +test('does not wait for rotation after the shared session was removed', async () => { + const originalNavigator = globalThis.navigator + const refreshError = new Error('session removed') + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {} }) + + try { + await assert.rejects(coordinateCrossTabRefresh({ + lockName: 'client-refresh', + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => '', + reuseCurrentSession: async () => 'unexpected', + refresh: async () => { throw refreshError }, + }), refreshError) + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) + } +}) + +test('aborts a refresh lock that cannot be acquired', async () => { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + request(_name, options) { + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true }) + }) + }, + }, + }, + }) + + try { + await assert.rejects(coordinateCrossTabRefresh({ + lockName: 'client-refresh', + lockTimeoutMs: 5, + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => 'refresh-old', + reuseCurrentSession: async () => 'unexpected', + refresh: async () => 'unexpected', + }), CrossTabRefreshTimeoutError) + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) + } +}) diff --git a/src/api/crossTabRefresh.ts b/src/api/crossTabRefresh.ts new file mode 100644 index 0000000..11252f2 --- /dev/null +++ b/src/api/crossTabRefresh.ts @@ -0,0 +1,56 @@ +export interface CrossTabRefreshOptions { + lockName: string + lockTimeoutMs?: number + observedRefreshToken: string + currentRefreshToken: () => string + reuseCurrentSession: () => Promise + refresh: (refreshToken: string) => Promise +} + +export class CrossTabRefreshTimeoutError extends Error { + constructor() { + super('登录状态刷新等待超时,请重新登录') + this.name = 'CrossTabRefreshTimeoutError' + } +} + +const DEFAULT_LOCK_TIMEOUT_MS = 20_000 + +export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptions): Promise { + 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) + } +} diff --git a/src/api/errorDetails.ts b/src/api/errorDetails.ts new file mode 100644 index 0000000..e932bab --- /dev/null +++ b/src/api/errorDetails.ts @@ -0,0 +1,16 @@ +export type ApiFieldErrors = Record + +export function asFieldErrors(value: unknown): ApiFieldErrors | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} + +export function apiErrorMessage(message: string, fieldErrors?: ApiFieldErrors) { + const baseMessage = message.trim() || '请求失败' + const entries = Object.entries(fieldErrors ?? {}) + if (entries.length === 0) return baseMessage + if (entries.every(([field, detail]) => baseMessage.includes(field) && baseMessage.includes(detail))) return baseMessage + const details = entries.map(([field, detail]) => `${field}: ${detail}`).join(';') + return `${baseMessage}:${details}` +} diff --git a/src/api/firstLeg.ts b/src/api/firstLeg.ts deleted file mode 100644 index c56d3cc..0000000 --- a/src/api/firstLeg.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { apiRequest } from '@/api/http' - -export type ApiFirstLegStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED' | 'PROCESSING' | 'COMPLETED' -export type ApiTransportMode = 'SEA' | 'AIR' | 'RAIL' | 'TRUCK' -export type ApiPickupMethod = 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK' -export type ApiTaxMode = 'BUY_DECLARATION' | 'TAX_REFUND' -export type ApiContainerMode = 'FULL_CONTAINER' | 'LCL' -export type ApiDestinationType = 'FBA' | 'OVERSEAS' | 'PRIVATE' - -export interface PageResult { - records: T[] - page: number - size: number - total: number - pages: number -} - -export interface MerchantWarehouse { - id: number | string - warehouseCode: string - warehouseName: string - warehouseType: string - countryCode: string - timezone: string | null - contactName: string | null - contactPhone: string | null - contactEmail: string | null - status: 'ACTIVE' | 'INACTIVE' - remark: string | null - updatedAt: string | null -} - -export interface MerchantWarehouseAddress { - countryCode: string - stateProvince: string | null - city: string | null - district: string | null - postalCode: string | null - addressLine1: string - addressLine2: string | null -} - -export interface MerchantWarehouseCapability { - capabilityCode: string - capabilityName: string - enabled: boolean - cutoffTime: string | null - description: string | null -} - -export interface MerchantWarehouseDetail { - warehouse: MerchantWarehouse - addresses: MerchantWarehouseAddress[] - capabilities: MerchantWarehouseCapability[] -} - -export interface FirstLegOrderPartyPayload { - companyName: string - contactName: string - phone: string - email?: string - countryCode: string - province?: string - city: string - district?: string - addressLine: string - postalCode?: string -} - -export interface FirstLegOrderCargoPayload { - cargoName: string - hsCode?: string - packageType: string - packageCount: number - grossWeightKg: number - volumeCbm: number - declaredValue?: number - currency?: string - dangerousGoods?: boolean - cargoRemark?: string - skuNo?: string - cargoNameEn?: string - totalQuantity?: number - netWeightKg?: number -} - -export interface FirstLegOrderSaveRequest { - customerId?: number | string - customerName: string - transportMode: ApiTransportMode - tradeTerm?: string - destinationCountryCode: string - destinationWarehouseId?: number | string - destinationWarehouseName: string - expectedShipDate?: string - customerRemark?: string - sender: FirstLegOrderPartyPayload - receiver: FirstLegOrderPartyPayload - cargoes: FirstLegOrderCargoPayload[] - pickupMethod: ApiPickupMethod - receivingWarehouseId: number | string - receivingWarehouseName: string - receivingWarehouseAddress?: string | null - receivingTime?: string | null - channelCode: string - channelName: string - taxMode: ApiTaxMode - containerMode?: ApiContainerMode - customsInspection: boolean - destinationType: ApiDestinationType - amazonWarehouseCode?: string - shipmentId?: string - referenceId?: string -} - -export interface MerchantFirstLegOrder { - id: number | string - orderNo: string | null - customerId: number | string | null - customerName: string - status: ApiFirstLegStatus - transportMode: ApiTransportMode - tradeTerm: string | null - destinationCountryCode: string - destinationWarehouseId: number | string | null - destinationWarehouseName: string - expectedShipDate: string | null - customerRemark: string | null - pickupMethod: ApiPickupMethod - receivingWarehouseId: number | string - receivingWarehouseName: string - receivingWarehouseAddress?: string | null - channelCode: string - channelName: string - taxMode: ApiTaxMode - containerMode: ApiContainerMode | null - customsInspection: boolean - destinationType: ApiDestinationType - amazonWarehouseCode: string | null - shipmentId: string | null - referenceId: string | null - expressCompany?: string | null - expressTrackingNo?: string | null - version: number - createdAt: string - updatedAt: string -} - -export interface MerchantFirstLegOrderCargo extends FirstLegOrderCargoPayload { - id: number | string - itemNo: number -} - -export interface MerchantFirstLegOrderParty extends FirstLegOrderPartyPayload { - id: number | string - partyType: 'SENDER' | 'RECEIVER' | string -} - -export interface MerchantOrderStatusHistory { - id: number | string - fromStatus: ApiFirstLegStatus | null - toStatus: ApiFirstLegStatus - action: string - reason: string | null - operatorName: string | null - occurredAt: string -} - -export interface MerchantFirstLegOrderDetail { - order: MerchantFirstLegOrder - cargoes: MerchantFirstLegOrderCargo[] - parties: MerchantFirstLegOrderParty[] - internalNotes: [] - statusHistory: MerchantOrderStatusHistory[] -} - -export interface FirstLegOrderQuery { - keyword?: string - status?: ApiFirstLegStatus - customerId?: number | string - destinationWarehouseId?: number | string - createdFrom?: string - createdTo?: string - page?: number - size?: number -} - -export function getMerchantWarehouses() { - return apiRequest>({ method: 'GET', url: '/merchant/v1/warehouses', params: { page: 1, size: 200, status: 'ACTIVE' } }) -} - -export function getMerchantWarehouseDetail(id: number | string) { - return apiRequest({ method: 'GET', url: `/merchant/v1/warehouses/${id}` }) -} - -export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) { - return apiRequest>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } }) -} - -export function getMerchantFirstLegOrder(id: number | string) { - return apiRequest({ method: 'GET', url: `/merchant/v1/orders/first-leg/${id}` }) -} - -export function createMerchantFirstLegOrder(payload: FirstLegOrderSaveRequest) { - return apiRequest({ - method: 'POST', - url: '/merchant/v1/orders/first-leg', - data: payload, - headers: { 'Idempotency-Key': createIdempotencyKey('first-leg-create') }, - }) -} - -export function updateMerchantFirstLegOrder(id: number | string, payload: FirstLegOrderSaveRequest) { - return apiRequest({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload }) -} - -export function submitMerchantFirstLegOrder(id: number | string) { - return apiRequest({ - method: 'POST', - url: `/merchant/v1/orders/first-leg/${id}/submit`, - headers: { 'Idempotency-Key': createIdempotencyKey(`first-leg-submit-${id}`) }, - }) -} - -function createIdempotencyKey(scope: string) { - const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` - return `${scope}-${randomPart}` -} diff --git a/src/api/http.ts b/src/api/http.ts index 1bbd964..0d619ee 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,10 +1,14 @@ import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' +import { parseApiJsonResponse } from '@chongchong/shared' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' -import { ApiError, asFieldErrors, isApiEnvelope, type ApiEnvelope } from '@/api/types' +import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' +import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' +import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh' const transport = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15_000, + transformResponse: [(data: unknown) => parseApiJsonResponse(data)], }) interface RetryableRequest extends InternalAxiosRequestConfig { @@ -12,8 +16,9 @@ interface RetryableRequest extends InternalAxiosRequestConfig { } function unwrap(envelope: ApiEnvelope) { - if (envelope.code !== '0') { - throw new ApiError(envelope.message || '请求失败', envelope.code, undefined, asFieldErrors(envelope.data), envelope.traceId) + if (envelope.code !== '200') { + const fieldErrors = asFieldErrors(envelope.data) + throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId) } return envelope.data } @@ -24,12 +29,14 @@ function mapError(error: unknown) { const body = error.response?.data if (isApiEnvelope(body)) { + const fieldErrors = asFieldErrors(body.data) return new ApiError( - body.message || '请求失败', + apiErrorMessage(body.message, fieldErrors), body.code, error.response?.status, - asFieldErrors(body.data), + fieldErrors, typeof body.traceId === 'string' ? body.traceId : null, + typeof body.requestId === 'string' ? body.requestId : null, ) } if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR') @@ -37,18 +44,55 @@ function mapError(error: unknown) { return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status) } -let refreshPromise: Promise | null = null +let refreshPromise: Promise | null = null +let redirectingToLogin = false +const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange'] -async function refreshSession() { - const refreshToken = getRefreshToken() - if (!refreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) +function isPublicAuthRequest(url?: string) { + return publicAuthPaths.some((path) => url?.endsWith(path)) +} + +function redirectToLoginAfterSessionExpiry() { + if (redirectingToLogin || typeof window === 'undefined') return + + const { pathname, search, hash } = window.location + if (pathname === '/login' || pathname === '/register') return + + redirectingToLogin = true + const redirect = `${pathname}${search}${hash}` + window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`) +} + +export async function refreshAuthenticatedSession() { + const observedRefreshToken = getRefreshToken() + if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) if (!refreshPromise) { - refreshPromise = transport - .post>('/auth/refresh', { refreshToken }) - .then((response) => unwrap(response.data)) - .then((tokens) => { + refreshPromise = coordinateCrossTabRefresh({ + lockName: 'cc-client-auth-refresh', + observedRefreshToken, + currentRefreshToken: getRefreshToken, + reuseCurrentSession: async () => { + const currentAccessToken = getAccessToken() + if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) + return currentAccessToken + }, + refresh: async (currentRefreshToken) => { + const response = await transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }) + const tokens = unwrap(response.data) + if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401) persistTokenPair(tokens) - return tokens + return tokens.accessToken + }, + }) + .catch((error) => { + if (error instanceof CrossTabRefreshTimeoutError) { + const expiredError = new ApiError(error.message, 'A0401', 401) + clearSessionTokens() + window.dispatchEvent(new CustomEvent('cc-client-session-expired')) + redirectToLoginAfterSessionExpiry() + throw expiredError + } + throw error }) .finally(() => { refreshPromise = null @@ -59,7 +103,7 @@ async function refreshSession() { transport.interceptors.request.use((config) => { const token = getAccessToken() - if (token) config.headers.Authorization = `Bearer ${token}` + if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}` return config }) @@ -67,21 +111,23 @@ transport.interceptors.response.use( (response) => response, async (error: AxiosError) => { const request = error.config as RetryableRequest | undefined - const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh'] - const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path)) - if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) { + if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) { throw mapError(error) } request._retriedAfterRefresh = true try { - const tokens = await refreshSession() - request.headers.Authorization = `Bearer ${tokens.accessToken}` + const accessToken = await refreshAuthenticatedSession() + request.headers.Authorization = `Bearer ${accessToken}` return await transport.request(request) } catch (refreshError) { - clearSessionTokens() - window.dispatchEvent(new CustomEvent('cc-client-session-expired')) - throw mapError(refreshError) + const mappedError = mapError(refreshError) + if (mappedError.status === 401 || mappedError.code === 'A0401') { + clearSessionTokens() + window.dispatchEvent(new CustomEvent('cc-client-session-expired')) + redirectToLoginAfterSessionExpiry() + } + throw mappedError } }, ) @@ -100,8 +146,38 @@ export async function apiBlobRequest(config: AxiosRequestConfig) { const response = await transport.request({ ...config, responseType: 'blob' }) return response.data } catch (error) { + await throwBlobApiError(error) throw mapError(error) } } +export async function apiBlobResponseRequest(config: AxiosRequestConfig) { + try { + const response = await transport.request({ ...config, responseType: 'blob' }) + return { blob: response.data, headers: response.headers } + } catch (error) { + await throwBlobApiError(error) + throw mapError(error) + } +} + +async function throwBlobApiError(error: unknown) { + if (!axios.isAxiosError(error) || !(error.response?.data instanceof Blob)) return + try { + const body = JSON.parse(await error.response.data.text()) as unknown + if (!isApiEnvelope(body)) return + const fieldErrors = asFieldErrors(body.data) + throw new ApiError( + apiErrorMessage(body.message, fieldErrors), + body.code, + error.response.status, + fieldErrors, + typeof body.traceId === 'string' ? body.traceId : null, + typeof body.requestId === 'string' ? body.requestId : null, + ) + } catch (parsedError) { + if (parsedError instanceof ApiError) throw parsedError + } +} + export { ApiError } diff --git a/src/api/merchantIam.test.mjs b/src/api/merchantIam.test.mjs deleted file mode 100644 index d2c01c2..0000000 --- a/src/api/merchantIam.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' - -const source = readFileSync(new URL('./merchantIam.ts', import.meta.url), 'utf8') -const storeSource = readFileSync(new URL('../stores/tenantAdministration.ts', import.meta.url), 'utf8') - -test('declares the documented merchant role and member endpoints', () => { - assert.match(source, /url: '\/merchant\/v1\/iam\/roles'/) - assert.match(source, /url: '\/merchant\/v1\/iam\/roles\/permissions'/) - assert.match(source, /url: '\/merchant\/v1\/iam\/members'/) - assert.match(source, /url: '\/merchant\/v1\/iam\/members\/roles'/) - assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}`/) - assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}\/status`/) - assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}\/password`/) - assert.match(source, /url: '\/merchant\/v1\/iam\/profile'/) -}) - -test('supports tenant profile updates and authenticated qualification downloads', () => { - assert.match(source, /method: 'PUT', url: '\/merchant\/v1\/iam\/tenant\/profile'/) - assert.match(source, /apiBlobRequest\(\{ method: 'GET', url \}\)/) - assert.match(source, /url\.startsWith\('\/merchant\/v1\/files\/qualifications\/'\)/) - assert.match(storeSource, /await updateTenantProfile/) -}) - -test('uses membershipId and full replacement arrays in authorization writes', () => { - assert.match(source, /data: \{ roleId, permissionIds \}/) - assert.match(source, /data: \{ membershipId, roleIds \}/) - assert.doesNotMatch(source, /data: \{ userId, roleIds \}/) -}) - -test('clears mock records before loading server role and member lists', () => { - assert.match(storeSource, /rolesLoadedFromApi\.value = true\s+roles\.value = \[\]/) - assert.match(storeSource, /membersLoadedFromApi\.value = true\s+members\.value = \[\]/) - assert.match(storeSource, /await createMerchantRole/) - assert.match(storeSource, /await createMerchantMember/) - assert.match(storeSource, /await updateMerchantMember/) - assert.match(storeSource, /await changeMerchantMemberStatus/) - assert.match(storeSource, /await resetMerchantMemberPassword/) -}) diff --git a/src/api/overseasFailure.test.mjs b/src/api/overseasFailure.test.mjs new file mode 100644 index 0000000..df61ce0 --- /dev/null +++ b/src/api/overseasFailure.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import ts from 'typescript' + +const source = await readFile(new URL('./types.ts', import.meta.url), 'utf8') +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, +}).outputText +const { isApiFailure, isApiEnvelope } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`) + +test('recognizes a security failure without data and preserves diagnostics', () => { + const failure = { code: 'A0403', message: 'Access denied', requestId: 'request-1', traceId: 'trace-1' } + assert.equal(isApiFailure(failure), true) + assert.equal(isApiEnvelope(failure), false) + assert.equal(failure.requestId, 'request-1') + assert.equal(failure.traceId, 'trace-1') +}) + +test('does not accept malformed or data-less successful responses', () => { + for (const value of [null, {}, { code: 403, message: 'Denied' }, { code: 'A0403' }, + { code: 'A0403', message: 'Denied', traceId: {} }, { code: '200', message: 'OK' }]) { + assert.equal(isApiFailure(value), false) + } + assert.equal(isApiEnvelope({ code: '200', message: 'OK' }), false) + assert.equal(isApiEnvelope({ code: '200', message: 'OK', data: [] }), true) +}) + +test('overseas transport handles business-code and HTTP security failures', async () => { + const transport = await readFile(new URL('./overseasHttp.ts', import.meta.url), 'utf8') + assert.match(transport, /if \(isApiFailure\(response.data\)\)/) + assert.match(transport, /isApiEnvelope\(body\) \|\| isApiFailure\(body\)/) +}) diff --git a/src/api/overseasHttp.ts b/src/api/overseasHttp.ts new file mode 100644 index 0000000..3abc261 --- /dev/null +++ b/src/api/overseasHttp.ts @@ -0,0 +1,67 @@ +import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' +import { parseApiJsonResponse } from '@chongchong/shared' +import { ApiError, isApiEnvelope, isApiFailure } from '@/api/types' +import { getOverseasAccessToken } from '@/api/session' +import { refreshAuthenticatedSession } from '@/api/http' + +interface RetryableRequest extends InternalAxiosRequestConfig { _retriedOverseasAuth?: boolean } + +const transport = axios.create({ + baseURL: import.meta.env.VITE_OVERSEAS_API_BASE_URL || '/overseas-api', + timeout: 15_000, + transformResponse: [(data: unknown) => parseApiJsonResponse(data)], +}) + +async function requireToken() { + const current = getOverseasAccessToken() + if (current) return current + await refreshAuthenticatedSession() + const refreshed = getOverseasAccessToken() + if (!refreshed) throw new ApiError('当前环境未签发海外访问令牌', 'OVERSEAS_TOKEN_UNAVAILABLE') + return refreshed +} + +transport.interceptors.request.use(async (config) => { + config.headers.Authorization = `Bearer ${await requireToken()}` + return config +}) + +transport.interceptors.response.use((response) => response, async (error) => { + const request = error.config as RetryableRequest | undefined + if (error.response?.status !== 401 || !request || request._retriedOverseasAuth) throw error + request._retriedOverseasAuth = true + await refreshAuthenticatedSession() + request.headers.Authorization = `Bearer ${await requireToken()}` + return transport.request(request) +}) + +export async function overseasApiRequest(config: AxiosRequestConfig) { + try { + const response = await transport.request(config) + if (isApiFailure(response.data)) { + throw new ApiError(response.data.message, response.data.code, response.status, undefined, + response.data.traceId, response.data.requestId) + } + if (!isApiEnvelope(response.data) || response.data.code !== '200') { + throw new ApiError( + isApiEnvelope(response.data) ? response.data.message : '海外接口响应格式错误', + isApiEnvelope(response.data) ? response.data.code : 'INVALID_RESPONSE', + undefined, + undefined, + isApiEnvelope(response.data) ? response.data.traceId : null, + isApiEnvelope(response.data) ? response.data.requestId : null, + ) + } + return response.data.data as T + } catch (error) { + if (error instanceof ApiError) throw error + if (axios.isAxiosError(error)) { + const body = error.response?.data + if (isApiEnvelope(body) || isApiFailure(body)) { + throw new ApiError(body.message, body.code, error.response?.status, undefined, body.traceId, body.requestId) + } + throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status) + } + throw new ApiError(error instanceof Error ? error.message : '海外接口请求失败') + } +} diff --git a/src/api/parseJson.test.mjs b/src/api/parseJson.test.mjs new file mode 100644 index 0000000..c58efb4 --- /dev/null +++ b/src/api/parseJson.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import ts from 'typescript' + +const source = await readFile(new URL('../../shared/src/index.ts', import.meta.url), 'utf8') +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, +}).outputText +const parser = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`) + +test('preserves integers outside the JavaScript safe range as strings', () => { + const value = parser.parseJsonWithSafeIntegers('{"id":2082674456475418625,"nested":[-2082674456475418625]}') + assert.deepEqual(value, { id: '2082674456475418625', nested: ['-2082674456475418625'] }) +}) + +test('keeps safe integers and decimals numeric without changing numeric strings', () => { + const value = parser.parseJsonWithSafeIntegers('{"page":1,"weight":12.5,"id":"2082674456475418625"}') + assert.deepEqual(value, { page: 1, weight: 12.5, id: '2082674456475418625' }) +}) + +test('leaves non-JSON and binary response values untouched', () => { + assert.equal(parser.parseApiJsonResponse('plain text'), 'plain text') + const binary = new Uint8Array([1, 2, 3]) + assert.equal(parser.parseApiJsonResponse(binary), binary) +}) diff --git a/src/api/session.test.mjs b/src/api/session.test.mjs new file mode 100644 index 0000000..ce48286 --- /dev/null +++ b/src/api/session.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + ACCESS_TOKEN_KEY, + REFRESH_TOKEN_KEY, + REMEMBERED_LOGIN_ID_KEY, + clearSessionTokens, + getRememberedLoginId, + persistRememberedLoginId, + persistTokenPair, +} from './session.ts' + +function storage() { + const values = new Map() + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + } +} + +function installStorage() { + globalThis.localStorage = storage() + globalThis.sessionStorage = storage() +} + +test('persists the normal rotating session independently of remembered account preference', () => { + installStorage() + persistTokenPair({ + tokenType: 'Bearer', + accessToken: 'access-token', + expiresIn: 1440, + refreshToken: 'refresh-token', + refreshExpiresIn: 604800, + }) + + assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), 'access-token') + assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), 'refresh-token') + assert.equal(sessionStorage.getItem(ACCESS_TOKEN_KEY), null) + assert.equal(sessionStorage.getItem(REFRESH_TOKEN_KEY), null) +}) + +test('remembers only the login id and keeps it when session tokens are cleared', () => { + installStorage() + persistRememberedLoginId('merchant-user', true) + localStorage.setItem(ACCESS_TOKEN_KEY, 'access-token') + localStorage.setItem(REFRESH_TOKEN_KEY, 'refresh-token') + + clearSessionTokens() + + assert.equal(getRememberedLoginId(), 'merchant-user') + assert.equal(localStorage.getItem(REMEMBERED_LOGIN_ID_KEY), 'merchant-user') + assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), null) + assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), null) + + persistRememberedLoginId('merchant-user', false) + assert.equal(getRememberedLoginId(), '') +}) diff --git a/src/api/session.ts b/src/api/session.ts index c3d38f6..2f2dd3b 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -1,5 +1,8 @@ export const ACCESS_TOKEN_KEY = 'cc-client-access-token' export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token' +export const OVERSEAS_ACCESS_TOKEN_KEY = 'cc-client-overseas-access-token' +export const OVERSEAS_EXPIRES_AT_KEY = 'cc-client-overseas-expires-at' +export const REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id' export interface TokenPair { tokenType: string @@ -7,6 +10,8 @@ export interface TokenPair { expiresIn: number refreshToken: string refreshExpiresIn: number + overseasAccessToken?: string | null + overseasExpiresIn?: number } function activeStorage() { @@ -22,17 +27,43 @@ export function getRefreshToken() { return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) || '' } -export function persistTokenPair(tokens: TokenPair, remember?: boolean) { - const storage = remember === undefined ? activeStorage() : remember ? localStorage : sessionStorage +export function getOverseasAccessToken() { + const storage = activeStorage() + const expiresAt = Number(storage.getItem(OVERSEAS_EXPIRES_AT_KEY) || 0) + if (expiresAt <= Date.now() + 30_000) return '' + return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || '' +} + +export function persistTokenPair(tokens: TokenPair) { clearSessionTokens() - storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) - storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) + if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) { + localStorage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken) + localStorage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000)) + } +} + +export function getRememberedLoginId() { + return localStorage.getItem(REMEMBERED_LOGIN_ID_KEY) || '' +} + +export function persistRememberedLoginId(loginId: string, remember: boolean) { + if (remember) localStorage.setItem(REMEMBERED_LOGIN_ID_KEY, loginId) + else localStorage.removeItem(REMEMBERED_LOGIN_ID_KEY) +} + +export function persistTemporaryAccessToken(accessToken: string) { + clearSessionTokens() + sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken) } export function clearSessionTokens() { for (const storage of [localStorage, sessionStorage]) { storage.removeItem(ACCESS_TOKEN_KEY) storage.removeItem(REFRESH_TOKEN_KEY) + storage.removeItem(OVERSEAS_ACCESS_TOKEN_KEY) + storage.removeItem(OVERSEAS_EXPIRES_AT_KEY) storage.removeItem('cc-client-token') } } diff --git a/src/api/types.test.mjs b/src/api/types.test.mjs new file mode 100644 index 0000000..809e521 --- /dev/null +++ b/src/api/types.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { apiErrorMessage, asFieldErrors } from './errorDetails.ts' + +test('adds validation field details to a generic API message', () => { + const errors = asFieldErrors({ carrierName: '不能为空', carrierId: '不能为null' }) + + assert.equal( + apiErrorMessage('Validation failed', errors), + 'Validation failed:carrierName: 不能为空;carrierId: 不能为null', + ) +}) + +test('does not duplicate validation details already included by the server', () => { + const errors = { carrierName: '不能为空', carrierId: '不能为null' } + const message = 'carrierName: 不能为空;carrierId: 不能为null' + + assert.equal(apiErrorMessage(message, errors), message) +}) diff --git a/src/api/types.ts b/src/api/types.ts index 5097dfa..0a405f0 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,3 +1,5 @@ +import type { ApiFieldErrors } from '@/api/errorDetails' + export interface ApiEnvelope { code: string message: string @@ -7,7 +9,7 @@ export interface ApiEnvelope { timestamp: string } -export type ApiFieldErrors = Record +export type { ApiFieldErrors } from '@/api/errorDetails' export class ApiError extends Error { constructor( @@ -16,6 +18,7 @@ export class ApiError extends Error { readonly status?: number, readonly fieldErrors?: ApiFieldErrors, readonly traceId?: string | null, + readonly requestId?: string | null, ) { super(message) this.name = 'ApiError' @@ -28,8 +31,12 @@ export function isApiEnvelope(value: unknown): value is ApiEnvelope { return typeof record.code === 'string' && typeof record.message === 'string' && 'data' in record } -export function asFieldErrors(value: unknown): ApiFieldErrors | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined - const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') - return entries.length > 0 ? Object.fromEntries(entries) : undefined +// Security failures have no business data, unlike successful API responses. +export function isApiFailure(value: unknown): value is Omit, 'data'> { + if (!value || typeof value !== 'object') return false + const record = value as Record + return typeof record.code === 'string' && record.code !== '200' + && typeof record.message === 'string' + && (record.requestId == null || typeof record.requestId === 'string') + && (record.traceId == null || typeof record.traceId === 'string') } diff --git a/src/assets/brand/auth-ocean.jpg b/src/assets/brand/auth-ocean.jpg new file mode 100644 index 0000000..f47f055 Binary files /dev/null and b/src/assets/brand/auth-ocean.jpg differ diff --git a/src/assets/brand/baiyue-favicon.png b/src/assets/brand/baiyue-favicon.png new file mode 100644 index 0000000..e6151c8 Binary files /dev/null and b/src/assets/brand/baiyue-favicon.png differ diff --git a/src/assets/brand/baiyue-logo-dark.png b/src/assets/brand/baiyue-logo-dark.png new file mode 100644 index 0000000..d23f9a9 Binary files /dev/null and b/src/assets/brand/baiyue-logo-dark.png differ diff --git a/src/components.d.ts b/src/components.d.ts index 1a30d16..befd71f 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -11,34 +11,53 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AppDialog: typeof import('./components/common/AppDialog.vue')['default'] + AppDrawer: typeof import('./components/common/AppDrawer.vue')['default'] ClientSidebarMenu: typeof import('./components/ClientSidebarMenu.vue')['default'] ClientSidebarMenuNode: typeof import('./components/ClientSidebarMenuNode.vue')['default'] + CopyButton: typeof import('./components/common/CopyButton.vue')['default'] + DataTable: typeof import('./components/common/DataTable.vue')['default'] + ElAlert: typeof import('element-plus/es')['ElAlert'] ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] 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'] ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElPopover: typeof import('element-plus/es')['ElPopover'] + ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] - ElTimeline: typeof import('element-plus/es')['ElTimeline'] - ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] + ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElUpload: typeof import('element-plus/es')['ElUpload'] + LoadErrorState: typeof import('./components/common/LoadErrorState.vue')['default'] + PrototypeFeatureView: typeof import('./components/common/PrototypeFeatureView.vue')['default'] + QueryToolbar: typeof import('./components/common/QueryToolbar.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + SingleLineText: typeof import('./components/common/SingleLineText.vue')['default'] + WorkspaceTabs: typeof import('./components/WorkspaceTabs.vue')['default'] } export interface GlobalDirectives { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] diff --git a/src/components/ClientSidebarMenu.vue b/src/components/ClientSidebarMenu.vue index ff89446..9fb3e6e 100644 --- a/src/components/ClientSidebarMenu.vue +++ b/src/components/ClientSidebarMenu.vue @@ -6,7 +6,7 @@ import { filterNavigationByPermissions, getNavigationParentKeys, } from '@/navigation/clientNavigation' -import { useAuthStore } from '@/stores/auth' +import { useAuthStore } from '@/modules/auth/public' const props = defineProps<{ activePath: string @@ -52,6 +52,7 @@ function handleSelect(path: string) { :active-path="activePath" :opened-keys="openedKeys" :active-parent-keys="activeParentKeys" + :level="0" @navigate="handleSelect" @toggle="handleToggle" /> diff --git a/src/components/ClientSidebarMenuNode.vue b/src/components/ClientSidebarMenuNode.vue index c75272c..27af8c8 100644 --- a/src/components/ClientSidebarMenuNode.vue +++ b/src/components/ClientSidebarMenuNode.vue @@ -8,6 +8,7 @@ const props = defineProps<{ activePath: string openedKeys: ReadonlySet activeParentKeys: ReadonlySet + level?: number }>() const emit = defineEmits<{ @@ -22,7 +23,10 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`)
  • @@ -48,20 +57,26 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`) :active-path="activePath" :opened-keys="openedKeys" :active-parent-keys="activeParentKeys" + :level="(level ?? 0) + 1" @navigate="emit('navigate', $event)" @toggle="emit('toggle', $event)" />
  • -
  • +
  • - +
  • diff --git a/src/components/WorkspaceTabs.vue b/src/components/WorkspaceTabs.vue new file mode 100644 index 0000000..e77d95a --- /dev/null +++ b/src/components/WorkspaceTabs.vue @@ -0,0 +1,137 @@ + + + diff --git a/src/components/common/AppDialog.vue b/src/components/common/AppDialog.vue new file mode 100644 index 0000000..2eb557c --- /dev/null +++ b/src/components/common/AppDialog.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/components/common/AppDrawer.vue b/src/components/common/AppDrawer.vue new file mode 100644 index 0000000..bf9cfbf --- /dev/null +++ b/src/components/common/AppDrawer.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/components/common/CopyButton.vue b/src/components/common/CopyButton.vue new file mode 100644 index 0000000..58bcb48 --- /dev/null +++ b/src/components/common/CopyButton.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/components/common/DataTable.vue b/src/components/common/DataTable.vue new file mode 100644 index 0000000..438411b --- /dev/null +++ b/src/components/common/DataTable.vue @@ -0,0 +1,255 @@ + + + + + + + diff --git a/src/components/common/LoadErrorState.vue b/src/components/common/LoadErrorState.vue new file mode 100644 index 0000000..3e2ef8a --- /dev/null +++ b/src/components/common/LoadErrorState.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/src/components/common/PrototypeFeatureView.vue b/src/components/common/PrototypeFeatureView.vue new file mode 100644 index 0000000..53fb914 --- /dev/null +++ b/src/components/common/PrototypeFeatureView.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/components/common/QueryToolbar.vue b/src/components/common/QueryToolbar.vue new file mode 100644 index 0000000..a84bee4 --- /dev/null +++ b/src/components/common/QueryToolbar.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/src/components/common/SingleLineText.vue b/src/components/common/SingleLineText.vue new file mode 100644 index 0000000..e61591e --- /dev/null +++ b/src/components/common/SingleLineText.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/src/components/common/tableActions.test.mjs b/src/components/common/tableActions.test.mjs new file mode 100644 index 0000000..2324e95 --- /dev/null +++ b/src/components/common/tableActions.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { splitTableActions, runTableAction } from './tableActions.ts' + +test('shows up to three permitted actions and folds only when there are more', () => { + const actions = Array.from({ length: 5 }, (_, index) => ({ key: String(index), label: String(index), run() {} })) + for (const count of [0, 1, 2, 3]) { + const result = splitTableActions(actions.slice(0, count)) + assert.equal(result.visible.length, count) + assert.equal(result.overflow.length, 0) + } + for (const count of [4, 5]) { + const result = splitTableActions(actions.slice(0, count)) + assert.deepEqual(result.visible, actions.slice(0, 2)) + assert.deepEqual(result.overflow, actions.slice(2, count)) + } +}) + +test('disabled and loading actions cannot execute or disappear from the layout', () => { + let count = 0 + const action = { key: 'edit', label: '编辑', run() { count++ } } + runTableAction({ ...action, disabled: true }) + runTableAction({ ...action, loading: true }) + assert.equal(count, 0) + runTableAction(action) + assert.equal(count, 1) + assert.equal(splitTableActions([{ ...action, disabled: true }]).visible.length, 1) +}) diff --git a/src/components/common/tableActions.ts b/src/components/common/tableActions.ts new file mode 100644 index 0000000..68facc8 --- /dev/null +++ b/src/components/common/tableActions.ts @@ -0,0 +1,18 @@ +export interface TableAction { + key: string + label: string + disabled?: boolean + loading?: boolean + run: () => unknown +} + +// Callers supply only permitted actions; disabled actions retain their place. +export function splitTableActions(actions: readonly TableAction[]) { + return actions.length > 3 + ? { visible: actions.slice(0, 2), overflow: actions.slice(2) } + : { visible: actions, overflow: [] } +} + +export function runTableAction(action: TableAction) { + if (!action.disabled && !action.loading) return action.run() +} diff --git a/src/data/firstLegParties.ts b/src/data/firstLegParties.ts deleted file mode 100644 index 3d6755f..0000000 --- a/src/data/firstLegParties.ts +++ /dev/null @@ -1,47 +0,0 @@ -export type FirstLegPartyRole = 'SHIPPER' | 'CONSIGNEE' - -export interface FirstLegParty { - id: string - code: string - name: string - roles: FirstLegPartyRole[] - contactName: string - phone: string - email: string - countryRegion: string - province: string - city: string - postalCode: string - address: string - status: 'ACTIVE' | 'INACTIVE' -} - -export interface FirstLegPartySnapshot extends Omit { - role: FirstLegPartyRole -} - -export function createFirstLegPartySnapshot(party: FirstLegParty, role: FirstLegPartyRole): FirstLegPartySnapshot { - return { - id: party.id, - code: party.code, - name: party.name, - role, - contactName: party.contactName, - phone: party.phone, - email: party.email, - countryRegion: party.countryRegion, - province: party.province, - city: party.city, - postalCode: party.postalCode, - address: party.address, - } -} - -// API 接入前供客户端订单静态流程使用,不代表已确认的往来单位类型或数据范围契约。 -export const DEMO_FIRST_LEG_PARTIES: readonly FirstLegParty[] = [ - { id: 'CP001', code: 'CN-SHIP-001', name: '示例贸易有限公司', roles: ['SHIPPER'], contactName: '王经理', phone: '138****1201', email: 'shipping@example.com', countryRegion: '中国', province: '浙江省', city: '杭州市', postalCode: '310000', address: '滨江区演示产业园 A 座', status: 'ACTIVE' }, - { id: 'CP002', code: 'CN-SHIP-002', name: '华南演示供应链有限公司', roles: ['SHIPPER'], contactName: '陈主管', phone: '139****8820', email: 'ops@example.cn', countryRegion: '中国', province: '广东省', city: '深圳市', postalCode: '518000', address: '宝安区演示供应链园 2 号楼', status: 'ACTIVE' }, - { id: 'CP003', code: 'US-CONS-001', name: 'DEMO GLOBAL LLC', roles: ['CONSIGNEE'], contactName: 'Alex Morgan', phone: '+1 *** *** 2108', email: 'receiving@example.us', countryRegion: '美国', province: 'California', city: 'Los Angeles', postalCode: '90001', address: '100 Demo Logistics Ave', status: 'ACTIVE' }, - { id: 'CP004', code: 'DE-CONS-001', name: 'DEMO EUROPE GMBH', roles: ['CONSIGNEE'], contactName: 'Lena Fischer', phone: '+49 *** *** 4810', email: 'warehouse@example.de', countryRegion: '德国', province: 'Hamburg', city: 'Hamburg', postalCode: '20095', address: '8 Demo Hafenstrasse', status: 'ACTIVE' }, - { id: 'CP005', code: 'US-CONS-OLD', name: '停用演示收货单位', roles: ['CONSIGNEE'], contactName: 'Demo Contact', phone: '', email: '', countryRegion: '美国', province: '', city: '', postalCode: '', address: '', status: 'INACTIVE' }, -] diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs new file mode 100644 index 0000000..691c0f4 --- /dev/null +++ b/src/deployment/nginxConfig.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8') +const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8') +const developmentCompose = readFileSync(new URL('../../docker-compose.development.yml', import.meta.url), 'utf8') +const productionCompose = readFileSync(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8') +const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8') +const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8') + +test('allows 50 MiB uploads with multipart overhead through both API proxies', () => { + assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 55m;/) + assert.match(config, /location \/overseas-api\/ \{[\s\S]*?client_max_body_size 55m;/) + assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2) +}) + +test('does not cache the SPA entry while keeping hashed assets immutable', () => { + assert.match(config, /location = \/index\.html[\s\S]*no-store, no-cache, must-revalidate/) + assert.match(config, /location \/assets\/[\s\S]*max-age=31536000, immutable/) + assert.match(config, /location \/ \{[\s\S]*no-store, no-cache, must-revalidate[\s\S]*try_files \$uri \$uri\/ \/index\.html/) +}) + +test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => { + assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/) + assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/) + assert.match(testCompose, /VITE_ADMIN_WEB_ORIGIN: "\$\{VITE_ADMIN_WEB_ORIGIN:-https:\/\/test-admin\.buggoglobal\.com\}"/) + assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/) + assert.match(config, /proxy_set_header Host \$proxy_host;/) + assert.doesNotMatch(config, /\/api\/api/) +}) + +test('connects the development client proxy to both development gateways', () => { + assert.match(developmentCompose, /API_UPSTREAM: "http:\/\/firstleg-development-gateway:9000"/) + assert.match(developmentCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/firstleg-development-gateway-overseas:9300"/) + assert.match(developmentCompose, /- development-backend/) + assert.match(developmentCompose, /- development-overseas-backend/) + assert.match(developmentCompose, /development-overseas-backend:[\s\S]*?external: true[\s\S]*?name: firstleg-development-overseas-network/) + assert.doesNotMatch(developmentCompose, /test-api|prod(?:uction)?/i) +}) + +test('keeps local environments on target nodes and uses credential-managed SSH for production', () => { + assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-prod'\) \? 'java-builder'/) + assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/) + assert.match(jenkinsfile, /docker compose -p "\$DEVELOPMENT_COMPOSE_PROJECT" -f "\$DEVELOPMENT_COMPOSE_FILE" up -d/) + assert.doesNotMatch(jenkinsfile, /\/var\/jenkins_home\/\.ssh|docker save/) + assert.match(jenkinsfile, /credentialsId: 'firstleg-production-ssh'/) + assert.match(jenkinsfile, /git rev-parse origin\/main/) + assert.match(productionCompose, /127\.0\.0\.1:\$\{HOST_PORT:-9002\}:80/) + assert.match(productionCompose, /API_UPSTREAM: "http:\/\/gateway:9000"/) + assert.match(productionCompose, /name: firstleg-prod-infra-network/) +}) + +test('uses authenticated internal base images in CI', () => { + assert.match(dockerfile, /ARG NODE_IMAGE=node:22-alpine/) + assert.match(dockerfile, /ARG NGINX_IMAGE=nginx:1\.27-alpine/) + assert.match(dockerfile, /FROM \$\{NODE_IMAGE\} AS build/) + assert.match(dockerfile, /FROM \$\{NGINX_IMAGE\}/) + assert.match(jenkinsfile, /NODE_BASE_IMAGE = '172\.31\.198\.7:10082\/java-project\/node:22-alpine'/) + assert.match(jenkinsfile, /NGINX_BASE_IMAGE = '172\.31\.198\.7:10082\/java-project\/nginx:1\.27-alpine'/) + assert.match(jenkinsfile, /withCredentials\(\[usernamePassword\(/) + assert.match(jenkinsfile, /export DOCKER_CONFIG="\$\{WORKSPACE\}@tmp\/docker-auth"/) +}) diff --git a/src/deployment/viteConfig.test.mjs b/src/deployment/viteConfig.test.mjs new file mode 100644 index 0000000..4d576ed --- /dev/null +++ b/src/deployment/viteConfig.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const configSource = readFileSync(new URL('../../vite.config.ts', import.meta.url), 'utf8') + +test('fails fast when the dedicated client development port is occupied', () => { + assert.match(configSource, /server:\s*\{\s*strictPort: true,/) +}) diff --git a/src/editableInputPlaceholders.test.mjs b/src/editableInputPlaceholders.test.mjs new file mode 100644 index 0000000..1ca287c --- /dev/null +++ b/src/editableInputPlaceholders.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const sourceRoot = path.dirname(fileURLToPath(import.meta.url)) + +function collectVueFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(directory, entry.name) + return entry.isDirectory() ? collectVueFiles(target) : entry.name.endsWith('.vue') ? [target] : [] + }) +} + +function lineNumber(source, offset) { + return source.slice(0, offset).split('\n').length +} + +function hasLiteralAttribute(tag, name) { + return new RegExp(`\\s${name}(?:\\s|=|/?>)`).test(tag) +} + +function isEditableNativeTextInput(tag) { + if (hasLiteralAttribute(tag, 'disabled') || hasLiteralAttribute(tag, 'readonly')) return false + const type = tag.match(/\stype\s*=\s*["']([^"']+)["']/)?.[1]?.toLowerCase() ?? 'text' + return ['text', 'search', 'email', 'tel', 'url', 'password'].includes(type) +} + +test('all editable text inputs provide a user-facing placeholder', () => { + const violations = [] + + for (const file of collectVueFiles(sourceRoot)) { + const source = readFileSync(file, 'utf8') + const template = source.match(/]*)?>([\s\S]*)<\/template>/)?.[1] ?? '' + const withoutComments = template.replace(//g, '') + const attributeContent = String.raw`(?:[^>"']|"[^"]*"|'[^']*')*` + const tagPattern = new RegExp(`|<(?:input|textarea)\\b${attributeContent}/?>`, 'g') + + for (const match of withoutComments.matchAll(tagPattern)) { + const tag = match[0] + const isElementInput = tag.startsWith(' { assert.match(layoutSource, /import \{ computed, nextTick, onBeforeUnmount, onMounted, ref, watch \} from 'vue'/) - assert.match(layoutSource, /import \{[\s\S]*?\bX,[\s\S]*?\} from '@lucide\/vue'/) + assert.match(layoutSource, /import \{[\s\S]*?\bX\s*\} from '@lucide\/vue'/) assert.match(layoutSource, /import ClientSidebarMenu from '@\/components\/ClientSidebarMenu\.vue'/) assert.match(layoutSource, /:class="\{ 'mobile-menu-open': mobileMenuOpen \}"/) assert.match(layoutSource, /]*class="client-sidebar-scrim"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"/s) assert.match(layoutSource, //) assert.doesNotMatch(layoutSource, /]*aria-label=/) assert.match(layoutSource, /