/* 管理后台渐进增强脚本:无框架、无依赖、无构建。 * * 定位:**JS 只是「更快的那条路」**。所有操作在服务端都仍有表单/链接兜底 * (禁用 JS、脚本加载失败时后台照常可用),所以这里只做三件事: * 1. 大图查看层 —— 审核是看图工作,缩略图点击放大是刚需; * 2. 批量操作 —— 多选后一次提交,避免逐条整页刷新(原逻辑 20 条要点 20 次); * 3. 键盘流 —— j/k 移动、x 勾选、m 设主图、d 拆出、Enter 看大图,审核图片时手不离键盘。 * * 约定:不改变页面结构语义,只挂事件、改标签与可见性;出错一律 fallback 到原生提交。 */ (function () { 'use strict'; var doc = document; var $ = function (sel, root) { return (root || doc).querySelector(sel); }; var $$ = function (sel, root) { return Array.prototype.slice.call((root || doc).querySelectorAll(sel)); }; /* ---------------- 通用:提示与请求 ---------------- */ var toastTimer = null; function toast(msg, ok) { var el = doc.getElementById('toast'); if (!el) { if (!ok) window.alert(msg); return; } el.textContent = msg; el.className = 'flash ' + (ok === false ? 'err' : 'ok'); el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(function () { el.hidden = true; }, 4000); } function postJSON(url, body) { return fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }).then(function (res) { return res.json().catch(function () { return null; }).then(function (data) { if (!res.ok) throw new Error((data && data.error) || ('请求失败(' + res.status + ')')); return data; }); }); } // 表单 action -> 同路径的 JSON 接口(/admin/reviews/... -> /admin/api/reviews/...) function apiURL(action) { return String(action).replace('/admin/reviews/', '/admin/api/reviews/'); } /* ---------------- 大图查看层 ---------------- */ var lb = null, lbList = [], lbIndex = 0; function lightboxNodes() { return $$('.cell .thumb img, .cell > img').filter(function (img) { return img.getAttribute('src'); }); } function ensureLightbox() { if (lb) return lb; lb = doc.createElement('div'); lb.className = 'lightbox'; lb.hidden = true; lb.innerHTML = '' + '' + '' + '
'; doc.body.appendChild(lb); lb.querySelector('.lb-close').addEventListener('click', closeLightbox); lb.querySelector('.lb-prev').addEventListener('click', function () { stepLightbox(-1); }); lb.querySelector('.lb-next').addEventListener('click', function () { stepLightbox(1); }); lb.addEventListener('click', function (e) { if (e.target === lb) closeLightbox(); }); return lb; } function showLightbox() { var box = ensureLightbox(); var img = lbList[lbIndex]; if (!img) return; var box2 = box.querySelector('img'); box2.src = img.getAttribute('src'); box2.alt = img.getAttribute('alt') || ''; var cell = img.closest ? img.closest('.cell') : null; var nm = cell ? $('.nm', cell) : null; box.querySelector('.lb-cap').textContent = (nm ? nm.textContent.trim() + ' · ' : '') + (lbIndex + 1) + ' / ' + lbList.length; box.hidden = false; doc.body.style.overflow = 'hidden'; } function closeLightbox() { if (lb) lb.hidden = true; doc.body.style.overflow = ''; } function stepLightbox(delta) { if (!lbList.length) return; lbIndex = (lbIndex + delta + lbList.length) % lbList.length; showLightbox(); } function initLightbox() { var imgs = lightboxNodes(); if (!imgs.length) return; imgs.forEach(function (img) { var trigger = img.closest ? (img.closest('.thumb') || img) : img; trigger.addEventListener('click', function (e) { e.preventDefault(); lbList = lightboxNodes(); lbIndex = lbList.indexOf(img); if (lbIndex < 0) lbIndex = 0; showLightbox(); }); }); } /* ---------------- 审核列表:多选 + 批量通过/驳回 ---------------- */ function initReviewList() { var bar = doc.getElementById('batchbar'); var table = $('table'); if (!bar || !table) return; var rows = $$('tbody tr', table).filter(function (r) { return r.dataset && r.dataset.id; }); if (!rows.length) return; var selAll = doc.getElementById('sel-all'); var counter = doc.getElementById('sel-count'); function picked() { return rows.filter(function (r) { var c = $('input.row-pick', r); return c && c.checked; }); } function refresh() { var n = picked().length; bar.hidden = n === 0; if (counter) counter.textContent = '已选 ' + n + ' 条'; rows.forEach(function (r) { var c = $('input.row-pick', r); r.classList.toggle('selected', !!(c && c.checked)); }); if (selAll) { selAll.checked = n > 0 && n === rows.length; selAll.indeterminate = n > 0 && n < rows.length; } } table.addEventListener('change', function (e) { if (e.target.classList.contains('row-pick')) refresh(); }); if (selAll) { selAll.addEventListener('change', function () { rows.forEach(function (r) { var c = $('input.row-pick', r); if (c) c.checked = selAll.checked; }); refresh(); }); } function submit(action, reason) { var items = picked().map(function (r) { return { kind: r.dataset.kind, id: Number(r.dataset.id) }; }); if (!items.length) return; var btns = $$('#batchbar button'); btns.forEach(function (b) { b.disabled = true; }); postJSON('/admin/api/reviews/batch', { action: action, reason: reason || '', items: items }) .then(function (res) { // 成功的行直接从表格移除;失败的行标红保留,便于逐条重试 var failed = {}; (res.failed || []).forEach(function (f) { failed[f.kind + '/' + f.id] = f.error; }); items.forEach(function (it) { var row = rows.filter(function (r) { return r.dataset.kind === it.kind && Number(r.dataset.id) === it.id; })[0]; if (!row) return; if (failed[it.kind + '/' + it.id]) { row.classList.add('failed-row'); row.title = failed[it.kind + '/' + it.id]; return; } row.parentNode.removeChild(row); rows = rows.filter(function (r) { return r !== row; }); }); var msg = (action === 'approve' ? '已通过 ' : '已驳回 ') + res.ok + ' 条'; if ((res.failed || []).length) msg += ',' + res.failed.length + ' 条失败'; toast(msg, !(res.failed || []).length); refresh(); }) .catch(function (err) { toast(err.message, false); }) .then(function () { btns.forEach(function (b) { b.disabled = false; }); }); } var approveBtn = doc.getElementById('batch-approve'); var rejectBtn = doc.getElementById('batch-reject'); var clearBtn = doc.getElementById('batch-clear'); if (approveBtn) { approveBtn.addEventListener('click', function () { var n = picked().length; if (n && window.confirm('通过所选 ' + n + ' 条草稿并发布到正式表?')) submit('approve'); }); } if (rejectBtn) { rejectBtn.addEventListener('click', function () { var n = picked().length; if (!n) return; var reason = window.prompt('驳回所选 ' + n + ' 条的理由(必填,会记录到审核留痕)'); if (reason === null) return; if (!reason.trim()) { toast('驳回理由不能为空', false); return; } submit('reject', reason.trim()); }); } if (clearBtn) { clearBtn.addEventListener('click', function () { rows.forEach(function (r) { var c = $('input.row-pick', r); if (c) c.checked = false; }); refresh(); }); } // 行内快捷通过(免进详情页) $$('button[data-quick-approve]', table).forEach(function (btn) { btn.addEventListener('click', function () { var row = btn.closest('tr'); if (!row || !window.confirm('通过并发布这条草稿?')) return; btn.disabled = true; postJSON('/admin/api/reviews/batch', { action: 'approve', items: [{ kind: row.dataset.kind, id: Number(row.dataset.id) }], }).then(function (res) { if ((res.failed || []).length) { toast(res.failed[0].error || '操作失败', false); btn.disabled = false; return; } toast('已通过', true); row.parentNode.removeChild(row); rows = rows.filter(function (r) { return r !== row; }); refresh(); }).catch(function (err) { toast(err.message, false); btn.disabled = false; }); }); }); refresh(); } /* ---------------- 审核详情:主副图合并/拆出(AJAX)+ 键盘流 ---------------- */ function initReviewDetail() { var grid = $('.grid'); if (!grid) return; var cells = $$('.cell', grid); var attachForm = doc.getElementById('batch'); // 群组状态回填:只改标签与可见性,不重排 DOM(避免打乱审核人的空间记忆) function applyGroups(res) { var images = res.images || []; var byId = {}; images.forEach(function (i) { byId[i.id] = i; }); cells.forEach(function (cell) { var st = byId[Number(cell.dataset.imgId)]; if (!st) return; cell.classList.toggle('det', st.isDetail === 1); var role = $('.role', cell); if (role) { role.textContent = st.isDetail === 1 ? ('副图 → #' + st.parentId) : ('主图' + (st.detailCount ? ' · ' + st.detailCount + ' 副图' : '')); } $$('[data-only="main"]', cell).forEach(function (el) { el.hidden = st.isDetail === 1; }); $$('[data-only="detail"]', cell).forEach(function (el) { el.hidden = st.isDetail !== 1; }); }); var stat = doc.getElementById('img-stat'); if (stat) stat.textContent = images.length + ' 张 · ' + (res.groups || 0) + ' 组'; } // 拦截合并/拆出表单:成功就地更新,失败回退原生提交(保底可用) function intercept(form, buildBody) { form.addEventListener('submit', function (e) { e.preventDefault(); var body = buildBody(form); if (body === null) return; // 校验未过:交给原生提交去报错 postJSON(apiURL(form.getAttribute('action')), body) .then(function (res) { applyGroups(res); cells.forEach(function (c) { var chk = $('input[type="checkbox"]', c); if (chk) chk.checked = false; var radio = $('input[type="radio"]', c); if (radio) radio.checked = false; }); toast('已更新分组', true); }) .catch(function (err) { toast(err.message, false); }); }); } if (attachForm) { intercept(attachForm, function (form) { var main = $('input[type="radio"]:checked', grid); var imgs = $$('input[type="checkbox"]:checked', grid).map(function (c) { return Number(c.value); }); if (!main || !imgs.length) { toast('请勾选图片,并在其中一张主图上点「主图」', false); return null; } return { main: Number(main.value), imgs: imgs }; }); } $$('form[action*="/detach"]', grid).forEach(function (form) { intercept(form, function () { return {}; }); }); // ---- 键盘流(审核图片时手不离键盘) ---- var focusIndex = -1; // scroll=false 用于初始化:只标记第一格,不要因为 scrollIntoView 把页面顶端的 // 字段表单(标题/年份/城市 + 通过按钮)滚出视野 —— 进页面必须先看到待填字段。 function setFocus(i, scroll) { if (!cells.length) return; focusIndex = Math.max(0, Math.min(cells.length - 1, i)); cells.forEach(function (c, j) { c.classList.toggle('focus', j === focusIndex); }); var el = cells[focusIndex]; if (scroll !== false && el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }); } function inField(t) { return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT'); } doc.addEventListener('keydown', function (e) { if (!lb.hidden && lb) { if (e.key === 'Escape') { closeLightbox(); e.preventDefault(); } else if (e.key === 'ArrowLeft') { stepLightbox(-1); e.preventDefault(); } else if (e.key === 'ArrowRight') { stepLightbox(1); e.preventDefault(); } return; } if (inField(e.target) || e.metaKey || e.ctrlKey || e.altKey) return; var cur = cells[focusIndex]; switch (e.key) { case 'j': setFocus(focusIndex + 1); e.preventDefault(); break; case 'k': setFocus(focusIndex - 1); e.preventDefault(); break; case 'Enter': if (cur) { var t = $('.thumb', cur) || $('img', cur); if (t) { t.click(); e.preventDefault(); } } break; case 'x': if (cur) { var chk = $('input[type="checkbox"]', cur); if (chk && !chk.disabled) { chk.checked = !chk.checked; e.preventDefault(); } } break; case 'm': if (cur) { var radio = $('input[type="radio"]', cur); if (radio) { radio.checked = true; e.preventDefault(); } } break; case 'd': if (cur) { var df = $('form[action*="/detach"]', cur); if (df) { df.requestSubmit ? df.requestSubmit() : df.submit(); e.preventDefault(); } } break; case 'a': // 通过 = 提交页面上那个「通过并发布」表单(复用当前输入框里的字段微调),故加一次确认 var af = $('form[action$="/approve"]'); if (af && window.confirm('通过并发布这条草稿?')) { af.requestSubmit ? af.requestSubmit() : af.submit(); e.preventDefault(); } break; } }); // 进页面先标记第一格(不滚动),键盘可直接用 if (cells.length) setFocus(0, false); } /* ---------------- 启动 ---------------- */ function boot() { // 标记脚本已执行(便于人工/自动化排查「脚本没加载」这类问题) doc.documentElement.setAttribute('data-admin-js', 'ready'); initLightbox(); initReviewList(); initReviewDetail(); var hint = doc.getElementById('hint-dismiss'); if (hint) { hint.addEventListener('click', function () { var box = hint.closest('.hint'); if (box) box.hidden = true; }); } } if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', boot); else boot(); })();