Эх сурвалжийг харах

feat: 历史报告显示学校/店铺范围,并支持下载 PDF 与 JSON

- listReports 返回保存时记录的 schools/shops,路由层映射成门店名称
- 历史列表新增「学校 / 店铺」两列(全部/部分名单),来源列区分 AI 与规则
- 每条记录支持:查看、下载 PDF(载入快照后浏览器打印)、下载 JSON(完整快照)、删除
- 保存时优先提交当前展示的 Report JSON,保证快照与所见一致;报告渲染完成前禁用保存按钮
- 测试补充快照保存断言(48/48、114/114)
liangzhao 2 долоо хоног өмнө
parent
commit
242c92eb8a

+ 1 - 1
README.md

@@ -53,7 +53,7 @@
 - **重复校验**:平台明细按「日期 + 门店编号」判断——同一天同一店的相同数据自动跳过;内容有变化记为「冲突」不覆盖,勾选「覆盖已导入的文件」才覆盖。上传结果会显示「新增 N / 已存在 N / 冲突 N」。
 - **报表展示**:一张表、一行一店一天,列 = 店铺 / 日期 / 勾选的指标(勾几个显示几列),按学校分组并带每日小计。平台明细同样进入「店铺运营」报表——有效订单→订单量、营业额→营收、曝光人数→曝光量、进店转化率→进店转化率;同一天同一店两种来源都有时,以人工表为准(人工优先、平台补位)。
 - **经营驾驶舱**:一屏看经营大盘——6 个 KPI(营业额 / 有效订单 / 客单价 / 曝光人数 / 进店转化率 / 店铺评分,带环比)+ 营业额与订单趋势、门店营业额 Top10、转化漏斗(曝光→进店→下单)、收入结构(收入/支出/补贴)。支持日期、学校、店铺筛选,环比按「紧邻的等长区间」计算;下方「零单诊断」用平台字段区分未开业 / 当日闭店 / 无曝光 / 无进店 / 无转化。
-- **报告中心**:报告页可「保存报告」,历史列表支持按区间/来源查看与删除;保存的是完整 Report JSON 快照(含 AI 文字与当时数值),数据后续变化不影响已保存版本。
+- **报告中心**:报告页可「保存报告」,历史列表显示保存时间、区间、学校、店铺范围与来源(AI/规则),支持查看、下载 PDF(浏览器打印)、下载 JSON 与删除;保存的是完整 Report JSON 快照(含 AI 文字与当时数值),数据后续变化不影响已保存版本。
 - **解读与报告同源**:驾驶舱的「经营解读」是经营报告 JSON 的摘要视图(同一份生成结果、同一缓存),卡片上可一键跳到同区间的完整报告;报告页「重新生成」会调用 AI 补齐文字,模型不可用时自动退回规则版并标注。
 - **AI 经营解读**:点「生成解读」,把当前范围的 KPI、趋势、门店增减、漏斗与成本结构整理成上下文交给对话模型,输出 3~5 条结论与建议(引用具体数字);结果缓存 10 分钟,可复制。未配置对话模型时会明确提示去「系统设置 → API 设置」配置。
 - **经营报告**:按统一 Report JSON 渲染「图文报告 / 交互报告」两版,支持选择日期区间、重新生成、浏览器打印导出 PDF(A4 打印样式已适配)。报告含管理层摘要(带证据)、核心指标、经营/门店/流量/费用四节图表、风险与行动计划、数据质量与方法论。

+ 61 - 4
public/report.js

@@ -76,7 +76,7 @@
         </div>
         <div class="rp-actions">
           <button type="button" class="btn btn-ghost btn-small" id="rpArchive">历史报告</button>
-          <button type="button" class="btn btn-ghost btn-small" id="rpSave">保存报告</button>
+          <button type="button" class="btn btn-ghost btn-small" id="rpSave" disabled>保存报告</button>
           <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>
@@ -245,6 +245,8 @@
 
   function renderReport(report) {
     const page = $('rpPage');
+    const saveBtn = $('rpSave');
+    if (saveBtn) saveBtn.disabled = false;
     page.innerHTML = [
       renderCover(report),
       renderSummary(report),
@@ -354,7 +356,11 @@
       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(',') }),
