'use strict'; /** * 趋势图表 · 分析模板库 * ---------------------------------------------------------------------------- * 与页面主逻辑(reports.js)解耦:本文件只定义「模板 = 对比方式 + 指标组合 + * 图表布局」的预设。模板与看谁(整校/某食堂/某档口)正交,对象由主体选择器 * 与门店筛选决定。 * * 依赖注入:reports.js 调用 shanAnalysisTemplates(kit) 取回注册表。kit 提供 * 格式化 / 面板与图表构造 / 逐日序列与数据质量标注 / 汇总辅助 / 颜色常量, * 以及当前主指标 metric() 与「对比上期」开关 comparePrev() 的读取函数。 * * 新增模板:在此追加 render 函数并往返回数组注册一项,reports.js 无需改动。 */ (function () { window.shanAnalysisTemplates = function (k) { const { chartCol, metric, comparePrev, fmtNum, fmtMoney, fmtPct, fmtByCol, fmtCompact, ymdToDash, ymdDate, shiftYmd, makePanel, panelChart, panelNote, panelEmpty, panelEmptyIn, buildDailySeries, missingMarkAreas, hollowPoints, dataQualityNote, sumField, wAvgField, deltaBadge, rangeOverlapsVacation, benchData, crossData, shopFilterCount, RATE_THRESHOLDS, AXIS_TXT, SPLIT_LINE, ACCENT, COLOR_OK, COLOR_FAIL, COLOR_INFO, COLOR_PREV, } = k; const WEEKDAY_CN = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; // ---------- 模板一:综合总览(原有四图,保持默认行为) ---------- function renderOverviewTpl(grid, data) { const chartMetric = metric(); const col = chartCol(chartMetric); const compareOn = comparePrev(); const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1)); const trendPanel = makePanel(grid, '', 2); const trendTitle = document.createElement('span'); trendTitle.textContent = `主指标趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`; trendPanel.head.appendChild(trendTitle); dataQualityNote(trendPanel, buildDailySeries(data, chartMetric)); const trend = panelChart(trendPanel, 'trend'); if (trend) { const series = buildDailySeries(data, chartMetric); const xData = series.dates.map(ymdToDash); const lines = [{ name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5, data: hollowPoints(series), itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 }, areaStyle: { color: 'rgba(255,107,53,.08)' }, }]; const areas = missingMarkAreas(series); if (areas.length) lines[0].markArea = { silent: true, data: areas }; if (compareOn && prevSorted.length) { lines.push({ name: '上期同期', type: 'line', smooth: true, symbol: 'none', data: series.dates.map((_, i) => (prevSorted[i] ? Number(prevSorted[i][chartMetric]) || 0 : null)), itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.6 }, }); } trend.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) }, legend: { data: lines.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: lines, }, true); } const rankPanel = makePanel(grid, '门店排行 Top 10', 1); const rank = panelChart(rankPanel, 'rank'); 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); } const funnelPanel = makePanel(grid, '转化漏斗(曝光 → 进店 → 下单)', 1); const funnel = panelChart(funnelPanel, 'funnel'); if (funnel) { const expUv = sumField(data.byDate, 'expUv'); const enter = data.byDate.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0); const order = sumField(data.byDate, '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); } const comparePanel = makePanel(grid, '', 2); const compareTitle = document.createElement('span'); comparePanel.head.appendChild(compareTitle); const compare = panelChart(comparePanel, 'compare'); const top5 = data.byShop.slice() .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0)) .slice(0, 5); compareTitle.textContent = `门店对比 · Top ${top5.length}(${col.label})`; if (compare && top5.length) { 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); } } // ---------- 模板二:环比对照 ---------- function buildMomTable(data) { const cur = data.byDate; const prev = data.byDatePrev; const curO = sumField(cur, 'validOrderCnt'); const prevO = sumField(prev, 'validOrderCnt'); const defs = [ { label: '营业额', cur: sumField(cur, 'grossAmt'), prev: sumField(prev, 'grossAmt'), fmt: fmtMoney }, { label: '收入', cur: sumField(cur, 'incomeAmt'), prev: sumField(prev, 'incomeAmt'), fmt: fmtMoney }, { label: '支出', cur: sumField(cur, 'expenseAmt'), prev: sumField(prev, 'expenseAmt'), fmt: fmtMoney }, { label: '有效订单', cur: curO, prev: prevO, fmt: fmtNum }, { label: '曝光人数', cur: sumField(cur, 'expUv'), prev: sumField(prev, 'expUv'), fmt: fmtNum }, { label: '下单人数', cur: sumField(cur, 'orderUserCnt'), prev: sumField(prev, 'orderUserCnt'), fmt: fmtNum }, { label: '客单价', cur: curO > 0 ? sumField(cur, 'grossAmt') / curO : 0, prev: prevO > 0 ? sumField(prev, 'grossAmt') / prevO : 0, fmt: fmtMoney }, { label: '进店转化率', cur: wAvgField(cur, 'clkExpRate', 'expUv'), prev: wAvgField(prev, 'clkExpRate', 'expUv'), fmt: fmtPct }, { label: '下单转化率', cur: wAvgField(cur, 'ordClkRate', 'orderUserCnt'), prev: wAvgField(prev, 'ordClkRate', 'orderUserCnt'), fmt: fmtPct }, { label: '活动订单', cur: sumField(cur, 'actOrdCnt'), prev: sumField(prev, 'actOrdCnt'), fmt: fmtNum }, { label: '活动补贴', cur: sumField(cur, 'actSubsidyAmt'), prev: sumField(prev, 'actSubsidyAmt'), fmt: fmtMoney }, { label: '30日复购率', cur: wAvgField(cur, 'rebuyRate30d', 'orderUserCnt'), prev: wAvgField(prev, 'rebuyRate30d', 'orderUserCnt'), fmt: fmtPct }, ]; const rowsHtml = defs.map((d) => ` ${d.label} ${d.fmt(Math.round(d.cur * 100) / 100)} ${d.fmt(Math.round(d.prev * 100) / 100)} ${deltaBadge(d.cur, d.prev)} `).join(''); return `${rowsHtml}
指标本期上期环比
`; } function renderMomTpl(grid, data) { const chartMetric = metric(); const col = chartCol(chartMetric); const vacNames = [...new Set([...rangeOverlapsVacation(data.from, data.to), ...rangeOverlapsVacation(data.prevFrom, data.prevTo)])]; const series = buildDailySeries(data, chartMetric); const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1)); const prevVals = series.dates.map((_, i) => (prevSorted[i] ? Number(prevSorted[i][chartMetric]) || 0 : null)); const diffs = series.values.map((v, i) => (v == null || prevVals[i] == null ? null : +(v - prevVals[i]).toFixed(2))); const head = makePanel(grid, '', 2); const title = document.createElement('span'); title.textContent = `环比对照 · ${col.label}(本期 ${ymdToDash(data.from)}~${ymdToDash(data.to)} vs 上期 ${ymdToDash(data.prevFrom)}~${ymdToDash(data.prevTo)})`; head.head.appendChild(title); if (vacNames.length) { const chip = document.createElement('span'); chip.className = 'badge badge-warn'; chip.textContent = `对比区间跨${vacNames.join('、')}`; chip.title = '假期前后环比受学期节奏影响(寒/暑假营业基数骤降),涨跌请结合更长区间或去年同期判断'; head.head.appendChild(chip); } dataQualityNote(head, series); const chart = panelChart(head, 'momTrend'); if (chart) { const xData = series.dates.map(ymdToDash); const lines = [ { name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5, data: hollowPoints(series), itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 } }, { name: '上期同期', type: 'line', smooth: true, symbol: 'none', data: prevVals, itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.6 } }, ]; const areas = missingMarkAreas(series); if (areas.length) lines[0].markArea = { silent: true, data: areas }; chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) }, legend: { data: [...lines.map((x) => x.name), '逐日差值'], top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } }, grid: { left: 64, right: 64, top: 36, bottom: 30 }, xAxis: { type: 'category', data: xData, ...AXIS_TXT }, yAxis: [ { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE }, { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { show: false } }, ], series: [ ...lines, { name: '逐日差值', type: 'bar', yAxisIndex: 1, barMaxWidth: 10, data: diffs.map((v) => (v == null ? null : { value: v, itemStyle: { color: v >= 0 ? 'rgba(26,138,76,.55)' : 'rgba(200,64,46,.55)' } })), }, ], }, true); } const tbl = makePanel(grid, '指标环比一览', 1); const wrap = document.createElement('div'); wrap.className = 'ana-table-scroll'; wrap.innerHTML = buildMomTable(data); tbl.cell.appendChild(wrap); } // ---------- 模板三:量价归因 ---------- // 营业额 = 曝光人数 × 进店转化 × 下单转化 × 人均单量 × 客单价(乘积链严格闭合) // 贡献拆解用 LMDI(对数平均迪氏分解):各因子贡献之和恰好等于总变化,无残差。 function attrFactors(rows) { const E = sumField(rows, 'expUv'); const enter = rows.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0); const U = sumField(rows, 'orderUserCnt'); const O = sumField(rows, 'validOrderCnt'); const G = sumField(rows, 'grossAmt'); return { E, C1: E > 0 ? enter / E : 0, C2: enter > 0 ? U / enter : 0, F: U > 0 ? O / U : 0, P: O > 0 ? G / O : 0, G }; } function logMean(a, b) { if (a <= 0 || b <= 0) return (a + b) / 2; return a === b ? a : (a - b) / (Math.log(a) - Math.log(b)); } function renderAttributionTpl(grid, data) { const f0 = attrFactors(data.byDatePrev); const f1 = attrFactors(data.byDate); const factors = [ { label: '流量·曝光人数', f0: f0.E, f1: f1.E, fmt: fmtNum }, { label: '进店转化率', f0: f0.C1, f1: f1.C1, fmt: fmtPct }, { label: '下单转化率', f0: f0.C2, f1: f1.C2, fmt: fmtPct }, { label: '人均单量', f0: f0.F, f1: f1.F, fmt: (v) => Number(v).toFixed(2) }, { label: '客单价', f0: f0.P, f1: f1.P, fmt: fmtMoney }, ]; const decomposable = f0.G > 0 && f1.G > 0 && factors.every((x) => x.f0 > 0 && x.f1 > 0); if (decomposable) { const L = logMean(f1.G, f0.G); for (const x of factors) x.contrib = L * Math.log(x.f1 / x.f0); } const panel = makePanel(grid, '', 2); const title = document.createElement('span'); title.textContent = `量价归因 · 营业额(本期 ${ymdToDash(data.from)}~${ymdToDash(data.to)} vs 上期)`; panel.head.appendChild(title); const chart = panelChart(panel, 'attrWf'); if (chart) { if (decomposable) { const cats = ['上期营业额', ...factors.map((x) => x.label), '本期营业额']; let cum = f0.G; const bases = [0]; const bars = [{ value: +f0.G.toFixed(2), itemStyle: { color: COLOR_PREV }, label: { show: true, position: 'top', fontSize: 10, color: '#6b6560', formatter: fmtCompact(f0.G) } }]; for (const x of factors) { const c = x.contrib; bases.push(+Math.min(cum, cum + c).toFixed(2)); bars.push({ value: +c.toFixed(2), itemStyle: { color: c >= 0 ? COLOR_OK : COLOR_FAIL }, // 贡献柱相对总额很细,靠标签读数(精确值见右侧因子明细表) label: { show: true, position: 'top', fontSize: 10, color: c >= 0 ? COLOR_OK : COLOR_FAIL, formatter: (c >= 0 ? '+' : '') + fmtCompact(c) }, }); cum += c; } bases.push(0); bars.push({ value: +f1.G.toFixed(2), itemStyle: { color: ACCENT }, label: { show: true, position: 'top', fontSize: 11, color: '#2b2a28', fontWeight: 600, formatter: fmtCompact(f1.G) } }); chart.setOption({ tooltip: { trigger: 'axis', formatter: (ps) => { const p = ps.find((x) => x.seriesName === '贡献'); if (!p) return ''; const v = Number(p.value); if (p.dataIndex === 0 || p.dataIndex === cats.length - 1) return `${cats[p.dataIndex]}:${fmtMoney(v)} 元`; return `${cats[p.dataIndex]}:贡献 ${v >= 0 ? '+' : ''}${fmtMoney(v)} 元`; }, }, grid: { left: 72, right: 20, top: 34, bottom: 52 }, xAxis: { type: 'category', data: cats, ...AXIS_TXT, axisLabel: { ...AXIS_TXT.axisLabel, interval: 0, rotate: 18 } }, yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE }, series: [ { name: '底', type: 'bar', stack: 'wf', barWidth: 34, itemStyle: { color: 'transparent' }, emphasis: { itemStyle: { color: 'transparent' } }, tooltip: { show: false }, data: bases }, { name: '贡献', type: 'bar', stack: 'wf', barWidth: 34, data: bars }, ], }, true); } else { chart.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => fmtMoney(v) + ' 元' }, grid: { left: 72, right: 20, top: 30, bottom: 30 }, xAxis: { type: 'category', data: ['上期营业额', '本期营业额'], ...AXIS_TXT }, yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE }, series: [{ type: 'bar', barWidth: 50, data: [ { value: +f0.G.toFixed(2), itemStyle: { color: COLOR_PREV } }, { value: +f1.G.toFixed(2), itemStyle: { color: ACCENT } }, ] }], }, true); panelNote(panel, '上期或本期存在零值因子,无法做乘法拆解,仅展示两期总额'); } } const tbl = makePanel(grid, '因子明细', 1); const wrap = document.createElement('div'); wrap.className = 'ana-table-scroll'; const dG = f1.G - f0.G; const rowsHtml = factors.map((x) => { const chg = x.f0 > 0 ? (x.f1 - x.f0) / x.f0 : null; const contrib = decomposable ? x.contrib : null; return ` ${x.label} ${x.fmt(x.f0)} ${x.fmt(x.f1)} ${chg == null ? '—' : `${chg >= 0 ? '↑' : '↓'} ${Math.abs(chg * 100).toFixed(1)}%`} ${contrib == null ? '—' : (contrib >= 0 ? '+' : '') + fmtMoney(contrib)} ${contrib == null || !dG ? '—' : Math.round(Math.abs(contrib / dG) * 100) + '%'} `; }).join(''); wrap.innerHTML = `${rowsHtml}
因子上期本期变化贡献(元)占Δ
营业额${fmtMoney(f0.G)}${fmtMoney(f1.G)} ${deltaBadge(f1.G, f0.G)} ${dG >= 0 ? '+' : ''}${fmtMoney(dG)}100%
`; tbl.cell.appendChild(wrap); } // ---------- 模板四:档口问责 ---------- function renderAccountabilityTpl(grid, data) { const chartMetric = metric(); const col = chartCol(chartMetric); const curMap = new Map(data.byShop.map((r) => [String(r.shopId), r])); const prevMap = new Map(data.byShopPrev.map((r) => [String(r.shopId), r])); const shops = [...new Set([...curMap.keys(), ...prevMap.keys()])].map((key) => { const cur = curMap.get(key) || null; const prev = prevMap.get(key) || null; const curV = cur ? Number(cur[chartMetric]) || 0 : null; const prevV = prev ? Number(prev[chartMetric]) || 0 : null; return { key, name: (cur && cur.shopName) || (prev && prev.shopName) || key, curV, prevV, delta: curV != null && prevV != null ? curV - prevV : null, pay: Number((cur || prev || {}).perActualPay) || 0, }; }); if (!shops.length) { panelEmpty(grid, '暂无档口明细数据(需按门店粒度同步)', 3); return; } const hasPrev = shops.some((s) => (s.prevV || 0) > 0); const newShops = shops.filter((s) => s.prevV == null && s.curV != null).length; // 涨跌榜(按 Δ 排序,而非绝对值——直接回答"谁在涨谁在跌") const rankPanel = makePanel(grid, hasPrev ? `档口涨跌榜 · ${col.label} 对上期` : `档口排行 · ${col.label}`, 1); if (!hasPrev) panelNote(rankPanel, '上期无有效数据,暂展示本期排行'); else if (newShops) panelNote(rankPanel, `${newShops} 家档口上期无数据(新增),未纳入涨跌对比`); const rank = panelChart(rankPanel, 'accDelta'); if (rank) { if (hasPrev) { let list = shops.filter((s) => s.delta != null).sort((a, b) => b.delta - a.delta); if (list.length > 14) list = [...list.slice(0, 7), ...list.slice(-7)]; const ordered = list.slice().reverse(); // echarts category 第 0 项在底部 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: ordered.map((s) => s.name), axisLabel: { color: '#2b2a28', width: 96, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } }, }, series: [{ type: 'bar', barMaxWidth: 14, data: ordered.map((s) => ({ value: +s.delta.toFixed(2), itemStyle: { color: s.delta >= 0 ? COLOR_OK : COLOR_FAIL, borderRadius: s.delta >= 0 ? [0, 6, 6, 0] : [6, 0, 0, 6] }, })), }], }, true); } else { const top = shops.filter((s) => s.curV != null).sort((a, b) => b.curV - a.curV).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((s) => s.name), axisLabel: { color: '#2b2a28', width: 96, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } }, }, series: [{ type: 'bar', barMaxWidth: 14, data: top.map((s) => +s.curV.toFixed(2)), itemStyle: { color: ACCENT, borderRadius: [0, 6, 6, 0] } }], }, true); } } // 帕累托:档口贡献 + 累计占比(80/20 线) const parPanel = makePanel(grid, `档口贡献帕累托 · ${col.label}`, 2); const par = panelChart(parPanel, 'accPareto'); if (par) { const sorted = shops.filter((s) => (s.curV || 0) > 0).sort((a, b) => b.curV - a.curV); const total = sorted.reduce((a, s) => a + s.curV, 0); let cum = 0; const cumPct = sorted.map((s) => { cum += s.curV; return total > 0 ? +(cum / total * 100).toFixed(1) : 0; }); par.setOption({ tooltip: { trigger: 'axis', formatter: (ps) => { const idx = ps[0].dataIndex; return `${sorted[idx].name}
${col.label}:${fmtByCol(col, sorted[idx].curV)}
累计占比:${cumPct[idx]}%`; }, }, grid: { left: 10, right: 40, top: 30, bottom: 64, containLabel: true }, xAxis: { type: 'category', data: sorted.map((s) => s.name), ...AXIS_TXT, axisLabel: { ...AXIS_TXT.axisLabel, interval: 0, rotate: 26, width: 100, overflow: 'truncate' } }, yAxis: [ { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE }, { type: 'value', max: 100, axisLabel: { color: '#6b6560', formatter: '{value}%' }, splitLine: { show: false } }, ], series: [ { name: col.label, type: 'bar', barMaxWidth: 18, data: sorted.map((s) => +s.curV.toFixed(2)), itemStyle: { color: ACCENT, borderRadius: [6, 6, 0, 0] } }, { name: '累计占比', type: 'line', yAxisIndex: 1, data: cumPct, symbolSize: 4, itemStyle: { color: COLOR_INFO }, lineStyle: { width: 1.8 }, markLine: { symbol: 'none', data: [{ yAxis: 80 }], lineStyle: { type: 'dashed', color: COLOR_PREV }, label: { formatter: '80%', color: '#6b6560' } }, }, ], }, true); } // 四象限:体量 × 环比增速,气泡 = 客单价 const quadPanel = makePanel(grid, '档口四象限(体量 × 环比增速)', 2); const pts = shops.filter((s) => s.prevV != null && s.prevV > 0 && s.curV != null); const excluded = shops.filter((s) => !((s.prevV || 0) > 0) && (s.curV || 0) > 0).length; if (excluded) panelNote(quadPanel, `${excluded} 家档口上期无有效数据,未纳入四象限`); const quad = panelChart(quadPanel, 'accQuad'); if (quad && pts.length) { const ratio = (s) => (s.curV - s.prevV) / s.prevV; const CLAMP = 3; // 增速轴截断 ±300%,避免上期近零的档口把整张图压扁 if (pts.some((s) => Math.abs(ratio(s)) > CLAMP)) panelNote(quadPanel, '增速轴截断于 ±300%,极值请悬停查看'); const curVals = pts.map((s) => s.curV).sort((a, b) => a - b); const median = curVals.length % 2 ? curVals[(curVals.length - 1) / 2] : (curVals[curVals.length / 2 - 1] + curVals[curVals.length / 2]) / 2; const avgPay = pts.reduce((a, s) => a + s.pay, 0) / pts.length; quad.setOption({ tooltip: { formatter: (p) => { const s = p.data.meta; return `${s.name}
本期 ${fmtByCol(col, s.curV)}
上期 ${fmtByCol(col, s.prevV)}
环比 ${(ratio(s) * 100).toFixed(1)}%${s.pay ? `
客单价 ${fmtMoney(s.pay)}` : ''}`; }, }, grid: { left: 64, right: 30, top: 30, bottom: 46 }, xAxis: { type: 'value', name: `本期${col.label}`, nameLocation: 'middle', nameGap: 28, nameTextStyle: { color: '#6b6560' }, axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE, axisLine: { show: true, lineStyle: { color: '#e8e4df' } }, }, yAxis: { type: 'value', name: '环比', nameTextStyle: { color: '#6b6560' }, axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE }, series: [{ type: 'scatter', data: pts.map((s) => ({ value: [s.curV, Math.max(-CLAMP, Math.min(CLAMP, ratio(s)))], meta: s, symbolSize: Math.max(8, Math.min(42, 8 + Math.sqrt(avgPay > 0 ? s.pay / avgPay : 1) * 10)), itemStyle: { color: 'rgba(255,107,53,.75)', borderColor: '#fff', borderWidth: 1 }, })), markLine: { symbol: 'none', lineStyle: { color: COLOR_PREV, type: 'dashed' }, label: { color: '#6b6560' }, data: [ { xAxis: +median.toFixed(2), label: { formatter: '体量中位' } }, { yAxis: 0, label: { formatter: '持平' } }, ], }, }], graphic: [ { type: 'text', right: 40, top: 32, style: { text: '明星 · 量大在涨', fill: '#a5a09a', fontSize: 11 } }, { type: 'text', left: 70, top: 32, style: { text: '潜力 · 量小在涨', fill: '#a5a09a', fontSize: 11 } }, { type: 'text', right: 40, bottom: 50, style: { text: '现金牛 · 量大微跌', fill: '#a5a09a', fontSize: 11 } }, { type: 'text', left: 70, bottom: 50, style: { text: '观察 · 量小在跌', fill: '#a5a09a', fontSize: 11 } }, ], }, true); } else if (quad) { panelEmptyIn(quadPanel, '上期无有效数据,无法计算环比增速'); } const helpPanel = makePanel(grid, '象限怎么用', 1); const helpBox = document.createElement('ul'); helpBox.className = 'ana-help'; helpBox.innerHTML = `
  • 明星(右上):体量 ≥ 中位且在涨 —— 保供给保体验,优先投资源
  • 潜力(左上):量小在涨 —— 加曝光、试推广、扩菜品
  • 现金牛(右下):量大但跌 —— 查菜品结构与评价维护
  • 观察(左下):量小且跌 —— 评估调整或汰换
  • 气泡大小=客单价;虚线=体量中位数 / 持平线
  • `; helpPanel.cell.appendChild(helpBox); } // ---------- 模板五:流量卡点 ---------- function renderFunnelTpl(grid, data) { const clkSeries = buildDailySeries(data, 'clkExpRate'); const ordSeries = buildDailySeries(data, 'ordClkRate'); const xData = clkSeries.dates.map(ymdToDash); const ratePanel = makePanel(grid, '转化率日趋势(虚线=健康基准,浅红区=偏低区)', 2); dataQualityNote(ratePanel, clkSeries); const rateChart = panelChart(ratePanel, 'funRates'); if (rateChart) { const areas = [ [{ xAxis: xData[0], yAxis: 0, itemStyle: { color: 'rgba(200,64,46,.04)' } }, { xAxis: xData[xData.length - 1], yAxis: RATE_THRESHOLDS.clkExpRate.bad }], ...missingMarkAreas(clkSeries), ]; rateChart.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => fmtPct(v) }, legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } }, grid: { left: 56, right: 20, top: 36, bottom: 30 }, xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT }, yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(1) + '%' }, splitLine: SPLIT_LINE }, series: [ { name: '进店转化率', type: 'line', smooth: true, symbolSize: 4, connectNulls: false, data: hollowPoints(clkSeries), itemStyle: { color: ACCENT }, lineStyle: { width: 2 }, markLine: { symbol: 'none', lineStyle: { type: 'dashed', color: ACCENT, opacity: 0.55 }, label: { formatter: '健康线 9%', color: '#6b6560', position: 'insideEndTop' }, data: [{ yAxis: 0.09 }] }, markArea: { silent: true, data: areas }, }, { name: '下单转化率', type: 'line', smooth: true, symbolSize: 4, connectNulls: false, data: hollowPoints(ordSeries), itemStyle: { color: COLOR_INFO }, lineStyle: { width: 2 }, markLine: { symbol: 'none', lineStyle: { type: 'dashed', color: COLOR_INFO, opacity: 0.55 }, label: { formatter: '健康线 2.5%', color: '#6b6560', position: 'insideEndBottom' }, data: [{ yAxis: 0.025 }] }, }, ], }, true); } // 卡点诊断卡:率值对照健康基准,量值对照上期 const diagPanel = makePanel(grid, '卡点诊断', 1); const box = document.createElement('div'); box.className = 'diag-steps'; diagPanel.cell.appendChild(box); const curClk = wAvgField(data.byDate, 'clkExpRate', 'expUv'); const prevClk = wAvgField(data.byDatePrev, 'clkExpRate', 'expUv'); const curOrd = wAvgField(data.byDate, 'ordClkRate', 'orderUserCnt'); const prevOrd = wAvgField(data.byDatePrev, 'ordClkRate', 'orderUserCnt'); const curExp = sumField(data.byDate, 'expUv'); const prevExp = sumField(data.byDatePrev, 'expUv'); function rateStatus(key, v) { const t = RATE_THRESHOLDS[key]; if (!(v > 0)) return { cls: 'badge badge-info', text: '无数据' }; if (v >= t.good) return { cls: 'badge badge-ok', text: '达标' }; if (v < t.bad) return { cls: 'badge badge-fail', text: '明显偏低' }; return { cls: 'badge badge-warn', text: '偏低' }; } const expDelta = prevExp > 0 ? (curExp - prevExp) / prevExp : null; const expBadge = expDelta == null ? '上期无数据' : Math.abs(expDelta) < 0.02 ? '持平' : `${expDelta > 0 ? '↑' : '↓'} ${Math.abs(expDelta * 100).toFixed(1)}%`; box.innerHTML = `
    ① 曝光人数(量)${expBadge}
    ${fmtNum(curExp)}
    上期 ${fmtNum(prevExp)} —— 瓶颈在平台流量侧时看这里
    ② 进店转化率${rateStatus('clkExpRate', curClk).text}
    ${fmtPct(curClk)}
    健康线 ≥9% · 上期 ${fmtPct(prevClk)}
    ③ 下单转化率${rateStatus('ordClkRate', curOrd).text}
    ${fmtPct(curOrd)}
    健康线 ≥2.5% · 上期 ${fmtPct(prevOrd)}
    `; const gaps = [ { key: 'clkExpRate', label: '进店转化率', v: curClk, good: RATE_THRESHOLDS.clkExpRate.good }, { key: 'ordClkRate', label: '下单转化率', v: curOrd, good: RATE_THRESHOLDS.ordClkRate.good }, ].filter((g) => g.v > 0 && g.v < g.good); let conclusion; if (gaps.length) { gaps.sort((a, b) => a.v / a.good - b.v / b.good); const g = gaps[0]; const advice = g.key === 'clkExpRate' ? '优先优化门头图/招牌/活动利益点' : '优先优化菜单结构、价格带与评价维护'; conclusion = `最大卡点:${g.label}(${fmtPct(g.v)},距健康线还差 ${fmtPct(g.good - g.v)})—— ${advice}`; } else if (prevExp > 0 && curExp < prevExp * 0.9) { conclusion = `两步转化均达标,但曝光较上期降 ${Math.round((1 - curExp / prevExp) * 100)}% —— 瓶颈更可能在平台流量侧,关注排名/推广/评分`; } else { conclusion = '两步转化均达标、曝光未见明显下滑,暂无明显卡点'; } const concl = document.createElement('div'); concl.className = 'diag-conclusion'; concl.textContent = conclusion; diagPanel.cell.appendChild(concl); // 三个量级日趋势:曝光 / 下单人数 / 客单价(含上期对照) const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1)); const minis = [ { field: 'expUv', label: '曝光人数', fmt: fmtNum }, { field: 'orderUserCnt', label: '下单人数', fmt: fmtNum }, { field: 'perActualPay', label: '客单价', fmt: fmtMoney }, ]; for (const m of minis) { const p = makePanel(grid, `${m.label}日趋势`, 1); const c = panelChart(p, 'funMini_' + m.field); if (!c) continue; const s = buildDailySeries(data, m.field); c.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => m.fmt(v) }, legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } }, grid: { left: 56, right: 16, top: 30, bottom: 26 }, xAxis: { type: 'category', boundaryGap: false, data: s.dates.map(ymdToDash), ...AXIS_TXT }, yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE }, series: [ { name: '本期', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: hollowPoints(s), itemStyle: { color: ACCENT }, lineStyle: { width: 2 } }, { name: '上期', type: 'line', smooth: true, symbol: 'none', data: s.dates.map((_, i) => (prevSorted[i] ? Number(prevSorted[i][m.field]) || 0 : null)), itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.4 } }, ], }, true); } } // ---------- 模板六:周内节律 ---------- function renderRhythmTpl(grid, data) { const chartMetric = metric(); const col = chartCol(chartMetric); const byDate = new Map(data.byDate.map((r) => [r.date, Number(r[chartMetric]) || 0])); // 周次行:区间内每个周一一行(首尾不足一周的照常显示,缺数留白) const weekStarts = []; const dowOf = (ymd) => (ymdDate(ymd).getDay() + 6) % 7; // 0=周一 let ws = shiftYmd(data.from, -dowOf(data.from)); for (; ws <= data.to; ws = shiftYmd(ws, 7)) weekStarts.push(ws); const weeks = weekStarts.slice().reverse(); // 最新一周显示在最上 const rowOf = new Map(weekStarts.map((w, i) => [w, weeks.length - 1 - i])); const cells = []; for (const w of weekStarts) { for (let di = 0; di < 7; di++) { const d = shiftYmd(w, di); if (d < data.from || d > data.to) continue; if (!byDate.has(d)) continue; cells.push([di, rowOf.get(w), byDate.get(d)]); } } const max = Math.max(0, ...cells.map((c) => c[2])); const hmPanel = makePanel(grid, `周内节律热力图 · ${col.label}(色深=数值高,留白=缺数)`, 2); const hm = panelChart(hmPanel, 'rhythmHm'); if (hm) { hm.setOption({ tooltip: { formatter: (p) => { const d = shiftYmd(weeks[p.value[1]], p.value[0]); return `${ymdToDash(d)} ${WEEKDAY_CN[p.value[0]]}
    ${col.label}:${fmtByCol(col, p.value[2])}`; }, }, grid: { left: 62, right: 20, top: 12, bottom: 64 }, xAxis: { type: 'category', data: WEEKDAY_CN, splitArea: { show: true }, ...AXIS_TXT }, yAxis: { type: 'category', data: weeks.map(ymdToDash), ...AXIS_TXT }, visualMap: { min: 0, max: max > 0 ? max : 1, calculable: false, orient: 'horizontal', left: 'center', bottom: 0, text: ['高', '低'], textStyle: { color: '#6b6560' }, inRange: { color: ['#f0ede8', '#ffd2b8', '#ff8a5c', '#e0501c'] }, }, series: [{ type: 'heatmap', data: cells, itemStyle: { borderColor: '#fff', borderWidth: 1, borderRadius: 2 }, }], }, true); if (max <= 0) panelNote(hmPanel, '区间内无有效数据'); } const dayPanel = makePanel(grid, '周内均值', 1); const dayChart = panelChart(dayPanel, 'rhythmDay'); if (dayChart) { const buckets = Array.from({ length: 7 }, () => []); for (const [d, v] of byDate) buckets[dowOf(d)].push(v); const avg = buckets.map((list) => (list.length ? +(list.reduce((a, b) => a + b, 0) / list.length).toFixed(2) : 0)); dayChart.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) }, grid: { left: 56, right: 16, top: 20, bottom: 30 }, xAxis: { type: 'category', data: WEEKDAY_CN, ...AXIS_TXT }, yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE }, series: [{ type: 'bar', barMaxWidth: 22, data: avg, itemStyle: { color: ACCENT, borderRadius: [6, 6, 0, 0] } }], }, true); } } // ---------- 模板七:收入结构 ---------- function renderIncomeTpl(grid, data) { const incomeS = buildDailySeries(data, 'incomeAmt'); const expenseS = buildDailySeries(data, 'expenseAmt'); const subsidyS = buildDailySeries(data, 'actSubsidyAmt'); const ordersS = buildDailySeries(data, 'validOrderCnt'); const actOrdS = buildDailySeries(data, 'actOrdCnt'); const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1)); const prevGross = incomeS.dates.map((_, i) => (prevSorted[i] ? +(Number(prevSorted[i].incomeAmt) + Number(prevSorted[i].expenseAmt)).toFixed(2) : null)); const xData = incomeS.dates.map(ymdToDash); const rate = (num, den) => (den != null && den > 0 ? num / den : null); // 流水构成:收入 + 支出 = 营业额(堆叠面积) const structPanel = makePanel(grid, '流水构成(收入 + 支出 = 营业额)', 2); dataQualityNote(structPanel, incomeS); const structChart = panelChart(structPanel, 'incStruct'); if (structChart) { const areas = missingMarkAreas(incomeS); const series = [ { name: '收入', type: 'line', stack: 'gross', smooth: true, symbol: 'none', areaStyle: { opacity: 0.85 }, data: incomeS.values, itemStyle: { color: ACCENT }, lineStyle: { width: 1.2 } }, { name: '支出(平台扣费等)', type: 'line', stack: 'gross', smooth: true, symbol: 'none', areaStyle: { opacity: 0.85 }, data: expenseS.values, itemStyle: { color: '#b8860b' }, lineStyle: { width: 1.2 } }, { name: '上期营业额', type: 'line', smooth: true, symbol: 'none', data: prevGross, itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.6 } }, ]; if (areas.length) series[0].markArea = { silent: true, data: areas }; structChart.setOption({ tooltip: { trigger: 'axis' }, legend: { 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', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE }, series, }, true); } // 收入率(左轴)与补贴率(右轴) const ratePanel = makePanel(grid, '收入率 与 补贴率', 1); const rateChart = panelChart(ratePanel, 'incRate'); if (rateChart) { const incomeRate = incomeS.values.map((v, i) => (v == null ? null : rate(v, v + (expenseS.values[i] || 0)))); const subsidyRate = subsidyS.values.map((v, i) => (v == null ? null : rate(v, incomeS.values[i]))); rateChart.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => (v == null ? '—' : (v * 100).toFixed(1) + '%') }, legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } }, grid: { left: 48, right: 44, top: 36, bottom: 30 }, xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT }, yAxis: [ { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE }, { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(1) + '%' }, splitLine: { show: false } }, ], series: [ { name: '收入率(收入/营业额)', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: incomeRate, itemStyle: { color: COLOR_OK }, lineStyle: { width: 2 } }, { name: '补贴率(补贴/收入)', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 3, connectNulls: false, data: subsidyRate, itemStyle: { color: '#b8860b' }, lineStyle: { width: 2 } }, ], }, true); } // 活动强度:补贴额(柱,右轴)× 活动订单占比(线,左轴) const actPanel = makePanel(grid, '活动强度:补贴额 与 活动订单占比', 2); const actChart = panelChart(actPanel, 'incAct'); if (actChart) { const actShare = actOrdS.values.map((v, i) => (v == null ? null : rate(v, ordersS.values[i]))); actChart.setOption({ tooltip: { trigger: 'axis' }, legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } }, grid: { left: 48, right: 56, top: 36, bottom: 30 }, xAxis: { type: 'category', data: xData, ...AXIS_TXT }, yAxis: [ { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE }, { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: { show: false } }, ], series: [ { name: '活动订单占比', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: actShare, itemStyle: { color: COLOR_INFO }, lineStyle: { width: 2 } }, { name: '活动补贴(元)', type: 'bar', yAxisIndex: 1, barMaxWidth: 10, data: subsidyS.values, itemStyle: { color: 'rgba(184,134,11,.45)' } }, ], }, true); } // 结构一览表 const tbl = makePanel(grid, '结构一览', 1); const wrap = document.createElement('div'); wrap.className = 'ana-table-scroll'; const cur = data.byDate; const prev = data.byDatePrev; const pair = (label, c, p, fmt, isRate) => ({ label, c, p, fmt, isRate }); const defs = [ pair('营业额', sumField(cur, 'grossAmt'), sumField(prev, 'grossAmt'), fmtMoney), pair('收入', sumField(cur, 'incomeAmt'), sumField(prev, 'incomeAmt'), fmtMoney), pair('支出', sumField(cur, 'expenseAmt'), sumField(prev, 'expenseAmt'), fmtMoney), pair('收入率', rate(sumField(cur, 'incomeAmt'), sumField(cur, 'grossAmt')), rate(sumField(prev, 'incomeAmt'), sumField(prev, 'grossAmt')), fmtPct), pair('活动补贴', sumField(cur, 'actSubsidyAmt'), sumField(prev, 'actSubsidyAmt'), fmtMoney), pair('补贴率', rate(sumField(cur, 'actSubsidyAmt'), sumField(cur, 'incomeAmt')), rate(sumField(prev, 'actSubsidyAmt'), sumField(prev, 'incomeAmt')), fmtPct), pair('活动订单占比', rate(sumField(cur, 'actOrdCnt'), sumField(cur, 'validOrderCnt')), rate(sumField(prev, 'actOrdCnt'), sumField(prev, 'validOrderCnt')), fmtPct), ]; wrap.innerHTML = `${defs.map((d) => ``).join('')}
    指标本期上期环比
    ${d.label} ${d.c == null ? '—' : d.fmt(d.c)} ${d.p == null ? '—' : d.fmt(d.p)} ${d.c == null || d.p == null ? '—' : deltaBadge(d.c, d.p)}
    `; tbl.cell.appendChild(wrap); } // ---------- 模板八:跨校对比 ---------- // 读湖中全部账号(门店筛选与主体选择不作用于本模板),补上"集团层"视角。 async function renderCrossTpl(grid, data) { const chartMetric = metric(); const col = chartCol(chartMetric); const cross = await crossData(data); const ents = cross.entities; if (!ents.length) { panelEmpty(grid, '湖中暂无任何账号数据 —— 先在「数据采集」页完成同步', 3); return; } const palette = ['#ff6b35', '#2563eb', '#1a8a4c', '#b8860b', '#8b5cf6', '#0d9488', '#c2410c', '#7c3aed']; const stats = ents.map((e, idx) => { const cur = sumField(e.byDate, chartMetric); const prev = sumField(e.byDatePrev, chartMetric); return { ...e, color: palette[idx % palette.length], cur, prev, days: new Set(e.byDate.map((r) => r.date)).size, orders: sumField(e.byDate, 'validOrderCnt'), gross: sumField(e.byDate, 'grossAmt'), }; }); if (ents.length === 1) panelNote(makePanel(grid, '', 3), '湖中目前只有 1 个有数据的账号 —— 多账号同步后对比自动生效'); // 各校日趋势 const dates = [...new Set(ents.flatMap((e) => e.byDate.map((r) => r.date)))].sort(); const trendPanel = makePanel(grid, `各账号日趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`, 2); const trendChart = panelChart(trendPanel, 'crossTrend'); if (trendChart) { trendChart.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', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE }, series: stats.map((e) => ({ name: e.name, type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: dates.map((d) => { const r = e.byDate.find((x) => x.date === d); return r ? Number(r[chartMetric]) || 0 : null; }), itemStyle: { color: e.color }, lineStyle: { width: 2 }, })), }, true); } // 本期合计 const barPanel = makePanel(grid, `本期合计 · ${col.label}`, 1); const barChart = panelChart(barPanel, 'crossBar'); if (barChart) { const sorted = stats.slice().sort((a, b) => b.cur - a.cur).reverse(); barChart.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) }, grid: { left: 10, right: 40, top: 10, bottom: 24, containLabel: true }, xAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE }, yAxis: { type: 'category', data: sorted.map((e) => e.name), axisLabel: { color: '#2b2a28', width: 96, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } }, }, series: [{ type: 'bar', barMaxWidth: 16, data: sorted.map((e) => ({ value: +e.cur.toFixed(2), itemStyle: { color: e.color, borderRadius: [0, 6, 6, 0] } })), label: { show: true, position: 'right', fontSize: 10, color: '#6b6560', formatter: (p) => fmtCompact(p.value) }, }], }, true); } // 对比明细表 const tbl = makePanel(grid, '账号对比一览', 2); const wrap = document.createElement('div'); wrap.className = 'ana-table-scroll'; wrap.innerHTML = `${stats.map((e) => ``).join('')}
    账号本期上期环比日均有效订单客单价档口数
    ${e.name} ${fmtByCol(col, e.cur)} ${fmtByCol(col, e.prev)} ${deltaBadge(e.cur, e.prev)} ${e.days > 0 ? fmtByCol(col, e.cur / e.days) : '—'} ${fmtNum(e.orders)} ${e.orders > 0 ? fmtMoney(e.gross / e.orders) : '—'} ${fmtNum(e.shopCount)}
    `; tbl.cell.appendChild(wrap); // 占比 const piePanel = makePanel(grid, `本期占比 · ${col.label}`, 1); const pieChart = panelChart(piePanel, 'crossPie'); if (pieChart) { pieChart.setOption({ tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${fmtByCol(col, p.value)}(${p.percent}%)` }, series: [{ type: 'pie', radius: ['42%', '70%'], center: ['50%', '52%'], itemStyle: { borderColor: '#fff', borderWidth: 2, borderRadius: 4 }, label: { color: '#6b6560', formatter: '{b}\n{d}%' }, data: stats.map((e) => ({ name: e.name, value: +e.cur.toFixed(2), itemStyle: { color: e.color } })), }], }, true); } } // ---------- 模板九:同行对标 ---------- // 自身值 vs 商圈同行均值/排名(raw.*_mall_avg / _mall_avg_rank,全字段落库自带)。 // 账号/多店看"合计 vs 同行均值合计";单店可看排名走势。 async function renderBenchTpl(grid, data) { const chartMetric = metric(); const col = chartCol(chartMetric); let bench; try { bench = await benchData(data); } catch { panelEmpty(grid, '同行数据加载失败,请稍后重试', 3); return; } const rows = bench.rows; if (!rows.length) { panelEmpty(grid, '暂无档口明细数据(需按门店粒度同步)', 3); return; } const isClk = chartMetric === 'clkExpRate'; const isPct = col.type === 'pct'; // 日聚合:加性指标求和;率指标按权重(进店率按曝光、其余按下单人数)加权 const byDate = new Map(); for (const r of rows) { let b = byDate.get(r.date); if (!b) { b = { vNum: 0, vDen: 0, aNum: 0, aDen: 0 }; byDate.set(r.date, b); } const w = isPct ? (isClk ? r.w1 : r.w2) : 1; b.vNum += r.v * w; b.vDen += w; if (r.avg != null && Number.isFinite(r.avg)) { b.aNum += r.avg * w; b.aDen += w; } } const dates = [...byDate.keys()].sort(); const ownVals = dates.map((d) => { const b = byDate.get(d); return isPct ? (b.vDen > 0 ? b.vNum / b.vDen : null) : b.vNum; }); const avgVals = dates.map((d) => { const b = byDate.get(d); return b.aDen > 0 ? b.aNum / b.aDen : null; }); const hasAvg = avgVals.some((v) => v != null); const xData = dates.map(ymdToDash); const headPanel = makePanel(grid, `本店 vs 商圈同行均值 · ${col.label}`, 2); if (!hasAvg) panelNote(headPanel, '当前数据未含同行均值 —— 2026-10-16 起同步的数据全字段落库自带,重新同步即可补全'); const headChart = panelChart(headPanel, 'benchTrend'); if (headChart) { const series = [ { name: `本店·${col.label}`, type: 'line', smooth: true, symbolSize: 4, connectNulls: false, data: ownVals, itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 } }, ]; if (hasAvg) { series.push({ name: '商圈同行均值', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: avgVals, itemStyle: { color: COLOR_INFO }, lineStyle: { type: 'dashed', width: 1.8 }, }); } headChart.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) }, legend: { 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', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE }, series, }, true); } // 超越率:本店 / 同行均值 - 1(逐日) const edgePanel = makePanel(grid, '超越同行幅度(逐日)', 1); const edgeChart = panelChart(edgePanel, 'benchEdge'); if (edgeChart) { if (!hasAvg) panelEmptyIn(edgePanel, '无同行均值数据'); else { const edges = dates.map((_, i) => (avgVals[i] != null && avgVals[i] > 0 ? ownVals[i] / avgVals[i] - 1 : null)); edgeChart.setOption({ tooltip: { trigger: 'axis', valueFormatter: (v) => (v == null ? '—' : (v >= 0 ? '+' : '') + (v * 100).toFixed(1) + '%') }, grid: { left: 48, right: 16, top: 20, bottom: 30 }, xAxis: { type: 'category', data: xData, ...AXIS_TXT }, yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE }, series: [{ type: 'bar', barMaxWidth: 10, data: edges.map((v) => (v == null ? null : { value: +v.toFixed(4), itemStyle: { color: v >= 0 ? COLOR_OK : COLOR_FAIL } })), }], }, true); } } // 排名:单店 = 排名日走势(名次越小越靠上);多店 = 最新日各店排名 const rankPanel = makePanel(grid, '商圈同行排名', 2); const rankRows = rows.filter((r) => r.rank != null && Number.isFinite(r.rank)); const rankChart = panelChart(rankPanel, 'benchRank'); if (rankChart && rankRows.length) { if (shopFilterCount() === 1) { const single = rankRows.slice().sort((a, b) => (a.date < b.date ? -1 : 1)); rankChart.setOption({ tooltip: { trigger: 'axis', formatter: (ps) => `${ps[0].axisValue}
    商圈排名:第 ${Math.round(ps[0].value)} 名` }, grid: { left: 56, right: 20, top: 20, bottom: 30 }, xAxis: { type: 'category', boundaryGap: false, data: single.map((r) => ymdToDash(r.date)), ...AXIS_TXT }, yAxis: { type: 'value', inverse: true, name: '名次', nameTextStyle: { color: '#6b6560' }, axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE }, series: [{ type: 'line', smooth: true, symbolSize: 4, data: single.map((r) => Math.round(r.rank)), itemStyle: { color: COLOR_INFO }, lineStyle: { width: 2 }, areaStyle: { color: 'rgba(37,99,235,.06)' } }], }, true); } else { const latest = dates.slice().reverse().find((d) => rows.some((r) => r.date === d && r.rank != null)); const list = rows.filter((r) => r.date === latest && r.rank != null).sort((a, b) => a.rank - b.rank).reverse(); panelNote(rankPanel, `取最新有排名日(${ymdToDash(latest)}),数字越小越好`); rankChart.setOption({ tooltip: { trigger: 'axis', formatter: (ps) => `${ps[0].name}
    商圈排名:第 ${Math.round(ps[0].value)} 名` }, grid: { left: 10, right: 44, top: 10, bottom: 24, containLabel: true }, xAxis: { type: 'value', inverse: true, axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE }, yAxis: { type: 'category', data: list.map((r) => r.shopName || r.shopId), axisLabel: { color: '#2b2a28', width: 110, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } }, }, series: [{ type: 'bar', barMaxWidth: 14, data: list.map((r) => ({ value: Math.round(r.rank), itemStyle: { color: r.rank <= 3 ? COLOR_OK : (r.rank >= list.length * 0.7 ? COLOR_FAIL : ACCENT) } })), label: { show: true, position: 'left', fontSize: 10, color: '#6b6560', formatter: (p) => '第' + Math.round(p.value) + '名' }, }], }, true); } } else if (rankChart) { panelEmptyIn(rankPanel, '该指标暂无同行排名数据'); } // 对标一览 const tbl = makePanel(grid, '对标一览(本期合计)', 1); const ownTotal = isPct ? rows.reduce((a, r) => { const w = isClk ? r.w1 : r.w2; return a + r.v * w; }, 0) / Math.max(1, rows.reduce((a, r) => a + (isClk ? r.w1 : r.w2), 0)) : rows.reduce((a, r) => a + r.v, 0); const avgTotal = isPct ? rows.reduce((a, r) => { if (r.avg == null) return a; const w = isClk ? r.w1 : r.w2; return a + r.avg * w; }, 0) / Math.max(1, rows.reduce((a, r) => (r.avg == null ? a : a + (isClk ? r.w1 : r.w2)), 0)) : rows.reduce((a, r) => a + (r.avg != null ? r.avg : 0), 0); const shopTotals = new Map(); for (const r of rows) shopTotals.set(r.shopName || r.shopId, (shopTotals.get(r.shopName || r.shopId) || 0) + r.v); const ranked = [...shopTotals.entries()].sort((a, b) => b[1] - a[1]); const wrap = document.createElement('div'); wrap.className = 'ana-table-scroll'; wrap.innerHTML = `
    本店(${isPct ? '加权均值' : '合计'})${fmtByCol(col, ownTotal)}
    商圈同行均值${hasAvg ? fmtByCol(col, avgTotal) : '—'}
    超越同行${hasAvg && avgTotal > 0 ? `${ownTotal >= avgTotal ? '↑' : '↓'} ${Math.abs((ownTotal / avgTotal - 1) * 100).toFixed(1)}%` : '—'}
    最强档口${ranked.length ? `${ranked[0][0]}(${fmtByCol(col, ranked[0][1])})` : '—'}
    最弱档口${ranked.length > 1 ? `${ranked[ranked.length - 1][0]}(${fmtByCol(col, ranked[ranked.length - 1][1])})` : '—'}
    `; tbl.cell.appendChild(wrap); } // ---------- 注册表 ---------- return [ { id: 'overview', name: '综合总览', group: '经营视角', usesMetric: true, usesCompare: true, desc: '主指标趋势 / 门店排行 / 转化漏斗 / 门店对比,一屏总览', render: renderOverviewTpl }, { id: 'mom', name: '环比对照', group: '经营视角', usesMetric: true, desc: '本期 vs 上期:逐日对照、逐日差值与指标环比一览', render: renderMomTpl }, { id: 'attribution', name: '量价归因', group: '经营视角', desc: '营业额涨跌拆解为 流量 × 进店率 × 下单率 × 人均单量 × 客单价 的贡献', render: renderAttributionTpl }, { id: 'accountability', name: '档口问责', group: '经营视角', usesMetric: true, desc: '涨跌榜 / 贡献帕累托 / 体量×增速四象限,直接回答"谁在涨、谁在跌"', render: renderAccountabilityTpl }, { id: 'income', name: '收入结构', group: '经营视角', desc: '收入 + 支出 = 营业额 的构成、收入率与补贴率、活动强度', render: renderIncomeTpl }, { id: 'cross', name: '跨校对比', group: '经营视角', usesMetric: true, desc: '湖中全部账号并排对比(趋势 / 合计 / 占比 / 一览表);主体与门店筛选不作用于本模板', render: renderCrossTpl }, { id: 'funnel', name: '流量卡点', group: '运营视角', desc: '进店率与下单率对照健康基准(9% / 2.5%),定位漏斗卡在哪一步', render: renderFunnelTpl }, { id: 'rhythm', name: '周内节律', group: '运营视角', usesMetric: true, desc: '星期 × 周次热力图与周内均值,辅助排班与备货', render: renderRhythmTpl }, { id: 'bench', name: '同行对标', group: '运营视角', usesMetric: true, desc: '本店 vs 商圈同行均值与排名(来自全字段落库的同行字段;单店可看排名走势)', render: renderBenchTpl }, ]; }; })();