Forráskód Böngészése

feat: 店铺运营支持字段与店铺筛选

- 新增 GET /api/ops/fields:字段目录 = 4 个基础指标 + 平台导出全部列(按列名/样本推断数值/金额/比率/文本类型)
- /api/ops/rows 与 /api/ops/export 改为按 fields 参数返回任意字段;平台行与人工行合并(人工 4 指标优先,其余字段取平台)
- 前端工具条新增「字段」「店铺」多选下拉(搜索/全选/清空),默认仍是 4 个基础字段,选择存 localStorage
- summary 的店铺列表合并平台行,店铺筛选可选到平台明细里的门店
- 测试:字段目录、字段过滤、店铺筛选;smoke 增加 fields 只读断言(111/111)
liangzhao 2 hete
szülő
commit
cbdd586f24
9 módosított fájl, 399 hozzáadás és 124 törlés
  1. 1 0
      README.md
  2. 2 1
      ops-import.js
  3. 186 81
      public/ops.js
  4. 21 0
      public/style.css
  5. 6 0
      scripts/smoke.js
  6. 22 5
      scripts/test-ops-import.js
  7. 149 35
      server/routes/ops.js
  8. 6 1
      server/stores/ops-pg.js
  9. 6 1
      server/stores/ops-sqlite.js

+ 1 - 0
README.md

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

+ 2 - 1
ops-import.js

@@ -211,7 +211,8 @@ function parsePlatformExport(cells, options = {}) {
     if (!shops.has(shopId)) shops.set(shopId, { key: shopId, shopId, shopName, school, status: '', kind: 'platform' });
   }
 
