|
|
@@ -0,0 +1,1694 @@
|
|
|
+/* 门店检测工作台 —— 前端逻辑
|
|
|
+ 数据全部来自后端 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(/</g, '<').replace(/"/g, '"');
|
|
|
+const pct = (v, d = 0) => (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 =
|
|
|
+ '<div class="cfcard" style="width:min(430px,92vw)">' +
|
|
|
+ '<div class="cfhead"><b>' + (forced ? '请先修改初始密码' : '修改密码') + '</b>' +
|
|
|
+ (forced ? '' : '<span class="pvclose" id="pwX">×</span>') + '</div>' +
|
|
|
+ '<div class="cfbody">' +
|
|
|
+ (forced ? '<div class="cfnote" style="margin:0 0 12px">出于安全考虑,初始密码必须先改掉才能继续使用。</div>' : '') +
|
|
|
+ (forced ? '' : '<div class="cfrow"><span class="k">原密码</span><span class="v">' +
|
|
|
+ '<input class="pwinput" id="pwOld" type="password" autocomplete="current-password"></span></div>') +
|
|
|
+ '<div class="cfrow"><span class="k">新密码</span><span class="v">' +
|
|
|
+ '<input class="pwinput" id="pwNew" type="password" autocomplete="new-password"></span></div>' +
|
|
|
+ '<div class="cfrow"><span class="k">再输一次</span><span class="v">' +
|
|
|
+ '<input class="pwinput" id="pwNew2" type="password" autocomplete="new-password"></span></div>' +
|
|
|
+ '<div class="cfnote">至少 6 位,不能全是数字。</div>' +
|
|
|
+ '<div class="err" id="pwErr" style="display:none"></div>' +
|
|
|
+ '</div>' +
|
|
|
+ '<div class="cffoot">' +
|
|
|
+ (forced ? '' : '<button id="pwCancel">取消</button>') +
|
|
|
+ '<button class="primary" id="pwOk">确认修改</button>' +
|
|
|
+ '</div>' +
|
|
|
+ '</div>';
|
|
|
+ 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 '<option value="' + p.period + '">' + tag + '(' + p.stores + '店)</option>';
|
|
|
+ }).join('') || '<option>暂无数据</option>';
|
|
|
+ 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 =>
|
|
|
+ '<div class="kpi' + (k.hot ? ' hot' : '') + (state.kpi === k.k ? ' on' : '') +
|
|
|
+ '" data-k="' + k.k + '"><div class="v">' + k.value + '<span class="u">家</span></div>' +
|
|
|
+ '<div class="l">' + esc(k.label) + '</div></div>'
|
|
|
+ ).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 = '<option value="">全部城市</option>' + cities.map(c => '<option>' + esc(c) + '</option>').join('');
|
|
|
+ catSel.innerHTML = '<option value="">全部问题类型</option>' + cats.map(c => '<option>' + esc(c) + '</option>').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 '<span class="muted">—</span>';
|
|
|
+ const nums = [...t.matchAll(/第\s*(\d+)\s*名/g)].map(m => parseInt(m[1], 10));
|
|
|
+ if (!nums.length) return '<span class="warn-text">入围</span>';
|
|
|
+ const best = Math.min(...nums);
|
|
|
+ return '<span class="good-text">第' + best + '名</span>';
|
|
|
+}
|
|
|
+
|
|
|
+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 '<tr class="row" data-id="' + s.id + '"><td class="cl-name"><div class="nm"><span class="dot" style="background:' +
|
|
|
+ sevColorOf(s.sev) + '"></span>' + esc(s.name) + '</div><div class="sid">' + s.id + '</div></td>' +
|
|
|
+ '<td class="cl-city">' + esc(s.city) + '</td>' +
|
|
|
+ '<td class="num"' + warn(scoreBad, 'R') + '>' +
|
|
|
+ (scoreUnrated ? '<span class="muted">未开分</span>' : (s.score == null ? '-' : s.score.toFixed(1))) + '</td>' +
|
|
|
+ '<td class="num">' + money(s.visit) + '</td>' +
|
|
|
+ '<td class="num">' + money(s.deal) + '</td>' +
|
|
|
+ '<td class="num"' + warn(s.hx_rate != null && s.hx_rate < T.hx_rate_base, 'B') + '>' + pct(s.hx_rate) + '</td>' +
|
|
|
+ '<td class="num"' + warn((s.hx_amt || 0) < T.hx_amt_base, 'B') + '>' + money(s.hx_amt) + '</td>' +
|
|
|
+ '<td class="num"' + warn(pendBad, 'B') + '>' + money(pend) + '</td>' +
|
|
|
+ '<td class="num"' + warn(tdBad, 'R') + '>' + pct(s.td, 2) + '</td>' +
|
|
|
+ '<td class="num"' + warn(tsBad, 'R') + '>' + pct(s.ts, 2) + '</td>' +
|
|
|
+ '<td class="num"' + warn(cpBad, 'R') + '>' +
|
|
|
+ (s.cp == null ? '<span class="muted">无评价</span>' : pct(s.cp, 2)) + '</td>' +
|
|
|
+ '<td class="num"' + warn(replyBad, 'B') + '>' + pct(s.reply) + '</td>' +
|
|
|
+ '<td class="num cl-rank">' + rankText(s.rank) + '</td>' +
|
|
|
+ '<td class="cl-tags">' + s.problems.slice(0, 5).map(p =>
|
|
|
+ '<span class="tag t-' + p.level + '">' + esc(p.cat) + '</span>').join('') +
|
|
|
+ (s.problems.length > 5 ? '<span class="tag t-T">+' + (s.problems.length - 5) + '</span>' : '') +
|
|
|
+ '</td></tr>';
|
|
|
+}
|
|
|
+
|
|
|
+/* 列表:分页渲染,每页 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('') ||
|
|
|
+ '<tr><td colspan="13" style="color:#a1957d;padding:30px;text-align:center">没有符合筛选条件的门店</td></tr>';
|
|
|
+ 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 = '<div class="dwhead"><h2>' + esc(s.name) + '</h2><div class="sid">' + s.id + ' · ' + esc(s.city) +
|
|
|
+ ' · 严重度 ' + s.sev.toFixed(1) + (s.days ? ' · ' + s.days + ' 天数据' : '') + '</div>' +
|
|
|
+ '<span class="dwclose" id="dwClose" title="关闭">×</span></div><div class="dwbody">';
|
|
|
+
|
|
|
+ // 一句话结论
|
|
|
+ h += '<div class="dxhead t-' + s.tier + '">' +
|
|
|
+ '<div class="dxhd">' + esc(D.headline) + '</div>' +
|
|
|
+ '<div class="dxmeta">共命中 ' + D.problems.length + ' 项 · 整改动作 ' + D.actions.length + ' 条</div></div>';
|
|
|
+
|
|
|
+ // 主因 + 次要问题
|
|
|
+ if (D.primary) {
|
|
|
+ h += '<div class="dxsec"><div class="dxt">主因</div>' +
|
|
|
+ '<div class="dxrow primary"><span class="tag t-' + D.primary.level + '">' + esc(D.primary.text) + '</span>' +
|
|
|
+ '<div class="dxact">' + esc(D.primary.action) + '</div></div>';
|
|
|
+ const rest = D.problems.filter(p => p !== D.primary);
|
|
|
+ if (rest.length) {
|
|
|
+ h += '<div class="dxt" style="margin-top:12px">其余问题(' + rest.length + ' 项)</div>' +
|
|
|
+ rest.map(p => '<div class="dxrow"><span class="tag t-' + p.level + '">' + esc(p.text) + '</span>' +
|
|
|
+ '<div class="dxact">' + esc(p.action) + '</div></div>').join('');
|
|
|
+ }
|
|
|
+ h += '</div>';
|
|
|
+ } else {
|
|
|
+ h += '<div class="dxsec"><div class="dxnone">' + esc(state.texts.fb_no_issue) + '</div></div>';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 指标条
|
|
|
+ h += '<div class="dxt" style="margin-top:6px">关键指标</div>';
|
|
|
+ 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 => '<span class="mk" style="left:' + (x / m.max * 100).toFixed(1) + '%"></span>').join('')
|
|
|
+ : '<span class="mk" style="left:' + (m.base / m.max * 100).toFixed(1) + '%"></span>' +
|
|
|
+ '<span class="mk" style="left:' + (m.target / m.max * 100).toFixed(1) + '%;background:#6f9d7f"></span>';
|
|
|
+ return '<div class="mrow"><div class="lab"><span>' + m.k + '</span><span>' + (v == null ? '-' : m.fmt(v)) + '</span></div>' +
|
|
|
+ '<div class="track"><div class="fill" style="width:' + w.toFixed(1) + '%;background:' + barColor(m, v) + '"></div>' + marks + '</div></div>';
|
|
|
+ }).join('');
|
|
|
+ if (s.rank) {
|
|
|
+ h += '<div class="rankbox"><span class="rk">上榜</span>' + esc(s.rank) + '</div>';
|
|
|
+ }
|
|
|
+ h += '<div class="mrow" style="color:#93876f;font-size:11.5px">访问 ' + money(s.visit) + ' 次 · 发布视频 ' + s.videos +
|
|
|
+ ' 个 · 成交券 ' + money(s.deal) + ' 张 · 点亮 ' + (s.lit || 0) + ' 次</div>';
|
|
|
+
|
|
|
+
|
|
|
+ h += '<div class="dwbrow">' +
|
|
|
+ '<button class="copybtn" id="pvBtn">预览分析结果</button>' +
|
|
|
+ '<button class="rpentry" id="rpBtn">导出报告</button></div>' +
|
|
|
+ '<div class="hint">【预览分析结果】按文案模板生成发给督导的反馈,可直接改再复制;' +
|
|
|
+ '【导出报告】生成一页 A4 门店分析报告,可打印成 PDF 或下载 HTML 存档。</div></div>';
|
|
|
+ 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 =
|
|
|
+ '<div class="pvcard"><div class="pvhead"><b>预览分析结果</b>' +
|
|
|
+ '<span class="pvclose" id="pvClose" title="关闭">×</span></div>' +
|
|
|
+ '<textarea id="pvText" spellcheck="false"></textarea>' +
|
|
|
+ '<div class="pvfoot"><button id="pvRegen">重新生成</button>' +
|
|
|
+ '<button class="primary" id="pvCopy">复制定稿文案</button></div></div>';
|
|
|
+ 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 => '<span class="mk" style="left:' + (x / m.max * 100).toFixed(1) + '%"></span>').join('')
|
|
|
+ : '<span class="mk" style="left:' + (m.base / m.max * 100).toFixed(1) + '%"></span>' +
|
|
|
+ '<span class="mk t" style="left:' + (m.target / m.max * 100).toFixed(1) + '%"></span>';
|
|
|
+ return '<div class="rpbar"><div class="lab"><span>' + m.k + '</span><b>' + shown + '</b></div>' +
|
|
|
+ '<div class="track"><div class="fill" style="width:' + w.toFixed(1) + '%;background:' +
|
|
|
+ barColor(m, v) + '"></div>' + marks + '</div></div>';
|
|
|
+}
|
|
|
+
|
|
|
+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 => '<div><div class="k">' + c[0] + '</div><div class="v">' + c[1] +
|
|
|
+ (c[2] ? '<small>' + c[2] + '</small>' : '') + '</div></div>').join('');
|
|
|
+
|
|
|
+ // 风险表:主因打头,其余为次要问题;不再把所有问题一次性列出
|
|
|
+ const riskList = D.ordered;
|
|
|
+ const riskRows = riskList.map((p, i) =>
|
|
|
+ '<tr><td class="no">' + (i + 1) + '</td>' +
|
|
|
+ '<td><span class="rptag ' + p.level + '">' + esc(p.text) + '</span>' +
|
|
|
+ (i === 0 ? '<span class="rpflag">主因</span>' : '') + '</td>' +
|
|
|
+ '<td class="act">' + esc(p.action) + '</td></tr>').join('');
|
|
|
+
|
|
|
+ const insights = insightTexts(s, X, D).map((t, i) =>
|
|
|
+ '<li><span class="idx">' + (i + 1) + '</span>' + esc(t) + '</li>').join('');
|
|
|
+
|
|
|
+ const actions = D.actions.map(a =>
|
|
|
+ '<li><span class="ic">✓</span>' + esc(a) + '</li>').join('');
|
|
|
+
|
|
|
+ return '<div class="rpdoc">' +
|
|
|
+ '<header class="rphead"><span class="rpbrand">怡呵美 · 水光会员店</span>' +
|
|
|
+ '<h1>门店检测分析报告</h1>' +
|
|
|
+ '<div class="rpmeta">' +
|
|
|
+ '<span>门店 <b>' + esc(s.name) + '</b></span>' +
|
|
|
+ '<span>门店ID <b>' + s.id + '</b></span>' +
|
|
|
+ '<span>城市 <b>' + esc(s.city || '-') + '</b></span>' +
|
|
|
+ '<span>统计周期 <b>' + state.period + '</b></span>' +
|
|
|
+ (s.days ? '<span>有效数据 <b>' + s.days + ' 天</b></span>' : '') +
|
|
|
+ '<span>生成时间 <b>' + stamp + '</b></span>' +
|
|
|
+ '</div></header>' +
|
|
|
+
|
|
|
+ '<div class="rpsum">' +
|
|
|
+ '<div class="rplead"><div class="k">综合严重度</div>' +
|
|
|
+ '<div class="v">' + s.sev.toFixed(1) + '<span class="u">/ 3.0</span></div>' +
|
|
|
+ '<span class="rpsev ' + sev.cls + '">' + sev.name + '</span></div>' +
|
|
|
+ '<div class="rpstats">' +
|
|
|
+ '<div><b>' + D.problems.length + '</b><span>命中问题</span></div>' +
|
|
|
+ '<div><b>' + (s.score == null ? '-' : s.score.toFixed(1)) + '</b><span>团购评分</span></div>' +
|
|
|
+ '<div><b>' + pct(s.hx_rate) + '</b><span>核销率</span></div>' +
|
|
|
+ '<div><b>' + money(s.hx_amt) + '</b><span>核销金额(元)</span></div>' +
|
|
|
+ '<div><b>' + cnt(pend) + '</b><span>待核销(单)</span></div>' +
|
|
|
+ '</div>' +
|
|
|
+ '</div>' +
|
|
|
+
|
|
|
+ '<h2><span class="n">01</span>关键指标</h2>' +
|
|
|
+ '<div class="rpmetrics">' + metricsOf(s, T).map(m => reportBar(m, s)).join('') + '</div>' +
|
|
|
+ '<div class="rplegend">刻度:<i></i>考核基准,<i class="t"></i>平台目标;' +
|
|
|
+ '退单率 / 投诉率 / 差评率为三级违规线(预警 · 一般严重 · 非常严重)</div>' +
|
|
|
+
|
|
|
+ '<h2><span class="n">02</span>问题诊断</h2>' +
|
|
|
+ '<div class="rpdx">' + esc(D.headline) + '</div>' +
|
|
|
+ (riskList.length
|
|
|
+ ? '<table class="rpflags"><thead><tr><th></th><th>问题</th><th>建议动作</th></tr></thead>' +
|
|
|
+ '<tbody>' + riskRows + '</tbody></table>'
|
|
|
+ : '<div class="rpmuted">' + esc(X.fb_no_issue) + '</div>') +
|
|
|
+
|
|
|
+ '<h2><span class="n">03</span>经营数据(近30天)</h2>' +
|
|
|
+ '<div class="rpgrid">' + cards + '</div>' +
|
|
|
+
|
|
|
+ (insights ? '<h2><span class="n">04</span>分析洞察</h2><ul class="rpol">' + insights + '</ul>' : '') +
|
|
|
+
|
|
|
+ (actions ? '<h2><span class="n">05</span>门店调整动作</h2><ul class="rpacts">' + actions + '</ul>' : '') +
|
|
|
+
|
|
|
+ '<div class="rpfoot">' +
|
|
|
+ '本报告由「门店检测工作台」于 ' + stamp + ' 自动生成,数据周期 ' + state.period + '。<br>' +
|
|
|
+ '口径:核销率 = 核销券数 ÷ 意向成交券数;差评率在新增评价 ≥10 时取新增口径,否则取累计口径;' +
|
|
|
+ '评分取周期内最近一次有值数据(含 0 分未开分)。考核基准值可在工作台【指标配置】中调整。' +
|
|
|
+ '</div>' +
|
|
|
+ '</div>';
|
|
|
+}
|
|
|
+
|
|
|
+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 = '<div class="rpcard rp">' +
|
|
|
+ '<div class="rptoolbar">' +
|
|
|
+ '<div class="rpname">' + esc(s.name) +
|
|
|
+ '<span class="rpsub">' + state.period + ' · 单店分析报告</span></div>' +
|
|
|
+ '<button class="rpbtn" id="rpDl">下载 HTML</button>' +
|
|
|
+ '<button class="rpbtn primary" id="rpPrint">打印 / 另存为 PDF</button>' +
|
|
|
+ '<button class="rpbtn x" id="rpClose" title="关闭">×</button>' +
|
|
|
+ '</div>' + reportDocHtml(s) + '</div>';
|
|
|
+ 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 = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' +
|
|
|
+ '<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
|
+ '<title>' + esc('门店分析报告 · ' + s.name + ' · ' + state.period) + '</title>' +
|
|
|
+ '<style>' + reportCssCache + '</style>' +
|
|
|
+ '<style>body{margin:0;background:#f8f4ea;padding:24px 16px}' +
|
|
|
+ '.rpdoc{max-width:820px;margin:0 auto;background:#fff;border-radius:12px;' +
|
|
|
+ 'box-shadow:0 8px 30px rgba(120,90,20,.12)}' +
|
|
|
+ '.phtop{max-width:820px;margin:0 auto 14px;text-align:right}' +
|
|
|
+ '.phtop button{font-family:inherit;font-size:12px;padding:8px 16px;border-radius:8px;cursor:pointer;border:0;' +
|
|
|
+ 'background:linear-gradient(135deg,#eab94e,#d29a29 48%,#b57e15);color:#fff;font-weight:600}' +
|
|
|
+ '@media print{body{padding:0;background:#fff}.phtop{display:none}' +
|
|
|
+ '.rpdoc{max-width:none;box-shadow:none;border-radius:0}}' +
|
|
|
+ '</style></head><body>' +
|
|
|
+ '<div class="phtop"><button onclick="window.print()">打印 / 另存为 PDF</button></div>' +
|
|
|
+ '<div class="rp">' + reportDocHtml(s) + '</div>' +
|
|
|
+ '</body></html>';
|
|
|
+ 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 '<div class="thgroup"><div class="gt">' + g.g + '</div><div class="gh">' + g.hint + '</div>' +
|
|
|
+ '<div class="throw' + cls + '">' + 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 '<div class="thfield' + chg + '"><label>' + it.l + '</label><div class="inp">' +
|
|
|
+ '<input type="number" step="' + (it.step || '0.01') + '" data-p="' + it.p + '" data-pct="' + isPct +
|
|
|
+ '" value="' + uiOf(T, it.p, isPct) + '"><span class="u">' + unitOf(g) + '</span></div></div>';
|
|
|
+ }).join('') + '</div></div>';
|
|
|
+ }).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,
|
|
|
+ '<span class="badvar">{$1}</span>');
|
|
|
+ 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 => '<b data-v="{' + v + '}">' + v + '</b>').join('');
|
|
|
+ $('txBody').innerHTML =
|
|
|
+ '<div class="thgroup"><div class="gt">实时预览</div>' +
|
|
|
+ '<div class="gh">以问题最多的门店「' + esc(prevStore.name) + '」为例,边改边看效果,点【应用到工作台】后保存</div>' +
|
|
|
+ '<div class="txbad" id="txBad"></div>' +
|
|
|
+ '<div class="txprev" id="txPrev"></div></div>' +
|
|
|
+ TSPEC.map(g =>
|
|
|
+ '<div class="thgroup"><div class="gt">' + g.g + '</div><div class="gh">' + esc(g.hint) + '</div>' +
|
|
|
+ '<div class="throw single">' + g.items.map(it => {
|
|
|
+ const chg = (X[it.p] || '') !== (D[it.p] || '') ? ' chg' : '';
|
|
|
+ const ctl = it.ta
|
|
|
+ ? '<textarea rows="7" data-p="' + it.p + '">' + esc(X[it.p]) + '</textarea>'
|
|
|
+ : '<input type="text" data-p="' + it.p + '" value="' + esc(X[it.p]) + '">';
|
|
|
+ return '<div class="thfield' + chg + '"><label>' + it.l +
|
|
|
+ '<button type="button" class="varbtn" data-for="' + it.p + '">+ 变量</button></label>' +
|
|
|
+ (it.ta ? ctl : '<div class="inp">' + ctl + '</div>') +
|
|
|
+ '<div class="varwarn"></div>' +
|
|
|
+ '<div class="varchips" data-for="' + it.p + '">' + chips + '</div></div>';
|
|
|
+ }).join('') + '</div></div>').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 '<div class="hrow">' +
|
|
|
+ '<span class="hmain" title="' + esc(h.filename) + '"><b>' + esc(h.filename) + '</b> · ' +
|
|
|
+ h.rows_written + ' 行</span>' +
|
|
|
+ '<span class="hmeta">' + (p || '—') + ' · ' +
|
|
|
+ (h.created_at || '').slice(5, 16).replace('T', ' ') + '</span>' +
|
|
|
+ '<button class="hbtn" data-b="' + h.id + '"' + (hasFile ? '' : ' disabled') +
|
|
|
+ ' title="' + (hasFile ? '下载当时的原始文件(与生意经完全一致)'
|
|
|
+ : '这个批次没有留存原始文件') + '">原始文件</button>' +
|
|
|
+ '</div>';
|
|
|
+ }).join('') || '<div style="color:#a1957d">还没有导入记录</div>';
|
|
|
+
|
|
|
+ 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 =>
|
|
|
+ '<option value="' + r.key + '"' + (r.key === u.role ? ' selected' : '') + '>' +
|
|
|
+ r.label + '</option>').join('');
|
|
|
+ return '<div class="urow' + (u.active ? '' : ' off') + '">' +
|
|
|
+ '<div class="uinfo"><b>' + esc(u.display_name || u.username) + '</b>' +
|
|
|
+ '<span class="uname">' + esc(u.username) + '</span>' +
|
|
|
+ (u.active ? '' : '<span class="uoff">已停用</span>') +
|
|
|
+ (u.id === (state.me && state.me.id) ? '<span class="ume">本人</span>' : '') +
|
|
|
+ '</div>' +
|
|
|
+ '<div class="uctl">' +
|
|
|
+ '<select class="urole" data-id="' + u.id + '">' + opts + '</select>' +
|
|
|
+ '<button class="ubtn" data-act="pw" data-id="' + u.id + '">重置密码</button>' +
|
|
|
+ (u.username === 'admin'
|
|
|
+ ? '<span class="ubadge">内置账号,不可停用/删除</span>'
|
|
|
+ : '<button class="ubtn" data-act="toggle" data-id="' + u.id + '">' +
|
|
|
+ (u.active ? '停用' : '启用') + '</button>' +
|
|
|
+ '<button class="ubtn danger" data-act="del" data-id="' + u.id + '">删除</button>') +
|
|
|
+ '</div>' +
|
|
|
+ '<div class="umeta">最近登录 ' + ((u.last_login || '').replace('T', ' ').slice(5, 16) || '从未') + '</div>' +
|
|
|
+ '</div>';
|
|
|
+ }).join('') || '<div style="color:#a1957d">还没有账号</div>';
|
|
|
+
|
|
|
+ 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 = '<select class="pwinput" id="' + id + '">' + f.options.map(o =>
|
|
|
+ '<option value="' + o.value + '"' + (o.value === f.value ? ' selected' : '') + '>' +
|
|
|
+ o.label + '</option>').join('') + '</select>';
|
|
|
+ } else {
|
|
|
+ input = '<input class="pwinput" id="' + id + '" type="' + (f.type || 'text') + '"' +
|
|
|
+ (f.placeholder ? ' placeholder="' + esc(f.placeholder) + '"' : '') +
|
|
|
+ (f.value ? ' value="' + esc(f.value) + '"' : '') +
|
|
|
+ (f.autocomplete ? ' autocomplete="' + f.autocomplete + '"' : '') + '>';
|
|
|
+ }
|
|
|
+ return '<div class="fmfield"><label class="fmlabel">' + f.label +
|
|
|
+ (f.required ? '<span class="fmreq">*</span>' : '') + '</label>' + input +
|
|
|
+ (f.hint ? '<div class="fmhint">' + f.hint + '</div>' : '') + '</div>';
|
|
|
+ }).join('');
|
|
|
+
|
|
|
+ const wrap = document.createElement('div');
|
|
|
+ wrap.id = 'fmWrap';
|
|
|
+ wrap.className = 'cfwrap';
|
|
|
+ wrap.innerHTML =
|
|
|
+ '<div class="cfcard" style="width:min(440px,92vw)">' +
|
|
|
+ '<div class="cfhead"><b>' + opts.title + '</b><span class="pvclose" id="fmX">×</span></div>' +
|
|
|
+ '<div class="cfbody">' + rows +
|
|
|
+ '<div class="err" id="fmErr" style="display:none"></div>' +
|
|
|
+ '</div>' +
|
|
|
+ '<div class="cffoot"><button id="fmCancel">取消</button>' +
|
|
|
+ '<button class="primary" id="fmOk">' + (opts.okText || '确定') + '</button></div>' +
|
|
|
+ '</div>';
|
|
|
+ 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 =
|
|
|
+ '<div class="ok">✓ 导入成功:写入 <b>' + r.rows_written + '</b> 行,新增门店 <b>' + r.new_stores + '</b> 家<br>' +
|
|
|
+ '周期 ' + r.period + (r.date_range && r.date_range.length ? ' · 日期 ' + r.date_range[0] + ' ~ ' + r.date_range[1] : '') +
|
|
|
+ ' · 粒度 ' + (r.granularity === 'daily' ? '按日' : '周期汇总') +
|
|
|
+ (r.skipped ? '<br>跳过 ' + r.skipped + ' 行(' + (r.errors[0] || '空行') + ')' : '') + '</div>';
|
|
|
+ toast('导入完成,正在刷新');
|
|
|
+ await load(r.period);
|
|
|
+ loadHistory();
|
|
|
+ setTimeout(closePanels, 900);
|
|
|
+ } catch (e) {
|
|
|
+ $('imResult').innerHTML = '<div class="bad">✗ ' + esc(e.message) + '</div>';
|
|
|
+ $('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 =
|
|
|
+ '<div class="cfcard">' +
|
|
|
+ '<div class="cfhead"><b>确认同步近30天数据?</b><span class="pvclose" id="cfClose">×</span></div>' +
|
|
|
+ '<div class="cfbody">' +
|
|
|
+ '<div class="cfrow"><span class="k">统计窗口</span><span class="v">' + syncWindowText() + '</span></div>' +
|
|
|
+ '<div class="cfrow"><span class="k">数据来源</span><span class="v">抖音来客 · 生意经报表集市</span></div>' +
|
|
|
+ '<div class="cfrow"><span class="k">预计耗时</span><span class="v">3 ~ 8 分钟(平台排队时间不定)</span></div>' +
|
|
|
+ '<div class="cfrow"><span class="k">会消耗</span><span class="v">1 次建任务配额(每天约 30 次)</span></div>' +
|
|
|
+ '<div class="cfrow"><span class="k">上次同步</span><span class="v">' + (last || '还没有同步过') + '</span></div>' +
|
|
|
+ '<div class="cfnote">同步期间会弹出一个浏览器窗口,<b>不影响你继续使用工作台</b>;' +
|
|
|
+ '完成后数据会自动刷新。系统每天 01:00 也会自动同步一次。</div>' +
|
|
|
+ '</div>' +
|
|
|
+ '<div class="cffoot">' +
|
|
|
+ '<button id="cfCancel">取消</button>' +
|
|
|
+ '<button class="primary" id="cfOk">开始同步</button>' +
|
|
|
+ '</div>' +
|
|
|
+ '</div>';
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+})();
|