瀏覽代碼

feat: server-side pagination for report details, push status in sync history, trend chart on lake detail tab

KenZ1117 2 周之前
父節點
當前提交
1cc8b62624
共有 5 個文件被更改,包括 181 次插入 和 23 次删除
  1. 18 8
      public/index.html
  2. 132 13
      public/reports.js
  3. 9 0
      public/style.css
  4. 9 0
      scripts/smoke.js
  5. 13 2
      server/routes/reports.js

+ 18 - 8
public/index.html

@@ -294,11 +294,6 @@
               <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>
               同步数据
             </button>
-            <button class="btn btn-ghost btn-small" id="btnReportPush" title="把本地湖增量推送到钉钉 AI 表格(云端主档案)">
-              <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 21V9"/><path d="M7 14l5-5 5 5"/><path d="M4 3h16"/></svg>
-              推送云端
-            </button>
-            <span class="badge" id="reportPushBadge">云端未启用</span>
           </div>
         </div>
 
@@ -307,12 +302,18 @@
         <div class="panel panel-fixed" id="syncHistoryPanel">
           <div class="panel-title">
             <span>同步历史</span>
-            <span class="badge badge-info" id="syncHistoryBadge">本主体最近 8 次</span>
+            <span class="actions">
+              <span class="badge" id="reportPushBadge">云端未启用</span>
+              <button class="btn btn-primary btn-small" id="btnReportPush">
+                <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 21V9"/><path d="M7 14l5-5 5 5"/><path d="M4 3h16"/></svg>
+                推送云端
+              </button>
+            </span>
           </div>
           <div class="table-wrap">
             <table class="data-table">
-              <thead><tr><th>时间</th><th>区间</th><th>范围</th><th>新增</th><th>更新</th><th>失败</th><th>备注</th></tr></thead>
-              <tbody id="syncHistoryRows"><tr><td colspan="7" class="empty-cell">暂无同步记录</td></tr></tbody>
+              <thead><tr><th>时间</th><th>区间</th><th>范围</th><th>新增</th><th>更新</th><th>失败</th><th>备注</th><th>云端</th></tr></thead>
+              <tbody id="syncHistoryRows"><tr><td colspan="8" class="empty-cell">暂无同步记录</td></tr></tbody>
             </table>
           </div>
         </div>
@@ -436,6 +437,14 @@
 
         <div class="stats-grid" id="reportKpis"></div>
 
+        <div class="panel panel-fixed" id="reportTrendPanel">
+          <div class="panel-title">
+            <span>主指标趋势</span>
+            <span class="actions"><select id="reportTrendMetric" class="chart-metric-select" title="切换趋势指标"></select></span>
+          </div>
+          <div id="reportTrendChart" class="chart chart-lg"></div>
+        </div>
+
         <div class="panel panel-fixed report-table-panel">
           <div class="panel-title">
             <span>报表数据</span>
@@ -451,6 +460,7 @@
               <p id="reportEmptyText">暂无数据<br><small>请确认商家客户端已登录,然后点击右上角「同步数据」</small></p>
             </div>
           </div>
+          <div class="report-pager" id="reportPager"></div>
         </div>
 
         </div>

+ 132 - 13
public/reports.js

@@ -91,6 +91,10 @@
     preset: '30',
     mode: 'detail',
     rawView: false,
+    page: 1,
+    pageSize: 200,
+    total: 0,
+    pages: 1,
     shopFilter: new Set(),
     q: '',
     columns: [],
@@ -171,7 +175,7 @@
           anaCache = null;
           renderEntityTabs();
           renderShopOptions();
-          loadReport();
+          reloadReport();
           renderCharts();
         });
         wrap.appendChild(btn);
@@ -372,7 +376,7 @@
     const { from, to } = currentRange();
     try {
       let url = state.mode === 'detail'
-        ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}${state.rawView ? '' : '&scope=shop'}`
+        ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}${state.rawView ? '' : '&scope=shop'}&page=${state.page}&pageSize=${state.pageSize}`
         : `/api/reports/aggregate?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&group=${state.mode}`;
       if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
       if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
@@ -380,14 +384,52 @@
       if (state.entity !== entity) return;
       state.rows = data.rows || [];
       state.summary = data.summary || null;
+      state.total = state.mode === 'detail' ? (data.total ?? state.rows.length) : state.rows.length;
+      state.pages = state.mode === 'detail' ? (data.pages ?? 1) : 1;
       renderKpis();
       renderTable();
+      renderPager();
+      loadTrend();
     } catch (e) {
       if (e.name === 'AbortError') return;
       showToast('报表加载失败:' + e.message, 'error');
     }
   }
 
