'use strict';
/**
* 数据采集 + 趋势图表 · 前端逻辑
* 数据采集:多主体 tab / 同步 / 筛选 / BI 表格 / CSV 导出
* 趋势图表:环比指标条 / 主趋势 / 门店排行 / 转化漏斗 / 门店对比 / 周报导出
* 智能问数:右下角机器人(仅经营分析页面显示),弹窗对话,支持上下文追问
*/
(function () {
// ---------- 工具 ----------
function showToast(msg, type) {
const wrap = document.getElementById('toast-wrap');
const node = document.createElement('div');
node.className = 'toast toast-' + (type || 'success');
node.textContent = msg;
wrap.appendChild(node);
setTimeout(() => { node.style.opacity = '0'; node.style.transition = 'opacity 0.3s'; }, 2600);
setTimeout(() => { if (node.parentNode) wrap.removeChild(node); }, 3000);
}
async function jsonFetch(url, opts) {
const r = await fetch(url, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
return data;
}
const z2 = (n) => String(n).padStart(2, '0');
function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`; }
function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
function ymdToDash(s) { return /^\d{8}$/.test(String(s)) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : String(s || ''); }
function dashToYmd(s) { return /^\d{4}-\d{2}-\d{2}$/.test(String(s)) ? s.replace(/-/g, '') : ''; }
function fmtNum(v) {
if (v == null || v === '') return '—';
const n = Number(v);
return Number.isFinite(n) ? n.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) : String(v);
}
function fmtMoney(v) {
if (v == null || v === '') return '—';
const n = Number(v);
return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v);
}
function fmtPct(v) {
if (v == null || v === '') return '—';
const n = Number(v);
return Number.isFinite(n) ? (n * 100).toFixed(2) + '%' : String(v);
}
function fmtCell(col, v) {
if (col.type === 'pct') return fmtPct(v);
if (col.type === 'money') return fmtMoney(v);
if (col.type === 'int') return fmtNum(v);
if (col.type === 'num') return v == null || v === '' ? '—' : Number(v).toFixed(2);
if (col.type === 'date') return v ? ymdToDash(v) : '—';
return v == null || v === '' ? '—' : String(v);
}
function fmtByCol(col, v) {
if (v == null || v === '') return '—';
if (!col) return String(v);
if (col.type === 'pct') return fmtPct(v);
if (col.type === 'money') return fmtMoney(v);
if (col.type === 'int') return fmtNum(v);
if (col.type === 'num') return Number(v).toFixed(2);
return String(v);
}
const RATE_THRESHOLDS = { clkExpRate: { good: 0.09, bad: 0.06 }, ordClkRate: { good: 0.025, bad: 0.02 } };
function rateClass(key, v) {
const t = RATE_THRESHOLDS[key];
if (!t || v == null || v === '') return '';
const n = Number(v);
if (!Number.isFinite(n)) return '';
if (n >= t.good) return 'val-good';
if (n < t.bad) return 'val-bad';
return 'val-warn';
}
const LS = {
get(k, fallback) { try { const v = localStorage.getItem(k); return v == null ? fallback : JSON.parse(v); } catch { return fallback; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* ignore */ } },
};
// ---------- 状态 ----------
const state = {
inited: false,
entities: [],
entity: null,
preset: '30',
mode: 'detail',
shopFilter: new Set(),
q: '',
columns: [],
visible: new Set(),
sortKey: 'date',
sortDir: 'desc',
rows: [],
summary: null,
conn: null,
syncShops: new Set(),
syncPollTimer: null,
reportAbort: null,
analysisShopFilter: new Set(),
analysisPreset: '30',
comparePrev: true,
chat: { open: false, busy: false, messages: [] },
};
let chartMetric = LS.get('shanReports.chartMetric', 'grossAmt');
const chartInstances = {};
let anaCache = null;
let lastAskApply = null;
const $ = (id) => document.getElementById(id);
// ---------- 日期区间 ----------
function currentRange() {
if (state.preset === 'custom') {
const from = dashToYmd($('reportFromDate').value);
const to = dashToYmd($('reportToDate').value);
if (from && to && from <= to) return { from, to };
return { from: daysAgoYmd(30), to: daysAgoYmd(0) };
}
const days = Number(state.preset) || 30;
return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
}
function analysisRange() {
const sel = $('analysisRangePreset');
const days = Number(sel && sel.value) || 30;
return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
}
// ---------- 主体 tabs(数据采集 / 趋势图表 共用选中) ----------
function activeEntity() {
return state.entities.find((e) => e.key === state.entity) || null;
}
function renderEntityTabs() {
for (const containerId of ['entityTabs', 'analysisEntityTabs']) {
const wrap = $(containerId);
if (!wrap) continue;
wrap.innerHTML = '';
if (!state.entities.length) {
const empty = document.createElement('div');
empty.className = 'entity-tabs-empty';
empty.textContent = '暂无主体 —— 请先在商家客户端登录并完成一次「同步数据」';
wrap.appendChild(empty);
continue;
}
for (const e of state.entities) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'entity-tab' + (e.key === state.entity ? ' active' : '');
btn.innerHTML = ``;
btn.querySelector('.entity-name').textContent = e.name || e.key;
const shopCount = (e.shops || []).length || e.shopCount || 0;
btn.querySelector('.entity-meta').textContent = shopCount ? `${shopCount} 家门店` : '主体';
btn.addEventListener('click', () => {
if (state.entity === e.key) return;
state.entity = e.key;
state.shopFilter = new Set();
state.analysisShopFilter = new Set();
state.sortKey = 'date'; state.sortDir = 'desc';
LS.set('shanReports.entity', e.key);
anaCache = null;
renderEntityTabs();
renderShopOptions();
loadReport();
renderCharts();
});
wrap.appendChild(btn);
}
}
}
// ---------- 门店多选下拉(数据采集 / 同步 / 趋势图表 三处) ----------
function toggleDropdown(menuId, triggerId, open) {
const trigger = $(triggerId);
const menu = menuId ? $(menuId) : null;
const shouldOpen = open === undefined ? (menu ? menu.hidden : false) : open;
if (menu) menu.hidden = !shouldOpen;
if (trigger) trigger.setAttribute('aria-expanded', String(shouldOpen));
}
function fillShopMenu(menu, shops) {
menu.innerHTML = '';
const all = document.createElement('button');
all.type = 'button';
all.className = 'scan-dropdown-item';
all.dataset.value = '__all__';
all.setAttribute('role', 'option');
all.innerHTML = '全部门店';
menu.appendChild(all);
const sorted = shops.slice().sort((a, b) => String(a.shopName).localeCompare(String(b.shopName), 'zh-CN'));
for (const s of sorted) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'scan-dropdown-item';
item.dataset.value = String(s.shopId);
item.setAttribute('role', 'option');
item.innerHTML = '';
item.lastElementChild.textContent = s.shopName;
menu.appendChild(item);
}
}
function renderDropdownSelection(menu, selection, textEl, allLabel) {
menu.querySelectorAll('.scan-dropdown-item').forEach((node) => {
const selected = selection.has(node.dataset.value);
node.classList.toggle('selected', selected);
node.setAttribute('aria-selected', String(selected));
});
const picked = Array.from(selection).filter((v) => v !== '__all__');
textEl.textContent = picked.length ? `已选 ${picked.length} 家门店` : allLabel;
}
function renderShopOptions() {
const entity = activeEntity();
const shops = (entity && entity.shops) || [];
const menus = [
{ menu: $('reportShopMenu'), sel: state.shopFilter, text: $('reportShopText'), label: '全部门店' },
{ menu: $('syncShopMenu'), sel: state.syncShops, text: $('syncShopText'), label: '选择门店' },
{ menu: $('anaShopMenu'), sel: state.analysisShopFilter, text: $('anaShopText'), label: '全部门店' },
];
for (const m of menus) {
if (!m.menu) continue;
fillShopMenu(m.menu, shops);
renderDropdownSelection(m.menu, m.sel, m.text, m.label);
}
}
function makeDropdownHandler(menu, selection, textEl, allLabel, onChange) {
return (event) => {
const item = event.target.closest('[data-value]');
if (!item) return;
const value = item.dataset.value;
if (value === '__all__') selection.clear();
else if (selection.has(value)) selection.delete(value);
else selection.add(value);
renderDropdownSelection(menu, selection, textEl, allLabel);
if (onChange) onChange();
};
}
// ---------- KPI 卡片(数据采集页) ----------
function renderKpis() {
const wrap = $('reportKpis');
wrap.innerHTML = '';
const s = state.summary || {};
const cards = [
{ label: '营业额(元)', value: fmtMoney(s.grossAmt), cls: 'accent' },
{ label: '收入(元)', value: fmtMoney(s.incomeAmt), cls: 'ok' },
{ label: '有效订单', value: fmtNum(s.validOrderCnt), cls: 'accent' },
{ label: '曝光人数', value: fmtNum(s.expUv), cls: '' },
{ label: '下单人数', value: fmtNum(s.orderUserCnt), cls: '' },
{ label: '进店转化率', value: fmtPct(s.clkExpRate), cls: 'warn', rate: 'clkExpRate' },
{ label: '客单价(元)', value: fmtMoney(s.perActualPay), cls: 'ok' },
];
for (const c of cards) {
const div = document.createElement('div');
div.className = 'metric ' + (c.cls || '');
div.innerHTML = ``;
div.querySelector('.metric-label').textContent = c.label;
div.querySelector('.metric-value').textContent = c.value;
wrap.appendChild(div);
}
}
// ---------- 报表表格(数据采集页) ----------
function metricColumns() {
return state.columns.filter((c) => c.key !== 'date' && c.key !== 'shopName' && state.visible.has(c.key));
}
function tableColumns() {
const metricCols = metricColumns();
if (state.mode === 'shop') return [{ key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
if (state.mode === 'date') return [{ key: 'date', label: '日期', type: 'date' }, ...metricCols];
return [{ key: 'date', label: '日期', type: 'date' }, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
}
function sortedRows() {
return state.rows.slice().sort((a, b) => {
const av = a[state.sortKey], bv = b[state.sortKey];
if (av == null || av === '') return 1;
if (bv == null || bv === '') return -1;
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * (state.sortDir === 'asc' ? 1 : -1);
return String(av).localeCompare(String(bv), 'zh-CN') * (state.sortDir === 'asc' ? 1 : -1);
});
}
function renderTable() {
const cols = tableColumns();
const thead = $('reportTableHead');
const tbody = $('reportTableBody');
$('reportRowCount').textContent = `${state.rows.length} 行`;
const empty = $('reportEmpty');
if (!state.rows.length) {
thead.innerHTML = '';
tbody.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
thead.innerHTML = '
' + cols.map((c) => {
const active = state.sortKey === c.key;
const arrow = active ? (state.sortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `| ${c.label}${arrow} | `;
}).join('') + '
';
tbody.innerHTML = sortedRows().map((r) => '' + cols.map((c) => {
const isNum = ['money', 'int', 'pct', 'num'].includes(c.type);
const cls = `col-${c.type}${isNum ? ' col-num' : ''}${c.type === 'pct' ? ' ' + rateClass(c.key, r[c.key]) : ''}`;
return `| ${fmtCell(c, r[c.key])} | `;
}).join('') + '
').join('');
}
// ---------- 数据加载(数据采集页) ----------
async function loadReport() {
const entity = state.entity;
if (!entity) {
state.rows = []; state.summary = null;
renderTable(); renderKpis();
return;
}
if (state.reportAbort) state.reportAbort.abort();
const ctrl = new AbortController();
state.reportAbort = ctrl;
const { from, to } = currentRange();
try {
let url = state.mode === 'detail'
? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&scope=shop`
: `/api/reports/aggregate?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&group=${state.mode}`;
if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
const data = await jsonFetch(url, { signal: ctrl.signal });
if (state.entity !== entity) return;
state.rows = data.rows || [];
state.summary = data.summary || null;
renderKpis();
renderTable();
} catch (e) {
if (e.name === 'AbortError') return;
showToast('报表加载失败:' + e.message, 'error');
}
}
// ---------- 连接状态 ----------
async function refreshConn() {
const badge = $('reportConnBadge');
try {
const data = await jsonFetch('/api/reports/status');
state.conn = data.napos || null;
state.entities = data.entities || [];
if (!state.entity || !state.entities.some((e) => e.key === state.entity)) {
const saved = LS.get('shanReports.entity', null);
state.entity = (saved && state.entities.some((e) => e.key === saved)) ? saved : (state.entities[0] ? state.entities[0].key : null);
}
renderEntityTabs();
renderShopOptions();
const ok = state.conn && state.conn.connected;
badge.textContent = ok ? '客户端已连接' : '客户端未连接';
badge.className = 'badge ' + (ok ? 'badge-ok' : 'badge-fail');
badge.title = ok
? `调试端口 ${state.conn.port}`
: [state.conn && state.conn.error, state.conn && state.conn.hint].filter(Boolean).join('\n') || '未检测到调试端口';
} catch {
badge.textContent = '状态检测失败';
badge.className = 'badge badge-fail';
}
}
// ---------- 列设置 ----------
function loadColumnPrefs() {
const saved = LS.get('shanReports.columns.v1', null);
const defaults = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
if (Array.isArray(saved)) {
const valid = new Set(state.columns.map((c) => c.key));
state.visible = new Set(saved.filter((k) => valid.has(k)));
} else {
state.visible = defaults;
}
if (!state.visible.size) state.visible = defaults;
}
function renderColumnCfg() {
const list = $('columnCfgList');
list.innerHTML = '';
for (const c of state.columns) {
if (c.locked) continue;
const label = document.createElement('label');
label.className = 'colcfg-item';
const checked = state.visible.has(c.key) ? 'checked' : '';
label.innerHTML = `${c.reserved ? '预留' : ''}`;
label.querySelector('span').textContent = c.label;
list.appendChild(label);
}
}
function applyColumnCfg() {
const picks = new Set();
$('columnCfgList').querySelectorAll('input[type=checkbox]').forEach((cb) => {
if (cb.checked) picks.add(cb.value);
});
state.visible = picks;
LS.set('shanReports.columns.v1', Array.from(picks));
$('modalReportColumns').hidden = true;
renderTable();
}
// ---------- 同步 ----------
function renderSyncProgress(st) {
const pct = st.planTotal ? Math.round((st.done / st.planTotal) * 100) : 0;
$('syncProgressBar').style.width = pct + '%';
$('syncProgressText').textContent = `${st.done} / ${st.planTotal}`;
$('syncStatAdded').textContent = `新增 ${st.added}`;
$('syncStatUpdated').textContent = `更新 ${st.updated}`;
$('syncStatFailed').textContent = `失败 ${st.failed}`;
const steps = (st.steps || []).slice(-8);
$('syncSteps').innerHTML = steps.map((s) => {
const cls = s.ok ? 'ok' : 'fail';
const detail = s.ok ? `+${s.added}/~${s.updated}` : (s.error || '失败').slice(0, 60);
return `${detail}
`;
}).join('');
const stepNames = $('syncSteps').querySelectorAll('.sync-step span');
steps.forEach((s, i) => { stepNames[i].textContent = s.label; });
$('btnSyncCancel').hidden = !st.running;
$('btnStartSync').disabled = !!st.running;
$('btnStartSync').textContent = st.running ? '同步中…' : '开始同步';
}
function pollSyncStatus() {
if (state.syncPollTimer) clearInterval(state.syncPollTimer);
state.syncPollTimer = setInterval(async () => {
try {
const st = await jsonFetch('/api/reports/sync/status');
renderSyncProgress(st);
if (!st.running) {
clearInterval(state.syncPollTimer);
state.syncPollTimer = null;
if (st.error) showToast('同步出错:' + st.error, 'error');
else showToast(st.cancelled ? '同步已取消' : `同步完成:新增 ${st.added},更新 ${st.updated}`);
refreshConn().then(loadReport);
}
} catch { /* 轮询失败忽略 */ }
}, 900);
}
async function loadAutoSyncPrefs() {
try {
const s = await jsonFetch('/api/settings');
$('autoSyncEnabled').checked = !!s.autoSyncEnabled;
if (s.autoSyncTime) $('autoSyncTime').value = s.autoSyncTime;
} catch { /* 设置读取失败时保留默认值 */ }
}
async function saveAutoSyncPrefs() {
try {
await jsonFetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoSyncEnabled: $('autoSyncEnabled').checked, autoSyncTime: $('autoSyncTime').value }),
});
showToast('自动同步设置已保存');
} catch (e) {
showToast('自动同步设置保存失败:' + e.message, 'error');
}
}
async function openSyncModal() {
$('modalReportSync').hidden = false;
$('syncEntityBadge').textContent = '识别主体中…';
$('syncEntityBadge').className = 'badge badge-info';
$('syncShopPickWrap').hidden = true;
$('syncProgressWrap').hidden = true;
loadAutoSyncPrefs();
try {
const tree = await jsonFetch('/api/reports/tree');
const badge = $('syncEntityBadge');
badge.textContent = `当前主体:${(tree.entity && tree.entity.name) || (tree.merchant && tree.merchant.name) || '未知'} · 门店 ${(tree.shops || []).length} 家`;
badge.className = 'badge badge-ok';
if (tree.entity && tree.entity.key && tree.entity.key !== state.entity) {
state.entity = tree.entity.key;
LS.set('shanReports.entity', tree.entity.key);
}
await refreshConn();
renderSyncShopOptions(tree.shops || []);
} catch (e) {
const badge = $('syncEntityBadge');
badge.textContent = '未连接客户端:' + e.message;
badge.className = 'badge badge-fail';
try {
const st = await jsonFetch('/api/reports/status');
const napos = st.napos || {};
$('syncHint').textContent = [napos.hint, napos.selfCheckUrl ? `自检:客户端带参启动后,用浏览器打开 ${napos.selfCheckUrl} 应看到一段 JSON。` : '']
.filter(Boolean).join(' ');
} catch { /* 服务不可达时保留默认文案 */ }
}
}
function renderSyncShopOptions(shops) {
state.syncShops.clear();
fillShopMenu($('syncShopMenu'), shops);
renderDropdownSelection($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店');
}
async function startSync() {
const scope = $('syncScope').value;
const days = Number($('syncRange').value) || 30;
const body = { from: daysAgoYmd(days), to: daysAgoYmd(1), scope: scope === 'custom' ? 'shop' : scope };
if (scope === 'custom') {
const shops = Array.from(state.syncShops).filter((v) => v !== '__all__');
if (!shops.length) { showToast('请先选择要同步的门店', 'error'); return; }
body.shopIds = shops;
}
try {
await jsonFetch('/api/reports/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
$('syncProgressWrap').hidden = false;
renderSyncProgress({ planTotal: 0, done: 0, added: 0, updated: 0, failed: 0, steps: [], running: true });
pollSyncStatus();
} catch (e) {
showToast('同步启动失败:' + e.message, 'error');
}
}
// ---------- 导出 ----------
function exportCsv() {
if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
const { from, to } = currentRange();
let url = `/api/reports/export?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`;
if (state.mode !== 'detail') url += `&group=${state.mode}`;
if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
window.location.href = url;
}
function exportHtml() {
if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
const { from, to } = analysisRange();
window.open(`/api/reports/report-html?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`, '_blank');
}
// ---------- 趋势图表 ----------
function chartCol(key) {
return state.columns.find((c) => c.key === key) || { key, label: key, type: 'num' };
}
function ensureChart(instanceKey, domId) {
if (typeof echarts === 'undefined') return null;
const dom = $(domId);
if (!dom) return null;
if (!chartInstances[instanceKey]) chartInstances[instanceKey] = echarts.init(dom);
return chartInstances[instanceKey];
}
function metricColumnOptions() {
return state.columns.filter((c) => ['int', 'money', 'pct', 'num'].includes(c.type) && !c.reserved);
}
function renderChartMetricSelect() {
const sel = $('chartMetric');
if (!sel) return;
sel.innerHTML = '';
for (const c of metricColumnOptions()) {
const opt = document.createElement('option');
opt.value = c.key;
opt.textContent = c.label;
if (c.key === chartMetric) opt.selected = true;
sel.appendChild(opt);
}
if (!sel.value && sel.options.length) sel.value = sel.options[0].value;
chartMetric = sel.value || 'grossAmt';
}
const AXIS_TXT = { axisLabel: { color: '#6b6560' }, axisLine: { lineStyle: { color: '#e8e4df' } } };
const SPLIT_LINE = { lineStyle: { color: '#f0ede8' } };
const ACCENT = '#ff6b35';
async function ensureAnalysisData() {
const { from, to } = analysisRange();
const shopSuffix = state.analysisShopFilter.size
? `&shops=${encodeURIComponent(Array.from(state.analysisShopFilter).join(','))}`
: '';
const key = [state.entity, from, to, state.analysisShopFilter.size ? [...state.analysisShopFilter].sort().join(',') : ''].join('|');
if (anaCache && anaCache.key === key) return anaCache;
const fd = (s) => new Date(Number(String(s).slice(0, 4)), Number(String(s).slice(4, 6)) - 1, Number(String(s).slice(6, 8)));
const spanDays = Math.max(1, Math.round((fd(to) - fd(from)) / 86400000) + 1);
const prevToD = fd(from);
prevToD.setDate(prevToD.getDate() - 1);
const prevTo = ymdLocal(prevToD);
const prevFromD = new Date(prevToD);
prevFromD.setDate(prevFromD.getDate() - (spanDays - 1));
const prevFrom = ymdLocal(prevFromD);
const base = `/api/reports/aggregate?entity=${encodeURIComponent(state.entity)}`;
const [byDate, byDatePrev, byShop, topDaily] = await Promise.all([
jsonFetch(`${base}&from=${from}&to=${to}&group=date${shopSuffix}`).then((d) => d.rows || []),
jsonFetch(`${base}&from=${prevFrom}&to=${prevTo}&group=date${shopSuffix}`).then((d) => d.rows || []),
jsonFetch(`${base}&from=${from}&to=${to}&group=shop${shopSuffix}`).then((d) => d.rows || []),
jsonFetch(`/api/reports/daily?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}&scope=shop${shopSuffix}`).then((d) => d.rows || []),
]);
anaCache = { key, from, to, prevFrom, prevTo, byDate, byDatePrev, byShop, topDaily };
return anaCache;
}
function renderDeltas(data) {
const wrap = $('analysisDeltas');
if (!wrap) return;
wrap.innerHTML = '';
const sum = (rows, f) => rows.reduce((a, r) => a + (Number(r[f]) || 0), 0);
const wAvg = (rows, f, wb) => {
let num = 0, den = 0;
for (const r of rows) { const wt = Number(r[wb]) || 0; num += (Number(r[f]) || 0) * wt; den += wt; }
return den > 0 ? num / den : 0;
};
const curOrders = sum(data.byDate, 'validOrderCnt');
const prevOrders = sum(data.byDatePrev, 'validOrderCnt');
const items = [
{ label: '营业额', cur: sum(data.byDate, 'grossAmt'), prev: sum(data.byDatePrev, 'grossAmt'), fmt: fmtMoney },
{ label: '有效订单', cur: curOrders, prev: prevOrders, fmt: fmtNum },
{ label: '收入', cur: sum(data.byDate, 'incomeAmt'), prev: sum(data.byDatePrev, 'incomeAmt'), fmt: fmtMoney },
{ label: '曝光人数', cur: sum(data.byDate, 'expUv'), prev: sum(data.byDatePrev, 'expUv'), fmt: fmtNum },
{ label: '下单人数', cur: sum(data.byDate, 'orderUserCnt'), prev: sum(data.byDatePrev, 'orderUserCnt'), fmt: fmtNum },
{ label: '进店转化率', cur: wAvg(data.byDate, 'clkExpRate', 'expUv'), prev: wAvg(data.byDatePrev, 'clkExpRate', 'expUv'), fmt: fmtPct },
{ label: '客单价', cur: curOrders > 0 ? sum(data.byDate, 'grossAmt') / curOrders : 0, prev: prevOrders > 0 ? sum(data.byDatePrev, 'grossAmt') / prevOrders : 0, fmt: fmtMoney },
];
for (const it of items) {
const cur = Math.round(it.cur * 100) / 100;
const div = document.createElement('div');
div.className = 'delta-card';
let deltaHtml;
const delta = it.prev > 0 ? (it.cur - it.prev) / it.prev : null;
if (delta == null || !Number.isFinite(delta)) deltaHtml = '上期无数据';
else if (Math.abs(delta) < 0.0001) deltaHtml = '持平';
else {
const up = delta > 0;
deltaHtml = `${up ? '↑' : '↓'} ${Math.abs(delta * 100).toFixed(1)}%`
+ `vs 上期 ${it.fmt(Math.round(it.prev * 100) / 100)}`;
}
div.innerHTML = `${deltaHtml}`;
div.querySelector('.delta-label').textContent = it.label;
div.querySelector('.delta-value').textContent = it.fmt(cur);
wrap.appendChild(div);
}
}
async function renderCharts() {
let data;
try { data = await ensureAnalysisData(); } catch { return; }
renderDeltas(data);
const col = chartCol(chartMetric);
const compareOn = $('anaComparePrev') && $('anaComparePrev').checked;
const trend = ensureChart('trend', 'chartTrend');
if (trend) {
const cur = data.byDate.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
const prev = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
const xData = cur.map((r) => ymdToDash(r.date));
const series = [{
name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5,
data: cur.map((r) => Number(r[chartMetric]) || 0),
itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 },
areaStyle: { color: 'rgba(255,107,53,.08)' },
}];
if (compareOn && prev.length) {
series.push({
name: '上期同期', type: 'line', smooth: true,
data: xData.map((_, i) => (prev[i] ? Number(prev[i][chartMetric]) || 0 : 0)),
itemStyle: { color: '#a5a09a' }, lineStyle: { type: 'dashed', width: 1.6 }, symbol: 'none',
});
}
trend.setOption({
tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
legend: { data: series.map((x) => x.name), top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
grid: { left: 64, right: 24, top: 36, bottom: 30 },
xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
series,
}, true);
const title = $('anaTrendTitle');
if (title) title.textContent = `主指标趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`;
}
const rank = ensureChart('rank', 'chartRank');
if (rank) {
const top = data.byShop.slice()
.sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
.slice(0, 10).reverse();
rank.setOption({
tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
yAxis: {
type: 'category', data: top.map((r) => r.shopName || r.shopId),
axisLabel: { color: '#2b2a28', width: 130, overflow: 'truncate' },
axisLine: { lineStyle: { color: '#e8e4df' } },
},
series: [{ type: 'bar', barMaxWidth: 18, data: top.map((r) => Number(r[chartMetric]) || 0), itemStyle: { color: ACCENT, borderRadius: [0, 6, 6, 0] } }],
}, true);
}
const funnel = ensureChart('funnel', 'chartFunnel');
if (funnel) {
const sum = (f) => data.byDate.reduce((a, r) => a + (Number(r[f]) || 0), 0);
const expUv = sum('expUv');
const enter = data.byDate.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0);
const order = sum('orderUserCnt');
funnel.setOption({
tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${fmtNum(Math.round(p.value))} 人` },
series: [{
type: 'funnel', left: '8%', width: '84%', top: 12, bottom: 8, minSize: '20%',
label: { color: '#fff', fontWeight: 600, formatter: (p) => `${p.name} ${fmtNum(Math.round(p.value))}` },
data: [
{ name: '曝光人数', value: Math.round(expUv), itemStyle: { color: '#ffb199' } },
{ name: '进店人数', value: Math.round(enter), itemStyle: { color: '#ff8a5c' } },
{ name: '下单人数', value: Math.round(order), itemStyle: { color: ACCENT } },
],
}],
}, true);
}
const compare = ensureChart('compare', 'chartCompare');
if (compare) {
const top5 = data.byShop.slice()
.sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
.slice(0, 5);
const dates = [...new Set(data.topDaily.map((r) => r.date))].sort();
const palette = ['#ff6b35', '#2563eb', '#1a8a4c', '#b8860b', '#8b5cf6'];
const series = top5.map((shop, i) => ({
name: shop.shopName || shop.shopId,
type: 'line', smooth: true, symbolSize: 4,
data: dates.map((d) => {
const r = data.topDaily.find((x) => x.date === d && String(x.shopId) === String(shop.shopId));
return r ? (Number(r[chartMetric]) || 0) : 0;
}),
itemStyle: { color: palette[i % palette.length] }, lineStyle: { width: 2 },
}));
compare.setOption({
tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
legend: { top: 0, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
grid: { left: 64, right: 24, top: 36, bottom: 30 },
xAxis: { type: 'category', boundaryGap: false, data: dates.map(ymdToDash), ...AXIS_TXT },
yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
series,
}, true);
const title = $('anaCompareTitle');
if (title) title.textContent = `门店对比 · Top ${top5.length}(${col.label})`;
}
}
// ---------- 智能问数 · 机器人(仅经营分析页面) ----------
// 对话历史按主体持久化到 localStorage,刷新/重开后可继续追问
const CHAT_LS_KEY = 'shanReports.chat.v1';
const chatStoreKey = () => state.entity || '_';
function loadChatForEntity() {
const all = LS.get(CHAT_LS_KEY, {});
state.chat.messages = Array.isArray(all[chatStoreKey()]) ? all[chatStoreKey()] : [];
if (state.chat.open) renderChatMessages();
}
function saveChatForEntity() {
const key = chatStoreKey();
const all = LS.get(CHAT_LS_KEY, {});
const trimmed = state.chat.messages.filter((m) => !m.pending).slice(-20);
const attempt = (payload) => {
try { localStorage.setItem(CHAT_LS_KEY, JSON.stringify(payload)); return true; } catch { return false; }
};
if (attempt({ ...all, [key]: trimmed })) return;
// 图表行数据可能撑爆配额 → 退化为只存文字与图表配置
const slim = { ...all, [key]: trimmed.map((m) => ({ ...m, rows: undefined })) };
if (!(attempt(slim) || attempt({ [key]: slim[key].slice(-10) }))) { /* 配额不足则放弃持久化 */ }
}
function chatVisibleFor(view) { return view === 'reports' || view === 'analysis'; }
function setChatOpen(open) {
state.chat.open = open;
$('chatbotPanel').hidden = !open;
$('chatbotFab').classList.toggle('active', open);
if (open) {
loadChatForEntity();
$('chatbotFab').hidden = false;
setTimeout(() => $('chatbotInput').focus(), 60);
scrollChatBottom();
}
}
function scrollChatBottom() {
const body = $('chatbotBody');
body.scrollTop = body.scrollHeight;
}
function renderChatMessages() {
const body = $('chatbotBody');
[...body.querySelectorAll('.chatbot-msg:not(.static)')].forEach((n) => n.remove());
body.querySelectorAll('.chatbot-chips').forEach((n) => { n.hidden = state.chat.messages.length > 0; });
for (let i = 0; i < state.chat.messages.length; i++) {
const m = state.chat.messages[i];
const div = document.createElement('div');
div.className = 'chatbot-msg ' + (m.role === 'user' ? 'user' : 'bot');
if (m.pending) {
div.innerHTML = '
';
} else {
const bubble = document.createElement('div');
bubble.className = 'chatbot-bubble';
bubble.textContent = m.text || '';
div.appendChild(bubble);
if (m.chart && m.chart.type !== 'none' && m.rows && m.rows.length) {
const chartWrap = document.createElement('div');
chartWrap.className = 'chatbot-chart';
chartWrap.dataset.idx = String(i);
div.appendChild(chartWrap);
const actions = document.createElement('div');
actions.className = 'chatbot-msg-actions';
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'btn btn-ghost btn-small';
applyBtn.textContent = '应用到报表';
applyBtn.addEventListener('click', () => applyMsgQuery(i));
actions.appendChild(applyBtn);
div.appendChild(actions);
}
}
body.appendChild(div);
}
scrollChatBottom();
// 渲染消息内图表
state.chat.messages.forEach((m, i) => {
if (m.pending || !m.chart || m.chart.type === 'none' || !m.rows || !m.rows.length) return;
const wrap = body.querySelector(`.chatbot-chart[data-idx="${i}"]`);
if (!wrap) return;
const col = (key) => (state.columns.find((c) => c.key === key) || { label: key, type: 'num' });
const isDate = m.chart.x === 'date';
const rows = m.rows.slice();
if (isDate) rows.sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1));
const chart = echarts.init(wrap);
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 56, right: 16, top: 20, bottom: m.chart.type === 'bar' && !isDate ? 52 : 24 },
xAxis: {
type: 'category',
data: rows.map((r) => (isDate ? ymdToDash(r.date) : (r.shopName || r.shopId))),
axisLabel: { color: '#6b6560', interval: 0, rotate: m.chart.type === 'bar' && !isDate ? 30 : 0, width: 80, overflow: 'truncate' },
axisLine: { lineStyle: { color: '#e8e4df' } },
},
yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { lineStyle: { color: '#f0ede8' } } },
series: (m.chart.y || []).map((key, yi) => {
const c = col(key);
return {
name: c.label || key,
type: m.chart.type === 'line' ? 'line' : 'bar',
smooth: true, barMaxWidth: 26,
data: rows.map((r) => {
const v = r[key];
if (v == null || v === '') return 0;
const n = Number(v);
return c.type === 'pct' ? +(n * 100).toFixed(2) : n;
}),
itemStyle: { color: yi === 0 ? '#ff6b35' : '#2563eb' },
};
}),
});
setTimeout(() => chart.resize(), 40);
});
}
function applyMsgQuery(idx) {
const m = state.chat.messages[idx];
const a = m && m.applied;
if (!a) return;
state.mode = ['detail', 'shop', 'date'].includes(a.mode) ? a.mode : 'shop';
$('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === state.mode));
state.preset = 'custom';
$('reportRangePreset').value = 'custom';
$('reportCustomRange').hidden = false;
$('reportFromDate').value = ymdToDash(a.from);
$('reportToDate').value = ymdToDash(a.to);
state.shopFilter = new Set((a.shopIds || []).map(String));
renderShopOptions();
loadReport();
const nav = document.querySelector('.nav-item[data-view="reports"]');
if (nav) nav.click();
}
async function sendChat(text) {
const q = String(text || '').trim();
if (!q || state.chat.busy) return;
state.chat.busy = true;
state.chat.messages.push({ role: 'user', text: q });
state.chat.messages.push({ role: 'assistant', pending: true });
renderChatMessages();
$('chatbotInput').value = '';
try {
const history = state.chat.messages
.filter((m) => !m.pending && (m.role === 'user' || (m.role === 'assistant' && m.text)))
.slice(0, -1)
.slice(-8)
.map((m) => ({ role: m.role, content: m.role === 'assistant' ? `${m.text}${m.applied ? `(已查询:mode=${m.applied.mode} ${m.applied.from}~${m.applied.to}${m.applied.shopIds && m.applied.shopIds.length ? ' 门店 ' + m.applied.shopIds.join(',') : ''})` : ''}` : m.text }));
const result = await jsonFetch('/api/reports/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity: state.entity, q, history }),
});
const pendingIdx = state.chat.messages.findIndex((m) => m.pending);
const msg = {
role: 'assistant',
text: result.reply || '(模型未给出文字回答)',
chart: result.chart,
rows: result.rows,
applied: result.applied,
chartTitle: result.chartTitle,
};
if (pendingIdx >= 0) state.chat.messages.splice(pendingIdx, 1, msg);
else state.chat.messages.push(msg);
saveChatForEntity();
} catch (e) {
const pendingIdx = state.chat.messages.findIndex((m) => m.pending);
const errMsg = { role: 'assistant', text: '出错了:' + e.message, error: true };
if (pendingIdx >= 0) state.chat.messages.splice(pendingIdx, 1, errMsg);
else state.chat.messages.push(errMsg);
} finally {
state.chat.busy = false;
renderChatMessages();
}
}
// ---------- 事件绑定 ----------
function bindEvents() {
$('btnReportRefresh').addEventListener('click', async () => {
await refreshConn();
await loadReport();
showToast('已刷新');
});
$('btnClientHelp').addEventListener('click', (event) => {
event.stopPropagation();
const pop = $('clientHelpPopover');
pop.hidden = !pop.hidden;
$('btnClientHelp').classList.toggle('active', !pop.hidden);
$('btnClientHelp').setAttribute('aria-expanded', String(!pop.hidden));
});
document.addEventListener('click', (event) => {
const pop = $('clientHelpPopover');
if (!pop.hidden && !event.target.closest('.conn-help-wrap')) {
pop.hidden = true;
$('btnClientHelp').classList.remove('active');
$('btnClientHelp').setAttribute('aria-expanded', 'false');
}
});
$('btnOpenSync').addEventListener('click', openSyncModal);
$('btnCloseSync').addEventListener('click', () => {
$('modalReportSync').hidden = true;
if (state.syncPollTimer) { clearInterval(state.syncPollTimer); state.syncPollTimer = null; }
$('syncProgressWrap').hidden = true;
});
$('btnCancelSync').addEventListener('click', () => $('modalReportSync').hidden = true);
$('btnStartSync').addEventListener('click', startSync);
$('btnSyncCancel').addEventListener('click', async () => {
try { await jsonFetch('/api/reports/sync/cancel', { method: 'POST' }); showToast('已请求取消,等待当前步骤结束…'); } catch { /* ignore */ }
});
$('syncScope').addEventListener('change', () => {
$('syncShopPickWrap').hidden = $('syncScope').value !== 'custom';
});
$('autoSyncEnabled').addEventListener('change', saveAutoSyncPrefs);
$('autoSyncTime').addEventListener('change', saveAutoSyncPrefs);
$('syncShopTrigger').addEventListener('click', () => toggleDropdown('syncShopMenu', 'syncShopTrigger'));
$('syncShopMenu').addEventListener('click', makeDropdownHandler($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店'));
document.addEventListener('click', (event) => {
if (!$('syncShopDropdown').contains(event.target)) toggleDropdown('syncShopMenu', 'syncShopTrigger', false);
});
$('reportRangePreset').addEventListener('change', () => {
state.preset = $('reportRangePreset').value;
$('reportCustomRange').hidden = state.preset !== 'custom';
loadReport();
});
$('reportFromDate').addEventListener('change', loadReport);
$('reportToDate').addEventListener('change', loadReport);
$('reportShopTrigger').addEventListener('click', () => toggleDropdown('reportShopMenu', 'reportShopTrigger'));
$('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', loadReport));
document.addEventListener('click', (event) => {
if (!$('reportShopDropdown').contains(event.target)) toggleDropdown('reportShopMenu', 'reportShopTrigger', false);
});
$('reportViewModes').addEventListener('click', (event) => {
const btn = event.target.closest('button[data-mode]');
if (!btn) return;
state.mode = btn.dataset.mode;
$('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
state.sortKey = state.mode === 'detail' ? 'date' : (state.mode === 'shop' ? 'shopName' : 'date');
state.sortDir = 'desc';
loadReport();
});
let searchTimer = null;
$('reportSearch').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.q = $('reportSearch').value.trim();
loadReport();
}, 300);
});
$('btnColumnCfg').addEventListener('click', () => {
renderColumnCfg();
$('modalReportColumns').hidden = false;
});
$('btnCloseColumnCfg').addEventListener('click', () => $('modalReportColumns').hidden = true);
$('btnApplyColumns').addEventListener('click', applyColumnCfg);
$('btnResetColumns').addEventListener('click', () => {
state.visible = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
LS.set('shanReports.columns.v1', Array.from(state.visible));
renderColumnCfg();
renderTable();
});
$('btnExportCsv').addEventListener('click', exportCsv);
$('btnExportHtml').addEventListener('click', exportHtml);
$('reportTableHead').addEventListener('click', (event) => {
const th = event.target.closest('th[data-key]');
if (!th) return;
const key = th.dataset.key;
if (state.sortKey === key) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
else { state.sortKey = key; state.sortDir = 'desc'; }
renderTable();
});
// 趋势图表
$('analysisRangePreset').addEventListener('change', () => {
state.analysisPreset = $('analysisRangePreset').value;
anaCache = null;
renderCharts();
});
$('anaShopTrigger').addEventListener('click', () => toggleDropdown('anaShopMenu', 'anaShopTrigger'));
$('anaShopMenu').addEventListener('click', makeDropdownHandler($('anaShopMenu'), state.analysisShopFilter, $('anaShopText'), '全部门店', () => {
anaCache = null;
renderCharts();
}));
document.addEventListener('click', (event) => {
if (!$('anaShopDropdown').contains(event.target)) toggleDropdown('anaShopMenu', 'anaShopTrigger', false);
});
$('chartMetric').addEventListener('change', () => {
chartMetric = $('chartMetric').value || 'grossAmt';
LS.set('shanReports.chartMetric', chartMetric);
renderCharts();
});
$('anaComparePrev').addEventListener('change', renderCharts);
// 智能问数机器人
$('chatbotFab').addEventListener('click', () => setChatOpen(!state.chat.open));
$('btnChatClose').addEventListener('click', () => setChatOpen(false));
$('btnChatClear').addEventListener('click', () => {
state.chat.messages = [];
const all = LS.get(CHAT_LS_KEY, {});
delete all[state.entity || '_'];
LS.set(CHAT_LS_KEY, all);
renderChatMessages();
});
$('btnChatSend').addEventListener('click', () => sendChat($('chatbotInput').value));
$('chatbotInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') sendChat($('chatbotInput').value);
});
$('chatbotChips').addEventListener('click', (event) => {
const chip = event.target.closest('[data-q]');
if (chip) sendChat(chip.dataset.q);
});
window.addEventListener('resize', () => {
Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
});
}
// ---------- 初始化 ----------
window.shanReportsInit = async function () {
if (!state.inited) {
state.inited = true;
bindEvents();
try {
const data = await jsonFetch('/api/reports/columns');
state.columns = data.columns || [];
} catch { state.columns = []; }
loadColumnPrefs();
renderChartMetricSelect();
}
await refreshConn();
await loadReport();
};
window.shanAnalysisActivate = async function () {
await window.shanReportsInit();
renderChartMetricSelect();
await renderCharts();
Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
};
window.shanSetAnalysisVisible = function (visible) {
const fab = $('chatbotFab');
if (!fab) return;
fab.hidden = !visible;
if (!visible) setChatOpen(false);
};
})();