+        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 = '已保存';
@@ -385,14 +391,19 @@
       return;
     }
     list.innerHTML = `<table class="rp-table">
-      <thead><tr><th>保存时间</th><th>区间</th><th>来源</th><th>关键指标</th><th class="rp-col-actions">操作</th></tr></thead>
+      <thead><tr><th>保存时间</th><th>区间</th><th>学校</th><th>店铺</th><th>来源</th><th class="rp-col-actions">操作</th></tr></thead>
       <tbody>${reports.map((r) => `<tr>
         <td>${esc(new Date(r.createdAt).toLocaleString('zh-CN'))}</td>
         <td>${esc(r.from)} ~ ${esc(r.to)}</td>
+        <td>${(r.schoolNames || []).length ? esc(r.schoolNames.join('、')) : '<span class="rp-muted">全部</span>'}</td>
+        <td title="${esc((r.shopNames || []).join('、'))}">${(r.shopNames || []).length
+          ? esc(r.shopNames.slice(0, 3).join('、')) + (r.shopNames.length > 3 ? ` 等 ${r.shopNames.length} 家` : '')
+          : '<span class="rp-muted">全部</span>'}</td>
         <td>${r.source === 'llm' ? 'AI 生成' : '规则生成'}</td>
-        <td class="rp-history-kpis">${(r.kpis || []).map((k) => `${esc(k.label)} ${esc(k.current)}`).join(' · ')}</td>
         <td class="rp-col-actions">
           <button type="button" class="btn btn-ghost btn-small" data-view="${esc(r.id)}">查看</button>
