diff --git a/.cursor/skills/oms-auto-trigger-formula/SKILL.md b/.cursor/skills/oms-auto-trigger-formula/SKILL.md index 083108f..5de69a2 100644 --- a/.cursor/skills/oms-auto-trigger-formula/SKILL.md +++ b/.cursor/skills/oms-auto-trigger-formula/SKILL.md @@ -3,7 +3,8 @@ 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. + oversize/国家/客户/销售产品条件, 字段非空/为空/不能为null 必填校验, + or formula verification sample data. --- # OMS 自动触发公式编写 @@ -41,6 +42,8 @@ description: >- | 字符串字段用 `字段包含('值')` | `客户包含('阿里')` | `客户()=='阿里'` | | 数值/聚合用 `字段()` 再比较 | `实重()>5`、`单件最大长度()>60` | `实重>5`、`长度包含('60')` | | 布尔用 `字段()==1` / `==0` | `是否偏远()==1` | `是否偏远()==true` | +| **非空 / 不能为 null** | `国家有值()` 或 `国家包含('')` | `国家!=null`、`国家()==null`、`国家!=''` | +| **为空 / 未填** | `收件人邮编为空()` 或 `!收件人邮编有值()` | `邮编==null`、`邮编包含(null)` | | 字符串字面量单引号 | `'GB'`;内部单引号翻倍 `''` | `"GB"` | | 逻辑运算 | `&&` `\|\|` `!` | `and` `or` `not` / 中文「且」 | | 比较运算 | `==` `!=` `>` `>=` `<` `<=` | `=` `<>` | @@ -60,14 +63,33 @@ description: >- !销售产品包含('EX_ASP_HKDHL_YD') ``` +### 空值 / 必填(不能为 null) + +求值器**没有** `null` 字面量;禁止写 `字段==null` / `字段()!=null`。 + +| 意图 | 推荐写法 | 等价写法 | +|------|----------|----------| +| 字段有值(非 null、字符串非空白) | `国家有值()` | `国家包含('')` | +| 字段为空(null 或空白) | `国家为空()` | `!国家有值()` | +| 拦截「缺某字段」 | `收件人电话为空()` | `!收件人电话有值()` | +| 拦截「某字段必须填」再叠加条件 | `国家有值()&&国家包含('GB')` | — | + +语义说明: + +- **字符串 / 日期**:`有值` = 非 null 且非空白;`为空` = null 或空白。 +- **数值 / 布尔**:`有值` = 底层值非 null(`0` / `false` 仍算有值);`为空` = null。 +- 件重尺若业务上「0 也算未填」,用 `单件最大长度()<=0` 等比较,**不要**只靠 `有值()`(因为 0 仍有值)。 +- `字段()` 把 null 当成 `0`,**不能**用 `字段()==0` 区分「未填」与「真是 0」。 + ## 写公式工作流 -1. 从用户描述抽出:时机、操作、条件(国家/客户/产品/尺寸/重量/状态等)。 +1. 从用户描述抽出:时机、操作、条件(国家/客户/产品/尺寸/重量/状态、**必填/不能为空**等)。 2. 映射到**中文字段**(见下方常用表;完整目录见 [field-catalog.md](field-catalog.md))。 -3. 按字段类型选 `包含('')` 或 `字段()`。 -4. 用 `&&` / `||` / `!` / `()` 组合;复杂条件先括号。 -5. 对照 [examples.md](examples.md) 自检语法。 -6. 输出「时机 + 操作 + 公式 + 说明」;需要时给验证样例。 +3. 按字段类型选:`包含('值')` / `字段()` / **`有值()` / `为空()`**。 +4. 用户说「不能为空 / 必填 / 未填拦截」→ 用 `字段为空()`(缺了就命中)或前置 `字段有值()&&…`。 +5. 用 `&&` / `||` / `!` / `()` 组合;复杂条件先括号。 +6. 对照 [examples.md](examples.md) 自检语法。 +7. 输出「时机 + 操作 + 公式 + 说明」;需要时给验证样例。 不确定字段名时:优先读 `formulaMeta.ts` 的 `ORDER_TABLE_FIELDS` / `VIRTUAL_FORMULA_FIELDS`,或后端 `AutoTriggerFormulaMeta`。**禁止编造未注册中文名**。 @@ -95,10 +117,12 @@ description: >- | 是否偏远 / 是否COD / 是否拦截 | boolean | `是否偏远()==1` | | 实重 / 计费重 / 长度/宽度/高度 | number | `实重()>20`(件重尺;下单前优先用聚合字段) | -### 收件人(选填场景) +### 收件人(选填 / 必填场景) `收件人邮编包含('9')`、`收件人省州包含('CA')`、`收件人城市包含('London')` +必填拦截示例:`收件人手机为空()||收件人电话为空()`、`收件人邮编为空()`(手机=`收件人手机`,固话=`收件人电话`) + 完整字段见 [field-catalog.md](field-catalog.md)。 ## 时机与操作注意 @@ -112,6 +136,7 @@ description: >- - [ ] 每个字段都是注册中文名 - [ ] 字符串用 `包含('…')`,数字用 `字段() 比较` +- [ ] 必填/非空用 `有值()` 或 `包含('')`,为空用 `为空()`;无 `null` 字面量 - [ ] 括号匹配;`&&`/`||` 优先级按需加括号 - [ ] 国家一般用二字码 `国家包含('XX')` - [ ] 排除条件用 `!字段包含('…')` diff --git a/.cursor/skills/oms-auto-trigger-formula/examples.md b/.cursor/skills/oms-auto-trigger-formula/examples.md index 084bd6d..e37bb4c 100644 --- a/.cursor/skills/oms-auto-trigger-formula/examples.md +++ b/.cursor/skills/oms-auto-trigger-formula/examples.md @@ -72,6 +72,38 @@ 国家包含('GB')&&!(销售产品包含('EX_A')||销售产品包含('EX_B')) ``` +## 11. 收件人联系方式不能为空(缺手机拦截) + +```text +时机:下单 +操作:拦截 +公式:收件人手机为空()&&收件人电话为空() +说明:手机与固话都未填时拦截 +验证样例:手机空、电话空 → 命中;填了手机 → 不命中 +``` + +仅校验手机:`收件人手机为空()`;等价非空写法:`!收件人手机有值()` / `!收件人手机包含('')` + +## 12. 国家必填且仅为 GB 才警告 + +```text +时机:下单 +操作:警告 +公式:国家有值()&&国家包含('GB') +说明:已填国家且为国家码含 GB 时警告 +``` + +`国家包含('')` 与 `国家有值()` 等价(空串参数 = 有值校验)。 + +## 13. 邮编或城市缺一即问题件 + +```text +时机:下单 +操作:问题件 +公式:收件人邮编为空()||收件人城市为空() +说明:邮编或城市未填时记问题件 +``` + ## 常见写错对照 | 错误 | 正确 | @@ -82,3 +114,5 @@ | `是否偏远()==true` | `是否偏远()==1` | | `国家包含(GB)` | `国家包含('GB')` | | `长度()>60`(想表达单件最大) | `单件最大长度()>60` | +| `国家!=null` / `国家()==null` | `国家为空()` / `!国家有值()` | +| `电话!=''` | `收件人电话有值()` 或 `收件人电话包含('')` | diff --git a/.cursor/skills/oms-auto-trigger-formula/field-catalog.md b/.cursor/skills/oms-auto-trigger-formula/field-catalog.md index 717980b..f99e998 100644 --- a/.cursor/skills/oms-auto-trigger-formula/field-catalog.md +++ b/.cursor/skills/oms-auto-trigger-formula/field-catalog.md @@ -8,6 +8,8 @@ - `string` / `datetime` → `中文包含('…')` - `number` / 聚合 → `中文()` 再 `> >= < <= == !=` - `boolean` → `中文()==1` 或 `==0` +- **非空 / 不能为 null** → `中文有值()` 或 `中文包含('')`(任意类型) +- **为空 / 未填** → `中文为空()` 或 `!中文有值()` ## 常用 / 聚合 `_virtual` diff --git a/docs/IDE跨境物流-2个月获客营销方案-务实版.md b/docs/IDE跨境物流-2个月获客营销方案-务实版.md new file mode 100644 index 0000000..de033f7 --- /dev/null +++ b/docs/IDE跨境物流-2个月获客营销方案-务实版.md @@ -0,0 +1,207 @@ +# IDE 跨境物流 · 2 个月获客方案(务实版) + +> 基于现有 OMS:**渠道代理门户**(专属链接/海报、名下客户与运单、预估→待结算→已打款返佣)+ **会员/优惠券/C2C 裂变**。 +> 只写能马上干、系统已支持或运营线下能补齐的事;砍掉未上线能力(代理推代理终身分成、系统内提现、设备指纹四重防刷、毛利自动算销售提成等)。 + +--- + +## 一、目标怎么定(别只盯 2000) + +原方案「0 广告、18 个种子用户 → 2 个月 2000 实名」属于冲刺上限,不是承诺线。 + +| 档位 | 2 个月实名客户 | 含义 | +|------|----------------|------| +| **底线** | **600~800** | 代理按节奏邀约 + 老客转介绍正常运转 | +| **达标** | **1200~1500** | 代理周周有动作、销顾跟进到位 | +| **冲刺** | **2000** | 代理拉满 + 销售外拓 + 券/会员刺激同时生效 | + +**实名口径(建议统一):** 完成注册且资料齐全(手机/实名相关字段按你们现有校验),能下单。 +**归因口径:** 注册带 `?agent=` → 绑定代理;带 C2C 邀请码 → 走会员裂变;**二者互斥,代理优先于 C2C**(与系统一致)。 + +**核心 KPI(每周看,别堆一堆虚指标):** + +1. 新增实名数(总 / 代理归因 / C2C / 自然) +2. 活跃代理数(本周至少邀到 1 个新客,或名下有下单) +3. 新客 7 日内首单率 +4. 待结算返佣金额(派送成功/失败后 pending)与当月应打款 + +--- + +## 二、你们手里真正能用的武器 + +### 1. 渠道代理(主引擎,建议承担 60%~70% 新增) + +**系统已具备:** + +- 专属注册链接 + 编号 + 海报/二维码(代理端「邀请」页) +- 客户扫码/打开链接注册 → **自动绑到该代理名下**(长期有效,解绑/换绑走后台) +- 代理工作台:名下客户、名下运单(按客户归属看,不是只看下单瞬间快照) +- 返佣:下单写 **预估** → 运单派送成功/失败 → **待结算** → 运营线下打款标 **已打款**;取消/退款 → **作废** +- 比例、计费基数(运费等)在后台按代理配置;**代理改不了卖价** + +**务实玩法(不要搞「代理推代理 0.5% 终身」——系统没有):** + +| 做法 | 说明 | +|------|------| +| 统一起步比例 | 例如运费(或应收)**5%**,后台写死到代理规则;谁做得好再**人工调到 6%~7%**,不要幻想系统按 GMV 自动跳档 | +| 结算 | **每月固定日**按「待结算」明细 Excel/后台导出,对公或微信打款,再在系统点已打款;**不做 APP 提现** | +| 阶梯激励(线下规则即可) | 例:当月名下新客 ≥20 且有首单 → 次月比例 +1%;连续两月不活跃 → 商务电话沟通,必要时换人,**不必上「沉睡自动解绑」功能** | +| 代理画像 | 华人超市/货代同事/留学群主/同城跑腿——**能天天加人的人**,不是大 V | + +**代理话术(一句话):** +「用我的码注册,下单正常价,我帮你跟单;我按运费拿返佣,不另加你钱。」 + +### 2. 老客 C2C + 优惠券/会员(辅助,约 20%~25%) + +**系统已具备:** 邀请有礼、新人券、会员等级。 +**注意:** 已被代理绑定的客户,不要再指望用 C2C 抢归因。 + +务实用法: + +- 给**未绑代理**的老客发「邀请好友得券/积分」模板(企微/群发即可) +- 新客注册送固定新人券(面额按毛利算死,如首单满 X 减 Y),避免无期叠太多券 +- 会员等级用来做「下单越多权益越好」的留存,**不要当成拉新主渠道** + +### 3. 销售/顾问人工(约 10%~15%) + +后台是「绑定代理」+ 顾问跟进,**销售提成可以继续 Excel 算**,不必等系统算毛利 30%。 + +务实要求: + +- 每人每周固定外拓指标(微信群、老客户要名单、地推扫码) +- 新客注册后 **24h 内**顾问加微信、催首单 +- 代理名下客户顾问可协助服务,**返佣仍归代理**;顾问绩效看首单率/投诉,别和代理抢账 + +--- + +## 三、数字怎么拆(按底线 700 / 冲刺 2000 两套) + +假设种子:**代理 15 人 + 销售/顾问 3 人 + 老客池可触达约 200 人**(按你们实际改)。 + +### 底线情景(约 700 实名 / 8 周) + +| 来源 | 周均新增 | 8 周合计 | 怎么做到 | +|------|----------|----------|----------| +| 代理 | ~55 | ~440 | 15 人 × 人均周有效邀约 ~4 人(注册) | +| C2C/老客 | ~20 | ~160 | 老客每周发出去、核销券 | +| 销售外拓 | ~12 | ~100 | 3 人 × 周均 ~4 个有效注册 | +| **合计** | | **~700** | | + +### 冲刺情景(约 2000) + +| 来源 | 思路 | +|------|------| +| 代理扩到 40~50 人 | 第 2~3 周批量签约,人均仍按「每周 3~5 个注册」算,不按「一人拉 100」幻想 | +| 单代理爆款周 | 个别代理有社群,单周 30+,用周榜红包激励(现金线下发) | +| 销售集中地推/行业群 | 短期加码,不替代代理 | + +**转化漏斗(盯运营,别只盯注册):** + +``` +曝光/发卡 → 打开注册页 → 完成实名 → 7 日内首单 → 复购 +建议经验值:注册→首单 30%~45%(跨境偏决策慢,靠顾问+券推一把) +``` + +没有首单的「实名」对现金流帮助有限——周会同时报 **首单数**,不要只报注册数。 + +--- + +## 四、8 周作战表(能执行的那种) + +### 第 0 周(准备,3~5 天) + +- [ ] 后台把现有/新签代理规则配齐(比例、计费项、启用) +- [ ] 每人生成专属链接/海报,发到微信「仅文件传输助手」存档,再转发 +- [ ] 定结算日(如每月 5 日打上月「待结算」) +- [ ] 新人券上架:规则写清楚、预算封顶(如 2 个月总券成本 ≤ X 万) +- [ ] 建一个「代理作战群」:每日晒新增截图,不搞复杂 CRM + +### 第 1~2 周:冷启动 + +- 目标:代理全员当周至少 **1 个新注册**;总实名冲击底线周进度 +- 动作:一对一教代理怎么发链接;销售帮代理写朋友圈文案(3 条轮换即可) +- 系统:看代理工作台「名下客户」是否涨;返佣是否出现「预估」 + +### 第 3~4 周:加码与首单 + +- 目标:注册→首单率拉到 ≥35% +- 动作:顾问对「已注册未下单」名单每日跟进;代理侧只负责继续拉人 +- 激励:周榜前 3 名代理发 **200~500 元现金红包**(线下,不做系统功能) + +### 第 5~6 周:扩代理人数 + +- 从老代理、老客户里挖「能拉人」的,签成新代理(仍是一级,**没有多级分销**) +- 差的代理当面谈,无效就停用链接,别养僵尸号 + +### 第 7~8 周:冲刺与清算 + +- 冲刺档看是否追 1500/2000;达不到就收在达标档,别为数字乱打折伤毛利 +- 第一次正式月结:导出待结算 → 打款 → 系统标已打款 → 代理群公示「已结清」建立信任 + +--- + +## 五、钱怎么算(务实版) + +### 代理返佣 + +- **计费:** 与后台一致(常见:运费 × 约定比例);预估仅供代理查看,**以待结算为准** +- **发放:** 仅「派送成功/失败」进入待结算的明细;取消/退款已作废的不发 +- **不要承诺:** 预估金额 = 最终到账(体积重/改价/退款都会变) + +### 获客成本粗算(举例,请换成你们真实客单) + +假设:客单价运费 300 元,代理佣金 5% = 15 元/单;新客首单率 40%。 + +| 项目 | 粗算 | +|------|------| +| 拉 1 个实名(若最终 40% 下单) | 佣金期望约 15×0.4 ≈ **6 元/实名**(仅佣金,未计券) | +| 新人券 | 按核销计,建议单新客券成本压在 **10~20 元以内** | +| 周榜红包 | 每周 1000 元封顶,8 周 ≤ 8000 | +| **结论** | 比烧信息流便宜;关键是 **别把比例开到吃光毛利** | + +销售提成、房租、系统成本另表,**本方案不假装「零成本」**——说的是「零付费广告」。 + +### 明确不做(避免和业务/研发扯皮) + +| 原方案常见说法 | 务实处理 | +|----------------|----------| +| 代理推代理终身 0.5% | **不做**;要激励就给推荐人一次性奖金 | +| 系统内提现/余额 | **线下打款** + 状态「已打款」 | +| GMV 自动升档 5→6→7% | **运营改配置** | +| 沉睡 6 个月自动解绑 | **人工停用** | +| 设备指纹四重防刷 | 先靠手机号/实名+人工抽查;刷单直接清退 | +| 销售毛利 30% 系统自动算 | **Excel** | + +--- + +## 六、组织与日/周节奏 + +**日(代理群,15 分钟):** 昨日新增实名、首单、卡在注册页的问题。 +**周(内部会,30 分钟):** 三档目标进度、Top/Bottom 代理、待结算金额、券核销与毛利是否安全。 +**月:** 打款日 + 调比例/扩招/清退。 + +负责人建议就 **1 个运营盯代理群 + 顾问跟首单**,不要单独成立「增长中台」。 + +--- + +## 七、风险(实话) + +1. **代理不发链接** — 最大风险。对策:周活代理数入 KPI,连续 2 周 0 新增就约谈。 +2. **只注册不下单** — 对策:顾问名单制 + 首单券,不看虚荣注册数。 +3. **返佣预期纠纷** — 对策:海报/群公告写清「以签收后待结算为准,取消不作数」。 +4. **和 C2C 抢人** — 对策:代理链接进来的一律归代理;销售口头承诺别和系统对着干。 +5. **冲刺 2000 伤价** — 对策:打折/加佣要有毛利底线审批,运营一句话能停。 + +--- + +## 八、一页纸结论 + +1. **主靠代理专属链接拉新**,系统已通;返佣按 **预估 → 待结算 → 线下已打款** 建立信任。 +2. **C2C 和券做补充**,会员做留存,销售做人肉补位。 +3. **目标分底线 / 达标 / 冲刺**;过程盯「活跃代理 + 首单」,不唯注册数。 +4. **激励用现金红包和人工调比例**,不上多级分销、不上提现钱包。 +5. **8 周按表执行**,每月结清一次待结算——比任何「增长模型」都管用。 + +--- + +*文档版本:务实版(对齐当前 OMS 代理/营销能力)。原 Word 方案中的多级分销、自动提现、指纹防刷、毛利自动提成等,一律降级为「不做或线下替代」。* diff --git a/docs/渠道代理与返佣设计方案.md b/docs/渠道代理与返佣设计方案.md new file mode 100644 index 0000000..f1c773f --- /dev/null +++ b/docs/渠道代理与返佣设计方案.md @@ -0,0 +1,535 @@ +# 渠道代理与返佣设计方案 + +> **文档状态**:产品 + 概念模型 + 主流程 + 表结构草案(**轻量首版已收口,可开发**) +> **编写日期**:2026-10-29(同日按 §14 收窄为首版默认) +> **关联系统**:OMS 业务(`docs/oms系统设计-050801.md`)、RBAC(`docs/rbac系统设计-060801.md`)、数据权限(`docs/数据权限设计.md`)、客户裂变(`docs/客户裂变与营销体系设计方案.md`) +> **关联规范**:`docs/前端页面设计规范.md` +> **目标读者**:产品、运营、研发、测试 + +--- + +## 0 阅读指引 + +| 角色 | 优先阅读 | +| --- | --- | +| 产品 / 运营 | §1~§4、§6、§10 | +| 前端 | §5、§7、§9 | +| 后端 / DBA | §3~§8 | +| 测试 | §4、§6、§10、§11 | + +**分期对照(正文已按轻量默认)** + +| 阶段 | 内容 | 交付 | +| --- | --- | --- | +| **首版(P0+P1 一次交付)** | 开户、注册绑定、订单快照、返佣明细、运营列表、薄代理门户 | 能归属、能出应结清单、代理只读;**无冻结入账、无提现、无毛利基数** | +| **P2+** | 毛利基数、多行产品规则、冻结/余额、提现、自动 clawback | 账户化与精细规则 | + +> 代理 **不碰价格**;结算 **只做返佣明细 + 运营线下打款**。详细裁剪理由见 §14。 +--- + +## 1 背景与目标 + +### 1.1 业务背景 + +OMS 当前是 **单运营方部署**(非多租户 SaaS):客户挂 `t_customer`,订单挂 `CustomerId`,组织树只管内部员工。已有能力中与「代理」最接近的是: + +| 现有能力 | 说明 | 为何不够 | +| --- | --- | --- | +| C2C 邀请(`inviteCode` → `ReferrerCustomerId`) | 客户邀客户,推荐佣金 | 受益人是客户,不是渠道代理 | +| 员工顾问(`?advisor=` → `CustomerService`) | 内部客服软绑定 | 不是外部渠道方,无独立结算主体 | +| 价档 `PriceTierKey` | 客户卖价分档 | 代理方案 **明确不改价** | + +需要单独建设 **渠道代理**:通过专属注册链接获客,只看名下客户与订单,按返佣结算,且 **不引入 SaaS 多租户**。 + +### 1.2 建设目标 + +1. **获客归因**:代理专属注册链接,客户注册时绑定 `AgentId`。 +2. **数据可见**:代理仅能看名下客户及其订单(只读;不看渠道成本/应付)。 +3. **返佣结算**:按规则计提返佣 **明细**;首版运营按清单线下打款(不做冻结入账/提现)。 +4. **身份清晰**:代理是第三种业务主体(与员工、客户并列),独立登录入口。 + +### 1.3 设计原则 + +1. **不走 SaaS**:同一库、同一运营方;用 `AgentId` 做归属,不引入 `Fake.MultiTenant` / 按代理拆库。 +2. **代理不碰价格**:查价 / 下单 / 卖价仍走现有销售产品与 `PriceTierKey`;不做代理加价册。 +3. **结算只做返佣**:客户仍与平台钱包结算;代理只拿佣金。 +4. **注册绑定 + 下单快照**:客户长期挂 `AgentId`;订单落库时快照 `Order.AgentId`,事后换绑不影响历史单。 +5. **与 C2C 邀请拆开**:`AgentId` 与 `ReferrerCustomerId` 分字段、分账本,避免双发佣金。 +6. **组织树不参与**:代理不挂 `organization`;数据范围靠 `AgentId`,不靠 DataPerm 组织。 + +--- + +## 2 已确认业务规则 + +| # | 决策 | 落点 | +| --- | --- | --- | +| 1 | **不做多租户 / 不做代理改价** | 无代理价册;无租户连接串 | +| 2 | **结算模式 = 可配置返佣** | 首版基数仅 **运费 / 应收**;比例 + 可选销售产品列表挂在代理上(见 §5.4);**毛利为 P2+** | +| 3 | **绑定时机 = 注册** | 链接带 `agent` 参数;登录后不靠链接改绑 | +| 4 | **订单归属 = 快照** | `Order.AgentId` = 下单时 **主客户** 的 `AgentId` | +| 5 | **登录入口 = 代理门户** | **不从运营端登录**;运营端只做开户与规则配置 | +| 6 | **登录凭证 = 代理编号** | 账号为 `AgentCode` + 密码;`loginType=2` | +| 7 | **RBAC 定位** | 新增 `loginType=2` + 业务表 `t_agent` + 固定角色 `Agent`;权限码宜少(约 3 个) | +| 8 | **组织** | 代理不进组织树 | +| 9 | **与邀请互斥(首版)** | 注册请求带 `agent` 时只走代理归因,不写 `ReferrerCustomerId`、不发 C2C 推荐佣金 | +| 10 | **可见费用** | 代理可见客户侧应收/实付与返佣金额;**不可见** `payable` / 买价 / 毛利 | +| 11 | **换绑** | 首版不做自助换绑;运营改 **主客户** `AgentId`(仅影响之后新单) | +| 12 | **子客户归属** | **跟主客户**:快照前 `ResolveMainCustomer` | +| 13 | **计提触发** | **下单写 `estimated`(预计)**;**`deliverySuccess` / `deliveryFailed` 锁定为 `pending`**;取消/全额退 → `void`;同单按 `OrderId` 幂等 | +| 14 | **首版资金形态** | **只写返佣明细,不冻结、不入余额、不提现**;运营按清单线下打款;错账人工改明细(P2+ 再账户化) | + +--- + +## 3 定位:相对用户 / 角色 / 权限 / 组织 + +### 3.1 一句话 + +**代理 = 第三种业务主体**(并列于内部员工、客户)。 +登录壳子用 RBAC `user`;业务身份在 `t_agent`;功能门禁用角色权限;数据范围用 `AgentId`。**组织不参与。** + +### 3.2 三种主体对照 + +| | 内部员工 | 客户 | **渠道代理** | +| --- | --- | --- | --- | +| `loginType` | `0` | `1` | **`2`(新增)** | +| RBAC `user` | 有 | 有(桥接) | **有(桥接)** | +| 业务主表 | 无 | `t_customer` | **`t_agent`** | +| 角色 | 各类内部角色 | `Customer` / `CustomerSub_*` | **固定角色 `Agent`** | +| 组织 `OrganizationId` | 可挂 | **不挂** | **不挂** | +| 数据范围 | 组织 / DataPerm | 自己的 `CustomerId` | **自己的 `AgentId`** | +| 登录入口 | 运营端 | 客户门户 / H5 | **代理门户**(独立) | +| 登录账号 | 员工 Account | 手机/邮箱/客户编号 | **`AgentCode`(代理编号)** | +| 核心诉求 | 履约与运营 | 下单支付 | 获客可见 + 返佣 | + +### 3.3 分层关系 + +``` +┌─ RBAC:谁登录、能点什么功能 ─────────────────────────┐ +│ user ──user_role──► role ──► role_permission / menu │ +│ └── OrganizationId(仅员工) │ +└────────────────────────────────────────────────────────┘ + │ mapId / UserId + ▼ +┌─ OMS 业务身份:你是哪种人、数据归谁 ──────────────────┐ +│ 员工:靠组织 / DataPerm │ +│ 客户:t_customer(下单、余额) │ +│ 代理:t_agent(获客归属、返佣) │ +│ └── Customer.AgentId → Order.AgentId(快照) │ +└────────────────────────────────────────────────────────┘ +``` + +| 概念 | 管什么 | 代理怎么用 | +| --- | --- | --- | +| **用户 User** | 账号、Token、`mapId` | 开户时建 `user`,`t_agent.UserId` 指向它 | +| **角色 Role** | 权限码集合载体 | 预置 `Agent`,开通时绑定 | +| **权限 Permission** | 菜单 / API 门禁 | 代理门户只读与佣金相关码 | +| **组织 Organization** | 内部部门树 | **代理不进**;运营在后台「代理管理」菜单维护名单 | + +### 3.4 明确不做的混用 + +| 错误做法 | 原因 | +| --- | --- | +| 给员工加「代理」角色进运营端 | 混全量数据与运营能力;返佣主体不清 | +| 用客户账号兼代理 | 与自身下单、余额、C2C 邀请搅在一起 | +| 把代理挂进组织树当「下级公司」 | 组织不是渠道容器;会逼近伪 SaaS | + +--- + +## 4 登录入口 + +### 4.1 结论 + +**代理不从运营端登录。** + +| 入口 | 受众 | 代理 | +| --- | --- | --- | +| 运营端 / 管理后台 | 内部员工 `loginType=0` | ❌ 不登录;仅被运营管理 | +| 客户门户 / H5 | 发货客户 `loginType=1` | ❌ | +| **代理门户** | 渠道代理 `loginType=2` | ✅ | + +### 4.2 门户形态(实现可选,语义固定) + +- 同前端工程:登录 `/agent`,门户 `/agent/customers`、`/agent/orders` +- 或子域:`agent.<域名>` + +登录成功后只加载代理菜单,不出现运营菜单、不出现客户下单主流程(除非后续明确要「代客下单」,首版不做)。 + +### 4.3 运营侧职责(仍用员工账号) + +运营在后台完成:代理开户 / 启停、返佣三字段(基数类型 / 比例 / 可选销售产品)、人工改绑主客户 `AgentId`、查看 / 导出返佣明细并线下打款。 +这是 **「管代理」**,不是代理本人会话。 + +--- + +## 5 主流程:注册绑定 → 下单 → 结算 + +### 5.1 总览 + +``` +运营开通代理 → 生成注册码 / 链接 + → 客户经链接注册 → 写 Customer.AgentId + → 客户正常查价 / 下单 / 支付(价格逻辑不变) + → 订单快照 Order.AgentId(跟主客户) + → 下单 → 写预计返佣(estimated) + → 派送成功/失败 → 锁定为待结算(pending,金额重算快照) + → 运营按明细线下打款 + →(P2+)冻结入账 / 提现 / 毛利基数 / 自动 clawback +``` + +### 5.2 注册绑定 + +**链接示例** + +- PC:`/register?agent=AG001` +- H5 / 微信:同等 query;与现有 `inviteCode`、`advisor` 并列解析 + +**注册时(在现有创建客户流程旁路归因)** + +1. 解析 `agent` 码 → 查 `t_agent` 且状态为启用。 +2. 写入 `Customer.AgentId`、`AgentBindTime`(不写 Channel 类扩展字段)。 +3. 创建客户、RBAC 用户、价档等 **与今天一致**;代理不参与报价。 +4. 若同时带了 `inviteCode`:**首版以 `agent` 为准**,不写 `ReferrerCustomerId`。 + +**约束** + +- 只认注册时绑定;登录后带链接不改绑。 +- 无码注册 → `AgentId` 为空。 +- 已存在客户补绑:首版仅运营后台人工改。 + +### 5.3 下单快照 + +客户查价、下单、充值支付:**业务路径不因代理而改价**。 + +| 场景 | 归属 | +| --- | --- | +| 主客户已绑定代理 | 解析主客户 → 取其 `AgentId` 写入订单快照 | +| 子客户下单 | **跟主客户**:`Order.AgentId = ResolveMainCustomer(下单客户).AgentId`(与营销主客户解析对齐) | +| 事后运营改主客户 `AgentId` | **仅影响新单**;历史订单快照不变 | +| 主客户未绑定 | `Order.AgentId` 为空,不返佣 | + +快照伪代码: + +``` +mainCustomer = ResolveMarketingMainCustomer(orderCustomer) +Order.AgentId = mainCustomer.AgentId // 允许为空 +``` + +### 5.4 返佣结算(首版轻量) + +**触发(均计算提成)** + +| 订单状态 | 是否计提 | +| --- | --- | +| `deliverySuccess`(派送成功) | ✅ | +| `deliveryFailed`(派送失败) | ✅ | +| 其他状态 | ❌ | + +同一订单只生成 **一条** 明细(幂等:按 `OrderId` 判重);已有明细则跳过。失败↔成功来回 **不自动重算、不自动扣回**,错了运营改明细。 + +**公式** + +``` +返佣金额 = 基数金额(BaseAmount) × 返佣比例(CommissionRate) +``` + +**规则挂在 `t_agent` 上(不建独立规则表)** + +| 字段 | 说明 | +| --- | --- | +| `BaseType` | 首版仅 `freight`(运费)/ `receivable`(应收) | +| `CommissionRate` | 比例,如 `0.0300` | +| `SalesProductIds` | 可选;空 = 该代理全部产品都计提;非空 = 仅订单 **主销售产品** 命中列表才计提 | + +| `BaseType` | 含义 | 取数示意 | +| --- | --- | --- | +| `freight` | 运费 | 对齐 `t_order_fee` 约定运费项(实现时定字段) | +| `receivable` | 应收 | 客户侧应收合计(或约定应收口径) | +| `grossProfit` | 毛利 | **首版不做**(P2+);应收 − 应付,仅服务端算 | + +**其他约定** + +- 写明细时 **快照** `BaseType`、`BaseAmount`、`CommissionRate`、主销售产品;改代理规则不追溯旧单。 +- `BaseAmount ≤ 0`、无 `Order.AgentId`、或产品未命中列表 → 不生成明细。 +- **不做** 冻结天数、定时入账、自动 clawback。 + +**明细状态(首版两个就够)** + +| 状态 | 含义 | +| --- | --- | +| `pending` | 已计提,待运营线下结算 | +| `paid` | 运营已标记打款(可选;也可用备注/导出勾选,不做复杂工作流) | + +**资金边界** + +- 客户:继续与平台 `Balance` 结算(不变)。 +- 代理首版:**无佣金钱包、无提现**;应结金额以明细为准,运营线下打款。 +- P2+:再引入余额 / 冻结 / 提现 / 毛利 / 多行规则表。 + +### 5.5 代理可见范围 + +| 数据 | 可见 | 不可见 | +| --- | --- | --- | +| 名下客户列表 / 基础资料 | ✅ | 可按需脱敏手机号 | +| 名下订单列表 / 轨迹 / 状态 | ✅(`Order.AgentId = 我`) | 他代理数据 | +| 客户侧费用(应收 / 实付) | ✅ | `payable`、买价、毛利 | +| 返佣明细 | ✅ | — | +| 改价、改渠道、审充值、仓操作 | ❌ | — | + +列表查询 **必须** 服务端强制 `AgentId = 当前代理`。 +--- + +## 6 与现有邀请佣金的关系 + +| | C2C 邀请(已有) | 渠道代理(本方案) | +| --- | --- | --- | +| 注册参数 | `inviteCode` | `agent` | +| 客户字段 | `ReferrerCustomerId` | `AgentId` | +| 受益主体 | 另一个客户 | `t_agent` | +| 账本 | `t_referral_commission_*` + 客户佣金余额 | 首版仅 `t_agent_commission_detail`(无代理余额) | +| 价格 | 不碰 | 不碰 | +| 同时存在 | 首版互斥(有 `agent` 只走代理) | 同左 | + +可复用:状态触发挂钩、规则参数快照、按单幂等。首版 **不抄** 推荐佣金的冻结入账与会员门槛。 +**不要** 把代理塞进 `ReferrerCustomerId`。 + +--- + +## 7 数据模型草案 + +> 命名与审计字段遵循 OMS 惯例:`IsDeleted`、`CreateUserId`、`CreateTime`、`UpdateUserId`、`UpdateTime`。 +> **禁止** 新增库级 `UNIQUE KEY`(业务唯一性在应用层校验)。编码类字段可建普通索引。 + +### 7.1 `t_agent` — 代理主数据(含返佣三字段) + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `Id` | INT PK | | +| `UserId` | INT | 对应 RBAC `user.userid` / JWT `mapId` | +| `AgentCode` | VARCHAR(32) | **登录账号 + 注册链接编码**;应用层保证启用态唯一 | +| `AgentName` | VARCHAR(64) | 显示名 | +| `Phone` / `Email` | VARCHAR | 联系用(非登录主键) | +| `Password` | VARCHAR | 登录密码(对齐客户模式) | +| `Status` | 枚举 | 启用 / 停用 | +| `BaseType` | VARCHAR(32) | `freight` / `receivable`(首版) | +| `CommissionRate` | DECIMAL | 返佣比例 | +| `SalesProductIds` | VARCHAR/TEXT 可空 | 可选产品 Id 列表(JSON 或逗号);空=不限产品 | +| `Remark` | VARCHAR | | + +> **首版不建** `t_agent_commission_rule`、**不建** `FreezeDays` / `CommissionBalance`。按产品不同比例、毛利、余额 → P2+ 再扩。 + +### 7.2 `t_customer` 扩展 + +| 字段 | 说明 | +| --- | --- | +| `AgentId` | INT 可空;绑定代理(建议写在主客户上) | +| `AgentBindTime` | DATETIME 可空 | + +### 7.3 `t_order` 扩展 + +| 字段 | 说明 | +| --- | --- | +| `AgentId` | INT 可空;**下单快照**,返佣只认此字段 | + +### 7.4 `t_agent_commission_detail` — 返佣明细 + +| 字段 | 说明 | +| --- | --- | +| `AgentId` / `OrderId` / `CustomerId` | 归属 | +| `SalesProductId` | 订单主销售产品(快照,可空) | +| `BaseType` | 基数类型快照 | +| `BaseAmount` | 计提基数(快照) | +| `CommissionRate` | 比例(快照) | +| `CommissionAmount` | 金额 | +| `Status` | `pending` / `paid`(首版) | +| `TriggerOrderStatus` | `deliverySuccess` / `deliveryFailed` | +| `PaidTime` / `Remark` | 可选;运营标记已打款 | + +### 7.5 P2+ 再议(首版不做) + +- `t_agent_commission_rule`(多行产品规则) +- `CommissionBalance` / 冻结入账 / `t_agent_commission_withdraw` +- `BaseType=grossProfit`、自动 clawback + +### 7.6 RBAC / 认证扩展 + +| 项 | 说明 | +| --- | --- | +| `OmsLoginType.Agent = 2` | Claims `loginType=2` | +| 角色 `Code=Agent` | 开通时代入 `user_role` | +| 权限码(宜少) | `Agent.Portal`、`Agent.Customers.Query`、`Agent.Orders.Query`(佣金可并进订单页则可不单列) | +| 运营权限 | `Agent.Admin.Query`、`Agent.Admin.Create`、`Agent.Admin.Update`、`Agent.Admin.Rebind` | +--- + +## 8 接口与模块边界(草案) + +### 8.1 运营端(员工) + +| 能力 | 建议路径前缀 | +| --- | --- | +| 代理 CRUD / 启停 / 比例 | `/agent/admin/*` | +| 客户改绑 Agent | `/agent/admin/rebind` | +| 返佣明细查询 / 标记已打款 | `/agent/admin/commission/*` | + +### 8.2 认证 + +| 能力 | 说明 | +| --- | --- | +| 代理登录 | 扩展现有 `AuthService`:账号 = **`AgentCode`** + 密码,签发 `loginType=2` | +| 刷新令牌 | 与现有一致,按 `loginType` 分支 | + +### 8.3 代理门户 + +| 能力 | 建议路径前缀 | +| --- | --- | +| 名下客户分页 | `/agent/portal/customers/*` | +| 名下订单分页 / 详情(脱敏成本字段) | `/agent/portal/orders/*` | +| 返佣明细(可与订单同页) | `/agent/portal/commission/*` 或并入 orders | + +### 8.4 注册归因改动点 + +- PC / H5 / 微信注册入参增加 `agent`(或 `agentCode`)。 +- `CustomerService.CreateAsync`(及微信注册同源逻辑)增加 `AgentBinder`,与 `MarketingReferralBinder`、`StaffAdvisorBinder` 并列。 +- 下单创建订单处:`ResolveMainCustomer` → 写 `Order.AgentId`。 +- 订单状态机在 **`deliverySuccess` 与 `deliveryFailed`** 两处挂钩计提(幂等写明细)。 + +--- + +## 9 前端页面范围 + +### 9.1 运营端 + +- 代理列表 / 新建编辑 / 启停(路由 `/agentAdmin/list`) +- 返佣明细(路由 `/agentAdmin/commission`) +- (可选)客户详情展示所属代理、改绑入口 + +遵循 `docs/前端页面设计规范.md`(容器、工具栏、TablePro、权限码)。 + +**菜单管理(须同步)** + +1. 菜单管理新建一级菜单:名称「渠道代理」,路由 `/agentAdmin`,类型目录/菜单。 +2. 在该一级行点「同步子菜单及按钮」→ 写入「代理管理」「代理返佣」及按钮权限。 +3. 把权限赋给运营角色后刷新即可在侧栏看到。 + +### 9.2 代理门户(首版三页即可) + +- 登录页 `/agent`(`AgentCode` + 密码) +- 客户列表 `/agent/customers`(只读) +- 订单列表 + 返佣明细 `/agent/orders` + +样式可复用中后台组件,**路由与布局与运营端隔离**。 + +> **不要**把代理门户页面同步进运营端菜单管理;代理端独立登录,不走后台菜单树。 + +### 9.3 注册页 + +- 识别 `agent` 参数;提交注册时带回。 +- 无需向客户展示复杂代理信息(可选展示「渠道推荐」文案)。 + +--- + +## 10 分期与验收 + +### 首版(一次交付) + +- [ ] `t_agent`(含 `BaseType` / `CommissionRate` / `SalesProductIds`)+ 客户/订单 `AgentId` +- [ ] 运营开户、启停、改三字段规则、改绑主客户 +- [ ] 注册链接归因;有 `agent` 时不写 C2C 引荐 +- [ ] 下单快照跟主客户;`deliverySuccess` / `deliveryFailed` 幂等写明细 +- [ ] 运营返佣明细列表 / 导出 / 可选标记 `paid` +- [ ] `loginType=2`,`AgentCode` 登录 + 角色 `Agent` +- [ ] 代理门户三页只读 + 接口强制 `AgentId` +- [ ] **无** 冻结、余额入账、提现、毛利基数、自动 clawback + +### P2+ + +- [ ] `grossProfit` 基数 +- [ ] 多行产品规则表 +- [ ] 冻结 / `CommissionBalance` / 提现 +- [ ] 自动 clawback +- [ ] 改绑审计强化 + + +--- + +## 11 测试要点 + +1. 无码注册 → 无 `AgentId`、无返佣。 +2. 带码注册 → 绑定正确;停用代理无法绑定。 +3. `agent` + `inviteCode` 同传 → 只走代理。 +4. 子客户下单 → 快照 = 主客户 `AgentId`。 +5. 改主客户 `AgentId` → 旧单不变。 +6. 代理 A 查不到代理 B 数据。 +7. 代理接口无应付/买价/毛利字段。 +8. 成功与失败均可出明细;同单不重复。 +9. `SalesProductIds` 非空且主产品未命中 → 不发佣;改规则不追溯旧明细。 +10. `freight` / `receivable` 取数与账单口径一致。 +11. `AgentCode` 登录门户;进不了运营端。 +12. 无提现、无自动入余额;运营可标记 `paid` 或仅导出打款。 + +--- + +## 12 风险与边界 + +| 风险 | 应对 | +| --- | --- | +| 与裂变佣金双发 | 注册互斥;账本分离 | +| 运营端误授代理权限 | 独立 `loginType` + 独立门户 | +| 误做成多租户 | 方案明确禁止拆库/租户解析 | +| 代理要改价 | 本方案明确拒绝;若未来需要加价,另立方案,不在本文范围 | +| 派送失败仍发佣 | 已确认;`BaseAmount≤0` 不发;已有明细不自动追 | +| 改费后金额不准 | 快照不重算;运营改明细 | + +--- + +## 13 评审结论(含轻量收口) + +| # | 议题 | 结论 | +| --- | --- | --- | +| 1 | 返佣基数 | 业务可配运费/应收/毛利;**首版只做运费/应收**,毛利 P2+ | +| 2 | 计提触发 | `deliverySuccess` 与 `deliveryFailed` 均计提 | +| 3 | 子客户归属 | 跟主客户 | +| 4 | 登录凭证 | `AgentCode` + 密码 | +| 5 | 提现 / 余额 | **首版都不做**;明细 + 线下打款 | +| 6 | 规则形态 | **挂在 `t_agent` 三字段**,不建规则表 | +| 7 | clawback | 首版不做自动扣回 | + +**实现前仍需对齐的字段映射** + +1. `freight` / `receivable` 对应 `t_order_fee` 的具体费用项。 +2. 状态码以 `OrderStatusCodes.DeliverySuccess` / `DeliveryFailed` 为准。 +3. 产品限制按订单 **主销售产品** 匹配 `SalesProductIds`。 + +然后出 DDL(`sql/migrate_agent_module.sql`)与接口明细即可开发。 + +--- + +## 14 轻量原则与坑点(已采纳为正文默认) + +> §0~§10 已按本节收口;此处保留备忘,避免开发时加回复杂度。 + +### 14.1 首版最薄闭环 + +``` +开户(AgentCode + BaseType/Rate/产品列表) + → 注册绑定 → 下单快照(跟主客户) + → 派送成功/失败写一条明细 + → 运营导出线下打款 + → 代理门户只读 +``` + +### 14.2 明确推迟到 P2+ + +冻结入账、`CommissionBalance`、提现、`grossProfit`、多行规则表、自动 clawback。 + +### 14.3 必防坑 + +| 坑 | 对策 | +| --- | --- | +| 费用事后变更 | 明细快照;不自动重算 | +| 失败后退款 | `BaseAmount≤0` 不发;已发靠人工 | +| 状态来回 | 按 `OrderId` 幂等 | +| 子客户无 AgentId | 必须 `ResolveMainCustomer` | +| C2C 双发 | 有 `agent` 只走代理 | +| 门户串权 | 查询强制 `AgentId` | +| `loginType=2` 漏改 | 对照客户登录分支清单 | +| 只挂成功态 | 失败态也要挂钩 | diff --git a/src/api/agent/index.ts b/src/api/agent/index.ts new file mode 100644 index 0000000..beb0283 --- /dev/null +++ b/src/api/agent/index.ts @@ -0,0 +1,276 @@ +import { axiosInstance } from '@/api/rbac' +import { assertBusinessOk, omsUrl, parseListPayload } from '@/api/oms/common' + +export type AgentStatus = 0 | 1 | 2 + +export interface AgentDto { + id: number + agentCode: string + agentName: string + phone?: string + email?: string + status: AgentStatus + baseType: string + baseTypeName?: string + commissionRate: number + salesProductIds?: string + salesProductNames?: string[] + remark?: string + createTime?: string +} + +export interface AgentCommissionDto { + id: number + agentId: number + agentCode?: string + agentName?: string + orderId: number + serviceNo?: string + customerId: number + customerCode?: string + customerName?: string + salesProductId?: number + baseType: string + baseAmount: number + commissionRate: number + commissionAmount: number + status: string + triggerOrderStatus: string + paidTime?: string + remark?: string + createTime?: string +} + +export interface AgentPortalCustomerDto { + id: number + customerCode: string + customerName: string + phone?: string + agentBindTime?: string + createTime?: string + lastOrderTime?: string + orderCount30d?: number + pendingPaymentCount?: number +} + +export interface AgentPortalOrderDto { + id: number + serviceNo: string + trackNo?: string + customerId?: number + customerCode?: string + customerName?: string + productCode?: string + orderStatus?: string + consigneeCountryName?: string + receivableSubtotal?: number + createTime?: string +} + +export interface AgentWorkplaceStats { + customerCount: number + todayCreated: number + yesterdayCreated: number + dayOverDayGrowthPercent?: number | null + pendingPayment: number + shipping: number + problematic: number + deliverySuccess: number + deliveryFailed: number + estimatedCommissionCount?: number + estimatedCommissionAmount?: number + pendingCommissionCount: number + pendingCommissionAmount: number + paidCommissionAmount: number + weekNewCustomers: number + counts?: Record +} + +export interface AgentCommissionListQuery { + page?: number + pageSize?: number + status?: string + keyword?: string + createTimeStart?: string + createTimeEnd?: string +} + +export interface AgentWorkplaceTrendPoint { + label: string + start?: string + end?: string + orderCount: number + commissionAmount: number +} + +export interface AgentWorkplaceTrend { + dimension: string + points: AgentWorkplaceTrendPoint[] +} + +interface PageResult { + items: T[] + total: number +} + +function unwrapPage(data: any): PageResult { + const parsed = parseListPayload(data) + return { + items: (parsed.items || []) as T[], + total: parsed.total, + } +} + +/** 匿名校验代理编号(注册页) */ +export async function validateAgentCode(code: string): Promise<{ + valid: boolean + agentCode?: string + agentNameMasked?: string + message?: string +}> { + const r = await axiosInstance.get(omsUrl('/agent/public/validate-code'), { + params: { code }, + }) + assertBusinessOk(r.data) + const raw = (r.data?.data ?? r.data) as Record + return { + valid: Boolean(raw.valid ?? raw.Valid), + agentCode: String(raw.agentCode ?? raw.AgentCode ?? '').trim() || undefined, + agentNameMasked: String(raw.agentNameMasked ?? raw.AgentNameMasked ?? '').trim() || undefined, + message: String(raw.message ?? raw.Message ?? '').trim() || undefined, + } +} + +export const AgentAdminApi = { + async list(body: { + page?: number + pageSize?: number + keyword?: string + status?: number + }): Promise> { + const r = await axiosInstance.post(omsUrl('/agent/admin/list'), body) + return unwrapPage(r.data) + }, + + async create(body: Record): Promise { + const r = await axiosInstance.post(omsUrl('/agent/admin/create'), body) + assertBusinessOk(r.data) + }, + + async update(body: Record): Promise { + const r = await axiosInstance.post(omsUrl('/agent/admin/update'), body) + assertBusinessOk(r.data) + }, + + async rebind(body: { customerId: number; agentId?: number | null }): Promise { + const r = await axiosInstance.post(omsUrl('/agent/admin/rebind'), body) + assertBusinessOk(r.data) + }, + + async commissionList(body: { + page?: number + pageSize?: number + agentId?: number + orderId?: number + status?: string + }): Promise> { + const r = await axiosInstance.post(omsUrl('/agent/admin/commission/list'), body) + return unwrapPage(r.data) + }, + + async markPaid(ids: number[], remark?: string): Promise { + const r = await axiosInstance.post(omsUrl('/agent/admin/commission/mark-paid'), { ids, remark }) + assertBusinessOk(r.data) + }, +} + +export const AgentPortalApi = { + async profile(): Promise { + const r = await axiosInstance.get(omsUrl('/agent/portal/profile')) + assertBusinessOk(r.data) + return r.data?.data ?? r.data + }, + + async customers(body: { + page?: number + pageSize?: number + keyword?: string + }): Promise> { + const r = await axiosInstance.post(omsUrl('/agent/portal/customers/list'), body) + return unwrapPage(r.data) + }, + + async orders(body: { + page?: number + pageSize?: number + keyword?: string + }): Promise> { + const r = await axiosInstance.post(omsUrl('/agent/portal/orders/list'), body) + return unwrapPage(r.data) + }, + + async commissions(body: AgentCommissionListQuery): Promise> { + const r = await axiosInstance.post(omsUrl('/agent/portal/commission/list'), body) + return unwrapPage(r.data) + }, + + async exportCommissions(body: AgentCommissionListQuery): Promise { + const r = await axiosInstance.post(omsUrl('/agent/portal/commission/export'), body, { + responseType: 'blob', + }) + const blob = r.data instanceof Blob ? r.data : new Blob([r.data]) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `代理返佣明细_${new Date().toISOString().slice(0, 10)}.xlsx` + a.click() + URL.revokeObjectURL(url) + }, + + async workplaceStats(): Promise { + const r = await axiosInstance.get(omsUrl('/agent/portal/workplace/stats')) + assertBusinessOk(r.data) + const raw = (r.data?.data ?? r.data) as Record + const growthRaw = raw.dayOverDayGrowthPercent ?? raw.DayOverDayGrowthPercent + return { + customerCount: Number(raw.customerCount ?? raw.CustomerCount ?? 0), + todayCreated: Number(raw.todayCreated ?? raw.TodayCreated ?? 0), + yesterdayCreated: Number(raw.yesterdayCreated ?? raw.YesterdayCreated ?? 0), + dayOverDayGrowthPercent: + growthRaw == null || growthRaw === '' ? null : Number(growthRaw), + pendingPayment: Number(raw.pendingPayment ?? raw.PendingPayment ?? 0), + shipping: Number(raw.shipping ?? raw.Shipping ?? 0), + problematic: Number(raw.problematic ?? raw.Problematic ?? 0), + deliverySuccess: Number(raw.deliverySuccess ?? raw.DeliverySuccess ?? 0), + deliveryFailed: Number(raw.deliveryFailed ?? raw.DeliveryFailed ?? 0), + pendingCommissionCount: Number( + raw.pendingCommissionCount ?? raw.PendingCommissionCount ?? 0, + ), + pendingCommissionAmount: Number( + raw.pendingCommissionAmount ?? raw.PendingCommissionAmount ?? 0, + ), + paidCommissionAmount: Number(raw.paidCommissionAmount ?? raw.PaidCommissionAmount ?? 0), + weekNewCustomers: Number(raw.weekNewCustomers ?? raw.WeekNewCustomers ?? 0), + counts: (raw.counts ?? raw.Counts) as Record | undefined, + } + }, + + async workplaceTrend(days = 7): Promise { + const r = await axiosInstance.get(omsUrl('/agent/portal/workplace/trend'), { + params: { days }, + }) + assertBusinessOk(r.data) + const raw = (r.data?.data ?? r.data) as Record + const pointsRaw = (raw.points ?? raw.Points ?? []) as Array> + return { + dimension: String(raw.dimension ?? raw.Dimension ?? 'day'), + points: pointsRaw.map((p) => ({ + label: String(p.label ?? p.Label ?? ''), + start: p.start != null || p.Start != null ? String(p.start ?? p.Start) : undefined, + end: p.end != null || p.End != null ? String(p.end ?? p.End) : undefined, + orderCount: Number(p.orderCount ?? p.OrderCount ?? 0), + commissionAmount: Number(p.commissionAmount ?? p.CommissionAmount ?? 0), + })), + } + }, +} diff --git a/src/api/customer/index.ts b/src/api/customer/index.ts index 5633bda..43b44e9 100644 --- a/src/api/customer/index.ts +++ b/src/api/customer/index.ts @@ -34,6 +34,9 @@ export interface CustomerDto { salesman?: string | number salesPerson?: string | number customerService?: string | number + agentId?: number | null + agentCode?: string + agentName?: string customerGroup?: CustomerGroup memberLevelId?: number | null memberLevelName?: string @@ -53,6 +56,7 @@ export interface CustomerListParams { customerGroup?: number salesman?: string customerService?: string + agentId?: number sortField?: string sortOrder?: string } @@ -88,6 +92,8 @@ function mapUpdateBody(id: string | number, data: Partial) { remark: data.remark, salesman: data.salesman ?? data.salesPerson, customerService: data.customerService, + // 空串表示解绑;有值则按编号绑定 + agentCode: data.agentCode !== undefined ? String(data.agentCode ?? '').trim() : undefined, customerGroup: data.customerGroup, memberLevelId: data.memberLevelId, priceTierKey: data.priceTierKey, @@ -162,7 +168,12 @@ export const CustomerService = { }, async create( - data: Partial & { password?: string; inviteCode?: string; advisorCode?: string }, + data: Partial & { + password?: string + inviteCode?: string + advisorCode?: string + agentCode?: string + }, ): Promise { const body = { customerCode: data.customerCode || undefined, @@ -185,6 +196,7 @@ export const CustomerService = { priceTierKey: data.priceTierKey, inviteCode: data.inviteCode?.trim() || undefined, advisorCode: data.advisorCode?.trim() || undefined, + agentCode: data.agentCode?.trim() || undefined, } const r = await axiosInstance.post(omsUrl('/customer/create'), body) assertBusinessOk(r.data) diff --git a/src/api/customer/resolve.ts b/src/api/customer/resolve.ts index f644aa2..e197586 100644 --- a/src/api/customer/resolve.ts +++ b/src/api/customer/resolve.ts @@ -28,6 +28,14 @@ export function mapCustomerRow(r: Record): CustomerDto { remark: (r.remark ?? r.Remark) as string | undefined, salesPerson: (r.salesPerson ?? r.salesman ?? r.Salesman) as string | number | undefined, customerService: (r.customerService ?? r.CustomerService) as string | number | undefined, + agentId: (() => { + const raw = r.agentId ?? r.AgentId + if (raw == null || raw === '') return null + const n = Number(raw) + return Number.isFinite(n) && n > 0 ? n : null + })(), + agentCode: (r.agentCode ?? r.AgentCode) as string | undefined, + agentName: (r.agentName ?? r.AgentName) as string | undefined, lastOnlineTime: (r.lastOnlineTime ?? r.lastLoginTime ?? r.LastLoginTime) as string | undefined, lastOnlineIp: (r.lastOnlineIp ?? r.lastLoginIp ?? r.LastLoginIp) as string | undefined, createTime: (r.createTime ?? r.CreateTime ?? r.createdAt) as string | undefined, @@ -55,6 +63,9 @@ function normalizeCustomerDetail(data: CustomerDto): CustomerDto { ...data, accountBalance: data.accountBalance ?? data.balance, salesPerson: data.salesPerson ?? data.salesman, + agentId: data.agentId ?? null, + agentCode: data.agentCode, + agentName: data.agentName, lastOnlineTime: data.lastOnlineTime ?? data.lastLoginTime, lastOnlineIp: data.lastOnlineIp ?? data.lastLoginIp, wechatAvatarUrl: resolveAvatarUrl(data.wechatAvatarUrl ?? data.wechatAvatar), diff --git a/src/api/order/index.ts b/src/api/order/index.ts index 393521a..05da310 100644 --- a/src/api/order/index.ts +++ b/src/api/order/index.ts @@ -1519,6 +1519,8 @@ export interface PriceCheckRequest { postalCode?: string /** 选用的优惠券实例 Id */ couponInstanceId?: number + /** 代客查价客户(管理端/代理可选;影响会员价与券) */ + customerId?: number } export interface MarketingPriceBreakdown { diff --git a/src/api/rbac/generated/models/OmsLoginType.ts b/src/api/rbac/generated/models/OmsLoginType.ts index a903dfc..350d279 100644 --- a/src/api/rbac/generated/models/OmsLoginType.ts +++ b/src/api/rbac/generated/models/OmsLoginType.ts @@ -3,9 +3,10 @@ /* tslint:disable */ /* eslint-disable */ /** - * 登录主体类型:0=内部用户(管理端),1=客户 + * 登录主体类型:0=内部用户(管理端),1=客户,2=渠道代理 */ export enum OmsLoginType { User = 0, Customer = 1, + Agent = 2, } diff --git a/src/api/wechat/types.ts b/src/api/wechat/types.ts index 6f042fe..6864bfe 100644 --- a/src/api/wechat/types.ts +++ b/src/api/wechat/types.ts @@ -58,6 +58,8 @@ export interface WeChatCustomerRegisterInput { inviteCode?: string; /** 顾问邀请(来自 ?advisor=,值为员工账号,注册时绑定所属顾问) */ advisorCode?: string; + /** 渠道代理编号(来自 ?agent=;有值时不走 C2C 邀请) */ + agentCode?: string; wechat: WeChatProfileInput; } diff --git a/src/components.d.ts b/src/components.d.ts index 468d0f1..a07ad97 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -9,6 +9,7 @@ export {} declare module 'vue' { export interface GlobalComponents { ActiveFilterBar: typeof import('./components/HandsontablePro/ActiveFilterBar.vue')['default'] + AgentSideRail: typeof import('./components/agent-side-rail/index.vue')['default'] AiAdvisorShell: typeof import('./components/NanaAssistant/AiAdvisorShell.vue')['default'] ApiTest: typeof import('./components/ApiTest/index.vue')['default'] BasicLayoutLeftRight: typeof import('./components/BasicLayoutLeftRight/index.vue')['default'] diff --git a/src/components/agent-side-rail/index.vue b/src/components/agent-side-rail/index.vue new file mode 100644 index 0000000..1a8fe14 --- /dev/null +++ b/src/components/agent-side-rail/index.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/src/components/top-nav/RightTopUser.vue b/src/components/top-nav/RightTopUser.vue index 6f728ad..a416554 100644 --- a/src/components/top-nav/RightTopUser.vue +++ b/src/components/top-nav/RightTopUser.vue @@ -337,11 +337,15 @@ const displayNameInitial = computed(() => { return name ? name.slice(0, 1).toUpperCase() : '?'; }); -/** 后端占位邮箱,不当真实邮箱展示 */ +/** 后端占位邮箱,不当真实邮箱展示(客户 @noemail.local / 代理 @agent.local) */ function isPlaceholderEmail(value: string) { const v = value.trim().toLowerCase(); if (!v) return true; - return v.endsWith('@noemail.local') || v.includes('@noemail.'); + return ( + v.endsWith('@noemail.local') || + v.includes('@noemail.') || + v.endsWith('@agent.local') + ); } const accountSubtitle = computed(() => { diff --git a/src/composables/useWarehouseAdminCustomer.ts b/src/composables/useWarehouseAdminCustomer.ts index 117f186..a0cedb2 100644 --- a/src/composables/useWarehouseAdminCustomer.ts +++ b/src/composables/useWarehouseAdminCustomer.ts @@ -1,21 +1,25 @@ import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import { CustomerService } from '@/api/customer' +import { AgentPortalApi } from '@/api/agent' import { extractCustomerId, mapCustomerRow } from '@/api/customer/resolve' import { $baseMessage } from '@/utils' import { useRbacUserStore } from '@/store/modules/rbac-user' /** - * 仓储页管理端客户上下文: - * - 列表/汇总:未选客户 → 不传 customerId → 查全部 - * - 写入:管理端须显式选择客户 - * 客户门户登录时由后端会话强制归属,前端不传 customerId。 + * 仓储/下单页「非客户门户」客户上下文: + * - 管理端:列表未选客户 → 不传 customerId → 查全部;写入须显式选客户 + * - 渠道代理:仅能选名下绑定客户,写入须显式选客户 + * - 客户门户:由后端会话强制归属,前端不传 customerId */ export function useWarehouseAdminCustomer() { const { t } = useI18n() const rbacUserStore = useRbacUserStore() - const isAdmin = computed(() => !rbacUserStore.isCustomerPortal()) + const isCustomer = computed(() => rbacUserStore.isCustomerPortal()) + const isAgent = computed(() => rbacUserStore.isAgentPortal()) + /** 需前端显式选客户:管理端 + 渠道代理 */ + const isAdmin = computed(() => !isCustomer.value) /** undefined = 不筛选(查全部);仅 >0 才作为筛选条件 */ const customerId = ref() const customerOptions = ref>([]) @@ -35,17 +39,32 @@ export function useWarehouseAdminCustomer() { } customerLoading.value = true try { - const { items } = await CustomerService.getList({ page: 1, pageSize: 500 }) - customerOptions.value = (items || []) - .map((row) => mapCustomerRow(row as Record)) - .map((c) => { - const id = extractCustomerId(c as Record) - if (!id) return null - const name = c.customerName || c.customerCode || c.phone || `#${id}` - const code = c.customerCode ? ` (${c.customerCode})` : '' - return { label: `${name}${code}`, value: id } - }) - .filter((x): x is { label: string; value: number } => !!x) + if (isAgent.value) { + const { items } = await AgentPortalApi.customers({ page: 1, pageSize: 500 }) + customerOptions.value = (items || []) + .map((row) => { + const r = row as unknown as Record + const id = Number(r.id ?? r.Id ?? 0) + if (!id) return null + const name = String(r.customerName ?? r.CustomerName ?? r.customerCode ?? r.CustomerCode ?? `#${id}`) + const code = String(r.customerCode ?? r.CustomerCode ?? '') + const suffix = code ? ` (${code})` : '' + return { label: `${name}${suffix}`, value: id } + }) + .filter((x): x is { label: string; value: number } => !!x) + } else { + const { items } = await CustomerService.getList({ page: 1, pageSize: 500 }) + customerOptions.value = (items || []) + .map((row) => mapCustomerRow(row as Record)) + .map((c) => { + const id = extractCustomerId(c as Record) + if (!id) return null + const name = c.customerName || c.customerCode || c.phone || `#${id}` + const code = c.customerCode ? ` (${c.customerCode})` : '' + return { label: `${name}${code}`, value: id } + }) + .filter((x): x is { label: string; value: number } => !!x) + } } catch { customerOptions.value = [] } finally { @@ -56,6 +75,7 @@ export function useWarehouseAdminCustomer() { /** * 追加到 list/save 请求体。 * 管理端未选客户时显式去掉 customerId,保证「不选 = 查全部」。 + * 代理写入必须带 customerId(由 ensureMutationCustomerId 校验)。 */ function withCustomerId>(body: T): T & { customerId?: number } { const next = { ...body } as T & { customerId?: number } @@ -66,12 +86,17 @@ export function useWarehouseAdminCustomer() { return next } - /** 管理端写操作前校验已选客户;客户门户返回 undefined 由后端会话解析 */ + /** 管理端/代理写操作前校验已选客户;客户门户返回 undefined 由后端会话解析 */ function ensureMutationCustomerId(): number | undefined { if (!isAdmin.value) return undefined const id = resolveSelectedCustomerId() if (id == null) { - $baseMessage(t('warehouse.common.selectCustomerRequired'), 'warning') + $baseMessage( + isAgent.value + ? '请先选择名下客户再下单' + : t('warehouse.common.selectCustomerRequired'), + 'warning', + ) return undefined } return id @@ -83,6 +108,7 @@ export function useWarehouseAdminCustomer() { return { isAdmin, + isAgent, customerId, customerOptions, customerLoading, diff --git a/src/constants/pageButtonCatalog.ts b/src/constants/pageButtonCatalog.ts index 13062b7..7dfd45d 100644 --- a/src/constants/pageButtonCatalog.ts +++ b/src/constants/pageButtonCatalog.ts @@ -324,6 +324,10 @@ export const PAGE_BUTTON_PARENT_ALIASES: Record = { 'Marketing.Commission.View': 'Marketing.CommissionDetail', 'Marketing.Commission.Withdraw.View': 'Marketing.CommissionWithdraw', 'Marketing.Referral.View': 'Marketing.ReferralTree', + 'Agent.Admin.Query': 'Agent.Admin.List', + 'Agent.Admin.List': 'Agent.Admin.List', + 'Agent.Admin.Commission.Query': 'Agent.Admin.Commission', + 'Agent.Admin.Commission': 'Agent.Admin.Commission', 'User.Marketing.Invite': 'User.Marketing.Invite', 'User.Marketing.Member': 'User.Marketing.Member', 'User.Marketing.Coupons': 'User.Marketing.Coupons', @@ -942,6 +946,18 @@ const CORE_GROUPS: PageButtonGroup[] = [ { code: 'Marketing.Referral.Rebind', name: '改绑' }, ]), + // ── 渠道代理(运营端)── + B('Agent.Admin.List', [ + { code: 'Agent.Admin.Query', name: '查询' }, + { code: 'Agent.Admin.Create', name: '新增' }, + { code: 'Agent.Admin.Update', name: '修改' }, + { code: 'Agent.Admin.Rebind', name: '改绑客户' }, + ]), + B('Agent.Admin.Commission', [ + { code: 'Agent.Admin.Commission.Query', name: '查询' }, + { code: 'Agent.Admin.Commission.MarkPaid', name: '标记已打款' }, + ]), + // ── 知识库 ── B('Knowledge.Category', [ { code: 'Query', name: '查询' }, diff --git a/src/layout/default-layout.vue b/src/layout/default-layout.vue index 729c8b5..256aa73 100644 --- a/src/layout/default-layout.vue +++ b/src/layout/default-layout.vue @@ -1,14 +1,15 @@ @@ -38,6 +39,7 @@ import { useRbacUserStore } from '@/store/modules/rbac-user'; import TopNav from '@/components/top-nav/index.vue'; import CustomerSideRail from '@/components/customer-side-rail/index.vue'; + import AgentSideRail from '@/components/agent-side-rail/index.vue'; import Footer from '@/components/footer/index.vue'; import NanaAssistant from '@/components/NanaAssistant/index.vue'; import PortalSideDrawer from '@/views/user/portal-drawer/PortalSideDrawer.vue'; @@ -62,6 +64,10 @@ const isCustomerShell = computed( () => rbacUserStore.isCustomerPortal() && rbacUserStore.isLoggedIn, ); + const isAgentShell = computed( + () => rbacUserStore.isAgentPortal() && rbacUserStore.isLoggedIn, + ); + const isPortalShell = computed(() => isCustomerShell.value || isAgentShell.value); // 仅运营端需要为 fixed 顶栏留白 const adminContentStyle = computed(() => { diff --git a/src/locale/bootstrap-en-US.ts b/src/locale/bootstrap-en-US.ts index 09e3981..17b920c 100644 --- a/src/locale/bootstrap-en-US.ts +++ b/src/locale/bootstrap-en-US.ts @@ -1,4 +1,5 @@ import localeLogin from '@/views/login/locale/en-US'; +import localeRegister from '@/views/register/locale/en-US'; import localeWechat from '@/views/wechat/locale/en-US'; import localeH5Center from '@/views/h5/customer-center/locale/en-US'; import localeH5AdminCenter from '@/views/h5/admin-center/locale/en-US'; @@ -13,6 +14,7 @@ export default { 'app.language': 'Language', 'navbar.action.locale': 'Switch to English', ...localeLogin, + ...localeRegister, ...localeWechat, ...localeH5Track, ...localeH5Center, diff --git a/src/locale/bootstrap-zh-CN.ts b/src/locale/bootstrap-zh-CN.ts index bdfad40..c12ba39 100644 --- a/src/locale/bootstrap-zh-CN.ts +++ b/src/locale/bootstrap-zh-CN.ts @@ -1,4 +1,5 @@ import localeLogin from '@/views/login/locale/zh-CN'; +import localeRegister from '@/views/register/locale/zh-CN'; import localeWechat from '@/views/wechat/locale/zh-CN'; import localeH5Center from '@/views/h5/customer-center/locale/zh-CN'; import localeH5AdminCenter from '@/views/h5/admin-center/locale/zh-CN'; @@ -13,6 +14,7 @@ export default { 'app.language': '语言', 'navbar.action.locale': '切换为中文', ...localeLogin, + ...localeRegister, ...localeWechat, ...localeH5Track, ...localeH5Center, diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 59b0e82..2d2074c 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -66,6 +66,9 @@ export default { 'menu.finance.rechargeRecord': 'Recharge Records', 'menu.finance.accountFlow': 'Account Transactions', 'menu.finance.receivablePayable': 'Receivables & Payables', + 'menu.agentAdmin': 'Channel Agents', + 'menu.agentAdmin.list': 'Agents', + 'menu.agentAdmin.commission': 'Agent Commission', 'menu.marketing': 'Marketing', 'menu.marketing.marketingGuide': 'Getting Started', 'menu.marketing.memberLevel': 'Member Levels', @@ -4903,7 +4906,8 @@ export default { 'user.customer.fields.createdBy': 'Created By', 'user.customer.fields.updateTime': 'Update Time', 'user.customer.fields.updateBy': 'Update By', - 'user.customer.fields.salesPerson': 'Sales Person', + 'user.customer.fields.salesPerson': 'Bound Agent', + 'user.customer.fields.agent': 'Bound Agent', 'user.customer.fields.customerService': 'Advisor', 'user.customer.fields.role': 'Role', 'user.customer.role.customer': 'Customer', @@ -5843,6 +5847,8 @@ export default { 'channel.area.modal.editDetail': 'Edit area detail', 'channel.area.modal.deleteConfirm': 'Are you sure to delete?', 'channel.area.modal.deleteConfirmDetail': 'Confirm deletion?', + 'channel.area.modal.deleteConfirmDetails': 'Delete {count} selected area detail(s)?', + 'channel.area.message.needSelectDetail': 'Select detail rows to delete first', 'channel.area.modal.warningTitle': 'Warning', 'channel.area.table.serialNo': 'No.', 'channel.area.table.leftPostcode': 'Start postcode', diff --git a/src/locale/marketing/en-US.ts b/src/locale/marketing/en-US.ts index 2ec7d7a..da7c374 100644 --- a/src/locale/marketing/en-US.ts +++ b/src/locale/marketing/en-US.ts @@ -463,16 +463,41 @@ export default { 'marketing.register.title': 'Register', 'marketing.register.subtitle': 'Member pricing and welcome benefits', + 'marketing.register.brandHint': 'Welcome to IDE customer portal', + 'marketing.register.brandHint.web': 'Register with email & phone', + 'marketing.register.brandHint.wechat': 'Register with WeChat', + 'marketing.register.choose.web': 'Web register', + 'marketing.register.choose.webDesc': 'Fill in details and open your portal', + 'marketing.register.choose.wechat': 'WeChat register', + 'marketing.register.choose.wechatDesc': 'Scan to authorize and finish quickly', + 'marketing.register.backToChoose': '← Back to options', + 'marketing.register.referrerHint': 'Invited by {name} ({code})', 'marketing.register.invalidRef': 'Invalid invite code — you can still register', 'marketing.register.referrerLoading': 'Validating invite code…', 'marketing.register.referrerTimeout': 'Invite validation timed out. Check your network and refresh; you can still register.', + 'marketing.register.referrerBadge': 'Friend invite', + 'marketing.register.agentHint': '{name} · {code}', + 'marketing.register.agentBadge': 'Channel partner', + 'marketing.register.agentFallbackName': 'Partner', + 'marketing.register.agentField': 'Agent code (optional)', + 'marketing.register.agentPlaceholder': 'Enter agent code if you were referred by a partner', + 'marketing.register.agentLockedHint': 'You will be linked to the partner above after register', + 'marketing.register.agentInvalid': 'Agent code is invalid or disabled', + 'marketing.register.agentValidating': 'Validating agent code…', + 'marketing.register.agentPriorityHint': + 'Agent code filled — channel agent binding takes priority over friend invite', 'marketing.register.customerName': 'Name', + 'marketing.register.customerNamePlaceholder': 'Your name', 'marketing.register.phone': 'Phone', + 'marketing.register.phonePlaceholder': 'Mobile number', 'marketing.register.email': 'Email', + 'marketing.register.emailPlaceholder': 'Email address', 'marketing.register.password': 'Password', + 'marketing.register.passwordPlaceholder': '6+ letters and digits', 'marketing.register.confirmPassword': 'Confirm password', + 'marketing.register.confirmPasswordPlaceholder': 'Re-enter password', 'marketing.register.submit': 'Register', 'marketing.register.submitting': 'Registering…', 'marketing.register.success': 'Registered — please sign in', diff --git a/src/locale/marketing/zh-CN.ts b/src/locale/marketing/zh-CN.ts index c54872f..1736516 100644 --- a/src/locale/marketing/zh-CN.ts +++ b/src/locale/marketing/zh-CN.ts @@ -460,15 +460,39 @@ export default { 'marketing.register.title': '客户注册', 'marketing.register.subtitle': '注册即享会员价与新人福利', + 'marketing.register.brandHint': '欢迎注册 IDE 客户门户', + 'marketing.register.brandHint.web': '网页注册 IDE 客户账号', + 'marketing.register.brandHint.wechat': '微信扫码注册 IDE 客户账号', + 'marketing.register.choose.web': '网页注册', + 'marketing.register.choose.webDesc': '填写资料,立即开通客户门户', + 'marketing.register.choose.wechat': '微信注册', + 'marketing.register.choose.wechatDesc': '扫码授权,快捷完成注册', + 'marketing.register.backToChoose': '← 返回选择注册方式', + 'marketing.register.referrerHint': '您正通过 {name}({code})的邀请注册', 'marketing.register.invalidRef': '邀请码无效或已失效,您仍可正常注册', 'marketing.register.referrerLoading': '正在校验邀请码…', 'marketing.register.referrerTimeout': '邀请码校验超时,请检查网络后刷新;您仍可继续注册', + 'marketing.register.referrerBadge': '好友邀请', + 'marketing.register.agentHint': '{name} · {code}', + 'marketing.register.agentBadge': '渠道推荐', + 'marketing.register.agentFallbackName': '渠道代理', + 'marketing.register.agentField': '代理编号(选填)', + 'marketing.register.agentPlaceholder': '有渠道推荐人可填写其代理编号', + 'marketing.register.agentLockedHint': '注册完成后将关联上方推荐渠道', + 'marketing.register.agentInvalid': '代理编号无效或已停用', + 'marketing.register.agentValidating': '正在校验代理编号…', + 'marketing.register.agentPriorityHint': '已填写代理编号,将优先绑定渠道代理(不走好友邀请)', 'marketing.register.customerName': '客户名称', + 'marketing.register.customerNamePlaceholder': '请输入客户名称', 'marketing.register.phone': '手机号', + 'marketing.register.phonePlaceholder': '请输入手机号', 'marketing.register.email': '邮箱', + 'marketing.register.emailPlaceholder': '请输入邮箱', 'marketing.register.password': '密码', + 'marketing.register.passwordPlaceholder': '6 位以上字母数字组合', 'marketing.register.confirmPassword': '确认密码', + 'marketing.register.confirmPasswordPlaceholder': '请再次输入密码', 'marketing.register.submit': '注册', 'marketing.register.submitting': '注册中…', 'marketing.register.success': '注册成功,请登录', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index b0c6bd4..aa3e1c3 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -66,6 +66,9 @@ export default { 'menu.finance.rechargeRecord': '充值记录', 'menu.finance.accountFlow': '资金流水', 'menu.finance.receivablePayable': '应收应付', + 'menu.agentAdmin': '渠道代理', + 'menu.agentAdmin.list': '代理管理', + 'menu.agentAdmin.commission': '代理返佣', 'menu.marketing': '营销', 'menu.marketing.marketingGuide': '新手指引', 'menu.marketing.memberLevel': '会员等级', @@ -4926,7 +4929,8 @@ export default { 'user.customer.fields.createdBy': '创建人', 'user.customer.fields.updateTime': '更新时间', 'user.customer.fields.updateBy': '更新人', - 'user.customer.fields.salesPerson': '所属销售', + 'user.customer.fields.salesPerson': '绑定代理', + 'user.customer.fields.agent': '绑定代理', 'user.customer.fields.customerService': '所属顾问', 'user.customer.fields.role': '角色', 'user.customer.role.customer': '客户', @@ -5851,6 +5855,8 @@ export default { 'channel.area.modal.editDetail': '修改分区明细', 'channel.area.modal.deleteConfirm': '确定删除吗?', 'channel.area.modal.deleteConfirmDetail': '是否确认删除?', + 'channel.area.modal.deleteConfirmDetails': '确定删除选中的 {count} 条分区明细吗?', + 'channel.area.message.needSelectDetail': '请先勾选要删除的明细行', 'channel.area.modal.warningTitle': '温馨提示', 'channel.area.table.serialNo': '序号', 'channel.area.table.leftPostcode': '起始邮编', diff --git a/src/router/constants.ts b/src/router/constants.ts index 686ee86..8a82b52 100644 --- a/src/router/constants.ts +++ b/src/router/constants.ts @@ -2,6 +2,14 @@ export const WHITE_LIST = [ { name: 'notFound', children: [] }, { name: 'login', children: [] }, { name: 'adminLogin', children: [] }, + { name: 'agentLogin', children: [] }, + { name: 'AgentPortalWorkplace', children: [] }, + { name: 'AgentPortalInvite', children: [] }, + { name: 'AgentPortalCustomers', children: [] }, + { name: 'AgentPortalOrders', children: [] }, + { name: 'AgentPortalCommission', children: [] }, + { name: 'AgentPortalPriceCheck', children: [] }, + { name: 'AgentPortalPlaceOrder', children: [] }, { name: 'siteRoot', children: [] }, ]; diff --git a/src/router/dynamicRouter.ts b/src/router/dynamicRouter.ts index dae77fe..5e5d75f 100644 --- a/src/router/dynamicRouter.ts +++ b/src/router/dynamicRouter.ts @@ -5,6 +5,7 @@ import { useAuthStore } from '@/store'; import router from '@/router'; import { useRbacUserStore } from '@/store/modules/rbac-user'; import { resolvePostLogoutLogin } from '@/utils/login-portal'; +import { OmsLoginType } from '@/api/rbac'; const HOME_URL = '/home/workplace'; // 默认首页路径 @@ -57,6 +58,12 @@ export const initDynamicRouter = async (options?: { }; try { + // 渠道代理门户:静态路由 /agent/customers|orders,不依赖运营端菜单 + if (rbacUserStore.loginPortalType === OmsLoginType.Agent) { + clearDynamicRoutes(); + return; + } + // 先清除旧的动态路由 clearDynamicRoutes(); diff --git a/src/router/guard/permission.ts b/src/router/guard/permission.ts index c170976..211dab2 100644 --- a/src/router/guard/permission.ts +++ b/src/router/guard/permission.ts @@ -18,6 +18,8 @@ import { isLoginRouteName, getDefaultHomePathByType, canAccessCustomerPortalRoute, + isAgentPortalPath, + AGENT_LOGIN_PATH, resolveCustomerEntryPath, resolvePostLogoutLogin, } from '@/utils/login-portal'; @@ -47,6 +49,32 @@ export default function setupPermissionGuard(router: Router) { !isLogin() && (path.startsWith('/h5') || path.startsWith('/wechat/') || path.startsWith('/track/')); + // 渠道代理只能进代理门户;非代理不能进代理业务页。 + // 官网 `/` 始终可进(登录页 Logo 回官网);未登录时勿用残留的 rbac_login_type 误拦。 + const isAgent = rbacUserStore.loginPortalType === OmsLoginType.Agent; + const isAgentPortalRoute = + Boolean(to.meta?.agentPortalOnly) || + (isAgentPortalPath(path) && path !== AGENT_LOGIN_PATH); + const isOfficialSite = path === '/' || to.name === 'siteRoot'; + if ( + isLogin() && + isAgent && + !isAgentPortalRoute && + !isOfficialSite && + !isLoginRouteName(to.name as string) + ) { + next({ path: getDefaultHomePathByType(OmsLoginType.Agent), replace: true }); + return; + } + if (isLogin() && !isAgent && isAgentPortalRoute) { + Message.warning('该页面仅渠道代理可访问,请使用代理账号登录'); + next({ + path: getDefaultHomePathByType(rbacUserStore.loginPortalType), + replace: true, + }); + return; + } + if ( !isGuestH5Entry && !canAccessCustomerPortalRoute(rbacUserStore.loginPortalType, { @@ -100,13 +128,14 @@ export default function setupPermissionGuard(router: Router) { return; } - // H5 / 微信 / 轨迹为静态路由,不依赖 PC 菜单动态路由。 - // 否则扫码进 /h5/* 时 initDynamicRouter 因无菜单会 logout,表现为「登录成功但不跳转」。 + // H5 / 微信 / 轨迹 / 代理门户为静态路由,不依赖 PC 菜单动态路由。 + // 否则扫码进 /h5/* 或代理登录后 initDynamicRouter 因无菜单会 logout,表现为「登录成功后空白」。 const isStaticCustomerShell = path === '/' || path.startsWith('/h5') || path.startsWith('/wechat/') || - path.startsWith('/track/'); + path.startsWith('/track/') || + isAgentPortalPath(path); // 动态路由未就绪:与 enterSystemAfterLogin 共用单例,避免重复 init if ( diff --git a/src/router/guard/userLoginInfo.ts b/src/router/guard/userLoginInfo.ts index 7556148..8a09c13 100644 --- a/src/router/guard/userLoginInfo.ts +++ b/src/router/guard/userLoginInfo.ts @@ -12,6 +12,7 @@ import { CUSTOMER_H5_HOME_PATH, getDefaultHomePathByType, isAdminH5Path, + isAgentPortalPath, isLoginRouteName, resolveCustomerEntryPath, resolvePostLogoutLogin, @@ -256,6 +257,18 @@ export default function setupUserLoginInfoGuard(router: Router) { applyRbacUserToUserStore(rbacUserStore.userInfo); } + // 代理门户不依赖运营菜单,有用户信息即可放行 + if ( + rbacUserStore.loginPortalType === OmsLoginType.Agent && + isAgentPortalPath(String(to.path || '')) + ) { + if (rbacUserStore.userInfo) { + applyRbacUserToUserStore(rbacUserStore.userInfo); + } + next(); + return; + } + const needMenus = !(rbacUserStore.menus?.length > 0); if (!needMenus && (userStore.name || userStore.accountId)) { next(); diff --git a/src/router/index.ts b/src/router/index.ts index e789b20..d6f5a36 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -48,6 +48,101 @@ const router = createRouter({ hideInMenu: true, }, }, + { + path: '/agent', + name: 'agentLogin', + component: () => import('@/views/login/index.vue'), + meta: { + requiresAuth: false, + loginPortal: 'agent', + noAffix: true, + hideInMenu: true, + }, + }, + // 门户:与客户同 DEFAULT_LAYOUT 壳;子路由用绝对 path,父级不占 /agent,以免抢走登录页 + { + path: '/agent-portal', + name: 'AgentPortal', + component: () => import('@/layout/default-layout.vue'), + redirect: '/agent/workplace', + meta: { + requiresAuth: true, + hideInMenu: true, + agentPortalOnly: true, + }, + children: [ + { + path: '/agent/workplace', + name: 'AgentPortalWorkplace', + component: () => import('@/views/agent/workplace/index.vue'), + meta: { + requiresAuth: true, + hideInMenu: true, + agentPortalOnly: true, + }, + }, + { + path: '/agent/invite', + name: 'AgentPortalInvite', + component: () => import('@/views/agent/invite/index.vue'), + meta: { + requiresAuth: true, + hideInMenu: true, + agentPortalOnly: true, + }, + }, + { + path: '/agent/customers', + name: 'AgentPortalCustomers', + component: () => import('@/views/agent/customers/index.vue'), + meta: { + requiresAuth: true, + hideInMenu: true, + agentPortalOnly: true, + }, + }, + { + path: '/agent/orders', + name: 'AgentPortalOrders', + component: () => import('@/views/order/myOrders/index.vue'), + meta: { + requiresAuth: true, + hideInMenu: true, + agentPortalOnly: true, + }, + }, + { + path: '/agent/commission', + name: 'AgentPortalCommission', + component: () => import('@/views/agent/commission/index.vue'), + meta: { + requiresAuth: true, + hideInMenu: true, + agentPortalOnly: true, + }, + }, + { + path: '/agent/priceCheck', + name: 'AgentPortalPriceCheck', + component: () => import('@/views/order/priceCheck/index.vue'), + meta: { + requiresAuth: true, + hideInMenu: true, + agentPortalOnly: true, + }, + }, + { + path: '/agent/placeOrder', + name: 'AgentPortalPlaceOrder', + component: () => import('@/views/order/placeOrder/index.vue'), + meta: { + requiresAuth: true, + hideInMenu: true, + agentPortalOnly: true, + }, + }, + ], + }, { path: '/track/query', name: 'H5TrackQuery', diff --git a/src/router/routes/newModules/agent.ts b/src/router/routes/newModules/agent.ts new file mode 100644 index 0000000..2673bff --- /dev/null +++ b/src/router/routes/newModules/agent.ts @@ -0,0 +1,50 @@ +import { DEFAULT_LAYOUT } from '../base'; +import { AppRouteRecordRaw } from '../types'; + +/** + * 运营端「渠道代理」模块。 + * 菜单管理:先建一级菜单 route=/agentAdmin,再点「同步子菜单及按钮」。 + * 代理门户(/agent、/agent/customers)不在此模块,不要同步进运营菜单。 + */ +const AGENT_ADMIN: AppRouteRecordRaw = { + path: '/agentAdmin', + name: 'AgentAdmin', + component: DEFAULT_LAYOUT, + meta: { + locale: 'menu.agentAdmin', + icon: 'icon-user-group', + requiresAuth: true, + order: 8, + }, + children: [ + { + path: 'list', + name: 'AgentAdminList', + component: () => import('@/views/agent-admin/list/index.vue'), + meta: { + locale: 'menu.agentAdmin.list', + requiresAuth: true, + // 供菜单同步写入 RBAC;与 pageButtonCatalog 对齐 + menuPermission: 'Agent.Admin.List', + permission: 'Agent.Admin.Query', + order: 1, + icon: 'icon-user', + }, + }, + { + path: 'commission', + name: 'AgentAdminCommission', + component: () => import('@/views/agent-admin/commission/index.vue'), + meta: { + locale: 'menu.agentAdmin.commission', + requiresAuth: true, + menuPermission: 'Agent.Admin.Commission', + permission: 'Agent.Admin.Commission.Query', + order: 2, + icon: 'icon-gift', + }, + }, + ], +}; + +export default AGENT_ADMIN; diff --git a/src/router/typings.d.ts b/src/router/typings.d.ts index 614728e..3bf6e80 100644 --- a/src/router/typings.d.ts +++ b/src/router/typings.d.ts @@ -13,6 +13,7 @@ declare module 'vue-router' { menuPermission?: string; customerPortalOnly?: boolean; // If true, only customer portal can access adminPortalOnly?: boolean; // If true, only admin (internal user) portal can access + agentPortalOnly?: boolean; // If true, only channel agent portal can access hideChildrenInMenu?: boolean; // if set true, the children are not displayed in the side menu activeMenu?: string; // if set name, the menu will be highlighted according to the name you set order?: number; // Sort routing menu items. If set key, the higher the value, the more forward it is diff --git a/src/store/modules/rbac-user.ts b/src/store/modules/rbac-user.ts index e0414c8..352a528 100644 --- a/src/store/modules/rbac-user.ts +++ b/src/store/modules/rbac-user.ts @@ -123,6 +123,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => { function readStoredLoginTypeRaw(): OmsLoginType | null { const raw = getAuthStorage().getItem(LOGIN_TYPE_KEY) ?? localStorage.getItem(LOGIN_TYPE_KEY) if (raw === String(OmsLoginType.Customer)) return OmsLoginType.Customer + if (raw === String(OmsLoginType.Agent)) return OmsLoginType.Agent if (raw === String(OmsLoginType.User)) return OmsLoginType.User return null } @@ -172,6 +173,10 @@ export const useRbacUserStore = defineStore('rbacUser', () => { return loginPortalType.value === OmsLoginType.Customer } + function isAgentPortal(): boolean { + return loginPortalType.value === OmsLoginType.Agent + } + // 保存 Token 信息 function saveTokens(result: LoginResultDto, options?: { sessionOnly?: boolean }) { if (!result.accessToken) { @@ -509,6 +514,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => { loginPortalType, getLoginRouteName, isCustomerPortal, + isAgentPortal, resolveLoginTypeForRedirect, syncLoginPortalTypeFromToken, getAccessTokenRealExpiresAt, diff --git a/src/utils/agentInviteStorage.ts b/src/utils/agentInviteStorage.ts new file mode 100644 index 0000000..7d92039 --- /dev/null +++ b/src/utils/agentInviteStorage.ts @@ -0,0 +1,108 @@ +/** 渠道代理注册归因暂存:7 天 last-touch(与 invite / advisor 并列,参数名为 agent) */ + +const AGENT_INVITE_STORAGE_KEY = 'oms_agent_invite_v1' +const AGENT_INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +type AgentInvitePayload = { + code: string + /** URL 带来的锁定码,手填不应覆盖 */ + locked?: boolean + ts: number +} + +function safeParse(raw: string | null): AgentInvitePayload | null { + if (!raw) return null + try { + const parsed = JSON.parse(raw) as AgentInvitePayload + if (!parsed?.code || typeof parsed.ts !== 'number') return null + return { + code: String(parsed.code).trim(), + locked: Boolean(parsed.locked), + ts: parsed.ts, + } + } catch { + return null + } +} + +function isExpired(ts: number, now = Date.now()): boolean { + return now - ts > AGENT_INVITE_TTL_MS +} + +export function stashAgentInvite(agentCode?: string | null, locked = false): void { + const code = String(agentCode ?? '').trim() + if (!code) return + const payload: AgentInvitePayload = { code, locked, ts: Date.now() } + const raw = JSON.stringify(payload) + try { + localStorage.setItem(AGENT_INVITE_STORAGE_KEY, raw) + } catch { + /* ignore */ + } + try { + sessionStorage.setItem(AGENT_INVITE_STORAGE_KEY, raw) + } catch { + /* ignore */ + } +} + +export function readStashedAgentInvite(): { code: string; locked: boolean } { + const now = Date.now() + const fromSession = safeParse( + (() => { + try { + return sessionStorage.getItem(AGENT_INVITE_STORAGE_KEY) + } catch { + return null + } + })(), + ) + if (fromSession && !isExpired(fromSession.ts, now)) { + return { code: fromSession.code, locked: Boolean(fromSession.locked) } + } + + const fromLocal = safeParse( + (() => { + try { + return localStorage.getItem(AGENT_INVITE_STORAGE_KEY) + } catch { + return null + } + })(), + ) + if (fromLocal && !isExpired(fromLocal.ts, now)) { + stashAgentInvite(fromLocal.code, Boolean(fromLocal.locked)) + return { code: fromLocal.code, locked: Boolean(fromLocal.locked) } + } + + if (fromLocal || fromSession) { + clearStashedAgentInvite() + } + return { code: '', locked: false } +} + +export function clearStashedAgentInvite(): void { + try { + localStorage.removeItem(AGENT_INVITE_STORAGE_KEY) + } catch { + /* ignore */ + } + try { + sessionStorage.removeItem(AGENT_INVITE_STORAGE_KEY) + } catch { + /* ignore */ + } +} + +/** 解析代理归因:query.agent → 7 天暂存 */ +export function resolveAgentInvite(query: Record): { + code: string + locked: boolean +} { + const fromQuery = String(query.agent ?? query.agentCode ?? '').trim() + if (fromQuery) { + stashAgentInvite(fromQuery, true) + return { code: fromQuery, locked: true } + } + return readStashedAgentInvite() +} diff --git a/src/utils/agentPortalCopy.ts b/src/utils/agentPortalCopy.ts new file mode 100644 index 0000000..67f99ff --- /dev/null +++ b/src/utils/agentPortalCopy.ts @@ -0,0 +1,44 @@ +/** 代理门户:催付款 / 邀请话术文案 */ + +export function buildPendingPaymentReminderText(opts: { + customerName?: string + pendingCount?: number + agentName?: string +}): string { + const name = (opts.customerName || '客户').trim() + const count = Math.max(0, Number(opts.pendingCount) || 0) + const agent = (opts.agentName || '您的渠道顾问').trim() + const countPart = count > 0 ? `目前有 ${count} 票运单待付款` : '您有运单待付款' + return ( + `【物流提醒】您好${name !== '客户' ? `,${name}` : ''},` + + `${countPart},请尽快登录系统完成支付,以免影响出货。` + + `如有疑问可联系${agent}。` + ) +} + +export type AgentInviteTemplateKey = 'wechat' | 'moments' | 'formal' + +export const AGENT_INVITE_TEMPLATES: Array<{ + key: AgentInviteTemplateKey + label: string + build: (agentCode: string, inviteUrl: string) => string +}> = [ + { + key: 'wechat', + label: '微信私聊', + build: (code, url) => + `嗨,推荐你用这个渠道寄国际件,注册就送专属通道:${url}\n我的代理编号:${code}`, + }, + { + key: 'moments', + label: '朋友圈', + build: (code, url) => + `国际物流靠谱渠道分享|扫码/点开即可注册下单\n${url}\n编号 ${code}(备注来找我可跟进)`, + }, + { + key: 'formal', + label: '商务简介', + build: (code, url) => + `【IDE Express 渠道邀请】请通过专属链接完成注册绑定:${url}\n代理编号:${code}\n注册后即可查价下单,运单派送完成后按约定结算返佣。`, + }, +] diff --git a/src/utils/enter-system.ts b/src/utils/enter-system.ts index d5eab8f..0266d9a 100644 --- a/src/utils/enter-system.ts +++ b/src/utils/enter-system.ts @@ -6,8 +6,10 @@ import { ensureDynamicRouterReady } from '@/router/ensure-dynamic-router'; import { ensureHeavyPlugins } from '@/plugins/heavy-plugins'; import { ensureFullLocaleMessages } from '@/locale'; import { skipNextPageTransition } from '@/utils/page-transition-flag'; -import type { UserInfoDto } from '@/api/rbac'; +import { OmsLoginType, type UserInfoDto } from '@/api/rbac'; import { resolveAvatarUrl } from '@/utils/avatar'; +import { useRbacUserStore } from '@/store/modules/rbac-user'; +import { markDynamicRouterInitialized } from '@/router/dynamic-router-state'; export type EnterSystemTarget = { path?: string; @@ -57,9 +59,12 @@ export async function enterSystemAfterLogin(options: { void options.playExit?.(); void ensureHeavyPlugins(); + const isAgentPortal = useRbacUserStore().loginPortalType === OmsLoginType.Agent; await Promise.all([ ensureFullLocaleMessages(), - ensureDynamicRouterReady({ forceRefresh: false }), + isAgentPortal + ? Promise.resolve().then(() => markDynamicRouterInitialized(true)) + : ensureDynamicRouterReady({ forceRefresh: false }), ]); skipNextPageTransition(); diff --git a/src/utils/headerFilterAdapter.ts b/src/utils/headerFilterAdapter.ts index 9eca5c1..a7a9f65 100644 --- a/src/utils/headerFilterAdapter.ts +++ b/src/utils/headerFilterAdapter.ts @@ -174,7 +174,7 @@ export function adaptOrderListHeaderFilters( export interface CustomerListHeaderQuery { keyword?: string; - salesman?: string; + agentId?: number; customerService?: string; status?: number; } @@ -191,9 +191,15 @@ export function adaptCustomerListHeaderFilters( 'phone', 'wechatNickname', ]) || undefined; - const salesman = pickHeaderText(params, ['salesPerson', 'salesman']) || undefined; const customerService = pickHeaderText(params, ['customerService']) || undefined; + let agentId: number | undefined; + const agentRaw = pickHeaderText(params, ['agent', 'agentId', 'agentCode']); + if (agentRaw) { + const n = Number(agentRaw); + if (!Number.isNaN(n) && n > 0) agentId = n; + } + let status: number | undefined; const statusRaw = asTrimmedString(params.status); if (statusRaw) { @@ -205,7 +211,7 @@ export function adaptCustomerListHeaderFilters( } } - return { keyword, salesman, customerService, status }; + return { keyword, agentId, customerService, status }; } export interface AccountFlowHeaderQuery { diff --git a/src/utils/inviteLink.ts b/src/utils/inviteLink.ts index d452119..d340725 100644 --- a/src/utils/inviteLink.ts +++ b/src/utils/inviteLink.ts @@ -16,6 +16,26 @@ export function buildAdvisorInviteRegisterUrl( return `${origin}/wechat/register?advisor=${encodeURIComponent(account)}`; } +/** 渠道代理获客:PC 注册页 ?agent= */ +export function buildAgentInviteRegisterUrl( + agentCode: string, + origin = window.location.origin, +): string { + const code = agentCode.trim(); + if (!code) return ''; + return `${origin}/register?agent=${encodeURIComponent(code)}`; +} + +/** 渠道代理获客:微信注册页(扫码主推)?agent= */ +export function buildAgentInviteWechatRegisterUrl( + agentCode: string, + origin = window.location.origin, +): string { + const code = agentCode.trim(); + if (!code) return ''; + return `${origin}/wechat/register?agent=${encodeURIComponent(code)}`; +} + /** 复制/分享用话术 */ export function buildInviteShareText(inviteCode: string, inviteUrl: string): string { const code = inviteCode.trim(); @@ -23,3 +43,11 @@ export function buildInviteShareText(inviteCode: string, inviteUrl: string): str if (!code || !url) return url || code; return `【IDE Express】送你一个专属邀请,微信打开链接注册即可:${url}\n邀请码:${code}`; } + +/** 渠道代理分享话术(默认微信私聊) */ +export function buildAgentInviteShareText(agentCode: string, inviteUrl: string): string { + const code = agentCode.trim(); + const url = inviteUrl.trim(); + if (!code || !url) return url || code; + return `嗨,推荐你用这个渠道寄国际件,注册就送专属通道:${url}\n我的代理编号:${code}`; +} diff --git a/src/utils/login-portal.ts b/src/utils/login-portal.ts index 42afc34..c0ac675 100644 --- a/src/utils/login-portal.ts +++ b/src/utils/login-portal.ts @@ -10,6 +10,11 @@ export const CUSTOMER_LOGIN_PATH = '/login' /** 管理端(内部用户)登录路径 */ export const ADMIN_LOGIN_PATH = '/admin' +/** 渠道代理登录路径 / 路由名 */ +export const AGENT_LOGIN_PATH = '/agent' +export const AGENT_LOGIN_ROUTE_NAME = 'agentLogin' +export const AGENT_HOME_PATH = '/agent/workplace' + /** 客户端(客户)登录路由 */ export const CUSTOMER_LOGIN_ROUTE_NAME = 'login' @@ -31,28 +36,42 @@ export const ADMIN_PC_HOME_PATH = '/home/workplace' export const LOGIN_ROUTE_NAMES = [ ADMIN_LOGIN_ROUTE_NAME, CUSTOMER_LOGIN_ROUTE_NAME, + AGENT_LOGIN_ROUTE_NAME, ] as const export function isLoginRouteName(name?: string | null): boolean { - return name === ADMIN_LOGIN_ROUTE_NAME || name === CUSTOMER_LOGIN_ROUTE_NAME + return ( + name === ADMIN_LOGIN_ROUTE_NAME + || name === CUSTOMER_LOGIN_ROUTE_NAME + || name === AGENT_LOGIN_ROUTE_NAME + ) } export function getLoginTypeFromRouteName(name?: string | null): OmsLoginType { - return name === CUSTOMER_LOGIN_ROUTE_NAME ? OmsLoginType.Customer : OmsLoginType.User + if (name === CUSTOMER_LOGIN_ROUTE_NAME) return OmsLoginType.Customer + if (name === AGENT_LOGIN_ROUTE_NAME) return OmsLoginType.Agent + return OmsLoginType.User } export function getLoginRouteNameByType(type: OmsLoginType): string { - return type === OmsLoginType.Customer ? CUSTOMER_LOGIN_ROUTE_NAME : ADMIN_LOGIN_ROUTE_NAME + if (type === OmsLoginType.Customer) return CUSTOMER_LOGIN_ROUTE_NAME + if (type === OmsLoginType.Agent) return AGENT_LOGIN_ROUTE_NAME + return ADMIN_LOGIN_ROUTE_NAME } export function getLoginPathByType(type: OmsLoginType): string { - return type === OmsLoginType.Customer ? CUSTOMER_LOGIN_PATH : ADMIN_LOGIN_PATH + if (type === OmsLoginType.Customer) return CUSTOMER_LOGIN_PATH + if (type === OmsLoginType.Agent) return AGENT_LOGIN_PATH + return ADMIN_LOGIN_PATH } export function getDefaultHomePathByType(type: OmsLoginType): string { if (type === OmsLoginType.Customer) { return preferH5Portal() ? CUSTOMER_H5_HOME_PATH : CUSTOMER_PC_HOME_PATH } + if (type === OmsLoginType.Agent) { + return AGENT_HOME_PATH + } return preferH5Portal() ? ADMIN_H5_HOME_PATH : ADMIN_PC_HOME_PATH } @@ -70,6 +89,7 @@ export function isH5ExemptPath(path: string): boolean { p.startsWith('/h5') || p.startsWith('/wechat/') || p.startsWith('/track/') || + p.startsWith('/agent') || p.startsWith('/redirect') ) } @@ -179,12 +199,21 @@ function isCustomerExclusivePath(path: string): boolean { ) } +/** 是否渠道代理门户路径(不含 /agentAdmin 运营端) */ +export function isAgentPortalPath(path: string): boolean { + const p = (path || '').trim() || '/' + return p === AGENT_LOGIN_PATH || p.startsWith(`${AGENT_LOGIN_PATH}/`) +} + /** 未登录时按目标路径选择应跳转的登录页(无主体类型时的兜底) */ export function resolveLoginRouteNameFromPath(path: string): string { const p = (path || '').trim() || '/' if (p === CUSTOMER_LOGIN_PATH || p === '/' || p.startsWith(`${CUSTOMER_LOGIN_PATH}/`)) { return CUSTOMER_LOGIN_ROUTE_NAME } + if (isAgentPortalPath(p)) { + return AGENT_LOGIN_ROUTE_NAME + } if (p === ADMIN_LOGIN_PATH || p.startsWith(`${ADMIN_LOGIN_PATH}/`)) { return ADMIN_LOGIN_ROUTE_NAME } @@ -212,6 +241,8 @@ export function resolvePostLogoutLogin(options?: { let routeName: string if (type === OmsLoginType.Customer) { routeName = CUSTOMER_LOGIN_ROUTE_NAME + } else if (type === OmsLoginType.Agent) { + routeName = AGENT_LOGIN_ROUTE_NAME } else if (type === OmsLoginType.User) { routeName = ADMIN_LOGIN_ROUTE_NAME } else { @@ -255,6 +286,19 @@ export function resolvePostLogoutLogin(options?: { } } + if (routeName === AGENT_LOGIN_ROUTE_NAME) { + return { + name: AGENT_LOGIN_ROUTE_NAME, + query: + path && + path !== '/' && + path !== AGENT_LOGIN_PATH && + isAgentPortalPath(path) + ? { redirect: path } + : undefined, + } + } + return { name: routeName, query: diff --git a/src/utils/menu-icon.ts b/src/utils/menu-icon.ts index fdf7711..89a585a 100644 --- a/src/utils/menu-icon.ts +++ b/src/utils/menu-icon.ts @@ -22,6 +22,9 @@ export const SUB_MENU_DEFAULT_ICONS: Record = { 'menu.home.monitor': 'IconLiveBroadcast', 'menu.home.adminEventBus': 'IconMindMapping', 'menu.home.trackEventBus': 'IconRelation', + 'menu.agentAdmin': 'IconUserGroup', + 'menu.agentAdmin.list': 'IconUser', + 'menu.agentAdmin.commission': 'IconGift', 'menu.user.customer': 'IconUserGroup', 'menu.user.profile': 'IconUser', 'menu.user.recharge': 'IconGift', @@ -124,6 +127,8 @@ export const PATH_TO_LOCALE_KEY: Record = { '/order/guide': 'menu.order.orderGuide', '/channel/guide': 'menu.channel.channelGuide', '/marketing/guide': 'menu.marketing.marketingGuide', + '/agentAdmin/list': 'menu.agentAdmin.list', + '/agentAdmin/commission': 'menu.agentAdmin.commission', } type MenuIconItem = { diff --git a/src/views/agent-admin/commission/index.vue b/src/views/agent-admin/commission/index.vue new file mode 100644 index 0000000..bec3b16 --- /dev/null +++ b/src/views/agent-admin/commission/index.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/src/views/agent-admin/list/index.vue b/src/views/agent-admin/list/index.vue new file mode 100644 index 0000000..aa31535 --- /dev/null +++ b/src/views/agent-admin/list/index.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/src/views/agent/commission/index.vue b/src/views/agent/commission/index.vue new file mode 100644 index 0000000..0d48066 --- /dev/null +++ b/src/views/agent/commission/index.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/src/views/agent/customers/index.vue b/src/views/agent/customers/index.vue new file mode 100644 index 0000000..f2e1b00 --- /dev/null +++ b/src/views/agent/customers/index.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/src/views/agent/invite/index.vue b/src/views/agent/invite/index.vue new file mode 100644 index 0000000..cb570e2 --- /dev/null +++ b/src/views/agent/invite/index.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/src/views/agent/workplace/components/AgentAlertBar.vue b/src/views/agent/workplace/components/AgentAlertBar.vue new file mode 100644 index 0000000..f200656 --- /dev/null +++ b/src/views/agent/workplace/components/AgentAlertBar.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/views/agent/workplace/components/AgentBanner.vue b/src/views/agent/workplace/components/AgentBanner.vue new file mode 100644 index 0000000..b008354 --- /dev/null +++ b/src/views/agent/workplace/components/AgentBanner.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/src/views/agent/workplace/components/AgentDataPanel.vue b/src/views/agent/workplace/components/AgentDataPanel.vue new file mode 100644 index 0000000..82ae048 --- /dev/null +++ b/src/views/agent/workplace/components/AgentDataPanel.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/src/views/agent/workplace/components/AgentInviteCard.vue b/src/views/agent/workplace/components/AgentInviteCard.vue new file mode 100644 index 0000000..941ac74 --- /dev/null +++ b/src/views/agent/workplace/components/AgentInviteCard.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/views/agent/workplace/components/AgentQuickOps.vue b/src/views/agent/workplace/components/AgentQuickOps.vue new file mode 100644 index 0000000..0f5b321 --- /dev/null +++ b/src/views/agent/workplace/components/AgentQuickOps.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/views/agent/workplace/components/AgentRecentCommission.vue b/src/views/agent/workplace/components/AgentRecentCommission.vue new file mode 100644 index 0000000..84c2c2e --- /dev/null +++ b/src/views/agent/workplace/components/AgentRecentCommission.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/src/views/agent/workplace/components/AgentTrendPanel.vue b/src/views/agent/workplace/components/AgentTrendPanel.vue new file mode 100644 index 0000000..275f385 --- /dev/null +++ b/src/views/agent/workplace/components/AgentTrendPanel.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/src/views/agent/workplace/index.vue b/src/views/agent/workplace/index.vue new file mode 100644 index 0000000..1be3aca --- /dev/null +++ b/src/views/agent/workplace/index.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/src/views/channel/autoTrigger/components/FormulaAiAssistDrawer.vue b/src/views/channel/autoTrigger/components/FormulaAiAssistDrawer.vue index 169ac4f..8cd7432 100644 --- a/src/views/channel/autoTrigger/components/FormulaAiAssistDrawer.vue +++ b/src/views/channel/autoTrigger/components/FormulaAiAssistDrawer.vue @@ -353,7 +353,7 @@ function resolveSystemPrompt(): string { if (!sendSkill.value) { return ( props.shortSystemPrompt || - '你是 OMS 自动触发公式助手。只输出中文公式表达式;字符串用 字段包含(\'…\'),数值用 字段() 比较。' + '你是 OMS 自动触发公式助手。只输出中文公式表达式;字符串用 字段包含(\'…\'),数值用 字段() 比较;非空用 字段有值() 或 字段包含(\'\'),为空用 字段为空()。' ) } const fromList = skills.value.find((s) => s.skillKey === skillKey.value) diff --git a/src/views/channel/autoTrigger/formulaAiSkill.ts b/src/views/channel/autoTrigger/formulaAiSkill.ts index 8ffadba..97f2d06 100644 --- a/src/views/channel/autoTrigger/formulaAiSkill.ts +++ b/src/views/channel/autoTrigger/formulaAiSkill.ts @@ -17,12 +17,12 @@ export type ParsedAiFormula = { function fieldUsage(def: OrderTableFieldDef): string { if (def.valueType === 'string' || def.valueType === 'datetime') { - return `${def.label}包含('…')` + return `${def.label}包含('…')|${def.label}有值()|${def.label}为空()` } if (def.valueType === 'boolean') { - return `${def.label}()==1` + return `${def.label}()==1|${def.label}有值()|${def.label}为空()` } - return `${def.label}()` + return `${def.label}()|${def.label}有值()|${def.label}为空()` } function buildFieldCatalog(): string { @@ -58,10 +58,14 @@ const SKILL_CORE = `你是 OMS「自动触发」公式助手。根据用户中 - 字段名必须是下方目录中的中文 label,禁止编造、禁止英文字段名 - 字符串:字段包含('值');数值/聚合:字段() 再比较;布尔:字段()==1 / ==0 +- 非空/不能为 null:字段有值() 或 字段包含('');为空/未填:字段为空() 或 !字段有值() +- 禁止 null 字面量、字段==null、字段()!=null、字段!=''(字符串用有值/为空) - 字面量用单引号;逻辑用 && || !;比较用 == != > >= < <= - 禁止 and/or/not、中文「且」、双引号、字段() 用于字符串相等 - 国家一般用二字码:国家包含('GB') - 公式单行、无注释夹杂、长度 ≤ 10000 +- 数值 0 / 布尔 false 仍算有值;尺寸「未填当 0」用 字段()<=0,不要只靠有值() +- 包含:子串匹配(非全等);多选用 ||;排除用 !字段包含('…') ## 示例 @@ -70,6 +74,18 @@ const SKILL_CORE = `你是 OMS「自动触发」公式助手。根据用户中 公式:(单件最大长度()>60||单件最大宽度()>46)&&国家包含('GB') 说明:英国且超尺寸时拦截 验证样例:国家 GB,长 70,宽 40,高 40 + +时机:下单 +操作:拦截 +公式:收件人手机为空()||收件人邮编为空() +说明:手机或邮编未填时拦截 +验证样例:手机空、邮编 10001 → 命中 + +时机:下单 +操作:警告 +公式:国家有值()&&国家包含('GB') +说明:已填国家且含 GB 时警告 +验证样例:国家 GB → 命中;国家空 → 不命中 ` let cachedSystemPrompt: string | null = null @@ -148,4 +164,5 @@ export const FORMULA_AI_QUICK_PROMPTS = [ '偏远地区下单时警告', '申报金额 ≥ 2000 且国家 AU 时记问题件', '敏感货且收货渠道含 DHL 时拦截', + '收件人手机与电话都为空,或邮编为空时下单拦截', ] diff --git a/src/views/channel/components/ChannelConfigHotTable.vue b/src/views/channel/components/ChannelConfigHotTable.vue index f680cc9..cdd7751 100644 --- a/src/views/channel/components/ChannelConfigHotTable.vue +++ b/src/views/channel/components/ChannelConfigHotTable.vue @@ -1,7 +1,7 @@ diff --git a/src/views/channel/components/KpiAreaPage.vue b/src/views/channel/components/KpiAreaPage.vue index e660574..9111b89 100644 --- a/src/views/channel/components/KpiAreaPage.vue +++ b/src/views/channel/components/KpiAreaPage.vue @@ -86,7 +86,7 @@ {{ t('channel.area.actions.delete') }} @@ -103,6 +103,15 @@ :total-count="totalCount" :current-page="currentPage" :page-size="pageSize" + :search-params="headerSearchParams" + :server-header-search="true" + :reset-search-key="currentId?.id" + sort-mode="remote" + :sort-field="listSortField" + :sort-order="listSortOrder" + @search="handleHeaderSearch" + @sort-change="handleSortChange" + @selection-change="handleDetailSelectionChange" @edit="(row) => handleOpenRightModal('edit', row)" @delete="(row) => handleDelRightData(row.id)" /> @@ -286,6 +295,9 @@ importModalRef, tableData, hotColumns, + headerSearchParams, + listSortField, + listSortOrder, leftModalTip, rightModalTip, searchModel, @@ -308,7 +320,11 @@ returnRightEditRowParams, handleDelData, handleToolbarDelete, + selectedDetailRows, + handleDetailSelectionChange, handleDelRightData, + handleHeaderSearch, + handleSortChange, handlePageChange, handlePageSizeChange, onFormSubmitClick, diff --git a/src/views/channel/components/channelHotTable.types.ts b/src/views/channel/components/channelHotTable.types.ts index bab50df..6a25d4e 100644 --- a/src/views/channel/components/channelHotTable.types.ts +++ b/src/views/channel/components/channelHotTable.types.ts @@ -2,4 +2,12 @@ export type ChannelHotColumn = { key: string; label: string; width: number; + /** 混合表头:内嵌搜索 / 仅图标 / 纯展示 */ + headerMode?: 'inline' | 'icon' | 'none'; + searchable?: boolean; + searchField?: string; + /** false=仅当前页本地过滤;true=交给页面发请求走后端 */ + serverFilter?: boolean; + /** 表头排序(remote 时由页面请求后端) */ + sortable?: boolean; }; diff --git a/src/views/channel/composables/useKpiAreaPage.ts b/src/views/channel/composables/useKpiAreaPage.ts index d286df7..41f79b9 100644 --- a/src/views/channel/composables/useKpiAreaPage.ts +++ b/src/views/channel/composables/useKpiAreaPage.ts @@ -15,12 +15,13 @@ import { getKpiAreaPublishRecord, } from '@/api/channel/kpiArea'; import { getCountryList } from '@/api/system/country'; - import { - DESTINATION_COUNTRY_QUICK_LABELS, - applyQuickCountryToFormModel, - } from '@/views/channel/countryQuickTags'; - import { syncChannelLeftSelection } from '@/views/channel/composables/channelLeftSelection'; - import type { ChannelHotColumn } from '@/views/channel/components/channelHotTable.types'; +import { + DESTINATION_COUNTRY_QUICK_LABELS, + applyQuickCountryToFormModel, +} from '@/views/channel/countryQuickTags'; +import { syncChannelLeftSelection } from '@/views/channel/composables/channelLeftSelection'; +import type { ChannelHotColumn } from '@/views/channel/components/channelHotTable.types'; +import { toServerSearchValue } from '@/components/HandsontablePro/filterValue'; import { $baseConfirm, $baseMessage } from '@/utils'; import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'; @@ -531,6 +532,10 @@ export function useKpiAreaPage(areaType: KpiAreaType) { const pageSize = ref(50); const tableLoading = ref(false); const leftLoading = ref(false); + /** 右侧明细表头搜索(走后端) */ + const headerSearchParams = reactive>({}); + const listSortField = ref(null); + const listSortOrder = ref<'asc' | 'desc' | null>(null); const leftMenuData = ref([]); const leftExpandedKeys = ref(['category-list']); @@ -582,15 +587,112 @@ export function useKpiAreaPage(areaType: KpiAreaType) { } const hotColumns = computed(() => [ - { key: 'countryName', label: t('channel.area.form.country'), width: 120 }, - { key: 'matchDimName', label: t('channel.area.form.matchDim'), width: 100 }, - { key: 'matchModeName', label: t('channel.area.form.matchMode'), width: 100 }, - { key: 'province', label: t('channel.area.form.province'), width: 120 }, - { key: 'city', label: t('channel.area.form.city'), width: 120 }, - { key: 'leftPostcode', label: t('channel.area.table.leftPostcode'), width: 120 }, - { key: 'rightPostcode', label: t('channel.area.table.rightPostcode'), width: 120 }, + { + key: 'countryName', + label: t('channel.area.form.country'), + width: 120, + searchField: 'country', + serverFilter: true, + }, + { + key: 'matchDimName', + label: t('channel.area.form.matchDim'), + width: 100, + searchField: 'matchDim', + serverFilter: true, + }, + { + key: 'matchModeName', + label: t('channel.area.form.matchMode'), + width: 100, + searchField: 'matchMode', + serverFilter: true, + }, + { + key: 'province', + label: t('channel.area.form.province'), + width: 120, + searchField: 'province', + serverFilter: true, + }, + { + key: 'city', + label: t('channel.area.form.city'), + width: 120, + searchField: 'city', + serverFilter: true, + }, + { + key: 'leftPostcode', + label: t('channel.area.table.leftPostcode'), + width: 120, + searchField: 'leftPostcode', + serverFilter: true, + sortable: true, + }, + { + key: 'rightPostcode', + label: t('channel.area.table.rightPostcode'), + width: 120, + searchField: 'rightPostcode', + serverFilter: true, + sortable: true, + }, ]); + function pickHeaderText(field: string): string { + return toServerSearchValue(String(headerSearchParams[field] ?? '').trim()); + } + + function resolveEnumFilter( + raw: string, + kind: 'matchDim' | 'matchMode', + codes: string[] + ): string { + const text = raw.trim(); + if (!text) return ''; + const lower = text.toLowerCase(); + for (const code of codes) { + if (code === lower) return code; + const label = String(t(`channel.area.${kind}.${code}`) || ''); + if (label === text || label.toLowerCase() === lower) return code; + } + for (const code of codes) { + const label = String(t(`channel.area.${kind}.${code}`) || ''); + if (label.includes(text) || code.includes(lower)) return code; + } + return text; + } + + function buildInfoListFilters() { + const country = pickHeaderText('country'); + const matchDim = resolveEnumFilter(pickHeaderText('matchDim'), 'matchDim', [ + 'country', + 'zip', + 'state', + 'city', + ]); + const matchMode = resolveEnumFilter(pickHeaderText('matchMode'), 'matchMode', [ + 'prefix', + 'exact', + 'range', + 'all', + ]); + const province = pickHeaderText('province'); + const city = pickHeaderText('city'); + const leftPostcode = pickHeaderText('leftPostcode'); + const rightPostcode = pickHeaderText('rightPostcode'); + return { + ...(country ? { country } : {}), + ...(matchDim ? { matchDim } : {}), + ...(matchMode ? { matchMode } : {}), + ...(province ? { province } : {}), + ...(city ? { city } : {}), + ...(leftPostcode ? { leftPostcode } : {}), + ...(rightPostcode ? { rightPostcode } : {}), + }; + } + function handleSelectFromTree(_keys: string[], info: any) { const key = info.node?.key; if (!key || !key.startsWith('item-')) return; @@ -603,9 +705,29 @@ export function useKpiAreaPage(areaType: KpiAreaType) { if (nodeData?.id != null) handleDelData(nodeData.id, nodeData?.name); } + function clearHeaderSearchParams() { + Object.keys(headerSearchParams).forEach((key) => { + headerSearchParams[key] = ''; + }); + } + + function clearListSort() { + listSortField.value = null; + listSortOrder.value = null; + } + + const selectedDetailRows = ref([]); + + function handleDetailSelectionChange(rows: any[]) { + selectedDetailRows.value = rows || []; + } + function selectArea(item: any) { currentId.value = item; currentPage.value = 1; + selectedDetailRows.value = []; + clearHeaderSearchParams(); + clearListSort(); loadTableData(); packList.value = []; } @@ -815,12 +937,33 @@ export function useKpiAreaPage(areaType: KpiAreaType) { } function handleToolbarDelete() { - const row = currentId.value; - if (row?.id == null) { + if (!currentId.value?.id) { $baseMessage(t('channel.area.import.needSelectArea'), 'warning'); return; } - handleDelData(row.id, row.name); + const ids = selectedDetailRows.value + .map((row) => Number(row?.id ?? row?.Id ?? 0)) + .filter((id) => id > 0); + if (!ids.length) { + $baseMessage(t('channel.area.message.needSelectDetail'), 'warning'); + return; + } + $baseConfirm({ + content: t('channel.area.modal.deleteConfirmDetails', { count: ids.length }), + title: t('channel.area.modal.warningTitle'), + onOk: async () => { + try { + for (const id of ids) { + await deleteForewarningServiceKpiAreaDeleteKpiAreaInfo({ id }); + } + selectedDetailRows.value = []; + await loadTableData(); + $baseMessage(t('channel.area.message.deleteSuccess'), 'success'); + } catch (e: any) { + $baseMessage(e?.message || t('channel.area.message.deleteFailed'), 'error'); + } + }, + }); } function handleDelRightData(id: number) { @@ -830,6 +973,9 @@ export function useKpiAreaPage(areaType: KpiAreaType) { onOk: async () => { try { await deleteForewarningServiceKpiAreaDeleteKpiAreaInfo({ id }); + selectedDetailRows.value = selectedDetailRows.value.filter( + (row) => Number(row?.id ?? row?.Id ?? 0) !== id, + ); await loadTableData(); $baseMessage(t('channel.area.message.deleteSuccess'), 'success'); } catch (e: any) { @@ -843,6 +989,7 @@ export function useKpiAreaPage(areaType: KpiAreaType) { if (!currentId.value?.id) { tableData.value = []; totalCount.value = 0; + selectedDetailRows.value = []; return; } @@ -850,13 +997,18 @@ export function useKpiAreaPage(areaType: KpiAreaType) { try { const response: any = await postForewarningServiceKpiAreaGetKpiAreaInfos({ areaId: currentId.value.id, - pageIndex: currentPage.value, + page: currentPage.value, pageSize: pageSize.value, + ...buildInfoListFilters(), + ...(listSortField.value && listSortOrder.value + ? { sortField: listSortField.value, sortOrder: listSortOrder.value } + : {}), }); const items = filterByAreaType(response?.items ?? response?.data?.items ?? []); totalCount.value = Number( response?.totalCount ?? response?.total ?? items.length ?? 0 ); + selectedDetailRows.value = []; tableData.value = items.map((item) => { const inferred = inferGeoMatch(item); const matchDim = item.matchDim || inferred.matchDim; @@ -878,6 +1030,18 @@ export function useKpiAreaPage(areaType: KpiAreaType) { } } + function handleHeaderSearch() { + currentPage.value = 1; + loadTableData(); + } + + function handleSortChange(payload: { field: string | null; order: 'asc' | 'desc' | null }) { + listSortField.value = payload.field; + listSortOrder.value = payload.order; + currentPage.value = 1; + loadTableData(); + } + function handlePageChange(page: number) { currentPage.value = page; loadTableData(); @@ -942,8 +1106,9 @@ export function useKpiAreaPage(areaType: KpiAreaType) { while (all.length < total && page <= 200) { const response: any = await postForewarningServiceKpiAreaGetKpiAreaInfos({ areaId, - pageIndex: page, + page, pageSize: pageSizeAll, + ...buildInfoListFilters(), }); const items = filterByAreaType(response?.items ?? response?.data?.items ?? []); total = Number(response?.totalCount ?? response?.total ?? items.length ?? 0); @@ -986,8 +1151,12 @@ export function useKpiAreaPage(areaType: KpiAreaType) { editFormRightParams.areaId = val?.id; if (val) { currentPage.value = 1; + clearHeaderSearchParams(); + clearListSort(); loadTableData(); } else { + clearHeaderSearchParams(); + clearListSort(); tableData.value = []; totalCount.value = 0; } @@ -1016,6 +1185,9 @@ export function useKpiAreaPage(areaType: KpiAreaType) { importModalRef, tableData, hotColumns, + headerSearchParams, + listSortField, + listSortOrder, leftModalTip, rightModalTip, searchModel, @@ -1038,7 +1210,11 @@ export function useKpiAreaPage(areaType: KpiAreaType) { returnRightEditRowParams, handleDelData, handleToolbarDelete, + selectedDetailRows, + handleDetailSelectionChange, handleDelRightData, + handleHeaderSearch, + handleSortChange, handlePageChange, handlePageSizeChange, onFormSubmitClick, diff --git a/src/views/channel/weightRounding/index.vue b/src/views/channel/weightRounding/index.vue index 18edd78..172cae3 100644 --- a/src/views/channel/weightRounding/index.vue +++ b/src/views/channel/weightRounding/index.vue @@ -108,6 +108,7 @@ :total-count="totalCount" :current-page="currentPage" :page-size="pageSize" + :reset-search-key="currentId?.id" :serial-no-header="t('channel.weightRounding.table.serialNo')" :action-column-header="t('channel.weightRounding.table.actions')" @edit="(row) => handleOpenRightModal('edit', row)" diff --git a/src/views/channel/weightRules/index.vue b/src/views/channel/weightRules/index.vue index b1d9d45..4d92a14 100644 --- a/src/views/channel/weightRules/index.vue +++ b/src/views/channel/weightRules/index.vue @@ -104,6 +104,7 @@ :total-count="totalCount" :current-page="currentPage" :page-size="pageSize" + :reset-search-key="currentId?.id" :serial-no-header="t('channel.weightRules.table.serialNo')" :action-column-header="t('channel.weightRules.table.actions')" :custom-cell-renderer="hotCellRenderer" diff --git a/src/views/login/components/LoginHero.vue b/src/views/login/components/LoginHero.vue index 4cb2ed2..551a879 100644 --- a/src/views/login/components/LoginHero.vue +++ b/src/views/login/components/LoginHero.vue @@ -34,7 +34,7 @@ @focusout="hovered = false" >