|
|
@@ -0,0 +1,383 @@
|
|
|
+'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: '',
|
|
|
+ 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 '<span class="rp-delta muted">—</span>';
|
|
|
+ const up = kpi.change_pct > 0;
|
|
|
+ const cls = kpi.change_pct === 0 ? 'muted' : up ? 'up' : 'down';
|
|
|
+ const unit = kpi.unit === '%' ? 'pp' : '%';
|
|
|
+ return `<span class="rp-delta ${cls}">${up ? '↑' : '↓'} ${Math.abs(kpi.change_pct).toFixed(1)}${unit}</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function api(url) {
|
|
|
+ const res = await fetch(url);
|
|
|
+ const body = await res.json().catch(() => null);
|
|
|
+ return { res, body };
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 渲染 ----------
|
|
|
+
|
|
|
+ function renderShell() {
|
|
|
+ const root = $('reportRoot');
|
|
|
+ if (!root) return;
|
|
|
+ root.innerHTML = `
|
|
|
+ <div class="rp-toolbar">
|
|
|
+ <div class="rp-range">
|
|
|
+ <input type="date" class="ops-input" id="rpFrom">
|
|
|
+ <span class="ops-tilde">~</span>
|
|
|
+ <input type="date" class="ops-input" id="rpTo">
|
|
|
+ </div>
|
|
|
+ <div class="rp-tabs" id="rpTabs">
|
|
|
+ <button type="button" data-mode="text" class="on">图文报告</button>
|
|
|
+ <button type="button" data-mode="interactive">交互报告</button>
|
|
|
+ </div>
|
|
|
+ <div class="rp-actions">
|
|
|
+ <button type="button" class="btn btn-ghost btn-small" id="rpRefresh">重新生成</button>
|
|
|
+ <button type="button" class="btn btn-primary btn-small" id="rpPrint">下载 PDF</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <article class="rp-page" id="rpPage"></article>`;
|
|
|
+ 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());
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderCover(report) {
|
|
|
+ const meta = report.meta || {};
|
|
|
+ return `
|
|
|
+ <header class="rp-cover">
|
|
|
+ <div class="rp-cover-main">
|
|
|
+ <div class="rp-kicker">AI 经营分析报告</div>
|
|
|
+ <h1>${esc(meta.title || '经营分析报告')}</h1>
|
|
|
+ <div class="rp-cover-sub">${esc(meta.organization || '')}${meta.organization ? ' · ' : ''}${esc(meta.period_start)} ~ ${esc(meta.period_end)}</div>
|
|
|
+ </div>
|
|
|
+ <div class="rp-cover-meta">
|
|
|
+ <div><span>统计范围</span><b>${esc(meta.data_scope || '–')}</b></div>
|
|
|
+ <div><span>数据来源</span><b>${esc((meta.source_files || []).join('、') || '平台导出明细')}</b></div>
|
|
|
+ <div><span>生成时间</span><b>${esc(new Date(meta.generated_at).toLocaleString('zh-CN'))}</b></div>
|
|
|
+ </div>
|
|
|
+ </header>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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) => `
|
|
|
+ <li>
|
|
|
+ <span class="rp-dot ${dot[f.severity] || 'na'}"></span>
|
|
|
+ <div>
|
|
|
+ <b>${esc(f.finding)}</b>
|
|
|
+ <p>${esc(f.interpretation || '')}</p>
|
|
|
+ ${(f.evidence || []).length ? `<div class="rp-evidence">${f.evidence.map((e) => `<code>${esc(e)}</code>`).join('')}</div>` : ''}
|
|
|
+ </div>
|
|
|
+ </li>`).join('');
|
|
|
+ return `
|
|
|
+ <section class="rp-section rp-summary">
|
|
|
+ <h2>管理层摘要</h2>
|
|
|
+ <p class="rp-lead">${esc(summary.overall_assessment || '')}</p>
|
|
|
+ <ul class="rp-findings">${items}</ul>
|
|
|
+ </section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderKpis(report) {
|
|
|
+ return `
|
|
|
+ <section class="rp-section">
|
|
|
+ <h2>核心指标</h2>
|
|
|
+ <div class="rp-kpis">
|
|
|
+ ${(report.kpis || []).map((kpi) => `
|
|
|
+ <div class="rp-kpi" title="${esc(kpi.definition)}">
|
|
|
+ <div class="rp-kpi-label">${esc(kpi.label)}<span>${esc(kpi.unit)}</span></div>
|
|
|
+ <div class="rp-kpi-value">${kpiText(kpi)}</div>
|
|
|
+ <div class="rp-kpi-foot">${deltaText(kpi)}<span class="rp-kpi-hint">较上期</span></div>
|
|
|
+ </div>`).join('')}
|
|
|
+ </div>
|
|
|
+ <p class="rp-note">口径:${(report.kpis || []).map((k) => `${k.label}=${k.definition}`).join(';')}</p>
|
|
|
+ </section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function chartCard(id, title, subtitle) {
|
|
|
+ return `<figure class="rp-figure">
|
|
|
+ <figcaption><b>${esc(title)}</b><span>${esc(subtitle || '')}</span></figcaption>
|
|
|
+ <div class="rp-chart" id="${id}"></div>
|
|
|
+ </figure>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 `
|
|
|
+ <section class="rp-section">
|
|
|
+ <h2><em>${String(index + 1).padStart(2, '0')}</em>${esc(section.title)}</h2>
|
|
|
+ ${section.summary ? `<p class="rp-lead">${esc(section.summary)}</p>` : ''}
|
|
|
+ ${(section.paragraphs || []).map((p) => `<p>${esc(p)}</p>`).join('')}
|
|
|
+ ${charts.map((chart) => chartCard(`rpChart_${chart.id}`, chart.title, chart.description)).join('')}
|
|
|
+ ${tables.map((table) => renderTable(table)).join('')}
|
|
|
+ </section>`;
|
|
|
+ }).join('');
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTable(table) {
|
|
|
+ return `
|
|
|
+ <div class="rp-table-wrap">
|
|
|
+ <table class="rp-table">
|
|
|
+ <thead><tr>${table.columns.map((c) => `<th>${esc(c.label)}</th>`).join('')}</tr></thead>
|
|
|
+ <tbody>${(table.rows || []).map((row) => `<tr>${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 `<td>${text}</td>`;
|
|
|
+ }).join('')}</tr>`).join('')}</tbody>
|
|
|
+ </table>
|
|
|
+ ${table.footnote ? `<p class="rp-note">${esc(table.footnote)}</p>` : ''}
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAlerts(report) {
|
|
|
+ const alerts = report.alerts || [];
|
|
|
+ const plan = report.action_plan || [];
|
|
|
+ return `
|
|
|
+ <section class="rp-section">
|
|
|
+ <h2><em>05</em>风险与建议</h2>
|
|
|
+ <div class="rp-alerts">
|
|
|
+ ${alerts.map((a) => `
|
|
|
+ <div class="rp-alert ${esc(a.severity)}">
|
|
|
+ <div class="rp-alert-head"><span class="rp-badge">${esc(a.severity)}</span><b>${esc(a.title)}</b></div>
|
|
|
+ <ul>${(a.evidence || []).map((e) => `<li>${esc(e)}</li>`).join('')}</ul>
|
|
|
+ ${(a.hypotheses || []).length ? `<p class="rp-note">待验证:${a.hypotheses.map(esc).join('、')}</p>` : ''}
|
|
|
+ </div>`).join('') || '<p class="rp-note">本期未触发风险规则。</p>'}
|
|
|
+ </div>
|
|
|
+ <h3 class="rp-h3">行动计划</h3>
|
|
|
+ <table class="rp-table">
|
|
|
+ <thead><tr><th>优先级</th><th>行动</th><th>负责人</th><th>时间窗</th><th>验收指标</th></tr></thead>
|
|
|
+ <tbody>${plan.map((p) => `<tr>
|
|
|
+ <td><span class="rp-badge">${esc(p.priority)}</span></td>
|
|
|
+ <td>${esc(p.action)}</td>
|
|
|
+ <td>${esc(p.owner_role)}</td>
|
|
|
+ <td>${esc(p.time_horizon)}</td>
|
|
|
+ <td>${esc(p.success_metric)}</td>
|
|
|
+ </tr>`).join('')}</tbody>
|
|
|
+ </table>
|
|
|
+ </section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderQuality(report) {
|
|
|
+ const q = report.data_quality || {};
|
|
|
+ return `
|
|
|
+ <section class="rp-section rp-quality">
|
|
|
+ <h2><em>06</em>数据质量与方法论</h2>
|
|
|
+ <p><b>覆盖范围:</b>${esc(q.coverage || '–')}</p>
|
|
|
+ <p><b>可比性:</b>${esc(q.comparability || '–')}</p>
|
|
|
+ ${(q.warnings || []).length ? `<p><b>提示:</b>${q.warnings.map(esc).join(';')}</p>` : ''}
|
|
|
+ ${(q.missing_metrics || []).length ? `<p><b>缺失指标:</b>${q.missing_metrics.map(esc).join(';')}</p>` : ''}
|
|
|
+ <p><b>方法论:</b></p>
|
|
|
+ <ul class="rp-method">${(report.methodology || []).map((m) => `<li>${esc(m)}</li>`).join('')}</ul>
|
|
|
+ </section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderReport(report) {
|
|
|
+ const page = $('rpPage');
|
|
|
+ page.innerHTML = [
|
|
|
+ renderCover(report),
|
|
|
+ renderSummary(report),
|
|
|
+ renderKpis(report),
|
|
|
+ renderSections(report),
|
|
|
+ renderAlerts(report),
|
|
|
+ renderQuality(report),
|
|
|
+ `<footer class="rp-footer">本报告由系统按统一口径自动生成,数值均来自后端计算,模型仅参与文字表述。</footer>`,
|
|
|
+ ].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 load(force) {
|
|
|
+ if (state.loading) return;
|
|
|
+ state.loading = true;
|
|
|
+ const page = $('rpPage');
|
|
|
+ if (page && !state.report) page.innerHTML = '<div class="rp-loading">正在生成报告…</div>';
|
|
|
+ try {
|
|
|
+ const params = new URLSearchParams();
|
|
|
+ if (state.from) params.set('from', state.from);
|
|
|
+ if (state.to) params.set('to', state.to);
|
|
|
+ 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 = '<div class="rp-loading">所选范围没有数据,请先导入明细。</div>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ state.report = body.report;
|
|
|
+ renderReport(state.report);
|
|
|
+ } catch (e) {
|
|
|
+ page.innerHTML = `<div class="rp-loading">报告生成失败:${esc(e.message || e)}</div>`;
|
|
|
+ } finally {
|
|
|
+ state.loading = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function initDates() {
|
|
|
+ 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;
|
|
|
+ const toInput = (ymd) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
|
|
|
+ $('rpFrom').value = toInput(state.from);
|
|
|
+ $('rpTo').value = toInput(state.to);
|
|
|
+ }
|
|
|
+
|
|
|
+ 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());
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+})();
|