|
@@ -0,0 +1,332 @@
|
|
|
|
|
+'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: {},
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ 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 '<span class="ckpt-delta muted">—</span>';
|
|
|
|
|
+ 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 `<span class="ckpt-delta ${cls}">${arrow} ${text}</span>`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function api(url) {
|
|
|
|
|
+ const res = await fetch(url);
|
|
|
|
|
+ const body = await res.json().catch(() => null);
|
|
|
|
|
+ return { res, body };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 骨架 ----------
|
|
|
|
|
+
|
|
|
|
|
+ function renderShell() {
|
|
|
|
|
+ const root = $('cockpitRoot');
|
|
|
|
|
+ if (!root) return;
|
|
|
|
|
+ root.innerHTML = `
|
|
|
|
|
+ <div class="ops-bar ckpt-bar">
|
|
|
|
|
+ <div class="ops-bar-group">
|
|
|
|
|
+ <span class="ops-bar-label">日期</span>
|
|
|
|
|
+ <input type="date" class="ops-input" id="ckptFrom">
|
|
|
|
|
+ <span class="ops-tilde">~</span>
|
|
|
|
|
+ <input type="date" class="ops-input" id="ckptTo">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="ops-bar-group"><span class="ops-bar-label">学校</span><div class="ops-multi" id="ckptSchool"></div></div>
|
|
|
|
|
+ <div class="ops-bar-group"><span class="ops-bar-label">店铺</span><div class="ops-multi" id="ckptShop"></div></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="ckpt-kpis" id="ckptKpis"></div>
|
|
|
|
|
+ <div class="ckpt-grid">
|
|
|
|
|
+ <section class="ckpt-card"><div class="ckpt-card-head"><h3>营业额 / 订单趋势</h3><span id="ckptTrendNote"></span></div><div class="ckpt-chart" id="ckptTrend"></div></section>
|
|
|
|
|
+ <section class="ckpt-card"><div class="ckpt-card-head"><h3>门店营业额 Top 10</h3><span></span></div><div class="ckpt-chart" id="ckptRank"></div></section>
|
|
|
|
|
+ <section class="ckpt-card"><div class="ckpt-card-head"><h3>转化漏斗</h3><span>曝光 → 进店 → 下单</span></div><div class="ckpt-chart" id="ckptFunnel"></div></section>
|
|
|
|
|
+ <section class="ckpt-card"><div class="ckpt-card-head"><h3>收入结构</h3><span>收入 / 支出 / 补贴</span></div><div class="ckpt-chart" id="ckptStructure"></div></section>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="ckpt-empty" id="ckptEmpty" hidden>
|
|
|
|
|
+ <p>所选范围没有数据</p>
|
|
|
|
|
+ <small>调整日期,或换一个学校 / 店铺</small>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+
|
|
|
|
|
+ $('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 `<div class="ckpt-kpi">
|
|
|
|
|
+ <div class="ckpt-kpi-label">${def.label}</div>
|
|
|
|
|
+ <div class="ckpt-kpi-value">${fmtValue(item.value, def.unit)}</div>
|
|
|
|
|
+ <div class="ckpt-kpi-foot">${fmtDelta(item.delta, def.unit)}<span class="ckpt-kpi-hint">较上期</span></div>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ }).join('');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 数据 ----------
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+ renderTrend(body);
|
|
|
|
|
+ renderRanking(body);
|
|
|
|
|
+ renderFunnel(body);
|
|
|
|
|
+ renderStructure(body);
|
|
|
|
|
+ $('ckptTrendNote').textContent = body.prev ? `对比 ${shortDate(body.prev.from)} ~ ${shortDate(body.prev.to)}` : '暂无上期可比';
|
|
|
|
|
+ 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());
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+})();
|