/* 门店检测工作台 —— 前端逻辑
数据全部来自后端 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 位,不能全是数字。
' +
'
' +
'
' +
'' +
'
';
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)) + '
' +
'
';
}).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 + '
' +
'
';
}
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 '' +
'
' +
'
' +
'
综合严重度
' +
'
' + 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门店调整动作
' : '') +
'' +
'
';
}
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 = '' +
'
' + 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 '
';
}).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 + '×
' +
'
' +
'' +
'
';
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);
}
})();