|
@@ -49,8 +49,8 @@
|
|
|
return `<span class="rp-delta ${cls}">${up ? '↑' : '↓'} ${Math.abs(kpi.change_pct).toFixed(1)}${unit}</span>`;
|
|
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);
|
|
|
|
|
|
|
+ async function api(url, options) {
|
|
|
|
|
+ const res = await fetch(url, options);
|
|
|
const body = await res.json().catch(() => null);
|
|
const body = await res.json().catch(() => null);
|
|
|
return { res, body };
|
|
return { res, body };
|
|
|
}
|
|
}
|
|
@@ -72,11 +72,22 @@
|
|
|
<button type="button" data-mode="interactive">交互报告</button>
|
|
<button type="button" data-mode="interactive">交互报告</button>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="rp-actions">
|
|
<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="rpRefresh">重新生成</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>
|
|
<button type="button" class="btn btn-primary btn-small" id="rpPrint">下载 PDF</button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
- <article class="rp-page" id="rpPage"></article>`;
|
|
|
|
|
|
|
+ <article class="rp-page" id="rpPage"></article>
|
|
|
|
|
+ <div class="modal-overlay" id="rpHistoryModal" hidden>
|
|
|
|
|
+ <div class="modal rp-history-modal">
|
|
|
|
|
+ <div class="modal-header">
|
|
|
|
|
+ <div class="modal-title"><span>历史报告</span></div>
|
|
|
|
|
+ <button class="modal-close" id="rpHistoryClose" type="button" aria-label="关闭">×</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="modal-body" id="rpHistoryList"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>`;
|
|
|
root.querySelectorAll('#rpTabs [data-mode]').forEach((btn) => {
|
|
root.querySelectorAll('#rpTabs [data-mode]').forEach((btn) => {
|
|
|
btn.addEventListener('click', () => {
|
|
btn.addEventListener('click', () => {
|
|
|
state.mode = btn.dataset.mode;
|
|
state.mode = btn.dataset.mode;
|
|
@@ -89,6 +100,9 @@
|
|
|
$('rpTo').addEventListener('change', (e) => { state.to = 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));
|
|
$('rpRefresh').addEventListener('click', () => load(true));
|
|
|
$('rpPrint').addEventListener('click', () => window.print());
|
|
$('rpPrint').addEventListener('click', () => window.print());
|
|
|
|
|
+ $('rpSave').addEventListener('click', saveReport);
|
|
|
|
|
+ $('rpArchive').addEventListener('click', openHistory);
|
|
|
|
|
+ $('rpHistoryClose').addEventListener('click', () => { $('rpHistoryModal').hidden = true; });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderCover(report) {
|
|
function renderCover(report) {
|
|
@@ -327,6 +341,90 @@
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ---------- 报告中心 ----------
|
|
|
|
|
+
|
|
|
|
|
+ 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 = '<div class="rp-loading" style="padding:24px 0">加载中…</div>';
|
|
|
|
|
+ const { res, body } = await api('/api/ops/reports');
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ list.innerHTML = `<div class="rp-loading">读取失败:${esc((body && (body.error || body.detail)) || res.status)}</div>`;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const reports = (body && body.reports) || [];
|
|
|
|
|
+ if (!reports.length) {
|
|
|
|
|
+ list.innerHTML = '<div class="rp-loading" style="padding:24px 0">还没有保存过报告</div>';
|
|
|
|
|
+ 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>
|
|
|
|
|
+ <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.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-del="${esc(r.id)}">删除</button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>`).join('')}</tbody>
|
|
|
|
|
+ </table>`;
|
|
|
|
|
+ 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) {
|
|
async function load(force) {
|
|
@@ -338,7 +436,7 @@
|
|
|
const params = new URLSearchParams();
|
|
const params = new URLSearchParams();
|
|
|
if (state.from) params.set('from', state.from);
|
|
if (state.from) params.set('from', state.from);
|
|
|
if (state.to) params.set('to', state.to);
|
|
if (state.to) params.set('to', state.to);
|
|
|
- if (force) params.set('ai', '1');
|
|
|
|
|
|
|
+ params.set('ai', '1'); // 默认走 AI,失败自动退回规则版
|
|
|
const { res, body } = await api('/api/ops/report?' + params.toString());
|
|
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 (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
|
|
|
if (body.empty) {
|
|
if (body.empty) {
|