| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702 |
- 'use strict';
- // 店铺运营页:上传人工统计表 → 按学校/店铺/日期看指标矩阵或卡片。
- // 数据来自 /api/ops/*;原件存服务端存储(默认本地 SQLite),页面只做展示与上传。
- (function () {
- const $ = (id) => document.getElementById(id);
- const META = {
- order_cnt: { label: '订单量', type: 'int' },
- revenue: { label: '营收', type: 'money' },
- exposure: { label: '曝光量', type: 'int' },
- click_rate: { label: '进店转化率', type: 'rate' },
- };
- const ALL_METRICS = Object.keys(META);
- const DEFAULT_METRICS = ALL_METRICS.slice();
- function loadJSON(key, fallback) {
- try {
- const raw = localStorage.getItem(key);
- if (!raw) return fallback;
- const v = JSON.parse(raw);
- return Array.isArray(v) && v.length ? v.filter((x) => ALL_METRICS.includes(x)) : fallback;
- } catch {
- return fallback;
- }
- }
- const state = {
- inited: false,
- loading: false,
- summary: null,
- data: null,
- schools: null, // null = 全选
- metrics: loadJSON('ops.metrics', DEFAULT_METRICS),
- view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
- from: '',
- to: '',
- upload: { files: [], busy: false },
- };
- function esc(value) {
- const div = document.createElement('div');
- div.textContent = String(value == null ? '' : value);
- return div.innerHTML;
- }
- function ymdToInput(ymd) {
- const s = String(ymd || '');
- return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : '';
- }
- function inputToYmd(value) {
- return String(value || '').replace(/-/g, '');
- }
- function shortDate(ymd) {
- const s = String(ymd || '');
- return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s;
- }
- function fmtValue(value, metric) {
- if (value === null || value === undefined) return '–';
- const n = Number(value);
- if (!Number.isFinite(n)) return '–';
- if (META[metric].type === 'rate') return (n * 100).toFixed(1) + '%';
- if (META[metric].type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
- return n.toLocaleString('zh-CN');
- }
- function selectedSchools() {
- if (!state.summary) return [];
- const all = state.summary.schools.map((s) => s.name);
- return state.schools === null ? all : all.filter((name) => state.schools.has(name));
- }
- async function api(url, options) {
- const res = await fetch(url, options);
- const isJson = /application\/json/.test(res.headers.get('content-type') || '');
- const body = isJson ? await res.json().catch(() => null) : null;
- return { res, body };
- }
- // ---------- 概览 / 数据 ----------
- async function loadSummary() {
- const { body } = await api('/api/ops/summary');
- state.summary = body || { enabled: false, reason: 'storage-unreachable' };
- if (state.summary.enabled && state.summary.coverage) {
- if (!state.from) state.from = state.summary.coverage.from;
- if (!state.to) state.to = state.summary.coverage.to;
- }
- return state.summary;
- }
- async function loadRows() {
- if (!state.summary || !state.summary.enabled) return;
- if (state.schools !== null && state.schools.size === 0) {
- state.data = null;
- state.loading = false;
- renderBody();
- return;
- }
- state.loading = true;
- renderBody();
- const params = new URLSearchParams();
- if (state.from) params.set('from', state.from);
- if (state.to) params.set('to', state.to);
- const schools = state.schools === null ? [] : [...state.schools];
- if (schools.length && schools.length !== (state.summary.schools || []).length) params.set('schools', schools.join(','));
- params.set('metrics', state.metrics.join(','));
- const { res, body } = await api('/api/ops/rows?' + params.toString());
- state.loading = false;
- if (!res.ok) {
- state.data = null;
- state.error = (body && (body.detail || body.error)) || `HTTP ${res.status}`;
- } else {
- state.data = body;
- state.error = null;
- }
- renderBody();
- }
- // ---------- 工具条 ----------
- function renderBar() {
- const bar = $('opsBar');
- if (!bar) return;
- const summary = state.summary;
- if (!summary || !summary.enabled || !summary.rowCount) {
- bar.innerHTML = '';
- return;
- }
- const schools = summary.schools || [];
- const chosen = state.schools === null ? new Set(schools.map((s) => s.name)) : state.schools;
- bar.innerHTML = `
- <div class="ops-bar">
- <div class="ops-bar-group">
- <span class="ops-bar-label">学校</span>
- <div class="ops-chips" id="opsSchoolChips">
- ${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('')}
- </div>
- </div>
- <div class="ops-bar-group">
- <span class="ops-bar-label">日期</span>
- <input type="date" class="ops-input" id="opsFrom" value="${ymdToInput(state.from)}">
- <span class="ops-tilde">~</span>
- <input type="date" class="ops-input" id="opsTo" value="${ymdToInput(state.to)}">
- </div>
- <div class="ops-bar-group">
- <span class="ops-bar-label">指标</span>
- <div class="ops-chips" id="opsMetricChips">
- ${ALL_METRICS.map((k) => `<button type="button" class="ops-chip${state.metrics.includes(k) ? ' on' : ''}" data-metric="${k}">${META[k].label}</button>`).join('')}
- </div>
- </div>
- </div>`;
- renderHeaderTools();
- bar.querySelectorAll('[data-school]').forEach((btn) => {
- btn.addEventListener('click', () => {
- const name = btn.dataset.school;
- const current = state.schools === null ? new Set(schools.map((s) => s.name)) : new Set(state.schools);
- if (current.has(name)) current.delete(name); else current.add(name);
- state.schools = current.size === schools.length ? null : current;
- if (!current.size) state.schools = new Set();
- renderBar();
- loadRows();
- });
- });
- bar.querySelectorAll('[data-metric]').forEach((btn) => {
- btn.addEventListener('click', () => {
- const key = btn.dataset.metric;
- if (state.metrics.includes(key)) {
- if (state.metrics.length === 1) return; // 至少保留一个指标
- state.metrics = state.metrics.filter((k) => k !== key);
- } else {
- state.metrics = ALL_METRICS.filter((k) => key === k || state.metrics.includes(k));
- }
- localStorage.setItem('ops.metrics', JSON.stringify(state.metrics));
- renderBar();
- renderBody();
- });
- });
- $('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); });
- $('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
- }
- function renderHeaderTools() {
- const el = $('opsHeaderTools');
- if (!el) return;
- const summary = state.summary;
- if (!summary || !summary.enabled || !summary.rowCount) {
- el.innerHTML = '';
- return;
- }
- el.innerHTML = `<div class="ops-seg" id="opsViewSeg">
- <button type="button" data-mode="table" class="${state.view === 'table' ? 'on' : ''}">报表</button>
- <button type="button" data-mode="cards" class="${state.view === 'cards' ? 'on' : ''}">卡片</button>
- </div>
- <button type="button" class="btn btn-ghost btn-small" id="opsExport">导出 CSV</button>`;
- el.querySelectorAll('#opsViewSeg [data-mode]').forEach((btn) => {
- btn.addEventListener('click', () => {
- state.view = btn.dataset.mode;
- localStorage.setItem('ops.view', state.view);
- renderHeaderTools();
- renderBody();
- });
- });
- $('opsExport').addEventListener('click', () => {
- const params = new URLSearchParams();
- if (state.from) params.set('from', state.from);
- if (state.to) params.set('to', state.to);
- if (state.schools !== null && state.schools.size) params.set('schools', [...state.schools].join(','));
- params.set('metrics', state.metrics.join(','));
- window.open('/api/ops/export?' + params.toString(), '_blank');
- });
- }
- // ---------- 内容区 ----------
- function renderBody() {
- const body = $('opsBody');
- if (!body) return;
- const summary = state.summary;
- if (!summary) {
- body.innerHTML = '<div class="empty-hint"><p>正在加载…</p></div>';
- return;
- }
- if (!summary.enabled) {
- const reasonText = {
- 'storage-not-configured': '未配置存储(config.json → storage)',
- 'storage-unreachable': '存储连接失败',
- 'pg-not-installed': '未安装 pg 依赖',
- 'sqlite-unavailable': '当前 Node 版本不支持内置 SQLite(需要 22.5+)',
- }[summary.reason] || '存储不可用';
- body.innerHTML = `
- <div class="empty-hint ops-empty">
- <p>店铺运营数据暂不可用</p>
- <small>${esc(reasonText)}${summary.detail ? ':' + esc(summary.detail) : ''}</small>
- <button type="button" class="btn btn-ghost btn-small" id="opsRetry" style="margin-top:14px">重试</button>
- </div>`;
- $('opsRetry').addEventListener('click', () => init(true));
- return;
- }
- if (!summary.rowCount) {
- body.innerHTML = `
- <div class="empty-hint ops-empty">
- <p>还没有店铺运营数据</p>
- <small>上传商家后台导出的人工统计表(.xlsx),解析后按学校 / 店铺 / 日期查看</small>
- <button type="button" class="btn btn-primary btn-small" id="opsEmptyUpload" style="margin-top:14px">上传文件</button>
- </div>`;
- $('opsEmptyUpload').addEventListener('click', openUpload);
- return;
- }
- if (state.error) {
- body.innerHTML = `<div class="empty-hint ops-empty"><p>数据加载失败</p><small>${esc(state.error)}</small>
- <button type="button" class="btn btn-ghost btn-small" id="opsReload" style="margin-top:14px">重试</button></div>`;
- $('opsReload').addEventListener('click', loadRows);
- return;
- }
- if (state.loading && !state.data) {
- body.innerHTML = '<div class="empty-hint"><p>正在加载…</p></div>';
- return;
- }
- if (state.schools !== null && state.schools.size === 0) {
- body.innerHTML = '<div class="empty-hint ops-empty"><p>请至少选择一个学校</p></div>';
- return;
- }
- if (!state.data) return;
- body.innerHTML = state.view === 'cards' ? renderCards(state.data) : renderTables(state.data);
- }
- function formatCell(shop, metric, date) {
- const series = shop.series[metric] || {};
- const value = Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
- const quality = (shop.quality || {})[date];
- const warn = quality && quality.length
- ? `<span class="ops-warn" title="${esc('解析标记:' + quality.join('、'))}">⚠</span>`
- : '';
- return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, metric)}${warn}</td>`;
- }
- function subtotalRow(group, metric, dates) {
- const cells = dates.map((date) => {
- if (metric === 'click_rate') {
- let weight = 0;
- let acc = 0;
- for (const shop of group.shops) {
- const exposure = (shop.series.exposure || {})[date];
- const rate = (shop.series.click_rate || {})[date];
- if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
- weight += exposure;
- acc += rate * exposure;
- }
- }
- return `<td class="ops-num ops-subtotal">${weight > 0 ? fmtValue(acc / weight, metric) : '–'}</td>`;
- }
- let sum = 0;
- let seen = false;
- for (const shop of group.shops) {
- const v = (shop.series[metric] || {})[date];
- if (typeof v === 'number') { sum += v; seen = true; }
- }
- return `<td class="ops-num ops-subtotal">${seen ? fmtValue(sum, metric) : '–'}</td>`;
- }).join('');
- return `<tr class="ops-subtotal-row"><td class="ops-shop-cell">${esc(group.school)} · 小计</td>${cells}</tr>`;
- }
- function renderTables(data) {
- const dates = data.dates || [];
- const metrics = state.metrics.filter((m) => META[m]);
- if (!metrics.length) return '<div class="empty-hint"><p>请至少选择一个指标</p></div>';
- return metrics.map((metric) => {
- const head = `<tr><th class="ops-shop-col">店铺</th>${dates.map((d) => `<th class="ops-date-col">${shortDate(d)}</th>`).join('')}</tr>`;
- const rows = data.groups.map((group) => {
- 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>`;
- const shopRows = group.shops.map((shop) => `<tr>
- <td class="ops-shop-cell" title="${esc(shop.shopName)}">${esc(shop.shopName)}${
- shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted ops-status">${esc(shop.status)}</span>` : ''
- }</td>
- ${dates.map((d) => formatCell(shop, metric, d)).join('')}
- </tr>`).join('');
- return schoolRow + shopRows + subtotalRow(group, metric, dates);
- }).join('');
- const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
- return `<section class="ops-block">
- <div class="ops-block-head"><h2>${META[metric].label}</h2><span>${shopCount} 家门店 · ${dates.length} 天</span></div>
- <div class="ops-table-wrap">
- <table class="ops-table">
- <thead>${head}</thead>
- <tbody>${rows}</tbody>
- </table>
- </div>
- </section>`;
- }).join('');
- }
- function sparkline(shop, metric, dates) {
- const values = dates.map((d) => {
- const v = (shop.series[metric] || {})[d];
- return typeof v === 'number' ? v : null;
- });
- const nums = values.filter((v) => v != null);
- if (nums.length < 2) return '';
- const min = Math.min(...nums);
- const max = Math.max(...nums);
- const span = max - min || 1;
- const w = 160;
- const h = 34;
- const step = w / Math.max(values.length - 1, 1);
- const points = [];
- values.forEach((v, i) => {
- if (v == null) return;
- const x = (i * step).toFixed(1);
- const y = (h - ((v - min) / span) * (h - 6) - 3).toFixed(1);
- points.push(`${x},${y}`);
- });
- return `<svg class="ops-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true"><polyline points="${points.join(' ')}" /></svg>`;
- }
- function renderCards(data) {
- const dates = data.dates || [];
- const metric = state.metrics[0];
- return `<div class="ops-cards">${data.groups.map((group) => `
- <section class="ops-card-group">
- <h3>${esc(group.school)}</h3>
- <div class="ops-card-grid">
- ${group.shops.map((shop) => `
- <article class="ops-card">
- <div class="ops-card-head">
- <span class="ops-card-name" title="${esc(shop.shopName)}">${esc(shop.shopName)}</span>
- ${shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted">${esc(shop.status)}</span>` : ''}
- </div>
- <div class="ops-card-metrics">
- ${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('')}
- </div>
- ${sparkline(shop, metric, dates)}
- </article>`).join('')}
- </div>
- </section>`).join('')}</div>`;
- }
- function avgRate(shop, dates) {
- let weight = 0;
- let acc = 0;
- for (const date of dates) {
- const exposure = (shop.series.exposure || {})[date];
- const rate = (shop.series.click_rate || {})[date];
- if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
- weight += exposure;
- acc += rate * exposure;
- }
- }
- return weight > 0 ? acc / weight : null;
- }
- // ---------- 上传 ----------
- async function refreshSchoolOptions() {
- const select = $('opsSchool');
- if (!select) return;
- // 已登记学校(校区地图里的学校注册表,含内置)
- const registered = [];
- try {
- const { res, body } = await api('/api/schools');
- if (res.ok) {
- for (const school of (body && body.schools) || []) {
- if (!school || !school.name) continue;
- registered.push({
- name: school.name,
- hint: [school.province, school.city].filter(Boolean).join(' · '),
- });
- }
- }
- } catch { /* 注册表取不到时回落到运营数据里的学校 */ }
- // 运营数据中出现过、但还没登记的学校
- const registeredNames = new Set(registered.map((x) => x.name));
- const fromOps = ((state.summary && state.summary.schools) || [])
- .map((x) => x.name)
- .filter((name) => name && !registeredNames.has(name));
- select.innerHTML = '<option value="">自动(读取表格里的「学校名称」列)</option>'
- + (registered.length
- ? `<optgroup label="已登记学校">${registered.map((x) => `<option value="${esc(x.name)}">${esc(x.name)}${x.hint ? '(' + esc(x.hint) + ')' : ''}</option>`).join('')}</optgroup>`
- : '')
- + (fromOps.length
- ? `<optgroup label="运营数据中的学校">${fromOps.map((name) => `<option value="${esc(name)}">${esc(name)}</option>`).join('')}</optgroup>`
- : '')
- + '<option value="__custom">手动输入…</option>';
- select.value = '';
- const custom = $('opsSchoolCustom');
- if (custom) {
- custom.value = '';
- custom.hidden = true;
- }
- }
- function selectedSchool() {
- const select = $('opsSchool');
- if (!select) return '';
- if (select.value === '__custom') return String(($('opsSchoolCustom') || {}).value || '').trim();
- return select.value;
- }
- function fileKey(f) {
- return f.name + '|' + f.size + '|' + (f.lastModified || 0);
- }
- function addFiles(fileList) {
- for (const f of Array.from(fileList || [])) {
- if (!/\.xlsx$/i.test(f.name)) continue;
- const key = fileKey(f);
- if (state.upload.files.some((x) => fileKey(x.file) === key)) continue;
- state.upload.files.push({ file: f, status: 'pending', message: '' });
- }
- renderFileList();
- }
- function renderFileList() {
- const box = $('opsFileList');
- if (!box) return;
- if (!state.upload.files.length) {
- box.innerHTML = '';
- return;
- }
- box.innerHTML = state.upload.files.map((item, i) => {
- const cls = item.status === 'error' ? 'ops-error'
- : item.status === 'done' ? 'ops-done-text'
- : item.status === 'duplicate' ? 'ops-muted' : '';
- const msg = item.message ? `<em class="${cls}">${esc(item.message)}</em>` : '';
- const removable = item.status !== 'uploading';
- return `<div class="ops-file-item">
- <span class="ops-file-name2" title="${esc(item.file.name)}">${esc(item.file.name)}</span>
- <span class="ops-file-size">${(item.file.size / 1024).toFixed(0)} KB</span>
- ${msg}
- ${removable ? `<button type="button" class="ops-file-del" data-idx="${i}" title="移除">×</button>` : ''}
- </div>`;
- }).join('');
- box.querySelectorAll('[data-idx]').forEach((btn) => {
- btn.addEventListener('click', () => {
- const i = Number(btn.dataset.idx);
- if (state.upload.files[i] && state.upload.files[i].status === 'uploading') return;
- state.upload.files.splice(i, 1);
- renderFileList();
- });
- });
- }
- async function openUpload() {
- const modal = $('opsUploadModal');
- if (!modal) return;
- state.upload.files = [];
- renderFileList();
- $('opsUploadResult').innerHTML = '';
- $('opsYear').value = String(new Date().getFullYear());
- await refreshSchoolOptions();
- if ($('opsForce')) $('opsForce').checked = false;
- $('opsUploadStart').disabled = false;
- modal.hidden = false;
- }
- function closeUpload() {
- const modal = $('opsUploadModal');
- if (modal) modal.hidden = true;
- }
- function readFileBase64(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => {
- const text = String(reader.result || '');
- resolve(text.slice(text.indexOf(',') + 1));
- };
- reader.onerror = () => reject(reader.error || new Error('读取文件失败'));
- reader.readAsDataURL(file);
- });
- }
- async function doUpload(forceOverride) {
- const items = state.upload.files;
- const result = $('opsUploadResult');
- if (!items.length) {
- result.innerHTML = '<span class="ops-error">请先选择 .xlsx 文件(可一次选多个)</span>';
- return;
- }
- if (items.length > 30) {
- result.innerHTML = '<span class="ops-error">一次最多 30 个文件</span>';
- return;
- }
- const year = Number($('opsYear').value) || new Date().getFullYear();
- const school = selectedSchool();
- const force = !!(forceOverride || ($('opsForce') && $('opsForce').checked));
- $('opsUploadStart').disabled = true;
- result.innerHTML = '<span class="ops-muted">正在逐个解析并入库…</span>';
- let okCount = 0;
- let skipCount = 0;
- let failCount = 0;
- for (const item of items) {
- if (item.status === 'done') continue;
- item.status = 'uploading';
- item.message = '导入中…';
- renderFileList();
- try {
- const base64 = await readFileBase64(item.file);
- const { res, body } = await api('/api/ops/upload', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ fileName: item.file.name, contentBase64: base64, year, school, force }),
- });
- if (res.status === 409) {
- item.status = 'duplicate';
- item.message = '已导入过(跳过)';
- skipCount += 1;
- } else if (!res.ok) {
- const detail = body && (body.detail ? body.error + ':' + body.detail : body.error);
- item.status = 'error';
- item.message = detail || ('失败 HTTP ' + res.status);
- failCount += 1;
- } else {
- const r = body.report || {};
- item.status = 'done';
- if (r.kind === 'platform-export') {
- const parts = ['新增 ' + r.newRows + ' 行'];
- if (r.identicalRows) parts.push('已存在 ' + r.identicalRows + ' 行');
- if (r.conflictRows) parts.push('冲突 ' + r.conflictRows + ' 行');
- item.message = parts.join(' · ');
- } else {
- item.message = r.rows + ' 行 · ' + r.shops + ' 家';
- }
- okCount += 1;
- }
- } catch (e) {
- item.status = 'error';
- item.message = e.message || String(e);
- failCount += 1;
- }
- renderFileList();
- }
- const parts = ['成功 ' + okCount + ' 个'];
- if (skipCount) parts.push('跳过 ' + skipCount + ' 个(已导入过)');
- if (failCount) parts.push('失败 ' + failCount + ' 个');
- result.innerHTML = `<div class="${failCount ? 'ops-error' : 'ops-ok'}">${parts.join(',')}${
- skipCount && !force ? '<br><small>已存在的文件可勾选下方「覆盖已导入的文件」后重新导入</small>' : ''
- }</div>`;
- $('opsUploadStart').disabled = false;
- if (okCount) {
- state.from = '';
- state.to = '';
- state.schools = null;
- await init(true);
- }
- }
- // ---------- 导入记录 ----------
- async function openRecords() {
- const modal = $('opsRecordsModal');
- if (!modal) return;
- modal.hidden = false;
- const list = $('opsRecordsList');
- list.innerHTML = '<div class="ops-muted" style="padding:16px">加载中…</div>';
- const { res, body } = await api('/api/ops/uploads');
- if (!res.ok) {
- list.innerHTML = `<div class="ops-error" style="padding:16px">${esc((body && (body.detail || body.error)) || ('HTTP ' + res.status))}</div>`;
- return;
- }
- const uploads = (body && body.uploads) || [];
- if (!uploads.length) {
- list.innerHTML = '<div class="ops-muted" style="padding:16px">还没有导入记录</div>';
- return;
- }
- list.innerHTML = `<table class="data-table">
- <thead><tr><th>文件名</th><th>上传时间</th><th>行数</th><th>门店</th><th>警告</th><th class="col-actions">操作</th></tr></thead>
- <tbody>${uploads.map((u) => `<tr>
- <td>${esc(u.originalName)}</td>
- <td>${esc(new Date(u.uploadedAt).toLocaleString('zh-CN'))}</td>
- <td>${u.rows || 0}</td>
- <td>${u.shops || 0}</td>
- <td>${u.warningCount || 0}</td>
- <td class="col-actions">
- <a class="btn btn-ghost btn-small" href="/api/ops/uploads/${esc(u.id)}/file">下载原件</a>
- <button type="button" class="btn btn-ghost btn-small" data-reparse="${esc(u.id)}">重新解析</button>
- </td>
- </tr>`).join('')}</tbody>
- </table>`;
- list.querySelectorAll('[data-reparse]').forEach((btn) => {
- btn.addEventListener('click', async () => {
- btn.disabled = true;
- btn.textContent = '解析中…';
- const { res, body: rep } = await api(`/api/ops/uploads/${btn.dataset.reparse}/reparse`, { method: 'POST' });
- btn.disabled = false;
- btn.textContent = '重新解析';
- if (!res.ok) {
- alert((rep && (rep.detail || rep.error)) || '重新解析失败');
- return;
- }
- await init(true);
- });
- });
- }
- // ---------- 初始化 ----------
- async function init(force) {
- if (state.inited && !force) return;
- state.inited = true;
- await loadSummary();
- renderBar();
- renderHeaderTools();
- renderBody();
- if (state.summary && state.summary.enabled && state.summary.rowCount) await loadRows();
- }
- function bindOnce() {
- const uploadBtn = $('btnOpsUpload');
- if (uploadBtn) uploadBtn.addEventListener('click', openUpload);
- const recordsBtn = $('btnOpsRecords');
- if (recordsBtn) recordsBtn.addEventListener('click', openRecords);
- const start = $('opsUploadStart');
- if (start) start.addEventListener('click', () => doUpload(false));
- const cancel = $('opsUploadCancel');
- if (cancel) cancel.addEventListener('click', closeUpload);
- const closeBtn = $('opsUploadClose');
- if (closeBtn) closeBtn.addEventListener('click', closeUpload);
- const schoolSelect = $('opsSchool');
- if (schoolSelect) {
- schoolSelect.addEventListener('change', () => {
- const custom = $('opsSchoolCustom');
- if (!custom) return;
- custom.hidden = schoolSelect.value !== '__custom';
- if (!custom.hidden) custom.focus();
- });
- }
- const file = $('opsFile');
- if (file) {
- file.addEventListener('change', () => {
- addFiles(file.files);
- file.value = '';
- });
- }
- const drop = $('opsDrop');
- if (drop) {
- ['dragover', 'dragenter'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('on'); }));
- ['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('on'); }));
- drop.addEventListener('drop', (e) => {
- if (!e.dataTransfer || !e.dataTransfer.files) return;
- addFiles(e.dataTransfer.files);
- });
- drop.addEventListener('click', () => { const input = $('opsFile'); if (input) input.click(); });
- }
- const recordsClose = $('opsRecordsClose');
- if (recordsClose) recordsClose.addEventListener('click', () => { $('opsRecordsModal').hidden = true; });
- }
- window.shanOpsInit = function () {
- bindOnce();
- init(false);
- };
- })();
|