+  // 筛选/主体/视图等变化:回到第一页再加载
+  function reloadReport() {
+    state.page = 1;
+    loadReport();
+  }
+
+  // 分页条:明细模式服务端分页(排序作用于当前页);聚合模式行数少不分页
+  function renderPager() {
+    const el = $('reportPager');
+    if (!el) return;
+    if (state.mode !== 'detail' || state.pages <= 1) {
+      el.innerHTML = state.mode === 'detail' ? `<span class="pager-info">共 ${state.total} 行</span>` : '';
+      return;
+    }
+    const from = (state.page - 1) * state.pageSize + 1;
+    const to = Math.min(state.total, state.page * state.pageSize);
+    const btn = (label, target, disabled) =>
+      `<button type="button" class="pager-btn" data-page="${target}" ${disabled ? 'disabled' : ''}>${label}</button>`;
+    el.innerHTML = `
+      ${btn('‹ 上一页', state.page - 1, state.page <= 1)}
+      <span class="pager-info">第 ${state.page} / ${state.pages} 页 · ${from}-${to} 行 · 共 ${state.total} 行</span>
+      ${btn('下一页 ›', state.page + 1, state.page >= state.pages)}
+      <span class="pager-info pager-note">列排序作用于当前页</span>
+    `;
+  }
+
+  function changePage(page) {
+    if (page < 1 || page > state.pages || page === state.page) return;
+    state.page = page;
+    loadReport();
+    const wrap = $('reportTableWrap');
+    if (wrap && wrap.scrollIntoView) wrap.scrollIntoView({ block: 'nearest' });
+  }
+
   // ---------- 连接状态 ----------
 
   async function refreshConn() {
@@ -957,6 +999,7 @@
     $('reportToDate').value = ymdToDash(a.to);
     state.shopFilter = new Set((a.shopIds || []).map(String));
     renderShopOptions();
+    state.page = 1;
     loadReport();
     // 明细浏览在数据中台页:先切到明细 tab,再导航过去
     if (window.shanLakeShowDetail) window.shanLakeShowDetail();
@@ -1056,13 +1099,13 @@
     $('reportRangePreset').addEventListener('change', () => {
       state.preset = $('reportRangePreset').value;
       $('reportCustomRange').hidden = state.preset !== 'custom';
-      loadReport();
+      reloadReport();
     });
     $('reportFromDate').addEventListener('change', loadReport);
     $('reportToDate').addEventListener('change', loadReport);
 
     $('reportShopTrigger').addEventListener('click', () => toggleDropdown('reportShopMenu', 'reportShopTrigger'));
-    $('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', loadReport));
+    $('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', reloadReport));
     document.addEventListener('click', (event) => {
       if (!$('reportShopDropdown').contains(event.target)) toggleDropdown('reportShopMenu', 'reportShopTrigger', false);
     });
@@ -1074,7 +1117,7 @@
       $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
       state.sortKey = state.mode === 'detail' ? 'date' : (state.mode === 'shop' ? 'shopName' : 'date');
       state.sortDir = 'desc';
-      loadReport();
+      reloadReport();
     });
 
     // 字段模式:报表列(语义列)/ 全部字段(明细 + 全部原始字段;聚合视图无 raw,自动回明细)
@@ -1100,7 +1143,7 @@
       clearTimeout(searchTimer);
       searchTimer = setTimeout(() => {
         state.q = $('reportSearch').value.trim();
-        loadReport();
+        reloadReport();
       }, 300);
     });
 
@@ -1174,22 +1217,28 @@
     });
   }
 
