'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, page: 1, pageSize: 200, total: 0, pages: 1, 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 = ``; 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(); reloadReport(); renderCharts(); refreshPushBadge(); // 待推计数按主体算,切主体要重取 }); 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(); }; } // ---------- 报表表格(数据采集页) ---------- 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'); // 全部字段模式列多且长:列宽随内容、容器横向滚动,避免 60 列挤成一团 $('reportTable').classList.toggle('raw-cols', state.rawView); $('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 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 `${fmtCell(c, display)}`; }).join('') + '').join(''); } // ---------- 数据加载(数据采集页) ---------- async function loadReport() { const entity = state.entity; if (!entity) { state.rows = []; state.summary = null; renderTable(); renderPager(); 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'}&page=${state.page}&pageSize=${state.pageSize}` : `/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; state.total = state.mode === 'detail' ? (data.total ?? state.rows.length) : state.rows.length; state.pages = state.mode === 'detail' ? (data.pages ?? 1) : 1; renderTable(); renderPager(); } catch (e) { if (e.name === 'AbortError') return; showToast('报表加载失败:' + e.message, 'error'); } } // 筛选/主体/视图等变化:回到第一页再加载 function reloadReport() { state.page = 1; loadReport(); } // 分页条:明细模式服务端分页(排序作用于当前页);聚合模式行数少不分页 function renderPager() { const el = $('reportPager'); if (!el) return; if (state.mode !== 'detail' || state.pages <= 1) { el.innerHTML = state.mode === 'detail' ? `共 ${state.total} 行` : ''; return; } const from = (state.page - 1) * state.pageSize + 1; const to = Math.min(state.total, state.page * state.pageSize); const btn = (label, target, disabled) => ``; el.innerHTML = ` ${btn('‹ 上一页', state.page - 1, state.page <= 1)} 第 ${state.page} / ${state.pages} 页 · ${from}-${to} 行 · 共 ${state.total} 行 ${btn('下一页 ›', state.page + 1, state.page >= state.pages)} 列排序作用于当前页 `; } function changePage(page) { if (page < 1 || page > state.pages || page === state.page) return; state.page = page; loadReport(); const wrap = $('reportTableWrap'); if (wrap && wrap.scrollIntoView) wrap.scrollIntoView({ block: 'nearest' }); } // ---------- 连接状态 ---------- 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 = `${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.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 `
${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; } // 全部字段模式:行数据只在客户端(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 = '上期无数据'; 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(); state.page = 1; 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'; reloadReport(); }); $('reportFromDate').addEventListener('change', loadReport); $('reportToDate').addEventListener('change', loadReport); $('reportShopTrigger').addEventListener('click', () => toggleDropdown('reportShopMenu', 'reportShopTrigger')); $('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', reloadReport)); 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'; reloadReport(); }); // 字段模式:报表列(语义列)/ 全部字段(明细 + 全部原始字段;聚合视图无 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(); reloadReport(); }, 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 = '暂无主体'; return; } const entityKey = encodeURIComponent(state.entity); Promise.all([ jsonFetch(`/api/reports/syncs?entity=${entityKey}`), jsonFetch(`/api/reports/aitable/status?entity=${entityKey}`).catch(() => null), ]) .then(([data, cloud]) => { const runs = (data.runs || []).slice(0, 8); if (!runs.length) { tbody.innerHTML = '暂无同步记录'; return; } const lastPush = cloud && cloud.lastPush ? cloud.lastPush : null; const pushAt = lastPush ? new Date(lastPush.at || lastPush.lastPushedAt || 0).getTime() : 0; 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')}` : '–'; // 该次同步之后是否推送过云端(以最近一次推送时间为准;更早的同步被其覆盖归档) const pushed = pushAt && t && pushAt >= t.getTime(); const cloudCell = !cloud || !cloud.enabled ? '未启用' : (pushed ? `已推 ${lastPush.pushedRows || 0} 行` : '未推送'); return ` ${ts} ${(r.from || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')} ~ ${(r.to || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')} ${SCOPE_LABEL[r.scope] || r.scope || '–'} ${r.added ?? 0} ${r.updated ?? 0} ${r.failed ?? 0} ${note} ${cloudCell} `; }).join(''); }) .catch(() => { tbody.innerHTML = '同步历史加载失败'; }); } // ---------- 初始化 ---------- // 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 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 entity = state.entity || (state.entities[0] || {}).key; const res = await fetch(`/api/reports/aitable/status${entity ? `?entity=${encodeURIComponent(entity)}` : ''}`); 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 if (typeof data.pending === 'number' && data.pending > 0) { badge.textContent = `待推送 ${data.pending} 行`; badge.title = '点「推送云端」会把上次推送之后的全部增量一次性推上钉钉 AI 表格'; badge.className = 'badge badge-warn'; } else { badge.textContent = '已同步至云端'; badge.title = ''; badge.className = 'badge badge-ok'; } } catch { /* 状态刷新失败不打扰 */ } } window.shanReportsInit = async function () { if (!state.inited) { state.inited = true; bindEvents(); $('btnReportPush').addEventListener('click', async () => { await pushCurrentEntity(); renderSyncHistory(); // 云端推送状态列随推送结果刷新 refreshPushBadge(); // 待推计数归零(或显示剩余) }); $('reportPager').addEventListener('click', (event) => { const btn = event.target.closest('button[data-page]'); if (btn) changePage(Number(btn.dataset.page)); }); 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); }; })();