-  return { rows, shops: [...shops.values()], warnings, dates: [...dates].sort(), kind: 'platform-export' };
+  const columnNames = columns.map((c) => c.name).filter((n) => !['日期', '门店编号', '门店名称'].includes(n));
+  return { rows, shops: [...shops.values()], warnings, dates: [...dates].sort(), kind: 'platform-export', columns: columnNames };
 }
 
 /**

+ 186 - 81
public/ops.js

@@ -5,21 +5,14 @@
 (function () {
   const $ = (id) => document.getElementById(id);
 
-  const META = {
-    order_cnt: { label: '订单量', type: 'int' },
-    revenue: { label: '营收', type: 'money' },
-    exposure: { label: '曝光量', type: 'int' },
-    click_rate: { label: '进店转化率', type: 'rate' },
-  };
-  const ALL_METRICS = Object.keys(META);
-  const DEFAULT_METRICS = ALL_METRICS.slice();
+  const DEFAULT_FIELDS = ['order_cnt', 'revenue', 'exposure', 'click_rate'];
 
   function loadJSON(key, fallback) {
     try {
       const raw = localStorage.getItem(key);
       if (!raw) return fallback;
-      const v = JSON.parse(raw);
-      return Array.isArray(v) && v.length ? v.filter((x) => ALL_METRICS.includes(x)) : fallback;
+      const value = JSON.parse(raw);
+      return Array.isArray(value) && value.length ? value : fallback;
     } catch {
       return fallback;
     }
@@ -30,14 +23,20 @@
     loading: false,
     summary: null,
     data: null,
+    catalog: [],
     schools: null, // null = 全选
-    metrics: loadJSON('ops.metrics', DEFAULT_METRICS),
+    shops: null, // null = 全部店铺
+    fields: loadJSON('ops.fields', DEFAULT_FIELDS.slice()),
     view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
     from: '',
     to: '',
     upload: { files: [], busy: false },
   };
 
+  function fieldByKey(key) {
+    return state.catalog.find((f) => f.key === key) || { key, label: key, type: 'int' };
+  }
+
   function esc(value) {
     const div = document.createElement('div');
     div.textContent = String(value == null ? '' : value);
@@ -58,13 +57,15 @@
     return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s;
   }
 
-  function fmtValue(value, metric) {
-    if (value === null || value === undefined) return '–';
+  function fmtValue(value, type) {
+    if (value === null || value === undefined || value === '') return '–';
+    if (type === 'text') return String(value);
     const n = Number(value);
-    if (!Number.isFinite(n)) return '–';
-    if (META[metric].type === 'rate') return (n * 100).toFixed(1) + '%';
-    if (META[metric].type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
-    return n.toLocaleString('zh-CN');
+    if (!Number.isFinite(n)) return String(value);
+    if (type === 'rate') return (n * 100).toFixed(1) + '%';
+    if (type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+    if (type === 'int') return n.toLocaleString('zh-CN');
+    return String(value);
   }
 
   function selectedSchools() {
@@ -107,7 +108,8 @@
     if (state.to) params.set('to', state.to);
     const schools = state.schools === null ? [] : [...state.schools];
     if (schools.length && schools.length !== (state.summary.schools || []).length) params.set('schools', schools.join(','));
-    params.set('metrics', state.metrics.join(','));
+    if (state.shops !== null && state.shops.size) params.set('shops', [...state.shops].join(','));
+    if (state.fields.length) params.set('fields', state.fields.join(','));
     const { res, body } = await api('/api/ops/rows?' + params.toString());
     state.loading = false;
     if (!res.ok) {
@@ -122,12 +124,88 @@
 
   // ---------- 工具条 ----------
 
+  /** 通用多选下拉:按钮 + 搜索 + 复选列表 + 全选/清空 */
+  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(); } };
+  }
+
   function renderBar() {
     const bar = $('opsBar');
     if (!bar) return;
     const summary = state.summary;
-    const matrixRows = matrixCount(summary);
-    if (!summary || !summary.enabled || !matrixRows) {
+    if (!summary || !summary.enabled || !matrixCount(summary)) {
       bar.innerHTML = '';
       return;
     }
@@ -148,10 +226,12 @@
           <input type="date" class="ops-input" id="opsTo" value="${ymdToInput(state.to)}">
         </div>
         <div class="ops-bar-group">
-          <span class="ops-bar-label">指标</span>
-          <div class="ops-chips" id="opsMetricChips">
-            ${ALL_METRICS.map((k) => `<button type="button" class="ops-chip${state.metrics.includes(k) ? ' on' : ''}" data-metric="${k}">${META[k].label}</button>`).join('')}
-          </div>
+          <span class="ops-bar-label">字段</span>
+          <div class="ops-multi" id="opsFieldPicker"></div>
+        </div>
+        <div class="ops-bar-group">
+          <span class="ops-bar-label">店铺</span>
+          <div class="ops-multi" id="opsShopPicker"></div>
         </div>
       </div>`;
     renderHeaderTools();
@@ -167,22 +247,37 @@
         loadRows();
       });
     });
-    bar.querySelectorAll('[data-metric]').forEach((btn) => {
-      btn.addEventListener('click', () => {
-        const key = btn.dataset.metric;
-        if (state.metrics.includes(key)) {
-          if (state.metrics.length === 1) return; // 至少保留一个指标
-          state.metrics = state.metrics.filter((k) => k !== key);
-        } else {
-          state.metrics = ALL_METRICS.filter((k) => key === k || state.metrics.includes(k));
-        }
-        localStorage.setItem('ops.metrics', JSON.stringify(state.metrics));
-        renderBar();
-        renderBody();
-      });
-    });
     $('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); });
     $('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
+
+    mountMulti($('opsFieldPicker'), {
+      label: '字段',
+      items: state.catalog.map((f) => ({ key: f.key, label: f.label, hint: f.type === 'text' ? '文本' : '' })),
+      selected: state.fields,
+      allowEmpty: false,
+      onChange: (keys) => {
+        state.fields = keys;
+        localStorage.setItem('ops.fields', JSON.stringify(keys));
+        loadRows();
+      },
+    });
+
+    const shopItems = (summary.shops || []).map((shop) => ({
+      key: String(shop.key || shop.shopId || ''),
+      label: shop.shopName || shop.key,
+      hint: shop.school || '',
+    })).filter((i) => i.key);
+    mountMulti($('opsShopPicker'), {
+      label: '店铺',
+      items: shopItems,
+      selected: state.shops ? [...state.shops] : [],
+      allowEmpty: true,
+      emptyLabel: '全部',
+      onChange: (keys) => {
+        state.shops = keys.length ? new Set(keys) : null;
+        loadRows();
+      },
+    });
   }
 
   function matrixCount(summary) {
@@ -216,7 +311,7 @@
       if (state.from) params.set('from', state.from);
       if (state.to) params.set('to', state.to);
       if (state.schools !== null && state.schools.size) params.set('schools', [...state.schools].join(','));
-      params.set('metrics', state.metrics.join(','));
+      if (state.fields.length) params.set('fields', state.fields.join(','));
       window.open('/api/ops/export?' + params.toString(), '_blank');
     });
   }
@@ -283,28 +378,35 @@
     if (state.view === 'table') bindRowHover();
   }
 
-  function cellValue(shop, metric, date) {
-    const series = shop.series[metric] || {};
+  function cellValue(shop, field, date) {
+    const series = shop.series[field.key] || {};
     return Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
   }
 
-  function metricCell(shop, metric, date) {
-    const value = cellValue(shop, metric, date);
+  function metricCell(shop, field, date) {
+    const value = cellValue(shop, field, date);
     const quality = (shop.quality || {})[date];
     const warn = quality && quality.length
       ? `<span class="ops-warn" title="${esc('解析标记:' + quality.join('、'))}">⚠</span>`
       : '';
-    return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, metric)}${warn}</td>`;
+    return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, field.type)}${warn}</td>`;
   }
 
-  /** 学校小计:数量/金额求和,转化率按曝光加权 */
-  function subtotalValue(group, metric, date) {
-    if (metric === 'click_rate') {
+  /** 学校小计:数量/金额求和,比率按曝光加权,文本取第一个非空 */
+  function subtotalValue(group, field, date) {
+    if (field.type === 'text') {
+      for (const shop of group.shops) {
+        const v = cellValue(shop, field, date);
+        if (v !== null) return v;
+      }
+      return null;
+    }
+    if (field.type === 'rate') {
       let weight = 0;
       let acc = 0;
       for (const shop of group.shops) {
-        const exposure = cellValue(shop, 'exposure', date);
-        const rate = cellValue(shop, 'click_rate', date);
+        const exposure = cellValue(shop, { key: 'exposure' }, date);
+        const rate = cellValue(shop, field, date);
         if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
           weight += exposure;
           acc += rate * exposure;
@@ -315,31 +417,35 @@
     let sum = 0;
     let seen = false;
     for (const shop of group.shops) {
-      const v = cellValue(shop, metric, date);
+      const v = cellValue(shop, field, date);
       if (typeof v === 'number') { sum += v; seen = true; }
     }
     return seen ? sum : null;
   }
 
+  function activeFields(data) {
+    if (data && Array.isArray(data.fields) && data.fields.length) return data.fields;
+    return state.fields.map((key) => fieldByKey(key));
+  }
+
   function renderTables(data) {
-    const metrics = state.metrics.filter((m) => META[m]);
-    if (!metrics.length) return '<div class="empty-hint"><p>请至少选择一个指标</p></div>';
+    const fields = activeFields(data);
+    if (!fields.length) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
     const dates = data.dates || [];
-    const colCount = metrics.length + 2;
+    const colCount = fields.length + 2;
 
     const head = `<tr>
       <th class="ops-shop-col">店铺</th>
       <th class="ops-date-col">日期</th>
