| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731 |
- 'use strict';
- /**
- * 数据采集 + 趋势图表 · 前端逻辑
- * 数据采集:多主体 tab / 同步 / 筛选 / BI 表格 / CSV 导出
- * 趋势图表:内置分析模板(经营/运营双视角,可切换)+ 环比指标条 + 周报导出
- * 智能问数已升级为独立页面(public/ask.js + server/agent/),此处仅保留「应用到报表」落点
- */
- (function () {
- // ---------- 工具 ----------
- function showToast(msg, type) {
- const wrap = document.getElementById('toast-wrap');
- const node = document.createElement('div');
- node.className = 'toast toast-' + (type || 'success');
- node.textContent = msg;
- wrap.appendChild(node);
- setTimeout(() => { node.style.opacity = '0'; node.style.transition = 'opacity 0.3s'; }, 2600);
- setTimeout(() => { if (node.parentNode) wrap.removeChild(node); }, 3000);
- }
- async function jsonFetch(url, opts) {
- const r = await fetch(url, opts);
- const data = await r.json().catch(() => ({}));
- if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
- return data;
- }
- const z2 = (n) => String(n).padStart(2, '0');
- function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`; }
- function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
- function ymdToDash(s) { return /^\d{8}$/.test(String(s)) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : String(s || ''); }
- function dashToYmd(s) { return /^\d{4}-\d{2}-\d{2}$/.test(String(s)) ? s.replace(/-/g, '') : ''; }
- function fmtNum(v) {
- if (v == null || v === '') return '—';
- const n = Number(v);
- return Number.isFinite(n) ? n.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) : String(v);
- }
- function fmtMoney(v) {
- if (v == null || v === '') return '—';
- const n = Number(v);
- return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v);
- }
- function fmtPct(v) {
- if (v == null || v === '') return '—';
- const n = Number(v);
- return Number.isFinite(n) ? (n * 100).toFixed(2) + '%' : String(v);
- }
- // 店铺名/账号名/raw 原始字段均来自外部平台数据,凡进 innerHTML 的文本必须先过 esc()
- function esc(value) {
- const div = document.createElement('div');
- div.textContent = String(value ?? '');
- return div.innerHTML;
- }
- function fmtCell(col, v) {
- if (col.type === 'pct') return fmtPct(v);
- if (col.type === 'money') return fmtMoney(v);
- if (col.type === 'int') return fmtNum(v);
- if (col.type === 'num') return v == null || v === '' ? '—' : Number(v).toFixed(2);
- if (col.type === 'date') return v ? ymdToDash(v) : '—';
- return v == null || v === '' ? '—' : String(v);
- }
- function fmtByCol(col, v) {
- if (v == null || v === '') return '—';
- if (!col) return String(v);
- if (col.type === 'pct') return fmtPct(v);
- if (col.type === 'money') return fmtMoney(v);
- if (col.type === 'int') return fmtNum(v);
- 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) {
- const t = RATE_THRESHOLDS[key];
- if (!t || v == null || v === '') return '';
- const n = Number(v);
- if (!Number.isFinite(n)) return '';
- if (n >= t.good) return 'val-good';
- if (n < t.bad) return 'val-bad';
- return 'val-warn';
- }
- const LS = {
- get(k, fallback) { try { const v = localStorage.getItem(k); return v == null ? fallback : JSON.parse(v); } catch { return fallback; } },
- set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* ignore */ } },
- };
- // ---------- 状态 ----------
- const state = {
- inited: false,
- entities: [],
- entity: null,
- preset: '30',
- mode: 'detail',
- rawView: false,
- page: 1,
- pageSize: 200,
- total: 0,
- pages: 1,
- shopFilter: new Set(),
- q: '',
- columns: [],
- visible: new Set(),
- sortKey: 'date',
- sortDir: 'desc',
- rows: [],
- summary: null,
- conn: null,
- syncShops: new Set(),
- syncPollTimer: null,
- reportAbort: null,
- analysisShopFilter: new Set(),
- analysisPreset: '30',
- comparePrev: true,
- };
- let chartMetric = LS.get('shanReports.chartMetric', 'grossAmt');
- const chartInstances = {};
- let anaCache = null;
- const $ = (id) => document.getElementById(id);
- // ---------- 日期区间 ----------
- function currentRange() {
- if (state.preset === 'custom') {
- const from = dashToYmd($('reportFromDate').value);
- const to = dashToYmd($('reportToDate').value);
- if (from && to && from <= to) return { from, to };
- return { from: daysAgoYmd(30), to: daysAgoYmd(0) };
- }
- if (String(state.preset) === '1') return { from: daysAgoYmd(1), to: daysAgoYmd(1) }; // 「昨日」:from=to=昨天
- const days = Number(state.preset) || 30;
- return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
- }
- function analysisRange() {
- const sel = $('analysisRangePreset');
- if (sel && String(sel.value) === '1') return { from: daysAgoYmd(1), to: daysAgoYmd(1) }; // 「昨日」
- const days = Number(sel && sel.value) || 30;
- return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
- }
- // ---------- 主体(账号)选择器:数据采集 / 数据湖 / 趋势图表 共用选中 ----------
- const ENTITY_SELECT_CONTAINERS = ['entityTabs', 'analysisEntityTabs', 'lakeEntityTabs'];
- const ENTITY_SEARCH_THRESHOLD = 8; // 账号超过该数量时下拉内显示搜索框
- function activeEntity() {
- return state.entities.find((e) => e.key === state.entity) || null;
- }
- function entityShopCount(e) {
- return (e.shops || []).length || e.shopCount || 0;
- }
- function selectEntity(key) {
- if (state.entity === key) return;
- state.entity = key;
- state.shopFilter = new Set();
- state.analysisShopFilter = new Set();
- state.sortKey = 'date'; state.sortDir = 'desc';
- LS.set('shanReports.entity', key);
- anaCache = null;
- renderEntityTabs();
- renderShopOptions();
- reloadReport();
- renderCharts();
- refreshPushBadge(); // 待推计数按主体算,切主体要重取
- }
- function closeEntitySelect(wrap) {
- const menu = wrap.querySelector('.entity-select-menu');
- const trigger = wrap.querySelector('.scan-dropdown-trigger');
- if (menu) menu.hidden = true;
- if (trigger) trigger.setAttribute('aria-expanded', 'false');
- }
- document.addEventListener('click', (event) => {
- for (const id of ENTITY_SELECT_CONTAINERS) {
- const wrap = $(id);
- if (wrap && !wrap.contains(event.target)) closeEntitySelect(wrap);
- }
- });
- function buildEntitySelect() {
- const current = activeEntity();
- const box = document.createElement('div');
- box.className = 'scan-dropdown entity-select';
- const trigger = document.createElement('button');
- trigger.type = 'button';
- trigger.className = 'scan-dropdown-trigger';
- trigger.setAttribute('aria-expanded', 'false');
- trigger.setAttribute('aria-haspopup', 'listbox');
- const label = document.createElement('span');
- label.textContent = current
- ? `${current.name || current.key}(${entityShopCount(current)} 家门店)`
- : '选择主体';
- 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.setAttribute('role', 'listbox');
- menu.hidden = true;
- box.appendChild(menu);
- const withSearch = state.entities.length > ENTITY_SEARCH_THRESHOLD;
- let search = null;
- let noMatch = null;
- if (withSearch) {
- search = document.createElement('input');
- search.type = 'search';
- search.className = 'entity-select-search';
- search.placeholder = '搜索账号…';
- menu.appendChild(search);
- }
- const items = state.entities.map((e) => {
- const item = document.createElement('button');
- item.type = 'button';
- item.className = 'scan-dropdown-item';
- item.dataset.value = e.key;
- item.dataset.text = `${e.name || ''} ${e.key}`.toLowerCase();
- item.setAttribute('role', 'option');
- item.innerHTML = '<span class="scan-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><span></span><span class="entity-select-item-meta"></span>';
- item.children[1].textContent = e.name || e.key;
- const cnt = entityShopCount(e);
- item.lastElementChild.textContent = cnt ? `${cnt} 家门店` : '';
- const active = e.key === state.entity;
- item.classList.toggle('selected', active);
- item.setAttribute('aria-selected', String(active));
- item.addEventListener('click', () => {
- closeEntitySelect(box);
- selectEntity(e.key);
- });
- menu.appendChild(item);
- return item;
- });
- if (withSearch) {
- noMatch = document.createElement('div');
- noMatch.className = 'entity-select-no-match';
- noMatch.textContent = '无匹配账号';
- noMatch.hidden = true;
- menu.appendChild(noMatch);
- }
- const applyFilter = () => {
- const kw = (search && search.value.trim().toLowerCase()) || '';
- let visible = 0;
- for (const it of items) {
- const hit = !kw || it.dataset.text.includes(kw);
- it.hidden = !hit;
- if (hit) visible += 1;
- }
- if (noMatch) noMatch.hidden = visible > 0;
- };
- if (search) search.addEventListener('input', applyFilter);
- trigger.addEventListener('click', () => {
- const willOpen = menu.hidden;
- closeEntitySelect(box);
- menu.hidden = !willOpen;
- trigger.setAttribute('aria-expanded', String(willOpen));
- if (willOpen) {
- applyFilter();
- if (search) search.focus();
- }
- });
- 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) {
- wrap.appendChild(buildEntitySelectEmpty(containerId));
- continue;
- }
- wrap.appendChild(buildEntitySelect());
- }
- }
- // ---------- 门店多选下拉(数据采集 / 同步 / 趋势图表 三处) ----------
- function toggleDropdown(menuId, triggerId, open) {
- const trigger = $(triggerId);
- const menu = menuId ? $(menuId) : null;
- const shouldOpen = open === undefined ? (menu ? menu.hidden : false) : open;
- if (menu) menu.hidden = !shouldOpen;
- if (trigger) trigger.setAttribute('aria-expanded', String(shouldOpen));
- }
- function fillShopMenu(menu, shops) {
- menu.innerHTML = '';
- const all = document.createElement('button');
- all.type = 'button';
- all.className = 'scan-dropdown-item';
- all.dataset.value = '__all__';
- all.setAttribute('role', 'option');
- all.innerHTML = '<span class="scan-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><span>全部门店</span>';
- menu.appendChild(all);
- const sorted = shops.slice().sort((a, b) => String(a.shopName).localeCompare(String(b.shopName), 'zh-CN'));
- for (const s of sorted) {
- const item = document.createElement('button');
- item.type = 'button';
- item.className = 'scan-dropdown-item';
- item.dataset.value = String(s.shopId);
- item.setAttribute('role', 'option');
- item.innerHTML = '<span class="scan-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><span></span>';
- item.lastElementChild.textContent = s.shopName;
- menu.appendChild(item);
- }
- }
- function renderDropdownSelection(menu, selection, textEl, allLabel) {
- menu.querySelectorAll('.scan-dropdown-item').forEach((node) => {
- const selected = selection.has(node.dataset.value);
- node.classList.toggle('selected', selected);
- node.setAttribute('aria-selected', String(selected));
- });
- const picked = Array.from(selection).filter((v) => v !== '__all__');
- textEl.textContent = picked.length ? `已选 ${picked.length} 家门店` : allLabel;
- }
- function renderShopOptions() {
- const entity = activeEntity();
- const shops = (entity && entity.shops) || [];
- const menus = [
- { menu: $('reportShopMenu'), sel: state.shopFilter, text: $('reportShopText'), label: '全部门店' },
- { menu: $('syncShopMenu'), sel: state.syncShops, text: $('syncShopText'), label: '选择门店' },
- { menu: $('anaShopMenu'), sel: state.analysisShopFilter, text: $('anaShopText'), label: '全部门店' },
- ];
- for (const m of menus) {
- if (!m.menu) continue;
- fillShopMenu(m.menu, shops);
- renderDropdownSelection(m.menu, m.sel, m.text, m.label);
- }
- }
- function makeDropdownHandler(menu, selection, textEl, allLabel, onChange) {
- return (event) => {
- const item = event.target.closest('[data-value]');
- if (!item) return;
- const value = item.dataset.value;
- if (value === '__all__') selection.clear();
- else if (selection.has(value)) selection.delete(value);
- else selection.add(value);
- renderDropdownSelection(menu, selection, textEl, allLabel);
- if (onChange) onChange();
- };
- }
- // ---------- 报表表格(数据采集页) ----------
- function metricColumns() {
- return state.columns.filter((c) => c.key !== 'date' && c.key !== 'shopName' && c.key !== 'account' && state.visible.has(c.key));
- }
- function tableColumns() {
- // 全部字段模式(数据中台):固定业务列 + 从首行 raw 发现的全部原始字段(原始 API 字段名)
- if (state.rawView) {
- const base = [
- { key: 'account', label: '账号名', type: 'text' },
- { key: 'date', label: '日期', type: 'date' },
- { key: 'scope', label: '维度', type: 'text' },
- { key: 'shopId', label: '门店ID', type: 'text' },
- { key: 'shopName', label: '门店名称', type: 'text' },
- ];
- const rawCols = [];
- const seen = new Set(base.map((c) => c.label));
- for (const r of state.rows) {
- const raw = r && r.raw;
- if (!raw || typeof raw !== 'object') continue;
- for (const k of Object.keys(raw)) {
- if (seen.has(k)) continue;
- seen.add(k);
- const sample = raw[k];
- const isNum = typeof sample === 'number' || (typeof sample === 'string' && sample !== '' && Number.isFinite(Number(sample)));
- rawCols.push({ key: `raw.${k}`, label: k, type: isNum ? 'num' : 'text', raw: true });
- }
- }
- return [...base, ...rawCols];
- }
- const metricCols = metricColumns();
- const accountCol = { key: 'account', label: '账号名', type: 'text' };
- if (state.mode === 'shop') return [accountCol, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
- if (state.mode === 'date') return [accountCol, { key: 'date', label: '日期', type: 'date' }, ...metricCols];
- return [accountCol, { key: 'date', label: '日期', type: 'date' }, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
- }
- // 取单元格值:raw. 前缀深入原始字段对象;对象/数组序列化展示
- function cellValue(r, key) {
- if (!key.startsWith('raw.')) return r[key];
- const raw = r && r.raw;
- if (!raw || typeof raw !== 'object') return undefined;
- return raw[key.slice(4)];
- }
- function sortedRows() {
- return state.rows.slice().sort((a, b) => {
- const av = cellValue(a, state.sortKey), bv = cellValue(b, state.sortKey);
- if (av == null || av === '') return 1;
- if (bv == null || bv === '') return -1;
- if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * (state.sortDir === 'asc' ? 1 : -1);
- return String(av).localeCompare(String(bv), 'zh-CN') * (state.sortDir === 'asc' ? 1 : -1);
- });
- }
- function renderTable() {
- const cols = tableColumns();
- const thead = $('reportTableHead');
- const tbody = $('reportTableBody');
- // 全部字段模式列多且长:列宽随内容、容器横向滚动,避免 60 列挤成一团
- $('reportTable').classList.toggle('raw-cols', state.rawView);
- $('reportRowCount').textContent = `${state.rows.length} 行`;
- const empty = $('reportEmpty');
- if (!state.rows.length) {
- thead.innerHTML = '';
- tbody.innerHTML = '';
- empty.hidden = false;
- return;
- }
- empty.hidden = true;
- thead.innerHTML = '<tr>' + cols.map((c) => {
- const active = state.sortKey === c.key;
- const arrow = active ? (state.sortDir === 'asc' ? ' ↑' : ' ↓') : '';
- return `<th class="col-${c.type}${['money', 'int', 'pct', 'num'].includes(c.type) ? ' col-num' : ''}" data-key="${esc(c.key)}">${esc(c.label)}${arrow}</th>`;
- }).join('') + '</tr>';
- tbody.innerHTML = sortedRows().map((r) => '<tr>' + cols.map((c) => {
- const isNum = ['money', 'int', 'pct', 'num'].includes(c.type);
- const v = cellValue(r, c.key);
- const display = (v != null && typeof v === 'object') ? JSON.stringify(v) : v;
- const cls = `col-${c.type}${isNum ? ' col-num' : ''}${c.type === 'pct' ? ' ' + rateClass(c.key, v) : ''}`;
- return `<td class="${cls}">${esc(fmtCell(c, display))}</td>`;
- }).join('') + '</tr>').join('');
- }
- // ---------- 数据加载(数据采集页) ----------
- async function loadReport() {
- const entity = state.entity;
- if (!entity) {
- state.rows = []; state.summary = null;
- renderTable(); renderPager();
- return;
- }
- if (state.reportAbort) state.reportAbort.abort();
- const ctrl = new AbortController();
- state.reportAbort = ctrl;
- const { from, to } = currentRange();
- try {
- let url = state.mode === 'detail'
- ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}${state.rawView ? '' : '&scope=shop'}&page=${state.page}&pageSize=${state.pageSize}`
- : `/api/reports/aggregate?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&group=${state.mode}`;
- if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
- if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
- const data = await jsonFetch(url, { signal: ctrl.signal });
- if (state.entity !== entity) return;
- state.rows = data.rows || [];
- state.summary = data.summary || null;
- state.total = state.mode === 'detail' ? (data.total ?? state.rows.length) : state.rows.length;
- state.pages = state.mode === 'detail' ? (data.pages ?? 1) : 1;
- renderTable();
- renderPager();
- } catch (e) {
- if (e.name === 'AbortError') return;
- showToast('报表加载失败:' + e.message, 'error');
- }
- }
- // 筛选/主体/视图等变化:回到第一页再加载
- function reloadReport() {
- state.page = 1;
- loadReport();
- }
- // 分页条:明细模式服务端分页(排序作用于当前页);聚合模式行数少不分页
- function renderPager() {
- const el = $('reportPager');
- if (!el) return;
- if (state.mode !== 'detail' || state.pages <= 1) {
- el.innerHTML = state.mode === 'detail' ? `<span class="pager-info">共 ${state.total} 行</span>` : '';
- return;
- }
- const from = (state.page - 1) * state.pageSize + 1;
- const to = Math.min(state.total, state.page * state.pageSize);
- const btn = (label, target, disabled) =>
- `<button type="button" class="btn btn-ghost btn-small" data-page="${target}" ${disabled ? 'disabled' : ''}>${label}</button>`;
- el.innerHTML = `
- ${btn('‹ 上一页', state.page - 1, state.page <= 1)}
- <span class="pager-info">第 ${state.page} / ${state.pages} 页 · ${from}-${to} 行 · 共 ${state.total} 行</span>
- ${btn('下一页 ›', state.page + 1, state.page >= state.pages)}
- <span class="pager-info pager-note">列排序作用于当前页</span>
- `;
- }
- function changePage(page) {
- if (page < 1 || page > state.pages || page === state.page) return;
- state.page = page;
- loadReport();
- const wrap = $('reportTableWrap');
- if (wrap && wrap.scrollIntoView) wrap.scrollIntoView({ block: 'nearest' });
- }
- // ---------- 连接状态 ----------
- // 服务端 hint 已结构化({title, steps:[{text, code}], note});兜底兼容旧版纯文本
- function connHintPlain(hint) {
- if (!hint) return '';
- if (typeof hint === 'string') return hint;
- const parts = [];
- if (hint.title) parts.push(hint.title);
- (hint.steps || []).forEach((s, i) => parts.push(`${i + 1}. ${s.text || ''}${s.code ? ' ' + s.code : ''}`));
- if (hint.note) parts.push('注意:' + hint.note);
- return parts.join('\n');
- }
- // 同步弹窗内渲染「以调试端口启动客户端」分步指引(命令用 code 胶囊,自检地址可点击)
- function renderSyncConnHint(napos) {
- const wrap = $('syncHint');
- wrap.innerHTML = '';
- const hint = napos && napos.hint;
- if (!hint) return;
- const box = document.createElement('div');
- box.className = 'conn-steps';
- if (Array.isArray(hint.steps)) {
- const title = document.createElement('div');
- title.className = 'conn-steps-title';
- title.textContent = `未连接客户端 · ${hint.title || '以调试端口启动客户端'}`;
- box.appendChild(title);
- const list = document.createElement('div');
- list.className = 'conn-steps-list';
- const rows = hint.steps.slice();
- if (napos.selfCheckUrl) {
- rows.push({ text: '自检:用浏览器打开', link: napos.selfCheckUrl, tail: ',看到一段 JSON 即端口已开。' });
- }
- rows.forEach((s, i) => {
- const row = document.createElement('div');
- row.className = 'conn-step';
- const idx = document.createElement('span');
- idx.className = 'conn-step-idx';
- idx.textContent = String(i + 1);
- row.appendChild(idx);
- const txt = document.createElement('span');
- txt.className = 'conn-step-text';
- txt.appendChild(document.createTextNode(s.text || ''));
- if (s.code) {
- const code = document.createElement('code');
- code.textContent = s.code;
- txt.appendChild(code);
- }
- if (s.link) {
- txt.appendChild(document.createTextNode(' '));
- const a = document.createElement('a');
- a.href = s.link;
- a.target = '_blank';
- a.rel = 'noopener';
- a.textContent = s.link;
- txt.appendChild(a);
- if (s.tail) txt.appendChild(document.createTextNode(s.tail));
- }
- row.appendChild(txt);
- list.appendChild(row);
- });
- box.appendChild(list);
- if (hint.note) {
- const note = document.createElement('div');
- note.className = 'conn-steps-note';
- note.textContent = hint.note;
- box.appendChild(note);
- }
- } else {
- // 旧版纯文本兜底
- const p = document.createElement('div');
- p.textContent = typeof hint === 'string' ? hint : '';
- box.appendChild(p);
- if (napos.selfCheckUrl) {
- const p2 = document.createElement('div');
- p2.textContent = `自检:用浏览器打开 ${napos.selfCheckUrl},看到一段 JSON 即端口已开。`;
- box.appendChild(p2);
- }
- }
- wrap.appendChild(box);
- }
- async function refreshConn() {
- const badge = $('reportConnBadge');
- try {
- const data = await jsonFetch('/api/reports/status');
- state.conn = data.napos || null;
- state.entities = data.entities || [];
- if (!state.entity || !state.entities.some((e) => e.key === state.entity)) {
- const saved = LS.get('shanReports.entity', null);
- state.entity = (saved && state.entities.some((e) => e.key === saved)) ? saved : (state.entities[0] ? state.entities[0].key : null);
- }
- renderEntityTabs();
- renderShopOptions();
- const ok = state.conn && state.conn.connected;
- badge.textContent = ok ? '客户端已连接' : '客户端未连接';
- badge.className = 'badge ' + (ok ? 'badge-ok' : 'badge-fail');
- badge.title = ok
- ? `调试端口 ${state.conn.port}`
- : [state.conn && state.conn.error, connHintPlain(state.conn && state.conn.hint)].filter(Boolean).join('\n') || '未检测到调试端口';
- } catch {
- badge.textContent = '状态检测失败';
- badge.className = 'badge badge-fail';
- }
- }
- // ---------- 列设置 ----------
- function loadColumnPrefs() {
- // 默认只展示原始/主指标列(营业时长、异常关店时长、商品销售额为预留列,数据源接入后自动回填);
- // 下单转化率、客单价、支出、活动订单、活动补贴、推广ROI、30日复购率等派生指标默认隐藏,可在列设置打开。
- const DEFAULT_VISIBLE_COLS = new Set([
- 'bizHours', 'abnormalCloseHours', 'validOrderCnt', 'incomeAmt', 'grossAmt',
- 'goodsSaleAmt', 'expUv', 'orderUserCnt', 'clkExpRate',
- ]);
- const saved = LS.get('shanReports.columns.v2', null);
- const defaults = new Set(state.columns.filter((c) => DEFAULT_VISIBLE_COLS.has(c.key)).map((c) => c.key));
- if (Array.isArray(saved)) {
- const valid = new Set(state.columns.map((c) => c.key));
- state.visible = new Set(saved.filter((k) => valid.has(k)));
- } else {
- state.visible = defaults;
- }
- if (!state.visible.size) state.visible = defaults;
- }
- function renderColumnCfg() {
- const list = $('columnCfgList');
- list.innerHTML = '';
- for (const c of state.columns) {
- if (c.locked) continue;
- const label = document.createElement('label');
- label.className = 'colcfg-item';
- const checked = state.visible.has(c.key) ? 'checked' : '';
- label.innerHTML = `<input type="checkbox" value="${esc(c.key)}" ${checked}><span></span>${c.reserved ? '<em class="colcfg-reserved">预留</em>' : ''}`;
- label.querySelector('span').textContent = c.label;
- list.appendChild(label);
- }
- }
- function applyColumnCfg() {
- const picks = new Set();
- $('columnCfgList').querySelectorAll('input[type=checkbox]').forEach((cb) => {
- if (cb.checked) picks.add(cb.value);
- });
- state.visible = picks;
- LS.set('shanReports.columns.v2', Array.from(picks));
- $('modalReportColumns').hidden = true;
- renderTable();
- }
- // ---------- 同步 ----------
- function renderSyncProgress(st) {
- const pct = st.planTotal ? Math.round((st.done / st.planTotal) * 100) : 0;
- $('syncProgressBar').style.width = pct + '%';
- $('syncProgressText').textContent = `${st.done} / ${st.planTotal}`;
- $('syncStatAdded').textContent = `新增 ${st.added}`;
- $('syncStatUpdated').textContent = `更新 ${st.updated}`;
- $('syncStatFailed').textContent = `失败 ${st.failed}`;
- const steps = (st.steps || []).slice(-8);
- $('syncSteps').innerHTML = steps.map((s) => {
- const cls = s.ok ? 'ok' : 'fail';
- const detail = s.ok
- ? `+${s.added}/~${s.updated}` + (s.rows != null ? ` · ${s.rows}行` : '') + (s.nonZero === 0 && s.rows > 0 ? ' · ⚠全零' : '')
- : esc((s.error || '失败').slice(0, 60));
- return `<div class="sync-step ${cls}"><span></span><em>${detail}</em></div>`;
- }).join('');
- const stepNames = $('syncSteps').querySelectorAll('.sync-step span');
- steps.forEach((s, i) => { stepNames[i].textContent = s.label; });
- $('btnSyncCancel').hidden = !st.running;
- $('btnStartSync').disabled = !!st.running;
- $('btnStartSync').textContent = st.running ? '同步中…' : '开始同步';
- }
- function pollSyncStatus() {
- if (state.syncPollTimer) clearInterval(state.syncPollTimer);
- let pollErrors = 0; // 连续失败计数:服务端持续不可达时停止轮询,避免永久空转
- state.syncPollTimer = setInterval(async () => {
- try {
- const st = await jsonFetch('/api/reports/sync/status');
- pollErrors = 0;
- renderSyncProgress(st);
- if (!st.running) {
- clearInterval(state.syncPollTimer);
- state.syncPollTimer = null;
- if (st.error) showToast('同步出错:' + st.error, 'error');
- else showToast(st.cancelled ? '同步已取消' : `同步完成:新增 ${st.added},更新 ${st.updated}`);
- refreshConn().then(loadReport);
- }
- } catch {
- pollErrors++;
- if (pollErrors >= 5) {
- clearInterval(state.syncPollTimer);
- state.syncPollTimer = null;
- showToast('同步状态轮询失败,已停止;可用「刷新」手动恢复', 'error');
- }
- }
- }, 900);
- }
- async function loadAutoSyncPrefs() {
- try {
- const s = await jsonFetch('/api/settings');
- $('autoSyncEnabled').checked = !!s.autoSyncEnabled;
- if (s.autoSyncTime) $('autoSyncTime').value = s.autoSyncTime;
- } catch { /* 设置读取失败时保留默认值 */ }
- }
- async function saveAutoSyncPrefs() {
- try {
- await jsonFetch('/api/settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ autoSyncEnabled: $('autoSyncEnabled').checked, autoSyncTime: $('autoSyncTime').value }),
- });
- showToast('自动同步设置已保存');
- } catch (e) {
- showToast('自动同步设置保存失败:' + e.message, 'error');
- }
- }
- async function openSyncModal() {
- $('modalReportSync').hidden = false;
- $('syncEntityBadge').textContent = '识别主体中…';
- $('syncEntityBadge').className = 'badge badge-info';
- $('syncShopPickWrap').hidden = true;
- $('syncProgressWrap').hidden = true;
- // 首次打开时记住默认说明文案;连接失败会覆盖为分步指引,连上后要能恢复
- if (!state.syncHintDefault) state.syncHintDefault = $('syncHint').textContent;
- else $('syncHint').textContent = state.syncHintDefault;
- loadAutoSyncPrefs();
- try {
- const tree = await jsonFetch('/api/reports/tree');
- const badge = $('syncEntityBadge');
- badge.textContent = `当前主体:${(tree.entity && tree.entity.name) || (tree.merchant && tree.merchant.name) || '未知'} · 门店 ${(tree.shops || []).length} 家`;
- badge.className = 'badge badge-ok';
- if (tree.entity && tree.entity.key && tree.entity.key !== state.entity) {
- state.entity = tree.entity.key;
- LS.set('shanReports.entity', tree.entity.key);
- }
- await refreshConn();
- renderSyncShopOptions(tree.shops || []);
- } catch (e) {
- const badge = $('syncEntityBadge');
- badge.textContent = '未连接客户端:' + e.message;
- badge.className = 'badge badge-fail';
- try {
- const st = await jsonFetch('/api/reports/status');
- renderSyncConnHint(st.napos || {});
- } catch { /* 服务不可达时保留默认文案 */ }
- }
- }
- function renderSyncShopOptions(shops) {
- state.syncShops.clear();
- fillShopMenu($('syncShopMenu'), shops);
- renderDropdownSelection($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店');
- }
- async function startSync() {
- const scope = $('syncScope').value;
- const days = Number($('syncRange').value) || 30;
- const body = { from: daysAgoYmd(days), to: daysAgoYmd(1), scope: scope === 'custom' ? 'shop' : scope };
- if (scope === 'custom') {
- const shops = Array.from(state.syncShops).filter((v) => v !== '__all__');
- if (!shops.length) { showToast('请先选择要同步的门店', 'error'); return; }
- body.shopIds = shops;
- }
- try {
- await jsonFetch('/api/reports/sync', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- $('syncProgressWrap').hidden = false;
- renderSyncProgress({ planTotal: 0, done: 0, added: 0, updated: 0, failed: 0, steps: [], running: true });
- pollSyncStatus();
- } catch (e) {
- showToast('同步启动失败:' + e.message, 'error');
- }
- }
- // ---------- 导出 ----------
- function exportCsv() {
- if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
- // 全部字段模式:行数据只在客户端(raw 列由前端发现),就地生成 CSV
- if (state.rawView) {
- const cols = tableColumns();
- const cell = (v) => {
- const s = (v == null || typeof v !== 'object') ? String(v ?? '') : JSON.stringify(v);
- return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
- };
- const lines = [cols.map((c) => cell(c.label)).join(',')];
- for (const r of sortedRows()) lines.push(cols.map((c) => cell(cellValue(r, c.key))).join(','));
- const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `湖明细-全部字段-${state.entity}.csv`;
- a.click();
- URL.revokeObjectURL(a.href);
- return;
- }
- const { from, to } = currentRange();
- let url = `/api/reports/export?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`;
- if (state.mode !== 'detail') url += `&group=${state.mode}`;
- if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
- if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
- window.location.href = url;
- }
- function exportHtml() {
- if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
- const { from, to } = analysisRange();
- 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 metricColumnOptions() {
- return state.columns.filter((c) => ['int', 'money', 'pct', 'num'].includes(c.type) && !c.reserved);
- }
- function renderChartMetricSelect() {
- const sel = $('chartMetric');
- if (!sel) return;
- sel.innerHTML = '';
- for (const c of metricColumnOptions()) {
- const opt = document.createElement('option');
- opt.value = c.key;
- opt.textContent = c.label;
- if (c.key === chartMetric) opt.selected = true;
- sel.appendChild(opt);
- }
- if (!sel.value && sel.options.length) sel.value = sel.options[0].value;
- 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();
- const shopSuffix = state.analysisShopFilter.size
- ? `&shops=${encodeURIComponent(Array.from(state.analysisShopFilter).join(','))}`
- : '';
- const key = [state.entity, from, to, state.analysisShopFilter.size ? [...state.analysisShopFilter].sort().join(',') : ''].join('|');
- if (anaCache && anaCache.key === key) return anaCache;
- const fd = (s) => new Date(Number(String(s).slice(0, 4)), Number(String(s).slice(4, 6)) - 1, Number(String(s).slice(6, 8)));
- const spanDays = Math.max(1, Math.round((fd(to) - fd(from)) / 86400000) + 1);
- const prevToD = fd(from);
- prevToD.setDate(prevToD.getDate() - 1);
- const prevTo = ymdLocal(prevToD);
- const prevFromD = new Date(prevToD);
- prevFromD.setDate(prevFromD.getDate() - (spanDays - 1));
- const prevFrom = ymdLocal(prevFromD);
- const base = `/api/reports/aggregate?entity=${encodeURIComponent(state.entity)}`;
- 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, 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 curOrders = sumField(data.byDate, 'validOrderCnt');
- const prevOrders = sumField(data.byDatePrev, 'validOrderCnt');
- const items = [
- { label: '营业额', cur: sumField(data.byDate, 'grossAmt'), prev: sumField(data.byDatePrev, 'grossAmt'), fmt: fmtMoney },
- { label: '有效订单', cur: curOrders, prev: prevOrders, fmt: fmtNum },
- { 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;
- const div = document.createElement('div');
- div.className = 'delta-card';
- let deltaHtml;
- const delta = it.prev > 0 ? (it.cur - it.prev) / it.prev : null;
- if (delta == null || !Number.isFinite(delta)) deltaHtml = '<span class="delta-none">上期无数据</span>';
- else if (Math.abs(delta) < 0.0001) deltaHtml = '<span class="delta-flat">持平</span>';
- else {
- const up = delta > 0;
- deltaHtml = `<span class="delta ${up ? 'up' : 'down'}">${up ? '↑' : '↓'} ${Math.abs(delta * 100).toFixed(1)}%</span>`
- + `<span class="delta-sub">vs 上期 ${it.fmt(Math.round(it.prev * 100) / 100)}</span>`;
- }
- div.innerHTML = `<span class="delta-label"></span><span class="delta-value"></span><span class="delta-line">${deltaHtml}</span>`;
- div.querySelector('.delta-label').textContent = it.label;
- div.querySelector('.delta-value').textContent = it.fmt(cur);
- wrap.appendChild(div);
- }
- }
- /** 趋势图表空态(与数据中台同款 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;
- }
- async function renderCharts() {
- if (!state.entity) {
- setAnalysisEmpty('暂无主体<br><small>请先在商家客户端登录并完成一次「同步数据」;本机也可在「数据中台」页执行「云端拉取」</small>');
- return;
- }
- 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);
- await tpl.render(grid, data); // 模板可为 async(同行对标/跨校对比自带取数)
- }
- // ---------- 模板库装配 ----------
- // 六个内置模板(综合总览 / 环比对照 / 量价归因 / 档口问责 / 流量卡点 / 周内节律)
- // 定义在 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()) : [];
- // ---------- 智能问数 ·「应用到报表」(独立问数页经 window.shanReportsApplyQuery 调用) ----------
- /** 把问数查询条件落到报表筛选,并跳转数据中台明细 tab(原机器人 applyMsgQuery 语义) */
- window.shanReportsApplyQuery = async function (a) {
- if (!a) return;
- if (!state.inited) await window.shanReportsInit();
- state.mode = ['detail', 'shop', 'date'].includes(a.mode) ? a.mode : 'shop';
- $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === state.mode));
- state.preset = 'custom';
- $('reportRangePreset').value = 'custom';
- $('reportCustomRange').hidden = false;
- $('reportFromDate').value = ymdToDash(a.from);
- $('reportToDate').value = ymdToDash(a.to);
- state.shopFilter = new Set((a.shopIds || []).map(String));
- renderShopOptions();
- state.page = 1;
- loadReport();
- // 明细浏览在数据中台页:先切到明细 tab,再导航过去
- if (window.shanLakeShowDetail) window.shanLakeShowDetail();
- const nav = document.querySelector('.nav-item[data-view="lake"]');
- if (nav) nav.click();
- };
- // ---------- 事件绑定 ----------
- function bindEvents() {
- $('btnReportRefresh').addEventListener('click', async () => {
- await refreshConn();
- await loadReport();
- showToast('已刷新');
- });
- $('btnNaposDiagnose').addEventListener('click', runNaposDiagnose);
- $('btnCloseDiagnose').addEventListener('click', closeDiagnoseModal);
- $('btnDismissDiagnose').addEventListener('click', closeDiagnoseModal);
- $('btnCopyDiagnose').addEventListener('click', copyDiagnoseReport);
- $('btnDownloadDiagnose').addEventListener('click', downloadDiagnoseReport);
- $('btnClientHelp').addEventListener('click', (event) => {
- event.stopPropagation();
- const pop = $('clientHelpPopover');
- pop.hidden = !pop.hidden;
- $('btnClientHelp').classList.toggle('active', !pop.hidden);
- $('btnClientHelp').setAttribute('aria-expanded', String(!pop.hidden));
- });
- document.addEventListener('click', (event) => {
- const pop = $('clientHelpPopover');
- if (!pop.hidden && !event.target.closest('.conn-help-wrap')) {
- pop.hidden = true;
- $('btnClientHelp').classList.remove('active');
- $('btnClientHelp').setAttribute('aria-expanded', 'false');
- }
- });
- $('btnOpenSync').addEventListener('click', openSyncModal);
- $('btnCloseSync').addEventListener('click', () => {
- $('modalReportSync').hidden = true;
- if (state.syncPollTimer) { clearInterval(state.syncPollTimer); state.syncPollTimer = null; }
- $('syncProgressWrap').hidden = true;
- });
- $('btnCancelSync').addEventListener('click', () => {
- $('modalReportSync').hidden = true;
- // 与 btnCloseSync 同款:取消路径也要停掉同步状态轮询,否则后台空转
- if (state.syncPollTimer) { clearInterval(state.syncPollTimer); state.syncPollTimer = null; }
- $('syncProgressWrap').hidden = true;
- });
- $('btnStartSync').addEventListener('click', startSync);
- $('btnSyncCancel').addEventListener('click', async () => {
- try { await jsonFetch('/api/reports/sync/cancel', { method: 'POST' }); showToast('已请求取消,等待当前步骤结束…'); } catch { /* ignore */ }
- });
- $('syncScope').addEventListener('change', () => {
- $('syncShopPickWrap').hidden = $('syncScope').value !== 'custom';
- });
- $('autoSyncEnabled').addEventListener('change', saveAutoSyncPrefs);
- $('autoSyncTime').addEventListener('change', saveAutoSyncPrefs);
- $('syncShopTrigger').addEventListener('click', () => toggleDropdown('syncShopMenu', 'syncShopTrigger'));
- $('syncShopMenu').addEventListener('click', makeDropdownHandler($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店'));
- document.addEventListener('click', (event) => {
- if (!$('syncShopDropdown').contains(event.target)) toggleDropdown('syncShopMenu', 'syncShopTrigger', false);
- });
- $('reportRangePreset').addEventListener('change', () => {
- state.preset = $('reportRangePreset').value;
- $('reportCustomRange').hidden = state.preset !== 'custom';
- reloadReport();
- });
- $('reportFromDate').addEventListener('change', loadReport);
- $('reportToDate').addEventListener('change', loadReport);
- $('reportShopTrigger').addEventListener('click', () => toggleDropdown('reportShopMenu', 'reportShopTrigger'));
- $('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', reloadReport));
- document.addEventListener('click', (event) => {
- if (!$('reportShopDropdown').contains(event.target)) toggleDropdown('reportShopMenu', 'reportShopTrigger', false);
- });
- $('reportViewModes').addEventListener('click', (event) => {
- const btn = event.target.closest('button[data-mode]');
- if (!btn) return;
- state.mode = btn.dataset.mode;
- $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
- state.sortKey = state.mode === 'detail' ? 'date' : (state.mode === 'shop' ? 'shopName' : 'date');
- state.sortDir = 'desc';
- reloadReport();
- });
- // 字段模式:报表列(语义列)/ 全部字段(明细 + 全部原始字段;聚合视图无 raw,自动回明细)
- $('reportFieldModes').addEventListener('click', (event) => {
- const btn = event.target.closest('button[data-fields]');
- if (!btn) return;
- state.rawView = btn.dataset.fields === 'all';
- $('reportFieldModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
- if (state.rawView && state.mode !== 'detail') {
- state.mode = 'detail';
- state.sortKey = 'date';
- state.sortDir = 'desc';
- $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === 'detail'));
- }
- $('reportViewModes').querySelectorAll('button').forEach((b) => { b.disabled = state.rawView; });
- $('btnColumnCfg').hidden = state.rawView; // 列设置只作用于报表列模式
- state.sortKey = state.rawView ? 'date' : state.sortKey;
- renderTable();
- });
- let searchTimer = null;
- $('reportSearch').addEventListener('input', () => {
- clearTimeout(searchTimer);
- searchTimer = setTimeout(() => {
- state.q = $('reportSearch').value.trim();
- reloadReport();
- }, 300);
- });
- $('btnColumnCfg').addEventListener('click', () => {
- renderColumnCfg();
- $('modalReportColumns').hidden = false;
- });
- $('btnCloseColumnCfg').addEventListener('click', () => $('modalReportColumns').hidden = true);
- $('btnApplyColumns').addEventListener('click', applyColumnCfg);
- $('btnResetColumns').addEventListener('click', () => {
- state.visible = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
- LS.set('shanReports.columns.v2', Array.from(state.visible));
- renderColumnCfg();
- renderTable();
- });
- $('btnExportCsv').addEventListener('click', exportCsv);
- $('btnExportHtml').addEventListener('click', exportHtml);
- $('reportTableHead').addEventListener('click', (event) => {
- const th = event.target.closest('th[data-key]');
- if (!th) return;
- const key = th.dataset.key;
- if (state.sortKey === key) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
- else { state.sortKey = key; state.sortDir = 'desc'; }
- renderTable();
- });
- // 趋势图表
- $('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;
- renderCharts();
- });
- $('anaShopTrigger').addEventListener('click', () => toggleDropdown('anaShopMenu', 'anaShopTrigger'));
- $('anaShopMenu').addEventListener('click', makeDropdownHandler($('anaShopMenu'), state.analysisShopFilter, $('anaShopText'), '全部门店', () => {
- anaCache = null;
- renderCharts();
- }));
- document.addEventListener('click', (event) => {
- if (!$('anaShopDropdown').contains(event.target)) toggleDropdown('anaShopMenu', 'anaShopTrigger', false);
- });
- $('chartMetric').addEventListener('change', () => {
- chartMetric = $('chartMetric').value || 'grossAmt';
- LS.set('shanReports.chartMetric', chartMetric);
- renderCharts();
- });
- $('anaComparePrev').addEventListener('change', renderCharts);
- window.addEventListener('resize', () => {
- Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
- });
- }
- // ---------- 同步历史(数据采集页,含云端推送状态) ----------
- /** 同步历史空态(与数据中台同款 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) {
- setSyncHistoryEmpty('暂无主体<br><small>请先在商家客户端登录并完成一次「同步数据」</small>');
- return;
- }
- const entityKey = encodeURIComponent(state.entity);
- Promise.all([
- jsonFetch(`/api/reports/syncs?entity=${entityKey}`),
- jsonFetch(`/api/reports/aitable/status?entity=${entityKey}`).catch(() => null),
- ])
- .then(([data, cloud]) => {
- const runs = (data.runs || []).slice(0, 8);
- if (!runs.length) {
- 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: '按门店' };
- tbody.innerHTML = runs.map((r) => {
- const warn = (r.steps || []).filter((s) => !s.ok).length;
- const note = r.cancelled ? '已取消' : (r.failed ? `${r.failed} 失败` : (warn ? `${warn} 步骤告警(含全零)` : '成功'));
- const cls = r.cancelled || r.failed ? 'badge-fail' : (warn ? 'badge-warn' : 'badge-ok');
- const t = r.at ? new Date(r.at) : null;
- const ts = t ? `${t.getMonth() + 1}/${t.getDate()} ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '–';
- // 该次同步之后是否推送过云端(以最近一次推送时间为准;更早的同步被其覆盖归档)
- const pushed = pushAt && t && pushAt >= t.getTime();
- const cloudCell = !cloud || !cloud.enabled
- ? '<span class="badge">未启用</span>'
- : (pushed ? `<span class="badge badge-ok">已推 ${lastPush.pushedRows || 0} 行</span>` : '<span class="badge badge-info">未推送</span>');
- return `<tr>
- <td>${ts}</td>
- <td>${esc((r.from || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'))} ~ ${esc((r.to || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'))}</td>
- <td>${esc(SCOPE_LABEL[r.scope] || r.scope || '–')}</td>
- <td class="num">${r.added ?? 0}</td>
- <td class="num">${r.updated ?? 0}</td>
- <td class="num">${r.failed ?? 0}</td>
- <td><span class="badge ${cls}">${note}</span></td>
- <td>${cloudCell}</td>
- </tr>`;
- }).join('');
- })
- .catch(() => {
- setSyncHistoryEmpty('同步历史加载失败<br><small>请稍后使用页面右上角「刷新」重试</small>');
- });
- }
- // ---------- 取数诊断(隐藏入口:连点侧边栏版本号 5 次) ----------
- const BUSY_LABEL = {
- OPEN: '营业中', CLOSE: '休息中(非营业时段)', SUSPEND_BUSINESS: '暂停营业', INVALID: '未生效/已下架', '(未知)': '未知',
- };
- let lastDiagnoseReport = null;
- async function runNaposDiagnose() {
- const btn = $('btnNaposDiagnose');
- btn.disabled = true;
- btn.textContent = '诊断中…';
- $('diagnoseSummary').innerHTML = '<div class="help">正在诊断,约需半分钟,请勿关闭…</div>';
- $('diagnoseJson').textContent = '';
- $('modalNaposDiagnose').hidden = false;
- try {
- const res = await fetch('/api/reports/diagnose/napos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
- lastDiagnoseReport = data;
- renderDiagnoseReport(data);
- } catch (e) {
- closeDiagnoseModal();
- showToast(`诊断失败:${e.message}`, 'error');
- } finally {
- btn.disabled = false;
- btn.textContent = '取数诊断';
- }
- }
- function closeDiagnoseModal() { $('modalNaposDiagnose').hidden = true; }
- function diagnoseRow(label, value) {
- return `<div><span class="diagnose-k">${esc(label)}</span>${value}</div>`;
- }
- function renderDiagnoseReport(r) {
- const busyText = Object.entries(r.busyLevelSummary || {}).map(([k, n]) => `${BUSY_LABEL[k] || k} ${n}`).join(',') || '无门店';
- const paused = r.pausedShops || [];
- const cases = r.cases || [];
- const cc = r.chainControl || {};
- const caseRows = cases.map((c) => `<tr>
- <td>${esc((c.shopName || '') + ' · ' + c.tag)}</td>
- <td class="num">${c.rows ?? '–'}</td>
- <td class="num">${c.ordersSum ?? '–'}</td>
- <td class="num">${c.grossSum ?? '–'}</td>
- <td class="num">${c.orderUserSum ?? '–'}</td>
- <td>${c.error ? `<span class="badge badge-fail">异常</span>` : (c.metricProbe ? `字段 ${c.metricProbe.fieldCount} 个` : '–')}</td>
- </tr>`).join('');
- $('diagnoseSummary').innerHTML = `
- <div class="diagnose-block">
- ${diagnoseRow('客户端', r.status && r.status.portOk ? `已连接(${esc(r.status.browser || '')})` : '不在线')}
- ${diagnoseRow('主体', `${esc((r.merchant && r.merchant.name) || '?')}(${r.shopCount ?? 0} 家门店 · 测试日期 ${esc(r.testDate || '–')})`)}
- ${diagnoseRow('门店状态', esc(busyText))}
- ${diagnoseRow('暂停营业/下架', paused.length ? paused.map((s) => esc(s.shopName)).join('、') : '无')}
- ${diagnoseRow('连锁对照', cc.rows ? `${cc.rows} 行 · 有效订单 ${cc.ordersSum} · 营业额 ${cc.grossSum} · 下单人数 ${cc.orderUserSum} · 字段 ${(cc.metricProbe && cc.metricProbe.fieldCount) ?? '–'} 个` : '无数据')}
- </div>
- ${cases.length ? `<table class="data-table diagnose-table"><thead><tr><th>取数变体</th><th>行</th><th>有效订单</th><th>营业额</th><th>下单人数</th><th>原始返回</th></tr></thead><tbody>${caseRows}</tbody></table>` : ''}
- ${r.conclusionLines && r.conclusionLines.length
- ? `<div class="diagnose-conclusion">${r.conclusionLines.map((l) => `<div>· ${esc(l)}</div>`).join('')}</div>`
- : (r.conclusion ? `<div class="diagnose-conclusion">${esc(r.conclusion)}</div>` : '')}
- `;
- $('diagnoseJson').textContent = JSON.stringify(r, null, 2);
- }
- function copyDiagnoseReport() {
- if (!lastDiagnoseReport) return;
- const text = JSON.stringify(lastDiagnoseReport, null, 2);
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(() => showToast('已复制,粘贴发给开发即可', 'success'), () => fallbackCopyDiagnose(text));
- } else {
- fallbackCopyDiagnose(text);
- }
- }
- function fallbackCopyDiagnose(text) {
- const ta = document.createElement('textarea');
- ta.value = text;
- document.body.appendChild(ta);
- ta.select();
- try { document.execCommand('copy'); showToast('已复制,粘贴发给开发即可', 'success'); } catch { showToast('复制失败,请改用「下载报告」', 'error'); }
- ta.remove();
- }
- function downloadDiagnoseReport() {
- if (!lastDiagnoseReport) return;
- const blob = new Blob([JSON.stringify(lastDiagnoseReport, null, 2)], { type: 'application/json' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `napos-诊断报告-${Date.now()}.json`;
- a.click();
- URL.revokeObjectURL(a.href);
- }
- // ---------- 初始化 ----------
- // 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)。返回是否推送成功。
- async function pushCurrentEntity() {
- const badge = $('reportPushBadge');
- const entity = state.entity || (state.entities[0] || {}).key;
- if (!entity) {
- badge.textContent = '暂无主体';
- badge.className = 'badge badge-fail';
- return false;
- }
- if (window.shanAitableAction) {
- try {
- await window.shanAitableAction('push', 'reportPushBadge', { entity });
- return true;
- } catch { return false; /* 徽标已由 aitableAction 写入,保留错误给用户读 */ }
- }
- return false;
- }
- async function refreshPushBadge() {
- const badge = $('reportPushBadge');
- if (!badge) return;
- try {
- const entity = state.entity || (state.entities[0] || {}).key;
- const res = await fetch(`/api/reports/aitable/status${entity ? `?entity=${encodeURIComponent(entity)}` : ''}`);
- const data = await res.json();
- if (!data.enabled) {
- badge.textContent = '云端未启用';
- badge.className = 'badge badge-info';
- } else if (!data.configured) {
- badge.textContent = '云端配置不完整';
- badge.className = 'badge badge-fail';
- } else if (typeof data.pending === 'number' && data.pending > 0) {
- badge.textContent = `待推送 ${data.pending} 行`;
- badge.title = '「推送云端」会把上次推送之后的全部增量一次性推送至钉钉 AI 表格';
- badge.className = 'badge badge-warn';
- } else {
- badge.textContent = '与云端一致';
- badge.title = '';
- badge.className = 'badge badge-ok';
- }
- } catch { /* 状态刷新失败不打扰 */ }
- }
- window.shanReportsInit = async function () {
- if (!state.inited) {
- state.inited = true;
- bindEvents();
- $('btnReportPush').addEventListener('click', async () => {
- const ok = await pushCurrentEntity();
- if (!ok) return; // 失败:保留错误徽标原样(待推计数也没变)
- renderSyncHistory(); // 云端推送状态列随推送结果刷新
- refreshPushBadge(); // 待推计数归零(或显示剩余)
- });
- $('reportPager').addEventListener('click', (event) => {
- const btn = event.target.closest('button[data-page]');
- if (btn) changePage(Number(btn.dataset.page));
- });
- try {
- const data = await jsonFetch('/api/reports/columns');
- state.columns = data.columns || [];
- } catch { state.columns = []; }
- loadColumnPrefs();
- renderChartMetricSelect();
- renderTemplateSelect();
- }
- await refreshConn();
- refreshPushBadge();
- renderSyncHistory();
- await loadReport();
- };
- window.shanAnalysisActivate = async function () {
- await window.shanReportsInit();
- renderChartMetricSelect();
- renderTemplateSelect();
- await renderCharts();
- Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
- };
- })();
|