Explorar o código

feat: 新增经营驾驶舱(KPI + 趋势/排行/漏斗/结构)

- 服务端 GET /api/ops/dashboard 统一口径聚合:营业额、订单、客单价、曝光、进店转化率(Σ进店/Σ曝光)、评分(排除 0 分店)
- 环比按紧邻的等长区间;金额/数量用百分比,比率用 pp,上期缺失显示 —
- 前端新菜单「经营驾驶舱」:6 个 KPI 卡 + 4 张 ECharts(双轴趋势、门店 Top10、转化漏斗、收入/支出/补贴分组柱)
- 筛选复用公共多选组件(从 ops.js 抽出 public/multi-select.js)
- 测试:驾驶舱 KPI/筛选 2 条,冒烟新增只读断言(46/46、112/112)
liangzhao hai 2 semanas
pai
achega
2066b4a274
Modificáronse 11 ficheiros con 710 adicións e 102 borrados
  1. 1 0
      README.md
  2. 2 0
      public/app.js
  3. 332 0
      public/cockpit.js
  4. 19 0
      public/index.html
  5. 110 0
      public/multi-select.js
  6. 3 102
      public/ops.js
  7. 28 0
      public/style.css
  8. 5 0
      scripts/smoke.js
  9. 21 0
      scripts/test-ops-import.js
  10. 187 0
      server/routes/ops.js
  11. 2 0
      server/routes/static.js

+ 1 - 0
README.md

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

+ 2 - 0
public/app.js

@@ -34,6 +34,7 @@
     tasks: document.getElementById('viewTasks'),
     history: document.getElementById('viewHistory'),
     reports: document.getElementById('viewReports'),
+    cockpit: document.getElementById('viewCockpit'),
     ops: document.getElementById('viewOps'),
     schools: document.getElementById('viewSchools'),
     lake: document.getElementById('viewLake'),
@@ -401,6 +402,7 @@
     if (window.shanTasksSetActive) window.shanTasksSetActive(name === 'tasks'); // 离开任务页清掉轮询
     if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
     if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
+    if (name === 'cockpit' && window.shanCockpitInit) window.shanCockpitInit();
     if (name === 'ops' && window.shanOpsInit) window.shanOpsInit();
     if (name === 'schools' && window.shanSchoolsInit) window.shanSchoolsInit();
     if (name === 'lake' && window.shanLakeInit) window.shanLakeInit(); // 明细 tab 激活时再懒加载报表

+ 332 - 0
public/cockpit.js