-      ${metrics.map((m) => `<th class="ops-metric-col">${META[m].label}</th>`).join('')}
+      ${fields.map((f) => `<th class="ops-metric-col" title="${esc(f.label)}">${esc(f.label)}</th>`).join('')}
     </tr>`;
 
     const body = data.groups.map((group, groupIdx) => {
       const schoolRow = `<tr class="ops-school-row"><td colspan="${colCount}">${esc(group.school)}<em>${group.shops.length} 家</em></td></tr>`;
 
       const shopRows = group.shops.map((shop, shopIdx) => {
-        const shopDates = dates.filter((d) => metrics.some((m) => cellValue(shop, m, d) !== null));
+        const shopDates = dates.filter((d) => fields.some((f) => cellValue(shop, f, d) !== null));
         const useDates = shopDates.length ? shopDates : dates.slice(0, 1);
-        // 每个店铺一个色块与悬停组,日期行共用同一个 block key
         const blockKey = `b${groupIdx}-${shopIdx}`;
         const alt = shopIdx % 2 === 1 ? ' ops-block-alt' : '';
         return useDates.map((date, i) => `<tr data-block="${blockKey}" class="ops-shop-row${i === 0 ? ' ops-block-start' : ''}${alt}">
@@ -347,16 +453,16 @@
             shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted ops-status">${esc(shop.status)}</span>` : ''
           }</td>` : ''}
           <td class="ops-date-cell">${shortDate(date)}</td>
-          ${metrics.map((m) => metricCell(shop, m, date)).join('')}
+          ${fields.map((f) => metricCell(shop, f, date)).join('')}
         </tr>`).join('');
       }).join('');
 
-      const subtotalDates = dates.filter((d) => group.shops.some((shop) => metrics.some((m) => cellValue(shop, m, d) !== null)));
+      const subtotalDates = dates.filter((d) => group.shops.some((shop) => fields.some((f) => cellValue(shop, f, d) !== null)));
       const subtotalRows = subtotalDates.map((date) => `<tr class="ops-subtotal-row">
         <td class="ops-subtotal-label" colspan="2">${esc(group.school)} · 小计 · ${shortDate(date)}</td>
-        ${metrics.map((m) => {
-          const v = subtotalValue(group, m, date);
-          return `<td class="ops-num ops-subtotal">${v == null ? '–' : fmtValue(v, m)}</td>`;
+        ${fields.map((f) => {
+          const v = subtotalValue(group, f, date);
+          return `<td class="ops-num ops-subtotal">${v == null ? '–' : fmtValue(v, f.type)}</td>`;
         }).join('')}
       </tr>`).join('');
 
