reports.js 74 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732
  1. 'use strict';
  2. /**
  3. * 数据采集 + 趋势图表 · 前端逻辑
  4. * 数据采集:多主体 tab / 同步 / 筛选 / BI 表格 / CSV 导出
  5. * 趋势图表:内置分析模板(经营/运营双视角,可切换)+ 环比指标条 + 周报导出
  6. * 智能问数已升级为独立页面(public/ask.js + server/agent/),此处仅保留「应用到报表」落点
  7. */
  8. (function () {
  9. // ---------- 工具 ----------
  10. function showToast(msg, type) {
  11. const wrap = document.getElementById('toast-wrap');
  12. const node = document.createElement('div');
  13. node.className = 'toast toast-' + (type || 'success');
  14. node.textContent = msg;
  15. wrap.appendChild(node);
  16. setTimeout(() => { node.style.opacity = '0'; node.style.transition = 'opacity 0.3s'; }, 2600);
  17. setTimeout(() => { if (node.parentNode) wrap.removeChild(node); }, 3000);
  18. }
  19. async function jsonFetch(url, opts) {
  20. const r = await fetch(url, opts);
  21. const data = await r.json().catch(() => ({}));
  22. if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
  23. return data;
  24. }
  25. const z2 = (n) => String(n).padStart(2, '0');
  26. function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`; }
  27. function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
  28. function ymdToDash(s) { return /^\d{8}$/.test(String(s)) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : String(s || ''); }
  29. function dashToYmd(s) { return /^\d{4}-\d{2}-\d{2}$/.test(String(s)) ? s.replace(/-/g, '') : ''; }
  30. function fmtNum(v) {
  31. if (v == null || v === '') return '—';
  32. const n = Number(v);
  33. return Number.isFinite(n) ? n.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) : String(v);
  34. }
  35. function fmtMoney(v) {
  36. if (v == null || v === '') return '—';
  37. const n = Number(v);
  38. return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v);
  39. }
  40. function fmtPct(v) {
  41. if (v == null || v === '') return '—';
  42. const n = Number(v);
  43. return Number.isFinite(n) ? (n * 100).toFixed(2) + '%' : String(v);
  44. }
  45. // 店铺名/账号名/raw 原始字段均来自外部平台数据,凡进 innerHTML 的文本必须先过 esc()
  46. function esc(value) {
  47. const div = document.createElement('div');
  48. div.textContent = String(value ?? '');
  49. return div.innerHTML;
  50. }
  51. function fmtCell(col, v) {
  52. if (col.type === 'pct') return fmtPct(v);
  53. if (col.type === 'money') return fmtMoney(v);
  54. if (col.type === 'int') return fmtNum(v);
  55. if (col.type === 'num') return v == null || v === '' ? '—' : Number(v).toFixed(2);
  56. if (col.type === 'date') return v ? ymdToDash(v) : '—';
  57. return v == null || v === '' ? '—' : String(v);
  58. }
  59. function fmtByCol(col, v) {
  60. if (v == null || v === '') return '—';
  61. if (!col) return String(v);
  62. if (col.type === 'pct') return fmtPct(v);
  63. if (col.type === 'money') return fmtMoney(v);
  64. if (col.type === 'int') return fmtNum(v);
  65. if (col.type === 'num') return Number(v).toFixed(2);
  66. return String(v);
  67. }
  68. // 紧凑数值(图表标签用):425725 → 42.6万
  69. function fmtCompact(v) {
  70. const n = Number(v);
  71. if (!Number.isFinite(n)) return '—';
  72. const abs = Math.abs(n);
  73. if (abs >= 1e8) return (n / 1e8).toFixed(1) + '亿';
  74. if (abs >= 1e4) return (n / 1e4).toFixed(1) + '万';
  75. return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
  76. }
  77. const RATE_THRESHOLDS = { clkExpRate: { good: 0.09, bad: 0.06 }, ordClkRate: { good: 0.025, bad: 0.02 } };
  78. function rateClass(key, v) {
  79. const t = RATE_THRESHOLDS[key];
  80. if (!t || v == null || v === '') return '';
  81. const n = Number(v);
  82. if (!Number.isFinite(n)) return '';
  83. if (n >= t.good) return 'val-good';
  84. if (n < t.bad) return 'val-bad';
  85. return 'val-warn';
  86. }
  87. const LS = {
  88. get(k, fallback) { try { const v = localStorage.getItem(k); return v == null ? fallback : JSON.parse(v); } catch { return fallback; } },
  89. set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* ignore */ } },
  90. };
  91. // ---------- 状态 ----------
  92. const state = {
  93. inited: false,
  94. entities: [],
  95. entity: null,
  96. preset: '30',
  97. mode: 'detail',
  98. rawView: false,
  99. page: 1,
  100. pageSize: 200,
  101. total: 0,
  102. pages: 1,
  103. shopFilter: new Set(),
  104. q: '',
  105. columns: [],
  106. visible: new Set(),
  107. sortKey: 'date',
  108. sortDir: 'desc',
  109. rows: [],
  110. summary: null,
  111. conn: null,
  112. syncShops: new Set(),
  113. syncPollTimer: null,
  114. reportAbort: null,
  115. analysisShopFilter: new Set(),
  116. analysisPreset: '30',
  117. comparePrev: true,
  118. };
  119. let chartMetric = LS.get('shanReports.chartMetric', 'grossAmt');
  120. const chartInstances = {};
  121. let anaCache = null;
  122. const $ = (id) => document.getElementById(id);
  123. // ---------- 日期区间 ----------
  124. function currentRange() {
  125. if (state.preset === 'custom') {
  126. const from = dashToYmd($('reportFromDate').value);
  127. const to = dashToYmd($('reportToDate').value);
  128. if (from && to && from <= to) return { from, to };
  129. return { from: daysAgoYmd(30), to: daysAgoYmd(0) };
  130. }
  131. if (String(state.preset) === '1') return { from: daysAgoYmd(1), to: daysAgoYmd(1) }; // 「昨日」:from=to=昨天
  132. const days = Number(state.preset) || 30;
  133. return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
  134. }
  135. function analysisRange() {
  136. const sel = $('analysisRangePreset');
  137. if (sel && String(sel.value) === '1') return { from: daysAgoYmd(1), to: daysAgoYmd(1) }; // 「昨日」
  138. const days = Number(sel && sel.value) || 30;
  139. return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
  140. }
  141. // ---------- 主体(账号)选择器:数据采集 / 数据湖 / 趋势图表 共用选中 ----------
  142. const ENTITY_SELECT_CONTAINERS = ['entityTabs', 'analysisEntityTabs', 'lakeEntityTabs'];
  143. const ENTITY_SEARCH_THRESHOLD = 8; // 账号超过该数量时下拉内显示搜索框
  144. function activeEntity() {
  145. return state.entities.find((e) => e.key === state.entity) || null;
  146. }
  147. function entityShopCount(e) {
  148. return (e.shops || []).length || e.shopCount || 0;
  149. }
  150. function selectEntity(key) {
  151. if (state.entity === key) return;
  152. state.entity = key;
  153. state.shopFilter = new Set();
  154. state.analysisShopFilter = new Set();
  155. state.sortKey = 'date'; state.sortDir = 'desc';
  156. LS.set('shanReports.entity', key);
  157. anaCache = null;
  158. renderEntityTabs();
  159. renderShopOptions();
  160. reloadReport();
  161. renderCharts();
  162. refreshPushBadge(); // 待推计数按主体算,切主体要重取
  163. }
  164. function closeEntitySelect(wrap) {
  165. const menu = wrap.querySelector('.entity-select-menu');
  166. const trigger = wrap.querySelector('.scan-dropdown-trigger');
  167. if (menu) menu.hidden = true;
  168. if (trigger) trigger.setAttribute('aria-expanded', 'false');
  169. }
  170. document.addEventListener('click', (event) => {
  171. for (const id of ENTITY_SELECT_CONTAINERS) {
  172. const wrap = $(id);
  173. if (wrap && !wrap.contains(event.target)) closeEntitySelect(wrap);
  174. }
  175. });
  176. function buildEntitySelect() {
  177. const current = activeEntity();
  178. const box = document.createElement('div');
  179. box.className = 'scan-dropdown entity-select';
  180. const trigger = document.createElement('button');
  181. trigger.type = 'button';
  182. trigger.className = 'scan-dropdown-trigger';
  183. trigger.setAttribute('aria-expanded', 'false');
  184. trigger.setAttribute('aria-haspopup', 'listbox');
  185. const label = document.createElement('span');
  186. label.textContent = current
  187. ? `${current.name || current.key}(${entityShopCount(current)} 家门店)`
  188. : '选择主体';
  189. trigger.appendChild(label);
  190. 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>');
  191. box.appendChild(trigger);
  192. const menu = document.createElement('div');
  193. menu.className = 'scan-dropdown-menu entity-select-menu';
  194. menu.setAttribute('role', 'listbox');
  195. menu.hidden = true;
  196. box.appendChild(menu);
  197. const withSearch = state.entities.length > ENTITY_SEARCH_THRESHOLD;
  198. let search = null;
  199. let noMatch = null;
  200. if (withSearch) {
  201. search = document.createElement('input');
  202. search.type = 'search';
  203. search.className = 'entity-select-search';
  204. search.placeholder = '搜索账号…';
  205. menu.appendChild(search);
  206. }
  207. const items = state.entities.map((e) => {
  208. const item = document.createElement('button');
  209. item.type = 'button';
  210. item.className = 'scan-dropdown-item';
  211. item.dataset.value = e.key;
  212. item.dataset.text = `${e.name || ''} ${e.key}`.toLowerCase();
  213. item.setAttribute('role', 'option');
  214. 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>';
  215. item.children[1].textContent = e.name || e.key;
  216. const cnt = entityShopCount(e);
  217. item.lastElementChild.textContent = cnt ? `${cnt} 家门店` : '';
  218. const active = e.key === state.entity;
  219. item.classList.toggle('selected', active);
  220. item.setAttribute('aria-selected', String(active));
  221. item.addEventListener('click', () => {
  222. closeEntitySelect(box);
  223. selectEntity(e.key);
  224. });
  225. menu.appendChild(item);
  226. return item;
  227. });
  228. if (withSearch) {
  229. noMatch = document.createElement('div');
  230. noMatch.className = 'entity-select-no-match';
  231. noMatch.textContent = '无匹配账号';
  232. noMatch.hidden = true;
  233. menu.appendChild(noMatch);
  234. }
  235. const applyFilter = () => {
  236. const kw = (search && search.value.trim().toLowerCase()) || '';
  237. let visible = 0;
  238. for (const it of items) {
  239. const hit = !kw || it.dataset.text.includes(kw);
  240. it.hidden = !hit;
  241. if (hit) visible += 1;
  242. }
  243. if (noMatch) noMatch.hidden = visible > 0;
  244. };
  245. if (search) search.addEventListener('input', applyFilter);
  246. trigger.addEventListener('click', () => {
  247. const willOpen = menu.hidden;
  248. closeEntitySelect(box);
  249. menu.hidden = !willOpen;
  250. trigger.setAttribute('aria-expanded', String(willOpen));
  251. if (willOpen) {
  252. applyFilter();
  253. if (search) search.focus();
  254. }
  255. });
  256. return box;
  257. }
  258. /**
  259. * 空状态:与有数据时同形态的下拉,菜单内容换成「怎么拿到数据」的引导。
  260. * 三个页面的动作不同:采集页直接打开同步面板;趋势页跳转采集页并弹出面板;
  261. * 中台页额外提示可从云端拉取。
  262. */
  263. function buildEntitySelectEmpty(containerId) {
  264. const box = document.createElement('div');
  265. box.className = 'scan-dropdown entity-select';
  266. const trigger = document.createElement('button');
  267. trigger.type = 'button';
  268. trigger.className = 'scan-dropdown-trigger is-empty';
  269. trigger.setAttribute('aria-expanded', 'false');
  270. trigger.setAttribute('aria-haspopup', 'listbox');
  271. const label = document.createElement('span');
  272. label.textContent = '暂无主体';
  273. trigger.appendChild(label);
  274. 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>');
  275. box.appendChild(trigger);
  276. const menu = document.createElement('div');
  277. menu.className = 'scan-dropdown-menu entity-select-menu';
  278. menu.hidden = true;
  279. box.appendChild(menu);
  280. const isCollect = containerId === 'entityTabs';
  281. const isLake = containerId === 'lakeEntityTabs';
  282. const guide = document.createElement('div');
  283. guide.className = 'entity-select-guide';
  284. const step = document.createElement('div');
  285. step.className = 'entity-select-guide-steps';
  286. step.textContent = isCollect
  287. ? '在「淘宝闪购商家版」客户端登录账号,再点击本页的「同步数据」,完成后这里会显示账号。'
  288. : '在「淘宝闪购商家版」客户端登录账号,到「数据采集」页完成一次「同步数据」,完成后这里会显示账号。';
  289. guide.appendChild(step);
  290. if (isLake) {
  291. const cloud = document.createElement('div');
  292. cloud.className = 'entity-select-guide-steps';
  293. cloud.textContent = '其他电脑已推送过云端?在本页「湖全景」右上角执行「云端拉取」即可恢复数据。';
  294. guide.appendChild(cloud);
  295. }
  296. const btn = document.createElement('button');
  297. btn.type = 'button';
  298. btn.className = 'btn btn-primary btn-small entity-select-guide-btn';
  299. btn.textContent = isCollect ? '打开同步面板' : '去「数据采集」同步';
  300. btn.addEventListener('click', async () => {
  301. closeEntitySelect(box);
  302. if (!isCollect) {
  303. const nav = document.querySelector('.nav-item[data-view="reports"]');
  304. if (nav) nav.click();
  305. if (window.shanReportsInit) { try { await window.shanReportsInit(); } catch { /* ignore */ } }
  306. }
  307. const syncBtn = document.getElementById('btnOpenSync');
  308. if (syncBtn) syncBtn.click();
  309. });
  310. guide.appendChild(btn);
  311. menu.appendChild(guide);
  312. trigger.addEventListener('click', () => {
  313. const willOpen = menu.hidden;
  314. closeEntitySelect(box);
  315. menu.hidden = !willOpen;
  316. trigger.setAttribute('aria-expanded', String(willOpen));
  317. });
  318. return box;
  319. }
  320. function renderEntityTabs() {
  321. for (const containerId of ENTITY_SELECT_CONTAINERS) {
  322. const wrap = $(containerId);
  323. if (!wrap) continue;
  324. wrap.innerHTML = '';
  325. if (!state.entities.length) {
  326. wrap.appendChild(buildEntitySelectEmpty(containerId));
  327. continue;
  328. }
  329. wrap.appendChild(buildEntitySelect());
  330. }
  331. }
  332. // ---------- 门店多选下拉(数据采集 / 同步 / 趋势图表 三处) ----------
  333. function toggleDropdown(menuId, triggerId, open) {
  334. const trigger = $(triggerId);
  335. const menu = menuId ? $(menuId) : null;
  336. const shouldOpen = open === undefined ? (menu ? menu.hidden : false) : open;
  337. if (menu) menu.hidden = !shouldOpen;
  338. if (trigger) trigger.setAttribute('aria-expanded', String(shouldOpen));
  339. }
  340. function fillShopMenu(menu, shops) {
  341. menu.innerHTML = '';
  342. const all = document.createElement('button');
  343. all.type = 'button';
  344. all.className = 'scan-dropdown-item';
  345. all.dataset.value = '__all__';
  346. all.setAttribute('role', 'option');
  347. 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>';
  348. menu.appendChild(all);
  349. const sorted = shops.slice().sort((a, b) => String(a.shopName).localeCompare(String(b.shopName), 'zh-CN'));
  350. for (const s of sorted) {
  351. const item = document.createElement('button');
  352. item.type = 'button';
  353. item.className = 'scan-dropdown-item';
  354. item.dataset.value = String(s.shopId);
  355. item.setAttribute('role', 'option');
  356. 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>';
  357. item.lastElementChild.textContent = s.shopName;
  358. menu.appendChild(item);
  359. }
  360. }
  361. function renderDropdownSelection(menu, selection, textEl, allLabel) {
  362. menu.querySelectorAll('.scan-dropdown-item').forEach((node) => {
  363. const selected = selection.has(node.dataset.value);
  364. node.classList.toggle('selected', selected);
  365. node.setAttribute('aria-selected', String(selected));
  366. });
  367. const picked = Array.from(selection).filter((v) => v !== '__all__');
  368. textEl.textContent = picked.length ? `已选 ${picked.length} 家门店` : allLabel;
  369. }
  370. function renderShopOptions() {
  371. const entity = activeEntity();
  372. const shops = (entity && entity.shops) || [];
  373. const menus = [
  374. { menu: $('reportShopMenu'), sel: state.shopFilter, text: $('reportShopText'), label: '全部门店' },
  375. { menu: $('syncShopMenu'), sel: state.syncShops, text: $('syncShopText'), label: '选择门店' },
  376. { menu: $('anaShopMenu'), sel: state.analysisShopFilter, text: $('anaShopText'), label: '全部门店' },
  377. ];
  378. for (const m of menus) {
  379. if (!m.menu) continue;
  380. fillShopMenu(m.menu, shops);
  381. renderDropdownSelection(m.menu, m.sel, m.text, m.label);
  382. }
  383. }
  384. function makeDropdownHandler(menu, selection, textEl, allLabel, onChange) {
  385. return (event) => {
  386. const item = event.target.closest('[data-value]');
  387. if (!item) return;
  388. const value = item.dataset.value;
  389. if (value === '__all__') selection.clear();
  390. else if (selection.has(value)) selection.delete(value);
  391. else selection.add(value);
  392. renderDropdownSelection(menu, selection, textEl, allLabel);
  393. if (onChange) onChange();
  394. };
  395. }
  396. // ---------- 报表表格(数据采集页) ----------
  397. function metricColumns() {
  398. return state.columns.filter((c) => c.key !== 'date' && c.key !== 'shopName' && c.key !== 'account' && state.visible.has(c.key));
  399. }
  400. function tableColumns() {
  401. // 全部字段模式(数据中台):固定业务列 + 从首行 raw 发现的全部原始字段(原始 API 字段名)
  402. if (state.rawView) {
  403. const base = [
  404. { key: 'account', label: '账号名', type: 'text' },
  405. { key: 'date', label: '日期', type: 'date' },
  406. { key: 'scope', label: '维度', type: 'text' },
  407. { key: 'shopId', label: '门店ID', type: 'text' },
  408. { key: 'shopName', label: '门店名称', type: 'text' },
  409. ];
  410. const rawCols = [];
  411. const seen = new Set(base.map((c) => c.label));
  412. for (const r of state.rows) {
  413. const raw = r && r.raw;
  414. if (!raw || typeof raw !== 'object') continue;
  415. for (const k of Object.keys(raw)) {
  416. if (seen.has(k)) continue;
  417. seen.add(k);
  418. const sample = raw[k];
  419. const isNum = typeof sample === 'number' || (typeof sample === 'string' && sample !== '' && Number.isFinite(Number(sample)));
  420. rawCols.push({ key: `raw.${k}`, label: k, type: isNum ? 'num' : 'text', raw: true });
  421. }
  422. }
  423. return [...base, ...rawCols];
  424. }
  425. const metricCols = metricColumns();
  426. const accountCol = { key: 'account', label: '账号名', type: 'text' };
  427. if (state.mode === 'shop') return [accountCol, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
  428. if (state.mode === 'date') return [accountCol, { key: 'date', label: '日期', type: 'date' }, ...metricCols];
  429. return [accountCol, { key: 'date', label: '日期', type: 'date' }, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
  430. }
  431. // 取单元格值:raw. 前缀深入原始字段对象;对象/数组序列化展示
  432. function cellValue(r, key) {
  433. if (!key.startsWith('raw.')) return r[key];
  434. const raw = r && r.raw;
  435. if (!raw || typeof raw !== 'object') return undefined;
  436. return raw[key.slice(4)];
  437. }
  438. function sortedRows() {
  439. return state.rows.slice().sort((a, b) => {
  440. const av = cellValue(a, state.sortKey), bv = cellValue(b, state.sortKey);
  441. if (av == null || av === '') return 1;
  442. if (bv == null || bv === '') return -1;
  443. if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * (state.sortDir === 'asc' ? 1 : -1);
  444. return String(av).localeCompare(String(bv), 'zh-CN') * (state.sortDir === 'asc' ? 1 : -1);
  445. });
  446. }
  447. function renderTable() {
  448. const cols = tableColumns();
  449. const thead = $('reportTableHead');
  450. const tbody = $('reportTableBody');
  451. // 全部字段模式列多且长:列宽随内容、容器横向滚动,避免 60 列挤成一团
  452. $('reportTable').classList.toggle('raw-cols', state.rawView);
  453. $('reportRowCount').textContent = `${state.rows.length} 行`;
  454. const empty = $('reportEmpty');
  455. if (!state.rows.length) {
  456. thead.innerHTML = '';
  457. tbody.innerHTML = '';
  458. empty.hidden = false;
  459. return;
  460. }
  461. empty.hidden = true;
  462. thead.innerHTML = '<tr>' + cols.map((c) => {
  463. const active = state.sortKey === c.key;
  464. const arrow = active ? (state.sortDir === 'asc' ? ' ↑' : ' ↓') : '';
  465. 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>`;
  466. }).join('') + '</tr>';
  467. tbody.innerHTML = sortedRows().map((r) => '<tr>' + cols.map((c) => {
  468. const isNum = ['money', 'int', 'pct', 'num'].includes(c.type);
  469. const v = cellValue(r, c.key);
  470. const display = (v != null && typeof v === 'object') ? JSON.stringify(v) : v;
  471. const cls = `col-${c.type}${isNum ? ' col-num' : ''}${c.type === 'pct' ? ' ' + rateClass(c.key, v) : ''}`;
  472. return `<td class="${cls}">${esc(fmtCell(c, display))}</td>`;
  473. }).join('') + '</tr>').join('');
  474. }
  475. // ---------- 数据加载(数据采集页) ----------
  476. async function loadReport() {
  477. const entity = state.entity;
  478. if (!entity) {
  479. state.rows = []; state.summary = null;
  480. renderTable(); renderPager();
  481. return;
  482. }
  483. if (state.reportAbort) state.reportAbort.abort();
  484. const ctrl = new AbortController();
  485. state.reportAbort = ctrl;
  486. const { from, to } = currentRange();
  487. try {
  488. let url = state.mode === 'detail'
  489. ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}${state.rawView ? '' : '&scope=shop'}&page=${state.page}&pageSize=${state.pageSize}`
  490. : `/api/reports/aggregate?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&group=${state.mode}`;
  491. if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
  492. if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
  493. const data = await jsonFetch(url, { signal: ctrl.signal });
  494. if (state.entity !== entity) return;
  495. state.rows = data.rows || [];
  496. state.summary = data.summary || null;
  497. state.total = state.mode === 'detail' ? (data.total ?? state.rows.length) : state.rows.length;
  498. state.pages = state.mode === 'detail' ? (data.pages ?? 1) : 1;
  499. renderTable();
  500. renderPager();
  501. } catch (e) {
  502. if (e.name === 'AbortError') return;
  503. showToast('报表加载失败:' + e.message, 'error');
  504. }
  505. }
  506. // 筛选/主体/视图等变化:回到第一页再加载
  507. function reloadReport() {
  508. state.page = 1;
  509. loadReport();
  510. }
  511. // 分页条:明细模式服务端分页(排序作用于当前页);聚合模式行数少不分页
  512. function renderPager() {
  513. const el = $('reportPager');
  514. if (!el) return;
  515. if (state.mode !== 'detail' || state.pages <= 1) {
  516. el.innerHTML = state.mode === 'detail' ? `<span class="pager-info">共 ${state.total} 行</span>` : '';
  517. return;
  518. }
  519. const from = (state.page - 1) * state.pageSize + 1;
  520. const to = Math.min(state.total, state.page * state.pageSize);
  521. const btn = (label, target, disabled) =>
  522. `<button type="button" class="btn btn-ghost btn-small" data-page="${target}" ${disabled ? 'disabled' : ''}>${label}</button>`;
  523. el.innerHTML = `
  524. ${btn('‹ 上一页', state.page - 1, state.page <= 1)}
  525. <span class="pager-info">第 ${state.page} / ${state.pages} 页 · ${from}-${to} 行 · 共 ${state.total} 行</span>
  526. ${btn('下一页 ›', state.page + 1, state.page >= state.pages)}
  527. <span class="pager-info pager-note">列排序作用于当前页</span>
  528. `;
  529. }
  530. function changePage(page) {
  531. if (page < 1 || page > state.pages || page === state.page) return;
  532. state.page = page;
  533. loadReport();
  534. const wrap = $('reportTableWrap');
  535. if (wrap && wrap.scrollIntoView) wrap.scrollIntoView({ block: 'nearest' });
  536. }
  537. // ---------- 连接状态 ----------
  538. // 服务端 hint 已结构化({title, steps:[{text, code}], note});兜底兼容旧版纯文本
  539. function connHintPlain(hint) {
  540. if (!hint) return '';
  541. if (typeof hint === 'string') return hint;
  542. const parts = [];
  543. if (hint.title) parts.push(hint.title);
  544. (hint.steps || []).forEach((s, i) => parts.push(`${i + 1}. ${s.text || ''}${s.code ? ' ' + s.code : ''}`));
  545. if (hint.note) parts.push('注意:' + hint.note);
  546. return parts.join('\n');
  547. }
  548. // 同步弹窗内渲染「以调试端口启动客户端」分步指引(命令用 code 胶囊,自检地址可点击)
  549. function renderSyncConnHint(napos) {
  550. const wrap = $('syncHint');
  551. wrap.innerHTML = '';
  552. const hint = napos && napos.hint;
  553. if (!hint) return;
  554. const box = document.createElement('div');
  555. box.className = 'conn-steps';
  556. if (Array.isArray(hint.steps)) {
  557. const title = document.createElement('div');
  558. title.className = 'conn-steps-title';
  559. title.textContent = `未连接客户端 · ${hint.title || '以调试端口启动客户端'}`;
  560. box.appendChild(title);
  561. const list = document.createElement('div');
  562. list.className = 'conn-steps-list';
  563. const rows = hint.steps.slice();
  564. if (napos.selfCheckUrl) {
  565. rows.push({ text: '自检:用浏览器打开', link: napos.selfCheckUrl, tail: ',看到一段 JSON 即端口已开。' });
  566. }
  567. rows.forEach((s, i) => {
  568. const row = document.createElement('div');
  569. row.className = 'conn-step';
  570. const idx = document.createElement('span');
  571. idx.className = 'conn-step-idx';
  572. idx.textContent = String(i + 1);
  573. row.appendChild(idx);
  574. const txt = document.createElement('span');
  575. txt.className = 'conn-step-text';
  576. txt.appendChild(document.createTextNode(s.text || ''));
  577. if (s.code) {
  578. const code = document.createElement('code');
  579. code.textContent = s.code;
  580. txt.appendChild(code);
  581. }
  582. if (s.link) {
  583. txt.appendChild(document.createTextNode(' '));
  584. const a = document.createElement('a');
  585. a.href = s.link;
  586. a.target = '_blank';
  587. a.rel = 'noopener';
  588. a.textContent = s.link;
  589. txt.appendChild(a);
  590. if (s.tail) txt.appendChild(document.createTextNode(s.tail));
  591. }
  592. row.appendChild(txt);
  593. list.appendChild(row);
  594. });
  595. box.appendChild(list);
  596. if (hint.note) {
  597. const note = document.createElement('div');
  598. note.className = 'conn-steps-note';
  599. note.textContent = hint.note;
  600. box.appendChild(note);
  601. }
  602. } else {
  603. // 旧版纯文本兜底
  604. const p = document.createElement('div');
  605. p.textContent = typeof hint === 'string' ? hint : '';
  606. box.appendChild(p);
  607. if (napos.selfCheckUrl) {
  608. const p2 = document.createElement('div');
  609. p2.textContent = `自检:用浏览器打开 ${napos.selfCheckUrl},看到一段 JSON 即端口已开。`;
  610. box.appendChild(p2);
  611. }
  612. }
  613. wrap.appendChild(box);
  614. }
  615. async function refreshConn() {
  616. const badge = $('reportConnBadge');
  617. try {
  618. const data = await jsonFetch('/api/reports/status');
  619. state.conn = data.napos || null;
  620. state.entities = data.entities || [];
  621. if (!state.entity || !state.entities.some((e) => e.key === state.entity)) {
  622. const saved = LS.get('shanReports.entity', null);
  623. state.entity = (saved && state.entities.some((e) => e.key === saved)) ? saved : (state.entities[0] ? state.entities[0].key : null);
  624. }
  625. renderEntityTabs();
  626. renderShopOptions();
  627. const ok = state.conn && state.conn.connected;
  628. badge.textContent = ok ? '客户端已连接' : '客户端未连接';
  629. badge.className = 'badge ' + (ok ? 'badge-ok' : 'badge-fail');
  630. badge.title = ok
  631. ? `调试端口 ${state.conn.port}`
  632. : [state.conn && state.conn.error, connHintPlain(state.conn && state.conn.hint)].filter(Boolean).join('\n') || '未检测到调试端口';
  633. } catch {
  634. badge.textContent = '状态检测失败';
  635. badge.className = 'badge badge-fail';
  636. }
  637. }
  638. // ---------- 列设置 ----------
  639. function loadColumnPrefs() {
  640. // 默认只展示原始/主指标列(营业时长、异常关店时长、商品销售额为预留列,数据源接入后自动回填);
  641. // 下单转化率、客单价、支出、活动订单、活动补贴、推广ROI、30日复购率等派生指标默认隐藏,可在列设置打开。
  642. const DEFAULT_VISIBLE_COLS = new Set([
  643. 'bizHours', 'abnormalCloseHours', 'validOrderCnt', 'incomeAmt', 'grossAmt',
  644. 'goodsSaleAmt', 'expUv', 'orderUserCnt', 'clkExpRate',
  645. ]);
  646. const saved = LS.get('shanReports.columns.v2', null);
  647. const defaults = new Set(state.columns.filter((c) => DEFAULT_VISIBLE_COLS.has(c.key)).map((c) => c.key));
  648. if (Array.isArray(saved)) {
  649. const valid = new Set(state.columns.map((c) => c.key));
  650. state.visible = new Set(saved.filter((k) => valid.has(k)));
  651. } else {
  652. state.visible = defaults;
  653. }
  654. if (!state.visible.size) state.visible = defaults;
  655. }
  656. function renderColumnCfg() {
  657. const list = $('columnCfgList');
  658. list.innerHTML = '';
  659. for (const c of state.columns) {
  660. if (c.locked) continue;
  661. const label = document.createElement('label');
  662. label.className = 'colcfg-item';
  663. const checked = state.visible.has(c.key) ? 'checked' : '';
  664. label.innerHTML = `<input type="checkbox" value="${esc(c.key)}" ${checked}><span></span>${c.reserved ? '<em class="colcfg-reserved">预留</em>' : ''}`;
  665. label.querySelector('span').textContent = c.label;
  666. list.appendChild(label);
  667. }
  668. }
  669. function applyColumnCfg() {
  670. const picks = new Set();
  671. $('columnCfgList').querySelectorAll('input[type=checkbox]').forEach((cb) => {
  672. if (cb.checked) picks.add(cb.value);
  673. });
  674. state.visible = picks;
  675. LS.set('shanReports.columns.v2', Array.from(picks));
  676. $('modalReportColumns').hidden = true;
  677. renderTable();
  678. }
  679. // ---------- 同步 ----------
  680. function renderSyncProgress(st) {
  681. const pct = st.planTotal ? Math.round((st.done / st.planTotal) * 100) : 0;
  682. $('syncProgressBar').style.width = pct + '%';
  683. $('syncProgressText').textContent = `${st.done} / ${st.planTotal}`;
  684. $('syncStatAdded').textContent = `新增 ${st.added}`;
  685. $('syncStatUpdated').textContent = `更新 ${st.updated}`;
  686. $('syncStatFailed').textContent = `失败 ${st.failed}`;
  687. const steps = (st.steps || []).slice(-8);
  688. $('syncSteps').innerHTML = steps.map((s) => {
  689. const cls = s.ok ? 'ok' : 'fail';
  690. const detail = s.ok
  691. ? `+${s.added}/~${s.updated}` + (s.rows != null ? ` · ${s.rows}行` : '') + (s.nonZero === 0 && s.rows > 0 ? ' · ⚠全零' : '')
  692. : esc((s.error || '失败').slice(0, 60));
  693. return `<div class="sync-step ${cls}"><span></span><em>${detail}</em></div>`;
  694. }).join('');
  695. const stepNames = $('syncSteps').querySelectorAll('.sync-step span');
  696. steps.forEach((s, i) => { stepNames[i].textContent = s.label; });
  697. $('btnSyncCancel').hidden = !st.running;
  698. $('btnStartSync').disabled = !!st.running;
  699. $('btnStartSync').textContent = st.running ? '同步中…' : '开始同步';
  700. }
  701. function pollSyncStatus() {
  702. if (state.syncPollTimer) clearInterval(state.syncPollTimer);
  703. let pollErrors = 0; // 连续失败计数:服务端持续不可达时停止轮询,避免永久空转
  704. state.syncPollTimer = setInterval(async () => {
  705. try {
  706. const st = await jsonFetch('/api/reports/sync/status');
  707. pollErrors = 0;
  708. renderSyncProgress(st);
  709. if (!st.running) {
  710. clearInterval(state.syncPollTimer);
  711. state.syncPollTimer = null;
  712. if (st.error) showToast('同步出错:' + st.error, 'error');
  713. else showToast(st.cancelled ? '同步已取消' : `同步完成:新增 ${st.added},更新 ${st.updated}`);
  714. refreshConn().then(loadReport);
  715. }
  716. } catch {
  717. pollErrors++;
  718. if (pollErrors >= 5) {
  719. clearInterval(state.syncPollTimer);
  720. state.syncPollTimer = null;
  721. showToast('同步状态轮询失败,已停止;可用「刷新」手动恢复', 'error');
  722. }
  723. }
  724. }, 900);
  725. }
  726. async function loadAutoSyncPrefs() {
  727. try {
  728. const s = await jsonFetch('/api/settings');
  729. $('autoSyncEnabled').checked = !!s.autoSyncEnabled;
  730. if (s.autoSyncTime) $('autoSyncTime').value = s.autoSyncTime;
  731. } catch { /* 设置读取失败时保留默认值 */ }
  732. }
  733. async function saveAutoSyncPrefs() {
  734. try {
  735. await jsonFetch('/api/settings', {
  736. method: 'POST',
  737. headers: { 'Content-Type': 'application/json' },
  738. body: JSON.stringify({ autoSyncEnabled: $('autoSyncEnabled').checked, autoSyncTime: $('autoSyncTime').value }),
  739. });
  740. showToast('自动同步设置已保存');
  741. } catch (e) {
  742. showToast('自动同步设置保存失败:' + e.message, 'error');
  743. }
  744. }
  745. async function openSyncModal() {
  746. $('modalReportSync').hidden = false;
  747. $('syncEntityBadge').textContent = '识别主体中…';
  748. $('syncEntityBadge').className = 'badge badge-info';
  749. $('syncShopPickWrap').hidden = true;
  750. $('syncProgressWrap').hidden = true;
  751. // 首次打开时记住默认说明文案;连接失败会覆盖为分步指引,连上后要能恢复
  752. if (!state.syncHintDefault) state.syncHintDefault = $('syncHint').textContent;
  753. else $('syncHint').textContent = state.syncHintDefault;
  754. loadAutoSyncPrefs();
  755. try {
  756. const tree = await jsonFetch('/api/reports/tree');
  757. const badge = $('syncEntityBadge');
  758. badge.textContent = `当前主体:${(tree.entity && tree.entity.name) || (tree.merchant && tree.merchant.name) || '未知'} · 门店 ${(tree.shops || []).length} 家`;
  759. badge.className = 'badge badge-ok';
  760. if (tree.entity && tree.entity.key && tree.entity.key !== state.entity) {
  761. state.entity = tree.entity.key;
  762. LS.set('shanReports.entity', tree.entity.key);
  763. }
  764. await refreshConn();
  765. renderSyncShopOptions(tree.shops || []);
  766. } catch (e) {
  767. const badge = $('syncEntityBadge');
  768. badge.textContent = '未连接客户端:' + e.message;
  769. badge.className = 'badge badge-fail';
  770. try {
  771. const st = await jsonFetch('/api/reports/status');
  772. renderSyncConnHint(st.napos || {});
  773. } catch { /* 服务不可达时保留默认文案 */ }
  774. }
  775. }
  776. function renderSyncShopOptions(shops) {
  777. state.syncShops.clear();
  778. fillShopMenu($('syncShopMenu'), shops);
  779. renderDropdownSelection($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店');
  780. }
  781. async function startSync() {
  782. const scope = $('syncScope').value;
  783. const days = Number($('syncRange').value) || 30;
  784. const body = { from: daysAgoYmd(days), to: daysAgoYmd(1), scope: scope === 'custom' ? 'shop' : scope };
  785. if (scope === 'custom') {
  786. const shops = Array.from(state.syncShops).filter((v) => v !== '__all__');
  787. if (!shops.length) { showToast('请先选择要同步的门店', 'error'); return; }
  788. body.shopIds = shops;
  789. }
  790. try {
  791. await jsonFetch('/api/reports/sync', {
  792. method: 'POST',
  793. headers: { 'Content-Type': 'application/json' },
  794. body: JSON.stringify(body),
  795. });
  796. $('syncProgressWrap').hidden = false;
  797. renderSyncProgress({ planTotal: 0, done: 0, added: 0, updated: 0, failed: 0, steps: [], running: true });
  798. pollSyncStatus();
  799. } catch (e) {
  800. showToast('同步启动失败:' + e.message, 'error');
  801. }
  802. }
  803. // ---------- 导出 ----------
  804. function exportCsv() {
  805. if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
  806. // 全部字段模式:行数据只在客户端(raw 列由前端发现),就地生成 CSV
  807. if (state.rawView) {
  808. const cols = tableColumns();
  809. const cell = (v) => {
  810. const s = (v == null || typeof v !== 'object') ? String(v ?? '') : JSON.stringify(v);
  811. return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  812. };
  813. const lines = [cols.map((c) => cell(c.label)).join(',')];
  814. for (const r of sortedRows()) lines.push(cols.map((c) => cell(cellValue(r, c.key))).join(','));
  815. const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
  816. const a = document.createElement('a');
  817. a.href = URL.createObjectURL(blob);
  818. a.download = `湖明细-全部字段-${state.entity}.csv`;
  819. a.click();
  820. URL.revokeObjectURL(a.href);
  821. return;
  822. }
  823. const { from, to } = currentRange();
  824. let url = `/api/reports/export?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`;
  825. if (state.mode !== 'detail') url += `&group=${state.mode}`;
  826. if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
  827. if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
  828. window.location.href = url;
  829. }
  830. function exportHtml() {
  831. if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
  832. const { from, to } = analysisRange();
  833. window.open(`/api/reports/report-html?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`, '_blank');
  834. }
  835. // ---------- 趋势图表 · 分析模板 ----------
  836. // 模板 = 对比方式 + 指标组合 + 图表布局的预设;看谁(整校/某食堂/某档口)由
  837. // 主体选择器 + 门店筛选决定,与模板正交。新增模板只需往 TEMPLATES 注册一项。
  838. function chartCol(key) {
  839. return state.columns.find((c) => c.key === key) || { key, label: key, type: 'num' };
  840. }
  841. function metricColumnOptions() {
  842. return state.columns.filter((c) => ['int', 'money', 'pct', 'num'].includes(c.type) && !c.reserved);
  843. }
  844. function renderChartMetricSelect() {
  845. const sel = $('chartMetric');
  846. if (!sel) return;
  847. sel.innerHTML = '';
  848. for (const c of metricColumnOptions()) {
  849. const opt = document.createElement('option');
  850. opt.value = c.key;
  851. opt.textContent = c.label;
  852. if (c.key === chartMetric) opt.selected = true;
  853. sel.appendChild(opt);
  854. }
  855. if (!sel.value && sel.options.length) sel.value = sel.options[0].value;
  856. chartMetric = sel.value || 'grossAmt';
  857. }
  858. let anaTemplate = LS.get('shanReports.anaTemplate', 'overview');
  859. /** 模板栏:分组胶囊按钮(复用 seg-control),是本页的一级切换控件 */
  860. function renderTemplateSelect() {
  861. const bar = $('anaTplBar');
  862. if (!bar) return;
  863. bar.innerHTML = '';
  864. let seg = null;
  865. let groupLabel = null;
  866. for (const t of TEMPLATES) {
  867. if (groupLabel !== t.group) {
  868. groupLabel = t.group;
  869. const label = document.createElement('span');
  870. label.className = 'ana-tpl-group';
  871. label.textContent = t.group;
  872. bar.appendChild(label);
  873. seg = document.createElement('div');
  874. seg.className = 'seg-control';
  875. bar.appendChild(seg);
  876. }
  877. const btn = document.createElement('button');
  878. btn.type = 'button';
  879. btn.dataset.tpl = t.id;
  880. btn.textContent = t.name;
  881. btn.title = t.desc || '';
  882. btn.classList.toggle('active', t.id === anaTemplate);
  883. seg.appendChild(btn);
  884. }
  885. const firstBtn = bar.querySelector('button');
  886. if (firstBtn && !bar.querySelector('button.active')) {
  887. anaTemplate = firstBtn.dataset.tpl;
  888. firstBtn.classList.add('active');
  889. }
  890. }
  891. function activeTemplate() {
  892. return TEMPLATES.find((t) => t.id === anaTemplate) || TEMPLATES[0];
  893. }
  894. function applyTemplateControls(tpl) {
  895. const metricField = $('anaMetricField');
  896. if (metricField) metricField.hidden = !tpl.usesMetric;
  897. const compareField = $('anaCompareField');
  898. if (compareField) compareField.hidden = !tpl.usesCompare;
  899. const desc = $('anaTplDesc');
  900. if (desc) desc.textContent = tpl.desc || '';
  901. }
  902. const AXIS_TXT = { axisLabel: { color: '#6b6560' }, axisLine: { lineStyle: { color: '#e8e4df' } } };
  903. const SPLIT_LINE = { lineStyle: { color: '#f0ede8' } };
  904. const ACCENT = '#ff6b35';
  905. const COLOR_OK = '#1a8a4c';
  906. const COLOR_FAIL = '#c8402e';
  907. const COLOR_INFO = '#2563eb';
  908. const COLOR_PREV = '#a5a09a';
  909. // ---------- 面板 / 图表 DOM 辅助 ----------
  910. function disposeAllCharts() {
  911. for (const k of Object.keys(chartInstances)) {
  912. try { chartInstances[k].dispose(); } catch { /* ignore */ }
  913. delete chartInstances[k];
  914. }
  915. }
  916. function makePanel(grid, title, span) {
  917. const cell = document.createElement('div');
  918. cell.className = 'panel ana-cell' + (span === 3 ? ' ana-span3' : (span === 2 ? ' ana-span2' : ''));
  919. const head = document.createElement('div');
  920. head.className = 'panel-title';
  921. if (title) {
  922. const label = document.createElement('span');
  923. label.textContent = title;
  924. head.appendChild(label);
  925. }
  926. cell.appendChild(head);
  927. grid.appendChild(cell);
  928. return { cell, head };
  929. }
  930. function panelChart(panel, instanceKey) {
  931. const dom = document.createElement('div');
  932. dom.className = 'chart chart-lg';
  933. panel.cell.appendChild(dom);
  934. if (typeof echarts === 'undefined') return null;
  935. const chart = echarts.init(dom);
  936. chartInstances[instanceKey] = chart;
  937. return chart;
  938. }
  939. function panelNote(panel, text) {
  940. const note = document.createElement('div');
  941. note.className = 'ana-note';
  942. note.textContent = text;
  943. panel.cell.appendChild(note);
  944. return note;
  945. }
  946. function panelEmpty(grid, msg, span) {
  947. const panel = makePanel(grid, '', span);
  948. const box = document.createElement('div');
  949. box.className = 'ana-empty';
  950. box.textContent = msg;
  951. panel.cell.appendChild(box);
  952. return panel;
  953. }
  954. function panelEmptyIn(panel, msg) {
  955. const box = document.createElement('div');
  956. box.className = 'ana-empty';
  957. box.textContent = msg;
  958. panel.cell.appendChild(box);
  959. }
  960. // ---------- 数据质量标注 / 日期辅助 ----------
  961. function ymdDate(ymd) {
  962. return new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
  963. }
  964. function shiftYmd(ymd, delta) {
  965. const d = ymdDate(ymd);
  966. d.setDate(d.getDate() + delta);
  967. return ymdLocal(d);
  968. }
  969. /** 逐日序列:补全区间内缺失日期(null 断线),并标记「全零天」(曝光/订单/营业额全为 0) */
  970. function buildDailySeries(data, field) {
  971. const rows = new Map(data.byDate.map((r) => [r.date, r]));
  972. const sorted = [...rows.keys()].sort();
  973. // 今天通常未结算:尾部以最后有数据的一天为界,避免图表天天挂着「缺今天」的灰带
  974. const last = sorted.length ? sorted[sorted.length - 1] : data.to;
  975. const end = last < data.to ? last : data.to;
  976. const dates = [];
  977. const values = [];
  978. const zeroDays = new Set();
  979. const missingDays = new Set();
  980. for (let d = data.from; d <= end; d = shiftYmd(d, 1)) {
  981. dates.push(d);
  982. const r = rows.get(d);
  983. if (!r) { values.push(null); missingDays.add(d); continue; }
  984. const v = Number(r[field]);
  985. values.push(Number.isFinite(v) ? v : null);
  986. if (!(Number(r.expUv) > 0) && !(Number(r.validOrderCnt) > 0) && !(Number(r.grossAmt) > 0)) zeroDays.add(d);
  987. }
  988. return { dates, values, zeroDays, missingDays };
  989. }
  990. /** 缺数日期 → 灰色底纹(挂在某个 series 的 markArea 上) */
  991. function missingMarkAreas(series) {
  992. const areas = [];
  993. let start = null;
  994. for (const d of series.dates) {
  995. if (series.missingDays.has(d)) { if (!start) start = d; }
  996. else if (start) { areas.push([{ xAxis: ymdToDash(start), itemStyle: { color: 'rgba(107,101,96,0.07)' } }, { xAxis: ymdToDash(d) }]); start = null; }
  997. }
  998. if (start) areas.push([{ xAxis: ymdToDash(start), itemStyle: { color: 'rgba(107,101,96,0.07)' } }, { xAxis: ymdToDash(series.dates[series.dates.length - 1]) }]);
  999. return areas;
  1000. }
  1001. /** 全零天 → 空心点(取数异常返回全零行时,图表层一眼可见,不会伪装成生意暴跌) */
  1002. function hollowPoints(series) {
  1003. return series.values.map((v, i) => {
  1004. if (v == null) return null;
  1005. if (series.zeroDays.has(series.dates[i])) {
  1006. return { value: v, symbol: 'circle', symbolSize: 7, itemStyle: { color: '#fff', borderColor: ACCENT, borderWidth: 1.6 } };
  1007. }
  1008. return v;
  1009. });
  1010. }
  1011. function dataQualityNote(panel, series) {
  1012. if (series.zeroDays.size || series.missingDays.size) {
  1013. panelNote(panel, '○ 空心点=当日全零;灰色底纹=缺数日期');
  1014. }
  1015. }
  1016. /** 高校日历:对比窗口跨寒/暑假时,环比天然失真(营业基数骤降),给用户挂提醒 */
  1017. const VACATIONS = [
  1018. { name: '寒假', from: '0108', to: '0225' },
  1019. { name: '暑假', from: '0701', to: '0831' },
  1020. ];
  1021. function rangeOverlapsVacation(from, to) {
  1022. const a = String(from).slice(4);
  1023. const b = String(to).slice(4);
  1024. const spans = a <= b ? [[a, b]] : [[a, '1231'], ['0101', b]];
  1025. return VACATIONS.filter((v) => spans.some(([s, e]) => s <= v.to && v.from <= e)).map((v) => v.name);
  1026. }
  1027. function sumField(rows, f) { return rows.reduce((acc, r) => acc + (Number(r[f]) || 0), 0); }
  1028. function wAvgField(rows, f, wb) {
  1029. let num = 0;
  1030. let den = 0;
  1031. for (const r of rows) { const wt = Number(r[wb]) || 0; num += (Number(r[f]) || 0) * wt; den += wt; }
  1032. return den > 0 ? num / den : 0;
  1033. }
  1034. function deltaBadge(cur, prev) {
  1035. if (!(prev > 0) || !Number.isFinite(cur)) return '<span class="delta-none">上期无数据</span>';
  1036. const delta = (cur - prev) / prev;
  1037. if (Math.abs(delta) < 0.0001) return '<span class="delta-flat">持平</span>';
  1038. const up = delta > 0;
  1039. return `<span class="delta ${up ? 'up' : 'down'}">${up ? '↑' : '↓'} ${Math.abs(delta * 100).toFixed(1)}%</span>`;
  1040. }
  1041. async function ensureAnalysisData() {
  1042. const { from, to } = analysisRange();
  1043. const shopSuffix = state.analysisShopFilter.size
  1044. ? `&shops=${encodeURIComponent(Array.from(state.analysisShopFilter).join(','))}`
  1045. : '';
  1046. const key = [state.entity, from, to, state.analysisShopFilter.size ? [...state.analysisShopFilter].sort().join(',') : ''].join('|');
  1047. if (anaCache && anaCache.key === key) return anaCache;
  1048. const fd = (s) => new Date(Number(String(s).slice(0, 4)), Number(String(s).slice(4, 6)) - 1, Number(String(s).slice(6, 8)));
  1049. const spanDays = Math.max(1, Math.round((fd(to) - fd(from)) / 86400000) + 1);
  1050. const prevToD = fd(from);
  1051. prevToD.setDate(prevToD.getDate() - 1);
  1052. const prevTo = ymdLocal(prevToD);
  1053. const prevFromD = new Date(prevToD);
  1054. prevFromD.setDate(prevFromD.getDate() - (spanDays - 1));
  1055. const prevFrom = ymdLocal(prevFromD);
  1056. const base = `/api/reports/aggregate?entity=${encodeURIComponent(state.entity)}`;
  1057. const [byDate, byDatePrev, byShop, byShopPrev, topDaily] = await Promise.all([
  1058. jsonFetch(`${base}&from=${from}&to=${to}&group=date${shopSuffix}`).then((d) => d.rows || []),
  1059. jsonFetch(`${base}&from=${prevFrom}&to=${prevTo}&group=date${shopSuffix}`).then((d) => d.rows || []),
  1060. jsonFetch(`${base}&from=${from}&to=${to}&group=shop${shopSuffix}`).then((d) => d.rows || []),
  1061. jsonFetch(`${base}&from=${prevFrom}&to=${prevTo}&group=shop${shopSuffix}`).then((d) => d.rows || []),
  1062. jsonFetch(`/api/reports/daily?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}&scope=shop${shopSuffix}`).then((d) => d.rows || []),
  1063. ]);
  1064. anaCache = { key, from, to, prevFrom, prevTo, byDate, byDatePrev, byShop, byShopPrev, topDaily };
  1065. return anaCache;
  1066. }
  1067. // 标准指标 → 原始蛇形字段(对应 napos-service FIELD_MAP;bench 接口按原始字段取 raw 同行值)
  1068. const RAW_FIELD_OF = {
  1069. validOrderCnt: 'valid_order_cnt', grossAmt: 'gross_amt', incomeAmt: 'income_amt',
  1070. expenseAmt: 'expense_amt', expUv: 'exp_uv', orderUserCnt: 'order_user_cnt',
  1071. clkExpRate: 'clk_exp_rate', ordClkRate: 'ord_clk_rate', perActualPay: 'per_actual_pay',
  1072. actOrdCnt: 'act_ord_cnt', actSubsidyAmt: 'act_shop_subsidy_amt', shopRoi: 'shop_roi',
  1073. rebuyRate30d: 'rebuy_rate_30d',
  1074. };
  1075. let benchCache = null;
  1076. /** 同行对标数据:门店×天 的自身值 + 同行均值/排名(key 含字段与门店筛选,自过期) */
  1077. async function ensureBenchData(data) {
  1078. const field = RAW_FIELD_OF[chartMetric] || 'gross_amt';
  1079. const shops = state.analysisShopFilter.size ? Array.from(state.analysisShopFilter) : [];
  1080. const key = [state.entity, data.from, data.to, field, shops.slice().sort().join(',')].join('|');
  1081. if (benchCache && benchCache.key === key) return benchCache;
  1082. const suffix = shops.length ? `&shops=${encodeURIComponent(shops.join(','))}` : '';
  1083. const r = await jsonFetch(`/api/reports/bench?entity=${encodeURIComponent(state.entity)}&from=${data.from}&to=${data.to}&field=${field}${suffix}`);
  1084. benchCache = { key, field, rows: r.rows || [] };
  1085. return benchCache;
  1086. }
  1087. let crossCache = null;
  1088. /** 跨校对比数据:湖中全部主体各取 本期/上期 按日聚合(无数据的主体自动滤掉) */
  1089. async function ensureCrossData(data) {
  1090. const key = [data.from, data.to].join('|');
  1091. if (crossCache && crossCache.key === key) return crossCache;
  1092. const list = await Promise.all(state.entities.map(async (e) => {
  1093. const base = `/api/reports/aggregate?entity=${encodeURIComponent(e.key)}`;
  1094. const [byDate, byDatePrev] = await Promise.all([
  1095. jsonFetch(`${base}&from=${data.from}&to=${data.to}&group=date`).then((d) => d.rows || []).catch(() => []),
  1096. jsonFetch(`${base}&from=${data.prevFrom}&to=${data.prevTo}&group=date`).then((d) => d.rows || []).catch(() => []),
  1097. ]);
  1098. return { key: e.key, name: e.name || e.key, shopCount: (e.shops || []).length || e.shopCount || 0, byDate, byDatePrev };
  1099. }));
  1100. crossCache = { key, entities: list.filter((x) => x.byDate.length) };
  1101. return crossCache;
  1102. }
  1103. function renderDeltas(data) {
  1104. const wrap = $('analysisDeltas');
  1105. if (!wrap) return;
  1106. wrap.innerHTML = '';
  1107. const curOrders = sumField(data.byDate, 'validOrderCnt');
  1108. const prevOrders = sumField(data.byDatePrev, 'validOrderCnt');
  1109. const items = [
  1110. { label: '营业额', cur: sumField(data.byDate, 'grossAmt'), prev: sumField(data.byDatePrev, 'grossAmt'), fmt: fmtMoney },
  1111. { label: '有效订单', cur: curOrders, prev: prevOrders, fmt: fmtNum },
  1112. { label: '收入', cur: sumField(data.byDate, 'incomeAmt'), prev: sumField(data.byDatePrev, 'incomeAmt'), fmt: fmtMoney },
  1113. { label: '曝光人数', cur: sumField(data.byDate, 'expUv'), prev: sumField(data.byDatePrev, 'expUv'), fmt: fmtNum },
  1114. { label: '下单人数', cur: sumField(data.byDate, 'orderUserCnt'), prev: sumField(data.byDatePrev, 'orderUserCnt'), fmt: fmtNum },
  1115. { label: '进店转化率', cur: wAvgField(data.byDate, 'clkExpRate', 'expUv'), prev: wAvgField(data.byDatePrev, 'clkExpRate', 'expUv'), fmt: fmtPct },
  1116. { label: '客单价', cur: curOrders > 0 ? sumField(data.byDate, 'grossAmt') / curOrders : 0, prev: prevOrders > 0 ? sumField(data.byDatePrev, 'grossAmt') / prevOrders : 0, fmt: fmtMoney },
  1117. ];
  1118. for (const it of items) {
  1119. const cur = Math.round(it.cur * 100) / 100;
  1120. const div = document.createElement('div');
  1121. div.className = 'delta-card';
  1122. let deltaHtml;
  1123. const delta = it.prev > 0 ? (it.cur - it.prev) / it.prev : null;
  1124. if (delta == null || !Number.isFinite(delta)) deltaHtml = '<span class="delta-none">上期无数据</span>';
  1125. else if (Math.abs(delta) < 0.0001) deltaHtml = '<span class="delta-flat">持平</span>';
  1126. else {
  1127. const up = delta > 0;
  1128. deltaHtml = `<span class="delta ${up ? 'up' : 'down'}">${up ? '↑' : '↓'} ${Math.abs(delta * 100).toFixed(1)}%</span>`
  1129. + `<span class="delta-sub">vs 上期 ${it.fmt(Math.round(it.prev * 100) / 100)}</span>`;
  1130. }
  1131. div.innerHTML = `<span class="delta-label"></span><span class="delta-value"></span><span class="delta-line">${deltaHtml}</span>`;
  1132. div.querySelector('.delta-label').textContent = it.label;
  1133. div.querySelector('.delta-value').textContent = it.fmt(cur);
  1134. wrap.appendChild(div);
  1135. }
  1136. }
  1137. /** 趋势图表空态(与数据中台同款 empty-hint):html=null 恢复指标条+图表区 */
  1138. function setAnalysisEmpty(html) {
  1139. const grid = $('anaGrid');
  1140. const deltas = $('analysisDeltas');
  1141. const empty = $('analysisEmpty');
  1142. if (!grid || !empty) return;
  1143. deltas.hidden = !!html;
  1144. grid.hidden = !!html;
  1145. empty.hidden = !html;
  1146. if (html) $('analysisEmptyText').innerHTML = html;
  1147. }
  1148. async function renderCharts() {
  1149. if (!state.entity) {
  1150. setAnalysisEmpty('暂无主体<br><small>请先在商家客户端登录并完成一次「同步数据」;本机也可在「数据中台」页执行「云端拉取」</small>');
  1151. return;
  1152. }
  1153. let data;
  1154. try { data = await ensureAnalysisData(); }
  1155. catch { setAnalysisEmpty('数据加载失败<br><small>请稍后重试;持续失败请查看「系统日志」</small>'); return; }
  1156. const tpl = activeTemplate();
  1157. if (!tpl) return; // 模板库(analysis-templates.js)未加载时静默退出
  1158. if (!data.byDate.length) {
  1159. disposeAllCharts();
  1160. $('anaGrid').innerHTML = '';
  1161. setAnalysisEmpty('暂无数据<br><small>所选区间没有数据;请先在「数据采集」页完成同步,或调整日期范围</small>');
  1162. return;
  1163. }
  1164. setAnalysisEmpty(null);
  1165. applyTemplateControls(tpl);
  1166. disposeAllCharts();
  1167. const grid = $('anaGrid');
  1168. if (!grid) return;
  1169. grid.innerHTML = '';
  1170. renderDeltas(data);
  1171. await tpl.render(grid, data); // 模板可为 async(同行对标/跨校对比自带取数)
  1172. }
  1173. // ---------- 模板库装配 ----------
  1174. // 六个内置模板(综合总览 / 环比对照 / 量价归因 / 档口问责 / 流量卡点 / 周内节律)
  1175. // 定义在 analysis-templates.js:kit 注入本页的格式化 / 面板构造 / 逐日序列等辅助,
  1176. // 模板只关心「对比方式 + 指标组合 + 图表布局」,新增模板无需改动本文件。
  1177. function buildTemplateKit() {
  1178. return {
  1179. chartCol,
  1180. metric: () => chartMetric,
  1181. comparePrev: () => !!($('anaComparePrev') && $('anaComparePrev').checked),
  1182. fmtNum, fmtMoney, fmtPct, fmtByCol, fmtCompact, ymdToDash, ymdDate, shiftYmd,
  1183. makePanel, panelChart, panelNote, panelEmpty, panelEmptyIn,
  1184. buildDailySeries, missingMarkAreas, hollowPoints, dataQualityNote,
  1185. sumField, wAvgField, deltaBadge, rangeOverlapsVacation,
  1186. fetchJson: jsonFetch,
  1187. benchData: ensureBenchData,
  1188. crossData: ensureCrossData,
  1189. shopFilterCount: () => state.analysisShopFilter.size,
  1190. RATE_THRESHOLDS, AXIS_TXT, SPLIT_LINE,
  1191. ACCENT, COLOR_OK, COLOR_FAIL, COLOR_INFO, COLOR_PREV,
  1192. };
  1193. }
  1194. const TEMPLATES = window.shanAnalysisTemplates ? window.shanAnalysisTemplates(buildTemplateKit()) : [];
  1195. // ---------- 智能问数 ·「应用到报表」(独立问数页经 window.shanReportsApplyQuery 调用) ----------
  1196. /** 把问数查询条件落到报表筛选,并跳转数据中台明细 tab(原机器人 applyMsgQuery 语义) */
  1197. window.shanReportsApplyQuery = async function (a) {
  1198. if (!a) return;
  1199. if (!state.inited) await window.shanReportsInit();
  1200. state.mode = ['detail', 'shop', 'date'].includes(a.mode) ? a.mode : 'shop';
  1201. $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === state.mode));
  1202. state.preset = 'custom';
  1203. $('reportRangePreset').value = 'custom';
  1204. $('reportCustomRange').hidden = false;
  1205. $('reportFromDate').value = ymdToDash(a.from);
  1206. $('reportToDate').value = ymdToDash(a.to);
  1207. state.shopFilter = new Set((a.shopIds || []).map(String));
  1208. renderShopOptions();
  1209. state.page = 1;
  1210. loadReport();
  1211. // 明细浏览在数据中台页:先切到明细 tab,再导航过去
  1212. if (window.shanLakeShowDetail) window.shanLakeShowDetail();
  1213. const nav = document.querySelector('.nav-item[data-view="lake"]');
  1214. if (nav) nav.click();
  1215. };
  1216. // ---------- 事件绑定 ----------
  1217. function bindEvents() {
  1218. $('btnReportRefresh').addEventListener('click', async () => {
  1219. await refreshConn();
  1220. await loadReport();
  1221. showToast('已刷新');
  1222. });
  1223. $('btnNaposDiagnose').addEventListener('click', runNaposDiagnose);
  1224. $('btnCloseDiagnose').addEventListener('click', closeDiagnoseModal);
  1225. $('btnDismissDiagnose').addEventListener('click', closeDiagnoseModal);
  1226. $('btnCopyDiagnose').addEventListener('click', copyDiagnoseReport);
  1227. $('btnDownloadDiagnose').addEventListener('click', downloadDiagnoseReport);
  1228. $('btnClientHelp').addEventListener('click', (event) => {
  1229. event.stopPropagation();
  1230. const pop = $('clientHelpPopover');
  1231. pop.hidden = !pop.hidden;
  1232. $('btnClientHelp').classList.toggle('active', !pop.hidden);
  1233. $('btnClientHelp').setAttribute('aria-expanded', String(!pop.hidden));
  1234. });
  1235. document.addEventListener('click', (event) => {
  1236. const pop = $('clientHelpPopover');
  1237. if (!pop.hidden && !event.target.closest('.conn-help-wrap')) {
  1238. pop.hidden = true;
  1239. $('btnClientHelp').classList.remove('active');
  1240. $('btnClientHelp').setAttribute('aria-expanded', 'false');
  1241. }
  1242. });
  1243. $('btnOpenSync').addEventListener('click', openSyncModal);
  1244. $('btnCloseSync').addEventListener('click', () => {
  1245. $('modalReportSync').hidden = true;
  1246. if (state.syncPollTimer) { clearInterval(state.syncPollTimer); state.syncPollTimer = null; }
  1247. $('syncProgressWrap').hidden = true;
  1248. });
  1249. $('btnCancelSync').addEventListener('click', () => {
  1250. $('modalReportSync').hidden = true;
  1251. // 与 btnCloseSync 同款:取消路径也要停掉同步状态轮询,否则后台空转
  1252. if (state.syncPollTimer) { clearInterval(state.syncPollTimer); state.syncPollTimer = null; }
  1253. $('syncProgressWrap').hidden = true;
  1254. });
  1255. $('btnStartSync').addEventListener('click', startSync);
  1256. $('btnSyncCancel').addEventListener('click', async () => {
  1257. try { await jsonFetch('/api/reports/sync/cancel', { method: 'POST' }); showToast('已请求取消,等待当前步骤结束…'); } catch { /* ignore */ }
  1258. });
  1259. $('syncScope').addEventListener('change', () => {
  1260. $('syncShopPickWrap').hidden = $('syncScope').value !== 'custom';
  1261. });
  1262. $('autoSyncEnabled').addEventListener('change', saveAutoSyncPrefs);
  1263. $('autoSyncTime').addEventListener('change', saveAutoSyncPrefs);
  1264. $('syncShopTrigger').addEventListener('click', () => toggleDropdown('syncShopMenu', 'syncShopTrigger'));
  1265. $('syncShopMenu').addEventListener('click', makeDropdownHandler($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店'));
  1266. document.addEventListener('click', (event) => {
  1267. if (!$('syncShopDropdown').contains(event.target)) toggleDropdown('syncShopMenu', 'syncShopTrigger', false);
  1268. });
  1269. $('reportRangePreset').addEventListener('change', () => {
  1270. state.preset = $('reportRangePreset').value;
  1271. $('reportCustomRange').hidden = state.preset !== 'custom';
  1272. reloadReport();
  1273. });
  1274. $('reportFromDate').addEventListener('change', loadReport);
  1275. $('reportToDate').addEventListener('change', loadReport);
  1276. $('reportShopTrigger').addEventListener('click', () => toggleDropdown('reportShopMenu', 'reportShopTrigger'));
  1277. $('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', reloadReport));
  1278. document.addEventListener('click', (event) => {
  1279. if (!$('reportShopDropdown').contains(event.target)) toggleDropdown('reportShopMenu', 'reportShopTrigger', false);
  1280. });
  1281. $('reportViewModes').addEventListener('click', (event) => {
  1282. const btn = event.target.closest('button[data-mode]');
  1283. if (!btn) return;
  1284. state.mode = btn.dataset.mode;
  1285. $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
  1286. state.sortKey = state.mode === 'detail' ? 'date' : (state.mode === 'shop' ? 'shopName' : 'date');
  1287. state.sortDir = 'desc';
  1288. reloadReport();
  1289. });
  1290. // 字段模式:报表列(语义列)/ 全部字段(明细 + 全部原始字段;聚合视图无 raw,自动回明细)
  1291. $('reportFieldModes').addEventListener('click', (event) => {
  1292. const btn = event.target.closest('button[data-fields]');
  1293. if (!btn) return;
  1294. state.rawView = btn.dataset.fields === 'all';
  1295. $('reportFieldModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
  1296. if (state.rawView && state.mode !== 'detail') {
  1297. state.mode = 'detail';
  1298. state.sortKey = 'date';
  1299. state.sortDir = 'desc';
  1300. $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === 'detail'));
  1301. }
  1302. $('reportViewModes').querySelectorAll('button').forEach((b) => { b.disabled = state.rawView; });
  1303. $('btnColumnCfg').hidden = state.rawView; // 列设置只作用于报表列模式
  1304. state.sortKey = state.rawView ? 'date' : state.sortKey;
  1305. renderTable();
  1306. });
  1307. let searchTimer = null;
  1308. $('reportSearch').addEventListener('input', () => {
  1309. clearTimeout(searchTimer);
  1310. searchTimer = setTimeout(() => {
  1311. state.q = $('reportSearch').value.trim();
  1312. reloadReport();
  1313. }, 300);
  1314. });
  1315. $('btnColumnCfg').addEventListener('click', () => {
  1316. renderColumnCfg();
  1317. $('modalReportColumns').hidden = false;
  1318. });
  1319. $('btnCloseColumnCfg').addEventListener('click', () => $('modalReportColumns').hidden = true);
  1320. $('btnApplyColumns').addEventListener('click', applyColumnCfg);
  1321. $('btnResetColumns').addEventListener('click', () => {
  1322. state.visible = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
  1323. LS.set('shanReports.columns.v2', Array.from(state.visible));
  1324. renderColumnCfg();
  1325. renderTable();
  1326. });
  1327. $('btnExportCsv').addEventListener('click', exportCsv);
  1328. $('btnExportHtml').addEventListener('click', exportHtml);
  1329. $('reportTableHead').addEventListener('click', (event) => {
  1330. const th = event.target.closest('th[data-key]');
  1331. if (!th) return;
  1332. const key = th.dataset.key;
  1333. if (state.sortKey === key) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
  1334. else { state.sortKey = key; state.sortDir = 'desc'; }
  1335. renderTable();
  1336. });
  1337. // 趋势图表
  1338. $('anaTplBar').addEventListener('click', (event) => {
  1339. const btn = event.target.closest('button[data-tpl]');
  1340. if (!btn || btn.dataset.tpl === anaTemplate) return;
  1341. anaTemplate = btn.dataset.tpl;
  1342. LS.set('shanReports.anaTemplate', anaTemplate);
  1343. renderTemplateSelect();
  1344. renderCharts();
  1345. });
  1346. $('analysisRangePreset').addEventListener('change', () => {
  1347. state.analysisPreset = $('analysisRangePreset').value;
  1348. anaCache = null;
  1349. renderCharts();
  1350. });
  1351. $('anaShopTrigger').addEventListener('click', () => toggleDropdown('anaShopMenu', 'anaShopTrigger'));
  1352. $('anaShopMenu').addEventListener('click', makeDropdownHandler($('anaShopMenu'), state.analysisShopFilter, $('anaShopText'), '全部门店', () => {
  1353. anaCache = null;
  1354. renderCharts();
  1355. }));
  1356. document.addEventListener('click', (event) => {
  1357. if (!$('anaShopDropdown').contains(event.target)) toggleDropdown('anaShopMenu', 'anaShopTrigger', false);
  1358. });
  1359. $('chartMetric').addEventListener('change', () => {
  1360. chartMetric = $('chartMetric').value || 'grossAmt';
  1361. LS.set('shanReports.chartMetric', chartMetric);
  1362. renderCharts();
  1363. });
  1364. $('anaComparePrev').addEventListener('change', renderCharts);
  1365. window.addEventListener('resize', () => {
  1366. Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
  1367. });
  1368. }
  1369. // ---------- 同步历史(数据采集页,含云端推送状态) ----------
  1370. /** 同步历史空态(与数据中台同款 empty-hint):html=null 恢复表格 */
  1371. function setSyncHistoryEmpty(html) {
  1372. const table = $('syncHistoryTable');
  1373. const empty = $('syncHistoryEmpty');
  1374. if (!table || !empty) return;
  1375. table.hidden = !!html;
  1376. empty.hidden = !html;
  1377. if (html) $('syncHistoryEmptyText').innerHTML = html;
  1378. }
  1379. function renderSyncHistory() {
  1380. const tbody = $('syncHistoryRows');
  1381. if (!tbody) return;
  1382. if (!state.entity) {
  1383. setSyncHistoryEmpty('暂无主体<br><small>请先在商家客户端登录并完成一次「同步数据」</small>');
  1384. return;
  1385. }
  1386. const entityKey = encodeURIComponent(state.entity);
  1387. Promise.all([
  1388. jsonFetch(`/api/reports/syncs?entity=${entityKey}`),
  1389. jsonFetch(`/api/reports/aitable/status?entity=${entityKey}`).catch(() => null),
  1390. ])
  1391. .then(([data, cloud]) => {
  1392. const runs = (data.runs || []).slice(0, 8);
  1393. if (!runs.length) {
  1394. setSyncHistoryEmpty('暂无同步记录<br><small>使用右上角「同步数据」开始第一次同步</small>');
  1395. return;
  1396. }
  1397. setSyncHistoryEmpty(null);
  1398. const lastPush = cloud && cloud.lastPush ? cloud.lastPush : null;
  1399. const pushAt = lastPush ? new Date(lastPush.at || lastPush.lastPushedAt || 0).getTime() : 0;
  1400. const SCOPE_LABEL = { all: '全主体', chain: '按连锁', shop: '按门店' };
  1401. tbody.innerHTML = runs.map((r) => {
  1402. const warn = (r.steps || []).filter((s) => !s.ok).length;
  1403. const note = r.cancelled ? '已取消' : (r.failed ? `${r.failed} 失败` : (warn ? `${warn} 步骤告警(含全零)` : '成功'));
  1404. const cls = r.cancelled || r.failed ? 'badge-fail' : (warn ? 'badge-warn' : 'badge-ok');
  1405. const t = r.at ? new Date(r.at) : null;
  1406. const ts = t ? `${t.getMonth() + 1}/${t.getDate()} ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '–';
  1407. // 该次同步之后是否推送过云端(以最近一次推送时间为准;更早的同步被其覆盖归档)
  1408. const pushed = pushAt && t && pushAt >= t.getTime();
  1409. const cloudCell = !cloud || !cloud.enabled
  1410. ? '<span class="badge">未启用</span>'
  1411. : (pushed ? `<span class="badge badge-ok">已推 ${lastPush.pushedRows || 0} 行</span>` : '<span class="badge badge-info">未推送</span>');
  1412. return `<tr>
  1413. <td>${ts}</td>
  1414. <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>
  1415. <td>${esc(SCOPE_LABEL[r.scope] || r.scope || '–')}</td>
  1416. <td class="num">${r.added ?? 0}</td>
  1417. <td class="num">${r.updated ?? 0}</td>
  1418. <td class="num">${r.failed ?? 0}</td>
  1419. <td><span class="badge ${cls}">${note}</span></td>
  1420. <td>${cloudCell}</td>
  1421. </tr>`;
  1422. }).join('');
  1423. })
  1424. .catch(() => {
  1425. setSyncHistoryEmpty('同步历史加载失败<br><small>请稍后使用页面右上角「刷新」重试</small>');
  1426. });
  1427. }
  1428. // ---------- 取数诊断(隐藏入口:连点侧边栏版本号 5 次) ----------
  1429. const BUSY_LABEL = {
  1430. OPEN: '营业中', CLOSE: '休息中(非营业时段)', SUSPEND_BUSINESS: '暂停营业', INVALID: '未生效/已下架', '(未知)': '未知',
  1431. };
  1432. let lastDiagnoseReport = null;
  1433. async function runNaposDiagnose() {
  1434. const btn = $('btnNaposDiagnose');
  1435. btn.disabled = true;
  1436. btn.textContent = '诊断中…';
  1437. $('diagnoseSummary').innerHTML = '<div class="help">正在诊断,约需半分钟,请勿关闭…</div>';
  1438. $('diagnoseJson').textContent = '';
  1439. $('modalNaposDiagnose').hidden = false;
  1440. try {
  1441. const res = await fetch('/api/reports/diagnose/napos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
  1442. const data = await res.json();
  1443. if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
  1444. lastDiagnoseReport = data;
  1445. renderDiagnoseReport(data);
  1446. } catch (e) {
  1447. closeDiagnoseModal();
  1448. showToast(`诊断失败:${e.message}`, 'error');
  1449. } finally {
  1450. btn.disabled = false;
  1451. btn.textContent = '取数诊断';
  1452. }
  1453. }
  1454. function closeDiagnoseModal() { $('modalNaposDiagnose').hidden = true; }
  1455. function diagnoseRow(label, value) {
  1456. return `<div><span class="diagnose-k">${esc(label)}</span>${value}</div>`;
  1457. }
  1458. function renderDiagnoseReport(r) {
  1459. const busyText = Object.entries(r.busyLevelSummary || {}).map(([k, n]) => `${BUSY_LABEL[k] || k} ${n}`).join(',') || '无门店';
  1460. const paused = r.pausedShops || [];
  1461. const cases = r.cases || [];
  1462. const cc = r.chainControl || {};
  1463. const caseRows = cases.map((c) => `<tr>
  1464. <td>${esc((c.shopName || '') + ' · ' + c.tag)}</td>
  1465. <td class="num">${c.rows ?? '–'}</td>
  1466. <td class="num">${c.ordersSum ?? '–'}</td>
  1467. <td class="num">${c.grossSum ?? '–'}</td>
  1468. <td class="num">${c.orderUserSum ?? '–'}</td>
  1469. <td>${c.error ? `<span class="badge badge-fail">异常</span>` : (c.metricProbe ? `字段 ${c.metricProbe.fieldCount} 个` : '–')}</td>
  1470. </tr>`).join('');
  1471. $('diagnoseSummary').innerHTML = `
  1472. <div class="diagnose-block">
  1473. ${diagnoseRow('客户端', r.status && r.status.portOk ? `已连接(${esc(r.status.browser || '')})` : '不在线')}
  1474. ${r.treeDiag ? diagnoseRow('组织树', r.treeDiag.ok ? '读取正常' : `读取失败${r.treeDiag.code || r.treeDiag.msg ? `:${esc([r.treeDiag.code, r.treeDiag.msg].filter(Boolean).join(' '))}` : ''}`) : ''}
  1475. ${diagnoseRow('主体', `${esc((r.merchant && r.merchant.name) || '?')}(${r.shopCount ?? 0} 家门店 · 测试日期 ${esc(r.testDate || '–')})`)}
  1476. ${diagnoseRow('门店状态', esc(busyText))}
  1477. ${diagnoseRow('暂停营业/下架', paused.length ? paused.map((s) => esc(s.shopName)).join('、') : '无')}
  1478. ${diagnoseRow('连锁对照', cc.rows ? `${cc.rows} 行 · 有效订单 ${cc.ordersSum} · 营业额 ${cc.grossSum} · 下单人数 ${cc.orderUserSum} · 字段 ${(cc.metricProbe && cc.metricProbe.fieldCount) ?? '–'} 个` : '无数据')}
  1479. </div>
  1480. ${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>` : ''}
  1481. ${r.conclusionLines && r.conclusionLines.length
  1482. ? `<div class="diagnose-conclusion">${r.conclusionLines.map((l) => `<div>· ${esc(l)}</div>`).join('')}</div>`
  1483. : (r.conclusion ? `<div class="diagnose-conclusion">${esc(r.conclusion)}</div>` : '')}
  1484. `;
  1485. $('diagnoseJson').textContent = JSON.stringify(r, null, 2);
  1486. }
  1487. function copyDiagnoseReport() {
  1488. if (!lastDiagnoseReport) return;
  1489. const text = JSON.stringify(lastDiagnoseReport, null, 2);
  1490. if (navigator.clipboard && navigator.clipboard.writeText) {
  1491. navigator.clipboard.writeText(text).then(() => showToast('已复制,粘贴发给开发即可', 'success'), () => fallbackCopyDiagnose(text));
  1492. } else {
  1493. fallbackCopyDiagnose(text);
  1494. }
  1495. }
  1496. function fallbackCopyDiagnose(text) {
  1497. const ta = document.createElement('textarea');
  1498. ta.value = text;
  1499. document.body.appendChild(ta);
  1500. ta.select();
  1501. try { document.execCommand('copy'); showToast('已复制,粘贴发给开发即可', 'success'); } catch { showToast('复制失败,请改用「下载报告」', 'error'); }
  1502. ta.remove();
  1503. }
  1504. function downloadDiagnoseReport() {
  1505. if (!lastDiagnoseReport) return;
  1506. const blob = new Blob([JSON.stringify(lastDiagnoseReport, null, 2)], { type: 'application/json' });
  1507. const a = document.createElement('a');
  1508. a.href = URL.createObjectURL(blob);
  1509. a.download = `napos-诊断报告-${Date.now()}.json`;
  1510. a.click();
  1511. URL.revokeObjectURL(a.href);
  1512. }
  1513. // ---------- 初始化 ----------
  1514. // 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)。返回是否推送成功。
  1515. async function pushCurrentEntity() {
  1516. const badge = $('reportPushBadge');
  1517. const entity = state.entity || (state.entities[0] || {}).key;
  1518. if (!entity) {
  1519. badge.textContent = '暂无主体';
  1520. badge.className = 'badge badge-fail';
  1521. return false;
  1522. }
  1523. if (window.shanAitableAction) {
  1524. try {
  1525. await window.shanAitableAction('push', 'reportPushBadge', { entity });
  1526. return true;
  1527. } catch { return false; /* 徽标已由 aitableAction 写入,保留错误给用户读 */ }
  1528. }
  1529. return false;
  1530. }
  1531. async function refreshPushBadge() {
  1532. const badge = $('reportPushBadge');
  1533. if (!badge) return;
  1534. try {
  1535. const entity = state.entity || (state.entities[0] || {}).key;
  1536. const res = await fetch(`/api/reports/aitable/status${entity ? `?entity=${encodeURIComponent(entity)}` : ''}`);
  1537. const data = await res.json();
  1538. if (!data.enabled) {
  1539. badge.textContent = '云端未启用';
  1540. badge.className = 'badge badge-info';
  1541. } else if (!data.configured) {
  1542. badge.textContent = '云端配置不完整';
  1543. badge.className = 'badge badge-fail';
  1544. } else if (typeof data.pending === 'number' && data.pending > 0) {
  1545. badge.textContent = `待推送 ${data.pending} 行`;
  1546. badge.title = '「推送云端」会把上次推送之后的全部增量一次性推送至钉钉 AI 表格';
  1547. badge.className = 'badge badge-warn';
  1548. } else {
  1549. badge.textContent = '与云端一致';
  1550. badge.title = '';
  1551. badge.className = 'badge badge-ok';
  1552. }
  1553. } catch { /* 状态刷新失败不打扰 */ }
  1554. }
  1555. window.shanReportsInit = async function () {
  1556. if (!state.inited) {
  1557. state.inited = true;
  1558. bindEvents();
  1559. $('btnReportPush').addEventListener('click', async () => {
  1560. const ok = await pushCurrentEntity();
  1561. if (!ok) return; // 失败:保留错误徽标原样(待推计数也没变)
  1562. renderSyncHistory(); // 云端推送状态列随推送结果刷新
  1563. refreshPushBadge(); // 待推计数归零(或显示剩余)
  1564. });
  1565. $('reportPager').addEventListener('click', (event) => {
  1566. const btn = event.target.closest('button[data-page]');
  1567. if (btn) changePage(Number(btn.dataset.page));
  1568. });
  1569. try {
  1570. const data = await jsonFetch('/api/reports/columns');
  1571. state.columns = data.columns || [];
  1572. } catch { state.columns = []; }
  1573. loadColumnPrefs();
  1574. renderChartMetricSelect();
  1575. renderTemplateSelect();
  1576. }
  1577. await refreshConn();
  1578. refreshPushBadge();
  1579. renderSyncHistory();
  1580. await loadReport();
  1581. };
  1582. window.shanAnalysisActivate = async function () {
  1583. await window.shanReportsInit();
  1584. renderChartMetricSelect();
  1585. renderTemplateSelect();
  1586. await renderCharts();
  1587. Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
  1588. };
  1589. })();