feat: 自动触发公式引擎、查价分组运输方式与销售产品预览
新增自动触发中文公式编辑/测试、查价运输方式 Tab 分组选择,并完善销售产品字段预览与菜单展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6442a2d83f
commit
67cf30b6e8
@ -214,6 +214,7 @@
|
||||
"useMediaQuery": true,
|
||||
"useMemoize": true,
|
||||
"useMemory": true,
|
||||
"useMenuDisplay": true,
|
||||
"useMessageStore": true,
|
||||
"useModel": true,
|
||||
"useMounted": true,
|
||||
|
||||
@ -2351,7 +2351,7 @@ JWT → mapId(user.userid INT)
|
||||
- **发布记录**:买价与规则的发布历史,支持查看版本内容、回滚到指定版本。
|
||||
- **黑名单区/偏远分区/价格分区**:共用 KpiArea 表,仅通过 `AreaType` 区分;支持分区及明细(国家/省/市/邮编区间)的增删改查。
|
||||
- **计重规则/计重进位**:配置计费重量规则与进位方式,明细结构与分区类似。
|
||||
- **自动触发**:配置下单/入库/出库时的自动拦截、警告、问题件等触发规则。
|
||||
- **自动触发**:配置下单/入库/出库时的自动拦截、警告、问题件等触发规则;**V2 目标**为订单校验规则引擎(结构化条件 + 全字段 + 预报同步拦截),见 **§5.3.0.6.1**。
|
||||
- **问题模板**:按组织维护问题件话术与通知方式,供标记问题件与自动触发消费(一期 CRUD)。
|
||||
- **标签设计**:保存 vg-print 导出的标签模板 JSON,支持尾程/客户/入库/出库/sku 等类型。
|
||||
|
||||
@ -3028,7 +3028,368 @@ JWT → mapId(user.userid INT)
|
||||
| `POST .../delete` | 软删 |
|
||||
| `POST .../toggleStatus` | 更新 `Status` |
|
||||
|
||||
> **运行时触发引擎**(订单/仓储事件消费规则)**尚未在 oms0414-back 实现**;当前仅为规则 CRUD 落库。
|
||||
> **运行时(现状)**:`AutoTriggerRuntimeService` **已部分落地**(见 **§9.2.5**):预报成功 / 入库 / 出库后异步执行;条件匹配器仅支持 **关键字子串** 与 **少量 JSON 等值**,**不支持** 表达式、重量尺寸、客户名称等字段;**拦截** 为预报成功后写 `IsIntercept=1`,**不能阻止下单**。
|
||||
>
|
||||
> **目标(V2 设计稿)**:见 **§5.3.0.6.1**,将自动触发升级为 **订单校验与限制规则引擎**——结构化条件、全量订单字段、比较/集合/逻辑运算符;**预报时机 + 拦截** 在提交前 **同步拒绝** 并返回 `promptMessage`。
|
||||
|
||||
##### 5.3.0.6.1 自动触发规则引擎 V2(设计稿 · 订单校验与限制)
|
||||
|
||||
> **状态**:设计稿(2026-07-09),**尚未开发**。本节为 `/channel/autoTrigger` 页面与 `AutoTriggerRuntimeService` 的下一版目标设计,服务于 **订单校验、限制下单、自动预警、自动问题件**。
|
||||
>
|
||||
> **业务目标**:运营可在渠道模块配置规则,在 **预报 / 入库 / 出库** 等节点,按 **订单全量相关字段**(主表、件重尺、收发件人、申报内件、客户、产品等)组合条件;动作为 **拦截(禁止继续)**、**警告(通知)**、**问题件(走问题模板)**。
|
||||
|
||||
###### 1)现状与差距
|
||||
|
||||
| 维度 | 现状(代码) | V2 目标 |
|
||||
| --- | --- | --- |
|
||||
| 配置页 | 触发时机/条件为 **纯文本**;操作三选一硬编码 | 时机/操作走字典;**可视化条件构建器**(字段 + 运算符 + 值) |
|
||||
| 条件语法 | 关键字子串;JSON 仅 `orderStatus/productCode/channelCode/keyword` 等值 | **结构化 AST**(AND/OR/NOT 分组 + 比较表达式) |
|
||||
| 可用字段 | 运单号、客户单号、跟踪号、产品/渠道编码 | **订单域全字段**(见 §5.3.0.6.1.4) |
|
||||
| 运算符 | 无(仅 contains 式关键字) | `>`、`>=`、`<`、`<=`、`=`、`!=`、`between`、`in`、`notIn`、`contains`、`notContains`、`isEmpty`… |
|
||||
| 预报 + 拦截 | 预报 **成功后** 写 `IsIntercept=1` | **预报提交前同步校验**;命中 **拦截** → **拒绝下单/预报**,返回 `promptMessage` |
|
||||
| 尺寸/重量 | 未读取 `t_order_weight`、内件 | 支持 **按件重尺类型**(forecast/receive/ship/downstream)及 **内件聚合** |
|
||||
| 客户 | 未参与匹配 | 支持 `customer.name` / `customer.code` / `customer.group` 等 |
|
||||
| 规则优先级 | 全部启用规则依次执行 | 增加 `Priority`(数字越小越先);**拦截** 命中即短路 |
|
||||
|
||||
###### 2)核心概念
|
||||
|
||||
**触发时机(TriggerTiming)** — 字典 `auto_trigger_timing`(扩展):
|
||||
|
||||
| dictCode | 名称 | 挂钩点 | 执行方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| `forecast` | 预报 | `POST /order/parcel/create`、批量下单、Excel 导入(均走 `CreateParcelCoreAsync`) | **同步**(提交前) |
|
||||
| `inbound` | 入库 | `POST /operation/inbound/create` | 异步(扫描成功后) |
|
||||
| `outbound` | 出库 | `POST /operation/outbound/create` | 异步(扫描成功后) |
|
||||
|
||||
> 兼容:旧数据 `下单`/`order_create`/`预报` 等文本,运行时归一化为 `forecast`。
|
||||
|
||||
**操作(Operation)** — 字典 `auto_trigger_operation`:
|
||||
|
||||
| dictCode | 名称 | 预报时机行为 | 入库/出库时机行为 |
|
||||
| --- | --- | --- | --- |
|
||||
| `intercept` | 拦截 | **拒绝**本次下单/预报,`HTTP` 业务错误文案 = `PromptMessage` | 订单已存在 → 设 `IsIntercept=1` |
|
||||
| `warn` | 警告 | 允许成功,发站内信(+ 模板占位符) | 同左 |
|
||||
| `problem` | 问题件 | 允许成功,按 `issueType` 匹配问题模板并标记 | 同左 |
|
||||
|
||||
**两类执行阶段**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[请求进入] --> B{时机=forecast?}
|
||||
B -->|是| C[PreCommit 同步评估]
|
||||
C -->|命中 intercept| D[抛 BusinessException\nPromptMessage]
|
||||
C -->|未命中或 warn/problem| E[继续预报主流程]
|
||||
E --> F[预报成功]
|
||||
F --> G[PostCommit 异步评估\nwarn / problem / 入库出库 intercept]
|
||||
```
|
||||
|
||||
- **PreCommit(同步校验)**:仅 `forecast`;用于 **拦截下单**;必须在 `OrderRepository.InsertAsync` **之前**(或至少在写 `predeclared` 之前)调用。
|
||||
- **PostCommit(异步处置)**:现有 `AutoTriggerRuntimeService.FireAsync` 演进;用于警告、问题件、以及入库/出库拦截标记。
|
||||
|
||||
###### 3)示例规则(超尺寸 · 英国 · 指定客户 · 排除产品)
|
||||
|
||||
**业务描述**(运营可读):
|
||||
|
||||
- **触发时机**:预报
|
||||
- **触发条件**:
|
||||
- (单件最大长度 > 60 **或** 单件最大宽度 > 46 **或** 单件长+宽+高 > 150)
|
||||
- **且** 收件国家包含 `GB`
|
||||
- **且** 客户名称包含 `阿里国际站/深圳一达通`
|
||||
- **且** 销售产品 **不包含** `EX_ASP_HKDHL_YD`
|
||||
- **操作**:拦截
|
||||
- **提示语**:`{serviceNo}超尺寸,禁止下单`(预报前尚无运单号时可用固定文案:`超尺寸,禁止下单`)
|
||||
|
||||
**结构化条件(`TriggerCondition` JSON · version=2)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"logic": "and",
|
||||
"children": [
|
||||
{
|
||||
"logic": "or",
|
||||
"children": [
|
||||
{ "field": "piece.maxLength", "op": "gt", "value": 60 },
|
||||
{ "field": "piece.maxWidth", "op": "gt", "value": 46 },
|
||||
{ "field": "piece.sumLwh", "op": "gt", "value": 150 }
|
||||
]
|
||||
},
|
||||
{ "field": "receiver.countryCode", "op": "in", "value": ["GB"] },
|
||||
{ "field": "customer.name", "op": "contains", "value": "阿里国际站/深圳一达通" },
|
||||
{ "field": "productCode", "op": "notIn", "value": ["EX_ASP_HKDHL_YD"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段取值说明(本例)**:
|
||||
|
||||
| 字段 token | 含义 | 数据来源(预报 PreCommit) |
|
||||
| --- | --- | --- |
|
||||
| `piece.maxLength` | 单件最大长度(cm) | `max(包裹预报长宽高, 各内件对应尺寸)`;单包裹场景 = `input.Length` |
|
||||
| `piece.maxWidth` | 单件最大宽度(cm) | 同上 → `input.Width` |
|
||||
| `piece.sumLwh` | 单件长+宽+高(cm) | `Length + Width + Height`(按件取 max 后再比) |
|
||||
| `receiver.countryCode` | 收件国家二字码 | `input.Receiver.CountryCode` 归一化后 |
|
||||
| `customer.name` | 客户名称 | `t_customer.CustomerName`(`input.CustomerId` 解析) |
|
||||
| `productCode` | 销售产品编码 | `input.ProductCode` |
|
||||
|
||||
###### 4)条件表达式模型
|
||||
|
||||
**叶子节点(比较)**:
|
||||
|
||||
```json
|
||||
{ "field": "forecast.chargeableWeight", "op": "gt", "value": 30 }
|
||||
```
|
||||
|
||||
| 属性 | 说明 |
|
||||
| --- | --- |
|
||||
| `field` | 字段 token,见 §5.3.0.6.1.5 |
|
||||
| `op` | 运算符 code,见 §5.3.0.6.1.6 |
|
||||
| `value` | 标量 / 数组 / 区间 `{ "min": 1, "max": 10 }`(`between`) |
|
||||
|
||||
**分组节点**:
|
||||
|
||||
```json
|
||||
{ "logic": "and|or", "children": [ /* 叶子或分组 */ ] }
|
||||
```
|
||||
|
||||
**否定(可选)**:
|
||||
|
||||
```json
|
||||
{ "logic": "not", "children": [ { "field": "...", "op": "...", "value": "..." } ] }
|
||||
```
|
||||
|
||||
**存储**:继续复用 `t_auto_trigger.TriggerCondition`(TEXT),内容为 **JSON**;`version: 2` 表示新引擎。`version` 缺失或 `version: 1`/纯文本 → 走 **兼容适配器**(映射到现有关键字匹配器,逐步迁移)。
|
||||
|
||||
###### 5)运算符一览
|
||||
|
||||
| op | 名称 | 适用类型 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `eq` | 等于 | 字符串/数字/布尔 | |
|
||||
| `ne` | 不等于 | 同上 | |
|
||||
| `gt` | 大于 | 数字 | |
|
||||
| `gte` | 大于等于 | 数字 | |
|
||||
| `lt` | 小于 | 数字 | |
|
||||
| `lte` | 小于等于 | 数字 | |
|
||||
| `between` | 区间内 | 数字 | `value: { min, max }` 闭区间 |
|
||||
| `in` | 在集合中 | 字符串/数字 | `value: []` |
|
||||
| `notIn` | 不在集合中 | 字符串/数字 | |
|
||||
| `contains` | 包含 | 字符串 | 子串,忽略大小写 |
|
||||
| `notContains` | 不包含 | 字符串 | |
|
||||
| `startsWith` | 前缀 | 字符串 | |
|
||||
| `endsWith` | 后缀 | 字符串 | |
|
||||
| `isEmpty` | 为空 | 任意 | 无需 value |
|
||||
| `isNotEmpty` | 不为空 | 任意 | |
|
||||
|
||||
> 页面按字段 **值类型** 过滤可选运算符(如数字字段不展示 `contains`)。
|
||||
|
||||
###### 6)订单相关字段目录(规则引擎可用 token)
|
||||
|
||||
> 下列为 **V2 一期** 拟支持字段;页面左侧按分组展示,右侧选运算符与值。带 `[]` 表示内件聚合。
|
||||
|
||||
**A. 订单主表 `order.*`**
|
||||
|
||||
| token | 说明 |
|
||||
| --- | --- |
|
||||
| `order.serviceNo` | 运单号 |
|
||||
| `order.clientNo` | 客户单号 |
|
||||
| `order.trackNo` | 跟踪号 |
|
||||
| `order.productCode` | 销售产品编码 |
|
||||
| `order.channelCode` | 服务渠道编码 |
|
||||
| `order.orderStatus` | 订单状态 |
|
||||
| `order.cargoAttribute` | 货物属性 |
|
||||
| `order.exportCustomsMode` | 出口报关方式 |
|
||||
| `order.warehouseId` | 仓库 ID |
|
||||
| `order.quantity` | 件数 |
|
||||
| `order.declaredCurrency` / `order.declaredSum` | 申报币种/总金额 |
|
||||
| `order.isCod` / `order.codCurrency` / `order.codSum` | COD |
|
||||
| `order.isIntercept` / `order.isRemote` / `order.isPaid` | 标志位 |
|
||||
| `order.consigneeCountry` / `order.consigneeCountryName` | 收件国家码/名 |
|
||||
| `order.salesProductRulesVersion` / `order.salesProductPriceVersion` | 绑定版本 |
|
||||
| `order.predeclareFailReason` | 预报失败原因 |
|
||||
|
||||
**B. 件重尺 `forecast.*` / `receive.*` / `ship.*` / `downstream.*`**
|
||||
|
||||
| token | 说明 |
|
||||
| --- | --- |
|
||||
| `{type}.length` / `width` / `height` | 长/宽/高(cm) |
|
||||
| `{type}.actualWeight` | 实重(kg) |
|
||||
| `{type}.volumetricWeight` | 体积重(kg) |
|
||||
| `{type}.chargeableWeight` | 计费重(kg) |
|
||||
| `{type}.cubic` | 体积 |
|
||||
| `{type}.sumLwh` | 长+宽+高 |
|
||||
|
||||
`{type}` ∈ `forecast`(预报,**PreCommit 默认**)、`receive`(收货)、`ship`(发货)、`downstream`(下游)。
|
||||
|
||||
**C. 包裹/单件聚合 `piece.*`(预报常用)**
|
||||
|
||||
| token | 说明 |
|
||||
| --- | --- |
|
||||
| `piece.maxLength` / `piece.maxWidth` / `piece.maxHeight` | 单件最大长宽高 |
|
||||
| `piece.sumLwh` | 单件长+宽+高(对当前包裹) |
|
||||
| `piece.maxChargeableWeight` | 单件最大计费重 |
|
||||
|
||||
**D. 收件人 `receiver.*` → `t_order_recipient` / 下单入参**
|
||||
|
||||
`recipientsName`, `companyName`, `phone`, `telePhone`, `email`, `countryCode`, `province`, `city`, `zipCode`, `streetNumber`, `streetAddress1~3`, `taxType`, `taxNo`, `certificateType`, `certificateNo`, `eori`, `vat`
|
||||
|
||||
**E. 发件人 `sender.*` → `t_order_sender` / 下单入参**
|
||||
|
||||
`senderName`, `company`, `phone`, `telePhone`, `email`, `countryCode`, `province`, `city`, `zipCode`, `streetAddress1~3`, `houseNumber`
|
||||
|
||||
**F. 客户 `customer.*` → `t_customer`**
|
||||
|
||||
| token | 说明 |
|
||||
| --- | --- |
|
||||
| `customer.id` | 客户 ID |
|
||||
| `customer.code` | 客户编码 |
|
||||
| `customer.name` | 客户名称(**本例「客户包含」**) |
|
||||
| `customer.group` | 客户组 |
|
||||
| `customer.phone` / `customer.email` | 联系方式 |
|
||||
| `customer.salesman` / `customer.customerService` | 销售/客服 |
|
||||
|
||||
**G. 销售产品 `product.*` → `t_sales_product`(按 `productCode` 关联)**
|
||||
|
||||
`product.code`, `product.name`, `product.group`, `product.minTimeliness`, `product.maxTimeliness`
|
||||
|
||||
**H. 申报内件聚合 `items.*`**
|
||||
|
||||
| token | 说明 |
|
||||
| --- | --- |
|
||||
| `items.count` | 内件行数 |
|
||||
| `items.totalQuantity` | 数量合计 |
|
||||
| `items.totalWeight` / `items.totalAmount` | 重量/金额合计 |
|
||||
| `items.maxUnitWeight` | 最大单重 |
|
||||
| `items.anyCnName` / `items.anyEnName` | 任一品名包含(需配合 `contains`) |
|
||||
| `items.anySku` / `items.anyHsCode` | SKU/税号 |
|
||||
| `items.anyIsMagnetic` | 是否存在带磁内件 |
|
||||
|
||||
**I. 费用快照(二期,可选)**
|
||||
|
||||
`fee.receivable.total` / `fee.payable.total` — 当前阶段应收/应付合计。
|
||||
|
||||
###### 7)提示语占位符
|
||||
|
||||
`PromptMessage` 支持 `IssuePlaceholderRenderer` 同款占位符,另增尺寸类:
|
||||
|
||||
| 占位符 | 说明 |
|
||||
| --- | --- |
|
||||
| `{serviceNo}` / `{运单号}` | 运单号(PreCommit 未生成时可空) |
|
||||
| `{clientNo}` / `{客户单号}` | 客户单号 |
|
||||
| `{productCode}` / `{产品代码}` | 销售产品 |
|
||||
| `{receiverCountry}` / `{收件国家}` | 国家码 |
|
||||
| `{pieceMaxLength}` 等 | 触发时计算的聚合值 |
|
||||
|
||||
本例落地文案:**`超尺寸,禁止下单`** 或 **`{clientNo}超尺寸,禁止下单`**。
|
||||
|
||||
###### 8)配置页 `/channel/autoTrigger` UI 设计
|
||||
|
||||
**列表页(保持 TablePro/Handsontable)**,列增加:`规则名称`、`优先级`、条件摘要(人类可读)、启用状态。
|
||||
|
||||
**新增/编辑弹窗**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 规则名称 * [ 英国阿里超尺寸拦截 ] │
|
||||
│ 状态 (•) 启用 ( ) 禁用 │
|
||||
│ 触发时机 * [ 预报 ▼ ] (字典 auto_trigger_timing)│
|
||||
│ 操作 * [ 拦截 ▼ ] (字典 auto_trigger_operation)│
|
||||
│ 优先级 [ 100 ] 数字越小越先执行 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 触发条件 * [+ 添加条件组] │
|
||||
│ ┌ 满足 [全部 AND ▼] ─────────────────────────────────┐ │
|
||||
│ │ ┌ 满足 [任一 OR ▼] ───────────────────────────────┐ │ │
|
||||
│ │ │ [单件最大长度 ▼] [大于 ▼] [ 60 ] [删除] │ │ │
|
||||
│ │ │ [单件最大宽度 ▼] [大于 ▼] [ 46 ] [删除] │ │ │
|
||||
│ │ │ [单件长+宽+高 ▼] [大于 ▼] [ 150 ] [删除] │ │ │
|
||||
│ │ └ [+ 添加条件] │ │ │
|
||||
│ │ [收件国家 ▼] [在列表中 ▼] [ GB (+) ] [删除]│ │
|
||||
│ │ [客户名称 ▼] [包含 ▼] [ 阿里国际站/深圳一达通 ] │ │
|
||||
│ │ [销售产品 ▼] [不在列表 ▼] [ EX_ASP_HKDHL_YD ] │ │
|
||||
│ └ [+ 添加条件组] │ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 提示语 * [ 超尺寸,禁止下单 ] │
|
||||
│ 通知人 [ 用户多选,仅警告/问题件 ] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**交互要点**:
|
||||
|
||||
- 字段下拉分组:订单 / 件重尺 / 收件人 / 发件人 / 客户 / 产品 / 内件聚合。
|
||||
- 运算符随字段类型变化;`in`/`notIn` 用多选或标签输入。
|
||||
- 底部 **「预览 JSON」** 只读(给研发调试);运营默认不展开。
|
||||
- 保存时前端 `condition` 序列化为 §4 JSON,`TriggerTiming`/`Operation` 存 **dictCode**(`forecast`/`intercept`)。
|
||||
|
||||
###### 9)表结构与接口变更(建议)
|
||||
|
||||
**`t_auto_trigger` 增字段(迁移脚本)**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `RuleName` | VARCHAR(128) | 规则名称 |
|
||||
| `Priority` | INT | 优先级,默认 100 |
|
||||
| `ConditionVersion` | TINYINT | 条件版本,默认 2 |
|
||||
|
||||
**新增接口(可选)**:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/channel/auto-trigger/meta` | 返回字段目录、运算符、时机/操作字典(供构建器) |
|
||||
| POST | `/channel/auto-trigger/test` | 传入样例订单 JSON + 规则 ID,返回是否命中(运营调试) |
|
||||
|
||||
CRUD 入参/出参增加 `ruleName`、`priority`;`triggerCondition` 存 JSON。
|
||||
|
||||
###### 10)后端评估器设计
|
||||
|
||||
**新组件**:
|
||||
|
||||
| 类 | 职责 |
|
||||
| --- | --- |
|
||||
| `OrderRuleEvaluationContext` | 聚合 `OrderParcelCreateInput`(PreCommit)或 `Order`+子表(PostCommit) |
|
||||
| `OrderRuleFieldResolver` | token → 强类型值 |
|
||||
| `OrderRuleConditionEvaluator` | 解析 JSON AST,递归求值 |
|
||||
| `OrderAutoTriggerService` | 加载启用规则 → 排序 → PreCommit/PostCommit 分流 |
|
||||
|
||||
**PreCommit 调用点**(`OrderService.ParcelBilling.CreateParcelCoreAsync`):
|
||||
|
||||
```
|
||||
ValidateParcelCreateInputAsync
|
||||
→ 销售产品/黑名单等硬编码校验
|
||||
→ OrderAutoTriggerService.EvaluatePreCommitAsync(input, timing=forecast) // 新增
|
||||
→ InsertAsync(order) ...
|
||||
→ ... 预报成功 ...
|
||||
→ OrderAutoTriggerService.EvaluatePostCommitAsync(order, timing=forecast) // 替代原 FireAsync 或内部转调
|
||||
```
|
||||
|
||||
**拦截短路**:PreCommit 命中 `intercept` → `throw new BusinessException(promptMessage)`;批量下单逐行失败,错误写入任务明细。
|
||||
|
||||
**PostCommit**:保留警告/问题件/入库出库拦截标记;失败 **仅记日志**,不影响主流程(与现网一致)。
|
||||
|
||||
###### 11)与硬编码校验的关系
|
||||
|
||||
| 机制 | 定位 |
|
||||
| --- | --- |
|
||||
| 代码内校验(产品存在、黑名单、分区匹配等) | **系统不变量**,不可由运营关闭 |
|
||||
| 自动触发 V2 | **可配置业务规则**,运营可启停;优先级低于安全类硬编码 |
|
||||
|
||||
黑名单(KPI 分区)与自动触发 **并存**;均命中时 **先执行的** 抛出错误(PreCommit 顺序:硬编码 → 自动触发按 `Priority`)。
|
||||
|
||||
###### 12)实施分期
|
||||
|
||||
| 阶段 | 范围 |
|
||||
| --- | --- |
|
||||
| **P0** | 表字段 + 条件 JSON 存储 + `OrderRuleConditionEvaluator` + 预报 PreCommit 拦截 + 字段目录 API |
|
||||
| **P1** | 配置页可视化构建器 + 字典化时机/操作 + 本例超尺寸规则可配置 |
|
||||
| **P2** | 入库/出库字段(`receive.*`/`ship.*`)+ 测试接口 + 条件人类可读摘要 |
|
||||
| **P3** | 费用字段、正则、规则命中审计日志 `t_auto_trigger_hit_log` |
|
||||
|
||||
###### 13)验收标准(本例)
|
||||
|
||||
1. 在 `/channel/autoTrigger` 配置 §3 示例规则并启用。
|
||||
2. 预报一单:GB、客户名含「阿里国际站/深圳一达通」、尺寸 61×40×40、产品非 `EX_ASP_HKDHL_YD` → **接口失败**,文案「超尺寸,禁止下单」,**不产生** `predeclared` 订单。
|
||||
3. 将产品改为 `EX_ASP_HKDHL_YD` → **放行**。
|
||||
4. 尺寸改为 50×40×40 → **放行**。
|
||||
|
||||
---
|
||||
|
||||
##### 5.3.0.7 问题模板(`IssueTemplateService`)
|
||||
|
||||
@ -3058,7 +3419,7 @@ JWT → mapId(user.userid INT)
|
||||
| 发布后同步 Redis + 刷新计费内存 | **未实现**;下单读 DB 发布表或 Live 组装(§4.3.5) |
|
||||
| 发布时旧版本 `Status` 置失效 | **未显式**更新历史记录状态;算价按 **`PublishTime <= bindTime` 取最新一条** |
|
||||
| 服务渠道 `publish/preview` | 与卖价对称 | **已落地**:`GET /channel/service-channel/publish/preview` |
|
||||
| 自动触发执行 | **无**后台消费者 |
|
||||
| 自动触发执行 | **部分落地** | `AutoTriggerRuntimeService` 已挂钩;**V2 规则引擎见 §5.3.0.6.1** |
|
||||
|
||||
#### 5.3.1 销售产品
|
||||
|
||||
@ -7477,7 +7838,7 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20
|
||||
| `/operation/outbound/create` | 出库、outbound、ship、发货 |
|
||||
|
||||
- **问题件**:按 `triggerCondition` 解析 `issueType`(支持 JSON `{"issueType":"address",...}` 或纯 code),匹配 **问题模板** 后自动标记并通知。
|
||||
- **拦截**:`IsIntercept=1`。
|
||||
- **拦截**:`IsIntercept=1`(**事后标记**;**不能阻止预报**,预报同步拦截见 **§5.3.0.6.1**)。
|
||||
- **警告**:仅发站内信(不改编订单状态)。
|
||||
|
||||
### 9.3 20260627-02 差异明细(规则快照下游元数据 + 订单绑定)
|
||||
@ -7650,7 +8011,7 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20
|
||||
| 入库 / 出库 | 触发 §4.3.4 分阶段算价 | **仅**作业记录表 |
|
||||
| 管理员订单列表 | §4.4.0 客户门户 | **管理员不过滤** `CustomerId` |
|
||||
| 渠道发布后 Redis 同步 | 写 Redis + 刷新计费内存 | **仅写 DB** 发布记录 |
|
||||
| 自动触发规则 | 订单/仓储事件消费 | **仅 CRUD 落库**,无运行时引擎 |
|
||||
| 自动触发规则 | 订单/仓储事件消费 | **部分落地**(关键字匹配 + 事后拦截);**V2 结构化条件 + 预报同步拦截见 §5.3.0.6.1** |
|
||||
| 服务渠道 publish/preview | 同销售产品 | **已落地** |
|
||||
|
||||
#### 9.6.4 渠道模块已落地要点(详见 §5.3.0)
|
||||
@ -7836,7 +8197,7 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20
|
||||
| `/channel/kpi-area/*` | 已落地 | 10 个端点:主表 CRUD + 明细分页 CRUD + **Excel 导入/模板** |
|
||||
| `/channel/weight-rule/*` | 已落地 | 8 个端点:主表 + info 子表 CRUD(POST 路由) |
|
||||
| `/channel/weight-rounding/*` | 已落地 | 8 个端点:同上 |
|
||||
| `/channel/auto-trigger/*` | 部分 | CRUD + toggleStatus **已落地**;**运行时触发未实现** |
|
||||
| `/channel/auto-trigger/*` | 部分 | CRUD + toggleStatus + **部分运行时**(事后拦截/警告/问题件);**V2 规则引擎 §5.3.0.6.1 未实现** |
|
||||
| `/channel/label-template/save` | 已落地 | upsert,`Name+TemplateType` 唯一 |
|
||||
|
||||
**渠道发布与订单算价衔接**:发布快照写入 DB 后,订单 **`ResolveSnapshotBindingAsync`** 按 `PublishTime <= bindTime` 读取;**Redis 同步未实现**(§5.2.6.2)。
|
||||
|
||||
773
pnpm-lock.yaml
generated
773
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -30,3 +30,35 @@ export async function toggleAutoTriggerStatus(id: number | string, status: numbe
|
||||
const res = await axiosInstance.post(omsUrl('/channel/auto-trigger/toggleStatus'), { id, status })
|
||||
assertBusinessOk(res.data)
|
||||
}
|
||||
|
||||
export async function getAutoTriggerMeta() {
|
||||
const res = await axiosInstance.get(omsUrl('/channel/auto-trigger/meta'))
|
||||
return unwrapData(res.data)
|
||||
}
|
||||
|
||||
export async function testAutoTriggerFormula(body: {
|
||||
expression?: string
|
||||
triggerCondition?: string
|
||||
orderId?: number
|
||||
sample?: {
|
||||
productCode?: string
|
||||
channelCode?: string
|
||||
consigneeCountry?: string
|
||||
consigneeCountryName?: string
|
||||
customerId?: number
|
||||
customerName?: string
|
||||
clientNo?: string
|
||||
length?: number
|
||||
width?: number
|
||||
height?: number
|
||||
actualWeight?: number
|
||||
}
|
||||
}) {
|
||||
const res = await axiosInstance.post(omsUrl('/channel/auto-trigger/test'), body)
|
||||
return unwrapData(res.data) as {
|
||||
matched: boolean
|
||||
expression?: string
|
||||
contextType?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
@ -801,6 +801,59 @@ export function parsePriceCheckResponse(resBody: any): {
|
||||
return { items: itemList, failures }
|
||||
}
|
||||
|
||||
export type PriceCheckShippingOption = {
|
||||
productId: number
|
||||
productCode: string
|
||||
productName: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type PriceCheckShippingGroup = {
|
||||
groupCode: string
|
||||
groupName: string
|
||||
products: PriceCheckShippingOption[]
|
||||
}
|
||||
|
||||
function normalizeShippingOption(raw: any): PriceCheckShippingOption | null {
|
||||
const productCode = String(raw?.productCode ?? raw?.ProductCode ?? '').trim()
|
||||
if (!productCode) return null
|
||||
const productName = String(raw?.productName ?? raw?.ProductName ?? '').trim()
|
||||
const label = String(raw?.label ?? raw?.Label ?? '').trim()
|
||||
return {
|
||||
productId: Number(raw?.productId ?? raw?.ProductId ?? 0),
|
||||
productCode,
|
||||
productName,
|
||||
label: label || (productName ? `${productName} ${productCode}` : productCode),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeShippingGroup(raw: any): PriceCheckShippingGroup | null {
|
||||
const groupCode = String(raw?.groupCode ?? raw?.GroupCode ?? '').trim()
|
||||
const groupName = String(raw?.groupName ?? raw?.GroupName ?? '').trim()
|
||||
const productsRaw = raw?.products ?? raw?.Products ?? []
|
||||
const products = (Array.isArray(productsRaw) ? productsRaw : [])
|
||||
.map(normalizeShippingOption)
|
||||
.filter((item): item is PriceCheckShippingOption => Boolean(item))
|
||||
if (!groupCode && !groupName && products.length === 0) return null
|
||||
return {
|
||||
groupCode: groupCode || groupName,
|
||||
groupName: groupName || groupCode,
|
||||
products,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrderPriceCheckShippingOptions(): Promise<PriceCheckShippingGroup[]> {
|
||||
const res = await axiosInstance.get(orderUrl('/order/price-check/shipping-options'))
|
||||
const body = res.data
|
||||
assertBusinessOk(body)
|
||||
const payload = body?.data ?? body?.Data ?? body
|
||||
const groupsRaw = payload?.groups ?? payload?.Groups ?? []
|
||||
return (Array.isArray(groupsRaw) ? groupsRaw : [])
|
||||
.map(normalizeShippingGroup)
|
||||
.filter((item): item is PriceCheckShippingGroup => Boolean(item))
|
||||
.filter((item) => item.products.length > 0)
|
||||
}
|
||||
|
||||
export async function postOrderPriceCheck(params: PriceCheckRequest): Promise<{
|
||||
items: any[]
|
||||
failures: PriceCheckFailure[]
|
||||
|
||||
7
src/auto-imports.d.ts
vendored
7
src/auto-imports.d.ts
vendored
@ -202,6 +202,7 @@ declare global {
|
||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useMenuDisplay: typeof import('./composables/useMenuDisplay')['useMenuDisplay']
|
||||
const useMessageStore: typeof import('./store/index')['useMessageStore']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
@ -311,6 +312,9 @@ declare global {
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { ScanFeedbackType } from './composables/useOperationScanFeedback'
|
||||
import('./composables/useOperationScanFeedback')
|
||||
// @ts-ignore
|
||||
export type { WarehouseItem } from './composables/useWarehouseList'
|
||||
import('./composables/useWarehouseList')
|
||||
}
|
||||
@ -516,6 +520,7 @@ declare module 'vue' {
|
||||
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
|
||||
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
||||
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
||||
readonly useMenuDisplay: UnwrapRef<typeof import('./composables/useMenuDisplay')['useMenuDisplay']>
|
||||
readonly useMessageStore: UnwrapRef<typeof import('./store/index')['useMessageStore']>
|
||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
||||
@ -529,7 +534,9 @@ declare module 'vue' {
|
||||
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
|
||||
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
|
||||
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
|
||||
readonly useOperationScanFeedback: UnwrapRef<typeof import('./composables/useOperationScanFeedback')['useOperationScanFeedback']>
|
||||
readonly useOperationScanFocus: UnwrapRef<typeof import('./composables/useOperationScanFocus')['useOperationScanFocus']>
|
||||
readonly useOperationVoiceBroadcast: UnwrapRef<typeof import('./composables/useOperationVoiceBroadcast')['useOperationVoiceBroadcast']>
|
||||
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
|
||||
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
|
||||
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
|
||||
|
||||
@ -4,13 +4,16 @@
|
||||
<icon-apps />
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-for="item in items" :key="item">
|
||||
{{ $t(item) }}
|
||||
{{ formatBreadcrumbLabel(item) }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t, te } = useI18n();
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
@ -20,6 +23,16 @@
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** 支持菜单原名、路由 path、或历史 locale key */
|
||||
function formatBreadcrumbLabel(item: string) {
|
||||
const text = item?.trim() || '';
|
||||
if (!text) return '';
|
||||
if (text.startsWith('menu.') && te(text)) {
|
||||
return t(text);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
<icon-refresh />
|
||||
</span>
|
||||
<span class="tag-link">
|
||||
{{ $t(itemData.title) }}
|
||||
{{ itemData.title }}
|
||||
</span>
|
||||
<span
|
||||
class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn"
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
<component :is="getSubMenuIcon(menuItem, item, menuNameToLocaleKeyMap)" />
|
||||
</span>
|
||||
<span class="child-menu-item__text">
|
||||
{{ formatMenuTitle(menuItem.name || menuItem.meta?.title, menuItem.path || menuItem.route) }}
|
||||
{{ formatMenuTitle(menuItem.name || menuItem.meta?.title) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -152,6 +152,7 @@ import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore } from '@/store';
|
||||
import { useTabBarStore } from '@/store';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import TabBar from '@/components/tab-bar/index.vue';
|
||||
import RightTopMessage from './RightTopMessage.vue';
|
||||
@ -169,7 +170,7 @@ import {
|
||||
useHiprintClientState,
|
||||
} from '@/utils/hiprint/hiprintClientState';
|
||||
import { getMapIdFromAccessToken } from '@/utils/jwt';
|
||||
import { getSubMenuIcon, PATH_TO_LOCALE_KEY } from '@/utils/menu-icon';
|
||||
import { getSubMenuIcon } from '@/utils/menu-icon';
|
||||
import { canAccessCustomerPortalRoute } from '@/utils/login-portal';
|
||||
import { resolveCurrentCustomer } from '@/api/customer/resolve';
|
||||
import { CUSTOMER_BALANCE_CHANGED_EVENT } from '@/api/customer/accountFlow';
|
||||
@ -178,6 +179,7 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const tabBarStore = useTabBarStore();
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
|
||||
const topMenuCon = ref<HTMLElement | null>(null);
|
||||
@ -367,11 +369,13 @@ async function loadMenuTree() {
|
||||
// 等待渲染完成后再标记数据就绪
|
||||
await nextTick();
|
||||
menuDataReady.value = true;
|
||||
tabBarStore.refreshTagTitles();
|
||||
} catch (menuError) {
|
||||
console.error('加载全量菜单树失败:', menuError);
|
||||
allMenuTreeData.value = [];
|
||||
await nextTick();
|
||||
menuDataReady.value = true;
|
||||
tabBarStore.refreshTagTitles();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载菜单树或用户信息失败:', error);
|
||||
@ -405,10 +409,10 @@ const belongTopMenu = computed(() => {
|
||||
return '';
|
||||
});
|
||||
|
||||
// 路径 → locale(同名菜单如「新手指引」须按 path 区分)
|
||||
const pathToLocaleKeyMap = PATH_TO_LOCALE_KEY;
|
||||
// 顶部菜单标题:直接使用 RBAC 菜单返回的名称
|
||||
const formatMenuTitle = (title?: string) => title?.trim() || '';
|
||||
|
||||
// 中文菜单名称到 locale key 的映射表
|
||||
// 中文菜单名称到 locale key 的映射表(仅用于图标等辅助展示)
|
||||
const menuNameToLocaleKeyMap: Record<string, string> = {
|
||||
'首页': 'menu.home',
|
||||
'客户': 'menu.user',
|
||||
@ -485,25 +489,6 @@ const menuNameToLocaleKeyMap: Record<string, string> = {
|
||||
'权限审计': 'menu.system.dataPermAudit',
|
||||
};
|
||||
|
||||
// 格式化菜单标题
|
||||
const formatMenuTitle = (title?: string, path?: string) => {
|
||||
if (!title) return '';
|
||||
const routePath = path?.trim();
|
||||
if (routePath && pathToLocaleKeyMap[routePath]) {
|
||||
return t(pathToLocaleKeyMap[routePath]) || title;
|
||||
}
|
||||
// 如果是 locale key,直接翻译
|
||||
if (title.startsWith('menu.')) {
|
||||
return t(title) || title;
|
||||
}
|
||||
// 如果是中文菜单名称,查找对应的 locale key
|
||||
if (menuNameToLocaleKeyMap[title]) {
|
||||
return t(menuNameToLocaleKeyMap[title]) || title;
|
||||
}
|
||||
// 否则直接返回原文本
|
||||
return title;
|
||||
};
|
||||
|
||||
// 过滤页面 - 只显示2级菜单,忽略3级菜单
|
||||
const filterPage = (pages?: any[]) => {
|
||||
if (!pages) return [];
|
||||
|
||||
38
src/composables/useMenuDisplay.ts
Normal file
38
src/composables/useMenuDisplay.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { MenuTreeDto } from '@/api/rbac';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import {
|
||||
buildMenuTitleMaps,
|
||||
resolveMenuBreadcrumbByPath,
|
||||
resolveMenuTitleByPath,
|
||||
} from '@/utils/menu-display';
|
||||
|
||||
export function useMenuDisplay(menus?: () => MenuTreeDto[] | null | undefined) {
|
||||
const route = useRoute();
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
|
||||
const menuTree = computed(() => menus?.() ?? rbacUserStore.menus ?? []);
|
||||
|
||||
const maps = computed(() => buildMenuTitleMaps(menuTree.value));
|
||||
|
||||
function resolveTitle(path: string, fallback = '') {
|
||||
return resolveMenuTitleByPath(path, maps.value.pathToTitle, fallback);
|
||||
}
|
||||
|
||||
function resolveBreadcrumb(path: string) {
|
||||
return resolveMenuBreadcrumbByPath(path, maps.value.pathToBreadcrumb);
|
||||
}
|
||||
|
||||
const currentTitle = computed(() => resolveTitle(route.path));
|
||||
|
||||
const currentBreadcrumb = computed(() => resolveBreadcrumb(route.path));
|
||||
|
||||
return {
|
||||
menuTree,
|
||||
resolveTitle,
|
||||
resolveBreadcrumb,
|
||||
currentTitle,
|
||||
currentBreadcrumb,
|
||||
};
|
||||
}
|
||||
@ -2461,6 +2461,12 @@ export default {
|
||||
'channel.salesProducts.form.placeholder.max': 'Max',
|
||||
'channel.salesProducts.form.placeholder.select': 'Please select',
|
||||
|
||||
'channel.salesProducts.referencePreview.view': 'View details',
|
||||
'channel.salesProducts.referencePreview.title': 'View: {name}',
|
||||
'channel.salesProducts.referencePreview.noSelection': 'Please select an item first',
|
||||
'channel.salesProducts.referencePreview.loadFailed': 'Failed to load details',
|
||||
'channel.salesProducts.referencePreview.empty': 'No detail data',
|
||||
|
||||
'channel.salesProducts.drawer.tabs.basic': 'Basic info',
|
||||
'channel.salesProducts.drawer.tabs.priceList': 'Price table',
|
||||
|
||||
@ -2713,6 +2719,8 @@ export default {
|
||||
'channel.autoTrigger.pagination.next': 'Next',
|
||||
'channel.autoTrigger.pagination.perPage': 'per page',
|
||||
'channel.autoTrigger.table.serialNo': 'No.',
|
||||
'channel.autoTrigger.table.ruleName': 'Rule name',
|
||||
'channel.autoTrigger.table.priority': 'Priority',
|
||||
'channel.autoTrigger.table.status': 'Status',
|
||||
'channel.autoTrigger.table.operation': 'Operation',
|
||||
'channel.autoTrigger.table.triggerTiming': 'Trigger timing',
|
||||
@ -2731,6 +2739,25 @@ export default {
|
||||
'channel.autoTrigger.message.addPending': 'Add function pending',
|
||||
'channel.autoTrigger.empty': 'No data',
|
||||
|
||||
'channel.autoTrigger.form.ruleNameRequired': 'Please enter a rule name',
|
||||
'channel.autoTrigger.form.promptRequired': 'Please enter a prompt message',
|
||||
'channel.autoTrigger.form.ruleNamePlaceholder': 'e.g. UK oversize intercept',
|
||||
'channel.autoTrigger.form.promptPlaceholder': 'e.g. Oversize — order blocked',
|
||||
'channel.autoTrigger.form.notifierPlaceholder': 'Optional user IDs for warn/problem, comma-separated',
|
||||
'channel.autoTrigger.form.loadExample': 'Load oversize example',
|
||||
'channel.autoTrigger.form.legacyTitle': 'Legacy plain-text condition detected — rebuild with the builder below',
|
||||
'channel.autoTrigger.form.testFormula': 'Test formula',
|
||||
'channel.autoTrigger.form.testSample': 'Sample data',
|
||||
'channel.autoTrigger.form.testMatched': 'Matched',
|
||||
'channel.autoTrigger.form.testNotMatched': 'Not matched',
|
||||
'channel.autoTrigger.form.testFailed': 'Formula test failed',
|
||||
'channel.autoTrigger.form.sampleCountry': 'Country',
|
||||
'channel.autoTrigger.form.sampleCustomer': 'Customer name',
|
||||
'channel.autoTrigger.form.sampleProduct': 'Product code',
|
||||
'channel.autoTrigger.form.sampleLength': 'Length',
|
||||
'channel.autoTrigger.form.sampleWidth': 'Width',
|
||||
'channel.autoTrigger.form.sampleHeight': 'Height',
|
||||
|
||||
// Channel - Issue Template
|
||||
'channel.issueTemplate.actions.add': 'Add',
|
||||
'channel.issueTemplate.actions.edit': 'Edit',
|
||||
|
||||
@ -2480,6 +2480,12 @@ export default {
|
||||
'channel.salesProducts.form.placeholder.max': '最大值',
|
||||
'channel.salesProducts.form.placeholder.select': '请选择',
|
||||
|
||||
'channel.salesProducts.referencePreview.view': '查看明细',
|
||||
'channel.salesProducts.referencePreview.title': '查看:{name}',
|
||||
'channel.salesProducts.referencePreview.noSelection': '请先选择一项',
|
||||
'channel.salesProducts.referencePreview.loadFailed': '加载明细失败',
|
||||
'channel.salesProducts.referencePreview.empty': '暂无明细数据',
|
||||
|
||||
'channel.salesProducts.drawer.tabs.basic': '基础信息',
|
||||
'channel.salesProducts.drawer.tabs.priceList': '价格表',
|
||||
|
||||
@ -2749,6 +2755,8 @@ export default {
|
||||
'channel.autoTrigger.pagination.next': '下一页',
|
||||
'channel.autoTrigger.pagination.perPage': '条/页',
|
||||
'channel.autoTrigger.table.serialNo': '序号',
|
||||
'channel.autoTrigger.table.ruleName': '规则名称',
|
||||
'channel.autoTrigger.table.priority': '优先级',
|
||||
'channel.autoTrigger.table.status': '状态',
|
||||
'channel.autoTrigger.table.operation': '操作',
|
||||
'channel.autoTrigger.table.triggerTiming': '触发时机',
|
||||
@ -2767,6 +2775,25 @@ export default {
|
||||
'channel.autoTrigger.message.addPending': '新增功能待实现',
|
||||
'channel.autoTrigger.empty': '暂无数据',
|
||||
|
||||
'channel.autoTrigger.form.ruleNameRequired': '请填写规则名称',
|
||||
'channel.autoTrigger.form.promptRequired': '请填写提示语',
|
||||
'channel.autoTrigger.form.ruleNamePlaceholder': '例如:英国阿里超尺寸拦截',
|
||||
'channel.autoTrigger.form.promptPlaceholder': '例如:超尺寸,禁止下单',
|
||||
'channel.autoTrigger.form.notifierPlaceholder': '警告/问题件可选,用户 ID,逗号分隔',
|
||||
'channel.autoTrigger.form.loadExample': '载入超尺寸示例',
|
||||
'channel.autoTrigger.form.legacyTitle': '检测到旧版纯文本条件,请用下方构建器重新配置',
|
||||
'channel.autoTrigger.form.testFormula': '测试公式',
|
||||
'channel.autoTrigger.form.testSample': '样例数据',
|
||||
'channel.autoTrigger.form.testMatched': '命中规则',
|
||||
'channel.autoTrigger.form.testNotMatched': '未命中',
|
||||
'channel.autoTrigger.form.testFailed': '公式测试失败',
|
||||
'channel.autoTrigger.form.sampleCountry': '国家',
|
||||
'channel.autoTrigger.form.sampleCustomer': '客户名称',
|
||||
'channel.autoTrigger.form.sampleProduct': '销售产品',
|
||||
'channel.autoTrigger.form.sampleLength': '长度',
|
||||
'channel.autoTrigger.form.sampleWidth': '宽度',
|
||||
'channel.autoTrigger.form.sampleHeight': '高度',
|
||||
|
||||
// 渠道 - 问题模板
|
||||
'channel.issueTemplate.actions.add': '新增',
|
||||
'channel.issueTemplate.actions.edit': '编辑',
|
||||
|
||||
@ -6,12 +6,31 @@ import {
|
||||
REDIRECT_ROUTE_NAME,
|
||||
} from '@/router/constants';
|
||||
import { isString } from '@/utils/is';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import { buildMenuTitleMaps, resolveMenuTitleByPath, resolveMenuTitleFromTree } from '@/utils/menu-display';
|
||||
import type { MenuTreeDto } from '@/api/rbac';
|
||||
import { TabBarState, TagProps } from './types';
|
||||
|
||||
function resolveRouteTabTitle(route: RouteLocationNormalized): string {
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
const { pathToTitle } = buildMenuTitleMaps(rbacUserStore.menus as MenuTreeDto[]);
|
||||
const menuTitle = resolveMenuTitleByPath(route.path, pathToTitle);
|
||||
if (menuTitle) {
|
||||
return menuTitle;
|
||||
}
|
||||
|
||||
const metaTitle = route.meta?.title;
|
||||
if (typeof metaTitle === 'string' && metaTitle.trim()) {
|
||||
return metaTitle.trim();
|
||||
}
|
||||
|
||||
return String(route.name || route.path || '');
|
||||
}
|
||||
|
||||
const formatTag = (route: RouteLocationNormalized): TagProps => {
|
||||
const { name, meta, fullPath, query } = route;
|
||||
return {
|
||||
title: meta.locale || '',
|
||||
title: resolveRouteTabTitle(route),
|
||||
name: String(name),
|
||||
fullPath,
|
||||
query,
|
||||
@ -83,6 +102,15 @@ const useAppStore = defineStore('tabBar', {
|
||||
list.splice(toIndex, 0, item);
|
||||
this.freshTabList(list);
|
||||
},
|
||||
/** 菜单加载后刷新标签标题(使用 RBAC 菜单名称) */
|
||||
refreshTagTitles() {
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
const menus = rbacUserStore.menus as MenuTreeDto[];
|
||||
this.tagList = this.tagList.map((tag) => ({
|
||||
...tag,
|
||||
title: resolveMenuTitleFromTree(menus, tag.fullPath, tag.title) || tag.title,
|
||||
}));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
93
src/utils/menu-display.ts
Normal file
93
src/utils/menu-display.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { MenuTreeDto } from '@/api/rbac';
|
||||
|
||||
export function normalizeMenuPath(path?: string | null): string {
|
||||
if (!path) return '';
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
export function buildMenuTitleMaps(menus: MenuTreeDto[] | null | undefined) {
|
||||
const pathToTitle = new Map<string, string>();
|
||||
const pathToBreadcrumb = new Map<string, string[]>();
|
||||
|
||||
function walk(items: MenuTreeDto[], ancestors: string[]) {
|
||||
items.forEach((item) => {
|
||||
const path = normalizeMenuPath(item.route);
|
||||
const title = item.name?.trim() || '';
|
||||
if (path && title) {
|
||||
pathToTitle.set(path, title);
|
||||
pathToBreadcrumb.set(path, [...ancestors, title]);
|
||||
}
|
||||
const nextAncestors = title ? [...ancestors, title] : ancestors;
|
||||
if (item.children?.length) {
|
||||
walk(item.children, nextAncestors);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
walk(menus || [], []);
|
||||
return { pathToTitle, pathToBreadcrumb };
|
||||
}
|
||||
|
||||
function findBestMenuPathMatch(path: string, keys: Iterable<string>): string | undefined {
|
||||
let best: string | undefined;
|
||||
let bestLen = 0;
|
||||
for (const menuPath of keys) {
|
||||
if (path === menuPath || path.startsWith(`${menuPath}/`)) {
|
||||
if (menuPath.length > bestLen) {
|
||||
bestLen = menuPath.length;
|
||||
best = menuPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function resolveMenuTitleByPath(
|
||||
path: string,
|
||||
pathToTitle: Map<string, string>,
|
||||
fallback = '',
|
||||
): string {
|
||||
const normalized = normalizeMenuPath(path);
|
||||
if (!normalized) return fallback;
|
||||
|
||||
if (pathToTitle.has(normalized)) {
|
||||
return pathToTitle.get(normalized) || fallback;
|
||||
}
|
||||
|
||||
const matched = findBestMenuPathMatch(normalized, pathToTitle.keys());
|
||||
return matched ? pathToTitle.get(matched) || fallback : fallback;
|
||||
}
|
||||
|
||||
export function resolveMenuBreadcrumbByPath(
|
||||
path: string,
|
||||
pathToBreadcrumb: Map<string, string[]>,
|
||||
): string[] {
|
||||
const normalized = normalizeMenuPath(path);
|
||||
if (!normalized) return [];
|
||||
|
||||
if (pathToBreadcrumb.has(normalized)) {
|
||||
return [...(pathToBreadcrumb.get(normalized) || [])];
|
||||
}
|
||||
|
||||
const matched = findBestMenuPathMatch(normalized, pathToBreadcrumb.keys());
|
||||
return matched ? [...(pathToBreadcrumb.get(matched) || [])] : [];
|
||||
}
|
||||
|
||||
export function resolveMenuTitleFromTree(
|
||||
menus: MenuTreeDto[] | null | undefined,
|
||||
path: string,
|
||||
fallback = '',
|
||||
): string {
|
||||
const { pathToTitle } = buildMenuTitleMaps(menus);
|
||||
return resolveMenuTitleByPath(path, pathToTitle, fallback);
|
||||
}
|
||||
|
||||
export function resolveMenuBreadcrumbFromTree(
|
||||
menus: MenuTreeDto[] | null | undefined,
|
||||
path: string,
|
||||
): string[] {
|
||||
const { pathToBreadcrumb } = buildMenuTitleMaps(menus);
|
||||
return resolveMenuBreadcrumbByPath(path, pathToBreadcrumb);
|
||||
}
|
||||
@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import {
|
||||
FORMULA_MAX_LENGTH,
|
||||
FORMULA_OPERATORS,
|
||||
ORDER_TABLE_CATEGORIES,
|
||||
OVERSIZE_EXAMPLE_FORMULA,
|
||||
chipsByTable,
|
||||
} from '../formulaMeta'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string]
|
||||
}>()
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const activeCategory = ref<string>('t_order')
|
||||
|
||||
const charCount = computed(() => props.modelValue.length)
|
||||
const activeChips = computed(() => chipsByTable(activeCategory.value))
|
||||
|
||||
function updateValue(next: string) {
|
||||
const clipped = next.slice(0, FORMULA_MAX_LENGTH)
|
||||
emit('update:modelValue', clipped)
|
||||
}
|
||||
|
||||
async function insertAtCursor(text: string) {
|
||||
const el = textareaRef.value
|
||||
if (!el) {
|
||||
updateValue((props.modelValue + text).slice(0, FORMULA_MAX_LENGTH))
|
||||
return
|
||||
}
|
||||
const start = el.selectionStart ?? props.modelValue.length
|
||||
const end = el.selectionEnd ?? start
|
||||
const before = props.modelValue.slice(0, start)
|
||||
const after = props.modelValue.slice(end)
|
||||
const next = (before + text + after).slice(0, FORMULA_MAX_LENGTH)
|
||||
updateValue(next)
|
||||
await nextTick()
|
||||
const pos = Math.min(start + text.length, next.length)
|
||||
el.focus()
|
||||
el.setSelectionRange(pos, pos)
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
const val = (e.target as HTMLTextAreaElement).value
|
||||
updateValue(val)
|
||||
}
|
||||
|
||||
function loadExample() {
|
||||
updateValue(OVERSIZE_EXAMPLE_FORMULA)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val.length > FORMULA_MAX_LENGTH) {
|
||||
updateValue(val.slice(0, FORMULA_MAX_LENGTH))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({ loadExample, insertAtCursor })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="formula-condition-editor">
|
||||
<div class="formula-text-wrap">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
class="formula-textarea"
|
||||
:value="modelValue"
|
||||
rows="5"
|
||||
:maxlength="FORMULA_MAX_LENGTH"
|
||||
placeholder="在此编辑条件公式,可点击下方运算符与字段标签插入"
|
||||
@input="onInput"
|
||||
/>
|
||||
<div class="char-count">{{ charCount }}/{{ FORMULA_MAX_LENGTH }}</div>
|
||||
</div>
|
||||
|
||||
<div class="operator-section">
|
||||
<div class="section-label">运算符</div>
|
||||
<div class="operator-bar">
|
||||
<button
|
||||
v-for="op in FORMULA_OPERATORS"
|
||||
:key="op.label"
|
||||
type="button"
|
||||
class="chip-btn operator-btn"
|
||||
@click="insertAtCursor(op.insert)"
|
||||
>
|
||||
{{ op.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-section">
|
||||
<div class="field-hint">
|
||||
按钮为<strong>中文字段名</strong>,悬停可查看对应库表字段;公式保存为中文表达式,后端按映射解析为
|
||||
<code>表名.字段名</code>。
|
||||
</div>
|
||||
<div class="field-layout">
|
||||
<div class="category-tabs">
|
||||
<button
|
||||
v-for="cat in ORDER_TABLE_CATEGORIES"
|
||||
:key="cat.key"
|
||||
type="button"
|
||||
class="category-tab"
|
||||
:class="{ active: activeCategory === cat.key }"
|
||||
:title="cat.key === '_virtual' ? '常用聚合字段' : cat.key"
|
||||
@click="activeCategory = cat.key"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="field-chips">
|
||||
<button
|
||||
v-for="(chip, idx) in activeChips"
|
||||
:key="`${chip.table}-${chip.label}-${idx}`"
|
||||
type="button"
|
||||
class="chip-btn field-chip"
|
||||
:title="`${chip.refHint} → ${chip.insert}`"
|
||||
@click="insertAtCursor(chip.insert)"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.formula-condition-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.formula-text-wrap {
|
||||
position: relative;
|
||||
|
||||
.formula-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
max-height: 200px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 12px 28px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #1d2129;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #c9cdd4;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--app-primary-color, #2d8c8c);
|
||||
box-shadow: 0 0 0 2px rgba(45, 140, 140, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.char-count {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4e5969;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.operator-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-section {
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
|
||||
code {
|
||||
padding: 0 4px;
|
||||
background: #f2f3f5;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-layout {
|
||||
display: flex;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
min-height: 220px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
flex: 0 0 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #e5e6eb;
|
||||
background: #f7f8fa;
|
||||
overflow-y: auto;
|
||||
|
||||
.category-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 8px;
|
||||
font-size: 11px;
|
||||
color: #4e5969;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
line-height: 1.35;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--app-primary-color, #2d8c8c);
|
||||
background: rgba(45, 140, 140, 0.06);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--app-primary-color, #2d8c8c);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-chips {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chip-btn {
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
background: #f7f8fa;
|
||||
color: #1d2129;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--app-primary-color, #2d8c8c);
|
||||
color: var(--app-primary-color, #2d8c8c);
|
||||
background: rgba(45, 140, 140, 0.06);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.operator-btn {
|
||||
min-width: 36px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-chip {
|
||||
padding: 6px 12px;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
71
src/views/channel/autoTrigger/conditionUtils.ts
Normal file
71
src/views/channel/autoTrigger/conditionUtils.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { FORMULA_MAX_LENGTH, OVERSIZE_EXAMPLE_FORMULA } from './formulaMeta'
|
||||
|
||||
export type StoredConditionMeta = {
|
||||
expression: string
|
||||
ruleName?: string
|
||||
priority?: number
|
||||
legacyText?: string
|
||||
}
|
||||
|
||||
/** 从库中读出:支持纯公式文本,或 { version:'formula', expression, ruleName, priority } */
|
||||
export function parseStoredCondition(text: string | undefined | null): StoredConditionMeta {
|
||||
const trimmed = String(text ?? '').trim()
|
||||
if (!trimmed) {
|
||||
return { expression: '' }
|
||||
}
|
||||
if (!trimmed.startsWith('{')) {
|
||||
return { expression: trimmed }
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(trimmed) as Record<string, unknown>
|
||||
if (Number(obj.version) === 2 && Array.isArray(obj.children)) {
|
||||
return { expression: trimmed, legacyText: '旧版结构化条件,请改用公式重新配置' }
|
||||
}
|
||||
if (obj.version === 'formula' || obj.expression != null) {
|
||||
return {
|
||||
expression: String(obj.expression ?? '').trim(),
|
||||
ruleName: obj.ruleName != null ? String(obj.ruleName) : undefined,
|
||||
priority: obj.priority != null ? Number(obj.priority) || 100 : undefined,
|
||||
}
|
||||
}
|
||||
return { expression: trimmed, legacyText: trimmed }
|
||||
} catch {
|
||||
return { expression: trimmed }
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存:附带规则名/优先级元数据,expression 为公式正文 */
|
||||
export function serializeStoredCondition(
|
||||
expression: string,
|
||||
meta?: { ruleName?: string; priority?: number }
|
||||
): string {
|
||||
const expr = expression.trim()
|
||||
const ruleName = meta?.ruleName?.trim()
|
||||
const priority = meta?.priority ?? 100
|
||||
if (!ruleName && priority === 100) return expr
|
||||
return JSON.stringify({
|
||||
version: 'formula',
|
||||
ruleName: ruleName || undefined,
|
||||
priority,
|
||||
expression: expr,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatConditionSummary(text: string | undefined | null): string {
|
||||
const { expression, legacyText } = parseStoredCondition(text)
|
||||
if (legacyText && !expression.startsWith('(')) return legacyText
|
||||
const summary = expression.replace(/\s+/g, ' ').trim()
|
||||
if (!summary) return '—'
|
||||
return summary.length > 120 ? `${summary.slice(0, 120)}…` : summary
|
||||
}
|
||||
|
||||
export function validateFormulaExpression(expression: string): string | null {
|
||||
const expr = expression.trim()
|
||||
if (!expr) return '请填写条件公式'
|
||||
if (expr.length > FORMULA_MAX_LENGTH) return `条件公式不能超过 ${FORMULA_MAX_LENGTH} 字`
|
||||
return null
|
||||
}
|
||||
|
||||
export function getOversizeExampleFormula(): string {
|
||||
return OVERSIZE_EXAMPLE_FORMULA
|
||||
}
|
||||
313
src/views/channel/autoTrigger/formulaMeta.ts
Normal file
313
src/views/channel/autoTrigger/formulaMeta.ts
Normal file
@ -0,0 +1,313 @@
|
||||
export type FieldValueType = 'string' | 'number' | 'boolean' | 'datetime'
|
||||
|
||||
/** 表分类:key 为库表名,label 为中文 */
|
||||
export const ORDER_TABLE_CATEGORIES: { key: string; label: string }[] = [
|
||||
{ key: 't_order', label: '订单' },
|
||||
{ key: 't_order_recipient', label: '收件人' },
|
||||
{ key: 't_order_sender', label: '发件人' },
|
||||
{ key: 't_order_declaration', label: '申报' },
|
||||
{ key: 't_order_weight', label: '件重尺' },
|
||||
{ key: 't_order_fee', label: '费用' },
|
||||
{ key: 't_customer', label: '客户' },
|
||||
{ key: '_virtual', label: '常用' },
|
||||
]
|
||||
|
||||
export type OrderTableFieldDef = {
|
||||
table: string
|
||||
field: string
|
||||
/** 公式与按钮上使用的中文名称(唯一) */
|
||||
label: string
|
||||
valueType: FieldValueType
|
||||
}
|
||||
|
||||
export type FormulaFieldRef = {
|
||||
table: string
|
||||
field: string
|
||||
/** 非库表字段(如单件最大长度) */
|
||||
virtual?: boolean
|
||||
}
|
||||
|
||||
/** 中文名称 → 库表字段映射(后端解析公式时使用) */
|
||||
export const FORMULA_LABEL_TO_REF: Record<string, FormulaFieldRef> = {}
|
||||
|
||||
function registerRef(label: string, ref: FormulaFieldRef) {
|
||||
FORMULA_LABEL_TO_REF[label] = ref
|
||||
}
|
||||
|
||||
function fieldRef(table: string, field: string): FormulaFieldRef {
|
||||
return { table, field }
|
||||
}
|
||||
|
||||
function virtualRef(field: string): FormulaFieldRef {
|
||||
return { table: '_virtual', field, virtual: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单及关联表字段中文映射(label 唯一)。
|
||||
* 不含 IsDeleted / CreateUserId / CreateTime / UpdateUserId / UpdateTime。
|
||||
*/
|
||||
export const ORDER_TABLE_FIELDS: OrderTableFieldDef[] = [
|
||||
// —— t_order ——
|
||||
{ table: 't_order', field: 'RequestId', label: '请求标识', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ServiceNo', label: '运单号', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ClientNo', label: '客户单号', valueType: 'string' },
|
||||
{ table: 't_order', field: 'TrackNo', label: '跟踪号', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ProductCode', label: '销售产品', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ChannelCode', label: '收货渠道', valueType: 'string' },
|
||||
{ table: 't_order', field: 'DownstreamSupplierCode', label: '下游服务商', valueType: 'string' },
|
||||
{ table: 't_order', field: 'DownstreamAppKey', label: '下游AppKey', valueType: 'string' },
|
||||
{ table: 't_order', field: 'DownstreamAppId', label: '下游AppId', valueType: 'number' },
|
||||
{ table: 't_order', field: 'ChannelForecastCode', label: '渠道预报编码', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ConsigneeCountry', label: '国家', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ConsigneeCountryName', label: '收件国家名称', valueType: 'string' },
|
||||
{ table: 't_order', field: 'CargoAttribute', label: '货物属性', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ExportCustomsMode', label: '出口报关方式', valueType: 'string' },
|
||||
{ table: 't_order', field: 'PredeclareFailReason', label: '预报失败原因', valueType: 'string' },
|
||||
{ table: 't_order', field: 'SalesProductRulesVersion', label: '规则版本号', valueType: 'string' },
|
||||
{ table: 't_order', field: 'SalesProductPriceVersion', label: '价格版本号', valueType: 'string' },
|
||||
{ table: 't_order', field: 'OrderStatus', label: '订单状态', valueType: 'string' },
|
||||
{ table: 't_order', field: 'HandleStatus', label: '处理子状态', valueType: 'string' },
|
||||
{ table: 't_order', field: 'IssueType', label: '问题类型', valueType: 'string' },
|
||||
{ table: 't_order', field: 'IssueTitle', label: '问题标题', valueType: 'string' },
|
||||
{ table: 't_order', field: 'IssueContent', label: '问题内容', valueType: 'string' },
|
||||
{ table: 't_order', field: 'IssueTemplateId', label: '问题模板', valueType: 'number' },
|
||||
{ table: 't_order', field: 'IssueRemark', label: '问题备注', valueType: 'string' },
|
||||
{ table: 't_order', field: 'IssueTime', label: '问题件时间', valueType: 'datetime' },
|
||||
{ table: 't_order', field: 'ClaimType', label: '理赔类型', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ClaimAmount', label: '索赔金额', valueType: 'number' },
|
||||
{ table: 't_order', field: 'ClaimCurrency', label: '索赔币种', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ClaimReason', label: '理赔原因', valueType: 'string' },
|
||||
{ table: 't_order', field: 'ClaimTime', label: '理赔时间', valueType: 'datetime' },
|
||||
{ table: 't_order', field: 'PreviousOrderStatus', label: '异常前状态', valueType: 'string' },
|
||||
{ table: 't_order', field: 'WarehouseId', label: '仓库', valueType: 'number' },
|
||||
{ table: 't_order', field: 'CustomerId', label: '订单客户', valueType: 'number' },
|
||||
{ table: 't_order', field: 'OrganizationId', label: '组织', valueType: 'string' },
|
||||
{ table: 't_order', field: 'SecretLevel', label: '密级', valueType: 'number' },
|
||||
{ table: 't_order', field: 'SenderId', label: '发件人关联', valueType: 'number' },
|
||||
{ table: 't_order', field: 'RecipientId', label: '收件人关联', valueType: 'number' },
|
||||
{ table: 't_order', field: 'DeclarationId', label: '申报关联', valueType: 'number' },
|
||||
{ table: 't_order', field: 'ReceiveTime', label: '收货时间', valueType: 'datetime' },
|
||||
{ table: 't_order', field: 'ShipTime', label: '发货时间', valueType: 'datetime' },
|
||||
{ table: 't_order', field: 'PayTime', label: '支付时间', valueType: 'datetime' },
|
||||
{ table: 't_order', field: 'IsPaid', label: '是否已支付', valueType: 'boolean' },
|
||||
{ table: 't_order', field: 'SignTime', label: '签收时间', valueType: 'datetime' },
|
||||
{ table: 't_order', field: 'IsCod', label: '是否COD', valueType: 'boolean' },
|
||||
{ table: 't_order', field: 'CodCurrency', label: 'COD币种', valueType: 'string' },
|
||||
{ table: 't_order', field: 'CodSum', label: 'COD金额', valueType: 'number' },
|
||||
{ table: 't_order', field: 'DeclaredCurrency', label: '申报币种', valueType: 'string' },
|
||||
{ table: 't_order', field: 'DeclaredSum', label: '申报总金额', valueType: 'number' },
|
||||
{ table: 't_order', field: 'Quantity', label: '订单件数', valueType: 'number' },
|
||||
{ table: 't_order', field: 'IsIntercept', label: '是否拦截', valueType: 'boolean' },
|
||||
{ table: 't_order', field: 'IsRemote', label: '是否偏远', valueType: 'boolean' },
|
||||
|
||||
// —— t_order_recipient ——
|
||||
{ table: 't_order_recipient', field: 'RecipientsName', label: '收件人姓名', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'CompanyName', label: '收件人公司', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'Country', label: '收件人国家', valueType: 'number' },
|
||||
{ table: 't_order_recipient', field: 'Phone', label: '收件人手机', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'TelePhone', label: '收件人电话', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'ZipCode', label: '收件人邮编', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'Province', label: '收件人省州', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'City', label: '收件人城市', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'StreetNumber', label: '收件人门牌号', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'StreetAddress1', label: '收件人地址1', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'StreetAddress2', label: '收件人地址2', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'StreetAddress3', label: '收件人地址3', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'Email', label: '收件人邮箱', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'TaxType', label: '收件人税号类型', valueType: 'number' },
|
||||
{ table: 't_order_recipient', field: 'TaxNo', label: '收件人税号', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'CertificateType', label: '收件人证件类型', valueType: 'number' },
|
||||
{ table: 't_order_recipient', field: 'CertificateNo', label: '收件人证件号', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'Eori', label: '收件人EORI', valueType: 'string' },
|
||||
{ table: 't_order_recipient', field: 'Vat', label: '收件人VAT', valueType: 'string' },
|
||||
|
||||
// —— t_order_sender ——
|
||||
{ table: 't_order_sender', field: 'SenderName', label: '发件人姓名', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'Company', label: '发件人公司', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'Country', label: '发件人国家', valueType: 'number' },
|
||||
{ table: 't_order_sender', field: 'Province', label: '发件人省州', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'City', label: '发件人城市', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'ZipCode', label: '发件人邮编', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'StreetAddress1', label: '发件人地址1', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'StreetAddress2', label: '发件人地址2', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'StreetAddress3', label: '发件人地址3', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'HouseNumber', label: '发件人门牌号', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'Phone', label: '发件人手机', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'TelePhone', label: '发件人电话', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'Email', label: '发件人邮箱', valueType: 'string' },
|
||||
{ table: 't_order_sender', field: 'IsDefault', label: '发件人是否默认', valueType: 'boolean' },
|
||||
|
||||
// —— t_order_declaration ——
|
||||
{ table: 't_order_declaration', field: 'CnName', label: '中文品名', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'EnName', label: '英文品名', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Sku', label: 'SKU', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Unit', label: '申报单位', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'UnitPrice', label: '申报单价', valueType: 'number' },
|
||||
{ table: 't_order_declaration', field: 'UnitWeight', label: '申报单重', valueType: 'number' },
|
||||
{ table: 't_order_declaration', field: 'Quantity', label: '申报数量', valueType: 'number' },
|
||||
{ table: 't_order_declaration', field: 'Amount', label: '申报金额', valueType: 'number' },
|
||||
{ table: 't_order_declaration', field: 'Weight', label: '申报总重', valueType: 'number' },
|
||||
{ table: 't_order_declaration', field: 'HsCode', label: 'HS编码', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'ImHsCode', label: '进口HS编码', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'ExHsCode', label: '出口HS编码', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Category', label: '申报类别', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Brand', label: '品牌', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'BrandType', label: '品牌类型', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'IsMagnetic', label: '是否带磁带电', valueType: 'boolean' },
|
||||
{ table: 't_order_declaration', field: 'Distribution', label: '配货信息', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Material', label: '材质', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'EnMaterial', label: '英文材质', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Purpose', label: '用途', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Attributes', label: '申报属性', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'OriginCountry', label: '原产国', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'SalesLink', label: '销售链接', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Model', label: '型号', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Specifications', label: '规格', valueType: 'string' },
|
||||
{ table: 't_order_declaration', field: 'Comment', label: '申报备注', valueType: 'string' },
|
||||
|
||||
// —— t_order_weight ——
|
||||
{ table: 't_order_weight', field: 'Type', label: '件重尺类型', valueType: 'string' },
|
||||
{ table: 't_order_weight', field: 'Length', label: '长度', valueType: 'number' },
|
||||
{ table: 't_order_weight', field: 'Width', label: '宽度', valueType: 'number' },
|
||||
{ table: 't_order_weight', field: 'Height', label: '高度', valueType: 'number' },
|
||||
{ table: 't_order_weight', field: 'ActualWeight', label: '实重', valueType: 'number' },
|
||||
{ table: 't_order_weight', field: 'VolumetricWeight', label: '体积重', valueType: 'number' },
|
||||
{ table: 't_order_weight', field: 'ChargeableWeight', label: '计费重', valueType: 'number' },
|
||||
{ table: 't_order_weight', field: 'Cubic', label: '体积', valueType: 'number' },
|
||||
|
||||
// —— t_order_fee ——
|
||||
{ table: 't_order_fee', field: 'Type', label: '费用类型', valueType: 'string' },
|
||||
{ table: 't_order_fee', field: 'CostItem', label: '费用项', valueType: 'string' },
|
||||
{ table: 't_order_fee', field: 'Amount', label: '费用金额', valueType: 'number' },
|
||||
{ table: 't_order_fee', field: 'Currency', label: '费用币种', valueType: 'string' },
|
||||
{ table: 't_order_fee', field: 'DownstreamProvider', label: '费用下游服务商', valueType: 'string' },
|
||||
{ table: 't_order_fee', field: 'PriceNo', label: '价格编号', valueType: 'string' },
|
||||
{ table: 't_order_fee', field: 'PriceEffectiveTime', label: '价格生效时间', valueType: 'datetime' },
|
||||
{ table: 't_order_fee', field: 'ChargingStage', label: '计费阶段', valueType: 'string' },
|
||||
|
||||
// —— t_customer ——
|
||||
{ table: 't_customer', field: 'CustomerCode', label: '客户编码', valueType: 'string' },
|
||||
{ table: 't_customer', field: 'CustomerName', label: '客户', valueType: 'string' },
|
||||
{ table: 't_customer', field: 'Email', label: '客户邮箱', valueType: 'string' },
|
||||
{ table: 't_customer', field: 'Phone', label: '客户手机', valueType: 'string' },
|
||||
{ table: 't_customer', field: 'Balance', label: '客户余额', valueType: 'number' },
|
||||
{ table: 't_customer', field: 'TotalQuota', label: '客户总额度', valueType: 'number' },
|
||||
{ table: 't_customer', field: 'UsedQuota', label: '客户已用额度', valueType: 'number' },
|
||||
{ table: 't_customer', field: 'Status', label: '客户状态', valueType: 'string' },
|
||||
{ table: 't_customer', field: 'Remark', label: '客户备注', valueType: 'string' },
|
||||
{ table: 't_customer', field: 'Salesman', label: '销售', valueType: 'string' },
|
||||
{ table: 't_customer', field: 'CustomerService', label: '客服', valueType: 'string' },
|
||||
{ table: 't_customer', field: 'CustomerGroup', label: '客户组', valueType: 'string' },
|
||||
]
|
||||
|
||||
/** 虚拟/聚合字段(无直接库表列,后端按中文名解析) */
|
||||
export const VIRTUAL_FORMULA_FIELDS: OrderTableFieldDef[] = [
|
||||
{ table: '_virtual', field: 'PieceMaxLength', label: '单件最大长度', valueType: 'number' },
|
||||
{ table: '_virtual', field: 'PieceMaxWidth', label: '单件最大宽度', valueType: 'number' },
|
||||
{ table: '_virtual', field: 'PieceMaxHeight', label: '单件最大高度', valueType: 'number' },
|
||||
{ table: '_virtual', field: 'PieceSumLwh', label: '单件长宽高之和', valueType: 'number' },
|
||||
]
|
||||
|
||||
export type FormulaChip = {
|
||||
table: string
|
||||
/** 按钮展示中文名 */
|
||||
label: string
|
||||
/** 插入公式片段(中文) */
|
||||
insert: string
|
||||
/** 悬停提示:映射库表字段 */
|
||||
refHint: string
|
||||
}
|
||||
|
||||
function buildInsert(def: OrderTableFieldDef): string {
|
||||
if (def.valueType === 'string' || def.valueType === 'datetime') {
|
||||
return `${def.label}包含('')`
|
||||
}
|
||||
if (def.valueType === 'boolean') {
|
||||
return `${def.label}()==1`
|
||||
}
|
||||
return `${def.label}()`
|
||||
}
|
||||
|
||||
function buildRefHint(def: OrderTableFieldDef): string {
|
||||
if (def.table === '_virtual') {
|
||||
return `虚拟字段 · ${def.field}`
|
||||
}
|
||||
return `${def.table}.${def.field}`
|
||||
}
|
||||
|
||||
function buildChipsFromFields(fields: OrderTableFieldDef[]): FormulaChip[] {
|
||||
return fields.map((def) => {
|
||||
registerRef(
|
||||
def.label,
|
||||
def.table === '_virtual' ? virtualRef(def.field) : fieldRef(def.table, def.field)
|
||||
)
|
||||
return {
|
||||
table: def.table,
|
||||
label: def.label,
|
||||
insert: buildInsert(def),
|
||||
refHint: buildRefHint(def),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const FORMULA_CHIPS: FormulaChip[] = buildChipsFromFields([
|
||||
...ORDER_TABLE_FIELDS,
|
||||
...VIRTUAL_FORMULA_FIELDS,
|
||||
])
|
||||
|
||||
export type FormulaOperator = {
|
||||
label: string
|
||||
insert: string
|
||||
}
|
||||
|
||||
export const FORMULA_OPERATORS: FormulaOperator[] = [
|
||||
{ label: '+', insert: '+' },
|
||||
{ label: '-', insert: '-' },
|
||||
{ label: '×', insert: '*' },
|
||||
{ label: '÷', insert: '/' },
|
||||
{ label: '=', insert: '==' },
|
||||
{ label: '≠', insert: '!=' },
|
||||
{ label: '≥', insert: '>=' },
|
||||
{ label: '>', insert: '>' },
|
||||
{ label: '≤', insert: '<=' },
|
||||
{ label: '<', insert: '<' },
|
||||
{ label: '(', insert: '(' },
|
||||
{ label: ')', insert: ')' },
|
||||
{ label: '非', insert: '!' },
|
||||
{ label: '与', insert: '&&' },
|
||||
{ label: '或', insert: '||' },
|
||||
]
|
||||
|
||||
/** 超尺寸示例(中文字段名,与运营习惯一致) */
|
||||
export const OVERSIZE_EXAMPLE_FORMULA =
|
||||
"(单件最大长度()>60||单件最大宽度()>46||((单件最大长度()+单件最大宽度()+单件最大高度())>150))&&国家包含('GB')&&客户包含('阿里国际站/深圳一达通')&&!销售产品包含('EX_ASP_HKDHL_YD')"
|
||||
|
||||
export const FORMULA_MAX_LENGTH = 10000
|
||||
|
||||
export const TIMING_SAVE_LABEL: Record<string, string> = {
|
||||
forecast: '预报',
|
||||
order_create: '下单',
|
||||
inbound: '入库',
|
||||
outbound: '出库',
|
||||
}
|
||||
|
||||
export const OPERATION_SAVE_LABEL: Record<string, string> = {
|
||||
intercept: '拦截',
|
||||
warn: '警告',
|
||||
problem: '问题件',
|
||||
}
|
||||
|
||||
export function chipsByTable(table: string): FormulaChip[] {
|
||||
return FORMULA_CHIPS.filter((c) => c.table === table)
|
||||
}
|
||||
|
||||
export function resolveFormulaLabel(label: string): FormulaFieldRef | undefined {
|
||||
return FORMULA_LABEL_TO_REF[label]
|
||||
}
|
||||
|
||||
export function getDbFieldPath(label: string): string {
|
||||
const ref = resolveFormulaLabel(label)
|
||||
if (!ref) return label
|
||||
if (ref.virtual) return ref.field
|
||||
return `${ref.table}.${ref.field}`
|
||||
}
|
||||
@ -1,21 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { $baseMessage } from '@/utils'
|
||||
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||
import ModalPro from '@/components/ModalPro/index.vue'
|
||||
import useModal from '@/components/ModalPro/useModal'
|
||||
import FormPro from '@/components/FormPro/index.vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Modal } from '@arco-design/web-vue'
|
||||
import { getDictListAll } from '@/api/system/dict'
|
||||
import {
|
||||
createAutoTrigger,
|
||||
deleteAutoTrigger,
|
||||
getAutoTriggerList,
|
||||
testAutoTriggerFormula,
|
||||
toggleAutoTriggerStatus,
|
||||
updateAutoTrigger,
|
||||
} from '@/api/channel/autoTrigger'
|
||||
import FormulaConditionEditor from './components/FormulaConditionEditor.vue'
|
||||
import {
|
||||
formatConditionSummary,
|
||||
getOversizeExampleFormula,
|
||||
parseStoredCondition,
|
||||
serializeStoredCondition,
|
||||
validateFormulaExpression,
|
||||
} from './conditionUtils'
|
||||
import { OPERATION_SAVE_LABEL, TIMING_SAVE_LABEL } from './formulaMeta'
|
||||
|
||||
defineOptions({
|
||||
name: 'AutoTrigger'
|
||||
@ -26,9 +36,12 @@ const { t } = useI18n()
|
||||
type AutoTriggerRow = {
|
||||
id: number
|
||||
status: boolean
|
||||
ruleName: string
|
||||
priority: number
|
||||
operation: string
|
||||
triggerTiming: string
|
||||
triggerCondition: string
|
||||
conditionSummary: string
|
||||
promptMessage: string
|
||||
notifier: string
|
||||
createTime: string
|
||||
@ -44,12 +57,17 @@ const listLoading = ref(false)
|
||||
const processedTableData = computed(() => tableData.value)
|
||||
|
||||
function mapRow(r: any): AutoTriggerRow {
|
||||
const triggerCondition = r.triggerCondition ?? r.TriggerCondition ?? ''
|
||||
const parsed = parseStoredCondition(triggerCondition)
|
||||
return {
|
||||
id: r.id ?? r.Id,
|
||||
status: (r.status ?? r.Status) === 1 || (r.status ?? r.Status) === true,
|
||||
ruleName: (r.ruleName ?? r.RuleName ?? parsed.ruleName) || '',
|
||||
priority: Number(r.priority ?? r.Priority ?? parsed.priority ?? 100) || 100,
|
||||
operation: r.operation ?? r.Operation ?? '',
|
||||
triggerTiming: r.triggerTiming ?? r.TriggerTiming ?? '',
|
||||
triggerCondition: r.triggerCondition ?? r.TriggerCondition ?? '',
|
||||
triggerCondition,
|
||||
conditionSummary: formatConditionSummary(triggerCondition),
|
||||
promptMessage: r.promptMessage ?? r.PromptMessage ?? '',
|
||||
notifier: r.notifier ?? r.Notifier ?? '',
|
||||
createTime: r.createTime ?? r.CreateTime ?? '',
|
||||
@ -115,143 +133,213 @@ const modalTitle = computed(() =>
|
||||
modalMode.value === 'add' ? t('channel.autoTrigger.actions.add') : t('channel.autoTrigger.actions.edit')
|
||||
)
|
||||
|
||||
const timingOptions = ref<{ label: string; value: string }[]>([])
|
||||
const operationOptions = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
async function loadDictOptions() {
|
||||
try {
|
||||
const [timingItems, operationItems] = await Promise.all([
|
||||
getDictListAll({ dictType: 'auto_trigger_timing' }),
|
||||
getDictListAll({ dictType: 'auto_trigger_operation' }),
|
||||
])
|
||||
const mapDict = (items: any[]) =>
|
||||
(items || [])
|
||||
.map((raw) => ({
|
||||
label: String(raw.dictName ?? raw.DictName ?? raw.dictCode ?? raw.DictCode ?? ''),
|
||||
value: String(raw.dictCode ?? raw.DictCode ?? ''),
|
||||
}))
|
||||
.filter((o) => o.label && o.value)
|
||||
|
||||
timingOptions.value = mapDict(timingItems)
|
||||
if (!timingOptions.value.some((o) => o.value === 'forecast')) {
|
||||
timingOptions.value.unshift({ label: '预报', value: 'forecast' })
|
||||
}
|
||||
|
||||
operationOptions.value = mapDict(operationItems)
|
||||
} catch (e: any) {
|
||||
$baseMessage(e?.message || '加载字典失败', 'error')
|
||||
timingOptions.value = [
|
||||
{ label: '预报', value: 'forecast' },
|
||||
{ label: '下单', value: 'order_create' },
|
||||
{ label: '入库', value: 'inbound' },
|
||||
{ label: '出库', value: 'outbound' },
|
||||
]
|
||||
operationOptions.value = [
|
||||
{ label: '拦截', value: 'intercept' },
|
||||
{ label: '警告', value: 'warn' },
|
||||
{ label: '问题件', value: 'problem' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTimingCode(saved: string): string {
|
||||
const s = String(saved ?? '').trim()
|
||||
if (!s) return timingOptions.value[0]?.value ?? 'forecast'
|
||||
const byValue = timingOptions.value.find((o) => o.value === s)
|
||||
if (byValue) return byValue.value
|
||||
const byLabel = timingOptions.value.find((o) => o.label === s)
|
||||
if (byLabel) return byLabel.value
|
||||
const hit = Object.entries(TIMING_SAVE_LABEL).find(([, label]) => label === s)
|
||||
return hit?.[0] ?? s
|
||||
}
|
||||
|
||||
function resolveOperationCode(saved: string): string {
|
||||
const s = String(saved ?? '').trim()
|
||||
if (!s) return 'intercept'
|
||||
const byValue = operationOptions.value.find((o) => o.value === s)
|
||||
if (byValue) return byValue.value
|
||||
const byLabel = operationOptions.value.find((o) => o.label === s)
|
||||
if (byLabel) return byLabel.value
|
||||
const hit = Object.entries(OPERATION_SAVE_LABEL).find(([, label]) => label === s)
|
||||
return hit?.[0] ?? s
|
||||
}
|
||||
|
||||
function timingLabel(code: string): string {
|
||||
return timingOptions.value.find((o) => o.value === code)?.label
|
||||
|| TIMING_SAVE_LABEL[code]
|
||||
|| code
|
||||
}
|
||||
|
||||
function operationLabel(code: string): string {
|
||||
return operationOptions.value.find((o) => o.value === code)?.label
|
||||
|| OPERATION_SAVE_LABEL[code]
|
||||
|| code
|
||||
}
|
||||
|
||||
function timingSaveValue(code: string): string {
|
||||
return TIMING_SAVE_LABEL[code] || timingLabel(code)
|
||||
}
|
||||
|
||||
function operationSaveValue(code: string): string {
|
||||
return OPERATION_SAVE_LABEL[code] || operationLabel(code)
|
||||
}
|
||||
|
||||
const [registerAutoTriggerModal, modalMethods] = useModal() as any
|
||||
|
||||
const formRef = ref<InstanceType<typeof FormPro> | null>(null)
|
||||
const editId = ref<number | null>(null)
|
||||
const legacyConditionText = ref('')
|
||||
const formulaText = ref('')
|
||||
const formulaEditorRef = ref<InstanceType<typeof FormulaConditionEditor> | null>(null)
|
||||
const testLoading = ref(false)
|
||||
const testResult = ref<{ matched: boolean; error?: string } | null>(null)
|
||||
const testSample = reactive({
|
||||
consigneeCountry: 'GB',
|
||||
customerName: '阿里国际站/深圳一达通',
|
||||
productCode: 'EX_ASP_HKDHL_YD',
|
||||
length: 65,
|
||||
width: 40,
|
||||
height: 30,
|
||||
})
|
||||
|
||||
const formState = reactive({
|
||||
const formModel = reactive({
|
||||
status: true,
|
||||
operation: '拦截',
|
||||
triggerTiming: '',
|
||||
triggerCondition: '',
|
||||
ruleName: '',
|
||||
priority: 100,
|
||||
operationCode: 'intercept',
|
||||
timingCode: 'forecast',
|
||||
promptMessage: '',
|
||||
notifier: '',
|
||||
createTime: '',
|
||||
creator: '',
|
||||
})
|
||||
|
||||
const formItemList = computed(() => {
|
||||
const requiredMsg = t('system.serviceProviders.modal.rules.requiredInput')
|
||||
return [
|
||||
{
|
||||
label: t('channel.autoTrigger.table.status'),
|
||||
field: 'status',
|
||||
comp: 'ASelect',
|
||||
span: 24,
|
||||
rules: [{ required: true, message: requiredMsg }],
|
||||
compProps: {
|
||||
placeholder: t('common.selectPlaceholder'),
|
||||
options: [
|
||||
{ label: t('channel.autoTrigger.message.statusEnabled'), value: true },
|
||||
{ label: t('channel.autoTrigger.message.statusDisabled'), value: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('channel.autoTrigger.table.operation'),
|
||||
field: 'operation',
|
||||
comp: 'ASelect',
|
||||
span: 24,
|
||||
rules: [{ required: true, message: requiredMsg }],
|
||||
compProps: {
|
||||
placeholder: t('common.selectPlaceholder'),
|
||||
options: [
|
||||
{ label: '拦截', value: '拦截' },
|
||||
{ label: '警告', value: '警告' },
|
||||
{ label: '问题件', value: '问题件' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('channel.autoTrigger.table.triggerTiming'),
|
||||
field: 'triggerTiming',
|
||||
comp: 'AInput',
|
||||
span: 24,
|
||||
rules: [{ required: true, message: requiredMsg }],
|
||||
compProps: { placeholder: t('channel.area.form.placeholder.input') },
|
||||
},
|
||||
{
|
||||
label: t('channel.autoTrigger.table.triggerCondition'),
|
||||
field: 'triggerCondition',
|
||||
comp: 'ATextarea',
|
||||
span: 24,
|
||||
rules: [{ required: true, message: requiredMsg }],
|
||||
compProps: {
|
||||
placeholder: t('channel.area.form.placeholder.input'),
|
||||
autoSize: { minRows: 3, maxRows: 6 },
|
||||
showWordLimit: true,
|
||||
maxlength: 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('channel.autoTrigger.table.promptMessage'),
|
||||
field: 'promptMessage',
|
||||
comp: 'AInput',
|
||||
span: 24,
|
||||
rules: [{ required: true, message: requiredMsg }],
|
||||
compProps: { placeholder: t('channel.area.form.placeholder.input') },
|
||||
},
|
||||
{
|
||||
label: t('channel.autoTrigger.table.notifier'),
|
||||
field: 'notifier',
|
||||
comp: 'AInput',
|
||||
span: 24,
|
||||
compProps: { placeholder: t('channel.area.form.placeholder.input'), allowClear: true },
|
||||
},
|
||||
{
|
||||
label: t('channel.autoTrigger.table.createTime'),
|
||||
field: 'createTime',
|
||||
comp: 'AInput',
|
||||
span: 24,
|
||||
compProps: { disabled: true },
|
||||
},
|
||||
{
|
||||
label: t('channel.autoTrigger.table.creator'),
|
||||
field: 'creator',
|
||||
comp: 'AInput',
|
||||
span: 24,
|
||||
compProps: { disabled: true },
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
async function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editId.value = null
|
||||
Object.assign(formState, {
|
||||
function resetFormModel() {
|
||||
formulaText.value = ''
|
||||
legacyConditionText.value = ''
|
||||
Object.assign(formModel, {
|
||||
status: true,
|
||||
operation: '拦截',
|
||||
triggerTiming: '',
|
||||
triggerCondition: '',
|
||||
ruleName: '',
|
||||
priority: 100,
|
||||
operationCode: 'intercept',
|
||||
timingCode: 'forecast',
|
||||
promptMessage: '',
|
||||
notifier: '',
|
||||
createTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
creator: '当前用户',
|
||||
})
|
||||
modalMethods.setModalProps({ title: modalTitle.value, width: 720 })
|
||||
}
|
||||
|
||||
function applyExampleRule() {
|
||||
formulaText.value = getOversizeExampleFormula()
|
||||
formModel.ruleName = '英国阿里超尺寸拦截'
|
||||
formModel.priority = 100
|
||||
formModel.timingCode = 'forecast'
|
||||
formModel.operationCode = 'intercept'
|
||||
formModel.promptMessage = '超尺寸,禁止下单'
|
||||
Object.assign(testSample, {
|
||||
consigneeCountry: 'GB',
|
||||
customerName: '阿里国际站/深圳一达通',
|
||||
productCode: 'EX_ASP_HKDHL_YD',
|
||||
length: 65,
|
||||
width: 40,
|
||||
height: 30,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleTestFormula() {
|
||||
const validateErr = validateFormulaExpression(formulaText.value)
|
||||
if (validateErr) {
|
||||
$baseMessage(validateErr, 'warning')
|
||||
return
|
||||
}
|
||||
testLoading.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const result = await testAutoTriggerFormula({
|
||||
expression: formulaText.value.trim(),
|
||||
sample: {
|
||||
consigneeCountry: testSample.consigneeCountry?.trim() || undefined,
|
||||
customerName: testSample.customerName?.trim() || undefined,
|
||||
productCode: testSample.productCode?.trim() || undefined,
|
||||
length: Number(testSample.length) || undefined,
|
||||
width: Number(testSample.width) || undefined,
|
||||
height: Number(testSample.height) || undefined,
|
||||
},
|
||||
})
|
||||
testResult.value = { matched: !!result?.matched, error: result?.error }
|
||||
$baseMessage(
|
||||
result?.error
|
||||
? result.error
|
||||
: result?.matched
|
||||
? t('channel.autoTrigger.form.testMatched')
|
||||
: t('channel.autoTrigger.form.testNotMatched'),
|
||||
result?.error ? 'warning' : result?.matched ? 'success' : 'info'
|
||||
)
|
||||
} catch (e: any) {
|
||||
testResult.value = { matched: false, error: e?.message }
|
||||
$baseMessage(e?.message || t('channel.autoTrigger.form.testFailed'), 'error')
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editId.value = null
|
||||
resetFormModel()
|
||||
modalMethods.setModalProps({ title: modalTitle.value, width: 1000 })
|
||||
modalMethods.openModal(true)
|
||||
await nextTick()
|
||||
formRef.value?.setValues?.(formState)
|
||||
}
|
||||
|
||||
async function openEditModal(row: AutoTriggerRow) {
|
||||
modalMode.value = 'edit'
|
||||
editId.value = row.id
|
||||
Object.assign(formState, {
|
||||
const parsed = parseStoredCondition(row.triggerCondition)
|
||||
formulaText.value = parsed.expression
|
||||
legacyConditionText.value = parsed.legacyText || ''
|
||||
Object.assign(formModel, {
|
||||
status: row.status,
|
||||
operation: row.operation,
|
||||
triggerTiming: row.triggerTiming,
|
||||
triggerCondition: row.triggerCondition,
|
||||
ruleName: parsed.ruleName || row.ruleName || '',
|
||||
priority: row.priority ?? parsed.priority ?? 100,
|
||||
operationCode: resolveOperationCode(row.operation),
|
||||
timingCode: resolveTimingCode(row.triggerTiming),
|
||||
promptMessage: row.promptMessage,
|
||||
notifier: row.notifier,
|
||||
createTime: row.createTime,
|
||||
creator: row.creator,
|
||||
})
|
||||
modalMethods.setModalProps({ title: modalTitle.value, width: 720 })
|
||||
modalMethods.setModalProps({ title: modalTitle.value, width: 1000 })
|
||||
modalMethods.openModal(true)
|
||||
await nextTick()
|
||||
formRef.value?.setValues?.(formState)
|
||||
}
|
||||
|
||||
// 编辑入口(Handsontable 操作列)
|
||||
@ -265,17 +353,33 @@ function handleAdd() {
|
||||
}
|
||||
|
||||
async function handleAutoTriggerModalOk() {
|
||||
if (!formModel.ruleName?.trim()) {
|
||||
$baseMessage(t('channel.autoTrigger.form.ruleNameRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
if (!formModel.promptMessage?.trim()) {
|
||||
$baseMessage(t('channel.autoTrigger.form.promptRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
const validateErr = validateFormulaExpression(formulaText.value)
|
||||
if (validateErr) {
|
||||
$baseMessage(validateErr, 'warning')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
status: formModel.status ? 1 : 0,
|
||||
ruleName: formModel.ruleName.trim(),
|
||||
priority: Number(formModel.priority) || 100,
|
||||
operation: operationSaveValue(formModel.operationCode),
|
||||
triggerTiming: timingSaveValue(formModel.timingCode),
|
||||
triggerCondition: serializeStoredCondition(formulaText.value, {
|
||||
ruleName: formModel.ruleName.trim(),
|
||||
priority: Number(formModel.priority) || 100,
|
||||
}),
|
||||
promptMessage: formModel.promptMessage.trim(),
|
||||
notifier: formModel.notifier?.trim() || '',
|
||||
}
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
const values = formRef.value?.getValues?.() || {}
|
||||
const payload = {
|
||||
status: values.status ? 1 : 0,
|
||||
operation: values.operation,
|
||||
triggerTiming: values.triggerTiming,
|
||||
triggerCondition: values.triggerCondition,
|
||||
promptMessage: values.promptMessage,
|
||||
notifier: values.notifier || '',
|
||||
}
|
||||
if (modalMode.value === 'add') {
|
||||
await createAutoTrigger(payload)
|
||||
currentPage.value = 1
|
||||
@ -288,8 +392,8 @@ async function handleAutoTriggerModalOk() {
|
||||
}
|
||||
modalMethods.closeModal()
|
||||
await loadList()
|
||||
} catch (_) {
|
||||
// 校验失败由表单组件自行提示
|
||||
} catch (e: any) {
|
||||
$baseMessage(e?.message || '保存失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@ -310,13 +414,14 @@ const handlePageSizeChange = (size: number) => {
|
||||
|
||||
const hotColumns = computed(() => [
|
||||
{ key: 'status', label: t('channel.autoTrigger.table.status'), width: 80 },
|
||||
{ key: 'operation', label: t('channel.autoTrigger.table.operation'), width: 110 },
|
||||
{ key: 'triggerTiming', label: t('channel.autoTrigger.table.triggerTiming'), width: 110 },
|
||||
{ key: 'triggerCondition', label: t('channel.autoTrigger.table.triggerCondition'), width: 320 },
|
||||
{ key: 'promptMessage', label: t('channel.autoTrigger.table.promptMessage'), width: 240 },
|
||||
{ key: 'notifier', label: t('channel.autoTrigger.table.notifier'), width: 120 },
|
||||
{ key: 'createTime', label: t('channel.autoTrigger.table.createTime'), width: 170 },
|
||||
{ key: 'creator', label: t('channel.autoTrigger.table.creator'), width: 110 }
|
||||
{ key: 'ruleName', label: t('channel.autoTrigger.table.ruleName'), width: 160 },
|
||||
{ key: 'operation', label: t('channel.autoTrigger.table.operation'), width: 90 },
|
||||
{ key: 'triggerTiming', label: t('channel.autoTrigger.table.triggerTiming'), width: 90 },
|
||||
{ key: 'conditionSummary', label: t('channel.autoTrigger.table.triggerCondition'), width: 360 },
|
||||
{ key: 'promptMessage', label: t('channel.autoTrigger.table.promptMessage'), width: 200 },
|
||||
{ key: 'notifier', label: t('channel.autoTrigger.table.notifier'), width: 100 },
|
||||
{ key: 'createTime', label: t('channel.autoTrigger.table.createTime'), width: 160 },
|
||||
{ key: 'creator', label: t('channel.autoTrigger.table.creator'), width: 100 },
|
||||
])
|
||||
|
||||
// 与其他 Handsontable 页面一致:捕获阶段处理内联开关,避免 Handsontable 抢事件
|
||||
@ -357,11 +462,16 @@ function customCellRenderer(row: number, col: number, value: any, rowData: AutoT
|
||||
}
|
||||
|
||||
if (colKey === 'operation') {
|
||||
const op = rowData.operation
|
||||
const op = operationLabel(resolveOperationCode(rowData.operation))
|
||||
const cls = op === '拦截' ? 'intercept' : op === '警告' ? 'warning' : op === '问题件' ? 'problem' : ''
|
||||
return `<span class="operation-tag ${cls}">${op}</span>`
|
||||
}
|
||||
|
||||
if (colKey === 'conditionSummary') {
|
||||
const text = rowData.conditionSummary || '—'
|
||||
return `<span class="condition-summary-cell" title="${text.replace(/"/g, '"')}">${text}</span>`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@ -409,6 +519,7 @@ function handleActionClick(row: AutoTriggerRow, rowIndex: number, action?: strin
|
||||
const handsontableRef = ref<InstanceType<typeof HandsontablePro> | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
void loadDictOptions()
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
@ -822,6 +933,99 @@ onMounted(() => {
|
||||
background: #fff1f0;
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
:deep(.condition-summary-cell) {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
.auto-trigger-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.legacy-alert {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
&.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
|
||||
&.required::before {
|
||||
content: '*';
|
||||
color: #f53f3f;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.condition-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.test-sample-panel {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed #e5e6eb;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
|
||||
.test-sample-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4e5969;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.test-sample-grid {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.test-result-alert {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
@ -906,20 +1110,143 @@ onMounted(() => {
|
||||
|
||||
<ModalPro
|
||||
:title="modalTitle"
|
||||
:width="720"
|
||||
:width="1000"
|
||||
@register="registerAutoTriggerModal"
|
||||
@ok="handleAutoTriggerModalOk"
|
||||
@cancel="handleAutoTriggerModalCancel"
|
||||
>
|
||||
<FormPro
|
||||
ref="formRef"
|
||||
:item-list="formItemList"
|
||||
:label-col-props="{ span: 6 }"
|
||||
:wrapper-col-props="{ span: 16 }"
|
||||
:row-gutter="20"
|
||||
:default-span="6"
|
||||
form-layout="horizontal"
|
||||
/>
|
||||
<div class="auto-trigger-form">
|
||||
<a-alert
|
||||
v-if="legacyConditionText"
|
||||
type="warning"
|
||||
class="legacy-alert"
|
||||
:title="t('channel.autoTrigger.form.legacyTitle')"
|
||||
>
|
||||
{{ legacyConditionText }}
|
||||
</a-alert>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-row">
|
||||
<label class="form-label required">{{ t('channel.autoTrigger.table.ruleName') }}</label>
|
||||
<a-input
|
||||
v-model="formModel.ruleName"
|
||||
:placeholder="t('channel.autoTrigger.form.ruleNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.table.status') }}</label>
|
||||
<a-select v-model="formModel.status">
|
||||
<a-option :value="true">{{ t('channel.autoTrigger.message.statusEnabled') }}</a-option>
|
||||
<a-option :value="false">{{ t('channel.autoTrigger.message.statusDisabled') }}</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label required">{{ t('channel.autoTrigger.table.triggerTiming') }}</label>
|
||||
<a-select v-model="formModel.timingCode" :placeholder="t('common.selectPlaceholder')">
|
||||
<a-option v-for="opt in timingOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label required">{{ t('channel.autoTrigger.table.operation') }}</label>
|
||||
<a-select v-model="formModel.operationCode" :placeholder="t('common.selectPlaceholder')">
|
||||
<a-option v-for="opt in operationOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.table.priority') }}</label>
|
||||
<a-input-number
|
||||
v-model="formModel.priority"
|
||||
:min="1"
|
||||
:max="9999"
|
||||
hide-button
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row" />
|
||||
</div>
|
||||
|
||||
<div class="condition-section">
|
||||
<div class="section-head">
|
||||
<label class="form-label required">{{ t('channel.autoTrigger.table.triggerCondition') }}</label>
|
||||
<div class="section-actions">
|
||||
<a-button type="text" size="small" @click="applyExampleRule">
|
||||
{{ t('channel.autoTrigger.form.loadExample') }}
|
||||
</a-button>
|
||||
<a-button type="text" size="small" :loading="testLoading" @click="handleTestFormula">
|
||||
{{ t('channel.autoTrigger.form.testFormula') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<FormulaConditionEditor ref="formulaEditorRef" v-model="formulaText" />
|
||||
<div class="test-sample-panel">
|
||||
<div class="test-sample-title">{{ t('channel.autoTrigger.form.testSample') }}</div>
|
||||
<div class="form-grid test-sample-grid">
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.sampleCountry') }}</label>
|
||||
<a-input v-model="testSample.consigneeCountry" allow-clear />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.sampleCustomer') }}</label>
|
||||
<a-input v-model="testSample.customerName" allow-clear />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.sampleProduct') }}</label>
|
||||
<a-input v-model="testSample.productCode" allow-clear />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.sampleLength') }}</label>
|
||||
<a-input-number v-model="testSample.length" :min="0" hide-button style="width: 100%" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.sampleWidth') }}</label>
|
||||
<a-input-number v-model="testSample.width" :min="0" hide-button style="width: 100%" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.sampleHeight') }}</label>
|
||||
<a-input-number v-model="testSample.height" :min="0" hide-button style="width: 100%" />
|
||||
</div>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="testResult"
|
||||
class="test-result-alert"
|
||||
:type="testResult.error ? 'warning' : testResult.matched ? 'success' : 'info'"
|
||||
:title="testResult.error || (testResult.matched ? t('channel.autoTrigger.form.testMatched') : t('channel.autoTrigger.form.testNotMatched'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-row full">
|
||||
<label class="form-label required">{{ t('channel.autoTrigger.table.promptMessage') }}</label>
|
||||
<a-input
|
||||
v-model="formModel.promptMessage"
|
||||
:placeholder="t('channel.autoTrigger.form.promptPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row full">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.table.notifier') }}</label>
|
||||
<a-input
|
||||
v-model="formModel.notifier"
|
||||
:placeholder="t('channel.autoTrigger.form.notifierPlaceholder')"
|
||||
allow-clear
|
||||
/>
|
||||
</div>
|
||||
<template v-if="modalMode === 'edit'">
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.table.createTime') }}</label>
|
||||
<a-input v-model="formModel.createTime" disabled />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.table.creator') }}</label>
|
||||
<a-input v-model="formModel.creator" disabled />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPro>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,245 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { postForewarningServiceKpiAreaGetKpiAreaInfos } from '@/api/channel/kpiArea';
|
||||
import { postWeightRuleInfoList } from '@/api/channel/weightRule';
|
||||
import { postWeightRoundingInfoList } from '@/api/channel/weightRounding';
|
||||
|
||||
export type ReferencePreviewType =
|
||||
| 'kpi-blacklist'
|
||||
| 'kpi-remote'
|
||||
| 'kpi-price'
|
||||
| 'weight-rule'
|
||||
| 'weight-rounding';
|
||||
|
||||
type OpenPayload = {
|
||||
type: ReferencePreviewType;
|
||||
id: number | string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const WEIGHT_RULE_CONDITION_DICT = 'weight_rule_condition';
|
||||
const BILLABLE_WEIGHT_DICT = 'billable_weight';
|
||||
|
||||
const props = defineProps<{
|
||||
dictLabelFn?: (dictType: string, code: string | undefined | null) => string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const previewType = ref<ReferencePreviewType>('kpi-blacklist');
|
||||
const previewName = ref('');
|
||||
const tableData = ref<Record<string, unknown>[]>([]);
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 30,
|
||||
total: 0,
|
||||
showTotal: true,
|
||||
showPageSize: true,
|
||||
pageSizeOptions: [10, 30, 50, 100],
|
||||
});
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
t('channel.salesProducts.referencePreview.title', { name: previewName.value })
|
||||
);
|
||||
|
||||
const columns = computed(() => {
|
||||
if (
|
||||
previewType.value === 'kpi-blacklist' ||
|
||||
previewType.value === 'kpi-remote' ||
|
||||
previewType.value === 'kpi-price'
|
||||
) {
|
||||
return [
|
||||
{
|
||||
title: t('channel.area.table.serialNo'),
|
||||
dataIndex: 'index',
|
||||
width: 72,
|
||||
align: 'center' as const,
|
||||
render: ({ rowIndex }: { rowIndex: number }) =>
|
||||
(pagination.value.current - 1) * pagination.value.pageSize + rowIndex + 1,
|
||||
},
|
||||
{ title: t('channel.area.form.country'), dataIndex: 'countryName', ellipsis: true, tooltip: true },
|
||||
{ title: t('channel.area.form.province'), dataIndex: 'province', ellipsis: true, tooltip: true },
|
||||
{ title: t('channel.area.form.city'), dataIndex: 'city', ellipsis: true, tooltip: true },
|
||||
{ title: t('channel.area.table.leftPostcode'), dataIndex: 'leftPostcode', width: 120 },
|
||||
{ title: t('channel.area.table.rightPostcode'), dataIndex: 'rightPostcode', width: 120 },
|
||||
];
|
||||
}
|
||||
if (previewType.value === 'weight-rule') {
|
||||
return [
|
||||
{
|
||||
title: t('channel.weightRules.table.serialNo'),
|
||||
dataIndex: 'index',
|
||||
width: 72,
|
||||
align: 'center' as const,
|
||||
render: ({ rowIndex }: { rowIndex: number }) =>
|
||||
(pagination.value.current - 1) * pagination.value.pageSize + rowIndex + 1,
|
||||
},
|
||||
{
|
||||
title: t('channel.weightRules.table.condition'),
|
||||
dataIndex: 'condition',
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
render: ({ record }: { record: Record<string, unknown> }) =>
|
||||
resolveDictLabel(WEIGHT_RULE_CONDITION_DICT, record.condition as string) ||
|
||||
String(record.condition ?? ''),
|
||||
},
|
||||
{
|
||||
title: t('channel.weightRules.table.billableWeight'),
|
||||
dataIndex: 'billableWeight',
|
||||
width: 140,
|
||||
render: ({ record }: { record: Record<string, unknown> }) =>
|
||||
resolveDictLabel(BILLABLE_WEIGHT_DICT, record.billableWeight as string) ||
|
||||
String(record.billableWeight ?? ''),
|
||||
},
|
||||
{
|
||||
title: t('channel.weightRules.table.volumetricFactor'),
|
||||
dataIndex: 'volumetricFactor',
|
||||
width: 120,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: t('channel.weightRounding.table.serialNo'),
|
||||
dataIndex: 'index',
|
||||
width: 72,
|
||||
align: 'center' as const,
|
||||
render: ({ rowIndex }: { rowIndex: number }) =>
|
||||
(pagination.value.current - 1) * pagination.value.pageSize + rowIndex + 1,
|
||||
},
|
||||
{ title: t('channel.weightRounding.table.startValue'), dataIndex: 'startValue', width: 120 },
|
||||
{ title: t('channel.weightRounding.table.endValue'), dataIndex: 'endValue', width: 120 },
|
||||
{ title: t('channel.weightRounding.table.public'), dataIndex: 'public', ellipsis: true, tooltip: true },
|
||||
];
|
||||
});
|
||||
|
||||
function resolveDictLabel(dictType: string, code: string | undefined | null): string {
|
||||
return props.dictLabelFn?.(dictType, code) ?? '';
|
||||
}
|
||||
|
||||
function normalizeKpiRow(raw: Record<string, unknown>) {
|
||||
return {
|
||||
countryName: String(raw.countryName ?? raw.CountryName ?? ''),
|
||||
province: String(raw.province ?? raw.Province ?? ''),
|
||||
city: String(raw.city ?? raw.City ?? ''),
|
||||
leftPostcode: String(raw.leftPostcode ?? raw.LeftPostcode ?? ''),
|
||||
rightPostcode: String(raw.rightPostcode ?? raw.RightPostcode ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPreviewData(type: ReferencePreviewType, id: number | string) {
|
||||
loading.value = true;
|
||||
tableData.value = [];
|
||||
try {
|
||||
if (type === 'kpi-blacklist' || type === 'kpi-remote' || type === 'kpi-price') {
|
||||
const res = await postForewarningServiceKpiAreaGetKpiAreaInfos({
|
||||
areaId: id,
|
||||
pageIndex: 1,
|
||||
pageSize: 500,
|
||||
});
|
||||
const items = (res?.items ?? (res as any)?.data?.items ?? []) as Record<string, unknown>[];
|
||||
tableData.value = items.map(normalizeKpiRow);
|
||||
} else if (type === 'weight-rule') {
|
||||
const res = await postWeightRuleInfoList({
|
||||
ruleId: id,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
});
|
||||
tableData.value = (res?.items ?? []) as Record<string, unknown>[];
|
||||
} else {
|
||||
const res = await postWeightRoundingInfoList({
|
||||
roundingId: id,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
});
|
||||
tableData.value = (res?.items ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
pagination.value.total = tableData.value.length;
|
||||
pagination.value.current = 1;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || t('channel.salesProducts.referencePreview.loadFailed'));
|
||||
tableData.value = [];
|
||||
pagination.value.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.value.current = page;
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
pagination.value.pageSize = pageSize;
|
||||
pagination.value.current = 1;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function open(payload: OpenPayload) {
|
||||
previewType.value = payload.type;
|
||||
previewName.value = payload.name;
|
||||
visible.value = true;
|
||||
await loadPreviewData(payload.type, payload.id);
|
||||
}
|
||||
|
||||
function getRowKey(record: Record<string, unknown>, index: number) {
|
||||
return String(record.id ?? record.Id ?? index);
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
:title="modalTitle"
|
||||
:width="920"
|
||||
unmount-on-close
|
||||
:footer="false"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<a-spin :loading="loading" class="preview-spin">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:pagination="pagination"
|
||||
:scroll="{ x: '100%', y: 420 }"
|
||||
size="small"
|
||||
:row-key="getRowKey"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #empty>
|
||||
<a-empty :description="t('channel.salesProducts.referencePreview.empty')" />
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
<div class="preview-footer">
|
||||
<a-button type="primary" @click="handleClose">
|
||||
{{ t('channel.salesProducts.modal.actions.ok') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.preview-spin {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
import { IconEye } from '@arco-design/web-vue/es/icon';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
hasValue?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-label-with-preview">
|
||||
<label class="form-label">{{ label }}</label>
|
||||
<a-tooltip :content="t('channel.salesProducts.referencePreview.view')">
|
||||
<button
|
||||
type="button"
|
||||
class="label-preview-btn"
|
||||
:class="{ disabled: !hasValue }"
|
||||
:disabled="!hasValue"
|
||||
@click.stop="emit('preview')"
|
||||
>
|
||||
<icon-eye />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.form-label-with-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.label-preview-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--app-primary-color, #2d8c8c);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&.disabled,
|
||||
&:disabled {
|
||||
color: #c9cdd4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -22,7 +22,7 @@
|
||||
import { getWarehouseList } from '@/api/system/warehouse';
|
||||
import { postWeightRuleList } from '@/api/channel/weightRule';
|
||||
import { postWeightRoundingList } from '@/api/channel/weightRounding';
|
||||
import { getDictList } from '@/api/system/dict';
|
||||
import { getDictList, getDictListAll } from '@/api/system/dict';
|
||||
import { getServiceChannelList } from '@/api/channel/serviceChannel';
|
||||
import {
|
||||
canonicalWeightFactorCellForApi,
|
||||
@ -30,6 +30,10 @@
|
||||
weightValueToIntervalCell,
|
||||
} from '@/utils/priceWeightInterval';
|
||||
import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestionCircleFill } from '@arco-design/web-vue/es/icon';
|
||||
import FormLabelWithPreview from './components/FormLabelWithPreview.vue';
|
||||
import ChannelReferencePreviewModal, {
|
||||
type ReferencePreviewType,
|
||||
} from './components/ChannelReferencePreviewModal.vue';
|
||||
import { Message, Modal, Input } from '@arco-design/web-vue';
|
||||
import ModalPro from '@/components/ModalPro/index.vue';
|
||||
import useModal from '@/components/ModalPro/useModal';
|
||||
@ -101,7 +105,7 @@
|
||||
const formData = reactive({
|
||||
productCode: '',
|
||||
productName: '',
|
||||
group: '专线',
|
||||
group: '',
|
||||
minTimeliness: '',
|
||||
maxTimeliness: '',
|
||||
customerWaybill: '系统面单',
|
||||
@ -118,12 +122,49 @@
|
||||
const customerGuideSaving = ref(false);
|
||||
const customerGuidePanelRef = ref<InstanceType<typeof MarkdownDocPanel> | null>(null);
|
||||
|
||||
// 分组选项
|
||||
const groupOptions = ref([
|
||||
{ label: '专线', value: '专线' },
|
||||
{ label: '快递', value: '快递' },
|
||||
{ label: '邮政', value: '邮政' },
|
||||
]);
|
||||
type IdSelectOption = { label: string; value: string };
|
||||
|
||||
// 分组选项:字典 sales_product_group
|
||||
const SALES_PRODUCT_GROUP_DICT = 'sales_product_group';
|
||||
const groupOptions = ref<IdSelectOption[]>([]);
|
||||
|
||||
function defaultProductGroupValue(): string {
|
||||
return groupOptions.value[0]?.value ?? '';
|
||||
}
|
||||
|
||||
function resolveProductGroupValue(raw: unknown): string {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!s) return defaultProductGroupValue();
|
||||
const hit = groupOptions.value.find((o) => o.value === s || o.label === s);
|
||||
return hit?.value ?? s;
|
||||
}
|
||||
|
||||
async function loadGroupSelectOptions() {
|
||||
try {
|
||||
const items = await getDictListAll({ dictType: SALES_PRODUCT_GROUP_DICT });
|
||||
groupOptions.value = (items || [])
|
||||
.map((raw: any) => {
|
||||
const dictCode = String(raw?.dictCode ?? raw?.DictCode ?? '').trim();
|
||||
const dictName = String(raw?.dictName ?? raw?.DictName ?? '').trim();
|
||||
if (!dictCode && !dictName) return null;
|
||||
const dictSort = Number(raw?.dictSort ?? raw?.DictSort ?? 0) || 0;
|
||||
return {
|
||||
dictCode: dictCode || dictName,
|
||||
dictName: dictName || dictCode,
|
||||
dictSort,
|
||||
};
|
||||
})
|
||||
.filter((row): row is { dictCode: string; dictName: string; dictSort: number } => Boolean(row))
|
||||
.sort((a, b) => a.dictSort - b.dictSort || String(a.dictCode).localeCompare(String(b.dictCode)))
|
||||
.map((row) => ({
|
||||
label: row.dictName || row.dictCode,
|
||||
value: row.dictCode,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载产品分组失败');
|
||||
groupOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 客户面单选项
|
||||
const customerWaybillOptions = ref([
|
||||
@ -131,8 +172,6 @@
|
||||
{ label: '客户面单', value: '客户面单' },
|
||||
]);
|
||||
|
||||
type IdSelectOption = { label: string; value: string };
|
||||
|
||||
const weightFactorOptions: IdSelectOption[] = Array.from({ length: 20 }, (_, index) => {
|
||||
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '');
|
||||
return { label: v, value: v };
|
||||
@ -211,6 +250,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
const referencePreviewRef = ref<InstanceType<typeof ChannelReferencePreviewModal> | null>(null);
|
||||
|
||||
function openReferencePreview(
|
||||
type: ReferencePreviewType,
|
||||
rawId: string | number | undefined | null,
|
||||
options: IdSelectOption[]
|
||||
) {
|
||||
const id = toOptionalPositiveId(rawId);
|
||||
if (!id) {
|
||||
Message.warning(t('channel.salesProducts.referencePreview.noSelection'));
|
||||
return;
|
||||
}
|
||||
void ensureSalesPriceDictLoaded().then(() => {
|
||||
const hit = options.find((o) => String(o.value) === String(rawId));
|
||||
const name = hit?.label || String(id);
|
||||
referencePreviewRef.value?.open({ type, id, name });
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWeightSelectOptions() {
|
||||
try {
|
||||
const [ruleRes, roundingRes] = await Promise.all([
|
||||
@ -575,7 +633,7 @@
|
||||
const addProductFormData = reactive({
|
||||
productCode: '',
|
||||
productName: '',
|
||||
group: '专线',
|
||||
group: '',
|
||||
minTimeliness: '',
|
||||
maxTimeliness: '',
|
||||
customerWaybill: '系统面单',
|
||||
@ -594,7 +652,7 @@
|
||||
Object.assign(addProductFormData, {
|
||||
productCode: '',
|
||||
productName: '',
|
||||
group: '专线',
|
||||
group: defaultProductGroupValue(),
|
||||
minTimeliness: '',
|
||||
maxTimeliness: '',
|
||||
customerWaybill: '系统面单',
|
||||
@ -622,7 +680,7 @@
|
||||
await createSalesProduct({
|
||||
productCode: addProductFormData.productCode.trim(),
|
||||
productName: addProductFormData.productName.trim(),
|
||||
productGroup: addProductFormData.group || '专线',
|
||||
productGroup: addProductFormData.group || defaultProductGroupValue(),
|
||||
minTimeliness: Number(addProductFormData.minTimeliness) || 0,
|
||||
maxTimeliness: Number(addProductFormData.maxTimeliness) || 0,
|
||||
customerWaybill: addProductFormData.customerWaybill || '系统面单',
|
||||
@ -899,7 +957,7 @@
|
||||
Object.assign(formData, {
|
||||
productCode: p.productCode ?? p.ProductCode ?? '',
|
||||
productName: p.productName ?? p.ProductName ?? '',
|
||||
group: p.productGroup ?? p.ProductGroup ?? '专线',
|
||||
group: resolveProductGroupValue(p.productGroup ?? p.ProductGroup),
|
||||
minTimeliness: numStr(p.minTimeliness ?? p.MinTimeliness),
|
||||
maxTimeliness: numStr(p.maxTimeliness ?? p.MaxTimeliness),
|
||||
customerWaybill: p.customerWaybill ?? p.CustomerWaybill ?? '系统面单',
|
||||
@ -920,6 +978,7 @@
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadGroupSelectOptions();
|
||||
void loadKpiAreaSelectOptions();
|
||||
void loadWeightSelectOptions();
|
||||
void loadBillingFactorSelectOptions();
|
||||
@ -2974,6 +3033,7 @@
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.group') }}</label>
|
||||
<a-select
|
||||
v-model="formData.group"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
class="form-select"
|
||||
>
|
||||
<a-option
|
||||
@ -3021,7 +3081,11 @@
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.blacklistArea') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.blacklistArea')"
|
||||
:has-value="!!formData.blacklistArea"
|
||||
@preview="openReferencePreview('kpi-blacklist', formData.blacklistArea, blacklistAreaOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="formData.blacklistArea"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3041,7 +3105,11 @@
|
||||
<!-- 第三行 -->
|
||||
<div class="form-row">
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.remoteAreaDivision') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.remoteAreaDivision')"
|
||||
:has-value="!!formData.remoteAreaDivision"
|
||||
@preview="openReferencePreview('kpi-remote', formData.remoteAreaDivision, remoteAreaDivisionOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="formData.remoteAreaDivision"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3057,7 +3125,11 @@
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.priceArea') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.priceArea')"
|
||||
:has-value="!!formData.priceArea"
|
||||
@preview="openReferencePreview('kpi-price', formData.priceArea, priceAreaFactorOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="formData.priceArea"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3074,7 +3146,11 @@
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.weightCalculationRule') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.weightCalculationRule')"
|
||||
:has-value="!!formData.weightCalculationRule"
|
||||
@preview="openReferencePreview('weight-rule', formData.weightCalculationRule, weightCalculationRuleOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="formData.weightCalculationRule"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3090,7 +3166,11 @@
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.weightRounding') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.weightRounding')"
|
||||
:has-value="!!formData.weightRounding"
|
||||
@preview="openReferencePreview('weight-rounding', formData.weightRounding, weightRoundingOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="formData.weightRounding"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3508,6 +3588,7 @@
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.group') }}</label>
|
||||
<a-select
|
||||
v-model="addProductFormData.group"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
class="form-select"
|
||||
>
|
||||
<a-option
|
||||
@ -3555,7 +3636,11 @@
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.blacklistArea') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.blacklistArea')"
|
||||
:has-value="!!addProductFormData.blacklistArea"
|
||||
@preview="openReferencePreview('kpi-blacklist', addProductFormData.blacklistArea, blacklistAreaOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="addProductFormData.blacklistArea"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3575,7 +3660,11 @@
|
||||
<!-- 第三行 -->
|
||||
<div class="form-row">
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.remoteAreaDivision') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.remoteAreaDivision')"
|
||||
:has-value="!!addProductFormData.remoteAreaDivision"
|
||||
@preview="openReferencePreview('kpi-remote', addProductFormData.remoteAreaDivision, remoteAreaDivisionOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="addProductFormData.remoteAreaDivision"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3591,7 +3680,11 @@
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.priceArea') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.priceArea')"
|
||||
:has-value="!!addProductFormData.priceArea"
|
||||
@preview="openReferencePreview('kpi-price', addProductFormData.priceArea, priceAreaFactorOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="addProductFormData.priceArea"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3608,7 +3701,11 @@
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.weightCalculationRule') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.weightCalculationRule')"
|
||||
:has-value="!!addProductFormData.weightCalculationRule"
|
||||
@preview="openReferencePreview('weight-rule', addProductFormData.weightCalculationRule, weightCalculationRuleOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="addProductFormData.weightCalculationRule"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3624,7 +3721,11 @@
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('channel.salesProducts.form.weightRounding') }}</label>
|
||||
<FormLabelWithPreview
|
||||
:label="t('channel.salesProducts.form.weightRounding')"
|
||||
:has-value="!!addProductFormData.weightRounding"
|
||||
@preview="openReferencePreview('weight-rounding', addProductFormData.weightRounding, weightRoundingOptions)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="addProductFormData.weightRounding"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.select')"
|
||||
@ -3895,6 +3996,8 @@
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<ChannelReferencePreviewModal ref="referencePreviewRef" :dict-label-fn="dictLabelByType" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -0,0 +1,278 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { IconDown, IconSearch } from '@arco-design/web-vue/es/icon'
|
||||
import type { PriceCheckShippingGroup } from '@/api/order'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
groups: PriceCheckShippingGroup[]
|
||||
loading?: boolean
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string]
|
||||
}>()
|
||||
|
||||
const popupVisible = ref(false)
|
||||
const activeGroupCode = ref('')
|
||||
const keyword = ref('')
|
||||
|
||||
const flatOptions = computed(() =>
|
||||
props.groups.flatMap((group) =>
|
||||
group.products.map((product) => ({
|
||||
...product,
|
||||
groupCode: group.groupCode,
|
||||
groupName: group.groupName,
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const hit = flatOptions.value.find((item) => item.productCode === props.modelValue)
|
||||
return hit?.label ?? ''
|
||||
})
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return props.groups
|
||||
return props.groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
products: group.products.filter((product) => {
|
||||
const text = `${product.label} ${product.productCode} ${product.productName}`.toLowerCase()
|
||||
return text.includes(kw)
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.products.length > 0)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.groups,
|
||||
(groups) => {
|
||||
if (!groups.length) {
|
||||
activeGroupCode.value = ''
|
||||
return
|
||||
}
|
||||
if (!groups.some((group) => group.groupCode === activeGroupCode.value)) {
|
||||
activeGroupCode.value = groups[0].groupCode
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(filteredGroups, (groups) => {
|
||||
if (!groups.length) {
|
||||
activeGroupCode.value = ''
|
||||
return
|
||||
}
|
||||
if (!groups.some((group) => group.groupCode === activeGroupCode.value)) {
|
||||
activeGroupCode.value = groups[0].groupCode
|
||||
}
|
||||
})
|
||||
|
||||
function openPopup() {
|
||||
if (props.disabled || props.loading) return
|
||||
popupVisible.value = true
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
popupVisible.value = false
|
||||
keyword.value = ''
|
||||
}
|
||||
|
||||
function selectProduct(productCode: string) {
|
||||
emit('update:modelValue', productCode)
|
||||
closePopup()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-dropdown
|
||||
v-model:popup-visible="popupVisible"
|
||||
trigger="click"
|
||||
position="bl"
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
<div
|
||||
class="shipping-tab-select-trigger"
|
||||
:class="{ disabled: disabled || loading, open: popupVisible }"
|
||||
@click="openPopup"
|
||||
>
|
||||
<span class="trigger-text" :class="{ placeholder: !selectedLabel }">
|
||||
{{ selectedLabel || placeholder || '请选择运输方式' }}
|
||||
</span>
|
||||
<icon-down class="trigger-icon" />
|
||||
</div>
|
||||
<template #content>
|
||||
<div class="shipping-tab-select-panel" @click.stop>
|
||||
<div class="panel-search">
|
||||
<a-input
|
||||
v-model="keyword"
|
||||
allow-clear
|
||||
size="small"
|
||||
:placeholder="placeholder || '搜索产品名称或代码'"
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-search />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
<a-tabs
|
||||
v-if="filteredGroups.length"
|
||||
v-model:active-key="activeGroupCode"
|
||||
type="rounded"
|
||||
size="small"
|
||||
class="group-tabs"
|
||||
>
|
||||
<a-tab-pane
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupCode"
|
||||
:title="group.groupName"
|
||||
>
|
||||
<div class="product-list">
|
||||
<button
|
||||
v-for="product in group.products"
|
||||
:key="product.productCode"
|
||||
type="button"
|
||||
class="product-item"
|
||||
:class="{ active: modelValue === product.productCode }"
|
||||
@click="selectProduct(product.productCode)"
|
||||
>
|
||||
<span class="product-name">{{ product.productName || product.productCode }}</span>
|
||||
<span class="product-code">{{ product.productCode }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
<a-empty v-else description="暂无运输方式" />
|
||||
</div>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.shipping-tab-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-color: #c9cdd4;
|
||||
}
|
||||
|
||||
&.open:not(.disabled) {
|
||||
border-color: var(--app-primary-color, #2d8c8c);
|
||||
box-shadow: 0 0 0 2px rgba(45, 140, 140, 0.12);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
background: #f7f8fa;
|
||||
color: #c9cdd4;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
color: #1d2129;
|
||||
|
||||
&.placeholder {
|
||||
color: #c9cdd4;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-icon {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
color: #86909c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shipping-tab-select-panel {
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
padding: 8px 8px 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.panel-search {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.group-tabs {
|
||||
:deep(.arco-tabs-nav) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.arco-tabs-content) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.product-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(45, 140, 140, 0.06);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(45, 140, 140, 0.12);
|
||||
|
||||
.product-name {
|
||||
color: var(--app-primary-color, #2d8c8c);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.product-code {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
</style>
|
||||
@ -6,12 +6,12 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import PaginationPro from '@/components/PaginationPro/index.vue';
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
|
||||
import { postOrderPriceCheck } from '@/api/order';
|
||||
import { postOrderPriceCheck, getOrderPriceCheckShippingOptions, type PriceCheckShippingGroup } from '@/api/order';
|
||||
import { getCountryList } from '@/api/system/country';
|
||||
import { getDictListAll } from '@/api/system/dict';
|
||||
import { getSalesProductList } from '@/api/channel/salesProduct';
|
||||
import { getWarehouseList } from '@/api/system/warehouse';
|
||||
import CustomerGuideDrawer from './components/CustomerGuideDrawer.vue';
|
||||
import ShippingMethodTabSelect from './components/ShippingMethodTabSelect.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PriceCheck',
|
||||
@ -259,7 +259,7 @@
|
||||
type SelectOption = { value: string; label: string };
|
||||
|
||||
const countryOptions = ref<SelectOption[]>([]);
|
||||
const shippingMethodOptions = ref<SelectOption[]>([]);
|
||||
const shippingMethodGroups = ref<PriceCheckShippingGroup[]>([]);
|
||||
const cargoTypeOptions = ref<SelectOption[]>([]);
|
||||
const warehouseOptions = ref<Array<{ value: number; label: string }>>([]);
|
||||
const optionsLoading = ref(false);
|
||||
@ -270,12 +270,6 @@
|
||||
return { code, name };
|
||||
}
|
||||
|
||||
function normalizeProductRow(row: any) {
|
||||
const code = String(row?.productCode ?? row?.ProductCode ?? '').trim();
|
||||
const name = String(row?.productName ?? row?.ProductName ?? '').trim();
|
||||
return { code, name };
|
||||
}
|
||||
|
||||
function normalizeDictRow(row: any) {
|
||||
const code = String(row?.dictCode ?? row?.DictCode ?? '').trim();
|
||||
const name = String(row?.dictName ?? row?.DictName ?? '').trim();
|
||||
@ -292,9 +286,9 @@
|
||||
async function loadPriceCheckOptions() {
|
||||
optionsLoading.value = true;
|
||||
try {
|
||||
const [countryRes, productRes, dictRows, warehouseRes] = await Promise.all([
|
||||
const [countryRes, shippingGroups, dictRows, warehouseRes] = await Promise.all([
|
||||
getCountryList(),
|
||||
getSalesProductList({ page: 1, pageSize: 500, isActive: 1 }),
|
||||
getOrderPriceCheckShippingOptions(),
|
||||
getDictListAll({ dictType: 'cargo_attribute' }),
|
||||
getWarehouseList({ page: 1, pageSize: 500 }),
|
||||
]);
|
||||
@ -307,14 +301,7 @@
|
||||
}));
|
||||
countryOptions.value = countries;
|
||||
|
||||
const products = (productRes.items ?? [])
|
||||
.map(normalizeProductRow)
|
||||
.filter((p) => p.code)
|
||||
.map((p) => ({
|
||||
value: p.code,
|
||||
label: p.name ? `${p.name} ${p.code}` : p.code,
|
||||
}));
|
||||
shippingMethodOptions.value = products;
|
||||
shippingMethodGroups.value = shippingGroups ?? [];
|
||||
|
||||
const cargoOpts = (dictRows ?? [])
|
||||
.map(normalizeDictRow)
|
||||
@ -336,7 +323,7 @@
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载筛选项失败');
|
||||
countryOptions.value = [];
|
||||
shippingMethodOptions.value = [];
|
||||
shippingMethodGroups.value = [];
|
||||
cargoTypeOptions.value = [];
|
||||
warehouseOptions.value = [];
|
||||
} finally {
|
||||
@ -463,17 +450,13 @@
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label required">{{ $t('order.priceCheck.form.shippingMethod') }}</label>
|
||||
<a-select
|
||||
<ShippingMethodTabSelect
|
||||
v-model="formData.shippingMethod"
|
||||
:groups="shippingMethodGroups"
|
||||
:loading="optionsLoading"
|
||||
:placeholder="$t('order.priceCheck.form.shippingMethod.placeholder')"
|
||||
class="form-select"
|
||||
allow-search
|
||||
:loading="optionsLoading"
|
||||
>
|
||||
<a-option v-for="option in shippingMethodOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user