|
@@ -0,0 +1,574 @@
|
|
|
|
|
+'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 = `
|
|
|
|
|
+ <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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 上传 ----------
|
|
|
|
|
+
|
|
|
|
|
+ function openUpload() {
|
|
|
|
|
+ const modal = $('opsUploadModal');
|
|
|
|
|
+ if (!modal) return;
|
|
|
|
|
+ state.upload.file = null;
|
|
|
|
|
+ $('opsFileName').textContent = '';
|
|
|
|
|
+ $('opsUploadResult').innerHTML = '';
|
|
|
|
|
+ $('opsYear').value = String(new Date().getFullYear());
|
|
|
|
|
+ $('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 = '<span class="ops-error">请先选择 .xlsx 文件</span>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!/\.xlsx$/i.test(file.name)) {
|
|
|
|
|
+ $('opsUploadResult').innerHTML = '<span class="ops-error">只支持 .xlsx 文件</span>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const result = $('opsUploadResult');
|
|
|
|
|
+ const year = Number($('opsYear').value) || new Date().getFullYear();
|
|
|
|
|
+ $('opsUploadStart').disabled = true;
|
|
|
|
|
+ result.innerHTML = '<span class="ops-muted">正在解析并入库…</span>';
|
|
|
|
|
+ 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, force: !!force }),
|
|
|
|
|
+ });
|
|
|
|
|
+ if (res.status === 409) {
|
|
|
|
|
+ result.innerHTML = `<span class="ops-error">该文件已导入过(uploadId ${esc(body && body.uploadId)})</span>
|
|
|
|
|
+ <button type="button" class="btn btn-ghost btn-small" id="opsForceUpload" style="margin-left:10px">仍然导入</button>`;
|
|
|
|
|
+ $('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 = `<span class="ops-error">${esc(detail || ('导入失败 HTTP ' + res.status))}</span>`;
|
|
|
|
|
+ $('opsUploadStart').disabled = false;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const r = body.report || {};
|
|
|
|
|
+ const warn = (body.warnings || []).slice(0, 5).map((w) => `<li>第 ${w.row} 行 ${esc(w.column)}:${esc(w.raw || '空')}(${esc(w.reason)})</li>`).join('');
|
|
|
|
|
+ result.innerHTML = `<div class="ops-ok">
|
|
|
|
|
+ <b>导入完成</b>:${r.rows} 行 · ${r.shops} 家门店 · ${r.dates ? shortDate(r.dates.from) + ' ~ ' + shortDate(r.dates.to) : ''} · 覆盖 ${r.overwritten} 行 · 警告 ${r.warningCount} 条
|
|
|
|
|
+ ${warn ? `<ul class="ops-warn-list">${warn}</ul>` : ''}
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ state.from = '';
|
|
|
|
|
+ state.to = '';
|
|
|
|
|
+ state.schools = null;
|
|
|
|
|
+ await init(true);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ result.innerHTML = `<span class="ops-error">${esc(e.message || e)}</span>`;
|
|
|
|
|
+ $('opsUploadStart').disabled = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 导入记录 ----------
|
|
|
|
|
+
|
|
|
|
|
+ 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 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);
|
|
|
|
|
+ };
|
|
|
|
|
+})();
|