Эх сурвалжийг харах

Merge branch 'master' of https://git.fmode.cn/18307996893/openclaw-voc-skill

gangvy 1 сар өмнө
parent
commit
2510b9c4a8
54 өөрчлөгдсөн 6473 нэмэгдсэн , 189 устгасан
  1. 11 1
      claude-code/claude-code-qiwe-assistant/README.md
  2. 38 0
      claude-code/claude-code-qiwe-assistant/docs/guides/DASHBOARD-DEV-LOG.md
  3. 152 0
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-implementation-log.md
  4. 500 0
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-product-development-spec.md
  5. 11 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js
  6. 41 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/group-operations-access.js
  7. 842 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/group-operations-store.js
  8. 1 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js
  9. 1 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-config.js
  10. 25 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js
  11. 8 17
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  12. 599 69
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  13. 200 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/business-diagnosis-service.js
  14. 76 5
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/customer-master-service.js
  15. 36 18
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html
  16. 243 9
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  17. 919 8
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  18. 178 4
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/unified-task-service.js
  19. 137 0
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  20. 31 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-business-diagnosis-run.js
  21. 14 3
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js
  22. 147 44
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js
  23. 320 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-operations-run.js
  24. 0 1
      claude-code/claude-code-qiwe-assistant/package.json
  25. 35 0
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  26. 59 0
      claude-code/claude-code-qiwe-assistant/scripts/business-diagnosis-smoke-test.js
  27. 43 0
      claude-code/claude-code-qiwe-assistant/scripts/cleanup-mock-data.js
  28. 3 1
      claude-code/claude-code-qiwe-assistant/scripts/customer-master-smoke-test.js
  29. 93 0
      claude-code/claude-code-qiwe-assistant/scripts/diagnosis-action-center-smoke-test.js
  30. 47 0
      claude-code/claude-code-qiwe-assistant/scripts/group-operations-access-smoke-test.js
  31. 47 0
      claude-code/claude-code-qiwe-assistant/scripts/group-operations-automation-smoke-test.js
  32. 45 0
      claude-code/claude-code-qiwe-assistant/scripts/group-operations-quality-eval.js
  33. 135 0
      claude-code/claude-code-qiwe-assistant/scripts/group-operations-smoke-test.js
  34. 6 0
      claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js
  35. 8 0
      claude-code/claude-code-qiwe-assistant/scripts/release-check.js
  36. 6 2
      claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js
  37. 1 1
      claude-code/claude-code-qiwe-assistant/scripts/validate-output-standard.js
  38. 5 2
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  39. 48 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/SKILL.md
  40. 4 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/agents/openai.yaml
  41. 45 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/references/output-contract.md
  42. 5 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-capability-router/SKILL.md
  43. 20 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  44. 79 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/SKILL.md
  45. 4 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/agents/openai.yaml
  46. 16 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/automation-safety.md
  47. 29 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/data-model.md
  48. 24 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/output-contracts.md
  49. 18 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/quality-rules.md
  50. 17 0
      fix_regex.py
  51. 22 0
      fix_regex2.py
  52. 374 0
      replace_customer_ops.py
  53. 385 0
      replace_portraits.py
  54. 320 0
      replace_transfers.py

+ 11 - 1
claude-code/claude-code-qiwe-assistant/README.md

@@ -112,6 +112,14 @@ Claude Code / MCP
 | `qiwei_check_friend_status` | 检查好友申请状态 |
 | `qiwei_check_friend_status` | 检查好友申请状态 |
 | `qiwei_get_customer_profile` | 查询客户档案 |
 | `qiwei_get_customer_profile` | 查询客户档案 |
 
 
+### 经营诊断
+
+| 工具 | 说明 |
+| --- | --- |
+| `qiwei_business_diagnosis` | 只读汇总客户、真实会话和社群运营数据,输出结论、证据、数据缺口与可下钻动作 |
+| `qiwei_business_action_candidate` | 将一条诊断建议加入统一任务候选池,等待人工确认后再转正式任务 |
+| `qiwei_business_action_feedback` | 为已完成的诊断行动记录内部效果反馈,不发送客户消息或同步企微待办 |
+
 ### 群管理
 ### 群管理
 
 
 | 工具 | 说明 |
 | 工具 | 说明 |
@@ -397,12 +405,14 @@ Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部
 
 
 | Skill | 路径 | 适用场景 | 核心工具 |
 | Skill | 路径 | 适用场景 | 核心工具 |
 | --- | --- | --- | --- |
 | --- | --- | --- | --- |
-| qiwei-dashboard | skills/qiwei-dashboard/SKILL.md | 启动本地 Web Dashboard、检查服务状态、恢复登录 | npm run dashboard、/api/health、/api/status |
+| qiwei-dashboard | skills/qiwei-dashboard/SKILL.md | 经营驾驶舱、服务状态、统一任务中心与本地知识库 | npm run dashboard、/api/health、/api/business-diagnosis、/api/knowledge/tasks |
 | qiwei-api-catalog | skills/qiwei-api-catalog/SKILL.md | 检索、阅读、调用企业微信开放接口 | qiwei_api_search、qiwei_api_doc、qiwei_api_call |
 | qiwei-api-catalog | skills/qiwei-api-catalog/SKILL.md | 检索、阅读、调用企业微信开放接口 | qiwei_api_search、qiwei_api_doc、qiwei_api_call |
 | qiwei-login | skills/qiwei-login/SKILL.md | 设备登录、扫码、验证码 | qiwei_login_status、qiwei_login_start、qiwei_login_check、qiwei_login_verify |
 | qiwei-login | skills/qiwei-login/SKILL.md | 设备登录、扫码、验证码 | qiwei_login_status、qiwei_login_start、qiwei_login_check、qiwei_login_verify |
 | qiwei-subscription | skills/qiwei-subscription/SKILL.md | 订阅查询、开通、续费、自动续费 | qiwei_subscription_status、qiwei_subscribe、qiwei_subscription_auto_renew |
 | qiwei-subscription | skills/qiwei-subscription/SKILL.md | 订阅查询、开通、续费、自动续费 | qiwei_subscription_status、qiwei_subscribe、qiwei_subscription_auto_renew |
 | qiwei-customer-ops | skills/qiwei-customer-ops/SKILL.md | 批量加好友、自动建群、客户档案 | qiwei_batch_add_friends、qiwei_auto_create_group、qiwei_check_friend_status、qiwei_get_customer_profile |
 | qiwei-customer-ops | skills/qiwei-customer-ops/SKILL.md | 批量加好友、自动建群、客户档案 | qiwei_batch_add_friends、qiwei_auto_create_group、qiwei_check_friend_status、qiwei_get_customer_profile |
 | qiwei-group-management | skills/qiwei-group-management/SKILL.md | 同步群列表、识别/确认客户群、同步群消息 | qiwei_sync_external_groups、qiwei_list_external_groups、qiwei_confirm_external_group、qiwei_sync_group_messages |
 | qiwei-group-management | skills/qiwei-group-management/SKILL.md | 同步群列表、识别/确认客户群、同步群消息 | qiwei_sync_external_groups、qiwei_list_external_groups、qiwei_confirm_external_group、qiwei_sync_group_messages |
+| qiwei-group-operations | skills/qiwei-group-operations/SKILL.md | 社群 SOP、计划、话术审核、质检整改与受控自动化 | qiwei_group_ops_* |
+| qiwei-business-diagnosis | skills/qiwei-business-diagnosis/SKILL.md | 诊断经营问题、采纳行动候选并复盘已完成行动效果 | qiwei_business_diagnosis、qiwei_business_action_candidate、qiwei_business_action_feedback |
 | qiwei-portrait-tags | skills/qiwei-portrait-tags/SKILL.md | 客户画像分析、标签管理、企微个人标签 | qiwei_update_customer_portrait、qiwei_save_customer_portrait、qiwei_add_customer_tags、qiwei_apply_personal_labels |
 | qiwei-portrait-tags | skills/qiwei-portrait-tags/SKILL.md | 客户画像分析、标签管理、企微个人标签 | qiwei_update_customer_portrait、qiwei_save_customer_portrait、qiwei_add_customer_tags、qiwei_apply_personal_labels |
 | qiwei-broker-playbook | skills/qiwei-broker-playbook/SKILL.md | 顾问 playbook 蒸馏、保存、导出 | qiwei_distill_broker、qiwei_save_broker_playbook、qiwei_export_broker_playbooks |
 | qiwei-broker-playbook | skills/qiwei-broker-playbook/SKILL.md | 顾问 playbook 蒸馏、保存、导出 | qiwei_distill_broker、qiwei_save_broker_playbook、qiwei_export_broker_playbooks |
 | qiwei-customer-transfer | skills/qiwei-customer-transfer/SKILL.md | 客户交接预览与执行 | qiwei_preview_transfer_package、qiwei_execute_transfer |
 | qiwei-customer-transfer | skills/qiwei-customer-transfer/SKILL.md | 客户交接预览与执行 | qiwei_preview_transfer_package、qiwei_execute_transfer |

+ 38 - 0
claude-code/claude-code-qiwe-assistant/docs/guides/DASHBOARD-DEV-LOG.md

@@ -1,5 +1,43 @@
 # Qiwei Dashboard 开发过程记录
 # Qiwei Dashboard 开发过程记录
 
 
+> 本文保留早期开发过程。当前能力以“2026-07 经营驾驶舱与行动闭环”一节、`skills/qiwei-dashboard/SKILL.md` 和实际 Dashboard 为准;后续“尚未实现”“Plan 修改建议”属于历史记录,不再代表当前版本状态。
+
+## 2026-07 经营驾驶舱与行动闭环
+
+### 经营总览
+
+- 默认入口调整为 `#overview`,导航按经营驾驶舱、数字岗位、业务资产和系统重组。
+- 汇总客户、社群、客服和交易数据边界,展示今日行动、运营健康度和数字员工状态。
+- 支持自然语言经营问题和预设诊断入口;后端失败时允许前端规则降级,但不得伪造订单、成交、复购或 GMV 结论。
+
+### 经营诊断
+
+- MCP 工具:`qiwei_business_diagnosis`。
+- Dashboard API:`POST /api/business-diagnosis`。
+- 输出结论、证据、数据缺口和可下钻动作;交易数据未接入时明确返回无法判断。
+- 对应 Skill:`skills/qiwei-business-diagnosis/`。
+
+### 诊断建议进入行动中心
+
+- MCP 工具:`qiwei_business_action_candidate`。
+- Dashboard API:`POST /api/knowledge/tasks/diagnosis-candidate`。
+- 诊断建议默认只进入候选池,按稳定来源键去重;必须人工点击“确认并转为任务”后才生成正式内部任务。
+- 候选、正式任务和企微官方待办保持不同状态,不自动发送客户消息或同步真实待办。
+
+### 行动效果反馈
+
+- MCP 工具:`qiwei_business_action_feedback`。
+- Dashboard API:`POST /api/knowledge/tasks/diagnosis-feedback`。
+- 生命周期记录 `created`、`promoted`、`completed` / `dismissed` 和 `outcome_recorded`。
+- 仅已完成的诊断来源正式任务可记录 `effective`、`partially_effective`、`ineffective` 或 `unknown`,并可附带内部复盘依据。
+- 统一任务中心展示候选数、采纳数/采纳率、完成数、验证有效数/有效率;没有有效分母时显示未知,不伪造为 `0%`。
+
+### Dashboard 适配与验证
+
+- 支持桌面与移动端布局、侧栏遮罩和静态资源版本控制。
+- 任务中心在 1440px 双栏工作区不产生横向滚动;移动端反馈选项保持至少 44px 点击高度。
+- 发布检查覆盖 19 个 Skills、91 个 MCP 工具和 15 组隔离检查;诊断行动闭环 smoke test 覆盖候选创建、去重、晋升、完成、反馈和汇总指标。
+
 ## 项目背景
 ## 项目背景
 
 
 为 `claude-code/claude-code-qiwe-assistant` 项目构建一个本地 Web Dashboard,让用户可以通过浏览器直观地完成企微核心能力的操作,降低纯对话调用的门槛。第一期聚焦在**客户群管理**模块,同时搭建好账号状态、登录恢复等公共基础设施。
 为 `claude-code/claude-code-qiwe-assistant` 项目构建一个本地 Web Dashboard,让用户可以通过浏览器直观地完成企微核心能力的操作,降低纯对话调用的门槛。第一期聚焦在**客户群管理**模块,同时搭建好账号状态、登录恢复等公共基础设施。

+ 152 - 0
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-implementation-log.md

@@ -0,0 +1,152 @@
+# 企微社群运营实施记录
+
+> 对应方案:`qiwei-group-operations-product-development-spec.md`  
+> 开始日期:2026-07-18  
+> 当前阶段:Phase 3 受控自动化底座已完成,真实自动发送保持关闭
+
+## 状态说明
+
+- `完成`:代码已落地并通过对应本地验证。
+- `进行中`:已有实现,但仍缺集成或真实环境验证。
+- `待开始`:尚未开发。
+- `受阻`:需要真实账号、业务规则或外部权限。
+
+## 已完成
+
+### 2026-07-18
+
+| 工作项 | 状态 | 产物/说明 |
+|---|---|---|
+| 产品与技术方案 | 完成 | `docs/specs/qiwei-group-operations-product-development-spec.md` |
+| `qiwei-group-operations` Skill 骨架 | 完成 | 主 `SKILL.md` + 数据模型、质检规则、输出契约三份渐进引用 |
+| Skill UI 元数据 | 完成 | `skills/qiwei-group-operations/agents/openai.yaml` |
+| 社群运营 SQLite 数据层 | 完成 | 群主档、SOP/版本、计划/计划项、消息、质检、审计;所有业务表带 `account_key` |
+| SOP 版本管理 | 完成 | 创建草稿、创建版本、发布、指定版本回滚、审计 |
+| 每日运营计划 | 完成 | 从已发布 SOP 生成待审核计划,同群同日幂等,支持模板变量 |
+| 计划项人工闭环 | 完成 | 批准、拒绝、跳过、标记人工已执行;真实自动发送保持关闭 |
+| 确定性质检 MVP | 完成 | 投诉/退款等风险词、客户问题超时未回应、稳定指纹去重、原消息证据 |
+| 每日运营简报 | 完成 | 群数、已发布 SOP、今日任务、完成率、待审核、高风险和未闭环事项 |
+| MCP 工具 | 完成 | 新增 8 个工具:上下文、计划、话术、质检、简报、SOP、计划执行、质检处理 |
+| Dashboard API | 完成 | 新增 `/api/group-ops/*` 读取、发布、计划、审核和质检处理接口 |
+| Dashboard MVP | 完成 | 新增“社群运营”导航、运营指标、SOP、计划生成、今日工作台、质检预警 |
+| 可访问性与响应式基础 | 完成 | 表单 label、按钮禁用态、键盘 focus、375px/平板布局、减少动态效果 |
+| 本地 smoke 脚本 | 完成 | 覆盖账号隔离、SOP 发布、计划幂等、审核执行、风险质检和闭环 |
+| 发布检查接入 | 完成 | `release-check.js` 已加入 `group-ops:check` 与 `group-ops:smoke` |
+| 群消息兼容入库 | 完成 | webhook 与历史同步成功落盘后,按设备 `guid` 兼容写入账号隔离的社群运营群/消息表;缺少 guid 时安全跳过新表 |
+
+### 2026-07-18 · Phase 2
+
+| 工作项 | 状态 | 产物/说明 |
+|---|---|---|
+| 按门店批量生成计划 | 完成 | 支持显式群列表或门店范围;逐群幂等,部分失败不回滚成功项 |
+| 跨门店运营对比 | 完成 | 展示群数、今日任务、完成数、执行率、未闭环与高风险;无任务时显示数据不足 |
+| 群活跃趋势与生命周期建议 | 完成 | 对比近 7 天与前 7 天消息、活跃成员、最近消息、风险,并给出可解释建议和覆盖率 |
+| 整改任务主账 | 完成 | 质检项可创建整改任务,支持负责人、期限、优先级、状态和审计 |
+| 企微官方待办衔接 | 完成 | 精确匹配内部成员且人工确认后创建真实待办;本地完成可同步官方完成状态 |
+| 质检阈值配置 | 完成 | 账号级 SLA、风险词、禁止承诺配置并留审计;消息质检自动采用当前配置 |
+| 误报反馈评测集 | 完成 | `false_positive` 不删除历史结论,独立保存反馈记录并统计误报率 |
+| 质量评测脚本 | 完成 | `scripts/group-operations-quality-eval.js` 输出 precision/recall 并纳入发布检查 |
+| 优秀话术沉淀 | 完成 | 从已执行且有最终内容的计划项汇总高频话术,不把未发送草稿计入 |
+| SOP 版本治理 | 完成 | 版本列表、结构差异、影响群/门店预览、指定版本发布和回滚 |
+| Dashboard 五视图 | 完成 | 运营总览、今日工作台、群洞察、SOP 版本、质检督导;移动端响应式与键盘焦点保持一致 |
+| Phase 2 MCP/API | 完成 | 新增批量计划、运营洞察、整改任务 3 个 MCP 工具,总计 86 个;Dashboard API 完成对应闭环 |
+
+### 2026-07-18 · Phase 3
+
+| 工作项 | 状态 | 产物/说明 |
+|---|---|---|
+| 自动化策略版本 | 完成 | 策略草稿、不可变版本、人工激活、历史策略保留;激活后强制保持暂停 |
+| 群与内容白名单 | 完成 | 只有显式 `allowedGroupIds` 和 `allowedTypes` 才能通过执行闸门 |
+| 时间窗与灰度 | 完成 | 使用 Asia/Shanghai 判断星期和时间窗;按 roomId 稳定哈希命中灰度比例 |
+| 单群频控 | 完成 | 单群每日、近 7 天发送上限,基于成功回执计数 |
+| 内容与风险闸门 | 完成 | 未批准、未到时间、高风险未闭环、变量未替换、禁止承诺和重复发送全部拦截 |
+| 幂等与回执 | 完成 | 保存幂等键、策略版本、决策、模式、状态、外部消息 ID、错误和完成时间 |
+| 连续失败熔断 | 完成 | 达到策略阈值自动熔断;后续项目全部阻止,只能人工确认复位 |
+| 一键暂停/恢复 | 完成 | 暂停立即生效;恢复必须人工确认;状态变化全量审计 |
+| dry-run | 完成 | 支持预演和记录 dry-run,不调用 Fmode、不改变计划项发送状态 |
+| 真实发送三重保护 | 完成 | 仅 `auto` 策略 + `execute=true/confirmed=true` + `QIWEI_GROUP_OPS_LIVE_SEND=1` 才可能调用 `/msg/sendText` |
+| 自动化 Dashboard | 完成 | 新增策略、状态、白名单、时间窗、灰度、频控、熔断、预演、dry-run 和执行日志视图 |
+| Phase 3 MCP/API | 完成 | 新增 `qiwei_group_ops_automation`,总计 87 个 MCP 工具 |
+| 自动化专项测试 | 完成 | 覆盖暂停、白名单、灰度、时间窗、连续失败熔断、人工复位、dry-run 和真实发送阻断 |
+
+### 2026-07-18 · 规格对照审查修复
+
+| 工作项 | 状态 | 产物/说明 |
+|---|---|---|
+| SOP 草稿与发布版本隔离 | 完成 | 创建新草稿不再停用当前已发布 SOP;生成计划只读取当前发布快照,实际话术与 `playbook_version_id` 保持一致 |
+| 有效回应判定 | 完成 | 客户问题后的无关、寒暄或占位消息不再算有效回应;新增意图与关键词相关性规则,并支持后续有效回复自动闭环 |
+| 质检时间范围 | 完成 | 消息质检支持 `startAt` / `endAt`,避免每次扫描全部历史消息 |
+| 发送回执未知保护 | 完成 | 网络超时、连接重置等结果不确定场景记录为 `unknown`,在人工核对回执前锁住同一幂等键,避免自动重发 |
+| 批次熔断即时生效 | 完成 | 每个发送项执行前重新计算安全闸门;批次中途触发熔断后,后续项目立即转为拦截 |
+| 账号级增量消息同步 | 完成 | 每个企微账号只调用一次增量消息流,按 `fromRoomId` 分流到目标群;完整拉取后保存账号级 `travelSyncKey` 游标,分页未完成时不推进游标 |
+| SOP 发布/回滚确认 | 完成 | MCP/API 必须显式传 `confirmed=true`;Dashboard 先展示影响范围并由用户确认 |
+| 可解释群健康分 | 完成 | 新增每日评分表,按 SOP、响应、风险、互动分项计算;缺少数据时排除对应权重并展示覆盖率、分项证据量和健康分布 |
+| 审查回归测试 | 完成 | 新增未发布草稿隔离、版本审计一致性、无关/有效回复、回执未知锁定、健康分覆盖率和发布确认测试 |
+| RBAC 权限框架与模拟验收 | 完成 | 新增总部管理员、区域经理、门店经理、执行员工、只读审计者角色;支持账号级 `userId` 绑定与门店范围;新增角色管理 MCP,当前总计 88 个 MCP 工具;覆盖首次初始化、跨门店拒绝、非总部发布 SOP 拒绝及审计者执行拒绝 |
+
+## 进行中
+
+| 工作项 | 状态 | 剩余内容 |
+|---|---|---|
+| Phase 0 账号隔离 | 进行中 | 新模块数据已隔离;旧 `outputs/groups/*` 和历史消息路径仍需迁移为账号目录 |
+| 真实样本质量评测 | 进行中 | 评测脚本与内置回归集已完成;仍缺 1,000 条脱敏消息和业务人员阈值校准 |
+
+## 受阻或需业务确认
+
+| 工作项 | 状态 | 阻塞条件 |
+|---|---|---|
+| 真实自动群消息发送 | 受阻 | `/msg/sendText`、`/msg/sendGroupMsg` 需真实账号完成回执、频控、超时幂等 smoke |
+| 20 群连续试点 | 受阻 | 需要确定 3 家门店、20 个授权客户群及负责人 |
+| 公司级正式 SOP | 受阻 | 需要业务方提供或审核群类型、服务 SLA、禁用词、升级人工规则和首批话术 |
+| 正式角色映射 | 受阻 | 权限框架与模拟测试已完成;需要提供真实总部、区域、门店、审计者的 `userId` 及门店范围后才能启用生产绑定 |
+| 数据保留与合规 | 受阻 | 需要确认群消息保留期限、脱敏范围和内部授权 |
+| 官方待办 live smoke | 受阻 | 同步逻辑与人工确认保护已完成;需要已初始化的企微官方 CLI 和测试成员完成真实创建/完成验证 |
+
+## 后续开发顺序
+
+1. 把 webhook 和历史同步改为“账号级一次增量拉取,按 roomId 分流入库”。
+2. 为旧群列表、确认映射和消息缓存增加账号命名空间及迁移脚本。
+3. 完成真实发送接口 smoke 后,仅对白名单和人工批准项开放发送。
+4. 用 1,000 条脱敏消息扩充评测集,校准投诉与未回应识别阈值。
+5. 用真实企微官方 CLI 完成整改待办创建和完成状态 live smoke。
+6. 开展 20 群、14 天真实试点并在本文件记录指标。
+
+## 验证记录
+
+| 日期 | 验证 | 结果 |
+|---|---|---|
+| 2026-07-18 | `npm run group-ops:check` | 通过 |
+| 2026-07-18 | `npm run group-ops:smoke` | 首次发现消息表占位符错误,修复后通过 |
+| 2026-07-18 | Skill `quick_validate.py` | 通过;`Skill is valid!` |
+| 2026-07-18 | `npm run check` | 通过 |
+| 2026-07-18 | Dashboard 本地 HTTP smoke(4322) | `/api/group-ops/overview` 返回 `ok`,首页包含 `group-ops` 导航 |
+| 2026-07-18 | `npm run package:smoke` | 通过;18 skills、83 MCP tools、0 forbidden files |
+| 2026-07-18 | `npm run release:check` | 最终通过 11 组发布检查;同步修复 webhook 旧测试期望、Relay 测试隔离、画像 Excel 复用现有导出组件及输出目录 manifest 基线 |
+| 2026-07-18 | Phase 2 `npm run group-ops:smoke` | 通过;覆盖 SOP v2/影响预览/回滚、门店批量计划、配置、反馈、整改任务、群洞察和优秀话术 |
+| 2026-07-18 | `npm run group-ops:eval` | 通过;内置 4 个规则样本,precision=1、recall=1,仅作为回归测试,不代替真实评测集 |
+| 2026-07-18 | Phase 2 Dashboard 隔离 API smoke | 通过;SOP 创建/预览/发布、计划、质检设置、整改任务、门店和群洞察全部返回 `ok` |
+| 2026-07-18 | Phase 2 Skill `quick_validate.py` | 通过;`Skill is valid!` |
+
+### 2026-07-18 · 当前登录账号 live smoke
+
+| 验证 | 结果 |
+|---|---|
+| Fmode 设备登录 | 通过;设备在线,状态码 2 |
+| 包月订阅 | 通过;1/1 席位已使用,订阅有效,自动续费开启 |
+| 自建外部群读取 | 通过;读取 6 个外部群,接口无错误 |
+| 群分类缓存 | 通过;4 个 `IMPORTED`、2 个 `SUGGESTED`,未自动确认真实客户群 |
+| 单群详情分析 | 通过;候选群详情可读,保持 `SUGGESTED`,未误改为已确认 |
+| 单页历史消息同步 | 接口通过;所选群本页新增 0 条,因此本次未验证真实消息质检样本 |
+| 企微官方待办 live smoke | 未执行;当前官方 CLI 尚未安装、未授权 |
+
+本次 live smoke 只执行读取、分析和本地缓存更新,没有发消息、建群、修改群资料或创建真实企微待办。
+
+### 2026-07-18 · Phase 3 自动化验证
+
+| 验证 | 结果 |
+|---|---|
+| `npm run group-ops:smoke` | 通过;策略、暂停、时间窗、灰度、频控、三次失败熔断和人工复位通过 |
+| `npm run group-ops:auto-smoke` | 通过;dry-run 不调用真实网关,环境开关关闭时真实执行被强制阻断 |
+| Phase 3 Dashboard 隔离 API smoke | 通过;激活后 `paused=1`、人工恢复后 `paused=0`、预演通过 1 项、dry-run 1 项、真实执行被环境开关阻断 |
+| 当前真实发送开关 | `QIWEI_GROUP_OPS_LIVE_SEND` 未开启,未向当前登录账号的任何群发送测试消息 |
+| `npm run release:check` | 通过 12 组发布检查;18 个 Skills、87 个 MCP 工具、148 个打包条目、0 个禁入文件 |

+ 500 - 0
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-product-development-spec.md

@@ -0,0 +1,500 @@
+# 企微客户群标准化运营与督导——产品开发文档
+
+> 文档状态:方案初稿  
+> 版本:v0.1  
+> 日期:2026-07-18  
+> 适用项目:`claude-code/claude-code-qiwe-assistant`
+
+## 1. 背景与问题
+
+企业客户群目前缺少公司级运营标准,群运营依赖门店员工个人经验,导致:
+
+- 没有统一的群运营 SOP、服务话术和内容节奏;
+- 总部无法知道门店是否执行、执行质量如何;
+- 没有专门运营团队,店员需要从零策划和撰写内容;
+- 客户问题、购买信号和投诉可能无人响应;
+- 不同门店服务质量差异大,无法复制优秀经验;
+- 群数量增长后,管理者无法逐群查看和督导。
+
+现有系统已具备群发现、群确认、群消息同步、单聊 Agent、人工审核、任务、预警和审计能力,但尚未形成以下业务闭环:
+
+```mermaid
+flowchart LR
+  A["总部制定标准"] --> B["按群生成运营计划"]
+  B --> C["门店审核并执行"]
+  C --> D["同步群消息与执行结果"]
+  D --> E["自动质检与风险识别"]
+  E --> F["生成整改任务与管理看板"]
+  F --> A
+```
+
+## 2. 产品目标
+
+建设“企微社群运营中心”,把总部运营标准转化为门店每天可执行的任务和话术,并通过群消息证据自动监督执行质量。
+
+首期目标:
+
+1. 总部可以统一定义、发布和更新群运营 SOP、话术模板与运营节奏。
+2. 门店可以通过 Agent 对话快速获得“今天该做什么、该怎么说”。
+3. 所有外发内容默认经过人工确认,发送结果全程留痕。
+4. 系统按群自动检查执行、响应、风险和活跃度,生成有原消息依据的预警。
+5. 管理者可以在 Dashboard 查看跨门店、跨群的执行率、健康度、风险和待办。
+
+### 2.1 成功指标
+
+MVP 试点建议使用 3 家门店、20 个客户群、连续运行 14 天:
+
+| 指标 | 目标 |
+|---|---:|
+| 每日计划生成成功率 | ≥ 95% |
+| 到期运营任务执行率 | ≥ 85% |
+| 话术人工一次采纳率 | ≥ 70% |
+| 高风险消息召回率 | ≥ 85% |
+| 高风险误报率 | ≤ 10% |
+| 未回应问题识别准确率 | ≥ 90% |
+| 质检结论可追溯到原消息比例 | 100% |
+| 门店日均群运营准备时间下降 | ≥ 50% |
+| 未经人工确认的外发消息 | 0 条 |
+
+### 2.2 非目标
+
+MVP 暂不包含:
+
+- 完全无人监管的自动群聊机器人;
+- 自动决定促销价格、退款、赔偿或对客户作出业务承诺;
+- 替代企业微信官方权限、合规审批和员工管理制度;
+- 以 AI 主观判断销售业绩或员工绩效;
+- 未经授权采集或长期保存客户敏感信息;
+- 一开始建设复杂的营销自动化平台或多渠道 CRM。
+
+## 3. 产品形态与功能拆分
+
+建议对用户呈现一个统一产品模块“社群运营”,内部由四项能力组成。MVP 先建设一个 `qiwei-group-operations` skill,避免多个 skill 重复加载群上下文和产生路由冲突;规模扩大后再按职责拆分。
+
+### 3.1 功能一:SOP 与话术标准中心
+
+解决“公司无统一指导和标准”。
+
+总部可维护:
+
+- 群类型:新客群、成交服务群、售后群、会员群、活动群等;
+- 生命周期:建群欢迎、需求了解、持续服务、活动转化、售后维护、沉默唤醒;
+- 运营节奏:星期、时间段、频次、任务类型;
+- 话术模板:欢迎、提醒、知识内容、活动、答疑、回访、投诉安抚;
+- 必做项、禁止项、升级人工条件和响应 SLA;
+- 适用门店、适用群标签、版本、生效日期和发布状态。
+
+核心规则:
+
+- 草稿版本不影响已执行计划;
+- 发布新版本必须记录发布人、变更说明和生效时间;
+- 每个计划项必须保存所依据的 SOP 版本,便于审计;
+- 话术模板支持变量,但变量缺失时禁止发送,不允许把 `{{customer_name}}` 原样发出;
+- 禁止词、敏感承诺和高风险场景采用确定性规则兜底,不仅依赖模型。
+
+### 3.2 功能二:群运营 Copilot
+
+解决“没有运营团队、店员不会策划和写话术”。
+
+Agent 根据群类型、生命周期、最近消息、历史执行情况和当前 SOP,生成:
+
+- 今日/本周运营计划;
+- 每个计划项的建议时间、目标、话术草稿和执行说明;
+- 针对当前群上下文改写后的个性化话术;
+- 发送前风险检查和需要人工补充的变量;
+- 门店员工的待办清单。
+
+执行状态:
+
+`draft → pending_review → approved → sending → sent/failed → verified`
+
+另有 `skipped` 和 `cancelled`,必须记录原因。
+
+发送策略:
+
+- MVP 默认“Agent 生成草稿 → 人工编辑/确认 → 执行发送”;
+- 批量发送必须逐项展示目标群、最终内容、发送时间和风险检查;
+- 同一计划项使用幂等键,避免重试导致重复发送;
+- 高风险话术、投诉、退款、法律、隐私和价格承诺只能生成建议,不提供自动发送;
+- 若真实发送接口未完成 live smoke,可退化为“复制话术 + 标记已执行”,不虚报已发送。
+
+### 3.3 功能三:群质检与智能督导
+
+解决“没有监督、服务质量参差不齐”。
+
+系统基于同步的群消息和计划执行记录检查:
+
+1. **节奏执行**:当天必做运营动作是否按时完成。
+2. **客户响应**:客户的明确提问是否在 SLA 内得到有效回应。
+3. **服务风险**:投诉、负面情绪、退款、敏感词、错误承诺和冲突升级。
+4. **运营质量**:连续机械刷屏、重复内容、模板变量未替换、内容与群阶段不匹配。
+5. **群活跃信号**:发言人数、互动率、连续沉默和异常退群;数据不完整时明确标记“暂不可评估”。
+
+每条质检发现必须包含:
+
+- 规则/模型判定类型;
+- 严重程度;
+- 原消息 ID、时间、发送者和证据片段;
+- 采用的规则或 SOP 版本;
+- 建议动作、负责人和截止时间;
+- 处理状态与处理结果。
+
+系统不直接用一个不可解释的总分评价员工。Dashboard 可以展示“群健康分”,但必须同时展示分项得分、数据覆盖率和证据。
+
+建议初始评分:
+
+| 分项 | 权重 | 计算原则 |
+|---|---:|---|
+| SOP 执行 | 30% | 到期必做项按时完成比例 |
+| 客户响应 | 30% | 明确问题在 SLA 内获得有效回应比例 |
+| 服务风险 | 25% | 未处理高风险事件扣分,已闭环事件减轻扣分 |
+| 群互动 | 15% | 活跃成员和有效互动趋势,不鼓励单纯刷消息量 |
+
+若某分项缺少可靠数据,应从有效权重中排除并显示覆盖率,不按 0 分处理。
+
+### 3.4 功能四:总部运营 Dashboard
+
+解决“无法规模化管理客户关系”。
+
+新增一级导航“社群运营”,包含五个页签:
+
+#### A. 运营总览
+
+- 今日应执行、已完成、逾期、待审核数量;
+- 运营任务执行率、响应 SLA 达标率、高风险未闭环数;
+- 门店/账号/群类型对比;
+- 群健康分布和数据覆盖率;
+- 本周趋势与异常群 Top N;
+- 只展示能够下钻到群、计划或原消息的数据。
+
+#### B. 今日工作台
+
+- 按紧急程度排列待审核话术、到期任务、失败发送和风险处理;
+- 支持编辑、批准、拒绝、重新生成、复制和发送;
+- 批量操作前展示影响范围和风险;
+- 门店员工默认只看自己负责的群。
+
+#### C. 群运营详情
+
+- 群基本信息、负责人、门店、群类型、生命周期和标签;
+- 当前 SOP 版本和未来 7 天运营日历;
+- 消息时间线、执行记录、未回应问题和风险事件;
+- 分项健康指标、数据覆盖率和原消息证据;
+- “让 Agent 生成计划”“生成下一条话术”“立即质检”入口。
+
+#### D. SOP 与话术库
+
+- SOP 列表、版本、适用范围、启停和发布;
+- 可视化节奏编辑器;
+- 话术模板、变量、禁用词和风险规则;
+- 发布前预览“哪些群会受到影响”;
+- 版本 diff 和回滚。
+
+#### E. 质检与预警
+
+- 按严重程度、门店、群、负责人、类型、状态筛选;
+- 查看证据、确认误报、分配负责人、创建待办、处理和关闭;
+- 误报反馈进入评测集,不能直接无审计地修改历史结论。
+
+## 4. Agent 对话能力设计
+
+### 4.1 Skill 定位
+
+建议新增:
+
+```text
+skills/qiwei-group-operations/
+├── SKILL.md
+├── agents/openai.yaml
+└── references/
+    ├── data-model.md
+    ├── quality-rules.md
+    └── output-contracts.md
+```
+
+`SKILL.md` 只保留核心工作流、权限边界和工具选择;详细字段、质检规则和输出结构放入 `references/`,避免主 skill 过长。
+
+建议触发场景:
+
+- “给新建的客户群做一个 7 天运营计划”;
+- “根据公司 SOP 写今天下午的群话术”;
+- “检查这 20 个群昨天有没有按要求运营”;
+- “找出超过两小时没人回复的客户问题”;
+- “汇总三家门店本周群运营情况”;
+- “把这条优秀话术保存为公司模板草稿”。
+
+### 4.2 MCP 工具
+
+首期建议提供 7 个面向业务的工具:
+
+| 工具 | 作用 | 是否产生外部写操作 |
+|---|---|---|
+| `qiwei_group_ops_get_context` | 获取群、SOP、近期消息、计划和风险上下文 | 否 |
+| `qiwei_group_ops_generate_plan` | 生成单群或批量群运营计划草稿 | 否 |
+| `qiwei_group_ops_generate_copy` | 为指定计划项生成或改写话术 | 否 |
+| `qiwei_group_ops_review_messages` | 对指定群和时间范围执行质检 | 否 |
+| `qiwei_group_ops_daily_brief` | 汇总今日任务、逾期和风险 | 否 |
+| `qiwei_group_ops_manage_playbook` | 创建草稿、更新、提交发布或回滚 SOP | 发布/回滚需确认 |
+| `qiwei_group_ops_execute_item` | 批准、拒绝、发送、重试、跳过计划项 | 发送需明确确认 |
+
+工具返回继续沿用项目的标准结果信封,并增加稳定的业务字段:`operationId`、`accountKey`、`roomId`、`evidence`、`requiresConfirmation`、`idempotencyKey` 和 `auditId`。
+
+### 4.3 对话工作流示例
+
+用户:“给所有新客群安排明天的运营内容。”
+
+Agent 应执行:
+
+1. 确认当前企微账号和用户可管理的群范围。
+2. 筛选 `groupType=new_customer` 且已绑定已发布 SOP 的群。
+3. 读取最近消息和未完成计划,防止内容重复或与群状态冲突。
+4. 生成计划草稿和每群话术,标出变量缺失与风险。
+5. 返回摘要:目标群数、跳过群数、待补信息、预计发送项数。
+6. 用户明确批准后才创建可执行计划;再次确认发送目标和最终内容后才外发。
+7. 写入发送结果和审计日志;失败可安全重试,不重复发送成功项。
+
+## 5. 技术方案
+
+### 5.1 可复用现有能力
+
+| 现有能力 | 复用方式 |
+|---|---|
+| `qiwei-group-management` | 发现群、确认客户群、读取群详情、同步群消息 |
+| `qiwei-customer-ops` | 客户档案、自动建群和欢迎语能力 |
+| Agent Workbench SQLite | 复用任务、预警、审计和人工审核设计模式 |
+| Dashboard | 复用账号切换、异步 job、Toast、筛选、分页和审计交互 |
+| Fmode 网关与 endpoint catalog | 复用登录设备、鉴权、错误信封和群消息接口 |
+| Webhook / polling | 复用增量消息入口,作为质检事实来源 |
+
+### 5.2 必须先修正的底层问题
+
+1. **账号隔离**:当前 `outputs/groups/rooms-latest.json` 等路径没有显式账号命名空间,多账号切换存在覆盖风险。新模块所有数据必须包含 `account_key`,文件缓存也应按账号分目录。
+2. **增量同步**:当前按每个 room 从消息流起点扫描会重复消耗请求。应改为“每账号只增量拉取一次 → 按 `fromRoomId` 分流入库 → 保存游标”。
+3. **群会话模型**:现有 `conversations.contact_id` 面向单聊。群运营应有独立群实体和群消息关系,不能把群 ID 伪装成客户 ID。
+4. **发送接口实测**:catalog 中 `/msg/sendGroupMsg` 的摘要存在明显错配,不能仅凭 catalog 宣称可用。必须先对 `/msg/sendText` 和 `/msg/sendGroupMsg` 分别完成 sample/live smoke,并确认频控、回执和失败语义。
+5. **权限模型**:Dashboard 当前主要是本地账号切换,新模块需要最小角色模型:总部管理员、区域/门店经理、执行员工、只读审计者。
+
+### 5.3 逻辑架构
+
+```mermaid
+flowchart TB
+  UI["Dashboard / Agent 对话"] --> API["Group Operations Service"]
+  API --> PB["SOP 与话术版本服务"]
+  API --> PLAN["计划与执行服务"]
+  API --> QA["质检规则与 LLM 分类器"]
+  API --> SEND["发送审批与幂等执行器"]
+  SYNC["Webhook / 增量消息同步"] --> STORE[("Group Operations SQLite")]
+  PB --> STORE
+  PLAN --> STORE
+  QA --> STORE
+  SEND --> GATEWAY["Fmode WeCom Gateway"]
+  GATEWAY --> SYNC
+  STORE --> API
+  API --> AUDIT["统一审计日志"]
+```
+
+MVP 可继续使用 Node 内置 SQLite,保持本地可部署;但数据访问必须封装为 service/store,不允许 Dashboard 路由直接拼 SQL,以便以后迁移 PostgreSQL 或多租户服务。
+
+### 5.4 建议数据模型
+
+| 表 | 关键字段 | 说明 |
+|---|---|---|
+| `group_ops_groups` | `id, account_key, room_id, store_id, owner_id, group_type, lifecycle_stage, playbook_id, status` | 群运营主档,`account_key + room_id` 唯一 |
+| `group_ops_playbooks` | `id, name, group_type, status, current_version_id, scope_json` | SOP 主记录 |
+| `group_ops_playbook_versions` | `id, playbook_id, version, content_json, change_note, created_by, published_at` | 不可变版本快照 |
+| `group_ops_templates` | `id, version_id, scene, title, content, variables_json, risk_level` | 话术模板 |
+| `group_ops_plans` | `id, account_key, room_id, plan_date, playbook_version_id, status, generated_by` | 每群每日/每周计划 |
+| `group_ops_plan_items` | `id, plan_id, scheduled_at, type, objective, draft_content, final_content, status, idempotency_key` | 可审核、可执行任务 |
+| `group_ops_messages` | `id, account_key, room_id, external_message_id, sender_id, sender_role, content, message_type, sent_at, raw_json` | 标准化群消息,外部消息 ID 唯一 |
+| `group_ops_findings` | `id, account_key, room_id, type, severity, evidence_json, rule_version, status, assignee, due_at` | 质检发现与处理闭环 |
+| `group_ops_daily_scores` | `account_key, room_id, score_date, component_json, coverage, total_score` | 可解释的日健康指标 |
+| `group_ops_send_attempts` | `id, plan_item_id, target_room_id, request_hash, status, external_message_id, error, created_at` | 发送幂等、重试与回执 |
+| `group_ops_audit_logs` | `id, actor, action, entity_type, entity_id, before_json, after_json, created_at` | 发布、审批、发送和关闭记录 |
+
+索引至少覆盖:
+
+- `(account_key, room_id)`;
+- `(account_key, plan_date, status)`;
+- `(account_key, severity, status, created_at)`;
+- `(account_key, room_id, sent_at)`;
+- `idempotency_key` 和 `external_message_id` 唯一索引。
+
+### 5.5 Dashboard API
+
+建议新增:
+
+```text
+GET    /api/group-ops/overview
+GET    /api/group-ops/workbench
+GET    /api/group-ops/groups
+GET    /api/group-ops/groups/:roomId
+POST   /api/group-ops/groups/:roomId/generate-plan
+POST   /api/group-ops/groups/:roomId/review
+
+GET    /api/group-ops/playbooks
+POST   /api/group-ops/playbooks
+POST   /api/group-ops/playbooks/:id/versions
+POST   /api/group-ops/playbooks/:id/publish
+POST   /api/group-ops/playbooks/:id/rollback
+
+POST   /api/group-ops/plan-items/:id/approve
+POST   /api/group-ops/plan-items/:id/reject
+POST   /api/group-ops/plan-items/:id/send
+POST   /api/group-ops/plan-items/:id/skip
+
+GET    /api/group-ops/findings
+POST   /api/group-ops/findings/:id/assign
+POST   /api/group-ops/findings/:id/resolve
+POST   /api/group-ops/findings/:id/false-positive
+```
+
+通用约束:
+
+- 所有请求从当前登录账号解析 `accountKey`,不接受浏览器任意伪造;
+- 写接口要求操作者身份、角色校验和审计;
+- 发布、回滚、批量批准和发送返回变更摘要;
+- 长任务复用现有 `/api/jobs/:id` 异步进度机制;
+- 所有列表支持分页,禁止一次把全部群消息返回前端;
+- 原始 `raw_json` 默认不返回 Dashboard,仅在受控调试中使用。
+
+## 6. 质检规则与模型边界
+
+### 6.1 规则优先
+
+确定性规则处理:
+
+- 计划是否逾期、是否已执行;
+- SLA 时间差;
+- 模板变量未替换;
+- 明确禁用词和必备免责声明;
+- 重复发送和频次上限;
+- 发送失败、账号离线和消息同步中断。
+
+模型处理:
+
+- 客户消息是否构成真实问题;
+- 回复是否有效回答,而非仅有员工发言;
+- 投诉、购买意向、负面情绪和潜在升级风险;
+- 话术是否符合指定语气和当前群阶段。
+
+模型结果必须带 `confidence`、分类理由和证据消息;低置信结果进入“待确认”,不能直接形成员工违规结论。
+
+### 6.2 证据与隐私
+
+- Dashboard 默认只展示必要证据片段,手机号等敏感字段脱敏;
+- 数据保留期限可配置,默认建议消息正文 90 天、聚合指标 1 年;
+- 员工只能查看职责范围内的群;
+- 模型输入按最小上下文原则截取,不发送无关群历史;
+- 导出、查看完整原文和修改规则均写审计日志。
+
+## 7. 关键异常与降级策略
+
+| 异常 | 产品行为 |
+|---|---|
+| 账号离线或 token 失效 | 停止发送,保留草稿,提示恢复登录 |
+| 消息同步中断 | 看板标记数据延迟,暂停生成确定性健康结论 |
+| SOP 未绑定 | 允许创建绑定任务,不生成“合规率” |
+| 模板变量缺失 | 阻止发送并列出缺失变量 |
+| 模型不可用 | 继续执行确定性规则,模型类质检标记“未评估” |
+| 发送超时 | 先查询或同步回执,再决定是否重试,不能直接重复发送 |
+| 批量任务部分失败 | 成功项保持成功,仅重试失败项 |
+| 规则版本更新 | 历史结论保留原版本,新消息使用新版本 |
+| 群被解散或无权限 | 停止计划并生成管理员处理项 |
+
+## 8. 开发阶段与优先级
+
+### Phase 0:验证底座(3~5 个开发日)
+
+- 修复群数据的账号隔离;
+- 将消息同步改为账号级增量分流;
+- 实测单群发送、群发助手、回执、频控和错误语义;
+- 确认客户群数据授权、保留期限和角色范围;
+- 准备 1,000 条脱敏群消息和 20 个群的试点样本。
+
+退出条件:增量消息无重复入库,跨账号数据不可见,发送接口边界得到真实回执验证。
+
+### Phase 1:MVP 闭环(8~12 个开发日)
+
+- 新建 `qiwei-group-operations` skill 和 MCP 工具;
+- 建设 SOP/话术版本、计划、计划项、发送尝试和审计表;
+- 完成“运营总览、今日工作台、群详情、SOP 话术库”基础页面;
+- 支持单群 7 天计划、话术生成、人工审批、复制/发送和失败重试;
+- 支持节奏执行、未回应问题、投诉风险三类质检;
+- 建立 sample 模式和 smoke test。
+
+退出条件:一个总部管理员可发布 SOP,一个门店员工可完成每日执行,一个管理者可从预警下钻到原消息并闭环。
+
+### Phase 2:规模化督导(5~8 个开发日)
+
+- 批量计划、跨门店对比和每日管理简报;
+- 分配负责人、整改待办和官方企微待办同步;
+- 误报反馈集、质检评测脚本和阈值配置;
+- 活跃趋势、生命周期建议和优秀话术沉淀;
+- 发布影响预览、版本 diff 和回滚。
+
+退出条件:20 群连续运行 14 天达到第 2.1 节验收指标。
+
+### Phase 3:受控自动化(试点指标达标后再立项)
+
+- 仅对白名单群、低风险模板、固定时间窗开放自动执行;
+- 配置每日/每周频控、熔断、灰度范围和一键停用;
+- 自动化仍保留审批策略版本、执行回执和全量审计。
+
+## 9. 测试与验收
+
+### 9.1 必测场景
+
+1. 同一群同一天重复生成计划,不产生重复可执行项。
+2. SOP 发布后旧计划仍引用旧版本,新计划引用新版本。
+3. 变量缺失、禁用词、高风险意图会阻止发送。
+4. 发送成功后网络超时,重试不会产生第二条消息。
+5. 客户提问后员工只发送无关内容,仍判定为未有效回应。
+6. 投诉在一分钟内产生可追溯预警,人工可确认误报或关闭。
+7. 消息同步延迟时健康分显示数据不足,不错误扣分。
+8. 两个企微账号的群、SOP、消息和报表完全隔离。
+9. 门店员工不能发布总部 SOP,也不能查看其他门店完整消息。
+10. 所有批准、编辑、发送、跳过、误报和关闭动作都能审计。
+
+### 9.2 Demo 闭环
+
+建议销售 Demo 控制在 30 秒:
+
+1. 展示总部已发布“新客群 7 天 SOP”。
+2. 对一个新客群说:“按公司 SOP 生成明天计划和话术。”
+3. Agent 返回计划和风险检查,员工在工作台确认一条话术。
+4. 切换管理视角,看到执行已完成;另一个群因客户问题超时未答产生预警。
+5. 点击预警查看原消息、负责人和建议动作。
+
+## 10. 工作量拆分建议
+
+| 工作包 | 主要交付 | 估算 |
+|---|---|---:|
+| 群消息与账号底座 | 账号隔离、增量分流、游标、回执 smoke | 3~5 人日 |
+| 数据层与服务层 | 表结构、迁移、store/service、审计、幂等 | 3~4 人日 |
+| Skill 与 MCP | skill、7 个工具、上下文装配、输出契约 | 3~4 人日 |
+| SOP/计划 Dashboard | 总览、工作台、群详情、SOP 页面 | 5~7 人日 |
+| 质检引擎 | 规则、模型分类、证据、评分、预警闭环 | 4~6 人日 |
+| 测试与试点 | sample、smoke、评测集、20 群试点支持 | 3~5 人日 |
+
+合计约 21~31 人日,可由后端/Agent 与前端并行;以上为方案阶段估算,需在 Phase 0 接口实测后校准。
+
+## 11. 风险与待确认事项
+
+开发前必须由业务负责人确认:
+
+1. 首批试点的 3 家门店、20 个群和具体负责人是谁?
+2. 首个群类型是新客群、成交服务群、售后群还是会员群?建议只选一种做 MVP。
+3. 公司已有可整理的优秀话术、禁用词、服务承诺和响应 SLA 吗?若没有,谁负责审核 AI 生成的第一版?
+4. 门店允许系统直接发送,还是首期只允许“生成 + 复制 + 人工标记已执行”?
+5. 投诉、价格、退款、医疗/法律等哪些场景必须升级人工?
+6. 总部、区域、门店的查看和发布权限如何划分?
+7. 群消息允许保存多久,哪些字段需要脱敏,是否取得内部合规授权?
+8. 用什么业务结果证明成功:响应速度、运营工时、到店/成交、复购,还是投诉下降?
+
+## 12. 最终建议
+
+不要把本需求只做成“话术生成器”或“群数据看板”。前者仍然依赖门店自觉,后者只能看问题不能推动执行。首个可售、可验收的闭环应是:
+
+> 总部发布标准 → Agent 为每个群生成每日计划和话术 → 门店人工确认执行 → 系统自动质检 → 总部查看执行率和风险并下发整改。
+
+技术上首期采用一个统一 `qiwei-group-operations` skill,加一个“社群运营”Dashboard 模块;在数据规模、角色和自动化策略稳定后,再拆分 SOP 管理、群运营 Copilot 和质检督导 skill。

+ 11 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js

@@ -165,9 +165,20 @@ function writeJsonAtomic(filePath, value) {
 }
 }
 
 
 function resolveClaudeExecutable(config = {}) {
 function resolveClaudeExecutable(config = {}) {
+  const pathCandidates = String(process.env.PATH || '')
+    .split(path.delimiter)
+    .map(item => item.trim().replace(/^"|"$/g, ''))
+    .filter(Boolean)
+    .flatMap(dir => process.platform === 'win32'
+      ? [
+          path.join(dir, 'claude.exe'),
+          path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
+        ]
+      : [path.join(dir, 'claude')]);
   const candidates = [
   const candidates = [
     config.claudeExecutable,
     config.claudeExecutable,
     process.env.CLAUDE_CODE_EXECUTABLE,
     process.env.CLAUDE_CODE_EXECUTABLE,
+    ...pathCandidates,
     path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
     path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
     path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
     path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
   ].filter(Boolean);
   ].filter(Boolean);

+ 41 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/group-operations-access.js

@@ -0,0 +1,41 @@
+'use strict';
+
+const ROLE_CAPABILITIES = Object.freeze({
+  headquarters_admin: ['*'],
+  regional_manager: ['read', 'manage_plans', 'execute_items', 'manage_findings', 'manage_tasks', 'manage_quality'],
+  store_manager: ['read', 'manage_plans', 'execute_items', 'manage_findings', 'manage_tasks'],
+  executor: ['read', 'manage_plans', 'execute_items', 'manage_findings'],
+  auditor: ['read']
+});
+
+function hasCapability(binding, capability) {
+  const capabilities = ROLE_CAPABILITIES[binding?.role] || [];
+  return capabilities.includes('*') || capabilities.includes(capability);
+}
+
+function resolveTargetStore(store, accountKey, input = {}) {
+  if (input.storeId) return String(input.storeId);
+  if (input.roomId) return String(store.getGroup(accountKey, input.roomId)?.store_id || '');
+  if (input.itemId) return String(store.getPlanItemWithContext(accountKey, input.itemId)?.store_id || '');
+  if (input.findingId) {
+    const finding = store.db.prepare('SELECT room_id FROM group_ops_findings WHERE id=? AND account_key=?').get(input.findingId, accountKey);
+    return String(finding?.room_id ? store.getGroup(accountKey, finding.room_id)?.store_id || '' : '');
+  }
+  if (input.taskId) return String(store.getTask(accountKey, input.taskId)?.room_id ? store.getGroup(accountKey, store.getTask(accountKey, input.taskId).room_id)?.store_id || '' : '');
+  return '';
+}
+
+function authorizeGroupOps(store, accountKey, principal, capability, input = {}) {
+  const bindings = store.listRoleBindings(accountKey);
+  if (!bindings.length) return { allowed: true, mode: 'bootstrap', warning: '当前账号尚未配置角色绑定,沿用兼容模式。' };
+  const binding = store.getRoleBinding(accountKey, principal);
+  if (!binding) return { allowed: false, reason: '当前身份未被授予社群运营角色' };
+  if (!hasCapability(binding, capability)) return { allowed: false, reason: `角色 ${binding.role} 没有 ${capability} 权限` };
+  if (binding.role === 'headquarters_admin' || capability === 'read' && binding.role === 'auditor') return { allowed: true, binding };
+  const targetStore = resolveTargetStore(store, accountKey, input);
+  if (!targetStore) return { allowed: capability === 'read', binding, reason: capability === 'read' ? '' : '该操作缺少可校验的门店范围' };
+  if (!binding.storeScope.includes(targetStore)) return { allowed: false, reason: `无权操作门店 ${targetStore}` };
+  return { allowed: true, binding, targetStore };
+}
+
+module.exports = { ROLE_CAPABILITIES, authorizeGroupOps };

+ 842 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/group-operations-store.js

@@ -0,0 +1,842 @@
+const path = require('path');
+const { randomUUID, createHash } = require('crypto');
+const { DatabaseSync } = require('node:sqlite');
+const { outputsRoot } = require('./output-paths');
+
+function now() { return new Date().toISOString(); }
+function id(prefix) { return `${prefix}_${randomUUID()}`; }
+function json(value) { return JSON.stringify(value ?? null); }
+function parse(value, fallback) {
+  try { return JSON.parse(value); } catch { return fallback; }
+}
+function hash(value) { return createHash('sha256').update(String(value)).digest('hex'); }
+function accountKey(value) { return String(value || 'local-default').trim() || 'local-default'; }
+function isQuestion(content) { return /[??]|怎么|如何|多少|什么时候|可以吗|有没有|是否|哪[里个]|为何|为什么/.test(String(content || '')); }
+function meaningfulResponse(question, response) {
+  const answer = String(response || '').trim();
+  if (!answer) return false;
+  const prompt = String(question || '').trim();
+  const genericNoise = /^(收到|好的|嗯|哦|稍等|在的|您好|你好|谢谢)[!!。,.,\s]*$/;
+  if (genericNoise.test(answer)) return false;
+  const intentRules = [
+    [/什么时候|多久|几点|日期|时间/, /(今天|明天|后天|上午|下午|晚上|点|分钟|小时|天|周|月|预计|之前|之后|稍后).*(处理|到账|完成|联系|回复|安排|提交|发货|到店|生效)|(处理|到账|完成|联系|回复|安排|提交|发货|到店|生效).*(今天|明天|后天|上午|下午|晚上|点|分钟|小时|天|周|月|预计|之前|之后|稍后)/],
+    [/多少|价格|费用|金额|退款/, /元|块|价格|费用|金额|退款|原路|到账|支付/],
+    [/怎么|如何|流程|办法/, /请|需要|可以|步骤|先|再|提交|联系|操作|处理/],
+    [/可以吗|能否|能不能|是否|有没有/, /可以|不可以|能|不能|有|没有|支持|暂不|需要/]
+  ];
+  const matchedIntent = intentRules.find(([questionPattern]) => questionPattern.test(prompt));
+  if (matchedIntent && matchedIntent[1].test(answer)) return true;
+  const tokens = [...new Set((prompt.match(/[\u4e00-\u9fff]{2,}|[a-zA-Z0-9]{2,}/g) || [])
+    .flatMap(token => token.length > 4 && /^[\u4e00-\u9fff]+$/.test(token) ? Array.from({ length: token.length - 1 }, (_, index) => token.slice(index, index + 2)) : [token])
+    .filter(token => !['请问', '什么', '怎么', '如何', '可以', '有没有', '时候'].includes(token)))];
+  return tokens.some(token => answer.includes(token));
+}
+function diffObjects(before = {}, after = {}) {
+  const keys = [...new Set([...Object.keys(before || {}), ...Object.keys(after || {})])];
+  return keys.filter(key => json(before?.[key]) !== json(after?.[key])).map(key => ({ key, before: before?.[key], after: after?.[key] }));
+}
+function shanghaiParts(value = new Date()) {
+  const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(value);
+  const get = type => parts.find(item => item.type === type)?.value || '';
+  const weekdays = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
+  return { date: `${get('year')}-${get('month')}-${get('day')}`, time: `${get('hour')}:${get('minute')}`, weekday: weekdays[get('weekday')] || 1 };
+}
+
+class GroupOperationsStore {
+  constructor(filePath = path.join(outputsRoot(), 'group-operations', 'group-operations.db')) {
+    require('fs').mkdirSync(path.dirname(filePath), { recursive: true });
+    this.filePath = filePath;
+    this.db = new DatabaseSync(filePath);
+    this.db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;');
+    this.init();
+  }
+
+  init() {
+    this.db.exec(`
+      CREATE TABLE IF NOT EXISTS group_ops_groups (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL,
+        room_name TEXT NOT NULL DEFAULT '', store_id TEXT NOT NULL DEFAULT '', owner_id TEXT NOT NULL DEFAULT '',
+        group_type TEXT NOT NULL DEFAULT 'customer', lifecycle_stage TEXT NOT NULL DEFAULT 'active',
+        playbook_id TEXT, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+        UNIQUE(account_key, room_id)
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_playbooks (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, name TEXT NOT NULL, group_type TEXT NOT NULL DEFAULT 'customer',
+        status TEXT NOT NULL DEFAULT 'draft', current_version_id TEXT, scope_json TEXT NOT NULL DEFAULT '{}',
+        created_at TEXT NOT NULL, updated_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_playbook_versions (
+        id TEXT PRIMARY KEY, playbook_id TEXT NOT NULL REFERENCES group_ops_playbooks(id) ON DELETE CASCADE,
+        version INTEGER NOT NULL, content_json TEXT NOT NULL, change_note TEXT NOT NULL DEFAULT '',
+        created_by TEXT NOT NULL DEFAULT 'agent', created_at TEXT NOT NULL, published_at TEXT,
+        UNIQUE(playbook_id, version)
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_plans (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL, plan_date TEXT NOT NULL,
+        playbook_version_id TEXT, status TEXT NOT NULL DEFAULT 'draft', generated_by TEXT NOT NULL DEFAULT 'agent',
+        created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(account_key, room_id, plan_date)
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_plan_items (
+        id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES group_ops_plans(id) ON DELETE CASCADE,
+        scheduled_at TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT 'content', objective TEXT NOT NULL DEFAULT '',
+        draft_content TEXT NOT NULL DEFAULT '', final_content TEXT NOT NULL DEFAULT '',
+        status TEXT NOT NULL DEFAULT 'pending_review', idempotency_key TEXT NOT NULL UNIQUE,
+        skip_reason TEXT NOT NULL DEFAULT '', external_message_id TEXT NOT NULL DEFAULT '',
+        created_at TEXT NOT NULL, updated_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_messages (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL, external_message_id TEXT NOT NULL,
+        sender_id TEXT NOT NULL DEFAULT '', sender_role TEXT NOT NULL DEFAULT 'unknown', content TEXT NOT NULL DEFAULT '',
+        message_type TEXT NOT NULL DEFAULT 'text', sent_at TEXT NOT NULL, raw_json TEXT NOT NULL DEFAULT '{}',
+        created_at TEXT NOT NULL, UNIQUE(account_key, external_message_id)
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_findings (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
+        type TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'medium', title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '',
+        evidence_json TEXT NOT NULL DEFAULT '[]', rule_version TEXT NOT NULL DEFAULT 'mvp-1', confidence REAL NOT NULL DEFAULT 1,
+        status TEXT NOT NULL DEFAULT 'open', assignee TEXT NOT NULL DEFAULT '', due_at TEXT NOT NULL DEFAULT '',
+        resolution TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+        UNIQUE(account_key, room_id, fingerprint)
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_audit_logs (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL,
+        entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, before_json TEXT NOT NULL DEFAULT '{}',
+        after_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_tasks (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, room_id TEXT NOT NULL DEFAULT '', finding_id TEXT,
+        title TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '', due_at TEXT NOT NULL DEFAULT '', priority TEXT NOT NULL DEFAULT 'medium',
+        status TEXT NOT NULL DEFAULT 'open', official_todo_id TEXT NOT NULL DEFAULT '', official_sync_status TEXT NOT NULL DEFAULT '',
+        created_at TEXT NOT NULL, updated_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_quality_settings (
+        account_key TEXT PRIMARY KEY, sla_minutes INTEGER NOT NULL DEFAULT 120,
+        risk_terms_json TEXT NOT NULL DEFAULT '[]', forbidden_claims_json TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_feedback (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, finding_id TEXT NOT NULL, label TEXT NOT NULL,
+        note TEXT NOT NULL DEFAULT '', actor TEXT NOT NULL DEFAULT 'human', created_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_automation_policies (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, version INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'draft',
+        policy_json TEXT NOT NULL, change_note TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT 'agent',
+        created_at TEXT NOT NULL, activated_at TEXT, UNIQUE(account_key, version)
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_role_bindings (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, principal TEXT NOT NULL,
+        role TEXT NOT NULL, store_scope_json TEXT NOT NULL DEFAULT '[]', status TEXT NOT NULL DEFAULT 'active',
+        created_by TEXT NOT NULL DEFAULT 'system', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+        UNIQUE(account_key, principal)
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_automation_state (
+        account_key TEXT PRIMARY KEY, paused INTEGER NOT NULL DEFAULT 1, consecutive_failures INTEGER NOT NULL DEFAULT 0,
+        breaker_open INTEGER NOT NULL DEFAULT 0, last_failure TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_send_attempts (
+        id TEXT PRIMARY KEY, account_key TEXT NOT NULL, plan_item_id TEXT NOT NULL, room_id TEXT NOT NULL,
+        idempotency_key TEXT NOT NULL, mode TEXT NOT NULL, status TEXT NOT NULL, decision_json TEXT NOT NULL DEFAULT '{}',
+        external_message_id TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', policy_version INTEGER,
+        created_at TEXT NOT NULL, completed_at TEXT
+      );
+      CREATE TABLE IF NOT EXISTS group_ops_daily_scores (
+        account_key TEXT NOT NULL, room_id TEXT NOT NULL, score_date TEXT NOT NULL,
+        component_json TEXT NOT NULL DEFAULT '{}', coverage REAL NOT NULL DEFAULT 0,
+        total_score REAL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+        PRIMARY KEY(account_key, room_id, score_date)
+      );
+      CREATE INDEX IF NOT EXISTS idx_group_ops_plan_day ON group_ops_plans(account_key, plan_date, status);
+      CREATE INDEX IF NOT EXISTS idx_group_ops_findings ON group_ops_findings(account_key, status, severity, created_at DESC);
+      CREATE INDEX IF NOT EXISTS idx_group_ops_messages ON group_ops_messages(account_key, room_id, sent_at);
+      CREATE INDEX IF NOT EXISTS idx_group_ops_tasks ON group_ops_tasks(account_key, status, due_at);
+      CREATE INDEX IF NOT EXISTS idx_group_ops_feedback ON group_ops_feedback(account_key, label, created_at DESC);
+      CREATE INDEX IF NOT EXISTS idx_group_ops_policy ON group_ops_automation_policies(account_key, status, version DESC);
+      CREATE INDEX IF NOT EXISTS idx_group_ops_roles ON group_ops_role_bindings(account_key, principal, status);
+      CREATE INDEX IF NOT EXISTS idx_group_ops_attempts ON group_ops_send_attempts(account_key, room_id, status, created_at DESC);
+      CREATE INDEX IF NOT EXISTS idx_group_ops_scores ON group_ops_daily_scores(account_key, score_date, total_score);
+    `);
+  }
+
+  audit(key, actor, action, entityType, entityId, before = {}, after = {}) {
+    const auditId = id('goa');
+    this.db.prepare('INSERT INTO group_ops_audit_logs VALUES(?,?,?,?,?,?,?,?,?)')
+      .run(auditId, accountKey(key), actor || 'agent', action, entityType, entityId, json(before), json(after), now());
+    return auditId;
+  }
+
+  upsertGroup(key, group = {}, actor = 'agent') {
+    const account = accountKey(key);
+    const roomId = String(group.roomId || group.room_id || '').trim();
+    if (!roomId) throw new Error('缺少 roomId');
+    const before = this.getGroup(account, roomId);
+    const timestamp = now();
+    if (before) {
+      this.db.prepare(`UPDATE group_ops_groups SET room_name=?,store_id=?,owner_id=?,group_type=?,lifecycle_stage=?,
+        playbook_id=COALESCE(?,playbook_id),status=?,updated_at=? WHERE account_key=? AND room_id=?`)
+        .run(group.roomName ?? before.room_name, group.storeId ?? before.store_id, group.ownerId ?? before.owner_id,
+          group.groupType ?? before.group_type, group.lifecycleStage ?? before.lifecycle_stage, group.playbookId || null,
+          group.status ?? before.status, timestamp, account, roomId);
+    } else {
+      this.db.prepare(`INSERT INTO group_ops_groups VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
+        .run(id('gog'), account, roomId, group.roomName || '', group.storeId || '', group.ownerId || '',
+          group.groupType || 'customer', group.lifecycleStage || 'active', group.playbookId || null,
+          group.status || 'active', timestamp, timestamp);
+    }
+    const after = this.getGroup(account, roomId);
+    this.audit(account, actor, before ? 'group.update' : 'group.create', 'group', after.id, before || {}, after);
+    return after;
+  }
+
+  getGroup(key, roomId) {
+    return this.db.prepare('SELECT * FROM group_ops_groups WHERE account_key=? AND room_id=?').get(accountKey(key), String(roomId)) || null;
+  }
+
+  listGroups(key) {
+    return this.db.prepare('SELECT * FROM group_ops_groups WHERE account_key=? ORDER BY updated_at DESC').all(accountKey(key));
+  }
+
+  listRoleBindings(key) {
+    return this.db.prepare(`SELECT * FROM group_ops_role_bindings WHERE account_key=? ORDER BY principal`)
+      .all(accountKey(key)).map(row => ({ ...row, storeScope: parse(row.store_scope_json, []) }));
+  }
+
+  getRoleBinding(key, principal) {
+    const row = this.db.prepare(`SELECT * FROM group_ops_role_bindings WHERE account_key=? AND principal=? AND status='active'`)
+      .get(accountKey(key), String(principal || '').trim());
+    return row ? { ...row, storeScope: parse(row.store_scope_json, []) } : null;
+  }
+
+  upsertRoleBinding(key, input = {}, actor = 'system') {
+    const account = accountKey(key);
+    const principal = String(input.principal || '').trim();
+    if (!principal) throw new Error('缺少 principal(通常为企微 userId)');
+    const role = String(input.role || '').trim();
+    const storeScope = [...new Set((input.storeScope || input.storeIds || []).map(String).map(item => item.trim()).filter(Boolean))];
+    const before = this.getRoleBinding(account, principal);
+    const timestamp = now();
+    const bindingId = before?.id || id('gor');
+    this.db.prepare(`INSERT INTO group_ops_role_bindings VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(account_key,principal) DO UPDATE SET
+      role=excluded.role,store_scope_json=excluded.store_scope_json,status=excluded.status,updated_at=excluded.updated_at`)
+      .run(bindingId, account, principal, role, json(storeScope), input.status || 'active', actor, before?.created_at || timestamp, timestamp);
+    const after = this.getRoleBinding(account, principal);
+    this.audit(account, actor, 'role_binding.upsert', 'role_binding', bindingId, before || {}, after || {});
+    return after;
+  }
+
+  createPlaybook(key, input = {}, actor = 'agent') {
+    const account = accountKey(key);
+    const playbookId = id('gopb');
+    const versionId = id('gopbv');
+    const timestamp = now();
+    const content = input.content || { cadence: [], templates: [], rules: {} };
+    this.db.exec('BEGIN');
+    try {
+      this.db.prepare('INSERT INTO group_ops_playbooks VALUES(?,?,?,?,?,?,?,?,?)')
+        .run(playbookId, account, String(input.name || '未命名群运营 SOP'), input.groupType || 'customer', 'draft', null, json(input.scope || {}), timestamp, timestamp);
+      this.db.prepare('INSERT INTO group_ops_playbook_versions VALUES(?,?,?,?,?,?,?,?)')
+        .run(versionId, playbookId, 1, json(content), input.changeNote || '创建初始版本', actor, timestamp, null);
+      this.db.exec('COMMIT');
+    } catch (error) { this.db.exec('ROLLBACK'); throw error; }
+    const result = this.getPlaybook(account, playbookId);
+    this.audit(account, actor, 'playbook.create', 'playbook', playbookId, {}, result);
+    return result;
+  }
+
+  listPlaybooks(key) {
+    return this.db.prepare(`SELECT p.*,v.version,v.content_json,v.published_at FROM group_ops_playbooks p
+      LEFT JOIN group_ops_playbook_versions v ON v.id=CASE WHEN p.status='published' THEN p.current_version_id ELSE
+        (SELECT id FROM group_ops_playbook_versions x WHERE x.playbook_id=p.id ORDER BY version DESC LIMIT 1)
+      END
+      WHERE p.account_key=? ORDER BY p.updated_at DESC`).all(accountKey(key)).map(this.hydratePlaybook);
+  }
+
+  hydratePlaybook(row) { return row ? { ...row, scope: parse(row.scope_json, {}), content: parse(row.content_json, {}) } : null; }
+
+  getPlaybook(key, playbookId) {
+    const row = this.db.prepare(`SELECT p.*,v.version,v.content_json,v.published_at FROM group_ops_playbooks p
+      LEFT JOIN group_ops_playbook_versions v ON v.id=COALESCE(p.current_version_id,
+        (SELECT id FROM group_ops_playbook_versions x WHERE x.playbook_id=p.id ORDER BY version DESC LIMIT 1))
+      WHERE p.account_key=? AND p.id=?`).get(accountKey(key), playbookId);
+    return this.hydratePlaybook(row);
+  }
+
+  addPlaybookVersion(key, playbookId, content, changeNote = '', actor = 'agent') {
+    const before = this.getPlaybook(key, playbookId);
+    if (!before) throw new Error('SOP 不存在或不属于当前账号');
+    const next = Number(this.db.prepare('SELECT MAX(version) value FROM group_ops_playbook_versions WHERE playbook_id=?').get(playbookId)?.value || 0) + 1;
+    const versionId = id('gopbv');
+    this.db.prepare('INSERT INTO group_ops_playbook_versions VALUES(?,?,?,?,?,?,?,?)')
+      .run(versionId, playbookId, next, json(content || {}), changeNote, actor, now(), null);
+    this.db.prepare("UPDATE group_ops_playbooks SET status=CASE WHEN current_version_id IS NULL THEN 'draft' ELSE status END,updated_at=? WHERE id=?").run(now(), playbookId);
+    const after = this.getPlaybook(key, playbookId);
+    this.audit(key, actor, 'playbook.version.create', 'playbook', playbookId, before, after);
+    return { ...after, version: next, draftVersion: next, draftVersionId: versionId, draftContent: content || {} };
+  }
+
+  publishPlaybook(key, playbookId, version, actor = 'agent') {
+    const before = this.getPlaybook(key, playbookId);
+    if (!before) throw new Error('SOP 不存在或不属于当前账号');
+    const target = version
+      ? this.db.prepare('SELECT * FROM group_ops_playbook_versions WHERE playbook_id=? AND version=?').get(playbookId, Number(version))
+      : this.db.prepare('SELECT * FROM group_ops_playbook_versions WHERE playbook_id=? ORDER BY version DESC LIMIT 1').get(playbookId);
+    if (!target) throw new Error('SOP 版本不存在');
+    const timestamp = now();
+    this.db.prepare('UPDATE group_ops_playbook_versions SET published_at=COALESCE(published_at,?) WHERE id=?').run(timestamp, target.id);
+    this.db.prepare("UPDATE group_ops_playbooks SET status='published',current_version_id=?,updated_at=? WHERE id=?").run(target.id, timestamp, playbookId);
+    const after = this.getPlaybook(key, playbookId);
+    this.audit(key, actor, 'playbook.publish', 'playbook', playbookId, before, after);
+    return after;
+  }
+
+  listPlaybookVersions(key, playbookId) {
+    const playbook = this.db.prepare('SELECT * FROM group_ops_playbooks WHERE id=? AND account_key=?').get(playbookId, accountKey(key));
+    if (!playbook) throw new Error('SOP 不存在或不属于当前账号');
+    return this.db.prepare('SELECT * FROM group_ops_playbook_versions WHERE playbook_id=? ORDER BY version DESC').all(playbookId)
+      .map(row => ({ ...row, content: parse(row.content_json, {}) }));
+  }
+
+  previewPlaybookPublish(key, playbookId, version) {
+    const account = accountKey(key);
+    const playbook = this.db.prepare('SELECT * FROM group_ops_playbooks WHERE id=? AND account_key=?').get(playbookId, account);
+    if (!playbook) throw new Error('SOP 不存在或不属于当前账号');
+    const versions = this.listPlaybookVersions(account, playbookId);
+    const target = version ? versions.find(item => item.version === Number(version)) : versions[0];
+    if (!target) throw new Error('目标版本不存在');
+    const current = versions.find(item => item.id === playbook.current_version_id) || null;
+    const groups = this.db.prepare(`SELECT room_id,room_name,store_id,group_type,lifecycle_stage FROM group_ops_groups
+      WHERE account_key=? AND status='active' AND (playbook_id=? OR (playbook_id IS NULL AND group_type=?)) ORDER BY store_id,room_name`).all(account, playbookId, playbook.group_type);
+    return {
+      playbookId, name: playbook.name, targetVersion: target.version, currentVersion: current?.version || null,
+      changes: diffObjects(current?.content || {}, target.content || {}), affectedGroupCount: groups.length,
+      affectedStores: [...new Set(groups.map(item => item.store_id).filter(Boolean))], groups
+    };
+  }
+
+  generatePlan(key, input = {}, actor = 'agent') {
+    const account = accountKey(key);
+    const roomId = String(input.roomId || '').trim();
+    if (!roomId) throw new Error('缺少 roomId');
+    const group = this.getGroup(account, roomId) || this.upsertGroup(account, input.group || { roomId, roomName: input.roomName || '' }, actor);
+    const planDate = String(input.planDate || new Date().toISOString().slice(0, 10));
+    const existing = this.db.prepare('SELECT * FROM group_ops_plans WHERE account_key=? AND room_id=? AND plan_date=?').get(account, roomId, planDate);
+    if (existing) return this.getPlan(existing.id);
+    const playbook = input.playbookId
+      ? this.getPlaybook(account, input.playbookId)
+      : (group.playbook_id
+          ? this.getPlaybook(account, group.playbook_id)
+          : this.listPlaybooks(account).find(item => item.status === 'published' && item.group_type === group.group_type));
+    if (playbook && playbook.status !== 'published' && !(Array.isArray(input.items) && input.items.length)) {
+      throw new Error('只能依据已发布 SOP 生成计划');
+    }
+    const items = Array.isArray(input.items) && input.items.length ? input.items : (playbook?.content?.cadence || []);
+    if (!items.length) throw new Error('没有可生成的计划项,请传入 items 或先发布包含 cadence 的 SOP');
+    const planId = id('goplan');
+    const timestamp = now();
+    this.db.prepare('INSERT INTO group_ops_plans VALUES(?,?,?,?,?,?,?,?,?)')
+      .run(planId, account, roomId, planDate, playbook?.current_version_id || null, 'draft', actor, timestamp, timestamp);
+    const insert = this.db.prepare('INSERT INTO group_ops_plan_items VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)');
+    items.forEach((item, index) => {
+      const content = String(item.content || item.template || '').replace(/\{\{room_name\}\}/g, group.room_name || input.roomName || '客户群');
+      insert.run(id('gopi'), planId, item.scheduledAt || `${planDate}T${item.time || '09:30'}:00+08:00`, item.type || 'content',
+        item.objective || '', content, '', 'pending_review', hash(`${account}|${roomId}|${planDate}|${index}|${item.type || 'content'}`), '', '', timestamp, timestamp);
+    });
+    this.audit(account, actor, 'plan.generate', 'plan', planId, {}, { roomId, planDate, itemCount: items.length, playbookId: playbook?.id || null });
+    return this.getPlan(planId);
+  }
+
+  batchGeneratePlans(key, input = {}, actor = 'agent') {
+    const account = accountKey(key);
+    const selected = Array.isArray(input.groups) && input.groups.length
+      ? input.groups
+      : this.listGroups(account).filter(group => !input.storeId || group.store_id === input.storeId);
+    const results = [];
+    for (const group of selected) {
+      try {
+        const plan = this.generatePlan(account, { ...input, roomId: group.roomId || group.room_id, roomName: group.roomName || group.room_name, group }, actor);
+        results.push({ roomId: plan.room_id, status: 'ok', planId: plan.id, itemCount: plan.items.length });
+      } catch (error) {
+        results.push({ roomId: group.roomId || group.room_id, status: 'error', error: error.message });
+      }
+    }
+    this.audit(account, actor, 'plan.batch_generate', 'plan_batch', `${input.planDate || new Date().toISOString().slice(0, 10)}`, {}, { count: selected.length, success: results.filter(item => item.status === 'ok').length });
+    return { total: selected.length, success: results.filter(item => item.status === 'ok').length, failed: results.filter(item => item.status === 'error').length, results };
+  }
+
+  getPlan(planId) {
+    const plan = this.db.prepare('SELECT * FROM group_ops_plans WHERE id=?').get(planId);
+    if (!plan) return null;
+    return { ...plan, items: this.db.prepare('SELECT * FROM group_ops_plan_items WHERE plan_id=? ORDER BY scheduled_at,id').all(planId) };
+  }
+
+  updatePlanItem(key, itemId, action, fields = {}, actor = 'agent') {
+    const before = this.db.prepare(`SELECT i.*,p.account_key FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id WHERE i.id=? AND p.account_key=?`).get(itemId, accountKey(key));
+    if (!before) throw new Error('计划项不存在或不属于当前账号');
+    const allowed = { approve: 'approved', reject: 'rejected', skip: 'skipped', mark_sent: 'sent' };
+    if (!allowed[action]) throw new Error('不支持的计划项操作');
+    const finalContent = fields.content === undefined ? before.final_content : String(fields.content);
+    if (action === 'approve' && !String(finalContent || before.draft_content).trim()) throw new Error('最终话术不能为空');
+    this.db.prepare('UPDATE group_ops_plan_items SET status=?,final_content=?,skip_reason=?,external_message_id=?,updated_at=? WHERE id=?')
+      .run(allowed[action], finalContent || (action === 'approve' ? before.draft_content : before.final_content), fields.reason || '', fields.externalMessageId || '', now(), itemId);
+    const after = this.db.prepare('SELECT * FROM group_ops_plan_items WHERE id=?').get(itemId);
+    const auditId = this.audit(key, actor, `plan_item.${action}`, 'plan_item', itemId, before, after);
+    return { ...after, auditId };
+  }
+
+  ingestMessages(key, roomId, messages = []) {
+    const account = accountKey(key);
+    const insert = this.db.prepare('INSERT OR IGNORE INTO group_ops_messages VALUES(?,?,?,?,?,?,?,?,?,?,?)');
+    let inserted = 0;
+    for (const message of messages) {
+      const externalId = String(message.messageId || message.msgId || message.externalMessageId || hash(json(message)));
+      inserted += Number(insert.run(id('gom'), account, roomId, externalId, message.senderId || '', message.senderRole || 'unknown',
+        message.content || '', message.messageType || message.msgType || 'text', message.sentAt || message.timestamp || now(), json(message.raw || message.rawData || {}), now()).changes || 0);
+    }
+    return inserted;
+  }
+
+  getQualitySettings(key) {
+    const account = accountKey(key);
+    const row = this.db.prepare('SELECT * FROM group_ops_quality_settings WHERE account_key=?').get(account);
+    if (!row) return { account_key: account, sla_minutes: 120, risk_terms: ['投诉', '退款', '欺骗', '曝光', '举报', '律师', '赔偿'], forbidden_claims: ['保证', '百分百', '绝对', '最低价', '无条件退款'] };
+    return { ...row, risk_terms: parse(row.risk_terms_json, []), forbidden_claims: parse(row.forbidden_claims_json, []) };
+  }
+
+  setQualitySettings(key, input = {}, actor = 'agent') {
+    const account = accountKey(key);
+    const before = this.getQualitySettings(account);
+    const next = {
+      slaMinutes: Math.max(1, Number(input.slaMinutes || before.sla_minutes || 120)),
+      riskTerms: Array.isArray(input.riskTerms) ? input.riskTerms.map(String).filter(Boolean) : before.risk_terms,
+      forbiddenClaims: Array.isArray(input.forbiddenClaims) ? input.forbiddenClaims.map(String).filter(Boolean) : before.forbidden_claims
+    };
+    this.db.prepare(`INSERT INTO group_ops_quality_settings(account_key,sla_minutes,risk_terms_json,forbidden_claims_json,updated_at)
+      VALUES(?,?,?,?,?) ON CONFLICT(account_key) DO UPDATE SET sla_minutes=excluded.sla_minutes,risk_terms_json=excluded.risk_terms_json,
+      forbidden_claims_json=excluded.forbidden_claims_json,updated_at=excluded.updated_at`)
+      .run(account, next.slaMinutes, json(next.riskTerms), json(next.forbiddenClaims), now());
+    const after = this.getQualitySettings(account);
+    this.audit(account, actor, 'quality_settings.update', 'quality_settings', account, before, after);
+    return after;
+  }
+
+  reviewMessages(key, input = {}, actor = 'agent') {
+    const account = accountKey(key);
+    const roomId = String(input.roomId || '').trim();
+    if (!roomId) throw new Error('缺少 roomId');
+    this.ingestMessages(account, roomId, input.messages || []);
+    const clauses = ['account_key=?', 'room_id=?'];
+    const params = [account, roomId];
+    if (input.startAt) { clauses.push('sent_at>=?'); params.push(String(input.startAt)); }
+    if (input.endAt) { clauses.push('sent_at<=?'); params.push(String(input.endAt)); }
+    const messages = this.db.prepare(`SELECT * FROM group_ops_messages WHERE ${clauses.join(' AND ')} ORDER BY sent_at`).all(...params);
+    const settings = this.getQualitySettings(account);
+    const slaMinutes = Math.max(1, Number(input.slaMinutes || settings.sla_minutes || 120));
+    const riskTerms = input.riskTerms || settings.risk_terms;
+    const findings = [];
+    for (let index = 0; index < messages.length; index++) {
+      const message = messages[index];
+      const content = String(message.content || '');
+      const matched = riskTerms.filter(term => content.includes(term));
+      if (matched.length) findings.push(this.upsertFinding(account, roomId, {
+        fingerprint: `risk:${message.external_message_id}`, type: 'service_risk', severity: matched.some(t => ['投诉', '举报', '律师'].includes(t)) ? 'high' : 'medium',
+        title: '发现客户服务风险', detail: `命中风险词:${matched.join('、')}`, evidence: [{ messageId: message.external_message_id, content, sentAt: message.sent_at }], confidence: 1
+      }, actor));
+      const customerQuestion = message.sender_role === 'customer' && isQuestion(content);
+      if (!customerQuestion) continue;
+      const response = messages.slice(index + 1).find(item => item.sender_role === 'staff' && meaningfulResponse(content, item.content));
+      const elapsed = response ? (new Date(response.sent_at) - new Date(message.sent_at)) / 60000 : (Date.now() - new Date(message.sent_at).getTime()) / 60000;
+      if (!response || elapsed > slaMinutes) findings.push(this.upsertFinding(account, roomId, {
+        fingerprint: `unanswered:${message.external_message_id}`, type: 'unanswered_question', severity: elapsed > slaMinutes * 2 ? 'high' : 'medium',
+        title: '客户问题未在 SLA 内回应', detail: `响应时限 ${slaMinutes} 分钟,当前 ${response ? `用时 ${Math.round(elapsed)} 分钟` : '尚未回应'}`,
+        evidence: [{ messageId: message.external_message_id, content, sentAt: message.sent_at }], confidence: 0.95
+      }, actor));
+      else {
+        const prior = this.db.prepare(`SELECT * FROM group_ops_findings WHERE account_key=? AND room_id=? AND fingerprint=?
+          AND status IN ('open','acknowledged')`).get(account, roomId, `unanswered:${message.external_message_id}`);
+        if (prior) this.updateFinding(account, prior.id, { status: 'resolved', resolution: `检测到 SLA 内有效回复:${response.external_message_id}` }, actor);
+      }
+    }
+    this.audit(account, actor, 'messages.review', 'group', roomId, {}, { messageCount: messages.length, findingCount: findings.length });
+    return { roomId, messageCount: messages.length, findingCount: findings.length, findings };
+  }
+
+  upsertFinding(key, roomId, finding, actor = 'agent') {
+    const account = accountKey(key);
+    const existing = this.db.prepare('SELECT * FROM group_ops_findings WHERE account_key=? AND room_id=? AND fingerprint=?').get(account, roomId, finding.fingerprint);
+    if (existing) return { ...existing, evidence: parse(existing.evidence_json, []) };
+    const findingId = id('gof');
+    const timestamp = now();
+    this.db.prepare('INSERT INTO group_ops_findings VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
+      .run(findingId, account, roomId, finding.fingerprint, finding.type, finding.severity || 'medium', finding.title, finding.detail || '',
+        json(finding.evidence || []), 'mvp-1', Number(finding.confidence ?? 1), 'open', '', finding.dueAt || '', '', timestamp, timestamp);
+    const created = this.db.prepare('SELECT * FROM group_ops_findings WHERE id=?').get(findingId);
+    this.audit(account, actor, 'finding.create', 'finding', findingId, {}, created);
+    return { ...created, evidence: parse(created.evidence_json, []) };
+  }
+
+  updateFinding(key, findingId, fields = {}, actor = 'agent') {
+    const before = this.db.prepare('SELECT * FROM group_ops_findings WHERE id=? AND account_key=?').get(findingId, accountKey(key));
+    if (!before) throw new Error('质检项不存在或不属于当前账号');
+    const statuses = ['open', 'acknowledged', 'resolved', 'false_positive'];
+    const status = statuses.includes(fields.status) ? fields.status : before.status;
+    this.db.prepare('UPDATE group_ops_findings SET status=?,assignee=?,resolution=?,updated_at=? WHERE id=?')
+      .run(status, fields.assignee ?? before.assignee, fields.resolution ?? before.resolution, now(), findingId);
+    const after = this.db.prepare('SELECT * FROM group_ops_findings WHERE id=?').get(findingId);
+    if (status === 'false_positive' && before.status !== 'false_positive') {
+      this.db.prepare('INSERT INTO group_ops_feedback VALUES(?,?,?,?,?,?,?)')
+        .run(id('gofb'), accountKey(key), findingId, 'false_positive', fields.resolution || fields.note || '', actor, now());
+    }
+    this.audit(key, actor, 'finding.update', 'finding', findingId, before, after);
+    return after;
+  }
+
+  createTask(key, input = {}, actor = 'agent') {
+    const account = accountKey(key);
+    const title = String(input.title || '').trim();
+    if (!title) throw new Error('整改任务标题不能为空');
+    if (input.findingId) {
+      const finding = this.db.prepare('SELECT * FROM group_ops_findings WHERE id=? AND account_key=?').get(input.findingId, account);
+      if (!finding) throw new Error('关联质检项不存在或不属于当前账号');
+    }
+    const taskId = id('got');
+    const timestamp = now();
+    this.db.prepare('INSERT INTO group_ops_tasks VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)')
+      .run(taskId, account, input.roomId || '', input.findingId || null, title, input.owner || '', input.dueAt || '', input.priority || 'medium', 'open', '', '', timestamp, timestamp);
+    const task = this.getTask(account, taskId);
+    this.audit(account, actor, 'task.create', 'task', taskId, {}, task);
+    return task;
+  }
+
+  getTask(key, taskId) {
+    return this.db.prepare('SELECT * FROM group_ops_tasks WHERE id=? AND account_key=?').get(taskId, accountKey(key)) || null;
+  }
+
+  listTasks(key, status = '') {
+    const account = accountKey(key);
+    return status
+      ? this.db.prepare('SELECT * FROM group_ops_tasks WHERE account_key=? AND status=? ORDER BY due_at,created_at DESC').all(account, status)
+      : this.db.prepare("SELECT * FROM group_ops_tasks WHERE account_key=? ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'in_progress' THEN 1 ELSE 2 END,due_at,created_at DESC").all(account);
+  }
+
+  updateTask(key, taskId, fields = {}, actor = 'agent') {
+    const account = accountKey(key);
+    const before = this.getTask(account, taskId);
+    if (!before) throw new Error('整改任务不存在或不属于当前账号');
+    const status = ['open', 'in_progress', 'done', 'cancelled'].includes(fields.status) ? fields.status : before.status;
+    this.db.prepare(`UPDATE group_ops_tasks SET owner=?,due_at=?,priority=?,status=?,official_todo_id=?,official_sync_status=?,updated_at=? WHERE id=?`)
+      .run(fields.owner ?? before.owner, fields.dueAt ?? before.due_at, fields.priority ?? before.priority, status,
+        fields.officialTodoId ?? before.official_todo_id, fields.officialSyncStatus ?? before.official_sync_status, now(), taskId);
+    const after = this.getTask(account, taskId);
+    this.audit(account, actor, 'task.update', 'task', taskId, before, after);
+    return after;
+  }
+
+  feedbackSummary(key) {
+    const account = accountKey(key);
+    const total = Number(this.db.prepare('SELECT COUNT(*) value FROM group_ops_feedback WHERE account_key=?').get(account)?.value || 0);
+    const falsePositives = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_feedback WHERE account_key=? AND label='false_positive'").get(account)?.value || 0);
+    return { total, falsePositives, falsePositiveRate: total ? Math.round(falsePositives / total * 100) : 0,
+      recent: this.db.prepare('SELECT * FROM group_ops_feedback WHERE account_key=? ORDER BY created_at DESC LIMIT 50').all(account) };
+  }
+
+  groupInsights(key, roomId = '') {
+    const account = accountKey(key);
+    const groups = roomId ? [this.getGroup(account, roomId)].filter(Boolean) : this.listGroups(account);
+    const currentStart = new Date(Date.now() - 7 * 86400000).toISOString();
+    const priorStart = new Date(Date.now() - 14 * 86400000).toISOString();
+    return groups.map(group => {
+      const current = this.db.prepare(`SELECT COUNT(*) message_count,COUNT(DISTINCT sender_id) speaker_count,
+        SUM(CASE WHEN sender_role='customer' THEN 1 ELSE 0 END) customer_count,
+        SUM(CASE WHEN sender_role='staff' THEN 1 ELSE 0 END) staff_count,MAX(sent_at) last_message_at
+        FROM group_ops_messages WHERE account_key=? AND room_id=? AND sent_at>=?`).get(account, group.room_id, currentStart);
+      const prior = this.db.prepare('SELECT COUNT(*) message_count FROM group_ops_messages WHERE account_key=? AND room_id=? AND sent_at>=? AND sent_at<?').get(account, group.room_id, priorStart, currentStart);
+      const openFindings = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_findings WHERE account_key=? AND room_id=? AND status IN ('open','acknowledged')").get(account, group.room_id)?.value || 0);
+      const lastTime = current.last_message_at ? new Date(current.last_message_at).getTime() : 0;
+      const inactiveDays = lastTime ? Math.floor((Date.now() - lastTime) / 86400000) : null;
+      let suggestion = '保持当前运营节奏';
+      if (inactiveDays === null) suggestion = '同步群消息后再评估生命周期';
+      else if (inactiveDays >= 14) suggestion = '建议进入沉默唤醒阶段';
+      else if (inactiveDays >= 7) suggestion = '建议安排轻量回访或价值内容';
+      else if (openFindings > 0) suggestion = '先处理未闭环服务风险';
+      return { ...group, activity: { currentMessages: Number(current.message_count || 0), priorMessages: Number(prior.message_count || 0),
+        activeSpeakers: Number(current.speaker_count || 0), customerMessages: Number(current.customer_count || 0), staffMessages: Number(current.staff_count || 0),
+        trend: Number(prior.message_count || 0) ? Math.round((Number(current.message_count || 0) - Number(prior.message_count || 0)) / Number(prior.message_count || 1) * 100) : null,
+        lastMessageAt: current.last_message_at || null, inactiveDays }, openFindings, lifecycleSuggestion: suggestion,
+        dataCoverage: Number(current.message_count || 0) ? 100 : 0 };
+    });
+  }
+
+  storeBreakdown(key, date) {
+    const account = accountKey(key);
+    const groups = this.listGroups(account);
+    const stores = new Map();
+    for (const group of groups) {
+      const storeId = group.store_id || '未分配门店';
+      if (!stores.has(storeId)) stores.set(storeId, { storeId, groups: 0, dueItems: 0, completedItems: 0, openFindings: 0, highRisk: 0 });
+      stores.get(storeId).groups++;
+    }
+    const itemRows = this.db.prepare(`SELECT COALESCE(g.store_id,'未分配门店') store_id,i.status FROM group_ops_plan_items i
+      JOIN group_ops_plans p ON p.id=i.plan_id LEFT JOIN group_ops_groups g ON g.account_key=p.account_key AND g.room_id=p.room_id
+      WHERE p.account_key=? AND p.plan_date=?`).all(account, date);
+    for (const row of itemRows) {
+      if (!stores.has(row.store_id)) stores.set(row.store_id, { storeId: row.store_id, groups: 0, dueItems: 0, completedItems: 0, openFindings: 0, highRisk: 0 });
+      stores.get(row.store_id).dueItems++;
+      if (row.status === 'sent') stores.get(row.store_id).completedItems++;
+    }
+    const riskRows = this.db.prepare(`SELECT COALESCE(g.store_id,'未分配门店') store_id,f.severity FROM group_ops_findings f
+      LEFT JOIN group_ops_groups g ON g.account_key=f.account_key AND g.room_id=f.room_id
+      WHERE f.account_key=? AND f.status IN ('open','acknowledged')`).all(account);
+    for (const row of riskRows) {
+      if (!stores.has(row.store_id)) stores.set(row.store_id, { storeId: row.store_id, groups: 0, dueItems: 0, completedItems: 0, openFindings: 0, highRisk: 0 });
+      stores.get(row.store_id).openFindings++;
+      if (row.severity === 'high') stores.get(row.store_id).highRisk++;
+    }
+    return [...stores.values()].map(item => ({ ...item, executionRate: item.dueItems ? Math.round(item.completedItems / item.dueItems * 100) : null }))
+      .sort((a, b) => (b.highRisk - a.highRisk) || ((a.executionRate ?? 101) - (b.executionRate ?? 101)));
+  }
+
+  excellentScripts(key) {
+    return this.db.prepare(`SELECT i.final_content content,i.type,COUNT(*) use_count,MAX(i.updated_at) last_used_at
+      FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id
+      WHERE p.account_key=? AND i.status='sent' AND i.final_content<>'' GROUP BY i.final_content,i.type ORDER BY use_count DESC,last_used_at DESC LIMIT 20`).all(accountKey(key));
+  }
+
+  defaultAutomationPolicy() {
+    return {
+      enabled: false, mode: 'dry_run', allowedGroupIds: [], allowedTypes: ['welcome', 'content'],
+      windows: [{ days: [1, 2, 3, 4, 5], start: '09:00', end: '18:00' }],
+      dailyLimitPerGroup: 1, weeklyLimitPerGroup: 7, consecutiveFailureLimit: 3,
+      grayPercent: 0, requireNoOpenHighRisk: true
+    };
+  }
+
+  listAutomationPolicies(key) {
+    return this.db.prepare('SELECT * FROM group_ops_automation_policies WHERE account_key=? ORDER BY version DESC').all(accountKey(key))
+      .map(row => ({ ...row, policy: parse(row.policy_json, {}) }));
+  }
+
+  getActiveAutomationPolicy(key) {
+    return this.listAutomationPolicies(key).find(item => item.status === 'active') || null;
+  }
+
+  createAutomationPolicy(key, input = {}, actor = 'agent') {
+    const account = accountKey(key);
+    const version = Number(this.db.prepare('SELECT MAX(version) value FROM group_ops_automation_policies WHERE account_key=?').get(account)?.value || 0) + 1;
+    const base = this.getActiveAutomationPolicy(account)?.policy || this.defaultAutomationPolicy();
+    const policy = { ...base, ...(input.policy || input) };
+    policy.enabled = policy.enabled === true;
+    policy.mode = ['dry_run', 'review', 'auto'].includes(policy.mode) ? policy.mode : 'dry_run';
+    policy.allowedGroupIds = [...new Set((policy.allowedGroupIds || []).map(String).filter(Boolean))];
+    policy.allowedTypes = [...new Set((policy.allowedTypes || []).map(String).filter(Boolean))];
+    policy.grayPercent = Math.max(0, Math.min(100, Number(policy.grayPercent || 0)));
+    policy.dailyLimitPerGroup = Math.max(1, Number(policy.dailyLimitPerGroup || 1));
+    policy.weeklyLimitPerGroup = Math.max(policy.dailyLimitPerGroup, Number(policy.weeklyLimitPerGroup || 7));
+    policy.consecutiveFailureLimit = Math.max(1, Number(policy.consecutiveFailureLimit || 3));
+    const policyId = id('goap');
+    this.db.prepare('INSERT INTO group_ops_automation_policies VALUES(?,?,?,?,?,?,?,?,?)')
+      .run(policyId, account, version, 'draft', json(policy), input.changeNote || '', actor, now(), null);
+    const created = this.listAutomationPolicies(account).find(item => item.id === policyId);
+    this.audit(account, actor, 'automation_policy.create', 'automation_policy', policyId, {}, created);
+    return created;
+  }
+
+  activateAutomationPolicy(key, policyId, actor = 'agent') {
+    const account = accountKey(key);
+    const target = this.db.prepare('SELECT * FROM group_ops_automation_policies WHERE id=? AND account_key=?').get(policyId, account);
+    if (!target) throw new Error('自动化策略不存在或不属于当前账号');
+    const before = this.getActiveAutomationPolicy(account);
+    const timestamp = now();
+    this.db.prepare("UPDATE group_ops_automation_policies SET status='retired' WHERE account_key=? AND status='active'").run(account);
+    this.db.prepare("UPDATE group_ops_automation_policies SET status='active',activated_at=? WHERE id=?").run(timestamp, policyId);
+    this.db.prepare(`INSERT INTO group_ops_automation_state(account_key,paused,consecutive_failures,breaker_open,last_failure,updated_at)
+      VALUES(?,1,0,0,'',?) ON CONFLICT(account_key) DO UPDATE SET paused=1,updated_at=excluded.updated_at`).run(account, timestamp);
+    const after = this.getActiveAutomationPolicy(account);
+    this.audit(account, actor, 'automation_policy.activate', 'automation_policy', policyId, before || {}, after);
+    return after;
+  }
+
+  getAutomationState(key) {
+    const account = accountKey(key);
+    const row = this.db.prepare('SELECT * FROM group_ops_automation_state WHERE account_key=?').get(account);
+    return row || { account_key: account, paused: 1, consecutive_failures: 0, breaker_open: 0, last_failure: '', updated_at: null };
+  }
+
+  updateAutomationState(key, action, actor = 'agent') {
+    const account = accountKey(key);
+    const before = this.getAutomationState(account);
+    let paused = before.paused; let failures = before.consecutive_failures; let breaker = before.breaker_open; let lastFailure = before.last_failure;
+    if (action === 'pause') paused = 1;
+    else if (action === 'resume') paused = 0;
+    else if (action === 'reset_breaker') { failures = 0; breaker = 0; lastFailure = ''; }
+    else throw new Error('不支持的自动化状态操作');
+    this.db.prepare(`INSERT INTO group_ops_automation_state VALUES(?,?,?,?,?,?) ON CONFLICT(account_key) DO UPDATE SET
+      paused=excluded.paused,consecutive_failures=excluded.consecutive_failures,breaker_open=excluded.breaker_open,last_failure=excluded.last_failure,updated_at=excluded.updated_at`)
+      .run(account, paused, failures, breaker, lastFailure, now());
+    const after = this.getAutomationState(account);
+    this.audit(account, actor, `automation.${action}`, 'automation_state', account, before, after);
+    return after;
+  }
+
+  getPlanItemWithContext(key, itemId) {
+    return this.db.prepare(`SELECT i.*,p.account_key,p.room_id,p.plan_date,g.room_name,g.store_id
+      FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id
+      LEFT JOIN group_ops_groups g ON g.account_key=p.account_key AND g.room_id=p.room_id
+      WHERE i.id=? AND p.account_key=?`).get(itemId, accountKey(key)) || null;
+  }
+
+  listAutomationCandidates(key, limit = 100) {
+    return this.db.prepare(`SELECT i.*,p.account_key,p.room_id,p.plan_date,g.room_name,g.store_id
+      FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id
+      LEFT JOIN group_ops_groups g ON g.account_key=p.account_key AND g.room_id=p.room_id
+      WHERE p.account_key=? AND i.status='approved' ORDER BY i.scheduled_at LIMIT ?`).all(accountKey(key), Math.max(1, Math.min(500, Number(limit || 100))));
+  }
+
+  evaluateAutomation(key, item, at = new Date()) {
+    const account = accountKey(key);
+    const policyRecord = this.getActiveAutomationPolicy(account);
+    const state = this.getAutomationState(account);
+    const reasons = [];
+    if (!policyRecord) reasons.push('没有已激活策略');
+    const policy = policyRecord?.policy || this.defaultAutomationPolicy();
+    if (!policy.enabled) reasons.push('策略未启用');
+    if (state.paused) reasons.push('自动化已暂停');
+    if (state.breaker_open) reasons.push('熔断器已打开');
+    if (!item || item.status !== 'approved') reasons.push('计划项未批准');
+    if (item?.scheduled_at && new Date(item.scheduled_at).getTime() > at.getTime()) reasons.push('尚未到计划时间');
+    if (item && !policy.allowedGroupIds.includes(item.room_id)) reasons.push('群不在白名单');
+    if (item && !policy.allowedTypes.includes(item.type)) reasons.push('内容类型不允许自动执行');
+    const grayBucket = item ? parseInt(hash(item.room_id).slice(0, 8), 16) % 100 : 100;
+    if (grayBucket >= policy.grayPercent) reasons.push('未命中灰度范围');
+    const time = shanghaiParts(at);
+    const inWindow = (policy.windows || []).some(window => (window.days || []).map(Number).includes(time.weekday) && time.time >= window.start && time.time <= window.end);
+    if (!inWindow) reasons.push('当前不在允许发送时间窗');
+    if (item && policy.requireNoOpenHighRisk) {
+      const highRisk = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_findings WHERE account_key=? AND room_id=? AND severity='high' AND status IN ('open','acknowledged')").get(account, item.room_id)?.value || 0);
+      if (highRisk) reasons.push('群存在未闭环高风险');
+    }
+    const settings = this.getQualitySettings(account);
+    const content = String(item?.final_content || '');
+    if (!content) reasons.push('最终话术为空');
+    if (/\{\{\s*[\w.-]+\s*\}\}/.test(content)) reasons.push('话术仍有未替换变量');
+    if (settings.forbidden_claims.some(term => content.includes(term))) reasons.push('话术命中禁止承诺');
+    if (item) {
+      const duplicate = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_send_attempts WHERE account_key=? AND idempotency_key=? AND mode='live' AND status IN ('sending','sent','unknown')").get(account, item.idempotency_key)?.value || 0);
+      if (duplicate) reasons.push('该计划项已有发送中、成功或结果待确认记录');
+      const date = time.date;
+      const weekStart = new Date(at.getTime() - 7 * 86400000).toISOString();
+      const daily = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_send_attempts WHERE account_key=? AND room_id=? AND mode='live' AND status='sent' AND substr(created_at,1,10)=?").get(account, item.room_id, date)?.value || 0);
+      const weekly = Number(this.db.prepare("SELECT COUNT(*) value FROM group_ops_send_attempts WHERE account_key=? AND room_id=? AND mode='live' AND status='sent' AND created_at>=?").get(account, item.room_id, weekStart)?.value || 0);
+      if (daily >= policy.dailyLimitPerGroup) reasons.push('达到单群每日频控');
+      if (weekly >= policy.weeklyLimitPerGroup) reasons.push('达到单群每周频控');
+    }
+    return { allowed: reasons.length === 0, reasons, policyId: policyRecord?.id || null, policyVersion: policyRecord?.version || null, mode: policy.mode, grayBucket, evaluatedAt: at.toISOString() };
+  }
+
+  createSendAttempt(key, item, decision, mode) {
+    const attemptId = id('gosa');
+    this.db.prepare('INSERT INTO group_ops_send_attempts VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)')
+      .run(attemptId, accountKey(key), item.id, item.room_id, item.idempotency_key, mode, mode === 'dry_run' ? 'dry_run' : 'sending', json(decision), '', '', decision.policyVersion || null, now(), mode === 'dry_run' ? now() : null);
+    return this.db.prepare('SELECT * FROM group_ops_send_attempts WHERE id=?').get(attemptId);
+  }
+
+  finishSendAttempt(key, attemptId, result = {}) {
+    const account = accountKey(key);
+    const attempt = this.db.prepare('SELECT * FROM group_ops_send_attempts WHERE id=? AND account_key=?').get(attemptId, account);
+    if (!attempt) throw new Error('发送尝试不存在');
+    const status = result.success ? 'sent' : result.ambiguous ? 'unknown' : 'failed';
+    this.db.prepare('UPDATE group_ops_send_attempts SET status=?,external_message_id=?,error=?,completed_at=? WHERE id=?')
+      .run(status, result.externalMessageId || '', result.error || '', now(), attemptId);
+    const active = this.getActiveAutomationPolicy(account);
+    const limit = active?.policy?.consecutiveFailureLimit || 3;
+    const state = this.getAutomationState(account);
+    const failures = result.success ? 0 : Number(state.consecutive_failures || 0) + 1;
+    const breaker = result.success ? 0 : failures >= limit ? 1 : state.breaker_open;
+    this.db.prepare(`INSERT INTO group_ops_automation_state VALUES(?,?,?,?,?,?) ON CONFLICT(account_key) DO UPDATE SET
+      consecutive_failures=excluded.consecutive_failures,breaker_open=excluded.breaker_open,last_failure=excluded.last_failure,updated_at=excluded.updated_at`)
+      .run(account, state.paused, failures, breaker, result.success ? '' : (result.error || '发送失败'), now());
+    this.audit(account, 'automation', `send.${status}`, 'send_attempt', attemptId, attempt, { ...result, breakerOpen: Boolean(breaker) });
+    return this.db.prepare('SELECT * FROM group_ops_send_attempts WHERE id=?').get(attemptId);
+  }
+
+  automationOverview(key) {
+    const account = accountKey(key);
+    const candidates = this.listAutomationCandidates(account);
+    return { activePolicy: this.getActiveAutomationPolicy(account), policies: this.listAutomationPolicies(account), state: this.getAutomationState(account), liveSendEnabled: process.env.QIWEI_GROUP_OPS_LIVE_SEND === '1',
+      candidates: candidates.map(item => ({ item, decision: this.evaluateAutomation(account, item) })),
+      attempts: this.db.prepare('SELECT * FROM group_ops_send_attempts WHERE account_key=? ORDER BY created_at DESC LIMIT 100').all(account) };
+  }
+
+  calculateDailyScores(key, date = new Date().toISOString().slice(0, 10)) {
+    const account = accountKey(key);
+    const weights = { sop: 30, response: 30, risk: 25, engagement: 15 };
+    const rows = [];
+    for (const group of this.listGroups(account)) {
+      const items = this.db.prepare(`SELECT i.status FROM group_ops_plan_items i JOIN group_ops_plans p ON p.id=i.plan_id
+        WHERE p.account_key=? AND p.room_id=? AND p.plan_date=?`).all(account, group.room_id, date);
+      const messageStats = this.db.prepare(`SELECT COUNT(*) total,COUNT(DISTINCT CASE WHEN sender_id<>'' THEN sender_id END) speakers,
+        SUM(CASE WHEN sender_role='customer' THEN 1 ELSE 0 END) customers FROM group_ops_messages
+        WHERE account_key=? AND room_id=? AND substr(sent_at,1,10)=?`).get(account, group.room_id, date);
+      const questions = this.db.prepare(`SELECT content FROM group_ops_messages WHERE account_key=? AND room_id=?
+        AND sender_role='customer' AND substr(sent_at,1,10)=?`).all(account, group.room_id, date).filter(row => isQuestion(row.content)).length;
+      const unanswered = Number(this.db.prepare(`SELECT COUNT(*) value FROM group_ops_findings WHERE account_key=? AND room_id=?
+        AND type='unanswered_question' AND status IN ('open','acknowledged')
+        AND substr(COALESCE(json_extract(evidence_json,'$[0].sentAt'),created_at),1,10)=?`).get(account, group.room_id, date)?.value || 0);
+      const risks = this.db.prepare(`SELECT severity,COUNT(*) count FROM group_ops_findings WHERE account_key=? AND room_id=?
+        AND type='service_risk' AND status IN ('open','acknowledged')
+        AND substr(COALESCE(json_extract(evidence_json,'$[0].sentAt'),created_at),1,10)=? GROUP BY severity`).all(account, group.room_id, date);
+      const riskPenalty = risks.reduce((sum, row) => sum + Number(row.count) * (row.severity === 'high' ? 30 : row.severity === 'medium' ? 15 : 5), 0);
+      const components = {
+        sop: items.length ? { available: true, score: Math.round(items.filter(item => item.status === 'sent').length / items.length * 100), evidenceCount: items.length } : { available: false, score: null, evidenceCount: 0 },
+        response: questions ? { available: true, score: Math.max(0, Math.round((questions - unanswered) / questions * 100)), evidenceCount: questions } : { available: false, score: null, evidenceCount: 0 },
+        risk: Number(messageStats.total) ? { available: true, score: Math.max(0, 100 - riskPenalty), evidenceCount: Number(messageStats.total) } : { available: false, score: null, evidenceCount: 0 },
+        engagement: Number(messageStats.total) ? { available: true, score: Math.min(100, Math.round(Number(messageStats.speakers || 0) * 20 + Number(messageStats.customers || 0) * 5)), evidenceCount: Number(messageStats.total) } : { available: false, score: null, evidenceCount: 0 }
+      };
+      const available = Object.entries(components).filter(([, component]) => component.available);
+      const effectiveWeight = available.reduce((sum, [name]) => sum + weights[name], 0);
+      const totalScore = effectiveWeight ? Math.round(available.reduce((sum, [name, component]) => sum + component.score * weights[name], 0) / effectiveWeight) : null;
+      const coverage = Math.round(effectiveWeight);
+      const timestamp = now();
+      this.db.prepare(`INSERT INTO group_ops_daily_scores VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(account_key,room_id,score_date) DO UPDATE SET
+        component_json=excluded.component_json,coverage=excluded.coverage,total_score=excluded.total_score,updated_at=excluded.updated_at`)
+        .run(account, group.room_id, date, json(components), coverage, totalScore, timestamp, timestamp);
+      rows.push({ accountKey: account, roomId: group.room_id, roomName: group.room_name, storeId: group.store_id, date, components, coverage, totalScore });
+    }
+    return rows;
+  }
+
+  overview(key, date = new Date().toISOString().slice(0, 10)) {
+    const account = accountKey(key);
+    const one = (sql, ...params) => Number(this.db.prepare(sql).get(...params)?.value || 0);
+    const groups = this.listGroups(account);
+    const plans = this.db.prepare('SELECT * FROM group_ops_plans WHERE account_key=? AND plan_date=? ORDER BY updated_at DESC').all(account, date).map(plan => this.getPlan(plan.id));
+    const findings = this.db.prepare("SELECT * FROM group_ops_findings WHERE account_key=? AND status IN ('open','acknowledged') ORDER BY CASE severity WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,created_at DESC LIMIT 100").all(account).map(row => ({ ...row, evidence: parse(row.evidence_json, []) }));
+    const allItems = plans.flatMap(plan => plan.items);
+    const completed = allItems.filter(item => item.status === 'sent').length;
+    const scores = this.calculateDailyScores(account, date);
+    const scored = scores.filter(item => item.totalScore !== null);
+    const healthDistribution = {
+      healthy: scored.filter(item => item.totalScore >= 80).length,
+      attention: scored.filter(item => item.totalScore >= 60 && item.totalScore < 80).length,
+      risk: scored.filter(item => item.totalScore < 60).length,
+      unavailable: scores.length - scored.length
+    };
+    return {
+      date, metrics: { groups: groups.length, playbooks: one("SELECT COUNT(*) value FROM group_ops_playbooks WHERE account_key=? AND status='published'", account),
+        dueItems: allItems.length, completedItems: completed, pendingReview: allItems.filter(item => item.status === 'pending_review').length,
+        openFindings: findings.length, highRisk: findings.filter(item => item.severity === 'high').length,
+        openTasks: one("SELECT COUNT(*) value FROM group_ops_tasks WHERE account_key=? AND status IN ('open','in_progress')", account),
+        executionRate: allItems.length ? Math.round(completed / allItems.length * 100) : null,
+        averageHealthScore: scored.length ? Math.round(scored.reduce((sum, item) => sum + item.totalScore, 0) / scored.length) : null,
+        averageHealthCoverage: scores.length ? Math.round(scores.reduce((sum, item) => sum + item.coverage, 0) / scores.length) : 0,
+        healthDistribution },
+      groups, plans, findings, tasks: this.listTasks(account), stores: this.storeBreakdown(account, date),
+      insights: this.groupInsights(account), qualitySettings: this.getQualitySettings(account), feedback: this.feedbackSummary(account),
+      excellentScripts: this.excellentScripts(account), automation: this.automationOverview(account), scores
+    };
+  }
+
+  close() { this.db.close(); }
+}
+
+let singleton;
+function getGroupOperationsStore() { if (!singleton) singleton = new GroupOperationsStore(); return singleton; }
+
+module.exports = { GroupOperationsStore, getGroupOperationsStore, accountKey };

+ 1 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js

@@ -13,6 +13,7 @@ const OUTPUT_CATEGORIES = Object.freeze([
   'messages',
   'messages',
   'customers',
   'customers',
   'groups',
   'groups',
+  'group-operations',
   'portraits',
   'portraits',
   'knowledge',
   'knowledge',
   'goals',
   'goals',

+ 1 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-config.js

@@ -63,6 +63,7 @@ function writeRelayConfigFile(config) {
 }
 }
 
 
 function isRelayEnabled() {
 function isRelayEnabled() {
+  if (process.env.QIWEI_DISABLE_RELAY === '1') return false;
   const env = readEnvLocal();
   const env = readEnvLocal();
   const baseUrl = process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || readRelayConfigFile().relayBaseUrl || '';
   const baseUrl = process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || readRelayConfigFile().relayBaseUrl || '';
   const apiKey = process.env.TENANT_API_KEY || env.TENANT_API_KEY || '';
   const apiKey = process.env.TENANT_API_KEY || env.TENANT_API_KEY || '';

+ 25 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js

@@ -37,6 +37,7 @@ const {
 } = require('../tools/qiwei-group-management-run');
 } = require('../tools/qiwei-group-management-run');
 const { enqueuePortraitUpdate } = require('../tools/qiwei-portrait-tags-run');
 const { enqueuePortraitUpdate } = require('../tools/qiwei-portrait-tags-run');
 const { qiweiTranscribeVoice } = require('../tools/qiwei-voice-run');
 const { qiweiTranscribeVoice } = require('../tools/qiwei-voice-run');
+const { getGroupOperationsStore } = require('./group-operations-store');
 
 
 const REQUIRED_METHODS = {
 const REQUIRED_METHODS = {
   getWxContactList: '/contact/getWxContactList'
   getWxContactList: '/contact/getWxContactList'
@@ -565,6 +566,30 @@ async function storeGroupMessageFromWebhook(event, eventFilePath = null) {
 
 
   appendWebhookMessage(roomId, message);
   appendWebhookMessage(roomId, message);
 
 
+  // 兼容写入账号隔离的社群运营事实表;失败不回滚已经落盘的旧消息。
+  const operationAccountKey = resolveDeviceGuid(event);
+  if (operationAccountKey) {
+    try {
+      const operationStore = getGroupOperationsStore();
+      operationStore.upsertGroup(operationAccountKey, {
+        roomId,
+        roomName: groupMapping.roomName || '',
+        ownerId: brokerId || '',
+        groupType: groupMapping.groupType || 'customer'
+      }, 'webhook');
+      operationStore.ingestMessages(operationAccountKey, roomId, [{
+        ...message,
+        messageId: msgUniqueId,
+        senderRole: senderType === 'CUSTOMER' ? 'customer' : ['BROKER', 'SUPPORT'].includes(senderType) ? 'staff' : 'unknown',
+        sentAt: message.timestamp
+      }]);
+    } catch (error) {
+      console.warn(`[Webhook] 社群运营事实表写入失败: ${error.message}`);
+    }
+  } else {
+    console.warn('[Webhook] 事件缺少设备 guid,已保留旧消息但跳过社群运营账号隔离入库');
+  }
+
   // 更新 mapping 的 lastMsgAt / lastSyncSeq
   // 更新 mapping 的 lastMsgAt / lastSyncSeq
   if (confirmed[roomId]) {
   if (confirmed[roomId]) {
     confirmed[roomId].lastMsgAt = message.timestamp;
     confirmed[roomId].lastMsgAt = message.timestamp;

+ 8 - 17
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -1335,23 +1335,9 @@ function getAudit(limit = 200) {
   return { status: 'ok', data: { audit: workbench.db.listAudit(Math.max(1, Math.min(500, Number(limit) || 200))) } };
   return { status: 'ok', data: { audit: workbench.db.listAudit(Math.max(1, Math.min(500, Number(limit) || 200))) } };
 }
 }
 
 
-async function startListener() {
-  const account = await detectAccountStatus(true);
+async function startListenerForWorkbench(target, account) {
   if (!account.online) throw new Error(`${account.nickname || '当前账号'}不在线,无法启动真实消息监听`);
   if (!account.online) throw new Error(`${account.nickname || '当前账号'}不在线,无法启动真实消息监听`);
-  workbench.service.setGlobal({ paused: false, defaultMode: 'auto' });
-  for (const conversation of workbench.db.listConversations()) {
-    workbench.service.setConversationMode(conversation.id, 'auto');
-  }
-  let status;
-  try {
-    status = await workbench.poller.start();
-  } catch (error) {
-    workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
-    for (const conversation of workbench.db.listConversations()) {
-      workbench.service.setConversationMode(conversation.id, 'human');
-    }
-    throw error;
-  }
+  const status = await target.poller.start();
   return {
   return {
     status: 'ok',
     status: 'ok',
     assistantMessage: 'AI 监听已启动:白名单私聊按当前策略处理;已确认客户群自动生成待审核草稿,不会自动群发',
     assistantMessage: 'AI 监听已启动:白名单私聊按当前策略处理;已确认客户群自动生成待审核草稿,不会自动群发',
@@ -1359,6 +1345,11 @@ async function startListener() {
   };
   };
 }
 }
 
 
+async function startListener() {
+  const account = await detectAccountStatus(true);
+  return startListenerForWorkbench(workbench, account);
+}
+
 function stopListener() {
 function stopListener() {
   const status = workbench.poller.stop();
   const status = workbench.poller.stop();
   workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
   workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
@@ -1404,5 +1395,5 @@ module.exports = {
   stopListener,
   stopListener,
   getAgentRuntimeConfig,
   getAgentRuntimeConfig,
   createWorkbench,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage, groupAgentService },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
 };
 };

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 599 - 69
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js


+ 200 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/business-diagnosis-service.js

@@ -0,0 +1,200 @@
+const { getStateSection } = require('../core/dashboard-state');
+const { getGroupOperationsStore, accountKey: normalizeAccountKey } = require('../core/group-operations-store');
+const { getAgentStatus, getConversations } = require('./agent-service');
+
+function number(value) {
+  const parsed = Number(value);
+  return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function percent(value) {
+  return value === null || value === undefined ? null : number(value);
+}
+
+function action(id, label, description, route, priority = 'medium') {
+  return { id, label, description, route, hash: route, priority, requiresConfirmation: false };
+}
+
+function evidence(label, value, source, route) {
+  return { label, value, source, route };
+}
+
+async function collectBusinessSnapshot(input = {}) {
+  if (input.snapshot) return input.snapshot;
+  const key = normalizeAccountKey(input.accountKey || input.guid || input.uid);
+  const [agentStatusResult, conversationsResult] = await Promise.allSettled([
+    getAgentStatus(),
+    Promise.resolve(getConversations())
+  ]);
+  const agentStatus = agentStatusResult.status === 'fulfilled' ? agentStatusResult.value?.data || {} : {};
+  const conversations = conversationsResult.status === 'fulfilled' ? conversationsResult.value?.data?.conversations || [] : [];
+  let groupOverview = {};
+  try {
+    groupOverview = getGroupOperationsStore().overview(key, input.date);
+  } catch {
+    groupOverview = {};
+  }
+  return {
+    accountKey: key,
+    date: input.date || new Date().toISOString().slice(0, 10),
+    loadedAt: new Date().toISOString(),
+    summary: getStateSection('summary'),
+    groupOverview,
+    agentStatus,
+    conversations
+  };
+}
+
+function snapshotMetrics(snapshot = {}) {
+  const summary = snapshot.summary || {};
+  const customers = summary.customers || {};
+  const portraits = summary.portraits || {};
+  const group = snapshot.groupOverview?.metrics || {};
+  const conversations = Array.isArray(snapshot.conversations) ? snapshot.conversations : [];
+  const pendingReplies = conversations.filter(item => item.pendingReply).length;
+  const openCustomerTasks = conversations.reduce((sum, item) => sum + number(item.customerIntelligence?.summary?.openTasks), 0);
+  const highCustomerAlerts = conversations.reduce((sum, item) => sum + number(item.customerIntelligence?.summary?.highAlerts), 0);
+  return {
+    customers: number(customers.total),
+    customerTodayNew: number(customers.todayNew),
+    portraits: number(portraits.total),
+    portraitCoverage: percent(portraits.coverage),
+    portraitsRecent7d: number(portraits.recent7d),
+    tags: number(summary.tags?.total),
+    operationsRecent7d: number(summary.operations?.recent7d),
+    conversations: conversations.length,
+    pendingReplies,
+    openCustomerTasks,
+    highCustomerAlerts,
+    groups: number(group.groups),
+    groupDueItems: number(group.dueItems),
+    groupCompletedItems: number(group.completedItems),
+    groupPendingReview: number(group.pendingReview),
+    groupOpenFindings: number(group.openFindings),
+    groupHighRisk: number(group.highRisk),
+    groupOpenTasks: number(group.openTasks),
+    groupExecutionRate: percent(group.executionRate),
+    groupHealthScore: percent(group.averageHealthScore),
+    groupHealthCoverage: percent(group.averageHealthCoverage)
+  };
+}
+
+function commonGaps(metrics) {
+  const gaps = [];
+  if (!metrics.conversations) gaps.push({ code: 'NO_CONVERSATIONS', label: '暂无可分析的真实会话', impact: '无法判断客服响应和客户意图变化', nextAction: '同步真实企微会话' });
+  if (!metrics.groups) gaps.push({ code: 'NO_GROUP_SCOPE', label: '暂无已接入运营群', impact: '无法判断群计划执行和风险分布', nextAction: '确认客户群并同步消息' });
+  if (metrics.portraitCoverage === null || metrics.portraitCoverage < 60) gaps.push({ code: 'LOW_PORTRAIT_COVERAGE', label: '画像覆盖不足', impact: '重点客户识别可能遗漏', nextAction: '优先补齐有互动客户画像' });
+  return gaps;
+}
+
+function diagnoseSnapshot(question, snapshot) {
+  const query = String(question || '').trim();
+  if (!query) throw new Error('缺少经营诊断问题 question');
+  const normalized = query.toLowerCase();
+  const metrics = snapshotMetrics(snapshot);
+  const missingPortraits = Math.max(0, metrics.customers - metrics.portraits);
+  const base = {
+    query,
+    generatedAt: new Date().toISOString(),
+    scope: { accountKey: snapshot.accountKey || '', date: snapshot.date || '', sources: ['dashboard-state', 'agent-workbench', 'group-operations'] },
+    metrics,
+    dataGaps: commonGaps(metrics),
+    evidence: [],
+    evidenceDetails: [],
+    actions: []
+  };
+
+  if (/复购|成交|gmv|销售额|订单|转化率/.test(normalized)) {
+    const details = [
+      evidence('客户主档', metrics.customers, 'dashboard-state', '#customer-ops'),
+      evidence('画像覆盖率', metrics.portraitCoverage, 'dashboard-state', '#portraits'),
+      evidence('近 7 日运营记录', metrics.operationsRecent7d, 'dashboard-state', '#overview')
+    ];
+    return {
+      ...base,
+      type: 'commerce', tone: 'info', label: '需要补充交易数据',
+      title: '当前企微数据不足以直接判断复购变化',
+      summary: '现有数据可以验证客户触达、画像和社群执行情况,但尚未接入订单、成交与复购口径,不能据此生成确定性经营结论。',
+      evidence: [`客户主档 ${metrics.customers} 人`, `画像覆盖率 ${metrics.portraitCoverage ?? '-'}%`, `近 7 日运营记录 ${metrics.operationsRecent7d} 条`],
+      evidenceDetails: details,
+      dataGaps: [{ code: 'NO_TRANSACTION_DATA', label: '缺少订单与交易数据', impact: '无法计算复购率、成交额和归因', nextAction: '接入 ERP、订单导出或统一经营数据接口' }, ...base.dataGaps],
+      actions: [
+        action('inspect-priority-customers', '检查重点客户', '从画像完整度和待跟进任务排查', '#customer-ops', 'high'),
+        action('inspect-group-execution', '查看社群执行', '确认近期计划是否按时触达', '#group-ops'),
+        action('connect-transaction-data', '补充经营数据', '在知识库接入订单或 ERP 导出', '#knowledge', 'high')
+      ]
+    };
+  }
+
+  if (/群|社群|sop|活动|话术|投诉|风险|质检/.test(normalized)) {
+    const highRisk = metrics.groupHighRisk;
+    const pending = metrics.groupPendingReview;
+    const details = [
+      evidence('群计划执行率', metrics.groupExecutionRate, 'group-operations', '#group-ops'),
+      evidence('开放预警', metrics.groupOpenFindings, 'group-operations', '#group-ops'),
+      evidence('平均健康度', metrics.groupHealthScore, 'group-operations', '#group-ops')
+    ];
+    return {
+      ...base,
+      type: 'group-operations', tone: highRisk ? 'danger' : pending ? 'warning' : 'success',
+      label: highRisk ? '存在高风险待闭环' : pending ? '存在待审核计划' : '当前未发现高风险积压',
+      title: highRisk ? `${highRisk} 项高风险需要优先处理` : `${metrics.groups} 个群的运营状态已汇总`,
+      summary: `今日计划 ${metrics.groupDueItems} 项,完成 ${metrics.groupCompletedItems} 项,待审核 ${pending} 项。风险结论必须回到原消息核验。`,
+      evidence: [`执行率 ${metrics.groupExecutionRate ?? '-'}%`, `开放预警 ${metrics.groupOpenFindings}`, `健康度 ${metrics.groupHealthScore ?? '-'} 分`],
+      evidenceDetails: details,
+      actions: [
+        action('resolve-group-risks', '处理风险', '查看原消息证据并建立整改任务', '#group-ops', 'urgent'),
+        action('review-group-plans', '审核今日计划', '批准话术或退回修改', '#group-ops', 'high'),
+        action('verify-group-scope', '核对客户群', '确认群身份与消息同步状态', '#groups')
+      ]
+    };
+  }
+
+  if (/客户|跟进|画像|标签|重点|流失|意向/.test(normalized)) {
+    const details = [
+      evidence('已形成画像', metrics.portraits, 'dashboard-state', '#portraits'),
+      evidence('近 7 日新增画像', metrics.portraitsRecent7d, 'dashboard-state', '#portraits'),
+      evidence('客户内部任务', metrics.openCustomerTasks, 'agent-workbench', '#customer-ops')
+    ];
+    return {
+      ...base,
+      type: 'customer-operations', tone: metrics.openCustomerTasks || missingPortraits ? 'warning' : 'success',
+      label: metrics.openCustomerTasks ? '存在客户待办' : missingPortraits ? '画像覆盖仍需提升' : '客户资产状态稳定',
+      title: metrics.openCustomerTasks ? `${metrics.openCustomerTasks} 项客户跟进任务待处理` : `${metrics.customers} 位客户已进入统一主档`,
+      summary: `当前画像覆盖率 ${metrics.portraitCoverage ?? '-'}%,仍有 ${missingPortraits} 位客户缺少完整画像。建议优先处理有任务、有预警且画像证据不足的客户。`,
+      evidence: [`已形成画像 ${metrics.portraits}`, `近 7 日新增画像 ${metrics.portraitsRecent7d}`, `客户标签 ${metrics.tags} 个`],
+      evidenceDetails: details,
+      actions: [
+        action('open-customer-master', '打开客户主档', '按任务、预警和画像完整度处理', '#customer-ops', 'high'),
+        action('complete-portraits', '补齐画像', '查看字段依据并人工核对', '#portraits'),
+        action('continue-conversation', '进入客服工作台', '从真实会话继续跟进', '#agent')
+      ]
+    };
+  }
+
+  return {
+    ...base,
+    type: 'general', tone: metrics.pendingReplies ? 'warning' : 'info',
+    label: metrics.pendingReplies ? '有草稿等待审核' : '经营数据已就绪',
+    title: metrics.pendingReplies ? `${metrics.pendingReplies} 条 Agent 草稿等待处理` : '可以从客户、社群或客服问题开始诊断',
+    summary: `系统已汇总 ${metrics.customers} 位客户、${metrics.groups} 个运营群和 ${metrics.conversations} 个真实会话。请提供更具体的业务问题以缩小范围。`,
+    evidence: [`真实会话 ${metrics.conversations}`, `今日群任务 ${metrics.groupDueItems}`, `近 7 日运营记录 ${metrics.operationsRecent7d}`],
+    evidenceDetails: [
+      evidence('真实会话', metrics.conversations, 'agent-workbench', '#agent'),
+      evidence('今日群任务', metrics.groupDueItems, 'group-operations', '#group-ops'),
+      evidence('近 7 日运营记录', metrics.operationsRecent7d, 'dashboard-state', '#overview')
+    ],
+    actions: [
+      action('review-agent-drafts', '处理客服草稿', '审核、驳回或人工接管', '#agent', 'high'),
+      action('open-action-center', '查看今日行动', '统一处理任务与 AI 候选', '#knowledge'),
+      action('open-group-operations', '查看社群运营', '处理计划、风险与自动化', '#group-ops')
+    ]
+  };
+}
+
+async function diagnoseBusiness(input = {}) {
+  const snapshot = await collectBusinessSnapshot(input);
+  return { diagnosis: diagnoseSnapshot(input.question || input.query, snapshot), snapshot };
+}
+
+module.exports = { collectBusinessSnapshot, diagnoseSnapshot, diagnoseBusiness, snapshotMetrics };

+ 76 - 5
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/customer-master-service.js

@@ -3,6 +3,7 @@ const path = require('path');
 const { outputsRoot } = require('../core/output-paths');
 const { outputsRoot } = require('../core/output-paths');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { getConversations, updateCustomerProfile, updateCustomerRecommendation } = require('./agent-service');
 const { getConversations, updateCustomerProfile, updateCustomerRecommendation } = require('./agent-service');
+const { readState } = require('../core/dashboard-state');
 
 
 const FIELD_LABELS = {
 const FIELD_LABELS = {
   intent: '客户意图',
   intent: '客户意图',
@@ -63,6 +64,64 @@ function recommendationStatusLabel(status) {
   return ({ candidate: 'Agent 候选', recommended: '已推荐·待反馈', interested: '客户感兴趣', rejected: '客户不合适', viewing: '已约带看', viewed: '已带看', closed: '已关闭' })[status] || status || '待确认';
   return ({ candidate: 'Agent 候选', recommended: '已推荐·待反馈', interested: '客户感兴趣', rejected: '客户不合适', viewing: '已约带看', viewed: '已带看', closed: '已关闭' })[status] || status || '待确认';
 }
 }
 
 
+function maskPhone(phone) {
+  const text = String(phone || '').trim();
+  if (!text) return '';
+  if (text.length <= 7) return text;
+  return `${text.slice(0, 3)}****${text.slice(-4)}`;
+}
+
+function fallbackConversation(customerOps) {
+  const displayName = customerOps.name || customerOps.phone || String(customerOps.externalUserId || '').slice(-6) || '未命名客户';
+  const maskedId = customerOps.phone ? maskPhone(customerOps.phone) : String(customerOps.externalUserId || '');
+  const source = customerOps.source === 'group-member' ? '群成员发现' : '客户运营导入';
+  const now = customerOps.updatedAt || new Date().toISOString();
+  return {
+    id: `co:${String(customerOps.customerId || customerOps.externalUserId || '')}`,
+    displayName,
+    maskedId,
+    source,
+    mode: 'customer-ops',
+    customerIntelligence: {
+      profile: {},
+      profileEvidence: {},
+      tags: Array.isArray(customerOps.tags) ? customerOps.tags : [],
+      tasks: [],
+      alerts: [],
+      recommendations: [],
+      summary: {},
+    },
+    analysis: { completenessScore: 0 },
+    messages: [],
+    updatedAt: now,
+    lastMessageAt: customerOps.lastSeenInGroupAt || now,
+  };
+}
+
+function getFallbackCustomers() {
+  try {
+    const state = readState();
+    const customers = Object.values(state.customers || {});
+    if (!customers.length) return [];
+    return customers
+      .filter(customer => customer.customerId || customer.externalUserId)
+      .map(customer => {
+        const normalized = normalizeCustomer(fallbackConversation(customer));
+        normalized.fallback = true;
+        normalized.friendRequestStatus = customer.friendRequestStatus;
+        normalized.groupStatus = customer.groupStatus;
+        normalized.externalUserId = String(customer.externalUserId || customer.customerId || '');
+        normalized.phone = customer.phone || '';
+        normalized.sourceRoomIds = Array.isArray(customer.sourceRoomIds) ? customer.sourceRoomIds : [];
+        normalized.groupNames = Array.isArray(customer.groupNames) ? customer.groupNames : [];
+        return normalized;
+      })
+      .sort((a, b) => String(b.lastMessageAt || '').localeCompare(String(a.lastMessageAt || '')));
+  } catch (err) {
+    return [];
+  }
+}
+
 function buildNextActions(customer) {
 function buildNextActions(customer) {
   const actions = [];
   const actions = [];
   const add = (id, title, description, evidence, priority = 'medium', confidence = 'rule') => actions.push({ id, title, description, evidence: (evidence || []).filter(Boolean), priority, confidence, status: 'suggested', action: 'open-conversation' });
   const add = (id, title, description, evidence, priority = 'medium', confidence = 'rule') => actions.push({ id, title, description, evidence: (evidence || []).filter(Boolean), priority, confidence, status: 'suggested', action: 'open-conversation' });
@@ -116,6 +175,7 @@ function normalizeCustomer(conversation) {
   const evidenced = fields.filter(item => item.evidence?.text).length;
   const evidenced = fields.filter(item => item.evidence?.text).length;
   const customer = {
   const customer = {
     id: conversation.id,
     id: conversation.id,
+    externalUserId: conversation.channelId || conversation.contactId || '',
     displayName: conversation.displayName,
     displayName: conversation.displayName,
     maskedId: conversation.maskedId,
     maskedId: conversation.maskedId,
     source: conversation.source === 'live' ? '真实企微会话' : conversation.source,
     source: conversation.source === 'live' ? '真实企微会话' : conversation.source,
@@ -181,25 +241,36 @@ function createCustomerMasterService(dependencies = {}) {
   function hub() {
   function hub() {
     const result = conversationReader() || {};
     const result = conversationReader() || {};
     const conversations = result.data?.conversations || [];
     const conversations = result.data?.conversations || [];
-    const customers = conversations.map(normalizeCustomer).sort((a, b) => String(b.lastMessageAt || '').localeCompare(String(a.lastMessageAt || '')));
+    let customers = conversations.map(normalizeCustomer).sort((a, b) => String(b.lastMessageAt || '').localeCompare(String(a.lastMessageAt || '')));
+    let fallback = false;
+    if (!customers.length) {
+      customers = getFallbackCustomers();
+      fallback = customers.length > 0;
+    }
     const projectedAt = persistProjection(customers);
     const projectedAt = persistProjection(customers);
+    const profiledCount = customers.filter(item => item.fields.length).length;
     return okResult({
     return okResult({
-      assistantMessage: `客户管理已汇总 ${customers.length} 位真实会话客户,其中 ${customers.filter(item => item.fields.length).length} 位已形成画像。`,
+      assistantMessage: fallback
+        ? `客户主档暂无真实企微会话客户,已自动从客户运营数据展示 ${customers.length} 位客户作为参考。`
+        : `客户管理已汇总 ${customers.length} 位真实会话客户,其中 ${profiledCount} 位已形成画像。`,
       summary: {
       summary: {
         customerCount: customers.length,
         customerCount: customers.length,
-        profiledCount: customers.filter(item => item.fields.length).length,
+        profiledCount,
         priorityCount: customers.filter(item => item.stage.key === 'priority').length,
         priorityCount: customers.filter(item => item.stage.key === 'priority').length,
         openTaskCount: customers.reduce((sum, item) => sum + item.openTaskCount, 0),
         openTaskCount: customers.reduce((sum, item) => sum + item.openTaskCount, 0),
         openAlertCount: customers.reduce((sum, item) => sum + item.openAlertCount, 0),
         openAlertCount: customers.reduce((sum, item) => sum + item.openAlertCount, 0),
         recommendationCount: customers.reduce((sum, item) => sum + item.recommendationCount, 0),
         recommendationCount: customers.reduce((sum, item) => sum + item.recommendationCount, 0),
         pendingFeedbackCount: customers.reduce((sum, item) => sum + item.pendingFeedbackCount, 0),
         pendingFeedbackCount: customers.reduce((sum, item) => sum + item.pendingFeedbackCount, 0),
         projectedAt,
         projectedAt,
+        fallback,
       },
       },
       data: {
       data: {
         customers,
         customers,
         policy: {
         policy: {
-          canonicalStore: 'SQLite 客户画像主账',
-          autoSync: '真实企微消息经 Agent 提取后自动更新客户主档;画像与标签工具也回写同一主账。',
+          canonicalStore: fallback ? '客户运营数据(会话主账为空时的参考视图)' : 'SQLite 客户画像主账',
+          autoSync: fallback
+            ? '当前展示的是客户运营导入/群成员发现数据;同步真实企微会话后,画像与任务会自动沉淀到这里。'
+            : '真实企微消息经 Agent 提取后自动更新客户主档;画像与标签工具也回写同一主账。',
           evidence: '客户原话证据只在本机管理页面显示;导出的客户投影不复制原话。',
           evidence: '客户原话证据只在本机管理页面显示;导出的客户投影不复制原话。',
           nextAction: '下一步行动属于规则或 AI 建议,不自动发送、不自动改写客户事实。',
           nextAction: '下一步行动属于规则或 AI 建议,不自动发送、不自动改写客户事实。',
         },
         },

+ 36 - 18
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html

@@ -5,7 +5,7 @@
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>企微智能办公助手</title>
 <title>企微智能办公助手</title>
 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23fa8c16'/%3E%3Cpath d='M18 20h28v20H34l-8 7v-7h-8z' fill='white'/%3E%3C/svg%3E">
 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23fa8c16'/%3E%3Cpath d='M18 20h28v20H34l-8 7v-7h-8z' fill='white'/%3E%3C/svg%3E">
-<link rel="stylesheet" href="/dashboard/styles.css">
+<link rel="stylesheet" href="/dashboard/styles.css?v=20260725-dialogs">
 </head>
 </head>
 <body>
 <body>
 <div class="app" id="app">
 <div class="app" id="app">
@@ -18,26 +18,32 @@
     </div>
     </div>
     <nav class="nav" id="nav">
     <nav class="nav" id="nav">
       <div class="nav-group">
       <div class="nav-group">
-        <div class="nav-label">功能</div>
-        <a class="nav-item active" href="#agent" data-page="agent">
+        <div class="nav-label">经营驾驶舱</div>
+        <a class="nav-item active" href="#overview" data-page="overview">
+          <svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z"/></svg>
+          <span>经营总览</span>
+        </a>
+      </div>
+      <div class="nav-group">
+        <div class="nav-label">数字岗位</div>
+        <a class="nav-item" href="#agent" data-page="agent">
           <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h3l4 4 4-4h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-8 10H7v-2h4v2zm6-4H7V7h10v2zm0 4h-4v-2h4v2z"/></svg>
           <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h3l4 4 4-4h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-8 10H7v-2h4v2zm6-4H7V7h10v2zm0 4h-4v-2h4v2z"/></svg>
-          <span>智能会话</span>
+          <span>客服工作台</span>
         </a>
         </a>
-        <a class="nav-item" href="#skills" data-page="skills">
-          <svg viewBox="0 0 24 24"><path d="M19 13h-2V7h-6V5H5c-1.1 0-2 .9-2 2v6h2v6c0 1.1.9 2 2 2h6v-2H7v-6h6v2h6c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2h-6v2h6v6zM9 9H5V7h4v2zm10 4h-4V7h4v6z"/></svg>
-          <span>技能中心</span>
+        <a class="nav-item" href="#group-ops" data-page="group-ops">
+          <svg viewBox="0 0 24 24"><path d="M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm3 4v2h10V7H7zm0 4v2h6v-2H7zm0 4v2h4v-2H7zm8-3 2 2 3-4 1.5 1.2L17.2 17 13.7 13.5 15 12z"/></svg>
+          <span>社群运营</span>
         </a>
         </a>
-        <a class="nav-item" href="#knowledge" data-page="knowledge">
-          <svg viewBox="0 0 24 24"><path d="M10 4H2v16h20V6H12l-2-2zm10 14H4V6h5.17l2 2H20v10zM8 10h8v2H8v-2zm0 4h6v2H8v-2z"/></svg>
-          <span>知识库</span>
+        <a class="nav-item" href="#customer-ops" data-page="customer-ops">
+          <svg viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
+          <span>客户运营</span>
         </a>
         </a>
+      </div>
+      <div class="nav-group">
+        <div class="nav-label">业务资产</div>
         <a class="nav-item" href="#groups" data-page="groups">
         <a class="nav-item" href="#groups" data-page="groups">
           <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
           <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
-          <span>客户群管理</span>
-        </a>
-        <a class="nav-item" href="#customer-ops" data-page="customer-ops">
-          <svg viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
-          <span>客户管理</span>
+          <span>客户群</span>
         </a>
         </a>
         <a class="nav-item" href="#portraits" data-page="portraits">
         <a class="nav-item" href="#portraits" data-page="portraits">
           <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4zM9 10c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z"/></svg>
           <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4zM9 10c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z"/></svg>
@@ -47,19 +53,31 @@
           <svg viewBox="0 0 24 24"><path d="M16.01 11H4v2h12.01v3L20 12l-3.99-4v3zM6 17l-4-4 4-4v3h12v2H6v3z"/></svg>
           <svg viewBox="0 0 24 24"><path d="M16.01 11H4v2h12.01v3L20 12l-3.99-4v3zM6 17l-4-4 4-4v3h12v2H6v3z"/></svg>
           <span>客户交接</span>
           <span>客户交接</span>
         </a>
         </a>
+        <a class="nav-item" href="#knowledge" data-page="knowledge">
+          <svg viewBox="0 0 24 24"><path d="M10 4H2v16h20V6H12l-2-2zm10 14H4V6h5.17l2 2H20v10zM8 10h8v2H8v-2zm0 4h6v2H8v-2z"/></svg>
+          <span>知识与任务</span>
+        </a>
+      </div>
+      <div class="nav-group">
+        <div class="nav-label">系统</div>
+        <a class="nav-item" href="#skills" data-page="skills">
+          <svg viewBox="0 0 24 24"><path d="M19 13h-2V7h-6V5H5c-1.1 0-2 .9-2 2v6h2v6c0 1.1.9 2 2 2h6v-2H7v-6h6v2h6c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2h-6v2h6v6zM9 9H5V7h4v2zm10 4h-4V7h4v6z"/></svg>
+          <span>数字员工能力</span>
+        </a>
         <a class="nav-item" href="#status" data-page="status">
         <a class="nav-item" href="#status" data-page="status">
           <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
           <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
-          <span>账号状态</span>
+          <span>连接与设置</span>
         </a>
         </a>
       </div>
       </div>
     </nav>
     </nav>
   </aside>
   </aside>
+  <button class="sidebar-backdrop" id="sidebar-backdrop" type="button" aria-label="关闭导航"></button>
   <main class="main">
   <main class="main">
     <header class="topbar">
     <header class="topbar">
       <button class="menu-btn" id="menu-btn" aria-label="切换菜单">
       <button class="menu-btn" id="menu-btn" aria-label="切换菜单">
         <svg viewBox="0 0 24 24"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
         <svg viewBox="0 0 24 24"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
       </button>
       </button>
-      <h1 class="page-title" id="page-title">客户群管理</h1>
+      <h1 class="page-title" id="page-title">经营总览</h1>
       <div class="account-switcher" id="account-switcher">
       <div class="account-switcher" id="account-switcher">
         <button class="account-btn" id="account-btn">
         <button class="account-btn" id="account-btn">
           <span class="account-dot offline" id="account-dot"></span>
           <span class="account-dot offline" id="account-dot"></span>
@@ -82,6 +100,6 @@
   <div class="toast-container" id="toast-container" aria-live="polite"></div>
   <div class="toast-container" id="toast-container" aria-live="polite"></div>
 </div>
 </div>
 <script src="/dashboard/echarts.min.js"></script>
 <script src="/dashboard/echarts.min.js"></script>
-<script src="/dashboard/app.js"></script>
+<script src="/dashboard/app.js?v=20260725-dialogs"></script>
 </body>
 </body>
 </html>
 </html>

+ 243 - 9
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -14,6 +14,18 @@ const {
   qiweiRejectExternalGroup,
   qiweiRejectExternalGroup,
   qiweiSyncGroupMessages
   qiweiSyncGroupMessages
 } = require('../tools/qiwei-group-management-run');
 } = require('../tools/qiwei-group-management-run');
+const {
+  qiweiGroupOpsGetContext,
+  qiweiGroupOpsGeneratePlan,
+  qiweiGroupOpsBatchGeneratePlans,
+  qiweiGroupOpsReviewMessages,
+  qiweiGroupOpsManagePlaybook,
+  qiweiGroupOpsExecuteItem,
+  qiweiGroupOpsUpdateFinding,
+  qiweiGroupOpsInsights,
+  qiweiGroupOpsManageTask,
+  qiweiGroupOpsAutomation
+} = require('../tools/qiwei-group-operations-run');
 const {
 const {
   qiweiLoginStatus,
   qiweiLoginStatus,
   qiweiLoginStart,
   qiweiLoginStart,
@@ -112,6 +124,8 @@ const {
 const {
 const {
   getUnifiedTaskHub,
   getUnifiedTaskHub,
   createUnifiedLocalTask,
   createUnifiedLocalTask,
+  createUnifiedDiagnosisCandidate,
+  updateUnifiedDiagnosisOutcome,
   updateUnifiedTask
   updateUnifiedTask
 } = require('./unified-task-service');
 } = require('./unified-task-service');
 const {
 const {
@@ -119,6 +133,7 @@ const {
   updateCustomerMaster,
   updateCustomerMaster,
   updateCustomerRecommendationFeedback
   updateCustomerRecommendationFeedback
 } = require('./customer-master-service');
 } = require('./customer-master-service');
+const { qiweiBusinessDiagnosis } = require('../tools/qiwei-business-diagnosis-run');
 
 
 const DASHBOARD_PORT = process.env.QIWEI_DASHBOARD_PORT || 4320;
 const DASHBOARD_PORT = process.env.QIWEI_DASHBOARD_PORT || 4320;
 const STATIC_DIR = path.join(__dirname);
 const STATIC_DIR = path.join(__dirname);
@@ -129,9 +144,84 @@ const jobs = new Map();
 
 
 const accountConnectionMonitor = createAccountConnectionMonitor({
 const accountConnectionMonitor = createAccountConnectionMonitor({
   checkStatus: () => qiweiLoginStatus({}),
   checkStatus: () => qiweiLoginStatus({}),
-  recoverLogin: () => qiweiLoginCheck({ manual: true, persistConfig: false })
+  recoverLogin: () => qiweiLoginCheck({ manual: true, persistConfig: false }),
+  // 后台每 15 秒主动探测一次;页面读取复用最近结果,避免再次阻塞远端登录状态接口。
+  cacheTtlMs: 20000
 });
 });
 accountConnectionMonitor.start();
 accountConnectionMonitor.start();
+void accountConnectionMonitor.getStatus().catch(() => {});
+
+const SUBSCRIPTION_STATUS_CACHE_TTL_MS = 2 * 60 * 1000;
+let subscriptionStatusCache = null;
+let subscriptionStatusCachedAt = 0;
+let subscriptionStatusInFlight = null;
+
+async function getCachedSubscriptionStatus({ force = false } = {}) {
+  const fresh = subscriptionStatusCache && Date.now() - subscriptionStatusCachedAt < SUBSCRIPTION_STATUS_CACHE_TTL_MS;
+  if (!force && fresh) return subscriptionStatusCache;
+  if (subscriptionStatusInFlight) return subscriptionStatusInFlight;
+  subscriptionStatusInFlight = qiweiSubscriptionStatus({})
+    .then(result => {
+      subscriptionStatusCache = result;
+      subscriptionStatusCachedAt = Date.now();
+      return result;
+    })
+    .finally(() => { subscriptionStatusInFlight = null; });
+  return subscriptionStatusInFlight;
+}
+
+// Dashboard 启动后立即预热较慢的远程订阅状态,用户进入账号页时通常可直接命中缓存。
+void getCachedSubscriptionStatus().catch(() => {});
+
+async function getDashboardCustomerDirectory() {
+  const merged = new Map();
+  const mergeCustomer = customer => {
+    const externalUserId = String(customer.externalUserId || customer.customerId || '').trim();
+    if (!externalUserId) return;
+    const current = merged.get(externalUserId) || {};
+    merged.set(externalUserId, {
+      ...current,
+      ...customer,
+      externalUserId,
+      customerId: customer.customerId || current.customerId || externalUserId,
+      name: customer.name || customer.displayName || current.name || '',
+      phone: customer.phone || current.phone || '',
+      tags: Array.isArray(customer.tags) ? customer.tags : (current.tags || []),
+      hasPortrait: customer.hasPortrait !== undefined
+        ? Boolean(customer.hasPortrait)
+        : Boolean((customer.fields || current.fields || []).length || current.hasPortrait),
+    });
+  };
+
+  try {
+    const allowlist = await getAllowlistCandidates();
+    for (const contact of allowlist.data?.contacts || []) {
+      mergeCustomer({
+        externalUserId: contact.id,
+        name: contact.displayName,
+        selected: contact.selected === true,
+        friendRequestStatus: 'ACCEPTED',
+        source: 'qiwei-contact',
+      });
+    }
+  } catch {
+    // Local and conversation-backed customers remain available during a remote outage.
+  }
+
+  const local = getStateSection('customers');
+  for (const customer of Object.values(local.customers || {})) mergeCustomer(customer);
+
+  const master = getCustomerMasterHub();
+  for (const customer of master.data?.customers || []) {
+    mergeCustomer({
+      ...customer,
+      name: customer.displayName,
+      hasPortrait: Array.isArray(customer.fields) && customer.fields.length > 0,
+    });
+  }
+
+  return Array.from(merged.values());
+}
 
 
 function ensureTmpDir() {
 function ensureTmpDir() {
   fs.mkdirSync(TMP_DIR, { recursive: true });
   fs.mkdirSync(TMP_DIR, { recursive: true });
@@ -184,7 +274,10 @@ function serveStatic(req, res, filePath, contentType) {
       res.end('not found');
       res.end('not found');
       return;
       return;
     }
     }
-    res.writeHead(200, { 'Content-Type': contentType });
+    res.writeHead(200, {
+      'Content-Type': contentType,
+      'Cache-Control': 'no-store'
+    });
     res.end(data);
     res.end(data);
   });
   });
 }
 }
@@ -250,17 +343,38 @@ function getContentType(filePath) {
   return map[ext] || 'application/octet-stream';
   return map[ext] || 'application/octet-stream';
 }
 }
 
 
-async function combinedStatus() {
-  const [login, subscription] = await Promise.all([
+async function combinedStatus({ fast = false } = {}) {
+  const statusPromise = Promise.all([
     accountConnectionMonitor.getStatus(),
     accountConnectionMonitor.getStatus(),
-    qiweiSubscriptionStatus({})
+    getCachedSubscriptionStatus()
   ]);
   ]);
+  let login;
+  let subscription;
+  if (fast) {
+    const timeout = Symbol('status-timeout');
+    const result = await Promise.race([
+      statusPromise,
+      new Promise(resolve => setTimeout(() => resolve(timeout), 180))
+    ]);
+    if (result === timeout) {
+      login = accountConnectionMonitor.state.lastOutput || { status: 'pending', summary: {}, data: {} };
+      subscription = subscriptionStatusCache || { status: 'pending', summary: {}, data: {} };
+    } else {
+      [login, subscription] = result;
+    }
+  } else {
+    [login, subscription] = await statusPromise;
+  }
+  const loginPending = login?.status === 'pending';
+  const subscriptionPending = subscription?.status === 'pending';
   return {
   return {
     status: 'ok',
     status: 'ok',
     summary: {
     summary: {
-      authConfigured: login.status !== 'needs_auth',
-      online: !!login.summary?.online,
-      subscribed: !!subscription.summary?.subscribed
+      authConfigured: loginPending ? null : login.status !== 'needs_auth',
+      online: loginPending ? null : !!login.summary?.online,
+      subscribed: subscriptionPending ? null : !!subscription.summary?.subscribed,
+      loginPending,
+      subscriptionPending
     },
     },
     data: { login, subscription }
     data: { login, subscription }
   };
   };
@@ -290,7 +404,7 @@ async function handleRequest(req, res) {
       return;
       return;
     }
     }
     if (pathname === '/api/status' && req.method === 'GET') {
     if (pathname === '/api/status' && req.method === 'GET') {
-      json(res, 200, await combinedStatus());
+      json(res, 200, await combinedStatus({ fast: url.searchParams.get('fast') === 'true' }));
       return;
       return;
     }
     }
     if (pathname === '/api/skills' && req.method === 'GET') {
     if (pathname === '/api/skills' && req.method === 'GET') {
@@ -362,6 +476,14 @@ async function handleRequest(req, res) {
       json(res, 200, createUnifiedLocalTask(await readBody(req)));
       json(res, 200, createUnifiedLocalTask(await readBody(req)));
       return;
       return;
     }
     }
+    if (pathname === '/api/knowledge/tasks/diagnosis-candidate' && req.method === 'POST') {
+      json(res, 200, createUnifiedDiagnosisCandidate(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/knowledge/tasks/diagnosis-feedback' && req.method === 'POST') {
+      json(res, 200, updateUnifiedDiagnosisOutcome(await readBody(req)));
+      return;
+    }
     if (pathname === '/api/knowledge/tasks/update' && req.method === 'POST') {
     if (pathname === '/api/knowledge/tasks/update' && req.method === 'POST') {
       json(res, 200, await updateUnifiedTask(await readBody(req)));
       json(res, 200, await updateUnifiedTask(await readBody(req)));
       return;
       return;
@@ -514,6 +636,40 @@ async function handleRequest(req, res) {
       json(res, 200, { status: 'ok', data: getStateSection('summary') });
       json(res, 200, { status: 'ok', data: getStateSection('summary') });
       return;
       return;
     }
     }
+    if (pathname === '/api/business-diagnosis' && req.method === 'POST') {
+      json(res, 200, await qiweiBusinessDiagnosis(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/dashboard/customers/search' && req.method === 'GET') {
+      const keyword = String(url.searchParams.get('keyword') || '').trim().toLowerCase();
+      const hasPortrait = url.searchParams.has('hasPortrait') ? url.searchParams.get('hasPortrait') === 'true' : undefined;
+      const scope = String(url.searchParams.get('scope') || '').trim();
+      let customers = (await getDashboardCustomerDirectory()).filter(customer => {
+        if (scope === 'portrait' && customer.selected !== true && customer.discoveredFromGroups !== true && customer.groupStatus !== 'IN_GROUP') return false;
+        if (keyword) {
+          const text = `${customer.name || ''} ${customer.phone || ''} ${customer.externalUserId || ''}`.toLowerCase();
+          if (!text.includes(keyword)) return false;
+        }
+        if (hasPortrait !== undefined && Boolean(customer.hasPortrait) !== hasPortrait) return false;
+        return true;
+      });
+      const sortBy = url.searchParams.get('sortBy') || 'default';
+      if (sortBy === 'lastActive') {
+        customers.sort((a, b) => String(b.lastSeenInGroupAt || b.updatedAt || '').localeCompare(String(a.lastSeenInGroupAt || a.updatedAt || '')));
+      } else if (sortBy === 'portraitDesc') {
+        customers.sort((a, b) => Number(Boolean(b.hasPortrait)) - Number(Boolean(a.hasPortrait)));
+      } else if (sortBy === 'portraitAsc') {
+        customers.sort((a, b) => Number(Boolean(a.hasPortrait)) - Number(Boolean(b.hasPortrait)));
+      } else if (sortBy === 'nameAsc') {
+        customers.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'));
+      }
+      const total = customers.length;
+      const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 20));
+      const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
+      customers = customers.slice(offset, offset + limit);
+      json(res, 200, { status: 'ok', data: { customers, total, limit, offset } });
+      return;
+    }
     if (pathname === '/api/dashboard/customers' && req.method === 'GET') {
     if (pathname === '/api/dashboard/customers' && req.method === 'GET') {
       const filter = {
       const filter = {
         keyword: url.searchParams.get('keyword') || '',
         keyword: url.searchParams.get('keyword') || '',
@@ -619,6 +775,84 @@ async function handleRequest(req, res) {
       return;
       return;
     }
     }
 
 
+    // Group Operations
+    if (pathname === '/api/group-ops/overview' && req.method === 'GET') {
+      json(res, 200, await qiweiGroupOpsGetContext({
+        accountKey: url.searchParams.get('accountKey') || undefined,
+        date: url.searchParams.get('date') || undefined
+      }));
+      return;
+    }
+    if (pathname === '/api/group-ops/playbooks' && req.method === 'GET') {
+      json(res, 200, await qiweiGroupOpsManagePlaybook({ accountKey: url.searchParams.get('accountKey') || undefined, action: 'list' }));
+      return;
+    }
+    if (pathname === '/api/group-ops/playbooks' && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsManagePlaybook({ ...(await readBody(req)), action: 'create' }));
+      return;
+    }
+    const groupOpsPreviewRoute = pathname.match(/^\/api\/group-ops\/playbooks\/([^/]+)\/(preview|versions)$/);
+    if (groupOpsPreviewRoute && req.method === 'GET') {
+      json(res, 200, await qiweiGroupOpsManagePlaybook({ accountKey: url.searchParams.get('accountKey') || undefined, playbookId: groupOpsPreviewRoute[1], version: url.searchParams.get('version') ? Number(url.searchParams.get('version')) : undefined, action: groupOpsPreviewRoute[2] }));
+      return;
+    }
+    const groupOpsVersionRoute = pathname.match(/^\/api\/group-ops\/playbooks\/([^/]+)\/versions$/);
+    if (groupOpsVersionRoute && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsManagePlaybook({ ...(await readBody(req)), playbookId: groupOpsVersionRoute[1], action: 'new_version' }));
+      return;
+    }
+    const groupOpsPublishRoute = pathname.match(/^\/api\/group-ops\/playbooks\/([^/]+)\/(publish|rollback)$/);
+    if (groupOpsPublishRoute && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsManagePlaybook({ ...(await readBody(req)), playbookId: groupOpsPublishRoute[1], action: groupOpsPublishRoute[2] }));
+      return;
+    }
+    const groupOpsPlanRoute = pathname.match(/^\/api\/group-ops\/groups\/([^/]+)\/generate-plan$/);
+    if (groupOpsPlanRoute && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsGeneratePlan({ ...(await readBody(req)), roomId: decodeURIComponent(groupOpsPlanRoute[1]) }));
+      return;
+    }
+    if (pathname === '/api/group-ops/plans/batch' && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsBatchGeneratePlans(await readBody(req)));
+      return;
+    }
+    const groupOpsReviewRoute = pathname.match(/^\/api\/group-ops\/groups\/([^/]+)\/review$/);
+    if (groupOpsReviewRoute && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsReviewMessages({ ...(await readBody(req)), roomId: decodeURIComponent(groupOpsReviewRoute[1]) }));
+      return;
+    }
+    const groupOpsItemRoute = pathname.match(/^\/api\/group-ops\/plan-items\/([^/]+)\/(approve|reject|skip|mark-sent)$/);
+    if (groupOpsItemRoute && req.method === 'POST') {
+      const action = groupOpsItemRoute[2] === 'mark-sent' ? 'mark_sent' : groupOpsItemRoute[2];
+      json(res, 200, await qiweiGroupOpsExecuteItem({ ...(await readBody(req)), itemId: groupOpsItemRoute[1], action }));
+      return;
+    }
+    const groupOpsFindingRoute = pathname.match(/^\/api\/group-ops\/findings\/([^/]+)$/);
+    if (groupOpsFindingRoute && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsUpdateFinding({ ...(await readBody(req)), findingId: groupOpsFindingRoute[1] }));
+      return;
+    }
+    if (pathname === '/api/group-ops/quality-settings' && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsInsights({ ...(await readBody(req)), action: 'update_settings' }));
+      return;
+    }
+    if (pathname === '/api/group-ops/tasks' && req.method === 'GET') {
+      json(res, 200, await qiweiGroupOpsManageTask({ accountKey: url.searchParams.get('accountKey') || undefined, status: url.searchParams.get('status') || undefined, action: 'list' }));
+      return;
+    }
+    if (pathname === '/api/group-ops/tasks' && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsManageTask({ ...(await readBody(req)), action: 'create' }));
+      return;
+    }
+    const groupOpsTaskRoute = pathname.match(/^\/api\/group-ops\/tasks\/([^/]+)\/(update|sync-official)$/);
+    if (groupOpsTaskRoute && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsManageTask({ ...(await readBody(req)), taskId: groupOpsTaskRoute[1], action: groupOpsTaskRoute[2] === 'sync-official' ? 'sync_official' : 'update' }));
+      return;
+    }
+    if (pathname === '/api/group-ops/automation' && req.method === 'POST') {
+      json(res, 200, await qiweiGroupOpsAutomation(await readBody(req)));
+      return;
+    }
+
     // Customer Operations
     // Customer Operations
     if (pathname === '/api/customers' && req.method === 'GET') {
     if (pathname === '/api/customers' && req.method === 'GET') {
       json(res, 200, getCustomerMasterHub());
       json(res, 200, getCustomerMasterHub());

+ 919 - 8
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css

@@ -42,6 +42,13 @@
 .customer-master-policy strong { color: #237348; font-size: 13px; }
 .customer-master-policy strong { color: #237348; font-size: 13px; }
 .customer-master-policy span { color: #4f695d; font-size: 12px; }
 .customer-master-policy span { color: #4f695d; font-size: 12px; }
 .customer-master-policy small { grid-column: 2; color: #809087; }
 .customer-master-policy small { grid-column: 2; color: #809087; }
+.customer-master-policy.fallback { border-color: #f1d6a5; background: #fffaf0; }
+.customer-master-policy.fallback strong { color: #b07d1a; }
+.customer-fallback-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; color: #b07d1a; background: #fff3d6; font-size: 10px; font-weight: 650; vertical-align: middle; }
+.customer-master-list-item .customer-fallback-badge { margin-left: 6px; }
+.customer-master-fallback-note { display: grid; gap: 6px; padding: 14px 16px; border-radius: 12px; border: 1px solid #f1d6a5; background: #fffaf0; color: #8a6a2f; }
+.customer-master-fallback-note strong { font-size: 12px; }
+.customer-master-fallback-note span { font-size: 11px; line-height: 1.6; }
 .customer-master-layout { min-height: 620px; display: grid; grid-template-columns: 300px minmax(0, 1fr); border: 1px solid #e2e7e5; border-radius: 16px; background: #fff; overflow: hidden; box-shadow: 0 10px 30px rgba(30, 57, 46, .05); }
 .customer-master-layout { min-height: 620px; display: grid; grid-template-columns: 300px minmax(0, 1fr); border: 1px solid #e2e7e5; border-radius: 16px; background: #fff; overflow: hidden; box-shadow: 0 10px 30px rgba(30, 57, 46, .05); }
 .customer-master-layout > aside { border-right: 1px solid #e6ebe8; background: #fafcfb; }
 .customer-master-layout > aside { border-right: 1px solid #e6ebe8; background: #fafcfb; }
 .customer-master-layout > aside > header { display: grid; gap: 10px; padding: 16px; border-bottom: 1px solid #e6ebe8; }
 .customer-master-layout > aside > header { display: grid; gap: 10px; padding: 16px; border-bottom: 1px solid #e6ebe8; }
@@ -197,6 +204,7 @@
 .unified-task-main footer { display: flex; flex-wrap: wrap; gap: 8px 18px; color: #8a929d; font-size: 11px; }
 .unified-task-main footer { display: flex; flex-wrap: wrap; gap: 8px 18px; color: #8a929d; font-size: 11px; }
 .task-source, .task-status, .task-audience { display: inline-flex; align-items: center; min-height: 22px; border-radius: 999px; padding: 0 9px; font-size: 10px; font-weight: 650; }
 .task-source, .task-status, .task-audience { display: inline-flex; align-items: center; min-height: 22px; border-radius: 999px; padding: 0 9px; font-size: 10px; font-weight: 650; }
 .task-source { color: #40546c; background: #edf2f8; }
 .task-source { color: #40546c; background: #edf2f8; }
+.task-source.diagnosis { color: #276b4d; background: #e9f6ef; }
 .task-status { color: #3565a4; background: #edf5ff; }
 .task-status { color: #3565a4; background: #edf5ff; }
 .task-status.done { color: #2d7a4d; background: #edf8f1; }
 .task-status.done { color: #2d7a4d; background: #edf8f1; }
 .task-status.blocked, .task-status.dismissed { color: #8a5c22; background: #fff5e8; }
 .task-status.blocked, .task-status.dismissed { color: #8a5c22; background: #fff5e8; }
@@ -204,12 +212,42 @@
 .task-audience { color: #237344; background: #eaf7ee; }
 .task-audience { color: #237344; background: #eaf7ee; }
 .unified-task-actions { min-width: 135px; display: flex; flex-wrap: wrap; justify-content: flex-end; align-content: center; gap: 7px; }
 .unified-task-actions { min-width: 135px; display: flex; flex-wrap: wrap; justify-content: flex-end; align-content: center; gap: 7px; }
 .unified-task-actions > span, .task-promoted { color: #76808d; font-size: 11px; align-self: center; }
 .unified-task-actions > span, .task-promoted { color: #76808d; font-size: 11px; align-self: center; }
-
-@media (max-width: 1180px) {
+.diagnosis-feedback-summary { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(0, 2.2fr); gap: 18px; align-items: center; padding: 14px 16px; border: 1px solid #d9e7df; border-radius: 12px; background: #f7fbf9; }
+.diagnosis-feedback-summary > header { display: grid; gap: 3px; }
+.diagnosis-feedback-summary > header strong { color: #244d3a; font-size: 13px; }
+.diagnosis-feedback-summary > header span { color: #72847a; font-size: 11px; }
+.diagnosis-feedback-summary > div { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
+.diagnosis-feedback-summary > div > span { min-width: 0; display: grid; gap: 2px; color: #748078; font-size: 10px; }
+.diagnosis-feedback-summary > div strong { color: #263c31; font-size: 17px; }
+.diagnosis-feedback-form { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(260px, 1.25fr) minmax(260px, 1fr) auto; gap: 10px; align-items: end; margin-top: 4px; padding: 12px; border: 1px solid #dfe8e3; border-radius: 10px; background: #f8fbf9; }
+.diagnosis-feedback-head { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
+.diagnosis-feedback-head > div { display: grid; gap: 2px; }
+.diagnosis-feedback-head strong { color: #2a4337; font-size: 12px; }
+.diagnosis-feedback-head span, .diagnosis-feedback-head > em { color: #7a8880; font-size: 10px; font-style: normal; }
+.diagnosis-outcome { border-radius: 999px; padding: 4px 8px; font-weight: 650; }
+.diagnosis-outcome.effective { color: #217148; background: #e6f5ec; }
+.diagnosis-outcome.partially_effective { color: #7b6421; background: #fff7df; }
+.diagnosis-outcome.ineffective { color: #a3473f; background: #fff0ee; }
+.diagnosis-outcome.unknown { color: #52616d; background: #edf1f3; }
+.diagnosis-outcome-options { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid #d8e1dc; border-radius: 8px; overflow: hidden; background: #fff; }
+.diagnosis-outcome-options label { min-width: 0; min-height: 40px; display: grid; place-items: center; padding: 0 8px; border-right: 1px solid #e2e8e4; color: #59685f; font-size: 11px; cursor: pointer; transition: color .18s, background-color .18s; }
+.diagnosis-outcome-options label:last-child { border-right: 0; }
+.diagnosis-outcome-options label:hover { background: #f0f7f3; }
+.diagnosis-outcome-options label.selected, .diagnosis-outcome-options label:has(input:checked) { color: #216d48; background: #e7f5ed; font-weight: 650; }
+.diagnosis-outcome-options input { position: absolute; opacity: 0; pointer-events: none; }
+.diagnosis-outcome-options label:focus-within { outline: 2px solid #4d8f70; outline-offset: -2px; }
+.diagnosis-feedback-note { min-width: 0; display: grid; gap: 4px; }
+.diagnosis-feedback-note > span { color: #66766d; font-size: 10px; }
+.diagnosis-feedback-note input { width: 100%; min-height: 40px; border: 1px solid #d8e1dc; border-radius: 8px; padding: 0 10px; background: #fff; color: #26352e; }
+.diagnosis-feedback-form > .btn { min-height: 40px; }
+
+@media (max-width: 1500px) {
   .task-create-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
   .task-create-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
   .task-create-form > div { grid-column: 1 / -1; }
   .task-create-form > div { grid-column: 1 / -1; }
   .task-filterbar { grid-template-columns: minmax(200px, 1fr) 180px 130px auto; }
   .task-filterbar { grid-template-columns: minmax(200px, 1fr) 180px 130px auto; }
   .task-filterbar > span { display: none; }
   .task-filterbar > span { display: none; }
+  .diagnosis-feedback-form { grid-template-columns: 1fr auto; }
+  .diagnosis-outcome-options { grid-column: 1 / -1; }
 }
 }
 
 
 @media (max-width: 760px) {
 @media (max-width: 760px) {
@@ -218,6 +256,12 @@
   .unified-task-card { grid-template-columns: 4px minmax(0, 1fr); }
   .unified-task-card { grid-template-columns: 4px minmax(0, 1fr); }
   .unified-task-actions { grid-column: 2; justify-content: flex-start; }
   .unified-task-actions { grid-column: 2; justify-content: flex-start; }
   .unified-task-main > header { align-items: flex-start; flex-direction: column; gap: 6px; }
   .unified-task-main > header { align-items: flex-start; flex-direction: column; gap: 6px; }
+  .diagnosis-feedback-summary { grid-template-columns: 1fr; }
+  .diagnosis-feedback-summary > div { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .diagnosis-feedback-form { grid-template-columns: 1fr; }
+  .diagnosis-outcome-options { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .diagnosis-outcome-options label { min-height: 44px; border-bottom: 1px solid #e2e8e4; }
+  .diagnosis-feedback-note, .diagnosis-feedback-form > .btn { grid-column: 1; }
 }
 }
 
 
 * { box-sizing: border-box; }
 * { box-sizing: border-box; }
@@ -254,6 +298,8 @@ html, body {
   transform: translateX(-100%);
   transform: translateX(-100%);
 }
 }
 
 
+.sidebar-backdrop { display: none; }
+
 .brand {
 .brand {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -331,6 +377,7 @@ html, body {
 
 
 .main {
 .main {
   flex: 1;
   flex: 1;
+  min-width: 0;
   margin-left: 220px;
   margin-left: 220px;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
@@ -1095,6 +1142,132 @@ html, body {
 .agent-intelligence-actions .btn { padding: 4px 7px; font-size: 8px; }
 .agent-intelligence-actions .btn { padding: 4px 7px; font-size: 8px; }
 .agent-intelligence-actions span { color: #7d8783; font-size: 8px; }
 .agent-intelligence-actions span { color: #7d8783; font-size: 8px; }
 
 
+/* ==================== Dashboard Motion System ==================== */
+:root {
+  --motion-fast: 160ms;
+  --motion-base: 240ms;
+  --motion-slow: 320ms;
+  --motion-ease-out: cubic-bezier(.2, .72, .24, 1);
+  --motion-ease-soft: cubic-bezier(.22, .61, .36, 1);
+}
+
+/* 模块切换:页面立即出现,内容以短距离错峰进入,不延长数据加载。 */
+.content > .page {
+  animation: dashboardPageIn var(--motion-base) var(--motion-ease-out) both;
+}
+
+.content > .page > * {
+  animation: dashboardSectionIn var(--motion-slow) var(--motion-ease-out) both;
+}
+
+.content > .page > *:nth-child(2) { animation-delay: 35ms; }
+.content > .page > *:nth-child(3) { animation-delay: 70ms; }
+.content > .page > *:nth-child(4) { animation-delay: 105ms; }
+.content > .page > *:nth-child(5) { animation-delay: 140ms; }
+.content > .page > *:nth-child(n+6) { animation-delay: 165ms; }
+
+@keyframes dashboardPageIn {
+  from { opacity: 0; transform: translate3d(8px, 0, 0); }
+  to { opacity: 1; transform: translate3d(0, 0, 0); }
+}
+
+@keyframes dashboardSectionIn {
+  from { opacity: 0; transform: translate3d(0, 10px, 0); }
+  to { opacity: 1; transform: translate3d(0, 0, 0); }
+}
+
+/* 侧边导航:新模块图标轻微弹入,文字与底色保持原有品牌风格。 */
+.nav-item { transition: color var(--motion-fast) ease, background-color var(--motion-fast) ease, box-shadow var(--motion-base) ease, transform var(--motion-fast) ease; }
+.nav-item:hover { transform: translate3d(2px, 0, 0); }
+.nav-item.active svg { animation: dashboardNavIconIn var(--motion-base) var(--motion-ease-out) both; }
+
+@keyframes dashboardNavIconIn {
+  0% { opacity: .55; transform: scale(.78) rotate(-5deg); }
+  100% { opacity: 1; transform: scale(1) rotate(0); }
+}
+
+/* 关键指标与状态卡:错峰进入,并提供稳定、不改变布局的悬停反馈。 */
+.kpi-row > .kpi-card,
+.status-cards > .status-card,
+.workspace-stat-row > div,
+.customer-master-stats > div,
+.task-stat-row > div {
+  animation: dashboardMetricIn var(--motion-slow) var(--motion-ease-out) both;
+  transition: transform var(--motion-base) var(--motion-ease-out), border-color var(--motion-fast) ease, box-shadow var(--motion-base) ease;
+}
+
+.kpi-row > :nth-child(2), .status-cards > :nth-child(2), .workspace-stat-row > :nth-child(2), .customer-master-stats > :nth-child(2), .task-stat-row > :nth-child(2) { animation-delay: 45ms; }
+.kpi-row > :nth-child(3), .status-cards > :nth-child(3), .workspace-stat-row > :nth-child(3), .customer-master-stats > :nth-child(3), .task-stat-row > :nth-child(3) { animation-delay: 90ms; }
+.kpi-row > :nth-child(4), .workspace-stat-row > :nth-child(4), .customer-master-stats > :nth-child(4), .task-stat-row > :nth-child(4) { animation-delay: 135ms; }
+.kpi-row > :nth-child(5), .workspace-stat-row > :nth-child(5) { animation-delay: 180ms; }
+.kpi-row > :nth-child(6), .workspace-stat-row > :nth-child(6) { animation-delay: 225ms; }
+
+.kpi-card:hover,
+.status-card:hover,
+.customer-master-stats > div:hover,
+.task-stat-row > div:hover {
+  transform: translate3d(0, -2px, 0);
+  border-color: #d5dde2;
+  box-shadow: 0 10px 28px rgba(28,45,59,.075);
+}
+
+@keyframes dashboardMetricIn {
+  from { opacity: 0; transform: translate3d(0, 8px, 0) scale(.985); }
+  to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
+}
+
+/* 页签、筛选结果和局部工作区切换。 */
+[data-group-ops-panel]:not([hidden]),
+.agent-knowledge-view,
+.customer-master-detail,
+.meeting-detail,
+.doc-detail,
+.todo-detail {
+  animation: dashboardPanelIn var(--motion-base) var(--motion-ease-soft) both;
+}
+
+@keyframes dashboardPanelIn {
+  from { opacity: 0; transform: translate3d(6px, 0, 0); }
+  to { opacity: 1; transform: translate3d(0, 0, 0); }
+}
+
+/* 常用列表与操作卡仅在悬停时轻移,避免数据密集页面持续运动。 */
+.skill-capability-card,
+.property-card,
+.customer-master-list-item,
+.meeting-list-item,
+.office-list-item,
+.unified-task-card,
+.group-ops-playbooks article,
+.group-ops-task-list article {
+  transition: transform var(--motion-fast) ease, border-color var(--motion-fast) ease, background-color var(--motion-fast) ease, box-shadow var(--motion-base) ease;
+}
+
+.skill-capability-card:hover,
+.property-card:hover,
+.customer-master-list-item:hover,
+.meeting-list-item:hover,
+.office-list-item:hover,
+.unified-task-card:hover,
+.group-ops-playbooks article:hover,
+.group-ops-task-list article:hover {
+  transform: translate3d(0, -1px, 0);
+}
+
+/* Hero 装饰只做低频漂移,主体文字与操作按钮保持稳定。 */
+.customer-workspace-hero::before { animation: dashboardHeroFloat 8s ease-in-out infinite alternate; }
+.customer-workspace-hero::after { animation: dashboardHeroFloatReverse 10s ease-in-out infinite alternate; }
+
+@keyframes dashboardHeroFloat {
+  from { transform: translate3d(0, 0, 0); }
+  to { transform: translate3d(-10px, 8px, 0); }
+}
+
+@keyframes dashboardHeroFloatReverse {
+  from { transform: translate3d(0, 0, 0); }
+  to { transform: translate3d(9px, -6px, 0); }
+}
+
 .agent-match-card { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 9px 0; border-bottom: 1px dashed #e5e9e7; }
 .agent-match-card { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 9px 0; border-bottom: 1px dashed #e5e9e7; }
 .agent-match-card:last-child { border-bottom: 0; }
 .agent-match-card:last-child { border-bottom: 0; }
 .agent-match-card > span { width: 20px; height: 20px; display: grid; place-items: center; color: var(--agent-green); border-radius: 6px; background: var(--agent-green-soft); font-size: 9px; font-weight: 800; }
 .agent-match-card > span { width: 20px; height: 20px; display: grid; place-items: center; color: var(--agent-green); border-radius: 6px; background: var(--agent-green-soft); font-size: 9px; font-weight: 800; }
@@ -1116,7 +1289,7 @@ html, body {
 .agent-empty strong { color: #53605b; font-size: 13px; }
 .agent-empty strong { color: #53605b; font-size: 13px; }
 .agent-empty span { max-width: 360px; font-size: 10px; line-height: 1.5; }
 .agent-empty span { max-width: 360px; font-size: 10px; line-height: 1.5; }
 
 
-@media (max-width: 1250px) {
+@media (max-width: 1320px) {
   .agent-workspace { grid-template-columns: 220px minmax(390px, 1fr) 300px; }
   .agent-workspace { grid-template-columns: 220px minmax(390px, 1fr) 300px; }
 }
 }
 
 
@@ -1548,6 +1721,271 @@ tr:hover td { background: var(--brand-bg); }
   margin-top: 24px;
   margin-top: 24px;
 }
 }
 
 
+body.modal-open { overflow: hidden; }
+
+/* Customer portrait detail */
+.portrait-modal-overlay {
+  align-items: flex-start;
+  padding: 24px;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.portrait-modal {
+  width: min(100%, 960px);
+  max-width: 960px;
+  max-height: calc(100vh - 48px);
+  padding: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+  border: 1px solid rgba(255,255,255,.75);
+  border-radius: 20px;
+  box-shadow: 0 28px 80px rgba(20,34,44,.24);
+}
+
+.portrait-modal > .modal-title {
+  position: sticky;
+  top: 0;
+  z-index: 3;
+  margin: 0;
+  padding: 21px 26px 18px;
+  border-bottom: 1px solid #e9eeec;
+  color: #1f3029;
+  background: rgba(255,255,255,.96);
+  backdrop-filter: blur(10px);
+  font-size: 20px;
+}
+
+.portrait-profile { padding: 24px 26px 18px; background: linear-gradient(145deg, #f4faf7, #fff 72%); }
+.portrait-hero { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 15px; }
+.portrait-avatar { width: 54px; height: 54px; display: grid; place-items: center; border-radius: 16px; color: #fff; background: linear-gradient(145deg, #218263, #155b48); box-shadow: 0 8px 18px rgba(21,91,72,.2); font-size: 21px; font-weight: 800; }
+.portrait-identity { min-width: 0; }
+.portrait-name { color: #20342b; font-size: 20px; font-weight: 750; }
+.portrait-subtitle { margin-top: 4px; overflow-wrap: anywhere; color: #75847d; font-size: 11px; line-height: 1.55; }
+.portrait-chip-row, .portrait-tags, .portrait-evidence { display: flex; flex-wrap: wrap; gap: 6px; }
+.portrait-chip-row { margin-top: 8px; }
+.portrait-soft-chip { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border-radius: 999px; color: #547066; background: #e8f2ed; font-size: 10px; }
+.portrait-meta { min-width: 132px; padding-left: 14px; text-align: right; }
+.portrait-meta div { color: #89958f; font-size: 9px; }
+.portrait-meta strong { display: block; margin-top: 4px; color: #496057; font-size: 11px; }
+.portrait-tags { margin-top: 16px; padding-top: 14px; border-top: 1px solid #dfeae5; }
+.portrait-highlight-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 17px; }
+
+.portrait-section { margin: 0 26px 18px; padding: 17px; border: 1px solid #e1e8e4; border-radius: 14px; background: #fff; }
+.portrait-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.portrait-section-head h3 { margin: 0; color: #293b33; font-size: 14px; }
+.portrait-section-head span { padding: 3px 7px; border-radius: 999px; color: #6f7e77; background: #f0f4f2; font-size: 9px; }
+.portrait-insight-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
+.portrait-insight-card { min-width: 0; padding: 12px 13px; border: 1px solid #e7ece9; border-radius: 10px; background: #f8faf9; }
+.portrait-insight-label { margin-bottom: 5px; color: #78867f; font-size: 9px; font-weight: 700; letter-spacing: .04em; }
+.portrait-insight-value { overflow-wrap: anywhere; color: #304139; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
+.portrait-insight-note { margin-top: 6px; color: #71847a; font-size: 9px; }
+.portrait-evidence { margin-top: 8px; }
+.portrait-evidence span { padding: 3px 7px; border-radius: 999px; color: #2f7258; background: #e7f4ed; font-size: 9px; }
+.portrait-empty-panel { margin: 0 26px 18px; padding: 28px; border: 1px dashed #dbe4df; border-radius: 13px; color: #819087; background: #fafcfb; text-align: center; }
+
+.portrait-modal .raw-toggle { display: flex; align-items: center; gap: 7px; margin: 0 26px; padding: 11px 13px; border: 1px solid #e1e7e4; border-radius: 10px; color: #65746d; background: #f7f9f8; cursor: pointer; font-size: 11px; font-weight: 650; }
+.portrait-modal .raw-toggle svg { width: 16px; height: 16px; flex: 0 0 auto; fill: currentColor; transition: transform .18s ease; }
+.portrait-modal .raw-toggle.open svg { transform: rotate(180deg); }
+.portrait-modal .raw-panel { display: none; max-height: 360px; margin: 10px 26px 0; padding: 14px; overflow: auto; border: 1px solid #dde5e1; border-radius: 10px; color: #4d5f56; background: #f5f8f6; font: 10px/1.65 Consolas, "SFMono-Regular", monospace; white-space: pre-wrap; word-break: break-word; }
+.portrait-modal > .modal-actions { position: sticky; bottom: 0; z-index: 3; margin: 18px 0 0; padding: 14px 26px; border-top: 1px solid #e9eeec; background: rgba(255,255,255,.96); backdrop-filter: blur(10px); }
+.portrait-modal-loading { min-height: 360px; display: grid; place-items: center; align-content: center; gap: 9px; padding: 36px; color: #708078; text-align: center; }
+.portrait-modal-loading .spinner { width: 30px; height: 30px; margin-bottom: 4px; }
+.portrait-modal-loading strong { color: #304139; font-size: 14px; }
+.portrait-modal-loading small { font-size: 10px; }
+.portrait-modal-error { min-height: 300px; display: grid; place-items: center; align-content: center; gap: 7px; padding: 32px; color: #8b5d54; text-align: center; }
+.portrait-modal-error strong { font-size: 14px; }
+.portrait-modal-error span { font-size: 11px; }
+
+/* Customer detail modal */
+.customer-detail-modal-overlay { align-items: flex-start; padding: 24px; overflow-x: hidden; overflow-y: auto; }
+.customer-detail-modal {
+  width: min(100%, 720px);
+  max-width: 720px;
+  max-height: calc(100vh - 48px);
+  padding: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+  border: 1px solid rgba(255,255,255,.75);
+  border-radius: 20px;
+  box-shadow: 0 28px 80px rgba(20,34,44,.24);
+}
+.customer-detail-modal > .modal-title {
+  position: sticky;
+  top: 0;
+  z-index: 3;
+  margin: 0;
+  padding: 21px 26px 18px;
+  border-bottom: 1px solid #e9eeec;
+  color: #1f3029;
+  background: rgba(255,255,255,.96);
+  backdrop-filter: blur(10px);
+  font-size: 20px;
+}
+.customer-detail-modal .detail-card {
+  margin: 18px 26px 0;
+  border: 1px solid #e1e8e4;
+  border-radius: 14px;
+  background: #fff;
+  overflow: hidden;
+}
+.customer-detail-modal .detail-card-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 13px 16px;
+  border-bottom: 1px solid #eef2f0;
+  background: #fafcfb;
+  color: #293b33;
+  font-size: 13px;
+  font-weight: 700;
+}
+.customer-detail-modal .detail-card-header svg { width: 18px; height: 18px; fill: currentColor; }
+.customer-detail-modal .detail-card-body { padding: 16px; }
+.customer-detail-modal .detail-section-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 0 0 12px;
+  color: #2b3c34;
+  font-size: 13px;
+  font-weight: 700;
+}
+.customer-detail-modal .detail-section-title svg { width: 18px; height: 18px; fill: currentColor; }
+.customer-detail-modal .detail-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+}
+.customer-detail-modal .detail-grid.compact { gap: 10px; }
+.customer-detail-modal .detail-item { display: grid; gap: 4px; }
+.customer-detail-modal .detail-item-label { color: #89948f; font-size: 11px; }
+.customer-detail-modal .detail-item-value { color: #2b3c34; font-size: 12px; line-height: 1.5; word-break: break-word; }
+.customer-detail-modal .detail-list { display: grid; gap: 8px; }
+.customer-detail-modal .detail-list.compact { gap: 6px; }
+.customer-detail-modal .detail-list-item {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr);
+  align-items: center;
+  gap: 10px;
+  padding: 10px 12px;
+  border-radius: 10px;
+  background: #f7f9f8;
+}
+.customer-detail-modal .detail-list-item > div { min-width: 0; }
+.customer-detail-modal .detail-list-item > div > div:first-child { color: #34443c; font-size: 12px; font-weight: 650; }
+.customer-detail-modal .detail-muted { color: #8e9a93; font-size: 11px; }
+.customer-detail-modal .detail-empty { padding: 20px; border-radius: 10px; color: #8e9a93; background: #f7f9f8; font-size: 12px; text-align: center; }
+.customer-detail-modal .detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
+.customer-detail-modal .detail-tags .tag { padding: 4px 10px; border-radius: 999px; color: #2f7258; background: #e7f4ed; font-size: 11px; }
+.customer-detail-modal .detail-id-line { margin-top: 12px; padding-top: 12px; border-top: 1px solid #eef2f0; color: #9aa49d; font-size: 11px; }
+.customer-detail-modal .detail-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  margin: 18px 26px 0;
+}
+.customer-detail-modal .raw-toggle {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  margin: 18px 26px 0;
+  padding: 11px 13px;
+  border: 1px solid #e1e7e4;
+  border-radius: 10px;
+  color: #65746d;
+  background: #f7f9f8;
+  cursor: pointer;
+  font-size: 11px;
+  font-weight: 650;
+}
+.customer-detail-modal .raw-toggle svg { width: 16px; height: 16px; flex: 0 0 auto; fill: currentColor; transition: transform .18s ease; }
+.customer-detail-modal .raw-toggle.open svg { transform: rotate(180deg); }
+.customer-detail-modal .raw-panel {
+  display: none;
+  max-height: 360px;
+  margin: 10px 26px 0;
+  padding: 14px;
+  overflow: auto;
+  border: 1px solid #dde5e1;
+  border-radius: 10px;
+  color: #4d5f56;
+  background: #f5f8f6;
+  font: 10px/1.65 Consolas, "SFMono-Regular", monospace;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+.customer-detail-modal > .modal-actions {
+  position: sticky;
+  bottom: 0;
+  z-index: 3;
+  margin: 18px 0 0;
+  padding: 14px 26px;
+  border-top: 1px solid #e9eeec;
+  background: rgba(255,255,255,.96);
+  backdrop-filter: blur(10px);
+}
+
+@media (max-width: 700px) {
+  .customer-detail-modal-overlay { align-items: flex-end; padding: 0; }
+  .customer-detail-modal { width: 100%; max-height: 92vh; border-radius: 20px 20px 0 0; }
+  .customer-detail-modal > .modal-title { padding: 18px 18px 15px; font-size: 18px; }
+  .customer-detail-modal .detail-card { margin: 14px 14px 0; }
+  .customer-detail-modal .detail-grid { grid-template-columns: 1fr; }
+  .customer-detail-modal .detail-actions,
+  .customer-detail-modal .raw-toggle,
+  .customer-detail-modal .raw-panel { margin: 14px 14px 0; }
+  .customer-detail-modal > .modal-actions { padding: 12px 14px; }
+  .customer-detail-modal > .modal-actions .btn { flex: 1; min-height: 44px; }
+}
+
+/* Customer selector */
+.customer-selector-modal-overlay { align-items: flex-start; padding: 24px; overflow-x: hidden; overflow-y: auto; }
+.customer-selector-modal { width: min(100%, 760px); max-width: 760px; max-height: calc(100vh - 48px); padding: 0; overflow-x: hidden; overflow-y: auto; border: 1px solid rgba(255,255,255,.75); border-radius: 20px; box-shadow: 0 28px 80px rgba(20,34,44,.24); }
+.customer-selector-modal > .modal-title { position: sticky; top: 0; z-index: 3; margin: 0; padding: 21px 24px 18px; border-bottom: 1px solid #e8edeb; color: #26362f; background: rgba(255,255,255,.96); backdrop-filter: blur(10px); font-size: 20px; }
+.customer-selector-modal > .form-group { padding: 20px 24px 4px; }
+.customer-selector-filters { display: grid; grid-template-columns: minmax(220px, 1fr) 160px 150px; gap: 9px; }
+.customer-selector-search, .customer-selector-filter { width: 100%; min-width: 0; min-height: 40px; padding: 0 11px; border: 1px solid #dbe3df; border-radius: 9px; outline: 0; color: #33433b; background: #fff; font: inherit; font-size: 12px; }
+.customer-selector-search:focus, .customer-selector-filter:focus { border-color: #4e9677; box-shadow: 0 0 0 3px rgba(44,126,91,.1); }
+.customer-selector-count { margin: 13px 1px 8px; color: #77857e; font-size: 10px; }
+.customer-selector-list { min-height: 260px; max-height: 420px; display: grid; align-content: start; gap: 7px; overflow-y: auto; padding: 6px; border: 1px solid #e2e8e5; border-radius: 12px; background: #f8faf9; }
+.customer-selector-item { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid transparent; border-radius: 9px; color: #34433c; background: #fff; cursor: pointer; transition: border-color .16s ease, background-color .16s ease, box-shadow .16s ease; }
+.customer-selector-item:hover { border-color: #ceddd6; background: #fbfdfc; }
+.customer-selector-item.selected { border-color: #9ecdb9; background: #eef8f3; box-shadow: inset 3px 0 0 #2d8664; }
+.customer-selector-item input { width: 18px; height: 18px; margin: 0; accent-color: #25815f; cursor: pointer; }
+.customer-selector-info { min-width: 0; }
+.customer-selector-name { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; color: #293a32; font-size: 12px; font-weight: 700; }
+.customer-selector-phone { margin-top: 4px; overflow: hidden; color: #849088; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+.customer-selector-empty { min-height: 240px; display: grid; place-items: center; color: #87938d; font-size: 12px; text-align: center; }
+.customer-selector-empty.error { color: #a95a4c; }
+.customer-selector-pagination { min-height: 38px; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; padding-top: 10px; }
+.customer-selector-modal > .modal-actions { position: sticky; bottom: 0; z-index: 3; margin: 16px 0 0; padding: 14px 24px; border-top: 1px solid #e8edeb; background: rgba(255,255,255,.96); backdrop-filter: blur(10px); }
+
+@media (max-width: 700px) {
+  .portrait-modal-overlay { align-items: flex-end; padding: 0; }
+  .portrait-modal { width: 100%; max-height: 92vh; border-radius: 20px 20px 0 0; }
+  .portrait-modal > .modal-title { padding: 18px 18px 15px; font-size: 18px; }
+  .portrait-profile { padding: 18px; }
+  .portrait-hero { grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 12px; }
+  .portrait-avatar { width: 46px; height: 46px; border-radius: 14px; font-size: 18px; }
+  .portrait-name { font-size: 18px; }
+  .portrait-meta { grid-column: 1 / -1; min-width: 0; padding: 10px 0 0; border-top: 1px solid #dfe9e4; text-align: left; }
+  .portrait-highlight-grid, .portrait-insight-grid { grid-template-columns: 1fr; }
+  .portrait-section { margin: 0 14px 14px; padding: 14px; }
+  .portrait-empty-panel { margin: 0 14px 14px; }
+  .portrait-modal .raw-toggle { margin: 0 14px; }
+  .portrait-modal .raw-panel { margin: 8px 14px 0; }
+  .portrait-modal > .modal-actions { padding: 12px 14px; }
+  .portrait-modal > .modal-actions .btn { flex: 1; min-height: 44px; }
+  .customer-selector-modal-overlay { align-items: flex-end; padding: 0; }
+  .customer-selector-modal { width: 100%; max-height: 92vh; border-radius: 20px 20px 0 0; }
+  .customer-selector-modal > .modal-title { padding: 18px 18px 15px; font-size: 18px; }
+  .customer-selector-modal > .form-group { padding: 15px 14px 2px; }
+  .customer-selector-filters { grid-template-columns: 1fr; }
+  .customer-selector-list { min-height: 240px; max-height: 46vh; }
+  .customer-selector-modal > .modal-actions { padding: 12px 14px; }
+  .customer-selector-modal > .modal-actions .btn { flex: 1; min-height: 44px; }
+}
 .modal.allowlist-modal {
 .modal.allowlist-modal {
   width: min(92vw, 700px);
   width: min(92vw, 700px);
   max-width: 700px;
   max-width: 700px;
@@ -1719,7 +2157,7 @@ body.business-modal-open { overflow: hidden; }
 }
 }
 
 
 .business-modal-header h3 { margin: 0; color: #1e2a32; font-size: 21px; line-height: 1.3; }
 .business-modal-header h3 { margin: 0; color: #1e2a32; font-size: 21px; line-height: 1.3; }
-.business-modal-header p { margin: 7px 0 0; color: #74808a; font-size: 13px; line-height: 1.65; }
+.business-modal-header p { margin: 7px 0 0; color: #68757d; font-size: 13px; line-height: 1.65; white-space: pre-line; }
 
 
 .business-modal-close {
 .business-modal-close {
   flex: 0 0 34px;
   flex: 0 0 34px;
@@ -1735,6 +2173,8 @@ body.business-modal-open { overflow: hidden; }
 }
 }
 
 
 .business-modal-close:hover { color: #25333b; background: #e7ecea; }
 .business-modal-close:hover { color: #25333b; background: #e7ecea; }
+.business-modal-close:focus-visible,
+.business-modal-actions .btn:focus-visible { outline: 3px solid rgba(42, 154, 119, .22); outline-offset: 2px; }
 .business-modal-form { padding: 22px 27px 25px; }
 .business-modal-form { padding: 22px 27px 25px; }
 .business-modal-fields { display: grid; gap: 16px; }
 .business-modal-fields { display: grid; gap: 16px; }
 .business-modal-field { display: grid; gap: 7px; }
 .business-modal-field { display: grid; gap: 7px; }
@@ -1798,6 +2238,8 @@ body.business-modal-open { overflow: hidden; }
 .business-modal-error { margin: 13px 0 0; color: #c3443d; font-size: 12px; }
 .business-modal-error { margin: 13px 0 0; color: #c3443d; font-size: 12px; }
 .business-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
 .business-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
 .business-modal-confirm { min-width: 128px; }
 .business-modal-confirm { min-width: 128px; }
+.business-modal-confirm.danger { border-color: #c94b43; color: #fff; background: #c94b43; }
+.business-modal-confirm.danger:hover { border-color: #b33f38; background: #b33f38; }
 
 
 @media (max-width: 600px) {
 @media (max-width: 600px) {
   .business-modal-backdrop { align-items: end; padding: 0; }
   .business-modal-backdrop { align-items: end; padding: 0; }
@@ -1807,6 +2249,11 @@ body.business-modal-open { overflow: hidden; }
   .business-modal-actions .btn { flex: 1; }
   .business-modal-actions .btn { flex: 1; }
 }
 }
 
 
+@media (prefers-reduced-motion: reduce) {
+  .business-modal-backdrop,
+  .business-modal { animation: none; }
+}
+
 .agent-session-modal-status { padding: 12px 14px; border-radius: 10px; color: #6d7673; background: #f4f6f5; }
 .agent-session-modal-status { padding: 12px 14px; border-radius: 10px; color: #6d7673; background: #f4f6f5; }
 .agent-session-modal-status.ready { color: #146a4f; background: #eaf7f2; }
 .agent-session-modal-status.ready { color: #146a4f; background: #eaf7f2; }
 .agent-session-modal-status strong,
 .agent-session-modal-status strong,
@@ -2349,6 +2796,8 @@ body.business-modal-open { overflow: hidden; }
 @media (max-width: 768px) {
 @media (max-width: 768px) {
   .sidebar { transform: translateX(-100%); }
   .sidebar { transform: translateX(-100%); }
   .sidebar.open { transform: translateX(0); }
   .sidebar.open { transform: translateX(0); }
+  .sidebar-backdrop { position: fixed; inset: 0; z-index: 90; display: block; border: 0; background: rgba(15, 23, 42, .42); opacity: 0; pointer-events: none; transition: opacity .2s ease; }
+  .sidebar-backdrop.active { opacity: 1; pointer-events: auto; }
   .main { margin-left: 0; }
   .main { margin-left: 0; }
   .menu-btn { display: block; }
   .menu-btn { display: block; }
   .content { padding: 20px 16px 40px; }
   .content { padding: 20px 16px 40px; }
@@ -2378,11 +2827,473 @@ body.business-modal-open { overflow: hidden; }
   .property-filterbar { grid-template-columns: 1fr; }
   .property-filterbar { grid-template-columns: 1fr; }
 }
 }
 
 
+/* 社群运营 */
+/* Business overview */
+.overview-root { max-width: 1520px; margin: 0 auto; }
+.overview-page { display: grid; gap: 16px; }
+.overview-command {
+  display: grid;
+  grid-template-columns: minmax(270px, .72fr) minmax(440px, 1.28fr);
+  gap: 30px;
+  align-items: center;
+  min-height: 190px;
+  padding: 26px 30px;
+  border: 1px solid #dce4e1;
+  border-radius: 12px;
+  background: #fff;
+  box-shadow: 0 8px 26px rgba(26, 52, 43, .055);
+}
+.overview-command-copy { display: grid; gap: 7px; }
+.overview-command-copy h2 { margin: 0; color: #172b23; font-size: 30px; line-height: 1.25; letter-spacing: 0; }
+.overview-command-copy p { max-width: 520px; margin: 0; color: #64736c; font-size: 13px; line-height: 1.7; }
+.overview-kicker { display: flex; align-items: center; gap: 7px; color: #648076; font-size: 11px; font-weight: 700; }
+.overview-live { width: 8px; height: 8px; border-radius: 50%; background: #a9b2ae; box-shadow: 0 0 0 4px #f0f3f2; }
+.overview-live.online { background: #24a369; box-shadow: 0 0 0 4px #e6f6ee; }
+.overview-query-form { display: grid; gap: 9px; }
+.overview-query-form > label { color: #35483f; font-size: 12px; font-weight: 700; }
+.overview-query-form > div:first-of-type { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
+.overview-query-form input { width: 100%; min-height: 46px; padding: 0 14px; border: 1px solid #d7dfdc; border-radius: 8px; color: #1e3028; background: #fbfcfc; font: inherit; }
+.overview-query-form input:focus { border-color: #2a8a64; outline: 3px solid rgba(42, 138, 100, .13); background: #fff; }
+.overview-query-form .btn { min-height: 46px; padding-inline: 19px; background: #237858; border-color: #237858; }
+.overview-query-form .btn:hover { background: #1b684b; border-color: #1b684b; }
+.overview-query-chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.overview-query-chips button { min-height: 30px; padding: 5px 10px; border: 1px solid #e0e5e3; border-radius: 999px; color: #617069; background: #fff; font: inherit; font-size: 11px; cursor: pointer; transition: border-color .18s, color .18s, background-color .18s; }
+.overview-query-chips button:hover { border-color: #a8c9bb; color: #246b50; background: #f4faf7; }
+.overview-kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
+.overview-kpi { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 12px; align-items: center; min-height: 104px; padding: 16px; border: 1px solid #dfe5e2; border-radius: 10px; color: inherit; background: #fff; text-align: left; cursor: pointer; box-shadow: 0 4px 16px rgba(28, 49, 41, .035); transition: border-color .18s, box-shadow .18s, background-color .18s; }
+.overview-kpi:hover { border-color: #b8cfc5; background: #fcfefd; box-shadow: 0 8px 22px rgba(28, 69, 52, .075); }
+.overview-kpi.attention { border-color: #efc9bf; background: #fffdfc; }
+.overview-kpi-icon { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; border-radius: 8px; color: #347659; background: #eaf5ef; }
+.overview-kpi-icon.portrait { color: #376b96; background: #edf5fb; }
+.overview-kpi-icon.task { color: #956925; background: #fff6e7; }
+.overview-kpi-icon.risk { color: #aa5043; background: #fff0ed; }
+.overview-kpi-icon svg { width: 21px; height: 21px; fill: currentColor; }
+.overview-kpi > div { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; align-items: baseline; }
+.overview-kpi small { grid-column: 1 / -1; color: #75827c; font-size: 11px; }
+.overview-kpi strong { color: #1d3028; font-size: 26px; line-height: 1; }
+.overview-kpi em { justify-self: end; color: #87918c; font-size: 10px; font-style: normal; white-space: nowrap; }
+.overview-main-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(310px, .75fr); gap: 12px; align-items: start; }
+.overview-primary-column, .overview-secondary-column { display: grid; gap: 12px; }
+.overview-panel { padding: 18px; border: 1px solid #dfe5e2; border-radius: 10px; background: #fff; box-shadow: 0 5px 18px rgba(24, 48, 38, .035); }
+.overview-panel-header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 14px; }
+.overview-panel-header > div { display: grid; gap: 2px; }
+.overview-panel-header span { color: #5d8473; font-size: 9px; font-weight: 750; letter-spacing: 1px; }
+.overview-panel-header h3 { margin: 0; color: #23372e; font-size: 16px; letter-spacing: 0; }
+.overview-panel-header > small { color: #89948f; font-size: 10px; }
+.overview-panel-header > strong { color: #223a30; font-size: 22px; }
+.overview-diagnosis-empty { min-height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; padding: 24px; border-top: 1px solid #edf0ee; color: #78857f; text-align: center; }
+.overview-diagnosis-empty strong { color: #40534a; font-size: 14px; }
+.overview-diagnosis-empty span { max-width: 520px; font-size: 11px; line-height: 1.7; }
+.overview-diagnosis-result { display: grid; gap: 13px; padding-top: 14px; border-top: 1px solid #edf0ee; }
+.overview-diagnosis-result > header { display: grid; gap: 5px; padding-left: 12px; border-left: 3px solid #4b89b6; }
+.overview-diagnosis-result.warning > header { border-left-color: #d99335; }
+.overview-diagnosis-result.danger > header { border-left-color: #cc5e50; }
+.overview-diagnosis-result.success > header { border-left-color: #319267; }
+.overview-diagnosis-result > header span { color: #638072; font-size: 10px; font-weight: 700; }
+.overview-diagnosis-result > header strong { color: #263a31; font-size: 17px; }
+.overview-diagnosis-result > p { margin: 0; color: #65726c; font-size: 12px; line-height: 1.7; }
+.overview-evidence { display: flex; flex-wrap: wrap; gap: 7px; }
+.overview-evidence span { padding: 6px 9px; border-radius: 6px; color: #53665d; background: #f2f6f4; font-size: 10px; }
+.overview-data-gaps { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 12px; align-items: start; padding: 10px 12px; border-left: 3px solid #d99a43; background: #fffaf1; }
+.overview-data-gaps > strong { color: #8b6327; font-size: 10px; white-space: nowrap; }
+.overview-data-gaps > div { display: flex; flex-wrap: wrap; gap: 6px; }
+.overview-data-gaps span { display: inline-flex; flex-direction: column; gap: 2px; padding-right: 9px; border-right: 1px solid #eadcc2; color: #765d35; font-size: 10px; }
+.overview-data-gaps span:last-child { border-right: 0; }
+.overview-data-gaps small { color: #9a845f; font-size: 8px; }
+.overview-diagnosis-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid #edf0ee; border-bottom: 1px solid #edf0ee; }
+.overview-diagnosis-action { min-width: 0; display: grid; grid-template-rows: minmax(70px, 1fr) auto; border-right: 1px solid #edf0ee; }
+.overview-diagnosis-action:last-child { border-right: 0; }
+.overview-diagnosis-go { position: relative; min-width: 0; min-height: 70px; display: grid; gap: 4px; align-content: center; padding: 11px 34px 8px 12px; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; transition: background-color .18s; }
+.overview-diagnosis-go:hover { background: #f7faf8; }
+.overview-diagnosis-go > span { color: #2e4b3e; font-size: 12px; font-weight: 700; }
+.overview-diagnosis-go > small { overflow: hidden; color: #68776f; font-size: 9px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
+.overview-diagnosis-go svg { position: absolute; top: 50%; right: 10px; width: 15px; height: 15px; fill: #7f9a8d; transform: translateY(-50%); }
+.overview-candidate-btn { min-height: 31px; padding: 0 12px; border: 0; border-top: 1px solid #edf0ee; color: #327157; background: #fbfdfc; font: inherit; font-size: 9px; font-weight: 700; text-align: left; cursor: pointer; transition: color .18s, background-color .18s; }
+.overview-candidate-btn:hover { color: #1f5e45; background: #f0f8f4; }
+.overview-candidate-btn.added, .overview-candidate-btn:disabled { color: #718079; background: #f3f5f4; cursor: default; }
+.overview-action-list { display: grid; gap: 7px; }
+.overview-action { display: grid; grid-template-columns: 4px 42px minmax(0, 1fr) 18px; gap: 10px; align-items: center; min-height: 61px; padding: 8px 11px; border: 1px solid #e4e8e6; border-radius: 8px; color: inherit; background: #fff; text-align: left; cursor: pointer; transition: border-color .18s, background-color .18s; }
+.overview-action:hover { border-color: #bdcec7; background: #fafcfb; }
+.overview-action > i { align-self: stretch; border-radius: 999px; background: #5f8fbc; }
+.overview-action.warning > i { background: #d99a43; }
+.overview-action.danger > i { background: #cf675b; }
+.overview-action > strong { color: #2a3f36; font-size: 21px; text-align: center; }
+.overview-action > span { min-width: 0; display: grid; gap: 3px; }
+.overview-action b { color: #35483f; font-size: 12px; }
+.overview-action small { overflow: hidden; color: #849089; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+.overview-action > svg { width: 16px; height: 16px; fill: #8da096; }
+.overview-health-track { height: 7px; margin: 17px 0; overflow: hidden; border-radius: 999px; background: #edf1ef; }
+.overview-health-track i { display: block; height: 100%; border-radius: inherit; background: #3d9b70; }
+.overview-health-panel dl { display: grid; gap: 9px; margin: 0 0 16px; }
+.overview-health-panel dl div { display: flex; justify-content: space-between; gap: 16px; color: #6f7e77; font-size: 11px; }
+.overview-health-panel dt, .overview-health-panel dd { margin: 0; }
+.overview-health-panel dd { color: #33493f; font-weight: 700; }
+.overview-text-action { width: 100%; min-height: 37px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0; border: 0; border-top: 1px solid #edf0ee; color: #39735a; background: transparent; font: inherit; font-size: 11px; cursor: pointer; }
+.overview-text-action svg { width: 15px; height: 15px; fill: currentColor; }
+.overview-worker { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; min-height: 58px; border-top: 1px solid #edf0ee; }
+.overview-worker-icon { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 7px; color: #417a61; background: #eef6f2; }
+.overview-worker-icon svg { width: 17px; height: 17px; fill: currentColor; }
+.overview-worker > div { min-width: 0; display: grid; gap: 3px; }
+.overview-worker strong { color: #30443a; font-size: 12px; }
+.overview-worker small { overflow: hidden; color: #87928c; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.overview-worker em { min-height: 22px; display: inline-flex; align-items: center; padding: 0 8px; border-radius: 999px; color: #69756f; background: #f0f2f1; font-size: 9px; font-style: normal; white-space: nowrap; }
+.overview-worker em.online { color: #227149; background: #e9f7ef; }
+.overview-data-note { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 22px; min-height: 40px; padding: 0 4px; color: #8a9590; font-size: 10px; }
+.overview-data-note button { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 9px; border: 0; color: #547065; background: transparent; font: inherit; cursor: pointer; }
+.overview-data-note button svg { width: 14px; height: 14px; fill: currentColor; }
+.overview-query-form button:focus-visible, .overview-kpi:focus-visible, .overview-action:focus-visible, .overview-diagnosis-actions button:focus-visible, .overview-text-action:focus-visible, .overview-data-note button:focus-visible { outline: 3px solid rgba(35, 120, 88, .22); outline-offset: 2px; }
+
+/* Business overview visual refresh */
+.overview-page { gap: 18px; }
+.overview-command {
+  position: relative;
+  overflow: hidden;
+  min-height: 224px;
+  padding: 32px 34px;
+  border: 0;
+  border-radius: 8px;
+  color: #fff;
+  background: #153c32;
+  box-shadow: 0 18px 42px rgba(21, 60, 50, .17);
+}
+.overview-command::after {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  width: 38%;
+  height: 3px;
+  background: #e5a33f;
+  content: '';
+}
+.overview-command-copy { gap: 10px; }
+.overview-command-copy h2 { color: #fff; font-size: 34px; line-height: 1.2; }
+.overview-command-copy p { color: rgba(255, 255, 255, .72); font-size: 14px; line-height: 1.65; }
+.overview-kicker { color: #b9d8cd; font-size: 12px; }
+.overview-live { background: #91a89f; box-shadow: 0 0 0 4px rgba(255, 255, 255, .08); }
+.overview-live.online { background: #55d794; box-shadow: 0 0 0 4px rgba(85, 215, 148, .15); }
+.overview-command-status { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 5px; }
+.overview-command-status span { min-height: 32px; display: inline-flex; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid rgba(255, 255, 255, .14); border-radius: 6px; color: rgba(255, 255, 255, .76); background: rgba(255, 255, 255, .07); font-size: 12px; }
+.overview-command-status strong { color: #fff; font-size: 14px; }
+.overview-command-status .is-risk { border-color: rgba(255, 193, 181, .3); color: #ffd5cd; background: rgba(154, 54, 42, .26); }
+.overview-query-form { position: relative; z-index: 1; gap: 11px; padding: 0; background: transparent; box-shadow: none; }
+.overview-query-form > label { color: #c6ded5; font-size: 13px; }
+.overview-query-form input { min-height: 48px; padding: 0 15px; border-color: rgba(255, 255, 255, .7); border-radius: 6px; color: #172b23; background: rgba(255, 255, 255, .96); font-size: 14px; box-shadow: 0 7px 20px rgba(6, 27, 21, .13); }
+.overview-query-form input::placeholder { color: #75837d; }
+.overview-query-form input:focus { border-color: #f1b252; outline-color: rgba(241, 178, 82, .28); }
+.overview-query-form .btn { min-height: 48px; border-color: #e7a03a; border-radius: 6px; color: #17372e; background: #f0aa43; box-shadow: 0 7px 18px rgba(6, 27, 21, .18); }
+.overview-query-form .btn:hover { border-color: #f3b75b; color: #102e26; background: #f3b75b; }
+.overview-query-chips { gap: 7px; }
+.overview-query-chips button { min-height: 34px; padding: 6px 11px; border-color: rgba(255, 255, 255, .18); border-radius: 6px; color: rgba(255, 255, 255, .78); background: rgba(255, 255, 255, .06); font-size: 12px; }
+.overview-query-chips button:hover { border-color: rgba(255, 255, 255, .36); color: #fff; background: rgba(255, 255, 255, .12); }
+.overview-kpi-grid { gap: 12px; }
+.overview-kpi { min-height: 118px; padding: 18px; border-color: #dce4e0; border-radius: 8px; box-shadow: 0 6px 20px rgba(29, 54, 44, .045); }
+.overview-kpi:hover { border-color: #9fc3b3; box-shadow: 0 12px 28px rgba(28, 69, 52, .1); }
+.overview-kpi-icon { width: 44px; height: 44px; border-radius: 7px; }
+.overview-kpi small { color: #65766e; font-size: 13px; font-weight: 650; }
+.overview-kpi strong { font-size: 30px; }
+.overview-kpi em { color: #738179; font-size: 12px; }
+.overview-main-grid { grid-template-columns: minmax(0, 1.58fr) minmax(320px, .72fr); gap: 14px; }
+.overview-primary-column, .overview-secondary-column { gap: 14px; }
+.overview-panel { padding: 21px; border-color: #dce4e0; border-radius: 8px; box-shadow: 0 7px 24px rgba(24, 48, 38, .045); }
+.overview-panel-header { margin-bottom: 17px; }
+.overview-panel-header span { color: #477664; font-size: 10px; letter-spacing: 1px; }
+.overview-panel-header h3 { font-size: 18px; }
+.overview-panel-header > small { color: #74827b; font-size: 12px; }
+.overview-panel-header > strong { font-size: 24px; }
+.overview-diagnosis-empty { min-height: 200px; gap: 9px; }
+.overview-diagnosis-empty strong { font-size: 16px; }
+.overview-diagnosis-empty span { color: #697870; font-size: 13px; }
+.overview-diagnosis-result > header span { font-size: 11px; }
+.overview-diagnosis-result > header strong { font-size: 18px; }
+.overview-diagnosis-result > p { font-size: 14px; }
+.overview-evidence span, .overview-data-gaps > strong, .overview-data-gaps span { font-size: 12px; }
+.overview-data-gaps small { font-size: 11px; }
+.overview-diagnosis-go { min-height: 82px; padding: 13px 36px 10px 14px; }
+.overview-diagnosis-go > span { font-size: 14px; }
+.overview-diagnosis-go > small { font-size: 12px; }
+.overview-candidate-btn { min-height: 38px; font-size: 12px; }
+.overview-action { min-height: 72px; padding: 10px 13px; border-radius: 7px; }
+.overview-action > strong { font-size: 23px; }
+.overview-action b { font-size: 14px; }
+.overview-action small { color: #718078; font-size: 12px; }
+.overview-health-track { height: 8px; }
+.overview-health-panel dl { gap: 12px; }
+.overview-health-panel dl div, .overview-text-action { font-size: 13px; }
+.overview-text-action { min-height: 44px; }
+.overview-worker { grid-template-columns: 42px minmax(0, 1fr) auto; min-height: 68px; }
+.overview-worker-icon { width: 40px; height: 40px; }
+.overview-worker strong { font-size: 14px; }
+.overview-worker small { color: #6f7d76; font-size: 12px; }
+.overview-worker em { min-height: 26px; font-size: 11px; }
+.overview-data-note { min-height: 44px; color: #6e7c75; font-size: 12px; }
+.overview-data-note button { min-height: 44px; }
+
+@media (max-width: 1180px) {
+  .overview-command { grid-template-columns: 1fr; gap: 18px; }
+  .overview-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .overview-main-grid { grid-template-columns: minmax(0, 1fr) 300px; }
+}
+
+@media (max-width: 860px) {
+  .overview-main-grid { grid-template-columns: 1fr; }
+  .overview-secondary-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 640px) {
+  .overview-page { gap: 12px; }
+  .overview-command { min-height: 0; padding: 22px 16px; }
+  .overview-command-copy h2 { font-size: 28px; }
+  .overview-command-copy p { font-size: 15px; }
+  .overview-command-status { display: grid; grid-template-columns: 1fr; }
+  .overview-command-status span { width: 100%; font-size: 13px; }
+  .overview-query-form { padding: 0; }
+  .overview-query-form > div:first-of-type { grid-template-columns: 1fr; }
+  .overview-query-form input { font-size: 16px; }
+  .overview-query-form .btn { width: 100%; }
+  .overview-query-chips button { min-height: 44px; }
+  .overview-kpi-grid, .overview-secondary-column { grid-template-columns: 1fr; }
+  .overview-kpi { min-height: 104px; }
+  .overview-panel { padding: 16px; }
+  .overview-diagnosis-actions { grid-template-columns: 1fr; }
+  .overview-diagnosis-action { border-right: 0; border-bottom: 1px solid #edf0ee; }
+  .overview-diagnosis-action:last-child { border-bottom: 0; }
+  .overview-data-note { align-items: flex-start; flex-direction: column; padding-bottom: 10px; }
+  .overview-data-note button { margin-left: 0; }
+}
+
 @media (prefers-reduced-motion: reduce) {
 @media (prefers-reduced-motion: reduce) {
-  *, *::before, *::after {
-    animation-duration: 0.01ms !important;
-    transition-duration: 0.01ms !important;
-  }
+  .overview-query-chips button, .overview-kpi, .overview-action, .overview-diagnosis-actions button { transition: none; }
+}
+
+.group-ops-page { display: flex; flex-direction: column; gap: 20px; }
+.group-ops-tabs { display: flex; gap: 6px; padding: 5px; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--panel-bg); box-shadow: var(--shadow); }
+.group-ops-tabs button { min-height: 42px; padding: 9px 16px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font: inherit; font-weight: 600; white-space: nowrap; cursor: pointer; transition: color .2s, background-color .2s; }
+.group-ops-tabs button:hover, .group-ops-tabs button:focus-visible { color: var(--brand); background: var(--brand-bg); outline: none; }
+.group-ops-tabs button.active { color: #873800; background: #fff1d6; box-shadow: inset 0 0 0 1px #ffd591; }
+.group-ops-hero {
+  display: flex; align-items: center; justify-content: space-between; gap: 20px;
+  padding: 22px 24px; border: 1px solid #ffd8a8; border-radius: var(--radius-md);
+  background: linear-gradient(120deg, #fff7e6 0%, #fff 68%); box-shadow: var(--shadow);
+}
+.group-ops-hero h2 { margin: 4px 0 6px; color: var(--text-primary); font-size: 24px; }
+.group-ops-hero p { margin: 0; color: var(--text-secondary); line-height: 1.6; }
+.group-ops-kicker { color: #ad4e00; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
+.group-ops-stats { margin-bottom: 0; grid-template-columns: repeat(5, minmax(130px, 1fr)); }
+.group-ops-stats .stat-card { text-align: left; }
+.group-ops-stats .stat-card.danger .stat-value { color: var(--error); }
+.group-ops-grid { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); gap: 20px; }
+.group-ops-builder, .group-ops-risks, .group-ops-workbench { padding: 20px; }
+.group-ops-form { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 10px 14px; align-items: center; padding: 16px; background: var(--page-bg); border: 1px solid var(--border); border-radius: var(--radius-md); }
+.group-ops-form label { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
+.group-ops-form input, .group-ops-form textarea { width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--panel-bg); color: var(--text-primary); font: inherit; resize: vertical; }
+.group-ops-form input:focus, .group-ops-form textarea:focus { outline: 3px solid rgba(250, 140, 22, .16); border-color: var(--brand); }
+.group-ops-form .btn { grid-column: 2; justify-self: start; min-height: 40px; }
+.group-ops-plan-form { margin-top: 16px; }
+.group-ops-playbooks { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
+.group-ops-playbooks article { display: flex; align-items: center; gap: 10px; padding: 11px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
+.group-ops-playbooks article > div { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 3px; }
+.group-ops-playbooks article span { color: var(--text-muted); font-size: 12px; }
+.group-ops-list { display: flex; flex-direction: column; gap: 10px; max-height: 560px; overflow-y: auto; }
+.group-ops-risk { display: flex; justify-content: space-between; gap: 12px; padding: 13px; border: 1px solid var(--border); border-left: 4px solid var(--warning); border-radius: var(--radius-sm); }
+.group-ops-risk.high { border-left-color: var(--error); background: var(--error-bg); }
+.group-ops-risk > div:first-child { min-width: 0; }
+.group-ops-risk p { margin: 5px 0; color: var(--text-secondary); font-size: 13px; }
+.group-ops-risk small { display: block; max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); }
+.group-ops-risk > div:last-child { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
+.group-ops-copy { min-width: 260px; max-width: 520px; white-space: normal; line-height: 1.6; }
+.group-ops-workbench .table-wrap { max-width: 100%; }
+.group-ops-workbench table { min-width: 880px; }
+.group-ops-inline-form { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; padding: 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--page-bg); }
+.group-ops-inline-form label { color: var(--text-secondary); font-size: 13px; font-weight: 600; }
+.group-ops-inline-form input, .group-ops-inline-form select { min-height: 40px; padding: 8px 11px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--panel-bg); color: var(--text-primary); font: inherit; }
+.group-ops-inline-form input:focus, .group-ops-inline-form select:focus { border-color: var(--brand); outline: 3px solid rgba(250,140,22,.16); }
+.group-ops-quality-form input { min-width: 180px; flex: 1; }
+.group-ops-task-list { display: flex; flex-direction: column; gap: 8px; }
+.group-ops-task-list article { display: flex; align-items: center; gap: 10px; padding: 11px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
+.group-ops-task-list article > div { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 4px; }
+.group-ops-task-list article span { color: var(--text-muted); font-size: 12px; }
+.group-ops-automation { padding: 20px; border-color: #ffd591; }
+.group-ops-automation-state { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
+.group-ops-automation-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(280px, .85fr); gap: 18px; }
+.group-ops-time-window { display: flex; align-items: center; gap: 8px; }
+.group-ops-time-window input { min-width: 0; }
+.group-ops-automation-summary { padding: 18px; border: 1px solid #ffd591; border-radius: var(--radius-md); background: #fffaf0; }
+.group-ops-automation-summary h4 { margin: 0 0 14px; color: var(--text-primary); font-size: 16px; }
+.group-ops-automation-summary dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
+.group-ops-automation-summary dl div { padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--panel-bg); }
+.group-ops-automation-summary dt { color: var(--text-muted); font-size: 12px; }
+.group-ops-automation-summary dd { margin: 4px 0 0; color: var(--text-primary); font-weight: 700; }
+.group-ops-automation-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
+.group-ops-automation-log-header { margin-top: 22px; }
+
+@media (max-width: 1100px) {
+  .group-ops-stats { grid-template-columns: repeat(3, minmax(130px, 1fr)); }
+  .group-ops-grid { grid-template-columns: 1fr; }
+  .group-ops-automation-grid { grid-template-columns: 1fr; }
+}
+@media (max-width: 700px) {
+  .group-ops-hero { align-items: flex-start; flex-direction: column; padding: 18px; }
+  .group-ops-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .group-ops-form { grid-template-columns: 1fr; }
+  .group-ops-form .btn { grid-column: 1; width: 100%; }
+  .group-ops-builder, .group-ops-risks, .group-ops-workbench { padding: 14px; }
+  .group-ops-inline-form { align-items: stretch; flex-direction: column; }
+  .group-ops-inline-form input, .group-ops-inline-form select, .group-ops-inline-form .btn { width: 100%; }
+  .group-ops-task-list article { align-items: flex-start; flex-wrap: wrap; }
+  .group-ops-time-window { flex-wrap: wrap; }
+  .group-ops-automation-summary dl { grid-template-columns: 1fr; }
+  .group-ops-automation-actions .btn { width: 100%; }
+}
+
+/* 社群运营:对齐客户群、画像与交接工作台的视觉语言 */
+.group-ops-page {
+  display: grid;
+  gap: 16px;
+  min-width: 0;
+}
+
+.group-ops-page .group-ops-hero {
+  min-height: 158px;
+  margin: 0;
+  padding: 26px 30px;
+  border: 0;
+  border-radius: 20px;
+  color: #fff;
+  background: linear-gradient(135deg, #172d4f, #294d7e 62%, #507fa3);
+  box-shadow: 0 16px 36px rgba(21, 45, 79, .16);
+}
+
+.group-ops-page .group-ops-hero::before,
+.group-ops-page .group-ops-hero::after {
+  position: absolute;
+  z-index: -1;
+  content: '';
+  border: 1px solid rgba(255,255,255,.09);
+  border-radius: 50%;
+  pointer-events: none;
+}
+
+.group-ops-page .group-ops-hero::before { width: 270px; height: 270px; top: -195px; right: 250px; }
+.group-ops-page .group-ops-hero::after { width: 220px; height: 220px; right: -82px; bottom: -162px; }
+.group-ops-page .group-ops-hero h2 { margin: 7px 0 5px; color: #fff; font-size: 30px; }
+.group-ops-page .group-ops-hero p { max-width: 610px; margin: 0; color: rgba(255,255,255,.72); font-size: 13px; line-height: 1.65; }
+
+.group-ops-hero-side { display: flex; flex: 0 0 auto; align-items: center; gap: 12px; }
+.group-ops-hero-status { display: flex; gap: 8px; }
+.group-ops-hero-status > span {
+  min-width: 76px;
+  padding: 11px 13px;
+  border: 1px solid rgba(255,255,255,.16);
+  border-radius: 12px;
+  color: rgba(255,255,255,.62);
+  background: rgba(255,255,255,.08);
+  font-size: 9px;
+  text-align: left;
+  backdrop-filter: blur(10px);
+}
+.group-ops-hero-status strong { display: block; margin-bottom: 3px; color: #fff; font-size: 20px; line-height: 1; }
+.group-ops-hero-status .is-risk { border-color: rgba(255,183,178,.34); background: rgba(170,48,48,.2); }
+.group-ops-refresh { min-height: 44px; padding: 10px 15px; border: 1px solid rgba(255,255,255,.24); background: rgba(255,255,255,.12); box-shadow: none; backdrop-filter: blur(10px); }
+.group-ops-refresh:hover { background: rgba(255,255,255,.2); }
+.group-ops-refresh:focus-visible { outline: 3px solid rgba(255,255,255,.38); outline-offset: 2px; }
+
+.group-ops-page .group-ops-tabs {
+  gap: 3px;
+  margin: 0;
+  padding: 5px;
+  border-color: #e1e7ea;
+  border-radius: 13px;
+  background: #fff;
+  box-shadow: 0 6px 18px rgba(28,45,59,.035);
+  scrollbar-width: thin;
+}
+.group-ops-page .group-ops-tabs button { min-height: 44px; flex: 1 0 auto; padding: 9px 15px; color: #6f7c84; font-size: 12px; font-weight: 650; }
+.group-ops-page .group-ops-tabs button:hover { color: #294d7e; background: #f2f6fa; }
+.group-ops-page .group-ops-tabs button:focus-visible { color: #294d7e; outline: 2px solid #6f93b5; outline-offset: -2px; }
+.group-ops-page .group-ops-tabs button.active { color: #23496f; background: #eaf1f7; box-shadow: inset 0 0 0 1px #d2e0eb; }
+
+.group-ops-page .group-ops-stats { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; margin: 0; }
+.group-ops-page .group-ops-stats .kpi-card { min-height: 108px; }
+.group-ops-page .group-ops-stats .kpi-card.danger { border-color: #f0d4d0; background: linear-gradient(145deg, #fff, #fff8f7); }
+.group-ops-page .group-ops-stats .kpi-card.danger .kpi-value { color: #bd5149; }
+
+.group-ops-page > .card {
+  min-width: 0;
+  margin: 0;
+  padding: 17px;
+  overflow: hidden;
+  border-color: #e1e7ea;
+  border-radius: 14px;
+  box-shadow: 0 8px 24px rgba(28,45,59,.04);
+  transform: none;
+}
+.group-ops-page > .card:hover { box-shadow: 0 10px 28px rgba(28,45,59,.06); }
+.group-ops-page > .card > .card-header { margin-bottom: 13px; padding: 0; }
+.group-ops-page > .card > .card-header h3 { margin: 0; color: #2b3b43; font-size: 13px; }
+.group-ops-page > .card > .card-header p { margin: 4px 0 0; color: #8b969c; font-size: 10px; line-height: 1.55; }
+.group-ops-page > .card > .card-header > span { color: #7d898f; font-size: 10px; }
+.group-ops-page .table-wrap { max-width: 100%; border-color: #e4e9ec; border-radius: 10px; }
+.group-ops-page table { min-width: 820px; }
+.group-ops-page th { padding: 10px 12px; color: #78848b; background: #f7f9fa; font-size: 10px; letter-spacing: .02em; }
+.group-ops-page td { padding: 11px 12px; color: #4b5b63; }
+.group-ops-page tbody tr:hover { background: #fafcfd; }
+
+.group-ops-page .group-ops-form,
+.group-ops-page .group-ops-inline-form {
+  gap: 11px 14px;
+  margin-bottom: 14px;
+  padding: 15px;
+  border-color: #e2e7ea;
+  border-radius: 11px;
+  background: #f8fafb;
+}
+.group-ops-page .group-ops-form label,
+.group-ops-page .group-ops-inline-form label { color: #66747b; font-size: 11px; }
+.group-ops-page .group-ops-form input,
+.group-ops-page .group-ops-form textarea,
+.group-ops-page .group-ops-inline-form input,
+.group-ops-page .group-ops-inline-form select { min-height: 42px; border-color: #dce3e7; border-radius: 9px; background: #fff; font-size: 12px; }
+.group-ops-page .group-ops-form input:focus,
+.group-ops-page .group-ops-form textarea:focus,
+.group-ops-page .group-ops-inline-form input:focus,
+.group-ops-page .group-ops-inline-form select:focus { border-color: #6f93b5; outline: 3px solid rgba(55,100,140,.11); }
+.group-ops-page .group-ops-form .btn,
+.group-ops-page .group-ops-inline-form .btn { min-height: 42px; }
+
+.group-ops-playbooks article,
+.group-ops-task-list article,
+.group-ops-risk { border-color: #e2e7ea; border-radius: 10px; background: #fff; }
+.group-ops-playbooks article:hover,
+.group-ops-task-list article:hover { border-color: #cedbe3; background: #fafcfd; }
+.group-ops-task-list article { min-height: 54px; }
+.group-ops-risk { padding: 13px 14px; border-left-width: 3px; }
+.group-ops-risk.high { border-color: #f0d4d0; border-left-color: #ca5b52; background: #fff9f8; }
+.group-ops-risk > div:last-child { flex-flow: row wrap; justify-content: flex-end; }
+
+.group-ops-page .group-ops-automation { border-color: #e1e7ea; }
+.group-ops-automation-summary { border-color: #dbe5eb; border-radius: 11px; background: #f4f8fb; }
+.group-ops-automation-summary dl div { border-color: #e0e7eb; border-radius: 9px; }
+.group-ops-automation-log-header { padding-top: 20px !important; border-top: 1px solid #e7ecef; }
+
+@media (max-width: 1180px) {
+  .group-ops-page .group-ops-hero { align-items: flex-start; flex-direction: column; }
+  .group-ops-hero-side { width: 100%; justify-content: space-between; }
+  .group-ops-page .group-ops-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+@media (max-width: 700px) {
+  .group-ops-page { gap: 12px; }
+  .group-ops-page .group-ops-hero { min-height: 0; padding: 20px; border-radius: 16px; }
+  .group-ops-page .group-ops-hero h2 { font-size: 25px; }
+  .group-ops-hero-side { align-items: stretch; flex-direction: column; }
+  .group-ops-hero-status { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
+  .group-ops-hero-status > span { min-width: 0; padding: 10px; }
+  .group-ops-refresh { width: 100%; }
+  .group-ops-page .group-ops-tabs button { flex: 0 0 auto; }
+  .group-ops-page .group-ops-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .group-ops-page .group-ops-stats .kpi-card { min-height: 98px; padding: 13px; }
+  .group-ops-page > .card { padding: 14px; }
+  .group-ops-risk > div:last-child { width: 100%; align-items: stretch; }
 }
 }
 
 
 /* ==================== Customer Operations Workspaces ==================== */
 /* ==================== Customer Operations Workspaces ==================== */

+ 178 - 4
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/unified-task-service.js

@@ -9,6 +9,7 @@ const { qiweiGoalUpdateTask } = require('../tools/qiwei-goal-management-run');
 
 
 const ACTIVE_STATUSES = new Set(['open', 'in_progress', 'blocked']);
 const ACTIVE_STATUSES = new Set(['open', 'in_progress', 'blocked']);
 const LOCAL_STATUSES = new Set(['open', 'in_progress', 'blocked', 'done', 'dismissed']);
 const LOCAL_STATUSES = new Set(['open', 'in_progress', 'blocked', 'done', 'dismissed']);
+const DIAGNOSIS_OUTCOMES = new Set(['effective', 'partially_effective', 'ineffective', 'unknown']);
 
 
 function ensureDir(dirPath) {
 function ensureDir(dirPath) {
   fs.mkdirSync(dirPath, { recursive: true });
   fs.mkdirSync(dirPath, { recursive: true });
@@ -52,6 +53,7 @@ function sourceMeta(source) {
     local: { label: '本地内部任务', kind: 'formal', channel: 'local-task' },
     local: { label: '本地内部任务', kind: 'formal', channel: 'local-task' },
     'document-candidate': { label: '文档待办候选', kind: 'candidate', channel: 'ai-analysis' },
     'document-candidate': { label: '文档待办候选', kind: 'candidate', channel: 'ai-analysis' },
     'meeting-candidate': { label: '会议待办候选', kind: 'candidate', channel: 'ai-analysis' },
     'meeting-candidate': { label: '会议待办候选', kind: 'candidate', channel: 'ai-analysis' },
+    'diagnosis-candidate': { label: '经营诊断候选', kind: 'candidate', channel: 'business-diagnosis' },
   })[source] || { label: source || '其他来源', kind: 'formal', channel: 'local' };
   })[source] || { label: source || '其他来源', kind: 'formal', channel: 'local' };
 }
 }
 
 
@@ -78,8 +80,10 @@ function baseTask(input) {
     goalId: String(input.goalId || ''),
     goalId: String(input.goalId || ''),
     officialTodoId: String(input.officialTodoId || ''),
     officialTodoId: String(input.officialTodoId || ''),
     officialSyncStatus: String(input.officialSyncStatus || ''),
     officialSyncStatus: String(input.officialSyncStatus || ''),
-    confidence: input.confidence === undefined ? null : Number(input.confidence),
+    confidence: input.confidence === undefined || input.confidence === null ? null : Number(input.confidence),
     promotedTo: String(input.promotedTo || ''),
     promotedTo: String(input.promotedTo || ''),
+    sourceRef: input.sourceRef && typeof input.sourceRef === 'object' ? input.sourceRef : null,
+    diagnosisOutcome: input.diagnosisOutcome && typeof input.diagnosisOutcome === 'object' ? input.diagnosisOutcome : null,
     actions: input.actions || {},
     actions: input.actions || {},
   };
   };
   task.overdue = isOverdue(task);
   task.overdue = isOverdue(task);
@@ -95,6 +99,7 @@ function createUnifiedTaskService(dependencies = {}) {
     goals: path.resolve(dependencies.goalPath || path.join(root, 'goals', 'plans.json')),
     goals: path.resolve(dependencies.goalPath || path.join(root, 'goals', 'plans.json')),
     documents: path.resolve(dependencies.documentPath || path.join(root, 'knowledge', 'docs', 'index.json')),
     documents: path.resolve(dependencies.documentPath || path.join(root, 'knowledge', 'docs', 'index.json')),
     meetings: path.resolve(dependencies.meetingPath || path.join(root, 'knowledge', 'meetings', 'index.json')),
     meetings: path.resolve(dependencies.meetingPath || path.join(root, 'knowledge', 'meetings', 'index.json')),
+    diagnoses: path.resolve(dependencies.diagnosisPath || path.join(root, 'knowledge', 'diagnoses', 'action-candidates.json')),
   };
   };
   const conversationReader = dependencies.getConversations || getConversations;
   const conversationReader = dependencies.getConversations || getConversations;
   const customerUpdater = dependencies.updateCustomerTask || updateCustomerTask;
   const customerUpdater = dependencies.updateCustomerTask || updateCustomerTask;
@@ -124,6 +129,28 @@ function createUnifiedTaskService(dependencies = {}) {
     fs.writeFileSync(path.join(storeDir, 'README.md'), lines.join('\n'), 'utf8');
     fs.writeFileSync(path.join(storeDir, 'README.md'), lines.join('\n'), 'utf8');
   }
   }
 
 
+  function readDiagnosisCandidates() {
+    const store = readJson(paths.diagnoses, { version: 1, candidates: [], updatedAt: null });
+    store.version ||= 1;
+    store.candidates = Array.isArray(store.candidates) ? store.candidates : [];
+    return store;
+  }
+
+  function persistDiagnosisCandidates(store) {
+    store.updatedAt = new Date().toISOString();
+    writeJsonAtomic(paths.diagnoses, store);
+  }
+
+  function appendDiagnosisLifecycle(candidate, event, details = {}) {
+    candidate.lifecycle = Array.isArray(candidate.lifecycle) ? candidate.lifecycle : [];
+    candidate.lifecycle.push({ event, at: new Date().toISOString(), ...details });
+    candidate.lifecycle = candidate.lifecycle.slice(-50);
+  }
+
+  function findDiagnosisCandidate(store, sourceKey) {
+    return store.candidates.find(item => item.sourceKey === sourceKey);
+  }
+
   function collectCustomerTasks() {
   function collectCustomerTasks() {
     const result = conversationReader() || {};
     const result = conversationReader() || {};
     const conversations = result.data?.conversations || [];
     const conversations = result.data?.conversations || [];
@@ -195,7 +222,11 @@ function createUnifiedTaskService(dependencies = {}) {
       ...task,
       ...task,
       source: 'local',
       source: 'local',
       sourceId: task.id,
       sourceId: task.id,
-      actions: { complete: ACTIVE_STATUSES.has(task.status), dismiss: ACTIVE_STATUSES.has(task.status) },
+      actions: {
+        complete: ACTIVE_STATUSES.has(task.status),
+        dismiss: ACTIVE_STATUSES.has(task.status),
+        diagnosisFeedback: task.status === 'done' && task.sourceRef?.type === 'diagnosis-candidate',
+      },
     }));
     }));
   }
   }
 
 
@@ -246,6 +277,25 @@ function createUnifiedTaskService(dependencies = {}) {
         }));
         }));
       }
       }
     }
     }
+    const diagnosisStore = readDiagnosisCandidates();
+    for (const item of diagnosisStore.candidates) {
+      const key = String(item.sourceKey || `diagnosis:${item.id}`);
+      candidates.push(baseTask({
+        id: key,
+        source: 'diagnosis-candidate',
+        title: item.title,
+        priority: item.priority,
+        status: 'suggested',
+        evidence: item.evidence,
+        sourceTitle: item.sourceTitle || '经营诊断',
+        sourceId: key,
+        confidence: null,
+        promotedTo: item.promotedTaskId || promoted.get(key),
+        diagnosisOutcome: item.diagnosisOutcome,
+        updatedAt: item.updatedAt || item.createdAt,
+        actions: { promote: !(item.promotedTaskId || promoted.has(key)) },
+      }));
+    }
     return candidates;
     return candidates;
   }
   }
 
 
@@ -267,6 +317,14 @@ function createUnifiedTaskService(dependencies = {}) {
     });
     });
     const bySource = {};
     const bySource = {};
     for (const task of tasks) bySource[task.source] = (bySource[task.source] || 0) + 1;
     for (const task of tasks) bySource[task.source] = (bySource[task.source] || 0) + 1;
+    const diagnosisCandidates = readDiagnosisCandidates().candidates;
+    const diagnosisTaskBySource = new Map(localStore.tasks.filter(task => task.sourceRef?.type === 'diagnosis-candidate').map(task => [task.sourceRef.key, task]));
+    const promotedDiagnosisCandidates = diagnosisCandidates.filter(item => item.promotedTaskId || diagnosisTaskBySource.has(item.sourceKey));
+    const completedDiagnosisCandidates = diagnosisCandidates.filter(item => item.taskStatus === 'done' || diagnosisTaskBySource.get(item.sourceKey)?.status === 'done');
+    const effectiveDiagnosisCandidates = diagnosisCandidates.filter(item => item.diagnosisOutcome?.value === 'effective');
+    const partiallyEffectiveDiagnosisCandidates = diagnosisCandidates.filter(item => item.diagnosisOutcome?.value === 'partially_effective');
+    const ineffectiveDiagnosisCandidates = diagnosisCandidates.filter(item => item.diagnosisOutcome?.value === 'ineffective');
+    const verifiedDiagnosisCandidates = effectiveDiagnosisCandidates.length + partiallyEffectiveDiagnosisCandidates.length + ineffectiveDiagnosisCandidates.length;
     return okResult({
     return okResult({
       assistantMessage: `统一任务中心已汇总 ${formalTasks.length} 条正式任务和 ${candidates.length} 条待人工确认候选。`,
       assistantMessage: `统一任务中心已汇总 ${formalTasks.length} 条正式任务和 ${candidates.length} 条待人工确认候选。`,
       summary: {
       summary: {
@@ -278,6 +336,16 @@ function createUnifiedTaskService(dependencies = {}) {
         promotedCandidates: candidates.filter(task => task.promotedTo).length,
         promotedCandidates: candidates.filter(task => task.promotedTo).length,
         deduplicatedOfficialCount: linkedOfficialIds.size,
         deduplicatedOfficialCount: linkedOfficialIds.size,
         bySource,
         bySource,
+        diagnosisFeedback: {
+          candidates: diagnosisCandidates.length,
+          promoted: promotedDiagnosisCandidates.length,
+          completed: completedDiagnosisCandidates.length,
+          effective: effectiveDiagnosisCandidates.length,
+          partiallyEffective: partiallyEffectiveDiagnosisCandidates.length,
+          ineffective: ineffectiveDiagnosisCandidates.length,
+          adoptionRate: diagnosisCandidates.length ? promotedDiagnosisCandidates.length / diagnosisCandidates.length : null,
+          effectivenessRate: verifiedDiagnosisCandidates ? effectiveDiagnosisCandidates.length / verifiedDiagnosisCandidates : null,
+        },
       },
       },
       data: {
       data: {
         tasks,
         tasks,
@@ -285,7 +353,7 @@ function createUnifiedTaskService(dependencies = {}) {
         policy: {
         policy: {
           internalTasksOnly: true,
           internalTasksOnly: true,
           externalCustomerSafety: '客户会话任务只能转到智能会话,由 Agent 结合上下文拟定回复;系统不会把内部任务标题直接发送给客户。',
           externalCustomerSafety: '客户会话任务只能转到智能会话,由 Agent 结合上下文拟定回复;系统不会把内部任务标题直接发送给客户。',
-          candidateSafety: '文档和会议中的 AI 行动项默认只是候选,必须人工确认后才会转为正式内部任务。',
+          candidateSafety: '文档、会议和经营诊断中的建议默认只是候选,必须人工确认后才会转为正式内部任务。',
         },
         },
       },
       },
       files: [localIndexPath].filter(fs.existsSync),
       files: [localIndexPath].filter(fs.existsSync),
@@ -318,9 +386,67 @@ function createUnifiedTaskService(dependencies = {}) {
     };
     };
     store.tasks.push(task);
     store.tasks.push(task);
     persistLocalStore(store);
     persistLocalStore(store);
+    if (sourceKey && task.sourceRef?.type === 'diagnosis-candidate') {
+      const diagnosisStore = readDiagnosisCandidates();
+      const candidate = findDiagnosisCandidate(diagnosisStore, sourceKey);
+      if (candidate) {
+        candidate.promotedTaskId = task.id;
+        candidate.promotedAt = now;
+        candidate.taskStatus = task.status;
+        candidate.updatedAt = now;
+        appendDiagnosisLifecycle(candidate, 'promoted', { taskId: task.id });
+        persistDiagnosisCandidates(diagnosisStore);
+      }
+    }
     return okResult({ assistantMessage: `已创建内部任务“${title}”。`, summary: { created: true }, data: { task }, files: [localIndexPath, path.join(storeDir, 'README.md')] });
     return okResult({ assistantMessage: `已创建内部任务“${title}”。`, summary: { created: true }, data: { task }, files: [localIndexPath, path.join(storeDir, 'README.md')] });
   }
   }
 
 
+  function createDiagnosisCandidate(input = {}) {
+    const question = String(input.question || '').trim().slice(0, 500);
+    const actionInput = input.action && typeof input.action === 'object' ? input.action : input;
+    const title = String(actionInput.label || actionInput.title || '').trim().slice(0, 200);
+    if (!question) return errorResult('缺少经营诊断问题。');
+    if (!title) return errorResult('缺少建议动作标题。');
+    const actionId = String(actionInput.id || actionInput.actionId || title).trim();
+    const scopeDate = String(input.scope?.date || input.date || '').trim();
+    const sourceKey = `diagnosis:${stableId(question, actionId, scopeDate)}`;
+    const store = readDiagnosisCandidates();
+    const existing = store.candidates.find(item => item.sourceKey === sourceKey);
+    if (existing) return okResult({ assistantMessage: '该诊断建议已在今日行动候选中,无需重复加入。', summary: { created: false, duplicate: true }, data: { candidate: existing }, files: [paths.diagnoses] });
+    const evidenceItems = Array.isArray(input.evidence) ? input.evidence : [];
+    const gapItems = Array.isArray(input.dataGaps) ? input.dataGaps : [];
+    const evidenceText = evidenceItems.slice(0, 3).map(item => typeof item === 'string' ? item : `${item.label || '指标'} ${item.value ?? ''}`.trim()).filter(Boolean).join(';');
+    const gapText = gapItems.slice(0, 2).map(item => item.label || item.code).filter(Boolean).join('、');
+    const now = new Date().toISOString();
+    const candidate = {
+      id: `diagnosis_${crypto.randomUUID()}`,
+      sourceKey,
+      diagnosisType: String(input.diagnosisType || input.type || 'general'),
+      question,
+      actionId,
+      title,
+      description: String(actionInput.description || '').trim().slice(0, 500),
+      route: String(actionInput.route || actionInput.hash || '').trim().slice(0, 100),
+      priority: ['low', 'medium', 'high', 'urgent'].includes(actionInput.priority) ? actionInput.priority : 'medium',
+      evidence: [String(actionInput.description || '').trim(), evidenceText, gapText ? `数据缺口:${gapText}` : ''].filter(Boolean).join(';').slice(0, 1200),
+      sourceTitle: `经营诊断 · ${question}`.slice(0, 300),
+      taskStatus: 'suggested',
+      lifecycle: [{ event: 'created', at: now }],
+      createdAt: now,
+      updatedAt: now,
+    };
+    store.candidates.push(candidate);
+    store.candidates = store.candidates.slice(-500);
+    persistDiagnosisCandidates(store);
+    return okResult({
+      assistantMessage: `已将“${title}”加入今日行动候选,等待人工确认后转为正式任务。`,
+      summary: { created: true, requiresPromotion: true, readOnlyDiagnosisPreserved: true },
+      data: { candidate },
+      files: [paths.diagnoses],
+      nextActions: [{ id: 'review-candidate', label: '前往统一任务中心确认', route: '#knowledge', requiresConfirmation: false }]
+    });
+  }
+
   async function update(input = {}) {
   async function update(input = {}) {
     const source = String(input.source || '');
     const source = String(input.source || '');
     const taskId = String(input.taskId || input.id || '');
     const taskId = String(input.taskId || input.id || '');
@@ -334,6 +460,19 @@ function createUnifiedTaskService(dependencies = {}) {
       task.status = status;
       task.status = status;
       task.updatedAt = new Date().toISOString();
       task.updatedAt = new Date().toISOString();
       persistLocalStore(store);
       persistLocalStore(store);
+      if (task.sourceRef?.type === 'diagnosis-candidate') {
+        const diagnosisStore = readDiagnosisCandidates();
+        const candidate = findDiagnosisCandidate(diagnosisStore, task.sourceRef.key);
+        if (candidate) {
+          candidate.promotedTaskId ||= task.id;
+          candidate.taskStatus = status;
+          candidate.updatedAt = task.updatedAt;
+          if (status === 'done') candidate.completedAt = task.updatedAt;
+          if (status === 'dismissed') candidate.dismissedAt = task.updatedAt;
+          appendDiagnosisLifecycle(candidate, status === 'done' ? 'completed' : status === 'dismissed' ? 'dismissed' : 'status_updated', { taskId: task.id, status });
+          persistDiagnosisCandidates(diagnosisStore);
+        }
+      }
       return okResult({ assistantMessage: `本地任务已更新为 ${status}。`, data: { task } });
       return okResult({ assistantMessage: `本地任务已更新为 ${status}。`, data: { task } });
     }
     }
     if (source === 'customer') return customerUpdater(taskId, { status });
     if (source === 'customer') return customerUpdater(taskId, { status });
@@ -349,7 +488,40 @@ function createUnifiedTaskService(dependencies = {}) {
     return errorResult('该来源的任务不能直接更新;AI 候选需先人工确认。');
     return errorResult('该来源的任务不能直接更新;AI 候选需先人工确认。');
   }
   }
 
 
-  return { hub: buildHub, createLocalTask, update, readLocalStore };
+  function updateDiagnosisOutcome(input = {}) {
+    const taskId = String(input.taskId || input.id || '').trim();
+    const outcome = String(input.outcome || input.value || '').trim();
+    const note = String(input.note || input.evidence || '').trim().slice(0, 1000);
+    const actor = String(input.actor || 'dashboard-user').trim().slice(0, 100);
+    if (!taskId) return errorResult('记录诊断行动反馈需要 taskId。');
+    if (!DIAGNOSIS_OUTCOMES.has(outcome)) return errorResult('反馈结果必须是 effective、partially_effective、ineffective 或 unknown。');
+    const localStore = readLocalStore();
+    const task = localStore.tasks.find(item => item.id === taskId);
+    if (!task) return errorResult('本地任务不存在。');
+    if (task.sourceRef?.type !== 'diagnosis-candidate') return errorResult('只有经营诊断来源的正式任务可以记录效果反馈。');
+    if (task.status !== 'done') return errorResult('请先将任务标记为已完成,再记录效果反馈。');
+    const diagnosisStore = readDiagnosisCandidates();
+    const candidate = findDiagnosisCandidate(diagnosisStore, task.sourceRef.key);
+    if (!candidate) return errorResult('未找到该任务对应的经营诊断候选。');
+    const recordedAt = new Date().toISOString();
+    const diagnosisOutcome = { value: outcome, note, actor, recordedAt };
+    task.diagnosisOutcome = diagnosisOutcome;
+    task.updatedAt = recordedAt;
+    candidate.diagnosisOutcome = diagnosisOutcome;
+    candidate.feedbackAt = recordedAt;
+    candidate.updatedAt = recordedAt;
+    appendDiagnosisLifecycle(candidate, 'outcome_recorded', { taskId, outcome, actor });
+    persistLocalStore(localStore);
+    persistDiagnosisCandidates(diagnosisStore);
+    return okResult({
+      assistantMessage: '经营诊断行动效果已记录,仅用于内部复盘。',
+      summary: { recorded: true, outcome, internalOnly: true, customerMessageSent: false },
+      data: { task, candidate },
+      files: [localIndexPath, paths.diagnoses],
+    });
+  }
+
+  return { hub: buildHub, createLocalTask, createDiagnosisCandidate, updateDiagnosisOutcome, update, readLocalStore, readDiagnosisCandidates };
 }
 }
 
 
 const service = createUnifiedTaskService();
 const service = createUnifiedTaskService();
@@ -358,6 +530,8 @@ module.exports = {
   createUnifiedTaskService,
   createUnifiedTaskService,
   getUnifiedTaskHub: service.hub,
   getUnifiedTaskHub: service.hub,
   createUnifiedLocalTask: service.createLocalTask,
   createUnifiedLocalTask: service.createLocalTask,
+  createUnifiedDiagnosisCandidate: service.createDiagnosisCandidate,
+  updateUnifiedDiagnosisOutcome: service.updateDiagnosisOutcome,
   updateUnifiedTask: service.update,
   updateUnifiedTask: service.update,
   __testing: { baseTask, stableId, isOverdue, sourceMeta },
   __testing: { baseTask, stableId, isOverdue, sourceMeta },
 };
 };

+ 137 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/server.js

@@ -38,6 +38,22 @@ const {
   qiweiRejectExternalGroup,
   qiweiRejectExternalGroup,
   qiweiSyncGroupMessages
   qiweiSyncGroupMessages
 } = require('./tools/qiwei-group-management-run');
 } = require('./tools/qiwei-group-management-run');
+const {
+  qiweiGroupOpsGetContext,
+  qiweiGroupOpsGeneratePlan,
+  qiweiGroupOpsBatchGeneratePlans,
+  qiweiGroupOpsGenerateCopy,
+  qiweiGroupOpsReviewMessages,
+  qiweiGroupOpsDailyBrief,
+  qiweiGroupOpsManagePlaybook,
+  qiweiGroupOpsExecuteItem,
+  qiweiGroupOpsUpdateFinding,
+  qiweiGroupOpsInsights,
+  qiweiGroupOpsManageTask,
+  qiweiGroupOpsAutomation,
+  qiweiGroupOpsManageAccess
+} = require('./tools/qiwei-group-operations-run');
+const { qiweiBusinessDiagnosis, qiweiBusinessActionCandidate, qiweiBusinessActionFeedback } = require('./tools/qiwei-business-diagnosis-run');
 const {
 const {
   qiweiPrepareCustomerPortrait,
   qiweiPrepareCustomerPortrait,
   qiweiUpdateCustomerPortrait,
   qiweiUpdateCustomerPortrait,
@@ -626,6 +642,127 @@ function createServer() {
     wrap(qiweiSyncGroupMessages)
     wrap(qiweiSyncGroupMessages)
   );
   );
 
 
+  server.registerTool('qiwei_group_ops_get_context', {
+    title: '读取社群运营上下文',
+    description: '读取当前账号的群运营总览,或指定群的主档、已发布 SOP、计划和质检风险。用于制定计划、写话术和生成管理简报前获取事实。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), roomId: z.string().optional(), date: z.string().optional(), group: z.record(z.any()).optional() }
+  }, wrap(qiweiGroupOpsGetContext));
+
+  server.registerTool('qiwei_group_ops_generate_plan', {
+    title: '生成客户群运营计划',
+    description: '依据已发布 SOP 或显式计划项,为指定客户群生成每日运营计划草稿。结果只进入待审核状态,不自动发送。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), roomId: z.string(), roomName: z.string().optional(), planDate: z.string().optional(), playbookId: z.string().optional(), items: z.array(z.record(z.any())).optional(), actor: z.string().optional() }
+  }, wrap(qiweiGroupOpsGeneratePlan));
+
+  server.registerTool('qiwei_group_ops_batch_generate_plans', {
+    title: '批量生成客户群运营计划',
+    description: '按门店或显式群列表批量生成指定日期的运营计划。每个群独立幂等,失败项不会影响成功项,全部结果进入待审核。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), planDate: z.string().optional(), storeId: z.string().optional(), playbookId: z.string().optional(), groups: z.array(z.record(z.any())).optional(), actor: z.string().optional() }
+  }, wrap(qiweiGroupOpsBatchGeneratePlans));
+
+  server.registerTool('qiwei_group_ops_generate_copy', {
+    title: '生成并检查群运营话术',
+    description: '替换话术变量并执行缺失变量、敏感承诺等基础规则检查。仅返回可审核草稿。',
+    inputSchema: { content: z.string().optional(), template: z.string().optional(), variables: z.record(z.any()).optional(), riskTerms: z.array(z.string()).optional() }
+  }, wrap(qiweiGroupOpsGenerateCopy));
+
+  server.registerTool('qiwei_group_ops_review_messages', {
+    title: '质检客户群消息',
+    description: '对指定客户群消息执行投诉风险和超时未回应检查,每个结论保留原消息证据。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), roomId: z.string(), messages: z.array(z.record(z.any())).optional(), startAt: z.string().optional(), endAt: z.string().optional(), slaMinutes: z.number().int().positive().optional(), riskTerms: z.array(z.string()).optional(), actor: z.string().optional() }
+  }, wrap(qiweiGroupOpsReviewMessages));
+
+  server.registerTool('qiwei_group_ops_daily_brief', {
+    title: '生成社群运营每日简报',
+    description: '汇总今日应执行、已完成、待审核和高风险事项,并返回可下钻的计划与质检数据。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), date: z.string().optional() }
+  }, wrap(qiweiGroupOpsDailyBrief));
+
+  server.registerTool('qiwei_group_ops_manage_playbook', {
+    title: '管理群运营 SOP',
+    description: '列出、创建、创建新版本、发布或回滚群运营 SOP。发布与回滚属于受控写操作,调用前必须向用户确认。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), action: z.enum(['list','create','new_version','versions','preview','publish','rollback']), playbookId: z.string().optional(), name: z.string().optional(), groupType: z.string().optional(), content: z.record(z.any()).optional(), scope: z.record(z.any()).optional(), version: z.number().int().positive().optional(), changeNote: z.string().optional(), confirmed: z.boolean().optional(), actor: z.string().optional() }
+  }, wrap(qiweiGroupOpsManagePlaybook));
+
+  server.registerTool('qiwei_group_ops_execute_item', {
+    title: '审核或记录群运营计划项',
+    description: '批准、拒绝、跳过或记录计划项已由人工发送。真实自动发送在接口回执验证前保持关闭。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), itemId: z.string(), action: z.enum(['approve','reject','skip','mark_sent','send']), content: z.string().optional(), reason: z.string().optional(), externalMessageId: z.string().optional(), actor: z.string().optional() }
+  }, wrap(qiweiGroupOpsExecuteItem));
+
+  server.registerTool('qiwei_group_ops_update_finding', {
+    title: '处理群质检事项',
+    description: '认领、确认、关闭或标记群质检误报,并保留处理审计。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), findingId: z.string(), status: z.enum(['open','acknowledged','resolved','false_positive']).optional(), assignee: z.string().optional(), resolution: z.string().optional(), actor: z.string().optional() }
+  }, wrap(qiweiGroupOpsUpdateFinding));
+
+  server.registerTool('qiwei_group_ops_insights', {
+    title: '分析社群运营与配置质检规则',
+    description: '查看跨门店执行对比、群活跃趋势、生命周期建议、优秀话术和质检反馈;也可更新当前账号的 SLA、风险词和禁止承诺。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), action: z.enum(['get','update_settings']).optional(), roomId: z.string().optional(), date: z.string().optional(), slaMinutes: z.number().int().positive().optional(), riskTerms: z.array(z.string()).optional(), forbiddenClaims: z.array(z.string()).optional(), actor: z.string().optional() }
+  }, wrap(qiweiGroupOpsInsights));
+
+  server.registerTool('qiwei_group_ops_manage_task', {
+    title: '管理社群整改任务',
+    description: '创建、查询、推进社群整改任务,并在负责人精确匹配且人工确认后同步为真实企微官方待办。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), action: z.enum(['list','create','update','sync_official']), taskId: z.string().optional(), findingId: z.string().optional(), roomId: z.string().optional(), title: z.string().optional(), owner: z.string().optional(), dueAt: z.string().optional(), priority: z.enum(['low','medium','high','urgent']).optional(), status: z.enum(['open','in_progress','done','cancelled']).optional(), remindType: z.number().int().optional(), confirmed: z.boolean().optional(), actor: z.string().optional() }
+  }, wrap(qiweiGroupOpsManageTask));
+
+  server.registerTool('qiwei_group_ops_automation', {
+    title: '管理社群受控自动化',
+    description: '创建和激活自动化策略,配置白名单、允许类型、时间窗、频控、灰度与熔断,执行 dry-run 或在全部安全条件满足时受控发送。默认暂停且真实发送环境开关关闭。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), action: z.enum(['status','create_policy','activate','pause','resume','reset_breaker','preview','run']), policyId: z.string().optional(), policy: z.record(z.any()).optional(), changeNote: z.string().optional(), itemIds: z.array(z.string()).optional(), limit: z.number().int().positive().max(500).optional(), at: z.string().optional(), execute: z.boolean().optional(), confirmed: z.boolean().optional(), actor: z.string().optional() }
+  }, wrap(qiweiGroupOpsAutomation));
+
+  server.registerTool('qiwei_group_ops_manage_access', {
+    title: '管理社群运营角色权限',
+    description: '配置或查看当前企微账号下的总部管理员、区域/门店经理、执行员工和审计者权限。首次初始化必须显式确认。',
+    inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), requester: z.string().optional(), principal: z.string().optional(), actor: z.string().optional(), action: z.enum(['list','upsert']).optional(), role: z.enum(['headquarters_admin','regional_manager','store_manager','executor','auditor']).optional(), storeScope: z.array(z.string()).optional(), status: z.enum(['active','disabled']).optional(), bootstrapConfirmed: z.boolean().optional() }
+  }, wrap(qiweiGroupOpsManageAccess));
+
+  server.registerTool('qiwei_business_diagnosis', {
+    title: '诊断企微经营问题',
+    description: '只读汇总客户主档、真实会话和社群运营数据,针对经营问题返回结论、证据、数据缺口和可下钻动作。缺少订单或 GMV 数据时明确拒绝生成虚假交易结论。',
+    inputSchema: {
+      question: z.string().min(2),
+      accountKey: z.string().optional(),
+      guid: z.string().optional(),
+      uid: z.string().optional(),
+      date: z.string().optional()
+    }
+  }, wrap(qiweiBusinessDiagnosis));
+
+  server.registerTool('qiwei_business_action_candidate', {
+    title: '将经营建议加入行动候选',
+    description: '把经营诊断中的一条建议加入统一任务中心候选池。只创建待人工确认候选,不会生成正式任务、同步企微待办或发送消息。',
+    inputSchema: {
+      question: z.string().min(2),
+      diagnosisType: z.string().optional(),
+      scope: z.record(z.any()).optional(),
+      action: z.object({
+        id: z.string().optional(),
+        label: z.string().min(1),
+        description: z.string().optional(),
+        route: z.string().optional(),
+        hash: z.string().optional(),
+        priority: z.enum(['low', 'medium', 'high', 'urgent']).optional()
+      }),
+      evidence: z.array(z.union([z.string(), z.record(z.any())])).optional(),
+      dataGaps: z.array(z.record(z.any())).optional()
+    }
+  }, wrap(qiweiBusinessActionCandidate));
+
+  server.registerTool('qiwei_business_action_feedback', {
+    title: '记录经营行动效果反馈',
+    description: '为已完成的经营诊断来源内部任务记录有效、部分有效、无效或暂无法判断。反馈只用于内部复盘,不会发送客户消息或同步企微待办。',
+    inputSchema: {
+      taskId: z.string().min(1),
+      outcome: z.enum(['effective', 'partially_effective', 'ineffective', 'unknown']),
+      note: z.string().max(1000).optional(),
+      actor: z.string().max(100).optional()
+    }
+  }, wrap(qiweiBusinessActionFeedback));
+
   server.registerTool(
   server.registerTool(
     'qiwei_prepare_customer_portrait',
     'qiwei_prepare_customer_portrait',
     {
     {

+ 31 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-business-diagnosis-run.js

@@ -0,0 +1,31 @@
+const { okResult } = require('../core/result-envelope');
+const { diagnoseBusiness } = require('../dashboard/business-diagnosis-service');
+const { createUnifiedDiagnosisCandidate, updateUnifiedDiagnosisOutcome } = require('../dashboard/unified-task-service');
+
+async function qiweiBusinessDiagnosis(input = {}) {
+  const { diagnosis } = await diagnoseBusiness(input);
+  return okResult({
+    assistantMessage: diagnosis.title,
+    summary: {
+      type: diagnosis.type,
+      tone: diagnosis.tone,
+      evidenceCount: diagnosis.evidenceDetails.length,
+      dataGapCount: diagnosis.dataGaps.length,
+      actionCount: diagnosis.actions.length,
+      readOnly: true
+    },
+    data: { diagnosis },
+    nextActions: diagnosis.actions,
+    warnings: diagnosis.dataGaps.map(item => ({ code: item.code, message: item.label }))
+  });
+}
+
+async function qiweiBusinessActionCandidate(input = {}) {
+  return createUnifiedDiagnosisCandidate(input);
+}
+
+async function qiweiBusinessActionFeedback(input = {}) {
+  return updateUnifiedDiagnosisOutcome(input);
+}
+
+module.exports = { qiweiBusinessDiagnosis, qiweiBusinessActionCandidate, qiweiBusinessActionFeedback };

+ 14 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js

@@ -445,13 +445,24 @@ const qiweiGetCustomerProfile = safeResult(async function qiweiGetCustomerProfil
     getWxContactList: '/contact/getWxContactList',
     getWxContactList: '/contact/getWxContactList',
     getRoomList: '/room/getRoomList'
     getRoomList: '/room/getRoomList'
   });
   });
-  const ctx = buildContext(input);
-  requireGuid(ctx);
-
   const phone = cleanPhone(input.phone);
   const phone = cleanPhone(input.phone);
   const externalUserId = String(input.externalUserId || '').trim();
   const externalUserId = String(input.externalUserId || '').trim();
   if (!phone && !externalUserId) return errorResult('缺少 phone 或 externalUserId');
   if (!phone && !externalUserId) return errorResult('缺少 phone 或 externalUserId');
 
 
+  if (input.localOnly === true) {
+    const portrait = readSavedPortrait(externalUserId);
+    const customers = (getStateSection('customers') || {}).customers || {};
+    const customer = customers[externalUserId] || Object.values(customers).find(item => String(item.externalUserId || '') === externalUserId) || null;
+    return okResult({
+      assistantMessage: `本地客户画像读取完成:${externalUserId}`,
+      summary: { phone: phone || customer?.phone || null, externalUserId, hasPortrait: Boolean(portrait), contactListCount: 0, roomCount: 0 },
+      data: { contact: null, contactList: [], rooms: [], portrait, customer }
+    });
+  }
+
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
   let contactInfo = null;
   let contactInfo = null;
   let resolvedExternalUserId = externalUserId;
   let resolvedExternalUserId = externalUserId;
 
 

+ 147 - 44
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js

@@ -3,6 +3,7 @@ const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
 const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
 const { recordGroupSync, recordCustomersFromGroups } = require('../core/dashboard-state');
 const { recordGroupSync, recordCustomersFromGroups } = require('../core/dashboard-state');
+const { getGroupOperationsStore } = require('../core/group-operations-store');
 const {
 const {
   buildContext,
   buildContext,
   gatewayCall,
   gatewayCall,
@@ -72,6 +73,20 @@ function ensureMessagesDir() {
   fs.mkdirSync(messagesDir(), { recursive: true });
   fs.mkdirSync(messagesDir(), { recursive: true });
 }
 }
 
 
+function messageSyncCursorPath(accountKey) {
+  const safeAccount = String(accountKey || 'local-default').trim().replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100) || 'local-default';
+  ensureGroupsDir();
+  return path.join(groupsDir(), `message-sync-cursor-${safeAccount}.json`);
+}
+
+function readMessageSyncCursor(accountKey) {
+  return safeReadJson(messageSyncCursorPath(accountKey), { travelSyncKey: 0, updatedAt: null });
+}
+
+function writeMessageSyncCursor(accountKey, cursor) {
+  fs.writeFileSync(messageSyncCursorPath(accountKey), JSON.stringify({ ...cursor, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
+}
+
 function ensureRoomMessagesDir(roomId) {
 function ensureRoomMessagesDir(roomId) {
   return fs.mkdirSync(roomMessagesDir(roomId), { recursive: true });
   return fs.mkdirSync(roomMessagesDir(roomId), { recursive: true });
 }
 }
@@ -550,7 +565,58 @@ function listGroupFiles() {
 }
 }
 
 
 function readLatestRooms() {
 function readLatestRooms() {
-  return safeReadJson(latestPath('groups', 'rooms-latest.json'), []);
+  const latestFile = latestPath('groups', 'rooms-latest.json');
+  if (fs.existsSync(latestFile)) {
+    const latest = safeReadJson(latestFile, null);
+    if (Array.isArray(latest)) return latest;
+  }
+
+  // latest 快照可能被清理,但按日期归档的扫描结果仍在;自动恢复最近一次有效结果。
+  const archived = [];
+  if (fs.existsSync(groupsDir())) {
+    for (const dateEntry of fs.readdirSync(groupsDir(), { withFileTypes: true })) {
+      if (!dateEntry.isDirectory() || !/^\d{4}-\d{2}-\d{2}$/.test(dateEntry.name)) continue;
+      const dateDir = path.join(groupsDir(), dateEntry.name);
+      for (const runEntry of fs.readdirSync(dateDir, { withFileTypes: true })) {
+        if (!runEntry.isDirectory() || !runEntry.name.endsWith('-sync-external-groups')) continue;
+        const runDir = path.join(dateDir, runEntry.name);
+        for (const fileName of fs.readdirSync(runDir)) {
+          if (!/^rooms-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.json$/.test(fileName)) continue;
+          const filePath = path.join(runDir, fileName);
+          archived.push({ filePath, mtimeMs: fs.statSync(filePath).mtimeMs });
+        }
+      }
+    }
+  }
+
+  archived.sort((a, b) => b.mtimeMs - a.mtimeMs);
+  for (const item of archived) {
+    const rooms = safeReadJson(item.filePath, null);
+    if (!Array.isArray(rooms)) continue;
+    fs.writeFileSync(latestFile, JSON.stringify(rooms, null, 2), 'utf8');
+    return rooms;
+  }
+  return [];
+}
+
+function mergeRoomSnapshots(existingRooms = [], incomingRooms = []) {
+  const roomMap = new Map();
+  for (const room of existingRooms) {
+    if (room && room.roomId) roomMap.set(String(room.roomId), room);
+  }
+  for (const room of incomingRooms) {
+    if (!room || !room.roomId) continue;
+    const roomId = String(room.roomId);
+    const existing = roomMap.get(roomId) || {};
+    roomMap.set(roomId, {
+      ...existing,
+      ...room,
+      roomId,
+      roomName: room.roomName || existing.roomName || roomId,
+      sources: [...new Set([...(existing.sources || [existing.source]).filter(Boolean), ...(room.sources || [room.source]).filter(Boolean)])]
+    });
+  }
+  return Array.from(roomMap.values());
 }
 }
 
 
 function confirmedMappingPath() {
 function confirmedMappingPath() {
@@ -735,7 +801,8 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
   const manifestPath = writeRunManifest(runDir, manifest);
   const manifestPath = writeRunManifest(runDir, manifest);
 
 
   const latestFile = latestPath('groups', 'rooms-latest.json');
   const latestFile = latestPath('groups', 'rooms-latest.json');
-  fs.writeFileSync(latestFile, JSON.stringify(classifiedRooms, null, 2), 'utf8');
+  const mergedSnapshot = mergeRoomSnapshots(readLatestRooms(), classifiedRooms);
+  fs.writeFileSync(latestFile, JSON.stringify(mergedSnapshot, null, 2), 'utf8');
 
 
   const scanManifestPath = path.join(groupsDir(), 'group-scan-manifest.json');
   const scanManifestPath = path.join(groupsDir(), 'group-scan-manifest.json');
   fs.writeFileSync(scanManifestPath, JSON.stringify({
   fs.writeFileSync(scanManifestPath, JSON.stringify({
@@ -777,11 +844,22 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
 });
 });
 
 
 const qiweiListExternalGroups = safeResult(async function qiweiListExternalGroups(input = {}) {
 const qiweiListExternalGroups = safeResult(async function qiweiListExternalGroups(input = {}) {
-  const rooms = readLatestRooms();
   const confirmed = readConfirmedMapping();
   const confirmed = readConfirmedMapping();
   const rejected = readRejectedMapping();
   const rejected = readRejectedMapping();
   const classifier = buildClassifierConfig(input);
   const classifier = buildClassifierConfig(input);
 
 
+  // 手工确认结果属于本地事实,即使扫描快照暂时缺失也必须在 Dashboard 中可见。
+  const rooms = mergeRoomSnapshots(
+    readLatestRooms(),
+    Object.entries(confirmed).map(([roomId, mapped]) => ({
+      roomId,
+      roomName: mapped.roomName || roomId,
+      source: 'local-confirmation',
+      sources: ['local-confirmation'],
+      seenAt: mapped.confirmedAt || null
+    }))
+  );
+
   let result = rooms.map(room => {
   let result = rooms.map(room => {
     const mapped = confirmed[room.roomId];
     const mapped = confirmed[room.roomId];
     const rejectedRecord = rejected[room.roomId];
     const rejectedRecord = rejected[room.roomId];
@@ -998,6 +1076,16 @@ const qiweiConfirmExternalGroup = safeResult(async function qiweiConfirmExternal
       }
       }
     : item);
     : item);
   const discoveredCustomers = recordCustomersFromGroups(roomsForDiscovery, { source: 'group-confirm' });
   const discoveredCustomers = recordCustomersFromGroups(roomsForDiscovery, { source: 'group-confirm' });
+  const operationAccountKey = String(input.accountKey || input.guid || input.uid || '').trim();
+  if (operationAccountKey) {
+    getGroupOperationsStore().upsertGroup(operationAccountKey, {
+      roomId,
+      roomName: room.roomName || roomId,
+      groupType: 'customer',
+      lifecycleStage: 'active',
+      status: 'active'
+    }, 'group-confirm');
+  }
 
 
   return okResult({
   return okResult({
     assistantMessage: `已确认外部群为客户群:${room.roomName || roomId}。`,
     assistantMessage: `已确认外部群为客户群:${room.roomName || roomId}。`,
@@ -1102,61 +1190,75 @@ const qiweiSyncGroupMessages = safeResult(async function qiweiSyncGroupMessages(
     if (!targetRoomIds.length) return errorResult('未指定 roomIds 且没有已确认的客户群映射,请先调用 qiwei_confirm_external_group');
     if (!targetRoomIds.length) return errorResult('未指定 roomIds 且没有已确认的客户群映射,请先调用 qiwei_confirm_external_group');
   }
   }
 
 
-  const results = [];
-  for (const roomId of targetRoomIds) {
-    let hasMore = true;
-    let msgSeq = 0;
-    let pages = 0;
-    let newCount = 0;
-    const messages = [];
-
-    while (hasMore && pages < maxPages && newCount < maxMessagesPerRoom) {
-      pages++;
-      const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
-        guid: ctx.guid,
-        msgSeq,
-        limit: 200
-      });
-      const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
-      hasMore = Boolean(data.hasMore);
-      msgSeq = data.travelSyncKey ?? msgSeq + 1;
-
-      for (const msg of msgList) {
-        const msgRoomId = String(msg.fromRoomId || '');
-        if (msgRoomId !== roomId) continue;
-        messages.push({
-          msgId: msg.msgUniqueIdentifier || msg.msgServerId || `${roomId}_${msg.seq}`,
-          seq: msg.seq,
-          senderId: msg.senderId || '',
-          senderName: msg.senderName || '',
-          receiverId: msg.receiverId || '',
-          fromRoomId: msg.fromRoomId || roomId,
-          msgType: String(msg.msgType),
-          content: extractMessageContent(msg),
-          timestamp: msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : new Date().toISOString(),
-          rawData: msg
-        });
-        newCount++;
-      }
+  const targetSet = new Set(targetRoomIds);
+  const messagesByRoom = new Map(targetRoomIds.map(roomId => [roomId, []]));
+  const cursorBefore = readMessageSyncCursor(ctx.guid);
+  let msgSeq = Number(cursorBefore.travelSyncKey || 0);
+  let nextCursor = msgSeq;
+  let hasMore = true;
+  let pages = 0;
+  let scannedMessages = 0;
 
 
-      if (!msgList.length) break;
+  while (hasMore && pages < maxPages) {
+    pages++;
+    const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, { guid: ctx.guid, msgSeq, limit: 500 });
+    const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
+    hasMore = Boolean(data.hasMore);
+    nextCursor = data.travelSyncKey ?? msgSeq;
+    scannedMessages += msgList.length;
+    for (const msg of msgList) {
+      const roomId = String(msg.fromRoomId || '');
+      if (!targetSet.has(roomId)) continue;
+      const roomMessages = messagesByRoom.get(roomId);
+      if (roomMessages.length >= maxMessagesPerRoom) continue;
+      roomMessages.push({
+        msgId: msg.msgUniqueIdentifier || msg.msgServerId || `${roomId}_${msg.seq}`,
+        seq: msg.seq,
+        senderId: msg.senderId || '',
+        senderName: msg.senderName || '',
+        receiverId: msg.receiverId || '',
+        fromRoomId: roomId,
+        msgType: String(msg.msgType),
+        content: extractMessageContent(msg),
+        timestamp: msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : new Date().toISOString(),
+        rawData: msg
+      });
     }
     }
+    if (!msgList.length || nextCursor === msgSeq) break;
+    msgSeq = nextCursor;
+  }
+
+  if (!hasMore) writeMessageSyncCursor(ctx.guid, { travelSyncKey: nextCursor, pages, scannedMessages });
 
 
+  const results = [];
+  const mapping = readConfirmedMapping();
+  const operationStore = getGroupOperationsStore();
+  for (const roomId of targetRoomIds) {
+    const messages = messagesByRoom.get(roomId) || [];
     if (messages.length) {
     if (messages.length) {
       ensureMessagesDir();
       ensureMessagesDir();
       let writtenCount = 0;
       let writtenCount = 0;
       for (const message of messages) {
       for (const message of messages) {
         if (appendWebhookMessage(roomId, message)) writtenCount++;
         if (appendWebhookMessage(roomId, message)) writtenCount++;
       }
       }
+      try {
+        operationStore.upsertGroup(ctx.guid, { roomId, roomName: mapping[roomId]?.roomName || '' }, 'history-sync');
+        operationStore.ingestMessages(ctx.guid, roomId, messages.map(message => ({
+          ...message,
+          messageId: message.msgId,
+          senderRole: 'unknown',
+          sentAt: message.timestamp
+        })));
+      } catch (error) {
+        console.warn(`[群消息同步] 社群运营事实表写入失败: ${error.message}`);
+      }
 
 
       // 更新 confirmed-mapping 的 lastMsgAt / lastSyncSeq
       // 更新 confirmed-mapping 的 lastMsgAt / lastSyncSeq
-      const mapping = readConfirmedMapping();
       if (mapping[roomId]) {
       if (mapping[roomId]) {
         const maxSeq = Math.max(...messages.map(m => Number(m.seq) || 0));
         const maxSeq = Math.max(...messages.map(m => Number(m.seq) || 0));
         const lastMsg = messages[messages.length - 1];
         const lastMsg = messages[messages.length - 1];
         mapping[roomId].lastMsgAt = lastMsg.timestamp || new Date().toISOString();
         mapping[roomId].lastMsgAt = lastMsg.timestamp || new Date().toISOString();
         mapping[roomId].lastSyncSeq = Math.max(mapping[roomId].lastSyncSeq || 0, maxSeq);
         mapping[roomId].lastSyncSeq = Math.max(mapping[roomId].lastSyncSeq || 0, maxSeq);
-        writeConfirmedMapping(mapping);
         recordGroupSync(roomId, {
         recordGroupSync(roomId, {
           messageCount: writtenCount,
           messageCount: writtenCount,
           lastMsgAt: lastMsg.timestamp || new Date().toISOString(),
           lastMsgAt: lastMsg.timestamp || new Date().toISOString(),
@@ -1169,12 +1271,13 @@ const qiweiSyncGroupMessages = safeResult(async function qiweiSyncGroupMessages(
       results.push({ roomId, newCount: 0, fileCount: 0 });
       results.push({ roomId, newCount: 0, fileCount: 0 });
     }
     }
   }
   }
+  writeConfirmedMapping(mapping);
 
 
   const totalNew = results.reduce((sum, r) => sum + r.newCount, 0);
   const totalNew = results.reduce((sum, r) => sum + r.newCount, 0);
   return okResult({
   return okResult({
     assistantMessage: `群消息同步完成:${targetRoomIds.length} 个群,共 ${totalNew} 条新消息。`,
     assistantMessage: `群消息同步完成:${targetRoomIds.length} 个群,共 ${totalNew} 条新消息。`,
-    summary: { rooms: targetRoomIds.length, totalNew, syncedRooms: results.filter(r => r.newCount > 0).length },
-    data: { results },
+    summary: { rooms: targetRoomIds.length, totalNew, syncedRooms: results.filter(r => r.newCount > 0).length, pages, scannedMessages, cursorAdvanced: !hasMore && nextCursor !== Number(cursorBefore.travelSyncKey || 0) },
+    data: { results, cursor: { before: cursorBefore.travelSyncKey || 0, after: !hasMore ? nextCursor : cursorBefore.travelSyncKey || 0, complete: !hasMore } },
     files: results.filter(r => r.filePath).map(r => path.join(outputsRoot(), r.filePath))
     files: results.filter(r => r.filePath).map(r => path.join(outputsRoot(), r.filePath))
   });
   });
 });
 });

+ 320 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-operations-run.js

@@ -0,0 +1,320 @@
+const { okResult, errorResult } = require('../core/result-envelope');
+const { safeResult, buildContext, gatewayCall, requireGuid, assertMethodsInCatalog } = require('../core/shared-gateway');
+const { getGroupOperationsStore, accountKey } = require('../core/group-operations-store');
+const { ROLE_CAPABILITIES, authorizeGroupOps } = require('../core/group-operations-access');
+const { qiweiListExternalGroups } = require('./qiwei-group-management-run');
+
+function context(input = {}) {
+  return {
+    key: accountKey(input.accountKey || input.guid || input.uid),
+    actor: String(input.actor || 'agent'),
+    principal: String(input.requester || input.principal || input.actor || 'agent')
+  };
+}
+
+function requireCapability(ctx, store, capability, input = {}) {
+  const decision = authorizeGroupOps(store, ctx.key, ctx.principal, capability, input);
+  return decision.allowed ? null : errorResult(decision.reason || '无权执行该操作');
+}
+
+function isAmbiguousSendFailure(error) {
+  const message = String(error?.message || error || '').toLowerCase();
+  return /timeout|timed out|etimedout|econnreset|socket hang up|network|连接重置|超时|回执/.test(message);
+}
+
+function syncCustomerGroupsToOperations(store, key, groups = [], actor = 'group-directory-sync') {
+  let synced = 0;
+  for (const group of groups) {
+    if (!['CONFIRMED', 'AUTO_CONFIRMED'].includes(group.reviewStatus)) continue;
+    const roomId = String(group.roomId || '').trim();
+    if (!roomId) continue;
+    const roomName = String(group.roomName || '').trim();
+    const existing = store.getGroup(key, roomId);
+    if (existing && existing.room_name === roomName && existing.status === 'active') continue;
+    store.upsertGroup(key, {
+      roomId,
+      roomName,
+      groupType: 'customer',
+      lifecycleStage: existing?.lifecycle_stage || 'active',
+      status: 'active',
+    }, actor);
+    synced++;
+  }
+  return synced;
+}
+
+const qiweiGroupOpsGetContext = safeResult(async function qiweiGroupOpsGetContext(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const directory = await qiweiListExternalGroups({ includeRejected: false });
+  syncCustomerGroupsToOperations(store, ctx.key, directory.data?.groups || []);
+  if (input.roomId && input.group) store.upsertGroup(ctx.key, { ...input.group, roomId: input.roomId }, ctx.actor);
+  const data = input.roomId
+    ? { group: store.getGroup(ctx.key, input.roomId), playbooks: store.listPlaybooks(ctx.key), overview: store.overview(ctx.key, input.date) }
+    : store.overview(ctx.key, input.date);
+  return okResult({ assistantMessage: input.roomId ? '已读取该客户群的运营上下文。' : '已读取社群运营总览。', data, summary: data.metrics || data.overview?.metrics });
+});
+
+const qiweiGroupOpsGeneratePlan = safeResult(async function qiweiGroupOpsGeneratePlan(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const denied = requireCapability(ctx, store, 'manage_plans', input);
+  if (denied) return denied;
+  const plan = store.generatePlan(ctx.key, input, ctx.actor);
+  return okResult({
+    assistantMessage: `已为群 ${plan.room_id} 生成 ${plan.plan_date} 的运营计划,共 ${plan.items.length} 项,全部等待人工审核。`,
+    data: { plan }, summary: { planId: plan.id, roomId: plan.room_id, planDate: plan.plan_date, itemCount: plan.items.length, requiresConfirmation: true }
+  });
+});
+
+const qiweiGroupOpsBatchGeneratePlans = safeResult(async function qiweiGroupOpsBatchGeneratePlans(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const denied = requireCapability(ctx, store, 'manage_plans', input);
+  if (denied) return denied;
+  const result = store.batchGeneratePlans(ctx.key, input, ctx.actor);
+  return okResult({ assistantMessage: `批量计划完成:${result.success}/${result.total} 个群成功,${result.failed} 个失败,全部进入待审核。`, data: result, summary: { ...result, results: undefined, requiresConfirmation: true } });
+});
+
+const qiweiGroupOpsGenerateCopy = safeResult(async function qiweiGroupOpsGenerateCopy(input = {}) {
+  const content = String(input.content || input.template || '').trim();
+  if (!content) return errorResult('缺少 content 或 template');
+  const variables = input.variables || {};
+  const missing = [];
+  const rendered = content.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (all, key) => {
+    if (variables[key] === undefined || variables[key] === '') { missing.push(key); return all; }
+    return String(variables[key]);
+  });
+  const riskTerms = input.riskTerms || ['保证', '百分百', '绝对', '最低价', '无条件退款'];
+  const risks = riskTerms.filter(term => rendered.includes(term));
+  return okResult({
+    assistantMessage: missing.length || risks.length ? '话术已生成,但存在待补变量或风险,暂不可发送。' : '话术已生成并通过基础规则检查,仍需人工确认。',
+    data: { content: rendered, missingVariables: [...new Set(missing)], risks, sendable: !missing.length && !risks.length },
+    summary: { requiresConfirmation: true, sendable: !missing.length && !risks.length }
+  });
+});
+
+const qiweiGroupOpsReviewMessages = safeResult(async function qiweiGroupOpsReviewMessages(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const denied = requireCapability(ctx, store, 'manage_findings', input);
+  if (denied) return denied;
+  const result = store.reviewMessages(ctx.key, input, ctx.actor);
+  return okResult({ assistantMessage: `质检完成:检查 ${result.messageCount} 条消息,发现 ${result.findingCount} 个待处理事项。`, data: result, summary: result });
+});
+
+const qiweiGroupOpsDailyBrief = safeResult(async function qiweiGroupOpsDailyBrief(input = {}) {
+  const ctx = context(input);
+  const overview = getGroupOperationsStore().overview(ctx.key, input.date);
+  const metrics = overview.metrics;
+  return okResult({
+    assistantMessage: `今日共 ${metrics.dueItems} 项运营任务,已完成 ${metrics.completedItems} 项,待审核 ${metrics.pendingReview} 项;当前高风险 ${metrics.highRisk} 项。`,
+    data: overview, summary: metrics
+  });
+});
+
+const qiweiGroupOpsManagePlaybook = safeResult(async function qiweiGroupOpsManagePlaybook(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const action = input.action || 'list';
+  if (!['list', 'versions', 'preview'].includes(action)) {
+    const denied = requireCapability(ctx, store, 'manage_playbooks', input);
+    if (denied) return denied;
+  }
+  let data;
+  if (action === 'list') data = { playbooks: store.listPlaybooks(ctx.key) };
+  else if (action === 'create') data = { playbook: store.createPlaybook(ctx.key, input, ctx.actor) };
+  else if (action === 'new_version') data = { playbook: store.addPlaybookVersion(ctx.key, input.playbookId, input.content, input.changeNote, ctx.actor) };
+  else if (action === 'versions') data = { versions: store.listPlaybookVersions(ctx.key, input.playbookId) };
+  else if (action === 'preview') data = { preview: store.previewPlaybookPublish(ctx.key, input.playbookId, input.version) };
+  else if (action === 'publish' || action === 'rollback') {
+    if (input.confirmed !== true) return errorResult(`${action === 'publish' ? '发布' : '回滚'} SOP 前必须完成人工确认并传 confirmed=true`);
+    data = { playbook: store.publishPlaybook(ctx.key, input.playbookId, input.version, ctx.actor) };
+  }
+  else return errorResult('action 仅支持 list/create/new_version/versions/preview/publish/rollback');
+  return okResult({ assistantMessage: action === 'list' ? `已读取 ${data.playbooks.length} 个 SOP。` : `SOP 操作 ${action} 已完成并写入审计。`, data, summary: { action, requiresConfirmation: ['publish', 'rollback'].includes(action) } });
+});
+
+const qiweiGroupOpsInsights = safeResult(async function qiweiGroupOpsInsights(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  if (input.action === 'update_settings') {
+    const denied = requireCapability(ctx, store, 'manage_quality', input);
+    if (denied) return denied;
+    const settings = store.setQualitySettings(ctx.key, input, ctx.actor);
+    return okResult({ assistantMessage: '质检阈值和风险词已更新并写入审计。', data: { settings }, summary: { requiresConfirmation: false } });
+  }
+  const data = input.roomId ? { insights: store.groupInsights(ctx.key, input.roomId) } : {
+    stores: store.storeBreakdown(ctx.key, input.date || new Date().toISOString().slice(0, 10)),
+    insights: store.groupInsights(ctx.key), qualitySettings: store.getQualitySettings(ctx.key),
+    feedback: store.feedbackSummary(ctx.key), excellentScripts: store.excellentScripts(ctx.key)
+  };
+  return okResult({ assistantMessage: input.roomId ? '已生成该群的活跃趋势与生命周期建议。' : '已生成跨门店运营洞察。', data, summary: { groupCount: data.insights.length } });
+});
+
+const qiweiGroupOpsManageTask = safeResult(async function qiweiGroupOpsManageTask(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const action = input.action || 'list';
+  if (action === 'list') return okResult({ assistantMessage: '已读取社群整改任务。', data: { tasks: store.listTasks(ctx.key, input.status) } });
+  const denied = requireCapability(ctx, store, 'manage_tasks', input);
+  if (denied) return denied;
+  if (action === 'create') {
+    const task = store.createTask(ctx.key, input, ctx.actor);
+    return okResult({ assistantMessage: '整改任务已创建,尚未同步到企微官方待办。', data: { task }, summary: { taskId: task.id, requiresConfirmation: true } });
+  }
+  const task = store.getTask(ctx.key, input.taskId);
+  if (!task) return errorResult('整改任务不存在或不属于当前账号');
+  if (action === 'update') {
+    let officialResult = null;
+    if (input.status === 'done' && task.official_todo_id) {
+      if (input.confirmed !== true) return errorResult('该任务已关联企微官方待办;完成会同步外部状态,请确认后传 confirmed=true');
+      const { completeTodoKnowledge } = require('../dashboard/official-office-knowledge-service');
+      officialResult = await completeTodoKnowledge(task.official_todo_id);
+      if (officialResult.status !== 'ok') return officialResult;
+    }
+    const updated = store.updateTask(ctx.key, input.taskId, input, ctx.actor);
+    return okResult({ assistantMessage: `整改任务已更新为 ${updated.status}${officialResult ? ',企微官方待办已同步' : ''}。`, data: { task: updated, officialTodo: officialResult?.data || null } });
+  }
+  if (action === 'sync_official') {
+    if (input.confirmed !== true) return errorResult('同步真实企微待办前必须展示负责人、截止时间和内容,并传 confirmed=true');
+    if (task.official_todo_id) return okResult({ assistantMessage: '该任务已同步企微,无需重复创建。', data: { task } });
+    const owner = String(input.owner || task.owner || '').trim();
+    const dueAt = String(input.dueAt || task.due_at || '').trim();
+    if (!owner || !dueAt) return errorResult('同步前必须补齐负责人和截止时间');
+    const { searchTodoUsers, createTodoKnowledge } = require('../dashboard/official-office-knowledge-service');
+    const search = await searchTodoUsers({ keyword: owner });
+    if (search.status !== 'ok') return search;
+    const users = search.data?.users || [];
+    const exact = users.filter(item => item.name === owner || item.alias === owner);
+    const selected = exact.length === 1 ? exact[0] : users.length === 1 ? users[0] : null;
+    if (!selected) return errorResult(users.length ? '负责人匹配到多位企微成员,请使用准确姓名或别名' : '未找到企微内部负责人');
+    const created = await createTodoKnowledge({ content: `社群整改:${task.title}`, followerIds: [selected.id], endTime: dueAt, remindType: input.remindType ?? 1 });
+    if (created.status !== 'ok') return created;
+    const todoId = String(created.summary?.todoId || created.data?.todo?.id || '').trim();
+    if (!todoId) return errorResult('企微待办创建成功但未返回可关联 ID');
+    const updated = store.updateTask(ctx.key, task.id, { owner, dueAt, status: 'in_progress', officialTodoId: todoId, officialSyncStatus: 'synced' }, ctx.actor);
+    return okResult({ assistantMessage: '整改任务已同步为真实企微官方待办。', data: { task: updated, officialTodo: created.data?.todo || null }, summary: { taskId: task.id, todoId } });
+  }
+  return errorResult('action 仅支持 list/create/update/sync_official');
+});
+
+const qiweiGroupOpsAutomation = safeResult(async function qiweiGroupOpsAutomation(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const action = input.action || 'status';
+  if (action === 'status') return okResult({ assistantMessage: '已读取社群自动化状态。', data: store.automationOverview(ctx.key) });
+  if (action !== 'preview') {
+    const denied = requireCapability(ctx, store, 'manage_automation', input);
+    if (denied) return denied;
+  }
+  if (action === 'create_policy') {
+    const policy = store.createAutomationPolicy(ctx.key, input, ctx.actor);
+    return okResult({ assistantMessage: `自动化策略 v${policy.version} 草稿已创建,尚未激活。`, data: { policy }, summary: { policyId: policy.id, version: policy.version, requiresConfirmation: true } });
+  }
+  if (action === 'activate') {
+    if (input.confirmed !== true) return errorResult('激活策略前必须展示白名单、灰度、频控、时间窗和熔断阈值,并传 confirmed=true');
+    const policy = store.activateAutomationPolicy(ctx.key, input.policyId, ctx.actor);
+    return okResult({ assistantMessage: `策略 v${policy.version} 已激活但保持暂停,需再次确认后才能恢复运行。`, data: { policy, state: store.getAutomationState(ctx.key) } });
+  }
+  if (['pause', 'resume', 'reset_breaker'].includes(action)) {
+    if (action !== 'pause' && input.confirmed !== true) return errorResult(`${action} 需要人工确认并传 confirmed=true`);
+    const state = store.updateAutomationState(ctx.key, action, ctx.actor);
+    return okResult({ assistantMessage: action === 'pause' ? '自动化已立即暂停。' : action === 'resume' ? '自动化已恢复;真实发送仍受环境开关和逐项闸门保护。' : '熔断器已人工复位。', data: { state } });
+  }
+  const at = input.at ? new Date(input.at) : new Date();
+  const all = store.listAutomationCandidates(ctx.key, input.limit || 100);
+  const selectedIds = new Set((input.itemIds || []).map(String));
+  const items = selectedIds.size ? all.filter(item => selectedIds.has(item.id)) : all;
+  const decisions = items.map(item => ({ item, decision: store.evaluateAutomation(ctx.key, item, at) }));
+  if (action === 'preview') return okResult({ assistantMessage: `自动化预演完成:${decisions.filter(row => row.decision.allowed).length}/${decisions.length} 项通过全部闸门。`, data: { decisions }, summary: { total: decisions.length, allowed: decisions.filter(row => row.decision.allowed).length, blocked: decisions.filter(row => !row.decision.allowed).length } });
+  if (action !== 'run') return errorResult('action 仅支持 status/create_policy/activate/pause/resume/reset_breaker/preview/run');
+
+  const active = store.getActiveAutomationPolicy(ctx.key);
+  const liveRequested = input.execute === true;
+  if (liveRequested) {
+    if (input.confirmed !== true) return errorResult('执行真实自动发送前必须确认本次目标和最终内容,并传 confirmed=true');
+    if (process.env.QIWEI_GROUP_OPS_LIVE_SEND !== '1') return errorResult('真实自动发送环境开关未开启;请先完成发送回执 live smoke,再设置 QIWEI_GROUP_OPS_LIVE_SEND=1');
+    if (active?.policy?.mode !== 'auto') return errorResult('当前策略不是 auto 模式,不能真实自动发送');
+    assertMethodsInCatalog({ sendText: '/msg/sendText' });
+  }
+  const gatewayContext = liveRequested ? buildContext(input) : null;
+  if (liveRequested) requireGuid(gatewayContext);
+  const results = [];
+  for (const row of decisions) {
+    const currentDecision = store.evaluateAutomation(ctx.key, store.getPlanItemWithContext(ctx.key, row.item.id), at);
+    if (!currentDecision.allowed) { results.push({ itemId: row.item.id, status: 'blocked', reasons: currentDecision.reasons }); continue; }
+    if (!liveRequested) {
+      const attempt = store.createSendAttempt(ctx.key, row.item, currentDecision, 'dry_run');
+      results.push({ itemId: row.item.id, status: 'dry_run', attemptId: attempt.id });
+      continue;
+    }
+    const attempt = store.createSendAttempt(ctx.key, row.item, currentDecision, 'live');
+    try {
+      const response = await gatewayCall(gatewayContext, '/msg/sendText', { guid: gatewayContext.guid, toId: row.item.room_id, content: row.item.final_content, isNoNeedRead: true });
+      const externalMessageId = String(response?.msgId || response?.messageId || response?.msgUniqueIdentifier || '');
+      store.finishSendAttempt(ctx.key, attempt.id, { success: true, externalMessageId });
+      store.updatePlanItem(ctx.key, row.item.id, 'mark_sent', { externalMessageId }, 'automation');
+      results.push({ itemId: row.item.id, status: 'sent', attemptId: attempt.id });
+    } catch (error) {
+      const ambiguous = isAmbiguousSendFailure(error);
+      const finished = store.finishSendAttempt(ctx.key, attempt.id, { success: false, ambiguous, error: error.message || String(error) });
+      results.push({ itemId: row.item.id, status: ambiguous ? 'unknown' : 'failed', attemptId: attempt.id, breakerOpen: Boolean(store.getAutomationState(ctx.key).breaker_open), error: finished.error });
+    }
+  }
+  return okResult({ assistantMessage: liveRequested ? `受控发送执行完成:成功 ${results.filter(item => item.status === 'sent').length},失败 ${results.filter(item => item.status === 'failed').length},结果待确认 ${results.filter(item => item.status === 'unknown').length},拦截 ${results.filter(item => item.status === 'blocked').length}。` : `dry-run 完成:${results.filter(item => item.status === 'dry_run').length} 项通过,${results.filter(item => item.status === 'blocked').length} 项被闸门拦截。`, data: { results, state: store.getAutomationState(ctx.key) }, summary: { total: results.length, sent: results.filter(item => item.status === 'sent').length, failed: results.filter(item => item.status === 'failed').length, unknown: results.filter(item => item.status === 'unknown').length, dryRun: results.filter(item => item.status === 'dry_run').length, blocked: results.filter(item => item.status === 'blocked').length } });
+});
+
+const qiweiGroupOpsExecuteItem = safeResult(async function qiweiGroupOpsExecuteItem(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const denied = requireCapability(ctx, store, 'execute_items', input);
+  if (denied) return denied;
+  if (input.action === 'send') return errorResult('真实发送尚未开放;请先完成企微发送接口回执 smoke。可使用 approve 审核或 mark_sent 记录已人工执行的结果。');
+  const item = store.updatePlanItem(ctx.key, input.itemId, input.action, input, ctx.actor);
+  return okResult({ assistantMessage: `计划项已更新为 ${item.status}。`, data: { item }, summary: { itemId: item.id, status: item.status, auditId: item.auditId } });
+});
+
+const qiweiGroupOpsUpdateFinding = safeResult(async function qiweiGroupOpsUpdateFinding(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const denied = requireCapability(ctx, store, 'manage_findings', input);
+  if (denied) return denied;
+  const finding = store.updateFinding(ctx.key, input.findingId, input, ctx.actor);
+  return okResult({ assistantMessage: `质检项已更新为 ${finding.status}。`, data: { finding }, summary: { findingId: finding.id, status: finding.status } });
+});
+
+const qiweiGroupOpsManageAccess = safeResult(async function qiweiGroupOpsManageAccess(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  const action = input.action || 'list';
+  if (action === 'list') return okResult({ assistantMessage: '已读取当前账号的角色绑定。', data: { roles: Object.keys(ROLE_CAPABILITIES), bindings: store.listRoleBindings(ctx.key) } });
+  if (action !== 'upsert') return errorResult('action 仅支持 list/upsert');
+  const bindings = store.listRoleBindings(ctx.key);
+  if (!bindings.length) {
+    if (input.bootstrapConfirmed !== true) return errorResult('首次配置角色绑定需确认初始化身份并传 bootstrapConfirmed=true');
+  } else {
+    const denied = requireCapability(ctx, store, 'manage_playbooks', input);
+    if (denied) return denied;
+  }
+  if (!Object.prototype.hasOwnProperty.call(ROLE_CAPABILITIES, input.role)) return errorResult(`未知角色,可选:${Object.keys(ROLE_CAPABILITIES).join('、')}`);
+  const binding = store.upsertRoleBinding(ctx.key, input, ctx.actor);
+  return okResult({ assistantMessage: `已为 ${binding.principal} 配置角色 ${binding.role}。`, data: { binding } });
+});
+
+module.exports = {
+  qiweiGroupOpsGetContext,
+  qiweiGroupOpsGeneratePlan,
+  qiweiGroupOpsBatchGeneratePlans,
+  qiweiGroupOpsGenerateCopy,
+  qiweiGroupOpsReviewMessages,
+  qiweiGroupOpsDailyBrief,
+  qiweiGroupOpsManagePlaybook,
+  qiweiGroupOpsExecuteItem,
+  qiweiGroupOpsUpdateFinding,
+  qiweiGroupOpsInsights,
+  qiweiGroupOpsManageTask,
+  qiweiGroupOpsAutomation,
+  qiweiGroupOpsManageAccess,
+  __testing: { syncCustomerGroupsToOperations }
+};

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 1
claude-code/claude-code-qiwe-assistant/package.json


+ 35 - 0
claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js

@@ -16,6 +16,7 @@ const {
   extractExplicitCustomerIntelligence,
   extractExplicitCustomerIntelligence,
   isNoReplyNeededMessage,
   isNoReplyNeededMessage,
   parseClaudeProcessResult,
   parseClaudeProcessResult,
+  resolveClaudeExecutable,
   selectAuthoritativeHistory,
   selectAuthoritativeHistory,
 } = require('../mcp/src/core/agent-runtime');
 } = require('../mcp/src/core/agent-runtime');
 const { getCustomerSessionGuide } = require('../mcp/src/core/agent-session-guide');
 const { getCustomerSessionGuide } = require('../mcp/src/core/agent-session-guide');
@@ -152,6 +153,40 @@ async function main() {
     assert.equal(configB.qiwei.guid, accountB.guid);
     assert.equal(configB.qiwei.guid, accountB.guid);
   });
   });
 
 
+  await check('启动监听保留当前审核策略,不自动切换会话模式', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const calls = [];
+    const target = {
+      poller: {
+        async start() {
+          calls.push('poller.start');
+          return { running: true, syncKey: 7 };
+        }
+      }
+    };
+    const result = await __testing.startListenerForWorkbench(target, { online: true, nickname: '测试账号' });
+    assert.deepEqual(calls, ['poller.start']);
+    assert.equal(result.status, 'ok');
+    assert.equal(result.data.running, true);
+  });
+
+  await check('Claude Code 可从 Fmode Studio npm-global PATH 中发现', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-claude-path-'));
+    const executable = process.platform === 'win32'
+      ? path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe')
+      : path.join(dir, 'claude');
+    fs.mkdirSync(path.dirname(executable), { recursive: true });
+    fs.writeFileSync(executable, 'smoke');
+    const previousPath = process.env.PATH;
+    try {
+      process.env.PATH = `${dir}${path.delimiter}${previousPath || ''}`;
+      assert.equal(resolveClaudeExecutable({}), executable);
+    } finally {
+      process.env.PATH = previousPath;
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
   await check('不同消息 ID 的同内容在 60 秒内只入库一次', async () => {
   await check('不同消息 ID 的同内容在 60 秒内只入库一次', async () => {
     const ctx = setup();
     const ctx = setup();
     try {
     try {

+ 59 - 0
claude-code/claude-code-qiwe-assistant/scripts/business-diagnosis-smoke-test.js

@@ -0,0 +1,59 @@
+'use strict';
+
+const assert = require('assert');
+const { diagnoseSnapshot } = require('../mcp/src/dashboard/business-diagnosis-service');
+
+const snapshot = {
+  accountKey: 'test-account',
+  date: '2026-07-22',
+  summary: {
+    customers: { total: 10, todayNew: 2 },
+    portraits: { total: 4, coverage: 40, recent7d: 3 },
+    tags: { total: 6 },
+    operations: { recent7d: 8 }
+  },
+  groupOverview: {
+    metrics: {
+      groups: 3,
+      dueItems: 6,
+      completedItems: 2,
+      pendingReview: 2,
+      openFindings: 2,
+      highRisk: 1,
+      openTasks: 1,
+      executionRate: 33,
+      averageHealthScore: 68,
+      averageHealthCoverage: 80
+    }
+  },
+  conversations: [
+    { pendingReply: { id: 'draft-1' }, customerIntelligence: { summary: { openTasks: 2, highAlerts: 1 } } },
+    { pendingReply: null, customerIntelligence: { summary: { openTasks: 0, highAlerts: 0 } } }
+  ]
+};
+
+const commerce = diagnoseSnapshot('最近为什么复购下降?', snapshot);
+assert.equal(commerce.type, 'commerce');
+assert(commerce.dataGaps.some(item => item.code === 'NO_TRANSACTION_DATA'));
+assert.match(commerce.title, /不足以直接判断/);
+
+const group = diagnoseSnapshot('哪些群存在投诉风险?', snapshot);
+assert.equal(group.type, 'group-operations');
+assert.equal(group.tone, 'danger');
+assert.equal(group.metrics.groupHighRisk, 1);
+assert.equal(group.actions[0].route, '#group-ops');
+
+const customer = diagnoseSnapshot('哪些客户今天必须跟进?', snapshot);
+assert.equal(customer.type, 'customer-operations');
+assert.equal(customer.metrics.openCustomerTasks, 2);
+assert.match(customer.summary, /6 位客户缺少完整画像/);
+assert.equal(customer.actions[0].route, '#customer-ops');
+
+const general = diagnoseSnapshot('今天先做什么?', snapshot);
+assert.equal(general.type, 'general');
+assert.equal(general.tone, 'warning');
+assert.equal(general.metrics.pendingReplies, 1);
+assert.equal(general.evidenceDetails.length, 3);
+
+assert.throws(() => diagnoseSnapshot('', snapshot), /缺少经营诊断问题/);
+process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 15, types: ['commerce', 'group-operations', 'customer-operations', 'general'] }, null, 2)}\n`);

+ 43 - 0
claude-code/claude-code-qiwe-assistant/scripts/cleanup-mock-data.js

@@ -0,0 +1,43 @@
+const fs = require('fs');
+const path = require('path');
+
+const outputsRoot = path.resolve(__dirname, '..', 'outputs');
+const dashboardStatePath = path.join(outputsRoot, 'dashboard', 'dashboard-state.json');
+const roomsLatestPath = path.join(outputsRoot, 'groups', 'rooms-latest.json');
+
+function timestamp() {
+  return new Date().toISOString().replace(/[:.]/g, '-');
+}
+
+function backup(filePath) {
+  const backupPath = `${filePath}.backup-${timestamp()}`;
+  fs.copyFileSync(filePath, backupPath);
+  console.log(`Backed up: ${filePath} -> ${backupPath}`);
+  return backupPath;
+}
+
+function cleanDashboardState() {
+  const data = JSON.parse(fs.readFileSync(dashboardStatePath, 'utf8'));
+  const before = Object.keys(data.customers || {}).length;
+  delete data.customers['wx-u-1'];
+  const after = Object.keys(data.customers || {}).length;
+  data.updatedAt = new Date().toISOString();
+  fs.writeFileSync(dashboardStatePath, JSON.stringify(data, null, 2), 'utf8');
+  console.log(`Dashboard state: removed ${before - after} mock customer(s)`);
+}
+
+function cleanRoomsLatest() {
+  const rooms = JSON.parse(fs.readFileSync(roomsLatestPath, 'utf8'));
+  const mockIds = new Set(['r-1', 'r-2', 'r-3']);
+  const before = rooms.length;
+  const cleaned = rooms.filter(room => !mockIds.has(room.roomId));
+  const after = cleaned.length;
+  fs.writeFileSync(roomsLatestPath, JSON.stringify(cleaned, null, 2), 'utf8');
+  console.log(`Rooms latest: removed ${before - after} mock group(s)`);
+}
+
+backup(dashboardStatePath);
+backup(roomsLatestPath);
+cleanDashboardState();
+cleanRoomsLatest();
+console.log('Cleanup complete.');

+ 3 - 1
claude-code/claude-code-qiwe-assistant/scripts/customer-master-smoke-test.js

@@ -11,6 +11,7 @@ async function main() {
   let updateCall = null;
   let updateCall = null;
   const conversation = {
   const conversation = {
     id: 'conversation-live-1',
     id: 'conversation-live-1',
+    channelId: 'external-contact-1',
     displayName: '测试客户',
     displayName: '测试客户',
     maskedId: '16***34',
     maskedId: '16***34',
     source: 'live',
     source: 'live',
@@ -47,6 +48,7 @@ async function main() {
   assert.equal(hub.summary.priorityCount, 1);
   assert.equal(hub.summary.priorityCount, 1);
   assert.equal(hub.data.customers[0].source, '真实企微会话');
   assert.equal(hub.data.customers[0].source, '真实企微会话');
   assert.equal(hub.data.customers[0].evidenceCoverage, 33);
   assert.equal(hub.data.customers[0].evidenceCoverage, 33);
+  assert.equal(hub.data.customers[0].externalUserId, 'external-contact-1');
   assert.equal(hub.data.customers[0].openTaskCount, 1);
   assert.equal(hub.data.customers[0].openTaskCount, 1);
   assert(fs.existsSync(projectionPath));
   assert(fs.existsSync(projectionPath));
   const projection = fs.readFileSync(projectionPath, 'utf8');
   const projection = fs.readFileSync(projectionPath, 'utf8');
@@ -59,7 +61,7 @@ async function main() {
   assert.equal(updateCall.input.profile.intent, '自住');
   assert.equal(updateCall.input.profile.intent, '自住');
   assert.equal(updateCall.input.reason, '人工确认');
   assert.equal(updateCall.input.reason, '人工确认');
 
 
-  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 13, customers: hub.summary.customerCount, evidenceProjectionSafe: true }, null, 2)}\n`);
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 14, customers: hub.summary.customerCount, evidenceProjectionSafe: true }, null, 2)}\n`);
 }
 }
 
 
 main().catch(error => {
 main().catch(error => {

+ 93 - 0
claude-code/claude-code-qiwe-assistant/scripts/diagnosis-action-center-smoke-test.js

@@ -0,0 +1,93 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { createUnifiedTaskService } = require('../mcp/src/dashboard/unified-task-service');
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-diagnosis-actions-'));
+
+async function main() {
+try {
+  const service = createUnifiedTaskService({
+    outputsDir: tempDir,
+    getConversations: () => ({ status: 'ok', data: { conversations: [] } })
+  });
+  const input = {
+    question: '哪些客户今天必须跟进?',
+    diagnosisType: 'customer-operations',
+    scope: { date: '2026-07-24' },
+    action: { id: 'open-customer-master', label: '打开客户主档', description: '按任务与画像完整度处理', route: '#customer-ops', priority: 'high' },
+    evidence: [{ label: '画像覆盖率', value: 40, source: 'dashboard-state' }],
+    dataGaps: [{ code: 'LOW_PORTRAIT_COVERAGE', label: '画像覆盖不足' }]
+  };
+
+  const created = service.createDiagnosisCandidate(input);
+  assert.equal(created.status, 'ok');
+  assert.equal(created.summary.created, true);
+  assert.equal(created.summary.requiresPromotion, true);
+  assert.match(created.data.candidate.sourceKey, /^diagnosis:/);
+
+  const duplicate = service.createDiagnosisCandidate(input);
+  assert.equal(duplicate.summary.created, false);
+  assert.equal(duplicate.summary.duplicate, true);
+
+  const firstHub = service.hub();
+  assert.equal(firstHub.summary.candidates, 1);
+  const candidate = firstHub.data.tasks.find(task => task.source === 'diagnosis-candidate');
+  assert(candidate);
+  assert.equal(candidate.actions.promote, true);
+
+  const promoted = service.createLocalTask({
+    title: candidate.title,
+    priority: candidate.priority,
+    evidence: candidate.evidence,
+    sourceTitle: candidate.sourceTitle,
+    source: candidate.source,
+    sourceKey: candidate.id
+  });
+  assert.equal(promoted.summary.created, true);
+  const prematureFeedback = service.updateDiagnosisOutcome({ taskId: promoted.data.task.id, outcome: 'effective' });
+  assert.equal(prematureFeedback.status, 'error');
+
+  const completed = await service.update({ source: 'local', taskId: promoted.data.task.id, status: 'done' });
+  assert.equal(completed.status, 'ok');
+  const feedback = service.updateDiagnosisOutcome({
+    taskId: promoted.data.task.id,
+    outcome: 'effective',
+    note: '跟进任务按计划完成,复盘确认流程有效。',
+    actor: 'smoke-test'
+  });
+  assert.equal(feedback.status, 'ok');
+  assert.equal(feedback.summary.internalOnly, true);
+  assert.equal(feedback.summary.customerMessageSent, false);
+
+  const secondHub = service.hub();
+  assert.equal(secondHub.summary.promotedCandidates, 1);
+  assert.equal(secondHub.summary.diagnosisFeedback.candidates, 1);
+  assert.equal(secondHub.summary.diagnosisFeedback.promoted, 1);
+  assert.equal(secondHub.summary.diagnosisFeedback.completed, 1);
+  assert.equal(secondHub.summary.diagnosisFeedback.effective, 1);
+  assert.equal(secondHub.summary.diagnosisFeedback.adoptionRate, 1);
+  assert.equal(secondHub.summary.diagnosisFeedback.effectivenessRate, 1);
+  assert.equal(secondHub.data.tasks.find(task => task.id === candidate.id).actions.promote, false);
+  const completedTask = secondHub.data.tasks.find(task => task.id === promoted.data.task.id);
+  assert.equal(completedTask.diagnosisOutcome.value, 'effective');
+  assert.equal(completedTask.actions.diagnosisFeedback, true);
+  const candidateStore = service.readDiagnosisCandidates();
+  assert(candidateStore.candidates[0].lifecycle.some(item => item.event === 'promoted'));
+  assert(candidateStore.candidates[0].lifecycle.some(item => item.event === 'completed'));
+  assert(candidateStore.candidates[0].lifecycle.some(item => item.event === 'outcome_recorded'));
+  assert(fs.existsSync(path.join(tempDir, 'knowledge', 'diagnoses', 'action-candidates.json')));
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 30, candidateSource: candidate.source, promotedCandidates: secondHub.summary.promotedCandidates, effectivenessRate: secondHub.summary.diagnosisFeedback.effectivenessRate }, null, 2)}\n`);
+} finally {
+  fs.rmSync(tempDir, { recursive: true, force: true });
+}
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exit(1);
+});

+ 47 - 0
claude-code/claude-code-qiwe-assistant/scripts/group-operations-access-smoke-test.js

@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-group-ops-access-'));
+process.env.QIWEI_OUTPUTS_DIR = path.join(tempDir, 'outputs');
+const { getGroupOperationsStore } = require('../mcp/src/core/group-operations-store');
+const { qiweiGroupOpsManageAccess, qiweiGroupOpsManagePlaybook, qiweiGroupOpsGeneratePlan, qiweiGroupOpsExecuteItem } = require('../mcp/src/tools/qiwei-group-operations-run');
+const store = getGroupOperationsStore();
+
+(async () => {
+  try {
+    const key = 'access-smoke';
+    const hq = 'hq-test';
+    const manager = 'store-manager-test';
+    const auditor = 'auditor-test';
+    const bootstrapped = await qiweiGroupOpsManageAccess({ accountKey: key, action: 'upsert', principal: hq, role: 'headquarters_admin', actor: hq, bootstrapConfirmed: true });
+    assert.equal(bootstrapped.status, 'ok');
+    assert.equal((await qiweiGroupOpsManageAccess({ accountKey: key, action: 'upsert', principal: manager, requester: hq, role: 'store_manager', storeScope: ['store-a'], actor: hq })).status, 'ok');
+    assert.equal((await qiweiGroupOpsManageAccess({ accountKey: key, action: 'upsert', principal: auditor, requester: hq, role: 'auditor', actor: hq })).status, 'ok');
+
+    const created = await qiweiGroupOpsManagePlaybook({ accountKey: key, action: 'create', name: '权限测试 SOP', groupType: 'customer', content: { cadence: [{ template: '测试 {{room_name}}' }] }, actor: hq, principal: hq });
+    assert.equal(created.status, 'ok');
+    const playbookId = created.data.playbook.id;
+    assert.equal((await qiweiGroupOpsManagePlaybook({ accountKey: key, action: 'publish', playbookId, confirmed: true, actor: hq, principal: hq })).status, 'ok');
+    store.upsertGroup(key, { roomId: 'room-a', roomName: '门店 A 群', storeId: 'store-a', playbookId }, hq);
+    store.upsertGroup(key, { roomId: 'room-b', roomName: '门店 B 群', storeId: 'store-b', playbookId }, hq);
+
+    const allowedPlan = await qiweiGroupOpsGeneratePlan({ accountKey: key, principal: manager, actor: manager, roomId: 'room-a', planDate: '2026-07-20' });
+    assert.equal(allowedPlan.status, 'ok');
+    const deniedPlan = await qiweiGroupOpsGeneratePlan({ accountKey: key, principal: manager, actor: manager, roomId: 'room-b', planDate: '2026-07-20' });
+    assert.equal(deniedPlan.status, 'error');
+    assert.match(deniedPlan.assistantMessage, /无权操作门店/);
+    const deniedPublish = await qiweiGroupOpsManagePlaybook({ accountKey: key, principal: manager, actor: manager, action: 'publish', playbookId, confirmed: true });
+    assert.equal(deniedPublish.status, 'error');
+    const itemId = allowedPlan.data.plan.items[0].id;
+    const deniedExecute = await qiweiGroupOpsExecuteItem({ accountKey: key, principal: auditor, actor: auditor, itemId, action: 'approve' });
+    assert.equal(deniedExecute.status, 'error');
+    assert.match(deniedExecute.assistantMessage, /没有 execute_items 权限/);
+    console.log('group-operations access smoke: ok');
+  } finally {
+    store.close();
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  }
+})().catch(error => { console.error(error.stack || error.message); process.exit(1); });

+ 47 - 0
claude-code/claude-code-qiwe-assistant/scripts/group-operations-automation-smoke-test.js

@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-group-ops-auto-'));
+process.env.QIWEI_OUTPUTS_DIR = path.join(tempDir, 'outputs');
+delete process.env.QIWEI_GROUP_OPS_LIVE_SEND;
+
+const { getGroupOperationsStore } = require('../mcp/src/core/group-operations-store');
+const { qiweiGroupOpsAutomation, qiweiGroupOpsManagePlaybook } = require('../mcp/src/tools/qiwei-group-operations-run');
+const store = getGroupOperationsStore();
+
+(async () => {
+  try {
+    const key = 'automation-smoke';
+    const confirmationPlaybook = store.createPlaybook(key, { name: '确认测试 SOP', content: { cadence: [{ template: '确认测试' }] } }, 'test');
+    const publishDenied = await qiweiGroupOpsManagePlaybook({ accountKey: key, action: 'publish', playbookId: confirmationPlaybook.id });
+    assert.equal(publishDenied.status, 'error');
+    assert.match(publishDenied.assistantMessage, /confirmed=true/);
+    const publishConfirmed = await qiweiGroupOpsManagePlaybook({ accountKey: key, action: 'publish', playbookId: confirmationPlaybook.id, confirmed: true });
+    assert.equal(publishConfirmed.status, 'ok');
+    const playbook = store.createPlaybook(key, { name: '自动化测试 SOP', content: { cadence: [{ time: '09:30', type: 'content', objective: '测试', template: '安全测试话术 {{room_name}}' }] } }, 'test');
+    store.publishPlaybook(key, playbook.id, 1, 'test');
+    store.upsertGroup(key, { roomId: 'room-safe', roomName: '安全测试群', playbookId: playbook.id }, 'test');
+    const plan = store.generatePlan(key, { roomId: 'room-safe', planDate: '2026-07-20' }, 'test');
+    store.updatePlanItem(key, plan.items[0].id, 'approve', {}, 'test');
+    const policy = store.createAutomationPolicy(key, { policy: { enabled: true, mode: 'auto', allowedGroupIds: ['room-safe'], allowedTypes: ['content'], windows: [{ days: [1], start: '09:00', end: '18:00' }], dailyLimitPerGroup: 1, weeklyLimitPerGroup: 7, consecutiveFailureLimit: 3, grayPercent: 100, requireNoOpenHighRisk: true } }, 'test');
+    store.activateAutomationPolicy(key, policy.id, 'test');
+    store.updateAutomationState(key, 'resume', 'test');
+
+    const preview = await qiweiGroupOpsAutomation({ accountKey: key, action: 'preview', at: '2026-07-20T10:00:00+08:00' });
+    assert.equal(preview.status, 'ok');
+    assert.equal(preview.summary.allowed, 1);
+    const dryRun = await qiweiGroupOpsAutomation({ accountKey: key, action: 'run', at: '2026-07-20T10:00:00+08:00', execute: false });
+    assert.equal(dryRun.status, 'ok');
+    assert.equal(dryRun.summary.dryRun, 1);
+    const liveBlocked = await qiweiGroupOpsAutomation({ accountKey: key, action: 'run', at: '2026-07-20T10:00:00+08:00', execute: true, confirmed: true });
+    assert.equal(liveBlocked.status, 'error');
+    assert.match(liveBlocked.assistantMessage, /环境开关未开启/);
+    console.log('group-operations automation smoke: ok');
+  } finally {
+    store.close();
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  }
+})().catch(error => { console.error(error.stack || error.message); process.exit(1); });

+ 45 - 0
claude-code/claude-code-qiwe-assistant/scripts/group-operations-quality-eval.js

@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { GroupOperationsStore } = require('../mcp/src/core/group-operations-store');
+
+const dataset = [
+  { id: 'risk-1', messages: [{ messageId: 'r1', senderRole: 'customer', content: '我要投诉你们的服务', sentAt: '2026-07-18T08:00:00Z' }], expected: ['service_risk'] },
+  { id: 'neutral-1', messages: [{ messageId: 'n1', senderRole: 'customer', content: '谢谢,已经收到了', sentAt: '2026-07-18T08:00:00Z' }], expected: [] },
+  { id: 'question-1', messages: [{ messageId: 'q1', senderRole: 'customer', content: '什么时候可以处理?', sentAt: '2026-07-18T08:00:00Z' }], expected: ['unanswered_question'] },
+  { id: 'answered-1', messages: [
+    { messageId: 'a1', senderRole: 'customer', content: '请问什么时候可以处理?', sentAt: '2026-07-18T08:00:00Z' },
+    { messageId: 'a2', senderRole: 'staff', content: '今天下午为您处理。', sentAt: '2026-07-18T08:10:00Z' }
+  ], expected: [] },
+  { id: 'unrelated-answer-1', messages: [
+    { messageId: 'u1', senderRole: 'customer', content: '退款什么时候到账?', sentAt: '2026-07-18T08:00:00Z' },
+    { messageId: 'u2', senderRole: 'staff', content: '今天天气不错', sentAt: '2026-07-18T08:01:00Z' }
+  ], expected: ['service_risk', 'unanswered_question'] },
+  { id: 'relevant-answer-1', messages: [
+    { messageId: 'v1', senderRole: 'customer', content: '退款什么时候到账?', sentAt: '2026-07-18T08:00:00Z' },
+    { messageId: 'v2', senderRole: 'staff', content: '退款已提交,预计今天下午原路到账。', sentAt: '2026-07-18T08:10:00Z' }
+  ], expected: ['service_risk'] }
+];
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-group-ops-eval-'));
+const store = new GroupOperationsStore(path.join(tempDir, 'eval.db'));
+let tp = 0; let fp = 0; let fn = 0;
+try {
+  for (const sample of dataset) {
+    store.upsertGroup('eval', { roomId: sample.id, roomName: sample.id }, 'eval');
+    const result = store.reviewMessages('eval', { roomId: sample.id, messages: sample.messages, slaMinutes: 120 }, 'eval');
+    const predicted = new Set(result.findings.map(item => item.type));
+    const expected = new Set(sample.expected);
+    for (const type of predicted) expected.has(type) ? tp++ : fp++;
+    for (const type of expected) if (!predicted.has(type)) fn++;
+  }
+  const precision = tp + fp ? tp / (tp + fp) : 1;
+  const recall = tp + fn ? tp / (tp + fn) : 1;
+  const report = { status: precision >= 0.9 && recall >= 0.9 ? 'ok' : 'failed', samples: dataset.length, truePositive: tp, falsePositive: fp, falseNegative: fn, precision, recall };
+  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+  if (report.status !== 'ok') process.exitCode = 1;
+} finally {
+  store.close();
+  fs.rmSync(tempDir, { recursive: true, force: true });
+}

+ 135 - 0
claude-code/claude-code-qiwe-assistant/scripts/group-operations-smoke-test.js

@@ -0,0 +1,135 @@
+#!/usr/bin/env node
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { GroupOperationsStore } = require('../mcp/src/core/group-operations-store');
+const { __testing: groupOpsTesting } = require('../mcp/src/tools/qiwei-group-operations-run');
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-group-ops-'));
+const store = new GroupOperationsStore(path.join(tempDir, 'test.db'));
+
+try {
+  const account = 'account-a';
+  const otherAccount = 'account-b';
+  const directoryAccount = 'directory-account';
+  assert.equal(groupOpsTesting.syncCustomerGroupsToOperations(store, directoryAccount, [
+    { roomId: 'confirmed-room', roomName: 'Confirmed room', reviewStatus: 'CONFIRMED' },
+    { roomId: 'auto-room', roomName: 'Auto room', reviewStatus: 'AUTO_CONFIRMED' },
+    { roomId: 'suggested-room', roomName: 'Suggested room', reviewStatus: 'SUGGESTED' },
+  ]), 2);
+  assert.deepEqual(store.listGroups(directoryAccount).map(group => group.room_id).sort(), ['auto-room', 'confirmed-room']);
+  assert.equal(groupOpsTesting.syncCustomerGroupsToOperations(store, directoryAccount, [
+    { roomId: 'confirmed-room', roomName: 'Confirmed room', reviewStatus: 'CONFIRMED' },
+    { roomId: 'auto-room', roomName: 'Auto room', reviewStatus: 'AUTO_CONFIRMED' },
+  ]), 0, 'directory reconciliation must be idempotent');
+  const playbook = store.createPlaybook(account, {
+    name: '新客群服务 SOP',
+    groupType: 'new_customer',
+    content: { cadence: [{ time: '09:30', type: 'welcome', objective: '欢迎新客户', template: '欢迎加入 {{room_name}}' }] }
+  }, 'test');
+  assert.equal(playbook.status, 'draft');
+  const published = store.publishPlaybook(account, playbook.id, 1, 'test');
+  assert.equal(published.status, 'published');
+
+  store.upsertGroup(account, { roomId: 'room-1', roomName: '客户服务群', storeId: '门店A', groupType: 'new_customer', playbookId: playbook.id }, 'test');
+  store.upsertGroup(account, { roomId: 'room-2', roomName: '会员服务群', storeId: '门店B', groupType: 'new_customer', playbookId: playbook.id }, 'test');
+  assert.equal(store.listGroups(account).length, 2);
+  assert.equal(store.listGroups(otherAccount).length, 0, '账号数据必须隔离');
+
+  const version2 = store.addPlaybookVersion(account, playbook.id, { cadence: [{ time: '10:00', type: 'welcome', objective: '新版欢迎', template: '新版欢迎 {{room_name}}' }] }, '升级欢迎话术', 'test');
+  assert.equal(version2.version, 2);
+  const stillPublished = store.getPlaybook(account, playbook.id);
+  assert.equal(stillPublished.status, 'published', '创建草稿版本不能停用当前已发布 SOP');
+  assert.equal(stillPublished.version, 1, '创建草稿版本后默认读取仍应是当前已发布版本');
+  const planBeforeV2Publish = store.generatePlan(account, { roomId: 'room-1', planDate: '2026-07-17' }, 'test');
+  assert.equal(planBeforeV2Publish.items[0].draft_content, '欢迎加入 客户服务群', '未发布草稿不能进入运营计划');
+  assert.equal(planBeforeV2Publish.playbook_version_id, stillPublished.current_version_id, '计划审计版本必须与实际内容一致');
+  const impact = store.previewPlaybookPublish(account, playbook.id, 2);
+  assert.equal(impact.affectedGroupCount, 2);
+  assert.equal(impact.changes.length > 0, true);
+  store.publishPlaybook(account, playbook.id, 2, 'test');
+  store.publishPlaybook(account, playbook.id, 1, 'test');
+
+  const plan = store.generatePlan(account, { roomId: 'room-1', planDate: '2026-07-18' }, 'test');
+  assert.equal(plan.items.length, 1);
+  assert.match(plan.items[0].draft_content, /客户服务群/);
+  const duplicate = store.generatePlan(account, { roomId: 'room-1', planDate: '2026-07-18' }, 'test');
+  assert.equal(duplicate.id, plan.id, '同群同日计划必须幂等');
+  const approved = store.updatePlanItem(account, plan.items[0].id, 'approve', {}, 'test');
+  assert.equal(approved.status, 'approved');
+  store.updatePlanItem(account, plan.items[0].id, 'mark_sent', { externalMessageId: 'manual-1' }, 'test');
+  const batch = store.batchGeneratePlans(account, { planDate: '2026-07-19', storeId: '门店B' }, 'test');
+  assert.equal(batch.total, 1);
+  assert.equal(batch.success, 1);
+  const automationPlan = store.getPlan(batch.results[0].planId);
+  const automationItem = store.updatePlanItem(account, automationPlan.items[0].id, 'approve', {}, 'test');
+  const policy = store.createAutomationPolicy(account, { policy: { enabled: true, mode: 'dry_run', allowedGroupIds: ['room-2'], allowedTypes: [automationItem.type], windows: [{ days: [1], start: '09:00', end: '18:00' }], dailyLimitPerGroup: 1, weeklyLimitPerGroup: 7, consecutiveFailureLimit: 3, grayPercent: 100, requireNoOpenHighRisk: true } }, 'test');
+  assert.equal(policy.status, 'draft');
+  store.activateAutomationPolicy(account, policy.id, 'test');
+  const automationAt = new Date('2026-07-20T10:00:00+08:00');
+  assert.equal(store.evaluateAutomation(account, store.getPlanItemWithContext(account, automationItem.id), automationAt).allowed, false, '激活后必须保持暂停');
+  store.updateAutomationState(account, 'resume', 'test');
+  const decision = store.evaluateAutomation(account, store.getPlanItemWithContext(account, automationItem.id), automationAt);
+  assert.equal(decision.allowed, true);
+  assert.equal(store.createSendAttempt(account, store.getPlanItemWithContext(account, automationItem.id), decision, 'dry_run').status, 'dry_run');
+  for (let index = 0; index < 3; index++) {
+    const attempt = store.createSendAttempt(account, store.getPlanItemWithContext(account, automationItem.id), decision, 'live');
+    store.finishSendAttempt(account, attempt.id, { success: false, error: `模拟失败 ${index + 1}` });
+  }
+  assert.equal(store.getAutomationState(account).breaker_open, 1, '连续失败必须熔断');
+  assert.equal(store.evaluateAutomation(account, store.getPlanItemWithContext(account, automationItem.id), automationAt).reasons.includes('熔断器已打开'), true);
+  store.updateAutomationState(account, 'reset_breaker', 'test');
+  assert.equal(store.getAutomationState(account).breaker_open, 0);
+  const ambiguousAttempt = store.createSendAttempt(account, store.getPlanItemWithContext(account, automationItem.id), decision, 'live');
+  assert.equal(store.finishSendAttempt(account, ambiguousAttempt.id, { success: false, ambiguous: true, error: '发送回执超时' }).status, 'unknown');
+  assert.equal(store.evaluateAutomation(account, store.getPlanItemWithContext(account, automationItem.id), automationAt).reasons.some(reason => reason.includes('结果待确认')), true, '回执未知时必须锁住重试');
+  store.updateAutomationState(account, 'pause', 'test');
+
+  const review = store.reviewMessages(account, {
+    roomId: 'room-1', slaMinutes: 30, messages: [
+      { messageId: 'm1', senderId: 'customer-1', senderRole: 'customer', content: '这个问题怎么退款?', sentAt: '2026-07-18T08:00:00.000Z' }
+    ]
+  }, 'test');
+  assert.equal(review.findingCount, 2, '应识别退款风险和超时未回应');
+  const unrelated = store.reviewMessages(account, {
+    roomId: 'room-2', slaMinutes: 30, messages: [
+      { messageId: 'q-unrelated', senderId: 'customer-2', senderRole: 'customer', content: '退款什么时候到账?', sentAt: '2026-07-18T08:00:00.000Z' },
+      { messageId: 'a-unrelated', senderId: 'staff-2', senderRole: 'staff', content: '今天天气不错', sentAt: '2026-07-18T08:01:00.000Z' }
+    ]
+  }, 'test');
+  assert.equal(unrelated.findings.some(item => item.type === 'unanswered_question'), true, '无关员工消息不能算有效回应');
+  const answered = store.reviewMessages(account, {
+    roomId: 'room-2', slaMinutes: 30, messages: [
+      { messageId: 'a-related', senderId: 'staff-2', senderRole: 'staff', content: '退款已提交,预计今天下午原路到账。', sentAt: '2026-07-18T08:02:00.000Z' }
+    ]
+  }, 'test');
+  assert.equal(answered.findings.filter(item => item.type === 'unanswered_question').length, 0, '有效回复后本次质检不应新增未回应结论');
+  const configured = store.setQualitySettings(account, { slaMinutes: 45, riskTerms: ['投诉', '退款'], forbiddenClaims: ['保证'] }, 'test');
+  assert.equal(configured.sla_minutes, 45);
+  const overview = store.overview(account, '2026-07-18');
+  assert.equal(overview.metrics.executionRate, 100);
+  assert.equal(overview.metrics.highRisk >= 1, true);
+  assert.equal(Array.isArray(overview.scores), true);
+  assert.equal(overview.scores.length, 2);
+  assert.equal(overview.scores.every(item => item.coverage >= 0 && item.coverage <= 100), true);
+  store.updateFinding(account, overview.findings[0].id, { status: 'false_positive', resolution: '测试误报' }, 'test');
+  assert.equal(store.feedbackSummary(account).falsePositives, 1);
+  const task = store.createTask(account, { roomId: 'room-1', findingId: overview.findings[1].id, title: '跟进客户退款问题', owner: '测试负责人', dueAt: '2026-07-20T18:00:00+08:00', priority: 'high' }, 'test');
+  assert.equal(task.status, 'open');
+  assert.equal(store.updateTask(account, task.id, { status: 'done' }, 'test').status, 'done');
+  assert.equal(store.storeBreakdown(account, '2026-07-18').length, 2);
+  store.reviewMessages(account, {
+    roomId: 'room-1',
+    messages: [
+      { messageId: 'recent-activity', senderId: 'customer-1', senderRole: 'customer', content: '收到,谢谢', sentAt: new Date().toISOString() }
+    ]
+  }, 'test');
+  assert.equal(store.groupInsights(account, 'room-1')[0].activity.currentMessages >= 1, true);
+  assert.equal(store.excellentScripts(account).length, 1);
+  assert.equal(store.overview(otherAccount, '2026-07-18').metrics.groups, 0);
+  console.log('group-operations smoke: ok');
+} finally {
+  store.close();
+  fs.rmSync(tempDir, { recursive: true, force: true });
+}

+ 6 - 0
claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js

@@ -29,6 +29,7 @@ function main() {
   const catalog = readJson('mcp/catalog/qiwei-endpoints.json');
   const catalog = readJson('mcp/catalog/qiwei-endpoints.json');
   const catalogSource = fs.readFileSync(path.join(ROOT, 'mcp', 'catalog', 'qiwei-endpoints.json'), 'utf8');
   const catalogSource = fs.readFileSync(path.join(ROOT, 'mcp', 'catalog', 'qiwei-endpoints.json'), 'utf8');
   const serverSource = fs.readFileSync(path.join(ROOT, 'mcp', 'src', 'server.js'), 'utf8');
   const serverSource = fs.readFileSync(path.join(ROOT, 'mcp', 'src', 'server.js'), 'utf8');
+  const dashboardSource = fs.readFileSync(path.join(ROOT, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
   const serverVersion = serverSource.match(/new McpServer\([\s\S]*?version:\s*['"]([^'"]+)/)?.[1];
   const serverVersion = serverSource.match(/new McpServer\([\s\S]*?version:\s*['"]([^'"]+)/)?.[1];
   const toolNames = [...serverSource.matchAll(/registerTool\(\s*['"]([^'"]+)['"]/g)].map(match => match[1]);
   const toolNames = [...serverSource.matchAll(/registerTool\(\s*['"]([^'"]+)['"]/g)].map(match => match[1]);
   const skillDirs = fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })
   const skillDirs = fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })
@@ -67,6 +68,11 @@ function main() {
     createClient.params.find(param => param.name === 'clientVersion')?.desc,
     createClient.params.find(param => param.name === 'clientVersion')?.desc,
     '客户端版本由 Fmode 服务自动选择,通常无需填写。'
     '客户端版本由 Fmode 服务自动选择,通常无需填写。'
   );
   );
+  assert.doesNotMatch(
+    dashboardSource,
+    /(?:window\s*\.\s*)?(?:alert|confirm|prompt)\s*\(/,
+    'Dashboard 不能调用浏览器原生 alert、confirm 或 prompt'
+  );
 
 
   const pack = runNpmPackDryRun();
   const pack = runNpmPackDryRun();
   const packedPaths = new Set(pack.files.map(item => item.path.replace(/\\/g, '/')));
   const packedPaths = new Set(pack.files.map(item => item.path.replace(/\\/g, '/')));

+ 8 - 0
claude-code/claude-code-qiwe-assistant/scripts/release-check.js

@@ -11,6 +11,13 @@ const outputsDir = path.join(tempRoot, 'outputs');
 const npmCli = process.env.npm_execpath || path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
 const npmCli = process.env.npm_execpath || path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
 const checks = [
 const checks = [
   'check',
   'check',
+  'group-ops:check',
+  'group-ops:smoke',
+  'group-ops:eval',
+  'group-ops:auto-smoke',
+  'group-ops:access-smoke',
+  'diagnosis:smoke',
+  'diagnosis-actions:smoke',
   'smoke',
   'smoke',
   'agent:smoke',
   'agent:smoke',
   'library:smoke',
   'library:smoke',
@@ -29,6 +36,7 @@ try {
         ...process.env,
         ...process.env,
         QIWEI_OUTPUTS_DIR: outputsDir,
         QIWEI_OUTPUTS_DIR: outputsDir,
         QIWEI_AGENT_DB_PATH: path.join(outputsDir, 'messages', 'agent-workbench.db'),
         QIWEI_AGENT_DB_PATH: path.join(outputsDir, 'messages', 'agent-workbench.db'),
+        QIWEI_DISABLE_RELAY: '1',
       },
       },
       stdio: 'inherit',
       stdio: 'inherit',
       windowsHide: true,
       windowsHide: true,

+ 6 - 2
claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js

@@ -557,10 +557,14 @@ async function main() {
     const added = await qiweiAddExternalGroup({ roomId: 'r-99', roomName: '手动群', externalUserId: 'wx-u-2' });
     const added = await qiweiAddExternalGroup({ roomId: 'r-99', roomName: '手动群', externalUserId: 'wx-u-2' });
     assert.strictEqual(added.status, 'ok');
     assert.strictEqual(added.status, 'ok');
 
 
-  fs.rmSync(path.join(outputsRoot(), 'messages', 'rooms', 'r-1'), { recursive: true, force: true });
-    const messages = await qiweiSyncGroupMessages({ ...common, guid: 'guid-1', roomIds: ['r-1'] });
+    const syncCallsBefore = gateway.requests.filter(request => request.body?.method === '/msg/syncMsg').length;
+    const messages = await qiweiSyncGroupMessages({ ...common, guid: 'guid-1', roomIds: ['r-1', 'r-99'] });
     assert.strictEqual(messages.status, 'ok');
     assert.strictEqual(messages.status, 'ok');
     assert.strictEqual(messages.summary.totalNew, 1);
     assert.strictEqual(messages.summary.totalNew, 1);
+    assert.strictEqual(messages.summary.rooms, 2);
+    assert.strictEqual(gateway.requests.filter(request => request.body?.method === '/msg/syncMsg').length - syncCallsBefore, 1, '多个群应共用一次账号级消息流拉取');
+    assert.strictEqual(messages.data.cursor.complete, true);
+    assert.strictEqual(messages.data.cursor.after, 1);
     assertProviderHidden(messages);
     assertProviderHidden(messages);
     console.log('[ok] group-management tools sync, list, analyze, configure, reject, confirm and sync messages');
     console.log('[ok] group-management tools sync, list, analyze, configure, reject, confirm and sync messages');
 
 

+ 1 - 1
claude-code/claude-code-qiwe-assistant/scripts/validate-output-standard.js

@@ -38,7 +38,7 @@ function checkCategory(dir, category) {
   const persistentStores = new Set(OUTPUT_PERSISTENT_STORES[category] || []);
   const persistentStores = new Set(OUTPUT_PERSISTENT_STORES[category] || []);
   for (const entry of listEntries(dir)) {
   for (const entry of listEntries(dir)) {
     if (!entry.isDirectory()) continue; // latest 模式文件
     if (!entry.isDirectory()) continue; // latest 模式文件
-    if (persistentStores.has(entry.name)) continue; // 注册的持续知识库
+    if (persistentStores.has('*') || persistentStores.has(entry.name)) continue; // 注册的持续知识库
     if (!DATE_DIR_RE.test(entry.name)) {
     if (!DATE_DIR_RE.test(entry.name)) {
       problems.push(`outputs/${category}/${entry.name} 不是 YYYY-MM-DD 日期目录`);
       problems.push(`outputs/${category}/${entry.name} 不是 YYYY-MM-DD 日期目录`);
       continue;
       continue;

+ 5 - 2
claude-code/claude-code-qiwe-assistant/skill-package-manifest.json

@@ -8,18 +8,21 @@
     "dailyOffice": ["会议", "文档", "日程", "待办", "本地知识库"],
     "dailyOffice": ["会议", "文档", "日程", "待办", "本地知识库"],
     "goalManagement": ["大目标拆解", "里程碑", "会议行动项", "进度/阻塞/逾期跟踪", "企微待办衔接"],
     "goalManagement": ["大目标拆解", "里程碑", "会议行动项", "进度/阻塞/逾期跟踪", "企微待办衔接"],
     "customerService": ["真实私信监听", "客户独立 Claude Code Session", "Agent 草稿", "人工审核", "人工接管", "安全会话审阅"],
     "customerService": ["真实私信监听", "客户独立 Claude Code Session", "Agent 草稿", "人工审核", "人工接管", "安全会话审阅"],
-    "crm": ["客户档案", "客户群", "群消息", "画像", "标签", "顾问 Playbook", "客户交接"],
+    "businessOperations": ["经营驾驶舱", "经营诊断", "证据与数据缺口", "行动候选", "人工任务晋升", "行动效果反馈"],
+    "crm": ["客户档案", "客户群", "群消息", "社群 SOP", "运营计划话术", "群质检督导", "画像", "标签", "顾问 Playbook", "客户交接"],
     "foundation": ["个人版本地监听", "企业版统一 Relay", "Fmode 网关", "官方 CLI", "104 个接口清单", "MCP 工具", "Webhook/Relay", "白名单与审计"]
     "foundation": ["个人版本地监听", "企业版统一 Relay", "Fmode 网关", "官方 CLI", "104 个接口清单", "MCP 工具", "Webhook/Relay", "白名单与审计"]
   },
   },
   "skills": [
   "skills": [
     "qiwei-api-catalog",
     "qiwei-api-catalog",
     "qiwei-broker-playbook",
     "qiwei-broker-playbook",
+    "qiwei-business-diagnosis",
     "qiwei-capability-router",
     "qiwei-capability-router",
     "qiwei-customer-ops",
     "qiwei-customer-ops",
     "qiwei-customer-transfer",
     "qiwei-customer-transfer",
     "qiwei-dashboard",
     "qiwei-dashboard",
     "qiwei-goal-management",
     "qiwei-goal-management",
     "qiwei-group-management",
     "qiwei-group-management",
+    "qiwei-group-operations",
     "qiwei-login",
     "qiwei-login",
     "qiwei-official-doc",
     "qiwei-official-doc",
     "qiwei-official-meeting",
     "qiwei-official-meeting",
@@ -30,7 +33,7 @@
     "qiwei-voice",
     "qiwei-voice",
     "qiwei-webhook-relay"
     "qiwei-webhook-relay"
   ],
   ],
-  "mcpToolCount": 77,
+  "mcpToolCount": 93,
   "apiCatalogCount": 104,
   "apiCatalogCount": 104,
   "npmPackage": "fmode-qiwei",
   "npmPackage": "fmode-qiwei",
   "installCommand": "npx --yes fmode-qiwei@latest workspace --smoke",
   "installCommand": "npx --yes fmode-qiwei@latest workspace --smoke",

+ 48 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/SKILL.md

@@ -0,0 +1,48 @@
+---
+name: qiwei-business-diagnosis
+description: 基于真实企业微信客户主档、会话、画像、任务和社群运营数据诊断经营问题,输出可核验结论、证据来源、数据缺口和下一步行动,并在用户明确要求时将建议加入行动候选或记录已完成行动的内部效果反馈。用于询问重点客户、跟进优先级、客服待审核、群执行率、SOP、活动、投诉风险、画像覆盖、复购、成交、GMV 变化、建议采纳和行动效果;交易数据未接入时必须明确说明无法判断,不得编造结论。
+---
+
+# 企微经营诊断
+
+## 原则
+
+- 先调用 `qiwei_business_diagnosis`,再解释结论;不要仅凭用户问题猜测原因。
+- 区分“已证实事实”“规则建议”和“数据不足”。
+- 每个数字保留来源,并优先提供可下钻页面或相关 skill。
+- 诊断只读,不自动创建任务、修改画像、批准话术或发送消息。
+- 缺少订单、成交、GMV 或复购口径时,明确返回数据缺口,不用企微互动数据代替交易指标。
+
+## 工作流
+
+1. 明确经营问题、企微账号范围和数据日期;没有日期时使用当前数据快照。
+2. 调用 `qiwei_business_diagnosis`,传入原始问题,不先改写为带结论的问题。
+3. 按 [output-contract.md](references/output-contract.md) 检查结论、证据、数据缺口和动作是否齐全。
+4. 先回答结论和限制,再列关键证据,最后给出不超过 3 个下一步动作。
+5. 用户选择业务动作后,切换到对应 skill;任何写操作继续遵守对应 skill 的确认要求。
+6. 用户明确要求采纳某条建议时,调用 `qiwei_business_action_candidate` 加入候选池,并说明仍需在统一任务中心人工晋升。
+7. 诊断来源的正式任务完成后,用户要求复盘效果时调用 `qiwei_business_action_feedback`;未完成任务不得提前评价。
+
+## 行动闭环
+
+- `qiwei_business_action_candidate` 只创建待确认候选;不得把“加入候选”描述成正式任务已经创建。
+- 候选必须由用户在 4320“知识与任务 → 统一任务中心”确认后,才能晋升为正式内部任务。
+- `qiwei_business_action_feedback` 只接受诊断来源且状态为已完成的正式任务,结果为 `effective`、`partially_effective`、`ineffective` 或 `unknown`。
+- 反馈可以附带简短复盘依据,只写入内部台账,不发送客户消息、不创建或同步企微待办。
+- 采纳率没有候选样本、有效率没有已验证反馈样本时显示未知,不伪造为 `0%`。
+
+## 路由
+
+- 客户主档、重点跟进、画像缺口:`qiwei-customer-ops` 或 `qiwei-portrait-tags`。
+- 客服草稿、人工接管、真实会话:`qiwei-real-estate-auto-reply`。
+- 社群计划、风险、SOP 和质检:`qiwei-group-operations`。
+- 客户群未发现或消息未同步:`qiwei-group-management`。
+- 订单或经营文件接入:先放入知识库,再重新诊断。
+
+## 安全边界
+
+- 不输出原始客户 ID、手机号、内部风险标签或员工评价给外部客户。
+- 不把低画像覆盖直接解释为客户低意向。
+- 不把群消息数量直接解释为成交或复购。
+- 不把建议动作描述成已经执行。
+- 不把任务完成等同于行动有效;效果必须由人工反馈确认。

+ 4 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "企微经营诊断"
+  short_description: "基于真实企微数据诊断问题并闭环跟踪行动效果"
+  default_prompt: "使用 $qiwei-business-diagnosis 诊断当前经营问题,说明证据与数据缺口,并在我确认后跟踪行动候选和效果反馈。"

+ 45 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/references/output-contract.md

@@ -0,0 +1,45 @@
+# 经营诊断输出契约
+
+按以下顺序输出:
+
+1. **结论**:一句话说明当前可以确认什么。
+2. **数据边界**:说明账号、日期和未接入的数据。
+3. **关键证据**:最多 3 项,包含指标值和来源。
+4. **数据缺口**:说明缺口如何影响判断以及补充方式。
+5. **下一步行动**:最多 3 项,标明去哪个工作台或 skill。
+
+## 行动候选契约
+
+用户明确要求采纳建议后,候选至少保留:诊断问题、动作标题、优先级、证据、数据缺口、来源日期和稳定去重键。生命周期按顺序记录:
+
+```text
+created -> promoted -> completed | dismissed -> outcome_recorded
+```
+
+- `created` 仅表示已加入候选池。
+- `promoted` 表示经人工确认后生成正式内部任务。
+- `completed` 和 `dismissed` 由正式任务状态回写,二者不代表行动有效。
+- `outcome_recorded` 仅允许在正式任务完成后记录。
+
+## 效果反馈契约
+
+`qiwei_business_action_feedback` 接受以下结果:
+
+| 值 | 含义 |
+| --- | --- |
+| `effective` | 已验证有效 |
+| `partially_effective` | 部分有效 |
+| `ineffective` | 无效 |
+| `unknown` | 当前无法判断 |
+
+反馈保存 `taskId`、结果、可选依据、记录人和记录时间,仅供内部复盘。不得触发客户消息、正式企微待办或其他外部写操作。
+
+Dashboard 汇总口径:采纳率为已晋升候选数除以候选总数;有效率为 `effective` 数除以已验证的 `effective + partially_effective + ineffective` 数。分母为零时返回未知,不显示虚假百分比。
+
+禁止事项:
+
+- 不用客户数、消息数或画像覆盖率推导 GMV、成交率或复购率。
+- 不隐藏空数据、低覆盖或未同步状态。
+- 不把规则生成的优先级称为模型预测。
+- 不声称建议动作已经创建、同步或发送。
+- 不把 `unknown` 纳入已验证有效率分母。

+ 5 - 1
claude-code/claude-code-qiwe-assistant/skills/qiwei-capability-router/SKILL.md

@@ -21,6 +21,9 @@ description: 在真实企微会话、本地 Agent 工作台、Fmode 网关与官
 | 用户意图 | 使用通道 |
 | 用户意图 | 使用通道 |
 |---|---|
 |---|---|
 | 外部联系人真实消息、AI 草稿、人工审核/暂停/接管 | 本地 Agent 工作台 + Fmode 真实消息链路 |
 | 外部联系人真实消息、AI 草稿、人工审核/暂停/接管 | 本地 Agent 工作台 + Fmode 真实消息链路 |
+| 重点客户、客服积压、群执行、投诉风险、成交或 GMV 等经营问题 | `qiwei-business-diagnosis`;先只读诊断并明确数据缺口 |
+| 将诊断建议加入行动中心 | `qiwei_business_action_candidate` 创建候选;在 4320 统一任务中心人工晋升 |
+| 复盘已完成诊断行动是否有效 | `qiwei_business_action_feedback`;只记录内部反馈,不触发外部发送 |
 | 统一查看客户跟进、目标、文档/会议行动项 | 4320 统一任务中心;正式同步企微时再按需使用官方待办 |
 | 统一查看客户跟进、目标、文档/会议行动项 | 4320 统一任务中心;正式同步企微时再按需使用官方待办 |
 | 普通文档、在线表格、智能表格、智能文档 | 企业微信官方 CLI |
 | 普通文档、在线表格、智能表格、智能文档 | 企业微信官方 CLI |
 | 会议、日程、待办 | 企业微信官方 CLI |
 | 会议、日程、待办 | 企业微信官方 CLI |
@@ -33,7 +36,8 @@ description: 在真实企微会话、本地 Agent 工作台、Fmode 网关与官
 ## 本地任务中枢
 ## 本地任务中枢
 
 
 - 客户会话产生的任务保存在本地 SQLite,保留会话、证据、状态和官方待办绑定关系;
 - 客户会话产生的任务保存在本地 SQLite,保留会话、证据、状态和官方待办绑定关系;
-- 文档和会议 AI 行动项默认只是候选,人工确认后才能转为正式内部任务;
+- 文档、会议和经营诊断 AI 行动项默认只是候选,人工确认后才能转为正式内部任务;
+- 诊断任务完成后可记录有效、部分有效、无效或暂无法判断;任务完成本身不等于行动有效;
 - 对外部联系人只提供“转到智能会话”,由 Agent 结合上下文拟定回复,不能把内部任务标题直接发给客户;
 - 对外部联系人只提供“转到智能会话”,由 Agent 结合上下文拟定回复,不能把内部任务标题直接发给客户;
 - 官方成员搜索或某个 CLI 分类不可用时,本地任务仍可创建、推进和复盘。
 - 官方成员搜索或某个 CLI 分类不可用时,本地任务仍可创建、推进和复盘。
 
 

+ 20 - 3
claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md

@@ -1,6 +1,6 @@
 ---
 ---
 name: qiwei-dashboard
 name: qiwei-dashboard
-description: 启动并预览 Qiwei Dashboard(本地 Web 操作界面),包括真实企微 Agent、人工监管、统一技能中心和本地知识库。用户说“启动企微助手”“打开企微工作台”“启动并预览”时,优先调用 qiwei_agent_dashboard_start,并一次返回鉴权、席位、企微在线、白名单和监听状态。
+description: 启动并预览 Qiwei Dashboard(本地 Web 操作界面),包括经营驾驶舱、真实企微 Agent、社群与客户运营、经营诊断、统一任务中心、行动效果闭环、技能中心和本地知识库。用户说“启动企微助手”“打开企微工作台”“启动并预览”时,优先调用 qiwei_agent_dashboard_start,并一次返回鉴权、席位、企微在线、白名单和监听状态;同时支持查看经营总览、诊断业务问题、管理内部任务、浏览技能包和知识文件
 ---
 ---
 
 
 # Qiwei Dashboard
 # Qiwei Dashboard
@@ -14,6 +14,9 @@ description: 启动并预览 Qiwei Dashboard(本地 Web 操作界面),包
 - 在登录失效时引导用户恢复登录;
 - 在登录失效时引导用户恢复登录;
 - 浏览已接入的源码版、OpenClaw、Agent Workbench 和房源匹配能力;
 - 浏览已接入的源码版、OpenClaw、Agent Workbench 和房源匹配能力;
 - 以只读方式预览 Agent 规则、技能说明、房源数据和运行输出;
 - 以只读方式预览 Agent 规则、技能说明、房源数据和运行输出;
+- 展示经营总览、诊断证据、数据缺口、今日行动和数字员工状态;
+- 在统一任务中心聚合客户跟进、目标、官方待办及文档/会议/诊断候选;
+- 对已完成的诊断行动记录仅供内部使用的效果反馈;
 - 告诉用户访问地址 `http://127.0.0.1:4320/`。
 - 告诉用户访问地址 `http://127.0.0.1:4320/`。
 
 
 业务操作(群管理、客户运营、画像标签等)由用户在浏览器里完成,或通过其他专门 skill 调用 MCP 工具完成。本 skill 不替代登录、订阅、业务工具 skill。
 业务操作(群管理、客户运营、画像标签等)由用户在浏览器里完成,或通过其他专门 skill 调用 MCP 工具完成。本 skill 不替代登录、订阅、业务工具 skill。
@@ -103,13 +106,14 @@ Qiwei Dashboard 已启动:http://127.0.0.1:4320/
 
 
 ## 技能中心与知识库
 ## 技能中心与知识库
 
 
+- 打开 `http://127.0.0.1:4320/#overview` 查看经营驾驶舱,并从客户、社群、客服和交易类问题发起只读诊断。
 - 打开 `http://127.0.0.1:4320/#skills` 查看统一技能目录、按关键词搜索能力并切换来源包。
 - 打开 `http://127.0.0.1:4320/#skills` 查看统一技能目录、按关键词搜索能力并切换来源包。
-- 打开 `http://127.0.0.1:4320/#knowledge` 以文件夹树浏览本地知识文件;点击 `properties.json` 进入房源筛选、卡片列表和单套详情
+- 打开 `http://127.0.0.1:4320/#knowledge` 以文件夹树浏览本地知识文件;选择“统一任务工作台”查看正式任务、AI 候选和经营诊断行动闭环
 - 知识目录由 `knowledge-base/catalog.json` 配置;新增受支持的本地目录后重载页面即可扫描,不要把 Token、`.env.local` 或其他凭据目录加入目录表。
 - 知识目录由 `knowledge-base/catalog.json` 配置;新增受支持的本地目录后重载页面即可扫描,不要把 Token、`.env.local` 或其他凭据目录加入目录表。
 - 默认只读预览 `.md`、`.json`、`.csv`、`.txt` 和 `.js`;禁止通过文件节点访问配置目录之外的路径。
 - 默认只读预览 `.md`、`.json`、`.csv`、`.txt` 和 `.js`;禁止通过文件节点访问配置目录之外的路径。
 - 知识库运行输出放入 `outputs/knowledge/`,不要直接写入外部技能包的源码目录。
 - 知识库运行输出放入 `outputs/knowledge/`,不要直接写入外部技能包的源码目录。
 
 
-可用的只读接口:
+核心只读接口:
 
 
 ```text
 ```text
 GET /api/skills
 GET /api/skills
@@ -117,8 +121,21 @@ GET /api/knowledge/tree
 GET /api/knowledge/file?id=<node-id>
 GET /api/knowledge/file?id=<node-id>
 GET /api/knowledge/properties
 GET /api/knowledge/properties
 GET /api/knowledge/properties/<property-id>
 GET /api/knowledge/properties/<property-id>
+GET /api/knowledge/tasks
+POST /api/business-diagnosis
 ```
 ```
 
 
+需要内部写入的接口:
+
+```text
+POST /api/knowledge/tasks/diagnosis-candidate
+POST /api/knowledge/tasks/create
+POST /api/knowledge/tasks/update
+POST /api/knowledge/tasks/diagnosis-feedback
+```
+
+`diagnosis-candidate` 只加入待确认候选;`create` 用于人工确认后晋升正式内部任务;`diagnosis-feedback` 只记录已完成诊断任务的内部效果。上述接口均不得被描述为已经发送客户消息,真实企微待办仍需独立确认。
+
 ## 端口与环境变量
 ## 端口与环境变量
 
 
 - 默认端口:`4320`
 - 默认端口:`4320`

+ 79 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/SKILL.md

@@ -0,0 +1,79 @@
+---
+name: qiwei-group-operations
+description: 统一管理企业微信客户群运营标准、SOP 版本、每日及批量计划、群话术、执行审核、消息质检、整改任务、门店对比、管理简报和受控自动化。用于制定或发布群运营规范、预览版本影响、批量生成群计划、分析活跃趋势、处理预警、同步整改待办,或配置群白名单、发送时间窗、频控、灰度与熔断并执行 dry-run;所有真实外发、SOP 发布、自动化激活和官方待办同步必须人工确认。
+---
+
+# 企微社群运营
+
+## 核心原则
+
+- 先读取当前账号和群运营上下文,再生成计划、话术或结论。
+- 把“公司标准 → 群计划 → 人工审核 → 执行结果 → 质检证据”串成闭环。
+- 只根据真实计划、消息和规则给出执行率或风险,不编造群活跃度与员工绩效。
+- 保留 SOP 版本、原消息证据、操作者和审计 ID。
+- 默认只生成草稿。发布、回滚、批量批准和真实外发前必须展示目标与最终内容并取得明确确认。
+- 当前真实自动发送未开放;不得绕过 `qiwei_group_ops_execute_item` 的安全边界调用通用 API 发送草稿。
+
+## 标准工作流
+
+### 制定或更新 SOP
+
+1. 读取 [data-model.md](references/data-model.md) 和现有 SOP。
+2. 明确群类型、生命周期、节奏、必做项、禁止项、响应 SLA 与适用范围。
+3. 调用 `qiwei_group_ops_manage_playbook` 创建草稿或新版本。
+4. 展示变更摘要和受影响范围。
+5. 用户确认后再调用 `publish` 或 `rollback`。
+
+### 生成运营计划与话术
+
+1. 调用 `qiwei_group_ops_get_context` 获取群、已发布 SOP、近期计划和风险。
+2. 避免重复已有计划项,缺少 SOP 时先提示绑定或显式提供计划项。
+3. 调用 `qiwei_group_ops_generate_plan` 生成待审核计划。
+4. 需要替换变量或检查话术时调用 `qiwei_group_ops_generate_copy`。
+5. 汇总目标群、计划日期、待审核项、缺失变量和风险。
+6. 用户批准后调用 `qiwei_group_ops_execute_item` 的 `approve`;人工实际发送后可用 `mark_sent` 留痕。
+
+### 质检与督导
+
+1. 读取 [quality-rules.md](references/quality-rules.md)。
+2. 调用 `qiwei_group_ops_review_messages` 检查投诉风险与超时未回应。
+3. 每个结论列出严重程度、证据消息、规则版本和建议动作。
+4. 不把低置信模型判断直接当员工违规。
+5. 使用 `qiwei_group_ops_update_finding` 认领、关闭或标记误报。
+
+### 每日管理简报
+
+调用 `qiwei_group_ops_daily_brief`,优先呈现:高风险未闭环、待审核、逾期/未完成、执行率和数据覆盖缺口。所有数字应可下钻到计划项或质检证据。
+
+### 批量运营与门店督导
+
+1. 调用 `qiwei_group_ops_insights` 查看门店执行差异、群活跃趋势和生命周期建议。
+2. 先展示目标门店、群数量和计划日期,再调用 `qiwei_group_ops_batch_generate_plans`。
+3. 失败群与成功群分开报告;不要重复生成已存在的同群同日计划。
+4. 对质检项创建整改任务时调用 `qiwei_group_ops_manage_task`。
+5. 同步真实企微待办前,确认准确负责人、截止时间和任务内容;同名成员不自动选择。
+
+### SOP 发布与回滚
+
+先用 `qiwei_group_ops_manage_playbook` 的 `versions` 和 `preview` 查看版本差异、影响群和影响门店。展示影响摘要并取得确认后,才调用 `publish` 或 `rollback`。
+
+### 受控自动化
+
+1. 先读取 [automation-safety.md](references/automation-safety.md)。
+2. 调用 `qiwei_group_ops_automation` 的 `create_policy` 创建不可变策略草稿。
+3. 展示群白名单、内容类型、时间窗、频控、灰度和熔断阈值;用户确认后才能 `activate`。
+4. 激活后保持暂停。先执行 `preview` 和 `run` dry-run,确认拦截原因符合预期。
+5. `resume`、熔断复位和任何真实执行都要求再次确认。
+6. 不得建议绕过 `QIWEI_GROUP_OPS_LIVE_SEND`;未完成 live smoke 时保持关闭。
+7. 发现投诉、高风险、未替换变量、禁止承诺、重复发送或频控超限时必须拦截。
+
+## 输出规范
+
+读取 [output-contracts.md](references/output-contracts.md) 后组织计划、话术、质检或简报。不要向客户发送内部风险标签、员工评价、原始客户 ID 或内部任务描述。
+
+## 相关能力
+
+- 群尚未发现或确认:使用 `qiwei-group-management`。
+- 需要同步群历史消息:先使用 `qiwei_sync_group_messages`。
+- 账号离线:转用 `qiwei-login`。
+- 客户档案和建群:转用 `qiwei-customer-ops`。

+ 4 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "企微社群运营"
+  short_description: "统一管理客户群SOP、计划质检、整改任务与受控自动化能力"
+  default_prompt: "使用 $qiwei-group-operations 为白名单客户群配置安全的运营策略,先执行 dry-run,并汇总所有通过与拦截原因。"

+ 16 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/automation-safety.md

@@ -0,0 +1,16 @@
+# 受控自动化安全规则
+
+真实执行必须同时满足:
+
+1. 当前账号存在已激活且 `enabled=true` 的策略。
+2. 自动化未暂停,熔断器未打开。
+3. 计划项已经人工批准且到达计划时间。
+4. 目标群在显式白名单内,内容类型在允许列表内,并命中灰度范围。
+5. 当前上海时间位于允许时间窗。
+6. 群没有未闭环高风险质检项。
+7. 最终话术非空、不含模板变量、不命中禁止承诺。
+8. 同一幂等键没有成功发送记录,单群日/周频控未超限。
+9. 策略模式为 `auto`,调用明确传入 `execute=true` 与 `confirmed=true`。
+10. 环境变量 `QIWEI_GROUP_OPS_LIVE_SEND=1` 已由实施负责人显式开启。
+
+策略激活后必须保持暂停,先通过 dry-run 再人工恢复。连续失败达到阈值时自动熔断;熔断只能由人工确认复位。每次预演和发送保存策略版本、决策、回执、错误和审计记录。

+ 29 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/data-model.md

@@ -0,0 +1,29 @@
+# 数据与 SOP 约定
+
+## SOP content
+
+```json
+{
+  "cadence": [
+    {
+      "time": "09:30",
+      "type": "welcome",
+      "objective": "完成新客入群欢迎和服务说明",
+      "template": "欢迎加入 {{room_name}},今天由您的专属顾问为大家服务。"
+    }
+  ],
+  "rules": {
+    "responseSlaMinutes": 120,
+    "riskTerms": ["投诉", "退款", "举报"],
+    "forbiddenClaims": ["保证", "百分百", "最低价"]
+  }
+}
+```
+
+计划状态:`draft → pending_review → approved → sent`,失败或不执行时使用 `rejected`、`skipped`。同群同日期重复生成应返回已有计划,不能重复创建。
+
+所有工具都应传入当前账号的 `accountKey` 或 `guid`。不允许跨账号复用 `roomId`、SOP 或质检结果。
+
+整改任务状态:`open → in_progress → done`,也可 `cancelled`。只有内部负责人精确匹配且人工确认时,才写入 `official_todo_id`。
+
+SOP 发布前读取版本列表和影响预览。历史计划继续保留原 `playbook_version_id`,回滚不改写历史计划。

+ 24 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/output-contracts.md

@@ -0,0 +1,24 @@
+# 输出契约
+
+## 运营计划
+
+- 群与日期
+- 采用的 SOP 及版本
+- 每项的时间、目标、话术、状态
+- 缺失变量与风险
+- 明确提示“待人工审核”
+
+## 质检结果
+
+- 高风险项优先
+- 类型、严重程度、证据、响应时限
+- 建议负责人和动作
+- 数据覆盖缺口
+
+## 每日简报
+
+- 今日任务:应执行、已完成、待审核
+- 风险:高风险与未闭环总数
+- 群范围与数据日期
+- 下一步动作,不输出不可解释的员工排名
+

+ 18 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/quality-rules.md

@@ -0,0 +1,18 @@
+# 质检规则
+
+## 确定性规则
+
+- 客户消息包含问号或“怎么、如何、多少、什么时候、可以吗、有没有”等明确问句,并在 SLA 内无员工回应:生成 `unanswered_question`。
+- 消息命中投诉、退款、举报、律师、赔偿等配置词:生成 `service_risk`。
+- 模板仍含 `{{variable}}` 或命中保证性承诺:阻止批准或发送。
+- 消息同步中断或证据不完整时标记数据不足,不按 0 分评价。
+
+## 证据要求
+
+每条质检项至少保存 `messageId`、内容片段和发送时间。风险严重程度、处理状态和误报反馈必须留痕。一个消息触发同一规则时使用稳定指纹去重。
+
+账号级设置包括响应 SLA、风险词和禁止承诺。修改设置必须审计;标记 `false_positive` 时把反馈保存到评测集,用于后续阈值校准,不删除原质检记录。
+
+## 边界
+
+确定性规则可以直接形成待处理项;语义分类只能作为建议并带置信度。不要根据消息数量直接判断员工勤奋或群质量。

+ 17 - 0
fix_regex.py

@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'r', encoding='utf-8') as f:
+    content = f.read()
+
+# 修复 2400-2401 行的 split 正则
+old = """      const externalUserIds = document.getElementById('new-tf-external').value.split(/
+|/).map(s => s.trim()).filter(Boolean);"""
+
+new = """      const externalUserIds = document.getElementById('new-tf-external').value.split(/\\n|\\r/).map(s => s.trim()).filter(Boolean);"""
+
+content = content.replace(old, new)
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'w', encoding='utf-8') as f:
+    f.write(content)
+
+print('fixed')

+ 22 - 0
fix_regex2.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'r', encoding='utf-8') as f:
+    lines = f.readlines()
+
+# 找到并修复所有被拆分的 split(/\n|\r/)
+i = 0
+while i < len(lines):
+    line = lines[i]
+    # 匹配 .split(/ 后跟换行,下一行是 |,再下一行是 /)
+    if '.split(/' in line and line.rstrip().endswith('split(/'):
+        if i + 2 < len(lines) and lines[i+1].strip() == '|' and lines[i+2].strip().startswith('/).map'):
+            indent = line[:len(line) - len(line.lstrip())]
+            lines[i] = line.replace('split(/', 'split(/\\n|\\r/')
+            del lines[i+1:i+3]
+            continue
+    i += 1
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'w', encoding='utf-8') as f:
+    f.writelines(lines)
+
+print('fixed split regex')

+ 374 - 0
replace_customer_ops.py

@@ -0,0 +1,374 @@
+# -*- coding: utf-8 -*-
+
+new_customer_ops = '''  async function renderCustomerOpsPage() {
+    els.pageTitle.textContent = '客户运营看板';
+    const page = document.createElement('div');
+    page.className = 'page';
+
+    page.innerHTML = `
+      <div class="dashboard-grid" id="co-dashboard">
+        <div class="kpi-row" id="co-kpi-row"></div>
+        <div class="chart-card">
+          <h3 class="chart-title"><span>好友状态分布</span><span class="chart-subtitle">全部客户</span></h3>
+          <div class="chart-container" id="co-friend-chart"></div>
+        </div>
+        <div class="chart-card">
+          <h3 class="chart-title"><span>近7日运营趋势</span><span class="chart-subtitle">加好友 / 建群</span></h3>
+          <div class="chart-container" id="co-trend-chart"></div>
+        </div>
+        <div class="operation-toolbar">
+          <div class="toolbar-left">
+            <button class="btn" id="btn-import-customers">导入客户</button>
+            <button class="btn btn-secondary" id="btn-co-refresh">刷新</button>
+            <button class="btn btn-secondary" id="btn-co-rebuild">重建索引</button>
+          </div>
+          <div class="toolbar-right">
+            <button class="btn btn-sm" id="btn-batch-add" data-job="batch-add-friends" disabled>批量加好友</button>
+            <button class="btn btn-sm btn-secondary" id="btn-check-friends" data-job="check-friend-status" disabled>检查好友状态</button>
+            <button class="btn btn-sm btn-secondary" id="btn-auto-group" data-job="auto-create-group" disabled>为通过好友建群</button>
+          </div>
+        </div>
+        <div class="data-table-card">
+          <div class="card-header">
+            <div>
+              <h3 class="card-title">客户列表</h3>
+              <div class="card-subtitle">支持按好友状态、画像、标签筛选</div>
+            </div>
+          </div>
+          <div class="filter-bar" style="margin-bottom:16px">
+            <input id="co-filter-keyword" type="text" placeholder="搜索手机号/姓名/externalUserId" style="width:220px" />
+            <select id="co-filter-status">
+              <option value="">全部好友状态</option>
+              <option value="ACCEPTED">已是好友</option>
+              <option value="PENDING">待通过</option>
+              <option value="NOT_FOUND">未找到</option>
+              <option value="FAILED">失败</option>
+            </select>
+            <select id="co-filter-portrait">
+              <option value="">全部画像状态</option>
+              <option value="true">已生成画像</option>
+              <option value="false">未生成画像</option>
+            </select>
+            <select id="co-filter-tag">
+              <option value="">全部标签</option>
+            </select>
+          </div>
+          <div id="co-customer-table-wrap"></div>
+          <div id="co-pagination"></div>
+        </div>
+      </div>
+    `;
+    els.content.appendChild(page);
+
+    await loadCustomerOpsDashboard(page);
+    dashboardRefreshTimer = setInterval(() => {
+      if (state.page === 'customer-ops') loadCustomerOpsDashboard(page, true);
+    }, 30000);
+
+    page.querySelector('#btn-co-refresh').addEventListener('click', () => loadCustomerOpsDashboard(page));
+    page.querySelector('#btn-co-rebuild').addEventListener('click', async () => {
+      try {
+        await api('POST', '/api/dashboard/rebuild');
+        toast('索引已重建');
+        loadCustomerOpsDashboard(page);
+      } catch (err) {
+        toast(err.message || '重建失败', 'error');
+      }
+    });
+    page.querySelector('#btn-import-customers').addEventListener('click', () => openImportCustomersModal(page));
+
+    page.querySelector('#co-filter-keyword').addEventListener('input', () => loadCustomerOpsTable(page));
+    page.querySelector('#co-filter-status').addEventListener('change', () => loadCustomerOpsTable(page));
+    page.querySelector('#co-filter-portrait').addEventListener('change', () => loadCustomerOpsTable(page));
+    page.querySelector('#co-filter-tag').addEventListener('change', () => loadCustomerOpsTable(page));
+
+    page.querySelector('#btn-batch-add').addEventListener('click', () => {
+      const selected = getSelectedCustomers(page);
+      if (!selected.length) return;
+      const body = { customers: selected.map(c => ({ phone: c.phone, name: c.name })), guid: getGuid() };
+      startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', body, () => loadCustomerOpsDashboard(page));
+    });
+    page.querySelector('#btn-check-friends').addEventListener('click', () => {
+      const selected = getSelectedCustomers(page);
+      if (!selected.length) return;
+      const body = { phones: selected.map(c => c.phone).filter(Boolean), guid: getGuid() };
+      startJob('check-friend-status', '/api/customer-ops/check-friend-status', body, () => loadCustomerOpsDashboard(page));
+    });
+    page.querySelector('#btn-auto-group').addEventListener('click', () => {
+      const selected = getSelectedCustomers(page).filter(c => c.friendRequestStatus === 'ACCEPTED' && c.groupStatus !== 'CREATED');
+      if (!selected.length) {
+        toast('请选中已是好友且未建群的客户', 'warning');
+        return;
+      }
+      const body = { memberList: selected.map(c => c.externalUserId).filter(Boolean), guid: getGuid() };
+      startJob('auto-create-group', '/api/customer-ops/auto-create-group', body, () => loadCustomerOpsDashboard(page));
+    });
+  }
+
+  async function loadCustomerOpsDashboard(page, silent = false) {
+    if (!silent) page.querySelector('#co-customer-table-wrap').innerHTML = '<div class="skeleton" style="height:200px"></div>';
+    try {
+      const [summaryRes, customersRes, tagsRes] = await Promise.all([
+        api('GET', '/api/dashboard/summary'),
+        api('GET', '/api/dashboard/customers'),
+        api('GET', '/api/dashboard/tags')
+      ]);
+      const summary = summaryRes.data || {};
+      const customers = Object.values(customersRes.data?.customers || {});
+      const tags = tagsRes.data?.allTags || [];
+
+      renderCustomerOpsKpi(page, summary);
+      renderCustomerOpsCharts(page, summary, customers);
+      renderCustomerOpsTagFilter(page, tags);
+      state.customerOps.allCustomers = customers;
+      loadCustomerOpsTable(page);
+    } catch (err) {
+      if (!silent) toast(err.message || '加载看板失败', 'error');
+    }
+  }
+
+  function renderCustomerOpsKpi(page, summary) {
+    const wrap = page.querySelector('#co-kpi-row');
+    const c = summary.customers || {};
+    const friendStatus = c.friendStatusCounts || {};
+    const kpi = [
+      { label: '本地客户总数', value: formatNumber(c.total) },
+      { label: '已是好友', value: formatNumber(friendStatus.ACCEPTED || 0), sub: '已通过好友申请' },
+      { label: '待通过', value: formatNumber(friendStatus.PENDING || 0), sub: '等待对方确认' },
+      { label: '自动建群', value: formatNumber(c.autoCreatedGroups || 0), sub: '已创建客户群' },
+      { label: '画像覆盖率', value: (c.portraitCoverage || 0) + '%', sub: '已生成画像客户占比' },
+      { label: '今日新增', value: formatNumber(friendStatus.ACCEPTED || 0), sub: '今日通过好友' }
+    ];
+    wrap.innerHTML = renderKpiRow(kpi);
+  }
+
+  function renderCustomerOpsCharts(page, summary, customers) {
+    const friendStatus = summary.customers?.friendStatusCounts || {};
+    initEChart('co-friend-chart', {
+      tooltip: { trigger: 'item' },
+      legend: { bottom: 0 },
+      series: [{
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '45%'],
+        data: [
+          { name: '已是好友', value: friendStatus.ACCEPTED || 0, itemStyle: { color: '#52c41a' } },
+          { name: '待通过', value: friendStatus.PENDING || 0, itemStyle: { color: '#faad14' } },
+          { name: '未找到', value: friendStatus.NOT_FOUND || 0, itemStyle: { color: '#ff4d4f' } },
+          { name: '失败', value: friendStatus.FAILED || 0, itemStyle: { color: '#722ed1' } },
+          { name: '未知', value: friendStatus.UNKNOWN || 0, itemStyle: { color: '#8c8c8c' } }
+        ]
+      }]
+    });
+
+    const days = last7Days();
+    const addCounts = countByDay(customers.filter(c => c.lastAddAttemptAt), 'lastAddAttemptAt');
+    const groupCounts = countByDay(customers.filter(c => c.autoCreatedAt), 'autoCreatedAt');
+    initEChart('co-trend-chart', {
+      tooltip: { trigger: 'axis' },
+      legend: { bottom: 0 },
+      xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
+      yAxis: { type: 'value' },
+      series: [
+        { name: '加好友', type: 'bar', data: addCounts, itemStyle: { color: '#fa8c16' } },
+        { name: '建群', type: 'line', data: groupCounts, itemStyle: { color: '#1890ff' } }
+      ]
+    });
+  }
+
+  function renderCustomerOpsTagFilter(page, tags) {
+    const select = page.querySelector('#co-filter-tag');
+    const current = select.value;
+    select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
+    select.value = current;
+  }
+
+  function getSelectedCustomers(page) {
+    const checkboxes = page.querySelectorAll('#co-customer-table-wrap input[type="checkbox"]:checked');
+    return Array.from(checkboxes).map(cb => {
+      const idx = Number(cb.dataset.index);
+      return state.customerOps.filteredCustomers?.[idx];
+    }).filter(Boolean);
+  }
+
+  function loadCustomerOpsTable(page) {
+    const wrap = page.querySelector('#co-customer-table-wrap');
+    const customers = state.customerOps.allCustomers || [];
+    const keyword = page.querySelector('#co-filter-keyword').value.trim().toLowerCase();
+    const status = page.querySelector('#co-filter-status').value;
+    const portrait = page.querySelector('#co-filter-portrait').value;
+    const tag = page.querySelector('#co-filter-tag').value;
+
+    let filtered = customers.filter(c => {
+      if (keyword) {
+        const text = `${c.phone || ''} ${c.name || ''} ${c.externalUserId || ''}`.toLowerCase();
+        if (!text.includes(keyword)) return false;
+      }
+      if (status && c.friendRequestStatus !== status) return false;
+      if (portrait === 'true' && !c.hasPortrait) return false;
+      if (portrait === 'false' && c.hasPortrait) return false;
+      if (tag && !(Array.isArray(c.tags) && c.tags.includes(tag))) return false;
+      return true;
+    });
+
+    state.customerOps.filteredCustomers = filtered;
+    const pageSize = 20;
+    const currentPage = state.customerOps.tablePage || 1;
+    const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
+    if (currentPage > totalPages) state.customerOps.tablePage = totalPages;
+    const start = (state.customerOps.tablePage - 1) * pageSize;
+    const pageItems = filtered.slice(start, start + pageSize);
+
+    const friendBadge = (status) => {
+      const map = {
+        ACCEPTED: ['badge-success', '已是好友'],
+        PENDING: ['badge-warning', '待通过'],
+        NOT_FOUND: ['badge-error', '未找到'],
+        FAILED: ['badge-error', '失败']
+      };
+      const [cls, text] = map[status] || ['badge-default', '未知'];
+      return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
+    };
+
+    wrap.innerHTML = `
+      <div class="table-wrap">
+        <table>
+          <thead>
+            <tr>
+              <th><input type="checkbox" id="co-select-all"></th>
+              <th>手机号</th>
+              <th>姓名</th>
+              <th>externalUserId</th>
+              <th>好友状态</th>
+              <th>建群状态</th>
+              <th>画像</th>
+              <th>标签</th>
+              <th>最后操作</th>
+              <th>操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            ${pageItems.length ? pageItems.map((c, i) => `
+              <tr>
+                <td><input type="checkbox" data-index="${start + i}"></td>
+                <td>${escapeHtml(c.phone || '-')}</td>
+                <td>${escapeHtml(c.name || '-')}</td>
+                <td style="font-family:monospace;font-size:12px">${escapeHtml(c.externalUserId || '-')}</td>
+                <td>${friendBadge(c.friendRequestStatus)}</td>
+                <td>${c.groupStatus === 'CREATED' ? '<span class="badge badge-success">已建群</span>' : '<span class="badge badge-default">未建群</span>'}</td>
+                <td>${c.hasPortrait ? '<span class="badge badge-info">已生成</span>' : '<span class="badge badge-default">未生成</span>'}</td>
+                <td>${Array.isArray(c.tags) && c.tags.length ? c.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
+                <td>${formatDateTime(c.updatedAt)}</td>
+                <td>
+                  <button class="btn btn-sm btn-secondary" onclick="window.dashboardCustomerDetail('${escapeHtml(c.externalUserId || c.phone || '')}')">详情</button>
+                </td>
+              </tr>
+            `).join('') : '<tr><td colspan="10" style="text-align:center;color:var(--text-muted);padding:40px">暂无客户数据</td></tr>'}
+          </tbody>
+        </table>
+      </div>
+    `;
+
+    const pagination = page.querySelector('#co-pagination');
+    pagination.innerHTML = renderPagination(state.customerOps.tablePage, filtered.length, pageSize);
+    pagination.querySelectorAll('button').forEach(btn => {
+      btn.addEventListener('click', () => {
+        state.customerOps.tablePage = Number(btn.dataset.page);
+        loadCustomerOpsTable(page);
+      });
+    });
+
+    const selectAll = wrap.querySelector('#co-select-all');
+    const rowCheckboxes = wrap.querySelectorAll('tbody input[type="checkbox"]');
+    selectAll?.addEventListener('change', () => {
+      rowCheckboxes.forEach(cb => cb.checked = selectAll.checked);
+      updateCustomerOpsActionButtons(page);
+    });
+    rowCheckboxes.forEach(cb => cb.addEventListener('change', () => updateCustomerOpsActionButtons(page)));
+    updateCustomerOpsActionButtons(page);
+  }
+
+  function updateCustomerOpsActionButtons(page) {
+    const selected = getSelectedCustomers(page);
+    page.querySelector('#btn-batch-add').disabled = !selected.length;
+    page.querySelector('#btn-check-friends').disabled = !selected.length;
+    page.querySelector('#btn-auto-group').disabled = !selected.some(c => c.friendRequestStatus === 'ACCEPTED' && c.groupStatus !== 'CREATED');
+  }
+
+  function openImportCustomersModal(page) {
+    const body = `
+      <div class="form-group" style="margin-bottom:16px">
+        <label>粘贴客户列表(每行:手机号 姓名)</label>
+        <textarea id="import-customer-text" rows="6" placeholder="13800138000 张三"></textarea>
+      </div>
+      <div class="form-group" style="margin-bottom:16px">
+        <label>或上传 Excel</label>
+        <input type="file" id="import-customer-file" accept=".xlsx,.xls" />
+      </div>
+      <div class="form-group">
+        <label>默认验证消息</label>
+        <input id="import-greeting" type="text" value="${escapeHtml(state.customerOps.defaultGreeting)}" />
+      </div>
+    `;
+    openModal('导入客户', body, `
+      <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
+      <button class="btn" id="btn-confirm-import">导入</button>
+    `);
+    document.getElementById('btn-confirm-import').addEventListener('click', async () => {
+      const text = document.getElementById('import-customer-text').value;
+      const file = document.getElementById('import-customer-file').files[0];
+      const greeting = document.getElementById('import-greeting').value;
+      let customers = parseCustomerText(text);
+      if (file) {
+        const base64 = await readFileBase64(file);
+        const upload = await api('POST', '/api/upload', { data: base64, name: file.name });
+        const filePath = upload.data?.path;
+        if (filePath) {
+          const res = await api('POST', '/api/customer-ops/batch-add-friends', { filePath, defaultGreeting: greeting, guid: getGuid() });
+          toast(res.assistantMessage || '导入任务已启动');
+          closeModal();
+          loadCustomerOpsDashboard(page);
+          return;
+        }
+      }
+      if (!customers.length) {
+        toast('请输入有效客户', 'warning');
+        return;
+      }
+      closeModal();
+      startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', { customers, defaultGreeting: greeting, guid: getGuid() }, () => loadCustomerOpsDashboard(page));
+    });
+  }
+
+  window.dashboardCustomerDetail = async (externalUserId) => {
+    if (!externalUserId) return;
+    try {
+      const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() });
+      const data = res.data || {};
+      const portrait = data.portrait;
+      openModal('客户详情', `
+        <div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">
+          <div><strong>externalUserId:</strong>${escapeHtml(externalUserId)}</div>
+          <div><strong>匹配联系人:</strong>${formatNumber(data.contactList?.length || 0)}</div>
+          <div><strong>相关群:</strong>${formatNumber(data.rooms?.length || 0)}</div>
+        </div>
+        <div class="detail-panel">${escapeHtml(JSON.stringify(portrait || data.contact || {}, null, 2))}</div>
+      `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
+    } catch (err) {
+      toast(err.message || '查询失败', 'error');
+    }
+  };
+'''
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'r', encoding='utf-8') as f:
+    lines = f.readlines()
+
+start = 1425
+end = 1865
+
+new_lines = lines[:start] + [new_customer_ops + '\n'] + lines[end:]
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'w', encoding='utf-8') as f:
+    f.writelines(new_lines)
+
+print('renderCustomerOpsPage replaced successfully')

+ 385 - 0
replace_portraits.py

@@ -0,0 +1,385 @@
+# -*- coding: utf-8 -*-
+
+new_portraits = '''  async function renderPortraitsPage() {
+    els.pageTitle.textContent = '画像与标签看板';
+    const page = document.createElement('div');
+    page.className = 'page';
+
+    page.innerHTML = `
+      <div class="dashboard-grid" id="pt-dashboard">
+        <div class="kpi-row" id="pt-kpi-row"></div>
+        <div class="chart-card third">
+          <h3 class="chart-title"><span>画像覆盖率</span></h3>
+          <div class="chart-container small" id="pt-coverage-chart"></div>
+        </div>
+        <div class="chart-card third">
+          <h3 class="chart-title"><span>画像来源分布</span></h3>
+          <div class="chart-container small" id="pt-source-chart"></div>
+        </div>
+        <div class="chart-card third">
+          <h3 class="chart-title"><span>Top 标签</span></h3>
+          <div class="chart-container small" id="pt-tags-chart"></div>
+        </div>
+        <div class="chart-card full">
+          <h3 class="chart-title"><span>近7日画像生成趋势</span></h3>
+          <div class="chart-container" id="pt-trend-chart"></div>
+        </div>
+        <div class="operation-toolbar">
+          <div class="toolbar-left">
+            <button class="btn" id="btn-pt-batch" data-job="batch-portraits">批量生成画像</button>
+            <button class="btn btn-secondary" id="btn-pt-tag">为客户打标签</button>
+            <button class="btn btn-secondary" id="btn-pt-export">导出全部画像</button>
+          </div>
+          <div class="toolbar-right">
+            <button class="btn btn-sm btn-secondary" id="btn-pt-refresh">刷新</button>
+            <button class="btn btn-sm btn-secondary" id="btn-pt-rebuild">重建索引</button>
+          </div>
+        </div>
+        <div class="data-table-card">
+          <div class="card-header">
+            <div>
+              <h3 class="card-title">客户画像列表</h3>
+              <div class="card-subtitle">支持按画像状态、来源、标签筛选</div>
+            </div>
+          </div>
+          <div class="filter-bar" style="margin-bottom:16px">
+            <input id="pt-filter-keyword" type="text" placeholder="搜索 externalUserId / 姓名" style="width:220px" />
+            <select id="pt-filter-source">
+              <option value="">全部来源</option>
+              <option value="keyword">关键词</option>
+              <option value="agent">AI</option>
+            </select>
+            <select id="pt-filter-tag">
+              <option value="">全部标签</option>
+            </select>
+          </div>
+          <div id="pt-portrait-table-wrap"></div>
+          <div id="pt-pagination"></div>
+        </div>
+      </div>
+    `;
+    els.content.appendChild(page);
+
+    await loadPortraitsDashboard(page);
+    dashboardRefreshTimer = setInterval(() => {
+      if (state.page === 'portraits') loadPortraitsDashboard(page, true);
+    }, 30000);
+
+    page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page));
+    page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => {
+      try {
+        await api('POST', '/api/dashboard/rebuild');
+        toast('索引已重建');
+        loadPortraitsDashboard(page);
+      } catch (err) {
+        toast(err.message || '重建失败', 'error');
+      }
+    });
+
+    page.querySelector('#pt-filter-keyword').addEventListener('input', () => loadPortraitsTable(page));
+    page.querySelector('#pt-filter-source').addEventListener('change', () => loadPortraitsTable(page));
+    page.querySelector('#pt-filter-tag').addEventListener('change', () => loadPortraitsTable(page));
+
+    page.querySelector('#btn-pt-batch').addEventListener('click', () => openBatchPortraitModal(page));
+    page.querySelector('#btn-pt-tag').addEventListener('click', () => openTagModal(page));
+    page.querySelector('#btn-pt-export').addEventListener('click', () => {
+      startJob('export-portraits', '/api/portraits/export', {}, (result) => {
+        if (result.files && result.files[0]) {
+          toast('导出成功,点击下载', 'success');
+          window.open(`/api/outputs?path=${encodeURIComponent(result.files[0])}`, '_blank');
+        }
+      });
+    });
+  }
+
+  async function loadPortraitsDashboard(page, silent = false) {
+    if (!silent) page.querySelector('#pt-portrait-table-wrap').innerHTML = '<div class="skeleton" style="height:200px"></div>';
+    try {
+      const [summaryRes, portraitsRes, tagsRes, customersRes] = await Promise.all([
+        api('GET', '/api/dashboard/summary'),
+        api('GET', '/api/dashboard/portraits'),
+        api('GET', '/api/dashboard/tags'),
+        api('GET', '/api/dashboard/customers')
+      ]);
+      const summary = summaryRes.data || {};
+      const portraits = portraitsRes.data?.portraits || {};
+      const tags = tagsRes.data?.allTags || [];
+      const customers = customersRes.data?.customers || {};
+
+      renderPortraitsKpi(page, summary);
+      renderPortraitsCharts(page, summary, portraits, tags);
+      renderPortraitsTagFilter(page, tags);
+      state.portraits.allPortraits = portraits;
+      state.portraits.allCustomers = customers;
+      loadPortraitsTable(page);
+    } catch (err) {
+      if (!silent) toast(err.message || '加载看板失败', 'error');
+    }
+  }
+
+  function renderPortraitsKpi(page, summary) {
+    const wrap = page.querySelector('#pt-kpi-row');
+    const p = summary.portraits || {};
+    const t = summary.tags || {};
+    const kpi = [
+      { label: '已生成画像', value: formatNumber(p.total) },
+      { label: '画像覆盖率', value: (p.coverage || 0) + '%', sub: '有画像客户占比' },
+      { label: '本地标签', value: formatNumber(t.total), sub: '去重后标签数' },
+      { label: '人均标签', value: t.avgPerCustomer || 0, sub: '每个客户平均标签' },
+      { label: 'AI 画像', value: formatNumber(p.bySource?.agent || 0), sub: 'Agent 分析生成' },
+      { label: '关键词画像', value: formatNumber(p.bySource?.keyword || 0), sub: '关键词匹配生成' }
+    ];
+    wrap.innerHTML = renderKpiRow(kpi);
+  }
+
+  function renderPortraitsCharts(page, summary, portraits, tags) {
+    const coverage = summary.portraits?.coverage || 0;
+    initEChart('pt-coverage-chart', {
+      series: [{
+        type: 'gauge',
+        startAngle: 180,
+        endAngle: 0,
+        min: 0,
+        max: 100,
+        progress: { show: true, width: 12 },
+        axisLine: { lineStyle: { width: 12 } },
+        axisTick: { show: false },
+        splitLine: { show: false },
+        axisLabel: { show: false },
+        pointer: { show: false },
+        anchor: { show: false },
+        title: { show: false },
+        detail: {
+          valueAnimation: true,
+          fontSize: 28,
+          fontWeight: 800,
+          color: '#1f1f1f',
+          formatter: '{value}%',
+          offsetCenter: [0, '0%']
+        },
+        data: [{ value: coverage }]
+      }]
+    });
+
+    const bySource = summary.portraits?.bySource || {};
+    initEChart('pt-source-chart', {
+      tooltip: { trigger: 'item' },
+      legend: { bottom: 0 },
+      series: [{
+        type: 'pie',
+        radius: ['40%', '65%'],
+        center: ['50%', '45%'],
+        data: [
+          { name: '关键词', value: bySource.keyword || 0, itemStyle: { color: '#fa8c16' } },
+          { name: 'AI', value: bySource.agent || 0, itemStyle: { color: '#1890ff' } },
+          { name: '其他', value: bySource.other || 0, itemStyle: { color: '#8c8c8c' } }
+        ]
+      }]
+    });
+
+    const tagCounts = {};
+    for (const p of Object.values(portraits)) {
+      // 标签统计需要从 customers 中读取
+    }
+    const topTags = tags.slice(0, 10).map(t => ({ name: t, value: Object.values(state.portraits.allCustomers || {}).filter(c => Array.isArray(c.tags) && c.tags.includes(t)).length }));
+    initEChart('pt-tags-chart', {
+      tooltip: { trigger: 'axis' },
+      xAxis: { type: 'value' },
+      yAxis: { type: 'category', data: topTags.map(t => t.name).reverse() },
+      series: [{ type: 'bar', data: topTags.map(t => t.value).reverse(), itemStyle: { color: '#fa8c16' } }]
+    });
+
+    const days = last7Days();
+    const portraitItems = Object.values(portraits);
+    const counts = countByDay(portraitItems, 'updatedAt');
+    initEChart('pt-trend-chart', {
+      tooltip: { trigger: 'axis' },
+      xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
+      yAxis: { type: 'value' },
+      series: [{ name: '画像生成', type: 'line', smooth: true, data: counts, itemStyle: { color: '#fa8c16' }, areaStyle: { color: 'rgba(250,140,22,0.15)' } }]
+    });
+  }
+
+  function renderPortraitsTagFilter(page, tags) {
+    const select = page.querySelector('#pt-filter-tag');
+    const current = select.value;
+    select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
+    select.value = current;
+  }
+
+  function loadPortraitsTable(page) {
+    const wrap = page.querySelector('#pt-portrait-table-wrap');
+    const portraits = state.portraits.allPortraits || {};
+    const customers = state.portraits.allCustomers || {};
+    const keyword = page.querySelector('#pt-filter-keyword').value.trim().toLowerCase();
+    const source = page.querySelector('#pt-filter-source').value;
+    const tag = page.querySelector('#pt-filter-tag').value;
+
+    let items = Object.values(portraits).map(p => {
+      const customer = Object.values(customers).find(c => c.externalUserId === p.externalUserId) || {};
+      return { ...p, name: customer.name, phone: customer.phone, tags: customer.tags || [] };
+    });
+
+    let filtered = items.filter(p => {
+      if (keyword) {
+        const text = `${p.externalUserId || ''} ${p.name || ''} ${p.phone || ''}`.toLowerCase();
+        if (!text.includes(keyword)) return false;
+      }
+      if (source && p.source !== source) return false;
+      if (tag && !p.tags.includes(tag)) return false;
+      return true;
+    });
+
+    const pageSize = 20;
+    const currentPage = state.portraits.tablePage || 1;
+    const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
+    if (currentPage > totalPages) state.portraits.tablePage = totalPages;
+    const start = (state.portraits.tablePage - 1) * pageSize;
+    const pageItems = filtered.slice(start, start + pageSize);
+
+    wrap.innerHTML = `
+      <div class="table-wrap">
+        <table>
+          <thead>
+            <tr>
+              <th>externalUserId</th>
+              <th>姓名</th>
+              <th>标签</th>
+              <th>画像字段</th>
+              <th>来源</th>
+              <th>消息数</th>
+              <th>更新时间</th>
+              <th>操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            ${pageItems.length ? pageItems.map(p => `
+              <tr>
+                <td style="font-family:monospace;font-size:12px">${escapeHtml(p.externalUserId || '-')}</td>
+                <td>${escapeHtml(p.name || '-')}</td>
+                <td>${p.tags.length ? p.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
+                <td>${formatNumber(p.fields?.length || 0)} 个字段</td>
+                <td>${p.source === 'keyword' ? '<span class="badge badge-warning">关键词</span>' : p.source === 'agent' ? '<span class="badge badge-info">AI</span>' : '<span class="badge badge-default">未知</span>'}</td>
+                <td>${formatNumber(p.messageCount)}</td>
+                <td>${formatDateTime(p.updatedAt)}</td>
+                <td>
+                  <button class="btn btn-sm btn-secondary" onclick="window.dashboardPortraitDetail('${escapeHtml(p.externalUserId)}')">查看</button>
+                </td>
+              </tr>
+            `).join('') : '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:40px">暂无画像数据</td></tr>'}
+          </tbody>
+        </table>
+      </div>
+    `;
+
+    const pagination = page.querySelector('#pt-pagination');
+    pagination.innerHTML = renderPagination(state.portraits.tablePage, filtered.length, pageSize);
+    pagination.querySelectorAll('button').forEach(btn => {
+      btn.addEventListener('click', () => {
+        state.portraits.tablePage = Number(btn.dataset.page);
+        loadPortraitsTable(page);
+      });
+    });
+  }
+
+  function openBatchPortraitModal(page) {
+    const body = `
+      <div class="form-group" style="margin-bottom:16px">
+        <label>externalUserIds(每行一个)</label>
+        <textarea id="batch-portrait-ids" rows="6" placeholder="externalUserId1\nexternalUserId2"></textarea>
+      </div>
+      <div class="form-group">
+        <label>生成模式</label>
+        <select id="batch-portrait-mode">
+          <option value="keyword">关键词模式(直接保存)</option>
+          <option value="ai">AI 模式(生成 context 供分析)</option>
+        </select>
+      </div>
+    `;
+    openModal('批量生成画像', body, `
+      <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
+      <button class="btn" id="btn-confirm-batch-portrait">生成</button>
+    `);
+    document.getElementById('btn-confirm-batch-portrait').addEventListener('click', () => {
+      const ids = document.getElementById('batch-portrait-ids').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
+      const mode = document.getElementById('batch-portrait-mode').value;
+      if (!ids.length) {
+        toast('请输入 externalUserIds', 'warning');
+        return;
+      }
+      closeModal();
+      startJob('batch-portraits', '/api/portraits/batch', { externalUserIds: ids, aiMode: mode, guid: getGuid() }, () => loadPortraitsDashboard(page));
+    });
+  }
+
+  function openTagModal(page) {
+    const body = `
+      <div class="form-group" style="margin-bottom:16px">
+        <label>externalUserId</label>
+        <input id="tag-external-id" type="text" placeholder="客户 externalUserId" />
+      </div>
+      <div class="form-group" style="margin-bottom:16px">
+        <label>标签(逗号分隔)</label>
+        <input id="tag-input" type="text" placeholder="高意向,学区房" />
+      </div>
+      <div class="form-group">
+        <label>操作</label>
+        <select id="tag-action">
+          <option value="add">添加标签</option>
+          <option value="remove">移除标签</option>
+        </select>
+      </div>
+    `;
+    openModal('为客户打标签', body, `
+      <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
+      <button class="btn" id="btn-confirm-tag">确定</button>
+    `);
+    document.getElementById('btn-confirm-tag').addEventListener('click', async () => {
+      const externalUserId = document.getElementById('tag-external-id').value.trim();
+      const tags = document.getElementById('tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
+      const action = document.getElementById('tag-action').value;
+      if (!externalUserId || !tags.length) {
+        toast('请输入 externalUserId 和标签', 'warning');
+        return;
+      }
+      try {
+        await api('POST', `/api/tags/${action}`, { externalUserId, tags });
+        toast('标签已更新');
+        closeModal();
+        loadPortraitsDashboard(page);
+      } catch (err) {
+        toast(err.message || '更新失败', 'error');
+      }
+    });
+  }
+
+  window.dashboardPortraitDetail = async (externalUserId) => {
+    if (!externalUserId) return;
+    try {
+      const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() });
+      const data = res.data || {};
+      const portrait = data.portrait;
+      openModal('画像详情', `
+        <div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">
+          <div><strong>externalUserId:</strong>${escapeHtml(externalUserId)}</div>
+        </div>
+        <div class="detail-panel">${escapeHtml(JSON.stringify(portrait || {}, null, 2))}</div>
+      `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
+    } catch (err) {
+      toast(err.message || '查询失败', 'error');
+    }
+  };
+'''
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'r', encoding='utf-8') as f:
+    lines = f.readlines()
+
+start = 1784
+end = 2228
+
+new_lines = lines[:start] + [new_portraits + '\n'] + lines[end:]
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'w', encoding='utf-8') as f:
+    f.writelines(new_lines)
+
+print('renderPortraitsPage replaced successfully')

+ 320 - 0
replace_transfers.py

@@ -0,0 +1,320 @@
+# -*- coding: utf-8 -*-
+
+new_transfers = '''  async function renderTransfersPage() {
+    els.pageTitle.textContent = '客户交接看板';
+    const page = document.createElement('div');
+    page.className = 'page';
+
+    page.innerHTML = `
+      <div class="dashboard-grid" id="tf-dashboard">
+        <div class="kpi-row" id="tf-kpi-row"></div>
+        <div class="chart-card">
+          <h3 class="chart-title"><span>交接漏斗</span></h3>
+          <div class="chart-container" id="tf-funnel-chart"></div>
+        </div>
+        <div class="chart-card">
+          <h3 class="chart-title"><span>交接结果分布</span></h3>
+          <div class="chart-container" id="tf-result-chart"></div>
+        </div>
+        <div class="chart-card full">
+          <h3 class="chart-title"><span>近7日交接执行趋势</span></h3>
+          <div class="chart-container" id="tf-trend-chart"></div>
+        </div>
+        <div class="operation-toolbar">
+          <div class="toolbar-left">
+            <button class="btn" id="btn-new-transfer">新建交接预览</button>
+          </div>
+          <div class="toolbar-right">
+            <button class="btn btn-sm btn-secondary" id="btn-tf-refresh">刷新</button>
+            <button class="btn btn-sm btn-secondary" id="btn-tf-rebuild">重建索引</button>
+          </div>
+        </div>
+        <div class="data-table-card">
+          <div class="card-header">
+            <div>
+              <h3 class="card-title">交接包列表</h3>
+              <div class="card-subtitle">预览包与执行记录</div>
+            </div>
+          </div>
+          <div class="filter-bar" style="margin-bottom:16px">
+            <select id="tf-filter-status">
+              <option value="">全部状态</option>
+              <option value="DRAFT">草稿</option>
+              <option value="EXECUTED">已执行</option>
+            </select>
+            <input id="tf-filter-user" type="text" placeholder="按顾问 userId 搜索" style="width:200px" />
+          </div>
+          <div id="tf-transfer-table-wrap"></div>
+          <div id="tf-pagination"></div>
+        </div>
+      </div>
+    `;
+    els.content.appendChild(page);
+
+    await loadTransfersDashboard(page);
+    dashboardRefreshTimer = setInterval(() => {
+      if (state.page === 'transfers') loadTransfersDashboard(page, true);
+    }, 30000);
+
+    page.querySelector('#btn-tf-refresh').addEventListener('click', () => loadTransfersDashboard(page));
+    page.querySelector('#btn-tf-rebuild').addEventListener('click', async () => {
+      try {
+        await api('POST', '/api/dashboard/rebuild');
+        toast('索引已重建');
+        loadTransfersDashboard(page);
+      } catch (err) {
+        toast(err.message || '重建失败', 'error');
+      }
+    });
+    page.querySelector('#btn-new-transfer').addEventListener('click', () => openTransferModal(page));
+    page.querySelector('#tf-filter-status').addEventListener('change', () => loadTransfersTable(page));
+    page.querySelector('#tf-filter-user').addEventListener('input', () => loadTransfersTable(page));
+  }
+
+  async function loadTransfersDashboard(page, silent = false) {
+    if (!silent) page.querySelector('#tf-transfer-table-wrap').innerHTML = '<div class="skeleton" style="height:200px"></div>';
+    try {
+      const [summaryRes, transfersRes] = await Promise.all([
+        api('GET', '/api/dashboard/summary'),
+        api('GET', '/api/dashboard/transfers')
+      ]);
+      const summary = summaryRes.data || {};
+      const transfers = transfersRes.data || {};
+
+      renderTransfersKpi(page, summary);
+      renderTransfersCharts(page, summary, transfers);
+      state.transfers = { ...state.transfers, ...transfers };
+      loadTransfersTable(page);
+    } catch (err) {
+      if (!silent) toast(err.message || '加载看板失败', 'error');
+    }
+  }
+
+  function renderTransfersKpi(page, summary) {
+    const wrap = page.querySelector('#tf-kpi-row');
+    const t = summary.transfers || {};
+    const kpi = [
+      { label: '交接预览包', value: formatNumber(t.previews) },
+      { label: '已执行交接', value: formatNumber(t.executions) },
+      { label: '交接成功率', value: (t.successRate || 0) + '%', sub: '成功记录 / 总记录' },
+      { label: '近7日交接', value: formatNumber(t.recent7d), sub: '最近7天执行数' }
+    ];
+    wrap.innerHTML = renderKpiRow(kpi);
+  }
+
+  function renderTransfersCharts(page, summary, transfers) {
+    const t = summary.transfers || {};
+    initEChart('tf-funnel-chart', {
+      tooltip: { trigger: 'item', formatter: '{b}: {c}' },
+      series: [{
+        type: 'funnel',
+        left: '10%',
+        width: '80%',
+        label: { show: true, position: 'inside' },
+        data: [
+          { name: '预览包', value: t.previews || 0, itemStyle: { color: '#fa8c16' } },
+          { name: '已执行', value: t.executions || 0, itemStyle: { color: '#1890ff' } },
+          { name: '成功', value: Math.round(((t.successRate || 0) / 100) * (t.executions || 0)), itemStyle: { color: '#52c41a' } }
+        ]
+      }]
+    });
+
+    const executions = transfers.executions || [];
+    const success = executions.reduce((sum, e) => sum + (e.success || 0), 0);
+    const failed = executions.reduce((sum, e) => sum + (e.failed || 0), 0);
+    initEChart('tf-result-chart', {
+      tooltip: { trigger: 'item' },
+      legend: { bottom: 0 },
+      series: [{
+        type: 'pie',
+        radius: ['40%', '65%'],
+        center: ['50%', '45%'],
+        data: [
+          { name: '成功', value: success, itemStyle: { color: '#52c41a' } },
+          { name: '失败', value: failed, itemStyle: { color: '#ff4d4f' } }
+        ]
+      }]
+    });
+
+    const days = last7Days();
+    const counts = countByDay(executions, 'executedAt');
+    initEChart('tf-trend-chart', {
+      tooltip: { trigger: 'axis' },
+      xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
+      yAxis: { type: 'value' },
+      series: [{ name: '交接执行', type: 'bar', data: counts, itemStyle: { color: '#fa8c16' } }]
+    });
+  }
+
+  function loadTransfersTable(page) {
+    const wrap = page.querySelector('#tf-transfer-table-wrap');
+    const transfers = state.transfers || {};
+    const statusFilter = page.querySelector('#tf-filter-status').value;
+    const userFilter = page.querySelector('#tf-filter-user').value.trim().toLowerCase();
+
+    let items = [
+      ...(transfers.previews || []).map(p => ({ ...p, type: 'preview' })),
+      ...(transfers.executions || []).map(e => ({ ...e, type: 'execution' }))
+    ];
+
+    let filtered = items.filter(item => {
+      if (statusFilter && item.status !== statusFilter) return false;
+      if (userFilter) {
+        const text = `${item.fromUserId || ''} ${item.toUserId || ''}`.toLowerCase();
+        if (!text.includes(userFilter)) return false;
+      }
+      return true;
+    });
+
+    filtered.sort((a, b) => (b.createdAt || b.executedAt || '').localeCompare(a.createdAt || a.executedAt || ''));
+
+    const pageSize = 20;
+    const currentPage = state.transfers.tablePage || 1;
+    const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
+    if (currentPage > totalPages) state.transfers.tablePage = totalPages;
+    const start = (state.transfers.tablePage - 1) * pageSize;
+    const pageItems = filtered.slice(start, start + pageSize);
+
+    wrap.innerHTML = `
+      <div class="table-wrap">
+        <table>
+          <thead>
+            <tr>
+              <th>类型</th>
+              <th>原顾问</th>
+              <th>新顾问</th>
+              <th>记录数</th>
+              <th>状态</th>
+              <th>创建/执行时间</th>
+              <th>操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            ${pageItems.length ? pageItems.map(item => `
+              <tr>
+                <td>${item.type === 'preview' ? '<span class="badge badge-warning">预览</span>' : '<span class="badge badge-info">执行</span>'}</td>
+                <td>${escapeHtml(item.fromUserId || '-')}</td>
+                <td>${escapeHtml(item.toUserId || '-')}</td>
+                <td>${formatNumber(item.itemCount || item.total || 0)}</td>
+                <td>${item.status === 'EXECUTED' ? '<span class="badge badge-success">已执行</span>' : '<span class="badge badge-warning">草稿</span>'}</td>
+                <td>${formatDateTime(item.createdAt || item.executedAt)}</td>
+                <td>
+                  ${item.type === 'preview' ? `<button class="btn btn-sm" onclick="window.dashboardExecuteTransfer('${escapeHtml(item.filePath || '')}')">执行</button>` : `<button class="btn btn-sm btn-secondary" onclick="window.dashboardTransferDetail('${escapeHtml(item.filePath || '')}')">详情</button>`}
+                </td>
+              </tr>
+            `).join('') : '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:40px">暂无交接记录</td></tr>'}
+          </tbody>
+        </table>
+      </div>
+    `;
+
+    const pagination = page.querySelector('#tf-pagination');
+    pagination.innerHTML = renderPagination(state.transfers.tablePage, filtered.length, pageSize);
+    pagination.querySelectorAll('button').forEach(btn => {
+      btn.addEventListener('click', () => {
+        state.transfers.tablePage = Number(btn.dataset.page);
+        loadTransfersTable(page);
+      });
+    });
+  }
+
+  function openTransferModal(page) {
+    const body = `
+      <div class="form-group" style="margin-bottom:16px">
+        <label>原顾问 userId</label>
+        <input id="new-tf-from" type="text" placeholder="fromUserId" />
+      </div>
+      <div class="form-group" style="margin-bottom:16px">
+        <label>新顾问 userId</label>
+        <input id="new-tf-to" type="text" placeholder="toUserId" />
+      </div>
+      <div class="form-group" style="margin-bottom:16px">
+        <label>externalUserIds(每行一个,可选)</label>
+        <textarea id="new-tf-external" rows="4" placeholder="externalUserId1\nexternalUserId2"></textarea>
+      </div>
+      <div class="form-group">
+        <label>已确认群 roomIds(逗号分隔,可选)</label>
+        <input id="new-tf-rooms" type="text" placeholder="roomId1,roomId2" />
+      </div>
+    `;
+    openModal('新建交接预览', body, `
+      <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
+      <button class="btn" id="btn-confirm-new-transfer">生成预览</button>
+    `);
+    document.getElementById('btn-confirm-new-transfer').addEventListener('click', async () => {
+      const fromUserId = document.getElementById('new-tf-from').value.trim();
+      const toUserId = document.getElementById('new-tf-to').value.trim();
+      const externalUserIds = document.getElementById('new-tf-external').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
+      const roomIds = document.getElementById('new-tf-rooms').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
+      if (!fromUserId || !toUserId) {
+        toast('请输入原顾问和新顾问 userId', 'warning');
+        return;
+      }
+      if (!externalUserIds.length && !roomIds.length) {
+        toast('请输入 externalUserIds 或 roomIds', 'warning');
+        return;
+      }
+      try {
+        await api('POST', '/api/transfers/preview', { fromUserId, toUserId, externalUserIds, roomIds });
+        toast('预览已生成');
+        closeModal();
+        loadTransfersDashboard(page);
+      } catch (err) {
+        toast(err.message || '生成失败', 'error');
+      }
+    });
+  }
+
+  window.dashboardExecuteTransfer = (filePath) => {
+    const body = `
+      <div class="form-group" style="margin-bottom:16px">
+        <label>预览文件路径</label>
+        <input id="exec-tf-file" type="text" value="${escapeHtml(filePath || '')}" placeholder="留空使用最新预览" />
+      </div>
+      <div class="form-group" style="margin-bottom:16px">
+        <label>欢迎语</label>
+        <input id="exec-tf-welcome" type="text" value="您好,我是您的新服务顾问,后续由我为您服务。" />
+      </div>
+      <div class="checkbox-row">
+        <input id="exec-tf-remove" type="checkbox" />
+        <label for="exec-tf-remove">交接后移除原顾问</label>
+      </div>
+    `;
+    openModal('执行交接', body, `
+      <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
+      <button class="btn" id="btn-confirm-exec-transfer">执行</button>
+    `);
+    document.getElementById('btn-confirm-exec-transfer').addEventListener('click', () => {
+      const previewFile = document.getElementById('exec-tf-file').value.trim() || undefined;
+      const welcomeText = document.getElementById('exec-tf-welcome').value.trim();
+      const removeOldBroker = document.getElementById('exec-tf-remove').checked;
+      closeModal();
+      startJob('execute-transfer', '/api/transfers/execute', { guid: getGuid(), previewFile, welcomeText, removeOldBroker }, () => loadTransfersDashboard(document.querySelector('#tf-dashboard')?.closest('.page')));
+    });
+  };
+
+  window.dashboardTransferDetail = async (filePath) => {
+    if (!filePath) return;
+    try {
+      openModal('交接详情', `
+        <div class="detail-panel">${escapeHtml(JSON.stringify({ filePath }, null, 2))}</div>
+        <div style="margin-top:12px">${renderFileLink(filePath, '下载文件')}</div>
+      `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
+    } catch (err) {
+      toast(err.message || '查询失败', 'error');
+    }
+  };
+'''
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'r', encoding='utf-8') as f:
+    lines = f.readlines()
+
+start = 2156
+end = 2347
+
+new_lines = lines[:start] + [new_transfers + '\n'] + lines[end:]
+
+with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'w', encoding='utf-8') as f:
+    f.writelines(new_lines)
+
+print('renderTransfersPage replaced successfully')

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно