'use strict'; // 经营驾驶舱:KPI + 趋势 / 门店排行 / 转化漏斗 / 收入结构 // 数据来自 GET /api/ops/dashboard(服务端已按统一口径聚合) (function () { const $ = (id) => document.getElementById(id); const COLORS = { main: '#ff6b35', soft: '#ffb08a', grey: '#b8b2ad', green: '#2e7d4f', red: '#c0392b' }; const KPI_DEFS = [ { key: 'revenue', label: '营业额', unit: 'money' }, { key: 'orders', label: '有效订单', unit: 'int' }, { key: 'avgTicket', label: '客单价', unit: 'money' }, { key: 'exposure', label: '曝光人数', unit: 'int' }, { key: 'conversion', label: '进店转化率', unit: 'rate' }, { key: 'rating', label: '店铺评分', unit: 'num' }, ]; const state = { inited: false, loading: false, data: null, from: '', to: '', schools: null, shops: null, charts: {}, insightPoints: [], }; function esc(value) { const div = document.createElement('div'); div.textContent = String(value == null ? '' : value); return div.innerHTML; } function ymdToInput(ymd) { const s = String(ymd || ''); return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : ''; } function inputToYmd(value) { return String(value || '').replace(/-/g, ''); } function shortDate(ymd) { const s = String(ymd || ''); return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s; } function fmtValue(value, unit) { if (value === null || value === undefined) return '–'; const n = Number(value); if (!Number.isFinite(n)) return '–'; if (unit === 'rate') return (n * 100).toFixed(2) + '%'; if (unit === 'num') return n.toFixed(2); if (unit === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN'); } function fmtDelta(delta, unit) { if (delta === null || delta === undefined) return '—'; const up = delta > 0; const cls = delta === 0 ? 'muted' : (up ? 'up' : 'down'); const arrow = delta === 0 ? '' : (up ? '↑' : '↓'); const text = unit === 'pp' ? Math.abs(delta).toFixed(1) + 'pp' : Math.abs(delta).toFixed(1) + '%'; return `${arrow} ${text}`; } async function api(url, options) { const res = await fetch(url, options); const body = await res.json().catch(() => null); return { res, body }; } // ---------- 骨架 ---------- function renderShell() { const root = $('cockpitRoot'); if (!root) return; root.innerHTML = `
日期 ~
学校
店铺
AI

经营解读

点击「生成解读」,模型会根据当前筛选范围的数据给出结论与建议。

营业额 / 订单趋势

门店营业额 Top 10

转化漏斗

曝光 → 进店 → 下单

收入结构

