Преглед изворни кода

WIP: local business-diagnosis + group-ops extensions

ETO-kai пре 1 месец
родитељ
комит
1820525e2b
25 измењених фајлова са 2500 додато и 39 уклоњено
  1. 11 1
      claude-code/claude-code-qiwe-assistant/README.md
  2. 38 0
      claude-code/claude-code-qiwe-assistant/docs/guides/DASHBOARD-DEV-LOG.md
  3. 364 7
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  4. 200 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/business-diagnosis-service.js
  5. 34 20
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html
  6. 15 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  7. 188 2
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  8. 178 4
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/unified-task-service.js
  9. 44 0
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  10. 31 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-business-diagnosis-run.js
  11. 0 0
      claude-code/claude-code-qiwe-assistant/package.json
  12. 59 0
      claude-code/claude-code-qiwe-assistant/scripts/business-diagnosis-smoke-test.js
  13. 93 0
      claude-code/claude-code-qiwe-assistant/scripts/diagnosis-action-center-smoke-test.js
  14. 2 0
      claude-code/claude-code-qiwe-assistant/scripts/release-check.js
  15. 3 1
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  16. 48 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/SKILL.md
  17. 4 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/agents/openai.yaml
  18. 45 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/references/output-contract.md
  19. 5 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-capability-router/SKILL.md
  20. 20 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  21. 17 0
      fix_regex.py
  22. 22 0
      fix_regex2.py
  23. 374 0
      replace_customer_ops.py
  24. 385 0
      replace_portraits.py
  25. 320 0
      replace_transfers.py

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

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

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

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

