reports.js 65 KB

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