'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 ``;
}
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) => `
| ${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)} |
`).join('')}
`;
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) => `
| ${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)} |
`).join('')}
`;
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 },
];
};
})();