'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 = `
点击「生成解读」,模型会根据当前筛选范围的数据给出结论与建议。
所选范围没有数据
调整日期,或换一个学校 / 店铺
`;
$('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)}
`;
}).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);
}
// ---------- 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 = `${points.map((point) => `- ${esc(point)}
`).join('')}
`;
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);
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 refresh() {
const { body } = await api('/api/ops/summary');
if (!body || !body.enabled) return;
// 清理已不存在于新数据的筛选值
const schoolNames = new Set((body.schools || []).map((s) => s.name));
if (state.schools) {
const next = new Set([...state.schools].filter((name) => schoolNames.has(name)));
state.schools = next.size ? next : null;
}
const shopKeys = new Set((body.shops || []).map((s) => String(s.key || s.shopId)));
if (state.shops) {
const next = new Set([...state.shops].filter((key) => shopKeys.has(key)));
state.shops = next.size ? next : null;
}
mountFilters(body);
await loadData();
}
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.addEventListener('ops:data-changed', () => {
if (state.inited) refresh();
});
window.shanCockpitInit = function () {
init();
const refresh = $('btnCockpitRefresh');
if (refresh && !refresh.dataset.bound) {
refresh.dataset.bound = '1';
refresh.addEventListener('click', () => loadData());
}
};
})();