Переглянути джерело

fix: 数据采集/趋势图表空态统一为数据中台同款 empty-hint

- 同步历史:暂无主体/暂无同步记录/加载失败三种空态改为图标+标题+引导
  (empty-hint 组件,表格隐藏),替换原裸表格行;初始行改「加载中…」
- 趋势图表:无主体/区间无数据/加载失败时整个指标条+图表区切换为
  empty-hint 引导(原先无主体时页面空白、无数据时只有小灰块),
  并去掉「—— 先…」随意表述
- 主体选择器引导文案:点→点击、「从云端拉取」→执行「云端拉取」
KenZ1117 2 тижнів тому
батько
коміт
3b968fed4f
2 змінених файлів з 430 додано та 153 видалено
  1. 18 22
      public/index.html
  2. 412 131
      public/reports.js

+ 18 - 22
public/index.html

@@ -311,10 +311,14 @@
             </span>
           </div>
           <div class="table-wrap">
-            <table class="data-table">
+            <table class="data-table" id="syncHistoryTable">
               <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>
+              <tbody id="syncHistoryRows"><tr><td colspan="8" class="empty-cell">加载中…</td></tr></tbody>
             </table>
+            <div id="syncHistoryEmpty" class="empty-hint" hidden>
+              <div class="empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20V11M10 20V4M16 20v-6M2.5 20h19"/></svg></div>
+              <p id="syncHistoryEmptyText"></p>
+            </div>
           </div>
         </div>
 
@@ -482,7 +486,7 @@
         <div class="page-header">
           <div>
             <h1>趋势图表</h1>
-            <div class="sub">主指标趋势 · 门店对比 · 转化漏斗,支持环比;有疑问点右下角问数助手</div>
+            <div class="sub">经营 / 运营双视角内置模板,按模板看趋势与归因;有疑问点右下角问数助手</div>
           </div>
           <div class="toolbar">
             <button class="btn btn-ghost btn-small" id="btnExportHtml">
@@ -494,6 +498,9 @@
 
         <div id="analysisEntityTabs"></div>
 
+        <div class="ana-tpl-bar" id="anaTplBar"></div>
+        <div class="ana-note" id="anaTplDesc"></div>
+
         <div class="report-toolbar panel">
           <div class="field report-field-sm">
             <label>日期范围</label>
@@ -514,11 +521,11 @@
               <div class="scan-dropdown-menu" id="anaShopMenu" role="listbox" aria-multiselectable="true" hidden></div>
             </div>
           </div>
-          <div class="field report-field-sm">
+          <div class="field report-field-sm" id="anaMetricField">
             <label>主指标</label>
             <select id="chartMetric" class="chart-metric-select"></select>
           </div>
-          <label class="ana-compare-toggle" title="主趋势图中叠加上一周期的虚线对照">
+          <label class="ana-compare-toggle" id="anaCompareField" title="主趋势图中叠加上一周期的虚线对照">
             <input type="checkbox" id="anaComparePrev" checked>
             <span>对比上期</span>
           </label>
@@ -526,24 +533,12 @@
 
         <div class="delta-strip" id="analysisDeltas"></div>
 
-        <div class="ana-grid">
-          <div class="panel ana-cell ana-span2">
-            <div class="panel-title"><span id="anaTrendTitle">主指标趋势</span></div>
-            <div id="chartTrend" class="chart chart-lg"></div>
-          </div>
-          <div class="panel ana-cell">
-            <div class="panel-title"><span>门店排行 Top 10</span></div>
-            <div id="chartRank" class="chart chart-lg"></div>
-          </div>
-          <div class="panel ana-cell">
-            <div class="panel-title"><span>转化漏斗(曝光 → 进店 → 下单)</span></div>
-            <div id="chartFunnel" class="chart chart-lg"></div>
-          </div>
-          <div class="panel ana-cell ana-span2">
-            <div class="panel-title"><span id="anaCompareTitle">门店对比 · Top 5</span></div>
-            <div id="chartCompare" class="chart chart-lg"></div>
-          </div>
+        <div id="analysisEmpty" class="empty-hint" hidden>
+          <div class="empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20V11M10 20V4M16 20v-6M2.5 20h19"/></svg></div>
+          <p id="analysisEmptyText"></p>
         </div>
+
+        <div class="ana-grid" id="anaGrid"></div>
       </main>
     </div>
 
@@ -918,6 +913,7 @@
 <script src="/tasks.js"></script>
 <script src="/history.js"></script>
 <script src="/vendor/echarts.min.js"></script>
+<script src="/analysis-templates.js"></script>
 <script src="/reports.js"></script>
 <script src="/lake.js"></script>
 <script src="/app.js"></script>

+ 412 - 131
public/reports.js

