update
This commit is contained in:
431
internal/handler/assets/admin.css
Normal file
431
internal/handler/assets/admin.css
Normal file
@ -0,0 +1,431 @@
|
||||
/* ============================================================================
|
||||
* 管理后台统一样式(合并原先散在 14 个内联模板里的 12 份重复 <style>)。
|
||||
*
|
||||
* 设计基调沿用原后台:黑白、直角感、系统字体、无外部依赖。
|
||||
* 统一后只此一份,改样式不必再逐页改 14 处。
|
||||
*
|
||||
* 约定:
|
||||
* 1) 布局三件套 —— body(页面底色)/ .bar(顶部导航)/ .main(内容容器)
|
||||
* 2) 组件化 —— .btn / .badge / .card / .cell / .pager / .flash 等按语义命名,
|
||||
* 页面只写结构,不再写内联 style
|
||||
* 3) 缩略图统一 2:3 —— 与站内图片素材比例一致(后端素材为 240×360 / 720×1080)
|
||||
* ==========================================================================*/
|
||||
|
||||
/* ---------- 基础 ---------- */
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* 让 hidden 属性始终生效:下面给 .batchbar/.flash/.chk 设了 display,
|
||||
作者样式会盖过浏览器默认的 [hidden]{display:none},故这里强制抬高。 */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #111;
|
||||
background: #f4f4f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
h2 { font-size: 15px; margin: 26px 0 10px; color: #333; }
|
||||
|
||||
a { color: #111; }
|
||||
a.view { text-decoration: underline; }
|
||||
.muted { color: #777; }
|
||||
code {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: #f6f6f7;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.break { word-break: break-all; }
|
||||
|
||||
:focus-visible { outline: 2px solid #111; outline-offset: 2px; }
|
||||
|
||||
/* ---------- 顶部导航 ---------- */
|
||||
.bar {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
padding: 10px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
.bar a { color: #fff; text-decoration: none; }
|
||||
.bar-title { font-weight: 600; white-space: nowrap; }
|
||||
.bar-nav { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
|
||||
.bar-nav a {
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
opacity: .78;
|
||||
transition: background .15s, opacity .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bar-nav a:hover { opacity: 1; background: rgba(255, 255, 255, .14); }
|
||||
.bar-nav a.on { opacity: 1; background: rgba(255, 255, 255, .2); }
|
||||
.bar-nav .sep { width: 1px; height: 14px; background: rgba(255, 255, 255, .25); margin: 0 6px; }
|
||||
.bar-badge {
|
||||
display: inline-block;
|
||||
min-width: 16px;
|
||||
padding: 0 5px;
|
||||
margin-left: 5px;
|
||||
border-radius: 9px;
|
||||
background: #f59e0b;
|
||||
color: #111;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
/* ---------- 内容容器 ---------- */
|
||||
.main { max-width: 1100px; margin: 24px auto 60px; padding: 0 24px; }
|
||||
.main.narrow { max-width: 620px; margin-top: 32px; }
|
||||
.main.mid { max-width: 900px; }
|
||||
|
||||
/* 登录页固定宽卡片 */
|
||||
.wrap {
|
||||
max-width: 360px;
|
||||
margin: 80px auto;
|
||||
background: #fff;
|
||||
padding: 28px 28px 32px;
|
||||
border: 1px solid #e2e2e3;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.wrap h1 { margin-bottom: 18px; }
|
||||
.wrap button { width: 100%; margin-top: 18px; }
|
||||
|
||||
/* ---------- 提示条 ---------- */
|
||||
.flash {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin: 14px 0;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.flash.ok { background: #e6f7ec; border: 1px solid #a9dcbd; color: #1a7f43; }
|
||||
.flash.err, .err { color: #c0392b; }
|
||||
.flash.err { background: #fdeaea; border: 1px solid #f0b7b3; }
|
||||
/* 收起按钮(提示条 / 顶部提示里的小 ✕):必须是「无底无边」的小图标,
|
||||
否则会继承下面 button 的黑底样式,变成一个突兀的黑方块。 */
|
||||
.hint .x, .flash .x {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* hint 是 sticky(已定位),收起按钮绝对定位到右上角,不参与文字排版 */
|
||||
.hint { padding-right: 34px; }
|
||||
.hint .x { position: absolute; top: 6px; right: 8px; }
|
||||
.hint {
|
||||
position: sticky;
|
||||
top: 52px;
|
||||
background: #fffbe6;
|
||||
border: 1px solid #f5e08a;
|
||||
color: #7a5b00;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ---------- 表单 ---------- */
|
||||
label { display: block; margin: 14px 0 4px; color: #555; }
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
input[type="checkbox"], input[type="radio"] { width: auto; padding: 0; }
|
||||
textarea { min-height: 72px; }
|
||||
.row { display: flex; gap: 14px; }
|
||||
.row > div { flex: 1; min-width: 0; }
|
||||
|
||||
button {
|
||||
padding: 9px 16px;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border: 1px solid #111;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
button:hover { opacity: .88; }
|
||||
button.reject, button.danger { background: #c0392b; border-color: #c0392b; }
|
||||
button.ghost { background: #fff; color: #111; border-color: #ccc; }
|
||||
button.retry {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
background: #fff;
|
||||
}
|
||||
button.retry:hover { background: #c0392b; color: #fff; opacity: 1; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
|
||||
/* ---------- 工具栏 / 筛选 ---------- */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.toolbar input, .toolbar select { width: auto; padding: 8px 10px; }
|
||||
.toolbar a { color: #555; text-decoration: none; font-size: 13px; }
|
||||
.tabs a, .kinds a {
|
||||
display: inline-block;
|
||||
margin: 0 8px 8px 0;
|
||||
color: #111;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.kinds a { border-color: #ddd; }
|
||||
.tabs a.on, .kinds a.on { background: #111; color: #fff; border-color: #111; }
|
||||
.idx { line-height: 2.2; }
|
||||
.idx a { margin-right: 8px; text-decoration: none; font-size: 13px; }
|
||||
.idx a.on { font-weight: 700; text-decoration: underline; }
|
||||
|
||||
/* ---------- 表格 ---------- */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
margin-top: 12px;
|
||||
border: 1px solid #e6e6e7;
|
||||
border-radius: 8px;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
}
|
||||
th { background: #fafafa; color: #555; font-weight: 600; white-space: nowrap; }
|
||||
tbody tr:hover { background: #fbfbfc; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ---------- 徽标 ---------- */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.pending { background: #fff4e0; color: #a85b00; }
|
||||
.badge.processing { background: #eef2ff; color: #3b4bb5; }
|
||||
.badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
|
||||
.badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
|
||||
.badge.dup { background: #f3e8ff; color: #6b21a8; }
|
||||
.badge.free { background: #eef2ff; color: #3b4bb5; }
|
||||
.badge.vip { background: #fff4e0; color: #a85b00; }
|
||||
.kind {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
background: #eef2ff;
|
||||
color: #3b4bb5;
|
||||
}
|
||||
.kind.street { background: #eafaf0; color: #1a7f43; }
|
||||
.down { display: inline-block; padding: 2px 8px; background: #fdeaea; color: #c0392b; border-radius: 10px; font-size: 12px; }
|
||||
|
||||
/* ---------- 卡片(首页入口) ---------- */
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 20px; }
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e2e3;
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
text-decoration: none;
|
||||
color: #111;
|
||||
display: block;
|
||||
transition: border-color .15s, transform .15s;
|
||||
}
|
||||
.card:hover { border-color: #111; transform: translateY(-1px); }
|
||||
.card .t { font-size: 15px; font-weight: 600; }
|
||||
.card .d { color: #777; font-size: 13px; margin-top: 6px; }
|
||||
.chips { margin-top: 10px; }
|
||||
.chip { display: inline-block; background: #111; color: #fff; font-size: 12px; padding: 3px 9px; border-radius: 10px; margin: 0 6px 6px 0; }
|
||||
|
||||
/* ---------- 图片网格(审核 / 图集) ---------- */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.cell {
|
||||
background: #fff;
|
||||
border: 1px solid #e6e6e7;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cell.det { background: #f8faff; border-color: #c7d2fe; }
|
||||
.cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
|
||||
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
|
||||
.cell .thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #f1f1f2;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.cell .thumb img, .cell > img { width: 100%; display: block; aspect-ratio: 2 / 3; object-fit: cover; }
|
||||
.cell > img { cursor: zoom-in; }
|
||||
.cell .thumb::after {
|
||||
content: "点击看大图";
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, .62);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
opacity: 0;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.cell .thumb:hover::after { opacity: 1; }
|
||||
.cell .role { display: block; padding: 6px 8px 0; font-size: 12px; color: #1a7f43; }
|
||||
.cell.det .role { color: #3b4bb5; }
|
||||
.cell .nm { display: block; padding: 2px 8px 6px; font-size: 12px; color: #555; word-break: break-all; }
|
||||
.cell .acts { padding: 8px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
|
||||
.cell .acts button, .cell .acts .btn { margin: 0; flex: 1 1 auto; padding: 7px 8px; font-size: 13px; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { border-color: #111; }
|
||||
.chk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 1 1 auto;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
.chk:hover { border-color: #111; }
|
||||
.chk input { margin: 0; }
|
||||
|
||||
/* ---------- 底部批量操作条 ---------- */
|
||||
.batchbar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e6e6e7;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, .06);
|
||||
z-index: 15;
|
||||
}
|
||||
.batchbar button { margin: 0; }
|
||||
.batchbar .spacer { flex: 1; }
|
||||
|
||||
/* ---------- 分页 ---------- */
|
||||
.pager { margin: 18px 0; display: flex; gap: 12px; align-items: center; }
|
||||
.pager a { padding: 7px 14px; border: 1px solid #ccc; border-radius: 6px; text-decoration: none; background: #fff; }
|
||||
.pager a:hover { border-color: #111; }
|
||||
.pager a.off { color: #bbb; border-color: #eee; pointer-events: none; }
|
||||
|
||||
/* ---------- 大图查看层(admin.js 用) ---------- */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, .88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
.lightbox[hidden] { display: none; }
|
||||
.lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.lightbox .lb-close,
|
||||
.lightbox .lb-prev,
|
||||
.lightbox .lb-next {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
color: #111;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lightbox .lb-close { top: 18px; right: 18px; }
|
||||
.lightbox .lb-prev { left: 18px; top: 50%; transform: translateY(-50%); }
|
||||
.lightbox .lb-next { right: 18px; top: 50%; transform: translateY(-50%); }
|
||||
.lightbox .lb-cap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 16px;
|
||||
text-align: center;
|
||||
color: #eee;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- 窄屏 ---------- */
|
||||
@media (max-width: 720px) {
|
||||
.bar { padding: 10px 14px; }
|
||||
.bar-title { font-size: 13px; }
|
||||
.main { padding: 0 14px; margin-top: 16px; }
|
||||
.row { flex-direction: column; gap: 0; }
|
||||
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||
}
|
||||
402
internal/handler/assets/admin.js
Normal file
402
internal/handler/assets/admin.js
Normal file
@ -0,0 +1,402 @@
|
||||
/* 管理后台渐进增强脚本:无框架、无依赖、无构建。
|
||||
*
|
||||
* 定位:**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 =
|
||||
'<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 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user