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