Преглед на файлове

feat: 店铺运营表格支持按字段排序

- 数值列表头点击排序,再点切换升/降序
- 门店在各自学校内排序,学校按小计排序
- 汇总口径:金额/数量求和,比率按曝光加权
liangzhao преди 1 седмица
родител
ревизия
ebb6f14d3f
променени са 2 файла, в които са добавени 80 реда и са изтрити 4 реда
  1. 74 4
      public/ops.js
  2. 6 0
      public/style.css

+ 74 - 4
public/ops.js

@@ -28,6 +28,8 @@
     shops: null, // null = 全部店铺
     fields: loadJSON('ops.fields', DEFAULT_FIELDS.slice()),
     view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
+    sortKey: null,
+    sortDir: 'desc',
     from: '',
     to: '',
     upload: { files: [], busy: false },
@@ -308,7 +310,7 @@
     }
     if (!state.data) return;
     body.innerHTML = state.view === 'cards' ? renderCards(state.data) : renderTables(state.data);
-    if (state.view === 'table') bindRowHover();
+    if (state.view === 'table') { bindRowHover(); bindSort(); }
   }
 
   function cellValue(shop, field, date) {
@@ -364,6 +366,53 @@
     return state.fields.map((key) => fieldByKey(key));
   }
 
+  /** 门店在某字段上的区间汇总:金额/数量求和,比率按曝光加权 */
+  function fieldAggregate(shop, field, dates) {
+    if (field.type === 'rate') {
+      let num = 0;
+      let den = 0;
+      for (const d of dates) {
+        const v = cellValue(shop, field, d);
+        if (v == null) continue;
+        const weight = Number((shop.series && shop.series.exposure && shop.series.exposure[d]) || 1) || 1;
+        num += v * weight;
+        den += weight;
+      }
+      return den ? num / den : null;
+    }
+    let sum = 0;
+    let has = false;
+    for (const d of dates) {
+      const v = cellValue(shop, field, d);
+      if (typeof v === 'number') { sum += v; has = true; }
+    }
+    return has ? sum : null;
+  }
+
+  /** 按当前排序列排序:门店在各自学校内排序,学校按小计排序 */
+  function sortGroups(groups, fields, dates) {
+    if (!state.sortKey) return groups;
+    const field = fields.find((f) => f.key === state.sortKey);
+    if (!field || field.type === 'text') return groups;
+    const dir = state.sortDir === 'asc' ? 1 : -1;
+    const cmp = (a, b) => {
+      if (a == null && b == null) return 0;
+      if (a == null) return 1;
+      if (b == null) return -1;
+      return dir * (a - b);
+    };
+    const groupTotal = (group) => group.shops.reduce((acc, shop) => {
+      const v = fieldAggregate(shop, field, dates);
+      return v == null ? acc : acc + v;
+    }, 0);
+    return groups
+      .map((group) => ({
+        ...group,
+        shops: group.shops.slice().sort((a, b) => cmp(fieldAggregate(a, field, dates), fieldAggregate(b, field, dates))),
+      }))
+      .sort((a, b) => cmp(groupTotal(a), groupTotal(b)));
+  }
+
   function renderTables(data) {
     const fields = activeFields(data);
     if (!fields.length) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
@@ -373,10 +422,18 @@
     const head = `<tr>
       <th class="ops-shop-col">店铺</th>
       <th class="ops-date-col">日期</th>
-      ${fields.map((f) => `<th class="ops-metric-col ${f.type === 'text' ? 'ops-col-text' : 'ops-col-num'}" title="${esc(f.label)}">${esc(f.label)}</th>`).join('')}
+      ${fields.map((f) => {
+        const sortable = f.type !== 'text';
+        const active = state.sortKey === f.key;
+        const arrow = active ? (state.sortDir === 'asc' ? '↑' : '↓') : '';
+        return `<th class="ops-metric-col ${f.type === 'text' ? 'ops-col-text' : 'ops-col-num'}${sortable ? ' ops-sortable' : ''}${active ? ' ops-sorted' : ''}"${sortable ? ` data-sort="${esc(f.key)}"` : ''} title="${esc(f.label)}${sortable ? '(点击排序)' : ''}">${esc(f.label)}${arrow ? `<span class="ops-sort-arrow">${arrow}</span>` : ''}</th>`;
+      }).join('')}
     </tr>`;
 
-    const body = data.groups.map((group, groupIdx) => {
+
+    const groups = sortGroups(data.groups, fields, dates);
+
+    const body = 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) => {
@@ -405,7 +462,7 @@
       return schoolRow + shopRows + subtotalRows;
     }).join('');
 
-    const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
+    const shopCount = 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} 天 · ${fields.length} 个字段</span></div>
       <div class="ops-table-wrap">
@@ -418,6 +475,19 @@
   }
 
   /** 悬停时整块高亮(同一店铺的多天一起亮),避免分不清行属于哪家店 */
+  function bindSort() {
+    document.querySelectorAll('.ops-table-wide th[data-sort]').forEach((th) => {
+      if (th.dataset.sortBound === '1') return;
+      th.dataset.sortBound = '1';
+      th.addEventListener('click', () => {
+        const key = th.dataset.sort;
+        if (state.sortKey === key) state.sortDir = state.sortDir === 'desc' ? 'asc' : 'desc';
+        else { state.sortKey = key; state.sortDir = 'desc'; }
+        renderBody();
+      });
+    });
+  }
+
   function bindRowHover() {
     document.querySelectorAll('.ops-table-wide tbody').forEach((tbody) => {
       if (tbody.dataset.hoverBound === '1') return;

+ 6 - 0
public/style.css

@@ -1478,3 +1478,9 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .schools-legend i.hollow { background: #fff; border: 1.5px solid #b8b2ad; }
 .schools-legend-title { color: var(--text-3); }
 .schools-legend-sep { width: 1px; height: 12px; background: var(--border); }
+
+/* 店铺运营:表头排序 */
+.ops-sortable { cursor: pointer; user-select: none; }
+.ops-sortable:hover { color: var(--accent); }
+.ops-sorted { color: var(--accent); }
+.ops-sort-arrow { margin-left: 4px; font-size: 10px; }