收入 / 支出 / 补贴
`; $('ckptAiBtn').addEventListener('click', () => requestInsight(true)); $('ckptAiCopy').addEventListener('click', () => { const text = state.insightPoints.join('\n'); if (!text) return; navigator.clipboard.writeText(text).then(() => { const btn = $('ckptAiCopy'); btn.textContent = '已复制'; setTimeout(() => { btn.textContent = '复制'; }, 1500); }).catch(() => {}); }); $('ckptFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadData(); }); $('ckptTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadData(); }); state.charts.trend = echarts.init($('ckptTrend')); state.charts.rank = echarts.init($('ckptRank')); state.charts.funnel = echarts.init($('ckptFunnel')); state.charts.structure = echarts.init($('ckptStructure')); window.bindMultiSelectDismiss(); window.addEventListener('resize', () => { Object.values(state.charts).forEach((chart) => chart && chart.resize()); }); } function mountFilters(summary) { const schools = (summary.schools || []).map((s) => ({ key: s.name, label: s.name, hint: `${s.shopCount} 家` })); window.createMultiSelect($('ckptSchool'), { label: '学校', items: schools, selected: state.schools ? [...state.schools] : [], allowEmpty: true, emptyLabel: '全部', onChange: (keys) => { state.schools = keys.length ? new Set(keys) : null; loadData(); }, }); const shops = (summary.shops || []).map((s) => ({ key: String(s.key || s.shopId || ''), label: s.shopName || s.key, hint: s.school || '', })).filter((i) => i.key); window.createMultiSelect($('ckptShop'), { label: '店铺', items: shops, selected: state.shops ? [...state.shops] : [], allowEmpty: true, emptyLabel: '全部', onChange: (keys) => { state.shops = keys.length ? new Set(keys) : null; loadData(); }, }); } // ---------- 渲染 ---------- function renderKpis(kpis) { const box = $('ckptKpis'); if (!box) return; if (!kpis) { box.innerHTML = ''; return; } box.innerHTML = KPI_DEFS.map((def) => { const item = kpis[def.key] || { value: null, delta: null, unit: def.unit }; return `
${def.label}
${fmtValue(item.value, def.unit)}
${fmtDelta(item.delta, def.unit)}较上期
`; }).join(''); } function renderZeroOrder(data) { const box = $('ckptZero'); if (!box) return; const items = data.zeroOrder || []; if (!items.length) { box.hidden = true; box.innerHTML = ''; return; } const chips = items.map((item) => { const tone = item.reason === 'no-conversion' ? 'warn' : item.reason === 'no-visit' || item.reason === 'no-traffic' ? 'info' : 'muted'; return ` ${shortDate(item.date)} ${esc(item.shopName)}${esc(item.reasonText)} `; }).join(''); box.hidden = false; box.innerHTML = `零单诊断 ${items.length} 店次${chips}`; } function renderTrend(data) { const chart = state.charts.trend; const dates = data.trend.map((t) => shortDate(t.date)); chart.setOption({ grid: { left: 56, right: 52, top: 24, bottom: 32 }, tooltip: { trigger: 'axis' }, legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } }, xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#e5e0da' } }, axisTick: { show: false }, axisLabel: { color: '#8f8880' } }, yAxis: [ { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } }, { type: 'value', splitLine: { show: false }, axisLabel: { color: '#8f8880' } }, ], series: [ { name: '营业额', type: 'bar', barMaxWidth: 28, itemStyle: { color: COLORS.main, borderRadius: [4, 4, 0, 0] }, data: data.trend.map((t) => t.revenue) }, { name: '订单', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 6, lineStyle: { width: 2, color: '#7a9cc6' }, itemStyle: { color: '#7a9cc6' }, data: data.trend.map((t) => t.orders) }, ], }, true); } function renderRanking(data) { const rows = data.ranking.slice().reverse(); state.charts.rank.setOption({ grid: { left: 8, right: 48, top: 10, bottom: 10, containLabel: true }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } }, yAxis: { type: 'category', data: rows.map((r) => r.shopName), axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: '#5f5852', width: 150, overflow: 'truncate', fontSize: 11 }, }, series: [{ type: 'bar', barMaxWidth: 16, itemStyle: { color: COLORS.soft, borderRadius: [0, 4, 4, 0] }, label: { show: true, position: 'right', fontSize: 11, color: '#6f6862', formatter: (p) => Number(p.value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }), }, data: rows.map((r) => r.revenue), }], }, true); } function renderFunnel(data) { const f = data.funnel; const stages = [ { name: '曝光', value: f.exposure }, { name: '进店', value: f.visit }, { name: '下单', value: f.order }, ]; const rate = (a, b) => (b > 0 ? ((a / b) * 100).toFixed(1) + '%' : '—'); state.charts.funnel.setOption({ tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${Number(p.value).toLocaleString('zh-CN')}` }, series: [{ type: 'funnel', left: '8%', right: '8%', top: 16, bottom: 16, minSize: '28%', sort: 'descending', gap: 4, label: { show: true, position: 'inside', color: '#fff', fontSize: 12, formatter: (p) => `${p.name} ${Number(p.value).toLocaleString('zh-CN')}`, }, itemStyle: { borderWidth: 0 }, color: [COLORS.main, '#ff9a70', '#ffc4a8'], data: stages, }], graphic: [ { type: 'text', right: 10, top: 8, style: { text: `进店 ${rate(f.visit, f.exposure)}`, fill: '#8f8880', fontSize: 11 } }, { type: 'text', right: 10, bottom: 8, style: { text: `下单 ${rate(f.order, f.visit)}`, fill: '#8f8880', fontSize: 11 } }, ], }, true); } function renderStructure(data) { const dates = data.structure.map((s) => shortDate(s.date)); state.charts.structure.setOption({ grid: { left: 56, right: 20, top: 28, bottom: 32 }, tooltip: { trigger: 'axis' }, legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } }, xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#e5e0da' } }, axisTick: { show: false }, axisLabel: { color: '#8f8880' } }, yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } }, series: [ { name: '收入', type: 'bar', barMaxWidth: 22, itemStyle: { color: COLORS.main, borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.income) }, { name: '支出', type: 'bar', barMaxWidth: 22, itemStyle: { color: '#c9c2ba', borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.expense) }, { name: '补贴', type: 'bar', barMaxWidth: 22, itemStyle: { color: '#ffd6c2', borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.subsidy) }, ], }, true); } // ---------- AI 解读 ---------- async function requestInsight(refresh) { const body = $('ckptAiBody'); const btn = $('ckptAiBtn'); if (!body || !btn) return; btn.disabled = true; btn.textContent = '分析中…'; body.innerHTML = 'AI 正在读取数据并生成结论…'; $('ckptAiMeta').textContent = ''; $('ckptAiCopy').hidden = true; const payload = { from: state.from, to: state.to, schools: state.schools ? [...state.schools].join(',') : '', shops: state.shops ? [...state.shops].join(',') : '', refresh: !!refresh, }; try { const { res, body: data } = await api('/api/ops/insights', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error((data && (data.detail || data.error)) || ('HTTP ' + res.status)); if (data && data.enabled === false) { body.innerHTML = '未配置对话模型。请到「系统设置 → API 设置」选择对话模型并填写 Key,然后回到这里重新生成。'; return; } if (data && data.note) { body.textContent = data.note; return; } const points = (data && data.points) || []; if (!points.length) { body.textContent = '模型没有返回有效结论,请稍后重试。'; return; } state.insightPoints = points; body.innerHTML = ``; const time = data.generatedAt ? new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : ''; $('ckptAiMeta').textContent = [data.model, time, data.cached ? '缓存' : ''].filter(Boolean).join(' · '); $('ckptAiCopy').hidden = false; } catch (e) { body.innerHTML = `生成失败:${esc(e.message || e)}`; } finally { btn.disabled = false; btn.textContent = refresh ? '重新生成' : '生成解读'; } } // ---------- 数据 ---------- async function loadData() { if (state.loading) return; state.loading = true; const params = new URLSearchParams(); if (state.from) params.set('from', state.from); if (state.to) params.set('to', state.to); if (state.schools && state.schools.size) params.set('schools', [...state.schools].join(',')); if (state.shops && state.shops.size) params.set('shops', [...state.shops].join(',')); try { const { res, body } = await api('/api/ops/dashboard?' + params.toString()); if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status)); state.data = body; const empty = !body.kpis || !body.trend.length; $('ckptEmpty').hidden = !empty; $('ckptKpis').hidden = empty; document.querySelector('.ckpt-grid').hidden = empty; if (!empty) { state.from = body.from || state.from; state.to = body.to || state.to; $('ckptFrom').value = ymdToInput(state.from); $('ckptTo').value = ymdToInput(state.to); renderKpis(body.kpis); renderZeroOrder(body); renderTrend(body); renderRanking(body); renderFunnel(body); renderStructure(body); $('ckptTrendNote').textContent = body.prev ? `对比 ${shortDate(body.prev.from)} ~ ${shortDate(body.prev.to)}` : '暂无上期可比'; if (state.insightPoints.length) { state.insightPoints = []; $('ckptAiBody').innerHTML = '筛选范围已变化,点击「生成解读」重新分析。'; $('ckptAiMeta').textContent = ''; $('ckptAiCopy').hidden = true; } Object.values(state.charts).forEach((chart) => chart && chart.resize()); } } catch (e) { $('ckptEmpty').hidden = false; $('ckptEmpty').querySelector('p').textContent = '驾驶舱加载失败'; $('ckptEmpty').querySelector('small').textContent = e.message || String(e); } finally { state.loading = false; } } async function init() { if (state.inited) return; state.inited = true; renderShell(); const { body } = await api('/api/ops/summary'); const summary = body || { enabled: false, schools: [], shops: [] }; if (summary.enabled) { if (summary.coverage) { state.from = summary.coverage.from || ''; state.to = summary.coverage.to || ''; $('ckptFrom').value = ymdToInput(state.from); $('ckptTo').value = ymdToInput(state.to); } mountFilters(summary); await loadData(); } else { $('ckptEmpty').hidden = false; $('ckptEmpty').querySelector('p').textContent = '数据暂不可用'; $('ckptEmpty').querySelector('small').textContent = summary.reason || '存储未连接'; } } window.shanCockpitInit = function () { init(); const refresh = $('btnCockpitRefresh'); if (refresh && !refresh.dataset.bound) { refresh.dataset.bound = '1'; refresh.addEventListener('click', () => loadData()); } }; })();