@@ -365,7 +471,7 @@
 
     const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
     return `<section class="ops-block">
-      <div class="ops-block-head"><h2>运营明细</h2><span>${shopCount} 家门店 · ${dates.length} 天 · ${metrics.length} 个指标</span></div>
+      <div class="ops-block-head"><h2>运营明细</h2><span>${shopCount} 家门店 · ${dates.length} 天 · ${fields.length} 个字段</span></div>
       <div class="ops-table-wrap">
         <table class="ops-table ops-table-wide">
           <thead>${head}</thead>
@@ -392,9 +498,9 @@
     });
   }
 
-  function sparkline(shop, metric, dates) {
+  function sparkline(shop, field, dates) {
     const values = dates.map((d) => {
-      const v = (shop.series[metric] || {})[d];
+      const v = (shop.series[field.key] || {})[d];
       return typeof v === 'number' ? v : null;
     });
     const nums = values.filter((v) => v != null);
@@ -417,7 +523,9 @@
 
   function renderCards(data) {
     const dates = data.dates || [];
-    const metric = state.metrics[0];
+    const fields = activeFields(data);
+    const first = fields[0];
+    if (!first) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
     return `<div class="ops-cards">${data.groups.map((group) => `
       <section class="ops-card-group">
         <h3>${esc(group.school)}</h3>
@@ -429,28 +537,14 @@
                 ${shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted">${esc(shop.status)}</span>` : ''}
               </div>
               <div class="ops-card-metrics">
-                ${state.metrics.map((m) => `<div class="ops-card-metric"><span>${META[m].label}</span><b>${m === 'click_rate' ? fmtValue(avgRate(shop, dates), m) : fmtValue(shop.totals && shop.totals[m], m)}</b></div>`).join('')}
+                ${fields.map((f) => `<div class="ops-card-metric"><span>${esc(f.label)}</span><b>${fmtValue(shop.totals ? shop.totals[f.key] : null, f.type)}</b></div>`).join('')}
               </div>
-              ${sparkline(shop, metric, dates)}
+              ${sparkline(shop, first, dates)}
             </article>`).join('')}
         </div>
       </section>`).join('')}</div>`;
   }
 
-  function avgRate(shop, dates) {
-    let weight = 0;
-    let acc = 0;
-    for (const date of dates) {
-      const exposure = (shop.series.exposure || {})[date];
-      const rate = (shop.series.click_rate || {})[date];
-      if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
-        weight += exposure;
-        acc += rate * exposure;
-      }
-    }
-    return weight > 0 ? acc / weight : null;
-  }
-
   // ---------- 上传 ----------
 
   async function refreshSchoolOptions() {
@@ -702,10 +796,21 @@
 
   // ---------- 初始化 ----------
 
+  async function loadCatalog() {
+    const { res, body } = await api('/api/ops/fields');
+    if (!res.ok || !body || !Array.isArray(body.fields)) return;
+    state.catalog = body.fields;
+    const valid = new Set(body.fields.map((f) => f.key));
+    state.fields = state.fields.filter((k) => valid.has(k));
+    if (!state.fields.length) state.fields = (body.defaults || []).filter((k) => valid.has(k));
+    if (!state.fields.length) state.fields = body.fields.slice(0, 4).map((f) => f.key);
+  }
+
   async function init(force) {
     if (state.inited && !force) return;
     state.inited = true;
     await loadSummary();
+    await loadCatalog();
     renderBar();
     renderHeaderTools();
     renderBody();

+ 21 - 0
public/style.css

@@ -1299,3 +1299,24 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ops-table-wide tbody tr.ops-hover td { background: #fff3ec; }
 .ops-table-wide tbody tr.ops-hover .ops-shop-cell { background: #ffe9dc; }
 .ops-table-wide tbody tr.ops-hover .ops-date-cell { color: var(--text); }
+
+/* 多选下拉(字段 / 店铺) */
+.ops-multi { position: relative; }
+.ops-multi-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px; font-size: 12.5px; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; max-width: 260px; }
+.ops-multi-btn:hover { background: #fff; }
+.ops-multi-btn em { font-style: normal; color: var(--accent); font-weight: 600; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ops-multi-caret { color: var(--text-3); font-size: 10px; }
+.ops-multi-menu { position: absolute; z-index: 30; top: 34px; left: 0; width: 280px; max-height: 380px; display: flex; flex-direction: column; background: #fff; border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 12px 32px rgba(31,27,24,.14); overflow: hidden; }
+.ops-multi-menu[hidden] { display: none; }
+.ops-multi-search { margin: 8px; padding: 6px 8px; font-size: 12.5px; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 7px; outline: none; }
+.ops-multi-search:focus { border-color: var(--accent); background: #fff; }
+.ops-multi-list { overflow: auto; max-height: 270px; padding: 0 6px 6px; }
+.ops-multi-item { display: flex; align-items: center; gap: 8px; padding: 6px; border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--text); }
+.ops-multi-item:hover { background: #faf7f3; }
+.ops-multi-item input { accent-color: var(--accent); flex: 0 0 auto; }
+.ops-multi-item span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ops-multi-item em { font-style: normal; color: var(--text-3); font-size: 11px; flex: 0 0 auto; }
+.ops-multi-foot { display: flex; gap: 8px; padding: 8px; border-top: 1px solid var(--border-light); }
+.ops-multi-foot button { flex: 1; padding: 5px 0; font-size: 12px; color: var(--text-2); background: var(--bg); border: 1px solid var(--border); border-radius: 7px; cursor: pointer; }
+.ops-multi-foot button:hover { color: var(--text); background: #fff; }
+.ops-multi-empty { padding: 14px; text-align: center; color: var(--text-3); font-size: 12px; }

+ 6 - 0
scripts/smoke.js

@@ -359,6 +359,12 @@ async function main() {
       expect(d.schools.some((s) => s.name === '浙江工商大学桐庐校区'), '缺少内置学校');
       expect(d.schools.every((s) => 'ops' in s), '缺少关联字段');
     });
+    await check('GET /api/ops/fields(默认 4 字段)', async () => {
+      const r = await request(port, 'GET', '/api/ops/fields');
+      const d = r.json();
+      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/rows(空库 → 空矩阵)', async () => {
       const r = await request(port, 'GET', '/api/ops/rows');
       const d = r.json();

+ 22 - 5
scripts/test-ops-import.js

@@ -261,11 +261,11 @@ async function main() {
       expect(r.body.coverage.from === '20260909' && r.body.coverage.to === '20261007', 'coverage');
     });
 
-    await check('E2E 矩阵查询 + 指标过滤', async () => {
-      const r = await fetch(base + '/api/ops/rows?metrics=order_cnt').then(readJson);
+    await check('E2E 矩阵查询 + 字段过滤', async () => {
+      const r = await fetch(base + '/api/ops/rows?fields=order_cnt').then(readJson);
       expect(r.status === 200, `status ${r.status}`);
       expect(r.body.dates.length === 29, `dates ${r.body.dates.length}`);
-      expect(r.body.metrics.length === 1 && r.body.metrics[0] === 'order_cnt', 'metrics 过滤失效');
+      expect(r.body.fields.length === 1 && r.body.fields[0].key === 'order_cnt', 'fields 过滤失效');
       expect(r.body.groups.length === 3, `groups ${r.body.groups.length}`);
       const shop = r.body.groups[0].shops[0];
       expect(typeof shop.totals.order_cnt === 'number', 'totals 缺失');
@@ -304,12 +304,12 @@ async function main() {
     });
 
     await check('E2E 导出 CSV', async () => {
-      const res = await fetch(base + '/api/ops/export?metrics=order_cnt');
+      const res = await fetch(base + '/api/ops/export?fields=order_cnt');
       expect(res.status === 200, `status ${res.status}`);
       const text = await res.text();
       expect(/text\/csv/.test(res.headers.get('content-type') || ''), 'content-type');
       expect(text.includes('样例学校A'), 'CSV 缺少学校列');
-      expect(text.split('\n')[0].includes('日期'), 'CSV 缺少日期列');
+      expect(text.split('\n')[0].includes('日期') && text.split('\n')[0].includes('订单量'), 'CSV 缺少日期/订单量列');
       expect(text.trim().split('\n').length === 1045, `行数 ${text.trim().split('\n').length}`);
     });
 
@@ -403,6 +403,23 @@ async function main() {
       expect(sum.body.platformRowCount === 10, `platformRows ${sum.body.platformRowCount}`);
     });
 
+    await check('E2E 字段目录包含平台列', async () => {
+      const r = await fetch(base + '/api/ops/fields').then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.defaults.join(',') === 'order_cnt,revenue,exposure,click_rate', '默认字段不对');
+      expect(r.body.fields.length > 4, `fields ${r.body.fields.length}`);
+      expect(r.body.fields.some((f) => f.key === 'p:营业额' && f.type === 'money'), '缺少营业额字段');
+    });
+
+    await check('E2E 店铺筛选', async () => {
+      const all = await fetch(base + '/api/ops/rows').then(readJson);
+      const shop = all.body.groups[0].shops[0];
+      const r = await fetch(base + '/api/ops/rows?shops=' + encodeURIComponent(shop.shopId || shop.key)).then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      const shops = r.body.groups.flatMap((g) => g.shops);
+      expect(shops.length === 1, `shops ${shops.length}`);
+    });
+
     await check('E2E 导入记录只含元数据', async () => {
       const r = await fetch(base + '/api/ops/uploads').then(readJson);
       expect(r.body.uploads.length >= 1, '记录为空');

+ 149 - 35
server/routes/ops.js

@@ -13,8 +13,13 @@ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
 const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
 const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
 const PARSER_VERSION = 1;
-const METRIC_KEYS = opsImport.METRIC_KEYS;
-const SUM_METRICS = new Set(['order_cnt', 'revenue', 'exposure']);
+const BASE_FIELDS = [
+  { key: 'order_cnt', label: '订单量', type: 'int', source: 'manual' },
+  { key: 'revenue', label: '营收', type: 'money', source: 'manual' },
+  { key: 'exposure', label: '曝光量', type: 'int', source: 'manual' },
+  { key: 'click_rate', label: '进店转化率', type: 'rate', source: 'manual' },
+];
+const BASE_FIELD_KEYS = BASE_FIELDS.map((f) => f.key);
 
 function pad2(n) {
   return String(n).padStart(2, '0');
@@ -41,10 +46,8 @@ function splitList(value) {
     .filter(Boolean);
 }
 
-function parseMetricsParam(value) {
-  const wanted = splitList(value);
-  if (!wanted.length) return METRIC_KEYS.slice();
-  return METRIC_KEYS.filter((k) => wanted.includes(k));
+function parseFieldKeys(value) {
+  return splitList(value);
 }
 
 function parseYmd(value) {
@@ -64,7 +67,68 @@ async function requireHealthy(store, res) {
   return false;
 }
 
-function buildMatrix(rows, dates) {
+function normalizeFieldValue(value, type) {
+  if (value === null || value === undefined || value === '') return null;
+  if (type === 'text') return String(value);
+  const n = Number(value);
+  if (Number.isFinite(n)) return n;
+  return type === 'int' || type === 'money' || type === 'rate' ? null : String(value);
+}
+
+function fieldValue(row, field) {
+  if (field.source === 'platform') {
+    const data = row.platform || {};
+    const raw = Object.prototype.hasOwnProperty.call(data, field.label) ? data[field.label] : null;
+    return normalizeFieldValue(raw, field.type);
+  }
+  return normalizeFieldValue(row[field.key], field.type);
+}
+
+/** 按字段名与样本值推断展示类型 */
+function inferFieldType(label, samples) {
+  const name = String(label || '');
+  if (/率|占比/.test(name)) return 'rate';
+  if (/额|费|价|收入|支出|补贴|成本|销售额/.test(name)) return 'money';
+  if (/时长|时间段|时间$|列表|名称|地址|ID$|id$/.test(name)) return 'text';
+  const present = samples.filter((v) => v !== null && v !== undefined && v !== '');
+  if (!present.length) return 'text';
+  const numeric = present.filter((v) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))));
+  return numeric.length === present.length ? 'int' : 'text';
+}
+
+/** 可选字段目录:4 个基础字段 + 平台导出里的全部列 */
+async function buildFieldCatalog(store) {
+  const fields = BASE_FIELDS.map((f) => ({ ...f }));
+  let columns = [];
+  let sample = [];
+  try {
+    const uploads = await store.listUploads();
+    const seen = new Set();
+    for (const upload of uploads) {
+      for (const col of ((upload.report && upload.report.columns) || [])) seen.add(col);
+    }
+    sample = (await store.queryPlatformRows({})).slice(0, 300);
+    if (!seen.size) {
+      for (const row of sample) for (const col of Object.keys(row.data || {})) seen.add(col);
+    }
+    columns = [...seen];
+  } catch { /* 存储不可用时只有基础字段 */ }
+
+  for (const column of columns) {
+    const key = `p:${column}`;
+    if (fields.some((f) => f.key === key)) continue;
+    if (['日期', '门店编号', '门店名称'].includes(column)) continue;
+    fields.push({
+      key,
+      label: column,
+      type: inferFieldType(column, sample.map((row) => (row.data || {})[column])),
+      source: 'platform',
+    });
+  }
+  return fields;
+}
+
+function buildMatrix(rows, dates, fields) {
   const schools = new Map();
   for (const row of rows) {
     const schoolName = row.school || '未分组';
@@ -82,9 +146,9 @@ function buildMatrix(rows, dates) {
       });
     }
     const shop = shopMap.get(row.shopKey);
-    for (const metric of METRIC_KEYS) {
-      if (!shop.series[metric]) shop.series[metric] = {};
-      shop.series[metric][row.date] = row[metric];
+    for (const field of fields) {
+      if (!shop.series[field.key]) shop.series[field.key] = {};
+      shop.series[field.key][row.date] = fieldValue(row, field);
     }
     if (Array.isArray(row.quality) && row.quality.length) shop.quality[row.date] = row.quality;
   }
@@ -93,15 +157,32 @@ function buildMatrix(rows, dates) {
   for (const [school, shopMap] of [...schools.entries()].sort((a, b) => a[0].localeCompare(b[0], 'zh-Hans-CN'))) {
     const shops = [...shopMap.values()].sort((a, b) => String(a.shopName).localeCompare(String(b.shopName), 'zh-Hans-CN'));
     for (const shop of shops) {
-      for (const metric of METRIC_KEYS) {
-        if (!SUM_METRICS.has(metric)) continue;
-        let sum = 0;
-        let seen = false;
-        for (const date of dates) {
-          const value = shop.series[metric][date];
-          if (typeof value === 'number') { sum += value; seen = true; }
+      for (const field of fields) {
+        const series = shop.series[field.key] || {};
+        if (field.type === 'int' || field.type === 'money') {
+          let sum = 0;
+          let seen = false;
+          for (const date of dates) {
+            const value = series[date];
+            if (typeof value === 'number') { sum += value; seen = true; }
+          }
+          shop.totals[field.key] = seen ? Math.round(sum * 100) / 100 : null;
+        } else if (field.type === 'rate') {
+          let weight = 0;
+          let acc = 0;
+          const base = shop.series.exposure || {};
+          for (const date of dates) {
+            const rate = series[date];
+            const exposure = base[date];
+            if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
+              weight += exposure;
+              acc += rate * exposure;
+            }
+          }
+          shop.totals[field.key] = weight > 0 ? acc / weight : null;
+        } else {
+          shop.totals[field.key] = null;
         }
-        shop.totals[metric] = seen ? Math.round(sum * 100) / 100 : null;
       }
     }
     groups.push({ school, shops });
@@ -115,7 +196,7 @@ function numOrNull(value) {
   return Number.isFinite(n) ? n : null;
 }
 
-/** 平台全字段行 → 矩阵行(只取 4 个可展示指标,其余字段留在 raw) */
+/** 平台全字段行 → 矩阵行:4 个基础指标另存,原始 124 字段留在 platform */
 function platformRowToMatrix(row) {
   const d = row.data || {};
   return {
@@ -129,25 +210,27 @@ function platformRowToMatrix(row) {
     revenue: numOrNull(d['营业额']),
     exposure: numOrNull(d['曝光人数']),
     click_rate: numOrNull(d['进店转化率']),
-    raw: d,
+    platform: d,
     quality: [],
   };
 }
 
-/** 人工表 + 平台明细合并:同一天同一店,人工优先,平台补位 */
+/** 人工表 + 平台明细合并:同一天同一店,人工的 4 个指标覆盖平台,其余平台字段保留 */
 async function loadMergedRows(store, { from, to, schools, shops } = {}) {
-  const manual = await store.queryRows({ from, to, schools, shops });
   let platform = [];
   try {
     platform = await store.queryPlatformRows({ from, to });
   } catch { /* 平台表不可用不影响人工数据 */ }
+  const manual = await store.queryRows({ from, to, schools, shops });
+
   const map = new Map();
-  for (const row of manual) map.set(`${row.date}|${row.shopKey}`, row);
-  for (const row of platform) {
-    const key = `${row.date}|${row.shopId}`;
-    if (map.has(key)) continue;
-    map.set(key, platformRowToMatrix(row));
+  for (const row of platform) map.set(`${row.date}|${row.shopId}`, platformRowToMatrix(row));
+  for (const row of manual) {
+    const key = `${row.date}|${row.shopKey}`;
+    const base = map.get(key);
+    map.set(key, { ...(base || {}), ...row, platform: base ? base.platform : null });
   }
+
   let rows = [...map.values()];
   if (schools && schools.length) rows = rows.filter((r) => schools.includes(String(r.school || '')));
   if (shops && shops.length) rows = rows.filter((r) => shops.includes(String(r.shopId || r.shopKey || '')));
@@ -262,6 +345,7 @@ function register(app, ctx) {
         year,
         schoolOverride: school,
         parser: { kind: 'platform-export', version: PARSER_VERSION },
+        columns: parsed.columns || [],
         warnings: parsed.warnings.slice(0, 500),
       };
 
@@ -385,26 +469,45 @@ function register(app, ctx) {
 
   // ---------- 矩阵查询 ----------
 
+  app.get('/api/ops/fields', async ({ res }) => {
+    if (!(await requireHealthy(store, res))) return;
+    try {
+      const fields = await buildFieldCatalog(store);
+      sendJson(res, 200, {
+        fields: fields.map(({ key, label, type, source }) => ({ key, label, type, source })),
+        defaults: BASE_FIELD_KEYS,
+      });
+    } 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);
     const to = parseYmd(query.to);
     const schools = splitList(query.schools);
     const shops = splitList(query.shops);
-    const metrics = parseMetricsParam(query.metrics);
     let rows;
     try {
       rows = await loadMergedRows(store, { from: from || undefined, to: to || undefined, schools, shops });
     } catch (e) {
       return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
     }
+    const catalog = await buildFieldCatalog(store);
+    const wantedFields = parseFieldKeys(query.fields);
+    let fields = wantedFields.length
+      ? wantedFields.map((key) => catalog.find((f) => f.key === key)).filter(Boolean)
+      : catalog.filter((f) => BASE_FIELD_KEYS.includes(f.key));
+    if (!fields.length) fields = catalog.filter((f) => BASE_FIELD_KEYS.includes(f.key));
+
     const dates = [...new Set(rows.map((r) => r.date))].sort();
     sendJson(res, 200, {
       from: from || (dates[0] || null),
       to: to || (dates[dates.length - 1] || null),
       dates,
-      metrics,
-      groups: buildMatrix(rows, dates),
+      fields: fields.map(({ key, label, type }) => ({ key, label, type })),
+      groups: buildMatrix(rows, dates, fields),
     });
   });
 
@@ -519,14 +622,20 @@ function register(app, ctx) {
     const to = parseYmd(query.to);
     const schools = splitList(query.schools);
     const shops = splitList(query.shops);
-    const metrics = parseMetricsParam(query.metrics);
     let rows;
     try {
       rows = await loadMergedRows(store, { from: from || undefined, to: to || undefined, schools, shops });
     } catch (e) {
       return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
     }
-    const labels = metrics.map((m) => (opsImport.METRICS[m] && opsImport.METRICS[m].label) || m);
+
+    const catalog = await buildFieldCatalog(store);
+    const wantedFields = parseFieldKeys(query.fields);
+    let fields = wantedFields.length
+      ? wantedFields.map((key) => catalog.find((f) => f.key === key)).filter(Boolean)
+      : catalog.filter((f) => BASE_FIELD_KEYS.includes(f.key));
+    if (!fields.length) fields = catalog.filter((f) => BASE_FIELD_KEYS.includes(f.key));
+
     const sorted = rows.slice().sort((a, b) => {
       const school = String(a.school || '').localeCompare(String(b.school || ''), 'zh-Hans-CN');
       if (school !== 0) return school;
@@ -534,18 +643,22 @@ function register(app, ctx) {
       if (shop !== 0) return shop;
       return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
     });
+
     const lines = [];
-    // 与页面一致:一行一店一天,指标做列
-    lines.push(['学校', '店铺ID', '店铺名称', '日期', ...labels].map(csvCell).join(','));
+    lines.push(['学校', '店铺ID', '店铺名称', '日期', ...fields.map((f) => f.label)].map(csvCell).join(','));
     for (const row of sorted) {
       lines.push([
         row.school || '',
         row.shopId || '',
         row.shopName || '',
         row.date,
-        ...metrics.map((m) => (row[m] == null ? '' : row[m])),
+        ...fields.map((field) => {
+          const value = fieldValue(row, field);
+          return value == null ? '' : value;
+        }),
       ].map(csvCell).join(','));
     }
+
     const csv = '\ufeff' + lines.join('\r\n');
     res.writeHead(200, {
       'Content-Type': 'text/csv; charset=utf-8',
@@ -553,6 +666,7 @@ function register(app, ctx) {
     });
     res.end(csv);
   });
+
 }
 
 module.exports = { register };

+ 6 - 1
server/stores/ops-pg.js

@@ -324,7 +324,12 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
         UNION SELECT school, shop_key AS shop FROM ops_row WHERE school <> ''
         UNION SELECT school, key AS shop FROM ops_shop WHERE school <> ''
       ) AS t GROUP BY school ORDER BY school ASC`),
