20261030 #16
534
docs/渠道代理与返佣设计方案.md
Normal file
534
docs/渠道代理与返佣设计方案.md
Normal file
@ -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` 漏改 | 对照客户登录分支清单 |
|
||||
| 只挂成功态 | 失败态也要挂钩 |
|
||||
153
src/api/agent/index.ts
Normal file
153
src/api/agent/index.ts
Normal file
@ -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<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
function unwrapPage<T>(data: any): PageResult<T> {
|
||||
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<PageResult<AgentDto>> {
|
||||
const r = await axiosInstance.post(omsUrl('/agent/admin/list'), body)
|
||||
return unwrapPage(r.data)
|
||||
},
|
||||
|
||||
async create(body: Record<string, unknown>): Promise<void> {
|
||||
const r = await axiosInstance.post(omsUrl('/agent/admin/create'), body)
|
||||
assertBusinessOk(r.data)
|
||||
},
|
||||
|
||||
async update(body: Record<string, unknown>): Promise<void> {
|
||||
const r = await axiosInstance.post(omsUrl('/agent/admin/update'), body)
|
||||
assertBusinessOk(r.data)
|
||||
},
|
||||
|
||||
async rebind(body: { customerId: number; agentId?: number | null }): Promise<void> {
|
||||
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<PageResult<AgentCommissionDto>> {
|
||||
const r = await axiosInstance.post(omsUrl('/agent/admin/commission/list'), body)
|
||||
return unwrapPage(r.data)
|
||||
},
|
||||
|
||||
async markPaid(ids: number[], remark?: string): Promise<void> {
|
||||
const r = await axiosInstance.post(omsUrl('/agent/admin/commission/mark-paid'), { ids, remark })
|
||||
assertBusinessOk(r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const AgentPortalApi = {
|
||||
async profile(): Promise<AgentDto> {
|
||||
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<PageResult<AgentPortalCustomerDto>> {
|
||||
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<PageResult<AgentPortalOrderDto>> {
|
||||
const r = await axiosInstance.post(omsUrl('/agent/portal/orders/list'), body)
|
||||
return unwrapPage(r.data)
|
||||
},
|
||||
|
||||
async commissions(body: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<PageResult<AgentCommissionDto>> {
|
||||
const r = await axiosInstance.post(omsUrl('/agent/portal/commission/list'), body)
|
||||
return unwrapPage(r.data)
|
||||
},
|
||||
}
|
||||
@ -162,7 +162,12 @@ export const CustomerService = {
|
||||
},
|
||||
|
||||
async create(
|
||||
data: Partial<CustomerDto> & { password?: string; inviteCode?: string; advisorCode?: string },
|
||||
data: Partial<CustomerDto> & {
|
||||
password?: string
|
||||
inviteCode?: string
|
||||
advisorCode?: string
|
||||
agentCode?: string
|
||||
},
|
||||
): Promise<void> {
|
||||
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)
|
||||
|
||||
@ -3,9 +3,10 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* 登录主体类型:0=内部用户(管理端),1=客户
|
||||
* 登录主体类型:0=内部用户(管理端),1=客户,2=渠道代理
|
||||
*/
|
||||
export enum OmsLoginType {
|
||||
User = 0,
|
||||
Customer = 1,
|
||||
Agent = 2,
|
||||
}
|
||||
|
||||
@ -58,6 +58,8 @@ export interface WeChatCustomerRegisterInput {
|
||||
inviteCode?: string;
|
||||
/** 顾问邀请(来自 ?advisor=,值为员工账号,注册时绑定所属顾问) */
|
||||
advisorCode?: string;
|
||||
/** 渠道代理编号(来自 ?agent=;有值时不走 C2C 邀请) */
|
||||
agentCode?: string;
|
||||
wechat: WeChatProfileInput;
|
||||
}
|
||||
|
||||
|
||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@ -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']
|
||||
|
||||
490
src/components/agent-side-rail/index.vue
Normal file
490
src/components/agent-side-rail/index.vue
Normal file
@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<aside
|
||||
class="agent-side-rail"
|
||||
:class="{
|
||||
'is-collapsed': collapsed,
|
||||
'is-compact': compactLayout,
|
||||
'is-animating': animating,
|
||||
}"
|
||||
:style="{
|
||||
width: `${railWidth}px`,
|
||||
'--asr-shell-width': `${AGENT_SIDE_RAIL_EXPANDED_WIDTH}px`,
|
||||
}"
|
||||
aria-label="渠道代理导航"
|
||||
@transitionend="onRailTransitionEnd"
|
||||
>
|
||||
<div class="agent-side-rail__shell">
|
||||
<a
|
||||
class="agent-side-rail__brand"
|
||||
href="/"
|
||||
:title="t('brand.fullName')"
|
||||
@click.prevent="goOfficialSite"
|
||||
>
|
||||
<img :src="navLogo" :alt="t('brand.name')" class="agent-side-rail__brand-logo" />
|
||||
<span class="agent-side-rail__brand-name agent-side-rail__fade">{{ t('brand.name') }}</span>
|
||||
</a>
|
||||
|
||||
<div class="agent-side-rail__scroll">
|
||||
<section class="agent-side-rail__group is-first">
|
||||
<a-tooltip
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:content="item.label"
|
||||
position="right"
|
||||
:disabled="!compactLayout"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="agent-side-rail__item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
:aria-current="isActive(item.path) ? 'page' : undefined"
|
||||
@click="onNavigate(item.path)"
|
||||
>
|
||||
<span class="agent-side-rail__icon">
|
||||
<component :is="item.icon" />
|
||||
</span>
|
||||
<span class="agent-side-rail__text agent-side-rail__fade">{{ item.label }}</span>
|
||||
</button>
|
||||
</a-tooltip>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="agent-side-rail__dock">
|
||||
<div class="agent-side-rail__toolbar">
|
||||
<div class="agent-side-rail__user">
|
||||
<RightTopUser
|
||||
variant="sider"
|
||||
compact
|
||||
dropdown-position="tl"
|
||||
:account-menus="[]"
|
||||
show-language-menu
|
||||
:show-recharge-cta="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="agent-side-rail__agent-meta agent-side-rail__fade">
|
||||
<span class="agent-side-rail__agent-name">{{ agentDisplayName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-side-rail__footer-row">
|
||||
<span class="agent-side-rail__portal-tag agent-side-rail__fade">渠道代理</span>
|
||||
<button
|
||||
type="button"
|
||||
class="agent-side-rail__toggle"
|
||||
:aria-label="collapsed ? t('nav.sideRail.expand') : t('nav.sideRail.collapse')"
|
||||
:title="collapsed ? t('nav.sideRail.expand') : t('nav.sideRail.collapse')"
|
||||
@click="toggleCollapsed"
|
||||
>
|
||||
<icon-double-right v-if="collapsed" class="agent-side-rail__toggle-icon" />
|
||||
<icon-double-left v-else class="agent-side-rail__toggle-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref, type Component } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
IconDoubleLeft,
|
||||
IconDoubleRight,
|
||||
IconList,
|
||||
IconShareInternal,
|
||||
IconUserGroup,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import RightTopUser from '@/components/top-nav/RightTopUser.vue'
|
||||
import { AgentPortalApi, type AgentDto } from '@/api/agent'
|
||||
import {
|
||||
CUSTOMER_SIDE_RAIL_COLLAPSED_WIDTH,
|
||||
CUSTOMER_SIDE_RAIL_EXPANDED_WIDTH,
|
||||
} from '@/utils/customer-portal-nav'
|
||||
import navLogo from '@/assets/logo2.png'
|
||||
|
||||
const AGENT_SIDE_RAIL_COLLAPSED_WIDTH = CUSTOMER_SIDE_RAIL_COLLAPSED_WIDTH
|
||||
const AGENT_SIDE_RAIL_EXPANDED_WIDTH = CUSTOMER_SIDE_RAIL_EXPANDED_WIDTH
|
||||
const AGENT_SIDE_RAIL_STORAGE_KEY = 'oms.agentSideRail.collapsed'
|
||||
const RAIL_ANIM_MS = 300
|
||||
|
||||
type AgentNavItem = {
|
||||
path: string
|
||||
label: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const collapsed = ref(readCollapsed())
|
||||
const compactLayout = ref(collapsed.value)
|
||||
const animating = ref(false)
|
||||
let compactTimer = 0
|
||||
|
||||
const profile = ref<AgentDto | null>(null)
|
||||
|
||||
const railWidth = computed(() =>
|
||||
collapsed.value ? AGENT_SIDE_RAIL_COLLAPSED_WIDTH : AGENT_SIDE_RAIL_EXPANDED_WIDTH,
|
||||
)
|
||||
|
||||
const navItems = computed<AgentNavItem[]>(() => [
|
||||
{
|
||||
path: '/agent/invite',
|
||||
label: '邀请获客',
|
||||
icon: IconShareInternal,
|
||||
},
|
||||
{
|
||||
path: '/agent/customers',
|
||||
label: '名下客户',
|
||||
icon: IconUserGroup,
|
||||
},
|
||||
{
|
||||
path: '/agent/orders',
|
||||
label: '订单与返佣',
|
||||
icon: IconList,
|
||||
},
|
||||
])
|
||||
|
||||
const agentDisplayName = computed(
|
||||
() => profile.value?.agentName || profile.value?.agentCode || '代理',
|
||||
)
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(AGENT_SIDE_RAIL_STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsed(value: boolean) {
|
||||
try {
|
||||
localStorage.setItem(AGENT_SIDE_RAIL_STORAGE_KEY, value ? '1' : '0')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(path: string) {
|
||||
const current = route.path
|
||||
return current === path || current.startsWith(`${path}/`)
|
||||
}
|
||||
|
||||
function onNavigate(path: string) {
|
||||
if (route.path === path) return
|
||||
void router.push(path)
|
||||
}
|
||||
|
||||
function goOfficialSite() {
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
function toggleCollapsed() {
|
||||
animating.value = true
|
||||
collapsed.value = !collapsed.value
|
||||
writeCollapsed(collapsed.value)
|
||||
if (compactTimer) window.clearTimeout(compactTimer)
|
||||
if (collapsed.value) {
|
||||
compactTimer = window.setTimeout(() => {
|
||||
compactLayout.value = true
|
||||
animating.value = false
|
||||
}, RAIL_ANIM_MS)
|
||||
} else {
|
||||
compactLayout.value = false
|
||||
compactTimer = window.setTimeout(() => {
|
||||
animating.value = false
|
||||
}, RAIL_ANIM_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function onRailTransitionEnd(e: TransitionEvent) {
|
||||
if (e.propertyName !== 'width') return
|
||||
if (!collapsed.value) compactLayout.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
profile.value = await AgentPortalApi.profile()
|
||||
} catch {
|
||||
profile.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (compactTimer) window.clearTimeout(compactTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.agent-side-rail {
|
||||
--asr-pad-x: 14px;
|
||||
--asr-item-h: 44px;
|
||||
--asr-radius: 10px;
|
||||
--asr-panel-radius: 4px;
|
||||
--asr-panel-inset: 16px;
|
||||
--asr-shell-width: 200px;
|
||||
--asr-ease: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--asr-dur: 0.3s;
|
||||
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
box-sizing: border-box;
|
||||
height: calc(100% - var(--asr-panel-inset) * 2);
|
||||
margin: var(--asr-panel-inset) 0 var(--asr-panel-inset) var(--asr-panel-inset);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--app-card-bg, #fff);
|
||||
border-radius: var(--asr-panel-radius);
|
||||
transition: width var(--asr-dur) var(--asr-ease);
|
||||
will-change: width;
|
||||
z-index: 10;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
--asr-dur: 0.01ms;
|
||||
}
|
||||
|
||||
&__shell {
|
||||
width: var(--asr-shell-width);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__fade {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s var(--asr-ease);
|
||||
}
|
||||
|
||||
&.is-collapsed &__fade {
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
pointer-events: none;
|
||||
transition-duration: 0.12s;
|
||||
}
|
||||
|
||||
&:not(.is-collapsed) &__fade {
|
||||
transition-delay: 0.1s;
|
||||
}
|
||||
|
||||
&__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
min-height: 56px;
|
||||
padding: 10px 16px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
&__brand-logo {
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__brand-name {
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 14px var(--asr-pad-x) 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
&__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: var(--asr-item-h);
|
||||
padding: 0 12px 0 14px;
|
||||
border: none;
|
||||
border-radius: var(--asr-radius);
|
||||
background: transparent;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 0.14s ease,
|
||||
color 0.14s ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 0;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--app-primary-color, rgb(var(--primary-6)));
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition:
|
||||
height 0.14s ease,
|
||||
opacity 0.14s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
background: rgba(var(--primary-6), 0.06);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--app-primary-color, rgb(var(--primary-6)));
|
||||
background: rgba(var(--primary-6), 0.1);
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
height: 18px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__dock {
|
||||
flex-shrink: 0;
|
||||
padding: 10px var(--asr-pad-x) 12px;
|
||||
border-top: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__user {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__agent-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__agent-name {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__footer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__portal-tag {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 30px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--app-primary-color, #1d7281);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.is-compact &__item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&.is-compact &__toolbar {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.is-compact &__portal-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -324,6 +324,10 @@ export const PAGE_BUTTON_PARENT_ALIASES: Record<string, string> = {
|
||||
'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: '查询' },
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<a-layout class="layout">
|
||||
<!-- 运营端顶栏;客户门户无顶栏(能力沉底侧栏) -->
|
||||
<TopNav v-if="!isCustomerShell" />
|
||||
<!-- 运营端顶栏;客户/代理门户无顶栏(能力沉底侧栏) -->
|
||||
<TopNav v-if="!isPortalShell" />
|
||||
|
||||
<!-- 客户门户:全高侧栏 + 主区(无多页签,当前页由侧栏高亮) -->
|
||||
<!-- 客户/代理门户:全高侧栏 + 主区(无多页签,当前页由侧栏高亮) -->
|
||||
<a-layout
|
||||
v-if="isCustomerShell"
|
||||
v-if="isPortalShell"
|
||||
class="layout-content layout-content--customer"
|
||||
>
|
||||
<CustomerSideRail />
|
||||
<CustomerSideRail v-if="isCustomerShell" />
|
||||
<AgentSideRail v-else-if="isAgentShell" />
|
||||
<a-layout class="layout-main-wrap">
|
||||
<a-layout-content class="layout-main">
|
||||
<PageLayout />
|
||||
@ -26,8 +27,8 @@
|
||||
</a-layout>
|
||||
|
||||
<NanaAssistant />
|
||||
<PortalSideDrawer />
|
||||
<HelpSideDrawer />
|
||||
<PortalSideDrawer v-if="isCustomerShell" />
|
||||
<HelpSideDrawer v-if="!isAgentShell" />
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '起始邮编',
|
||||
|
||||
@ -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: [] },
|
||||
];
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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',
|
||||
|
||||
50
src/router/routes/newModules/agent.ts
Normal file
50
src/router/routes/newModules/agent.ts
Normal file
@ -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;
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -22,6 +22,9 @@ export const SUB_MENU_DEFAULT_ICONS: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'/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 = {
|
||||
|
||||
246
src/views/agent-admin/commission/index.vue
Normal file
246
src/views/agent-admin/commission/index.vue
Normal file
@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="agent-admin-commission-container">
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="toolbar-filters">
|
||||
<a-input-number
|
||||
v-model="agentId"
|
||||
placeholder="代理Id"
|
||||
:min="1"
|
||||
allow-clear
|
||||
style="width: 140px"
|
||||
/>
|
||||
<a-select v-model="status" allow-clear placeholder="状态" style="width: 140px">
|
||||
<a-option value="pending">待结算</a-option>
|
||||
<a-option value="paid">已打款</a-option>
|
||||
</a-select>
|
||||
<a-button class="app-btn-search" @click="handleSearch">查询</a-button>
|
||||
</div>
|
||||
<a-button :disabled="!selectedIds.length" @click="markPaid">标记已打款</a-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-space :size="8">
|
||||
<a-button @click="loadList">刷新</a-button>
|
||||
<PaginationPro
|
||||
:total-count="totalCount"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
@page-change="loadList"
|
||||
@page-size-change="loadList"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :loading="listLoading" class="list-spin">
|
||||
<div ref="tableContainerRef" class="list-wrap">
|
||||
<HandsontablePro
|
||||
:columns="hotColumns"
|
||||
:data="tableRows"
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:stretch-columns="true"
|
||||
:height="handsontableHeight"
|
||||
:read-only="true"
|
||||
:show-action-column="true"
|
||||
:action-renderer="actionRenderer"
|
||||
:show-checkbox-column="true"
|
||||
@selection-change="onSelectionChange"
|
||||
:custom-cell-renderer="customCellRenderer"
|
||||
@action-click="handleActionClick"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||
import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||
import { buildStatusTagHtml } from '@/utils/statusTag'
|
||||
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
|
||||
import { AgentAdminApi, type AgentCommissionDto } from '@/api/agent'
|
||||
|
||||
defineOptions({ name: 'AgentAdminCommission' })
|
||||
|
||||
const { tableContainerRef, tableHeight } = useTableContainerHeight()
|
||||
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
|
||||
|
||||
interface CommissionRow {
|
||||
id: number
|
||||
agentCode: string
|
||||
agentName: string
|
||||
serviceNo: string
|
||||
customerCode: string
|
||||
customerName: string
|
||||
baseType: string
|
||||
baseAmount: number
|
||||
commissionRate: number
|
||||
commissionAmount: number
|
||||
status: string
|
||||
statusName: string
|
||||
triggerOrderStatus: string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
const tableRows = ref<CommissionRow[]>([])
|
||||
const totalCount = ref(0)
|
||||
const listLoading = ref(false)
|
||||
const agentId = ref<number | undefined>()
|
||||
const status = ref<string | undefined>()
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const selectedRows = ref<CommissionRow[]>([])
|
||||
const selectedIds = computed(() => selectedRows.value.map((x) => x.id).filter((x) => x > 0))
|
||||
|
||||
const hotColumns = computed(() => [
|
||||
{ key: 'agentCode', label: '代理编号', width: 110 },
|
||||
{ key: 'serviceNo', label: '运单号', width: 140 },
|
||||
{ key: 'customerCode', label: '客户编号', width: 110 },
|
||||
{ key: 'baseType', label: '基数类型', width: 100 },
|
||||
{ key: 'baseAmount', label: '基数', width: 100 },
|
||||
{ key: 'commissionRate', label: '比例', width: 80 },
|
||||
{ key: 'commissionAmount', label: '返佣金额', width: 100 },
|
||||
{ key: 'statusName', label: '状态', width: 90 },
|
||||
{ key: 'triggerOrderStatus', label: '触发状态', width: 120 },
|
||||
{ key: 'createTime', label: '计提时间', width: 160 },
|
||||
])
|
||||
|
||||
function pick(row: Record<string, unknown>, camel: string, pascal: string) {
|
||||
return row[camel] ?? row[pascal]
|
||||
}
|
||||
|
||||
function mapRow(raw: AgentCommissionDto): CommissionRow {
|
||||
const r = raw as unknown as Record<string, unknown>
|
||||
const st = String(pick(r, 'status', 'Status') ?? '')
|
||||
return {
|
||||
id: Number(pick(r, 'id', 'Id') ?? 0),
|
||||
agentCode: String(pick(r, 'agentCode', 'AgentCode') ?? ''),
|
||||
agentName: String(pick(r, 'agentName', 'AgentName') ?? ''),
|
||||
serviceNo: String(pick(r, 'serviceNo', 'ServiceNo') ?? ''),
|
||||
customerCode: String(pick(r, 'customerCode', 'CustomerCode') ?? ''),
|
||||
customerName: String(pick(r, 'customerName', 'CustomerName') ?? ''),
|
||||
baseType: String(pick(r, 'baseType', 'BaseType') ?? ''),
|
||||
baseAmount: Number(pick(r, 'baseAmount', 'BaseAmount') ?? 0),
|
||||
commissionRate: Number(pick(r, 'commissionRate', 'CommissionRate') ?? 0),
|
||||
commissionAmount: Number(pick(r, 'commissionAmount', 'CommissionAmount') ?? 0),
|
||||
status: st,
|
||||
statusName: st === 'paid' ? '已打款' : '待结算',
|
||||
triggerOrderStatus: String(pick(r, 'triggerOrderStatus', 'TriggerOrderStatus') ?? ''),
|
||||
createTime: String(pick(r, 'createTime', 'CreateTime') ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
function actionRenderer(row: CommissionRow, rowIndex: number): string {
|
||||
if (row.status === 'paid') {
|
||||
return '<span style="color:var(--color-text-3);font-size:12px;">--</span>'
|
||||
}
|
||||
return wrapWarehouseActions(
|
||||
[renderWarehouseActionBtn('markPaid', '标记已打款', 'pay')],
|
||||
rowIndex,
|
||||
)
|
||||
}
|
||||
|
||||
function handleActionClick(row: CommissionRow, _rowIndex: number, action?: string) {
|
||||
if (action === 'markPaid') void markPaidIds([row.id])
|
||||
}
|
||||
|
||||
function customCellRenderer(_row: number, col: number, _value: unknown, rowData: CommissionRow): string | null {
|
||||
// 操作列 + 序号列 + 复选框列
|
||||
const dataColIndex = col - 3
|
||||
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
|
||||
const colKey = hotColumns.value[dataColIndex].key
|
||||
if (colKey === 'statusName') {
|
||||
return buildStatusTagHtml(rowData.statusName, rowData.status === 'paid' ? 'ok' : 'wait')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function onSelectionChange(rows: CommissionRow[]) {
|
||||
selectedRows.value = rows || []
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await AgentAdminApi.commissionList({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
agentId: agentId.value,
|
||||
status: status.value,
|
||||
})
|
||||
tableRows.value = (res.items || []).map(mapRow)
|
||||
totalCount.value = res.total
|
||||
selectedRows.value = []
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
void loadList()
|
||||
}
|
||||
|
||||
async function markPaidIds(ids: number[]) {
|
||||
const valid = ids.map(Number).filter((x) => x > 0)
|
||||
if (!valid.length) return
|
||||
try {
|
||||
await AgentAdminApi.markPaid(valid)
|
||||
Message.success('已标记')
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function markPaid() {
|
||||
await markPaidIds(selectedIds.value)
|
||||
}
|
||||
|
||||
onMounted(() => void loadList())
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.agent-admin-commission-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.toolbar-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.list-spin {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
368
src/views/agent-admin/list/index.vue
Normal file
368
src/views/agent-admin/list/index.vue
Normal file
@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="agent-admin-list-container">
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="toolbar-filters">
|
||||
<a-input
|
||||
v-model="keyword"
|
||||
allow-clear
|
||||
style="width: 220px"
|
||||
placeholder="编号/名称/手机"
|
||||
@press-enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<a-button class="app-btn-search" @click="handleSearch">查询</a-button>
|
||||
</div>
|
||||
<a-button type="primary" @click="openCreate">新建代理</a-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-space :size="8">
|
||||
<a-button @click="loadList">刷新</a-button>
|
||||
<PaginationPro
|
||||
:total-count="totalCount"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
@page-change="loadList"
|
||||
@page-size-change="loadList"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :loading="listLoading" class="list-spin">
|
||||
<div ref="tableContainerRef" class="list-wrap">
|
||||
<HandsontablePro
|
||||
:columns="hotColumns"
|
||||
:data="tableRows"
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:stretch-columns="true"
|
||||
:height="handsontableHeight"
|
||||
:read-only="true"
|
||||
:show-action-column="true"
|
||||
:action-renderer="actionRenderer"
|
||||
:custom-cell-renderer="customCellRenderer"
|
||||
@action-click="handleActionClick"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<ModalPro
|
||||
v-model:visible="visible"
|
||||
:title="editingId ? '编辑代理' : '新建代理'"
|
||||
:confirm-loading="saving"
|
||||
width="560px"
|
||||
unmount-on-close
|
||||
@ok="handleSave"
|
||||
@cancel="visible = false"
|
||||
>
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item v-if="!editingId" label="代理编号" required>
|
||||
<a-input v-model="form.agentCode" placeholder="登录账号 / 注册链接 ?agent=" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称" required>
|
||||
<a-input v-model="form.agentName" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机">
|
||||
<a-input v-model="form.phone" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱">
|
||||
<a-input v-model="form.email" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="editingId ? '新密码(可选)' : '密码'" :required="!editingId">
|
||||
<a-input-password v-model="form.password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="基数类型" required>
|
||||
<a-select v-model="form.baseType">
|
||||
<a-option value="freight">运费 freight</a-option>
|
||||
<a-option value="receivable">应收 receivable</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="返佣比例" required>
|
||||
<a-input-number
|
||||
v-model="form.commissionRate"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="销售产品Id(逗号或JSON,空=不限)">
|
||||
<a-input v-model="form.salesProductIds" placeholder="如 1,2,3" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="editingId" label="状态">
|
||||
<a-select v-model="form.status">
|
||||
<a-option :value="2">启用</a-option>
|
||||
<a-option :value="1">停用</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model="form.remark" :max-length="512" />
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
v-if="!editingId"
|
||||
type="info"
|
||||
content="注册链接示例:/register?agent=代理编号;代理登录:/agent"
|
||||
/>
|
||||
</a-form>
|
||||
</ModalPro>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||
import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||
import ModalPro from '@/components/ModalPro/index.vue'
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||
import { buildStatusTagHtml } from '@/utils/statusTag'
|
||||
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
|
||||
import { AgentAdminApi, type AgentDto } from '@/api/agent'
|
||||
|
||||
defineOptions({ name: 'AgentAdminList' })
|
||||
|
||||
const { tableContainerRef, tableHeight } = useTableContainerHeight()
|
||||
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
|
||||
|
||||
interface AgentRow {
|
||||
id: number
|
||||
agentCode: string
|
||||
agentName: string
|
||||
phone: string
|
||||
email: string
|
||||
baseType: string
|
||||
commissionRate: number
|
||||
salesProductIds: string
|
||||
status: number
|
||||
statusName: string
|
||||
remark: string
|
||||
createTime: string
|
||||
_raw: AgentDto
|
||||
}
|
||||
|
||||
const tableRows = ref<AgentRow[]>([])
|
||||
const totalCount = ref(0)
|
||||
const listLoading = ref(false)
|
||||
const keyword = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
|
||||
const visible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
agentCode: '',
|
||||
agentName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
password: '',
|
||||
baseType: 'freight',
|
||||
commissionRate: 0.03,
|
||||
salesProductIds: '',
|
||||
status: 2,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const hotColumns = computed(() => [
|
||||
{ key: 'agentCode', label: '代理编号', width: 120 },
|
||||
{ key: 'agentName', label: '名称', width: 140 },
|
||||
{ key: 'phone', label: '手机', width: 120 },
|
||||
{ key: 'baseType', label: '基数', width: 100 },
|
||||
{ key: 'commissionRate', label: '比例', width: 90 },
|
||||
{ key: 'salesProductIds', label: '产品限制', width: 140 },
|
||||
{ key: 'statusName', label: '状态', width: 80 },
|
||||
{ key: 'createTime', label: '创建时间', width: 160 },
|
||||
])
|
||||
|
||||
function pick(row: Record<string, unknown>, camel: string, pascal: string) {
|
||||
return row[camel] ?? row[pascal]
|
||||
}
|
||||
|
||||
function mapRow(raw: AgentDto): AgentRow {
|
||||
const r = raw as unknown as Record<string, unknown>
|
||||
const status = Number(pick(r, 'status', 'Status') ?? 0)
|
||||
return {
|
||||
id: Number(pick(r, 'id', 'Id') ?? 0),
|
||||
agentCode: String(pick(r, 'agentCode', 'AgentCode') ?? ''),
|
||||
agentName: String(pick(r, 'agentName', 'AgentName') ?? ''),
|
||||
phone: String(pick(r, 'phone', 'Phone') ?? ''),
|
||||
email: String(pick(r, 'email', 'Email') ?? ''),
|
||||
baseType: String(pick(r, 'baseType', 'BaseType') ?? ''),
|
||||
commissionRate: Number(pick(r, 'commissionRate', 'CommissionRate') ?? 0),
|
||||
salesProductIds: String(pick(r, 'salesProductIds', 'SalesProductIds') ?? ''),
|
||||
status,
|
||||
statusName: status === 2 ? '启用' : '停用',
|
||||
remark: String(pick(r, 'remark', 'Remark') ?? ''),
|
||||
createTime: String(pick(r, 'createTime', 'CreateTime') ?? ''),
|
||||
_raw: raw,
|
||||
}
|
||||
}
|
||||
|
||||
function actionRenderer(_row: AgentRow, rowIndex: number): string {
|
||||
return wrapWarehouseActions([renderWarehouseActionBtn('edit', '编辑')], rowIndex)
|
||||
}
|
||||
|
||||
function handleActionClick(row: AgentRow, _rowIndex: number, action?: string) {
|
||||
if (action === 'edit') openEdit(row)
|
||||
}
|
||||
|
||||
function customCellRenderer(_row: number, col: number, _value: unknown, rowData: AgentRow): string | null {
|
||||
// 操作列 + 序号列
|
||||
const dataColIndex = col - 2
|
||||
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
|
||||
const colKey = hotColumns.value[dataColIndex].key
|
||||
if (colKey === 'statusName') {
|
||||
return buildStatusTagHtml(rowData.statusName, rowData.status === 2 ? 'ok' : 'mute')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.agentCode = ''
|
||||
form.agentName = ''
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
form.password = ''
|
||||
form.baseType = 'freight'
|
||||
form.commissionRate = 0.03
|
||||
form.salesProductIds = ''
|
||||
form.status = 2
|
||||
form.remark = ''
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await AgentAdminApi.list({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
})
|
||||
tableRows.value = (res.items || []).map(mapRow)
|
||||
totalCount.value = res.total
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
void loadList()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: AgentRow) {
|
||||
editingId.value = row.id
|
||||
form.agentCode = row.agentCode
|
||||
form.agentName = row.agentName
|
||||
form.phone = row.phone
|
||||
form.email = row.email
|
||||
form.password = ''
|
||||
form.baseType = row.baseType || 'freight'
|
||||
form.commissionRate = row.commissionRate
|
||||
form.salesProductIds = row.salesProductIds
|
||||
form.status = row.status
|
||||
form.remark = row.remark
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.agentName.trim()) {
|
||||
Message.warning('请填写名称')
|
||||
return false
|
||||
}
|
||||
if (!editingId.value && (!form.agentCode.trim() || !form.password)) {
|
||||
Message.warning('请填写代理编号和密码')
|
||||
return false
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await AgentAdminApi.update({
|
||||
id: editingId.value,
|
||||
agentName: form.agentName.trim(),
|
||||
phone: form.phone.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
password: form.password || undefined,
|
||||
status: form.status,
|
||||
baseType: form.baseType,
|
||||
commissionRate: form.commissionRate,
|
||||
salesProductIds: form.salesProductIds.trim() || undefined,
|
||||
remark: form.remark.trim() || undefined,
|
||||
})
|
||||
} else {
|
||||
await AgentAdminApi.create({
|
||||
agentCode: form.agentCode.trim(),
|
||||
agentName: form.agentName.trim(),
|
||||
phone: form.phone.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
password: form.password,
|
||||
baseType: form.baseType,
|
||||
commissionRate: form.commissionRate,
|
||||
salesProductIds: form.salesProductIds.trim() || undefined,
|
||||
remark: form.remark.trim() || undefined,
|
||||
})
|
||||
}
|
||||
Message.success('已保存')
|
||||
visible.value = false
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '保存失败')
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.agent-admin-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.toolbar-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.list-spin {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
175
src/views/agent/customers/index.vue
Normal file
175
src/views/agent/customers/index.vue
Normal file
@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="agent-customers-page">
|
||||
<div class="agent-customers-page-card">
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<a-input
|
||||
v-model="keyword"
|
||||
allow-clear
|
||||
placeholder="客户编号/名称/手机"
|
||||
style="width: 240px"
|
||||
@press-enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<a-button class="app-btn-search" @click="handleSearch">查询</a-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-space :size="8">
|
||||
<a-button @click="loadList">刷新</a-button>
|
||||
<PaginationPro
|
||||
:total-count="totalCount"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
@page-change="loadList"
|
||||
@page-size-change="loadList"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :loading="listLoading" class="list-spin">
|
||||
<div ref="tableContainerRef" class="list-wrap">
|
||||
<HandsontablePro
|
||||
:columns="hotColumns"
|
||||
:data="tableRows"
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:stretch-columns="true"
|
||||
:height="handsontableHeight"
|
||||
:read-only="true"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||
import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||
import { AgentPortalApi, type AgentPortalCustomerDto } from '@/api/agent'
|
||||
|
||||
defineOptions({ name: 'AgentPortalCustomers' })
|
||||
|
||||
const { tableContainerRef, tableHeight } = useTableContainerHeight()
|
||||
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 320))
|
||||
|
||||
interface CustomerRow {
|
||||
id: number
|
||||
customerCode: string
|
||||
customerName: string
|
||||
phone: string
|
||||
agentBindTime: string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
const tableRows = ref<CustomerRow[]>([])
|
||||
const totalCount = ref(0)
|
||||
const listLoading = ref(false)
|
||||
const keyword = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
|
||||
const hotColumns = computed(() => [
|
||||
{ key: 'customerCode', label: '客户编号', width: 140 },
|
||||
{ key: 'customerName', label: '客户名称', width: 160 },
|
||||
{ key: 'phone', label: '手机', width: 130 },
|
||||
{ key: 'agentBindTime', label: '绑定时间', width: 170 },
|
||||
{ key: 'createTime', label: '注册时间', width: 170 },
|
||||
])
|
||||
|
||||
function pick(row: Record<string, unknown>, camel: string, pascal: string) {
|
||||
return row[camel] ?? row[pascal]
|
||||
}
|
||||
|
||||
function mapRow(raw: AgentPortalCustomerDto): CustomerRow {
|
||||
const r = raw as unknown as Record<string, unknown>
|
||||
return {
|
||||
id: Number(pick(r, 'id', 'Id') ?? 0),
|
||||
customerCode: String(pick(r, 'customerCode', 'CustomerCode') ?? ''),
|
||||
customerName: String(pick(r, 'customerName', 'CustomerName') ?? ''),
|
||||
phone: String(pick(r, 'phone', 'Phone') ?? ''),
|
||||
agentBindTime: String(pick(r, 'agentBindTime', 'AgentBindTime') ?? ''),
|
||||
createTime: String(pick(r, 'createTime', 'CreateTime') ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await AgentPortalApi.customers({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
})
|
||||
tableRows.value = (res.items || []).map(mapRow)
|
||||
totalCount.value = Number(res.total || 0)
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载失败')
|
||||
tableRows.value = []
|
||||
totalCount.value = 0
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
void loadList()
|
||||
}
|
||||
|
||||
onMounted(() => void loadList())
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.agent-customers-page {
|
||||
padding: 16px;
|
||||
background: var(--app-page-bg);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-customers-page-card {
|
||||
background: var(--app-card-bg);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-spin {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
374
src/views/agent/invite/index.vue
Normal file
374
src/views/agent/invite/index.vue
Normal file
@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<div class="agent-invite-page">
|
||||
<a-spin :loading="loading" class="invite-spin">
|
||||
<template v-if="agentCode">
|
||||
<div class="invite-grid">
|
||||
<section class="invite-card invite-card--hero">
|
||||
<p class="invite-card__eyebrow">我的代理编号</p>
|
||||
<div class="invite-code-row">
|
||||
<span class="invite-code">{{ agentCode }}</span>
|
||||
<span class="invite-pill">渠道代理</span>
|
||||
</div>
|
||||
<p class="invite-card__desc">
|
||||
客户扫码或打开专属链接完成注册后,自动绑定到你名下;派送成功/失败后按比例计提返佣。
|
||||
</p>
|
||||
<div class="invite-card__actions">
|
||||
<a-button type="primary" class="app-btn-primary-soft" @click="copyShareText">
|
||||
复制邀请话术
|
||||
</a-button>
|
||||
<a-button @click="copyText(agentCode)">复制编号</a-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="invite-card invite-card--qr">
|
||||
<div class="invite-card__head">
|
||||
<h3 class="invite-card__title">微信扫码注册</h3>
|
||||
<span class="invite-card__badge">推荐</span>
|
||||
</div>
|
||||
<p class="invite-card__hint">客户用微信扫一扫即可打开注册页</p>
|
||||
<div class="qr-panel">
|
||||
<InviteQrImage
|
||||
v-if="wechatUrl"
|
||||
:value="wechatUrl"
|
||||
:size="168"
|
||||
alt="代理邀请二维码"
|
||||
/>
|
||||
</div>
|
||||
<div class="link-row">
|
||||
<a-input :model-value="wechatUrl" readonly class="link-input" />
|
||||
<a-button class="app-btn-search" @click="copyText(wechatUrl)">复制链接</a-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="invite-card invite-card--pc">
|
||||
<div class="invite-card__head">
|
||||
<h3 class="invite-card__title">PC 注册链接</h3>
|
||||
</div>
|
||||
<p class="invite-card__hint">适合电脑端分享或嵌入话术</p>
|
||||
<div class="link-row">
|
||||
<a-input :model-value="pcUrl" readonly class="link-input" />
|
||||
<a-button @click="copyText(pcUrl)">复制链接</a-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="invite-card invite-card--howto">
|
||||
<div class="invite-card__head">
|
||||
<h3 class="invite-card__title">怎么用</h3>
|
||||
</div>
|
||||
<ol class="howto-list">
|
||||
<li>
|
||||
<span class="howto-index">01</span>
|
||||
<div>
|
||||
<div class="howto-title">分享链接或二维码</div>
|
||||
<div class="howto-desc">发给目标客户,微信扫码更省事</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="howto-index">02</span>
|
||||
<div>
|
||||
<div class="howto-title">客户完成注册</div>
|
||||
<div class="howto-desc">链接带 agent 参数,注册即绑定本代理</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="howto-index">03</span>
|
||||
<div>
|
||||
<div class="howto-title">查看客户与返佣</div>
|
||||
<div class="howto-desc">「名下客户」看绑定,「订单与返佣」看计提</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="!loading" class="invite-card invite-card--empty">
|
||||
<p class="invite-empty__title">暂无法获取代理编号</p>
|
||||
<a-button type="primary" @click="loadProfile">重试</a-button>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import InviteQrImage from '@/components/InviteQrImage.vue'
|
||||
import { AgentPortalApi, type AgentDto } from '@/api/agent'
|
||||
import {
|
||||
buildAgentInviteRegisterUrl,
|
||||
buildAgentInviteShareText,
|
||||
buildAgentInviteWechatRegisterUrl,
|
||||
} from '@/utils/inviteLink'
|
||||
|
||||
defineOptions({ name: 'AgentPortalInvite' })
|
||||
|
||||
const loading = ref(false)
|
||||
const profile = ref<AgentDto | null>(null)
|
||||
|
||||
const agentCode = computed(() => {
|
||||
const raw = profile.value as unknown as Record<string, unknown> | null
|
||||
return String(raw?.agentCode ?? raw?.AgentCode ?? '').trim()
|
||||
})
|
||||
|
||||
const pcUrl = computed(() => buildAgentInviteRegisterUrl(agentCode.value))
|
||||
const wechatUrl = computed(() => buildAgentInviteWechatRegisterUrl(agentCode.value))
|
||||
const shareText = computed(() =>
|
||||
buildAgentInviteShareText(agentCode.value, wechatUrl.value || pcUrl.value),
|
||||
)
|
||||
|
||||
async function copyText(text: string) {
|
||||
const value = text.trim()
|
||||
if (!value) {
|
||||
Message.warning('内容为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
Message.success('已复制')
|
||||
} catch {
|
||||
Message.error('复制失败,请手动选择复制')
|
||||
}
|
||||
}
|
||||
|
||||
function copyShareText() {
|
||||
void copyText(shareText.value)
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
loading.value = true
|
||||
try {
|
||||
profile.value = await AgentPortalApi.profile()
|
||||
} catch (e: any) {
|
||||
profile.value = null
|
||||
Message.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadProfile())
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.agent-invite-page {
|
||||
padding: 16px;
|
||||
background: var(--app-page-bg);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.invite-spin {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr);
|
||||
grid-template-areas:
|
||||
'hero qr'
|
||||
'pc howto';
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.invite-card {
|
||||
background: var(--app-card-bg, #fff);
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.invite-card--hero {
|
||||
grid-area: hero;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-card--qr {
|
||||
grid-area: qr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.invite-card--pc {
|
||||
grid-area: pc;
|
||||
}
|
||||
|
||||
.invite-card--howto {
|
||||
grid-area: howto;
|
||||
}
|
||||
|
||||
.invite-card--empty {
|
||||
max-width: 420px;
|
||||
margin: 48px auto;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-card__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.invite-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.invite-card__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.invite-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--app-primary-color, #1d7281);
|
||||
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 12%, transparent);
|
||||
}
|
||||
|
||||
.invite-card__hint,
|
||||
.invite-card__desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.invite-card__hint {
|
||||
margin-bottom: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.invite-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.invite-code-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invite-code {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.1;
|
||||
color: var(--app-primary-color, #1d7281);
|
||||
}
|
||||
|
||||
.invite-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, var(--app-card-bg, #fff));
|
||||
border: 1px solid color-mix(in srgb, var(--app-primary-color, #1d7281) 18%, var(--color-border-2));
|
||||
}
|
||||
|
||||
.qr-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-fill-1, #f7f8fa);
|
||||
border: 1px solid var(--color-border-2, #e5e6eb);
|
||||
}
|
||||
|
||||
.link-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.howto-list {
|
||||
list-style: none;
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.howto-list li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.howto-index {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 22px;
|
||||
color: var(--app-primary-color, #1d7281);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.howto-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.howto-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.invite-empty__title {
|
||||
margin: 0;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.invite-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'hero'
|
||||
'qr'
|
||||
'pc'
|
||||
'howto';
|
||||
}
|
||||
}
|
||||
</style>
|
||||
285
src/views/agent/orders/index.vue
Normal file
285
src/views/agent/orders/index.vue
Normal file
@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="agent-orders-page">
|
||||
<div class="agent-orders-page-card">
|
||||
<a-tabs v-model:active-key="tab" class="orders-tabs" @change="onTabChange">
|
||||
<a-tab-pane key="orders" title="订单" />
|
||||
<a-tab-pane key="commission" title="返佣明细" />
|
||||
</a-tabs>
|
||||
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<a-input
|
||||
v-if="tab === 'orders'"
|
||||
v-model="keyword"
|
||||
allow-clear
|
||||
placeholder="运单号/追踪号/产品"
|
||||
style="width: 240px"
|
||||
@press-enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<a-button v-if="tab === 'orders'" class="app-btn-search" @click="handleSearch">
|
||||
查询
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-space :size="8">
|
||||
<a-button @click="loadList">刷新</a-button>
|
||||
<PaginationPro
|
||||
:total-count="totalCount"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
@page-change="loadList"
|
||||
@page-size-change="loadList"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :loading="listLoading" class="list-spin">
|
||||
<div ref="tableContainerRef" class="list-wrap">
|
||||
<HandsontablePro
|
||||
:columns="hotColumns"
|
||||
:data="tableRows"
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:stretch-columns="true"
|
||||
:height="handsontableHeight"
|
||||
:read-only="true"
|
||||
:custom-cell-renderer="customCellRenderer"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||
import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||
import { buildStatusTagHtml } from '@/utils/statusTag'
|
||||
import {
|
||||
AgentPortalApi,
|
||||
type AgentCommissionDto,
|
||||
type AgentPortalOrderDto,
|
||||
} from '@/api/agent'
|
||||
|
||||
defineOptions({ name: 'AgentPortalOrders' })
|
||||
|
||||
const { tableContainerRef, tableHeight } = useTableContainerHeight()
|
||||
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 320))
|
||||
|
||||
type TabKey = 'orders' | 'commission'
|
||||
|
||||
interface OrderRow {
|
||||
id: number
|
||||
serviceNo: string
|
||||
trackNo: string
|
||||
customerName: string
|
||||
productCode: string
|
||||
consigneeCountryName: string
|
||||
orderStatus: string
|
||||
receivableSubtotal: number | string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
interface CommissionRow {
|
||||
id: number
|
||||
serviceNo: string
|
||||
customerName: string
|
||||
baseType: string
|
||||
baseAmount: number | string
|
||||
commissionRate: number | string
|
||||
commissionAmount: number | string
|
||||
status: string
|
||||
statusName: string
|
||||
triggerOrderStatus: string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
const tab = ref<TabKey>('orders')
|
||||
const listLoading = ref(false)
|
||||
const tableRows = ref<Array<OrderRow | CommissionRow>>([])
|
||||
const totalCount = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const keyword = ref('')
|
||||
|
||||
const orderColumns = [
|
||||
{ key: 'serviceNo', label: '运单号', width: 140 },
|
||||
{ key: 'trackNo', label: '追踪号', width: 140 },
|
||||
{ key: 'customerName', label: '客户', width: 140 },
|
||||
{ key: 'productCode', label: '产品', width: 120 },
|
||||
{ key: 'consigneeCountryName', label: '国家', width: 100 },
|
||||
{ key: 'orderStatus', label: '状态', width: 110 },
|
||||
{ key: 'receivableSubtotal', label: '应收', width: 100 },
|
||||
{ key: 'createTime', label: '下单时间', width: 170 },
|
||||
]
|
||||
|
||||
const commissionColumns = [
|
||||
{ key: 'serviceNo', label: '运单号', width: 140 },
|
||||
{ key: 'customerName', label: '客户', width: 140 },
|
||||
{ key: 'baseType', label: '基数类型', width: 100 },
|
||||
{ key: 'baseAmount', label: '基数', width: 100 },
|
||||
{ key: 'commissionRate', label: '比例', width: 80 },
|
||||
{ key: 'commissionAmount', label: '返佣金额', width: 110 },
|
||||
{ key: 'statusName', label: '状态', width: 90 },
|
||||
{ key: 'triggerOrderStatus', label: '触发状态', width: 120 },
|
||||
{ key: 'createTime', label: '计提时间', width: 170 },
|
||||
]
|
||||
|
||||
const hotColumns = computed(() =>
|
||||
tab.value === 'orders' ? orderColumns : commissionColumns,
|
||||
)
|
||||
|
||||
function pick(row: Record<string, unknown>, camel: string, pascal: string) {
|
||||
return row[camel] ?? row[pascal]
|
||||
}
|
||||
|
||||
function mapOrder(raw: AgentPortalOrderDto): OrderRow {
|
||||
const r = raw as unknown as Record<string, unknown>
|
||||
return {
|
||||
id: Number(pick(r, 'id', 'Id') ?? 0),
|
||||
serviceNo: String(pick(r, 'serviceNo', 'ServiceNo') ?? ''),
|
||||
trackNo: String(pick(r, 'trackNo', 'TrackNo') ?? ''),
|
||||
customerName: String(pick(r, 'customerName', 'CustomerName') ?? ''),
|
||||
productCode: String(pick(r, 'productCode', 'ProductCode') ?? ''),
|
||||
consigneeCountryName: String(pick(r, 'consigneeCountryName', 'ConsigneeCountryName') ?? ''),
|
||||
orderStatus: String(pick(r, 'orderStatus', 'OrderStatus') ?? ''),
|
||||
receivableSubtotal: pick(r, 'receivableSubtotal', 'ReceivableSubtotal') as number | string,
|
||||
createTime: String(pick(r, 'createTime', 'CreateTime') ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
function mapCommission(raw: AgentCommissionDto): CommissionRow {
|
||||
const r = raw as unknown as Record<string, unknown>
|
||||
const st = String(pick(r, 'status', 'Status') ?? '')
|
||||
return {
|
||||
id: Number(pick(r, 'id', 'Id') ?? 0),
|
||||
serviceNo: String(pick(r, 'serviceNo', 'ServiceNo') ?? ''),
|
||||
customerName: String(pick(r, 'customerName', 'CustomerName') ?? ''),
|
||||
baseType: String(pick(r, 'baseType', 'BaseType') ?? ''),
|
||||
baseAmount: pick(r, 'baseAmount', 'BaseAmount') as number | string,
|
||||
commissionRate: pick(r, 'commissionRate', 'CommissionRate') as number | string,
|
||||
commissionAmount: pick(r, 'commissionAmount', 'CommissionAmount') as number | string,
|
||||
status: st,
|
||||
statusName: st === 'paid' ? '已打款' : '待结算',
|
||||
triggerOrderStatus: String(pick(r, 'triggerOrderStatus', 'TriggerOrderStatus') ?? ''),
|
||||
createTime: String(pick(r, 'createTime', 'CreateTime') ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
function customCellRenderer(
|
||||
_row: number,
|
||||
col: number,
|
||||
_value: unknown,
|
||||
rowData: OrderRow | CommissionRow,
|
||||
): string | null {
|
||||
// 仅序号列
|
||||
const dataColIndex = col - 1
|
||||
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
|
||||
const colKey = hotColumns.value[dataColIndex].key
|
||||
if (tab.value === 'commission' && colKey === 'statusName') {
|
||||
const c = rowData as CommissionRow
|
||||
return buildStatusTagHtml(c.statusName, c.status === 'paid' ? 'ok' : 'wait')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
if (tab.value === 'orders') {
|
||||
const res = await AgentPortalApi.orders({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
})
|
||||
tableRows.value = (res.items || []).map(mapOrder)
|
||||
totalCount.value = Number(res.total || 0)
|
||||
} else {
|
||||
const res = await AgentPortalApi.commissions({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
tableRows.value = (res.items || []).map(mapCommission)
|
||||
totalCount.value = Number(res.total || 0)
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载失败')
|
||||
tableRows.value = []
|
||||
totalCount.value = 0
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
void loadList()
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
currentPage.value = 1
|
||||
keyword.value = ''
|
||||
void loadList()
|
||||
}
|
||||
|
||||
onMounted(() => void loadList())
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.agent-orders-page {
|
||||
padding: 16px;
|
||||
background: var(--app-page-bg);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-orders-page-card {
|
||||
background: var(--app-card-bg);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.orders-tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-spin {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div ref="tableContainerRef" class="channel-config-hot-table">
|
||||
<HandsontablePro
|
||||
:columns="columns"
|
||||
:columns="resolvedColumns"
|
||||
:data="data"
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
@ -13,17 +13,24 @@
|
||||
:custom-cell-renderer="customCellRenderer"
|
||||
:stretch-columns="true"
|
||||
:height="handsontableHeight"
|
||||
:enable-header-search="false"
|
||||
:auto-hybrid-header="false"
|
||||
:search-params="resolvedSearchParams"
|
||||
:enable-header-search="true"
|
||||
:auto-hybrid-header="true"
|
||||
:enable-column-filter="false"
|
||||
:sort-mode="sortMode"
|
||||
:sort-field="sortField"
|
||||
:sort-order="sortOrder"
|
||||
@search="handleSearch"
|
||||
@sort-change="handleSortChange"
|
||||
@action-click="handleActionClick"
|
||||
@selection-change="handleSelectionChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
|
||||
import { computed } from 'vue';
|
||||
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer';
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import HandsontablePro from '@/components/HandsontablePro/index.vue';
|
||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
|
||||
@ -36,6 +43,8 @@ import { computed } from 'vue';
|
||||
rowData: any
|
||||
) => string | null;
|
||||
|
||||
type SortOrder = 'asc' | 'desc' | null;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns: ChannelHotColumn[];
|
||||
@ -46,6 +55,15 @@ import { computed } from 'vue';
|
||||
serialNoHeader?: string;
|
||||
actionColumnHeader?: string;
|
||||
customCellRenderer?: CellRenderer;
|
||||
/** 外部托管表头搜索条件(服务端筛选时由页面传入) */
|
||||
searchParams?: Record<string, string>;
|
||||
/** true=列标记 serverFilter,回车后 emit search 由页面请求后端 */
|
||||
serverHeaderSearch?: boolean;
|
||||
/** 切换左侧选中项时清空表头搜索条件的依据(如 areaId) */
|
||||
resetSearchKey?: string | number | null;
|
||||
sortMode?: 'local' | 'remote';
|
||||
sortField?: string | null;
|
||||
sortOrder?: SortOrder;
|
||||
}>(),
|
||||
{
|
||||
totalCount: 0,
|
||||
@ -54,17 +72,40 @@ import { computed } from 'vue';
|
||||
serialNoHeader: '',
|
||||
actionColumnHeader: '',
|
||||
customCellRenderer: undefined,
|
||||
searchParams: undefined,
|
||||
serverHeaderSearch: false,
|
||||
resetSearchKey: null,
|
||||
sortMode: 'local',
|
||||
sortField: null,
|
||||
sortOrder: null,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [row: any];
|
||||
delete: [row: any];
|
||||
search: [];
|
||||
'sort-change': [payload: { field: string | null; order: SortOrder }];
|
||||
'selection-change': [rows: any[]];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tableContainerRef, tableHeight } = useTableContainerHeight();
|
||||
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 200));
|
||||
const internalSearchParams = reactive<Record<string, string>>({});
|
||||
|
||||
const resolvedSearchParams = computed(() => props.searchParams ?? internalSearchParams);
|
||||
|
||||
const resolvedColumns = computed(() => {
|
||||
if (!props.serverHeaderSearch) return props.columns;
|
||||
return props.columns.map((col) => ({
|
||||
...col,
|
||||
serverFilter: col.serverFilter !== false,
|
||||
searchable: col.searchable !== false,
|
||||
searchField: col.searchField || col.key,
|
||||
headerMode: col.headerMode || ('inline' as const),
|
||||
}));
|
||||
});
|
||||
|
||||
const resolvedSerialNoHeader = computed(
|
||||
() => props.serialNoHeader || t('channel.area.table.serialNo')
|
||||
@ -73,20 +114,44 @@ import { computed } from 'vue';
|
||||
() => props.actionColumnHeader || t('channel.area.table.actions')
|
||||
);
|
||||
|
||||
function clearHeaderSearch(target: Record<string, string>) {
|
||||
Object.keys(target).forEach((key) => {
|
||||
target[key] = '';
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.resetSearchKey,
|
||||
() => {
|
||||
clearHeaderSearch(resolvedSearchParams.value);
|
||||
}
|
||||
);
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
|
||||
function handleSortChange(payload: { field: string | null; order: SortOrder }) {
|
||||
emit('sort-change', payload);
|
||||
}
|
||||
|
||||
function actionRenderer(_row: any, _rowIndex: number): string {
|
||||
return wrapWarehouseActions([
|
||||
renderWarehouseActionBtn('edit', t('channel.area.actions.edit')),
|
||||
renderWarehouseActionBtn('delete', t('channel.area.actions.delete')),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function handleActionClick(row: any, rowIndex: number, action?: string) {
|
||||
const idx = (props.currentPage - 1) * props.pageSize + rowIndex;
|
||||
const record = props.data[idx] || row;
|
||||
const record = row || props.data[rowIndex];
|
||||
if (action === 'delete') {
|
||||
emit('delete', record);
|
||||
return;
|
||||
}
|
||||
emit('edit', record);
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: any[]) {
|
||||
emit('selection-change', rows || []);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -86,7 +86,7 @@
|
||||
</a-button>
|
||||
<a-button
|
||||
status="danger"
|
||||
:disabled="!currentId?.id"
|
||||
:disabled="!currentId?.id || !selectedDetailRows.length"
|
||||
@click="handleToolbarDelete"
|
||||
>
|
||||
{{ 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,
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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<Record<string, string>>({});
|
||||
const listSortField = ref<string | null>(null);
|
||||
const listSortOrder = ref<'asc' | 'desc' | null>(null);
|
||||
|
||||
const leftMenuData = ref<any[]>([]);
|
||||
const leftExpandedKeys = ref<string[]>(['category-list']);
|
||||
@ -582,15 +587,112 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
||||
}
|
||||
|
||||
const hotColumns = computed<ChannelHotColumn[]>(() => [
|
||||
{ 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<any[]>([]);
|
||||
|
||||
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,
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
@focusout="hovered = false"
|
||||
>
|
||||
<li
|
||||
v-for="(pillar, index) in adminPillars"
|
||||
v-for="(pillar, index) in activePillars"
|
||||
:key="pillar.key"
|
||||
class="login-hero__principle login-hero__spine-item login-hero__spine-item--left"
|
||||
:class="{
|
||||
@ -68,7 +68,7 @@
|
||||
<icon-robot :size="14" />
|
||||
AI
|
||||
</span>
|
||||
<p class="login-hero__ai-text">{{ t('login.hero.admin.ai') }}</p>
|
||||
<p class="login-hero__ai-text">{{ t(aiNoteKey) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -110,10 +110,8 @@ import {
|
||||
|
||||
defineOptions({ name: 'LoginHero' });
|
||||
|
||||
type AdminPillarKey = 'care' | 'speed' | 'reach';
|
||||
|
||||
interface AdminPillar {
|
||||
key: AdminPillarKey;
|
||||
interface HeroPillar {
|
||||
key: string;
|
||||
icon: Component;
|
||||
titleKey: string;
|
||||
descKey: string;
|
||||
@ -121,7 +119,7 @@ interface AdminPillar {
|
||||
t: number;
|
||||
}
|
||||
|
||||
const adminPillars: AdminPillar[] = [
|
||||
const adminPillars: HeroPillar[] = [
|
||||
{
|
||||
key: 'care',
|
||||
icon: IconCheckCircle,
|
||||
@ -148,11 +146,38 @@ const adminPillars: AdminPillar[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const agentPillars: HeroPillar[] = [
|
||||
{
|
||||
key: 'customers',
|
||||
icon: IconCheckCircle,
|
||||
titleKey: 'login.hero.agent.pillar.customers.title',
|
||||
descKey: 'login.hero.agent.pillar.customers.desc',
|
||||
side: 'left',
|
||||
t: 0.24,
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
icon: IconThunderbolt,
|
||||
titleKey: 'login.hero.agent.pillar.orders.title',
|
||||
descKey: 'login.hero.agent.pillar.orders.desc',
|
||||
side: 'left',
|
||||
t: 0.40,
|
||||
},
|
||||
{
|
||||
key: 'commission',
|
||||
icon: IconMindMapping,
|
||||
titleKey: 'login.hero.agent.pillar.commission.title',
|
||||
descKey: 'login.hero.agent.pillar.commission.desc',
|
||||
side: 'left',
|
||||
t: 0.56,
|
||||
},
|
||||
];
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
light?: boolean;
|
||||
/** 客户门户强调记忆;管理端强调运营开场 */
|
||||
portal?: 'customer' | 'admin';
|
||||
/** 客户门户 / 管理端 / 渠道代理 */
|
||||
portal?: 'customer' | 'admin' | 'agent';
|
||||
}>(),
|
||||
{
|
||||
portal: 'customer',
|
||||
@ -167,13 +192,19 @@ const hovered = ref(false);
|
||||
let cycleTimer: ReturnType<typeof window.setInterval> | null = null;
|
||||
|
||||
const isCustomer = computed(() => props.portal === 'customer');
|
||||
const activePillars = computed(() =>
|
||||
props.portal === 'agent' ? agentPillars : adminPillars,
|
||||
);
|
||||
const aiNoteKey = computed(() =>
|
||||
props.portal === 'agent' ? 'login.hero.agent.ai' : 'login.hero.admin.ai',
|
||||
);
|
||||
|
||||
function startCycle() {
|
||||
stopCycle();
|
||||
if (isCustomer.value) return;
|
||||
cycleTimer = window.setInterval(() => {
|
||||
if (hovered.value) return;
|
||||
activePrinciple.value = (activePrinciple.value + 1) % adminPillars.length;
|
||||
activePrinciple.value = (activePrinciple.value + 1) % activePillars.value.length;
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
|
||||
@ -137,25 +137,30 @@ const loginExit = inject<LoginExitApi | null>(LOGIN_EXIT_KEY, null);
|
||||
const loginCharacters = computed(() => loginCharactersRef?.value ?? null);
|
||||
|
||||
const isCustomerLogin = computed(() => props.loginType === OmsLoginType.Customer);
|
||||
const brandHint = computed(() =>
|
||||
t(isCustomerLogin.value ? 'login.form.brandHint.customer' : 'login.form.brandHint.admin'),
|
||||
);
|
||||
const isAgentLogin = computed(() => props.loginType === OmsLoginType.Agent);
|
||||
const brandHint = computed(() => {
|
||||
if (isCustomerLogin.value) return t('login.form.brandHint.customer');
|
||||
if (isAgentLogin.value) return t('login.form.brandHint.agent');
|
||||
return t('login.form.brandHint.admin');
|
||||
});
|
||||
|
||||
/** Logo / 欢迎文案 → 官网首页 */
|
||||
function goOfficialSite() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
const accountFieldLabel = computed(() => t('login.field.account'));
|
||||
const accountPlaceholder = computed(() =>
|
||||
isCustomerLogin.value
|
||||
? t('login.form.account.placeholder.customer')
|
||||
: t('login.form.account.placeholder.admin'),
|
||||
);
|
||||
const accountRequiredMsg = computed(() =>
|
||||
isCustomerLogin.value
|
||||
? t('login.form.account.errMsg.customer')
|
||||
: t('login.form.account.errMsg.admin'),
|
||||
const accountFieldLabel = computed(() =>
|
||||
isAgentLogin.value ? t('login.field.account.agent') : t('login.field.account'),
|
||||
);
|
||||
const accountPlaceholder = computed(() => {
|
||||
if (isCustomerLogin.value) return t('login.form.account.placeholder.customer');
|
||||
if (isAgentLogin.value) return t('login.form.account.placeholder.agent');
|
||||
return t('login.form.account.placeholder.admin');
|
||||
});
|
||||
const accountRequiredMsg = computed(() => {
|
||||
if (isCustomerLogin.value) return t('login.form.account.errMsg.customer');
|
||||
if (isAgentLogin.value) return t('login.form.account.errMsg.agent');
|
||||
return t('login.form.account.errMsg.admin');
|
||||
});
|
||||
|
||||
const agreementChecked = ref(true);
|
||||
const agreementVisible = ref(false);
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<div ref="rightSplitRef" class="login-split login-split--right" aria-hidden="true" />
|
||||
|
||||
<LoginCareBanner
|
||||
v-if="shellReady && !isCustomerLogin"
|
||||
v-if="shellReady && isAdminLogin"
|
||||
:light="isLightMode"
|
||||
/>
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
:class="{ 'login-brand-corner--light': isLightMode }"
|
||||
>
|
||||
<span class="login-brand-corner__name">{{ t('brand.name') }}</span>
|
||||
<span class="login-brand-corner__suffix">· {{ t('brand.fullName') }}</span>
|
||||
<span class="login-brand-corner__suffix">· {{ brandCornerSuffix }}</span>
|
||||
</div>
|
||||
<aside v-if="isCustomerLogin" class="login-hero-panel">
|
||||
<LoginHero
|
||||
@ -43,7 +43,7 @@
|
||||
>
|
||||
<LoginHero
|
||||
:light="isLightMode"
|
||||
portal="admin"
|
||||
:portal="isAgentLogin ? 'agent' : 'admin'"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@ -80,7 +80,10 @@
|
||||
@switch-pwd="setLoginMode('pwd')"
|
||||
/>
|
||||
</div>
|
||||
<LoginPortalSwitch :target="isCustomerLogin ? 'admin' : 'customer'" />
|
||||
<LoginPortalSwitch
|
||||
v-if="loginType !== OmsLoginType.Agent"
|
||||
:target="isCustomerLogin ? 'admin' : 'customer'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -132,6 +135,11 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const loginType = computed(() => getLoginTypeFromRouteName(route.name as string));
|
||||
const isCustomerLogin = computed(() => loginType.value === OmsLoginType.Customer);
|
||||
const isAgentLogin = computed(() => loginType.value === OmsLoginType.Agent);
|
||||
const isAdminLogin = computed(() => loginType.value === OmsLoginType.User);
|
||||
const brandCornerSuffix = computed(() =>
|
||||
isAgentLogin.value ? t('login.brand.suffix.agent') : t('brand.fullName'),
|
||||
);
|
||||
/** 角色动画偏重,首屏延后一帧再挂,管理端也挂但异步分包 */
|
||||
const showCharacters = ref(false);
|
||||
const activeKey = ref<'pwd' | 'wechat'>('pwd');
|
||||
@ -141,7 +149,8 @@ const wechatLoginRef = ref<{ prefetch?: () => void } | null>(null);
|
||||
const shellReady = ref(
|
||||
route.query.from === 'official-site' ||
|
||||
route.name === 'login' ||
|
||||
route.name === 'adminLogin',
|
||||
route.name === 'adminLogin' ||
|
||||
route.name === 'agentLogin',
|
||||
);
|
||||
const isExiting = ref(false);
|
||||
const loginCharactersApi = shallowRef<LoginCharactersApi | null>(null);
|
||||
|
||||
@ -9,13 +9,17 @@
|
||||
'login.form.account.placeholder': 'Please enter account',
|
||||
'login.form.account.placeholder.admin': 'Please enter account',
|
||||
'login.form.account.placeholder.customer': 'Phone / Email / Customer code',
|
||||
'login.form.account.placeholder.agent': 'Please enter agent code',
|
||||
'login.form.account.errMsg': 'Please enter account',
|
||||
'login.form.account.errMsg.admin': 'Please enter account',
|
||||
'login.form.account.errMsg.customer': 'Please enter phone, email, or customer code',
|
||||
'login.form.account.errMsg.agent': 'Please enter agent code',
|
||||
'login.form.account.hint.customer': 'Sign in with phone, email, or customer code',
|
||||
'login.form.login': 'Login',
|
||||
'login.form.brandHint.customer': 'Welcome to your IDE portal',
|
||||
'login.form.brandHint.admin': 'Welcome to IDE Operations Console',
|
||||
'login.form.brandHint.agent': 'Welcome to IDE Channel Agent Portal',
|
||||
'login.brand.suffix.agent': 'Channel Agent',
|
||||
'login.switchToWechat': 'Sign in / register with WeChat',
|
||||
'login.switchToAccount': 'Account and password',
|
||||
'login.wechat.tips': 'Scan with WeChat to sign in / register. QR code valid for 10 minutes.',
|
||||
@ -48,6 +52,14 @@
|
||||
'login.hero.admin.pillar.reach.desc': 'Pickup to delivery · Clear flow · Fewer detours',
|
||||
'login.hero.admin.ai':
|
||||
'Embrace AI: spot issues, protect SLAs, simplify flows — people decide, AI accelerates.',
|
||||
'login.hero.agent.pillar.customers.title': 'Your Customers',
|
||||
'login.hero.agent.pillar.customers.desc': 'See customers bound at registration',
|
||||
'login.hero.agent.pillar.orders.title': 'Orders',
|
||||
'login.hero.agent.pillar.orders.desc': 'Track shipments under your account',
|
||||
'login.hero.agent.pillar.commission.title': 'Commission',
|
||||
'login.hero.agent.pillar.commission.desc': 'Accruals and payout status at a glance',
|
||||
'login.hero.agent.ai':
|
||||
'Focus on customers and commission: accrues on delivery, details stay visible, payouts settle offline.',
|
||||
'login.care.period.morning': 'Good morning — a fresh start',
|
||||
'login.care.period.afternoon': 'Good afternoon — steady wins',
|
||||
'login.care.period.evening': 'Good evening — thanks for today',
|
||||
@ -108,6 +120,7 @@
|
||||
'login.hero.track': 'Track',
|
||||
'login.hero.trackPending': 'Tracking lookup is coming soon.',
|
||||
'login.field.account': 'Account',
|
||||
'login.field.account.agent': 'Agent code',
|
||||
'login.field.password': 'Password',
|
||||
'login.showPassword': 'Show password',
|
||||
'login.hidePassword': 'Hide password',
|
||||
|
||||
@ -9,13 +9,17 @@
|
||||
'login.form.account.placeholder': '请输入登录账号',
|
||||
'login.form.account.placeholder.admin': '请输入登录账号',
|
||||
'login.form.account.placeholder.customer': '手机号 / 邮箱 / 客户编号',
|
||||
'login.form.account.placeholder.agent': '请输入代理编号',
|
||||
'login.form.account.errMsg': '请输入登录账号',
|
||||
'login.form.account.errMsg.admin': '请输入登录账号',
|
||||
'login.form.account.errMsg.customer': '请输入手机号、邮箱或客户编号',
|
||||
'login.form.account.errMsg.agent': '请输入代理编号',
|
||||
'login.form.account.hint.customer': '支持手机号、邮箱或客户编号登录',
|
||||
'login.form.login': '登录',
|
||||
'login.form.brandHint.customer': '欢迎登录 IDE 客户门户',
|
||||
'login.form.brandHint.admin': '欢迎登录 IDE 运营管理后台',
|
||||
'login.form.brandHint.agent': '欢迎登录 IDE 渠道代理门户',
|
||||
'login.brand.suffix.agent': '渠道代理',
|
||||
'login.switchToWechat': '微信扫码登录/注册',
|
||||
'login.switchToAccount': '账号密码登录',
|
||||
'login.wechat.tips': '请使用微信扫一扫登录/注册,二维码 10 分钟内有效',
|
||||
@ -47,6 +51,13 @@
|
||||
'login.hero.admin.pillar.reach.title': '更简单',
|
||||
'login.hero.admin.pillar.reach.desc': '揽收到签收 · 链路清晰 · 少绕少错',
|
||||
'login.hero.admin.ai': '积极拥抱 AI:帮你盯异常、催时效、简流程——人做决策,AI 做加速。',
|
||||
'login.hero.agent.pillar.customers.title': '名下客户',
|
||||
'login.hero.agent.pillar.customers.desc': '查看注册绑定客户,跟进业务进展',
|
||||
'login.hero.agent.pillar.orders.title': '订单跟踪',
|
||||
'login.hero.agent.pillar.orders.desc': '名下运单状态清晰可见',
|
||||
'login.hero.agent.pillar.commission.title': '返佣明细',
|
||||
'login.hero.agent.pillar.commission.desc': '计提与打款进度一目了然',
|
||||
'login.hero.agent.ai': '专注客户与返佣:订单签收计提、明细可查,结算由运营线下完成。',
|
||||
'login.care.period.morning': '早上好,新的一天开始了',
|
||||
'login.care.period.afternoon': '下午好,稳稳推进就好',
|
||||
'login.care.period.evening': '傍晚好,今天也辛苦了',
|
||||
@ -98,6 +109,7 @@
|
||||
'login.hero.track': '查询物流',
|
||||
'login.hero.trackPending': '物流查询功能即将上线。',
|
||||
'login.field.account': '账号',
|
||||
'login.field.account.agent': '代理编号',
|
||||
'login.field.password': '密码',
|
||||
'login.showPassword': '显示密码',
|
||||
'login.hidePassword': '隐藏密码',
|
||||
|
||||
@ -123,6 +123,7 @@ const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const inviteCode = ref('')
|
||||
const advisorCode = ref('')
|
||||
const agentCode = ref('')
|
||||
const referrerInfo = ref<Record<string, unknown> | null>(null)
|
||||
const referrerValidated = ref<boolean | null>(null)
|
||||
const referrerLoading = ref(false)
|
||||
@ -215,9 +216,11 @@ async function handleSubmit() {
|
||||
phone: formState.phone.trim(),
|
||||
email: formState.email.trim(),
|
||||
password: formState.password,
|
||||
...(inviteCode.value && referrerValidated.value
|
||||
? ({ inviteCode: inviteCode.value } as Record<string, string>)
|
||||
: {}),
|
||||
...(agentCode.value
|
||||
? ({ agentCode: agentCode.value } as Record<string, string>)
|
||||
: inviteCode.value && referrerValidated.value
|
||||
? ({ inviteCode: inviteCode.value } as Record<string, string>)
|
||||
: {}),
|
||||
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
|
||||
})
|
||||
const { clearStashedInviteRef } = await import('@/utils/inviteRefStorage')
|
||||
@ -237,8 +240,13 @@ function goLogin() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const agent = String(route.query.agent ?? route.query.agentCode ?? '').trim()
|
||||
if (agent) {
|
||||
agentCode.value = agent
|
||||
}
|
||||
const ref = String(route.query.ref ?? route.query.invite ?? '').trim()
|
||||
if (ref) void loadReferrer(ref)
|
||||
// 有代理码时不校验 C2C 邀请码(与后端互斥一致)
|
||||
if (ref && !agent) void loadReferrer(ref)
|
||||
const advisor = resolveAdvisorInvite(route.query as Record<string, unknown>)
|
||||
if (advisor) {
|
||||
advisorCode.value = advisor
|
||||
|
||||
@ -305,6 +305,7 @@ const authModalVisible = ref(false);
|
||||
const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
|
||||
const inviteCode = ref('');
|
||||
const advisorCode = ref('');
|
||||
const agentCode = ref('');
|
||||
const referrerValidated = ref<boolean | null>(null);
|
||||
const referrerInfo = ref<{ name: string; code: string } | null>(null);
|
||||
let loadingSlowTimer: number | undefined;
|
||||
@ -419,13 +420,16 @@ async function handleSubmit() {
|
||||
const errors = await formRef.value?.validate();
|
||||
if (errors) return;
|
||||
|
||||
const canBindInvite = Boolean(inviteCode.value && referrerValidated.value === true);
|
||||
const canBindInvite = Boolean(
|
||||
!agentCode.value && inviteCode.value && referrerValidated.value === true,
|
||||
);
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const result = await registerByWechat({
|
||||
customerName: formState.customerName.trim(),
|
||||
password: formState.password,
|
||||
...(agentCode.value ? { agentCode: agentCode.value } : {}),
|
||||
...(canBindInvite ? { inviteCode: inviteCode.value } : {}),
|
||||
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
|
||||
wechat: {
|
||||
@ -462,8 +466,12 @@ async function handleSubmit() {
|
||||
onMounted(() => {
|
||||
hideWeChatBrowserNav();
|
||||
loadingHint.value = t('wechat.common.loading');
|
||||
const agent = String(route.query.agent ?? route.query.agentCode ?? '').trim();
|
||||
if (agent) {
|
||||
agentCode.value = agent;
|
||||
}
|
||||
const refCode = resolveWeChatInviteRef(route.query as Record<string, unknown>);
|
||||
if (refCode) {
|
||||
if (refCode && !agent) {
|
||||
void loadReferrer(refCode);
|
||||
}
|
||||
const advisor = resolveAdvisorInvite(route.query as Record<string, unknown>);
|
||||
|
||||
@ -327,6 +327,23 @@ export default defineConfig(({ mode }) => {
|
||||
}
|
||||
},
|
||||
},
|
||||
// 渠道代理:API /agent/admin/*、/agent/portal/*;页面 /agent、/agent/invite|customers|orders
|
||||
'/agent': {
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
bypass(req) {
|
||||
const path = req.url?.split('?')[0] ?? '';
|
||||
// API 走代理;门户 SPA 回 index.html
|
||||
if (path.startsWith('/agent/admin') || path.startsWith('/agent/portal')) {
|
||||
return undefined;
|
||||
}
|
||||
const agentSpaPages = ['/agent', '/agent/invite', '/agent/customers', '/agent/orders'];
|
||||
if (agentSpaPages.some((p) => path === p || path === `${p}/`)) {
|
||||
return '/index.html';
|
||||
}
|
||||
},
|
||||
},
|
||||
'/operation': {
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
@ -556,6 +573,13 @@ export default defineConfig(({ mode }) => {
|
||||
'/warehouse/inboundNotice',
|
||||
'/warehouse/binLocation',
|
||||
'/Plugin/AddPackage',
|
||||
// 渠道代理门户(API 为 /agent/admin、/agent/portal,勿用前缀匹配)
|
||||
'/agent',
|
||||
'/agent/invite',
|
||||
'/agent/customers',
|
||||
'/agent/orders',
|
||||
'/agentAdmin/list',
|
||||
'/agentAdmin/commission',
|
||||
];
|
||||
|
||||
// 前端路由路径列表(这些路径不应该被代理,应该走前端路由)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user