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