'use strict';
// 经营报告:同一份 Report JSON 渲染「图文报告 / 交互报告」,PDF 走浏览器打印样式。
// 数据来自 GET /api/ops/report(后端算指标、规则打标,模型只负责文字)。
(function () {
const $ = (id) => document.getElementById(id);
const COLORS = { main: '#ff6b35', soft: '#ffb08a', grey: '#c9c2ba', blue: '#7a9cc6', light: '#ffd6c2' };
const state = {
inited: false,
loading: false,
report: null,
mode: 'text', // text | interactive
from: '',
to: '',
schools: [],
shops: [],
charts: {},
};
function esc(value) {
const div = document.createElement('div');
div.textContent = String(value == null ? '' : value);
return div.innerHTML;
}
function moneyText(v) {
if (v == null) return '–';
const n = Number(v);
return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '–';
}
function kpiText(kpi) {
if (kpi.current == null) return '–';
if (kpi.unit === '%') return kpi.current + '%';
if (kpi.unit === '单' || kpi.unit === '人') return numText(kpi.current);
return moneyText(kpi.current);
}
function numText(v) {
if (v == null) return '–';
const n = Number(v);
return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '–';
}
function deltaText(kpi) {
if (kpi.change_pct == null) return '—';
const up = kpi.change_pct > 0;
const cls = kpi.change_pct === 0 ? 'muted' : up ? 'up' : 'down';
const unit = kpi.unit === '%' ? 'pp' : '%';
return `${up ? '↑' : '↓'} ${Math.abs(kpi.change_pct).toFixed(1)}${unit}`;
}
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 = $('reportRoot');
if (!root) return;
root.innerHTML = `
`;
root.querySelectorAll('#rpTabs [data-mode]').forEach((btn) => {
btn.addEventListener('click', () => {
state.mode = btn.dataset.mode;
root.querySelectorAll('#rpTabs [data-mode]').forEach((b) => b.classList.toggle('on', b === btn));
$('rpPage').classList.toggle('interactive', state.mode === 'interactive');
if (state.report) drawCharts(state.report);
});
});
$('rpFrom').addEventListener('change', (e) => { state.from = String(e.target.value || '').replace(/-/g, ''); load(true); });
$('rpTo').addEventListener('change', (e) => { state.to = String(e.target.value || '').replace(/-/g, ''); load(true); });
$('rpRefresh').addEventListener('click', () => load(true));
$('rpPrint').addEventListener('click', () => window.print());
$('rpSave').addEventListener('click', saveReport);
$('rpArchive').addEventListener('click', openHistory);
$('rpHistoryClose').addEventListener('click', () => { $('rpHistoryModal').hidden = true; });
}
function renderCover(report) {
const meta = report.meta || {};
return `
AI 经营分析报告
${esc(meta.title || '经营分析报告')}
${esc(meta.organization || '')}${meta.organization ? ' · ' : ''}${esc(meta.period_start)} ~ ${esc(meta.period_end)}
`;
}
function renderSummary(report) {
const summary = report.executive_summary || {};
const dot = { positive: 'ok', neutral: 'na', warning: 'warn', critical: 'bad' };
const items = (summary.key_findings || []).map((f) => `
${esc(f.finding)}
${esc(f.interpretation || '')}
${(f.evidence || []).length ? `
${f.evidence.map((e) => `${esc(e)}`).join('')}
` : ''}
`).join('');
return `
管理层摘要
${esc(summary.overall_assessment || '')}
`;
}
function renderKpis(report) {
return `
核心指标
${(report.kpis || []).map((kpi) => `
${esc(kpi.label)}${esc(kpi.unit)}
${kpiText(kpi)}
`).join('')}
口径:${(report.kpis || []).map((k) => `${k.label}=${k.definition}`).join(';')}
`;
}
function chartCard(id, title, subtitle) {
return `
${esc(title)}${esc(subtitle || '')}
`;
}
function renderSections(report) {
const chartById = new Map((report.charts || []).map((c) => [c.id, c]));
const tableById = new Map((report.tables || []).map((t) => [t.id, t]));
return (report.sections || []).map((section, index) => {
if (section.id === 'quality') return '';
const charts = (section.chart_ids || []).map((id) => chartById.get(id)).filter(Boolean);
const tables = (section.table_ids || []).map((id) => tableById.get(id)).filter(Boolean);
return `
${String(index + 1).padStart(2, '0')}${esc(section.title)}
${section.summary ? `${esc(section.summary)}
` : ''}
${(section.paragraphs || []).map((p) => `${esc(p)}
`).join('')}
${charts.map((chart) => chartCard(`rpChart_${chart.id}`, chart.title, chart.description)).join('')}
${tables.map((table) => renderTable(table)).join('')}
`;
}).join('');
}
function renderTable(table) {
return `
${table.columns.map((c) => `| ${esc(c.label)} | `).join('')}
${(table.rows || []).map((row) => `${table.columns.map((c) => {
const v = row[c.key];
const text = c.key === 'revenue' ? moneyText(v) : c.key === 'share' ? (v == null ? '–' : v + '%') : (v == null ? '–' : esc(v));
return `| ${text} | `;
}).join('')}
`).join('')}
${table.footnote ? `
${esc(table.footnote)}
` : ''}
`;
}
function renderAlerts(report) {
const alerts = report.alerts || [];
const plan = report.action_plan || [];
return `
05风险与建议
${alerts.map((a) => `
${esc(a.severity)}${esc(a.title)}
${(a.evidence || []).map((e) => `- ${esc(e)}
`).join('')}
${(a.hypotheses || []).length ? `
待验证:${a.hypotheses.map(esc).join('、')}
` : ''}
`).join('') || '
本期未触发风险规则。
'}
行动计划
| 优先级 | 行动 | 负责人 | 时间窗 | 验收指标 |
${plan.map((p) => `
| ${esc(p.priority)} |
${esc(p.action)} |
${esc(p.owner_role)} |
${esc(p.time_horizon)} |
${esc(p.success_metric)} |
`).join('')}
`;
}
function renderQuality(report) {
const q = report.data_quality || {};
return `
06数据质量与方法论
覆盖范围:${esc(q.coverage || '–')}
可比性:${esc(q.comparability || '–')}
${(q.warnings || []).length ? `提示:${q.warnings.map(esc).join(';')}
` : ''}
${(q.missing_metrics || []).length ? `缺失指标:${q.missing_metrics.map(esc).join(';')}
` : ''}
方法论:
${(report.methodology || []).map((m) => `- ${esc(m)}
`).join('')}
`;
}
function renderReport(report) {
const page = $('rpPage');
const saveBtn = $('rpSave');
if (saveBtn) saveBtn.disabled = false;
page.innerHTML = [
renderCover(report),
renderSummary(report),
renderKpis(report),
renderSections(report),
renderAlerts(report),
renderQuality(report),
``,
].join('');
drawCharts(report);
}
// ---------- 图表 ----------
function chartBase(interactive) {
return {
animation: !interactive,
silent: !interactive,
tooltip: interactive ? { trigger: 'axis' } : undefined,
};
}
function drawCharts(report) {
const interactive = state.mode === 'interactive';
const byId = new Map((report.charts || []).map((c) => [c.id, c]));
Object.values(state.charts).forEach((chart) => chart && chart.dispose());
state.charts = {};
const mount = (chart) => {
const el = $(`rpChart_${chart.id}`);
if (!el) return null;
const instance = echarts.init(el);
state.charts[chart.id] = instance;
return instance;
};
const trend = byId.get('chart_trend');
if (trend) {
const chart = mount(trend);
chart && chart.setOption({
...chartBase(interactive),
grid: { left: 56, right: 48, top: 24, bottom: 30 },
legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
xAxis: { type: 'category', data: trend.data.map((d) => d.date.slice(4)), axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e0da' } } },
yAxis: [{ type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } }, { type: 'value', splitLine: { show: false } }],
series: [
{ name: '营业额', type: 'bar', barMaxWidth: 22, itemStyle: { color: COLORS.main, borderRadius: [3, 3, 0, 0] }, data: trend.data.map((d) => d.revenue) },
{ name: '订单', type: 'line', yAxisIndex: 1, symbolSize: 5, lineStyle: { width: 2, color: COLORS.blue }, itemStyle: { color: COLORS.blue }, data: trend.data.map((d) => d.orders) },
],
});
}
const rank = byId.get('chart_rank');
if (rank) {
const rows = rank.data.slice().reverse();
const chart = mount(rank);
chart && chart.setOption({
...chartBase(interactive),
grid: { left: 8, right: 60, top: 10, bottom: 10, containLabel: true },
xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } },
yAxis: { type: 'category', data: rows.map((r) => r.shopName), axisTick: { show: false }, axisLine: { show: false }, axisLabel: { width: 140, overflow: 'truncate', fontSize: 11 } },
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) }],
});
}
const funnel = byId.get('chart_funnel');
if (funnel) {
const f = funnel.data;
const chart = mount(funnel);
chart && chart.setOption({
...chartBase(interactive),
series: [{
type: 'funnel', left: '10%', right: '10%', top: 12, bottom: 12, minSize: '30%', gap: 3,
label: { show: true, position: 'inside', color: '#fff', fontSize: 11, formatter: (p) => `${p.name} ${Number(p.value).toLocaleString('zh-CN')}` },
itemStyle: { borderWidth: 0 },
color: [COLORS.main, '#ff9a70', COLORS.light],
data: [{ name: '曝光', value: f.exposure }, { name: '进店', value: f.visit }, { name: '下单', value: f.order }],
}],
});
}
const structure = byId.get('chart_structure');
if (structure) {
const chart = mount(structure);
chart && chart.setOption({
...chartBase(interactive),
grid: { left: 56, right: 20, top: 28, bottom: 30 },
legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
xAxis: { type: 'category', data: structure.data.map((d) => d.date.slice(4)), axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e0da' } } },
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } },
series: [
{ name: '收入', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.main }, data: structure.data.map((d) => d.income) },
{ name: '支出', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.grey }, data: structure.data.map((d) => d.expense) },
{ name: '补贴', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.light }, data: structure.data.map((d) => d.subsidy) },
],
});
}
}
// ---------- 报告中心 ----------
async function saveReport() {
const btn = $('rpSave');
btn.disabled = true;
btn.textContent = '保存中…';
try {
const { res, body } = await api('/api/ops/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from: state.from, to: state.to,
schools: state.schools.join(','), shops: state.shops.join(','),
report: state.report,
}),
});
if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
btn.textContent = '已保存';
setTimeout(() => { btn.textContent = '保存报告'; btn.disabled = false; }, 1500);
} catch (e) {
btn.textContent = '保存报告';
btn.disabled = false;
alert('保存失败:' + (e.message || e));
}
}
async function openHistory() {
$('rpHistoryModal').hidden = false;
await renderHistory();
}
async function renderHistory() {
const list = $('rpHistoryList');
list.innerHTML = '加载中…
';
const { res, body } = await api('/api/ops/reports');
if (!res.ok) {
list.innerHTML = `读取失败:${esc((body && (body.error || body.detail)) || res.status)}
`;
return;
}
const reports = (body && body.reports) || [];
if (!reports.length) {
list.innerHTML = '还没有保存过报告
';
return;
}
list.innerHTML = `
| 保存时间 | 区间 | 学校 | 店铺 | 来源 | 操作 |
${reports.map((r) => `
| ${esc(new Date(r.createdAt).toLocaleString('zh-CN'))} |
${esc(r.from)} ~ ${esc(r.to)} |
${(r.schoolNames || []).length ? esc(r.schoolNames.join('、')) : '全部'} |
${(r.shopNames || []).length
? esc(r.shopNames.slice(0, 3).join('、')) + (r.shopNames.length > 3 ? ` 等 ${r.shopNames.length} 家` : '')
: '全部'} |
${r.source === 'llm' ? 'AI 生成' : '规则生成'} |
|
`).join('')}
`;
list.querySelectorAll('[data-view]').forEach((btn) => {
btn.addEventListener('click', () => loadSaved(btn.dataset.view));
});
list.querySelectorAll('[data-pdf]').forEach((btn) => {
btn.addEventListener('click', () => downloadSavedPdf(btn.dataset.pdf));
});
list.querySelectorAll('[data-json]').forEach((btn) => {
btn.addEventListener('click', () => downloadSavedJson(btn.dataset.json));
});
list.querySelectorAll('[data-del]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm('删除这份历史报告?')) return;
btn.disabled = true;
await api('/api/ops/reports/' + encodeURIComponent(btn.dataset.del), { method: 'DELETE' });
renderHistory();
});
});
}
async function fetchSaved(id) {
const { res, body } = await api('/api/ops/reports/' + encodeURIComponent(id));
if (!res.ok || !body || !body.report) {
alert('读取失败');
return null;
}
return body.report;
}
async function downloadSavedPdf(id) {
const report = await fetchSaved(id);
if (!report) return;
state.report = report;
$('rpHistoryModal').hidden = true;
renderReport(report);
const meta = report.meta || {};
const prevTitle = document.title;
document.title = `经营分析报告_${String(meta.period_start || '').replace(/-/g, '')}-${String(meta.period_end || '').replace(/-/g, '')}`;
setTimeout(() => {
window.print();
document.title = prevTitle;
}, 500);
}
async function downloadSavedJson(id) {
const report = await fetchSaved(id);
if (!report) return;
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `经营分析报告_${id}.json`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function loadSaved(id) {
const { res, body } = await api('/api/ops/reports/' + encodeURIComponent(id));
if (!res.ok || !body || !body.report) {
alert('读取失败');
return;
}
const report = body.report;
state.report = report;
const from = String(report.meta.period_start || '').replace(/-/g, '');
const to = String(report.meta.period_end || '').replace(/-/g, '');
state.from = from;
state.to = to;
$('rpFrom').value = String(report.meta.period_start || '');
$('rpTo').value = String(report.meta.period_end || '');
renderReport(report);
$('rpHistoryModal').hidden = true;
}
// ---------- 数据 ----------
async function load(force) {
if (state.loading) return;
state.loading = true;
const page = $('rpPage');
const saveBtn = $('rpSave');
if (saveBtn) saveBtn.disabled = true;
if (page && !state.report) page.innerHTML = '正在生成报告…
';
try {
const params = new URLSearchParams();
if (state.from) params.set('from', state.from);
if (state.to) params.set('to', state.to);
if (state.schools.length) params.set('schools', state.schools.join(','));
if (state.shops.length) params.set('shops', state.shops.join(','));
params.set('ai', '1'); // 默认走 AI,失败自动退回规则版
const { res, body } = await api('/api/ops/report?' + params.toString());
if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
if (body.empty) {
page.innerHTML = '所选范围没有数据,请先导入明细。
';
return;
}
state.report = body.report;
renderReport(state.report);
} catch (e) {
page.innerHTML = `报告生成失败:${esc(e.message || e)}
`;
} finally {
state.loading = false;
}
}
function renderFilterChip() {
const chip = $('rpFilterChip');
if (!chip) return;
const parts = [];
if (state.schools.length) parts.push(state.schools.length === 1 ? state.schools[0] : `${state.schools.length} 个学校`);
if (state.shops.length) parts.push(state.shops.length === 1 ? `1 家门店` : `${state.shops.length} 家门店`);
if (!parts.length) {
chip.hidden = true;
chip.innerHTML = '';
return;
}
chip.hidden = false;
chip.innerHTML = `已按 ${parts.join(' · ')} 筛选 `;
$('rpFilterClear').addEventListener('click', () => {
state.schools = [];
state.shops = [];
renderFilterChip();
load(true);
});
}
async function initDates() {
const search = new URLSearchParams(location.search);
const qFrom = String(search.get('from') || '').replace(/\D/g, '');
const qTo = String(search.get('to') || '').replace(/\D/g, '');
const qShops = String(search.get('shops') || '').split(',').map((s) => s.trim()).filter(Boolean);
const qSchools = String(search.get('schools') || '').split(',').map((s) => s.trim()).filter(Boolean);
state.shops = qShops;
state.schools = qSchools;
const toInput = (ymd) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
if (/^\d{8}$/.test(qFrom) && /^\d{8}$/.test(qTo)) {
state.from = qFrom;
state.to = qTo;
$('rpFrom').value = toInput(qFrom);
$('rpTo').value = toInput(qTo);
renderFilterChip();
return;
}
const { body } = await api('/api/ops/summary');
const coverage = body && body.coverage;
if (!coverage) return;
const to = coverage.to;
const d = new Date(Date.UTC(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)) - 6));
const from = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
state.to = to;
state.from = from < coverage.from ? coverage.from : from;
$('rpFrom').value = toInput(state.from);
$('rpTo').value = toInput(state.to);
renderFilterChip();
}
window.shanReportInit = function () {
if (!state.inited) {
state.inited = true;
renderShell();
initDates().then(() => load());
window.addEventListener('resize', () => {
Object.values(state.charts).forEach((chart) => chart && chart.resize());
});
window.addEventListener('beforeprint', () => {
Object.values(state.charts).forEach((chart) => chart && chart.resize());
});
}
};
})();