Browse Source

feat: 驾驶舱接入 AI 经营解读

- 新增 POST /api/ops/insights:把 dashboard 聚合结果整理成紧凑上下文(KPI/环比/趋势/Top店/垫底店/漏斗/成本结构)交给对话模型,返回 3~5 条结论
- 提示词约束:引用具体数字、指出风险给建议、数据不足要直说、每条 ≤60 字
- 10 分钟内存缓存,前端「生成解读 / 重新生成」按钮 + 复制;筛选变化后标记结果过期
- 未配置对话模型(含占位 Key)时返回 enabled:false,前端提示去系统设置
- 测试:未配置模型场景 1 条(47/47)
liangzhao 2 weeks ago
parent
commit
6dd0a95cd1
5 changed files with 228 additions and 5 deletions
  1. 1 0
      README.md
  2. 82 2
      public/cockpit.js
  3. 14 0
      public/style.css
  4. 10 0
      scripts/test-ops-import.js
  5. 121 3
      server/routes/ops.js

+ 1 - 0
README.md

@@ -53,6 +53,7 @@
 - **重复校验**:平台明细按「日期 + 门店编号」判断——同一天同一店的相同数据自动跳过;内容有变化记为「冲突」不覆盖,勾选「覆盖已导入的文件」才覆盖。上传结果会显示「新增 N / 已存在 N / 冲突 N」。
 - **报表展示**:一张表、一行一店一天,列 = 店铺 / 日期 / 勾选的指标(勾几个显示几列),按学校分组并带每日小计。平台明细同样进入「店铺运营」报表——有效订单→订单量、营业额→营收、曝光人数→曝光量、进店转化率→进店转化率;同一天同一店两种来源都有时,以人工表为准(人工优先、平台补位)。
 - **经营驾驶舱**:一屏看经营大盘——6 个 KPI(营业额 / 有效订单 / 客单价 / 曝光人数 / 进店转化率 / 店铺评分,带环比)+ 营业额与订单趋势、门店营业额 Top10、转化漏斗(曝光→进店→下单)、收入结构(收入/支出/补贴)。支持日期、学校、店铺筛选,环比按「紧邻的等长区间」计算。
+- **AI 经营解读**:点「生成解读」,把当前范围的 KPI、趋势、门店增减、漏斗与成本结构整理成上下文交给对话模型,输出 3~5 条结论与建议(引用具体数字);结果缓存 10 分钟,可复制。未配置对话模型时会明确提示去「系统设置 → API 设置」配置。
 - **字段与店铺筛选**:字段下拉可勾选平台导出里的任意列(默认 4 个基础指标),勾几个就显示几列;店铺下拉可多选门店,只看选中的店;选择保存在浏览器本地。
 - **学校归属**:平台表里没有学校列时,优先用上传时选的学校,其次从文件名识别(如「浙江工商桐庐校区20261018…」匹配到已登记学校);都匹配不到会要求先选学校。
 - **原件存档**:每次上传的 xlsx 原样入库,可在「导入记录」里下载回;解析规则升级后可「重新解析」而不必重传。

+ 82 - 2
public/cockpit.js

@@ -24,6 +24,7 @@
     schools: null,
     shops: null,
     charts: {},
+    insightPoints: [],
   };
 
   function esc(value) {
@@ -65,8 +66,8 @@
     return `<span class="ckpt-delta ${cls}">${arrow} ${text}</span>`;
   }
 
