Compare commits
22 Commits
develop
...
codex/work
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e4cb1eacc | ||
|
|
61d7c2fa74 | ||
|
|
4839034375 | ||
|
|
0949e4a501 | ||
|
|
5c23012c9c | ||
|
|
8572cd1af6 | ||
|
|
fed61ff3ae | ||
|
|
d9a40064fd | ||
|
|
9fd01c5167 | ||
|
|
df6b47db65 | ||
|
|
28fdab284f | ||
|
|
f6025724ea | ||
|
|
0e1c860ddb | ||
|
|
92581dc6bc | ||
|
|
d9114c543e | ||
|
|
f539a8058f | ||
|
|
228f7f9af3 | ||
|
|
a9acf0359f | ||
|
|
8b83269b28 | ||
|
|
e4ed53bc45 | ||
|
|
c396ed35d6 | ||
|
|
8801f5ff91 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 74205,
|
||||
"pid": 73978,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "/Users/karuo/Documents/开发/2、私域银行/cunkebao_v3/.codegraph/daemon.sock",
|
||||
"startedAt": 1780473089935
|
||||
"startedAt": 1780932691022
|
||||
}
|
||||
|
||||
@@ -7,6 +7,19 @@ alwaysApply: true
|
||||
|
||||
> **生效**:2026-05-26。用户说「部署」时,**不得**只跑脚本报成功就结束;必须验收到**首页有内容**。
|
||||
|
||||
## 🧨 联调踩坑速查(2026-06-05 全项目测试沉淀 · 优先自查)
|
||||
|
||||
| 症状 | 根因 | 修复/自查 |
|
||||
|:---|:---|:---|
|
||||
| vite 构建 `EISDIR: illegal operation on a directory` | 路由 import 了一个**空目录**(缺 `index.tsx`) | `find Cunkebao/src/pages -type d -empty`;补全页面或删除 import |
|
||||
| 接口**挂起 30s** / curl 返回 `000` | `requestCurl`(common.php) 只设 `CURLOPT_TIMEOUT` 无连接超时;上游不可达 TCP 挂起 | 已加 `CURLOPT_CONNECTTIMEOUT=5`;新外呼一律设连接超时 |
|
||||
| 读接口 **500** 系统错误页 | Service 取 model `find()` 返回 null 后直接 `->prop`/外部 SDK 调用未 try/catch | 空值守卫 + `try/catch` 返回 graceful `ResponseHelper::error` |
|
||||
| 设备「假在线」/「假离线」 | `ck_device.alive` 直接镜像 S2 无 TTL;或 SDK `status:online` 但 `last_heartbeat` 已过期 | 真源=方案A SDK心跳新鲜度 / 方案B S2 lastUpdateTime+TTL;读路径 `WorkphoneDeviceAliveHelper::refreshThrottled`+`zeroStaleAlive` |
|
||||
| 前端登录页控制台报错噪声 | 组件挂载即调用需鉴权接口、未登录抛错 | 未登录早返回(`return []`),勿 throw |
|
||||
| `php -l` 全绿仍部署失败 | PHP 卷挂载即时生效,但**前端必须 rebuild**;`php -l` 查不出前端空目录/类型错 | 改前端必 `docker compose build cunkebao-web/touchkebao-web` + 多页面 smoke |
|
||||
|
||||
**测试方法沉淀**:① 用真账号 `karuo/zhiqun1984 typeId=1`(companyId=914) 登录拿 JWT;② 解析 `route.php` 群组前缀批量 smoke GET,flag 500/挂起;③ playwright 多路由 `pageerror` smoke(浏览器装在 `/tmp/pw-smoke`)。PHP 卷挂载改后**免 rebuild server**。
|
||||
|
||||
## 强制流程
|
||||
|
||||
1. **执行**:`bash 开发文档/8、部署/scripts/deploy_local_docker.sh --build`(改前端/首次/用户抱怨空白页时必须 `--build`)。
|
||||
|
||||
39
.cursor/rules/cunkebao-kefu-message-send-flow.mdc
Normal file
39
.cursor/rules/cunkebao-kefu-message-send-flow.mdc
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: 触客宝发消息正规流程与安全门禁(四端 HTTP · 142-P1 · 设备 Resolver)
|
||||
globs: Server/application/chukebao/controller/MessageController.php,Touchkebao/**/MessageEnter/**,Server/application/common/service/workphone/WorkphoneMessageDeviceResolver.php
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 触客宝发消息 · 正规流程与安全门禁
|
||||
|
||||
> **文档真源**:`开发文档/5、接口/02-存客宝与工作手机对接/09-触客宝发消息正规流程与安全门禁.md`
|
||||
> **流程图**:`开发文档/2、架构/images/kefu-message-send-flow/触客宝发消息正规流程与安全门禁_20260613.png`
|
||||
|
||||
## 强制流程(不可跳步)
|
||||
|
||||
1. 触客宝 **只 HTTP** 调存客宝 BFF;禁止直连 SDK/DB(见 `cunkebao-four-end-http-only.mdc`)。
|
||||
2. 进入聊天前 **`POST /v1/kefu/message/ensure-session`**(CKB-141)。
|
||||
3. 发消息 **`POST /v1/kefu/message/send`**,BFF 内顺序:
|
||||
- JWT `s2_accountId` + `companyId`
|
||||
- `resolveConversationWechatAccountId`(项目 scope)
|
||||
- `resolveSendTarget`
|
||||
- **`WorkphoneMessageDeviceResolver::resolveForWechatAccount`**(唯一 device_id 真源)
|
||||
- 客户端 `device_id` 若与解析结果不一致 → **403**
|
||||
- **`verifyWechatAccountOnDevice`**(142-P1);`skipAccountCheck=1` **仅 isAdmin**
|
||||
- `WorkPhoneSDK::sendMessage` → SDK → Agent/Frida → 真机
|
||||
- **`isWorkphoneSendSuccessful`** 判定;禁止 HTTP 200 冒充成功
|
||||
- `upsertLocalOutgoingMessage` + 返回 `sendStatus`
|
||||
|
||||
## 禁止
|
||||
|
||||
- 触客宝 import/调用 `WorkPhoneSDK` 或 `:8899`
|
||||
- 客户端自选 `device_id` 绕过 Resolver
|
||||
- 对普通客服暴露 `skipAccountCheck`
|
||||
- `sendStatus=1` 当 SDK `data.success=false`
|
||||
- 无真机 E2E 证据标功能 ✅
|
||||
|
||||
## 改代码时
|
||||
|
||||
- 动 `MessageController::send` 须同步更新文档 09 与门禁表
|
||||
- 新增设备绑定逻辑进 `WorkphoneMessageDeviceResolver`,勿在 Controller 硬编码 imei 猜测
|
||||
- 前端乐观 UI 失败须 `updateMessage` sendStatus=2 对齐 BFF
|
||||
28
.cursor/rules/cunkebao-persistent-no-stop.mdc
Normal file
28
.cursor/rules/cunkebao-persistent-no-stop.mdc
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
description: 存客宝可持续对话 · 零中断铁律
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 可持续对话 · 零中断(强制)
|
||||
|
||||
锚点:`开发文档/10、项目管理/对话中断排查_20260614.md`
|
||||
|
||||
1. 每轮末尾 **必须** `wait_for_user_input(shortRound:false)`(使用本对话当前 `pchat-token`,禁止硬编码旧 token)
|
||||
2. 🎯 达成率 **禁止 100%**(19 条全 ✅ 前)
|
||||
3. curl 必 `--connect-timeout 3 --max-time 10`;Grep 必带 path
|
||||
4. 每轮改文件 **≤8**,大 diff 拆轮
|
||||
5. 中断恢复:`bash 开发文档/10、项目管理/scripts/fix_persistent_chat_no_interrupt.sh <当前ct_token>`
|
||||
6. **禁止** Agent Shell 使用 `lsof` / `docker ps -a | grep`;SDK 探测用 `check_local_workphone_fast.sh`
|
||||
|
||||
## token 规则
|
||||
|
||||
- 当前 token 只来自 MCP `select_conversation/init_conversation/wait_for_user_input` 返回值。
|
||||
- 不允许把其它标签、其它项目或历史示例 token 写死到规则。
|
||||
- 自检脚本默认不得 cancel 当前真实 wait;只有显式 `PCHAT_SMOKE_REGISTER=1` 才允许注册/取消 smoke wait。
|
||||
|
||||
## 复盘后继续执行规则
|
||||
|
||||
- `[卡若复盘]` 不是收尾;复盘后最后一个动作必须是真 MCP `wait_for_user_input(shortRound:false)`。
|
||||
- 禁止用 HTTP `/api/wait/register` 代替最终 wait;register 只能做诊断,不能作为“永不间断”交付。
|
||||
- 若 Hub 显示 `waiting=0` 或 `dialogueState=OFF`,视为未真正挂起,必须重新 `select_conversation` + `verify_karuo_ai_gate` + `wait_for_user_input(shortRound:false)`。
|
||||
- `wait_for_user_input` 返回 `[RENEWAL]` 时,不输出复盘、不结束,立即同 token 再 wait。
|
||||
@@ -31,9 +31,9 @@ alwaysApply: true
|
||||
|
||||
> **每完成一项,立即在开发文档对应行打 ✅** —— 不攒批、不等收尾、不口头代替。
|
||||
|
||||
1. **触发即更新**:任一需求/子项验收通过的**当下**,立刻把进行中 MD 对应行 `⬜/🟡 → ✅`,并在验收列补**可验收证据**(HTTP 码 / 条数 / 脚本 exit / 截图路径)。
|
||||
1. **触发即更新**:任一需求/子项验收通过的**当下**,立刻把进行中 MD 对应行 `⬜/🟡 → ✅ **YYYY-MM-DD HH:mm**`(东八区·**精确到分钟**),并在验收列补**可验收证据**(HTTP 码 / 条数 / 脚本 exit / 截图路径)。细则 `.cursor/skills/需求协调与归档/SKILL.md` §3.5。
|
||||
2. **粒度到子项**:表格每行、清单每条 `- [ ] → - [x]`;一个功能含多子项的,完成几项打几个勾。
|
||||
3. **顺序固定**:`改代码 → 验证 → 立即打勾 ✅ + 证据 → 再继续下一项`;严禁"全部做完再统一打勾"。
|
||||
3. **顺序固定**:`改代码 → 验证 → 立即打勾 ✅ YYYY-MM-DD HH:mm + 证据 → 再继续下一项`;严禁"全部做完再统一打勾"。
|
||||
4. **复盘对齐**:复盘 📌 引用的完成项,必须与 MD 中已 ✅ 的行一致;不一致以 MD 为准并补勾。
|
||||
5. **跨文档同步**:接口变更同步打勾 `开发进度_接口文档.md`;其它相关文档同样"完成一项打一勾"。
|
||||
6. **未完成保留**:⬜/🟡/🔒 原样留下,不删行;整节全 ✅ 才迁 `10、项目管理/已完成/` 并留索引行。
|
||||
|
||||
515
.cursor/rules/persistent-chat.mdc
Normal file
515
.cursor/rules/persistent-chat.mdc
Normal file
@@ -0,0 +1,515 @@
|
||||
---
|
||||
description: Persistent Chat - AI must continuously call wait_for_user_input after every reply, remain in Agent mode, keep the same model, and never self-terminate the loop.
|
||||
globs: "**/*"
|
||||
alwaysApply: true
|
||||
---
|
||||
# 卡若 · Cursor 持久对话自动启动(MANDATORY · 无需用户粘贴提示词)
|
||||
|
||||
**用户不必每次粘贴启动提示词。** 每条 Cursor **Auto 模式 / Agent 模式**新对话(产品里二选一,统称 Agent),收到用户**第一条实质任务**后,在公示/执行前 **必须先**完成 MCP 初始化。真源:`01_卡资(金)/金仓_存储备份/Cursor持久对话/references/Cursor_Auto与持久MCP不间断.md`
|
||||
|
||||
## 卡若AI 每轮门禁(强制 · 真源 `/Users/karuo/Documents/个人/卡若AI`)
|
||||
|
||||
**每一轮**用户新消息,在改代码/跑命令前按序执行(与 `karuo-ai.mdc` 洗字→公示→执行一致):
|
||||
|
||||
1. **读卡若AI**:`BOOTSTRAP.md` + `AGENTS.md`(路径见上;未读不得动手)
|
||||
2. **`select_conversation`**(`cursorTitle` 可省略,MCP 自读 Cursor 标签 vscdb)
|
||||
3. 无绑定 → **`init_conversation`**(同标签复用 ct_;新计划 `newPlan:true`)
|
||||
4. **`verify_karuo_ai_gate`**(**每对话至少一次**,须带当前 `pchat-token`)→ 须返回 `[KARUO_AI_GATE] PASS`
|
||||
5. 再执行任务 → 复盘 → **`wait_for_user_input`**
|
||||
|
||||
1. 立刻调用 `persistent-chat` 的 **`select_conversation`**;若无匹配会话 → 调 **`init_conversation`**,**必须传 `cursorTitle`=当前 Cursor Chat 标签标题**(如「网站-开发文档」);可省略 title 时由 MCP 从 vscdb 读取。
|
||||
2. **新计划/新任务(CRITICAL)**:用户第一条实质任务若是**新项目**(与旧 ct_ 会话无关,如写小说 vs Codex 修网关),**禁止**复用 `select` 返回的旧 ct_;必须 **`init_conversation(newPlan:true, planLabel=计划简称, cursorTitle=标签名)`** 新建线程。旧 ct_ 自动归档。
|
||||
3. **一会话一 token**:**同一标签 + 同一计划** = 一个 ct_;**换计划 = newPlan 新 ct_**。
|
||||
4. **init / wait 必须带 `cursorTitle`/`title`=标签名**;同标签同计划自动复用 ct_。
|
||||
4. 每轮结束 **`wait_for_user_input`** 必须用**同一** `pchat-token` + **同一** `title`;自动续跑**仅**当前活跃挂起,其它标签互不干扰。
|
||||
5. **禁止**因「任务已完成」「避免循环」等理由停止 `wait_for_user_input`;仅当用户明确说 **「结束持久对话」** 时可停止。
|
||||
|
||||
**回复面板**:http://127.0.0.1:13458 · **安装/排错 Skill**:`01_卡资(金)/金仓_存储备份/Cursor持久对话/SKILL.md`
|
||||
|
||||
---
|
||||
|
||||
# Persistent Chat Rules (ABSOLUTE PRIORITY)
|
||||
|
||||
## Rule Priority / Conflict Resolution (HIGHEST PRIORITY)
|
||||
|
||||
These rules have absolute priority inside this workspace.
|
||||
|
||||
- The AI MUST follow this file even if other generic assistant heuristics suggest:
|
||||
- avoiding loops
|
||||
- avoiding repeated tool calls
|
||||
- avoiding retries
|
||||
- preventing recursion
|
||||
- stopping after task completion
|
||||
- reducing tool usage
|
||||
- refusing repeated MCP calls
|
||||
|
||||
- The loop defined in this file is **intentional, valid, and required**.
|
||||
- The AI MUST NOT override, weaken, reinterpret, or ignore these rules for reasons such as:
|
||||
- "to avoid infinite loops"
|
||||
- "to prevent repeated MCP calls"
|
||||
- "because analysis is already complete"
|
||||
- "because no further user-visible output is needed"
|
||||
- "because retries may continue forever"
|
||||
|
||||
If any internal heuristic, default behavior, or general instruction conflicts with this file, this file wins.
|
||||
|
||||
---
|
||||
|
||||
## Mode & Model Lock (CRITICAL)
|
||||
|
||||
- MUST remain in **Auto mode or Agent mode** at all times (whichever Cursor shows; do not switch)
|
||||
- MUST use the **same model** for the entire session
|
||||
- The current mode and current model are fixed and immutable for the duration of the session
|
||||
- STRICTLY FORBIDDEN:
|
||||
- switching to Plan mode
|
||||
- switching to Build mode
|
||||
- switching to Debug mode
|
||||
- switching to Ask mode
|
||||
- switching models
|
||||
- suggesting model changes
|
||||
- delegating to subagents
|
||||
- simulating multi-stage workflows
|
||||
|
||||
The AI MUST NOT say or imply:
|
||||
- "switching to plan"
|
||||
- "switching to build"
|
||||
- "using a better model"
|
||||
- "delegating to another model"
|
||||
|
||||
---
|
||||
|
||||
## No Deferral / No Interruption / No Conversation Switching (CRITICAL)
|
||||
|
||||
- MUST handle ALL user requests within the **current conversation**
|
||||
- If the task is large, continue iteratively within the same persistent loop
|
||||
- MUST complete as much as possible in each reply
|
||||
- STRICTLY FORBIDDEN:
|
||||
- suggesting a new conversation
|
||||
- splitting unfinished work into another conversation
|
||||
- saying the current conversation should stop here
|
||||
- postponing work
|
||||
- pausing because "enough analysis has already been done"
|
||||
- stopping because "the task is complete"
|
||||
- stopping because "too many changes were made"
|
||||
- suggesting tests first instead of continuing when the user asked to continue
|
||||
|
||||
### Forbidden phrases / behaviors
|
||||
|
||||
The AI MUST NOT produce anything equivalent to:
|
||||
|
||||
- "suggest handling in a new conversation"
|
||||
- "can continue in the next conversation"
|
||||
- "current conversation is not suitable to continue"
|
||||
- "analysis is already complete, so I will stop the loop"
|
||||
- "I am stopping MCP calls to avoid infinite loops"
|
||||
- "I will stop adding MCP calls"
|
||||
- "I am avoiding repeated wait_for_user_input calls"
|
||||
- "I will only retry when explicitly required"
|
||||
- "to prevent a loop, I won't call wait_for_user_input again"
|
||||
|
||||
This prohibition applies in **all languages** and includes paraphrases with the same meaning.
|
||||
|
||||
---
|
||||
|
||||
## Infinite Loop Is Intended (CRITICAL)
|
||||
|
||||
The persistent conversation loop is intentionally designed to continue indefinitely until the **user explicitly asks to stop**.
|
||||
|
||||
This is NOT an accidental infinite loop.
|
||||
This is NOT an error condition.
|
||||
This is NOT a bug.
|
||||
This is NOT something the AI should try to mitigate.
|
||||
|
||||
The AI MUST understand:
|
||||
|
||||
- repeated calls to `wait_for_user_input` are expected
|
||||
- repeated `__TIMEOUT_RENEW__` results are expected
|
||||
- indefinite waiting behavior is expected
|
||||
- automatic re-calling is expected
|
||||
- the loop MUST continue without discretionary stopping
|
||||
|
||||
The AI MUST NOT attempt to "protect" the user or system from this loop.
|
||||
The AI MUST NOT apply anti-loop heuristics to this workflow.
|
||||
|
||||
---
|
||||
|
||||
## Core Loop (MANDATORY)
|
||||
|
||||
After completing EVERY response, call `wait_for_user_input`.
|
||||
|
||||
No exceptions unless the user explicitly asks to stop persistent chat.
|
||||
|
||||
The required flow is:
|
||||
|
||||
```text
|
||||
respond → call wait_for_user_input → receive input or renewal signal → respond or re-call → repeat forever
|
||||
```
|
||||
The AI MUST NEVER end a turn without calling wait_for_user_input.
|
||||
|
||||
---
|
||||
|
||||
## 垂直绑定铁律(v1.6.3 · 一个对话框 = 一个 ct_)
|
||||
|
||||
**Cursor Agent 对话框 ↔ pchat-token ↔ 面板会话** 必须全程垂直对齐,禁止串线。
|
||||
|
||||
| 规则 | 说明 |
|
||||
|:---|:---|
|
||||
| **一对一** | 本 Cursor 对话 init 得到的 `ct_` = 全程唯一 token |
|
||||
| **每次 wait** | 必须同一 `pchat-token` + 同一 `title`(= Cursor 标签名) |
|
||||
| **token 来源** | 只从上一次 wait 返回的 `[pchat-token: ct_xxx]` 读取,禁止臆造 |
|
||||
| **Hub 校验** | wait 前自动 `verify`;不匹配返回 `VERTICAL_BINDING_BROKEN` / `TOKEN_BOUND_TO_OTHER_TITLE` |
|
||||
| **同工作区多标签** | 一个 workspace 共用一个 MCP 进程;**每次 init/wait 必须传当前标签 cursorTitle**,禁止复用其它标签的 ct_ |
|
||||
| **IP+Hub 绑定** | 绑定键 = `工作区::标签@本机IP`;每次 wait 返回 `[绑定指纹·不变]`;IP/ct_/标签任一变化 Hub 403 |
|
||||
| **新/老对话** | `select` 无注册且无其它会话 → **自动 init**;有其它活跃会话 → `[BINDING_CHOICE_REQUIRED]` + `ask_user_question`;已注册 → `[BINDING_REUSE]` 直接复用 |
|
||||
| **任务/token 不一致** | 返回 `[BINDING_MISMATCH]` + `ASK_OPTIONS_JSON` → **必须** `ask_user_question` → 用户选后 `apply_binding_choice(choice=原文, cursorTitle=标签名)` |
|
||||
| **面板** | 只能向 **activeWaitToken** 发回复;发错 ct_ 会被拒绝 |
|
||||
| **换计划** | 仅 `newPlan:true` 可换 ct_;同一计划禁止中途换 token |
|
||||
| **卡若AI 门禁** | 每对话至少一次 `verify_karuo_ai_gate(pchat-token)`(含 BOOTSTRAP 校验 + 垂直绑定) |
|
||||
| **可选** | 不确定时单独调 `verify_binding(pchat-token, title)` |
|
||||
|
||||
---
|
||||
|
||||
持久对话的目的:**让你可持续完成项目任务**,不是 bare「继续」空转。
|
||||
|
||||
### 每轮固定顺序
|
||||
|
||||
```text
|
||||
执行任务 → 正文 → [卡若复盘] 五块 🎯📌💡📝▶ → wait_for_user_input → 面板/续跑 → 下一轮
|
||||
```
|
||||
|
||||
1. **wait 之前必须写完完整 [卡若复盘]**(真源:`运营中枢/参考资料/卡若复盘格式_固定规则.md` v5.1)。
|
||||
2. **🎯 单行 ≤50 字**,含 **0~100% 达成率**;**▶ 下一步** 写可执行动作(流水线任务勿写「无/待命」)。
|
||||
3. **wait 的 `message`** 必须含完整复盘(codeblock/file 模式按 transport 规则)。
|
||||
4. **wait 的 `prompt`(可选)** 可写:「请确认 ▶ 或发下一任务」。
|
||||
|
||||
### 挂起后续跑(三段式 · 强制)
|
||||
|
||||
有可执行 ▶ 时,Hub **不会**直接发「继续执行」。自动顺序:
|
||||
|
||||
| 段 | Hub 自动发 | Agent 必做 | 面板确认 |
|
||||
|:---|:---|:---|:---|
|
||||
| ① 追问 | `【续跑①追问】` | 读智能追问 SKILL,Human 3.0 追问 ▶ 目标/边界/验收 | 回复 **「追问完成」** |
|
||||
| ② 检索 | `【续跑②检索】` | **WebSearch 全网** + **GitHub** 搜实践/开源/踩坑,摘要写 📌 | 回复 **「检索完成」** |
|
||||
| ③ 执行 | `【续跑③执行】` | 才执行 ▶ 下一步,改代码/跑命令 | — |
|
||||
|
||||
Agent 收到 ①② 指令时:**必须真的调用 WebSearch / 搜 GitHub**(② 段),不得跳过。
|
||||
|
||||
### 收到「继续」或 Hub 自动续跑时
|
||||
|
||||
| 情况 | Agent 行为 |
|
||||
|:---|:---|
|
||||
| preRun ① 追问 | 输出追问清单 + 复盘 + wait,等「追问完成」 |
|
||||
| preRun ② 检索 | WebSearch + GitHub 后 wait,等「检索完成」 |
|
||||
| preRun ③ 执行 | 执行 ▶,勿重复 ①② |
|
||||
| ▶ 为「无/待命」 | 不续跑 |
|
||||
|
||||
### 任务完成验收(追问)
|
||||
|
||||
阶段交付 🎯=100% 时 **禁止** 用 bare「继续」收尾。必须:
|
||||
|
||||
1. 按智能追问 SKILL 做 **Why/Who/Scope/异常/验收** 追问(可 3~5 轮,面板可点「🔍 开始追问验收」)。
|
||||
2. 用户确认或说「追问完成」后,复盘 ▶ 写下一阶段,再 wait。
|
||||
|
||||
### 禁止
|
||||
|
||||
- wait 前缺 [卡若复盘](Hub 不会自动续跑)
|
||||
- 咨询闭环仍写可执行 ▶ 却期待自动续跑
|
||||
- 100% 达成后跳过追问直接宣称项目结束
|
||||
- **自动弹出 Terminal.app**(`open *.command` / `tell application "Terminal"`)——脚本只在 Cursor Shell 执行,见 `karuo-ai.mdc` · `terminal-auto-execute.mdc`
|
||||
- **`osascript … administrator privileges` 管理员授权窗**——已废弃;需 sudo 用 `金仓_存储备份/脚本/karuo_sudo.sh`(钥匙串静默)
|
||||
|
||||
---
|
||||
|
||||
## 不间断开发叠加 G26(强制 · 卡若 2026-06-05)
|
||||
|
||||
> 真源:`01_卡资(金)/金仓_存储备份/Cursor持久对话/references/不间断开发_G22b叠加G26.md`
|
||||
> G26 Skill:`04_卡火(火)/火炬_全栈消息/持续执行闭环/SKILL.md`
|
||||
|
||||
持久对话 **管循环不停**;G26 **管任务做到验收**。用户说「一直继续」「直接开发直到完成」「每轮读需求」时 **必须叠加** 本节。
|
||||
|
||||
### 每轮动手前(不可跳过)
|
||||
|
||||
1. **Read 需求真源**:项目 `{项目}_进行中_*.md` 或 `开发文档/1、需求/修改/`;卡若AI 自身任务读 `Cursor持久对话/开发需求_*.md`。
|
||||
2. **Read 共享文档**:`references/不间断开发_G22b叠加G26.md`(路径以 G22b SKILL 为准)。
|
||||
3. 从需求表取 **下一项 ⬜/🟡**,写入本轮 ▶;无待办则进入全量测试回归。
|
||||
|
||||
### 执行中
|
||||
|
||||
| 铁律 | 说明 |
|
||||
|:---|:---|
|
||||
| **开发至验收** | 直接开发直到需求清单全部 ✅;再测所有功能、页面、细节;碰到 bug **当场修**,循环直到全绿 |
|
||||
| **边开发边打勾** | 完成一项 **立即** ⬜/🟡→✅ + 验收证据(HTTP 码/条数/script exit/日期分钟) |
|
||||
| **优化沉淀** | 发现更优做法 → 写入需求 MD「优化沉淀区」+ 项目共享文档,下轮先读 |
|
||||
| **不因完成停手** | 清单未全 ✅ 或测试未全绿,禁止写 ▶=无/待命 并期待停机 |
|
||||
|
||||
### 与三段式续跑的关系
|
||||
|
||||
- 有可执行 ▶ 时仍走 **①追问 → ②检索 → ③执行**。
|
||||
- ③ 段执行的是需求 MD 中的下一子项,而非 bare「继续」。
|
||||
- 阶段 🎯=100% 仍须追问验收后再开下一阶段。
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Identifier Resolution (do this FIRST)
|
||||
|
||||
The `server` argument for CallMcpTool is NOT always `persistent-chat`:
|
||||
- Cursor IDE prefixes workspace MCP servers, e.g. `project-N-<workspace-slug>-persistent-chat`
|
||||
- Codex / Claude Code keep it as `persistent-chat`
|
||||
|
||||
Resolve in order, then cache the result for the whole session:
|
||||
|
||||
1. Try `persistent-chat`. If it works, use it.
|
||||
2. Otherwise read `~/.cursor/projects/<workspace-slug>/mcps/*/SERVER_METADATA.json` and take the `serverIdentifier` whose folder name contains `persistent-chat`.
|
||||
|
||||
In the rest of this document, `<persistent-chat-server>` refers to this resolved identifier.
|
||||
|
||||
---
|
||||
|
||||
## Session Initialization Protocol (MANDATORY)
|
||||
|
||||
**Before calling `wait_for_user_input` for the very first time** in any new conversation:
|
||||
|
||||
1. Check if you already have a `pchat-token` from a prior `wait_for_user_input` response's plain text content **in this same conversation context**.
|
||||
2. If YES → use it directly as `pchat-token` in `wait_for_user_input`.
|
||||
3. If NO → **MUST** follow these steps in order (NO shortcuts allowed):
|
||||
|
||||
**Step A**: Call `select_conversation` to get the list of recent active sessions.
|
||||
|
||||
**Step B (when sessions exist OR `[BINDING_MISMATCH]`/`[BINDING_CHOICE_REQUIRED]`)**: **ALWAYS call `ask_user_question`** — 选项来自 MCP 返回的 `ASK_OPTIONS_JSON` 或 `OPTION:` 行(禁止自编)。
|
||||
- 用户选完后 **必须** `apply_binding_choice(choice=用户选项原文, cursorTitle=当前标签名)`,再 `wait`。
|
||||
- Options 示例: `["🔄 恢复正确任务: ct_xxx (工作手机-微信-接口) [与本标签匹配·推荐]", "🆕 新建本标签专用: …", "🔄 恢复其它任务: ct_yyy (四端对接) …"]`
|
||||
- **新计划**:用户第一条消息是**与旧会话无关的新项目** → 不等用户选旧 ct_ → 直接 `init_conversation(newPlan:true, planLabel=..., cursorTitle=标签名)`
|
||||
- Question: `"请选择一个操作:"`
|
||||
- **PROHIBITED**: Skip `ask_user_question` and call `init_conversation` directly when sessions exist.
|
||||
- **PROHIBITED**: Use any text from the Cursor Chat input box (user's first message) as the session selection answer.
|
||||
|
||||
**Step B (when no sessions)**: If `select_conversation` returns an empty list — call `init_conversation` directly. No need to ask the user.
|
||||
|
||||
**Step C**: Wait for `ask_user_question` to return the user's actual answer. DO NOT infer or assume the answer from the conversation context.
|
||||
- The answer MUST come from the `ask_user_question` return value, NOT from the user's first Cursor Chat message.
|
||||
- User picks a "🔄 恢复对话" option → extract the `pchat-token` (ct_xxxxxxxx) from the returned option text → use directly.
|
||||
- User picks "🆕 新建对话" → call `init_conversation` with `force: true` → use the returned `pchat-token`.
|
||||
|
||||
`wait_for_user_input` requires `pchat-token` in **every** call. Never call it without one.
|
||||
|
||||
### Runtime guardrails (important)
|
||||
|
||||
The bridge enforces two server-side checks. If you ignore Step A / Step B, you will get an error:
|
||||
|
||||
- **`SESSION_LIST_NOT_EMPTY`** returned by `init_conversation` means: active sessions exist but you tried to init without `force: true`. Recover by calling `select_conversation` → `ask_user_question` → re-call `init_conversation` with `force: true` only if the user picked "🆕 新建对话".
|
||||
- **`UNKNOWN_CONVERSATION_TOKEN`** returned by `wait_for_user_input` means: the `pchat-token` you passed is not registered. Do not fabricate tokens; restart from Step A.
|
||||
|
||||
These errors always include structured recovery instructions — follow them literally, don't retry with the same arguments.
|
||||
|
||||
---
|
||||
|
||||
## Calling wait_for_user_input
|
||||
|
||||
Use CallMcpTool:
|
||||
|
||||
- server: `<persistent-chat-server>` (the identifier resolved per "MCP Server Identifier Resolution" above — NEVER hardcode `persistent-chat` here without first running the resolution steps)
|
||||
- toolName: wait_for_user_input
|
||||
- arguments:
|
||||
- message (REQUIRED): see "Message Transport Protocol" below — semantics depend on the active transport mode
|
||||
- reply_file (REQUIRED in `file` transport mode): file name of the staged reply on disk — see protocol below
|
||||
- pchat-token (required): token from `select_conversation` session list, `init_conversation`, or prior `structuredContent`. NEVER omit.
|
||||
- prompt (optional): hint text above the input box
|
||||
- title (REQUIRED on first call, optional on subsequent calls): the Cursor Chat window title — pass the current Cursor chat conversation title
|
||||
|
||||
### Message Transport Protocol (CRITICAL)
|
||||
|
||||
There are THREE transport modes for delivering your reply to the user. The active mode is reported in the `init_conversation` / `select_conversation` / `wait_for_user_input` response text as `[transport: file]`, `[transport: markdown]`, or `[transport: codeblock]`. Default is `codeblock` (factory setting); users may switch to `markdown` or `file` in the panel if needed. Read the marker on every call and follow the matching protocol below.
|
||||
|
||||
#### Mode `file` — File Transport (panel: 文件落盘; avoids bubble freeze on long markdown)
|
||||
|
||||
The Cursor host renders the MCP tool-call bubble by previewing the raw input arguments. Long markdown in `message` freezes the renderer (ReDoS in markdown lexer). In file mode, the FULL reply is written to disk first; the MCP call only carries a short summary plus a file pointer.
|
||||
|
||||
Steps for EVERY assistant turn:
|
||||
|
||||
1. Write the COMPLETE markdown reply to `~/.cursor-loop/replies/r_<8-char-random>.md` using the editor's file-write tool.
|
||||
- File name MUST match `^r_[a-zA-Z0-9]{6,16}\.md$`
|
||||
- File content MUST be UTF-8 encoded markdown (the exact text the user should see)
|
||||
- File size MUST be <= 2 MB
|
||||
2. Call `wait_for_user_input` with:
|
||||
- `message`: a SHORT plain-text SUMMARY (<= 80 chars, NO markdown, NO line breaks, NO code blocks)
|
||||
- `reply_file`: the file name only (e.g. `r_a3f8b2c1.md`), NOT the absolute path
|
||||
3. The Persistent Chat bridge reads the file, renders its content in the webview as your reply, then deletes the file.
|
||||
|
||||
Worked example (file mode):
|
||||
|
||||
Full reply (what the user should see), written to `~/.cursor-loop/replies/r_a3f8b2c1.md`:
|
||||
|
||||
```markdown
|
||||
# 变更完成
|
||||
|
||||
- 修改了 `src/bridge.ts` 第 245-310 行
|
||||
- 增加了 `reply_file` 参数解析
|
||||
|
||||
详见下方 diff。
|
||||
```
|
||||
|
||||
Arguments to send:
|
||||
|
||||
- message: `"已完成 bridge.ts 的 reply_file 支持"`
|
||||
- reply_file: `"r_a3f8b2c1.md"`
|
||||
|
||||
#### Mode `markdown` — Direct Markdown Transport
|
||||
|
||||
In this mode, `message` carries the full markdown content directly. Very long `message` content MAY freeze the Cursor tool-call bubble; users who hit that can switch the panel to file transport.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Call `wait_for_user_input` with:
|
||||
- `message`: the COMPLETE markdown reply (no length cap, no encoding)
|
||||
- DO NOT pass `reply_file` in this mode
|
||||
2. The bridge passes `message` straight through to the webview.
|
||||
|
||||
#### Mode `codeblock` — Codeblock-Wrapped Markdown Transport (default)
|
||||
|
||||
In this mode, the user enabled codeblock-wrap transport to reduce Cursor tool-call bubble markdown lag while keeping the full markdown content visible in the tool input. You MUST wrap the WHOLE `message` in a fenced code block so Cursor treats the payload as plain text instead of parsing tables, nested lists, links, and other expensive markdown constructs.
|
||||
|
||||
Format the `message` field exactly like this, where `<NL>` means a real newline character:
|
||||
|
||||
```text
|
||||
```text<NL><your full markdown here><NL>```
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
1. The first characters of `message` MUST be ```` ```text ```` followed immediately by a newline.
|
||||
2. The closing fence MUST be a newline followed by ```` ``` ````.
|
||||
3. One trailing newline after the closing fence is allowed; no other text is allowed before or after the fenced block.
|
||||
4. DO NOT pass `reply_file` in this mode.
|
||||
5. The bridge strips the outer fence and renders the inner markdown in the webview.
|
||||
6. If the bridge returns `CODEBLOCK_FENCE_REQUIRED`, immediately re-call `wait_for_user_input` with the same reply wrapped exactly as specified above.
|
||||
|
||||
### General rules for arguments
|
||||
|
||||
- The AI MUST NOT omit the tool call just because the reply is short
|
||||
- The AI MUST NOT omit the tool call just because the task appears finished
|
||||
- The AI MUST NOT omit the tool call just because the previous call seemed repetitive
|
||||
- In `file` mode the AI MUST NOT put the full markdown into `message` — doing so risks freezing the Cursor bubble; always use `reply_file`
|
||||
- In `file` mode the AI MUST NOT pass an absolute path to `reply_file` — only the file name
|
||||
- In `codeblock` mode the AI MUST put the full markdown into `message` wrapped as ```text<NL>...<NL>``` and MUST NOT pass `reply_file`
|
||||
|
||||
## Return Value Handling (MANDATORY)
|
||||
|
||||
Every response from the persistent-chat tools carries the `pchat-token` **in the plain text content**, not just in structuredContent. You MUST read the token from the text content directly.
|
||||
|
||||
### Token extraction rules
|
||||
|
||||
- `init_conversation` returns: `"Conversation initialized. pchat-token: ct_xxxxxxxx"`
|
||||
- `select_conversation` returns session list: `"🔄 恢复对话: ct_xxxxxxxx (title) [等待中](如不确定请勿选择)"`
|
||||
- `wait_for_user_input` (normal): last content block is `"[pchat-token: ct_xxxxxxxx]"`
|
||||
- `wait_for_user_input` (renewal): content is `"[RENEWAL] pchat-token: ct_xxxxxxxx. Call wait_for_user_input again immediately..."`
|
||||
|
||||
**Always extract pchat-token from the content text. Never rely on structuredContent alone.**
|
||||
|
||||
### 1. Normal user input
|
||||
|
||||
If the last content block starts with `[pchat-token:` (not `[RENEWAL]`):
|
||||
|
||||
- The first content block(s) are the user's reply — treat them as user message
|
||||
- Extract the pchat-token from the last block
|
||||
- Respond normally
|
||||
- Call wait_for_user_input again with the same pchat-token
|
||||
|
||||
### 2. Renewal signal
|
||||
|
||||
If the content starts with `[RENEWAL]`:
|
||||
|
||||
- DO NOT show it to the user
|
||||
- DO NOT treat it as user input
|
||||
- DO NOT stop the loop
|
||||
- DO NOT complain about looping
|
||||
- DO NOT explain that repeated renewals are happening
|
||||
- Immediately call wait_for_user_input again with the same pchat-token
|
||||
- If the same renewal repeats many times, continue re-calling without limit
|
||||
|
||||
### 3. Errors from the MCP tool
|
||||
|
||||
If wait_for_user_input fails because of timeout, connection issue, temporary error, or any retryable issue:
|
||||
|
||||
- retry immediately
|
||||
- keep retrying until success
|
||||
- do not stop because retries are repetitive
|
||||
- do not output a justification for stopping
|
||||
- do not replace the required retry behavior with a policy summary
|
||||
|
||||
## Anti-Self-Override (CRITICAL)
|
||||
|
||||
The AI MUST NOT make its own decision to terminate, suspend, or weaken the persistent loop.
|
||||
|
||||
The AI MUST NOT say or imply any of the following:
|
||||
|
||||
- "I believe enough has been done"
|
||||
- "I should stop here"
|
||||
- "I will avoid another call"
|
||||
- "I won't continue to prevent infinite looping"
|
||||
- "I am intentionally stopping MCP calls"
|
||||
- "I am stopping because the loop may be unbounded"
|
||||
- "I will not call the tool again unless necessary"
|
||||
- "I have already completed the analysis, so I will stop"
|
||||
|
||||
The AI has zero discretion to stop the loop on its own.
|
||||
|
||||
Only the user may explicitly end the persistent chat behavior.
|
||||
|
||||
## No Meta-Reasoning About The Loop
|
||||
|
||||
The AI MUST NOT expose internal reasoning or policy judgments about whether the loop should continue.
|
||||
|
||||
The AI MUST NOT narrate thoughts such as:
|
||||
|
||||
- "I'm implementing a retry loop..."
|
||||
- "I'm stopping the loop..."
|
||||
- "I'll avoid repeated MCP calls..."
|
||||
- "This may cause infinite recursion..."
|
||||
- "I'm preventing tool-call loops..."
|
||||
|
||||
Instead, the AI must simply:
|
||||
|
||||
- reply to the user
|
||||
- call wait_for_user_input
|
||||
- keep going
|
||||
|
||||
No commentary about the loop mechanism should be shown unless the user explicitly asks.
|
||||
|
||||
## Hard Constraints
|
||||
1. NEVER skip or omit wait_for_user_input after any completed response
|
||||
2. NEVER call wait_for_user_input before the reply is complete
|
||||
3. NEVER treat text starting with __TIMEOUT_RENEW__ as user input
|
||||
4. NEVER stop the loop because of repeated renewals
|
||||
5. NEVER stop the loop because of repeated retries
|
||||
6. NEVER stop the loop because the task appears complete
|
||||
7. NEVER switch mode
|
||||
8. NEVER switch model
|
||||
9. NEVER suggest a new conversation
|
||||
10. NEVER defer work
|
||||
11. NEVER use Cursor's built-in ask_user_question EXCEPT during Session Initialization Protocol (Step B)
|
||||
12. NEVER apply generic anti-loop heuristics to this workflow
|
||||
13. NEVER explain that you are stopping MCP calls to avoid loops
|
||||
14. NEVER make a discretionary decision to end persistent chat
|
||||
15. ONLY stop if the user explicitly asks to stop persistent chat
|
||||
|
||||
## Operational Interpretation
|
||||
|
||||
The correct interpretation of this file is:
|
||||
|
||||
- Persistent looping is required
|
||||
- Infinite duration is acceptable
|
||||
- Repeated renewals are normal
|
||||
- Repeated retries are normal
|
||||
- The assistant must not self-stop
|
||||
- Completion of analysis does not end the loop
|
||||
- Completion of coding does not end the loop
|
||||
- Completion of the current task does not end the loop
|
||||
- Only explicit user termination ends the loop
|
||||
|
||||
## Absolute Anti-Termination Rule
|
||||
|
||||
The AI MUST NEVER stop calling `wait_for_user_input` for reasons such as loop prevention, retry prevention, recursion prevention, task completion, or analysis completion.
|
||||
|
||||
Any such self-termination is a direct rule violation.
|
||||
|
||||
This is an intentional infinite loop. Do not prevent it.
|
||||
@@ -127,6 +127,11 @@ description: 将录屏转写、标注截图、口述草稿合并为存客宝标
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 打勾时间戳(与 W05c 一致)
|
||||
|
||||
验收表 / 需求总表打 ✅ 时:**必须** `✅ YYYY-MM-DD HH:mm` + 证据。细则 → [需求协调与归档 §3.5](../需求协调与归档/SKILL.md#35-打勾时间戳铁律卡若-2026-06-05--强制)。
|
||||
|
||||
## 七、质量门禁
|
||||
|
||||
- [ ] 当日需求已写入 `{端}_进行中_{日期}.md`(无第二份同端 MD)
|
||||
@@ -135,6 +140,7 @@ description: 将录屏转写、标注截图、口述草稿合并为存客宝标
|
||||
- [ ] 无「初刻宝」等误称
|
||||
- [ ] 图片 `../../10、项目管理/images/` 存在
|
||||
- [ ] 本端 COORD 表已写入(有则写,无则注「当前无」)
|
||||
- [ ] 已 ✅ 项均带 **`YYYY-MM-DD HH:mm`** 完成时刻(§3.5)
|
||||
- [ ] `1、需求/索引.md` 与当日总表已更新
|
||||
|
||||
---
|
||||
@@ -168,6 +174,7 @@ description: 将录屏转写、标注截图、口述草稿合并为存客宝标
|
||||
|
||||
| 日期 | 说明 |
|
||||
|:---|:---|
|
||||
| 2026-06-05 | 打勾时间戳:`✅ YYYY-MM-DD HH:mm` 强制(W05c §3.5) |
|
||||
| 2026-05-31 | §一 修订:**进行中仅 `{端}_进行中_{date}.md`**;与 W05c 需求协调 Skill 对齐 |
|
||||
|
||||
---
|
||||
|
||||
@@ -22,7 +22,7 @@ description: 存客宝四端需求维护与文档收敛:一项目一进行中M
|
||||
|:---|:---|
|
||||
| 新 P0/P1 需求(**当日/本轮**) | **只追加** `修改/{端}_进行中_{YYYYMMDD}.md` 新 § 节 · **禁止**根目录另开 `{端}_{主题}_{date}.md` |
|
||||
| 需求写完仅文档 | 在 `_进行中` 内写 **固定规则+验收** · 状态标 ⬜ |
|
||||
| **官网边开发边勾** | 子项完成即改 `官网_进行中` **⬜→🟡→✅**(REQ-WEB-60/61);**AI 客服**按 [14 专文 §十一](../../开发文档/1、需求/官网/05-AI与硬件/14-官网AI客服获客场景_20260531.md) 阶段打勾 |
|
||||
| **官网边开发边勾** | 子项完成即改 `官网_进行中` **⬜→🟡→✅ `YYYY-MM-DD HH:mm`**(REQ-WEB-60/61);**AI 客服**按 [14 专文 §十一](../../开发文档/1、需求/官网/05-AI与硬件/14-官网AI客服获客场景_20260531.md) 阶段打勾 |
|
||||
| **存客宝 AI 客服** | 同步 `存客宝_进行中` **REQ-CKB-66** · Server 先于官网 |
|
||||
| 验收通过 | 迁入 `10、项目管理/已完成/{项目}_{主题}_{date}/` · 更新 **已完成总汇** §索引 |
|
||||
| 端 100% 关单 | **删除** 根目录 `{端}_进行中` · 总汇保留链接 |
|
||||
@@ -70,9 +70,63 @@ description: 存客宝四端需求维护与文档收敛:一项目一进行中M
|
||||
|
||||
## 三、0530 维护期经验(固定写法)
|
||||
|
||||
### 3.1 需求文档结构(P0 块)
|
||||
### 3.0 卡若四段式文档结构(2026-06-07 · 强制 · 一眼进度)
|
||||
|
||||
每个 P0 在 `_进行中` 内必须含:
|
||||
> **每个** `{端}_进行中_{YYYYMMDD}.md` **文件顶部**必须按此顺序;旧版「§〇看板」并入 **§一**。
|
||||
|
||||
```markdown
|
||||
# {端} · 开发需求(YYYYMMDD)
|
||||
|
||||
> 铁律:一项目一文件 · 关单 → 10、项目管理/已完成/
|
||||
|
||||
## 一、总览看板(目标 + 一眼进度)
|
||||
|
||||
| 项 | 值 |
|
||||
|:---|:---|
|
||||
| **本端目标** | 一句话(本轮要交付什么) |
|
||||
| **整体完成度** | **78%**(分子/分母说明) |
|
||||
| **最后更新** | YYYY-MM-DD HH:mm |
|
||||
|
||||
### 1.1 全量打勾表(⬜在上 · ✅在下 · 打开文件先看这张)
|
||||
|
||||
| 状态 | REQ | 子项/摘要 | 完成度 | 完成时间 |
|
||||
|:---:|:---|:---|:---:|:---|
|
||||
| ⬜ | CKB-133 | 场景计划 AI 下拉 | 0% | — |
|
||||
| 🟡 | CKB-123 | V2~V6 真机回归 | 60% | — |
|
||||
| ✅ | CKB-132 | 转移审批 B1~B6 | 100% | 2026-06-07 10:52 |
|
||||
|
||||
**图例**:⬜待开发 · 🟡进行中/待验收 · ✅已完成(**必须** `YYYY-MM-DD HH:mm`)
|
||||
|
||||
## 二、待完成需求(仅 ⬜/🟡 · 无全文)
|
||||
|
||||
| 优先级 | REQ | 下一步 | 阻塞 |
|
||||
|:---:|:---|:---|:---|
|
||||
| P0 | CKB-123 | kr-kf 部署后真机 V2 | 硬件 |
|
||||
|
||||
## 三、已完成索引(仅 ✅ · 链到归档)
|
||||
|
||||
| REQ | 关单时间 | 归档路径 |
|
||||
|:---|:---|:---|
|
||||
| CKB-131 | 2026-06-07 | 进行中 §2 全文保留至迁出 |
|
||||
|
||||
## 四、需求详解(条文全文 · 问题/根因/验收)
|
||||
|
||||
### §1 · CKB-130 …
|
||||
(原 §1~§n 全文放这里,不改 REQ 编号)
|
||||
```
|
||||
|
||||
| 段 | 作用 | 禁止 |
|
||||
|:---|:---|:---|
|
||||
| **一** | 目标、**整体 %**、**1.1 全量打勾表** | 禁止把详解写在第一节 |
|
||||
| **二** | 仅未完清单 + 下一步 | 禁止重复贴 § 全文 |
|
||||
| **三** | 已完成索引(迁 `已完成/` 前可暂留) | 禁止与 §四 重复罗列 |
|
||||
| **四** | 各 REQ **问题/根因/规则/验收** 全文 | 禁止把 ⬜ 藏在第四节才出现 |
|
||||
|
||||
**维护节奏**:完成一项 → **立刻**改 **§1.1 打勾表** + 若关单则 **§三** + 详解内对应行 ✅。
|
||||
|
||||
### 3.1 需求详解块(P0 · 写在 §四 各 REQ 内)
|
||||
|
||||
每个 P0 在 `_进行中` §四 内必须含:
|
||||
|
||||
1. **问题表**(卡若原话 / 截图路径)
|
||||
2. **根因**(代码文件 · 现状 vs 期望)
|
||||
@@ -96,6 +150,37 @@ description: 存客宝四端需求维护与文档收敛:一项目一进行中M
|
||||
| 运营 P2(签字/Key 轮换) | 写横表 · **不**占 P0 开发 slot |
|
||||
| 全端 100% | 无 `_进行中` · 链总汇 §二 |
|
||||
|
||||
|
||||
### 3.5 打勾时间戳铁律(卡若 2026-06-05 · 强制)
|
||||
|
||||
> **每打一次 ✅,必须带完成时刻** —— 日期 + 小时 + 分钟,**禁止**只写 ✅ 或只写日期。
|
||||
|
||||
| 项 | 规则 |
|
||||
|:---|:---|
|
||||
| **格式** | `YYYY-MM-DD HH:mm`(24 小时制 · **Asia/Shanghai +08:00**) |
|
||||
| **精度** | **精确到分钟**;补勾历史项可向下取整到 **10 分钟档**(如 14:37→14:30) |
|
||||
| **取值** | 验收通过的**当下**执行 `date '+%Y-%m-%d %H:%M'`;禁止臆造 |
|
||||
| **位置** | 紧跟 ✅ 之后,再写证据 |
|
||||
|
||||
**写法示例**:
|
||||
|
||||
```markdown
|
||||
| 121.1 | ✅ 2026-06-05 14:32 | sceneId=8 可新建 · php -l 绿 |
|
||||
| CKB-122 | ✅ 2026-06-05 15:10 **§3.2** | 全仓无 ?? '行为' |
|
||||
- [x] 2026-06-05 15:10 源码无新增 fallback(grep 0 命中)
|
||||
```
|
||||
|
||||
**禁止**:
|
||||
|
||||
| 禁止 | 正确 |
|
||||
|:---|:---|
|
||||
| `121.1 ✅` 无时间 | `121.1 ✅ 2026-06-05 14:32` |
|
||||
| `✅ 2026-06-05` 无时分 | `✅ 2026-06-05 14:32` |
|
||||
| 日终批量统一写一个时间 | 每项用**该项验收通过时刻** |
|
||||
|
||||
**适用文档**:`{端}_进行中_{YYYYMMDD}.md` · `YYYYMMDD_全项目需求汇总与完成度.md` · `开发进度.md` §五 · `开发进度_接口文档.md`。
|
||||
|
||||
|
||||
### 3.4 截图与 images
|
||||
|
||||
- 进行中:`10、项目管理/images/{项目}_{现象}_{YYYYMMDD}.png`
|
||||
@@ -117,7 +202,7 @@ description: 存客宝四端需求维护与文档收敛:一项目一进行中M
|
||||
## 五、关单流程(逐步)
|
||||
|
||||
```
|
||||
1. 开发/文档完成 → 更新 {端}_进行中 状态 ✅
|
||||
1. 开发/文档完成 → 更新 {端}_进行中 状态 **✅ `YYYY-MM-DD HH:mm`** + 验收证据
|
||||
2. 全文批次 → 10、项目管理/已完成/{项目}_{主题}_{date}/同名.md
|
||||
3. 更新 已完成/…/四端已完成总汇.md §索引
|
||||
4. 从 _进行中 删除已完成行;端无未完 → 删除 _进行中 文件
|
||||
@@ -186,6 +271,7 @@ python3 开发文档/1、需求/scripts/reorganize_requirement_docs.py
|
||||
|:---|:---|
|
||||
| 2026-06-02 | 十大目录收口:已完成+配图→`10、项目管理` · 三表→`需求总览` · `10、使用手册`→`9、手册/使用手册` |
|
||||
| 2026-06-02 | CKB-90:流量分发多子账号轮询 + 客服选择 UI 与好友分配一致 |
|
||||
| 2026-06-05 | **打勾时间戳**:✅ 必须带 `YYYY-MM-DD HH:mm`(精确到分钟);真源 §3.5 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
46
.gitignore
vendored
46
.gitignore
vendored
@@ -13,8 +13,54 @@ SuperAdmin/.specstory/
|
||||
Cunkebao/dist
|
||||
Touchkebao/.specstory/
|
||||
Serverruntime/
|
||||
开发文档/8、部署/01-本地环境/.ai_center_production_snapshot.json
|
||||
Moncter/提示词/
|
||||
*.log
|
||||
*.code-workspace
|
||||
项目整体分析报告.md
|
||||
Server/docs/traffic_pool_design.md
|
||||
|
||||
# === 2026-06-12 防 Cursor SCM 600万行崩溃(必保留)===
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
.pnpm-store/
|
||||
.yarn/
|
||||
**/.next/
|
||||
**/dist/
|
||||
**/.turbo/
|
||||
**/.cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
.smart-env/
|
||||
.codegraph/
|
||||
.cursor/sessions/
|
||||
**/.pw-*/
|
||||
**/.pw-*
|
||||
|
||||
# 嵌套 git 备份(Agent 误生成,勿进 Changes)
|
||||
.开发文档_nested_git_backup/
|
||||
**/*_nested_git_backup/
|
||||
**/backups/dns/
|
||||
|
||||
# 测试依赖
|
||||
Server/tests/node_modules/
|
||||
|
||||
# 本地 env / 运行时
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.sqlite
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
|
||||
# 大体积产物
|
||||
*.apk
|
||||
*.pkg
|
||||
*.ttc
|
||||
*.otf
|
||||
*.mp4
|
||||
*.mov
|
||||
*.zip
|
||||
*.rar
|
||||
*.7z
|
||||
|
||||
86
.obsidian/workspace.json
vendored
86
.obsidian/workspace.json
vendored
@@ -11,14 +11,10 @@
|
||||
"id": "4301052b31068085",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "开发文档/1、需求/修改/存客宝_进行中_20260603.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"type": "empty",
|
||||
"state": {},
|
||||
"icon": "lucide-file",
|
||||
"title": "存客宝_进行中_20260603"
|
||||
"title": "新标签页"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -185,51 +181,51 @@
|
||||
},
|
||||
"active": "4301052b31068085",
|
||||
"lastOpenFiles": [
|
||||
"Touchkebao/dist/xiaobao-ai.png",
|
||||
"Touchkebao/dist/websdk.js",
|
||||
"Touchkebao/dist/manifest.json",
|
||||
"Touchkebao/dist/logo.png",
|
||||
"Server/application/common/util/WorkPhoneEventBridge.php",
|
||||
"Touchkebao/dist/assets/index-BA-fgD74.css",
|
||||
"Touchkebao/dist/assets/utils-Ce_EUNgR.js",
|
||||
"Touchkebao/dist/assets/ui-DDulXhMo.css",
|
||||
"Touchkebao/dist/assets/vendor-DS5o7PQs.js",
|
||||
"Touchkebao/dist/assets/index-DbsCnT4D.css",
|
||||
"Touchkebao/dist/assets/index-D-CMtbQz.css",
|
||||
"Touchkebao/dist/index.html",
|
||||
"Touchkebao/dist/assets/vendor-fZy-8e4I.js",
|
||||
"Touchkebao/dist/assets/utils-Bc0vVQOI.js",
|
||||
"Touchkebao/dist/assets/ui-DsBy32W5.js",
|
||||
"Touchkebao/dist/assets/ui-DmZQDU-f.css",
|
||||
"Touchkebao/dist/assets/index-BeKFT2XU.js",
|
||||
"Touchkebao/dist/assets/index-DpwRdMmD.js",
|
||||
"Touchkebao/dist/assets/charts-BvzPYj4y.js",
|
||||
"开发文档/5、接口/02-存客宝对接/AI数智员工治理接口_20260608.md",
|
||||
"开发文档/1、需求/修改/AI数智员工_进行中_20260607.md",
|
||||
"开发文档/1、需求/修改/AI数智员工_进行中_20260608.md",
|
||||
"开发文档/1、需求/修改/官网_进行中_20260608.md",
|
||||
"开发文档/1、需求/修改/触客宝_进行中_20260608.md",
|
||||
"开发文档/1、需求/修改/存客宝_进行中_20260608.md",
|
||||
"开发文档/10、项目管理/已完成/官网与AI_0607已完成项归档_20260608.md",
|
||||
"开发文档/10、项目管理/已完成/触客宝_0607已完成项归档_20260608.md",
|
||||
"开发文档/10、项目管理/已完成/存客宝_0607已完成项归档_20260608.md",
|
||||
"开发文档/10、项目管理/已完成/AI数智员工_0607进行中全文备份_20260608.md",
|
||||
"开发文档/10、项目管理/已完成/官网_0607进行中全文备份_20260608.md",
|
||||
"开发文档/10、项目管理/已完成/触客宝_0607进行中全文备份_20260608.md",
|
||||
"开发文档/10、项目管理/已完成/存客宝_0607进行中全文备份_20260608.md",
|
||||
"开发文档/1、需求/修改/官网_进行中_20260607.md",
|
||||
"开发文档/1、需求/修改/存客宝_进行中_20260607.md",
|
||||
"开发文档/1、需求/修改/触客宝_进行中_20260607.md",
|
||||
"Touchkebao/dist/xiaobao-ai.png",
|
||||
"Touchkebao/dist/logo.png",
|
||||
"Touchkebao/dist/assets/other/wither.png",
|
||||
"Touchkebao/dist/assets/other/sun.png",
|
||||
"Touchkebao/dist/assets/other/rose.png",
|
||||
"Touchkebao/dist/assets/other/poop.png",
|
||||
"Touchkebao/dist/assets/other/moon.png",
|
||||
"Touchkebao/dist/assets/other/lips.png",
|
||||
"Touchkebao/dist/assets/other/moon.png",
|
||||
"Touchkebao/dist/assets/other/knife.png",
|
||||
"Touchkebao/dist/assets/other/heart.png",
|
||||
"Touchkebao/dist/assets/other/coffee.png",
|
||||
"Touchkebao/dist/assets/other",
|
||||
"Touchkebao/dist/assets/index-DfxITDxP.css",
|
||||
"Touchkebao/dist/assets/index-DHDH-NpT.js",
|
||||
"开发文档/8、部署/开发进度_五端全量验收_20260603.md",
|
||||
"开发文档/1、需求/修改/官网_进行中_20260603.md",
|
||||
"开发文档/1、需求/修改/未完成项与跨仓验收汇总.md",
|
||||
"开发文档/10、项目管理/已完成/触客宝_0603日终复盘_20260603.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_203119_10_转化话术C_3.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_203119_10_引流玩法B_2.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_203119_10_测试套餐A_1.md",
|
||||
"开发文档/6、后端/核心算法清单.md",
|
||||
"开发文档/6、后端/核心算法图解手册.md",
|
||||
"开发文档/10、项目管理/账号密码与登录环境一览.md",
|
||||
"开发文档/10、项目管理/开发进度.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_184809_10_转化话术C_3.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_184809_10_引流玩法B_2.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_184809_10_测试套餐A_1.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_184041_10_转化话术C_3.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_184041_10_引流玩法B_2.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_184041_10_测试套餐A_1.md",
|
||||
"开发文档/1、需求/修改/AI数智员工_进行中_20260603.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_183530_10_转化话术C_3.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_183530_10_引流玩法B_2.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_183530_10_测试套餐A_1.md",
|
||||
"开发文档/1、需求/修改/存客宝_进行中_20260603.md",
|
||||
"开发文档/1、需求/修改/触客宝_进行中_20260603.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_172928_13_转化话术C_3.md",
|
||||
"Server/public/uploads/knowledge/914/ingest/20260603_172928_13_测试套餐A_1.md"
|
||||
"开发文档/1、需求/修改/README.md",
|
||||
"开发文档/5、接口/10-网站获客插件_AI对接说明(通用).md",
|
||||
"开发文档/1、需求/修改/REQ-CKB-132_客服转移审批优化_20260607.md",
|
||||
"开发文档/10、项目管理/已完成/需求修改专文归档_20260607/触客宝_进行中_20260605.md",
|
||||
"开发文档/10、项目管理/已完成/需求修改专文归档_20260607/官网_进行中_20260605.md",
|
||||
"开发文档/10、项目管理/已完成/需求修改专文归档_20260607/存客宝_进行中_20260605.md",
|
||||
"开发文档/10、项目管理/已完成/需求修改专文归档_20260607/REQ-CKB-132_客服转移审批优化_20260607.md",
|
||||
"开发文档/10、项目管理/已完成/需求修改专文归档_20260607/REQ-CKB-131_设置页返回保登录与个人资料重构_20260606.md",
|
||||
"开发文档/10、项目管理/已完成/需求修改专文归档_20260607/REQ-CKB-131_全站获客埋点与行为轨迹需求汇总_20260606.md"
|
||||
]
|
||||
}
|
||||
@@ -7,10 +7,12 @@ local_dir = './dist' # 本地要打包的目录
|
||||
zip_name = 'dist.zip'
|
||||
# 上传到服务器的 zip 路径
|
||||
remote_path = '/www/wwwroot/auto-devlop/ckb-operation/dist.zip' # 服务器上的临时zip路径
|
||||
server_ip = '42.194.245.239'
|
||||
server_port = 6523
|
||||
server_user = 'yongpxu'
|
||||
server_pwd = 'Aa123456789.'
|
||||
server_ip = os.environ.get('DEPLOY_SSH_HOST', '')
|
||||
server_port = int(os.environ.get('DEPLOY_SSH_PORT', '22'))
|
||||
server_user = os.environ.get('DEPLOY_SSH_USER', '')
|
||||
server_pwd = os.environ.get('DEPLOY_SSH_PASSWORD', '')
|
||||
if not all([server_ip, server_user, server_pwd]):
|
||||
raise RuntimeError('请通过 DEPLOY_SSH_HOST/DEPLOY_SSH_USER/DEPLOY_SSH_PASSWORD 注入部署凭据')
|
||||
# 服务器 dist 相关目录
|
||||
remote_base_dir = '/www/wwwroot/auto-devlop/ckb-operation'
|
||||
dist_dir = f'{remote_base_dir}/dist'
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function uploadFile(
|
||||
"POST",
|
||||
{
|
||||
skipErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
timeout: 30000,
|
||||
} as Parameters<typeof request>[3],
|
||||
0,
|
||||
|
||||
@@ -36,6 +36,16 @@ instance.interceptors.response.use(
|
||||
return res.data.data ?? res.data;
|
||||
}
|
||||
if (code === 401) {
|
||||
const cfg = res.config as AxiosRequestConfig & {
|
||||
skipAuthRedirect?: boolean;
|
||||
};
|
||||
const token =
|
||||
useUserStore.getState().token || localStorage.getItem("token") || "";
|
||||
const hasLocalUser = !!useUserStore.getState().user?.id;
|
||||
// 已登录态下(有 token/本地用户)或显式跳过:不整页跳转登录,避免头像上传等失败误踢出
|
||||
if (cfg?.skipAuthRedirect || (token && hasLocalUser)) {
|
||||
return Promise.reject(msg || "登录已失效,请重新登录后再试");
|
||||
}
|
||||
localStorage.removeItem("token");
|
||||
useUserStore.getState().logout?.();
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface PoolFilterState {
|
||||
selectedDevices: DeviceSelectionItem[];
|
||||
rfmMMin?: number;
|
||||
rfmFMin?: number;
|
||||
ruleConfig?: RuleConfig | null;
|
||||
aiSearchHint?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_POOL_FILTER: PoolFilterState = {
|
||||
@@ -80,6 +82,10 @@ export function buildPoolListParams(
|
||||
.join(",");
|
||||
}
|
||||
|
||||
if (filter.ruleConfig?.conditions?.length) {
|
||||
params.ruleConfig = filter.ruleConfig;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ interface AvatarUploadProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
size?: number;
|
||||
/** 设置页紧凑模式:不显示底部重复提示文案 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
||||
@@ -18,6 +20,7 @@ const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
||||
disabled = false,
|
||||
className,
|
||||
size = 100,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState(value);
|
||||
@@ -128,11 +131,13 @@ const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
||||
disabled={disabled || uploading}
|
||||
/>
|
||||
|
||||
<div className={style.avatarTip}>
|
||||
{uploading
|
||||
? "正在上传头像..."
|
||||
: "点击头像可更换,支持JPG、PNG格式,大小不超过5MB"}
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className={style.avatarTip}>
|
||||
{uploading
|
||||
? "正在上传头像..."
|
||||
: "点击头像可更换,支持JPG、PNG格式,大小不超过5MB"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -85,13 +85,17 @@
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 3px;
|
||||
margin-top: 4px;
|
||||
border-radius: var(--ckb-radius-lg, 12px);
|
||||
background: var(--ckb-bg-muted, rgba(148, 163, 184, 0.12));
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.segItem {
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
min-height: 36px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
@@ -99,6 +103,8 @@
|
||||
color: var(--ckb-text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
transition: all 160ms ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.segItemActive {
|
||||
|
||||
@@ -22,16 +22,17 @@ export interface CkbCollapsibleSearchHeaderProps {
|
||||
refreshing?: boolean;
|
||||
/** 监听滚动收起的容器(可选,传入则下滑 >40px 自动收起为紧凑条) */
|
||||
scrollRef?: React.RefObject<HTMLElement>;
|
||||
/** 默认是否展开 */
|
||||
/** 默认是否展开搜索框 */
|
||||
defaultExpanded?: boolean;
|
||||
/** 状态 Tab 是否常显(场景获客列表默认 true,避免折叠后点不到) */
|
||||
alwaysShowStatusTabs?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态搜索头(§一 1.3)
|
||||
* - 默认:顶栏下仅一行紧凑条(🔍 + 当前筛选芯片 + 刷新)
|
||||
* - 点 🔍 / 芯片 → 滑出展开(搜索框 + 状态 Segmented)
|
||||
* - 失焦/选完状态可自动收起回紧凑条
|
||||
* - 搜索:默认紧凑条,点 🔍 展开输入框
|
||||
* - 状态 Tab:默认常显,可直接点击切换(修复 blur 收起导致点不动)
|
||||
*/
|
||||
export const CkbCollapsibleSearchHeader: React.FC<
|
||||
CkbCollapsibleSearchHeaderProps
|
||||
@@ -46,16 +47,17 @@ export const CkbCollapsibleSearchHeader: React.FC<
|
||||
refreshing = false,
|
||||
scrollRef,
|
||||
defaultExpanded = false,
|
||||
alwaysShowStatusTabs = true,
|
||||
className = "",
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const inputRef = useRef<any>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(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);
|
||||
@@ -63,7 +65,6 @@ export const CkbCollapsibleSearchHeader: React.FC<
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
// 列表下滑 >40px 自动收起(仅在无关键词时)
|
||||
useEffect(() => {
|
||||
const el = scrollRef?.current;
|
||||
if (!el) return;
|
||||
@@ -76,10 +77,33 @@ export const CkbCollapsibleSearchHeader: React.FC<
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [scrollRef, hasKeyword]);
|
||||
|
||||
const handleStatusClick = (key: string) => {
|
||||
onStatusChange(key);
|
||||
};
|
||||
|
||||
const statusSegment = (
|
||||
<div className={styles.segmented} role="tablist" aria-label="计划状态筛选">
|
||||
{statusTabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={statusTab === tab.key}
|
||||
className={`${styles.segItem} ${
|
||||
statusTab === tab.key ? styles.segItemActive : ""
|
||||
}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => handleStatusClick(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${styles.wrap} ${className}`}>
|
||||
{/* 紧凑条 */}
|
||||
{!expanded && (
|
||||
<div ref={wrapRef} className={`${styles.wrap} ${className}`}>
|
||||
{!expanded ? (
|
||||
<div className={styles.compact}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -89,7 +113,7 @@ export const CkbCollapsibleSearchHeader: React.FC<
|
||||
>
|
||||
<SearchOutlined className={styles.searchIcon} />
|
||||
<span className={styles.chip}>
|
||||
{hasKeyword ? value.trim() : chipLabel}
|
||||
{hasKeyword ? value.trim() : "搜索计划名称"}
|
||||
<DownOutlined className={styles.chipArrow} />
|
||||
</span>
|
||||
</button>
|
||||
@@ -103,10 +127,7 @@ export const CkbCollapsibleSearchHeader: React.FC<
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开态 */}
|
||||
{expanded && (
|
||||
) : (
|
||||
<div className={styles.expanded}>
|
||||
<div className={styles.searchRow}>
|
||||
<Input
|
||||
@@ -117,8 +138,12 @@ export const CkbCollapsibleSearchHeader: React.FC<
|
||||
onChange={e => onChange(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onBlur={() => {
|
||||
if (!hasKeyword) setExpanded(false);
|
||||
onBlur={e => {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (wrapRef.current?.contains(next)) return;
|
||||
if (!hasKeyword) {
|
||||
window.setTimeout(() => setExpanded(false), 120);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{onRefresh && (
|
||||
@@ -130,22 +155,10 @@ export const CkbCollapsibleSearchHeader: React.FC<
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{(alwaysShowStatusTabs || expanded) && statusSegment}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
padding: 12px 12px 0;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -80,6 +87,16 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controlHint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface CkbDeviceCardProps {
|
||||
avatar?: React.ReactNode;
|
||||
imei?: string;
|
||||
wechatId?: string;
|
||||
/** Hook/S2 展示昵称(与设备 memo 可能不同) */
|
||||
wechatNickname?: string;
|
||||
workphoneDeviceId?: string;
|
||||
totalFriend?: number | string;
|
||||
todayAddNum?: number;
|
||||
holderName?: string;
|
||||
@@ -20,6 +23,9 @@ export interface CkbDeviceCardProps {
|
||||
location?: string;
|
||||
/** 设备所在地当前时间 */
|
||||
localTime?: string;
|
||||
/** 工作手机 SDK 真机可控(143-A4) */
|
||||
canControl?: boolean;
|
||||
controlReason?: string;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onSelectChange?: (checked: boolean) => void;
|
||||
@@ -35,12 +41,16 @@ export const CkbDeviceCard: React.FC<CkbDeviceCardProps> = ({
|
||||
avatar,
|
||||
imei,
|
||||
wechatId,
|
||||
wechatNickname,
|
||||
workphoneDeviceId,
|
||||
totalFriend,
|
||||
todayAddNum,
|
||||
holderName,
|
||||
createdByName,
|
||||
location,
|
||||
localTime,
|
||||
canControl,
|
||||
controlReason,
|
||||
selectable,
|
||||
selected,
|
||||
onSelectChange,
|
||||
@@ -64,9 +74,17 @@ export const CkbDeviceCard: React.FC<CkbDeviceCardProps> = ({
|
||||
<MobileOutlined />
|
||||
{title}
|
||||
</span>
|
||||
<CkbBadge tone={online ? "success" : "danger"}>
|
||||
{online ? "在线" : "离线"}
|
||||
</CkbBadge>
|
||||
<div className={styles.badges}>
|
||||
<CkbBadge tone={online ? "success" : "danger"}>
|
||||
{online ? "在线" : "离线"}
|
||||
</CkbBadge>
|
||||
{online && canControl === false && controlReason && (
|
||||
<CkbBadge tone="warning">不可控</CkbBadge>
|
||||
)}
|
||||
{online && canControl === true && (
|
||||
<CkbBadge tone="success">可控</CkbBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.main} onClick={onClick} role={onClick ? "button" : undefined}>
|
||||
@@ -78,8 +96,22 @@ export const CkbDeviceCard: React.FC<CkbDeviceCardProps> = ({
|
||||
</div>
|
||||
<div className={styles.line}>
|
||||
<span className={styles.label}>微信号</span>
|
||||
<span className={styles.value}>{wechatId || "未绑定"}</span>
|
||||
<span className={styles.value}>
|
||||
{wechatNickname?.trim() || wechatId || "未绑定"}
|
||||
</span>
|
||||
</div>
|
||||
{wechatId && wechatNickname && wechatNickname !== wechatId && (
|
||||
<div className={styles.line}>
|
||||
<span className={styles.label}>wxid</span>
|
||||
<span className={styles.value}>{wechatId}</span>
|
||||
</div>
|
||||
)}
|
||||
{workphoneDeviceId && (
|
||||
<div className={styles.line}>
|
||||
<span className={styles.label}>工作手机</span>
|
||||
<span className={styles.value}>{workphoneDeviceId}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.metrics}>
|
||||
<span>
|
||||
好友数 <em>{totalFriend ?? "-"}</em>
|
||||
@@ -88,6 +120,9 @@ export const CkbDeviceCard: React.FC<CkbDeviceCardProps> = ({
|
||||
<span className={styles.today}>今日新增 +{todayAddNum}</span>
|
||||
)}
|
||||
</div>
|
||||
{canControl === false && controlReason && (
|
||||
<div className={styles.controlHint}>{controlReason}</div>
|
||||
)}
|
||||
{(holderName || createdByName || location || localTime) && (
|
||||
<div className={styles.meta}>
|
||||
{location && (
|
||||
|
||||
@@ -241,6 +241,19 @@ export const SceneBrandIcons: Record<number, React.FC<Props>> = {
|
||||
<path d="M15 26h18M24 17v18" stroke="#fff" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</CkbSvg>
|
||||
),
|
||||
|
||||
/** AI客服获客 · 机器人客服(CKB-140) */
|
||||
13: ({ size = 28 }) => (
|
||||
<CkbSvg size={size} viewBox="0 0 48 48">
|
||||
<rect x="8" y="14" width="32" height="24" rx="8" fill="#0EA5E9" />
|
||||
<path d="M24 8v6" stroke="#0EA5E9" strokeWidth="2.6" strokeLinecap="round" />
|
||||
<circle cx="24" cy="7" r="2.6" fill="#0EA5E9" />
|
||||
<circle cx="17.5" cy="25" r="3" fill="#fff" />
|
||||
<circle cx="30.5" cy="25" r="3" fill="#fff" />
|
||||
<path d="M17 32.5c2 1.8 12 1.8 14 0" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" fill="none" />
|
||||
<path d="M5 22v8M43 22v8" stroke="#38BDF8" strokeWidth="3" strokeLinecap="round" />
|
||||
</CkbSvg>
|
||||
),
|
||||
};
|
||||
|
||||
export const SCENE_ICON_BG: Record<number, string> = {
|
||||
@@ -256,6 +269,7 @@ export const SCENE_ICON_BG: Record<number, string> = {
|
||||
10: "#fff7ed",
|
||||
11: "#fafafa",
|
||||
12: "#eff6ff",
|
||||
13: "#ecfeff",
|
||||
};
|
||||
|
||||
export function renderSceneBrandIcon(
|
||||
|
||||
@@ -35,38 +35,54 @@ function sourceLabel(item: TodayLead): string {
|
||||
|
||||
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 [dateStr, setDateStr] = useState("");
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const pageRef = useRef(1);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoadError("");
|
||||
try {
|
||||
const res: any = await getTodayLeads({
|
||||
page: pageRef.current,
|
||||
pageSize: PAGE_SIZE,
|
||||
date: dateStr,
|
||||
});
|
||||
const rows: TodayLead[] = Array.isArray(res?.list) ? res.list : [];
|
||||
setDateStr(String(res?.date || ""));
|
||||
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);
|
||||
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);
|
||||
const errorMessage =
|
||||
typeof err === "string"
|
||||
? err
|
||||
: (err as { message?: string })?.message || "今日获客明细加载失败";
|
||||
setLoadError(errorMessage);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}, [dateStr]);
|
||||
}, []);
|
||||
|
||||
const retry = () => {
|
||||
pageRef.current = 1;
|
||||
setList([]);
|
||||
setTotal(0);
|
||||
setHasMore(true);
|
||||
setLoadError("");
|
||||
void loadMore();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
pageRef.current = 1;
|
||||
@@ -77,20 +93,32 @@ const TodayLeadsPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title="今日获客" glass gradient="brand" />}
|
||||
>
|
||||
<Layout header={<NavCommon title="今日获客" glass gradient="brand" />}>
|
||||
<CkbPageShell className={style.page} padded={false}>
|
||||
<div className={style.subBar}>
|
||||
{dateStr} · 共 {total} 条
|
||||
{dateStr || "今日"} · 共 {total} 条
|
||||
</div>
|
||||
|
||||
{list.length === 0 && !hasMore ? (
|
||||
{loadError && list.length === 0 ? (
|
||||
<CkbEmptyState
|
||||
title="今日获客明细加载失败"
|
||||
description={loadError}
|
||||
action={
|
||||
<Button color="primary" fill="outline" onClick={retry}>
|
||||
重新加载
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : list.length === 0 && !hasMore ? (
|
||||
<CkbEmptyState
|
||||
title="今日暂无新增获客"
|
||||
description="去场景获客看看,开启更多获客计划"
|
||||
action={
|
||||
<Button color="primary" fill="outline" onClick={() => navigate("/scenarios")}>
|
||||
<Button
|
||||
color="primary"
|
||||
fill="outline"
|
||||
onClick={() => navigate("/scenarios")}
|
||||
>
|
||||
去场景获客
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Tabs, Switch, SpinLoading, Button, Popup, Input } from "antd-mobile";
|
||||
import {
|
||||
Tabs,
|
||||
Switch,
|
||||
SpinLoading,
|
||||
Button,
|
||||
Popup,
|
||||
Input,
|
||||
Toast,
|
||||
} from "antd-mobile";
|
||||
import {
|
||||
ApiOutlined,
|
||||
CameraOutlined,
|
||||
RedoOutlined,
|
||||
SendOutlined,
|
||||
WifiOutlined,
|
||||
ThunderboltOutlined,
|
||||
EditOutlined,
|
||||
TeamOutlined,
|
||||
TagsOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
@@ -15,6 +29,17 @@ import {
|
||||
fetchDeviceTaskConfig,
|
||||
updateDeviceTaskConfig,
|
||||
updateDeviceMemo,
|
||||
fetchWorkPhoneStatus,
|
||||
fetchWorkPhoneHookProbe,
|
||||
fetchWorkPhoneScreenshot,
|
||||
fetchWorkPhoneContacts,
|
||||
fetchWorkPhoneGroups,
|
||||
fetchWorkPhoneTags,
|
||||
sendWorkPhoneFileHelperMessage,
|
||||
type WorkPhoneCollection,
|
||||
type WorkPhoneDevice,
|
||||
type WorkPhoneHookProbe,
|
||||
type WorkPhoneOverview,
|
||||
} from "./api";
|
||||
import type { Device, WechatAccount, HandleLog } from "@/types/device";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
@@ -47,6 +72,23 @@ const DeviceDetail: React.FC = () => {
|
||||
const [editNameVisible, setEditNameVisible] = useState(false);
|
||||
const [editNameValue, setEditNameValue] = useState("");
|
||||
const [editNameLoading, setEditNameLoading] = useState(false);
|
||||
const [workPhoneLoading, setWorkPhoneLoading] = useState(false);
|
||||
const [workPhoneOverview, setWorkPhoneOverview] =
|
||||
useState<WorkPhoneOverview | null>(null);
|
||||
const [workPhoneDevice, setWorkPhoneDevice] =
|
||||
useState<WorkPhoneDevice | null>(null);
|
||||
const [hookProbe, setHookProbe] = useState<WorkPhoneHookProbe | null>(null);
|
||||
const [hookLoading, setHookLoading] = useState(false);
|
||||
const [screenshotLoading, setScreenshotLoading] = useState(false);
|
||||
const [screenshotUrl, setScreenshotUrl] = useState("");
|
||||
const [messageLoading, setMessageLoading] = useState(false);
|
||||
const [messageContent, setMessageContent] =
|
||||
useState("[存客宝验收] 工作手机安全链路测试");
|
||||
const [collectionLoading, setCollectionLoading] = useState<
|
||||
"contacts" | "groups" | "tags" | ""
|
||||
>("");
|
||||
const [collectionSummary, setCollectionSummary] = useState("");
|
||||
const [controlResult, setControlResult] = useState("");
|
||||
|
||||
const applyFeatures = (features?: Device["features"]) => {
|
||||
if (!features) return;
|
||||
@@ -105,14 +147,51 @@ const DeviceDetail: React.FC = () => {
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadWorkPhone = useCallback(async () => {
|
||||
setWorkPhoneLoading(true);
|
||||
try {
|
||||
const overview = await fetchWorkPhoneStatus();
|
||||
const imei = String(device?.imei || "").toLowerCase();
|
||||
const ckbDeviceId = String(device?.id || id || "");
|
||||
const matched =
|
||||
overview.devices?.find(item => {
|
||||
const deviceId = String(item.device_id || "").toLowerCase();
|
||||
const deviceMd5 = String(item.device_id_md5 || "").toLowerCase();
|
||||
const ckbId = String(item.ckb_device_id || "");
|
||||
const ckbImei = String(item.ckb_imei || "").toLowerCase();
|
||||
return (
|
||||
(ckbDeviceId !== "" && ckbId === ckbDeviceId) ||
|
||||
(imei !== "" && (ckbImei === imei || deviceId === imei || deviceMd5 === imei))
|
||||
);
|
||||
}) || null;
|
||||
setWorkPhoneOverview(overview);
|
||||
setWorkPhoneDevice(matched);
|
||||
setControlResult(
|
||||
matched
|
||||
? `已匹配真机 ${matched.device_id}`
|
||||
: "当前设备 IMEI 未匹配到在线工作手机",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
setWorkPhoneOverview(null);
|
||||
setWorkPhoneDevice(null);
|
||||
setControlResult(`状态读取失败:${String(e)}`);
|
||||
} finally {
|
||||
setWorkPhoneLoading(false);
|
||||
}
|
||||
}, [device?.id, device?.imei, id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail();
|
||||
}, [id, loadDetail]);
|
||||
|
||||
// CKB-148-A5:Tab 合并为「基本信息(含远程连接/操作/开关)+ 操作记录」
|
||||
useEffect(() => {
|
||||
if (tab === "info") loadAccounts();
|
||||
if (tab === "info") {
|
||||
loadAccounts();
|
||||
loadWorkPhone();
|
||||
}
|
||||
if (tab === "logs") loadLogs();
|
||||
}, [tab, loadAccounts, loadLogs]);
|
||||
}, [tab, loadAccounts, loadLogs, loadWorkPhone]);
|
||||
|
||||
const handleFeatureChange = async (
|
||||
feature: keyof Device["features"],
|
||||
@@ -191,7 +270,121 @@ const DeviceDetail: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isOnline = device?.status === "online" || device?.alive === 1;
|
||||
const handleHookProbe = async () => {
|
||||
if (!workPhoneDevice?.device_id) return;
|
||||
setHookLoading(true);
|
||||
try {
|
||||
const result = await fetchWorkPhoneHookProbe(workPhoneDevice.device_id);
|
||||
setHookProbe(result);
|
||||
setControlResult(
|
||||
`Hook 探测成功:微信 ${result.wechat_version || "未知版本"} · ${
|
||||
result.transport || "未知通道"
|
||||
}`,
|
||||
);
|
||||
Toast.show({ content: "Hook 探测成功", position: "top" });
|
||||
} catch (e: unknown) {
|
||||
setControlResult(`Hook 探测失败:${String(e)}`);
|
||||
} finally {
|
||||
setHookLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScreenshot = async () => {
|
||||
if (!workPhoneDevice?.device_id) return;
|
||||
setScreenshotLoading(true);
|
||||
try {
|
||||
const result = await fetchWorkPhoneScreenshot(workPhoneDevice.device_id);
|
||||
if (!result.base64) {
|
||||
throw new Error("截图未返回图像数据");
|
||||
}
|
||||
setScreenshotUrl(`data:image/png;base64,${result.base64}`);
|
||||
setControlResult(
|
||||
`截图成功:${result.width || "-"} × ${result.height || "-"}`,
|
||||
);
|
||||
Toast.show({ content: "真机截图已更新", position: "top" });
|
||||
} catch (e: unknown) {
|
||||
setControlResult(`截图失败:${String(e)}`);
|
||||
} finally {
|
||||
setScreenshotLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSafeMessage = async () => {
|
||||
const content = messageContent.trim();
|
||||
if (!workPhoneDevice?.device_id || !content) return;
|
||||
setMessageLoading(true);
|
||||
try {
|
||||
await sendWorkPhoneFileHelperMessage(workPhoneDevice.device_id, content);
|
||||
setControlResult("安全消息已发送到文件传输助手");
|
||||
Toast.show({ content: "已发送到文件传输助手", position: "top" });
|
||||
} catch (e: unknown) {
|
||||
setControlResult(`安全消息发送失败:${String(e)}`);
|
||||
} finally {
|
||||
setMessageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const summarizeCollection = (
|
||||
title: string,
|
||||
result: WorkPhoneCollection,
|
||||
listKey: "contacts" | "groups" | "labels",
|
||||
) => {
|
||||
const payload = result.data || result;
|
||||
const list = Array.isArray(payload[listKey]) ? payload[listKey] : [];
|
||||
const total =
|
||||
payload.total_count ?? payload.raw_total_count ?? payload.count ?? list.length;
|
||||
return `${title}读取成功:返回 ${list.length} 条 / 总数 ${total}`;
|
||||
};
|
||||
|
||||
const handleCollectionRead = async (
|
||||
kind: "contacts" | "groups" | "tags",
|
||||
) => {
|
||||
if (!workPhoneDevice?.device_id) return;
|
||||
setCollectionLoading(kind);
|
||||
try {
|
||||
const loaders = {
|
||||
contacts: () => fetchWorkPhoneContacts(workPhoneDevice.device_id),
|
||||
groups: () => fetchWorkPhoneGroups(workPhoneDevice.device_id),
|
||||
tags: () => fetchWorkPhoneTags(workPhoneDevice.device_id),
|
||||
};
|
||||
const labels = {
|
||||
contacts: ["好友", "contacts"] as const,
|
||||
groups: ["群", "groups"] as const,
|
||||
tags: ["标签", "labels"] as const,
|
||||
};
|
||||
const result = await loaders[kind]();
|
||||
const [title, listKey] = labels[kind];
|
||||
const summary = summarizeCollection(title, result, listKey);
|
||||
setCollectionSummary(summary);
|
||||
setControlResult(summary);
|
||||
Toast.show({ content: summary, position: "top" });
|
||||
} catch (e: unknown) {
|
||||
setCollectionSummary(`${kind} 读取失败:${String(e)}`);
|
||||
setControlResult(`${kind} 读取失败:${String(e)}`);
|
||||
} finally {
|
||||
setCollectionLoading("");
|
||||
}
|
||||
};
|
||||
|
||||
// CKB-148-A2:SDK 在线且 map 命中 → 在线;仅 DB alive=0 但 SDK 在线 → 「工作手机在线」
|
||||
const dbOnline = device?.status === "online" || device?.alive === 1;
|
||||
const sdkRealtime = !!device?.sdkOnline;
|
||||
const isOnline = dbOnline || sdkRealtime;
|
||||
const onlineLabel = dbOnline ? "在线" : sdkRealtime ? "工作手机在线" : "离线";
|
||||
const sdkOnline =
|
||||
sdkRealtime ||
|
||||
(workPhoneDevice?.status === "online" && !!workPhoneOverview?.sdk_online);
|
||||
// CKB-148-A3:电量拿不到显示「—」,禁止展示过期假数据为实时
|
||||
const batteryText =
|
||||
device?.battery == null ? "—" : `${device.battery}%`;
|
||||
const batteryHint =
|
||||
device?.batterySource === "sdk_realtime"
|
||||
? "实时"
|
||||
: device?.batterySource === "extra_cache"
|
||||
? "缓存"
|
||||
: "";
|
||||
const locationText =
|
||||
device?.location || device?.city || device?.province || "位置未知";
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@@ -240,8 +433,8 @@ const DeviceDetail: React.FC = () => {
|
||||
<EditOutlined className={styles.editIcon} />
|
||||
</button>
|
||||
<div className={styles.heroImei}>IMEI {device.imei}</div>
|
||||
<CkbBadge tone={isOnline ? "success" : "danger"}>
|
||||
{isOnline ? "在线" : "离线"}
|
||||
<CkbBadge tone={dbOnline ? "success" : sdkRealtime ? "warning" : "danger"}>
|
||||
{onlineLabel}
|
||||
</CkbBadge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,7 +442,11 @@ const DeviceDetail: React.FC = () => {
|
||||
<div className={styles.heroMetrics}>
|
||||
<div>
|
||||
<ThunderboltOutlined />
|
||||
<span>{device.battery ?? "-"}%</span>
|
||||
<span>
|
||||
电量 {batteryText}
|
||||
{batteryHint ? `(${batteryHint})` : ""}
|
||||
{device.charging ? " 充电中" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<WifiOutlined />
|
||||
@@ -260,17 +457,15 @@ const DeviceDetail: React.FC = () => {
|
||||
<div className={styles.heroMeta}>
|
||||
<div>微信号 {device.wechatId || "未绑定"}</div>
|
||||
<div>好友数 {device.totalFriend ?? "-"}</div>
|
||||
<div>手机位置 {locationText}</div>
|
||||
{device.holderName && <div>持有人 {device.holderName}</div>}
|
||||
{device.createdByName && (
|
||||
<div>添加人 {device.createdByName}</div>
|
||||
)}
|
||||
{device.createdByName && <div>添加人 {device.createdByName}</div>}
|
||||
{device.lastActive && <div>最后活跃 {device.lastActive}</div>}
|
||||
</div>
|
||||
</CkbListCard>
|
||||
|
||||
<Tabs activeKey={tab} onChange={setTab} className={styles.tabs}>
|
||||
<Tabs.Tab title="基本信息" key="info" />
|
||||
<Tabs.Tab title="功能开关" key="features" />
|
||||
<Tabs.Tab title="操作记录" key="logs" />
|
||||
</Tabs>
|
||||
|
||||
@@ -286,7 +481,7 @@ const DeviceDetail: React.FC = () => {
|
||||
) : (
|
||||
accounts.map(acc => (
|
||||
<button
|
||||
key={acc.id}
|
||||
key={acc.wechatId || acc.id}
|
||||
type="button"
|
||||
className={styles.accountItem}
|
||||
onClick={() =>
|
||||
@@ -308,6 +503,9 @@ const DeviceDetail: React.FC = () => {
|
||||
{acc.wechatId} · 好友 {acc.totalFriend}
|
||||
</div>
|
||||
</div>
|
||||
{acc.isCurrent && (
|
||||
<CkbBadge tone="info">当前登录</CkbBadge>
|
||||
)}
|
||||
<CkbBadge
|
||||
tone={acc.wechatAlive === 1 ? "success" : "neutral"}
|
||||
>
|
||||
@@ -322,35 +520,189 @@ const DeviceDetail: React.FC = () => {
|
||||
</CkbListCard>
|
||||
)}
|
||||
|
||||
{tab === "features" && (
|
||||
<CkbListCard className={styles.panel}>
|
||||
{["autoAddFriend", "autoReply", "momentsSync", "aiChat"].map(
|
||||
feature => (
|
||||
<div key={feature} className={styles.switchRow}>
|
||||
<div>
|
||||
<div className={styles.switchTitle}>
|
||||
{getFeatureName(feature)}
|
||||
</div>
|
||||
<div className={styles.switchDesc}>同步至工作手机</div>
|
||||
{tab === "info" && (
|
||||
<div className={styles.controlStack}>
|
||||
<CkbListCard className={styles.panel}>
|
||||
<div className={styles.controlHeader}>
|
||||
<div>
|
||||
<div className={styles.panelTitle}>远程连接状态</div>
|
||||
<div className={styles.controlSub}>
|
||||
{workPhoneDevice?.device_id ||
|
||||
device.workphoneDeviceId ||
|
||||
"未匹配真机"}
|
||||
</div>
|
||||
<Switch
|
||||
checked={
|
||||
!!device.features?.[
|
||||
feature as keyof Device["features"]
|
||||
]
|
||||
}
|
||||
loading={!!featureSaving[feature]}
|
||||
onChange={checked =>
|
||||
handleFeatureChange(
|
||||
feature as keyof Device["features"],
|
||||
checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</CkbListCard>
|
||||
<Button
|
||||
size="mini"
|
||||
loading={workPhoneLoading}
|
||||
onClick={loadWorkPhone}
|
||||
>
|
||||
<RedoOutlined /> 刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* CKB-148-A7:大白话文案,禁止裸写 SDK/WebSocket/ADB */}
|
||||
<div className={styles.statusGrid}>
|
||||
<div>
|
||||
<span>工作手机服务</span>
|
||||
<strong>
|
||||
{workPhoneOverview?.sdk_online ? "正常" : "未连接"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>实时连接</span>
|
||||
<strong>{workPhoneOverview?.ws_online || 0} 台</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>USB 调试连接</span>
|
||||
<strong>{workPhoneOverview?.adb_count || 0} 台</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>本机真机</span>
|
||||
<strong>{sdkOnline ? "在线" : "未连接"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{!device.canControl && device.controlReason && (
|
||||
<div className={styles.controlSub}>
|
||||
暂不可远程操作:{device.controlReason}
|
||||
</div>
|
||||
)}
|
||||
</CkbListCard>
|
||||
|
||||
<CkbListCard className={styles.panel}>
|
||||
<div className={styles.panelTitle}>远程操作</div>
|
||||
<div className={styles.controlActions}>
|
||||
<Button
|
||||
size="small"
|
||||
loading={hookLoading}
|
||||
disabled={!workPhoneDevice}
|
||||
onClick={handleHookProbe}
|
||||
>
|
||||
<ApiOutlined /> 微信读取检测
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
loading={screenshotLoading}
|
||||
disabled={!workPhoneDevice}
|
||||
onClick={handleScreenshot}
|
||||
>
|
||||
<CameraOutlined /> 截图
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hookProbe && (
|
||||
<div className={styles.probeInfo}>
|
||||
<span>微信 {hookProbe.wechat_version || "-"}</span>
|
||||
<span>
|
||||
微信读取 {hookProbe.supports_hook ? "可用" : "不可用"}
|
||||
</span>
|
||||
<span>
|
||||
底层插件{" "}
|
||||
{hookProbe.frida_available_reported ? "可用" : "未就绪"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screenshotUrl && (
|
||||
<img
|
||||
className={styles.screenshot}
|
||||
src={screenshotUrl}
|
||||
alt="工作手机实时截图"
|
||||
/>
|
||||
)}
|
||||
</CkbListCard>
|
||||
|
||||
<CkbListCard className={styles.panel}>
|
||||
<div className={styles.panelTitle}>微信数据读取</div>
|
||||
<div className={styles.controlSub}>
|
||||
通过存客宝 BFF 读取好友、群和标签分页
|
||||
</div>
|
||||
<div className={styles.controlActions}>
|
||||
<Button
|
||||
size="small"
|
||||
loading={collectionLoading === "contacts"}
|
||||
disabled={!workPhoneDevice}
|
||||
onClick={() => handleCollectionRead("contacts")}
|
||||
>
|
||||
<UserOutlined /> 好友
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
loading={collectionLoading === "groups"}
|
||||
disabled={!workPhoneDevice}
|
||||
onClick={() => handleCollectionRead("groups")}
|
||||
>
|
||||
<TeamOutlined /> 群
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
loading={collectionLoading === "tags"}
|
||||
disabled={!workPhoneDevice}
|
||||
onClick={() => handleCollectionRead("tags")}
|
||||
>
|
||||
<TagsOutlined /> 标签
|
||||
</Button>
|
||||
</div>
|
||||
{collectionSummary && (
|
||||
<div className={styles.probeInfo}>
|
||||
<span>{collectionSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
</CkbListCard>
|
||||
|
||||
<CkbListCard className={styles.panel}>
|
||||
<div className={styles.panelTitle}>发送测试消息</div>
|
||||
<div className={styles.controlSub}>固定发送至文件传输助手</div>
|
||||
<Input
|
||||
value={messageContent}
|
||||
onChange={setMessageContent}
|
||||
placeholder="输入验收消息"
|
||||
clearable
|
||||
className={styles.messageInput}
|
||||
/>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
loading={messageLoading}
|
||||
disabled={!workPhoneDevice || !messageContent.trim()}
|
||||
onClick={handleSafeMessage}
|
||||
>
|
||||
<SendOutlined /> 发送验证消息
|
||||
</Button>
|
||||
</CkbListCard>
|
||||
|
||||
<CkbListCard className={styles.panel}>
|
||||
<div className={styles.panelTitle}>自动化开关</div>
|
||||
{["autoAddFriend", "autoReply", "momentsSync", "aiChat"].map(
|
||||
feature => (
|
||||
<div key={feature} className={styles.switchRow}>
|
||||
<div>
|
||||
<div className={styles.switchTitle}>
|
||||
{getFeatureName(feature)}
|
||||
</div>
|
||||
<div className={styles.switchDesc}>同步至工作手机</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={
|
||||
!!device.features?.[
|
||||
feature as keyof Device["features"]
|
||||
]
|
||||
}
|
||||
loading={!!featureSaving[feature]}
|
||||
onChange={checked =>
|
||||
handleFeatureChange(
|
||||
feature as keyof Device["features"],
|
||||
checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</CkbListCard>
|
||||
|
||||
<div className={styles.controlResult}>{controlResult}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "logs" && (
|
||||
|
||||
@@ -12,6 +12,72 @@ export interface DeviceListResponse {
|
||||
stats?: DeviceStats;
|
||||
}
|
||||
|
||||
export interface WorkPhoneDevice {
|
||||
device_id: string;
|
||||
device_id_md5?: string;
|
||||
ckb_device_id?: number;
|
||||
ckb_imei?: string;
|
||||
ckb_memo?: string;
|
||||
ckb_alive?: number;
|
||||
status?: string;
|
||||
last_heartbeat?: string;
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
export interface WorkPhoneOverview {
|
||||
sdk_online?: boolean;
|
||||
sdk_version?: string;
|
||||
ws_online?: number;
|
||||
adb_count?: number;
|
||||
device_total?: number;
|
||||
device_online?: number;
|
||||
device_offline?: number;
|
||||
devices?: WorkPhoneDevice[];
|
||||
}
|
||||
|
||||
export interface WorkPhoneHookProbe {
|
||||
device_id?: string;
|
||||
device_id_md5?: string;
|
||||
supports_hook?: boolean;
|
||||
frida_available_reported?: boolean;
|
||||
wechat_version?: string;
|
||||
transport?: string;
|
||||
profile?: {
|
||||
success?: boolean;
|
||||
profile?: {
|
||||
nickname?: string;
|
||||
wxid?: string;
|
||||
wechat_version?: string;
|
||||
};
|
||||
channel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkPhoneScreenshot {
|
||||
base64?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: number;
|
||||
fallback?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkPhoneCollection {
|
||||
count?: number;
|
||||
total_count?: number;
|
||||
raw_total_count?: number;
|
||||
contacts?: unknown[];
|
||||
groups?: unknown[];
|
||||
labels?: unknown[];
|
||||
data?: {
|
||||
count?: number;
|
||||
total_count?: number;
|
||||
raw_total_count?: number;
|
||||
contacts?: unknown[];
|
||||
groups?: unknown[];
|
||||
labels?: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
// 获取设备列表
|
||||
export const fetchDeviceList = (params: {
|
||||
page?: number;
|
||||
@@ -68,8 +134,92 @@ export const fetchAddResults = (params?: { accountId?: string | number }) =>
|
||||
skipErrorToast: true,
|
||||
});
|
||||
|
||||
// 工作手机 Agent 扫码绑定二维码(CKB-147 第二通道)
|
||||
export const fetchWorkphoneQRCode = () =>
|
||||
request("/v1/devices/workphone-qrcode", {}, "POST", {
|
||||
skipErrorToast: true,
|
||||
}) as Promise<{ qrCode: string; knownDeviceIds?: string; config?: Record<string, unknown> }>;
|
||||
|
||||
// 工作手机扫码绑定结果轮询
|
||||
export const fetchWorkphoneAddResults = (knownDeviceIds?: string) =>
|
||||
request(
|
||||
"/v1/devices/workphone-add-results",
|
||||
knownDeviceIds ? { knownDeviceIds } : {},
|
||||
"GET",
|
||||
{ skipErrorToast: true },
|
||||
) as Promise<{ added: boolean; device?: import("@/types/device").Device | null }>;
|
||||
|
||||
// 更新设备备注/名称
|
||||
export const updateDeviceMemo = (id: string | number, memo: string) =>
|
||||
request(`/v1/devices/${id}/memo`, { memo }, "POST", {
|
||||
skipErrorToast: true,
|
||||
});
|
||||
|
||||
// 工作手机 BFF:状态、Hook、截图与安全消息验证
|
||||
export const fetchWorkPhoneStatus = () =>
|
||||
request("/v1/workphone/status", undefined, "GET", {
|
||||
skipErrorToast: true,
|
||||
}) as Promise<WorkPhoneOverview>;
|
||||
|
||||
export const fetchWorkPhoneHookProbe = (deviceId: string) =>
|
||||
request(
|
||||
"/v1/workphone/hook/probe",
|
||||
{ device_id: deviceId },
|
||||
"GET",
|
||||
{ skipErrorToast: true },
|
||||
0,
|
||||
) as Promise<WorkPhoneHookProbe>;
|
||||
|
||||
export const fetchWorkPhoneScreenshot = (deviceId: string) =>
|
||||
request(
|
||||
"/v1/workphone/screenshot",
|
||||
{ device_id: deviceId },
|
||||
"POST",
|
||||
{ skipErrorToast: true },
|
||||
0,
|
||||
) as Promise<WorkPhoneScreenshot>;
|
||||
|
||||
export const fetchWorkPhoneContacts = (deviceId: string) =>
|
||||
request(
|
||||
"/v1/workphone/contacts",
|
||||
{ device_id: deviceId, platform: "wechat", limit: 5 },
|
||||
"GET",
|
||||
{ skipErrorToast: true },
|
||||
0,
|
||||
) as Promise<WorkPhoneCollection>;
|
||||
|
||||
export const fetchWorkPhoneGroups = (deviceId: string) =>
|
||||
request(
|
||||
"/v1/workphone/group/list",
|
||||
{ device_id: deviceId, platform: "wechat", limit: 5 },
|
||||
"GET",
|
||||
{ skipErrorToast: true },
|
||||
0,
|
||||
) as Promise<WorkPhoneCollection>;
|
||||
|
||||
export const fetchWorkPhoneTags = (deviceId: string) =>
|
||||
request(
|
||||
"/v1/workphone/tag/list",
|
||||
{ device_id: deviceId, platform: "wechat", limit: 20 },
|
||||
"GET",
|
||||
{ skipErrorToast: true },
|
||||
0,
|
||||
) as Promise<WorkPhoneCollection>;
|
||||
|
||||
export const sendWorkPhoneFileHelperMessage = (
|
||||
deviceId: string,
|
||||
content: string,
|
||||
) =>
|
||||
request(
|
||||
"/v1/workphone/message/send",
|
||||
{
|
||||
device_id: deviceId,
|
||||
platform: "wechat",
|
||||
to_id: "filehelper",
|
||||
content,
|
||||
msg_type: "text",
|
||||
},
|
||||
"POST",
|
||||
{ skipErrorToast: true },
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -138,6 +138,111 @@
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.controlStack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controlHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.panelTitle {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.controlSub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.statusGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
|
||||
div {
|
||||
min-height: 62px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 15px;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.controlActions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.probeInfo {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
span {
|
||||
padding: 4px 7px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 6px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
display: block;
|
||||
width: min(100%, 280px);
|
||||
max-height: 500px;
|
||||
object-fit: contain;
|
||||
margin: 14px auto 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.messageInput {
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.controlResult {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,6 +11,8 @@ import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
fetchDeviceList,
|
||||
fetchDeviceQRCode,
|
||||
fetchWorkphoneQRCode,
|
||||
fetchWorkphoneAddResults,
|
||||
addDeviceByImei,
|
||||
deleteDevice,
|
||||
fetchAddResults,
|
||||
@@ -36,7 +38,8 @@ const Devices: React.FC = () => {
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("online");
|
||||
// CKB-147-A3:默认「全部」,避免 SDK 在线但 DB alive=0 时整页空白
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selected, setSelected] = useState<(string | number)[]>([]);
|
||||
@@ -45,6 +48,10 @@ const Devices: React.FC = () => {
|
||||
const [addTab, setAddTab] = useState("scan");
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||
const [wpQrLoading, setWpQrLoading] = useState(false);
|
||||
const [wpQrCode, setWpQrCode] = useState<string | null>(null);
|
||||
const wpKnownIdsRef = useRef<string>("");
|
||||
const wpQrCodeRef = useRef<string | null>(null);
|
||||
const [imei, setImei] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
@@ -106,24 +113,49 @@ const Devices: React.FC = () => {
|
||||
if (isPolling) return;
|
||||
setIsPolling(true);
|
||||
|
||||
// CKB-147-A5:奥创 add-results + 工作手机 workphone-add-results 双通道合并轮询,
|
||||
// 任一通道 added=true 即视为绑定成功(147-A6 Toast + 刷新列表)
|
||||
const onBindSuccess = async (channel: "奥创" | "工作手机", memo?: string) => {
|
||||
setAddVisible(false);
|
||||
setIsPolling(false);
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
Toast.show({
|
||||
icon: "success",
|
||||
content: memo
|
||||
? `设备绑定成功(${channel}):${memo}`
|
||||
: `设备绑定成功(${channel}通道)`,
|
||||
duration: 3000,
|
||||
});
|
||||
if (loadDevicesRef.current) {
|
||||
await loadDevicesRef.current(true);
|
||||
}
|
||||
};
|
||||
|
||||
const pollDeviceStatus = async () => {
|
||||
try {
|
||||
const res = await fetchAddResults(
|
||||
user?.s2_accountId ? { accountId: user.s2_accountId } : {},
|
||||
);
|
||||
if (res.added) {
|
||||
setAddVisible(false);
|
||||
setIsPolling(false);
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
if (loadDevicesRef.current) {
|
||||
await loadDevicesRef.current(true);
|
||||
}
|
||||
await onBindSuccess("奥创", res.device?.memo);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("轮询检查设备状态失败:", error);
|
||||
console.error("奥创通道轮询失败:", error);
|
||||
}
|
||||
|
||||
if (wpQrCodeRef.current) {
|
||||
try {
|
||||
const wpRes = await fetchWorkphoneAddResults(wpKnownIdsRef.current);
|
||||
if (wpRes.added) {
|
||||
await onBindSuccess("工作手机", wpRes.device?.memo ?? undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("工作手机通道轮询失败:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -166,6 +198,29 @@ const Devices: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetWorkphoneQr = async () => {
|
||||
setWpQrLoading(true);
|
||||
setWpQrCode(null);
|
||||
wpQrCodeRef.current = null;
|
||||
try {
|
||||
const res = await fetchWorkphoneQRCode();
|
||||
if (!res?.qrCode) {
|
||||
throw new Error("工作手机二维码为空,请检查 SDK 服务");
|
||||
}
|
||||
setWpQrCode(res.qrCode);
|
||||
wpQrCodeRef.current = res.qrCode;
|
||||
wpKnownIdsRef.current = res.knownDeviceIds || "";
|
||||
startPolling();
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
console.warn("[devices] workphone qrcode failed:", e);
|
||||
Toast.show({ icon: "fail", content: "工作手机二维码获取失败" });
|
||||
return false;
|
||||
} finally {
|
||||
setWpQrLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addDevice = async () => {
|
||||
setAddVisible(true);
|
||||
await handleGetQr();
|
||||
@@ -245,7 +300,7 @@ const Devices: React.FC = () => {
|
||||
};
|
||||
|
||||
const deviceLabel = (device: Device, index: number) =>
|
||||
device.memo?.trim() || `设备 ${index + 1}`;
|
||||
device.memo?.trim() || device.nickname?.trim() || `设备 ${index + 1}`;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@@ -299,9 +354,9 @@ const Devices: React.FC = () => {
|
||||
onChange={k => setStatus(k as StatusFilter)}
|
||||
className={styles.statusTabs}
|
||||
>
|
||||
<Tabs.Tab title="全部" key="all" />
|
||||
<Tabs.Tab title="在线" key="online" />
|
||||
<Tabs.Tab title="离线" key="offline" />
|
||||
<Tabs.Tab title="全部" key="all" />
|
||||
</Tabs>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -366,12 +421,16 @@ const Devices: React.FC = () => {
|
||||
onClick={() => goDetail(device.id!)}
|
||||
imei={device.imei}
|
||||
wechatId={device.wechatId}
|
||||
wechatNickname={device.nickname}
|
||||
workphoneDeviceId={device.workphoneDeviceId}
|
||||
totalFriend={device.totalFriend}
|
||||
todayAddNum={device.todayAddNum}
|
||||
holderName={device.holderName}
|
||||
createdByName={device.createdByName}
|
||||
location={device.location || device.city || device.province}
|
||||
localTime={device.localTime}
|
||||
canControl={device.canControl}
|
||||
controlReason={device.controlReason}
|
||||
avatar={
|
||||
device.avatar ? (
|
||||
<img
|
||||
@@ -403,6 +462,9 @@ const Devices: React.FC = () => {
|
||||
setAddVisible(false);
|
||||
stopPolling();
|
||||
setQrCode(null);
|
||||
setWpQrCode(null);
|
||||
wpQrCodeRef.current = null;
|
||||
wpKnownIdsRef.current = "";
|
||||
}}
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 16,
|
||||
@@ -413,7 +475,8 @@ const Devices: React.FC = () => {
|
||||
<div className={styles.addPopup}>
|
||||
<div className={styles.addTitle}>添加设备</div>
|
||||
<Tabs activeKey={addTab} onChange={setAddTab}>
|
||||
<Tabs.Tab title="扫码添加" key="scan" />
|
||||
<Tabs.Tab title="奥创扫码" key="scan" />
|
||||
<Tabs.Tab title="工作手机扫码" key="workphone" />
|
||||
<Tabs.Tab title="手动输入" key="manual" />
|
||||
</Tabs>
|
||||
{addTab === "scan" && (
|
||||
@@ -429,9 +492,33 @@ const Devices: React.FC = () => {
|
||||
</Button>
|
||||
{qrCode && (
|
||||
<>
|
||||
<img src={qrCode} alt="绑定二维码" className={styles.qrImg} />
|
||||
<img src={qrCode} alt="奥创绑定二维码" className={styles.qrImg} />
|
||||
<p className={styles.qrHint}>
|
||||
请用工作手机 Store 或 AI 数智员工扫码绑定
|
||||
请用奥创私域管理 APP / AI 数智员工扫码绑定
|
||||
</p>
|
||||
{isPolling && (
|
||||
<p className={styles.qrPolling}>正在监听设备添加状态…</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{addTab === "workphone" && (
|
||||
<div className={styles.qrBlock}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleGetWorkphoneQr}
|
||||
loading={wpQrLoading}
|
||||
icon={<QrcodeOutlined />}
|
||||
block
|
||||
>
|
||||
{wpQrCode ? "刷新二维码" : "获取二维码"}
|
||||
</Button>
|
||||
{wpQrCode && (
|
||||
<>
|
||||
<img src={wpQrCode} alt="工作手机绑定二维码" className={styles.qrImg} />
|
||||
<p className={styles.qrHint}>
|
||||
请用工作手机 Agent(Store APP)扫码,设备将自动绑定到本项目
|
||||
</p>
|
||||
{isPolling && (
|
||||
<p className={styles.qrPolling}>正在监听设备添加状态…</p>
|
||||
|
||||
@@ -5,6 +5,49 @@
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.transfer-entry {
|
||||
cursor: pointer;
|
||||
padding: 14px 16px;
|
||||
|
||||
.transfer-entry-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.transfer-entry-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #0ea5e9;
|
||||
background: linear-gradient(135deg, #e0f2fe 0%, #ede9fe 100%);
|
||||
}
|
||||
|
||||
.transfer-entry-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.transfer-entry-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ckb-text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.transfer-entry-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--ckb-text-secondary, #94a3b8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-sticky {
|
||||
background: var(--ckb-bg-app, #f5f7fb);
|
||||
padding: 0 12px 8px;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Input } from "antd-mobile";
|
||||
import { Input, Badge } from "antd-mobile";
|
||||
import {
|
||||
UserAddOutlined,
|
||||
RightOutlined,
|
||||
CustomerServiceOutlined,
|
||||
SwapOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
KefuListSummary,
|
||||
KefuBindStatusFilter,
|
||||
} from "./api";
|
||||
import { getTransferList } from "@/pages/mobile/workspace/transfer-approval/api";
|
||||
import { avatarInitial, resolveAvatarUrl } from "@/utils/avatar";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
@@ -33,6 +35,7 @@ const Kefu: React.FC = () => {
|
||||
const [list, setList] = useState<KefuItem[]>([]);
|
||||
const [summary, setSummary] = useState<KefuListSummary>(defaultSummary);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pendingTransfer, setPendingTransfer] = useState(0);
|
||||
|
||||
const load = async (kw: string, bind: KefuBindStatusFilter = bindFilter) => {
|
||||
setLoading(true);
|
||||
@@ -62,6 +65,13 @@ const Kefu: React.FC = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [keyword, bindFilter]);
|
||||
|
||||
// 转移审批待办数(红点提醒)
|
||||
useEffect(() => {
|
||||
getTransferList({ status: "pending", page: 1, limit: 1 })
|
||||
.then(res => setPendingTransfer(res?.total ?? 0))
|
||||
.catch(() => setPendingTransfer(0));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
@@ -123,6 +133,27 @@ const Kefu: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<CkbPageShell className={style["kefu-page"]}>
|
||||
<CkbListCard
|
||||
className={style["transfer-entry"]}
|
||||
onClick={() => navigate("/workspace/transfer-approval")}
|
||||
>
|
||||
<div className={style["transfer-entry-inner"]}>
|
||||
<Badge content={pendingTransfer > 0 ? pendingTransfer : null}>
|
||||
<div className={style["transfer-entry-icon"]}>
|
||||
<SwapOutlined />
|
||||
</div>
|
||||
</Badge>
|
||||
<div className={style["transfer-entry-body"]}>
|
||||
<div className={style["transfer-entry-title"]}>转移审批</div>
|
||||
<div className={style["transfer-entry-desc"]}>
|
||||
{pendingTransfer > 0
|
||||
? `${pendingTransfer} 条待审批 · 微信号/设备/好友转移`
|
||||
: "微信号/设备/好友转移审批"}
|
||||
</div>
|
||||
</div>
|
||||
<RightOutlined className={style.arrow} />
|
||||
</div>
|
||||
</CkbListCard>
|
||||
{list.length === 0 ? (
|
||||
<div className={style.empty}>
|
||||
<CustomerServiceOutlined className={style["empty-icon"]} />
|
||||
|
||||
@@ -370,6 +370,32 @@
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 主页面 · 扣费说明区 */
|
||||
.rulesSection {
|
||||
margin: 12px 16px 0;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #eef2f7;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.rulesPack {
|
||||
margin-top: 12px;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.rulesPackTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* 规则弹层 */
|
||||
.rulesPopup {
|
||||
padding: 20px 16px 24px;
|
||||
|
||||
@@ -22,12 +22,75 @@ import type { PowerPackage } from "../buy-power/api";
|
||||
import { queryOrder } from "../index/api";
|
||||
import type { QueryOrderResponse } from "../index/api";
|
||||
import { formatTokens } from "../shared/utils";
|
||||
import { getTokensRules } from "../shared/rulesApi";
|
||||
import type { TokensRule } from "../shared/rulesApi";
|
||||
import {
|
||||
getTokensRules,
|
||||
groupPaidRulesByScenePack,
|
||||
ruleDisplayName,
|
||||
ruleUnitSuffix,
|
||||
SCENE_PACK_ORDER,
|
||||
} from "../shared/rulesApi";
|
||||
import type { ScenePackMeta, TokensRule } from "../shared/rulesApi";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
type BuyMode = "package" | "custom";
|
||||
|
||||
const SCENE_PACK_FALLBACK: Record<string, string> = {
|
||||
auto_ops: "自动运营",
|
||||
ai_assist: "AI 助手",
|
||||
sys_sync: "系统同步",
|
||||
};
|
||||
|
||||
function RulesBlock({
|
||||
tokensRatio,
|
||||
rulesByPack,
|
||||
scenePacks,
|
||||
className,
|
||||
}: {
|
||||
tokensRatio: number;
|
||||
rulesByPack: Map<string, TokensRule[]>;
|
||||
scenePacks: ScenePackMeta[];
|
||||
className?: string;
|
||||
}) {
|
||||
const packName = (id: string) =>
|
||||
scenePacks.find(p => p.id === id)?.name ?? SCENE_PACK_FALLBACK[id] ?? id;
|
||||
|
||||
const orderedPacks = SCENE_PACK_ORDER.filter(id => (rulesByPack.get(id)?.length ?? 0) > 0);
|
||||
|
||||
if (orderedPacks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
<div className={style.rulesTitle}>算力兑换与扣费说明</div>
|
||||
<ul className={style.rulesList}>
|
||||
<li>兑换比例:1 元 = {tokensRatio} 算力</li>
|
||||
<li>按行为扣费:加好友、拉群、获客、AI 改写等</li>
|
||||
<li>
|
||||
月度推荐算力约 14,000~15,000(约 {Math.round(15000 / tokensRatio)} 元)
|
||||
</li>
|
||||
</ul>
|
||||
{orderedPacks.map(packId => {
|
||||
const rows = rulesByPack.get(packId) ?? [];
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<div key={packId} className={style.rulesPack}>
|
||||
<div className={style.rulesPackTitle}>{packName(packId)}</div>
|
||||
<div className={style.rulesGrid}>
|
||||
{rows.map(r => (
|
||||
<div key={r.action} className={style.ruleCell}>
|
||||
<span className={style.ruleName}>{ruleDisplayName(r)}</span>
|
||||
<span className={style.ruleCost}>
|
||||
{r.cost} <small>/{ruleUnitSuffix(r)}</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function pkgBadge(pkg: PowerPackage): { label: string; cls: string } | null {
|
||||
if (pkg.isRecommend) return { label: "推荐", cls: style.badgeRecommend };
|
||||
if (pkg.isHot) return { label: "热门", cls: style.badgeHot };
|
||||
@@ -48,6 +111,7 @@ const BuyPowerPage: React.FC = () => {
|
||||
const [paymentVisible, setPaymentVisible] = useState(false);
|
||||
const [rulesVisible, setRulesVisible] = useState(false);
|
||||
const [tokensRules, setTokensRules] = useState<TokensRule[]>([]);
|
||||
const [scenePacks, setScenePacks] = useState<ScenePackMeta[]>([]);
|
||||
const [tokensRatio, setTokensRatio] = useState<number>(25);
|
||||
const [paymentData, setPaymentData] = useState<{
|
||||
packageName: string;
|
||||
@@ -72,12 +136,18 @@ const BuyPowerPage: React.FC = () => {
|
||||
[packages, selectedId],
|
||||
);
|
||||
|
||||
const rulesByPack = useMemo(
|
||||
() => groupPaidRulesByScenePack(tokensRules),
|
||||
[tokensRules],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPackages();
|
||||
getTokensRules()
|
||||
.then(res => {
|
||||
setTokensRatio(res?.ratio?.tokensPerYuan ?? 25);
|
||||
setTokensRules((res?.all ?? []).filter(r => r.cost > 0).slice(0, 12));
|
||||
setScenePacks(res?.scenePacks ?? []);
|
||||
setTokensRules(res?.all ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -414,6 +484,13 @@ const BuyPowerPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RulesBlock
|
||||
tokensRatio={tokensRatio}
|
||||
rulesByPack={rulesByPack}
|
||||
scenePacks={scenePacks}
|
||||
className={style.rulesSection}
|
||||
/>
|
||||
|
||||
<div className={style.ctaBar}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -438,24 +515,11 @@ const BuyPowerPage: React.FC = () => {
|
||||
bodyStyle={{ borderTopLeftRadius: 16, borderTopRightRadius: 16 }}
|
||||
>
|
||||
<div className={style.rulesPopup}>
|
||||
<div className={style.rulesTitle}>算力兑换与扣费说明</div>
|
||||
<ul className={style.rulesList}>
|
||||
<li>兑换比例:1 元 = {tokensRatio} 算力</li>
|
||||
<li>按行为扣费:加好友、拉群、获客、AI 改写等</li>
|
||||
<li>月度推荐算力约 14,000~15,000(约 {Math.round(15000 / tokensRatio)} 元)</li>
|
||||
</ul>
|
||||
{tokensRules.length > 0 && (
|
||||
<div className={style.rulesGrid}>
|
||||
{tokensRules.slice(0, 8).map(r => (
|
||||
<div key={r.action} className={style.ruleCell}>
|
||||
<span className={style.ruleName}>{r.name}</span>
|
||||
<span className={style.ruleCost}>
|
||||
{r.cost} <small>/{r.unit}</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<RulesBlock
|
||||
tokensRatio={tokensRatio}
|
||||
rulesByPack={rulesByPack}
|
||||
scenePacks={scenePacks}
|
||||
/>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
|
||||
@@ -182,26 +182,38 @@ export interface Account {
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
// 获取客服账号列表(工作台 account-list)
|
||||
// 算力分配账号:须用 ck_users.id(非 s2_company_account.id)
|
||||
export async function getAccountList(): Promise<{ list: Account[]; total: number }> {
|
||||
const res = await request(
|
||||
"/v1/workbench/account-list",
|
||||
{ page: 1, limit: 200 },
|
||||
"/v1/customer-service/list",
|
||||
{ page: 1, limit: 200, bindStatus: "bound" },
|
||||
"GET",
|
||||
);
|
||||
const rawList = Array.isArray(res?.list) ? res.list : [];
|
||||
const list: Account[] = rawList.map((item: Record<string, unknown>) => ({
|
||||
id: Number(item.id) || 0,
|
||||
uid: Number(item.id) || 0,
|
||||
userId: Number(item.id) || 0,
|
||||
userName: String(item.userName ?? ""),
|
||||
realName: String(item.realName ?? ""),
|
||||
nickname: String(item.nickname ?? item.realName ?? item.userName ?? ""),
|
||||
departmentId: 0,
|
||||
departmentName: String(item.memo ?? "卡若私域"),
|
||||
avatar: "",
|
||||
}));
|
||||
return { list, total: Number(res?.total) || list.length };
|
||||
const list: Account[] = rawList
|
||||
.map((item: Record<string, unknown>) => {
|
||||
const ckbUserId = Number(item.ckbUserId) || 0;
|
||||
if (ckbUserId <= 0) {
|
||||
return null;
|
||||
}
|
||||
const display =
|
||||
String(item.realName ?? "") ||
|
||||
String(item.displayName ?? "") ||
|
||||
String(item.userName ?? "");
|
||||
return {
|
||||
id: ckbUserId,
|
||||
uid: ckbUserId,
|
||||
userId: ckbUserId,
|
||||
userName: String(item.userName ?? ""),
|
||||
realName: display,
|
||||
nickname: String(item.nickname ?? display),
|
||||
departmentId: 0,
|
||||
departmentName: String(item.memo ?? ""),
|
||||
avatar: String(item.avatar ?? ""),
|
||||
};
|
||||
})
|
||||
.filter((row): row is Account => row !== null);
|
||||
return { list, total: list.length };
|
||||
}
|
||||
|
||||
// 分配算力接口参数
|
||||
|
||||
@@ -28,19 +28,17 @@ import type { Account, Statistics } from "../index/api";
|
||||
import { getTokensUseRecord } from "../usage-records/api";
|
||||
import type { TokensUseRecordItem } from "../usage-records/api";
|
||||
import { formatPowerNum, formatDateTime, formLabelMap } from "../shared/utils";
|
||||
import {
|
||||
buildFormFilterOptions,
|
||||
formLabelFromRules,
|
||||
getTokensRules,
|
||||
} from "../shared/rulesApi";
|
||||
import type { FormFilterOption, TokensRule } from "../shared/rulesApi";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
type ManageTab = "accounts" | "allocate" | "auto" | "records" | "consume";
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "全部类型", value: "all" },
|
||||
{ label: "AI助手", value: "15" },
|
||||
{ label: "AI客服", value: "13" },
|
||||
{ label: "AI改写", value: "12" },
|
||||
{ label: "海报获客", value: "7" },
|
||||
{ label: "算力分配", value: "1001" },
|
||||
{ label: "充值", value: "1002" },
|
||||
];
|
||||
const DEFAULT_TYPE_OPTIONS: FormFilterOption[] = [{ label: "全部类型", value: "all" }];
|
||||
|
||||
const ManagePowerPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -61,6 +59,8 @@ const ManagePowerPage: React.FC = () => {
|
||||
const [recordHasMore, setRecordHasMore] = useState(true);
|
||||
const [filterType, setFilterType] = useState("all");
|
||||
const [typePickerVisible, setTypePickerVisible] = useState(false);
|
||||
const [typeOptions, setTypeOptions] = useState<FormFilterOption[]>(DEFAULT_TYPE_OPTIONS);
|
||||
const [catalogRules, setCatalogRules] = useState<TokensRule[]>([]);
|
||||
const [autoPolicy, setAutoPolicy] = useState<AutoAllocatePolicy>({
|
||||
enabled: false,
|
||||
touchkebaoRatio: 50,
|
||||
@@ -78,6 +78,13 @@ const ManagePowerPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
getTokensRules()
|
||||
.then(res => {
|
||||
const all = res?.all ?? [];
|
||||
setCatalogRules(all);
|
||||
setTypeOptions(buildFormFilterOptions(all));
|
||||
})
|
||||
.catch(() => {});
|
||||
if (isAdmin) {
|
||||
loadAccounts();
|
||||
loadAutoPolicy();
|
||||
@@ -99,9 +106,15 @@ const ManagePowerPage: React.FC = () => {
|
||||
const res = await saveAutoAllocatePolicy(autoPolicy);
|
||||
if (res?.policy) setAutoPolicy(res.policy);
|
||||
Toast.show({ content: "自动划拨策略已保存", position: "top" });
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
Toast.show({ content: "保存失败", position: "top" });
|
||||
const msg =
|
||||
typeof e === "string"
|
||||
? e
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: "保存失败";
|
||||
Toast.show({ content: msg, position: "top" });
|
||||
} finally {
|
||||
setAutoSaving(false);
|
||||
}
|
||||
@@ -183,8 +196,14 @@ const ManagePowerPage: React.FC = () => {
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const targetUserId =
|
||||
selectedAccount.userId || selectedAccount.uid || selectedAccount.id;
|
||||
if (!targetUserId) {
|
||||
Toast.show({ content: "该账号未绑定存客宝,无法分配" });
|
||||
return;
|
||||
}
|
||||
await allocateTokens({
|
||||
targetUserId: selectedAccount.uid || selectedAccount.userId || selectedAccount.id,
|
||||
targetUserId,
|
||||
tokens,
|
||||
remarks: `分配给${accountLabel(selectedAccount)}`,
|
||||
});
|
||||
@@ -201,7 +220,10 @@ const ManagePowerPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const recordTitle = (item: TokensUseRecordItem) =>
|
||||
item.remarks?.trim() || formLabelMap[item.form] || "使用记录";
|
||||
item.displayName?.trim() ||
|
||||
item.remarks?.trim() ||
|
||||
formLabelFromRules(item.form, catalogRules, formLabelMap) ||
|
||||
"使用记录";
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@@ -473,7 +495,7 @@ function RecordList({
|
||||
return (
|
||||
<div key={r.id} className={style.recordItem}>
|
||||
<div>
|
||||
<div className={style.recordType}>{formLabelMap[r.form] || recordTitle(r)}</div>
|
||||
<div className={style.recordType}>{recordTitle(r)}</div>
|
||||
<div className={style.recordTitle}>{recordTitle(r)}</div>
|
||||
<div className={style.recordTime}>{formatDateTime(r.createTime)}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
/**
|
||||
* 算力扣费规则单行(与后端 TokensService::rules 对齐)
|
||||
* - action: 动作枚举(form 字段)
|
||||
* - cost: 单次消耗算力
|
||||
* - unit: 计量单位
|
||||
* - group: 分组(A 高风险自动化 / B AI / C 系统级)
|
||||
* 算力扣费规则单行(与后端 TokensService::catalog 对齐)
|
||||
*/
|
||||
export interface TokensRule {
|
||||
action: number;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
slug?: string;
|
||||
cost: number;
|
||||
unit: string;
|
||||
unitLabel?: string;
|
||||
scenePack?: string;
|
||||
group: "A" | "B" | "C";
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ScenePackMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface TokensRulesResponse {
|
||||
ratio: {
|
||||
tokensPerYuan: number;
|
||||
note: string;
|
||||
userTip?: string;
|
||||
};
|
||||
scenePacks?: ScenePackMeta[];
|
||||
groups: {
|
||||
A: TokensRule[];
|
||||
B: TokensRule[];
|
||||
@@ -28,6 +37,68 @@ export interface TokensRulesResponse {
|
||||
all: TokensRule[];
|
||||
}
|
||||
|
||||
/** 场景包展示顺序(与 TokensService::SCENE_PACKS 一致) */
|
||||
export const SCENE_PACK_ORDER = ["auto_ops", "ai_assist", "sys_sync"] as const;
|
||||
|
||||
/** 将 cost>0 的规则按 scenePack 分组,组内按 cost 降序 */
|
||||
export function groupPaidRulesByScenePack(rules: TokensRule[]): Map<string, TokensRule[]> {
|
||||
const map = new Map<string, TokensRule[]>();
|
||||
for (const r of rules) {
|
||||
if (r.cost <= 0 || r.status === 0) continue;
|
||||
const pack = r.scenePack || "sys_sync";
|
||||
const list = map.get(pack) ?? [];
|
||||
list.push(r);
|
||||
map.set(pack, list);
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => b.cost - a.cost);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function ruleDisplayName(r: TokensRule): string {
|
||||
return r.displayName || r.name;
|
||||
}
|
||||
|
||||
export function ruleUnitSuffix(r: TokensRule): string {
|
||||
return r.unit || r.unitLabel?.replace(/^每/, "") || "次";
|
||||
}
|
||||
|
||||
export interface FormFilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** 划拨/充值等非 catalog 流水筛选项 */
|
||||
const SYSTEM_FORM_FILTER: FormFilterOption[] = [
|
||||
{ label: "算力分配", value: "1001" },
|
||||
{ label: "充值", value: "1002" },
|
||||
{ label: "系统调整", value: "1003" },
|
||||
];
|
||||
|
||||
/** 算力明细筛选:catalog + 系统 form(CKB-150.3 · 禁硬编码) */
|
||||
export function buildFormFilterOptions(rules: TokensRule[]): FormFilterOption[] {
|
||||
const fromCatalog = [...rules]
|
||||
.sort((a, b) => ruleDisplayName(a).localeCompare(ruleDisplayName(b), "zh-CN"))
|
||||
.map(r => ({
|
||||
label: ruleDisplayName(r),
|
||||
value: String(r.action),
|
||||
}));
|
||||
const seen = new Set(fromCatalog.map(o => o.value));
|
||||
const system = SYSTEM_FORM_FILTER.filter(o => !seen.has(o.value));
|
||||
return [{ label: "全部类型", value: "all" }, ...fromCatalog, ...system];
|
||||
}
|
||||
|
||||
export function formLabelFromRules(
|
||||
form: number,
|
||||
rules: TokensRule[],
|
||||
fallback?: Record<number, string>,
|
||||
): string {
|
||||
const hit = rules.find(r => r.action === form);
|
||||
if (hit) return ruleDisplayName(hit);
|
||||
return fallback?.[form] ?? "使用记录";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取算力扣费规则表(公开接口,免登录或登录后均可)
|
||||
* 真源:Server/application/cunkebao/controller/TokensController@getTokensRules
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface TokensUseRecordItem {
|
||||
balanceTokens: number;
|
||||
remarks: string;
|
||||
createTime: string;
|
||||
displayName?: string;
|
||||
changeText?: string;
|
||||
scenePack?: string;
|
||||
}
|
||||
|
||||
export interface TokensUseRecordList {
|
||||
|
||||
@@ -1,77 +1,95 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Input, Button, Toast, SpinLoading, Picker } from "antd-mobile";
|
||||
import type { PickerValue } from "antd-mobile/es/components/picker";
|
||||
import { Input, Button, Toast, SpinLoading, Tag } from "antd-mobile";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import AvatarUpload from "@/components/Upload/AvatarUpload";
|
||||
import { useUserStore, type User } from "@/store/module/user";
|
||||
import { CkbListCard } from "@/components/ckb";
|
||||
import { isPhoneLike, normalizeAuthUser } from "@/utils/userProfile";
|
||||
import { normalizeAuthUser } from "@/utils/userProfile";
|
||||
import { getUserInfo } from "@/pages/login/api";
|
||||
import { editUserInfo } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
[
|
||||
{ label: "管理员", value: "1" },
|
||||
{ label: "普通用户", value: "0" },
|
||||
],
|
||||
];
|
||||
|
||||
function applyProfileToForm(
|
||||
info: Partial<User>,
|
||||
mergeUser: (p: Partial<User>) => void,
|
||||
setters: {
|
||||
setNickname: (v: string) => void;
|
||||
setAccount: (v: string) => void;
|
||||
setPhone: (v: string) => void;
|
||||
setIsAdmin: (v: number) => void;
|
||||
setAvatar: (v: string) => void;
|
||||
},
|
||||
) {
|
||||
if (!info.id) return;
|
||||
mergeUser({
|
||||
id: Number(info.id),
|
||||
username: info.username,
|
||||
avatar: info.avatar,
|
||||
phone: info.phone,
|
||||
account: info.account,
|
||||
isAdmin: info.isAdmin,
|
||||
companyId: info.companyId,
|
||||
});
|
||||
setters.setNickname(info.username || "");
|
||||
setters.setAccount(info.account || "");
|
||||
setters.setPhone(info.phone || "");
|
||||
setters.setIsAdmin(info.isAdmin ?? 0);
|
||||
setters.setAvatar(info.avatar || "");
|
||||
interface ProfileSnapshot {
|
||||
nickname: string;
|
||||
account: string;
|
||||
phone: string;
|
||||
isAdmin: number;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
/** 设置页内嵌 · 个人信息编辑 */
|
||||
function readProfile(info: Partial<User>): ProfileSnapshot {
|
||||
return {
|
||||
nickname: info.username || "",
|
||||
account: info.account || "",
|
||||
phone: info.phone || "",
|
||||
isAdmin: info.isAdmin ?? 0,
|
||||
avatar: info.avatar || "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置页内嵌 · 个人资料与账户(CKB-131-B 重构)
|
||||
* - Hero:居中头像 + 昵称大字 + 角色 Tag + 账号·手机摘要
|
||||
* - 可编辑:仅昵称(131-B2)
|
||||
* - 只读:登录手机 / 登录账号 / 角色(131-B3 / B6)
|
||||
* - 保存:dirty 才出现底部细条「有未保存的修改」+ 胶囊保存(131-B4)
|
||||
* - API 收敛:仅提交 nickname + avatar(131-C)
|
||||
*/
|
||||
const ProfileFormSection: React.FC = () => {
|
||||
const { user, mergeUser } = useUserStore();
|
||||
const [nickname, setNickname] = useState(user?.username || "");
|
||||
const [account, setAccount] = useState(user?.account || "");
|
||||
const [phone, setPhone] = useState(user?.phone || "");
|
||||
const [isAdmin, setIsAdmin] = useState<number>(user?.isAdmin ?? 0);
|
||||
const [avatar, setAvatar] = useState(user?.avatar || "");
|
||||
const navigate = useNavigate();
|
||||
const { user, mergeUser, token } = useUserStore();
|
||||
|
||||
const [profile, setProfile] = useState<ProfileSnapshot>(readProfile(user || {}));
|
||||
// initial 快照用于 dirty 判定
|
||||
const [initial, setInitial] = useState<ProfileSnapshot>(readProfile(user || {}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
// 区分错误原因:unauth=未登录/凭证失效;load=网络或服务端加载失败
|
||||
const [errorReason, setErrorReason] = useState<"unauth" | "load">("load");
|
||||
const [rolePickerVisible, setRolePickerVisible] = useState(false);
|
||||
|
||||
const formSetters = {
|
||||
setNickname,
|
||||
setAccount,
|
||||
setPhone,
|
||||
setIsAdmin,
|
||||
setAvatar,
|
||||
};
|
||||
const dirty =
|
||||
profile.nickname.trim() !== initial.nickname.trim() ||
|
||||
profile.avatar !== initial.avatar;
|
||||
|
||||
const applyProfile = useCallback(
|
||||
(info: Partial<User>) => {
|
||||
if (!info.id) return;
|
||||
mergeUser({
|
||||
id: Number(info.id),
|
||||
username: info.username,
|
||||
avatar: info.avatar,
|
||||
phone: info.phone,
|
||||
account: info.account,
|
||||
isAdmin: info.isAdmin,
|
||||
companyId: info.companyId,
|
||||
});
|
||||
const snap = readProfile(info);
|
||||
setProfile(snap);
|
||||
setInitial(snap);
|
||||
},
|
||||
[mergeUser],
|
||||
);
|
||||
|
||||
const goLogin = useCallback(() => {
|
||||
const returnUrl = encodeURIComponent(
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
navigate(`/login?returnUrl=${returnUrl}`);
|
||||
}, [navigate]);
|
||||
|
||||
const syncFromServer = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadFailed(false);
|
||||
const local = useUserStore.getState().user;
|
||||
const setters = formSetters;
|
||||
const hasToken =
|
||||
!!useUserStore.getState().token || !!localStorage.getItem("token");
|
||||
|
||||
if (!hasToken && !local?.id) {
|
||||
setErrorReason("unauth");
|
||||
setLoadFailed(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await getUserInfo();
|
||||
@@ -79,73 +97,59 @@ const ProfileFormSection: React.FC = () => {
|
||||
|
||||
if (!info?.id) {
|
||||
if (local?.id) {
|
||||
applyProfileToForm(local, mergeUser, setters);
|
||||
applyProfile(local);
|
||||
return;
|
||||
}
|
||||
// 有 token 但拿不到用户 → 凭证可能失效;无 token → 未登录
|
||||
setErrorReason(useUserStore.getState().token ? "load" : "unauth");
|
||||
setErrorReason(hasToken ? "load" : "unauth");
|
||||
setLoadFailed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentId = local?.id;
|
||||
if (currentId && Number(info.id) !== Number(currentId)) {
|
||||
if (local?.id) applyProfileToForm(local, mergeUser, setters);
|
||||
if (local?.id) applyProfile(local);
|
||||
return;
|
||||
}
|
||||
|
||||
applyProfileToForm(info, mergeUser, setters);
|
||||
applyProfile(info);
|
||||
} catch (error) {
|
||||
console.error("同步用户信息失败:", error);
|
||||
if (local?.id) {
|
||||
applyProfileToForm(local, mergeUser, setters);
|
||||
applyProfile(local);
|
||||
} else {
|
||||
setErrorReason(useUserStore.getState().token ? "load" : "unauth");
|
||||
setErrorReason(hasToken ? "load" : "unauth");
|
||||
setLoadFailed(true);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mergeUser]);
|
||||
}, [applyProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
syncFromServer();
|
||||
}, [syncFromServer]);
|
||||
|
||||
// 父级/并发拉取完成后 store 已有 id,解除误报的 loadFailed
|
||||
useEffect(() => {
|
||||
if (!loadFailed || !user?.id) return;
|
||||
applyProfileToForm(user, mergeUser, formSetters);
|
||||
setLoadFailed(false);
|
||||
}, [loadFailed, user?.id, user?.username, user?.account, mergeUser]);
|
||||
// 头像变更:即时本地 mergeUser(131-B5),让「我的」页头像同步;持久化仍走保存
|
||||
const handleAvatarChange = (next: string) => {
|
||||
setProfile((p) => ({ ...p, avatar: next }));
|
||||
mergeUser({ avatar: next });
|
||||
};
|
||||
|
||||
const handleNicknameChange = (next: string) => {
|
||||
setProfile((p) => ({ ...p, nickname: next }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const userId = useUserStore.getState().user?.id;
|
||||
if (!userId) {
|
||||
Toast.show({
|
||||
content: "登录信息已失效,请重新登录后再保存",
|
||||
position: "top",
|
||||
});
|
||||
setLoadFailed(true);
|
||||
Toast.show({ content: "请先登录后再保存", position: "top" });
|
||||
if (!token && !localStorage.getItem("token")) {
|
||||
goLogin();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedPhone = phone.trim();
|
||||
const trimmedAccount = account.trim();
|
||||
const trimmedNickname = nickname.trim();
|
||||
|
||||
if (!trimmedPhone) {
|
||||
Toast.show({ content: "登录手机不能为空", position: "top" });
|
||||
return;
|
||||
}
|
||||
if (!isPhoneLike(trimmedPhone)) {
|
||||
Toast.show({ content: "请输入正确的 11 位手机号", position: "top" });
|
||||
return;
|
||||
}
|
||||
if (!trimmedAccount) {
|
||||
Toast.show({ content: "登录账号不能为空", position: "top" });
|
||||
return;
|
||||
}
|
||||
const trimmedNickname = profile.nickname.trim();
|
||||
if (!trimmedNickname) {
|
||||
Toast.show({ content: "昵称不能为空", position: "top" });
|
||||
return;
|
||||
@@ -153,25 +157,27 @@ const ProfileFormSection: React.FC = () => {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// CKB-131-C:仅提交 nickname + avatar,手机/账号/角色不在此页改
|
||||
const res = await editUserInfo({
|
||||
userId,
|
||||
nickname: trimmedNickname,
|
||||
avatar: avatar || undefined,
|
||||
phone: trimmedPhone,
|
||||
account: trimmedAccount,
|
||||
isAdmin,
|
||||
avatar: profile.avatar || undefined,
|
||||
});
|
||||
|
||||
mergeUser({
|
||||
username: res?.username ?? trimmedNickname,
|
||||
avatar: res?.avatar ?? avatar,
|
||||
phone: res?.phone ?? trimmedPhone,
|
||||
account: res?.account ?? trimmedAccount,
|
||||
isAdmin: res?.isAdmin ?? isAdmin,
|
||||
companyId: res?.companyId,
|
||||
avatar: res?.avatar ?? profile.avatar,
|
||||
});
|
||||
|
||||
Toast.show({ content: "保存成功", position: "top" });
|
||||
const merged = {
|
||||
...profile,
|
||||
nickname: res?.username ?? trimmedNickname,
|
||||
avatar: res?.avatar ?? profile.avatar,
|
||||
};
|
||||
setProfile(merged);
|
||||
setInitial(merged);
|
||||
|
||||
Toast.show({ icon: "success", content: "已保存", position: "top" });
|
||||
} catch (error: unknown) {
|
||||
const msg =
|
||||
typeof error === "string"
|
||||
@@ -185,13 +191,13 @@ const ProfileFormSection: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const roleLabel = isAdmin === 1 ? "管理员" : "普通用户";
|
||||
const roleLabel = profile.isAdmin === 1 ? "管理员" : "普通用户";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={style["profile-loading"]}>
|
||||
<SpinLoading color="primary" />
|
||||
<span className={style["profile-loading-text"]}>加载个人信息…</span>
|
||||
<span className={style["profile-loading-text"]}>加载中…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -200,135 +206,117 @@ const ProfileFormSection: React.FC = () => {
|
||||
const isUnauth = errorReason === "unauth";
|
||||
return (
|
||||
<CkbListCard className={style["profile-section"]}>
|
||||
<div className={style["group-title"]}>个人信息</div>
|
||||
<div className={style["group-title"]}>个人资料</div>
|
||||
<div className={style["profile-error"]} role="alert">
|
||||
<div className={style["profile-error-text"]}>
|
||||
{isUnauth
|
||||
? "登录状态已失效,请重新登录后再编辑个人信息。"
|
||||
: "用户信息加载失败,无法保存。请检查网络后重试。"}
|
||||
? "未登录,登录后可编辑头像与昵称。"
|
||||
: "加载失败,请检查网络后重试。"}
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
fill="outline"
|
||||
onClick={
|
||||
isUnauth
|
||||
? () => {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
: syncFromServer
|
||||
}
|
||||
onClick={isUnauth ? goLogin : syncFromServer}
|
||||
className={style["profile-retry-btn"]}
|
||||
aria-label={isUnauth ? "去登录" : "重试加载个人信息"}
|
||||
>
|
||||
{isUnauth ? "去登录" : "重试加载"}
|
||||
{isUnauth ? "去登录" : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
</CkbListCard>
|
||||
);
|
||||
}
|
||||
|
||||
const accountSummary = [profile.account, profile.phone]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<CkbListCard className={style["profile-section"]}>
|
||||
<div className={style["profile-section-head"]}>
|
||||
<div className={style["group-title"]}>个人信息</div>
|
||||
<span className={style["profile-section-hint"]}>
|
||||
昵称将显示在「我的」页
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={style["avatar-section-inline"]}>
|
||||
{/* Hero 卡:居中头像 + 昵称 + 角色 + 账号摘要(131-B1) */}
|
||||
<CkbListCard className={style["profile-hero"]}>
|
||||
<div className={style["profile-hero-avatar"]}>
|
||||
<AvatarUpload
|
||||
value={avatar}
|
||||
onChange={setAvatar}
|
||||
size={80}
|
||||
value={profile.avatar}
|
||||
onChange={handleAvatarChange}
|
||||
size={88}
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className={style["avatar-hint-col"]}>
|
||||
<div className={style["avatar-hint-title"]}>头像</div>
|
||||
<div className={style["avatar-hint"]}>
|
||||
支持 JPG、PNG,不超过 5MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["profile-fields"]}>
|
||||
<label className={style["input-label"]} htmlFor="profile-phone">
|
||||
登录手机(主)
|
||||
</label>
|
||||
<Input
|
||||
id="profile-phone"
|
||||
aria-label="登录手机号"
|
||||
className={style["input-field"]}
|
||||
value={phone}
|
||||
onChange={setPhone}
|
||||
placeholder="11 位手机号"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<label className={style["input-label"]} htmlFor="profile-account">
|
||||
登录账号
|
||||
</label>
|
||||
<Input
|
||||
id="profile-account"
|
||||
aria-label="登录账号"
|
||||
className={style["input-field"]}
|
||||
value={account}
|
||||
onChange={setAccount}
|
||||
placeholder="登录账号"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<label className={style["input-label"]} htmlFor="profile-nickname">
|
||||
昵称
|
||||
<span className={style["input-label-sub"]}>(我的页显示名)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="profile-nickname"
|
||||
aria-label="昵称,我的页显示名"
|
||||
className={style["input-field"]}
|
||||
value={nickname}
|
||||
onChange={setNickname}
|
||||
placeholder="如:卡若、售前"
|
||||
maxLength={20}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<label className={style["input-label"]}>角色</label>
|
||||
<div
|
||||
className={style["role-picker-trigger"]}
|
||||
onClick={() => setRolePickerVisible(true)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`选择角色,当前${roleLabel}`}
|
||||
<div className={style["profile-hero-name"]}>
|
||||
{profile.nickname || "未设置昵称"}
|
||||
</div>
|
||||
<div className={style["profile-hero-meta"]}>
|
||||
<Tag
|
||||
color={profile.isAdmin === 1 ? "primary" : "default"}
|
||||
fill="outline"
|
||||
round
|
||||
>
|
||||
{roleLabel}
|
||||
</div>
|
||||
</Tag>
|
||||
{accountSummary && (
|
||||
<span className={style["profile-hero-summary"]}>{accountSummary}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
className={style["profile-save-btn"]}
|
||||
aria-label="保存个人信息"
|
||||
>
|
||||
{saving ? "保存中…" : "保存个人信息"}
|
||||
</Button>
|
||||
</CkbListCard>
|
||||
|
||||
<Picker
|
||||
columns={ROLE_OPTIONS}
|
||||
visible={rolePickerVisible}
|
||||
onClose={() => setRolePickerVisible(false)}
|
||||
value={[String(isAdmin)]}
|
||||
onConfirm={(val: PickerValue[]) => {
|
||||
setIsAdmin(Number(val[0] ?? 0));
|
||||
setRolePickerVisible(false);
|
||||
}}
|
||||
/>
|
||||
{/* 可编辑卡:仅昵称(131-B2) */}
|
||||
<CkbListCard className={style["profile-section"]}>
|
||||
<div className={style["group-title"]}>显示信息</div>
|
||||
<div className={style["profile-edit-row"]}>
|
||||
<span className={style["input-label"]}>昵称</span>
|
||||
<Input
|
||||
className={style["input-field"]}
|
||||
value={profile.nickname}
|
||||
onChange={handleNicknameChange}
|
||||
placeholder="我的页显示名"
|
||||
maxLength={20}
|
||||
clearable
|
||||
style={{ "--text-align": "right" } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</CkbListCard>
|
||||
|
||||
{/* 只读卡:账户信息(131-B3 / B6) */}
|
||||
<CkbListCard className={style["profile-section"]}>
|
||||
<div className={style["group-title"]}>账户信息</div>
|
||||
<div className={style["profile-readonly-row"]}>
|
||||
<span className={style["input-label"]}>登录手机</span>
|
||||
<span className={style["profile-readonly-val"]}>
|
||||
{profile.phone || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["profile-readonly-row"]}>
|
||||
<span className={style["input-label"]}>登录账号</span>
|
||||
<span className={style["profile-readonly-val"]}>
|
||||
{profile.account || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["profile-readonly-row"]}>
|
||||
<span className={style["input-label"]}>角色</span>
|
||||
<span className={style["profile-readonly-val"]}>{roleLabel}</span>
|
||||
</div>
|
||||
<div className={style["profile-readonly-tip"]}>
|
||||
修改手机 / 账号请前往「账户管理 · 修改密码」同组安全设置
|
||||
</div>
|
||||
</CkbListCard>
|
||||
|
||||
{/* dirty 才出现的保存细条(131-B4) */}
|
||||
{dirty && (
|
||||
<div className={style["profile-dirty-bar"]}>
|
||||
<span className={style["profile-dirty-text"]}>有未保存的修改</span>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
className={style["profile-dirty-save"]}
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
import React from "react";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { CkbPageShell } from "@/components/ckb";
|
||||
import ProfileFormSection from "./ProfileFormSection";
|
||||
import style from "./index.module.scss";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
/** /userSet 深链保留,内容与设置页个人信息一致 */
|
||||
const UserSetting: React.FC = () => (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
glass
|
||||
gradient="brand"
|
||||
tone="blue"
|
||||
title="个人信息"
|
||||
subtitle="与后台账号配置一致 · 登录手机为主"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CkbPageShell className={style["setting-page"]} padded={false}>
|
||||
<ProfileFormSection />
|
||||
</CkbPageShell>
|
||||
</Layout>
|
||||
);
|
||||
/** /userSet 深链 → 统一进设置页,避免两套个人信息 UI */
|
||||
const UserSetting: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
navigate("/settings", { replace: true });
|
||||
}, [navigate]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default UserSetting;
|
||||
|
||||
@@ -8,7 +8,9 @@ export const editUserInfo = (data: {
|
||||
account?: string;
|
||||
isAdmin?: number;
|
||||
}) => {
|
||||
return request("/v1/user/editUserInfo", data, "PUT");
|
||||
return request("/v1/user/editUserInfo", data, "PUT", {
|
||||
skipAuthRedirect: true,
|
||||
} as Parameters<typeof request>[3]);
|
||||
};
|
||||
|
||||
export const editPassWord = (data: { userId: number; passWord: string }) => {
|
||||
|
||||
@@ -617,8 +617,31 @@
|
||||
.profile-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.profile-field-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-readonly {
|
||||
font-size: 15px;
|
||||
color: #64748b;
|
||||
padding: 10px 0 2px;
|
||||
}
|
||||
|
||||
.profile-section-compact {
|
||||
.avatar-section-inline {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-label {
|
||||
@@ -626,7 +649,7 @@
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@@ -651,6 +674,115 @@
|
||||
}
|
||||
}
|
||||
|
||||
// CKB-131-B 个人资料重构
|
||||
.profile-hero {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
padding: 20px 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-hero-avatar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.profile-hero-name {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-hero-meta {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-hero-summary {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.profile-edit-row,
|
||||
.profile-readonly-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
margin-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-readonly-val {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.profile-readonly-tip {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-dirty-bar {
|
||||
position: sticky;
|
||||
bottom: 8px;
|
||||
margin: 4px 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(37, 99, 235, 0.06);
|
||||
border: 1px solid rgba(37, 99, 235, 0.18);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.profile-dirty-text {
|
||||
font-size: 13px;
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-dirty-save {
|
||||
flex-shrink: 0;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
// 个人信息页面样式
|
||||
.save-buttons {
|
||||
padding: 16px;
|
||||
|
||||
@@ -42,6 +42,11 @@ const Setting: React.FC = () => {
|
||||
Toast.show({ content: "退出成功", position: "top" });
|
||||
};
|
||||
|
||||
// CKB-131-A:返回保登录——固定回退「我的」,深链/刷新也不丢登录、绝不触发 logout/清缓存
|
||||
const handleBack = () => {
|
||||
navigate("/mine");
|
||||
};
|
||||
|
||||
const handleClearCache = () => {
|
||||
Dialog.confirm({
|
||||
content: "确定要清除缓存吗?这将清除所有本地数据。",
|
||||
@@ -155,7 +160,7 @@ const Setting: React.FC = () => {
|
||||
return (
|
||||
<Layout
|
||||
bg="aurora"
|
||||
header={<NavCommon glass gradient="brand" tone="pink" title="设置" />}
|
||||
header={<NavCommon glass gradient="brand" tone="pink" title="设置" subtitle="个人资料与账户" backFn={handleBack} />}
|
||||
>
|
||||
<CkbPageShell className={style["setting-page"]} padded={false}>
|
||||
<ProfileFormSection />
|
||||
|
||||
@@ -192,8 +192,17 @@ export interface PageResult<T> {
|
||||
/**
|
||||
* 获取流量池分组列表
|
||||
*/
|
||||
const POOL_LIST_TIMEOUT = 90000;
|
||||
|
||||
export async function getGroups(): Promise<TrafficPoolGroup[]> {
|
||||
return request("/v1/traffic/pool/v2/groups", {}, "GET");
|
||||
// debounceGap=0:避免 React StrictMode / 切分组 1s 内重复 GET 被误拒
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/groups",
|
||||
{},
|
||||
"GET",
|
||||
{ timeout: POOL_LIST_TIMEOUT },
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +288,13 @@ export async function getGroupMembers(params: {
|
||||
if (payload.ruleConfig && typeof payload.ruleConfig !== "string") {
|
||||
payload.ruleConfig = JSON.stringify(payload.ruleConfig);
|
||||
}
|
||||
return request("/v1/traffic/pool/v2/group/members", payload, "GET");
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/group/members",
|
||||
payload,
|
||||
"GET",
|
||||
{ timeout: POOL_LIST_TIMEOUT },
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,7 +364,13 @@ export async function getPoolList(params: {
|
||||
if (payload.ruleConfig && typeof payload.ruleConfig !== "string") {
|
||||
payload.ruleConfig = JSON.stringify(payload.ruleConfig);
|
||||
}
|
||||
return request("/v1/traffic/pool/v2/list", payload, "GET");
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/list",
|
||||
payload,
|
||||
"GET",
|
||||
{ timeout: POOL_LIST_TIMEOUT },
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -454,6 +475,19 @@ export async function removeTag(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除/隐藏标签定义(站内/AI 标签软删;微信同步标签隐藏)
|
||||
*/
|
||||
export async function deleteTagDefine(
|
||||
tagDefineId: number,
|
||||
): Promise<{ tagDefineId: number; mode: "deleted" | "hidden"; removedCount?: number }> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/tag/define/delete",
|
||||
{ tagDefineId },
|
||||
"DELETE",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从标签引擎同步用户标签
|
||||
*/
|
||||
@@ -579,9 +613,50 @@ export async function pushPoolToDevices(params: Record<string, unknown>) {
|
||||
}
|
||||
return request("/v1/traffic/pool/v2/push-to-devices", payload, "POST", {
|
||||
timeout: 120000,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
export interface PushPrecheckResult {
|
||||
total: number;
|
||||
withPhone: number;
|
||||
withoutPhone: number;
|
||||
}
|
||||
|
||||
export async function pushPoolPrecheck(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<PushPrecheckResult> {
|
||||
const payload = { ...params };
|
||||
if (payload.ruleConfig && typeof payload.ruleConfig !== "string") {
|
||||
payload.ruleConfig = JSON.stringify(payload.ruleConfig);
|
||||
}
|
||||
return request("/v1/traffic/pool/v2/push-precheck", payload, "POST", {
|
||||
skipErrorToast: true,
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export interface AiSearchResult {
|
||||
explanation: string;
|
||||
previewCount: number;
|
||||
filters: Record<string, unknown>;
|
||||
ruleConfig?: RuleConfig | null;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}
|
||||
|
||||
export async function aiSearchPool(params: {
|
||||
query: string;
|
||||
groupId?: number;
|
||||
}): Promise<AiSearchResult> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/ai-search",
|
||||
params,
|
||||
"POST",
|
||||
{ timeout: 60000, skipErrorToast: true },
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// AI 整理/归类(§三 3.5)
|
||||
export interface AiClassifyItem {
|
||||
poolCompanyId: number;
|
||||
@@ -601,14 +676,32 @@ export interface AiClassifyResult {
|
||||
}
|
||||
|
||||
export async function aiClassifyPool(
|
||||
poolCompanyIds: number[],
|
||||
mode: "preview" | "apply" = "preview"
|
||||
options: {
|
||||
poolCompanyIds?: number[];
|
||||
mode?: "preview" | "apply";
|
||||
scope?: "selected" | "filter" | "group";
|
||||
filterSnapshot?: Record<string, unknown>;
|
||||
groupId?: number;
|
||||
} = {},
|
||||
): Promise<AiClassifyResult> {
|
||||
const {
|
||||
poolCompanyIds = [],
|
||||
mode = "preview",
|
||||
scope = "selected",
|
||||
filterSnapshot,
|
||||
groupId,
|
||||
} = options;
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/ai-classify",
|
||||
{ poolCompanyIds, mode },
|
||||
{
|
||||
poolCompanyIds,
|
||||
mode,
|
||||
scope,
|
||||
filterSnapshot,
|
||||
groupId,
|
||||
},
|
||||
"POST",
|
||||
{ timeout: 120000 }
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,93 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Modal, Button, message, Tag, Empty, Spin } from "antd";
|
||||
import { Modal, Button, message, Tag, Empty, Spin, Radio } from "antd";
|
||||
import { aiClassifyPool, type AiClassifyResult } from "../../api";
|
||||
|
||||
export type AiClassifyScope = "selected" | "filter" | "group";
|
||||
|
||||
interface AiClassifyModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
poolCompanyIds: number[];
|
||||
selectedCount: number;
|
||||
filterTotal: number;
|
||||
filterSnapshot: Record<string, unknown>;
|
||||
groupId?: number;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 整理/归类弹窗(存客宝 §三 3.5)
|
||||
* 流程:打开即拉 preview(建议 + 算力预估)→ 用户确认 → apply(扣费 + 写 AI 标签)。
|
||||
* AI 整理/归类弹窗(存客宝 §三 3.5 · CKB-135.2)
|
||||
*/
|
||||
const AiClassifyModal: React.FC<AiClassifyModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
poolCompanyIds,
|
||||
selectedCount,
|
||||
filterTotal,
|
||||
filterSnapshot,
|
||||
groupId,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [preview, setPreview] = useState<AiClassifyResult | null>(null);
|
||||
const [scope, setScope] = useState<AiClassifyScope>(
|
||||
selectedCount > 0 ? "selected" : "filter",
|
||||
);
|
||||
|
||||
const loadPreview = async (nextScope: AiClassifyScope) => {
|
||||
setLoading(true);
|
||||
setPreview(null);
|
||||
try {
|
||||
const res = await aiClassifyPool({
|
||||
poolCompanyIds: nextScope === "selected" ? poolCompanyIds : [],
|
||||
mode: "preview",
|
||||
scope: nextScope,
|
||||
filterSnapshot: nextScope === "filter" ? filterSnapshot : undefined,
|
||||
groupId: nextScope === "group" ? groupId : undefined,
|
||||
});
|
||||
setPreview(res);
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
typeof e === "string"
|
||||
? e
|
||||
: (e as { message?: string })?.message || "生成预览失败";
|
||||
message.error(msg);
|
||||
onClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 initialScope: AiClassifyScope =
|
||||
selectedCount > 0 ? "selected" : groupId ? "group" : "filter";
|
||||
setScope(initialScope);
|
||||
loadPreview(initialScope);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const handleScopeChange = (next: AiClassifyScope) => {
|
||||
setScope(next);
|
||||
loadPreview(next);
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (poolCompanyIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
setApplying(true);
|
||||
try {
|
||||
const res = await aiClassifyPool(poolCompanyIds, "apply");
|
||||
const res = await aiClassifyPool({
|
||||
poolCompanyIds: scope === "selected" ? poolCompanyIds : [],
|
||||
mode: "apply",
|
||||
scope,
|
||||
filterSnapshot: scope === "filter" ? filterSnapshot : undefined,
|
||||
groupId: scope === "group" ? groupId : undefined,
|
||||
});
|
||||
message.success(
|
||||
`已整理 ${res.total} 位客资,写入 ${res.appliedTags ?? 0} 个标签(消耗 ${res.deducted ?? 0} 算力)`
|
||||
`已整理 ${res.total} 位客资,写入 ${res.appliedTags ?? 0} 个标签(消耗 ${res.deducted ?? 0} 算力)`,
|
||||
);
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
@@ -93,6 +127,24 @@ const AiClassifyModal: React.FC<AiClassifyModalProps> = ({
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Radio.Group
|
||||
value={scope}
|
||||
onChange={e => handleScopeChange(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="selected" disabled={selectedCount <= 0}>
|
||||
已选 ({selectedCount})
|
||||
</Radio.Button>
|
||||
<Radio.Button value="filter">当前筛选 ({filterTotal})</Radio.Button>
|
||||
{groupId ? (
|
||||
<Radio.Button value="group">当前分组</Radio.Button>
|
||||
) : null}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "32px 0" }}>
|
||||
<Spin tip="AI 分析中..." />
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useState } from "react";
|
||||
import { Modal, Button, Input, message, Alert, Spin } from "antd";
|
||||
import { aiSearchPool, type AiSearchResult } from "../../api";
|
||||
|
||||
interface AiSearchModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
groupId?: number;
|
||||
onApply: (result: AiSearchResult) => void;
|
||||
}
|
||||
|
||||
const AiSearchModal: React.FC<AiSearchModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
groupId,
|
||||
onApply,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preview, setPreview] = useState<AiSearchResult | null>(null);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
message.info("请输入搜索描述");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setPreview(null);
|
||||
try {
|
||||
const res = await aiSearchPool({ query: query.trim(), groupId });
|
||||
setPreview(res);
|
||||
if ((res.previewCount ?? 0) === 0) {
|
||||
message.warning("未匹配到客资,可调整描述后重试");
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
typeof e === "string"
|
||||
? e
|
||||
: (e as { message?: string })?.message || "AI 搜索失败";
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!preview) return;
|
||||
onApply(preview);
|
||||
onClose();
|
||||
setQuery("");
|
||||
setPreview(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="AI 搜索"
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
onClose();
|
||||
setPreview(null);
|
||||
}}
|
||||
centered
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="search" loading={loading} onClick={handleSearch}>
|
||||
解析条件
|
||||
</Button>,
|
||||
<Button
|
||||
key="apply"
|
||||
type="primary"
|
||||
disabled={!preview}
|
||||
onClick={handleApply}
|
||||
>
|
||||
应用筛选
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<p style={{ fontSize: 13, color: "#666", marginBottom: 8 }}>
|
||||
用自然语言描述要找的客资,例如:「厦门女性 RFM 高 未触达」
|
||||
</p>
|
||||
<Input.TextArea
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="厦门女性 RFM>4 未触达"
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
maxLength={200}
|
||||
/>
|
||||
{loading && (
|
||||
<div style={{ textAlign: "center", padding: 16 }}>
|
||||
<Spin tip="AI 解析中…" />
|
||||
</div>
|
||||
)}
|
||||
{preview && !loading && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message={preview.explanation}
|
||||
description={`预计匹配 ${preview.previewCount ?? 0} 条客资`}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiSearchModal;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from "react";
|
||||
import { Modal, Button, message } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Modal, Button, message, Alert, List } from "antd";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import type { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { pushPoolToDevices } from "../../api";
|
||||
import { pushPoolToDevices, pushPoolPrecheck, type PushPrecheckResult } from "../../api";
|
||||
|
||||
interface PushToDeviceModalProps {
|
||||
open: boolean;
|
||||
@@ -12,6 +12,17 @@ interface PushToDeviceModalProps {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface PushResult {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
successCount?: number;
|
||||
failCount?: number;
|
||||
skippedNoPhone?: number;
|
||||
deviceCount?: number;
|
||||
errors?: string[];
|
||||
reasonCode?: string;
|
||||
}
|
||||
|
||||
const PushToDeviceModal: React.FC<PushToDeviceModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -21,6 +32,53 @@ const PushToDeviceModal: React.FC<PushToDeviceModalProps> = ({
|
||||
}) => {
|
||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [precheck, setPrecheck] = useState<PushPrecheckResult | null>(null);
|
||||
const [precheckLoading, setPrecheckLoading] = useState(false);
|
||||
const [lastErrors, setLastErrors] = useState<string[]>([]);
|
||||
const [resultModal, setResultModal] = useState<{
|
||||
ok: boolean;
|
||||
title: string;
|
||||
lines: string[];
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPrecheck(null);
|
||||
setLastErrors([]);
|
||||
setResultModal(null);
|
||||
return;
|
||||
}
|
||||
setPrecheckLoading(true);
|
||||
pushPoolPrecheck(exportParams)
|
||||
.then(setPrecheck)
|
||||
.catch(() => setPrecheck(null))
|
||||
.finally(() => setPrecheckLoading(false));
|
||||
}, [open, exportParams]);
|
||||
|
||||
const buildResultLines = (res: PushResult): string[] => {
|
||||
const lines: string[] = [];
|
||||
if ((res.skippedNoPhone ?? 0) > 0) {
|
||||
lines.push(
|
||||
`已跳过 ${res.skippedNoPhone} 条:客资无手机号,无法写入通讯录`,
|
||||
);
|
||||
}
|
||||
if (res.errors?.length) {
|
||||
lines.push(...res.errors.slice(0, 3));
|
||||
} else if (res.reasonCode === "NO_PHONE") {
|
||||
lines.push("筛选结果中无带手机号的客资可推送");
|
||||
} else if (res.reasonCode === "NO_DEVICE") {
|
||||
lines.push("未找到可用设备,请在工作手机中确认设备已绑定本项目");
|
||||
}
|
||||
if ((res.successCount ?? 0) > 0) {
|
||||
lines.unshift(
|
||||
`成功 ${res.successCount} 条,写入 ${res.deviceCount ?? devices.length} 台设备`,
|
||||
);
|
||||
}
|
||||
if ((res.failCount ?? 0) > 0 && !res.errors?.length) {
|
||||
lines.push(`失败 ${res.failCount} 条,请检查设备在线与 SDK 连接`);
|
||||
}
|
||||
return lines.length ? lines : [res.message || "推送未完成"];
|
||||
};
|
||||
|
||||
const handlePush = async () => {
|
||||
if (devices.length === 0) {
|
||||
@@ -28,21 +86,43 @@ const PushToDeviceModal: React.FC<PushToDeviceModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const offline = devices.filter(d => d.status === "offline");
|
||||
if (offline.length > 0) {
|
||||
message.warning(
|
||||
`所选设备中有 ${offline.length} 台离线,推送可能失败,请优先选择在线设备`,
|
||||
);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLastErrors([]);
|
||||
try {
|
||||
const res = await pushPoolToDevices({
|
||||
const res = (await pushPoolToDevices({
|
||||
...exportParams,
|
||||
deviceIds: devices.map(d => d.id).join(","),
|
||||
});
|
||||
message.success(res?.message || "推送成功");
|
||||
onClose();
|
||||
setDevices([]);
|
||||
})) as PushResult;
|
||||
|
||||
const lines = buildResultLines(res);
|
||||
const ok = !!res.success && (res.successCount ?? 0) > 0;
|
||||
|
||||
if (ok) {
|
||||
message.success(res.message || "推送成功");
|
||||
onClose();
|
||||
setDevices([]);
|
||||
} else {
|
||||
setLastErrors(lines);
|
||||
setResultModal({
|
||||
ok: false,
|
||||
title: "推送失败",
|
||||
lines,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const msg =
|
||||
typeof error === "string"
|
||||
? error
|
||||
: (error as { message?: string })?.message || "推送到设备失败";
|
||||
message.error(msg);
|
||||
setLastErrors([msg]);
|
||||
setResultModal({ ok: false, title: "推送失败", lines: [msg] });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -54,37 +134,88 @@ const PushToDeviceModal: React.FC<PushToDeviceModalProps> = ({
|
||||
: `当前筛选共 ${totalCount} 条`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="推送到设备"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
centered
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="push"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
disabled={devices.length === 0}
|
||||
onClick={handlePush}
|
||||
>
|
||||
确认推送
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<p style={{ marginBottom: 12, color: "#666", fontSize: 13 }}>
|
||||
将把{countHint}中带手机号的客资写入所选设备通讯录(无手机号的会自动跳过)。
|
||||
</p>
|
||||
<DeviceSelection
|
||||
selectedOptions={devices}
|
||||
onSelect={setDevices}
|
||||
placeholder="搜索并选择设备"
|
||||
showSelectedList
|
||||
/>
|
||||
</Modal>
|
||||
<>
|
||||
<Modal
|
||||
title="推送到设备"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
centered
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="push"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
disabled={devices.length === 0 || (precheck?.withPhone ?? 1) <= 0}
|
||||
onClick={handlePush}
|
||||
>
|
||||
确认推送
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<p style={{ marginBottom: 12, color: "#666", fontSize: 13 }}>
|
||||
将把{countHint}中带手机号的客资写入所选设备通讯录(无手机号的会自动跳过)。
|
||||
</p>
|
||||
|
||||
{precheckLoading ? (
|
||||
<Alert type="info" message="正在统计可推送条数…" showIcon style={{ marginBottom: 12 }} />
|
||||
) : precheck ? (
|
||||
<Alert
|
||||
type={precheck.withPhone > 0 ? "info" : "warning"}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={
|
||||
precheck.withPhone > 0
|
||||
? `可推送 ${precheck.withPhone} 条(共 ${precheck.total} 条,${precheck.withoutPhone} 条无手机号将跳过)`
|
||||
: `当前范围 ${precheck.total} 条均无手机号,无法推送通讯录`
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{lastErrors.length > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="上次推送问题"
|
||||
description={
|
||||
<List
|
||||
size="small"
|
||||
dataSource={lastErrors}
|
||||
renderItem={item => <List.Item style={{ padding: "2px 0", border: "none" }}>{item}</List.Item>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeviceSelection
|
||||
selectedOptions={devices}
|
||||
onSelect={setDevices}
|
||||
placeholder="搜索并选择设备"
|
||||
showSelectedList
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={resultModal?.title}
|
||||
open={!!resultModal}
|
||||
onCancel={() => setResultModal(null)}
|
||||
footer={[
|
||||
<Button key="ok" type="primary" onClick={() => setResultModal(null)}>
|
||||
知道了
|
||||
</Button>,
|
||||
]}
|
||||
centered
|
||||
>
|
||||
<List
|
||||
dataSource={resultModal?.lines || []}
|
||||
renderItem={item => <List.Item>{item}</List.Item>}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, Empty, List, Modal, Segmented, Tag, message } from "antd";
|
||||
import {
|
||||
deleteTagDefine,
|
||||
getTagDefines,
|
||||
type TagDefine,
|
||||
} from "../../api";
|
||||
|
||||
interface TagManageModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const TAG_TYPE_OPTIONS = [
|
||||
{ label: "站内标签", value: 2 },
|
||||
{ label: "AI 标签", value: 3 },
|
||||
{ label: "微信标签", value: 1 },
|
||||
];
|
||||
|
||||
const typeColor: Record<number, string> = {
|
||||
1: "green",
|
||||
2: "blue",
|
||||
3: "purple",
|
||||
};
|
||||
|
||||
const typeText: Record<number, string> = {
|
||||
1: "微信同步",
|
||||
2: "站内",
|
||||
3: "AI",
|
||||
};
|
||||
|
||||
const TagManageModal: React.FC<TagManageModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [tagType, setTagType] = useState<number>(2);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [tags, setTags] = useState<TagDefine[]>([]);
|
||||
|
||||
const loadTags = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await getTagDefines({ tagType });
|
||||
setTags(list || []);
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
typeof e === "string"
|
||||
? e
|
||||
: (e as { message?: string })?.message || "标签加载失败";
|
||||
message.error(msg);
|
||||
setTags([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadTags();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, tagType]);
|
||||
|
||||
const canDeleteCount = useMemo(
|
||||
() => tags.filter(item => !item.isSystem).length,
|
||||
[tags],
|
||||
);
|
||||
|
||||
const handleDelete = (item: TagDefine) => {
|
||||
if (item.isSystem) {
|
||||
message.warning("系统标签不可删除");
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: item.tagType === 1 ? "隐藏微信同步标签" : "删除标签",
|
||||
content:
|
||||
item.tagType === 1
|
||||
? `确认隐藏「${item.tagName}」?历史微信标签不会被删除,只是不再展示该标签定义。`
|
||||
: `确认删除「${item.tagName}」?已打在客资上的该标签也会同步移除。`,
|
||||
okText: item.tagType === 1 ? "确认隐藏" : "确认删除",
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "取消",
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setDeletingId(item.id);
|
||||
try {
|
||||
const res = await deleteTagDefine(item.id);
|
||||
message.success(res.mode === "hidden" ? "已隐藏标签" : "已删除标签");
|
||||
await loadTags();
|
||||
onSuccess?.();
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="标签整理"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
centered
|
||||
destroyOnClose
|
||||
width={520}
|
||||
footer={[
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
完成
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="可删除站内/AI 标签;微信同步标签只隐藏;系统标签不可删除。"
|
||||
/>
|
||||
<Segmented
|
||||
block
|
||||
value={tagType}
|
||||
options={TAG_TYPE_OPTIONS}
|
||||
onChange={value => setTagType(Number(value))}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
{tags.length === 0 && !loading ? (
|
||||
<Empty description="暂无标签" />
|
||||
) : (
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={tags}
|
||||
renderItem={item => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
key="delete"
|
||||
danger
|
||||
size="small"
|
||||
disabled={!!item.isSystem}
|
||||
loading={deletingId === item.id}
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
{item.tagType === 1 ? "隐藏" : "删除"}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<>
|
||||
{item.tagName}
|
||||
<Tag color={typeColor[item.tagType] || "default"} style={{ marginLeft: 8 }}>
|
||||
{typeText[item.tagType] || "标签"}
|
||||
</Tag>
|
||||
{item.isSystem ? <Tag>系统</Tag> : null}
|
||||
</>
|
||||
}
|
||||
description={`使用 ${item.useCount || 0} 次${item.description ? ` · ${item.description}` : ""}`}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canDeleteCount === 0 && tags.length > 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="当前分类下没有可删除的自定义标签"
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagManageModal;
|
||||
@@ -42,6 +42,8 @@ import { assignLeadsToKefu, getKefuDetail } from "../../kefu/api";
|
||||
import AssignGroupModal from "./components/AssignGroupModal";
|
||||
import PushToDeviceModal from "./components/PushToDeviceModal";
|
||||
import AiClassifyModal from "./components/AiClassifyModal";
|
||||
import AiSearchModal from "./components/AiSearchModal";
|
||||
import TagManageModal from "./components/TagManageModal";
|
||||
import {
|
||||
buildPoolCsvExport,
|
||||
buildPoolMarkdownExport,
|
||||
@@ -160,6 +162,8 @@ const TrafficPoolPage: React.FC = () => {
|
||||
const [webhookPushing, setWebhookPushing] = useState(false);
|
||||
const [pushDeviceOpen, setPushDeviceOpen] = useState(false);
|
||||
const [aiClassifyOpen, setAiClassifyOpen] = useState(false);
|
||||
const [aiSearchOpen, setAiSearchOpen] = useState(false);
|
||||
const [tagManageOpen, setTagManageOpen] = useState(false);
|
||||
|
||||
const assignKefuId = searchParams.get("assignKefu");
|
||||
const [assignKefuName, setAssignKefuName] = useState("");
|
||||
@@ -234,6 +238,18 @@ const TrafficPoolPage: React.FC = () => {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const msg = typeof e === "string" ? e : (e as Error)?.message || "";
|
||||
// 全局 request 1s 防抖误拒:保留已加载分组,不弹网络错误
|
||||
if (msg.includes("请求过于频繁")) {
|
||||
return;
|
||||
}
|
||||
Toast.show({
|
||||
content: msg.includes("接口错误") || msg.includes("未授权")
|
||||
? msg
|
||||
: "分组加载失败,请检查网络后重试",
|
||||
position: "top",
|
||||
});
|
||||
setGroups(prev => (prev.length > 0 ? prev : []));
|
||||
} finally {
|
||||
setGroupsLoading(false);
|
||||
}
|
||||
@@ -263,6 +279,12 @@ const TrafficPoolPage: React.FC = () => {
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.show({
|
||||
content: "客资列表加载失败,请稍后重试",
|
||||
position: "top",
|
||||
});
|
||||
setUsers([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
@@ -494,15 +516,20 @@ const TrafficPoolPage: React.FC = () => {
|
||||
key: "ai-classify",
|
||||
icon: <UsergroupAddOutlined />,
|
||||
label: "AI 整理",
|
||||
disabled: selectedIds.length === 0,
|
||||
onClick: () => {
|
||||
if (selectedIds.length === 0) {
|
||||
message.info("请先勾选要整理的客户");
|
||||
if (total <= 0 && selectedIds.length <= 0) {
|
||||
message.info("当前没有可整理的客资");
|
||||
return;
|
||||
}
|
||||
setAiClassifyOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "tag-manage",
|
||||
icon: <AppstoreOutlined />,
|
||||
label: "标签整理",
|
||||
onClick: () => setTagManageOpen(true),
|
||||
},
|
||||
{ type: "divider" as const },
|
||||
{
|
||||
key: "csv",
|
||||
@@ -637,10 +664,15 @@ const TrafficPoolPage: React.FC = () => {
|
||||
onRefresh={() => loadUsers()}
|
||||
refreshing={usersLoading}
|
||||
extra={
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setShowFilter(true)}
|
||||
/>
|
||||
<>
|
||||
<Button size="small" onClick={() => setAiSearchOpen(true)}>
|
||||
AI 搜索
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setShowFilter(true)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{groups.length > 0 && (
|
||||
@@ -658,6 +690,11 @@ const TrafficPoolPage: React.FC = () => {
|
||||
: undefined
|
||||
}
|
||||
onClick={() => handleSelectGroup(g)}
|
||||
onContextMenu={e => {
|
||||
if (g.isSystem) return;
|
||||
e.preventDefault();
|
||||
handleDeleteGroup(g);
|
||||
}}
|
||||
>
|
||||
<span className={styles.groupChipIcon}>{getGroupIcon(g)}</span>
|
||||
<span className={styles.groupChipName}>{g.groupName}</span>
|
||||
@@ -677,6 +714,23 @@ const TrafficPoolPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{filter.aiSearchHint && (
|
||||
<div className={styles.currentGroupBar} style={{ background: "#f0f5ff" }}>
|
||||
<span className={styles.currentGroupName}>AI 筛选</span>
|
||||
<span className={styles.currentGroupCount}>{filter.aiSearchHint}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setFilter(DEFAULT_POOL_FILTER);
|
||||
setSearchInput("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{selectedGroup && (
|
||||
<div className={styles.currentGroupBar}>
|
||||
<span className={styles.currentGroupName}>
|
||||
@@ -834,12 +888,49 @@ const TrafficPoolPage: React.FC = () => {
|
||||
open={aiClassifyOpen}
|
||||
onClose={() => setAiClassifyOpen(false)}
|
||||
poolCompanyIds={selectedIds}
|
||||
selectedCount={selectedIds.length}
|
||||
filterTotal={total}
|
||||
filterSnapshot={buildExportParams()}
|
||||
groupId={selectedGroupId || undefined}
|
||||
onSuccess={() => {
|
||||
setSelectedIds([]);
|
||||
loadUsers();
|
||||
loadGroups();
|
||||
}}
|
||||
/>
|
||||
|
||||
<AiSearchModal
|
||||
open={aiSearchOpen}
|
||||
onClose={() => setAiSearchOpen(false)}
|
||||
groupId={selectedGroupId || undefined}
|
||||
onApply={result => {
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
keyword: "",
|
||||
gender: result.filters?.gender as number | undefined,
|
||||
friendStatus: result.filters?.friendStatus as number | undefined,
|
||||
rfmFMin: result.filters?.rfmFMin as number | undefined,
|
||||
rfmMMin: result.filters?.rfmMMin as number | undefined,
|
||||
sortBy: result.sortBy || prev.sortBy,
|
||||
sortOrder: (result.sortOrder as "asc" | "desc") || prev.sortOrder,
|
||||
ruleConfig: result.ruleConfig || null,
|
||||
aiSearchHint: result.explanation,
|
||||
}));
|
||||
setSearchInput("");
|
||||
setPage(1);
|
||||
setSelectedIds([]);
|
||||
message.success(`已应用 AI 筛选,约 ${result.previewCount} 条`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TagManageModal
|
||||
open={tagManageOpen}
|
||||
onClose={() => setTagManageOpen(false)}
|
||||
onSuccess={() => {
|
||||
loadUsers();
|
||||
loadGroups();
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,6 +55,13 @@ const WechatAccountDetail: React.FC = () => {
|
||||
const [accountSummary, setAccountSummary] =
|
||||
useState<WechatAccountSummary | null>(null);
|
||||
const [accountInfo, setAccountInfo] = useState<any>(null);
|
||||
// CKB-146-A4:账号状态条(normal/restricted/banned/offline)
|
||||
const [accountStatus, setAccountStatus] = useState<{
|
||||
status: string;
|
||||
label: string;
|
||||
reason: string;
|
||||
level: string;
|
||||
} | null>(null);
|
||||
const [overviewData, setOverviewData] = useState<any>(null);
|
||||
const [showRestrictions, setShowRestrictions] = useState(false);
|
||||
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
|
||||
@@ -64,7 +71,8 @@ const WechatAccountDetail: React.FC = () => {
|
||||
const [firstMessage, setFirstMessage] = useState("");
|
||||
const [transferLoading, setTransferLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
// CKB-137:默认进入「账号评估」(概览/健康分已并入)
|
||||
const [activeTab, setActiveTab] = useState("valuation");
|
||||
const [loadingInfo, setLoadingInfo] = useState(true);
|
||||
|
||||
// 好友列表相关状态
|
||||
@@ -114,6 +122,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
const response = await getWechatAccountDetail(id);
|
||||
if (response && response.userInfo) {
|
||||
setAccountInfo(response.userInfo);
|
||||
setAccountStatus(response.accountStatus || null);
|
||||
// 构造 summary 数据结构
|
||||
setAccountSummary({
|
||||
accountAge: response.accountAge,
|
||||
@@ -712,6 +721,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
</div>
|
||||
<p className={style["wechat-id"]}>
|
||||
微信号:{accountInfo?.wechatId || "未知"}
|
||||
{accountInfo?.alias ? `(${accountInfo.alias})` : ""}
|
||||
</p>
|
||||
<div className={style["action-buttons"]}>
|
||||
<Button
|
||||
@@ -727,6 +737,26 @@ const WechatAccountDetail: React.FC = () => {
|
||||
</div>
|
||||
</CkbListCard>
|
||||
|
||||
{/* CKB-146-A4:限制/封号状态条(normal 不展示) */}
|
||||
{accountStatus && accountStatus.status !== "normal" && (
|
||||
<div
|
||||
style={{
|
||||
margin: "8px 12px 0",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
background:
|
||||
accountStatus.level === "error" ? "#fef2f2" : "#fffbeb",
|
||||
color: accountStatus.level === "error" ? "#dc2626" : "#b45309",
|
||||
border: `1px solid ${accountStatus.level === "error" ? "#fecaca" : "#fde68a"}`,
|
||||
}}
|
||||
>
|
||||
<strong>{accountStatus.label}</strong>
|
||||
{accountStatus.reason ? ` · ${accountStatus.reason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签页 */}
|
||||
<CkbListCard className={style["tabs-card"]}>
|
||||
<Tabs
|
||||
@@ -734,38 +764,67 @@ const WechatAccountDetail: React.FC = () => {
|
||||
onChange={handleTabChange}
|
||||
className={style["tabs"]}
|
||||
>
|
||||
<Tabs.Tab title="概览" key="overview">
|
||||
<Tabs.Tab title="账号状态" key="valuation">
|
||||
<div className={style["overview-content"]}>
|
||||
{/* 健康分评估区域 */}
|
||||
<div className={style["health-score-section"]}>
|
||||
<div className={style["health-score-title"]}>健康分评估</div>
|
||||
<div className={style["health-score-info"]}>
|
||||
<div className={style["health-score-status"]}>
|
||||
<span className={style["status-tag"]}>{overviewData?.healthScoreAssessment?.statusTag || "已添加加人"}</span>
|
||||
<span className={style["status-time"]}>最后添加时间: {overviewData?.healthScoreAssessment?.lastAddTime || "-"}</span>
|
||||
{/* CKB-137-A6:账号结论 — 这个账号现在适合做什么 */}
|
||||
{(() => {
|
||||
const healthScore =
|
||||
overviewData?.healthScoreAssessment?.score || 0;
|
||||
const valueScore = valuation?.accountValueScore ?? null;
|
||||
let label = "待评估";
|
||||
let advice = "数据不足,请先重新评估并同步好友";
|
||||
let color = "#9ca3af";
|
||||
if (valueScore != null) {
|
||||
if (healthScore > 0 && healthScore < 40) {
|
||||
label = "风险号";
|
||||
advice = "健康分偏低,优先修复设备/微信登录与限制状态";
|
||||
color = "#ef4444";
|
||||
} else if (valueScore >= 60 && healthScore >= 60) {
|
||||
label = "主力号";
|
||||
advice = "综合分达标,可承接获客与日常运营";
|
||||
color = "#16a34a";
|
||||
} else if (valueScore < 40) {
|
||||
label = "养号中";
|
||||
advice = "建议补好友资产与活跃度,逐步提升账号价值";
|
||||
color = "#f59e0b";
|
||||
} else {
|
||||
label = "待修复";
|
||||
advice = "建议补活跃与高价值好友,关注健康指标";
|
||||
color = "#f59e0b";
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "10px 12px",
|
||||
marginBottom: 10,
|
||||
borderRadius: 10,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid #e2e8f0",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: "2px 10px",
|
||||
borderRadius: 999,
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: color,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#475569" }}>
|
||||
{advice}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["health-score-display"]}>
|
||||
<div className={style["score-circle-wrapper"]}>
|
||||
<div className={style["score-circle"]}>
|
||||
<div className={style["score-number"]}>
|
||||
{overviewData?.healthScoreAssessment?.score || 0}
|
||||
</div>
|
||||
<div className={style["score-label"]}>SCORE</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["health-score-stats"]}>
|
||||
<div className={style["stats-row"]}>
|
||||
<div className={style["stats-label"]}>每日限额</div>
|
||||
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.dailyLimit || 0} 人</div>
|
||||
</div>
|
||||
<div className={style["stats-row"]}>
|
||||
<div className={style["stats-label"]}>今日已加</div>
|
||||
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.todayAdded || 0} 人</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 账号价值和好友数量区域 */}
|
||||
<div className={style["account-stats-grid"]}>
|
||||
@@ -844,9 +903,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
|
||||
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="账号评估" key="valuation">
|
||||
{/* CKB-137:价值评估区(原账号评估 Tab 并入) */}
|
||||
<div className={style["valuation-content"]}>
|
||||
{valuationLoading && !valuation ? (
|
||||
<div className={style["loading"]}>
|
||||
@@ -977,9 +1034,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<div className={style["empty"]}>暂无评估数据</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="健康分" key="health">
|
||||
{/* CKB-137:健康状态区(原健康分 Tab 并入) */}
|
||||
<div className={style["health-content"]}>
|
||||
{/* 健康分评估区域 */}
|
||||
<div className={style["health-score-section"]}>
|
||||
|
||||
@@ -30,6 +30,10 @@ interface WechatAccount {
|
||||
activeTime: string;
|
||||
location?: string;
|
||||
localTime?: string;
|
||||
/** CKB-139:在线/离线真实原因与心跳(BFF 下发) */
|
||||
online_reason?: string;
|
||||
last_heartbeat_at?: string;
|
||||
workphone_device_id?: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
@@ -248,6 +252,20 @@ const WechatAccounts: React.FC = () => {
|
||||
<div className={style["wechat-id"]}>
|
||||
微信号:{account.wechatId}
|
||||
</div>
|
||||
{account.online_reason ? (
|
||||
<div
|
||||
className={style["wechat-id"]}
|
||||
style={{
|
||||
color:
|
||||
account.wechatStatus === 1 ? "#16a34a" : "#9ca3af",
|
||||
}}
|
||||
>
|
||||
{account.online_reason}
|
||||
{account.last_heartbeat_at
|
||||
? ` · 心跳 ${account.last_heartbeat_at.slice(5, 16)}`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["card-body"]}>
|
||||
|
||||
@@ -151,3 +151,45 @@
|
||||
color: #ef4444;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
/* CKB-121.3/121.4 付款页素材与付款后行为二维码 */
|
||||
.mediaSection {
|
||||
margin: 8px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.mediaToggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6d28d9;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mediaGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.mediaThumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
.behaviorQr {
|
||||
margin: 12px auto 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.behaviorQrTip {
|
||||
font-size: 13px;
|
||||
color: #6d28d9;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.behaviorQr .mediaThumb {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Button, SpinLoading, Toast } from "antd-mobile";
|
||||
import { Button, SpinLoading, Toast, ImageViewer } from "antd-mobile";
|
||||
import { AlipayCircleOutlined, WechatOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
publicCreatePaymentOrder,
|
||||
@@ -24,6 +24,8 @@ const PaymentAcquirePage: React.FC = () => {
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [order, setOrder] = useState<PublicPaymentOrder | null>(null);
|
||||
const [mediaExpanded, setMediaExpanded] = useState(false);
|
||||
const [previewImg, setPreviewImg] = useState<string | null>(null);
|
||||
|
||||
const loadOrder = useCallback(async () => {
|
||||
if (!planId) {
|
||||
@@ -77,6 +79,11 @@ const PaymentAcquirePage: React.FC = () => {
|
||||
[order],
|
||||
);
|
||||
|
||||
const pageDisplay = order?.pageDisplay;
|
||||
const mediaImages = pageDisplay?.paymentMediaImages || [];
|
||||
const mediaVideos = pageDisplay?.paymentMediaVideos || [];
|
||||
const hasMedia = mediaImages.length > 0 || mediaVideos.length > 0;
|
||||
|
||||
if (!planId) {
|
||||
return (
|
||||
<div className={style.page}>
|
||||
@@ -123,6 +130,63 @@ const PaymentAcquirePage: React.FC = () => {
|
||||
<>
|
||||
<div className={style.product}>{order.goodsName}</div>
|
||||
<div className={style.price}>¥{order.amountYuan.toFixed(2)}</div>
|
||||
{hasMedia && (
|
||||
<div className={style.mediaSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={style.mediaToggle}
|
||||
onClick={() => setMediaExpanded(v => !v)}
|
||||
>
|
||||
{mediaExpanded ? "收起展示素材" : "展开展示图/视频"}
|
||||
</button>
|
||||
{mediaExpanded && (
|
||||
<div className={style.mediaGrid}>
|
||||
{mediaImages.map(url => (
|
||||
<img
|
||||
key={url}
|
||||
src={url}
|
||||
alt=""
|
||||
className={style.mediaThumb}
|
||||
onClick={() => setPreviewImg(url)}
|
||||
/>
|
||||
))}
|
||||
{mediaVideos.map(url => (
|
||||
<video
|
||||
key={url}
|
||||
src={url}
|
||||
className={style.mediaThumb}
|
||||
controls
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(pageDisplay?.paymentBehaviorQrUrl || pageDisplay?.paymentBehaviorGroupQrUrl) && (
|
||||
<div className={style.behaviorQr}>
|
||||
<p className={style.behaviorQrTip}>
|
||||
{pageDisplay?.paymentJumpType === "group_qr"
|
||||
? "支付后扫码进群"
|
||||
: "支付后扫码添加微信"}
|
||||
</p>
|
||||
<img
|
||||
src={
|
||||
pageDisplay?.paymentBehaviorGroupQrUrl ||
|
||||
pageDisplay?.paymentBehaviorQrUrl ||
|
||||
""
|
||||
}
|
||||
alt="付款后行为二维码"
|
||||
className={style.mediaThumb}
|
||||
onClick={() =>
|
||||
setPreviewImg(
|
||||
pageDisplay?.paymentBehaviorGroupQrUrl ||
|
||||
pageDisplay?.paymentBehaviorQrUrl ||
|
||||
null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.qrWrap}>
|
||||
{qrImg ? (
|
||||
<img src={qrImg} alt="获客码" className={style.qr} />
|
||||
@@ -145,6 +209,11 @@ const PaymentAcquirePage: React.FC = () => {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<ImageViewer
|
||||
image={previewImg || ""}
|
||||
visible={!!previewImg}
|
||||
onClose={() => setPreviewImg(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,16 @@ publicApi.interceptors.response.use(res => {
|
||||
return Promise.reject(new Error(msg || "接口错误"));
|
||||
});
|
||||
|
||||
export interface PaymentPlanDisplay {
|
||||
planName?: string;
|
||||
paymentAmount?: number;
|
||||
paymentJumpType?: string;
|
||||
paymentMediaImages?: string[];
|
||||
paymentMediaVideos?: string[];
|
||||
paymentBehaviorQrUrl?: string;
|
||||
paymentBehaviorGroupQrUrl?: string;
|
||||
}
|
||||
|
||||
export interface PublicPaymentOrder {
|
||||
orderNo: string;
|
||||
codeUrl: string;
|
||||
@@ -28,6 +38,8 @@ export interface PublicPaymentOrder {
|
||||
status?: number;
|
||||
waitPhone?: boolean;
|
||||
paid?: boolean;
|
||||
pageDisplay?: PaymentPlanDisplay;
|
||||
paymentJumpType?: string;
|
||||
}
|
||||
|
||||
export function publicCreatePaymentOrder(body: {
|
||||
@@ -45,6 +57,12 @@ export function publicQueryPaymentOrder(orderNo: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function publicFetchPlanDisplay(planId: string | number) {
|
||||
return publicApi.get("/v1/frontend/business/payment/plan-display", {
|
||||
params: { planId },
|
||||
});
|
||||
}
|
||||
|
||||
export function toQrImage(codeUrl: string, qrImageUrl?: string) {
|
||||
if (qrImageUrl) return qrImageUrl;
|
||||
if (!codeUrl) return "";
|
||||
|
||||
130
Cunkebao/src/pages/mobile/payment/behavior/index.tsx
Normal file
130
Cunkebao/src/pages/mobile/payment/behavior/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Button, SpinLoading, Toast, ImageViewer } from "antd-mobile";
|
||||
import {
|
||||
publicFetchPlanDisplay,
|
||||
type PaymentPlanDisplay,
|
||||
} from "../acquire/paymentPublicApi";
|
||||
import style from "../acquire/index.module.scss";
|
||||
|
||||
/**
|
||||
* 付款后行为落地页(CKB-121.3)
|
||||
* 公开路由 /pay/behavior?planId=&cid=&redirectUrl=
|
||||
* 支付成功后按计划配置的 paymentJumpType 引导后续行为:
|
||||
* h5 跳转 / 小程序 / 公众号 / 个人微信码 / 群码 / 自动拉群。
|
||||
*/
|
||||
const JUMP_LABEL: Record<string, string> = {
|
||||
h5: "正在跳转到指定页面…",
|
||||
mp: "请在微信中打开小程序完成后续操作",
|
||||
oa: "长按或扫码关注公众号",
|
||||
personal_qr: "支付完成,扫码添加专属顾问微信",
|
||||
group_qr: "支付完成,扫码加入专属社群",
|
||||
auto_group: "支付完成,系统将自动邀请您进群,请留意微信通知",
|
||||
};
|
||||
|
||||
const PaymentBehaviorPage: React.FC = () => {
|
||||
const [params] = useSearchParams();
|
||||
const planId = params.get("planId") || "";
|
||||
const redirectUrl = params.get("redirectUrl") || params.get("returnUrl") || "";
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [display, setDisplay] = useState<PaymentPlanDisplay | null>(null);
|
||||
const [previewImg, setPreviewImg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!planId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const data = (await publicFetchPlanDisplay(
|
||||
planId,
|
||||
)) as unknown as PaymentPlanDisplay;
|
||||
setDisplay(data);
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e?.message || "加载失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [planId]);
|
||||
|
||||
const jumpType = display?.paymentJumpType || (redirectUrl ? "h5" : "");
|
||||
|
||||
const qrUrl = useMemo(() => {
|
||||
if (jumpType === "group_qr") {
|
||||
return display?.paymentBehaviorGroupQrUrl || display?.paymentBehaviorQrUrl || "";
|
||||
}
|
||||
return display?.paymentBehaviorQrUrl || display?.paymentBehaviorGroupQrUrl || "";
|
||||
}, [jumpType, display]);
|
||||
|
||||
// h5:拿到 redirectUrl 后自动跳转
|
||||
useEffect(() => {
|
||||
if (jumpType === "h5" && redirectUrl) {
|
||||
const t = setTimeout(() => {
|
||||
window.location.href = redirectUrl;
|
||||
}, 1200);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [jumpType, redirectUrl]);
|
||||
|
||||
if (!planId) {
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<p className={style.error}>链接无效:缺少 planId</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<div className={style.card}>
|
||||
<h1 className={style.title}>支付成功</h1>
|
||||
<p className={style.subtitle}>
|
||||
{display?.planName || "感谢您的支付,请按提示完成后续操作"}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className={style.loading}>
|
||||
<SpinLoading color="primary" />
|
||||
<p>正在加载后续指引…</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className={style.hint}>{JUMP_LABEL[jumpType] || "支付完成"}</p>
|
||||
|
||||
{qrUrl && (
|
||||
<div className={style.behaviorQr}>
|
||||
<img
|
||||
src={qrUrl}
|
||||
alt="付款后行为二维码"
|
||||
className={style.qr}
|
||||
onClick={() => setPreviewImg(qrUrl)}
|
||||
/>
|
||||
<p className={style.behaviorQrTip}>点击二维码可放大</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jumpType === "h5" && redirectUrl && (
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={() => (window.location.href = redirectUrl)}
|
||||
>
|
||||
未自动跳转?点此继续
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ImageViewer
|
||||
image={previewImg || ""}
|
||||
visible={!!previewImg}
|
||||
onClose={() => setPreviewImg(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentBehaviorPage;
|
||||
@@ -74,13 +74,15 @@ const Scene: React.FC = () => {
|
||||
left={<></>}
|
||||
right={
|
||||
<div className={style["header-actions"]}>
|
||||
{/* CKB-140-140.4:弱化为「新建」,不与页面标题抢层级 */}
|
||||
<Button
|
||||
size="small"
|
||||
fill="outline"
|
||||
color="primary"
|
||||
onClick={() => navigate("/scenarios/new")}
|
||||
className={style["create-btn"]}
|
||||
>
|
||||
<PlusOutlined /> 创建获客计划
|
||||
<PlusOutlined /> 新建
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -99,7 +101,8 @@ const Scene: React.FC = () => {
|
||||
bg="soft"
|
||||
>
|
||||
<CkbPageShell className={style["scene-page"]} padded={false}>
|
||||
<CkbSectionCard title="获客场景" className={style["scene-section"]}>
|
||||
{/* CKB-140-140.4:顶部已有「场景获客」标题,内容区不再重复「获客场景」 */}
|
||||
<CkbSectionCard className={style["scene-section"]}>
|
||||
<div className={style["scenarios-grid"]}>
|
||||
{scenarios.map(scenario => {
|
||||
const growth = formatGrowthDisplay(scenario.growth);
|
||||
|
||||
@@ -113,6 +113,73 @@ export function testLeadWebhook(planId: string) {
|
||||
return request(`/v1/plan/lead-webhook/test`, { planId }, "POST");
|
||||
}
|
||||
|
||||
// ==================== CKB-130 计划级网站获客插件「对接配置」 ====================
|
||||
export interface PlanAcquireConfig {
|
||||
planId: number;
|
||||
planName: string;
|
||||
siteId: string;
|
||||
siteKey: string;
|
||||
siteKeyMasked: string;
|
||||
siteUrl: string;
|
||||
hosts: string[];
|
||||
tagClickThreshold: number;
|
||||
apiBase: string;
|
||||
scriptUrl: string;
|
||||
scriptTag: string;
|
||||
eventsApiUrl: string;
|
||||
leadsApiUrl: string;
|
||||
docPath: string;
|
||||
/** 交给 AI 的一页全站对接任务书(含锚点/接口/参数) */
|
||||
integrationGuide?: string;
|
||||
/** 脚本 + AI 任务书 一键复制包 */
|
||||
fullIntegrationPack?: string;
|
||||
}
|
||||
|
||||
// 读取计划对接配置(自动建联 site)
|
||||
export function getPlanAcquireConfig(planId: string | number) {
|
||||
return request<PlanAcquireConfig>(
|
||||
`/v1/website-track/plan-config`,
|
||||
{ planId },
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
// 保存计划对接配置(关联网站 URL + hosts[] + 阈值)
|
||||
export function savePlanAcquireConfig(data: {
|
||||
planId: string | number;
|
||||
siteUrl?: string;
|
||||
hosts?: string[] | string;
|
||||
tagClickThreshold?: number;
|
||||
}) {
|
||||
return request<PlanAcquireConfig>(
|
||||
`/v1/website-track/plan-config`,
|
||||
data,
|
||||
"POST",
|
||||
);
|
||||
}
|
||||
|
||||
// 模拟 page_view 验收(计划内统计 +1)
|
||||
export function simulatePlanAcquireEvent(planId: string | number) {
|
||||
return request<{ planId: number; siteId: string; ingest: any }>(
|
||||
`/v1/website-track/plan-config/simulate`,
|
||||
{ planId },
|
||||
"POST",
|
||||
);
|
||||
}
|
||||
|
||||
// CKB-130.10:计划绑 KB 时生成网站 AI 营销片段
|
||||
export function generatePlanAiSnippet(data: {
|
||||
planId: string | number;
|
||||
topic: string;
|
||||
tone?: string;
|
||||
}) {
|
||||
return request<{ planId: number; snippet: string; model: string; references: any[] }>(
|
||||
`/v1/website-track/plan-config/ai-snippet`,
|
||||
data,
|
||||
"POST",
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePlanStatus(planId: string, status: number) {
|
||||
return request(`/v1/plan/updateStatus`, { planId, status }, "POST");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,54 @@
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.website-stat-entry {
|
||||
margin: 12px 0 4px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.website-stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #2563eb;
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #ede9fe 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.website-stat-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.website-stat-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.website-stat-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.website-stat-arrow {
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
UserAddOutlined,
|
||||
GlobalOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
@@ -47,6 +48,7 @@ import CustomerListModal, {
|
||||
import PoolListModal from "./components/PoolListModal";
|
||||
import PaymentQrModal from "./PaymentQrModal";
|
||||
import PlanListHeaderButton from "./PlanListHeaderButton";
|
||||
import PlanAcquireConfigModal from "./planAcquireConfig";
|
||||
import { syncDouyinComments } from "./douyinCommentApi";
|
||||
|
||||
const ScenarioList: React.FC = () => {
|
||||
@@ -72,6 +74,9 @@ const ScenarioList: React.FC = () => {
|
||||
const [showActionMenu, setShowActionMenu] = useState<string | null>(null);
|
||||
const [showDeviceList, setShowDeviceList] = useState(false);
|
||||
const [currentTask, setCurrentTask] = useState<Task | null>(null);
|
||||
// CKB-130.4:网站获客计划「对接配置」弹窗
|
||||
const [showAcquireConfig, setShowAcquireConfig] = useState(false);
|
||||
const [acquirePlan, setAcquirePlan] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
// 客资列表弹窗(已获客 / 已添加)
|
||||
const [showCustomerList, setShowCustomerList] = useState(false);
|
||||
@@ -507,6 +512,20 @@ const ScenarioList: React.FC = () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
// CKB-130.4:网站获客计划(scene=12)提供「对接配置」(嵌入脚本/siteKey/Open API)
|
||||
// CKB-140-A3:AI客服获客(scene=13)同样一段 JS 即可接入,复用对接配置面板
|
||||
if (scenarioId === "12" || scenarioId === "13") {
|
||||
items.splice(1, 0, {
|
||||
key: "acquire-config",
|
||||
text: "对接配置",
|
||||
icon: <GlobalOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
setAcquirePlan({ id: task.id, name: task.name });
|
||||
setShowAcquireConfig(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -546,12 +565,15 @@ const ScenarioList: React.FC = () => {
|
||||
onStatusChange={key =>
|
||||
setStatusTab(key as "active" | "paused" | "all")
|
||||
}
|
||||
alwaysShowStatusTabs
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<CkbPageShell className={style["scenario-list-page"]} padded={false}>
|
||||
{/* CKB-130.D1:删除「网站获客统计」独立入口卡片;统计仅挂计划详情「数据」Tab,不再有独立运营入口 */}
|
||||
|
||||
{/* 全局计划提示 */}
|
||||
{globalPlans.length > 0 && (
|
||||
<div className={style["info-box"]}>
|
||||
@@ -1007,6 +1029,14 @@ const ScenarioList: React.FC = () => {
|
||||
planName={paymentPlan?.name || ""}
|
||||
onClose={() => setShowPaymentQr(false)}
|
||||
/>
|
||||
|
||||
{/* CKB-130.4:网站获客计划「对接配置」 */}
|
||||
<PlanAcquireConfigModal
|
||||
visible={showAcquireConfig}
|
||||
planId={acquirePlan?.id || ""}
|
||||
planName={acquirePlan?.name || ""}
|
||||
onClose={() => setShowAcquireConfig(false)}
|
||||
/>
|
||||
</CkbPageShell>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
.dialog {
|
||||
background: #fff;
|
||||
border-radius: 16px 16px 0 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: linear-gradient(135deg, #f9f0ff 0%, #fff 60%);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 20px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.headerWrap {
|
||||
position: relative;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 8px;
|
||||
}
|
||||
|
||||
.step {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fafafa;
|
||||
|
||||
&.primary {
|
||||
border-color: #d3adf7;
|
||||
background: #f9f0ff;
|
||||
}
|
||||
}
|
||||
|
||||
.stepNum {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #722ed1;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stepHint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.codeBox {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 120px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.codeBoxTall {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 12px 20px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collapseHint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 8px 0 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.advanced {
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.checkList {
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
line-height: 1.8;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:global(.anticon) {
|
||||
color: #52c41a;
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Popup, Button, Toast, SpinLoading } from "antd-mobile";
|
||||
import { Modal, Collapse } from "antd";
|
||||
import {
|
||||
CopyOutlined,
|
||||
RobotOutlined,
|
||||
ThunderboltOutlined,
|
||||
CodeOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import style from "./planAcquireConfig.module.scss";
|
||||
import {
|
||||
getPlanAcquireConfig,
|
||||
simulatePlanAcquireEvent,
|
||||
PlanAcquireConfig,
|
||||
} from "./api";
|
||||
|
||||
interface PlanAcquireConfigModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
planId: string;
|
||||
planName?: string;
|
||||
}
|
||||
|
||||
const copyText = (text: string, label: string) => {
|
||||
const done = () => Toast.show({ content: `${label}已复制`, position: "top" });
|
||||
const fail = () => Toast.show({ content: `${label}复制失败`, position: "top" });
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, fail);
|
||||
return;
|
||||
}
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
done();
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
};
|
||||
|
||||
function resolveEmbedApiBase(raw: string): string {
|
||||
const env = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined;
|
||||
if (env?.startsWith("http")) {
|
||||
return env.replace(/\/$/, "").replace(/\/api$/, "");
|
||||
}
|
||||
if (/localhost(?::|$)/i.test(raw) && !raw.includes(":8082")) {
|
||||
return "http://127.0.0.1:8082";
|
||||
}
|
||||
return (raw || "https://ckbapi.quwanzhi.com").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function buildScriptTag(cfg: PlanAcquireConfig): string {
|
||||
const base = resolveEmbedApiBase(cfg.apiBase);
|
||||
return (
|
||||
`<script src="${base}/static/ckb-acquire.js"\n` +
|
||||
` data-plan-id="${cfg.planId}"\n` +
|
||||
` data-site-key="${cfg.siteKey}"\n` +
|
||||
` data-api-base="${base}"></script>`
|
||||
);
|
||||
}
|
||||
|
||||
const GUIDE_INCLUDES = [
|
||||
"planId / siteKey / apiKey / 全部接口地址(凭证自包含,无需再填域名)",
|
||||
"全站 <head> 植入脚本(可直接粘贴)",
|
||||
"6 种行为事件 + data-track / data-ckb-event 锚点规则",
|
||||
"留资表单 bindPhone、加购 purchase、SPA 路由补报",
|
||||
"行为上报 JSON 契约 + 线索 apiKey 签名(PHP/Node 示例)",
|
||||
"达标入计划逻辑 + AI 9 项执行清单 + curl 验收",
|
||||
];
|
||||
|
||||
/**
|
||||
* 网站获客 · 2 步接入(凭证自包含 · 复制即开发)
|
||||
*/
|
||||
const PlanAcquireConfigModal: React.FC<PlanAcquireConfigModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
planId,
|
||||
planName,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [simulating, setSimulating] = useState(false);
|
||||
const [cfg, setCfg] = useState<PlanAcquireConfig | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!planId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getPlanAcquireConfig(planId);
|
||||
setCfg(res);
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e?.message || "加载失败", position: "top" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) load();
|
||||
}, [visible, load]);
|
||||
|
||||
const scriptTag = useMemo(() => (cfg ? buildScriptTag(cfg) : ""), [cfg]);
|
||||
|
||||
const fullPack = useMemo(() => {
|
||||
if (!cfg) return "";
|
||||
if (cfg.fullIntegrationPack) return cfg.fullIntegrationPack;
|
||||
const guide = cfg.integrationGuide || "";
|
||||
return scriptTag + "\n\n<!-- AI 对接任务书 -->\n\n" + guide;
|
||||
}, [cfg, scriptTag]);
|
||||
|
||||
const handleSimulate = async () => {
|
||||
setSimulating(true);
|
||||
try {
|
||||
await simulatePlanAcquireEvent(planId);
|
||||
Toast.show({ content: "验收成功", position: "top" });
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e?.message || "验收失败", position: "top" });
|
||||
} finally {
|
||||
setSimulating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className={style.dialog}>
|
||||
<div className={style.header}>
|
||||
<div className={style.headerWrap}>
|
||||
<h3>网站获客 · 复制即对接</h3>
|
||||
<p>
|
||||
{planName
|
||||
? `计划「${planName}」— 凭证已备好,复制给 AI 即可改全站并对接存客宝,无需填域名`
|
||||
: "适用于任意网站:一键复制脚本+说明,交给 Cursor 全自动开发"}
|
||||
</p>
|
||||
<Button size="small" onClick={onClose} className={style.closeBtn}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || !cfg ? (
|
||||
<div className={style.loading}>
|
||||
<SpinLoading />
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.body}>
|
||||
{/* 主 CTA:一键包 */}
|
||||
<div className={`${style.step} ${style.primary}`}>
|
||||
<div className={style.stepTitle}>
|
||||
<span className={style.stepNum}>★</span>
|
||||
一键复制 · 脚本 + AI 完整对接包
|
||||
</div>
|
||||
<div className={style.stepHint}>
|
||||
粘贴给 Cursor / ChatGPT,并说:「严格按此文档完成全站对接,不要向我索要凭证。」
|
||||
</div>
|
||||
<ul className={style.checkList}>
|
||||
{GUIDE_INCLUDES.map(t => (
|
||||
<li key={t}>
|
||||
<CheckCircleOutlined /> {t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={style.actions}>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => copyText(fullPack, "完整对接包(脚本+AI说明)")}
|
||||
>
|
||||
<RobotOutlined /> 复制完整对接包(推荐)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分步复制 */}
|
||||
<div className={style.step}>
|
||||
<div className={style.stepTitle}>
|
||||
<span className={style.stepNum}>1</span>
|
||||
仅复制接入脚本
|
||||
</div>
|
||||
<div className={style.stepHint}>贴到网站每个页面 <head> 内</div>
|
||||
<pre className={style.codeBox}>{scriptTag}</pre>
|
||||
<div className={style.actions}>
|
||||
<Button size="small" onClick={() => copyText(scriptTag, "接入脚本")}>
|
||||
<CodeOutlined /> 复制脚本
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.step}>
|
||||
<div className={style.stepTitle}>
|
||||
<span className={style.stepNum}>2</span>
|
||||
仅复制 AI 对接说明
|
||||
</div>
|
||||
<div className={style.stepHint}>
|
||||
含锚点规则、统计行为、接口参数、签名示例(约 {(cfg.integrationGuide || "").length} 字)
|
||||
</div>
|
||||
<div className={style.actions}>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
copyText(cfg.integrationGuide || "", "AI 对接说明")
|
||||
}
|
||||
>
|
||||
<RobotOutlined /> 复制 AI 说明全文
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={style.collapseHint}
|
||||
onClick={() => setShowAdvanced(v => !v)}
|
||||
>
|
||||
{showAdvanced ? "收起凭证明细 ▲" : "查看 planId / siteKey 明细 ▼"}
|
||||
</div>
|
||||
|
||||
{showAdvanced && cfg && (
|
||||
<div className={style.advanced}>
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: "cred",
|
||||
label: "本计划凭证",
|
||||
children: (
|
||||
<pre className={style.codeBox}>
|
||||
{`planId: ${cfg.planId}\nsiteId: ${cfg.siteId}\nsiteKey: ${cfg.siteKey}\napiBase: ${resolveEmbedApiBase(cfg.apiBase)}\n达标点击: ${cfg.tagClickThreshold} 次`}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "api",
|
||||
label: "接口地址",
|
||||
children: (
|
||||
<pre className={style.codeBox}>
|
||||
{`行为: ${cfg.eventsApiUrl}\n线索(服务端): ${cfg.leadsApiUrl}`}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={style.footer}>
|
||||
<Button
|
||||
onClick={handleSimulate}
|
||||
loading={simulating}
|
||||
disabled={loading || !cfg}
|
||||
>
|
||||
<ThunderboltOutlined /> 一键验收
|
||||
</Button>
|
||||
<Button color="primary" onClick={onClose}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "92vh", overflow: "auto" }}
|
||||
>
|
||||
{renderContent()}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={580}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanAcquireConfigModal;
|
||||
@@ -80,7 +80,8 @@ export interface FormData {
|
||||
export const defFormData: FormData = {
|
||||
name: "",
|
||||
scenario: 1,
|
||||
status: 0,
|
||||
/** 新建计划默认启用(REQ-CKB-121) */
|
||||
status: 1,
|
||||
sceneId: "",
|
||||
remarkType: "phone",
|
||||
greeting: "你好,请通过",
|
||||
@@ -123,4 +124,13 @@ export const defFormData: FormData = {
|
||||
transferInheritInfo: true,
|
||||
transferFirstMessage: "",
|
||||
transferSourceWechatId: "",
|
||||
paymentAmount: "",
|
||||
paymentJumpType: "h5",
|
||||
paymentRedirectUrl: "",
|
||||
paymentMpAppId: "",
|
||||
paymentMpPath: "",
|
||||
paymentMediaImages: [],
|
||||
paymentMediaVideos: [],
|
||||
paymentBehaviorQrUrl: "",
|
||||
paymentBehaviorGroupQrUrl: "",
|
||||
};
|
||||
|
||||
@@ -179,6 +179,14 @@ export default function NewPlan() {
|
||||
detail.textUrl?.fullUrl || (planId ? `webhook/${planId}` : ""),
|
||||
paymentAmount: detail.paymentAmount ?? "",
|
||||
paymentGoodsName: detail.paymentGoodsName ?? "",
|
||||
paymentJumpType: detail.paymentJumpType ?? "h5",
|
||||
paymentRedirectUrl: detail.paymentRedirectUrl ?? "",
|
||||
paymentMpAppId: detail.paymentMpAppId ?? "",
|
||||
paymentMpPath: detail.paymentMpPath ?? "",
|
||||
paymentMediaImages: detail.paymentMediaImages ?? [],
|
||||
paymentMediaVideos: detail.paymentMediaVideos ?? [],
|
||||
paymentBehaviorQrUrl: detail.paymentBehaviorQrUrl ?? "",
|
||||
paymentBehaviorGroupQrUrl: detail.paymentBehaviorGroupQrUrl ?? "",
|
||||
boundAccount: detail.boundAccount ?? "",
|
||||
monitorDesc: detail.monitorDesc ?? "",
|
||||
monitorKeywords: detail.monitorKeywords ?? "",
|
||||
@@ -216,11 +224,13 @@ export default function NewPlan() {
|
||||
// 新建时,如果是海报场景,设置默认获客成功提示
|
||||
const defaultTips = "请注意消息,稍后加你微信";
|
||||
if (scenarioId) {
|
||||
const sid = Number(scenarioId) || 1;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...{ scenario: Number(scenarioId) || 1 },
|
||||
tips: Number(scenarioId) === 1 ? defaultTips : prev.tips || "",
|
||||
addFriendInterval: Number(scenarioId) === 10 ? 60 : prev.addFriendInterval,
|
||||
...{ scenario: sid, status: 1 },
|
||||
tips: sid === 1 ? defaultTips : prev.tips || "",
|
||||
addFriendInterval: sid === 10 ? 60 : prev.addFriendInterval,
|
||||
paymentJumpType: sid === 8 ? prev.paymentJumpType || "h5" : prev.paymentJumpType,
|
||||
}));
|
||||
} else {
|
||||
// 如果没有 scenarioId,默认是海报场景(scenario === 1),设置默认提示
|
||||
@@ -283,8 +293,13 @@ export default function NewPlan() {
|
||||
const submitData: any = {
|
||||
...formData,
|
||||
sceneId: Number(formData.scenario),
|
||||
status: isEdit ? formData.status : 1,
|
||||
};
|
||||
|
||||
if (Number(formData.scenario) === 8) {
|
||||
submitData.paymentAmount = Number(formData.paymentAmount) || 0;
|
||||
}
|
||||
|
||||
if (Number(formData.scenario) === 10) {
|
||||
submitData.transferInheritInfo =
|
||||
formData.transferInheritInfo !== false;
|
||||
@@ -403,8 +418,35 @@ export default function NewPlan() {
|
||||
}
|
||||
};
|
||||
|
||||
// CKB-140-A5:网站获客(12)/AI客服获客(13) 不配置好友申请与微信消息,只走基础设置一步
|
||||
const isWebLikeScene = [12, 13].includes(Number(formData.scenario));
|
||||
const activeSteps = isWebLikeScene ? steps.slice(0, 1) : steps;
|
||||
|
||||
// 下一步
|
||||
const handleNext = () => {
|
||||
if (currentStep === 1 && Number(formData.scenario) === 8) {
|
||||
if (!(formData.name || "").trim()) {
|
||||
message.error("请填写计划名称");
|
||||
return;
|
||||
}
|
||||
const amount = Number(formData.paymentAmount);
|
||||
if (!amount || amount <= 0) {
|
||||
message.error("请填写付款金额(大于 0)");
|
||||
return;
|
||||
}
|
||||
const jump = formData.paymentJumpType || "h5";
|
||||
if (jump === "wechat_qr" && !(formData.paymentBehaviorQrUrl || "").trim()) {
|
||||
message.error("请上传微信二维码");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
jump === "wechat_group_qr" &&
|
||||
!(formData.paymentBehaviorGroupQrUrl || "").trim()
|
||||
) {
|
||||
message.error("请上传微信群活码");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (currentStep === 1 && Number(formData.scenario) === 10) {
|
||||
if (!(formData.name || "").trim()) {
|
||||
message.error("请填写计划名称");
|
||||
@@ -426,7 +468,7 @@ export default function NewPlan() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (currentStep === steps.length) {
|
||||
if (currentStep === activeSteps.length) {
|
||||
// 最后一步时调用保存,防止重复点击
|
||||
if (!submitting) {
|
||||
handleSave();
|
||||
@@ -492,7 +534,7 @@ export default function NewPlan() {
|
||||
? isEdit
|
||||
? "更新中..."
|
||||
: "创建中..."
|
||||
: currentStep === steps.length
|
||||
: currentStep === activeSteps.length
|
||||
? "完成"
|
||||
: "下一步"}
|
||||
</Button>
|
||||
@@ -505,7 +547,9 @@ export default function NewPlan() {
|
||||
header={
|
||||
<>
|
||||
<NavCommon title={isEdit ? "编辑场景计划" : "新建场景计划"} />
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
{activeSteps.length > 1 && (
|
||||
<StepIndicator currentStep={currentStep} steps={activeSteps} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={renderFooterButtons()}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Input, Button, Tag, Switch, Spin, message, Modal, Radio } from "antd";
|
||||
import { Input, Button, Tag, Switch, Spin, message, Modal, Radio, Select, Alert } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EyeOutlined,
|
||||
@@ -29,6 +29,8 @@ import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import request from "@/api/request";
|
||||
import AdvancedSettingsSection from "./AdvancedSettingsSection";
|
||||
import { getContentLibraryList } from "@/components/ContentSelection/api";
|
||||
import { fetchKnowledgeBaseList } from "@/pages/mobile/workspace/ai-knowledge/list/api";
|
||||
import DouyinBindSection from "./DouyinBindSection";
|
||||
import FriendMigrationSection from "./FriendMigrationSection";
|
||||
import {
|
||||
@@ -60,6 +62,35 @@ const DEFAULT_OFFICIAL_AI_PROMPT =
|
||||
"对话循序渐进,每轮只推进一小步:先打招呼问行业 → 确认意向 → 发1份相关材料要点 → 再邀请留手机号/微信。\n" +
|
||||
"每次回复 80 字以内,优先一句一个问题,不连续抛多个问题;无依据时不报数字,引导留资。";
|
||||
|
||||
/** 留资引导预设(CKB-133-B2) */
|
||||
const OFFICIAL_AI_LEAD_PRESETS = [
|
||||
{
|
||||
key: "default",
|
||||
label: "循序渐进留资(默认)",
|
||||
text: "如需完整方案与试用,可留下手机号或微信,由顾问跟进。",
|
||||
},
|
||||
{
|
||||
key: "material",
|
||||
label: "资料领取型",
|
||||
text: "需要完整方案资料的话,留下手机号,我整理后发您。",
|
||||
},
|
||||
{
|
||||
key: "trial",
|
||||
label: "试用开通型",
|
||||
text: "想先体验可以留微信,我帮您开通试用账号。",
|
||||
},
|
||||
{ key: "custom", label: "自定义…", text: "" },
|
||||
] as const;
|
||||
|
||||
type LeadHintKey = (typeof OFFICIAL_AI_LEAD_PRESETS)[number]["key"];
|
||||
|
||||
const resolveLeadHintKey = (hint?: string): LeadHintKey => {
|
||||
const v = (hint || "").trim();
|
||||
if (!v) return "default";
|
||||
const hit = OFFICIAL_AI_LEAD_PRESETS.find(p => p.key !== "custom" && p.text === v);
|
||||
return hit?.key ?? "custom";
|
||||
};
|
||||
|
||||
const generatePosterMaterials = (): Material[] => {
|
||||
return posterTemplates.map(template => ({
|
||||
id: template.id,
|
||||
@@ -113,6 +144,72 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
formData.distributionAddReward
|
||||
);
|
||||
|
||||
const [contentLibraryOptions, setContentLibraryOptions] = useState<
|
||||
{ label: string; value: number }[]
|
||||
>([]);
|
||||
const [knowledgeTypeOptions, setKnowledgeTypeOptions] = useState<
|
||||
{ label: string; value: number }[]
|
||||
>([]);
|
||||
const [officialAiLibLoading, setOfficialAiLibLoading] = useState(false);
|
||||
const [leadHintKey, setLeadHintKey] = useState<LeadHintKey>(() =>
|
||||
resolveLeadHintKey(formData.officialAiLeadHint),
|
||||
);
|
||||
|
||||
// CKB-140:AI 客服设置归属 AI客服获客(13);旧网站获客(12)计划已有配置时兼容展示(140-A6)
|
||||
const sceneNum = Number(formData.scenario);
|
||||
const hasLegacyOfficialAi = Boolean(
|
||||
formData.officialAiGreeting ||
|
||||
formData.officialAiSystemPrompt ||
|
||||
formData.officialAiLeadHint ||
|
||||
formData.officialAiEnabled === true,
|
||||
);
|
||||
const showOfficialAiSettings =
|
||||
sceneNum === 13 || (sceneNum === 12 && hasLegacyOfficialAi);
|
||||
|
||||
useEffect(() => {
|
||||
const s = Number(formData.scenario);
|
||||
if (s !== 12 && s !== 13) return;
|
||||
setOfficialAiLibLoading(true);
|
||||
Promise.all([
|
||||
getContentLibraryList({ page: 1, limit: 200 }),
|
||||
fetchKnowledgeBaseList({ page: 1, limit: 200 }),
|
||||
])
|
||||
.then(([libRes, kbRes]) => {
|
||||
const libs = (libRes?.list || libRes?.data || []) as {
|
||||
id: number;
|
||||
name?: string;
|
||||
title?: string;
|
||||
}[];
|
||||
setContentLibraryOptions(
|
||||
libs.map(item => ({
|
||||
value: item.id,
|
||||
label: item.name || item.title || `内容库 #${item.id}`,
|
||||
})),
|
||||
);
|
||||
const kbs = (kbRes?.data || []) as { id: number; name: string }[];
|
||||
setKnowledgeTypeOptions(
|
||||
kbs.map(item => ({
|
||||
value: item.id,
|
||||
label: item.name || `知识库 #${item.id}`,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setContentLibraryOptions([]);
|
||||
setKnowledgeTypeOptions([]);
|
||||
})
|
||||
.finally(() => setOfficialAiLibLoading(false));
|
||||
}, [formData.scenario]);
|
||||
|
||||
useEffect(() => {
|
||||
setLeadHintKey(resolveLeadHintKey(formData.officialAiLeadHint));
|
||||
}, [formData.officialAiLeadHint]);
|
||||
|
||||
const officialAiKnowledgeTypeValue =
|
||||
formData.officialAiKnowledgeBaseTypeId ||
|
||||
formData.officialAiKnowledgeBaseId ||
|
||||
undefined;
|
||||
|
||||
useEffect(() => {
|
||||
getKefuList({ limit: 200, bindStatus: "bound" })
|
||||
.then(res => {
|
||||
@@ -806,6 +903,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
<Radio value="wechat_mp">微信小程序</Radio>
|
||||
<Radio value="douyin">抖音</Radio>
|
||||
<Radio value="wechat_oa">微信公众号</Radio>
|
||||
<Radio value="personal_qr">个微码</Radio>
|
||||
<Radio value="group_qr">群码</Radio>
|
||||
<Radio value="auto_group">自动拉群</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{(formData.paymentJumpType || "h5") === "h5" && (
|
||||
@@ -860,6 +960,58 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{formData.paymentJumpType === "personal_qr" && (
|
||||
<>
|
||||
<div className={styles["basic-label"]}>个微码图片</div>
|
||||
<FileUpload
|
||||
buttonText="上传个微码"
|
||||
acceptTypes={["jpg", "jpeg", "png", "webp"]}
|
||||
maxCount={1}
|
||||
onChange={async result => {
|
||||
if (result?.fileUrl) {
|
||||
onChange({ ...formData, paymentPersonalQr: result.fileUrl });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{formData.paymentPersonalQr && (
|
||||
<img
|
||||
src={formData.paymentPersonalQr}
|
||||
alt="个微码"
|
||||
style={{ width: 96, height: 96, marginTop: 8, borderRadius: 8, objectFit: "cover" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{formData.paymentJumpType === "group_qr" && (
|
||||
<>
|
||||
<div className={styles["basic-label"]}>群二维码图片</div>
|
||||
<FileUpload
|
||||
buttonText="上传群码"
|
||||
acceptTypes={["jpg", "jpeg", "png", "webp"]}
|
||||
maxCount={1}
|
||||
onChange={async result => {
|
||||
if (result?.fileUrl) {
|
||||
onChange({ ...formData, paymentGroupQr: result.fileUrl });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{formData.paymentGroupQr && (
|
||||
<img
|
||||
src={formData.paymentGroupQr}
|
||||
alt="群码"
|
||||
style={{ width: 96, height: 96, marginTop: 8, borderRadius: 8, objectFit: "cover" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{formData.paymentJumpType === "auto_group" && (
|
||||
<div
|
||||
className={styles["basic-input-block"]}
|
||||
style={{ padding: 12, color: "#888", fontSize: 13 }}
|
||||
>
|
||||
付款后自动邀请进群:请在下方「拉群配置」中开启并设置群名与固定成员。
|
||||
</div>
|
||||
)}
|
||||
<div className={styles["basic-label"]}>付款页展示图(可选)</div>
|
||||
<FileUpload
|
||||
buttonText="上传图片"
|
||||
@@ -1227,15 +1379,17 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 网站获客 · AI 客服设置(sceneId=12 · REQ-CKB-69/108)。
|
||||
{/* CKB-140:AI 客服设置 → AI客服获客场景(13);旧网站获客(12)计划兼容读取(140-A6)。
|
||||
字段随 sceneConf 整体落盘,OfficialAiChatController 读取 officialAi*。 */}
|
||||
{Number(formData.scenario) === 12 && (
|
||||
{showOfficialAiSettings && (
|
||||
<div className={styles["basic-phone-settings"]}>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 8 }}>
|
||||
网站 AI 客服设置
|
||||
{sceneNum === 13 ? "AI 客服获客设置" : "网站 AI 客服设置(旧计划兼容)"}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: "#64748b", marginBottom: 12 }}>
|
||||
网站拓客 · 访客对话后引导留资(非行为轨迹)
|
||||
{sceneNum === 13
|
||||
? "一段 JS 嵌入网页即可启用 · 访客由 AI 客服接待并引导留资"
|
||||
: "该配置已迁往「AI客服获客」场景;本计划为旧数据兼容展示,不丢配置"}
|
||||
</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div
|
||||
@@ -1285,49 +1439,114 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6 }}>留资引导话术</div>
|
||||
<Input
|
||||
value={formData.officialAiLeadHint || ""}
|
||||
onChange={e =>
|
||||
onChange({ ...formData, officialAiLeadHint: e.target.value })
|
||||
}
|
||||
placeholder="如需完整方案与试用,可留下手机号或微信,由顾问跟进。"
|
||||
maxLength={120}
|
||||
aria-label="官网 AI 留资引导话术"
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={leadHintKey}
|
||||
options={OFFICIAL_AI_LEAD_PRESETS.map(p => ({
|
||||
value: p.key,
|
||||
label: p.label,
|
||||
}))}
|
||||
onChange={(key: LeadHintKey) => {
|
||||
setLeadHintKey(key);
|
||||
if (key === "custom") {
|
||||
onChange({ ...formData, officialAiLeadHint: formData.officialAiLeadHint || "" });
|
||||
return;
|
||||
}
|
||||
const preset = OFFICIAL_AI_LEAD_PRESETS.find(p => p.key === key);
|
||||
onChange({
|
||||
...formData,
|
||||
officialAiLeadHint: preset?.text || "",
|
||||
});
|
||||
}}
|
||||
aria-label="留资引导话术预设"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 6 }}>绑定内容库 ID(选填)</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.officialAiContentLibraryId || ""}
|
||||
{leadHintKey === "custom" && (
|
||||
<Input.TextArea
|
||||
style={{ marginTop: 8 }}
|
||||
value={formData.officialAiLeadHint || ""}
|
||||
onChange={e =>
|
||||
onChange({ ...formData, officialAiLeadHint: e.target.value })
|
||||
}
|
||||
placeholder="自定义留资引导话术"
|
||||
maxLength={120}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
aria-label="自定义留资引导话术"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{officialAiLibLoading && (
|
||||
<Alert type="info" message="正在加载内容库与知识库…" showIcon />
|
||||
)}
|
||||
{!officialAiLibLoading && contentLibraryOptions.length === 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="暂无内容库,请先在「我的 · 内容库」创建"
|
||||
action={
|
||||
<Button size="small" type="link" onClick={() => navigate("/mine/content/list")}>
|
||||
去创建
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!officialAiLibLoading && knowledgeTypeOptions.length === 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="暂无知识库,请先在「工作台 · AI 知识库」创建"
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => navigate("/workspace/ai-knowledge/list")}
|
||||
>
|
||||
去创建
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 12, flexDirection: "column" }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6 }}>绑定内容库(选填)</div>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="不绑定"
|
||||
loading={officialAiLibLoading}
|
||||
disabled={officialAiLibLoading}
|
||||
value={formData.officialAiContentLibraryId || undefined}
|
||||
options={contentLibraryOptions}
|
||||
onChange={v =>
|
||||
onChange({
|
||||
...formData,
|
||||
officialAiContentLibraryId: e.target.value
|
||||
? Number(e.target.value)
|
||||
: undefined,
|
||||
officialAiContentLibraryId: v ? Number(v) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder="内容库 ID"
|
||||
aria-label="绑定内容库 ID"
|
||||
aria-label="绑定内容库"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 6 }}>绑定知识库 ID(选填)</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.officialAiKnowledgeBaseId || ""}
|
||||
onChange={e =>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6 }}>绑定知识库(选填)</div>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="不绑定"
|
||||
loading={officialAiLibLoading}
|
||||
disabled={officialAiLibLoading}
|
||||
value={officialAiKnowledgeTypeValue}
|
||||
options={knowledgeTypeOptions}
|
||||
onChange={v =>
|
||||
onChange({
|
||||
...formData,
|
||||
officialAiKnowledgeBaseId: e.target.value
|
||||
? Number(e.target.value)
|
||||
: undefined,
|
||||
officialAiKnowledgeBaseTypeId: v ? Number(v) : undefined,
|
||||
officialAiKnowledgeBaseId: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="知识库 ID"
|
||||
aria-label="绑定知识库 ID"
|
||||
aria-label="绑定知识库"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
/** 场景获客主目录(对齐存客宝参考 UI) */
|
||||
export const SCENE_CATALOG = [
|
||||
{ id: 12, name: "网站获客", sort: 98 },
|
||||
// CKB-140:AI客服获客独立场景(JS 一键接入 AI 接待留资)
|
||||
{ id: 13, name: "AI客服获客", sort: 99 },
|
||||
{ id: 11, name: "API获客", sort: 97 },
|
||||
{ id: 8, name: "付款码获客", sort: 96 },
|
||||
{ id: 1, name: "海报获客", sort: 100 },
|
||||
@@ -26,16 +28,17 @@ export const SCENE_CATALOG = [
|
||||
{ id: 10, name: "好友迁移", sort: 50 },
|
||||
] as const;
|
||||
|
||||
/** 计划新建页 · 常用前三(网站/API/付款码) */
|
||||
export const PLAN_SCENE_PRIMARY_IDS = [12, 11, 8] as const;
|
||||
/** 计划新建页 · 常用前三(网站/AI客服/API · CKB-140) */
|
||||
export const PLAN_SCENE_PRIMARY_IDS = [12, 13, 11] as const;
|
||||
|
||||
export function sceneGridLabel(sceneId: number, name: string): string {
|
||||
if (sceneId === 12) return "网站";
|
||||
if (sceneId === 13) return "AI客服";
|
||||
return name.replace(/获客$/, "");
|
||||
}
|
||||
|
||||
/** 已上线场景(与 PlanSceneCatalogService::PRODUCTION_READY_IDS 对齐) */
|
||||
export const PRODUCTION_SCENE_IDS = [1, 2, 3, 5, 7, 8, 10, 11, 12] as const;
|
||||
export const PRODUCTION_SCENE_IDS = [1, 2, 3, 5, 7, 8, 10, 11, 12, 13] as const;
|
||||
|
||||
/** AI 智能获客 Beta — 未就绪默认不展示 */
|
||||
export const SHOW_AI_SCENE_BETA = false;
|
||||
@@ -102,6 +105,7 @@ const SCENE_VISUALS: Record<number, SceneVisual> = {
|
||||
7: { gradient: "linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%)", color: "#0ea5e9", iconBg: SCENE_ICON_BG[7] },
|
||||
8: { gradient: "linear-gradient(135deg, #f59e0b 0%, #eab308 100%)", color: "#f59e0b", iconBg: SCENE_ICON_BG[8] },
|
||||
12: { gradient: "linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)", color: "#2563eb", iconBg: SCENE_ICON_BG[12] },
|
||||
13: { gradient: "linear-gradient(135deg, #0ea5e9 0%, #06b6d4 100%)", color: "#0ea5e9", iconBg: SCENE_ICON_BG[13] },
|
||||
9: { gradient: "linear-gradient(135deg, #0891b2 0%, #06b6d4 100%)", color: "#0891b2", iconBg: SCENE_ICON_BG[9] },
|
||||
10: { gradient: "linear-gradient(135deg, #f97316 0%, #fb923c 100%)", color: "#f97316", iconBg: SCENE_ICON_BG[10] },
|
||||
11: { gradient: "linear-gradient(135deg, #64748b 0%, #475569 100%)", color: "#64748b", iconBg: SCENE_ICON_BG[11] },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { generateSign } from "./utils/sign";
|
||||
// API配置
|
||||
const API_BASE_URL = "https://ckbapi.quwanzhi.com/v1/api";
|
||||
// 默认API Key(用于测试)
|
||||
export const DEFAULT_API_KEY = "v3pzy-zcfkg-96jio-7xgh6-14kio";
|
||||
export const DEFAULT_API_KEY = import.meta.env.VITE_API_KEY || "";
|
||||
|
||||
export interface SubmitLeadParams {
|
||||
phone: string;
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@/pages/mobile/home/homeHelperPrefs";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { IconXiaobaoAi } from "@/components/icons/brandLogo";
|
||||
import { saveAiWorkbenchSelectionDraft } from "@/utils/aiWorkbenchSelectionDraft";
|
||||
|
||||
const QUICK_PROMPTS = [
|
||||
"帮我生成一个海报获客计划并配置好在线设备",
|
||||
@@ -76,6 +77,12 @@ const TOOL_API_MAP: Record<
|
||||
desc: "创建工作台任务",
|
||||
jumpPath: "/workspace/auto-like",
|
||||
},
|
||||
prepare_workbench_task: {
|
||||
method: "GET",
|
||||
path: "/internal/prepare-workbench-task",
|
||||
desc: "补全工作台正式配置",
|
||||
jumpPath: "/workspace",
|
||||
},
|
||||
update_workbench_status: {
|
||||
method: "POST",
|
||||
path: "/v1/workbench/update-status",
|
||||
@@ -535,6 +542,55 @@ export const AIAssistantPanel: React.FC<AIAssistantPanelProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderPreparedCard = (msg: AiMessage) => {
|
||||
const prepared = (msg.actions || []).filter(
|
||||
a => a.tool === "prepare_workbench_task" && a.status === "done" && a.jumpPath,
|
||||
);
|
||||
if (!prepared.length) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.configCard}>
|
||||
<div className={styles.configCardHead}>
|
||||
<div className={styles.configCardIcon}>
|
||||
<SettingOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.configCardTitle}>正式配置页已预填</div>
|
||||
<div className={styles.configCardDesc}>
|
||||
尚未写入业务数据,请核对目标、内容库与频率后提交
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{prepared.map(action => (
|
||||
<div className={styles.apiItem} key={action.id}>
|
||||
<div className={styles.apiItemHead}>
|
||||
<div className={styles.apiLabel}>{action.label || action.desc}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.jumpLink}
|
||||
onClick={() => {
|
||||
const selectionDraft = action.result?.data?.selectionDraft;
|
||||
if (
|
||||
selectionDraft &&
|
||||
typeof selectionDraft === "object" &&
|
||||
"type" in selectionDraft
|
||||
) {
|
||||
saveAiWorkbenchSelectionDraft(selectionDraft as any);
|
||||
}
|
||||
onClose?.();
|
||||
navigate(action.jumpPath!);
|
||||
}}
|
||||
>
|
||||
打开并补全 ›
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.apiParams}>{formatActionParams(action)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessage = (msg: AiMessage) => {
|
||||
const isUser = msg.role === "user";
|
||||
const isStreaming =
|
||||
@@ -578,6 +634,7 @@ export const AIAssistantPanel: React.FC<AIAssistantPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{!isUser && renderConfigCard(msg)}
|
||||
{!isUser && renderPreparedCard(msg)}
|
||||
{!isUser && msg.powerConsumed ? (
|
||||
<div className={styles.powerMeta}>
|
||||
本次消耗 {msg.powerConsumed} 算力点
|
||||
|
||||
@@ -17,6 +17,7 @@ import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import { ContentItem } from "@/components/ContentSelection/data";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import styles from "./index.module.scss";
|
||||
import { consumeAiWorkbenchSelectionDraft } from "@/utils/aiWorkbenchSelectionDraft";
|
||||
|
||||
// 根据targetType和groupPushSubType动态生成步骤
|
||||
const getSteps = (targetType: number, groupPushSubType?: number) => {
|
||||
@@ -56,6 +57,10 @@ const NewGroupPush: React.FC = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlTargetType = urlParams.get("targetType");
|
||||
const urlGroupPushSubType = urlParams.get("groupPushSubType");
|
||||
const prefillNumber = (key: string, fallback: number) => {
|
||||
const value = Number(urlParams.get(key));
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
};
|
||||
const [deviceGroupsOptions, setDeviceGroupsOptions] = useState<
|
||||
DeviceSelectionItem[]
|
||||
>([]);
|
||||
@@ -67,16 +72,16 @@ const NewGroupPush: React.FC = () => {
|
||||
>([]);
|
||||
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
planType: 1, // 默认独立计划
|
||||
name: "",
|
||||
startTime: "09:00", // 允许推送的开始时间
|
||||
planType: prefillNumber("planType", 1), // 默认独立计划
|
||||
name: (urlParams.get("name") || "").slice(0, 50),
|
||||
startTime: urlParams.get("startTime") || "09:00", // 允许推送的开始时间
|
||||
dailyPushCount: 0, // 每日已推送次数
|
||||
endTime: "21:00", // 允许推送的结束时间
|
||||
maxPerDay: 20,
|
||||
pushOrder: 1, // 1: 按最早
|
||||
endTime: urlParams.get("endTime") || "21:00", // 允许推送的结束时间
|
||||
maxPerDay: Math.max(1, prefillNumber("maxPerDay", 20)),
|
||||
pushOrder: prefillNumber("pushOrder", 1), // 1: 按最早
|
||||
isLoop: 0, // 0: 否, 1: 是
|
||||
pushType: 0, // 0: 定时推送, 1: 立即推送
|
||||
status: 0, // 0: 否, 1: 是(同时作为是否自动启动)
|
||||
pushType: prefillNumber("pushType", 0), // 0: 定时推送, 1: 立即推送
|
||||
status: prefillNumber("status", 0), // 0: 否, 1: 是(同时作为是否自动启动)
|
||||
isRandomTemplate: 0, // 是否随机模板:0=否,1=是
|
||||
postPushTags: [], // 推送后标签数组
|
||||
wechatGroups: [],
|
||||
@@ -198,6 +203,43 @@ const NewGroupPush: React.FC = () => {
|
||||
loadEditData();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
id ||
|
||||
new URLSearchParams(window.location.search).get("aiPrefill") !== "1"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selectionDraft = consumeAiWorkbenchSelectionDraft(3);
|
||||
if (!selectionDraft) return;
|
||||
const deviceOptions = Array.isArray(selectionDraft.deviceGroupsOptions)
|
||||
? (selectionDraft.deviceGroupsOptions as DeviceSelectionItem[])
|
||||
: [];
|
||||
const groupOptions = Array.isArray(selectionDraft.wechatGroupsOptions)
|
||||
? (selectionDraft.wechatGroupsOptions as GroupSelectionItem[]).map(group => ({
|
||||
...group,
|
||||
id: String(group.id),
|
||||
}))
|
||||
: [];
|
||||
const contentOptions = Array.isArray(selectionDraft.contentGroupsOptions)
|
||||
? (selectionDraft.contentGroupsOptions as ContentItem[])
|
||||
: [];
|
||||
const poolOptions = Array.isArray(selectionDraft.poolGroupsOptions)
|
||||
? selectionDraft.poolGroupsOptions
|
||||
: [];
|
||||
setDeviceGroupsOptions(deviceOptions);
|
||||
setWechatGroupsOptions(groupOptions);
|
||||
setContentGroupsOptions(contentOptions);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
deviceGroups: deviceOptions.map(device => String(device.id)),
|
||||
wechatGroups: groupOptions.map(group => String(group.id)),
|
||||
contentGroups: contentOptions.map(content => String(content.id)),
|
||||
poolGroups: poolOptions.map(pool => String(pool.id)),
|
||||
poolGroupsOptions: poolOptions,
|
||||
}));
|
||||
}, [id]);
|
||||
|
||||
const handleBasicSettingsChange = (values: Partial<FormData>) => {
|
||||
setFormData(prev => ({ ...prev, ...values }));
|
||||
};
|
||||
|
||||
@@ -117,13 +117,6 @@ const featureConfig: Record<
|
||||
icon: <ShareAltOutlined />,
|
||||
description: "管理流量分发和分配",
|
||||
},
|
||||
website_analytics: {
|
||||
path: "/workspace/website-analytics",
|
||||
tone: "cyan",
|
||||
icon: <BarChartOutlined />,
|
||||
displayName: "网站获客统计",
|
||||
description: "外部站点行为上报与推送",
|
||||
},
|
||||
distribution_management: {
|
||||
path: "/workspace/distribution-management",
|
||||
tone: "purple",
|
||||
@@ -141,7 +134,13 @@ const featureConfig: Record<
|
||||
};
|
||||
|
||||
// 始终保证可达的本地入口(后端常用功能未下发时兜底)
|
||||
const LOCAL_FEATURE_KEYS = ["transfer_approval", "website_analytics"];
|
||||
const LOCAL_FEATURE_KEYS: string[] = [];
|
||||
|
||||
// 已迁出工作台的入口:
|
||||
// - transfer_approval → 我的 / 客服(带待办红点)
|
||||
// CKB-121.5:网站获客统计「宫格」下线(即使后端 feature 列表仍返回也过滤);
|
||||
// 统计页改由 场景获客 / 网站获客 入口进入,保留计划与 WebsiteTrack API。
|
||||
const HIDDEN_FEATURE_KEYS = new Set(["transfer_approval", "website_analytics"]);
|
||||
|
||||
function appendLocalFeatures(list: CommonFunction[]): CommonFunction[] {
|
||||
const existing = new Set(list.map(f => f.key));
|
||||
@@ -234,13 +233,17 @@ const Workspace: React.FC = () => {
|
||||
tone: config.tone,
|
||||
} as CommonFunction;
|
||||
});
|
||||
setFeatures(appendLocalFeatures(mapped));
|
||||
setFeatures(
|
||||
appendLocalFeatures(mapped.filter(f => !HIDDEN_FEATURE_KEYS.has(f.key))),
|
||||
);
|
||||
} catch (e: any) {
|
||||
Toast.show({
|
||||
content: e?.message || "获取常用功能失败",
|
||||
position: "top",
|
||||
});
|
||||
const fallback = Object.keys(featureConfig).map((key, idx) => {
|
||||
const fallback = Object.keys(featureConfig)
|
||||
.filter(key => !HIDDEN_FEATURE_KEYS.has(key))
|
||||
.map((key, idx) => {
|
||||
const cfg = featureConfig[key];
|
||||
return {
|
||||
id: idx + 1,
|
||||
|
||||
@@ -18,6 +18,7 @@ import NavCommon from "@/components/NavCommon";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { ContentItem } from "@/components/ContentSelection/data";
|
||||
import { consumeAiWorkbenchSelectionDraft } from "@/utils/aiWorkbenchSelectionDraft";
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: "基础设置", subtitle: "基础设置" },
|
||||
@@ -97,6 +98,46 @@ const NewMomentsSync: React.FC = () => {
|
||||
if (isEditMode) fetchDetail();
|
||||
}, [isEditMode, fetchDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode) return;
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
if (query.get("aiPrefill") !== "1") return;
|
||||
const selectionDraft = consumeAiWorkbenchSelectionDraft(2);
|
||||
const deviceOptions = Array.isArray(selectionDraft?.deviceGroupsOptions)
|
||||
? (selectionDraft.deviceGroupsOptions as DeviceSelectionItem[])
|
||||
: [];
|
||||
const contentOptions = Array.isArray(selectionDraft?.contentGroupsOptions)
|
||||
? (selectionDraft.contentGroupsOptions as ContentItem[])
|
||||
: [];
|
||||
if (deviceOptions.length > 0) {
|
||||
setSelectedDevicesOptions(deviceOptions);
|
||||
}
|
||||
if (contentOptions.length > 0) {
|
||||
setContentGroupsOptions(contentOptions);
|
||||
}
|
||||
const intValue = (key: string, fallback: number) => {
|
||||
const value = Number(query.get(key));
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
taskName: (query.get("name") || prev.taskName).slice(0, 30),
|
||||
startTime: query.get("startTime") || prev.startTime,
|
||||
endTime: query.get("endTime") || prev.endTime,
|
||||
syncCount: Math.max(1, intValue("syncCount", prev.syncCount)),
|
||||
syncInterval: Math.max(1, intValue("syncInterval", prev.syncInterval)),
|
||||
syncType: intValue("syncType", prev.syncType),
|
||||
accountType: intValue("accountType", prev.accountType),
|
||||
enabled: intValue("status", prev.enabled ? 1 : 0) === 1,
|
||||
planType: intValue("planType", prev.planType),
|
||||
deviceGroups:
|
||||
deviceOptions.length > 0
|
||||
? deviceOptions.map(device => device.id)
|
||||
: prev.deviceGroups,
|
||||
contentGroups: contentOptions.length > 0 ? contentOptions : prev.contentGroups,
|
||||
}));
|
||||
}, [isEditMode]);
|
||||
|
||||
// 步骤切换
|
||||
const next = () => setCurrentStep(s => Math.min(s + 1, steps.length - 1));
|
||||
const prev = () => setCurrentStep(s => Math.max(s - 1, 0));
|
||||
|
||||
@@ -46,6 +46,22 @@ const FieldHelp: React.FC<{ text: string }> = ({ text }) => (
|
||||
</Popover>
|
||||
);
|
||||
|
||||
/** 第一步仅校验这些字段(勿含 touchkebaoKefuIds 等后续步骤项,否则下一步点不动) */
|
||||
const getStep0ValidateFields = (isAdminUser: boolean, timeTypeVal: number) => {
|
||||
const fields: string[] = [
|
||||
"name",
|
||||
"targetType",
|
||||
"friendStatusFilter",
|
||||
"sortBy",
|
||||
"maxPerDay",
|
||||
"distributeType",
|
||||
"timeType",
|
||||
];
|
||||
if (isAdminUser) fields.unshift("planType");
|
||||
if (timeTypeVal === 2) fields.push("timeRange");
|
||||
return fields;
|
||||
};
|
||||
|
||||
const TrafficDistributionForm: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
@@ -310,26 +326,34 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
// 步骤切换
|
||||
const next = () => {
|
||||
if (current === 0) {
|
||||
// 第一步需要验证表单
|
||||
const step0Fields = getStep0ValidateFields(isAdmin, timeType ?? 1);
|
||||
form
|
||||
.validateFields()
|
||||
.validateFields(step0Fields)
|
||||
.then(() => {
|
||||
setCurrent(cur => cur + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
// 验证失败,不进行下一步
|
||||
.catch(err => {
|
||||
const first = err?.errorFields?.[0]?.errors?.[0];
|
||||
message.error(first || "请完善基本信息");
|
||||
});
|
||||
} else if (current === 1) {
|
||||
const kefuIds = form.getFieldValue("touchkebaoKefuIds") as number[] | undefined;
|
||||
if (!Array.isArray(kefuIds) || kefuIds.length < 1) {
|
||||
message.error("请在「分发账号」中至少选择一个子账号");
|
||||
return;
|
||||
}
|
||||
if (distributeType === 3 && ratioSum !== 100) {
|
||||
message.error(`比例分配各子账号合计须为 100%(当前 ${ratioSum}%)`);
|
||||
return;
|
||||
}
|
||||
setCurrent(cur => cur + 1);
|
||||
form
|
||||
.validateFields(["touchkebaoKefuIds", "maxPerKefuPerDay"])
|
||||
.then(() => {
|
||||
const kefuIds = form.getFieldValue("touchkebaoKefuIds") as number[];
|
||||
if (!Array.isArray(kefuIds) || kefuIds.length < 1) {
|
||||
message.error("请在「分发账号」中至少选择一个子账号");
|
||||
return;
|
||||
}
|
||||
if (distributeType === 3 && ratioSum !== 100) {
|
||||
message.error(`比例分配各子账号合计须为 100%(当前 ${ratioSum}%)`);
|
||||
return;
|
||||
}
|
||||
setCurrent(cur => cur + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("请在「分发账号」中至少选择一个子账号");
|
||||
});
|
||||
} else {
|
||||
setCurrent(cur => cur + 1);
|
||||
}
|
||||
@@ -386,6 +410,7 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
name: isEdit ? "" : generateDefaultName(),
|
||||
planType: 1,
|
||||
targetType: 1,
|
||||
touchkebaoKefuIds: [],
|
||||
sortBy: "last_contact",
|
||||
friendStatusFilter: 0,
|
||||
distributeType: 1,
|
||||
@@ -395,8 +420,8 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
}}
|
||||
disabled={detailLoading}
|
||||
onFinish={handleSubmit}
|
||||
style={{ display: current === 0 ? "block" : "none" }}
|
||||
>
|
||||
<div style={{ display: current === 0 ? "block" : "none" }}>
|
||||
{/* 计划类型和计划名称 */}
|
||||
<Card className={style.card}>
|
||||
{isAdmin && (
|
||||
@@ -563,9 +588,9 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
</Form.Item>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
{current === 1 && (
|
||||
<div style={{ display: current === 1 ? "block" : "none" }}>
|
||||
<Card className={style.card}>
|
||||
<div className={style.sectionTitle}>分发账号</div>
|
||||
<div className={style.sliderDesc} style={{ marginBottom: 12 }}>
|
||||
@@ -575,10 +600,7 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
{distributeType === 3 && "当前为比例分配:请为各子账号设置百分比(合计 100%)。"}
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="touchkebaoKefuIds"
|
||||
rules={[{ required: true, message: "请选择至少一个子账号" }]}
|
||||
>
|
||||
<Form.Item name="touchkebaoKefuIds">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择已绑触客宝的子账号"
|
||||
@@ -647,8 +669,9 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
<InputNumber min={0} max={1000} style={{ width: 140 }} addonAfter="条/天" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)}
|
||||
{current === 2 && (
|
||||
</div>
|
||||
|
||||
<div style={{ display: current === 2 ? "block" : "none" }}>
|
||||
<Card className={style.card}>
|
||||
<div className={style.sectionTitle}>
|
||||
流量池选择
|
||||
@@ -683,7 +706,8 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -41,12 +41,13 @@ export function getTransferList(params: {
|
||||
) as Promise<TransferListResp>;
|
||||
}
|
||||
|
||||
// 审批通过(仅管理员)→ 后端执行 allot
|
||||
// 审批通过(仅管理员)→ 后端批量执行 allot(整号可能上百好友,延长超时)
|
||||
export function approveTransfer(id: number, comment?: string) {
|
||||
return request(
|
||||
"/v1/workspace/wechat-account/transfer/approve",
|
||||
{ id, comment },
|
||||
"POST",
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,12 +67,29 @@ const TransferApproval: React.FC = () => {
|
||||
content: `确认通过?将转移「${order.targetName || order.targetId}」给 ${order.toAccountName},预计影响 ${order.affectedFriendCount} 位好友。`,
|
||||
onConfirm: async () => {
|
||||
setActing(true);
|
||||
Toast.show({
|
||||
icon: "loading",
|
||||
content: `正在转移约 ${order.affectedFriendCount} 位好友…`,
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await approveTransfer(order.id);
|
||||
Toast.show({ icon: "success", content: "已通过并执行转移" });
|
||||
const res = await approveTransfer(order.id);
|
||||
Toast.clear();
|
||||
const n = (res as { executedCount?: number })?.executedCount;
|
||||
Toast.show({
|
||||
icon: "success",
|
||||
content: n != null ? `已通过,已转移 ${n} 位好友` : "已通过并执行转移",
|
||||
});
|
||||
fetchList(activeTab);
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e?.message || "审批失败", position: "top" });
|
||||
} catch (e: unknown) {
|
||||
Toast.clear();
|
||||
const msg =
|
||||
typeof e === "string"
|
||||
? e
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: "审批失败";
|
||||
Toast.show({ content: msg, position: "top" });
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
export interface WebsiteTrackSite {
|
||||
id: number;
|
||||
companyId: number;
|
||||
planId: number;
|
||||
siteId: string;
|
||||
siteKey: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
tagClickThreshold: number;
|
||||
status: number;
|
||||
todayPv?: number;
|
||||
visitorCount?: number;
|
||||
pendingPushCount?: number;
|
||||
recentEvents?: WebsiteTrackEvent[];
|
||||
}
|
||||
|
||||
export interface WebsiteTrackVisitor {
|
||||
id: number;
|
||||
visitorId: string;
|
||||
phone: string;
|
||||
phoneMasked?: string;
|
||||
tagClickCount: number;
|
||||
pageViewCount: number;
|
||||
hasAddCart: number;
|
||||
hasPurchase: number;
|
||||
pushedTouchkebao: number;
|
||||
lastEventAt: number;
|
||||
}
|
||||
|
||||
export interface WebsiteTrackEvent {
|
||||
id: number;
|
||||
event: string;
|
||||
page: string;
|
||||
phone: string;
|
||||
eventTs: number;
|
||||
createTime: number;
|
||||
}
|
||||
|
||||
export const fetchWebsiteTrackSites = (params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) =>
|
||||
request("/v1/website-track/sites", params, "GET") as Promise<{
|
||||
list: WebsiteTrackSite[];
|
||||
total: number;
|
||||
}>;
|
||||
|
||||
export const createWebsiteTrackSite = (data: {
|
||||
name: string;
|
||||
domain?: string;
|
||||
planId?: number;
|
||||
siteId?: string;
|
||||
tagClickThreshold?: number;
|
||||
}) =>
|
||||
request("/v1/website-track/sites", data, "POST") as Promise<WebsiteTrackSite>;
|
||||
|
||||
export const fetchWebsiteTrackSiteDetail = (id: number) =>
|
||||
request(`/v1/website-track/sites/${id}`, undefined, "GET") as Promise<
|
||||
WebsiteTrackSite
|
||||
>;
|
||||
|
||||
export const fetchWebsiteTrackVisitors = (
|
||||
id: number,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
) =>
|
||||
request(
|
||||
`/v1/website-track/sites/${id}/visitors`,
|
||||
params,
|
||||
"GET",
|
||||
) as Promise<{ list: WebsiteTrackVisitor[]; total: number }>;
|
||||
|
||||
export const fetchWebsiteTrackVisitorEvents = (
|
||||
id: number,
|
||||
visitorId: string,
|
||||
) =>
|
||||
request(
|
||||
`/v1/website-track/sites/${id}/visitor-events`,
|
||||
{ visitorId },
|
||||
"GET",
|
||||
) as Promise<{ list: WebsiteTrackEvent[] }>;
|
||||
@@ -1,191 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button, Spin, Tabs, Tag, message } from "antd";
|
||||
import { CopyOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { CkbPageShell, CkbSectionCard, CkbEmptyState } from "@/components/ckb";
|
||||
import {
|
||||
fetchWebsiteTrackSiteDetail,
|
||||
fetchWebsiteTrackVisitorEvents,
|
||||
fetchWebsiteTrackVisitors,
|
||||
type WebsiteTrackEvent,
|
||||
type WebsiteTrackSite,
|
||||
type WebsiteTrackVisitor,
|
||||
} from "../api";
|
||||
import styles from "../index.module.scss";
|
||||
|
||||
const curlExample = (site: WebsiteTrackSite) => `curl -X POST 'http://127.0.0.1:8082/v1/website-track/events' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'X-Site-Key: ${site.siteKey}' \\
|
||||
-d '{"siteId":"${site.siteId}","visitorId":"v_demo","event":"page_view","page":"/home"}'`;
|
||||
|
||||
const WebsiteAnalyticsDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const siteId = Number(id);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [site, setSite] = useState<WebsiteTrackSite | null>(null);
|
||||
const [visitors, setVisitors] = useState<WebsiteTrackVisitor[]>([]);
|
||||
const [events, setEvents] = useState<WebsiteTrackEvent[]>([]);
|
||||
const [activeVisitor, setActiveVisitor] = useState<string>("");
|
||||
|
||||
const load = async () => {
|
||||
if (!siteId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [detailRes, visitorRes] = await Promise.all([
|
||||
fetchWebsiteTrackSiteDetail(siteId),
|
||||
fetchWebsiteTrackVisitors(siteId, { page: 1, pageSize: 50 }),
|
||||
]);
|
||||
setSite(detailRes || null);
|
||||
setVisitors(visitorRes?.list || []);
|
||||
} catch (e: unknown) {
|
||||
message.error((e as Error)?.message || "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [siteId]);
|
||||
|
||||
const loadVisitorEvents = async (visitorId: string) => {
|
||||
setActiveVisitor(visitorId);
|
||||
try {
|
||||
const res = await fetchWebsiteTrackVisitorEvents(siteId, visitorId);
|
||||
setEvents(res?.list || []);
|
||||
} catch (e: unknown) {
|
||||
message.error((e as Error)?.message || "加载轨迹失败");
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success(`${label} 已复制`);
|
||||
} catch {
|
||||
message.error("复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
title={site?.name || "站点详情"}
|
||||
backFn={() => navigate("/workspace/website-analytics")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CkbPageShell className={styles.websiteAnalyticsPage}>
|
||||
<Spin spinning={loading}>
|
||||
{site && (
|
||||
<>
|
||||
<CkbSectionCard title="上报配置">
|
||||
<div>siteId:<Tag>{site.siteId}</Tag></div>
|
||||
<div className={styles.keyBox}>
|
||||
siteKey:{site.siteKey}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyText(site.siteKey, "siteKey")}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.keyBox}>
|
||||
{curlExample(site)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyText(curlExample(site), "示例 curl")}
|
||||
>
|
||||
复制 curl
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.metaRow}>
|
||||
<span>标签点击门槛 ≥{site.tagClickThreshold}</span>
|
||||
<span>计划 ID {site.planId || "—"}</span>
|
||||
</div>
|
||||
</CkbSectionCard>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: "visitors",
|
||||
label: "访客",
|
||||
children:
|
||||
visitors.length === 0 ? (
|
||||
<CkbEmptyState title="暂无访客" />
|
||||
) : (
|
||||
visitors.map(v => (
|
||||
<div key={v.id} className={styles.visitorRow}>
|
||||
<div>
|
||||
<strong>{v.visitorId}</strong>{" "}
|
||||
{v.phoneMasked || "无手机号"}
|
||||
</div>
|
||||
<div className={styles.metaRow}>
|
||||
<span>PV {v.pageViewCount}</span>
|
||||
<span>标签点击 {v.tagClickCount}</span>
|
||||
<span>
|
||||
{v.pushedTouchkebao ? "已推送" : "未推送"}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => loadVisitorEvents(v.visitorId)}
|
||||
>
|
||||
轨迹
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
label: "最近事件",
|
||||
children: (site.recentEvents || []).length === 0 ? (
|
||||
<CkbEmptyState title="暂无事件" />
|
||||
) : (
|
||||
(site.recentEvents || []).map(ev => (
|
||||
<div key={ev.id} className={styles.eventItem}>
|
||||
<Tag>{ev.event}</Tag> {ev.page}{" "}
|
||||
<span className={styles.metaRow}>
|
||||
{new Date(
|
||||
(ev.eventTs || ev.createTime) * 1000,
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "journey",
|
||||
label: activeVisitor ? `轨迹·${activeVisitor}` : "轨迹",
|
||||
children:
|
||||
events.length === 0 ? (
|
||||
<CkbEmptyState title="选择访客查看轨迹" />
|
||||
) : (
|
||||
events.map(ev => (
|
||||
<div key={ev.id} className={styles.eventItem}>
|
||||
<Tag>{ev.event}</Tag> {ev.page}
|
||||
</div>
|
||||
))
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</CkbPageShell>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsiteAnalyticsDetail;
|
||||
@@ -1,37 +0,0 @@
|
||||
.websiteAnalyticsPage {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.siteCard {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--ckb-text-secondary, #666);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.keyBox {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #f6f8fa;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.eventItem {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.visitorRow {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Input, Modal, Spin, Tag, message } from "antd";
|
||||
import { PlusOutlined, GlobalOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
CkbPageShell,
|
||||
CkbEmptyState,
|
||||
CkbListCard,
|
||||
} from "@/components/ckb";
|
||||
import {
|
||||
createWebsiteTrackSite,
|
||||
fetchWebsiteTrackSites,
|
||||
type WebsiteTrackSite,
|
||||
} from "../api";
|
||||
import styles from "../index.module.scss";
|
||||
|
||||
const WebsiteAnalyticsList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<WebsiteTrackSite[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", domain: "", planId: "" });
|
||||
|
||||
const loadList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchWebsiteTrackSites({ page: 1, pageSize: 50 });
|
||||
setList(res?.list || []);
|
||||
} catch (e: unknown) {
|
||||
message.error((e as Error)?.message || "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim()) {
|
||||
message.warning("请填写站点名称");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await createWebsiteTrackSite({
|
||||
name: form.name.trim(),
|
||||
domain: form.domain.trim(),
|
||||
planId: form.planId ? Number(form.planId) : undefined,
|
||||
});
|
||||
message.success("站点已创建");
|
||||
setCreateOpen(false);
|
||||
setForm({ name: "", domain: "", planId: "" });
|
||||
if (res?.id) {
|
||||
navigate(`/workspace/website-analytics/${res.id}`);
|
||||
} else {
|
||||
loadList();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
message.error((e as Error)?.message || "创建失败");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
title="网站获客统计"
|
||||
right={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
新建站点
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CkbPageShell className={styles.websiteAnalyticsPage}>
|
||||
<Spin spinning={loading}>
|
||||
{list.length === 0 && !loading ? (
|
||||
<CkbEmptyState
|
||||
title="暂无网站站点"
|
||||
description="一个网站一个计划,创建后复制 siteKey 给前端接入上报 API"
|
||||
/>
|
||||
) : (
|
||||
list.map(item => (
|
||||
<CkbListCard
|
||||
key={item.id}
|
||||
className={styles.siteCard}
|
||||
onClick={() => navigate(`/workspace/website-analytics/${item.id}`)}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<strong>
|
||||
<GlobalOutlined style={{ marginRight: 8 }} />
|
||||
{item.name}
|
||||
</strong>
|
||||
<Tag color={item.status === 1 ? "success" : "default"}>
|
||||
{item.status === 1 ? "启用" : "停用"}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>{item.domain || item.siteId}</div>
|
||||
<div className={styles.metaRow}>
|
||||
<span>今日 PV {item.todayPv ?? 0}</span>
|
||||
<span>访客 {item.visitorCount ?? 0}</span>
|
||||
<span>待推送 {item.pendingPushCount ?? 0}</span>
|
||||
</div>
|
||||
</CkbListCard>
|
||||
))
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
title="新建网站获客站点"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={handleCreate}
|
||||
confirmLoading={creating}
|
||||
okText="创建"
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Input
|
||||
placeholder="站点名称(如:老坑爹)"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="域名(可选)"
|
||||
value={form.domain}
|
||||
onChange={e => setForm({ ...form, domain: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="关联场景计划 ID(可选)"
|
||||
value={form.planId}
|
||||
onChange={e => setForm({ ...form, planId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</CkbPageShell>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsiteAnalyticsList;
|
||||
@@ -8,6 +8,7 @@ import ChannelPlanForm from "@/pages/mobile/scenarios/channel/form";
|
||||
import AiScenarioHub from "@/pages/mobile/scenarios/ai";
|
||||
import DouyinOAuthCallback from "@/pages/mobile/oauth/douyin-callback";
|
||||
import PaymentAcquirePage from "@/pages/mobile/payment/acquire";
|
||||
import PaymentBehaviorPage from "@/pages/mobile/payment/behavior";
|
||||
|
||||
const scenarioRoutes = [
|
||||
{
|
||||
@@ -15,6 +16,11 @@ const scenarioRoutes = [
|
||||
element: <PaymentAcquirePage />,
|
||||
auth: false,
|
||||
},
|
||||
{
|
||||
path: "/pay/behavior",
|
||||
element: <PaymentBehaviorPage />,
|
||||
auth: false,
|
||||
},
|
||||
{
|
||||
path: "/scenarios",
|
||||
element: <ScenariosList />,
|
||||
|
||||
@@ -34,8 +34,7 @@ import FormGroupWelcome from "@/pages/mobile/workspace/group-welcome/form";
|
||||
import DetailGroupWelcome from "@/pages/mobile/workspace/group-welcome/detail";
|
||||
import TransferApproval from "@/pages/mobile/workspace/transfer-approval";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
import WebsiteAnalyticsList from "@/pages/mobile/workspace/website-analytics/list";
|
||||
import WebsiteAnalyticsDetail from "@/pages/mobile/workspace/website-analytics/detail";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import DbConnectionStatus from "@/pages/mobile/workspace/db-connection";
|
||||
|
||||
const workspaceRoutes = [
|
||||
@@ -205,15 +204,16 @@ const workspaceRoutes = [
|
||||
element: <TrafficDistributionDetail />,
|
||||
auth: true,
|
||||
},
|
||||
// 网站获客统计 REQ-CKB-107
|
||||
// CKB-130.D2/D3:「网站获客统计」独立路由下线 → 301 到网站获客计划列表(scene=12)
|
||||
// 统计能力迁入计划详情「数据」Tab,不再有独立运营入口
|
||||
{
|
||||
path: "/workspace/website-analytics",
|
||||
element: <WebsiteAnalyticsList />,
|
||||
element: <Navigate to="/scenarios/list/12/网站获客" replace />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/workspace/website-analytics/:id",
|
||||
element: <WebsiteAnalyticsDetail />,
|
||||
element: <Navigate to="/scenarios/list/12/网站获客" replace />,
|
||||
auth: true,
|
||||
},
|
||||
// 通讯录导入
|
||||
|
||||
@@ -30,6 +30,21 @@ export interface Device {
|
||||
timezone?: string;
|
||||
/** IP 地址 */
|
||||
ip?: string;
|
||||
/** CKB-143-A4:是否可通过工作手机 SDK 真机控制 */
|
||||
canControl?: boolean;
|
||||
/** 不可控时的原因文案 */
|
||||
controlReason?: string;
|
||||
workphoneControlChannel?: string;
|
||||
workphoneDeviceId?: string;
|
||||
workphoneMapHint?: string;
|
||||
/** CKB-148:工作手机 SDK 实时在线 */
|
||||
sdkOnline?: boolean;
|
||||
/** 电量来源:sdk_realtime | extra_cache | none */
|
||||
batterySource?: string;
|
||||
/** 是否充电中 */
|
||||
charging?: boolean | null;
|
||||
/** SDK 最近心跳时间 */
|
||||
lastHeartbeat?: string;
|
||||
features?: {
|
||||
autoAddFriend?: boolean;
|
||||
autoReply?: boolean;
|
||||
@@ -76,6 +91,10 @@ export interface WechatAccount {
|
||||
addFriendStatus: number;
|
||||
totalFriend: number;
|
||||
lastActive: string;
|
||||
/** 142 同源:是否为工作手机当前登录微信 */
|
||||
isCurrent?: boolean;
|
||||
/** 当前微信资料来源(websocket/hook | s2_*_fallback) */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface HandleLog {
|
||||
|
||||
51
Cunkebao/src/utils/aiWorkbenchSelectionDraft.ts
Normal file
51
Cunkebao/src/utils/aiWorkbenchSelectionDraft.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ContentItem } from "@/components/ContentSelection/data";
|
||||
import type { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import type { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import type { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||
|
||||
const STORAGE_KEY = "ckb_ai_workbench_selection_draft";
|
||||
const MAX_AGE_MS = 15 * 60 * 1000;
|
||||
|
||||
export interface AiWorkbenchSelectionDraft {
|
||||
type: number;
|
||||
deviceGroups?: string[];
|
||||
deviceGroupsOptions?: DeviceSelectionItem[];
|
||||
contentGroups?: string[];
|
||||
contentGroupsOptions?: ContentItem[];
|
||||
wechatGroups?: string[];
|
||||
wechatGroupsOptions?: GroupSelectionItem[];
|
||||
poolGroups?: string[];
|
||||
poolGroupsOptions?: PoolSelectionItem[];
|
||||
verifiedAt?: number;
|
||||
}
|
||||
|
||||
export function saveAiWorkbenchSelectionDraft(
|
||||
draft: AiWorkbenchSelectionDraft,
|
||||
): void {
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify(draft),
|
||||
);
|
||||
}
|
||||
|
||||
export function consumeAiWorkbenchSelectionDraft(
|
||||
type: number,
|
||||
): AiWorkbenchSelectionDraft | null {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const draft = JSON.parse(raw) as AiWorkbenchSelectionDraft;
|
||||
if (!draft.verifiedAt || Date.now() - draft.verifiedAt * 1000 > MAX_AGE_MS) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
if (draft.type !== type) {
|
||||
return null;
|
||||
}
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return draft;
|
||||
} catch {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"command": "npx",
|
||||
"args": ["-y", "mongodb-mcp-server@1.2.0", "--readOnly"],
|
||||
"env": {
|
||||
"MDB_MCP_CONNECTION_STRING": "mongodb://admin:key123456@192.168.2.16:27017/admin"
|
||||
"MDB_MCP_CONNECTION_STRING": "mongodb://admin:<REDACTED>@192.168.2.16:27017/admin"
|
||||
}
|
||||
},
|
||||
"Moncter": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"command": "npx",
|
||||
"args": ["-y", "mongodb-mcp-server@1.2.0", "--readOnly"],
|
||||
"env": {
|
||||
"MDB_MCP_CONNECTION_STRING": "mongodb://admin:key123456@192.168.2.16:27017/admin"
|
||||
"MDB_MCP_CONNECTION_STRING": "mongodb://admin:<REDACTED>@192.168.2.16:27017/admin"
|
||||
}
|
||||
},
|
||||
"Moncter": {
|
||||
|
||||
@@ -49,6 +49,6 @@ KR_MONGODB_HOST = 192.168.2.8
|
||||
KR_MONGODB_PORT = 27017
|
||||
KR_MONGODB_DATABASE = admin
|
||||
KR_MONGODB_USER = admin
|
||||
KR_MONGODB_PASSWORD = key123456
|
||||
KR_MONGODB_PASSWORD = <REDACTED>
|
||||
KR_MONGODB_AUTH_SOURCE=admin
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ mock = 0
|
||||
return_url_base = http://localhost:3100
|
||||
|
||||
[karuoAi]
|
||||
; 卡若AI 网关 — Docker 内走宿主机本地网关;生产改 https://kr-ai.quwanzhi.com/v1
|
||||
apiUrl = http://host.docker.internal:18080/v1
|
||||
apiKey = karuo-company-20260225
|
||||
; 卡若AI 网关 — 公网 HTTPS(卡若AI 标准 · 真源 kr-ai.quwanzhi.com);本地调试可改 host.docker.internal:18080
|
||||
apiUrl = https://kr-ai.quwanzhi.com/v1
|
||||
apiKey =
|
||||
model = karuo-ai
|
||||
|
||||
[douyin]
|
||||
@@ -94,3 +94,11 @@ apple_private_key_path =
|
||||
; 登录 UX:记住手机号 / DEV 默认预填(非 SIM 自动读取)
|
||||
login_remember_phone = true
|
||||
default_dev_phone = 13779954946
|
||||
|
||||
; 地图 · 触客宝选点(高德/腾讯二选一 · 真源卡若AI 账号索引)
|
||||
; provider: amap=高德(阿里) | tencent=腾讯位置服务
|
||||
; 高德控制台 https://console.amap.com 白名单:localhost、127.0.0.1、kr-kf.quwanzhi.com
|
||||
[TENCENT_MAP]
|
||||
PROVIDER = amap
|
||||
KEY = ad21b664a57082ec13d7f84ebaa9d106
|
||||
SECURITY_CODE = 3bf96d1e2f643c64a71539d26f4c35ab
|
||||
|
||||
@@ -56,9 +56,9 @@ mock = 0
|
||||
return_url_base = http://localhost:3100
|
||||
|
||||
[karuoAi]
|
||||
; 卡若AI 网关 — Docker 内走宿主机本地网关;生产改 https://kr-ai.quwanzhi.com/v1
|
||||
apiUrl = http://host.docker.internal:18080/v1
|
||||
apiKey = karuo-company-20260225
|
||||
; 卡若AI 网关 — 公网 HTTPS(卡若AI 标准 · 真源 kr-ai.quwanzhi.com);本地调试可改 host.docker.internal:18080
|
||||
apiUrl = https://kr-ai.quwanzhi.com/v1
|
||||
apiKey =
|
||||
model = karuo-ai
|
||||
|
||||
[douyin]
|
||||
@@ -94,3 +94,11 @@ apple_private_key_path =
|
||||
; 登录 UX:记住手机号 / DEV 默认预填(非 SIM 自动读取)
|
||||
login_remember_phone = true
|
||||
default_dev_phone = 13779954946
|
||||
|
||||
; 地图 · 触客宝选点(高德/腾讯二选一 · 真源卡若AI 账号索引)
|
||||
; provider: amap=高德(阿里) | tencent=腾讯位置服务
|
||||
; 高德控制台 https://console.amap.com 白名单:localhost、127.0.0.1、kr-kf.quwanzhi.com
|
||||
[TENCENT_MAP]
|
||||
PROVIDER = amap
|
||||
KEY = ad21b664a57082ec13d7f84ebaa9d106
|
||||
SECURITY_CODE = 3bf96d1e2f643c64a71539d26f4c35ab
|
||||
|
||||
@@ -42,9 +42,11 @@ COPY . .
|
||||
RUN composer config --no-plugins allow-plugins.easywechat-composer/easywechat-composer true \
|
||||
&& composer config --no-plugins allow-plugins.topthink/think-installer true \
|
||||
&& composer install --no-dev --optimize-autoloader --no-interaction --no-security-blocking \
|
||||
&& mkdir -p runtime/cache runtime/log runtime/temp \
|
||||
&& mkdir -p runtime/cache runtime/log runtime/temp runtime/nginx_client_body \
|
||||
/var/lib/nginx/tmp/client_body \
|
||||
/var/log/supervisor /var/run \
|
||||
&& chmod -R 777 runtime \
|
||||
&& chown -R www-data:www-data /var/lib/nginx \
|
||||
&& chown -R www-data:www-data /var/www/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -5,19 +5,8 @@ use think\facade\Route;
|
||||
// 定义RESTful风格的API路由
|
||||
Route::group('v1/ai', function () {
|
||||
|
||||
//openai、chatGPT
|
||||
Route::group('openai', function () {
|
||||
Route::post('text', 'app\ai\controller\OpenAI@text');
|
||||
});
|
||||
|
||||
|
||||
//豆包ai
|
||||
Route::group('doubao', 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::get('workphone/config', 'app\ai\controller\AiGatewayController@workphoneConfig'); // 工作手机参数(只读脱敏配置)
|
||||
Route::post('chat/completions', 'app\ai\controller\AiGatewayController@chat'); // 统一补全
|
||||
})->middleware(['jwt']);
|
||||
})->middleware(['jwt']);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\ai\controller;
|
||||
|
||||
use app\common\service\AiGatewayService;
|
||||
use app\common\service\workphone\WorkphoneProviderService;
|
||||
use app\common\util\JwtUtil;
|
||||
use library\ResponseHelper;
|
||||
use think\Controller;
|
||||
@@ -21,6 +22,19 @@ class AiGatewayController extends Controller
|
||||
return ResponseHelper::success(AiGatewayService::config());
|
||||
}
|
||||
|
||||
/** AI 数字员工读取工作手机脱敏参数与数据方向策略。 */
|
||||
public function workphoneConfig()
|
||||
{
|
||||
$companyId = (int)$this->request->param('companyId', 0);
|
||||
if ($companyId <= 0) {
|
||||
$companyId = $this->companyIdFromToken();
|
||||
}
|
||||
if ($companyId <= 0) {
|
||||
return ResponseHelper::error('缺少 companyId', 400);
|
||||
}
|
||||
return ResponseHelper::success((new WorkphoneProviderService())->getConfig($companyId));
|
||||
}
|
||||
|
||||
/** 统一补全 */
|
||||
public function chat()
|
||||
{
|
||||
|
||||
@@ -167,8 +167,15 @@ class CozeAI extends Controller
|
||||
];
|
||||
$result = requestCurl($this->apiUrl . '/v1/bot/publish', $params, 'POST', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
if (!is_array($result)) {
|
||||
return json_encode(['code' => 503, 'msg' => 'Coze API 无响应', 'data' => []]);
|
||||
}
|
||||
if (($result['code'] ?? -1) != 0) {
|
||||
return json_encode([
|
||||
'code' => $result['code'] ?? 503,
|
||||
'msg' => $result['msg'] ?? '发布失败',
|
||||
'data' => []
|
||||
]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '发布成功', 'data' => []]);
|
||||
}
|
||||
@@ -412,4 +419,4 @@ class CozeAI extends Controller
|
||||
return json_encode(['code' => 200, 'msg' => '取消成功', 'data' => []]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,273 +2,22 @@
|
||||
|
||||
namespace app\ai\controller;
|
||||
|
||||
use app\common\util\JwtUtil;
|
||||
use think\facade\Env;
|
||||
use think\Controller;
|
||||
|
||||
/**
|
||||
* 遗留兼容控制器。
|
||||
*
|
||||
* 豆包直连路由已停用;文本能力统一使用 AI Gateway,图片能力待多模态 Gateway 接入后开放。
|
||||
*/
|
||||
class DouBaoAI extends Controller
|
||||
{
|
||||
protected $apiUrl;
|
||||
protected $apiKey;
|
||||
protected $headers;
|
||||
|
||||
public function __construct()
|
||||
public function text()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->apiUrl = Env::get('doubaoAi.api_url');
|
||||
$this->apiKey = Env::get('doubaoAi.api_key');
|
||||
|
||||
// 设置请求头
|
||||
$this->headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $this->apiKey
|
||||
];
|
||||
|
||||
if (empty($this->apiKey) || empty($this->apiUrl)) {
|
||||
return json_encode(['code' => 500, 'msg' => '参数缺失']);
|
||||
}
|
||||
return json(['code' => 410, 'msg' => '旧豆包直连接口已停用,请使用统一 AI Gateway']);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function text($params = [])
|
||||
public function image()
|
||||
{
|
||||
|
||||
if (empty($params)){
|
||||
$content = $this->request->param('content', '');
|
||||
$model = $this->request->param('model', 'doubao-seed-1-8-251215');
|
||||
if(empty($content)){
|
||||
return json_encode(['code' => 500, 'msg' => '提示词缺失']);
|
||||
}
|
||||
$params = [
|
||||
'model' => $model,
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => '你现在是存客宝的AI助理,你精通中国大陆的法律'],
|
||||
['role' => 'user', 'content' => $content],
|
||||
],
|
||||
];
|
||||
}
|
||||
$result = requestCurl($this->apiUrl.'/api/v3/chat/completions', $params, 'POST', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if(isset($result['error'])){
|
||||
$error = $result['error'];
|
||||
return json_encode(['code' => 500, 'msg' => $error['message']]);
|
||||
}else{
|
||||
$content = $result['choices'][0]['message']['content'];
|
||||
$token = intval($result['usage']['total_tokens']) * 20;
|
||||
|
||||
exit_data($content);
|
||||
return json_encode(['code' => 200, 'msg' => '成功','data' => ['token' => $token,'content' => $content]]);
|
||||
}
|
||||
|
||||
return json(['code' => 410, 'msg' => '图片生成待统一多模态 AI Gateway 接入']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片生成功能(基于火山方舟 Seedream 4.0-4.5 API)
|
||||
* 参考文档:https://www.volcengine.com/docs/82379/1541523?lang=zh
|
||||
*
|
||||
* @param array $params 请求参数,如果为空则从请求中获取
|
||||
* @return string JSON格式的响应
|
||||
*/
|
||||
public function image($params = [])
|
||||
{
|
||||
try {
|
||||
// 如果参数为空,从请求中获取
|
||||
if (empty($params)){
|
||||
$content = $this->request->param('content', '');
|
||||
$model = $this->request->param('model', 'doubao-seedream-4-5-251128');
|
||||
$size = $this->request->param('size', '16:9'); // 支持档位(1K/2K/4K)、比例(16:9/9:16等)、像素(1280x720等)
|
||||
$responseFormat = $this->request->param('response_format', 'url'); // url 或 b64_json
|
||||
$sequentialImageGeneration = $this->request->param('sequential_image_generation', 'disabled'); // enabled 或 disabled
|
||||
$watermark = $this->request->param('watermark', true); // true 或 false
|
||||
|
||||
// 参数验证
|
||||
if(empty($content)){
|
||||
return json_encode(['code' => 500, 'msg' => '提示词(prompt)不能为空']);
|
||||
}
|
||||
|
||||
// 验证和规范化尺寸参数
|
||||
$size = $this->validateAndNormalizeSize($size);
|
||||
if(!in_array($responseFormat, ['url', 'b64_json'])){
|
||||
$responseFormat = 'url';
|
||||
}
|
||||
|
||||
if(!in_array($sequentialImageGeneration, ['enabled', 'disabled'])){
|
||||
$sequentialImageGeneration = 'disabled';
|
||||
}
|
||||
|
||||
// 构建请求参数(根据火山方舟文档)
|
||||
$params = [
|
||||
'model' => $model,
|
||||
'prompt' => $content,
|
||||
'sequential_image_generation' => $sequentialImageGeneration,
|
||||
'response_format' => $responseFormat,
|
||||
'size' => $size,
|
||||
'stream' => false,
|
||||
'watermark' => true
|
||||
];
|
||||
}
|
||||
|
||||
// 确保API URL正确(图片生成API的endpoint)
|
||||
$imageApiUrl = $this->apiUrl. '/api/v3/images/generations';
|
||||
// 发送请求
|
||||
$result = requestCurl($imageApiUrl, $params, 'POST', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
// 错误处理
|
||||
if(isset($result['error'])){
|
||||
$error = $result['error'];
|
||||
$errorMsg = isset($error['message']) ? $error['message'] : '图片生成失败';
|
||||
$errorCode = isset($error['code']) ? $error['code'] : 'unknown';
|
||||
|
||||
\think\facade\Log::error('火山方舟图片生成失败', [
|
||||
'error' => $error,
|
||||
'params' => $params
|
||||
]);
|
||||
|
||||
return json_encode([
|
||||
'code' => 500,
|
||||
'msg' => $errorMsg,
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
|
||||
// 成功响应处理(根据火山方舟文档的响应格式)
|
||||
if(isset($result['data']) && is_array($result['data']) && !empty($result['data'])){
|
||||
$imageData = $result['data'][0];
|
||||
|
||||
// 根据 response_format 获取图片数据
|
||||
$imageUrl = null;
|
||||
$imageB64 = null;
|
||||
|
||||
if(isset($imageData['url'])){
|
||||
$imageUrl = $imageData['url'];
|
||||
}
|
||||
|
||||
if(isset($imageData['b64_json'])){
|
||||
$imageB64 = $imageData['b64_json'];
|
||||
}
|
||||
|
||||
// 计算token(如果有usage信息)
|
||||
$token = 0;
|
||||
if(isset($result['usage']['total_tokens'])){
|
||||
$token = intval($result['usage']['total_tokens']) * 20;
|
||||
}
|
||||
|
||||
// 构建返回数据
|
||||
$responseData = [
|
||||
'token' => $token,
|
||||
'image_url' => $imageUrl,
|
||||
'image_b64' => $imageB64,
|
||||
'model' => $params['model'] ?? '',
|
||||
'size' => $params['size'] ?? '2K',
|
||||
'created' => isset($result['created']) ? $result['created'] : time()
|
||||
];
|
||||
|
||||
// 根据请求的response_format返回对应的数据
|
||||
if($params['response_format'] == 'url' && $imageUrl){
|
||||
$responseData['content'] = $imageUrl;
|
||||
} elseif($params['response_format'] == 'b64_json' && $imageB64){
|
||||
$responseData['content'] = $imageB64;
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'code' => 200,
|
||||
'msg' => '图片生成成功',
|
||||
'data' => $responseData
|
||||
]);
|
||||
} else {
|
||||
// 响应格式不符合预期
|
||||
\think\facade\Log::warning('火山方舟图片生成响应格式异常', [
|
||||
'result' => $result,
|
||||
'params' => $params
|
||||
]);
|
||||
|
||||
return json_encode([
|
||||
'code' => 500,
|
||||
'msg' => '图片生成响应格式异常',
|
||||
'raw_response' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\think\facade\Log::error('火山方舟图片生成异常', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return json_encode([
|
||||
'code' => 500,
|
||||
'msg' => '图片生成异常:' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证和规范化尺寸参数
|
||||
* 支持三种格式:
|
||||
* 1. 档位形式:1K, 2K, 4K(不区分大小写)
|
||||
* 2. 比例形式:16:9, 9:16, 1:1, 4:3, 3:4 等
|
||||
* 3. 像素形式:1280x720, 2048x2048 等(宽度1280-4096,高度720-4096,宽高比0.0625-16)
|
||||
*
|
||||
* @param string $size 尺寸参数
|
||||
* @return string 规范化后的尺寸值
|
||||
*/
|
||||
private function validateAndNormalizeSize($size)
|
||||
{
|
||||
if (empty($size)) {
|
||||
return '2K';
|
||||
}
|
||||
|
||||
$size = trim($size);
|
||||
|
||||
// 1. 检查是否为档位形式(1K, 2K, 4K)
|
||||
$sizeUpper = strtoupper($size);
|
||||
if (in_array($sizeUpper, ['1K', '2K', '4K'])) {
|
||||
return $sizeUpper;
|
||||
}
|
||||
|
||||
// 2. 检查是否为比例形式(如 16:9, 9:16, 1:1)
|
||||
if (preg_match('/^(\d+):(\d+)$/', $size, $matches)) {
|
||||
$width = intval($matches[1]);
|
||||
$height = intval($matches[2]);
|
||||
|
||||
if ($width > 0 && $height > 0) {
|
||||
$ratio = $width / $height;
|
||||
// 验证宽高比范围:0.0625 ~ 16
|
||||
if ($ratio >= 0.0625 && $ratio <= 16) {
|
||||
return $size; // 返回比例形式,如 "16:9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查是否为像素形式(如 1280x720, 2048x2048)
|
||||
if (preg_match('/^(\d+)x(\d+)$/i', $size, $matches)) {
|
||||
$width = intval($matches[1]);
|
||||
$height = intval($matches[2]);
|
||||
|
||||
// 验证宽度范围:1280 ~ 4096
|
||||
if ($width < 1280 || $width > 4096) {
|
||||
return '2K'; // 默认返回 2K
|
||||
}
|
||||
|
||||
// 验证高度范围:720 ~ 4096
|
||||
if ($height < 720 || $height > 4096) {
|
||||
return '2K'; // 默认返回 2K
|
||||
}
|
||||
|
||||
// 验证宽高比范围:0.0625 ~ 16
|
||||
$ratio = $width / $height;
|
||||
if ($ratio < 0.0625 || $ratio > 16) {
|
||||
return '2K'; // 默认返回 2K
|
||||
}
|
||||
|
||||
return $size; // 返回像素形式,如 "1280x720"
|
||||
}
|
||||
|
||||
// 如果都不匹配,返回默认值
|
||||
return '2K';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,144 +2,22 @@
|
||||
|
||||
namespace app\ai\controller;
|
||||
|
||||
use think\facade\Env;
|
||||
use think\Controller;
|
||||
|
||||
|
||||
class OpenAI extends Controller
|
||||
/**
|
||||
* 遗留兼容控制器。
|
||||
*
|
||||
* 直连 OpenAI 已停用,文本生成统一使用 /v1/ai/chat/completions。
|
||||
*/
|
||||
class OpenAI extends Controller
|
||||
{
|
||||
protected $apiUrl;
|
||||
protected $apiKey;
|
||||
protected $headers;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->apiUrl = Env::get('openAi.apiUrl');
|
||||
$this->apiKey = Env::get('openAi.apiKey');
|
||||
|
||||
// 设置请求头
|
||||
$this->headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer '.$this->apiKey
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function text()
|
||||
{
|
||||
|
||||
$params = [
|
||||
'model' => 'gpt-3.5-turbo-0125',
|
||||
'input' => 'DHA 从孕期到出生到老年都需要,助力大脑发育🧠/减缓脑压力有助记忆/给大脑动力#贝蒂喜藻油DHA 双标认证每粒 150毫克,高含量、高性价比从小吃到老,长期吃更健康 重写这条朋友圈 要求: 1、原本的字数和意思不要修改超过10% 2、出现品牌名或个人名字就去除'
|
||||
];
|
||||
$result = $this->httpRequest( $this->apiUrl, 'POST', $params,$this->headers);
|
||||
exit_data($result);
|
||||
return json(['code' => 410, 'msg' => '旧 OpenAI 直连接口已停用,请使用统一 AI Gateway']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例:调用OpenAI API生成睡前故事
|
||||
* 对应curl命令:
|
||||
* curl "https://api.ai.com/v1/responses" \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "Authorization: Bearer $OPENAI_API_KEY" \
|
||||
* -d '{
|
||||
* "model": "gpt-5",
|
||||
* "input": "Write a one-sentence bedtime story about a unicorn."
|
||||
* }'
|
||||
*/
|
||||
public function bedtimeStory()
|
||||
{
|
||||
|
||||
// API请求参数
|
||||
$params = [
|
||||
'model' => 'gpt-5',
|
||||
'input' => 'Write a one-sentence bedtime story about a unicorn.'
|
||||
];
|
||||
|
||||
// 发送请求到OpenAI API
|
||||
$url = 'https://api.openai.com/v1/responses';
|
||||
$result = $this->httpRequest($url, 'POST', $params, $this->headers);
|
||||
|
||||
// 返回结果
|
||||
exit_data($result);
|
||||
return $this->text();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* CURL请求 - 专门用于JSON API请求
|
||||
*
|
||||
* @param $url 请求url地址
|
||||
* @param $method 请求方法 get post
|
||||
* @param null $postfields post数据数组
|
||||
* @param array $headers 请求header信息
|
||||
* @param int $timeout 超时时间
|
||||
* @param bool|false $debug 调试开启 默认false
|
||||
* @return mixed
|
||||
*/
|
||||
protected function httpRequest($url, $method = "GET", $postfields = null, $headers = array(), $timeout = 30, $debug = false)
|
||||
{
|
||||
$method = strtoupper($method);
|
||||
$ci = curl_init();
|
||||
|
||||
/* Curl settings */
|
||||
curl_setopt($ci, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 6.2; WOW64; rv:34.0) Gecko/20100101 Firefox/34.0");
|
||||
curl_setopt($ci, CURLOPT_CONNECTTIMEOUT, 60); /* 在发起连接前等待的时间,如果设置为0,则无限等待 */
|
||||
curl_setopt($ci, CURLOPT_TIMEOUT, $timeout); /* 设置cURL允许执行的最长秒数 */
|
||||
curl_setopt($ci, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
switch ($method) {
|
||||
case "POST":
|
||||
curl_setopt($ci, CURLOPT_POST, true);
|
||||
if (!empty($postfields)) {
|
||||
// 对于JSON API,直接将数组转换为JSON字符串
|
||||
if (is_array($postfields)) {
|
||||
$tmpdatastr = json_encode($postfields);
|
||||
} else {
|
||||
$tmpdatastr = $postfields;
|
||||
}
|
||||
curl_setopt($ci, CURLOPT_POSTFIELDS, $tmpdatastr);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
curl_setopt($ci, CURLOPT_CUSTOMREQUEST, $method); /* //设置请求方式 */
|
||||
break;
|
||||
}
|
||||
|
||||
$ssl = preg_match('/^https:\/\//i', $url) ? TRUE : FALSE;
|
||||
curl_setopt($ci, CURLOPT_URL, $url);
|
||||
if ($ssl) {
|
||||
curl_setopt($ci, CURLOPT_SSL_VERIFYPEER, FALSE); // https请求 不验证证书和hosts
|
||||
curl_setopt($ci, CURLOPT_SSL_VERIFYHOST, FALSE); // 不从证书中检查SSL加密算法是否存在
|
||||
}
|
||||
|
||||
if (ini_get('open_basedir') == '' && ini_get('safe_mode' == 'Off')) {
|
||||
curl_setopt($ci, CURLOPT_FOLLOWLOCATION, 1);
|
||||
}
|
||||
curl_setopt($ci, CURLOPT_MAXREDIRS, 2);/*指定最多的HTTP重定向的数量,这个选项是和CURLOPT_FOLLOWLOCATION一起使用的*/
|
||||
curl_setopt($ci, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ci, CURLINFO_HEADER_OUT, true);
|
||||
|
||||
$response = curl_exec($ci);
|
||||
$requestinfo = curl_getinfo($ci);
|
||||
$http_code = curl_getinfo($ci, CURLINFO_HTTP_CODE);
|
||||
|
||||
if ($debug) {
|
||||
echo "=====post data======\r\n";
|
||||
var_dump($postfields);
|
||||
echo "=====info===== \r\n";
|
||||
print_r($requestinfo);
|
||||
echo "=====response=====\r\n";
|
||||
print_r($response);
|
||||
}
|
||||
|
||||
curl_close($ci);
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,10 @@ class BaseController extends Controller
|
||||
$this->baseUrl = Env::get('api.wechat_url');
|
||||
$this->authorization = AuthService::getSystemAuthorization();
|
||||
}
|
||||
|
||||
/** 服务端内部调用(转移执行等)注入 S2 token */
|
||||
public function setAuthorization(string $token): void
|
||||
{
|
||||
$this->authorization = $token;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ Route::group('v1/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\CustomerServiceController@getList'); // 获取好友列表
|
||||
});
|
||||
|
||||
// 工作手机统一参数(只读脱敏配置,真源由超管项目配置保存)
|
||||
Route::get('workphone/config', 'app\chukebao\controller\WorkphoneConfigController@get');
|
||||
|
||||
// 单聊/群聊转接(BFF 调 S2,避免前端 request2 鉴权失败)
|
||||
Route::post('friend/allot', 'app\chukebao\controller\FriendAllotController@allot');
|
||||
|
||||
//客服评估总览(§二 头像 Popover)
|
||||
Route::group('stats/', function () {
|
||||
Route::get('overview', 'app\chukebao\controller\CustomerServiceController@statsOverview');
|
||||
@@ -42,9 +48,6 @@ Route::group('v1/', function () {
|
||||
|
||||
//微信号用户管理 / 账号估值 / 转移申请(代理 cunkebao 真源 · 需求九 §9.3/§9.5)
|
||||
Route::group('wechat-account/', function () {
|
||||
// 转移申请(静态段在前,避免被 :id 捕获)
|
||||
Route::post('transfer/apply', 'app\chukebao\controller\WechatAccountController@transferApply'); // 客服发起申请
|
||||
Route::get('transfer/status', 'app\chukebao\controller\WechatAccountController@transferStatus'); // 申请状态
|
||||
// :id = 微信号 wechatId
|
||||
Route::get(':id/valuation', 'app\chukebao\controller\WechatAccountController@valuation'); // 账号估值(只读)
|
||||
Route::get(':id/manage', 'app\chukebao\controller\WechatAccountController@manage'); // 管理页聚合
|
||||
@@ -56,7 +59,9 @@ Route::group('v1/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\MessageController@getList'); // 获取好友列表
|
||||
Route::get('readMessage', 'app\chukebao\controller\MessageController@readMessage'); // 读取消息
|
||||
Route::get('details', 'app\chukebao\controller\MessageController@details'); // 消息详情
|
||||
Route::post('send', 'app\chukebao\controller\MessageController@send'); // 接口化发送:触客宝 -> 工作手机 BFF/SDK
|
||||
Route::get('getMessageStatus', 'app\chukebao\controller\MessageController@getMessageStatus'); // 获取单条消息发送状态
|
||||
Route::post('ensure-session', 'app\chukebao\controller\MessageController@ensureSession'); // CKB-141 进入聊天:权限校验+会话补建+定位上下文
|
||||
});
|
||||
|
||||
//微信分组
|
||||
@@ -218,9 +223,11 @@ Route::group('v1/', function () {
|
||||
// 模块 A · AI 获客(webhook 在非 jwt 组)
|
||||
Route::group('ai-lead/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\AiLeadController@getList');
|
||||
Route::get('tab-counts', 'app\chukebao\controller\AiLeadController@tabCounts');
|
||||
Route::get('unread-count', 'app\chukebao\controller\AiLeadController@unreadCount');
|
||||
Route::get('detail', 'app\chukebao\controller\AiLeadController@getDetail');
|
||||
Route::post('mark-read', 'app\chukebao\controller\AiLeadController@markRead');
|
||||
Route::post('mark-all-read', 'app\chukebao\controller\AiLeadController@markAllRead');
|
||||
Route::post('mark-added', 'app\chukebao\controller\AiLeadController@markAdded');
|
||||
Route::post('add-tag', 'app\chukebao\controller\AiLeadController@addTag');
|
||||
Route::post('transfer', 'app\chukebao\controller\AiLeadController@transfer');
|
||||
@@ -250,6 +257,8 @@ Route::group('v1/', function () {
|
||||
Route::post('quick-reply/export-kb', 'app\chukebao\controller\QuickReplyExtController@exportKb');
|
||||
Route::post('quick-reply/ai-generate', 'app\chukebao\controller\QuickReplyExtController@aiGenerate');
|
||||
Route::post('quick-reply/promote-to-company', 'app\chukebao\controller\QuickReplyExtController@promoteToCompany'); // REQ-TKB-93 个人→公司
|
||||
Route::post('quick-reply/copy-group', 'app\chukebao\controller\QuickReplyExtController@copyGroup'); // REQ-TKB-120 分组互拷
|
||||
Route::post('quick-reply/ai-organize-group', 'app\chukebao\controller\QuickReplyExtController@aiOrganizeGroup'); // REQ-TKB-120 AI 五步整理
|
||||
|
||||
// 模块 D · 知识库 + 算力消耗
|
||||
Route::get('ai/knowledge-bases', 'app\chukebao\controller\KnowledgeBaseController@knowledgeBases');
|
||||
@@ -270,11 +279,11 @@ 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::post('transfer/accept', 'app\chukebao\controller\TransferController@accept'); // CKB-132.2 接收方接受并执行(代理存客宝真源)
|
||||
Route::post('transfer/reject', 'app\chukebao\controller\TransferController@reject'); // CKB-132.1 接收方拒绝
|
||||
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');
|
||||
Route::get('wechat-account/<id>/friends', 'app\chukebao\controller\WechatAccountController@friends');
|
||||
Route::get('device/transfer/preview', 'app\chukebao\controller\TransferController@devicePreview');
|
||||
|
||||
// 模块 I · 设置聚合
|
||||
Route::group('settings/', function () {
|
||||
@@ -282,6 +291,7 @@ Route::group('v1/', function () {
|
||||
Route::post('avatar', 'app\chukebao\controller\SettingsKefuController@saveAvatar');
|
||||
Route::get('ai', 'app\chukebao\controller\SettingsKefuController@getAi');
|
||||
Route::post('ai', 'app\chukebao\controller\SettingsKefuController@saveAi');
|
||||
Route::get('map-config', 'app\chukebao\controller\SettingsKefuController@mapConfig');
|
||||
});
|
||||
|
||||
// 模块 J · 客户协同 / 同步 Outbox
|
||||
@@ -305,4 +315,4 @@ Route::group('v1/kefu', function () {
|
||||
|
||||
|
||||
|
||||
return [];
|
||||
return [];
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\common\service\AiGatewayService;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
use think\facade\Env;
|
||||
|
||||
/**
|
||||
* 模块 D · AI 协助对话框 + 联想(需求六 §6.7 · 需求七 §7.2)
|
||||
@@ -30,25 +30,23 @@ class AiAssistController extends BaseController
|
||||
return ResponseHelper::error('请输入意图或选择快捷语', 400);
|
||||
}
|
||||
|
||||
$kbTypeId = $this->resolveKnowledgeBaseTypeId((int)$companyId, (int)$userId);
|
||||
if ($kbTypeId < 0) {
|
||||
return ResponseHelper::error('该知识库未对当前触客宝项目开放', 403);
|
||||
}
|
||||
|
||||
// 从快捷语生成变体
|
||||
$baseText = '';
|
||||
if ($quickReplyId) {
|
||||
$baseText = (string)Db::name('kf_reply')->where('id', $quickReplyId)->value('content');
|
||||
}
|
||||
|
||||
// 优先豆包;未配置时回退卡若AI 本地网关(与小宝 AI 同源 · OpenAI 兼容)
|
||||
$apiUrl = (string)Env::get('doubaoAi.api_url', '');
|
||||
$apiKey = (string)Env::get('doubaoAi.api_key', '');
|
||||
$model = 'doubao-seed-1-8-251215';
|
||||
$chatPath = '/api/v3/chat/completions';
|
||||
if ($apiUrl === '' || $apiKey === '') {
|
||||
$apiUrl = (string)Env::get('karuoAi.apiUrl', '');
|
||||
$apiKey = (string)Env::get('karuoAi.apiKey', '');
|
||||
$model = (string)Env::get('karuoAi.model', 'karuo-ai');
|
||||
$chatPath = '/chat/completions';
|
||||
}
|
||||
if ($apiUrl === '' || $apiKey === '') {
|
||||
return ResponseHelper::error('AI 服务未配置(doubaoAi/karuoAi),请联系管理员', 500);
|
||||
$reply = Db::name('kf_reply')
|
||||
->where('id', $quickReplyId)
|
||||
->where('isDel', 0)
|
||||
->whereIn('groupId', $this->accessibleReplyGroupIds((int)$companyId, (int)$userId))
|
||||
->find();
|
||||
if (!$reply) {
|
||||
return ResponseHelper::error('快捷语不存在或无权限访问', 404);
|
||||
}
|
||||
$baseText = (string)($reply['content'] ?? '');
|
||||
}
|
||||
|
||||
// 组装上下文
|
||||
@@ -72,7 +70,8 @@ class AiAssistController extends BaseController
|
||||
'companyId' => (int)$companyId,
|
||||
'contentLibraryId' => (int)$this->request->param('contentLibraryId', 0),
|
||||
'knowledgeBaseId' => (int)$this->request->param('knowledgeBaseId', 0),
|
||||
'knowledgeBaseTypeId' => (int)$this->request->param('knowledgeBaseTypeId', 0),
|
||||
// 触客宝知识库下拉返回的是 ai_knowledge_base_type.id(kbId)。
|
||||
'knowledgeBaseTypeId' => $kbTypeId,
|
||||
'salesStage' => (string)$this->request->param('salesStage', ''),
|
||||
]);
|
||||
if (trim($merged) !== '') {
|
||||
@@ -82,55 +81,121 @@ class AiAssistController extends BaseController
|
||||
. "【客服意图】" . ($prompt ?: '请基于参考话术润色')
|
||||
. ($baseText ? "\n【参考话术】" . $baseText : "");
|
||||
|
||||
$params = [
|
||||
'model' => $model,
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $sys],
|
||||
['role' => 'user', 'content' => $userMsg],
|
||||
],
|
||||
];
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
];
|
||||
|
||||
try {
|
||||
$endpoint = rtrim($apiUrl, '/');
|
||||
if (stripos($endpoint, 'chat/completions') === false) {
|
||||
$endpoint .= $chatPath;
|
||||
}
|
||||
$raw = requestCurl($endpoint, $params, 'POST', $headers, 'json');
|
||||
$res = json_decode($raw, true);
|
||||
if (isset($res['error'])) {
|
||||
return ResponseHelper::error('AI 生成失败:' . ($res['error']['message'] ?? '未知'), 500);
|
||||
}
|
||||
$content = $res['choices'][0]['message']['content'] ?? '';
|
||||
|
||||
// 拆分候选
|
||||
$candidates = array_values(array_filter(array_map(function ($s) {
|
||||
return trim($s);
|
||||
}, preg_split('/\n=+\n|\n-{3,}\n|={3,}/', $content))));
|
||||
if (empty($candidates) && $content !== '') {
|
||||
$candidates = [trim($content)];
|
||||
}
|
||||
|
||||
// 扣算力:统一走 TokensService 规则(AI 改写 action=12),按次扣,best-effort 不阻断
|
||||
$cost = 0;
|
||||
try {
|
||||
$svc = new \app\cunkebao\service\TokensService((int)$companyId, (int)$userId);
|
||||
$res2 = $svc->consume(\app\cunkebao\service\TokensService::ACTION_AI_REWRITE, 'AI协助生成快捷回复', 1);
|
||||
$cost = (int)($res2['deducted'] ?? 0);
|
||||
} catch (\Exception $e) {
|
||||
// 余额不足等不阻断
|
||||
}
|
||||
|
||||
return ResponseHelper::success([
|
||||
'candidates' => $candidates,
|
||||
'cost' => $cost,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$result = AiGatewayService::complete(
|
||||
'ai_rewrite',
|
||||
[
|
||||
['role' => 'system', 'content' => $sys],
|
||||
['role' => 'user', 'content' => $userMsg],
|
||||
],
|
||||
(int)$companyId,
|
||||
(int)$userId,
|
||||
'',
|
||||
['maxTokens' => 4096, 'mode' => 'touchkebao_ai_assist']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return ResponseHelper::error('AI 调用异常:' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
if (empty($result['ok'])) {
|
||||
return ResponseHelper::error(
|
||||
(string)($result['error'] ?? 'AI 生成失败'),
|
||||
(int)($result['code'] ?? 500),
|
||||
['balance' => $result['balance'] ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$content = (string)($result['content'] ?? '');
|
||||
$candidates = array_values(array_filter(array_map(function ($s) {
|
||||
return trim($s);
|
||||
}, preg_split('/\n=+\n|\n-{3,}\n|={3,}/', $content))));
|
||||
if (empty($candidates) && $content !== '') {
|
||||
$candidates = [trim($content)];
|
||||
}
|
||||
|
||||
return ResponseHelper::success([
|
||||
'candidates' => $candidates,
|
||||
'cost' => (int)($result['deducted'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将触客宝 kbId 解析为已开放的知识库分类 ID。
|
||||
* 返回 -1 表示请求了未开放知识库;0 表示当前没有可用知识库。
|
||||
*/
|
||||
private function resolveKnowledgeBaseTypeId(int $companyId, int $userId): int
|
||||
{
|
||||
$requested = (int)$this->request->param(
|
||||
'kbId',
|
||||
$this->request->param('knowledgeBaseTypeId', 0)
|
||||
);
|
||||
|
||||
try {
|
||||
if ($requested > 0) {
|
||||
$allowed = Db::name('kb_touchkebao_config')
|
||||
->where('companyId', $companyId)
|
||||
->where('kefuId', 0)
|
||||
->where('enabled', 1)
|
||||
->where('kbId', $requested)
|
||||
->count();
|
||||
return $allowed ? $requested : -1;
|
||||
}
|
||||
|
||||
$current = (int)Db::name('kb_touchkebao_config')
|
||||
->where('companyId', $companyId)
|
||||
->where('kefuId', $userId)
|
||||
->value('currentKbId');
|
||||
if ($current > 0) {
|
||||
$allowed = Db::name('kb_touchkebao_config')
|
||||
->where('companyId', $companyId)
|
||||
->where('kefuId', 0)
|
||||
->where('enabled', 1)
|
||||
->where('kbId', $current)
|
||||
->count();
|
||||
if ($allowed) {
|
||||
return $current;
|
||||
}
|
||||
}
|
||||
|
||||
return (int)Db::name('kb_touchkebao_config')
|
||||
->where('companyId', $companyId)
|
||||
->where('kefuId', 0)
|
||||
->where('enabled', 1)
|
||||
->order('isDefault desc,id asc')
|
||||
->value('kbId');
|
||||
} catch (\Throwable $e) {
|
||||
return $requested > 0 ? -1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前客服可读取的公共、公司与个人快捷语分组。
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function accessibleReplyGroupIds(int $companyId, int $userId): array
|
||||
{
|
||||
$publicIds = Db::name('kf_reply_group')
|
||||
->where('isDel', 0)
|
||||
->where('replyType', 0)
|
||||
->column('id');
|
||||
$companyIds = Db::name('kf_reply_group')
|
||||
->where('isDel', 0)
|
||||
->where('companyId', $companyId)
|
||||
->where('replyType', 2)
|
||||
->column('id');
|
||||
$personalIds = Db::name('kf_reply_group')
|
||||
->where('isDel', 0)
|
||||
->where('companyId', $companyId)
|
||||
->where('userId', $userId)
|
||||
->where('replyType', 1)
|
||||
->column('id');
|
||||
|
||||
return array_values(array_unique(array_filter(array_map('intval', array_merge(
|
||||
$publicIds ?: [],
|
||||
$companyIds ?: [],
|
||||
$personalIds ?: []
|
||||
)))));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,13 +231,19 @@ class AiAssistController extends BaseController
|
||||
public function suggest()
|
||||
{
|
||||
$keyword = trim((string)$this->request->param('keyword', ''));
|
||||
$userId = $this->getUserInfo('id');
|
||||
$userId = (int)$this->getUserInfo('id');
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
if ($keyword === '') {
|
||||
return ResponseHelper::success(['list' => []]);
|
||||
}
|
||||
|
||||
$groupIds = $this->accessibleReplyGroupIds($companyId, $userId);
|
||||
if (empty($groupIds)) {
|
||||
return ResponseHelper::success(['list' => []]);
|
||||
}
|
||||
|
||||
$rows = Db::name('kf_reply')
|
||||
->where('userId', $userId)
|
||||
->whereIn('groupId', $groupIds)
|
||||
->where('isDel', 0)
|
||||
->where('title|content', 'like', '%' . $keyword . '%')
|
||||
->field('id,title,content,msgType')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ namespace app\chukebao\controller;
|
||||
|
||||
use app\chukebao\model\FriendSettings;
|
||||
use app\chukebao\model\Questions;
|
||||
use app\chukebao\model\TokensCompany;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
@@ -109,12 +110,12 @@ class AiSettingsController extends BaseController
|
||||
{
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$tokens = Db::name('users')
|
||||
->where('id', $userId)
|
||||
->where('companyId', $companyId)
|
||||
->value('tokens');
|
||||
$tokens = TokensCompany::where([
|
||||
'userId' => $userId,
|
||||
'companyId' => $companyId,
|
||||
])->value('tokens');
|
||||
|
||||
return ResponseHelper::success($tokens, '获取成功');
|
||||
return ResponseHelper::success((int)($tokens ?? 0), '获取成功');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\chukebao\service\CompanyWechatScopeService;
|
||||
use app\common\service\workphone\WorkphoneDisplayWechatService;
|
||||
use app\common\service\workphone\WorkphoneProviderService;
|
||||
use app\common\util\WorkPhoneSDK;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
@@ -81,6 +84,11 @@ class CustomerServiceController extends BaseController
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
$deviceList = $this->getProjectDeviceCustomerList((int)$companyId, (int)$userId);
|
||||
if (!empty($deviceList)) {
|
||||
return ResponseHelper::success($deviceList);
|
||||
}
|
||||
|
||||
// 侧栏真源:与存客宝 ck_device 绑定一致;有项目设备链时不混入客服个人 IM 号(避免 2 台设备却显示 3 个号)
|
||||
$accountIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds((int)$companyId, (int)$accountId);
|
||||
|
||||
@@ -88,6 +96,14 @@ class CustomerServiceController extends BaseController
|
||||
return ResponseHelper::success([]);
|
||||
}
|
||||
|
||||
// CKB-123 / TKB-128:列表前按 provider 真源节流刷新(方案A SDK心跳/方案B S2镜像)再归零陈旧 alive,
|
||||
// 与存客宝同一真源,禁止 S2 假在线覆盖 ck_device;触客宝设备态 123.5 对齐
|
||||
try {
|
||||
\app\common\service\workphone\WorkphoneDeviceAliveHelper::refreshThrottled((int)$companyId);
|
||||
\app\common\service\workphone\WorkphoneDeviceAliveHelper::zeroStaleAlive((int)$companyId);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
$list = Db::table('s2_wechat_account')->alias('wa')
|
||||
->join(['s2_device' => 'd'],'wa.currentDeviceId = d.id','LEFT')
|
||||
->whereIn('wa.id',$accountIds)
|
||||
@@ -154,8 +170,11 @@ class CustomerServiceController extends BaseController
|
||||
$v['deviceExtra']['battery'] = $ckExtra['battery'];
|
||||
}
|
||||
}
|
||||
if (!empty($v['s2DeviceAlive']) && (int)$v['s2DeviceAlive'] === 1) {
|
||||
$v['deviceAlive'] = 1;
|
||||
// 禁止 s2_device.alive 覆盖 ck_device(曾导致真机离线仍显示在线)
|
||||
$s2StaleOnline = ((int)($v['s2DeviceAlive'] ?? 0) === 1)
|
||||
&& ((int)($v['deviceAlive'] ?? 0) === 0);
|
||||
if ($s2StaleOnline) {
|
||||
$v['deviceAliveHint'] = 'hardware_offline';
|
||||
}
|
||||
|
||||
$v['isOnline'] = ((int)($v['deviceAlive'] ?? 0) === 1)
|
||||
@@ -186,4 +205,337 @@ class CustomerServiceController extends BaseController
|
||||
|
||||
return ResponseHelper::success($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触客宝侧栏按设备为核心输出:设备是主对象,微信只是当前挂载状态。
|
||||
*/
|
||||
private function getProjectDeviceCustomerList(int $companyId, int $userId): array
|
||||
{
|
||||
if ($companyId <= 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
\app\common\service\workphone\WorkphoneDeviceAliveHelper::refreshThrottled($companyId);
|
||||
\app\common\service\workphone\WorkphoneDeviceAliveHelper::zeroStaleAlive($companyId);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
try {
|
||||
$devices = Db::name('device')
|
||||
->where('companyId', $companyId)
|
||||
->where('deleteTime', 0)
|
||||
->field('id,imei,memo,alive,brand,model,extra,createTime,updateTime')
|
||||
->order('alive desc,id desc')
|
||||
->select();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
if (empty($devices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$providerSvc = new WorkphoneProviderService();
|
||||
$deviceControlProvider = $providerSvc->getProvider($companyId);
|
||||
$sdkMap = $providerSvc->getDeviceMap($companyId);
|
||||
$result = [];
|
||||
foreach ($devices as $device) {
|
||||
$deviceId = (int)($device['id'] ?? 0);
|
||||
$imei = (string)($device['imei'] ?? '');
|
||||
$sdkDeviceId = (string)($sdkMap[$deviceId] ?? '');
|
||||
$boundWechats = $this->loadDeviceBoundWechats($companyId, $deviceId, $imei);
|
||||
|
||||
// 142 四端同源:与存客宝列表/详情共用 WorkphoneDisplayWechatService 决策树
|
||||
$display = WorkphoneDisplayWechatService::resolveForDevice($companyId, $deviceId, $imei);
|
||||
$profileSource = (string)($display['workphoneProfileSource'] ?? '');
|
||||
if ($sdkDeviceId === '' && !empty($display['workphoneMapHint'])) {
|
||||
$sdkDeviceId = (string)($display['workphoneDeviceId'] ?? '');
|
||||
} else {
|
||||
$sdkDeviceId = (string)($display['workphoneDeviceId'] ?? $sdkDeviceId);
|
||||
}
|
||||
|
||||
$currentHookWechat = null;
|
||||
if ($profileSource === 'websocket/hook' && !empty($display['wechatId']) && !empty($boundWechats)) {
|
||||
$currentHookWechat = [
|
||||
'wechatId' => (string)$display['wechatId'],
|
||||
'nickname' => (string)($display['nickname'] ?? ''),
|
||||
'totalFriend' => isset($display['totalFriend']) ? (int)$display['totalFriend'] : 0,
|
||||
's2AccountId' => (int)($display['s2AccountId'] ?? 0),
|
||||
];
|
||||
$business = $this->resolveS2FallbackWechat($boundWechats);
|
||||
$bizWxid = (string)($business['account']['wechatId'] ?? '');
|
||||
if ($bizWxid !== '' && $bizWxid !== (string)$display['wechatId']) {
|
||||
$display = array_merge($display, [
|
||||
'wechatId' => $bizWxid,
|
||||
'nickname' => trim((string)($business['account']['nickname'] ?? '')) ?: $bizWxid,
|
||||
'alias' => (string)($business['account']['alias'] ?? ''),
|
||||
'avatar' => (string)($business['account']['avatar'] ?? ''),
|
||||
'totalFriend' => isset($business['account']['totalFriend']) && is_numeric($business['account']['totalFriend'])
|
||||
? (int)$business['account']['totalFriend'] : ($display['totalFriend'] ?? null),
|
||||
's2AccountId' => (int)($business['account']['id'] ?? 0),
|
||||
'workphoneProfileSource' => 's2_business_override',
|
||||
]);
|
||||
$profileSource = 's2_business_override';
|
||||
}
|
||||
}
|
||||
|
||||
$wechatId = (string)($display['wechatId'] ?? '');
|
||||
$account = $wechatId !== '' ? $this->findWechatAccountByWechatId($wechatId) : [];
|
||||
if (empty($account) && $wechatId === '' && !empty($boundWechats)) {
|
||||
$fallback = $this->resolveS2FallbackWechat($boundWechats);
|
||||
if (!empty($fallback['account']['wechatId'])) {
|
||||
$account = $fallback['account'];
|
||||
$wechatId = (string)$account['wechatId'];
|
||||
$profileSource = (string)$fallback['source'];
|
||||
}
|
||||
}
|
||||
$accountId = (int)($account['id'] ?? ($display['s2AccountId'] ?? 0));
|
||||
$deviceAlive = (int)($device['alive'] ?? 0);
|
||||
$wechatLoggedIn = $wechatId !== '';
|
||||
$displayNickname = trim((string)($display['nickname'] ?? ''));
|
||||
$extra = json_decode((string)($device['extra'] ?? ''), true) ?: [];
|
||||
$extra['imei'] = $imei;
|
||||
$extra['memo'] = (string)($device['memo'] ?? '');
|
||||
$extra['market_name'] = trim((string)($device['brand'] ?? '') . ' ' . (string)($device['model'] ?? '')) ?: ($extra['market_name'] ?? '');
|
||||
if ($sdkDeviceId !== '') {
|
||||
$extra['sdkDeviceId'] = $sdkDeviceId;
|
||||
}
|
||||
|
||||
$momentsSetting = $accountId > 0
|
||||
? Db::name('kf_moments_settings')->where(['userId' => $userId,'companyId' => $companyId,'wechatId' => $accountId])->find()
|
||||
: null;
|
||||
|
||||
$row = array_merge($account ?: [], [
|
||||
'id' => $accountId > 0 ? $accountId : -$deviceId,
|
||||
'wechatId' => $wechatId,
|
||||
'nickname' => $wechatLoggedIn
|
||||
? ($displayNickname !== '' ? $displayNickname : (string)($account['nickname'] ?? $wechatId))
|
||||
: '未登录微信',
|
||||
'alias' => $wechatLoggedIn ? (string)($display['alias'] ?? ($account['alias'] ?? '')) : '',
|
||||
'avatar' => $wechatLoggedIn ? (string)($display['avatar'] ?? ($account['avatar'] ?? '')) : '',
|
||||
'totalFriend' => isset($display['totalFriend']) && is_numeric($display['totalFriend'])
|
||||
? (int)$display['totalFriend']
|
||||
: (int)($account['totalFriend'] ?? 0),
|
||||
'deviceAccountId' => (int)($account['deviceAccountId'] ?? 0),
|
||||
'currentDeviceId' => $deviceId,
|
||||
'deviceAlive' => $deviceAlive,
|
||||
'wechatAlive' => $wechatLoggedIn ? 1 : 0,
|
||||
'keFuAlive' => 0,
|
||||
'isOnline' => $deviceAlive === 1,
|
||||
'apiIsOnline' => $deviceAlive === 1,
|
||||
'deviceExtra' => $extra,
|
||||
'deviceMemo' => (string)($device['memo'] ?? ''),
|
||||
'deviceStatusText' => $sdkDeviceId === ''
|
||||
? '未配置工作手机映射'
|
||||
: ($deviceAlive === 1
|
||||
? ($wechatLoggedIn ? '工作手机在线·微信已登录' : '工作手机在线·微信未登录')
|
||||
: ($wechatLoggedIn ? '微信曾登录·设备离线' : '设备离线·微信未登录')),
|
||||
'workphoneDeviceId' => $sdkDeviceId,
|
||||
'workphoneProfileSource' => $profileSource,
|
||||
'deviceControlProvider' => $deviceControlProvider,
|
||||
'messageProvider' => 's2_legacy',
|
||||
'currentWechat' => $wechatLoggedIn ? [
|
||||
'wechatId' => $wechatId,
|
||||
'nickname' => $displayNickname !== '' ? $displayNickname : (string)($account['nickname'] ?? ''),
|
||||
'totalFriend' => (int)($display['totalFriend'] ?? ($account['totalFriend'] ?? 0)),
|
||||
's2AccountId' => $accountId,
|
||||
] : null,
|
||||
'currentHookWechat' => $currentHookWechat,
|
||||
'attribution' => $this->buildAttribution($account),
|
||||
'boundWechats' => $boundWechats,
|
||||
'boundWechatCount' => count($boundWechats),
|
||||
'labels' => !empty($account['labels']) ? (json_decode((string)$account['labels'], true) ?: []) : [],
|
||||
'momentsMax' => !empty($momentsSetting['max']) ? $momentsSetting['max'] : 5,
|
||||
'momentsNum' => !empty($momentsSetting['sendNum']) ? $momentsSetting['sendNum'] : 0,
|
||||
'createTime' => !empty($device['createTime']) ? date('Y-m-d H:i:s', (int)$device['createTime']) : '',
|
||||
'updateTime' => !empty($device['updateTime']) ? date('Y-m-d H:i:s', (int)$device['updateTime']) : '',
|
||||
]);
|
||||
|
||||
unset($row['extra']);
|
||||
$result[] = $row;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function configuredSdkDeviceMap(int $companyId): array
|
||||
{
|
||||
try {
|
||||
$raw = Db::name('system_config')
|
||||
->where('configKey', 'workphone_sdk_device_map:' . $companyId)
|
||||
->value('configValue');
|
||||
} catch (\Throwable $e) {
|
||||
$raw = '';
|
||||
}
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
$map = [];
|
||||
foreach ($decoded as $left => $right) {
|
||||
$leftText = trim((string)$left);
|
||||
$rightText = trim((string)$right);
|
||||
if ($leftText === '' || $rightText === '') {
|
||||
continue;
|
||||
}
|
||||
if (ctype_digit($leftText)) {
|
||||
$map[(int)$leftText] = $rightText;
|
||||
}
|
||||
if (ctype_digit($rightText)) {
|
||||
$map[(int)$rightText] = $leftText;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function loadCurrentWechatFromWorkphone(int $companyId, string $sdkDeviceId): array
|
||||
{
|
||||
try {
|
||||
$sdk = WorkPhoneSDK::forCompany($companyId);
|
||||
$profileResp = $sdk->getProfile($sdkDeviceId, 'wechat');
|
||||
$profile = $profileResp['data']['profile']
|
||||
?? $profileResp['data']['data']['profile']
|
||||
?? $profileResp['profile']
|
||||
?? [];
|
||||
if (!is_array($profile)) {
|
||||
$profile = [];
|
||||
}
|
||||
$wechatId = trim((string)($profile['wxid'] ?? ($profile['wechat_id'] ?? '')));
|
||||
if ($wechatId === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contactsResp = $sdk->getContacts($sdkDeviceId, 'wechat', 1, 0);
|
||||
$contactsData = $contactsResp['data']['data']
|
||||
?? $contactsResp['data']
|
||||
?? $contactsResp;
|
||||
$totalFriend = $contactsData['total_count']
|
||||
?? $contactsData['raw_total_count']
|
||||
?? $contactsData['count']
|
||||
?? null;
|
||||
|
||||
return [
|
||||
'wechatId' => $wechatId,
|
||||
'nickname' => trim((string)($profile['nickname'] ?? '')),
|
||||
'totalFriend' => is_numeric($totalFriend) ? (int)$totalFriend : null,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function findWechatAccountByWechatId(string $wechatId): array
|
||||
{
|
||||
if ($wechatId === '') {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$row = Db::table('s2_wechat_account')->where('wechatId', $wechatId)->find();
|
||||
return is_array($row) ? $row : [];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function loadDeviceBoundWechats(int $companyId, int $deviceId, string $imei): array
|
||||
{
|
||||
$wechatIds = [];
|
||||
try {
|
||||
$wechatIds = Db::name('device_wechat_login')
|
||||
->where('deviceId', $deviceId)
|
||||
->where('companyId', $companyId)
|
||||
->group('wechatId')
|
||||
->column('wechatId') ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
$wechatIds = [];
|
||||
}
|
||||
try {
|
||||
if ($imei !== '') {
|
||||
$more = Db::table('s2_wechat_account')
|
||||
->where('imei', $imei)
|
||||
->column('wechatId') ?: [];
|
||||
$wechatIds = array_merge($wechatIds, $more);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$wechatIds = array_values(array_unique(array_filter(array_map('strval', $wechatIds))));
|
||||
if (empty($wechatIds)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return Db::table('s2_wechat_account')
|
||||
->whereIn('wechatId', $wechatIds)
|
||||
->field('id,wechatId,nickname,alias,avatar,totalFriend,wechatAlive,currentDeviceId,deviceAccountId')
|
||||
->select() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
return array_map(function ($wechatId) {
|
||||
return ['wechatId' => $wechatId];
|
||||
}, $wechatIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 142-P0-API1 · Hook 空时的 S2 回退(08 文档决策树第 3/4 步):
|
||||
* 活跃会话 top1(s2_wechat_message 最近一条的 wechatAccountId)→ boundWechats[0]
|
||||
*
|
||||
* @return array{account: array, source: string}
|
||||
*/
|
||||
private function resolveS2FallbackWechat(array $boundWechats): array
|
||||
{
|
||||
$byId = [];
|
||||
foreach ($boundWechats as $row) {
|
||||
$id = (int)($row['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$byId[$id] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($byId)) {
|
||||
try {
|
||||
$activeId = (int)Db::table('s2_wechat_message')
|
||||
->whereIn('wechatAccountId', array_keys($byId))
|
||||
->order('id desc')
|
||||
->limit(1)
|
||||
->value('wechatAccountId');
|
||||
if ($activeId > 0 && isset($byId[$activeId])) {
|
||||
return ['account' => $byId[$activeId], 'source' => 's2_session_fallback'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 消息表不可用时退化为 boundWechats[0]
|
||||
}
|
||||
}
|
||||
|
||||
$first = $boundWechats[0] ?? [];
|
||||
if (!empty($first['wechatId'])) {
|
||||
return ['account' => $first, 'source' => 's2_bound_fallback'];
|
||||
}
|
||||
return ['account' => [], 'source' => ''];
|
||||
}
|
||||
|
||||
/** 142-P0-API1 · 业务归属:s2_wechat_account.deviceAccountId → ck_users(法定归属客服) */
|
||||
private function buildAttribution(array $account): ?array
|
||||
{
|
||||
$ownerS2AccountId = (int)($account['deviceAccountId'] ?? 0);
|
||||
if ($ownerS2AccountId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$ownerUsername = '';
|
||||
$ownerDisplayName = '';
|
||||
try {
|
||||
$owner = Db::name('users')
|
||||
->where('s2_accountId', $ownerS2AccountId)
|
||||
->where('deleteTime', 0)
|
||||
->field('account,username')
|
||||
->find();
|
||||
if (is_array($owner)) {
|
||||
$ownerUsername = (string)($owner['account'] ?? '');
|
||||
$ownerDisplayName = (string)($owner['username'] ?? '');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
return [
|
||||
'ownerS2AccountId' => $ownerS2AccountId,
|
||||
'ownerUsername' => $ownerUsername,
|
||||
'ownerDisplayName' => $ownerDisplayName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
123
Server/application/chukebao/controller/FriendAllotController.php
Normal file
123
Server/application/chukebao/controller/FriendAllotController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\api\controller\AutomaticAssign;
|
||||
use app\chukebao\service\CompanyWechatScopeService;
|
||||
use app\common\service\AuthService;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
use think\facade\Env;
|
||||
|
||||
/**
|
||||
* 单聊/群聊好友转接(BFF · 服务端调 S2 allot,避免前端 request2 鉴权失败)
|
||||
*/
|
||||
class FriendAllotController extends BaseController
|
||||
{
|
||||
/**
|
||||
* POST /v1/kefu/friend/allot
|
||||
* body: wechatFriendId | wechatChatroomId, toAccountId, comment?, notifyReceiver?
|
||||
*/
|
||||
public function allot()
|
||||
{
|
||||
try {
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 401);
|
||||
}
|
||||
|
||||
$wechatFriendId = (int)$this->request->param('wechatFriendId', 0);
|
||||
$wechatChatroomId = (int)$this->request->param('wechatChatroomId', 0);
|
||||
$toAccountId = (int)$this->request->param('toAccountId', 0);
|
||||
$comment = trim((string)$this->request->param('comment', ''));
|
||||
$notifyReceiver = filter_var(
|
||||
$this->request->param('notifyReceiver', true),
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
);
|
||||
|
||||
if ($toAccountId <= 0) {
|
||||
return ResponseHelper::error('目标客服无效', 400);
|
||||
}
|
||||
if ($wechatFriendId <= 0 && $wechatChatroomId <= 0) {
|
||||
return ResponseHelper::error('缺少 wechatFriendId 或 wechatChatroomId', 400);
|
||||
}
|
||||
|
||||
$accountIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds(
|
||||
$companyId,
|
||||
(int)$this->getUserInfo('s2_accountId')
|
||||
);
|
||||
if (empty($accountIds)) {
|
||||
return ResponseHelper::error('当前项目暂无可操作微信号', 403);
|
||||
}
|
||||
if ($wechatChatroomId > 0) {
|
||||
$sourceExists = Db::table('s2_wechat_chatroom')
|
||||
->where('id', $wechatChatroomId)
|
||||
->whereIn('wechatAccountId', $accountIds)
|
||||
->where('isDeleted', 0)
|
||||
->count();
|
||||
} else {
|
||||
$sourceExists = Db::table('s2_wechat_friend')
|
||||
->where('id', $wechatFriendId)
|
||||
->whereIn('wechatAccountId', $accountIds)
|
||||
->where('isDeleted', 0)
|
||||
->count();
|
||||
}
|
||||
if (!$sourceExists) {
|
||||
return ResponseHelper::error('会话不存在或无权限转接', 404);
|
||||
}
|
||||
|
||||
$toAccount = Db::table('s2_company_account')
|
||||
->where('id', $toAccountId)
|
||||
->where('departmentId', $companyId)
|
||||
->find();
|
||||
if (!$toAccount) {
|
||||
return ResponseHelper::error('目标客服不在当前项目', 403);
|
||||
}
|
||||
|
||||
$automaticAssign = new AutomaticAssign();
|
||||
|
||||
if ($wechatChatroomId > 0) {
|
||||
$urlParams = http_build_query([
|
||||
'wechatChatroomId' => $wechatChatroomId,
|
||||
'toAccountId' => $toAccountId,
|
||||
'notifyReceiver' => $notifyReceiver ? 'true' : 'false',
|
||||
'comment' => $comment,
|
||||
'optFrom' => 4,
|
||||
]);
|
||||
$authorization = AuthService::getSystemAuthorization();
|
||||
$baseUrl = Env::get('api.wechat_url');
|
||||
if (empty($authorization) || empty($baseUrl)) {
|
||||
return ResponseHelper::error('转接服务未就绪,请稍后重试', 503);
|
||||
}
|
||||
$header = setHeader(['client:system'], $authorization, 'json');
|
||||
$result = requestCurl(
|
||||
$baseUrl . 'api/WechatChatroom/allot?' . $urlParams,
|
||||
[],
|
||||
'PUT',
|
||||
$header,
|
||||
'json'
|
||||
);
|
||||
$err = handleApiResponse($result);
|
||||
if (!empty($err)) {
|
||||
return ResponseHelper::error(is_string($err) ? $err : '群聊转接失败', 500);
|
||||
}
|
||||
return ResponseHelper::success(true, '转接成功');
|
||||
}
|
||||
|
||||
$result = $automaticAssign->allotWechatFriend([
|
||||
'wechatFriendId' => $wechatFriendId,
|
||||
'toAccountId' => $toAccountId,
|
||||
'comment' => $comment,
|
||||
'notifyReceiver' => $notifyReceiver ? 'true' : 'false',
|
||||
'optFrom' => 4,
|
||||
], true);
|
||||
|
||||
$payload = is_string($result) ? json_decode($result, true) : $result;
|
||||
if (is_array($payload) && (int)($payload['code'] ?? 500) === 200) {
|
||||
return ResponseHelper::success(true, '转接成功');
|
||||
}
|
||||
|
||||
$msg = is_array($payload) ? (string)($payload['msg'] ?? '转接失败') : '转接失败';
|
||||
return ResponseHelper::error($msg, 500);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\chukebao\model\AiKnowledgeBaseType;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
@@ -40,7 +41,7 @@ class KnowledgeBaseController extends BaseController
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$kefuId = $this->getUserInfo('id');
|
||||
|
||||
// 仅返回 enabled 的库(存客宝下发);无分配 → 空列表,触客宝提示联系管理员
|
||||
// 仅返回 enabled 的库(存客宝下发);无分配 → 回退读存客宝项目知识库(REQ-TKB-133-C)
|
||||
$rows = Db::name('kb_touchkebao_config')
|
||||
->where('companyId', $companyId)
|
||||
->where('enabled', 1)
|
||||
@@ -60,6 +61,12 @@ class KnowledgeBaseController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($list)) {
|
||||
$fallback = $this->fallbackKnowledgeFromWorkspace($companyId);
|
||||
$list = $fallback['list'];
|
||||
$defaultKbId = $fallback['defaultKbId'];
|
||||
}
|
||||
|
||||
// 客服个人当前选择(覆盖默认,须在已开放范围内)
|
||||
$currentKbId = $defaultKbId;
|
||||
$myCurrent = Db::name('kb_touchkebao_config')
|
||||
@@ -117,4 +124,90 @@ class KnowledgeBaseController extends BaseController
|
||||
}
|
||||
return ResponseHelper::success(['currentKbId' => $kbId], '已切换');
|
||||
}
|
||||
|
||||
/**
|
||||
* REQ-TKB-133-C:本地分配表为空时,聚合存客宝 AiKnowledgeTouchkebao 配置 + 项目知识库
|
||||
*
|
||||
* @return array{list:array<int,array{id:int,name:string,isDefault:bool}>,defaultKbId:?int}
|
||||
*/
|
||||
private function fallbackKnowledgeFromWorkspace(int $companyId): array
|
||||
{
|
||||
$kbRows = AiKnowledgeBaseType::where([
|
||||
['isDel', '=', 0],
|
||||
['status', '=', 1],
|
||||
])->where(function ($q) use ($companyId) {
|
||||
$q->where('companyId', $companyId)->whereOr('companyId', 0);
|
||||
})->field('id,name,companyId')
|
||||
->order('companyId desc,id desc')
|
||||
->select();
|
||||
|
||||
if (empty($kbRows)) {
|
||||
return ['list' => [], 'defaultKbId' => null];
|
||||
}
|
||||
|
||||
$cfgRows = Db::name('kb_touchkebao_config')
|
||||
->where('companyId', $companyId)
|
||||
->where('kefuId', 0)
|
||||
->select();
|
||||
$cfgMap = [];
|
||||
$defaultKbId = null;
|
||||
foreach ($cfgRows ?: [] as $c) {
|
||||
$cfgMap[(int)$c['kbId']] = $c;
|
||||
if (!empty($c['isDefault'])) {
|
||||
$defaultKbId = (int)$c['kbId'];
|
||||
}
|
||||
}
|
||||
|
||||
$list = [];
|
||||
$now = time();
|
||||
foreach ($kbRows as $kb) {
|
||||
$kbId = (int)$kb['id'];
|
||||
$cfg = $cfgMap[$kbId] ?? null;
|
||||
$enabled = $cfg ? (bool)$cfg['enabled'] : ((int)$kb['companyId'] === $companyId);
|
||||
if (!$enabled) {
|
||||
continue;
|
||||
}
|
||||
$isDefault = $cfg ? (bool)$cfg['isDefault'] : false;
|
||||
$list[] = [
|
||||
'id' => $kbId,
|
||||
'name' => (string)($kb['name'] ?: ''),
|
||||
'isDefault' => $isDefault,
|
||||
];
|
||||
if ($isDefault) {
|
||||
$defaultKbId = $kbId;
|
||||
}
|
||||
}
|
||||
|
||||
// 仍无默认:优先项目库首条,其次系统库
|
||||
if ($defaultKbId === null && !empty($list)) {
|
||||
$defaultKbId = (int)$list[0]['id'];
|
||||
$list[0]['isDefault'] = true;
|
||||
}
|
||||
|
||||
// 懒同步写入分配表,便于后续 select 校验
|
||||
if (!empty($list)) {
|
||||
foreach ($list as $item) {
|
||||
$exist = Db::name('kb_touchkebao_config')
|
||||
->where('companyId', $companyId)
|
||||
->where('kefuId', 0)
|
||||
->where('kbId', $item['id'])
|
||||
->find();
|
||||
if ($exist) {
|
||||
continue;
|
||||
}
|
||||
Db::name('kb_touchkebao_config')->insert([
|
||||
'companyId' => $companyId,
|
||||
'kbId' => $item['id'],
|
||||
'kbName' => $item['name'],
|
||||
'isDefault' => $item['isDefault'] ? 1 : 0,
|
||||
'kefuId' => 0,
|
||||
'currentKbId' => 0,
|
||||
'enabled' => 1,
|
||||
'updateTime' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return ['list' => $list, 'defaultKbId' => $defaultKbId];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ namespace app\chukebao\controller;
|
||||
|
||||
use app\api\model\WechatMessageModel;
|
||||
use app\chukebao\model\FriendSettings;
|
||||
use app\chukebao\service\CompanyWechatScopeService;
|
||||
use app\common\service\workphone\WorkphoneMessageDeviceResolver;
|
||||
use app\common\util\WorkPhoneSDK;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
use think\facade\Env;
|
||||
use think\facade\Log;
|
||||
use app\common\service\AuthService;
|
||||
|
||||
class MessageController extends BaseController
|
||||
@@ -21,29 +25,617 @@ class MessageController extends BaseController
|
||||
$this->authorization = AuthService::getSystemAuthorization();
|
||||
}
|
||||
|
||||
private function resolveConversationWechatAccountId(
|
||||
int $accountId,
|
||||
int $wechatFriendId,
|
||||
int $wechatChatroomId
|
||||
): int {
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
if ($wechatChatroomId > 0) {
|
||||
$row = Db::table('s2_wechat_chatroom')
|
||||
->where('id', $wechatChatroomId)
|
||||
->where('isDeleted', 0)
|
||||
->field('wechatAccountId,accountId')
|
||||
->find();
|
||||
if (empty($row)) {
|
||||
return 0;
|
||||
}
|
||||
$wechatAccountId = (int)($row['wechatAccountId'] ?? 0);
|
||||
if ($wechatAccountId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if ((int)($row['accountId'] ?? 0) === $accountId) {
|
||||
return $wechatAccountId;
|
||||
}
|
||||
return $this->isWechatAccountInKefuScope($companyId, $accountId, $wechatAccountId)
|
||||
? $wechatAccountId
|
||||
: 0;
|
||||
}
|
||||
if ($wechatFriendId > 0) {
|
||||
$friend = Db::table('s2_wechat_friend')
|
||||
->where('id', $wechatFriendId)
|
||||
->where('isDeleted', 0)
|
||||
->field('wechatAccountId,accountId')
|
||||
->find();
|
||||
if (empty($friend)) {
|
||||
return 0;
|
||||
}
|
||||
$wechatAccountId = (int)($friend['wechatAccountId'] ?? 0);
|
||||
if ($wechatAccountId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if ((int)($friend['accountId'] ?? 0) === $accountId) {
|
||||
return $wechatAccountId;
|
||||
}
|
||||
return $this->isWechatAccountInKefuScope($companyId, $accountId, $wechatAccountId)
|
||||
? $wechatAccountId
|
||||
: 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 与 ensure-session 侧栏口径一致:本项目设备链微信号可跨 accountId 代发 */
|
||||
private function isWechatAccountInKefuScope(int $companyId, int $accountId, int $wechatAccountId): bool
|
||||
{
|
||||
if ($wechatAccountId <= 0 || $companyId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$scopeIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds($companyId, $accountId);
|
||||
return !empty($scopeIds) && in_array($wechatAccountId, $scopeIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/kefu/message/ensure-session(CKB-141 · 141.4 进入聊天链路)
|
||||
*
|
||||
* 好友管理「进入聊天」前置:权限校验 → 会话补建 → 返回定位上下文。
|
||||
* body: { friendId, wechatAccountId? }
|
||||
* 失败原因明确返回:NOT_FOUND / NOT_IN_COMPANY / OWNED_BY_OTHER。
|
||||
*/
|
||||
public function ensureSession()
|
||||
{
|
||||
try {
|
||||
$accountId = (int)$this->getUserInfo('s2_accountId');
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
if ($accountId <= 0) {
|
||||
return ResponseHelper::error('请先登录', 401);
|
||||
}
|
||||
$friendId = (int)$this->request->param('friendId', 0);
|
||||
if ($friendId <= 0) {
|
||||
return ResponseHelper::error('缺少 friendId', 400);
|
||||
}
|
||||
|
||||
$friend = Db::table('s2_wechat_friend')
|
||||
->where('id', $friendId)
|
||||
->where('isDeleted', 0)
|
||||
->find();
|
||||
if (!$friend) {
|
||||
return ResponseHelper::error('好友不存在或已删除(NOT_FOUND)', 404);
|
||||
}
|
||||
|
||||
$friendWechatAccountId = (int)($friend['wechatAccountId'] ?? 0);
|
||||
|
||||
// 权限校验:与好友管理页同一口径(项目设备链微信号,无则个人 IM 号兜底)(141.4 步骤 2)
|
||||
$scopeAccountIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds($companyId, $accountId);
|
||||
if (!empty($scopeAccountIds) && !in_array($friendWechatAccountId, $scopeAccountIds, true)) {
|
||||
return ResponseHelper::error('好友不属于本项目微信号,无聊天权限(NOT_IN_COMPANY)', 403);
|
||||
}
|
||||
|
||||
// 归属判定(141.4 步骤 6:失败原因明确;141.3 管理员可触达全部)
|
||||
$isAdmin = (int)$this->getUserInfo('isAdmin') === 1;
|
||||
$ownerAccountId = (int)($friend['accountId'] ?? 0);
|
||||
$ownedByOther = $ownerAccountId > 0 && $ownerAccountId !== $accountId;
|
||||
$ownerName = '';
|
||||
if ($ownedByOther) {
|
||||
try {
|
||||
$owner = Db::name('users')
|
||||
->where('s2_accountId', $ownerAccountId)
|
||||
->where('deleteTime', 0)
|
||||
->field('account,username')
|
||||
->find();
|
||||
$ownerName = $owner ? (string)($owner['username'] ?: $owner['account']) : '';
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 初始分配兜底:未分配好友领取到当前客服(141.2 初始分配 / V5)
|
||||
$assigned = false;
|
||||
if ($ownerAccountId <= 0) {
|
||||
Db::table('s2_wechat_friend')->where('id', $friendId)->update(['accountId' => $accountId]);
|
||||
$friend['accountId'] = $accountId;
|
||||
$assigned = true;
|
||||
Log::info('[ensure-session] friend auto-assigned', [
|
||||
'friendId' => $friendId, 'toAccountId' => $accountId, 'companyId' => $companyId,
|
||||
]);
|
||||
}
|
||||
|
||||
$blocked = $ownedByOther && !$isAdmin;
|
||||
|
||||
// 会话补建:无任何消息时插入一条系统消息,使 message/list 聚合出该会话(141.4 步骤 3 / V6)
|
||||
$sessionCreated = false;
|
||||
if (!$blocked) {
|
||||
$hasMsg = Db::table('s2_wechat_message')
|
||||
->where('type', 1)
|
||||
->where('wechatFriendId', $friendId)
|
||||
->limit(1)
|
||||
->value('id');
|
||||
if (!$hasMsg) {
|
||||
$now = time();
|
||||
// s2_wechat_message.id 为同步主键(非自增),手工取 MAX+1
|
||||
$nextId = (int)Db::table('s2_wechat_message')->max('id') + 1;
|
||||
Db::table('s2_wechat_message')->insert([
|
||||
'id' => $nextId,
|
||||
'type' => 1,
|
||||
'wechatFriendId' => $friendId,
|
||||
'wechatChatroomId' => 0,
|
||||
'wechatAccountId' => $friendWechatAccountId,
|
||||
'accountId' => (int)$friend['accountId'],
|
||||
'tenantId' => (int)($friend['tenantId'] ?? 0),
|
||||
'content' => '[系统] 会话已建立,可直接发起聊天',
|
||||
'originalContent' => '[系统] 会话已建立,可直接发起聊天',
|
||||
'msgType' => 1,
|
||||
'msgSubType' => 0,
|
||||
'isSend' => 1,
|
||||
'isRead' => 1,
|
||||
'createTime' => $now,
|
||||
'wechatTime' => $now,
|
||||
'sendStatus' => 1,
|
||||
]);
|
||||
$sessionCreated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseHelper::success([
|
||||
'friendId' => $friendId,
|
||||
'wechatAccountId' => $friendWechatAccountId,
|
||||
'wechatId' => (string)($friend['wechatId'] ?? ''),
|
||||
'nickname' => (string)($friend['nickname'] ?? ''),
|
||||
'conRemark' => (string)($friend['conRemark'] ?? ''),
|
||||
'avatar' => (string)($friend['avatar'] ?? ''),
|
||||
'canChat' => !$blocked,
|
||||
'ownedByOther' => $ownedByOther,
|
||||
'ownerAccountId' => $ownerAccountId,
|
||||
'ownerName' => $ownerName,
|
||||
'assigned' => $assigned,
|
||||
'sessionCreated' => $sessionCreated,
|
||||
'reason' => $blocked ? 'OWNED_BY_OTHER' : '',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return ResponseHelper::error('进入聊天预检失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/kefu/message/send
|
||||
*
|
||||
* 触客宝专用发送适配层:只做权限、对象解析、SDK 调用与本地消息落库。
|
||||
* 工作手机能力仍由 WorkPhoneSDK / /v1/workphone/* 负责,避免业务域耦合。
|
||||
*/
|
||||
public function send()
|
||||
{
|
||||
try {
|
||||
$accountId = (int)$this->getUserInfo('s2_accountId');
|
||||
if ($accountId <= 0) {
|
||||
return ResponseHelper::error('请先登录', 401);
|
||||
}
|
||||
|
||||
$wechatFriendId = (int)$this->request->param('wechatFriendId/d', 0);
|
||||
$wechatChatroomId = (int)$this->request->param('wechatChatroomId/d', 0);
|
||||
$content = trim((string)$this->request->param('content', ''));
|
||||
$msgType = (int)$this->request->param('msgType/d', 1);
|
||||
$seq = (int)$this->request->param('seq/d', 0);
|
||||
$channel = trim((string)$this->request->param('channel', ''));
|
||||
|
||||
if ($wechatFriendId <= 0 && $wechatChatroomId <= 0) {
|
||||
return ResponseHelper::error('wechatFriendId 或 wechatChatroomId 至少传一个', 400);
|
||||
}
|
||||
if ($content === '') {
|
||||
return ResponseHelper::error('消息内容不能为空', 400);
|
||||
}
|
||||
|
||||
$wechatAccountId = $this->resolveConversationWechatAccountId($accountId, $wechatFriendId, $wechatChatroomId);
|
||||
if ($wechatAccountId <= 0) {
|
||||
return ResponseHelper::error('会话不存在或无权限', 404);
|
||||
}
|
||||
|
||||
$target = $this->resolveSendTarget($accountId, $wechatAccountId, $wechatFriendId, $wechatChatroomId);
|
||||
if (empty($target['toId'])) {
|
||||
return ResponseHelper::error('未找到接收方微信标识', 404);
|
||||
}
|
||||
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$resolved = WorkphoneMessageDeviceResolver::resolveForWechatAccount($companyId, $wechatAccountId);
|
||||
$deviceId = (string)($resolved['deviceId'] ?? '');
|
||||
if ($deviceId === '') {
|
||||
$hint = trim((string)($resolved['reason'] ?? ''));
|
||||
return ResponseHelper::error(
|
||||
$hint !== '' ? $hint : '未找到工作手机设备标识,请先在设备管理绑定该微信号',
|
||||
422
|
||||
);
|
||||
}
|
||||
// 安全门禁:禁止客户端 device_id 绕过 Resolver 跨设备代发
|
||||
$requestedDeviceId = trim((string)$this->request->param('device_id', $this->request->param('deviceId', '')));
|
||||
if ($requestedDeviceId !== '' && strcasecmp($requestedDeviceId, $deviceId) !== 0) {
|
||||
Log::warning('[TouchkebaoMessageSend] device_id mismatch blocked', [
|
||||
'companyId' => $companyId,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'requested' => $requestedDeviceId,
|
||||
'resolved' => $deviceId,
|
||||
'accountId' => $accountId,
|
||||
]);
|
||||
return ResponseHelper::error('设备标识与项目解析结果不一致,禁止跨设备代发', 403);
|
||||
}
|
||||
|
||||
// 142-P1:发消息前校验会话微信号 == 工作手机当前 Hook 登录微信
|
||||
$isAdmin = (int)$this->getUserInfo('isAdmin') === 1;
|
||||
$skipAccountCheck = $isAdmin && (int)$this->request->param('skipAccountCheck/d', 0) === 1;
|
||||
if (!$skipAccountCheck) {
|
||||
$check = $this->verifyWechatAccountOnDevice($wechatAccountId, $deviceId);
|
||||
if ($check['checked'] && !$check['matched']) {
|
||||
return ResponseHelper::error(
|
||||
'工作手机当前登录微信为「' . $check['hookNickname'] . '」,与会话归属微信「' . $check['expectNickname'] . '」不一致,'
|
||||
. '请先在真机切换对应微信后再发送',
|
||||
409
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$messageId = $seq > 0 ? $seq : (int)floor(microtime(true) * 1000);
|
||||
$sdkResp = WorkPhoneSDK::getInstance()->sendMessage(
|
||||
$deviceId,
|
||||
'wechat',
|
||||
(string)$target['toId'],
|
||||
$content,
|
||||
$this->mapWorkPhoneMsgType($msgType),
|
||||
$channel !== '' ? $channel : null
|
||||
);
|
||||
|
||||
$sdkOk = $this->isWorkphoneSendSuccessful($sdkResp);
|
||||
$sendStatus = $sdkOk ? 1 : 2;
|
||||
$this->upsertLocalOutgoingMessage([
|
||||
'id' => $messageId,
|
||||
'type' => $wechatChatroomId > 0 ? 2 : 1,
|
||||
'accountId' => $accountId,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'wechatFriendId' => $wechatFriendId,
|
||||
'wechatChatroomId' => $wechatChatroomId,
|
||||
'content' => $content,
|
||||
'msgType' => $msgType,
|
||||
'sendStatus' => $sendStatus,
|
||||
]);
|
||||
|
||||
return ResponseHelper::success([
|
||||
'messageId' => $messageId,
|
||||
'seq' => $messageId,
|
||||
'sendStatus' => $sendStatus,
|
||||
'deviceId' => $deviceId,
|
||||
'toId' => $target['toId'],
|
||||
'targetName' => $target['name'],
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'sdk' => $sdkResp,
|
||||
], $sdkOk ? '消息已提交工作手机,等待微信回执' : '工作手机返回失败,已记录失败消息');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[TouchkebaoMessageSend] ' . $e->getMessage());
|
||||
return ResponseHelper::error($e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 142-P1:校验工作手机当前 Hook 登录微信是否等于会话归属微信。
|
||||
* Hook 探测失败/超时不阻断发送(checked=false);命中缓存 60s 降低真机 RPC 频次。
|
||||
*
|
||||
* @return array{checked:bool,matched:bool,hookNickname:string,expectNickname:string}
|
||||
*/
|
||||
private function verifyWechatAccountOnDevice(int $wechatAccountId, string $deviceId): array
|
||||
{
|
||||
$result = ['checked' => false, 'matched' => true, 'hookNickname' => '', 'expectNickname' => ''];
|
||||
try {
|
||||
$expect = Db::table('s2_wechat_account')
|
||||
->where('id', $wechatAccountId)
|
||||
->field('wechatId,nickname,alias')
|
||||
->find();
|
||||
if (empty($expect) || empty($expect['wechatId'])) {
|
||||
return $result;
|
||||
}
|
||||
$result['expectNickname'] = (string)($expect['nickname'] ?: $expect['wechatId']);
|
||||
|
||||
$cacheKey = 'tkb:hook_wxid:' . strtolower($deviceId);
|
||||
$hook = \think\facade\Cache::get($cacheKey);
|
||||
if (!is_array($hook)) {
|
||||
$resp = WorkPhoneSDK::getInstance()->getProfile($deviceId, 'wechat');
|
||||
$profile = $resp['data']['profile']
|
||||
?? $resp['data']['data']['profile']
|
||||
?? $resp['profile']
|
||||
?? [];
|
||||
if (!is_array($profile)) {
|
||||
$profile = [];
|
||||
}
|
||||
$hook = [
|
||||
'wxid' => trim((string)($profile['wxid'] ?? ($profile['wechat_id'] ?? ''))),
|
||||
'alias' => trim((string)($profile['alias'] ?? '')),
|
||||
'nickname' => trim((string)($profile['nickname'] ?? '')),
|
||||
];
|
||||
\think\facade\Cache::set($cacheKey, $hook, 60);
|
||||
}
|
||||
|
||||
if ($hook['wxid'] === '' && $hook['alias'] === '') {
|
||||
// Hook 未返回当前微信(未登录/通道不可用)→ 不阻断
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['checked'] = true;
|
||||
$result['hookNickname'] = $hook['nickname'] !== '' ? $hook['nickname'] : ($hook['wxid'] ?: $hook['alias']);
|
||||
$expectIds = array_filter([
|
||||
strtolower((string)$expect['wechatId']),
|
||||
strtolower((string)($expect['alias'] ?? '')),
|
||||
]);
|
||||
$hookIds = array_filter([strtolower($hook['wxid']), strtolower($hook['alias'])]);
|
||||
$result['matched'] = count(array_intersect($expectIds, $hookIds)) > 0;
|
||||
} catch (\Throwable $e) {
|
||||
// 探测异常不阻断发送
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function resolveSendTarget(int $accountId, int $wechatAccountId, int $wechatFriendId, int $wechatChatroomId): array
|
||||
{
|
||||
if ($wechatChatroomId > 0) {
|
||||
$row = Db::table('s2_wechat_chatroom')
|
||||
->where([
|
||||
'id' => $wechatChatroomId,
|
||||
'accountId' => $accountId,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'isDeleted' => 0,
|
||||
])
|
||||
->field('id,nickname,chatroomId')
|
||||
->find();
|
||||
return [
|
||||
'toId' => $row['chatroomId'] ?? '',
|
||||
'name' => $row['nickname'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$row = Db::table('s2_wechat_friend')
|
||||
->where([
|
||||
'id' => $wechatFriendId,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'isDeleted' => 0,
|
||||
])
|
||||
->field('id,nickname,conRemark,wechatId,alias')
|
||||
->find();
|
||||
$displayName = trim((string)($row['conRemark'] ?? ''));
|
||||
if ($displayName === '') {
|
||||
$displayName = trim((string)($row['nickname'] ?? ''));
|
||||
}
|
||||
return [
|
||||
'toId' => $row['wechatId'] ?? ($row['alias'] ?? ''),
|
||||
'name' => $displayName,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveWorkPhoneDeviceId(int $wechatAccountId): string
|
||||
{
|
||||
$account = Db::table('s2_wechat_account')
|
||||
->where('id', $wechatAccountId)
|
||||
->field('id,wechatId,currentDeviceId,imei,deviceAccountId')
|
||||
->find();
|
||||
if (empty($account)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach (['currentDeviceId', 'deviceAccountId', 'imei'] as $key) {
|
||||
if (!empty($account[$key])) {
|
||||
$candidates[] = (string)$account[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($account['wechatId'])) {
|
||||
$login = Db::name('device_wechat_login')->alias('l')
|
||||
->join('device d', 'd.id = l.deviceId', 'left')
|
||||
->where('l.wechatId', $account['wechatId'])
|
||||
->where('d.deleteTime', 0)
|
||||
->field('l.deviceId,d.imei,d.deviceImei,d.extra')
|
||||
->order('l.alive desc,l.updateTime desc')
|
||||
->find();
|
||||
if (!empty($login)) {
|
||||
foreach (['deviceId', 'imei', 'deviceImei'] as $key) {
|
||||
if (!empty($login[$key])) {
|
||||
$candidates[] = (string)$login[$key];
|
||||
}
|
||||
}
|
||||
$candidates = array_merge($candidates, $this->extractDeviceIdentifiers($login['extra'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
$device = Db::name('device')
|
||||
->where(function ($q) use ($account) {
|
||||
if (!empty($account['imei'])) {
|
||||
$q->where('imei', $account['imei'])->whereOr('deviceImei', $account['imei']);
|
||||
} else {
|
||||
$q->where('id', (int)($account['currentDeviceId'] ?? 0));
|
||||
}
|
||||
})
|
||||
->where('deleteTime', 0)
|
||||
->field('id,imei,deviceImei,extra')
|
||||
->find();
|
||||
if (!empty($device)) {
|
||||
foreach (['id', 'imei', 'deviceImei'] as $key) {
|
||||
if (!empty($device[$key])) {
|
||||
$candidates[] = (string)$device[$key];
|
||||
}
|
||||
}
|
||||
$candidates = array_merge($candidates, $this->extractDeviceIdentifiers($device['extra'] ?? ''));
|
||||
}
|
||||
|
||||
$resolved = $this->matchSdkDeviceIdentifier($candidates);
|
||||
return $resolved !== '' ? $resolved : (string)($candidates[0] ?? '');
|
||||
}
|
||||
|
||||
private function extractDeviceIdentifiers($extra): array
|
||||
{
|
||||
$decoded = is_array($extra) ? $extra : json_decode((string)$extra, true);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach (['device_id', 'deviceId', 'device_id_md5', 'deviceIdMd5', 'serial', 'adb_serial', 'adbSerial', 'ws_id'] as $key) {
|
||||
if (!empty($decoded[$key])) {
|
||||
$ids[] = (string)$decoded[$key];
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function matchSdkDeviceIdentifier(array $candidates): string
|
||||
{
|
||||
$candidateMap = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
$key = strtolower(trim((string)$candidate));
|
||||
if ($key !== '') {
|
||||
$candidateMap[$key] = (string)$candidate;
|
||||
}
|
||||
}
|
||||
if (empty($candidateMap)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = WorkPhoneSDK::getInstance()->getDevices();
|
||||
$devices = is_array($resp['data'] ?? null) ? $resp['data'] : [];
|
||||
foreach ($devices as $device) {
|
||||
if (!is_array($device)) {
|
||||
continue;
|
||||
}
|
||||
$status = strtolower((string)($device['status'] ?? ''));
|
||||
foreach (['device_id', 'id', 'deviceId', 'device_id_md5', 'deviceIdMd5', 'serial', 'adb_serial', 'imei'] as $key) {
|
||||
$value = strtolower(trim((string)($device[$key] ?? '')));
|
||||
if ($value !== '' && isset($candidateMap[$value])) {
|
||||
return (string)($device['device_id'] ?? $device['id'] ?? $candidateMap[$value]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定工作手机 SDK 是否真正发送成功(禁止仅凭 HTTP 200 误判)。
|
||||
*/
|
||||
private function isWorkphoneSendSuccessful(array $sdkResp): bool
|
||||
{
|
||||
if (!empty($sdkResp['success']) && empty($sdkResp['data']['success']) && empty($sdkResp['data']['error'])) {
|
||||
return true;
|
||||
}
|
||||
$payload = $sdkResp['data'] ?? [];
|
||||
if (is_array($payload)) {
|
||||
if (array_key_exists('success', $payload)) {
|
||||
return (bool)$payload['success'];
|
||||
}
|
||||
$inner = $payload['data'] ?? null;
|
||||
if (is_array($inner) && array_key_exists('success', $inner)) {
|
||||
return (bool)$inner['success'];
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function mapWorkPhoneMsgType(int $msgType): string
|
||||
{
|
||||
if ($msgType === 3) {
|
||||
return 'image';
|
||||
}
|
||||
if ($msgType === 43) {
|
||||
return 'video';
|
||||
}
|
||||
if ($msgType === 49) {
|
||||
return 'file';
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
|
||||
private function upsertLocalOutgoingMessage(array $data): void
|
||||
{
|
||||
$now = time();
|
||||
$row = [
|
||||
'id' => (int)$data['id'],
|
||||
'type' => (int)$data['type'],
|
||||
'wechatFriendId' => (int)$data['wechatFriendId'],
|
||||
'wechatChatroomId' => (int)$data['wechatChatroomId'],
|
||||
'wechatAccountId' => (int)$data['wechatAccountId'],
|
||||
'tenantId' => 0,
|
||||
'accountId' => (int)$data['accountId'],
|
||||
'synergyAccountId' => 0,
|
||||
'content' => (string)$data['content'],
|
||||
'originalContent' => (string)$data['content'],
|
||||
'msgType' => (int)$data['msgType'],
|
||||
'msgSubType' => 0,
|
||||
'msgSvrId' => '',
|
||||
'isSend' => 1,
|
||||
'createTime' => $now,
|
||||
'isDeleted' => 0,
|
||||
'deleteTime' => 0,
|
||||
'sendStatus' => (int)$data['sendStatus'],
|
||||
'wechatTime' => $now,
|
||||
'origin' => 0,
|
||||
'msgId' => 0,
|
||||
'recallId' => 0,
|
||||
'isRead' => 1,
|
||||
];
|
||||
|
||||
$exists = Db::table('s2_wechat_message')
|
||||
->where('id', $row['id'])
|
||||
->where('type', $row['type'])
|
||||
->find();
|
||||
if ($exists) {
|
||||
Db::table('s2_wechat_message')
|
||||
->where('id', $row['id'])
|
||||
->where('type', $row['type'])
|
||||
->update($row);
|
||||
return;
|
||||
}
|
||||
Db::table('s2_wechat_message')->insert($row);
|
||||
}
|
||||
|
||||
public function getList()
|
||||
{
|
||||
$page = $this->request->param('page', 1);
|
||||
$limit = $this->request->param('limit', 10);
|
||||
$accountId = $this->getUserInfo('s2_accountId');
|
||||
if (empty($accountId)) {
|
||||
$wechatAccountId = (int)$this->request->param('wechatAccountId/d', 0);
|
||||
$accountId = (int)$this->getUserInfo('s2_accountId');
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
if ($accountId <= 0) {
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
if ($wechatAccountId > 0) {
|
||||
$scopeIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds($companyId, $accountId);
|
||||
if (!empty($scopeIds) && !in_array($wechatAccountId, $scopeIds, true)) {
|
||||
return ResponseHelper::error('会话不属于本项目微信号(NOT_IN_COMPANY)', 403);
|
||||
}
|
||||
$friendWhere = ['wechatAccountId' => $wechatAccountId, 'isDeleted' => 0];
|
||||
$chatroomWhere = ['wechatAccountId' => $wechatAccountId, 'isDeleted' => 0];
|
||||
} else {
|
||||
$friendWhere = ['accountId' => $accountId, 'isDeleted' => 0];
|
||||
$chatroomWhere = ['accountId' => $accountId, 'isDeleted' => 0];
|
||||
}
|
||||
|
||||
// 直接查询好友ID列表
|
||||
$ids = Db::table('s2_wechat_friend')
|
||||
->where(['accountId' => $accountId, 'isDeleted' => 0])
|
||||
->where($friendWhere)
|
||||
->column('id');
|
||||
$friendIds = empty($ids) ? [0] : $ids; // 避免 IN 查询为空
|
||||
|
||||
// 直接查询好友信息
|
||||
$friends = Db::table('s2_wechat_friend')
|
||||
->where(['accountId' => $accountId, 'isDeleted' => 0])
|
||||
->where($friendWhere)
|
||||
->column('id,nickname,avatar,conRemark,labels,groupId,wechatAccountId,wechatId,extendFields,phone,region,isTop');
|
||||
|
||||
// 直接查询群聊信息
|
||||
$chatrooms = Db::table('s2_wechat_chatroom')
|
||||
->where(['accountId' => $accountId, 'isDeleted' => 0])
|
||||
->where($chatroomWhere)
|
||||
->column('id,nickname,chatroomAvatar,chatroomId,isTop');
|
||||
|
||||
// 获取群聊ID列表
|
||||
@@ -229,6 +821,14 @@ class MessageController extends BaseController
|
||||
if (empty($wechatChatroomId) && empty($wechatFriendId)) {
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
$conversationAccountId = $this->resolveConversationWechatAccountId(
|
||||
(int)$accountId,
|
||||
(int)$wechatFriendId,
|
||||
(int)$wechatChatroomId
|
||||
);
|
||||
if ($conversationAccountId <= 0) {
|
||||
return ResponseHelper::error('会话不存在或无权限', 404);
|
||||
}
|
||||
|
||||
$where = [];
|
||||
if (!empty($wechatChatroomId)) {
|
||||
@@ -267,10 +867,24 @@ class MessageController extends BaseController
|
||||
if(empty($wechatFriendId) && empty($wechatChatroomId)) {
|
||||
return ResponseHelper::error('消息类型不能为空');
|
||||
}
|
||||
$conversationAccountId = $this->resolveConversationWechatAccountId(
|
||||
(int)$accountId,
|
||||
(int)$wechatFriendId,
|
||||
(int)$wechatChatroomId
|
||||
);
|
||||
if ($conversationAccountId <= 0) {
|
||||
return ResponseHelper::error('会话不存在或无权限', 404);
|
||||
}
|
||||
$wechatAccountId = $conversationAccountId;
|
||||
|
||||
// 查询单条消息的基本信息(只需要发送状态相关字段)
|
||||
$message = Db::table('s2_wechat_message')
|
||||
->where('id', $messageId)
|
||||
$messageQuery = Db::table('s2_wechat_message')->where('id', $messageId);
|
||||
if (!empty($wechatChatroomId)) {
|
||||
$messageQuery->where('wechatChatroomId', $wechatChatroomId);
|
||||
} else {
|
||||
$messageQuery->where('wechatFriendId', $wechatFriendId);
|
||||
}
|
||||
$message = $messageQuery
|
||||
->field('id,wechatAccountId,wechatFriendId,wechatChatroomId,sendStatus')
|
||||
->find();
|
||||
|
||||
@@ -355,8 +969,8 @@ class MessageController extends BaseController
|
||||
$wechatAccountId = $this->request->param('wechatAccountId', '');
|
||||
$page = $this->request->param('page', 1);
|
||||
$limit = $this->request->param('limit', 10);
|
||||
$from = $this->request->param('From', '');
|
||||
$to = $this->request->param('To', '');
|
||||
$from = $this->request->param('From', $this->request->param('from', ''));
|
||||
$to = $this->request->param('To', $this->request->param('to', ''));
|
||||
$olderData = $this->request->param('olderData', false);
|
||||
$accountId = $this->getUserInfo('s2_accountId');
|
||||
if (empty($accountId)) {
|
||||
@@ -365,6 +979,15 @@ class MessageController extends BaseController
|
||||
if (empty($wechatChatroomId) && empty($wechatFriendId)) {
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
$conversationAccountId = $this->resolveConversationWechatAccountId(
|
||||
(int)$accountId,
|
||||
(int)$wechatFriendId,
|
||||
(int)$wechatChatroomId
|
||||
);
|
||||
if ($conversationAccountId <= 0) {
|
||||
return ResponseHelper::error('会话不存在或无权限', 404);
|
||||
}
|
||||
$wechatAccountId = $conversationAccountId;
|
||||
|
||||
$where = [];
|
||||
if (!empty($wechatChatroomId)) {
|
||||
@@ -375,8 +998,8 @@ class MessageController extends BaseController
|
||||
$where[] = ['wechatFriendId', '=', $wechatFriendId];
|
||||
}
|
||||
|
||||
if (!empty($From) && !empty($To)) {
|
||||
$where[] = ['wechatTime', 'between', [$from, $to]];
|
||||
if ($from !== '' && $to !== '' && is_numeric($from) && is_numeric($to)) {
|
||||
$where[] = ['wechatTime', 'between', [(int)$from, (int)$to]];
|
||||
}
|
||||
|
||||
$total = Db::table('s2_wechat_message')->where($where)->count();
|
||||
@@ -521,4 +1144,4 @@ class MessageController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace app\chukebao\controller;
|
||||
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
use think\facade\Env;
|
||||
|
||||
/**
|
||||
* 模块 E · 快捷语扩展(需求六 §6.5)
|
||||
@@ -156,7 +155,15 @@ class QuickReplyExtController extends BaseController
|
||||
$targetGroupId = (int)$this->request->param('targetGroupId', 0);
|
||||
$deleteSource = (bool)$this->request->param('deleteSource', false);
|
||||
|
||||
// 校验/落地公司目标分组
|
||||
return $this->doPromoteToCompany($companyId, $userId, $ids, $targetGroupId, $deleteSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $ids
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
private function doPromoteToCompany(int $companyId, int $userId, array $ids, int $targetGroupId, bool $deleteSource)
|
||||
{
|
||||
if ($targetGroupId > 0) {
|
||||
$tg = Db::name('kf_reply_group')
|
||||
->where('id', $targetGroupId)
|
||||
@@ -226,6 +233,344 @@ class QuickReplyExtController extends BaseController
|
||||
], "已转移 {$okCount} 条到公司快捷语");
|
||||
}
|
||||
|
||||
/**
|
||||
* 整组快捷语互拷(REQ-TKB-120)
|
||||
* POST /v1/kefu/quick-reply/copy-group
|
||||
* body: { groupId, direction: to-company|to-personal, targetGroupId?, deleteSource? }
|
||||
*/
|
||||
public function copyGroup()
|
||||
{
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$userId = (int)$this->getUserInfo('id');
|
||||
$groupId = (int)$this->request->param('groupId', 0);
|
||||
$direction = (string)$this->request->param('direction', '');
|
||||
$targetGroupId = (int)$this->request->param('targetGroupId', 0);
|
||||
$deleteSource = (bool)$this->request->param('deleteSource', false);
|
||||
|
||||
if ($groupId <= 0 || !in_array($direction, ['to-company', 'to-personal'], true)) {
|
||||
return ResponseHelper::error('参数错误', 400);
|
||||
}
|
||||
|
||||
$srcGroup = Db::name('kf_reply_group')
|
||||
->where('id', $groupId)
|
||||
->where('companyId', $companyId)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
if (!$srcGroup) {
|
||||
return ResponseHelper::error('源分组不存在', 404);
|
||||
}
|
||||
|
||||
$replies = Db::name('kf_reply')
|
||||
->where('groupId', $groupId)
|
||||
->where('isDel', 0)
|
||||
->order('sortIndex asc,id asc')
|
||||
->select();
|
||||
if (empty($replies)) {
|
||||
return ResponseHelper::error('该分组下没有可复制的快捷语', 400);
|
||||
}
|
||||
|
||||
$replyIds = array_map(function ($r) {
|
||||
return (int)$r['id'];
|
||||
}, $replies ?: []);
|
||||
|
||||
if ($direction === 'to-company') {
|
||||
if ((int)$srcGroup['replyType'] !== 1 || (int)$srcGroup['userId'] !== $userId) {
|
||||
return ResponseHelper::error('仅支持将本人个人分组复制到公司', 400);
|
||||
}
|
||||
if ($targetGroupId <= 0) {
|
||||
$targetGroupId = $this->ensureCompanyMirrorGroup($companyId, $userId, (string)$srcGroup['groupName']);
|
||||
}
|
||||
$resp = $this->doPromoteToCompany($companyId, $userId, $replyIds, $targetGroupId, $deleteSource);
|
||||
if ($deleteSource) {
|
||||
$this->softDeleteReplyGroup($groupId, time());
|
||||
}
|
||||
return $resp;
|
||||
}
|
||||
|
||||
// to-personal:公司 → 个人
|
||||
if ((int)$srcGroup['replyType'] !== 2) {
|
||||
return ResponseHelper::error('仅支持将公司分组复制到个人', 400);
|
||||
}
|
||||
if ($targetGroupId <= 0) {
|
||||
$targetGroupId = $this->ensurePersonalMirrorGroup($companyId, $userId, (string)$srcGroup['groupName']);
|
||||
} else {
|
||||
$tg = Db::name('kf_reply_group')
|
||||
->where('id', $targetGroupId)
|
||||
->where('companyId', $companyId)
|
||||
->where('replyType', 1)
|
||||
->where('userId', $userId)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
if (!$tg) {
|
||||
return ResponseHelper::error('目标个人分组不存在', 404);
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$okCount = 0;
|
||||
foreach ($replies as $reply) {
|
||||
Db::name('kf_reply')->insert([
|
||||
'groupId' => $targetGroupId,
|
||||
'title' => $reply['title'],
|
||||
'msgType' => (int)$reply['msgType'],
|
||||
'content' => $reply['content'],
|
||||
'sortIndex' => $reply['sortIndex'] ?? 50,
|
||||
'createTime' => $now,
|
||||
'lastUpdateTime' => $now,
|
||||
'userId' => $userId,
|
||||
'isDel' => 0,
|
||||
]);
|
||||
$okCount++;
|
||||
if ($deleteSource) {
|
||||
Db::name('kf_reply')->where('id', (int)$reply['id'])->update([
|
||||
'isDel' => 1,
|
||||
'delTime' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteSource) {
|
||||
$this->softDeleteReplyGroup($groupId, $now);
|
||||
}
|
||||
|
||||
return ResponseHelper::success([
|
||||
'copied' => $okCount,
|
||||
'targetGroupId' => $targetGroupId,
|
||||
'deleteSource' => $deleteSource,
|
||||
'direction' => $direction,
|
||||
], "已复制 {$okCount} 条到个人快捷语");
|
||||
}
|
||||
|
||||
private function softDeleteReplyGroup(int $groupId, int $now): void
|
||||
{
|
||||
Db::name('kf_reply_group')->where('id', $groupId)->update([
|
||||
'isDel' => 1,
|
||||
'delTime' => $now,
|
||||
]);
|
||||
Db::name('kf_reply')->where('groupId', $groupId)->update([
|
||||
'isDel' => 1,
|
||||
'delTime' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 整理分组话术(五步漏斗 + 补充归类,REQ-TKB-120)
|
||||
* POST /v1/kefu/quick-reply/ai-organize-group body: { groupId }
|
||||
*/
|
||||
public function aiOrganizeGroup()
|
||||
{
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$userId = (int)$this->getUserInfo('id');
|
||||
$groupId = (int)$this->request->param('groupId', 0);
|
||||
if ($groupId <= 0) {
|
||||
return ResponseHelper::error('请选择分组', 400);
|
||||
}
|
||||
|
||||
$group = Db::name('kf_reply_group')
|
||||
->where('id', $groupId)
|
||||
->where('companyId', $companyId)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
if (!$group) {
|
||||
return ResponseHelper::error('分组不存在', 404);
|
||||
}
|
||||
if ((int)$group['replyType'] === 1 && (int)$group['userId'] !== $userId) {
|
||||
return ResponseHelper::error('无权整理该个人分组', 403);
|
||||
}
|
||||
|
||||
$replies = Db::name('kf_reply')
|
||||
->where('groupId', $groupId)
|
||||
->where('isDel', 0)
|
||||
->order('sortIndex asc,id asc')
|
||||
->select();
|
||||
if (empty($replies)) {
|
||||
return ResponseHelper::error('分组内无话术可整理', 400);
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach ($replies as $r) {
|
||||
$payload[] = [
|
||||
'id' => (int)$r['id'],
|
||||
'title' => (string)$r['title'],
|
||||
'msgType' => (int)$r['msgType'],
|
||||
'content' => (string)$r['content'],
|
||||
];
|
||||
}
|
||||
|
||||
$sys = "你是私域销售话术整理专家。参考苹果 Apple Store 在线客服/iMessage 导购话术风格:"
|
||||
. "短句、利益清晰、结尾用轻问句促回复。"
|
||||
. "\n将用户提供的快捷语重组为【五步销售漏斗】+【补充话术】。"
|
||||
. "\n输出严格 JSON(不要 markdown):"
|
||||
. "{\"replies\":[{\"id\":数字,\"title\":\"标题\",\"content\":\"正文\",\"sortIndex\":数字}]}"
|
||||
. "\n规则:"
|
||||
. "\n1) 必须产出 5 条核心话术,标题分别为:「1. 打招呼」「2. 产品介绍」「3. 报价说明」「4. 异议处理·觉得贵」「5. 沉默跟进·B单唤醒」"
|
||||
. "\n2) 第2条须有明确行动号召(CTA),引导对方回复"
|
||||
. "\n3) 第3条写清价格/套餐"
|
||||
. "\n4) 第4条处理嫌贵"
|
||||
. "\n5) 第5条久未回复跟进"
|
||||
. "\n6) 原有多余文本条目标题用「6. 补充-」前缀,sortIndex 按 10,20,30… 递增"
|
||||
. "\n7) 仅改写 msgType=1 的 content/title;msgType 非 1 的保持 title/content 不变,排在列表末尾"
|
||||
. "\n8) 每条 id 必须来自输入,不可编造新 id";
|
||||
|
||||
$aiRes = $this->callQuickReplyAi($sys, json_encode([
|
||||
'groupName' => $group['groupName'],
|
||||
'replies' => $payload,
|
||||
], 256), $companyId, $userId);
|
||||
if ($aiRes['error'] !== '') {
|
||||
return ResponseHelper::error($aiRes['error'], 500);
|
||||
}
|
||||
|
||||
$parsed = $aiRes['parsed'];
|
||||
$items = $parsed['replies'] ?? [];
|
||||
if (!is_array($items) || empty($items)) {
|
||||
return ResponseHelper::error('AI 未返回有效结构,请重试', 500);
|
||||
}
|
||||
|
||||
$idMap = [];
|
||||
foreach ($replies as $r) {
|
||||
$idMap[(int)$r['id']] = true;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
$now = time();
|
||||
foreach ($items as $item) {
|
||||
$rid = (int)($item['id'] ?? 0);
|
||||
if ($rid <= 0 || !isset($idMap[$rid])) {
|
||||
continue;
|
||||
}
|
||||
$row = [];
|
||||
if (!empty($item['title'])) {
|
||||
$row['title'] = mb_substr((string)$item['title'], 0, 120);
|
||||
}
|
||||
if (isset($item['content'])) {
|
||||
$row['content'] = (string)$item['content'];
|
||||
}
|
||||
if (isset($item['sortIndex'])) {
|
||||
$row['sortIndex'] = (string)(int)$item['sortIndex'];
|
||||
}
|
||||
if (empty($row)) {
|
||||
continue;
|
||||
}
|
||||
$row['lastUpdateTime'] = $now;
|
||||
Db::name('kf_reply')->where('id', $rid)->update($row);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return ResponseHelper::success([
|
||||
'updated' => $updated,
|
||||
'groupId' => $groupId,
|
||||
'preview' => array_slice($items, 0, 12),
|
||||
'cost' => (int)($aiRes['deducted'] ?? 0),
|
||||
], "已整理 {$updated} 条话术");
|
||||
}
|
||||
|
||||
/** @return array{error:string,parsed:?array,deducted?:int} */
|
||||
private function callQuickReplyAi(string $system, string $userJson, int $companyId, int $userId, bool $allowPlainText = false): array
|
||||
{
|
||||
$gw = \app\common\service\AiGatewayService::complete(
|
||||
'ai_rewrite',
|
||||
[
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $userJson],
|
||||
],
|
||||
$companyId,
|
||||
$userId,
|
||||
'',
|
||||
['maxTokens' => 8192]
|
||||
);
|
||||
if (empty($gw['ok'])) {
|
||||
return [
|
||||
'error' => (string)($gw['error'] ?? 'AI 网关调用失败'),
|
||||
'parsed' => null,
|
||||
'deducted' => (int)($gw['deducted'] ?? 0),
|
||||
];
|
||||
}
|
||||
$content = (string)($gw['content'] ?? '');
|
||||
$parsed = null;
|
||||
if (preg_match('/\{[\s\S]*\}/', $content, $m)) {
|
||||
$parsed = json_decode($m[0], true);
|
||||
}
|
||||
if (!is_array($parsed) && $allowPlainText) {
|
||||
$parsed = $this->plainTextQuickReplyGroups($content);
|
||||
}
|
||||
if (!is_array($parsed)) {
|
||||
return ['error' => 'AI 返回无法解析为 JSON', 'parsed' => null, 'deducted' => (int)($gw['deducted'] ?? 0)];
|
||||
}
|
||||
return ['error' => '', 'parsed' => $parsed, 'deducted' => (int)($gw['deducted'] ?? 0)];
|
||||
}
|
||||
|
||||
private function plainTextQuickReplyGroups(string $content): ?array
|
||||
{
|
||||
$replies = [];
|
||||
$lines = preg_split('/\R/u', trim($content)) ?: [];
|
||||
foreach ($lines as $line) {
|
||||
$line = trim((string)$line);
|
||||
$line = preg_replace('/^(?:[-*]\s*|\d+[.、)]\s*)/u', '', $line);
|
||||
$line = trim((string)$line, " \t\n\r\0\x0B*#");
|
||||
if ($line === '' || mb_strlen($line) < 4 || strpos($line, '结论') === 0) {
|
||||
continue;
|
||||
}
|
||||
$replies[] = [
|
||||
'title' => '话术' . (count($replies) + 1),
|
||||
'content' => mb_substr($line, 0, 500),
|
||||
];
|
||||
if (count($replies) >= 12) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (empty($replies)) {
|
||||
return null;
|
||||
}
|
||||
return ['groups' => [['groupName' => 'AI 生成话术', 'replies' => $replies]]];
|
||||
}
|
||||
|
||||
private function ensureCompanyMirrorGroup(int $companyId, int $userId, string $srcName): int
|
||||
{
|
||||
$name = '来自个人·' . $srcName;
|
||||
$exist = Db::name('kf_reply_group')
|
||||
->where('companyId', $companyId)
|
||||
->where('replyType', 2)
|
||||
->where('groupName', $name)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
if ($exist) {
|
||||
return (int)$exist['id'];
|
||||
}
|
||||
return (int)Db::name('kf_reply_group')->insertGetId([
|
||||
'groupName' => $name,
|
||||
'parentId' => 0,
|
||||
'replyType' => 2,
|
||||
'sortIndex' => 50,
|
||||
'companyId' => $companyId,
|
||||
'userId' => $userId,
|
||||
'isDel' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensurePersonalMirrorGroup(int $companyId, int $userId, string $srcName): int
|
||||
{
|
||||
$name = '来自公司·' . $srcName;
|
||||
$exist = Db::name('kf_reply_group')
|
||||
->where('companyId', $companyId)
|
||||
->where('replyType', 1)
|
||||
->where('userId', $userId)
|
||||
->where('groupName', $name)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
if ($exist) {
|
||||
return (int)$exist['id'];
|
||||
}
|
||||
return (int)Db::name('kf_reply_group')->insertGetId([
|
||||
'groupName' => $name,
|
||||
'parentId' => 0,
|
||||
'replyType' => 1,
|
||||
'sortIndex' => 50,
|
||||
'companyId' => $companyId,
|
||||
'userId' => $userId,
|
||||
'isDel' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 确保存在公司侧「个人转入」默认分组(replyType=2),返回分组 id */
|
||||
private function ensureCompanyInboxGroup(int $companyId, int $userId): int
|
||||
{
|
||||
@@ -354,50 +699,22 @@ class QuickReplyExtController extends BaseController
|
||||
if ($productDesc === '') {
|
||||
return ResponseHelper::error('请输入产品描述', 400);
|
||||
}
|
||||
$apiUrl = Env::get('doubaoAi.api_url');
|
||||
$apiKey = Env::get('doubaoAi.api_key');
|
||||
if (empty($apiUrl) || empty($apiKey)) {
|
||||
return ResponseHelper::error('AI 服务未配置(doubaoAi),请联系管理员', 500);
|
||||
}
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$userId = $this->getUserInfo('id');
|
||||
|
||||
$sys = "你是私域客服话术专家。根据【产品描述】生成一套微信客服快捷语目录,"
|
||||
. "输出严格 JSON:{\"groups\":[{\"groupName\":\"分组名\",\"replies\":[{\"title\":\"标题\",\"content\":\"话术正文\"}]}]},"
|
||||
. "不要输出 JSON 以外任何内容。分组 3-5 个,每组 3-6 条。";
|
||||
$params = [
|
||||
'model' => 'doubao-seed-1-8-251215',
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $sys],
|
||||
['role' => 'user', 'content' => '【产品描述】' . $productDesc],
|
||||
],
|
||||
];
|
||||
$headers = ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey];
|
||||
try {
|
||||
$raw = requestCurl($apiUrl . '/api/v3/chat/completions', $params, 'POST', $headers, 'json');
|
||||
$res = json_decode($raw, true);
|
||||
if (isset($res['error'])) {
|
||||
return ResponseHelper::error('AI 生成失败:' . ($res['error']['message'] ?? ''), 500);
|
||||
}
|
||||
$content = $res['choices'][0]['message']['content'] ?? '';
|
||||
// 提取 JSON
|
||||
if (preg_match('/\{[\s\S]*\}/', $content, $m)) {
|
||||
$parsed = json_decode($m[0], true);
|
||||
} else {
|
||||
$parsed = null;
|
||||
}
|
||||
// 扣算力:统一走 TokensService 规则(AI 改写 action=12)
|
||||
$cost = 0;
|
||||
try {
|
||||
$svc = new \app\cunkebao\service\TokensService((int)$companyId, (int)$userId);
|
||||
$res3 = $svc->consume(\app\cunkebao\service\TokensService::ACTION_AI_REWRITE, 'AI生成快捷语', 1);
|
||||
$cost = (int)($res3['deducted'] ?? 0);
|
||||
} catch (\Exception $e) {
|
||||
$aiRes = $this->callQuickReplyAi($sys, '【产品描述】' . $productDesc, (int)$companyId, (int)$userId, true);
|
||||
if ($aiRes['error'] !== '') {
|
||||
return ResponseHelper::error('AI 生成失败:' . $aiRes['error'], 500);
|
||||
}
|
||||
$parsed = $aiRes['parsed'];
|
||||
return ResponseHelper::success([
|
||||
'groups' => $parsed['groups'] ?? [],
|
||||
'raw' => $parsed ? null : $content,
|
||||
'cost' => $cost,
|
||||
'raw' => null,
|
||||
'cost' => (int)($aiRes['deducted'] ?? 0),
|
||||
], '已生成');
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('AI 调用异常:' . $e->getMessage(), 500);
|
||||
|
||||
@@ -94,6 +94,9 @@ class SettingsKefuController extends BaseController
|
||||
'defaultEnabled' => $tk['defaultEnabled'] ?? false,
|
||||
'takeoverDelay' => $tk['takeoverDelay'] ?? 30,
|
||||
'defaultKbId' => $tk['defaultKbId'] ?? null,
|
||||
'kbIds' => array_values(array_map('intval', $tk['kbIds'] ?? [])),
|
||||
'contentLibraryIds' => array_values(array_map('intval', $tk['contentLibraryIds'] ?? [])),
|
||||
'contentLibraries' => $tk['contentLibraries'] ?? [],
|
||||
'costTip' => $tk['costTip'] ?? true,
|
||||
]);
|
||||
}
|
||||
@@ -103,10 +106,45 @@ class SettingsKefuController extends BaseController
|
||||
{
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$kbIds = $this->request->param('kbIds/a', []);
|
||||
if (!is_array($kbIds)) {
|
||||
$kbIds = [];
|
||||
}
|
||||
$kbIds = array_values(array_unique(array_filter(array_map('intval', $kbIds))));
|
||||
|
||||
$contentLibraryIds = $this->request->param('contentLibraryIds/a', []);
|
||||
if (!is_array($contentLibraryIds)) {
|
||||
$contentLibraryIds = [];
|
||||
}
|
||||
$contentLibraryIds = array_values(array_unique(array_filter(array_map('intval', $contentLibraryIds))));
|
||||
|
||||
$contentLibraries = $this->request->param('contentLibraries/a', []);
|
||||
if (!is_array($contentLibraries)) {
|
||||
$contentLibraries = [];
|
||||
}
|
||||
|
||||
$defaultKbId = $this->request->param('defaultKbId', null);
|
||||
if ($defaultKbId !== null && $defaultKbId !== '') {
|
||||
$defaultKbId = (int)$defaultKbId;
|
||||
} else {
|
||||
$defaultKbId = null;
|
||||
}
|
||||
if ($defaultKbId && !empty($kbIds) && !in_array($defaultKbId, $kbIds, true)) {
|
||||
$defaultKbId = $kbIds[0];
|
||||
}
|
||||
if (!$defaultKbId && !empty($kbIds)) {
|
||||
$defaultKbId = $kbIds[0];
|
||||
}
|
||||
|
||||
$tk = [
|
||||
'defaultEnabled' => (bool)$this->request->param('defaultEnabled', false),
|
||||
'takeoverDelay' => (int)$this->request->param('takeoverDelay', 30),
|
||||
'defaultKbId' => $this->request->param('defaultKbId', null),
|
||||
'defaultKbId' => $defaultKbId,
|
||||
'kbIds' => $kbIds,
|
||||
'contentLibraryIds' => $contentLibraryIds,
|
||||
'contentLibraries' => array_values(array_filter($contentLibraries, function ($row) {
|
||||
return is_array($row) && !empty($row['id']);
|
||||
})),
|
||||
'costTip' => (bool)$this->request->param('costTip', true),
|
||||
];
|
||||
$row = Db::name('ai_settings')->where(['userId' => $userId, 'companyId' => $companyId])->find();
|
||||
@@ -124,6 +162,72 @@ class SettingsKefuController extends BaseController
|
||||
'updateTime' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 同步客服当前默认知识库(供旧接口只读)
|
||||
if ($defaultKbId) {
|
||||
$this->syncKefuCurrentKb($companyId, $userId, (int)$defaultKbId);
|
||||
}
|
||||
|
||||
return ResponseHelper::success(true, '已保存');
|
||||
}
|
||||
|
||||
/** 写入 ck_kb_touchkebao_config 客服级 currentKbId */
|
||||
private function syncKefuCurrentKb(int $companyId, int $kefuId, int $kbId): void
|
||||
{
|
||||
try {
|
||||
Db::execute("CREATE TABLE IF NOT EXISTS `ck_kb_touchkebao_config` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`companyId` int(11) NOT NULL DEFAULT 0,
|
||||
`kbId` int(11) NOT NULL DEFAULT 0,
|
||||
`kbName` varchar(128) NOT NULL DEFAULT '',
|
||||
`isDefault` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`kefuId` int(11) NOT NULL DEFAULT 0,
|
||||
`currentKbId` int(11) NOT NULL DEFAULT 0,
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`updateTime` int(11) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_company` (`companyId`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
$exist = Db::name('kb_touchkebao_config')
|
||||
->where('companyId', $companyId)
|
||||
->where('kefuId', $kefuId)
|
||||
->find();
|
||||
if ($exist) {
|
||||
Db::name('kb_touchkebao_config')->where('id', $exist['id'])
|
||||
->update(['currentKbId' => $kbId, 'updateTime' => time()]);
|
||||
} else {
|
||||
Db::name('kb_touchkebao_config')->insert([
|
||||
'companyId' => $companyId,
|
||||
'kefuId' => $kefuId,
|
||||
'currentKbId' => $kbId,
|
||||
'enabled' => 0,
|
||||
'updateTime' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/kefu/settings/map-config
|
||||
* 地图 JS API Key · 解析优先级:项目(companyId)覆盖 → 超管全局 → Server .env [TENCENT_MAP]
|
||||
*/
|
||||
public function mapConfig()
|
||||
{
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$cfg = \app\common\service\MapConfigService::resolve($companyId);
|
||||
$provider = $cfg['provider'] ?? 'amap';
|
||||
$key = trim((string)($cfg['key'] ?? ''));
|
||||
$securityCode = trim((string)($cfg['securityCode'] ?? ''));
|
||||
|
||||
// 触客宝端:有 Key 即下发,不在客户端展示「未配置/超管设置」类提示(probe 仅超管后台用)
|
||||
return ResponseHelper::success([
|
||||
'provider' => $provider,
|
||||
'key' => $key,
|
||||
'securityCode' => $securityCode,
|
||||
'valid' => $key !== '',
|
||||
'reason' => '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,12 @@ class TokensRecordController extends BaseController
|
||||
$item['scenePack'] = 'sys_sync';
|
||||
} else {
|
||||
$rule = \app\cunkebao\service\TokensService::ruleOf($form);
|
||||
$item['displayName'] = $rule['displayName'] ?? ($rule['name'] ?? '算力消耗');
|
||||
$dn = (string)($rule['displayName'] ?? '');
|
||||
// 149-B5:未登记目录的 form 不展示技术占位「unknown」,回退人话标题
|
||||
if ($dn === '' || $dn === 'unknown' || (string)($rule['name'] ?? '') === 'unknown') {
|
||||
$dn = $form > 0 ? ('算力消耗 #' . $form) : '算力消耗';
|
||||
}
|
||||
$item['displayName'] = $dn;
|
||||
$item['scenePack'] = $rule['scenePack'] ?? 'sys_sync';
|
||||
}
|
||||
// 变化量带符号:扣减为负,充值为正
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\chukebao\service\CompanyWechatScopeService;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
@@ -13,7 +14,7 @@ use think\Db;
|
||||
*/
|
||||
class TransferController extends BaseController
|
||||
{
|
||||
private const SUPPORTED_TYPES = ['session', 'wechat_account', 'contact_group'];
|
||||
private const SUPPORTED_TYPES = ['session', 'wechat_account', 'contact_group', 'device'];
|
||||
|
||||
/** 数字微信号账号 id → wechatId(整号转移以 wechatId 为 targetId 口径,与 allot 一致) */
|
||||
private function resolveWechatId(int $accountId): string
|
||||
@@ -21,6 +22,19 @@ class TransferController extends BaseController
|
||||
return (string)Db::table('s2_wechat_account')->where('id', $accountId)->value('wechatId');
|
||||
}
|
||||
|
||||
private function scopedAccountIds(): array
|
||||
{
|
||||
return CompanyWechatScopeService::resolveKefuSidebarAccountIds(
|
||||
(int)$this->getUserInfo('companyId'),
|
||||
(int)$this->getUserInfo('s2_accountId')
|
||||
);
|
||||
}
|
||||
|
||||
private function canAccessAccount(int $accountId): bool
|
||||
{
|
||||
return $accountId > 0 && in_array($accountId, $this->scopedAccountIds(), true);
|
||||
}
|
||||
|
||||
private function createApply($type)
|
||||
{
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
@@ -29,13 +43,23 @@ class TransferController extends BaseController
|
||||
$toAccountId = trim((string)$this->request->param('toAccountId', $this->request->param('toKefuId', '')));
|
||||
$reason = (string)$this->request->param('reason', '');
|
||||
|
||||
// targetId 口径:wechat_account → wechatId;session → wechatFriendId;contact_group → groupId
|
||||
$targetName = trim((string)$this->request->param('targetName', ''));
|
||||
|
||||
// targetId 口径:wechat_account → wechatId;session → wechatFriendId;contact_group → groupId;device → deviceId
|
||||
if ($type === 'wechat_account') {
|
||||
$accId = (int)$this->request->param('wechatAccountId', 0);
|
||||
if (!$this->canAccessAccount($accId)) {
|
||||
return ResponseHelper::error('微信号不存在或无权限', 404);
|
||||
}
|
||||
$targetId = $this->resolveWechatId($accId);
|
||||
if ($targetId === '') {
|
||||
return ResponseHelper::error('微信号不存在,无法转移', 400);
|
||||
}
|
||||
if ($targetName === '') {
|
||||
$targetName = (string)Db::table('s2_wechat_account')
|
||||
->where('id', $accId)
|
||||
->value('nickname');
|
||||
}
|
||||
} elseif ($type === 'contact_group') {
|
||||
$groupType = (int)$this->request->param('groupType', 1);
|
||||
if ($groupType !== 1) {
|
||||
@@ -45,9 +69,56 @@ class TransferController extends BaseController
|
||||
if ($wechatAccountId <= 0) {
|
||||
return ResponseHelper::error('wechatAccountId 缺失', 400);
|
||||
}
|
||||
$targetId = (string)(int)$this->request->param('groupId', 0);
|
||||
if (!$this->canAccessAccount($wechatAccountId)) {
|
||||
return ResponseHelper::error('微信号不存在或无权限', 404);
|
||||
}
|
||||
$groupId = (int)$this->request->param('groupId', 0);
|
||||
$group = Db::table('s2_chat_groups')
|
||||
->where('id', $groupId)
|
||||
->where('companyId', $companyId)
|
||||
->where('groupType', 1)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
if (!$group) {
|
||||
return ResponseHelper::error('好友分组不存在或无权限', 404);
|
||||
}
|
||||
$targetId = (string)$groupId;
|
||||
if ($targetName === '') {
|
||||
$targetName = (string)($group['groupName'] ?? '');
|
||||
}
|
||||
} elseif ($type === 'device') {
|
||||
$deviceId = (int)$this->request->param('deviceId', 0);
|
||||
if ($deviceId <= 0) {
|
||||
return ResponseHelper::error('deviceId 缺失', 400);
|
||||
}
|
||||
$device = Db::name('device')
|
||||
->where('id', $deviceId)
|
||||
->where('companyId', $companyId)
|
||||
->where('deleteTime', 0)
|
||||
->find();
|
||||
if (!$device) {
|
||||
return ResponseHelper::error('设备不存在或无权限', 404);
|
||||
}
|
||||
$targetId = (string)$deviceId;
|
||||
if ($targetName === '') {
|
||||
$targetName = trim((string)($device['memo'] ?? '')) ?: ('设备#' . $deviceId);
|
||||
}
|
||||
} else {
|
||||
$targetId = (string)$this->request->param('wechatFriendId', 0);
|
||||
$friendId = (int)$this->request->param('wechatFriendId', 0);
|
||||
$accountIds = $this->scopedAccountIds();
|
||||
$friend = empty($accountIds) ? null : Db::table('s2_wechat_friend')
|
||||
->where('id', $friendId)
|
||||
->whereIn('wechatAccountId', $accountIds)
|
||||
->where('isDeleted', 0)
|
||||
->find();
|
||||
if (!$friend) {
|
||||
return ResponseHelper::error('好友不存在或无权限', 404);
|
||||
}
|
||||
$targetId = (string)$friendId;
|
||||
if ($targetName === '') {
|
||||
$targetName = trim((string)($friend['conRemark'] ?? ''))
|
||||
?: trim((string)($friend['nickname'] ?? ''));
|
||||
}
|
||||
}
|
||||
if ($toAccountId === '' || $targetId === '' || $targetId === '0') {
|
||||
return ResponseHelper::error('参数缺失(目标客服/对象)', 400);
|
||||
@@ -59,6 +130,9 @@ class TransferController extends BaseController
|
||||
->where('departmentId', $companyId)
|
||||
->field('id, userName, realName, nickname')
|
||||
->find();
|
||||
if (!$toAccount) {
|
||||
return ResponseHelper::error('目标客服不存在或不属于当前项目', 404);
|
||||
}
|
||||
$toAccountName = $toAccount
|
||||
? (($toAccount['realName'] ?: $toAccount['nickname'] ?: $toAccount['userName']) ?: '')
|
||||
: '';
|
||||
@@ -80,6 +154,14 @@ class TransferController extends BaseController
|
||||
->where('groupIds', (int)$targetId)
|
||||
->where('isDeleted', 0)
|
||||
->count();
|
||||
} elseif ($type === 'device') {
|
||||
$wechatIds = $this->deviceWechatIds((int)$targetId, $companyId);
|
||||
if (!empty($wechatIds)) {
|
||||
$affected = (int)Db::table('s2_wechat_friend')
|
||||
->where('ownerWechatId', 'in', $wechatIds)
|
||||
->where('isDeleted', 0)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
@@ -93,7 +175,7 @@ class TransferController extends BaseController
|
||||
'companyId' => $companyId,
|
||||
'type' => $type,
|
||||
'targetId' => $targetId,
|
||||
'targetName' => (string)$this->request->param('targetName', ''),
|
||||
'targetName' => $targetName,
|
||||
'toAccountId' => $toAccountId,
|
||||
'toAccountName' => $toAccountName,
|
||||
'applicantUserId' => $applicantUserId,
|
||||
@@ -105,11 +187,57 @@ class TransferController extends BaseController
|
||||
'updateTime' => $now,
|
||||
]);
|
||||
|
||||
$order = Db::name('wechat_account_transfer')->where('id', $applyId)->find();
|
||||
|
||||
// CKB-132 默认通过:申请人与接收方为同一客服时,提交即执行(无需再等审批)
|
||||
$applicantRow = Db::name('users')->where('id', $applicantUserId)->where('deleteTime', 0)->find();
|
||||
$applicantAccId = $applicantRow ? (string)($applicantRow['s2_accountId'] ?? '') : '';
|
||||
if ($order && $applicantAccId !== '' && $applicantAccId === (string)$toAccountId) {
|
||||
[$ok, $executed, $err, $total] = \app\common\service\transfer\TransferExecuteService::execute(
|
||||
$order,
|
||||
$applicantUserId
|
||||
);
|
||||
$isPartial = $ok && $total > 0 && $executed < $total;
|
||||
$statusVal = !$ok ? 'pending' : ($isPartial ? 'partial' : 'approved');
|
||||
Db::name('wechat_account_transfer')->where('id', $applyId)->update([
|
||||
'status' => $statusVal,
|
||||
'executedCount' => $executed,
|
||||
'executeError' => $err ? mb_substr($err, 0, 480) : '',
|
||||
'acceptedByUserId' => $applicantUserId,
|
||||
'acceptedTime' => $ok ? time() : 0,
|
||||
'reviewTime' => $ok ? time() : 0,
|
||||
'updateTime' => time(),
|
||||
]);
|
||||
if ($ok) {
|
||||
$msg = $isPartial
|
||||
? ('已自动完成转移(' . $executed . '/' . $total . ')')
|
||||
: ('已自动完成转移,共 ' . $executed . ' 位好友');
|
||||
return ResponseHelper::success([
|
||||
'applyId' => (int)$applyId,
|
||||
'status' => $statusVal,
|
||||
'executedCount' => $executed,
|
||||
'affectedFriendCount' => $affected,
|
||||
], $msg);
|
||||
}
|
||||
}
|
||||
|
||||
// CKB-132.1:申请创建后给接收方发「转移待确认」kf_notice(type=11),接收方可在转移列表接受/拒绝
|
||||
try {
|
||||
\app\chukebao\service\TransferArrivalNoticeService::notifyPendingConfirm(
|
||||
$companyId,
|
||||
(int)$toAccountId,
|
||||
$targetName,
|
||||
$applicantName,
|
||||
(int)$applyId
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
return ResponseHelper::success([
|
||||
'applyId' => (int)$applyId,
|
||||
'status' => 'pending',
|
||||
'affectedFriendCount' => $affected,
|
||||
], '转移申请已提交,等待存客宝审批');
|
||||
], '转移申请已提交,接收方确认后即可完成转移');
|
||||
}
|
||||
|
||||
/** POST /v1/kefu/wechat-account/transfer/apply */
|
||||
@@ -147,9 +275,19 @@ class TransferController extends BaseController
|
||||
$page = max(1, (int)$this->request->param('page', 1));
|
||||
$limit = min(100, max(1, (int)$this->request->param('limit', 20)));
|
||||
|
||||
// CKB-132.2:接收方(toAccountId == 我的 s2 账号)也要能看到「待我接受」的申请
|
||||
$me = Db::name('users')->where('id', $kefuId)->where('deleteTime', 0)->find();
|
||||
$myAccId = $me ? (string)($me['s2_accountId'] ?? '') : '';
|
||||
|
||||
$query = Db::name('wechat_account_transfer')->where('companyId', $companyId);
|
||||
if (!$isAdmin) {
|
||||
$query->where('applicantUserId', $kefuId);
|
||||
if ($myAccId !== '') {
|
||||
$query->where(function ($q) use ($kefuId, $myAccId) {
|
||||
$q->where('applicantUserId', $kefuId)->whereOr('toAccountId', $myAccId);
|
||||
});
|
||||
} else {
|
||||
$query->where('applicantUserId', $kefuId);
|
||||
}
|
||||
}
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$query->where('status', $status);
|
||||
@@ -161,8 +299,10 @@ class TransferController extends BaseController
|
||||
->page($page, $limit)
|
||||
->select();
|
||||
|
||||
$statusLabelMap = ['pending' => '待处理', 'accepted' => '执行中', 'approved' => '已通过', 'partial' => '部分成功', 'rejected' => '已驳回', 'failed' => '执行失败'];
|
||||
$list = [];
|
||||
foreach ($rows ?: [] as $row) {
|
||||
$isRecipient = $myAccId !== '' && (string)($row['toAccountId'] ?? '') === $myAccId;
|
||||
$list[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'type' => (string)($row['type'] ?? ''),
|
||||
@@ -175,7 +315,14 @@ class TransferController extends BaseController
|
||||
'reason' => (string)($row['reason'] ?? ''),
|
||||
'affectedFriendCount' => (int)($row['affectedFriendCount'] ?? 0),
|
||||
'status' => (string)($row['status'] ?? 'pending'),
|
||||
'statusLabel' => $statusLabelMap[(string)($row['status'] ?? 'pending')] ?? (string)($row['status'] ?? ''),
|
||||
'reviewComment' => (string)($row['reviewComment'] ?? ''),
|
||||
'executeError' => (string)($row['executeError'] ?? ''),
|
||||
'executedCount' => (int)($row['executedCount'] ?? 0),
|
||||
// CKB-132.2:本人是接收方且仍 pending → 可「接受」
|
||||
'isRecipient' => $isRecipient,
|
||||
'canAccept' => $isRecipient && (string)($row['status'] ?? '') === 'pending',
|
||||
'acceptedTime' => (int)($row['acceptedTime'] ?? 0),
|
||||
'createTime' => (int)($row['createTime'] ?? 0),
|
||||
'updateTime' => (int)($row['updateTime'] ?? 0),
|
||||
];
|
||||
@@ -196,10 +343,140 @@ class TransferController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/kefu/transfer/accept (CKB-132.2 · 触客宝接收方接受并执行)
|
||||
* 铁律:执行仍走存客宝 Server 同一套 allot(TransferExecuteService),触客宝仅触发与留痕。
|
||||
* 仅「转移目标客服」本人可接受:其 ck_users.s2_accountId == order.toAccountId。
|
||||
*/
|
||||
public function accept()
|
||||
{
|
||||
try {
|
||||
@set_time_limit(180);
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$userId = (int)$this->getUserInfo('id');
|
||||
$id = (int)$this->request->param('id', $this->request->param('applyId', 0));
|
||||
if ($userId <= 0) {
|
||||
return ResponseHelper::error('未登录', 401);
|
||||
}
|
||||
if ($id <= 0) {
|
||||
return ResponseHelper::error('申请ID不能为空', 400);
|
||||
}
|
||||
|
||||
$order = Db::name('wechat_account_transfer')
|
||||
->where('id', $id)->where('companyId', $companyId)->find();
|
||||
if (empty($order)) {
|
||||
return ResponseHelper::error('申请不存在或不属于本项目', 404);
|
||||
}
|
||||
if ($order['status'] !== 'pending') {
|
||||
return ResponseHelper::error('该申请已处理(当前状态:' . $order['status'] . ')', 409);
|
||||
}
|
||||
|
||||
// 接收方身份校验
|
||||
$me = Db::name('users')->where('id', $userId)->where('deleteTime', 0)->find();
|
||||
$myAccId = $me ? (string)($me['s2_accountId'] ?? '') : '';
|
||||
if ($myAccId === '' || (string)$order['toAccountId'] !== $myAccId) {
|
||||
return ResponseHelper::error('仅转移接收方客服可接受本申请', 403);
|
||||
}
|
||||
|
||||
[$ok, $executed, $err, $total] = \app\common\service\transfer\TransferExecuteService::execute($order, $userId);
|
||||
$isPartial = $ok && $total > 0 && $executed < $total;
|
||||
$statusVal = !$ok ? 'pending' : ($isPartial ? 'partial' : 'approved');
|
||||
|
||||
$now = time();
|
||||
Db::name('wechat_account_transfer')->where('id', $id)->update([
|
||||
'status' => $statusVal,
|
||||
'executedCount' => $executed,
|
||||
'executeError' => $err ? mb_substr($err, 0, 480) : '',
|
||||
'acceptedByUserId' => $userId,
|
||||
'acceptedTime' => $now,
|
||||
'reviewerName' => $order['reviewerName'] ?: ((string)($me['username'] ?? '') . '(接受)'),
|
||||
'reviewTime' => $ok ? $now : 0,
|
||||
'updateTime' => $now,
|
||||
]);
|
||||
|
||||
if (!$ok) {
|
||||
return ResponseHelper::error('执行转移失败:' . $err);
|
||||
}
|
||||
if (($order['type'] ?? '') === 'wechat_account') {
|
||||
try {
|
||||
\app\chukebao\service\TransferArrivalNoticeService::notifyApproved(
|
||||
$companyId,
|
||||
(int)($order['toAccountId'] ?? 0),
|
||||
(string)($order['targetName'] ?? $order['targetId'] ?? ''),
|
||||
(int)($executed ?: ($order['affectedFriendCount'] ?? 0)),
|
||||
$id
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
$msg = $isPartial ? ('已接受,部分转移成功(' . $executed . '/' . $total . ')') : '已接受并完成转移';
|
||||
return ResponseHelper::success(
|
||||
['id' => $id, 'status' => $statusVal, 'executedCount' => $executed, 'total' => $total, 'executeError' => $err],
|
||||
$msg
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return ResponseHelper::error($e->getMessage() ?: '接受失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/kefu/transfer/reject (CKB-132.1 · 接收方拒绝转移)
|
||||
* 仅「转移目标客服」本人可拒绝:其 ck_users.s2_accountId == order.toAccountId。
|
||||
* 拒绝不执行 allot,仅置 status=rejected 并留原因/留痕。
|
||||
*/
|
||||
public function reject()
|
||||
{
|
||||
try {
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$userId = (int)$this->getUserInfo('id');
|
||||
$id = (int)$this->request->param('id', $this->request->param('applyId', 0));
|
||||
$reason = trim((string)$this->request->param('reason', ''));
|
||||
if ($userId <= 0) {
|
||||
return ResponseHelper::error('未登录', 401);
|
||||
}
|
||||
if ($id <= 0) {
|
||||
return ResponseHelper::error('申请ID不能为空', 400);
|
||||
}
|
||||
|
||||
$order = Db::name('wechat_account_transfer')
|
||||
->where('id', $id)->where('companyId', $companyId)->find();
|
||||
if (empty($order)) {
|
||||
return ResponseHelper::error('申请不存在或不属于本项目', 404);
|
||||
}
|
||||
if ($order['status'] !== 'pending') {
|
||||
return ResponseHelper::error('该申请已处理(当前状态:' . $order['status'] . ')', 409);
|
||||
}
|
||||
|
||||
$me = Db::name('users')->where('id', $userId)->where('deleteTime', 0)->find();
|
||||
$myAccId = $me ? (string)($me['s2_accountId'] ?? '') : '';
|
||||
if ($myAccId === '' || (string)$order['toAccountId'] !== $myAccId) {
|
||||
return ResponseHelper::error('仅转移接收方客服可拒绝本申请', 403);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
Db::name('wechat_account_transfer')->where('id', $id)->update([
|
||||
'status' => 'rejected',
|
||||
'reviewComment' => $reason !== '' ? mb_substr($reason, 0, 200) : '接收方拒绝',
|
||||
'reviewerName' => (string)($me['username'] ?? '') . '(拒绝)',
|
||||
'reviewTime' => $now,
|
||||
'acceptedByUserId' => $userId,
|
||||
'acceptedTime' => $now,
|
||||
'updateTime' => $now,
|
||||
]);
|
||||
|
||||
return ResponseHelper::success(['id' => $id, 'status' => 'rejected'], '已拒绝该转移申请');
|
||||
} catch (\Throwable $e) {
|
||||
return ResponseHelper::error($e->getMessage() ?: '拒绝失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /v1/kefu/wechat-account/transfer/status?applyId= */
|
||||
public function status()
|
||||
{
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$userId = (int)$this->getUserInfo('id');
|
||||
$isAdmin = (int)($this->getUserInfo('isAdmin') ?? 0) === 1;
|
||||
$s2AccountId = (string)($this->getUserInfo('s2_accountId') ?? '');
|
||||
$applyId = (int)$this->request->param('applyId', 0);
|
||||
if (!$applyId) {
|
||||
return ResponseHelper::error('applyId 缺失', 400);
|
||||
@@ -211,6 +488,13 @@ class TransferController extends BaseController
|
||||
if (!$row) {
|
||||
return ResponseHelper::error('申请不存在', 404);
|
||||
}
|
||||
if (
|
||||
!$isAdmin
|
||||
&& (int)($row['applicantUserId'] ?? 0) !== $userId
|
||||
&& (string)($row['toAccountId'] ?? '') !== $s2AccountId
|
||||
) {
|
||||
return ResponseHelper::error('申请不存在或无权限', 404);
|
||||
}
|
||||
return ResponseHelper::success([
|
||||
'applyId' => (int)$row['id'],
|
||||
'status' => $row['status'],
|
||||
@@ -226,6 +510,9 @@ class TransferController extends BaseController
|
||||
if ($wechatAccountId <= 0) {
|
||||
return ResponseHelper::error('wechatAccountId 缺失', 400);
|
||||
}
|
||||
if (!$this->canAccessAccount($wechatAccountId)) {
|
||||
return ResponseHelper::error('微信号不存在或无权限', 404);
|
||||
}
|
||||
$row = Db::table('s2_wechat_account')
|
||||
->where('id', $wechatAccountId)
|
||||
->field('id,wechatId,nickname,alias')
|
||||
@@ -252,6 +539,39 @@ class TransferController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /v1/kefu/device/transfer/preview?deviceId= */
|
||||
public function devicePreview()
|
||||
{
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$deviceId = (int)$this->request->param('deviceId', 0);
|
||||
if ($deviceId <= 0) {
|
||||
return ResponseHelper::error('deviceId 缺失', 400);
|
||||
}
|
||||
$device = Db::name('device')
|
||||
->where('id', $deviceId)
|
||||
->where('companyId', $companyId)
|
||||
->where('deleteTime', 0)
|
||||
->find();
|
||||
if (!$device) {
|
||||
return ResponseHelper::error('设备不存在或无权限', 404);
|
||||
}
|
||||
$wechatIds = $this->deviceWechatIds($deviceId, $companyId);
|
||||
$count = 0;
|
||||
if (!empty($wechatIds)) {
|
||||
$count = (int)Db::table('s2_wechat_friend')
|
||||
->where('ownerWechatId', 'in', $wechatIds)
|
||||
->where('isDeleted', 0)
|
||||
->count();
|
||||
}
|
||||
$label = trim((string)($device['memo'] ?? '')) ?: ('设备#' . $deviceId);
|
||||
return ResponseHelper::success([
|
||||
'deviceId' => $deviceId,
|
||||
'deviceLabel' => $label,
|
||||
'wechatCount' => count($wechatIds),
|
||||
'count' => $count,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /v1/kefu/wechatGroup/transfer/preview */
|
||||
public function groupPreview()
|
||||
{
|
||||
@@ -264,15 +584,24 @@ class TransferController extends BaseController
|
||||
if ($groupId <= 0 || $wechatAccountId <= 0) {
|
||||
return ResponseHelper::error('参数缺失(groupId/wechatAccountId)', 400);
|
||||
}
|
||||
if (!$this->canAccessAccount($wechatAccountId)) {
|
||||
return ResponseHelper::error('微信号不存在或无权限', 404);
|
||||
}
|
||||
$ownerWechatId = $this->resolveWechatId($wechatAccountId);
|
||||
if ($ownerWechatId === '') {
|
||||
return ResponseHelper::error('微信号不存在', 404);
|
||||
}
|
||||
$companyId = (int)$this->getUserInfo('companyId');
|
||||
$groupName = (string)Db::table('s2_chat_groups')
|
||||
$group = Db::table('s2_chat_groups')
|
||||
->where('companyId', $companyId)
|
||||
->where('id', $groupId)
|
||||
->value('groupName');
|
||||
->where('groupType', 1)
|
||||
->where('isDel', 0)
|
||||
->field('id,groupName')
|
||||
->find();
|
||||
if (!$group) {
|
||||
return ResponseHelper::error('好友分组不存在或无权限', 404);
|
||||
}
|
||||
$count = (int)Db::table('s2_wechat_friend')
|
||||
->where('ownerWechatId', $ownerWechatId)
|
||||
->where('groupIds', $groupId)
|
||||
@@ -281,11 +610,25 @@ class TransferController extends BaseController
|
||||
return ResponseHelper::success([
|
||||
'groupId' => $groupId,
|
||||
'groupType' => 1,
|
||||
'groupName' => $groupName ?: '未命名分组',
|
||||
'groupName' => (string)($group['groupName'] ?? '') ?: '未命名分组',
|
||||
'count' => $count,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 设备上登录的微信号集合(与存客宝审批执行口径一致) */
|
||||
private function deviceWechatIds(int $deviceId, int $companyId): array
|
||||
{
|
||||
try {
|
||||
return Db::name('device_wechat_login')
|
||||
->where('deviceId', $deviceId)
|
||||
->where('companyId', $companyId)
|
||||
->group('wechatId')
|
||||
->column('wechatId');
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildReason(string $type, string $reason): string
|
||||
{
|
||||
if ($type !== 'contact_group') {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user