| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090 |
- '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 = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
- // 账号名/门店名来自外部平台数据,进 innerHTML(表格与 tooltip 默认按 HTML 渲染)前必须转义
- function esc(value) {
- const div = document.createElement('div');
- div.textContent = String(value ?? '');
- return div.innerHTML;
- }
- // ---------- 模板一:综合总览(原有四图,保持默认行为) ----------
- 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) => `<tr>
- <td>${d.label}</td>
- <td>${d.fmt(Math.round(d.cur * 100) / 100)}</td>
- <td>${d.fmt(Math.round(d.prev * 100) / 100)}</td>
- <td>${deltaBadge(d.cur, d.prev)}</td>
- </tr>`).join('');
- return `<table class="ana-table">
- <thead><tr><th>指标</th><th>本期</th><th>上期</th><th>环比</th></tr></thead>
- <tbody>${rowsHtml}</tbody></table>`;
- }
- 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 `<tr>
- <td>${x.label}</td>
- <td>${x.fmt(x.f0)}</td>
- <td>${x.fmt(x.f1)}</td>
- <td>${chg == null ? '—' : `<span class="delta ${chg >= 0 ? 'up' : 'down'}">${chg >= 0 ? '↑' : '↓'} ${Math.abs(chg * 100).toFixed(1)}%</span>`}</td>
- <td>${contrib == null ? '—' : (contrib >= 0 ? '+' : '') + fmtMoney(contrib)}</td>
- <td>${contrib == null || !dG ? '—' : Math.round(Math.abs(contrib / dG) * 100) + '%'}</td>
- </tr>`;
- }).join('');
- wrap.innerHTML = `<table class="ana-table">
- <thead><tr><th>因子</th><th>上期</th><th>本期</th><th>变化</th><th>贡献(元)</th><th>占Δ</th></tr></thead>
- <tbody>${rowsHtml}
- <tr><td><b>营业额</b></td><td>${fmtMoney(f0.G)}</td><td>${fmtMoney(f1.G)}</td>
- <td>${deltaBadge(f1.G, f0.G)}</td>
- <td>${dG >= 0 ? '+' : ''}${fmtMoney(dG)}</td><td>100%</td></tr></tbody></table>`;
- 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 `<b>${esc(sorted[idx].name)}</b><br>${col.label}:${fmtByCol(col, sorted[idx].curV)}<br>累计占比:${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 `<b>${esc(s.name)}</b><br>本期 ${fmtByCol(col, s.curV)}<br>上期 ${fmtByCol(col, s.prevV)}<br>环比 ${(ratio(s) * 100).toFixed(1)}%${s.pay ? `<br>客单价 ${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 = `
- <li><b>明星</b>(右上):体量 ≥ 中位且在涨 —— 保供给保体验,优先投资源</li>
- <li><b>潜力</b>(左上):量小在涨 —— 加曝光、试推广、扩菜品</li>
- <li><b>现金牛</b>(右下):量大但跌 —— 查菜品结构与评价维护</li>
- <li><b>观察</b>(左下):量小且跌 —— 评估调整或汰换</li>
- <li>气泡大小=客单价;虚线=体量中位数 / 持平线</li>`;
- 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 ? '<span class="badge badge-info">上期无数据</span>'
- : Math.abs(expDelta) < 0.02 ? '<span class="badge badge-ok">持平</span>'
- : `<span class="badge ${expDelta > 0 ? 'badge-ok' : 'badge-fail'}">${expDelta > 0 ? '↑' : '↓'} ${Math.abs(expDelta * 100).toFixed(1)}%</span>`;
- box.innerHTML = `
- <div class="diag-step">
- <div class="diag-step-head"><span class="diag-step-name">① 曝光人数(量)</span>${expBadge}</div>
- <div class="diag-step-val">${fmtNum(curExp)}</div>
- <div class="diag-step-meta">上期 ${fmtNum(prevExp)} —— 瓶颈在平台流量侧时看这里</div>
- </div>
- <div class="diag-step">
- <div class="diag-step-head"><span class="diag-step-name">② 进店转化率</span><span class="${rateStatus('clkExpRate', curClk).cls}">${rateStatus('clkExpRate', curClk).text}</span></div>
- <div class="diag-step-val">${fmtPct(curClk)}</div>
- <div class="diag-step-meta">健康线 ≥9% · 上期 ${fmtPct(prevClk)}</div>
- </div>
- <div class="diag-step">
- <div class="diag-step-head"><span class="diag-step-name">③ 下单转化率</span><span class="${rateStatus('ordClkRate', curOrd).cls}">${rateStatus('ordClkRate', curOrd).text}</span></div>
- <div class="diag-step-val">${fmtPct(curOrd)}</div>
- <div class="diag-step-meta">健康线 ≥2.5% · 上期 ${fmtPct(prevOrd)}</div>
- </div>`;
- 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]]}<br>${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 = `<table class="ana-table">
- <thead><tr><th>指标</th><th>本期</th><th>上期</th><th>环比</th></tr></thead>
- <tbody>${defs.map((d) => `<tr>
- <td>${d.label}</td>
- <td>${d.c == null ? '—' : d.fmt(d.c)}</td>
- <td>${d.p == null ? '—' : d.fmt(d.p)}</td>
- <td>${d.c == null || d.p == null ? '—' : deltaBadge(d.c, d.p)}</td>
- </tr>`).join('')}</tbody></table>`;
- 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 = `<table class="ana-table">
- <thead><tr><th>账号</th><th>本期</th><th>上期</th><th>环比</th><th>日均</th><th>有效订单</th><th>客单价</th><th>档口数</th></tr></thead>
- <tbody>${stats.map((e) => `<tr>
- <td>${esc(e.name)}</td>
- <td>${fmtByCol(col, e.cur)}</td>
- <td>${fmtByCol(col, e.prev)}</td>
- <td>${deltaBadge(e.cur, e.prev)}</td>
- <td>${e.days > 0 ? fmtByCol(col, e.cur / e.days) : '—'}</td>
- <td>${fmtNum(e.orders)}</td>
- <td>${e.orders > 0 ? fmtMoney(e.gross / e.orders) : '—'}</td>
- <td>${fmtNum(e.shopCount)}</td>
- </tr>`).join('')}</tbody></table>`;
- 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}<br>商圈排名:第 ${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}<br>商圈排名:第 ${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 = `<table class="ana-table">
- <tbody>
- <tr><td>本店(${isPct ? '加权均值' : '合计'})</td><td>${fmtByCol(col, ownTotal)}</td></tr>
- <tr><td>商圈同行均值</td><td>${hasAvg ? fmtByCol(col, avgTotal) : '—'}</td></tr>
- <tr><td>超越同行</td><td>${hasAvg && avgTotal > 0 ? `<span class="delta ${ownTotal >= avgTotal ? 'up' : 'down'}">${ownTotal >= avgTotal ? '↑' : '↓'} ${Math.abs((ownTotal / avgTotal - 1) * 100).toFixed(1)}%</span>` : '—'}</td></tr>
- <tr><td>最强档口</td><td>${ranked.length ? `${esc(ranked[0][0])}(${fmtByCol(col, ranked[0][1])})` : '—'}</td></tr>
- <tr><td>最弱档口</td><td>${ranked.length > 1 ? `${esc(ranked[ranked.length - 1][0])}(${fmtByCol(col, ranked[ranked.length - 1][1])})` : '—'}</td></tr>
- </tbody></table>`;
- 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 },
- ];
- };
- })();
|