feat: 自动触发支持 AI 写公式,并完善下单失败与订单附件体验
触发条件接入 AI 助手(选模型、发送 skill、一键填入);预报时机并入下单并支持公式验证;下单失败可继续修改或存草稿;补充订单附件、H5 下单与渠道买价相关优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8a9d900628
commit
a2e71a3a6b
123
.cursor/skills/oms-auto-trigger-formula/SKILL.md
Normal file
123
.cursor/skills/oms-auto-trigger-formula/SKILL.md
Normal file
@ -0,0 +1,123 @@
|
||||
---
|
||||
name: oms-auto-trigger-formula
|
||||
description: >-
|
||||
Writes OMS auto-trigger (自动触发) Chinese formulas for /channel/autoTrigger.
|
||||
Use when the user asks for 触发条件, 自动触发公式, 拦截/警告/问题件规则,
|
||||
oversize/国家/客户/销售产品条件, or formula verification sample data.
|
||||
---
|
||||
|
||||
# OMS 自动触发公式编写
|
||||
|
||||
为 `/channel/autoTrigger` 生成可直接粘贴的**中文公式**(后端 `ChineseFormulaEvaluator`)。
|
||||
|
||||
源码权威:
|
||||
|
||||
- 前端字段/算子:`src/views/channel/autoTrigger/formulaMeta.ts`
|
||||
- 后端解析:`Oms.Application/.../ChineseFormulaEvaluator.cs`、`AutoTriggerFormulaMeta.cs`
|
||||
|
||||
## 产出格式
|
||||
|
||||
回复必须包含:
|
||||
|
||||
1. **时机**:`下单` | `入库` | `出库`(无「预报」;旧「预报」=「下单」)
|
||||
2. **操作**:`拦截` | `警告` | `问题件`
|
||||
3. **公式**:单行可执行表达式(无解释性注释夹在公式内)
|
||||
4. **一句话说明**:命中条件是什么
|
||||
5. (可选)**验证样例**:国家/尺寸/客户等,方便弹窗「验证触发条件」
|
||||
|
||||
```text
|
||||
时机:下单
|
||||
操作:拦截
|
||||
公式:(单件最大长度()>60||单件最大宽度()>46)&&国家包含('GB')
|
||||
说明:英国且超尺寸时拦截
|
||||
验证样例:国家 GB,长 70,宽 40,高 40
|
||||
```
|
||||
|
||||
## 语法硬规则(必遵守)
|
||||
|
||||
| 规则 | 正确 | 错误 |
|
||||
|------|------|------|
|
||||
| 字段名必须是注册过的**中文 label** | `国家包含('GB')` | `ConsigneeCountry=='GB'` |
|
||||
| 字符串字段用 `字段包含('值')` | `客户包含('阿里')` | `客户()=='阿里'` |
|
||||
| 数值/聚合用 `字段()` 再比较 | `实重()>5`、`单件最大长度()>60` | `实重>5`、`长度包含('60')` |
|
||||
| 布尔用 `字段()==1` / `==0` | `是否偏远()==1` | `是否偏远()==true` |
|
||||
| 字符串字面量单引号 | `'GB'`;内部单引号翻倍 `''` | `"GB"` |
|
||||
| 逻辑运算 | `&&` `\|\|` `!` | `and` `or` `not` / 中文「且」 |
|
||||
| 比较运算 | `==` `!=` `>` `>=` `<` `<=` | `=` `<>` |
|
||||
| 算术 | `+ - * /` 与 `()` | — |
|
||||
| 空公式 | 空串视为恒真(勿故意输出空) | — |
|
||||
| 长度 | ≤ 10000 | — |
|
||||
|
||||
`包含` 语义:字段字符串 **Contains** 参数(子串匹配,非全等)。多选可用 `\|\|`:
|
||||
|
||||
```text
|
||||
国家包含('GB')||国家包含('UK')
|
||||
```
|
||||
|
||||
排除产品:
|
||||
|
||||
```text
|
||||
!销售产品包含('EX_ASP_HKDHL_YD')
|
||||
```
|
||||
|
||||
## 写公式工作流
|
||||
|
||||
1. 从用户描述抽出:时机、操作、条件(国家/客户/产品/尺寸/重量/状态等)。
|
||||
2. 映射到**中文字段**(见下方常用表;完整目录见 [field-catalog.md](field-catalog.md))。
|
||||
3. 按字段类型选 `包含('')` 或 `字段()`。
|
||||
4. 用 `&&` / `||` / `!` / `()` 组合;复杂条件先括号。
|
||||
5. 对照 [examples.md](examples.md) 自检语法。
|
||||
6. 输出「时机 + 操作 + 公式 + 说明」;需要时给验证样例。
|
||||
|
||||
不确定字段名时:优先读 `formulaMeta.ts` 的 `ORDER_TABLE_FIELDS` / `VIRTUAL_FORMULA_FIELDS`,或后端 `AutoTriggerFormulaMeta`。**禁止编造未注册中文名**。
|
||||
|
||||
## 常用字段速查
|
||||
|
||||
### 聚合(下单尺寸最常用)
|
||||
|
||||
| 中文 | 用法 |
|
||||
|------|------|
|
||||
| 单件最大长度 / 宽度 / 高度 | `单件最大长度()>60` |
|
||||
| 单件长宽高之和 | `单件长宽高之和()>150` |
|
||||
|
||||
### 订单 / 客户
|
||||
|
||||
| 中文 | 类型 | 典型 |
|
||||
|------|------|------|
|
||||
| 国家 | string | `国家包含('GB')`(二字码) |
|
||||
| 收件国家名称 | string | `收件国家名称包含('英国')` |
|
||||
| 销售产品 | string | `销售产品包含('EX_')` |
|
||||
| 收货渠道 | string | `收货渠道包含('DHL')` |
|
||||
| 客户 | string | `客户包含('阿里国际站')` |
|
||||
| 客户组 | string | `客户组包含('VIP')` |
|
||||
| 货物属性 | string | `货物属性包含('sensitive')` |
|
||||
| 申报总金额 | number | `申报总金额()>1000` |
|
||||
| 是否偏远 / 是否COD / 是否拦截 | boolean | `是否偏远()==1` |
|
||||
| 实重 / 计费重 / 长度/宽度/高度 | number | `实重()>20`(件重尺;下单前优先用聚合字段) |
|
||||
|
||||
### 收件人(选填场景)
|
||||
|
||||
`收件人邮编包含('9')`、`收件人省州包含('CA')`、`收件人城市包含('London')`
|
||||
|
||||
完整字段见 [field-catalog.md](field-catalog.md)。
|
||||
|
||||
## 时机与操作注意
|
||||
|
||||
- **下单 + 拦截**:建单前阻断(含草稿保存尝试)。
|
||||
- **下单 + 警告/问题件**:预报成功后触发;纯草稿保存不触发。
|
||||
- **入库/出库**:作业创建**之后**触发;「拦截」只打标,不回滚入库/出库。
|
||||
- **问题件**:需能匹配问题模板,否则可能静默跳过。
|
||||
|
||||
## 自检清单
|
||||
|
||||
- [ ] 每个字段都是注册中文名
|
||||
- [ ] 字符串用 `包含('…')`,数字用 `字段() 比较`
|
||||
- [ ] 括号匹配;`&&`/`||` 优先级按需加括号
|
||||
- [ ] 国家一般用二字码 `国家包含('XX')`
|
||||
- [ ] 排除条件用 `!字段包含('…')`
|
||||
- [ ] 公式可单行粘贴进触发条件编辑器
|
||||
|
||||
## 更多
|
||||
|
||||
- 完整字段目录:[field-catalog.md](field-catalog.md)
|
||||
- 场景示例:[examples.md](examples.md)
|
||||
84
.cursor/skills/oms-auto-trigger-formula/examples.md
Normal file
84
.cursor/skills/oms-auto-trigger-formula/examples.md
Normal file
@ -0,0 +1,84 @@
|
||||
# 自动触发公式 · 场景示例
|
||||
|
||||
可直接粘贴到触发条件编辑器。按需改时机/操作。
|
||||
|
||||
## 1. 英国超尺寸拦截(官方示例)
|
||||
|
||||
```text
|
||||
时机:下单
|
||||
操作:拦截
|
||||
公式:(单件最大长度()>60||单件最大宽度()>46||((单件最大长度()+单件最大宽度()+单件最大高度())>150))&&国家包含('GB')&&客户包含('阿里国际站/深圳一达通')&&!销售产品包含('EX_ASP_HKDHL_YD')
|
||||
```
|
||||
|
||||
验证样例:国家 `GB`,客户名含「阿里国际站」,长 70,宽 40,高 40 → 应命中;产品含 `EX_ASP_HKDHL_YD` → 不应命中。
|
||||
|
||||
## 2. 仅国家 + 超长
|
||||
|
||||
```text
|
||||
(单件最大长度()>120)&&国家包含('US')
|
||||
```
|
||||
|
||||
## 3. 多国家
|
||||
|
||||
```text
|
||||
国家包含('GB')||国家包含('DE')||国家包含('FR')
|
||||
```
|
||||
|
||||
## 4. 偏远警告
|
||||
|
||||
```text
|
||||
时机:下单
|
||||
操作:警告
|
||||
公式:是否偏远()==1
|
||||
```
|
||||
|
||||
## 5. 高申报金额问题件
|
||||
|
||||
```text
|
||||
时机:下单
|
||||
操作:问题件
|
||||
公式:申报总金额()>=2000&&国家包含('AU')
|
||||
```
|
||||
|
||||
## 6. 敏感货 + 指定渠道
|
||||
|
||||
```text
|
||||
货物属性包含('sensitive')&&收货渠道包含('DHL')
|
||||
```
|
||||
|
||||
## 7. 客户组 VIP 且实重过大(入库后)
|
||||
|
||||
```text
|
||||
时机:入库
|
||||
操作:警告
|
||||
公式:客户组包含('VIP')&&实重()>30
|
||||
```
|
||||
|
||||
## 8. 排除某产品后的重量限制
|
||||
|
||||
```text
|
||||
!销售产品包含('EX_LIGHT')&&计费重()>25
|
||||
```
|
||||
|
||||
## 9. 邮编前缀(子串)
|
||||
|
||||
```text
|
||||
国家包含('US')&&收件人邮编包含('90')
|
||||
```
|
||||
|
||||
## 10. 组合排除
|
||||
|
||||
```text
|
||||
国家包含('GB')&&!(销售产品包含('EX_A')||销售产品包含('EX_B'))
|
||||
```
|
||||
|
||||
## 常见写错对照
|
||||
|
||||
| 错误 | 正确 |
|
||||
|------|------|
|
||||
| `国家=='GB'` | `国家包含('GB')` |
|
||||
| `实重>5` | `实重()>5` |
|
||||
| `客户包含("阿里")` | `客户包含('阿里')` |
|
||||
| `是否偏远()==true` | `是否偏远()==1` |
|
||||
| `国家包含(GB)` | `国家包含('GB')` |
|
||||
| `长度()>60`(想表达单件最大) | `单件最大长度()>60` |
|
||||
154
.cursor/skills/oms-auto-trigger-formula/field-catalog.md
Normal file
154
.cursor/skills/oms-auto-trigger-formula/field-catalog.md
Normal file
@ -0,0 +1,154 @@
|
||||
# 自动触发公式 · 字段目录
|
||||
|
||||
与 `src/views/channel/autoTrigger/formulaMeta.ts` / `AutoTriggerFormulaMeta.cs` 对齐。
|
||||
**公式里只能用「中文」列**;英文列为库映射,禁止写进公式。
|
||||
|
||||
用法约定:
|
||||
|
||||
- `string` / `datetime` → `中文包含('…')`
|
||||
- `number` / 聚合 → `中文()` 再 `> >= < <= == !=`
|
||||
- `boolean` → `中文()==1` 或 `==0`
|
||||
|
||||
## 常用 / 聚合 `_virtual`
|
||||
|
||||
| 中文 | 英文 Field | 类型 |
|
||||
|------|------------|------|
|
||||
| 单件最大长度 | PieceMaxLength | number |
|
||||
| 单件最大宽度 | PieceMaxWidth | number |
|
||||
| 单件最大高度 | PieceMaxHeight | number |
|
||||
| 单件长宽高之和 | PieceSumLwh | number |
|
||||
|
||||
## 订单 `t_order`
|
||||
|
||||
| 中文 | Field | 类型 |
|
||||
|------|-------|------|
|
||||
| 请求标识 | RequestId | string |
|
||||
| 运单号 | ServiceNo | string |
|
||||
| 客户单号 | ClientNo | string |
|
||||
| 跟踪号 | TrackNo | string |
|
||||
| 销售产品 | ProductCode | string |
|
||||
| 收货渠道 | ChannelCode | string |
|
||||
| 下游服务商 | DownstreamSupplierCode | string |
|
||||
| 下游AppKey | DownstreamAppKey | string |
|
||||
| 下游AppId | DownstreamAppId | number |
|
||||
| 渠道预报编码 | ChannelForecastCode | string |
|
||||
| 国家 | ConsigneeCountry | string |
|
||||
| 收件国家名称 | ConsigneeCountryName | string |
|
||||
| 货物属性 | CargoAttribute | string |
|
||||
| 出口报关方式 | ExportCustomsMode | string |
|
||||
| 预报失败原因 | PredeclareFailReason | string |
|
||||
| 规则版本号 | SalesProductRulesVersion | string |
|
||||
| 价格版本号 | SalesProductPriceVersion | string |
|
||||
| 订单状态 | OrderStatus | string |
|
||||
| 处理子状态 | HandleStatus | string |
|
||||
| 问题类型 | IssueType | string |
|
||||
| 问题标题 | IssueTitle | string |
|
||||
| 问题内容 | IssueContent | string |
|
||||
| 问题模板 | IssueTemplateId | number |
|
||||
| 问题备注 | IssueRemark | string |
|
||||
| 问题件时间 | IssueTime | datetime |
|
||||
| 理赔类型 | ClaimType | string |
|
||||
| 索赔金额 | ClaimAmount | number |
|
||||
| 索赔币种 | ClaimCurrency | string |
|
||||
| 理赔原因 | ClaimReason | string |
|
||||
| 理赔时间 | ClaimTime | datetime |
|
||||
| 异常前状态 | PreviousOrderStatus | string |
|
||||
| 仓库 | WarehouseId | number |
|
||||
| 订单客户 | CustomerId | number |
|
||||
| 组织 | OrganizationId | string |
|
||||
| 密级 | SecretLevel | number |
|
||||
| 发件人关联 | SenderId | number |
|
||||
| 收件人关联 | RecipientId | number |
|
||||
| 申报关联 | DeclarationId | number |
|
||||
| 收货时间 | ReceiveTime | datetime |
|
||||
| 发货时间 | ShipTime | datetime |
|
||||
| 支付时间 | PayTime | datetime |
|
||||
| 是否已支付 | IsPaid | boolean |
|
||||
| 签收时间 | SignTime | datetime |
|
||||
| 是否COD | IsCod | boolean |
|
||||
| COD币种 | CodCurrency | string |
|
||||
| COD金额 | CodSum | number |
|
||||
| 申报币种 | DeclaredCurrency | string |
|
||||
| 申报总金额 | DeclaredSum | number |
|
||||
| 订单件数 | Quantity | number |
|
||||
| 是否拦截 | IsIntercept | boolean |
|
||||
| 是否偏远 | IsRemote | boolean |
|
||||
|
||||
## 收件人 `t_order_recipient`
|
||||
|
||||
| 中文 | Field | 类型 |
|
||||
|------|-------|------|
|
||||
| 收件人姓名 | RecipientsName | string |
|
||||
| 收件人公司 | CompanyName | string |
|
||||
| 收件人国家 | Country | number |
|
||||
| 收件人手机 | Phone | string |
|
||||
| 收件人电话 | TelePhone | string |
|
||||
| 收件人邮编 | ZipCode | string |
|
||||
| 收件人省州 | Province | string |
|
||||
| 收件人城市 | City | string |
|
||||
| 收件人门牌号 | StreetNumber | string |
|
||||
| 收件人地址1 | StreetAddress1 | string |
|
||||
| 收件人地址2 | StreetAddress2 | string |
|
||||
| 收件人地址3 | StreetAddress3 | string |
|
||||
| 收件人邮箱 | Email | string |
|
||||
| 收件人税号类型 | TaxType | number |
|
||||
| 收件人税号 | TaxNo | string |
|
||||
| 收件人证件类型 | CertificateType | number |
|
||||
| 收件人证件号 | CertificateNo | string |
|
||||
| EORI | Eori | string |
|
||||
| VAT | Vat | string |
|
||||
|
||||
(若前端 label 与上表略有差异,以 `formulaMeta.ts` 为准。)
|
||||
|
||||
## 发件人 `t_order_sender`
|
||||
|
||||
以 `formulaMeta.ts` 中 `t_order_sender` 段为准(发件人姓名/公司/手机/地址等)。
|
||||
|
||||
## 申报 `t_order_declaration`
|
||||
|
||||
常用:`申报品名`、`申报英文名`、`申报数量`、`申报单价`、`SKU`、`品牌`、`材质`、`原产国`、`是否带磁带电` 等——一律用文件内注册中文名。
|
||||
|
||||
## 件重尺 `t_order_weight`
|
||||
|
||||
| 中文 | Field | 类型 |
|
||||
|------|-------|------|
|
||||
| 件重尺类型 | Type | string |
|
||||
| 长度 | Length | number |
|
||||
| 宽度 | Width | number |
|
||||
| 高度 | Height | number |
|
||||
| 实重 | ActualWeight | number |
|
||||
| 体积重 | VolumetricWeight | number |
|
||||
| 计费重 | ChargeableWeight | number |
|
||||
| 体积 | Cubic | number |
|
||||
|
||||
下单拦截优先用聚合「单件最大*」;入库/出库后可用实重/计费重。
|
||||
|
||||
## 费用 `t_order_fee`
|
||||
|
||||
| 中文 | Field | 类型 |
|
||||
|------|-------|------|
|
||||
| 费用类型 | Type | string |
|
||||
| 费用项 | CostItem | string |
|
||||
| 费用金额 | Amount | number |
|
||||
| 费用币种 | Currency | string |
|
||||
| 费用下游服务商 | DownstreamProvider | string |
|
||||
| 价格编号 | PriceNo | string |
|
||||
| 价格生效时间 | PriceEffectiveTime | datetime |
|
||||
| 计费阶段 | ChargingStage | string |
|
||||
|
||||
## 客户 `t_customer`
|
||||
|
||||
| 中文 | Field | 类型 |
|
||||
|------|-------|------|
|
||||
| 客户编码 | CustomerCode | string |
|
||||
| 客户 | CustomerName | string |
|
||||
| 客户邮箱 | Email | string |
|
||||
| 客户手机 | Phone | string |
|
||||
| 客户余额 | Balance | number |
|
||||
| 客户总额度 | TotalQuota | number |
|
||||
| 客户已用额度 | UsedQuota | number |
|
||||
| 客户状态 | Status | string |
|
||||
| 客户备注 | Remark | string |
|
||||
| 销售 | Salesman | string |
|
||||
| 客服 | CustomerService | string |
|
||||
| 客户组 | CustomerGroup | string |
|
||||
@ -108,9 +108,9 @@ OMS 业务表中的 **`CustomerId`** 指向 **`t_customer.Id`**(客户业务
|
||||
| Excel 导入 | 解析后等同批量创建 / 异步 | **统一走** `t_order_batch_job`(`action=import-row`);MQ 开启异步入队,关闭则同步逐条处理;返回 **`jobId`** |
|
||||
| 批量重算费用 | `POST /order/fee/batch-recalculate` | **未实现**(§4.4.1.10 为设计稿) |
|
||||
| 订单支付 | 扣减 `t_customer.Balance` 并写 `t_customer_account_flow` | **已实现**:仅 `pendingPayment` 可支付;应收合计扣款;流水 `BizType=orderPay` |
|
||||
| 面单 URL | 可选空 | 预报成功时生成 **HTML** 至 `/uploads/prints/orders/`,响应 **`labelUrl`** |
|
||||
| 系统面单 / 订单附件 | 独立表;系统面单仅 1 份;客户只读系统面单 | **已实现**:`t_order_file`;创单成功后异步/同步生成 PDF 写入 **`systemLabel`**;打印优先读文件表,缺失则补生成;**已废弃** `t_order.LabelUrl` / `LabelGenerateStatus` / `LabelGenerateError` |
|
||||
| 规则快照下游元数据 | 排货 Provider 含渠道/itfx 对接字段 | **已实现**:规则发布时 **`AllocationProviderSnapshotEnricher`** enrich `Providers`;排货确认写入 **`t_order`** 四字段;**AppSecret 不落快照/订单** |
|
||||
| 轨迹 / 下游预报 | 预报后异步轨迹;下游 createOrderOne | **部分**:预报成功异步 UpsertSmallBag;「我的订单」可手动 **`POST /order/track-upsert-small-bag`**;下游预报由前端调 itfx **`ForwardWithSubscribe`**,凭证优先读订单绑定字段 |
|
||||
| 轨迹 / 下游预报 | 预报后异步轨迹;下游 createOrderOne | **部分**:预报成功异步 UpsertSmallBag;「我的订单」可手动 **`POST /order/track-upsert-small-bag`**;下游预报 **`POST /order/downstream-forward-create-one`**(及批量任务);成功回写 **`DownstreamServiceNo` / `TrackNo`**,下游面单写入 **`t_order_file.downstreamLabel`** |
|
||||
| 创建问题件 / 理赔件 | §4.4.1.13~§4.4.1.15 批量标记 + 问题模板消费 | **已落地**(`OrderService.Exception` + `OrderExceptionWorkflowService`) |
|
||||
| 问题件通知 | 按模板 `NotifyMethod` 投递 | **部分**:`inApp`(站内信)、`email`(操作人 SMTP)已接入;`sms/feishu/wechat` 待通道对接 |
|
||||
| 自动触发运行时 | 下单/入库/出库消费 `t_auto_trigger` | **部分**:`AutoTriggerRuntimeService` 已挂钩;**问题件**联动问题模板,**拦截/警告**已支持 |
|
||||
@ -145,12 +145,13 @@ OMS 业务表中的 **`CustomerId`** 指向 **`t_customer.Id`**(客户业务
|
||||
|
||||
1. **查价**(可选)→ **创建 / 预报** → 校验 → 草稿与包裹 → **预报计重完成后**将全部启用卖价项写入 **`t_order_fee`**(`ChargingStage=forecast`)。
|
||||
2. **契约固化**:预报等关键节点写入 **`SalesProductRulesVersion`、`SalesProductPriceVersion`**(及渠道侧应付相关版本,若有);非显式「改单重算」流程 **不得** 因后台新发布而改写已存版本号。
|
||||
3. **状态机**(概念顺序;枚举见 **`## 10 附录` → `### 10.3`**):如 草稿 → 已预报 → **待排货(可选)** → 待支付 → 待发货 → **待上网** → 运输中 → 派送成功/失败;另有拦截、**问题件**(`problematic`)、**理赔件**(`claimed`)、删除等分支。入库排货失败时进入 **`pendingAllocation`**,写入 **`AllocationPendingReason`**,仍保留收货与 inbound 应收。
|
||||
4. **支付**:OMS 侧订单状态与支付动作对接;**扣减 `t_customer.Balance`** 并 **写入 `t_customer_account_flow`(`BizType=orderPay`,金额为负)**;支付金额取订单 **`t_order_fee` 应收合计**。
|
||||
5. **分阶段计费(按件重尺类型)**:卖价/买价 **不再配置「收费时机」**;应收在 **`t_order_weight.Type=forecast` 或 `receive` 计重完成** 后触发,全部启用价格项参与;`t_order_fee.ChargingStage` 标记本次算价所用重量阶段(`forecast` / `inbound`)。出库阶段(`ship` 行)待 §6.3.2.1 接入。
|
||||
6. **批量下单**:Excel/模板 或 JSON 批量接口 → 异步任务;**单次最多受理 2000 条**。
|
||||
7. **揽收订单**(§4.4.4):客户维护 **发件地址簿**(§4.2.10 / §4.4.5)→ 创建时选 **发件地址 + 仓库 + 运输方式(销售产品)+ 揽收时间 + 货物名称/重量** → 服务端组装为小包创建入参 → **复用 `CreateParcelCoreAsync` / §4.4.3.1** 落 `t_order`(`OrderSource=pickup`)。**不是**仓储模块 `C_parcel` / 到货登记另开一套订单主表。
|
||||
8. **店铺订单创建物流**(§4.4.7):平台店铺单勾选 → 校对收件人/明细 → 选运输方式 → **批量创建** `t_order`(`OrderSource=platform`)并回写 `LinkedOrderId`;若「物流&代发」则同弹窗内按单绑定包裹/库存(复用 §4.4.6 语义)。与「匹配库存」分流:后者依赖平台 SKU 映射并创建时扣减库存。
|
||||
3. **系统面单**:创单进入 **`predeclared`** 后触发生成(队列开启则异步 `generate-label-pdf`,否则同步);PDF 落盘并写入 **`t_order_file`(`FileType=systemLabel`,仅 1 份可替换)**;失败**不阻断创单**,打印时补生成。模板解析顺序:订单 **`LabelTemplateId`** → 销售产品绑定模板 → 全局默认模板。
|
||||
4. **状态机**(概念顺序;枚举见 **`## 10 附录` → `### 10.3`**):如 草稿 → 已预报 → **待排货(可选)** → 待支付 → 待发货 → **待上网** → 运输中 → 派送成功/失败;另有拦截、**问题件**(`problematic`)、**理赔件**(`claimed`)、删除等分支。入库排货失败时进入 **`pendingAllocation`**,写入 **`AllocationPendingReason`**,仍保留收货与 inbound 应收。
|
||||
5. **支付**:OMS 侧订单状态与支付动作对接;**扣减 `t_customer.Balance`** 并 **写入 `t_customer_account_flow`(`BizType=orderPay`,金额为负)**;支付金额取订单 **`t_order_fee` 应收合计**。
|
||||
6. **分阶段计费(按件重尺类型)**:卖价/买价 **不再配置「收费时机」**;应收在 **`t_order_weight.Type=forecast` 或 `receive` 计重完成** 后触发,全部启用价格项参与;`t_order_fee.ChargingStage` 标记本次算价所用重量阶段(`forecast` / `inbound`)。出库阶段(`ship` 行)待 §6.3.2.1 接入。
|
||||
7. **批量下单**:Excel/模板 或 JSON 批量接口 → 异步任务;**单次最多受理 2000 条**。
|
||||
8. **揽收订单**(§4.4.4):客户维护 **发件地址簿**(§4.2.10 / §4.4.5)→ 创建时选 **发件地址 + 仓库 + 运输方式(销售产品)+ 揽收时间 + 货物名称/重量** → 服务端组装为小包创建入参 → **复用 `CreateParcelCoreAsync` / §4.4.3.1** 落 `t_order`(`OrderSource=pickup`)。**不是**仓储模块 `C_parcel` / 到货登记另开一套订单主表。
|
||||
9. **店铺订单创建物流**(§4.4.7):平台店铺单勾选 → 校对收件人/明细 → 选运输方式 → **批量创建** `t_order`(`OrderSource=platform`)并回写 `LinkedOrderId`;若「物流&代发」则同弹窗内按单绑定包裹/库存(复用 §4.4.6 语义)。与「匹配库存」分流:后者依赖平台 SKU 映射并创建时扣减库存。
|
||||
|
||||
### 2.4 主流程 C:操作(仓库)
|
||||
|
||||
@ -316,7 +317,7 @@ JWT → mapId(user.userid INT)
|
||||
| 单条读 | 按 `id` 加载后校验 `t_order.CustomerId == effectiveCustomerId`,否则按 **「订单不存在」** 返回(**禁止**泄露他客订单存在性) |
|
||||
| 新增(C) | 创建订单/批量任务时 **`CustomerId` 仅取自会话**,写入 `t_order.CustomerId` / `t_order_batch_job.CustomerId`;请求体若带 `customerId` 须与 `effectiveCustomerId` **一致**,否则 **403** |
|
||||
| 更新/删除(U/D) | 支付、预报、拦截、删除、打印、费用重算等:先 **归属校验** 再执行业务;批量接口逐单校验,任一不属于当前客户则该单失败或整批拒绝(实现二选一须在 **§4.4.0** 写死) |
|
||||
| 关联表 | `t_order_sender`、`t_order_recipient`、`t_order_declaration`、`t_order_weight`、`t_order_fee` 等通过 **`OrderId`** 间接隔离,**禁止**仅凭子表 Id 绕过主表 `CustomerId` 校验;**`t_customer_sender_address`** 以 **`CustomerId`** 直接隔离(§4.2.10) |
|
||||
| 关联表 | `t_order_sender`、`t_order_recipient`、`t_order_declaration`、`t_order_weight`、`t_order_fee`、**`t_order_file`** 等通过 **`OrderId`** 间接隔离,**禁止**仅凭子表 Id 绕过主表 `CustomerId` 校验;**`t_customer_sender_address`** 以 **`CustomerId`** 直接隔离(§4.2.10) |
|
||||
| 内部用户 | `loginType=0` **默认禁止**调用 `/admin/order/*` 客户订单接口;若运营后台需跨客户查单,须 **另设** 内部管理路径与 PermissionCode,**不得**复用客户侧 `/admin/order` 且不带客户隔离 |
|
||||
|
||||
> **安全原则**:`CustomerId` **不得**由前端单独作为可信事实;**不得**仅按订单 `Id` 操作而不校验归属。
|
||||
@ -326,7 +327,7 @@ JWT → mapId(user.userid INT)
|
||||
|
||||
### 4.1 核心需求说明
|
||||
|
||||
- **我的订单**:支持订单全流程管理,包括分页查询、详情查看、支付、预报、拦截/取消拦截、删除、打印、导出;**创建问题件**、**创建理赔件**、处理子状态筛选;可按 **`OrderSource`**(含揽收)筛选;订单状态覆盖草稿→已预报→已收货→待支付→待发货→运输中→派送成功/失败→问题件→理赔件等全生命周期。
|
||||
- **我的订单**:支持订单全流程管理,包括分页查询、详情查看、支付、预报、拦截/取消拦截、删除、打印、导出、**订单附件**(§4.2.12 / §4.4.1.17);**创建问题件**、**创建理赔件**、处理子状态筛选;可按 **`OrderSource`**(含揽收)筛选;订单状态覆盖草稿→已预报→已收货→待支付→待发货→运输中→派送成功/失败→问题件→理赔件等全生命周期。**客户门户**附件仅可查看 **系统面单**,不可上传/删除。
|
||||
- **我要查价**:根据目的国、运输方式、重量尺寸、货物类型等条件查价,返回可选渠道及费用明细;与正式下单共用「选版本 + 内存匹配」计费逻辑,响应宜携带 `rulesVersion` / `priceVersion` 便于与订单对齐。
|
||||
- **我要下单**:支持下载下单模板、上传 Excel 批量下单、在 **批量任务** 页查看导入进度与失败明细;模板分为标准模板与自定义模板。**单次导入订单上限 2000 条**(Excel 有效数据行或 `/order/parcel/batch-create` 请求数组长度,超出拒绝)。
|
||||
- **发件地址**(菜单挂 **客户**,接口属订单域):客户维护可复用的发件地址簿,供揽收下单选择;**不**写入订单快照表 `t_order_sender`(见 §4.2.10 / §4.4.5)。
|
||||
@ -345,6 +346,7 @@ JWT → mapId(user.userid INT)
|
||||
| ServiceNo | VARCHAR | 64 | - | 是 | 运单号 |
|
||||
| ClientNo | VARCHAR | 64 | - | 否 | 客户单号 |
|
||||
| TrackNo | VARCHAR | 64 | - | 否 | 跟踪号 |
|
||||
| DownstreamServiceNo | VARCHAR | 64 | - | 否 | 服务商单号(下游 createOrderOne 成功响应 **`Data.serviceNo`**);OMS 自有运单号仍为 **`ServiceNo`** |
|
||||
| ProductCode | VARCHAR | 128 | - | 否 | 产品code |
|
||||
| ChannelCode | VARCHAR | 128 | - | 否 | 渠道code |
|
||||
| DownstreamSupplierCode | VARCHAR | 64 | - | 否 | 下游 API 服务商编码(itfx **`supplierCode`**);排货确认渠道时自规则快照 **`Providers`** 写入 |
|
||||
@ -389,12 +391,15 @@ JWT → mapId(user.userid INT)
|
||||
| AllocationPendingReason | VARCHAR | 512 | - | 否 | 待排货原因:入库排货或渠道买价未匹配时记录(`OrderStatus=pendingAllocation`) |
|
||||
| SalesProductRulesVersion | VARCHAR | 32 | - | 否 | 下单绑定的规则版本号(与发布记录 `Version` 一致,`OperatorType=rules`) |
|
||||
| SalesProductPriceVersion | VARCHAR | 32 | - | 否 | 下单绑定的价格版本号(与发布记录 `Version` 一致,`OperatorType=price`) |
|
||||
| LabelTemplateId | INT | - | 外键 → `t_label_template.Id` | 否 | 出单实际使用的标签模板 Id(模板配置,**非**面单文件地址;文件见 **`t_order_file.systemLabel`**) |
|
||||
| IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 |
|
||||
| CreateUserId | INT | - | - | 是 | 创建人 |
|
||||
| CreateTime | DATETIME | - | - | 是 | 创建时间 |
|
||||
| UpdateUserId | INT | - | - | 否 | 更新人 |
|
||||
| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
|
||||
|
||||
> **已废弃字段(勿再写入)**:原 `LabelUrl` / `LabelGenerateStatus` / `LabelGenerateError` 已迁至 **`t_order_file`**(见 **§4.2.12**),迁移脚本删除订单侧列。
|
||||
|
||||
**OrderStatus 枚举**:draft, predeclared, pendingAllocation, received, pendingPayment, pendingShip, pendingOnline, shipping, deliverySuccess, deliveryFailed, problematic, deleted, claimed
|
||||
|
||||
**OrderSource 枚举**:见 **§10.3.9**(`manual` / `pickup` / `upload` / `batch` / `platform`)
|
||||
@ -721,6 +726,47 @@ JWT → mapId(user.userid INT)
|
||||
4. 已上架包裹行(有 `SkuId`)绑定包裹时**同时**冻结对应库存,避免重复售卖。
|
||||
5. 保存采用**全量覆盖**:提交 `items[]` 为最终态。
|
||||
|
||||
#### 4.2.12 订单附件表 `t_order_file`
|
||||
|
||||
> **用途**:订单级附件(交易证明、系统面单、下游面单、末端面单、红外图片)统一落库;**不再**在 `t_order` 上存面单 URL / 生成状态字段。
|
||||
> **系统面单**:`FileType=systemLabel` **仅允许 1 份**;生成或上传时先软删同类型旧记录再插入(替换)。其它类型每类最多 **20** 份。
|
||||
> **客户 ACL**:客户登录仅可 **查询** `systemLabel`;**禁止**上传/删除任意附件类型。内部账号可管理全部类型。
|
||||
> **迁移脚本**:`sql/20260728_order_file.sql`(建表 + 迁移旧 `LabelUrl` → `systemLabel` + 删除订单旧列);注释补丁 `sql/20260728_order_file_comments.sql`。
|
||||
|
||||
| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Id | INT | - | 主键 | 是 | 自增 |
|
||||
| OrderId | INT | - | 外键 → `t_order.Id` | 是 | 订单 Id |
|
||||
| FileType | VARCHAR | 32 | - | 是 | 附件类型:`tradeProof` 交易证明 / `systemLabel` 系统面单 / `downstreamLabel` 下游面单 / `lastMileLabel` 末端面单 / `infraredImage` 红外图片 |
|
||||
| FileName | VARCHAR | 255 | - | 否 | 原始文件名 |
|
||||
| FileUrl | VARCHAR | 512 | - | 是 | 文件地址(相对路径如 `/uploads/...` 或外链) |
|
||||
| ContentType | VARCHAR | 128 | - | 否 | 文件 MIME 类型 |
|
||||
| FileSize | BIGINT | - | - | 否 | 文件大小(字节) |
|
||||
| Sort | INT | - | - | 是 | 同类型内排序,默认 0 |
|
||||
| Source | VARCHAR | 32 | - | 是 | 来源:`upload` 上传 / `systemGenerate` 系统生成 / `downstream` 下游回传 / `migrate` 迁移;默认 `upload` |
|
||||
| Remark | VARCHAR | 512 | - | 否 | 备注 |
|
||||
| IsDeleted | TINYINT | - | - | 是 | 软删除:0-否,1-是,默认 0 |
|
||||
| ConcurrencyStamp | CHAR | 36 | - | 是 | 并发戳;见 **§3.1.2**,默认 UUID |
|
||||
| CreateUserId | INT | - | - | 是 | 创建人 |
|
||||
| CreateTime | DATETIME | - | - | 是 | 创建时间 |
|
||||
| UpdateUserId | INT | - | - | 否 | 更新人 |
|
||||
| UpdateTime | DATETIME | - | - | 否 | 更新时间 |
|
||||
|
||||
**索引**
|
||||
|
||||
| 索引名 | 类型 | 字段 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| idx_ord_file_order_type | KEY | OrderId, FileType, IsDeleted | 按订单+类型查询 |
|
||||
|
||||
**写入约定**
|
||||
|
||||
| 场景 | FileType | Source | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 创单成功 / 打印补生成 / 批量 `generate-label-pdf` | `systemLabel` | `systemGenerate` | `LabelPdfGenerationService`;替换旧系统面单 |
|
||||
| 内部上传 | 任一类型 | `upload` | `POST /order/files/upload`;`systemLabel` 仍仅 1 份 |
|
||||
| 下游 createOrderOne 成功返回 `labelUrl` | `downstreamLabel` | `downstream` | 不写 `t_order`;与 `DownstreamServiceNo`/`TrackNo` 一并回写 |
|
||||
| 历史 `t_order.LabelUrl` 迁移 | `systemLabel` | `migrate` | 一次性 SQL |
|
||||
|
||||
### 4.3 下单计费与版本策略(配置 → 运行时 → 下单)
|
||||
|
||||
> 本节约定**销售产品**维度的规则/卖价如何进入线上算价链路,以及与 `t_order` 版本字段的对应关系;与渠道模块中「运营发布 → 生成版本」接口衔接。
|
||||
@ -858,7 +904,7 @@ JWT → mapId(user.userid INT)
|
||||
| **增** | `POST /order/parcel/create`、`POST /order/parcel/batch-create`、`POST /order/upload` | 落库 `CustomerId` **仅**取 `effectiveCustomerId`;请求体 `customerId` 若存在须 **相等**,否则拒绝 |
|
||||
| **改** | `POST /order/pay`、`POST /order/pre-declare`、`POST /order/intercept`、`POST /order/cancel-intercept`、`POST /order/fee/batch-recalculate` | 按 `id` / `orderIds` 加载订单后 **逐单**校验 `CustomerId`;不属于当前客户 **不得**变更状态或费用 |
|
||||
| **删** | `POST /order/delete` | 同上,仅允许删除 **`CustomerId = effectiveCustomerId`** 的订单 |
|
||||
| **其它写** | `POST /order/print`、`POST /order/export` | 打印/导出前校验归属;导出筛选范围 **限定**当前客户 |
|
||||
| **其它写** | `POST /order/print`、`POST /order/export`、`GET/POST /order/files*` | 打印/导出/附件前校验归属;导出筛选范围 **限定**当前客户;客户附件仅可读 systemLabel |
|
||||
|
||||
**批量与关联资源**
|
||||
|
||||
@ -898,14 +944,17 @@ JWT → mapId(user.userid INT)
|
||||
| 拦截 | POST | /order/intercept | 订单拦截;**须校验订单归属** |
|
||||
| 取消拦截 | POST | /order/cancel-intercept | 取消订单拦截;**须校验订单归属** |
|
||||
| 删除 | POST | /order/delete | 删除订单;**须校验订单归属** |
|
||||
| 打印 | POST | /order/print | 打印订单;**须校验订单归属** |
|
||||
| 打印 | POST | /order/print | 打印订单;优先返回 **`t_order_file.systemLabel`**,缺失则补生成 PDF;**须校验订单归属** |
|
||||
| 订单附件列表 | GET | /order/files | 按类型分组;客户仅返回 **systemLabel**;**须校验订单归属** |
|
||||
| 上传订单附件 | POST | /order/files/upload | multipart;客户账号 **禁止**;**须校验订单归属** |
|
||||
| 删除订单附件 | POST | /order/files/delete | 客户账号 **禁止**;**须校验订单归属** |
|
||||
| 导出 | POST | /order/export | 导出订单;**仅导出当前客户订单** |
|
||||
| 批量重算费用 | POST | /order/fee/batch-recalculate | 入参订单 Id 列表 + `scopes`;**逐单校验 `CustomerId`**(§4.4.0) |
|
||||
| 创建轨迹订单 | POST | /order/track-upsert-small-bag | 手动同步调用轨迹 **UpsertSmallBag**(联调/补推);**须校验订单归属** |
|
||||
| 创建问题件 | POST | /order/mark-problematic | 批量将订单标记为 **问题件**;消费问题模板(§5.2.21);**须校验订单归属** |
|
||||
| 创建理赔件 | POST | /order/mark-claimed | 批量将订单标记为 **理赔件**;**须校验订单归属** |
|
||||
| 更新处理子状态 | POST | /order/handle-status/update | 问题件/理赔件 **待处理→已处理**;**须校验订单归属** |
|
||||
| 预报下游订单 | —(前端) | itfx **`POST /InterfaceForward/ForwardWithSubscribe`** | 「我的订单」批量操作;`standardCode=createOrderOne`;凭证优先读订单下游绑定字段(§4.4.1.12) |
|
||||
| 预报下游订单 | POST | /order/downstream-forward-create-one | OMS 组装 createOrderOne 并转发 itfx;成功回写服务商单号/跟踪号,下游面单入 **`t_order_file`**(§4.4.1.12) |
|
||||
|
||||
##### 4.4.1.1 分页查询订单列表(POST /order/list)
|
||||
|
||||
@ -1332,22 +1381,27 @@ JWT → mapId(user.userid INT)
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"printUrl": ""
|
||||
"printUrl": "/uploads/prints/orders/....pdf",
|
||||
"labelGenerateStatus": "Ready",
|
||||
"labelGenerateError": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `labelGenerateStatus` / `labelGenerateError` 为 **本次响应瞬时字段**(Ready / Failed),**不落库**。
|
||||
|
||||
###### 2 接口业务逻辑
|
||||
|
||||
###### 核心流程
|
||||
|
||||
1. `GetOrderOrThrowAsync(id)` → 映射订单详情。
|
||||
2. `CreateOrderPrintFileAsync`:生成 **HTML** 面单至 `wwwroot/uploads/prints/orders/{serviceNo}-{timestamp}.html`。
|
||||
3. 返回 **`printUrl`**(相对路径,如 `/uploads/prints/orders/...`)。
|
||||
2. 若已存在 **`t_order_file`(`FileType=systemLabel`)** 有效记录,直接返回其 **`FileUrl`** 作为 `printUrl`。
|
||||
3. 否则调用 **`LabelPdfGenerationService.GenerateAndPersistAsync`**:按模板渲染 PDF → 落盘 `/uploads/prints/orders/` → **替换写入** `systemLabel`;成功返回 PDF 相对路径。
|
||||
4. PDF 仍失败时:回落 `CreateOrderPrintFileAsync` 生成简易 **HTML** stub,避免彻底不可打;响应带失败原因。
|
||||
|
||||
###### 3 当前实现(oms0414-back)
|
||||
|
||||
- `OrderService.PrintAsync` + `OrderService.Helpers.CreateOrderPrintFileAsync`(简易 HTML,非渠道标签模板)。
|
||||
- `OrderService.PrintAsync` + `OrderFileManager` + `LabelPdfGenerationService`;批量打印走 `PrintForBatchAsync` / 批量任务 `action=print`。
|
||||
|
||||
##### 4.4.1.9 导出(POST /order/export)
|
||||
|
||||
@ -1481,11 +1535,11 @@ JWT → mapId(user.userid INT)
|
||||
4. 轨迹凭证读 **`t_system_other_config`**(`ConfigCode=track_service`),缺省回退 **`Oms:TrackService`**(§8.2.7);未启用或参数不全时返回 **`skipped=true`** 及原因。
|
||||
5. 联调入口:**`POST /order/debug/track-upsert-small-bag`**(Swagger,可自定义测试字段)。
|
||||
|
||||
##### 4.4.1.12 预报下游订单(前端 → itfx,非 OMS 后端接口)
|
||||
##### 4.4.1.12 预报下游订单(POST /order/downstream-forward-create-one)
|
||||
|
||||
> **实现状态:已落地**(前端 `order/myOrders` 批量操作 + `forwardCreateOrderOne`;凭证解析 `resolveOrderDownstreamCredentials`)。
|
||||
> **实现状态:已落地**(OMS 后端转发 itfx;亦可批量任务 `downstream-forward-create-one`)。
|
||||
|
||||
运营在「我的订单」勾选订单后,浏览器直连 itfx 网关 **`POST /InterfaceForward/ForwardWithSubscribe`**,**`standardCode=createOrderOne`**,Body 为 **§4.4.3.1** 同构的 parcel/create JSON(`orderCreateOneBody.ts` 由订单详情组装)。
|
||||
运营在「我的订单」勾选订单后,由 **OMS** 组装 createOrderOne Body 并调用 itfx 下游;凭证优先读订单绑定字段(§4.3.4.2)。
|
||||
|
||||
###### 1 凭证解析优先级
|
||||
|
||||
@ -1496,11 +1550,13 @@ JWT → mapId(user.userid INT)
|
||||
|
||||
**AppSecret** 不落订单、不落规则快照;无论哪条路径,均按 **`downstreamAppId`(或分组 `AppId`)** 调 itfx **`GET /App/GetInfo`** 读取。
|
||||
|
||||
###### 2 请求要点
|
||||
###### 2 成功回写
|
||||
|
||||
- Query:`standardCode=createOrderOne`,`supplierCode={DownstreamSupplierCode}`。
|
||||
- Header:`appkey`、`appSecret`、`followName=0`(itfx 网关约定)。
|
||||
- Body:camelCase,与 **§4.4.3.1** 创建小包请求体对齐(含 `receiver`、`items` 等)。
|
||||
| 下游 Data 字段 | 落库位置 |
|
||||
| --- | --- |
|
||||
| `serviceNo` | **`t_order.DownstreamServiceNo`**(OMS 自有运单号 `ServiceNo` 不变) |
|
||||
| `trackNo` | **`t_order.TrackNo`**(空值不覆盖已有) |
|
||||
| `labelUrl` | **`t_order_file`**,`FileType=downstreamLabel`,`Source=downstream`(**不写**订单主表) |
|
||||
|
||||
###### 3 与规则快照的关系
|
||||
|
||||
@ -1669,6 +1725,7 @@ JWT → mapId(user.userid INT)
|
||||
|
||||
| 场景 | UI |
|
||||
| --- | --- |
|
||||
| 订单详情抽屉 | Tab **附件**(§4.4.1.17):内部可上传各类型;客户门户仅展示系统面单只读 |
|
||||
| 非问题件/理赔件 Tab | 工具栏 **创建问题件**、**创建理赔件**(须勾选行;`hasPermission` 控制) |
|
||||
| 问题件/理赔件 Tab | 子 Tab **待处理 / 已处理**(已落地);工具栏 **标记已处理**(`Order.HandleStatus.Update`) |
|
||||
| 创建问题件弹窗 | `ModalPro` + `FormPro`:问题类型 → 联动 **`issue-template/options`** → 可选模板;标题、内容可编辑;备注 |
|
||||
@ -1677,6 +1734,29 @@ JWT → mapId(user.userid INT)
|
||||
|
||||
列表/详情响应补充字段(实现时):`handleStatus`、`handleStatusName`、`issueType`、`issueTitle`、`claimType`、`claimAmount` 等(与 **§4.2.1** 对齐)。
|
||||
|
||||
##### 4.4.1.17 订单附件(GET/POST /order/files*)
|
||||
|
||||
> **实现状态:已落地**(`OrderService.OrderFiles` + `OrderFileManager`;前端订单详情抽屉「附件」Tab)。
|
||||
|
||||
###### 1 查询(GET /order/files?orderId=)
|
||||
|
||||
- 校验订单归属。
|
||||
- 返回按 `FileType` 分组的附件列表;**客户登录仅含 `systemLabel`**,其它类型键可不返回或为空数组。
|
||||
|
||||
###### 2 上传(POST /order/files/upload,multipart)
|
||||
|
||||
- 表单:`orderId`、`fileType`、`files`。
|
||||
- **客户账号禁止**;内部账号校验订单归属后写入。
|
||||
- `systemLabel` 仅允许 1 个文件(替换旧记录);其它类型合计不超过 20。
|
||||
|
||||
###### 3 删除(POST /order/files/delete)
|
||||
|
||||
```json
|
||||
{ "id": 1 }
|
||||
```
|
||||
|
||||
- **客户账号禁止**;软删 `t_order_file` 行。
|
||||
|
||||
#### 4.4.2 我要查价
|
||||
|
||||
| 接口名称 | 请求方式 | 请求路径 | 说明 |
|
||||
@ -2074,11 +2154,12 @@ JWT → mapId(user.userid INT)
|
||||
- 更新 `t_order.OrderStatus = 'predeclared'`
|
||||
- 更新 `t_order.PredeclareFailReason = ''`
|
||||
- 更新 `t_order.UpdateUserId`、`t_order.UpdateTime`
|
||||
- **系统面单**:调用 `EnqueueGenerateLabelPdfAsync`(MQ 开启则入队 `generate-label-pdf`,否则同步生成);写入 **`t_order_file.systemLabel`**;失败不阻断创单。
|
||||
- 返回字段:
|
||||
- `clientNo`:取 `t_order.ClientNo`
|
||||
- `serviceNo`:取 `t_order.ServiceNo`
|
||||
- `trackNo`:取 `t_order.TrackNo`
|
||||
- `labelUrl`:若当前流程未生成面单则返回空字符串
|
||||
- `labelUrl`:取已生成的 **系统面单** `FileUrl`;若尚未就绪则为空字符串(异步队列场景常见)
|
||||
|
||||
###### 状态与失败处理
|
||||
|
||||
@ -2106,7 +2187,7 @@ JWT → mapId(user.userid INT)
|
||||
| `serviceNo` | 自定义时查重,冲突 → **「运单号已存在」**;否则 **`IDE{yyMMddHHmmss}{4位随机}`** |
|
||||
| 快照 | 见 **§4.3.5**(DB 发布 + Live 降级,**无 Redis**) |
|
||||
| 应付/排货 | **创建时不执行**;入库时 **TryConfirm**(失败 → `pendingAllocation`) |
|
||||
| 成功 | `OrderStatus=predeclared`;清空 `AllocationPendingReason`;**`labelUrl`** 同 print 逻辑生成 HTML |
|
||||
| 成功 | `OrderStatus=predeclared`;清空 `AllocationPendingReason`;触发系统面单生成写入 **`t_order_file.systemLabel`**;响应 **`labelUrl`** 读文件表(未就绪可为空) |
|
||||
| 失败 | 草稿已建则 **`MarkParcelDraftFailedAsync`**(保持 `draft` + `PredeclareFailReason`)后 **仍抛业务异常** |
|
||||
|
||||
##### 4.4.3.2 批量创建小包(POST /order/parcel/batch-create)
|
||||
@ -8424,6 +8505,7 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20
|
||||
|
||||
| 迭代日期 | 迭代编号 | 迭代功能 | 变更模块 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2026-07-28 | 20260728-01 | 订单附件表 + 系统面单落文件表 | 订单 §1.5.4、§2.3、§4.1、§4.2.1、**§4.2.12**、§4.4.1、§4.4.1.8、§4.4.1.12、§4.4.1.17、§4.4.3.1、§10.6.1 | `t_order_file`;废弃订单 `LabelUrl`/`LabelGenerate*`;客户只读 systemLabel;下游 labelUrl→downstreamLabel |
|
||||
| 2026-07-22 | 20260722-01 | 查价/下单/入库/出库链路加固;待排货;运单号 IDE | 订单 §2.3、§4.3.5、§4.4.2.1、§4.4.3.1、§6.3;附录 §10.3.1;新手指引 `/order/guide` | 查价可选尺寸+参考价;创建仅 forecast 应收;入库可选排货→`pendingAllocation`;运单号 `IDE{yyMMddHHmmss}{4位随机}` |
|
||||
| 2026-07-19 | 20260719-01 | 店铺订单创建物流 / 物流&代发 | 订单 §4.1、§2.3、§4.2.11、**§4.4.7**;附录权限 | 校对弹窗;`batch-create-logistics` / `batch-create-fulfill`;与匹配库存分流 |
|
||||
| 2026-07-15 | 20260715-01 | 客户发件地址 + 揽收订单 | 订单 §4.1、§4.2.1、§4.2.10、§4.4.1.1、§4.4.4、§4.4.5;附录 §10.3.9 | 地址簿 `t_customer_sender_address`;`OrderSource=pickup` 复用小包创建;菜单客户/订单 |
|
||||
@ -8769,7 +8851,7 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20
|
||||
- 查价 **`failures[].step`**:`product` / `snapshot` / `blacklist` / `weight` / `zone` / `price`
|
||||
- 充值审核 **`CustomerBalanceMutator.CreditRechargeAsync`**(`Pending` 可审,**Balance 与 `t_customer_account_flow` 同事务**)
|
||||
- 订单支付 **`CustomerBalanceMutator.DebitOrderPayAsync`**(见 **§4.4.1.3、§7.4**)
|
||||
- 面单 **`labelUrl` / `printUrl`**:HTML 文件 `/uploads/prints/orders/`
|
||||
- 面单 **`labelUrl` / `printUrl`**:优先 **`t_order_file.systemLabel`**(PDF);缺失时打印可补生成或回落 HTML stub `/uploads/prints/orders/`
|
||||
|
||||
---
|
||||
|
||||
@ -8781,7 +8863,7 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20
|
||||
|
||||
| 模块 | 网关前缀(生产) | 服务内路径 | 示例 |
|
||||
| --- | --- | --- | --- |
|
||||
| 订单 | `/admin/order/` | `/order/` | list, detail, pay, pre-declare, cancel-intercept, delete, price-check, parcel/create, parcel/batch-create;**设计中**:fee/batch-recalculate, mark-problematic, mark-claimed, handle-status/update |
|
||||
| 订单 | `/admin/order/` | `/order/` | list, detail, pay, pre-declare, cancel-intercept, delete, print, files, files/upload, files/delete, price-check, parcel/create, parcel/batch-create, downstream-forward-create-one;**设计中**:fee/batch-recalculate, mark-problematic, mark-claimed, handle-status/update |
|
||||
| 渠道-销售产品 | `/admin/channel/`(同订单网关) | `/channel/sales-product/` | list, detail, create, update, config/save, delete, price/list, price/save, allocation/get, publish/preview |
|
||||
| 渠道-服务渠道 | `/admin/channel/` | `/channel/service-channel/` | list, detail, create, update, config/save, delete, group/list, price/save, publish/preview, publish/rollback |
|
||||
| 渠道-计重 | `/admin/channel/` | `/channel/weight-rule/`、`/channel/weight-rounding/` | list, create, update, delete, info/list, info/add |
|
||||
@ -8928,7 +9010,10 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20
|
||||
| POST | `/order/intercept` | 已落地 | `IsIntercept=1` |
|
||||
| POST | `/order/cancel-intercept` | 已落地 | `IsIntercept=0` |
|
||||
| POST | `/order/delete` | 已落地 | 软删 + `deleted` |
|
||||
| POST | `/order/print` | 已落地 | HTML 面单 URL |
|
||||
| POST | `/order/print` | 已落地 | 优先 `t_order_file.systemLabel`;缺失补生成 PDF |
|
||||
| GET | `/order/files` | 已落地 | 按类型分组;客户仅 systemLabel |
|
||||
| POST | `/order/files/upload` | 已落地 | 内部上传;客户禁止 |
|
||||
| POST | `/order/files/delete` | 已落地 | 软删;客户禁止 |
|
||||
| POST | `/order/export` | 已落地 | Excel,24h 过期 |
|
||||
| POST | `/order/price-check` | 已落地 | 见 §4.3.5、§4.4.2.1 |
|
||||
| GET | `/order/template/list` | 已落地 | 客户见 standard + 自有 custom |
|
||||
@ -8939,8 +9024,10 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20
|
||||
| GET | `/order/batch/jobs/{id}/items` | 已落地 | 子项明细(含失败行、客户单号) |
|
||||
| POST | `/order/batch/jobs/{id}/retry-failed` | 已落地 | 重试失败子项 |
|
||||
| POST | `/order/batch/jobs/{id}/cancel` | 已落地 | 取消待处理子项 |
|
||||
| POST | `/order/parcel/create` | 部分 | 卖价预报应收;无应付;Live 快照 |
|
||||
| POST | `/order/parcel/create` | 部分 | 卖价预报应收;无应付;Live 快照;成功后生成 systemLabel |
|
||||
| POST | `/order/parcel/batch-create` | 部分 | 同步批处理,见 §4.4.3.2 |
|
||||
| POST | `/order/downstream-forward-create-one` | 已落地 | itfx createOrderOne;回写 DownstreamServiceNo/TrackNo + downstreamLabel |
|
||||
| GET | `/order/downstream-create-one-body` | 已落地 | 预览下游请求体 |
|
||||
| POST | `/order/fee/batch-recalculate` | 设计中 | §4.4.1.10 |
|
||||
| POST | `/order/track-upsert-small-bag` | 已落地 | 手动轨迹 UpsertSmallBag;见 **§4.4.1.11** |
|
||||
| POST | `/order/mark-problematic` | 设计中 | §4.4.1.13;批量标记问题件 + 问题模板 |
|
||||
|
||||
@ -55,10 +55,12 @@ export async function testAutoTriggerFormula(body: {
|
||||
}
|
||||
}) {
|
||||
const res = await axiosInstance.post(omsUrl('/channel/auto-trigger/test'), body)
|
||||
return unwrapData(res.data) as {
|
||||
matched: boolean
|
||||
expression?: string
|
||||
contextType?: string
|
||||
error?: string
|
||||
assertBusinessOk(res.data)
|
||||
const data = unwrapData(res.data) as Record<string, any>
|
||||
return {
|
||||
matched: Boolean(data?.matched ?? data?.Matched),
|
||||
expression: data?.expression ?? data?.Expression,
|
||||
contextType: data?.contextType ?? data?.ContextType,
|
||||
error: data?.error ?? data?.Error,
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { axiosInstance } from '@/api/rbac'
|
||||
import { resolveAdminApiPath } from '@/utils/adminApi'
|
||||
import { extractDraftFailFromBody, parseOmsDraftMarker } from '@/utils/parcelCreateFailGuide'
|
||||
|
||||
function orderUrl(path: string): string {
|
||||
return resolveAdminApiPath(path)
|
||||
@ -203,8 +204,23 @@ export function postOrderPay(body: OrderIdRequest) {
|
||||
}
|
||||
|
||||
/** POST /order/pre-declare */
|
||||
export function postOrderPreDeclare(body: OrderIdRequest) {
|
||||
return postOrderById('/order/pre-declare', body)
|
||||
export async function postOrderPreDeclare(body: OrderIdRequest): Promise<void> {
|
||||
try {
|
||||
const res = await axiosInstance.post(orderUrl('/order/pre-declare'), { id: body.id })
|
||||
const resBody = res.data
|
||||
if (!isBusinessOkBody(resBody)) {
|
||||
throwParcelCreateBusinessError(resBody, '预报失败')
|
||||
}
|
||||
const extracted = extractDraftFailFromBody(resBody)
|
||||
if (extracted.meta?.draftSaved) {
|
||||
throwParcelCreateBusinessError(resBody, extracted.cleanMessage || '预报失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e instanceof ParcelCreateBusinessError) throw e
|
||||
const data = e?.response?.data
|
||||
if (data) throwParcelCreateBusinessError(data, e?.message || '预报失败')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /order/intercept */
|
||||
@ -1576,11 +1592,84 @@ export async function postOrderUpload(
|
||||
}
|
||||
}
|
||||
|
||||
/** 小包创建/预报业务失败(可能已落草稿) */
|
||||
export class ParcelCreateBusinessError extends Error {
|
||||
draftSaved?: boolean
|
||||
orderId?: number
|
||||
serviceNo?: string
|
||||
raw?: any
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
extra?: {
|
||||
draftSaved?: boolean
|
||||
orderId?: number
|
||||
serviceNo?: string
|
||||
raw?: any
|
||||
},
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ParcelCreateBusinessError'
|
||||
this.draftSaved = extra?.draftSaved
|
||||
this.orderId = extra?.orderId
|
||||
this.serviceNo = extra?.serviceNo
|
||||
this.raw = extra?.raw
|
||||
}
|
||||
}
|
||||
|
||||
function isBusinessOkBody(body: any): boolean {
|
||||
if (body == null) return false
|
||||
const code = body.code ?? body.Code
|
||||
return (
|
||||
code === undefined ||
|
||||
code === null ||
|
||||
code === 0 ||
|
||||
code === 200 ||
|
||||
code === '0' ||
|
||||
code === '200' ||
|
||||
body.success === true ||
|
||||
body.Success === true
|
||||
)
|
||||
}
|
||||
|
||||
function throwParcelCreateBusinessError(body: any, fallbackMsg: string): never {
|
||||
const extracted = extractDraftFailFromBody(body)
|
||||
const fallbackParsed = parseOmsDraftMarker(
|
||||
String(body?.msg ?? body?.Msg ?? body?.message ?? body?.Message ?? fallbackMsg ?? '请求失败'),
|
||||
)
|
||||
const cleanMessage =
|
||||
extracted.cleanMessage || fallbackParsed.cleanMessage || fallbackMsg || '请求失败'
|
||||
const draftSaved = Boolean(extracted.meta?.draftSaved || fallbackParsed.meta?.draftSaved)
|
||||
const orderId = extracted.meta?.orderId ?? fallbackParsed.meta?.orderId
|
||||
const serviceNo = extracted.meta?.serviceNo ?? fallbackParsed.meta?.serviceNo
|
||||
throw new ParcelCreateBusinessError(cleanMessage, {
|
||||
draftSaved,
|
||||
orderId,
|
||||
serviceNo,
|
||||
raw: body,
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /order/parcel/create */
|
||||
export async function postOrderParcelCreate(body: Record<string, any>): Promise<any> {
|
||||
const res = await axiosInstance.post(orderUrl('/order/parcel/create'), body)
|
||||
assertBusinessOk(res.data)
|
||||
return res.data?.data ?? res.data?.Data ?? {}
|
||||
try {
|
||||
const res = await axiosInstance.post(orderUrl('/order/parcel/create'), body)
|
||||
const resBody = res.data
|
||||
if (!isBusinessOkBody(resBody)) {
|
||||
throwParcelCreateBusinessError(resBody, '请求失败')
|
||||
}
|
||||
// Fake 可能返回 { message, details } 且无 code:若含草稿失败标记仍按失败处理
|
||||
const extracted = extractDraftFailFromBody(resBody)
|
||||
if (extracted.meta?.draftSaved) {
|
||||
throwParcelCreateBusinessError(resBody, extracted.cleanMessage || '预报未完成')
|
||||
}
|
||||
return resBody?.data ?? resBody?.Data ?? {}
|
||||
} catch (e: any) {
|
||||
if (e instanceof ParcelCreateBusinessError) throw e
|
||||
const data = e?.response?.data
|
||||
if (data) throwParcelCreateBusinessError(data, e?.message || '请求失败')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export interface OrderParcelBatchCreateResult {
|
||||
@ -1816,3 +1905,78 @@ export async function postOrderStockBindClear(orderId: number): Promise<OrderSto
|
||||
assertBusinessOk(res.data)
|
||||
return unwrapData(res.data)
|
||||
}
|
||||
|
||||
export interface OrderFileItem {
|
||||
id: number
|
||||
orderId: number
|
||||
fileType: string
|
||||
fileName?: string
|
||||
fileUrl: string
|
||||
contentType?: string
|
||||
fileSize?: number
|
||||
sort?: number
|
||||
source?: string
|
||||
remark?: string
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
export interface OrderFileGroupedResult {
|
||||
orderId: number
|
||||
groups: Record<string, OrderFileItem[]>
|
||||
}
|
||||
|
||||
function normalizeOrderFileItem(raw: any): OrderFileItem {
|
||||
return {
|
||||
id: Number(raw?.id ?? raw?.Id ?? 0),
|
||||
orderId: Number(raw?.orderId ?? raw?.OrderId ?? 0),
|
||||
fileType: String(raw?.fileType ?? raw?.FileType ?? ''),
|
||||
fileName: raw?.fileName ?? raw?.FileName,
|
||||
fileUrl: String(raw?.fileUrl ?? raw?.FileUrl ?? ''),
|
||||
contentType: raw?.contentType ?? raw?.ContentType,
|
||||
fileSize: raw?.fileSize ?? raw?.FileSize,
|
||||
sort: raw?.sort ?? raw?.Sort,
|
||||
source: raw?.source ?? raw?.Source,
|
||||
remark: raw?.remark ?? raw?.Remark,
|
||||
createTime: raw?.createTime ?? raw?.CreateTime,
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /order/files */
|
||||
export async function getOrderFiles(orderId: number): Promise<OrderFileGroupedResult> {
|
||||
const res = await axiosInstance.get(orderUrl('/order/files'), { params: { orderId } })
|
||||
assertBusinessOk(res.data)
|
||||
const data = unwrapData<any>(res.data)
|
||||
const rawGroups = data?.groups ?? data?.Groups ?? {}
|
||||
const groups: Record<string, OrderFileItem[]> = {}
|
||||
Object.keys(rawGroups || {}).forEach((key) => {
|
||||
const list = Array.isArray(rawGroups[key]) ? rawGroups[key] : []
|
||||
groups[key] = list.map(normalizeOrderFileItem)
|
||||
})
|
||||
return {
|
||||
orderId: Number(data?.orderId ?? data?.OrderId ?? orderId),
|
||||
groups,
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /order/files/upload */
|
||||
export async function uploadOrderFiles(
|
||||
orderId: number,
|
||||
fileType: string,
|
||||
files: File[]
|
||||
): Promise<OrderFileItem[]> {
|
||||
const form = new FormData()
|
||||
form.append('orderId', String(orderId))
|
||||
form.append('fileType', fileType)
|
||||
files.forEach((file) => form.append('files', file))
|
||||
const res = await axiosInstance.post(orderUrl('/order/files/upload'), form)
|
||||
assertBusinessOk(res.data)
|
||||
const data = unwrapData<any>(res.data)
|
||||
const list = Array.isArray(data) ? data : data?.items ?? data?.Items ?? []
|
||||
return list.map(normalizeOrderFileItem)
|
||||
}
|
||||
|
||||
/** POST /order/files/delete */
|
||||
export async function deleteOrderFile(id: number): Promise<void> {
|
||||
const res = await axiosInstance.post(orderUrl('/order/files/delete'), { id })
|
||||
assertBusinessOk(res.data)
|
||||
}
|
||||
|
||||
@ -185,11 +185,25 @@
|
||||
const selectedRowIndicesSet = ref<Set<number>>(new Set());
|
||||
const internalColumnOrder = ref<string[]>([]);
|
||||
|
||||
/** 避免 `value || '--'` 把 0、false 当成空(订单金额/重量等会为 0) */
|
||||
/** 空值 / 无效数字:不展示 NaN,统一为 '--'(保留 0 / false) */
|
||||
function isEmptyCellValue(v: any): boolean {
|
||||
if (v === undefined || v === null || v === '') return true;
|
||||
if (typeof v === 'number' && Number.isNaN(v)) return true;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim().toLowerCase();
|
||||
if (s === '' || s === 'nan' || s === 'null' || s === 'undefined') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatCellDisplay(v: any): string {
|
||||
if (isEmptyCellValue(v)) return '--';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** 单元格展示:null / undefined / '' / NaN → '--',0 / false 正常显示 */
|
||||
function cellDisplayValue(row: any, key: string) {
|
||||
const v = row?.[key];
|
||||
if (v === undefined || v === null || v === '') return '--';
|
||||
return v;
|
||||
return formatCellDisplay(row?.[key]);
|
||||
}
|
||||
|
||||
function normalizeColumnOrder(
|
||||
@ -245,8 +259,7 @@
|
||||
// 创建省略号渲染器
|
||||
const createEllipsisRenderer = () => {
|
||||
return function(instance: any, td: any, row: number, col: number, prop: any, value: any) {
|
||||
const text = value || '--';
|
||||
const textStr = String(text);
|
||||
const textStr = formatCellDisplay(value);
|
||||
|
||||
td.style.textAlign = 'left';
|
||||
td.style.verticalAlign = 'middle';
|
||||
@ -257,7 +270,7 @@
|
||||
|
||||
td.textContent = textStr;
|
||||
|
||||
if (textStr && textStr !== '--') {
|
||||
if (textStr !== '--') {
|
||||
td.title = textStr;
|
||||
td.style.cursor = 'help';
|
||||
} else {
|
||||
@ -880,22 +893,24 @@
|
||||
// 自定义渲染器
|
||||
if (props.customCellRenderer) {
|
||||
const customResult = props.customCellRenderer(row, col, value, rowData);
|
||||
if (customResult !== null) {
|
||||
td.innerHTML = customResult;
|
||||
if (customResult !== null && customResult !== undefined) {
|
||||
td.innerHTML = isEmptyCellValue(customResult) ? '--' : String(customResult);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 列定义中的渲染器
|
||||
if (colDef.renderer) {
|
||||
td.innerHTML = colDef.renderer(rowData, value);
|
||||
const html = colDef.renderer(rowData, value);
|
||||
td.innerHTML = isEmptyCellValue(html) ? '--' : String(html);
|
||||
// 允许单元格内角标(如「多」)不被 td overflow 裁切
|
||||
td.style.overflow = 'visible';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
td.innerHTML = value || '--';
|
||||
// 默认渲染:null / NaN → '--'(保留 0 / false)
|
||||
td.innerHTML = formatCellDisplay(value);
|
||||
};
|
||||
|
||||
return cellProperties;
|
||||
|
||||
@ -1283,7 +1283,28 @@ export default {
|
||||
'order.myOrders.drawer.tabs.weight': 'Weight/Dimensions',
|
||||
'order.myOrders.drawer.tabs.fee': 'Receivables/Payables',
|
||||
'order.myOrders.drawer.tabs.track': 'Tracking',
|
||||
'order.myOrders.drawer.tabs.attachments': 'Attachments',
|
||||
'order.myOrders.drawer.tabs.stockBind': 'Bound stock',
|
||||
'order.myOrders.drawer.attachments.upload': 'Upload',
|
||||
'order.myOrders.drawer.attachments.preview': 'Preview',
|
||||
'order.myOrders.drawer.attachments.delete': 'Delete',
|
||||
'order.myOrders.drawer.attachments.empty': 'No attachments',
|
||||
'order.myOrders.drawer.attachments.loadFailed': 'Failed to load attachments',
|
||||
'order.myOrders.drawer.attachments.uploadSuccess': 'Uploaded',
|
||||
'order.myOrders.drawer.attachments.uploadFailed': 'Upload failed',
|
||||
'order.myOrders.drawer.attachments.deleteSuccess': 'Deleted',
|
||||
'order.myOrders.drawer.attachments.deleteFailed': 'Delete failed',
|
||||
'order.myOrders.drawer.attachments.deleteConfirmTitle': 'Delete attachment',
|
||||
'order.myOrders.drawer.attachments.deleteConfirm': 'Delete this attachment?',
|
||||
'order.myOrders.drawer.attachments.types.tradeProof': 'Trade proof',
|
||||
'order.myOrders.drawer.attachments.types.systemLabel': 'System label',
|
||||
'order.myOrders.drawer.attachments.types.downstreamLabel': 'Downstream label',
|
||||
'order.myOrders.drawer.attachments.types.lastMileLabel': 'Last-mile label',
|
||||
'order.myOrders.drawer.attachments.types.infraredImage': 'Infrared image',
|
||||
'order.myOrders.drawer.attachments.source.upload': 'Upload',
|
||||
'order.myOrders.drawer.attachments.source.systemGenerate': 'System generated',
|
||||
'order.myOrders.drawer.attachments.source.downstream': 'Downstream',
|
||||
'order.myOrders.drawer.attachments.source.migrate': 'Migrated',
|
||||
'order.myOrders.drawer.orderBasic': 'Basic Info',
|
||||
'order.myOrders.drawer.basic.viewAll': 'All',
|
||||
'order.myOrders.drawer.basic.group.identity': 'Order IDs',
|
||||
@ -1314,6 +1335,10 @@ export default {
|
||||
'order.myOrders.table.cargoAttribute': 'Cargo Attribute',
|
||||
'order.myOrders.drawer.orderStatus': 'Order Status',
|
||||
'order.myOrders.drawer.predeclareFailReason': 'Predeclare Failure Reason',
|
||||
'order.myOrders.drawer.failGuideTitle': 'Pre-declaration incomplete — what to change',
|
||||
'order.myOrders.drawer.failGuideRaw': 'Details',
|
||||
'order.myOrders.drawer.failGuideAskSupport': 'Contact support',
|
||||
'order.myOrders.drawer.failGuideEdit': 'Edit order',
|
||||
'order.myOrders.drawer.allocationPendingReason': 'Allocation Pending Reason',
|
||||
'order.myOrders.drawer.warehouse': 'Warehouse',
|
||||
'order.myOrders.drawer.receiveTime': 'Receive Time',
|
||||
@ -1704,6 +1729,43 @@ export default {
|
||||
'order.placeOrder.single.successGoOrders': 'Go to My Orders',
|
||||
'order.placeOrder.single.successStay': 'Stay on this page',
|
||||
'order.placeOrder.single.failed': 'Failed to place order',
|
||||
'order.placeOrder.single.failTitle': 'Order was not placed',
|
||||
'order.placeOrder.single.failTitleDraft': 'Pre-declaration failed (draft saved)',
|
||||
'order.placeOrder.single.failWhatHappened': 'What happened',
|
||||
'order.placeOrder.single.failHowToFix': 'What to change',
|
||||
'order.placeOrder.single.failRawReason': 'Details',
|
||||
'order.placeOrder.single.failAskSupport': 'Contact support',
|
||||
'order.placeOrder.single.failCopied': 'Waybill No. copied',
|
||||
'order.placeOrder.single.failCopyFailed': 'Copy failed. Please select the number manually.',
|
||||
'order.placeOrder.single.failDraftSavedNote':
|
||||
'The order was saved as a draft, but pre-declaration did not finish. Edit the draft in My Orders and pre-declare again — do not submit again here (that would create a duplicate).',
|
||||
'order.placeOrder.single.failNotSavedNote':
|
||||
'The order was not saved. Continue editing and retry, or save it as a draft for later.',
|
||||
'order.placeOrder.single.failServiceNo': 'Waybill No.',
|
||||
'order.placeOrder.single.failContinueEdit': 'Continue editing',
|
||||
'order.placeOrder.single.failEditDraft': 'Edit draft',
|
||||
'order.placeOrder.single.failKeepDraftEdit': 'Keep draft and edit',
|
||||
'order.placeOrder.single.failSaveAsDraft': 'Save as draft',
|
||||
'order.placeOrder.single.failSaveAsDraftOk': 'Saved as draft',
|
||||
'order.placeOrder.single.failSaveAsDraftFailed': 'Failed to save draft',
|
||||
'order.placeOrder.single.failSaveAsDraftGo': 'View draft',
|
||||
'order.placeOrder.single.failSaveAsDraftStay': 'Stay on this page',
|
||||
'order.placeOrder.single.failSaveLocal': 'Save local draft',
|
||||
'order.placeOrder.single.failSaveLocalOk': 'Saved locally. You can continue later.',
|
||||
'order.placeOrder.single.failSaveLocalFailed': 'Failed to save local draft',
|
||||
'order.placeOrder.single.failGoDrafts': 'View draft list',
|
||||
'order.placeOrder.single.failAbandon': 'Discard',
|
||||
'order.placeOrder.single.failCancelSubmit': 'Cancel submission (delete draft)',
|
||||
'order.placeOrder.single.failAbandonTitle': 'Discard this form?',
|
||||
'order.placeOrder.single.failAbandonContent': 'The current form will be cleared. This cannot be undone.',
|
||||
'order.placeOrder.single.failAbandonOk': 'Discard',
|
||||
'order.placeOrder.single.failAbandonDone': 'Form cleared',
|
||||
'order.placeOrder.single.failCancelDraftTitle': 'Cancel this submission?',
|
||||
'order.placeOrder.single.failCancelDraftContent': 'The draft order just saved will be deleted. This cannot be undone.',
|
||||
'order.placeOrder.single.failCancelDraftOk': 'Delete draft',
|
||||
'order.placeOrder.single.failCancelKeep': 'Keep editing',
|
||||
'order.placeOrder.single.failCancelDraftDone': 'Draft deleted',
|
||||
'order.placeOrder.single.failCancelDraftFailed': 'Failed to delete draft',
|
||||
|
||||
'order.placeOrder.standardTemplates': 'Standard templates',
|
||||
'order.placeOrder.customTemplates': 'Custom templates',
|
||||
@ -3640,7 +3702,7 @@ export default {
|
||||
'channel.salesProducts.form.productName': 'Product name',
|
||||
'channel.salesProducts.form.group': 'Group',
|
||||
'channel.salesProducts.form.efficiency': 'Transit time',
|
||||
'channel.salesProducts.form.customerWaybill': 'Customer waybill',
|
||||
'channel.salesProducts.form.customerWaybill': 'System label',
|
||||
'channel.salesProducts.form.placeholder.labelTemplate': 'Select label template (global default if empty)',
|
||||
'channel.salesProducts.form.blacklistArea': 'Blacklist area',
|
||||
'channel.salesProducts.form.remoteAreaDivision': 'Remote area division',
|
||||
@ -3685,7 +3747,14 @@ export default {
|
||||
|
||||
'channel.salesProducts.drawer.footer.cancel': 'Cancel',
|
||||
'channel.salesProducts.drawer.footer.generatePriceList': 'Generate price table',
|
||||
'channel.salesProducts.drawer.footer.syncPriceList': 'Sync price table',
|
||||
'channel.salesProducts.drawer.footer.save': 'Save',
|
||||
'channel.salesProducts.drawer.priceList.hint': 'Existing rows keep prices; select rows to batch-fill',
|
||||
'channel.salesProducts.drawer.priceList.batchFill': 'Batch fill',
|
||||
'channel.salesProducts.drawer.priceList.batchFillEmpty': 'Fill empty/0 only',
|
||||
'channel.salesProducts.drawer.priceList.copyPrev': 'Copy previous row',
|
||||
'channel.salesProducts.drawer.priceList.batchFillSelected': '{count} rows selected',
|
||||
'channel.salesProducts.drawer.priceList.batchFillValue': 'Price',
|
||||
|
||||
// Channel - Service Channels
|
||||
'channel.serviceChannels.search.placeholder': 'Enter channel name',
|
||||
@ -3752,13 +3821,21 @@ export default {
|
||||
'channel.serviceChannels.drawer.form.currency': 'Currency',
|
||||
'channel.serviceChannels.drawer.form.chargingCondition': 'Charging condition',
|
||||
'channel.serviceChannels.drawer.section.billingFactors': 'Billing factors',
|
||||
'channel.serviceChannels.drawer.section.priceListTitle': 'Freight',
|
||||
'channel.serviceChannels.drawer.table.billingFactors.serialNo': 'No.',
|
||||
'channel.serviceChannels.drawer.table.billingFactors.factor': 'Factor',
|
||||
'channel.serviceChannels.drawer.table.billingFactors.factorValues': 'Factor values',
|
||||
'channel.serviceChannels.drawer.table.billingFactors.actions': 'Action',
|
||||
'channel.serviceChannels.drawer.footer.cancel': 'Cancel',
|
||||
'channel.serviceChannels.drawer.footer.generatePriceList': 'Generate price table',
|
||||
'channel.serviceChannels.drawer.footer.syncPriceList': 'Sync price table',
|
||||
'channel.serviceChannels.drawer.footer.save': 'Save',
|
||||
'channel.serviceChannels.drawer.priceList.hint': 'Existing combos keep unit prices; select rows to batch-fill',
|
||||
'channel.serviceChannels.drawer.priceList.batchFill': 'Batch fill',
|
||||
'channel.serviceChannels.drawer.priceList.batchFillEmpty': 'Fill empty/0 only',
|
||||
'channel.serviceChannels.drawer.priceList.copyPrev': 'Copy previous row',
|
||||
'channel.serviceChannels.drawer.priceList.batchFillSelected': '{count} rows selected',
|
||||
'channel.serviceChannels.drawer.priceList.batchFillValue': 'Unit price',
|
||||
'channel.serviceChannels.createTime': 'Created: {time}',
|
||||
'channel.serviceChannels.empty.selectChannel': 'Select a channel from the left',
|
||||
'channel.serviceChannels.context.channel': 'Channel',
|
||||
@ -4020,17 +4097,54 @@ export default {
|
||||
'channel.autoTrigger.form.notifierPlaceholder': 'Optional notifier users for warn/problem',
|
||||
'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.testFormula': 'Verify condition',
|
||||
'channel.autoTrigger.form.testSample': 'Sample data',
|
||||
'channel.autoTrigger.form.testMatched': 'Matched',
|
||||
'channel.autoTrigger.form.testNotMatched': 'Not matched',
|
||||
'channel.autoTrigger.form.testByOrder': 'Real order',
|
||||
'channel.autoTrigger.form.testHint':
|
||||
'Evaluate the formula against sample data or an existing order. Does not affect live orders.',
|
||||
'channel.autoTrigger.form.testRun': 'Run test',
|
||||
'channel.autoTrigger.form.testNeedFormula': 'Enter a trigger formula first',
|
||||
'channel.autoTrigger.form.testNeedOrderId': 'Enter a valid order ID',
|
||||
'channel.autoTrigger.form.testOrderId': 'Order ID',
|
||||
'channel.autoTrigger.form.testOrderIdPlaceholder': 't_order.Id',
|
||||
'channel.autoTrigger.form.testMatched': 'Matched (true)',
|
||||
'channel.autoTrigger.form.testNotMatched': 'Not matched (false)',
|
||||
'channel.autoTrigger.form.testFailed': 'Formula test failed',
|
||||
'channel.autoTrigger.form.sampleCountry': 'Country',
|
||||
'channel.autoTrigger.form.sampleCountry': 'Country code',
|
||||
'channel.autoTrigger.form.sampleCountryName': 'Country name',
|
||||
'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.autoTrigger.form.sampleLength': 'Length (cm)',
|
||||
'channel.autoTrigger.form.sampleWidth': 'Width (cm)',
|
||||
'channel.autoTrigger.form.sampleHeight': 'Height (cm)',
|
||||
'channel.autoTrigger.form.sampleWeight': 'Weight (kg)',
|
||||
'channel.autoTrigger.ai.open': 'AI formula',
|
||||
'channel.autoTrigger.ai.toolbarHint': 'Describe the rule and let AI fill the formula',
|
||||
'channel.autoTrigger.ai.title': 'AI trigger condition',
|
||||
'channel.autoTrigger.ai.configLink': 'Configure AI',
|
||||
'channel.autoTrigger.ai.intro': 'Pick an enabled AI provider and model, optionally send the formula skill, then generate and fill.',
|
||||
'channel.autoTrigger.ai.provider': 'AI provider',
|
||||
'channel.autoTrigger.ai.providerPlaceholder': 'Select AI',
|
||||
'channel.autoTrigger.ai.model': 'Model',
|
||||
'channel.autoTrigger.ai.modelPlaceholder': 'Select or type a model',
|
||||
'channel.autoTrigger.ai.sendSkill': 'Send formula skill',
|
||||
'channel.autoTrigger.ai.sendSkillHint': 'Includes syntax rules and field catalog (recommended)',
|
||||
'channel.autoTrigger.ai.prompt': 'Rule description',
|
||||
'channel.autoTrigger.ai.promptPlaceholder': 'e.g. UK oversize intercept… (Ctrl+Enter to generate)',
|
||||
'channel.autoTrigger.ai.generate': 'Generate',
|
||||
'channel.autoTrigger.ai.generating': 'Generating…',
|
||||
'channel.autoTrigger.ai.clear': 'Clear result',
|
||||
'channel.autoTrigger.ai.result': 'Parsed result',
|
||||
'channel.autoTrigger.ai.sampleHint': 'Sample',
|
||||
'channel.autoTrigger.ai.rawReply': 'Full reply',
|
||||
'channel.autoTrigger.ai.fillFormula': 'Fill formula',
|
||||
'channel.autoTrigger.ai.filled': 'Formula filled',
|
||||
'channel.autoTrigger.ai.generateOk': 'Formula ready — review then fill',
|
||||
'channel.autoTrigger.ai.generateFail': 'Generation failed',
|
||||
'channel.autoTrigger.ai.emptyReply': 'Empty AI reply',
|
||||
'channel.autoTrigger.ai.parseFail': 'Could not parse a formula from the reply',
|
||||
'channel.autoTrigger.ai.needProviderModelPrompt': 'Select AI, model, and enter a description',
|
||||
'channel.autoTrigger.ai.loadProvidersFail': 'Failed to load AI providers',
|
||||
|
||||
// Channel - Issue Template
|
||||
'channel.issueTemplate.actions.add': 'Add',
|
||||
|
||||
@ -1171,7 +1171,28 @@ export default {
|
||||
'order.myOrders.drawer.tabs.weight': '件重尺',
|
||||
'order.myOrders.drawer.tabs.fee': '应收应付',
|
||||
'order.myOrders.drawer.tabs.track': '轨迹',
|
||||
'order.myOrders.drawer.tabs.attachments': '附件',
|
||||
'order.myOrders.drawer.tabs.stockBind': '绑定包裹/库存',
|
||||
'order.myOrders.drawer.attachments.upload': '上传',
|
||||
'order.myOrders.drawer.attachments.preview': '预览',
|
||||
'order.myOrders.drawer.attachments.delete': '删除',
|
||||
'order.myOrders.drawer.attachments.empty': '暂无附件',
|
||||
'order.myOrders.drawer.attachments.loadFailed': '加载附件失败',
|
||||
'order.myOrders.drawer.attachments.uploadSuccess': '上传成功',
|
||||
'order.myOrders.drawer.attachments.uploadFailed': '上传失败',
|
||||
'order.myOrders.drawer.attachments.deleteSuccess': '已删除',
|
||||
'order.myOrders.drawer.attachments.deleteFailed': '删除失败',
|
||||
'order.myOrders.drawer.attachments.deleteConfirmTitle': '删除附件',
|
||||
'order.myOrders.drawer.attachments.deleteConfirm': '确认删除该附件?',
|
||||
'order.myOrders.drawer.attachments.types.tradeProof': '交易证明',
|
||||
'order.myOrders.drawer.attachments.types.systemLabel': '系统面单',
|
||||
'order.myOrders.drawer.attachments.types.downstreamLabel': '下游面单',
|
||||
'order.myOrders.drawer.attachments.types.lastMileLabel': '末端面单',
|
||||
'order.myOrders.drawer.attachments.types.infraredImage': '红外图片',
|
||||
'order.myOrders.drawer.attachments.source.upload': '上传',
|
||||
'order.myOrders.drawer.attachments.source.systemGenerate': '系统生成',
|
||||
'order.myOrders.drawer.attachments.source.downstream': '下游回传',
|
||||
'order.myOrders.drawer.attachments.source.migrate': '迁移',
|
||||
'order.myOrders.drawer.orderBasic': '基本信息',
|
||||
'order.myOrders.drawer.basic.viewAll': '全部',
|
||||
'order.myOrders.drawer.basic.group.identity': '单号信息',
|
||||
@ -1202,6 +1223,10 @@ export default {
|
||||
'order.myOrders.table.cargoAttribute': '货物属性',
|
||||
'order.myOrders.drawer.orderStatus': '订单状态',
|
||||
'order.myOrders.drawer.predeclareFailReason': '预报失败原因',
|
||||
'order.myOrders.drawer.failGuideTitle': '预报未完成 — 建议修改',
|
||||
'order.myOrders.drawer.failGuideRaw': '详细原因',
|
||||
'order.myOrders.drawer.failGuideAskSupport': '联系客服',
|
||||
'order.myOrders.drawer.failGuideEdit': '去修改',
|
||||
'order.myOrders.drawer.allocationPendingReason': '待排货原因',
|
||||
'order.myOrders.drawer.warehouse': '仓库',
|
||||
'order.myOrders.drawer.receiveTime': '收货时间',
|
||||
@ -1591,6 +1616,41 @@ export default {
|
||||
'order.placeOrder.single.successGoOrders': '查看我的订单',
|
||||
'order.placeOrder.single.successStay': '继续留在本页',
|
||||
'order.placeOrder.single.failed': '单票下单失败',
|
||||
'order.placeOrder.single.failTitle': '下单未成功',
|
||||
'order.placeOrder.single.failTitleDraft': '预报未完成(草稿已保存)',
|
||||
'order.placeOrder.single.failWhatHappened': '发生了什么',
|
||||
'order.placeOrder.single.failHowToFix': '建议修改',
|
||||
'order.placeOrder.single.failRawReason': '详细原因',
|
||||
'order.placeOrder.single.failAskSupport': '联系客服',
|
||||
'order.placeOrder.single.failCopied': '运单号已复制',
|
||||
'order.placeOrder.single.failCopyFailed': '复制失败,请手动选中运单号',
|
||||
'order.placeOrder.single.failDraftSavedNote': '订单已保存为草稿,但预报未完成。请到「我的订单」修改草稿后重新预报,不要在本页再次提交(会重复建单)。',
|
||||
'order.placeOrder.single.failNotSavedNote': '订单尚未落库。您可以继续修改后重试,或先保存为草稿稍后再预报。',
|
||||
'order.placeOrder.single.failServiceNo': '运单号',
|
||||
'order.placeOrder.single.failContinueEdit': '继续修改',
|
||||
'order.placeOrder.single.failEditDraft': '去修改草稿',
|
||||
'order.placeOrder.single.failKeepDraftEdit': '保留草稿并修改',
|
||||
'order.placeOrder.single.failSaveAsDraft': '保存为草稿',
|
||||
'order.placeOrder.single.failSaveAsDraftOk': '已保存为草稿',
|
||||
'order.placeOrder.single.failSaveAsDraftFailed': '保存草稿失败',
|
||||
'order.placeOrder.single.failSaveAsDraftGo': '查看草稿',
|
||||
'order.placeOrder.single.failSaveAsDraftStay': '继续留在本页',
|
||||
'order.placeOrder.single.failSaveLocal': '保存到本地草稿',
|
||||
'order.placeOrder.single.failSaveLocalOk': '已保存到本地,可稍后继续填写',
|
||||
'order.placeOrder.single.failSaveLocalFailed': '本地草稿保存失败',
|
||||
'order.placeOrder.single.failGoDrafts': '查看草稿列表',
|
||||
'order.placeOrder.single.failAbandon': '放弃本次',
|
||||
'order.placeOrder.single.failCancelSubmit': '取消提交(删除草稿)',
|
||||
'order.placeOrder.single.failAbandonTitle': '放弃本次填写?',
|
||||
'order.placeOrder.single.failAbandonContent': '将清空当前表单内容,此操作不可恢复。',
|
||||
'order.placeOrder.single.failAbandonOk': '确认放弃',
|
||||
'order.placeOrder.single.failAbandonDone': '已清空本次填写',
|
||||
'order.placeOrder.single.failCancelDraftTitle': '取消本次提交?',
|
||||
'order.placeOrder.single.failCancelDraftContent': '将删除刚保存的草稿订单,此操作不可恢复。',
|
||||
'order.placeOrder.single.failCancelDraftOk': '确认删除草稿',
|
||||
'order.placeOrder.single.failCancelKeep': '再想想',
|
||||
'order.placeOrder.single.failCancelDraftDone': '草稿已删除',
|
||||
'order.placeOrder.single.failCancelDraftFailed': '删除草稿失败',
|
||||
|
||||
'order.placeOrder.standardTemplates': '标准模板',
|
||||
'order.placeOrder.customTemplates': '自定义模板',
|
||||
@ -3660,7 +3720,7 @@ export default {
|
||||
'channel.salesProducts.form.productName': '产品名称',
|
||||
'channel.salesProducts.form.group': '分组',
|
||||
'channel.salesProducts.form.efficiency': '时效',
|
||||
'channel.salesProducts.form.customerWaybill': '客户面单',
|
||||
'channel.salesProducts.form.customerWaybill': '系统面单',
|
||||
'channel.salesProducts.form.blacklistArea': '黑名单区',
|
||||
'channel.salesProducts.form.remoteAreaDivision': '偏远分区',
|
||||
'channel.salesProducts.form.priceArea': '价格分区',
|
||||
@ -3705,7 +3765,14 @@ export default {
|
||||
|
||||
'channel.salesProducts.drawer.footer.cancel': '取消',
|
||||
'channel.salesProducts.drawer.footer.generatePriceList': '生成价格表',
|
||||
'channel.salesProducts.drawer.footer.syncPriceList': '同步价格表',
|
||||
'channel.salesProducts.drawer.footer.save': '保存',
|
||||
'channel.salesProducts.drawer.priceList.hint': '已有组合会保留价格;勾选行可批量填价',
|
||||
'channel.salesProducts.drawer.priceList.batchFill': '批量填价',
|
||||
'channel.salesProducts.drawer.priceList.batchFillEmpty': '只填空白/0',
|
||||
'channel.salesProducts.drawer.priceList.copyPrev': '复制上一行',
|
||||
'channel.salesProducts.drawer.priceList.batchFillSelected': '已选 {count} 行',
|
||||
'channel.salesProducts.drawer.priceList.batchFillValue': '统一价格',
|
||||
|
||||
// 渠道 - 服务渠道
|
||||
'channel.serviceChannels.search.placeholder': '请输入渠道名称',
|
||||
@ -3774,13 +3841,21 @@ export default {
|
||||
'channel.serviceChannels.drawer.form.currency': '币种',
|
||||
'channel.serviceChannels.drawer.form.chargingCondition': '收费条件',
|
||||
'channel.serviceChannels.drawer.section.billingFactors': '计费因素',
|
||||
'channel.serviceChannels.drawer.section.priceListTitle': '运费',
|
||||
'channel.serviceChannels.drawer.table.billingFactors.serialNo': '序号',
|
||||
'channel.serviceChannels.drawer.table.billingFactors.factor': '因素',
|
||||
'channel.serviceChannels.drawer.table.billingFactors.factorValues': '因素值',
|
||||
'channel.serviceChannels.drawer.table.billingFactors.actions': '操作',
|
||||
'channel.serviceChannels.drawer.footer.cancel': '取消',
|
||||
'channel.serviceChannels.drawer.footer.generatePriceList': '生成价格表',
|
||||
'channel.serviceChannels.drawer.footer.syncPriceList': '同步价格表',
|
||||
'channel.serviceChannels.drawer.footer.save': '保存',
|
||||
'channel.serviceChannels.drawer.priceList.hint': '已有组合会保留单价;勾选行可批量填价',
|
||||
'channel.serviceChannels.drawer.priceList.batchFill': '批量填价',
|
||||
'channel.serviceChannels.drawer.priceList.batchFillEmpty': '只填空白/0',
|
||||
'channel.serviceChannels.drawer.priceList.copyPrev': '复制上一行',
|
||||
'channel.serviceChannels.drawer.priceList.batchFillSelected': '已选 {count} 行',
|
||||
'channel.serviceChannels.drawer.priceList.batchFillValue': '统一单价',
|
||||
'channel.serviceChannels.createTime': '创建: {time}',
|
||||
'channel.serviceChannels.empty.selectChannel': '请从左侧选择渠道',
|
||||
'channel.serviceChannels.context.channel': '当前渠道',
|
||||
@ -4057,17 +4132,53 @@ export default {
|
||||
'channel.autoTrigger.form.notifierPlaceholder': '警告/问题件可选,请选择通知人',
|
||||
'channel.autoTrigger.form.loadExample': '载入超尺寸示例',
|
||||
'channel.autoTrigger.form.legacyTitle': '检测到旧版纯文本条件,请用下方构建器重新配置',
|
||||
'channel.autoTrigger.form.testFormula': '测试公式',
|
||||
'channel.autoTrigger.form.testFormula': '验证触发条件',
|
||||
'channel.autoTrigger.form.testSample': '样例数据',
|
||||
'channel.autoTrigger.form.testMatched': '命中规则',
|
||||
'channel.autoTrigger.form.testNotMatched': '未命中',
|
||||
'channel.autoTrigger.form.testByOrder': '真实订单',
|
||||
'channel.autoTrigger.form.testHint': '用样例或已有订单试算当前公式,确认是否会命中(拦截/警告/问题件)。不影响正式下单。',
|
||||
'channel.autoTrigger.form.testRun': '立即验证',
|
||||
'channel.autoTrigger.form.testNeedFormula': '请先填写触发条件公式',
|
||||
'channel.autoTrigger.form.testNeedOrderId': '请填写有效的订单 ID',
|
||||
'channel.autoTrigger.form.testOrderId': '订单 ID',
|
||||
'channel.autoTrigger.form.testOrderIdPlaceholder': '输入 t_order.Id',
|
||||
'channel.autoTrigger.form.testMatched': '命中规则(条件为真)',
|
||||
'channel.autoTrigger.form.testNotMatched': '未命中(条件为假)',
|
||||
'channel.autoTrigger.form.testFailed': '公式测试失败',
|
||||
'channel.autoTrigger.form.sampleCountry': '国家',
|
||||
'channel.autoTrigger.form.sampleCountry': '国家代码',
|
||||
'channel.autoTrigger.form.sampleCountryName': '国家名称',
|
||||
'channel.autoTrigger.form.sampleCustomer': '客户名称',
|
||||
'channel.autoTrigger.form.sampleProduct': '销售产品',
|
||||
'channel.autoTrigger.form.sampleLength': '长度',
|
||||
'channel.autoTrigger.form.sampleWidth': '宽度',
|
||||
'channel.autoTrigger.form.sampleHeight': '高度',
|
||||
'channel.autoTrigger.form.sampleLength': '长度 (cm)',
|
||||
'channel.autoTrigger.form.sampleWidth': '宽度 (cm)',
|
||||
'channel.autoTrigger.form.sampleHeight': '高度 (cm)',
|
||||
'channel.autoTrigger.form.sampleWeight': '实重 (kg)',
|
||||
'channel.autoTrigger.ai.open': 'AI 写公式',
|
||||
'channel.autoTrigger.ai.toolbarHint': '不会写公式?用 AI 助手按描述生成后填入',
|
||||
'channel.autoTrigger.ai.title': 'AI 写触发条件',
|
||||
'channel.autoTrigger.ai.configLink': '配置 AI',
|
||||
'channel.autoTrigger.ai.intro': '选择已启用的 AI 与模型,勾选发送 skill 后描述规则,生成中文公式并可一键填入。',
|
||||
'channel.autoTrigger.ai.provider': 'AI 提供商',
|
||||
'channel.autoTrigger.ai.providerPlaceholder': '选择 AI',
|
||||
'channel.autoTrigger.ai.model': '模型',
|
||||
'channel.autoTrigger.ai.modelPlaceholder': '选择或输入模型',
|
||||
'channel.autoTrigger.ai.sendSkill': '发送公式 skill',
|
||||
'channel.autoTrigger.ai.sendSkillHint': '附带语法规则与字段目录,推荐开启',
|
||||
'channel.autoTrigger.ai.prompt': '规则描述',
|
||||
'channel.autoTrigger.ai.promptPlaceholder': '例如:英国超尺寸拦截,排除某产品…(Ctrl+Enter 生成)',
|
||||
'channel.autoTrigger.ai.generate': '生成公式',
|
||||
'channel.autoTrigger.ai.generating': '生成中…',
|
||||
'channel.autoTrigger.ai.clear': '清空结果',
|
||||
'channel.autoTrigger.ai.result': '解析结果',
|
||||
'channel.autoTrigger.ai.sampleHint': '验证样例',
|
||||
'channel.autoTrigger.ai.rawReply': '查看完整回复',
|
||||
'channel.autoTrigger.ai.fillFormula': '填入公式',
|
||||
'channel.autoTrigger.ai.filled': '已填入公式',
|
||||
'channel.autoTrigger.ai.generateOk': '公式已生成,可预览后填入',
|
||||
'channel.autoTrigger.ai.generateFail': '生成失败',
|
||||
'channel.autoTrigger.ai.emptyReply': 'AI 未返回内容',
|
||||
'channel.autoTrigger.ai.parseFail': '未能解析出公式,请查看完整回复或换一种描述重试',
|
||||
'channel.autoTrigger.ai.needProviderModelPrompt': '请选择 AI、模型并填写描述',
|
||||
'channel.autoTrigger.ai.loadProvidersFail': '加载 AI 提供商失败',
|
||||
|
||||
// 渠道 - 问题模板
|
||||
'channel.issueTemplate.actions.add': '新增',
|
||||
|
||||
233
src/utils/parcelCreateFailGuide.ts
Normal file
233
src/utils/parcelCreateFailGuide.ts
Normal file
@ -0,0 +1,233 @@
|
||||
/** 下单创建失败:解析草稿标记 + 原因→修改指引 */
|
||||
|
||||
export type PlaceOrderFailSection = 'basic' | 'sender' | 'recipient' | 'declaration'
|
||||
|
||||
export interface ParcelCreateDraftMeta {
|
||||
draftSaved: boolean
|
||||
orderId?: number
|
||||
serviceNo?: string
|
||||
}
|
||||
|
||||
export interface ParcelCreateFailGuide {
|
||||
/** 原始技术原因(可用于排查) */
|
||||
rawReason: string
|
||||
/** 对客户展示的人话原因 */
|
||||
reason: string
|
||||
tips: string[]
|
||||
section: PlaceOrderFailSection
|
||||
draftSaved: boolean
|
||||
orderId?: number
|
||||
serviceNo?: string
|
||||
/** 是否建议联系客服 */
|
||||
suggestSupport?: boolean
|
||||
}
|
||||
|
||||
const OMS_DRAFT_RE = /\[\[omsDraft:(\{[\s\S]*\})\]\]\s*$/
|
||||
export const OMS_DRAFT_SAVED_ERROR_CODE = 'OMS_DRAFT_SAVED'
|
||||
|
||||
export function parseOmsDraftMarker(message: string): {
|
||||
cleanMessage: string
|
||||
meta?: ParcelCreateDraftMeta
|
||||
} {
|
||||
const raw = String(message || '')
|
||||
const match = raw.match(OMS_DRAFT_RE)
|
||||
if (!match || match.index == null) {
|
||||
return { cleanMessage: raw.trim() }
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]) as Record<string, unknown>
|
||||
const orderIdRaw = Number(parsed.orderId ?? parsed.OrderId)
|
||||
const serviceNo = String(parsed.serviceNo ?? parsed.ServiceNo ?? '').trim()
|
||||
return {
|
||||
cleanMessage: raw.slice(0, match.index).trim(),
|
||||
meta: {
|
||||
draftSaved: Boolean(parsed.draftSaved ?? parsed.DraftSaved),
|
||||
orderId: Number.isFinite(orderIdRaw) && orderIdRaw > 0 ? orderIdRaw : undefined,
|
||||
serviceNo: serviceNo || undefined,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return { cleanMessage: raw.replace(OMS_DRAFT_RE, '').trim() }
|
||||
}
|
||||
}
|
||||
|
||||
/** 从业务错误响应体提取草稿元数据(优先 data/details,兼容 msg 标记) */
|
||||
export function extractDraftFailFromBody(body: any): {
|
||||
cleanMessage: string
|
||||
meta?: ParcelCreateDraftMeta & { errorCode?: string }
|
||||
} {
|
||||
const rawMsg = String(
|
||||
body?.msg ?? body?.Msg ?? body?.message ?? body?.Message ?? '',
|
||||
)
|
||||
const fromMarker = parseOmsDraftMarker(rawMsg)
|
||||
let draftSaved = Boolean(fromMarker.meta?.draftSaved)
|
||||
let orderId = fromMarker.meta?.orderId
|
||||
let serviceNo = fromMarker.meta?.serviceNo
|
||||
let errorCode =
|
||||
String(body?.errorCode ?? body?.ErrorCode ?? '').trim() || undefined
|
||||
|
||||
const candidates = [
|
||||
body?.data,
|
||||
body?.Data,
|
||||
body?.details,
|
||||
body?.Details,
|
||||
body?.error?.details,
|
||||
body?.error?.Details,
|
||||
body?.error?.data,
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate == null) continue
|
||||
let obj = candidate
|
||||
if (typeof candidate === 'string') {
|
||||
const trimmed = candidate.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
obj = JSON.parse(trimmed)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!obj || typeof obj !== 'object') continue
|
||||
if (obj.draftSaved === true || obj.DraftSaved === true) draftSaved = true
|
||||
const id = Number(obj.orderId ?? obj.OrderId)
|
||||
if (Number.isFinite(id) && id > 0) orderId = id
|
||||
const sn = String(obj.serviceNo ?? obj.ServiceNo ?? '').trim()
|
||||
if (sn) serviceNo = sn
|
||||
const code = String(obj.errorCode ?? obj.ErrorCode ?? '').trim()
|
||||
if (code) errorCode = code
|
||||
const reason = String(obj.reason ?? obj.Reason ?? '').trim()
|
||||
if (reason && !fromMarker.cleanMessage) {
|
||||
fromMarker.cleanMessage = reason
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCode === OMS_DRAFT_SAVED_ERROR_CODE) draftSaved = true
|
||||
|
||||
return {
|
||||
cleanMessage: fromMarker.cleanMessage || rawMsg.trim(),
|
||||
meta:
|
||||
draftSaved || orderId || serviceNo || errorCode
|
||||
? { draftSaved, orderId, serviceNo, errorCode }
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
type GuideRule = {
|
||||
test: RegExp
|
||||
plainReason: string
|
||||
tips: string[]
|
||||
section: PlaceOrderFailSection
|
||||
suggestSupport?: boolean
|
||||
}
|
||||
|
||||
const GUIDE_RULES: GuideRule[] = [
|
||||
{
|
||||
test: /黑名单/,
|
||||
plainReason: '收件地址在当前运输方式的禁运/黑名单范围内',
|
||||
tips: ['请更换收件国家、省市或邮编', '或尝试更换运输方式(销售产品)'],
|
||||
section: 'recipient',
|
||||
},
|
||||
{
|
||||
test: /价格分区未匹配/,
|
||||
plainReason: '收件邮编/国家未匹配到可用价格分区',
|
||||
tips: ['请核对收件国家与邮编是否正确', '若仍无法匹配,请更换运输方式或联系客服'],
|
||||
section: 'recipient',
|
||||
suggestSupport: true,
|
||||
},
|
||||
{
|
||||
test: /计费重|件重尺|货物重量|长、宽、高|实重/,
|
||||
plainReason: '包裹尺寸或重量无法完成计费',
|
||||
tips: ['请检查长、宽、高与实重是否填写完整且大于 0'],
|
||||
section: 'basic',
|
||||
},
|
||||
{
|
||||
test: /产品卖价未匹配|卖价未匹配|未配置计重|未命中价格|未命中规则|未命中.*快照/,
|
||||
plainReason: '当前运输方式暂无可用报价或规则未配置完整',
|
||||
tips: ['请更换运输方式,或联系客服确认该线路价格/规则是否已配置'],
|
||||
section: 'basic',
|
||||
suggestSupport: true,
|
||||
},
|
||||
{
|
||||
test: /销售产品不存在|产品代码|产品未启用|仓库不存在|仓库ID|货物属性|出口报关/,
|
||||
plainReason: '基础信息选择有误或选项已失效',
|
||||
tips: ['请检查运输方式、仓库、货物属性等基础信息是否选择正确'],
|
||||
section: 'basic',
|
||||
},
|
||||
{
|
||||
test: /客户单号已存在/,
|
||||
plainReason: '客户单号与已有订单重复',
|
||||
tips: ['请修改客户单号后重新提交'],
|
||||
section: 'basic',
|
||||
},
|
||||
{
|
||||
test: /客户单号不能为空|包裹数量/,
|
||||
plainReason: '基础必填信息不完整',
|
||||
tips: ['请完善基础信息中的必填项'],
|
||||
section: 'basic',
|
||||
},
|
||||
{
|
||||
test: /收件人信息不完整/,
|
||||
plainReason: '收件人必填信息不完整',
|
||||
tips: ['请补全收件人姓名、国家、地址1、邮编等必填项'],
|
||||
section: 'recipient',
|
||||
},
|
||||
{
|
||||
test: /发件人姓名|sender\s*name/i,
|
||||
plainReason: '尚未选择发件人地址',
|
||||
tips: ['请选择或维护发件地址'],
|
||||
section: 'sender',
|
||||
},
|
||||
{
|
||||
test: /申报明细|申报币种/,
|
||||
plainReason: '申报信息不完整',
|
||||
tips: ['请检查申报明细必填字段与申报币种是否完整'],
|
||||
section: 'declaration',
|
||||
},
|
||||
{
|
||||
test: /完善手机号和邮箱|联系方式/,
|
||||
plainReason: '账户联系方式未完善',
|
||||
tips: ['请先完善账户手机号与邮箱后再下单'],
|
||||
section: 'basic',
|
||||
},
|
||||
]
|
||||
|
||||
export function buildParcelCreateFailGuide(error: unknown): ParcelCreateFailGuide {
|
||||
const err = error as {
|
||||
message?: string
|
||||
draftSaved?: boolean
|
||||
orderId?: number
|
||||
serviceNo?: string
|
||||
raw?: any
|
||||
response?: { data?: any }
|
||||
}
|
||||
|
||||
const body = err?.raw ?? err?.response?.data
|
||||
const fromBody = body ? extractDraftFailFromBody(body) : null
|
||||
const rawMessage = String(err?.message || fromBody?.cleanMessage || error || '')
|
||||
const { cleanMessage, meta } = fromBody
|
||||
? { cleanMessage: fromBody.cleanMessage || parseOmsDraftMarker(rawMessage).cleanMessage, meta: fromBody.meta }
|
||||
: parseOmsDraftMarker(rawMessage)
|
||||
|
||||
const rawReason = cleanMessage || '下单未成功,请检查表单后重试'
|
||||
const hit = GUIDE_RULES.find((rule) => rule.test.test(rawReason))
|
||||
const draftSaved = Boolean(meta?.draftSaved || err?.draftSaved)
|
||||
const orderId = meta?.orderId ?? (Number(err?.orderId) > 0 ? Number(err.orderId) : undefined)
|
||||
const serviceNo = meta?.serviceNo || (err?.serviceNo ? String(err.serviceNo) : undefined)
|
||||
|
||||
return {
|
||||
rawReason,
|
||||
reason: hit?.plainReason || rawReason,
|
||||
tips: hit?.tips?.length ? hit.tips : ['请根据失败原因核对表单后重试;若仍失败请联系客服'],
|
||||
section: hit?.section ?? 'basic',
|
||||
draftSaved,
|
||||
orderId,
|
||||
serviceNo,
|
||||
suggestSupport: hit?.suggestSupport ?? !hit,
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅根据失败原因文案构建指引(用于我的订单草稿详情) */
|
||||
export function buildFailGuideFromReason(reason: string): ParcelCreateFailGuide {
|
||||
return buildParcelCreateFailGuide({ message: String(reason || '') })
|
||||
}
|
||||
@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
:visible="visible"
|
||||
:width="520"
|
||||
unmount-on-close
|
||||
:mask-closable="!loading"
|
||||
@cancel="close"
|
||||
@update:visible="onVisibleChange"
|
||||
>
|
||||
<template #title>
|
||||
<div class="drawer-title">
|
||||
<span>{{ t('channel.autoTrigger.ai.title') }}</span>
|
||||
<a class="config-link" href="javascript:;" @click="goConfig">
|
||||
{{ t('channel.autoTrigger.ai.configLink') }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="ai-assist-body">
|
||||
<p class="intro">{{ t('channel.autoTrigger.ai.intro') }}</p>
|
||||
|
||||
<div class="row-fields">
|
||||
<div class="field">
|
||||
<label class="label required">{{ t('channel.autoTrigger.ai.provider') }}</label>
|
||||
<a-select
|
||||
v-model="providerKey"
|
||||
:placeholder="t('channel.autoTrigger.ai.providerPlaceholder')"
|
||||
:loading="providersLoading"
|
||||
allow-search
|
||||
@change="onProviderChange"
|
||||
>
|
||||
<a-option
|
||||
v-for="p in enabledProviders"
|
||||
:key="p.providerKey"
|
||||
:value="p.providerKey"
|
||||
:label="p.displayName || p.providerKey"
|
||||
/>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label required">{{ t('channel.autoTrigger.ai.model') }}</label>
|
||||
<a-select
|
||||
v-model="modelId"
|
||||
:placeholder="t('channel.autoTrigger.ai.modelPlaceholder')"
|
||||
allow-create
|
||||
allow-search
|
||||
>
|
||||
<a-option v-for="m in modelOptions" :key="m.modelId" :value="m.modelId">
|
||||
{{ m.modelId }}{{ m.role ? `(${m.role})` : '' }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-row">
|
||||
<a-checkbox v-model="sendSkill">
|
||||
{{ t('channel.autoTrigger.ai.sendSkill') }}
|
||||
</a-checkbox>
|
||||
<span class="skill-hint">{{ t('channel.autoTrigger.ai.sendSkillHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label required">{{ t('channel.autoTrigger.ai.prompt') }}</label>
|
||||
<a-textarea
|
||||
v-model="prompt"
|
||||
:placeholder="t('channel.autoTrigger.ai.promptPlaceholder')"
|
||||
:auto-size="{ minRows: 4, maxRows: 8 }"
|
||||
:disabled="loading"
|
||||
@keydown.ctrl.enter.exact.prevent="generate"
|
||||
/>
|
||||
<div class="quick-prompts">
|
||||
<button
|
||||
v-for="(q, i) in FORMULA_AI_QUICK_PROMPTS"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="quick-chip"
|
||||
:disabled="loading"
|
||||
@click="prompt = q"
|
||||
>
|
||||
{{ shortQuick(q) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a-button type="primary" :loading="loading" :disabled="!canSend" @click="generate">
|
||||
{{ loading ? t('channel.autoTrigger.ai.generating') : t('channel.autoTrigger.ai.generate') }}
|
||||
</a-button>
|
||||
<a-button :disabled="loading" @click="clearResult">
|
||||
{{ t('channel.autoTrigger.ai.clear') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="error-box">{{ errorMsg }}</div>
|
||||
|
||||
<div v-if="parsed || rawReply" class="result-block">
|
||||
<div class="section-label">{{ t('channel.autoTrigger.ai.result') }}</div>
|
||||
<div v-if="parsed?.expression" class="formula-preview">
|
||||
<div class="meta-line" v-if="parsed.timingLabel || parsed.operationLabel">
|
||||
<a-tag v-if="parsed.timingLabel" size="small" color="arcoblue">
|
||||
{{ parsed.timingLabel }}
|
||||
</a-tag>
|
||||
<a-tag v-if="parsed.operationLabel" size="small" color="orangered">
|
||||
{{ parsed.operationLabel }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<pre class="formula-code">{{ parsed.expression }}</pre>
|
||||
<p v-if="parsed.explanation" class="explain">{{ parsed.explanation }}</p>
|
||||
<p v-if="parsed.sampleHint" class="sample">
|
||||
{{ t('channel.autoTrigger.ai.sampleHint') }}:{{ parsed.sampleHint }}
|
||||
</p>
|
||||
</div>
|
||||
<details v-if="rawReply" class="raw-details">
|
||||
<summary>{{ t('channel.autoTrigger.ai.rawReply') }}</summary>
|
||||
<pre class="raw-pre">{{ rawReply }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="footer-actions">
|
||||
<a-button @click="close">{{ t('common.cancel') }}</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="!parsed?.expression"
|
||||
@click="applyFormula"
|
||||
>
|
||||
{{ t('channel.autoTrigger.ai.fillFormula') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { chatXhsAi, listXhsAiProviders } from '@/api/xhs'
|
||||
import {
|
||||
providerModelTags,
|
||||
type AiProviderItem,
|
||||
} from '@/views/system/xhsAiAssistant/providerCatalog'
|
||||
import {
|
||||
FORMULA_AI_QUICK_PROMPTS,
|
||||
buildFormulaAiSystemPrompt,
|
||||
parseAiFormulaReply,
|
||||
type ParsedAiFormula,
|
||||
} from '../formulaAiSkill'
|
||||
|
||||
const STORAGE_KEY = 'oms.autoTrigger.formulaAi'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
/** 当前公式,生成时一并作为上下文 */
|
||||
currentFormula?: string
|
||||
timingLabel?: string
|
||||
operationLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [boolean]
|
||||
apply: [payload: {
|
||||
expression: string
|
||||
timingLabel?: string
|
||||
operationLabel?: string
|
||||
sampleHint?: string
|
||||
}]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const providersLoading = ref(false)
|
||||
const providers = ref<AiProviderItem[]>([])
|
||||
const providerKey = ref('')
|
||||
const modelId = ref('')
|
||||
const sendSkill = ref(true)
|
||||
const prompt = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const rawReply = ref('')
|
||||
const parsed = ref<ParsedAiFormula | null>(null)
|
||||
|
||||
const enabledProviders = computed(() =>
|
||||
providers.value.filter((p) => p.enabled && Boolean(p.apiKey)),
|
||||
)
|
||||
|
||||
const selectedProvider = computed(
|
||||
() => enabledProviders.value.find((p) => p.providerKey === providerKey.value) ?? null,
|
||||
)
|
||||
|
||||
const modelOptions = computed(() => {
|
||||
const p = selectedProvider.value
|
||||
if (!p) return []
|
||||
const tags = providerModelTags(p.providerType)
|
||||
const extra = String(p.apiSecret || '').trim()
|
||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
||||
return [...tags, { modelId: extra, role: '自定义' }]
|
||||
}
|
||||
return tags
|
||||
})
|
||||
|
||||
const canSend = computed(
|
||||
() =>
|
||||
Boolean(providerKey.value && modelId.value && prompt.value.trim()) && !loading.value,
|
||||
)
|
||||
|
||||
function shortQuick(q: string) {
|
||||
return q.length > 22 ? `${q.slice(0, 22)}…` : q
|
||||
}
|
||||
|
||||
function onVisibleChange(v: boolean) {
|
||||
emit('update:visible', v)
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function goConfig() {
|
||||
router.push({ path: '/system/xhsAiAssistant' })
|
||||
}
|
||||
|
||||
function loadPrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const data = JSON.parse(raw) as { providerKey?: string; modelId?: string; sendSkill?: boolean }
|
||||
if (data.providerKey) providerKey.value = data.providerKey
|
||||
if (data.modelId) modelId.value = data.modelId
|
||||
if (typeof data.sendSkill === 'boolean') sendSkill.value = data.sendSkill
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
providerKey: providerKey.value,
|
||||
modelId: modelId.value,
|
||||
sendSkill: sendSkill.value,
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
const opts = modelOptions.value
|
||||
if (!opts.some((m) => m.modelId === modelId.value)) {
|
||||
modelId.value = opts[0]?.modelId ?? ''
|
||||
}
|
||||
savePrefs()
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
providersLoading.value = true
|
||||
try {
|
||||
const items = await listXhsAiProviders()
|
||||
providers.value = (items ?? []) as AiProviderItem[]
|
||||
if (!providerKey.value) {
|
||||
const def =
|
||||
enabledProviders.value.find((p) => p.isDefault) ?? enabledProviders.value[0]
|
||||
if (def) providerKey.value = def.providerKey
|
||||
}
|
||||
if (providerKey.value && !enabledProviders.value.some((p) => p.providerKey === providerKey.value)) {
|
||||
providerKey.value = enabledProviders.value[0]?.providerKey ?? ''
|
||||
}
|
||||
onProviderChange()
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || t('channel.autoTrigger.ai.loadProvidersFail'))
|
||||
} finally {
|
||||
providersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearResult() {
|
||||
rawReply.value = ''
|
||||
parsed.value = null
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!canSend.value) {
|
||||
Message.warning(t('channel.autoTrigger.ai.needProviderModelPrompt'))
|
||||
return
|
||||
}
|
||||
savePrefs()
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
rawReply.value = ''
|
||||
parsed.value = null
|
||||
|
||||
const ctxParts: string[] = []
|
||||
if (props.timingLabel) ctxParts.push(`当前时机:${props.timingLabel}`)
|
||||
if (props.operationLabel) ctxParts.push(`当前操作:${props.operationLabel}`)
|
||||
if (props.currentFormula?.trim()) {
|
||||
ctxParts.push(`当前公式(可改写):\n${props.currentFormula.trim()}`)
|
||||
}
|
||||
|
||||
const userContent = [
|
||||
ctxParts.length ? `【上下文】\n${ctxParts.join('\n')}` : '',
|
||||
`【需求】\n${prompt.value.trim()}`,
|
||||
'请按 skill 规定的格式输出(时机/操作/公式/说明)。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
const messages: { role: string; content: string }[] = []
|
||||
if (sendSkill.value) {
|
||||
messages.push({ role: 'system', content: buildFormulaAiSystemPrompt() })
|
||||
} else {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content:
|
||||
'你是 OMS 自动触发公式助手。只输出中文公式表达式;字符串用 字段包含(\'…\'),数值用 字段() 比较。',
|
||||
})
|
||||
}
|
||||
messages.push({ role: 'user', content: userContent })
|
||||
|
||||
try {
|
||||
const res = await chatXhsAi({
|
||||
providerKey: providerKey.value,
|
||||
model: modelId.value,
|
||||
messages,
|
||||
scene: 'auto_trigger_formula',
|
||||
})
|
||||
const content = String(res?.content ?? '').trim()
|
||||
if (!content) {
|
||||
errorMsg.value = t('channel.autoTrigger.ai.emptyReply')
|
||||
return
|
||||
}
|
||||
rawReply.value = content
|
||||
const result = parseAiFormulaReply(content)
|
||||
parsed.value = result
|
||||
if (!result.expression) {
|
||||
errorMsg.value = t('channel.autoTrigger.ai.parseFail')
|
||||
} else {
|
||||
Message.success(t('channel.autoTrigger.ai.generateOk'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || t('channel.autoTrigger.ai.generateFail')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFormula() {
|
||||
if (!parsed.value?.expression) return
|
||||
emit('apply', {
|
||||
expression: parsed.value.expression,
|
||||
timingLabel: parsed.value.timingLabel,
|
||||
operationLabel: parsed.value.operationLabel,
|
||||
sampleHint: parsed.value.sampleHint,
|
||||
})
|
||||
Message.success(t('channel.autoTrigger.ai.filled'))
|
||||
close()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (v) => {
|
||||
if (!v) return
|
||||
loadPrefs()
|
||||
await loadProviders()
|
||||
},
|
||||
)
|
||||
|
||||
watch(sendSkill, savePrefs)
|
||||
watch(modelId, savePrefs)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.drawer-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding-right: 8px;
|
||||
|
||||
.config-link {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--app-primary-color, #2d8c8c);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-assist-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.row-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #4e5969;
|
||||
font-weight: 500;
|
||||
|
||||
&.required::before {
|
||||
content: '*';
|
||||
color: #f53f3f;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.skill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.skill-hint {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-prompts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.quick-chip {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
background: #f7f8fa;
|
||||
color: #4e5969;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--app-primary-color, #2d8c8c);
|
||||
color: var(--app-primary-color, #2d8c8c);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: #ffece8;
|
||||
color: #cb272d;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.result-block {
|
||||
border-top: 1px solid #e5e6eb;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4e5969;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.formula-preview {
|
||||
background: #f7f8fa;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.meta-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.formula-code {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.explain,
|
||||
.sample {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.raw-details {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.raw-pre {
|
||||
margin: 8px 0 0;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@ -1,22 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
FORMULA_MAX_LENGTH,
|
||||
FORMULA_OPERATORS,
|
||||
ORDER_TABLE_CATEGORIES,
|
||||
chipsByTable,
|
||||
} from '../formulaMeta'
|
||||
import FormulaAiAssistDrawer from './FormulaAiAssistDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
timingLabel?: string
|
||||
operationLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string]
|
||||
'ai-apply': [
|
||||
payload: {
|
||||
expression: string
|
||||
timingLabel?: string
|
||||
operationLabel?: string
|
||||
sampleHint?: string
|
||||
},
|
||||
]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const activeCategory = ref<string>('t_order')
|
||||
const aiVisible = ref(false)
|
||||
|
||||
const charCount = computed(() => props.modelValue.length)
|
||||
const activeChips = computed(() => chipsByTable(activeCategory.value))
|
||||
@ -26,6 +40,16 @@ function updateValue(next: string) {
|
||||
emit('update:modelValue', clipped)
|
||||
}
|
||||
|
||||
function onAiApply(payload: {
|
||||
expression: string
|
||||
timingLabel?: string
|
||||
operationLabel?: string
|
||||
sampleHint?: string
|
||||
}) {
|
||||
updateValue(payload.expression.slice(0, FORMULA_MAX_LENGTH))
|
||||
emit('ai-apply', payload)
|
||||
}
|
||||
|
||||
async function insertAtCursor(text: string) {
|
||||
const el = textareaRef.value
|
||||
if (!el) {
|
||||
@ -63,6 +87,13 @@ defineExpose({ insertAtCursor })
|
||||
|
||||
<template>
|
||||
<div class="formula-condition-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span class="toolbar-hint">{{ t('channel.autoTrigger.ai.toolbarHint') }}</span>
|
||||
<button type="button" class="ai-btn" @click="aiVisible = true">
|
||||
{{ t('channel.autoTrigger.ai.open') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="formula-text-wrap">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
@ -76,6 +107,14 @@ defineExpose({ insertAtCursor })
|
||||
<div class="char-count">{{ charCount }}/{{ FORMULA_MAX_LENGTH }}</div>
|
||||
</div>
|
||||
|
||||
<FormulaAiAssistDrawer
|
||||
v-model:visible="aiVisible"
|
||||
:current-formula="modelValue"
|
||||
:timing-label="timingLabel"
|
||||
:operation-label="operationLabel"
|
||||
@apply="onAiApply"
|
||||
/>
|
||||
|
||||
<div class="operator-section">
|
||||
<div class="section-label">运算符</div>
|
||||
<div class="operator-bar">
|
||||
@ -135,6 +174,37 @@ defineExpose({ insertAtCursor })
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-hint {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ai-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--app-primary-color, #2d8c8c);
|
||||
border-radius: 4px;
|
||||
background: rgba(45, 140, 140, 0.08);
|
||||
color: var(--app-primary-color, #2d8c8c);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--app-primary-color, #2d8c8c);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.formula-text-wrap {
|
||||
position: relative;
|
||||
|
||||
|
||||
144
src/views/channel/autoTrigger/formulaAiSkill.ts
Normal file
144
src/views/channel/autoTrigger/formulaAiSkill.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import {
|
||||
ORDER_TABLE_FIELDS,
|
||||
VIRTUAL_FORMULA_FIELDS,
|
||||
type OrderTableFieldDef,
|
||||
} from './formulaMeta'
|
||||
|
||||
/** 与 .cursor/skills/oms-auto-trigger-formula 对齐的运行时 skill(发给 AI 的 system) */
|
||||
|
||||
export type ParsedAiFormula = {
|
||||
expression: string
|
||||
timingLabel?: string
|
||||
operationLabel?: string
|
||||
explanation?: string
|
||||
sampleHint?: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
function fieldUsage(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 buildFieldCatalog(): string {
|
||||
const all = [...ORDER_TABLE_FIELDS, ...VIRTUAL_FORMULA_FIELDS]
|
||||
const byTable = new Map<string, OrderTableFieldDef[]>()
|
||||
for (const f of all) {
|
||||
const list = byTable.get(f.table) ?? []
|
||||
list.push(f)
|
||||
byTable.set(f.table, list)
|
||||
}
|
||||
const lines: string[] = []
|
||||
for (const [table, fields] of byTable) {
|
||||
lines.push(`### ${table}`)
|
||||
for (const f of fields) {
|
||||
const virt = f.virtual ? ' [聚合]' : ''
|
||||
lines.push(`- ${f.label} (${f.valueType}${virt}) → ${fieldUsage(f)}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const SKILL_CORE = `你是 OMS「自动触发」公式助手。根据用户中文描述,生成可粘贴的中文公式(ChineseFormulaEvaluator)。
|
||||
|
||||
## 产出格式(必须)
|
||||
|
||||
时机:下单 | 入库 | 出库
|
||||
操作:拦截 | 警告 | 问题件
|
||||
公式:单行可执行表达式
|
||||
说明:一句话
|
||||
验证样例:可选
|
||||
|
||||
## 语法硬规则
|
||||
|
||||
- 字段名必须是下方目录中的中文 label,禁止编造、禁止英文字段名
|
||||
- 字符串:字段包含('值');数值/聚合:字段() 再比较;布尔:字段()==1 / ==0
|
||||
- 字面量用单引号;逻辑用 && || !;比较用 == != > >= < <=
|
||||
- 禁止 and/or/not、中文「且」、双引号、字段() 用于字符串相等
|
||||
- 国家一般用二字码:国家包含('GB')
|
||||
- 公式单行、无注释夹杂、长度 ≤ 10000
|
||||
|
||||
## 示例
|
||||
|
||||
时机:下单
|
||||
操作:拦截
|
||||
公式:(单件最大长度()>60||单件最大宽度()>46)&&国家包含('GB')
|
||||
说明:英国且超尺寸时拦截
|
||||
验证样例:国家 GB,长 70,宽 40,高 40
|
||||
`
|
||||
|
||||
let cachedSystemPrompt: string | null = null
|
||||
|
||||
/** 完整 skill system prompt(含字段目录) */
|
||||
export function buildFormulaAiSystemPrompt(): string {
|
||||
if (cachedSystemPrompt) return cachedSystemPrompt
|
||||
cachedSystemPrompt = `${SKILL_CORE}\n\n## 可用字段目录(仅可使用下列中文名)\n\n${buildFieldCatalog()}`
|
||||
return cachedSystemPrompt
|
||||
}
|
||||
|
||||
/** 从 AI 回复中解析公式块 */
|
||||
export function parseAiFormulaReply(raw: string): ParsedAiFormula {
|
||||
const text = String(raw || '').trim()
|
||||
const timing =
|
||||
text.match(/时机[::]\s*(下单|入库|出库|预报)/)?.[1] ??
|
||||
undefined
|
||||
const operation =
|
||||
text.match(/操作[::]\s*(拦截|警告|问题件)/)?.[1] ??
|
||||
undefined
|
||||
const explanation =
|
||||
text.match(/说明[::]\s*(.+?)(?:\n|$)/)?.[1]?.trim() ??
|
||||
undefined
|
||||
const sampleHint =
|
||||
text.match(/验证样例[::]\s*(.+?)(?:\n|$)/)?.[1]?.trim() ??
|
||||
undefined
|
||||
|
||||
let expression =
|
||||
text.match(/公式[::]\s*(.+?)(?:\n|$)/)?.[1]?.trim() ??
|
||||
''
|
||||
|
||||
if (!expression) {
|
||||
// 尝试取 fenced code 或首行像公式的内容
|
||||
const fence = text.match(/```(?:\w*)?\s*\n?([\s\S]*?)```/)
|
||||
if (fence?.[1]) {
|
||||
const inner = fence[1].trim()
|
||||
expression =
|
||||
inner.match(/公式[::]\s*(.+?)(?:\n|$)/)?.[1]?.trim() ||
|
||||
inner.split('\n').find((l) => /包含\(|\(\)|&&|\|\|/.test(l))?.trim() ||
|
||||
''
|
||||
}
|
||||
}
|
||||
|
||||
if (!expression) {
|
||||
const candidate = text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => /包含\(|\(\)|&&|\|\|/.test(l) && !/^(时机|操作|说明|验证)/.test(l))
|
||||
expression = candidate || ''
|
||||
}
|
||||
|
||||
// 去掉可能的围栏残片
|
||||
expression = expression.replace(/^`+|`+$/g, '').trim()
|
||||
|
||||
const timingLabel = timing === '预报' ? '下单' : timing
|
||||
|
||||
return {
|
||||
expression,
|
||||
timingLabel,
|
||||
operationLabel: operation,
|
||||
explanation,
|
||||
sampleHint,
|
||||
raw: text,
|
||||
}
|
||||
}
|
||||
|
||||
export const FORMULA_AI_QUICK_PROMPTS = [
|
||||
'英国超尺寸拦截:单边超 60/46 或三边和超 150,国家 GB,排除产品 EX_ASP_HKDHL_YD',
|
||||
'偏远地区下单时警告',
|
||||
'申报金额 ≥ 2000 且国家 AU 时记问题件',
|
||||
'敏感货且收货渠道含 DHL 时拦截',
|
||||
]
|
||||
@ -286,7 +286,6 @@ export const OVERSIZE_EXAMPLE_FORMULA =
|
||||
export const FORMULA_MAX_LENGTH = 10000
|
||||
|
||||
export const TIMING_SAVE_LABEL: Record<string, string> = {
|
||||
forecast: '预报',
|
||||
order_create: '下单',
|
||||
inbound: '入库',
|
||||
outbound: '出库',
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
createAutoTrigger,
|
||||
deleteAutoTrigger,
|
||||
getAutoTriggerList,
|
||||
testAutoTriggerFormula,
|
||||
toggleAutoTriggerStatus,
|
||||
updateAutoTrigger,
|
||||
} from '@/api/channel/autoTrigger'
|
||||
@ -149,16 +150,23 @@ async function loadDictOptions() {
|
||||
}))
|
||||
.filter((o) => o.label && o.value)
|
||||
|
||||
timingOptions.value = mapDict(timingItems)
|
||||
if (!timingOptions.value.some((o) => o.value === 'forecast')) {
|
||||
timingOptions.value.unshift({ label: '预报', value: 'forecast' })
|
||||
timingOptions.value = mapDict(timingItems).filter(
|
||||
(o) => o.value !== 'forecast' && o.label !== '预报',
|
||||
)
|
||||
if (!timingOptions.value.some((o) => o.value === 'order_create')) {
|
||||
timingOptions.value.unshift({ label: '下单', value: 'order_create' })
|
||||
}
|
||||
if (!timingOptions.value.some((o) => o.value === 'inbound')) {
|
||||
timingOptions.value.push({ label: '入库', value: 'inbound' })
|
||||
}
|
||||
if (!timingOptions.value.some((o) => o.value === 'outbound')) {
|
||||
timingOptions.value.push({ label: '出库', value: 'outbound' })
|
||||
}
|
||||
|
||||
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' },
|
||||
@ -173,7 +181,8 @@ async function loadDictOptions() {
|
||||
|
||||
function resolveTimingCode(saved: string): string {
|
||||
const s = String(saved ?? '').trim()
|
||||
if (!s) return timingOptions.value[0]?.value ?? 'forecast'
|
||||
if (!s) return timingOptions.value[0]?.value ?? 'order_create'
|
||||
if (s === 'forecast' || s === '预报' || s === '下单' || s === 'order_create') return 'order_create'
|
||||
const byValue = timingOptions.value.find((o) => o.value === s)
|
||||
if (byValue) return byValue.value
|
||||
const byLabel = timingOptions.value.find((o) => o.label === s)
|
||||
@ -194,8 +203,9 @@ function resolveOperationCode(saved: string): string {
|
||||
}
|
||||
|
||||
function timingLabel(code: string): string {
|
||||
return timingOptions.value.find((o) => o.value === code)?.label
|
||||
|| TIMING_SAVE_LABEL[code]
|
||||
const normalized = resolveTimingCode(code)
|
||||
return timingOptions.value.find((o) => o.value === normalized)?.label
|
||||
|| TIMING_SAVE_LABEL[normalized]
|
||||
|| code
|
||||
}
|
||||
|
||||
@ -221,6 +231,23 @@ const formulaText = ref('')
|
||||
const formulaEditorRef = ref<InstanceType<typeof FormulaConditionEditor> | null>(null)
|
||||
const notifierUserRef = ref<InstanceType<typeof SelectPro> | null>(null)
|
||||
|
||||
const formulaEditorTimingLabel = computed(() => timingLabel(formModel.timingCode))
|
||||
const formulaEditorOperationLabel = computed(() => operationLabel(formModel.operationCode))
|
||||
|
||||
function onFormulaAiApply(payload: {
|
||||
expression: string
|
||||
timingLabel?: string
|
||||
operationLabel?: string
|
||||
sampleHint?: string
|
||||
}) {
|
||||
if (payload.timingLabel) {
|
||||
formModel.timingCode = resolveTimingCode(payload.timingLabel)
|
||||
}
|
||||
if (payload.operationLabel) {
|
||||
formModel.operationCode = resolveOperationCode(payload.operationLabel)
|
||||
}
|
||||
}
|
||||
|
||||
async function getRbacUserList(params: { pageIndex?: number; pageSize?: number; keyword?: string }) {
|
||||
const res = await UserService.getRbacUserGetList(
|
||||
params.keyword,
|
||||
@ -272,22 +299,118 @@ const formModel = reactive({
|
||||
status: true,
|
||||
ruleName: '',
|
||||
operationCode: 'intercept',
|
||||
timingCode: 'forecast',
|
||||
timingCode: 'order_create',
|
||||
promptMessage: '',
|
||||
notifierIds: [] as string[],
|
||||
})
|
||||
|
||||
/** 触发条件真伪验证:样例 / 真实订单 */
|
||||
const testMode = ref<'sample' | 'order'>('sample')
|
||||
const testLoading = ref(false)
|
||||
const testOrderId = ref<string>('')
|
||||
const testResult = ref<{
|
||||
matched?: boolean
|
||||
error?: string
|
||||
contextType?: string
|
||||
} | null>(null)
|
||||
const testSample = reactive({
|
||||
consigneeCountry: 'GB',
|
||||
consigneeCountryName: '英国',
|
||||
customerName: '测试客户',
|
||||
productCode: '',
|
||||
clientNo: 'TEST-001',
|
||||
length: 60 as number | undefined,
|
||||
width: 40 as number | undefined,
|
||||
height: 40 as number | undefined,
|
||||
actualWeight: 5 as number | undefined,
|
||||
})
|
||||
|
||||
function resetFormModel() {
|
||||
formulaText.value = ''
|
||||
legacyConditionText.value = ''
|
||||
testResult.value = null
|
||||
testMode.value = 'sample'
|
||||
testOrderId.value = ''
|
||||
Object.assign(formModel, {
|
||||
status: true,
|
||||
ruleName: '',
|
||||
operationCode: 'intercept',
|
||||
timingCode: 'forecast',
|
||||
timingCode: 'order_create',
|
||||
promptMessage: '',
|
||||
notifierIds: [],
|
||||
})
|
||||
Object.assign(testSample, {
|
||||
consigneeCountry: 'GB',
|
||||
consigneeCountryName: '英国',
|
||||
customerName: '测试客户',
|
||||
productCode: '',
|
||||
clientNo: 'TEST-001',
|
||||
length: 60,
|
||||
width: 40,
|
||||
height: 40,
|
||||
actualWeight: 5,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleTestFormula() {
|
||||
const validateErr = validateFormulaExpression(formulaText.value)
|
||||
if (validateErr) {
|
||||
$baseMessage(validateErr, 'warning')
|
||||
return
|
||||
}
|
||||
if (!String(formulaText.value || '').trim()) {
|
||||
$baseMessage(t('channel.autoTrigger.form.testNeedFormula'), 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
testLoading.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const body: Parameters<typeof testAutoTriggerFormula>[0] = {
|
||||
expression: String(formulaText.value).trim(),
|
||||
}
|
||||
if (testMode.value === 'order') {
|
||||
const oid = Number(String(testOrderId.value || '').trim())
|
||||
if (!Number.isFinite(oid) || oid <= 0) {
|
||||
$baseMessage(t('channel.autoTrigger.form.testNeedOrderId'), 'warning')
|
||||
return
|
||||
}
|
||||
body.orderId = oid
|
||||
} else {
|
||||
body.sample = {
|
||||
consigneeCountry: String(testSample.consigneeCountry || '').trim() || undefined,
|
||||
consigneeCountryName: String(testSample.consigneeCountryName || '').trim() || undefined,
|
||||
customerName: String(testSample.customerName || '').trim() || undefined,
|
||||
productCode: String(testSample.productCode || '').trim() || undefined,
|
||||
clientNo: String(testSample.clientNo || '').trim() || undefined,
|
||||
length: testSample.length,
|
||||
width: testSample.width,
|
||||
height: testSample.height,
|
||||
actualWeight: testSample.actualWeight,
|
||||
}
|
||||
}
|
||||
|
||||
const raw = await testAutoTriggerFormula(body)
|
||||
const matched = Boolean((raw as any)?.matched ?? (raw as any)?.Matched)
|
||||
const error = String((raw as any)?.error ?? (raw as any)?.Error ?? '').trim()
|
||||
const contextType = String((raw as any)?.contextType ?? (raw as any)?.ContextType ?? '')
|
||||
testResult.value = { matched, error: error || undefined, contextType }
|
||||
if (error) {
|
||||
$baseMessage(error || t('channel.autoTrigger.form.testFailed'), 'error')
|
||||
} else {
|
||||
$baseMessage(
|
||||
matched
|
||||
? t('channel.autoTrigger.form.testMatched')
|
||||
: t('channel.autoTrigger.form.testNotMatched'),
|
||||
matched ? 'success' : 'warning',
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
testResult.value = { matched: false, error: e?.message || t('channel.autoTrigger.form.testFailed') }
|
||||
$baseMessage(e?.message || t('channel.autoTrigger.form.testFailed'), 'error')
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openAddModal() {
|
||||
@ -304,6 +427,9 @@ async function openEditModal(row: AutoTriggerRow) {
|
||||
const parsed = parseStoredCondition(row.triggerCondition)
|
||||
formulaText.value = parsed.expression
|
||||
legacyConditionText.value = parsed.legacyText || ''
|
||||
testResult.value = null
|
||||
testMode.value = 'sample'
|
||||
testOrderId.value = ''
|
||||
Object.assign(formModel, {
|
||||
status: row.status,
|
||||
ruleName: parsed.ruleName || row.ruleName || '',
|
||||
@ -982,6 +1108,63 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid #e5e6eb;
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.form-label {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.test-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.test-sample-grid {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.test-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&.is-matched {
|
||||
color: #00b42a;
|
||||
}
|
||||
|
||||
&.is-miss {
|
||||
color: #ff7d00;
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
color: #f53f3f;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<template>
|
||||
@ -1116,7 +1299,91 @@ onMounted(() => {
|
||||
|
||||
<div class="condition-section">
|
||||
<label class="form-label required">{{ t('channel.autoTrigger.table.triggerCondition') }}</label>
|
||||
<FormulaConditionEditor ref="formulaEditorRef" v-model="formulaText" />
|
||||
<FormulaConditionEditor
|
||||
ref="formulaEditorRef"
|
||||
v-model="formulaText"
|
||||
:timing-label="formulaEditorTimingLabel"
|
||||
:operation-label="formulaEditorOperationLabel"
|
||||
@ai-apply="onFormulaAiApply"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<div class="section-head">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.testFormula') }}</label>
|
||||
<a-radio-group v-model="testMode" type="button" size="small">
|
||||
<a-radio value="sample">{{ t('channel.autoTrigger.form.testSample') }}</a-radio>
|
||||
<a-radio value="order">{{ t('channel.autoTrigger.form.testByOrder') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<p class="test-hint">{{ t('channel.autoTrigger.form.testHint') }}</p>
|
||||
|
||||
<div v-if="testMode === 'sample'" 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" placeholder="GB" allow-clear />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.sampleCountryName') }}</label>
|
||||
<a-input v-model="testSample.consigneeCountryName" placeholder="英国" 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" :precision="2" 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" :precision="2" 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" :precision="2" style="width: 100%" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.sampleWeight') }}</label>
|
||||
<a-input-number v-model="testSample.actualWeight" :min="0" :precision="3" style="width: 100%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-row">
|
||||
<label class="form-label">{{ t('channel.autoTrigger.form.testOrderId') }}</label>
|
||||
<a-input
|
||||
v-model="testOrderId"
|
||||
:placeholder="t('channel.autoTrigger.form.testOrderIdPlaceholder')"
|
||||
allow-clear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="test-actions">
|
||||
<a-button type="outline" :loading="testLoading" @click="handleTestFormula">
|
||||
{{ t('channel.autoTrigger.form.testRun') }}
|
||||
</a-button>
|
||||
<span
|
||||
v-if="testResult"
|
||||
class="test-result"
|
||||
:class="{
|
||||
'is-matched': testResult.matched && !testResult.error,
|
||||
'is-miss': testResult.matched === false && !testResult.error,
|
||||
'is-error': !!testResult.error,
|
||||
}"
|
||||
>
|
||||
<template v-if="testResult.error">{{ testResult.error }}</template>
|
||||
<template v-else-if="testResult.matched">
|
||||
{{ t('channel.autoTrigger.form.testMatched') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('channel.autoTrigger.form.testNotMatched') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
|
||||
@ -254,7 +254,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 客户面单:真实标签模板下拉
|
||||
// 系统面单:真实标签模板下拉
|
||||
const labelTemplateOptions = ref<{ label: string; value: number }[]>([]);
|
||||
|
||||
async function loadLabelTemplateOptions() {
|
||||
@ -269,10 +269,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
const weightFactorOptions: IdSelectOption[] = Array.from({ length: 20 }, (_, index) => {
|
||||
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '');
|
||||
return { label: v, value: v };
|
||||
});
|
||||
/** 重量因素值:0.5 步进至 30,其后固定 999 */
|
||||
const weightFactorOptions: IdSelectOption[] = [
|
||||
...Array.from({ length: 60 }, (_, index) => {
|
||||
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '');
|
||||
return { label: v, value: v };
|
||||
}),
|
||||
{ label: '999', value: '999' },
|
||||
];
|
||||
|
||||
function filterKpiAreasByType(items: any[], areaType: string) {
|
||||
const want = areaType.toLowerCase();
|
||||
@ -1864,6 +1868,8 @@
|
||||
billingFactorsData.value = getDefaultBillingFactorsData().map(item => ({ ...item }));
|
||||
// 重置价格表数据
|
||||
priceListData.value = [];
|
||||
priceListCustomerCodesSnapshot.value = [];
|
||||
priceListSelectedIds.value = [];
|
||||
priceDrawerActiveTab.value = 'basic';
|
||||
priceModalVisible.value = true;
|
||||
|
||||
@ -2034,6 +2040,48 @@
|
||||
|
||||
// 价格表数据
|
||||
const priceListData = ref<any[]>([]);
|
||||
/** 当前价格表行对应的客户组编码顺序(用于同步时按编码保留价格) */
|
||||
const priceListCustomerCodesSnapshot = ref<string[]>([]);
|
||||
const priceListSelectedIds = ref<number[]>([]);
|
||||
const batchFillModalVisible = ref(false);
|
||||
const batchFillValue = ref<number | undefined>(undefined);
|
||||
const batchFillOnlyEmpty = ref(false);
|
||||
|
||||
function buildPriceRowFactorKey(row: any, factorColKeys: string[]): string {
|
||||
return factorColKeys.map((k) => String(row?.[k] ?? '').trim()).join('\u0001');
|
||||
}
|
||||
|
||||
function capturePriceListCustomerCodesSnapshot() {
|
||||
priceListCustomerCodesSnapshot.value = getSelectedCustomerGroupCodesFromBilling();
|
||||
}
|
||||
|
||||
function buildEmptyCustomerPrices(codes: string[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
codes.forEach((_, i) => {
|
||||
out[`customer_${i}`] = 0;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractCustomerPricesByCode(row: any, codes: string[]): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
codes.forEach((code, i) => {
|
||||
const raw = row?.[`customer_${i}`];
|
||||
const n = Number(raw);
|
||||
map[code] = Number.isFinite(n) ? n : 0;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function applyCustomerPricesByCode(
|
||||
row: any,
|
||||
newCodes: string[],
|
||||
priceByCode: Record<string, number>
|
||||
) {
|
||||
newCodes.forEach((code, i) => {
|
||||
row[`customer_${i}`] = priceByCode[code] ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 价格表列顺序(表头):[ 非客户计费因素列… ] → [ 计算公式 ] → [ 客户组列… ]
|
||||
// 客户组列表头为 customer_group.dictName,且整段客户组列紧跟在「计算公式」列之后(右侧)
|
||||
@ -2098,49 +2146,63 @@
|
||||
return [...factorHeaders, formulaHeader, ...customerGroupHeaders];
|
||||
});
|
||||
|
||||
// 生成价格表数据(根据计费因素和因素值)
|
||||
const generatePriceListData = () => {
|
||||
const newData: any[] = [];
|
||||
|
||||
// 获取非客户因素
|
||||
// 按计费因素同步价格表:已有组合保留价格,仅补新增行
|
||||
const syncPriceListData = (opts?: { dropMissing?: boolean }) => {
|
||||
const dropMissing = opts?.dropMissing !== false;
|
||||
const nonCustomerFactors = billingFactorsData.value.filter(
|
||||
(item) => item.factor && !isCustomerFactor(item.factor) && item.factorValues.length > 0
|
||||
);
|
||||
|
||||
|
||||
if (nonCustomerFactors.length === 0) {
|
||||
Message.warning('请至少配置一个非客户因素');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 生成所有因素值的组合(笛卡尔积)
|
||||
|
||||
const generateCombinations = (arrays: string[][]): string[][] => {
|
||||
if (arrays.length === 0) return [[]];
|
||||
if (arrays.length === 1) return arrays[0].map((item) => [item]);
|
||||
|
||||
|
||||
const [first, ...rest] = arrays;
|
||||
const restCombinations = generateCombinations(rest);
|
||||
const result: string[][] = [];
|
||||
|
||||
|
||||
for (const firstItem of first) {
|
||||
for (const restCombination of restCombinations) {
|
||||
result.push([firstItem, ...restCombination]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
const factorValueArrays = nonCustomerFactors.map((factor) => factor.factorValues);
|
||||
const combinations = generateCombinations(factorValueArrays);
|
||||
|
||||
// 为每个组合创建一行数据
|
||||
const factorColKeys = nonCustomerFactors.map((factor) => {
|
||||
const colIdx = billingFactorsData.value.findIndex((item) => item.id === factor.id);
|
||||
return `factor_${colIdx}`;
|
||||
});
|
||||
|
||||
const oldCodes = priceListCustomerCodesSnapshot.value.length
|
||||
? [...priceListCustomerCodesSnapshot.value]
|
||||
: getSelectedCustomerGroupCodesFromBilling();
|
||||
const newCodes = getSelectedCustomerGroupCodesFromBilling();
|
||||
const oldByKey = new Map<string, any>();
|
||||
for (const row of priceListData.value) {
|
||||
const key = buildPriceRowFactorKey(row, factorColKeys);
|
||||
if (!key || key.split('\u0001').every((p) => !p)) continue;
|
||||
if (!oldByKey.has(key)) oldByKey.set(key, row);
|
||||
}
|
||||
|
||||
const newData: any[] = [];
|
||||
let kept = 0;
|
||||
let added = 0;
|
||||
combinations.forEach((combination, index) => {
|
||||
const row: any = {
|
||||
id: index + 1,
|
||||
calculationFormula: defaultCalculationFormulaCode(),
|
||||
...buildEmptyCustomerPrices(newCodes),
|
||||
};
|
||||
|
||||
// 设置因素值(重量转为左开右闭区间,如 (0-0.5])
|
||||
|
||||
nonCustomerFactors.forEach((factor, factorIndex) => {
|
||||
const colIdx = billingFactorsData.value.findIndex((item) => item.id === factor.id);
|
||||
const raw = combination[factorIndex];
|
||||
@ -2148,25 +2210,69 @@
|
||||
? weightValueToIntervalCell(raw, factor.factorValues)
|
||||
: raw;
|
||||
});
|
||||
|
||||
const selectedCg = getSelectedCustomerGroupCodesFromBilling();
|
||||
selectedCg.forEach((_, customerIndex) => {
|
||||
row[`customer_${customerIndex}`] = 0;
|
||||
});
|
||||
|
||||
const key = buildPriceRowFactorKey(row, factorColKeys);
|
||||
const oldRow = oldByKey.get(key);
|
||||
if (oldRow) {
|
||||
kept += 1;
|
||||
row.calculationFormula = oldRow.calculationFormula || row.calculationFormula;
|
||||
const priceByCode = extractCustomerPricesByCode(oldRow, oldCodes);
|
||||
applyCustomerPricesByCode(row, newCodes, priceByCode);
|
||||
oldByKey.delete(key);
|
||||
} else {
|
||||
added += 1;
|
||||
}
|
||||
|
||||
newData.push(row);
|
||||
});
|
||||
|
||||
priceListData.value = newData;
|
||||
|
||||
const removed = dropMissing ? oldByKey.size : 0;
|
||||
if (!dropMissing && oldByKey.size > 0) {
|
||||
// 保留未匹配旧行(极端兜底,默认不会走)
|
||||
let nextId = newData.length + 1;
|
||||
for (const oldRow of oldByKey.values()) {
|
||||
newData.push({ ...oldRow, id: nextId++ });
|
||||
}
|
||||
}
|
||||
|
||||
return { newData, kept, added, removed };
|
||||
};
|
||||
|
||||
// 生成价格表
|
||||
const applySyncedPriceList = (result: {
|
||||
newData: any[];
|
||||
kept: number;
|
||||
added: number;
|
||||
removed: number;
|
||||
}) => {
|
||||
priceListData.value = result.newData;
|
||||
capturePriceListCustomerCodesSnapshot();
|
||||
priceListSelectedIds.value = [];
|
||||
priceDrawerActiveTab.value = 'priceList';
|
||||
const parts = [`保留 ${result.kept} 行`, `新增 ${result.added} 行`];
|
||||
if (result.removed > 0) parts.push(`移除 ${result.removed} 行`);
|
||||
Message.success(`价格表已同步(${parts.join(',')})`);
|
||||
};
|
||||
|
||||
// 同步价格表结构(保留已有价格)
|
||||
const handleGeneratePriceList = async () => {
|
||||
await ensureSalesPriceDictLoaded();
|
||||
generatePriceListData();
|
||||
// 切换到价格表tab
|
||||
priceDrawerActiveTab.value = 'priceList';
|
||||
Message.success('价格表生成成功');
|
||||
const preview = syncPriceListData({ dropMissing: true });
|
||||
if (!preview) return;
|
||||
|
||||
if (preview.removed > 0 && priceListData.value.length > 0) {
|
||||
Modal.confirm({
|
||||
title: '同步价格表',
|
||||
content: `将新增 ${preview.added} 行、移除 ${preview.removed} 行。被移除行的已填价格会丢失,是否继续?`,
|
||||
okText: '继续同步',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
applySyncedPriceList(preview);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
applySyncedPriceList(preview);
|
||||
};
|
||||
|
||||
/** 与文档 /channel/sales-product/price/create 的 factors 一致:serialNumber、factor、factorValues */
|
||||
@ -2448,6 +2554,8 @@
|
||||
|
||||
priceListData.value = importedData;
|
||||
coercePriceListRowsFactorCells();
|
||||
capturePriceListCustomerCodesSnapshot();
|
||||
priceListSelectedIds.value = [];
|
||||
Message.success('导入成功');
|
||||
} catch (error) {
|
||||
Message.error('导入失败,请检查文件格式');
|
||||
@ -2458,12 +2566,89 @@
|
||||
input.click();
|
||||
};
|
||||
|
||||
const priceListAllSelected = computed(() => {
|
||||
const ids = priceListData.value.map((r) => r.id);
|
||||
return ids.length > 0 && ids.every((id) => priceListSelectedIds.value.includes(id));
|
||||
});
|
||||
|
||||
const toggleSelectAllPriceRows = (checked: boolean | Event) => {
|
||||
const on = typeof checked === 'boolean' ? checked : !!(checked as any)?.target?.checked;
|
||||
priceListSelectedIds.value = on ? priceListData.value.map((r) => r.id) : [];
|
||||
};
|
||||
|
||||
const toggleSelectPriceRow = (id: number, checked: boolean | Event) => {
|
||||
const on = typeof checked === 'boolean' ? checked : !!(checked as any)?.target?.checked;
|
||||
const set = new Set(priceListSelectedIds.value);
|
||||
if (on) set.add(id);
|
||||
else set.delete(id);
|
||||
priceListSelectedIds.value = [...set];
|
||||
};
|
||||
|
||||
const openBatchFillModal = (onlyEmpty = false) => {
|
||||
if (!priceListSelectedIds.value.length) {
|
||||
Message.warning('请先勾选要填价的行');
|
||||
return;
|
||||
}
|
||||
batchFillOnlyEmpty.value = onlyEmpty;
|
||||
batchFillValue.value = undefined;
|
||||
batchFillModalVisible.value = true;
|
||||
};
|
||||
|
||||
const handleConfirmBatchFill = () => {
|
||||
const val = Number(batchFillValue.value);
|
||||
if (!Number.isFinite(val) || val < 0) {
|
||||
Message.warning('请输入有效价格(≥0)');
|
||||
return;
|
||||
}
|
||||
const selected = new Set(priceListSelectedIds.value);
|
||||
const customerKeys = priceTableHeaders.value.filter((h) => h.isCustomer).map((h) => h.key);
|
||||
if (!customerKeys.length) {
|
||||
Message.warning('当前没有客户组价格列');
|
||||
return;
|
||||
}
|
||||
let touched = 0;
|
||||
for (const row of priceListData.value) {
|
||||
if (!selected.has(row.id)) continue;
|
||||
for (const key of customerKeys) {
|
||||
const cur = Number((row as any)[key]);
|
||||
const isEmpty = !Number.isFinite(cur) || cur === 0;
|
||||
if (batchFillOnlyEmpty.value && !isEmpty) continue;
|
||||
(row as any)[key] = val;
|
||||
touched += 1;
|
||||
}
|
||||
}
|
||||
batchFillModalVisible.value = false;
|
||||
Message.success(touched ? `已填充 ${touched} 个价格格` : '没有需要填充的格子');
|
||||
};
|
||||
|
||||
/** 将上一行客户组价格复制到勾选行(或下一空行) */
|
||||
const handleCopyPrevRowPrices = () => {
|
||||
if (!priceListSelectedIds.value.length) {
|
||||
Message.warning('请先勾选目标行');
|
||||
return;
|
||||
}
|
||||
const selected = new Set(priceListSelectedIds.value);
|
||||
const customerKeys = priceTableHeaders.value.filter((h) => h.isCustomer).map((h) => h.key);
|
||||
let copied = 0;
|
||||
for (let i = 0; i < priceListData.value.length; i++) {
|
||||
const row = priceListData.value[i];
|
||||
if (!selected.has(row.id) || i === 0) continue;
|
||||
const prev = priceListData.value[i - 1];
|
||||
for (const key of customerKeys) {
|
||||
(row as any)[key] = Number((prev as any)[key]) || 0;
|
||||
}
|
||||
copied += 1;
|
||||
}
|
||||
Message.success(copied ? `已将上一行价格复制到 ${copied} 行` : '没有可复制的行');
|
||||
};
|
||||
|
||||
// 取消
|
||||
const handleCancelPriceDrawer = () => {
|
||||
priceModalVisible.value = false;
|
||||
// 重置编辑模式
|
||||
isEditMode.value = false;
|
||||
editingPriceRecord.value = null;
|
||||
priceListSelectedIds.value = [];
|
||||
// 清理拖拽实例
|
||||
if (sortableInstance) {
|
||||
sortableInstance.destroy();
|
||||
@ -2482,7 +2667,7 @@
|
||||
// 处理tab切换
|
||||
const handlePriceDrawerTabSwitch = async (tabKey: string) => {
|
||||
if (tabKey === 'priceList' && !canSwitchToPriceList.value) {
|
||||
Message.warning('请配置计费因素和映射值,点击生成价格表');
|
||||
Message.warning('请配置计费因素和映射值,点击「同步价格表」');
|
||||
return;
|
||||
}
|
||||
if (tabKey === 'priceList') {
|
||||
@ -2653,9 +2838,11 @@
|
||||
}
|
||||
priceListData.value = mapSalesProductDetailsToPriceListRows(sourceRecord?.details || []);
|
||||
coercePriceListRowsFactorCells();
|
||||
capturePriceListCustomerCodesSnapshot();
|
||||
priceListSelectedIds.value = [];
|
||||
|
||||
// 重置到基础信息tab
|
||||
priceDrawerActiveTab.value = 'basic';
|
||||
// 编辑有明细时直接进入价格表,避免误点「生成」清价
|
||||
priceDrawerActiveTab.value = priceListData.value.length > 0 ? 'priceList' : 'basic';
|
||||
priceModalVisible.value = true;
|
||||
};
|
||||
|
||||
@ -3585,7 +3772,7 @@
|
||||
@click="handleGeneratePriceList"
|
||||
>
|
||||
<icon-plus />
|
||||
{{ t('channel.salesProducts.drawer.footer.generatePriceList') }}
|
||||
{{ t('channel.salesProducts.drawer.footer.syncPriceList') }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
@ -3677,8 +3864,33 @@
|
||||
dictLabelByType('fee_item', basicInfoForm.costItem) ||
|
||||
t('channel.salesProducts.drawer.section.priceListTitle')
|
||||
}}
|
||||
<span class="price-list-hint">{{ t('channel.salesProducts.drawer.priceList.hint') }}</span>
|
||||
</div>
|
||||
<div class="price-list-actions">
|
||||
<a-button
|
||||
type="secondary"
|
||||
size="small"
|
||||
:disabled="!priceListSelectedIds.length"
|
||||
@click="openBatchFillModal(false)"
|
||||
>
|
||||
{{ t('channel.salesProducts.drawer.priceList.batchFill') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="secondary"
|
||||
size="small"
|
||||
:disabled="!priceListSelectedIds.length"
|
||||
@click="openBatchFillModal(true)"
|
||||
>
|
||||
{{ t('channel.salesProducts.drawer.priceList.batchFillEmpty') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="secondary"
|
||||
size="small"
|
||||
:disabled="!priceListSelectedIds.length"
|
||||
@click="handleCopyPrevRowPrices"
|
||||
>
|
||||
{{ t('channel.salesProducts.drawer.priceList.copyPrev') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="secondary"
|
||||
size="small"
|
||||
@ -3706,6 +3918,12 @@
|
||||
<table class="price-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 44px">
|
||||
<a-checkbox
|
||||
:model-value="priceListAllSelected"
|
||||
@change="toggleSelectAllPriceRows"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
v-for="header in priceTableHeaders"
|
||||
:key="header.key"
|
||||
@ -3720,6 +3938,12 @@
|
||||
v-for="(row, index) in priceListData"
|
||||
:key="row.id"
|
||||
>
|
||||
<td class="price-row-check">
|
||||
<a-checkbox
|
||||
:model-value="priceListSelectedIds.includes(row.id)"
|
||||
@change="(v: boolean | Event) => toggleSelectPriceRow(row.id, v)"
|
||||
/>
|
||||
</td>
|
||||
<template v-for="header in priceTableHeaders" :key="header.key">
|
||||
<td
|
||||
v-if="header.key.startsWith('factor_')"
|
||||
@ -3785,7 +4009,7 @@
|
||||
@click="handleGeneratePriceList"
|
||||
>
|
||||
<icon-plus />
|
||||
{{ t('channel.salesProducts.drawer.footer.generatePriceList') }}
|
||||
{{ t('channel.salesProducts.drawer.footer.syncPriceList') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="priceDrawerActiveTab === 'priceList'"
|
||||
@ -3799,6 +4023,32 @@
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 批量填价 -->
|
||||
<a-modal
|
||||
v-model:visible="batchFillModalVisible"
|
||||
:title="batchFillOnlyEmpty
|
||||
? t('channel.salesProducts.drawer.priceList.batchFillEmpty')
|
||||
: t('channel.salesProducts.drawer.priceList.batchFill')"
|
||||
width="420px"
|
||||
@ok="handleConfirmBatchFill"
|
||||
@cancel="batchFillModalVisible = false"
|
||||
>
|
||||
<div class="batch-fill-form">
|
||||
<div class="batch-fill-meta">
|
||||
{{ t('channel.salesProducts.drawer.priceList.batchFillSelected', { count: priceListSelectedIds.length }) }}
|
||||
</div>
|
||||
<a-form-item :label="t('channel.salesProducts.drawer.priceList.batchFillValue')" required>
|
||||
<a-input-number
|
||||
v-model="batchFillValue"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
:placeholder="t('channel.salesProducts.form.placeholder.input')"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 新增产品配置弹窗 -->
|
||||
<a-modal
|
||||
v-model:visible="addProductModalVisible"
|
||||
@ -4730,7 +4980,10 @@
|
||||
margin-bottom: 16px;
|
||||
|
||||
.price-list-title {
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
@ -4739,10 +4992,19 @@
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.price-list-hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.price-list-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
max-width: 72%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4758,6 +5020,11 @@
|
||||
width: 100%;
|
||||
min-width: 1000px;
|
||||
border-collapse: collapse;
|
||||
|
||||
.price-row-check {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: #fafafa;
|
||||
@ -5491,6 +5758,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.batch-fill-form {
|
||||
.batch-fill-meta {
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.sales-product-layout {
|
||||
|
||||
@ -549,7 +549,7 @@
|
||||
@click="handleGeneratePriceList"
|
||||
>
|
||||
<icon-plus />
|
||||
{{ t('channel.serviceChannels.drawer.footer.generatePriceList') }}
|
||||
{{ t('channel.serviceChannels.drawer.footer.syncPriceList') }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
@ -641,8 +641,33 @@
|
||||
dictLabelByType('fee_item', basicInfoForm.costItem) ||
|
||||
t('channel.serviceChannels.drawer.section.priceListTitle')
|
||||
}}
|
||||
<span class="price-list-hint">{{ t('channel.serviceChannels.drawer.priceList.hint') }}</span>
|
||||
</div>
|
||||
<div class="price-list-actions">
|
||||
<a-button
|
||||
type="secondary"
|
||||
size="small"
|
||||
:disabled="!priceListSelectedIds.length"
|
||||
@click="openBatchFillModal(false)"
|
||||
>
|
||||
{{ t('channel.serviceChannels.drawer.priceList.batchFill') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="secondary"
|
||||
size="small"
|
||||
:disabled="!priceListSelectedIds.length"
|
||||
@click="openBatchFillModal(true)"
|
||||
>
|
||||
{{ t('channel.serviceChannels.drawer.priceList.batchFillEmpty') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="secondary"
|
||||
size="small"
|
||||
:disabled="!priceListSelectedIds.length"
|
||||
@click="handleCopyPrevRowPrices"
|
||||
>
|
||||
{{ t('channel.serviceChannels.drawer.priceList.copyPrev') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="secondary"
|
||||
size="small"
|
||||
@ -671,6 +696,12 @@
|
||||
<table class="price-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 44px">
|
||||
<a-checkbox
|
||||
:model-value="priceListAllSelected"
|
||||
@change="toggleSelectAllPriceRows"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
v-for="header in priceTableHeaders"
|
||||
:key="header.key"
|
||||
@ -685,6 +716,12 @@
|
||||
v-for="(row, index) in priceListData"
|
||||
:key="row.id"
|
||||
>
|
||||
<td class="price-row-check">
|
||||
<a-checkbox
|
||||
:model-value="priceListSelectedIds.includes(row.id)"
|
||||
@change="(v: boolean | Event) => toggleSelectPriceRow(row.id, v)"
|
||||
/>
|
||||
</td>
|
||||
<template v-for="header in priceTableHeaders" :key="header.key">
|
||||
<td
|
||||
v-if="header.key.startsWith('factor_')"
|
||||
@ -749,7 +786,7 @@
|
||||
@click="handleGeneratePriceList"
|
||||
>
|
||||
<icon-plus />
|
||||
{{ t('channel.serviceChannels.drawer.footer.generatePriceList') }}
|
||||
{{ t('channel.serviceChannels.drawer.footer.syncPriceList') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="priceDrawerActiveTab === 'priceList'"
|
||||
@ -764,6 +801,32 @@
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 批量填价 -->
|
||||
<a-modal
|
||||
v-model:visible="batchFillModalVisible"
|
||||
:title="batchFillOnlyEmpty
|
||||
? t('channel.serviceChannels.drawer.priceList.batchFillEmpty')
|
||||
: t('channel.serviceChannels.drawer.priceList.batchFill')"
|
||||
width="420px"
|
||||
@ok="handleConfirmBatchFill"
|
||||
@cancel="batchFillModalVisible = false"
|
||||
>
|
||||
<div class="batch-fill-form">
|
||||
<div class="batch-fill-meta">
|
||||
{{ t('channel.serviceChannels.drawer.priceList.batchFillSelected', { count: priceListSelectedIds.length }) }}
|
||||
</div>
|
||||
<a-form-item :label="t('channel.serviceChannels.drawer.priceList.batchFillValue')" required>
|
||||
<a-input-number
|
||||
v-model="batchFillValue"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
style="width: 100%"
|
||||
:placeholder="t('channel.serviceChannels.form.placeholder.input')"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 发布弹窗 -->
|
||||
<ModalPro
|
||||
:title="t('channel.serviceChannels.publish.title')"
|
||||
@ -1090,7 +1153,7 @@
|
||||
v-model:visible="addFactorValueModalVisible"
|
||||
title="添加因素值"
|
||||
width="400px"
|
||||
@cancel="handleCancelAddFactorValue"
|
||||
@cancel="handleAddFactorValueModalCancel"
|
||||
@ok="handleConfirmAddFactorValue"
|
||||
>
|
||||
<div class="add-factor-value-modal">
|
||||
@ -2206,6 +2269,10 @@ function buildServiceChannelDetailsPayload(): any[] {
|
||||
|
||||
// 价格表数据(详情弹窗内)
|
||||
const priceListData = ref<any[]>([])
|
||||
const priceListSelectedIds = ref<number[]>([])
|
||||
const batchFillModalVisible = ref(false)
|
||||
const batchFillValue = ref<number | undefined>(undefined)
|
||||
const batchFillOnlyEmpty = ref(false)
|
||||
|
||||
// 价格表数据(用于列表展示)
|
||||
const priceTableData = ref<any[]>([])
|
||||
@ -2459,17 +2526,20 @@ const priceTableHeaders = computed(() => {
|
||||
return [...factorHeaders, formulaHeader, unitPriceHeader]
|
||||
})
|
||||
|
||||
// 生成价格表数据(根据计费因素和因素值,与销售产品卖价一致)
|
||||
const generatePriceListData = () => {
|
||||
const newData: any[] = []
|
||||
function buildPriceRowFactorKey(row: any, factorColKeys: string[]): string {
|
||||
return factorColKeys.map((k) => String(row?.[k] ?? '').trim()).join('\u0001')
|
||||
}
|
||||
|
||||
// 按计费因素同步价格表:已有组合保留单价,仅补新增行(对齐产品卖价)
|
||||
const syncPriceListData = (opts?: { dropMissing?: boolean }) => {
|
||||
const dropMissing = opts?.dropMissing !== false
|
||||
const configuredFactors = billingFactorsData.value.filter(
|
||||
(item: BillingFactor) => item.factor && item.factorValues.length > 0
|
||||
(item: BillingFactor) => item.factor && !isCustomerFactor(item.factor) && item.factorValues.length > 0
|
||||
)
|
||||
|
||||
if (configuredFactors.length === 0) {
|
||||
Message.warning('请至少配置一个计费因素及因素值')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
const generateCombinations = (arrays: string[][]): string[][] => {
|
||||
@ -2491,7 +2561,21 @@ const generatePriceListData = () => {
|
||||
|
||||
const factorValueArrays = configuredFactors.map((factor) => factor.factorValues)
|
||||
const combinations = generateCombinations(factorValueArrays)
|
||||
const factorColKeys = configuredFactors.map((factor) => {
|
||||
const colIdx = billingFactorsData.value.findIndex((item: BillingFactor) => item.id === factor.id)
|
||||
return `factor_${colIdx}`
|
||||
})
|
||||
|
||||
const oldByKey = new Map<string, any>()
|
||||
for (const row of priceListData.value) {
|
||||
const key = buildPriceRowFactorKey(row, factorColKeys)
|
||||
if (!key || key.split('\u0001').every((p) => !p)) continue
|
||||
if (!oldByKey.has(key)) oldByKey.set(key, row)
|
||||
}
|
||||
|
||||
const newData: any[] = []
|
||||
let kept = 0
|
||||
let added = 0
|
||||
combinations.forEach((combination, index) => {
|
||||
const row: any = {
|
||||
id: index + 1,
|
||||
@ -2507,19 +2591,133 @@ const generatePriceListData = () => {
|
||||
: raw
|
||||
})
|
||||
|
||||
const key = buildPriceRowFactorKey(row, factorColKeys)
|
||||
const oldRow = oldByKey.get(key)
|
||||
if (oldRow) {
|
||||
kept += 1
|
||||
row.calculationFormula = oldRow.calculationFormula || row.calculationFormula
|
||||
const n = Number(oldRow.calculatedValue)
|
||||
row.calculatedValue = Number.isFinite(n) ? n : 0
|
||||
oldByKey.delete(key)
|
||||
} else {
|
||||
added += 1
|
||||
}
|
||||
|
||||
newData.push(row)
|
||||
})
|
||||
|
||||
priceListData.value = newData
|
||||
const removed = dropMissing ? oldByKey.size : 0
|
||||
if (!dropMissing && oldByKey.size > 0) {
|
||||
let nextId = newData.length + 1
|
||||
for (const oldRow of oldByKey.values()) {
|
||||
newData.push({ ...oldRow, id: nextId++ })
|
||||
}
|
||||
}
|
||||
|
||||
return { newData, kept, added, removed }
|
||||
}
|
||||
|
||||
// 生成价格表
|
||||
const applySyncedPriceList = (result: {
|
||||
newData: any[]
|
||||
kept: number
|
||||
added: number
|
||||
removed: number
|
||||
}) => {
|
||||
priceListData.value = result.newData
|
||||
priceListSelectedIds.value = []
|
||||
priceDrawerActiveTab.value = 'priceList'
|
||||
const parts = [`保留 ${result.kept} 行`, `新增 ${result.added} 行`]
|
||||
if (result.removed > 0) parts.push(`移除 ${result.removed} 行`)
|
||||
Message.success(`价格表已同步(${parts.join(',')})`)
|
||||
}
|
||||
|
||||
// 同步价格表结构(保留已有单价)
|
||||
const handleGeneratePriceList = async () => {
|
||||
await ensureChannelPriceDictLoaded()
|
||||
await loadBillingFactorSelectOptions()
|
||||
generatePriceListData()
|
||||
priceDrawerActiveTab.value = 'priceList'
|
||||
Message.success('价格表生成成功')
|
||||
const preview = syncPriceListData({ dropMissing: true })
|
||||
if (!preview) return
|
||||
|
||||
if (preview.removed > 0 && priceListData.value.length > 0) {
|
||||
Modal.confirm({
|
||||
title: '同步价格表',
|
||||
content: `将新增 ${preview.added} 行、移除 ${preview.removed} 行。被移除行的已填价格会丢失,是否继续?`,
|
||||
okText: '继续同步',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
applySyncedPriceList(preview)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
applySyncedPriceList(preview)
|
||||
}
|
||||
|
||||
const priceListAllSelected = computed(() => {
|
||||
const ids = priceListData.value.map((r) => r.id)
|
||||
return ids.length > 0 && ids.every((id) => priceListSelectedIds.value.includes(id))
|
||||
})
|
||||
|
||||
const toggleSelectAllPriceRows = (checked: boolean | Event) => {
|
||||
const on = typeof checked === 'boolean' ? checked : !!(checked as any)?.target?.checked
|
||||
priceListSelectedIds.value = on ? priceListData.value.map((r) => r.id) : []
|
||||
}
|
||||
|
||||
const toggleSelectPriceRow = (id: number, checked: boolean | Event) => {
|
||||
const on = typeof checked === 'boolean' ? checked : !!(checked as any)?.target?.checked
|
||||
const set = new Set(priceListSelectedIds.value)
|
||||
if (on) set.add(id)
|
||||
else set.delete(id)
|
||||
priceListSelectedIds.value = [...set]
|
||||
}
|
||||
|
||||
const openBatchFillModal = (onlyEmpty = false) => {
|
||||
if (!priceListSelectedIds.value.length) {
|
||||
Message.warning('请先勾选要填价的行')
|
||||
return
|
||||
}
|
||||
batchFillOnlyEmpty.value = onlyEmpty
|
||||
batchFillValue.value = undefined
|
||||
batchFillModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleConfirmBatchFill = () => {
|
||||
const val = Number(batchFillValue.value)
|
||||
if (!Number.isFinite(val) || val < 0) {
|
||||
Message.warning('请输入有效价格(≥0)')
|
||||
return
|
||||
}
|
||||
const selected = new Set(priceListSelectedIds.value)
|
||||
let touched = 0
|
||||
for (const row of priceListData.value) {
|
||||
if (!selected.has(row.id)) continue
|
||||
const cur = Number(row.calculatedValue)
|
||||
const isEmpty = !Number.isFinite(cur) || cur === 0
|
||||
if (batchFillOnlyEmpty.value && !isEmpty) continue
|
||||
row.calculatedValue = val
|
||||
touched += 1
|
||||
}
|
||||
batchFillModalVisible.value = false
|
||||
Message.success(touched ? `已填充 ${touched} 行单价` : '没有需要填充的行')
|
||||
}
|
||||
|
||||
/** 将上一行单价复制到勾选行 */
|
||||
const handleCopyPrevRowPrices = () => {
|
||||
if (!priceListSelectedIds.value.length) {
|
||||
Message.warning('请先勾选目标行')
|
||||
return
|
||||
}
|
||||
const selected = new Set(priceListSelectedIds.value)
|
||||
let copied = 0
|
||||
for (let i = 0; i < priceListData.value.length; i++) {
|
||||
const row = priceListData.value[i]
|
||||
if (!selected.has(row.id) || i === 0) continue
|
||||
const prev = priceListData.value[i - 1]
|
||||
row.calculatedValue = Number(prev.calculatedValue) || 0
|
||||
copied += 1
|
||||
}
|
||||
Message.success(copied ? `已将上一行单价复制到 ${copied} 行` : '没有可复制的行')
|
||||
}
|
||||
|
||||
const savingPriceDrawer = ref(false)
|
||||
@ -2593,7 +2791,7 @@ const handleSavePriceList = async () => {
|
||||
return
|
||||
}
|
||||
if (!priceListData.value.length) {
|
||||
Message.warning('请先在「基础信息」中生成价格表后再保存')
|
||||
Message.warning('请先在「基础信息」中同步价格表后再保存')
|
||||
return
|
||||
}
|
||||
savingPriceDrawer.value = true
|
||||
@ -2819,7 +3017,7 @@ const canSwitchToPriceList = computed(() => {
|
||||
// 处理tab切换
|
||||
const handlePriceDrawerTabSwitch = async (tabKey: string) => {
|
||||
if (tabKey === 'priceList' && !canSwitchToPriceList.value) {
|
||||
Message.warning('请配置计费因素和映射值,点击生成价格表')
|
||||
Message.warning('请配置计费因素和映射值,点击「同步价格表」')
|
||||
return
|
||||
}
|
||||
if (tabKey === 'priceList') {
|
||||
@ -2827,6 +3025,11 @@ const handlePriceDrawerTabSwitch = async (tabKey: string) => {
|
||||
await loadBillingFactorSelectOptions()
|
||||
}
|
||||
priceDrawerActiveTab.value = tabKey
|
||||
if (tabKey === 'basic' && priceModalVisible.value) {
|
||||
nextTick(() => {
|
||||
initSortable()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3254,11 +3457,15 @@ const handleEditPrice = async (record: any) => {
|
||||
}
|
||||
priceListData.value = mapDetailsToPriceListRows(sourceRecord?.details || [])
|
||||
coercePriceListRowsFactorCells()
|
||||
priceDrawerActiveTab.value = 'basic'
|
||||
priceListSelectedIds.value = []
|
||||
// 编辑有明细时直接进入价格表,避免误点「同步」清价
|
||||
priceDrawerActiveTab.value = priceListData.value.length > 0 ? 'priceList' : 'basic'
|
||||
priceModalVisible.value = true
|
||||
nextTick(() => {
|
||||
initSortable()
|
||||
})
|
||||
if (priceDrawerActiveTab.value === 'basic') {
|
||||
nextTick(() => {
|
||||
initSortable()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除价格 - 使用弹窗确认
|
||||
@ -3378,6 +3585,7 @@ const handleCancelPriceDrawer = () => {
|
||||
// 重置编辑模式
|
||||
isEditMode.value = false
|
||||
editingPriceRecord.value = null
|
||||
priceListSelectedIds.value = []
|
||||
// 清理拖拽实例
|
||||
if (sortableInstance) {
|
||||
sortableInstance.destroy()
|
||||
@ -3457,34 +3665,60 @@ const handleAddFactorValue = async (row: any) => {
|
||||
await ensureChannelPriceDictLoaded()
|
||||
await loadBillingFactorSelectOptions()
|
||||
currentFactorRow.value = row
|
||||
addFactorValueSelected.value = []
|
||||
const factor = row.factor as string
|
||||
const opts = factorValuesOptionsByFactor.value[factor] || []
|
||||
const allowed = new Set(opts.map((o) => String(o.value)))
|
||||
const canonical = toCanonicalFactorValues(factor, row.factorValues || [])
|
||||
addFactorValueSelected.value = canonical
|
||||
.map((v) => String(v).trim())
|
||||
.filter((v) => v && allowed.has(v))
|
||||
addFactorValueModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleConfirmAddFactorValue = () => {
|
||||
function resetAddFactorValueModal() {
|
||||
addFactorValueModalVisible.value = false
|
||||
addFactorValueSelected.value = []
|
||||
currentFactorRow.value = null
|
||||
}
|
||||
|
||||
/** 将弹窗内勾选的因素值写入当前行;forbidEmpty 为 true 且无勾选时提示并返回 false */
|
||||
function commitAddFactorSelections(opts: { forbidEmpty: boolean }): boolean {
|
||||
const selectedValues = addFactorValueSelected.value || []
|
||||
if (selectedValues.length === 0) {
|
||||
Message.warning('请至少选择一个因素值')
|
||||
return
|
||||
if (opts.forbidEmpty) {
|
||||
Message.warning('请至少选择一个因素值')
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!currentFactorRow.value) return
|
||||
if (!currentFactorRow.value) return false
|
||||
|
||||
const existing = new Set(currentFactorRow.value.factorValues || [])
|
||||
const toAdd = selectedValues.filter((value) => !existing.has(value))
|
||||
if (toAdd.length === 0) {
|
||||
Message.warning('所选因素值均已存在')
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
currentFactorRow.value.factorValues.push(...toAdd)
|
||||
addFactorValueModalVisible.value = false
|
||||
addFactorValueSelected.value = []
|
||||
currentFactorRow.value = null
|
||||
Message.success('添加成功')
|
||||
return true
|
||||
}
|
||||
|
||||
const handleCancelAddFactorValue = () => {
|
||||
addFactorValueModalVisible.value = false
|
||||
addFactorValueSelected.value = []
|
||||
currentFactorRow.value = null
|
||||
const handleConfirmAddFactorValue = () => {
|
||||
if (!commitAddFactorSelections({ forbidEmpty: true })) return
|
||||
resetAddFactorValueModal()
|
||||
}
|
||||
|
||||
/** 点击遮罩关闭时:按当前勾选提交(无勾选则仅关闭);点取消/X/ESC 仍丢弃 */
|
||||
function handleAddFactorValueModalCancel(e?: Event) {
|
||||
const el = e && 'target' in e ? ((e as MouseEvent).target as HTMLElement | null) : null
|
||||
const fromMask = !!el?.classList?.contains('arco-modal-wrapper')
|
||||
if (fromMask) {
|
||||
commitAddFactorSelections({ forbidEmpty: false })
|
||||
resetAddFactorValueModal()
|
||||
return
|
||||
}
|
||||
resetAddFactorValueModal()
|
||||
}
|
||||
|
||||
const handleSelectAllFactorValues = () => {
|
||||
@ -4279,14 +4513,27 @@ const handleConfirmPublish = async () => {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.price-list-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.price-list-hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.price-list-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
max-width: 72%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4302,6 +4549,11 @@ const handleConfirmPublish = async () => {
|
||||
width: 100%;
|
||||
min-width: 1000px;
|
||||
border-collapse: collapse;
|
||||
|
||||
.price-row-check {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: #fafafa;
|
||||
@ -4837,4 +5089,12 @@ const handleConfirmPublish = async () => {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.batch-fill-form {
|
||||
.batch-fill-meta {
|
||||
margin-bottom: 12px;
|
||||
color: #4e5969;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -88,7 +88,16 @@
|
||||
{{ t('h5.orders.askAdvisor') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isPendingPay"
|
||||
v-if="isDraft"
|
||||
type="button"
|
||||
class="h5-detail__btn is-primary"
|
||||
:disabled="predeclaring"
|
||||
@click="handlePreDeclare"
|
||||
>
|
||||
{{ predeclaring ? t('h5.orders.detail.predeclaring') : t('h5.orders.detail.predeclare') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="isPendingPay"
|
||||
type="button"
|
||||
class="h5-detail__btn is-primary"
|
||||
@click="openPay"
|
||||
@ -114,9 +123,9 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal } from '@arco-design/web-vue';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { IconClose, IconClockCircle, IconCommon } from '@arco-design/web-vue/es/icon';
|
||||
import { getOrderDetail } from '@/api/order';
|
||||
import { getOrderDetail, postOrderPreDeclare } from '@/api/order';
|
||||
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
|
||||
import { h5Navigate } from '@/utils/device';
|
||||
import {
|
||||
@ -178,12 +187,16 @@ const waybillNo = computed(
|
||||
);
|
||||
const statusCode = computed(() => resolveOrderStatusCode(detail.value));
|
||||
const isPendingPay = computed(() => statusCode.value === 'pendingPayment');
|
||||
const isDraft = computed(() => statusCode.value === 'draft');
|
||||
const predeclaring = ref(false);
|
||||
|
||||
const overviewRows = computed(() => {
|
||||
const d = detail.value || {};
|
||||
const r = d.receiver || d.Receiver || {};
|
||||
const meta = getOrderStatusMeta(statusCode.value);
|
||||
return [
|
||||
const failReason =
|
||||
pick(d, 'predeclareFailReason', 'PredeclareFailReason') || '';
|
||||
const rows = [
|
||||
{ label: t('h5.orders.detail.waybillNo'), value: waybillNo.value || '-' },
|
||||
{
|
||||
label: t('h5.orders.detail.customerOrderNo'),
|
||||
@ -197,6 +210,14 @@ const overviewRows = computed(() => {
|
||||
label: t('h5.orders.detail.status'),
|
||||
value: meta ? t(meta.labelKey) : statusCode.value || '-',
|
||||
},
|
||||
];
|
||||
if (failReason) {
|
||||
rows.push({
|
||||
label: t('h5.orders.detail.predeclareFailReason'),
|
||||
value: String(failReason),
|
||||
});
|
||||
}
|
||||
rows.push(
|
||||
{
|
||||
label: t('h5.orders.detail.channel'),
|
||||
value: pick(d, 'channelName', 'ChannelName', 'productName', 'ProductName') || '-',
|
||||
@ -219,7 +240,8 @@ const overviewRows = computed(() => {
|
||||
label: t('h5.orders.detail.weight'),
|
||||
value: pick(d, 'chargeableWeight', 'ChargeableWeight') || '-',
|
||||
},
|
||||
];
|
||||
);
|
||||
return rows;
|
||||
});
|
||||
|
||||
const feeList = computed(() => {
|
||||
@ -285,6 +307,22 @@ function openPay() {
|
||||
payVisible.value = true;
|
||||
}
|
||||
|
||||
async function handlePreDeclare() {
|
||||
if (!props.orderId || predeclaring.value) return;
|
||||
predeclaring.value = true;
|
||||
try {
|
||||
await postOrderPreDeclare({ id: props.orderId });
|
||||
Message.success(t('h5.orders.detail.predeclareOk'));
|
||||
await loadDetail();
|
||||
emit('paid');
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || t('h5.orders.detail.predeclareFailed'));
|
||||
await loadDetail();
|
||||
} finally {
|
||||
predeclaring.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPaySuccess() {
|
||||
void loadDetail();
|
||||
emit('paid');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -114,6 +114,11 @@ export default {
|
||||
'h5.orders.detail.noTrack': 'No tracking yet',
|
||||
'h5.orders.detail.noFee': 'No fee details',
|
||||
'h5.orders.detail.close': 'Close',
|
||||
'h5.orders.detail.predeclareFailReason': 'Pre-declare failure',
|
||||
'h5.orders.detail.predeclare': 'Pre-declare again',
|
||||
'h5.orders.detail.predeclaring': 'Pre-declaring…',
|
||||
'h5.orders.detail.predeclareOk': 'Pre-declared',
|
||||
'h5.orders.detail.predeclareFailed': 'Pre-declare failed',
|
||||
|
||||
'h5.me.guestTitle': 'Welcome to Customer Center',
|
||||
'h5.me.guestDesc':
|
||||
@ -226,20 +231,46 @@ export default {
|
||||
'h5.placeOrder.default': 'Default',
|
||||
'h5.placeOrder.receiverName': 'Recipient',
|
||||
'h5.placeOrder.receiverPhone': 'Phone',
|
||||
'h5.placeOrder.company': 'Company',
|
||||
'h5.placeOrder.telePhone': 'Tel',
|
||||
'h5.placeOrder.email': 'Email',
|
||||
'h5.placeOrder.streetNumber': 'House No.',
|
||||
'h5.placeOrder.country': 'Country',
|
||||
'h5.placeOrder.address': 'Address',
|
||||
'h5.placeOrder.address': 'Address 1',
|
||||
'h5.placeOrder.address2': 'Address 2',
|
||||
'h5.placeOrder.address3': 'Address 3',
|
||||
'h5.placeOrder.city': 'City',
|
||||
'h5.placeOrder.province': 'State / Province',
|
||||
'h5.placeOrder.zip': 'Postal code',
|
||||
'h5.placeOrder.taxNo': 'Tax No.',
|
||||
'h5.placeOrder.certificateNo': 'ID No.',
|
||||
'h5.placeOrder.eori': 'EORI',
|
||||
'h5.placeOrder.vat': 'VAT',
|
||||
'h5.placeOrder.currency': 'Declared currency',
|
||||
'h5.placeOrder.enName': 'Item name (EN)',
|
||||
'h5.placeOrder.cnName': 'Item name (CN)',
|
||||
'h5.placeOrder.sku': 'SKU',
|
||||
'h5.placeOrder.unit': 'Unit',
|
||||
'h5.placeOrder.unitPrice': 'Unit price',
|
||||
'h5.placeOrder.unitWeight': 'Unit weight',
|
||||
'h5.placeOrder.qty': 'Qty',
|
||||
'h5.placeOrder.amount': 'Amount',
|
||||
'h5.placeOrder.itemWeight': 'Weight',
|
||||
'h5.placeOrder.imHsCode': 'Import HS',
|
||||
'h5.placeOrder.exHsCode': 'Export HS',
|
||||
'h5.placeOrder.brand': 'Brand',
|
||||
'h5.placeOrder.model': 'Model',
|
||||
'h5.placeOrder.material': 'Material',
|
||||
'h5.placeOrder.enMaterial': 'Material (EN)',
|
||||
'h5.placeOrder.purpose': 'Purpose',
|
||||
'h5.placeOrder.originCountry': 'Origin',
|
||||
'h5.placeOrder.category': 'Category',
|
||||
'h5.placeOrder.specifications': 'Specs',
|
||||
'h5.placeOrder.salesLink': 'Sales link',
|
||||
'h5.placeOrder.comment': 'Comment',
|
||||
'h5.placeOrder.itemTitle': 'Item {n}',
|
||||
'h5.placeOrder.showMore': 'More fields',
|
||||
'h5.placeOrder.hideMore': 'Hide more',
|
||||
'h5.placeOrder.addItem': 'Add item',
|
||||
'h5.placeOrder.removeItem': 'Remove',
|
||||
'h5.placeOrder.err.itemsEmpty': 'Add at least one declaration item',
|
||||
@ -253,6 +284,34 @@ export default {
|
||||
'h5.placeOrder.goOrders': 'Go to Orders',
|
||||
'h5.placeOrder.stay': 'Stay',
|
||||
'h5.placeOrder.failed': 'Failed to create order',
|
||||
'h5.placeOrder.failTitle': 'Order was not placed',
|
||||
'h5.placeOrder.failTitleDraft': 'Pre-declaration failed (draft saved)',
|
||||
'h5.placeOrder.failWhat': 'What happened',
|
||||
'h5.placeOrder.failHow': 'What to change',
|
||||
'h5.placeOrder.failRaw': 'Details',
|
||||
'h5.placeOrder.failDraftNote':
|
||||
'Saved as draft. Open the order, fix details, then pre-declare again. Do not resubmit here.',
|
||||
'h5.placeOrder.failNotSavedNote':
|
||||
'The order was not saved. Continue editing and retry, or save it as a draft.',
|
||||
'h5.placeOrder.failContinue': 'Continue editing',
|
||||
'h5.placeOrder.failSaveDraft': 'Save as draft',
|
||||
'h5.placeOrder.failSaveDraftOk': 'Saved as draft',
|
||||
'h5.placeOrder.failSaveDraftFailed': 'Failed to save draft',
|
||||
'h5.placeOrder.failSaveDraftGo': 'Open draft',
|
||||
'h5.placeOrder.failSaveDraftStay': 'Stay',
|
||||
'h5.placeOrder.failGoDraft': 'Open draft & pre-declare',
|
||||
'h5.placeOrder.failAbandon': 'Discard',
|
||||
'h5.placeOrder.failCancelDraft': 'Cancel (delete draft)',
|
||||
'h5.placeOrder.failAbandonTitle': 'Discard this form?',
|
||||
'h5.placeOrder.failAbandonContent': 'The current form will be cleared.',
|
||||
'h5.placeOrder.failAbandonOk': 'Discard',
|
||||
'h5.placeOrder.failAbandonDone': 'Cleared',
|
||||
'h5.placeOrder.failCancelDraftTitle': 'Cancel this submission?',
|
||||
'h5.placeOrder.failCancelDraftContent': 'The draft just saved will be deleted.',
|
||||
'h5.placeOrder.failCancelDraftOk': 'Delete draft',
|
||||
'h5.placeOrder.failKeep': 'Keep editing',
|
||||
'h5.placeOrder.failCancelDraftDone': 'Draft deleted',
|
||||
'h5.placeOrder.failCancelDraftFailed': 'Failed to delete',
|
||||
'h5.placeOrder.optionsFailed': 'Failed to load options',
|
||||
'h5.placeOrder.err.clientNo': 'Enter customer order No.',
|
||||
'h5.placeOrder.err.product': 'Select shipping method',
|
||||
|
||||
@ -114,6 +114,11 @@ export default {
|
||||
'h5.orders.detail.noTrack': '暂无轨迹',
|
||||
'h5.orders.detail.noFee': '暂无费用明细',
|
||||
'h5.orders.detail.close': '关闭',
|
||||
'h5.orders.detail.predeclareFailReason': '预报失败原因',
|
||||
'h5.orders.detail.predeclare': '重新预报',
|
||||
'h5.orders.detail.predeclaring': '预报中…',
|
||||
'h5.orders.detail.predeclareOk': '预报成功',
|
||||
'h5.orders.detail.predeclareFailed': '预报失败',
|
||||
|
||||
'h5.me.guestTitle': '欢迎使用客户中心',
|
||||
'h5.me.guestDesc': '微信内长按二维码识别即可登录;新用户可先注册或绑定',
|
||||
@ -225,20 +230,46 @@ export default {
|
||||
'h5.placeOrder.default': '默认',
|
||||
'h5.placeOrder.receiverName': '收件人',
|
||||
'h5.placeOrder.receiverPhone': '手机号',
|
||||
'h5.placeOrder.company': '公司',
|
||||
'h5.placeOrder.telePhone': '电话',
|
||||
'h5.placeOrder.email': '邮箱',
|
||||
'h5.placeOrder.streetNumber': '门牌号',
|
||||
'h5.placeOrder.country': '目的国家',
|
||||
'h5.placeOrder.address': '地址',
|
||||
'h5.placeOrder.address': '地址1',
|
||||
'h5.placeOrder.address2': '地址2',
|
||||
'h5.placeOrder.address3': '地址3',
|
||||
'h5.placeOrder.city': '城市',
|
||||
'h5.placeOrder.province': '州/省',
|
||||
'h5.placeOrder.zip': '邮编',
|
||||
'h5.placeOrder.taxNo': '税号',
|
||||
'h5.placeOrder.certificateNo': '证件号码',
|
||||
'h5.placeOrder.eori': 'EORI',
|
||||
'h5.placeOrder.vat': 'VAT',
|
||||
'h5.placeOrder.currency': '申报币种',
|
||||
'h5.placeOrder.enName': '品名(英)',
|
||||
'h5.placeOrder.cnName': '品名(中)',
|
||||
'h5.placeOrder.sku': 'SKU',
|
||||
'h5.placeOrder.unit': '单位',
|
||||
'h5.placeOrder.unitPrice': '单价',
|
||||
'h5.placeOrder.unitWeight': '单重',
|
||||
'h5.placeOrder.qty': '数量',
|
||||
'h5.placeOrder.amount': '总价',
|
||||
'h5.placeOrder.itemWeight': '总重',
|
||||
'h5.placeOrder.imHsCode': '进口HS',
|
||||
'h5.placeOrder.exHsCode': '出口HS',
|
||||
'h5.placeOrder.brand': '品牌',
|
||||
'h5.placeOrder.model': '型号',
|
||||
'h5.placeOrder.material': '材质',
|
||||
'h5.placeOrder.enMaterial': '英文材质',
|
||||
'h5.placeOrder.purpose': '用途',
|
||||
'h5.placeOrder.originCountry': '原产国',
|
||||
'h5.placeOrder.category': '类别',
|
||||
'h5.placeOrder.specifications': '规格',
|
||||
'h5.placeOrder.salesLink': '销售链接',
|
||||
'h5.placeOrder.comment': '备注',
|
||||
'h5.placeOrder.itemTitle': '申报品 {n}',
|
||||
'h5.placeOrder.showMore': '更多信息',
|
||||
'h5.placeOrder.hideMore': '收起更多',
|
||||
'h5.placeOrder.addItem': '添加申报品',
|
||||
'h5.placeOrder.removeItem': '删除',
|
||||
'h5.placeOrder.err.itemsEmpty': '请至少添加一条申报品',
|
||||
@ -252,6 +283,32 @@ export default {
|
||||
'h5.placeOrder.goOrders': '去查订单',
|
||||
'h5.placeOrder.stay': '继续下单',
|
||||
'h5.placeOrder.failed': '下单失败',
|
||||
'h5.placeOrder.failTitle': '下单未成功',
|
||||
'h5.placeOrder.failTitleDraft': '预报未完成(草稿已保存)',
|
||||
'h5.placeOrder.failWhat': '发生了什么',
|
||||
'h5.placeOrder.failHow': '建议修改',
|
||||
'h5.placeOrder.failRaw': '详细原因',
|
||||
'h5.placeOrder.failDraftNote': '订单已存为草稿。请打开订单详情修改后重新预报,勿在此页再次提交。',
|
||||
'h5.placeOrder.failNotSavedNote': '订单尚未落库。可继续修改后重试,或先保存为草稿。',
|
||||
'h5.placeOrder.failContinue': '继续修改',
|
||||
'h5.placeOrder.failSaveDraft': '保存为草稿',
|
||||
'h5.placeOrder.failSaveDraftOk': '已保存为草稿',
|
||||
'h5.placeOrder.failSaveDraftFailed': '保存草稿失败',
|
||||
'h5.placeOrder.failSaveDraftGo': '去看草稿',
|
||||
'h5.placeOrder.failSaveDraftStay': '继续留在本页',
|
||||
'h5.placeOrder.failGoDraft': '去看草稿并预报',
|
||||
'h5.placeOrder.failAbandon': '放弃本次',
|
||||
'h5.placeOrder.failCancelDraft': '取消提交(删草稿)',
|
||||
'h5.placeOrder.failAbandonTitle': '放弃本次填写?',
|
||||
'h5.placeOrder.failAbandonContent': '将清空当前表单。',
|
||||
'h5.placeOrder.failAbandonOk': '确认放弃',
|
||||
'h5.placeOrder.failAbandonDone': '已清空',
|
||||
'h5.placeOrder.failCancelDraftTitle': '取消本次提交?',
|
||||
'h5.placeOrder.failCancelDraftContent': '将删除刚保存的草稿。',
|
||||
'h5.placeOrder.failCancelDraftOk': '确认删除',
|
||||
'h5.placeOrder.failKeep': '再想想',
|
||||
'h5.placeOrder.failCancelDraftDone': '草稿已删除',
|
||||
'h5.placeOrder.failCancelDraftFailed': '删除失败',
|
||||
'h5.placeOrder.optionsFailed': '加载选项失败',
|
||||
'h5.placeOrder.err.clientNo': '请填写客户单号',
|
||||
'h5.placeOrder.err.product': '请选择运输方式',
|
||||
|
||||
@ -509,7 +509,26 @@ watch(activeGroup, () => {
|
||||
void reload();
|
||||
});
|
||||
|
||||
void reload();
|
||||
async function tryOpenOrderFromQuery() {
|
||||
const rawId = route.query.orderId;
|
||||
const idStr = Array.isArray(rawId) ? rawId[0] : rawId;
|
||||
const id = Number(idStr);
|
||||
if (!Number.isFinite(id) || id <= 0) return;
|
||||
goDetail({ id });
|
||||
const nextQuery = { ...route.query } as Record<string, any>;
|
||||
delete nextQuery.orderId;
|
||||
await router.replace({ query: nextQuery });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.orderId,
|
||||
(orderId) => {
|
||||
if (!orderId) return;
|
||||
void tryOpenOrderFromQuery();
|
||||
},
|
||||
);
|
||||
|
||||
void reload().then(() => tryOpenOrderFromQuery());
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
342
src/views/order/components/OrderAttachmentsTab.vue
Normal file
342
src/views/order/components/OrderAttachmentsTab.vue
Normal file
@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div class="order-attachments-tab">
|
||||
<a-spin :loading="loading" class="attachments-spin">
|
||||
<div
|
||||
v-for="section in visibleSections"
|
||||
:key="section.type"
|
||||
class="attach-section"
|
||||
>
|
||||
<div class="attach-section-header">
|
||||
<div class="attach-section-title-wrap">
|
||||
<span class="attach-section-title">{{ $t(section.labelKey) }}</span>
|
||||
<span class="attach-section-count">
|
||||
{{ section.files.length }}/{{ section.maxCount }}
|
||||
</span>
|
||||
</div>
|
||||
<a-upload
|
||||
v-if="canUpload"
|
||||
:accept="section.accept"
|
||||
:multiple="section.maxCount > 1"
|
||||
:show-file-list="false"
|
||||
:disabled="uploadingType === section.type || section.files.length >= section.maxCount"
|
||||
:custom-request="(opt) => handleUpload(section.type, opt)"
|
||||
>
|
||||
<a-button
|
||||
type="outline"
|
||||
size="small"
|
||||
:loading="uploadingType === section.type"
|
||||
:disabled="section.files.length >= section.maxCount"
|
||||
>
|
||||
{{ $t('order.myOrders.drawer.attachments.upload') }}
|
||||
</a-button>
|
||||
</a-upload>
|
||||
</div>
|
||||
|
||||
<div v-if="section.files.length" class="attach-file-list">
|
||||
<div
|
||||
v-for="file in section.files"
|
||||
:key="file.id"
|
||||
class="attach-file-item"
|
||||
>
|
||||
<a
|
||||
class="attach-file-name"
|
||||
:href="resolveUrl(file.fileUrl)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ file.fileName || file.fileUrl }}
|
||||
</a>
|
||||
<div class="attach-file-meta">
|
||||
<span>{{ formatTime(file.createTime) }}</span>
|
||||
<span v-if="file.source">{{ $t(`order.myOrders.drawer.attachments.source.${file.source}`, file.source) }}</span>
|
||||
</div>
|
||||
<div class="attach-file-actions">
|
||||
<a-button
|
||||
type="text"
|
||||
size="mini"
|
||||
@click="openFile(file.fileUrl)"
|
||||
>
|
||||
{{ $t('order.myOrders.drawer.attachments.preview') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="canUpload"
|
||||
type="text"
|
||||
size="mini"
|
||||
status="danger"
|
||||
:loading="deletingId === file.id"
|
||||
@click="handleDelete(file.id)"
|
||||
>
|
||||
{{ $t('order.myOrders.drawer.attachments.delete') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty
|
||||
v-else
|
||||
:description="$t('order.myOrders.drawer.attachments.empty')"
|
||||
:image-style="{ height: '48px' }"
|
||||
class="attach-empty"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import {
|
||||
deleteOrderFile,
|
||||
getOrderFiles,
|
||||
uploadOrderFiles,
|
||||
type OrderFileItem,
|
||||
} from '@/api/order';
|
||||
import { resolveOmsAssetUrl } from '@/api/oms/common';
|
||||
|
||||
const FILE_TYPE_DEFS = [
|
||||
{
|
||||
type: 'tradeProof',
|
||||
labelKey: 'order.myOrders.drawer.attachments.types.tradeProof',
|
||||
maxCount: 20,
|
||||
accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf',
|
||||
},
|
||||
{
|
||||
type: 'systemLabel',
|
||||
labelKey: 'order.myOrders.drawer.attachments.types.systemLabel',
|
||||
maxCount: 1,
|
||||
accept: '.pdf,.jpg,.jpeg,.png',
|
||||
},
|
||||
{
|
||||
type: 'downstreamLabel',
|
||||
labelKey: 'order.myOrders.drawer.attachments.types.downstreamLabel',
|
||||
maxCount: 20,
|
||||
accept: '.pdf,.jpg,.jpeg,.png',
|
||||
},
|
||||
{
|
||||
type: 'lastMileLabel',
|
||||
labelKey: 'order.myOrders.drawer.attachments.types.lastMileLabel',
|
||||
maxCount: 20,
|
||||
accept: '.pdf,.jpg,.jpeg,.png',
|
||||
},
|
||||
{
|
||||
type: 'infraredImage',
|
||||
labelKey: 'order.myOrders.drawer.attachments.types.infraredImage',
|
||||
maxCount: 20,
|
||||
accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
orderId?: number | null;
|
||||
active?: boolean;
|
||||
/** 客户门户:只读系统面单 */
|
||||
readOnly?: boolean;
|
||||
customerView?: boolean;
|
||||
}>(),
|
||||
{
|
||||
orderId: null,
|
||||
active: false,
|
||||
readOnly: false,
|
||||
customerView: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const loading = ref(false);
|
||||
const uploadingType = ref<string | null>(null);
|
||||
const deletingId = ref<number | null>(null);
|
||||
const groups = ref<Record<string, OrderFileItem[]>>({});
|
||||
|
||||
const canUpload = computed(() => !props.readOnly && !props.customerView);
|
||||
|
||||
const visibleSections = computed(() => {
|
||||
const defs = props.customerView
|
||||
? FILE_TYPE_DEFS.filter((d) => d.type === 'systemLabel')
|
||||
: FILE_TYPE_DEFS;
|
||||
return defs.map((def) => ({
|
||||
...def,
|
||||
files: groups.value[def.type] ?? [],
|
||||
}));
|
||||
});
|
||||
|
||||
function resolveUrl(path?: string) {
|
||||
return resolveOmsAssetUrl(path);
|
||||
}
|
||||
|
||||
function openFile(path?: string) {
|
||||
const url = resolveUrl(path);
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) return '';
|
||||
return String(value).replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
if (!props.orderId) {
|
||||
groups.value = {};
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getOrderFiles(props.orderId);
|
||||
groups.value = data.groups ?? {};
|
||||
} catch (e: any) {
|
||||
groups.value = {};
|
||||
Message.error(e?.message || t('order.myOrders.drawer.attachments.loadFailed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(fileType: string, options: any) {
|
||||
const file = options?.fileItem?.file ?? options?.file;
|
||||
if (!file || !props.orderId) {
|
||||
options?.onError?.(new Error('no file'));
|
||||
return;
|
||||
}
|
||||
uploadingType.value = fileType;
|
||||
try {
|
||||
await uploadOrderFiles(props.orderId, fileType, [file as File]);
|
||||
options?.onSuccess?.();
|
||||
Message.success(t('order.myOrders.drawer.attachments.uploadSuccess'));
|
||||
await loadFiles();
|
||||
} catch (e: any) {
|
||||
options?.onError?.(e);
|
||||
Message.error(e?.message || t('order.myOrders.drawer.attachments.uploadFailed'));
|
||||
} finally {
|
||||
uploadingType.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(id: number) {
|
||||
Modal.confirm({
|
||||
title: t('order.myOrders.drawer.attachments.deleteConfirmTitle'),
|
||||
content: t('order.myOrders.drawer.attachments.deleteConfirm'),
|
||||
onOk: async () => {
|
||||
deletingId.value = id;
|
||||
try {
|
||||
await deleteOrderFile(id);
|
||||
Message.success(t('order.myOrders.drawer.attachments.deleteSuccess'));
|
||||
await loadFiles();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || t('order.myOrders.drawer.attachments.deleteFailed'));
|
||||
} finally {
|
||||
deletingId.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.active, props.orderId] as const,
|
||||
([active, orderId]) => {
|
||||
if (active && orderId) {
|
||||
void loadFiles();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.order-attachments-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.attachments-spin {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attach-section {
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.attach-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.attach-section-title-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attach-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.attach-section-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.attach-file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attach-file-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.attach-file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-primary-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.attach-file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.attach-file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.attach-empty {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.attach-file-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.attach-file-meta {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -79,6 +79,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="predeclareFailGuide" class="predeclare-fail-guide">
|
||||
<div class="predeclare-fail-guide__title">
|
||||
{{ $t('order.myOrders.drawer.failGuideTitle') }}
|
||||
</div>
|
||||
<p class="predeclare-fail-guide__reason">{{ predeclareFailGuide.reason }}</p>
|
||||
<p
|
||||
v-if="predeclareFailGuide.rawReason !== predeclareFailGuide.reason"
|
||||
class="predeclare-fail-guide__raw"
|
||||
>
|
||||
{{ $t('order.myOrders.drawer.failGuideRaw') }}:{{ predeclareFailGuide.rawReason }}
|
||||
</p>
|
||||
<ul class="predeclare-fail-guide__tips">
|
||||
<li v-for="(tip, idx) in predeclareFailGuide.tips" :key="idx">{{ tip }}</li>
|
||||
</ul>
|
||||
<div class="predeclare-fail-guide__actions">
|
||||
<a-button size="small" type="outline" @click="handleAskAdvisor">
|
||||
{{ $t('order.myOrders.drawer.failGuideAskSupport') }}
|
||||
</a-button>
|
||||
<a-button size="small" type="primary" @click="startBasicEdit">
|
||||
{{ $t('order.myOrders.drawer.failGuideEdit') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="basic-view-switch">
|
||||
<a-radio-group
|
||||
v-model="basicViewMode"
|
||||
@ -428,6 +452,16 @@
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 附件 -->
|
||||
<div v-if="activeTab === 'attachments'" class="tab-panel attachments-info">
|
||||
<OrderAttachmentsTab
|
||||
:order-id="orderId"
|
||||
:active="activeTab === 'attachments'"
|
||||
:customer-view="isCustomerPortal"
|
||||
:read-only="isCustomerPortal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 绑定包裹/库存 -->
|
||||
<div v-if="activeTab === 'stockBind'" class="tab-panel stock-bind-info">
|
||||
<OrderStockBindTab
|
||||
@ -465,6 +499,8 @@ import { getDictListAll, type DictDto } from '@/api/system/dict';
|
||||
import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import { openAskAdvisor } from '@/composables/useAskAdvisor';
|
||||
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
|
||||
import OrderAttachmentsTab from '../../components/OrderAttachmentsTab.vue';
|
||||
import AddOrderFeeModal from './AddOrderFeeModal.vue';
|
||||
import OrderStockBindTab from './OrderStockBindTab.vue';
|
||||
|
||||
@ -547,6 +583,14 @@ const currentOrderStatusMeta = computed(() => {
|
||||
return code ? getOrderStatusMeta(code) : null;
|
||||
});
|
||||
|
||||
const predeclareFailGuide = computed(() => {
|
||||
const status = resolveOrderStatusCode(detail.value);
|
||||
if (status !== 'draft') return null;
|
||||
const reason = String(getOrderValue('predeclareFailReason') || '').trim();
|
||||
if (!reason) return null;
|
||||
return buildFailGuideFromReason(reason);
|
||||
});
|
||||
|
||||
type FieldInputType = 'text' | 'number' | 'select' | 'switch';
|
||||
|
||||
interface FormFieldDef {
|
||||
@ -783,6 +827,7 @@ const activeTab = ref('basic');
|
||||
const tabList = computed(() => [
|
||||
{ key: 'basic', label: t('order.myOrders.drawer.tabs.basic') },
|
||||
{ key: 'track', label: t('order.myOrders.drawer.tabs.track') },
|
||||
{ key: 'attachments', label: t('order.myOrders.drawer.tabs.attachments') },
|
||||
{ key: 'stockBind', label: t('order.myOrders.drawer.tabs.stockBind') },
|
||||
]);
|
||||
|
||||
@ -1696,7 +1741,7 @@ watch(
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'stockBind' }) => {
|
||||
openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'attachments' | 'stockBind' }) => {
|
||||
const tab = resolveDrawerTab(opts?.tab);
|
||||
activeTab.value = tab;
|
||||
basicViewMode.value = 'all';
|
||||
@ -1871,7 +1916,51 @@ defineExpose({
|
||||
}
|
||||
|
||||
.basic-summary-actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.predeclare-fail-guide {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: #fff7e8;
|
||||
border: 1px solid #ffe4ba;
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__reason {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
&__raw {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__tips {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.basic-chip {
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
type OrderDownstreamForwardResult,
|
||||
} from '@/api/order';
|
||||
import { notifyCustomerBalanceChanged } from '@/api/customer/accountFlow';
|
||||
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
|
||||
import OrderDetailDrawer from './components/OrderDetailDrawer.vue';
|
||||
import OrderPayConfirmModal from './components/OrderPayConfirmModal.vue';
|
||||
import MarkProblematicModal from './components/MarkProblematicModal.vue';
|
||||
@ -1165,8 +1166,11 @@
|
||||
if (colKey === 'predeclareFailReason') {
|
||||
const reason = rowData?.predeclareFailReason || value || '';
|
||||
if (!reason) return null;
|
||||
const escaped = escapeHtml(String(reason));
|
||||
return `<span title="${escaped}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
|
||||
const guide = buildFailGuideFromReason(String(reason));
|
||||
const display = guide.reason || String(reason);
|
||||
const escaped = escapeHtml(display);
|
||||
const title = escapeHtml(guide.rawReason || String(reason));
|
||||
return `<span title="${title}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
|
||||
}
|
||||
if (colKey === 'allocationPendingReason') {
|
||||
const reason = rowData?.allocationPendingReason || value || '';
|
||||
@ -1239,12 +1243,31 @@
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
loadOrderList();
|
||||
nextTick(async () => {
|
||||
await loadOrderList();
|
||||
loadTabCounts();
|
||||
await tryOpenOrderFromQuery();
|
||||
});
|
||||
});
|
||||
|
||||
async function tryOpenOrderFromQuery() {
|
||||
const rawId = route.query.orderId;
|
||||
const idStr = Array.isArray(rawId) ? rawId[0] : rawId;
|
||||
const id = Number(idStr);
|
||||
if (!Number.isFinite(id) || id <= 0) return;
|
||||
const editRaw = route.query.edit;
|
||||
const editStr = Array.isArray(editRaw) ? editRaw[0] : editRaw;
|
||||
const editBasic = String(editStr || '') === '1';
|
||||
try {
|
||||
await openDetailWithOptionalFetch({ id }, { editBasic });
|
||||
} finally {
|
||||
const nextQuery = { ...route.query } as Record<string, any>;
|
||||
delete nextQuery.orderId;
|
||||
delete nextQuery.edit;
|
||||
router.replace({ query: nextQuery });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.status,
|
||||
() => {
|
||||
@ -1260,6 +1283,14 @@
|
||||
loadTabCounts();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query.orderId,
|
||||
(orderId) => {
|
||||
if (!orderId) return;
|
||||
void tryOpenOrderFromQuery();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
import {
|
||||
getOrderTemplateList,
|
||||
postOrderParcelCreate,
|
||||
postOrderDelete,
|
||||
downloadOrderTemplate,
|
||||
deleteOrderTemplateCustom,
|
||||
postOrderUpload,
|
||||
@ -29,6 +30,11 @@
|
||||
checkCustomerContactRequired,
|
||||
isCustomerContactRequiredError,
|
||||
} from '@/utils/customerContactGate';
|
||||
import {
|
||||
buildParcelCreateFailGuide,
|
||||
type ParcelCreateFailGuide,
|
||||
} from '@/utils/parcelCreateFailGuide';
|
||||
import { openAskAdvisor } from '@/composables/useAskAdvisor';
|
||||
import CompleteContactModal from '@/components/CompleteContactModal/index.vue';
|
||||
import type { CustomerProfileCompleteness } from '@/api/customer';
|
||||
|
||||
@ -2149,6 +2155,229 @@
|
||||
});
|
||||
}
|
||||
|
||||
const createFailVisible = ref(false);
|
||||
const createFailCancelling = ref(false);
|
||||
const createFailSavingDraft = ref(false);
|
||||
const createFailInfo = ref<ParcelCreateFailGuide | null>(null);
|
||||
|
||||
function emptyDeclarationItem() {
|
||||
return {
|
||||
cnName: '',
|
||||
enName: '',
|
||||
sku: '',
|
||||
unit: '',
|
||||
unitPrice: '',
|
||||
unitWeight: '',
|
||||
quantity: '',
|
||||
amount: '',
|
||||
weight: '',
|
||||
imHsCode: '',
|
||||
exHsCode: '',
|
||||
skuImage: '',
|
||||
category: '',
|
||||
brand: '',
|
||||
brandType: '',
|
||||
isMagnetic: false,
|
||||
distribution: '',
|
||||
material: '',
|
||||
enMaterial: '',
|
||||
purpose: '',
|
||||
attributes: '',
|
||||
originCountry: '',
|
||||
salesLink: '',
|
||||
model: '',
|
||||
specifications: '',
|
||||
comment: '',
|
||||
};
|
||||
}
|
||||
|
||||
function resetSingleForm() {
|
||||
Object.assign(singleForm, {
|
||||
clientNo: '',
|
||||
productCode: '',
|
||||
cargoAttribute: '',
|
||||
exportCustomsMode: '',
|
||||
warehouseId: '',
|
||||
length: '10',
|
||||
width: '10',
|
||||
height: '10',
|
||||
actualWeight: '',
|
||||
isCod: false,
|
||||
codCurrency: '',
|
||||
codSum: '',
|
||||
declaredCurrency: resolveDefaultDeclaredCurrency(),
|
||||
quantity: '1',
|
||||
});
|
||||
Object.assign(singleForm.receiver, {
|
||||
recipientsName: '',
|
||||
companyName: '',
|
||||
phone: '',
|
||||
telePhone: '',
|
||||
email: '',
|
||||
countryCode: '',
|
||||
province: '',
|
||||
city: '',
|
||||
streetNumber: '',
|
||||
streetAddress1: '',
|
||||
streetAddress2: '',
|
||||
streetAddress3: '',
|
||||
zipCode: '',
|
||||
taxType: '',
|
||||
taxNo: '',
|
||||
certificateType: '',
|
||||
certificateNo: '',
|
||||
eori: '',
|
||||
vat: '',
|
||||
});
|
||||
Object.assign(singleForm.sender, emptySenderForm());
|
||||
singleForm.items = [emptyDeclarationItem()];
|
||||
selectedSenderAddressId.value = undefined;
|
||||
singleShowFieldErrors.value = false;
|
||||
singleAgreementChecked.value = false;
|
||||
priceCheckHandoff.value = null;
|
||||
}
|
||||
|
||||
function openCreateFailModal(error: unknown) {
|
||||
createFailInfo.value = buildParcelCreateFailGuide(error);
|
||||
createFailVisible.value = true;
|
||||
}
|
||||
|
||||
function closeCreateFailModal() {
|
||||
createFailVisible.value = false;
|
||||
}
|
||||
|
||||
function handleCreateFailContinueEdit() {
|
||||
const info = createFailInfo.value;
|
||||
closeCreateFailModal();
|
||||
if (!info) return;
|
||||
if (info.draftSaved && info.orderId) {
|
||||
router.push({
|
||||
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
|
||||
query: { status: 'draft', orderId: String(info.orderId), edit: '1' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
singleShowFieldErrors.value = true;
|
||||
focusSingleSection(info.section);
|
||||
}
|
||||
|
||||
async function handleCreateFailSaveAsDraft() {
|
||||
const info = createFailInfo.value;
|
||||
if (!info || info.draftSaved) return;
|
||||
|
||||
createFailSavingDraft.value = true;
|
||||
try {
|
||||
const created = await postOrderParcelCreate({
|
||||
...buildSinglePayload(),
|
||||
saveAsDraft: true,
|
||||
});
|
||||
const orderId = Number(created?.orderId ?? created?.OrderId ?? created?.id ?? created?.Id ?? 0) || undefined;
|
||||
closeCreateFailModal();
|
||||
priceCheckHandoff.value = null;
|
||||
singleShowFieldErrors.value = false;
|
||||
Modal.confirm({
|
||||
title: t('order.placeOrder.single.failSaveAsDraftOk'),
|
||||
content: t('order.placeOrder.single.failDraftSavedNote'),
|
||||
okText: t('order.placeOrder.single.failSaveAsDraftGo'),
|
||||
cancelText: t('order.placeOrder.single.failSaveAsDraftStay'),
|
||||
onOk: () => {
|
||||
router.push({
|
||||
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
|
||||
query: {
|
||||
status: 'draft',
|
||||
...(orderId ? { orderId: String(orderId), edit: '1' } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
resetSingleForm();
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || t('order.placeOrder.single.failSaveAsDraftFailed'));
|
||||
} finally {
|
||||
createFailSavingDraft.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateFailAskSupport() {
|
||||
const info = createFailInfo.value;
|
||||
openAskAdvisor(router, {
|
||||
orderId: info?.orderId ? String(info.orderId) : undefined,
|
||||
waybillNo: info?.serviceNo,
|
||||
from: rbacUserStore.isCustomerPortal() ? 'myOrders' : 'waybillManage',
|
||||
orderStatus: info?.draftSaved ? 'draft' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateFailCopyServiceNo() {
|
||||
const no = createFailInfo.value?.serviceNo;
|
||||
if (!no) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(no);
|
||||
Message.success(t('order.placeOrder.single.failCopied'));
|
||||
} catch {
|
||||
Message.warning(t('order.placeOrder.single.failCopyFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateFailGoDrafts() {
|
||||
const info = createFailInfo.value;
|
||||
closeCreateFailModal();
|
||||
router.push({
|
||||
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
|
||||
query: {
|
||||
status: 'draft',
|
||||
...(info?.orderId ? { orderId: String(info.orderId) } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateFailCancelSubmit() {
|
||||
const info = createFailInfo.value;
|
||||
if (!info) {
|
||||
closeCreateFailModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.draftSaved && info.orderId) {
|
||||
Modal.confirm({
|
||||
title: t('order.placeOrder.single.failCancelDraftTitle'),
|
||||
content: t('order.placeOrder.single.failCancelDraftContent'),
|
||||
okText: t('order.placeOrder.single.failCancelDraftOk'),
|
||||
cancelText: t('order.placeOrder.single.failCancelKeep'),
|
||||
okButtonProps: { status: 'danger' },
|
||||
onOk: async () => {
|
||||
createFailCancelling.value = true;
|
||||
try {
|
||||
await postOrderDelete({ id: info.orderId! });
|
||||
Message.success(t('order.placeOrder.single.failCancelDraftDone'));
|
||||
closeCreateFailModal();
|
||||
resetSingleForm();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || t('order.placeOrder.single.failCancelDraftFailed'));
|
||||
} finally {
|
||||
createFailCancelling.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: t('order.placeOrder.single.failAbandonTitle'),
|
||||
content: t('order.placeOrder.single.failAbandonContent'),
|
||||
okText: t('order.placeOrder.single.failAbandonOk'),
|
||||
cancelText: t('order.placeOrder.single.failCancelKeep'),
|
||||
okButtonProps: { status: 'danger' },
|
||||
onOk: () => {
|
||||
closeCreateFailModal();
|
||||
resetSingleForm();
|
||||
Message.success(t('order.placeOrder.single.failAbandonDone'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSingleOrderSubmit() {
|
||||
const validationError = getSingleFormValidationError();
|
||||
if (validationError) {
|
||||
@ -2191,7 +2420,7 @@
|
||||
);
|
||||
void openSenderModal();
|
||||
} else {
|
||||
Message.error(e?.message || t('order.placeOrder.single.failed'));
|
||||
openCreateFailModal(e);
|
||||
}
|
||||
} finally {
|
||||
singleSubmitting.value = false;
|
||||
@ -3006,6 +3235,98 @@
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="createFailVisible"
|
||||
:title="
|
||||
createFailInfo?.draftSaved
|
||||
? t('order.placeOrder.single.failTitleDraft')
|
||||
: t('order.placeOrder.single.failTitle')
|
||||
"
|
||||
:footer="false"
|
||||
width="520px"
|
||||
unmount-on-close
|
||||
:mask-closable="!createFailCancelling && !createFailSavingDraft"
|
||||
:esc-to-close="!createFailCancelling && !createFailSavingDraft"
|
||||
>
|
||||
<div v-if="createFailInfo" class="create-fail-wrap">
|
||||
<div class="create-fail-section">
|
||||
<div class="create-fail-label">{{ t('order.placeOrder.single.failWhatHappened') }}</div>
|
||||
<p class="create-fail-reason">{{ createFailInfo.reason }}</p>
|
||||
<p
|
||||
v-if="createFailInfo.rawReason && createFailInfo.rawReason !== createFailInfo.reason"
|
||||
class="create-fail-raw"
|
||||
>
|
||||
{{ t('order.placeOrder.single.failRawReason') }}:{{ createFailInfo.rawReason }}
|
||||
</p>
|
||||
<p v-if="createFailInfo.draftSaved" class="create-fail-draft-note">
|
||||
{{ t('order.placeOrder.single.failDraftSavedNote') }}
|
||||
<template v-if="createFailInfo.serviceNo">
|
||||
({{ t('order.placeOrder.single.failServiceNo') }}:
|
||||
<button type="button" class="create-fail-copy" @click="handleCreateFailCopyServiceNo">
|
||||
{{ createFailInfo.serviceNo }}
|
||||
</button>
|
||||
)
|
||||
</template>
|
||||
</p>
|
||||
<p v-else class="create-fail-draft-note">
|
||||
{{ t('order.placeOrder.single.failNotSavedNote') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="create-fail-section">
|
||||
<div class="create-fail-label">{{ t('order.placeOrder.single.failHowToFix') }}</div>
|
||||
<ul class="create-fail-tips">
|
||||
<li v-for="(tip, idx) in createFailInfo.tips" :key="idx">{{ tip }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="create-fail-actions">
|
||||
<a-button
|
||||
:disabled="createFailCancelling || createFailSavingDraft"
|
||||
@click="handleCreateFailCancelSubmit"
|
||||
>
|
||||
{{
|
||||
createFailInfo.draftSaved
|
||||
? t('order.placeOrder.single.failCancelSubmit')
|
||||
: t('order.placeOrder.single.failAbandon')
|
||||
}}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="createFailInfo.suggestSupport || createFailInfo.draftSaved"
|
||||
:disabled="createFailCancelling || createFailSavingDraft"
|
||||
@click="handleCreateFailAskSupport"
|
||||
>
|
||||
{{ t('order.placeOrder.single.failAskSupport') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="createFailInfo.draftSaved"
|
||||
:disabled="createFailCancelling || createFailSavingDraft"
|
||||
@click="handleCreateFailGoDrafts"
|
||||
>
|
||||
{{ t('order.placeOrder.single.failGoDrafts') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="!createFailInfo.draftSaved"
|
||||
:disabled="createFailCancelling"
|
||||
:loading="createFailSavingDraft"
|
||||
@click="handleCreateFailSaveAsDraft"
|
||||
>
|
||||
{{ t('order.placeOrder.single.failSaveAsDraft') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="createFailSavingDraft"
|
||||
:loading="createFailCancelling"
|
||||
@click="handleCreateFailContinueEdit"
|
||||
>
|
||||
{{
|
||||
createFailInfo.draftSaved
|
||||
? t('order.placeOrder.single.failEditDraft')
|
||||
: t('order.placeOrder.single.failContinueEdit')
|
||||
}}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="senderModalVisible"
|
||||
:title="t('order.placeOrder.single.senderModalTitle')"
|
||||
@ -4232,6 +4553,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
.create-fail-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.create-fail-section {
|
||||
.create-fail-label {
|
||||
margin-bottom: 6px;
|
||||
color: #1d2129;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.create-fail-reason {
|
||||
margin: 0;
|
||||
color: #4e5969;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.create-fail-raw {
|
||||
margin: 6px 0 0;
|
||||
color: #86909c;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.create-fail-draft-note {
|
||||
margin: 8px 0 0;
|
||||
color: var(--app-primary-color);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.create-fail-copy {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.create-fail-tips {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #4e5969;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.create-fail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.single-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -113,6 +113,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="predeclareFailGuide" class="predeclare-fail-guide">
|
||||
<div class="predeclare-fail-guide__title">
|
||||
{{ $t('order.myOrders.drawer.failGuideTitle') }}
|
||||
</div>
|
||||
<p class="predeclare-fail-guide__reason">{{ predeclareFailGuide.reason }}</p>
|
||||
<p
|
||||
v-if="predeclareFailGuide.rawReason !== predeclareFailGuide.reason"
|
||||
class="predeclare-fail-guide__raw"
|
||||
>
|
||||
{{ $t('order.myOrders.drawer.failGuideRaw') }}:{{ predeclareFailGuide.rawReason }}
|
||||
</p>
|
||||
<ul class="predeclare-fail-guide__tips">
|
||||
<li v-for="(tip, idx) in predeclareFailGuide.tips" :key="idx">{{ tip }}</li>
|
||||
</ul>
|
||||
<div class="predeclare-fail-guide__actions">
|
||||
<a-button size="small" type="outline" @click="handleAskAdvisor">
|
||||
{{ $t('order.myOrders.drawer.failGuideAskSupport') }}
|
||||
</a-button>
|
||||
<a-button size="small" type="primary" @click="startBasicEdit">
|
||||
{{ $t('order.myOrders.drawer.failGuideEdit') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="basic-view-switch">
|
||||
<a-radio-group
|
||||
v-model="basicViewMode"
|
||||
@ -674,6 +698,14 @@
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 附件 -->
|
||||
<div v-if="activeTab === 'attachments'" class="tab-panel attachments-info">
|
||||
<OrderAttachmentsTab
|
||||
:order-id="orderId"
|
||||
:active="activeTab === 'attachments'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 绑定包裹/库存 -->
|
||||
<div v-if="activeTab === 'stockBind'" class="tab-panel stock-bind-info">
|
||||
<OrderStockBindTab
|
||||
@ -711,7 +743,9 @@ import { getDictListAll, type DictDto } from '@/api/system/dict';
|
||||
import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus';
|
||||
import AddOrderFeeModal from './AddOrderFeeModal.vue';
|
||||
import OrderStockBindTab from '../../myOrders/components/OrderStockBindTab.vue';
|
||||
import OrderAttachmentsTab from '../../components/OrderAttachmentsTab.vue';
|
||||
import { openAskAdvisor } from '@/composables/useAskAdvisor';
|
||||
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@ -767,6 +801,14 @@ const currentOrderStatusMeta = computed(() => {
|
||||
return code ? getOrderStatusMeta(code) : null;
|
||||
});
|
||||
|
||||
const predeclareFailGuide = computed(() => {
|
||||
const status = resolveOrderStatusCode(detail.value);
|
||||
if (status !== 'draft') return null;
|
||||
const reason = String(getOrderValue('predeclareFailReason') || '').trim();
|
||||
if (!reason) return null;
|
||||
return buildFailGuideFromReason(reason);
|
||||
});
|
||||
|
||||
type FieldInputType = 'text' | 'number' | 'select' | 'switch';
|
||||
|
||||
interface FormFieldDef {
|
||||
@ -1007,6 +1049,7 @@ const tabList = computed(() => [
|
||||
{ key: 'weight', label: t('order.myOrders.drawer.tabs.weight') },
|
||||
{ key: 'fee', label: t('order.myOrders.drawer.tabs.fee') },
|
||||
{ key: 'track', label: t('order.myOrders.drawer.tabs.track') },
|
||||
{ key: 'attachments', label: t('order.myOrders.drawer.tabs.attachments') },
|
||||
{ key: 'stockBind', label: t('order.myOrders.drawer.tabs.stockBind') },
|
||||
]);
|
||||
|
||||
@ -1921,7 +1964,7 @@ watch(
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'stockBind' }) => {
|
||||
openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'attachments' | 'stockBind' }) => {
|
||||
activeTab.value = opts?.tab || 'basic';
|
||||
basicViewMode.value = 'all';
|
||||
feeViewMode.value = 'all';
|
||||
@ -2101,6 +2144,50 @@ defineExpose({
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.predeclare-fail-guide {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: #fff7e8;
|
||||
border: 1px solid #ffe4ba;
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__reason {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
&__raw {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__tips {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.basic-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
type OrderDownstreamForwardResult,
|
||||
} from '@/api/order';
|
||||
import { notifyCustomerBalanceChanged } from '@/api/customer/accountFlow';
|
||||
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
|
||||
import OrderDetailDrawer from './components/OrderDetailDrawer.vue';
|
||||
import OrderPayConfirmModal from './components/OrderPayConfirmModal.vue';
|
||||
import MarkProblematicModal from './components/MarkProblematicModal.vue';
|
||||
@ -1220,8 +1221,11 @@
|
||||
if (colKey === 'predeclareFailReason') {
|
||||
const reason = rowData?.predeclareFailReason || value || '';
|
||||
if (!reason) return null;
|
||||
const escaped = escapeHtml(String(reason));
|
||||
return `<span title="${escaped}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
|
||||
const guide = buildFailGuideFromReason(String(reason));
|
||||
const display = guide.reason || String(reason);
|
||||
const escaped = escapeHtml(display);
|
||||
const title = escapeHtml(guide.rawReason || String(reason));
|
||||
return `<span title="${title}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
|
||||
}
|
||||
if (colKey === 'allocationPendingReason') {
|
||||
const reason = rowData?.allocationPendingReason || value || '';
|
||||
@ -1295,12 +1299,31 @@
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
loadOrderList();
|
||||
nextTick(async () => {
|
||||
await loadOrderList();
|
||||
loadTabCounts();
|
||||
await tryOpenOrderFromQuery();
|
||||
});
|
||||
});
|
||||
|
||||
async function tryOpenOrderFromQuery() {
|
||||
const rawId = route.query.orderId;
|
||||
const idStr = Array.isArray(rawId) ? rawId[0] : rawId;
|
||||
const id = Number(idStr);
|
||||
if (!Number.isFinite(id) || id <= 0) return;
|
||||
const editRaw = route.query.edit;
|
||||
const editStr = Array.isArray(editRaw) ? editRaw[0] : editRaw;
|
||||
const editBasic = String(editStr || '') === '1';
|
||||
try {
|
||||
await openDetailWithOptionalFetch({ id }, { editBasic });
|
||||
} finally {
|
||||
const nextQuery = { ...route.query } as Record<string, any>;
|
||||
delete nextQuery.orderId;
|
||||
delete nextQuery.edit;
|
||||
router.replace({ query: nextQuery });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.status,
|
||||
() => {
|
||||
@ -1316,6 +1339,14 @@
|
||||
loadTabCounts();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query.orderId,
|
||||
(orderId) => {
|
||||
if (!orderId) return;
|
||||
void tryOpenOrderFromQuery();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user