cockpit.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. 'use strict';
  2. // 经营驾驶舱:KPI + 趋势 / 门店排行 / 转化漏斗 / 收入结构
  3. // 数据来自 GET /api/ops/dashboard(服务端已按统一口径聚合)
  4. (function () {
  5. const $ = (id) => document.getElementById(id);
  6. const COLORS = { main: '#ff6b35', soft: '#ffb08a', grey: '#b8b2ad', green: '#2e7d4f', red: '#c0392b' };
  7. const KPI_DEFS = [
  8. { key: 'revenue', label: '营业额', unit: 'money' },
  9. { key: 'orders', label: '有效订单', unit: 'int' },
  10. { key: 'avgTicket', label: '客单价', unit: 'money' },
  11. { key: 'exposure', label: '曝光人数', unit: 'int' },
  12. { key: 'conversion', label: '进店转化率', unit: 'rate' },
  13. { key: 'rating', label: '店铺评分', unit: 'num' },
  14. ];
  15. const state = {
  16. inited: false,
  17. loading: false,
  18. data: null,
  19. from: '',
  20. to: '',
  21. schools: null,
  22. shops: null,
  23. charts: {},
  24. insightPoints: [],
  25. pending: false,
  26. };
  27. function esc(value) {
  28. const div = document.createElement('div');
  29. div.textContent = String(value == null ? '' : value);
  30. return div.innerHTML;
  31. }
  32. function ymdToInput(ymd) {
  33. const s = String(ymd || '');
  34. return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : '';
  35. }
  36. function inputToYmd(value) {
  37. return String(value || '').replace(/-/g, '');
  38. }
  39. function shortDate(ymd) {
  40. const s = String(ymd || '');
  41. return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s;
  42. }
  43. function fmtValue(value, unit) {
  44. if (value === null || value === undefined) return '–';
  45. const n = Number(value);
  46. if (!Number.isFinite(n)) return '–';
  47. if (unit === 'rate') return (n * 100).toFixed(2) + '%';
  48. if (unit === 'num') return n.toFixed(2);
  49. if (unit === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  50. return n.toLocaleString('zh-CN');
  51. }
  52. function fmtDelta(delta, unit) {
  53. if (delta === null || delta === undefined) return '<span class="ckpt-delta muted">—</span>';
  54. const up = delta > 0;
  55. const cls = delta === 0 ? 'muted' : (up ? 'up' : 'down');
  56. const arrow = delta === 0 ? '' : (up ? '↑' : '↓');
  57. const text = unit === 'pp' ? Math.abs(delta).toFixed(1) + 'pp' : Math.abs(delta).toFixed(1) + '%';
  58. return `<span class="ckpt-delta ${cls}">${arrow} ${text}</span>`;
  59. }
  60. async function api(url, options) {
  61. const res = await fetch(url, options);
  62. const body = await res.json().catch(() => null);
  63. return { res, body };
  64. }
  65. // ---------- 骨架 ----------
  66. function renderShell() {
  67. const root = $('cockpitRoot');
  68. if (!root) return;
  69. root.innerHTML = `
  70. <div class="ops-bar ckpt-bar">
  71. <div class="ops-bar-group">
  72. <span class="ops-bar-label">日期</span>
  73. <input type="date" class="ops-input" id="ckptFrom">
  74. <span class="ops-tilde">~</span>
  75. <input type="date" class="ops-input" id="ckptTo">
  76. </div>
  77. <div class="ops-bar-group"><span class="ops-bar-label">学校</span><div class="ops-multi" id="ckptSchool"></div></div>
  78. <div class="ops-bar-group"><span class="ops-bar-label">店铺</span><div class="ops-multi" id="ckptShop"></div></div>
  79. </div>
  80. <section class="ckpt-ai" id="ckptAi">
  81. <div class="ckpt-ai-head">
  82. <div class="ckpt-ai-title"><span class="ckpt-ai-badge">AI</span><h3>经营解读</h3><span class="ckpt-ai-meta" id="ckptAiMeta"></span></div>
  83. <div class="ckpt-ai-actions">
  84. <button class="btn btn-ghost btn-small" id="ckptAiCopy" hidden>复制</button>
  85. <button class="btn btn-primary btn-small" id="ckptAiBtn">生成解读</button>
  86. </div>
  87. </div>
  88. <div class="ckpt-ai-body" id="ckptAiBody">点击「生成解读」,模型会根据当前筛选范围的数据给出结论与建议。</div>
  89. </section>
  90. <div class="ckpt-kpis" id="ckptKpis"></div>
  91. <div class="ckpt-grid">
  92. <section class="ckpt-card"><div class="ckpt-card-head"><h3>营业额 / 订单趋势</h3><span id="ckptTrendNote"></span></div><div class="ckpt-chart" id="ckptTrend"></div></section>
  93. <section class="ckpt-card"><div class="ckpt-card-head"><h3>门店营业额 Top 10</h3><span></span></div><div class="ckpt-chart" id="ckptRank"></div></section>
  94. <section class="ckpt-card"><div class="ckpt-card-head"><h3>转化漏斗</h3><span>曝光 → 进店 → 下单</span></div><div class="ckpt-chart" id="ckptFunnel"></div></section>
  95. <section class="ckpt-card"><div class="ckpt-card-head"><h3>收入结构</h3><span>收入 / 支出 / 补贴</span></div><div class="ckpt-chart" id="ckptStructure"></div></section>
  96. </div>
  97. <div class="ckpt-empty" id="ckptEmpty" hidden>
  98. <p>所选范围没有数据</p>
  99. <small>调整日期,或换一个学校 / 店铺</small>
  100. </div>`;
  101. $('ckptAiBtn').addEventListener('click', () => requestInsight(true));
  102. $('ckptAiCopy').addEventListener('click', () => {
  103. const text = state.insightPoints.join('\n');
  104. if (!text) return;
  105. navigator.clipboard.writeText(text).then(() => {
  106. const btn = $('ckptAiCopy');
  107. btn.textContent = '已复制';
  108. setTimeout(() => { btn.textContent = '复制'; }, 1500);
  109. }).catch(() => {});
  110. });
  111. $('ckptFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadData(); });
  112. $('ckptTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadData(); });
  113. state.charts.trend = echarts.init($('ckptTrend'));
  114. state.charts.rank = echarts.init($('ckptRank'));
  115. state.charts.funnel = echarts.init($('ckptFunnel'));
  116. state.charts.structure = echarts.init($('ckptStructure'));
  117. window.bindMultiSelectDismiss();
  118. window.addEventListener('resize', () => {
  119. Object.values(state.charts).forEach((chart) => chart && chart.resize());
  120. });
  121. }
  122. function mountFilters(summary) {
  123. const schools = (summary.schools || []).map((s) => ({ key: s.name, label: s.name, hint: `${s.shopCount} 家` }));
  124. window.createMultiSelect($('ckptSchool'), {
  125. label: '学校',
  126. items: schools,
  127. selected: state.schools ? [...state.schools] : [],
  128. allowEmpty: true,
  129. emptyLabel: '全部',
  130. onChange: (keys) => { state.schools = keys.length ? new Set(keys) : null; loadData(); },
  131. });
  132. const shops = (summary.shops || []).map((s) => ({
  133. key: String(s.key || s.shopId || ''),
  134. label: s.shopName || s.key,
  135. hint: s.school || '',
  136. })).filter((i) => i.key);
  137. window.createMultiSelect($('ckptShop'), {
  138. label: '店铺',
  139. items: shops,
  140. selected: state.shops ? [...state.shops] : [],
  141. allowEmpty: true,
  142. emptyLabel: '全部',
  143. onChange: (keys) => { state.shops = keys.length ? new Set(keys) : null; loadData(); },
  144. });
  145. }
  146. // ---------- 渲染 ----------
  147. function renderKpis(kpis) {
  148. const box = $('ckptKpis');
  149. if (!box) return;
  150. if (!kpis) {
  151. box.innerHTML = '';
  152. return;
  153. }
  154. box.innerHTML = KPI_DEFS.map((def) => {
  155. const item = kpis[def.key] || { value: null, delta: null, unit: def.unit };
  156. return `<div class="ckpt-kpi">
  157. <div class="ckpt-kpi-label">${def.label}</div>
  158. <div class="ckpt-kpi-value">${fmtValue(item.value, def.unit)}</div>
  159. <div class="ckpt-kpi-foot">${fmtDelta(item.delta, def.unit)}<span class="ckpt-kpi-hint">较上期</span></div>
  160. </div>`;
  161. }).join('');
  162. }
  163. function renderTrend(data) {
  164. const chart = state.charts.trend;
  165. const dates = data.trend.map((t) => shortDate(t.date));
  166. chart.setOption({
  167. grid: { left: 56, right: 52, top: 24, bottom: 32 },
  168. tooltip: { trigger: 'axis' },
  169. legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
  170. xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#e5e0da' } }, axisTick: { show: false }, axisLabel: { color: '#8f8880' } },
  171. yAxis: [
  172. { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } },
  173. { type: 'value', splitLine: { show: false }, axisLabel: { color: '#8f8880' } },
  174. ],
  175. series: [
  176. { name: '营业额', type: 'bar', barMaxWidth: 28, itemStyle: { color: COLORS.main, borderRadius: [4, 4, 0, 0] }, data: data.trend.map((t) => t.revenue) },
  177. { name: '订单', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 6, lineStyle: { width: 2, color: '#7a9cc6' }, itemStyle: { color: '#7a9cc6' }, data: data.trend.map((t) => t.orders) },
  178. ],
  179. }, true);
  180. }
  181. function renderRanking(data) {
  182. const rows = data.ranking.slice().reverse();
  183. state.charts.rank.setOption({
  184. grid: { left: 8, right: 48, top: 10, bottom: 10, containLabel: true },
  185. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  186. xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } },
  187. yAxis: {
  188. type: 'category',
  189. data: rows.map((r) => r.shopName),
  190. axisLine: { show: false },
  191. axisTick: { show: false },
  192. axisLabel: { color: '#5f5852', width: 150, overflow: 'truncate', fontSize: 11 },
  193. },
  194. series: [{
  195. type: 'bar',
  196. barMaxWidth: 16,
  197. itemStyle: { color: COLORS.soft, borderRadius: [0, 4, 4, 0] },
  198. label: {
  199. show: true,
  200. position: 'right',
  201. fontSize: 11,
  202. color: '#6f6862',
  203. formatter: (p) => Number(p.value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
  204. },
  205. data: rows.map((r) => r.revenue),
  206. }],
  207. }, true);
  208. }
  209. function renderFunnel(data) {
  210. const f = data.funnel;
  211. const stages = [
  212. { name: '曝光', value: f.exposure },
  213. { name: '进店', value: f.visit },
  214. { name: '下单', value: f.order },
  215. ];
  216. const rate = (a, b) => (b > 0 ? ((a / b) * 100).toFixed(1) + '%' : '—');
  217. state.charts.funnel.setOption({
  218. tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${Number(p.value).toLocaleString('zh-CN')}` },
  219. series: [{
  220. type: 'funnel',
  221. left: '8%',
  222. right: '8%',
  223. top: 16,
  224. bottom: 16,
  225. minSize: '28%',
  226. sort: 'descending',
  227. gap: 4,
  228. label: {
  229. show: true,
  230. position: 'inside',
  231. color: '#fff',
  232. fontSize: 12,
  233. formatter: (p) => `${p.name} ${Number(p.value).toLocaleString('zh-CN')}`,
  234. },
  235. itemStyle: { borderWidth: 0 },
  236. color: [COLORS.main, '#ff9a70', '#ffc4a8'],
  237. data: stages,
  238. }],
  239. graphic: [
  240. { type: 'text', right: 10, top: 8, style: { text: `进店 ${rate(f.visit, f.exposure)}`, fill: '#8f8880', fontSize: 11 } },
  241. { type: 'text', right: 10, bottom: 8, style: { text: `下单 ${rate(f.order, f.visit)}`, fill: '#8f8880', fontSize: 11 } },
  242. ],
  243. }, true);
  244. }
  245. function renderStructure(data) {
  246. const dates = data.structure.map((s) => shortDate(s.date));
  247. state.charts.structure.setOption({
  248. grid: { left: 56, right: 20, top: 28, bottom: 32 },
  249. tooltip: { trigger: 'axis' },
  250. legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
  251. xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#e5e0da' } }, axisTick: { show: false }, axisLabel: { color: '#8f8880' } },
  252. yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } },
  253. series: [
  254. { name: '收入', type: 'bar', barMaxWidth: 22, itemStyle: { color: COLORS.main, borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.income) },
  255. { name: '支出', type: 'bar', barMaxWidth: 22, itemStyle: { color: '#c9c2ba', borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.expense) },
  256. { name: '补贴', type: 'bar', barMaxWidth: 22, itemStyle: { color: '#ffd6c2', borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.subsidy) },
  257. ],
  258. }, true);
  259. }
  260. // ---------- AI 解读 ----------
  261. /** 报告链接:带上当前学校/店铺筛选,避免打开后变成全部门店 */
  262. function reportLink(from, to) {
  263. const params = new URLSearchParams({ view: 'report', from, to });
  264. if (state.schools && state.schools.size) params.set('schools', [...state.schools].join(','));
  265. if (state.shops && state.shops.size) params.set('shops', [...state.shops].join(','));
  266. return '/?' + params.toString();
  267. }
  268. async function requestInsight(refresh) {
  269. const body = $('ckptAiBody');
  270. const btn = $('ckptAiBtn');
  271. if (!body || !btn) return;
  272. btn.disabled = true;
  273. btn.textContent = '分析中…';
  274. body.innerHTML = '<span class="ckpt-ai-loading">AI 正在读取数据并生成结论…</span>';
  275. $('ckptAiMeta').textContent = '';
  276. $('ckptAiCopy').hidden = true;
  277. const payload = {
  278. from: state.from,
  279. to: state.to,
  280. schools: state.schools ? [...state.schools].join(',') : '',
  281. shops: state.shops ? [...state.shops].join(',') : '',
  282. refresh: !!refresh,
  283. };
  284. try {
  285. const { res, body: data } = await api('/api/ops/insights', {
  286. method: 'POST',
  287. headers: { 'Content-Type': 'application/json' },
  288. body: JSON.stringify(payload),
  289. });
  290. if (!res.ok) throw new Error((data && (data.detail || data.error)) || ('HTTP ' + res.status));
  291. if (data && data.enabled === false) {
  292. body.innerHTML = data.reason === 'chat-not-configured'
  293. ? '未配置对话模型,当前只能显示规则版摘要。请到「系统设置 → API 设置」配置后重新生成。'
  294. : '经营解读暂不可用。';
  295. return;
  296. }
  297. if (data && data.note) {
  298. body.textContent = data.note;
  299. return;
  300. }
  301. const points = (data && data.points) || [];
  302. if (!points.length) {
  303. body.textContent = '模型没有返回有效结论,请稍后重试。';
  304. return;
  305. }
  306. state.insightPoints = points;
  307. const rangeFrom = data.range && data.range.from ? String(data.range.from).replace(/-/g, '') : '';
  308. const rangeTo = data.range && data.range.to ? String(data.range.to).replace(/-/g, '') : '';
  309. const foot = [
  310. data.aiError ? `<span class="ckpt-ai-note">${esc(data.aiError)}</span>` : '',
  311. rangeFrom && rangeTo ? `<a class="ckpt-ai-link" href="${reportLink(rangeFrom, rangeTo)}">查看完整报告 →</a>` : '',
  312. ].filter(Boolean).join('');
  313. body.innerHTML = `<ul class="ckpt-ai-list">${points.map((point) => `<li>${esc(point)}</li>`).join('')}</ul>${foot ? `<div class="ckpt-ai-foot">${foot}</div>` : ''}`;
  314. const time = data.generatedAt ? new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '';
  315. $('ckptAiMeta').textContent = [data.source === 'llm' ? 'AI 生成' : '规则生成', data.model, time, data.cached ? '缓存' : ''].filter(Boolean).join(' · ');
  316. $('ckptAiCopy').hidden = false;
  317. } catch (e) {
  318. body.innerHTML = `<span class="ckpt-ai-error">生成失败:${esc(e.message || e)}</span>`;
  319. } finally {
  320. btn.disabled = false;
  321. btn.textContent = refresh ? '重新生成' : '生成解读';
  322. }
  323. }
  324. // ---------- 数据 ----------
  325. async function loadData() {
  326. if (state.loading) { state.pending = true; return; }
  327. state.loading = true;
  328. const params = new URLSearchParams();
  329. if (state.from) params.set('from', state.from);
  330. if (state.to) params.set('to', state.to);
  331. if (state.schools && state.schools.size) params.set('schools', [...state.schools].join(','));
  332. if (state.shops && state.shops.size) params.set('shops', [...state.shops].join(','));
  333. try {
  334. const { res, body } = await api('/api/ops/dashboard?' + params.toString());
  335. if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
  336. state.data = body;
  337. const empty = !body.kpis || !body.trend.length;
  338. $('ckptEmpty').hidden = !empty;
  339. $('ckptKpis').hidden = empty;
  340. document.querySelector('.ckpt-grid').hidden = empty;
  341. if (!empty) {
  342. state.from = body.from || state.from;
  343. state.to = body.to || state.to;
  344. $('ckptFrom').value = ymdToInput(state.from);
  345. $('ckptTo').value = ymdToInput(state.to);
  346. renderKpis(body.kpis);
  347. renderTrend(body);
  348. renderRanking(body);
  349. renderFunnel(body);
  350. renderStructure(body);
  351. $('ckptTrendNote').textContent = body.prev ? `对比 ${shortDate(body.prev.from)} ~ ${shortDate(body.prev.to)}` : '暂无上期可比';
  352. if (state.insightPoints.length) {
  353. state.insightPoints = [];
  354. $('ckptAiBody').innerHTML = '筛选范围已变化,点击「生成解读」重新分析。';
  355. $('ckptAiMeta').textContent = '';
  356. $('ckptAiCopy').hidden = true;
  357. }
  358. Object.values(state.charts).forEach((chart) => chart && chart.resize());
  359. }
  360. } catch (e) {
  361. $('ckptEmpty').hidden = false;
  362. $('ckptEmpty').querySelector('p').textContent = '驾驶舱加载失败';
  363. $('ckptEmpty').querySelector('small').textContent = e.message || String(e);
  364. } finally {
  365. state.loading = false;
  366. if (state.pending) {
  367. state.pending = false;
  368. loadData();
  369. }
  370. }
  371. }
  372. /** 数据变更后刷新:重拉筛选选项与聚合数据,不重建图表 */
  373. async function refresh() {
  374. const { body } = await api('/api/ops/summary');
  375. if (!body || !body.enabled) return;
  376. // 清理已不存在于新数据的筛选值
  377. const schoolNames = new Set((body.schools || []).map((s) => s.name));
  378. if (state.schools) {
  379. const next = new Set([...state.schools].filter((name) => schoolNames.has(name)));
  380. state.schools = next.size ? next : null;
  381. }
  382. const shopKeys = new Set((body.shops || []).map((s) => String(s.key || s.shopId)));
  383. if (state.shops) {
  384. const next = new Set([...state.shops].filter((key) => shopKeys.has(key)));
  385. state.shops = next.size ? next : null;
  386. }
  387. mountFilters(body);
  388. await loadData();
  389. }
  390. async function init() {
  391. if (state.inited) return;
  392. state.inited = true;
  393. renderShell();
  394. const { body } = await api('/api/ops/summary');
  395. const summary = body || { enabled: false, schools: [], shops: [] };
  396. if (summary.enabled) {
  397. if (summary.coverage) {
  398. state.from = summary.coverage.from || '';
  399. state.to = summary.coverage.to || '';
  400. $('ckptFrom').value = ymdToInput(state.from);
  401. $('ckptTo').value = ymdToInput(state.to);
  402. }
  403. mountFilters(summary);
  404. await loadData();
  405. } else {
  406. $('ckptEmpty').hidden = false;
  407. $('ckptEmpty').querySelector('p').textContent = '数据暂不可用';
  408. $('ckptEmpty').querySelector('small').textContent = summary.reason || '存储未连接';
  409. }
  410. }
  411. window.addEventListener('ops:data-changed', () => {
  412. if (state.inited) refresh();
  413. });
  414. window.shanCockpitInit = function () {
  415. init();
  416. const refresh = $('btnCockpitRefresh');
  417. if (refresh && !refresh.dataset.bound) {
  418. refresh.dataset.bound = '1';
  419. refresh.addEventListener('click', () => loadData());
  420. }
  421. };
  422. })();