diff --git a/AGENTS.md b/AGENTS.md index df7c55f..e454460 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## 项目定位 - 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。 -- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。 +- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。 - 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/跨区域认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。 - 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。 @@ -43,22 +43,25 @@ | 业务 | 模块入口 | 联查模块或服务端 | |---|---|---| -| 登录、注册与会话 | `src/modules/auth/` | Backend `auth/`、`iam/` | -| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`file/` | +| 登录、注册与会话 | `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` 进入,点击入仓码由 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts` 查询订单、客户和目的仓关联摘要;箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/` 的客户隔离 `InboundCodeAssociationService`、`order-v1.yaml`,以及 `firstleg/`、`warehouse/`;不得用内部 ID 补齐缺失业务编号 | +| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `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` | Backend `finance/`;只能查看登录态客户的已出账账单。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认以登录成员、`expectedVersion` 和幂等键提交,成功后记录确认人姓名快照与时间 | -| 跨境候选能力 | `src/modules/cross-border/` | 先核对 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 | +| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `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;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`;Backend `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` | ## 租户、认证与数据安全 - 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。 - 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。 -- 认证统一经过 `src/modules/auth/store/auth.ts`、`src/api/http.ts`、`src/api/session.ts`;国内请求使用国内 Access Token,海外请求统一由 `src/api/overseasHttp.ts` 读取短时海外 Token。海外 Token 缺失或 401 时复用同一刷新编排并最多重放一次,页面不要复制令牌逻辑或持久化密码、敏感资质内容。 +- 注册时登录手机号同时初始化公司联系电话;注册完成后两者独立维护。企业资料只修改 `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 和测试,不以现有代码或测试仍覆盖旧路径作为保留依据。 - 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。 @@ -95,17 +98,17 @@ pnpm build ## 全量验收脚本 -- Client 全量验收唯一入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `client-phase2-test-env-acceptance.mjs`,不替代完整入口。 +- 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 页面与客户可见契约,服务端实现详见后端模块指引。 +当前按客户侧全量业务闭环验收。业务域文档用于核对功能来源和验收证据,不限制本次验收的模块范围;按“模块 `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/`;按服务端权限核对客户可见字段、客户隔离和只读/写入边界 | diff --git a/README.md b/README.md index b2a4b55..74d02aa 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,16 @@ pnpm test 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 -一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 +订单与客户路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限、头程订单、海外采购、美国本土卡、美本土散板以及海外仓入库订单。跨境下单支持真实列表、详情和新增,海外入库支持当前客户列表与详情;均经海外 Gateway 的客户接口访问,客户范围只取 Token,不提交客户 ID。开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。 -登录表单在开发和生产环境均默认留空;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。 +登录表单在开发和生产环境默认留空;用户主动选择“记住账号”后只回填登录账号、不保存密码,也不改变 Access/Refresh Token 的持久化和刷新策略;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。 “我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `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` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照、法人身份证为必填资质,货代资质证书和银行开户许可证为选填资质,均使用文件服务分别上传,接受 PDF、JPG、PNG 且不超过 5MB;两项必填资质齐全后可显式提交审核,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。 -头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 +头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地订单与客户验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。 diff --git a/nginx/default.conf.template b/nginx/default.conf.template index 2c714ab..68ea42b 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -11,6 +11,19 @@ server { 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; @@ -36,6 +49,7 @@ server { # 支持 Vue Router history 路由直接刷新。 location / { + add_header Cache-Control "no-store, no-cache, must-revalidate" always; try_files $uri $uri/ /index.html; } } 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/shared/AGENTS.md b/shared/AGENTS.md index 239316c..e6bf65b 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -19,12 +19,12 @@ - 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。 - 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。 -## 一期/二期/三期契约索引 +## 订单与客户/入库测量与计费/国内货柜履约契约索引 -- 一期共享模型服务客户认证、企业资料/成员权限和头程订单;修改订单状态、仓库摘要、资质或权限枚举前,先核对阶段契约并搜索 `src/modules/` 全部消费者。 -- 二期仅承接客户可见的到仓与计费结果。`WAIT_SPLIT + APPROVED + CONFIRMED` 为当前终点;内部测量明细、备注和文件权限不得通过共享类型绕过。 -- 三期仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Client 模块复用时才新增共享导出;先读 `../../../docs/三期/三期开工门禁.md`。只建客户可见的柜次、进度、授权回执和船期类型,不通过 shared 暴露照片、锁码或后台内部资料。 +- 订单与客户共享模型服务客户认证、企业资料/成员权限和头程订单;修改订单状态、仓库摘要、资质或权限枚举前,先核对阶段契约并搜索 `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`。范围与契约以阶段确认和设计文档为准,原型只作补充。 +业务域文档入口(路径相对本文件所在目录):订单与客户 `../../../docs/订单与客户/README.md`;入库测量与计费 `../../../docs/入库测量与计费/README.md` 与 `../../../docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`;国内货柜履约 `../../../docs/国内货柜履约/README.md`、`../../../docs/国内货柜履约/国内货柜履约开发规划.md` 和 `../../../docs/国内货柜履约/国内货柜履约需求确认表.md`。范围与契约以正式确认和设计文档为准,原型只作补充。 diff --git a/src/App.vue b/src/App.vue index f38f8d2..77311e0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,32 +1,52 @@ diff --git a/src/api/http.ts b/src/api/http.ts index ed8a391..0d619ee 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -46,6 +46,11 @@ function mapError(error: unknown) { let refreshPromise: Promise | null = null let redirectingToLogin = false +const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange'] + +function isPublicAuthRequest(url?: string) { + return publicAuthPaths.some((path) => url?.endsWith(path)) +} function redirectToLoginAfterSessionExpiry() { if (redirectingToLogin || typeof window === 'undefined') return @@ -98,7 +103,7 @@ export async function refreshAuthenticatedSession() { 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 }) @@ -106,9 +111,7 @@ transport.interceptors.response.use( (response) => response, async (error: AxiosError) => { const request = error.config as RetryableRequest | undefined - const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange'] - 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) } 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 9d933bb..2f2dd3b 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -2,6 +2,7 @@ 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 @@ -33,17 +34,25 @@ export function getOverseasAccessToken() { return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || '' } -export function persistTokenPair(tokens: TokenPair, remember?: boolean) { - const storage = remember === undefined ? activeStorage() : remember ? localStorage : sessionStorage +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) { - storage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken) - storage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000)) + 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) diff --git a/src/components.d.ts b/src/components.d.ts index 90a9533..723bdea 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -19,6 +19,7 @@ declare module 'vue' { 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'] diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index 44dbcd0..f7632ce 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -14,6 +14,12 @@ test('allows 50 MiB uploads with multipart overhead through both API proxies', ( 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"/) 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(' { if (token.value) { try { applyToken(token.value) - } catch { - clearSession() + } catch (error) { + if (!(error instanceof MerchantTokenExpiredError && getRefreshToken())) clearSession() } } @@ -133,8 +133,20 @@ export const useAuthStore = defineStore('client-auth', () => { async function restoreSession() { if (sessionRestored.value) return if (!isAuthenticated.value) { - sessionRestored.value = true - return + if (!getRefreshToken()) { + sessionRestored.value = true + return + } + try { + applyToken(await refreshAuthenticatedSession()) + } catch (error) { + if (isExpiredSessionError(error)) { + clearSession() + throw error + } + sessionRestored.value = true + return + } } try { await loadSessionContext() @@ -152,7 +164,8 @@ export const useAuthStore = defineStore('client-auth', () => { async function login(loginId: string, password: string, remember = false) { const tokens = await loginMerchant(loginId, password) const decoded = decodeMerchantToken(tokens.accessToken) - persistTokenPair(tokens, remember) + persistTokenPair(tokens) + persistRememberedLoginId(loginId, remember) token.value = tokens.accessToken payload.value = decoded user.value = currentUser(decoded) @@ -223,15 +236,6 @@ export const useAuthStore = defineStore('client-auth', () => { } window.addEventListener('cc-client-session-expired', clearSession) - window.addEventListener('message', (event: MessageEvent) => { - const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim() - const expectedAdminOrigin = configuredAdminOrigin || window.location.origin - const message = event.data as { source?: string; type?: string; code?: string } | null - if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || !message.code) return - void acceptImpersonationCode(message.code) - .then(() => window.location.replace('/')) - .catch(() => window.location.replace('/login?impersonation=failed')) - }) window.addEventListener('storage', (event) => { if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession() }) diff --git a/src/modules/auth/utils/jwt.test.mjs b/src/modules/auth/utils/jwt.test.mjs index b0a0bed..bf1d3f7 100644 --- a/src/modules/auth/utils/jwt.test.mjs +++ b/src/modules/auth/utils/jwt.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { decodeMerchantToken } from './jwt.ts' +import { decodeMerchantToken, MerchantTokenExpiredError } from './jwt.ts' function tokenFor(payload) { const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url') @@ -29,5 +29,5 @@ test('rejects malformed tokens', () => { }) test('rejects expired tokens', () => { - assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), /登录状态已过期/) + assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), MerchantTokenExpiredError) }) diff --git a/src/modules/auth/utils/jwt.ts b/src/modules/auth/utils/jwt.ts index 73bdcda..4ec31df 100644 --- a/src/modules/auth/utils/jwt.ts +++ b/src/modules/auth/utils/jwt.ts @@ -9,6 +9,13 @@ export interface MerchantJwtPayload { exp: number } +export class MerchantTokenExpiredError extends Error { + constructor() { + super('登录状态已过期') + this.name = 'MerchantTokenExpiredError' + } +} + function decodeBase64Url(value: string) { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') @@ -27,10 +34,11 @@ export function decodeMerchantToken(token: string): MerchantJwtPayload { const payload = value as Partial if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error() if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端') - if (payload.exp * 1000 <= Date.now()) throw new Error('登录状态已过期') + if (payload.exp * 1000 <= Date.now()) throw new MerchantTokenExpiredError() return payload as MerchantJwtPayload } catch (error) { - if (error instanceof Error && ['账号不属于当前应用端', '登录状态已过期'].includes(error.message)) throw error + if (error instanceof Error && error.message === '账号不属于当前应用端') throw error + if (error instanceof MerchantTokenExpiredError) throw error throw new Error('登录凭证无法解析,请重新登录') } } diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index 6adc8fb..44831e8 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -13,6 +13,8 @@ const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url) const sessionSource = readFileSync(new URL('../../../api/session.ts', import.meta.url), 'utf8') const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8') const mainSource = readFileSync(new URL('../../../main.ts', import.meta.url), 'utf8') +const appSource = readFileSync(new URL('../../../App.vue', import.meta.url), 'utf8') +const routerSource = readFileSync(new URL('../../../router/index.ts', import.meta.url), 'utf8') const stylesSource = readFileSync(new URL('../../../styles/index.css', import.meta.url), 'utf8') test('uses one prototype-inspired responsive shell for login and registration', () => { @@ -42,10 +44,14 @@ test('matches the frozen username-or-mobile login contract', () => { assert.match(loginSource, /忘记密码/) }) -test('persists both rotating tokens according to the remember choice', () => { +test('persists rotating tokens independently and only remembers the login id by choice', () => { assert.match(sessionSource, /ACCESS_TOKEN_KEY/) assert.match(sessionSource, /REFRESH_TOKEN_KEY/) - assert.match(sessionSource, /remember \? localStorage : sessionStorage/) + assert.match(sessionSource, /localStorage\.setItem\(ACCESS_TOKEN_KEY, tokens\.accessToken\)/) + assert.match(sessionSource, /localStorage\.setItem\(REFRESH_TOKEN_KEY, tokens\.refreshToken\)/) + assert.match(sessionSource, /persistRememberedLoginId/) + assert.match(loginSource, /getRememberedLoginId/) + assert.match(loginSource, /记住账号/) assert.match(sessionSource, /clearSessionTokens/) }) @@ -91,6 +97,7 @@ test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(httpSource, /refreshPromise/) assert.match(httpSource, /\/auth\/refresh/) assert.match(httpSource, /persistTokenPair\(tokens\)/) + assert.match(httpSource, /token && !isPublicAuthRequest\(config\.url\)/) assert.match(httpSource, /request\._retriedAfterRefresh = true/) assert.match(httpSource, /return await transport\.request\(request\)/) assert.match(httpSource, /envelope\.code !== '200'/) @@ -100,12 +107,19 @@ test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError[\s\S]*window\.dispatchEvent\(new CustomEvent\('cc-client-session-expired'\)\)[\s\S]*redirectToLoginAfterSessionExpiry\(\)/) assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/) assert.match(authStoreSource, /decodeMerchantToken/) + assert.match(authStoreSource, /MerchantTokenExpiredError && getRefreshToken\(\)/) + assert.match(authStoreSource, /applyToken\(await refreshAuthenticatedSession\(\)\)/) assert.doesNotMatch(authStoreSource, /mock-client:/) }) test('handles impersonation exchange as public authentication and exposes failures', () => { assert.match(httpSource, /publicAuthPaths = \[[^\]]*'\/auth\/impersonations\/exchange'/) - assert.match(authStoreSource, /\/login\?impersonation=failed/) + assert.match(appSource, /impersonationOrigin/) + assert.match(appSource, /await auth\.acceptImpersonationCode\(message\.code\)/) + assert.match(appSource, /type: 'impersonation-complete'/) + assert.match(appSource, /type: 'impersonation-failed'/) + assert.doesNotMatch(authStoreSource, /message\.type !== 'impersonation-ticket'/) + assert.match(routerSource, /isImpersonationHandoff[\s\S]*!isImpersonationHandoff/) assert.match(loginSource, /route\.query\.impersonation === 'failed'/) assert.match(loginSource, /代登录失败或授权已失效,请返回管理后台重新发起/) }) diff --git a/src/modules/auth/views/LoginView.vue b/src/modules/auth/views/LoginView.vue index f10f820..6f48b3a 100644 --- a/src/modules/auth/views/LoginView.vue +++ b/src/modules/auth/views/LoginView.vue @@ -5,6 +5,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue' import { useAuthStore } from '@/modules/auth/store/auth' import AuthShell from '@/modules/auth/components/AuthShell.vue' +import { getRememberedLoginId } from '@/api/session' interface LoginForm { account: string @@ -19,12 +20,13 @@ const formRef = ref() const loading = ref(false) const impersonationFailed = route.query.impersonation === 'failed' const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated' +const rememberedLoginId = getRememberedLoginId() const form = reactive({ account: typeof route.query.account === 'string' ? route.query.account - : '', + : rememberedLoginId, password: '', - remember: false, + remember: Boolean(rememberedLoginId), }) const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/ const mainlandMobilePattern = /^1[3-9]\d{9}$/ @@ -113,7 +115,7 @@ function showResetHint() {
- 记住我 + 记住账号
登录 diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts new file mode 100644 index 0000000..91db22d --- /dev/null +++ b/src/modules/cross-border/api/businessOrders.ts @@ -0,0 +1,165 @@ +import { overseasApiRequest } from '@/api/overseasHttp' + +export interface PageResult { + records: T[] + page: number + size: number + total: number + pages: number +} + +export type CrossBorderBusinessKind = 'procurement' | 'trucking' | 'looseboard' + +export interface BusinessOrderQuery { + page: number + size: number + orderNo?: string + status?: string + routeKeyword?: string + productName?: string + purchaseCategory?: string + transportType?: string + transportMode?: string + requestedFrom?: string + requestedTo?: string + createdFrom?: string + createdTo?: string +} + +export interface ProcurementSummary { + orderNo: string + purchaseCategory: string + productName: string + productSpecification: string | null + purchaseQuantity: number + budgetUnitPrice: number | null + currency: string | null + inboundQuantity: number + outboundQuantity: number + inventoryQuantity: number + status: string + version: number + createdAt: string + updatedAt: string +} + +export interface ProcurementDetail { + summary: ProcurementSummary + productUrl: string | null + receivingAddress: string | null + contactName: string + contactPhone: string + domesticCarrier: string | null + domesticTrackingNo: string | null + overseasCarrier: string | null + overseasTrackingNo: string | null + estimatedArrivalAt: string | null + remark: string | null + reviewRemark: string | null +} + +export interface ProcurementCreate { + customerName: string + purchaseCategory: string + productName: string + productSpecification?: string + productUrl?: string + purchaseQuantity: number + budgetUnitPrice?: number + currency?: string + receivingAddress?: string + contactName: string + contactPhone: string + remark?: string + idempotencyKey: string +} + +export interface TransportSummary { + orderNo: string + transportType: string + transportMode?: string + palletRequired?: string + origin: string + destination: string + containerType: string | null + palletCount?: number + packageCount: number + totalWeightKg: number + totalVolumeCbm: number + budgetAmount: number | null + currency: string | null + contactName: string | null + contactInfo: string | null + requestedDate: string | null + status: string + version: number + createdAt: string + updatedAt: string +} + +export interface TransportDetail { + summary: TransportSummary + originAddress: string | null + destinationAddress: string | null + remark: string | null +} + +export interface TransportCreate { + transportType: string + transportMode?: string + palletRequired?: string + origin: string + originAddress?: string + destination: string + destinationAddress?: string + containerType?: string + palletCount?: number + packageCount: number + totalWeightKg: number + totalVolumeCbm: number + budgetAmount?: number + currency?: string + contactName?: string + contactInfo?: string + requestedDate?: string + remark?: string + idempotencyKey: string +} + +export interface TransportUpdate extends Omit { + version: number +} + +const endpointByKind: Record = { + procurement: '/merchant/v1/last-leg/overseas-procurements', + trucking: '/merchant/v1/last-leg/us-truck-orders', + looseboard: '/merchant/v1/last-leg/local-looseboard-orders', +} + +function normalizePage(data: PageResult): PageResult { + return { ...data, page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) } +} + +export function listBusinessOrders(kind: CrossBorderBusinessKind, params: BusinessOrderQuery) { + return overseasApiRequest>({ method: 'GET', url: endpointByKind[kind], params }).then(normalizePage) +} + +export function getBusinessOrder(kind: CrossBorderBusinessKind, orderNo: string) { + return overseasApiRequest({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` }) +} + +export function createProcurement(payload: ProcurementCreate) { + return overseasApiRequest({ method: 'POST', url: endpointByKind.procurement, data: payload }) +} + +export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) { + return overseasApiRequest({ method: 'POST', url: endpointByKind[kind], data: payload }) +} + +export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) { + return overseasApiRequest({ + method: kind === 'trucking' ? 'PATCH' : 'PUT', + url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`, + data: payload, + }) +} diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs new file mode 100644 index 0000000..0c0a0ed --- /dev/null +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8') +const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') +const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8') + +test('activates all three customer cross-border pages with frozen permissions', () => { + for (const [path, permission] of [ + ['cross-border/procurement', 'merchant:cross-border:procurement'], + ['cross-border/domestic-trucking', 'merchant:cross-border:domestic-trucking'], + ['cross-border/pallet-shipping', 'merchant:cross-border:pallet-shipping'], + ]) { + assert.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?release: 'active'[\\s\\S]*?permission: '${permission}'`)) + } + assert.doesNotMatch(view, /PrototypeFeatureView|mock|customerId|tenantId/i) +}) + +test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => { + assert.match(api, /overseas-procurements/) + assert.match(api, /us-truck-orders/) + assert.match(api, /local-looseboard-orders/) + assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/) + assert.match(view, /const stableSubmitKey = submitKey\.value \|\| idempotencyKey\(\)/) + assert.match(view, /submitKey\.value = stableSubmitKey/) + assert.match(view, /idempotencyKey: stableSubmitKey/g) +}) + +test('covers list detail create loading error empty pagination and actions-first contracts', () => { + assert.match(view, /mode\.value = 'detail'/) + assert.match(view, /mode\.value = 'list'/) + assert.match(view, /mode = 'create'/) + assert.match(view, /v-loading="loading"/) + assert.match(view, /type="error"/) + assert.match(view, /empty-text="暂无订单"/) + assert.match(view, /]*fixed="left"/) +}) diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue new file mode 100644 index 0000000..a2c6e77 --- /dev/null +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/src/modules/cross-border/routes.ts b/src/modules/cross-border/routes.ts index e0a1c4e..1d54e92 100644 --- a/src/modules/cross-border/routes.ts +++ b/src/modules/cross-border/routes.ts @@ -9,7 +9,7 @@ export const crossBorderModule = { path: 'cross-border/procurement', name: 'cross-border-procurement', component: () => import('./views/ProcurementView.vue'), - release: 'prototype', + release: 'active', meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement' }, navigation: { key: 'cross-border-procurement', icon: Package }, }, @@ -17,7 +17,7 @@ export const crossBorderModule = { path: 'cross-border/domestic-trucking', name: 'cross-border-domestic-trucking', component: () => import('./views/DomesticTruckingView.vue'), - release: 'prototype', + release: 'active', meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking' }, navigation: { key: 'cross-border-domestic-trucking', icon: Truck }, }, @@ -25,7 +25,7 @@ export const crossBorderModule = { path: 'cross-border/pallet-shipping', name: 'cross-border-pallet-shipping', component: () => import('./views/PalletShippingView.vue'), - release: 'prototype', + release: 'active', meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping' }, navigation: { key: 'cross-border-pallet-shipping', icon: Ship }, }, diff --git a/src/modules/cross-border/views/DomesticTruckingView.vue b/src/modules/cross-border/views/DomesticTruckingView.vue index d3ecc7e..bc9e623 100644 --- a/src/modules/cross-border/views/DomesticTruckingView.vue +++ b/src/modules/cross-border/views/DomesticTruckingView.vue @@ -1,5 +1,5 @@ - + diff --git a/src/modules/cross-border/views/PalletShippingView.vue b/src/modules/cross-border/views/PalletShippingView.vue index e5cec3f..0f4ee30 100644 --- a/src/modules/cross-border/views/PalletShippingView.vue +++ b/src/modules/cross-border/views/PalletShippingView.vue @@ -1,5 +1,5 @@ - + diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index edb2695..bde268f 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -1,5 +1,5 @@ - + diff --git a/src/modules/dashboard/views/DashboardView.test.mjs b/src/modules/dashboard/views/DashboardView.test.mjs index ba777db..67c6f0c 100644 --- a/src/modules/dashboard/views/DashboardView.test.mjs +++ b/src/modules/dashboard/views/DashboardView.test.mjs @@ -25,12 +25,15 @@ test('isolates recoverable dashboard failures without hiding successful sections assert.doesNotMatch(source, / diff --git a/src/modules/first-leg/api/inboundBilling.test.mjs b/src/modules/first-leg/api/inboundBilling.test.mjs index 2e8e05a..d0742c1 100644 --- a/src/modules/first-leg/api/inboundBilling.test.mjs +++ b/src/modules/first-leg/api/inboundBilling.test.mjs @@ -6,9 +6,17 @@ const source = readFileSync(new URL('./inboundBilling.ts', import.meta.url), 'ut const typesSource = readFileSync(new URL('../types/inboundBilling.ts', import.meta.url), 'utf8') test('queries the merchant order inbound-billing endpoint without tenant parameters', () => { - assert.match(source, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/) - assert.match(source, /method: 'GET'/) - assert.doesNotMatch(source, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/) + const readSource = source.slice(source.indexOf('export async function getMerchantInboundBilling'), source.indexOf('function createFeeConfirmationKey')) + assert.match(readSource, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/) + assert.match(readSource, /method: 'GET'/) + assert.doesNotMatch(readSource, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/) +}) + +test('confirms the latest customer fee version with an idempotency key', () => { + assert.match(source, /function confirmMerchantInboundFee/) + assert.match(source, /\/inbound-billing\/confirm/) + assert.match(source, /headers: \{ 'Idempotency-Key': idempotencyKey \}/) + assert.match(source, /data: \{ expectedFeeVersion \}/) }) test('sanitizes the platform-shaped response into a merchant whitelist', () => { diff --git a/src/modules/first-leg/api/inboundBilling.ts b/src/modules/first-leg/api/inboundBilling.ts index fef4283..59d9dc3 100644 --- a/src/modules/first-leg/api/inboundBilling.ts +++ b/src/modules/first-leg/api/inboundBilling.ts @@ -9,6 +9,9 @@ import type { interface MerchantInboundBillingWireResponse { inbound: MerchantInboundResult + measurementSummary: MerchantInboundBilling['measurementSummary'] + measurementItems: MerchantInboundBilling['measurementItems'] + billingBasis: MerchantInboundBilling['billingBasis'] latestFee: MerchantInboundFeeSnapshot } @@ -62,6 +65,8 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho feeStatus: fee.feeStatus, inboundId: fee.inboundId, inboundCode: fee.inboundCode, + measurementId: fee.measurementId == null ? null : String(fee.measurementId), + measurementSource: fee.measurementSource, chargeRuleId: fee.chargeRuleId, ruleCode: fee.ruleCode, chargeRuleVersion: fee.chargeRuleVersion, @@ -98,6 +103,43 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho confirmedAt: fee.confirmedAt, sentAt: fee.sentAt, nextProcessStatus: fee.nextProcessStatus, + customerConfirmationStatus: fee.customerConfirmationStatus, + customerConfirmedBy: fee.customerConfirmedBy == null ? null : String(fee.customerConfirmedBy), + customerConfirmedByName: fee.customerConfirmedByName, + customerConfirmedAt: fee.customerConfirmedAt, + } +} + +function sanitizeMeasurementSummary(item: MerchantInboundBilling['measurementSummary']): MerchantInboundBilling['measurementSummary'] { + return { + measurementId: item.measurementId == null ? null : String(item.measurementId), + sourceType: item.sourceType, + measurementStatus: item.measurementStatus, + actualPackageCount: item.actualPackageCount, + actualGrossWeightKg: item.actualGrossWeightKg, + actualNetWeightKg: item.actualNetWeightKg, + actualVolumeCbm: item.actualVolumeCbm, + volumeWeightKg: item.volumeWeightKg, + actualGoodsName: item.actualGoodsName, + calculationSource: item.calculationSource, + } +} + +function sanitizeMeasurementItem(item: MerchantInboundBilling['measurementItems'][number]): MerchantInboundBilling['measurementItems'][number] { + return { + id: String(item.id), + itemNo: item.itemNo, + boxSpecification: item.boxSpecification, + packageCount: item.packageCount, + lengthCm: item.lengthCm, + widthCm: item.widthCm, + heightCm: item.heightCm, + unitVolumeCbm: item.unitVolumeCbm, + totalVolumeCbm: item.totalVolumeCbm, + volumeWeightKg: item.volumeWeightKg, + chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId), + coefficientMode: item.coefficientMode, + calculationRuleVersion: item.calculationRuleVersion, } } @@ -108,6 +150,9 @@ function sanitizeInboundBilling(response: MerchantInboundBillingWireResponse): M publicRemark: response.inbound.publicRemark, palletLocations: response.inbound.palletLocations.map(sanitizePalletLocation), }, + measurementSummary: sanitizeMeasurementSummary(response.measurementSummary), + measurementItems: (response.measurementItems || []).map(sanitizeMeasurementItem), + billingBasis: response.billingBasis, latestFee: sanitizeFee(response.latestFee), } } @@ -119,3 +164,19 @@ export async function getMerchantInboundBilling(orderId: string): Promise({ + method: 'POST', + url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/inbound-billing/confirm`, + headers: { 'Idempotency-Key': idempotencyKey }, + data: { expectedFeeVersion }, + }) + return sanitizeFee(response) +} diff --git a/src/modules/first-leg/components/FirstLegOrderEditorShell.vue b/src/modules/first-leg/components/FirstLegOrderEditorShell.vue index 9bf5a06..d790f7b 100644 --- a/src/modules/first-leg/components/FirstLegOrderEditorShell.vue +++ b/src/modules/first-leg/components/FirstLegOrderEditorShell.vue @@ -47,7 +47,7 @@ const emit = defineEmits<{ .editor-page-nav span { color: var(--muted); font-size: 12px; } .editor-page-body { padding: 26px 32px 34px; } .editor-page-footer { position: sticky; z-index: 3; bottom: 0; display: flex; justify-content: center; padding: 12px 24px; border-top: 1px solid #edf0f5; background: rgba(255, 255, 255, 0.96); box-shadow: 0 -6px 18px rgba(15, 31, 58, .06); backdrop-filter: blur(10px); } -.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: flex-start; } +.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: center; } .editor-page-footer :deep(.editor-actions > .el-button) { min-width: 132px; min-height: 42px; } .editor-page-footer :deep(.editor-actions > .el-button:last-child) { min-width: 172px; } :global(.first-leg-editor-dialog.el-dialog) { display: flex; max-width: 1500px; height: 94vh; margin-bottom: 0; flex-direction: column; overflow: hidden; border-radius: 6px; } diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs index 083ba22..77c3e26 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -10,17 +10,33 @@ test('renders loading, pending, isolated error, and published states', () => { assert.match(source, /role="alert"/) assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/) assert.match(source, /role="status"/) - assert.match(source, /待分柜 · 费用已发布/) + assert.match(source, /费用已发布 · 待确认/) assert.match(source, /\$emit\('retry'\)/) }) -test('shows the confirmed merchant inbound and fee fields without write actions', () => { +test('supports a standalone dialog and keeps the unpushed billing-weight state visible', () => { + assert.match(source, /dialogOnly\?: boolean/) + assert.match(source, /v-if="!props\.dialogOnly"/) + assert.match(source, /watch\(\(\) => props\.autoOpen/) + assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/) + assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/) + assert.match(source, /平台推送计费重后,可在这里核对并确认/) + assert.match(source, /@closed="emit\('closed'\)"/) +}) + +test('shows the published merchant inbound, measurement details and customer confirmation entry', () => { for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) { assert.match(source, new RegExp(label)) } assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/) assert.match(source, /formatConversionPrecision\(/) - assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/) + assert.match(source, /确认费用/) + assert.match(source, /二次确认费用/) + assert.match(source, /确认后该费用将锁定,平台不能再修改/) + assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/) + assert.match(source, /confirmMerchantInboundFee/) + assert.match(source, /measurementItems/) + assert.doesNotMatch(source, /调整费用|发送费用|下载照片/) assert.doesNotMatch(source, /internalNotes|statusHistory|files/) assert.match(source, /latestFee\.ruleCode/) assert.match(source, /latestFee\.rateCode/) diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue index 29f3fa3..1cb573d 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.vue +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -1,5 +1,8 @@ + + + +
+ + 正在加载计费重 +
+ +
+ +
{{ props.unavailableMessage }}平台推送计费重后,可在这里核对并确认。
+
+ + +
diff --git a/src/modules/tenant-management/api/merchantIam.test.mjs b/src/modules/tenant-management/api/merchantIam.test.mjs index bb0cb5f..68b133e 100644 --- a/src/modules/tenant-management/api/merchantIam.test.mjs +++ b/src/modules/tenant-management/api/merchantIam.test.mjs @@ -91,6 +91,14 @@ test('shares the merchant permission map and preserves resources not rendered by assert.match(contractsSource, /export const FRONTEND_TO_MERCHANT_PERMISSION_CODES/) assert.match(contractsSource, /'order:first-leg:cancel': \['merchant:order:cancel'\]/) assert.match(contractsSource, /'company:member:create': \['merchant:member:create'\]/) + for (const permission of [ + 'merchant:warehouse:inbound:list', + 'merchant:cross-border:procurement', + 'merchant:cross-border:domestic-trucking', + 'merchant:cross-border:pallet-shipping', + ]) { + assert.match(contractsSource, new RegExp(`'${permission}': \\['${permission}'\\]`)) + } assert.match(contractsSource, /'company:member:edit': \['merchant:member:edit'\]/) assert.match(contractsSource, /'company:member:grant-role': \['merchant:member:grant-role'\]/) assert.match(contractsSource, /'company:member:status': \['merchant:member:status'\]/) diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index 0884cdf..b19e0dd 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -38,6 +38,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ key: 'finance', label: '财务账单', items: [ + { code: 'merchant:finance:account:view', label: '查看资金账户', description: '查看本企业实际、冻结和可用余额及资金流水' }, + { code: 'merchant:finance:recharge:create', label: '提交充值申请', description: '上传付款截图并提交本企业充值申请' }, { code: 'finance:bill:view', label: '查看客户账单', description: '查看本企业账单、收款记录和未收余额' }, { code: 'finance:bill:confirm', label: '确认客户账单', description: '确认本企业已出账且待客户确认的对账单' }, ], @@ -93,6 +95,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ ] export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly> = { + 'merchant:finance:account:view': ['merchant:finance:account:view'], + 'merchant:finance:recharge:create': ['merchant:finance:recharge:create'], 'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'], 'order:first-leg:create': ['merchant:order:create'], 'order:first-leg:quick-create': ['merchant:order:quick-create', 'merchant:order:create'], @@ -101,6 +105,10 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly qualificationCertifi const draftCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value)) const businessLicenseFileSelected = computed(() => Boolean(qualificationDraftItem('BUSINESS_LICENSE')?.file)) const legalPersonIdFileSelected = computed(() => Boolean(qualificationDraftItem('LEGAL_PERSON_ID')?.file)) -const standaloneLegalPersonIdUnsupported = computed(() => legalPersonIdFileSelected.value && !businessLicenseFileSelected.value) const requiredPackageChangeSelected = computed(() => businessLicenseFileSelected.value) const requiredPackageFilesSelected = computed(() => businessLicenseFileSelected.value) const qualificationCreditCodeReady = computed(() => requiredPackageChangeSelected.value @@ -122,11 +121,9 @@ const qualificationUploadDisabledReason = computed(() => { const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length) const canSaveQualificationBatch = computed(() => canUploadQualification.value && selectedQualificationCount.value > 0 - && !standaloneLegalPersonIdUnsupported.value && requiredPackageComplete.value) const qualificationSaveDisabledReason = computed(() => { if (!canUploadQualification.value) return qualificationUploadDisabledReason.value - if (standaloneLegalPersonIdUnsupported.value) return '法人身份证为选填项;当前文件服务尚未开放单独保存,请取消本次选择后保存其他资质。' if (!requiredPackageComplete.value) return `请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}` if (!selectedQualificationCount.value) return '当前没有需要保存的变更' return '' @@ -340,20 +337,15 @@ async function submitQualification() { ElMessage.warning(qualificationUploadDisabledReason.value) return } - if (standaloneLegalPersonIdUnsupported.value) { - const legalPersonId = qualificationDraftItem('LEGAL_PERSON_ID') - const message = '当前文件服务尚未开放法人身份证单独上传或替换,该选填文件未提交。' - if (legalPersonId) { legalPersonId.error = message; legalPersonId.uploadState = 'error' } - ElMessage.warning(message) - return - } if (!requiredPackageComplete.value) { const businessLicense = qualificationDraftItem('BUSINESS_LICENSE') if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`) return } - const optionalItems = qualificationDrafts.filter((item) => !item.required && item.value !== 'LEGAL_PERSON_ID' && item.file) + const optionalItems = qualificationDrafts.filter((item) => !item.required + && item.file + && (item.value !== 'LEGAL_PERSON_ID' || !requiredPackageChangeSelected.value)) const includedLegalPersonId = legalPersonIdFileSelected.value qualificationSubmitting.value = true let uploadedCount = 0 @@ -430,8 +422,10 @@ async function submitQualification() { } qualificationOpen.value = false if (refreshed) ElMessage.success(includedLegalPersonId - ? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核' - : '营业执照和统一社会信用代码已保存,尚未提交审核') + ? (requiredPackageChangeSelected.value + ? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核' + : '法人身份证已保存,尚未提交审核') + : '资质文件已保存,尚未提交审核') else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态') } finally { qualificationSubmitting.value = false diff --git a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs index 2816e3d..2967085 100644 --- a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs +++ b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs @@ -73,12 +73,14 @@ test('implements company details, contact information and qualification workflow assert.match(profileSource, /requiredQualificationTypes\.every/) assert.doesNotMatch(profileSource, /四类必填资质/) assert.doesNotMatch(profileSource, /法人身份证需与本次营业执照一起保存/) - assert.match(profileSource, /const standaloneLegalPersonIdUnsupported = computed/) - assert.match(profileSource, /当前文件服务尚未开放法人身份证单独上传或替换,该选填文件未提交/) + assert.doesNotMatch(profileSource, /standaloneLegalPersonIdUnsupported/) + assert.doesNotMatch(profileSource, /当前文件服务尚未开放法人身份证单独上传或替换/) + assert.match(profileSource, /item\.value !== 'LEGAL_PERSON_ID' \|\| !requiredPackageChangeSelected\.value/) + assert.match(profileSource, /法人身份证已保存,尚未提交审核/) assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/) assert.match(profileSource, /submitTenantQualificationReview/) assert.match(profileSource, /提交企业审核/) - assert.match(profileSource, /营业执照和统一社会信用代码已保存,尚未提交审核/) + assert.match(profileSource, /资质文件已保存,尚未提交审核/) assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/) assert.match(profileSource, /企业资料已提交审核,但最新状态刷新失败/) assert.match(profileSource, /qualificationStatusMeta/) diff --git a/src/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs index 0e5f74e..ca9604d 100644 --- a/src/navigation/clientNavigation.test.mjs +++ b/src/navigation/clientNavigation.test.mjs @@ -53,17 +53,21 @@ test('keeps only implemented navigation groups visible', () => { title: '头程物流', children: ['订单列表', '快速下单'], }, + { + title: '出海下单', + children: ['海外采购', '美国本土卡', '美本土散板'], + }, { title: '海外仓', children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'], }, { title: '订单管理', - children: ['派送订单列表'], + children: ['入库订单列表', '派送订单列表'], }, { title: '财务结算', - children: ['客户账单'], + children: ['资金账户', '客户账单'], }, { title: '企业管理', @@ -95,11 +99,16 @@ test('flattens visible leaves with unique paths', () => { '/', '/orders/first-leg', '/first-leg/quick-order', + '/cross-border/procurement', + '/cross-border/domestic-trucking', + '/cross-border/pallet-shipping', '/overseas-warehouse/labeling', '/overseas-warehouse/storage-transfer', '/overseas-warehouse/cargo-disposal', '/overseas-warehouse/return-relabel', + '/orders/inbound', '/orders/delivery', + '/finance/account', '/finance/bills', '/company/profile', '/organization/members', @@ -317,7 +326,7 @@ test('keeps the dashboard route permission synchronized with navigation', () => assert.match(dashboardRoute, /permission: 'dashboard:view'/) }) -test('keeps prototype views cataloged while allowing implemented order and overseas warehouse pages', () => { +test('keeps only the unified my-orders placeholder cataloged while activating implemented business pages', () => { const routerSource = [ 'first-leg', 'cross-border', @@ -339,7 +348,7 @@ test('keeps prototype views cataloged while allowing implemented order and overs ] for (const [path, view] of routes) { - if (path === 'first-leg/quick-order' || path.startsWith('overseas-warehouse/') || path === 'orders/delivery') { + if (path === 'first-leg/quick-order' || path.startsWith('cross-border/') || path.startsWith('overseas-warehouse/') || path === 'orders/inbound' || path === 'orders/delivery') { assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'active'[\\s\\S]*?permission:`)) assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), true) continue diff --git a/src/router/index.ts b/src/router/index.ts index 0280d8c..679f504 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -32,7 +32,8 @@ router.beforeEach(async (to) => { if (to.meta.requiresAuth && !auth.isAuthenticated) { return { name: 'login', query: { redirect: to.fullPath } } } - if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated) { + const isImpersonationHandoff = to.name === 'login' && typeof to.query.impersonationOrigin === 'string' + if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated && !isImpersonationHandoff) { return getFirstAccessiblePath(auth.permissionCodes) } const permission = typeof to.meta.permission === 'string' ? to.meta.permission : undefined