reports.js 76 KB

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