-      poolRef.query('SELECT key, shop_id, shop_name, school, status FROM ops_shop ORDER BY school ASC, shop_name ASC'),
+      poolRef.query(`SELECT key, MAX(shop_id) AS shop_id, MAX(shop_name) AS shop_name,
+        MAX(school) AS school, MAX(status) AS status FROM (
+        SELECT key, shop_id, shop_name, school, status FROM ops_shop
+        UNION ALL
+        SELECT shop_id AS key, shop_id, shop_name, school, NULL AS status FROM ops_platform_row
+      ) AS t GROUP BY key ORDER BY school ASC, shop_name ASC`),
       poolRef.query('SELECT id, original_name, uploaded_at, report FROM ops_upload ORDER BY uploaded_at DESC LIMIT 1'),
     ]);
     return {

+ 6 - 1
server/stores/ops-sqlite.js

@@ -360,7 +360,12 @@ function createSqliteStore({ file }) {
         UNION SELECT school, key AS shop FROM ops_shop WHERE school <> ''
       ) GROUP BY school ORDER BY school ASC`)
       .all().map((r) => ({ name: r.school, shopCount: r.c }));
-    const shops = handle.prepare('SELECT key, shop_id, shop_name, school, status FROM ops_shop ORDER BY school ASC, shop_name ASC')
+    const shops = handle.prepare(`SELECT key, MAX(shop_id) AS shop_id, MAX(shop_name) AS shop_name,
+        MAX(school) AS school, MAX(status) AS status FROM (
+        SELECT key, shop_id, shop_name, school, status FROM ops_shop
+        UNION ALL
+        SELECT shop_id AS key, shop_id, shop_name, school, NULL AS status FROM ops_platform_row
+      ) GROUP BY key ORDER BY school ASC, shop_name ASC`)
       .all().map((r) => ({ key: r.key, shopId: r.shop_id, shopName: r.shop_name, school: r.school, status: r.status }));
     const last = handle.prepare(`SELECT id, original_name, uploaded_at, report FROM ops_upload
       ORDER BY uploaded_at DESC LIMIT 1`).get();