ops.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. 'use strict';
  2. // 店铺运营页:上传人工统计表 → 按学校/店铺/日期看指标矩阵或卡片。
  3. // 数据来自 /api/ops/*;原件存服务端存储(默认本地 SQLite),页面只做展示与上传。
  4. (function () {
  5. const $ = (id) => document.getElementById(id);
  6. const DEFAULT_FIELDS = ['order_cnt', 'revenue', 'exposure', 'click_rate'];
  7. function loadJSON(key, fallback) {
  8. try {
  9. const raw = localStorage.getItem(key);
  10. if (!raw) return fallback;
  11. const value = JSON.parse(raw);
  12. return Array.isArray(value) && value.length ? value : fallback;
  13. } catch {
  14. return fallback;
  15. }
  16. }
  17. const state = {
  18. inited: false,
  19. loading: false,
  20. summary: null,
  21. data: null,
  22. catalog: [],
  23. schools: null, // null = 全选
  24. shops: null, // null = 全部店铺
  25. fields: loadJSON('ops.fields', DEFAULT_FIELDS.slice()),
  26. view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
  27. from: '',
  28. to: '',
  29. upload: { files: [], busy: false },
  30. };
  31. function fieldByKey(key) {
  32. return state.catalog.find((f) => f.key === key) || { key, label: key, type: 'int' };
  33. }
  34. function esc(value) {
  35. const div = document.createElement('div');
  36. div.textContent = String(value == null ? '' : value);
  37. return div.innerHTML;
  38. }
  39. function ymdToInput(ymd) {
  40. const s = String(ymd || '');
  41. return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : '';
  42. }
  43. function inputToYmd(value) {
  44. return String(value || '').replace(/-/g, '');
  45. }
  46. function shortDate(ymd) {
  47. const s = String(ymd || '');
  48. return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s;
  49. }
  50. function fmtValue(value, type) {
  51. if (value === null || value === undefined || value === '') return '–';
  52. if (type === 'text') return String(value);
  53. const n = Number(value);
  54. if (!Number.isFinite(n)) return String(value);
  55. if (type === 'rate') return (n * 100).toFixed(1) + '%';
  56. if (type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  57. if (type === 'int') return n.toLocaleString('zh-CN');
  58. return String(value);
  59. }
  60. function selectedSchools() {
  61. if (!state.summary) return [];
  62. const all = state.summary.schools.map((s) => s.name);
  63. return state.schools === null ? all : all.filter((name) => state.schools.has(name));
  64. }
  65. async function api(url, options) {
  66. const res = await fetch(url, options);
  67. const isJson = /application\/json/.test(res.headers.get('content-type') || '');
  68. const body = isJson ? await res.json().catch(() => null) : null;
  69. return { res, body };
  70. }
  71. // ---------- 概览 / 数据 ----------
  72. async function loadSummary() {
  73. const { body } = await api('/api/ops/summary');
  74. state.summary = body || { enabled: false, reason: 'storage-unreachable' };
  75. if (state.summary.enabled && state.summary.coverage) {
  76. if (!state.from) state.from = state.summary.coverage.from;
  77. if (!state.to) state.to = state.summary.coverage.to;
  78. }
  79. return state.summary;
  80. }
  81. async function loadRows() {
  82. if (!state.summary || !state.summary.enabled) return;
  83. if (state.schools !== null && state.schools.size === 0) {
  84. state.data = null;
  85. state.loading = false;
  86. renderBody();
  87. return;
  88. }
  89. state.loading = true;
  90. renderBody();
  91. const params = new URLSearchParams();
  92. if (state.from) params.set('from', state.from);
  93. if (state.to) params.set('to', state.to);
  94. const schools = state.schools === null ? [] : [...state.schools];
  95. if (schools.length && schools.length !== (state.summary.schools || []).length) params.set('schools', schools.join(','));
  96. if (state.shops !== null && state.shops.size) params.set('shops', [...state.shops].join(','));
  97. if (state.fields.length) params.set('fields', state.fields.join(','));
  98. const { res, body } = await api('/api/ops/rows?' + params.toString());
  99. state.loading = false;
  100. if (!res.ok) {
  101. state.data = null;
  102. state.error = (body && (body.detail || body.error)) || `HTTP ${res.status}`;
  103. } else {
  104. state.data = body;
  105. state.error = null;
  106. }
  107. renderBody();
  108. }
  109. // ---------- 工具条 ----------
  110. /** 通用多选下拉:按钮 + 搜索 + 复选列表 + 全选/清空 */
  111. function mountMulti(root, { label, items, selected, allowEmpty, emptyLabel, onChange }) {
  112. const sel = new Set(selected || []);
  113. let keyword = '';
  114. root.innerHTML = `<button type="button" class="ops-multi-btn" aria-expanded="false"></button>
  115. <div class="ops-multi-menu" hidden>
  116. <input type="text" class="ops-multi-search" placeholder="搜索${esc(label)}">
  117. <div class="ops-multi-list"></div>
  118. <div class="ops-multi-foot"><button type="button" data-all>全选</button><button type="button" data-none>清空</button></div>
  119. </div>`;
  120. const btn = root.querySelector('.ops-multi-btn');
  121. const menu = root.querySelector('.ops-multi-menu');
  122. const list = root.querySelector('.ops-multi-list');
  123. const search = root.querySelector('.ops-multi-search');
  124. function refreshButton() {
  125. let text;
  126. if (!sel.size) text = `${label} <em>${emptyLabel || '全部'}</em>`;
  127. else if (sel.size === 1) {
  128. const one = items.find((i) => sel.has(i.key));
  129. text = `${label} <em>${esc(one ? one.label : '1')}</em>`;
  130. } else text = `${label} <em>${sel.size} 项</em>`;
  131. btn.innerHTML = text + '<span class="ops-multi-caret">▾</span>';
  132. }
  133. function renderList() {
  134. const kw = keyword.trim().toLowerCase();
  135. const filtered = items.filter((i) => !kw
  136. || String(i.label).toLowerCase().includes(kw)
  137. || String(i.hint || '').toLowerCase().includes(kw));
  138. list.innerHTML = filtered.length
  139. ? filtered.map((i) => `<label class="ops-multi-item">
  140. <input type="checkbox" value="${esc(i.key)}"${sel.has(i.key) ? ' checked' : ''}>
  141. <span>${esc(i.label)}</span>${i.hint ? `<em>${esc(i.hint)}</em>` : ''}
  142. </label>`).join('')
  143. : '<div class="ops-multi-empty">没有匹配项</div>';
  144. list.querySelectorAll('input[type="checkbox"]').forEach((input) => {
  145. input.addEventListener('change', () => {
  146. if (input.checked) sel.add(input.value);
  147. else sel.delete(input.value);
  148. if (!allowEmpty && !sel.size) {
  149. input.checked = true;
  150. sel.add(input.value);
  151. return;
  152. }
  153. refreshButton();
  154. onChange([...sel]);
  155. });
  156. });
  157. }
  158. btn.addEventListener('click', (e) => {
  159. e.stopPropagation();
  160. const willOpen = menu.hidden;
  161. document.querySelectorAll('.ops-multi-menu').forEach((m) => { m.hidden = true; });
  162. menu.hidden = !willOpen;
  163. btn.setAttribute('aria-expanded', String(willOpen));
  164. if (willOpen) { renderList(); search.focus(); }
  165. });
  166. search.addEventListener('input', () => { keyword = search.value; renderList(); });
  167. root.querySelector('[data-all]').addEventListener('click', () => {
  168. items.forEach((i) => sel.add(i.key));
  169. refreshButton();
  170. renderList();
  171. onChange([...sel]);
  172. });
  173. root.querySelector('[data-none]').addEventListener('click', () => {
  174. sel.clear();
  175. if (!allowEmpty && items[0]) sel.add(items[0].key);
  176. refreshButton();
  177. renderList();
  178. onChange([...sel]);
  179. });
  180. refreshButton();
  181. return { refresh: () => { refreshButton(); renderList(); } };
  182. }
  183. let menuDismissBound = false;
  184. /** 点击空白处 / Esc 关闭下拉(按钮本身已 stopPropagation,不会误关) */
  185. function bindMenuDismiss() {
  186. if (menuDismissBound) return;
  187. menuDismissBound = true;
  188. document.addEventListener('click', (event) => {
  189. document.querySelectorAll('.ops-multi').forEach((root) => {
  190. const menu = root.querySelector('.ops-multi-menu');
  191. if (!menu || menu.hidden) return;
  192. if (root.contains(event.target)) return;
  193. menu.hidden = true;
  194. const btn = root.querySelector('.ops-multi-btn');
  195. if (btn) btn.setAttribute('aria-expanded', 'false');
  196. });
  197. });
  198. document.addEventListener('keydown', (event) => {
  199. if (event.key !== 'Escape') return;
  200. document.querySelectorAll('.ops-multi-menu').forEach((menu) => { menu.hidden = true; });
  201. });
  202. }
  203. function renderBar() {
  204. const bar = $('opsBar');
  205. if (!bar) return;
  206. const summary = state.summary;
  207. if (!summary || !summary.enabled || !matrixCount(summary)) {
  208. bar.innerHTML = '';
  209. return;
  210. }
  211. const schools = summary.schools || [];
  212. const chosen = state.schools === null ? new Set(schools.map((s) => s.name)) : state.schools;
  213. bar.innerHTML = `
  214. <div class="ops-bar">
  215. <div class="ops-bar-group">
  216. <span class="ops-bar-label">学校</span>
  217. <div class="ops-chips" id="opsSchoolChips">
  218. ${schools.map((s) => `<button type="button" class="ops-chip${chosen.has(s.name) ? ' on' : ''}" data-school="${esc(s.name)}">${esc(s.name)}<em>${s.shopCount}</em></button>`).join('')}
  219. </div>
  220. </div>
  221. <div class="ops-bar-group">
  222. <span class="ops-bar-label">日期</span>
  223. <input type="date" class="ops-input" id="opsFrom" value="${ymdToInput(state.from)}">
  224. <span class="ops-tilde">~</span>
  225. <input type="date" class="ops-input" id="opsTo" value="${ymdToInput(state.to)}">
  226. </div>
  227. <div class="ops-bar-group">
  228. <span class="ops-bar-label">字段</span>
  229. <div class="ops-multi" id="opsFieldPicker"></div>
  230. </div>
  231. <div class="ops-bar-group">
  232. <span class="ops-bar-label">店铺</span>
  233. <div class="ops-multi" id="opsShopPicker"></div>
  234. </div>
  235. </div>`;
  236. renderHeaderTools();
  237. bar.querySelectorAll('[data-school]').forEach((btn) => {
  238. btn.addEventListener('click', () => {
  239. const name = btn.dataset.school;
  240. const current = state.schools === null ? new Set(schools.map((s) => s.name)) : new Set(state.schools);
  241. if (current.has(name)) current.delete(name); else current.add(name);
  242. state.schools = current.size === schools.length ? null : current;
  243. if (!current.size) state.schools = new Set();
  244. renderBar();
  245. loadRows();
  246. });
  247. });
  248. $('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); });
  249. $('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
  250. mountMulti($('opsFieldPicker'), {
  251. label: '字段',
  252. items: state.catalog.map((f) => ({ key: f.key, label: f.label, hint: f.type === 'text' ? '文本' : '' })),
  253. selected: state.fields,
  254. allowEmpty: false,
  255. onChange: (keys) => {
  256. state.fields = keys;
  257. localStorage.setItem('ops.fields', JSON.stringify(keys));
  258. loadRows();
  259. },
  260. });
  261. const shopItems = (summary.shops || []).map((shop) => ({
  262. key: String(shop.key || shop.shopId || ''),
  263. label: shop.shopName || shop.key,
  264. hint: shop.school || '',
  265. })).filter((i) => i.key);
  266. mountMulti($('opsShopPicker'), {
  267. label: '店铺',
  268. items: shopItems,
  269. selected: state.shops ? [...state.shops] : [],
  270. allowEmpty: true,
  271. emptyLabel: '全部',
  272. onChange: (keys) => {
  273. state.shops = keys.length ? new Set(keys) : null;
  274. loadRows();
  275. },
  276. });
  277. }
  278. function matrixCount(summary) {
  279. if (!summary) return 0;
  280. return Number(summary.matrixRowCount != null ? summary.matrixRowCount : summary.rowCount) || 0;
  281. }
  282. function renderHeaderTools() {
  283. const el = $('opsHeaderTools');
  284. if (!el) return;
  285. const summary = state.summary;
  286. if (!summary || !summary.enabled || !matrixCount(summary)) {
  287. el.innerHTML = '';
  288. return;
  289. }
  290. el.innerHTML = `<div class="ops-seg" id="opsViewSeg">
  291. <button type="button" data-mode="table" class="${state.view === 'table' ? 'on' : ''}">报表</button>
  292. <button type="button" data-mode="cards" class="${state.view === 'cards' ? 'on' : ''}">卡片</button>
  293. </div>
  294. <button type="button" class="btn btn-ghost btn-small" id="opsExport">导出 CSV</button>`;
  295. el.querySelectorAll('#opsViewSeg [data-mode]').forEach((btn) => {
  296. btn.addEventListener('click', () => {
  297. state.view = btn.dataset.mode;
  298. localStorage.setItem('ops.view', state.view);
  299. renderHeaderTools();
  300. renderBody();
  301. });
  302. });
  303. $('opsExport').addEventListener('click', () => {
  304. const params = new URLSearchParams();
  305. if (state.from) params.set('from', state.from);
  306. if (state.to) params.set('to', state.to);
  307. if (state.schools !== null && state.schools.size) params.set('schools', [...state.schools].join(','));
  308. if (state.fields.length) params.set('fields', state.fields.join(','));
  309. window.open('/api/ops/export?' + params.toString(), '_blank');
  310. });
  311. }
  312. // ---------- 内容区 ----------
  313. function renderBody() {
  314. const body = $('opsBody');
  315. if (!body) return;
  316. const summary = state.summary;
  317. if (!summary) {
  318. body.innerHTML = '<div class="empty-hint"><p>正在加载…</p></div>';
  319. return;
  320. }
  321. if (!summary.enabled) {
  322. const reasonText = {
  323. 'storage-not-configured': '未配置存储(config.json → storage)',
  324. 'storage-unreachable': '存储连接失败',
  325. 'pg-not-installed': '未安装 pg 依赖',
  326. 'sqlite-unavailable': '当前 Node 版本不支持内置 SQLite(需要 22.5+)',
  327. }[summary.reason] || '存储不可用';
  328. body.innerHTML = `
  329. <div class="empty-hint ops-empty">
  330. <p>店铺运营数据暂不可用</p>
  331. <small>${esc(reasonText)}${summary.detail ? ':' + esc(summary.detail) : ''}</small>
  332. <button type="button" class="btn btn-ghost btn-small" id="opsRetry" style="margin-top:14px">重试</button>
  333. </div>`;
  334. $('opsRetry').addEventListener('click', () => init(true));
  335. return;
  336. }
  337. const matrixRows = matrixCount(summary);
  338. if (!matrixRows) {
  339. const platformRows = Number(summary.platformRowCount) || 0;
  340. body.innerHTML = platformRows > 0
  341. ? `<div class="empty-hint ops-empty">
  342. <p>已导入 ${platformRows} 行平台明细</p>
  343. <small>平台全字段数据已入库(可在「导入记录」查看原件与行数);<br>上报人工统计表后,这里会显示指标矩阵</small>
  344. <button type="button" class="btn btn-primary btn-small" id="opsEmptyUpload" style="margin-top:14px">上传文件</button>
  345. </div>`
  346. : `<div class="empty-hint ops-empty">
  347. <p>还没有店铺运营数据</p>
  348. <small>上传商家后台导出的人工统计表(.xlsx),解析后按学校 / 店铺 / 日期查看</small>
  349. <button type="button" class="btn btn-primary btn-small" id="opsEmptyUpload" style="margin-top:14px">上传文件</button>
  350. </div>`;
  351. $('opsEmptyUpload').addEventListener('click', openUpload);
  352. return;
  353. }
  354. if (state.error) {
  355. body.innerHTML = `<div class="empty-hint ops-empty"><p>数据加载失败</p><small>${esc(state.error)}</small>
  356. <button type="button" class="btn btn-ghost btn-small" id="opsReload" style="margin-top:14px">重试</button></div>`;
  357. $('opsReload').addEventListener('click', loadRows);
  358. return;
  359. }
  360. if (state.loading && !state.data) {
  361. body.innerHTML = '<div class="empty-hint"><p>正在加载…</p></div>';
  362. return;
  363. }
  364. if (state.schools !== null && state.schools.size === 0) {
  365. body.innerHTML = '<div class="empty-hint ops-empty"><p>请至少选择一个学校</p></div>';
  366. return;
  367. }
  368. if (!state.data) return;
  369. body.innerHTML = state.view === 'cards' ? renderCards(state.data) : renderTables(state.data);
  370. if (state.view === 'table') bindRowHover();
  371. }
  372. function cellValue(shop, field, date) {
  373. const series = shop.series[field.key] || {};
  374. return Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
  375. }
  376. function metricCell(shop, field, date) {
  377. const value = cellValue(shop, field, date);
  378. const quality = (shop.quality || {})[date];
  379. const warn = quality && quality.length
  380. ? `<span class="ops-warn" title="${esc('解析标记:' + quality.join('、'))}">⚠</span>`
  381. : '';
  382. if (field.type === 'text') {
  383. return `<td class="ops-text-cell${value == null ? ' ops-null' : ''}">${fmtValue(value, field.type)}${warn}</td>`;
  384. }
  385. return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, field.type)}${warn}</td>`;
  386. }
  387. /** 学校小计:数量/金额求和,比率按曝光加权,文本取第一个非空 */
  388. function subtotalValue(group, field, date) {
  389. if (field.type === 'text') {
  390. for (const shop of group.shops) {
  391. const v = cellValue(shop, field, date);
  392. if (v !== null) return v;
  393. }
  394. return null;
  395. }
  396. if (field.type === 'rate') {
  397. let weight = 0;
  398. let acc = 0;
  399. for (const shop of group.shops) {
  400. const exposure = cellValue(shop, { key: 'exposure' }, date);
  401. const rate = cellValue(shop, field, date);
  402. if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
  403. weight += exposure;
  404. acc += rate * exposure;
  405. }
  406. }
  407. return weight > 0 ? acc / weight : null;
  408. }
  409. let sum = 0;
  410. let seen = false;
  411. for (const shop of group.shops) {
  412. const v = cellValue(shop, field, date);
  413. if (typeof v === 'number') { sum += v; seen = true; }
  414. }
  415. return seen ? sum : null;
  416. }
  417. function activeFields(data) {
  418. if (data && Array.isArray(data.fields) && data.fields.length) return data.fields;
  419. return state.fields.map((key) => fieldByKey(key));
  420. }
  421. function renderTables(data) {
  422. const fields = activeFields(data);
  423. if (!fields.length) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
  424. const dates = data.dates || [];
  425. const colCount = fields.length + 3;
  426. const head = `<tr>
  427. <th class="ops-shop-col">店铺</th>
  428. <th class="ops-date-col">日期</th>
  429. ${fields.map((f) => `<th class="ops-metric-col ${f.type === 'text' ? 'ops-col-text' : 'ops-col-num'}" title="${esc(f.label)}">${esc(f.label)}</th>`).join('')}
  430. <th class="ops-filler-col"></th>
  431. </tr>`;
  432. const body = data.groups.map((group, groupIdx) => {
  433. const schoolRow = `<tr class="ops-school-row"><td colspan="${colCount}">${esc(group.school)}<em>${group.shops.length} 家</em></td></tr>`;
  434. const shopRows = group.shops.map((shop, shopIdx) => {
  435. const shopDates = dates.filter((d) => fields.some((f) => cellValue(shop, f, d) !== null));
  436. const useDates = shopDates.length ? shopDates : dates.slice(0, 1);
  437. const blockKey = `b${groupIdx}-${shopIdx}`;
  438. const alt = shopIdx % 2 === 1 ? ' ops-block-alt' : '';
  439. return useDates.map((date, i) => `<tr data-block="${blockKey}" class="ops-shop-row${i === 0 ? ' ops-block-start' : ''}${alt}">
  440. ${i === 0 ? `<td class="ops-shop-cell" rowspan="${useDates.length}" title="${esc(shop.shopName)}"><span class="ops-shop-name">${esc(shop.shopName)}</span>${
  441. shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted ops-status">${esc(shop.status)}</span>` : ''
  442. }</td>` : ''}
  443. <td class="ops-date-cell">${shortDate(date)}</td>
  444. ${fields.map((f) => metricCell(shop, f, date)).join('')}
  445. <td class="ops-filler"></td>
  446. </tr>`).join('');
  447. }).join('');
  448. const subtotalDates = dates.filter((d) => group.shops.some((shop) => fields.some((f) => cellValue(shop, f, d) !== null)));
  449. const subtotalRows = subtotalDates.map((date) => `<tr class="ops-subtotal-row">
  450. <td class="ops-subtotal-label" colspan="2">${esc(group.school)} · 小计 · ${shortDate(date)}</td>
  451. ${fields.map((f) => {
  452. const v = subtotalValue(group, f, date);
  453. return `<td class="${f.type === 'text' ? 'ops-text-cell' : 'ops-num'} ops-subtotal">${v == null ? '–' : fmtValue(v, f.type)}</td>`;
  454. }).join('')}
  455. <td class="ops-filler ops-subtotal"></td>
  456. </tr>`).join('');
  457. return schoolRow + shopRows + subtotalRows;
  458. }).join('');
  459. const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
  460. return `<section class="ops-block">
  461. <div class="ops-block-head"><h2>运营明细</h2><span>${shopCount} 家门店 · ${dates.length} 天 · ${fields.length} 个字段</span></div>
  462. <div class="ops-table-wrap">
  463. <table class="ops-table ops-table-wide">
  464. <thead>${head}</thead>
  465. <tbody>${body}</tbody>
  466. </table>
  467. </div>
  468. </section>`;
  469. }
  470. /** 悬停时整块高亮(同一店铺的多天一起亮),避免分不清行属于哪家店 */
  471. function bindRowHover() {
  472. document.querySelectorAll('.ops-table-wide tbody').forEach((tbody) => {
  473. if (tbody.dataset.hoverBound === '1') return;
  474. tbody.dataset.hoverBound = '1';
  475. const clear = () => tbody.querySelectorAll('tr.ops-hover').forEach((tr) => tr.classList.remove('ops-hover'));
  476. tbody.addEventListener('mouseover', (e) => {
  477. const tr = e.target.closest('tr[data-block]');
  478. if (!tr) return;
  479. if (tr.classList.contains('ops-hover')) return;
  480. clear();
  481. tbody.querySelectorAll(`tr[data-block="${tr.dataset.block}"]`).forEach((row) => row.classList.add('ops-hover'));
  482. });
  483. tbody.addEventListener('mouseleave', clear);
  484. });
  485. }
  486. function sparkline(shop, field, dates) {
  487. const values = dates.map((d) => {
  488. const v = (shop.series[field.key] || {})[d];
  489. return typeof v === 'number' ? v : null;
  490. });
  491. const nums = values.filter((v) => v != null);
  492. if (nums.length < 2) return '';
  493. const min = Math.min(...nums);
  494. const max = Math.max(...nums);
  495. const span = max - min || 1;
  496. const w = 160;
  497. const h = 34;
  498. const step = w / Math.max(values.length - 1, 1);
  499. const points = [];
  500. values.forEach((v, i) => {
  501. if (v == null) return;
  502. const x = (i * step).toFixed(1);
  503. const y = (h - ((v - min) / span) * (h - 6) - 3).toFixed(1);
  504. points.push(`${x},${y}`);
  505. });
  506. return `<svg class="ops-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true"><polyline points="${points.join(' ')}" /></svg>`;
  507. }
  508. function renderCards(data) {
  509. const dates = data.dates || [];
  510. const fields = activeFields(data);
  511. const first = fields[0];
  512. if (!first) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
  513. return `<div class="ops-cards">${data.groups.map((group) => `
  514. <section class="ops-card-group">
  515. <h3>${esc(group.school)}</h3>
  516. <div class="ops-card-grid">
  517. ${group.shops.map((shop) => `
  518. <article class="ops-card">
  519. <div class="ops-card-head">
  520. <span class="ops-card-name" title="${esc(shop.shopName)}">${esc(shop.shopName)}</span>
  521. ${shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted">${esc(shop.status)}</span>` : ''}
  522. </div>
  523. <div class="ops-card-metrics">
  524. ${fields.map((f) => `<div class="ops-card-metric"><span>${esc(f.label)}</span><b>${fmtValue(shop.totals ? shop.totals[f.key] : null, f.type)}</b></div>`).join('')}
  525. </div>
  526. ${sparkline(shop, first, dates)}
  527. </article>`).join('')}
  528. </div>
  529. </section>`).join('')}</div>`;
  530. }
  531. // ---------- 上传 ----------
  532. async function refreshSchoolOptions() {
  533. const select = $('opsSchool');
  534. if (!select) return;
  535. // 已登记学校(校区地图里的学校注册表,含内置)
  536. const registered = [];
  537. try {
  538. const { res, body } = await api('/api/schools');
  539. if (res.ok) {
  540. for (const school of (body && body.schools) || []) {
  541. if (!school || !school.name) continue;
  542. registered.push({
  543. name: school.name,
  544. hint: [school.province, school.city].filter(Boolean).join(' · '),
  545. });
  546. }
  547. }
  548. } catch { /* 注册表取不到时回落到运营数据里的学校 */ }
  549. // 运营数据中出现过、但还没登记的学校
  550. const registeredNames = new Set(registered.map((x) => x.name));
  551. const fromOps = ((state.summary && state.summary.schools) || [])
  552. .map((x) => x.name)
  553. .filter((name) => name && !registeredNames.has(name));
  554. select.innerHTML = '<option value="">自动(读取表格里的「学校名称」列)</option>'
  555. + (registered.length
  556. ? `<optgroup label="已登记学校">${registered.map((x) => `<option value="${esc(x.name)}">${esc(x.name)}${x.hint ? '(' + esc(x.hint) + ')' : ''}</option>`).join('')}</optgroup>`
  557. : '')
  558. + (fromOps.length
  559. ? `<optgroup label="运营数据中的学校">${fromOps.map((name) => `<option value="${esc(name)}">${esc(name)}</option>`).join('')}</optgroup>`
  560. : '')
  561. + '<option value="__custom">手动输入…</option>';
  562. select.value = '';
  563. const custom = $('opsSchoolCustom');
  564. if (custom) {
  565. custom.value = '';
  566. custom.hidden = true;
  567. }
  568. }
  569. function selectedSchool() {
  570. const select = $('opsSchool');
  571. if (!select) return '';
  572. if (select.value === '__custom') return String(($('opsSchoolCustom') || {}).value || '').trim();
  573. return select.value;
  574. }
  575. function fileKey(f) {
  576. return f.name + '|' + f.size + '|' + (f.lastModified || 0);
  577. }
  578. function addFiles(fileList) {
  579. for (const f of Array.from(fileList || [])) {
  580. if (!/\.xlsx$/i.test(f.name)) continue;
  581. const key = fileKey(f);
  582. if (state.upload.files.some((x) => fileKey(x.file) === key)) continue;
  583. state.upload.files.push({ file: f, status: 'pending', message: '' });
  584. }
  585. renderFileList();
  586. }
  587. function renderFileList() {
  588. const box = $('opsFileList');
  589. if (!box) return;
  590. if (!state.upload.files.length) {
  591. box.innerHTML = '';
  592. return;
  593. }
  594. box.innerHTML = state.upload.files.map((item, i) => {
  595. const cls = item.status === 'error' ? 'ops-error'
  596. : item.status === 'done' ? 'ops-done-text'
  597. : item.status === 'duplicate' ? 'ops-muted' : '';
  598. const msg = item.message ? `<em class="${cls}">${esc(item.message)}</em>` : '';
  599. const removable = item.status !== 'uploading';
  600. return `<div class="ops-file-item">
  601. <span class="ops-file-name2" title="${esc(item.file.name)}">${esc(item.file.name)}</span>
  602. <span class="ops-file-size">${(item.file.size / 1024).toFixed(0)} KB</span>
  603. ${msg}
  604. ${removable ? `<button type="button" class="ops-file-del" data-idx="${i}" title="移除">×</button>` : ''}
  605. </div>`;
  606. }).join('');
  607. box.querySelectorAll('[data-idx]').forEach((btn) => {
  608. btn.addEventListener('click', () => {
  609. const i = Number(btn.dataset.idx);
  610. if (state.upload.files[i] && state.upload.files[i].status === 'uploading') return;
  611. state.upload.files.splice(i, 1);
  612. renderFileList();
  613. });
  614. });
  615. }
  616. async function openUpload() {
  617. const modal = $('opsUploadModal');
  618. if (!modal) return;
  619. state.upload.files = [];
  620. renderFileList();
  621. $('opsUploadResult').innerHTML = '';
  622. $('opsYear').value = String(new Date().getFullYear());
  623. await refreshSchoolOptions();
  624. if ($('opsForce')) $('opsForce').checked = false;
  625. $('opsUploadStart').disabled = false;
  626. modal.hidden = false;
  627. }
  628. function closeUpload() {
  629. const modal = $('opsUploadModal');
  630. if (modal) modal.hidden = true;
  631. }
  632. function readFileBase64(file) {
  633. return new Promise((resolve, reject) => {
  634. const reader = new FileReader();
  635. reader.onload = () => {
  636. const text = String(reader.result || '');
  637. resolve(text.slice(text.indexOf(',') + 1));
  638. };
  639. reader.onerror = () => reject(reader.error || new Error('读取文件失败'));
  640. reader.readAsDataURL(file);
  641. });
  642. }
  643. async function doUpload(forceOverride) {
  644. const items = state.upload.files;
  645. const result = $('opsUploadResult');
  646. if (!items.length) {
  647. result.innerHTML = '<span class="ops-error">请先选择 .xlsx 文件(可一次选多个)</span>';
  648. return;
  649. }
  650. if (items.length > 30) {
  651. result.innerHTML = '<span class="ops-error">一次最多 30 个文件</span>';
  652. return;
  653. }
  654. const year = Number($('opsYear').value) || new Date().getFullYear();
  655. const school = selectedSchool();
  656. const force = !!(forceOverride || ($('opsForce') && $('opsForce').checked));
  657. $('opsUploadStart').disabled = true;
  658. result.innerHTML = '<span class="ops-muted">正在逐个解析并入库…</span>';
  659. let okCount = 0;
  660. let skipCount = 0;
  661. let failCount = 0;
  662. for (const item of items) {
  663. if (item.status === 'done') continue;
  664. item.status = 'uploading';
  665. item.message = '导入中…';
  666. renderFileList();
  667. try {
  668. const base64 = await readFileBase64(item.file);
  669. const { res, body } = await api('/api/ops/upload', {
  670. method: 'POST',
  671. headers: { 'Content-Type': 'application/json' },
  672. body: JSON.stringify({ fileName: item.file.name, contentBase64: base64, year, school, force }),
  673. });
  674. if (res.status === 409) {
  675. item.status = 'duplicate';
  676. item.message = '已导入过(跳过)';
  677. skipCount += 1;
  678. } else if (!res.ok) {
  679. const detail = body && (body.detail ? body.error + ':' + body.detail : body.error);
  680. item.status = 'error';
  681. item.message = detail || ('失败 HTTP ' + res.status);
  682. failCount += 1;
  683. } else {
  684. const r = body.report || {};
  685. item.status = 'done';
  686. if (r.kind === 'platform-export') {
  687. const parts = ['新增 ' + r.newRows + ' 行'];
  688. if (r.identicalRows) parts.push('已存在 ' + r.identicalRows + ' 行');
  689. if (r.conflictRows) parts.push('冲突 ' + r.conflictRows + ' 行');
  690. item.message = parts.join(' · ');
  691. } else {
  692. item.message = r.rows + ' 行 · ' + r.shops + ' 家';
  693. }
  694. okCount += 1;
  695. }
  696. } catch (e) {
  697. item.status = 'error';
  698. item.message = e.message || String(e);
  699. failCount += 1;
  700. }
  701. renderFileList();
  702. }
  703. const parts = ['成功 ' + okCount + ' 个'];
  704. if (skipCount) parts.push('跳过 ' + skipCount + ' 个(已导入过)');
  705. if (failCount) parts.push('失败 ' + failCount + ' 个');
  706. result.innerHTML = `<div class="${failCount ? 'ops-error' : 'ops-ok'}">${parts.join(',')}${
  707. skipCount && !force ? '<br><small>已存在的文件可勾选下方「覆盖已导入的文件」后重新导入</small>' : ''
  708. }</div>`;
  709. $('opsUploadStart').disabled = false;
  710. if (okCount) {
  711. state.from = '';
  712. state.to = '';
  713. state.schools = null;
  714. await init(true);
  715. }
  716. }
  717. // ---------- 导入记录 ----------
  718. async function openRecords() {
  719. const modal = $('opsRecordsModal');
  720. if (!modal) return;
  721. modal.hidden = false;
  722. const list = $('opsRecordsList');
  723. list.innerHTML = '<div class="ops-muted" style="padding:16px">加载中…</div>';
  724. const { res, body } = await api('/api/ops/uploads');
  725. if (!res.ok) {
  726. list.innerHTML = `<div class="ops-error" style="padding:16px">${esc((body && (body.detail || body.error)) || ('HTTP ' + res.status))}</div>`;
  727. return;
  728. }
  729. const uploads = (body && body.uploads) || [];
  730. if (!uploads.length) {
  731. list.innerHTML = '<div class="ops-muted" style="padding:16px">还没有导入记录</div>';
  732. return;
  733. }
  734. list.innerHTML = `<table class="data-table">
  735. <thead><tr><th>文件名</th><th>上传时间</th><th>行数</th><th>门店</th><th>警告</th><th class="col-actions">操作</th></tr></thead>
  736. <tbody>${uploads.map((u) => `<tr>
  737. <td>${esc(u.originalName)}</td>
  738. <td>${esc(new Date(u.uploadedAt).toLocaleString('zh-CN'))}</td>
  739. <td>${u.rows || 0}</td>
  740. <td>${u.shops || 0}</td>
  741. <td>${u.warningCount || 0}</td>
  742. <td class="col-actions">
  743. <a class="btn btn-ghost btn-small" href="/api/ops/uploads/${esc(u.id)}/file">下载原件</a>
  744. <button type="button" class="btn btn-ghost btn-small" data-reparse="${esc(u.id)}">重新解析</button>
  745. </td>
  746. </tr>`).join('')}</tbody>
  747. </table>`;
  748. list.querySelectorAll('[data-reparse]').forEach((btn) => {
  749. btn.addEventListener('click', async () => {
  750. btn.disabled = true;
  751. btn.textContent = '解析中…';
  752. const { res, body: rep } = await api(`/api/ops/uploads/${btn.dataset.reparse}/reparse`, { method: 'POST' });
  753. btn.disabled = false;
  754. btn.textContent = '重新解析';
  755. if (!res.ok) {
  756. alert((rep && (rep.detail || rep.error)) || '重新解析失败');
  757. return;
  758. }
  759. await init(true);
  760. });
  761. });
  762. }
  763. // ---------- 初始化 ----------
  764. async function loadCatalog() {
  765. const { res, body } = await api('/api/ops/fields');
  766. if (!res.ok || !body || !Array.isArray(body.fields)) return;
  767. state.catalog = body.fields;
  768. const valid = new Set(body.fields.map((f) => f.key));
  769. state.fields = state.fields.filter((k) => valid.has(k));
  770. if (!state.fields.length) state.fields = (body.defaults || []).filter((k) => valid.has(k));
  771. if (!state.fields.length) state.fields = body.fields.slice(0, 4).map((f) => f.key);
  772. }
  773. async function init(force) {
  774. if (state.inited && !force) return;
  775. state.inited = true;
  776. await loadSummary();
  777. await loadCatalog();
  778. renderBar();
  779. renderHeaderTools();
  780. renderBody();
  781. if (state.summary && state.summary.enabled && matrixCount(state.summary)) await loadRows();
  782. }
  783. function bindOnce() {
  784. bindMenuDismiss();
  785. const uploadBtn = $('btnOpsUpload');
  786. if (uploadBtn) uploadBtn.addEventListener('click', openUpload);
  787. const recordsBtn = $('btnOpsRecords');
  788. if (recordsBtn) recordsBtn.addEventListener('click', openRecords);
  789. const start = $('opsUploadStart');
  790. if (start) start.addEventListener('click', () => doUpload(false));
  791. const cancel = $('opsUploadCancel');
  792. if (cancel) cancel.addEventListener('click', closeUpload);
  793. const closeBtn = $('opsUploadClose');
  794. if (closeBtn) closeBtn.addEventListener('click', closeUpload);
  795. const schoolSelect = $('opsSchool');
  796. if (schoolSelect) {
  797. schoolSelect.addEventListener('change', () => {
  798. const custom = $('opsSchoolCustom');
  799. if (!custom) return;
  800. custom.hidden = schoolSelect.value !== '__custom';
  801. if (!custom.hidden) custom.focus();
  802. });
  803. }
  804. const file = $('opsFile');
  805. if (file) {
  806. file.addEventListener('change', () => {
  807. addFiles(file.files);
  808. file.value = '';
  809. });
  810. }
  811. const drop = $('opsDrop');
  812. if (drop) {
  813. ['dragover', 'dragenter'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('on'); }));
  814. ['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('on'); }));
  815. drop.addEventListener('drop', (e) => {
  816. if (!e.dataTransfer || !e.dataTransfer.files) return;
  817. addFiles(e.dataTransfer.files);
  818. });
  819. drop.addEventListener('click', () => { const input = $('opsFile'); if (input) input.click(); });
  820. }
  821. const recordsClose = $('opsRecordsClose');
  822. if (recordsClose) recordsClose.addEventListener('click', () => { $('opsRecordsModal').hidden = true; });
  823. }
  824. window.shanOpsInit = function () {
  825. bindOnce();
  826. init(false);
  827. };
  828. })();