+ 364 - 7
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -42,7 +42,7 @@
   const initialAgentSnapshot = loadAgentSnapshot();
 
   const state = {
-    page: 'agent',
+    page: 'overview',
     accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'),
     currentAccountId: localStorage.getItem(CURRENT_ACCOUNT_KEY) || null,
     status: null,
@@ -51,6 +51,7 @@
     groupFilters: loadFilters(GROUP_FILTERS_KEY, { keyword: '', status: '', showCustomerOnly: false }),
     groupKeywords: null,
     groupOps: { overview: null, playbooks: [], loading: false, view: 'overview' },
+    overview: { data: null, query: '', diagnosis: null, loading: false, candidateActionIds: new Set() },
     jobs: {},
     renderToken: 0,
     bootstrapping: true,
@@ -83,6 +84,7 @@
     pageTitle: document.getElementById('page-title'),
     nav: document.getElementById('nav'),
     sidebar: document.getElementById('sidebar'),
+    sidebarBackdrop: document.getElementById('sidebar-backdrop'),
     menuBtn: document.getElementById('menu-btn'),
     toastContainer: document.getElementById('toast-container'),
     accountBtn: document.getElementById('account-btn'),
@@ -1137,7 +1139,7 @@
 
   async function renderPage() {
     const renderToken = ++state.renderToken;
-    const page = location.hash.slice(1) || 'agent';
+    const page = location.hash.slice(1) || 'overview';
     const loggedIn = !!currentAccount();
     els.nav.classList.toggle('nav-locked', !loggedIn);
     setActiveNav(page);
@@ -1150,13 +1152,14 @@
     disposeDashboard();
 
     const acc = currentAccount();
-    if (!acc && !['agent', 'skills', 'knowledge', 'customer-ops'].includes(page)) {
+    if (!acc && !['overview', 'agent', 'skills', 'knowledge', 'customer-ops'].includes(page)) {
       renderLoginPage();
       return;
     }
 
     try {
-      if (page === 'agent') await renderAgentPage();
+      if (page === 'overview') await renderOverviewPage();
+      else if (page === 'agent') await renderAgentPage();
       else if (page === 'skills') await renderSkillsPage();
       else if (page === 'knowledge') await renderKnowledgePage();
       else if (page === 'status') await renderStatusPage();
@@ -1178,6 +1181,309 @@
     }
   }
 
+  // ==================== Business Overview ====================
+  function overviewNumber(value, fallback = '0') {
+    const number = Number(value);
+    return Number.isFinite(number) ? number.toLocaleString('zh-CN') : fallback;
+  }
+
+  function overviewMetric(value, suffix = '') {
+    return value === null || value === undefined ? '-' : `${overviewNumber(value)}${suffix}`;
+  }
+
+  function overviewIcon(name) {
+    const paths = {
+      customer: 'M16 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3ZM8 11c1.66 0 3-1.34 3-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.5C15 14.17 10.33 13 8 13Zm8 0c-.29 0-.62.02-.97.05A4.2 4.2 0 0 1 17 16.5V19h6v-2.5c0-2.33-4.67-3.5-7-3.5Z',
+      portrait: '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',
+      task: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2Zm-8 14-4-4 1.4-1.4 2.6 2.6 5.6-5.6L18 10l-7 7Z',
+      risk: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z',
+      chat: 'M20 2H4a2 2 0 0 0-2 2v18l4-4h14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Zm-2 11H6v-2h12v2Zm0-3H6V8h12v2Zm0-3H6V5h12v2Z',
+      refresh: 'M17.65 6.35C16.2 4.9 14.21 4 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',
+      arrow: 'm12 4-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8-8-8Z'
+    };
+    return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${paths[name] || paths.task}"/></svg>`;
+  }
+
+  function buildOverviewDiagnosis(query, data) {
+    const text = String(query || '').trim();
+    const normalized = text.toLowerCase();
+    const summary = data.summary || {};
+    const customer = summary.customers || {};
+    const group = data.groupOverview?.metrics || {};
+    const conversations = data.conversations || [];
+    const pendingReplies = conversations.filter(item => item.pendingReply).length;
+    const openCustomerTasks = conversations.reduce((sum, item) => sum + Number(item.customerIntelligence?.summary?.openTasks || 0), 0);
+    const missingPortraits = Math.max(0, Number(customer.total || 0) - Number(summary.portraits?.total || 0));
+
+    if (/复购|成交|gmv|销售额|订单|转化率/.test(normalized)) {
+      return {
+        tone: 'info',
+        label: '需要补充交易数据',
+        title: '当前企微数据不足以直接判断复购变化',
+        summary: '系统目前能验证客户触达、画像和社群执行情况,但尚未接入订单与复购口径。继续给出确定性结论会造成误导。',
+        evidence: [`客户主档 ${overviewNumber(customer.total)} 人`, `画像覆盖率 ${overviewMetric(summary.portraits?.coverage, '%')}`, `近 7 日运营记录 ${overviewNumber(summary.operations?.recent7d)} 条`],
+        actions: [
+          { label: '检查重点客户', description: '从画像完整度和待跟进任务开始排查', hash: '#customer-ops' },
+          { label: '查看社群执行', description: '确认近期计划是否按时触达', hash: '#group-ops' },
+          { label: '补充经营数据', description: '在知识库中接入订单或 ERP 导出', hash: '#knowledge' }
+        ]
+      };
+    }
+
+    if (/群|社群|sop|活动|话术|投诉|风险|质检/.test(normalized)) {
+      const highRisk = Number(group.highRisk || 0);
+      const pendingReview = Number(group.pendingReview || 0);
+      return {
+        tone: highRisk ? 'danger' : pendingReview ? 'warning' : 'success',
+        label: highRisk ? '存在高风险待闭环' : pendingReview ? '存在待审核计划' : '当前未发现高风险积压',
+        title: highRisk ? `${highRisk} 项高风险需要优先处理` : `${overviewNumber(group.groups)} 个群的运营状态已汇总`,
+        summary: `今日计划 ${overviewNumber(group.dueItems)} 项,完成 ${overviewNumber(group.completedItems)} 项,待审核 ${overviewNumber(pendingReview)} 项。所有风险结论均应回到原消息核验。`,
+        evidence: [`执行率 ${overviewMetric(group.executionRate, '%')}`, `开放预警 ${overviewNumber(group.openFindings)}`, `健康度 ${overviewMetric(group.averageHealthScore, ' 分')}`],
+        actions: [
+          { label: '处理风险', description: '查看原消息证据并建立整改任务', hash: '#group-ops' },
+          { label: '审核今日计划', description: '批准话术或退回修改', hash: '#group-ops' },
+          { label: '核对客户群', description: '确认群身份与消息同步状态', hash: '#groups' }
+        ]
+      };
+    }
+
+    if (/客户|跟进|画像|标签|重点|流失|意向/.test(normalized)) {
+      return {
+        tone: openCustomerTasks || missingPortraits ? 'warning' : 'success',
+        label: openCustomerTasks ? '存在客户待办' : missingPortraits ? '画像覆盖仍需提升' : '客户资产状态稳定',
+        title: openCustomerTasks ? `${openCustomerTasks} 项客户跟进任务待处理` : `${overviewNumber(customer.total)} 位客户已进入统一主档`,
+        summary: `当前画像覆盖率 ${overviewMetric(summary.portraits?.coverage, '%')},仍有 ${overviewNumber(missingPortraits)} 位客户缺少完整画像。建议优先处理有任务、有预警且画像证据不足的客户。`,
+        evidence: [`已形成画像 ${overviewNumber(summary.portraits?.total)}`, `近 7 日新增画像 ${overviewNumber(summary.portraits?.recent7d)}`, `客户标签 ${overviewNumber(summary.tags?.total)} 个`],
+        actions: [
+          { label: '打开客户主档', description: '按任务、预警和画像完整度处理', hash: '#customer-ops' },
+          { label: '补齐画像', description: '查看字段依据并人工核对', hash: '#portraits' },
+          { label: '进入客服工作台', description: '从真实会话继续跟进', hash: '#agent' }
+        ]
+      };
+    }
+
+    return {
+      tone: pendingReplies ? 'warning' : 'info',
+      label: pendingReplies ? '有草稿等待审核' : '经营数据已就绪',
+      title: pendingReplies ? `${pendingReplies} 条 Agent 草稿等待处理` : '可以从客户、社群或客服问题开始诊断',
+      summary: `系统已汇总 ${overviewNumber(customer.total)} 位客户、${overviewNumber(group.groups)} 个运营群和 ${overviewNumber(conversations.length)} 个真实会话。请输入更具体的业务问题以缩小范围。`,
+      evidence: [`真实会话 ${overviewNumber(conversations.length)}`, `今日群任务 ${overviewNumber(group.dueItems)}`, `近 7 日运营记录 ${overviewNumber(summary.operations?.recent7d)}`],
+      actions: [
+        { label: '处理客服草稿', description: '审核、驳回或人工接管', hash: '#agent' },
+        { label: '查看今日行动', description: '统一处理任务与 AI 候选', hash: '#knowledge' },
+        { label: '查看社群运营', description: '处理计划、风险与自动化', hash: '#group-ops' }
+      ]
+    };
+  }
+
+  function renderOverviewDiagnosis(diagnosis) {
+    if (!diagnosis) return '<div class="overview-diagnosis-empty"><strong>从一个经营问题开始</strong><span>诊断只使用已接入的真实企微数据;数据不足时会明确提示,不生成虚假结论。</span></div>';
+    const gaps = Array.isArray(diagnosis.dataGaps) ? diagnosis.dataGaps : [];
+    return `<article class="overview-diagnosis-result ${escapeHtml(diagnosis.tone)}">
+      <header><span>${escapeHtml(diagnosis.label)}</span><strong>${escapeHtml(diagnosis.title)}</strong></header>
+      <p>${escapeHtml(diagnosis.summary)}</p>
+      <div class="overview-evidence">${diagnosis.evidence.map(item => `<span>${escapeHtml(item)}</span>`).join('')}</div>
+      ${gaps.length ? `<div class="overview-data-gaps"><strong>数据缺口</strong><div>${gaps.slice(0, 3).map(item => `<span title="${escapeHtml(item.impact || '')}">${escapeHtml(item.label)}<small>${escapeHtml(item.nextAction || '')}</small></span>`).join('')}</div></div>` : ''}
+      <div class="overview-diagnosis-actions">${diagnosis.actions.map((action, index) => {
+        const actionKey = `${String(state.overview.query || '')}:${String(action.id || action.label || index)}`;
+        const added = state.overview.candidateActionIds.has(actionKey);
+        return `<article class="overview-diagnosis-action">
+          <button type="button" class="overview-diagnosis-go" data-overview-go="${escapeHtml(action.hash || action.route || '#overview')}"><span>${escapeHtml(action.label)}</span><small>${escapeHtml(action.description)}</small>${overviewIcon('arrow')}</button>
+          <button type="button" class="overview-candidate-btn ${added ? 'added' : ''}" data-overview-candidate="${index}" ${added ? 'disabled' : ''}>${added ? '已加入今日行动' : '加入今日行动'}</button>
+        </article>`;
+      }).join('')}</div>
+    </article>`;
+  }
+
+  function renderOverviewWorkspace(page) {
+    const data = state.overview.data || {};
+    const summary = data.summary || {};
+    const customers = summary.customers || {};
+    const portraits = summary.portraits || {};
+    const group = data.groupOverview?.metrics || {};
+    const agent = data.agentStatus || {};
+    const conversations = data.conversations || [];
+    const pendingReplies = conversations.filter(item => item.pendingReply).length;
+    const openCustomerTasks = conversations.reduce((sum, item) => sum + Number(item.customerIntelligence?.summary?.openTasks || 0), 0);
+    const highAlerts = conversations.reduce((sum, item) => sum + Number(item.customerIntelligence?.summary?.highAlerts || 0), 0);
+    const actionCount = pendingReplies + Number(group.pendingReview || 0) + Number(group.highRisk || 0) + openCustomerTasks;
+    const connectionOnline = Boolean(agent.account?.online || currentAccount()?.online);
+    const listenerRunning = Boolean(agent.listener?.running);
+    const healthScore = group.averageHealthScore;
+    const query = state.overview.query || '';
+
+    const actions = [
+      { tone: 'danger', count: Number(group.highRisk || 0) + highAlerts, title: '高风险待闭环', desc: '回到原消息核验并建立整改任务', hash: '#group-ops' },
+      { tone: 'warning', count: Number(group.pendingReview || 0) + pendingReplies, title: '内容待审核', desc: '包括社群话术和 Agent 回复草稿', hash: pendingReplies ? '#agent' : '#group-ops' },
+      { tone: 'info', count: openCustomerTasks, title: '客户待跟进', desc: '基于真实会话形成的内部任务', hash: '#customer-ops' }
+    ];
+
+    page.innerHTML = `<div class="overview-page">
+      <section class="overview-command">
+        <div class="overview-command-copy">
+          <div class="overview-kicker"><span class="overview-live ${connectionOnline ? 'online' : ''}"></span>${connectionOnline ? '真实企微数据已连接' : '本地经营数据模式'}</div>
+          <h2>今天先处理什么?</h2>
+          <p>从真实会话、客户主档和社群执行记录中定位问题,再进入对应工作台处理。</p>
+        </div>
+        <form id="overview-diagnosis-form" class="overview-query-form">
+          <label for="overview-query">经营问题</label>
+          <div><input id="overview-query" name="query" value="${escapeHtml(query)}" placeholder="例如:哪些客户今天必须跟进?哪些群存在高风险?" autocomplete="off" /><button class="btn" type="submit">开始诊断</button></div>
+          <div class="overview-query-chips">
+            ${['哪些客户今天必须跟进?', '哪些群存在高风险?', '今天有哪些内容待审核?', '最近为什么复购下降?'].map(item => `<button type="button" data-overview-query="${escapeHtml(item)}">${escapeHtml(item)}</button>`).join('')}
+          </div>
+        </form>
+      </section>
+
+      <section class="overview-kpi-grid" aria-label="核心经营指标">
+        <button type="button" class="overview-kpi" data-overview-go="#customer-ops"><span class="overview-kpi-icon customer">${overviewIcon('customer')}</span><div><small>客户资产</small><strong>${overviewNumber(customers.total)}</strong><em>今日新增 ${overviewNumber(customers.todayNew)}</em></div></button>
+        <button type="button" class="overview-kpi" data-overview-go="#portraits"><span class="overview-kpi-icon portrait">${overviewIcon('portrait')}</span><div><small>画像覆盖</small><strong>${overviewMetric(portraits.coverage, '%')}</strong><em>近 7 日更新 ${overviewNumber(portraits.recent7d)}</em></div></button>
+        <button type="button" class="overview-kpi" data-overview-go="#group-ops"><span class="overview-kpi-icon task">${overviewIcon('task')}</span><div><small>今日群任务</small><strong>${overviewNumber(group.dueItems)}</strong><em>执行率 ${overviewMetric(group.executionRate, '%')}</em></div></button>
+        <button type="button" class="overview-kpi ${Number(group.highRisk || 0) + highAlerts ? 'attention' : ''}" data-overview-go="#group-ops"><span class="overview-kpi-icon risk">${overviewIcon('risk')}</span><div><small>高风险未闭环</small><strong>${overviewNumber(Number(group.highRisk || 0) + highAlerts)}</strong><em>${Number(group.openFindings || 0)} 项开放预警</em></div></button>
+      </section>
+
+      <section class="overview-main-grid">
+        <div class="overview-primary-column">
+          <section class="overview-panel overview-diagnosis-panel">
+            <header class="overview-panel-header"><div><span>BUSINESS DIAGNOSIS</span><h3>经营诊断</h3></div><small>基于当前数据快照</small></header>
+            <div id="overview-diagnosis-result">${renderOverviewDiagnosis(state.overview.diagnosis)}</div>
+          </section>
+          <section class="overview-panel overview-actions-panel">
+            <header class="overview-panel-header"><div><span>TODAY'S ACTIONS</span><h3>今日行动</h3></div><strong>${overviewNumber(actionCount)} 项</strong></header>
+            <div class="overview-action-list">${actions.map(item => `<button type="button" class="overview-action ${item.tone}" data-overview-go="${item.hash}"><i></i><strong>${overviewNumber(item.count)}</strong><span><b>${item.title}</b><small>${item.desc}</small></span>${overviewIcon('arrow')}</button>`).join('')}</div>
+          </section>
+        </div>
+
+        <aside class="overview-secondary-column">
+          <section class="overview-panel overview-health-panel">
+            <header class="overview-panel-header"><div><span>OPERATING HEALTH</span><h3>运营健康度</h3></div><strong>${overviewMetric(healthScore, '')}</strong></header>
+            <div class="overview-health-track"><i style="width:${Math.max(0, Math.min(100, Number(healthScore || 0)))}%"></i></div>
+            <dl><div><dt>群执行率</dt><dd>${overviewMetric(group.executionRate, '%')}</dd></div><div><dt>数据覆盖</dt><dd>${overviewMetric(group.averageHealthCoverage, '%')}</dd></div><div><dt>画像覆盖</dt><dd>${overviewMetric(portraits.coverage, '%')}</dd></div></dl>
+            <button type="button" class="overview-text-action" data-overview-go="#group-ops">查看指标依据 ${overviewIcon('arrow')}</button>
+          </section>
+          <section class="overview-panel overview-workforce-panel">
+            <header class="overview-panel-header"><div><span>AI WORKFORCE</span><h3>数字员工</h3></div><small>${listenerRunning ? '运行中' : '待命'}</small></header>
+            <div class="overview-worker"><span class="overview-worker-icon">${overviewIcon('chat')}</span><div><strong>AI 客服</strong><small>${overviewNumber(conversations.length)} 个真实会话 · ${overviewNumber(pendingReplies)} 条待审</small></div><em class="${listenerRunning ? 'online' : ''}">${listenerRunning ? '已上岗' : '待启动'}</em></div>
+            <div class="overview-worker"><span class="overview-worker-icon">${overviewIcon('task')}</span><div><strong>社群运营官</strong><small>${overviewNumber(group.groups)} 个群 · ${overviewNumber(group.pendingReview)} 项待审</small></div><em class="${group.groups ? 'online' : ''}">${group.groups ? '已接入' : '待接入'}</em></div>
+            <button type="button" class="overview-text-action" data-overview-go="#skills">查看能力与边界 ${overviewIcon('arrow')}</button>
+          </section>
+        </aside>
+      </section>
+      <footer class="overview-data-note"><span>数据时间:${escapeHtml(data.loadedAt ? formatAgentTime(data.loadedAt) : '刚刚')}</span><span>不包含交易、订单和 GMV 数据;相关问题会提示补充数据。</span><button type="button" id="overview-refresh">${overviewIcon('refresh')}刷新</button></footer>
+    </div>`;
+
+    const runDiagnosis = async (value) => {
+      state.overview.query = String(value || '').trim();
+      const result = page.querySelector('#overview-diagnosis-result');
+      const submit = page.querySelector('#overview-diagnosis-form button[type="submit"]');
+      if (submit) { submit.disabled = true; submit.textContent = '诊断中...'; }
+      if (result) result.innerHTML = '<div class="overview-diagnosis-empty"><div class="spinner"></div><strong>正在核对经营数据</strong><span>汇总客户、会话和社群运营证据...</span></div>';
+      try {
+        const response = await api('POST', '/api/business-diagnosis', { question: state.overview.query, accountKey: agentAccountKey() || undefined });
+        state.overview.diagnosis = response.data?.diagnosis || buildOverviewDiagnosis(state.overview.query, state.overview.data || {});
+      } catch {
+        state.overview.diagnosis = buildOverviewDiagnosis(state.overview.query, state.overview.data || {});
+        toast('诊断服务暂不可用,已使用本地规则完成降级诊断', 'error');
+      } finally {
+        if (submit) { submit.disabled = false; submit.textContent = '开始诊断'; }
+      }
+      if (result) result.innerHTML = renderOverviewDiagnosis(state.overview.diagnosis);
+      bindOverviewNavigation(result || page);
+      bindOverviewCandidates(result || page, state.overview.diagnosis);
+    };
+    page.querySelector('#overview-diagnosis-form').addEventListener('submit', async event => {
+      event.preventDefault();
+      const input = page.querySelector('#overview-query');
+      if (!input.value.trim()) { toast('请先输入一个经营问题', 'error'); input.focus(); return; }
+      await runDiagnosis(input.value);
+    });
+    page.querySelectorAll('[data-overview-query]').forEach(button => button.addEventListener('click', async () => {
+      page.querySelector('#overview-query').value = button.dataset.overviewQuery;
+      await runDiagnosis(button.dataset.overviewQuery);
+    }));
+    page.querySelector('#overview-refresh').addEventListener('click', () => loadOverviewData(page, false));
+    bindOverviewNavigation(page);
+  }
+
+  function bindOverviewNavigation(root) {
+    if (!root) return;
+    root.querySelectorAll('[data-overview-go]').forEach(button => {
+      if (button.dataset.overviewBound) return;
+      button.dataset.overviewBound = '1';
+      button.addEventListener('click', () => { location.hash = button.dataset.overviewGo; });
+    });
+  }
+
+  function bindOverviewCandidates(root, diagnosis) {
+    if (!root || !diagnosis) return;
+    root.querySelectorAll('[data-overview-candidate]').forEach(button => {
+      button.addEventListener('click', async () => {
+        const index = Number(button.dataset.overviewCandidate);
+        const action = diagnosis.actions?.[index];
+        if (!action) return;
+        const actionKey = `${String(diagnosis.query || state.overview.query || '')}:${String(action.id || action.label || index)}`;
+        button.disabled = true;
+        button.textContent = '正在加入...';
+        try {
+          const response = await api('POST', '/api/knowledge/tasks/diagnosis-candidate', {
+            question: diagnosis.query || state.overview.query,
+            diagnosisType: diagnosis.type,
+            scope: diagnosis.scope,
+            action,
+            evidence: diagnosis.evidenceDetails?.length ? diagnosis.evidenceDetails : diagnosis.evidence,
+            dataGaps: diagnosis.dataGaps || []
+          });
+          if (response.status === 'error') throw new Error(response.assistantMessage || '加入行动候选失败');
+          state.overview.candidateActionIds.add(actionKey);
+          button.classList.add('added');
+          button.textContent = response.summary?.created === false ? '已在今日行动' : '已加入今日行动';
+          toast(response.assistantMessage || '已加入今日行动候选');
+        } catch (error) {
+          button.disabled = false;
+          button.textContent = '加入今日行动';
+          toast(error.message || '加入行动候选失败', 'error');
+        }
+      });
+    });
+  }
+
+  async function loadOverviewData(page, silent = false) {
+    if (state.overview.loading) return;
+    state.overview.loading = true;
+    const accountKey = agentAccountKey();
+    try {
+      const requests = [
+        api('GET', '/api/dashboard/summary'),
+        api('GET', '/api/agent/status'),
+        api('GET', '/api/agent/conversations')
+      ];
+      if (accountKey) requests.push(api('GET', `/api/group-ops/overview?accountKey=${encodeURIComponent(accountKey)}`));
+      const results = await Promise.allSettled(requests);
+      const read = (index, fallback = {}) => results[index]?.status === 'fulfilled' ? results[index].value : fallback;
+      state.overview.data = {
+        summary: read(0).data || {},
+        agentStatus: read(1).data || {},
+        conversations: read(2).data?.conversations || [],
+        groupOverview: accountKey ? (read(3).data || {}) : {},
+        loadedAt: new Date().toISOString()
+      };
+      if (state.overview.query && !state.overview.diagnosis?.scope) state.overview.diagnosis = buildOverviewDiagnosis(state.overview.query, state.overview.data);
+      if (state.page === 'overview' && page.isConnected) renderOverviewWorkspace(page);
+    } catch (error) {
+      if (!silent) toast(error.message || '经营总览加载失败', 'error');
+    } finally {
+      state.overview.loading = false;
+    }
+  }
+
+  async function renderOverviewPage() {
+    els.pageTitle.textContent = '经营总览';
+    const page = document.createElement('div');
+    page.className = 'page overview-root';
+    page.innerHTML = '<div class="loading"><div class="spinner"></div><span>正在汇总经营数据...</span></div>';
+    els.content.appendChild(page);
+    await loadOverviewData(page);
+  }
+
   // ==================== Unified Skill Center ====================
   function markdownInline(value) {
     return escapeHtml(value)
@@ -1520,6 +1826,27 @@
     return `${task.overdue ? '已逾期 · ' : ''}${formatAgentTime(task.dueAt)}`;
   }
 
+  function diagnosisOutcomeLabel(value) {
+    return ({ effective: '有效', partially_effective: '部分有效', ineffective: '无效', unknown: '暂无法判断' })[value] || '待评价';
+  }
+
+  function renderDiagnosisFeedback(task) {
+    if (!task.actions?.diagnosisFeedback) return '';
+    const current = task.diagnosisOutcome || {};
+    const outcomes = [
+      ['effective', '有效'],
+      ['partially_effective', '部分有效'],
+      ['ineffective', '无效'],
+      ['unknown', '暂无法判断'],
+    ];
+    return `<form class="diagnosis-feedback-form" data-diagnosis-feedback-form="${escapeHtml(task.id)}">
+      <div class="diagnosis-feedback-head"><div><strong>行动效果复盘</strong><span>仅内部记录,不会发送客户消息</span></div>${current.value ? `<em class="diagnosis-outcome ${escapeHtml(current.value)}">当前:${escapeHtml(diagnosisOutcomeLabel(current.value))}</em>` : '<em>尚未评价</em>'}</div>
+      <div class="diagnosis-outcome-options" role="radiogroup" aria-label="行动效果">${outcomes.map(([value, label]) => `<label class="${current.value === value ? 'selected' : ''}"><input type="radio" name="outcome" value="${value}" ${current.value === value ? 'checked' : ''} required /><span>${label}</span></label>`).join('')}</div>
+      <label class="diagnosis-feedback-note"><span>复盘依据(可选)</span><input name="note" maxlength="1000" value="${escapeHtml(current.note || '')}" placeholder="例如:跟进完成率提升,但客户回复率未明显变化" /></label>
+      <button class="btn btn-sm" type="submit" ${state.knowledge.tasks.loading ? 'disabled' : ''}>${current.value ? '更新反馈' : '保存反馈'}</button>
+    </form>`;
+  }
+
   function unifiedTaskActions(task) {
     if (task.sourceKind === 'candidate') {
       return task.promotedTo
@@ -1557,13 +1884,16 @@
       const sample = allTasks.find(task => task.source === source);
       return `<option value="${escapeHtml(source)}" ${filters.source === source ? 'selected' : ''}>${escapeHtml(sample?.sourceLabel || source)}(${summary.bySource[source]})</option>`;
     }).join('');
+    const diagnosisFeedback = summary.diagnosisFeedback || {};
+    const percentLabel = value => value === null || value === undefined ? '-' : `${Math.round(Number(value) * 100)}%`;
     return `<div class="unified-task-workbench">
       <header class="knowledge-view-header"><div><span>UNIFIED ACTION CENTER</span><h3>统一任务中心</h3><p>真实企微会话 · 官方待办 · 目标计划 · 文档/会议行动项</p></div><em>内部任务中枢</em></header>
       <div class="task-stat-row"><div><strong>${summary.active || 0}</strong><span>待处理</span></div><div><strong>${summary.overdue || 0}</strong><span>已逾期</span></div><div><strong>${summary.completed || 0}</strong><span>已完成</span></div><div><strong>${summary.candidates || 0}</strong><span>待确认候选</span></div></div>
+      <section class="diagnosis-feedback-summary" aria-label="经营诊断行动闭环"><header><strong>经营诊断行动闭环</strong><span>从建议采纳到结果验证</span></header><div><span><strong>${diagnosisFeedback.candidates || 0}</strong>候选</span><span><strong>${diagnosisFeedback.promoted || 0}</strong>已采纳 · ${percentLabel(diagnosisFeedback.adoptionRate)}</span><span><strong>${diagnosisFeedback.completed || 0}</strong>已完成</span><span><strong>${diagnosisFeedback.effective || 0}</strong>验证有效</span><span><strong>${percentLabel(diagnosisFeedback.effectivenessRate)}</strong>有效率</span></div></section>
       <section class="task-policy-banner"><div><strong>客户任务不会被原样发给客户</strong><span>${escapeHtml(data.policy?.externalCustomerSafety || '')}</span></div><div><strong>AI 候选不会自动成为正式任务</strong><span>${escapeHtml(data.policy?.candidateSafety || '')}</span></div></section>
       <form class="task-create-form" id="unified-task-create-form"><div><strong>新建内部任务</strong><span>不依赖 CLI,先在本地形成可管理的执行项</span></div><input name="title" placeholder="任务标题" required /><input name="owner" placeholder="负责人(可选)" /><input name="dueAt" type="datetime-local" /><select name="priority"><option value="medium">普通</option><option value="high">高</option><option value="urgent">紧急</option><option value="low">低</option></select><button class="btn" type="submit" ${state.knowledge.tasks.loading ? 'disabled' : ''}>创建</button></form>
       <form class="task-filterbar" id="unified-task-filter-form"><input name="q" value="${escapeHtml(filters.q)}" placeholder="搜索任务、客户、负责人或证据" /><select name="source"><option value="">全部来源</option>${sourceOptions}</select><select name="status"><option value="active" ${filters.status === 'active' ? 'selected' : ''}>待处理</option><option value="overdue" ${filters.status === 'overdue' ? 'selected' : ''}>已逾期</option><option value="completed" ${filters.status === 'completed' ? 'selected' : ''}>已完成</option><option value="suggested" ${filters.status === 'suggested' ? 'selected' : ''}>AI 候选</option><option value="all" ${filters.status === 'all' ? 'selected' : ''}>全部</option></select><button class="btn secondary" type="submit">筛选</button><span>当前 ${tasks.length} 条</span></form>
-      <div class="unified-task-list">${tasks.map(task => `<article class="unified-task-card ${task.overdue ? 'overdue' : ''} ${task.sourceKind === 'candidate' ? 'candidate' : ''}"><i class="task-priority ${escapeHtml(task.priority)}"></i><div class="unified-task-main"><header><div><span class="task-source">${escapeHtml(task.sourceLabel)}</span><span class="task-status ${escapeHtml(task.status)}">${escapeHtml(unifiedTaskStatusLabel(task.status))}</span>${task.audience === 'external' ? '<span class="task-audience">客户跟进 · 内部使用</span>' : ''}</div><small>${escapeHtml(unifiedTaskDueLabel(task))}</small></header><h4>${escapeHtml(task.title)}</h4><p>${escapeHtml(task.evidence || '暂无补充证据')}</p><footer><span>${task.owner ? `负责人:${escapeHtml(task.owner)}` : '负责人待分配'}</span><span>${escapeHtml(task.sourceTitle || '')}</span>${task.confidence !== null ? `<span>可信度 ${Math.round(Number(task.confidence || 0) * 100)}%</span>` : ''}</footer></div><div class="unified-task-actions">${unifiedTaskActions(task)}</div></article>`).join('') || '<div class="library-empty large"><strong>当前筛选下没有任务</strong><span>可以新建本地内部任务,或切换为“全部 / AI 候选”查看其他来源。</span></div>'}</div>
+      <div class="unified-task-list">${tasks.map(task => `<article class="unified-task-card ${task.overdue ? 'overdue' : ''} ${task.sourceKind === 'candidate' ? 'candidate' : ''}"><i class="task-priority ${escapeHtml(task.priority)}"></i><div class="unified-task-main"><header><div><span class="task-source">${escapeHtml(task.sourceLabel)}</span>${task.sourceRef?.type === 'diagnosis-candidate' ? '<span class="task-source diagnosis">诊断行动</span>' : ''}<span class="task-status ${escapeHtml(task.status)}">${escapeHtml(unifiedTaskStatusLabel(task.status))}</span>${task.audience === 'external' ? '<span class="task-audience">客户跟进 · 内部使用</span>' : ''}</div><small>${escapeHtml(unifiedTaskDueLabel(task))}</small></header><h4>${escapeHtml(task.title)}</h4><p>${escapeHtml(task.evidence || '暂无补充证据')}</p><footer><span>${task.owner ? `负责人:${escapeHtml(task.owner)}` : '负责人待分配'}</span><span>${escapeHtml(task.sourceTitle || '')}</span>${task.confidence !== null ? `<span>可信度 ${Math.round(Number(task.confidence || 0) * 100)}%</span>` : ''}</footer>${renderDiagnosisFeedback(task)}</div><div class="unified-task-actions">${unifiedTaskActions(task)}</div></article>`).join('') || '<div class="library-empty large"><strong>当前筛选下没有任务</strong><span>可以新建本地内部任务,或切换为“全部 / AI 候选”查看其他来源。</span></div>'}</div>
     </div>`;
   }
 
@@ -1804,6 +2134,26 @@
       }
     });
     page.addEventListener('submit', async event => {
+      const diagnosisFeedbackForm = event.target.closest('[data-diagnosis-feedback-form]');
+      if (diagnosisFeedbackForm) {
+        event.preventDefault();
+        const form = new FormData(diagnosisFeedbackForm);
+        state.knowledge.tasks.loading = true;
+        renderKnowledgeWorkspace(page);
+        try {
+          const result = await api('POST', '/api/knowledge/tasks/diagnosis-feedback', {
+            taskId: diagnosisFeedbackForm.dataset.diagnosisFeedbackForm,
+            outcome: String(form.get('outcome') || ''),
+            note: String(form.get('note') || '').trim(),
+            actor: 'dashboard-user',
+          });
+          if (result.status !== 'ok') throw result;
+          await loadUnifiedTaskHub(page);
+          toast(result.assistantMessage || '行动效果反馈已保存');
+        } catch (error) { toast(error.assistantMessage || error.message || '行动效果反馈保存失败', 'error'); }
+        finally { state.knowledge.tasks.loading = false; renderKnowledgeWorkspace(page); }
+        return;
+      }
       if (event.target.id === 'unified-task-filter-form') {
         event.preventDefault();
         const form = new FormData(event.target);
@@ -5795,15 +6145,22 @@
       renderPage();
     });
 
+    const closeSidebar = () => {
+      els.sidebar.classList.remove('open');
+      els.sidebarBackdrop.classList.remove('active');
+    };
+
     els.menuBtn.addEventListener('click', () => {
-      els.sidebar.classList.toggle('open');
+      const open = els.sidebar.classList.toggle('open');
+      els.sidebarBackdrop.classList.toggle('active', open);
     });
+    els.sidebarBackdrop.addEventListener('click', closeSidebar);
 
     window.addEventListener('hashchange', renderPage);
 
     document.addEventListener('click', e => {
       const link = e.target.closest('.nav-item');
-      if (link) els.sidebar.classList.remove('open');
+      if (link) closeSidebar();
     });
 
     const hadAccountAtStart = Boolean(currentAccount());

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

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

+ 34 - 20
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">
 <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="stylesheet" href="/dashboard/styles.css?v=20260720-bootfix2">
+<link rel="stylesheet" href="/dashboard/styles.css?v=20260724-feedback3">
 </head>
 <body>
 <div class="app" id="app">
@@ -18,22 +18,17 @@
     </div>
     <nav class="nav" id="nav">
       <div class="nav-group">
-        <div class="nav-label">功能</div>
-        <a class="nav-item active" href="#agent" data-page="agent">
-          <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h3l4 4 4-4h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-8 10H7v-2h4v2zm6-4H7V7h10v2zm0 4h-4v-2h4v2z"/></svg>
-          <span>智能会话</span>
-        </a>
-        <a class="nav-item" href="#skills" data-page="skills">
-          <svg viewBox="0 0 24 24"><path d="M19 13h-2V7h-6V5H5c-1.1 0-2 .9-2 2v6h2v6c0 1.1.9 2 2 2h6v-2H7v-6h6v2h6c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2h-6v2h6v6zM9 9H5V7h4v2zm10 4h-4V7h4v6z"/></svg>
-          <span>技能中心</span>
-        </a>
-        <a class="nav-item" href="#knowledge" data-page="knowledge">
-          <svg viewBox="0 0 24 24"><path d="M10 4H2v16h20V6H12l-2-2zm10 14H4V6h5.17l2 2H20v10zM8 10h8v2H8v-2zm0 4h6v2H8v-2z"/></svg>
-          <span>知识库</span>
+        <div class="nav-label">经营驾驶舱</div>
+        <a class="nav-item active" href="#overview" data-page="overview">
+          <svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z"/></svg>
+          <span>经营总览</span>
         </a>
-        <a class="nav-item" href="#groups" data-page="groups">
-          <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
-          <span>客户群管理</span>
+      </div>
+      <div class="nav-group">
+        <div class="nav-label">数字岗位</div>
+        <a class="nav-item" href="#agent" data-page="agent">
+          <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h3l4 4 4-4h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-8 10H7v-2h4v2zm6-4H7V7h10v2zm0 4h-4v-2h4v2z"/></svg>
+          <span>客服工作台</span>
         </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>
@@ -41,7 +36,14 @@
         </a>
         <a class="nav-item" href="#customer-ops" data-page="customer-ops">
           <svg viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
-          <span>客户管理</span>
+          <span>客户运营</span>
+        </a>
+      </div>
+      <div class="nav-group">
+        <div class="nav-label">业务资产</div>
+        <a class="nav-item" href="#groups" data-page="groups">
+          <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
+          <span>客户群</span>
         </a>
         <a class="nav-item" href="#portraits" data-page="portraits">
           <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4zM9 10c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z"/></svg>
@@ -51,19 +53,31 @@
           <svg viewBox="0 0 24 24"><path d="M16.01 11H4v2h12.01v3L20 12l-3.99-4v3zM6 17l-4-4 4-4v3h12v2H6v3z"/></svg>
           <span>客户交接</span>
         </a>
+        <a class="nav-item" href="#knowledge" data-page="knowledge">
+          <svg viewBox="0 0 24 24"><path d="M10 4H2v16h20V6H12l-2-2zm10 14H4V6h5.17l2 2H20v10zM8 10h8v2H8v-2zm0 4h6v2H8v-2z"/></svg>
+          <span>知识与任务</span>
+        </a>
+      </div>
+      <div class="nav-group">
+        <div class="nav-label">系统</div>
+        <a class="nav-item" href="#skills" data-page="skills">
+          <svg viewBox="0 0 24 24"><path d="M19 13h-2V7h-6V5H5c-1.1 0-2 .9-2 2v6h2v6c0 1.1.9 2 2 2h6v-2H7v-6h6v2h6c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2h-6v2h6v6zM9 9H5V7h4v2zm10 4h-4V7h4v6z"/></svg>
+          <span>数字员工能力</span>
+        </a>
         <a class="nav-item" href="#status" data-page="status">
           <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
-          <span>账号状态</span>
+          <span>连接与设置</span>
         </a>
       </div>
     </nav>
   </aside>
+  <button class="sidebar-backdrop" id="sidebar-backdrop" type="button" aria-label="关闭导航"></button>
   <main class="main">
     <header class="topbar">
       <button class="menu-btn" id="menu-btn" aria-label="切换菜单">
         <svg viewBox="0 0 24 24"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
       </button>
-      <h1 class="page-title" id="page-title">客户群管理</h1>
+      <h1 class="page-title" id="page-title">经营总览</h1>
       <div class="account-switcher" id="account-switcher">
         <button class="account-btn" id="account-btn">
           <span class="account-dot offline" id="account-dot"></span>
@@ -86,6 +100,6 @@
   <div class="toast-container" id="toast-container" aria-live="polite"></div>
 </div>
 <script src="/dashboard/echarts.min.js"></script>
-<script src="/dashboard/app.js?v=20260720-bootfix2"></script>
+<script src="/dashboard/app.js?v=20260724-feedback3"></script>
 </body>
 </html>

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

@@ -114,6 +114,8 @@ const {
 const {
   getUnifiedTaskHub,
   createUnifiedLocalTask,
+  createUnifiedDiagnosisCandidate,
+  updateUnifiedDiagnosisOutcome,
   updateUnifiedTask
 } = require('./unified-task-service');
 const {
@@ -121,6 +123,7 @@ const {
   updateCustomerMaster,
   updateCustomerRecommendationFeedback
 } = require('./customer-master-service');
+const { qiweiBusinessDiagnosis } = require('../tools/qiwei-business-diagnosis-run');
 
 const DASHBOARD_PORT = process.env.QIWEI_DASHBOARD_PORT || 4320;
 const STATIC_DIR = path.join(__dirname);
@@ -409,6 +412,14 @@ async function handleRequest(req, res) {
       json(res, 200, createUnifiedLocalTask(await readBody(req)));
       return;
     }
+    if (pathname === '/api/knowledge/tasks/diagnosis-candidate' && req.method === 'POST') {
+      json(res, 200, createUnifiedDiagnosisCandidate(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/knowledge/tasks/diagnosis-feedback' && req.method === 'POST') {
+      json(res, 200, updateUnifiedDiagnosisOutcome(await readBody(req)));
+      return;
+    }
     if (pathname === '/api/knowledge/tasks/update' && req.method === 'POST') {
       json(res, 200, await updateUnifiedTask(await readBody(req)));
       return;
@@ -525,6 +536,10 @@ async function handleRequest(req, res) {
       json(res, 200, { status: 'ok', data: getStateSection('summary') });
       return;
     }
+    if (pathname === '/api/business-diagnosis' && req.method === 'POST') {
+      json(res, 200, await qiweiBusinessDiagnosis(await readBody(req)));
+      return;
+    }
     if (pathname === '/api/dashboard/customers/search' && req.method === 'GET') {
       const filter = {
         keyword: url.searchParams.get('keyword') || '',

+ 188 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css

@@ -204,6 +204,7 @@
 .unified-task-main footer { display: flex; flex-wrap: wrap; gap: 8px 18px; color: #8a929d; font-size: 11px; }
 .task-source, .task-status, .task-audience { display: inline-flex; align-items: center; min-height: 22px; border-radius: 999px; padding: 0 9px; font-size: 10px; font-weight: 650; }
 .task-source { color: #40546c; background: #edf2f8; }
+.task-source.diagnosis { color: #276b4d; background: #e9f6ef; }
 .task-status { color: #3565a4; background: #edf5ff; }
 .task-status.done { color: #2d7a4d; background: #edf8f1; }
 .task-status.blocked, .task-status.dismissed { color: #8a5c22; background: #fff5e8; }
@@ -211,12 +212,42 @@
 .task-audience { color: #237344; background: #eaf7ee; }
 .unified-task-actions { min-width: 135px; display: flex; flex-wrap: wrap; justify-content: flex-end; align-content: center; gap: 7px; }
 .unified-task-actions > span, .task-promoted { color: #76808d; font-size: 11px; align-self: center; }
-
-@media (max-width: 1180px) {
+.diagnosis-feedback-summary { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(0, 2.2fr); gap: 18px; align-items: center; padding: 14px 16px; border: 1px solid #d9e7df; border-radius: 12px; background: #f7fbf9; }
+.diagnosis-feedback-summary > header { display: grid; gap: 3px; }
+.diagnosis-feedback-summary > header strong { color: #244d3a; font-size: 13px; }
+.diagnosis-feedback-summary > header span { color: #72847a; font-size: 11px; }
+.diagnosis-feedback-summary > div { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
+.diagnosis-feedback-summary > div > span { min-width: 0; display: grid; gap: 2px; color: #748078; font-size: 10px; }
+.diagnosis-feedback-summary > div strong { color: #263c31; font-size: 17px; }
+.diagnosis-feedback-form { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(260px, 1.25fr) minmax(260px, 1fr) auto; gap: 10px; align-items: end; margin-top: 4px; padding: 12px; border: 1px solid #dfe8e3; border-radius: 10px; background: #f8fbf9; }
+.diagnosis-feedback-head { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
+.diagnosis-feedback-head > div { display: grid; gap: 2px; }
+.diagnosis-feedback-head strong { color: #2a4337; font-size: 12px; }
+.diagnosis-feedback-head span, .diagnosis-feedback-head > em { color: #7a8880; font-size: 10px; font-style: normal; }
+.diagnosis-outcome { border-radius: 999px; padding: 4px 8px; font-weight: 650; }
+.diagnosis-outcome.effective { color: #217148; background: #e6f5ec; }
+.diagnosis-outcome.partially_effective { color: #7b6421; background: #fff7df; }
+.diagnosis-outcome.ineffective { color: #a3473f; background: #fff0ee; }
+.diagnosis-outcome.unknown { color: #52616d; background: #edf1f3; }
+.diagnosis-outcome-options { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid #d8e1dc; border-radius: 8px; overflow: hidden; background: #fff; }
+.diagnosis-outcome-options label { min-width: 0; min-height: 40px; display: grid; place-items: center; padding: 0 8px; border-right: 1px solid #e2e8e4; color: #59685f; font-size: 11px; cursor: pointer; transition: color .18s, background-color .18s; }
+.diagnosis-outcome-options label:last-child { border-right: 0; }
+.diagnosis-outcome-options label:hover { background: #f0f7f3; }
+.diagnosis-outcome-options label.selected, .diagnosis-outcome-options label:has(input:checked) { color: #216d48; background: #e7f5ed; font-weight: 650; }
+.diagnosis-outcome-options input { position: absolute; opacity: 0; pointer-events: none; }
+.diagnosis-outcome-options label:focus-within { outline: 2px solid #4d8f70; outline-offset: -2px; }
+.diagnosis-feedback-note { min-width: 0; display: grid; gap: 4px; }
+.diagnosis-feedback-note > span { color: #66766d; font-size: 10px; }
+.diagnosis-feedback-note input { width: 100%; min-height: 40px; border: 1px solid #d8e1dc; border-radius: 8px; padding: 0 10px; background: #fff; color: #26352e; }
+.diagnosis-feedback-form > .btn { min-height: 40px; }
+
+@media (max-width: 1500px) {
   .task-create-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
   .task-create-form > div { grid-column: 1 / -1; }
   .task-filterbar { grid-template-columns: minmax(200px, 1fr) 180px 130px auto; }
   .task-filterbar > span { display: none; }
+  .diagnosis-feedback-form { grid-template-columns: 1fr auto; }
+  .diagnosis-outcome-options { grid-column: 1 / -1; }
 }
 
 @media (max-width: 760px) {
@@ -225,6 +256,12 @@
   .unified-task-card { grid-template-columns: 4px minmax(0, 1fr); }
   .unified-task-actions { grid-column: 2; justify-content: flex-start; }
   .unified-task-main > header { align-items: flex-start; flex-direction: column; gap: 6px; }
+  .diagnosis-feedback-summary { grid-template-columns: 1fr; }
+  .diagnosis-feedback-summary > div { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .diagnosis-feedback-form { grid-template-columns: 1fr; }
+  .diagnosis-outcome-options { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .diagnosis-outcome-options label { min-height: 44px; border-bottom: 1px solid #e2e8e4; }
+  .diagnosis-feedback-note, .diagnosis-feedback-form > .btn { grid-column: 1; }
 }
 
 * { box-sizing: border-box; }
@@ -261,6 +298,8 @@ html, body {
   transform: translateX(-100%);
 }
 
+.sidebar-backdrop { display: none; }
+
 .brand {
   display: flex;
   align-items: center;
@@ -2464,6 +2503,8 @@ body.business-modal-open { overflow: hidden; }
 @media (max-width: 768px) {
   .sidebar { transform: translateX(-100%); }
   .sidebar.open { transform: translateX(0); }
+  .sidebar-backdrop { position: fixed; inset: 0; z-index: 90; display: block; border: 0; background: rgba(15, 23, 42, .42); opacity: 0; pointer-events: none; transition: opacity .2s ease; }
+  .sidebar-backdrop.active { opacity: 1; pointer-events: auto; }
   .main { margin-left: 0; }
   .menu-btn { display: block; }
   .content { padding: 20px 16px 40px; }
@@ -2494,6 +2535,151 @@ body.business-modal-open { overflow: hidden; }
 }
 
 /* 社群运营 */
+/* Business overview */
+.overview-root { max-width: 1520px; margin: 0 auto; }
+.overview-page { display: grid; gap: 16px; }
+.overview-command {
+  display: grid;
+  grid-template-columns: minmax(270px, .72fr) minmax(440px, 1.28fr);
+  gap: 30px;
+  align-items: center;
+  min-height: 190px;
+  padding: 26px 30px;
+  border: 1px solid #dce4e1;
+  border-radius: 12px;
+  background: #fff;
+  box-shadow: 0 8px 26px rgba(26, 52, 43, .055);
+}
+.overview-command-copy { display: grid; gap: 7px; }
+.overview-command-copy h2 { margin: 0; color: #172b23; font-size: 30px; line-height: 1.25; letter-spacing: 0; }
+.overview-command-copy p { max-width: 520px; margin: 0; color: #64736c; font-size: 13px; line-height: 1.7; }
+.overview-kicker { display: flex; align-items: center; gap: 7px; color: #648076; font-size: 11px; font-weight: 700; }
+.overview-live { width: 8px; height: 8px; border-radius: 50%; background: #a9b2ae; box-shadow: 0 0 0 4px #f0f3f2; }
+.overview-live.online { background: #24a369; box-shadow: 0 0 0 4px #e6f6ee; }
+.overview-query-form { display: grid; gap: 9px; }
+.overview-query-form > label { color: #35483f; font-size: 12px; font-weight: 700; }
+.overview-query-form > div:first-of-type { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
+.overview-query-form input { width: 100%; min-height: 46px; padding: 0 14px; border: 1px solid #d7dfdc; border-radius: 8px; color: #1e3028; background: #fbfcfc; font: inherit; }
+.overview-query-form input:focus { border-color: #2a8a64; outline: 3px solid rgba(42, 138, 100, .13); background: #fff; }
+.overview-query-form .btn { min-height: 46px; padding-inline: 19px; background: #237858; border-color: #237858; }
+.overview-query-form .btn:hover { background: #1b684b; border-color: #1b684b; }
+.overview-query-chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.overview-query-chips button { min-height: 30px; padding: 5px 10px; border: 1px solid #e0e5e3; border-radius: 999px; color: #617069; background: #fff; font: inherit; font-size: 11px; cursor: pointer; transition: border-color .18s, color .18s, background-color .18s; }
+.overview-query-chips button:hover { border-color: #a8c9bb; color: #246b50; background: #f4faf7; }
+.overview-kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
+.overview-kpi { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 12px; align-items: center; min-height: 104px; padding: 16px; border: 1px solid #dfe5e2; border-radius: 10px; color: inherit; background: #fff; text-align: left; cursor: pointer; box-shadow: 0 4px 16px rgba(28, 49, 41, .035); transition: border-color .18s, box-shadow .18s, background-color .18s; }
+.overview-kpi:hover { border-color: #b8cfc5; background: #fcfefd; box-shadow: 0 8px 22px rgba(28, 69, 52, .075); }
+.overview-kpi.attention { border-color: #efc9bf; background: #fffdfc; }
+.overview-kpi-icon { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; border-radius: 8px; color: #347659; background: #eaf5ef; }
+.overview-kpi-icon.portrait { color: #376b96; background: #edf5fb; }
+.overview-kpi-icon.task { color: #956925; background: #fff6e7; }
+.overview-kpi-icon.risk { color: #aa5043; background: #fff0ed; }
+.overview-kpi-icon svg { width: 21px; height: 21px; fill: currentColor; }
+.overview-kpi > div { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; align-items: baseline; }
+.overview-kpi small { grid-column: 1 / -1; color: #75827c; font-size: 11px; }
+.overview-kpi strong { color: #1d3028; font-size: 26px; line-height: 1; }
+.overview-kpi em { justify-self: end; color: #87918c; font-size: 10px; font-style: normal; white-space: nowrap; }
+.overview-main-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(310px, .75fr); gap: 12px; align-items: start; }
+.overview-primary-column, .overview-secondary-column { display: grid; gap: 12px; }
+.overview-panel { padding: 18px; border: 1px solid #dfe5e2; border-radius: 10px; background: #fff; box-shadow: 0 5px 18px rgba(24, 48, 38, .035); }
+.overview-panel-header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 14px; }
+.overview-panel-header > div { display: grid; gap: 2px; }
+.overview-panel-header span { color: #5d8473; font-size: 9px; font-weight: 750; letter-spacing: 1px; }
+.overview-panel-header h3 { margin: 0; color: #23372e; font-size: 16px; letter-spacing: 0; }
+.overview-panel-header > small { color: #89948f; font-size: 10px; }
+.overview-panel-header > strong { color: #223a30; font-size: 22px; }
+.overview-diagnosis-empty { min-height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; padding: 24px; border-top: 1px solid #edf0ee; color: #78857f; text-align: center; }
+.overview-diagnosis-empty strong { color: #40534a; font-size: 14px; }
+.overview-diagnosis-empty span { max-width: 520px; font-size: 11px; line-height: 1.7; }
+.overview-diagnosis-result { display: grid; gap: 13px; padding-top: 14px; border-top: 1px solid #edf0ee; }
+.overview-diagnosis-result > header { display: grid; gap: 5px; padding-left: 12px; border-left: 3px solid #4b89b6; }
+.overview-diagnosis-result.warning > header { border-left-color: #d99335; }
+.overview-diagnosis-result.danger > header { border-left-color: #cc5e50; }
+.overview-diagnosis-result.success > header { border-left-color: #319267; }
+.overview-diagnosis-result > header span { color: #638072; font-size: 10px; font-weight: 700; }
+.overview-diagnosis-result > header strong { color: #263a31; font-size: 17px; }
+.overview-diagnosis-result > p { margin: 0; color: #65726c; font-size: 12px; line-height: 1.7; }
+.overview-evidence { display: flex; flex-wrap: wrap; gap: 7px; }
+.overview-evidence span { padding: 6px 9px; border-radius: 6px; color: #53665d; background: #f2f6f4; font-size: 10px; }
+.overview-data-gaps { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 12px; align-items: start; padding: 10px 12px; border-left: 3px solid #d99a43; background: #fffaf1; }
+.overview-data-gaps > strong { color: #8b6327; font-size: 10px; white-space: nowrap; }
+.overview-data-gaps > div { display: flex; flex-wrap: wrap; gap: 6px; }
+.overview-data-gaps span { display: inline-flex; flex-direction: column; gap: 2px; padding-right: 9px; border-right: 1px solid #eadcc2; color: #765d35; font-size: 10px; }
+.overview-data-gaps span:last-child { border-right: 0; }
+.overview-data-gaps small { color: #9a845f; font-size: 8px; }
+.overview-diagnosis-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid #edf0ee; border-bottom: 1px solid #edf0ee; }
+.overview-diagnosis-action { min-width: 0; display: grid; grid-template-rows: minmax(70px, 1fr) auto; border-right: 1px solid #edf0ee; }
+.overview-diagnosis-action:last-child { border-right: 0; }
+.overview-diagnosis-go { position: relative; min-width: 0; min-height: 70px; display: grid; gap: 4px; align-content: center; padding: 11px 34px 8px 12px; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; transition: background-color .18s; }
+.overview-diagnosis-go:hover { background: #f7faf8; }
+.overview-diagnosis-go > span { color: #2e4b3e; font-size: 12px; font-weight: 700; }
+.overview-diagnosis-go > small { overflow: hidden; color: #68776f; font-size: 9px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
+.overview-diagnosis-go svg { position: absolute; top: 50%; right: 10px; width: 15px; height: 15px; fill: #7f9a8d; transform: translateY(-50%); }
+.overview-candidate-btn { min-height: 31px; padding: 0 12px; border: 0; border-top: 1px solid #edf0ee; color: #327157; background: #fbfdfc; font: inherit; font-size: 9px; font-weight: 700; text-align: left; cursor: pointer; transition: color .18s, background-color .18s; }
+.overview-candidate-btn:hover { color: #1f5e45; background: #f0f8f4; }
+.overview-candidate-btn.added, .overview-candidate-btn:disabled { color: #718079; background: #f3f5f4; cursor: default; }
+.overview-action-list { display: grid; gap: 7px; }
+.overview-action { display: grid; grid-template-columns: 4px 42px minmax(0, 1fr) 18px; gap: 10px; align-items: center; min-height: 61px; padding: 8px 11px; border: 1px solid #e4e8e6; border-radius: 8px; color: inherit; background: #fff; text-align: left; cursor: pointer; transition: border-color .18s, background-color .18s; }
+.overview-action:hover { border-color: #bdcec7; background: #fafcfb; }
+.overview-action > i { align-self: stretch; border-radius: 999px; background: #5f8fbc; }
+.overview-action.warning > i { background: #d99a43; }
+.overview-action.danger > i { background: #cf675b; }
+.overview-action > strong { color: #2a3f36; font-size: 21px; text-align: center; }
+.overview-action > span { min-width: 0; display: grid; gap: 3px; }
+.overview-action b { color: #35483f; font-size: 12px; }
+.overview-action small { overflow: hidden; color: #849089; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+.overview-action > svg { width: 16px; height: 16px; fill: #8da096; }
+.overview-health-track { height: 7px; margin: 17px 0; overflow: hidden; border-radius: 999px; background: #edf1ef; }
+.overview-health-track i { display: block; height: 100%; border-radius: inherit; background: #3d9b70; }
+.overview-health-panel dl { display: grid; gap: 9px; margin: 0 0 16px; }
+.overview-health-panel dl div { display: flex; justify-content: space-between; gap: 16px; color: #6f7e77; font-size: 11px; }
+.overview-health-panel dt, .overview-health-panel dd { margin: 0; }
+.overview-health-panel dd { color: #33493f; font-weight: 700; }
+.overview-text-action { width: 100%; min-height: 37px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0; border: 0; border-top: 1px solid #edf0ee; color: #39735a; background: transparent; font: inherit; font-size: 11px; cursor: pointer; }
+.overview-text-action svg { width: 15px; height: 15px; fill: currentColor; }
+.overview-worker { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; min-height: 58px; border-top: 1px solid #edf0ee; }
+.overview-worker-icon { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 7px; color: #417a61; background: #eef6f2; }
+.overview-worker-icon svg { width: 17px; height: 17px; fill: currentColor; }
+.overview-worker > div { min-width: 0; display: grid; gap: 3px; }
+.overview-worker strong { color: #30443a; font-size: 12px; }
+.overview-worker small { overflow: hidden; color: #87928c; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.overview-worker em { min-height: 22px; display: inline-flex; align-items: center; padding: 0 8px; border-radius: 999px; color: #69756f; background: #f0f2f1; font-size: 9px; font-style: normal; white-space: nowrap; }
+.overview-worker em.online { color: #227149; background: #e9f7ef; }
+.overview-data-note { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 22px; min-height: 40px; padding: 0 4px; color: #8a9590; font-size: 10px; }
+.overview-data-note button { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 9px; border: 0; color: #547065; background: transparent; font: inherit; cursor: pointer; }
+.overview-data-note button svg { width: 14px; height: 14px; fill: currentColor; }
+.overview-query-form button:focus-visible, .overview-kpi:focus-visible, .overview-action:focus-visible, .overview-diagnosis-actions button:focus-visible, .overview-text-action:focus-visible, .overview-data-note button:focus-visible { outline: 3px solid rgba(35, 120, 88, .22); outline-offset: 2px; }
+
+@media (max-width: 1180px) {
+  .overview-command { grid-template-columns: 1fr; gap: 18px; }
+  .overview-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .overview-main-grid { grid-template-columns: minmax(0, 1fr) 300px; }
+}
+
+@media (max-width: 860px) {
+  .overview-main-grid { grid-template-columns: 1fr; }
+  .overview-secondary-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 640px) {
+  .overview-command { min-height: 0; padding: 19px 16px; }
+  .overview-command-copy h2 { font-size: 24px; }
+  .overview-query-form > div:first-of-type { grid-template-columns: 1fr; }
+  .overview-query-form input { font-size: 16px; }
+  .overview-query-form .btn { width: 100%; }
+  .overview-kpi-grid, .overview-secondary-column { grid-template-columns: 1fr; }
+  .overview-kpi { min-height: 88px; }
+  .overview-diagnosis-actions { grid-template-columns: 1fr; }
+  .overview-diagnosis-action { border-right: 0; border-bottom: 1px solid #edf0ee; }
+  .overview-diagnosis-action:last-child { border-bottom: 0; }
+  .overview-data-note { align-items: flex-start; flex-direction: column; padding-bottom: 10px; }
+  .overview-data-note button { margin-left: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .overview-query-chips button, .overview-kpi, .overview-action, .overview-diagnosis-actions button { transition: none; }
+}
+
 .group-ops-page { display: flex; flex-direction: column; gap: 20px; }
 .group-ops-tabs { display: flex; gap: 6px; padding: 5px; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--panel-bg); box-shadow: var(--shadow); }
 .group-ops-tabs button { min-height: 42px; padding: 9px 16px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font: inherit; font-weight: 600; white-space: nowrap; cursor: pointer; transition: color .2s, background-color .2s; }

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

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

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

@@ -52,6 +52,7 @@ const {
   qiweiGroupOpsAutomation,
   qiweiGroupOpsManageAccess
 } = require('./tools/qiwei-group-operations-run');
+const { qiweiBusinessDiagnosis, qiweiBusinessActionCandidate, qiweiBusinessActionFeedback } = require('./tools/qiwei-business-diagnosis-run');
 const {
   qiweiPrepareCustomerPortrait,
   qiweiUpdateCustomerPortrait,
@@ -696,6 +697,49 @@ function createServer() {
     inputSchema: { accountKey: z.string().optional(), guid: z.string().optional(), requester: z.string().optional(), principal: z.string().optional(), actor: z.string().optional(), action: z.enum(['list','upsert']).optional(), role: z.enum(['headquarters_admin','regional_manager','store_manager','executor','auditor']).optional(), storeScope: z.array(z.string()).optional(), status: z.enum(['active','disabled']).optional(), bootstrapConfirmed: z.boolean().optional() }
   }, wrap(qiweiGroupOpsManageAccess));
 
+  server.registerTool('qiwei_business_diagnosis', {
+    title: '诊断企微经营问题',
+    description: '只读汇总客户主档、真实会话和社群运营数据,针对经营问题返回结论、证据、数据缺口和可下钻动作。缺少订单或 GMV 数据时明确拒绝生成虚假交易结论。',
+    inputSchema: {
+      question: z.string().min(2),
+      accountKey: z.string().optional(),
+      guid: z.string().optional(),
+      uid: z.string().optional(),
+      date: z.string().optional()
+    }
+  }, wrap(qiweiBusinessDiagnosis));
+
+  server.registerTool('qiwei_business_action_candidate', {
+    title: '将经营建议加入行动候选',
+    description: '把经营诊断中的一条建议加入统一任务中心候选池。只创建待人工确认候选,不会生成正式任务、同步企微待办或发送消息。',
+    inputSchema: {
+      question: z.string().min(2),
+      diagnosisType: z.string().optional(),
+      scope: z.record(z.any()).optional(),
+      action: z.object({
+        id: z.string().optional(),
+        label: z.string().min(1),
+        description: z.string().optional(),
+        route: z.string().optional(),
+        hash: z.string().optional(),
+        priority: z.enum(['low', 'medium', 'high', 'urgent']).optional()
+      }),
+      evidence: z.array(z.union([z.string(), z.record(z.any())])).optional(),
+      dataGaps: z.array(z.record(z.any())).optional()
+    }
+  }, wrap(qiweiBusinessActionCandidate));
+
+  server.registerTool('qiwei_business_action_feedback', {
+    title: '记录经营行动效果反馈',
+    description: '为已完成的经营诊断来源内部任务记录有效、部分有效、无效或暂无法判断。反馈只用于内部复盘,不会发送客户消息或同步企微待办。',
+    inputSchema: {
+      taskId: z.string().min(1),
+      outcome: z.enum(['effective', 'partially_effective', 'ineffective', 'unknown']),
+      note: z.string().max(1000).optional(),
+      actor: z.string().max(100).optional()
+    }
+  }, wrap(qiweiBusinessActionFeedback));
+
   server.registerTool(
     'qiwei_prepare_customer_portrait',
     {

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

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

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
claude-code/claude-code-qiwe-assistant/package.json


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

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

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

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

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

@@ -16,6 +16,8 @@ const checks = [
   'group-ops:eval',
   'group-ops:auto-smoke',
   'group-ops:access-smoke',
+  'diagnosis:smoke',
+  'diagnosis-actions:smoke',
   'smoke',
   'agent:smoke',
   'library:smoke',

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

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

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

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

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

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

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

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

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

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

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

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

+ 17 - 0
fix_regex.py

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

+ 22 - 0
fix_regex2.py

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

+ 374 - 0
replace_customer_ops.py

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

+ 385 - 0
replace_portraits.py

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

+ 320 - 0
replace_transfers.py

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

Неке датотеке нису приказане због велике количине промена