@@ -3,7 +3,7 @@
 /**
  * 数据采集 + 趋势图表 · 前端逻辑
  * 数据采集:多主体 tab / 同步 / 筛选 / BI 表格 / CSV 导出
- * 趋势图表:环比指标条 / 主趋势 / 门店排行 / 转化漏斗 / 门店对比 / 周报导出
+ * 趋势图表:内置分析模板(经营/运营双视角,可切换)+ 环比指标条 + 周报导出
  * 智能问数:右下角机器人(仅经营分析页面显示),弹窗对话,支持上下文追问
  */
 (function () {
@@ -65,6 +65,15 @@
     if (col.type === 'num') return Number(v).toFixed(2);
     return String(v);
   }
+  // 紧凑数值(图表标签用):425725 → 42.6万
+  function fmtCompact(v) {
+    const n = Number(v);
+    if (!Number.isFinite(n)) return '—';
+    const abs = Math.abs(n);
+    if (abs >= 1e8) return (n / 1e8).toFixed(1) + '亿';
+    if (abs >= 1e4) return (n / 1e4).toFixed(1) + '万';
+    return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
+  }
 
   const RATE_THRESHOLDS = { clkExpRate: { good: 0.09, bad: 0.06 }, ordClkRate: { good: 0.025, bad: 0.02 } };
   function rateClass(key, v) {
@@ -273,16 +282,80 @@
     return box;
   }
 
+  /**
+   * 空状态:与有数据时同形态的下拉,菜单内容换成「怎么拿到数据」的引导。
+   * 三个页面的动作不同:采集页直接打开同步面板;趋势页跳转采集页并弹出面板;
+   * 中台页额外提示可从云端拉取。
+   */
+  function buildEntitySelectEmpty(containerId) {
+    const box = document.createElement('div');
+    box.className = 'scan-dropdown entity-select';
+
+    const trigger = document.createElement('button');
+    trigger.type = 'button';
+    trigger.className = 'scan-dropdown-trigger is-empty';
+    trigger.setAttribute('aria-expanded', 'false');
+    trigger.setAttribute('aria-haspopup', 'listbox');
+    const label = document.createElement('span');
+    label.textContent = '暂无主体';
+    trigger.appendChild(label);
+    trigger.insertAdjacentHTML('beforeend', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>');
+    box.appendChild(trigger);
+
+    const menu = document.createElement('div');
+    menu.className = 'scan-dropdown-menu entity-select-menu';
+    menu.hidden = true;
+    box.appendChild(menu);
+
+    const isCollect = containerId === 'entityTabs';
+    const isLake = containerId === 'lakeEntityTabs';
+    const guide = document.createElement('div');
+    guide.className = 'entity-select-guide';
+    const step = document.createElement('div');
+    step.className = 'entity-select-guide-steps';
+    step.textContent = isCollect
+      ? '在「淘宝闪购商家版」客户端登录账号,再点击本页的「同步数据」,完成后这里会显示账号。'
+      : '在「淘宝闪购商家版」客户端登录账号,到「数据采集」页完成一次「同步数据」,完成后这里会显示账号。';
+    guide.appendChild(step);
+    if (isLake) {
+      const cloud = document.createElement('div');
+      cloud.className = 'entity-select-guide-steps';
+      cloud.textContent = '其他电脑已推送过云端?在本页「湖全景」右上角执行「云端拉取」即可恢复数据。';
+      guide.appendChild(cloud);
+    }
+    const btn = document.createElement('button');
+    btn.type = 'button';
+    btn.className = 'btn btn-primary btn-small entity-select-guide-btn';
+    btn.textContent = isCollect ? '打开同步面板' : '去「数据采集」同步';
+    btn.addEventListener('click', async () => {
+      closeEntitySelect(box);
+      if (!isCollect) {
+        const nav = document.querySelector('.nav-item[data-view="reports"]');
+        if (nav) nav.click();
+        if (window.shanReportsInit) { try { await window.shanReportsInit(); } catch { /* ignore */ } }
+      }
+      const syncBtn = document.getElementById('btnOpenSync');
+      if (syncBtn) syncBtn.click();
+    });
+    guide.appendChild(btn);
+    menu.appendChild(guide);
+
+    trigger.addEventListener('click', () => {
+      const willOpen = menu.hidden;
+      closeEntitySelect(box);
+      menu.hidden = !willOpen;
+      trigger.setAttribute('aria-expanded', String(willOpen));
+    });
+    return box;
+  }
+
   function renderEntityTabs() {
     for (const containerId of ENTITY_SELECT_CONTAINERS) {
       const wrap = $(containerId);
       if (!wrap) continue;
       wrap.innerHTML = '';
       if (!state.entities.length) {
-        const empty = document.createElement('div');
-        empty.className = 'entity-select-empty';
-        empty.textContent = '暂无主体 —— 请先在商家客户端登录并完成一次「同步数据」';
-        wrap.appendChild(empty);
+        wrap.appendChild(buildEntitySelectEmpty(containerId));
         continue;
       }
       wrap.appendChild(buildEntitySelect());
@@ -819,20 +892,14 @@
     window.open(`/api/reports/report-html?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`, '_blank');
   }
 
-  // ---------- 趋势图表 ----------
+  // ---------- 趋势图表 · 分析模板 ----------
+  // 模板 = 对比方式 + 指标组合 + 图表布局的预设;看谁(整校/某食堂/某档口)由
+  // 主体选择器 + 门店筛选决定,与模板正交。新增模板只需往 TEMPLATES 注册一项。
 
   function chartCol(key) {
     return state.columns.find((c) => c.key === key) || { key, label: key, type: 'num' };
   }
 
-  function ensureChart(instanceKey, domId) {
-    if (typeof echarts === 'undefined') return null;
-    const dom = $(domId);
-    if (!dom) return null;
-    if (!chartInstances[instanceKey]) chartInstances[instanceKey] = echarts.init(dom);
-    return chartInstances[instanceKey];
-  }
-
   function metricColumnOptions() {
     return state.columns.filter((c) => ['int', 'money', 'pct', 'num'].includes(c.type) && !c.reserved);
   }
@@ -852,9 +919,209 @@
     chartMetric = sel.value || 'grossAmt';
   }
 
+  let anaTemplate = LS.get('shanReports.anaTemplate', 'overview');
+
+  /** 模板栏:分组胶囊按钮(复用 seg-control),是本页的一级切换控件 */
+  function renderTemplateSelect() {
+    const bar = $('anaTplBar');
+    if (!bar) return;
+    bar.innerHTML = '';
+    let seg = null;
+    let groupLabel = null;
+    for (const t of TEMPLATES) {
+      if (groupLabel !== t.group) {
+        groupLabel = t.group;
+        const label = document.createElement('span');
+        label.className = 'ana-tpl-group';
+        label.textContent = t.group;
+        bar.appendChild(label);
+        seg = document.createElement('div');
+        seg.className = 'seg-control';
+        bar.appendChild(seg);
+      }
+      const btn = document.createElement('button');
+      btn.type = 'button';
+      btn.dataset.tpl = t.id;
+      btn.textContent = t.name;
+      btn.title = t.desc || '';
+      btn.classList.toggle('active', t.id === anaTemplate);
+      seg.appendChild(btn);
+    }
+    const firstBtn = bar.querySelector('button');
+    if (firstBtn && !bar.querySelector('button.active')) {
+      anaTemplate = firstBtn.dataset.tpl;
+      firstBtn.classList.add('active');
+    }
+  }
+
+  function activeTemplate() {
+    return TEMPLATES.find((t) => t.id === anaTemplate) || TEMPLATES[0];
+  }
+
+  function applyTemplateControls(tpl) {
+    const metricField = $('anaMetricField');
+    if (metricField) metricField.hidden = !tpl.usesMetric;
+    const compareField = $('anaCompareField');
+    if (compareField) compareField.hidden = !tpl.usesCompare;
+    const desc = $('anaTplDesc');
+    if (desc) desc.textContent = tpl.desc || '';
+  }
+
   const AXIS_TXT = { axisLabel: { color: '#6b6560' }, axisLine: { lineStyle: { color: '#e8e4df' } } };
   const SPLIT_LINE = { lineStyle: { color: '#f0ede8' } };
   const ACCENT = '#ff6b35';
+  const COLOR_OK = '#1a8a4c';
+  const COLOR_FAIL = '#c8402e';
+  const COLOR_INFO = '#2563eb';
+  const COLOR_PREV = '#a5a09a';
+
+  // ---------- 面板 / 图表 DOM 辅助 ----------
+
+  function disposeAllCharts() {
+    for (const k of Object.keys(chartInstances)) {
+      try { chartInstances[k].dispose(); } catch { /* ignore */ }
+      delete chartInstances[k];
+    }
+  }
+
+  function makePanel(grid, title, span) {
+    const cell = document.createElement('div');
+    cell.className = 'panel ana-cell' + (span === 3 ? ' ana-span3' : (span === 2 ? ' ana-span2' : ''));
+    const head = document.createElement('div');
+    head.className = 'panel-title';
+    if (title) {
+      const label = document.createElement('span');
+      label.textContent = title;
+      head.appendChild(label);
+    }
+    cell.appendChild(head);
+    grid.appendChild(cell);
+    return { cell, head };
+  }
+
+  function panelChart(panel, instanceKey) {
+    const dom = document.createElement('div');
+    dom.className = 'chart chart-lg';
+    panel.cell.appendChild(dom);
+    if (typeof echarts === 'undefined') return null;
+    const chart = echarts.init(dom);
+    chartInstances[instanceKey] = chart;
+    return chart;
+  }
+
+  function panelNote(panel, text) {
+    const note = document.createElement('div');
+    note.className = 'ana-note';
+    note.textContent = text;
+    panel.cell.appendChild(note);
+    return note;
+  }
+
+  function panelEmpty(grid, msg, span) {
+    const panel = makePanel(grid, '', span);
+    const box = document.createElement('div');
+    box.className = 'ana-empty';
+    box.textContent = msg;
+    panel.cell.appendChild(box);
+    return panel;
+  }
+
+  function panelEmptyIn(panel, msg) {
+    const box = document.createElement('div');
+    box.className = 'ana-empty';
+    box.textContent = msg;
+    panel.cell.appendChild(box);
+  }
+
+  // ---------- 数据质量标注 / 日期辅助 ----------
+
+  function ymdDate(ymd) {
+    return new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
+  }
+  function shiftYmd(ymd, delta) {
+    const d = ymdDate(ymd);
+    d.setDate(d.getDate() + delta);
+    return ymdLocal(d);
+  }
+
+  /** 逐日序列:补全区间内缺失日期(null 断线),并标记「全零天」(曝光/订单/营业额全为 0) */
+  function buildDailySeries(data, field) {
+    const rows = new Map(data.byDate.map((r) => [r.date, r]));
+    const sorted = [...rows.keys()].sort();
+    // 今天通常未结算:尾部以最后有数据的一天为界,避免图表天天挂着「缺今天」的灰带
+    const last = sorted.length ? sorted[sorted.length - 1] : data.to;
+    const end = last < data.to ? last : data.to;
+    const dates = [];
+    const values = [];
+    const zeroDays = new Set();
+    const missingDays = new Set();
+    for (let d = data.from; d <= end; d = shiftYmd(d, 1)) {
+      dates.push(d);
+      const r = rows.get(d);
+      if (!r) { values.push(null); missingDays.add(d); continue; }
+      const v = Number(r[field]);
+      values.push(Number.isFinite(v) ? v : null);
+      if (!(Number(r.expUv) > 0) && !(Number(r.validOrderCnt) > 0) && !(Number(r.grossAmt) > 0)) zeroDays.add(d);
+    }
+    return { dates, values, zeroDays, missingDays };
+  }
+
+  /** 缺数日期 → 灰色底纹(挂在某个 series 的 markArea 上) */
+  function missingMarkAreas(series) {
+    const areas = [];
+    let start = null;
+    for (const d of series.dates) {
+      if (series.missingDays.has(d)) { if (!start) start = d; }
+      else if (start) { areas.push([{ xAxis: ymdToDash(start), itemStyle: { color: 'rgba(107,101,96,0.07)' } }, { xAxis: ymdToDash(d) }]); start = null; }
+    }
+    if (start) areas.push([{ xAxis: ymdToDash(start), itemStyle: { color: 'rgba(107,101,96,0.07)' } }, { xAxis: ymdToDash(series.dates[series.dates.length - 1]) }]);
+    return areas;
+  }
+
+  /** 全零天 → 空心点(取数异常返回全零行时,图表层一眼可见,不会伪装成生意暴跌) */
+  function hollowPoints(series) {
+    return series.values.map((v, i) => {
+      if (v == null) return null;
+      if (series.zeroDays.has(series.dates[i])) {
+        return { value: v, symbol: 'circle', symbolSize: 7, itemStyle: { color: '#fff', borderColor: ACCENT, borderWidth: 1.6 } };
+      }
+      return v;
+    });
+  }
+
+  function dataQualityNote(panel, series) {
+    if (series.zeroDays.size || series.missingDays.size) {
+      panelNote(panel, '○ 空心点=当日全零;灰色底纹=缺数日期');
+    }
+  }
+
+  /** 高校日历:对比窗口跨寒/暑假时,环比天然失真(营业基数骤降),给用户挂提醒 */
+  const VACATIONS = [
+    { name: '寒假', from: '0108', to: '0225' },
+    { name: '暑假', from: '0701', to: '0831' },
+  ];
+  function rangeOverlapsVacation(from, to) {
+    const a = String(from).slice(4);
+    const b = String(to).slice(4);
+    const spans = a <= b ? [[a, b]] : [[a, '1231'], ['0101', b]];
+    return VACATIONS.filter((v) => spans.some(([s, e]) => s <= v.to && v.from <= e)).map((v) => v.name);
+  }
+
+  function sumField(rows, f) { return rows.reduce((acc, r) => acc + (Number(r[f]) || 0), 0); }
+  function wAvgField(rows, f, wb) {
+    let num = 0;
+    let den = 0;
+    for (const r of rows) { const wt = Number(r[wb]) || 0; num += (Number(r[f]) || 0) * wt; den += wt; }
+    return den > 0 ? num / den : 0;
+  }
+
+  function deltaBadge(cur, prev) {
+    if (!(prev > 0) || !Number.isFinite(cur)) return '<span class="delta-none">上期无数据</span>';
+    const delta = (cur - prev) / prev;
+    if (Math.abs(delta) < 0.0001) return '<span class="delta-flat">持平</span>';
+    const up = delta > 0;
+    return `<span class="delta ${up ? 'up' : 'down'}">${up ? '↑' : '↓'} ${Math.abs(delta * 100).toFixed(1)}%</span>`;
+  }
 
   async function ensureAnalysisData() {
     const { from, to } = analysisRange();
@@ -874,36 +1141,70 @@
     const prevFrom = ymdLocal(prevFromD);
 
     const base = `/api/reports/aggregate?entity=${encodeURIComponent(state.entity)}`;
-    const [byDate, byDatePrev, byShop, topDaily] = await Promise.all([
+    const [byDate, byDatePrev, byShop, byShopPrev, topDaily] = await Promise.all([
       jsonFetch(`${base}&from=${from}&to=${to}&group=date${shopSuffix}`).then((d) => d.rows || []),
       jsonFetch(`${base}&from=${prevFrom}&to=${prevTo}&group=date${shopSuffix}`).then((d) => d.rows || []),
       jsonFetch(`${base}&from=${from}&to=${to}&group=shop${shopSuffix}`).then((d) => d.rows || []),
+      jsonFetch(`${base}&from=${prevFrom}&to=${prevTo}&group=shop${shopSuffix}`).then((d) => d.rows || []),
       jsonFetch(`/api/reports/daily?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}&scope=shop${shopSuffix}`).then((d) => d.rows || []),
     ]);
-    anaCache = { key, from, to, prevFrom, prevTo, byDate, byDatePrev, byShop, topDaily };
+    anaCache = { key, from, to, prevFrom, prevTo, byDate, byDatePrev, byShop, byShopPrev, topDaily };
     return anaCache;
   }
 
+  // 标准指标 → 原始蛇形字段(对应 napos-service FIELD_MAP;bench 接口按原始字段取 raw 同行值)
+  const RAW_FIELD_OF = {
+    validOrderCnt: 'valid_order_cnt', grossAmt: 'gross_amt', incomeAmt: 'income_amt',
+    expenseAmt: 'expense_amt', expUv: 'exp_uv', orderUserCnt: 'order_user_cnt',
+    clkExpRate: 'clk_exp_rate', ordClkRate: 'ord_clk_rate', perActualPay: 'per_actual_pay',
+    actOrdCnt: 'act_ord_cnt', actSubsidyAmt: 'act_shop_subsidy_amt', shopRoi: 'shop_roi',
+    rebuyRate30d: 'rebuy_rate_30d',
+  };
+
+  let benchCache = null;
+  /** 同行对标数据:门店×天 的自身值 + 同行均值/排名(key 含字段与门店筛选,自过期) */
+  async function ensureBenchData(data) {
+    const field = RAW_FIELD_OF[chartMetric] || 'gross_amt';
+    const shops = state.analysisShopFilter.size ? Array.from(state.analysisShopFilter) : [];
+    const key = [state.entity, data.from, data.to, field, shops.slice().sort().join(',')].join('|');
+    if (benchCache && benchCache.key === key) return benchCache;
+    const suffix = shops.length ? `&shops=${encodeURIComponent(shops.join(','))}` : '';
+    const r = await jsonFetch(`/api/reports/bench?entity=${encodeURIComponent(state.entity)}&from=${data.from}&to=${data.to}&field=${field}${suffix}`);
+    benchCache = { key, field, rows: r.rows || [] };
+    return benchCache;
+  }
+
+  let crossCache = null;
+  /** 跨校对比数据:湖中全部主体各取 本期/上期 按日聚合(无数据的主体自动滤掉) */
+  async function ensureCrossData(data) {
+    const key = [data.from, data.to].join('|');
+    if (crossCache && crossCache.key === key) return crossCache;
+    const list = await Promise.all(state.entities.map(async (e) => {
+      const base = `/api/reports/aggregate?entity=${encodeURIComponent(e.key)}`;
+      const [byDate, byDatePrev] = await Promise.all([
+        jsonFetch(`${base}&from=${data.from}&to=${data.to}&group=date`).then((d) => d.rows || []).catch(() => []),
+        jsonFetch(`${base}&from=${data.prevFrom}&to=${data.prevTo}&group=date`).then((d) => d.rows || []).catch(() => []),
+      ]);
+      return { key: e.key, name: e.name || e.key, shopCount: (e.shops || []).length || e.shopCount || 0, byDate, byDatePrev };
+    }));
+    crossCache = { key, entities: list.filter((x) => x.byDate.length) };
+    return crossCache;
+  }
+
   function renderDeltas(data) {
     const wrap = $('analysisDeltas');
     if (!wrap) return;
     wrap.innerHTML = '';
-    const sum = (rows, f) => rows.reduce((a, r) => a + (Number(r[f]) || 0), 0);
-    const wAvg = (rows, f, wb) => {
-      let num = 0, den = 0;
-      for (const r of rows) { const wt = Number(r[wb]) || 0; num += (Number(r[f]) || 0) * wt; den += wt; }
-      return den > 0 ? num / den : 0;
-    };
-    const curOrders = sum(data.byDate, 'validOrderCnt');
-    const prevOrders = sum(data.byDatePrev, 'validOrderCnt');
+    const curOrders = sumField(data.byDate, 'validOrderCnt');
+    const prevOrders = sumField(data.byDatePrev, 'validOrderCnt');
     const items = [
-      { label: '营业额', cur: sum(data.byDate, 'grossAmt'), prev: sum(data.byDatePrev, 'grossAmt'), fmt: fmtMoney },
+      { label: '营业额', cur: sumField(data.byDate, 'grossAmt'), prev: sumField(data.byDatePrev, 'grossAmt'), fmt: fmtMoney },
       { label: '有效订单', cur: curOrders, prev: prevOrders, fmt: fmtNum },
-      { label: '收入', cur: sum(data.byDate, 'incomeAmt'), prev: sum(data.byDatePrev, 'incomeAmt'), fmt: fmtMoney },
-      { label: '曝光人数', cur: sum(data.byDate, 'expUv'), prev: sum(data.byDatePrev, 'expUv'), fmt: fmtNum },
-      { label: '下单人数', cur: sum(data.byDate, 'orderUserCnt'), prev: sum(data.byDatePrev, 'orderUserCnt'), fmt: fmtNum },
-      { label: '进店转化率', cur: wAvg(data.byDate, 'clkExpRate', 'expUv'), prev: wAvg(data.byDatePrev, 'clkExpRate', 'expUv'), fmt: fmtPct },
-      { label: '客单价', cur: curOrders > 0 ? sum(data.byDate, 'grossAmt') / curOrders : 0, prev: prevOrders > 0 ? sum(data.byDatePrev, 'grossAmt') / prevOrders : 0, fmt: fmtMoney },
+      { label: '收入', cur: sumField(data.byDate, 'incomeAmt'), prev: sumField(data.byDatePrev, 'incomeAmt'), fmt: fmtMoney },
+      { label: '曝光人数', cur: sumField(data.byDate, 'expUv'), prev: sumField(data.byDatePrev, 'expUv'), fmt: fmtNum },
+      { label: '下单人数', cur: sumField(data.byDate, 'orderUserCnt'), prev: sumField(data.byDatePrev, 'orderUserCnt'), fmt: fmtNum },
+      { label: '进店转化率', cur: wAvgField(data.byDate, 'clkExpRate', 'expUv'), prev: wAvgField(data.byDatePrev, 'clkExpRate', 'expUv'), fmt: fmtPct },
+      { label: '客单价', cur: curOrders > 0 ? sumField(data.byDate, 'grossAmt') / curOrders : 0, prev: prevOrders > 0 ? sumField(data.byDatePrev, 'grossAmt') / prevOrders : 0, fmt: fmtMoney },
     ];
     for (const it of items) {
       const cur = Math.round(it.cur * 100) / 100;
@@ -925,110 +1226,69 @@
     }
   }
 
-  async function renderCharts() {
-    let data;
-    try { data = await ensureAnalysisData(); } catch { return; }
-    renderDeltas(data);
-    const col = chartCol(chartMetric);
-    const compareOn = $('anaComparePrev') && $('anaComparePrev').checked;
-
-    const trend = ensureChart('trend', 'chartTrend');
-    if (trend) {
-      const cur = data.byDate.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
-      const prev = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
-      const xData = cur.map((r) => ymdToDash(r.date));
-      const series = [{
-        name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5,
-        data: cur.map((r) => Number(r[chartMetric]) || 0),
-        itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 },
-        areaStyle: { color: 'rgba(255,107,53,.08)' },
-      }];
-      if (compareOn && prev.length) {
-        series.push({
-          name: '上期同期', type: 'line', smooth: true,
-          data: xData.map((_, i) => (prev[i] ? Number(prev[i][chartMetric]) || 0 : 0)),
-          itemStyle: { color: '#a5a09a' }, lineStyle: { type: 'dashed', width: 1.6 }, symbol: 'none',
-        });
-      }
-      trend.setOption({
-        tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
-        legend: { data: series.map((x) => x.name), top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
-        grid: { left: 64, right: 24, top: 36, bottom: 30 },
-        xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
-        yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
-        series,
-      }, true);
-      const title = $('anaTrendTitle');
-      if (title) title.textContent = `主指标趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`;
-    }
+  /** 趋势图表空态(与数据中台同款 empty-hint):html=null 恢复指标条+图表区 */
+  function setAnalysisEmpty(html) {
+    const grid = $('anaGrid');
+    const deltas = $('analysisDeltas');
+    const empty = $('analysisEmpty');
+    if (!grid || !empty) return;
+    deltas.hidden = !!html;
+    grid.hidden = !!html;
+    empty.hidden = !html;
+    if (html) $('analysisEmptyText').innerHTML = html;
+  }
 
-    const rank = ensureChart('rank', 'chartRank');
-    if (rank) {
-      const top = data.byShop.slice()
-        .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
-        .slice(0, 10).reverse();
-      rank.setOption({
-        tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
-        grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
-        xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
-        yAxis: {
-          type: 'category', data: top.map((r) => r.shopName || r.shopId),
-          axisLabel: { color: '#2b2a28', width: 130, overflow: 'truncate' },
-          axisLine: { lineStyle: { color: '#e8e4df' } },
-        },
-        series: [{ type: 'bar', barMaxWidth: 18, data: top.map((r) => Number(r[chartMetric]) || 0), itemStyle: { color: ACCENT, borderRadius: [0, 6, 6, 0] } }],
-      }, true);
+  async function renderCharts() {
+    if (!state.entity) {
+      setAnalysisEmpty('暂无主体<br><small>请先在商家客户端登录并完成一次「同步数据」;本机也可在「数据中台」页执行「云端拉取」</small>');
+      return;
     }
-
-    const funnel = ensureChart('funnel', 'chartFunnel');
-    if (funnel) {
-      const sum = (f) => data.byDate.reduce((a, r) => a + (Number(r[f]) || 0), 0);
-      const expUv = sum('expUv');
-      const enter = data.byDate.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0);
-      const order = sum('orderUserCnt');
-      funnel.setOption({
-        tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${fmtNum(Math.round(p.value))} 人` },
-        series: [{
-          type: 'funnel', left: '8%', width: '84%', top: 12, bottom: 8, minSize: '20%',
-          label: { color: '#fff', fontWeight: 600, formatter: (p) => `${p.name}  ${fmtNum(Math.round(p.value))}` },
-          data: [
-            { name: '曝光人数', value: Math.round(expUv), itemStyle: { color: '#ffb199' } },
-            { name: '进店人数', value: Math.round(enter), itemStyle: { color: '#ff8a5c' } },
-            { name: '下单人数', value: Math.round(order), itemStyle: { color: ACCENT } },
-          ],
-        }],
-      }, true);
+    let data;
+    try { data = await ensureAnalysisData(); }
+    catch { setAnalysisEmpty('数据加载失败<br><small>请稍后重试;持续失败请查看「系统日志」</small>'); return; }
+    const tpl = activeTemplate();
+    if (!tpl) return; // 模板库(analysis-templates.js)未加载时静默退出
+    if (!data.byDate.length) {
+      disposeAllCharts();
+      $('anaGrid').innerHTML = '';
+      setAnalysisEmpty('暂无数据<br><small>所选区间没有数据;请先在「数据采集」页完成同步,或调整日期范围</small>');
+      return;
     }
+    setAnalysisEmpty(null);
+    applyTemplateControls(tpl);
+    disposeAllCharts();
+    const grid = $('anaGrid');
+    if (!grid) return;
+    grid.innerHTML = '';
+    renderDeltas(data);
+    tpl.render(grid, data);
+  }
 
-    const compare = ensureChart('compare', 'chartCompare');
-    if (compare) {
-      const top5 = data.byShop.slice()
-        .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
-        .slice(0, 5);
-      const dates = [...new Set(data.topDaily.map((r) => r.date))].sort();
-      const palette = ['#ff6b35', '#2563eb', '#1a8a4c', '#b8860b', '#8b5cf6'];
-      const series = top5.map((shop, i) => ({
-        name: shop.shopName || shop.shopId,
-        type: 'line', smooth: true, symbolSize: 4,
-        data: dates.map((d) => {
-          const r = data.topDaily.find((x) => x.date === d && String(x.shopId) === String(shop.shopId));
-          return r ? (Number(r[chartMetric]) || 0) : 0;
-        }),
-        itemStyle: { color: palette[i % palette.length] }, lineStyle: { width: 2 },
-      }));
-      compare.setOption({
-        tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
-        legend: { top: 0, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
-        grid: { left: 64, right: 24, top: 36, bottom: 30 },
-        xAxis: { type: 'category', boundaryGap: false, data: dates.map(ymdToDash), ...AXIS_TXT },
-        yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
-        series,
-      }, true);
-      const title = $('anaCompareTitle');
-      if (title) title.textContent = `门店对比 · Top ${top5.length}(${col.label})`;
-    }
+  // ---------- 模板库装配 ----------
+  // 六个内置模板(综合总览 / 环比对照 / 量价归因 / 档口问责 / 流量卡点 / 周内节律)
+  // 定义在 analysis-templates.js:kit 注入本页的格式化 / 面板构造 / 逐日序列等辅助,
+  // 模板只关心「对比方式 + 指标组合 + 图表布局」,新增模板无需改动本文件。
+
+  function buildTemplateKit() {
+    return {
+      chartCol,
+      metric: () => chartMetric,
+      comparePrev: () => !!($('anaComparePrev') && $('anaComparePrev').checked),
+      fmtNum, fmtMoney, fmtPct, fmtByCol, fmtCompact, ymdToDash, ymdDate, shiftYmd,
+      makePanel, panelChart, panelNote, panelEmpty, panelEmptyIn,
+      buildDailySeries, missingMarkAreas, hollowPoints, dataQualityNote,
+      sumField, wAvgField, deltaBadge, rangeOverlapsVacation,
+      fetchJson: jsonFetch,
+      benchData: ensureBenchData,
+      crossData: ensureCrossData,
+      shopFilterCount: () => state.analysisShopFilter.size,
+      RATE_THRESHOLDS, AXIS_TXT, SPLIT_LINE,
+      ACCENT, COLOR_OK, COLOR_FAIL, COLOR_INFO, COLOR_PREV,
+    };
   }
 
+  const TEMPLATES = window.shanAnalysisTemplates ? window.shanAnalysisTemplates(buildTemplateKit()) : [];
+
   // ---------- 智能问数 · 机器人(仅经营分析页面) ----------
 
   // 对话历史按主体持久化到 localStorage,刷新/重开后可继续追问
@@ -1334,6 +1594,14 @@
     });
 
     // 趋势图表
+    $('anaTplBar').addEventListener('click', (event) => {
+      const btn = event.target.closest('button[data-tpl]');
+      if (!btn || btn.dataset.tpl === anaTemplate) return;
+      anaTemplate = btn.dataset.tpl;
+      LS.set('shanReports.anaTemplate', anaTemplate);
+      renderTemplateSelect();
+      renderCharts();
+    });
     $('analysisRangePreset').addEventListener('change', () => {
       state.analysisPreset = $('analysisRangePreset').value;
       anaCache = null;
@@ -1380,11 +1648,21 @@
 
   // ---------- 同步历史(数据采集页,含云端推送状态) ----------
 
+  /** 同步历史空态(与数据中台同款 empty-hint):html=null 恢复表格 */
+  function setSyncHistoryEmpty(html) {
+    const table = $('syncHistoryTable');
+    const empty = $('syncHistoryEmpty');
+    if (!table || !empty) return;
+    table.hidden = !!html;
+    empty.hidden = !html;
+    if (html) $('syncHistoryEmptyText').innerHTML = html;
+  }
+
   function renderSyncHistory() {
     const tbody = $('syncHistoryRows');
     if (!tbody) return;
     if (!state.entity) {
-      tbody.innerHTML = '<tr><td colspan="8" class="empty-cell">暂无主体</td></tr>';
+      setSyncHistoryEmpty('暂无主体<br><small>请先在商家客户端登录并完成一次「同步数据」</small>');
       return;
     }
     const entityKey = encodeURIComponent(state.entity);
@@ -1395,9 +1673,10 @@
       .then(([data, cloud]) => {
         const runs = (data.runs || []).slice(0, 8);
         if (!runs.length) {
-          tbody.innerHTML = '<tr><td colspan="8" class="empty-cell">暂无同步记录</td></tr>';
+          setSyncHistoryEmpty('暂无同步记录<br><small>使用右上角「同步数据」开始第一次同步</small>');
           return;
         }
+        setSyncHistoryEmpty(null);
         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: '按门店' };
@@ -1425,7 +1704,7 @@
         }).join('');
       })
       .catch(() => {
-        tbody.innerHTML = '<tr><td colspan="8" class="empty-cell">同步历史加载失败</td></tr>';
+        setSyncHistoryEmpty('同步历史加载失败<br><small>请稍后使用页面右上角「刷新」重试</small>');
       });
   }
 
@@ -1491,6 +1770,7 @@
       } catch { state.columns = []; }
       loadColumnPrefs();
       renderChartMetricSelect();
+      renderTemplateSelect();
     }
     await refreshConn();
     refreshPushBadge();
@@ -1501,6 +1781,7 @@
   window.shanAnalysisActivate = async function () {
     await window.shanReportsInit();
     renderChartMetricSelect();
+    renderTemplateSelect();
     await renderCharts();
     Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
   };