reports.js 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136
  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. shopFilter: new Set(),
  84. q: '',
  85. columns: [],
  86. visible: new Set(),
  87. sortKey: 'date',
  88. sortDir: 'desc',
  89. rows: [],
  90. summary: null,
  91. conn: null,
  92. syncShops: new Set(),
  93. syncPollTimer: null,
  94. reportAbort: null,
  95. analysisShopFilter: new Set(),
  96. analysisPreset: '30',
  97. comparePrev: true,
  98. chat: { open: false, busy: false, messages: [] },
  99. };
  100. let chartMetric = LS.get('shanReports.chartMetric', 'grossAmt');
  101. const chartInstances = {};
  102. let anaCache = null;
  103. let lastAskApply = null;
  104. const $ = (id) => document.getElementById(id);
  105. // ---------- 日期区间 ----------
  106. function currentRange() {
  107. if (state.preset === 'custom') {
  108. const from = dashToYmd($('reportFromDate').value);
  109. const to = dashToYmd($('reportToDate').value);
  110. if (from && to && from <= to) return { from, to };
  111. return { from: daysAgoYmd(30), to: daysAgoYmd(0) };
  112. }
  113. const days = Number(state.preset) || 30;
  114. return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
  115. }
  116. function analysisRange() {
  117. const sel = $('analysisRangePreset');
  118. const days = Number(sel && sel.value) || 30;
  119. return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
  120. }
  121. // ---------- 主体 tabs(数据采集 / 趋势图表 共用选中) ----------
  122. function activeEntity() {
  123. return state.entities.find((e) => e.key === state.entity) || null;
  124. }
  125. function renderEntityTabs() {
  126. for (const containerId of ['entityTabs', 'analysisEntityTabs']) {
  127. const wrap = $(containerId);
  128. if (!wrap) continue;
  129. wrap.innerHTML = '';
  130. if (!state.entities.length) {
  131. const empty = document.createElement('div');
  132. empty.className = 'entity-tabs-empty';
  133. empty.textContent = '暂无主体 —— 请先在商家客户端登录并完成一次「同步数据」';
  134. wrap.appendChild(empty);
  135. continue;
  136. }
  137. for (const e of state.entities) {
  138. const btn = document.createElement('button');
  139. btn.type = 'button';
  140. btn.className = 'entity-tab' + (e.key === state.entity ? ' active' : '');
  141. btn.innerHTML = `<span class="entity-name"></span><span class="entity-meta"></span>`;
  142. btn.querySelector('.entity-name').textContent = e.name || e.key;
  143. const shopCount = (e.shops || []).length || e.shopCount || 0;
  144. btn.querySelector('.entity-meta').textContent = shopCount ? `${shopCount} 家门店` : '主体';
  145. btn.addEventListener('click', () => {
  146. if (state.entity === e.key) return;
  147. state.entity = e.key;
  148. state.shopFilter = new Set();
  149. state.analysisShopFilter = new Set();
  150. state.sortKey = 'date'; state.sortDir = 'desc';
  151. LS.set('shanReports.entity', e.key);
  152. anaCache = null;
  153. renderEntityTabs();
  154. renderShopOptions();
  155. loadReport();
  156. renderCharts();
  157. });
  158. wrap.appendChild(btn);
  159. }
  160. }
  161. }
  162. // ---------- 门店多选下拉(数据采集 / 同步 / 趋势图表 三处) ----------
  163. function toggleDropdown(menuId, triggerId, open) {
  164. const trigger = $(triggerId);
  165. const menu = menuId ? $(menuId) : null;
  166. const shouldOpen = open === undefined ? (menu ? menu.hidden : false) : open;
  167. if (menu) menu.hidden = !shouldOpen;
  168. if (trigger) trigger.setAttribute('aria-expanded', String(shouldOpen));
  169. }
  170. function fillShopMenu(menu, shops) {
  171. menu.innerHTML = '';
  172. const all = document.createElement('button');
  173. all.type = 'button';
  174. all.className = 'scan-dropdown-item';
  175. all.dataset.value = '__all__';
  176. all.setAttribute('role', 'option');
  177. 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>';
  178. menu.appendChild(all);
  179. const sorted = shops.slice().sort((a, b) => String(a.shopName).localeCompare(String(b.shopName), 'zh-CN'));
  180. for (const s of sorted) {
  181. const item = document.createElement('button');
  182. item.type = 'button';
  183. item.className = 'scan-dropdown-item';
  184. item.dataset.value = String(s.shopId);
  185. item.setAttribute('role', 'option');
  186. 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>';
  187. item.lastElementChild.textContent = s.shopName;
  188. menu.appendChild(item);
  189. }
  190. }
  191. function renderDropdownSelection(menu, selection, textEl, allLabel) {
  192. menu.querySelectorAll('.scan-dropdown-item').forEach((node) => {
  193. const selected = selection.has(node.dataset.value);
  194. node.classList.toggle('selected', selected);
  195. node.setAttribute('aria-selected', String(selected));
  196. });
  197. const picked = Array.from(selection).filter((v) => v !== '__all__');
  198. textEl.textContent = picked.length ? `已选 ${picked.length} 家门店` : allLabel;
  199. }
  200. function renderShopOptions() {
  201. const entity = activeEntity();
  202. const shops = (entity && entity.shops) || [];
  203. const menus = [
  204. { menu: $('reportShopMenu'), sel: state.shopFilter, text: $('reportShopText'), label: '全部门店' },
  205. { menu: $('syncShopMenu'), sel: state.syncShops, text: $('syncShopText'), label: '选择门店' },
  206. { menu: $('anaShopMenu'), sel: state.analysisShopFilter, text: $('anaShopText'), label: '全部门店' },
  207. ];
  208. for (const m of menus) {
  209. if (!m.menu) continue;
  210. fillShopMenu(m.menu, shops);
  211. renderDropdownSelection(m.menu, m.sel, m.text, m.label);
  212. }
  213. }
  214. function makeDropdownHandler(menu, selection, textEl, allLabel, onChange) {
  215. return (event) => {
  216. const item = event.target.closest('[data-value]');
  217. if (!item) return;
  218. const value = item.dataset.value;
  219. if (value === '__all__') selection.clear();
  220. else if (selection.has(value)) selection.delete(value);
  221. else selection.add(value);
  222. renderDropdownSelection(menu, selection, textEl, allLabel);
  223. if (onChange) onChange();
  224. };
  225. }
  226. // ---------- KPI 卡片(数据采集页) ----------
  227. function renderKpis() {
  228. const wrap = $('reportKpis');
  229. wrap.innerHTML = '';
  230. const s = state.summary || {};
  231. const cards = [
  232. { label: '营业额(元)', value: fmtMoney(s.grossAmt), cls: 'accent' },
  233. { label: '收入(元)', value: fmtMoney(s.incomeAmt), cls: 'ok' },
  234. { label: '有效订单', value: fmtNum(s.validOrderCnt), cls: 'accent' },
  235. { label: '曝光人数', value: fmtNum(s.expUv), cls: '' },
  236. { label: '下单人数', value: fmtNum(s.orderUserCnt), cls: '' },
  237. { label: '进店转化率', value: fmtPct(s.clkExpRate), cls: 'warn', rate: 'clkExpRate' },
  238. { label: '客单价(元)', value: fmtMoney(s.perActualPay), cls: 'ok' },
  239. ];
  240. for (const c of cards) {
  241. const div = document.createElement('div');
  242. div.className = 'metric ' + (c.cls || '');
  243. div.innerHTML = `<span class="metric-body"><span class="metric-label"></span><span class="metric-value ${c.rate ? rateClass(c.rate, s[c.rate]) : ''}"></span></span>`;
  244. div.querySelector('.metric-label').textContent = c.label;
  245. div.querySelector('.metric-value').textContent = c.value;
  246. wrap.appendChild(div);
  247. }
  248. }
  249. // ---------- 报表表格(数据采集页) ----------
  250. function metricColumns() {
  251. return state.columns.filter((c) => c.key !== 'date' && c.key !== 'shopName' && c.key !== 'account' && state.visible.has(c.key));
  252. }
  253. function tableColumns() {
  254. const metricCols = metricColumns();
  255. const accountCol = { key: 'account', label: '账号名', type: 'text' };
  256. if (state.mode === 'shop') return [accountCol, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
  257. if (state.mode === 'date') return [accountCol, { key: 'date', label: '日期', type: 'date' }, ...metricCols];
  258. return [accountCol, { key: 'date', label: '日期', type: 'date' }, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
  259. }
  260. function sortedRows() {
  261. return state.rows.slice().sort((a, b) => {
  262. const av = a[state.sortKey], bv = b[state.sortKey];
  263. if (av == null || av === '') return 1;
  264. if (bv == null || bv === '') return -1;
  265. if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * (state.sortDir === 'asc' ? 1 : -1);
  266. return String(av).localeCompare(String(bv), 'zh-CN') * (state.sortDir === 'asc' ? 1 : -1);
  267. });
  268. }
  269. function renderTable() {
  270. const cols = tableColumns();
  271. const thead = $('reportTableHead');
  272. const tbody = $('reportTableBody');
  273. $('reportRowCount').textContent = `${state.rows.length} 行`;
  274. const empty = $('reportEmpty');
  275. if (!state.rows.length) {
  276. thead.innerHTML = '';
  277. tbody.innerHTML = '';
  278. empty.hidden = false;
  279. return;
  280. }
  281. empty.hidden = true;
  282. thead.innerHTML = '<tr>' + cols.map((c) => {
  283. const active = state.sortKey === c.key;
  284. const arrow = active ? (state.sortDir === 'asc' ? ' ↑' : ' ↓') : '';
  285. return `<th class="col-${c.type}${['money', 'int', 'pct', 'num'].includes(c.type) ? ' col-num' : ''}" data-key="${c.key}">${c.label}${arrow}</th>`;
  286. }).join('') + '</tr>';
  287. tbody.innerHTML = sortedRows().map((r) => '<tr>' + cols.map((c) => {
  288. const isNum = ['money', 'int', 'pct', 'num'].includes(c.type);
  289. const cls = `col-${c.type}${isNum ? ' col-num' : ''}${c.type === 'pct' ? ' ' + rateClass(c.key, r[c.key]) : ''}`;
  290. return `<td class="${cls}">${fmtCell(c, r[c.key])}</td>`;
  291. }).join('') + '</tr>').join('');
  292. }
  293. // ---------- 数据加载(数据采集页) ----------
  294. async function loadReport() {
  295. const entity = state.entity;
  296. if (!entity) {
  297. state.rows = []; state.summary = null;
  298. renderTable(); renderKpis();
  299. return;
  300. }
  301. if (state.reportAbort) state.reportAbort.abort();
  302. const ctrl = new AbortController();
  303. state.reportAbort = ctrl;
  304. const { from, to } = currentRange();
  305. try {
  306. let url = state.mode === 'detail'
  307. ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&scope=shop`
  308. : `/api/reports/aggregate?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&group=${state.mode}`;
  309. if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
  310. if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
  311. const data = await jsonFetch(url, { signal: ctrl.signal });
  312. if (state.entity !== entity) return;
  313. state.rows = data.rows || [];
  314. state.summary = data.summary || null;
  315. renderKpis();
  316. renderTable();
  317. } catch (e) {
  318. if (e.name === 'AbortError') return;
  319. showToast('报表加载失败:' + e.message, 'error');
  320. }
  321. }
  322. // ---------- 连接状态 ----------
  323. async function refreshConn() {
  324. const badge = $('reportConnBadge');
  325. try {
  326. const data = await jsonFetch('/api/reports/status');
  327. state.conn = data.napos || null;
  328. state.entities = data.entities || [];
  329. if (!state.entity || !state.entities.some((e) => e.key === state.entity)) {
  330. const saved = LS.get('shanReports.entity', null);
  331. state.entity = (saved && state.entities.some((e) => e.key === saved)) ? saved : (state.entities[0] ? state.entities[0].key : null);
  332. }
  333. renderEntityTabs();
  334. renderShopOptions();
  335. const ok = state.conn && state.conn.connected;
  336. badge.textContent = ok ? '客户端已连接' : '客户端未连接';
  337. badge.className = 'badge ' + (ok ? 'badge-ok' : 'badge-fail');
  338. badge.title = ok
  339. ? `调试端口 ${state.conn.port}`
  340. : [state.conn && state.conn.error, state.conn && state.conn.hint].filter(Boolean).join('\n') || '未检测到调试端口';
  341. } catch {
  342. badge.textContent = '状态检测失败';
  343. badge.className = 'badge badge-fail';
  344. }
  345. }
  346. // ---------- 列设置 ----------
  347. function loadColumnPrefs() {
  348. // 默认只展示原始/主指标列(营业时长、异常关店时长、商品销售额为预留列,数据源接入后自动回填);
  349. // 下单转化率、客单价、支出、活动订单、活动补贴、推广ROI、30日复购率等派生指标默认隐藏,可在列设置打开。
  350. const DEFAULT_VISIBLE_COLS = new Set([
  351. 'bizHours', 'abnormalCloseHours', 'validOrderCnt', 'incomeAmt', 'grossAmt',
  352. 'goodsSaleAmt', 'expUv', 'orderUserCnt', 'clkExpRate',
  353. ]);
  354. const saved = LS.get('shanReports.columns.v2', null);
  355. const defaults = new Set(state.columns.filter((c) => DEFAULT_VISIBLE_COLS.has(c.key)).map((c) => c.key));
  356. if (Array.isArray(saved)) {
  357. const valid = new Set(state.columns.map((c) => c.key));
  358. state.visible = new Set(saved.filter((k) => valid.has(k)));
  359. } else {
  360. state.visible = defaults;
  361. }
  362. if (!state.visible.size) state.visible = defaults;
  363. }
  364. function renderColumnCfg() {
  365. const list = $('columnCfgList');
  366. list.innerHTML = '';
  367. for (const c of state.columns) {
  368. if (c.locked) continue;
  369. const label = document.createElement('label');
  370. label.className = 'colcfg-item';
  371. const checked = state.visible.has(c.key) ? 'checked' : '';
  372. label.innerHTML = `<input type="checkbox" value="${c.key}" ${checked}><span></span>${c.reserved ? '<em class="colcfg-reserved">预留</em>' : ''}`;
  373. label.querySelector('span').textContent = c.label;
  374. list.appendChild(label);
  375. }
  376. }
  377. function applyColumnCfg() {
  378. const picks = new Set();
  379. $('columnCfgList').querySelectorAll('input[type=checkbox]').forEach((cb) => {
  380. if (cb.checked) picks.add(cb.value);
  381. });
  382. state.visible = picks;
  383. LS.set('shanReports.columns.v2', Array.from(picks));
  384. $('modalReportColumns').hidden = true;
  385. renderTable();
  386. }
  387. // ---------- 同步 ----------
  388. function renderSyncProgress(st) {
  389. const pct = st.planTotal ? Math.round((st.done / st.planTotal) * 100) : 0;
  390. $('syncProgressBar').style.width = pct + '%';
  391. $('syncProgressText').textContent = `${st.done} / ${st.planTotal}`;
  392. $('syncStatAdded').textContent = `新增 ${st.added}`;
  393. $('syncStatUpdated').textContent = `更新 ${st.updated}`;
  394. $('syncStatFailed').textContent = `失败 ${st.failed}`;
  395. const steps = (st.steps || []).slice(-8);
  396. $('syncSteps').innerHTML = steps.map((s) => {
  397. const cls = s.ok ? 'ok' : 'fail';
  398. const detail = s.ok
  399. ? `+${s.added}/~${s.updated}` + (s.rows != null ? ` · ${s.rows}行` : '') + (s.nonZero === 0 && s.rows > 0 ? ' · ⚠全零' : '')
  400. : (s.error || '失败').slice(0, 60);
  401. return `<div class="sync-step ${cls}"><span></span><em>${detail}</em></div>`;
  402. }).join('');
  403. const stepNames = $('syncSteps').querySelectorAll('.sync-step span');
  404. steps.forEach((s, i) => { stepNames[i].textContent = s.label; });
  405. $('btnSyncCancel').hidden = !st.running;
  406. $('btnStartSync').disabled = !!st.running;
  407. $('btnStartSync').textContent = st.running ? '同步中…' : '开始同步';
  408. }
  409. function pollSyncStatus() {
  410. if (state.syncPollTimer) clearInterval(state.syncPollTimer);
  411. state.syncPollTimer = setInterval(async () => {
  412. try {
  413. const st = await jsonFetch('/api/reports/sync/status');
  414. renderSyncProgress(st);
  415. if (!st.running) {
  416. clearInterval(state.syncPollTimer);
  417. state.syncPollTimer = null;
  418. if (st.error) showToast('同步出错:' + st.error, 'error');
  419. else showToast(st.cancelled ? '同步已取消' : `同步完成:新增 ${st.added},更新 ${st.updated}`);
  420. refreshConn().then(loadReport);
  421. }
  422. } catch { /* 轮询失败忽略 */ }
  423. }, 900);
  424. }
  425. async function loadAutoSyncPrefs() {
  426. try {
  427. const s = await jsonFetch('/api/settings');
  428. $('autoSyncEnabled').checked = !!s.autoSyncEnabled;
  429. if (s.autoSyncTime) $('autoSyncTime').value = s.autoSyncTime;
  430. } catch { /* 设置读取失败时保留默认值 */ }
  431. }
  432. async function saveAutoSyncPrefs() {
  433. try {
  434. await jsonFetch('/api/settings', {
  435. method: 'POST',
  436. headers: { 'Content-Type': 'application/json' },
  437. body: JSON.stringify({ autoSyncEnabled: $('autoSyncEnabled').checked, autoSyncTime: $('autoSyncTime').value }),
  438. });
  439. showToast('自动同步设置已保存');
  440. } catch (e) {
  441. showToast('自动同步设置保存失败:' + e.message, 'error');
  442. }
  443. }
  444. async function openSyncModal() {
  445. $('modalReportSync').hidden = false;
  446. $('syncEntityBadge').textContent = '识别主体中…';
  447. $('syncEntityBadge').className = 'badge badge-info';
  448. $('syncShopPickWrap').hidden = true;
  449. $('syncProgressWrap').hidden = true;
  450. loadAutoSyncPrefs();
  451. try {
  452. const tree = await jsonFetch('/api/reports/tree');
  453. const badge = $('syncEntityBadge');
  454. badge.textContent = `当前主体:${(tree.entity && tree.entity.name) || (tree.merchant && tree.merchant.name) || '未知'} · 门店 ${(tree.shops || []).length} 家`;
  455. badge.className = 'badge badge-ok';
  456. if (tree.entity && tree.entity.key && tree.entity.key !== state.entity) {
  457. state.entity = tree.entity.key;
  458. LS.set('shanReports.entity', tree.entity.key);
  459. }
  460. await refreshConn();
  461. renderSyncShopOptions(tree.shops || []);
  462. } catch (e) {
  463. const badge = $('syncEntityBadge');
  464. badge.textContent = '未连接客户端:' + e.message;
  465. badge.className = 'badge badge-fail';
  466. try {
  467. const st = await jsonFetch('/api/reports/status');
  468. const napos = st.napos || {};
  469. $('syncHint').textContent = [napos.hint, napos.selfCheckUrl ? `自检:客户端带参启动后,用浏览器打开 ${napos.selfCheckUrl} 应看到一段 JSON。` : '']
  470. .filter(Boolean).join(' ');
  471. } catch { /* 服务不可达时保留默认文案 */ }
  472. }
  473. }
  474. function renderSyncShopOptions(shops) {
  475. state.syncShops.clear();
  476. fillShopMenu($('syncShopMenu'), shops);
  477. renderDropdownSelection($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店');
  478. }
  479. async function startSync() {
  480. const scope = $('syncScope').value;
  481. const days = Number($('syncRange').value) || 30;
  482. const body = { from: daysAgoYmd(days), to: daysAgoYmd(1), scope: scope === 'custom' ? 'shop' : scope };
  483. if (scope === 'custom') {
  484. const shops = Array.from(state.syncShops).filter((v) => v !== '__all__');
  485. if (!shops.length) { showToast('请先选择要同步的门店', 'error'); return; }
  486. body.shopIds = shops;
  487. }
  488. try {
  489. await jsonFetch('/api/reports/sync', {
  490. method: 'POST',
  491. headers: { 'Content-Type': 'application/json' },
  492. body: JSON.stringify(body),
  493. });
  494. $('syncProgressWrap').hidden = false;
  495. renderSyncProgress({ planTotal: 0, done: 0, added: 0, updated: 0, failed: 0, steps: [], running: true });
  496. pollSyncStatus();
  497. } catch (e) {
  498. showToast('同步启动失败:' + e.message, 'error');
  499. }
  500. }
  501. // ---------- 导出 ----------
  502. function exportCsv() {
  503. if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
  504. const { from, to } = currentRange();
  505. let url = `/api/reports/export?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`;
  506. if (state.mode !== 'detail') url += `&group=${state.mode}`;
  507. if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
  508. if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
  509. window.location.href = url;
  510. }
  511. function exportHtml() {
  512. if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
  513. const { from, to } = analysisRange();
  514. window.open(`/api/reports/report-html?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`, '_blank');
  515. }
  516. // ---------- 趋势图表 ----------
  517. function chartCol(key) {
  518. return state.columns.find((c) => c.key === key) || { key, label: key, type: 'num' };
  519. }
  520. function ensureChart(instanceKey, domId) {
  521. if (typeof echarts === 'undefined') return null;
  522. const dom = $(domId);
  523. if (!dom) return null;
  524. if (!chartInstances[instanceKey]) chartInstances[instanceKey] = echarts.init(dom);
  525. return chartInstances[instanceKey];
  526. }
  527. function metricColumnOptions() {
  528. return state.columns.filter((c) => ['int', 'money', 'pct', 'num'].includes(c.type) && !c.reserved);
  529. }
  530. function renderChartMetricSelect() {
  531. const sel = $('chartMetric');
  532. if (!sel) return;
  533. sel.innerHTML = '';
  534. for (const c of metricColumnOptions()) {
  535. const opt = document.createElement('option');
  536. opt.value = c.key;
  537. opt.textContent = c.label;
  538. if (c.key === chartMetric) opt.selected = true;
  539. sel.appendChild(opt);
  540. }
  541. if (!sel.value && sel.options.length) sel.value = sel.options[0].value;
  542. chartMetric = sel.value || 'grossAmt';
  543. }
  544. const AXIS_TXT = { axisLabel: { color: '#6b6560' }, axisLine: { lineStyle: { color: '#e8e4df' } } };
  545. const SPLIT_LINE = { lineStyle: { color: '#f0ede8' } };
  546. const ACCENT = '#ff6b35';
  547. async function ensureAnalysisData() {
  548. const { from, to } = analysisRange();
  549. const shopSuffix = state.analysisShopFilter.size
  550. ? `&shops=${encodeURIComponent(Array.from(state.analysisShopFilter).join(','))}`
  551. : '';
  552. const key = [state.entity, from, to, state.analysisShopFilter.size ? [...state.analysisShopFilter].sort().join(',') : ''].join('|');
  553. if (anaCache && anaCache.key === key) return anaCache;
  554. const fd = (s) => new Date(Number(String(s).slice(0, 4)), Number(String(s).slice(4, 6)) - 1, Number(String(s).slice(6, 8)));
  555. const spanDays = Math.max(1, Math.round((fd(to) - fd(from)) / 86400000) + 1);
  556. const prevToD = fd(from);
  557. prevToD.setDate(prevToD.getDate() - 1);
  558. const prevTo = ymdLocal(prevToD);
  559. const prevFromD = new Date(prevToD);
  560. prevFromD.setDate(prevFromD.getDate() - (spanDays - 1));
  561. const prevFrom = ymdLocal(prevFromD);
  562. const base = `/api/reports/aggregate?entity=${encodeURIComponent(state.entity)}`;
  563. const [byDate, byDatePrev, byShop, topDaily] = await Promise.all([
  564. jsonFetch(`${base}&from=${from}&to=${to}&group=date${shopSuffix}`).then((d) => d.rows || []),
  565. jsonFetch(`${base}&from=${prevFrom}&to=${prevTo}&group=date${shopSuffix}`).then((d) => d.rows || []),
  566. jsonFetch(`${base}&from=${from}&to=${to}&group=shop${shopSuffix}`).then((d) => d.rows || []),
  567. jsonFetch(`/api/reports/daily?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}&scope=shop${shopSuffix}`).then((d) => d.rows || []),
  568. ]);
  569. anaCache = { key, from, to, prevFrom, prevTo, byDate, byDatePrev, byShop, topDaily };
  570. return anaCache;
  571. }
  572. function renderDeltas(data) {
  573. const wrap = $('analysisDeltas');
  574. if (!wrap) return;
  575. wrap.innerHTML = '';
  576. const sum = (rows, f) => rows.reduce((a, r) => a + (Number(r[f]) || 0), 0);
  577. const wAvg = (rows, f, wb) => {
  578. let num = 0, den = 0;
  579. for (const r of rows) { const wt = Number(r[wb]) || 0; num += (Number(r[f]) || 0) * wt; den += wt; }
  580. return den > 0 ? num / den : 0;
  581. };
  582. const curOrders = sum(data.byDate, 'validOrderCnt');
  583. const prevOrders = sum(data.byDatePrev, 'validOrderCnt');
  584. const items = [
  585. { label: '营业额', cur: sum(data.byDate, 'grossAmt'), prev: sum(data.byDatePrev, 'grossAmt'), fmt: fmtMoney },
  586. { label: '有效订单', cur: curOrders, prev: prevOrders, fmt: fmtNum },
  587. { label: '收入', cur: sum(data.byDate, 'incomeAmt'), prev: sum(data.byDatePrev, 'incomeAmt'), fmt: fmtMoney },
  588. { label: '曝光人数', cur: sum(data.byDate, 'expUv'), prev: sum(data.byDatePrev, 'expUv'), fmt: fmtNum },
  589. { label: '下单人数', cur: sum(data.byDate, 'orderUserCnt'), prev: sum(data.byDatePrev, 'orderUserCnt'), fmt: fmtNum },
  590. { label: '进店转化率', cur: wAvg(data.byDate, 'clkExpRate', 'expUv'), prev: wAvg(data.byDatePrev, 'clkExpRate', 'expUv'), fmt: fmtPct },
  591. { label: '客单价', cur: curOrders > 0 ? sum(data.byDate, 'grossAmt') / curOrders : 0, prev: prevOrders > 0 ? sum(data.byDatePrev, 'grossAmt') / prevOrders : 0, fmt: fmtMoney },
  592. ];
  593. for (const it of items) {
  594. const cur = Math.round(it.cur * 100) / 100;
  595. const div = document.createElement('div');
  596. div.className = 'delta-card';
  597. let deltaHtml;
  598. const delta = it.prev > 0 ? (it.cur - it.prev) / it.prev : null;
  599. if (delta == null || !Number.isFinite(delta)) deltaHtml = '<span class="delta-none">上期无数据</span>';
  600. else if (Math.abs(delta) < 0.0001) deltaHtml = '<span class="delta-flat">持平</span>';
  601. else {
  602. const up = delta > 0;
  603. deltaHtml = `<span class="delta ${up ? 'up' : 'down'}">${up ? '↑' : '↓'} ${Math.abs(delta * 100).toFixed(1)}%</span>`
  604. + `<span class="delta-sub">vs 上期 ${it.fmt(Math.round(it.prev * 100) / 100)}</span>`;
  605. }
  606. div.innerHTML = `<span class="delta-label"></span><span class="delta-value"></span><span class="delta-line">${deltaHtml}</span>`;
  607. div.querySelector('.delta-label').textContent = it.label;
  608. div.querySelector('.delta-value').textContent = it.fmt(cur);
  609. wrap.appendChild(div);
  610. }
  611. }
  612. async function renderCharts() {
  613. let data;
  614. try { data = await ensureAnalysisData(); } catch { return; }
  615. renderDeltas(data);
  616. const col = chartCol(chartMetric);
  617. const compareOn = $('anaComparePrev') && $('anaComparePrev').checked;
  618. const trend = ensureChart('trend', 'chartTrend');
  619. if (trend) {
  620. const cur = data.byDate.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
  621. const prev = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
  622. const xData = cur.map((r) => ymdToDash(r.date));
  623. const series = [{
  624. name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5,
  625. data: cur.map((r) => Number(r[chartMetric]) || 0),
  626. itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 },
  627. areaStyle: { color: 'rgba(255,107,53,.08)' },
  628. }];
  629. if (compareOn && prev.length) {
  630. series.push({
  631. name: '上期同期', type: 'line', smooth: true,
  632. data: xData.map((_, i) => (prev[i] ? Number(prev[i][chartMetric]) || 0 : 0)),
  633. itemStyle: { color: '#a5a09a' }, lineStyle: { type: 'dashed', width: 1.6 }, symbol: 'none',
  634. });
  635. }
  636. trend.setOption({
  637. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  638. legend: { data: series.map((x) => x.name), top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  639. grid: { left: 64, right: 24, top: 36, bottom: 30 },
  640. xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
  641. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  642. series,
  643. }, true);
  644. const title = $('anaTrendTitle');
  645. if (title) title.textContent = `主指标趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`;
  646. }
  647. const rank = ensureChart('rank', 'chartRank');
  648. if (rank) {
  649. const top = data.byShop.slice()
  650. .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
  651. .slice(0, 10).reverse();
  652. rank.setOption({
  653. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  654. grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
  655. xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  656. yAxis: {
  657. type: 'category', data: top.map((r) => r.shopName || r.shopId),
  658. axisLabel: { color: '#2b2a28', width: 130, overflow: 'truncate' },
  659. axisLine: { lineStyle: { color: '#e8e4df' } },
  660. },
  661. series: [{ type: 'bar', barMaxWidth: 18, data: top.map((r) => Number(r[chartMetric]) || 0), itemStyle: { color: ACCENT, borderRadius: [0, 6, 6, 0] } }],
  662. }, true);
  663. }
  664. const funnel = ensureChart('funnel', 'chartFunnel');
  665. if (funnel) {
  666. const sum = (f) => data.byDate.reduce((a, r) => a + (Number(r[f]) || 0), 0);
  667. const expUv = sum('expUv');
  668. const enter = data.byDate.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0);
  669. const order = sum('orderUserCnt');
  670. funnel.setOption({
  671. tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${fmtNum(Math.round(p.value))} 人` },
  672. series: [{
  673. type: 'funnel', left: '8%', width: '84%', top: 12, bottom: 8, minSize: '20%',
  674. label: { color: '#fff', fontWeight: 600, formatter: (p) => `${p.name} ${fmtNum(Math.round(p.value))}` },
  675. data: [
  676. { name: '曝光人数', value: Math.round(expUv), itemStyle: { color: '#ffb199' } },
  677. { name: '进店人数', value: Math.round(enter), itemStyle: { color: '#ff8a5c' } },
  678. { name: '下单人数', value: Math.round(order), itemStyle: { color: ACCENT } },
  679. ],
  680. }],
  681. }, true);
  682. }
  683. const compare = ensureChart('compare', 'chartCompare');
  684. if (compare) {
  685. const top5 = data.byShop.slice()
  686. .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
  687. .slice(0, 5);
  688. const dates = [...new Set(data.topDaily.map((r) => r.date))].sort();
  689. const palette = ['#ff6b35', '#2563eb', '#1a8a4c', '#b8860b', '#8b5cf6'];
  690. const series = top5.map((shop, i) => ({
  691. name: shop.shopName || shop.shopId,
  692. type: 'line', smooth: true, symbolSize: 4,
  693. data: dates.map((d) => {
  694. const r = data.topDaily.find((x) => x.date === d && String(x.shopId) === String(shop.shopId));
  695. return r ? (Number(r[chartMetric]) || 0) : 0;
  696. }),
  697. itemStyle: { color: palette[i % palette.length] }, lineStyle: { width: 2 },
  698. }));
  699. compare.setOption({
  700. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  701. legend: { top: 0, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  702. grid: { left: 64, right: 24, top: 36, bottom: 30 },
  703. xAxis: { type: 'category', boundaryGap: false, data: dates.map(ymdToDash), ...AXIS_TXT },
  704. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  705. series,
  706. }, true);
  707. const title = $('anaCompareTitle');
  708. if (title) title.textContent = `门店对比 · Top ${top5.length}(${col.label})`;
  709. }
  710. }
  711. // ---------- 智能问数 · 机器人(仅经营分析页面) ----------
  712. // 对话历史按主体持久化到 localStorage,刷新/重开后可继续追问
  713. const CHAT_LS_KEY = 'shanReports.chat.v1';
  714. const chatStoreKey = () => state.entity || '_';
  715. function loadChatForEntity() {
  716. const all = LS.get(CHAT_LS_KEY, {});
  717. state.chat.messages = Array.isArray(all[chatStoreKey()]) ? all[chatStoreKey()] : [];
  718. if (state.chat.open) renderChatMessages();
  719. }
  720. function saveChatForEntity() {
  721. const key = chatStoreKey();
  722. const all = LS.get(CHAT_LS_KEY, {});
  723. const trimmed = state.chat.messages.filter((m) => !m.pending).slice(-20);
  724. const attempt = (payload) => {
  725. try { localStorage.setItem(CHAT_LS_KEY, JSON.stringify(payload)); return true; } catch { return false; }
  726. };
  727. if (attempt({ ...all, [key]: trimmed })) return;
  728. // 图表行数据可能撑爆配额 → 退化为只存文字与图表配置
  729. const slim = { ...all, [key]: trimmed.map((m) => ({ ...m, rows: undefined })) };
  730. if (!(attempt(slim) || attempt({ [key]: slim[key].slice(-10) }))) { /* 配额不足则放弃持久化 */ }
  731. }
  732. function chatVisibleFor(view) { return view === 'reports' || view === 'analysis'; }
  733. function setChatOpen(open) {
  734. state.chat.open = open;
  735. $('chatbotPanel').hidden = !open;
  736. $('chatbotFab').classList.toggle('active', open);
  737. if (open) {
  738. loadChatForEntity();
  739. $('chatbotFab').hidden = false;
  740. setTimeout(() => $('chatbotInput').focus(), 60);
  741. scrollChatBottom();
  742. }
  743. }
  744. function scrollChatBottom() {
  745. const body = $('chatbotBody');
  746. body.scrollTop = body.scrollHeight;
  747. }
  748. function renderChatMessages() {
  749. const body = $('chatbotBody');
  750. [...body.querySelectorAll('.chatbot-msg:not(.static)')].forEach((n) => n.remove());
  751. body.querySelectorAll('.chatbot-chips').forEach((n) => { n.hidden = state.chat.messages.length > 0; });
  752. for (let i = 0; i < state.chat.messages.length; i++) {
  753. const m = state.chat.messages[i];
  754. const div = document.createElement('div');
  755. div.className = 'chatbot-msg ' + (m.role === 'user' ? 'user' : 'bot');
  756. if (m.pending) {
  757. div.innerHTML = '<div class="chatbot-bubble typing"><span></span><span></span><span></span></div>';
  758. } else {
  759. const bubble = document.createElement('div');
  760. bubble.className = 'chatbot-bubble';
  761. bubble.textContent = m.text || '';
  762. div.appendChild(bubble);
  763. if (m.chart && m.chart.type !== 'none' && m.rows && m.rows.length) {
  764. const chartWrap = document.createElement('div');
  765. chartWrap.className = 'chatbot-chart';
  766. chartWrap.dataset.idx = String(i);
  767. div.appendChild(chartWrap);
  768. const actions = document.createElement('div');
  769. actions.className = 'chatbot-msg-actions';
  770. const applyBtn = document.createElement('button');
  771. applyBtn.type = 'button';
  772. applyBtn.className = 'btn btn-ghost btn-small';
  773. applyBtn.textContent = '应用到报表';
  774. applyBtn.addEventListener('click', () => applyMsgQuery(i));
  775. actions.appendChild(applyBtn);
  776. div.appendChild(actions);
  777. }
  778. }
  779. body.appendChild(div);
  780. }
  781. scrollChatBottom();
  782. // 渲染消息内图表
  783. state.chat.messages.forEach((m, i) => {
  784. if (m.pending || !m.chart || m.chart.type === 'none' || !m.rows || !m.rows.length) return;
  785. const wrap = body.querySelector(`.chatbot-chart[data-idx="${i}"]`);
  786. if (!wrap) return;
  787. const col = (key) => (state.columns.find((c) => c.key === key) || { label: key, type: 'num' });
  788. const isDate = m.chart.x === 'date';
  789. const rows = m.rows.slice();
  790. if (isDate) rows.sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1));
  791. const chart = echarts.init(wrap);
  792. chart.setOption({
  793. tooltip: { trigger: 'axis' },
  794. grid: { left: 56, right: 16, top: 20, bottom: m.chart.type === 'bar' && !isDate ? 52 : 24 },
  795. xAxis: {
  796. type: 'category',
  797. data: rows.map((r) => (isDate ? ymdToDash(r.date) : (r.shopName || r.shopId))),
  798. axisLabel: { color: '#6b6560', interval: 0, rotate: m.chart.type === 'bar' && !isDate ? 30 : 0, width: 80, overflow: 'truncate' },
  799. axisLine: { lineStyle: { color: '#e8e4df' } },
  800. },
  801. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { lineStyle: { color: '#f0ede8' } } },
  802. series: (m.chart.y || []).map((key, yi) => {
  803. const c = col(key);
  804. return {
  805. name: c.label || key,
  806. type: m.chart.type === 'line' ? 'line' : 'bar',
  807. smooth: true, barMaxWidth: 26,
  808. data: rows.map((r) => {
  809. const v = r[key];
  810. if (v == null || v === '') return 0;
  811. const n = Number(v);
  812. return c.type === 'pct' ? +(n * 100).toFixed(2) : n;
  813. }),
  814. itemStyle: { color: yi === 0 ? '#ff6b35' : '#2563eb' },
  815. };
  816. }),
  817. });
  818. setTimeout(() => chart.resize(), 40);
  819. });
  820. }
  821. function applyMsgQuery(idx) {
  822. const m = state.chat.messages[idx];
  823. const a = m && m.applied;
  824. if (!a) return;
  825. state.mode = ['detail', 'shop', 'date'].includes(a.mode) ? a.mode : 'shop';
  826. $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === state.mode));
  827. state.preset = 'custom';
  828. $('reportRangePreset').value = 'custom';
  829. $('reportCustomRange').hidden = false;
  830. $('reportFromDate').value = ymdToDash(a.from);
  831. $('reportToDate').value = ymdToDash(a.to);
  832. state.shopFilter = new Set((a.shopIds || []).map(String));
  833. renderShopOptions();
  834. loadReport();
  835. const nav = document.querySelector('.nav-item[data-view="reports"]');
  836. if (nav) nav.click();
  837. }
  838. async function sendChat(text) {
  839. const q = String(text || '').trim();
  840. if (!q || state.chat.busy) return;
  841. state.chat.busy = true;
  842. state.chat.messages.push({ role: 'user', text: q });
  843. state.chat.messages.push({ role: 'assistant', pending: true });
  844. renderChatMessages();
  845. $('chatbotInput').value = '';
  846. try {
  847. const history = state.chat.messages
  848. .filter((m) => !m.pending && (m.role === 'user' || (m.role === 'assistant' && m.text)))
  849. .slice(0, -1)
  850. .slice(-8)
  851. .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 }));
  852. const result = await jsonFetch('/api/reports/ask', {
  853. method: 'POST',
  854. headers: { 'Content-Type': 'application/json' },
  855. body: JSON.stringify({ entity: state.entity, q, history }),
  856. });
  857. const pendingIdx = state.chat.messages.findIndex((m) => m.pending);
  858. const msg = {
  859. role: 'assistant',
  860. text: result.reply || '(模型未给出文字回答)',
  861. chart: result.chart,
  862. rows: result.rows,
  863. applied: result.applied,
  864. chartTitle: result.chartTitle,
  865. };
  866. if (pendingIdx >= 0) state.chat.messages.splice(pendingIdx, 1, msg);
  867. else state.chat.messages.push(msg);
  868. saveChatForEntity();
  869. } catch (e) {
  870. const pendingIdx = state.chat.messages.findIndex((m) => m.pending);
  871. const errMsg = { role: 'assistant', text: '出错了:' + e.message, error: true };
  872. if (pendingIdx >= 0) state.chat.messages.splice(pendingIdx, 1, errMsg);
  873. else state.chat.messages.push(errMsg);
  874. } finally {
  875. state.chat.busy = false;
  876. renderChatMessages();
  877. }
  878. }
  879. // ---------- 事件绑定 ----------
  880. function bindEvents() {
  881. $('btnReportRefresh').addEventListener('click', async () => {
  882. await refreshConn();
  883. await loadReport();
  884. showToast('已刷新');
  885. });
  886. $('btnClientHelp').addEventListener('click', (event) => {
  887. event.stopPropagation();
  888. const pop = $('clientHelpPopover');
  889. pop.hidden = !pop.hidden;
  890. $('btnClientHelp').classList.toggle('active', !pop.hidden);
  891. $('btnClientHelp').setAttribute('aria-expanded', String(!pop.hidden));
  892. });
  893. document.addEventListener('click', (event) => {
  894. const pop = $('clientHelpPopover');
  895. if (!pop.hidden && !event.target.closest('.conn-help-wrap')) {
  896. pop.hidden = true;
  897. $('btnClientHelp').classList.remove('active');
  898. $('btnClientHelp').setAttribute('aria-expanded', 'false');
  899. }
  900. });
  901. $('btnOpenSync').addEventListener('click', openSyncModal);
  902. $('btnCloseSync').addEventListener('click', () => {
  903. $('modalReportSync').hidden = true;
  904. if (state.syncPollTimer) { clearInterval(state.syncPollTimer); state.syncPollTimer = null; }
  905. $('syncProgressWrap').hidden = true;
  906. });
  907. $('btnCancelSync').addEventListener('click', () => $('modalReportSync').hidden = true);
  908. $('btnStartSync').addEventListener('click', startSync);
  909. $('btnSyncCancel').addEventListener('click', async () => {
  910. try { await jsonFetch('/api/reports/sync/cancel', { method: 'POST' }); showToast('已请求取消,等待当前步骤结束…'); } catch { /* ignore */ }
  911. });
  912. $('syncScope').addEventListener('change', () => {
  913. $('syncShopPickWrap').hidden = $('syncScope').value !== 'custom';
  914. });
  915. $('autoSyncEnabled').addEventListener('change', saveAutoSyncPrefs);
  916. $('autoSyncTime').addEventListener('change', saveAutoSyncPrefs);
  917. $('syncShopTrigger').addEventListener('click', () => toggleDropdown('syncShopMenu', 'syncShopTrigger'));
  918. $('syncShopMenu').addEventListener('click', makeDropdownHandler($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店'));
  919. document.addEventListener('click', (event) => {
  920. if (!$('syncShopDropdown').contains(event.target)) toggleDropdown('syncShopMenu', 'syncShopTrigger', false);
  921. });
  922. $('reportRangePreset').addEventListener('change', () => {
  923. state.preset = $('reportRangePreset').value;
  924. $('reportCustomRange').hidden = state.preset !== 'custom';
  925. loadReport();
  926. });
  927. $('reportFromDate').addEventListener('change', loadReport);
  928. $('reportToDate').addEventListener('change', loadReport);
  929. $('reportShopTrigger').addEventListener('click', () => toggleDropdown('reportShopMenu', 'reportShopTrigger'));
  930. $('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', loadReport));
  931. document.addEventListener('click', (event) => {
  932. if (!$('reportShopDropdown').contains(event.target)) toggleDropdown('reportShopMenu', 'reportShopTrigger', false);
  933. });
  934. $('reportViewModes').addEventListener('click', (event) => {
  935. const btn = event.target.closest('button[data-mode]');
  936. if (!btn) return;
  937. state.mode = btn.dataset.mode;
  938. $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
  939. state.sortKey = state.mode === 'detail' ? 'date' : (state.mode === 'shop' ? 'shopName' : 'date');
  940. state.sortDir = 'desc';
  941. loadReport();
  942. });
  943. let searchTimer = null;
  944. $('reportSearch').addEventListener('input', () => {
  945. clearTimeout(searchTimer);
  946. searchTimer = setTimeout(() => {
  947. state.q = $('reportSearch').value.trim();
  948. loadReport();
  949. }, 300);
  950. });
  951. $('btnColumnCfg').addEventListener('click', () => {
  952. renderColumnCfg();
  953. $('modalReportColumns').hidden = false;
  954. });
  955. $('btnCloseColumnCfg').addEventListener('click', () => $('modalReportColumns').hidden = true);
  956. $('btnApplyColumns').addEventListener('click', applyColumnCfg);
  957. $('btnResetColumns').addEventListener('click', () => {
  958. state.visible = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
  959. LS.set('shanReports.columns.v2', Array.from(state.visible));
  960. renderColumnCfg();
  961. renderTable();
  962. });
  963. $('btnExportCsv').addEventListener('click', exportCsv);
  964. $('btnExportHtml').addEventListener('click', exportHtml);
  965. $('reportTableHead').addEventListener('click', (event) => {
  966. const th = event.target.closest('th[data-key]');
  967. if (!th) return;
  968. const key = th.dataset.key;
  969. if (state.sortKey === key) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
  970. else { state.sortKey = key; state.sortDir = 'desc'; }
  971. renderTable();
  972. });
  973. // 趋势图表
  974. $('analysisRangePreset').addEventListener('change', () => {
  975. state.analysisPreset = $('analysisRangePreset').value;
  976. anaCache = null;
  977. renderCharts();
  978. });
  979. $('anaShopTrigger').addEventListener('click', () => toggleDropdown('anaShopMenu', 'anaShopTrigger'));
  980. $('anaShopMenu').addEventListener('click', makeDropdownHandler($('anaShopMenu'), state.analysisShopFilter, $('anaShopText'), '全部门店', () => {
  981. anaCache = null;
  982. renderCharts();
  983. }));
  984. document.addEventListener('click', (event) => {
  985. if (!$('anaShopDropdown').contains(event.target)) toggleDropdown('anaShopMenu', 'anaShopTrigger', false);
  986. });
  987. $('chartMetric').addEventListener('change', () => {
  988. chartMetric = $('chartMetric').value || 'grossAmt';
  989. LS.set('shanReports.chartMetric', chartMetric);
  990. renderCharts();
  991. });
  992. $('anaComparePrev').addEventListener('change', renderCharts);
  993. // 智能问数机器人
  994. $('chatbotFab').addEventListener('click', () => setChatOpen(!state.chat.open));
  995. $('btnChatClose').addEventListener('click', () => setChatOpen(false));
  996. $('btnChatClear').addEventListener('click', () => {
  997. state.chat.messages = [];
  998. const all = LS.get(CHAT_LS_KEY, {});
  999. delete all[state.entity || '_'];
  1000. LS.set(CHAT_LS_KEY, all);
  1001. renderChatMessages();
  1002. });
  1003. $('btnChatSend').addEventListener('click', () => sendChat($('chatbotInput').value));
  1004. $('chatbotInput').addEventListener('keydown', (event) => {
  1005. if (event.key === 'Enter') sendChat($('chatbotInput').value);
  1006. });
  1007. $('chatbotChips').addEventListener('click', (event) => {
  1008. const chip = event.target.closest('[data-q]');
  1009. if (chip) sendChat(chip.dataset.q);
  1010. });
  1011. window.addEventListener('resize', () => {
  1012. Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
  1013. });
  1014. }
  1015. // ---------- 初始化 ----------
  1016. window.shanReportsInit = async function () {
  1017. if (!state.inited) {
  1018. state.inited = true;
  1019. bindEvents();
  1020. try {
  1021. const data = await jsonFetch('/api/reports/columns');
  1022. state.columns = data.columns || [];
  1023. } catch { state.columns = []; }
  1024. loadColumnPrefs();
  1025. renderChartMetricSelect();
  1026. }
  1027. await refreshConn();
  1028. await loadReport();
  1029. };
  1030. window.shanAnalysisActivate = async function () {
  1031. await window.shanReportsInit();
  1032. renderChartMetricSelect();
  1033. await renderCharts();
  1034. Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
  1035. };
  1036. window.shanSetAnalysisVisible = function (visible) {
  1037. const fab = $('chatbotFab');
  1038. if (!fab) return;
  1039. fab.hidden = !visible;
  1040. if (!visible) setChatOpen(false);
  1041. };
  1042. })();