app.js 78 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694
  1. /* 门店检测工作台 —— 前端逻辑
  2. 数据全部来自后端 API:/api/dashboard、/api/thresholds、/api/import、/api/export.csv
  3. 前端不做检测计算,只负责渲染,规则唯一来源在 backend/rules.py */
  4. 'use strict';
  5. const PAGE_SIZE = 10; // 每页条数
  6. const state = {
  7. period: null, kpi: 'ALL', city: '', cat: '', score: '', q: '', sort: 'sev', order: 'desc',
  8. page: 0, rows: [],
  9. data: null, thresholds: null, defaults: null, texts: null, textDefaults: null,
  10. };
  11. const $ = (id) => document.getElementById(id);
  12. const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
  13. const pct = (v, d = 0) => (v == null ? '-' : (v * 100).toFixed(d) + '%');
  14. const money = (v) => Math.round(v || 0).toLocaleString();
  15. const cnt = (v) => String(Math.round(v || 0)); // 计数类:不带千分位
  16. function toast(msg, ms) {
  17. const t = $('toast');
  18. t.textContent = msg;
  19. t.classList.add('on');
  20. clearTimeout(t._tm);
  21. t._tm = setTimeout(() => t.classList.remove('on'), ms || 2400);
  22. }
  23. async function api(path, opt) {
  24. const r = await fetch(path, opt);
  25. const ct = r.headers.get('content-type') || '';
  26. const body = ct.includes('json') ? await r.json() : await r.text();
  27. if (r.status === 401) { location.replace('/login.html'); throw new Error('请先登录'); }
  28. if (!r.ok) throw new Error((body && body.error) || ('请求失败 ' + r.status));
  29. return body;
  30. }
  31. /* ---------------- 当前账号与权限 ---------------- */
  32. function can(perm) {
  33. return !!(state.me && (state.me.perms || []).includes(perm));
  34. }
  35. /* 按权限显示/隐藏功能入口 */
  36. function applyPerms() {
  37. const map = {
  38. btnTh: 'config', btnText: 'config', btnImport: 'import',
  39. btnSync: 'sync', btnExportCur: 'export', btnUsers: 'users',
  40. };
  41. Object.keys(map).forEach(id => {
  42. const el = $(id);
  43. if (el) el.style.display = can(map[id]) ? '' : 'none';
  44. });
  45. if (state.me) {
  46. const nm = state.me.display_name || state.me.username;
  47. if ($('whoName')) $('whoName').textContent = nm;
  48. if ($('whoRole')) $('whoRole').textContent = state.me.role_label;
  49. if ($('whoAvatar')) $('whoAvatar').textContent = nm.slice(0, 1);
  50. }
  51. if (state.me && state.me.must_change) showChangePw(true);
  52. }
  53. /* 强制/主动修改密码 */
  54. function showChangePw(forced) {
  55. closePanels();
  56. const old = $('pwWrap');
  57. if (old) old.remove();
  58. const wrap = document.createElement('div');
  59. wrap.id = 'pwWrap';
  60. wrap.className = 'cfwrap';
  61. wrap.innerHTML =
  62. '<div class="cfcard" style="width:min(430px,92vw)">' +
  63. '<div class="cfhead"><b>' + (forced ? '请先修改初始密码' : '修改密码') + '</b>' +
  64. (forced ? '' : '<span class="pvclose" id="pwX">&times;</span>') + '</div>' +
  65. '<div class="cfbody">' +
  66. (forced ? '<div class="cfnote" style="margin:0 0 12px">出于安全考虑,初始密码必须先改掉才能继续使用。</div>' : '') +
  67. (forced ? '' : '<div class="cfrow"><span class="k">原密码</span><span class="v">' +
  68. '<input class="pwinput" id="pwOld" type="password" autocomplete="current-password"></span></div>') +
  69. '<div class="cfrow"><span class="k">新密码</span><span class="v">' +
  70. '<input class="pwinput" id="pwNew" type="password" autocomplete="new-password"></span></div>' +
  71. '<div class="cfrow"><span class="k">再输一次</span><span class="v">' +
  72. '<input class="pwinput" id="pwNew2" type="password" autocomplete="new-password"></span></div>' +
  73. '<div class="cfnote">至少 6 位,不能全是数字。</div>' +
  74. '<div class="err" id="pwErr" style="display:none"></div>' +
  75. '</div>' +
  76. '<div class="cffoot">' +
  77. (forced ? '' : '<button id="pwCancel">取消</button>') +
  78. '<button class="primary" id="pwOk">确认修改</button>' +
  79. '</div>' +
  80. '</div>';
  81. document.body.appendChild(wrap);
  82. requestAnimationFrame(() => wrap.classList.add('on'));
  83. if (!forced && $('pwX')) $('pwX').onclick = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); };
  84. if ($('pwCancel')) $('pwCancel').onclick = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); };
  85. $('pwOk').onclick = async () => {
  86. const n1 = $('pwNew').value, n2 = $('pwNew2').value;
  87. const box = $('pwErr');
  88. const fail = m => { box.textContent = m; box.style.display = 'block'; };
  89. if (n1 !== n2) return fail('两次输入的新密码不一样');
  90. if (n1.length < 6) return fail('密码至少 6 位');
  91. if (/^\d+$/.test(n1)) return fail('密码不能全是数字');
  92. try {
  93. await api('/api/me', { method: 'PUT', headers: {'Content-Type': 'application/json'},
  94. body: JSON.stringify({ password: n1,
  95. old_password: forced ? null : $('pwOld').value }) });
  96. if (state.me) state.me.must_change = false;
  97. wrap.classList.remove('on');
  98. setTimeout(() => wrap.remove(), 200);
  99. toast('密码已修改,下次请用新密码登录');
  100. } catch (e) { fail(e.message); }
  101. };
  102. }
  103. /* ---------------- 加载 ---------------- */
  104. async function load(period) {
  105. const q = period ? '?period=' + encodeURIComponent(period) : '';
  106. try {
  107. const d = await api('/api/dashboard' + q);
  108. state.data = d;
  109. state.thresholds = d.thresholds;
  110. state.defaults = d.defaults;
  111. state.texts = d.texts;
  112. state.textDefaults = d.text_defaults;
  113. state.period = d.period;
  114. (d.stores || []).forEach(st => decorateStore(st, d.texts));
  115. setupPeriods(d.periods, d.period);
  116. renderAll();
  117. } catch (e) {
  118. toast('加载失败:' + e.message, 4000);
  119. }
  120. }
  121. function setupPeriods(list, current) {
  122. const sel = $('period');
  123. sel.innerHTML = (list || []).map(p => {
  124. const tag = p.granularity === 'snapshot' ? (p.label || ('近' + (p.days || 30) + '天 · 截至 ' + p.period))
  125. : p.granularity === 'daily' ? (p.period + ' · 日更') : (p.period + ' · 月度汇总');
  126. return '<option value="' + p.period + '">' + tag + '(' + p.stores + '店)</option>';
  127. }).join('') || '<option>暂无数据</option>';
  128. if (current) sel.value = current;
  129. }
  130. function renderAll() {
  131. const d = state.data;
  132. const empty = !d || d.empty;
  133. $('empty').hidden = !empty;
  134. document.querySelectorAll('.kpis,.charts,.bar,.panel').forEach(el => el.style.display = empty ? 'none' : '');
  135. $('ruleNote').textContent = d && d.customized
  136. ? '规则:自定义考核指标(默认值来自《门店考核指标》)'
  137. : '规则来源:《门店考核指标》官方基准线';
  138. const cur = d && !d.empty && (d.periods || []).find(x => x.period === d.period);
  139. $('dataNote').textContent = d && !d.empty
  140. ? ((cur && cur.window_start)
  141. ? (cur.window_start + ' ~ ' + cur.window_end + ' · ')
  142. : (d.period + ' 周期 · '))
  143. + d.stores.length + ' 家门店 · 导入批次 ' + d.stats.batches
  144. : '';
  145. $('btnTh').classList.toggle('mod', !!(d && d.customized));
  146. if (empty) return;
  147. renderKpis();
  148. renderCharts();
  149. renderFilters();
  150. renderTable();
  151. }
  152. /* 后端只下发「模板键 + 变量」,这里按当前文案模板还原成文本。
  153. 好处:同一个问题不再被传 4 遍(problems/flags/primary/secondary/actions)。 */
  154. function decorateStore(s, X) {
  155. const problems = (s.problems || []).map(p => {
  156. const vars = p.x || {};
  157. const tpl = X[p.p] !== undefined ? X[p.p] : '';
  158. const atpl = X[p.a] !== undefined ? X[p.a] : '';
  159. return {
  160. key: p.k, tier: p.t, layer: p.l, level: p.v, cat: p.c,
  161. tpl: p.p, atpl: p.a, vars,
  162. text: fmtTpl(tpl, vars),
  163. action: fmtTpl(atpl, vars).replace(/^[\s\u2705\u2714\u2611\ufe0f]+/, '').trim(),
  164. };
  165. });
  166. s.problems = problems;
  167. s.primary = problems.find(p => p.key === s.primary_key) || null;
  168. s.flags = problems.map(p => [p.level, p.text, p.action, p.key]);
  169. s.cats = Array.from(new Set(problems.map(p => p.cat)));
  170. return s;
  171. }
  172. /* ---------------- KPI ---------------- */
  173. function renderKpis() {
  174. $('kpis').innerHTML = state.data.kpis.map(k =>
  175. '<div class="kpi' + (k.hot ? ' hot' : '') + (state.kpi === k.k ? ' on' : '') +
  176. '" data-k="' + k.k + '"><div class="v">' + k.value + '<span class="u">家</span></div>' +
  177. '<div class="l">' + esc(k.label) + '</div></div>'
  178. ).join('');
  179. document.querySelectorAll('.kpi').forEach(el => el.onclick = () => {
  180. state.kpi = el.dataset.k; state.page = 0; renderKpis(); renderTable();
  181. });
  182. }
  183. /* ---------------- 图表 ---------------- */
  184. let c1 = null, c2 = null, c3 = null;
  185. const GOLD_BAR = {
  186. type: 'linear', x: 0, y: 0, x2: 1, y2: 0,
  187. colorStops: [{ offset: 0, color: '#eec25c' }, { offset: 1, color: '#c8952c' }],
  188. };
  189. function renderCharts() {
  190. const d = state.data, T = d.thresholds;
  191. const ax = {
  192. axisLine: { lineStyle: { color: '#ecdfc2' } },
  193. axisLabel: { color: '#93876f', fontSize: 11 },
  194. splitLine: { lineStyle: { color: '#f6efe0' } },
  195. };
  196. if (!c1) c1 = echarts.init($('c1'));
  197. const cats = d.dist.cats;
  198. c1.setOption({
  199. grid: { left: 118, top: 8, bottom: 24, right: 30 }, tooltip: {},
  200. xAxis: Object.assign({ type: 'value' }, ax),
  201. yAxis: { type: 'category', data: cats.map(x => x[0]), axisLabel: { color: '#7f7359', fontSize: 11 } },
  202. series: [{ type: 'bar', data: cats.map(x => x[1]), barWidth: 11, itemStyle: { color: GOLD_BAR, borderRadius: [0, 3, 3, 0] } }],
  203. }, true);
  204. const a = d.dist.amount;
  205. const baseLabel = T.hx_amt_base >= 10000 ? '达标≥' + (T.hx_amt_base / 10000).toFixed(1) + '万' : '达标≥' + T.hx_amt_base;
  206. $('c2title').textContent = '核销金额分布(基准 ' + T.hx_amt_base + ' 元)';
  207. if (!c2) c2 = echarts.init($('c2'));
  208. c2.setOption({
  209. grid: { left: 44, top: 14, bottom: 40, right: 16 }, tooltip: {},
  210. xAxis: Object.assign({ type: 'category', data: ['零核销', '0-1千', '1千-基准', baseLabel] }, ax),
  211. yAxis: Object.assign({ type: 'value' }, ax),
  212. series: [{
  213. type: 'bar', barWidth: 26,
  214. data: [
  215. { value: a.zero, itemStyle: { color: '#b5563a' } },
  216. { value: a.lt1k, itemStyle: { color: '#cd8a35' } },
  217. { value: a.ltbase, itemStyle: { color: '#ddb45e' } },
  218. { value: a.ok, itemStyle: { color: '#6f9d7f' } }],
  219. itemStyle: { borderRadius: [3, 3, 0, 0] },
  220. label: { show: true, position: 'top', color: '#93876f', fontSize: 11 },
  221. }],
  222. }, true);
  223. if (!c3) c3 = echarts.init($('c3'));
  224. const cities = d.dist.cities.slice().reverse();
  225. c3.setOption({
  226. grid: { left: 72, top: 8, bottom: 24, right: 30 }, tooltip: {},
  227. xAxis: Object.assign({ type: 'value' }, ax),
  228. yAxis: { type: 'category', data: cities.map(x => x[0]), axisLabel: { color: '#7f7359', fontSize: 11 } },
  229. series: [{ type: 'bar', data: cities.map(x => x[1]), barWidth: 10, itemStyle: { color: '#e3c47f', borderRadius: [0, 3, 3, 0] } }],
  230. }, true);
  231. }
  232. /* ---------------- 筛选 / 表格 ---------------- */
  233. function renderFilters() {
  234. const stores = state.data.stores;
  235. const citySel = $('fcity'), catSel = $('fcat');
  236. const cities = [...new Set(stores.map(s => s.city).filter(Boolean))].sort();
  237. const cats = [...new Set(stores.flatMap(s => s.cats))].sort();
  238. citySel.innerHTML = '<option value="">全部城市</option>' + cities.map(c => '<option>' + esc(c) + '</option>').join('');
  239. catSel.innerHTML = '<option value="">全部问题类型</option>' + cats.map(c => '<option>' + esc(c) + '</option>').join('');
  240. citySel.value = state.city; catSel.value = state.cat;
  241. const scSel = $('fscore');
  242. if (scSel) scSel.value = state.score;
  243. }
  244. /* 评分筛选档位:闭区间按【】语义含端点(4.3 同时落在 4.0-4.3 与 4.3-4.5,4.5 同时落在 4.3-4.5 与 4.5以上) */
  245. function scoreInBand(v, band) {
  246. if (v == null || !band) return true;
  247. const x = Math.round(v * 10) / 10;
  248. if (band === 'lt40') return x < 4.0;
  249. if (band === '40_43') return x >= 4.0 && x <= 4.3;
  250. if (band === '43_45') return x >= 4.3 && x <= 4.5;
  251. if (band === 'gte45') return x >= 4.5;
  252. return true;
  253. }
  254. function filtered() {
  255. const d = state.data, T = d.thresholds;
  256. let rows = d.stores.slice();
  257. switch (state.kpi) {
  258. case 'R': rows = rows.filter(s => s.flags.some(f => f[0][0] === 'R')); break;
  259. case 'SCORE': rows = rows.filter(s => s.score != null && s.score < T.score_base); break;
  260. case 'HX': rows = rows.filter(s => s.hx_rate != null && s.hx_rate < T.hx_rate_base); break;
  261. case 'ZERO': rows = rows.filter(s => s.hx_cnt === 0); break;
  262. }
  263. if (state.city) rows = rows.filter(s => s.city === state.city);
  264. if (state.cat) rows = rows.filter(s => s.cats.indexOf(state.cat) >= 0);
  265. if (state.score) rows = rows.filter(s => s.score != null && scoreInBand(s.score, state.score));
  266. if (state.q) {
  267. const q = state.q.toLowerCase();
  268. rows = rows.filter(s => s.name.toLowerCase().indexOf(q) >= 0 || s.id.indexOf(q) >= 0);
  269. }
  270. const s = state.sort;
  271. const dir = state.order === 'asc' ? 1 : -1;
  272. const val = (o) => {
  273. if (s === 'amt') return o.hx_amt;
  274. if (s === 'visit') return o.visit;
  275. if (s === 'score') return o.score;
  276. if (s === 'hx') return o.hx_rate;
  277. return o.sev;
  278. };
  279. rows.sort((x, y) => {
  280. const ax = val(x), ay = val(y);
  281. if (ax == null && ay == null) return 0;
  282. if (ax == null) return 1; // 空值永远排最后,不随方向翻转
  283. if (ay == null) return -1;
  284. const r = (ax - ay) * dir;
  285. if (r !== 0) return r;
  286. return (x.hx_amt || 0) - (y.hx_amt || 0); // 同值时按金额兜底
  287. });
  288. return rows;
  289. }
  290. function sevColorOf(sev) {
  291. return sev >= 3 ? '#b5563a' : sev >= 2 ? '#cd8a35' : sev >= 1 ? '#c9a24a' : sev >= .6 ? '#dcc79a' : '#e3d9c2';
  292. }
  293. /* 上榜:从「XX榜第3名、YY榜第12名」里取最好名次 */
  294. function rankText(txt) {
  295. const t = (txt || '').trim();
  296. if (!t || t === '-') return '<span class="muted">—</span>';
  297. const nums = [...t.matchAll(/第\s*(\d+)\s*名/g)].map(m => parseInt(m[1], 10));
  298. if (!nums.length) return '<span class="warn-text">入围</span>';
  299. const best = Math.min(...nums);
  300. return '<span class="good-text">第' + best + '名</span>';
  301. }
  302. function rowHtml(s) {
  303. const T = state.thresholds || {};
  304. // 超线/未达标的值标红,方便一眼扫到
  305. const warn = (bad, level) => bad ? ' style="color:' + (level === 'R' ? '#b5563a' : '#c8952c') + ';font-weight:600"' : '';
  306. const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
  307. const scoreBad = s.score != null && s.score > 0 && s.score < T.score_base;
  308. const scoreUnrated = s.score === 0;
  309. const tdBad = (s.td || 0) > (T.tuDan ? T.tuDan[0] : 9);
  310. const tsBad = (s.ts || 0) > (T.touSu ? T.touSu[0] : 9);
  311. const cpBad = s.cp != null && s.cp > (T.chaPing ? T.chaPing[0] : 9);
  312. const replyBad = s.reply != null && s.reply < T.reply_base / 100;
  313. const pendBad = pend >= (T.pend_high || 30);
  314. return '<tr class="row" data-id="' + s.id + '"><td class="cl-name"><div class="nm"><span class="dot" style="background:' +
  315. sevColorOf(s.sev) + '"></span>' + esc(s.name) + '</div><div class="sid">' + s.id + '</div></td>' +
  316. '<td class="cl-city">' + esc(s.city) + '</td>' +
  317. '<td class="num"' + warn(scoreBad, 'R') + '>' +
  318. (scoreUnrated ? '<span class="muted">未开分</span>' : (s.score == null ? '-' : s.score.toFixed(1))) + '</td>' +
  319. '<td class="num">' + money(s.visit) + '</td>' +
  320. '<td class="num">' + money(s.deal) + '</td>' +
  321. '<td class="num"' + warn(s.hx_rate != null && s.hx_rate < T.hx_rate_base, 'B') + '>' + pct(s.hx_rate) + '</td>' +
  322. '<td class="num"' + warn((s.hx_amt || 0) < T.hx_amt_base, 'B') + '>' + money(s.hx_amt) + '</td>' +
  323. '<td class="num"' + warn(pendBad, 'B') + '>' + money(pend) + '</td>' +
  324. '<td class="num"' + warn(tdBad, 'R') + '>' + pct(s.td, 2) + '</td>' +
  325. '<td class="num"' + warn(tsBad, 'R') + '>' + pct(s.ts, 2) + '</td>' +
  326. '<td class="num"' + warn(cpBad, 'R') + '>' +
  327. (s.cp == null ? '<span class="muted">无评价</span>' : pct(s.cp, 2)) + '</td>' +
  328. '<td class="num"' + warn(replyBad, 'B') + '>' + pct(s.reply) + '</td>' +
  329. '<td class="num cl-rank">' + rankText(s.rank) + '</td>' +
  330. '<td class="cl-tags">' + s.problems.slice(0, 5).map(p =>
  331. '<span class="tag t-' + p.level + '">' + esc(p.cat) + '</span>').join('') +
  332. (s.problems.length > 5 ? '<span class="tag t-T">+' + (s.problems.length - 5) + '</span>' : '') +
  333. '</td></tr>';
  334. }
  335. /* 列表:分页渲染,每页 PAGE_SIZE 条 */
  336. function renderTable() {
  337. const rows = filtered();
  338. state.rows = rows;
  339. const pages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
  340. if (state.page >= pages) state.page = pages - 1;
  341. if (state.page < 0) state.page = 0;
  342. const slice = rows.slice(state.page * PAGE_SIZE, state.page * PAGE_SIZE + PAGE_SIZE);
  343. $('tb').innerHTML = slice.map(rowHtml).join('') ||
  344. '<tr><td colspan="13" style="color:#a1957d;padding:30px;text-align:center">没有符合筛选条件的门店</td></tr>';
  345. bindRows();
  346. $('cnt').textContent = '共 ' + rows.length + ' 家';
  347. $('pginfo').textContent = '第 ' + (state.page + 1) + ' / ' + pages + ' 页';
  348. $('prev').disabled = state.page === 0;
  349. $('next').disabled = state.page >= pages - 1;
  350. }
  351. function bindRows() {
  352. document.querySelectorAll('tr.row').forEach(el => el.onclick = () => openDrawer(el.dataset.id));
  353. }
  354. /* ---------------- 门店详情 ---------------- */
  355. function metricsOf(s, T) {
  356. return [
  357. { k: '评分', v: s.score, fmt: v => v.toFixed(1), max: 5, base: T.score_base, target: T.score_target, dir: 'high' },
  358. { k: '核销率', v: s.hx_rate, fmt: v => pct(v), max: 1, base: T.hx_rate_base, target: T.hx_rate_target, dir: 'high' },
  359. { 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' },
  360. { k: '商责退单率', v: s.td, fmt: v => pct(v, 2), max: T.tuDan[2] * 1.1, warn: T.tuDan, dir: 'low' },
  361. { k: '投诉率', v: s.ts, fmt: v => pct(v, 2), max: T.touSu[2] * 1.4, warn: T.touSu, dir: 'low' },
  362. { k: '差评率', v: s.cp, fmt: v => v == null ? '无评价' : pct(v, 2), max: T.chaPing[2] * 1.4, warn: T.chaPing, dir: 'low' },
  363. { k: '评价回复率', v: s.reply, fmt: v => pct(v), max: 1, base: T.reply_base / 100, target: 1, dir: 'high' },
  364. ];
  365. }
  366. function barColor(m, v) {
  367. if (v == null) return '#e3d9c2';
  368. if (m.dir === 'high') return v < m.base ? '#b5563a' : v < m.target ? '#cd8a35' : '#6f9d7f';
  369. return v > m.warn[2] ? '#b5563a' : v > m.warn[1] ? '#cd8a35' : v > m.warn[0] ? '#c9a24a' : '#6f9d7f';
  370. }
  371. function openDrawer(id) {
  372. const s = state.data.stores.find(x => x.id === id);
  373. if (!s) return;
  374. const T = state.thresholds;
  375. const D = diagnosisOf(s, state.texts);
  376. closePanels();
  377. let h = '<div class="dwhead"><h2>' + esc(s.name) + '</h2><div class="sid">' + s.id + ' · ' + esc(s.city) +
  378. ' · 严重度 ' + s.sev.toFixed(1) + (s.days ? ' · ' + s.days + ' 天数据' : '') + '</div>' +
  379. '<span class="dwclose" id="dwClose" title="关闭">&times;</span></div><div class="dwbody">';
  380. // 一句话结论
  381. h += '<div class="dxhead t-' + s.tier + '">' +
  382. '<div class="dxhd">' + esc(D.headline) + '</div>' +
  383. '<div class="dxmeta">共命中 ' + D.problems.length + ' 项 · 整改动作 ' + D.actions.length + ' 条</div></div>';
  384. // 主因 + 次要问题
  385. if (D.primary) {
  386. h += '<div class="dxsec"><div class="dxt">主因</div>' +
  387. '<div class="dxrow primary"><span class="tag t-' + D.primary.level + '">' + esc(D.primary.text) + '</span>' +
  388. '<div class="dxact">' + esc(D.primary.action) + '</div></div>';
  389. const rest = D.problems.filter(p => p !== D.primary);
  390. if (rest.length) {
  391. h += '<div class="dxt" style="margin-top:12px">其余问题(' + rest.length + ' 项)</div>' +
  392. rest.map(p => '<div class="dxrow"><span class="tag t-' + p.level + '">' + esc(p.text) + '</span>' +
  393. '<div class="dxact">' + esc(p.action) + '</div></div>').join('');
  394. }
  395. h += '</div>';
  396. } else {
  397. h += '<div class="dxsec"><div class="dxnone">' + esc(state.texts.fb_no_issue) + '</div></div>';
  398. }
  399. // 指标条
  400. h += '<div class="dxt" style="margin-top:6px">关键指标</div>';
  401. h += metricsOf(s, T).map(m => {
  402. const v = typeof m.v === 'function' ? m.v(s) : m.v;
  403. const w = v == null ? 0 : Math.min(v / m.max * 100, 100);
  404. const marks = m.warn
  405. ? m.warn.map(x => '<span class="mk" style="left:' + (x / m.max * 100).toFixed(1) + '%"></span>').join('')
  406. : '<span class="mk" style="left:' + (m.base / m.max * 100).toFixed(1) + '%"></span>' +
  407. '<span class="mk" style="left:' + (m.target / m.max * 100).toFixed(1) + '%;background:#6f9d7f"></span>';
  408. return '<div class="mrow"><div class="lab"><span>' + m.k + '</span><span>' + (v == null ? '-' : m.fmt(v)) + '</span></div>' +
  409. '<div class="track"><div class="fill" style="width:' + w.toFixed(1) + '%;background:' + barColor(m, v) + '"></div>' + marks + '</div></div>';
  410. }).join('');
  411. if (s.rank) {
  412. h += '<div class="rankbox"><span class="rk">上榜</span>' + esc(s.rank) + '</div>';
  413. }
  414. h += '<div class="mrow" style="color:#93876f;font-size:11.5px">访问 ' + money(s.visit) + ' 次 · 发布视频 ' + s.videos +
  415. ' 个 · 成交券 ' + money(s.deal) + ' 张 · 点亮 ' + (s.lit || 0) + ' 次</div>';
  416. h += '<div class="dwbrow">' +
  417. '<button class="copybtn" id="pvBtn">预览分析结果</button>' +
  418. '<button class="rpentry" id="rpBtn">导出报告</button></div>' +
  419. '<div class="hint">【预览分析结果】按文案模板生成发给督导的反馈,可直接改再复制;' +
  420. '【导出报告】生成一页 A4 门店分析报告,可打印成 PDF 或下载 HTML 存档。</div></div>';
  421. const dw = $('dw');
  422. dw.innerHTML = h;
  423. dw.classList.add('on');
  424. $('ov').classList.add('on');
  425. $('pvBtn').onclick = () => openPreview(s);
  426. $('rpBtn').onclick = () => openReport(s);
  427. $('dwClose').onclick = closePanels;
  428. }
  429. /* ---------------- 分析结果预览(可改,不保存) ---------------- */
  430. function ensurePreviewDom() {
  431. if ($('pvWrap')) return;
  432. const pv = document.createElement('div');
  433. pv.id = 'pvWrap';
  434. pv.className = 'pvwrap';
  435. pv.innerHTML =
  436. '<div class="pvcard"><div class="pvhead"><b>预览分析结果</b>' +
  437. '<span class="pvclose" id="pvClose" title="关闭">&times;</span></div>' +
  438. '<textarea id="pvText" spellcheck="false"></textarea>' +
  439. '<div class="pvfoot"><button id="pvRegen">重新生成</button>' +
  440. '<button class="primary" id="pvCopy">复制定稿文案</button></div></div>';
  441. document.body.appendChild(pv);
  442. }
  443. function closePreview() {
  444. const pv = $('pvWrap');
  445. if (pv) pv.classList.remove('on');
  446. }
  447. function openPreview(s) {
  448. ensurePreviewDom();
  449. const pv = $('pvWrap');
  450. $('pvText').value = feedbackText(s);
  451. pv.classList.add('on');
  452. $('pvClose').onclick = closePreview;
  453. pv.onclick = e => { if (e.target === pv) closePreview(); };
  454. $('pvRegen').onclick = () => { $('pvText').value = feedbackText(s); };
  455. $('pvCopy').onclick = () => {
  456. const t = $('pvText').value;
  457. if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t);
  458. else {
  459. const ta = document.createElement('textarea');
  460. ta.value = t; document.body.appendChild(ta); ta.select();
  461. document.execCommand('copy'); ta.remove();
  462. }
  463. const b = $('pvCopy'); b.textContent = '已复制 ✓';
  464. setTimeout(() => { b.textContent = '复制定稿文案'; }, 1600);
  465. };
  466. }
  467. function fmtTpl(tpl, vars) {
  468. return String(tpl == null ? '' : tpl).replace(/\{([^{}]+)\}/g, (m, k) =>
  469. vars[k] !== undefined && vars[k] !== null ? vars[k] : m);
  470. }
  471. /* 变量别名:允许直接按数据表列名写(如 {门店意向成交券数}),与短名等价 */
  472. const VAR_ALIAS = {
  473. '门店名称': '门店名', '门店城市': '城市', '统计周期': '周期',
  474. '门店页访问次数': '访问', '门店页有效人数': '访问', '访问次数': '访问', '有效人数': '访问',
  475. '门店页成交券数': '客资', '成交券数': '客资',
  476. '门店意向成交券数': '客资', '意向成交券数': '客资', '意向成交': '客资',
  477. '门店核销券数': '核销券数', '核销券': '核销券数',
  478. '门店意向退款券数': '退单', '意向退款券数': '退单', '门店退款券数': '退单', '退款券数': '退单',
  479. '门店实际成交人数': '成交', '成交人数': '成交', '实际成交': '成交',
  480. '流失人数': '流失', '流失人数占比': '流失占比',
  481. '核销金额': '金额', '核销金额(元)': '金额',
  482. '门店评分': '评分', '团购评分': '评分', '评价分': '评分',
  483. '待核销订单数': '待核销', '后台待核销订单': '待核销', '后台待核销订单数': '待核销',
  484. '评价回复率': '回复率', '核销率基准': '核销目标', '核销率目标': '核销目标',
  485. '评分基准线': '评分基准', '考核基准': '评分基准',
  486. };
  487. function fbVars(s) {
  488. const T = state.thresholds;
  489. const visit = s.visit || 0, deal = s.deal || 0;
  490. const lost = Math.max(visit - deal, 0);
  491. const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
  492. const v = {
  493. '门店名': s.name, '门店ID': s.id, '城市': s.city, '周期': state.period,
  494. '访问': cnt(visit), '客资': cnt(s.deal), '核销': cnt(s.hx_cnt),
  495. '退单': cnt(s.deal_refund), '待核销': cnt(pend),
  496. '成交': cnt(deal), '流失': cnt(lost),
  497. '成交占比': visit > 0 ? pct(deal / visit, 2) : '-',
  498. '流失占比': visit > 0 ? pct(lost / visit, 2) : '-',
  499. '访问环比': '-', // 暂无上月数据,可在模板中删掉该变量
  500. '金额': money(s.hx_amt),
  501. '评分': s.score == null ? '-' : s.score.toFixed(1),
  502. '核销率': pct(s.hx_rate), '核销目标': pct(T.hx_rate_target),
  503. '回复率': pct(s.reply), '回复基准': T.reply_base + '%',
  504. '评分基准': T.score_base, '核销金额基准': T.hx_amt_base,
  505. '核销券数': cnt(s.hx_cnt), '成交券数': cnt(s.deal),
  506. };
  507. // 展开别名,让全称变量也能渲染
  508. Object.keys(VAR_ALIAS).forEach(a => { if (v[VAR_ALIAS[a]] !== undefined) v[a] = v[VAR_ALIAS[a]]; });
  509. return v;
  510. }
  511. /* —— 变量校验:非法的(写错的)变量名会被原样输出,这里提前抓出来 —— */
  512. const RESERVED_VARS = ['i', '问题', '建议', '洞察', 'v', 'base', 'target', '口径', '档',
  513. 'n', '金额', '客单价', '新增', '原因', 'p', '结论', '动作', '榜'];
  514. const BASE_VARS = ['门店名', '门店ID', '城市', '周期', '访问', '客资', '核销', '退单', '待核销',
  515. '成交', '流失', '成交占比', '流失占比', '访问环比', '金额', '评分', '核销率', '核销目标',
  516. '回复率', '回复基准', '评分基准', '核销金额基准', '核销券数', '成交券数'];
  517. function knownVarSet() {
  518. const known = new Set(BASE_VARS.concat(RESERVED_VARS));
  519. Object.keys(VAR_ALIAS).forEach(k => known.add(k));
  520. if (prevStore) Object.keys(fbVars(prevStore)).forEach(k => known.add(k));
  521. return known;
  522. }
  523. function unknownVars(text) {
  524. const known = knownVarSet(), out = [];
  525. String(text == null ? '' : text).replace(/\{([^{}]+)\}/g, (m, k) => {
  526. if (!known.has(k) && out.indexOf(k) < 0) out.push(k);
  527. return m;
  528. });
  529. return out;
  530. }
  531. /* ---------------- 诊断结果(v2) ----------------
  532. 规则与判定全部在后端 rules.py;前端只做两件事:
  533. 1) 用当前(可能还没保存的)文案模板重渲染问题文案与动作,供文案配置实时预览
  534. 2) 按「主因 + 次要 + ≤3 动作」组织成反馈文案 */
  535. const LAYER_CN = { traffic: '流量', convert: '转化', fulfill: '履约', repute: '口碑' };
  536. const LV_WEIGHT = { R3: 3, R2: 2, R1: 1, B: 0.6, T: 0.3, N: 0 };
  537. function renderProblem(p, X) {
  538. if (!p) return '';
  539. const vars = Object.assign({}, p.vars || {});
  540. if (p.level && String(p.level).charAt(0) === 'R') vars['档'] = X['lv_' + p.level] || vars['档'];
  541. const tpl = (p.tpl && X[p.tpl] !== undefined) ? X[p.tpl] : p.text;
  542. return fmtTpl(tpl, vars);
  543. }
  544. function renderAction(p, X) {
  545. if (!p) return '';
  546. const tpl = (p.atpl && X[p.atpl] !== undefined) ? X[p.atpl] : p.action;
  547. // 去掉行首的 ✅/✓,避免和「✅ {动作}」模板叠加出现「✅ ✅」
  548. return fmtTpl(tpl, p.vars || {}).replace(/^[\s\u2705\u2714\u2611\ufe0f]+/, '').trim();
  549. }
  550. /* 用当前模板重新组织出:问题列表 / 主因 / 次要 / 动作 / 结论 */
  551. function diagnosisOf(s, X) {
  552. const problems = (s.problems || []).map(p =>
  553. Object.assign({}, p, { text: renderProblem(p, X), action: renderAction(p, X) }));
  554. const pk = s.primary && s.primary.key;
  555. let primary = problems.find(p => p.key === pk) || null;
  556. // 主因 key 找不到时(理论上不会)退化为最高档位那条
  557. if (!primary && problems.length) {
  558. primary = problems.slice().sort((a, b) =>
  559. (LV_WEIGHT[b.level] || 0) - (LV_WEIGHT[a.level] || 0))[0];
  560. }
  561. const secondary = problems
  562. .filter(p => p !== primary && (p.tier === 'R' || p.tier === 'B'))
  563. .sort((a, b) => (LV_WEIGHT[b.level] || 0) - (LV_WEIGHT[a.level] || 0))
  564. .slice(0, 2);
  565. // 每个问题都给对应动作:主因排第一,其余按原顺序
  566. const ordered = primary ? [primary].concat(problems.filter(p => p !== primary)) : problems.slice();
  567. const actions = [];
  568. ordered.forEach(p => {
  569. const act = renderAction(p, X);
  570. if (act && actions.indexOf(act) < 0) actions.push(act);
  571. });
  572. // 优先用规则专属结论句,没有就退回该层通用句(与 rules.py 同一套回退规则)
  573. let hdKey = primary ? 'hd_' + primary.key : 'hd_OK';
  574. if (X[hdKey] === undefined) hdKey = primary ? (primary.tier === 'R' ? 'hd_R' : 'hd_' + primary.layer) : 'hd_OK';
  575. if (X[hdKey] === undefined) hdKey = 'hd_OK';
  576. const headline = fmtTpl(X[hdKey] !== undefined ? X[hdKey] : (s.headline || ''),
  577. { 问题: primary ? primary.text : '' });
  578. return {
  579. problems, primary, secondary, ordered,
  580. actions,
  581. headline, hdKey,
  582. };
  583. }
  584. /* 洞察条目:哪几条出现、按什么顺序,只在这里决定(反馈文案与单店报告共用) */
  585. function insightTexts(s, X, D) {
  586. const vars = fbVars(s);
  587. const T = state.thresholds;
  588. const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
  589. const visit = s.visit || 0;
  590. const out = [];
  591. if (s.hx_rate != null && s.hx_rate < T.hx_rate_base) out.push(fmtTpl(X.fb_insight_metric, vars));
  592. if (s.score != null && s.score < T.score_base) out.push(fmtTpl(X.fb_insight_score, vars));
  593. if (visit > 0) out.push(fmtTpl(X.fb_insight_flow, vars));
  594. if (pend > 0) out.push(fmtTpl(X.fb_insight_order, vars));
  595. if (s.reply != null && s.reply < T.reply_base / 100) out.push(fmtTpl(X.fb_insight_reply, vars));
  596. if (D && D.problems.length) out.push(fmtTpl(X.fb_insight_priority, vars));
  597. return out;
  598. }
  599. function renderFeedback(s, X) {
  600. const vars = fbVars(s);
  601. const D = diagnosisOf(s, X);
  602. const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
  603. const visit = s.visit || 0;
  604. const L = [X.fb_sec_overview, fmtTpl(X.fb_line_overview, vars)];
  605. if (!D.problems.length) {
  606. L.push(X.fb_no_issue);
  607. } else {
  608. L.push(X.fb_sec_headline);
  609. L.push(fmtTpl(X.fb_item_headline, Object.assign({}, vars, { 结论: D.headline })));
  610. const risk = D.ordered;
  611. if (risk.length) {
  612. L.push(X.fb_sec_risk);
  613. risk.forEach((p, i) => L.push(
  614. fmtTpl(X.fb_item_risk, Object.assign({}, vars, { i: i + 1, '问题': p.text }))));
  615. }
  616. }
  617. if (pend > 0) L.push(fmtTpl(X.fb_line_pending, vars));
  618. if (visit > 0) {
  619. L.push(X.fb_sec_loss);
  620. L.push(fmtTpl(X.fb_item_visit, Object.assign({ i: 1 }, vars)));
  621. L.push(fmtTpl(X.fb_item_deal, Object.assign({ i: 2 }, vars)));
  622. L.push(fmtTpl(X.fb_item_lost, Object.assign({ i: 3 }, vars)));
  623. }
  624. const ins = insightTexts(s, X, D);
  625. if (ins.length) {
  626. L.push(X.fb_sec_insight);
  627. ins.forEach((t, k) => L.push(fmtTpl(X.fb_item_insight, { i: k + 1, '洞察': t })));
  628. }
  629. if (D.actions.length) {
  630. L.push(X.fb_sec_action);
  631. D.actions.forEach(a => L.push(fmtTpl(X.fb_action_item, Object.assign({}, vars, { 动作: a }))));
  632. }
  633. return L.join('\n');
  634. }
  635. function feedbackText(s) {
  636. return renderFeedback(s, state.texts);
  637. }
  638. /* ---------------- 单店分析报告(A4 可打印 / 可下载) ---------------- */
  639. const SEV_TIERS = [
  640. { min: 3, name: '非常严重', cls: 's3' },
  641. { min: 2, name: '一般严重', cls: 's2' },
  642. { min: 1, name: '预警', cls: 's1' },
  643. { min: 0.6, name: '低于考核基准', cls: 's1' },
  644. { min: 1e-9, name: '有提升项', cls: 'n' },
  645. { min: 0, name: '达标', cls: 's0' },
  646. ];
  647. function sevOf(sev) {
  648. return SEV_TIERS.find(t => sev >= t.min) || SEV_TIERS[SEV_TIERS.length - 1];
  649. }
  650. /* 报告里的指标条:与详情抽屉同一套 metricsOf / barColor,只是换成打印友好的类名 */
  651. function reportBar(m, s) {
  652. const v = typeof m.v === 'function' ? m.v(s) : m.v;
  653. const w = v == null ? 0 : Math.min(v / m.max * 100, 100);
  654. const shown = v == null ? (m.k === '差评率' ? '无评价' : '-') : m.fmt(v);
  655. const marks = m.warn
  656. ? m.warn.map(x => '<span class="mk" style="left:' + (x / m.max * 100).toFixed(1) + '%"></span>').join('')
  657. : '<span class="mk" style="left:' + (m.base / m.max * 100).toFixed(1) + '%"></span>' +
  658. '<span class="mk t" style="left:' + (m.target / m.max * 100).toFixed(1) + '%"></span>';
  659. return '<div class="rpbar"><div class="lab"><span>' + m.k + '</span><b>' + shown + '</b></div>' +
  660. '<div class="track"><div class="fill" style="width:' + w.toFixed(1) + '%;background:' +
  661. barColor(m, v) + '"></div>' + marks + '</div></div>';
  662. }
  663. function nowStamp() {
  664. const d = new Date(), p = n => String(n).padStart(2, '0');
  665. return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
  666. ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
  667. }
  668. function reportDocHtml(s) {
  669. const T = state.thresholds, X = state.texts;
  670. const vars = fbVars(s);
  671. const D = diagnosisOf(s, X);
  672. const sev = sevOf(s.sev);
  673. const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
  674. const visit = s.visit || 0, deal = s.deal || 0;
  675. const lost = Math.max(visit - deal, 0);
  676. const stamp = nowStamp();
  677. const cards = [
  678. ['门店页成交券数', cnt(s.deal), '张'],
  679. ['核销券数', cnt(s.hx_cnt), '张'],
  680. ['核销金额', money(s.hx_amt), '元'],
  681. ['意向退款券数', cnt(s.deal_refund), '张'],
  682. ['待核销订单', cnt(pend), '单'],
  683. ['门店页访问次数', money(visit), '次'],
  684. ['实际成交人数', cnt(deal), '人'],
  685. ['流失人数', cnt(lost), visit > 0 ? '(' + pct(lost / visit, 1) + ')' : ''],
  686. ].map(c => '<div><div class="k">' + c[0] + '</div><div class="v">' + c[1] +
  687. (c[2] ? '<small>' + c[2] + '</small>' : '') + '</div></div>').join('');
  688. // 风险表:主因打头,其余为次要问题;不再把所有问题一次性列出
  689. const riskList = D.ordered;
  690. const riskRows = riskList.map((p, i) =>
  691. '<tr><td class="no">' + (i + 1) + '</td>' +
  692. '<td><span class="rptag ' + p.level + '">' + esc(p.text) + '</span>' +
  693. (i === 0 ? '<span class="rpflag">主因</span>' : '') + '</td>' +
  694. '<td class="act">' + esc(p.action) + '</td></tr>').join('');
  695. const insights = insightTexts(s, X, D).map((t, i) =>
  696. '<li><span class="idx">' + (i + 1) + '</span>' + esc(t) + '</li>').join('');
  697. const actions = D.actions.map(a =>
  698. '<li><span class="ic">✓</span>' + esc(a) + '</li>').join('');
  699. return '<div class="rpdoc">' +
  700. '<header class="rphead"><span class="rpbrand">怡呵美 · 水光会员店</span>' +
  701. '<h1>门店检测分析报告</h1>' +
  702. '<div class="rpmeta">' +
  703. '<span>门店 <b>' + esc(s.name) + '</b></span>' +
  704. '<span>门店ID <b>' + s.id + '</b></span>' +
  705. '<span>城市 <b>' + esc(s.city || '-') + '</b></span>' +
  706. '<span>统计周期 <b>' + state.period + '</b></span>' +
  707. (s.days ? '<span>有效数据 <b>' + s.days + ' 天</b></span>' : '') +
  708. '<span>生成时间 <b>' + stamp + '</b></span>' +
  709. '</div></header>' +
  710. '<div class="rpsum">' +
  711. '<div class="rplead"><div class="k">综合严重度</div>' +
  712. '<div class="v">' + s.sev.toFixed(1) + '<span class="u">/ 3.0</span></div>' +
  713. '<span class="rpsev ' + sev.cls + '">' + sev.name + '</span></div>' +
  714. '<div class="rpstats">' +
  715. '<div><b>' + D.problems.length + '</b><span>命中问题</span></div>' +
  716. '<div><b>' + (s.score == null ? '-' : s.score.toFixed(1)) + '</b><span>团购评分</span></div>' +
  717. '<div><b>' + pct(s.hx_rate) + '</b><span>核销率</span></div>' +
  718. '<div><b>' + money(s.hx_amt) + '</b><span>核销金额(元)</span></div>' +
  719. '<div><b>' + cnt(pend) + '</b><span>待核销(单)</span></div>' +
  720. '</div>' +
  721. '</div>' +
  722. '<h2><span class="n">01</span>关键指标</h2>' +
  723. '<div class="rpmetrics">' + metricsOf(s, T).map(m => reportBar(m, s)).join('') + '</div>' +
  724. '<div class="rplegend">刻度:<i></i>考核基准,<i class="t"></i>平台目标;' +
  725. '退单率 / 投诉率 / 差评率为三级违规线(预警 · 一般严重 · 非常严重)</div>' +
  726. '<h2><span class="n">02</span>问题诊断</h2>' +
  727. '<div class="rpdx">' + esc(D.headline) + '</div>' +
  728. (riskList.length
  729. ? '<table class="rpflags"><thead><tr><th></th><th>问题</th><th>建议动作</th></tr></thead>' +
  730. '<tbody>' + riskRows + '</tbody></table>'
  731. : '<div class="rpmuted">' + esc(X.fb_no_issue) + '</div>') +
  732. '<h2><span class="n">03</span>经营数据(近30天)</h2>' +
  733. '<div class="rpgrid">' + cards + '</div>' +
  734. (insights ? '<h2><span class="n">04</span>分析洞察</h2><ul class="rpol">' + insights + '</ul>' : '') +
  735. (actions ? '<h2><span class="n">05</span>门店调整动作</h2><ul class="rpacts">' + actions + '</ul>' : '') +
  736. '<div class="rpfoot">' +
  737. '本报告由「门店检测工作台」于 ' + stamp + ' 自动生成,数据周期 ' + state.period + '。<br>' +
  738. '口径:核销率 = 核销券数 ÷ 意向成交券数;差评率在新增评价 ≥10 时取新增口径,否则取累计口径;' +
  739. '评分取周期内最近一次有值数据(含 0 分未开分)。考核基准值可在工作台【指标配置】中调整。' +
  740. '</div>' +
  741. '</div>';
  742. }
  743. function ensureReportDom() {
  744. if ($('rpWrap')) return;
  745. const w = document.createElement('div');
  746. w.id = 'rpWrap';
  747. w.className = 'rp';
  748. w.classList.add('rpwrap');
  749. document.body.appendChild(w);
  750. }
  751. function openReport(s) {
  752. ensureReportDom();
  753. const w = $('rpWrap');
  754. w.innerHTML = '<div class="rpcard rp">' +
  755. '<div class="rptoolbar">' +
  756. '<div class="rpname">' + esc(s.name) +
  757. '<span class="rpsub">' + state.period + ' · 单店分析报告</span></div>' +
  758. '<button class="rpbtn" id="rpDl">下载 HTML</button>' +
  759. '<button class="rpbtn primary" id="rpPrint">打印 / 另存为 PDF</button>' +
  760. '<button class="rpbtn x" id="rpClose" title="关闭">&times;</button>' +
  761. '</div>' + reportDocHtml(s) + '</div>';
  762. w.classList.add('on');
  763. document.body.classList.add('rp-on');
  764. $('rpClose').onclick = closeReport;
  765. $('rpPrint').onclick = () => window.print();
  766. $('rpDl').onclick = () => downloadReport(s);
  767. w.onclick = e => { if (e.target === w) closeReport(); };
  768. }
  769. function closeReport() {
  770. const w = $('rpWrap');
  771. if (w) w.classList.remove('on');
  772. document.body.classList.remove('rp-on');
  773. }
  774. let reportCssCache = null;
  775. async function downloadReport(s) {
  776. try {
  777. if (!reportCssCache) reportCssCache = await (await fetch('/report.css')).text();
  778. } catch (e) {
  779. toast('读取报告样式失败:' + e.message, 3600);
  780. return;
  781. }
  782. const file = '门店分析报告_' + String(s.name).replace(/[\\/:*?"<>|]/g, '_') + '_' + state.period + '.html';
  783. const doc = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' +
  784. '<meta name="viewport" content="width=device-width,initial-scale=1">' +
  785. '<title>' + esc('门店分析报告 · ' + s.name + ' · ' + state.period) + '</title>' +
  786. '<style>' + reportCssCache + '</style>' +
  787. '<style>body{margin:0;background:#f8f4ea;padding:24px 16px}' +
  788. '.rpdoc{max-width:820px;margin:0 auto;background:#fff;border-radius:12px;' +
  789. 'box-shadow:0 8px 30px rgba(120,90,20,.12)}' +
  790. '.phtop{max-width:820px;margin:0 auto 14px;text-align:right}' +
  791. '.phtop button{font-family:inherit;font-size:12px;padding:8px 16px;border-radius:8px;cursor:pointer;border:0;' +
  792. 'background:linear-gradient(135deg,#eab94e,#d29a29 48%,#b57e15);color:#fff;font-weight:600}' +
  793. '@media print{body{padding:0;background:#fff}.phtop{display:none}' +
  794. '.rpdoc{max-width:none;box-shadow:none;border-radius:0}}' +
  795. '</style></head><body>' +
  796. '<div class="phtop"><button onclick="window.print()">打印 / 另存为 PDF</button></div>' +
  797. '<div class="rp">' + reportDocHtml(s) + '</div>' +
  798. '</body></html>';
  799. const url = URL.createObjectURL(new Blob([doc], { type: 'text/html;charset=utf-8' }));
  800. const a = document.createElement('a');
  801. a.href = url;
  802. a.download = file;
  803. document.body.appendChild(a);
  804. a.click();
  805. a.remove();
  806. setTimeout(() => URL.revokeObjectURL(url), 4000);
  807. toast('已下载:' + file, 3200);
  808. }
  809. /* ---------------- 考核指标面板 ---------------- */
  810. const SPEC = [
  811. { g: '门店评分', hint: '低于基准即判「评分不达标」', items: [{ p: 'score_base', l: '考核基准', step: '0.1' }, { p: 'score_target', l: '目标值', step: '0.1' }] },
  812. { g: '核销率', hint: '核销券数 ÷ 意向成交券数', pct: 1, items: [{ p: 'hx_rate_base', l: '考核基准', step: '1' }, { p: 'hx_rate_target', l: '目标值', step: '1' }] },
  813. { g: '核销金额', hint: '单店周期核销金额,低于基准即提醒', items: [{ p: 'hx_amt_base', l: '考核基准(元)', step: '500' }] },
  814. { g: '商责退单率', hint: '超出即计入违规,分三级预警', pct: 1, items: [{ p: 'tuDan.0', l: '预警线' }, { p: 'tuDan.1', l: '一般严重' }, { p: 'tuDan.2', l: '非常严重' }] },
  815. { g: '投诉率', hint: '超出即计入违规,分三级预警', pct: 1, items: [{ p: 'touSu.0', l: '预警线' }, { p: 'touSu.1', l: '一般严重' }, { p: 'touSu.2', l: '非常严重' }] },
  816. { g: '差评率', hint: '只要有评价就判定:新增评价≥10 用新增口径,否则用累计口径', pct: 1, items: [{ p: 'chaPing.0', l: '预警线' }, { p: 'chaPing.1', l: '一般严重' }, { p: 'chaPing.2', l: '非常严重' }] },
  817. { g: '评价回复率', hint: '低于此比例判「回复率偏低」', unit: '%', items: [{ p: 'reply_base', l: '最低要求' }] },
  818. { g: '访问量', hint: '低于此值判「访问过低」', items: [{ p: 'visit_low', l: '过低线(次)', step: '10' }] },
  819. { g: '成交转化率', hint: '成交人数 ÷ 访问人数 低于此值,判「转化率低」(T 级提示)', unit: '%',
  820. items: [{ p: 'cvr_low', l: '过低线', step: '0.5' }] },
  821. { g: '未核销积压', hint: '估算未核销券达到此数量,提示集中邀约', unit: '张',
  822. items: [{ p: 'pend_high', l: '积压提醒线', step: '5' }] },
  823. { g: '差评率样本量', hint: '达到最小样本才按三级判定;不足时最高只判预警线', unit: '条',
  824. items: [{ p: 'cp_min_new', l: '新增口径最小评价数' }, { p: 'cp_min_total', l: '累计口径最小评价数' }] },
  825. { g: '内容与评价量', hint: '低于此数判「数量偏少」(T 级提升项)', unit: '条',
  826. items: [{ p: 'video_low', l: '视频数下限' }, { p: 'lit_low', l: '点亮数下限' },
  827. { p: 'new_review_low', l: '新增评价数下限' }] },
  828. { g: '其他最小样本量', hint: '样本不够就不判「不达标」,避免一条数据定性', unit: '条',
  829. items: [{ p: 'hx_min_deal', l: '核销率最小成交券数' }, { p: 'amt_min_coupons', l: '客单价最小核销券数' },
  830. { p: 'reply_min_new', l: '回复率最小新增评价数' }] },
  831. ];
  832. const getP = (o, path) => path.split('.').reduce((v, k) => (v == null ? null : v[k]), o);
  833. function setP(o, path, val) {
  834. const ks = path.split('.');
  835. let v = o;
  836. for (let i = 0; i < ks.length - 1; i++) v = v[ks[i]];
  837. v[ks[ks.length - 1]] = val;
  838. }
  839. const uiOf = (obj, path, isPct) => {
  840. const raw = getP(obj, path);
  841. return isPct ? +(raw * 100).toFixed(3) : raw;
  842. };
  843. const unitOf = g => g.unit ? g.unit : g.pct ? '%' : (g.g.indexOf('金额') >= 0 ? '元' : g.g.indexOf('访问') >= 0 ? '次' : '分');
  844. function buildThresholdPanel() {
  845. const T = state.thresholds, D = state.defaults;
  846. $('thPill').textContent = state.data.customized ? '已自定义' : '出厂值';
  847. $('thBody').innerHTML = SPEC.map(g => {
  848. const cls = g.items.length === 3 ? '' : (g.items.length === 2 ? ' one' : ' single');
  849. return '<div class="thgroup"><div class="gt">' + g.g + '</div><div class="gh">' + g.hint + '</div>' +
  850. '<div class="throw' + cls + '">' + g.items.map(it => {
  851. const isPct = g.pct ? 1 : 0;
  852. const chg = Math.abs(uiOf(T, it.p, isPct) - uiOf(D, it.p, isPct)) > 1e-9 ? ' chg' : '';
  853. return '<div class="thfield' + chg + '"><label>' + it.l + '</label><div class="inp">' +
  854. '<input type="number" step="' + (it.step || '0.01') + '" data-p="' + it.p + '" data-pct="' + isPct +
  855. '" value="' + uiOf(T, it.p, isPct) + '"><span class="u">' + unitOf(g) + '</span></div></div>';
  856. }).join('') + '</div></div>';
  857. }).join('');
  858. document.querySelectorAll('#thBody input').forEach(inp => {
  859. inp.oninput = () => {
  860. const def = uiOf(D, inp.dataset.p, inp.dataset.pct === '1');
  861. const cur = parseFloat(inp.value);
  862. inp.closest('.thfield').classList.toggle('chg', !isNaN(cur) && Math.abs(cur - def) > 1e-9);
  863. };
  864. });
  865. }
  866. function collectThresholds() {
  867. const T = JSON.parse(JSON.stringify(state.thresholds));
  868. let bad = null;
  869. document.querySelectorAll('#thBody input').forEach(inp => {
  870. const v = parseFloat(inp.value);
  871. if (isNaN(v)) { bad = bad || '有输入框为空'; return; }
  872. setP(T, inp.dataset.p, inp.dataset.pct === '1' ? v / 100 : v);
  873. });
  874. return { T, bad };
  875. }
  876. async function applyThresholds() {
  877. const { T, bad } = collectThresholds();
  878. if (bad) { toast(bad); return; }
  879. try {
  880. const r = await api('/api/thresholds', {
  881. method: 'PUT', headers: { 'Content-Type': 'application/json' },
  882. body: JSON.stringify(T),
  883. });
  884. closePanels();
  885. await load(state.period);
  886. toast(r && r.code_synced ? '已生效,并同步写回代码默认值' :
  887. ('已生效(写回代码失败:' + ((r && r.code_msg) || '未知') + ')'),
  888. r && r.code_synced ? 2600 : 5000);
  889. } catch (e) { toast('保存失败:' + e.message, 3600); }
  890. }
  891. async function resetThresholds() {
  892. try {
  893. const r = await api('/api/thresholds/reset', { method: 'POST' });
  894. closePanels();
  895. await load(state.period);
  896. toast(r && r.code_synced ? '已恢复到出厂值,并同步写回代码' : '已恢复到出厂值', 3200);
  897. } catch (e) { toast('恢复失败:' + e.message, 3600); }
  898. }
  899. /* ---------------- 文案配置面板 ---------------- */
  900. const TSPEC = [
  901. { g: '概览与结论', hint: '点字段右侧「+ 变量」可直接插入。短名:{客资} {核销} {退单} {待核销} {成交} {流失} {成交占比} {流失占比} {访问} {金额} {评分} {核销率} {核销目标} {回复率} {核销金额基准} {门店名} {城市}。也支持按数据表列名写:{门店意向成交券数} {门店核销券数} {门店意向退款券数} {门店页有效人数}',
  902. items: [
  903. { p: 'fb_sec_overview', l: '概览段标题' },
  904. { p: 'fb_line_overview', l: '概览行模板' },
  905. { p: 'fb_sec_headline', l: '结论段标题' },
  906. { p: 'fb_item_headline', l: '结论行模板({结论}=下面 hd_* 生成的整句)' },
  907. ] },
  908. { g: '一句话结论(按主因自动选一条)', hint: '诊断树先判定这家店卡在哪一层,再套用对应的一句。{问题}=该店具体问题文案',
  909. items: [
  910. { p: 'hd_R', l: '有违规在限流' },
  911. { p: 'hd_traffic', l: '卡在流量层' },
  912. { p: 'hd_convert', l: '卡在转化层' },
  913. { p: 'hd_fulfill', l: '卡在履约层' },
  914. { p: 'hd_repute', l: '卡在口碑层' },
  915. { p: 'hd_AMT', l: '卡在客单价(核销率达标)' },
  916. { p: 'hd_SCORE0', l: '未开分' },
  917. { p: 'hd_OK', l: '全部达标' },
  918. ] },
  919. { g: '风险因素', hint: '只列主因 + 最多 2 条次要问题,不再把所有问题一次性堆出来',
  920. items: [
  921. { p: 'fb_sec_risk', l: '风险段标题' },
  922. { p: 'fb_item_risk', l: '风险条目格式({i}=序号 {问题}=问题文案)' },
  923. { p: 'fb_line_pending', l: '待核销订单行(待核销=0 时不显示)' },
  924. { p: 'fb_no_issue', l: '无问题门店文案' },
  925. ] },
  926. { g: '顾客流失分析', hint: '有访问数据时显示这一段', items: [
  927. { p: 'fb_sec_loss', l: '段标题' },
  928. { p: 'fb_item_visit', l: '访问行' },
  929. { p: 'fb_item_deal', l: '成交行' },
  930. { p: 'fb_item_lost', l: '流失行' },
  931. ] },
  932. { g: '分析洞察', hint: '每条洞察按条件自动取舍:指标层面(核销率不达标)、口碑分(评分不达标)、流量转化(有访问)、订单风险(待核销>0)、口碑管理(回复率偏低)、整改优先级(有风险项)',
  933. items: [
  934. { p: 'fb_sec_insight', l: '段标题' },
  935. { p: 'fb_item_insight', l: '洞察条目格式({i}=序号 {洞察}=洞察文案)' },
  936. { p: 'fb_insight_metric', l: '指标层面' },
  937. { p: 'fb_insight_score', l: '口碑分' },
  938. { p: 'fb_insight_flow', l: '流量转化' },
  939. { p: 'fb_insight_order', l: '订单风险' },
  940. { p: 'fb_insight_reply', l: '口碑管理' },
  941. { p: 'fb_insight_priority', l: '整改优先级' },
  942. ] },
  943. { g: '门店调整动作', hint: '动作来自诊断结果,只给主因和次要问题对应的动作,最多 3 条(不再给固定清单)',
  944. items: [
  945. { p: 'fb_sec_action', l: '段标题' },
  946. { p: 'fb_action_item', l: '动作条目格式({动作}=该条整改动作)' },
  947. ] },
  948. { g: '严重档位名称', hint: '问题文案中 {档} 的显示名', items: [
  949. { p: 'lv_R1', l: '一级' }, { p: 'lv_R2', l: '二级' }, { p: 'lv_R3', l: '三级' },
  950. ] },
  951. { g: '问题文案', hint: '变量:{v}=实际值 {base}=基准值 {口径}=新增/累计 {档}=档位名 {n}=券数 {金额}=核销金额 {客单价}=元 {新增}=新增评价数 {p}=积压券数 {原因}=未开分原因。显示在风险因素、详情抽屉和导出清单', items: [
  952. { p: 't_TD', l: '商责退单率超线' },
  953. { p: 't_TS', l: '投诉率超线' },
  954. { p: 't_CP', l: '差评率超线' },
  955. { p: 't_SCORE', l: '评分不达标' },
  956. { p: 't_SCORE0', l: '未开分' },
  957. { p: 't_REPLY', l: '评价回复率偏低' },
  958. { p: 't_DEAL0', l: '零成交' },
  959. { p: 't_ZERO', l: '成交未核销' },
  960. { p: 't_HX', l: '核销率不达标' },
  961. { p: 't_AMT', l: '核销金额偏低' },
  962. { p: 't_CVR', l: '转化率低' },
  963. { p: 't_VISIT', l: '访问过低' },
  964. { p: 't_RVOL', l: '新增评价少' },
  965. { p: 't_RANK0', l: '未上榜' },
  966. { p: 't_RANK_IN', l: '仅入围({榜})'},
  967. { p: 't_PEND', l: '未核销积压' },
  968. { p: 't_LIT', l: '点亮偏低' },
  969. { p: 't_VIDEO', l: '视频量少' },
  970. ] },
  971. { g: '整改建议', hint: '评分建议可用 {base}=基准 {target}=目标。一条问题对应一条建议,最终只输出 ≤3 条', items: [
  972. { p: 'a_TD', l: '退单率超线' },
  973. { p: 'a_TS', l: '投诉率超线' },
  974. { p: 'a_CP', l: '差评率超线' },
  975. { p: 'a_SCORE', l: '评分不达标' },
  976. { p: 'a_SCORE0', l: '未开分' },
  977. { p: 'a_REPLY', l: '评价回复率偏低' },
  978. { p: 'a_DEAL0', l: '零成交' },
  979. { p: 'a_ZERO', l: '成交未核销' },
  980. { p: 'a_HX', l: '核销率不达标' },
  981. { p: 'a_AMT', l: '核销金额偏低' },
  982. { p: 'a_CVR', l: '转化率低' },
  983. { p: 'a_VISIT', l: '访问过低' },
  984. { p: 'a_RVOL', l: '新增评价少' },
  985. { p: 'a_RANK0', l: '未上榜' },
  986. { p: 'a_RANK_IN', l: '仅入围' },
  987. { p: 'a_PEND', l: '未核销积压' },
  988. { p: 'a_LIT', l: '点亮偏低' },
  989. { p: 'a_VIDEO', l: '视频量少' },
  990. ] },
  991. ];
  992. function textsChanged() {
  993. const X = state.texts, D = state.textDefaults;
  994. return TSPEC.some(g => g.items.some(it => (X[it.p] || '') !== (D[it.p] || '')));
  995. }
  996. /* —— 实时预览 ——
  997. v2 起不再在前端复刻一套判定规则:问题的判定结果由后端给出(s.problems),
  998. 这里只用「当前编辑中的文案」重渲染文案,规则唯一源仍是 backend/rules.py */
  999. let prevStore = null;
  1000. function collectTextInputs() {
  1001. const X = Object.assign({}, state.texts);
  1002. document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => { X[inp.dataset.p] = inp.value; });
  1003. return X;
  1004. }
  1005. function renderTextPreview() {
  1006. if (!prevStore || !$('txPrev')) return;
  1007. const X = collectTextInputs();
  1008. const txt = renderFeedback(prevStore, X);
  1009. // 已知变量已替换,残留的 {...} 就是写错的,标红提示
  1010. $('txPrev').innerHTML = esc(txt).replace(/\{([^{}]+)\}/g,
  1011. '<span class="badvar">{$1}</span>');
  1012. const bad = [];
  1013. Object.keys(X).forEach(k => unknownVars(X[k]).forEach(u => { if (bad.indexOf(u) < 0) bad.push(u); }));
  1014. const tip = $('txBad');
  1015. if (tip) {
  1016. tip.style.display = bad.length ? 'block' : 'none';
  1017. tip.textContent = bad.length
  1018. ? '⚠️ 有 ' + bad.length + ' 个变量系统不认识,会原样输出:' + bad.map(u => '{' + u + '}').join(' ') +
  1019. '。请改用下方「可用变量」里的名字。'
  1020. : '';
  1021. }
  1022. }
  1023. const INSERT_VARS = [
  1024. '门店名', '门店ID', '城市', '周期',
  1025. '访问', '客资', '核销', '退单', '待核销', '成交', '流失', '成交占比', '流失占比',
  1026. '金额', '评分', '评分基准', '核销率', '核销目标', '回复率', '回复基准', '核销金额基准',
  1027. ];
  1028. function insertAtCursor(inp, text) {
  1029. const st = inp.selectionStart == null ? inp.value.length : inp.selectionStart;
  1030. const en = inp.selectionEnd == null ? inp.value.length : inp.selectionEnd;
  1031. inp.value = inp.value.slice(0, st) + text + inp.value.slice(en);
  1032. if (inp.setSelectionRange) inp.setSelectionRange(st + text.length, st + text.length);
  1033. if (inp.focus) inp.focus();
  1034. }
  1035. function syncTextField(inp) {
  1036. const field = inp.closest('.thfield');
  1037. if (!field) return;
  1038. const def = state.textDefaults[inp.dataset.p] || '';
  1039. field.classList.toggle('chg', inp.value !== def);
  1040. const bad = unknownVars(inp.value);
  1041. field.classList.toggle('bad', bad.length > 0);
  1042. const w = field.querySelector('.varwarn');
  1043. if (w) w.textContent = bad.length
  1044. ? '⚠️ 无法识别的变量:' + bad.map(u => '{' + u + '}').join(' ') + '(会原样输出)'
  1045. : '';
  1046. renderTextPreview();
  1047. }
  1048. function buildTextPanel() {
  1049. const X = state.texts, D = state.textDefaults;
  1050. $('txPill').textContent = textsChanged() ? '自定义' : '默认值';
  1051. const chips = INSERT_VARS.map(v => '<b data-v="{' + v + '}">' + v + '</b>').join('');
  1052. $('txBody').innerHTML =
  1053. '<div class="thgroup"><div class="gt">实时预览</div>' +
  1054. '<div class="gh">以问题最多的门店「' + esc(prevStore.name) + '」为例,边改边看效果,点【应用到工作台】后保存</div>' +
  1055. '<div class="txbad" id="txBad"></div>' +
  1056. '<div class="txprev" id="txPrev"></div></div>' +
  1057. TSPEC.map(g =>
  1058. '<div class="thgroup"><div class="gt">' + g.g + '</div><div class="gh">' + esc(g.hint) + '</div>' +
  1059. '<div class="throw single">' + g.items.map(it => {
  1060. const chg = (X[it.p] || '') !== (D[it.p] || '') ? ' chg' : '';
  1061. const ctl = it.ta
  1062. ? '<textarea rows="7" data-p="' + it.p + '">' + esc(X[it.p]) + '</textarea>'
  1063. : '<input type="text" data-p="' + it.p + '" value="' + esc(X[it.p]) + '">';
  1064. return '<div class="thfield' + chg + '"><label>' + it.l +
  1065. '<button type="button" class="varbtn" data-for="' + it.p + '">+ 变量</button></label>' +
  1066. (it.ta ? ctl : '<div class="inp">' + ctl + '</div>') +
  1067. '<div class="varwarn"></div>' +
  1068. '<div class="varchips" data-for="' + it.p + '">' + chips + '</div></div>';
  1069. }).join('') + '</div></div>').join('');
  1070. document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => {
  1071. inp.oninput = () => syncTextField(inp);
  1072. });
  1073. document.querySelectorAll('#txBody .varbtn').forEach(btn => {
  1074. btn.onclick = () => {
  1075. const row = document.querySelector('#txBody .varchips[data-for="' + btn.dataset.for + '"]');
  1076. if (row) row.classList.toggle('on');
  1077. };
  1078. });
  1079. document.querySelectorAll('#txBody .varchips b').forEach(b => {
  1080. b.onclick = () => {
  1081. const p = b.parentNode.dataset.for;
  1082. const inp = document.querySelector('#txBody [data-p="' + p + '"]');
  1083. if (!inp) return;
  1084. insertAtCursor(inp, b.dataset.v);
  1085. syncTextField(inp);
  1086. };
  1087. });
  1088. document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => syncTextField(inp));
  1089. }
  1090. async function applyTexts() {
  1091. const X = {};
  1092. let empty = null;
  1093. document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => {
  1094. if (!inp.value.trim() && inp.dataset.p !== 'fb_actions') empty = empty || '「' + inp.dataset.p + '」不能为空';
  1095. X[inp.dataset.p] = inp.value;
  1096. });
  1097. if (empty) { toast(empty); return; }
  1098. const bad = [];
  1099. Object.keys(X).forEach(k => unknownVars(X[k]).forEach(u => { if (bad.indexOf(u) < 0) bad.push(u); }));
  1100. try {
  1101. await api('/api/copytext', {
  1102. method: 'PUT', headers: { 'Content-Type': 'application/json' },
  1103. body: JSON.stringify(X),
  1104. });
  1105. closePanels();
  1106. await load(state.period);
  1107. toast(bad.length
  1108. ? '已保存,但有 ' + bad.length + ' 个变量不认识(' + bad.map(u => '{' + u + '}').join(' ') + '),会原样输出'
  1109. : '文案已更新,全部门店生效', bad.length ? 4200 : 2400);
  1110. } catch (e) { toast('保存失败:' + e.message, 3600); }
  1111. }
  1112. async function resetTexts() {
  1113. try {
  1114. await api('/api/copytext/reset', { method: 'POST' });
  1115. closePanels();
  1116. await load(state.period);
  1117. toast('已恢复默认文案');
  1118. } catch (e) { toast('恢复失败:' + e.message, 3600); }
  1119. }
  1120. function openTexts() {
  1121. if (!state.data || state.data.empty) { toast('先导入数据再配置文案'); return; }
  1122. closePanels();
  1123. prevStore = state.data.stores.slice().sort((a, b) => b.flags.length - a.flags.length)[0] || state.data.stores[0];
  1124. buildTextPanel();
  1125. $('txPanel').classList.add('on');
  1126. $('btnText').classList.add('on');
  1127. $('ov').classList.add('on', 'hi');
  1128. }
  1129. /* ---------------- 导入 ---------------- */
  1130. let pickedFile = null;
  1131. function openImport() {
  1132. closePanels();
  1133. $('imPeriod').value = state.period || new Date().toISOString().slice(0, 7);
  1134. $('imDate').value = '';
  1135. $('imResult').innerHTML = '';
  1136. $('dropText').textContent = '点击选择 CSV / Excel 文件,或把文件拖到这里';
  1137. $('imGo').disabled = true;
  1138. pickedFile = null;
  1139. loadHistory();
  1140. $('imPanel').classList.add('on');
  1141. $('btnImport').classList.add('on');
  1142. $('ov').classList.add('on', 'hi');
  1143. }
  1144. function loadHistory() {
  1145. api('/api/meta').then(m => {
  1146. // 只对「当前数据库里还有数据」的周期给出可点的导出按钮
  1147. const have = new Set((m.periods || []).map(x => x.period));
  1148. $('imHistory').innerHTML = (m.history || []).map(h => {
  1149. const p = h.period || '';
  1150. const ok = p && have.has(p);
  1151. const hasFile = !!h.file_path;
  1152. return '<div class="hrow">' +
  1153. '<span class="hmain" title="' + esc(h.filename) + '"><b>' + esc(h.filename) + '</b> · ' +
  1154. h.rows_written + ' 行</span>' +
  1155. '<span class="hmeta">' + (p || '—') + ' · ' +
  1156. (h.created_at || '').slice(5, 16).replace('T', ' ') + '</span>' +
  1157. '<button class="hbtn" data-b="' + h.id + '"' + (hasFile ? '' : ' disabled') +
  1158. ' title="' + (hasFile ? '下载当时的原始文件(与生意经完全一致)'
  1159. : '这个批次没有留存原始文件') + '">原始文件</button>' +
  1160. '</div>';
  1161. }).join('') || '<div style="color:#a1957d">还没有导入记录</div>';
  1162. document.querySelectorAll('#imHistory .hbtn').forEach(b => {
  1163. b.onclick = e => { e.stopPropagation(); downloadBatchFile(b.dataset.b); };
  1164. });
  1165. }).catch(() => {});
  1166. }
  1167. /* ---------------- 账号管理 ---------------- */
  1168. function openUsers() {
  1169. closePanels();
  1170. $('usPanel').classList.add('on');
  1171. loadUsers();
  1172. }
  1173. async function loadUsers() {
  1174. try {
  1175. const d = await api('/api/users');
  1176. state.userRoles = d.roles;
  1177. state.usersCache = d.users;
  1178. const roleMap = {};
  1179. d.roles.forEach(r => roleMap[r.key] = r);
  1180. $('usList').innerHTML = (d.users || []).map(u => {
  1181. const opts = d.roles.map(r =>
  1182. '<option value="' + r.key + '"' + (r.key === u.role ? ' selected' : '') + '>' +
  1183. r.label + '</option>').join('');
  1184. return '<div class="urow' + (u.active ? '' : ' off') + '">' +
  1185. '<div class="uinfo"><b>' + esc(u.display_name || u.username) + '</b>' +
  1186. '<span class="uname">' + esc(u.username) + '</span>' +
  1187. (u.active ? '' : '<span class="uoff">已停用</span>') +
  1188. (u.id === (state.me && state.me.id) ? '<span class="ume">本人</span>' : '') +
  1189. '</div>' +
  1190. '<div class="uctl">' +
  1191. '<select class="urole" data-id="' + u.id + '">' + opts + '</select>' +
  1192. '<button class="ubtn" data-act="pw" data-id="' + u.id + '">重置密码</button>' +
  1193. (u.username === 'admin'
  1194. ? '<span class="ubadge">内置账号,不可停用/删除</span>'
  1195. : '<button class="ubtn" data-act="toggle" data-id="' + u.id + '">' +
  1196. (u.active ? '停用' : '启用') + '</button>' +
  1197. '<button class="ubtn danger" data-act="del" data-id="' + u.id + '">删除</button>') +
  1198. '</div>' +
  1199. '<div class="umeta">最近登录 ' + ((u.last_login || '').replace('T', ' ').slice(5, 16) || '从未') + '</div>' +
  1200. '</div>';
  1201. }).join('') || '<div style="color:#a1957d">还没有账号</div>';
  1202. document.querySelectorAll('#usList .urole').forEach(sel => {
  1203. sel.onchange = async () => {
  1204. try {
  1205. await api('/api/users/' + sel.dataset.id, { method: 'PUT',
  1206. headers: {'Content-Type': 'application/json'},
  1207. body: JSON.stringify({ role: sel.value }) });
  1208. toast('角色已修改');
  1209. loadUsers();
  1210. } catch (e) { toast(e.message, 3500); loadUsers(); }
  1211. };
  1212. });
  1213. document.querySelectorAll('#usList .ubtn').forEach(b => {
  1214. b.onclick = () => userAction(b.dataset.act, +b.dataset.id);
  1215. });
  1216. } catch (e) { toast('读取账号失败:' + e.message, 3500); }
  1217. }
  1218. async function userAction(act, id) {
  1219. try {
  1220. if (act === 'toggle') {
  1221. const u = (state.usersCache || []).find(x => x.id === id);
  1222. await api('/api/users/' + id, { method: 'PUT',
  1223. headers: {'Content-Type': 'application/json'},
  1224. body: JSON.stringify({ active: !(u && u.active) }) });
  1225. toast('已更新');
  1226. loadUsers();
  1227. } else if (act === 'del') {
  1228. if (!confirm('确定删除这个账号?删除后无法恢复。')) return;
  1229. await api('/api/users/' + id, { method: 'DELETE' });
  1230. toast('已删除');
  1231. loadUsers();
  1232. } else if (act === 'pw') {
  1233. const u = (state.usersCache || []).find(x => x.id === id);
  1234. resetPassword(id, (u && (u.display_name || u.username)) || '该账号');
  1235. }
  1236. } catch (e) { toast(e.message, 3500); }
  1237. }
  1238. /* 通用表单弹窗:一次性把所有字段列出来,不再一个个 prompt */
  1239. function formModal(opts) {
  1240. const old = $('fmWrap');
  1241. if (old) old.remove();
  1242. const rows = opts.fields.map(f => {
  1243. const id = 'fm_' + f.key;
  1244. let input;
  1245. if (f.type === 'select') {
  1246. input = '<select class="pwinput" id="' + id + '">' + f.options.map(o =>
  1247. '<option value="' + o.value + '"' + (o.value === f.value ? ' selected' : '') + '>' +
  1248. o.label + '</option>').join('') + '</select>';
  1249. } else {
  1250. input = '<input class="pwinput" id="' + id + '" type="' + (f.type || 'text') + '"' +
  1251. (f.placeholder ? ' placeholder="' + esc(f.placeholder) + '"' : '') +
  1252. (f.value ? ' value="' + esc(f.value) + '"' : '') +
  1253. (f.autocomplete ? ' autocomplete="' + f.autocomplete + '"' : '') + '>';
  1254. }
  1255. return '<div class="fmfield"><label class="fmlabel">' + f.label +
  1256. (f.required ? '<span class="fmreq">*</span>' : '') + '</label>' + input +
  1257. (f.hint ? '<div class="fmhint">' + f.hint + '</div>' : '') + '</div>';
  1258. }).join('');
  1259. const wrap = document.createElement('div');
  1260. wrap.id = 'fmWrap';
  1261. wrap.className = 'cfwrap';
  1262. wrap.innerHTML =
  1263. '<div class="cfcard" style="width:min(440px,92vw)">' +
  1264. '<div class="cfhead"><b>' + opts.title + '</b><span class="pvclose" id="fmX">&times;</span></div>' +
  1265. '<div class="cfbody">' + rows +
  1266. '<div class="err" id="fmErr" style="display:none"></div>' +
  1267. '</div>' +
  1268. '<div class="cffoot"><button id="fmCancel">取消</button>' +
  1269. '<button class="primary" id="fmOk">' + (opts.okText || '确定') + '</button></div>' +
  1270. '</div>';
  1271. document.body.appendChild(wrap);
  1272. requestAnimationFrame(() => wrap.classList.add('on'));
  1273. const close = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); };
  1274. $('fmX').onclick = close;
  1275. $('fmCancel').onclick = close;
  1276. wrap.onclick = e => { if (e.target === wrap) close(); };
  1277. const first = $('fm_' + opts.fields[0].key);
  1278. if (first) setTimeout(() => first.focus(), 60);
  1279. const fail = m => { const b = $('fmErr'); b.textContent = m; b.style.display = 'block'; };
  1280. const submit = async () => {
  1281. const box = $('fmErr'); box.style.display = 'none';
  1282. const vals = {};
  1283. opts.fields.forEach(f => { vals[f.key] = ($('fm_' + f.key).value || '').trim(); });
  1284. const msg = opts.validate && opts.validate(vals);
  1285. if (msg) return fail(msg);
  1286. $('fmOk').disabled = true;
  1287. try {
  1288. await opts.onSubmit(vals);
  1289. close();
  1290. } catch (e) {
  1291. fail(e.message);
  1292. $('fmOk').disabled = false;
  1293. }
  1294. };
  1295. $('fmOk').onclick = submit;
  1296. wrap.querySelectorAll('input').forEach(inp => {
  1297. inp.onkeydown = e => { if (e.key === 'Enter') submit(); };
  1298. });
  1299. }
  1300. /* 新建账号:一次性填完 */
  1301. function openUserNew() {
  1302. formModal({
  1303. title: '新建账号',
  1304. okText: '创建',
  1305. fields: [
  1306. { key: 'username', label: '登录账号', required: true,
  1307. placeholder: '英文或数字,登录时用', autocomplete: 'off',
  1308. hint: '只能包含字母、数字、下划线、点或减号' },
  1309. { key: 'display_name', label: '显示名称', placeholder: '如:督导·王(不填就用账号名)' },
  1310. { key: 'password', label: '初始密码', type: 'password', required: true,
  1311. autocomplete: 'new-password', hint: '至少 6 位,不能全是数字' },
  1312. { key: 'password2', label: '确认密码', type: 'password', required: true,
  1313. autocomplete: 'new-password' },
  1314. { key: 'role', label: '角色', type: 'select', value: 'viewer', options: [
  1315. { value: 'viewer', label: '查看 —— 只能看数据' },
  1316. { value: 'editor', label: '运营 —— 可导入、同步、导出' },
  1317. { value: 'admin', label: '管理员 —— 全部权限,含账号管理' },
  1318. ] },
  1319. ],
  1320. validate: v => {
  1321. if (!v.username) return '请填写登录账号';
  1322. if (!/^[A-Za-z0-9_.-]{2,32}$/.test(v.username)) return '登录账号只能用字母、数字、_ . -,2~32 位';
  1323. if (v.password.length < 6) return '密码至少 6 位';
  1324. if (/^\d+$/.test(v.password)) return '密码不能全是数字';
  1325. if (v.password !== v.password2) return '两次输入的密码不一样';
  1326. return '';
  1327. },
  1328. onSubmit: async v => {
  1329. await api('/api/users', { method: 'POST', headers: {'Content-Type': 'application/json'},
  1330. body: JSON.stringify({ username: v.username, password: v.password,
  1331. role: v.role, display_name: v.display_name }) });
  1332. toast('账号「' + (v.display_name || v.username) + '」已创建');
  1333. loadUsers();
  1334. },
  1335. });
  1336. }
  1337. /* 重置密码:同样是表单 */
  1338. function resetPassword(id, name) {
  1339. formModal({
  1340. title: '重置「' + name + '」的密码',
  1341. okText: '重置',
  1342. fields: [
  1343. { key: 'password', label: '新密码', type: 'password', required: true,
  1344. autocomplete: 'new-password', hint: '至少 6 位,不能全是数字' },
  1345. { key: 'password2', label: '确认密码', type: 'password', required: true,
  1346. autocomplete: 'new-password' },
  1347. ],
  1348. validate: v => {
  1349. if (v.password.length < 6) return '密码至少 6 位';
  1350. if (/^\d+$/.test(v.password)) return '密码不能全是数字';
  1351. if (v.password !== v.password2) return '两次输入的密码不一样';
  1352. return '';
  1353. },
  1354. onSubmit: async v => {
  1355. await api('/api/users/' + id, { method: 'PUT', headers: {'Content-Type': 'application/json'},
  1356. body: JSON.stringify({ password: v.password, force_change: true }) });
  1357. toast('密码已重置,该账号下次登录需要修改');
  1358. loadUsers();
  1359. },
  1360. });
  1361. }
  1362. /* 下载某次导入的原始文件(生意经原样导出,不含任何计算) */
  1363. function downloadBatchFile(batchId) {
  1364. if (!batchId) return;
  1365. window.location.href = '/api/batch/' + encodeURIComponent(batchId) + '/file';
  1366. toast('正在下载原始文件', 2400);
  1367. }
  1368. /* 左侧栏【导出原始数据】:当前周期最近一次导入的原件 */
  1369. function downloadOriginal() {
  1370. if (!state.period) return;
  1371. window.location.href = '/api/export.file?period=' + encodeURIComponent(state.period);
  1372. toast('正在下载 ' + state.period + ' 的原始文件', 2600);
  1373. }
  1374. async function doImport() {
  1375. if (!pickedFile) return;
  1376. $('imGo').disabled = true;
  1377. $('imGo').textContent = '导入中…';
  1378. const params = new URLSearchParams({ filename: pickedFile.name });
  1379. if ($('imPeriod').value) params.set('period', $('imPeriod').value);
  1380. if ($('imDate').value) params.set('date', $('imDate').value);
  1381. try {
  1382. const r = await api('/api/import?' + params.toString(), {
  1383. method: 'POST', headers: { 'Content-Type': 'text/csv' }, body: pickedFile,
  1384. });
  1385. $('imResult').innerHTML =
  1386. '<div class="ok">✓ 导入成功:写入 <b>' + r.rows_written + '</b> 行,新增门店 <b>' + r.new_stores + '</b> 家<br>' +
  1387. '周期 ' + r.period + (r.date_range && r.date_range.length ? ' · 日期 ' + r.date_range[0] + ' ~ ' + r.date_range[1] : '') +
  1388. ' · 粒度 ' + (r.granularity === 'daily' ? '按日' : '周期汇总') +
  1389. (r.skipped ? '<br>跳过 ' + r.skipped + ' 行(' + (r.errors[0] || '空行') + ')' : '') + '</div>';
  1390. toast('导入完成,正在刷新');
  1391. await load(r.period);
  1392. loadHistory();
  1393. setTimeout(closePanels, 900);
  1394. } catch (e) {
  1395. $('imResult').innerHTML = '<div class="bad">✗ ' + esc(e.message) + '</div>';
  1396. $('imGo').disabled = false;
  1397. } finally {
  1398. $('imGo').textContent = '开始导入';
  1399. }
  1400. }
  1401. /* ---------------- 面板开关 ---------------- */
  1402. function closePanels() {
  1403. ['thPanel', 'imPanel', 'txPanel', 'usPanel'].forEach(id => $(id).classList.remove('on'));
  1404. ['btnTh', 'btnImport', 'btnText', 'btnUsers'].forEach(id => {
  1405. const el = $(id); if (el) el.classList.remove('on');
  1406. });
  1407. $('dw').classList.remove('on');
  1408. closePreview();
  1409. closeReport();
  1410. const ov = $('ov');
  1411. ov.classList.remove('on', 'hi');
  1412. }
  1413. function openThresholds() {
  1414. if (!state.data || state.data.empty) { toast('先导入数据再配置指标'); return; }
  1415. closePanels();
  1416. buildThresholdPanel();
  1417. $('thPanel').classList.add('on');
  1418. $('btnTh').classList.add('on');
  1419. $('ov').classList.add('on', 'hi');
  1420. }
  1421. /* ---------------- 近30天数据同步 ---------------- */
  1422. let syncTimer = null;
  1423. /* 同步确认弹窗:这一步会消耗配额、要跑几分钟,不能点一下就直接执行 */
  1424. function syncWindowText() {
  1425. const end = new Date();
  1426. const start = new Date(end.getTime() - 29 * 86400000);
  1427. const f = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' +
  1428. String(d.getDate()).padStart(2, '0');
  1429. return f(start) + ' ~ ' + f(end);
  1430. }
  1431. function openSyncConfirm() {
  1432. api('/api/sync/status').then(st => {
  1433. const s = st.state || {};
  1434. if (s.running) {
  1435. toast('同步正在进行中(' + (s.last_start || '').slice(11) + ' 开始),完成后会自动刷新', 4000);
  1436. watchSync();
  1437. return;
  1438. }
  1439. showSyncDialog(st);
  1440. }).catch(() => showSyncDialog({}));
  1441. }
  1442. function showSyncDialog(st) {
  1443. const old = $('cfWrap');
  1444. if (old) old.remove();
  1445. const last = (st.last_sync || '').replace('T', ' ').slice(0, 16);
  1446. const wrap = document.createElement('div');
  1447. wrap.id = 'cfWrap';
  1448. wrap.className = 'cfwrap';
  1449. wrap.innerHTML =
  1450. '<div class="cfcard">' +
  1451. '<div class="cfhead"><b>确认同步近30天数据?</b><span class="pvclose" id="cfClose">&times;</span></div>' +
  1452. '<div class="cfbody">' +
  1453. '<div class="cfrow"><span class="k">统计窗口</span><span class="v">' + syncWindowText() + '</span></div>' +
  1454. '<div class="cfrow"><span class="k">数据来源</span><span class="v">抖音来客 · 生意经报表集市</span></div>' +
  1455. '<div class="cfrow"><span class="k">预计耗时</span><span class="v">3 ~ 8 分钟(平台排队时间不定)</span></div>' +
  1456. '<div class="cfrow"><span class="k">会消耗</span><span class="v">1 次建任务配额(每天约 30 次)</span></div>' +
  1457. '<div class="cfrow"><span class="k">上次同步</span><span class="v">' + (last || '还没有同步过') + '</span></div>' +
  1458. '<div class="cfnote">同步期间会弹出一个浏览器窗口,<b>不影响你继续使用工作台</b>;' +
  1459. '完成后数据会自动刷新。系统每天 01:00 也会自动同步一次。</div>' +
  1460. '</div>' +
  1461. '<div class="cffoot">' +
  1462. '<button id="cfCancel">取消</button>' +
  1463. '<button class="primary" id="cfOk">开始同步</button>' +
  1464. '</div>' +
  1465. '</div>';
  1466. document.body.appendChild(wrap);
  1467. requestAnimationFrame(() => wrap.classList.add('on'));
  1468. const close = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); };
  1469. $('cfClose').onclick = close;
  1470. $('cfCancel').onclick = close;
  1471. wrap.onclick = e => { if (e.target === wrap) close(); };
  1472. $('cfOk').onclick = () => { close(); startSync(); };
  1473. document.addEventListener('keydown', function esc(e) {
  1474. if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
  1475. });
  1476. }
  1477. async function startSync() {
  1478. try {
  1479. await api('/api/sync', { method: 'POST' });
  1480. toast('已开始同步近30天数据,约 3-5 分钟(期间可以继续用)', 4200);
  1481. watchSync();
  1482. } catch (e) {
  1483. toast('同步启动失败:' + e.message, 4200);
  1484. }
  1485. }
  1486. async function watchSync() {
  1487. clearInterval(syncTimer);
  1488. syncTimer = setInterval(async () => {
  1489. try {
  1490. const s = await api('/api/sync/status');
  1491. const st = s.state || {};
  1492. if (!st.running) {
  1493. clearInterval(syncTimer);
  1494. if (st.last_ok) {
  1495. toast('数据同步完成,正在刷新', 4000);
  1496. await load(state.period);
  1497. } else {
  1498. toast('同步失败:' + (st.last_msg || '看服务窗口日志'), 6000);
  1499. }
  1500. }
  1501. } catch (e) { clearInterval(syncTimer); }
  1502. }, 5000);
  1503. }
  1504. /* ---------------- 导出 ---------------- */
  1505. /* 导出 = 下载原始文件,逻辑在 downloadOriginal / downloadBatchFile */
  1506. /* ---------------- 事件 ---------------- */
  1507. function bind() {
  1508. $('period').onchange = e => { state.period = e.target.value; load(e.target.value); };
  1509. $('q').oninput = e => { state.q = e.target.value.trim(); state.page = 0; renderTable(); };
  1510. $('fcity').onchange = e => { state.city = e.target.value; state.page = 0; renderTable(); };
  1511. $('fcat').onchange = e => { state.cat = e.target.value; state.page = 0; renderTable(); };
  1512. $('fscore').onchange = e => { state.score = e.target.value; state.page = 0; renderTable(); };
  1513. $('fsort').onchange = e => { state.sort = e.target.value; state.page = 0; renderTable(); };
  1514. $('forder').onchange = e => { state.order = e.target.value; state.page = 0; renderTable(); };
  1515. $('prev').onclick = () => { state.page--; renderTable(); };
  1516. $('next').onclick = () => { state.page++; renderTable(); };
  1517. $('btnTh').onclick = () => $('thPanel').classList.contains('on') ? closePanels() : openThresholds();
  1518. $('btnText').onclick = () => $('txPanel').classList.contains('on') ? closePanels() : openTexts();
  1519. $('btnImport').onclick = openImport;
  1520. $('btnSync').onclick = openSyncConfirm;
  1521. $('btnUsers').onclick = () => $('usPanel').classList.contains('on') ? closePanels() : openUsers();
  1522. $('usClose').onclick = closePanels;
  1523. $('usNew').onclick = openUserNew;
  1524. $('btnPw').onclick = () => showChangePw(false);
  1525. $('btnLogout').onclick = async () => {
  1526. await fetch('/api/logout', { method: 'POST' }).catch(() => {});
  1527. location.replace('/login.html');
  1528. };
  1529. $('btnExportCur').onclick = downloadOriginal;
  1530. $('thApply').onclick = applyThresholds;
  1531. $('thReset').onclick = resetThresholds;
  1532. $('thClose').onclick = closePanels;
  1533. $('txApply').onclick = applyTexts;
  1534. $('txReset').onclick = resetTexts;
  1535. $('txClose').onclick = closePanels;
  1536. $('imClose').onclick = closePanels;
  1537. $('imCancel').onclick = closePanels;
  1538. $('imGo').onclick = doImport;
  1539. $('ov').onclick = closePanels;
  1540. document.addEventListener('keydown', e => {
  1541. if (e.key === 'Escape') {
  1542. const rp = $('rpWrap');
  1543. const pv = $('pvWrap');
  1544. if (rp && rp.classList.contains('on')) closeReport();
  1545. else if (pv && pv.classList.contains('on')) closePreview();
  1546. else closePanels();
  1547. }
  1548. });
  1549. const drop = $('drop'), file = $('file');
  1550. drop.onclick = () => file.click();
  1551. file.onchange = () => {
  1552. pickedFile = file.files[0] || null;
  1553. $('dropText').textContent = pickedFile ? pickedFile.name : '点击选择 CSV / Excel 文件,或把文件拖到这里';
  1554. $('imGo').disabled = !pickedFile;
  1555. };
  1556. ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); }));
  1557. ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); }));
  1558. drop.addEventListener('drop', e => {
  1559. const f = e.dataTransfer.files[0];
  1560. if (!f) return;
  1561. if (!/\.(csv|xlsx)$/i.test(f.name)) { toast('只支持 CSV 或 .xlsx 文件(旧版 .xls 请先另存为 .xlsx)'); return; }
  1562. pickedFile = f;
  1563. $('dropText').textContent = f.name;
  1564. $('imGo').disabled = false;
  1565. });
  1566. window.addEventListener('resize', () => { [c1, c2, c3].forEach(c => c && c.resize()); });
  1567. }
  1568. function handleHash() {
  1569. const h = decodeURIComponent((location.hash || '').slice(1));
  1570. if (!h) return;
  1571. if (h === 'TH') return openThresholds();
  1572. if (h === 'TX') return openTexts();
  1573. if (h === 'IM') return openImport();
  1574. if (state.data && !state.data.empty) openDrawer(h);
  1575. }
  1576. window.addEventListener('hashchange', handleHash);
  1577. bind();
  1578. (async function boot() {
  1579. try {
  1580. const me = await api('/api/me');
  1581. state.me = me.user;
  1582. applyPerms();
  1583. await load();
  1584. handleHash();
  1585. } catch (e) {
  1586. if (!String(e.message).includes('登录')) toast('加载失败:' + e.message, 4000);
  1587. }
  1588. })();