ops.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. 'use strict';
  2. // 店铺运营页:上传人工统计表 → 按学校/店铺/日期看指标矩阵或卡片。
  3. // 数据来自 /api/ops/*;原件存服务端存储(默认本地 SQLite),页面只做展示与上传。
  4. (function () {
  5. const $ = (id) => document.getElementById(id);
  6. const META = {
  7. order_cnt: { label: '订单量', type: 'int' },
  8. revenue: { label: '营收', type: 'money' },
  9. exposure: { label: '曝光量', type: 'int' },
  10. click_rate: { label: '进店转化率', type: 'rate' },
  11. };
  12. const ALL_METRICS = Object.keys(META);
  13. const DEFAULT_METRICS = ALL_METRICS.slice();
  14. function loadJSON(key, fallback) {
  15. try {
  16. const raw = localStorage.getItem(key);
  17. if (!raw) return fallback;
  18. const v = JSON.parse(raw);
  19. return Array.isArray(v) && v.length ? v.filter((x) => ALL_METRICS.includes(x)) : fallback;
  20. } catch {
  21. return fallback;
  22. }
  23. }
  24. const state = {
  25. inited: false,
  26. loading: false,
  27. summary: null,
  28. data: null,
  29. schools: null, // null = 全选
  30. metrics: loadJSON('ops.metrics', DEFAULT_METRICS),
  31. view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
  32. from: '',
  33. to: '',
  34. upload: { file: null, busy: false },
  35. };
  36. function esc(value) {
  37. const div = document.createElement('div');
  38. div.textContent = String(value == null ? '' : value);
  39. return div.innerHTML;
  40. }
  41. function ymdToInput(ymd) {
  42. const s = String(ymd || '');
  43. return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : '';
  44. }
  45. function inputToYmd(value) {
  46. return String(value || '').replace(/-/g, '');
  47. }
  48. function shortDate(ymd) {
  49. const s = String(ymd || '');
  50. return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s;
  51. }
  52. function fmtValue(value, metric) {
  53. if (value === null || value === undefined) return '–';
  54. const n = Number(value);
  55. if (!Number.isFinite(n)) return '–';
  56. if (META[metric].type === 'rate') return (n * 100).toFixed(1) + '%';
  57. if (META[metric].type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  58. return n.toLocaleString('zh-CN');
  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. params.set('metrics', state.metrics.join(','));
  97. const { res, body } = await api('/api/ops/rows?' + params.toString());
  98. state.loading = false;
  99. if (!res.ok) {
  100. state.data = null;
  101. state.error = (body && (body.detail || body.error)) || `HTTP ${res.status}`;
  102. } else {
  103. state.data = body;
  104. state.error = null;
  105. }
  106. renderBody();
  107. }
  108. // ---------- 工具条 ----------
  109. function renderBar() {
  110. const bar = $('opsBar');
  111. if (!bar) return;
  112. const summary = state.summary;
  113. if (!summary || !summary.enabled || !summary.rowCount) {
  114. bar.innerHTML = '';
  115. return;
  116. }
  117. const schools = summary.schools || [];
  118. const chosen = state.schools === null ? new Set(schools.map((s) => s.name)) : state.schools;
  119. bar.innerHTML = `
  120. <div class="ops-bar">
  121. <div class="ops-bar-group">
  122. <span class="ops-bar-label">学校</span>
  123. <div class="ops-chips" id="opsSchoolChips">
  124. ${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('')}
  125. </div>
  126. </div>
  127. <div class="ops-bar-group">
  128. <span class="ops-bar-label">日期</span>
  129. <input type="date" class="ops-input" id="opsFrom" value="${ymdToInput(state.from)}">
  130. <span class="ops-tilde">~</span>
  131. <input type="date" class="ops-input" id="opsTo" value="${ymdToInput(state.to)}">
  132. </div>
  133. <div class="ops-bar-group">
  134. <span class="ops-bar-label">指标</span>
  135. <div class="ops-chips" id="opsMetricChips">
  136. ${ALL_METRICS.map((k) => `<button type="button" class="ops-chip${state.metrics.includes(k) ? ' on' : ''}" data-metric="${k}">${META[k].label}</button>`).join('')}
  137. </div>
  138. </div>
  139. </div>`;
  140. renderHeaderTools();
  141. bar.querySelectorAll('[data-school]').forEach((btn) => {
  142. btn.addEventListener('click', () => {
  143. const name = btn.dataset.school;
  144. const current = state.schools === null ? new Set(schools.map((s) => s.name)) : new Set(state.schools);
  145. if (current.has(name)) current.delete(name); else current.add(name);
  146. state.schools = current.size === schools.length ? null : current;
  147. if (!current.size) state.schools = new Set();
  148. renderBar();
  149. loadRows();
  150. });
  151. });
  152. bar.querySelectorAll('[data-metric]').forEach((btn) => {
  153. btn.addEventListener('click', () => {
  154. const key = btn.dataset.metric;
  155. if (state.metrics.includes(key)) {
  156. if (state.metrics.length === 1) return; // 至少保留一个指标
  157. state.metrics = state.metrics.filter((k) => k !== key);
  158. } else {
  159. state.metrics = ALL_METRICS.filter((k) => key === k || state.metrics.includes(k));
  160. }
  161. localStorage.setItem('ops.metrics', JSON.stringify(state.metrics));
  162. renderBar();
  163. renderBody();
  164. });
  165. });
  166. $('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); });
  167. $('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
  168. }
  169. function renderHeaderTools() {
  170. const el = $('opsHeaderTools');
  171. if (!el) return;
  172. const summary = state.summary;
  173. if (!summary || !summary.enabled || !summary.rowCount) {
  174. el.innerHTML = '';
  175. return;
  176. }
  177. el.innerHTML = `<div class="ops-seg" id="opsViewSeg">
  178. <button type="button" data-mode="table" class="${state.view === 'table' ? 'on' : ''}">报表</button>
  179. <button type="button" data-mode="cards" class="${state.view === 'cards' ? 'on' : ''}">卡片</button>
  180. </div>
  181. <button type="button" class="btn btn-ghost btn-small" id="opsExport">导出 CSV</button>`;
  182. el.querySelectorAll('#opsViewSeg [data-mode]').forEach((btn) => {
  183. btn.addEventListener('click', () => {
  184. state.view = btn.dataset.mode;
  185. localStorage.setItem('ops.view', state.view);
  186. renderHeaderTools();
  187. renderBody();
  188. });
  189. });
  190. $('opsExport').addEventListener('click', () => {
  191. const params = new URLSearchParams();
  192. if (state.from) params.set('from', state.from);
  193. if (state.to) params.set('to', state.to);
  194. if (state.schools !== null && state.schools.size) params.set('schools', [...state.schools].join(','));
  195. params.set('metrics', state.metrics.join(','));
  196. window.open('/api/ops/export?' + params.toString(), '_blank');
  197. });
  198. }
  199. // ---------- 内容区 ----------
  200. function renderBody() {
  201. const body = $('opsBody');
  202. if (!body) return;
  203. const summary = state.summary;
  204. if (!summary) {
  205. body.innerHTML = '<div class="empty-hint"><p>正在加载…</p></div>';
  206. return;
  207. }
  208. if (!summary.enabled) {
  209. const reasonText = {
  210. 'storage-not-configured': '未配置存储(config.json → storage)',
  211. 'storage-unreachable': '存储连接失败',
  212. 'pg-not-installed': '未安装 pg 依赖',
  213. 'sqlite-unavailable': '当前 Node 版本不支持内置 SQLite(需要 22.5+)',
  214. }[summary.reason] || '存储不可用';
  215. body.innerHTML = `
  216. <div class="empty-hint ops-empty">
  217. <p>店铺运营数据暂不可用</p>
  218. <small>${esc(reasonText)}${summary.detail ? ':' + esc(summary.detail) : ''}</small>
  219. <button type="button" class="btn btn-ghost btn-small" id="opsRetry" style="margin-top:14px">重试</button>
  220. </div>`;
  221. $('opsRetry').addEventListener('click', () => init(true));
  222. return;
  223. }
  224. if (!summary.rowCount) {
  225. body.innerHTML = `
  226. <div class="empty-hint ops-empty">
  227. <p>还没有店铺运营数据</p>
  228. <small>上传商家后台导出的人工统计表(.xlsx),解析后按学校 / 店铺 / 日期查看</small>
  229. <button type="button" class="btn btn-primary btn-small" id="opsEmptyUpload" style="margin-top:14px">上传文件</button>
  230. </div>`;
  231. $('opsEmptyUpload').addEventListener('click', openUpload);
  232. return;
  233. }
  234. if (state.error) {
  235. body.innerHTML = `<div class="empty-hint ops-empty"><p>数据加载失败</p><small>${esc(state.error)}</small>
  236. <button type="button" class="btn btn-ghost btn-small" id="opsReload" style="margin-top:14px">重试</button></div>`;
  237. $('opsReload').addEventListener('click', loadRows);
  238. return;
  239. }
  240. if (state.loading && !state.data) {
  241. body.innerHTML = '<div class="empty-hint"><p>正在加载…</p></div>';
  242. return;
  243. }
  244. if (state.schools !== null && state.schools.size === 0) {
  245. body.innerHTML = '<div class="empty-hint ops-empty"><p>请至少选择一个学校</p></div>';
  246. return;
  247. }
  248. if (!state.data) return;
  249. body.innerHTML = state.view === 'cards' ? renderCards(state.data) : renderTables(state.data);
  250. }
  251. function formatCell(shop, metric, date) {
  252. const series = shop.series[metric] || {};
  253. const value = Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
  254. const quality = (shop.quality || {})[date];
  255. const warn = quality && quality.length
  256. ? `<span class="ops-warn" title="${esc('解析标记:' + quality.join('、'))}">⚠</span>`
  257. : '';
  258. return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, metric)}${warn}</td>`;
  259. }
  260. function subtotalRow(group, metric, dates) {
  261. const cells = dates.map((date) => {
  262. if (metric === 'click_rate') {
  263. let weight = 0;
  264. let acc = 0;
  265. for (const shop of group.shops) {
  266. const exposure = (shop.series.exposure || {})[date];
  267. const rate = (shop.series.click_rate || {})[date];
  268. if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
  269. weight += exposure;
  270. acc += rate * exposure;
  271. }
  272. }
  273. return `<td class="ops-num ops-subtotal">${weight > 0 ? fmtValue(acc / weight, metric) : '–'}</td>`;
  274. }
  275. let sum = 0;
  276. let seen = false;
  277. for (const shop of group.shops) {
  278. const v = (shop.series[metric] || {})[date];
  279. if (typeof v === 'number') { sum += v; seen = true; }
  280. }
  281. return `<td class="ops-num ops-subtotal">${seen ? fmtValue(sum, metric) : '–'}</td>`;
  282. }).join('');
  283. return `<tr class="ops-subtotal-row"><td class="ops-shop-cell">${esc(group.school)} · 小计</td>${cells}</tr>`;
  284. }
  285. function renderTables(data) {
  286. const dates = data.dates || [];
  287. const metrics = state.metrics.filter((m) => META[m]);
  288. if (!metrics.length) return '<div class="empty-hint"><p>请至少选择一个指标</p></div>';
  289. return metrics.map((metric) => {
  290. const head = `<tr><th class="ops-shop-col">店铺</th>${dates.map((d) => `<th class="ops-date-col">${shortDate(d)}</th>`).join('')}</tr>`;
  291. const rows = data.groups.map((group) => {
  292. const schoolRow = `<tr class="ops-school-row"><td class="ops-shop-cell" colspan="${dates.length + 1}">${esc(group.school)}<em>${group.shops.length} 家</em></td></tr>`;
  293. const shopRows = group.shops.map((shop) => `<tr>
  294. <td class="ops-shop-cell" title="${esc(shop.shopName)}">${esc(shop.shopName)}${
  295. shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted ops-status">${esc(shop.status)}</span>` : ''
  296. }</td>
  297. ${dates.map((d) => formatCell(shop, metric, d)).join('')}
  298. </tr>`).join('');
  299. return schoolRow + shopRows + subtotalRow(group, metric, dates);
  300. }).join('');
  301. const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
  302. return `<section class="ops-block">
  303. <div class="ops-block-head"><h2>${META[metric].label}</h2><span>${shopCount} 家门店 · ${dates.length} 天</span></div>
  304. <div class="ops-table-wrap">
  305. <table class="ops-table">
  306. <thead>${head}</thead>
  307. <tbody>${rows}</tbody>
  308. </table>
  309. </div>
  310. </section>`;
  311. }).join('');
  312. }
  313. function sparkline(shop, metric, dates) {
  314. const values = dates.map((d) => {
  315. const v = (shop.series[metric] || {})[d];
  316. return typeof v === 'number' ? v : null;
  317. });
  318. const nums = values.filter((v) => v != null);
  319. if (nums.length < 2) return '';
  320. const min = Math.min(...nums);
  321. const max = Math.max(...nums);
  322. const span = max - min || 1;
  323. const w = 160;
  324. const h = 34;
  325. const step = w / Math.max(values.length - 1, 1);
  326. const points = [];
  327. values.forEach((v, i) => {
  328. if (v == null) return;
  329. const x = (i * step).toFixed(1);
  330. const y = (h - ((v - min) / span) * (h - 6) - 3).toFixed(1);
  331. points.push(`${x},${y}`);
  332. });
  333. return `<svg class="ops-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true"><polyline points="${points.join(' ')}" /></svg>`;
  334. }
  335. function renderCards(data) {
  336. const dates = data.dates || [];
  337. const metric = state.metrics[0];
  338. return `<div class="ops-cards">${data.groups.map((group) => `
  339. <section class="ops-card-group">
  340. <h3>${esc(group.school)}</h3>
  341. <div class="ops-card-grid">
  342. ${group.shops.map((shop) => `
  343. <article class="ops-card">
  344. <div class="ops-card-head">
  345. <span class="ops-card-name" title="${esc(shop.shopName)}">${esc(shop.shopName)}</span>
  346. ${shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted">${esc(shop.status)}</span>` : ''}
  347. </div>
  348. <div class="ops-card-metrics">
  349. ${state.metrics.map((m) => `<div class="ops-card-metric"><span>${META[m].label}</span><b>${m === 'click_rate' ? fmtValue(avgRate(shop, dates), m) : fmtValue(shop.totals && shop.totals[m], m)}</b></div>`).join('')}
  350. </div>
  351. ${sparkline(shop, metric, dates)}
  352. </article>`).join('')}
  353. </div>
  354. </section>`).join('')}</div>`;
  355. }
  356. function avgRate(shop, dates) {
  357. let weight = 0;
  358. let acc = 0;
  359. for (const date of dates) {
  360. const exposure = (shop.series.exposure || {})[date];
  361. const rate = (shop.series.click_rate || {})[date];
  362. if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
  363. weight += exposure;
  364. acc += rate * exposure;
  365. }
  366. }
  367. return weight > 0 ? acc / weight : null;
  368. }
  369. // ---------- 上传 ----------
  370. function fillSchoolOptions() {
  371. const select = $('opsSchool');
  372. if (!select) return;
  373. const known = (state.summary && state.summary.schools) || [];
  374. select.innerHTML = '<option value="">自动(读取表格里的「学校名称」列)</option>'
  375. + known.map((s) => `<option value="${esc(s.name)}">${esc(s.name)}</option>`).join('')
  376. + '<option value="__custom">手动输入…</option>';
  377. select.value = '';
  378. const custom = $('opsSchoolCustom');
  379. if (custom) {
  380. custom.value = '';
  381. custom.hidden = true;
  382. }
  383. }
  384. function selectedSchool() {
  385. const select = $('opsSchool');
  386. if (!select) return '';
  387. if (select.value === '__custom') return String(($('opsSchoolCustom') || {}).value || '').trim();
  388. return select.value;
  389. }
  390. function openUpload() {
  391. const modal = $('opsUploadModal');
  392. if (!modal) return;
  393. state.upload.file = null;
  394. $('opsFileName').textContent = '';
  395. $('opsUploadResult').innerHTML = '';
  396. $('opsYear').value = String(new Date().getFullYear());
  397. fillSchoolOptions();
  398. $('opsUploadStart').disabled = false;
  399. modal.hidden = false;
  400. }
  401. function closeUpload() {
  402. const modal = $('opsUploadModal');
  403. if (modal) modal.hidden = true;
  404. }
  405. function readFileBase64(file) {
  406. return new Promise((resolve, reject) => {
  407. const reader = new FileReader();
  408. reader.onload = () => {
  409. const text = String(reader.result || '');
  410. resolve(text.slice(text.indexOf(',') + 1));
  411. };
  412. reader.onerror = () => reject(reader.error || new Error('读取文件失败'));
  413. reader.readAsDataURL(file);
  414. });
  415. }
  416. async function doUpload(force) {
  417. const file = state.upload.file;
  418. if (!file) {
  419. $('opsUploadResult').innerHTML = '<span class="ops-error">请先选择 .xlsx 文件</span>';
  420. return;
  421. }
  422. if (!/\.xlsx$/i.test(file.name)) {
  423. $('opsUploadResult').innerHTML = '<span class="ops-error">只支持 .xlsx 文件</span>';
  424. return;
  425. }
  426. const result = $('opsUploadResult');
  427. const year = Number($('opsYear').value) || new Date().getFullYear();
  428. $('opsUploadStart').disabled = true;
  429. result.innerHTML = '<span class="ops-muted">正在解析并入库…</span>';
  430. try {
  431. const base64 = await readFileBase64(file);
  432. const { res, body } = await api('/api/ops/upload', {
  433. method: 'POST',
  434. headers: { 'Content-Type': 'application/json' },
  435. body: JSON.stringify({ fileName: file.name, contentBase64: base64, year, school, force: !!force }),
  436. });
  437. if (res.status === 409) {
  438. result.innerHTML = `<span class="ops-error">该文件已导入过(uploadId ${esc(body && body.uploadId)})</span>
  439. <button type="button" class="btn btn-ghost btn-small" id="opsForceUpload" style="margin-left:10px">仍然导入</button>`;
  440. $('opsForceUpload').addEventListener('click', () => doUpload(true));
  441. $('opsUploadStart').disabled = false;
  442. return;
  443. }
  444. if (!res.ok) {
  445. const detail = body && (body.detail ? `${body.error}:${body.detail}` : body.error);
  446. result.innerHTML = `<span class="ops-error">${esc(detail || ('导入失败 HTTP ' + res.status))}</span>`;
  447. $('opsUploadStart').disabled = false;
  448. return;
  449. }
  450. const r = body.report || {};
  451. const warn = (body.warnings || []).slice(0, 5).map((w) => `<li>第 ${w.row} 行 ${esc(w.column)}:${esc(w.raw || '空')}(${esc(w.reason)})</li>`).join('');
  452. result.innerHTML = `<div class="ops-ok">
  453. <b>导入完成</b>:${r.rows} 行 · ${r.shops} 家门店 · ${r.dates ? shortDate(r.dates.from) + ' ~ ' + shortDate(r.dates.to) : ''} · 覆盖 ${r.overwritten} 行 · 警告 ${r.warningCount} 条
  454. ${warn ? `<ul class="ops-warn-list">${warn}</ul>` : ''}
  455. </div>`;
  456. state.from = '';
  457. state.to = '';
  458. state.schools = null;
  459. await init(true);
  460. } catch (e) {
  461. result.innerHTML = `<span class="ops-error">${esc(e.message || e)}</span>`;
  462. $('opsUploadStart').disabled = false;
  463. }
  464. }
  465. // ---------- 导入记录 ----------
  466. async function openRecords() {
  467. const modal = $('opsRecordsModal');
  468. if (!modal) return;
  469. modal.hidden = false;
  470. const list = $('opsRecordsList');
  471. list.innerHTML = '<div class="ops-muted" style="padding:16px">加载中…</div>';
  472. const { res, body } = await api('/api/ops/uploads');
  473. if (!res.ok) {
  474. list.innerHTML = `<div class="ops-error" style="padding:16px">${esc((body && (body.detail || body.error)) || ('HTTP ' + res.status))}</div>`;
  475. return;
  476. }
  477. const uploads = (body && body.uploads) || [];
  478. if (!uploads.length) {
  479. list.innerHTML = '<div class="ops-muted" style="padding:16px">还没有导入记录</div>';
  480. return;
  481. }
  482. list.innerHTML = `<table class="data-table">
  483. <thead><tr><th>文件名</th><th>上传时间</th><th>行数</th><th>门店</th><th>警告</th><th class="col-actions">操作</th></tr></thead>
  484. <tbody>${uploads.map((u) => `<tr>
  485. <td>${esc(u.originalName)}</td>
  486. <td>${esc(new Date(u.uploadedAt).toLocaleString('zh-CN'))}</td>
  487. <td>${u.rows || 0}</td>
  488. <td>${u.shops || 0}</td>
  489. <td>${u.warningCount || 0}</td>
  490. <td class="col-actions">
  491. <a class="btn btn-ghost btn-small" href="/api/ops/uploads/${esc(u.id)}/file">下载原件</a>
  492. <button type="button" class="btn btn-ghost btn-small" data-reparse="${esc(u.id)}">重新解析</button>
  493. </td>
  494. </tr>`).join('')}</tbody>
  495. </table>`;
  496. list.querySelectorAll('[data-reparse]').forEach((btn) => {
  497. btn.addEventListener('click', async () => {
  498. btn.disabled = true;
  499. btn.textContent = '解析中…';
  500. const { res, body: rep } = await api(`/api/ops/uploads/${btn.dataset.reparse}/reparse`, { method: 'POST' });
  501. btn.disabled = false;
  502. btn.textContent = '重新解析';
  503. if (!res.ok) {
  504. alert((rep && (rep.detail || rep.error)) || '重新解析失败');
  505. return;
  506. }
  507. await init(true);
  508. });
  509. });
  510. }
  511. // ---------- 初始化 ----------
  512. async function init(force) {
  513. if (state.inited && !force) return;
  514. state.inited = true;
  515. await loadSummary();
  516. renderBar();
  517. renderHeaderTools();
  518. renderBody();
  519. if (state.summary && state.summary.enabled && state.summary.rowCount) await loadRows();
  520. }
  521. function bindOnce() {
  522. const uploadBtn = $('btnOpsUpload');
  523. if (uploadBtn) uploadBtn.addEventListener('click', openUpload);
  524. const recordsBtn = $('btnOpsRecords');
  525. if (recordsBtn) recordsBtn.addEventListener('click', openRecords);
  526. const start = $('opsUploadStart');
  527. if (start) start.addEventListener('click', () => doUpload(false));
  528. const cancel = $('opsUploadCancel');
  529. if (cancel) cancel.addEventListener('click', closeUpload);
  530. const closeBtn = $('opsUploadClose');
  531. if (closeBtn) closeBtn.addEventListener('click', closeUpload);
  532. const schoolSelect = $('opsSchool');
  533. if (schoolSelect) {
  534. schoolSelect.addEventListener('change', () => {
  535. const custom = $('opsSchoolCustom');
  536. if (!custom) return;
  537. custom.hidden = schoolSelect.value !== '__custom';
  538. if (!custom.hidden) custom.focus();
  539. });
  540. }
  541. const file = $('opsFile');
  542. if (file) {
  543. file.addEventListener('change', () => {
  544. state.upload.file = file.files && file.files[0] ? file.files[0] : null;
  545. $('opsFileName').textContent = state.upload.file ? state.upload.file.name : '';
  546. });
  547. }
  548. const drop = $('opsDrop');
  549. if (drop) {
  550. ['dragover', 'dragenter'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('on'); }));
  551. ['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('on'); }));
  552. drop.addEventListener('drop', (e) => {
  553. const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
  554. if (!f) return;
  555. state.upload.file = f;
  556. $('opsFileName').textContent = f.name;
  557. });
  558. drop.addEventListener('click', () => { const input = $('opsFile'); if (input) input.click(); });
  559. }
  560. const recordsClose = $('opsRecordsClose');
  561. if (recordsClose) recordsClose.addEventListener('click', () => { $('opsRecordsModal').hidden = true; });
  562. }
  563. window.shanOpsInit = function () {
  564. bindOnce();
  565. init(false);
  566. };
  567. })();