Ver Fonte

feat(qiwei): add group operations workflow

ETO-kai há 2 meses atrás
pai
commit
3285d12d3f
39 ficheiros alterados com 4178 adições e 165 exclusões
  1. 152 0
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-implementation-log.md
  2. 500 0
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-product-development-spec.md
  3. 41 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/group-operations-access.js
  4. 842 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/group-operations-store.js
  5. 3 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js
  6. 1 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-config.js
  7. 25 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js
  8. 572 76
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  9. 75 5
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/customer-master-service.js
  10. 6 2
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html
  11. 168 8
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  12. 630 6
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  13. 93 0
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  14. 14 3
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js
  15. 3 1
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-transfer-run.js
  16. 137 44
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js
  17. 295 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-operations-run.js
  18. 5 12
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js
  19. 1 0
      claude-code/claude-code-qiwe-assistant/package.json
  20. 43 0
      claude-code/claude-code-qiwe-assistant/scripts/cleanup-mock-data.js
  21. 47 0
      claude-code/claude-code-qiwe-assistant/scripts/group-operations-access-smoke-test.js
  22. 47 0
      claude-code/claude-code-qiwe-assistant/scripts/group-operations-automation-smoke-test.js
  23. 45 0
      claude-code/claude-code-qiwe-assistant/scripts/group-operations-quality-eval.js
  24. 117 0
      claude-code/claude-code-qiwe-assistant/scripts/group-operations-smoke-test.js
  25. 6 0
      claude-code/claude-code-qiwe-assistant/scripts/release-check.js
  26. 38 0
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals.py
  27. 30 0
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals2.py
  28. 25 0
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals3.py
  29. 22 0
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals4.py
  30. 12 0
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops.py
  31. 9 4
      claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js
  32. 1 1
      claude-code/claude-code-qiwe-assistant/scripts/validate-output-standard.js
  33. 3 2
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  34. 79 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/SKILL.md
  35. 4 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/agents/openai.yaml
  36. 16 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/automation-safety.md
  37. 29 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/data-model.md
  38. 24 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/output-contracts.md
  39. 18 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/quality-rules.md

+ 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。

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

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

