/* 门店检测工作台 —— 前端逻辑 数据全部来自后端 API:/api/dashboard、/api/thresholds、/api/import、/api/export.csv 前端不做检测计算,只负责渲染,规则唯一来源在 backend/rules.py */ 'use strict'; const PAGE_SIZE = 10; // 每页条数 const state = { period: null, kpi: 'ALL', city: '', cat: '', score: '', q: '', sort: 'sev', order: 'desc', page: 0, rows: [], data: null, thresholds: null, defaults: null, texts: null, textDefaults: null, }; const $ = (id) => document.getElementById(id); const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/ (v == null ? '-' : (v * 100).toFixed(d) + '%'); const money = (v) => Math.round(v || 0).toLocaleString(); const cnt = (v) => String(Math.round(v || 0)); // 计数类:不带千分位 function toast(msg, ms) { const t = $('toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(t._tm); t._tm = setTimeout(() => t.classList.remove('on'), ms || 2400); } async function api(path, opt) { const r = await fetch(path, opt); const ct = r.headers.get('content-type') || ''; const body = ct.includes('json') ? await r.json() : await r.text(); if (r.status === 401) { location.replace('/login.html'); throw new Error('请先登录'); } if (!r.ok) throw new Error((body && body.error) || ('请求失败 ' + r.status)); return body; } /* ---------------- 当前账号与权限 ---------------- */ function can(perm) { return !!(state.me && (state.me.perms || []).includes(perm)); } /* 按权限显示/隐藏功能入口 */ function applyPerms() { const map = { btnTh: 'config', btnText: 'config', btnImport: 'import', btnSync: 'sync', btnExportCur: 'export', btnUsers: 'users', }; Object.keys(map).forEach(id => { const el = $(id); if (el) el.style.display = can(map[id]) ? '' : 'none'; }); if (state.me) { const nm = state.me.display_name || state.me.username; if ($('whoName')) $('whoName').textContent = nm; if ($('whoRole')) $('whoRole').textContent = state.me.role_label; if ($('whoAvatar')) $('whoAvatar').textContent = nm.slice(0, 1); } if (state.me && state.me.must_change) showChangePw(true); } /* 强制/主动修改密码 */ function showChangePw(forced) { closePanels(); const old = $('pwWrap'); if (old) old.remove(); const wrap = document.createElement('div'); wrap.id = 'pwWrap'; wrap.className = 'cfwrap'; wrap.innerHTML = '
' + '
' + (forced ? '请先修改初始密码' : '修改密码') + '' + (forced ? '' : '×') + '
' + '
' + (forced ? '
出于安全考虑,初始密码必须先改掉才能继续使用。
' : '') + (forced ? '' : '
原密码' + '
') + '
新密码' + '
' + '
再输一次' + '
' + '
至少 6 位,不能全是数字。
' + '' + '
' + '
' + (forced ? '' : '') + '' + '
' + '
'; document.body.appendChild(wrap); requestAnimationFrame(() => wrap.classList.add('on')); if (!forced && $('pwX')) $('pwX').onclick = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); }; if ($('pwCancel')) $('pwCancel').onclick = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); }; $('pwOk').onclick = async () => { const n1 = $('pwNew').value, n2 = $('pwNew2').value; const box = $('pwErr'); const fail = m => { box.textContent = m; box.style.display = 'block'; }; if (n1 !== n2) return fail('两次输入的新密码不一样'); if (n1.length < 6) return fail('密码至少 6 位'); if (/^\d+$/.test(n1)) return fail('密码不能全是数字'); try { await api('/api/me', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ password: n1, old_password: forced ? null : $('pwOld').value }) }); if (state.me) state.me.must_change = false; wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); toast('密码已修改,下次请用新密码登录'); } catch (e) { fail(e.message); } }; } /* ---------------- 加载 ---------------- */ async function load(period) { const q = period ? '?period=' + encodeURIComponent(period) : ''; try { const d = await api('/api/dashboard' + q); state.data = d; state.thresholds = d.thresholds; state.defaults = d.defaults; state.texts = d.texts; state.textDefaults = d.text_defaults; state.period = d.period; (d.stores || []).forEach(st => decorateStore(st, d.texts)); setupPeriods(d.periods, d.period); renderAll(); } catch (e) { toast('加载失败:' + e.message, 4000); } } function setupPeriods(list, current) { const sel = $('period'); sel.innerHTML = (list || []).map(p => { const tag = p.granularity === 'snapshot' ? (p.label || ('近' + (p.days || 30) + '天 · 截至 ' + p.period)) : p.granularity === 'daily' ? (p.period + ' · 日更') : (p.period + ' · 月度汇总'); return ''; }).join('') || ''; if (current) sel.value = current; } function renderAll() { const d = state.data; const empty = !d || d.empty; $('empty').hidden = !empty; document.querySelectorAll('.kpis,.charts,.bar,.panel').forEach(el => el.style.display = empty ? 'none' : ''); $('ruleNote').textContent = d && d.customized ? '规则:自定义考核指标(默认值来自《门店考核指标》)' : '规则来源:《门店考核指标》官方基准线'; const cur = d && !d.empty && (d.periods || []).find(x => x.period === d.period); $('dataNote').textContent = d && !d.empty ? ((cur && cur.window_start) ? (cur.window_start + ' ~ ' + cur.window_end + ' · ') : (d.period + ' 周期 · ')) + d.stores.length + ' 家门店 · 导入批次 ' + d.stats.batches : ''; $('btnTh').classList.toggle('mod', !!(d && d.customized)); if (empty) return; renderKpis(); renderCharts(); renderFilters(); renderTable(); } /* 后端只下发「模板键 + 变量」,这里按当前文案模板还原成文本。 好处:同一个问题不再被传 4 遍(problems/flags/primary/secondary/actions)。 */ function decorateStore(s, X) { const problems = (s.problems || []).map(p => { const vars = p.x || {}; const tpl = X[p.p] !== undefined ? X[p.p] : ''; const atpl = X[p.a] !== undefined ? X[p.a] : ''; return { key: p.k, tier: p.t, layer: p.l, level: p.v, cat: p.c, tpl: p.p, atpl: p.a, vars, text: fmtTpl(tpl, vars), action: fmtTpl(atpl, vars).replace(/^[\s\u2705\u2714\u2611\ufe0f]+/, '').trim(), }; }); s.problems = problems; s.primary = problems.find(p => p.key === s.primary_key) || null; s.flags = problems.map(p => [p.level, p.text, p.action, p.key]); s.cats = Array.from(new Set(problems.map(p => p.cat))); return s; } /* ---------------- KPI ---------------- */ function renderKpis() { $('kpis').innerHTML = state.data.kpis.map(k => '
' + k.value + '家
' + '
' + esc(k.label) + '
' ).join(''); document.querySelectorAll('.kpi').forEach(el => el.onclick = () => { state.kpi = el.dataset.k; state.page = 0; renderKpis(); renderTable(); }); } /* ---------------- 图表 ---------------- */ let c1 = null, c2 = null, c3 = null; const GOLD_BAR = { type: 'linear', x: 0, y: 0, x2: 1, y2: 0, colorStops: [{ offset: 0, color: '#eec25c' }, { offset: 1, color: '#c8952c' }], }; function renderCharts() { const d = state.data, T = d.thresholds; const ax = { axisLine: { lineStyle: { color: '#ecdfc2' } }, axisLabel: { color: '#93876f', fontSize: 11 }, splitLine: { lineStyle: { color: '#f6efe0' } }, }; if (!c1) c1 = echarts.init($('c1')); const cats = d.dist.cats; c1.setOption({ grid: { left: 118, top: 8, bottom: 24, right: 30 }, tooltip: {}, xAxis: Object.assign({ type: 'value' }, ax), yAxis: { type: 'category', data: cats.map(x => x[0]), axisLabel: { color: '#7f7359', fontSize: 11 } }, series: [{ type: 'bar', data: cats.map(x => x[1]), barWidth: 11, itemStyle: { color: GOLD_BAR, borderRadius: [0, 3, 3, 0] } }], }, true); const a = d.dist.amount; const baseLabel = T.hx_amt_base >= 10000 ? '达标≥' + (T.hx_amt_base / 10000).toFixed(1) + '万' : '达标≥' + T.hx_amt_base; $('c2title').textContent = '核销金额分布(基准 ' + T.hx_amt_base + ' 元)'; if (!c2) c2 = echarts.init($('c2')); c2.setOption({ grid: { left: 44, top: 14, bottom: 40, right: 16 }, tooltip: {}, xAxis: Object.assign({ type: 'category', data: ['零核销', '0-1千', '1千-基准', baseLabel] }, ax), yAxis: Object.assign({ type: 'value' }, ax), series: [{ type: 'bar', barWidth: 26, data: [ { value: a.zero, itemStyle: { color: '#b5563a' } }, { value: a.lt1k, itemStyle: { color: '#cd8a35' } }, { value: a.ltbase, itemStyle: { color: '#ddb45e' } }, { value: a.ok, itemStyle: { color: '#6f9d7f' } }], itemStyle: { borderRadius: [3, 3, 0, 0] }, label: { show: true, position: 'top', color: '#93876f', fontSize: 11 }, }], }, true); if (!c3) c3 = echarts.init($('c3')); const cities = d.dist.cities.slice().reverse(); c3.setOption({ grid: { left: 72, top: 8, bottom: 24, right: 30 }, tooltip: {}, xAxis: Object.assign({ type: 'value' }, ax), yAxis: { type: 'category', data: cities.map(x => x[0]), axisLabel: { color: '#7f7359', fontSize: 11 } }, series: [{ type: 'bar', data: cities.map(x => x[1]), barWidth: 10, itemStyle: { color: '#e3c47f', borderRadius: [0, 3, 3, 0] } }], }, true); } /* ---------------- 筛选 / 表格 ---------------- */ function renderFilters() { const stores = state.data.stores; const citySel = $('fcity'), catSel = $('fcat'); const cities = [...new Set(stores.map(s => s.city).filter(Boolean))].sort(); const cats = [...new Set(stores.flatMap(s => s.cats))].sort(); citySel.innerHTML = '' + cities.map(c => '').join(''); catSel.innerHTML = '' + cats.map(c => '').join(''); citySel.value = state.city; catSel.value = state.cat; const scSel = $('fscore'); if (scSel) scSel.value = state.score; } /* 评分筛选档位:闭区间按【】语义含端点(4.3 同时落在 4.0-4.3 与 4.3-4.5,4.5 同时落在 4.3-4.5 与 4.5以上) */ function scoreInBand(v, band) { if (v == null || !band) return true; const x = Math.round(v * 10) / 10; if (band === 'lt40') return x < 4.0; if (band === '40_43') return x >= 4.0 && x <= 4.3; if (band === '43_45') return x >= 4.3 && x <= 4.5; if (band === 'gte45') return x >= 4.5; return true; } function filtered() { const d = state.data, T = d.thresholds; let rows = d.stores.slice(); switch (state.kpi) { case 'R': rows = rows.filter(s => s.flags.some(f => f[0][0] === 'R')); break; case 'SCORE': rows = rows.filter(s => s.score != null && s.score < T.score_base); break; case 'HX': rows = rows.filter(s => s.hx_rate != null && s.hx_rate < T.hx_rate_base); break; case 'ZERO': rows = rows.filter(s => s.hx_cnt === 0); break; } if (state.city) rows = rows.filter(s => s.city === state.city); if (state.cat) rows = rows.filter(s => s.cats.indexOf(state.cat) >= 0); if (state.score) rows = rows.filter(s => s.score != null && scoreInBand(s.score, state.score)); if (state.q) { const q = state.q.toLowerCase(); rows = rows.filter(s => s.name.toLowerCase().indexOf(q) >= 0 || s.id.indexOf(q) >= 0); } const s = state.sort; const dir = state.order === 'asc' ? 1 : -1; const val = (o) => { if (s === 'amt') return o.hx_amt; if (s === 'visit') return o.visit; if (s === 'score') return o.score; if (s === 'hx') return o.hx_rate; return o.sev; }; rows.sort((x, y) => { const ax = val(x), ay = val(y); if (ax == null && ay == null) return 0; if (ax == null) return 1; // 空值永远排最后,不随方向翻转 if (ay == null) return -1; const r = (ax - ay) * dir; if (r !== 0) return r; return (x.hx_amt || 0) - (y.hx_amt || 0); // 同值时按金额兜底 }); return rows; } function sevColorOf(sev) { return sev >= 3 ? '#b5563a' : sev >= 2 ? '#cd8a35' : sev >= 1 ? '#c9a24a' : sev >= .6 ? '#dcc79a' : '#e3d9c2'; } /* 上榜:从「XX榜第3名、YY榜第12名」里取最好名次 */ function rankText(txt) { const t = (txt || '').trim(); if (!t || t === '-') return '—'; const nums = [...t.matchAll(/第\s*(\d+)\s*名/g)].map(m => parseInt(m[1], 10)); if (!nums.length) return '入围'; const best = Math.min(...nums); return '第' + best + '名'; } function rowHtml(s) { const T = state.thresholds || {}; // 超线/未达标的值标红,方便一眼扫到 const warn = (bad, level) => bad ? ' style="color:' + (level === 'R' ? '#b5563a' : '#c8952c') + ';font-weight:600"' : ''; const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0); const scoreBad = s.score != null && s.score > 0 && s.score < T.score_base; const scoreUnrated = s.score === 0; const tdBad = (s.td || 0) > (T.tuDan ? T.tuDan[0] : 9); const tsBad = (s.ts || 0) > (T.touSu ? T.touSu[0] : 9); const cpBad = s.cp != null && s.cp > (T.chaPing ? T.chaPing[0] : 9); const replyBad = s.reply != null && s.reply < T.reply_base / 100; const pendBad = pend >= (T.pend_high || 30); return '
' + esc(s.name) + '
' + s.id + '
' + '' + esc(s.city) + '' + '' + (scoreUnrated ? '未开分' : (s.score == null ? '-' : s.score.toFixed(1))) + '' + '' + money(s.visit) + '' + '' + money(s.deal) + '' + '' + pct(s.hx_rate) + '' + '' + money(s.hx_amt) + '' + '' + money(pend) + '' + '' + pct(s.td, 2) + '' + '' + pct(s.ts, 2) + '' + '' + (s.cp == null ? '无评价' : pct(s.cp, 2)) + '' + '' + pct(s.reply) + '' + '' + rankText(s.rank) + '' + '' + s.problems.slice(0, 5).map(p => '' + esc(p.cat) + '').join('') + (s.problems.length > 5 ? '+' + (s.problems.length - 5) + '' : '') + ''; } /* 列表:分页渲染,每页 PAGE_SIZE 条 */ function renderTable() { const rows = filtered(); state.rows = rows; const pages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); if (state.page >= pages) state.page = pages - 1; if (state.page < 0) state.page = 0; const slice = rows.slice(state.page * PAGE_SIZE, state.page * PAGE_SIZE + PAGE_SIZE); $('tb').innerHTML = slice.map(rowHtml).join('') || '没有符合筛选条件的门店'; bindRows(); $('cnt').textContent = '共 ' + rows.length + ' 家'; $('pginfo').textContent = '第 ' + (state.page + 1) + ' / ' + pages + ' 页'; $('prev').disabled = state.page === 0; $('next').disabled = state.page >= pages - 1; } function bindRows() { document.querySelectorAll('tr.row').forEach(el => el.onclick = () => openDrawer(el.dataset.id)); } /* ---------------- 门店详情 ---------------- */ function metricsOf(s, T) { return [ { k: '评分', v: s.score, fmt: v => v.toFixed(1), max: 5, base: T.score_base, target: T.score_target, dir: 'high' }, { k: '核销率', v: s.hx_rate, fmt: v => pct(v), max: 1, base: T.hx_rate_base, target: T.hx_rate_target, dir: 'high' }, { k: '核销金额', v: s.hx_amt, fmt: v => money(v) + '元', max: T.hx_amt_base * 2, base: T.hx_amt_base, target: T.hx_amt_base, dir: 'high' }, { k: '商责退单率', v: s.td, fmt: v => pct(v, 2), max: T.tuDan[2] * 1.1, warn: T.tuDan, dir: 'low' }, { k: '投诉率', v: s.ts, fmt: v => pct(v, 2), max: T.touSu[2] * 1.4, warn: T.touSu, dir: 'low' }, { k: '差评率', v: s.cp, fmt: v => v == null ? '无评价' : pct(v, 2), max: T.chaPing[2] * 1.4, warn: T.chaPing, dir: 'low' }, { k: '评价回复率', v: s.reply, fmt: v => pct(v), max: 1, base: T.reply_base / 100, target: 1, dir: 'high' }, ]; } function barColor(m, v) { if (v == null) return '#e3d9c2'; if (m.dir === 'high') return v < m.base ? '#b5563a' : v < m.target ? '#cd8a35' : '#6f9d7f'; return v > m.warn[2] ? '#b5563a' : v > m.warn[1] ? '#cd8a35' : v > m.warn[0] ? '#c9a24a' : '#6f9d7f'; } function openDrawer(id) { const s = state.data.stores.find(x => x.id === id); if (!s) return; const T = state.thresholds; const D = diagnosisOf(s, state.texts); closePanels(); let h = '

