From c66071ed5eef56572ce4ecb9a87e9c5f79874c1a Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 29 Oct 2026 18:38:18 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B8=A0=E9=81=93=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E9=97=A8=E6=88=B7=E4=B8=8E=E8=BF=90=E8=90=A5=E5=88=97?= =?UTF-8?q?=E8=A1=A8=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E5=B7=A5=E5=85=B7=E6=A0=8F=E8=AF=AF=E5=88=A0?= =?UTF-8?q?=E5=88=86=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- docs/渠道代理与返佣设计方案.md | 534 ++++++++++++++++++ src/api/agent/index.ts | 153 +++++ src/api/customer/index.ts | 8 +- src/api/rbac/generated/models/OmsLoginType.ts | 3 +- src/api/wechat/types.ts | 2 + src/components.d.ts | 1 + src/components/agent-side-rail/index.vue | 490 ++++++++++++++++ src/constants/pageButtonCatalog.ts | 16 + src/layout/default-layout.vue | 20 +- src/locale/en-US.ts | 5 + src/locale/zh-CN.ts | 5 + src/router/constants.ts | 4 + src/router/dynamicRouter.ts | 7 + src/router/guard/permission.ts | 27 +- src/router/guard/userLoginInfo.ts | 13 + src/router/index.ts | 55 ++ src/router/routes/newModules/agent.ts | 50 ++ src/store/modules/rbac-user.ts | 6 + src/utils/enter-system.ts | 9 +- src/utils/inviteLink.ts | 28 + src/utils/login-portal.ts | 52 +- src/utils/menu-icon.ts | 5 + src/views/agent-admin/commission/index.vue | 246 ++++++++ src/views/agent-admin/list/index.vue | 368 ++++++++++++ src/views/agent/customers/index.vue | 175 ++++++ src/views/agent/invite/index.vue | 374 ++++++++++++ src/views/agent/orders/index.vue | 285 ++++++++++ .../components/ChannelConfigHotTable.vue | 81 ++- src/views/channel/components/KpiAreaPage.vue | 18 +- .../components/channelHotTable.types.ts | 8 + .../channel/composables/useKpiAreaPage.ts | 212 ++++++- src/views/channel/weightRounding/index.vue | 1 + src/views/channel/weightRules/index.vue | 1 + src/views/login/components/LoginHero.vue | 51 +- .../login/components/pwd-login/index.vue | 31 +- src/views/login/index.vue | 19 +- src/views/login/locale/en-US.ts | 13 + src/views/login/locale/zh-CN.ts | 12 + src/views/register/index.vue | 16 +- src/views/wechat/register/index.vue | 12 +- vite.config.ts | 24 + 41 files changed, 3361 insertions(+), 79 deletions(-) create mode 100644 docs/渠道代理与返佣设计方案.md create mode 100644 src/api/agent/index.ts create mode 100644 src/components/agent-side-rail/index.vue create mode 100644 src/router/routes/newModules/agent.ts create mode 100644 src/views/agent-admin/commission/index.vue create mode 100644 src/views/agent-admin/list/index.vue create mode 100644 src/views/agent/customers/index.vue create mode 100644 src/views/agent/invite/index.vue create mode 100644 src/views/agent/orders/index.vue diff --git a/docs/渠道代理与返佣设计方案.md b/docs/渠道代理与返佣设计方案.md new file mode 100644 index 0000000..a9d18f6 --- /dev/null +++ b/docs/渠道代理与返佣设计方案.md @@ -0,0 +1,534 @@ +# 渠道代理与返佣设计方案 + +> **文档状态**:产品 + 概念模型 + 主流程 + 表结构草案(**轻量首版已收口,可开发**) +> **编写日期**: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 | **计提触发** | **`deliverySuccess` / `deliveryFailed`** 均计提;同单按 `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(跟主客户) + → 派送成功/失败 → 写一条返佣明细(金额快照) + → 运营按明细线下打款 + →(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..18cf230 --- /dev/null +++ b/src/api/agent/index.ts @@ -0,0 +1,153 @@ +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 + commissionRate: number + salesProductIds?: 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 +} + +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 +} + +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 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: { + page?: number + pageSize?: number + }): Promise> { + const r = await axiosInstance.post(omsUrl('/agent/portal/commission/list'), body) + return unwrapPage(r.data) + }, +} diff --git a/src/api/customer/index.ts b/src/api/customer/index.ts index 5633bda..40a8539 100644 --- a/src/api/customer/index.ts +++ b/src/api/customer/index.ts @@ -162,7 +162,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 +190,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/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..9d153b9 --- /dev/null +++ b/src/components/agent-side-rail/index.vue @@ -0,0 +1,490 @@ + + + + + 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/en-US.ts b/src/locale/en-US.ts index 59b0e82..dc0e4a7 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', @@ -5843,6 +5846,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/zh-CN.ts b/src/locale/zh-CN.ts index b0c6bd4..f5df5c2 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': '会员等级', @@ -5851,6 +5854,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..4a24a09 100644 --- a/src/router/constants.ts +++ b/src/router/constants.ts @@ -2,6 +2,10 @@ export const WHITE_LIST = [ { name: 'notFound', children: [] }, { name: 'login', children: [] }, { name: 'adminLogin', children: [] }, + { name: 'agentLogin', children: [] }, + { name: 'AgentPortalInvite', children: [] }, + { name: 'AgentPortalCustomers', children: [] }, + { name: 'AgentPortalOrders', 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..9f85ae9 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,24 @@ export default function setupPermissionGuard(router: Router) { !isLogin() && (path.startsWith('/h5') || path.startsWith('/wechat/') || path.startsWith('/track/')); + // 渠道代理只能进代理门户;非代理不能进代理业务页 + const isAgent = rbacUserStore.loginPortalType === OmsLoginType.Agent; + const isAgentPortalRoute = + Boolean(to.meta?.agentPortalOnly) || + (isAgentPortalPath(path) && path !== AGENT_LOGIN_PATH); + if (isAgent && !isAgentPortalRoute && !isLoginRouteName(to.name as string)) { + next({ path: getDefaultHomePathByType(OmsLoginType.Agent), replace: true }); + return; + } + if (!isAgent && isAgentPortalRoute) { + Message.warning('该页面仅渠道代理可访问,请使用代理账号登录'); + next({ + path: getDefaultHomePathByType(rbacUserStore.loginPortalType), + replace: true, + }); + return; + } + if ( !isGuestH5Entry && !canAccessCustomerPortalRoute(rbacUserStore.loginPortalType, { @@ -100,13 +120,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..3853113 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -48,6 +48,61 @@ 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/invite', + meta: { + requiresAuth: true, + hideInMenu: true, + agentPortalOnly: true, + }, + children: [ + { + 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/agent/orders/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/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/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/inviteLink.ts b/src/utils/inviteLink.ts index d452119..210c063 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 `【IDE Express】通过我的专属链接注册,微信打开即可:${url}\n代理编号:${code}`; +} diff --git a/src/utils/login-portal.ts b/src/utils/login-portal.ts index 42afc34..adb9cba 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/invite' + /** 客户端(客户)登录路由 */ 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..c730e3d --- /dev/null +++ b/src/views/agent-admin/commission/index.vue @@ -0,0 +1,246 @@ + + + + + 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/customers/index.vue b/src/views/agent/customers/index.vue new file mode 100644 index 0000000..1bed665 --- /dev/null +++ b/src/views/agent/customers/index.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/src/views/agent/invite/index.vue b/src/views/agent/invite/index.vue new file mode 100644 index 0000000..390ff2a --- /dev/null +++ b/src/views/agent/invite/index.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/src/views/agent/orders/index.vue b/src/views/agent/orders/index.vue new file mode 100644 index 0000000..36038e9 --- /dev/null +++ b/src/views/agent/orders/index.vue @@ -0,0 +1,285 @@ + + + + + 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" >