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

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-09-17 11:51:49 +08:00
231 changed files with 21447 additions and 3837 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.playwright-cli
.impeccable
dist
node_modules
node_modules.broken-by-path
.env.local
npm-debug.log*
pnpm-debug.log*
+3
View File
@@ -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
+1 -1
View File
@@ -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
+8
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/client-web.iml" filepath="$PROJECT_DIR$/.idea/client-web.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+69 -8
View File
@@ -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/<domain>/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` 定位。
+32
View File
@@ -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
Vendored
+176
View File
@@ -0,0 +1,176 @@
pipeline {
agent {
label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}"
}
environment {
DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml'
DEVELOPMENT_COMPOSE_PROJECT = '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
'''
}
}
}
+17 -7
View File
@@ -23,18 +23,28 @@ pnpm build
pnpm test
```
## 源码组织
业务代码按领域放在 `src/modules/<domain>/`。每个模块可以包含 `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 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
+21
View File
@@ -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
+19
View File
@@ -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
+17
View File
@@ -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"
+4 -2
View File
@@ -3,8 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="虫虫出海客户工作台" />
<title>虫虫出海客户工作台</title>
<meta name="description" content="柏悦物流客户工作台" />
<link rel="icon" type="image/png" href="/src/assets/brand/baiyue-favicon.png" />
<title>柏悦物流客户工作台</title>
</head>
<body>
<div id="app"></div>
@@ -12,3 +13,4 @@
</body>
</html>
+55
View File
@@ -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;
}
}
+2 -1
View File
@@ -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",
+5 -2
View File
@@ -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))
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240" role="img" aria-labelledby="title desc">
<title id="title">开发环境示例收款二维码</title>
<desc id="desc">仅用于开发流程验收,不包含真实收款信息</desc>
<rect width="240" height="240" fill="#fff"/>
<g fill="#111827">
<path d="M16 16h64v64H16zm10 10v44h44V26zm10 10h24v24H36zM160 16h64v64h-64zm10 10v44h44V26zm10 10h24v24h-24zM16 160h64v64H16zm10 10v44h44v-44zm10 10h24v24H36z"/>
<path d="M96 16h12v12H96zm24 0h12v24h-12zm16 8h12v12h-12zM88 44h20v12H88zm28 8h12v20h-12zm20-8h12v28h-12zM88 84h12v20H88zm24 0h28v12h-28zm36 4h12v20h-12zm20 0h20v12h-20zm28 0h28v12h-28zM16 96h20v12H16zm32-8h12v28H48zm20 8h20v12H68zm96 16h20v12h-20zm32-8h28v12h-28zM16 124h12v20H16zm20-8h20v12H36zm32 4h20v20H68zm92 16h12v16h-12zm20-4h20v12h-20zm28 4h16v20h-16zM92 160h20v12H92zm28-8h12v28h-12zm16 8h20v12h-20zm28 0h20v20h-20zm32 4h28v12h-28zM88 188h12v20H88zm20-4h20v12h-20zm28 4h16v20h-16zm28 0h12v12h-12zm20 0h20v12h-20zm28 4h12v28h-12zM96 216h24v8H96zm36-4h28v12h-28zm48 4h20v8h-20z"/>
</g>
<rect x="82" y="100" width="76" height="42" rx="5" fill="#fff" stroke="#111827" stroke-width="3"/>
<text x="120" y="119" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" font-weight="700" fill="#111827">开发示例</text>
<text x="120" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" fill="#b42318">不可付款</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 4 ]; then
echo "用法: $0 <服务名> <镜像仓库> <镜像标签> <Compose 文件>" >&2
exit 2
fi
SERVICE="$1"
IMAGE_REPOSITORY="$2"
IMAGE_TAG="$3"
COMPOSE_FILE="$4"
IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG"
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}"
SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}"
SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}"
REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}"
: "${REGISTRY_USER:?REGISTRY_USER 未设置}"
: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}"
run_ssh() {
ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@"
}
run_scp() {
scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@"
}
run_ssh install -d -m 750 "$REMOTE_DIR"
REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml"
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp"
run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE'
set -eu
compose_tmp="$1"
compose_target="$2"
chmod 644 "$compose_tmp"
mv -f "$compose_tmp" "$compose_target"
REMOTE_PREPARE
printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM
run_ssh docker pull "$IMAGE"
run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY'
set -eu
remote_dir="$1"
service="$2"
image_repository="$3"
image_tag="$4"
compose_file="docker-compose.$service.yml"
project_name="firstleg-prod-$service"
cd "$remote_dir"
export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag"
compose() {
docker compose --project-name "$project_name" -f "$compose_file" "$@"
}
container_id="$(compose ps --all --quiet "$service" | head -n 1)"
previous_image=""
if [ -n "$container_id" ]; then
previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")"
fi
if compose up -d --no-deps --pull never "$service" && \
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then
echo "生产前端部署成功: $image_repository:$image_tag"
compose ps "$service"
exit 0
fi
echo "生产前端健康检查失败,准备回滚" >&2
compose logs --tail=100 "$service" >&2 || true
case "$previous_image" in
"$image_repository:"*) ;;
'') echo "首次部署无旧镜像可回滚" >&2; exit 1 ;;
*) echo "旧镜像不属于当前应用,拒绝回滚: $previous_image" >&2; exit 1 ;;
esac
previous_tag="${previous_image#"$image_repository:"}"
if [ "$previous_tag" = "$image_tag" ]; then
echo "旧镜像与新镜像一致,无法回滚" >&2
exit 1
fi
docker pull "$previous_image"
export IMAGE_TAG="$previous_tag"
compose up -d --no-deps --pull never "$service"
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true
echo "已回滚至: $previous_image" >&2
exit 1
REMOTE_DEPLOY
+30
View File
@@ -0,0 +1,30 @@
# 应用内 Shared Package Instructions
## 项目定位
- 本包只服务当前前端应用,提供多个业务模块共同使用的 TypeScript 类型、常量和轻量工具。
- 依赖保持为 `@chongchong/shared: link:./shared`,确保当前应用目录可作为独立容器构建上下文。
- 不承担 Admin、Client 之间的源码复用;跨应用字段需要分别映射同一份 Java Controller/OpenAPI 契约,不能通过目录外链接耦合两个部署单元。
## 公共 API 与依赖边界
- `src/index.ts` 是当前唯一公共入口;模块专属页面状态、UI 组件和临时业务逻辑留在对应模块。
- 共享类型保持前端平台无关,不编码鉴权结论;租户隔离、权限、状态机和数据合法性由 `baiyue-firstleg` Java 服务端最终保证。
- 服务端字段变化先核对 Controller、DTO 和 OpenAPI,再更新本应用的类型、API 适配层和测试;本包不反向定义服务端行为。
- 避免建立宽泛的万能类型或引入重量级依赖。
## 契约与验证
- 修改导出名称、字段类型、枚举值或常量语义前,搜索当前应用内全部消费者。
- 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。
- 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。
## 订单与客户/入库测量与计费/国内货柜履约契约索引
- 订单与客户共享模型服务客户认证、企业资料/成员权限和头程订单;修改订单状态、仓库摘要、资质或权限枚举前,先核对阶段契约并搜索 `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`。范围与契约以正式确认和设计文档为准,原型只作补充。
+5
View File
@@ -0,0 +1,5 @@
# 应用内共享模块
本目录存放当前前端应用多个业务模块共同使用的 TypeScript 类型、常量和轻量工具,通过 `@chongchong/shared: link:./shared` 引用。
该目录必须保留在应用内部,以支持 Admin、Client 分别使用自身目录作为容器构建上下文。它不作为 Java 服务端契约来源;接口字段、状态机、权限和业务规则以 `baiyue-firstleg` Controller、DTO 和 OpenAPI 为准。
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@chongchong/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"types": "./src/index.ts"
}
+157
View File
@@ -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<T> {
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' },
]
+52 -3
View File
@@ -1,4 +1,53 @@
<template>
<RouterView />
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { useAuthStore } from '@/modules/auth/public'
let readyInterval: number | null = null
let expectedAdminOrigin = ''
let impersonationExchangePending = false
function configuredImpersonationOrigin() {
const queryOrigin = new URLSearchParams(window.location.search).get('impersonationOrigin')
if (queryOrigin) {
try { return new URL(queryOrigin).origin } catch { return '' }
}
return String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim() || window.location.origin
}
onMounted(() => {
expectedAdminOrigin = configuredImpersonationOrigin()
if (!window.opener || !expectedAdminOrigin) return
window.addEventListener('message', acceptImpersonationTicket)
const announceReady = () => window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-ready' }, expectedAdminOrigin)
announceReady()
readyInterval = window.setInterval(announceReady, 300)
window.setTimeout(() => {
if (readyInterval !== null) window.clearInterval(readyInterval)
readyInterval = null
}, 10000)
})
async function acceptImpersonationTicket(event: MessageEvent<unknown>) {
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 || impersonationExchangePending) return
impersonationExchangePending = true
if (readyInterval !== null) window.clearInterval(readyInterval)
readyInterval = null
const auth = useAuthStore()
try {
await auth.acceptImpersonationCode(message.code)
window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-complete' }, expectedAdminOrigin)
window.location.replace('/')
} catch {
window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-failed' }, expectedAdminOrigin)
window.location.replace('/login?impersonation=failed')
}
}
onBeforeUnmount(() => {
if (readyInterval !== null) window.clearInterval(readyInterval)
window.removeEventListener('message', acceptImpersonationTicket)
})
</script>
<template><RouterView /></template>
+107
View File
@@ -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 })
}
})
+56
View File
@@ -0,0 +1,56 @@
export interface CrossTabRefreshOptions<T> {
lockName: string
lockTimeoutMs?: number
observedRefreshToken: string
currentRefreshToken: () => string
reuseCurrentSession: () => Promise<T>
refresh: (refreshToken: string) => Promise<T>
}
export class CrossTabRefreshTimeoutError extends Error {
constructor() {
super('登录状态刷新等待超时,请重新登录')
this.name = 'CrossTabRefreshTimeoutError'
}
}
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
const run = async () => {
const currentRefreshToken = options.currentRefreshToken()
if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) {
return options.reuseCurrentSession()
}
return options.refresh(currentRefreshToken || options.observedRefreshToken)
}
if (typeof navigator === 'undefined' || !navigator.locks) {
try {
return await run()
} catch (error) {
if (!options.currentRefreshToken()) throw error
// Web Locks requires a secure context. On plain HTTP, briefly allow another tab's
// successful rotation to reach shared storage before treating this session as expired.
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 50))
const currentRefreshToken = options.currentRefreshToken()
if (!currentRefreshToken) throw error
if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) {
return options.reuseCurrentSession()
}
}
throw error
}
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
try {
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
} catch (error) {
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
throw error
} finally {
clearTimeout(timeout)
}
}
+16
View File
@@ -0,0 +1,16 @@
export type ApiFieldErrors = Record<string, string>
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}`
}
-228
View File
@@ -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<T> {
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<PageResult<MerchantWarehouse>>({ method: 'GET', url: '/merchant/v1/warehouses', params: { page: 1, size: 200, status: 'ACTIVE' } })
}
export function getMerchantWarehouseDetail(id: number | string) {
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` })
}
export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) {
return apiRequest<PageResult<MerchantFirstLegOrder>>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } })
}
export function getMerchantFirstLegOrder(id: number | string) {
return apiRequest<MerchantFirstLegOrderDetail>({ method: 'GET', url: `/merchant/v1/orders/first-leg/${id}` })
}
export function createMerchantFirstLegOrder(payload: FirstLegOrderSaveRequest) {
return apiRequest<number | string>({
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<void>({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload })
}
export function submitMerchantFirstLegOrder(id: number | string) {
return apiRequest<void>({
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}`
}
+99 -23
View File
@@ -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<T>(envelope: ApiEnvelope<T>) {
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<TokenPair> | null = null
let refreshPromise: Promise<string> | 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<ApiEnvelope<TokenPair>>('/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<ApiEnvelope<TokenPair>>('/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<Blob>({ ...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<Blob>({ ...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 }
-40
View File
@@ -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/)
})
+33
View File
@@ -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\)/)
})
+67
View File
@@ -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<T>(config: AxiosRequestConfig) {
try {
const response = await transport.request<unknown>(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 : '海外接口请求失败')
}
}
+26
View File
@@ -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)
})
+58
View File
@@ -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(), '')
})
+35 -4
View File
@@ -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')
}
}
+19
View File
@@ -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)
})
+12 -5
View File
@@ -1,3 +1,5 @@
import type { ApiFieldErrors } from '@/api/errorDetails'
export interface ApiEnvelope<T> {
code: string
message: string
@@ -7,7 +9,7 @@ export interface ApiEnvelope<T> {
timestamp: string
}
export type ApiFieldErrors = Record<string, string>
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<unknown> {
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<ApiEnvelope<never>, 'data'> {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
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')
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+21 -2
View File
@@ -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']
+2 -1
View File
@@ -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"
/>
+19 -4
View File
@@ -8,6 +8,7 @@ const props = defineProps<{
activePath: string
openedKeys: ReadonlySet<string>
activeParentKeys: ReadonlySet<string>
level?: number
}>()
const emit = defineEmits<{
@@ -22,7 +23,10 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`)
<li
class="client-nav-node"
v-if="node.children"
:class="{ 'is-active-parent': activeParentKeys.has(node.key) }"
:class="{
'has-divider-before': node.dividerBefore,
'is-active-parent': activeParentKeys.has(node.key),
}"
>
<button
type="button"
@@ -31,7 +35,12 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`)
:aria-controls="groupId"
@click="emit('toggle', node.key)"
>
<component :is="node.icon" :size="18" />
<component
:is="node.icon"
class="client-sidebar-node-icon"
:size="level ? 16 : 20"
aria-hidden="true"
/>
<span>{{ node.title }}</span>
<ChevronDown class="client-sidebar-group-chevron" :size="16" aria-hidden="true" />
</button>
@@ -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)"
/>
</ul>
</li>
<li v-else class="client-nav-node">
<li v-else class="client-nav-node" :class="{ 'has-divider-before': node.dividerBefore }">
<RouterLink
:to="node.path"
class="client-sidebar-link"
:aria-current="node.path === activePath ? 'page' : undefined"
@click="emit('navigate', node.path)"
>
<component :is="node.icon" :size="18" />
<component
:is="node.icon"
class="client-sidebar-node-icon"
:size="level ? 16 : 20"
aria-hidden="true"
/>
<span>{{ node.title }}</span>
</RouterLink>
</li>
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { MoreHorizontal, X } from '@lucide/vue'
import { useAuthStore } from '@/modules/auth/public'
import { getFirstAccessiblePath } from '@/navigation/clientNavigation'
import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork'
import { useWorkspaceTabsStore } from '@/navigation/workspace-tabs/workspaceTabs'
type CloseCommand = 'current' | 'others' | 'right' | 'all'
const router = useRouter()
const auth = useAuthStore()
const workspace = useWorkspaceTabsStore()
const unsaved = useUnsavedWorkStore()
const tabScroller = ref<HTMLElement>()
async function activate(fullPath: string) {
if (fullPath === workspace.activeTab?.fullPath) return
await router.push(fullPath)
}
async function confirmDiscard(keys: readonly string[]) {
const dirty = unsaved.findDirty(keys)
if (dirty.length === 0) return true
const labels = [...new Set(dirty.map((entry) => entry.label))].join('、')
try {
await ElMessageBox.confirm(
`“${labels}”还有未保存的修改。关闭页签后这些内容将丢失。`,
'放弃未保存的修改?',
{ type: 'warning', confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑' },
)
return true
} catch {
return false
}
}
async function closeTabs(keys: readonly string[]) {
const existingKeys = keys.filter((key) => workspace.tabs.some((tab) => tab.key === key))
if (existingKeys.length === 0 || !await confirmDiscard(existingKeys)) return
unsaved.removeRoutes(existingKeys)
const result = workspace.close(existingKeys)
if (!result.closedActive) return
await router.push(result.fallbackPath ?? getFirstAccessiblePath(auth.permissionCodes))
}
function keysForCommand(command: CloseCommand) {
const activeIndex = workspace.tabs.findIndex((tab) => tab.key === workspace.activeKey)
if (command === 'current') return workspace.activeKey ? [workspace.activeKey] : []
if (command === 'others') return workspace.tabs.filter((tab) => tab.key !== workspace.activeKey).map((tab) => tab.key)
if (command === 'right') return activeIndex < 0 ? [] : workspace.tabs.slice(activeIndex + 1).map((tab) => tab.key)
return workspace.tabs.map((tab) => tab.key)
}
function handleCommand(command: CloseCommand) {
void closeTabs(keysForCommand(command))
}
function handleTabWheel(event: WheelEvent) {
const scroller = tabScroller.value
if (!scroller || scroller.scrollWidth <= scroller.clientWidth) return
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
if (delta === 0) return
const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth
const canScroll = delta < 0 ? scroller.scrollLeft > 0 : scroller.scrollLeft < maxScrollLeft
if (!canScroll) return
event.preventDefault()
scroller.scrollLeft = Math.max(0, Math.min(maxScrollLeft, scroller.scrollLeft + delta))
}
watch(
() => workspace.activeKey,
async () => {
await nextTick()
tabScroller.value?.querySelector<HTMLElement>('.workspace-tab.is-active')?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
})
},
)
</script>
<template>
<section class="workspace-tabs" aria-label="已打开页面">
<div
ref="tabScroller"
class="workspace-tabs__scroller"
role="tablist"
aria-label="工作区页签"
@wheel="handleTabWheel"
>
<div
v-for="tab in workspace.tabs"
:key="tab.key"
class="workspace-tab"
:class="{ 'is-active': tab.key === workspace.activeKey, 'is-dirty': unsaved.dirtyRouteKeys.has(tab.key) }"
>
<button
type="button"
class="workspace-tab__label"
role="tab"
:aria-selected="tab.key === workspace.activeKey"
:aria-current="tab.key === workspace.activeKey ? 'page' : undefined"
:title="tab.title"
@click="activate(tab.fullPath)"
>
<span>{{ tab.title }}</span>
<i v-if="unsaved.dirtyRouteKeys.has(tab.key)" aria-label="有未保存修改" />
</button>
<button
type="button"
class="workspace-tab__close"
:aria-label="`关闭${tab.title}`"
:title="`关闭${tab.title}`"
@click="closeTabs([tab.key])"
><X :size="14" aria-hidden="true" /></button>
</div>
</div>
<el-dropdown trigger="click" placement="bottom-end" @command="handleCommand">
<button type="button" class="workspace-tabs__more" aria-label="页签管理" title="页签管理">
<MoreHorizontal :size="18" aria-hidden="true" />
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="current" :disabled="!workspace.activeKey">关闭当前</el-dropdown-item>
<el-dropdown-item command="others" :disabled="workspace.tabs.length <= 1">关闭其他</el-dropdown-item>
<el-dropdown-item command="right" :disabled="keysForCommand('right').length === 0">关闭右侧</el-dropdown-item>
<el-dropdown-item command="all" :disabled="workspace.tabs.length === 0" divided>关闭全部</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</section>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
export type DialogConfirmType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
title: string
width?: string | number
loading?: boolean
confirmLoading?: boolean
confirmDisabled?: boolean
confirmText?: string
cancelText?: string
confirmType?: DialogConfirmType
showFooter?: boolean
showCancel?: boolean
showConfirm?: boolean
showClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
lockWhileSubmitting?: boolean
}>(), {
width: 'min(560px, calc(100vw - 28px))',
loading: false,
confirmLoading: false,
confirmDisabled: false,
confirmText: '确定',
cancelText: '取消',
confirmType: 'primary',
showFooter: true,
showCancel: true,
showConfirm: true,
showClose: true,
closeOnClickModal: false,
closeOnPressEscape: true,
destroyOnClose: false,
lockWhileSubmitting: true,
})
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
function closeDialog() {
if (submittingLocked.value) return
open.value = false
emit('cancel')
}
</script>
<template>
<el-dialog
v-bind="$attrs"
v-model="open"
class="app-dialog"
:title="title"
:width="width"
:show-close="showClose && !submittingLocked"
:close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose"
>
<template v-if="$slots.header" #header><slot name="header" /></template>
<div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading">
<slot />
</div>
<template v-if="showFooter" #footer>
<slot name="footer" :close="closeDialog" :submitting="confirmLoading">
<div class="app-dialog__footer">
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDialog">{{ cancelText }}</el-button>
<el-button
v-if="showConfirm"
:type="confirmType"
:loading="confirmLoading"
:disabled="loading || confirmDisabled"
@click="emit('confirm')"
>{{ confirmText }}</el-button>
</div>
</slot>
</template>
</el-dialog>
</template>
<style scoped>
.app-dialog__body { min-height: 0; }
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-dialog__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { .app-dialog__footer :deep(.el-button) { min-height: 44px; } }
</style>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DialogConfirmType } from './AppDialog.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
title: string
size?: string | number
loading?: boolean
confirmLoading?: boolean
confirmDisabled?: boolean
confirmText?: string
cancelText?: string
confirmType?: DialogConfirmType
showFooter?: boolean
showCancel?: boolean
showConfirm?: boolean
showClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
lockWhileSubmitting?: boolean
}>(), {
size: 'min(680px, 94vw)',
loading: false,
confirmLoading: false,
confirmDisabled: false,
confirmText: '确定',
cancelText: '取消',
confirmType: 'primary',
showFooter: true,
showCancel: true,
showConfirm: true,
showClose: true,
closeOnClickModal: false,
closeOnPressEscape: true,
destroyOnClose: false,
lockWhileSubmitting: true,
})
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
function closeDrawer() {
if (submittingLocked.value) return
open.value = false
emit('cancel')
}
</script>
<template>
<el-drawer
v-bind="$attrs"
v-model="open"
class="app-drawer"
:title="title"
:size="size"
:show-close="showClose && !submittingLocked"
:close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose"
>
<template v-if="$slots.header" #header><slot name="header" /></template>
<div v-loading="loading" class="app-drawer__body" :aria-busy="loading || confirmLoading">
<slot />
</div>
<template v-if="showFooter" #footer>
<slot name="footer" :close="closeDrawer" :submitting="confirmLoading">
<div class="app-drawer__footer">
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDrawer">{{ cancelText }}</el-button>
<el-button
v-if="showConfirm"
:type="confirmType"
:loading="confirmLoading"
:disabled="loading || confirmDisabled"
@click="emit('confirm')"
>{{ confirmText }}</el-button>
</div>
</slot>
</template>
</el-drawer>
</template>
<style scoped>
:global(.app-drawer) { max-width: calc(100vw - 20px); }
.app-drawer__body { min-height: 0; }
.app-drawer__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-drawer__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { :global(.app-drawer) { max-width: 94vw; } .app-drawer__footer :deep(.el-button) { min-height: 44px; } }
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Copy } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { copyText } from '@/utils/copyText'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
value: string | number | null | undefined
label?: string
buttonText?: string
successText?: string
errorText?: string
disabled?: boolean
notify?: boolean
}>(), {
label: '内容',
buttonText: '复制',
successText: undefined,
errorText: undefined,
disabled: false,
notify: true,
})
const emit = defineEmits<{
copied: [value: string]
'copy-error': [error: unknown]
}>()
const copying = ref(false)
const copyValue = computed(() => props.value == null ? '' : String(props.value))
const isDisabled = computed(() => props.disabled || copying.value || copyValue.value.length === 0)
async function handleCopy() {
if (isDisabled.value) return
copying.value = true
try {
await copyText(copyValue.value)
if (props.notify) ElMessage.success(props.successText || `${props.label}已复制`)
emit('copied', copyValue.value)
} catch (error) {
if (props.notify) ElMessage.error(props.errorText || `${props.label}复制失败,请手动选择复制`)
emit('copy-error', error)
} finally {
copying.value = false
}
}
</script>
<template>
<el-button
v-bind="$attrs"
:disabled="isDisabled"
:aria-label="`复制${label}`"
:title="`复制${label}`"
@click="handleCopy"
>
<Copy :size="14" aria-hidden="true" />
<slot :copying="copying">{{ buttonText }}</slot>
</el-button>
</template>
+255
View File
@@ -0,0 +1,255 @@
<script lang="ts">
export interface DataTableColumn {
key?: string | number
label?: string
prop?: string
property?: string
columnKey?: string
slotName?: string
headerSlotName?: string
singleLine?: boolean
showOverflowTooltip?: boolean
className?: string
[attribute: string]: unknown
}
export type PaginationItem = number | 'ellipsis-start' | 'ellipsis-end'
export function normalizePageSizes(values: readonly number[]) {
const sizes = values
.filter((value) => Number.isFinite(value))
.map((value) => Math.trunc(value))
.filter((value) => value > 0)
const uniqueSizes = [...new Set(sizes)]
return uniqueSizes.length > 0 ? uniqueSizes : [10]
}
export function normalizePage(value: number, pageCount: number) {
const maximum = Number.isFinite(pageCount) && pageCount > 0 ? Math.trunc(pageCount) : 1
if (!Number.isFinite(value)) return 1
return Math.min(Math.max(Math.trunc(value), 1), maximum)
}
export function createPaginationItems(page: number, pageCount: number): PaginationItem[] {
if (pageCount <= 0) return []
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1)
if (page <= 4) return [1, 2, 3, 4, 5, 'ellipsis-end', pageCount]
if (page >= pageCount - 3) {
return [1, 'ellipsis-start', pageCount - 4, pageCount - 3, pageCount - 2, pageCount - 1, pageCount]
}
return [1, 'ellipsis-start', page - 1, page, page + 1, 'ellipsis-end', pageCount]
}
</script>
<script setup lang="ts">
import { computed, useSlots, watch } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { splitTableActions, runTableAction, type TableAction } from './tableActions'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
data: object[]
columns: DataTableColumn[]
loading?: boolean
emptyText?: string
total?: number
pageSizes?: number[]
rowActions?: (row: object) => TableAction[]
}>(), {
loading: false,
emptyText: '暂无数据',
total: undefined,
pageSizes: () => [10, 20, 50, 100],
})
const page = defineModel<number>('page', { default: 1 })
const pageSize = defineModel<number>('pageSize', { default: 10 })
const slots = useSlots()
const normalizedTotal = computed(() => {
const value = props.total ?? 0
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0
})
const normalizedPageSizes = computed(() => normalizePageSizes(props.pageSizes))
const effectivePageSize = computed(() => (
normalizedPageSizes.value.includes(pageSize.value) ? pageSize.value : normalizedPageSizes.value[0]
))
const pageCount = computed(() => (
normalizedTotal.value === 0 ? 0 : Math.ceil(normalizedTotal.value / effectivePageSize.value)
))
const currentPage = computed(() => normalizePage(page.value, pageCount.value))
const paginationItems = computed(() => createPaginationItems(currentPage.value, pageCount.value))
watch([pageSize, normalizedPageSizes], ([value, sizes]) => {
const integerValue = Number.isFinite(value) ? Math.trunc(value) : sizes[0]
const nextValue = sizes.includes(integerValue) ? integerValue : sizes[0]
if (value === nextValue) return
pageSize.value = nextValue
page.value = 1
}, { immediate: true })
watch([page, pageCount], ([value, count]) => {
const nextValue = normalizePage(value, count)
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
function columnAttrs({ key, slotName, headerSlotName, singleLine, ...attrs }: DataTableColumn) {
return attrs
}
function systemColumn(column: DataTableColumn) {
return column.type === 'selection'
|| column.type === 'index'
|| column.type === 'expand'
|| column.slotName === 'actions'
|| column.label === '操作'
}
function singleLineColumn(column: DataTableColumn) {
return column.singleLine !== false && !systemColumn(column)
}
function columnOverflowTooltip(column: DataTableColumn) {
return singleLineColumn(column) ? column.showOverflowTooltip ?? true : column.showOverflowTooltip
}
function columnClassName(column: DataTableColumn) {
return [
column.className,
systemColumn(column) ? 'data-table__system-cell' : '',
singleLineColumn(column) ? 'data-table__single-line-cell' : '',
].filter(Boolean).join(' ')
}
function columnRenderKey(column: DataTableColumn, index: number) {
return String(column.key ?? column.columnKey ?? column.prop ?? column.property ?? index)
}
function hasSlot(name?: string) {
return Boolean(name && slots[name])
}
function updatePageSize(value: number) {
const nextValue = normalizedPageSizes.value.includes(value) ? value : normalizedPageSizes.value[0]
if (nextValue === pageSize.value) return
pageSize.value = nextValue
page.value = 1
}
function setPage(value: number) {
page.value = normalizePage(value, pageCount.value)
}
</script>
<template>
<div class="data-table" :class="$attrs.class">
<header v-if="$slots.toolbar || $slots['toolbar-actions']" class="data-table__toolbar">
<div class="data-table__toolbar-heading"><slot name="toolbar" /></div>
<div class="data-table__toolbar-actions"><slot name="toolbar-actions" /></div>
</header>
<slot name="before-table" />
<slot v-if="$slots.error" name="error" />
<template v-else>
<el-table v-bind="{ ...$attrs, class: undefined }" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<el-table-column
v-for="(column, index) in columns"
:key="columnRenderKey(column, index)"
v-bind="columnAttrs(column)"
:class-name="columnClassName(column)"
:show-overflow-tooltip="columnOverflowTooltip(column)"
>
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
<slot :name="column.headerSlotName" v-bind="scope" />
</template>
<template v-if="column.slotName === 'actions' && rowActions" #default="{ row }">
<div class="data-table__row-actions">
<el-button v-for="action in splitTableActions(rowActions(row)).visible" :key="action.key"
:disabled="action.disabled" :loading="action.loading" @click="runTableAction(action)">
{{ action.label }}
</el-button>
<el-dropdown v-if="splitTableActions(rowActions(row)).overflow.length" trigger="click">
<el-button class="data-table__more" aria-label="更多操作" title="更多操作">…</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="action in splitTableActions(rowActions(row)).overflow" :key="action.key"
:disabled="action.disabled || action.loading" @click="runTableAction(action)">
{{ action.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<template v-else-if="hasSlot(column.slotName)" #default="scope">
<slot :name="column.slotName" v-bind="scope" />
</template>
</el-table-column>
<template v-if="$slots.empty" #empty><slot name="empty" /></template>
</el-table>
<footer v-if="total !== undefined" class="data-table__pagination" aria-label="表格分页">
<div class="data-table__pagination-left">
<slot name="pagination-left" :total="normalizedTotal" :page="currentPage" :page-size="effectivePageSize" :page-count="pageCount">
<span v-if="pageCount > 0">共 {{ normalizedTotal }} 条 · 第 {{ currentPage }} / {{ pageCount }} 页</span>
<span v-else>共 0 条</span>
</slot>
</div>
<div class="data-table__pagination-controls">
<label class="data-table__page-size">
<span>每页</span>
<el-select :model-value="effectivePageSize" :disabled="loading" aria-label="每页条数" @update:model-value="updatePageSize">
<el-option v-for="size in normalizedPageSizes" :key="size" :label="String(size)" :value="size" />
</el-select>
<span>条</span>
</label>
<nav v-if="pageCount > 0" class="data-table__page-buttons" aria-label="页码">
<el-button class="data-table__page-button" :disabled="loading || currentPage <= 1" aria-label="上一页" title="上一页" @click="setPage(currentPage - 1)"><ChevronLeft :size="16" /></el-button>
<template v-for="item in paginationItems" :key="item">
<span v-if="typeof item !== 'number'" class="data-table__page-ellipsis" aria-hidden="true">...</span>
<el-button
v-else
class="data-table__page-button"
:type="item === currentPage ? 'primary' : undefined"
:disabled="loading"
:aria-current="item === currentPage ? 'page' : undefined"
:aria-label="`第 ${item} 页`"
:title="`第 ${item} 页`"
@click="setPage(item)"
>{{ item }}</el-button>
</template>
<el-button class="data-table__page-button" :disabled="loading || currentPage >= pageCount" aria-label="下一页" title="下一页" @click="setPage(currentPage + 1)"><ChevronRight :size="16" /></el-button>
</nav>
</div>
</footer>
</template>
</div>
</template>
<style scoped>
.data-table { min-width: 0; }
.data-table__toolbar { display: flex; min-height: 48px; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--client-gap); padding: var(--client-toolbar-padding); border-bottom: 1px solid var(--client-border); }
.data-table__toolbar-heading, .data-table__toolbar-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--client-gap); }
.data-table__toolbar-heading { color: var(--client-muted); font-size: 12px; }
.data-table__toolbar-heading :slotted(strong) { color: var(--client-text); font-size: 14px; }
.data-table__toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
.data-table__row-actions { display: flex; align-items: center; gap: var(--client-gap); white-space: nowrap; }
.data-table__row-actions :deep(.el-button) { height: var(--client-row-action-height); padding: 0 8px; margin: 0; }
.data-table :deep(.data-table__system-cell .data-table__row-actions .el-button + .el-button), .data-table :deep(.data-table__system-cell .data-table__row-actions .el-button + .el-dropdown) { margin-left: 0; }
.data-table__more { min-width: 28px; }
:global(.client-ui .data-table .el-table) { color: var(--client-text); font-size: var(--client-font-size); --el-table-border-color: var(--client-border); --el-table-header-bg-color: var(--client-header-bg); --el-table-row-hover-bg-color: var(--client-hover-bg); }
:global(.client-ui .data-table th.el-table__cell) { height: var(--client-table-header-height); padding: 0; color: var(--client-muted); font-weight: 600; }
:global(.client-ui .data-table td.el-table__cell) { height: var(--client-table-row-height); padding: 0; }
:global(.client-ui .data-table .el-table .cell) { padding: 0 12px; line-height: 20px; }
:global(.client-ui .data-table .el-table__row:nth-child(even)) { --el-table-tr-bg-color: #fcfcfd; }
.data-table :deep(.data-table__single-line-cell .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table :deep(.data-table__system-cell .cell) { white-space: nowrap; }
.data-table :deep(.data-table__system-cell .el-button + .el-button), .data-table :deep(.data-table__system-cell .el-button + .el-dropdown) { margin-left: 8px; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: var(--client-pagination-padding); border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
.data-table__page-size :deep(.el-select) { width: 82px; }
.data-table__page-buttons { gap: 4px; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
@media (max-width: 720px) { .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
</style>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { CircleAlert } from '@lucide/vue'
withDefaults(defineProps<{
title: string
message?: string
loading?: boolean
retryText?: string
showRetry?: boolean
compact?: boolean
}>(), {
message: '',
loading: false,
retryText: '重新加载',
showRetry: true,
compact: false,
})
defineEmits<{ retry: [] }>()
</script>
<template>
<section class="load-error-state" :class="{ 'is-compact': compact }" role="alert">
<CircleAlert class="load-error-state__icon" :size="20" aria-hidden="true" />
<div class="load-error-state__content">
<strong>{{ title }}</strong>
<p v-if="message">{{ message }}</p>
<slot name="details" />
</div>
<div v-if="showRetry || $slots.actions" class="load-error-state__actions">
<slot name="actions">
<el-button type="primary" plain :loading="loading" @click="$emit('retry')">{{ retryText }}</el-button>
</slot>
</div>
</section>
</template>
<style scoped>
.load-error-state { display: flex; min-width: 0; align-items: flex-start; gap: 12px; padding: 16px; border: 1px solid var(--el-color-danger-light-7); border-radius: 8px; background: var(--el-color-danger-light-9); color: var(--el-text-color-primary); }
.load-error-state.is-compact { padding: 12px; }
.load-error-state__icon { flex: 0 0 auto; margin-top: 1px; color: var(--el-color-danger); }
.load-error-state__content { min-width: 0; flex: 1; }
.load-error-state__content strong { display: block; font-size: 14px; line-height: 1.5; }
.load-error-state__content p { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.load-error-state__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
.load-error-state__actions :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { .load-error-state { flex-wrap: wrap; } .load-error-state__content { flex-basis: calc(100% - 36px); } .load-error-state__actions { width: 100%; padding-left: 32px; } .load-error-state__actions :deep(.el-button) { min-height: 44px; } }
</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
title: string
description: string
}>()
</script>
<template>
<section class="prototype-feature">
<div class="prototype-feature-empty">
<span>待接入</span>
<h2>{{ title }}</h2>
<p>{{ description }}的业务页面已建立,后续接入对应接口和操作流程。</p>
<RouterLink to="/">返回业务总览</RouterLink>
</div>
</section>
</template>
<style scoped>
.prototype-feature { display: grid; min-height: calc(100vh - 128px); place-items: center; }
.prototype-feature-empty { display: flex; width: min(520px, 100%); flex-direction: column; align-items: center; padding: 40px 28px; border: 1px solid var(--border); background: #fff; text-align: center; }
.prototype-feature-empty span { color: var(--primary); font-size: 12px; font-weight: 700; }
.prototype-feature-empty h2 { margin: 12px 0 8px; color: var(--navy); font-size: 22px; }
.prototype-feature-empty p { max-width: 34ch; margin: 0; color: var(--muted); font-size: 13px; line-height: 1.7; }
.prototype-feature-empty a { margin-top: 22px; color: var(--primary); font-size: 13px; font-weight: 650; text-decoration: none; }
.prototype-feature-empty a:hover { text-decoration: underline; }
.prototype-feature-empty a:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
</style>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { Search, RotateCcw } from '@lucide/vue'
defineOptions({ inheritAttrs: false })
export type QueryFieldType = 'input' | 'select' | 'date' | 'daterange'
export type QueryValue = string | number | boolean | null | undefined | string[] | number[] | Date | Date[]
export type QueryModel = Record<string, QueryValue>
export interface QueryOption {
label: string
value: string | number | boolean
disabled?: boolean
}
export interface QueryField {
key: string
label?: string
type: QueryFieldType
placeholder?: string
options?: QueryOption[]
props?: Record<string, unknown>
}
const props = withDefaults(defineProps<{
layout?: 'inline' | 'grid'
ariaLabel: string
fields: QueryField[]
searchText?: string
resetText?: string
searchOnReset?: boolean
}>(), {
layout: 'inline',
searchText: '查询',
resetText: '重置',
searchOnReset: true,
})
const model = defineModel<QueryModel>({ required: true })
const emit = defineEmits<{
search: [model: QueryModel]
reset: [model: QueryModel]
}>()
function updateField(key: string, value: QueryValue) {
model.value = { ...model.value, [key]: value }
}
function defaultPlaceholder(field: QueryField) {
if (field.type === 'input') return '请输入关键词'
if (field.type === 'select') return '全部'
return field.type === 'daterange' ? '请选择日期范围' : '请选择日期'
}
function datePickerType(field: QueryField): 'date' | 'daterange' {
return field.type === 'daterange' ? 'daterange' : 'date'
}
function resetValue(field: QueryField): QueryValue {
return field.type === 'daterange' ? [] : ''
}
function search() {
emit('search', model.value)
}
function reset() {
const next = { ...model.value }
for (const field of props.fields) next[field.key] = resetValue(field)
model.value = next
emit('reset', next)
if (props.searchOnReset) emit('search', next)
}
defineExpose({ search, reset })
</script>
<template>
<section
v-bind="$attrs"
class="query-toolbar"
:class="{ 'query-toolbar--grid': layout === 'grid' }"
:aria-label="ariaLabel"
>
<div class="query-toolbar__filters">
<label v-for="field in fields" :key="field.key" class="query-toolbar__field">
<span v-if="field.label" class="query-toolbar__label">{{ field.label }}</span>
<el-input
v-if="field.type === 'input'"
:model-value="model[field.key] as string"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
>
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select
v-else-if="field.type === 'select'"
:model-value="model[field.key] as string | number | boolean | undefined"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
>
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" :disabled="option.disabled" />
</el-select>
<el-date-picker
v-else
:model-value="model[field.key] as string | string[] | undefined"
:type="datePickerType(field)"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
/>
</label>
</div>
<div class="query-toolbar__actions">
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="actions" />
</div>
</section>
</template>
<style scoped>
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--border); background: #fff; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
.query-toolbar__field { display: flex; min-width: 0; align-items: center; gap: 9px; }
.query-toolbar__label { flex: 0 0 auto; color: #5f6877; font-size: 12px; white-space: nowrap; }
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 260px; max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
.query-toolbar--grid { display: block; padding: var(--client-query-padding); border: 1px solid var(--client-border); border-radius: var(--client-radius); }
.query-toolbar--grid .query-toolbar__filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 16px; }
.query-toolbar--grid .query-toolbar__field { min-width: 0; }
.query-toolbar--grid .query-toolbar__field :deep(.el-input), .query-toolbar--grid .query-toolbar__field :deep(.el-select), .query-toolbar--grid .query-toolbar__field :deep(.el-date-editor) { flex: 1; width: 100%; min-width: 0; }
.query-toolbar--grid .query-toolbar__actions { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--client-border); }
@media (max-width: 1200px) { .query-toolbar--grid .query-toolbar__filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { display: grid; gap: 6px; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
</style>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
withDefaults(defineProps<{
value?: string | number | null
emptyText?: string
}>(), {
value: null,
emptyText: '—',
})
</script>
<template>
<span class="single-line-text" :title="value == null || value === '' ? undefined : String(value)">
{{ value == null || value === '' ? emptyText : value }}
</span>
</template>
<style scoped>
.single-line-text {
display: block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -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)
})
+18
View File
@@ -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()
}
-47
View File
@@ -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<FirstLegParty, 'roles' | 'status'> {
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' },
]
+63
View File
@@ -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"/)
})
+9
View File
@@ -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,/)
})
+54
View File
@@ -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(/<template(?:\s[^>]*)?>([\s\S]*)<\/template>/)?.[1] ?? ''
const withoutComments = template.replace(/<!--[\s\S]*?-->/g, '')
const attributeContent = String.raw`(?:[^>"']|"[^"]*"|'[^']*')*`
const tagPattern = new RegExp(`<el-input(?:-number)?\\b${attributeContent}/?>|<(?:input|textarea)\\b${attributeContent}/?>`, 'g')
for (const match of withoutComments.matchAll(tagPattern)) {
const tag = match[0]
const isElementInput = tag.startsWith('<el-input')
const editable = isElementInput
? !hasLiteralAttribute(tag, 'disabled') && !hasLiteralAttribute(tag, 'readonly')
: isEditableNativeTextInput(tag)
if (editable && !/\s:?(?:placeholder)\s*=/.test(tag)) {
violations.push(`${path.relative(sourceRoot, file)}:${lineNumber(withoutComments, match.index ?? 0)} ${tag.replace(/\s+/g, ' ')}`)
}
}
}
assert.deepEqual(violations, [], `Editable text inputs without placeholders:\n${violations.join('\n')}`)
})
+2 -1
View File
@@ -10,6 +10,7 @@ declare module 'vue-router' {
description?: string
requiresAuth?: boolean
permission?: string
workspaceTab?: boolean
keepAlive?: boolean
}
}
+37 -3
View File
@@ -3,22 +3,25 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
const layoutSource = readFileSync(new URL('./AppLayout.vue', import.meta.url), 'utf8')
const shellStyles = readFileSync(new URL('../styles/shell.css', import.meta.url), 'utf8')
const globalStyles = readFileSync(new URL('../styles/index.css', import.meta.url), 'utf8')
const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
const sidebarMenuSource = readFileSync(new URL('../components/ClientSidebarMenu.vue', import.meta.url), 'utf8')
const sidebarMenuNodeSource = readFileSync(new URL('../components/ClientSidebarMenuNode.vue', import.meta.url), 'utf8')
test('source contract: renders the grouped sidebar menu and mobile drawer controls', () => {
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, /<button\s+v-if="mobileMenuOpen"[^>]*class="client-sidebar-scrim"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"/s)
assert.match(layoutSource, /<aside\s+ref="sidebarRef"\s+id="client-sidebar"\s+class="client-sidebar"\s+:inert="!desktopViewport && !mobileMenuOpen"\s+:aria-hidden="!desktopViewport && !mobileMenuOpen \? 'true' : undefined"\s*>/)
assert.doesNotMatch(layoutSource, /<aside[^>]*aria-label=/)
assert.match(layoutSource, /<nav aria-label="客户工作台导航">/)
assert.match(layoutSource, /<div class="client-sidebar-header">\s*<strong>功能导航<\/strong>/s)
assert.match(layoutSource, /<div class="client-sidebar-brand">[\s\S]*?<RouterLink class="client-sidebar-brand-link" to="\/" aria-label="返回业务总览" @click="closeMobileMenu">[\s\S]*?alt="柏悦物流"[\s\S]*?客户工作台/s)
assert.match(layoutSource, /<button[^>]*ref="sidebarCloseButtonRef"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"[^>]*>\s*<X :size="20" \/>/s)
assert.match(layoutSource, /<ClientSidebarMenu\s+:active-path="route\.path"\s+@navigate="closeMobileMenu"\s*\/>/s)
assert.match(layoutSource, /<button type="button" class="client-sidebar-logout" @click="handleLogout">[\s\S]*?退出登录/s)
assert.doesNotMatch(layoutSource, /v-for="item in clientNavigation"/)
assert.doesNotMatch(layoutSource, /from '@\/router'/)
})
@@ -42,8 +45,36 @@ test('source contract: removes the header and main content from the accessibilit
assert.match(layoutSource, /<main\s+class="client-main"\s+:inert="mobileMenuOpen"\s+:aria-hidden="mobileMenuOpen \? 'true' : undefined"\s*>/)
})
test('source contract: keeps route context and user identity in the compact header', () => {
assert.match(layoutSource, /const pageTitle = computed\(\(\) => route\.meta\.title \|\| '业务总览'\)/)
assert.match(layoutSource, /const pageDescription = computed\(\(\) => route\.meta\.description \|\| ''\)/)
assert.match(layoutSource, /<div class="client-header-heading">\s*<h1>\{\{ pageTitle \}\}<\/h1>\s*<p v-if="pageDescription">\{\{ pageDescription \}\}<\/p>/s)
assert.match(layoutSource, /<div class="client-user">[\s\S]*\{\{ auth\.user\?\.name \}\}/)
assert.doesNotMatch(layoutSource, /<div class="client-user">[\s\S]*aria-label="退出登录"/)
assert.doesNotMatch(layoutSource, /Building2|tenant-name|tenantName/)
assert.doesNotMatch(routerSource, /showPageHeading|showTenantName/)
})
test('source contract: renders the complete route breadcrumb before page content', () => {
assert.match(layoutSource, /import \{ getFirstAccessiblePath, getRouteBreadcrumbs \} from '@\/navigation\/clientNavigation'/)
assert.match(layoutSource, /const breadcrumbs = computed\(\(\) => getRouteBreadcrumbs\(route\.path, String\(pageTitle\.value\)\)\)/)
assert.match(layoutSource, /<nav class="client-breadcrumb" aria-label="页面路径">[\s\S]*?v-for="\(item, index\) in breadcrumbs"[\s\S]*?<RouterLink v-if="item\.path" :to="item\.path">[\s\S]*?<strong v-else aria-current="page">/)
assert.match(shellStyles, /\.client-breadcrumb \{[^}]*margin: 0 0 14px;/)
})
test('source contract: aligns the light top bar beside the prototype-inspired dark sidebar and bounds the mobile header', () => {
assert.match(shellStyles, /\.top-header \{[^}]*inset: 0 0 auto 250px;[^}]*max-width: calc\(100vw - 250px\);[^}]*padding: 0 24px;/)
assert.match(shellStyles, /\.top-header \{[^}]*background: rgba\(255, 255, 255, \.96\)/)
assert.match(shellStyles, /\.client-sidebar \{[^}]*inset: 0 auto 0 0;[^}]*width: 250px;[^}]*background: linear-gradient\(180deg, #0b1f3a 0%, #0c2a52 100%\)/)
assert.match(shellStyles, /\.client-sidebar-brand \{[^}]*border-bottom: 1px solid rgba\(255, 255, 255, \.09\)/)
assert.match(shellStyles, /\.client-main \{[^}]*padding: 134px 20px 40px;/)
assert.match(globalStyles, /@media \(max-width: 720px\) \{[\s\S]*\.top-header \{ inset: 0;[^}]*padding: 0 14px; \}/)
assert.match(globalStyles, /\.client-header-menu-button \{ display: grid; \}/)
assert.match(globalStyles, /\.client-header-heading p, \.client-user-copy \{ display: none; \}/)
})
test('source contract: closes the mobile menu on route changes and Escape', () => {
assert.match(layoutSource, /watch\(\(\) => route\.fullPath, closeMobileMenu\)/)
assert.match(layoutSource, /watch\(\(\) => route\.fullPath, \(\) => \{\s*closeMobileMenu\(\)\s*registerCurrentRoute\(\)\s*\}\)/)
assert.match(layoutSource, /event\.key === 'Escape'/)
assert.match(layoutSource, /onMounted\(\(\) => \{[\s\S]*document\.addEventListener\('keydown', handleKeydown\)[\s\S]*\}\)/)
assert.match(layoutSource, /document\.removeEventListener\('keydown', handleKeydown\)/)
@@ -86,9 +117,12 @@ test('source contract: uses a native recursive disclosure list without Element m
assert.match(sidebarMenuSource, /:active-parent-keys="activeParentKeys"/)
assert.match(sidebarMenuSource, /@toggle="handleToggle"/)
assert.match(sidebarMenuNodeSource, /<li\s+class="client-nav-node"[\s\S]*?v-if="node\.children"/)
assert.match(sidebarMenuNodeSource, /'has-divider-before': node\.dividerBefore/)
assert.match(sidebarMenuNodeSource, /<button[\s\S]*?:aria-expanded="openedKeys\.has\(node\.key\)"[\s\S]*?:aria-controls="groupId"[\s\S]*?@click="emit\('toggle', node\.key\)"/)
assert.match(sidebarMenuNodeSource, /<ul\s+v-show="openedKeys\.has\(node\.key\)"\s+:id="groupId"\s+class="client-nav-submenu"\s*>/)
assert.match(sidebarMenuNodeSource, /<RouterLink[\s\S]*?:aria-current="node\.path === activePath \? 'page' : undefined"[\s\S]*?@click="emit\('navigate', node\.path\)"/)
assert.match(sidebarMenuNodeSource, /<component\s+:is="node\.icon"\s+class="client-sidebar-node-icon"\s+:size="level \? 16 : 20"\s+aria-hidden="true"/)
assert.doesNotMatch(sidebarMenuNodeSource, /<component\s+v-if="!level"/)
assert.match(sidebarMenuNodeSource, /@navigate="emit\('navigate', \$event\)"/)
assert.match(sidebarMenuNodeSource, /@toggle="emit\('toggle', \$event\)"/)
})
+148 -38
View File
@@ -2,21 +2,30 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import {
Building2,
LogOut,
Menu,
X,
} from '@lucide/vue'
import { PRODUCT_NAME } from '@chongchong/shared'
import { Bell, LogOut, Menu, X } from '@lucide/vue'
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
import { useAuthStore } from '@/stores/auth'
import WorkspaceTabs from '@/components/WorkspaceTabs.vue'
import { useAuthStore } from '@/modules/auth/public'
import { useNotificationsStore } from '@/modules/notifications/public'
import { getFirstAccessiblePath, getRouteBreadcrumbs } from '@/navigation/clientNavigation'
import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork'
import {
MAX_CACHED_WORKSPACE_VIEWS,
isWorkspaceRoute,
useWorkspaceTabsStore,
type WorkspaceTab,
} from '@/navigation/workspace-tabs/workspaceTabs'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const notifications = useNotificationsStore()
const workspace = useWorkspaceTabsStore()
const unsaved = useUnsavedWorkStore()
const pageTitle = computed(() => route.meta.title || '业务总览')
const pageDescription = computed(() => route.meta.description || '')
const breadcrumbs = computed(() => getRouteBreadcrumbs(route.path, String(pageTitle.value)))
const mobileMenuOpen = ref(false)
const desktopViewport = ref(true)
const menuButtonRef = ref<HTMLButtonElement>()
@@ -24,6 +33,25 @@ const sidebarRef = ref<HTMLElement>()
const sidebarCloseButtonRef = ref<HTMLButtonElement>()
let previousBodyOverflow: string | undefined
let desktopMediaQuery: MediaQueryList | undefined
const scrollPositions = new Map<string, number>()
function isAllowedTab(tab: WorkspaceTab) {
const resolved = router.resolve(tab.fullPath)
if (String(resolved.name ?? '') !== tab.routeName || !isWorkspaceRoute(resolved)) return false
const permission = typeof resolved.meta.permission === 'string' ? resolved.meta.permission : undefined
return auth.hasPermission(permission)
}
function registerCurrentRoute() {
workspace.openRoute(route, unsaved.dirtyRouteKeys)
}
const restoredActivePath = workspace.initialize(auth.user?.id ?? '', isAllowedTab)
if (route.fullPath === '/' && restoredActivePath && restoredActivePath !== '/') {
void router.replace(restoredActivePath)
} else {
registerCurrentRoute()
}
function openMobileMenu() {
mobileMenuOpen.value = true
@@ -49,7 +77,51 @@ function handleDesktopBreakpoint(event: MediaQueryList | MediaQueryListEvent) {
if (event.matches) closeMobileMenu()
}
watch(() => route.fullPath, closeMobileMenu)
watch(() => route.fullPath, () => {
closeMobileMenu()
registerCurrentRoute()
})
watch(
() => String(route.name ?? ''),
async (routeName, previousRouteName) => {
if (previousRouteName && workspace.tabs.some((tab) => tab.key === previousRouteName)) {
scrollPositions.set(previousRouteName, window.scrollY)
}
await nextTick()
window.requestAnimationFrame(() => {
window.scrollTo(0, scrollPositions.get(routeName) ?? 0)
})
},
)
watch(
() => workspace.tabs.map((tab) => tab.key),
(tabKeys) => {
const retainedKeys = new Set(tabKeys)
for (const routeKey of scrollPositions.keys()) {
if (!retainedKeys.has(routeKey)) scrollPositions.delete(routeKey)
}
},
)
watch(
() => auth.permissionCodes.join('|'),
() => {
const result = workspace.reconcile(isAllowedTab)
if (result.closedActive) void router.replace(result.fallbackPath ?? getFirstAccessiblePath(auth.permissionCodes))
},
)
watch(
() => auth.isAuthenticated,
(authenticated) => {
if (authenticated) return
workspace.clear()
unsaved.clear()
},
)
watch(mobileMenuOpen, async (open, wasOpen) => {
if (open) {
@@ -70,7 +142,9 @@ watch(mobileMenuOpen, async (open, wasOpen) => {
})
onMounted(() => {
if (auth.hasPermission('merchant:notification:view')) void notifications.refreshUnreadCount()
document.addEventListener('keydown', handleKeydown)
window.addEventListener('beforeunload', handleBeforeUnload)
desktopMediaQuery = window.matchMedia('(min-width: 721px)')
desktopMediaQuery.addEventListener('change', handleDesktopBreakpoint)
handleDesktopBreakpoint(desktopMediaQuery)
@@ -78,21 +152,33 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('beforeunload', handleBeforeUnload)
desktopMediaQuery?.removeEventListener('change', handleDesktopBreakpoint)
restoreBodyOverflow()
})
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!unsaved.hasDirty) return
event.preventDefault()
event.returnValue = ''
}
async function handleLogout() {
try {
await ElMessageBox.confirm('确定退出客户工作台吗?', '退出登录', {
await ElMessageBox.confirm(unsaved.hasDirty
? '当前还有未保存的修改,退出后这些内容将丢失。确定继续吗?'
: '确定退出客户工作台吗?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: unsaved.hasDirty ? 'warning' : undefined,
})
} catch (error) {
if (error === 'cancel' || error === 'close') return
throw error
}
workspace.clear()
unsaved.clear()
await auth.logout()
await router.replace('/login')
}
@@ -100,20 +186,26 @@ async function handleLogout() {
<template>
<div class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen }">
<header
class="top-header"
:inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
>
<RouterLink class="client-brand" to="/">
<span class="client-brand-mark">CC</span>
<span><strong>{{ PRODUCT_NAME }}</strong><small>客户工作台</small></span>
</RouterLink>
<div class="tenant-name"><Building2 :size="17" /> {{ auth.user?.tenantName }}</div>
<header class="top-header" :inert="mobileMenuOpen" :aria-hidden="mobileMenuOpen ? 'true' : undefined">
<button
ref="menuButtonRef"
type="button"
class="client-header-menu-button"
aria-label="打开导航"
aria-controls="client-sidebar"
:aria-expanded="mobileMenuOpen"
@click="openMobileMenu"
>
<Menu :size="20" />
</button>
<div class="client-header-heading">
<h1>{{ pageTitle }}</h1>
<p v-if="pageDescription">{{ pageDescription }}</p>
</div>
<div class="client-user">
<RouterLink v-if="auth.hasPermission('merchant:notification:view')" class="client-header-notifications" to="/notifications" aria-label="打开我的消息"><Bell :size="19" /><span v-if="notifications.unreadCount !== null && notifications.unreadCount > 0" class="client-header-notifications-badge">{{ notifications.unreadCount > 99 ? '99+' : notifications.unreadCount }}</span></RouterLink>
<span class="client-avatar">{{ auth.user?.name.slice(0, 1) }}</span>
<span class="client-user-copy"><strong>{{ auth.user?.name }}</strong><small>{{ auth.user?.roles.join('、') }}</small></span>
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
</div>
</header>
@@ -132,8 +224,11 @@ async function handleLogout() {
:inert="!desktopViewport && !mobileMenuOpen"
:aria-hidden="!desktopViewport && !mobileMenuOpen ? 'true' : undefined"
>
<div class="client-sidebar-header">
<strong>功能导航</strong>
<div class="client-sidebar-brand">
<RouterLink class="client-sidebar-brand-link" to="/" aria-label="返回业务总览" @click="closeMobileMenu">
<img class="client-sidebar-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" />
<span class="client-sidebar-brand-copy"><small>客户工作台</small></span>
</RouterLink>
<button
ref="sidebarCloseButtonRef"
type="button"
@@ -148,29 +243,44 @@ async function handleLogout() {
:active-path="route.path"
@navigate="closeMobileMenu"
/>
<button type="button" class="client-sidebar-logout" @click="handleLogout">
<LogOut :size="18" aria-hidden="true" />
<span>退出登录</span>
</button>
</nav>
<div class="sidebar-help"><strong>需要帮助?</strong><span>联系您的专属客户经理</span></div>
</aside>
<WorkspaceTabs
:inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
/>
<main
class="client-main"
:inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
>
<div class="client-page-heading">
<button
ref="menuButtonRef"
type="button"
aria-label="打开导航"
aria-controls="client-sidebar"
:aria-expanded="mobileMenuOpen"
@click="openMobileMenu"
>
<Menu :size="20" />
</button>
<div><h1>{{ pageTitle }}</h1><p>{{ pageDescription }}</p></div>
</div>
<RouterView />
<nav class="client-breadcrumb" aria-label="页面路径">
<template v-for="(item, index) in breadcrumbs" :key="`${item.title}-${index}`">
<span v-if="index" aria-hidden="true">/</span>
<RouterLink v-if="item.path" :to="item.path">{{ item.title }}</RouterLink>
<strong v-else aria-current="page">{{ item.title }}</strong>
</template>
</nav>
<RouterView v-slot="{ Component, route: viewRoute }">
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
<component
:is="Component"
v-if="viewRoute.meta.keepAlive !== false"
:key="workspace.cacheKey(viewRoute.name)"
/>
</KeepAlive>
<component
:is="Component"
v-if="viewRoute.meta.keepAlive === false"
:key="String(viewRoute.name ?? viewRoute.fullPath)"
/>
</RouterView>
</main>
</div>
</template>
+8 -1
View File
@@ -1,7 +1,14 @@
import { createApp } from 'vue'
import { h } from 'vue'
import 'dayjs/locale/zh-cn'
import { createPinia } from 'pinia'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import './styles/index.css'
createApp(App).use(createPinia()).use(router).mount('#app')
const app = createApp({ render: () => h(ElConfigProvider, { locale: zhCn }, { default: () => h(App) }) })
app.use(createPinia()).use(router).mount('#app')
+16
View File
@@ -0,0 +1,16 @@
import { CircleUserRound } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
export const accountModule = {
key: 'account',
routes: [
{
path: 'account/profile',
name: 'account-profile',
component: () => import('./views/AccountProfileView.vue'),
release: 'active',
meta: { title: '我的信息', description: '维护个人联系方式和登录密码', permission: 'account:profile:view' },
navigation: { key: 'account-profile', icon: CircleUserRound },
},
],
} satisfies ClientModuleManifest
@@ -3,20 +3,26 @@ import test from 'node:test'
import {
getPasswordStrength,
validateEditableAccountProfile,
validateLoginIdentifierChange,
validatePasswordChange,
} from './accountProfile.ts'
test('accepts a complete editable account profile', () => {
assert.deepEqual(validateEditableAccountProfile({
realName: '企业管理员',
phone: '13800006601',
email: 'admin@example.com',
}), [])
})
test('reports invalid profile fields together', () => {
const errors = validateEditableAccountProfile({ realName: '', phone: '1'.repeat(33), email: 'bad' })
assert.equal(errors.length, 3)
const errors = validateEditableAccountProfile({ realName: '', email: 'bad' })
assert.equal(errors.length, 2)
})
test('validates the frozen username, mainland mobile and current-password rules', () => {
assert.deepEqual(validateLoginIdentifierChange({ username: 'Merchant.Admin', phone: '13800006601', currentPassword: 'Current123' }), [])
assert.equal(validateLoginIdentifierChange({ username: '1bad', phone: '10086', currentPassword: '' }).length, 3)
assert.match(validateLoginIdentifierChange({ username: 'Merchant.Admin', phone: '11111111111', currentPassword: 'Current123' }).join('|'), /有效的中国大陆手机号/)
})
test('validates password length, equality and confirmation', () => {
@@ -1,9 +1,14 @@
export interface EditableAccountProfile {
realName: string
phone: string
email: string
}
export interface LoginIdentifierChangePayload {
username: string
phone: string
currentPassword: string
}
export interface PasswordChangePayload {
currentPassword: string
newPassword: string
@@ -11,20 +16,30 @@ export interface PasswordChangePayload {
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const USERNAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/
const MAINLAND_MOBILE_PATTERN = /^1[3-9]\d{9}$/
export function validateEditableAccountProfile(profile: EditableAccountProfile): string[] {
const errors: string[] = []
const realName = profile.realName.trim()
const phone = profile.phone.trim()
const email = profile.email.trim()
if (realName.length < 1 || realName.length > 64) errors.push('姓名需为 1–64 个字符')
if (phone.length > 32) errors.push('联系电话不能超过 32 个字符')
if (email && !EMAIL_PATTERN.test(email)) errors.push('请输入有效的邮箱地址')
return errors
}
export function validateLoginIdentifierChange(payload: LoginIdentifierChangePayload): string[] {
const errors: string[] = []
if (!USERNAME_PATTERN.test(payload.username.trim())) {
errors.push('用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成')
}
if (!MAINLAND_MOBILE_PATTERN.test(payload.phone.trim())) errors.push('请输入有效的中国大陆手机号')
if (!payload.currentPassword) errors.push('请输入当前密码')
return errors
}
export function validatePasswordChange(payload: PasswordChangePayload): string[] {
const errors: string[] = []
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
const routerSource = fs.readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const viewSource = fs.readFileSync(new URL('./AccountProfileView.vue', import.meta.url), 'utf8')
const authSource = fs.readFileSync(new URL('../../auth/store/auth.ts', import.meta.url), 'utf8')
const authApiSource = fs.readFileSync(new URL('../../auth/api/auth.ts', import.meta.url), 'utf8')
const iamApiSource = fs.readFileSync(new URL('../../tenant-management/api/merchantIam.ts', import.meta.url), 'utf8')
test('registers the account profile route and navigation entry', () => {
assert.match(routerSource, /path: 'account\/profile'[\s\S]*?component: \(\) => import\('\.\/views\/AccountProfileView\.vue'\)/)
assert.match(routerSource, /release: 'active'/)
assert.match(routerSource, /permission: 'account:profile:view'/)
assert.match(routerSource, /navigation: \{ key: 'account-profile'/)
})
test('implements profile view, edit, save and cancel states', () => {
assert.match(viewSource, /基本信息/)
assert.doesNotMatch(viewSource, /account-identity/)
assert.doesNotMatch(viewSource, /<h2>基本信息<\/h2>|<h2>修改密码<\/h2>/)
assert.match(viewSource, /function startEditing\(\)/)
assert.match(viewSource, /function cancelEditing\(\)/)
assert.match(viewSource, /async function saveProfile\(\)/)
assert.match(viewSource, /请在“登录标识”中修改/)
assert.match(viewSource, /<dt>登录手机号<\/dt><dd>\{\{ auth\.profile\.phone/)
assert.doesNotMatch(viewSource, /<dt>联系电话<\/dt><dd>\{\{ auth\.profile\.phone/)
assert.match(viewSource, /企业归属由平台审核关系确定/)
assert.match(authSource, /async function savePersonalProfile\(/)
assert.match(viewSource, /await auth\.savePersonalProfile/)
})
test('changes username and mobile with current-password verification then clears every local session', () => {
assert.match(viewSource, /name="identifiers"/)
assert.match(viewSource, /validateLoginIdentifierChange/)
assert.match(viewSource, /await updateMerchantLoginIdentifiers/)
assert.match(iamApiSource, /url: '\/merchant\/v1\/iam\/profile\/login-identifiers'/)
assert.match(iamApiSource, /data: payload/)
assert.match(viewSource, /error\.code === 'A0409'/)
assert.match(viewSource, /error\.code === 'A0401'/)
assert.match(viewSource, /auth\.clearSession\(\)/)
assert.match(viewSource, /reason: 'identifiers-updated'/)
assert.match(viewSource, /label="登录手机号" prop="phone"/)
assert.match(viewSource, /全部登录会话都会立即退出/)
assert.doesNotMatch(viewSource, /auth\.logout\(\)[\s\S]*identifiers-updated/)
})
test('implements password validation, strength feedback and double-submit protection', () => {
assert.match(viewSource, /async function changePassword\(\)/)
assert.match(viewSource, /validatePasswordChange/)
assert.match(viewSource, /callback\(new Error\('请输入新密码'\)\)/)
assert.match(viewSource, /getPasswordStrength/)
assert.match(viewSource, /passwordStrengthState/)
assert.match(viewSource, /if \(passwordSaving\.value\) return/)
assert.match(viewSource, /await changeCurrentPassword/)
assert.match(authApiSource, /method: 'PUT', url: '\/auth\/password'/)
assert.match(viewSource, /安全记录已更新/)
assert.match(viewSource, /await router\.replace\('\/login'\)/)
assert.doesNotMatch(viewSource, /DEMO_CURRENT_PASSWORD|静态演示/)
})
test('provides explicit responsive account layouts', () => {
assert.match(viewSource, /@media \(max-width: 820px\)/)
assert.match(viewSource, /@media \(max-width: 620px\)/)
assert.match(viewSource, /\.account-info-grid, \.account-form-grid \{ grid-template-columns: 1fr; \}/)
assert.match(viewSource, /min-height: 44px/)
})
@@ -4,7 +4,6 @@ import { useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import {
Building2,
CheckCircle2,
KeyRound,
Pencil,
@@ -13,16 +12,19 @@ import {
UserRound,
X,
} from '@lucide/vue'
import { useAuthStore } from '@/stores/auth'
import { changeCurrentPassword } from '@/api/auth'
import { changeCurrentPassword, useAuthStore } from '@/modules/auth/public'
import { ApiError } from '@/api/http'
import { updateMerchantLoginIdentifiers } from '@/modules/tenant-management/public'
import {
getPasswordStrength,
validateEditableAccountProfile,
validateLoginIdentifierChange,
validatePasswordChange,
type LoginIdentifierChangePayload,
type PasswordChangePayload,
} from '@/utils/accountProfile'
} from '@/modules/account/utils/accountProfile'
type AccountTab = 'profile' | 'password'
type AccountTab = 'profile' | 'identifiers' | 'password'
interface ProfileForm {
username: string
@@ -37,9 +39,12 @@ const router = useRouter()
const activeTab = ref<AccountTab>('profile')
const editing = ref(false)
const profileSaving = ref(false)
const identifiersSaving = ref(false)
const identifiersError = ref('')
const passwordSaving = ref(false)
const passwordChangedAt = ref('')
const profileFormRef = ref<FormInstance>()
const identifiersFormRef = ref<FormInstance>()
const passwordFormRef = ref<FormInstance>()
const profileForm = reactive<ProfileForm>({ ...auth.profile })
@@ -48,20 +53,38 @@ const passwordForm = reactive<PasswordChangePayload>({
newPassword: '',
confirmPassword: '',
})
const identifiersForm = reactive<LoginIdentifierChangePayload>({
username: auth.profile.username,
phone: auth.profile.phone,
currentPassword: '',
})
const profileRules: FormRules<ProfileForm> = {
realName: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{ min: 1, max: 64, message: '姓名需为 1–64 个字符', trigger: 'blur' },
],
phone: [
{ max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' },
],
email: [
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
],
}
const identifierRules: FormRules<LoginIdentifierChangePayload> = {
username: [{
required: true,
pattern: /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/,
message: '用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成',
trigger: 'blur',
}],
phone: [{
required: true,
pattern: /^1[3-9]\d{9}$/,
message: '请输入有效的中国大陆手机号',
trigger: 'blur',
}],
currentPassword: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
}
const passwordRules: FormRules<PasswordChangePayload> = {
currentPassword: [{
required: true,
@@ -74,6 +97,10 @@ const passwordRules: FormRules<PasswordChangePayload> = {
newPassword: [{
required: true,
validator: (_rule, value: string, callback) => {
if (!value) {
callback(new Error('请输入新密码'))
return
}
const errors = validatePasswordChange({
currentPassword: passwordForm.currentPassword,
newPassword: value,
@@ -127,7 +154,6 @@ async function saveProfile() {
try {
await auth.savePersonalProfile({
realName: profileForm.realName.trim(),
phone: profileForm.phone.trim(),
email: profileForm.email.trim(),
})
syncProfileForm()
@@ -140,6 +166,42 @@ async function saveProfile() {
}
}
function resetIdentifiersForm() {
Object.assign(identifiersForm, {
username: auth.profile.username,
phone: auth.profile.phone,
currentPassword: '',
})
identifiersError.value = ''
identifiersFormRef.value?.clearValidate()
}
async function changeLoginIdentifiers() {
if (identifiersSaving.value) return
identifiersError.value = ''
const valid = await identifiersFormRef.value?.validate().catch(() => false)
if (!valid || validateLoginIdentifierChange(identifiersForm).length > 0) return
identifiersSaving.value = true
const username = identifiersForm.username.trim()
try {
await updateMerchantLoginIdentifiers({
username,
phone: identifiersForm.phone.trim(),
currentPassword: identifiersForm.currentPassword,
})
auth.clearSession()
await router.replace({ path: '/login', query: { account: username, reason: 'identifiers-updated' } })
} catch (error) {
identifiersForm.currentPassword = ''
if (error instanceof ApiError && error.code === 'A0409') identifiersError.value = '用户名或登录手机号已被使用,请更换后重试'
else if (error instanceof ApiError && error.code === 'A0401') identifiersError.value = '当前密码错误'
else if (error instanceof ApiError && error.code === 'A0403') identifiersError.value = '当前会话不允许修改登录标识,请退出代登录后重试'
else identifiersError.value = error instanceof Error ? error.message : '登录标识修改失败,请重试'
} finally {
identifiersSaving.value = false
}
}
function resetPasswordForm() {
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
@@ -170,28 +232,14 @@ async function changePassword() {
<template>
<section class="account-page">
<div class="client-card account-identity">
<div class="account-avatar" aria-hidden="true"><UserRound :size="28" /></div>
<div class="account-identity-copy">
<h2>{{ auth.profile.realName }}</h2>
<p>{{ auth.profile.username }}</p>
</div>
<div class="account-context">
<span><Building2 :size="16" />{{ auth.profile.tenantName }}</span>
<span><ShieldCheck :size="16" />{{ auth.user?.roles.join('、') }}</span>
</div>
<el-tag type="success" effect="plain"><CheckCircle2 :size="14" />账号正常</el-tag>
</div>
<div class="client-card account-workspace">
<el-tabs v-model="activeTab" class="account-tabs">
<el-tab-pane name="profile">
<template #label><span class="account-tab-label"><UserRound :size="16" />基本信息</span></template>
<header class="account-section-heading">
<div><h2>基本信息</h2><p>维护当前登录人的联系方式;账号和所属企业由系统身份关系确定。</p></div>
<el-button v-if="!editing" type="primary" @click="startEditing"><Pencil :size="16" />编辑信息</el-button>
</header>
<div v-if="!editing" class="account-actions">
<el-button type="primary" @click="startEditing"><Pencil :size="16" />编辑信息</el-button>
</div>
<el-form
v-if="editing"
@@ -202,12 +250,9 @@ async function changePassword() {
class="account-form-grid"
@submit.prevent="saveProfile"
>
<el-form-item label="用户名">
<el-input v-model="profileForm.username" disabled />
<span class="field-help">账号标识暂不支持在客户端修改</span>
</el-form-item>
<el-form-item label="姓名" prop="realName"><el-input v-model="profileForm.realName" maxlength="64" show-word-limit /></el-form-item>
<el-form-item label="联系电话" prop="phone"><el-input v-model="profileForm.phone" maxlength="32" inputmode="tel" placeholder="选填" /></el-form-item>
<el-form-item label="用户名"><el-input v-model="profileForm.username" disabled /><span class="field-help">请在“登录标识”中修改</span></el-form-item>
<el-form-item label="姓名" prop="realName"><el-input v-model="profileForm.realName" maxlength="64" show-word-limit placeholder="请输入真实姓名" /></el-form-item>
<el-form-item label="登录手机号"><el-input v-model="profileForm.phone" disabled /><span class="field-help">请在“登录标识”中修改</span></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="profileForm.email" maxlength="128" inputmode="email" placeholder="选填" /></el-form-item>
<el-form-item label="所属公司">
<el-input v-model="profileForm.tenantName" disabled />
@@ -222,20 +267,31 @@ async function changePassword() {
<dl v-else class="account-info-grid">
<div><dt>用户名</dt><dd>{{ auth.profile.username }}</dd></div>
<div><dt>姓名</dt><dd>{{ auth.profile.realName }}</dd></div>
<div><dt>联系电话</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div>
<div><dt>登录手机号</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div>
<div><dt>邮箱</dt><dd>{{ auth.profile.email || '未填写' }}</dd></div>
<div><dt>所属公司</dt><dd>{{ auth.profile.tenantName }}</dd></div>
</dl>
</el-tab-pane>
<el-tab-pane name="identifiers">
<template #label><span class="account-tab-label"><ShieldCheck :size="16" />登录标识</span></template>
<div class="identifier-layout">
<el-form ref="identifiersFormRef" :model="identifiersForm" :rules="identifierRules" label-position="top" class="identifier-form" @submit.prevent="changeLoginIdentifiers">
<el-alert title="修改成功后,国内与海外的全部登录会话都会立即退出,需要使用新用户名或登录手机号重新登录。" type="warning" :closable="false" show-icon />
<el-form-item label="用户名" prop="username"><el-input v-model="identifiersForm.username" maxlength="64" autocomplete="username" placeholder="请输入新用户名" /></el-form-item>
<el-form-item label="登录手机号" prop="phone"><el-input v-model="identifiersForm.phone" maxlength="11" inputmode="tel" autocomplete="tel" placeholder="请输入11位中国大陆手机号" /></el-form-item>
<el-form-item label="当前密码" prop="currentPassword"><el-input v-model="identifiersForm.currentPassword" type="password" show-password maxlength="72" autocomplete="current-password" placeholder="用于确认是本人操作" /></el-form-item>
<el-alert v-if="identifiersError" :title="identifiersError" type="error" :closable="false" show-icon />
<div class="identifier-actions"><el-button :disabled="identifiersSaving" @click="resetIdentifiersForm">重置</el-button><el-button type="primary" native-type="submit" :loading="identifiersSaving"><Save :size="16" />保存并重新登录</el-button></div>
</el-form>
</div>
</el-tab-pane>
<el-tab-pane name="password">
<template #label><span class="account-tab-label"><KeyRound :size="16" />修改密码</span></template>
<div class="password-layout">
<div>
<header class="account-section-heading password-heading">
<div><h2>修改密码</h2><p>输入当前密码并设置 8–64 位、同时包含字母和数字的新密码。</p></div>
</header>
<el-form
ref="passwordFormRef"
:model="passwordForm"
@@ -282,22 +338,11 @@ async function changePassword() {
<style scoped>
.account-page { display: flex; flex-direction: column; gap: 14px; }
.account-identity { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 18px 22px; }
.account-avatar { display: grid; width: 52px; height: 52px; flex: 0 0 52px; place-items: center; border-radius: 14px; background: #e7f3f1; color: var(--primary); }
.account-identity-copy { min-width: 140px; }
.account-identity h2 { margin: 0 0 4px; color: var(--navy); font-size: 18px; }
.account-identity p { margin: 0; color: var(--muted); font-size: 12px; }
.account-context { display: flex; min-width: 0; flex: 1; gap: 22px; color: #52616b; font-size: 12px; }
.account-context span { display: flex; min-width: 0; align-items: center; gap: 7px; overflow-wrap: anywhere; }
.account-identity :deep(.el-tag) { display: flex; align-items: center; gap: 5px; }
.account-workspace { min-height: 520px; padding: 0 24px 28px; }
.account-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.account-tabs :deep(.el-tabs__item) { min-height: 52px; }
.account-tab-label { display: inline-flex; align-items: center; gap: 7px; }
.account-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 24px 0 20px; }
.account-section-heading h2 { margin: 0 0 6px; color: var(--navy); font-size: 18px; }
.account-section-heading p { max-width: 620px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
.account-actions { display: flex; gap: 8px; }
.account-actions { display: flex; justify-content: flex-end; gap: 8px; margin: 20px 0; }
.account-info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.account-info-grid div { min-width: 0; min-height: 94px; padding: 18px 20px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.account-info-grid dt { color: var(--muted); font-size: 11px; }
@@ -306,45 +351,46 @@ async function changePassword() {
.account-form-grid :deep(.el-form-item) { align-content: start; margin-bottom: 21px; }
.account-form-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.field-help { margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
.password-layout { display: grid; grid-template-columns: minmax(320px, 520px) minmax(240px, 1fr); align-items: start; gap: clamp(36px, 7vw, 90px); }
.password-heading { padding-bottom: 18px; }
.identifier-layout { max-width: 620px; padding-top: 22px; }
.identifier-form { display: grid; gap: 2px; }
.identifier-form > :deep(.el-alert) { margin-bottom: 16px; }
.identifier-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; padding-top: 18px; border-top: 1px solid var(--border); }
.password-layout { display: grid; grid-template-columns: minmax(0, 440px) minmax(260px, 1fr); align-items: start; max-width: 980px; gap: clamp(32px, 6vw, 72px); padding-top: 22px; }
.password-form { max-width: 440px; }
.password-form :deep(.el-form-item) { margin-bottom: 20px; }
.password-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.password-form :deep(.el-form-item) { margin-bottom: 18px; }
.password-form :deep(.el-form-item__error) { padding-top: 5px; line-height: 1.45; white-space: normal; }
.password-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--border); }
.password-result { display: flex; align-items: flex-start; gap: 9px; margin-top: 18px; padding: 14px; background: #edf6f4; color: var(--primary); }
.password-result strong { color: var(--navy); font-size: 12px; }
.password-result p { margin: 3px 0 0; color: #526b67; font-size: 11px; line-height: 1.6; }
.password-strength { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; color: var(--muted); }
.password-strength { display: grid; width: 100%; min-height: 18px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; margin-top: 9px; color: var(--muted); }
.password-strength > span { display: grid; flex: 1; grid-template-columns: repeat(3, 1fr); gap: 4px; }
.password-strength i { height: 3px; background: #dce3e6; }
.password-strength[data-strength="weak"] i:first-child { background: #c2413b; }
.password-strength[data-strength="medium"] i:nth-child(-n+2) { background: #c47913; }
.password-strength[data-strength="strong"] i { background: var(--primary); }
.password-strength small { white-space: nowrap; font-size: 10px; }
.security-note { display: grid; grid-template-columns: auto 1fr; gap: 10px 12px; margin-top: 24px; padding: 22px; background: #f2f7f6; color: var(--primary); }
.security-note { display: grid; grid-template-columns: auto 1fr; gap: 10px 12px; margin-top: 0; padding: 22px; background: #f2f7f6; color: var(--primary); }
.security-note strong { color: var(--navy); font-size: 13px; }
.security-note p { margin: 5px 0 0; color: #526b67; font-size: 11px; line-height: 1.7; }
.security-note > p { grid-column: 1 / -1; margin-top: 8px; padding-top: 14px; border-top: 1px solid #d7e5e2; }
.security-note code { color: var(--navy); font-weight: 700; }
@media (max-width: 820px) {
.account-context { align-items: flex-start; flex-direction: column; gap: 7px; }
.password-layout { grid-template-columns: 1fr; gap: 14px; }
.password-layout { grid-template-columns: 1fr; gap: 24px; padding-top: 18px; }
.security-note { margin-top: 0; }
}
@media (max-width: 620px) {
.account-identity { align-items: flex-start; flex-wrap: wrap; }
.account-identity-copy { flex: 1; }
.account-context { order: 3; width: 100%; flex-basis: 100%; padding-top: 12px; border-top: 1px solid var(--border); }
.account-workspace { min-height: 0; padding: 0 16px 22px; }
.account-section-heading { align-items: stretch; flex-direction: column; gap: 14px; padding-top: 20px; }
.account-section-heading > .el-button { width: 100%; }
.account-actions { width: 100%; margin: 16px 0; }
.account-actions .el-button { width: 100%; min-height: 44px; }
.account-form-actions { width: 100%; }
.account-form-actions .el-button { min-height: 44px; flex: 1; }
.account-info-grid, .account-form-grid { grid-template-columns: 1fr; }
.account-info-grid div { min-height: 82px; }
.password-actions { width: 100%; }
.password-actions .el-button { min-height: 44px; flex: 1; }
.identifier-actions .el-button { min-height: 44px; flex: 1; }
}
</style>
@@ -6,8 +6,8 @@ export interface RegisterMerchantRequest {
password: string
displayName: string
companyName: string
legalPerson: string
legalPhone: string
legalPerson?: string
phone: string
contactEmail?: string
countryCode?: string
address?: string
@@ -21,8 +21,14 @@ export interface RegisterMerchantResult {
nextStep: string
}
export function loginMerchant(username: string, password: string) {
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { username, password } })
export interface ImpersonationExchangeResult {
tokenType: string
accessToken: string
expiresIn: number
}
export function loginMerchant(loginId: string, password: string) {
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { loginId, password } })
}
export function registerMerchant(payload: RegisterMerchantRequest) {
@@ -36,3 +42,7 @@ export function logoutMerchant(refreshToken: string) {
export function changeCurrentPassword(oldPassword: string, newPassword: string) {
return apiRequest<void>({ method: 'PUT', url: '/auth/password', data: { oldPassword, newPassword } })
}
export function exchangeImpersonationCode(code: string) {
return apiRequest<ImpersonationExchangeResult>({ method: 'POST', url: '/auth/impersonations/exchange', data: { code } })
}
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { CheckCircle2 } from '@lucide/vue'
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
import authOcean from '@/assets/brand/auth-ocean.jpg'
defineProps<{
switchText: string
switchLabel: string
switchTo: string
visualTitle: string
visualDescription: string
features: string[]
variant?: 'login' | 'register'
}>()
</script>
<template>
<div class="auth-page">
<header class="auth-header">
<RouterLink to="/login" class="auth-brand" aria-label="柏悦物流客户工作台">
<img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" />
</RouterLink>
<div class="auth-header-action">
<span>{{ switchText }}</span>
<RouterLink :to="switchTo">{{ switchLabel }}</RouterLink>
</div>
</header>
<main class="auth-stage">
<div class="auth-panel" :class="`auth-panel--${variant || 'login'}`">
<section class="auth-visual" aria-label="柏悦物流服务介绍">
<img :src="authOcean" alt="集装箱码头与跨境物流作业场景" />
<div class="auth-visual-shade" aria-hidden="true"></div>
<div class="auth-visual-content">
<div>
<h1>{{ visualTitle }}</h1>
<p>{{ visualDescription }}</p>
</div>
<ul>
<li v-for="feature in features" :key="feature">
<CheckCircle2 :size="19" aria-hidden="true" />
<span>{{ feature }}</span>
</li>
</ul>
</div>
</section>
<section class="auth-form-panel">
<slot />
</section>
</div>
</main>
</div>
</template>
+222
View File
@@ -0,0 +1,222 @@
export type LegalDocumentKey = 'terms' | 'privacy'
export interface LegalSection {
title: string
paragraphs?: string[]
items?: string[]
}
export interface LegalDocument {
title: string
version: string
updatedAt: string
effectiveAt: string
introduction: string[]
sections: LegalSection[]
}
const terms: LegalDocument = {
title: '柏悦物流用户服务协议',
version: '1.0',
updatedAt: '2026年8月3日',
effectiveAt: '2026年8月3日',
introduction: [
'欢迎使用柏悦物流客户工作台。本协议是您与柏悦物流之间关于注册、登录和使用客户工作台及相关物流服务所订立的协议。',
'请您在注册前完整阅读并充分理解本协议,尤其是有关责任范围、违约处理、争议解决等可能对您权益产生重大影响的条款。您勾选同意、完成注册或实际使用服务,即表示您已阅读、理解并同意接受本协议。',
],
sections: [
{
title: '一、协议范围与定义',
items: [
'“柏悦物流”或“我们”是指运营柏悦物流客户工作台并在具体订单、报价单、运输单证或服务合同中载明的实际服务主体。',
'“平台”是指柏悦物流向企业客户提供的客户工作台及相关网页、系统和功能。',
'“用户”或“您”包括注册企业、企业管理员及经企业授权使用平台的成员。企业管理员在平台内进行的授权、下单和管理行为,视为企业行为。',
'本协议规范平台账号及通用服务。具体物流服务还可能适用报价单、订单、运单、委托书、服务规则或双方另行签署的合同;约定不一致时,以双方就具体服务作出的特别约定为准,但法律另有强制性规定的除外。',
],
},
{
title: '二、账号注册与管理',
items: [
'您应具备签订和履行本协议所需的民事权利能力及行为能力,并保证已获得所在企业的合法授权。',
'您应提供真实、准确、完整、有效的企业及联系人信息,并在信息发生变化时及时更新。柏悦物流有权依法或基于风险控制需要核验营业执照、法定代表人、联系人及其他资质信息。',
'您应妥善保管账号、密码及验证信息,不得出租、出借、转让或与无权人员共享。通过您账号完成的操作原则上视为您的行为;发现账号被盗用、冒用或存在安全风险时,应立即修改密码并联系平台客服。',
'企业管理员应根据成员实际职责分配权限,并及时停用离职、调岗或不再需要访问平台的成员账号。因企业内部授权管理不当造成的损失,由企业依法承担相应责任。',
],
},
{
title: '三、平台及物流服务',
items: [
'平台可用于维护企业资料和成员权限、创建及提交物流订单、查看订单和履约信息,以及使用柏悦物流后续开放的其他功能。具体功能以平台实际提供为准。',
'您提交的订单为物流服务需求。服务范围、线路、时效、价格、计费重量、附加费用、清关要求及赔偿规则,以柏悦物流确认的订单、报价、合同和适用服务规则为准。平台展示的预计时效不构成绝对到达时间承诺。',
'柏悦物流可自行履行服务,也可根据履约需要委托仓库、承运人、报关服务商、目的地服务商及其他合格合作方完成全部或部分服务。',
'因实际重量、体积、品类、地址、海关查验、税费或其他订单信息变化导致费用调整的,柏悦物流将依据适用计费规则与实际服务情况结算。',
],
},
{
title: '四、您的陈述、保证与义务',
items: [
'您应如实、完整填写发货人、收货人、货物名称、数量、重量、尺寸、价值、原产地、用途及监管属性等信息,并按要求提供真实有效的发票、装箱单、合同、授权文件和清关资料。',
'您保证对交付运输的货物享有合法处分权,货物来源和用途合法,包装符合运输、仓储和目的地国家或地区的要求。',
'您不得通过平台寄递或安排运输法律法规、国际公约、海关、航空安全规则或柏悦物流服务规则禁止或限制且未如实申报的物品,包括但不限于危险品、违禁品、侵权商品及来源不明物品。',
'您向平台提供联系人、发货人、收货人及其他第三方的个人信息前,应确保具有合法处理依据,并已向相关人员履行必要告知义务。您不得提供与履约无关的信息。',
'您不得以任何方式干扰平台运行、绕过权限控制、探测系统漏洞、传播恶意程序、批量抓取数据,或利用平台从事违法、侵权、欺诈及损害他人合法权益的活动。',
],
},
{
title: '五、费用与结算',
items: [
'您应按双方确认的价格、账期和方式支付运费、仓储费、操作费、关税税费、附加费及其他已实际发生的费用。',
'如您对账单或费用有异议,应在双方约定期限内提出并提供相关依据;无争议部分仍应按期支付。逾期付款的责任以双方合同、订单或适用规则为准。',
'第三方收取的关税、税款、查验费、港杂费及目的地费用,除双方另有书面约定外,由依法或依交易安排应承担该费用的一方支付。',
],
},
{
title: '六、知识产权与平台数据',
items: [
'平台的软件、页面设计、商标、标识、文字、图片及相关技术成果的知识产权归柏悦物流或合法权利人所有。未经书面许可,您不得复制、改编、反向工程、出售或用于本协议目的之外的用途。',
'您保留对依法上传内容的相应权利,并授权柏悦物流在提供服务、履行法定义务、处理争议和保障安全所必要的范围内使用相关内容。',
],
},
{
title: '七、服务变更、中断与终止',
items: [
'柏悦物流可基于业务安排、系统维护、安全风险或法律法规要求调整平台功能,并通过平台公告、站内消息或其他合理方式通知您。',
'因维护升级、网络故障、第三方服务异常、自然灾害、政府行为、战争、罢工、公共卫生事件或其他不可合理控制的原因造成服务中断或延迟的,柏悦物流将尽合理努力恢复服务并降低影响。',
'如您严重违反法律法规、本协议或服务规则,提供虚假资料,寄递违禁品,危害平台安全,或逾期履行主要付款义务,柏悦物流可视情采取限制功能、暂停服务、终止账号等措施,并保留依法追究责任的权利。',
'账号终止不影响终止前已产生的订单、费用、保密义务、赔偿责任及争议解决条款。',
],
},
{
title: '八、责任范围',
items: [
'柏悦物流将按照法律法规、双方合同和行业合理标准提供服务。因柏悦物流原因造成货物灭失、损毁或延误的,责任范围依适用法律、具体服务合同、运单条款及保价或保险约定确定。依法不得限制或免除的责任,不受本条限制。',
'因您提供的信息或单证不真实、不准确、不完整,包装不当,货物自身性质或合理损耗,收发货方原因,海关及其他主管机关行为,或您违反本协议导致的损失及第三方索赔,由责任方依法承担。',
'平台中的查询结果、预计时效和节点信息可能因承运人或其他合作方回传存在合理延迟,最终履约状态以实际运输和签收记录为准。',
],
},
{
title: '九、协议更新与通知',
items: [
'柏悦物流可根据法律法规、监管要求或业务变化更新本协议。涉及您重大权益的更新,我们将通过平台显著提示、站内消息或其他合理方式通知,并依法在必要时重新取得您的同意。',
'通知发送至您在平台预留的联系方式、账号消息中心或平台公告区域,即视为已采用合理方式送达。请及时维护有效联系方式并关注相关通知。',
],
},
{
title: '十、适用法律与争议解决',
paragraphs: [
'本协议的订立、履行、解释及争议解决适用中华人民共和国大陆地区法律。因本协议产生的争议,双方应先友好协商;协商不成的,按双方另行签署的合同约定处理。没有特别约定的,可向依法有管辖权的人民法院提起诉讼。',
],
},
{
title: '十一、联系我们',
paragraphs: [
'如您对本协议或服务有疑问、意见或投诉,可通过平台公示的客服渠道联系柏悦物流。我们会在核验您的身份和请求后尽快处理。',
],
},
],
}
const privacy: LegalDocument = {
title: '柏悦物流隐私政策',
version: '1.0',
updatedAt: '2026年8月3日',
effectiveAt: '2026年8月3日',
introduction: [
'柏悦物流重视对个人信息的保护。本政策说明我们在运营客户工作台、提供物流及相关服务时,如何收集、使用、存储、提供和保护个人信息,以及您如何行使个人信息权利。',
'本平台主要面向企业客户。企业向我们提供其员工、联系人、发货人、收货人或其他个人的信息时,应确保具有合法处理依据并已履行必要的告知义务。',
],
sections: [
{
title: '一、适用范围',
items: [
'本政策适用于柏悦物流客户工作台及通过该平台提供的企业账号管理、头程订单、物流履约、客户服务等功能。',
'具体服务由合作方独立提供并由其自行决定个人信息处理目的和方式的,相关合作方的隐私政策同时适用。我们会在合理可行范围内提示您关注。',
'本政策中的“柏悦物流”或“我们”是指平台及具体订单、合同或平台公示信息中载明的实际运营和服务主体。',
],
},
{
title: '二、我们如何收集和使用个人信息',
items: [
'注册与登录:为创建和管理企业账号、验证身份及保障账号安全,我们会处理登录用户名、密码的安全校验信息、企业名称、联系人姓名、法定代表人姓名、手机号、联系邮箱、国家或地区和企业地址。',
'企业认证与权限管理:为核验企业资质、开通下单资格和管理企业成员,我们可能处理营业执照、统一社会信用代码、法定代表人及联系人信息、企业资质文件、成员姓名、联系方式、角色和权限信息。',
'物流下单与履约:为接收订单并完成收货、仓储、运输、报关清关、派送、查询和售后,我们可能处理订单号,发货人和收货人的姓名、公司、电话、邮箱、地址,货物名称、数量、重量、尺寸、价值、原产地、品牌、包装及申报信息,以及发票、装箱单、合同、委托书和依法需要的清关资料。部分清关资料可能包含身份证件等敏感个人信息,我们会在具体业务需要时另行提示,并在取得相应授权或具备其他合法依据后处理。',
'费用与对账:为计费、结算、开票和处理争议,我们可能处理订单费用、账单、付款状态、发票抬头及税务信息。柏悦当前平台功能不要求您直接提交银行卡密码或支付验证码。',
'客户服务:当您咨询、投诉或申请售后时,我们可能处理您的账号及联系方式、咨询内容、沟通记录、相关订单和您主动提交的证明材料,以核实身份、定位问题和处理请求。',
'安全与运维:为保障账号、交易和系统安全,排查故障并防范欺诈、攻击和越权访问,我们可能自动记录登录时间、操作日志、IP 地址、浏览器类型、设备与系统基础信息、异常和错误日志。',
],
},
{
title: '三、处理个人信息的依据与原则',
items: [
'我们遵循合法、正当、必要、诚信和目的明确原则,仅处理实现相关功能所必需的信息。',
'我们会根据具体情形,基于取得您的同意、订立或履行合同所必需、履行法定义务、应对突发公共卫生事件或紧急保护自然人人身财产安全,以及法律允许的其他依据处理个人信息。',
'如某项信息并非基本功能所必需,您可以选择不提供;但这可能导致对应的可选功能无法使用。对于履行合同或法定义务所必需的信息,拒绝提供可能导致我们无法提供相关服务。',
],
},
{
title: '四、Cookie 与同类技术',
paragraphs: [
'平台可能使用 Cookie、本地存储或类似技术维持登录状态、记住必要设置、保障安全并改善系统稳定性。我们不会将这些技术用于与平台服务无关的目的。您可通过浏览器设置管理 Cookie,但禁用必要 Cookie 可能导致登录或部分功能无法正常使用。',
],
},
{
title: '五、委托处理、共享、转让与公开披露',
items: [
'为完成物流服务,我们可能在必要范围内向仓库、承运人、报关或清关服务商、目的地派送方、信息技术服务商及其他履约合作方提供订单和联系人信息。我们会通过合同等方式要求其按照约定目的和适用法律处理信息。',
'为履行法定义务或配合有权机关依法提出的要求,我们可能向海关、交通、邮政、税务、公安、司法或其他主管机关提供必要信息。',
'除取得单独同意或法律法规另有规定外,我们不会向其他个人信息处理者提供您的个人信息,不会公开披露您的个人信息。发生合并、分立、资产转让、破产清算等导致个人信息转移的,我们将依法告知接收方信息,并要求其继续受本政策约束;处理目的或方式发生变化的,由接收方依法重新取得同意。',
],
},
{
title: '六、跨境物流中的信息出境',
paragraphs: [
'当您委托我们提供跨境物流、境外仓储、清关或派送服务时,为履行订单所必需,我们可能需要向目的地国家或地区的关联服务主体、承运人、仓库、清关机构或派送方提供收发货人信息、地址、联系方式、货物和清关资料。我们将依据适用法律履行个人信息出境相关义务,并采取合同、权限控制、最小化提供等保护措施。您向我们提供第三方信息前,应向相关个人告知可能发生的跨境传输并确保具有合法依据。',
],
},
{
title: '七、个人信息的存储',
items: [
'我们原则上将在中华人民共和国境内运营过程中收集和产生的个人信息存储在境内;因跨境履约确需向境外提供的,按本政策第六条处理。',
'我们仅在实现处理目的所需的最短期限及法律法规要求的期限内保存信息。保存期限届满后,我们会依法删除或匿名化处理;法律规定的保存期限尚未届满,或删除从技术上难以实现的,我们将停止除存储和必要安全保护之外的处理。',
'账号注销或企业终止合作后,部分订单、运单、结算、审计及争议处理信息仍可能按照法律规定或处理争议所需期限保留。',
],
},
{
title: '八、我们如何保护个人信息',
items: [
'我们会根据个人信息的类型和风险,采取与业务相适应的访问控制、身份鉴别、权限隔离、日志审计、备份和安全管理等措施,尽力防止信息遭到未经授权的访问、泄露、篡改、丢失或滥用。',
'我们限制员工和合作方仅在履行职责所需范围内访问信息,并通过制度、培训和合同要求其承担保密和安全义务。',
'如发生或可能发生个人信息安全事件,我们将依法采取补救措施,并按规定向有关主管部门报告及向受影响个人告知。',
],
},
{
title: '九、您的个人信息权利',
items: [
'在适用法律规定的范围内,您有权查阅、复制、更正、补充或删除您的个人信息,并有权撤回基于同意作出的授权、请求注销账号、要求解释个人信息处理规则。',
'部分企业资料和成员信息可由有权限的用户在平台内查看或更新。对平台暂未提供自助入口的请求,您可通过平台公示的客服渠道提出。',
'为保障信息安全,我们可能要求您验证身份并说明具体请求。我们将在法律规定期限内答复;对于法律规定的例外情形,我们可能无法响应,并会向您说明原因。',
'撤回同意不影响撤回前基于同意已进行的处理。删除信息或注销账号可能导致相关功能不可用,但不影响依法应继续保存的信息及您在注销前应承担的责任。',
],
},
{
title: '十、未成年人保护',
paragraphs: [
'本平台面向具备相应授权的企业用户,不以未满十四周岁的未成年人为服务对象。如我们发现未经监护人同意收集了儿童个人信息,将依法尽快删除或采取其他必要措施。请勿通过平台提交与物流履约无关的未成年人信息。',
],
},
{
title: '十一、政策更新',
paragraphs: [
'我们可能根据法律法规、监管要求、服务或技术变化更新本政策。对于可能对您权益产生重大影响的变更,我们会通过平台显著提示、站内消息或其他合理方式通知,并在法律要求时重新取得您的同意。',
],
},
{
title: '十二、联系我们',
paragraphs: [
'如您对本政策、个人信息处理活动或权利请求有疑问、意见或投诉,可通过平台公示的客服渠道联系柏悦物流。我们将在核验您的身份后,按照适用法律规定的期限处理和答复。',
],
},
],
}
export const legalDocuments: Record<LegalDocumentKey, LegalDocument> = { terms, privacy }
+2
View File
@@ -0,0 +1,2 @@
export { changeCurrentPassword } from './api/auth'
export { useAuthStore } from './store/auth'
+8
View File
@@ -0,0 +1,8 @@
import type { RouteRecordRaw } from 'vue-router'
export const authRoutes = [
{ path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { title: '登录' } },
{ path: '/register', name: 'register', component: () => import('./views/RegisterView.vue'), meta: { title: '企业注册' } },
{ path: '/legal/terms', name: 'terms', component: () => import('./views/LegalDocumentView.vue'), props: { documentKey: 'terms' }, meta: { title: '用户服务协议' } },
{ path: '/legal/privacy', name: 'privacy', component: () => import('./views/LegalDocumentView.vue'), props: { documentKey: 'privacy' }, meta: { title: '隐私政策' } },
] satisfies RouteRecordRaw[]
@@ -1,35 +1,18 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { CurrentUser } from '@chongchong/shared'
import { loginMerchant, logoutMerchant } from '@/api/auth'
import { getMerchantPermissionResources, getMerchantPersonalProfile, getTenantProfile, updateMerchantPersonalProfile, type MerchantPermissionResource } from '@/api/merchantIam'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair } from '@/api/session'
import { TENANT_PERMISSION_GROUPS } from '@/stores/tenantAdministration'
import { decodeMerchantToken, type MerchantJwtPayload } from '@/utils/jwt'
const frontendPermissionCodes = new Set(TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code)))
const backendPermissionMapping: Readonly<Record<string, readonly string[]>> = {
'merchant:profile:view': ['company:profile:view'],
'merchant:profile:edit': ['company:profile:edit'],
'merchant:qualification:upload': ['company:qualification:upload'],
'merchant:member:list': ['company:member:list'],
'merchant:member:create': ['company:member:write'],
'merchant:member:edit': ['company:member:write'],
'merchant:member:grant-role': ['company:member:write'],
'merchant:member:status': ['company:member:write'],
'merchant:member:reset-password': ['company:member:reset-password'],
'merchant:role:list': ['company:role:list', 'company:permission:list'],
'merchant:role:create': ['company:role:write'],
'merchant:role:edit': ['company:role:write'],
'merchant:role:status': ['company:role:write'],
'merchant:role:delete': ['company:role:write'],
'merchant:role:grant': ['company:permission:list', 'company:permission:assign'],
'merchant:order:list': ['order:first-leg:list'],
'merchant:order:view': ['order:first-leg:list'],
'merchant:order:create': ['order:first-leg:create'],
'merchant:order:edit': ['order:first-leg:edit'],
'merchant:order:submit': ['order:first-leg:submit'],
}
import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modules/auth/api/auth'
import { ApiError, refreshAuthenticatedSession } from '@/api/http'
import {
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
hasMappedMerchantPermission,
getCurrentMerchantPermissionCodes,
getMerchantPersonalProfile,
getTenantProfile,
updateMerchantPersonalProfile,
} from '@/modules/tenant-management/public'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistRememberedLoginId, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
import { decodeMerchantToken, MerchantTokenExpiredError, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
export interface ClientAccountProfile {
username: string
@@ -47,19 +30,11 @@ const emptyProfile: ClientAccountProfile = {
tenantName: '',
}
function effectiveBackendPermissions(payload: MerchantJwtPayload, resources?: MerchantPermissionResource[]) {
const jwtPermissions = new Set(payload.permissions ?? [])
if (!resources) return jwtPermissions
const activeResources = new Set(resources.filter((item) => item.status === 'ACTIVE').map((item) => item.permissionCode))
return new Set([...jwtPermissions].filter((permission) => activeResources.has(permission)))
}
function mapPermissions(payload: MerchantJwtPayload, resources?: MerchantPermissionResource[]) {
const backendPermissions = effectiveBackendPermissions(payload, resources)
function mapPermissions(backendPermissionCodes: string[] = []) {
const backendPermissions = new Set(backendPermissionCodes)
const result = new Set<string>(['dashboard:view', 'account:profile:view'])
for (const permission of backendPermissions) {
if (frontendPermissionCodes.has(permission)) result.add(permission)
for (const mapped of backendPermissionMapping[permission] ?? []) result.add(mapped)
for (const [frontendCode] of Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES)) {
if (hasMappedMerchantPermission(frontendCode, backendPermissions)) result.add(frontendCode)
}
return [...result]
}
@@ -70,10 +45,14 @@ function currentUser(payload: MerchantJwtPayload, tenantName?: string): CurrentU
name: payload.username,
account: payload.username,
tenantName: tenantName || `企业租户 ${payload.tenant_id}`,
roles: payload.roles?.length ? [...payload.roles] : ['企业成员'],
roles: [],
}
}
function isExpiredSessionError(error: unknown) {
return error instanceof ApiError && (error.status === 401 || error.code === 'A0401')
}
export const useAuthStore = defineStore('client-auth', () => {
const token = ref(getAccessToken())
const payload = ref<MerchantJwtPayload | null>(null)
@@ -82,14 +61,14 @@ export const useAuthStore = defineStore('client-auth', () => {
const permissionCodes = ref<string[]>([])
const sessionRestored = ref(false)
function applyToken(accessToken: string, resources?: MerchantPermissionResource[]) {
function applyToken(accessToken: string) {
const decoded = decodeMerchantToken(accessToken)
payload.value = decoded
token.value = accessToken
user.value = currentUser(decoded, user.value?.tenantName)
profile.value.username = decoded.username
profile.value.realName = decoded.username
permissionCodes.value = mapPermissions(decoded, resources)
permissionCodes.value = mapPermissions()
}
function clearSession() {
@@ -102,11 +81,30 @@ export const useAuthStore = defineStore('client-auth', () => {
sessionRestored.value = true
}
function applyStoredSession() {
const storedToken = getAccessToken()
if (!storedToken) {
token.value = ''
payload.value = null
user.value = null
permissionCodes.value = []
profile.value = { ...emptyProfile }
sessionRestored.value = true
return
}
try {
applyToken(storedToken)
sessionRestored.value = false
} catch {
clearSession()
}
}
if (token.value) {
try {
applyToken(token.value)
} catch {
clearSession()
} catch (error) {
if (!(error instanceof MerchantTokenExpiredError && getRefreshToken())) clearSession()
}
}
@@ -114,12 +112,14 @@ export const useAuthStore = defineStore('client-auth', () => {
async function loadSessionContext() {
if (!payload.value) return
const [resources, tenant, personalProfile] = await Promise.all([
getMerchantPermissionResources(),
const currentPermissionCodes = await getCurrentMerchantPermissionCodes()
const currentAccessToken = getAccessToken()
if (currentAccessToken && currentAccessToken !== token.value) applyToken(currentAccessToken)
permissionCodes.value = mapPermissions(currentPermissionCodes)
const [tenant, personalProfile] = await Promise.all([
getTenantProfile(),
getMerchantPersonalProfile(),
])
permissionCodes.value = mapPermissions(payload.value, resources)
user.value = currentUser(payload.value, tenant.tenantName)
profile.value = {
...profile.value,
@@ -133,37 +133,78 @@ 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()
} catch (error) {
clearSession()
throw error
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
sessionRestored.value = true
return
}
sessionRestored.value = true
}
async function login(username: string, password: string, remember = false) {
const tokens = await loginMerchant(username, password)
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)
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
permissionCodes.value = mapPermissions(decoded)
permissionCodes.value = mapPermissions()
sessionRestored.value = false
try {
await loadSessionContext()
sessionRestored.value = true
} catch (error) {
clearSession()
throw error
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
sessionRestored.value = true
}
}
async function acceptImpersonationCode(code: string) {
const exchanged = await exchangeImpersonationCode(code)
const decoded = decodeMerchantToken(exchanged.accessToken)
persistTemporaryAccessToken(exchanged.accessToken)
token.value = exchanged.accessToken
payload.value = decoded
user.value = currentUser(decoded)
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
permissionCodes.value = mapPermissions()
sessionRestored.value = false
try {
await loadSessionContext()
} catch (error) {
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
}
sessionRestored.value = true
}
async function logout() {
const refreshToken = getRefreshToken()
try {
@@ -182,10 +223,9 @@ export const useAuthStore = defineStore('client-auth', () => {
if (user.value) user.value = { ...user.value, name: next.realName }
}
async function savePersonalProfile(next: Pick<ClientAccountProfile, 'realName' | 'phone' | 'email'>) {
async function savePersonalProfile(next: Pick<ClientAccountProfile, 'realName' | 'email'>) {
const updated = await updateMerchantPersonalProfile({
displayName: next.realName.trim(),
phone: next.phone.trim(),
email: next.email.trim(),
})
updateProfile({
@@ -196,6 +236,9 @@ export const useAuthStore = defineStore('client-auth', () => {
}
window.addEventListener('cc-client-session-expired', clearSession)
window.addEventListener('storage', (event) => {
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
})
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, restoreSession, updateProfile, savePersonalProfile }
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, clearSession, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile }
})
@@ -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')
@@ -13,12 +13,10 @@ const payload = {
tenant_id: 2001,
membership_id: 3001,
app_code: 'MERCHANT',
roles: ['MERCHANT_OWNER'],
permissions: ['merchant:profile:view'],
exp: 1900000000,
}
test('decodes a merchant JWT payload for UI identity and permissions', () => {
test('decodes a merchant JWT payload containing identity without roles or permissions', () => {
assert.deepEqual(decodeMerchantToken(tokenFor(payload)), payload)
})
@@ -29,3 +27,7 @@ test('rejects tokens from the platform application domain', () => {
test('rejects malformed tokens', () => {
assert.throws(() => decodeMerchantToken('not-a-jwt'), /登录凭证格式无效/)
})
test('rejects expired tokens', () => {
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), MerchantTokenExpiredError)
})
@@ -6,11 +6,16 @@ export interface MerchantJwtPayload {
membership_id: number | string
app_code: string
client_type?: string
roles?: string[]
permissions?: string[]
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, '=')
@@ -29,9 +34,11 @@ export function decodeMerchantToken(token: string): MerchantJwtPayload {
const payload = value as Partial<MerchantJwtPayload>
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 MerchantTokenExpiredError()
return payload as MerchantJwtPayload
} catch (error) {
if (error instanceof Error && error.message === '账号不属于当前应用端') throw error
if (error instanceof MerchantTokenExpiredError) throw error
throw new Error('登录凭证无法解析,请重新登录')
}
}
+163
View File
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const loginSource = readFileSync(new URL('./LoginView.vue', import.meta.url), 'utf8')
const registerSource = readFileSync(new URL('./RegisterView.vue', import.meta.url), 'utf8')
const authShellSource = readFileSync(new URL('../components/AuthShell.vue', import.meta.url), 'utf8')
const legalSource = readFileSync(new URL('./LegalDocumentView.vue', import.meta.url), 'utf8')
const legalDocumentsSource = readFileSync(new URL('../legalDocuments.ts', import.meta.url), 'utf8')
const authRoutesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const authStoreSource = readFileSync(new URL('../store/auth.ts', import.meta.url), 'utf8')
const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url), 'utf8')
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', () => {
assert.match(loginSource, /<AuthShell/)
assert.match(registerSource, /<AuthShell/)
assert.match(authShellSource, /auth-ocean\.jpg/)
assert.match(authShellSource, /class="auth-visual"/)
assert.match(stylesSource, /\.auth-panel \{[^}]*grid-template-columns:/s)
assert.match(stylesSource, /@media \(max-width: 960px\)[\s\S]*?\.auth-visual \{ display: none; \}/)
assert.match(stylesSource, /@media \(max-width: 720px\)[\s\S]*?\.register-form-grid \{ grid-template-columns: 1fr; \}/)
})
test('groups the full registration contract without adding out-of-scope sign-in methods', () => {
for (const section of ['账号信息', '企业信息', '联系信息']) assert.match(registerSource, new RegExp(section))
assert.doesNotMatch(loginSource + registerSource, /微信登录|企业微信登录|短信验证码|邮箱验证码|lang-switcher/)
})
test('matches the frozen username-or-mobile login contract', () => {
assert.match(loginSource, /label="用户名或手机号"/)
assert.match(loginSource, /const usernamePattern = \/\^\[A-Za-z\]/)
assert.match(loginSource, /const mainlandMobilePattern = \/\^1\[3-9\]\\d\{9\}\$\//)
assert.match(authApiSource, /data: \{ loginId, password \}/)
assert.doesNotMatch(authApiSource, /data: \{ username, password \}/)
assert.match(loginSource, /typeof route\.query\.account === 'string'/)
assert.doesNotMatch(loginSource, /developmentHint|demo-tip|merchant_admin|联调账号|本地后端/)
assert.match(loginSource, /form\.remember/)
assert.match(loginSource, /忘记密码/)
})
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, /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/)
})
test('implements every backend registration field and real submission', () => {
for (const field of ['username', 'companyName', 'displayName', 'legalPerson', 'phone', 'contactEmail', 'countryCode', 'address', 'password', 'agreement']) {
assert.match(registerSource, new RegExp(`prop="${field}"`))
}
assert.match(registerSource, /label="登录手机号" prop="phone"/)
assert.doesNotMatch(registerSource, /legalPhone/)
assert.match(authApiSource, /phone: string/)
assert.doesNotMatch(authApiSource, /legalPhone/)
assert.match(registerSource, /注册时同时初始化公司联系电话;注册后两者可分别修改,互不联动/)
assert.match(registerSource, /registerMerchant\(/)
assert.match(registerSource, /4–64 位/)
assert.match(registerSource, /8–72 位/)
assert.match(registerSource, /label="法定代表人(选填)" prop="legalPerson"/)
assert.doesNotMatch(registerSource, /label="法定代表人[^\"]*" prop="legalPerson" required/)
assert.doesNotMatch(registerSource, /required: true, message: '请输入法定代表人'/)
assert.match(authApiSource, /legalPerson\?: string/)
})
test('shows an explicit registration entry directly below the login button', () => {
assert.match(loginSource, /<el-button[^>]*class="auth-submit"[\s\S]*?<p class="login-register-entry">还没有企业账号?<RouterLink to="\/register">立即注册<\/RouterLink><\/p>/)
assert.match(stylesSource, /\.login-register-entry \{[^}]*text-align: center;/)
})
test('shows immediate login after registration and separates company review from account activation', () => {
assert.match(registerSource, /企业账号注册成功/)
assert.match(registerSource, /无需等待注册审核/)
assert.match(registerSource, /提交订单前还需完成企业审核/)
assert.match(registerSource, /首次登录需上传营业执照/)
assert.match(registerSource, /法人身份证、货代资质证书和银行开户许可证可选填/)
assert.match(registerSource, /法人身份证及其他资质可选填/)
assert.match(registerSource, /立即登录/)
})
test('opens complete service and privacy documents from registration', () => {
assert.match(registerSource, /to="\/legal\/terms" target="_blank"/)
assert.match(registerSource, /to="\/legal\/privacy" target="_blank"/)
assert.doesNotMatch(registerSource, /showDocument/)
assert.match(authRoutesSource, /path: '\/legal\/terms'/)
assert.match(authRoutesSource, /path: '\/legal\/privacy'/)
assert.match(legalSource, /document\.sections/)
assert.match(legalDocumentsSource, /跨境物流中的信息出境/)
assert.match(legalDocumentsSource, /违禁品/)
})
test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(authApiSource, /\/auth\/login/)
assert.match(authApiSource, /\/auth\/register/)
assert.match(authApiSource, /\/auth\/logout/)
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'/)
assert.match(httpSource, /redirectToLoginAfterSessionExpiry/)
assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError/)
assert.match(httpSource, /const expiredError = new ApiError\(error\.message, 'A0401', 401\)/)
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(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, /代登录失败或授权已失效,请返回管理后台重新发起/)
})
test('treats only body code 200 as success', () => {
assert.match(httpSource, /if \(envelope\.code !== '200'\)/)
assert.doesNotMatch(httpSource, /if \(envelope\.code !== '0'\)/)
})
test('shows the backend credential error on login', () => {
assert.match(httpSource, /new ApiError\(apiErrorMessage\(envelope\.message, fieldErrors\), envelope\.code/)
assert.match(loginSource, /ElMessage\.error\(error instanceof Error \? error\.message : '登录失败,请检查账号后重试'\)/)
assert.doesNotMatch(loginSource, /登录状态已失效,请重新登录/)
})
test('shows a forced re-login notice after login identifiers change', () => {
assert.match(loginSource, /route\.query\.reason === 'identifiers-updated'/)
assert.match(loginSource, /所有会话均已退出,请重新登录/)
})
test('keeps a valid local session during temporary context and refresh outages', () => {
assert.match(authStoreSource, /function isExpiredSessionError/)
assert.match(authStoreSource, /if \(isExpiredSessionError\(error\)\)/)
assert.match(authStoreSource, /sessionRestored\.value = true\s+return/)
assert.match(httpSource, /if \(mappedError\.status === 401 \|\| mappedError\.code === 'A0401'\)/)
assert.doesNotMatch(httpSource, /catch \(refreshError\) \{\s+clearSessionTokens\(\)/)
})
test('loads styles for programmatic Element Plus messages and dialogs', () => {
assert.match(mainSource, /element-plus\/es\/components\/message\/style\/css/)
assert.match(mainSource, /element-plus\/es\/components\/message-box\/style\/css/)
})
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowLeft } from '@lucide/vue'
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
import { legalDocuments, type LegalDocumentKey } from '@/modules/auth/legalDocuments'
const props = defineProps<{ documentKey: LegalDocumentKey }>()
const document = computed(() => legalDocuments[props.documentKey])
</script>
<template>
<div class="legal-page">
<header class="legal-header">
<RouterLink to="/register"><ArrowLeft :size="18" /> 返回企业注册</RouterLink>
<RouterLink to="/login" class="auth-brand" aria-label="返回柏悦物流登录页">
<img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" />
</RouterLink>
</header>
<main class="legal-main">
<article class="legal-document">
<header class="legal-title">
<h1>{{ document.title }}</h1>
<dl>
<div><dt>版本</dt><dd>{{ document.version }}</dd></div>
<div><dt>更新日期</dt><dd>{{ document.updatedAt }}</dd></div>
<div><dt>生效日期</dt><dd>{{ document.effectiveAt }}</dd></div>
</dl>
</header>
<section class="legal-introduction" aria-label="重要提示">
<p v-for="paragraph in document.introduction" :key="paragraph">{{ paragraph }}</p>
</section>
<section v-for="section in document.sections" :key="section.title" class="legal-section">
<h2>{{ section.title }}</h2>
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
<ul v-if="section.items">
<li v-for="item in section.items" :key="item">{{ item }}</li>
</ul>
</section>
</article>
</main>
</div>
</template>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { nextTick, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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
password: string
remember: boolean
}
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const impersonationFailed = route.query.impersonation === 'failed'
const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated'
const rememberedLoginId = getRememberedLoginId()
const form = reactive<LoginForm>({
account: typeof route.query.account === 'string'
? route.query.account
: rememberedLoginId,
password: '',
remember: Boolean(rememberedLoginId),
})
const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/
const mainlandMobilePattern = /^1[3-9]\d{9}$/
function validLoginId(value: string) {
return usernamePattern.test(value) || mainlandMobilePattern.test(value)
}
const rules: FormRules<LoginForm> = {
account: [
{ required: true, message: '请输入用户名或手机号', trigger: 'blur' },
{
validator: (_rule, value: string, callback) => {
if (!value || validLoginId(value.trim())) callback()
else callback(new Error('请输入有效用户名或中国大陆手机号'))
},
trigger: 'blur',
},
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 72, message: '密码长度应为 8–72 位', trigger: 'blur' },
],
}
async function submit() {
if (!formRef.value || loading.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) {
await nextTick()
document.querySelector<HTMLElement>('.auth-form-panel .el-form-item.is-error input')?.focus()
return
}
loading.value = true
try {
await auth.login(form.account.trim(), form.password, form.remember)
ElMessage.success('登录成功')
await router.replace(String(route.query.redirect || '/'))
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '登录失败,请检查账号后重试')
} finally {
loading.value = false
}
}
function showResetHint() {
ElMessage.info('请联系平台客服重置密码')
}
</script>
<template>
<AuthShell
switch-text="还没有企业账号?"
switch-label="申请入驻"
switch-to="/register"
visual-title="跨境物流业务,一个工作台完成"
visual-description="从企业协作到头程订单跟进,让业务信息与操作权限始终清晰。"
:features="['企业级角色与权限', '头程订单全程跟进', '多成员业务协同']"
>
<div class="auth-form-content auth-form-content--login">
<div class="auth-form-heading">
<h2 id="login-title">登录客户工作台</h2>
<p>使用用户名或中国大陆手机号登录,继续管理您的跨境物流业务。</p>
</div>
<el-alert
v-if="impersonationFailed"
title="代登录失败或授权已失效,请返回管理后台重新发起"
type="error"
:closable="false"
show-icon
/>
<el-alert
v-if="loginIdentifiersUpdated"
title="用户名或手机号已更新,所有会话均已退出,请重新登录"
type="success"
:closable="false"
show-icon
/>
<div class="auth-account-note">企业注册成功后即可登录;提交订单前需完成企业资料审核。</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="用户名或手机号" prop="account">
<el-input v-model="form.account" autocomplete="username" maxlength="64" placeholder="请输入用户名或中国大陆手机号" :prefix-icon="UserRound" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
</el-form-item>
<div class="auth-form-options">
<el-checkbox v-model="form.remember">记住账号</el-checkbox>
<button type="button" @click="showResetHint">忘记密码?</button>
</div>
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
<p class="login-register-entry">还没有企业账号?<RouterLink to="/register">立即注册</RouterLink></p>
</el-form>
</div>
</AuthShell>
</template>
+161
View File
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { CheckCircle2, Mail, Phone } from '@lucide/vue'
import { registerMerchant } from '@/modules/auth/api/auth'
import AuthShell from '@/modules/auth/components/AuthShell.vue'
interface RegisterForm {
username: string
companyName: string
displayName: string
legalPerson: string
phone: string
contactEmail: string
countryCode: string
address: string
password: string
agreement: boolean
}
const router = useRouter()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const submitted = ref(false)
const submittedAt = ref('')
const form = reactive<RegisterForm>({ username: '', companyName: '', displayName: '', legalPerson: '', phone: '', contactEmail: '', countryCode: 'CN', address: '', password: '', agreement: false })
const rules: FormRules<RegisterForm> = {
username: [
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
{ pattern: /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/, message: '用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成', trigger: 'blur' },
],
companyName: [
{ required: true, message: '请输入企业名称', trigger: 'blur' },
{ min: 2, max: 100, message: '企业名称长度应为 2–100 个字符', trigger: 'blur' },
],
displayName: [
{ required: true, message: '请输入联系人姓名', trigger: 'blur' },
{ max: 64, message: '联系人姓名最多 64 个字符', trigger: 'blur' },
],
legalPerson: [
{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' },
],
contactEmail: [
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的中国大陆手机号', trigger: 'blur' },
],
password: [
{ required: true, message: '请设置登录密码', trigger: 'blur' },
{ min: 8, max: 72, message: '密码长度应为 8–72 位', trigger: 'blur' },
],
agreement: [{ validator: (_rule, value, callback) => value ? callback() : callback(new Error('请先阅读并同意服务协议和隐私政策')), trigger: 'change' }],
}
async function submit() {
if (!formRef.value || submitting.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) {
await nextTick()
document.querySelector<HTMLElement>('.auth-form-panel .el-form-item.is-error input')?.focus()
return
}
submitting.value = true
try {
const legalPerson = form.legalPerson.trim()
await registerMerchant({
username: form.username.trim(),
password: form.password,
displayName: form.displayName.trim(),
companyName: form.companyName.trim(),
...(legalPerson ? { legalPerson } : {}),
phone: form.phone.trim(),
contactEmail: form.contactEmail.trim() || undefined,
countryCode: form.countryCode,
address: form.address.trim() || undefined,
})
submittedAt.value = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date())
submitted.value = true
ElMessage.success('企业账号注册成功')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '注册失败,请检查信息后重试')
} finally {
submitting.value = false
}
}
</script>
<template>
<AuthShell
variant="register"
switch-text="已有企业账号?"
switch-label="立即登录"
switch-to="/login"
visual-title="加入柏悦,开启高效跨境物流协作"
visual-description="创建企业账号后即可进入客户工作台,维护团队、资料与头程订单。"
:features="['注册成功即可登录', '企业资料独立审核', '审核期间可维护订单草稿']"
>
<div class="auth-form-content auth-form-content--register">
<template v-if="!submitted">
<div class="auth-form-heading">
<h1>企业注册</h1>
<p>填写账号与企业基本信息,注册成功后即可登录。</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" class="register-form" label-position="top" size="large" @submit.prevent="submit">
<section class="register-section" aria-labelledby="account-section-title">
<h2 id="account-section-title">账号信息</h2>
<div class="register-form-grid">
<el-form-item label="登录用户名" prop="username" required><el-input v-model="form.username" autocomplete="username" maxlength="64" placeholder="以字母开头,支持数字和 _.@-" /></el-form-item>
<el-form-item label="登录密码" prop="password" required><el-input v-model="form.password" type="password" autocomplete="new-password" maxlength="72" show-password placeholder="请设置 8–72 位密码" /></el-form-item>
</div>
</section>
<section class="register-section" aria-labelledby="company-section-title">
<h2 id="company-section-title">企业信息</h2>
<div class="register-form-grid">
<el-form-item label="企业名称" prop="companyName" class="register-form-full" required><el-input v-model="form.companyName" maxlength="100" placeholder="请输入营业执照上的企业名称" /></el-form-item>
<el-form-item label="法定代表人(选填)" prop="legalPerson"><el-input v-model="form.legalPerson" maxlength="64" placeholder="可在企业资料中后续补充" /></el-form-item>
<el-form-item label="国家/地区" prop="countryCode"><el-select v-model="form.countryCode" class="register-select"><el-option label="中国大陆(CN)" value="CN" /></el-select></el-form-item>
</div>
</section>
<section class="register-section" aria-labelledby="contact-section-title">
<h2 id="contact-section-title">联系信息</h2>
<div class="register-form-grid">
<el-form-item label="联系人" prop="displayName" required><el-input v-model="form.displayName" maxlength="64" placeholder="请输入联系人姓名" /></el-form-item>
<el-form-item label="登录手机号" prop="phone" required><el-input v-model="form.phone" type="tel" autocomplete="tel" inputmode="numeric" maxlength="11" placeholder="请输入 11 位手机号" :prefix-icon="Phone" /><span class="register-security-note">注册时同时初始化公司联系电话;注册后两者可分别修改,互不联动。</span></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" type="email" autocomplete="email" maxlength="128" placeholder="选填,用于接收业务通知" :prefix-icon="Mail" /></el-form-item>
<el-form-item label="企业地址" prop="address"><el-input v-model="form.address" maxlength="255" placeholder="选填,最多 255 个字符" /></el-form-item>
</div>
</section>
<el-form-item prop="agreement" class="agreement-item">
<div class="agreement-control">
<el-checkbox v-model="form.agreement"><span class="agreement-required" aria-hidden="true">*</span>我已阅读并同意</el-checkbox>
<span><RouterLink to="/legal/terms" target="_blank" rel="noopener">《用户服务协议》</RouterLink> 和 <RouterLink to="/legal/privacy" target="_blank" rel="noopener">《隐私政策》</RouterLink></span>
</div>
</el-form-item>
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照,法人身份证、货代资质证书和银行开户许可证可选填。提交平台审核通过后才能提交订单。</p>
<el-button native-type="submit" class="register-submit" type="success" :loading="submitting">注册企业账号</el-button>
</el-form>
</template>
<section v-else class="registration-result" aria-live="polite">
<CheckCircle2 :size="42" />
<h1>企业账号注册成功</h1>
<p><strong>{{ form.username }}</strong> 现在可以直接登录客户工作台,无需等待注册审核。</p>
<dl><div><dt>企业名称</dt><dd>{{ form.companyName }}</dd></div><div><dt>登录账号</dt><dd>{{ form.username }}</dd></div><div><dt>注册时间</dt><dd>{{ submittedAt }}</dd></div><div><dt>账号状态</dt><dd><el-tag type="success" effect="light">可登录</el-tag></dd></div></dl>
<div class="registration-next-step" role="note"><strong>提交订单前还需完成企业审核</strong><span>登录后请在“企业资料”上传营业执照并提交审核;法人身份证及其他资质可选填。审核期间仍可维护资料、成员和订单草稿。</span></div>
<div class="registration-result-actions"><el-button type="primary" @click="router.push({ path: '/login', query: { account: form.username } })">立即登录</el-button></div>
</section>
</div>
</AuthShell>
</template>
@@ -0,0 +1,165 @@
import { overseasApiRequest } from '@/api/overseasHttp'
export interface PageResult<T> {
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<TransportCreate, 'idempotencyKey'> {
version: number
}
const endpointByKind: Record<CrossBorderBusinessKind, string> = {
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<T>(data: PageResult<T>): PageResult<T> {
return { ...data, page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) }
}
export function listBusinessOrders<T>(kind: CrossBorderBusinessKind, params: BusinessOrderQuery) {
return overseasApiRequest<PageResult<T>>({ method: 'GET', url: endpointByKind[kind], params }).then(normalizePage)
}
export function getBusinessOrder<T>(kind: CrossBorderBusinessKind, orderNo: string) {
return overseasApiRequest<T>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` })
}
export function createProcurement(payload: ProcurementCreate) {
return overseasApiRequest<ProcurementDetail>({ method: 'POST', url: endpointByKind.procurement, data: payload })
}
export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) {
return overseasApiRequest<TransportDetail>({ method: 'POST', url: endpointByKind[kind], data: payload })
}
export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) {
return overseasApiRequest<TransportDetail>({
method: kind === 'trucking' ? 'PATCH' : 'PUT',
url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`,
data: payload,
})
}
@@ -0,0 +1,52 @@
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, /<el-pagination/)
assert.match(view, /<el-table-column label="操作"[^>]*fixed="left"/)
assert.match(view, /PENDING_OUTBOUND: '待出库'/)
assert.match(view, /PARTIALLY_OUTBOUND: '部分出库'/)
assert.match(view, /OUTBOUNDED: '全部出库'/)
assert.match(view, /\['入库量', read\(summary, 'inboundQuantity'\)\]/)
assert.match(view, /\['审核备注', current\.reviewRemark\]/)
})
test('keeps procurement filters, details and forms readable at PDA width', () => {
assert.match(view, /@media \(max-width: 767px\)/)
assert.match(view, /\.filter-card :deep\(\.el-form\) \{ display: grid; grid-template-columns: 1fr; \}/)
assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/)
assert.match(view, /\.form-card :deep\(\.el-form-item\) \{ display: block; \}/)
assert.match(view, /\.budget-form-item :deep\(\.el-form-item__content\) \{ display: flex; flex-wrap: nowrap; \}/)
})
@@ -0,0 +1,395 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAuthStore } from '@/modules/auth/public'
import {
createProcurement,
createTransport,
getBusinessOrder,
listBusinessOrders,
type CrossBorderBusinessKind,
type ProcurementDetail,
type ProcurementSummary,
type TransportDetail,
type TransportSummary,
} from '../api/businessOrders'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '../containerSpecifications'
const props = defineProps<{ kind: CrossBorderBusinessKind }>()
const auth = useAuthStore()
type BusinessRow = ProcurementSummary | TransportSummary
type BusinessDetail = ProcurementDetail | TransportDetail
type PageMode = 'list' | 'create' | 'detail'
interface ColumnDefinition { key: string; label: string; width?: number }
interface BusinessForm {
purchaseCategory: string
productName: string
productSpecification: string
productUrl: string
purchaseQuantity: number
transportMode: string
palletRequired: string
transportType: 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
receivingAddress: string
remark: string
}
const configuration = computed(() => ({
procurement: {
title: '海外采购',
description: '查询并管理海外采购订单',
columns: [
{ key: 'orderNo', label: '订单号', width: 180 },
{ key: 'purchaseCategory', label: '采购品类', width: 130 },
{ key: 'productName', label: '商品名称', width: 160 },
{ key: 'productSpecification', label: '商品规格', width: 160 },
{ key: 'purchaseQuantity', label: '采购数量', width: 100 },
{ key: 'budgetUnitPrice', label: '预算单价', width: 110 },
{ key: 'inboundQuantity', label: '入库量', width: 90 },
{ key: 'outboundQuantity', label: '出库量', width: 90 },
{ key: 'inventoryQuantity', label: '库存量', width: 90 },
{ key: 'status', label: '状态', width: 120 },
{ key: 'createdAt', label: '创建时间', width: 170 },
] satisfies ColumnDefinition[],
},
trucking: {
title: '美国本土卡',
description: '查询并管理美国本土卡订单',
columns: transportColumns(false),
},
looseboard: {
title: '美本土散板',
description: '查询并管理美本土散板订单',
columns: transportColumns(true),
},
})[props.kind])
function transportColumns(looseboard: boolean): ColumnDefinition[] {
return [
{ key: 'orderNo', label: '订单编号', width: 180 },
...(looseboard ? [{ key: 'transportMode', label: '运输方式', width: 120 }, { key: 'palletRequired', label: '是否打盘', width: 100 }] : []),
{ key: 'transportType', label: '运输类型', width: 130 },
{ key: 'origin', label: '起运地', width: 150 },
{ key: 'destination', label: '目的地', width: 150 },
{ key: 'containerType', label: '柜型', width: 100 },
{ key: 'totalWeightKg', label: '总重量(kg)', width: 120 },
{ key: 'budgetAmount', label: '预算总价', width: 110 },
{ key: 'requestedDate', label: '要求时间', width: 120 },
{ key: 'status', label: '状态', width: 130 },
]
}
const mode = ref<PageMode>('list')
const loading = ref(false)
const submitting = ref(false)
const submitKey = ref('')
const error = ref('')
const rows = ref<BusinessRow[]>([])
const detail = ref<BusinessDetail | null>(null)
const formRef = ref<FormInstance>()
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
const total = ref(0)
const form = reactive<BusinessForm>(emptyForm())
const rules: FormRules<BusinessForm> = {
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
purchaseQuantity: [{ required: true, type: 'number', min: 1, message: '采购数量至少为 1', trigger: 'change' }],
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
palletCount: [{ required: true, type: 'number', min: 1, message: '托盘数至少为 1', trigger: 'change' }],
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
contactInfo: [{ required: true, message: '请输入联系电话或联系方式', trigger: 'blur' }],
requestedDate: [{ required: true, message: '请选择要求时间', trigger: 'change' }],
}
const detailFields = computed(() => {
if (!detail.value) return []
const summary = detail.value.summary
if (props.kind === 'procurement') {
const current = detail.value as ProcurementDetail
return [
['订单号', summary.orderNo], ['采购品类', read(summary, 'purchaseCategory')],
['商品名称', read(summary, 'productName')], ['商品规格', read(summary, 'productSpecification')],
['采购数量', read(summary, 'purchaseQuantity')], ['预算单价', money(read(summary, 'budgetUnitPrice'), read(summary, 'currency'))],
['入库量', read(summary, 'inboundQuantity')], ['出库量', read(summary, 'outboundQuantity')],
['库存量', read(summary, 'inventoryQuantity')], ['预计到达时间', dateTime(current.estimatedArrivalAt)],
['联系人', current.contactName], ['联系电话', current.contactPhone],
['收货地址', current.receivingAddress], ['商品链接', current.productUrl],
['国内运单', joinText(current.domesticCarrier, current.domesticTrackingNo)],
['海外运单', joinText(current.overseasCarrier, current.overseasTrackingNo)],
['状态', statusLabel(summary.status)], ['审核备注', current.reviewRemark], ['备注', current.remark],
]
}
const current = detail.value as TransportDetail
return [
['订单编号', summary.orderNo], ['运输方式', read(summary, 'transportMode')],
['是否打盘', yesNo(read(summary, 'palletRequired'))], ['运输类型', transportTypeLabel(read(summary, 'transportType'))],
['起运地', read(summary, 'origin')], ['起运地址', current.originAddress],
['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress],
['柜型', read(summary, 'containerType')], ['托盘数', read(summary, 'palletCount')],
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
['联系人', read(summary, 'contactName')], ['联系方式', read(summary, 'contactInfo')],
['要求时间', read(summary, 'requestedDate')], ['状态', statusLabel(summary.status)], ['备注', current.remark],
]
})
function emptyForm(): BusinessForm {
return {
purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: 1,
transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '',
destination: '', destinationAddress: '', containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0,
totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '',
receivingAddress: '', remark: '',
}
}
function resetForm() { Object.assign(form, emptyForm()); submitKey.value = ''; formRef.value?.clearValidate() }
function read(source: object, key: string) { return Reflect.get(source, key) as unknown }
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function joinText(...values: unknown[]) { const result = values.filter(Boolean).join(' / '); return result || '—' }
function money(amount: unknown, currency: unknown) { return amount === null || amount === undefined ? '—' : `${text(currency)} ${Number(amount).toFixed(2)}` }
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO' ? '否' : text(value) }
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) }
function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record<string, string>)[String(value)] || text(value) }
function statusLabel(value: unknown) {
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
}
function displayCell(candidate: unknown, key: string) {
const row = candidate as BusinessRow
const value = read(row, key)
if (key === 'status') return statusLabel(value)
if (key === 'transportType') return transportTypeLabel(value)
if (key === 'transportMode') return transportModeLabel(value)
if (key === 'palletRequired') return yesNo(value)
if (key === 'createdAt') return dateTime(value)
if (key === 'budgetUnitPrice' || key === 'budgetAmount') return money(value, read(row, 'currency'))
return text(value)
}
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `client-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function errorMessage(cause: unknown) { return cause instanceof Error ? cause.message : '请求失败,请稍后重试' }
async function load() {
loading.value = true
error.value = ''
try {
const params = {
page: query.page, size: query.size, orderNo: query.orderNo || undefined, status: query.status || undefined,
routeKeyword: query.routeKeyword || undefined, productName: query.productName || undefined,
purchaseCategory: query.purchaseCategory || undefined,
}
const result = props.kind === 'procurement'
? await listBusinessOrders<ProcurementSummary>('procurement', params)
: await listBusinessOrders<TransportSummary>(props.kind, params)
rows.value = result.records
total.value = result.total
} catch (cause) {
error.value = errorMessage(cause)
rows.value = []
total.value = 0
} finally { loading.value = false }
}
function resetQuery() {
Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
void load()
}
async function openDetail(candidate: unknown) {
const row = candidate as BusinessRow
loading.value = true
error.value = ''
try {
detail.value = props.kind === 'procurement'
? await getBusinessOrder<ProcurementDetail>('procurement', row.orderNo)
: await getBusinessOrder<TransportDetail>(props.kind, row.orderNo)
mode.value = 'detail'
} catch (cause) { ElMessage.error(errorMessage(cause)) }
finally { loading.value = false }
}
async function submit() {
if (!await formRef.value?.validate().catch(() => false)) return
submitting.value = true
const stableSubmitKey = submitKey.value || idempotencyKey()
submitKey.value = stableSubmitKey
try {
if (props.kind === 'procurement') {
await createProcurement({
customerName: auth.user?.tenantName || '',
purchaseCategory: form.purchaseCategory, productName: form.productName,
productSpecification: form.productSpecification || undefined, productUrl: form.productUrl || undefined,
purchaseQuantity: form.purchaseQuantity, budgetUnitPrice: form.budgetAmount,
currency: form.currency || undefined, receivingAddress: form.receivingAddress || undefined,
contactName: form.contactName, contactPhone: form.contactInfo, remark: form.remark || undefined,
idempotencyKey: stableSubmitKey,
})
} else {
const base = {
transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined,
destination: form.destination, destinationAddress: form.destinationAddress || undefined,
containerType: form.containerType || undefined, packageCount: form.packageCount,
totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
budgetAmount: form.budgetAmount, currency: form.currency || undefined,
contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined,
requestedDate: form.requestedDate || undefined, remark: form.remark || undefined,
idempotencyKey: stableSubmitKey,
}
await createTransport(props.kind, props.kind === 'trucking'
? { ...base, palletCount: form.palletCount }
: { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired })
}
ElMessage.success('订单提交成功')
mode.value = 'list'
resetForm()
await load()
} catch (cause) { ElMessage.error(errorMessage(cause)) }
finally { submitting.value = false }
}
onMounted(() => void load())
</script>
<template>
<section v-loading="loading" class="business-orders" :aria-busy="loading">
<header class="page-head">
<div>
<h2>{{ mode === 'detail' ? `${configuration.title}订单详情` : mode === 'create' ? `${configuration.title}下单` : configuration.title }}</h2>
<p>{{ mode === 'list' ? configuration.description : mode === 'create' ? '请填写订单信息,带 * 项为必填项' : `订单号:${detail?.summary.orderNo || '—'}` }}</p>
</div>
<el-button v-if="mode !== 'list'" @click="mode = 'list'">返回列表</el-button>
<el-button v-else type="primary" @click="resetForm(); mode = 'create'">新增下单</el-button>
</header>
<template v-if="mode === 'list'">
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false">
<template #default><el-button size="small" @click="load">重新加载</el-button></template>
</el-alert>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="query.page = 1; load()">
<el-form-item label="订单号"><el-input v-model.trim="query.orderNo" clearable placeholder="请输入订单号" /></el-form-item>
<el-form-item v-if="kind === 'procurement'" label="采购品类"><el-input v-model.trim="query.purchaseCategory" clearable placeholder="请输入采购品类" /></el-form-item>
<el-form-item v-if="kind === 'procurement'" label="商品名称"><el-input v-model.trim="query.productName" clearable placeholder="请输入商品名称" /></el-form-item>
<el-form-item v-else label="运输路线"><el-input v-model.trim="query.routeKeyword" clearable placeholder="起运地或目的地" /></el-form-item>
<el-form-item label="状态"><el-input v-model.trim="query.status" clearable placeholder="请输入状态" /></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="rows" empty-text="暂无订单" border>
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
<el-table-column v-for="column in configuration.columns" :key="column.key" :label="column.label" :width="column.width" show-overflow-tooltip>
<template #default="scope">{{ displayCell(scope.row, column.key) }}</template>
</el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
</el-card>
</template>
<el-card v-else-if="mode === 'detail'" shadow="never">
<dl class="detail-grid">
<div v-for="field in detailFields" :key="String(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
</dl>
</el-card>
<el-card v-else shadow="never" class="form-card">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<template v-if="kind === 'procurement'">
<el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item>
<el-form-item label="商品名称" prop="productName"><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item>
<el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="请输入规格、型号等信息" /></el-form-item>
<el-form-item label="商品链接"><el-input v-model.trim="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item>
<el-form-item label="采购数量" prop="purchaseQuantity"><el-input-number v-model="form.purchaseQuantity" :min="1" placeholder="请输入数量" /></el-form-item>
<el-form-item label="收货地址"><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="请输入收货地址" /></el-form-item>
</template>
<template v-else>
<el-form-item v-if="kind === 'looseboard'" label="运输方式"><el-radio-group v-model="form.transportMode"><el-radio-button value="SHARED_TRUCK">拼车</el-radio-button><el-radio-button value="PRIORITY_CONSOLIDATION">优先拼载</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="是否打盘"><el-radio-group v-model="form.palletRequired"><el-radio-button value="NO">否</el-radio-button><el-radio-button value="YES">是</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="运输类型"><el-select v-model="form.transportType"><el-option label="港口提柜" value="PORT_PICKUP" /><el-option label="仓到仓" value="WAREHOUSE_TO_WAREHOUSE" /><el-option label="仓到门" value="WAREHOUSE_TO_DOOR" /></el-select></el-form-item>
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="form.origin" maxlength="255" placeholder="请输入起运地" /></el-form-item>
<el-form-item label="起运地址"><el-input v-model.trim="form.originAddress" maxlength="500" placeholder="请输入详细起运地址" /></el-form-item>
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="form.destination" maxlength="255" placeholder="请输入目的地" /></el-form-item>
<el-form-item label="目的地址"><el-input v-model.trim="form.destinationAddress" maxlength="500" placeholder="请输入详细目的地址" /></el-form-item>
<el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item v-if="kind === 'trucking'" label="托盘数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" placeholder="请输入托盘数" /></el-form-item>
<el-form-item label="件数"><el-input-number v-model="form.packageCount" :min="0" placeholder="请输入件数" /></el-form-item>
<el-form-item label="总重量(kg)"><el-input-number v-model="form.totalWeightKg" :min="0" :precision="2" placeholder="请输入总重量" /></el-form-item>
<el-form-item label="总体积(m³)"><el-input-number v-model="form.totalVolumeCbm" :min="0" :precision="3" placeholder="请输入总体积" /></el-form-item>
<el-form-item label="要求时间" :prop="kind === 'trucking' ? 'requestedDate' : undefined"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
</template>
<el-form-item class="budget-form-item" :label="kind === 'procurement' ? '预算单价' : '预算金额'"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" placeholder="请输入预算金额" /><el-select v-model="form.currency" class="currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="请输入联系人" /></el-form-item>
<el-form-item label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="请输入电话、微信或邮箱" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item>
<el-form-item><el-button @click="mode = 'list'">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
</el-form>
</el-card>
</section>
</template>
<style scoped>
.business-orders { display: grid; gap: 18px; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
.filter-card :deep(.el-form-item) { margin-bottom: 0; }
.filter-card :deep(.el-input) { width: 190px; }
.pagination { justify-content: flex-end; margin-top: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; }
.detail-grid div { min-width: 0; padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #1f2937; line-height: 1.55; }
.form-card :deep(.el-form) { max-width: 860px; }
.form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; }
.form-card .currency { width: 110px; margin-left: 12px; }
@media (max-width: 767px) {
.business-orders { gap: 14px; }
.page-head { align-items: flex-start; gap: 12px; }
.page-head h2 { font-size: 24px; }
.page-head > div { min-width: 0; }
.filter-card :deep(.el-form) { display: grid; grid-template-columns: 1fr; }
.filter-card :deep(.el-form-item) { width: 100%; margin-right: 0; margin-bottom: 12px; }
.filter-card :deep(.el-form-item:last-child) { margin-bottom: 0; }
.filter-card :deep(.el-form-item__content),
.filter-card :deep(.el-input) { width: 100%; }
.detail-grid { grid-template-columns: 1fr; }
.detail-grid div { padding: 14px 16px; border-right: 0; }
.detail-grid dd { overflow-wrap: break-word; word-break: normal; }
.form-card :deep(.el-form-item) { display: block; }
.form-card :deep(.el-form-item__label) {
width: auto !important;
height: auto;
margin-bottom: 7px;
line-height: 1.4;
justify-content: flex-start;
}
.form-card :deep(.el-form-item__content) { width: 100%; margin-left: 0 !important; }
.form-card :deep(.el-input-number) { width: 100%; }
.form-card .budget-form-item :deep(.el-form-item__content) { display: flex; flex-wrap: nowrap; }
.form-card .budget-form-item :deep(.el-input-number) { width: auto; min-width: 0; flex: 1 1 auto; }
.form-card .currency { width: 96px; flex: 0 0 96px; margin-left: 8px; }
}
</style>
@@ -0,0 +1,7 @@
export const CONTAINER_SPECIFICATIONS = [
{ type: '40GP', recommendedMinCbm: 56, capacityCbm: 58, maxWeightKg: 19500 },
{ type: '40HQ', recommendedMinCbm: 65, capacityCbm: 68, maxWeightKg: 19500 },
{ type: '45HQ', recommendedMinCbm: 82, capacityCbm: 86, maxWeightKg: 19500 },
] as const
export const DEFAULT_CONTAINER_TYPE = '40HQ' as const
+33
View File
@@ -0,0 +1,33 @@
import { Package, PlaneTakeoff, Ship, Truck } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
export const crossBorderModule = {
key: 'cross-border',
navigationGroup: { title: '出海下单', icon: PlaneTakeoff },
routes: [
{
path: 'cross-border/procurement',
name: 'cross-border-procurement',
component: () => import('./views/ProcurementView.vue'),
release: 'active',
meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement' },
navigation: { key: 'cross-border-procurement', icon: Package },
},
{
path: 'cross-border/domestic-trucking',
name: 'cross-border-domestic-trucking',
component: () => import('./views/DomesticTruckingView.vue'),
release: 'active',
meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking' },
navigation: { key: 'cross-border-domestic-trucking', icon: Truck },
},
{
path: 'cross-border/pallet-shipping',
name: 'cross-border-pallet-shipping',
component: () => import('./views/PalletShippingView.vue'),
release: 'active',
meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping' },
navigation: { key: 'cross-border-pallet-shipping', icon: Ship },
},
],
} satisfies ClientModuleManifest
@@ -0,0 +1,5 @@
<script setup lang="ts">
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
</script>
<template><BusinessOrdersView kind="trucking" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
</script>
<template><BusinessOrdersView kind="looseboard" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
</script>
<template><BusinessOrdersView kind="procurement" /></template>
+16
View File
@@ -0,0 +1,16 @@
import { Gauge } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
export const dashboardModule = {
key: 'dashboard',
routes: [
{
path: '',
name: 'dashboard',
component: () => import('./views/DashboardView.vue'),
release: 'active',
meta: { title: '业务总览', description: '企业账户与头程业务概览', permission: 'dashboard:view' },
navigation: { key: 'dashboard', icon: Gauge },
},
],
} satisfies ClientModuleManifest
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./DashboardView.vue', import.meta.url), 'utf8')
test('loads real dashboard data allowed by the current permissions', () => {
assert.match(source, /auth\.hasPermission\('company:profile:view'\).*tenant\.loadCompanyContext\(\)/)
assert.match(source, /auth\.hasPermission\('company:member:list'\).*tenant\.loadMembers\(\)/)
assert.match(source, /auth\.hasPermission\('order:first-leg:list'\).*orders\.load\(\)/)
assert.match(source, /onMounted\(async \(\) => \{\s*await loadDashboardData\(\)\s*showQualificationReminderOnce\(\)\s*\}\)/)
})
test('prompts once per session for incomplete company qualification and opens the upload dialog', () => {
assert.match(source, /\['PROFILE_REQUIRED', 'REJECTED'\]\.includes\(tenant\.company\.auditStatus\)/)
assert.match(source, /sessionStorage\.getItem\(reminderKey\)/)
assert.match(source, /sessionStorage\.setItem\(reminderKey, 'shown'\)/)
assert.match(source, /title="完善企业认证资料"/)
assert.match(source, /立即上传认证资料/)
assert.match(source, /router\.push\(\{ path: '\/company\/profile', query: \{ qualification: 'upload' \} \}\)/)
})
test('isolates recoverable dashboard failures without hiding successful sections', () => {
assert.match(source, /Promise\.allSettled/)
assert.match(source, /Partial<Record<DashboardDataSource, string>>/)
assert.match(source, /:aria-busy="loading"/)
assert.match(source, /role="alert"/)
assert.match(source, /部分业务数据加载失败/)
assert.match(source, /ordersUnavailable/)
assert.match(source, /companyUnavailable/)
assert.match(source, /订单数据暂不可用,请重新加载后查看。/)
assert.match(source, /企业资料暂不可用,请重新加载后查看。/)
assert.match(source, /@click="loadDashboardData"/)
assert.doesNotMatch(source, /<template v-else>\s*<section class="quick-order-band">/)
})
test('matches the prototype business overview and routes each card to its real business page', () => {
assert.match(source, /选择业务,快速下单/)
assert.match(source, /海外采购/)
assert.match(source, /美国本土卡/)
assert.match(source, /美本土散板/)
assert.match(source, /route: '\/cross-border\/procurement'/)
assert.match(source, /route: '\/cross-border\/domestic-trucking'/)
assert.match(source, /route: '\/cross-border\/pallet-shipping'/)
assert.match(source, /auth\.hasPermission\(business\.permission\)/)
assert.match(source, /order\.status === 'DELIVERED'/)
assert.match(source, /currentMonthOrderCount/)
assert.match(source, /提交订单后,专属客户经理将在 24 小时内与您联系/)
})
@@ -0,0 +1,640 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
AlertTriangle,
ArrowRight,
CircleQuestionMark,
Headphones,
Lightbulb,
PanelsTopLeft,
ShoppingCart,
Tags,
Truck,
} from '@lucide/vue'
import { useFirstLegOrdersStore } from '@/modules/first-leg/public'
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
import { useAuthStore } from '@/modules/auth/public'
const router = useRouter()
const orders = useFirstLegOrdersStore()
const tenant = useTenantAdministrationStore()
const auth = useAuthStore()
const loading = ref(false)
const qualificationReminderOpen = ref(false)
type DashboardDataSource = 'company' | 'members' | 'orders'
const loadErrors = ref<Partial<Record<DashboardDataSource, string>>>({})
const dataSourceLabels: Record<DashboardDataSource, string> = {
company: '企业资料',
members: '企业成员',
orders: '头程订单',
}
const companyUnavailable = computed(() => Boolean(loadErrors.value.company))
const ordersUnavailable = computed(() => Boolean(loadErrors.value.orders))
const loadErrorItems = computed(() =>
(Object.keys(loadErrors.value) as DashboardDataSource[]).map((key) => ({
key,
label: dataSourceLabels[key],
message: loadErrors.value[key] ?? '请求失败',
})),
)
const canViewOrders = computed(() => auth.hasPermission('order:first-leg:list'))
const accountName = computed(() => {
if (!companyUnavailable.value && tenant.company.companyName) return tenant.company.companyName
return auth.user?.tenantName || auth.user?.name || '企业客户'
})
const accountInitial = computed(() => accountName.value.trim().slice(0, 1).toUpperCase() || 'C')
const completedOrderCount = computed(() => orders.orders.filter((order) => order.status === 'DELIVERED').length)
const currentMonthOrderCount = computed(() => {
const now = new Date()
return orders.orders.filter((order) => {
const createdAt = new Date(order.createdAt)
return !Number.isNaN(createdAt.getTime())
&& createdAt.getFullYear() === now.getFullYear()
&& createdAt.getMonth() === now.getMonth()
}).length
})
const overviewStats = computed(() => [
{ label: '进行中订单', value: dashboardOrderValue(orders.activeCount) },
{ label: '已完成订单', value: dashboardOrderValue(completedOrderCount.value) },
{ label: '本月下单', value: dashboardOrderValue(currentMonthOrderCount.value) },
])
const businessCards = [
{
title: '海外采购',
description: '采购代购 · 验货质检 · 集中发货',
icon: ShoppingCart,
tone: 'blue',
route: '/cross-border/procurement',
permission: 'merchant:cross-border:procurement',
},
{
title: '美国本土卡',
description: '港口提柜 · 仓到仓 · 仓到门',
icon: Truck,
tone: 'green',
route: '/cross-border/domestic-trucking',
permission: 'merchant:cross-border:domestic-trucking',
},
{
title: '美本土散板',
description: '拼车拼载 · 打盘服务 · 灵活派送',
icon: PanelsTopLeft,
tone: 'teal',
route: '/cross-border/pallet-shipping',
permission: 'merchant:cross-border:pallet-shipping',
},
] as const
function dashboardOrderValue(value: number) {
if (!canViewOrders.value || ordersUnavailable.value) return '—'
return String(value)
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
async function loadDashboardData() {
loading.value = true
loadErrors.value = {}
const requests: Array<{ source: DashboardDataSource; request: Promise<unknown> }> = []
if (auth.hasPermission('company:profile:view')) requests.push({ source: 'company', request: tenant.loadCompanyContext() })
if (auth.hasPermission('company:member:list')) requests.push({ source: 'members', request: tenant.loadMembers() })
if (auth.hasPermission('order:first-leg:list')) requests.push({ source: 'orders', request: orders.load() })
try {
const results = await Promise.allSettled(requests.map(({ request }) => request))
const nextErrors: Partial<Record<DashboardDataSource, string>> = {}
results.forEach((result, index) => {
const request = requests[index]
if (request && result.status === 'rejected') nextErrors[request.source] = errorMessage(result.reason)
})
loadErrors.value = nextErrors
} finally {
loading.value = false
}
}
function showQualificationReminderOnce() {
if (companyUnavailable.value || !auth.hasPermission('company:qualification:upload')) return
if (!['PROFILE_REQUIRED', 'REJECTED'].includes(tenant.company.auditStatus)) return
const reminderKey = `client-qualification-reminder:${auth.user?.id ?? 'current'}`
if (sessionStorage.getItem(reminderKey)) return
sessionStorage.setItem(reminderKey, 'shown')
qualificationReminderOpen.value = true
}
async function openQualificationUpload() {
qualificationReminderOpen.value = false
await router.push({ path: '/company/profile', query: { qualification: 'upload' } })
}
function openBusinessOrder(business: typeof businessCards[number]) {
if (auth.hasPermission(business.permission)) {
void router.push(business.route)
return
}
ElMessage.warning(`当前账号暂无${business.title}权限,请联系企业管理员。`)
}
function showSupportMessage(type: 'service' | 'faq' | 'quote') {
const messages = {
service: '客服联系方式正在配置中,请联系您的专属客户经理。',
faq: '常见问题页面即将上线。',
quote: '提交订单后,客户经理将根据货物信息为您提供报价。',
}
ElMessage.info(messages[type])
}
onMounted(async () => {
await loadDashboardData()
showQualificationReminderOnce()
})
</script>
<template>
<div v-loading="loading" class="client-dashboard" :aria-busy="loading">
<el-dialog v-model="qualificationReminderOpen" title="完善企业认证资料" width="min(520px, 92vw)" append-to-body>
<p class="qualification-reminder-copy">请先上传营业执照和统一社会信用代码并提交审核;法人身份证及其他资质可选填。审核通过后即可提交头程订单。</p>
<template #footer><el-button @click="qualificationReminderOpen = false">稍后处理</el-button><el-button type="primary" @click="openQualificationUpload">立即上传认证资料</el-button></template>
</el-dialog>
<div v-if="loadErrorItems.length" class="dashboard-load-error" role="alert">
<AlertTriangle :size="20" aria-hidden="true" />
<div>
<strong>部分业务数据加载失败</strong>
<ul>
<li v-for="item in loadErrorItems" :key="item.key">
<span>{{ item.label }}:</span>{{ item.message }}
</li>
</ul>
</div>
<el-button :loading="loading" @click="loadDashboardData">重新加载</el-button>
</div>
<div class="dashboard-layout">
<main class="dashboard-business" aria-labelledby="dashboard-heading">
<header class="dashboard-intro">
<h2 id="dashboard-heading">选择业务,快速下单</h2>
<p>请选择您需要的业务类型,填写信息后提交订单,我们将尽快为您安排。</p>
</header>
<section class="business-card-grid" aria-label="可选业务">
<button
v-for="business in businessCards"
:key="business.title"
type="button"
class="business-card"
:class="`business-card--${business.tone}`"
@click="openBusinessOrder(business)"
>
<span class="business-card__icon" aria-hidden="true">
<component :is="business.icon" :size="29" :stroke-width="2.4" />
</span>
<span class="business-card__copy">
<strong>{{ business.title }}</strong>
<small>{{ business.description }}</small>
</span>
<span class="business-card__action">
{{ auth.hasPermission(business.permission) ? '开始下单' : '暂无权限' }}
<ArrowRight :size="18" aria-hidden="true" />
</span>
</button>
</section>
</main>
<aside class="dashboard-aside" aria-label="账户与业务信息">
<section class="aside-card account-card">
<h3>账户信息</h3>
<div class="account-summary">
<span class="account-avatar">{{ accountInitial }}</span>
<span class="account-copy">
<strong>{{ accountName }}</strong>
<small>企业客户</small>
</span>
</div>
<p v-if="companyUnavailable" class="account-load-error" role="status">企业资料暂不可用,请重新加载后查看。</p>
</section>
<section class="aside-card">
<h3>数据概览</h3>
<dl class="overview-stats">
<div v-for="stat in overviewStats" :key="stat.label">
<dt>{{ stat.label }}</dt>
<dd>{{ stat.value }}</dd>
</div>
</dl>
<p v-if="ordersUnavailable" class="dashboard-section-error" role="status">订单数据暂不可用,请重新加载后查看。</p>
</section>
<section class="aside-card">
<h3>帮助与支持</h3>
<div class="support-links">
<button type="button" @click="showSupportMessage('service')"><Headphones :size="19" /><span>联系客服</span></button>
<button type="button" @click="showSupportMessage('faq')"><CircleQuestionMark :size="19" /><span>常见问题</span></button>
<button type="button" @click="showSupportMessage('quote')"><Tags :size="19" /><span>获取报价</span></button>
</div>
</section>
<div class="dashboard-tip">
<Lightbulb :size="19" aria-hidden="true" />
<p>提交订单后,专属客户经理将在 24 小时内与您联系,确认运输方案与报价。</p>
</div>
</aside>
</div>
</div>
</template>
<style scoped>
.client-dashboard {
--dashboard-blue: #1565c0;
--dashboard-green: #3f9f47;
--dashboard-teal: #078a99;
display: block;
min-height: calc(100vh - 128px);
}
.dashboard-load-error {
margin-bottom: 20px;
}
.qualification-reminder-copy { margin: 0; color: #475467; font-size: 14px; line-height: 1.75; }
.dashboard-load-error ul {
display: grid;
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
}
.dashboard-load-error li {
overflow-wrap: anywhere;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.dashboard-load-error li span {
color: #9a5a09;
font-weight: 650;
}
.dashboard-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 286px;
gap: clamp(24px, 3vw, 42px);
align-items: start;
}
.dashboard-business {
min-width: 0;
padding: 14px 0 24px;
}
.dashboard-intro {
margin-bottom: 34px;
}
.dashboard-intro h2 {
max-width: 720px;
margin: 0;
color: #111827;
font-size: clamp(30px, 3.2vw, 46px);
line-height: 1.18;
letter-spacing: -.025em;
}
.dashboard-intro p {
max-width: 720px;
margin: 16px 0 0;
color: #697586;
font-size: 15px;
line-height: 1.7;
}
.business-card-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.business-card {
--card-accent: var(--dashboard-blue);
display: flex;
min-width: 0;
min-height: 286px;
flex-direction: column;
align-items: flex-start;
padding: 30px;
border: 0;
border-radius: 16px;
background: #fff;
box-shadow: 0 12px 36px rgba(23, 50, 77, .08);
color: #111827;
cursor: pointer;
text-align: left;
transition: transform .25s cubic-bezier(.16, 1, .3, 1), box-shadow .25s cubic-bezier(.16, 1, .3, 1);
}
.business-card--green { --card-accent: var(--dashboard-green); }
.business-card--teal { --card-accent: var(--dashboard-teal); }
.business-card:hover {
transform: translateY(-5px);
box-shadow: 0 20px 46px rgba(23, 50, 77, .14);
}
.business-card:focus-visible {
outline: 3px solid color-mix(in srgb, var(--card-accent) 36%, white);
outline-offset: 4px;
}
.business-card__icon {
display: grid;
width: 60px;
height: 60px;
place-items: center;
border-radius: 16px;
background: var(--card-accent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--card-accent) 24%, transparent);
color: #fff;
}
.business-card__copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 10px;
margin-top: 34px;
}
.business-card__copy strong {
font-size: 22px;
line-height: 1.35;
}
.business-card__copy small {
color: #697586;
font-size: 14px;
line-height: 1.65;
}
.business-card__action {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: auto;
padding-top: 28px;
color: var(--card-accent);
font-size: 14px;
font-weight: 700;
}
.business-card__action svg {
transition: transform .2s cubic-bezier(.16, 1, .3, 1);
}
.business-card:hover .business-card__action svg {
transform: translateX(3px);
}
.dashboard-aside {
display: flex;
flex-direction: column;
gap: 14px;
}
.aside-card {
padding: 20px;
border: 1px solid #e9eef5;
border-radius: 14px;
background: #f8fafc;
}
.aside-card h3 {
margin: 0 0 18px;
color: #8b9bb2;
font-size: 12px;
font-weight: 750;
letter-spacing: .06em;
}
.account-summary {
display: flex;
min-width: 0;
align-items: center;
gap: 14px;
}
.account-avatar {
display: grid;
width: 52px;
height: 52px;
flex: 0 0 52px;
place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, #1976d2, #00a993);
color: #fff;
font-size: 20px;
font-weight: 750;
}
.account-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.account-copy strong {
overflow: hidden;
color: #111827;
font-size: 15px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-copy small {
color: #66758a;
font-size: 12px;
}
.account-load-error {
margin: 14px 0 0;
color: #8a5a22;
font-size: 12px;
line-height: 1.55;
}
.overview-stats {
margin: 0;
}
.overview-stats div {
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px dashed #dfe6ef;
}
.overview-stats div:last-child {
border-bottom: 0;
}
.overview-stats dt {
color: #475569;
font-size: 13px;
}
.overview-stats dd {
margin: 0;
color: #111827;
font-size: 15px;
font-weight: 750;
}
.dashboard-section-error {
margin: 10px 0 0;
padding: 10px 0 0;
border-top: 1px solid #e9eef5;
color: #8a5a22;
font-size: 12px;
line-height: 1.55;
}
.support-links {
display: grid;
gap: 3px;
}
.support-links button {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
gap: 11px;
padding: 0 9px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
cursor: pointer;
font-size: 14px;
text-align: left;
}
.support-links button svg {
flex: 0 0 19px;
color: #00a993;
}
.support-links button:hover {
background: #edf2f7;
color: var(--dashboard-blue);
}
.support-links button:focus-visible {
outline: 2px solid var(--dashboard-blue);
outline-offset: 2px;
}
.dashboard-tip {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 16px;
border: 1px solid rgba(0, 169, 147, .24);
border-radius: 14px;
background: #eef9f8;
color: #53657a;
}
.dashboard-tip svg {
margin-top: 2px;
color: #00a993;
}
.dashboard-tip p {
margin: 0;
font-size: 12px;
line-height: 1.75;
}
@media (max-width: 1180px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
.dashboard-aside {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
}
.dashboard-tip {
grid-column: 1 / -1;
}
}
@media (max-width: 960px) {
.business-card-grid {
grid-template-columns: 1fr;
}
.business-card {
min-height: 232px;
}
}
@media (max-width: 720px) {
.client-dashboard {
min-height: calc(100vh - 118px);
}
.dashboard-business {
padding-top: 2px;
}
.dashboard-intro {
margin-bottom: 24px;
}
.dashboard-intro h2 {
font-size: 28px;
}
.dashboard-intro p {
margin-top: 12px;
font-size: 14px;
}
.business-card {
min-height: 220px;
padding: 24px;
}
.business-card__copy {
margin-top: 24px;
}
.dashboard-aside {
grid-template-columns: 1fr;
}
.dashboard-tip {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.business-card,
.business-card__action svg {
transition: none;
}
}
</style>
+128
View File
@@ -0,0 +1,128 @@
import { apiRequest } from '@/api/http'
export interface PageResult<T> {
records: T[]
total: number
page: number
size: number
pages: number
}
interface PageResultWire<T> extends Omit<PageResult<T>, 'total' | 'page' | 'size' | 'pages'> {
total: number | string
page: number | string
size: number | string
pages: number | string
}
function normalizePageResult<T>(result: PageResultWire<T>): PageResult<T> {
return {
...result,
total: Number(result.total),
page: Number(result.page),
size: Number(result.size),
pages: Number(result.pages),
}
}
export interface FinanceAccount {
currencyCode: 'CNY'
actualBalance: string
frozenBalance: string
availableBalance: string
creditLimit: string
creditUsed: string
creditFrozen: string
availableCredit: string
creditExpiresOn: string | null
creditActive: boolean
version: number
updatedAt: string
}
export type AccountChangeType = 'RECHARGE' | 'DEDUCTION' | 'FREEZE' | 'UNFREEZE' | 'CONSUMPTION' | 'CREDIT_LIMIT'
export interface AccountLedgerEntry {
ledgerId: string
businessNo: string
changeType: AccountChangeType
amount: string
actualBalanceAfter: string
frozenBalanceAfter: string
availableBalanceAfter: string
creditLimitAfter: string
creditUsedAfter: string
availableCreditAfter: string
creditExpiresOnAfter: string | null
sourceType: 'CLIENT_RECHARGE' | 'MANUAL_RECHARGE' | 'MANUAL_DEDUCTION' | 'CREDIT_LIMIT_ADJUSTMENT' | string
sourceNo: string
operatorName: string | null
remark: string | null
createdAt: string
}
export type RechargePaymentMethod = 'WECHAT' | 'ALIPAY'
export type RechargeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
export interface RechargeRequest {
requestId: string
requestNo: string
amount: string
paymentMethod: RechargePaymentMethod
voucherFileId: string
voucherFileName: string
status: RechargeStatus
version: number
submittedByName: string | null
reviewedByName: string | null
reviewRemark: string | null
createdAt: string
reviewedAt: string | null
}
export interface RechargeChannel {
paymentMethod: RechargePaymentMethod
displayName: string
enabled: boolean
qrCodeUrl: string | null
}
export function getFinanceAccount() {
return apiRequest<FinanceAccount>({ method: 'GET', url: '/client/finance/v1/account' })
}
export async function getAccountLedger(page: number, size: number) {
const result = await apiRequest<PageResultWire<AccountLedgerEntry>>({
method: 'GET',
url: '/client/finance/v1/account/ledger',
params: { page, size },
})
return normalizePageResult(result)
}
export async function getRechargeRequests(page: number, size: number) {
const result = await apiRequest<PageResultWire<RechargeRequest>>({
method: 'GET',
url: '/client/finance/v1/recharge-requests',
params: { page, size },
})
return normalizePageResult(result)
}
export function getRechargeChannels() {
return apiRequest<RechargeChannel[]>({ method: 'GET', url: '/client/finance/v1/recharge-channels' })
}
export function createRechargeRequest(amount: string, paymentMethod: RechargePaymentMethod, file: File,
idempotencyKey: string) {
const formData = new FormData()
formData.append('amount', amount)
formData.append('paymentMethod', paymentMethod)
formData.append('file', file, file.name)
return apiRequest<RechargeRequest>({
method: 'POST',
url: '/client/finance/v1/recharge-requests',
headers: { 'Idempotency-Key': idempotencyKey },
data: formData,
})
}
+148
View File
@@ -0,0 +1,148 @@
import type {
FinanceBillDetail,
FinanceBillLine,
FinanceBillStatus,
FinanceCustomerConfirmationStatus,
FinanceBillSummary,
FinanceReceiptRecord,
} from '../contracts'
type WireScalar = string | number
export interface FinanceBillWire {
id: WireScalar
customerId: WireScalar
billNo: string
customerName: string
currency: string
billDate: string | null
dueDate: string | null
status: FinanceBillStatus
totalAmount: WireScalar
receivedAmount: WireScalar
outstandingAmount: WireScalar
version: number
customerConfirmationStatus: FinanceCustomerConfirmationStatus
customerConfirmedByName: string | null
customerConfirmedAt: string | null
}
export interface FinanceBillLineWire {
id: WireScalar
sourceType: string
sourceId: WireScalar
sourceVersion: number
orderId: WireScalar | null
inboundId: WireScalar | null
inboundCode: string | null
feeItemCode: string
feeItemName: string
amount: WireScalar
currency: string
}
export interface FinanceBillDetailWire {
statement: FinanceBillWire
lines: FinanceBillLineWire[]
}
export interface FinanceReceiptAllocationWire {
billId: WireScalar
billNo: string
appliedAmount: WireScalar
}
export interface FinanceReceiptWire {
id: WireScalar
receiptNo: string
receivedAmount: WireScalar
receivedAt: string
paymentMethod: string | null
currency: string
status: string
allocations: FinanceReceiptAllocationWire[]
}
function text(value: WireScalar) {
return String(value)
}
function mapBillSummary(bill: FinanceBillWire): FinanceBillSummary {
return {
id: text(bill.id),
billNo: bill.billNo,
customerName: bill.customerName,
currencyCode: 'CNY',
totalAmount: text(bill.totalAmount),
receivedAmount: text(bill.receivedAmount),
outstandingAmount: text(bill.outstandingAmount),
issueDate: bill.billDate,
dueDate: bill.dueDate,
status: bill.status,
version: bill.version ?? 0,
customerConfirmationStatus: bill.customerConfirmationStatus ?? 'NOT_REQUIRED',
customerConfirmedByName: bill.customerConfirmedByName ?? null,
customerConfirmedAt: bill.customerConfirmedAt ?? null,
}
}
function mapBillLine(line: FinanceBillLineWire): FinanceBillLine {
return {
id: text(line.id),
orderId: line.orderId == null ? null : text(line.orderId),
inboundCode: line.inboundCode?.trim() || null,
feeNo: `${line.sourceType}:${text(line.sourceId)}/v${line.sourceVersion}`,
feeName: line.feeItemName,
amount: text(line.amount),
currencyCode: 'CNY',
}
}
export function mapFinanceBills(bills: FinanceBillWire[]) {
return bills
.filter((bill) => bill.status === 'ISSUED' || bill.status === 'PARTIALLY_PAID' || bill.status === 'PAID')
.map(mapBillSummary)
}
export function mapFinanceBillDetail(detail: FinanceBillDetailWire): FinanceBillDetail {
return {
...mapBillSummary(detail.statement),
lines: (detail.lines ?? []).map(mapBillLine),
receipts: [],
}
}
export function mapFinanceReceipts(receipts: FinanceReceiptWire[], billId: string): FinanceReceiptRecord[] {
return receipts.flatMap((receipt) => receipt.allocations
.filter((allocation) => text(allocation.billId) === billId)
.map((allocation) => ({
id: text(receipt.id),
receiptNo: receipt.receiptNo,
receivedAt: receipt.receivedAt,
amount: text(receipt.receivedAmount),
currencyCode: 'CNY' as const,
method: receipt.paymentMethod,
allocatedAmount: text(allocation.appliedAmount),
})))
}
export function sumDecimalStrings(values: string[]) {
let scale = 0
const parsed = values.map((value) => {
const normalized = value.trim() || '0'
const [integer = '0', fraction = ''] = normalized.split('.')
scale = Math.max(scale, fraction.length)
return { integer, fraction }
})
const total = parsed.reduce((sum, value) => {
const sign = value.integer.startsWith('-') ? -1n : 1n
const integer = value.integer.replace(/^[+-]/, '') || '0'
const digits = `${integer}${value.fraction.padEnd(scale, '0')}`
return sum + sign * BigInt(digits || '0')
}, 0n)
if (scale === 0) return total.toString()
const negative = total < 0n
const absolute = (negative ? -total : total).toString().padStart(scale + 1, '0')
const result = `${absolute.slice(0, -scale)}.${absolute.slice(-scale)}`
return negative ? `-${result}` : result
}
+71
View File
@@ -0,0 +1,71 @@
import { apiRequest } from '@/api/http'
import type { FinanceBillDetail, FinanceBillPage, FinanceBillQuery, FinanceCustomerConfirmationResult, FinanceReceiptRecord } from '../contracts'
import {
mapFinanceBillDetail,
mapFinanceBills,
mapFinanceReceipts,
sumDecimalStrings,
type FinanceBillDetailWire,
type FinanceBillWire,
type FinanceReceiptWire,
} from './adapters'
/** The customer scope is resolved by the authenticated merchant session. */
export async function getMerchantFinanceBills(params: FinanceBillQuery = {}): Promise<FinanceBillPage> {
const response = await apiRequest<FinanceBillWire[]>({
method: 'GET',
url: '/client/finance/v1/bills',
})
const keyword = params.keyword?.trim().toLocaleLowerCase('zh-CN')
const filtered = mapFinanceBills(response).filter((bill) => {
const matchesStatus = !params.status || bill.status === params.status
const matchesKeyword = !keyword || bill.billNo.toLocaleLowerCase('zh-CN').includes(keyword)
|| bill.customerName.toLocaleLowerCase('zh-CN').includes(keyword)
return matchesStatus && matchesKeyword
})
const page = Math.max(1, params.page ?? 1)
const size = Math.max(1, params.size ?? 20)
const start = (page - 1) * size
return {
records: filtered.slice(start, start + size),
total: filtered.length,
page,
size,
pages: Math.ceil(filtered.length / size),
totalBilledAmount: sumDecimalStrings(filtered.map((bill) => bill.totalAmount)),
totalReceivedAmount: sumDecimalStrings(filtered.map((bill) => bill.receivedAmount)),
totalOutstandingAmount: sumDecimalStrings(filtered.map((bill) => bill.outstandingAmount)),
currencyCode: 'CNY',
}
}
export async function getMerchantFinanceBill(id: string): Promise<FinanceBillDetail> {
const response = await apiRequest<FinanceBillDetailWire>({
method: 'GET',
url: `/client/finance/v1/bills/${encodeURIComponent(id)}`,
})
return mapFinanceBillDetail(response)
}
export async function getMerchantFinanceReceipts(billId: string): Promise<FinanceReceiptRecord[]> {
const response = await apiRequest<FinanceReceiptWire[]>({
method: 'GET',
url: '/client/finance/v1/receipts',
})
return mapFinanceReceipts(response, billId)
}
function confirmationIdempotencyKey(statementId: string) {
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
return `statement-confirm-${statementId}-${randomPart}`
}
export async function confirmMerchantFinanceStatement(statementId: string, expectedVersion: number,
idempotencyKey = confirmationIdempotencyKey(statementId)) {
return apiRequest<FinanceCustomerConfirmationResult>({
method: 'POST',
url: `/client/finance/v1/statements/${encodeURIComponent(statementId)}/confirm`,
headers: { 'Idempotency-Key': idempotencyKey },
data: { expectedVersion },
})
}
+69
View File
@@ -0,0 +1,69 @@
import type { PageResult } from '@/modules/first-leg/api/firstLeg'
export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
export type FinanceCustomerConfirmationStatus = 'NOT_REQUIRED' | 'PENDING_CONFIRMATION' | 'CONFIRMED'
export interface FinanceBillQuery {
keyword?: string
status?: FinanceBillStatus
page?: number
size?: number
}
export interface FinanceBillSummary {
id: string
billNo: string
customerName: string
currencyCode: 'CNY'
totalAmount: string
receivedAmount: string
outstandingAmount: string
issueDate: string | null
dueDate: string | null
status: FinanceBillStatus
version: number
customerConfirmationStatus: FinanceCustomerConfirmationStatus
customerConfirmedByName: string | null
customerConfirmedAt: string | null
}
export interface FinanceCustomerConfirmationResult {
statementId: string
confirmationStatus: 'CONFIRMED'
confirmedBy: string
confirmedByName: string
confirmedAt: string
version: number
}
export interface FinanceBillLine {
id: string
orderId: string | null
inboundCode: string | null
feeNo: string | null
feeName: string
amount: string
currencyCode: 'CNY'
}
export interface FinanceReceiptRecord {
id: string
receiptNo: string
receivedAt: string
amount: string
currencyCode: 'CNY'
method: string | null
allocatedAmount: string
}
export interface FinanceBillDetail extends FinanceBillSummary {
lines: FinanceBillLine[]
receipts: FinanceReceiptRecord[]
}
export interface FinanceBillPage extends PageResult<FinanceBillSummary> {
totalBilledAmount: string
totalReceivedAmount: string
totalOutstandingAmount: string
currencyCode: 'CNY'
}
+25
View File
@@ -0,0 +1,25 @@
import { CircleDollarSign, ReceiptText } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
export const financeModule = {
key: 'finance',
navigationGroup: { title: '财务结算', icon: ReceiptText },
routes: [
{
path: 'finance/account',
name: 'finance-account',
component: () => import('./views/FinanceAccountView.vue'),
release: 'active',
meta: { title: '资金账户', description: '查看本企业资金余额、流水与充值申请', permission: 'merchant:finance:account:view' },
navigation: { key: 'finance-account', title: '资金账户', icon: CircleDollarSign },
},
{
path: 'finance/bills',
name: 'finance-bills',
component: () => import('./views/FinanceBillsView.vue'),
release: 'active',
meta: { title: '客户账单', description: '查看本企业账单、收款记录和未收余额', permission: 'finance:bill:view' },
navigation: { key: 'finance-bills', title: '客户账单', icon: ReceiptText },
},
],
} satisfies ClientModuleManifest
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const apiSource = readFileSync(new URL('../api/account.ts', import.meta.url), 'utf8')
const viewSource = readFileSync(new URL('./FinanceAccountView.vue', import.meta.url), 'utf8')
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const permissionsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
test('uses only authenticated client finance account endpoints without customer scope', () => {
for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests', '/client/finance/v1/recharge-channels']) {
assert.match(apiSource, new RegExp(path.replaceAll('/', '\\/')))
}
assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
assert.match(apiSource, /formData\.append\('amount', amount\)/)
assert.match(apiSource, /formData\.append\('paymentMethod', paymentMethod\)/)
assert.match(apiSource, /formData\.append\('file', file, file\.name\)/)
assert.doesNotMatch(apiSource, /customerId|tenantId|mock|fallback/i)
})
test('activates the account route and maps read and recharge permissions independently', () => {
assert.match(routesSource, /path: 'finance\/account'[\s\S]*FinanceAccountView\.vue[\s\S]*release: 'active'[\s\S]*permission: 'merchant:finance:account:view'/)
assert.match(permissionsSource, /'merchant:finance:account:view': \['merchant:finance:account:view'\]/)
assert.match(permissionsSource, /'merchant:finance:recharge:create': \['merchant:finance:recharge:create'\]/)
assert.match(viewSource, /auth\.hasPermission\('merchant:finance:recharge:create'\)/)
assert.match(viewSource, /<el-button v-if="canRecharge"/)
})
test('renders server balances, ledger and recharge states without internal identifiers', () => {
for (const field of ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`))
for (const field of ['creditUsedAfter', 'availableCreditAfter']) assert.match(viewSource, new RegExp(`row\\.${field}`))
assert.match(viewSource, /实际可用资金与可用授信合计/)
assert.match(viewSource, /CREDIT_LIMIT: '授信调整'/)
assert.match(viewSource, /row-key="businessNo"/)
assert.match(viewSource, /row-key="requestNo"/)
assert.match(viewSource, /empty-text="暂无资金流水"/)
assert.match(viewSource, /empty-text="暂无充值申请"/)
assert.match(viewSource, /account-state--error/)
assert.match(viewSource, /v-loading="pageLoading"/)
assert.match(viewSource, /function formatAccountAmount[\s\S]*value == null \? '—' : formatAmount\(value\)/)
assert.match(viewSource, /formatAccountAmount\(account\?\.actualBalance\)/)
assert.match(viewSource, /:default-current-page="ledgerPage"/)
assert.match(viewSource, /:default-current-page="rechargePage"/)
assert.doesNotMatch(viewSource, /<el-pagination (?:v-model:current-page|:current-page)=/)
assert.match(apiSource, /function normalizePageResult<[\s\S]*total: Number\(result\.total\)/)
assert.match(apiSource, /page: Number\(result\.page\)/)
assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/)
})
test('matches the frozen recharge workflow and preserves an idempotency key for failed retries', () => {
assert.match(viewSource, /title="账户充值" width="760px"/)
for (const amount of ['50.00', '100.00', '200.00', '500.00']) assert.match(viewSource, new RegExp(amount.replace('.', '\\.')))
assert.match(viewSource, /\['WECHAT', 'ALIPAY'\]/)
assert.match(viewSource, /selectedChannel\.value\?\.enabled && selectedChannel\.value\.qrCodeUrl/)
assert.match(viewSource, /accept="\.jpg,\.jpeg,\.png,image\/jpeg,image\/png"/)
assert.match(viewSource, /if \(!pendingIdempotencyKey\)[\s\S]*globalThis\.crypto\?\.randomUUID\?\.\(\)/)
assert.match(viewSource, /pendingIdempotencyKey = ''[\s\S]*rechargeVisible\.value = false/)
assert.match(viewSource, /:disabled="!canSubmit"/)
assert.match(viewSource, /await loadAccountData\(\)/)
assert.doesNotMatch(viewSource, /payment SDK|支付成功|模拟成功/)
})
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { CircleDollarSign, RefreshCcw, Upload } from '@lucide/vue'
import { ElMessage, type UploadFile, type UploadFiles, type UploadInstance } from 'element-plus'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/modules/auth/public'
import {
createRechargeRequest,
getAccountLedger,
getFinanceAccount,
getRechargeChannels,
getRechargeRequests,
type AccountChangeType,
type AccountLedgerEntry,
type FinanceAccount,
type RechargeChannel,
type RechargePaymentMethod,
type RechargeRequest,
type RechargeStatus,
} from '../api/account'
const auth = useAuthStore()
const account = ref<FinanceAccount | null>(null)
const ledger = ref<AccountLedgerEntry[]>([])
const rechargeRequests = ref<RechargeRequest[]>([])
const channels = ref<RechargeChannel[]>([])
const pageLoading = ref(false)
const pageError = ref('')
const channelsError = ref('')
const ledgerPage = ref(1)
const ledgerTotal = ref(0)
const rechargePage = ref(1)
const rechargeTotal = ref(0)
const pageSize = 10
const rechargeVisible = ref(false)
const submitting = ref(false)
const submitError = ref('')
const voucherFile = ref<File | null>(null)
const voucherUpload = ref<UploadInstance>()
let pendingIdempotencyKey = ''
const form = reactive({ amount: '100.00', paymentMethod: 'WECHAT' as RechargePaymentMethod })
const quickAmounts = ['50.00', '100.00', '200.00', '500.00']
const canRecharge = computed(() => auth.hasPermission('merchant:finance:recharge:create'))
const selectedChannel = computed(() => channels.value.find((item) => item.paymentMethod === form.paymentMethod))
const channelReady = computed(() => Boolean(selectedChannel.value?.enabled && selectedChannel.value.qrCodeUrl))
const amountValid = computed(() => {
const value = Number(form.amount)
return Number.isFinite(value) && value > 0 && /^\d+(?:\.\d{1,2})?$/.test(form.amount.trim())
})
const canSubmit = computed(() => amountValid.value && channelReady.value && Boolean(voucherFile.value) && !submitting.value)
const changeLabels: Record<AccountChangeType, string> = {
RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费', CREDIT_LIMIT: '授信调整',
}
const statusLabels: Record<RechargeStatus, string> = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
const methodLabels: Record<RechargePaymentMethod, string> = { WECHAT: '微信支付', ALIPAY: '支付宝' }
function messageOf(error: unknown, fallback: string) {
return error instanceof ApiError ? error.message : fallback
}
function formatAmount(value: string | null | undefined) {
const number = Number(value ?? '0')
return Number.isFinite(number)
? number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '0.00'
}
function formatAccountAmount(value: string | null | undefined) {
return value == null ? '—' : formatAmount(value)
}
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
}
function statusType(status: RechargeStatus) {
return ({ PENDING: 'warning', APPROVED: 'success', REJECTED: 'danger' })[status] as 'warning' | 'success' | 'danger'
}
function amountClass(amount: string) {
return Number(amount) >= 0 ? 'amount-positive' : 'amount-negative'
}
async function loadAccountData() {
pageLoading.value = true
pageError.value = ''
channelsError.value = ''
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, pageSize), getRechargeRequests(rechargePage.value, pageSize)] as const
try {
const [accountResult, ledgerResult, rechargeResult] = await Promise.all(requests)
account.value = accountResult
ledger.value = ledgerResult.records
ledgerTotal.value = ledgerResult.total
rechargeRequests.value = rechargeResult.records
rechargeTotal.value = rechargeResult.total
} catch (error) {
account.value = null
ledger.value = []
rechargeRequests.value = []
ledgerTotal.value = 0
rechargeTotal.value = 0
pageError.value = messageOf(error, '资金账户加载失败,请稍后重试')
} finally {
pageLoading.value = false
}
if (canRecharge.value) {
try {
channels.value = await getRechargeChannels()
} catch (error) {
channels.value = []
channelsError.value = messageOf(error, '充值渠道加载失败,请稍后重试')
}
}
}
async function changeLedgerPage(nextPage: number) {
ledgerPage.value = nextPage
try {
const result = await getAccountLedger(nextPage, pageSize)
ledger.value = result.records
ledgerTotal.value = result.total
} catch (error) {
ElMessage.error(messageOf(error, '资金流水加载失败,请重试'))
}
}
async function changeRechargePage(nextPage: number) {
rechargePage.value = nextPage
try {
const result = await getRechargeRequests(nextPage, pageSize)
rechargeRequests.value = result.records
rechargeTotal.value = result.total
} catch (error) {
ElMessage.error(messageOf(error, '充值申请加载失败,请重试'))
}
}
function openRecharge() {
submitError.value = ''
rechargeVisible.value = true
}
function chooseAmount(amount: string) {
form.amount = amount
}
function handleVoucherChange(uploadFile: UploadFile, _uploadFiles: UploadFiles) {
const file = uploadFile.raw
if (!file) return
const extensionAllowed = /\.(?:jpe?g|png)$/i.test(file.name)
const mimeAllowed = ['image/jpeg', 'image/png'].includes(file.type)
if (!extensionAllowed || !mimeAllowed) {
voucherFile.value = null
ElMessage.error('付款截图仅支持 jpg、jpeg 或 png 图片')
return
}
voucherFile.value = file
}
function handleVoucherRemove() {
voucherFile.value = null
}
function normalizedAmount() {
return Number(form.amount).toFixed(2)
}
async function submitRecharge() {
if (submitting.value) return
submitError.value = ''
if (!amountValid.value) {
submitError.value = '请输入大于 0 且最多两位小数的充值金额'
return
}
if (!channelReady.value) {
submitError.value = `${methodLabels[form.paymentMethod]}收款二维码尚未配置,暂不能提交充值申请`
return
}
if (!voucherFile.value) {
submitError.value = '请上传付款截图'
return
}
if (!pendingIdempotencyKey) {
pendingIdempotencyKey = globalThis.crypto?.randomUUID?.() ?? `recharge-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
submitting.value = true
try {
await createRechargeRequest(normalizedAmount(), form.paymentMethod, voucherFile.value, pendingIdempotencyKey)
pendingIdempotencyKey = ''
rechargeVisible.value = false
voucherFile.value = null
voucherUpload.value?.clearFiles()
ElMessage.success('充值申请已提交,等待平台审核')
await loadAccountData()
} catch (error) {
submitError.value = messageOf(error, '充值申请提交失败,请使用当前内容重试')
} finally {
submitting.value = false
}
}
watch([() => form.amount, () => form.paymentMethod, voucherFile], () => {
if (!submitting.value) pendingIdempotencyKey = ''
submitError.value = ''
})
onMounted(() => { void loadAccountData() })
</script>
<template>
<div v-loading="pageLoading" class="finance-account-page" :aria-busy="pageLoading">
<div class="account-heading">
<div><h2>资金账户</h2><p>查看本企业 CNY 资金、授信额度、账户流水和充值申请状态。</p></div>
<el-button v-if="canRecharge" type="primary" @click="openRecharge"><CircleDollarSign :size="17" />账户充值</el-button>
</div>
<div v-if="pageError" class="account-state account-state--error" role="alert">
<p>{{ pageError }}</p><el-button @click="loadAccountData"><RefreshCcw :size="15" />重新加载</el-button>
</div>
<template v-else>
<section class="balance-grid" aria-label="账户余额">
<article><span>实际余额</span><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
<article><span>冻结金额</span><strong>¥ {{ formatAccountAmount(account?.frozenBalance) }}</strong><small>暂不可使用的资金</small></article>
<article class="balance-available"><span>总可用余额</span><strong>¥ {{ formatAccountAmount(account?.availableBalance) }}</strong><small>实际可用资金与可用授信合计</small></article>
<article><span>授信额度</span><strong>¥ {{ formatAccountAmount(account?.creditLimit) }}</strong><small>{{ account?.creditExpiresOn ? `${account.creditExpiresOn} ${account.creditActive ? '到期' : '已到期'}` : '未设置有效期' }}</small></article>
<article><span>已用授信</span><strong>¥ {{ formatAccountAmount(account?.creditUsed) }}</strong><small>已结算使用的授信</small></article>
<article><span>可用授信</span><strong>¥ {{ formatAccountAmount(account?.availableCredit) }}</strong><small>扣除已用和冻结后的授信</small></article>
</section>
<section class="account-card">
<div class="section-heading"><div><h3>账户流水</h3><p>每次资金变动均生成唯一业务单号。</p></div></div>
<el-table :data="ledger" row-key="businessNo" empty-text="暂无资金流水">
<el-table-column prop="businessNo" label="业务单号" min-width="190" />
<el-table-column label="变动类型" width="110"><template #default="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template></el-table-column>
<el-table-column label="变动金额" width="145" align="right"><template #default="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template></el-table-column>
<el-table-column label="实际余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template></el-table-column>
<el-table-column label="冻结金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template></el-table-column>
<el-table-column label="可用余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template></el-table-column>
<el-table-column label="已用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.creditUsedAfter) }}</template></el-table-column>
<el-table-column label="可用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableCreditAfter) }}</template></el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="130"><template #default="{ row }">{{ row.operatorName || '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
<el-table-column label="发生时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<div v-if="ledgerTotal" class="pagination-row"><span>共 {{ ledgerTotal }} 条</span><el-pagination :default-current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" /></div>
</section>
<section class="account-card">
<div class="section-heading"><div><h3>充值申请</h3><p>付款后上传截图,平台审核通过后资金入账。</p></div></div>
<el-table :data="rechargeRequests" row-key="requestNo" empty-text="暂无充值申请">
<el-table-column prop="requestNo" label="充值申请单号" min-width="190" />
<el-table-column label="金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column>
<el-table-column label="付款方式" width="120"><template #default="{ row }">{{ methodLabels[row.paymentMethod as RechargePaymentMethod] }}</template></el-table-column>
<el-table-column prop="voucherFileName" label="付款截图" min-width="170" />
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template></el-table-column>
<el-table-column prop="submittedByName" label="提交人" min-width="120"><template #default="{ row }">{{ row.submittedByName || '—' }}</template></el-table-column>
<el-table-column prop="reviewRemark" label="审核备注" min-width="180"><template #default="{ row }">{{ row.reviewRemark || '—' }}</template></el-table-column>
<el-table-column label="提交时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="审核时间" width="170"><template #default="{ row }">{{ formatDateTime(row.reviewedAt) }}</template></el-table-column>
</el-table>
<div v-if="rechargeTotal" class="pagination-row"><span>共 {{ rechargeTotal }} 条</span><el-pagination :default-current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" /></div>
</section>
</template>
<el-dialog v-model="rechargeVisible" title="账户充值" width="760px" :close-on-click-modal="false" :close-on-press-escape="!submitting" :show-close="!submitting" class="recharge-dialog">
<div class="recharge-form">
<label class="field-label" for="recharge-amount">充值金额</label>
<el-input id="recharge-amount" v-model="form.amount" size="large" inputmode="decimal" placeholder="请输入充值金额" aria-label="充值金额"><template #prefix>¥</template></el-input>
<div class="quick-amounts" aria-label="快捷充值金额">
<button v-for="amount in quickAmounts" :key="amount" type="button" :class="{ active: form.amount === amount }" @click="chooseAmount(amount)">¥{{ Number(amount) }}</button>
</div>
<div class="payment-methods" aria-label="付款方式">
<button v-for="method in (['WECHAT', 'ALIPAY'] as RechargePaymentMethod[])" :key="method" type="button" :class="[`payment-${method.toLowerCase()}`, { active: form.paymentMethod === method }]" @click="form.paymentMethod = method"><span>{{ method === 'WECHAT' ? '微' : '支' }}</span>{{ methodLabels[method] }}</button>
</div>
<div v-if="channelsError" class="channel-warning" role="alert">{{ channelsError }}</div>
<div v-else-if="channelReady" class="qr-panel">
<img :src="selectedChannel?.qrCodeUrl || ''" :alt="`${methodLabels[form.paymentMethod]}收款二维码`">
<p>请使用{{ methodLabels[form.paymentMethod] }}扫码完成付款</p>
</div>
<div v-else class="channel-warning" role="status">{{ methodLabels[form.paymentMethod] }}收款二维码尚未配置,暂不能提交充值申请。</div>
<div class="voucher-upload">
<span class="field-label">付款截图</span>
<el-upload ref="voucherUpload" :auto-upload="false" :limit="1" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :on-change="handleVoucherChange" :on-remove="handleVoucherRemove">
<el-button><Upload :size="16" />选择付款截图</el-button>
<template #tip><div class="el-upload__tip">仅支持 jpg、jpeg、png 图片;提交后进入平台审核。</div></template>
</el-upload>
</div>
<p v-if="submitError" class="submit-error" role="alert">{{ submitError }}</p>
</div>
<template #footer><div class="dialog-actions"><el-button :disabled="submitting" @click="rechargeVisible = false">取消</el-button><el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="submitRecharge">确认充值</el-button></div></template>
</el-dialog>
</div>
</template>
<style scoped>
.finance-account-page { display: flex; min-height: 420px; flex-direction: column; gap: 16px; }
.account-heading, .section-heading, .pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.account-heading h2, .section-heading h3 { margin: 0; color: var(--navy); }.account-heading h2 { font-size: 22px; }.section-heading h3 { font-size: 16px; }
.account-heading p, .section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }.account-heading .el-button { display: inline-flex; align-items: center; gap: 6px; }
.balance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.balance-grid article { display: flex; min-height: 132px; flex-direction: column; padding: 22px 24px; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
.balance-grid span { color: var(--muted); font-size: 13px; }.balance-grid strong { margin-top: 14px; color: var(--navy); font-size: 28px; line-height: 1; }.balance-grid small { margin-top: auto; color: #8a98a8; }.balance-grid .balance-available { border-color: #b9e1db; background: #f3faf8; }.balance-grid .balance-available strong { color: #058b78; }
.account-card { min-width: 0; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: #fff; }.section-heading { margin-bottom: 14px; }
.pagination-row { padding-top: 14px; color: var(--muted); font-size: 12px; }.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
.account-state { display: flex; min-height: 240px; align-items: center; justify-content: center; flex-direction: column; gap: 12px; border-radius: 8px; }.account-state--error { border: 1px solid #f5c2bd; background: #fff8f7; color: #b42318; }.account-state p { margin: 0; }
.recharge-form { display: flex; flex-direction: column; gap: 16px; }.field-label { color: #4e6075; font-size: 14px; font-weight: 650; }
.quick-amounts, .payment-methods { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.quick-amounts button, .payment-methods button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active, .payment-methods button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
.payment-methods { grid-template-columns: repeat(2, 1fr); }.payment-methods button { display: flex; align-items: center; justify-content: center; gap: 10px; font-size: 17px; font-weight: 650; }.payment-methods span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: #1685ef; color: #fff; font-size: 14px; }.payment-wechat span { background: #09ae42; }
.qr-panel { display: flex; align-items: center; flex-direction: column; gap: 14px; padding: 22px; border: 1px dashed #bed0e4; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel p { margin: 0; color: #62758d; }
.channel-warning { padding: 24px; border: 1px solid #f0d39b; border-radius: 10px; background: #fff9ed; color: #8b5b12; text-align: center; }.voucher-upload { display: grid; gap: 10px; }.voucher-upload :deep(.el-button) { display: inline-flex; align-items: center; gap: 6px; }.submit-error { margin: 0; color: #b42318; font-size: 13px; }.dialog-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }.dialog-actions .el-button { min-height: 46px; margin: 0; }
</style>
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import ts from 'typescript'
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const viewSource = readFileSync(new URL('./FinanceBillsView.vue', import.meta.url), 'utf8')
const apiSource = readFileSync(new URL('../api/finance.ts', import.meta.url), 'utf8')
const adapterSource = readFileSync(new URL('../api/adapters.ts', import.meta.url), 'utf8')
const permissionSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
async function importTypeScriptModule(url) {
const source = readFileSync(url, 'utf8')
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
return import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
}
const financeAdapters = await importTypeScriptModule(new URL('../api/adapters.ts', import.meta.url))
const permissionContracts = await importTypeScriptModule(new URL('../../tenant-management/contracts.ts', import.meta.url))
test('activates the customer finance route with its existing list and detail permission', () => {
assert.match(routesSource, /path: 'finance\/bills'/)
assert.match(routesSource, /FinanceBillsView\.vue/)
assert.match(routesSource, /release: 'active'/)
assert.match(routesSource, /permission: 'finance:bill:view'/)
assert.doesNotMatch(routesSource, /finance\/payables|应付账单/)
})
test('uses authenticated-scope finance APIs and confirms without browser-selected customer scope', () => {
assert.match(apiSource, /method: 'GET'/)
assert.match(apiSource, /\/client\/finance\/v1\/bills/)
assert.match(apiSource, /\/client\/finance\/v1\/receipts/)
assert.doesNotMatch(apiSource, /\/merchant\/v1\/finance/)
assert.doesNotMatch(apiSource, /customerId|tenantId/)
assert.match(apiSource, /method: 'POST'/)
assert.match(apiSource, /\/client\/finance\/v1\/statements\/\$\{encodeURIComponent\(statementId\)\}\/confirm/)
assert.match(apiSource, /'Idempotency-Key'/)
assert.match(apiSource, /data: \{ expectedVersion \}/)
assert.doesNotMatch(apiSource, /method: '(PUT|PATCH|DELETE)'/)
assert.doesNotMatch(apiSource, /params:\s*\{\s*billId/)
})
test('adapts the Finance Java DTO contract and filters nested receipt allocations by bill id', () => {
for (const field of ['billNo', 'customerName', 'billDate', 'totalAmount', 'outstandingAmount', 'feeItemName', 'orderId', 'receiptNo', 'allocations', 'billId', 'appliedAmount']) {
assert.match(adapterSource, new RegExp(field))
}
assert.match(adapterSource, /flatMap\(\(receipt\).*receipt\.allocations/s)
assert.match(adapterSource, /detail\.statement/)
assert.match(adapterSource, /detail\.lines/)
assert.doesNotMatch(adapterSource, /bill_no|customer_name|bill_date|total_amount|outstanding_amount|receipt_no|bill_id|applied_amount/)
assert.doesNotMatch(adapterSource, /receipt\.billId|receipt\.appliedAmount/)
assert.doesNotMatch(adapterSource, /voucherNo|voucher_no/)
assert.match(viewSource, /loadDetail\(row\.id\)/)
assert.doesNotMatch(viewSource, /loadDetail\(row\.billNo\)/)
})
test('does not expose draft or voided bills returned by the server', () => {
const bill = (id, status) => ({
id,
customerId: 10001,
billNo: `B-${id}`,
customerName: '测试客户',
currency: 'CNY',
billDate: '2026-08-11',
dueDate: '2026-09-10',
status,
totalAmount: '100.00',
receivedAmount: '0.00',
outstandingAmount: '100.00',
version: 1,
customerConfirmationStatus: 'PENDING_CONFIRMATION',
customerConfirmedByName: null,
customerConfirmedAt: null,
})
const visible = financeAdapters.mapFinanceBills([
bill(1, 'DRAFT'),
bill(2, 'ISSUED'),
bill(3, 'PARTIALLY_PAID'),
bill(4, 'PAID'),
bill(5, 'VOIDED'),
])
assert.deepEqual(visible.map((item) => item.status), ['ISSUED', 'PARTIALLY_PAID', 'PAID'])
})
test('maps nested bill detail and customer-safe receipt allocations', () => {
const detail = financeAdapters.mapFinanceBillDetail({
statement: {
id: 2, customerId: 10001, billNo: 'B-2', customerName: '测试客户', currency: 'CNY',
billDate: '2026-08-11', dueDate: '2026-09-10', status: 'ISSUED', totalAmount: '100.00',
receivedAmount: '40.00', outstandingAmount: '60.00',
version: 3, customerConfirmationStatus: 'PENDING_CONFIRMATION', customerConfirmedByName: null, customerConfirmedAt: null,
},
lines: [{ id: 21, sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, orderId: 31, inboundId: 41, inboundCode: 'NYEXX2608180003', feeItemCode: 'FREIGHT', feeItemName: '运费', amount: '100.00', currency: 'CNY' }],
})
const receipts = financeAdapters.mapFinanceReceipts([{
id: 51, receiptNo: 'RCPT-51', receivedAmount: '40.00', receivedAt: '2026-08-12T10:00:00',
paymentMethod: 'BANK_TRANSFER', currency: 'CNY', status: 'CONFIRMED',
allocations: [{ billId: 2, billNo: 'B-2', appliedAmount: '40.00' }],
}], '2')
assert.equal(detail.billNo, 'B-2')
assert.deepEqual(detail.lines[0], { id: '21', orderId: '31', inboundCode: 'NYEXX2608180003', feeNo: 'INBOUND_FEE:81/v3', feeName: '运费', amount: '100.00', currencyCode: 'CNY' })
assert.deepEqual(receipts[0], { id: '51', receiptNo: 'RCPT-51', receivedAt: '2026-08-12T10:00:00', amount: '40.00', currencyCode: 'CNY', method: 'BANK_TRANSFER', allocatedAmount: '40.00' })
})
test('requires both finance statement authorities for the page permission', () => {
const allowed = (codes) => permissionContracts.hasMappedMerchantPermission('finance:bill:view', new Set(codes))
assert.equal(allowed([]), false)
assert.equal(allowed(['merchant:finance:statement:list']), false)
assert.equal(allowed(['merchant:finance:statement:view']), false)
assert.equal(allowed(['merchant:finance:statement:list', 'merchant:finance:statement:view']), true)
})
test('keeps actions first and exposes list, balance, detail, confirmation, receipt, empty and error states', () => {
const actionIndex = viewSource.indexOf('label="操作"')
const billNumberIndex = viewSource.indexOf('label="账单编号"')
assert.ok(actionIndex >= 0 && actionIndex < billNumberIndex)
for (const expected of ['未收余额', '账单详情', '确认账单', '客户确认信息', '收款记录', '暂无账单', '暂无收款记录', 'state-panel--error']) {
assert.match(viewSource, new RegExp(expected))
}
assert.match(viewSource, /BANK_TRANSFER: '银行转账'/)
assert.match(viewSource, /paymentMethodLabel\(row\.method\)/)
assert.match(viewSource, /label="入仓码"/)
assert.match(viewSource, /openInboundAssociation\(row\.inboundCode\)/)
assert.match(viewSource, /InboundCodeAssociationDialog/)
assert.match(viewSource, /:inbound-code="inboundAssociationCode"/)
assert.match(viewSource, /auth\.hasPermission\('finance:bill:confirm'\)/)
assert.match(viewSource, /row\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(viewSource, /size="small" type="success" plain/)
assert.match(viewSource, /confirmStatement\(row as FinanceBillSummary\)/)
assert.match(viewSource, /detail\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(viewSource, /confirmStatement\(detail\)/)
assert.match(viewSource, /confirmMerchantFinanceStatement\(statementId, statement\.version, key\)/)
assert.match(viewSource, /ElMessage\.success\('对账单已确认'\)/)
assert.match(viewSource, /ElMessage\.error\(error instanceof ApiError \? error\.message : '对账单确认失败,请重试'\)/)
for (const forbidden of ['发起争议', '新增收款', '核销', '修改金额']) {
assert.doesNotMatch(viewSource, new RegExp(forbidden))
}
})
test('keeps the existing customer finance view permission mapping', () => {
assert.match(permissionSource, /code: 'finance:bill:view'/)
assert.match(permissionSource, /merchant:finance:statement:list/)
assert.match(permissionSource, /merchant:finance:statement:view/)
assert.match(permissionSource, /FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES/)
assert.match(permissionSource, /code: 'finance:bill:confirm'/)
assert.match(permissionSource, /'finance:bill:confirm': \['merchant:finance:statement:confirm'\]/)
})
test('grants the customer confirmation action only from its independent backend authority', () => {
const allowed = (codes) => permissionContracts.hasMappedMerchantPermission('finance:bill:confirm', new Set(codes))
assert.equal(allowed([]), false)
assert.equal(allowed(['merchant:finance:statement:list', 'merchant:finance:statement:view']), false)
assert.equal(allowed(['merchant:finance:statement:confirm']), true)
})

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