-  // ---------- 同步历史(数据采集页) ----------
+  // ---------- 同步历史(数据采集页,含云端推送状态) ----------
 
   function renderSyncHistory() {
     const tbody = $('syncHistoryRows');
     if (!tbody) return;
     if (!state.entity) {
-      tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">暂无主体</td></tr>';
+      tbody.innerHTML = '<tr><td colspan="8" class="empty-cell">暂无主体</td></tr>';
       return;
     }
-    jsonFetch(`/api/reports/syncs?entity=${encodeURIComponent(state.entity)}`)
-      .then((data) => {
+    const entityKey = encodeURIComponent(state.entity);
+    Promise.all([
+      jsonFetch(`/api/reports/syncs?entity=${entityKey}`),
+      jsonFetch(`/api/reports/aitable/status?entity=${entityKey}`).catch(() => null),
+    ])
+      .then(([data, cloud]) => {
         const runs = (data.runs || []).slice(0, 8);
         if (!runs.length) {
-          tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">暂无同步记录</td></tr>';
+          tbody.innerHTML = '<tr><td colspan="8" class="empty-cell">暂无同步记录</td></tr>';
           return;
         }
+        const lastPush = cloud && cloud.lastPush ? cloud.lastPush : null;
+        const pushAt = lastPush ? new Date(lastPush.at || lastPush.lastPushedAt || 0).getTime() : 0;
         const SCOPE_LABEL = { all: '全主体', chain: '按连锁', shop: '按门店' };
         tbody.innerHTML = runs.map((r) => {
           const warn = (r.steps || []).filter((s) => !s.ok).length;
@@ -1197,6 +1246,11 @@
           const cls = r.cancelled || r.failed ? 'badge-fail' : (warn ? 'badge-warn' : 'badge-ok');
           const t = r.at ? new Date(r.at) : null;
           const ts = t ? `${t.getMonth() + 1}/${t.getDate()} ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '–';
+          // 该次同步之后是否推送过云端(以最近一次推送时间为准;更早的同步被其覆盖归档)
+          const pushed = pushAt && t && pushAt >= t.getTime();
+          const cloudCell = !cloud || !cloud.enabled
+            ? '<span class="badge">未启用</span>'
+            : (pushed ? `<span class="badge badge-ok">已推 ${lastPush.pushedRows || 0} 行</span>` : '<span class="badge badge-info">未推送</span>');
           return `<tr>
             <td>${ts}</td>
             <td>${(r.from || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')} ~ ${(r.to || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')}</td>
@@ -1205,14 +1259,71 @@
             <td class="num">${r.updated ?? 0}</td>
             <td class="num">${r.failed ?? 0}</td>
             <td><span class="badge ${cls}">${note}</span></td>
+            <td>${cloudCell}</td>
           </tr>`;
         }).join('');
       })
       .catch(() => {
-        tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">同步历史加载失败</td></tr>';
+        tbody.innerHTML = '<tr><td colspan="8" class="empty-cell">同步历史加载失败</td></tr>';
       });
   }
 
+  // ---------- 主指标趋势(明细 tab 顶部,BI 图表层) ----------
+  // 数据独立于表格分页:按日聚合全区间(行数=天数,轻量);懒 init——容器隐藏时 ECharts 拿不到宽高
+
+  let trendChart = null;
+  let trendRows = [];
+
+  function buildTrendMetricOptions() {
+    const sel = $('reportTrendMetric');
+    if (!sel) return;
+    const current = sel.value || 'grossAmt';
+    const cols = metricColumns().filter((c) => ['money', 'int', 'num', 'pct'].includes(c.type));
+    sel.innerHTML = cols.map((c) => `<option value="${c.key}" ${c.key === current ? 'selected' : ''}>${c.label}</option>`).join('');
+  }
+
+  async function loadTrend() {
+    const entity = state.entity;
+    const el = $('reportTrendChart');
+    if (!el || !entity) { trendRows = []; return; }
+    const { from, to } = currentRange();
+    let shops = '';
+    if (state.shopFilter.size) shops = `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
+    try {
+      const data = await jsonFetch(`/api/reports/aggregate?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&group=date${shops}`);
+      if (state.entity !== entity) return;
+      trendRows = data.rows || [];
+      buildTrendMetricOptions();
+      renderTrend();
+    } catch { /* 趋势图失败不打扰表格 */ }
+  }
+
+  function renderTrend() {
+    const el = $('reportTrendChart');
+    if (!el || !el.offsetWidth) return; // 容器隐藏(tab 未激活):等激活后再画
+    if (!trendRows.length) { if (trendChart) trendChart.clear(); return; }
+    if (!trendChart) {
+      trendChart = echarts.init(el);
+      window.addEventListener('resize', () => { if (trendChart) trendChart.resize(); });
+    }
+    const metric = $('reportTrendMetric').value || 'grossAmt';
+    const col = state.columns.find((c) => c.key === metric) || {};
+    const rows = trendRows.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
+    const isRate = col.type === 'pct';
+    trendChart.setOption({
+      grid: { left: 56, right: 20, top: 24, bottom: 36 },
+      tooltip: { trigger: 'axis' },
+      xAxis: { type: 'category', data: rows.map((r) => String(r.date).replace(/^(\d{4})(\d{2})(\d{2})$/, '$2/$3')) },
+      yAxis: { type: 'value', axisLabel: { formatter: (v) => (isRate ? (v * 100).toFixed(0) + '%' : v >= 10000 ? (v / 10000) + '万' : v) } },
+      series: [{
+        type: 'line', smooth: true, symbolSize: 5, data: rows.map((r) => r[metric]),
+        lineStyle: { width: 2.2 }, areaStyle: { opacity: 0.08 },
+        tooltip: { valueFormatter: (v) => (v == null ? '—' : isRate ? (v * 100).toFixed(2) + '%' : String(v)) },
+      }],
+    }, { notMerge: true });
+    trendChart.resize();
+  }
+
   // ---------- 初始化 ----------
 
   // 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)
@@ -1254,7 +1365,15 @@
     if (!state.inited) {
       state.inited = true;
       bindEvents();
-      $('btnReportPush').addEventListener('click', pushCurrentEntity);
+      $('btnReportPush').addEventListener('click', async () => {
+        await pushCurrentEntity();
+        renderSyncHistory(); // 云端推送状态列随推送结果刷新
+      });
+      $('reportPager').addEventListener('click', (event) => {
+        const btn = event.target.closest('button[data-page]');
+        if (btn) changePage(Number(btn.dataset.page));
+      });
+      $('reportTrendMetric').addEventListener('change', renderTrend);
       try {
         const data = await jsonFetch('/api/reports/columns');
         state.columns = data.columns || [];

+ 9 - 0
public/style.css

@@ -990,3 +990,12 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 #lakeKpis .metric-value { font-size: clamp(15px, 1.3vw, 24px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
 #lakeKpis .metric-sub { font-size: var(--font-size-small); color: var(--text-3); line-height: 1.35; }
 .data-table .empty-cell { color: var(--text-3); text-align: center; padding: 28px 12px; }
+
+/* ── 报表分页条 ───────────────────────────────────────────── */
+.report-pager { display: flex; align-items: center; gap: 10px; padding: 10px 12px 2px; flex-wrap: wrap; }
+.report-pager:empty { display: none; }
+.pager-btn { border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: 8px; padding: 4px 12px; font-size: var(--font-size-small); cursor: pointer; transition: border-color 0.15s var(--ease-out); }
+.pager-btn:hover:not(:disabled) { border-color: var(--text-3); }
+.pager-btn:disabled { opacity: 0.4; cursor: default; }
+.pager-info { font-size: var(--font-size-small); color: var(--text-2); }
+.pager-note { color: var(--text-3); margin-left: auto; }

+ 9 - 0
scripts/smoke.js

@@ -290,6 +290,15 @@ async function main() {
       const d = r.json();
       expect(r.status === 200 && Array.isArray(d.entities) && typeof d.totalRows === 'number' && d.aitable && 'enabled' in d.aitable, `status=${r.status}`);
     });
+    await check('GET /api/reports/daily → 分页字段', async () => {
+      const r = await request(port, 'GET', '/api/reports/daily');
+      const d = r.json();
+      expect(r.status === 200 && typeof d.total === 'number' && d.page >= 1 && d.pageSize >= 50 && d.pages >= 1 && 'summary' in d, `status=${r.status}`);
+    });
+    await check('GET /api/reports/syncs → 同步历史', async () => {
+      const r = await request(port, 'GET', '/api/reports/syncs');
+      expect(r.status === 200 && Array.isArray(r.json().runs), `status=${r.status}`);
+    });
     await check('POST /api/reports/aitable/push(无主体 → 400)', async () => {
       const r = await request(port, 'POST', '/api/reports/aitable/push', {});
       expect(r.status === 400 && r.json().code === 'AITABLE_NO_ENTITY', `status=${r.status} code=${r.json().code}`);

+ 13 - 2
server/routes/reports.js

@@ -176,13 +176,24 @@ function register(app, ctx) {
     sendJson(res, 200, naposService.requestSyncCancel());
   });
 
+  // 明细行分页:明细(门店×天)量大(90 天 × 数十店 = 数千行),
+  // 前端全量渲染会卡;summary 仍是全区间汇总(分页前计算),KPI 卡不受分页影响
   app.get('/api/reports/daily', async ({ res, query }) => {
-    sendJson(res, 200, naposService.queryReport({
+    const r = naposService.queryReport({
       entity: query.entity, from: query.from, to: query.to,
       shopId: query.shopId,
       shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
       scope: query.scope || 'shop', q: query.q,
-    }));
+    });
+    const total = r.rows.length;
+    const pageSize = Math.min(1000, Math.max(50, Number(query.pageSize) || 200));
+    const pages = Math.max(1, Math.ceil(total / pageSize));
+    const page = Math.min(Math.max(1, Number(query.page) || 1), pages);
+    sendJson(res, 200, {
+      ...r,
+      rows: r.rows.slice((page - 1) * pageSize, page * pageSize),
+      total, page, pageSize, pages,
+    });
   });
 
   app.get('/api/reports/aggregate', async ({ res, query }) => {