+          <button type="button" class="btn btn-ghost btn-small" data-pdf="${esc(r.id)}">PDF</button>
+          <button type="button" class="btn btn-ghost btn-small" data-json="${esc(r.id)}">JSON</button>
           <button type="button" class="btn btn-ghost btn-small" data-del="${esc(r.id)}">删除</button>
         </td>
       </tr>`).join('')}</tbody>
@@ -400,6 +411,12 @@
     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;
@@ -410,6 +427,44 @@
     });
   }
 
+  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) {
@@ -434,6 +489,8 @@
     if (state.loading) return;
     state.loading = true;
     const page = $('rpPage');
+    const saveBtn = $('rpSave');
+    if (saveBtn) saveBtn.disabled = true;
     if (page && !state.report) page.innerHTML = '<div class="rp-loading">正在生成报告…</div>';
     try {
       const params = new URLSearchParams();

+ 1 - 0
public/style.css

@@ -1470,3 +1470,4 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .rp-filter-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; margin-right: 8px; font-size: 11.5px; color: var(--text-2); background: #fff5ef; border: 1px solid rgba(255,107,53,.25); border-radius: 999px; }
 .rp-filter-chip button { padding: 0; font-size: 11.5px; color: var(--accent); background: none; border: 0; cursor: pointer; text-decoration: underline; }
 @media print { .rp-filter-chip { display: none !important; } }
+.rp-muted { color: var(--text-3); }

+ 17 - 0
scripts/test-ops-import.js

@@ -467,6 +467,23 @@ async function main() {
       const list = await fetch(base + '/api/ops/reports').then(readJson);
       expect(list.status === 200 && list.body.reports.some((r) => r.id === saved.body.id), '列表缺少刚保存的报告');
 
+      // 前端提交当前 Report JSON 时应原样保存(快照),不被重新生成成规则版
+      const snapshot = {
+        meta: { period_start: '2026-10-14', period_end: '2026-10-20', title: '快照测试' },
+        kpis: [{ key: 'revenue', label: '营业额', current: 1, unit: 'CNY' }],
+        executive_summary: { overall_assessment: 'snapshot', key_findings: [] },
+        ai: { source: 'llm', model: 'test-model' },
+      };
+      const snapSaved = await fetch(base + '/api/ops/reports', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ from: '20261014', to: '20261020', report: snapshot }),
+      }).then(readJson);
+      expect(snapSaved.status === 200, `snapshot save ${snapSaved.status}`);
+      const snapList = await fetch(base + '/api/ops/reports').then(readJson);
+      const snapRow = snapList.body.reports.find((r) => r.id === snapSaved.body.id);
+      expect(snapRow && snapRow.source === 'llm', '快照来源未保留');
+
       const one = await fetch(base + '/api/ops/reports/' + saved.body.id).then(readJson);
       expect(one.status === 200 && one.body.report && one.body.report.meta, '读取报告失败');
 

+ 20 - 3
server/routes/ops.js

@@ -1231,8 +1231,12 @@ function register(app, ctx) {
       shops: splitList(body.shops),
     };
     try {
-      const report = await getReportPayload(params, { ai: false });
-      if (report.empty) return sendJson(res, 400, { error: '所选范围没有数据,无法保存' });
+      // 前端会把它当前看到的 Report JSON 一起提交,保证「保存的就是所见」
+      let report = body.report;
+      if (!report || !report.meta || !Array.isArray(report.kpis)) {
+        report = await getReportPayload(params, { ai: false });
+      }
+      if (!report || report.empty) return sendJson(res, 400, { error: '所选范围没有数据,无法保存' });
       const id = `rpt_${newUploadId()}`;
       const record = {
         id,
@@ -1256,7 +1260,20 @@ function register(app, ctx) {
   app.get('/api/ops/reports', async ({ res }) => {
     if (!(await requireHealthy(store, res))) return;
     try {
-      sendJson(res, 200, { reports: await store.listReports() });
+      const reports = await store.listReports();
+      // 把保存时记录的店铺 key 映射成名称,历史记录才看得懂范围
+      let shopNameByKey = new Map();
+      try {
+        const summary = await store.summary();
+        shopNameByKey = new Map((summary.shops || []).map((s) => [String(s.key || s.shopId || ''), s.shopName || '']));
+      } catch { /* 映射失败时保留 key */ }
+      sendJson(res, 200, {
+        reports: reports.map((item) => ({
+          ...item,
+          schoolNames: item.schools || [],
+          shopNames: (item.shops || []).map((key) => shopNameByKey.get(String(key)) || String(key)),
+        })),
+      });
     } catch (e) {
       sendJson(res, 503, { error: '读取失败', detail: e.message || String(e) });
     }

+ 3 - 1
server/stores/ops-pg.js

@@ -378,7 +378,7 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
   }
 
   async function listReports() {
-    const r = await getPool().query(`SELECT id, created_at, period_from, period_to, source, model, title, report
+    const r = await getPool().query(`SELECT id, created_at, period_from, period_to, schools, shops, source, model, title, report
       FROM ops_report ORDER BY created_at DESC LIMIT 100`);
     return r.rows.map((row) => ({
       id: row.id,
@@ -388,6 +388,8 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
       source: row.source,
       model: row.model,
       title: row.title,
+      schools: row.schools || [],
+      shops: row.shops || [],
       kpis: ((row.report && row.report.kpis) || []).slice(0, 3).map((k) => ({ label: k.label, current: k.current, unit: k.unit })),
     }));
   }

+ 3 - 1
server/stores/ops-sqlite.js

@@ -412,7 +412,7 @@ function createSqliteStore({ file }) {
   }
 
   async function listReports() {
-    return open().prepare(`SELECT id, created_at, period_from, period_to, source, model, title, report
+    return open().prepare(`SELECT id, created_at, period_from, period_to, schools, shops, source, model, title, report
       FROM ops_report ORDER BY created_at DESC LIMIT 100`).all().map((row) => {
       const report = safeJson(row.report, {});
       return {
@@ -423,6 +423,8 @@ function createSqliteStore({ file }) {
         source: row.source,
         model: row.model,
         title: row.title,
+        schools: safeJson(row.schools, []),
+        shops: safeJson(row.shops, []),
         kpis: (report.kpis || []).slice(0, 3).map((k) => ({ label: k.label, current: k.current, unit: k.unit })),
       };
     });