This commit is contained in:
toom1996
2026-09-25 11:31:52 +08:00
parent 4f4c11d508
commit 42f6316125
51 changed files with 4489 additions and 998 deletions

View File

@ -1,402 +1,209 @@
/* 管理后台渐进增强脚本:无框架、无依赖、无构建。
*
* 定位:**JS 只是「更快的那条路」**。所有操作在服务端都仍有表单/链接兜底
* (禁用 JS、脚本加载失败时后台照常可用),所以这里只做三件事:
* 1. 大图查看层 —— 审核是看图工作,缩略图点击放大是刚需;
* 2. 批量操作 —— 多选后一次提交,避免逐条整页刷新(原逻辑 20 条要点 20 次);
* 3. 键盘流 —— j/k 移动、x 勾选、m 设主图、d 拆出、Enter 看大图,审核图片时手不离键盘。
*
* 约定:不改变页面结构语义,只挂事件、改标签与可见性;出错一律 fallback 到原生提交。
*/
// 后台交互脚本(原生 JS,无构建步骤)。data-admin-js
// 两个入口页面:
// - 文章管理混合列表(/admin/articles):批量通过/驳回
// - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组)
(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);
// 表单动作里的 /admin/articles/ 换成 /admin/api/articles/,得到对应的 JSON 接口地址。
function apiOf(action) {
return action.replace('/admin/articles/', '/admin/api/articles/');
}
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;
// ── 看大图弹层(所有页面通用)────────────────────────────────────────────
function initZoom() {
var modal = document.getElementById('zoom-modal');
if (!modal) return;
var img = document.getElementById('zoom-img');
document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) {
btn.addEventListener('click', function () {
img.src = btn.querySelector('img').src;
modal.showModal();
});
});
modal.addEventListener('click', function (e) {
if (e.target === modal) modal.close();
});
}
// 表单 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 =
'<button class="lb-close" type="button" aria-label="关闭">✕</button>' +
'<button class="lb-prev" type="button" aria-label="上一张">‹</button>' +
'<button class="lb-next" type="button" aria-label="下一张">›</button>' +
'<img alt=""><div class="lb-cap"></div>';
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 initArticleList() {
var selAll = document.getElementById('sel-all');
var picks = function () { return Array.prototype.slice.call(document.querySelectorAll('.row-pick')); };
var bar = document.getElementById('batchbar');
var count = document.getElementById('sel-count');
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;
}
var n = picks().filter(function (c) { return c.checked; }).length;
if (bar) bar.hidden = n === 0;
if (count) count.textContent = '已选 ' + n + ' 条';
}
table.addEventListener('change', function (e) {
if (e.target.classList.contains('row-pick')) refresh();
if (selAll) selAll.addEventListener('change', function () {
picks().forEach(function (c) { c.checked = selAll.checked; });
refresh();
});
if (selAll) {
selAll.addEventListener('change', function () {
rows.forEach(function (r) {
var c = $('input.row-pick', r);
if (c) c.checked = selAll.checked;
});
refresh();
});
}
picks().forEach(function (c) { c.addEventListener('change', 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;
});
});
var clear = document.getElementById('batch-clear');
if (clear) clear.addEventListener('click', function () {
picks().forEach(function (c) { c.checked = false; });
if (selAll) selAll.checked = false;
refresh();
});
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 + ' 副图' : ''));
function postBatch(action) {
var items = picks().filter(function (c) {
return c.checked;
}).map(function (c) {
var tr = c.closest('tr');
return { kind: tr.getAttribute('data-kind'), id: parseInt(tr.getAttribute('data-id'), 10) };
});
if (items.length === 0) { alert('请先勾选要操作的记录'); return; }
if (!confirm('确认' + (action === 'approve' ? '通过' : '驳回') + '选中的 ' + items.length + ' 条?')) return;
fetch('/admin/api/articles/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action, items: items })
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.failed && d.failed.length) {
alert('成功 ' + d.ok + ' 条,失败 ' + d.failed.length + ' 条:' + d.failed[0].error);
}
$$('[data-only="main"]', cell).forEach(function (el) { el.hidden = st.isDetail === 1; });
$$('[data-only="detail"]', cell).forEach(function (el) { el.hidden = st.isDetail !== 1; });
location.reload();
}).catch(function (e) { alert('请求失败:' + e); });
}
var approve = document.getElementById('batch-approve');
var reject = document.getElementById('batch-reject');
if (approve) approve.addEventListener('click', function () { postBatch('approve'); });
if (reject) reject.addEventListener('click', function () { postBatch('reject'); });
}
// ── 街拍编辑页:主副图分组(合并 / 拆出)──────────────────────────────
function initArticleDetail() {
var batchForm = document.getElementById('batch');
var stat = document.getElementById('img-stat');
var hint = document.getElementById('hint-dismiss');
if (hint) hint.addEventListener('click', function () {
var h = hint.closest('.hint'); if (h) h.style.display = 'none';
});
// 每个主图各自的「显示副图」开关:点一下只展开/收起该主图紧邻的副图(不会一次全开)。
var grid = document.querySelector('.grid');
if (grid) {
grid.addEventListener('click', function (e) {
var btn = e.target.closest('.toggle-det');
if (!btn) return;
var pid = btn.getAttribute('data-parent');
var dets = grid.querySelectorAll('.cell.det[data-parent="' + pid + '"]');
var expand = false;
dets.forEach(function (d) { if (!d.classList.contains('shown')) expand = true; });
dets.forEach(function (d) { d.classList.toggle('shown', expand); });
btn.textContent = expand
? ('隐藏副图(' + dets.length + ' 张)')
: ('显示副图(' + dets.length + ' 张)');
});
var stat = doc.getElementById('img-stat');
if (stat) stat.textContent = images.length + ' 张 · ' + (res.groups || 0) + ' 组';
}
// 拦截合并/拆出表单:成功就地更新,失败回退原生提交(保底可用)
function intercept(form, buildBody) {
form.addEventListener('submit', function (e) {
// 合并选中为一组(AJAX,成功后就地更新标签)
if (batchForm) batchForm.addEventListener('submit', function (e) {
e.preventDefault();
// 注意:主图 radio / 选入 checkbox 用 form="batch" 关联到批量表单,但 DOM 上并不在
// <form id="batch"> 内部(包在 .cell 里)。它们虽能随表单正确提交,却不在 form 子树中,
// 故必须用 document 级查询(而非 batchForm.querySelector)才能取到已勾选的项。
var main = document.querySelector('input[name="main"]:checked');
var picks = document.querySelectorAll('input[name="img"]:checked');
if (!main) { alert('请先在其中一张主图上点「主图」'); return; }
if (!picks.length) { alert('请勾选要合并的图片'); return; }
// 接口 ArticleAttachImagesAPI 收 JSON(main + imgs),不能发 FormData:
// multipart 会让 ShouldBindJSON 失败、返回「请求格式错误」。
var data = {
main: parseInt(main.value, 10),
imgs: Array.prototype.slice.call(picks).map(function (c) { return parseInt(c.value, 10); })
};
fetch(apiOf(batchForm.getAttribute('action')), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(function (r) { return r.json(); })
.then(function (d) { applyImages(d.images, kindOf(batchForm.getAttribute('action')), main.value); if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组'; })
.catch(function (err) { alert('合并失败:' + err); });
});
// 单张拆出(AJAX)
document.querySelectorAll('form[data-only="detail"]').forEach(function (f) {
f.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); });
fetch(apiOf(f.getAttribute('action')), { method: 'POST', body: new FormData(f) })
.then(function (r) { return r.json(); })
.then(function (d) { applyImages(d.images, kindOf(f.getAttribute('action'))); if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组'; })
.catch(function (err) { alert('拆出失败:' + err); });
});
}
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);
}
/* ---------------- 启动 ---------------- */
// 从 /admin/articles/<kind>/... 形式的 action 取出实体类型(street / runway)。
function kindOf(action) {
var m = /^\/admin\/articles\/(\w+)\//.exec(action || '');
return m ? m[1] : 'street';
}
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;
});
// 更新某个主图的「显示副图」开关:按最新副图数创建/更新/移除按钮,文案随展开态变化。
function updateToggle(mainId, detailCount) {
var cell = document.querySelector('.cell[data-img-id="' + mainId + '"]');
var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]');
if (detailCount > 0) {
if (!btn && cell) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'toggle-det';
btn.setAttribute('data-parent', mainId);
var acts = cell.querySelector('.acts');
if (acts) acts.appendChild(btn);
}
var dets = document.querySelectorAll('.cell.det[data-parent="' + mainId + '"]');
var expanded = dets.length > 0 && dets[0].classList.contains('shown');
if (btn) btn.textContent = (expanded ? '隐藏副图(' : '显示副图(') + detailCount + ' 张)';
} else if (btn) {
btn.remove();
}
}
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', boot);
else boot();
// 展开/收起某主图的全部副图,并同步按钮文案。
function setExpanded(mainId, expanded) {
var dets = document.querySelectorAll('.cell.det[data-parent="' + mainId + '"]');
dets.forEach(function (d) { d.classList.toggle('shown', expanded); });
var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]');
if (btn) btn.textContent = (expanded ? '隐藏副图(' : '显示副图(') + dets.length + ' 张)';
}
// 用服务端返回的最新图片投影,重写网格里的角色标签、副图开关与统计。
// kind 决定细节图文案;focusMain 为合并操作的目标主图 id,合并后自动展开其副图。
function applyImages(images, kind, focusMain) {
if (!images) return;
images.forEach(function (im) {
var cell = document.querySelector('.cell[data-img-id="' + im.id + '"]');
if (!cell) return;
cell.classList.toggle('det', im.isDetail === 1);
if (im.isDetail === 1) {
cell.setAttribute('data-parent', im.parentId);
} else {
cell.removeAttribute('data-parent');
updateToggle(im.id, im.detailCount);
}
var role = cell.querySelector('.role');
if (role) {
if (im.isDetail === 1) {
role.textContent = (kind === 'runway' ? '细节图' : '副图') + ' → #' + im.parentId;
} else {
role.textContent = '主图' + (im.detailCount ? (' · ' + im.detailCount + ' 副图') : '');
}
}
});
if (focusMain) setExpanded(focusMain, true);
}
document.addEventListener('DOMContentLoaded', function () {
initZoom();
if (document.getElementById('sel-all') || document.getElementById('batchbar')) initArticleList();
if (document.getElementById('batch')) initArticleDetail();
});
})();