'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 '—'; 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)}
统计范围${esc(meta.data_scope || '–')}
数据来源${esc((meta.source_files || []).join('、') || '平台导出明细')}
生成时间${esc(new Date(meta.generated_at).toLocaleString('zh-CN'))}
`; } 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)}
    ${deltaText(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) => ``).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 ``; }).join('')}`).join('')}
    ${esc(c.label)}
    ${text}
    ${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) => ``).join('')}
    优先级行动负责人时间窗验收指标
    ${esc(p.priority)} ${esc(p.action)} ${esc(p.owner_role)} ${esc(p.time_horizon)} ${esc(p.success_metric)}
    `; } 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(';')}

    ` : ''}

    方法论:

    `; } function renderReport(report) { const page = $('rpPage'); 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 }), }); 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) => ``).join('')}
    保存时间区间来源关键指标操作
    ${esc(new Date(r.createdAt).toLocaleString('zh-CN'))} ${esc(r.from)} ~ ${esc(r.to)} ${r.source === 'llm' ? 'AI 生成' : '规则生成'} ${(r.kpis || []).map((k) => `${esc(k.label)} ${esc(k.current)}`).join(' · ')}
    `; list.querySelectorAll('[data-view]').forEach((btn) => { btn.addEventListener('click', () => loadSaved(btn.dataset.view)); }); 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 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'); 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); 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; } } 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 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); 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); } 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()); }); } }; })();