'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', 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; } 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) { 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(',')); 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 mountMulti(root, { label, items, selected, allowEmpty, emptyLabel, onChange }) { const sel = new Set(selected || []); let keyword = ''; root.innerHTML = ` `; const btn = root.querySelector('.ops-multi-btn'); const menu = root.querySelector('.ops-multi-menu'); const list = root.querySelector('.ops-multi-list'); const search = root.querySelector('.ops-multi-search'); function refreshButton() { let text; if (!sel.size) text = `${label} ${emptyLabel || '全部'}`; else if (sel.size === 1) { const one = items.find((i) => sel.has(i.key)); text = `${label} ${esc(one ? one.label : '1')}`; } else text = `${label} ${sel.size} 项`; btn.innerHTML = text + '▾'; } function renderList() { const kw = keyword.trim().toLowerCase(); const filtered = items.filter((i) => !kw || String(i.label).toLowerCase().includes(kw) || String(i.hint || '').toLowerCase().includes(kw)); list.innerHTML = filtered.length ? filtered.map((i) => ``).join('') : '
没有匹配项
'; list.querySelectorAll('input[type="checkbox"]').forEach((input) => { input.addEventListener('change', () => { if (input.checked) sel.add(input.value); else sel.delete(input.value); if (!allowEmpty && !sel.size) { input.checked = true; sel.add(input.value); return; } refreshButton(); onChange([...sel]); }); }); } btn.addEventListener('click', (e) => { e.stopPropagation(); const willOpen = menu.hidden; document.querySelectorAll('.ops-multi-menu').forEach((m) => { m.hidden = true; }); menu.hidden = !willOpen; btn.setAttribute('aria-expanded', String(willOpen)); if (willOpen) { renderList(); search.focus(); } }); search.addEventListener('input', () => { keyword = search.value; renderList(); }); root.querySelector('[data-all]').addEventListener('click', () => { items.forEach((i) => sel.add(i.key)); refreshButton(); renderList(); onChange([...sel]); }); root.querySelector('[data-none]').addEventListener('click', () => { sel.clear(); if (!allowEmpty && items[0]) sel.add(items[0].key); refreshButton(); renderList(); onChange([...sel]); }); refreshButton(); return { refresh: () => { refreshButton(); renderList(); } }; } let menuDismissBound = false; /** 点击空白处 / Esc 关闭下拉(按钮本身已 stopPropagation,不会误关) */ function bindMenuDismiss() { if (menuDismissBound) return; menuDismissBound = true; document.addEventListener('click', (event) => { document.querySelectorAll('.ops-multi').forEach((root) => { const menu = root.querySelector('.ops-multi-menu'); if (!menu || menu.hidden) return; if (root.contains(event.target)) return; menu.hidden = true; const btn = root.querySelector('.ops-multi-btn'); if (btn) btn.setAttribute('aria-expanded', 'false'); }); }); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; document.querySelectorAll('.ops-multi-menu').forEach((menu) => { menu.hidden = true; }); }); } 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 = `
学校
${schools.map((s) => ``).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(); }); }); $('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); }); $('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); }); mountMulti($('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); mountMulti($('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(); } 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 ? `⚠` : ''; 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 renderTables(data) { const fields = activeFields(data); if (!fields.length) return '

请至少选择一个字段

'; const dates = data.dates || []; const colCount = fields.length + 2; const head = ` 店铺 日期 ${fields.map((f) => `${esc(f.label)}`).join('')} `; const body = data.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 = data.groups.reduce((n, g) => n + g.shops.length, 0); return `

运营明细

${shopCount} 家门店 · ${dates.length} 天 · ${fields.length} 个字段
${head}${body}
`; } /** 悬停时整块高亮(同一店铺的多天一起亮),避免分不清行属于哪家店 */ 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 ? `${registered.map((x) => ``).join('')}` : '') + (fromOps.length ? `${fromOps.map((name) => ``).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 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; } 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; 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 = '
加载中…
'; 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 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(); } function bindOnce() { bindMenuDismiss(); 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); }; })();