'use strict';
// 店铺运营页:上传人工统计表 → 按学校/店铺/日期看指标矩阵或卡片。
// 数据来自 /api/ops/*;原件存服务端存储(默认本地 SQLite),页面只做展示与上传。
(function () {
const $ = (id) => document.getElementById(id);
const DEFAULT_FIELDS = ['order_cnt', 'revenue', 'exposure', 'click_rate'];
function loadJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const value = JSON.parse(raw);
return Array.isArray(value) && value.length ? value : fallback;
} catch {
return fallback;
}
}
const state = {
inited: false,
loading: false,
summary: null,
data: null,
catalog: [],
schools: null, // null = 全选
shops: null, // null = 全部店铺
fields: loadJSON('ops.fields', DEFAULT_FIELDS.slice()),
view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
sortKey: null,
sortDir: 'desc',
from: '',
to: '',
upload: { files: [], busy: false },
};
function fieldByKey(key) {
return state.catalog.find((f) => f.key === key) || { key, label: key, type: 'int' };
}
function esc(value) {
const div = document.createElement('div');
div.textContent = String(value == null ? '' : value);
return div.innerHTML;
}
/** 日期加减(YYYYMMDD),用于默认「最近 N 天」 */
function shiftYmd(ymd, days) {
const d = new Date(Date.UTC(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)) + days));
return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
}
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, type) {
if (value === null || value === undefined || value === '') return '–';
if (type === 'text') return String(value);
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
if (type === 'rate') return (n * 100).toFixed(1) + '%';
if (type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (type === 'int') return n.toLocaleString('zh-CN');
return String(value);
}
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) {
// 默认只看最近 2 天;数据不足 2 天时从最早一天开始
if (!state.to) state.to = state.summary.coverage.to;
if (!state.from) {
const recent = shiftYmd(state.to, -1);
state.from = recent < state.summary.coverage.from ? state.summary.coverage.from : recent;
}
}
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(','));
if (state.shops !== null && state.shops.size) params.set('shops', [...state.shops].join(','));
if (state.fields.length) params.set('fields', state.fields.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 || !matrixCount(summary)) {
bar.innerHTML = '';
return;
}
const schools = summary.schools || [];
const chosen = state.schools === null ? new Set(schools.map((s) => s.name)) : state.schools;
bar.innerHTML = `
`;
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();
});
});
$('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); });
$('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
window.createMultiSelect($('opsFieldPicker'), {
label: '字段',
items: state.catalog.map((f) => ({ key: f.key, label: f.label, hint: f.type === 'text' ? '文本' : '' })),
selected: state.fields,
allowEmpty: false,
onChange: (keys) => {
state.fields = keys;
localStorage.setItem('ops.fields', JSON.stringify(keys));
loadRows();
},
});
const shopItems = (summary.shops || []).map((shop) => ({
key: String(shop.key || shop.shopId || ''),
label: shop.shopName || shop.key,
hint: shop.school || '',
})).filter((i) => i.key);
window.createMultiSelect($('opsShopPicker'), {
label: '店铺',
items: shopItems,
selected: state.shops ? [...state.shops] : [],
allowEmpty: true,
emptyLabel: '全部',
onChange: (keys) => {
state.shops = keys.length ? new Set(keys) : null;
loadRows();
},
});
}
function matrixCount(summary) {
if (!summary) return 0;
return Number(summary.matrixRowCount != null ? summary.matrixRowCount : summary.rowCount) || 0;
}
function renderHeaderTools() {
const el = $('opsHeaderTools');
if (!el) return;
const summary = state.summary;
if (!summary || !summary.enabled || !matrixCount(summary)) {
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(','));
if (state.fields.length) params.set('fields', state.fields.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;
}
const matrixRows = matrixCount(summary);
if (!matrixRows) {
const platformRows = Number(summary.platformRowCount) || 0;
body.innerHTML = platformRows > 0
? `
已导入 ${platformRows} 行平台明细
平台全字段数据已入库(可在「导入记录」查看原件与行数);
上报人工统计表后,这里会显示指标矩阵
`
: `
还没有店铺运营数据
上传商家后台导出的人工统计表(.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);
if (state.view === 'table') { bindRowHover(); bindSort(); }
}
function cellValue(shop, field, date) {
const series = shop.series[field.key] || {};
return Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
}
function metricCell(shop, field, date) {
const value = cellValue(shop, field, date);
const quality = (shop.quality || {})[date];
const warn = quality && quality.length
? `⚠`
: '';
if (field.type === 'text') {
return `${fmtValue(value, field.type)}${warn} | `;
}
return `${fmtValue(value, field.type)}${warn} | `;
}
/** 学校小计:数量/金额求和,比率按曝光加权,文本取第一个非空 */
function subtotalValue(group, field, date) {
if (field.type === 'text') {
for (const shop of group.shops) {
const v = cellValue(shop, field, date);
if (v !== null) return v;
}
return null;
}
if (field.type === 'rate') {
let weight = 0;
let acc = 0;
for (const shop of group.shops) {
const exposure = cellValue(shop, { key: 'exposure' }, date);
const rate = cellValue(shop, field, date);
if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
weight += exposure;
acc += rate * exposure;
}
}
return weight > 0 ? acc / weight : null;
}
let sum = 0;
let seen = false;
for (const shop of group.shops) {
const v = cellValue(shop, field, date);
if (typeof v === 'number') { sum += v; seen = true; }
}
return seen ? sum : null;
}
function activeFields(data) {
if (data && Array.isArray(data.fields) && data.fields.length) return data.fields;
return state.fields.map((key) => fieldByKey(key));
}
/** 门店在某字段上的区间汇总:金额/数量求和,比率按曝光加权 */
function fieldAggregate(shop, field, dates) {
if (field.type === 'rate') {
let num = 0;
let den = 0;
for (const d of dates) {
const v = cellValue(shop, field, d);
if (v == null) continue;
const weight = Number((shop.series && shop.series.exposure && shop.series.exposure[d]) || 1) || 1;
num += v * weight;
den += weight;
}
return den ? num / den : null;
}
let sum = 0;
let has = false;
for (const d of dates) {
const v = cellValue(shop, field, d);
if (typeof v === 'number') { sum += v; has = true; }
}
return has ? sum : null;
}
/** 按当前排序列排序:门店在各自学校内排序,学校按小计排序 */
function sortGroups(groups, fields, dates) {
if (!state.sortKey) return groups;
const field = fields.find((f) => f.key === state.sortKey);
if (!field || field.type === 'text') return groups;
const dir = state.sortDir === 'asc' ? 1 : -1;
const cmp = (a, b) => {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return dir * (a - b);
};
const groupTotal = (group) => group.shops.reduce((acc, shop) => {
const v = fieldAggregate(shop, field, dates);
return v == null ? acc : acc + v;
}, 0);
return groups
.map((group) => ({
...group,
shops: group.shops.slice().sort((a, b) => cmp(fieldAggregate(a, field, dates), fieldAggregate(b, field, dates))),
}))
.sort((a, b) => cmp(groupTotal(a), groupTotal(b)));
}
function renderTables(data) {
const fields = activeFields(data);
if (!fields.length) return '';
const dates = data.dates || [];
const colCount = fields.length + 2;
const head = `
| 店铺 |
日期 |
${fields.map((f) => {
const sortable = f.type !== 'text';
const active = state.sortKey === f.key;
const arrow = active ? (state.sortDir === 'asc' ? '↑' : '↓') : '';
return `${esc(f.label)}${arrow ? `${arrow}` : ''} | `;
}).join('')}
`;
const groups = sortGroups(data.groups, fields, dates);
const body = groups.map((group, groupIdx) => {
const schoolRow = `| ${esc(group.school)}${group.shops.length} 家 |
`;
const shopRows = group.shops.map((shop, shopIdx) => {
const shopDates = dates.filter((d) => fields.some((f) => cellValue(shop, f, d) !== null));
const useDates = shopDates.length ? shopDates : dates.slice(0, 1);
const blockKey = `b${groupIdx}-${shopIdx}`;
const alt = shopIdx % 2 === 1 ? ' ops-block-alt' : '';
return useDates.map((date, i) => `
${i === 0 ? `| ${esc(shop.shopName)}${
shop.status && shop.status !== '已上线' ? `${esc(shop.status)}` : ''
} | ` : ''}
${shortDate(date)} |
${fields.map((f) => metricCell(shop, f, date)).join('')}
`).join('');
}).join('');
const subtotalDates = dates.filter((d) => group.shops.some((shop) => fields.some((f) => cellValue(shop, f, d) !== null)));
const subtotalRows = subtotalDates.map((date) => `
| ${esc(group.school)} · 小计 · ${shortDate(date)} |
${fields.map((f) => {
const v = subtotalValue(group, f, date);
return `${v == null ? '–' : fmtValue(v, f.type)} | `;
}).join('')}
`).join('');
return schoolRow + shopRows + subtotalRows;
}).join('');
const shopCount = groups.reduce((n, g) => n + g.shops.length, 0);
return `
运营明细
${shopCount} 家门店 · ${dates.length} 天 · ${fields.length} 个字段
`;
}
/** 悬停时整块高亮(同一店铺的多天一起亮),避免分不清行属于哪家店 */
function bindSort() {
document.querySelectorAll('.ops-table-wide th[data-sort]').forEach((th) => {
if (th.dataset.sortBound === '1') return;
th.dataset.sortBound = '1';
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (state.sortKey === key) state.sortDir = state.sortDir === 'desc' ? 'asc' : 'desc';
else { state.sortKey = key; state.sortDir = 'desc'; }
renderBody();
});
});
}
function bindRowHover() {
document.querySelectorAll('.ops-table-wide tbody').forEach((tbody) => {
if (tbody.dataset.hoverBound === '1') return;
tbody.dataset.hoverBound = '1';
const clear = () => tbody.querySelectorAll('tr.ops-hover').forEach((tr) => tr.classList.remove('ops-hover'));
tbody.addEventListener('mouseover', (e) => {
const tr = e.target.closest('tr[data-block]');
if (!tr) return;
if (tr.classList.contains('ops-hover')) return;
clear();
tbody.querySelectorAll(`tr[data-block="${tr.dataset.block}"]`).forEach((row) => row.classList.add('ops-hover'));
});
tbody.addEventListener('mouseleave', clear);
});
}
function sparkline(shop, field, dates) {
const values = dates.map((d) => {
const v = (shop.series[field.key] || {})[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 fields = activeFields(data);
const first = fields[0];
if (!first) return '';
return `${data.groups.map((group) => `
${esc(group.school)}
${group.shops.map((shop) => `
${esc(shop.shopName)}
${shop.status && shop.status !== '已上线' ? `${esc(shop.status)}` : ''}
${fields.map((f) => `
${esc(f.label)}${fmtValue(shop.totals ? shop.totals[f.key] : null, f.type)}
`).join('')}
${sparkline(shop, first, dates)}
`).join('')}
`).join('')}
`;
}
// ---------- 上传 ----------
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 = ''
+ (registered.length
? ``
: '')
+ (fromOps.length
? ``
: '')
+ '';
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 ? `${esc(item.message)}` : '';
const removable = item.status !== 'uploading';
return `
${esc(item.file.name)}
${(item.file.size / 1024).toFixed(0)} KB
${msg}
${removable ? `` : ''}
`;
}).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 = '请先选择 .xlsx 文件(可一次选多个)';
return;
}
if (items.length > 30) {
result.innerHTML = '一次最多 30 个文件';
return;
}
if (state.upload.busy) return;
state.upload.busy = true;
const year = Number($('opsYear').value) || new Date().getFullYear();
const school = selectedSchool();
const force = !!(forceOverride || ($('opsForce') && $('opsForce').checked));
$('opsUploadStart').disabled = true;
result.innerHTML = '正在逐个解析并入库…';
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 = `${parts.join(',')}${
skipCount && !force ? '
已存在的文件可勾选下方「覆盖已导入的文件」后重新导入' : ''
}
`;
$('opsUploadStart').disabled = false;
state.upload.busy = false;
if (okCount) {
state.from = '';
state.to = '';
state.schools = null;
await init(true);
// 通知其它页面(校区地图 / 经营驾驶舱)重新拉取数据
window.dispatchEvent(new CustomEvent('ops:data-changed'));
}
}
// ---------- 导入记录 ----------
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) => `
| ${esc(u.originalName)} |
${esc(new Date(u.uploadedAt).toLocaleString('zh-CN'))} |
${u.rows || 0} |
${u.shops || 0} |
${u.warningCount || 0} |
下载原件
|
`).join('')}
`;
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 loadCatalog() {
const { res, body } = await api('/api/ops/fields');
if (!res.ok || !body || !Array.isArray(body.fields)) return;
state.catalog = body.fields;
const valid = new Set(body.fields.map((f) => f.key));
state.fields = state.fields.filter((k) => valid.has(k));
if (!state.fields.length) state.fields = (body.defaults || []).filter((k) => valid.has(k));
if (!state.fields.length) state.fields = body.fields.slice(0, 4).map((f) => f.key);
}
async function init(force) {
if (state.inited && !force) return;
state.inited = true;
await loadSummary();
await loadCatalog();
renderBar();
renderHeaderTools();
renderBody();
if (state.summary && state.summary.enabled && matrixCount(state.summary)) await loadRows();
}
let bound = false;
function bindOnce() {
if (bound) return;
bound = true;
window.bindMultiSelectDismiss();
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);
};
})();