' + esc(s.name) + '

' + s.id + ' · ' + esc(s.city) + ' · 严重度 ' + s.sev.toFixed(1) + (s.days ? ' · ' + s.days + ' 天数据' : '') + '
' + '×
'; // 一句话结论 h += '
' + '
' + esc(D.headline) + '
' + '
共命中 ' + D.problems.length + ' 项 · 整改动作 ' + D.actions.length + ' 条
'; // 主因 + 次要问题 if (D.primary) { h += '
主因
' + '
' + esc(D.primary.text) + '' + '
' + esc(D.primary.action) + '
'; const rest = D.problems.filter(p => p !== D.primary); if (rest.length) { h += '
其余问题(' + rest.length + ' 项)
' + rest.map(p => '
' + esc(p.text) + '' + '
' + esc(p.action) + '
').join(''); } h += '
'; } else { h += '
' + esc(state.texts.fb_no_issue) + '
'; } // 指标条 h += '
关键指标
'; h += metricsOf(s, T).map(m => { const v = typeof m.v === 'function' ? m.v(s) : m.v; const w = v == null ? 0 : Math.min(v / m.max * 100, 100); const marks = m.warn ? m.warn.map(x => '').join('') : '' + ''; return '
' + m.k + '' + (v == null ? '-' : m.fmt(v)) + '
' + '
' + marks + '
'; }).join(''); if (s.rank) { h += '
上榜' + esc(s.rank) + '
'; } h += '
访问 ' + money(s.visit) + ' 次 · 发布视频 ' + s.videos + ' 个 · 成交券 ' + money(s.deal) + ' 张 · 点亮 ' + (s.lit || 0) + ' 次
'; h += '
' + '' + '
' + '
【预览分析结果】按文案模板生成发给督导的反馈,可直接改再复制;' + '【导出报告】生成一页 A4 门店分析报告,可打印成 PDF 或下载 HTML 存档。
'; const dw = $('dw'); dw.innerHTML = h; dw.classList.add('on'); $('ov').classList.add('on'); $('pvBtn').onclick = () => openPreview(s); $('rpBtn').onclick = () => openReport(s); $('dwClose').onclick = closePanels; } /* ---------------- 分析结果预览(可改,不保存) ---------------- */ function ensurePreviewDom() { if ($('pvWrap')) return; const pv = document.createElement('div'); pv.id = 'pvWrap'; pv.className = 'pvwrap'; pv.innerHTML = '
预览分析结果' + '×
' + '' + '
' + '
'; document.body.appendChild(pv); } function closePreview() { const pv = $('pvWrap'); if (pv) pv.classList.remove('on'); } function openPreview(s) { ensurePreviewDom(); const pv = $('pvWrap'); $('pvText').value = feedbackText(s); pv.classList.add('on'); $('pvClose').onclick = closePreview; pv.onclick = e => { if (e.target === pv) closePreview(); }; $('pvRegen').onclick = () => { $('pvText').value = feedbackText(s); }; $('pvCopy').onclick = () => { const t = $('pvText').value; if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t); else { const ta = document.createElement('textarea'); ta.value = t; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } const b = $('pvCopy'); b.textContent = '已复制 ✓'; setTimeout(() => { b.textContent = '复制定稿文案'; }, 1600); }; } function fmtTpl(tpl, vars) { return String(tpl == null ? '' : tpl).replace(/\{([^{}]+)\}/g, (m, k) => vars[k] !== undefined && vars[k] !== null ? vars[k] : m); } /* 变量别名:允许直接按数据表列名写(如 {门店意向成交券数}),与短名等价 */ const VAR_ALIAS = { '门店名称': '门店名', '门店城市': '城市', '统计周期': '周期', '门店页访问次数': '访问', '门店页有效人数': '访问', '访问次数': '访问', '有效人数': '访问', '门店页成交券数': '客资', '成交券数': '客资', '门店意向成交券数': '客资', '意向成交券数': '客资', '意向成交': '客资', '门店核销券数': '核销券数', '核销券': '核销券数', '门店意向退款券数': '退单', '意向退款券数': '退单', '门店退款券数': '退单', '退款券数': '退单', '门店实际成交人数': '成交', '成交人数': '成交', '实际成交': '成交', '流失人数': '流失', '流失人数占比': '流失占比', '核销金额': '金额', '核销金额(元)': '金额', '门店评分': '评分', '团购评分': '评分', '评价分': '评分', '待核销订单数': '待核销', '后台待核销订单': '待核销', '后台待核销订单数': '待核销', '评价回复率': '回复率', '核销率基准': '核销目标', '核销率目标': '核销目标', '评分基准线': '评分基准', '考核基准': '评分基准', }; function fbVars(s) { const T = state.thresholds; const visit = s.visit || 0, deal = s.deal || 0; const lost = Math.max(visit - deal, 0); const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0); const v = { '门店名': s.name, '门店ID': s.id, '城市': s.city, '周期': state.period, '访问': cnt(visit), '客资': cnt(s.deal), '核销': cnt(s.hx_cnt), '退单': cnt(s.deal_refund), '待核销': cnt(pend), '成交': cnt(deal), '流失': cnt(lost), '成交占比': visit > 0 ? pct(deal / visit, 2) : '-', '流失占比': visit > 0 ? pct(lost / visit, 2) : '-', '访问环比': '-', // 暂无上月数据,可在模板中删掉该变量 '金额': money(s.hx_amt), '评分': s.score == null ? '-' : s.score.toFixed(1), '核销率': pct(s.hx_rate), '核销目标': pct(T.hx_rate_target), '回复率': pct(s.reply), '回复基准': T.reply_base + '%', '评分基准': T.score_base, '核销金额基准': T.hx_amt_base, '核销券数': cnt(s.hx_cnt), '成交券数': cnt(s.deal), }; // 展开别名,让全称变量也能渲染 Object.keys(VAR_ALIAS).forEach(a => { if (v[VAR_ALIAS[a]] !== undefined) v[a] = v[VAR_ALIAS[a]]; }); return v; } /* —— 变量校验:非法的(写错的)变量名会被原样输出,这里提前抓出来 —— */ const RESERVED_VARS = ['i', '问题', '建议', '洞察', 'v', 'base', 'target', '口径', '档', 'n', '金额', '客单价', '新增', '原因', 'p', '结论', '动作', '榜']; const BASE_VARS = ['门店名', '门店ID', '城市', '周期', '访问', '客资', '核销', '退单', '待核销', '成交', '流失', '成交占比', '流失占比', '访问环比', '金额', '评分', '核销率', '核销目标', '回复率', '回复基准', '评分基准', '核销金额基准', '核销券数', '成交券数']; function knownVarSet() { const known = new Set(BASE_VARS.concat(RESERVED_VARS)); Object.keys(VAR_ALIAS).forEach(k => known.add(k)); if (prevStore) Object.keys(fbVars(prevStore)).forEach(k => known.add(k)); return known; } function unknownVars(text) { const known = knownVarSet(), out = []; String(text == null ? '' : text).replace(/\{([^{}]+)\}/g, (m, k) => { if (!known.has(k) && out.indexOf(k) < 0) out.push(k); return m; }); return out; } /* ---------------- 诊断结果(v2) ---------------- 规则与判定全部在后端 rules.py;前端只做两件事: 1) 用当前(可能还没保存的)文案模板重渲染问题文案与动作,供文案配置实时预览 2) 按「主因 + 次要 + ≤3 动作」组织成反馈文案 */ const LAYER_CN = { traffic: '流量', convert: '转化', fulfill: '履约', repute: '口碑' }; const LV_WEIGHT = { R3: 3, R2: 2, R1: 1, B: 0.6, T: 0.3, N: 0 }; function renderProblem(p, X) { if (!p) return ''; const vars = Object.assign({}, p.vars || {}); if (p.level && String(p.level).charAt(0) === 'R') vars['档'] = X['lv_' + p.level] || vars['档']; const tpl = (p.tpl && X[p.tpl] !== undefined) ? X[p.tpl] : p.text; return fmtTpl(tpl, vars); } function renderAction(p, X) { if (!p) return ''; const tpl = (p.atpl && X[p.atpl] !== undefined) ? X[p.atpl] : p.action; // 去掉行首的 ✅/✓,避免和「✅ {动作}」模板叠加出现「✅ ✅」 return fmtTpl(tpl, p.vars || {}).replace(/^[\s\u2705\u2714\u2611\ufe0f]+/, '').trim(); } /* 用当前模板重新组织出:问题列表 / 主因 / 次要 / 动作 / 结论 */ function diagnosisOf(s, X) { const problems = (s.problems || []).map(p => Object.assign({}, p, { text: renderProblem(p, X), action: renderAction(p, X) })); const pk = s.primary && s.primary.key; let primary = problems.find(p => p.key === pk) || null; // 主因 key 找不到时(理论上不会)退化为最高档位那条 if (!primary && problems.length) { primary = problems.slice().sort((a, b) => (LV_WEIGHT[b.level] || 0) - (LV_WEIGHT[a.level] || 0))[0]; } const secondary = problems .filter(p => p !== primary && (p.tier === 'R' || p.tier === 'B')) .sort((a, b) => (LV_WEIGHT[b.level] || 0) - (LV_WEIGHT[a.level] || 0)) .slice(0, 2); // 每个问题都给对应动作:主因排第一,其余按原顺序 const ordered = primary ? [primary].concat(problems.filter(p => p !== primary)) : problems.slice(); const actions = []; ordered.forEach(p => { const act = renderAction(p, X); if (act && actions.indexOf(act) < 0) actions.push(act); }); // 优先用规则专属结论句,没有就退回该层通用句(与 rules.py 同一套回退规则) let hdKey = primary ? 'hd_' + primary.key : 'hd_OK'; if (X[hdKey] === undefined) hdKey = primary ? (primary.tier === 'R' ? 'hd_R' : 'hd_' + primary.layer) : 'hd_OK'; if (X[hdKey] === undefined) hdKey = 'hd_OK'; const headline = fmtTpl(X[hdKey] !== undefined ? X[hdKey] : (s.headline || ''), { 问题: primary ? primary.text : '' }); return { problems, primary, secondary, ordered, actions, headline, hdKey, }; } /* 洞察条目:哪几条出现、按什么顺序,只在这里决定(反馈文案与单店报告共用) */ function insightTexts(s, X, D) { const vars = fbVars(s); const T = state.thresholds; const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0); const visit = s.visit || 0; const out = []; if (s.hx_rate != null && s.hx_rate < T.hx_rate_base) out.push(fmtTpl(X.fb_insight_metric, vars)); if (s.score != null && s.score < T.score_base) out.push(fmtTpl(X.fb_insight_score, vars)); if (visit > 0) out.push(fmtTpl(X.fb_insight_flow, vars)); if (pend > 0) out.push(fmtTpl(X.fb_insight_order, vars)); if (s.reply != null && s.reply < T.reply_base / 100) out.push(fmtTpl(X.fb_insight_reply, vars)); if (D && D.problems.length) out.push(fmtTpl(X.fb_insight_priority, vars)); return out; } function renderFeedback(s, X) { const vars = fbVars(s); const D = diagnosisOf(s, X); const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0); const visit = s.visit || 0; const L = [X.fb_sec_overview, fmtTpl(X.fb_line_overview, vars)]; if (!D.problems.length) { L.push(X.fb_no_issue); } else { L.push(X.fb_sec_headline); L.push(fmtTpl(X.fb_item_headline, Object.assign({}, vars, { 结论: D.headline }))); const risk = D.ordered; if (risk.length) { L.push(X.fb_sec_risk); risk.forEach((p, i) => L.push( fmtTpl(X.fb_item_risk, Object.assign({}, vars, { i: i + 1, '问题': p.text })))); } } if (pend > 0) L.push(fmtTpl(X.fb_line_pending, vars)); if (visit > 0) { L.push(X.fb_sec_loss); L.push(fmtTpl(X.fb_item_visit, Object.assign({ i: 1 }, vars))); L.push(fmtTpl(X.fb_item_deal, Object.assign({ i: 2 }, vars))); L.push(fmtTpl(X.fb_item_lost, Object.assign({ i: 3 }, vars))); } const ins = insightTexts(s, X, D); if (ins.length) { L.push(X.fb_sec_insight); ins.forEach((t, k) => L.push(fmtTpl(X.fb_item_insight, { i: k + 1, '洞察': t }))); } if (D.actions.length) { L.push(X.fb_sec_action); D.actions.forEach(a => L.push(fmtTpl(X.fb_action_item, Object.assign({}, vars, { 动作: a })))); } return L.join('\n'); } function feedbackText(s) { return renderFeedback(s, state.texts); } /* ---------------- 单店分析报告(A4 可打印 / 可下载) ---------------- */ const SEV_TIERS = [ { min: 3, name: '非常严重', cls: 's3' }, { min: 2, name: '一般严重', cls: 's2' }, { min: 1, name: '预警', cls: 's1' }, { min: 0.6, name: '低于考核基准', cls: 's1' }, { min: 1e-9, name: '有提升项', cls: 'n' }, { min: 0, name: '达标', cls: 's0' }, ]; function sevOf(sev) { return SEV_TIERS.find(t => sev >= t.min) || SEV_TIERS[SEV_TIERS.length - 1]; } /* 报告里的指标条:与详情抽屉同一套 metricsOf / barColor,只是换成打印友好的类名 */ function reportBar(m, s) { const v = typeof m.v === 'function' ? m.v(s) : m.v; const w = v == null ? 0 : Math.min(v / m.max * 100, 100); const shown = v == null ? (m.k === '差评率' ? '无评价' : '-') : m.fmt(v); const marks = m.warn ? m.warn.map(x => '').join('') : '' + ''; return '
' + m.k + '' + shown + '
' + '
' + marks + '
'; } function nowStamp() { const d = new Date(), p = n => String(n).padStart(2, '0'); return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } function reportDocHtml(s) { const T = state.thresholds, X = state.texts; const vars = fbVars(s); const D = diagnosisOf(s, X); const sev = sevOf(s.sev); const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0); const visit = s.visit || 0, deal = s.deal || 0; const lost = Math.max(visit - deal, 0); const stamp = nowStamp(); const cards = [ ['门店页成交券数', cnt(s.deal), '张'], ['核销券数', cnt(s.hx_cnt), '张'], ['核销金额', money(s.hx_amt), '元'], ['意向退款券数', cnt(s.deal_refund), '张'], ['待核销订单', cnt(pend), '单'], ['门店页访问次数', money(visit), '次'], ['实际成交人数', cnt(deal), '人'], ['流失人数', cnt(lost), visit > 0 ? '(' + pct(lost / visit, 1) + ')' : ''], ].map(c => '
' + c[0] + '
' + c[1] + (c[2] ? '' + c[2] + '' : '') + '
').join(''); // 风险表:主因打头,其余为次要问题;不再把所有问题一次性列出 const riskList = D.ordered; const riskRows = riskList.map((p, i) => '' + (i + 1) + '' + '' + esc(p.text) + '' + (i === 0 ? '主因' : '') + '' + '' + esc(p.action) + '').join(''); const insights = insightTexts(s, X, D).map((t, i) => '
  • ' + (i + 1) + '' + esc(t) + '
  • ').join(''); const actions = D.actions.map(a => '
  • ✓' + esc(a) + '
  • ').join(''); return '
    ' + '
    怡呵美 · 水光会员店' + '

    门店检测分析报告

    ' + '
    ' + '门店 ' + esc(s.name) + '' + '门店ID ' + s.id + '' + '城市 ' + esc(s.city || '-') + '' + '统计周期 ' + state.period + '' + (s.days ? '有效数据 ' + s.days + ' 天' : '') + '生成时间 ' + stamp + '' + '
    ' + '
    ' + '
    综合严重度
    ' + '
    ' + s.sev.toFixed(1) + '/ 3.0
    ' + '' + sev.name + '
    ' + '
    ' + '
    ' + D.problems.length + '命中问题
    ' + '
    ' + (s.score == null ? '-' : s.score.toFixed(1)) + '团购评分
    ' + '
    ' + pct(s.hx_rate) + '核销率
    ' + '
    ' + money(s.hx_amt) + '核销金额(元)
    ' + '
    ' + cnt(pend) + '待核销(单)
    ' + '
    ' + '
    ' + '

    01关键指标

    ' + '
    ' + metricsOf(s, T).map(m => reportBar(m, s)).join('') + '
    ' + '
    刻度:考核基准,平台目标;' + '退单率 / 投诉率 / 差评率为三级违规线(预警 · 一般严重 · 非常严重)
    ' + '

    02问题诊断

    ' + '
    ' + esc(D.headline) + '
    ' + (riskList.length ? '' + '' + riskRows + '
    问题建议动作
    ' : '
    ' + esc(X.fb_no_issue) + '
    ') + '

    03经营数据(近30天)

    ' + '
    ' + cards + '
    ' + (insights ? '

    04分析洞察

    ' : '') + (actions ? '

    05门店调整动作

    ' : '') + '
    ' + '本报告由「门店检测工作台」于 ' + stamp + ' 自动生成,数据周期 ' + state.period + '。
    ' + '口径:核销率 = 核销券数 ÷ 意向成交券数;差评率在新增评价 ≥10 时取新增口径,否则取累计口径;' + '评分取周期内最近一次有值数据(含 0 分未开分)。考核基准值可在工作台【指标配置】中调整。' + '
    ' + '
    '; } function ensureReportDom() { if ($('rpWrap')) return; const w = document.createElement('div'); w.id = 'rpWrap'; w.className = 'rp'; w.classList.add('rpwrap'); document.body.appendChild(w); } function openReport(s) { ensureReportDom(); const w = $('rpWrap'); w.innerHTML = '
    ' + '
    ' + '
    ' + esc(s.name) + '' + state.period + ' · 单店分析报告
    ' + '' + '' + '' + '
    ' + reportDocHtml(s) + '
    '; w.classList.add('on'); document.body.classList.add('rp-on'); $('rpClose').onclick = closeReport; $('rpPrint').onclick = () => window.print(); $('rpDl').onclick = () => downloadReport(s); w.onclick = e => { if (e.target === w) closeReport(); }; } function closeReport() { const w = $('rpWrap'); if (w) w.classList.remove('on'); document.body.classList.remove('rp-on'); } let reportCssCache = null; async function downloadReport(s) { try { if (!reportCssCache) reportCssCache = await (await fetch('/report.css')).text(); } catch (e) { toast('读取报告样式失败:' + e.message, 3600); return; } const file = '门店分析报告_' + String(s.name).replace(/[\\/:*?"<>|]/g, '_') + '_' + state.period + '.html'; const doc = '' + '' + '' + esc('门店分析报告 · ' + s.name + ' · ' + state.period) + '' + '' + '' + '
    ' + '
    ' + reportDocHtml(s) + '
    ' + ''; const url = URL.createObjectURL(new Blob([doc], { type: 'text/html;charset=utf-8' })); const a = document.createElement('a'); a.href = url; a.download = file; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000); toast('已下载:' + file, 3200); } /* ---------------- 考核指标面板 ---------------- */ const SPEC = [ { g: '门店评分', hint: '低于基准即判「评分不达标」', items: [{ p: 'score_base', l: '考核基准', step: '0.1' }, { p: 'score_target', l: '目标值', step: '0.1' }] }, { g: '核销率', hint: '核销券数 ÷ 意向成交券数', pct: 1, items: [{ p: 'hx_rate_base', l: '考核基准', step: '1' }, { p: 'hx_rate_target', l: '目标值', step: '1' }] }, { g: '核销金额', hint: '单店周期核销金额,低于基准即提醒', items: [{ p: 'hx_amt_base', l: '考核基准(元)', step: '500' }] }, { g: '商责退单率', hint: '超出即计入违规,分三级预警', pct: 1, items: [{ p: 'tuDan.0', l: '预警线' }, { p: 'tuDan.1', l: '一般严重' }, { p: 'tuDan.2', l: '非常严重' }] }, { g: '投诉率', hint: '超出即计入违规,分三级预警', pct: 1, items: [{ p: 'touSu.0', l: '预警线' }, { p: 'touSu.1', l: '一般严重' }, { p: 'touSu.2', l: '非常严重' }] }, { g: '差评率', hint: '只要有评价就判定:新增评价≥10 用新增口径,否则用累计口径', pct: 1, items: [{ p: 'chaPing.0', l: '预警线' }, { p: 'chaPing.1', l: '一般严重' }, { p: 'chaPing.2', l: '非常严重' }] }, { g: '评价回复率', hint: '低于此比例判「回复率偏低」', unit: '%', items: [{ p: 'reply_base', l: '最低要求' }] }, { g: '访问量', hint: '低于此值判「访问过低」', items: [{ p: 'visit_low', l: '过低线(次)', step: '10' }] }, { g: '成交转化率', hint: '成交人数 ÷ 访问人数 低于此值,判「转化率低」(T 级提示)', unit: '%', items: [{ p: 'cvr_low', l: '过低线', step: '0.5' }] }, { g: '未核销积压', hint: '估算未核销券达到此数量,提示集中邀约', unit: '张', items: [{ p: 'pend_high', l: '积压提醒线', step: '5' }] }, { g: '差评率样本量', hint: '达到最小样本才按三级判定;不足时最高只判预警线', unit: '条', items: [{ p: 'cp_min_new', l: '新增口径最小评价数' }, { p: 'cp_min_total', l: '累计口径最小评价数' }] }, { g: '内容与评价量', hint: '低于此数判「数量偏少」(T 级提升项)', unit: '条', items: [{ p: 'video_low', l: '视频数下限' }, { p: 'lit_low', l: '点亮数下限' }, { p: 'new_review_low', l: '新增评价数下限' }] }, { g: '其他最小样本量', hint: '样本不够就不判「不达标」,避免一条数据定性', unit: '条', items: [{ p: 'hx_min_deal', l: '核销率最小成交券数' }, { p: 'amt_min_coupons', l: '客单价最小核销券数' }, { p: 'reply_min_new', l: '回复率最小新增评价数' }] }, ]; const getP = (o, path) => path.split('.').reduce((v, k) => (v == null ? null : v[k]), o); function setP(o, path, val) { const ks = path.split('.'); let v = o; for (let i = 0; i < ks.length - 1; i++) v = v[ks[i]]; v[ks[ks.length - 1]] = val; } const uiOf = (obj, path, isPct) => { const raw = getP(obj, path); return isPct ? +(raw * 100).toFixed(3) : raw; }; const unitOf = g => g.unit ? g.unit : g.pct ? '%' : (g.g.indexOf('金额') >= 0 ? '元' : g.g.indexOf('访问') >= 0 ? '次' : '分'); function buildThresholdPanel() { const T = state.thresholds, D = state.defaults; $('thPill').textContent = state.data.customized ? '已自定义' : '出厂值'; $('thBody').innerHTML = SPEC.map(g => { const cls = g.items.length === 3 ? '' : (g.items.length === 2 ? ' one' : ' single'); return '
    ' + g.g + '
    ' + g.hint + '
    ' + '
    ' + g.items.map(it => { const isPct = g.pct ? 1 : 0; const chg = Math.abs(uiOf(T, it.p, isPct) - uiOf(D, it.p, isPct)) > 1e-9 ? ' chg' : ''; return '
    ' + '' + unitOf(g) + '
    '; }).join('') + '
    '; }).join(''); document.querySelectorAll('#thBody input').forEach(inp => { inp.oninput = () => { const def = uiOf(D, inp.dataset.p, inp.dataset.pct === '1'); const cur = parseFloat(inp.value); inp.closest('.thfield').classList.toggle('chg', !isNaN(cur) && Math.abs(cur - def) > 1e-9); }; }); } function collectThresholds() { const T = JSON.parse(JSON.stringify(state.thresholds)); let bad = null; document.querySelectorAll('#thBody input').forEach(inp => { const v = parseFloat(inp.value); if (isNaN(v)) { bad = bad || '有输入框为空'; return; } setP(T, inp.dataset.p, inp.dataset.pct === '1' ? v / 100 : v); }); return { T, bad }; } async function applyThresholds() { const { T, bad } = collectThresholds(); if (bad) { toast(bad); return; } try { const r = await api('/api/thresholds', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(T), }); closePanels(); await load(state.period); toast(r && r.code_synced ? '已生效,并同步写回代码默认值' : ('已生效(写回代码失败:' + ((r && r.code_msg) || '未知') + ')'), r && r.code_synced ? 2600 : 5000); } catch (e) { toast('保存失败:' + e.message, 3600); } } async function resetThresholds() { try { const r = await api('/api/thresholds/reset', { method: 'POST' }); closePanels(); await load(state.period); toast(r && r.code_synced ? '已恢复到出厂值,并同步写回代码' : '已恢复到出厂值', 3200); } catch (e) { toast('恢复失败:' + e.message, 3600); } } /* ---------------- 文案配置面板 ---------------- */ const TSPEC = [ { g: '概览与结论', hint: '点字段右侧「+ 变量」可直接插入。短名:{客资} {核销} {退单} {待核销} {成交} {流失} {成交占比} {流失占比} {访问} {金额} {评分} {核销率} {核销目标} {回复率} {核销金额基准} {门店名} {城市}。也支持按数据表列名写:{门店意向成交券数} {门店核销券数} {门店意向退款券数} {门店页有效人数}', items: [ { p: 'fb_sec_overview', l: '概览段标题' }, { p: 'fb_line_overview', l: '概览行模板' }, { p: 'fb_sec_headline', l: '结论段标题' }, { p: 'fb_item_headline', l: '结论行模板({结论}=下面 hd_* 生成的整句)' }, ] }, { g: '一句话结论(按主因自动选一条)', hint: '诊断树先判定这家店卡在哪一层,再套用对应的一句。{问题}=该店具体问题文案', items: [ { p: 'hd_R', l: '有违规在限流' }, { p: 'hd_traffic', l: '卡在流量层' }, { p: 'hd_convert', l: '卡在转化层' }, { p: 'hd_fulfill', l: '卡在履约层' }, { p: 'hd_repute', l: '卡在口碑层' }, { p: 'hd_AMT', l: '卡在客单价(核销率达标)' }, { p: 'hd_SCORE0', l: '未开分' }, { p: 'hd_OK', l: '全部达标' }, ] }, { g: '风险因素', hint: '只列主因 + 最多 2 条次要问题,不再把所有问题一次性堆出来', items: [ { p: 'fb_sec_risk', l: '风险段标题' }, { p: 'fb_item_risk', l: '风险条目格式({i}=序号 {问题}=问题文案)' }, { p: 'fb_line_pending', l: '待核销订单行(待核销=0 时不显示)' }, { p: 'fb_no_issue', l: '无问题门店文案' }, ] }, { g: '顾客流失分析', hint: '有访问数据时显示这一段', items: [ { p: 'fb_sec_loss', l: '段标题' }, { p: 'fb_item_visit', l: '访问行' }, { p: 'fb_item_deal', l: '成交行' }, { p: 'fb_item_lost', l: '流失行' }, ] }, { g: '分析洞察', hint: '每条洞察按条件自动取舍:指标层面(核销率不达标)、口碑分(评分不达标)、流量转化(有访问)、订单风险(待核销>0)、口碑管理(回复率偏低)、整改优先级(有风险项)', items: [ { p: 'fb_sec_insight', l: '段标题' }, { p: 'fb_item_insight', l: '洞察条目格式({i}=序号 {洞察}=洞察文案)' }, { p: 'fb_insight_metric', l: '指标层面' }, { p: 'fb_insight_score', l: '口碑分' }, { p: 'fb_insight_flow', l: '流量转化' }, { p: 'fb_insight_order', l: '订单风险' }, { p: 'fb_insight_reply', l: '口碑管理' }, { p: 'fb_insight_priority', l: '整改优先级' }, ] }, { g: '门店调整动作', hint: '动作来自诊断结果,只给主因和次要问题对应的动作,最多 3 条(不再给固定清单)', items: [ { p: 'fb_sec_action', l: '段标题' }, { p: 'fb_action_item', l: '动作条目格式({动作}=该条整改动作)' }, ] }, { g: '严重档位名称', hint: '问题文案中 {档} 的显示名', items: [ { p: 'lv_R1', l: '一级' }, { p: 'lv_R2', l: '二级' }, { p: 'lv_R3', l: '三级' }, ] }, { g: '问题文案', hint: '变量:{v}=实际值 {base}=基准值 {口径}=新增/累计 {档}=档位名 {n}=券数 {金额}=核销金额 {客单价}=元 {新增}=新增评价数 {p}=积压券数 {原因}=未开分原因。显示在风险因素、详情抽屉和导出清单', items: [ { p: 't_TD', l: '商责退单率超线' }, { p: 't_TS', l: '投诉率超线' }, { p: 't_CP', l: '差评率超线' }, { p: 't_SCORE', l: '评分不达标' }, { p: 't_SCORE0', l: '未开分' }, { p: 't_REPLY', l: '评价回复率偏低' }, { p: 't_DEAL0', l: '零成交' }, { p: 't_ZERO', l: '成交未核销' }, { p: 't_HX', l: '核销率不达标' }, { p: 't_AMT', l: '核销金额偏低' }, { p: 't_CVR', l: '转化率低' }, { p: 't_VISIT', l: '访问过低' }, { p: 't_RVOL', l: '新增评价少' }, { p: 't_RANK0', l: '未上榜' }, { p: 't_RANK_IN', l: '仅入围({榜})'}, { p: 't_PEND', l: '未核销积压' }, { p: 't_LIT', l: '点亮偏低' }, { p: 't_VIDEO', l: '视频量少' }, ] }, { g: '整改建议', hint: '评分建议可用 {base}=基准 {target}=目标。一条问题对应一条建议,最终只输出 ≤3 条', items: [ { p: 'a_TD', l: '退单率超线' }, { p: 'a_TS', l: '投诉率超线' }, { p: 'a_CP', l: '差评率超线' }, { p: 'a_SCORE', l: '评分不达标' }, { p: 'a_SCORE0', l: '未开分' }, { p: 'a_REPLY', l: '评价回复率偏低' }, { p: 'a_DEAL0', l: '零成交' }, { p: 'a_ZERO', l: '成交未核销' }, { p: 'a_HX', l: '核销率不达标' }, { p: 'a_AMT', l: '核销金额偏低' }, { p: 'a_CVR', l: '转化率低' }, { p: 'a_VISIT', l: '访问过低' }, { p: 'a_RVOL', l: '新增评价少' }, { p: 'a_RANK0', l: '未上榜' }, { p: 'a_RANK_IN', l: '仅入围' }, { p: 'a_PEND', l: '未核销积压' }, { p: 'a_LIT', l: '点亮偏低' }, { p: 'a_VIDEO', l: '视频量少' }, ] }, ]; function textsChanged() { const X = state.texts, D = state.textDefaults; return TSPEC.some(g => g.items.some(it => (X[it.p] || '') !== (D[it.p] || ''))); } /* —— 实时预览 —— v2 起不再在前端复刻一套判定规则:问题的判定结果由后端给出(s.problems), 这里只用「当前编辑中的文案」重渲染文案,规则唯一源仍是 backend/rules.py */ let prevStore = null; function collectTextInputs() { const X = Object.assign({}, state.texts); document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => { X[inp.dataset.p] = inp.value; }); return X; } function renderTextPreview() { if (!prevStore || !$('txPrev')) return; const X = collectTextInputs(); const txt = renderFeedback(prevStore, X); // 已知变量已替换,残留的 {...} 就是写错的,标红提示 $('txPrev').innerHTML = esc(txt).replace(/\{([^{}]+)\}/g, '{$1}'); const bad = []; Object.keys(X).forEach(k => unknownVars(X[k]).forEach(u => { if (bad.indexOf(u) < 0) bad.push(u); })); const tip = $('txBad'); if (tip) { tip.style.display = bad.length ? 'block' : 'none'; tip.textContent = bad.length ? '⚠️ 有 ' + bad.length + ' 个变量系统不认识,会原样输出:' + bad.map(u => '{' + u + '}').join(' ') + '。请改用下方「可用变量」里的名字。' : ''; } } const INSERT_VARS = [ '门店名', '门店ID', '城市', '周期', '访问', '客资', '核销', '退单', '待核销', '成交', '流失', '成交占比', '流失占比', '金额', '评分', '评分基准', '核销率', '核销目标', '回复率', '回复基准', '核销金额基准', ]; function insertAtCursor(inp, text) { const st = inp.selectionStart == null ? inp.value.length : inp.selectionStart; const en = inp.selectionEnd == null ? inp.value.length : inp.selectionEnd; inp.value = inp.value.slice(0, st) + text + inp.value.slice(en); if (inp.setSelectionRange) inp.setSelectionRange(st + text.length, st + text.length); if (inp.focus) inp.focus(); } function syncTextField(inp) { const field = inp.closest('.thfield'); if (!field) return; const def = state.textDefaults[inp.dataset.p] || ''; field.classList.toggle('chg', inp.value !== def); const bad = unknownVars(inp.value); field.classList.toggle('bad', bad.length > 0); const w = field.querySelector('.varwarn'); if (w) w.textContent = bad.length ? '⚠️ 无法识别的变量:' + bad.map(u => '{' + u + '}').join(' ') + '(会原样输出)' : ''; renderTextPreview(); } function buildTextPanel() { const X = state.texts, D = state.textDefaults; $('txPill').textContent = textsChanged() ? '自定义' : '默认值'; const chips = INSERT_VARS.map(v => '' + v + '').join(''); $('txBody').innerHTML = '
    实时预览
    ' + '
    以问题最多的门店「' + esc(prevStore.name) + '」为例,边改边看效果,点【应用到工作台】后保存
    ' + '
    ' + '
    ' + TSPEC.map(g => '
    ' + g.g + '
    ' + esc(g.hint) + '
    ' + '
    ' + g.items.map(it => { const chg = (X[it.p] || '') !== (D[it.p] || '') ? ' chg' : ''; const ctl = it.ta ? '' : ''; return '
    ' + (it.ta ? ctl : '
    ' + ctl + '
    ') + '
    ' + '
    ' + chips + '
    '; }).join('') + '
    ').join(''); document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => { inp.oninput = () => syncTextField(inp); }); document.querySelectorAll('#txBody .varbtn').forEach(btn => { btn.onclick = () => { const row = document.querySelector('#txBody .varchips[data-for="' + btn.dataset.for + '"]'); if (row) row.classList.toggle('on'); }; }); document.querySelectorAll('#txBody .varchips b').forEach(b => { b.onclick = () => { const p = b.parentNode.dataset.for; const inp = document.querySelector('#txBody [data-p="' + p + '"]'); if (!inp) return; insertAtCursor(inp, b.dataset.v); syncTextField(inp); }; }); document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => syncTextField(inp)); } async function applyTexts() { const X = {}; let empty = null; document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => { if (!inp.value.trim() && inp.dataset.p !== 'fb_actions') empty = empty || '「' + inp.dataset.p + '」不能为空'; X[inp.dataset.p] = inp.value; }); if (empty) { toast(empty); return; } const bad = []; Object.keys(X).forEach(k => unknownVars(X[k]).forEach(u => { if (bad.indexOf(u) < 0) bad.push(u); })); try { await api('/api/copytext', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(X), }); closePanels(); await load(state.period); toast(bad.length ? '已保存,但有 ' + bad.length + ' 个变量不认识(' + bad.map(u => '{' + u + '}').join(' ') + '),会原样输出' : '文案已更新,全部门店生效', bad.length ? 4200 : 2400); } catch (e) { toast('保存失败:' + e.message, 3600); } } async function resetTexts() { try { await api('/api/copytext/reset', { method: 'POST' }); closePanels(); await load(state.period); toast('已恢复默认文案'); } catch (e) { toast('恢复失败:' + e.message, 3600); } } function openTexts() { if (!state.data || state.data.empty) { toast('先导入数据再配置文案'); return; } closePanels(); prevStore = state.data.stores.slice().sort((a, b) => b.flags.length - a.flags.length)[0] || state.data.stores[0]; buildTextPanel(); $('txPanel').classList.add('on'); $('btnText').classList.add('on'); $('ov').classList.add('on', 'hi'); } /* ---------------- 导入 ---------------- */ let pickedFile = null; function openImport() { closePanels(); $('imPeriod').value = state.period || new Date().toISOString().slice(0, 7); $('imDate').value = ''; $('imResult').innerHTML = ''; $('dropText').textContent = '点击选择 CSV / Excel 文件,或把文件拖到这里'; $('imGo').disabled = true; pickedFile = null; loadHistory(); $('imPanel').classList.add('on'); $('btnImport').classList.add('on'); $('ov').classList.add('on', 'hi'); } function loadHistory() { api('/api/meta').then(m => { // 只对「当前数据库里还有数据」的周期给出可点的导出按钮 const have = new Set((m.periods || []).map(x => x.period)); $('imHistory').innerHTML = (m.history || []).map(h => { const p = h.period || ''; const ok = p && have.has(p); const hasFile = !!h.file_path; return '
    ' + '' + esc(h.filename) + ' · ' + h.rows_written + ' 行' + '' + (p || '—') + ' · ' + (h.created_at || '').slice(5, 16).replace('T', ' ') + '' + '' + '
    '; }).join('') || '
    还没有导入记录
    '; document.querySelectorAll('#imHistory .hbtn').forEach(b => { b.onclick = e => { e.stopPropagation(); downloadBatchFile(b.dataset.b); }; }); }).catch(() => {}); } /* ---------------- 账号管理 ---------------- */ function openUsers() { closePanels(); $('usPanel').classList.add('on'); loadUsers(); } async function loadUsers() { try { const d = await api('/api/users'); state.userRoles = d.roles; state.usersCache = d.users; const roleMap = {}; d.roles.forEach(r => roleMap[r.key] = r); $('usList').innerHTML = (d.users || []).map(u => { const opts = d.roles.map(r => '').join(''); return '
    ' + '
    ' + esc(u.display_name || u.username) + '' + '' + esc(u.username) + '' + (u.active ? '' : '已停用') + (u.id === (state.me && state.me.id) ? '本人' : '') + '
    ' + '
    ' + '' + '' + (u.username === 'admin' ? '内置账号,不可停用/删除' : '' + '') + '
    ' + '
    最近登录 ' + ((u.last_login || '').replace('T', ' ').slice(5, 16) || '从未') + '
    ' + '
    '; }).join('') || '
    还没有账号
    '; document.querySelectorAll('#usList .urole').forEach(sel => { sel.onchange = async () => { try { await api('/api/users/' + sel.dataset.id, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ role: sel.value }) }); toast('角色已修改'); loadUsers(); } catch (e) { toast(e.message, 3500); loadUsers(); } }; }); document.querySelectorAll('#usList .ubtn').forEach(b => { b.onclick = () => userAction(b.dataset.act, +b.dataset.id); }); } catch (e) { toast('读取账号失败:' + e.message, 3500); } } async function userAction(act, id) { try { if (act === 'toggle') { const u = (state.usersCache || []).find(x => x.id === id); await api('/api/users/' + id, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ active: !(u && u.active) }) }); toast('已更新'); loadUsers(); } else if (act === 'del') { if (!confirm('确定删除这个账号?删除后无法恢复。')) return; await api('/api/users/' + id, { method: 'DELETE' }); toast('已删除'); loadUsers(); } else if (act === 'pw') { const u = (state.usersCache || []).find(x => x.id === id); resetPassword(id, (u && (u.display_name || u.username)) || '该账号'); } } catch (e) { toast(e.message, 3500); } } /* 通用表单弹窗:一次性把所有字段列出来,不再一个个 prompt */ function formModal(opts) { const old = $('fmWrap'); if (old) old.remove(); const rows = opts.fields.map(f => { const id = 'fm_' + f.key; let input; if (f.type === 'select') { input = ''; } else { input = ''; } return '
    ' + input + (f.hint ? '
    ' + f.hint + '
    ' : '') + '
    '; }).join(''); const wrap = document.createElement('div'); wrap.id = 'fmWrap'; wrap.className = 'cfwrap'; wrap.innerHTML = '
    ' + '
    ' + opts.title + '×
    ' + '
    ' + rows + '' + '
    ' + '
    ' + '
    ' + '
    '; document.body.appendChild(wrap); requestAnimationFrame(() => wrap.classList.add('on')); const close = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); }; $('fmX').onclick = close; $('fmCancel').onclick = close; wrap.onclick = e => { if (e.target === wrap) close(); }; const first = $('fm_' + opts.fields[0].key); if (first) setTimeout(() => first.focus(), 60); const fail = m => { const b = $('fmErr'); b.textContent = m; b.style.display = 'block'; }; const submit = async () => { const box = $('fmErr'); box.style.display = 'none'; const vals = {}; opts.fields.forEach(f => { vals[f.key] = ($('fm_' + f.key).value || '').trim(); }); const msg = opts.validate && opts.validate(vals); if (msg) return fail(msg); $('fmOk').disabled = true; try { await opts.onSubmit(vals); close(); } catch (e) { fail(e.message); $('fmOk').disabled = false; } }; $('fmOk').onclick = submit; wrap.querySelectorAll('input').forEach(inp => { inp.onkeydown = e => { if (e.key === 'Enter') submit(); }; }); } /* 新建账号:一次性填完 */ function openUserNew() { formModal({ title: '新建账号', okText: '创建', fields: [ { key: 'username', label: '登录账号', required: true, placeholder: '英文或数字,登录时用', autocomplete: 'off', hint: '只能包含字母、数字、下划线、点或减号' }, { key: 'display_name', label: '显示名称', placeholder: '如:督导·王(不填就用账号名)' }, { key: 'password', label: '初始密码', type: 'password', required: true, autocomplete: 'new-password', hint: '至少 6 位,不能全是数字' }, { key: 'password2', label: '确认密码', type: 'password', required: true, autocomplete: 'new-password' }, { key: 'role', label: '角色', type: 'select', value: 'viewer', options: [ { value: 'viewer', label: '查看 —— 只能看数据' }, { value: 'editor', label: '运营 —— 可导入、同步、导出' }, { value: 'admin', label: '管理员 —— 全部权限,含账号管理' }, ] }, ], validate: v => { if (!v.username) return '请填写登录账号'; if (!/^[A-Za-z0-9_.-]{2,32}$/.test(v.username)) return '登录账号只能用字母、数字、_ . -,2~32 位'; if (v.password.length < 6) return '密码至少 6 位'; if (/^\d+$/.test(v.password)) return '密码不能全是数字'; if (v.password !== v.password2) return '两次输入的密码不一样'; return ''; }, onSubmit: async v => { await api('/api/users', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ username: v.username, password: v.password, role: v.role, display_name: v.display_name }) }); toast('账号「' + (v.display_name || v.username) + '」已创建'); loadUsers(); }, }); } /* 重置密码:同样是表单 */ function resetPassword(id, name) { formModal({ title: '重置「' + name + '」的密码', okText: '重置', fields: [ { key: 'password', label: '新密码', type: 'password', required: true, autocomplete: 'new-password', hint: '至少 6 位,不能全是数字' }, { key: 'password2', label: '确认密码', type: 'password', required: true, autocomplete: 'new-password' }, ], validate: v => { if (v.password.length < 6) return '密码至少 6 位'; if (/^\d+$/.test(v.password)) return '密码不能全是数字'; if (v.password !== v.password2) return '两次输入的密码不一样'; return ''; }, onSubmit: async v => { await api('/api/users/' + id, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ password: v.password, force_change: true }) }); toast('密码已重置,该账号下次登录需要修改'); loadUsers(); }, }); } /* 下载某次导入的原始文件(生意经原样导出,不含任何计算) */ function downloadBatchFile(batchId) { if (!batchId) return; window.location.href = '/api/batch/' + encodeURIComponent(batchId) + '/file'; toast('正在下载原始文件', 2400); } /* 左侧栏【导出原始数据】:当前周期最近一次导入的原件 */ function downloadOriginal() { if (!state.period) return; window.location.href = '/api/export.file?period=' + encodeURIComponent(state.period); toast('正在下载 ' + state.period + ' 的原始文件', 2600); } async function doImport() { if (!pickedFile) return; $('imGo').disabled = true; $('imGo').textContent = '导入中…'; const params = new URLSearchParams({ filename: pickedFile.name }); if ($('imPeriod').value) params.set('period', $('imPeriod').value); if ($('imDate').value) params.set('date', $('imDate').value); try { const r = await api('/api/import?' + params.toString(), { method: 'POST', headers: { 'Content-Type': 'text/csv' }, body: pickedFile, }); $('imResult').innerHTML = '
    ✓ 导入成功:写入 ' + r.rows_written + ' 行,新增门店 ' + r.new_stores + ' 家
    ' + '周期 ' + r.period + (r.date_range && r.date_range.length ? ' · 日期 ' + r.date_range[0] + ' ~ ' + r.date_range[1] : '') + ' · 粒度 ' + (r.granularity === 'daily' ? '按日' : '周期汇总') + (r.skipped ? '
    跳过 ' + r.skipped + ' 行(' + (r.errors[0] || '空行') + ')' : '') + '
    '; toast('导入完成,正在刷新'); await load(r.period); loadHistory(); setTimeout(closePanels, 900); } catch (e) { $('imResult').innerHTML = '
    ✗ ' + esc(e.message) + '
    '; $('imGo').disabled = false; } finally { $('imGo').textContent = '开始导入'; } } /* ---------------- 面板开关 ---------------- */ function closePanels() { ['thPanel', 'imPanel', 'txPanel', 'usPanel'].forEach(id => $(id).classList.remove('on')); ['btnTh', 'btnImport', 'btnText', 'btnUsers'].forEach(id => { const el = $(id); if (el) el.classList.remove('on'); }); $('dw').classList.remove('on'); closePreview(); closeReport(); const ov = $('ov'); ov.classList.remove('on', 'hi'); } function openThresholds() { if (!state.data || state.data.empty) { toast('先导入数据再配置指标'); return; } closePanels(); buildThresholdPanel(); $('thPanel').classList.add('on'); $('btnTh').classList.add('on'); $('ov').classList.add('on', 'hi'); } /* ---------------- 近30天数据同步 ---------------- */ let syncTimer = null; /* 同步确认弹窗:这一步会消耗配额、要跑几分钟,不能点一下就直接执行 */ function syncWindowText() { const end = new Date(); const start = new Date(end.getTime() - 29 * 86400000); const f = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); return f(start) + ' ~ ' + f(end); } function openSyncConfirm() { api('/api/sync/status').then(st => { const s = st.state || {}; if (s.running) { toast('同步正在进行中(' + (s.last_start || '').slice(11) + ' 开始),完成后会自动刷新', 4000); watchSync(); return; } showSyncDialog(st); }).catch(() => showSyncDialog({})); } function showSyncDialog(st) { const old = $('cfWrap'); if (old) old.remove(); const last = (st.last_sync || '').replace('T', ' ').slice(0, 16); const wrap = document.createElement('div'); wrap.id = 'cfWrap'; wrap.className = 'cfwrap'; wrap.innerHTML = '
    ' + '
    确认同步近30天数据?×
    ' + '
    ' + '
    统计窗口' + syncWindowText() + '
    ' + '
    数据来源抖音来客 · 生意经报表集市
    ' + '
    预计耗时3 ~ 8 分钟(平台排队时间不定)
    ' + '
    会消耗1 次建任务配额(每天约 30 次)
    ' + '
    上次同步' + (last || '还没有同步过') + '
    ' + '
    同步期间会弹出一个浏览器窗口,不影响你继续使用工作台;' + '完成后数据会自动刷新。系统每天 01:00 也会自动同步一次。
    ' + '
    ' + '
    ' + '' + '' + '
    ' + '
    '; document.body.appendChild(wrap); requestAnimationFrame(() => wrap.classList.add('on')); const close = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); }; $('cfClose').onclick = close; $('cfCancel').onclick = close; wrap.onclick = e => { if (e.target === wrap) close(); }; $('cfOk').onclick = () => { close(); startSync(); }; document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } }); } async function startSync() { try { await api('/api/sync', { method: 'POST' }); toast('已开始同步近30天数据,约 3-5 分钟(期间可以继续用)', 4200); watchSync(); } catch (e) { toast('同步启动失败:' + e.message, 4200); } } async function watchSync() { clearInterval(syncTimer); syncTimer = setInterval(async () => { try { const s = await api('/api/sync/status'); const st = s.state || {}; if (!st.running) { clearInterval(syncTimer); if (st.last_ok) { toast('数据同步完成,正在刷新', 4000); await load(state.period); } else { toast('同步失败:' + (st.last_msg || '看服务窗口日志'), 6000); } } } catch (e) { clearInterval(syncTimer); } }, 5000); } /* ---------------- 导出 ---------------- */ /* 导出 = 下载原始文件,逻辑在 downloadOriginal / downloadBatchFile */ /* ---------------- 事件 ---------------- */ function bind() { $('period').onchange = e => { state.period = e.target.value; load(e.target.value); }; $('q').oninput = e => { state.q = e.target.value.trim(); state.page = 0; renderTable(); }; $('fcity').onchange = e => { state.city = e.target.value; state.page = 0; renderTable(); }; $('fcat').onchange = e => { state.cat = e.target.value; state.page = 0; renderTable(); }; $('fscore').onchange = e => { state.score = e.target.value; state.page = 0; renderTable(); }; $('fsort').onchange = e => { state.sort = e.target.value; state.page = 0; renderTable(); }; $('forder').onchange = e => { state.order = e.target.value; state.page = 0; renderTable(); }; $('prev').onclick = () => { state.page--; renderTable(); }; $('next').onclick = () => { state.page++; renderTable(); }; $('btnTh').onclick = () => $('thPanel').classList.contains('on') ? closePanels() : openThresholds(); $('btnText').onclick = () => $('txPanel').classList.contains('on') ? closePanels() : openTexts(); $('btnImport').onclick = openImport; $('btnSync').onclick = openSyncConfirm; $('btnUsers').onclick = () => $('usPanel').classList.contains('on') ? closePanels() : openUsers(); $('usClose').onclick = closePanels; $('usNew').onclick = openUserNew; $('btnPw').onclick = () => showChangePw(false); $('btnLogout').onclick = async () => { await fetch('/api/logout', { method: 'POST' }).catch(() => {}); location.replace('/login.html'); }; $('btnExportCur').onclick = downloadOriginal; $('thApply').onclick = applyThresholds; $('thReset').onclick = resetThresholds; $('thClose').onclick = closePanels; $('txApply').onclick = applyTexts; $('txReset').onclick = resetTexts; $('txClose').onclick = closePanels; $('imClose').onclick = closePanels; $('imCancel').onclick = closePanels; $('imGo').onclick = doImport; $('ov').onclick = closePanels; document.addEventListener('keydown', e => { if (e.key === 'Escape') { const rp = $('rpWrap'); const pv = $('pvWrap'); if (rp && rp.classList.contains('on')) closeReport(); else if (pv && pv.classList.contains('on')) closePreview(); else closePanels(); } }); const drop = $('drop'), file = $('file'); drop.onclick = () => file.click(); file.onchange = () => { pickedFile = file.files[0] || null; $('dropText').textContent = pickedFile ? pickedFile.name : '点击选择 CSV / Excel 文件,或把文件拖到这里'; $('imGo').disabled = !pickedFile; }; ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); })); ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); })); drop.addEventListener('drop', e => { const f = e.dataTransfer.files[0]; if (!f) return; if (!/\.(csv|xlsx)$/i.test(f.name)) { toast('只支持 CSV 或 .xlsx 文件(旧版 .xls 请先另存为 .xlsx)'); return; } pickedFile = f; $('dropText').textContent = f.name; $('imGo').disabled = false; }); window.addEventListener('resize', () => { [c1, c2, c3].forEach(c => c && c.resize()); }); } function handleHash() { const h = decodeURIComponent((location.hash || '').slice(1)); if (!h) return; if (h === 'TH') return openThresholds(); if (h === 'TX') return openTexts(); if (h === 'IM') return openImport(); if (state.data && !state.data.empty) openDrawer(h); } window.addEventListener('hashchange', handleHash); bind(); (async function boot() { try { const me = await api('/api/me'); state.me = me.user; applyPerms(); await load(); handleHash(); } catch (e) { if (!String(e.message).includes('登录')) toast('加载失败:' + e.message, 4000); } })();