@@ -12,6 +12,7 @@ const OUTPUT_CATEGORIES = Object.freeze([
   'messages',
   'messages',
   'customers',
   'customers',
   'groups',
   'groups',
+  'group-operations',
   'portraits',
   'portraits',
   'knowledge',
   'knowledge',
   'goals',
   'goals',
@@ -30,7 +31,8 @@ const OUTPUT_CATEGORIES = Object.freeze([
 ]);
 ]);
 
 
 const OUTPUT_PERSISTENT_STORES = Object.freeze({
 const OUTPUT_PERSISTENT_STORES = Object.freeze({
-  knowledge: Object.freeze(['meetings', 'docs', 'todos', 'tasks'])
+  knowledge: Object.freeze(['meetings', 'docs', 'todos', 'tasks']),
+  messages: Object.freeze(['*'])
 });
 });
 
 
 function outputsRoot() {
 function outputsRoot() {

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

+ 572 - 76
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -9,6 +9,7 @@
   const PT_FILTERS_KEY = 'qiwei-pt-filters';
   const PT_FILTERS_KEY = 'qiwei-pt-filters';
   const TF_FILTERS_KEY = 'qiwei-tf-filters';
   const TF_FILTERS_KEY = 'qiwei-tf-filters';
   const GROUP_FILTERS_KEY = 'qiwei-group-filters';
   const GROUP_FILTERS_KEY = 'qiwei-group-filters';
+  const AGENT_SNAPSHOT_KEY = 'qiwei-agent-snapshot-v1';
 
 
   function loadFilters(key, defaults = {}) {
   function loadFilters(key, defaults = {}) {
     try {
     try {
@@ -21,6 +22,25 @@
     localStorage.setItem(key, JSON.stringify(filters));
     localStorage.setItem(key, JSON.stringify(filters));
   }
   }
 
 
+  function loadAgentSnapshot() {
+    try {
+      const snapshot = JSON.parse(sessionStorage.getItem(AGENT_SNAPSHOT_KEY) || 'null');
+      return snapshot && typeof snapshot === 'object' ? snapshot : {};
+    } catch {
+      return {};
+    }
+  }
+
+  function saveAgentSnapshot(snapshot) {
+    try {
+      sessionStorage.setItem(AGENT_SNAPSHOT_KEY, JSON.stringify(snapshot));
+    } catch {
+      // 快照只是刷新体验优化;容量不足时不影响真实会话数据。
+    }
+  }
+
+  const initialAgentSnapshot = loadAgentSnapshot();
+
   const state = {
   const state = {
     page: 'agent',
     page: 'agent',
     accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'),
     accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'),
@@ -30,8 +50,10 @@
     groupSync: {},
     groupSync: {},
     groupFilters: loadFilters(GROUP_FILTERS_KEY, { keyword: '', status: '', showCustomerOnly: false }),
     groupFilters: loadFilters(GROUP_FILTERS_KEY, { keyword: '', status: '', showCustomerOnly: false }),
     groupKeywords: null,
     groupKeywords: null,
+    groupOps: { overview: null, playbooks: [], loading: false, view: 'overview' },
     jobs: {},
     jobs: {},
     renderToken: 0,
     renderToken: 0,
+    bootstrapping: true,
     toastId: 0,
     toastId: 0,
     modal: null,
     modal: null,
     loginTimer: null,
     loginTimer: null,
@@ -42,7 +64,15 @@
     selectedGroupIds: new Set(),
     selectedGroupIds: new Set(),
     customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null, master: { hub: null, selectedId: null, keyword: '', loading: false }, ...loadFilters(CO_FILTERS_KEY, { tablePage: 1, keyword: '', status: '', portrait: '', tag: '' }) },
     customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null, master: { hub: null, selectedId: null, keyword: '', loading: false }, ...loadFilters(CO_FILTERS_KEY, { tablePage: 1, keyword: '', status: '', portrait: '', tag: '' }) },
     portraits: { lastPortrait: null, lastTags: [], personalLabels: [], lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
     portraits: { lastPortrait: null, lastTags: [], personalLabels: [], lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
-    agent: { status: null, conversations: [], selectedId: null, timer: null, loading: false, signature: null },
+    agent: {
+      status: initialAgentSnapshot.status || null,
+      conversations: Array.isArray(initialAgentSnapshot.conversations) ? initialAgentSnapshot.conversations : [],
+      selectedId: initialAgentSnapshot.selectedId || null,
+      timer: null,
+      loading: false,
+      signature: initialAgentSnapshot.signature || null,
+      cachedAccountKey: initialAgentSnapshot.accountKey || null
+    },
     skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
     skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
     knowledge: { tree: null, selectedNodeId: null, file: null, properties: null, selectedProperty: null, expandedFolderIds: new Set(), foldersInitialized: false, filters: { q: '', district: '', layout: '', decoration: '', maxPrice: '' }, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
     knowledge: { tree: null, selectedNodeId: null, file: null, properties: null, selectedProperty: null, expandedFolderIds: new Set(), foldersInitialized: false, filters: { q: '', district: '', layout: '', decoration: '', maxPrice: '' }, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
     transfers: { lastTransferPreview: null, ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
     transfers: { lastTransferPreview: null, ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
@@ -79,6 +109,10 @@
     return state.accounts.find(a => a.id === state.currentAccountId) || null;
     return state.accounts.find(a => a.id === state.currentAccountId) || null;
   }
   }
 
 
+  function agentAccountKey(account = currentAccount()) {
+    return String(account?.uid || account?.guid || account?.userId || '').trim();
+  }
+
   function getGuid() {
   function getGuid() {
     const acc = currentAccount();
     const acc = currentAccount();
     return acc ? (acc.guid || acc.userId || '') : '';
     return acc ? (acc.guid || acc.userId || '') : '';
@@ -118,6 +152,7 @@
       return false;
       return false;
     }
     }
     try {
     try {
+      const accountChanged = state.currentAccountId !== id;
       const result = await api('POST', '/api/accounts/switch', {
       const result = await api('POST', '/api/accounts/switch', {
         uid: account.uid,
         uid: account.uid,
         guid: account.guid,
         guid: account.guid,
@@ -133,13 +168,17 @@
       account.nickname = account.nickname || active.nickname || account.userId;
       account.nickname = account.nickname || active.nickname || account.userId;
       account.corpName = account.corpName || active.corpName || '';
       account.corpName = account.corpName || active.corpName || '';
       account.online = Boolean(active.online);
       account.online = Boolean(active.online);
-      state.agent.status = null;
-      state.agent.conversations = [];
-      state.agent.selectedId = null;
-      state.agent.signature = null;
+      if (accountChanged) {
+        state.agent.status = null;
+        state.agent.conversations = [];
+        state.agent.selectedId = null;
+        state.agent.signature = null;
+        state.agent.cachedAccountKey = null;
+        try { sessionStorage.removeItem(AGENT_SNAPSHOT_KEY); } catch {}
+      }
       saveAccounts();
       saveAccounts();
       updateAccountSwitcher();
       updateAccountSwitcher();
-      renderPage();
+      if (!options.deferRender) void renderPage();
       if (!options.silent) toast(result.assistantMessage || `已切换账号:${account.nickname}`);
       if (!options.silent) toast(result.assistantMessage || `已切换账号:${account.nickname}`);
       return true;
       return true;
     } catch (error) {
     } catch (error) {
@@ -244,8 +283,12 @@
     const previousFocus = document.activeElement;
     const previousFocus = document.activeElement;
     const overlay = document.createElement('div');
     const overlay = document.createElement('div');
     overlay.className = 'modal-overlay';
     overlay.className = 'modal-overlay';
+    if (modalClass === 'portrait-modal') overlay.classList.add('portrait-modal-overlay');
+    if (modalClass === 'customer-selector-modal') overlay.classList.add('customer-selector-modal-overlay');
+    if (modalClass === 'customer-detail-modal') overlay.classList.add('customer-detail-modal-overlay');
     overlay.innerHTML = `<div class="modal ${escapeHtml(modalClass)}"><div class="modal-title">${escapeHtml(title)}</div>${bodyHtml}<div class="modal-actions">${actionsHtml}</div></div>`;
     overlay.innerHTML = `<div class="modal ${escapeHtml(modalClass)}"><div class="modal-title">${escapeHtml(title)}</div>${bodyHtml}<div class="modal-actions">${actionsHtml}</div></div>`;
     document.body.appendChild(overlay);
     document.body.appendChild(overlay);
+    document.body.classList.add('modal-open');
     modalStack.push(overlay);
     modalStack.push(overlay);
     state.modal = overlay;
     state.modal = overlay;
     const onKeydown = event => {
     const onKeydown = event => {
@@ -263,6 +306,7 @@
     if (!overlay) return;
     if (!overlay) return;
     overlay.__modalCleanup?.();
     overlay.__modalCleanup?.();
     overlay.remove();
     overlay.remove();
+    if (!document.querySelector('.modal-overlay')) document.body.classList.remove('modal-open');
     if (state.modal === overlay) state.modal = null;
     if (state.modal === overlay) state.modal = null;
     if (overlay.__previousFocus?.isConnected) overlay.__previousFocus.focus();
     if (overlay.__previousFocus?.isConnected) overlay.__previousFocus.focus();
   }
   }
@@ -319,7 +363,7 @@
         ${shortcuts.length ? `<div class="business-modal-shortcuts"><span>${escapeHtml(options.shortcutLabel || '快捷选择')}</span><div>${shortcuts.map((shortcut, index) => `<button type="button" data-business-shortcut="${index}">${escapeHtml(shortcut.label)}</button>`).join('')}</div></div>` : ''}
         ${shortcuts.length ? `<div class="business-modal-shortcuts"><span>${escapeHtml(options.shortcutLabel || '快捷选择')}</span><div>${shortcuts.map((shortcut, index) => `<button type="button" data-business-shortcut="${index}">${escapeHtml(shortcut.label)}</button>`).join('')}</div></div>` : ''}
         ${options.warning ? `<div class="business-modal-live-warning"><i>!</i><div><strong>${escapeHtml(options.warningTitle || '真实业务操作')}</strong><span>${escapeHtml(options.warning)}</span></div></div>` : ''}
         ${options.warning ? `<div class="business-modal-live-warning"><i>!</i><div><strong>${escapeHtml(options.warningTitle || '真实业务操作')}</strong><span>${escapeHtml(options.warning)}</span></div></div>` : ''}
         <p class="business-modal-error" data-business-error role="alert" hidden></p>
         <p class="business-modal-error" data-business-error role="alert" hidden></p>
-        <footer class="business-modal-actions"><button type="button" class="btn btn-secondary" data-business-cancel>${escapeHtml(options.cancelText || '取消')}</button><button type="submit" class="btn business-modal-confirm">${escapeHtml(options.confirmText || '确认')}</button></footer>
+        <footer class="business-modal-actions">${options.cancelText !== null ? `<button type="button" class="btn btn-secondary" data-business-cancel>${escapeHtml(options.cancelText || '取消')}</button>` : ''}<button type="submit" class="btn business-modal-confirm">${escapeHtml(options.confirmText || '确认')}</button></footer>
       </form>
       </form>
     </section>`;
     </section>`;
 
 
@@ -407,6 +451,36 @@
     });
     });
   }
   }
 
 
+  function confirmModal(message, options = {}) {
+    return showBusinessModal({
+      title: options.title || '确认操作',
+      description: message,
+      confirmText: options.confirmText || '确认',
+      cancelText: options.cancelText || '取消',
+      warning: options.warning,
+      warningTitle: options.warningTitle,
+    }).then(result => result !== null);
+  }
+
+  function alertModal(message, options = {}) {
+    return showBusinessModal({
+      title: options.title || '提示',
+      description: message,
+      confirmText: options.confirmText || '知道了',
+      cancelText: null,
+    }).then(() => {});
+  }
+
+  function promptModal(message, defaultValue = '', options = {}) {
+    return showBusinessModal({
+      title: options.title || '请输入',
+      description: message,
+      fields: [{ name: 'value', label: options.label || '内容', type: options.textarea ? 'textarea' : 'text', value: String(defaultValue ?? ''), required: options.required !== false, placeholder: options.placeholder || '' }],
+      confirmText: options.confirmText || '确认',
+      cancelText: options.cancelText || '取消',
+    }).then(result => result ? String(result.value || '').trim() : null);
+  }
+
   function localDeadlineValue(dayOffset, hour = 18, minute = 0) {
   function localDeadlineValue(dayOffset, hour = 18, minute = 0) {
     const date = new Date();
     const date = new Date();
     date.setDate(date.getDate() + dayOffset);
     date.setDate(date.getDate() + dayOffset);
@@ -796,7 +870,7 @@
 
 
   async function openCustomerSelector(options = {}) {
   async function openCustomerSelector(options = {}) {
     const { multiple = true, selectedIds = [], title = '选择客户', confirmText = '确定' } = options;
     const { multiple = true, selectedIds = [], title = '选择客户', confirmText = '确定' } = options;
-    const selected = new Set(selectedIds.filter(Boolean));
+    const selected = new Set(selectedIds.filter(Boolean).map(String));
     const loadedCustomers = new Map();
     const loadedCustomers = new Map();
     let query = '';
     let query = '';
     let portraitFilter = '';
     let portraitFilter = '';
@@ -832,11 +906,14 @@
         if (currentPage > totalPages) currentPage = totalPages || 1;
         if (currentPage > totalPages) currentPage = totalPages || 1;
         loadedCustomers.clear();
         loadedCustomers.clear();
         for (const c of items) {
         for (const c of items) {
-          if (c.externalUserId) loadedCustomers.set(c.externalUserId, c);
+          const id = String(c.externalUserId || '').trim();
+          if (id) loadedCustomers.set(id, { ...c, externalUserId: id });
         }
         }
         renderList();
         renderList();
         renderPagination();
         renderPagination();
       } catch (err) {
       } catch (err) {
+        const listEl = document.getElementById('customer-selector-list');
+        if (listEl) listEl.innerHTML = '<div class="customer-selector-empty error">客户列表加载失败,请稍后重试</div>';
         toast('加载客户列表失败', 'error');
         toast('加载客户列表失败', 'error');
       } finally {
       } finally {
         isLoading = false;
         isLoading = false;
@@ -865,7 +942,7 @@
         return;
         return;
       }
       }
       listEl.innerHTML = items.map(c => {
       listEl.innerHTML = items.map(c => {
-        const isSelected = selected.has(c.externalUserId);
+        const isSelected = selected.has(String(c.externalUserId || ''));
         const portraitBadge = c.hasPortrait ? '<span class="badge badge-info" style="margin-left:6px">已画像</span>' : '';
         const portraitBadge = c.hasPortrait ? '<span class="badge badge-info" style="margin-left:6px">已画像</span>' : '';
         return `
         return `
           <div class="customer-selector-item ${isSelected ? 'selected' : ''}" data-id="${escapeHtml(c.externalUserId || '')}">
           <div class="customer-selector-item ${isSelected ? 'selected' : ''}" data-id="${escapeHtml(c.externalUserId || '')}">
@@ -985,8 +1062,8 @@
       sortEl.addEventListener('change', scheduleReload);
       sortEl.addEventListener('change', scheduleReload);
 
 
       document.getElementById('btn-confirm-customer-selector').addEventListener('click', () => {
       document.getElementById('btn-confirm-customer-selector').addEventListener('click', () => {
-        const selectedCustomers = Array.from(loadedCustomers.values()).filter(c => selected.has(c.externalUserId));
-        const externalUserIds = selectedCustomers.map(c => c.externalUserId).filter(Boolean);
+        const selectedCustomers = Array.from(loadedCustomers.values()).filter(c => selected.has(String(c.externalUserId || '')));
+        const externalUserIds = selectedCustomers.map(c => String(c.externalUserId || '')).filter(Boolean);
         closeModal();
         closeModal();
         resolve({ externalUserIds, customers: selectedCustomers });
         resolve({ externalUserIds, customers: selectedCustomers });
       });
       });
@@ -1047,13 +1124,19 @@
 
 
   function setActiveNav(page) {
   function setActiveNav(page) {
     state.page = page;
     state.page = page;
-    document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
+    document.querySelectorAll('.nav-item').forEach(el => {
+      el.classList.remove('active');
+      el.removeAttribute('aria-current');
+    });
     const active = document.querySelector(`.nav-item[data-page="${page}"]`);
     const active = document.querySelector(`.nav-item[data-page="${page}"]`);
-    if (active) active.classList.add('active');
+    if (active) {
+      active.classList.add('active');
+      active.setAttribute('aria-current', 'page');
+    }
   }
   }
 
 
-  function renderPage() {
-    state.renderToken += 1;
+  async function renderPage() {
+    const renderToken = ++state.renderToken;
     const page = location.hash.slice(1) || 'agent';
     const page = location.hash.slice(1) || 'agent';
     const loggedIn = !!currentAccount();
     const loggedIn = !!currentAccount();
     els.nav.classList.toggle('nav-locked', !loggedIn);
     els.nav.classList.toggle('nav-locked', !loggedIn);
@@ -1072,14 +1155,27 @@
       return;
       return;
     }
     }
 
 
-    if (page === 'agent') renderAgentPage();
-    else if (page === 'skills') renderSkillsPage();
-    else if (page === 'knowledge') renderKnowledgePage();
-    else if (page === 'status') renderStatusPage();
-    else if (page === 'customer-ops') renderCustomerOpsPage();
-    else if (page === 'portraits') renderPortraitsPage();
-    else if (page === 'transfers') renderTransfersPage();
-    else renderGroupsPage();
+    try {
+      if (page === 'agent') await renderAgentPage();
+      else if (page === 'skills') await renderSkillsPage();
+      else if (page === 'knowledge') await renderKnowledgePage();
+      else if (page === 'status') await renderStatusPage();
+      else if (page === 'customer-ops') await renderCustomerOpsPage();
+      else if (page === 'portraits') await renderPortraitsPage();
+      else if (page === 'transfers') await renderTransfersPage();
+      else if (page === 'group-ops') await renderGroupOperationsPage();
+      else await renderGroupsPage();
+    } catch (error) {
+      if (renderToken !== state.renderToken || page !== state.page) return;
+      const current = els.content.querySelector('.page');
+      if (current) showError(current, error.message || '页面加载失败', () => renderPage());
+      else {
+        const fallback = document.createElement('div');
+        fallback.className = 'page';
+        els.content.appendChild(fallback);
+        showError(fallback, error.message || '页面加载失败', () => renderPage());
+      }
+    }
   }
   }
 
 
   // ==================== Unified Skill Center ====================
   // ==================== Unified Skill Center ====================
@@ -2248,6 +2344,15 @@
       state.agent.conversations = nextConversations;
       state.agent.conversations = nextConversations;
       state.agent.signature = nextSignature;
       state.agent.signature = nextSignature;
       if (!state.agent.selectedId && state.agent.conversations.length) state.agent.selectedId = state.agent.conversations[0].id;
       if (!state.agent.selectedId && state.agent.conversations.length) state.agent.selectedId = state.agent.conversations[0].id;
+      state.agent.cachedAccountKey = String(activeAccount.uid || agentAccountKey(localAccount) || '').trim() || null;
+      saveAgentSnapshot({
+        accountKey: state.agent.cachedAccountKey,
+        status: state.agent.status,
+        conversations: state.agent.conversations,
+        selectedId: state.agent.selectedId,
+        signature: state.agent.signature,
+        savedAt: new Date().toISOString()
+      });
       const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
       const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
       if ((changed || waitingForFirstRender) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
       if ((changed || waitingForFirstRender) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
     } catch (error) {
     } catch (error) {
@@ -2257,17 +2362,34 @@
     }
     }
   }
   }
 
 
+  function startAgentPageRuntime(page) {
+    if (!page || !page.isConnected || page.__agentRuntimeStarted) return;
+    page.__agentRuntimeStarted = true;
+    void loadAgentData(page);
+    state.agent.timer = setInterval(() => loadAgentData(page, true), 5000);
+  }
+
   function renderAgentPage() {
   function renderAgentPage() {
     els.pageTitle.textContent = '智能会话';
     els.pageTitle.textContent = '智能会话';
-    state.agent.signature = null;
+    const currentAgentKey = agentAccountKey();
+    if (state.agent.cachedAccountKey && currentAgentKey && state.agent.cachedAccountKey !== currentAgentKey) {
+      state.agent.status = null;
+      state.agent.conversations = [];
+      state.agent.selectedId = null;
+      state.agent.signature = null;
+      state.agent.cachedAccountKey = null;
+    }
     if (!currentAccount()) {
     if (!currentAccount()) {
       els.accountName.textContent = '正在检测账号';
       els.accountName.textContent = '正在检测账号';
       els.accountDot.className = 'account-dot offline';
       els.accountDot.className = 'account-dot offline';
     }
     }
     const page = document.createElement('div');
     const page = document.createElement('div');
     page.className = 'page agent-page';
     page.className = 'page agent-page';
-    page.innerHTML = '<div class="agent-loading">正在读取企微智能会话…</div>';
+    page.innerHTML = state.agent.status
+      ? ''
+      : '<div class="agent-loading">正在读取企微智能会话…</div>';
     els.content.appendChild(page);
     els.content.appendChild(page);
+    if (state.agent.status) renderAgentWorkspaceV2(page);
 
 
     page.addEventListener('click', async event => {
     page.addEventListener('click', async event => {
       const conversationButton = event.target.closest('[data-conversation-id]');
       const conversationButton = event.target.closest('[data-conversation-id]');
@@ -2378,8 +2500,7 @@
       }
       }
     });
     });
 
 
-    loadAgentData(page);
-    state.agent.timer = setInterval(() => loadAgentData(page, true), 5000);
+    if (!state.bootstrapping) startAgentPageRuntime(page);
   }
   }
 
 
   // ==================== Login ====================
   // ==================== Login ====================
@@ -2462,9 +2583,10 @@
       }
       }
 
 
       if (!qrcodeFile) {
       if (!qrcodeFile) {
+        const message = res.assistantMessage || res.errors?.[0]?.message || '未获取到二维码,请重试。';
         wrap.innerHTML = `<div class="hint-box" style="text-align:left">
         wrap.innerHTML = `<div class="hint-box" style="text-align:left">
             <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 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></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 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
-            <div>未获取到二维码,请重试。</div>
+            <div>${escapeHtml(message)}</div>
           </div>
           </div>
           <button class="btn" id="btn-retry-login">重试</button>`;
           <button class="btn" id="btn-retry-login">重试</button>`;
         document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
         document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
@@ -2598,7 +2720,7 @@
         return;
         return;
       }
       }
       addAccount(account);
       addAccount(account);
-      const switched = await switchAccount(state.currentAccountId, { silent: true });
+      const switched = await switchAccount(state.currentAccountId, { silent: true, deferRender: state.bootstrapping });
       if (!switched) return;
       if (!switched) return;
       state.loginUid = null;
       state.loginUid = null;
       toast(`登录成功:${account.nickname || account.userId}`);
       toast(`登录成功:${account.nickname || account.userId}`);
@@ -2727,24 +2849,18 @@
   }
   }
 
 
   // ==================== Status Page ====================
   // ==================== Status Page ====================
-  async function renderStatusPage() {
-    els.pageTitle.textContent = '账号状态';
-    const page = document.createElement('div');
-    page.className = 'page';
-    els.content.appendChild(page);
-    showLoading(page, 'list');
-
-    try {
-      const res = await api('GET', '/api/status');
-      hideLoading(page);
+  function renderStatusContent(page, res, { localOnly = false, scheduleRefresh = true } = {}) {
       const login = res.data?.login || {};
       const login = res.data?.login || {};
       const sub = res.data?.subscription || {};
       const sub = res.data?.subscription || {};
-      const online = !!login.summary?.online;
-      const subscribed = !!sub.summary?.subscribed;
+      const loginPending = res.summary?.loginPending === true;
+      const subscriptionPending = res.summary?.subscriptionPending === true;
+      const authPending = res.summary?.authConfigured === null;
+      const online = !loginPending && !!login.summary?.online;
+      const subscribed = !subscriptionPending && !!sub.summary?.subscribed;
       const acc = currentAccount() || {};
       const acc = currentAccount() || {};
 
 
       // 同步已保存账号的在线状态,确保表格显示与当前状态一致
       // 同步已保存账号的在线状态,确保表格显示与当前状态一致
-      syncAccountOnlineStatus(online, login.data?.detail);
+      if (!localOnly && !loginPending) syncAccountOnlineStatus(online, login.data?.detail);
 
 
       const currentAcc = {
       const currentAcc = {
         nickname: acc.nickname || login.summary?.nickname || '当前账号',
         nickname: acc.nickname || login.summary?.nickname || '当前账号',
@@ -2755,7 +2871,7 @@
       page.innerHTML = `
       page.innerHTML = `
         <div class="status-cards">
         <div class="status-cards">
           <div class="status-card">
           <div class="status-card">
-            <div class="status-icon ${online ? 'green' : 'red'}">
+            <div class="status-icon ${loginPending ? 'orange' : online ? 'green' : 'red'}">
               <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-4z"/></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-4z"/></svg>
             </div>
             </div>
             <div class="status-content">
             <div class="status-content">
@@ -2763,31 +2879,31 @@
               <p>${escapeHtml(currentAcc.corpName || '企业微信账号')}</p>
               <p>${escapeHtml(currentAcc.corpName || '企业微信账号')}</p>
               <div class="status-detail">
               <div class="status-detail">
                 <span class="status-dot ${online ? 'online' : 'offline'}"></span>
                 <span class="status-dot ${online ? 'online' : 'offline'}"></span>
-                ${online ? '账号在线,可正常操作' : '账号离线,请重新登录'}
+                ${loginPending ? '正在后台确认账号连接状态…' : online ? '账号在线,可正常操作' : '账号离线,请重新登录'}
               </div>
               </div>
-              ${!online ? `<div id="status-recover-wrap" style="margin-top:12px"></div>` : ''}
+              ${!loginPending && !online ? `<div id="status-recover-wrap" style="margin-top:12px"></div>` : ''}
             </div>
             </div>
           </div>
           </div>
 
 
           <div class="status-card">
           <div class="status-card">
-            <div class="status-icon ${subscribed ? 'green' : 'orange'}">
+            <div class="status-icon ${subscriptionPending ? 'orange' : subscribed ? 'green' : 'orange'}">
               <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
               <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
             </div>
             </div>
             <div class="status-content">
             <div class="status-content">
-              <h3>${subscribed ? '订阅有效' : '未开通订阅'}</h3>
-              <p>${escapeHtml(sub.summary?.expireAt ? `到期时间:${sub.summary.expireAt}` : '请检查订阅状态')}</p>
+              <h3>${subscriptionPending ? '正在检查订阅' : subscribed ? '订阅有效' : '未开通订阅'}</h3>
+              <p>${subscriptionPending ? '页面已先展示,远程订阅状态将在后台补充' : escapeHtml(sub.summary?.expireAt ? `到期时间:${sub.summary.expireAt}` : '请检查订阅状态')}</p>
               ${sub.summary?.seats ? `<div class="status-detail">席位:${formatNumber(sub.summary.seats)} 个</div>` : ''}
               ${sub.summary?.seats ? `<div class="status-detail">席位:${formatNumber(sub.summary.seats)} 个</div>` : ''}
-              <button class="btn btn-sm" id="btn-subscribe-action" style="margin-top:10px">${subscribed ? '续费/管理' : '开通订阅'}</button>
+              <button class="btn btn-sm" id="btn-subscribe-action" style="margin-top:10px" ${subscriptionPending ? 'disabled' : ''}>${subscriptionPending ? '检查中…' : subscribed ? '续费/管理' : '开通订阅'}</button>
             </div>
             </div>
           </div>
           </div>
 
 
           <div class="status-card">
           <div class="status-card">
-            <div class="status-icon ${res.summary?.authConfigured ? 'green' : 'red'}">
+            <div class="status-icon ${authPending ? 'orange' : res.summary?.authConfigured ? 'green' : 'red'}">
               <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg>
               <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg>
             </div>
             </div>
             <div class="status-content">
             <div class="status-content">
-              <h3>${res.summary?.authConfigured ? '鉴权已配置' : '鉴权未配置'}</h3>
-              <p>${res.summary?.authConfigured ? '已检测到 Fmode 鉴权 Token' : '请配置 QIWEI_AUTH_TOKEN 或 FMODE_API_KEY'}</p>
+              <h3>${authPending ? '正在检查鉴权' : res.summary?.authConfigured ? '鉴权已配置' : '鉴权未配置'}</h3>
+              <p>${authPending ? '正在读取当前账号配置' : res.summary?.authConfigured ? '已检测到 Fmode 鉴权 Token' : '请配置 QIWEI_AUTH_TOKEN 或 FMODE_API_KEY'}</p>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
@@ -2852,12 +2968,363 @@
       if (recoverWrap) {
       if (recoverWrap) {
         renderRecoverButton(recoverWrap);
         renderRecoverButton(recoverWrap);
       }
       }
+      if (scheduleRefresh && (loginPending || subscriptionPending)) {
+        setTimeout(() => {
+          if (state.page === 'status' && page.isConnected) renderPage();
+        }, 900);
+      }
+  }
+
+  async function renderStatusPage() {
+    els.pageTitle.textContent = '账号状态';
+    const page = document.createElement('div');
+    page.className = 'page';
+    els.content.appendChild(page);
+    const account = currentAccount() || {};
+    const accountKey = account.id || account.guid || account.uid || account.userId || 'local';
+    const cached = state.status?.accountKey === accountKey ? state.status.response : null;
+    const immediate = cached || {
+      status: 'ok',
+      summary: {
+        authConfigured: null,
+        online: Boolean(account.online),
+        subscribed: null,
+        loginPending: false,
+        subscriptionPending: true
+      },
+      data: {
+        login: {
+          status: 'ok',
+          summary: { online: Boolean(account.online), nickname: account.nickname || account.userId || '当前账号' },
+          data: { detail: { userId: account.userId || '', corpName: account.corpName || '' } }
+        },
+        subscription: { status: 'pending', summary: {}, data: {} }
+      }
+    };
+
+    // 首帧直接使用本地账号或上次结果,避免页面切换后出现空白/骨架等待。
+    renderStatusContent(page, immediate, { localOnly: !cached, scheduleRefresh: false });
+
+    try {
+      const res = await api('GET', '/api/status?fast=true');
+      state.status = { accountKey, response: res };
+      if (state.page !== 'status' || !page.isConnected) return;
+      renderStatusContent(page, res);
     } catch (err) {
     } catch (err) {
-      showError(page, err.message || '加载状态失败', () => renderStatusPage());
+      // 已有本地首屏时保留内容,只提示刷新失败,避免整页退化为空白错误态。
+      toast(err.message || '账号状态刷新失败,当前显示本地缓存', 'warning');
     }
     }
   }
   }
 
 
   // ==================== Groups Page ====================
   // ==================== Groups Page ====================
+  // ==================== Group Operations ====================
+  function groupOpsAccountKey() {
+    const account = currentAccount();
+    return account?.guid || account?.uid || account?.userId || 'local-default';
+  }
+
+  function groupOpsStatusBadge(status) {
+    const labels = { pending_review: '待审核', approved: '已批准', sent: '已执行', sending: '发送中', failed: '失败', dry_run: '预演通过', blocked: '已拦截', rejected: '已拒绝', skipped: '已跳过', published: '已发布', draft: '草稿', open: '待处理', in_progress: '处理中', done: '已完成', acknowledged: '处理中', resolved: '已关闭', false_positive: '误报' };
+    const style = status === 'sent' || status === 'published' || status === 'resolved' || status === 'done' || status === 'dry_run' ? 'success' : status === 'failed' || status === 'open' ? 'error' : status === 'rejected' || status === 'false_positive' || status === 'blocked' ? 'default' : 'warning';
+    return `<span class="badge badge-${style}">${escapeHtml(labels[status] || status || '-')}</span>`;
+  }
+
+  function renderGroupOpsContent(page) {
+    const overview = state.groupOps.overview || { metrics: {}, plans: [], findings: [] };
+    const m = overview.metrics || {};
+    const plans = overview.plans || [];
+    const findings = overview.findings || [];
+    const playbooks = state.groupOps.playbooks || [];
+    const stores = overview.stores || [];
+    const insights = overview.insights || [];
+    const scores = overview.scores || [];
+    const tasks = overview.tasks || [];
+    const settings = overview.qualitySettings || { sla_minutes: 120, risk_terms: [], forbidden_claims: [] };
+    const automation = overview.automation || { policies: [], state: { paused: 1, breaker_open: 0 }, candidates: [], attempts: [], liveSendEnabled: false };
+    const automationPolicy = automation.activePolicy?.policy || null;
+    const activeView = state.groupOps.view || 'overview';
+    const hidden = view => activeView === view ? '' : 'hidden';
+    page.innerHTML = `
+      <section class="workspace-hero customer-workspace-hero group-ops-hero">
+        <div>
+          <span>WECOM COMMUNITY OPERATIONS</span>
+          <h2>社群运营中心</h2>
+          <p>把总部 SOP 转化为门店每日可审核、可执行、可追溯的客户群任务。</p>
+        </div>
+        <div class="group-ops-hero-side">
+          <div class="group-ops-hero-status" aria-label="社群运营当前状态">
+            <span><strong>${formatNumber(m.dueItems || 0)}</strong>今日任务</span>
+            <span><strong>${formatNumber(m.pendingReview || 0)}</strong>待审话术</span>
+            <span class="${Number(m.highRisk || 0) > 0 ? 'is-risk' : ''}"><strong>${formatNumber(m.highRisk || 0)}</strong>高风险</span>
+          </div>
+          <button class="btn group-ops-refresh" id="group-ops-refresh" aria-label="刷新社群运营数据">
+            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17.65 6.35A7.95 7.95 0 0 0 12 4a8 8 0 1 0 7.75 10h-2.1A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h8V3l-3.35 3.35Z"/></svg>
+            刷新数据
+          </button>
+        </div>
+      </section>
+      <nav class="group-ops-tabs" aria-label="社群运营功能">
+        ${[['overview','运营总览'],['workbench','今日工作台'],['groups','群洞察'],['playbooks','SOP 版本'],['quality','质检督导'],['automation','受控自动化']].map(([key,label]) => `<button class="${activeView === key ? 'active' : ''}" data-group-ops-view="${key}">${label}</button>`).join('')}
+      </nav>
+      <div class="kpi-row group-ops-stats" data-group-ops-panel="overview" ${hidden('overview')}>
+        <div class="kpi-card"><span class="kpi-label">纳管客户群</span><strong class="kpi-value">${formatNumber(m.groups || 0)}</strong><span class="kpi-sub">已进入运营范围</span></div>
+        <div class="kpi-card"><span class="kpi-label">今日运营任务</span><strong class="kpi-value">${formatNumber(m.dueItems || 0)}</strong><span class="kpi-sub">待门店执行</span></div>
+        <div class="kpi-card"><span class="kpi-label">今日执行率</span><strong class="kpi-value">${m.executionRate === null || m.executionRate === undefined ? '-' : `${m.executionRate}%`}</strong><span class="kpi-sub">无任务时不计算</span></div>
+        <div class="kpi-card"><span class="kpi-label">平均群健康分</span><strong class="kpi-value">${m.averageHealthScore === null || m.averageHealthScore === undefined ? '-' : m.averageHealthScore}</strong><span class="kpi-sub">数据覆盖 ${m.averageHealthCoverage || 0}%</span></div>
+        <div class="kpi-card"><span class="kpi-label">待审核话术</span><strong class="kpi-value">${formatNumber(m.pendingReview || 0)}</strong><span class="kpi-sub">需人工确认后执行</span></div>
+        <div class="kpi-card danger"><span class="kpi-label">高风险未闭环</span><strong class="kpi-value">${formatNumber(m.highRisk || 0)}</strong><span class="kpi-sub">建议优先处理</span></div>
+      </div>
+      <section class="card group-ops-workbench" data-group-ops-panel="overview" ${hidden('overview')}>
+        <div class="card-header"><div><h3>门店执行对比</h3><p>优先显示高风险多、执行率低的门店;无任务时不计算执行率。</p></div><span>${stores.length} 家门店</span></div>
+        <div class="table-wrap"><table><thead><tr><th>门店</th><th>客户群</th><th>今日任务</th><th>已完成</th><th>执行率</th><th>风险</th></tr></thead><tbody>
+          ${stores.map(item => `<tr><td><strong>${escapeHtml(item.storeId)}</strong></td><td>${item.groups}</td><td>${item.dueItems}</td><td>${item.completedItems}</td><td>${item.executionRate === null ? '数据不足' : `${item.executionRate}%`}</td><td>${item.highRisk ? `<span class="badge badge-error">${item.highRisk} 高风险</span>` : `<span class="badge badge-success">${item.openFindings} 待处理</span>`}</td></tr>`).join('') || '<tr><td colspan="6">暂无门店数据</td></tr>'}
+        </tbody></table></div>
+      </section>
+      <section class="card group-ops-workbench" data-group-ops-panel="groups" ${hidden('groups')}>
+        <div class="card-header"><div><h3>群活跃趋势与生命周期建议</h3><p>活跃度只反映消息事实,不用于员工绩效排名。</p></div><span>${insights.length} 个群</span></div>
+        <div class="table-wrap"><table><thead><tr><th>客户群</th><th>门店</th><th>近 7 天消息</th><th>活跃成员</th><th>趋势</th><th>建议</th><th>覆盖率</th></tr></thead><tbody>
+          ${insights.map(item => `<tr><td><strong>${escapeHtml(item.room_name || item.room_id)}</strong><br><small>${escapeHtml(item.lifecycle_stage)}</small></td><td>${escapeHtml(item.store_id || '未分配')}</td><td>${item.activity.currentMessages}</td><td>${item.activity.activeSpeakers}</td><td>${item.activity.trend === null ? '无对比基线' : `${item.activity.trend > 0 ? '+' : ''}${item.activity.trend}%`}</td><td>${escapeHtml(item.lifecycleSuggestion)}</td><td>${item.dataCoverage}%</td></tr>`).join('') || '<tr><td colspan="7">暂无群消息数据</td></tr>'}
+        </tbody></table></div>
+        <div class="card-header"><div><h3>今日群健康评分</h3><p>只按有可靠数据的分项重新分配权重;点击证据数量可继续定位计划、消息和预警。</p></div><span>健康 ${m.healthDistribution?.healthy || 0} · 关注 ${m.healthDistribution?.attention || 0} · 风险 ${m.healthDistribution?.risk || 0} · 暂不可评估 ${m.healthDistribution?.unavailable || 0}</span></div>
+        <div class="table-wrap"><table><thead><tr><th>客户群</th><th>健康分</th><th>覆盖率</th><th>SOP</th><th>响应</th><th>风险</th><th>互动</th></tr></thead><tbody>
+          ${scores.map(item => `<tr><td><strong>${escapeHtml(item.roomName || item.roomId)}</strong></td><td>${item.totalScore === null ? '暂不可评估' : item.totalScore}</td><td>${item.coverage}%</td>${['sop','response','risk','engagement'].map(key => { const component = item.components?.[key]; return `<td title="证据 ${component?.evidenceCount || 0} 条">${component?.available ? component.score : '数据不足'}</td>`; }).join('')}</tr>`).join('') || '<tr><td colspan="7">暂无可评分群数据</td></tr>'}
+        </tbody></table></div>
+      </section>
+      <section class="card group-ops-automation" data-group-ops-panel="automation" ${hidden('automation')}>
+        <div class="card-header"><div><h3>受控自动化策略</h3><p>默认暂停。真实发送还需要环境开关、白名单、灰度、时间窗、频控和内容安全全部通过。</p></div><div class="group-ops-automation-state"><span class="badge ${automation.state?.breaker_open ? 'badge-error' : automation.state?.paused ? 'badge-warning' : 'badge-success'}">${automation.state?.breaker_open ? '已熔断' : automation.state?.paused ? '已暂停' : '运行中'}</span><span class="badge ${automation.liveSendEnabled ? 'badge-warning' : 'badge-default'}">真实发送 ${automation.liveSendEnabled ? '已开放' : '未开放'}</span></div></div>
+        <div class="group-ops-automation-grid">
+          <form id="group-ops-automation-form" class="group-ops-form">
+            <label for="group-ops-auto-groups">群白名单</label><textarea id="group-ops-auto-groups" rows="3" placeholder="多个 roomId 用逗号隔开">${escapeHtml((automationPolicy?.allowedGroupIds || []).join(','))}</textarea>
+            <label for="group-ops-auto-types">允许内容类型</label><input id="group-ops-auto-types" value="${escapeHtml((automationPolicy?.allowedTypes || ['welcome','content']).join(','))}" />
+            <label for="group-ops-auto-window-start">允许时间窗</label><div class="group-ops-time-window"><input id="group-ops-auto-window-start" type="time" value="${escapeHtml(automationPolicy?.windows?.[0]?.start || '09:00')}" /><span>至</span><input id="group-ops-auto-window-end" type="time" value="${escapeHtml(automationPolicy?.windows?.[0]?.end || '18:00')}" /></div>
+            <label for="group-ops-auto-gray">灰度比例</label><input id="group-ops-auto-gray" type="number" min="0" max="100" value="${Number(automationPolicy?.grayPercent ?? 0)}" />
+            <label for="group-ops-auto-daily">单群每日上限</label><input id="group-ops-auto-daily" type="number" min="1" max="20" value="${Number(automationPolicy?.dailyLimitPerGroup || 1)}" />
+            <label for="group-ops-auto-failures">连续失败熔断</label><input id="group-ops-auto-failures" type="number" min="1" max="20" value="${Number(automationPolicy?.consecutiveFailureLimit || 3)}" />
+            <button class="btn" type="submit">保存新策略草稿</button>
+          </form>
+          <div class="group-ops-automation-summary">
+            <h4>${automation.activePolicy ? `当前策略 v${automation.activePolicy.version}` : '尚未激活策略'}</h4>
+            <dl><div><dt>模式</dt><dd>${escapeHtml(automationPolicy?.mode || 'dry_run')}</dd></div><div><dt>白名单群</dt><dd>${automationPolicy?.allowedGroupIds?.length || 0}</dd></div><div><dt>灰度</dt><dd>${automationPolicy?.grayPercent || 0}%</dd></div><div><dt>连续失败</dt><dd>${automation.state?.consecutive_failures || 0}/${automationPolicy?.consecutiveFailureLimit || 3}</dd></div></dl>
+            <div class="group-ops-automation-actions">${automation.policies?.find(item => item.status === 'draft') ? `<button class="btn btn-secondary" data-group-ops-policy-activate="${escapeHtml(automation.policies.find(item => item.status === 'draft').id)}">激活最新草稿</button>` : ''}<button class="btn btn-secondary" data-group-ops-automation-action="preview">预演闸门</button><button class="btn btn-secondary" data-group-ops-automation-action="run">执行 dry-run</button>${automation.state?.paused ? '<button class="btn btn-secondary" data-group-ops-automation-action="resume">恢复运行</button>' : '<button class="btn btn-danger" data-group-ops-automation-action="pause">一键暂停</button>'}${automation.state?.breaker_open ? '<button class="btn btn-secondary" data-group-ops-automation-action="reset_breaker">人工复位熔断</button>' : ''}</div>
+          </div>
+        </div>
+        <div class="card-header group-ops-automation-log-header"><div><h3>预演与执行记录</h3><p>每次判断都保存策略版本、通过/拦截原因和回执。</p></div><span>${automation.attempts?.length || 0} 条</span></div>
+        <div class="table-wrap"><table><thead><tr><th>时间</th><th>群</th><th>模式</th><th>状态</th><th>策略</th><th>错误</th></tr></thead><tbody>${(automation.attempts || []).map(item => `<tr><td>${escapeHtml(formatAgentTime(item.created_at))}</td><td>${escapeHtml(item.room_id)}</td><td>${escapeHtml(item.mode)}</td><td>${groupOpsStatusBadge(item.status)}</td><td>v${item.policy_version || '-'}</td><td>${escapeHtml(item.error || '-')}</td></tr>`).join('') || '<tr><td colspan="6">暂无自动化记录。先保存并激活 dry-run 策略。</td></tr>'}</tbody></table></div>
+      </section>
+      <section class="card group-ops-builder" data-group-ops-panel="playbooks" ${hidden('playbooks')}>
+          <div class="card-header"><div><h3>SOP 与计划</h3><p>先发布公司标准,再为客户群生成每日任务。</p></div></div>
+          <form id="group-ops-playbook-form" class="group-ops-form">
+            <label for="group-ops-playbook-name">SOP 名称</label>
+            <input id="group-ops-playbook-name" required placeholder="例如:新客群 7 天服务 SOP" />
+            <label for="group-ops-template">首条标准话术</label>
+            <textarea id="group-ops-template" rows="3" required placeholder="支持 {{room_name}} 变量">欢迎加入 {{room_name}},今天由您的专属顾问为大家服务。</textarea>
+            <button class="btn" type="submit">创建 SOP 草稿</button>
+          </form>
+          <div class="group-ops-playbooks">
+            ${playbooks.map(item => `<article><div><strong>${escapeHtml(item.name)}</strong><span>v${item.version || 1} · ${escapeHtml(item.group_type)}</span></div>${groupOpsStatusBadge(item.status)}<button class="btn btn-sm btn-secondary" data-group-ops-preview="${escapeHtml(item.id)}">影响预览</button><button class="btn btn-sm btn-secondary" data-group-ops-new-version="${escapeHtml(item.id)}" data-template="${escapeHtml(item.content?.cadence?.[0]?.template || '')}">新版本</button>${item.status !== 'published' ? `<button class="btn btn-sm" data-group-ops-publish="${escapeHtml(item.id)}">发布</button>` : `<button class="btn btn-sm btn-secondary" data-group-ops-rollback="${escapeHtml(item.id)}">回滚</button>`}</article>`).join('') || '<p class="agent-muted">还没有 SOP。创建草稿并审核发布后即可生成计划。</p>'}
+          </div>
+          <form id="group-ops-plan-form" class="group-ops-form group-ops-plan-form">
+            <label for="group-ops-room-id">客户群 ID</label><input id="group-ops-room-id" required placeholder="roomId" />
+            <label for="group-ops-room-name">群名称</label><input id="group-ops-room-name" placeholder="例如:张先生服务群" />
+            <button class="btn" type="submit" ${playbooks.some(item => item.status === 'published') ? '' : 'disabled'}>生成今日计划</button>
+          </form>
+      </section>
+      <section class="card group-ops-workbench" data-group-ops-panel="workbench" ${hidden('workbench')}>
+        <div class="card-header"><div><h3>批量生成与整改任务</h3><p>按门店生成今日计划;失败群不会影响成功群。</p></div><span>${tasks.filter(item => ['open','in_progress'].includes(item.status)).length} 个整改任务</span></div>
+        <form id="group-ops-batch-form" class="group-ops-inline-form"><label for="group-ops-store-id">门店</label><select id="group-ops-store-id"><option value="">全部门店</option>${stores.map(item => `<option value="${escapeHtml(item.storeId === '未分配门店' ? '' : item.storeId)}">${escapeHtml(item.storeId)}</option>`).join('')}</select><button class="btn" type="submit" ${playbooks.some(item => item.status === 'published') ? '' : 'disabled'}>批量生成今日计划</button></form>
+        <div class="group-ops-task-list">${tasks.map(task => `<article><div><strong>${escapeHtml(task.title)}</strong><span>${escapeHtml(task.room_id || '跨群')} · ${escapeHtml(task.owner || '待分配')} · ${escapeHtml(task.due_at || '未设期限')}</span></div>${groupOpsStatusBadge(task.status)}${task.status !== 'done' && task.owner && task.due_at && !task.official_todo_id ? `<button class="btn btn-sm btn-secondary" data-group-ops-task-sync="${escapeHtml(task.id)}" data-owner="${escapeHtml(task.owner)}" data-due-at="${escapeHtml(task.due_at)}">同步企微待办</button>` : ''}${task.status !== 'done' ? `<button class="btn btn-sm btn-secondary" data-group-ops-task-done="${escapeHtml(task.id)}" data-official="${escapeHtml(task.official_todo_id || '')}">完成</button>` : ''}</article>`).join('') || '<p class="agent-muted">当前没有整改任务。</p>'}</div>
+      </section>
+      <section class="card group-ops-risks" data-group-ops-panel="quality" ${hidden('quality')}>
+          <div class="card-header"><div><h3>质检规则</h3><p>配置当前账号的响应 SLA、风险词和禁止承诺。</p></div><span>误报率 ${overview.feedback?.falsePositiveRate || 0}%</span></div>
+          <form id="group-ops-quality-form" class="group-ops-inline-form group-ops-quality-form"><label for="group-ops-sla">响应 SLA(分钟)</label><input id="group-ops-sla" type="number" min="1" value="${Number(settings.sla_minutes || 120)}" /><label for="group-ops-risk-terms">风险词</label><input id="group-ops-risk-terms" value="${escapeHtml((settings.risk_terms || []).join(','))}" /><label for="group-ops-forbidden">禁止承诺</label><input id="group-ops-forbidden" value="${escapeHtml((settings.forbidden_claims || []).join(','))}" /><button class="btn" type="submit">保存规则</button></form>
+          <div class="card-header"><div><h3>质检与预警</h3><p>所有结论都保留原消息证据。</p></div><span class="badge badge-error">${findings.length} 项</span></div>
+          <div class="group-ops-list">
+            ${findings.map(item => `<article class="group-ops-risk ${escapeHtml(item.severity)}"><div><strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.detail || '')}</p><small>${escapeHtml(item.room_id)} · ${escapeHtml(item.evidence?.[0]?.content || '证据已记录')}</small></div><div>${groupOpsStatusBadge(item.status)}<button class="btn btn-sm btn-secondary" data-group-ops-create-task="${escapeHtml(item.id)}" data-room-id="${escapeHtml(item.room_id)}" data-title="${escapeHtml(item.title)}">建整改任务</button><button class="btn btn-sm btn-secondary" data-group-ops-false-positive="${escapeHtml(item.id)}">标记误报</button><button class="btn btn-sm" data-group-ops-finding="${escapeHtml(item.id)}">关闭</button></div></article>`).join('') || '<div class="empty-state"><div class="empty-state-title">当前没有待处理预警</div><div class="empty-state-desc">通过 Agent 质检群消息后,风险会出现在这里。</div></div>'}
+          </div>
+      </section>
+      <section class="card group-ops-workbench" data-group-ops-panel="workbench" ${hidden('workbench')}>
+        <div class="card-header"><div><h3>今日工作台</h3><p>话术须人工批准;实际人工发送后再标记完成。</p></div><span>${plans.length} 个群计划</span></div>
+        <div class="table-wrap"><table><thead><tr><th>客户群</th><th>时间与目标</th><th>话术草稿</th><th>状态</th><th>操作</th></tr></thead><tbody>
+          ${plans.flatMap(plan => plan.items.map(item => `<tr><td><strong>${escapeHtml(plan.room_id)}</strong><br><small>${escapeHtml(plan.plan_date)}</small></td><td>${escapeHtml(formatAgentTime(item.scheduled_at))}<br><small>${escapeHtml(item.objective || item.type)}</small></td><td class="group-ops-copy">${escapeHtml(item.final_content || item.draft_content || '-')}</td><td>${groupOpsStatusBadge(item.status)}</td><td>${item.status === 'pending_review' ? `<button class="btn btn-sm" data-group-ops-item="${escapeHtml(item.id)}" data-action="approve">批准</button>` : item.status === 'approved' ? `<button class="btn btn-sm" data-group-ops-item="${escapeHtml(item.id)}" data-action="mark-sent">标记已执行</button>` : '-'}</td></tr>`)).join('') || '<tr><td colspan="5"><div class="empty-state"><div class="empty-state-title">今天还没有运营计划</div><div class="empty-state-desc">选择已发布 SOP,并为客户群生成今日计划。</div></div></td></tr>'}
+        </tbody></table></div>
+      </section>`;
+  }
+
+  async function loadGroupOperations(page) {
+    state.groupOps.loading = true;
+    try {
+      const key = encodeURIComponent(groupOpsAccountKey());
+      const [overview, playbooks] = await Promise.all([
+        api('GET', `/api/group-ops/overview?accountKey=${key}`),
+        api('GET', `/api/group-ops/playbooks?accountKey=${key}`)
+      ]);
+      state.groupOps.overview = overview.data;
+      state.groupOps.playbooks = playbooks.data?.playbooks || [];
+      renderGroupOpsContent(page);
+      bindGroupOperations(page);
+    } catch (error) { showError(page, error.message || '社群运营数据加载失败', () => loadGroupOperations(page)); }
+    finally { state.groupOps.loading = false; }
+  }
+
+  function bindGroupOperations(page) {
+    page.querySelector('#group-ops-refresh')?.addEventListener('click', () => loadGroupOperations(page));
+    page.querySelectorAll('[data-group-ops-view]').forEach(button => button.addEventListener('click', () => {
+      state.groupOps.view = button.dataset.groupOpsView;
+      renderGroupOpsContent(page);
+      bindGroupOperations(page);
+    }));
+    page.querySelector('#group-ops-automation-form')?.addEventListener('submit', async event => {
+      event.preventDefault(); const button = event.submitter; if (button) button.disabled = true;
+      const ids = page.querySelector('#group-ops-auto-groups').value.split(/[,,\s]+/).map(item => item.trim()).filter(Boolean);
+      const types = page.querySelector('#group-ops-auto-types').value.split(/[,,\s]+/).map(item => item.trim()).filter(Boolean);
+      try {
+        const result = await api('POST', '/api/group-ops/automation', { accountKey: groupOpsAccountKey(), action: 'create_policy', policy: { enabled: true, mode: 'dry_run', allowedGroupIds: ids, allowedTypes: types, windows: [{ days: [1,2,3,4,5], start: page.querySelector('#group-ops-auto-window-start').value, end: page.querySelector('#group-ops-auto-window-end').value }], grayPercent: Number(page.querySelector('#group-ops-auto-gray').value), dailyLimitPerGroup: Number(page.querySelector('#group-ops-auto-daily').value), weeklyLimitPerGroup: 7, consecutiveFailureLimit: Number(page.querySelector('#group-ops-auto-failures').value), requireNoOpenHighRisk: true }, changeNote: 'Dashboard 创建受控自动化策略', actor: 'dashboard' });
+        if (result.status === 'error') throw new Error(result.assistantMessage); toast(result.assistantMessage || '策略草稿已保存'); await loadGroupOperations(page);
+      } catch (error) { toast(error.message || '保存策略失败', 'error'); if (button) button.disabled = false; }
+    });
+    page.querySelectorAll('[data-group-ops-policy-activate]').forEach(button => button.addEventListener('click', async () => {
+      const ok = await confirmModal('激活后仍保持暂停。请确认白名单、时间窗、频控、灰度和熔断阈值均正确。', { title: '激活自动化策略' });
+      if (!ok) return;
+      button.disabled = true;
+      try { const result = await api('POST', '/api/group-ops/automation', { accountKey: groupOpsAccountKey(), action: 'activate', policyId: button.dataset.groupOpsPolicyActivate, confirmed: true, actor: 'dashboard' }); if (result.status === 'error') throw new Error(result.assistantMessage); toast(result.assistantMessage); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '策略激活失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelectorAll('[data-group-ops-automation-action]').forEach(button => button.addEventListener('click', async () => {
+      const action = button.dataset.groupOpsAutomationAction;
+      const confirmations = { resume: '恢复自动化运行?真实发送仍受独立环境开关保护。', pause: '立即暂停所有社群自动化?', reset_breaker: '确认故障原因已经排除并人工复位熔断?', run: '执行一次 dry-run?不会向真实群发送消息。' };
+      if (confirmations[action]) {
+        const ok = await confirmModal(confirmations[action], { title: '自动化操作确认' });
+        if (!ok) return;
+      }
+      button.disabled = true;
+      try { const result = await api('POST', '/api/group-ops/automation', { accountKey: groupOpsAccountKey(), action, execute: false, confirmed: ['resume','reset_breaker'].includes(action), actor: 'dashboard' }); if (result.status === 'error') throw new Error(result.assistantMessage); toast(result.assistantMessage || '自动化操作完成'); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '自动化操作失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelector('#group-ops-playbook-form')?.addEventListener('submit', async event => {
+      event.preventDefault();
+      const button = event.submitter; if (button) button.disabled = true;
+      try {
+        const name = page.querySelector('#group-ops-playbook-name').value.trim();
+        const template = page.querySelector('#group-ops-template').value.trim();
+        const result = await api('POST', '/api/group-ops/playbooks', { accountKey: groupOpsAccountKey(), name, groupType: 'customer', content: { cadence: [{ time: '09:30', type: 'welcome', objective: '完成标准客户服务触达', template }], rules: { responseSlaMinutes: 120, riskTerms: ['投诉','退款','举报'] } }, actor: 'dashboard' });
+        toast(result.assistantMessage || 'SOP 草稿已创建'); await loadGroupOperations(page);
+      } catch (error) { toast(error.message || '创建失败', 'error'); if (button) button.disabled = false; }
+    });
+    page.querySelectorAll('[data-group-ops-publish]').forEach(button => button.addEventListener('click', async () => {
+      button.disabled = true;
+      try {
+        const preview = await api('GET', `/api/group-ops/playbooks/${button.dataset.groupOpsPublish}/preview?accountKey=${encodeURIComponent(groupOpsAccountKey())}`);
+        const impact = preview.data?.preview;
+        const ok = await confirmModal(`将发布 v${impact?.targetVersion || '-'},影响 ${impact?.affectedGroupCount || 0} 个群、${impact?.affectedStores?.length || 0} 家门店。确认发布吗?`, { title: '发布 SOP' });
+        if (!ok) { button.disabled = false; return; }
+        await api('POST', `/api/group-ops/playbooks/${button.dataset.groupOpsPublish}/publish`, { accountKey: groupOpsAccountKey(), confirmed: true, actor: 'dashboard' }); toast('SOP 已发布'); await loadGroupOperations(page);
+      }
+      catch (error) { toast(error.message || '发布失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelectorAll('[data-group-ops-preview]').forEach(button => button.addEventListener('click', async () => {
+      try {
+        const result = await api('GET', `/api/group-ops/playbooks/${button.dataset.groupOpsPreview}/preview?accountKey=${encodeURIComponent(groupOpsAccountKey())}`);
+        const item = result.data?.preview;
+        await alertModal(`目标版本:v${item.targetVersion}\n当前版本:${item.currentVersion ? `v${item.currentVersion}` : '未发布'}\n影响群:${item.affectedGroupCount}\n影响门店:${item.affectedStores.length}\n变更字段:${item.changes.map(change => change.key).join('、') || '无'}`, { title: 'SOP 影响预览' });
+      }
+      catch (error) { toast(error.message || '预览失败', 'error'); }
+    }));
+    page.querySelectorAll('[data-group-ops-new-version]').forEach(button => button.addEventListener('click', async () => {
+      const template = await promptModal('请输入新版本的首条标准话术:', button.dataset.template || '', { title: '创建新版本', label: '首条标准话术' });
+      if (template === null || !template.trim()) return;
+      button.disabled = true;
+      try { await api('POST', `/api/group-ops/playbooks/${button.dataset.groupOpsNewVersion}/versions`, { accountKey: groupOpsAccountKey(), content: { cadence: [{ time: '09:30', type: 'content', objective: '完成标准客户服务触达', template: template.trim() }] }, changeNote: '由 Dashboard 创建新版本', actor: 'dashboard' }); toast('新版本草稿已创建'); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '创建版本失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelectorAll('[data-group-ops-rollback]').forEach(button => button.addEventListener('click', async () => {
+      const versionText = await promptModal('请输入要回滚并重新发布的版本号:', '1', { title: 'SOP 回滚', label: '版本号' });
+      if (versionText === null) return;
+      const version = Number(versionText);
+      if (!Number.isInteger(version) || version < 1) return;
+      button.disabled = true;
+      try {
+        const preview = await api('GET', `/api/group-ops/playbooks/${button.dataset.groupOpsRollback}/preview?accountKey=${encodeURIComponent(groupOpsAccountKey())}&version=${version}`);
+        const impact = preview.data?.preview;
+        const ok = await confirmModal(`确认回滚到 v${version}?将影响 ${impact?.affectedGroupCount || 0} 个群。`, { title: '确认回滚' });
+        if (!ok) { button.disabled = false; return; }
+        await api('POST', `/api/group-ops/playbooks/${button.dataset.groupOpsRollback}/rollback`, { accountKey: groupOpsAccountKey(), version, confirmed: true, actor: 'dashboard' }); toast(`已回滚到 v${version}`); await loadGroupOperations(page);
+      }
+      catch (error) { toast(error.message || '回滚失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelector('#group-ops-plan-form')?.addEventListener('submit', async event => {
+      event.preventDefault(); const button = event.submitter; if (button) button.disabled = true;
+      const roomId = page.querySelector('#group-ops-room-id').value.trim(); const roomName = page.querySelector('#group-ops-room-name').value.trim();
+      try { const result = await api('POST', `/api/group-ops/groups/${encodeURIComponent(roomId)}/generate-plan`, { accountKey: groupOpsAccountKey(), roomName, group: { roomId, roomName }, actor: 'dashboard' }); toast(result.assistantMessage || '计划已生成'); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '生成计划失败', 'error'); if (button) button.disabled = false; }
+    });
+    page.querySelectorAll('[data-group-ops-item]').forEach(button => button.addEventListener('click', async () => {
+      const action = button.dataset.action; const label = action === 'approve' ? '批准这条最终话术' : '确认已由人工实际发送';
+      const ok = await confirmModal(`${label}?此操作会写入审计记录。`, { title: '计划项确认' });
+      if (!ok) return;
+      button.disabled = true;
+      try { await api('POST', `/api/group-ops/plan-items/${button.dataset.groupOpsItem}/${action}`, { accountKey: groupOpsAccountKey(), actor: 'dashboard' }); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '操作失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelector('#group-ops-batch-form')?.addEventListener('submit', async event => {
+      event.preventDefault(); const button = event.submitter;
+      const ok = await confirmModal('将为筛选范围内所有客户群生成今日待审核计划,确认继续?', { title: '批量生成计划' });
+      if (!ok) return;
+      if (button) button.disabled = true;
+      try { const storeId = page.querySelector('#group-ops-store-id').value; const result = await api('POST', '/api/group-ops/plans/batch', { accountKey: groupOpsAccountKey(), storeId, actor: 'dashboard' }); toast(result.assistantMessage || '批量计划已完成'); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '批量生成失败', 'error'); if (button) button.disabled = false; }
+    });
+    page.querySelector('#group-ops-quality-form')?.addEventListener('submit', async event => {
+      event.preventDefault(); const button = event.submitter; if (button) button.disabled = true;
+      try { await api('POST', '/api/group-ops/quality-settings', { accountKey: groupOpsAccountKey(), slaMinutes: Number(page.querySelector('#group-ops-sla').value), riskTerms: page.querySelector('#group-ops-risk-terms').value.split(/[,,]/).map(item => item.trim()).filter(Boolean), forbiddenClaims: page.querySelector('#group-ops-forbidden').value.split(/[,,]/).map(item => item.trim()).filter(Boolean), actor: 'dashboard' }); toast('质检规则已保存'); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '保存规则失败', 'error'); if (button) button.disabled = false; }
+    });
+    page.querySelectorAll('[data-group-ops-create-task]').forEach(button => button.addEventListener('click', async () => {
+      const owner = await promptModal('请输入整改负责人姓名(可暂时留空):', '', { title: '创建整改任务', label: '负责人', required: false });
+      if (owner === null) return;
+      const dueAt = await promptModal('请输入截止时间,例如 2026-07-20 18:00(可暂时留空):', '', { title: '创建整改任务', label: '截止时间', required: false });
+      if (dueAt === null) return;
+      button.disabled = true;
+      try { await api('POST', '/api/group-ops/tasks', { accountKey: groupOpsAccountKey(), findingId: button.dataset.groupOpsCreateTask, roomId: button.dataset.roomId, title: `处理:${button.dataset.title}`, owner, dueAt, priority: 'high', actor: 'dashboard' }); toast('整改任务已创建'); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '创建任务失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelectorAll('[data-group-ops-false-positive]').forEach(button => button.addEventListener('click', async () => {
+      const reason = await promptModal('请说明误报原因,该反馈会进入质检评测集:', '规则不适用于当前语境', { title: '标记误报', label: '误报原因' });
+      if (reason === null) return;
+      button.disabled = true;
+      try { await api('POST', `/api/group-ops/findings/${button.dataset.groupOpsFalsePositive}`, { accountKey: groupOpsAccountKey(), status: 'false_positive', resolution: reason, actor: 'dashboard' }); toast('已标记误报并保存反馈'); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '提交反馈失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelectorAll('[data-group-ops-task-done]').forEach(button => button.addEventListener('click', async () => {
+      const hasOfficial = Boolean(button.dataset.official);
+      const ok = await confirmModal(hasOfficial ? '完成后将同步完成企微官方待办,确认继续?' : '确认完成该整改任务?', { title: '完成整改任务' });
+      if (!ok) return;
+      button.disabled = true;
+      try { await api('POST', `/api/group-ops/tasks/${button.dataset.groupOpsTaskDone}/update`, { accountKey: groupOpsAccountKey(), status: 'done', confirmed: hasOfficial, actor: 'dashboard' }); toast('整改任务已完成'); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '任务更新失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelectorAll('[data-group-ops-task-sync]').forEach(button => button.addEventListener('click', async () => {
+      const ok = await confirmModal(`将为 ${button.dataset.owner} 创建真实企微待办,截止 ${button.dataset.dueAt}。确认继续?`, { title: '同步企微待办' });
+      if (!ok) return;
+      button.disabled = true;
+      try { const result = await api('POST', `/api/group-ops/tasks/${button.dataset.groupOpsTaskSync}/sync-official`, { accountKey: groupOpsAccountKey(), owner: button.dataset.owner, dueAt: button.dataset.dueAt, confirmed: true, actor: 'dashboard' }); if (result.status === 'error') throw new Error(result.assistantMessage); toast(result.assistantMessage || '已同步企微待办'); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '企微待办同步失败', 'error'); button.disabled = false; }
+    }));
+    page.querySelectorAll('[data-group-ops-finding]').forEach(button => button.addEventListener('click', async () => {
+      const ok = await confirmModal('确认该风险已经处理完成?', { title: '关闭风险' });
+      if (!ok) return;
+      button.disabled = true;
+      try { await api('POST', `/api/group-ops/findings/${button.dataset.groupOpsFinding}`, { accountKey: groupOpsAccountKey(), status: 'resolved', resolution: '由 Dashboard 人工确认关闭', actor: 'dashboard' }); await loadGroupOperations(page); }
+      catch (error) { toast(error.message || '关闭失败', 'error'); button.disabled = false; }
+    }));
+  }
+
+  async function renderGroupOperationsPage() {
+    els.pageTitle.textContent = '社群运营';
+    const page = document.createElement('div'); page.className = 'page customer-workspace-page group-ops-page'; els.content.appendChild(page);
+    page.innerHTML = '<div class="loading"><div class="spinner"></div><span>正在汇总社群运营数据...</span></div>';
+    await loadGroupOperations(page);
+  }
+
   async function renderGroupsPage() {
   async function renderGroupsPage() {
     els.pageTitle.textContent = '客户群管理';
     els.pageTitle.textContent = '客户群管理';
     state.selectedGroupIds = state.selectedGroupIds || new Set();
     state.selectedGroupIds = state.selectedGroupIds || new Set();
@@ -3446,25 +3913,27 @@
     const recommendations = selected?.recommendations || [];
     const recommendations = selected?.recommendations || [];
     const timeline = selected?.timeline || [];
     const timeline = selected?.timeline || [];
     const intelligenceItems = `${openTasks.map(task => `<div class="customer-master-action-item"><strong>${escapeHtml(task.title)}</strong><p>${escapeHtml(task.evidence || task.reason || '')}</p><small>内部任务 · ${escapeHtml(agentTaskStatusLabel(task.status))}</small></div>`).join('')}${openAlerts.map(alert => `<div class="customer-master-action-item alert"><strong>${escapeHtml(alert.title)}</strong><p>${escapeHtml(alert.evidence || alert.detail || '')}</p><small>客户预警 · ${escapeHtml(alert.severity)}</small></div>`).join('')}`;
     const intelligenceItems = `${openTasks.map(task => `<div class="customer-master-action-item"><strong>${escapeHtml(task.title)}</strong><p>${escapeHtml(task.evidence || task.reason || '')}</p><small>内部任务 · ${escapeHtml(agentTaskStatusLabel(task.status))}</small></div>`).join('')}${openAlerts.map(alert => `<div class="customer-master-action-item alert"><strong>${escapeHtml(alert.title)}</strong><p>${escapeHtml(alert.evidence || alert.detail || '')}</p><small>客户预警 · ${escapeHtml(alert.severity)}</small></div>`).join('')}`;
+    const isFallback = Boolean(summary.fallback);
+    const fallbackBadge = isFallback ? '<span class="customer-fallback-badge">客户运营数据</span>' : '';
     const detail = selected ? `<article class="customer-master-detail">
     const detail = selected ? `<article class="customer-master-detail">
-      <header class="customer-master-detail-head"><div><span>REAL WECOM CUSTOMER</span><h3>${escapeHtml(selected.displayName)}</h3><p>${escapeHtml(selected.maskedId)} · ${escapeHtml(selected.source)} · 最近互动 ${escapeHtml(formatAgentTime(selected.lastMessageAt))}</p></div><div><span class="customer-stage ${escapeHtml(selected.stage?.key)}">${escapeHtml(selected.stage?.label)}</span><button class="btn btn-sm btn-secondary" data-customer-master-open="${escapeHtml(selected.id)}">转到智能会话</button></div></header>
+      <header class="customer-master-detail-head"><div><span>${isFallback ? 'CUSTOMER OPS REFERENCE' : 'REAL WECOM CUSTOMER'}</span><h3>${escapeHtml(selected.displayName)} ${fallbackBadge}</h3><p>${escapeHtml(selected.maskedId)} · ${escapeHtml(selected.source)} · 最近互动 ${escapeHtml(formatAgentTime(selected.lastMessageAt))}</p></div><div><span class="customer-stage ${escapeHtml(selected.stage?.key)}">${escapeHtml(selected.stage?.label)}</span>${selected.fallback ? '' : `<button class="btn btn-sm btn-secondary" data-customer-master-open="${escapeHtml(selected.id)}">转到智能会话</button>`}</div></header>
       <div class="customer-master-score-row"><div><strong>${selected.completenessScore}%</strong><span>画像完整度</span></div><div><strong>${selected.evidenceCoverage}%</strong><span>字段证据覆盖</span></div><div><strong>${selected.messageCount}</strong><span>已纳入消息</span></div><div><strong>${selected.openTaskCount}</strong><span>待跟进任务</span></div></div>
       <div class="customer-master-score-row"><div><strong>${selected.completenessScore}%</strong><span>画像完整度</span></div><div><strong>${selected.evidenceCoverage}%</strong><span>字段证据覆盖</span></div><div><strong>${selected.messageCount}</strong><span>已纳入消息</span></div><div><strong>${selected.openTaskCount}</strong><span>待跟进任务</span></div></div>
       <section class="customer-profile-fields"><header><strong>自动沉淀画像</strong><span>${escapeHtml(formatAgentTime(selected.profileUpdatedAt))} 更新</span></header><div>${(selected.fields || []).map(field => `<div><small>${escapeHtml(field.label)}</small><strong>${escapeHtml(field.displayValue || '-')}</strong><em>${field.evidence ? field.evidence.source === 'human' ? '人工核对' : '有原话依据' : '历史字段 · 待补依据'}</em></div>`).join('') || '<p class="agent-muted">还没有形成结构化画像,后续真实对话会持续补齐。</p>'}</div>${selected.tags?.length ? `<footer>${selected.tags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}</footer>` : ''}</section>
       <section class="customer-profile-fields"><header><strong>自动沉淀画像</strong><span>${escapeHtml(formatAgentTime(selected.profileUpdatedAt))} 更新</span></header><div>${(selected.fields || []).map(field => `<div><small>${escapeHtml(field.label)}</small><strong>${escapeHtml(field.displayValue || '-')}</strong><em>${field.evidence ? field.evidence.source === 'human' ? '人工核对' : '有原话依据' : '历史字段 · 待补依据'}</em></div>`).join('') || '<p class="agent-muted">还没有形成结构化画像,后续真实对话会持续补齐。</p>'}</div>${selected.tags?.length ? `<footer>${selected.tags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}</footer>` : ''}</section>
-      <section class="customer-next-action-section"><header><div><strong>下一步行动工作台</strong><span>规则建议 · 需人工判断 · 不自动外发</span></div><em>${nextActions.length} 项建议</em></header><div>${nextActions.map(action => `<article class="customer-next-action-card ${escapeHtml(action.priority)}"><div><span>${action.confidence === 'rule' ? '规则建议' : 'AI 建议'}</span><strong>${escapeHtml(action.title)}</strong><p>${escapeHtml(action.description)}</p></div><ul>${(action.evidence || []).slice(0, 3).map(item => `<li>${escapeHtml(item)}</li>`).join('') || '<li>当前依据待补充</li>'}</ul><button class="btn btn-sm btn-secondary" data-customer-master-open="${escapeHtml(selected.id)}">去智能会话处理</button></article>`).join('') || '<p class="agent-muted">当前没有新的行动建议。</p>'}</div></section>
-      <section class="customer-recommendation-section"><header><div><strong>房源推荐历史</strong><span>保存已推荐内容和客户反馈,避免重复推荐</span></div><em>${recommendations.length} 套 · ${selected.pendingFeedbackCount || 0} 套待反馈</em></header><div>${recommendations.map(item => `<article class="customer-recommendation-card"><header><div><span>${escapeHtml(item.propertyId)}</span><strong>${escapeHtml(item.property?.community || '未命名房源')}</strong></div><em class="${escapeHtml(item.status)}">${escapeHtml(item.statusLabel)}</em></header><p>${[item.property?.district, item.property?.layout, item.property?.area ? `${item.property.area}㎡` : '', item.property?.decoration].filter(Boolean).map(escapeHtml).join(' · ') || '房源快照字段待补充'}</p><div><strong>${item.property?.totalPrice || '-'}<small>万</small></strong><span>累计推荐 ${item.recommendCount || 1} 次</span><span>最近 ${escapeHtml(formatAgentTime(item.lastRecommendedAt))}</span></div>${item.feedbackReason ? `<blockquote>${escapeHtml(item.feedbackReason)}</blockquote>` : ''}<footer><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="interested">感兴趣</button><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="rejected">不合适</button><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="viewing">已约带看</button><button class="btn btn-sm" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="viewed">已带看</button></footer></article>`).join('') || '<p class="agent-muted">还没有可验证的房源推荐记录。后续房源工具调用或明确发送的房源方案会自动进入这里。</p>'}</div></section>
+      <section class="customer-next-action-section"><header><div><strong>下一步行动工作台</strong><span>规则建议 · 需人工判断 · 不自动外发</span></div><em>${nextActions.length} 项建议</em></header><div>${nextActions.map(action => `<article class="customer-next-action-card ${escapeHtml(action.priority)}"><div><span>${action.confidence === 'rule' ? '规则建议' : 'AI 建议'}</span><strong>${escapeHtml(action.title)}</strong><p>${escapeHtml(action.description)}</p></div><ul>${(action.evidence || []).slice(0, 3).map(item => `<li>${escapeHtml(item)}</li>`).join('') || '<li>当前依据待补充</li>'}</ul>${selected.fallback ? '' : `<button class="btn btn-sm btn-secondary" data-customer-master-open="${escapeHtml(selected.id)}">去智能会话处理</button>`}</article>`).join('') || '<p class="agent-muted">当前没有新的行动建议。</p>'}</div></section>
+      <section class="customer-recommendation-section"><header><div><strong>房源推荐历史</strong><span>保存已推荐内容和客户反馈,避免重复推荐</span></div><em>${recommendations.length} 套 · ${selected.pendingFeedbackCount || 0} 套待反馈</em></header><div>${recommendations.map(item => `<article class="customer-recommendation-card"><header><div><span>${escapeHtml(item.propertyId)}</span><strong>${escapeHtml(item.property?.community || '未命名房源')}</strong></div><em class="${escapeHtml(item.status)}">${escapeHtml(item.statusLabel)}</em></header><p>${[item.property?.district, item.property?.layout, item.property?.area ? `${item.property.area}㎡` : '', item.property?.decoration].filter(Boolean).map(escapeHtml).join(' · ') || '房源快照字段待补充'}</p><div><strong>${item.property?.totalPrice || '-'}<small>万</small></strong><span>累计推荐 ${item.recommendCount || 1} 次</span><span>最近 ${escapeHtml(formatAgentTime(item.lastRecommendedAt))}</span></div>${item.feedbackReason ? `<blockquote>${escapeHtml(item.feedbackReason)}</blockquote>` : ''}<footer>${selected.fallback ? '' : `<button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="interested">感兴趣</button><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="rejected">不合适</button><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="viewing">已约带看</button><button class="btn btn-sm" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="viewed">已带看</button>`}</footer></article>`).join('') || '<p class="agent-muted">还没有可验证的房源推荐记录。后续房源工具调用或明确发送的房源方案会自动进入这里。</p>'}</div></section>
       <div class="customer-master-intelligence-grid">
       <div class="customer-master-intelligence-grid">
         <section><header><strong>字段依据</strong><span>${evidenceItems.length} 条</span></header>${evidenceItems.map(field => `<div class="customer-evidence-item"><strong>${escapeHtml(field.label)}</strong><p>${escapeHtml(field.evidence.text || '人工核对')}</p><small>${field.evidence.source === 'human' ? '人工修正记录' : '真实企微消息'} · ${escapeHtml(formatAgentTime(field.evidence.updatedAt))}</small></div>`).join('') || '<p class="agent-muted">现有历史画像没有逐字段原话定位;新消息产生的字段会自动记录证据。</p>'}</section>
         <section><header><strong>字段依据</strong><span>${evidenceItems.length} 条</span></header>${evidenceItems.map(field => `<div class="customer-evidence-item"><strong>${escapeHtml(field.label)}</strong><p>${escapeHtml(field.evidence.text || '人工核对')}</p><small>${field.evidence.source === 'human' ? '人工修正记录' : '真实企微消息'} · ${escapeHtml(formatAgentTime(field.evidence.updatedAt))}</small></div>`).join('') || '<p class="agent-muted">现有历史画像没有逐字段原话定位;新消息产生的字段会自动记录证据。</p>'}</section>
         <section><header><strong>跟进任务与预警</strong><span>${openTasks.length + openAlerts.length} 项</span></header>${intelligenceItems || '<p class="agent-muted">当前没有待处理任务或预警。</p>'}</section>
         <section><header><strong>跟进任务与预警</strong><span>${openTasks.length + openAlerts.length} 项</span></header>${intelligenceItems || '<p class="agent-muted">当前没有待处理任务或预警。</p>'}</section>
       </div>
       </div>
       ${selected.latestMessage ? `<section class="customer-latest-message"><strong>最近纳入主档的消息</strong><p>${escapeHtml(selected.latestMessage.content)}</p><small>${selected.latestMessage.role === 'customer' ? '客户' : '内部回复'} · ${escapeHtml(formatAgentTime(selected.latestMessage.timestamp))}</small></section>` : ''}
       ${selected.latestMessage ? `<section class="customer-latest-message"><strong>最近纳入主档的消息</strong><p>${escapeHtml(selected.latestMessage.content)}</p><small>${selected.latestMessage.role === 'customer' ? '客户' : '内部回复'} · ${escapeHtml(formatAgentTime(selected.latestMessage.timestamp))}</small></section>` : ''}
       <section class="customer-timeline-section"><header><div><strong>客户时间线</strong><span>消息、画像、任务、预警和房源反馈统一留痕</span></div><em>${timeline.length} 个事件</em></header><div>${timeline.slice(0, 20).map(event => `<article class="customer-timeline-item ${escapeHtml(event.type)}"><i></i><time>${escapeHtml(formatAgentTime(event.occurredAt))}</time><div><strong>${escapeHtml(event.title)}</strong><p>${escapeHtml(event.description || '')}</p><small>${escapeHtml(event.source || '内部主账')}${event.status ? ` · ${escapeHtml(event.status)}` : ''}</small></div></article>`).join('') || '<p class="agent-muted">暂无客户事件。</p>'}</div></section>
       <section class="customer-timeline-section"><header><div><strong>客户时间线</strong><span>消息、画像、任务、预警和房源反馈统一留痕</span></div><em>${timeline.length} 个事件</em></header><div>${timeline.slice(0, 20).map(event => `<article class="customer-timeline-item ${escapeHtml(event.type)}"><i></i><time>${escapeHtml(formatAgentTime(event.occurredAt))}</time><div><strong>${escapeHtml(event.title)}</strong><p>${escapeHtml(event.description || '')}</p><small>${escapeHtml(event.source || '内部主账')}${event.status ? ` · ${escapeHtml(event.status)}` : ''}</small></div></article>`).join('') || '<p class="agent-muted">暂无客户事件。</p>'}</div></section>
-      <form class="customer-master-edit-form" id="customer-master-profile-form" data-customer-id="${escapeHtml(selected.id)}"><header><div><strong>人工核对画像</strong><span>只修正主档,不会向客户发送消息</span></div><button class="btn btn-sm" type="submit" ${state.customerOps.master.loading ? 'disabled' : ''}>保存主档</button></header><div><label><span>意向区域</span><input name="preferredRegion" value="${escapeHtml(customerProfileInput(profile, 'preferredRegion', 'region', 'district'))}" /></label><label><span>预算/万</span><input name="budgetWan" type="number" value="${escapeHtml(customerProfileInput(profile, 'budgetWan'))}" /></label><label><span>户型</span><input name="layout" value="${escapeHtml(customerProfileInput(profile, 'layout', 'houseType'))}" /></label><label><span>购置用途</span><input name="intent" value="${escapeHtml(customerProfileInput(profile, 'intent', 'purpose'))}" placeholder="自住 / 投资 / 待确认" /></label><label><span>购置时间</span><input name="timeline" value="${escapeHtml(customerProfileInput(profile, 'timeline'))}" /></label><label><span>标签</span><input name="tags" value="${escapeHtml((selected.tags || []).join(','))}" /></label></div><label class="customer-master-reason"><span>修正依据(可选)</span><input name="reason" placeholder="例如:已电话确认;仅用于审计留痕" /></label></form>
+      ${selected.fallback ? `<div class="customer-master-fallback-note"><strong>参考数据</strong><span>该客户来自客户运营导入或群成员发现,尚未建立真实企微会话。同步会话后,画像、任务和证据会自动沉淀到这里。</span></div>` : `<form class="customer-master-edit-form" id="customer-master-profile-form" data-customer-id="${escapeHtml(selected.id)}"><header><div><strong>人工核对画像</strong><span>只修正主档,不会向客户发送消息</span></div><button class="btn btn-sm" type="submit" ${state.customerOps.master.loading ? 'disabled' : ''}>保存主档</button></header><div><label><span>意向区域</span><input name="preferredRegion" value="${escapeHtml(customerProfileInput(profile, 'preferredRegion', 'region', 'district'))}" /></label><label><span>预算/万</span><input name="budgetWan" type="number" value="${escapeHtml(customerProfileInput(profile, 'budgetWan'))}" /></label><label><span>户型</span><input name="layout" value="${escapeHtml(customerProfileInput(profile, 'layout', 'houseType'))}" /></label><label><span>购置用途</span><input name="intent" value="${escapeHtml(customerProfileInput(profile, 'intent', 'purpose'))}" placeholder="自住 / 投资 / 待确认" /></label><label><span>购置时间</span><input name="timeline" value="${escapeHtml(customerProfileInput(profile, 'timeline'))}" /></label><label><span>标签</span><input name="tags" value="${escapeHtml((selected.tags || []).join(','))}" /></label></div><label class="customer-master-reason"><span>修正依据(可选)</span><input name="reason" placeholder="例如:已电话确认;仅用于审计留痕" /></label></form>`}
     </article>` : '<div class="library-empty large"><strong>暂无已同步客户</strong><span>从真实企微会话同步消息后,画像会自动沉淀到这里。</span></div>';
     </article>` : '<div class="library-empty large"><strong>暂无已同步客户</strong><span>从真实企微会话同步消息后,画像会自动沉淀到这里。</span></div>';
     return `<section class="customer-master-shell">
     return `<section class="customer-master-shell">
-      <header class="customer-master-hero"><div><span>CUSTOMER 360 · CANONICAL PROFILE</span><h2>客户管理</h2><p>真实企微消息持续生成客户主档,画像、标签、任务、预警和证据统一归档。</p></div><button class="btn secondary" id="btn-customer-master-refresh" ${state.customerOps.master.loading ? 'disabled' : ''}>刷新主档</button></header>
-      <div class="customer-master-stats"><div><strong>${summary.customerCount || 0}</strong><span>真实会话客户</span></div><div><strong>${summary.profiledCount || 0}</strong><span>已形成画像</span></div><div><strong>${summary.priorityCount || 0}</strong><span>重点跟进</span></div><div><strong>${summary.openTaskCount || 0}</strong><span>待跟进任务</span></div></div>
-      <section class="customer-master-policy"><strong>自动沉淀已启用</strong><span>${escapeHtml(data.policy?.autoSync || '')}</span><small>${escapeHtml(data.policy?.evidence || '')}</small></section>
-      <div class="customer-master-layout"><aside><header><strong>客户主档</strong><input id="customer-master-search" value="${escapeHtml(state.customerOps.master.keyword)}" placeholder="搜索姓名、画像或标签" /></header><div>${customers.map(customer => `<button class="customer-master-list-item ${selected?.id === customer.id ? 'active' : ''}" data-customer-master-id="${escapeHtml(customer.id)}"><div><strong>${escapeHtml(customer.displayName)}</strong><span class="customer-stage ${escapeHtml(customer.stage?.key)}">${escapeHtml(customer.stage?.label)}</span></div><p>${(customer.fields || []).slice(0, 3).map(field => `${field.label}:${field.displayValue}`).join(' · ') || '画像待完善'}</p><footer><span>完整度 ${customer.completenessScore}%</span><span>${customer.openTaskCount} 待办 / ${customer.openAlertCount} 预警</span></footer></button>`).join('') || '<p class="agent-muted">没有匹配客户</p>'}</div></aside><main>${detail}</main></div>
+      <header class="customer-master-hero"><div><span>CUSTOMER 360 · CANONICAL PROFILE</span><h2>客户管理</h2><p>${isFallback ? '当前展示客户运营导入/群成员发现数据作为参考;同步真实企微会话后会自动替换为画像主档。' : '真实企微消息持续生成客户主档,画像、标签、任务、预警和证据统一归档。'}</p></div><button class="btn secondary" id="btn-customer-master-refresh" ${state.customerOps.master.loading ? 'disabled' : ''}>刷新主档</button></header>
+      <div class="customer-master-stats"><div><strong>${summary.customerCount || 0}</strong><span>${isFallback ? '客户运营客户' : '真实会话客户'}</span></div><div><strong>${summary.profiledCount || 0}</strong><span>已形成画像</span></div><div><strong>${summary.priorityCount || 0}</strong><span>重点跟进</span></div><div><strong>${summary.openTaskCount || 0}</strong><span>待跟进任务</span></div></div>
+      <section class="customer-master-policy ${isFallback ? 'fallback' : ''}"><strong>${isFallback ? '参考视图已启用' : '自动沉淀已启用'}</strong><span>${escapeHtml(data.policy?.autoSync || '')}</span><small>${escapeHtml(data.policy?.evidence || '')}</small></section>
+      <div class="customer-master-layout"><aside><header><strong>客户主档</strong><input id="customer-master-search" value="${escapeHtml(state.customerOps.master.keyword)}" placeholder="搜索姓名、画像或标签" /></header><div>${customers.map(customer => `<button class="customer-master-list-item ${selected?.id === customer.id ? 'active' : ''}" data-customer-master-id="${escapeHtml(customer.id)}"><div><strong>${escapeHtml(customer.displayName)}</strong>${customer.fallback ? '<span class="customer-fallback-badge">参考</span>' : `<span class="customer-stage ${escapeHtml(customer.stage?.key)}">${escapeHtml(customer.stage?.label)}</span>`}</div><p>${(customer.fields || []).slice(0, 3).map(field => `${field.label}:${field.displayValue}`).join(' · ') || '画像待完善'}</p><footer><span>完整度 ${customer.completenessScore}%</span><span>${customer.openTaskCount} 待办 / ${customer.openAlertCount} 预警</span></footer></button>`).join('') || '<p class="agent-muted">没有匹配客户</p>'}</div></aside><main>${detail}</main></div>
     </section>`;
     </section>`;
   }
   }
 
 
@@ -3472,6 +3941,10 @@
     const renderToken = state.renderToken;
     const renderToken = state.renderToken;
     els.pageTitle.textContent = '客户管理';
     els.pageTitle.textContent = '客户管理';
     void refreshAgentAccountHeader();
     void refreshAgentAccountHeader();
+    const page = document.createElement('div');
+    page.className = 'page';
+    els.content.appendChild(page);
+    showLoading(page, 'list');
     try {
     try {
       state.customerOps.master.hub = await api('GET', '/api/customers');
       state.customerOps.master.hub = await api('GET', '/api/customers');
       const customers = state.customerOps.master.hub.data?.customers || [];
       const customers = state.customerOps.master.hub.data?.customers || [];
@@ -3480,11 +3953,8 @@
       state.customerOps.master.hub = { status: 'error', summary: {}, data: { customers: [], policy: {} }, assistantMessage: error.message || '客户主档读取失败' };
       state.customerOps.master.hub = { status: 'error', summary: {}, data: { customers: [], policy: {} }, assistantMessage: error.message || '客户主档读取失败' };
     }
     }
     if (renderToken !== state.renderToken || state.page !== 'customer-ops') return;
     if (renderToken !== state.renderToken || state.page !== 'customer-ops') return;
-    const page = document.createElement('div');
-    page.className = 'page';
 
 
     page.innerHTML = `
     page.innerHTML = `
-      <div id="customer-master-root">${renderCustomerMasterSection()}</div>
       <div id="customer-master-root">${renderCustomerMasterSection()}</div>
       <div id="customer-master-root">${renderCustomerMasterSection()}</div>
       <div class="dashboard-grid" id="co-dashboard">
       <div class="dashboard-grid" id="co-dashboard">
         <div class="kpi-row" id="co-kpi-row"></div>
         <div class="kpi-row" id="co-kpi-row"></div>
@@ -3539,7 +4009,6 @@
         </div>
         </div>
       </div>
       </div>
     `;
     `;
-    els.content.appendChild(page);
     bindCustomerOpsPage(page);
     bindCustomerOpsPage(page);
   }
   }
 
 
@@ -4198,7 +4667,7 @@
         <button class="btn btn-sm" id="btn-save-customer-info">保存修改</button>
         <button class="btn btn-sm" id="btn-save-customer-info">保存修改</button>
       </div>
       </div>
       <div id="customer-full-profile"></div>
       <div id="customer-full-profile"></div>
-    `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
+    `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`, 'customer-detail-modal');
 
 
     document.getElementById('btn-load-customer-full-profile')?.addEventListener('click', () => loadCustomerFullProfile(externalUserId));
     document.getElementById('btn-load-customer-full-profile')?.addEventListener('click', () => loadCustomerFullProfile(externalUserId));
     document.getElementById('btn-save-customer-info')?.addEventListener('click', async () => {
     document.getElementById('btn-save-customer-info')?.addEventListener('click', async () => {
@@ -4753,8 +5222,16 @@
 
 
   window.dashboardPortraitDetail = async (externalUserId) => {
   window.dashboardPortraitDetail = async (externalUserId) => {
     if (!externalUserId) return;
     if (!externalUserId) return;
+    openModal('画像详情', `
+      <div class="portrait-modal-loading" id="portrait-detail-content">
+        <span class="spinner"></span>
+        <strong>正在读取客户画像</strong>
+        <small>画像字段较多时可能需要几秒钟</small>
+      </div>
+    `, '<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>', 'portrait-modal');
+    const detailRoot = document.getElementById('portrait-detail-content');
     try {
     try {
-      const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() });
+      const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid(), localOnly: true });
       const data = res.data || {};
       const data = res.data || {};
       const portrait = data.portrait || {};
       const portrait = data.portrait || {};
       const portraitFields = portrait.portrait || {};
       const portraitFields = portrait.portrait || {};
@@ -4796,7 +5273,9 @@
         sectionsHtml = '<div class="portrait-empty-panel">当前画像没有结构化字段。请先同步更多客户对话,再重新生成画像。</div>';
         sectionsHtml = '<div class="portrait-empty-panel">当前画像没有结构化字段。请先同步更多客户对话,再重新生成画像。</div>';
       }
       }
 
 
-      openModal('画像详情', `
+      if (!detailRoot?.isConnected) return;
+      detailRoot.classList.remove('portrait-modal-loading');
+      detailRoot.innerHTML = `
         <div class="portrait-profile">
         <div class="portrait-profile">
           <div class="portrait-hero">
           <div class="portrait-hero">
             <div class="portrait-avatar">${escapeHtml(customerInitial)}</div>
             <div class="portrait-avatar">${escapeHtml(customerInitial)}</div>
@@ -4821,9 +5300,13 @@
         </div>
         </div>
         ${sectionsHtml}
         ${sectionsHtml}
         ${renderRawToggle(portraitFields)}
         ${renderRawToggle(portraitFields)}
-      `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`, 'portrait-modal');
+      `;
       bindRawToggle();
       bindRawToggle();
     } catch (err) {
     } catch (err) {
+      if (detailRoot?.isConnected) {
+        detailRoot.classList.remove('portrait-modal-loading');
+        detailRoot.innerHTML = `<div class="portrait-modal-error"><strong>画像详情加载失败</strong><span>${escapeHtml(err.message || '请稍后重试')}</span></div>`;
+      }
       toast(err.message || '查询失败', 'error');
       toast(err.message || '查询失败', 'error');
     }
     }
   };
   };
@@ -5267,7 +5750,7 @@
     }
     }
   }
   }
 
 
-  async function hydrateCurrentAccountBinding() {
+  async function hydrateCurrentAccountBinding(options = {}) {
     const account = currentAccount();
     const account = currentAccount();
     if (!account) return false;
     if (!account) return false;
     try {
     try {
@@ -5283,7 +5766,7 @@
         saveAccounts();
         saveAccounts();
         updateAccountSwitcher();
         updateAccountSwitcher();
       }
       }
-      return await switchAccount(account.id, { silent: true });
+      return await switchAccount(account.id, { silent: true, deferRender: options.deferRender === true });
     } catch (error) {
     } catch (error) {
       console.error('恢复当前账号绑定失败', error);
       console.error('恢复当前账号绑定失败', error);
       return false;
       return false;
@@ -5292,9 +5775,6 @@
 
 
   async function init() {
   async function init() {
     updateAccountSwitcher();
     updateAccountSwitcher();
-    if (currentAccount()?.uid) await switchAccount(state.currentAccountId, { silent: true });
-    await checkExistingLogin();
-    await hydrateCurrentAccountBinding();
 
 
     els.accountBtn.addEventListener('click', e => {
     els.accountBtn.addEventListener('click', e => {
       e.stopPropagation();
       e.stopPropagation();
@@ -5326,7 +5806,23 @@
       if (link) els.sidebar.classList.remove('open');
       if (link) els.sidebar.classList.remove('open');
     });
     });
 
 
-    renderPage();
+    const hadAccountAtStart = Boolean(currentAccount());
+    // URL 中的当前模块先渲染一次;账号恢复只在后台静默更新绑定,不重放页面路由。
+    void renderPage();
+    try {
+      if (currentAccount()?.uid) {
+        await switchAccount(state.currentAccountId, { silent: true, deferRender: true });
+      }
+      await checkExistingLogin();
+      await hydrateCurrentAccountBinding({ deferRender: true });
+    } finally {
+      state.bootstrapping = false;
+      if (!hadAccountAtStart && currentAccount()) {
+        void renderPage();
+      } else if (state.page === 'agent') {
+        startAgentPageRuntime(els.content.querySelector('.agent-page'));
+      }
+    }
   }
   }
 
 
   init();
   init();

+ 75 - 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' });
@@ -181,25 +240,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 建议,不自动发送、不自动改写客户事实。',
         },
         },

+ 6 - 2
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=20260720-bootfix2">
 </head>
 </head>
 <body>
 <body>
 <div class="app" id="app">
 <div class="app" id="app">
@@ -35,6 +35,10 @@
           <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>
           <span>客户群管理</span>
         </a>
         </a>
+        <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 class="nav-item" href="#customer-ops" data-page="customer-ops">
         <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>
           <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>
@@ -82,6 +86,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=20260720-bootfix2"></script>
 </body>
 </body>
 </html>
 </html>

+ 168 - 8
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -13,6 +13,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,
@@ -118,9 +130,34 @@ 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(() => {});
 
 
 function ensureTmpDir() {
 function ensureTmpDir() {
   fs.mkdirSync(TMP_DIR, { recursive: true });
   fs.mkdirSync(TMP_DIR, { recursive: true });
@@ -239,17 +276,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 }
   };
   };
@@ -279,7 +337,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') {
@@ -467,6 +525,30 @@ 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/dashboard/customers/search' && req.method === 'GET') {
+      const filter = {
+        keyword: url.searchParams.get('keyword') || '',
+        hasPortrait: url.searchParams.has('hasPortrait') ? url.searchParams.get('hasPortrait') === 'true' : undefined
+      };
+      const stateSection = getStateSection('customers', { filter });
+      let customers = Object.values(stateSection.customers || {});
+      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') || '',
@@ -572,6 +654,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());

+ 630 - 6
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; }
@@ -943,6 +950,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; }
@@ -964,7 +1097,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; }
 }
 }
 
 