-  async function api(url) {
-    const res = await fetch(url);
+  async function api(url, options) {
+    const res = await fetch(url, options);
     const body = await res.json().catch(() => null);
     return { res, body };
   }
@@ -87,6 +88,16 @@
         <div class="ops-bar-group"><span class="ops-bar-label">学校</span><div class="ops-multi" id="ckptSchool"></div></div>
         <div class="ops-bar-group"><span class="ops-bar-label">店铺</span><div class="ops-multi" id="ckptShop"></div></div>
       </div>
+      <section class="ckpt-ai" id="ckptAi">
+        <div class="ckpt-ai-head">
+          <div class="ckpt-ai-title"><span class="ckpt-ai-badge">AI</span><h3>经营解读</h3><span class="ckpt-ai-meta" id="ckptAiMeta"></span></div>
+          <div class="ckpt-ai-actions">
+            <button class="btn btn-ghost btn-small" id="ckptAiCopy" hidden>复制</button>
+            <button class="btn btn-primary btn-small" id="ckptAiBtn">生成解读</button>
+          </div>
+        </div>
+        <div class="ckpt-ai-body" id="ckptAiBody">点击「生成解读」,模型会根据当前筛选范围的数据给出结论与建议。</div>
+      </section>
       <div class="ckpt-kpis" id="ckptKpis"></div>
       <div class="ckpt-grid">
         <section class="ckpt-card"><div class="ckpt-card-head"><h3>营业额 / 订单趋势</h3><span id="ckptTrendNote"></span></div><div class="ckpt-chart" id="ckptTrend"></div></section>
@@ -99,6 +110,17 @@
         <small>调整日期,或换一个学校 / 店铺</small>
       </div>`;
 
+    $('ckptAiBtn').addEventListener('click', () => requestInsight(true));
+    $('ckptAiCopy').addEventListener('click', () => {
+      const text = state.insightPoints.join('\n');
+      if (!text) return;
+      navigator.clipboard.writeText(text).then(() => {
+        const btn = $('ckptAiCopy');
+        btn.textContent = '已复制';
+        setTimeout(() => { btn.textContent = '复制'; }, 1500);
+      }).catch(() => {});
+    });
+
     $('ckptFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadData(); });
     $('ckptTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadData(); });
 
@@ -259,6 +281,58 @@
     }, true);
   }
 
+  // ---------- AI 解读 ----------
+
+  async function requestInsight(refresh) {
+    const body = $('ckptAiBody');
+    const btn = $('ckptAiBtn');
+    if (!body || !btn) return;
+    btn.disabled = true;
+    btn.textContent = '分析中…';
+    body.innerHTML = '<span class="ckpt-ai-loading">AI 正在读取数据并生成结论…</span>';
+    $('ckptAiMeta').textContent = '';
+    $('ckptAiCopy').hidden = true;
+
+    const payload = {
+      from: state.from,
+      to: state.to,
+      schools: state.schools ? [...state.schools].join(',') : '',
+      shops: state.shops ? [...state.shops].join(',') : '',
+      refresh: !!refresh,
+    };
+    try {
+      const { res, body: data } = await api('/api/ops/insights', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(payload),
+      });
+      if (!res.ok) throw new Error((data && (data.detail || data.error)) || ('HTTP ' + res.status));
+      if (data && data.enabled === false) {
+        body.innerHTML = '未配置对话模型。请到「系统设置 → API 设置」选择对话模型并填写 Key,然后回到这里重新生成。';
+        return;
+      }
+      if (data && data.note) {
+        body.textContent = data.note;
+        return;
+      }
+      const points = (data && data.points) || [];
+      if (!points.length) {
+        body.textContent = '模型没有返回有效结论,请稍后重试。';
+        return;
+      }
+      state.insightPoints = points;
+      body.innerHTML = `<ul class="ckpt-ai-list">${points.map((point) => `<li>${esc(point)}</li>`).join('')}</ul>`;
+      const time = data.generatedAt ? new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '';
+      $('ckptAiMeta').textContent = [data.model, time, data.cached ? '缓存' : ''].filter(Boolean).join(' · ');
+      $('ckptAiCopy').hidden = false;
+    } catch (e) {
+      body.innerHTML = `<span class="ckpt-ai-error">生成失败:${esc(e.message || e)}</span>`;
+    } finally {
+      btn.disabled = false;
+      btn.textContent = refresh ? '重新生成' : '生成解读';
+    }
+  }
+
   // ---------- 数据 ----------
 
   async function loadData() {
@@ -288,6 +362,12 @@
         renderFunnel(body);
         renderStructure(body);
         $('ckptTrendNote').textContent = body.prev ? `对比 ${shortDate(body.prev.from)} ~ ${shortDate(body.prev.to)}` : '暂无上期可比';
+        if (state.insightPoints.length) {
+          state.insightPoints = [];
+          $('ckptAiBody').innerHTML = '筛选范围已变化,点击「生成解读」重新分析。';
+          $('ckptAiMeta').textContent = '';
+          $('ckptAiCopy').hidden = true;
+        }
         Object.values(state.charts).forEach((chart) => chart && chart.resize());
       }
     } catch (e) {

+ 14 - 0
public/style.css

@@ -1366,3 +1366,17 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 @media (max-width: 1080px) {
   .ckpt-grid { grid-template-columns: 1fr; }
 }
+
+/* ── 驾驶舱 · AI 解读 ─────────────────────────────────── */
+.ckpt-ai { margin-bottom: 14px; padding: 14px 16px; background: linear-gradient(135deg, #fff 0%, #fff8f4 100%); border: 1px solid rgba(255, 107, 53, 0.22); border-radius: var(--radius-lg); }
+.ckpt-ai-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.ckpt-ai-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.ckpt-ai-title h3 { margin: 0; font-size: 13.5px; font-weight: 650; color: var(--text); }
+.ckpt-ai-badge { padding: 1px 6px; font-size: 10px; font-weight: 700; letter-spacing: .04em; color: #fff; background: var(--accent); border-radius: 5px; }
+.ckpt-ai-meta { font-size: 11.5px; color: var(--text-3); }
+.ckpt-ai-actions { display: flex; gap: 8px; flex: 0 0 auto; }
+.ckpt-ai-body { margin-top: 10px; font-size: 13px; line-height: 1.75; color: var(--text-2); }
+.ckpt-ai-list { margin: 0; padding-left: 18px; }
+.ckpt-ai-list li { margin: 3px 0; }
+.ckpt-ai-loading { color: var(--text-3); }
+.ckpt-ai-error { color: #c0392b; }

+ 10 - 0
scripts/test-ops-import.js

@@ -441,6 +441,16 @@ async function main() {
       expect(one.body.ranking.length === 1, `ranking ${one.body.ranking.length}`);
     });
 
+    await check('E2E AI 解读:未配置模型时明确返回', async () => {
+      const r = await fetch(base + '/api/ops/insights', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({}),
+      }).then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.enabled === false && r.body.reason === 'chat-not-configured', JSON.stringify(r.body));
+    });
+
     await check('E2E 导入记录只含元数据', async () => {
       const r = await fetch(base + '/api/ops/uploads').then(readJson);
       expect(r.body.uploads.length >= 1, '记录为空');

+ 121 - 3
server/routes/ops.js

@@ -8,6 +8,8 @@ const { sendJson, readBody } = require('../util.js');
 const opsImport = require('../../ops-import.js');
 const { createOpsStore } = require('../../ops-store.js');
 const { loadSchools } = require('../../schools-store.js');
+const { makeChatCfg } = require('../context.js');
+const { chatCompletion } = require('../agent/llm.js');
 
 const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
 const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
@@ -379,13 +381,14 @@ async function buildDashboard(store, { from, to, schools, shops }) {
     return { date, revenue: agg.revenue, orders: agg.orders };
   });
 
-  const ranking = [...byShop.values()]
+  const allShops = [...byShop.values()]
     .map((shop) => {
       const agg = aggregateRows(shop.rows);
       return { shopId: shop.shopId, shopName: shop.shopName, school: shop.school, revenue: agg.revenue, orders: agg.orders };
     })
-    .sort((a, b) => b.revenue - a.revenue)
-    .slice(0, 10);
+    .sort((a, b) => b.revenue - a.revenue);
+  const ranking = allShops.slice(0, 10);
+  const bottom = allShops.slice(-5).reverse();
 
   const funnel = {
     exposure: cur.exposure,
@@ -405,11 +408,66 @@ async function buildDashboard(store, { from, to, schools, shops }) {
     kpis,
     trend,
     ranking,
+    bottom,
+    shopCount: allShops.length,
     funnel,
     structure,
   };
 }
 
+const INSIGHT_SYSTEM_PROMPT = [
+  '你是连锁餐饮公司的经营分析师。用户会给一段 JSON,包含某段时间的经营汇总(kpis)、按日趋势(trend)、门店排行(ranking)与垫底(bottom)、转化漏斗(funnel)、收入支出结构(structure)。',
+  '请用中文输出 3~5 条要点,每行一条,以「- 」开头。要求:',
+  '1. 先给结论,再给依据;',
+  '2. 必须引用 JSON 里的具体数字(金额保留两位小数、人数/单量用整数、比率用百分比);',
+  '3. 指出风险(下滑、零单门店、成本占比高、漏斗转化偏低等),并给一句可执行建议;',
+  '4. 数据不足(如 prev 为 null、只有 1 天)就直接说明,不要编造;',
+  '5. 每条不超过 60 字,不要输出 JSON、标题或前后缀。',
+].join('\n');
+
+function insightContext(data) {
+  const k = (key) => (data.kpis[key] ? { value: data.kpis[key].value, delta: data.kpis[key].delta } : null);
+  const rate = (a, b) => (b > 0 ? Math.round((a / b) * 1000) / 10 : null);
+  const funnel = data.funnel || {};
+  return {
+    range: { from: data.from, to: data.to, days: data.trend.length, prev: data.prev },
+    kpis: {
+      revenue: k('revenue'),
+      orders: k('orders'),
+      avgTicket: k('avgTicket'),
+      exposure: k('exposure'),
+      conversion: k('conversion'),
+      rating: k('rating'),
+    },
+    funnel: {
+      exposure: funnel.exposure,
+      visit: funnel.visit,
+      order: funnel.order,
+      visitRate: rate(funnel.visit, funnel.exposure),
+      orderRate: rate(funnel.order, funnel.visit),
+    },
+    trend: data.trend.slice(-14),
+    shopCount: data.shopCount,
+    topShops: (data.ranking || []).slice(0, 5).map((s) => ({ name: s.shopName, revenue: s.revenue, orders: s.orders })),
+    bottomShops: (data.bottom || []).map((s) => ({ name: s.shopName, revenue: s.revenue, orders: s.orders })),
+    structure: (data.structure || []).map((s) => ({
+      date: s.date,
+      income: s.income,
+      expense: s.expense,
+      subsidy: s.subsidy,
+      expenseRatio: s.income > 0 ? Math.round((s.expense / s.income) * 1000) / 10 : null,
+    })),
+  };
+}
+
+function parseInsightPoints(text) {
+  return String(text || '')
+    .split(/\r?\n/)
+    .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.、)])\s*/, '').trim())
+    .filter(Boolean)
+    .slice(0, 6);
+}
+
 function csvCell(value) {
   const text = value == null ? '' : String(value);
   return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
@@ -655,6 +713,66 @@ function register(app, ctx) {
     }
   });
 
+  const insightCache = new Map();
+  const INSIGHT_TTL_MS = 10 * 60 * 1000;
+
+  app.post('/api/ops/insights', async ({ req, res }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const chatCfg = makeChatCfg(ctx);
+    if (!chatCfg.chatApiKey || chatCfg.chatApiKey === 'your-api-key-here') {
+      return sendJson(res, 200, { enabled: false, reason: 'chat-not-configured' });
+    }
+    const body = await readBody(req);
+    const params = {
+      from: parseYmd(body.from) || undefined,
+      to: parseYmd(body.to) || undefined,
+      schools: splitList(body.schools),
+      shops: splitList(body.shops),
+    };
+    const cacheKey = JSON.stringify(params);
+    const cached = insightCache.get(cacheKey);
+    if (!body.refresh && cached && Date.now() - cached.at < INSIGHT_TTL_MS) {
+      return sendJson(res, 200, { enabled: true, ...cached.value, cached: true });
+    }
+
+    let data;
+    try {
+      data = await buildDashboard(store, params);
+    } catch (e) {
+      return sendJson(res, 503, { error: '聚合失败', detail: e.message || String(e) });
+    }
+    if (!data.kpis) {
+      return sendJson(res, 200, { enabled: true, points: [], note: '所选范围没有数据' });
+    }
+
+    const context = insightContext(data);
+    let text;
+    try {
+      text = await chatCompletion({
+        apiBase: chatCfg.chatApiBase,
+        apiKey: chatCfg.chatApiKey,
+        model: chatCfg.chatModel,
+        temperature: 0.3,
+        timeoutMs: 45000,
+        messages: [
+          { role: 'system', content: INSIGHT_SYSTEM_PROMPT },
+          { role: 'user', content: JSON.stringify(context) },
+        ],
+      });
+    } catch (e) {
+      return sendJson(res, 502, { enabled: true, error: '模型调用失败', detail: e.message || String(e) });
+    }
+
+    const value = {
+      points: parseInsightPoints(text),
+      model: chatCfg.chatModel,
+      generatedAt: new Date().toISOString(),
+      digest: context,
+    };
+    insightCache.set(cacheKey, { at: Date.now(), value });
+    sendJson(res, 200, { enabled: true, ...value });
+  });
+
   app.get('/api/ops/dashboard', async ({ res, query }) => {
     if (!(await requireHealthy(store, res))) return;
     const from = parseYmd(query.from);