| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285 |
- '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',
- rawView: false,
- 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', 'lakeEntityTabs']) {
- 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 = `<span class="entity-name"></span><span class="entity-meta"></span>`;
- 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 = '<span class="scan-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><span>全部门店</span>';
- 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 = '<span class="scan-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><span></span>';
- 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 = `<span class="metric-body"><span class="metric-label"></span><span class="metric-value ${c.rate ? rateClass(c.rate, s[c.rate]) : ''}"></span></span>`;
- 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' && c.key !== 'account' && state.visible.has(c.key));
- }
- function tableColumns() {
- // 全部字段模式(数据中台):固定业务列 + 从首行 raw 发现的全部原始字段(原始 API 字段名)
- if (state.rawView) {
- const base = [
- { key: 'account', label: '账号名', type: 'text' },
- { key: 'date', label: '日期', type: 'date' },
- { key: 'scope', label: '维度', type: 'text' },
- { key: 'shopId', label: '门店ID', type: 'text' },
- { key: 'shopName', label: '门店名称', type: 'text' },
- ];
- const rawCols = [];
- const seen = new Set(base.map((c) => c.label));
- for (const r of state.rows) {
- const raw = r && r.raw;
- if (!raw || typeof raw !== 'object') continue;
- for (const k of Object.keys(raw)) {
- if (seen.has(k)) continue;
- seen.add(k);
- const sample = raw[k];
- const isNum = typeof sample === 'number' || (typeof sample === 'string' && sample !== '' && Number.isFinite(Number(sample)));
- rawCols.push({ key: `raw.${k}`, label: k, type: isNum ? 'num' : 'text', raw: true });
- }
- }
- return [...base, ...rawCols];
- }
- const metricCols = metricColumns();
- const accountCol = { key: 'account', label: '账号名', type: 'text' };
- if (state.mode === 'shop') return [accountCol, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
- if (state.mode === 'date') return [accountCol, { key: 'date', label: '日期', type: 'date' }, ...metricCols];
- return [accountCol, { key: 'date', label: '日期', type: 'date' }, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
- }
- // 取单元格值:raw. 前缀深入原始字段对象;对象/数组序列化展示
- function cellValue(r, key) {
- if (!key.startsWith('raw.')) return r[key];
- const raw = r && r.raw;
- if (!raw || typeof raw !== 'object') return undefined;
- return raw[key.slice(4)];
- }
- function sortedRows() {
- return state.rows.slice().sort((a, b) => {
- const av = cellValue(a, state.sortKey), bv = cellValue(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 = '<tr>' + cols.map((c) => {
- const active = state.sortKey === c.key;
- const arrow = active ? (state.sortDir === 'asc' ? ' ↑' : ' ↓') : '';
- return `<th class="col-${c.type}${['money', 'int', 'pct', 'num'].includes(c.type) ? ' col-num' : ''}" data-key="${c.key}">${c.label}${arrow}</th>`;
- }).join('') + '</tr>';
- tbody.innerHTML = sortedRows().map((r) => '<tr>' + cols.map((c) => {
- const isNum = ['money', 'int', 'pct', 'num'].includes(c.type);
- const v = cellValue(r, c.key);
- const display = (v != null && typeof v === 'object') ? JSON.stringify(v) : v;
- const cls = `col-${c.type}${isNum ? ' col-num' : ''}${c.type === 'pct' ? ' ' + rateClass(c.key, v) : ''}`;
- return `<td class="${cls}">${fmtCell(c, display)}</td>`;
- }).join('') + '</tr>').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}${state.rawView ? '' : '&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() {
- // 默认只展示原始/主指标列(营业时长、异常关店时长、商品销售额为预留列,数据源接入后自动回填);
- // 下单转化率、客单价、支出、活动订单、活动补贴、推广ROI、30日复购率等派生指标默认隐藏,可在列设置打开。
- const DEFAULT_VISIBLE_COLS = new Set([
- 'bizHours', 'abnormalCloseHours', 'validOrderCnt', 'incomeAmt', 'grossAmt',
- 'goodsSaleAmt', 'expUv', 'orderUserCnt', 'clkExpRate',
- ]);
- const saved = LS.get('shanReports.columns.v2', null);
- const defaults = new Set(state.columns.filter((c) => DEFAULT_VISIBLE_COLS.has(c.key)).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 = `<input type="checkbox" value="${c.key}" ${checked}><span></span>${c.reserved ? '<em class="colcfg-reserved">预留</em>' : ''}`;
- 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.v2', 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.rows != null ? ` · ${s.rows}行` : '') + (s.nonZero === 0 && s.rows > 0 ? ' · ⚠全零' : '')
- : (s.error || '失败').slice(0, 60);
- return `<div class="sync-step ${cls}"><span></span><em>${detail}</em></div>`;
- }).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; }
- // 全部字段模式:行数据只在客户端(raw 列由前端发现),就地生成 CSV
- if (state.rawView) {
- const cols = tableColumns();
- const cell = (v) => {
- const s = (v == null || typeof v !== 'object') ? String(v ?? '') : JSON.stringify(v);
- return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
- };
- const lines = [cols.map((c) => cell(c.label)).join(',')];
- for (const r of sortedRows()) lines.push(cols.map((c) => cell(cellValue(r, c.key))).join(','));
- const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `湖明细-全部字段-${state.entity}.csv`;
- a.click();
- URL.revokeObjectURL(a.href);
- 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 = '<span class="delta-none">上期无数据</span>';
- else if (Math.abs(delta) < 0.0001) deltaHtml = '<span class="delta-flat">持平</span>';
- else {
- const up = delta > 0;
- deltaHtml = `<span class="delta ${up ? 'up' : 'down'}">${up ? '↑' : '↓'} ${Math.abs(delta * 100).toFixed(1)}%</span>`
- + `<span class="delta-sub">vs 上期 ${it.fmt(Math.round(it.prev * 100) / 100)}</span>`;
- }
- div.innerHTML = `<span class="delta-label"></span><span class="delta-value"></span><span class="delta-line">${deltaHtml}</span>`;
- 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 = '<div class="chatbot-bubble typing"><span></span><span></span><span></span></div>';
- } 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();
- // 明细浏览在数据中台页:先切到明细 tab,再导航过去
- if (window.shanLakeShowDetail) window.shanLakeShowDetail();
- const nav = document.querySelector('.nav-item[data-view="lake"]');
- 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();
- });
- // 字段模式:报表列(语义列)/ 全部字段(明细 + 全部原始字段;聚合视图无 raw,自动回明细)
- $('reportFieldModes').addEventListener('click', (event) => {
- const btn = event.target.closest('button[data-fields]');
- if (!btn) return;
- state.rawView = btn.dataset.fields === 'all';
- $('reportFieldModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
- if (state.rawView && state.mode !== 'detail') {
- state.mode = 'detail';
- state.sortKey = 'date';
- state.sortDir = 'desc';
- $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === 'detail'));
- }
- $('reportViewModes').querySelectorAll('button').forEach((b) => { b.disabled = state.rawView; });
- $('btnColumnCfg').hidden = state.rawView; // 列设置只作用于报表列模式
- state.sortKey = state.rawView ? 'date' : state.sortKey;
- renderTable();
- });
- 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.v2', 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(); });
- });
- }
- // ---------- 同步历史(数据采集页) ----------
- function renderSyncHistory() {
- const tbody = $('syncHistoryRows');
- if (!tbody) return;
- if (!state.entity) {
- tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">暂无主体</td></tr>';
- return;
- }
- jsonFetch(`/api/reports/syncs?entity=${encodeURIComponent(state.entity)}`)
- .then((data) => {
- const runs = (data.runs || []).slice(0, 8);
- if (!runs.length) {
- tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">暂无同步记录</td></tr>';
- return;
- }
- const SCOPE_LABEL = { all: '全主体', chain: '按连锁', shop: '按门店' };
- tbody.innerHTML = runs.map((r) => {
- const warn = (r.steps || []).filter((s) => !s.ok).length;
- const note = r.cancelled ? '已取消' : (r.failed ? `${r.failed} 失败` : (warn ? `${warn} 步骤告警(含全零)` : '成功'));
- const cls = r.cancelled || r.failed ? 'badge-fail' : (warn ? 'badge-warn' : 'badge-ok');
- const t = r.at ? new Date(r.at) : null;
- const ts = t ? `${t.getMonth() + 1}/${t.getDate()} ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '–';
- return `<tr>
- <td>${ts}</td>
- <td>${(r.from || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')} ~ ${(r.to || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')}</td>
- <td>${SCOPE_LABEL[r.scope] || r.scope || '–'}</td>
- <td class="num">${r.added ?? 0}</td>
- <td class="num">${r.updated ?? 0}</td>
- <td class="num">${r.failed ?? 0}</td>
- <td><span class="badge ${cls}">${note}</span></td>
- </tr>`;
- }).join('');
- })
- .catch(() => {
- tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">同步历史加载失败</td></tr>';
- });
- }
- // ---------- 初始化 ----------
- // 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)
- async function pushCurrentEntity() {
- const badge = $('reportPushBadge');
- const entity = state.entity || (state.entities[0] || {}).key;
- if (!entity) {
- badge.textContent = '暂无主体';
- badge.className = 'badge badge-fail';
- return;
- }
- if (window.shanAitableAction) {
- try {
- await window.shanAitableAction('push', 'reportPushBadge', { entity });
- } catch { /* 徽标已由 aitableAction 写入 */ }
- }
- }
- async function refreshPushBadge() {
- const badge = $('reportPushBadge');
- if (!badge) return;
- try {
- const res = await fetch('/api/reports/aitable/status');
- const data = await res.json();
- if (!data.enabled) {
- badge.textContent = '云端未启用';
- badge.className = 'badge badge-info';
- } else if (!data.configured) {
- badge.textContent = '云端配置不完整';
- badge.className = 'badge badge-fail';
- } else {
- badge.textContent = '云端就绪';
- badge.className = 'badge badge-ok';
- }
- } catch { /* 状态刷新失败不打扰 */ }
- }
- window.shanReportsInit = async function () {
- if (!state.inited) {
- state.inited = true;
- bindEvents();
- $('btnReportPush').addEventListener('click', pushCurrentEntity);
- try {
- const data = await jsonFetch('/api/reports/columns');
- state.columns = data.columns || [];
- } catch { state.columns = []; }
- loadColumnPrefs();
- renderChartMetricSelect();
- }
- await refreshConn();
- refreshPushBadge();
- renderSyncHistory();
- 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);
- };
- })();
|