@@ -1385,6 +1518,272 @@ 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; }
+}
+
 body.business-modal-open { overflow: hidden; }
 body.business-modal-open { overflow: hidden; }
 
 
 .business-modal-backdrop {
 .business-modal-backdrop {
@@ -2094,11 +2493,236 @@ body.business-modal-open { overflow: hidden; }
   .property-filterbar { grid-template-columns: 1fr; }
   .property-filterbar { grid-template-columns: 1fr; }
 }
 }
 
 
-@media (prefers-reduced-motion: reduce) {
-  *, *::before, *::after {
-    animation-duration: 0.01ms !important;
-    transition-duration: 0.01ms !important;
-  }
+/* 社群运营 */
+.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 ==================== */

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

@@ -37,6 +37,21 @@ 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 {
 const {
   qiweiPrepareCustomerPortrait,
   qiweiPrepareCustomerPortrait,
   qiweiUpdateCustomerPortrait,
   qiweiUpdateCustomerPortrait,
@@ -603,6 +618,84 @@ 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(
   server.registerTool(
     'qiwei_prepare_customer_portrait',
     'qiwei_prepare_customer_portrait',
     {
     {

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

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

@@ -1,7 +1,7 @@
 const fs = require('fs');
 const fs = require('fs');
 const path = require('path');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { createRunDir, outputsRoot } = require('../core/output-paths');
+const { createRunDir, outputsRoot, writeRunManifest } = require('../core/output-paths');
 const { recordTransferPreview, recordTransferExecution } = require('../core/dashboard-state');
 const { recordTransferPreview, recordTransferExecution } = require('../core/dashboard-state');
 const {
 const {
   buildContext,
   buildContext,
@@ -107,6 +107,7 @@ const qiweiPreviewTransferPackage = safeResult(async function qiweiPreviewTransf
   const fileName = `preview-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const fileName = `preview-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const filePath = path.join(runDir, fileName);
   const filePath = path.join(runDir, fileName);
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
+  writeRunManifest(runDir, { operation: 'preview-customer-transfer', fromUserId, toUserId, files: [filePath] });
 
 
   // 同时写入 latest
   // 同时写入 latest
   const latestFile = path.join(transfersDir(), fileName);
   const latestFile = path.join(transfersDir(), fileName);
@@ -195,6 +196,7 @@ const qiweiExecuteTransfer = safeResult(async function qiweiExecuteTransfer(inpu
   const fileName = `execution-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const fileName = `execution-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const filePath = path.join(runDir, fileName);
   const filePath = path.join(runDir, fileName);
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
+  writeRunManifest(runDir, { operation: 'execute-customer-transfer', fromUserId: preview.fromUserId, toUserId: preview.toUserId, files: [filePath] });
   recordTransferExecution({ ...preview, filePath: path.relative(outputsRoot(), filePath) }, results);
   recordTransferExecution({ ...preview, filePath: path.relative(outputsRoot(), filePath) }, results);
 
 
   return okResult({
   return okResult({

+ 137 - 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,
@@ -60,6 +61,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 });
 }
 }
@@ -541,7 +556,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() {
@@ -726,7 +792,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({
@@ -768,11 +835,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];
@@ -1093,61 +1171,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(),
@@ -1160,12 +1252,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))
   });
   });
 });
 });

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

@@ -0,0 +1,295 @@
+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');
+
+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);
+}
+
+const qiweiGroupOpsGetContext = safeResult(async function qiweiGroupOpsGetContext(input = {}) {
+  const ctx = context(input);
+  const store = getGroupOperationsStore();
+  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
+};

+ 5 - 12
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js

@@ -1,9 +1,10 @@
 const fs = require('fs');
 const fs = require('fs');
 const path = require('path');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { createRunDir, outputsRoot } = require('../core/output-paths');
+const { createRunDir, outputsRoot, writeRunManifest } = require('../core/output-paths');
 const { readState, recordPortrait, removePortrait, recordTags, recordOperation } = require('../core/dashboard-state');
 const { readState, recordPortrait, removePortrait, recordTags, recordOperation } = require('../core/dashboard-state');
 const { analyzePortraitWithLlm } = require('../core/llm-client');
 const { analyzePortraitWithLlm } = require('../core/llm-client');
+const { writeObjectRows } = require('../core/xlsx-io');
 const {
 const {
   buildContext,
   buildContext,
   gatewayCall,
   gatewayCall,
@@ -305,6 +306,7 @@ const qiweiPrepareCustomerPortrait = safeResult(async function qiweiPrepareCusto
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const filePath = path.join(runDir, `context-${externalUserId}.json`);
   const filePath = path.join(runDir, `context-${externalUserId}.json`);
   fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
   fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
+  writeRunManifest(runDir, { operation: 'prepare-customer-portrait', externalUserId, files: [filePath] });
 
 
   return okResult({
   return okResult({
     assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文:共 ${messages.length} 条消息。`,
     assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文:共 ${messages.length} 条消息。`,
@@ -362,6 +364,7 @@ const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustome
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const contextFilePath = path.join(runDir, `context-${externalUserId}.json`);
   const contextFilePath = path.join(runDir, `context-${externalUserId}.json`);
   fs.writeFileSync(contextFilePath, JSON.stringify(context, null, 2), 'utf8');
   fs.writeFileSync(contextFilePath, JSON.stringify(context, null, 2), 'utf8');
+  writeRunManifest(runDir, { operation: 'update-customer-portrait', externalUserId, files: [contextFilePath] });
 
 
   try {
   try {
     const { portrait, usage } = await analyzePortraitWithLlm(context, {
     const { portrait, usage } = await analyzePortraitWithLlm(context, {
@@ -503,13 +506,6 @@ const qiweiExportCustomerPortraits = safeResult(async function qiweiExportCustom
 
 
   if (!externalUserIds.length) return errorResult('没有可导出的画像');
   if (!externalUserIds.length) return errorResult('没有可导出的画像');
 
 
-  let XLSX;
-  try {
-    XLSX = require('xlsx');
-  } catch {
-    return errorResult('当前包尚未安装 xlsx,无法导出 Excel;请运行 npm install xlsx');
-  }
-
   const rows = [];
   const rows = [];
   for (const externalUserId of externalUserIds) {
   for (const externalUserId of externalUserIds) {
     const data = readPortrait(externalUserId);
     const data = readPortrait(externalUserId);
@@ -520,10 +516,7 @@ const qiweiExportCustomerPortraits = safeResult(async function qiweiExportCustom
   ensurePortraitsDir();
   ensurePortraitsDir();
   const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
   const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
   const filePath = path.join(portraitsDir(), fileName);
   const filePath = path.join(portraitsDir(), fileName);
-  const worksheet = XLSX.utils.json_to_sheet(rows);
-  const workbook = XLSX.utils.book_new();
-  XLSX.utils.book_append_sheet(workbook, worksheet, 'portraits');
-  XLSX.writeFile(workbook, filePath);
+  await writeObjectRows(filePath, 'portraits', rows);
 
 
   return okResult({
   return okResult({
     assistantMessage: `已导出 ${rows.length} 条客户画像:${filePath}。`,
     assistantMessage: `已导出 ${rows.length} 条客户画像:${filePath}。`,

Diff do ficheiro suprimidas por serem muito extensas
+ 1 - 0
claude-code/claude-code-qiwe-assistant/package.json


+ 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.');

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

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

@@ -0,0 +1,117 @@
+#!/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 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 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);
+  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/release-check.js

@@ -11,6 +11,11 @@ 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',
   'smoke',
   'smoke',
   'agent:smoke',
   'agent:smoke',
   'library:smoke',
   'library:smoke',
@@ -28,6 +33,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,

+ 38 - 0
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals.py

@@ -0,0 +1,38 @@
+from playwright.sync_api import sync_playwright
+import time
+with sync_playwright() as p:
+    browser = p.chromium.launch(headless=True)
+    page = browser.new_page(viewport={'width': 1440, 'height': 900})
+    page.goto('http://127.0.0.1:4320/')
+    time.sleep(2)
+    page.click('text=社群运营')
+    time.sleep(2)
+    page.click('text=SOP 版本')
+    time.sleep(1)
+    page.screenshot(path='/tmp/qiwei-group-ops-sop.png', full_page=False)
+    # Create SOP
+    page.fill('#group-ops-playbook-name', '测试SOP')
+    page.fill('#group-ops-template', '欢迎加入 {{room_name}}')
+    page.click('#group-ops-playbook-form button[type="submit"]')
+    time.sleep(2)
+    page.screenshot(path='/tmp/qiwei-group-ops-sop-created.png', full_page=False)
+    # Click 影响预览
+    page.click('text=影响预览')
+    time.sleep(1)
+    page.screenshot(path='/tmp/qiwei-group-ops-alert-modal.png', full_page=False)
+    # Close alert
+    page.click('button:has-text("知道了")')
+    time.sleep(0.5)
+    # Click 发布
+    page.click('text=发布')
+    time.sleep(1)
+    page.screenshot(path='/tmp/qiwei-group-ops-confirm-modal.png', full_page=False)
+    # Cancel
+    page.click('button:has-text("取消")')
+    time.sleep(0.5)
+    # Click 新版本
+    page.click('text=新版本')
+    time.sleep(1)
+    page.screenshot(path='/tmp/qiwei-group-ops-prompt-modal.png', full_page=False)
+    browser.close()
+print('screenshots saved')

+ 30 - 0
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals2.py

@@ -0,0 +1,30 @@
+from playwright.sync_api import sync_playwright
+import time
+with sync_playwright() as p:
+    browser = p.chromium.launch(headless=True)
+    page = browser.new_page(viewport={'width': 1440, 'height': 900})
+    page.goto('http://127.0.0.1:4320/')
+    time.sleep(2)
+    page.click('text=社群运营')
+    time.sleep(2)
+    page.click('text=SOP 版本')
+    time.sleep(1)
+
+    # Test confirm modal by clicking 发布
+    page.click('text=发布')
+    time.sleep(1)
+    page.screenshot(path='/tmp/qiwei-group-ops-confirm-modal2.png', full_page=False)
+    # Close with cancel
+    page.click('button:has-text("取消")')
+    time.sleep(0.5)
+
+    # Test prompt modal by clicking 新版本
+    page.click('text=新版本')
+    time.sleep(1)
+    page.screenshot(path='/tmp/qiwei-group-ops-prompt-modal2.png', full_page=False)
+    # Close with cancel
+    page.click('button:has-text("取消")')
+    time.sleep(0.5)
+
+    browser.close()
+print('screenshots saved')

+ 25 - 0
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals3.py

@@ -0,0 +1,25 @@
+from playwright.sync_api import sync_playwright
+import time
+with sync_playwright() as p:
+    browser = p.chromium.launch(headless=True)
+    page = browser.new_page(viewport={'width': 1440, 'height': 900})
+    page.goto('http://127.0.0.1:4320/')
+    time.sleep(2)
+    page.click('text=社群运营')
+    time.sleep(2)
+    page.click('text=SOP 版本')
+    time.sleep(1)
+
+    # Find publish button
+    btn = page.query_selector('[data-group-ops-publish]')
+    print('publish button found:', btn is not None)
+    if btn:
+        print('dataset:', btn.evaluate('el => el.dataset.groupOpsPublish'))
+        btn.click()
+        time.sleep(2)
+        page.screenshot(path='/tmp/qiwei-group-ops-confirm-modal3.png', full_page=False)
+        # Try to find any modal
+        modal = page.query_selector('.business-modal, .modal')
+        print('modal found:', modal is not None)
+    browser.close()
+print('done')

+ 22 - 0
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals4.py

@@ -0,0 +1,22 @@
+import os
+from playwright.sync_api import sync_playwright
+import time
+out = os.path.join(os.environ.get('TEMP', 'C:/tmp'), 'qiwei-group-ops-prompt-modal3.png')
+with sync_playwright() as p:
+    browser = p.chromium.launch(headless=True)
+    page = browser.new_page(viewport={'width': 1440, 'height': 900})
+    page.goto('http://127.0.0.1:4320/')
+    time.sleep(2)
+    page.click('text=社群运营')
+    time.sleep(2)
+    page.click('text=SOP 版本')
+    time.sleep(1)
+    btn = page.query_selector('[data-group-ops-new-version]')
+    print('new version button found:', btn is not None)
+    if btn:
+        btn.click()
+        time.sleep(1)
+        page.screenshot(path=out, full_page=False)
+        print('screenshot saved to', out)
+    browser.close()
+print('done')

+ 12 - 0
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops.py

@@ -0,0 +1,12 @@
+from playwright.sync_api import sync_playwright
+import time
+with sync_playwright() as p:
+    browser = p.chromium.launch(headless=True)
+    page = browser.new_page(viewport={'width': 1440, 'height': 900})
+    page.goto('http://127.0.0.1:4320/')
+    time.sleep(2)
+    page.click('text=社群运营')
+    time.sleep(2)
+    page.screenshot(path='/tmp/qiwei-group-ops-overview.png', full_page=False)
+    browser.close()
+print('screenshot saved')

+ 9 - 4
claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js

@@ -453,9 +453,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');
 
 
-    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');
 
 
@@ -541,13 +546,13 @@ async function main() {
 
 
     const statusBefore = await qiweiWebhookStatus({});
     const statusBefore = await qiweiWebhookStatus({});
     assert.strictEqual(statusBefore.status, 'ok');
     assert.strictEqual(statusBefore.status, 'ok');
-    assert.strictEqual(statusBefore.summary.localMode, 'disabled');
+    assert.strictEqual(statusBefore.summary.localMode, 'enabled');
     assert.strictEqual(statusBefore.summary.relayEnabled, false);
     assert.strictEqual(statusBefore.summary.relayEnabled, false);
 
 
     const autoSetupWithoutRelay = await qiweiWebhookAutoSetup({ ...common, guid: 'guid-1' });
     const autoSetupWithoutRelay = await qiweiWebhookAutoSetup({ ...common, guid: 'guid-1' });
     assert.strictEqual(autoSetupWithoutRelay.status, 'error');
     assert.strictEqual(autoSetupWithoutRelay.status, 'error');
-    assert.strictEqual(autoSetupWithoutRelay.assistantMessage.includes('本地 webhook 模式已禁用'), true);
-    console.log('[ok] webhook-relay tools disabled local mode and require relay');
+    assert.strictEqual(autoSetupWithoutRelay.assistantMessage.includes('Relay is not configured'), true);
+    console.log('[ok] webhook-relay tools expose local mode and require relay for auto setup');
 
 
     for (const request of gateway.requests) {
     for (const request of gateway.requests) {
       assert.strictEqual(request.authorization, 'Bearer sk-test-relay-token');
       assert.strictEqual(request.authorization, 'Bearer sk-test-relay-token');

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

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

@@ -8,7 +8,7 @@
     "dailyOffice": ["会议", "文档", "日程", "待办", "本地知识库"],
     "dailyOffice": ["会议", "文档", "日程", "待办", "本地知识库"],
     "goalManagement": ["大目标拆解", "里程碑", "会议行动项", "进度/阻塞/逾期跟踪", "企微待办衔接"],
     "goalManagement": ["大目标拆解", "里程碑", "会议行动项", "进度/阻塞/逾期跟踪", "企微待办衔接"],
     "customerService": ["真实私信监听", "客户独立 Claude Code Session", "Agent 草稿", "人工审核", "人工接管", "安全会话审阅"],
     "customerService": ["真实私信监听", "客户独立 Claude Code Session", "Agent 草稿", "人工审核", "人工接管", "安全会话审阅"],
-    "crm": ["客户档案", "客户群", "群消息", "画像", "标签", "顾问 Playbook", "客户交接"],
+    "crm": ["客户档案", "客户群", "群消息", "社群 SOP", "运营计划话术", "群质检督导", "画像", "标签", "顾问 Playbook", "客户交接"],
     "foundation": ["Fmode 网关", "官方 CLI", "104 个接口清单", "MCP 工具", "Webhook/Relay", "白名单与审计"]
     "foundation": ["Fmode 网关", "官方 CLI", "104 个接口清单", "MCP 工具", "Webhook/Relay", "白名单与审计"]
   },
   },
   "skills": [
   "skills": [
@@ -20,6 +20,7 @@
     "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 +31,7 @@
     "qiwei-voice",
     "qiwei-voice",
     "qiwei-webhook-relay"
     "qiwei-webhook-relay"
   ],
   ],
-  "mcpToolCount": 75,
+  "mcpToolCount": 88,
   "apiCatalogCount": 104,
   "apiCatalogCount": 104,
   "installCommand": "node install.js workspace <客户项目目录> --smoke",
   "installCommand": "node install.js workspace <客户项目目录> --smoke",
   "workspaceInstallTarget": ".claude/plugins/qiwei-assistant",
   "workspaceInstallTarget": ".claude/plugins/qiwei-assistant",

+ 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` 时把反馈保存到评测集,用于后续阈值校准,不删除原质检记录。
+
+## 边界
+
+确定性规则可以直接形成待处理项;语义分类只能作为建议并带置信度。不要根据消息数量直接判断员工勤奋或群质量。

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff