sync(本地→GitHub): 2026-05-30 21:41 全量单向同步
## GitHub 同步说明
| 项 | 内容 |
|:---|:---|
| 仓库 | https://github.com/fnvtk/cunkebao_v3 |
| 分支 | develop |
| 方向 | 本地 → GitHub(单向 push) |
| 是否拉取远程 | 否(未 fetch / pull / merge) |
| 是否改本地文件 | 否(仅 git add/commit,未编辑任何源文件正文) |
| 基准 commit | 0643e78a |
| 变更规模 | 709 files, +26436 / -3407 lines |
## 目录变更统计
| 目录 | 文件数 | 说明 |
|:---|---:|:---|
| 开发文档/ | 404 | 四端进行中/已完成需求、接口文档、官网需求、部署脚本 |
| Server/ | 95 | 后端 API、迁移脚本、触客宝/超管/存客宝服务 |
| Cunkebao/ | 85 | 移动端 H5、设备绑定、客服、转号审批等 |
| Touchkebao/ | 68 | PC 工作台、AI 获客、算力中心、Glass UI |
| SuperAdmin/ | 33 | 超管项目中心、算力计费、内容库 |
| 官网/ | 19 | 新增存客宝官网静态页(index/pricing/solutions 等) |
| .cursor/ | 3 | Cursor 规则与 Skill |
| .obsidian/ | 2 | Obsidian 工作区配置 |
## 重点新增/更新(本地为准)
- 官网/: 完整静态站点(index、pricing、manual、solutions、api)
- 开发文档/1、需求/修改/*_进行中_20260530.md(存客宝/触客宝/AI数智员工)
- 开发文档/1、需求/官网需求.md
- 开发文档/5、接口/开发进度_接口文档.md 等接口文档迭代
- Touchkebao Glass UI 与 P0 收尾、通道获客算力
- SuperAdmin 算力计费规则、项目工作台
## 刻意未上传(本地保留)
- node_modules/
- .DS_Store、.smart-env/
- .开发文档_nested_git_backup/(旧嵌套 git 备份)
- **/__pycache__/
## 验收
GitHub 浏览: https://github.com/fnvtk/cunkebao_v3/tree/develop
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
28
.cursor/rules/cunkebao-four-end-http-only.mdc
Normal file
28
.cursor/rules/cunkebao-four-end-http-only.mdc
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
description: 四端只 HTTP 对接、不串仓硬性铁律。改触客宝/AI数智员工/存客宝/超管任意一端时强制遵守。
|
||||
globs:
|
||||
- "**/*"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 四端对接铁律 · 只 HTTP · 不串仓
|
||||
|
||||
> 真源:`开发文档/2、架构/四端对接铁律_只HTTP不串仓.md`
|
||||
|
||||
## 强制
|
||||
|
||||
1. **各端各自仓库、各自职责** — 存客宝(3100) · 触客宝(3101) · AI数智员工(3104) · SuperAdmin(3103) **不互相改业务代码**。
|
||||
2. **协作只走 HTTP API** — JWT `/v1/*` · Open `X-API-KEY` · 禁止跨项目 import 源码、禁止直连 MySQL/Mongo。
|
||||
3. **数据范围** — 请求必须带当前项目 `companyId`;禁止跨项目读写。
|
||||
4. **新能力** — 先更 `开发文档/5、接口/` 索引,再改 `route.php`,最后写 UI。
|
||||
5. **工作手机/神射手** — 只经存客宝 BFF 或 documented HTTP;AI数智员工 **禁止** `/v1/kefu/*`。
|
||||
|
||||
## 禁止
|
||||
|
||||
- 在触客宝/AI数智员工仓修改 `Server/application/cunkebao`(除非用户本轮明确「改存客宝 Server」)
|
||||
- copy 兄弟项目 React 业务组件进本仓(设计 token 可对齐)
|
||||
- 触客宝直连 `traffic_pool` 表
|
||||
|
||||
## 接口索引
|
||||
|
||||
`开发文档/5、接口/SuperAdmin_四端对接接口索引.md` · `02-存客宝对接/触客宝PC接口对接索引.md`
|
||||
@@ -23,6 +23,7 @@ alwaysApply: true
|
||||
- 路由真源:`Server/application/*/config/route.php`
|
||||
- 不改原有业务 URL;设备写操作走 `/v1/workphone/*`
|
||||
- 对照 `5、接口/02-存客宝与工作手机对接/06-业务接口与工作手机一对一映射总表.md`
|
||||
- **边开发边标注**:改 `开发文档/1、需求/修改/{项目}_进行中_*.md` 对应行状态(⬜→🟡→✅)+ 验收证据;整节关单再迁入 `已完成/`
|
||||
|
||||
## 收尾(Phase 2)
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: 需求协调与归档
|
||||
description: 存客宝四端需求日终汇总、命名项目_功能主题_日期、已完成扁平归档、COORD分写入各端MD。触发词:需求协调、需求归档、四端汇总、已完成、跨项目协调、W05c、0527未完、存客宝需求目录。
|
||||
description: 存客宝四端需求维护与文档收敛:一项目一进行中MD、已完成只进已完成/、关单分拣、COORD、0530维护经验。触发词:需求协调、需求归档、四端汇总、已完成、进行中、修改目录整理、一项目一文件、关单、需求维护。
|
||||
---
|
||||
|
||||
# 需求协调与归档 Skill(存客宝 v3 · W05c 项目内)
|
||||
# 需求协调与归档 Skill(存客宝 v3 · 0530 维护版)
|
||||
|
||||
> **当日总表**:`开发文档/1、需求/修改/YYYYMMDD_四端需求与协同汇总.md`
|
||||
> **协调真源**:`/Users/karuo/Documents/开发/_共享/跨项目协调/索引.md`
|
||||
> **未完成横表**:`开发文档/1、需求/修改/YYYYMMDD_四端未完成需求汇总.md`
|
||||
> **已完成总汇**:`开发文档/1、需求/修改/已完成/YYYYMMDD_四端已完成与全量检测总汇/`(**不在根目录**)
|
||||
> **端真源**:`开发文档/1、需求/修改/{项目}_进行中_{YYYYMMDD}.md`(**每端最多 1 个**)
|
||||
> **卡若AI 母 Skill**:`/Users/karuo/Documents/个人/卡若AI/02_卡人(水)/水泉_规划拆解/需求协调/SKILL.md`
|
||||
> **单篇整理**:[开发需求文档整理/SKILL.md](../开发需求文档整理/SKILL.md)
|
||||
|
||||
@@ -16,73 +17,131 @@ description: 存客宝四端需求日终汇总、命名项目_功能主题_日
|
||||
|
||||
| 场景 | 动作 |
|
||||
|:---|:---|
|
||||
| 日终 / 新一天 | 更新 `YYYYMMDD_四端需求与协同汇总.md` |
|
||||
| 验收通过 | 迁入 `已完成/{项目}_{功能主题}_{日期}/` |
|
||||
| 昨日未完 | 写入**今日**该端主题 MD(不新增第二文件同端同日) |
|
||||
| 跨端依赖 | 登记 COORD + **各端 MD**「跨项目协调(本端)」 |
|
||||
| 开发前 | 读总表 + 本端 MD + 共享协调索引 |
|
||||
| 新 P0/P1 需求 | **写入对应端 `_进行中`** · 禁止另开 `{项目}_{主题}.md` |
|
||||
| 需求写完仅文档 | 在 `_进行中` 内写 **固定规则+验收** · 状态标 ⬜ |
|
||||
| 验收通过 | 迁入 `已完成/{项目}_{主题}_{date}/` · 更新 **已完成总汇** §索引 |
|
||||
| 端 100% 关单 | **删除** 根目录 `{端}_进行中` · 总汇保留链接 |
|
||||
| 日终 / 分拣 | 更新 `四端未完成需求汇总` · 已完成项 **移出** 根目录 |
|
||||
| 跨端依赖 | COORD 写入 **各端 `_进行中`**「跨项目协调」小节 |
|
||||
|
||||
---
|
||||
|
||||
## 二、目录纪律(强制)
|
||||
## 二、目录纪律(0530 收敛 · 强制)
|
||||
|
||||
```
|
||||
开发文档/1、需求/修改/
|
||||
├── YYYYMMDD_四端需求与协同汇总.md
|
||||
├── README.md
|
||||
├── YYYYMMDD_四端未完成需求汇总.md ← 唯一四端横表
|
||||
├── 存客宝_进行中_YYYYMMDD.md ← 含该端全部未完 + P0 全文
|
||||
├── 触客宝_进行中_YYYYMMDD.md
|
||||
├── AI数智员工_进行中_YYYYMMDD.md ← 有未完才保留
|
||||
├── images/
|
||||
├── 存客宝_{功能主题}_{YYYYMMDD}.md # 每端每天一篇
|
||||
├── 触客宝_{功能主题}_{YYYYMMDD}.md
|
||||
├── SuperAdmin_{功能主题}_{YYYYMMDD}.md
|
||||
├── AI数智员工_{功能主题}_{YYYYMMDD}.md
|
||||
└── 已完成/
|
||||
├── README.md
|
||||
├── YYYYMMDD_四端已完成与全量检测总汇/
|
||||
│ └── 同名.md ← 已完成唯一总入口
|
||||
└── {项目}_{功能主题}_{YYYYMMDD}/
|
||||
├── 同名.md
|
||||
└── images/
|
||||
```
|
||||
|
||||
| 禁止 | 说明 |
|
||||
### 禁止(本轮维护教训)
|
||||
|
||||
| 禁止 | 正确做法 |
|
||||
|:---|:---|
|
||||
| `modify/存客宝/` 等子目录 | 进行中全部扁平 |
|
||||
| 文件名含「当日需求」 | 用功能主题 |
|
||||
| 初刻宝/初客宝 | 统一 **触客宝** |
|
||||
| COORD 只写总表 | **必须**分写入各端项目 MD |
|
||||
| 根目录 `四端已完成*.md` | 只放 `已完成/…/四端已完成…/` |
|
||||
| 同项目两个 MD(`_进行中` + 专文) | P0 全文 **并入** `_进行中` · 旧专文 **归档** 到 `已完成/` |
|
||||
| 已关单端仍留 `_进行中` stub | SuperAdmin 100% → 无根目录 `_进行中` |
|
||||
| 根目录堆 `存客宝_流量池_*` / `触客宝_手机_*` | 合并进 `存客宝_进行中` / `触客宝_进行中` |
|
||||
| 文件名 初刻宝/初客宝/触刻版 | 统一 **触客宝** |
|
||||
| COORD 只写横表 | **必须**写入各端 `_进行中` |
|
||||
| 仅文档却开第二个「真源」 | **一个端一个真源** |
|
||||
|
||||
---
|
||||
|
||||
## 三、COORD 分配(写入各端 MD)
|
||||
## 三、0530 维护期经验(固定写法)
|
||||
|
||||
### 3.1 需求文档结构(P0 块)
|
||||
|
||||
每个 P0 在 `_进行中` 内必须含:
|
||||
|
||||
1. **问题表**(卡若原话 / 截图路径)
|
||||
2. **根因**(代码文件 · 现状 vs 期望)
|
||||
3. **固定规则**(归一化、合并键、禁止项 · **不可写抽象**)
|
||||
4. **接口清单**(不变 URL)
|
||||
5. **代码锚点**(待建/已有路径)
|
||||
6. **验收表**(可数字 · V1~Vn)
|
||||
|
||||
### 3.2 只写文档不开发
|
||||
|
||||
- 状态标 **「P0 · 仅文档 · 待开发」**
|
||||
- 同步 `开发文档/5、接口/开发进度_接口文档.md` 顶部一节
|
||||
- **不**为专文单独占根目录文件名
|
||||
|
||||
### 3.3 已完成 vs 进行中边界
|
||||
|
||||
| 状态 | 放哪 |
|
||||
|:---|:---|
|
||||
| 代码 ✅ · 待目视 🟡 | 进行中留一行 · 细节进总汇或 `已完成/` 批次 |
|
||||
| 代码 ✅ · 验收 ✅ | 移入 `已完成/` · 从 `_进行中` **删**该行 |
|
||||
| 运营 P2(签字/Key 轮换) | 写横表 · **不**占 P0 开发 slot |
|
||||
| 全端 100% | 无 `_进行中` · 链总汇 §二 |
|
||||
|
||||
### 3.4 典型 P0 模板(本轮实例)
|
||||
|
||||
| 端 | 主题 | 要点 |
|
||||
|:---|:---|:---|
|
||||
| 存客宝 | 流量池去重+RFM | M1–M4 同人键 · poolKeywords 并集 · 02:20/02:30 cron · 四维度 |
|
||||
| 触客宝 | 手机↔PC 会话 | 抽 `useKefuSessionBootstrap` · `message/list` |
|
||||
| 触客宝 | 手机微信号切换 | 顶栏 **头像+Popup** · `MobileWechatAccountSwitcher` |
|
||||
| 触客宝 | AI 获客 §十四 | 已添加态 · 去 Webhook · `sort=rfm` · 标签打勾 **`已添加·{微信号}`** |
|
||||
| 跨端 | COORD-006 | `CompanyWechatScopeService` · 设备/微信同源 |
|
||||
|
||||
### 3.5 截图与 images
|
||||
|
||||
- 路径:`修改/images/{项目}_{现象}_{YYYYMMDD}.png`
|
||||
- `_进行中` 内引用:`./images/…`
|
||||
- 关单迁 `已完成/{批次}/images/` 时 **改相对路径**
|
||||
|
||||
---
|
||||
|
||||
## 四、COORD 分配(写入各端 `_进行中`)
|
||||
|
||||
| COORD | 简述 | 写入文档 |
|
||||
|:---|:---|:---|
|
||||
| 001 | SuperAdmin 开放 API | 存客宝(提供)、SuperAdmin、触客宝、AI数智员工 |
|
||||
| 002 | aistaff 勿入客户池 | SuperAdmin、AI数智员工 |
|
||||
| 003 | RFM 估值同口径 | SuperAdmin、触客宝 |
|
||||
| 003 | RFM 估值同口径 | SuperAdmin、触客宝、存客宝流量池 |
|
||||
| 004 | AI 数智员工扫码绑定 | 存客宝、AI数智员工 |
|
||||
| 005 | Soul 永平同步 | 存客宝 |
|
||||
| 006 | Soul 内容库 API | 存客宝 |
|
||||
| 006 | 设备/微信项目同源 | **存客宝、触客宝** |
|
||||
|
||||
各端 MD 内固定小节:
|
||||
各端固定小节:
|
||||
|
||||
```markdown
|
||||
## 跨项目协调(本端 · W05c)
|
||||
## 跨项目协调(本端)
|
||||
|
||||
| COORD | 简述 | 角色 | 状态 | 阻塞本端模块 |
|
||||
| COORD | 简述 | 角色 | 状态 | 阻塞本端 |
|
||||
|:---|:---|:---|:---:|:---|
|
||||
| … | … | 需求方/提供方/消费方 | 🟡 | … |
|
||||
| 006 | 设备/微信同源 | 提供/消费 | ✅/⬜ | … |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、日终流程
|
||||
## 五、关单流程(逐步)
|
||||
|
||||
```
|
||||
1. 各端更新 {项目}_{主题}_{日期}.md(未完 + COORD)
|
||||
2. 更新 YYYYMMDD_四端需求与协同汇总.md
|
||||
3. 验收完成 → 已完成/{项目}_{主题}_{date}/
|
||||
4. 更新 1、需求/索引.md
|
||||
5. 同步共享协调索引(若有新 COORD)
|
||||
1. 开发/文档完成 → 更新 {端}_进行中 状态 ✅
|
||||
2. 全文批次 → 已完成/{项目}_{主题}_{date}/同名.md
|
||||
3. 更新 已完成/…/四端已完成总汇.md §二~§三 索引(勿在根目录复制)
|
||||
4. 从 _进行中 删除已完成行;若端无未完 → 删除根目录 _进行中 文件
|
||||
5. 更新 四端未完成需求汇总.md
|
||||
6. 更新 5、接口/开发进度_接口文档.md
|
||||
7. 更新 已完成/README.md 表格一行
|
||||
8. 禁止 git push(除非卡若本轮明确说提交)
|
||||
```
|
||||
|
||||
### 脚本
|
||||
### 脚本(可选)
|
||||
|
||||
```bash
|
||||
python3 开发文档/1、需求/scripts/flatten_requirements.py
|
||||
@@ -91,37 +150,42 @@ python3 开发文档/1、需求/scripts/reorganize_requirement_docs.py
|
||||
|
||||
---
|
||||
|
||||
## 五、W05c 开发前检查
|
||||
## 六、开发前检查
|
||||
|
||||
1. 读当日总表 **完成目标** + 本端 MD
|
||||
2. 读本端 **跨项目协调** 表
|
||||
3. 🔴/🟡 且任务在阻塞范围 → 不整体验收
|
||||
1. 读 `四端未完成需求汇总` + **本端唯一** `_进行中`
|
||||
2. 读 `_进行中` 内 **跨项目协调** 表
|
||||
3. 🔴/🟡 阻塞项在范围内 → 不整体验收
|
||||
4. 对话列出:已读文档 ≤5 条 + COORD 状态
|
||||
|
||||
---
|
||||
|
||||
## 六、与卡若AI 分工
|
||||
|
||||
| Skill | 职责 |
|
||||
|:---|:---|
|
||||
| 卡若AI W05b 需求整理 | 通用落盘;存客宝执行 **本项目** `开发需求文档整理` |
|
||||
| 卡若AI W05c 需求协调 | 共享 COORD 索引;存客宝执行 **本项目** 本文 |
|
||||
| 开发需求文档整理 | 录屏→全量/模块、完成目标、四端命名 |
|
||||
5. 确认 **没有** 第二份同端 MD
|
||||
|
||||
---
|
||||
|
||||
## 七、质量门禁
|
||||
|
||||
- [ ] 四端各有当日主题 MD(无进行中可注「—」)
|
||||
- [ ] 各端 MD 含 COORD 表(或「当前无」)
|
||||
- [ ] 总表链接文件名正确
|
||||
- [ ] 已完成在 `已完成/{项目}_{主题}_{date}/` 且图片不断链
|
||||
- [ ] `索引.md` 已更新
|
||||
- [ ] 根目录无 `*已完成*` MD(总汇只在 `已完成/` 子夹)
|
||||
- [ ] 每端 **≤1** 个 `_进行中` 在根目录
|
||||
- [ ] 无 `{项目}_{主题}` 与 `_进行中` 并存(专文已合并或归档)
|
||||
- [ ] 横表链接指向 `_进行中` · 不链已删专文
|
||||
- [ ] P0 块含:问题 / 根因 / 固定规则 / 验收
|
||||
- [ ] 已完成批次在 `已完成/{项目}_{主题}_{date}/` 且图片不断链
|
||||
- [ ] `修改/README.md` 与 `已完成/README.md` 已更新
|
||||
|
||||
---
|
||||
|
||||
## 八、延伸阅读
|
||||
## 八、与卡若AI 分工
|
||||
|
||||
| Skill | 职责 |
|
||||
|:---|:---|
|
||||
| 卡若AI W05b 需求整理 | 通用落盘 → 本项目用 `开发需求文档整理` |
|
||||
| 卡若AI W05c 需求协调 | 共享 COORD → 本项目用 **本文** |
|
||||
| 开发需求文档整理 | 录屏→全量 MD · Obsidian 配图 |
|
||||
| **本文(0530)** | **一项目一文件 · 已完成下沉 · 关单分拣 · 维护期模板** |
|
||||
|
||||
---
|
||||
|
||||
## 九、延伸阅读
|
||||
|
||||
- [修改/README.md](../../开发文档/1、需求/修改/README.md)
|
||||
- [已完成/README.md](../../开发文档/1、需求/修改/已完成/README.md)
|
||||
- [四端对接铁律](../../开发文档/2、架构/四端对接铁律_只HTTP不串仓.md)
|
||||
- [跨项目协调索引](file:///Users/karuo/Documents/开发/_共享/跨项目协调/索引.md)
|
||||
|
||||
2
.obsidian/graph.json
vendored
2
.obsidian/graph.json
vendored
@@ -88,6 +88,6 @@
|
||||
"repelStrength": 10,
|
||||
"linkStrength": 1,
|
||||
"linkDistance": 200,
|
||||
"scale": 2.3596490376776305,
|
||||
"scale": 0.17544920373570672,
|
||||
"close": true
|
||||
}
|
||||
146
.obsidian/workspace.json
vendored
146
.obsidian/workspace.json
vendored
@@ -13,15 +13,58 @@
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "开发文档/1、需求/修改/存客宝_待办_20260529.md",
|
||||
"file": "开发文档/1、需求/修改/20260530_四端未完成需求汇总.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "存客宝_待办_20260529"
|
||||
"title": "20260530_四端未完成需求汇总"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ec1134c89e8fb5ef",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "开发文档/1、需求/修改/存客宝_进行中_20260530.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "存客宝_进行中_20260530"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b04f69b0c217f981",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "开发文档/1、需求/修改/20260530_四端未完成需求汇总.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "20260530_四端未完成需求汇总"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a1a5490d2d226da7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "开发文档/1、需求/修改/20260530_四端未完成需求汇总.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "20260530_四端未完成需求汇总"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"currentTab": 3
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
@@ -53,7 +96,7 @@
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"query": "tag:#存客宝",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
@@ -94,7 +137,7 @@
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "开发文档/项目文档生成器_核心提示词.md",
|
||||
"file": "开发文档/1、需求/修改/20260530_四端已完成与全量检测总汇.md",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
@@ -104,7 +147,7 @@
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "项目文档生成器_核心提示词 的反向链接列表"
|
||||
"title": "20260530_四端已完成与全量检测总汇 的反向链接列表"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -183,53 +226,52 @@
|
||||
"bases:新建数据库": false
|
||||
}
|
||||
},
|
||||
"active": "4301052b31068085",
|
||||
"active": "a1a5490d2d226da7",
|
||||
"lastOpenFiles": [
|
||||
"开发文档/1、需求/修改/images/触客宝_工作台UI调整_AI获客入口_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_工作台UI调整_全屏标注_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_工作台UI调整_快捷语用户资料_20260530.png",
|
||||
"开发文档/1、需求/修改/存客宝_进行中_20260530.md",
|
||||
"开发文档/1、需求/修改/触客宝_进行中_20260530.md",
|
||||
"Touchkebao/src/store/module/workspaceView.ts",
|
||||
"Touchkebao/dist/assets/ui-DGusZFzJ.js.map",
|
||||
"Touchkebao/dist/assets/charts-CbrQ5b9I.js.map",
|
||||
"Touchkebao/dist/assets/index-KGFM1siQ.js.map",
|
||||
"Touchkebao/dist/assets/ui-DGusZFzJ.js",
|
||||
"Touchkebao/dist/assets/charts-CbrQ5b9I.js",
|
||||
"Touchkebao/dist/assets/index-KGFM1siQ.js",
|
||||
"Touchkebao/dist/assets/index-Dg9z7D7m.js.map",
|
||||
"Touchkebao/dist/assets/index-74fWUKTM.css",
|
||||
"Touchkebao/dist/assets/index-CBDCvNSf.js.map",
|
||||
"开发文档/1、需求/修改/存客宝_待办_20260529.md",
|
||||
"开发文档/1、需求/修改/AI数智员工_待办_20260529.md",
|
||||
"开发文档/1、需求/修改/已完成/触客宝_GlassUI与P0收尾_20260530/触客宝_GlassUI与P0收尾_20260530.md",
|
||||
"开发文档/1、需求/修改/20260529_四端需求与协同汇总.md",
|
||||
"开发文档/1、需求/修改/已完成/SuperAdmin_待办闭环_20260529/SuperAdmin_待办闭环_20260529.md",
|
||||
"开发文档/1、需求/修改/已完成/存客宝_0529HTTP对接_20260529/存客宝_0529HTTP对接_20260529.md",
|
||||
"开发文档/1、需求/修改/已完成/触客宝_通道获客算力_20260530/触客宝_通道获客算力_20260530.md",
|
||||
"开发文档/1、需求/修改/触客宝_进行中_20260530/触客宝_进行中_20260530.md",
|
||||
"开发文档/1、需求/修改/images/触客宝_转移弹窗_文案过长_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_设置页布局_客户资料AI知识库_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_老坑爹设备显示离线_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_消息推送助手_子页过宽_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_微信号右键菜单_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_客服通道异常_ErrorBoundary_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_头像下拉_缓存退出_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_功能中心_卡片过宽_20260530.png",
|
||||
"开发文档/1、需求/修改/触客宝_待办_20260529.md",
|
||||
"开发文档/1、需求/修改/SuperAdmin_待办_20260529.md",
|
||||
"开发文档/1、需求/修改/已完成/触客宝_登录与稳定性修Bug_20260529/触客宝_登录与稳定性修Bug_20260529.md",
|
||||
"开发文档/1、需求/修改/已完成/AI数智员工_协同对接与扫码绑定_20260529/AI数智员工_协同对接与扫码绑定_20260529.md",
|
||||
"开发文档/1、需求/修改/已完成/SuperAdmin_AI算力计费规则配置_20260529/SuperAdmin_AI算力计费规则配置_20260529.md",
|
||||
"开发文档/1、需求/修改/已完成/SuperAdmin_超管后台重构_20260529/SuperAdmin_超管后台重构_20260529.md",
|
||||
"开发文档/1、需求/修改/触客宝_登录与稳定性修Bug_20260529.md",
|
||||
"开发文档/1、需求/修改/触客宝_登录与稳定性修Bug_20260529/触客宝_登录与稳定性修Bug_20260529.md",
|
||||
"开发文档/1、需求/修改/触客宝_AI获客与工作台重构_20260529.md",
|
||||
"开发文档/1、需求/修改/已完成/触客宝_AI获客与工作台重构_20260529/触客宝_AI获客与工作台重构_20260529.md",
|
||||
"开发文档/1、需求/修改/SuperAdmin_超管后台重构_20260529.md",
|
||||
"开发文档/1、需求/修改/已完成/存客宝_微信号场景迁移与设备_20260529/存客宝_微信号场景迁移与设备_20260529.md",
|
||||
"开发文档/1、需求/修改/SuperAdmin_AI算力计费规则配置_20260529.md",
|
||||
"开发文档/1、需求/修改/存客宝_微信号场景迁移与设备_20260529.md",
|
||||
"开发文档/1、需求/修改/AI数智员工_协同对接与扫码绑定_20260529.md",
|
||||
"开发文档/1、需求/技术需求.md"
|
||||
"Touchkebao/dist/assets/index-_21d12yV.js",
|
||||
"Touchkebao/dist/assets/index-C0leaL0s.js",
|
||||
"Touchkebao/dist/assets/index-BrGEjQC4.js",
|
||||
"Touchkebao/dist/index.html",
|
||||
"Touchkebao/dist/assets/charts-BWbQe5Ma.js",
|
||||
"Touchkebao/dist/assets/utils-B5Rx2zvY.js",
|
||||
"Touchkebao/dist/assets/ui-CCc4q-w9.js",
|
||||
"Touchkebao/dist/assets/vendor-D4UbCzhU.js",
|
||||
"Touchkebao/dist/assets/index-GOnB0xwO.css",
|
||||
"Touchkebao/dist/assets/index-DfxITDxP.css",
|
||||
"开发文档/1、需求/修改/AI数智员工_进行中_20260530.md",
|
||||
"开发文档/1、需求/修改/已完成/SuperAdmin_进行中_stub_20260530/README.md",
|
||||
"开发文档/1、需求/修改/已完成/存客宝_流量池去重与每日RFM迭代_20260530/README.md",
|
||||
"开发文档/1、需求/修改/已完成/触客宝_手机端会话与PC同步_20260530/README.md",
|
||||
"开发文档/1、需求/修改/20260530_四端未完成需求汇总.md",
|
||||
"开发文档/1、需求/修改/触客宝_手机端会话与PC同步_20260530.md",
|
||||
"开发文档/1、需求/修改/已完成/SuperAdmin_进行中_stub_20260530/SuperAdmin_进行中_20260530.md",
|
||||
"开发文档/1、需求/修改/已完成/触客宝_手机端会话与PC同步_20260530/触客宝_手机端会话与PC同步_20260530.md",
|
||||
"开发文档/1、需求/修改/已完成/存客宝_流量池去重与每日RFM迭代_20260530/存客宝_流量池去重与每日RFM迭代_20260530.md",
|
||||
"开发文档/1、需求/修改/已完成/20260530_四端已完成与全量检测总汇/20260530_四端已完成与全量检测总汇.md",
|
||||
"开发文档/1、需求/修改/存客宝_流量池去重与每日RFM迭代_20260530.md",
|
||||
"开发文档/1、需求/修改/images/触客宝_手机端暂无会话_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_手机端头像无法退出_20260530.png",
|
||||
"开发文档/1、需求/修改/SuperAdmin_进行中_20260530.md",
|
||||
"开发文档/1、需求/修改/images/触客宝_AI获客同号重复_20260530.png",
|
||||
"开发文档/1、需求/修改/images/触客宝_添加微信无设备_20260530.png",
|
||||
"开发文档/1、需求/修改/20260530_四端已完成与全量检测总汇.md",
|
||||
"开发文档/1、需求/需求修改/README.md",
|
||||
"开发文档/1、需求/需求修改/2026-05-25_场景获客_Webhook客资推送.md",
|
||||
"开发文档/1、需求/修改/已完成/需求修改_20260525批次/README.md",
|
||||
"开发文档/1、需求/修改/已完成/20260530_四端需求收尾总文档_归档/20260530_四端需求收尾总文档.md",
|
||||
"开发文档/1、需求/修改/已完成/需求修改_20260525批次/2026-05-25_首页运营看板重构.md",
|
||||
"开发文档/1、需求/修改/已完成/需求修改_20260525批次/2026-05-25_设备管理_Tab顺序与扫码设备可见性.md",
|
||||
"开发文档/1、需求/修改/已完成/需求修改_20260525批次/2026-05-25_设备扫码二维码获取失败修复.md",
|
||||
"开发文档/1、需求/修改/已完成/需求修改_20260525批次/2026-05-25_海报获客_扫码误跳老游条修复.md",
|
||||
"开发文档/1、需求/修改/已完成/需求修改_20260525批次/2026-05-25_海报获客_手机号授权与入池修复.md",
|
||||
"开发文档/1、需求/修改/已完成/需求修改_20260525批次/2026-05-25_海报获客_小程序码落地页修正.md",
|
||||
"开发文档/1、需求/修改/images/存客宝_添加设备参数缺失_20260530.png",
|
||||
"开发文档/1、需求/修改/images/存客宝_设备开关Toast_20260530.png",
|
||||
"开发文档/1、需求/修改/images/存客宝_设备改名404_20260530.png",
|
||||
"开发文档/1、需求/修改/images/存客宝_我的页设备数错误_20260530.png",
|
||||
"官网/assets/img/ind-star.svg",
|
||||
"官网/assets/img/ind-kol.svg"
|
||||
]
|
||||
}
|
||||
@@ -217,7 +217,7 @@ if (isAndroid() && isLowVersionAndroid()) {
|
||||
return setTimeout(function () {
|
||||
callback(Date.now());
|
||||
}, 1000 / 60);
|
||||
};
|
||||
} as unknown as typeof window.requestAnimationFrame;
|
||||
}
|
||||
|
||||
if (!window.cancelAnimationFrame) {
|
||||
@@ -257,7 +257,7 @@ if (isAndroid() && isLowVersionAndroid()) {
|
||||
this.disconnect = function () {
|
||||
this.observers = [];
|
||||
};
|
||||
};
|
||||
} as unknown as typeof window.IntersectionObserver;
|
||||
}
|
||||
|
||||
// 修复ResizeObserver在Android WebView中的问题
|
||||
@@ -280,7 +280,7 @@ if (isAndroid() && isLowVersionAndroid()) {
|
||||
this.disconnect = function () {
|
||||
this.observers = [];
|
||||
};
|
||||
};
|
||||
} as unknown as typeof window.ResizeObserver;
|
||||
}
|
||||
|
||||
// 修复URLSearchParams在Android WebView中的问题
|
||||
@@ -345,8 +345,6 @@ if (isAndroid() && isLowVersionAndroid()) {
|
||||
}
|
||||
return pairs.join("&");
|
||||
};
|
||||
};
|
||||
} as unknown as typeof window.URLSearchParams;
|
||||
}
|
||||
|
||||
console.log("Android兼容性polyfill已加载完成");
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ instance.interceptors.response.use(
|
||||
}
|
||||
return Promise.reject(payload);
|
||||
}
|
||||
Toast.show({ content: msg || "接口错误", position: "top" });
|
||||
if (!(res.config as any)?.skipErrorToast) {
|
||||
Toast.show({ content: msg || "接口错误", position: "top" });
|
||||
}
|
||||
return Promise.reject(msg || "接口错误");
|
||||
},
|
||||
err => {
|
||||
|
||||
@@ -66,8 +66,10 @@
|
||||
}
|
||||
|
||||
.friendList {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.friendListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
@@ -3,6 +3,11 @@ import { Popup } from "antd-mobile";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import {
|
||||
selectionPopupBodyStyle,
|
||||
selectionPopupLayoutStyle,
|
||||
selectionPopupStyle,
|
||||
} from "@/components/PopuLayout/popupShell";
|
||||
import style from "./index.module.scss";
|
||||
import { getAccountList } from "./api";
|
||||
import { AccountItem, SelectionPopupProps } from "./data";
|
||||
@@ -143,9 +148,14 @@ export default function SelectionPopup({
|
||||
visible={visible && !readonly}
|
||||
onMaskClick={() => onVisibleChange(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
style={selectionPopupStyle}
|
||||
bodyStyle={selectionPopupBodyStyle}
|
||||
closeOnMaskClick
|
||||
destroyOnClose
|
||||
>
|
||||
<Layout
|
||||
embedded
|
||||
style={selectionPopupLayoutStyle}
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择账号"
|
||||
@@ -154,6 +164,7 @@ export default function SelectionPopup({
|
||||
searchPlaceholder="搜索账号"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchAccounts(currentPage, searchQuery)}
|
||||
onClose={() => onVisibleChange(false)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -5,6 +5,11 @@ import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import {
|
||||
selectionPopupBodyStyle,
|
||||
selectionPopupLayoutStyle,
|
||||
selectionPopupStyle,
|
||||
} from "@/components/PopuLayout/popupShell";
|
||||
import { ContentItem } from "./data";
|
||||
|
||||
interface SelectionPopupProps {
|
||||
@@ -211,10 +216,14 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
style={selectionPopupStyle}
|
||||
bodyStyle={selectionPopupBodyStyle}
|
||||
closeOnMaskClick={false}
|
||||
destroyOnClose
|
||||
>
|
||||
<Layout
|
||||
embedded
|
||||
style={selectionPopupLayoutStyle}
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择内容库"
|
||||
@@ -224,6 +233,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
loading={loading}
|
||||
onSearch={handleSearch}
|
||||
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -5,6 +5,11 @@ import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import {
|
||||
selectionPopupBodyStyle,
|
||||
selectionPopupLayoutStyle,
|
||||
selectionPopupStyle,
|
||||
} from "@/components/PopuLayout/popupShell";
|
||||
import { DeviceSelectionItem } from "./data";
|
||||
|
||||
interface SelectionPopupProps {
|
||||
@@ -169,17 +174,14 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{
|
||||
height: "min(92vh, calc(100dvh - env(safe-area-inset-top, 0px)))",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
style={selectionPopupStyle}
|
||||
bodyStyle={selectionPopupBodyStyle}
|
||||
closeOnMaskClick
|
||||
destroyOnClose
|
||||
>
|
||||
<Layout
|
||||
embedded
|
||||
style={selectionPopupLayoutStyle}
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择设备"
|
||||
@@ -188,6 +190,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
searchPlaceholder="搜索设备IMEI/备注/微信号"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchDevices(searchQuery, currentPage)}
|
||||
onClose={onClose}
|
||||
showTabs={true}
|
||||
tabsConfig={{
|
||||
activeKey: statusFilter,
|
||||
|
||||
@@ -3,6 +3,11 @@ import { Popup, Checkbox } from "antd-mobile";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import {
|
||||
selectionPopupBodyStyle,
|
||||
selectionPopupLayoutStyle,
|
||||
selectionPopupStyle,
|
||||
} from "@/components/PopuLayout/popupShell";
|
||||
import { getFriendList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import type { FriendSelectionItem } from "./data";
|
||||
@@ -156,9 +161,14 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible={visible && !readonly}
|
||||
onMaskClick={() => onVisibleChange(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
style={selectionPopupStyle}
|
||||
bodyStyle={selectionPopupBodyStyle}
|
||||
closeOnMaskClick
|
||||
destroyOnClose
|
||||
>
|
||||
<Layout
|
||||
embedded
|
||||
style={selectionPopupLayoutStyle}
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择微信好友"
|
||||
@@ -167,6 +177,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
searchPlaceholder="搜索好友"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchFriends(currentPage, searchQuery)}
|
||||
onClose={() => onVisibleChange(false)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -6,6 +6,11 @@ import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import {
|
||||
selectionPopupBodyStyle,
|
||||
selectionPopupLayoutStyle,
|
||||
selectionPopupStyle,
|
||||
} from "@/components/PopuLayout/popupShell";
|
||||
import { GroupSelectionItem } from "./data";
|
||||
// 群组接口类型
|
||||
interface WechatGroup {
|
||||
@@ -172,9 +177,14 @@ export default function SelectionPopup({
|
||||
visible={visible && !readonly}
|
||||
onMaskClick={() => onVisibleChange(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
style={selectionPopupStyle}
|
||||
bodyStyle={selectionPopupBodyStyle}
|
||||
closeOnMaskClick
|
||||
destroyOnClose
|
||||
>
|
||||
<Layout
|
||||
embedded
|
||||
style={selectionPopupLayoutStyle}
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择群聊"
|
||||
@@ -183,6 +193,7 @@ export default function SelectionPopup({
|
||||
searchPlaceholder="搜索群聊"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchGroups(currentPage, searchQuery)}
|
||||
onClose={() => onVisibleChange(false)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -181,7 +181,6 @@ const InfiniteList = <T extends any>({
|
||||
return (
|
||||
<PullToRefresh
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
className={styles.pullToRefresh}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface LayoutProps {
|
||||
children?: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
/**
|
||||
* frame:mobile = 全端统一手机全屏布局(PC 不再居中壳/侧栏)
|
||||
* frame:fullscreen 与 mobile 等效,保留兼容旧调用
|
||||
@@ -15,6 +16,11 @@ interface LayoutProps {
|
||||
frame?: "mobile" | "fullscreen";
|
||||
/** 主体背景,默认沿用旧 layout 的 #f5f7fb */
|
||||
bg?: "default" | "soft" | "aurora";
|
||||
/**
|
||||
* embedded:用于 Popup/Drawer 内嵌(height 100% + 底栏不 Portal)
|
||||
* 避免与页面 TabBarPortal(z-index 10000) 叠层导致「取消/确定」点不动
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({
|
||||
@@ -24,6 +30,8 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
loading = false,
|
||||
frame = "mobile",
|
||||
bg = "default",
|
||||
embedded = false,
|
||||
style,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const setRealHeight = () => {
|
||||
@@ -53,11 +61,12 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.container} ${frame === "mobile" ? "ckb-frame" : ""} ${bgClassName}`}
|
||||
className={`${styles.container} ${embedded ? styles.embedded : ""} ${frame === "mobile" ? "ckb-frame" : ""} ${bgClassName}`}
|
||||
style={style}
|
||||
>
|
||||
{header && <header className={styles.header}>{header}</header>}
|
||||
<main
|
||||
className={`${styles.main} ${footer ? styles.mainWithTabbar : ""}`}
|
||||
className={`${styles.main} ${footer && !embedded ? styles.mainWithTabbar : ""} ${footer && embedded ? styles.mainWithInlineFooter : ""}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
@@ -68,7 +77,12 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
children
|
||||
)}
|
||||
</main>
|
||||
{footer && <TabBarPortal>{footer}</TabBarPortal>}
|
||||
{footer &&
|
||||
(embedded ? (
|
||||
<footer className={styles.inlineFooter}>{footer}</footer>
|
||||
) : (
|
||||
<TabBarPortal>{footer}</TabBarPortal>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,32 @@
|
||||
background: var(--ckb-bg-app, #f5f7fb);
|
||||
}
|
||||
|
||||
/** Popup/Drawer 内嵌:填满父容器,勿用 100dvh */
|
||||
.embedded {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inlineFooter {
|
||||
flex: 0 0 auto;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2px 10px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.mainWithInlineFooter {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.bgSoft {
|
||||
background: var(--ckb-bg-soft);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useAIAssistantStore } from "@/store/module/aiAssistant";
|
||||
interface TabItem {
|
||||
key: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
path?: string;
|
||||
tone?: string;
|
||||
isFab?: boolean;
|
||||
@@ -56,7 +56,7 @@ const tabs: TabItem[] = [
|
||||
];
|
||||
|
||||
interface MeauMobileProps {
|
||||
activeKey: string;
|
||||
activeKey?: string;
|
||||
}
|
||||
|
||||
const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ interface NavCommonProps {
|
||||
/** 标题使用品牌渐变文字(蓝→绿/蓝→紫);默认 false */
|
||||
gradient?: boolean | "brand" | "primary";
|
||||
/** 主色调(用于返回按钮 hover 等) */
|
||||
tone?: "blue" | "green" | "purple" | "pink";
|
||||
tone?: "blue" | "green" | "purple" | "pink" | "cyan";
|
||||
}
|
||||
|
||||
const NavCommon: React.FC<NavCommonProps> = ({
|
||||
@@ -50,7 +50,9 @@ const NavCommon: React.FC<NavCommonProps> = ({
|
||||
? "#8b5cf6"
|
||||
: tone === "pink"
|
||||
? "#ec4899"
|
||||
: "var(--ckb-primary, #1677ff)";
|
||||
: tone === "cyan"
|
||||
? "#06b6d4"
|
||||
: "var(--ckb-primary, #1677ff)";
|
||||
|
||||
const titleNode =
|
||||
typeof title === "string" ? (
|
||||
|
||||
@@ -78,15 +78,24 @@ export default function PoolSelection({
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedOptions.map(item => (
|
||||
{selectedOptions.map(item => {
|
||||
const displayName =
|
||||
item.name || item.groupName || item.nickname || "未命名";
|
||||
const subtitle =
|
||||
item.description ||
|
||||
(item.num != null ? `${item.num} 人` : "") ||
|
||||
item.wechatId ||
|
||||
item.mobile ||
|
||||
"";
|
||||
return (
|
||||
<div key={item.id} className={style.selectedListRow}>
|
||||
<div className={style.selectedListRowContent}>
|
||||
<div className={style.groupAvatar}>
|
||||
{(item.nickname || item.name || "").charAt(0)}
|
||||
{displayName.charAt(0)}
|
||||
</div>
|
||||
<div className={style.selectedListRowContentText}>
|
||||
<div>{item.nickname || item.name}</div>
|
||||
<div>{item.wechatId || item.mobile}</div>
|
||||
<div>{displayName}</div>
|
||||
<div>{subtitle}</div>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
@@ -109,7 +118,8 @@ export default function PoolSelection({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
|
||||
@@ -6,6 +6,11 @@ import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import {
|
||||
selectionPopupBodyStyle,
|
||||
selectionPopupLayoutStyle,
|
||||
selectionPopupStyle,
|
||||
} from "@/components/PopuLayout/popupShell";
|
||||
import { PoolSelectionItem, PoolPackageItem } from "./data";
|
||||
|
||||
// 弹窗属性接口
|
||||
@@ -52,7 +57,6 @@ export default function SelectionPopup({
|
||||
};
|
||||
|
||||
const response = await getPoolPackages(params);
|
||||
console.log("getPoolPackages response:", response);
|
||||
// request 函数会自动提取 data 字段,所以 response 应该是数组
|
||||
// 但为了兼容,也处理可能的包装格式
|
||||
let groupsList: any[] = [];
|
||||
@@ -63,11 +67,10 @@ export default function SelectionPopup({
|
||||
} else if (response?.list && Array.isArray(response.list)) {
|
||||
groupsList = response.list;
|
||||
}
|
||||
console.log("groupsList:", groupsList);
|
||||
|
||||
if (groupsList.length > 0) {
|
||||
// 适配 V2 API 返回格式
|
||||
const formattedList = groupsList.map((item: any) => ({
|
||||
let formattedList = groupsList.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.groupName || item.name || `分组${item.id}`,
|
||||
description: item.description || "",
|
||||
@@ -80,9 +83,20 @@ export default function SelectionPopup({
|
||||
isSystem: item.isSystem,
|
||||
ruleType: item.ruleType,
|
||||
}));
|
||||
setPoolPackages(formattedList);
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
if (kw) {
|
||||
formattedList = formattedList.filter(
|
||||
item =>
|
||||
item.name.toLowerCase().includes(kw) ||
|
||||
item.description.toLowerCase().includes(kw),
|
||||
);
|
||||
}
|
||||
const pageSize = 20;
|
||||
const start = (page - 1) * pageSize;
|
||||
const pageItems = formattedList.slice(start, start + pageSize);
|
||||
setPoolPackages(pageItems);
|
||||
setTotalItems(formattedList.length);
|
||||
setTotalPages(Math.ceil(formattedList.length / 20));
|
||||
setTotalPages(Math.max(1, Math.ceil(formattedList.length / pageSize)));
|
||||
} else {
|
||||
setPoolPackages([]);
|
||||
setTotalItems(0);
|
||||
@@ -209,9 +223,14 @@ export default function SelectionPopup({
|
||||
visible={visible}
|
||||
onMaskClick={() => onVisibleChange(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
style={selectionPopupStyle}
|
||||
bodyStyle={selectionPopupBodyStyle}
|
||||
closeOnMaskClick
|
||||
destroyOnClose
|
||||
>
|
||||
<Layout
|
||||
embedded
|
||||
style={selectionPopupLayoutStyle}
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择流量池包"
|
||||
@@ -220,6 +239,7 @@ export default function SelectionPopup({
|
||||
searchPlaceholder="搜索流量池包"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchPoolPackages(currentPage, searchQuery)}
|
||||
onClose={() => onVisibleChange(false)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
.footerShell {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
singleSelect = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={style.footerShell}>
|
||||
{/* 分页栏 */}
|
||||
<div className={style.paginationRow}>
|
||||
{onSelectAll && (
|
||||
@@ -81,7 +81,7 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
.popupHeader {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: #f5f5f5;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
|
||||
&:active {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
.popupTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { SearchOutlined, ReloadOutlined, CloseOutlined } from "@ant-design/icons";
|
||||
import { Input, Button } from "antd";
|
||||
import { Tabs } from "antd-mobile";
|
||||
import style from "./header.module.scss";
|
||||
@@ -15,6 +15,8 @@ interface PopupHeaderProps {
|
||||
showRefresh?: boolean;
|
||||
showSearch?: boolean;
|
||||
showTabs?: boolean;
|
||||
/** 右上角关闭 */
|
||||
onClose?: () => void;
|
||||
tabsConfig?: {
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
@@ -33,11 +35,22 @@ const PopupHeader: React.FC<PopupHeaderProps> = ({
|
||||
showRefresh = true,
|
||||
showSearch = true,
|
||||
showTabs = false,
|
||||
onClose,
|
||||
tabsConfig,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={style.popupHeader}>
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className={style.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
<div className={style.popupTitle}>{title}</div>
|
||||
</div>
|
||||
|
||||
|
||||
36
Cunkebao/src/components/PopuLayout/popupShell.ts
Normal file
36
Cunkebao/src/components/PopuLayout/popupShell.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
/**
|
||||
* 选择类弹窗(设备/好友/群/流量池/账号/内容)统一外壳样式。
|
||||
*
|
||||
* 关键修复(2026-05-30 §二「选择设备无法关闭」):
|
||||
* - 页面底部 TabBar 经 TabBarPortal 渲染在 z-index 10000;
|
||||
* antd-mobile Popup 默认 z-index 仅 1000,会被 TabBar 盖住底部,
|
||||
* 导致「取消 / 确定 / 遮罩」点击被 TabBar 拦截,弹窗看似无法关闭。
|
||||
* - 这里把 Popup 的 z-index 抬到 10050(> 10000),并配合
|
||||
* Layout embedded(footer 内联、不再 Portal)彻底解决。
|
||||
*/
|
||||
export const selectionPopupStyle: CSSProperties = {
|
||||
// antd-mobile 通过 CSS 变量 --z-index 控制层级
|
||||
["--z-index" as keyof CSSProperties]: "10050",
|
||||
zIndex: 10050,
|
||||
};
|
||||
|
||||
export const selectionPopupBodyStyle: CSSProperties = {
|
||||
height: "min(92vh, calc(100dvh - env(safe-area-inset-top, 0px)))",
|
||||
maxHeight: "min(92vh, calc(100dvh - env(safe-area-inset-top, 0px)))",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
||||
};
|
||||
|
||||
/** Popup 内 Layout 须占满 body,否则 embedded footer(取消/确定)会被裁切 */
|
||||
export const selectionPopupLayoutStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
};
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
LoadingOutlined,
|
||||
FileOutlined,
|
||||
FileImageOutlined,
|
||||
FileVideoOutlined,
|
||||
FileAudioOutlined,
|
||||
VideoCameraOutlined,
|
||||
AudioOutlined,
|
||||
FilePdfOutlined,
|
||||
FileWordOutlined,
|
||||
FileExcelOutlined,
|
||||
@@ -50,9 +50,9 @@ const ChatFileUpload: React.FC<ChatFileUploadProps> = ({
|
||||
if (type.startsWith("image/")) {
|
||||
return <FileImageOutlined />;
|
||||
} else if (type.startsWith("video/")) {
|
||||
return <FileVideoOutlined />;
|
||||
return <VideoCameraOutlined />;
|
||||
} else if (type.startsWith("audio/")) {
|
||||
return <FileAudioOutlined />;
|
||||
return <AudioOutlined />;
|
||||
} else if (type === "application/pdf") {
|
||||
return <FilePdfOutlined />;
|
||||
} else if (name.endsWith(".doc") || name.endsWith(".docx")) {
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Card, List, Badge, Toast } from "antd-mobile";
|
||||
import {
|
||||
useWebSocketStore,
|
||||
WebSocketStatus,
|
||||
WebSocketMessage,
|
||||
} from "@/store/module/websocket/websocket";
|
||||
|
||||
/**
|
||||
* WebSocket使用示例组件
|
||||
* 展示如何使用WebSocket store进行消息收发
|
||||
*/
|
||||
const WebSocketExample: React.FC = () => {
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
|
||||
// 使用WebSocket store
|
||||
const {
|
||||
status,
|
||||
messages,
|
||||
unreadCount,
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
sendCommand,
|
||||
clearMessages,
|
||||
markAsRead,
|
||||
reconnect,
|
||||
} = useWebSocketStore();
|
||||
|
||||
// 连接状态显示
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case WebSocketStatus.DISCONNECTED:
|
||||
return "未连接";
|
||||
case WebSocketStatus.CONNECTING:
|
||||
return "连接中...";
|
||||
case WebSocketStatus.CONNECTED:
|
||||
return "已连接";
|
||||
case WebSocketStatus.RECONNECTING:
|
||||
return "重连中...";
|
||||
case WebSocketStatus.ERROR:
|
||||
return "连接错误";
|
||||
default:
|
||||
return "未知状态";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case WebSocketStatus.CONNECTED:
|
||||
return "success";
|
||||
case WebSocketStatus.CONNECTING:
|
||||
case WebSocketStatus.RECONNECTING:
|
||||
return "warning";
|
||||
case WebSocketStatus.ERROR:
|
||||
return "danger";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = () => {
|
||||
if (!messageInput.trim()) {
|
||||
Toast.show({ content: "请输入消息内容", position: "top" });
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
type: "chat",
|
||||
content: {
|
||||
text: messageInput,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
sender: "user",
|
||||
receiver: "all",
|
||||
});
|
||||
|
||||
setMessageInput("");
|
||||
};
|
||||
|
||||
// 发送命令
|
||||
const handleSendCommand = (cmdType: string) => {
|
||||
sendCommand(cmdType, {
|
||||
data: "示例数据",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "16px" }}>
|
||||
<Card title="WebSocket 连接状态">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<Badge color={getStatusColor()}>
|
||||
<div style={{ width: "8px", height: "8px", borderRadius: "50%" }} />
|
||||
</Badge>
|
||||
<span>{getStatusText()}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "16px",
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
connect({
|
||||
url: "wss://kf.quwanzhi.com:9993", // 显式指定WebSocket URL,确保使用正确的服务器地址
|
||||
client: "kefu-client",
|
||||
autoReconnect: true,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
status === WebSocketStatus.CONNECTING ||
|
||||
status === WebSocketStatus.CONNECTED
|
||||
}
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
onClick={disconnect}
|
||||
disabled={status === WebSocketStatus.DISCONNECTED}
|
||||
>
|
||||
断开
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
color="warning"
|
||||
onClick={reconnect}
|
||||
disabled={status === WebSocketStatus.CONNECTED}
|
||||
>
|
||||
重连
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={`消息列表 ${unreadCount > 0 ? `(${unreadCount} 条未读)` : ""}`}
|
||||
extra={
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<Button size="small" onClick={markAsRead}>
|
||||
标记已读
|
||||
</Button>
|
||||
<Button size="small" onClick={clearMessages}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
style={{ marginTop: "16px" }}
|
||||
>
|
||||
<List style={{ maxHeight: "300px", overflowY: "auto" }}>
|
||||
{messages.length === 0 ? (
|
||||
<List.Item>暂无消息</List.Item>
|
||||
) : (
|
||||
messages.map((message: WebSocketMessage) => (
|
||||
<List.Item key={message.id}>
|
||||
<div style={{ fontSize: "12px", color: "#666" }}>
|
||||
{formatTime(message.timestamp)} - {message.type}
|
||||
</div>
|
||||
<div style={{ marginTop: "4px" }}>
|
||||
{typeof message.content === "string"
|
||||
? message.content
|
||||
: JSON.stringify(message.content, null, 2)}
|
||||
</div>
|
||||
</List.Item>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
<Card title="发送消息" style={{ marginTop: "16px" }}>
|
||||
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={messageInput}
|
||||
onChange={e => setMessageInput(e.target.value)}
|
||||
placeholder="输入消息内容"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "8px 12px",
|
||||
border: "1px solid #d9d9d9",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
onKeyPress={e => e.key === "Enter" && handleSendMessage()}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleSendMessage}
|
||||
disabled={status !== WebSocketStatus.CONNECTED}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleSendCommand("CmdHeartbeat")}
|
||||
disabled={status !== WebSocketStatus.CONNECTED}
|
||||
>
|
||||
心跳
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleSendCommand("CmdGetStatus")}
|
||||
disabled={status !== WebSocketStatus.CONNECTED}
|
||||
>
|
||||
获取状态
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleSendCommand("CmdSignIn")}
|
||||
disabled={status !== WebSocketStatus.CONNECTED}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="使用说明" style={{ marginTop: "16px" }}>
|
||||
<div style={{ fontSize: "14px", lineHeight: "1.6", color: "#666" }}>
|
||||
<p>1. 点击"连接"按钮建立WebSocket连接</p>
|
||||
<p>2. 连接成功后可以发送消息和命令</p>
|
||||
<p>3. 收到的消息会显示在消息列表中</p>
|
||||
<p>4. 页面刷新后会自动重连(如果之前是连接状态)</p>
|
||||
<p>5. 支持自动重连和错误处理</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebSocketExample;
|
||||
@@ -0,0 +1,109 @@
|
||||
.wrap {
|
||||
padding: 6px 12px 8px;
|
||||
}
|
||||
|
||||
/* ---------- 紧凑条(默认) ---------- */
|
||||
.compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.searchEntry {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: var(--ckb-radius-lg, 12px);
|
||||
background: var(--ckb-bg-muted, rgba(255, 255, 255, 0.6));
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--ckb-text-tertiary, #94a3b8);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--ckb-text-secondary, #475569);
|
||||
max-width: 70%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chipArrow {
|
||||
font-size: 9px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ---------- 展开态 ---------- */
|
||||
.expanded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
animation: ckbSlideDown 240ms ease;
|
||||
}
|
||||
|
||||
@keyframes ckbSlideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.refreshBtn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 3px;
|
||||
border-radius: var(--ckb-radius-lg, 12px);
|
||||
background: var(--ckb-bg-muted, rgba(148, 163, 184, 0.12));
|
||||
}
|
||||
|
||||
.segItem {
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--ckb-text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
|
||||
.segItemActive {
|
||||
background: #fff;
|
||||
color: var(--ckb-brand, #6366f1);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
153
Cunkebao/src/components/ckb/CkbCollapsibleSearchHeader.tsx
Normal file
153
Cunkebao/src/components/ckb/CkbCollapsibleSearchHeader.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { SearchOutlined, ReloadOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { Input, Button } from "antd";
|
||||
import styles from "./CkbCollapsibleSearchHeader.module.scss";
|
||||
|
||||
export interface CkbStatusTab {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CkbCollapsibleSearchHeaderProps {
|
||||
/** 搜索关键词 */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** 当前状态 Tab */
|
||||
statusTab: string;
|
||||
statusTabs: CkbStatusTab[];
|
||||
onStatusChange: (key: string) => void;
|
||||
/** 刷新 */
|
||||
onRefresh?: () => void;
|
||||
refreshing?: boolean;
|
||||
/** 监听滚动收起的容器(可选,传入则下滑 >40px 自动收起为紧凑条) */
|
||||
scrollRef?: React.RefObject<HTMLElement>;
|
||||
/** 默认是否展开 */
|
||||
defaultExpanded?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态搜索头(§一 1.3)
|
||||
* - 默认:顶栏下仅一行紧凑条(🔍 + 当前筛选芯片 + 刷新)
|
||||
* - 点 🔍 / 芯片 → 滑出展开(搜索框 + 状态 Segmented)
|
||||
* - 失焦/选完状态可自动收起回紧凑条
|
||||
*/
|
||||
export const CkbCollapsibleSearchHeader: React.FC<
|
||||
CkbCollapsibleSearchHeaderProps
|
||||
> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "搜索",
|
||||
statusTab,
|
||||
statusTabs,
|
||||
onStatusChange,
|
||||
onRefresh,
|
||||
refreshing = false,
|
||||
scrollRef,
|
||||
defaultExpanded = false,
|
||||
className = "",
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const inputRef = useRef<any>(null);
|
||||
|
||||
const activeTab = statusTabs.find(t => t.key === statusTab);
|
||||
const chipLabel = activeTab ? activeTab.label : "全部";
|
||||
const hasKeyword = value.trim() !== "";
|
||||
|
||||
// 展开后自动聚焦
|
||||
useEffect(() => {
|
||||
if (expanded) {
|
||||
const t = setTimeout(() => inputRef.current?.focus?.(), 120);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
// 列表下滑 >40px 自动收起(仅在无关键词时)
|
||||
useEffect(() => {
|
||||
const el = scrollRef?.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
if (el.scrollTop > 40 && !hasKeyword) {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [scrollRef, hasKeyword]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.wrap} ${className}`}>
|
||||
{/* 紧凑条 */}
|
||||
{!expanded && (
|
||||
<div className={styles.compact}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchEntry}
|
||||
onClick={() => setExpanded(true)}
|
||||
aria-label="展开搜索"
|
||||
>
|
||||
<SearchOutlined className={styles.searchIcon} />
|
||||
<span className={styles.chip}>
|
||||
{hasKeyword ? value.trim() : chipLabel}
|
||||
<DownOutlined className={styles.chipArrow} />
|
||||
</span>
|
||||
</button>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshing}
|
||||
onClick={onRefresh}
|
||||
className={styles.refreshBtn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开态 */}
|
||||
{expanded && (
|
||||
<div className={styles.expanded}>
|
||||
<div className={styles.searchRow}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onBlur={() => {
|
||||
if (!hasKeyword) setExpanded(false);
|
||||
}}
|
||||
/>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshing}
|
||||
onClick={onRefresh}
|
||||
className={styles.refreshBtn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.segmented}>
|
||||
{statusTabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={`${styles.segItem} ${
|
||||
statusTab === tab.key ? styles.segItemActive : ""
|
||||
}`}
|
||||
onClick={() => onStatusChange(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CkbCollapsibleSearchHeader;
|
||||
@@ -5,6 +5,7 @@ export interface CkbListCardProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** 列表卡片:白底圆角 + 轻阴影,对齐 Next devices 卡片 */
|
||||
@@ -12,9 +13,11 @@ export const CkbListCard: React.FC<CkbListCardProps> = ({
|
||||
children,
|
||||
onClick,
|
||||
className = "",
|
||||
style,
|
||||
}) => (
|
||||
<div
|
||||
className={`${styles.card} ${onClick ? styles.clickable : ""} ${className}`}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import styles from "./CkbSectionCard.module.scss";
|
||||
|
||||
export interface CkbSectionCardProps {
|
||||
title: string;
|
||||
title?: string;
|
||||
badge?: React.ReactNode;
|
||||
linkText?: React.ReactNode;
|
||||
onLinkClick?: () => void;
|
||||
@@ -22,15 +22,17 @@ export const CkbSectionCard: React.FC<CkbSectionCardProps> = ({
|
||||
summary,
|
||||
}) => (
|
||||
<section className={`${styles.section} ${className}`}>
|
||||
<div className={styles.head}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
{badge != null && <span className={styles.badge}>{badge}</span>}
|
||||
{linkText != null && (
|
||||
<button type="button" className={styles.link} onClick={onLinkClick}>
|
||||
{linkText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(title != null || badge != null || linkText != null) && (
|
||||
<div className={styles.head}>
|
||||
{title != null && <h2 className={styles.title}>{title}</h2>}
|
||||
{badge != null && <span className={styles.badge}>{badge}</span>}
|
||||
{linkText != null && (
|
||||
<button type="button" className={styles.link} onClick={onLinkClick}>
|
||||
{linkText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{summary && <div className={styles.summary}>{summary}</div>}
|
||||
{children}
|
||||
</section>
|
||||
|
||||
@@ -10,6 +10,11 @@ export { CkbEmptyState } from "./CkbEmptyState";
|
||||
export type { CkbEmptyStateProps } from "./CkbEmptyState";
|
||||
export { CkbSearchToolbar } from "./CkbSearchToolbar";
|
||||
export type { CkbSearchToolbarProps } from "./CkbSearchToolbar";
|
||||
export { CkbCollapsibleSearchHeader } from "./CkbCollapsibleSearchHeader";
|
||||
export type {
|
||||
CkbCollapsibleSearchHeaderProps,
|
||||
CkbStatusTab,
|
||||
} from "./CkbCollapsibleSearchHeader";
|
||||
export { CkbMenuCard, CkbMenuCardList } from "./CkbMenuCard";
|
||||
export type { CkbMenuCardProps, CkbMenuCardListProps } from "./CkbMenuCard";
|
||||
export { CkbDeviceCard } from "./CkbDeviceCard";
|
||||
|
||||
@@ -7,7 +7,14 @@ import {
|
||||
import { CkbListCard } from "@/components/ckb";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
|
||||
import { loginWithPassword, loginWithCode, sendVerificationCode } from "./api";
|
||||
import {
|
||||
loginWithPassword,
|
||||
loginWithCode,
|
||||
sendVerificationCode,
|
||||
fetchUserInfoByToken,
|
||||
getOAuthConfig,
|
||||
buildOAuthRedirectUrl,
|
||||
} from "./api";
|
||||
import { CkbLogo } from "@/components/icons/brandLogo";
|
||||
import style from "./login.module.scss";
|
||||
|
||||
@@ -19,6 +26,8 @@ const Login: React.FC = () => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
// 协议默认同意(卡若 2026-05-27 要求:不要因协议未勾选阻塞登录)
|
||||
const [agreeToTerms, setAgreeToTerms] = useState(true);
|
||||
// 已启用的第三方登录提供商(§五)
|
||||
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
|
||||
const { login, login2 } = useUserStore();
|
||||
|
||||
// 开发环境自动登录(仅在开发模式下生效)
|
||||
@@ -43,6 +52,43 @@ const Login: React.FC = () => {
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
// 手机号预填(§五 4.2):优先上次登录手机号,DEV 可用 VITE_DEFAULT_LOGIN_PHONE 兜底
|
||||
if (!form.getFieldValue("account")) {
|
||||
const lastPhone = localStorage.getItem("ckb_last_login_phone") || "";
|
||||
const devPhone = isDev
|
||||
? (import.meta.env.VITE_DEFAULT_LOGIN_PHONE as string | undefined) || ""
|
||||
: "";
|
||||
const prefill = lastPhone || devPhone;
|
||||
if (prefill) {
|
||||
form.setFieldsValue({ account: prefill });
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// OAuth 回跳处理(§五):BFF 回调带 token/oauthError 回到登录页
|
||||
useEffect(() => {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const token = search.get("token");
|
||||
const oauthError = search.get("oauthError");
|
||||
if (oauthError) {
|
||||
Toast.show({ content: decodeURIComponent(oauthError), position: "top" });
|
||||
return;
|
||||
}
|
||||
if (token) {
|
||||
// OAuth 登录成功:用 token 拉取用户信息后入库
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const info = await fetchUserInfoByToken(token);
|
||||
login(token, info, info?.deviceTotal ?? 0, info?.phone);
|
||||
} catch (e) {
|
||||
Toast.show({ content: "登录态获取失败,请重新登录", position: "top" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 开发环境自动登录处理
|
||||
@@ -59,7 +105,6 @@ const Login: React.FC = () => {
|
||||
login(res.token, member, deviceTotal);
|
||||
} catch (error) {
|
||||
// 自动登录失败,用户可手动登录
|
||||
console.log("[v0] 开发环境自动登录失败,请手动登录");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -119,6 +164,10 @@ const Login: React.FC = () => {
|
||||
|
||||
response.then(res => {
|
||||
const { member, deviceTotal } = res;
|
||||
// 记住上次登录手机号(§五 4.2 P1)
|
||||
if (values.account) {
|
||||
localStorage.setItem("ckb_last_login_phone", values.account);
|
||||
}
|
||||
login(res.token, member, deviceTotal ?? 0, values.account);
|
||||
});
|
||||
};
|
||||
@@ -132,18 +181,29 @@ const Login: React.FC = () => {
|
||||
getToken(values);
|
||||
};
|
||||
|
||||
// 第三方登录处理
|
||||
const handleWechatLogin = () => {
|
||||
Toast.show({ content: "微信登录正在对接中,请使用密码登录", position: "top" });
|
||||
// 拉取已启用的 OAuth 提供商(不含 secret,§五 3.1)
|
||||
useEffect(() => {
|
||||
getOAuthConfig()
|
||||
.then((cfg: any) => {
|
||||
const list = cfg?.enabledProviders || cfg?.providers || [];
|
||||
setEnabledProviders(Array.isArray(list) ? list : []);
|
||||
})
|
||||
.catch(() => setEnabledProviders([]));
|
||||
}, []);
|
||||
|
||||
// 第三方登录处理:跳转存客宝 OAuth BFF(§五 3.2)
|
||||
const startOAuth = (provider: string, enabledKey: string, label: string) => {
|
||||
if (!enabledProviders.includes(enabledKey)) {
|
||||
Toast.show({ content: `${label}登录暂未开放,请使用手机号登录`, position: "top" });
|
||||
return;
|
||||
}
|
||||
const returnUrl = `${window.location.origin}/login`;
|
||||
window.location.href = buildOAuthRedirectUrl(provider, returnUrl);
|
||||
};
|
||||
|
||||
const handleAlipayLogin = () => {
|
||||
Toast.show({ content: "支付宝登录正在对接中,请使用密码登录", position: "top" });
|
||||
};
|
||||
|
||||
const handleAppleLogin = () => {
|
||||
Toast.show({ content: "Apple 登录正在对接中,请使用密码登录", position: "top" });
|
||||
};
|
||||
const handleWechatLogin = () => startOAuth("wechat_mp", "wechat", "微信");
|
||||
const handleAlipayLogin = () => startOAuth("alipay", "alipay", "支付宝");
|
||||
const handleAppleLogin = () => startOAuth("apple", "apple", "Apple");
|
||||
const paddingTop = localStorage.getItem("paddingTop") || "44px";
|
||||
return (
|
||||
<div className={style["login-page"]}>
|
||||
@@ -218,6 +278,9 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder="请输入手机号"
|
||||
clearable
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel"
|
||||
className={style["phone-input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,30 @@ export function getUserInfo() {
|
||||
return request("/v1/auth/info", {}, "GET");
|
||||
}
|
||||
|
||||
// OAuth 登录成功后,用 BFF 回跳带回的 token 拉取用户信息(§五)
|
||||
export function fetchUserInfoByToken(token: string) {
|
||||
return request(
|
||||
"/v1/auth/info",
|
||||
{},
|
||||
"GET",
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// OAuth 可用提供商配置(不含 secret,§五 3.1)
|
||||
export function getOAuthConfig() {
|
||||
return request("/v1/auth/oauth/config", {}, "GET", undefined, 0);
|
||||
}
|
||||
|
||||
// 拼接 OAuth 跳转地址:window.location 直接跳转,由后端 302 到微信/支付宝
|
||||
export function buildOAuthRedirectUrl(provider: string, returnUrl: string) {
|
||||
const base =
|
||||
(import.meta as any).env?.VITE_API_BASE_URL || "/api";
|
||||
const u = encodeURIComponent(returnUrl);
|
||||
return `${base}/v1/auth/oauth/${provider}/redirect?returnUrl=${u}`;
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 触客宝接口; 2025年8月16日 17:19:15
|
||||
// 开发:yongpxu
|
||||
|
||||
@@ -44,3 +44,13 @@ export function getSevenDayStats() {
|
||||
export function getUserInfoStats() {
|
||||
return request("/v1/dashboard/userInfoStats", {}, "GET");
|
||||
}
|
||||
|
||||
// 今日获客明细(全场景当日新增客资,与首页 todayLeads 同口径)
|
||||
export function getTodayLeads(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
date?: string;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/dashboard/today-leads", params, "GET");
|
||||
}
|
||||
|
||||
@@ -193,12 +193,18 @@ const Home: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 今日获客简报 */}
|
||||
{/* 今日获客简报(可点击进入明细) */}
|
||||
<section className={style["today-section"]}>
|
||||
<CkbListCard className={style["today-card"]}>
|
||||
<CkbListCard
|
||||
className={style["today-card"]}
|
||||
onClick={() => navigate("/home/today-leads")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className={style["today-header"]}>
|
||||
<span className={style["today-title"]}>今日获客</span>
|
||||
<span className={style["today-value"]}>{overview.todayLeads ?? 0} 条</span>
|
||||
<span className={style["today-value"]}>
|
||||
{overview.todayLeads ?? 0} 条 <RightOutlined style={{ fontSize: 12, opacity: 0.5 }} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["today-sub"]}>
|
||||
累计 {formatCount(totalLeads)} · {runningPlans} 计划运行中
|
||||
|
||||
28
Cunkebao/src/pages/mobile/home/today-leads/index.module.scss
Normal file
28
Cunkebao/src/pages/mobile/home/today-leads/index.module.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
.page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.subBar {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--ckb-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.listWrap {
|
||||
padding: 0 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.source {
|
||||
padding: 0 8px 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ckb-text-tertiary, #94a3b8);
|
||||
}
|
||||
122
Cunkebao/src/pages/mobile/home/today-leads/index.tsx
Normal file
122
Cunkebao/src/pages/mobile/home/today-leads/index.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { InfiniteScroll, DotLoading, Button } from "antd-mobile";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { CkbPageShell, CkbEmptyState } from "@/components/ckb";
|
||||
import LeadCustomerCard from "@/pages/mobile/scenarios/plan/list/components/LeadCustomerCard";
|
||||
import type { CustomerListItem } from "@/pages/mobile/scenarios/plan/list/components/customerExport";
|
||||
import { SCENE_CATALOG } from "@/pages/mobile/scenarios/sceneCatalog";
|
||||
import { getTodayLeads } from "../api";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
interface TodayLead extends CustomerListItem {
|
||||
planId?: number;
|
||||
planName?: string;
|
||||
sceneId?: number;
|
||||
}
|
||||
|
||||
const sceneNameMap: Record<number, string> = SCENE_CATALOG.reduce(
|
||||
(acc, s) => {
|
||||
acc[s.id] = s.name;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
|
||||
function sourceLabel(item: TodayLead): string {
|
||||
const scene = item.sceneId ? sceneNameMap[item.sceneId] : "";
|
||||
const plan = item.planName || "";
|
||||
if (scene && plan) return `${scene} · ${plan}`;
|
||||
return plan || scene || "场景获客";
|
||||
}
|
||||
|
||||
const TodayLeadsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
|
||||
const [list, setList] = useState<TodayLead[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const pageRef = useRef(1);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
try {
|
||||
const res: any = await getTodayLeads({
|
||||
page: pageRef.current,
|
||||
pageSize: PAGE_SIZE,
|
||||
date: dateStr,
|
||||
});
|
||||
const rows: TodayLead[] = Array.isArray(res?.list) ? res.list : [];
|
||||
setTotal(Number(res?.total) || 0);
|
||||
setList(prev => (pageRef.current === 1 ? rows : [...prev, ...rows]));
|
||||
const loaded = (pageRef.current - 1) * PAGE_SIZE + rows.length;
|
||||
const more = rows.length >= PAGE_SIZE && loaded < (Number(res?.total) || 0);
|
||||
setHasMore(more);
|
||||
pageRef.current += 1;
|
||||
} catch (err) {
|
||||
console.warn("[today-leads] load failed", err);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}, [dateStr]);
|
||||
|
||||
useEffect(() => {
|
||||
pageRef.current = 1;
|
||||
setList([]);
|
||||
setHasMore(true);
|
||||
void loadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title="今日获客" glass gradient="brand" />}
|
||||
>
|
||||
<CkbPageShell className={style.page} padded={false}>
|
||||
<div className={style.subBar}>
|
||||
{dateStr} · 共 {total} 条
|
||||
</div>
|
||||
|
||||
{list.length === 0 && !hasMore ? (
|
||||
<CkbEmptyState
|
||||
title="今日暂无新增获客"
|
||||
description="去场景获客看看,开启更多获客计划"
|
||||
action={
|
||||
<Button color="primary" fill="outline" onClick={() => navigate("/scenarios")}>
|
||||
去场景获客
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className={style.listWrap}>
|
||||
{list.map(item => (
|
||||
<div key={`${item.id}`} className={style.row}>
|
||||
<LeadCustomerCard customer={item} />
|
||||
<div className={style.source}>来源:{sourceLabel(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
<InfiniteScroll loadMore={loadMore} hasMore={hasMore}>
|
||||
{hasMore ? (
|
||||
<span>
|
||||
加载中 <DotLoading />
|
||||
</span>
|
||||
) : (
|
||||
<span>没有更多了</span>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
)}
|
||||
</CkbPageShell>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayLeadsPage;
|
||||
@@ -317,22 +317,18 @@ const ContentLibraryList: React.FC = () => {
|
||||
{library.status === 1 ? "已启用" : "未启用"}
|
||||
</CkbBadge>
|
||||
{library.aiEnabled === 1 && (
|
||||
<Tag
|
||||
color="primary"
|
||||
className={style["feature-tag"]}
|
||||
title="分发到不同微信号每次都会重新 AI 改写一次"
|
||||
>
|
||||
AI改写
|
||||
</Tag>
|
||||
<span title="分发到不同微信号每次都会重新 AI 改写一次">
|
||||
<Tag color="primary" className={style["feature-tag"]}>
|
||||
AI改写
|
||||
</Tag>
|
||||
</span>
|
||||
)}
|
||||
{library.externalApiEnabled === 1 && (
|
||||
<Tag
|
||||
color="warning"
|
||||
className={style["feature-tag"]}
|
||||
title="已开启外部接口对接(推入/拉取)"
|
||||
>
|
||||
外部接口
|
||||
</Tag>
|
||||
<span title="已开启外部接口对接(推入/拉取)">
|
||||
<Tag color="warning" className={style["feature-tag"]}>
|
||||
外部接口
|
||||
</Tag>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -309,7 +309,9 @@ const MaterialForm: React.FC = () => {
|
||||
<label className={style["form-label"]}>视频上传</label>
|
||||
<VideoUpload
|
||||
value={resUrls[0] || ""}
|
||||
onChange={url => setResUrls([url])}
|
||||
onChange={url =>
|
||||
setResUrls([Array.isArray(url) ? url[0] || "" : url])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -148,10 +148,6 @@ const MaterialsList: React.FC = () => {
|
||||
});
|
||||
|
||||
setAiResult(response.contentAfter || "暂无改写结果");
|
||||
|
||||
// 可以在这里显示原内容和改写后内容的对比
|
||||
console.log("原内容:", response.contentFront);
|
||||
console.log("改写后内容:", response.contentAfter);
|
||||
} catch (error) {
|
||||
console.error("AI改写失败:", error);
|
||||
Toast.show({
|
||||
@@ -713,7 +709,7 @@ const MaterialsList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className={style["import-actions"]}>
|
||||
<Button
|
||||
<MobileButton
|
||||
block
|
||||
color="primary"
|
||||
onClick={handleImport}
|
||||
@@ -721,8 +717,8 @@ const MaterialsList: React.FC = () => {
|
||||
disabled={importLoading || !importFileUrl}
|
||||
>
|
||||
{importLoading ? "导入中..." : "确认导入"}
|
||||
</Button>
|
||||
<Button
|
||||
</MobileButton>
|
||||
<MobileButton
|
||||
block
|
||||
color="danger"
|
||||
fill="outline"
|
||||
@@ -730,7 +726,7 @@ const MaterialsList: React.FC = () => {
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</MobileButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Tabs, Switch, Toast, SpinLoading, Button, Popup, Input } from "antd-mobile";
|
||||
import { Tabs, Switch, SpinLoading, Button, Popup, Input } from "antd-mobile";
|
||||
import {
|
||||
RedoOutlined,
|
||||
WifiOutlined,
|
||||
@@ -48,12 +48,6 @@ const DeviceDetail: React.FC = () => {
|
||||
const [editNameValue, setEditNameValue] = useState("");
|
||||
const [editNameLoading, setEditNameLoading] = useState(false);
|
||||
|
||||
const resolveErrorMessage = (e: unknown, fallback: string) => {
|
||||
if (typeof e === "string" && e.trim()) return e;
|
||||
if (e instanceof Error && e.message) return e.message;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const applyFeatures = (features?: Device["features"]) => {
|
||||
if (!features) return;
|
||||
setDevice(prev => (prev ? { ...prev, features } : prev));
|
||||
@@ -79,10 +73,7 @@ const DeviceDetail: React.FC = () => {
|
||||
await loadTaskConfig();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
Toast.show({
|
||||
content: resolveErrorMessage(e, "获取设备详情失败"),
|
||||
position: "top",
|
||||
});
|
||||
console.warn("[device-detail] load failed:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -95,7 +86,7 @@ const DeviceDetail: React.FC = () => {
|
||||
const res = await fetchDeviceRelatedAccounts(id);
|
||||
setAccounts(Array.isArray(res.accounts) ? res.accounts : []);
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e.message || "获取关联账号失败", position: "top" });
|
||||
console.warn("[device-detail] accounts failed:", e);
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
@@ -108,7 +99,7 @@ const DeviceDetail: React.FC = () => {
|
||||
const res = await fetchDeviceHandleLogs(id, 1, 20);
|
||||
setLogs(Array.isArray(res.list) ? res.list : []);
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e.message || "获取操作日志失败", position: "top" });
|
||||
console.warn("[device-detail] logs failed:", e);
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
@@ -140,14 +131,8 @@ const DeviceDetail: React.FC = () => {
|
||||
[feature]: checked,
|
||||
},
|
||||
);
|
||||
Toast.show({
|
||||
content: `${getFeatureName(feature)}已${checked ? "开启" : "关闭"}`,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
Toast.show({
|
||||
content: resolveErrorMessage(e, "设置失败"),
|
||||
position: "top",
|
||||
});
|
||||
console.warn("[device-detail] feature save failed:", e);
|
||||
} finally {
|
||||
setFeatureSaving(prev => ({ ...prev, [feature]: false }));
|
||||
}
|
||||
@@ -169,7 +154,7 @@ const DeviceDetail: React.FC = () => {
|
||||
setKefuOptions(res.list || []);
|
||||
setAssignVisible(true);
|
||||
} catch {
|
||||
Toast.show({ content: "加载客服列表失败", position: "top" });
|
||||
console.warn("[device-detail] kefu list failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,11 +163,10 @@ const DeviceDetail: React.FC = () => {
|
||||
setAssignLoading(true);
|
||||
try {
|
||||
await assignDeviceToKefu({ deviceId: id, accountId });
|
||||
Toast.show({ content: "分配成功", position: "top" });
|
||||
setAssignVisible(false);
|
||||
loadDetail();
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e?.msg || e?.message || "分配失败", position: "top" });
|
||||
console.warn("[device-detail] assign failed:", e);
|
||||
} finally {
|
||||
setAssignLoading(false);
|
||||
}
|
||||
@@ -194,18 +178,14 @@ const DeviceDetail: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!id || !editNameValue.trim()) {
|
||||
Toast.show({ content: "请输入设备名称", position: "top" });
|
||||
return;
|
||||
}
|
||||
if (!id || !editNameValue.trim()) return;
|
||||
setEditNameLoading(true);
|
||||
try {
|
||||
await updateDeviceMemo(id, editNameValue.trim());
|
||||
Toast.show({ content: "设备名称已更新", position: "top" });
|
||||
setEditNameVisible(false);
|
||||
loadDetail();
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e?.msg || e?.message || "更新失败", position: "top" });
|
||||
console.warn("[device-detail] rename failed:", e);
|
||||
} finally {
|
||||
setEditNameLoading(false);
|
||||
}
|
||||
|
||||
@@ -53,17 +53,23 @@ export const deleteDevice = (id: number) =>
|
||||
request(`/v1/devices/${id}`, undefined, "DELETE");
|
||||
|
||||
// 获取设备扫码绑定二维码(BFF 自动取当前用户 s2_accountId)
|
||||
export const fetchDeviceQRCode = (params?: { accountId?: string }) =>
|
||||
request("/v1/devices/qrcode", params || {}, "POST");
|
||||
export const fetchDeviceQRCode = (params?: { accountId?: string | number }) =>
|
||||
request("/v1/devices/qrcode", params || {}, "POST", {
|
||||
skipErrorToast: true,
|
||||
});
|
||||
|
||||
// 通过IMEI添加设备
|
||||
export const addDeviceByImei = (imei: string, name: string) =>
|
||||
request("/v1/api/device/add-by-imei", { imei, name }, "POST");
|
||||
|
||||
// 获取设备添加结果(用于轮询检查)
|
||||
export const fetchAddResults = (params: { accountId?: string }) =>
|
||||
request("/v1/devices/add-results", params, "GET");
|
||||
// 获取设备添加结果(用于轮询检查 · 静默,不弹 Toast)
|
||||
export const fetchAddResults = (params?: { accountId?: string | number }) =>
|
||||
request("/v1/devices/add-results", params || {}, "GET", {
|
||||
skipErrorToast: true,
|
||||
});
|
||||
|
||||
// 更新设备备注/名称
|
||||
export const updateDeviceMemo = (id: string | number, memo: string) =>
|
||||
request(`/v1/devices/${id}/memo`, { memo }, "POST");
|
||||
request(`/v1/devices/${id}/memo`, { memo }, "POST", {
|
||||
skipErrorToast: true,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Popup, Tabs, Toast, SpinLoading } from "antd-mobile";
|
||||
import { Popup, Tabs, SpinLoading } from "antd-mobile";
|
||||
import { Button, Input, Pagination } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AddOutline, DeleteOutline } from "antd-mobile-icons";
|
||||
@@ -83,7 +83,7 @@ const Devices: React.FC = () => {
|
||||
if (reset) setPage(1);
|
||||
else if (nextPage) setPage(nextPage);
|
||||
} catch {
|
||||
Toast.show({ content: "获取设备列表失败", position: "top" });
|
||||
console.warn("[devices] load list failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -106,9 +106,10 @@ const Devices: React.FC = () => {
|
||||
|
||||
const pollDeviceStatus = async () => {
|
||||
try {
|
||||
const res = await fetchAddResults({ accountId: user?.s2_accountId });
|
||||
const res = await fetchAddResults(
|
||||
user?.s2_accountId ? { accountId: user.s2_accountId } : {},
|
||||
);
|
||||
if (res.added) {
|
||||
Toast.show({ content: "设备添加成功!", position: "top" });
|
||||
setAddVisible(false);
|
||||
setIsPolling(false);
|
||||
if (pollingRef.current) {
|
||||
@@ -141,17 +142,14 @@ const Devices: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolveErrorMessage = (e: unknown, fallback: string) => {
|
||||
if (typeof e === "string" && e.trim()) return e;
|
||||
if (e instanceof Error && e.message) return e.message;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const handleGetQr = async () => {
|
||||
setQrLoading(true);
|
||||
setQrCode(null);
|
||||
try {
|
||||
const res = await fetchDeviceQRCode({ accountId: user?.s2_accountId });
|
||||
const res = await fetchDeviceQRCode(
|
||||
user?.s2_accountId ? { accountId: user.s2_accountId } : {},
|
||||
);
|
||||
if (!res?.qrCode) {
|
||||
throw new Error("二维码数据为空,请检查后端 device_bind 配置");
|
||||
}
|
||||
@@ -159,10 +157,7 @@ const Devices: React.FC = () => {
|
||||
startPolling();
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
Toast.show({
|
||||
content: resolveErrorMessage(e, "获取二维码失败"),
|
||||
position: "top",
|
||||
});
|
||||
console.warn("[devices] qrcode failed:", e);
|
||||
return false;
|
||||
} finally {
|
||||
setQrLoading(false);
|
||||
@@ -175,20 +170,16 @@ const Devices: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleAddDevice = async () => {
|
||||
if (!imei.trim() || !name.trim()) {
|
||||
Toast.show({ content: "请填写完整信息", position: "top" });
|
||||
return;
|
||||
}
|
||||
if (!imei.trim() || !name.trim()) return;
|
||||
setAddLoading(true);
|
||||
try {
|
||||
await addDeviceByImei(imei, name);
|
||||
Toast.show({ content: "添加成功", position: "top" });
|
||||
setAddVisible(false);
|
||||
setImei("");
|
||||
setName("");
|
||||
loadDevices(true);
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e.message || "添加失败", position: "top" });
|
||||
console.warn("[devices] add by imei failed:", e);
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
@@ -200,11 +191,10 @@ const Devices: React.FC = () => {
|
||||
for (const id of selected) {
|
||||
await deleteDevice(Number(id));
|
||||
}
|
||||
Toast.show({ content: "删除成功", position: "top" });
|
||||
setSelected([]);
|
||||
loadDevices(true);
|
||||
} catch (e: any) {
|
||||
if (e) Toast.show({ content: e.message || "删除失败", position: "top" });
|
||||
console.warn("[devices] delete failed:", e);
|
||||
} finally {
|
||||
setDelLoading(false);
|
||||
}
|
||||
|
||||
@@ -454,7 +454,13 @@ const BuyPowerPage: React.FC = () => {
|
||||
{paymentData.orderNo} <CopyOutlined />
|
||||
</span>
|
||||
</div>
|
||||
<Button block color="primary" onClick={() => queryOrder(paymentData.orderNo)}>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
queryOrder(paymentData.orderNo);
|
||||
}}
|
||||
>
|
||||
<CheckOutlined /> 我已完成支付
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -215,3 +215,21 @@ export interface AllocateTokensParams {
|
||||
export function allocateTokens(params: AllocateTokensParams): Promise<any> {
|
||||
return request("/v1/tokens/allocate", params, "POST");
|
||||
}
|
||||
|
||||
// 自动划拨策略(§六 V11)
|
||||
export interface AutoAllocatePolicy {
|
||||
enabled: boolean;
|
||||
touchkebaoRatio: number;
|
||||
aiStaffRatio: number;
|
||||
minReserve: number;
|
||||
}
|
||||
|
||||
export function getAutoAllocatePolicy(): Promise<{ policy: AutoAllocatePolicy }> {
|
||||
return request("/v1/tokens/auto-allocate-policy", undefined, "GET");
|
||||
}
|
||||
|
||||
export function saveAutoAllocatePolicy(
|
||||
policy: Partial<AutoAllocatePolicy>,
|
||||
): Promise<{ policy: AutoAllocatePolicy }> {
|
||||
return request("/v1/tokens/auto-allocate-policy", policy, "PUT");
|
||||
}
|
||||
|
||||
@@ -215,3 +215,17 @@
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.autoHint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.autoSwitchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
getStatistics,
|
||||
getAccountList,
|
||||
allocateTokens,
|
||||
getAutoAllocatePolicy,
|
||||
saveAutoAllocatePolicy,
|
||||
type AutoAllocatePolicy,
|
||||
} from "../index/api";
|
||||
import type { Account, Statistics } from "../index/api";
|
||||
import { getTokensUseRecord } from "../usage-records/api";
|
||||
@@ -27,7 +30,7 @@ import type { TokensUseRecordItem } from "../usage-records/api";
|
||||
import { formatPowerNum, formatDateTime, formLabelMap } from "../shared/utils";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
type ManageTab = "accounts" | "allocate" | "records" | "consume";
|
||||
type ManageTab = "accounts" | "allocate" | "auto" | "records" | "consume";
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "全部类型", value: "all" },
|
||||
@@ -58,18 +61,52 @@ const ManagePowerPage: React.FC = () => {
|
||||
const [recordHasMore, setRecordHasMore] = useState(true);
|
||||
const [filterType, setFilterType] = useState("all");
|
||||
const [typePickerVisible, setTypePickerVisible] = useState(false);
|
||||
const [autoPolicy, setAutoPolicy] = useState<AutoAllocatePolicy>({
|
||||
enabled: false,
|
||||
touchkebaoRatio: 50,
|
||||
aiStaffRatio: 50,
|
||||
minReserve: 0,
|
||||
});
|
||||
const [autoSaving, setAutoSaving] = useState(false);
|
||||
|
||||
const adminTabs = [
|
||||
{ key: "accounts" as const, label: "客服账号" },
|
||||
{ key: "allocate" as const, label: "算力分配" },
|
||||
{ key: "allocate" as const, label: "手动分配" },
|
||||
{ key: "auto" as const, label: "自动划拨" },
|
||||
{ key: "records" as const, label: "分配记录" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
if (isAdmin) loadAccounts();
|
||||
if (isAdmin) {
|
||||
loadAccounts();
|
||||
loadAutoPolicy();
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
const loadAutoPolicy = async () => {
|
||||
try {
|
||||
const res = await getAutoAllocatePolicy();
|
||||
if (res?.policy) setAutoPolicy(res.policy);
|
||||
} catch (e) {
|
||||
console.error("加载自动划拨策略失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAutoPolicy = async () => {
|
||||
setAutoSaving(true);
|
||||
try {
|
||||
const res = await saveAutoAllocatePolicy(autoPolicy);
|
||||
if (res?.policy) setAutoPolicy(res.policy);
|
||||
Toast.show({ content: "自动划拨策略已保存", position: "top" });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.show({ content: "保存失败", position: "top" });
|
||||
} finally {
|
||||
setAutoSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
setRecordPage(1);
|
||||
@@ -282,6 +319,74 @@ const ManagePowerPage: React.FC = () => {
|
||||
</CkbListCard>
|
||||
)}
|
||||
|
||||
{isAdmin && activeTab === "auto" && (
|
||||
<CkbListCard className={style.panel}>
|
||||
<div className={style.panelHead}>
|
||||
<ThunderboltOutlined /> 充值后自动划拨
|
||||
</div>
|
||||
<p className={style.autoHint}>
|
||||
管理员主池充值到账后,按下方比例自动分给触客宝客服与 AI数智员工子账号(均分)。
|
||||
</p>
|
||||
<div className={style.formItem}>
|
||||
<label>启用自动划拨</label>
|
||||
<div className={style.autoSwitchRow}>
|
||||
<span>{autoPolicy.enabled ? "已开启" : "已关闭"}</span>
|
||||
<Button
|
||||
size="mini"
|
||||
color={autoPolicy.enabled ? "primary" : "default"}
|
||||
onClick={() =>
|
||||
setAutoPolicy(p => ({ ...p, enabled: !p.enabled }))
|
||||
}
|
||||
>
|
||||
{autoPolicy.enabled ? "关闭" : "开启"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<label>触客宝客服比例(%)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(autoPolicy.touchkebaoRatio)}
|
||||
onChange={v =>
|
||||
setAutoPolicy(p => ({
|
||||
...p,
|
||||
touchkebaoRatio: Math.max(0, Math.min(100, parseInt(v || "0", 10) || 0)),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<label>AI数智员工比例(%)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(autoPolicy.aiStaffRatio)}
|
||||
onChange={v =>
|
||||
setAutoPolicy(p => ({
|
||||
...p,
|
||||
aiStaffRatio: Math.max(0, Math.min(100, parseInt(v || "0", 10) || 0)),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<label>主池最低保留(算力)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(autoPolicy.minReserve)}
|
||||
onChange={v =>
|
||||
setAutoPolicy(p => ({
|
||||
...p,
|
||||
minReserve: Math.max(0, parseInt(v || "0", 10) || 0),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button block color="primary" loading={autoSaving} onClick={handleSaveAutoPolicy}>
|
||||
保存策略
|
||||
</Button>
|
||||
</CkbListCard>
|
||||
)}
|
||||
|
||||
{isAdmin && activeTab === "records" && (
|
||||
<CkbListCard className={style.panel}>
|
||||
<div className={style.panelHead}>
|
||||
@@ -379,7 +484,7 @@ function RecordList({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<InfiniteScroll loadMore={onLoadMore} hasMore={hasMore} />
|
||||
<InfiniteScroll loadMore={async () => onLoadMore()} hasMore={hasMore} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -575,6 +575,36 @@ export async function pushPoolToDevices(params: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
// AI 整理/归类(§三 3.5)
|
||||
export interface AiClassifyItem {
|
||||
poolCompanyId: number;
|
||||
name: string;
|
||||
hitGroups: string[];
|
||||
suggestedTags: string[];
|
||||
}
|
||||
export interface AiClassifyResult {
|
||||
mode: "preview" | "apply";
|
||||
total: number;
|
||||
skipped: number[];
|
||||
items: AiClassifyItem[];
|
||||
estimate?: { totalTokens: number; totalYuan: number };
|
||||
appliedTags?: number;
|
||||
deducted?: number;
|
||||
balance?: number;
|
||||
}
|
||||
|
||||
export async function aiClassifyPool(
|
||||
poolCompanyIds: number[],
|
||||
mode: "preview" | "apply" = "preview"
|
||||
): Promise<AiClassifyResult> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/ai-classify",
|
||||
{ poolCompanyIds, mode },
|
||||
"POST",
|
||||
{ timeout: 120000 }
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 常量定义 ====================
|
||||
|
||||
// 好友状态
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface CreateTrafficPackageParams {
|
||||
|
||||
export interface CreateTrafficPackageResponse {
|
||||
id: number;
|
||||
groupName: string;
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
export async function createTrafficPackage(
|
||||
|
||||
@@ -28,6 +28,7 @@ interface FilterCondition {
|
||||
value: any;
|
||||
operator?: string;
|
||||
field?: string;
|
||||
fieldType?: string;
|
||||
displayValue?: string;
|
||||
friends?: Friend[]; // 选中的好友列表
|
||||
}
|
||||
|
||||
@@ -174,7 +174,11 @@ const SchemeRecommendation: React.FC<SchemeRecommendationProps> = ({
|
||||
<div className={styles.conditionsList}>
|
||||
{scheme.conditions.map((condition, index) => (
|
||||
<span key={index} className={styles.conditionTag}>
|
||||
{condition.label}: {condition.value}
|
||||
{condition.label}:{" "}
|
||||
{typeof condition.value === "object" &&
|
||||
condition.value !== null
|
||||
? `${condition.value.min}-${condition.value.max}`
|
||||
: condition.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
try {
|
||||
// 构造ruleConfig
|
||||
const ruleConfig = {
|
||||
logic: "AND",
|
||||
logic: "AND" as const,
|
||||
conditions: formData.filterConditions,
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ export async function getPackage(params: {
|
||||
formattedTime = new Date(group.createTime * 1000).toLocaleDateString();
|
||||
} else if (typeof group.createTime === "string") {
|
||||
// 已经是日期字符串,直接取日期部分
|
||||
formattedTime = group.createTime.split(" ")[0] || group.createTime;
|
||||
const ct = group.createTime as string;
|
||||
formattedTime = ct.split(" ")[0] || ct;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Modal, Button, message, Tag, Empty, Spin } from "antd";
|
||||
import { aiClassifyPool, type AiClassifyResult } from "../../api";
|
||||
|
||||
interface AiClassifyModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
poolCompanyIds: number[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 整理/归类弹窗(存客宝 §三 3.5)
|
||||
* 流程:打开即拉 preview(建议 + 算力预估)→ 用户确认 → apply(扣费 + 写 AI 标签)。
|
||||
*/
|
||||
const AiClassifyModal: React.FC<AiClassifyModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
poolCompanyIds,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [preview, setPreview] = useState<AiClassifyResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
if (poolCompanyIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
aiClassifyPool(poolCompanyIds, "preview")
|
||||
.then(res => setPreview(res))
|
||||
.catch((e: unknown) => {
|
||||
const msg =
|
||||
typeof e === "string"
|
||||
? e
|
||||
: (e as { message?: string })?.message || "生成预览失败";
|
||||
message.error(msg);
|
||||
onClose();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, poolCompanyIds]);
|
||||
|
||||
const handleApply = async () => {
|
||||
if (poolCompanyIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
setApplying(true);
|
||||
try {
|
||||
const res = await aiClassifyPool(poolCompanyIds, "apply");
|
||||
message.success(
|
||||
`已整理 ${res.total} 位客资,写入 ${res.appliedTags ?? 0} 个标签(消耗 ${res.deducted ?? 0} 算力)`
|
||||
);
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
typeof e === "string"
|
||||
? e
|
||||
: (e as { message?: string })?.message || "AI 整理失败";
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const est = preview?.estimate;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="AI 整理 / 归类"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
centered
|
||||
destroyOnClose
|
||||
width={520}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="apply"
|
||||
type="primary"
|
||||
loading={applying}
|
||||
disabled={loading || !preview || preview.total === 0}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{est ? `确认整理(约 ${est.totalTokens} 算力 ≈ ¥${est.totalYuan})` : "确认整理"}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "32px 0" }}>
|
||||
<Spin tip="AI 分析中..." />
|
||||
</div>
|
||||
) : !preview || preview.total === 0 ? (
|
||||
<Empty description="没有可整理的客资(系统标识客资会自动跳过)" />
|
||||
) : (
|
||||
<div>
|
||||
<p style={{ color: "#666", fontSize: 13, marginBottom: 12 }}>
|
||||
将根据昵称/备注/行为为 <b>{preview.total}</b> 位客资建议归类与标签
|
||||
{preview.skipped.length > 0
|
||||
? `(已跳过 ${preview.skipped.length} 条系统标识客资)`
|
||||
: ""}
|
||||
,确认后扣减算力并写入 AI 标签。
|
||||
</p>
|
||||
<div style={{ maxHeight: 320, overflowY: "auto" }}>
|
||||
{preview.items.map(item => (
|
||||
<div
|
||||
key={item.poolCompanyId}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{item.name}
|
||||
</div>
|
||||
<div>
|
||||
{item.suggestedTags.map(t => (
|
||||
<Tag key={t} color="blue" style={{ marginBottom: 4 }}>
|
||||
{t}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiClassifyModal;
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { assignLeadsToKefu, getKefuDetail } from "../../kefu/api";
|
||||
import AssignGroupModal from "./components/AssignGroupModal";
|
||||
import PushToDeviceModal from "./components/PushToDeviceModal";
|
||||
import AiClassifyModal from "./components/AiClassifyModal";
|
||||
import {
|
||||
buildPoolCsvExport,
|
||||
buildPoolMarkdownExport,
|
||||
@@ -58,6 +59,11 @@ const getGroupIcon = (group: TrafficPoolGroup) => {
|
||||
high_value: "⭐",
|
||||
potential: "📈",
|
||||
high_interact: "💬",
|
||||
scene_added: "✅",
|
||||
scene_not_added: "⏳",
|
||||
scene_reached: "📨",
|
||||
scene_not_reached: "🔕",
|
||||
scene_old_customer: "🏆",
|
||||
};
|
||||
return map[group.groupCode] || (group.isSystem ? "📦" : "🏷");
|
||||
};
|
||||
@@ -68,6 +74,11 @@ const getGroupColor = (group: TrafficPoolGroup) => {
|
||||
high_value: "#FFF1F0",
|
||||
potential: "#F6FFED",
|
||||
high_interact: "#F9F0FF",
|
||||
scene_added: "#F6FFED",
|
||||
scene_not_added: "#FFF7E6",
|
||||
scene_reached: "#E6F4FF",
|
||||
scene_not_reached: "#F0F2F5",
|
||||
scene_old_customer: "#F9F0FF",
|
||||
};
|
||||
return group.groupColor || map[group.groupCode] || "#F0F2F5";
|
||||
};
|
||||
@@ -127,6 +138,7 @@ const TrafficPoolPage: React.FC = () => {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [webhookPushing, setWebhookPushing] = useState(false);
|
||||
const [pushDeviceOpen, setPushDeviceOpen] = useState(false);
|
||||
const [aiClassifyOpen, setAiClassifyOpen] = useState(false);
|
||||
|
||||
const assignKefuId = searchParams.get("assignKefu");
|
||||
const [assignKefuName, setAssignKefuName] = useState("");
|
||||
@@ -457,6 +469,19 @@ const TrafficPoolPage: React.FC = () => {
|
||||
setAssignModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ai-classify",
|
||||
icon: <UsergroupAddOutlined />,
|
||||
label: "AI 整理",
|
||||
disabled: selectedIds.length === 0,
|
||||
onClick: () => {
|
||||
if (selectedIds.length === 0) {
|
||||
message.info("请先勾选要整理的客户");
|
||||
return;
|
||||
}
|
||||
setAiClassifyOpen(true);
|
||||
},
|
||||
},
|
||||
{ type: "divider" as const },
|
||||
{
|
||||
key: "csv",
|
||||
@@ -783,6 +808,17 @@ const TrafficPoolPage: React.FC = () => {
|
||||
selectedCount={selectedIds.length}
|
||||
totalCount={total}
|
||||
/>
|
||||
|
||||
<AiClassifyModal
|
||||
open={aiClassifyOpen}
|
||||
onClose={() => setAiClassifyOpen(false)}
|
||||
poolCompanyIds={selectedIds}
|
||||
onSuccess={() => {
|
||||
setSelectedIds([]);
|
||||
loadUsers();
|
||||
loadGroups();
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,7 +29,9 @@ const BatchAddModal: React.FC<BatchAddModalProps> = ({
|
||||
setLoading(true);
|
||||
fetchPackageOptions()
|
||||
.then(res => {
|
||||
setPackageOptions(res.list || []);
|
||||
setPackageOptions(
|
||||
(res.list || []).map(p => ({ id: String(p.id), name: p.name })),
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("获取分组选项失败:", error);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Card, Button } from "antd-mobile";
|
||||
import { fetchTrafficPoolList } from "./api";
|
||||
import type { TrafficPoolUser } from "./data";
|
||||
import { fetchTrafficPoolList, type PoolListItem } from "./api";
|
||||
|
||||
interface DataAnalysisPanelProps {
|
||||
showStats: boolean;
|
||||
@@ -21,7 +20,7 @@ const DataAnalysisPanel: React.FC<DataAnalysisPanelProps> = ({
|
||||
setShowStats,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||
const [list, setList] = useState<PoolListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 计算统计数据
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface PoolListItem {
|
||||
totalMsgCount: number;
|
||||
totalOrderAmount: number;
|
||||
tags: string[];
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface PoolListResult {
|
||||
|
||||
@@ -170,7 +170,7 @@ export async function exportWechatMoments(params: {
|
||||
);
|
||||
|
||||
// 检查响应类型,如果是JSON错误响应,需要解析错误信息
|
||||
const contentType = response.headers["content-type"] || "";
|
||||
const contentType = String(response.headers["content-type"] || "");
|
||||
if (contentType.includes("application/json")) {
|
||||
// 如果是JSON响应,说明可能是错误信息
|
||||
const text = await response.data.text();
|
||||
|
||||
@@ -37,7 +37,7 @@ const PaymentAcquirePage: React.FC = () => {
|
||||
payType,
|
||||
cid: cid ? Number(cid) : undefined,
|
||||
redirectUrl: redirectUrl || undefined,
|
||||
})) as PublicPaymentOrder;
|
||||
})) as unknown as PublicPaymentOrder;
|
||||
setOrder(data);
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e?.message || "下单失败" });
|
||||
@@ -54,7 +54,7 @@ const PaymentAcquirePage: React.FC = () => {
|
||||
if (!order?.orderNo) return;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const res = (await publicQueryPaymentOrder(order.orderNo)) as PublicPaymentOrder;
|
||||
const res = (await publicQueryPaymentOrder(order.orderNo)) as unknown as PublicPaymentOrder;
|
||||
if (res?.status === 1 || res?.paid) {
|
||||
clearInterval(timer);
|
||||
const target = res.finalRedirectUrl || redirectUrl;
|
||||
|
||||
@@ -43,7 +43,11 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const detailRes = await getUserList(ruleId.toString(), 1);
|
||||
const detailRes = await getUserList({
|
||||
planId: ruleId.toString(),
|
||||
type: 1,
|
||||
page: 1,
|
||||
});
|
||||
const accountData = detailRes?.list || [];
|
||||
setAccounts(accountData);
|
||||
} catch (error) {
|
||||
|
||||
@@ -103,9 +103,9 @@ export const LeadCustomerCard: React.FC<LeadCustomerCardProps> = ({
|
||||
{kw}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className={styles.tagMuted}>暂无流量池</span>
|
||||
)}
|
||||
) : customer.poolFriendStatusText ? (
|
||||
<span className={styles.tagMuted}>{customer.poolFriendStatusText}</span>
|
||||
) : null}
|
||||
{keywords.length > 3 && (
|
||||
<span className={styles.tagMore}>+{keywords.length - 3}</span>
|
||||
)}
|
||||
|
||||
@@ -42,7 +42,11 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const detailRes = await getUserList(ruleId.toString(), 2);
|
||||
const detailRes = await getUserList({
|
||||
planId: ruleId.toString(),
|
||||
type: 2,
|
||||
page: 1,
|
||||
});
|
||||
const accountData = detailRes?.list || [];
|
||||
setAccounts(accountData);
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,8 +24,7 @@ import {
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
CkbSearchToolbar,
|
||||
CkbToolbarPanel,
|
||||
CkbCollapsibleSearchHeader,
|
||||
CkbPageShell,
|
||||
CkbEmptyState,
|
||||
CkbListCard,
|
||||
@@ -334,25 +333,9 @@ const ScenarioList: React.FC = () => {
|
||||
h5Url: parsed.h5Url || buildPosterH5Url(taskId),
|
||||
qrImageUrl: parsed.qrImageUrl || imgSrc,
|
||||
});
|
||||
if (parsed.qrType === "h5") {
|
||||
setQrType("h5");
|
||||
setQrDialogTitle("H5 获客表单二维码");
|
||||
Toast.show({
|
||||
content: "当前为 H5 表单码,与小程序码不同",
|
||||
position: "top",
|
||||
});
|
||||
} else if (parsed.qrType === "h5_fallback") {
|
||||
setQrType("h5_fallback");
|
||||
setQrDialogTitle("H5 获客表单二维码");
|
||||
setQrIsFallback(true);
|
||||
Toast.show({
|
||||
content: "小程序码不可用,已降级为 H5 链接码",
|
||||
position: "top",
|
||||
});
|
||||
} else {
|
||||
setQrType("miniprogram");
|
||||
setQrDialogTitle(`${parsed.miniProgramName || "存客宝"}小程序码`);
|
||||
}
|
||||
// 经上方校验,到此 qrType 必为 miniprogram
|
||||
setQrType("miniprogram");
|
||||
setQrDialogTitle(`${parsed.miniProgramName || "存客宝"}小程序码`);
|
||||
} else {
|
||||
Toast.show({ content: "小程序码数据为空", position: "top" });
|
||||
}
|
||||
@@ -547,37 +530,23 @@ const ScenarioList: React.FC = () => {
|
||||
right={headerNewPlanButton}
|
||||
/>
|
||||
|
||||
<CkbToolbarPanel>
|
||||
<CkbSearchToolbar
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="搜索计划名称"
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={loadingTasks}
|
||||
/>
|
||||
|
||||
{/* 计划状态筛选 */}
|
||||
<div className={style["status-tabs"]}>
|
||||
{(
|
||||
[
|
||||
{ key: "active", label: "已启用" },
|
||||
{ key: "paused", label: "已暂停" },
|
||||
{ key: "all", label: "全部" },
|
||||
] as const
|
||||
).map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={`${style["status-tab"]} ${
|
||||
statusTab === tab.key ? style["status-tab-active"] : ""
|
||||
}`}
|
||||
onClick={() => setStatusTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CkbToolbarPanel>
|
||||
{/* 动态搜索头(§一 1.3):默认紧凑条,点 🔍 滑出搜索 + 状态 Segmented */}
|
||||
<CkbCollapsibleSearchHeader
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="搜索计划名称"
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={loadingTasks}
|
||||
statusTab={statusTab}
|
||||
statusTabs={[
|
||||
{ key: "active", label: "已启用" },
|
||||
{ key: "paused", label: "已暂停" },
|
||||
{ key: "all", label: "全部" },
|
||||
]}
|
||||
onStatusChange={key =>
|
||||
setStatusTab(key as "active" | "paused" | "all")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
|
||||
@@ -54,6 +54,9 @@ export interface FormData {
|
||||
leadPushWebhookEnabled?: boolean;
|
||||
leadPushWebhook?: string;
|
||||
leadPushWebhookSecret?: string;
|
||||
/** 触客宝 AI 获客内置通道 */
|
||||
leadPushTouchkebaoEnabled?: boolean;
|
||||
leadPushTouchkebaoKefuId?: number;
|
||||
/** 入站计划接口 */
|
||||
apiKey?: string;
|
||||
inboundWebhookPath?: string;
|
||||
@@ -106,6 +109,8 @@ export const defFormData: FormData = {
|
||||
leadPushWebhookEnabled: false,
|
||||
leadPushWebhook: "",
|
||||
leadPushWebhookSecret: "",
|
||||
leadPushTouchkebaoEnabled: false,
|
||||
leadPushTouchkebaoKefuId: undefined,
|
||||
transferInheritInfo: true,
|
||||
transferFirstMessage: "",
|
||||
transferSourceWechatId: "",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SCENE_CATALOG,
|
||||
PRODUCTION_SCENE_IDS,
|
||||
} from "@/pages/mobile/scenarios/sceneCatalog";
|
||||
import { saveLeadWebhook } from "@/pages/mobile/scenarios/plan/list/api";
|
||||
|
||||
export default function NewPlan() {
|
||||
const router = useNavigate();
|
||||
@@ -165,6 +166,10 @@ export default function NewPlan() {
|
||||
leadPushWebhookEnabled: detail.leadPushWebhookEnabled ?? false,
|
||||
leadPushWebhook: detail.leadPushWebhook ?? "",
|
||||
leadPushWebhookSecret: detail.leadPushWebhookSecret ?? "",
|
||||
leadPushTouchkebaoEnabled: !!detail.leadPushTouchkebaoEnabled,
|
||||
leadPushTouchkebaoKefuId: detail.leadPushTouchkebaoKefuId
|
||||
? Number(detail.leadPushTouchkebaoKefuId)
|
||||
: undefined,
|
||||
apiKey: detail.apiKey ?? "",
|
||||
inboundWebhookPath:
|
||||
detail.textUrl?.fullUrl || (planId ? `webhook/${planId}` : ""),
|
||||
@@ -262,6 +267,14 @@ export default function NewPlan() {
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
formData.leadPushTouchkebaoEnabled &&
|
||||
!formData.leadPushTouchkebaoKefuId
|
||||
) {
|
||||
message.error("开启触客宝 AI 获客请选择已绑定的客服");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData: any = {
|
||||
...formData,
|
||||
@@ -336,6 +349,16 @@ export default function NewPlan() {
|
||||
submitData.id = Number(planId);
|
||||
submitData.planId = Number(planId);
|
||||
await updatePlan(submitData);
|
||||
await saveLeadWebhook({
|
||||
planId: String(planId),
|
||||
enabled: !!formData.leadPushWebhookEnabled,
|
||||
url: (formData.leadPushWebhook || "").trim(),
|
||||
secret: (formData.leadPushWebhookSecret || "").trim(),
|
||||
touchkebaoEnabled: !!formData.leadPushTouchkebaoEnabled,
|
||||
touchkebaoKefuId: formData.leadPushTouchkebaoKefuId
|
||||
? Number(formData.leadPushTouchkebaoKefuId)
|
||||
: 0,
|
||||
});
|
||||
message.success("计划已更新");
|
||||
const sceneItem = sceneList.find(v => formData.scenario === v.id);
|
||||
router(`/scenarios/list/${formData.scenario}/${sceneItem?.name ?? "计划"}`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Input, Button, Switch } from "antd";
|
||||
import { Input, Button, Switch, Select } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
DeleteOutlined,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
UpOutlined,
|
||||
RobotOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { PlanApiPanel } from "@/pages/mobile/scenarios/plan/list/planApi";
|
||||
@@ -19,6 +20,12 @@ import styles from "./base.module.scss";
|
||||
|
||||
export type AdvancedPanelKey = "distribution" | "webhook" | "planApi" | "group";
|
||||
|
||||
export interface TouchkebaoKefuOption {
|
||||
label: string;
|
||||
value: number;
|
||||
accountId?: number;
|
||||
}
|
||||
|
||||
interface AdvancedSettingsSectionProps {
|
||||
expanded: AdvancedPanelKey | null;
|
||||
onExpand: (key: AdvancedPanelKey | null) => void;
|
||||
@@ -26,6 +33,7 @@ interface AdvancedSettingsSectionProps {
|
||||
planId?: string;
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
touchkebaoKefuOptions?: TouchkebaoKefuOption[];
|
||||
distributionEnabled: boolean;
|
||||
onToggleDistribution: (v: boolean) => void;
|
||||
customerReward?: number;
|
||||
@@ -64,6 +72,7 @@ const AdvancedSettingsSection: React.FC<AdvancedSettingsSectionProps> = ({
|
||||
onShowQRCode,
|
||||
webhookTesting,
|
||||
onWebhookTest,
|
||||
touchkebaoKefuOptions = [],
|
||||
onToggleGroupInvite,
|
||||
onGroupNameChange,
|
||||
onFixedMembersSelect,
|
||||
@@ -105,7 +114,7 @@ const AdvancedSettingsSection: React.FC<AdvancedSettingsSectionProps> = ({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles["advanced-tile"]} ${expanded === "webhook" ? styles["tile-active"] : ""} ${formData.leadPushWebhookEnabled ? styles["tile-on"] : ""}`}
|
||||
className={`${styles["advanced-tile"]} ${expanded === "webhook" ? styles["tile-active"] : ""} ${formData.leadPushWebhookEnabled || formData.leadPushTouchkebaoEnabled ? styles["tile-on"] : ""}`}
|
||||
onClick={() => toggle("webhook")}
|
||||
>
|
||||
<span
|
||||
@@ -117,7 +126,7 @@ const AdvancedSettingsSection: React.FC<AdvancedSettingsSectionProps> = ({
|
||||
<ApiOutlined />
|
||||
</span>
|
||||
<span className={styles["tile-label"]}>Webhook</span>
|
||||
{formData.leadPushWebhookEnabled && (
|
||||
{(formData.leadPushWebhookEnabled || formData.leadPushTouchkebaoEnabled) && (
|
||||
<span className={styles["tile-badge"]} />
|
||||
)}
|
||||
</button>
|
||||
@@ -252,8 +261,8 @@ const AdvancedSettingsSection: React.FC<AdvancedSettingsSectionProps> = ({
|
||||
{expanded === "webhook" && (
|
||||
<div className={styles["advanced-panel"]}>
|
||||
<PanelHeader
|
||||
title="Webhook 客资推送"
|
||||
desc="客资变更时 POST 推送电话、标签与行为轨迹"
|
||||
title="HTTP Webhook 客资推送"
|
||||
desc="客资变更时 POST 推送到外部地址(如飞书机器人)"
|
||||
enabled={!!formData.leadPushWebhookEnabled}
|
||||
onToggle={v =>
|
||||
onChange({ ...formData, leadPushWebhookEnabled: v })
|
||||
@@ -297,11 +306,45 @@ const AdvancedSettingsSection: React.FC<AdvancedSettingsSectionProps> = ({
|
||||
block
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
发送测试推送
|
||||
发送 HTTP 测试推送
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles["touchkebao-block"]}>
|
||||
<PanelHeader
|
||||
title="触客宝 AI 获客"
|
||||
desc="新客资自动推送到子账号 AI 获客,无需填 URL"
|
||||
enabled={!!formData.leadPushTouchkebaoEnabled}
|
||||
onToggle={v =>
|
||||
onChange({ ...formData, leadPushTouchkebaoEnabled: v })
|
||||
}
|
||||
onCollapse={() => {}}
|
||||
/>
|
||||
{formData.leadPushTouchkebaoEnabled && (
|
||||
<div className={styles["distribution-input-wrapper"]}>
|
||||
<div className={styles["basic-label"]}>
|
||||
<RobotOutlined style={{ marginRight: 6 }} />
|
||||
绑定客服(须已绑触客宝)
|
||||
</div>
|
||||
<Select
|
||||
placeholder="选择接收 AI 获客的子账号"
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
value={formData.leadPushTouchkebaoKefuId || undefined}
|
||||
options={touchkebaoKefuOptions}
|
||||
onChange={(v: number) =>
|
||||
onChange({ ...formData, leadPushTouchkebaoKefuId: v })
|
||||
}
|
||||
notFoundContent="暂无已绑触客宝的客服,请先到「我的 → 客服管理」绑定"
|
||||
/>
|
||||
<div className={styles["touchkebao-hint"]}>
|
||||
开启后,本计划新客资将自动进入所选客服的触客宝 AI 获客列表
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
saveLeadWebhook,
|
||||
testLeadWebhook,
|
||||
} from "@/pages/mobile/scenarios/plan/list/api";
|
||||
import { getKefuList } from "@/pages/mobile/mine/kefu/api";
|
||||
import type { TouchkebaoKefuOption } from "./AdvancedSettingsSection";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
isEdit: boolean;
|
||||
@@ -89,6 +91,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
Array<string | number>
|
||||
>(formData.distributionChannelIds || []);
|
||||
const [webhookTesting, setWebhookTesting] = useState(false);
|
||||
const [touchkebaoKefuOptions, setTouchkebaoKefuOptions] = useState<
|
||||
TouchkebaoKefuOption[]
|
||||
>([]);
|
||||
const [expandedAdvanced, setExpandedAdvanced] = useState<
|
||||
"distribution" | "webhook" | "planApi" | "group" | null
|
||||
>(null);
|
||||
@@ -102,6 +107,22 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
formData.distributionAddReward
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getKefuList({ limit: 200, bindStatus: "bound" })
|
||||
.then(res => {
|
||||
setTouchkebaoKefuOptions(
|
||||
(res.list || [])
|
||||
.filter(k => k.ckbUserId)
|
||||
.map(k => ({
|
||||
label: `${k.displayName || k.realName || k.userName}${k.userName ? ` · ${k.userName}` : ""}`,
|
||||
value: Number(k.ckbUserId),
|
||||
accountId: k.id,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setTouchkebaoKefuOptions([]));
|
||||
}, []);
|
||||
|
||||
// 二维码相关状态
|
||||
const [qrCodeMap, setQrCodeMap] = useState<Record<string | number, {
|
||||
qrCode: string;
|
||||
@@ -1080,6 +1101,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
onShowQRCode={handleShowQRCode}
|
||||
webhookTesting={webhookTesting}
|
||||
onWebhookTest={handleWebhookTest}
|
||||
touchkebaoKefuOptions={touchkebaoKefuOptions}
|
||||
onToggleGroupInvite={handleToggleGroupInvite}
|
||||
onGroupNameChange={handleGroupNameChange}
|
||||
onFixedMembersSelect={handleFixedMembersSelect}
|
||||
|
||||
@@ -36,7 +36,7 @@ const FriendMigrationSection: React.FC<FriendMigrationSectionProps> = ({
|
||||
try {
|
||||
const res = await getWechatAccounts({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
limit: 100,
|
||||
keyword: search || undefined,
|
||||
});
|
||||
const list = res?.list || [];
|
||||
|
||||
@@ -603,6 +603,19 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.touchkebao-block {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.touchkebao-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.advanced-panel {
|
||||
.distribution-input-wrapper {
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -54,6 +54,8 @@ export const AI_SCENE_CATALOG = [
|
||||
description: "智能分析客户画像,自动优化获客策略",
|
||||
sort: 30,
|
||||
route: "/scenarios/ai/ai-friend",
|
||||
demoToday: 0,
|
||||
demoGrowth: "0%",
|
||||
},
|
||||
{
|
||||
key: "ai-group",
|
||||
@@ -61,6 +63,8 @@ export const AI_SCENE_CATALOG = [
|
||||
description: "智能群聊互动,提高群活跃度与转化率",
|
||||
sort: 25,
|
||||
route: "/scenarios/ai/ai-group",
|
||||
demoToday: 0,
|
||||
demoGrowth: "0%",
|
||||
},
|
||||
{
|
||||
key: "ai-conversion",
|
||||
@@ -68,6 +72,8 @@ export const AI_SCENE_CATALOG = [
|
||||
description: "多场景智能营销,提升转化效果",
|
||||
sort: 20,
|
||||
route: "/scenarios/ai/ai-conversion",
|
||||
demoToday: 0,
|
||||
demoGrowth: "0%",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -141,6 +147,7 @@ export interface ScenarioListItem {
|
||||
totalPlanCount?: number;
|
||||
status?: number;
|
||||
sort?: number;
|
||||
allNum?: number;
|
||||
}
|
||||
|
||||
export interface ScenarioDashboardSummary {
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface AiAction {
|
||||
requiresConfirm?: boolean;
|
||||
status?: "pending" | "done" | "failed";
|
||||
result?: { ok: boolean; data?: Record<string, unknown>; error?: string };
|
||||
method?: string;
|
||||
path?: string;
|
||||
desc?: string;
|
||||
jumpPath?: string;
|
||||
}
|
||||
|
||||
export interface AiMessage {
|
||||
|
||||
@@ -90,6 +90,9 @@ const AutoGroupForm: React.FC = () => {
|
||||
setAdminsOptions(res.config.adminsOptions || []);
|
||||
setpoolGroupsOptions(res.config.poolGroupsOptions || []);
|
||||
setDataLoaded(true); // 标记数据已加载
|
||||
}).catch(() => {
|
||||
Toast.show({ content: "加载任务详情失败,请重试", position: "top" });
|
||||
setDataLoaded(true); // 失败也放行,避免页面卡在加载态
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ const ContactImportDetail: React.FC = () => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchContactImportTaskDetail(id);
|
||||
const data = response?.data || response;
|
||||
const response = await fetchContactImportTaskDetail(Number(id));
|
||||
const data = (response as any)?.data || response;
|
||||
setTask(data);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
@@ -71,8 +71,8 @@ const ContactImportDetail: React.FC = () => {
|
||||
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const response = await fetchImportRecords(id, pageNum, 20);
|
||||
const data = response?.data || response;
|
||||
const response = await fetchImportRecords(Number(id), pageNum, 20);
|
||||
const data = (response as any)?.data || response;
|
||||
if (reset) {
|
||||
setRecords(data.list || []);
|
||||
} else {
|
||||
|
||||
@@ -176,6 +176,7 @@ const ContactImportForm: React.FC = () => {
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
planType: 0,
|
||||
name: "",
|
||||
status: 1,
|
||||
type: 6,
|
||||
|
||||
@@ -614,7 +614,7 @@ const GroupDetailPage: React.FC = () => {
|
||||
{
|
||||
key: "confirm",
|
||||
text: "确定",
|
||||
primary: true,
|
||||
bold: true,
|
||||
onClick: handleConfirmEditName,
|
||||
},
|
||||
]}
|
||||
@@ -663,7 +663,7 @@ const GroupDetailPage: React.FC = () => {
|
||||
{
|
||||
key: "confirm",
|
||||
text: "确定",
|
||||
primary: true,
|
||||
bold: true,
|
||||
onClick: handleConfirmEditAnnouncement,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -24,7 +24,7 @@ const Detail: React.FC = () => {
|
||||
setLoading(true);
|
||||
getGroupPushTaskDetail(id)
|
||||
.then(res => {
|
||||
setTask(res.data || res); // 兼容两种返回格式
|
||||
setTask((res as any).data || res); // 兼容两种返回格式
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
@@ -181,7 +181,7 @@ const Detail: React.FC = () => {
|
||||
<div>推送间隔:{task.pushInterval} 秒</div>
|
||||
<div>每日最大推送数:{task.maxPerDay} 条</div>
|
||||
<div>
|
||||
执行时间段:{task.timeRange.start} - {task.timeRange.end}
|
||||
执行时间段:{task.startTime} - {task.endTime}
|
||||
</div>
|
||||
<div>
|
||||
推送模式:
|
||||
|
||||
@@ -19,3 +19,12 @@ export const createTrafficDistribution = (
|
||||
) => {
|
||||
return request("/v1/workbench/create", data, "POST");
|
||||
};
|
||||
|
||||
/** 流量分发计划 · 手动推送到触客宝 AI 获客 */
|
||||
export const pushTrafficToAiLead = (workbenchId: number, maxBatch?: number) => {
|
||||
return request(
|
||||
"/v1/workbench/traffic-push-ai-lead",
|
||||
{ workbenchId, maxBatch: maxBatch || 0 },
|
||||
"POST",
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,11 @@ export interface TrafficDistributionDetail {
|
||||
export interface TrafficDistributionConfig {
|
||||
id: number;
|
||||
workbenchId: number;
|
||||
/** 1=微信好友分配 2=触客宝AI获客 */
|
||||
targetType?: number;
|
||||
touchkebaoKefuId?: number;
|
||||
sortBy?: string;
|
||||
friendStatusFilter?: number;
|
||||
distributeType: number;
|
||||
maxPerDay: number;
|
||||
timeType: number;
|
||||
@@ -56,6 +61,11 @@ export interface TrafficDistributionFormData {
|
||||
timeType: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
/** 1=微信好友 2=触客宝AI获客 */
|
||||
targetType?: number;
|
||||
touchkebaoKefuId?: number;
|
||||
sortBy?: string;
|
||||
friendStatusFilter?: number;
|
||||
deviceGroups: any[];
|
||||
deviceGroupsOptions: any[];
|
||||
accountGroups: any[];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Form, Input, Button, Radio, Slider, TimePicker, message, Card } from "antd";
|
||||
import { Form, Input, Button, Radio, Slider, TimePicker, message, Card, Select } from "antd";
|
||||
import { RobotOutlined } from "@ant-design/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import style from "./index.module.scss";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from "./api";
|
||||
import type { TrafficDistributionFormData } from "./data";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { getKefuList } from "@/pages/mobile/mine/kefu/api";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const stepList = [
|
||||
@@ -54,10 +56,43 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
const [poolGroupsOptions, setPoolGroupsOptions] = useState<
|
||||
PoolSelectionItem[]
|
||||
>([]);
|
||||
const [touchkebaoKefuOptions, setTouchkebaoKefuOptions] = useState<
|
||||
{ label: string; value: number }[]
|
||||
>([]);
|
||||
|
||||
// 监听表单字段变化(antd v5 推荐)
|
||||
const maxPerDay = Form.useWatch("maxPerDay", form);
|
||||
const timeType = Form.useWatch("timeType", form);
|
||||
const targetType = Form.useWatch("targetType", form) ?? 1;
|
||||
const isAiLeadTarget = targetType === 2;
|
||||
|
||||
useEffect(() => {
|
||||
getKefuList({ limit: 200, bindStatus: "bound" })
|
||||
.then(res => {
|
||||
setTouchkebaoKefuOptions(
|
||||
(res.list || [])
|
||||
.filter(k => k.ckbUserId)
|
||||
.map(k => ({
|
||||
label: `${k.userName || k.displayName || k.realName || `客服${k.id}`}${
|
||||
k.realName && k.userName ? ` · ${k.realName}` : ""
|
||||
}`,
|
||||
value: Number(k.ckbUserId),
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setTouchkebaoKefuOptions([]));
|
||||
}, []);
|
||||
|
||||
// 客服选项加载后再回填 touchkebaoKefuId,避免 Select 只显示数字 ID
|
||||
useEffect(() => {
|
||||
if (!touchkebaoKefuOptions.length) return;
|
||||
const current = form.getFieldValue("touchkebaoKefuId");
|
||||
if (current == null || current === "") return;
|
||||
const normalized = Number(current);
|
||||
if (touchkebaoKefuOptions.some(o => o.value === normalized)) {
|
||||
form.setFieldsValue({ touchkebaoKefuId: normalized });
|
||||
}
|
||||
}, [touchkebaoKefuOptions, form]);
|
||||
|
||||
// 生成默认名称
|
||||
const generateDefaultName = () => {
|
||||
@@ -78,6 +113,12 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
const config = detail.config;
|
||||
form.setFieldsValue({
|
||||
name: detail.name,
|
||||
targetType: config.targetType ?? 1,
|
||||
touchkebaoKefuId: config.touchkebaoKefuId
|
||||
? Number(config.touchkebaoKefuId)
|
||||
: undefined,
|
||||
sortBy: config.sortBy || "last_contact",
|
||||
friendStatusFilter: config.friendStatusFilter ?? 0,
|
||||
distributeType: config.distributeType,
|
||||
maxPerDay: config.maxPerDay,
|
||||
timeType: config.timeType,
|
||||
@@ -160,10 +201,27 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
// 如果没有传递values参数,从表单中获取
|
||||
const formValues = values ?? form.getFieldsValue();
|
||||
|
||||
if (formValues.targetType === 2 && !formValues.touchkebaoKefuId) {
|
||||
message.error("触客宝 AI 获客模式请选择已绑定的子账号");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!poolGroupsOptions || poolGroupsOptions.length === 0) {
|
||||
message.error("请至少选择一个流量池");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData: TrafficDistributionFormData = {
|
||||
id,
|
||||
type: 5, // 流量分发类型
|
||||
name: formValues.name,
|
||||
targetType: formValues.targetType ?? 1,
|
||||
touchkebaoKefuId: formValues.touchkebaoKefuId
|
||||
? Number(formValues.touchkebaoKefuId)
|
||||
: undefined,
|
||||
sortBy: formValues.sortBy || "last_contact",
|
||||
friendStatusFilter: formValues.friendStatusFilter ?? 0,
|
||||
source: "",
|
||||
sourceIcon: "",
|
||||
description: "",
|
||||
@@ -216,6 +274,10 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
// 验证失败,不进行下一步
|
||||
});
|
||||
} else if (current === 1) {
|
||||
if (isAiLeadTarget) {
|
||||
setCurrent(cur => cur + 1);
|
||||
return;
|
||||
}
|
||||
// 第二步:目标设置至少需要选择一个设备
|
||||
const hasDevice =
|
||||
Array.isArray(selectedDevices) && selectedDevices.length > 0;
|
||||
@@ -278,6 +340,9 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
initialValues={{
|
||||
name: isEdit ? "" : generateDefaultName(),
|
||||
planType: 1,
|
||||
targetType: 1,
|
||||
sortBy: "last_contact",
|
||||
friendStatusFilter: 0,
|
||||
distributeType: 1,
|
||||
maxPerDay: 50,
|
||||
timeType: 1,
|
||||
@@ -305,6 +370,67 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 分发目标 */}
|
||||
<Card className={style.card}>
|
||||
<Form.Item label="分发目标" name="targetType" required>
|
||||
<Radio.Group className={style.radioGroup}>
|
||||
<Radio value={1}>
|
||||
微信好友分配
|
||||
<span className={style.radioDesc}>
|
||||
(将好友转移给客服微信号)
|
||||
</span>
|
||||
</Radio>
|
||||
<Radio value={2}>
|
||||
触客宝 AI 获客
|
||||
<span className={style.radioDesc}>
|
||||
(未添加客资推送到子账号 AI 获客列表)
|
||||
</span>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{isAiLeadTarget && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={
|
||||
<>
|
||||
<RobotOutlined style={{ marginRight: 6 }} />
|
||||
绑定子账号(须已绑触客宝)
|
||||
</>
|
||||
}
|
||||
name="touchkebaoKefuId"
|
||||
rules={[
|
||||
{ required: true, message: "请选择接收 AI 获客的子账号" },
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择子账号,如 wz_02 / 魔兽客服"
|
||||
size="large"
|
||||
optionLabelProp="label"
|
||||
options={touchkebaoKefuOptions}
|
||||
notFoundContent="暂无已绑触客宝的客服,请先到「我的 → 客服管理」绑定"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="客资筛选" name="friendStatusFilter">
|
||||
<Radio.Group className={style.radioGroupHorizontal}>
|
||||
<Radio value={0}>未添加</Radio>
|
||||
<Radio value={1}>待通过</Radio>
|
||||
<Radio value={-1}>全部</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="RF 排序(最近联系优先,其次最新添加)"
|
||||
name="sortBy"
|
||||
>
|
||||
<Radio.Group className={style.radioGroupHorizontal}>
|
||||
<Radio value="last_contact">最近联系优先</Radio>
|
||||
<Radio value="create_time">最新添加优先</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 分配方式 */}
|
||||
<Card className={style.card}>
|
||||
<Form.Item label="分配方式" name="distributeType" required>
|
||||
@@ -385,7 +511,8 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 客服选择 */}
|
||||
{/* 客服选择(微信好友模式) */}
|
||||
{!isAiLeadTarget && (
|
||||
<Card className={style.card}>
|
||||
<div className={style.sectionTitle}>客服选择</div>
|
||||
<div className={style.formBlock}>
|
||||
@@ -401,8 +528,18 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
{current === 1 && (
|
||||
isAiLeadTarget ? (
|
||||
<Card className={style.card}>
|
||||
<div className={style.sectionTitle}>目标说明</div>
|
||||
<p style={{ color: "#64748b", fontSize: 14, margin: 0 }}>
|
||||
触客宝 AI 获客模式无需选择设备,下一步选择流量池后,系统将按 RF
|
||||
排序把「未添加」客资推送到所选子账号的 AI 获客列表。
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className={style.card}>
|
||||
<div className={style.sectionTitle}>目标设置</div>
|
||||
<div className={style.formBlock}>
|
||||
@@ -419,6 +556,7 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
{current === 2 && (
|
||||
<Card className={style.card}>
|
||||
|
||||
@@ -27,6 +27,15 @@ export function deleteDistributionRule(id: number): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
/** 手动推送到触客宝 AI 获客 */
|
||||
export function pushTrafficToAiLead(workbenchId: number): Promise<any> {
|
||||
return request(
|
||||
"/v1/workbench/traffic-push-ai-lead",
|
||||
{ workbenchId },
|
||||
"POST",
|
||||
);
|
||||
}
|
||||
|
||||
// 获取流量分发规则详情
|
||||
export function fetchDistributionRuleDetail(id: number): Promise<any> {
|
||||
return request(`/v1/workbench/detail?id=${id}`, {}, "GET");
|
||||
|
||||
@@ -101,11 +101,17 @@ export interface DistributionRule {
|
||||
status: number;
|
||||
autoStart: number;
|
||||
userId: number;
|
||||
planType?: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
config: {
|
||||
id: number;
|
||||
workbenchId: number;
|
||||
planType?: number;
|
||||
targetType?: number;
|
||||
touchkebaoKefuId?: number;
|
||||
sortBy?: string;
|
||||
friendStatusFilter?: number;
|
||||
distributeType: number;
|
||||
maxPerDay: number;
|
||||
timeType: number;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
fetchDistributionRuleList,
|
||||
toggleDistributionRuleStatus,
|
||||
deleteDistributionRule,
|
||||
pushTrafficToAiLead,
|
||||
} from "./api";
|
||||
import type { DistributionRule } from "./data";
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
DeleteOutlined,
|
||||
ClockCircleOutlined,
|
||||
CalendarOutlined,
|
||||
RobotOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import style from "./index.module.scss";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -119,6 +121,10 @@ const TrafficDistributionList: React.FC = () => {
|
||||
await deleteDistributionRule(item.id);
|
||||
message.success("删除成功");
|
||||
handleRefresh();
|
||||
} else if (key === "push-ai-lead") {
|
||||
const res = await pushTrafficToAiLead(item.id);
|
||||
message.success(res?.msg || res?.message || "推送完成");
|
||||
handleRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
message.error("操作失败");
|
||||
@@ -174,37 +180,65 @@ const TrafficDistributionList: React.FC = () => {
|
||||
};
|
||||
|
||||
const renderCard = (item: DistributionRule) => {
|
||||
const isAiLead = (item.config?.targetType ?? 1) === 2;
|
||||
const menuItems = [
|
||||
{
|
||||
key: "edit",
|
||||
icon: <EditOutlined />,
|
||||
label: "编辑计划",
|
||||
disabled: menuLoadingId === item.id,
|
||||
},
|
||||
...(isAiLead
|
||||
? [
|
||||
{
|
||||
key: "push-ai-lead",
|
||||
icon: <RobotOutlined />,
|
||||
label: "立即推送到触客宝 AI 获客",
|
||||
disabled: menuLoadingId === item.id,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "pause",
|
||||
icon: <PauseOutlined />,
|
||||
label: item.status === 1 ? "暂停计划" : "启用计划",
|
||||
disabled: menuLoadingId === item.id,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
icon: <DeleteOutlined />,
|
||||
label: "删除计划",
|
||||
disabled: menuLoadingId === item.id,
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
const menu = (
|
||||
<Menu onClick={({ key }) => handleMenuClick(key, item)}>
|
||||
<Menu.Item
|
||||
key="edit"
|
||||
icon={<EditOutlined />}
|
||||
disabled={menuLoadingId === item.id}
|
||||
>
|
||||
编辑计划
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="pause"
|
||||
icon={<PauseOutlined />}
|
||||
disabled={menuLoadingId === item.id}
|
||||
>
|
||||
{item.status === 1 ? "暂停计划" : "启用计划"}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="delete"
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={menuLoadingId === item.id}
|
||||
danger
|
||||
>
|
||||
删除计划
|
||||
</Menu.Item>
|
||||
{menuItems.map(m => (
|
||||
<Menu.Item
|
||||
key={m.key}
|
||||
icon={m.icon}
|
||||
disabled={m.disabled}
|
||||
danger={!!(m as { danger?: boolean }).danger}
|
||||
>
|
||||
{m.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<CkbListCard key={item.id} className={style.ruleCard}>
|
||||
<div className={style.ruleHeader}>
|
||||
<span className={style.ruleName}>{item.name}</span>
|
||||
<span className={style.ruleName}>
|
||||
{item.name}
|
||||
{isAiLead && (
|
||||
<Tag color="purple" style={{ marginLeft: 8, fontSize: 11 }}>
|
||||
AI获客
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
<div className={style.ruleStatus}>
|
||||
<Tag
|
||||
color={statusMap[item.status]?.color || "default"}
|
||||
@@ -223,27 +257,7 @@ const TrafficDistributionList: React.FC = () => {
|
||||
{/* Dropdown 只允许传递单一元素给 menu 属性 */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "edit",
|
||||
icon: <EditOutlined />,
|
||||
label: "编辑计划",
|
||||
disabled: menuLoadingId === item.id,
|
||||
},
|
||||
{
|
||||
key: "pause",
|
||||
icon: <PauseOutlined />,
|
||||
label: item.status === 1 ? "暂停计划" : "启用计划",
|
||||
disabled: menuLoadingId === item.id,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
icon: <DeleteOutlined />,
|
||||
label: "删除计划",
|
||||
disabled: menuLoadingId === item.id,
|
||||
danger: true,
|
||||
},
|
||||
],
|
||||
items: menuItems,
|
||||
onClick: ({ key }) => handleMenuClick(key, item),
|
||||
}}
|
||||
trigger={["click"]}
|
||||
|
||||
@@ -102,7 +102,7 @@ if (typeof window !== "undefined") {
|
||||
unobserve: function () {},
|
||||
disconnect: function () {},
|
||||
};
|
||||
};
|
||||
} as unknown as typeof window.IntersectionObserver;
|
||||
}
|
||||
|
||||
// 确保ResizeObserver在全局可用
|
||||
@@ -113,7 +113,7 @@ if (typeof window !== "undefined") {
|
||||
unobserve: function () {},
|
||||
disconnect: function () {},
|
||||
};
|
||||
};
|
||||
} as unknown as typeof window.ResizeObserver;
|
||||
}
|
||||
|
||||
// 确保MutationObserver在全局可用
|
||||
@@ -123,7 +123,7 @@ if (typeof window !== "undefined") {
|
||||
observe: function () {},
|
||||
disconnect: function () {},
|
||||
};
|
||||
};
|
||||
} as unknown as typeof window.MutationObserver;
|
||||
}
|
||||
|
||||
// 确保Performance API在全局可用
|
||||
@@ -132,7 +132,7 @@ if (typeof window !== "undefined") {
|
||||
now: function () {
|
||||
return Date.now();
|
||||
},
|
||||
};
|
||||
} as unknown as Performance;
|
||||
}
|
||||
|
||||
// 确保URLSearchParams在全局可用
|
||||
@@ -158,7 +158,7 @@ if (typeof window !== "undefined") {
|
||||
this.signal.aborted = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
} as unknown as typeof window.AbortController;
|
||||
}
|
||||
|
||||
// 确保AbortSignal在全局可用
|
||||
@@ -171,6 +171,6 @@ if (typeof window !== "undefined") {
|
||||
removeEventListener: function () {},
|
||||
};
|
||||
},
|
||||
};
|
||||
} as unknown as typeof window.AbortSignal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Home from "@/pages/mobile/home/index";
|
||||
import TodayLeads from "@/pages/mobile/home/today-leads/index";
|
||||
import Init from "@/pages/iframe/init";
|
||||
|
||||
const routes = [
|
||||
@@ -8,6 +9,11 @@ const routes = [
|
||||
element: <Home />,
|
||||
auth: true, // 需要登录
|
||||
},
|
||||
{
|
||||
path: "/home/today-leads",
|
||||
element: <TodayLeads />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/init",
|
||||
element: <Init />,
|
||||
|
||||
@@ -187,7 +187,7 @@ export function createPersistStore<T>(
|
||||
migrate,
|
||||
onRehydrateStorage,
|
||||
skipHydration,
|
||||
} as PersistOptions<T>),
|
||||
} as unknown as PersistOptions<T>),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
// src/store/examples.ts
|
||||
// 高级持久化使用示例
|
||||
|
||||
import {
|
||||
createLocalStorageStore,
|
||||
createSessionStorageStore,
|
||||
createEncryptedStore,
|
||||
createCompressedStore,
|
||||
createTTLStore,
|
||||
createPersistStore,
|
||||
} from "./createPersistStore";
|
||||
|
||||
// 示例1: 基础localStorage持久化store
|
||||
interface BasicState {
|
||||
count: number;
|
||||
name: string;
|
||||
increment: () => void;
|
||||
setName: (name: string) => void;
|
||||
}
|
||||
|
||||
export const useBasicStore = createLocalStorageStore<BasicState>(
|
||||
set => ({
|
||||
count: 0,
|
||||
name: "",
|
||||
increment: () => set(state => ({ count: state.count + 1 })),
|
||||
setName: name => set({ name }),
|
||||
}),
|
||||
"basic-store",
|
||||
state => ({ count: state.count, name: state.name }), // 只持久化数据,不持久化方法
|
||||
);
|
||||
|
||||
// 示例2: sessionStorage持久化store(会话级存储)
|
||||
interface SessionState {
|
||||
sessionId: string;
|
||||
lastActivity: number;
|
||||
updateActivity: () => void;
|
||||
}
|
||||
|
||||
export const useSessionStore = createSessionStorageStore<SessionState>(
|
||||
set => ({
|
||||
sessionId: "",
|
||||
lastActivity: Date.now(),
|
||||
updateActivity: () => set({ lastActivity: Date.now() }),
|
||||
}),
|
||||
"session-store",
|
||||
state => ({ sessionId: state.sessionId, lastActivity: state.lastActivity }),
|
||||
);
|
||||
|
||||
// 示例3: 加密持久化store(敏感数据)
|
||||
interface SensitiveState {
|
||||
apiKey: string;
|
||||
privateData: any;
|
||||
setApiKey: (key: string) => void;
|
||||
setPrivateData: (data: any) => void;
|
||||
}
|
||||
|
||||
export const useSensitiveStore = createEncryptedStore<SensitiveState>(
|
||||
set => ({
|
||||
apiKey: "",
|
||||
privateData: null,
|
||||
setApiKey: key => set({ apiKey: key }),
|
||||
setPrivateData: data => set({ privateData: data }),
|
||||
}),
|
||||
"sensitive-store",
|
||||
"your-secret-key-here", // 加密密钥
|
||||
state => ({ apiKey: state.apiKey, privateData: state.privateData }),
|
||||
);
|
||||
|
||||
// 示例4: 压缩持久化store(大数据)
|
||||
interface LargeDataState {
|
||||
largeArray: number[];
|
||||
largeObject: Record<string, any>;
|
||||
addToArray: (item: number) => void;
|
||||
updateObject: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
export const useLargeDataStore = createCompressedStore<LargeDataState>(
|
||||
set => ({
|
||||
largeArray: [],
|
||||
largeObject: {},
|
||||
addToArray: item =>
|
||||
set(state => ({ largeArray: [...state.largeArray, item] })),
|
||||
updateObject: (key, value) =>
|
||||
set(state => ({ largeObject: { ...state.largeObject, [key]: value } })),
|
||||
}),
|
||||
"large-data-store",
|
||||
state => ({ largeArray: state.largeArray, largeObject: state.largeObject }),
|
||||
);
|
||||
|
||||
// 示例5: TTL持久化store(临时数据,自动过期)
|
||||
interface TemporaryState {
|
||||
tempData: any;
|
||||
expiresAt: number;
|
||||
setTempData: (data: any, ttlMs: number) => void;
|
||||
}
|
||||
|
||||
export const useTemporaryStore = createTTLStore<TemporaryState>(
|
||||
set => ({
|
||||
tempData: null,
|
||||
expiresAt: 0,
|
||||
setTempData: (data, ttlMs) =>
|
||||
set({ tempData: data, expiresAt: Date.now() + ttlMs }),
|
||||
}),
|
||||
"temporary-store",
|
||||
24 * 60 * 60 * 1000, // 24小时TTL
|
||||
state => ({ tempData: state.tempData, expiresAt: state.expiresAt }),
|
||||
);
|
||||
|
||||
// 示例6: 高级配置持久化store
|
||||
interface AdvancedState {
|
||||
complexData: {
|
||||
nested: {
|
||||
deep: {
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
array: any[];
|
||||
};
|
||||
metadata: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
updateComplexData: (data: any) => void;
|
||||
updateMetadata: (metadata: any) => void;
|
||||
}
|
||||
|
||||
export const useAdvancedStore = createPersistStore<AdvancedState>(
|
||||
set => ({
|
||||
complexData: {
|
||||
nested: {
|
||||
deep: {
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
array: [],
|
||||
},
|
||||
metadata: {
|
||||
version: "1.0.0",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
updateComplexData: data =>
|
||||
set(state => ({
|
||||
complexData: { ...state.complexData, ...data },
|
||||
metadata: { ...state.metadata, updatedAt: Date.now() },
|
||||
})),
|
||||
updateMetadata: metadata =>
|
||||
set(state => ({
|
||||
metadata: { ...state.metadata, ...metadata, updatedAt: Date.now() },
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "advanced-store",
|
||||
partialize: state => ({
|
||||
complexData: state.complexData,
|
||||
metadata: state.metadata,
|
||||
}),
|
||||
version: 2,
|
||||
migrate: (persistedState, version) => {
|
||||
if (version === 1) {
|
||||
// 从版本1迁移到版本2
|
||||
return {
|
||||
...persistedState,
|
||||
metadata: {
|
||||
version: "2.0.0",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return persistedState;
|
||||
},
|
||||
onRehydrateStorage: () => state => {
|
||||
console.log("Advanced store hydrated:", state);
|
||||
},
|
||||
compress: true, // 启用压缩
|
||||
ttl: 7 * 24 * 60 * 60 * 1000, // 7天TTL
|
||||
},
|
||||
);
|
||||
|
||||
// 示例7: 购物车持久化store(电商场景)
|
||||
interface CartState {
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
total: number;
|
||||
addItem: (item: any) => void;
|
||||
removeItem: (id: string) => void;
|
||||
updateQuantity: (id: string, quantity: number) => void;
|
||||
clearCart: () => void;
|
||||
calculateTotal: () => void;
|
||||
}
|
||||
|
||||
export const useCartStore = createLocalStorageStore<CartState>(
|
||||
(set, get) => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
addItem: item => {
|
||||
const state = get();
|
||||
const existingItem = state.items.find(i => i.id === item.id);
|
||||
|
||||
if (existingItem) {
|
||||
set({
|
||||
items: state.items.map(i =>
|
||||
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
set({ items: [...state.items, { ...item, quantity: 1 }] });
|
||||
}
|
||||
|
||||
get().calculateTotal();
|
||||
},
|
||||
removeItem: id => {
|
||||
const state = get();
|
||||
set({ items: state.items.filter(i => i.id !== id) });
|
||||
get().calculateTotal();
|
||||
},
|
||||
updateQuantity: (id, quantity) => {
|
||||
const state = get();
|
||||
set({
|
||||
items: state.items.map(i =>
|
||||
i.id === id ? { ...i, quantity: Math.max(0, quantity) } : i,
|
||||
),
|
||||
});
|
||||
get().calculateTotal();
|
||||
},
|
||||
clearCart: () => set({ items: [], total: 0 }),
|
||||
calculateTotal: () => {
|
||||
const state = get();
|
||||
const total = state.items.reduce(
|
||||
(sum, item) => sum + item.price * item.quantity,
|
||||
0,
|
||||
);
|
||||
set({ total });
|
||||
},
|
||||
}),
|
||||
"cart-store",
|
||||
state => ({ items: state.items, total: state.total }),
|
||||
{
|
||||
ttl: 30 * 24 * 60 * 60 * 1000, // 30天TTL
|
||||
},
|
||||
);
|
||||
|
||||
// 示例8: 用户偏好设置store
|
||||
interface PreferencesState {
|
||||
theme: "light" | "dark" | "auto";
|
||||
language: string;
|
||||
notifications: {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
sms: boolean;
|
||||
};
|
||||
privacy: {
|
||||
shareData: boolean;
|
||||
analytics: boolean;
|
||||
};
|
||||
setTheme: (theme: "light" | "dark" | "auto") => void;
|
||||
setLanguage: (language: string) => void;
|
||||
toggleNotification: (type: keyof PreferencesState["notifications"]) => void;
|
||||
updatePrivacy: (settings: Partial<PreferencesState["privacy"]>) => void;
|
||||
}
|
||||
|
||||
export const usePreferencesStore = createEncryptedStore<PreferencesState>(
|
||||
set => ({
|
||||
theme: "auto",
|
||||
language: "zh-CN",
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
sms: false,
|
||||
},
|
||||
privacy: {
|
||||
shareData: false,
|
||||
analytics: true,
|
||||
},
|
||||
setTheme: theme => set({ theme }),
|
||||
setLanguage: language => set({ language }),
|
||||
toggleNotification: type =>
|
||||
set(state => ({
|
||||
notifications: {
|
||||
...state.notifications,
|
||||
[type]: !state.notifications[type],
|
||||
},
|
||||
})),
|
||||
updatePrivacy: settings =>
|
||||
set(state => ({
|
||||
privacy: { ...state.privacy, ...settings },
|
||||
})),
|
||||
}),
|
||||
"preferences-store",
|
||||
"user-preferences-key",
|
||||
state => ({
|
||||
theme: state.theme,
|
||||
language: state.language,
|
||||
notifications: state.notifications,
|
||||
privacy: state.privacy,
|
||||
}),
|
||||
);
|
||||
|
||||
// 使用示例
|
||||
export function useStoreExamples() {
|
||||
// 基础store使用
|
||||
const basic = useBasicStore();
|
||||
|
||||
// 敏感数据store使用
|
||||
const sensitive = useSensitiveStore();
|
||||
|
||||
// 购物车store使用
|
||||
const cart = useCartStore();
|
||||
|
||||
// 偏好设置store使用
|
||||
const preferences = usePreferencesStore();
|
||||
|
||||
return {
|
||||
basic,
|
||||
sensitive,
|
||||
cart,
|
||||
preferences,
|
||||
};
|
||||
}
|
||||
@@ -82,8 +82,8 @@ export const useAppStore = createPersistStore<AppStoreState>(
|
||||
partialize: state => ({
|
||||
app: state.app,
|
||||
}),
|
||||
onRehydrateStorage: () => state => {
|
||||
console.log("App store hydrated:", state);
|
||||
onRehydrateStorage: () => () => {
|
||||
// app store 已从本地存储恢复
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -20,7 +20,6 @@ export const sendMessageToParent = (message: any, type: number) => {
|
||||
window.uni.postMessage({
|
||||
data: params,
|
||||
});
|
||||
console.log("[存客宝]SendMessage=>\n" + JSON.stringify(params));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[存客宝]SendMessage=>\n" + JSON.stringify(params) + "发送失败:",
|
||||
|
||||
@@ -69,3 +69,28 @@ DOUYIN_OAUTH_REDIRECT_URI = http://localhost:8082/v1/frontend/douyin/oauth/callb
|
||||
DOUYIN_OAUTH_SCOPE = user_info,video.create,video.list,item.comment
|
||||
DOUYIN_OAUTH_FRONTEND_SUCCESS = http://localhost:3100/oauth/douyin/callback
|
||||
CUNKEBAO_WEB_URL = http://localhost:3100
|
||||
|
||||
; ========== 第三方登录 OAuth(非支付 · 凭证真源:卡若AI 00_账号与API索引 §三)==========
|
||||
[oauth]
|
||||
; 微信 · 服务号(微信内 H5 网页授权 · 登录首选)
|
||||
wechat_mp_app_id = wx7c0dbf34ddba300d
|
||||
wechat_mp_secret = f865ef18c43dfea6cbe3b1f1aebdb82e
|
||||
; 微信 · 网站应用(PC/外站扫码登录)
|
||||
wechat_web_app_id = wx432c93e275548671
|
||||
wechat_web_secret = 25b7e7fdb7998e5107e242ebb6ddabd0
|
||||
; 微信 · 小程序(与 [weChat] 同源,用于 wx.login)
|
||||
wechat_mini_app_id = wx789850448e26c91d
|
||||
wechat_mini_secret = d18f75b3a3623cb40da05648b08365a1
|
||||
; OAuth 回调(前端页 · BFF 路由开发后对齐)
|
||||
wechat_oauth_redirect_uri = http://localhost:3100/oauth/wechat/callback
|
||||
; 支付宝 OAuth 登录(与 [payment] 商户不同 · 待开放平台创建应用)
|
||||
alipay_oauth_app_id =
|
||||
alipay_oauth_redirect_uri = http://localhost:3100/oauth/alipay/callback
|
||||
; Apple Sign In(待 Apple Developer 配置)
|
||||
apple_client_id =
|
||||
apple_team_id =
|
||||
apple_key_id =
|
||||
apple_private_key_path =
|
||||
; 登录 UX:记住手机号 / DEV 默认预填(非 SIM 自动读取)
|
||||
login_remember_phone = true
|
||||
default_dev_phone = 13779954946
|
||||
|
||||
@@ -16,4 +16,8 @@ Route::group('v1/ai', function () {
|
||||
Route::post('text', 'app\ai\controller\DouBaoAI@text'); // 文本生成
|
||||
Route::post('image', 'app\ai\controller\DouBaoAI@image'); // 图片生成
|
||||
});
|
||||
|
||||
// AI Gateway · 全平台统一出口(§2.5)
|
||||
Route::get('gateway/config', 'app\ai\controller\AiGatewayController@config'); // 脱敏配置(各端只读)
|
||||
Route::post('chat/completions', 'app\ai\controller\AiGatewayController@chat'); // 统一补全
|
||||
})->middleware(['jwt']);
|
||||
95
Server/application/ai/controller/AiGatewayController.php
Normal file
95
Server/application/ai/controller/AiGatewayController.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace app\ai\controller;
|
||||
|
||||
use app\common\service\AiGatewayService;
|
||||
use app\common\util\JwtUtil;
|
||||
use library\ResponseHelper;
|
||||
use think\Controller;
|
||||
|
||||
/**
|
||||
* AI Gateway · 各端统一消费入口(§2.5)
|
||||
*
|
||||
* - GET /v1/ai/gateway/config 脱敏配置(各端只读)
|
||||
* - POST /v1/ai/chat/completions 统一补全(slug / tier? / messages[] / companyId)
|
||||
*/
|
||||
class AiGatewayController extends Controller
|
||||
{
|
||||
/** 脱敏配置(不含 Key) */
|
||||
public function config()
|
||||
{
|
||||
return ResponseHelper::success(AiGatewayService::config());
|
||||
}
|
||||
|
||||
/** 统一补全 */
|
||||
public function chat()
|
||||
{
|
||||
$slug = (string) $this->request->param('slug', '');
|
||||
$tier = (string) $this->request->param('tier', '');
|
||||
$messages = $this->request->param('messages', []);
|
||||
if (!is_array($messages)) {
|
||||
$messages = json_decode((string) $messages, true) ?: [];
|
||||
}
|
||||
$companyId = (int) $this->request->param('companyId', 0);
|
||||
$maxTokens = (int) $this->request->param('maxTokens', 0);
|
||||
|
||||
// companyId 缺省时从 JWT 兜底
|
||||
if ($companyId <= 0) {
|
||||
$companyId = $this->companyIdFromToken();
|
||||
}
|
||||
$userId = $this->userIdFromToken();
|
||||
|
||||
if (empty($messages)) {
|
||||
return ResponseHelper::error('messages 不能为空', 400);
|
||||
}
|
||||
|
||||
$result = AiGatewayService::complete($slug, $messages, $companyId, $userId, $tier, [
|
||||
'maxTokens' => $maxTokens,
|
||||
]);
|
||||
|
||||
if (!$result['ok']) {
|
||||
return ResponseHelper::error($result['error'] ?? 'AI 调用失败', (int) ($result['code'] ?? 500), [
|
||||
'tier' => $result['tier'] ?? '',
|
||||
'balance' => $result['balance'] ?? null,
|
||||
]);
|
||||
}
|
||||
return ResponseHelper::success([
|
||||
'content' => $result['content'],
|
||||
'providerId' => $result['providerId'],
|
||||
'model' => $result['model'],
|
||||
'tier' => $result['tier'],
|
||||
'strategy' => $result['strategy'] ?? '',
|
||||
'deducted' => $result['deducted'],
|
||||
'balance' => $result['balance'],
|
||||
'usage' => $result['usage'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function companyIdFromToken(): int
|
||||
{
|
||||
$payload = $this->jwtPayload();
|
||||
if (!$payload) {
|
||||
return 0;
|
||||
}
|
||||
return (int) (is_array($payload) ? ($payload['companyId'] ?? 0) : ($payload->companyId ?? 0));
|
||||
}
|
||||
|
||||
protected function userIdFromToken(): int
|
||||
{
|
||||
$payload = $this->jwtPayload();
|
||||
if (!$payload) {
|
||||
return 0;
|
||||
}
|
||||
return (int) (is_array($payload) ? ($payload['id'] ?? 0) : ($payload->id ?? 0));
|
||||
}
|
||||
|
||||
protected function jwtPayload()
|
||||
{
|
||||
try {
|
||||
$token = JwtUtil::getRequestToken();
|
||||
return $token ? JwtUtil::verifyToken($token) : false;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,9 +217,13 @@ Route::group('v1/', function () {
|
||||
Route::post('mark-read', 'app\chukebao\controller\AiLeadController@markRead');
|
||||
Route::post('add-tag', 'app\chukebao\controller\AiLeadController@addTag');
|
||||
Route::post('transfer', 'app\chukebao\controller\AiLeadController@transfer');
|
||||
Route::get('add-friend-devices', 'app\chukebao\controller\AiLeadController@addFriendDevices');
|
||||
Route::post('add-wechat', 'app\chukebao\controller\AiLeadController@addWechat');
|
||||
Route::get('webhook-config', 'app\chukebao\controller\AiLeadController@webhookConfig');
|
||||
});
|
||||
|
||||
Route::get('project-context', 'app\chukebao\controller\AiLeadController@projectContext');
|
||||
|
||||
// 模块 B · 客户资料池 / 流量池
|
||||
Route::group('traffic-pool/', function () {
|
||||
Route::get('options', 'app\chukebao\controller\TrafficPoolKefuController@getOptions');
|
||||
@@ -254,6 +258,7 @@ Route::group('v1/', function () {
|
||||
Route::get('wechat-account/transfer/status', 'app\chukebao\controller\TransferController@status');
|
||||
Route::post('session/transfer/apply', 'app\chukebao\controller\TransferController@applySession');
|
||||
Route::post('transfer/apply', 'app\chukebao\controller\TransferController@apply');
|
||||
Route::get('transfer/list', 'app\chukebao\controller\TransferController@list');
|
||||
Route::get('wechatGroup/transfer/preview', 'app\chukebao\controller\TransferController@groupPreview');
|
||||
Route::get('wechat-account/<id>/manage', 'app\chukebao\controller\WechatAccountController@manage');
|
||||
Route::get('wechat-account/<id>/valuation', 'app\chukebao\controller\WechatAccountController@valuation');
|
||||
|
||||
@@ -26,6 +26,7 @@ class AccountsController extends BaseController
|
||||
$page = max(1, intval($this->request->param('page', 1)));
|
||||
$limit = max(1, intval($this->request->param('limit', 10)));
|
||||
$keyword = trim((string)$this->request->param('keyword', ''));
|
||||
$scope = trim((string)$this->request->param('scope/s', ''));
|
||||
|
||||
$query = Db::table('s2_company_account')
|
||||
->alias('a')
|
||||
@@ -37,6 +38,12 @@ class AccountsController extends BaseController
|
||||
->whereNotLike('a.userName', '%_offline')
|
||||
->whereNotLike('a.userName', '%_delete');
|
||||
|
||||
if ($scope === 'project_bound') {
|
||||
$query->where('u.companyId', $companyId)
|
||||
->where('u.status', 1)
|
||||
->where('u.s2_accountId', '>', 0);
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($subQuery) use ($keyword) {
|
||||
$likeKeyword = '%' . $keyword . '%';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\chukebao\service\CompanyWechatScopeService;
|
||||
use app\common\model\TrafficPoolV2;
|
||||
use app\common\model\TrafficPoolCompany;
|
||||
use app\common\model\TrafficPoolTag;
|
||||
@@ -16,6 +17,43 @@ use think\Db;
|
||||
*/
|
||||
class AiLeadController extends BaseController
|
||||
{
|
||||
/**
|
||||
* 同一触客宝账号(s2_accountId)可能在多 company 下有多条 ck_users;
|
||||
* 流量分发推送的 kefuId 与当前登录 id 不一致时,仍应可见同账号客资。
|
||||
*
|
||||
* @return array{kefuIds:int[],companyIds:int[]}
|
||||
*/
|
||||
private function resolveKefuScope(int $companyId, int $kefuId): array
|
||||
{
|
||||
$kefuIds = [$kefuId];
|
||||
$companyIds = [$companyId];
|
||||
if ($kefuId <= 0) {
|
||||
return [
|
||||
'kefuIds' => $kefuIds,
|
||||
'companyIds' => $companyIds,
|
||||
];
|
||||
}
|
||||
$s2AccountId = (int)Db::name('users')->where('id', $kefuId)->value('s2_accountId');
|
||||
if ($s2AccountId <= 0) {
|
||||
return [
|
||||
'kefuIds' => $kefuIds,
|
||||
'companyIds' => $companyIds,
|
||||
];
|
||||
}
|
||||
$related = Db::name('users')
|
||||
->where('s2_accountId', $s2AccountId)
|
||||
->field('id,companyId')
|
||||
->select();
|
||||
foreach ($related ?: [] as $row) {
|
||||
$kefuIds[] = (int)$row['id'];
|
||||
$companyIds[] = (int)$row['companyId'];
|
||||
}
|
||||
return [
|
||||
'kefuIds' => array_values(array_unique(array_filter($kefuIds))),
|
||||
'companyIds' => array_values(array_unique(array_filter($companyIds))),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* best-effort:将标签写入流量池标签表(ck_traffic_pool_tag)
|
||||
*/
|
||||
@@ -128,16 +166,379 @@ class AiLeadController extends BaseController
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
}
|
||||
|
||||
private static function decodeRow(array $row): array
|
||||
private static function decodeRow(array $row, ?array $poolMeta = null, array $products = []): array
|
||||
{
|
||||
$row['tags'] = !empty($row['tags']) ? (json_decode($row['tags'], true) ?: []) : [];
|
||||
$row['behaviorTimeline'] = !empty($row['behaviorTimeline'])
|
||||
? (json_decode($row['behaviorTimeline'], true) ?: [])
|
||||
: [];
|
||||
$row['wechatAdded'] = (bool)$row['wechatAdded'];
|
||||
$row['updatedAt'] = (int)($row['updateTime'] ?? 0);
|
||||
return self::enrichSortMeta($row, $poolMeta, $products);
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private static function loadPoolMetaMap(array $poolCompanyIds, int $companyId): array
|
||||
{
|
||||
$poolCompanyIds = array_values(array_unique(array_filter(array_map('intval', $poolCompanyIds))));
|
||||
if (empty($poolCompanyIds)) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('traffic_pool_company')->alias('tpc')
|
||||
->join('traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT')
|
||||
->whereIn('tpc.id', $poolCompanyIds)
|
||||
->where('tpc.companyId', $companyId)
|
||||
->field('tpc.id,tpc.remark,tpc.totalOrderCount,tpc.phone as poolPhone,tp.wechatId,tp.wechatAlias')
|
||||
->select();
|
||||
$map = [];
|
||||
foreach ($rows ?: [] as $r) {
|
||||
$map[(int)$r['id']] = $r;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private static function loadProjectProducts(int $companyId): array
|
||||
{
|
||||
$products = [];
|
||||
$rows = Db::name('customer_acquisition_task')
|
||||
->where('companyId', $companyId)
|
||||
->where('deleteTime', 0)
|
||||
->order('id desc')
|
||||
->limit(30)
|
||||
->column('sceneConf');
|
||||
foreach ($rows ?: [] as $conf) {
|
||||
$scene = is_array($conf) ? $conf : (json_decode((string)$conf, true) ?: []);
|
||||
if (!is_array($scene)) {
|
||||
continue;
|
||||
}
|
||||
foreach (['poolKeywords', 'productWords', 'products'] as $key) {
|
||||
if (empty($scene[$key])) {
|
||||
continue;
|
||||
}
|
||||
$items = $scene[$key];
|
||||
if (!is_array($items)) {
|
||||
$items = preg_split('/[,,、]/u', (string)$items);
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
$name = is_string($item) ? trim($item) : trim((string)($item['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$products[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($products)) {
|
||||
$products = ['一键宏', '魔兽一键宏', 'iOS一键宏'];
|
||||
}
|
||||
return array_values(array_unique($products));
|
||||
}
|
||||
|
||||
/** 项目上下文(产品词 + 备注模板) */
|
||||
public static function loadProjectContext(int $companyId): array
|
||||
{
|
||||
$remarkFormat = '';
|
||||
$rows = Db::name('customer_acquisition_task')
|
||||
->where('companyId', $companyId)
|
||||
->where('deleteTime', 0)
|
||||
->order('id desc')
|
||||
->limit(10)
|
||||
->column('sceneConf');
|
||||
foreach ($rows ?: [] as $conf) {
|
||||
$scene = is_array($conf) ? $conf : (json_decode((string)$conf, true) ?: []);
|
||||
if (!empty($scene['remarkFormat']) && $remarkFormat === '') {
|
||||
$remarkFormat = (string)$scene['remarkFormat'];
|
||||
}
|
||||
}
|
||||
return [
|
||||
'products' => self::loadProjectProducts($companyId),
|
||||
'remarkFormat' => $remarkFormat !== '' ? $remarkFormat : '手机号+{product}',
|
||||
];
|
||||
}
|
||||
|
||||
private static function sanitizePlanName(string $planName): string
|
||||
{
|
||||
$parts = array_filter(array_map('trim', explode('|', $planName)));
|
||||
$out = [];
|
||||
foreach ($parts as $p) {
|
||||
if ($p === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/webhook/i', $p)) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^流量分发\s*\d/u', $p)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = $p;
|
||||
}
|
||||
return implode(' · ', $out);
|
||||
}
|
||||
|
||||
private static function isValidProjectRemark(string $remark): bool
|
||||
{
|
||||
if ($remark === '') {
|
||||
return false;
|
||||
}
|
||||
$invalid = ['-', '未填写', '被对方删除', '未命名'];
|
||||
return !in_array($remark, $invalid, true);
|
||||
}
|
||||
|
||||
/** 解析行为轨迹时间(m-d H:i 或标准时间串) */
|
||||
private static function parseBehaviorTime(string $time): int
|
||||
{
|
||||
$time = trim($time);
|
||||
if ($time === '') {
|
||||
return 0;
|
||||
}
|
||||
if (preg_match('/^(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})$/', $time, $m)) {
|
||||
$year = (int)date('Y');
|
||||
$ts = strtotime(sprintf('%d-%02d-%02d %02d:%02d:00', $year, (int)$m[1], (int)$m[2], (int)$m[3], (int)$m[4]));
|
||||
return $ts ?: 0;
|
||||
}
|
||||
$ts = strtotime($time);
|
||||
return $ts ?: 0;
|
||||
}
|
||||
|
||||
/** 排序元数据 v2:买过/备注/产品/D0/联系方式 + 行为/触达/获客/RFM */
|
||||
private static function enrichSortMeta(array $row, ?array $poolMeta = null, array $products = []): array
|
||||
{
|
||||
if ($poolMeta) {
|
||||
$row['remark'] = trim((string)($poolMeta['remark'] ?? ''));
|
||||
$wx = trim((string)($poolMeta['wechatId'] ?? ''));
|
||||
$alias = trim((string)($poolMeta['wechatAlias'] ?? ''));
|
||||
$row['wechatId'] = $wx !== '' ? $wx : $alias;
|
||||
if (empty($row['phone']) && !empty($poolMeta['poolPhone'])) {
|
||||
$row['phone'] = trim((string)$poolMeta['poolPhone']);
|
||||
}
|
||||
if ((int)($poolMeta['totalOrderCount'] ?? 0) > 0) {
|
||||
$row['hasPurchased'] = true;
|
||||
}
|
||||
} else {
|
||||
$row['remark'] = trim((string)($row['remark'] ?? ''));
|
||||
$row['wechatId'] = trim((string)($row['wechatId'] ?? ''));
|
||||
}
|
||||
|
||||
$phone = trim((string)($row['phone'] ?? ''));
|
||||
$unionId = trim((string)($row['unionId'] ?? ''));
|
||||
$openId = trim((string)($row['openId'] ?? ''));
|
||||
$wechatId = trim((string)($row['wechatId'] ?? ''));
|
||||
$hasWechatId = $wechatId !== '' || $unionId !== '' || $openId !== '';
|
||||
$row['hasContactD0'] = $phone !== '' && $hasWechatId;
|
||||
|
||||
$tags = is_array($row['tags']) ? $row['tags'] : [];
|
||||
if (empty($row['hasPurchased'])) {
|
||||
$row['hasPurchased'] = false;
|
||||
$purchasedKeywords = ['购买', '已购', '买过', '成交', '下单', '老客户', '付费'];
|
||||
foreach ($tags as $tag) {
|
||||
$tag = (string)$tag;
|
||||
foreach ($purchasedKeywords as $kw) {
|
||||
if ($tag !== '' && mb_strpos($tag, $kw) !== false) {
|
||||
$row['hasPurchased'] = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$row['hasPurchased']) {
|
||||
foreach ($row['behaviorTimeline'] ?? [] as $behavior) {
|
||||
$action = (string)($behavior['action'] ?? '');
|
||||
if ($action !== '' && preg_match('/下单|购买|成交/u', $action)) {
|
||||
$row['hasPurchased'] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$remark = trim((string)($row['remark'] ?? ''));
|
||||
$row['hasProjectRemark'] = self::isValidProjectRemark($remark);
|
||||
|
||||
$matched = [];
|
||||
$haystack = mb_strtolower(
|
||||
($row['nickname'] ?? '') . ' ' . $remark . ' ' . ($row['poolKeywordsText'] ?? '') . ' ' . implode(' ', $tags)
|
||||
);
|
||||
foreach ($products as $prod) {
|
||||
$prod = trim((string)$prod);
|
||||
if ($prod !== '' && mb_stripos($haystack, $prod) !== false) {
|
||||
$matched[] = $prod;
|
||||
}
|
||||
}
|
||||
$row['matchedProducts'] = array_values(array_unique($matched));
|
||||
$row['hasProductMatch'] = !empty($matched);
|
||||
|
||||
$lastBehaviorTs = 0;
|
||||
foreach ($row['behaviorTimeline'] ?? [] as $behavior) {
|
||||
$ts = self::parseBehaviorTime((string)($behavior['time'] ?? ''));
|
||||
if ($ts > $lastBehaviorTs) {
|
||||
$lastBehaviorTs = $ts;
|
||||
}
|
||||
}
|
||||
$row['lastBehaviorTs'] = $lastBehaviorTs;
|
||||
$row['lastTouchTs'] = max($lastBehaviorTs, (int)($row['updateTime'] ?? 0));
|
||||
|
||||
$tier = 0;
|
||||
if (!empty($row['hasPurchased'])) {
|
||||
$tier += 4;
|
||||
}
|
||||
if (!empty($row['hasProjectRemark'])) {
|
||||
$tier += 3;
|
||||
}
|
||||
if (!empty($row['hasProductMatch'])) {
|
||||
$tier += 2;
|
||||
}
|
||||
if (!empty($row['hasContactD0'])) {
|
||||
$tier += 2;
|
||||
}
|
||||
if ($phone !== '' || $hasWechatId) {
|
||||
$tier += 1;
|
||||
}
|
||||
$row['priorityTier'] = $tier;
|
||||
|
||||
if (!empty($row['createTime'])) {
|
||||
$row['acquireTimeLabel'] = date('Y-m-d H:i', (int)$row['createTime']);
|
||||
} else {
|
||||
$row['acquireTimeLabel'] = '';
|
||||
}
|
||||
$row['planNameDisplay'] = self::sanitizePlanName((string)($row['planName'] ?? ''));
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** AI 获客列表 · 同手机号/同人合并为一条(最新为主 · 轨迹/来源累加) */
|
||||
private static function dedupeLeadRowsByContact(array $rows): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($rows as $row) {
|
||||
$digits = preg_replace('/\D/', '', (string)($row['phone'] ?? ''));
|
||||
if (strlen($digits) >= 11) {
|
||||
$key = 'p:' . substr($digits, -11);
|
||||
} else {
|
||||
$uk = trim((string)($row['userKey'] ?? ''));
|
||||
$key = $uk !== '' ? 'u:' . $uk : 'id:' . (string)($row['id'] ?? '');
|
||||
}
|
||||
$groups[$key][] = $row;
|
||||
}
|
||||
|
||||
$merged = [];
|
||||
foreach ($groups as $group) {
|
||||
usort($group, function ($a, $b) {
|
||||
return ((int)($b['updateTime'] ?? 0)) <=> ((int)($a['updateTime'] ?? 0));
|
||||
});
|
||||
$primary = $group[0];
|
||||
$primary['mergedCount'] = count($group);
|
||||
if (count($group) > 1) {
|
||||
$plans = [];
|
||||
$tags = is_array($primary['tags'] ?? null) ? $primary['tags'] : [];
|
||||
$timeline = is_array($primary['behaviorTimeline'] ?? null) ? $primary['behaviorTimeline'] : [];
|
||||
foreach ($group as $item) {
|
||||
$pn = trim((string)($item['planName'] ?? ''));
|
||||
if ($pn !== '') {
|
||||
$plans[] = $pn;
|
||||
}
|
||||
if (!empty($item['tags']) && is_array($item['tags'])) {
|
||||
$tags = array_merge($tags, $item['tags']);
|
||||
}
|
||||
if (!empty($item['behaviorTimeline']) && is_array($item['behaviorTimeline'])) {
|
||||
$timeline = array_merge($timeline, $item['behaviorTimeline']);
|
||||
}
|
||||
}
|
||||
$plans = array_values(array_unique(array_filter($plans)));
|
||||
$primary['planName'] = implode('|', $plans);
|
||||
$primary['tags'] = array_values(array_unique(array_filter(array_map('strval', $tags))));
|
||||
$seen = [];
|
||||
$dedupTimeline = [];
|
||||
foreach ($timeline as $b) {
|
||||
if (!is_array($b)) {
|
||||
continue;
|
||||
}
|
||||
$sig = ($b['time'] ?? '') . '|' . ($b['action'] ?? '');
|
||||
if (isset($seen[$sig])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$sig] = true;
|
||||
$dedupTimeline[] = $b;
|
||||
}
|
||||
usort($dedupTimeline, function ($a, $b) {
|
||||
return self::parseBehaviorTime((string)($b['time'] ?? ''))
|
||||
<=> self::parseBehaviorTime((string)($a['time'] ?? ''));
|
||||
});
|
||||
$primary['behaviorTimeline'] = $dedupTimeline;
|
||||
$primary['planNameDisplay'] = self::sanitizePlanName((string)$primary['planName']);
|
||||
} else {
|
||||
$primary['mergedCount'] = 1;
|
||||
}
|
||||
$merged[] = $primary;
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/** Tab 计数(与列表同口径 · 按手机号去重后统计) */
|
||||
private function buildStatusCountsDeduped(array $scope): array
|
||||
{
|
||||
$rows = Db::name('ai_lead')
|
||||
->whereIn('companyId', $scope['companyIds'])
|
||||
->where(function ($q) use ($scope) {
|
||||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||||
})
|
||||
->select();
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$products = self::loadProjectProducts($companyId);
|
||||
$poolMetaMap = self::loadPoolMetaMap(array_column($rows ?: [], 'poolId'), $companyId);
|
||||
$decoded = array_map(function ($row) use ($poolMetaMap, $products) {
|
||||
$meta = $poolMetaMap[(int)($row['poolId'] ?? 0)] ?? null;
|
||||
return self::decodeRow($row, $meta, $products);
|
||||
}, $rows ?: []);
|
||||
$deduped = self::dedupeLeadRowsByContact($decoded);
|
||||
|
||||
$all = count($deduped);
|
||||
$unadded = 0;
|
||||
$added = 0;
|
||||
$expired = 0;
|
||||
foreach ($deduped as $r) {
|
||||
if (!empty($r['wechatAdded'])) {
|
||||
$added++;
|
||||
} elseif (($r['status'] ?? '') === 'expired') {
|
||||
$expired++;
|
||||
} else {
|
||||
$unadded++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'all' => $all,
|
||||
'unadded' => $unadded,
|
||||
'added' => $added,
|
||||
'expired' => $expired,
|
||||
];
|
||||
}
|
||||
|
||||
/** AI 获客列表排序:D0/买过优先 → 行为日期 → 最近触达 → 获客时间 → RFM */
|
||||
private static function sortLeadRows(array $rows): array
|
||||
{
|
||||
usort($rows, function ($a, $b) {
|
||||
$tierA = (int)($a['priorityTier'] ?? 0);
|
||||
$tierB = (int)($b['priorityTier'] ?? 0);
|
||||
if ($tierB !== $tierA) {
|
||||
return $tierB <=> $tierA;
|
||||
}
|
||||
$cmp = ((int)($b['lastBehaviorTs'] ?? 0)) <=> ((int)($a['lastBehaviorTs'] ?? 0));
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
$cmp = ((int)($b['lastTouchTs'] ?? 0)) <=> ((int)($a['lastTouchTs'] ?? 0));
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
$cmp = ((int)($b['createTime'] ?? 0)) <=> ((int)($a['createTime'] ?? 0));
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
return ((int)($b['rfmScore'] ?? 0)) <=> ((int)($a['rfmScore'] ?? 0));
|
||||
});
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 入站 Webhook(存客宝 Server 推送,非客服 JWT)
|
||||
* POST /v1/kefu/ai-lead/webhook
|
||||
@@ -202,6 +603,21 @@ class AiLeadController extends BaseController
|
||||
return ['error' => 'userKey 缺失(需 unionId/openId/phone 任一)'];
|
||||
}
|
||||
|
||||
// 同手机号并入一条(避免列表重复展示)
|
||||
$exist = null;
|
||||
if ($phone !== '') {
|
||||
$exist = Db::name('ai_lead')
|
||||
->where('companyId', $companyId)
|
||||
->where('phone', $phone)
|
||||
->find();
|
||||
}
|
||||
if (!$exist) {
|
||||
$exist = Db::name('ai_lead')
|
||||
->where('companyId', $companyId)
|
||||
->where('userKey', $userKey)
|
||||
->find();
|
||||
}
|
||||
|
||||
$tags = $body['tags'] ?? [];
|
||||
if (is_string($tags)) {
|
||||
$tags = array_filter(array_map('trim', explode(',', $tags)));
|
||||
@@ -216,11 +632,6 @@ class AiLeadController extends BaseController
|
||||
// 绑定流量池/客户资料池(同源),回填 poolId(poolCompanyId)
|
||||
$poolId = self::ensurePoolId($companyId, $body);
|
||||
|
||||
$exist = Db::name('ai_lead')
|
||||
->where('companyId', $companyId)
|
||||
->where('userKey', $userKey)
|
||||
->find();
|
||||
|
||||
if ($exist) {
|
||||
$oldTags = !empty($exist['tags']) ? (json_decode($exist['tags'], true) ?: []) : [];
|
||||
$mergedTags = array_values(array_unique(array_merge($oldTags, $tags)));
|
||||
@@ -252,6 +663,9 @@ class AiLeadController extends BaseController
|
||||
if (isset($body['rfmScore'])) {
|
||||
$update['rfmScore'] = intval($body['rfmScore']);
|
||||
}
|
||||
if ($kefuId > 0) {
|
||||
$update['kefuId'] = $kefuId;
|
||||
}
|
||||
// poolId:显式传入优先,否则用绑定结果回填(仅当原值为空)
|
||||
if (isset($body['poolId'])) {
|
||||
$update['poolId'] = intval($body['poolId']);
|
||||
@@ -310,10 +724,11 @@ class AiLeadController extends BaseController
|
||||
$this->ensureTable();
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$kefuId = (int)$this->getUserInfo('id');
|
||||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||||
$count = Db::name('ai_lead')
|
||||
->where('companyId', $companyId)
|
||||
->where(function ($q) use ($kefuId) {
|
||||
$q->where('kefuId', $kefuId)->whereOr('kefuId', 0);
|
||||
->whereIn('companyId', $scope['companyIds'])
|
||||
->where(function ($q) use ($scope) {
|
||||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||||
})
|
||||
->where('isRead', 0)
|
||||
->count();
|
||||
@@ -327,35 +742,66 @@ class AiLeadController extends BaseController
|
||||
public function getList()
|
||||
{
|
||||
$this->ensureTable();
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$kefuId = $this->getUserInfo('id');
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$kefuId = (int)$this->getUserInfo('id');
|
||||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||||
$page = $this->request->param('page', 1);
|
||||
$limit = $this->request->param('limit', 20);
|
||||
$status = $this->request->param('status', 'all');
|
||||
$keyword = $this->request->param('keyword', '');
|
||||
|
||||
$where = [
|
||||
['companyId', '=', $companyId],
|
||||
];
|
||||
// 同项目全部获客可见;kefuId=0 为 Webhook 未指定客服时的公共客资
|
||||
$query = Db::name('ai_lead')->where($where)->where(function ($q) use ($kefuId) {
|
||||
$q->where('kefuId', $kefuId)->whereOr('kefuId', 0);
|
||||
});
|
||||
// 同 s2 账号跨 company 可见;kefuId=0 为未指定客服的公共客资
|
||||
$query = Db::name('ai_lead')
|
||||
->whereIn('companyId', $scope['companyIds'])
|
||||
->where(function ($q) use ($scope) {
|
||||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||||
});
|
||||
$onlyUnread = $this->request->param('onlyUnread', '');
|
||||
if ($onlyUnread === '1' || $onlyUnread === 'true') {
|
||||
$query->where('isRead', 0);
|
||||
}
|
||||
if ($status !== 'all' && $status !== '') {
|
||||
if ($status === 'unadded') {
|
||||
$query->where('wechatAdded', 0)->where('status', '<>', 'expired');
|
||||
} elseif ($status === 'added') {
|
||||
$query->where('wechatAdded', 1);
|
||||
} elseif ($status === 'expired') {
|
||||
$query->where('status', 'expired');
|
||||
} elseif ($status !== 'all' && $status !== '') {
|
||||
$query->where('status', '=', $status);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$query->where('nickname|phone', 'like', '%' . $keyword . '%');
|
||||
$like = '%' . $keyword . '%';
|
||||
$poolIdsByRemark = Db::name('traffic_pool_company')
|
||||
->where('companyId', $companyId)
|
||||
->whereLike('remark', $like)
|
||||
->column('id');
|
||||
$query->where(function ($q) use ($like, $poolIdsByRemark) {
|
||||
$q->whereLike('nickname|phone|unionId|openId', $like);
|
||||
if (!empty($poolIdsByRemark)) {
|
||||
$q->whereOr('poolId', 'in', $poolIdsByRemark);
|
||||
}
|
||||
});
|
||||
}
|
||||
$total = $query->count();
|
||||
$list = $query->page($page, $limit)->order('updateTime desc')->select();
|
||||
$list = array_map([self::class, 'decodeRow'], $list);
|
||||
$products = self::loadProjectProducts($companyId);
|
||||
$allRows = $query->select();
|
||||
$poolMetaMap = self::loadPoolMetaMap(array_column($allRows ?: [], 'poolId'), $companyId);
|
||||
$allRows = array_map(function ($row) use ($poolMetaMap, $products) {
|
||||
$meta = $poolMetaMap[(int)($row['poolId'] ?? 0)] ?? null;
|
||||
return self::decodeRow($row, $meta, $products);
|
||||
}, $allRows ?: []);
|
||||
$allRows = self::dedupeLeadRowsByContact($allRows);
|
||||
$allRows = self::sortLeadRows($allRows);
|
||||
$total = count($allRows);
|
||||
$offset = max(0, ((int)$page - 1) * (int)$limit);
|
||||
$list = array_slice($allRows, $offset, (int)$limit);
|
||||
|
||||
return ResponseHelper::success(['list' => $list, 'total' => $total]);
|
||||
$counts = $this->buildStatusCountsDeduped($scope);
|
||||
|
||||
return ResponseHelper::success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'counts' => $counts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,11 +811,17 @@ class AiLeadController extends BaseController
|
||||
public function getDetail()
|
||||
{
|
||||
$this->ensureTable();
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$kefuId = (int)$this->getUserInfo('id');
|
||||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||||
$id = $this->request->param('id', 0);
|
||||
$poolId = $this->request->param('poolId', 0);
|
||||
|
||||
$query = Db::name('ai_lead')->where('companyId', $companyId);
|
||||
$query = Db::name('ai_lead')
|
||||
->whereIn('companyId', $scope['companyIds'])
|
||||
->where(function ($q) use ($scope) {
|
||||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||||
});
|
||||
if ($id) {
|
||||
$query->where('id', $id);
|
||||
} elseif ($poolId) {
|
||||
@@ -381,7 +833,155 @@ class AiLeadController extends BaseController
|
||||
if (!$row) {
|
||||
return ResponseHelper::error('未找到该客资', 404);
|
||||
}
|
||||
return ResponseHelper::success(self::decodeRow($row));
|
||||
$products = self::loadProjectProducts($companyId);
|
||||
$meta = self::loadPoolMetaMap([(int)($row['poolId'] ?? 0)], $companyId);
|
||||
$poolMeta = $meta[(int)($row['poolId'] ?? 0)] ?? null;
|
||||
return ResponseHelper::success(self::decodeRow($row, $poolMeta, $products));
|
||||
}
|
||||
|
||||
/** Tab 计数 */
|
||||
private function buildStatusCounts(array $scope): array
|
||||
{
|
||||
$base = Db::name('ai_lead')
|
||||
->whereIn('companyId', $scope['companyIds'])
|
||||
->where(function ($q) use ($scope) {
|
||||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||||
});
|
||||
return [
|
||||
'all' => (int)(clone $base)->count(),
|
||||
'unadded' => (int)(clone $base)->where('wechatAdded', 0)->where('status', '<>', 'expired')->count(),
|
||||
'added' => (int)(clone $base)->where('wechatAdded', 1)->count(),
|
||||
'expired' => (int)(clone $base)->where('status', 'expired')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目上下文(产品词 + 备注模板)
|
||||
* GET /v1/kefu/project-context
|
||||
*/
|
||||
public function projectContext()
|
||||
{
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
return ResponseHelper::success(self::loadProjectContext($companyId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 加好友可选设备 + 今日剩余次数
|
||||
* GET /v1/kefu/ai-lead/add-friend-devices
|
||||
*/
|
||||
public function addFriendDevices()
|
||||
{
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$s2AccountId = (int)$this->getUserInfo('s2_accountId');
|
||||
|
||||
// §5.3.2 / COORD-006:与侧栏 CustomerServiceController 完全同源
|
||||
$companyWaIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds($companyId, $s2AccountId);
|
||||
|
||||
if (empty($companyWaIds)) {
|
||||
return ResponseHelper::success(['list' => []]);
|
||||
}
|
||||
|
||||
$wechatRows = Db::table('s2_wechat_account')->alias('wa')
|
||||
->whereIn('wa.id', $companyWaIds)
|
||||
->where('wa.isDeleted', 0)
|
||||
->field('wa.id,wa.wechatId,wa.nickname,wa.alias,wa.currentDeviceId as deviceId,wa.deviceMemo as memo,wa.imei')
|
||||
->order('wa.id desc')
|
||||
->limit(50)
|
||||
->select();
|
||||
|
||||
$wechatIds = array_filter(array_column($wechatRows ?: [], 'wechatId'));
|
||||
$limits = [];
|
||||
$todayAdded = [];
|
||||
if (!empty($wechatIds)) {
|
||||
$limits = Db::table('s2_wechat_account_score')
|
||||
->whereIn('wechatId', $wechatIds)
|
||||
->column('maxAddFriendPerDay', 'wechatId');
|
||||
$start = strtotime(date('Y-m-d 00:00:00'));
|
||||
$end = strtotime(date('Y-m-d 23:59:59'));
|
||||
$stats = Db::table('s2_wechat_friend')
|
||||
->whereIn('ownerWechatId', $wechatIds)
|
||||
->where('isDeleted', 0)
|
||||
->whereBetween('createTime', [$start, $end])
|
||||
->field('ownerWechatId, count(*) as cnt')
|
||||
->group('ownerWechatId')
|
||||
->select();
|
||||
foreach ($stats ?: [] as $sr) {
|
||||
$todayAdded[(string)$sr['ownerWechatId']] = (int)$sr['cnt'];
|
||||
}
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($wechatRows ?: [] as $row) {
|
||||
$wid = (string)($row['wechatId'] ?? '');
|
||||
$max = (int)($limits[$wid] ?? 5);
|
||||
if ($max <= 0) {
|
||||
$max = 5;
|
||||
}
|
||||
$used = (int)($todayAdded[$wid] ?? 0);
|
||||
$list[] = [
|
||||
'wechatAccountId' => (int)$row['id'],
|
||||
'wechatId' => $wid,
|
||||
'nickname' => $row['nickname'] ?: ($row['alias'] ?: $wid),
|
||||
'deviceId' => (int)($row['deviceId'] ?? 0),
|
||||
'deviceMemo' => $row['memo'] ?: ($row['imei'] ?? ''),
|
||||
'maxToday' => $max,
|
||||
'usedToday' => $used,
|
||||
'remainToday' => max(0, $max - $used),
|
||||
];
|
||||
}
|
||||
|
||||
return ResponseHelper::success(['list' => $list]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 获客 · 发起加微信
|
||||
* POST /v1/kefu/ai-lead/add-wechat
|
||||
*/
|
||||
public function addWechat()
|
||||
{
|
||||
$this->ensureTable();
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$kefuId = (int)$this->getUserInfo('id');
|
||||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||||
$leadId = (int)$this->request->param('leadId', 0);
|
||||
$wechatAccountId = (int)$this->request->param('wechatAccountId', 0);
|
||||
$remark = trim((string)$this->request->param('remark', ''));
|
||||
|
||||
if ($leadId <= 0 || $wechatAccountId <= 0) {
|
||||
return ResponseHelper::error('参数缺失', 400);
|
||||
}
|
||||
|
||||
$lead = Db::name('ai_lead')
|
||||
->whereIn('companyId', $scope['companyIds'])
|
||||
->where('id', $leadId)
|
||||
->find();
|
||||
if (!$lead) {
|
||||
return ResponseHelper::error('客资不存在', 404);
|
||||
}
|
||||
|
||||
$account = trim((string)($lead['phone'] ?? ''));
|
||||
if ($account === '') {
|
||||
$account = trim((string)($lead['unionId'] ?? ($lead['openId'] ?? '')));
|
||||
}
|
||||
if ($account === '') {
|
||||
return ResponseHelper::error('该客资无手机号/微信号,无法添加', 400);
|
||||
}
|
||||
|
||||
$poolId = (int)($lead['poolId'] ?? 0);
|
||||
if ($remark !== '' && $poolId > 0) {
|
||||
Db::name('traffic_pool_company')
|
||||
->where('id', $poolId)
|
||||
->where('companyId', $companyId)
|
||||
->update(['remark' => $remark, 'updateTime' => time()]);
|
||||
}
|
||||
|
||||
return ResponseHelper::success([
|
||||
'leadId' => $leadId,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'account' => $account,
|
||||
'poolId' => $poolId,
|
||||
'remark' => $remark,
|
||||
], '已发起加好友,请在手机端确认通过');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,15 +991,23 @@ class AiLeadController extends BaseController
|
||||
public function markRead()
|
||||
{
|
||||
$this->ensureTable();
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$kefuId = (int)$this->getUserInfo('id');
|
||||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||||
$leadId = $this->request->param('leadId', 0);
|
||||
if (!$leadId) {
|
||||
return ResponseHelper::error('leadId 缺失', 400);
|
||||
}
|
||||
Db::name('ai_lead')
|
||||
->where('companyId', $companyId)
|
||||
$updated = Db::name('ai_lead')
|
||||
->whereIn('companyId', $scope['companyIds'])
|
||||
->where(function ($q) use ($scope) {
|
||||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||||
})
|
||||
->where('id', $leadId)
|
||||
->update(['isRead' => 1, 'updateTime' => time()]);
|
||||
if (!$updated) {
|
||||
return ResponseHelper::error('客资不存在或无权操作', 404);
|
||||
}
|
||||
return ResponseHelper::success(true, '已忽略');
|
||||
}
|
||||
|
||||
@@ -443,17 +1051,63 @@ class AiLeadController extends BaseController
|
||||
public function transfer()
|
||||
{
|
||||
$this->ensureTable();
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$leadId = $this->request->param('leadId', 0);
|
||||
$toKefuId = $this->request->param('toKefuId', 0);
|
||||
if (!$leadId || !$toKefuId) {
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$kefuId = (int)$this->getUserInfo('id');
|
||||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||||
$leadId = (int)$this->request->param('leadId', 0);
|
||||
// 前端传 s2_company_account.id,需解析为 ck_users.id
|
||||
$toAccountId = (int)$this->request->param('toKefuId', 0);
|
||||
if ($leadId <= 0 || $toAccountId <= 0) {
|
||||
return ResponseHelper::error('参数缺失', 400);
|
||||
}
|
||||
|
||||
$row = Db::name('ai_lead')
|
||||
->whereIn('companyId', $scope['companyIds'])
|
||||
->where(function ($q) use ($scope) {
|
||||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||||
})
|
||||
->where('id', $leadId)
|
||||
->find();
|
||||
if (!$row) {
|
||||
return ResponseHelper::error('客资不存在或无权操作', 404);
|
||||
}
|
||||
|
||||
$toAccount = Db::table('s2_company_account')
|
||||
->where('id', $toAccountId)
|
||||
->where('departmentId', $companyId)
|
||||
->field('id,userName,realName,nickname')
|
||||
->find();
|
||||
if (!$toAccount) {
|
||||
return ResponseHelper::error('目标客服不存在或不属于本项目', 403);
|
||||
}
|
||||
|
||||
$toCkbUserId = (int)Db::name('users')
|
||||
->where('s2_accountId', $toAccountId)
|
||||
->where('companyId', $companyId)
|
||||
->value('id');
|
||||
if ($toCkbUserId <= 0) {
|
||||
$toCkbUserId = (int)Db::name('users')
|
||||
->where('s2_accountId', $toAccountId)
|
||||
->order('id', 'desc')
|
||||
->value('id');
|
||||
}
|
||||
if ($toCkbUserId <= 0) {
|
||||
return ResponseHelper::error('目标客服未绑定触客宝账号', 400);
|
||||
}
|
||||
|
||||
Db::name('ai_lead')->where('id', $leadId)->update([
|
||||
'kefuId' => $toCkbUserId,
|
||||
'updateTime' => time(),
|
||||
]);
|
||||
|
||||
$toName = ($toAccount['userName'] ?: $toAccount['realName'] ?: $toAccount['nickname']) ?: '';
|
||||
return ResponseHelper::success([
|
||||
'leadId' => (int)$leadId,
|
||||
'toKefuId' => (int)$toKefuId,
|
||||
'status' => 'pending',
|
||||
], '转移申请已提交,等待审批');
|
||||
'leadId' => $leadId,
|
||||
'toKefuId' => $toCkbUserId,
|
||||
'toAccountId' => $toAccountId,
|
||||
'toAccountName' => $toName,
|
||||
'status' => 'transferred',
|
||||
], '已转给客服 ' . $toName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\chukebao\service\CompanyWechatScopeService;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
@@ -16,13 +17,8 @@ class CustomerServiceController extends BaseController
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
$accountIds1= Db::table('s2_wechat_friend')->where(['accountId' => $accountId,'isDeleted' => 0])->group('wechatAccountId')->column('wechatAccountId');
|
||||
$accountIds2 = Db::table('s2_wechat_chatroom')->where(['accountId' => $accountId,'isDeleted' => 0])->group('wechatAccountId')->column('wechatAccountId');
|
||||
$accountIds = array_unique(array_merge($accountIds1 ?: [], $accountIds2 ?: []));
|
||||
|
||||
// 同项目设备上的微信号(老坑爹/油条姐等)一律并入侧栏,不论是否掉线
|
||||
$companyAccountIds = $this->getCompanyWechatAccountIds((int)$companyId);
|
||||
$accountIds = array_values(array_unique(array_merge($accountIds, $companyAccountIds)));
|
||||
// 侧栏真源:与存客宝 ck_device 绑定一致;有项目设备链时不混入客服个人 IM 号(避免 2 台设备却显示 3 个号)
|
||||
$accountIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds((int)$companyId, (int)$accountId);
|
||||
|
||||
if (empty($accountIds)) {
|
||||
return ResponseHelper::success([]);
|
||||
@@ -126,50 +122,4 @@ class CustomerServiceController extends BaseController
|
||||
|
||||
return ResponseHelper::success($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本项目设备登录过的微信号(s2_wechat_account.id)
|
||||
*/
|
||||
private function getCompanyWechatAccountIds(int $companyId): array
|
||||
{
|
||||
if ($companyId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
try {
|
||||
// 1) device_wechat_login 表
|
||||
$wechatIds = Db::name('device_wechat_login')
|
||||
->alias('l')
|
||||
->join('device d', 'd.id = l.deviceId')
|
||||
->where('d.companyId', $companyId)
|
||||
->where('d.deleteTime', 0)
|
||||
->group('l.wechatId')
|
||||
->column('l.wechatId');
|
||||
if (!empty($wechatIds)) {
|
||||
$ids = array_merge($ids, Db::table('s2_wechat_account')
|
||||
->whereIn('wechatId', $wechatIds)
|
||||
->column('id') ?: []);
|
||||
}
|
||||
|
||||
// 2) ck_device.imei → s2_device → s2_wechat_account(补全未写入 login 表的微信号)
|
||||
$imeis = Db::name('device')
|
||||
->where('companyId', $companyId)
|
||||
->where('deleteTime', 0)
|
||||
->where('imei', '<>', '')
|
||||
->column('imei');
|
||||
if (!empty($imeis)) {
|
||||
$s2DeviceIds = Db::table('s2_device')
|
||||
->whereIn('imei', array_unique($imeis))
|
||||
->column('id');
|
||||
if (!empty($s2DeviceIds)) {
|
||||
$ids = array_merge($ids, Db::table('s2_wechat_account')
|
||||
->whereIn('currentDeviceId', $s2DeviceIds)
|
||||
->column('id') ?: []);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 表差异不阻断
|
||||
}
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,68 @@ class TransferController extends BaseController
|
||||
return $this->createApply($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/kefu/transfer/list
|
||||
* 我的转移申请(客服看自己提交的;管理员可看本项目全部 pending)
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$kefuId = (int)$this->getUserInfo('id');
|
||||
$isAdmin = (int)($this->getUserInfo('isAdmin') ?? 0) === 1;
|
||||
$status = trim((string)$this->request->param('status', ''));
|
||||
$page = max(1, (int)$this->request->param('page', 1));
|
||||
$limit = min(100, max(1, (int)$this->request->param('limit', 20)));
|
||||
|
||||
$query = Db::name('wechat_account_transfer')->where('companyId', $companyId);
|
||||
if (!$isAdmin) {
|
||||
$query->where('applicantUserId', $kefuId);
|
||||
}
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$total = (int)$query->count();
|
||||
$rows = $query
|
||||
->order('id desc')
|
||||
->page($page, $limit)
|
||||
->select();
|
||||
|
||||
$list = [];
|
||||
foreach ($rows ?: [] as $row) {
|
||||
$list[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'type' => (string)($row['type'] ?? ''),
|
||||
'targetId' => (string)($row['targetId'] ?? ''),
|
||||
'targetName' => (string)($row['targetName'] ?? ''),
|
||||
'toAccountId' => (string)($row['toAccountId'] ?? ''),
|
||||
'toAccountName' => (string)($row['toAccountName'] ?? ''),
|
||||
'applicantUserId' => (int)($row['applicantUserId'] ?? 0),
|
||||
'applicantName' => (string)($row['applicantName'] ?? ''),
|
||||
'reason' => (string)($row['reason'] ?? ''),
|
||||
'affectedFriendCount' => (int)($row['affectedFriendCount'] ?? 0),
|
||||
'status' => (string)($row['status'] ?? 'pending'),
|
||||
'reviewComment' => (string)($row['reviewComment'] ?? ''),
|
||||
'createTime' => (int)($row['createTime'] ?? 0),
|
||||
'updateTime' => (int)($row['updateTime'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$pendingQuery = Db::name('wechat_account_transfer')
|
||||
->where('companyId', $companyId)
|
||||
->where('status', 'pending');
|
||||
if (!$isAdmin) {
|
||||
$pendingQuery->where('applicantUserId', $kefuId);
|
||||
}
|
||||
$pendingCount = (int)$pendingQuery->count();
|
||||
|
||||
return ResponseHelper::success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'pendingCount' => $pendingCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /v1/kefu/wechat-account/transfer/status?applyId= */
|
||||
public function status()
|
||||
{
|
||||
|
||||
@@ -135,30 +135,108 @@ class WechatAccountController extends BaseController
|
||||
if (!$id) {
|
||||
return ResponseHelper::error('id 缺失', 400);
|
||||
}
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$page = (int)$this->request->param('page', 1);
|
||||
$limit = (int)$this->request->param('limit', 20);
|
||||
$keyword = $this->request->param('keyword', '');
|
||||
|
||||
$query = Db::table('s2_wechat_friend')
|
||||
->where('wechatAccountId', $id)
|
||||
->where('isDeleted', 0);
|
||||
$query = Db::table('s2_wechat_friend')->alias('f')
|
||||
->leftJoin(
|
||||
'ck_traffic_pool_company t',
|
||||
't.wechatFriendId=f.id AND t.companyId=' . $companyId . ' AND t.isDel=0'
|
||||
)
|
||||
->leftJoin(['s2_company_account' => 'ca'], 'ca.id=f.accountId', 'LEFT')
|
||||
->where('f.wechatAccountId', $id)
|
||||
->where('f.isDeleted', 0);
|
||||
if ($keyword !== '') {
|
||||
$query->where('nickname|conRemark|alias', 'like', '%' . $keyword . '%');
|
||||
$query->where('f.nickname|f.conRemark|f.alias', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
$total = (clone $query)->count();
|
||||
$list = $query->field([
|
||||
'id', 'nickname', 'conRemark', 'alias', 'wechatId', 'avatar',
|
||||
'labels', 'phone', 'createTime',
|
||||
'f.id', 'f.nickname', 'f.conRemark', 'f.alias', 'f.wechatId', 'f.avatar',
|
||||
'f.labels', 'f.phone', 'f.createTime', 'f.updateTime', 'f.accountId',
|
||||
't.rfmScore', 't.lastInteractTime', 't.lastMsgTime', 't.id as poolCompanyId',
|
||||
'ca.realName as kefuName', 'ca.userName as kefuUserName',
|
||||
])
|
||||
->page($page, $limit)
|
||||
->order('id desc')
|
||||
->orderRaw(
|
||||
'COALESCE(t.lastMsgTime,t.lastInteractTime,f.updateTime,0) DESC, COALESCE(t.rfmScore,0) DESC'
|
||||
)
|
||||
->select();
|
||||
foreach ($list as &$v) {
|
||||
$v['labels'] = !empty($v['labels']) ? (json_decode($v['labels'], true) ?: []) : [];
|
||||
$v['remark'] = $v['conRemark'];
|
||||
}
|
||||
unset($v);
|
||||
|
||||
$list = $this->enrichFriendsWithPoolTags($list ?: [], $companyId);
|
||||
|
||||
return ResponseHelper::success(['list' => $list, 'total' => $total]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并微信 labels + 流量池标签,补齐 RFM 等级
|
||||
*/
|
||||
private function enrichFriendsWithPoolTags(array $list, int $companyId): array
|
||||
{
|
||||
if (empty($list)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$poolIds = [];
|
||||
foreach ($list as $row) {
|
||||
if (!empty($row['poolCompanyId'])) {
|
||||
$poolIds[] = (int)$row['poolCompanyId'];
|
||||
}
|
||||
}
|
||||
$poolIds = array_values(array_unique($poolIds));
|
||||
|
||||
$tagsByPool = [];
|
||||
if (!empty($poolIds)) {
|
||||
$tagRows = Db::name('traffic_pool_tag')
|
||||
->where('poolCompanyId', 'in', $poolIds)
|
||||
->where('isDel', 0)
|
||||
->field('poolCompanyId, tagName')
|
||||
->select();
|
||||
foreach ($tagRows ?: [] as $tr) {
|
||||
$pid = (int)$tr['poolCompanyId'];
|
||||
$name = trim((string)($tr['tagName'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$tagsByPool[$pid][] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$v) {
|
||||
$wxLabels = !empty($v['labels']) ? (json_decode($v['labels'], true) ?: []) : [];
|
||||
if (!is_array($wxLabels)) {
|
||||
$wxLabels = [];
|
||||
}
|
||||
$poolTags = $tagsByPool[(int)($v['poolCompanyId'] ?? 0)] ?? [];
|
||||
$v['labels'] = array_values(array_unique(array_merge($wxLabels, $poolTags)));
|
||||
$v['remark'] = $v['conRemark'];
|
||||
$score = (int)($v['rfmScore'] ?? 0);
|
||||
$v['rfmScore'] = $score;
|
||||
$v['rfmLevel'] = $this->rfmLevelFromScore($score);
|
||||
$kefu = trim((string)($v['kefuName'] ?? ''));
|
||||
if ($kefu === '') {
|
||||
$kefu = trim((string)($v['kefuUserName'] ?? ''));
|
||||
}
|
||||
$v['kefuName'] = $kefu;
|
||||
unset($v['kefuUserName'], $v['poolCompanyId']);
|
||||
}
|
||||
unset($v);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function rfmLevelFromScore(int $score): string
|
||||
{
|
||||
if ($score >= 80) {
|
||||
return 'S';
|
||||
}
|
||||
if ($score >= 60) {
|
||||
return 'A';
|
||||
}
|
||||
if ($score >= 40) {
|
||||
return 'B';
|
||||
}
|
||||
return $score > 0 ? 'C' : '-';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,15 @@ class WechatGroupController extends BaseController
|
||||
])->find();
|
||||
|
||||
if (empty($chatGroup)) {
|
||||
// 幂等:已软删则视为成功,避免前端缓存重复删报「不存在」
|
||||
$alreadyDeleted = ChatGroups::where([
|
||||
'id' => $id,
|
||||
'companyId' => $companyId,
|
||||
'isDel' => 1,
|
||||
])->find();
|
||||
if (!empty($alreadyDeleted)) {
|
||||
return ResponseHelper::success('', '删除成功');
|
||||
}
|
||||
return ResponseHelper::error('该分组不存在或已删除');
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user