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:
Manus AI
2026-05-30 21:41:40 +08:00
parent 0643e78a79
commit 5182529a58
709 changed files with 26466 additions and 3437 deletions

View 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 HTTPAI数智员工 **禁止** `/v1/kefu/*`。
## 禁止
- 在触客宝/AI数智员工仓修改 `Server/application/cunkebao`(除非用户本轮明确「改存客宝 Server」
- copy 兄弟项目 React 业务组件进本仓(设计 token 可对齐)
- 触客宝直连 `traffic_pool` 表
## 接口索引
`开发文档/5、接口/SuperAdmin_四端对接接口索引.md` · `02-存客宝对接/触客宝PC接口对接索引.md`

View File

@@ -23,6 +23,7 @@ alwaysApply: true
- 路由真源:`Server/application/*/config/route.php`
- 不改原有业务 URL设备写操作走 `/v1/workphone/*`
- 对照 `5、接口/02-存客宝与工作手机对接/06-业务接口与工作手机一对一映射总表.md`
- **边开发边标注**:改 `开发文档/1、需求/修改/{项目}_进行中_*.md` 对应行状态(⬜→🟡→✅)+ 验收证据;整节关单再迁入 `已完成/`
## 收尾Phase 2

View File

@@ -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. **验收表**(可数字 · V1Vn
### 3.2 只写文档不开发
- 状态标 **「P0 · 仅文档 · 待开发」**
- 同步 `开发文档/5、接口/开发进度_接口文档.md` 顶部一节
- **不**为专文单独占根目录文件名
### 3.3 已完成 vs 进行中边界
| 状态 | 放哪 |
|:---|:---|
| 代码 ✅ · 待目视 🟡 | 进行中留一行 · 细节进总汇或 `已完成/` 批次 |
| 代码 ✅ · 验收 ✅ | 移入 `已完成/` · 从 `_进行中` **删**该行 |
| 运营 P2签字/Key 轮换) | 写横表 · **不**占 P0 开发 slot |
| 全端 100% | 无 `_进行中` · 链总汇 §二 |
### 3.4 典型 P0 模板(本轮实例)
| 端 | 主题 | 要点 |
|:---|:---|:---|
| 存客宝 | 流量池去重+RFM | M1M4 同人键 · 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)

View File

@@ -88,6 +88,6 @@
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 200,
"scale": 2.3596490376776305,
"scale": 0.17544920373570672,
"close": true
}

View File

@@ -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"
]
}

View File

@@ -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已加载完成");
}

View File

@@ -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 => {

View File

@@ -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;

View File

@@ -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={

View File

@@ -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={

View File

@@ -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,

View File

@@ -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={

View File

@@ -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={

View File

@@ -181,7 +181,6 @@ const InfiniteList = <T extends any>({
return (
<PullToRefresh
onRefresh={handleRefresh}
refreshing={refreshing}
className={styles.pullToRefresh}
>
{content}

View File

@@ -8,6 +8,7 @@ interface LayoutProps {
children?: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
style?: React.CSSProperties;
/**
* framemobile = 全端统一手机全屏布局PC 不再居中壳/侧栏)
* framefullscreen 与 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>
);
};

View File

@@ -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);
}

View File

@@ -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 }) => {

View File

@@ -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" ? (

View File

@@ -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>
)}
{/* 弹窗 */}

View File

@@ -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={

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>

View 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 embeddedfooter 内联、不再 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%",
};

View File

@@ -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")) {

View File

@@ -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;

View File

@@ -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);
}

View 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;

View File

@@ -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}

View File

@@ -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>

View File

@@ -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";

View File

@@ -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>

View File

@@ -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

View File

@@ -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");
}

View File

@@ -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}

View 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);
}

View 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;

View File

@@ -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()}>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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,
});

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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} />
</>
);
}

View File

@@ -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 }
);
}
// ==================== 常量定义 ====================
// 好友状态

View File

@@ -14,7 +14,7 @@ export interface CreateTrafficPackageParams {
export interface CreateTrafficPackageResponse {
id: number;
groupName: string;
groupName?: string;
}
export async function createTrafficPackage(

View File

@@ -28,6 +28,7 @@ interface FilterCondition {
value: any;
operator?: string;
field?: string;
fieldType?: string;
displayValue?: string;
friends?: Friend[]; // 选中的好友列表
}

View File

@@ -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>

View File

@@ -69,7 +69,7 @@ const CreateTrafficPackage: React.FC = () => {
try {
// 构造ruleConfig
const ruleConfig = {
logic: "AND",
logic: "AND" as const,
conditions: formData.filterConditions,
};

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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);
// 计算统计数据

View File

@@ -25,6 +25,7 @@ export interface PoolListItem {
totalMsgCount: number;
totalOrderAmount: number;
tags: string[];
status?: number;
}
export interface PoolListResult {

View File

@@ -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();

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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>
)}

View File

@@ -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) {

View File

@@ -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}

View File

@@ -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: "",

View File

@@ -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 ?? "计划"}`);

View File

@@ -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>
)}

View File

@@ -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}

View File

@@ -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 || [];

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -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 {

View File

@@ -176,6 +176,7 @@ const ContactImportForm: React.FC = () => {
// 重置表单
const handleReset = () => {
setFormData({
planType: 0,
name: "",
status: 1,
type: 6,

View File

@@ -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,
},
]}

View File

@@ -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>

View File

@@ -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",
);
};

View File

@@ -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[];

View File

@@ -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}>

View File

@@ -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");

View File

@@ -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;

View File

@@ -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"]}

View File

@@ -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;
}
}

View File

@@ -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 />,

View File

@@ -187,7 +187,7 @@ export function createPersistStore<T>(
migrate,
onRehydrateStorage,
skipHydration,
} as PersistOptions<T>),
} as unknown as PersistOptions<T>),
);
}

View File

@@ -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,
};
}

View File

@@ -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 已从本地存储恢复
},
},
);

View File

@@ -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) + "发送失败:",

View File

@@ -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

View File

@@ -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']);

View 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;
}
}
}

View File

@@ -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');

View File

@@ -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 . '%';

View File

@@ -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
// 绑定流量池/客户资料池(同源),回填 poolIdpoolCompanyId
$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);
}
/**

View File

@@ -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))));
}
}

View File

@@ -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()
{

View File

@@ -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' : '-';
}
}

View File

@@ -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