report.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. 'use strict';
  2. // 经营报告:同一份 Report JSON 渲染「图文报告 / 交互报告」,PDF 走浏览器打印样式。
  3. // 数据来自 GET /api/ops/report(后端算指标、规则打标,模型只负责文字)。
  4. (function () {
  5. const $ = (id) => document.getElementById(id);
  6. const COLORS = { main: '#ff6b35', soft: '#ffb08a', grey: '#c9c2ba', blue: '#7a9cc6', light: '#ffd6c2' };
  7. const state = {
  8. inited: false,
  9. loading: false,
  10. report: null,
  11. mode: 'text', // text | interactive
  12. from: '',
  13. to: '',
  14. schools: [],
  15. shops: [],
  16. charts: {},
  17. };
  18. function esc(value) {
  19. const div = document.createElement('div');
  20. div.textContent = String(value == null ? '' : value);
  21. return div.innerHTML;
  22. }
  23. function moneyText(v) {
  24. if (v == null) return '–';
  25. const n = Number(v);
  26. return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '–';
  27. }
  28. function kpiText(kpi) {
  29. if (kpi.current == null) return '–';
  30. if (kpi.unit === '%') return kpi.current + '%';
  31. if (kpi.unit === '单' || kpi.unit === '人') return numText(kpi.current);
  32. return moneyText(kpi.current);
  33. }
  34. function numText(v) {
  35. if (v == null) return '–';
  36. const n = Number(v);
  37. return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '–';
  38. }
  39. function deltaText(kpi) {
  40. if (kpi.change_pct == null) return '<span class="rp-delta muted">—</span>';
  41. const up = kpi.change_pct > 0;
  42. const cls = kpi.change_pct === 0 ? 'muted' : up ? 'up' : 'down';
  43. const unit = kpi.unit === '%' ? 'pp' : '%';
  44. return `<span class="rp-delta ${cls}">${up ? '↑' : '↓'} ${Math.abs(kpi.change_pct).toFixed(1)}${unit}</span>`;
  45. }
  46. async function api(url, options) {
  47. const res = await fetch(url, options);
  48. const body = await res.json().catch(() => null);
  49. return { res, body };
  50. }
  51. // ---------- 渲染 ----------
  52. function renderShell() {
  53. const root = $('reportRoot');
  54. if (!root) return;
  55. root.innerHTML = `
  56. <div class="rp-toolbar">
  57. <div class="rp-range">
  58. <input type="date" class="ops-input" id="rpFrom">
  59. <span class="ops-tilde">~</span>
  60. <input type="date" class="ops-input" id="rpTo">
  61. </div>
  62. <div class="ops-multi" id="rpSchool"></div>
  63. <div class="ops-multi" id="rpShop"></div>
  64. <div class="rp-tabs" id="rpTabs">
  65. <button type="button" data-mode="text" class="on">图文报告</button>
  66. <button type="button" data-mode="interactive">交互报告</button>
  67. </div>
  68. <div class="rp-actions">
  69. <button type="button" class="btn btn-ghost btn-small" id="rpArchive">历史报告</button>
  70. <button type="button" class="btn btn-ghost btn-small" id="rpSave" disabled>保存报告</button>
  71. <button type="button" class="btn btn-ghost btn-small" id="rpRefresh">重新生成</button>
  72. <button type="button" class="btn btn-primary btn-small" id="rpPrint">下载 PDF</button>
  73. </div>
  74. </div>
  75. <article class="rp-page" id="rpPage"></article>
  76. <div class="modal-overlay" id="rpHistoryModal" hidden>
  77. <div class="modal rp-history-modal">
  78. <div class="modal-header">
  79. <div class="modal-title"><span>历史报告</span></div>
  80. <button class="modal-close" id="rpHistoryClose" type="button" aria-label="关闭">×</button>
  81. </div>
  82. <div class="modal-body" id="rpHistoryList"></div>
  83. </div>
  84. </div>`;
  85. root.querySelectorAll('#rpTabs [data-mode]').forEach((btn) => {
  86. btn.addEventListener('click', () => {
  87. state.mode = btn.dataset.mode;
  88. root.querySelectorAll('#rpTabs [data-mode]').forEach((b) => b.classList.toggle('on', b === btn));
  89. $('rpPage').classList.toggle('interactive', state.mode === 'interactive');
  90. if (state.report) drawCharts(state.report);
  91. });
  92. });
  93. $('rpFrom').addEventListener('change', (e) => { state.from = String(e.target.value || '').replace(/-/g, ''); load(true); });
  94. $('rpTo').addEventListener('change', (e) => { state.to = String(e.target.value || '').replace(/-/g, ''); load(true); });
  95. $('rpRefresh').addEventListener('click', () => load(true));
  96. $('rpPrint').addEventListener('click', () => window.print());
  97. $('rpSave').addEventListener('click', saveReport);
  98. $('rpArchive').addEventListener('click', openHistory);
  99. $('rpHistoryClose').addEventListener('click', () => { $('rpHistoryModal').hidden = true; });
  100. }
  101. function renderCover(report) {
  102. const meta = report.meta || {};
  103. return `
  104. <header class="rp-cover">
  105. <div class="rp-cover-main">
  106. <div class="rp-kicker">AI 经营分析报告</div>
  107. <h1>${esc(meta.title || '经营分析报告')}</h1>
  108. <div class="rp-cover-sub">${esc(meta.organization || '')}${meta.organization ? ' · ' : ''}${esc(meta.period_start)} ~ ${esc(meta.period_end)}</div>
  109. </div>
  110. <div class="rp-cover-meta">
  111. <div><span>统计范围</span><b>${esc(meta.data_scope || '–')}</b></div>
  112. <div><span>数据来源</span><b>${esc((meta.source_files || []).join('、') || '平台导出明细')}</b></div>
  113. <div><span>生成时间</span><b>${esc(new Date(meta.generated_at).toLocaleString('zh-CN'))}</b></div>
  114. </div>
  115. </header>`;
  116. }
  117. function renderSummary(report) {
  118. const summary = report.executive_summary || {};
  119. const dot = { positive: 'ok', neutral: 'na', warning: 'warn', critical: 'bad' };
  120. const items = (summary.key_findings || []).map((f) => `
  121. <li>
  122. <span class="rp-dot ${dot[f.severity] || 'na'}"></span>
  123. <div>
  124. <b>${esc(f.finding)}</b>
  125. <p>${esc(f.interpretation || '')}</p>
  126. ${(f.evidence || []).length ? `<div class="rp-evidence">${f.evidence.map((e) => `<code>${esc(e)}</code>`).join('')}</div>` : ''}
  127. </div>
  128. </li>`).join('');
  129. return `
  130. <section class="rp-section rp-summary">
  131. <h2>管理层摘要</h2>
  132. <p class="rp-lead">${esc(summary.overall_assessment || '')}</p>
  133. <ul class="rp-findings">${items}</ul>
  134. </section>`;
  135. }
  136. function renderKpis(report) {
  137. return `
  138. <section class="rp-section">
  139. <h2>核心指标</h2>
  140. <div class="rp-kpis">
  141. ${(report.kpis || []).map((kpi) => `
  142. <div class="rp-kpi" title="${esc(kpi.definition)}">
  143. <div class="rp-kpi-label">${esc(kpi.label)}<span>${esc(kpi.unit === 'CNY' ? '元' : kpi.unit)}</span></div>
  144. <div class="rp-kpi-value">${kpiText(kpi)}</div>
  145. <div class="rp-kpi-foot">${deltaText(kpi)}<span class="rp-kpi-hint">较上期</span></div>
  146. </div>`).join('')}
  147. </div>
  148. <p class="rp-note">口径:${(report.kpis || []).map((k) => `${k.label}=${k.definition}`).join(';')}</p>
  149. </section>`;
  150. }
  151. function chartCard(id, title, subtitle) {
  152. return `<figure class="rp-figure">
  153. <figcaption><b>${esc(title)}</b><span>${esc(subtitle || '')}</span></figcaption>
  154. <div class="rp-chart" id="${id}"></div>
  155. </figure>`;
  156. }
  157. function renderSections(report) {
  158. const chartById = new Map((report.charts || []).map((c) => [c.id, c]));
  159. const tableById = new Map((report.tables || []).map((t) => [t.id, t]));
  160. return (report.sections || []).map((section, index) => {
  161. if (section.id === 'quality') return '';
  162. const charts = (section.chart_ids || []).map((id) => chartById.get(id)).filter(Boolean);
  163. const tables = (section.table_ids || []).map((id) => tableById.get(id)).filter(Boolean);
  164. return `
  165. <section class="rp-section">
  166. <h2><em>${String(index + 1).padStart(2, '0')}</em>${esc(section.title)}</h2>
  167. ${section.summary ? `<p class="rp-lead">${esc(section.summary)}</p>` : ''}
  168. ${(section.paragraphs || []).map((p) => `<p>${esc(p)}</p>`).join('')}
  169. ${charts.map((chart) => chartCard(`rpChart_${chart.id}`, chart.title, chart.description)).join('')}
  170. ${tables.map((table) => renderTable(table)).join('')}
  171. </section>`;
  172. }).join('');
  173. }
  174. function renderTable(table) {
  175. return `
  176. <div class="rp-table-wrap">
  177. <table class="rp-table">
  178. <thead><tr>${table.columns.map((c) => `<th>${esc(c.label)}</th>`).join('')}</tr></thead>
  179. <tbody>${(table.rows || []).map((row) => `<tr>${table.columns.map((c) => {
  180. const v = row[c.key];
  181. const text = c.key === 'revenue' ? moneyText(v) : c.key === 'share' ? (v == null ? '–' : v + '%') : (v == null ? '–' : esc(v));
  182. return `<td>${text}</td>`;
  183. }).join('')}</tr>`).join('')}</tbody>
  184. </table>
  185. ${table.footnote ? `<p class="rp-note">${esc(table.footnote)}</p>` : ''}
  186. </div>`;
  187. }
  188. function renderAlerts(report) {
  189. const alerts = report.alerts || [];
  190. const plan = report.action_plan || [];
  191. return `
  192. <section class="rp-section">
  193. <h2><em>05</em>风险与建议</h2>
  194. <div class="rp-alerts">
  195. ${alerts.map((a) => `
  196. <div class="rp-alert ${esc(a.severity)}">
  197. <div class="rp-alert-head"><span class="rp-badge">${esc(a.severity)}</span><b>${esc(a.title)}</b></div>
  198. <ul>${(a.evidence || []).map((e) => `<li>${esc(e)}</li>`).join('')}</ul>
  199. ${(a.hypotheses || []).length ? `<p class="rp-note">待验证:${a.hypotheses.map(esc).join('、')}</p>` : ''}
  200. </div>`).join('') || '<p class="rp-note">本期未触发风险规则。</p>'}
  201. </div>
  202. <h3 class="rp-h3">行动计划</h3>
  203. <table class="rp-table">
  204. <thead><tr><th>优先级</th><th>行动</th><th>负责人</th><th>时间窗</th><th>验收指标</th></tr></thead>
  205. <tbody>${plan.map((p) => `<tr>
  206. <td><span class="rp-badge">${esc(p.priority)}</span></td>
  207. <td>${esc(p.action)}</td>
  208. <td>${esc(p.owner_role)}</td>
  209. <td>${esc(p.time_horizon)}</td>
  210. <td>${esc(p.success_metric)}</td>
  211. </tr>`).join('')}</tbody>
  212. </table>
  213. </section>`;
  214. }
  215. function renderQuality(report) {
  216. const q = report.data_quality || {};
  217. return `
  218. <section class="rp-section rp-quality">
  219. <h2><em>06</em>数据质量与方法论</h2>
  220. <p><b>覆盖范围:</b>${esc(q.coverage || '–')}</p>
  221. <p><b>可比性:</b>${esc(q.comparability || '–')}</p>
  222. ${(q.warnings || []).length ? `<p><b>提示:</b>${q.warnings.map(esc).join(';')}</p>` : ''}
  223. ${(q.missing_metrics || []).length ? `<p><b>缺失指标:</b>${q.missing_metrics.map(esc).join(';')}</p>` : ''}
  224. <p><b>方法论:</b></p>
  225. <ul class="rp-method">${(report.methodology || []).map((m) => `<li>${esc(m)}</li>`).join('')}</ul>
  226. </section>`;
  227. }
  228. function renderReport(report) {
  229. const page = $('rpPage');
  230. const saveBtn = $('rpSave');
  231. if (saveBtn) saveBtn.disabled = false;
  232. page.innerHTML = [
  233. renderCover(report),
  234. renderSummary(report),
  235. renderKpis(report),
  236. renderSections(report),
  237. renderAlerts(report),
  238. renderQuality(report),
  239. `<footer class="rp-footer">${report.ai && report.ai.source === 'llm' ? `文字由 AI 生成(${esc(report.ai.model || '')})` : '当前为规则版文字'};数值均由后端按统一口径计算。${report.aiError ? ' ' + esc(report.aiError) : ''}</footer>`,
  240. ].join('');
  241. drawCharts(report);
  242. }
  243. // ---------- 图表 ----------
  244. function chartBase(interactive) {
  245. return {
  246. animation: !interactive,
  247. silent: !interactive,
  248. tooltip: interactive ? { trigger: 'axis' } : undefined,
  249. };
  250. }
  251. function drawCharts(report) {
  252. const interactive = state.mode === 'interactive';
  253. const byId = new Map((report.charts || []).map((c) => [c.id, c]));
  254. Object.values(state.charts).forEach((chart) => chart && chart.dispose());
  255. state.charts = {};
  256. const mount = (chart) => {
  257. const el = $(`rpChart_${chart.id}`);
  258. if (!el) return null;
  259. const instance = echarts.init(el);
  260. state.charts[chart.id] = instance;
  261. return instance;
  262. };
  263. const trend = byId.get('chart_trend');
  264. if (trend) {
  265. const chart = mount(trend);
  266. chart && chart.setOption({
  267. ...chartBase(interactive),
  268. grid: { left: 56, right: 48, top: 24, bottom: 30 },
  269. legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
  270. xAxis: { type: 'category', data: trend.data.map((d) => d.date.slice(4)), axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e0da' } } },
  271. yAxis: [{ type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } }, { type: 'value', splitLine: { show: false } }],
  272. series: [
  273. { name: '营业额', type: 'bar', barMaxWidth: 22, itemStyle: { color: COLORS.main, borderRadius: [3, 3, 0, 0] }, data: trend.data.map((d) => d.revenue) },
  274. { name: '订单', type: 'line', yAxisIndex: 1, symbolSize: 5, lineStyle: { width: 2, color: COLORS.blue }, itemStyle: { color: COLORS.blue }, data: trend.data.map((d) => d.orders) },
  275. ],
  276. });
  277. }
  278. const rank = byId.get('chart_rank');
  279. if (rank) {
  280. const rows = rank.data.slice().reverse();
  281. const chart = mount(rank);
  282. chart && chart.setOption({
  283. ...chartBase(interactive),
  284. grid: { left: 8, right: 60, top: 10, bottom: 10, containLabel: true },
  285. xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } },
  286. yAxis: { type: 'category', data: rows.map((r) => r.shopName), axisTick: { show: false }, axisLine: { show: false }, axisLabel: { width: 140, overflow: 'truncate', fontSize: 11 } },
  287. series: [{ type: 'bar', barMaxWidth: 14, itemStyle: { color: COLORS.soft, borderRadius: [0, 3, 3, 0] }, label: { show: true, position: 'right', fontSize: 10, color: '#6f6862' }, data: rows.map((r) => r.revenue) }],
  288. });
  289. }
  290. const funnel = byId.get('chart_funnel');
  291. if (funnel) {
  292. const f = funnel.data;
  293. const chart = mount(funnel);
  294. chart && chart.setOption({
  295. ...chartBase(interactive),
  296. series: [{
  297. type: 'funnel', left: '10%', right: '10%', top: 12, bottom: 12, minSize: '30%', gap: 3,
  298. label: { show: true, position: 'inside', color: '#fff', fontSize: 11, formatter: (p) => `${p.name} ${Number(p.value).toLocaleString('zh-CN')}` },
  299. itemStyle: { borderWidth: 0 },
  300. color: [COLORS.main, '#ff9a70', COLORS.light],
  301. data: [{ name: '曝光', value: f.exposure }, { name: '进店', value: f.visit }, { name: '下单', value: f.order }],
  302. }],
  303. });
  304. }
  305. const structure = byId.get('chart_structure');
  306. if (structure) {
  307. const chart = mount(structure);
  308. chart && chart.setOption({
  309. ...chartBase(interactive),
  310. grid: { left: 56, right: 20, top: 28, bottom: 30 },
  311. legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
  312. xAxis: { type: 'category', data: structure.data.map((d) => d.date.slice(4)), axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e0da' } } },
  313. yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } },
  314. series: [
  315. { name: '收入', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.main }, data: structure.data.map((d) => d.income) },
  316. { name: '支出', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.grey }, data: structure.data.map((d) => d.expense) },
  317. { name: '补贴', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.light }, data: structure.data.map((d) => d.subsidy) },
  318. ],
  319. });
  320. }
  321. }
  322. // ---------- 报告中心 ----------
  323. async function saveReport() {
  324. const btn = $('rpSave');
  325. btn.disabled = true;
  326. btn.textContent = '保存中…';
  327. try {
  328. const { res, body } = await api('/api/ops/reports', {
  329. method: 'POST',
  330. headers: { 'Content-Type': 'application/json' },
  331. body: JSON.stringify({
  332. from: state.from, to: state.to,
  333. schools: state.schools.join(','), shops: state.shops.join(','),
  334. report: state.report,
  335. }),
  336. });
  337. if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
  338. btn.textContent = '已保存';
  339. setTimeout(() => { btn.textContent = '保存报告'; btn.disabled = false; }, 1500);
  340. } catch (e) {
  341. btn.textContent = '保存报告';
  342. btn.disabled = false;
  343. alert('保存失败:' + (e.message || e));
  344. }
  345. }
  346. async function openHistory() {
  347. $('rpHistoryModal').hidden = false;
  348. await renderHistory();
  349. }
  350. async function renderHistory() {
  351. const list = $('rpHistoryList');
  352. list.innerHTML = '<div class="rp-loading" style="padding:24px 0">加载中…</div>';
  353. const { res, body } = await api('/api/ops/reports');
  354. if (!res.ok) {
  355. list.innerHTML = `<div class="rp-loading">读取失败:${esc((body && (body.error || body.detail)) || res.status)}</div>`;
  356. return;
  357. }
  358. const reports = (body && body.reports) || [];
  359. if (!reports.length) {
  360. list.innerHTML = '<div class="rp-loading" style="padding:24px 0">还没有保存过报告</div>';
  361. return;
  362. }
  363. list.innerHTML = `<table class="rp-table">
  364. <thead><tr><th>保存时间</th><th>区间</th><th>学校</th><th>店铺</th><th>来源</th><th class="rp-col-actions">操作</th></tr></thead>
  365. <tbody>${reports.map((r) => `<tr>
  366. <td>${esc(new Date(r.createdAt).toLocaleString('zh-CN'))}</td>
  367. <td>${esc(r.from)} ~ ${esc(r.to)}</td>
  368. <td>${(r.schoolNames || []).length ? esc(r.schoolNames.join('、')) : '<span class="rp-muted">全部</span>'}</td>
  369. <td title="${esc((r.shopNames || []).join('、'))}">${(r.shopNames || []).length
  370. ? esc(r.shopNames.slice(0, 3).join('、')) + (r.shopNames.length > 3 ? ` 等 ${r.shopNames.length} 家` : '')
  371. : '<span class="rp-muted">全部</span>'}</td>
  372. <td>${r.source === 'llm' ? 'AI 生成' : '规则生成'}</td>
  373. <td class="rp-col-actions">
  374. <button type="button" class="btn btn-ghost btn-small" data-view="${esc(r.id)}">查看</button>
  375. <button type="button" class="btn btn-ghost btn-small" data-pdf="${esc(r.id)}">PDF</button>
  376. <button type="button" class="btn btn-ghost btn-small" data-json="${esc(r.id)}">JSON</button>
  377. <button type="button" class="btn btn-ghost btn-small" data-del="${esc(r.id)}">删除</button>
  378. </td>
  379. </tr>`).join('')}</tbody>
  380. </table>`;
  381. list.querySelectorAll('[data-view]').forEach((btn) => {
  382. btn.addEventListener('click', () => loadSaved(btn.dataset.view));
  383. });
  384. list.querySelectorAll('[data-pdf]').forEach((btn) => {
  385. btn.addEventListener('click', () => downloadSavedPdf(btn.dataset.pdf));
  386. });
  387. list.querySelectorAll('[data-json]').forEach((btn) => {
  388. btn.addEventListener('click', () => downloadSavedJson(btn.dataset.json));
  389. });
  390. list.querySelectorAll('[data-del]').forEach((btn) => {
  391. btn.addEventListener('click', async () => {
  392. if (!confirm('删除这份历史报告?')) return;
  393. btn.disabled = true;
  394. await api('/api/ops/reports/' + encodeURIComponent(btn.dataset.del), { method: 'DELETE' });
  395. renderHistory();
  396. });
  397. });
  398. }
  399. async function fetchSaved(id) {
  400. const { res, body } = await api('/api/ops/reports/' + encodeURIComponent(id));
  401. if (!res.ok || !body || !body.report) {
  402. alert('读取失败');
  403. return null;
  404. }
  405. return body.report;
  406. }
  407. async function downloadSavedPdf(id) {
  408. const report = await fetchSaved(id);
  409. if (!report) return;
  410. state.report = report;
  411. $('rpHistoryModal').hidden = true;
  412. renderReport(report);
  413. const meta = report.meta || {};
  414. const prevTitle = document.title;
  415. document.title = `经营分析报告_${String(meta.period_start || '').replace(/-/g, '')}-${String(meta.period_end || '').replace(/-/g, '')}`;
  416. setTimeout(() => {
  417. window.print();
  418. document.title = prevTitle;
  419. }, 500);
  420. }
  421. async function downloadSavedJson(id) {
  422. const report = await fetchSaved(id);
  423. if (!report) return;
  424. const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
  425. const url = URL.createObjectURL(blob);
  426. const a = document.createElement('a');
  427. a.href = url;
  428. a.download = `经营分析报告_${id}.json`;
  429. document.body.appendChild(a);
  430. a.click();
  431. a.remove();
  432. setTimeout(() => URL.revokeObjectURL(url), 1000);
  433. }
  434. async function loadSaved(id) {
  435. const { res, body } = await api('/api/ops/reports/' + encodeURIComponent(id));
  436. if (!res.ok || !body || !body.report) {
  437. alert('读取失败');
  438. return;
  439. }
  440. const report = body.report;
  441. state.report = report;
  442. const from = String(report.meta.period_start || '').replace(/-/g, '');
  443. const to = String(report.meta.period_end || '').replace(/-/g, '');
  444. state.from = from;
  445. state.to = to;
  446. $('rpFrom').value = String(report.meta.period_start || '');
  447. $('rpTo').value = String(report.meta.period_end || '');
  448. renderReport(report);
  449. $('rpHistoryModal').hidden = true;
  450. }
  451. // ---------- 数据 ----------
  452. async function load(force) {
  453. if (state.loading) { state.pending = true; return; }
  454. state.loading = true;
  455. const page = $('rpPage');
  456. if (page) page.classList.add('rp-stale');
  457. const saveBtn = $('rpSave');
  458. if (saveBtn) saveBtn.disabled = true;
  459. if (page && !state.report) page.innerHTML = '<div class="rp-loading">正在生成报告…</div>';
  460. try {
  461. const params = new URLSearchParams();
  462. if (state.from) params.set('from', state.from);
  463. if (state.to) params.set('to', state.to);
  464. if (state.schools.length) params.set('schools', state.schools.join(','));
  465. if (state.shops.length) params.set('shops', state.shops.join(','));
  466. params.set('ai', '1'); // 默认走 AI,失败自动退回规则版
  467. const { res, body } = await api('/api/ops/report?' + params.toString());
  468. if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
  469. if (body.empty) {
  470. page.innerHTML = '<div class="rp-loading">所选范围没有数据,请先导入明细。</div>';
  471. return;
  472. }
  473. state.report = body.report;
  474. renderReport(state.report);
  475. } catch (e) {
  476. page.innerHTML = `<div class="rp-loading">报告生成失败:${esc(e.message || e)}</div>`;
  477. } finally {
  478. state.loading = false;
  479. if (page) page.classList.remove('rp-stale');
  480. if (state.pending) {
  481. state.pending = false;
  482. load(true);
  483. }
  484. }
  485. }
  486. async function initDates() {
  487. const search = new URLSearchParams(location.search);
  488. const qFrom = String(search.get('from') || '').replace(/\D/g, '');
  489. const qTo = String(search.get('to') || '').replace(/\D/g, '');
  490. state.shops = String(search.get('shops') || '').split(',').map((s) => s.trim()).filter(Boolean);
  491. state.schools = String(search.get('schools') || '').split(',').map((s) => s.trim()).filter(Boolean);
  492. const { body } = await api('/api/ops/summary');
  493. const summary = body || { schools: [], shops: [] };
  494. const coverage = summary.coverage;
  495. const toInput = (ymd) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
  496. if (/^\d{8}$/.test(qFrom) && /^\d{8}$/.test(qTo)) {
  497. state.from = qFrom;
  498. state.to = qTo;
  499. } else if (coverage) {
  500. const to = coverage.to;
  501. const d = new Date(Date.UTC(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)) - 6));
  502. const from = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
  503. state.to = to;
  504. state.from = from < coverage.from ? coverage.from : from;
  505. }
  506. if (state.from && state.to) {
  507. $('rpFrom').value = toInput(state.from);
  508. $('rpTo').value = toInput(state.to);
  509. }
  510. mountFilters(summary);
  511. syncUrl();
  512. }
  513. function mountFilters(summary) {
  514. const schools = (summary.schools || []).map((s) => ({ key: s.name, label: s.name, hint: `${s.shopCount || 0} 家` }));
  515. window.createMultiSelect($('rpSchool'), {
  516. label: '学校',
  517. items: schools,
  518. selected: state.schools,
  519. allowEmpty: true,
  520. emptyLabel: '全部',
  521. onChange: (keys) => {
  522. state.schools = keys;
  523. syncUrl();
  524. load(true);
  525. },
  526. });
  527. const shops = (summary.shops || []).map((s) => ({
  528. key: String(s.key || s.shopId || ''),
  529. label: s.shopName || s.key,
  530. hint: s.school || '',
  531. })).filter((item) => item.key);
  532. window.createMultiSelect($('rpShop'), {
  533. label: '店铺',
  534. items: shops,
  535. selected: state.shops,
  536. allowEmpty: true,
  537. emptyLabel: '全部',
  538. onChange: (keys) => {
  539. state.shops = keys;
  540. syncUrl();
  541. load(true);
  542. },
  543. });
  544. window.bindMultiSelectDismiss();
  545. }
  546. function syncUrl() {
  547. const params = new URLSearchParams({ view: 'report' });
  548. if (state.from) params.set('from', state.from);
  549. if (state.to) params.set('to', state.to);
  550. if (state.schools.length) params.set('schools', state.schools.join(','));
  551. if (state.shops.length) params.set('shops', state.shops.join(','));
  552. history.replaceState(null, '', '/?' + params.toString());
  553. }
  554. window.shanReportInit = function () {
  555. if (!state.inited) {
  556. state.inited = true;
  557. renderShell();
  558. initDates().then(() => load());
  559. window.addEventListener('resize', () => {
  560. Object.values(state.charts).forEach((chart) => chart && chart.resize());
  561. });
  562. window.addEventListener('beforeprint', () => {
  563. Object.values(state.charts).forEach((chart) => chart && chart.resize());
  564. });
  565. }
  566. };
  567. })();