'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: { file: null, 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 = `
学校
${schools.map((s) => ``).join('')}
日期 ~
指标
${ALL_METRICS.map((k) => ``).join('')}
`; 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 = `
`; 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 = '

正在加载…

'; 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 = `

店铺运营数据暂不可用

${esc(reasonText)}${summary.detail ? ':' + esc(summary.detail) : ''}
`; $('opsRetry').addEventListener('click', () => init(true)); return; } if (!summary.rowCount) { body.innerHTML = `

还没有店铺运营数据

上传商家后台导出的人工统计表(.xlsx),解析后按学校 / 店铺 / 日期查看
`; $('opsEmptyUpload').addEventListener('click', openUpload); return; } if (state.error) { body.innerHTML = `

数据加载失败

${esc(state.error)}
`; $('opsReload').addEventListener('click', loadRows); return; } if (state.loading && !state.data) { body.innerHTML = '

正在加载…

'; return; } if (state.schools !== null && state.schools.size === 0) { body.innerHTML = '

请至少选择一个学校

'; 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 ? `⚠` : ''; return `${fmtValue(value, metric)}${warn}`; } 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 `${weight > 0 ? fmtValue(acc / weight, metric) : '–'}`; } 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 `${seen ? fmtValue(sum, metric) : '–'}`; }).join(''); return `${esc(group.school)} · 小计${cells}`; } function renderTables(data) { const dates = data.dates || []; const metrics = state.metrics.filter((m) => META[m]); if (!metrics.length) return '

请至少选择一个指标

'; return metrics.map((metric) => { const head = `店铺${dates.map((d) => `${shortDate(d)}`).join('')}`; const rows = data.groups.map((group) => { const schoolRow = `${esc(group.school)}${group.shops.length} 家`; const shopRows = group.shops.map((shop) => ` ${esc(shop.shopName)}${ shop.status && shop.status !== '已上线' ? `${esc(shop.status)}` : '' } ${dates.map((d) => formatCell(shop, metric, d)).join('')} `).join(''); return schoolRow + shopRows + subtotalRow(group, metric, dates); }).join(''); const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0); return `

${META[metric].label}

${shopCount} 家门店 · ${dates.length} 天
${head}${rows}
`; }).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 ``; } function renderCards(data) { const dates = data.dates || []; const metric = state.metrics[0]; return `
${data.groups.map((group) => `

${esc(group.school)}

${group.shops.map((shop) => `
${esc(shop.shopName)} ${shop.status && shop.status !== '已上线' ? `${esc(shop.status)}` : ''}
${state.metrics.map((m) => `
${META[m].label}${m === 'click_rate' ? fmtValue(avgRate(shop, dates), m) : fmtValue(shop.totals && shop.totals[m], m)}
`).join('')}
${sparkline(shop, metric, dates)}
`).join('')}
`).join('')}
`; } 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; } // ---------- 上传 ---------- function fillSchoolOptions() { const select = $('opsSchool'); if (!select) return; const known = (state.summary && state.summary.schools) || []; select.innerHTML = '' + known.map((s) => ``).join('') + ''; 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 openUpload() { const modal = $('opsUploadModal'); if (!modal) return; state.upload.file = null; $('opsFileName').textContent = ''; $('opsUploadResult').innerHTML = ''; $('opsYear').value = String(new Date().getFullYear()); fillSchoolOptions(); $('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(force) { const file = state.upload.file; if (!file) { $('opsUploadResult').innerHTML = '请先选择 .xlsx 文件'; return; } if (!/\.xlsx$/i.test(file.name)) { $('opsUploadResult').innerHTML = '只支持 .xlsx 文件'; return; } const result = $('opsUploadResult'); const year = Number($('opsYear').value) || new Date().getFullYear(); $('opsUploadStart').disabled = true; result.innerHTML = '正在解析并入库…'; try { const base64 = await readFileBase64(file); const { res, body } = await api('/api/ops/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, contentBase64: base64, year, school, force: !!force }), }); if (res.status === 409) { result.innerHTML = `该文件已导入过(uploadId ${esc(body && body.uploadId)}) `; $('opsForceUpload').addEventListener('click', () => doUpload(true)); $('opsUploadStart').disabled = false; return; } if (!res.ok) { const detail = body && (body.detail ? `${body.error}:${body.detail}` : body.error); result.innerHTML = `${esc(detail || ('导入失败 HTTP ' + res.status))}`; $('opsUploadStart').disabled = false; return; } const r = body.report || {}; const warn = (body.warnings || []).slice(0, 5).map((w) => `
  • 第 ${w.row} 行 ${esc(w.column)}:${esc(w.raw || '空')}(${esc(w.reason)})
  • `).join(''); result.innerHTML = `
    导入完成:${r.rows} 行 · ${r.shops} 家门店 · ${r.dates ? shortDate(r.dates.from) + ' ~ ' + shortDate(r.dates.to) : ''} · 覆盖 ${r.overwritten} 行 · 警告 ${r.warningCount} 条 ${warn ? `` : ''}
    `; state.from = ''; state.to = ''; state.schools = null; await init(true); } catch (e) { result.innerHTML = `${esc(e.message || e)}`; $('opsUploadStart').disabled = false; } } // ---------- 导入记录 ---------- async function openRecords() { const modal = $('opsRecordsModal'); if (!modal) return; modal.hidden = false; const list = $('opsRecordsList'); list.innerHTML = '
    加载中…
    '; const { res, body } = await api('/api/ops/uploads'); if (!res.ok) { list.innerHTML = `
    ${esc((body && (body.detail || body.error)) || ('HTTP ' + res.status))}
    `; return; } const uploads = (body && body.uploads) || []; if (!uploads.length) { list.innerHTML = '
    还没有导入记录
    '; return; } list.innerHTML = `${uploads.map((u) => ``).join('')}
    文件名上传时间行数门店警告操作
    ${esc(u.originalName)} ${esc(new Date(u.uploadedAt).toLocaleString('zh-CN'))} ${u.rows || 0} ${u.shops || 0} ${u.warningCount || 0} 下载原件
    `; 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', () => { state.upload.file = file.files && file.files[0] ? file.files[0] : null; $('opsFileName').textContent = state.upload.file ? state.upload.file.name : ''; }); } 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) => { const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (!f) return; state.upload.file = f; $('opsFileName').textContent = f.name; }); 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); }; })();