@@ -0,0 +1,332 @@
+'use strict';
+
+// 经营驾驶舱:KPI + 趋势 / 门店排行 / 转化漏斗 / 收入结构
+// 数据来自 GET /api/ops/dashboard(服务端已按统一口径聚合)
+(function () {
+  const $ = (id) => document.getElementById(id);
+  const COLORS = { main: '#ff6b35', soft: '#ffb08a', grey: '#b8b2ad', green: '#2e7d4f', red: '#c0392b' };
+
+  const KPI_DEFS = [
+    { key: 'revenue', label: '营业额', unit: 'money' },
+    { key: 'orders', label: '有效订单', unit: 'int' },
+    { key: 'avgTicket', label: '客单价', unit: 'money' },
+    { key: 'exposure', label: '曝光人数', unit: 'int' },
+    { key: 'conversion', label: '进店转化率', unit: 'rate' },
+    { key: 'rating', label: '店铺评分', unit: 'num' },
+  ];
+
+  const state = {
+    inited: false,
+    loading: false,
+    data: null,
+    from: '',
+    to: '',
+    schools: null,
+    shops: null,
+    charts: {},
+  };
+
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value == null ? '' : value);
+    return div.innerHTML;
+  }
+
+  function ymdToInput(ymd) {
+    const s = String(ymd || '');
+    return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : '';
+  }
+
+  function inputToYmd(value) {
+    return String(value || '').replace(/-/g, '');
+  }
+
+  function shortDate(ymd) {
+    const s = String(ymd || '');
+    return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s;
+  }
+
+  function fmtValue(value, unit) {
+    if (value === null || value === undefined) return '–';
+    const n = Number(value);
+    if (!Number.isFinite(n)) return '–';
+    if (unit === 'rate') return (n * 100).toFixed(2) + '%';
+    if (unit === 'num') return n.toFixed(2);
+    if (unit === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+    return n.toLocaleString('zh-CN');
+  }
+
+  function fmtDelta(delta, unit) {
+    if (delta === null || delta === undefined) return '<span class="ckpt-delta muted">—</span>';
+    const up = delta > 0;
+    const cls = delta === 0 ? 'muted' : (up ? 'up' : 'down');
+    const arrow = delta === 0 ? '' : (up ? '↑' : '↓');
+    const text = unit === 'pp' ? Math.abs(delta).toFixed(1) + 'pp' : Math.abs(delta).toFixed(1) + '%';
+    return `<span class="ckpt-delta ${cls}">${arrow} ${text}</span>`;
+  }
+
+  async function api(url) {
+    const res = await fetch(url);
+    const body = await res.json().catch(() => null);
+    return { res, body };
+  }
+
+  // ---------- 骨架 ----------
+
+  function renderShell() {
+    const root = $('cockpitRoot');
+    if (!root) return;
+    root.innerHTML = `
+      <div class="ops-bar ckpt-bar">
+        <div class="ops-bar-group">
+          <span class="ops-bar-label">日期</span>
+          <input type="date" class="ops-input" id="ckptFrom">
+          <span class="ops-tilde">~</span>
+          <input type="date" class="ops-input" id="ckptTo">
+        </div>
+        <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>
+      <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>
+        <section class="ckpt-card"><div class="ckpt-card-head"><h3>门店营业额 Top 10</h3><span></span></div><div class="ckpt-chart" id="ckptRank"></div></section>
+        <section class="ckpt-card"><div class="ckpt-card-head"><h3>转化漏斗</h3><span>曝光 → 进店 → 下单</span></div><div class="ckpt-chart" id="ckptFunnel"></div></section>
+        <section class="ckpt-card"><div class="ckpt-card-head"><h3>收入结构</h3><span>收入 / 支出 / 补贴</span></div><div class="ckpt-chart" id="ckptStructure"></div></section>
+      </div>
+      <div class="ckpt-empty" id="ckptEmpty" hidden>
+        <p>所选范围没有数据</p>
+        <small>调整日期,或换一个学校 / 店铺</small>
+      </div>`;
+
+    $('ckptFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadData(); });
+    $('ckptTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadData(); });
+
+    state.charts.trend = echarts.init($('ckptTrend'));
+    state.charts.rank = echarts.init($('ckptRank'));
+    state.charts.funnel = echarts.init($('ckptFunnel'));
+    state.charts.structure = echarts.init($('ckptStructure'));
+
+    window.bindMultiSelectDismiss();
+    window.addEventListener('resize', () => {
+      Object.values(state.charts).forEach((chart) => chart && chart.resize());
+    });
+  }
+
+  function mountFilters(summary) {
+    const schools = (summary.schools || []).map((s) => ({ key: s.name, label: s.name, hint: `${s.shopCount} 家` }));
+    window.createMultiSelect($('ckptSchool'), {
+      label: '学校',
+      items: schools,
+      selected: state.schools ? [...state.schools] : [],
+      allowEmpty: true,
+      emptyLabel: '全部',
+      onChange: (keys) => { state.schools = keys.length ? new Set(keys) : null; loadData(); },
+    });
+
+    const shops = (summary.shops || []).map((s) => ({
+      key: String(s.key || s.shopId || ''),
+      label: s.shopName || s.key,
+      hint: s.school || '',
+    })).filter((i) => i.key);
+    window.createMultiSelect($('ckptShop'), {
+      label: '店铺',
+      items: shops,
+      selected: state.shops ? [...state.shops] : [],
+      allowEmpty: true,
+      emptyLabel: '全部',
+      onChange: (keys) => { state.shops = keys.length ? new Set(keys) : null; loadData(); },
+    });
+  }
+
+  // ---------- 渲染 ----------
+
+  function renderKpis(kpis) {
+    const box = $('ckptKpis');
+    if (!box) return;
+    if (!kpis) {
+      box.innerHTML = '';
+      return;
+    }
+    box.innerHTML = KPI_DEFS.map((def) => {
+      const item = kpis[def.key] || { value: null, delta: null, unit: def.unit };
+      return `<div class="ckpt-kpi">
+        <div class="ckpt-kpi-label">${def.label}</div>
+        <div class="ckpt-kpi-value">${fmtValue(item.value, def.unit)}</div>
+        <div class="ckpt-kpi-foot">${fmtDelta(item.delta, def.unit)}<span class="ckpt-kpi-hint">较上期</span></div>
+      </div>`;
+    }).join('');
+  }
+
+  function renderTrend(data) {
+    const chart = state.charts.trend;
+    const dates = data.trend.map((t) => shortDate(t.date));
+    chart.setOption({
+      grid: { left: 56, right: 52, top: 24, bottom: 32 },
+      tooltip: { trigger: 'axis' },
+      legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
+      xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#e5e0da' } }, axisTick: { show: false }, axisLabel: { color: '#8f8880' } },
+      yAxis: [
+        { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } },
+        { type: 'value', splitLine: { show: false }, axisLabel: { color: '#8f8880' } },
+      ],
+      series: [
+        { name: '营业额', type: 'bar', barMaxWidth: 28, itemStyle: { color: COLORS.main, borderRadius: [4, 4, 0, 0] }, data: data.trend.map((t) => t.revenue) },
+        { name: '订单', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 6, lineStyle: { width: 2, color: '#7a9cc6' }, itemStyle: { color: '#7a9cc6' }, data: data.trend.map((t) => t.orders) },
+      ],
+    }, true);
+  }
+
+  function renderRanking(data) {
+    const rows = data.ranking.slice().reverse();
+    state.charts.rank.setOption({
+      grid: { left: 8, right: 48, top: 10, bottom: 10, containLabel: true },
+      tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+      xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } },
+      yAxis: {
+        type: 'category',
+        data: rows.map((r) => r.shopName),
+        axisLine: { show: false },
+        axisTick: { show: false },
+        axisLabel: { color: '#5f5852', width: 150, overflow: 'truncate', fontSize: 11 },
+      },
+      series: [{
+        type: 'bar',
+        barMaxWidth: 16,
+        itemStyle: { color: COLORS.soft, borderRadius: [0, 4, 4, 0] },
+        label: {
+          show: true,
+          position: 'right',
+          fontSize: 11,
+          color: '#6f6862',
+          formatter: (p) => Number(p.value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
+        },
+        data: rows.map((r) => r.revenue),
+      }],
+    }, true);
+  }
+
+  function renderFunnel(data) {
+    const f = data.funnel;
+    const stages = [
+      { name: '曝光', value: f.exposure },
+      { name: '进店', value: f.visit },
+      { name: '下单', value: f.order },
+    ];
+    const rate = (a, b) => (b > 0 ? ((a / b) * 100).toFixed(1) + '%' : '—');
+    state.charts.funnel.setOption({
+      tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${Number(p.value).toLocaleString('zh-CN')}` },
+      series: [{
+        type: 'funnel',
+        left: '8%',
+        right: '8%',
+        top: 16,
+        bottom: 16,
+        minSize: '28%',
+        sort: 'descending',
+        gap: 4,
+        label: {
+          show: true,
+          position: 'inside',
+          color: '#fff',
+          fontSize: 12,
+          formatter: (p) => `${p.name} ${Number(p.value).toLocaleString('zh-CN')}`,
+        },
+        itemStyle: { borderWidth: 0 },
+        color: [COLORS.main, '#ff9a70', '#ffc4a8'],
+        data: stages,
+      }],
+      graphic: [
+        { type: 'text', right: 10, top: 8, style: { text: `进店 ${rate(f.visit, f.exposure)}`, fill: '#8f8880', fontSize: 11 } },
+        { type: 'text', right: 10, bottom: 8, style: { text: `下单 ${rate(f.order, f.visit)}`, fill: '#8f8880', fontSize: 11 } },
+      ],
+    }, true);
+  }
+
+  function renderStructure(data) {
+    const dates = data.structure.map((s) => shortDate(s.date));
+    state.charts.structure.setOption({
+      grid: { left: 56, right: 20, top: 28, bottom: 32 },
+      tooltip: { trigger: 'axis' },
+      legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
+      xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#e5e0da' } }, axisTick: { show: false }, axisLabel: { color: '#8f8880' } },
+      yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } },
+      series: [
+        { name: '收入', type: 'bar', barMaxWidth: 22, itemStyle: { color: COLORS.main, borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.income) },
+        { name: '支出', type: 'bar', barMaxWidth: 22, itemStyle: { color: '#c9c2ba', borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.expense) },
+        { name: '补贴', type: 'bar', barMaxWidth: 22, itemStyle: { color: '#ffd6c2', borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.subsidy) },
+      ],
+    }, true);
+  }
+
+  // ---------- 数据 ----------
+
+  async function loadData() {
+    if (state.loading) return;
+    state.loading = true;
+    const params = new URLSearchParams();
+    if (state.from) params.set('from', state.from);
+    if (state.to) params.set('to', state.to);
+    if (state.schools && state.schools.size) params.set('schools', [...state.schools].join(','));
+    if (state.shops && state.shops.size) params.set('shops', [...state.shops].join(','));
+    try {
+      const { res, body } = await api('/api/ops/dashboard?' + params.toString());
+      if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
+      state.data = body;
+      const empty = !body.kpis || !body.trend.length;
+      $('ckptEmpty').hidden = !empty;
+      $('ckptKpis').hidden = empty;
+      document.querySelector('.ckpt-grid').hidden = empty;
+      if (!empty) {
+        state.from = body.from || state.from;
+        state.to = body.to || state.to;
+        $('ckptFrom').value = ymdToInput(state.from);
+        $('ckptTo').value = ymdToInput(state.to);
+        renderKpis(body.kpis);
+        renderTrend(body);
+        renderRanking(body);
+        renderFunnel(body);
+        renderStructure(body);
+        $('ckptTrendNote').textContent = body.prev ? `对比 ${shortDate(body.prev.from)} ~ ${shortDate(body.prev.to)}` : '暂无上期可比';
+        Object.values(state.charts).forEach((chart) => chart && chart.resize());
+      }
+    } catch (e) {
+      $('ckptEmpty').hidden = false;
+      $('ckptEmpty').querySelector('p').textContent = '驾驶舱加载失败';
+      $('ckptEmpty').querySelector('small').textContent = e.message || String(e);
+    } finally {
+      state.loading = false;
+    }
+  }
+
+  async function init() {
+    if (state.inited) return;
+    state.inited = true;
+    renderShell();
+    const { body } = await api('/api/ops/summary');
+    const summary = body || { enabled: false, schools: [], shops: [] };
+    if (summary.enabled) {
+      if (summary.coverage) {
+        state.from = summary.coverage.from || '';
+        state.to = summary.coverage.to || '';
+        $('ckptFrom').value = ymdToInput(state.from);
+        $('ckptTo').value = ymdToInput(state.to);
+      }
+      mountFilters(summary);
+      await loadData();
+    } else {
+      $('ckptEmpty').hidden = false;
+      $('ckptEmpty').querySelector('p').textContent = '数据暂不可用';
+      $('ckptEmpty').querySelector('small').textContent = summary.reason || '存储未连接';
+    }
+  }
+
+  window.shanCockpitInit = function () {
+    init();
+    const refresh = $('btnCockpitRefresh');
+    if (refresh && !refresh.dataset.bound) {
+      refresh.dataset.bound = '1';
+      refresh.addEventListener('click', () => loadData());
+    }
+  };
+})();

+ 19 - 0
public/index.html

@@ -41,6 +41,7 @@
     <div class="nav-section-label">经营分析</div>
     <button class="nav-item" data-view="reports"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 21h16"/></svg></span><span>数据采集</span></button>
     <button class="nav-item" data-view="schools"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="10" r="3"/><path d="M12 21s7-6.2 7-11a7 7 0 1 0-14 0c0 4.8 7 11 7 11Z"/></svg></span><span>校区地图</span></button>
+    <button class="nav-item" data-view="cockpit"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 13a9 9 0 0 1 18 0"/><path d="M12 13l4-4"/><circle cx="12" cy="13" r="1.6"/><path d="M3 17h18"/></svg></span><span>经营驾驶舱</span></button>
     <button class="nav-item" data-view="ops"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h16"/><path d="M7 20V9M12 20V4M17 20v-7"/></svg></span><span>店铺运营</span></button>
     <button class="nav-item" data-view="lake"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="7" ry="2.5"/><path d="M5 5v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V5"/><path d="M5 11v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-6"/></svg></span><span>数据中台</span></button>
     <button class="nav-item" data-view="analysis"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17l5-6 4 3 5-8 4 5"/><path d="M3 21h18"/></svg></span><span>趋势图表</span><span class="nav-beta">Beta</span></button>
@@ -350,6 +351,22 @@
       </main>
     </div>
 
+    <!-- ==================== 经营驾驶舱 ==================== -->
+    <div id="viewCockpit" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>经营驾驶舱</h1>
+            <div class="sub">一屏看清经营大盘:规模、效率、转化与结构</div>
+          </div>
+          <div class="toolbar">
+            <button class="btn btn-ghost btn-small" id="btnCockpitRefresh">刷新</button>
+          </div>
+        </div>
+        <div id="cockpitRoot"></div>
+      </main>
+    </div>
+
     <!-- ==================== 店铺运营 ==================== -->
     <div id="viewOps" class="view">
       <main class="content">
@@ -1152,7 +1169,9 @@
 <script src="/analysis-templates.js"></script>
 <script src="/reports.js"></script>
 <script src="/lake.js"></script>
+<script src="/multi-select.js"></script>
 <script src="/ops.js"></script>
+<script src="/cockpit.js"></script>
 <script src="/schools.js"></script>
 <script src="/ask.js"></script>
 <script src="/app.js"></script>

+ 110 - 0
public/multi-select.js

@@ -0,0 +1,110 @@
+'use strict';
+
+// 通用多选下拉(字段 / 店铺 / 学校共用):按钮 + 搜索 + 复选列表 + 全选/清空
+// 用法:window.createMultiSelect(rootEl, { label, items:[{key,label,hint}], selected, allowEmpty, emptyLabel, onChange })
+(function () {
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value == null ? '' : value);
+    return div.innerHTML;
+  }
+
+  function createMultiSelect(root, { label, items, selected, allowEmpty, emptyLabel, onChange }) {
+    const sel = new Set(selected || []);
+    let keyword = '';
+    root.innerHTML = `<button type="button" class="ops-multi-btn" aria-expanded="false"></button>
+      <div class="ops-multi-menu" hidden>
+        <input type="text" class="ops-multi-search" placeholder="搜索${esc(label)}">
+        <div class="ops-multi-list"></div>
+        <div class="ops-multi-foot"><button type="button" data-all>全选</button><button type="button" data-none>清空</button></div>
+      </div>`;
+    const btn = root.querySelector('.ops-multi-btn');
+    const menu = root.querySelector('.ops-multi-menu');
+    const list = root.querySelector('.ops-multi-list');
+    const search = root.querySelector('.ops-multi-search');
+
+    function refreshButton() {
+      let text;
+      if (!sel.size) text = `${label} <em>${emptyLabel || '全部'}</em>`;
+      else if (sel.size === 1) {
+        const one = items.find((i) => sel.has(i.key));
+        text = `${label} <em>${esc(one ? one.label : '1')}</em>`;
+      } else text = `${label} <em>${sel.size} 项</em>`;
+      btn.innerHTML = text + '<span class="ops-multi-caret">▾</span>';
+    }
+
+    function renderList() {
+      const kw = keyword.trim().toLowerCase();
+      const filtered = items.filter((i) => !kw
+        || String(i.label).toLowerCase().includes(kw)
+        || String(i.hint || '').toLowerCase().includes(kw));
+      list.innerHTML = filtered.length
+        ? filtered.map((i) => `<label class="ops-multi-item">
+            <input type="checkbox" value="${esc(i.key)}"${sel.has(i.key) ? ' checked' : ''}>
+            <span>${esc(i.label)}</span>${i.hint ? `<em>${esc(i.hint)}</em>` : ''}
+          </label>`).join('')
+        : '<div class="ops-multi-empty">没有匹配项</div>';
+      list.querySelectorAll('input[type="checkbox"]').forEach((input) => {
+        input.addEventListener('change', () => {
+          if (input.checked) sel.add(input.value);
+          else sel.delete(input.value);
+          if (!allowEmpty && !sel.size) {
+            input.checked = true;
+            sel.add(input.value);
+            return;
+          }
+          refreshButton();
+          onChange([...sel]);
+        });
+      });
+    }
+
+    btn.addEventListener('click', (e) => {
+      e.stopPropagation();
+      const willOpen = menu.hidden;
+      document.querySelectorAll('.ops-multi-menu').forEach((m) => { m.hidden = true; });
+      menu.hidden = !willOpen;
+      btn.setAttribute('aria-expanded', String(willOpen));
+      if (willOpen) { renderList(); search.focus(); }
+    });
+    search.addEventListener('input', () => { keyword = search.value; renderList(); });
+    root.querySelector('[data-all]').addEventListener('click', () => {
+      items.forEach((i) => sel.add(i.key));
+      refreshButton();
+      renderList();
+      onChange([...sel]);
+    });
+    root.querySelector('[data-none]').addEventListener('click', () => {
+      sel.clear();
+      if (!allowEmpty && items[0]) sel.add(items[0].key);
+      refreshButton();
+      renderList();
+      onChange([...sel]);
+    });
+    refreshButton();
+    return { refresh: () => { refreshButton(); renderList(); } };
+  }
+
+  let dismissBound = false;
+  function bindMultiSelectDismiss() {
+    if (dismissBound) return;
+    dismissBound = true;
+    document.addEventListener('click', (event) => {
+      document.querySelectorAll('.ops-multi').forEach((root) => {
+        const menu = root.querySelector('.ops-multi-menu');
+        if (!menu || menu.hidden) return;
+        if (root.contains(event.target)) return;
+        menu.hidden = true;
+        const btn = root.querySelector('.ops-multi-btn');
+        if (btn) btn.setAttribute('aria-expanded', 'false');
+      });
+    });
+    document.addEventListener('keydown', (event) => {
+      if (event.key !== 'Escape') return;
+      document.querySelectorAll('.ops-multi-menu').forEach((menu) => { menu.hidden = true; });
+    });
+  }
+
+  window.createMultiSelect = createMultiSelect;
+  window.bindMultiSelectDismiss = bindMultiSelectDismiss;
+})();

+ 3 - 102
public/ops.js

@@ -124,105 +124,6 @@
 
   // ---------- 工具条 ----------
 
-  /** 通用多选下拉:按钮 + 搜索 + 复选列表 + 全选/清空 */
-  function mountMulti(root, { label, items, selected, allowEmpty, emptyLabel, onChange }) {
-    const sel = new Set(selected || []);
-    let keyword = '';
-    root.innerHTML = `<button type="button" class="ops-multi-btn" aria-expanded="false"></button>
-      <div class="ops-multi-menu" hidden>
-        <input type="text" class="ops-multi-search" placeholder="搜索${esc(label)}">
-        <div class="ops-multi-list"></div>
-        <div class="ops-multi-foot"><button type="button" data-all>全选</button><button type="button" data-none>清空</button></div>
-      </div>`;
-    const btn = root.querySelector('.ops-multi-btn');
-    const menu = root.querySelector('.ops-multi-menu');
-    const list = root.querySelector('.ops-multi-list');
-    const search = root.querySelector('.ops-multi-search');
-
-    function refreshButton() {
-      let text;
-      if (!sel.size) text = `${label} <em>${emptyLabel || '全部'}</em>`;
-      else if (sel.size === 1) {
-        const one = items.find((i) => sel.has(i.key));
-        text = `${label} <em>${esc(one ? one.label : '1')}</em>`;
-      } else text = `${label} <em>${sel.size} 项</em>`;
-      btn.innerHTML = text + '<span class="ops-multi-caret">▾</span>';
-    }
-
-    function renderList() {
-      const kw = keyword.trim().toLowerCase();
-      const filtered = items.filter((i) => !kw
-        || String(i.label).toLowerCase().includes(kw)
-        || String(i.hint || '').toLowerCase().includes(kw));
-      list.innerHTML = filtered.length
-        ? filtered.map((i) => `<label class="ops-multi-item">
-            <input type="checkbox" value="${esc(i.key)}"${sel.has(i.key) ? ' checked' : ''}>
-            <span>${esc(i.label)}</span>${i.hint ? `<em>${esc(i.hint)}</em>` : ''}
-          </label>`).join('')
-        : '<div class="ops-multi-empty">没有匹配项</div>';
-      list.querySelectorAll('input[type="checkbox"]').forEach((input) => {
-        input.addEventListener('change', () => {
-          if (input.checked) sel.add(input.value);
-          else sel.delete(input.value);
-          if (!allowEmpty && !sel.size) {
-            input.checked = true;
-            sel.add(input.value);
-            return;
-          }
-          refreshButton();
-          onChange([...sel]);
-        });
-      });
-    }
-
-    btn.addEventListener('click', (e) => {
-      e.stopPropagation();
-      const willOpen = menu.hidden;
-      document.querySelectorAll('.ops-multi-menu').forEach((m) => { m.hidden = true; });
-      menu.hidden = !willOpen;
-      btn.setAttribute('aria-expanded', String(willOpen));
-      if (willOpen) { renderList(); search.focus(); }
-    });
-    search.addEventListener('input', () => { keyword = search.value; renderList(); });
-    root.querySelector('[data-all]').addEventListener('click', () => {
-      items.forEach((i) => sel.add(i.key));
-      refreshButton();
-      renderList();
-      onChange([...sel]);
-    });
-    root.querySelector('[data-none]').addEventListener('click', () => {
-      sel.clear();
-      if (!allowEmpty && items[0]) sel.add(items[0].key);
-      refreshButton();
-      renderList();
-      onChange([...sel]);
-    });
-    refreshButton();
-    return { refresh: () => { refreshButton(); renderList(); } };
-  }
-
-  let menuDismissBound = false;
-
-  /** 点击空白处 / Esc 关闭下拉(按钮本身已 stopPropagation,不会误关) */
-  function bindMenuDismiss() {
-    if (menuDismissBound) return;
-    menuDismissBound = true;
-    document.addEventListener('click', (event) => {
-      document.querySelectorAll('.ops-multi').forEach((root) => {
-        const menu = root.querySelector('.ops-multi-menu');
-        if (!menu || menu.hidden) return;
-        if (root.contains(event.target)) return;
-        menu.hidden = true;
-        const btn = root.querySelector('.ops-multi-btn');
-        if (btn) btn.setAttribute('aria-expanded', 'false');
-      });
-    });
-    document.addEventListener('keydown', (event) => {
-      if (event.key !== 'Escape') return;
-      document.querySelectorAll('.ops-multi-menu').forEach((menu) => { menu.hidden = true; });
-    });
-  }
-
   function renderBar() {
     const bar = $('opsBar');
     if (!bar) return;
@@ -272,7 +173,7 @@
     $('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); });
     $('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
 
-    mountMulti($('opsFieldPicker'), {
+    window.createMultiSelect($('opsFieldPicker'), {
       label: '字段',
       items: state.catalog.map((f) => ({ key: f.key, label: f.label, hint: f.type === 'text' ? '文本' : '' })),
       selected: state.fields,
@@ -289,7 +190,7 @@
       label: shop.shopName || shop.key,
       hint: shop.school || '',
     })).filter((i) => i.key);
-    mountMulti($('opsShopPicker'), {
+    window.createMultiSelect($('opsShopPicker'), {
       label: '店铺',
       items: shopItems,
       selected: state.shops ? [...state.shops] : [],
@@ -843,7 +744,7 @@
   }
 
   function bindOnce() {
-    bindMenuDismiss();
+    window.bindMultiSelectDismiss();
     const uploadBtn = $('btnOpsUpload');
     if (uploadBtn) uploadBtn.addEventListener('click', openUpload);
     const recordsBtn = $('btnOpsRecords');

+ 28 - 0
public/style.css

@@ -1338,3 +1338,31 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ops-table-wide td.ops-text-cell { padding-left: 18px; padding-right: 18px; }
 .ops-table-wide tbody td { border-bottom-color: #f0ece7; }
 .ops-shop-name { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
+
+/* ── 经营驾驶舱 ───────────────────────────────────────── */
+.ckpt-bar { margin-bottom: 14px; }
+.ckpt-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; }
+.ckpt-kpi { padding: 14px 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); }
+.ckpt-kpi-label { font-size: 12px; color: var(--text-3); }
+.ckpt-kpi-value { margin: 6px 0 4px; font-size: clamp(19px, 1.6vw, 25px); font-weight: 650; color: var(--text); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
+.ckpt-kpi-foot { display: flex; align-items: center; gap: 6px; font-size: 11.5px; }
+.ckpt-delta { font-weight: 600; }
+.ckpt-delta.up { color: #2e7d4f; }
+.ckpt-delta.down { color: #c0392b; }
+.ckpt-delta.muted { color: var(--text-3); }
+.ckpt-kpi-hint { color: var(--text-3); }
+.ckpt-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
+.ckpt-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px 16px 8px; min-width: 0; }
+.ckpt-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
+.ckpt-card-head h3 { margin: 0; font-size: 13.5px; font-weight: 650; color: var(--text); }
+.ckpt-card-head span { font-size: 11.5px; color: var(--text-3); }
+.ckpt-chart { width: 100%; height: 260px; }
+.ckpt-empty { padding: 48px 0; text-align: center; color: var(--text-3); }
+.ckpt-empty p { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: var(--text-2); }
+.ckpt-empty small { font-size: 12px; }
+@media (max-width: 1360px) {
+  .ckpt-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+@media (max-width: 1080px) {
+  .ckpt-grid { grid-template-columns: 1fr; }
+}

+ 5 - 0
scripts/smoke.js

@@ -365,6 +365,11 @@ async function main() {
       expect(r.status === 200 && Array.isArray(d.fields) && d.fields.length >= 4, `status=${r.status}`);
       expect(Array.isArray(d.defaults) && d.defaults.length === 4, 'defaults 不对');
     });
+    await check('GET /api/ops/dashboard(空库 → 空 KPI)', async () => {
+      const r = await request(port, 'GET', '/api/ops/dashboard');
+      const d = r.json();
+      expect(r.status === 200 && d.kpis === null && Array.isArray(d.trend), `status=${r.status}`);
+    });
     await check('GET /api/ops/rows(空库 → 空矩阵)', async () => {
       const r = await request(port, 'GET', '/api/ops/rows');
       const d = r.json();

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

@@ -420,6 +420,27 @@ async function main() {
       expect(shops.length === 1, `shops ${shops.length}`);
     });
 
+    await check('E2E 驾驶舱:KPI 与图表数据', async () => {
+      const r = await fetch(base + '/api/ops/dashboard').then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      const d = r.body;
+      expect(d.kpis && d.kpis.revenue.value > 0, `revenue ${d.kpis && d.kpis.revenue.value}`);
+      expect(d.trend.length >= 1, `trend ${d.trend.length}`);
+      expect(d.structure.length === d.trend.length, '结构图与趋势日期数不一致');
+      expect(d.ranking.length <= 10 && d.ranking.length > 0, `ranking ${d.ranking.length}`);
+      expect(d.funnel.exposure >= 0 && d.funnel.visit >= 0, '漏斗数据异常');
+    });
+
+    await check('E2E 驾驶舱:店铺筛选后金额变化', async () => {
+      const all = await fetch(base + '/api/ops/dashboard').then(readJson);
+      const shopId = all.body.ranking[0].shopId;
+      const one = await fetch(base + '/api/ops/dashboard?shops=' + encodeURIComponent(shopId)).then(readJson);
+      expect(one.status === 200, `status ${one.status}`);
+      expect(one.body.kpis.revenue.value > 0, '单店营收为 0');
+      expect(one.body.kpis.revenue.value < all.body.kpis.revenue.value, '单店营收不应大于全部');
+      expect(one.body.ranking.length === 1, `ranking ${one.body.ranking.length}`);
+    });
+
     await check('E2E 导入记录只含元数据', async () => {
       const r = await fetch(base + '/api/ops/uploads').then(readJson);
       expect(r.body.uploads.length >= 1, '记录为空');

+ 187 - 0
server/routes/ops.js

@@ -237,6 +237,179 @@ async function loadMergedRows(store, { from, to, schools, shops } = {}) {
   return rows;
 }
 
+function addDays(ymd, n) {
+  const d = new Date(Date.UTC(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8))));
+  d.setUTCDate(d.getUTCDate() + n);
+  return `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}`;
+}
+
+function dayCount(from, to) {
+  const a = Date.UTC(Number(from.slice(0, 4)), Number(from.slice(4, 6)) - 1, Number(from.slice(6, 8)));
+  const b = Date.UTC(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)));
+  return Math.max(1, Math.round((b - a) / 86400000) + 1);
+}
+
+function pctDelta(cur, prev) {
+  if (prev == null || prev === 0 || cur == null) return null;
+  return Math.round(((cur - prev) / prev) * 1000) / 10;
+}
+
+function sumSeries(rows, key) {
+  let sum = 0;
+  for (const row of rows) if (typeof row[key] === 'number') sum += row[key];
+  return sum;
+}
+
+function platformNum(row, column) {
+  if (!row.platform) return null;
+  return numOrNull(row.platform[column]);
+}
+
+/** 一次聚合:KPI 口径集中在这里,前端不再二次计算 */
+function aggregateRows(rows) {
+  let revenue = 0;
+  let orders = 0;
+  let exposure = 0;
+  let visit = 0;
+  let buyers = 0;
+  let expense = 0;
+  let subsidy = 0;
+  let ratingSum = 0;
+  let ratingCount = 0;
+  let rateWeight = 0;
+  let rateAcc = 0;
+
+  for (const row of rows) {
+    if (typeof row.revenue === 'number') revenue += row.revenue;
+    if (typeof row.order_cnt === 'number') orders += row.order_cnt;
+    if (typeof row.exposure === 'number') exposure += row.exposure;
+    if (typeof row.click_rate === 'number' && row.exposure > 0) {
+      rateWeight += row.exposure;
+      rateAcc += row.click_rate * row.exposure;
+    }
+    const v = platformNum(row, '进店人数');
+    if (v != null) visit += v;
+    const b = platformNum(row, '下单人数');
+    if (b != null) buyers += b;
+    const e = platformNum(row, '支出');
+    if (e != null) expense += e;
+    const sub = platformNum(row, '活动总补贴');
+    if (sub != null) subsidy += sub;
+    const rating = platformNum(row, '店铺评分');
+    if (rating != null && rating > 0) {
+      ratingSum += rating;
+      ratingCount += 1;
+    }
+  }
+
+  const conversion = visit > 0 && exposure > 0
+    ? visit / exposure
+    : (rateWeight > 0 ? rateAcc / rateWeight : null);
+
+  return {
+    revenue: Math.round(revenue * 100) / 100,
+    orders,
+    exposure,
+    visit,
+    buyers,
+    expense: Math.round(expense * 100) / 100,
+    subsidy: Math.round(subsidy * 100) / 100,
+    rating: ratingCount ? Math.round((ratingSum / ratingCount) * 100) / 100 : null,
+    avgTicket: orders > 0 ? Math.round((revenue / orders) * 100) / 100 : null,
+    conversion: conversion == null ? null : Math.round(conversion * 10000) / 10000,
+  };
+}
+
+async function buildDashboard(store, { from, to, schools, shops }) {
+  const filters = { schools, shops };
+  let rows = await loadMergedRows(store, { from, to, ...filters });
+  const dates = [...new Set(rows.map((r) => r.date))].sort();
+  const start = from || dates[0];
+  const end = to || dates[dates.length - 1];
+
+  if (!start || !end) {
+    return { from: null, to: null, prev: null, kpis: null, trend: [], ranking: [], funnel: null, structure: [] };
+  }
+
+  // 未指定区间时按实际数据范围收敛
+  if (!from || !to) rows = await loadMergedRows(store, { from: start, to: end, ...filters });
+
+  const span = dayCount(start, end);
+  const prevTo = addDays(start, -1);
+  const prevFrom = addDays(prevTo, -(span - 1));
+  let prevRows = [];
+  try {
+    prevRows = await loadMergedRows(store, { from: prevFrom, to: prevTo, ...filters });
+  } catch { /* 上期缺失不影响本期 */ }
+
+  const cur = aggregateRows(rows);
+  const prev = prevRows.length ? aggregateRows(prevRows) : null;
+
+  const kpi = (value, prevValue, unit, isRate) => ({
+    value,
+    prev: prevValue,
+    delta: prevValue == null || value == null
+      ? null
+      : (isRate ? Math.round((value - prevValue) * 1000) / 10 : pctDelta(value, prevValue)),
+    unit,
+  });
+
+  const kpis = {
+    revenue: kpi(cur.revenue, prev && prev.revenue, 'money'),
+    orders: kpi(cur.orders, prev && prev.orders, 'int'),
+    avgTicket: kpi(cur.avgTicket, prev && prev.avgTicket, 'money'),
+    exposure: kpi(cur.exposure, prev && prev.exposure, 'int'),
+    conversion: kpi(cur.conversion, prev && prev.conversion, 'pp', true),
+    rating: { value: cur.rating, prev: null, delta: null, unit: 'num' },
+  };
+
+  const byDate = new Map();
+  const byShop = new Map();
+  for (const row of rows) {
+    if (!byDate.has(row.date)) byDate.set(row.date, []);
+    byDate.get(row.date).push(row);
+    const key = String(row.shopId || row.shopKey || '');
+    if (!key) continue;
+    if (!byShop.has(key)) byShop.set(key, { shopId: key, shopName: row.shopName || key, school: row.school || '', rows: [] });
+    byShop.get(key).rows.push(row);
+  }
+
+  const trend = [...byDate.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([date, list]) => {
+    const agg = aggregateRows(list);
+    return { date, revenue: agg.revenue, orders: agg.orders };
+  });
+
+  const ranking = [...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);
+
+  const funnel = {
+    exposure: cur.exposure,
+    visit: cur.visit,
+    order: cur.buyers || cur.orders,
+  };
+
+  const structure = [...byDate.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([date, list]) => {
+    const agg = aggregateRows(list);
+    return { date, income: agg.revenue, expense: agg.expense, subsidy: agg.subsidy };
+  });
+
+  return {
+    from: start,
+    to: end,
+    prev: prevRows.length ? { from: prevFrom, to: prevTo } : null,
+    kpis,
+    trend,
+    ranking,
+    funnel,
+    structure,
+  };
+}
+
 function csvCell(value) {
   const text = value == null ? '' : String(value);
   return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
@@ -482,6 +655,20 @@ function register(app, ctx) {
     }
   });
 
+  app.get('/api/ops/dashboard', async ({ res, query }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const from = parseYmd(query.from);
+    const to = parseYmd(query.to);
+    const schools = splitList(query.schools);
+    const shops = splitList(query.shops);
+    try {
+      const data = await buildDashboard(store, { from: from || undefined, to: to || undefined, schools, shops });
+      sendJson(res, 200, data);
+    } catch (e) {
+      sendJson(res, 503, { error: '聚合失败', detail: e.message || String(e) });
+    }
+  });
+
   app.get('/api/ops/rows', async ({ res, query }) => {
     if (!(await requireHealthy(store, res))) return;
     const from = parseYmd(query.from);

+ 2 - 0
server/routes/static.js

@@ -18,6 +18,8 @@ const STATIC_MAP = {
   '/lake.js': ['lake.js', 'application/javascript; charset=utf-8'],
   '/ops.js': ['ops.js', 'application/javascript; charset=utf-8'],
   '/schools.js': ['schools.js', 'application/javascript; charset=utf-8'],
+  '/multi-select.js': ['multi-select.js', 'application/javascript; charset=utf-8'],
+  '/cockpit.js': ['cockpit.js', 'application/javascript; charset=utf-8'],
   '/vendor/china-map.json': ['vendor/china-map.json', 'application/json; charset=utf-8'],
   '/vendor/cn-universities.json': ['vendor/cn-universities.json', 'application/json; charset=utf-8'],
   '/vendor/cn-cities.json': ['vendor/cn-cities.json', 'application/json; charset=utf-8'],