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(
|
async create(
|
||||||
data: Partial<CustomerDto> & { password?: string; inviteCode?: string; advisorCode?: string },
|
data: Partial<CustomerDto> & {
|
||||||
|
password?: string
|
||||||
|
inviteCode?: string
|
||||||
|
advisorCode?: string
|
||||||
|
agentCode?: string
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const body = {
|
const body = {
|
||||||
customerCode: data.customerCode || undefined,
|
customerCode: data.customerCode || undefined,
|
||||||
@ -185,6 +190,7 @@ export const CustomerService = {
|
|||||||
priceTierKey: data.priceTierKey,
|
priceTierKey: data.priceTierKey,
|
||||||
inviteCode: data.inviteCode?.trim() || undefined,
|
inviteCode: data.inviteCode?.trim() || undefined,
|
||||||
advisorCode: data.advisorCode?.trim() || undefined,
|
advisorCode: data.advisorCode?.trim() || undefined,
|
||||||
|
agentCode: data.agentCode?.trim() || undefined,
|
||||||
}
|
}
|
||||||
const r = await axiosInstance.post(omsUrl('/customer/create'), body)
|
const r = await axiosInstance.post(omsUrl('/customer/create'), body)
|
||||||
assertBusinessOk(r.data)
|
assertBusinessOk(r.data)
|
||||||
|
|||||||
@ -3,9 +3,10 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/**
|
/**
|
||||||
* 登录主体类型:0=内部用户(管理端),1=客户
|
* 登录主体类型:0=内部用户(管理端),1=客户,2=渠道代理
|
||||||
*/
|
*/
|
||||||
export enum OmsLoginType {
|
export enum OmsLoginType {
|
||||||
User = 0,
|
User = 0,
|
||||||
Customer = 1,
|
Customer = 1,
|
||||||
|
Agent = 2,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,8 @@ export interface WeChatCustomerRegisterInput {
|
|||||||
inviteCode?: string;
|
inviteCode?: string;
|
||||||
/** 顾问邀请(来自 ?advisor=,值为员工账号,注册时绑定所属顾问) */
|
/** 顾问邀请(来自 ?advisor=,值为员工账号,注册时绑定所属顾问) */
|
||||||
advisorCode?: string;
|
advisorCode?: string;
|
||||||
|
/** 渠道代理编号(来自 ?agent=;有值时不走 C2C 邀请) */
|
||||||
|
agentCode?: string;
|
||||||
wechat: WeChatProfileInput;
|
wechat: WeChatProfileInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@ -9,6 +9,7 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ActiveFilterBar: typeof import('./components/HandsontablePro/ActiveFilterBar.vue')['default']
|
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']
|
AiAdvisorShell: typeof import('./components/NanaAssistant/AiAdvisorShell.vue')['default']
|
||||||
ApiTest: typeof import('./components/ApiTest/index.vue')['default']
|
ApiTest: typeof import('./components/ApiTest/index.vue')['default']
|
||||||
BasicLayoutLeftRight: typeof import('./components/BasicLayoutLeftRight/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.View': 'Marketing.CommissionDetail',
|
||||||
'Marketing.Commission.Withdraw.View': 'Marketing.CommissionWithdraw',
|
'Marketing.Commission.Withdraw.View': 'Marketing.CommissionWithdraw',
|
||||||
'Marketing.Referral.View': 'Marketing.ReferralTree',
|
'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.Invite': 'User.Marketing.Invite',
|
||||||
'User.Marketing.Member': 'User.Marketing.Member',
|
'User.Marketing.Member': 'User.Marketing.Member',
|
||||||
'User.Marketing.Coupons': 'User.Marketing.Coupons',
|
'User.Marketing.Coupons': 'User.Marketing.Coupons',
|
||||||
@ -942,6 +946,18 @@ const CORE_GROUPS: PageButtonGroup[] = [
|
|||||||
{ code: 'Marketing.Referral.Rebind', name: '改绑' },
|
{ 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', [
|
B('Knowledge.Category', [
|
||||||
{ code: 'Query', name: '查询' },
|
{ code: 'Query', name: '查询' },
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-layout class="layout">
|
<a-layout class="layout">
|
||||||
<!-- 运营端顶栏;客户门户无顶栏(能力沉底侧栏) -->
|
<!-- 运营端顶栏;客户/代理门户无顶栏(能力沉底侧栏) -->
|
||||||
<TopNav v-if="!isCustomerShell" />
|
<TopNav v-if="!isPortalShell" />
|
||||||
|
|
||||||
<!-- 客户门户:全高侧栏 + 主区(无多页签,当前页由侧栏高亮) -->
|
<!-- 客户/代理门户:全高侧栏 + 主区(无多页签,当前页由侧栏高亮) -->
|
||||||
<a-layout
|
<a-layout
|
||||||
v-if="isCustomerShell"
|
v-if="isPortalShell"
|
||||||
class="layout-content layout-content--customer"
|
class="layout-content layout-content--customer"
|
||||||
>
|
>
|
||||||
<CustomerSideRail />
|
<CustomerSideRail v-if="isCustomerShell" />
|
||||||
|
<AgentSideRail v-else-if="isAgentShell" />
|
||||||
<a-layout class="layout-main-wrap">
|
<a-layout class="layout-main-wrap">
|
||||||
<a-layout-content class="layout-main">
|
<a-layout-content class="layout-main">
|
||||||
<PageLayout />
|
<PageLayout />
|
||||||
@ -26,8 +27,8 @@
|
|||||||
</a-layout>
|
</a-layout>
|
||||||
|
|
||||||
<NanaAssistant />
|
<NanaAssistant />
|
||||||
<PortalSideDrawer />
|
<PortalSideDrawer v-if="isCustomerShell" />
|
||||||
<HelpSideDrawer />
|
<HelpSideDrawer v-if="!isAgentShell" />
|
||||||
</a-layout>
|
</a-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -38,6 +39,7 @@
|
|||||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||||
import TopNav from '@/components/top-nav/index.vue';
|
import TopNav from '@/components/top-nav/index.vue';
|
||||||
import CustomerSideRail from '@/components/customer-side-rail/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 Footer from '@/components/footer/index.vue';
|
||||||
import NanaAssistant from '@/components/NanaAssistant/index.vue';
|
import NanaAssistant from '@/components/NanaAssistant/index.vue';
|
||||||
import PortalSideDrawer from '@/views/user/portal-drawer/PortalSideDrawer.vue';
|
import PortalSideDrawer from '@/views/user/portal-drawer/PortalSideDrawer.vue';
|
||||||
@ -62,6 +64,10 @@
|
|||||||
const isCustomerShell = computed(
|
const isCustomerShell = computed(
|
||||||
() => rbacUserStore.isCustomerPortal() && rbacUserStore.isLoggedIn,
|
() => rbacUserStore.isCustomerPortal() && rbacUserStore.isLoggedIn,
|
||||||
);
|
);
|
||||||
|
const isAgentShell = computed(
|
||||||
|
() => rbacUserStore.isAgentPortal() && rbacUserStore.isLoggedIn,
|
||||||
|
);
|
||||||
|
const isPortalShell = computed(() => isCustomerShell.value || isAgentShell.value);
|
||||||
|
|
||||||
// 仅运营端需要为 fixed 顶栏留白
|
// 仅运营端需要为 fixed 顶栏留白
|
||||||
const adminContentStyle = computed(() => {
|
const adminContentStyle = computed(() => {
|
||||||
|
|||||||
@ -66,6 +66,9 @@ export default {
|
|||||||
'menu.finance.rechargeRecord': 'Recharge Records',
|
'menu.finance.rechargeRecord': 'Recharge Records',
|
||||||
'menu.finance.accountFlow': 'Account Transactions',
|
'menu.finance.accountFlow': 'Account Transactions',
|
||||||
'menu.finance.receivablePayable': 'Receivables & Payables',
|
'menu.finance.receivablePayable': 'Receivables & Payables',
|
||||||
|
'menu.agentAdmin': 'Channel Agents',
|
||||||
|
'menu.agentAdmin.list': 'Agents',
|
||||||
|
'menu.agentAdmin.commission': 'Agent Commission',
|
||||||
'menu.marketing': 'Marketing',
|
'menu.marketing': 'Marketing',
|
||||||
'menu.marketing.marketingGuide': 'Getting Started',
|
'menu.marketing.marketingGuide': 'Getting Started',
|
||||||
'menu.marketing.memberLevel': 'Member Levels',
|
'menu.marketing.memberLevel': 'Member Levels',
|
||||||
@ -5843,6 +5846,8 @@ export default {
|
|||||||
'channel.area.modal.editDetail': 'Edit area detail',
|
'channel.area.modal.editDetail': 'Edit area detail',
|
||||||
'channel.area.modal.deleteConfirm': 'Are you sure to delete?',
|
'channel.area.modal.deleteConfirm': 'Are you sure to delete?',
|
||||||
'channel.area.modal.deleteConfirmDetail': 'Confirm deletion?',
|
'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.modal.warningTitle': 'Warning',
|
||||||
'channel.area.table.serialNo': 'No.',
|
'channel.area.table.serialNo': 'No.',
|
||||||
'channel.area.table.leftPostcode': 'Start postcode',
|
'channel.area.table.leftPostcode': 'Start postcode',
|
||||||
|
|||||||
@ -66,6 +66,9 @@ export default {
|
|||||||
'menu.finance.rechargeRecord': '充值记录',
|
'menu.finance.rechargeRecord': '充值记录',
|
||||||
'menu.finance.accountFlow': '资金流水',
|
'menu.finance.accountFlow': '资金流水',
|
||||||
'menu.finance.receivablePayable': '应收应付',
|
'menu.finance.receivablePayable': '应收应付',
|
||||||
|
'menu.agentAdmin': '渠道代理',
|
||||||
|
'menu.agentAdmin.list': '代理管理',
|
||||||
|
'menu.agentAdmin.commission': '代理返佣',
|
||||||
'menu.marketing': '营销',
|
'menu.marketing': '营销',
|
||||||
'menu.marketing.marketingGuide': '新手指引',
|
'menu.marketing.marketingGuide': '新手指引',
|
||||||
'menu.marketing.memberLevel': '会员等级',
|
'menu.marketing.memberLevel': '会员等级',
|
||||||
@ -5851,6 +5854,8 @@ export default {
|
|||||||
'channel.area.modal.editDetail': '修改分区明细',
|
'channel.area.modal.editDetail': '修改分区明细',
|
||||||
'channel.area.modal.deleteConfirm': '确定删除吗?',
|
'channel.area.modal.deleteConfirm': '确定删除吗?',
|
||||||
'channel.area.modal.deleteConfirmDetail': '是否确认删除?',
|
'channel.area.modal.deleteConfirmDetail': '是否确认删除?',
|
||||||
|
'channel.area.modal.deleteConfirmDetails': '确定删除选中的 {count} 条分区明细吗?',
|
||||||
|
'channel.area.message.needSelectDetail': '请先勾选要删除的明细行',
|
||||||
'channel.area.modal.warningTitle': '温馨提示',
|
'channel.area.modal.warningTitle': '温馨提示',
|
||||||
'channel.area.table.serialNo': '序号',
|
'channel.area.table.serialNo': '序号',
|
||||||
'channel.area.table.leftPostcode': '起始邮编',
|
'channel.area.table.leftPostcode': '起始邮编',
|
||||||
|
|||||||
@ -2,6 +2,10 @@ export const WHITE_LIST = [
|
|||||||
{ name: 'notFound', children: [] },
|
{ name: 'notFound', children: [] },
|
||||||
{ name: 'login', children: [] },
|
{ name: 'login', children: [] },
|
||||||
{ name: 'adminLogin', children: [] },
|
{ name: 'adminLogin', children: [] },
|
||||||
|
{ name: 'agentLogin', children: [] },
|
||||||
|
{ name: 'AgentPortalInvite', children: [] },
|
||||||
|
{ name: 'AgentPortalCustomers', children: [] },
|
||||||
|
{ name: 'AgentPortalOrders', children: [] },
|
||||||
{ name: 'siteRoot', children: [] },
|
{ name: 'siteRoot', children: [] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useAuthStore } from '@/store';
|
|||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||||
import { resolvePostLogoutLogin } from '@/utils/login-portal';
|
import { resolvePostLogoutLogin } from '@/utils/login-portal';
|
||||||
|
import { OmsLoginType } from '@/api/rbac';
|
||||||
|
|
||||||
const HOME_URL = '/home/workplace'; // 默认首页路径
|
const HOME_URL = '/home/workplace'; // 默认首页路径
|
||||||
|
|
||||||
@ -57,6 +58,12 @@ export const initDynamicRouter = async (options?: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 渠道代理门户:静态路由 /agent/customers|orders,不依赖运营端菜单
|
||||||
|
if (rbacUserStore.loginPortalType === OmsLoginType.Agent) {
|
||||||
|
clearDynamicRoutes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 先清除旧的动态路由
|
// 先清除旧的动态路由
|
||||||
clearDynamicRoutes();
|
clearDynamicRoutes();
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import {
|
|||||||
isLoginRouteName,
|
isLoginRouteName,
|
||||||
getDefaultHomePathByType,
|
getDefaultHomePathByType,
|
||||||
canAccessCustomerPortalRoute,
|
canAccessCustomerPortalRoute,
|
||||||
|
isAgentPortalPath,
|
||||||
|
AGENT_LOGIN_PATH,
|
||||||
resolveCustomerEntryPath,
|
resolveCustomerEntryPath,
|
||||||
resolvePostLogoutLogin,
|
resolvePostLogoutLogin,
|
||||||
} from '@/utils/login-portal';
|
} from '@/utils/login-portal';
|
||||||
@ -47,6 +49,24 @@ export default function setupPermissionGuard(router: Router) {
|
|||||||
!isLogin() &&
|
!isLogin() &&
|
||||||
(path.startsWith('/h5') || path.startsWith('/wechat/') || path.startsWith('/track/'));
|
(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 (
|
if (
|
||||||
!isGuestH5Entry &&
|
!isGuestH5Entry &&
|
||||||
!canAccessCustomerPortalRoute(rbacUserStore.loginPortalType, {
|
!canAccessCustomerPortalRoute(rbacUserStore.loginPortalType, {
|
||||||
@ -100,13 +120,14 @@ export default function setupPermissionGuard(router: Router) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// H5 / 微信 / 轨迹为静态路由,不依赖 PC 菜单动态路由。
|
// H5 / 微信 / 轨迹 / 代理门户为静态路由,不依赖 PC 菜单动态路由。
|
||||||
// 否则扫码进 /h5/* 时 initDynamicRouter 因无菜单会 logout,表现为「登录成功但不跳转」。
|
// 否则扫码进 /h5/* 或代理登录后 initDynamicRouter 因无菜单会 logout,表现为「登录成功后空白」。
|
||||||
const isStaticCustomerShell =
|
const isStaticCustomerShell =
|
||||||
path === '/' ||
|
path === '/' ||
|
||||||
path.startsWith('/h5') ||
|
path.startsWith('/h5') ||
|
||||||
path.startsWith('/wechat/') ||
|
path.startsWith('/wechat/') ||
|
||||||
path.startsWith('/track/');
|
path.startsWith('/track/') ||
|
||||||
|
isAgentPortalPath(path);
|
||||||
|
|
||||||
// 动态路由未就绪:与 enterSystemAfterLogin 共用单例,避免重复 init
|
// 动态路由未就绪:与 enterSystemAfterLogin 共用单例,避免重复 init
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
CUSTOMER_H5_HOME_PATH,
|
CUSTOMER_H5_HOME_PATH,
|
||||||
getDefaultHomePathByType,
|
getDefaultHomePathByType,
|
||||||
isAdminH5Path,
|
isAdminH5Path,
|
||||||
|
isAgentPortalPath,
|
||||||
isLoginRouteName,
|
isLoginRouteName,
|
||||||
resolveCustomerEntryPath,
|
resolveCustomerEntryPath,
|
||||||
resolvePostLogoutLogin,
|
resolvePostLogoutLogin,
|
||||||
@ -256,6 +257,18 @@ export default function setupUserLoginInfoGuard(router: Router) {
|
|||||||
applyRbacUserToUserStore(rbacUserStore.userInfo);
|
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);
|
const needMenus = !(rbacUserStore.menus?.length > 0);
|
||||||
if (!needMenus && (userStore.name || userStore.accountId)) {
|
if (!needMenus && (userStore.name || userStore.accountId)) {
|
||||||
next();
|
next();
|
||||||
|
|||||||
@ -48,6 +48,61 @@ const router = createRouter({
|
|||||||
hideInMenu: true,
|
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',
|
path: '/track/query',
|
||||||
name: 'H5TrackQuery',
|
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 {
|
function readStoredLoginTypeRaw(): OmsLoginType | null {
|
||||||
const raw = getAuthStorage().getItem(LOGIN_TYPE_KEY) ?? localStorage.getItem(LOGIN_TYPE_KEY)
|
const raw = getAuthStorage().getItem(LOGIN_TYPE_KEY) ?? localStorage.getItem(LOGIN_TYPE_KEY)
|
||||||
if (raw === String(OmsLoginType.Customer)) return OmsLoginType.Customer
|
if (raw === String(OmsLoginType.Customer)) return OmsLoginType.Customer
|
||||||
|
if (raw === String(OmsLoginType.Agent)) return OmsLoginType.Agent
|
||||||
if (raw === String(OmsLoginType.User)) return OmsLoginType.User
|
if (raw === String(OmsLoginType.User)) return OmsLoginType.User
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -172,6 +173,10 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
|
|||||||
return loginPortalType.value === OmsLoginType.Customer
|
return loginPortalType.value === OmsLoginType.Customer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAgentPortal(): boolean {
|
||||||
|
return loginPortalType.value === OmsLoginType.Agent
|
||||||
|
}
|
||||||
|
|
||||||
// 保存 Token 信息
|
// 保存 Token 信息
|
||||||
function saveTokens(result: LoginResultDto, options?: { sessionOnly?: boolean }) {
|
function saveTokens(result: LoginResultDto, options?: { sessionOnly?: boolean }) {
|
||||||
if (!result.accessToken) {
|
if (!result.accessToken) {
|
||||||
@ -509,6 +514,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
|
|||||||
loginPortalType,
|
loginPortalType,
|
||||||
getLoginRouteName,
|
getLoginRouteName,
|
||||||
isCustomerPortal,
|
isCustomerPortal,
|
||||||
|
isAgentPortal,
|
||||||
resolveLoginTypeForRedirect,
|
resolveLoginTypeForRedirect,
|
||||||
syncLoginPortalTypeFromToken,
|
syncLoginPortalTypeFromToken,
|
||||||
getAccessTokenRealExpiresAt,
|
getAccessTokenRealExpiresAt,
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import { ensureDynamicRouterReady } from '@/router/ensure-dynamic-router';
|
|||||||
import { ensureHeavyPlugins } from '@/plugins/heavy-plugins';
|
import { ensureHeavyPlugins } from '@/plugins/heavy-plugins';
|
||||||
import { ensureFullLocaleMessages } from '@/locale';
|
import { ensureFullLocaleMessages } from '@/locale';
|
||||||
import { skipNextPageTransition } from '@/utils/page-transition-flag';
|
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 { resolveAvatarUrl } from '@/utils/avatar';
|
||||||
|
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||||
|
import { markDynamicRouterInitialized } from '@/router/dynamic-router-state';
|
||||||
|
|
||||||
export type EnterSystemTarget = {
|
export type EnterSystemTarget = {
|
||||||
path?: string;
|
path?: string;
|
||||||
@ -57,9 +59,12 @@ export async function enterSystemAfterLogin(options: {
|
|||||||
void options.playExit?.();
|
void options.playExit?.();
|
||||||
void ensureHeavyPlugins();
|
void ensureHeavyPlugins();
|
||||||
|
|
||||||
|
const isAgentPortal = useRbacUserStore().loginPortalType === OmsLoginType.Agent;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
ensureFullLocaleMessages(),
|
ensureFullLocaleMessages(),
|
||||||
ensureDynamicRouterReady({ forceRefresh: false }),
|
isAgentPortal
|
||||||
|
? Promise.resolve().then(() => markDynamicRouterInitialized(true))
|
||||||
|
: ensureDynamicRouterReady({ forceRefresh: false }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
skipNextPageTransition();
|
skipNextPageTransition();
|
||||||
|
|||||||
@ -16,6 +16,26 @@ export function buildAdvisorInviteRegisterUrl(
|
|||||||
return `${origin}/wechat/register?advisor=${encodeURIComponent(account)}`;
|
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 {
|
export function buildInviteShareText(inviteCode: string, inviteUrl: string): string {
|
||||||
const code = inviteCode.trim();
|
const code = inviteCode.trim();
|
||||||
@ -23,3 +43,11 @@ export function buildInviteShareText(inviteCode: string, inviteUrl: string): str
|
|||||||
if (!code || !url) return url || code;
|
if (!code || !url) return url || code;
|
||||||
return `【IDE Express】送你一个专属邀请,微信打开链接注册即可:${url}\n邀请码:${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 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'
|
export const CUSTOMER_LOGIN_ROUTE_NAME = 'login'
|
||||||
|
|
||||||
@ -31,28 +36,42 @@ export const ADMIN_PC_HOME_PATH = '/home/workplace'
|
|||||||
export const LOGIN_ROUTE_NAMES = [
|
export const LOGIN_ROUTE_NAMES = [
|
||||||
ADMIN_LOGIN_ROUTE_NAME,
|
ADMIN_LOGIN_ROUTE_NAME,
|
||||||
CUSTOMER_LOGIN_ROUTE_NAME,
|
CUSTOMER_LOGIN_ROUTE_NAME,
|
||||||
|
AGENT_LOGIN_ROUTE_NAME,
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function isLoginRouteName(name?: string | null): boolean {
|
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 {
|
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 {
|
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 {
|
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 {
|
export function getDefaultHomePathByType(type: OmsLoginType): string {
|
||||||
if (type === OmsLoginType.Customer) {
|
if (type === OmsLoginType.Customer) {
|
||||||
return preferH5Portal() ? CUSTOMER_H5_HOME_PATH : CUSTOMER_PC_HOME_PATH
|
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
|
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('/h5') ||
|
||||||
p.startsWith('/wechat/') ||
|
p.startsWith('/wechat/') ||
|
||||||
p.startsWith('/track/') ||
|
p.startsWith('/track/') ||
|
||||||
|
p.startsWith('/agent') ||
|
||||||
p.startsWith('/redirect')
|
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 {
|
export function resolveLoginRouteNameFromPath(path: string): string {
|
||||||
const p = (path || '').trim() || '/'
|
const p = (path || '').trim() || '/'
|
||||||
if (p === CUSTOMER_LOGIN_PATH || p === '/' || p.startsWith(`${CUSTOMER_LOGIN_PATH}/`)) {
|
if (p === CUSTOMER_LOGIN_PATH || p === '/' || p.startsWith(`${CUSTOMER_LOGIN_PATH}/`)) {
|
||||||
return CUSTOMER_LOGIN_ROUTE_NAME
|
return CUSTOMER_LOGIN_ROUTE_NAME
|
||||||
}
|
}
|
||||||
|
if (isAgentPortalPath(p)) {
|
||||||
|
return AGENT_LOGIN_ROUTE_NAME
|
||||||
|
}
|
||||||
if (p === ADMIN_LOGIN_PATH || p.startsWith(`${ADMIN_LOGIN_PATH}/`)) {
|
if (p === ADMIN_LOGIN_PATH || p.startsWith(`${ADMIN_LOGIN_PATH}/`)) {
|
||||||
return ADMIN_LOGIN_ROUTE_NAME
|
return ADMIN_LOGIN_ROUTE_NAME
|
||||||
}
|
}
|
||||||
@ -212,6 +241,8 @@ export function resolvePostLogoutLogin(options?: {
|
|||||||
let routeName: string
|
let routeName: string
|
||||||
if (type === OmsLoginType.Customer) {
|
if (type === OmsLoginType.Customer) {
|
||||||
routeName = CUSTOMER_LOGIN_ROUTE_NAME
|
routeName = CUSTOMER_LOGIN_ROUTE_NAME
|
||||||
|
} else if (type === OmsLoginType.Agent) {
|
||||||
|
routeName = AGENT_LOGIN_ROUTE_NAME
|
||||||
} else if (type === OmsLoginType.User) {
|
} else if (type === OmsLoginType.User) {
|
||||||
routeName = ADMIN_LOGIN_ROUTE_NAME
|
routeName = ADMIN_LOGIN_ROUTE_NAME
|
||||||
} else {
|
} 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 {
|
return {
|
||||||
name: routeName,
|
name: routeName,
|
||||||
query:
|
query:
|
||||||
|
|||||||
@ -22,6 +22,9 @@ export const SUB_MENU_DEFAULT_ICONS: Record<string, string> = {
|
|||||||
'menu.home.monitor': 'IconLiveBroadcast',
|
'menu.home.monitor': 'IconLiveBroadcast',
|
||||||
'menu.home.adminEventBus': 'IconMindMapping',
|
'menu.home.adminEventBus': 'IconMindMapping',
|
||||||
'menu.home.trackEventBus': 'IconRelation',
|
'menu.home.trackEventBus': 'IconRelation',
|
||||||
|
'menu.agentAdmin': 'IconUserGroup',
|
||||||
|
'menu.agentAdmin.list': 'IconUser',
|
||||||
|
'menu.agentAdmin.commission': 'IconGift',
|
||||||
'menu.user.customer': 'IconUserGroup',
|
'menu.user.customer': 'IconUserGroup',
|
||||||
'menu.user.profile': 'IconUser',
|
'menu.user.profile': 'IconUser',
|
||||||
'menu.user.recharge': 'IconGift',
|
'menu.user.recharge': 'IconGift',
|
||||||
@ -124,6 +127,8 @@ export const PATH_TO_LOCALE_KEY: Record<string, string> = {
|
|||||||
'/order/guide': 'menu.order.orderGuide',
|
'/order/guide': 'menu.order.orderGuide',
|
||||||
'/channel/guide': 'menu.channel.channelGuide',
|
'/channel/guide': 'menu.channel.channelGuide',
|
||||||
'/marketing/guide': 'menu.marketing.marketingGuide',
|
'/marketing/guide': 'menu.marketing.marketingGuide',
|
||||||
|
'/agentAdmin/list': 'menu.agentAdmin.list',
|
||||||
|
'/agentAdmin/commission': 'menu.agentAdmin.commission',
|
||||||
}
|
}
|
||||||
|
|
||||||
type MenuIconItem = {
|
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>
|
<template>
|
||||||
<div ref="tableContainerRef" class="channel-config-hot-table">
|
<div ref="tableContainerRef" class="channel-config-hot-table">
|
||||||
<HandsontablePro
|
<HandsontablePro
|
||||||
:columns="columns"
|
:columns="resolvedColumns"
|
||||||
:data="data"
|
:data="data"
|
||||||
:total="totalCount"
|
:total="totalCount"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
@ -13,17 +13,24 @@
|
|||||||
:custom-cell-renderer="customCellRenderer"
|
:custom-cell-renderer="customCellRenderer"
|
||||||
:stretch-columns="true"
|
:stretch-columns="true"
|
||||||
:height="handsontableHeight"
|
:height="handsontableHeight"
|
||||||
:enable-header-search="false"
|
:search-params="resolvedSearchParams"
|
||||||
:auto-hybrid-header="false"
|
:enable-header-search="true"
|
||||||
|
:auto-hybrid-header="true"
|
||||||
:enable-column-filter="false"
|
:enable-column-filter="false"
|
||||||
|
:sort-mode="sortMode"
|
||||||
|
:sort-field="sortField"
|
||||||
|
:sort-order="sortOrder"
|
||||||
|
@search="handleSearch"
|
||||||
|
@sort-change="handleSortChange"
|
||||||
@action-click="handleActionClick"
|
@action-click="handleActionClick"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
|
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer';
|
||||||
import { computed } from 'vue';
|
import { computed, reactive, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import HandsontablePro from '@/components/HandsontablePro/index.vue';
|
import HandsontablePro from '@/components/HandsontablePro/index.vue';
|
||||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
|
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
|
||||||
@ -36,6 +43,8 @@ import { computed } from 'vue';
|
|||||||
rowData: any
|
rowData: any
|
||||||
) => string | null;
|
) => string | null;
|
||||||
|
|
||||||
|
type SortOrder = 'asc' | 'desc' | null;
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
columns: ChannelHotColumn[];
|
columns: ChannelHotColumn[];
|
||||||
@ -46,6 +55,15 @@ import { computed } from 'vue';
|
|||||||
serialNoHeader?: string;
|
serialNoHeader?: string;
|
||||||
actionColumnHeader?: string;
|
actionColumnHeader?: string;
|
||||||
customCellRenderer?: CellRenderer;
|
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,
|
totalCount: 0,
|
||||||
@ -54,17 +72,40 @@ import { computed } from 'vue';
|
|||||||
serialNoHeader: '',
|
serialNoHeader: '',
|
||||||
actionColumnHeader: '',
|
actionColumnHeader: '',
|
||||||
customCellRenderer: undefined,
|
customCellRenderer: undefined,
|
||||||
|
searchParams: undefined,
|
||||||
|
serverHeaderSearch: false,
|
||||||
|
resetSearchKey: null,
|
||||||
|
sortMode: 'local',
|
||||||
|
sortField: null,
|
||||||
|
sortOrder: null,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
edit: [row: any];
|
edit: [row: any];
|
||||||
delete: [row: any];
|
delete: [row: any];
|
||||||
|
search: [];
|
||||||
|
'sort-change': [payload: { field: string | null; order: SortOrder }];
|
||||||
|
'selection-change': [rows: any[]];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { tableContainerRef, tableHeight } = useTableContainerHeight();
|
const { tableContainerRef, tableHeight } = useTableContainerHeight();
|
||||||
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 200));
|
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(
|
const resolvedSerialNoHeader = computed(
|
||||||
() => props.serialNoHeader || t('channel.area.table.serialNo')
|
() => props.serialNoHeader || t('channel.area.table.serialNo')
|
||||||
@ -73,20 +114,44 @@ import { computed } from 'vue';
|
|||||||
() => props.actionColumnHeader || t('channel.area.table.actions')
|
() => 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 {
|
function actionRenderer(_row: any, _rowIndex: number): string {
|
||||||
return wrapWarehouseActions([
|
return wrapWarehouseActions([
|
||||||
renderWarehouseActionBtn('edit', t('channel.area.actions.edit')),
|
renderWarehouseActionBtn('edit', t('channel.area.actions.edit')),
|
||||||
renderWarehouseActionBtn('delete', t('channel.area.actions.delete')),
|
renderWarehouseActionBtn('delete', t('channel.area.actions.delete')),
|
||||||
])
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleActionClick(row: any, rowIndex: number, action?: string) {
|
function handleActionClick(row: any, rowIndex: number, action?: string) {
|
||||||
const idx = (props.currentPage - 1) * props.pageSize + rowIndex;
|
const record = row || props.data[rowIndex];
|
||||||
const record = props.data[idx] || row;
|
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
emit('delete', record);
|
emit('delete', record);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit('edit', record);
|
emit('edit', record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: any[]) {
|
||||||
|
emit('selection-change', rows || []);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -86,7 +86,7 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
status="danger"
|
status="danger"
|
||||||
:disabled="!currentId?.id"
|
:disabled="!currentId?.id || !selectedDetailRows.length"
|
||||||
@click="handleToolbarDelete"
|
@click="handleToolbarDelete"
|
||||||
>
|
>
|
||||||
{{ t('channel.area.actions.delete') }}
|
{{ t('channel.area.actions.delete') }}
|
||||||
@ -103,6 +103,15 @@
|
|||||||
:total-count="totalCount"
|
:total-count="totalCount"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
: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)"
|
@edit="(row) => handleOpenRightModal('edit', row)"
|
||||||
@delete="(row) => handleDelRightData(row.id)"
|
@delete="(row) => handleDelRightData(row.id)"
|
||||||
/>
|
/>
|
||||||
@ -286,6 +295,9 @@
|
|||||||
importModalRef,
|
importModalRef,
|
||||||
tableData,
|
tableData,
|
||||||
hotColumns,
|
hotColumns,
|
||||||
|
headerSearchParams,
|
||||||
|
listSortField,
|
||||||
|
listSortOrder,
|
||||||
leftModalTip,
|
leftModalTip,
|
||||||
rightModalTip,
|
rightModalTip,
|
||||||
searchModel,
|
searchModel,
|
||||||
@ -308,7 +320,11 @@
|
|||||||
returnRightEditRowParams,
|
returnRightEditRowParams,
|
||||||
handleDelData,
|
handleDelData,
|
||||||
handleToolbarDelete,
|
handleToolbarDelete,
|
||||||
|
selectedDetailRows,
|
||||||
|
handleDetailSelectionChange,
|
||||||
handleDelRightData,
|
handleDelRightData,
|
||||||
|
handleHeaderSearch,
|
||||||
|
handleSortChange,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
handlePageSizeChange,
|
handlePageSizeChange,
|
||||||
onFormSubmitClick,
|
onFormSubmitClick,
|
||||||
|
|||||||
@ -2,4 +2,12 @@ export type ChannelHotColumn = {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
width: number;
|
width: number;
|
||||||
|
/** 混合表头:内嵌搜索 / 仅图标 / 纯展示 */
|
||||||
|
headerMode?: 'inline' | 'icon' | 'none';
|
||||||
|
searchable?: boolean;
|
||||||
|
searchField?: string;
|
||||||
|
/** false=仅当前页本地过滤;true=交给页面发请求走后端 */
|
||||||
|
serverFilter?: boolean;
|
||||||
|
/** 表头排序(remote 时由页面请求后端) */
|
||||||
|
sortable?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,12 +15,13 @@ import {
|
|||||||
getKpiAreaPublishRecord,
|
getKpiAreaPublishRecord,
|
||||||
} from '@/api/channel/kpiArea';
|
} from '@/api/channel/kpiArea';
|
||||||
import { getCountryList } from '@/api/system/country';
|
import { getCountryList } from '@/api/system/country';
|
||||||
import {
|
import {
|
||||||
DESTINATION_COUNTRY_QUICK_LABELS,
|
DESTINATION_COUNTRY_QUICK_LABELS,
|
||||||
applyQuickCountryToFormModel,
|
applyQuickCountryToFormModel,
|
||||||
} from '@/views/channel/countryQuickTags';
|
} from '@/views/channel/countryQuickTags';
|
||||||
import { syncChannelLeftSelection } from '@/views/channel/composables/channelLeftSelection';
|
import { syncChannelLeftSelection } from '@/views/channel/composables/channelLeftSelection';
|
||||||
import type { ChannelHotColumn } from '@/views/channel/components/channelHotTable.types';
|
import type { ChannelHotColumn } from '@/views/channel/components/channelHotTable.types';
|
||||||
|
import { toServerSearchValue } from '@/components/HandsontablePro/filterValue';
|
||||||
import { $baseConfirm, $baseMessage } from '@/utils';
|
import { $baseConfirm, $baseMessage } from '@/utils';
|
||||||
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types';
|
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types';
|
||||||
|
|
||||||
@ -531,6 +532,10 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
const pageSize = ref(50);
|
const pageSize = ref(50);
|
||||||
const tableLoading = ref(false);
|
const tableLoading = ref(false);
|
||||||
const leftLoading = 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 leftMenuData = ref<any[]>([]);
|
||||||
const leftExpandedKeys = ref<string[]>(['category-list']);
|
const leftExpandedKeys = ref<string[]>(['category-list']);
|
||||||
@ -582,15 +587,112 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hotColumns = computed<ChannelHotColumn[]>(() => [
|
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: 'countryName',
|
||||||
{ key: 'matchModeName', label: t('channel.area.form.matchMode'), width: 100 },
|
label: t('channel.area.form.country'),
|
||||||
{ key: 'province', label: t('channel.area.form.province'), width: 120 },
|
width: 120,
|
||||||
{ key: 'city', label: t('channel.area.form.city'), width: 120 },
|
searchField: 'country',
|
||||||
{ key: 'leftPostcode', label: t('channel.area.table.leftPostcode'), width: 120 },
|
serverFilter: true,
|
||||||
{ key: 'rightPostcode', label: t('channel.area.table.rightPostcode'), width: 120 },
|
},
|
||||||
|
{
|
||||||
|
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) {
|
function handleSelectFromTree(_keys: string[], info: any) {
|
||||||
const key = info.node?.key;
|
const key = info.node?.key;
|
||||||
if (!key || !key.startsWith('item-')) return;
|
if (!key || !key.startsWith('item-')) return;
|
||||||
@ -603,9 +705,29 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
if (nodeData?.id != null) handleDelData(nodeData.id, nodeData?.name);
|
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) {
|
function selectArea(item: any) {
|
||||||
currentId.value = item;
|
currentId.value = item;
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
|
selectedDetailRows.value = [];
|
||||||
|
clearHeaderSearchParams();
|
||||||
|
clearListSort();
|
||||||
loadTableData();
|
loadTableData();
|
||||||
packList.value = [];
|
packList.value = [];
|
||||||
}
|
}
|
||||||
@ -815,12 +937,33 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleToolbarDelete() {
|
function handleToolbarDelete() {
|
||||||
const row = currentId.value;
|
if (!currentId.value?.id) {
|
||||||
if (row?.id == null) {
|
|
||||||
$baseMessage(t('channel.area.import.needSelectArea'), 'warning');
|
$baseMessage(t('channel.area.import.needSelectArea'), 'warning');
|
||||||
return;
|
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) {
|
function handleDelRightData(id: number) {
|
||||||
@ -830,6 +973,9 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteForewarningServiceKpiAreaDeleteKpiAreaInfo({ id });
|
await deleteForewarningServiceKpiAreaDeleteKpiAreaInfo({ id });
|
||||||
|
selectedDetailRows.value = selectedDetailRows.value.filter(
|
||||||
|
(row) => Number(row?.id ?? row?.Id ?? 0) !== id,
|
||||||
|
);
|
||||||
await loadTableData();
|
await loadTableData();
|
||||||
$baseMessage(t('channel.area.message.deleteSuccess'), 'success');
|
$baseMessage(t('channel.area.message.deleteSuccess'), 'success');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -843,6 +989,7 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
if (!currentId.value?.id) {
|
if (!currentId.value?.id) {
|
||||||
tableData.value = [];
|
tableData.value = [];
|
||||||
totalCount.value = 0;
|
totalCount.value = 0;
|
||||||
|
selectedDetailRows.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -850,13 +997,18 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
try {
|
try {
|
||||||
const response: any = await postForewarningServiceKpiAreaGetKpiAreaInfos({
|
const response: any = await postForewarningServiceKpiAreaGetKpiAreaInfos({
|
||||||
areaId: currentId.value.id,
|
areaId: currentId.value.id,
|
||||||
pageIndex: currentPage.value,
|
page: currentPage.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
|
...buildInfoListFilters(),
|
||||||
|
...(listSortField.value && listSortOrder.value
|
||||||
|
? { sortField: listSortField.value, sortOrder: listSortOrder.value }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
const items = filterByAreaType(response?.items ?? response?.data?.items ?? []);
|
const items = filterByAreaType(response?.items ?? response?.data?.items ?? []);
|
||||||
totalCount.value = Number(
|
totalCount.value = Number(
|
||||||
response?.totalCount ?? response?.total ?? items.length ?? 0
|
response?.totalCount ?? response?.total ?? items.length ?? 0
|
||||||
);
|
);
|
||||||
|
selectedDetailRows.value = [];
|
||||||
tableData.value = items.map((item) => {
|
tableData.value = items.map((item) => {
|
||||||
const inferred = inferGeoMatch(item);
|
const inferred = inferGeoMatch(item);
|
||||||
const matchDim = item.matchDim || inferred.matchDim;
|
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) {
|
function handlePageChange(page: number) {
|
||||||
currentPage.value = page;
|
currentPage.value = page;
|
||||||
loadTableData();
|
loadTableData();
|
||||||
@ -942,8 +1106,9 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
while (all.length < total && page <= 200) {
|
while (all.length < total && page <= 200) {
|
||||||
const response: any = await postForewarningServiceKpiAreaGetKpiAreaInfos({
|
const response: any = await postForewarningServiceKpiAreaGetKpiAreaInfos({
|
||||||
areaId,
|
areaId,
|
||||||
pageIndex: page,
|
page,
|
||||||
pageSize: pageSizeAll,
|
pageSize: pageSizeAll,
|
||||||
|
...buildInfoListFilters(),
|
||||||
});
|
});
|
||||||
const items = filterByAreaType(response?.items ?? response?.data?.items ?? []);
|
const items = filterByAreaType(response?.items ?? response?.data?.items ?? []);
|
||||||
total = Number(response?.totalCount ?? response?.total ?? items.length ?? 0);
|
total = Number(response?.totalCount ?? response?.total ?? items.length ?? 0);
|
||||||
@ -986,8 +1151,12 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
editFormRightParams.areaId = val?.id;
|
editFormRightParams.areaId = val?.id;
|
||||||
if (val) {
|
if (val) {
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
|
clearHeaderSearchParams();
|
||||||
|
clearListSort();
|
||||||
loadTableData();
|
loadTableData();
|
||||||
} else {
|
} else {
|
||||||
|
clearHeaderSearchParams();
|
||||||
|
clearListSort();
|
||||||
tableData.value = [];
|
tableData.value = [];
|
||||||
totalCount.value = 0;
|
totalCount.value = 0;
|
||||||
}
|
}
|
||||||
@ -1016,6 +1185,9 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
importModalRef,
|
importModalRef,
|
||||||
tableData,
|
tableData,
|
||||||
hotColumns,
|
hotColumns,
|
||||||
|
headerSearchParams,
|
||||||
|
listSortField,
|
||||||
|
listSortOrder,
|
||||||
leftModalTip,
|
leftModalTip,
|
||||||
rightModalTip,
|
rightModalTip,
|
||||||
searchModel,
|
searchModel,
|
||||||
@ -1038,7 +1210,11 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
|||||||
returnRightEditRowParams,
|
returnRightEditRowParams,
|
||||||
handleDelData,
|
handleDelData,
|
||||||
handleToolbarDelete,
|
handleToolbarDelete,
|
||||||
|
selectedDetailRows,
|
||||||
|
handleDetailSelectionChange,
|
||||||
handleDelRightData,
|
handleDelRightData,
|
||||||
|
handleHeaderSearch,
|
||||||
|
handleSortChange,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
handlePageSizeChange,
|
handlePageSizeChange,
|
||||||
onFormSubmitClick,
|
onFormSubmitClick,
|
||||||
|
|||||||
@ -108,6 +108,7 @@
|
|||||||
:total-count="totalCount"
|
:total-count="totalCount"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
|
:reset-search-key="currentId?.id"
|
||||||
:serial-no-header="t('channel.weightRounding.table.serialNo')"
|
:serial-no-header="t('channel.weightRounding.table.serialNo')"
|
||||||
:action-column-header="t('channel.weightRounding.table.actions')"
|
:action-column-header="t('channel.weightRounding.table.actions')"
|
||||||
@edit="(row) => handleOpenRightModal('edit', row)"
|
@edit="(row) => handleOpenRightModal('edit', row)"
|
||||||
|
|||||||
@ -104,6 +104,7 @@
|
|||||||
:total-count="totalCount"
|
:total-count="totalCount"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
|
:reset-search-key="currentId?.id"
|
||||||
:serial-no-header="t('channel.weightRules.table.serialNo')"
|
:serial-no-header="t('channel.weightRules.table.serialNo')"
|
||||||
:action-column-header="t('channel.weightRules.table.actions')"
|
:action-column-header="t('channel.weightRules.table.actions')"
|
||||||
:custom-cell-renderer="hotCellRenderer"
|
:custom-cell-renderer="hotCellRenderer"
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
@focusout="hovered = false"
|
@focusout="hovered = false"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="(pillar, index) in adminPillars"
|
v-for="(pillar, index) in activePillars"
|
||||||
:key="pillar.key"
|
:key="pillar.key"
|
||||||
class="login-hero__principle login-hero__spine-item login-hero__spine-item--left"
|
class="login-hero__principle login-hero__spine-item login-hero__spine-item--left"
|
||||||
:class="{
|
:class="{
|
||||||
@ -68,7 +68,7 @@
|
|||||||
<icon-robot :size="14" />
|
<icon-robot :size="14" />
|
||||||
AI
|
AI
|
||||||
</span>
|
</span>
|
||||||
<p class="login-hero__ai-text">{{ t('login.hero.admin.ai') }}</p>
|
<p class="login-hero__ai-text">{{ t(aiNoteKey) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -110,10 +110,8 @@ import {
|
|||||||
|
|
||||||
defineOptions({ name: 'LoginHero' });
|
defineOptions({ name: 'LoginHero' });
|
||||||
|
|
||||||
type AdminPillarKey = 'care' | 'speed' | 'reach';
|
interface HeroPillar {
|
||||||
|
key: string;
|
||||||
interface AdminPillar {
|
|
||||||
key: AdminPillarKey;
|
|
||||||
icon: Component;
|
icon: Component;
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
descKey: string;
|
descKey: string;
|
||||||
@ -121,7 +119,7 @@ interface AdminPillar {
|
|||||||
t: number;
|
t: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminPillars: AdminPillar[] = [
|
const adminPillars: HeroPillar[] = [
|
||||||
{
|
{
|
||||||
key: 'care',
|
key: 'care',
|
||||||
icon: IconCheckCircle,
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
light?: boolean;
|
light?: boolean;
|
||||||
/** 客户门户强调记忆;管理端强调运营开场 */
|
/** 客户门户 / 管理端 / 渠道代理 */
|
||||||
portal?: 'customer' | 'admin';
|
portal?: 'customer' | 'admin' | 'agent';
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
portal: 'customer',
|
portal: 'customer',
|
||||||
@ -167,13 +192,19 @@ const hovered = ref(false);
|
|||||||
let cycleTimer: ReturnType<typeof window.setInterval> | null = null;
|
let cycleTimer: ReturnType<typeof window.setInterval> | null = null;
|
||||||
|
|
||||||
const isCustomer = computed(() => props.portal === 'customer');
|
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() {
|
function startCycle() {
|
||||||
stopCycle();
|
stopCycle();
|
||||||
if (isCustomer.value) return;
|
if (isCustomer.value) return;
|
||||||
cycleTimer = window.setInterval(() => {
|
cycleTimer = window.setInterval(() => {
|
||||||
if (hovered.value) return;
|
if (hovered.value) return;
|
||||||
activePrinciple.value = (activePrinciple.value + 1) % adminPillars.length;
|
activePrinciple.value = (activePrinciple.value + 1) % activePillars.value.length;
|
||||||
}, 3200);
|
}, 3200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -137,25 +137,30 @@ const loginExit = inject<LoginExitApi | null>(LOGIN_EXIT_KEY, null);
|
|||||||
const loginCharacters = computed(() => loginCharactersRef?.value ?? null);
|
const loginCharacters = computed(() => loginCharactersRef?.value ?? null);
|
||||||
|
|
||||||
const isCustomerLogin = computed(() => props.loginType === OmsLoginType.Customer);
|
const isCustomerLogin = computed(() => props.loginType === OmsLoginType.Customer);
|
||||||
const brandHint = computed(() =>
|
const isAgentLogin = computed(() => props.loginType === OmsLoginType.Agent);
|
||||||
t(isCustomerLogin.value ? 'login.form.brandHint.customer' : 'login.form.brandHint.admin'),
|
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 / 欢迎文案 → 官网首页 */
|
/** Logo / 欢迎文案 → 官网首页 */
|
||||||
function goOfficialSite() {
|
function goOfficialSite() {
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
const accountFieldLabel = computed(() => t('login.field.account'));
|
const accountFieldLabel = computed(() =>
|
||||||
const accountPlaceholder = computed(() =>
|
isAgentLogin.value ? t('login.field.account.agent') : t('login.field.account'),
|
||||||
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 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 agreementChecked = ref(true);
|
||||||
const agreementVisible = ref(false);
|
const agreementVisible = ref(false);
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
<div ref="rightSplitRef" class="login-split login-split--right" aria-hidden="true" />
|
<div ref="rightSplitRef" class="login-split login-split--right" aria-hidden="true" />
|
||||||
|
|
||||||
<LoginCareBanner
|
<LoginCareBanner
|
||||||
v-if="shellReady && !isCustomerLogin"
|
v-if="shellReady && isAdminLogin"
|
||||||
:light="isLightMode"
|
:light="isLightMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -24,7 +24,7 @@
|
|||||||
:class="{ 'login-brand-corner--light': isLightMode }"
|
:class="{ 'login-brand-corner--light': isLightMode }"
|
||||||
>
|
>
|
||||||
<span class="login-brand-corner__name">{{ t('brand.name') }}</span>
|
<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>
|
</div>
|
||||||
<aside v-if="isCustomerLogin" class="login-hero-panel">
|
<aside v-if="isCustomerLogin" class="login-hero-panel">
|
||||||
<LoginHero
|
<LoginHero
|
||||||
@ -43,7 +43,7 @@
|
|||||||
>
|
>
|
||||||
<LoginHero
|
<LoginHero
|
||||||
:light="isLightMode"
|
:light="isLightMode"
|
||||||
portal="admin"
|
:portal="isAgentLogin ? 'agent' : 'admin'"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -80,7 +80,10 @@
|
|||||||
@switch-pwd="setLoginMode('pwd')"
|
@switch-pwd="setLoginMode('pwd')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LoginPortalSwitch :target="isCustomerLogin ? 'admin' : 'customer'" />
|
<LoginPortalSwitch
|
||||||
|
v-if="loginType !== OmsLoginType.Agent"
|
||||||
|
:target="isCustomerLogin ? 'admin' : 'customer'"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -132,6 +135,11 @@ const { t } = useI18n();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const loginType = computed(() => getLoginTypeFromRouteName(route.name as string));
|
const loginType = computed(() => getLoginTypeFromRouteName(route.name as string));
|
||||||
const isCustomerLogin = computed(() => loginType.value === OmsLoginType.Customer);
|
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 showCharacters = ref(false);
|
||||||
const activeKey = ref<'pwd' | 'wechat'>('pwd');
|
const activeKey = ref<'pwd' | 'wechat'>('pwd');
|
||||||
@ -141,7 +149,8 @@ const wechatLoginRef = ref<{ prefetch?: () => void } | null>(null);
|
|||||||
const shellReady = ref(
|
const shellReady = ref(
|
||||||
route.query.from === 'official-site' ||
|
route.query.from === 'official-site' ||
|
||||||
route.name === 'login' ||
|
route.name === 'login' ||
|
||||||
route.name === 'adminLogin',
|
route.name === 'adminLogin' ||
|
||||||
|
route.name === 'agentLogin',
|
||||||
);
|
);
|
||||||
const isExiting = ref(false);
|
const isExiting = ref(false);
|
||||||
const loginCharactersApi = shallowRef<LoginCharactersApi | null>(null);
|
const loginCharactersApi = shallowRef<LoginCharactersApi | null>(null);
|
||||||
|
|||||||
@ -9,13 +9,17 @@
|
|||||||
'login.form.account.placeholder': 'Please enter account',
|
'login.form.account.placeholder': 'Please enter account',
|
||||||
'login.form.account.placeholder.admin': 'Please enter account',
|
'login.form.account.placeholder.admin': 'Please enter account',
|
||||||
'login.form.account.placeholder.customer': 'Phone / Email / Customer code',
|
'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': 'Please enter account',
|
||||||
'login.form.account.errMsg.admin': '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.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.account.hint.customer': 'Sign in with phone, email, or customer code',
|
||||||
'login.form.login': 'Login',
|
'login.form.login': 'Login',
|
||||||
'login.form.brandHint.customer': 'Welcome to your IDE portal',
|
'login.form.brandHint.customer': 'Welcome to your IDE portal',
|
||||||
'login.form.brandHint.admin': 'Welcome to IDE Operations Console',
|
'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.switchToWechat': 'Sign in / register with WeChat',
|
||||||
'login.switchToAccount': 'Account and password',
|
'login.switchToAccount': 'Account and password',
|
||||||
'login.wechat.tips': 'Scan with WeChat to sign in / register. QR code valid for 10 minutes.',
|
'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.pillar.reach.desc': 'Pickup to delivery · Clear flow · Fewer detours',
|
||||||
'login.hero.admin.ai':
|
'login.hero.admin.ai':
|
||||||
'Embrace AI: spot issues, protect SLAs, simplify flows — people decide, AI accelerates.',
|
'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.morning': 'Good morning — a fresh start',
|
||||||
'login.care.period.afternoon': 'Good afternoon — steady wins',
|
'login.care.period.afternoon': 'Good afternoon — steady wins',
|
||||||
'login.care.period.evening': 'Good evening — thanks for today',
|
'login.care.period.evening': 'Good evening — thanks for today',
|
||||||
@ -108,6 +120,7 @@
|
|||||||
'login.hero.track': 'Track',
|
'login.hero.track': 'Track',
|
||||||
'login.hero.trackPending': 'Tracking lookup is coming soon.',
|
'login.hero.trackPending': 'Tracking lookup is coming soon.',
|
||||||
'login.field.account': 'Account',
|
'login.field.account': 'Account',
|
||||||
|
'login.field.account.agent': 'Agent code',
|
||||||
'login.field.password': 'Password',
|
'login.field.password': 'Password',
|
||||||
'login.showPassword': 'Show password',
|
'login.showPassword': 'Show password',
|
||||||
'login.hidePassword': 'Hide password',
|
'login.hidePassword': 'Hide password',
|
||||||
|
|||||||
@ -9,13 +9,17 @@
|
|||||||
'login.form.account.placeholder': '请输入登录账号',
|
'login.form.account.placeholder': '请输入登录账号',
|
||||||
'login.form.account.placeholder.admin': '请输入登录账号',
|
'login.form.account.placeholder.admin': '请输入登录账号',
|
||||||
'login.form.account.placeholder.customer': '手机号 / 邮箱 / 客户编号',
|
'login.form.account.placeholder.customer': '手机号 / 邮箱 / 客户编号',
|
||||||
|
'login.form.account.placeholder.agent': '请输入代理编号',
|
||||||
'login.form.account.errMsg': '请输入登录账号',
|
'login.form.account.errMsg': '请输入登录账号',
|
||||||
'login.form.account.errMsg.admin': '请输入登录账号',
|
'login.form.account.errMsg.admin': '请输入登录账号',
|
||||||
'login.form.account.errMsg.customer': '请输入手机号、邮箱或客户编号',
|
'login.form.account.errMsg.customer': '请输入手机号、邮箱或客户编号',
|
||||||
|
'login.form.account.errMsg.agent': '请输入代理编号',
|
||||||
'login.form.account.hint.customer': '支持手机号、邮箱或客户编号登录',
|
'login.form.account.hint.customer': '支持手机号、邮箱或客户编号登录',
|
||||||
'login.form.login': '登录',
|
'login.form.login': '登录',
|
||||||
'login.form.brandHint.customer': '欢迎登录 IDE 客户门户',
|
'login.form.brandHint.customer': '欢迎登录 IDE 客户门户',
|
||||||
'login.form.brandHint.admin': '欢迎登录 IDE 运营管理后台',
|
'login.form.brandHint.admin': '欢迎登录 IDE 运营管理后台',
|
||||||
|
'login.form.brandHint.agent': '欢迎登录 IDE 渠道代理门户',
|
||||||
|
'login.brand.suffix.agent': '渠道代理',
|
||||||
'login.switchToWechat': '微信扫码登录/注册',
|
'login.switchToWechat': '微信扫码登录/注册',
|
||||||
'login.switchToAccount': '账号密码登录',
|
'login.switchToAccount': '账号密码登录',
|
||||||
'login.wechat.tips': '请使用微信扫一扫登录/注册,二维码 10 分钟内有效',
|
'login.wechat.tips': '请使用微信扫一扫登录/注册,二维码 10 分钟内有效',
|
||||||
@ -47,6 +51,13 @@
|
|||||||
'login.hero.admin.pillar.reach.title': '更简单',
|
'login.hero.admin.pillar.reach.title': '更简单',
|
||||||
'login.hero.admin.pillar.reach.desc': '揽收到签收 · 链路清晰 · 少绕少错',
|
'login.hero.admin.pillar.reach.desc': '揽收到签收 · 链路清晰 · 少绕少错',
|
||||||
'login.hero.admin.ai': '积极拥抱 AI:帮你盯异常、催时效、简流程——人做决策,AI 做加速。',
|
'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.morning': '早上好,新的一天开始了',
|
||||||
'login.care.period.afternoon': '下午好,稳稳推进就好',
|
'login.care.period.afternoon': '下午好,稳稳推进就好',
|
||||||
'login.care.period.evening': '傍晚好,今天也辛苦了',
|
'login.care.period.evening': '傍晚好,今天也辛苦了',
|
||||||
@ -98,6 +109,7 @@
|
|||||||
'login.hero.track': '查询物流',
|
'login.hero.track': '查询物流',
|
||||||
'login.hero.trackPending': '物流查询功能即将上线。',
|
'login.hero.trackPending': '物流查询功能即将上线。',
|
||||||
'login.field.account': '账号',
|
'login.field.account': '账号',
|
||||||
|
'login.field.account.agent': '代理编号',
|
||||||
'login.field.password': '密码',
|
'login.field.password': '密码',
|
||||||
'login.showPassword': '显示密码',
|
'login.showPassword': '显示密码',
|
||||||
'login.hidePassword': '隐藏密码',
|
'login.hidePassword': '隐藏密码',
|
||||||
|
|||||||
@ -123,6 +123,7 @@ const formRef = ref<FormInstance>()
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const inviteCode = ref('')
|
const inviteCode = ref('')
|
||||||
const advisorCode = ref('')
|
const advisorCode = ref('')
|
||||||
|
const agentCode = ref('')
|
||||||
const referrerInfo = ref<Record<string, unknown> | null>(null)
|
const referrerInfo = ref<Record<string, unknown> | null>(null)
|
||||||
const referrerValidated = ref<boolean | null>(null)
|
const referrerValidated = ref<boolean | null>(null)
|
||||||
const referrerLoading = ref(false)
|
const referrerLoading = ref(false)
|
||||||
@ -215,9 +216,11 @@ async function handleSubmit() {
|
|||||||
phone: formState.phone.trim(),
|
phone: formState.phone.trim(),
|
||||||
email: formState.email.trim(),
|
email: formState.email.trim(),
|
||||||
password: formState.password,
|
password: formState.password,
|
||||||
...(inviteCode.value && referrerValidated.value
|
...(agentCode.value
|
||||||
? ({ inviteCode: inviteCode.value } as Record<string, string>)
|
? ({ agentCode: agentCode.value } as Record<string, string>)
|
||||||
: {}),
|
: inviteCode.value && referrerValidated.value
|
||||||
|
? ({ inviteCode: inviteCode.value } as Record<string, string>)
|
||||||
|
: {}),
|
||||||
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
|
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
|
||||||
})
|
})
|
||||||
const { clearStashedInviteRef } = await import('@/utils/inviteRefStorage')
|
const { clearStashedInviteRef } = await import('@/utils/inviteRefStorage')
|
||||||
@ -237,8 +240,13 @@ function goLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
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()
|
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>)
|
const advisor = resolveAdvisorInvite(route.query as Record<string, unknown>)
|
||||||
if (advisor) {
|
if (advisor) {
|
||||||
advisorCode.value = advisor
|
advisorCode.value = advisor
|
||||||
|
|||||||
@ -305,6 +305,7 @@ const authModalVisible = ref(false);
|
|||||||
const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
|
const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
|
||||||
const inviteCode = ref('');
|
const inviteCode = ref('');
|
||||||
const advisorCode = ref('');
|
const advisorCode = ref('');
|
||||||
|
const agentCode = ref('');
|
||||||
const referrerValidated = ref<boolean | null>(null);
|
const referrerValidated = ref<boolean | null>(null);
|
||||||
const referrerInfo = ref<{ name: string; code: string } | null>(null);
|
const referrerInfo = ref<{ name: string; code: string } | null>(null);
|
||||||
let loadingSlowTimer: number | undefined;
|
let loadingSlowTimer: number | undefined;
|
||||||
@ -419,13 +420,16 @@ async function handleSubmit() {
|
|||||||
const errors = await formRef.value?.validate();
|
const errors = await formRef.value?.validate();
|
||||||
if (errors) return;
|
if (errors) return;
|
||||||
|
|
||||||
const canBindInvite = Boolean(inviteCode.value && referrerValidated.value === true);
|
const canBindInvite = Boolean(
|
||||||
|
!agentCode.value && inviteCode.value && referrerValidated.value === true,
|
||||||
|
);
|
||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await registerByWechat({
|
const result = await registerByWechat({
|
||||||
customerName: formState.customerName.trim(),
|
customerName: formState.customerName.trim(),
|
||||||
password: formState.password,
|
password: formState.password,
|
||||||
|
...(agentCode.value ? { agentCode: agentCode.value } : {}),
|
||||||
...(canBindInvite ? { inviteCode: inviteCode.value } : {}),
|
...(canBindInvite ? { inviteCode: inviteCode.value } : {}),
|
||||||
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
|
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
|
||||||
wechat: {
|
wechat: {
|
||||||
@ -462,8 +466,12 @@ async function handleSubmit() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
hideWeChatBrowserNav();
|
hideWeChatBrowserNav();
|
||||||
loadingHint.value = t('wechat.common.loading');
|
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>);
|
const refCode = resolveWeChatInviteRef(route.query as Record<string, unknown>);
|
||||||
if (refCode) {
|
if (refCode && !agent) {
|
||||||
void loadReferrer(refCode);
|
void loadReferrer(refCode);
|
||||||
}
|
}
|
||||||
const advisor = resolveAdvisorInvite(route.query as Record<string, unknown>);
|
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': {
|
'/operation': {
|
||||||
target: omsAdminTarget,
|
target: omsAdminTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
@ -556,6 +573,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
'/warehouse/inboundNotice',
|
'/warehouse/inboundNotice',
|
||||||
'/warehouse/binLocation',
|
'/warehouse/binLocation',
|
||||||
'/Plugin/AddPackage',
|
'/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