update
This commit is contained in:
@ -290,7 +290,11 @@ tbody tr:last-child td { border-bottom: 0; }
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cell.det { background: #f8faff; border-color: #c7d2fe; }
|
||||
/* 细节图 / 副图:明显的靛蓝边框 + 淡蓝底,和主图一眼区分开 */
|
||||
.cell.det { background: #eef2ff; border: 2px solid #818cf8; }
|
||||
/* 副图默认隐藏,点对应主图上的「显示副图」才展开(逐组,不会一次全开) */
|
||||
.cell.det { display: none; }
|
||||
.cell.det.shown { display: flex; }
|
||||
.cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
|
||||
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
|
||||
.cell .thumb {
|
||||
@ -319,8 +323,18 @@ tbody tr:last-child td { border-bottom: 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 .role {
|
||||
align-self: flex-start;
|
||||
margin: 6px 8px 0;
|
||||
padding: 2px 9px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1a7f43;
|
||||
background: #e6f7ec;
|
||||
}
|
||||
.cell.det .role { color: #fff; background: #4338ca; }
|
||||
.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; }
|
||||
@ -339,6 +353,44 @@ tbody tr:last-child td { border-bottom: 0; }
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { border-color: #111; }
|
||||
.toggle-det { background: #eef2ff; border: 1px solid #818cf8; color: #4338ca; }
|
||||
|
||||
/* ---------- 行内操作按钮组(列表行 / 审核操作) ---------- */
|
||||
/* 把原先「编辑 · 通过 · 驳回 · 下架」那种用中点分隔的散落按钮,
|
||||
收敛成一组紧凑胶囊按钮:编辑是描边链接,通过=绿、驳回=红、下架=琥珀描边。 */
|
||||
.ops { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.ops .inline { display: inline-flex; margin: 0; padding: 0; }
|
||||
.ops .view {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
color: #111;
|
||||
background: #fff;
|
||||
}
|
||||
.ops .view:hover { border-color: #111; }
|
||||
.op {
|
||||
margin: 0;
|
||||
padding: 5px 11px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.op-primary { background: #1a7f43; border-color: #1a7f43; color: #fff; }
|
||||
.op-danger { background: #c0392b; border-color: #c0392b; color: #fff; }
|
||||
.op-warn { background: #fff; border-color: #e0a800; color: #8a6d00; }
|
||||
.op-primary:hover, .op-danger:hover { opacity: .9; }
|
||||
.op-warn:hover { background: #fff8e6; }
|
||||
|
||||
/* 审核操作区(编辑页底部的通过 / 驳回)同款胶囊按钮组 */
|
||||
.review-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 18px 0; }
|
||||
.review-actions .inline { display: inline-flex; margin: 0; padding: 0; }
|
||||
.chk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -357,6 +409,25 @@ tbody tr:last-child td { border-bottom: 0; }
|
||||
.chk:hover { border-color: #111; }
|
||||
.chk input { margin: 0; }
|
||||
|
||||
/* ---------- 看大图弹层(<dialog>,initZoom 依赖 #zoom-modal / #zoom-img) ---------- */
|
||||
#zoom-modal {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
max-width: 94vw;
|
||||
max-height: 94vh;
|
||||
}
|
||||
#zoom-modal::backdrop { background: rgba(0, 0, 0, .82); }
|
||||
#zoom-img {
|
||||
display: block;
|
||||
max-width: 94vw;
|
||||
max-height: 94vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
/* ---------- 底部批量操作条 ---------- */
|
||||
.batchbar {
|
||||
position: sticky;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
|
||||
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
|
||||
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
|
||||
// 同时表单式路由(/admin/reviews/...)原样保留:关掉 JS 后台依然可用。
|
||||
// 同时表单式路由(/admin/articles/... 下的 approve/reject/attach/detach)原样保留:关掉 JS 后台依然可用。
|
||||
//
|
||||
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
|
||||
package handler
|
||||
@ -32,13 +32,13 @@ type reviewBatchReq struct {
|
||||
Items []reviewBatchItem `json:"items"`
|
||||
}
|
||||
|
||||
// ReviewBatch 批量通过 / 驳回审核记录。
|
||||
// ArticleBatch 文章管理批量通过 / 驳回(替代原审核批量接口)。
|
||||
//
|
||||
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
|
||||
// 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17}
|
||||
// 响应: {"ok":2,"failed":[{"kind":"street","id":9,"error":"..."}],"pending":17}
|
||||
//
|
||||
// 逐条处理并逐条回报失败原因(而不是整体失败):审核时最怕「一批提交下去不知道哪条没成」。
|
||||
func (h *BackstageHandler) ReviewBatch(c *gin.Context) {
|
||||
func (h *BackstageHandler) ArticleBatch(c *gin.Context) {
|
||||
var req reviewBatchReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||
@ -99,10 +99,10 @@ type reviewAttachReq struct {
|
||||
Imgs []uint32 `json:"imgs"`
|
||||
}
|
||||
|
||||
// ReviewAttachImagesAPI 把多张图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。
|
||||
// ArticleAttachImagesAPI 把多张图并入指定主图(街拍「合并选中为一组」的 AJAX 版)。
|
||||
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
|
||||
// 加 API 后缀以区别于表单版 ReviewAttachImages:本方法收发 JSON,表单版返回 302。
|
||||
func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
|
||||
// 加 API 后缀以区别于表单版 ArticleAttachImages:本方法收发 JSON,表单版返回 302。
|
||||
func (h *BackstageHandler) ArticleAttachImagesAPI(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id == 0 {
|
||||
@ -129,8 +129,8 @@ func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
|
||||
h.respondRecordImages(c, kind, uint32(id))
|
||||
}
|
||||
|
||||
// ReviewDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImagesAPI。
|
||||
func (h *BackstageHandler) ReviewDetachImageAPI(c *gin.Context) {
|
||||
// ArticleDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ArticleAttachImagesAPI。
|
||||
func (h *BackstageHandler) ArticleDetachImageAPI(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
imgID := atoiDefault(c.Param("img"), 0)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@ -8,7 +9,9 @@ import (
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/middleware"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/repository"
|
||||
"fashionapi/internal/pkg/pagination"
|
||||
"fashionapi/internal/service"
|
||||
|
||||
@ -24,17 +27,18 @@ const adminCookie = "admin_session"
|
||||
// 定位:朴素、实用、代码简洁的服务端渲染后台,复用现有 service 层读库与鉴权,
|
||||
// 不引入额外前端框架。会话用 access token 存 HttpOnly cookie,由 router 层中间件校验。
|
||||
type BackstageHandler struct {
|
||||
auth service.AuthService
|
||||
brand service.BrandService
|
||||
article service.ArticleService
|
||||
street service.StreetSnapService
|
||||
review service.ReviewService
|
||||
ingest *service.IngestService
|
||||
auth service.AuthService
|
||||
brand service.BrandService
|
||||
article service.ArticleService
|
||||
street service.StreetSnapService
|
||||
review service.ReviewService
|
||||
ingest *service.IngestService
|
||||
adminRepo repository.AdminRepository
|
||||
}
|
||||
|
||||
// NewBackstageHandler 创建后台 handler。
|
||||
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService) *BackstageHandler {
|
||||
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest}
|
||||
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository) *BackstageHandler {
|
||||
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo}
|
||||
}
|
||||
|
||||
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
|
||||
@ -296,7 +300,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||
return
|
||||
}
|
||||
snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
||||
snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||
return
|
||||
@ -304,50 +308,65 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
||||
renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap})
|
||||
}
|
||||
|
||||
// ── 审核记录 ───────────────────────────────────────────────────────────────────
|
||||
// ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)─────────────
|
||||
|
||||
// ReviewList 待审记录列表:按类型(kind,缺省全部)与状态筛选。
|
||||
// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。
|
||||
func (h *BackstageHandler) ReviewList(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
if kind == "" {
|
||||
kind = c.Query("kind")
|
||||
}
|
||||
// Articles 文章管理混合列表:跨 brand_runways / street_snaps 统一展示,按状态 + 类型筛选。
|
||||
// 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 驳回 / 编辑 / 下架都在这里发起。
|
||||
func (h *BackstageHandler) Articles(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
kind := c.Query("kind")
|
||||
if kind != "runway" && kind != "street" {
|
||||
kind = "" // 非法的 kind 回落为「全部」
|
||||
}
|
||||
page := atoiDefault(c.Query("page"), 1)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
// 校验 kind 是否在注册表中;未知 kind 退回「全部」。
|
||||
kinds := h.review.KindTabs(c.Request.Context())
|
||||
registered := map[string]bool{}
|
||||
for _, t := range kinds {
|
||||
if t.Kind != "" {
|
||||
registered[t.Kind] = true
|
||||
}
|
||||
}
|
||||
if kind != "" && kind != "all" && !registered[kind] {
|
||||
kind = ""
|
||||
}
|
||||
records, total, err := h.review.ListRecords(c.Request.Context(), kind, status, page, 20)
|
||||
const size = 24
|
||||
cards, total, err := h.adminRepo.ListMixed(c.Request.Context(), status, kind, page, size)
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 分页链接基址:带 kind 时追加 /{kind},否则裸 /admin/reviews
|
||||
listBase := ""
|
||||
if kind != "" && kind != "all" {
|
||||
listBase = "/" + kind
|
||||
// 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。
|
||||
dupStats := h.dupStatsByCards(c.Request.Context(), cards)
|
||||
items := make([]gin.H, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
var t byte
|
||||
if card.Kind == "runway" {
|
||||
t = hashid.TypeRunway
|
||||
} else {
|
||||
t = hashid.TypeSnap
|
||||
}
|
||||
ds := dupStats[card.ID]
|
||||
dupPct := 0.0
|
||||
if ds.Total > 0 {
|
||||
dupPct = float64(ds.Dup) / float64(ds.Total) * 100
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"Kind": card.Kind,
|
||||
"UID": hashid.EncodeWithType(card.ID, t),
|
||||
"ID": card.ID,
|
||||
"Title": card.Title,
|
||||
"Subtitle": card.Subtitle,
|
||||
"ImageCount": card.ImageCount,
|
||||
"Status": card.Status,
|
||||
"DupCount": ds.Dup,
|
||||
"DupPct": dupPct,
|
||||
})
|
||||
}
|
||||
p := pagination.Params{Page: page, PageSize: 20}
|
||||
// 状态 tab 角标(空串=全部)。
|
||||
tabCounts := map[string]int64{}
|
||||
for _, s := range []string{"", "pending", "published", "rejected"} {
|
||||
n, e := h.adminRepo.CountMixed(c.Request.Context(), s, kind)
|
||||
if e == nil {
|
||||
tabCounts[s] = n
|
||||
}
|
||||
}
|
||||
p := pagination.Params{Page: page, PageSize: size}
|
||||
meta := p.NewMeta(total)
|
||||
renderPage(c, http.StatusOK, "review-list", gin.H{
|
||||
"Records": records,
|
||||
"Total": total,
|
||||
"Status": status,
|
||||
"Kind": kind,
|
||||
"Kinds": kinds,
|
||||
"ListBase": listBase,
|
||||
renderPage(c, http.StatusOK, "articles", gin.H{
|
||||
"Items": items,
|
||||
"Status": status,
|
||||
"Kind": kind,
|
||||
"TabCounts": tabCounts,
|
||||
"Pager": pagination.Pager{
|
||||
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
||||
NextURL: pagination.PageLink(c, meta.NextPage),
|
||||
@ -358,96 +377,74 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ReviewDetail 记录详情:展示预览 + 可编辑字段 + 通过/驳回操作。
|
||||
func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id == 0 {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
|
||||
return
|
||||
// dupStatsByCards 按卡片类型批量取每篇图集的重复统计(is_duplicate=1 张数 / 总存活图片数)。
|
||||
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无统计」(不阻断列表)。
|
||||
func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]repository.DupStat {
|
||||
out := make(map[uint32]repository.DupStat)
|
||||
runwayIDs := make([]uint32, 0, len(cards))
|
||||
streetIDs := make([]uint32, 0, len(cards))
|
||||
for _, c := range cards {
|
||||
if c.Kind == "runway" {
|
||||
runwayIDs = append(runwayIDs, c.ID)
|
||||
} else {
|
||||
streetIDs = append(streetIDs, c.ID)
|
||||
}
|
||||
}
|
||||
record, err := h.review.RecordDetail(c.Request.Context(), kind, uint32(id))
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在或已删除: " + c.Param("id")})
|
||||
return
|
||||
}
|
||||
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。
|
||||
brandName := ""
|
||||
if record.BrandID > 0 {
|
||||
if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(record.BrandID)); berr == nil {
|
||||
brandName = b.NameEn
|
||||
if brandName == "" {
|
||||
brandName = b.NameCn
|
||||
if len(runwayIDs) > 0 {
|
||||
if m, err := h.adminRepo.DupRateByRecords(ctx, "runway", runwayIDs); err == nil {
|
||||
for id, s := range m {
|
||||
out[id] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
renderPage(c, http.StatusOK, "review-detail", gin.H{
|
||||
"Record": record,
|
||||
"BrandName": brandName,
|
||||
})
|
||||
if len(streetIDs) > 0 {
|
||||
if m, err := h.adminRepo.DupRateByRecords(ctx, "street", streetIDs); err == nil {
|
||||
for id, s := range m {
|
||||
out[id] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReviewApprove 通过:先应用审核人的字段微调,再把记录状态置为已发布(单表模型:不再晋升重建)。
|
||||
func (h *BackstageHandler) ReviewApprove(c *gin.Context) {
|
||||
// ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。
|
||||
// 路由标识用 hashid,与编辑页一致;已发布 / 已驳回记录调用仍是幂等的。
|
||||
func (h *BackstageHandler) ArticleApprove(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
id := uint32(atoiDefault(c.Param("id"), 0))
|
||||
if id == 0 {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
|
||||
return
|
||||
}
|
||||
fields := reviewFormFields(c)
|
||||
if err := h.review.UpdateRecord(c.Request.Context(), kind, uint32(id), fields); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||
return
|
||||
}
|
||||
username, _ := c.Get(middleware.ContextUsername)
|
||||
if _, err := h.review.Approve(c.Request.Context(), kind, uint32(id), usernameString(username)); err != nil {
|
||||
if _, err := h.review.Approve(c.Request.Context(), kind, id, usernameString(username)); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, reviewListURL(kind))
|
||||
c.Redirect(http.StatusFound, "/admin/articles?status=pending")
|
||||
}
|
||||
|
||||
// ReviewReject 驳回:仅记录状态与理由。
|
||||
func (h *BackstageHandler) ReviewReject(c *gin.Context) {
|
||||
// ArticleReject 驳回:记录状态与理由(理由用于审核留痕)。
|
||||
func (h *BackstageHandler) ArticleReject(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
id := uint32(atoiDefault(c.Param("id"), 0))
|
||||
if id == 0 {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||
return
|
||||
}
|
||||
reason := c.PostForm("reason")
|
||||
username, _ := c.Get(middleware.ContextUsername)
|
||||
if err := h.review.Reject(c.Request.Context(), kind, uint32(id), usernameString(username), reason); err != nil {
|
||||
if err := h.review.Reject(c.Request.Context(), kind, id, usernameString(username), reason); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, reviewListURL(kind))
|
||||
c.Redirect(http.StatusFound, "/admin/articles?status=pending")
|
||||
}
|
||||
|
||||
// ReviewImageDelete 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||
// 校验 kind 合法后委派给审核服务,成功后跳回记录详情页。
|
||||
func (h *BackstageHandler) ReviewImageDelete(c *gin.Context) {
|
||||
// ArticleAttachImages 把勾选的图并入指定主图(街拍「合并选中为一组」的表单版;AJAX 版见 ArticleAttachImagesAPI)。
|
||||
func (h *BackstageHandler) ArticleAttachImages(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
imgID := atoiDefault(c.Param("img"), 0)
|
||||
if id == 0 || imgID == 0 {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.review.DeleteImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
|
||||
}
|
||||
|
||||
// ReviewAttachImages 把勾选的图并入指定主图(街拍审核页「合并选中为一组」的表单版)。
|
||||
// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。
|
||||
// (AJAX 版同名的 JSON 接口是 ReviewAttachImagesAPI —— 两者响应形态不同,故不能合并成一个。)
|
||||
func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
id := uint32(atoiDefault(c.Param("id"), 0))
|
||||
if id == 0 {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||
return
|
||||
@ -459,48 +456,50 @@ func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
|
||||
imgIDs = append(imgIDs, uint32(v))
|
||||
}
|
||||
}
|
||||
if len(imgIDs) == 0 {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片"})
|
||||
if len(imgIDs) == 0 || mainID == 0 {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片,并在其中一张主图上点选「主图」"})
|
||||
return
|
||||
}
|
||||
if mainID == 0 {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
|
||||
return
|
||||
}
|
||||
if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil {
|
||||
if err := h.review.AttachImages(c.Request.Context(), kind, id, mainID, imgIDs); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
|
||||
c.Redirect(http.StatusFound, articleEditURL(kind, id))
|
||||
}
|
||||
|
||||
// ReviewDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ReviewDetachImageAPI)。
|
||||
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
|
||||
// ArticleDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ArticleDetachImageAPI)。
|
||||
func (h *BackstageHandler) ArticleDetachImage(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
imgID := atoiDefault(c.Param("img"), 0)
|
||||
if id == 0 || imgID == 0 {
|
||||
id := uint32(atoiDefault(c.Param("id"), 0))
|
||||
if id == 0 {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||
imgID := atoiDefault(c.Param("img"), 0)
|
||||
if imgID == 0 {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.review.DetachImage(c.Request.Context(), kind, id, uint32(imgID)); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
|
||||
c.Redirect(http.StatusFound, articleEditURL(kind, id))
|
||||
}
|
||||
|
||||
// reviewDetailURL 生成审核详情页跳转地址。
|
||||
func reviewDetailURL(kind string, id uint32) string {
|
||||
return "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。
|
||||
func reviewListURL(kind string) string {
|
||||
if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet {
|
||||
return "/admin/reviews/" + kind
|
||||
// articleEditURL 生成类型对应的编辑页地址(分组 / 通过 / 驳回操作完成后跳回编辑页)。
|
||||
// 编辑页路由用 hashid 标识记录,故这里把数值主键编码回去。
|
||||
func articleEditURL(kind string, id uint32) string {
|
||||
var t byte
|
||||
if kind == "runway" {
|
||||
t = hashid.TypeRunway
|
||||
} else {
|
||||
t = hashid.TypeSnap
|
||||
}
|
||||
return "/admin/reviews"
|
||||
if kind == "runway" {
|
||||
return "/admin/runways/" + hashid.EncodeWithType(id, t) + "/edit"
|
||||
}
|
||||
return "/admin/street-snaps/" + hashid.EncodeWithType(id, t) + "/edit"
|
||||
}
|
||||
|
||||
// ── 爬虫入库任务监控 ──────────────────────────────────────────────────────────
|
||||
@ -637,12 +636,76 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
||||
brandName = b.NameCn
|
||||
}
|
||||
}
|
||||
cells := make([]gin.H, 0, len(imgs))
|
||||
var dupCount int
|
||||
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
|
||||
detailsByParent := map[uint32][]model.BrandRunwayImage{}
|
||||
var mains []model.BrandRunwayImage
|
||||
for _, im := range imgs {
|
||||
if im.IsDuplicate == 1 {
|
||||
dupCount++
|
||||
}
|
||||
if im.IsDetail == 1 {
|
||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||
} else {
|
||||
mains = append(mains, im)
|
||||
}
|
||||
}
|
||||
mainIDs := map[uint32]bool{}
|
||||
for _, m := range mains {
|
||||
mainIDs[m.ID] = true
|
||||
cells = append(cells, gin.H{
|
||||
"id": m.ID,
|
||||
"image": m.Image,
|
||||
"name": m.Name,
|
||||
"isDetail": 0,
|
||||
"role": "主图",
|
||||
"DetCount": len(detailsByParent[m.ID]),
|
||||
})
|
||||
for _, d := range detailsByParent[m.ID] {
|
||||
cells = append(cells, gin.H{
|
||||
"id": d.ID,
|
||||
"image": d.Image,
|
||||
"name": d.Name,
|
||||
"isDetail": 1,
|
||||
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
|
||||
"parent": d.ParentImageID,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 孤立副图(父主图缺失)仍保留并默认显示,避免丢图。
|
||||
for pid, ds := range detailsByParent {
|
||||
if mainIDs[pid] {
|
||||
continue
|
||||
}
|
||||
for _, d := range ds {
|
||||
cells = append(cells, gin.H{
|
||||
"id": d.ID,
|
||||
"image": d.Image,
|
||||
"name": d.Name,
|
||||
"isDetail": 1,
|
||||
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
|
||||
"parent": d.ParentImageID,
|
||||
"orphan": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
dupPct := 0.0
|
||||
if n := len(imgs); n > 0 {
|
||||
dupPct = float64(dupCount) / float64(n) * 100
|
||||
}
|
||||
renderPage(c, http.StatusOK, "runway-edit", gin.H{
|
||||
"UID": raw,
|
||||
"Kind": "runway",
|
||||
"RecordID": rw.ID,
|
||||
"Status": rw.Status,
|
||||
"Runway": rw,
|
||||
"Images": imgs,
|
||||
"Cells": cells,
|
||||
"BrandName": brandName,
|
||||
"IsDown": rw.IsDeleted == 1,
|
||||
"DupCount": dupCount,
|
||||
"DupPct": dupPct,
|
||||
})
|
||||
}
|
||||
|
||||
@ -674,7 +737,7 @@ func (h *BackstageHandler) RunwayUpdate(c *gin.Context) {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
|
||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
|
||||
}
|
||||
|
||||
// RunwayImageDelete 软删单张走秀图片。
|
||||
@ -693,7 +756,7 @@ func (h *BackstageHandler) RunwayImageDelete(c *gin.Context) {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
|
||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
|
||||
}
|
||||
|
||||
// RunwaySetCover 把某张图片设为封面。
|
||||
@ -713,7 +776,7 @@ func (h *BackstageHandler) RunwaySetCover(c *gin.Context) {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
|
||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
|
||||
}
|
||||
|
||||
// RunwayToggleDeleted 上下架(下架=is_deleted=1 对外不可见)。
|
||||
@ -732,7 +795,7 @@ func (h *BackstageHandler) RunwayToggleDeleted(c *gin.Context) {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
|
||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
|
||||
}
|
||||
|
||||
// ── 街拍编辑 ─────────────────────────────────────────────────────────────────────
|
||||
@ -750,11 +813,75 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||
return
|
||||
}
|
||||
cells := make([]gin.H, 0, len(imgs))
|
||||
var dupCount int
|
||||
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
|
||||
detailsByParent := map[uint32][]model.StreetSnapImage{}
|
||||
var mains []model.StreetSnapImage
|
||||
for _, im := range imgs {
|
||||
if im.IsDuplicate == 1 {
|
||||
dupCount++
|
||||
}
|
||||
if im.IsDetail == 1 {
|
||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||
} else {
|
||||
mains = append(mains, im)
|
||||
}
|
||||
}
|
||||
mainIDs := map[uint32]bool{}
|
||||
for _, m := range mains {
|
||||
mainIDs[m.ID] = true
|
||||
cells = append(cells, gin.H{
|
||||
"id": m.ID,
|
||||
"image": m.Image,
|
||||
"name": m.Name,
|
||||
"isDetail": 0,
|
||||
"role": "主图",
|
||||
"DetCount": len(detailsByParent[m.ID]),
|
||||
})
|
||||
for _, d := range detailsByParent[m.ID] {
|
||||
cells = append(cells, gin.H{
|
||||
"id": d.ID,
|
||||
"image": d.Image,
|
||||
"name": d.Name,
|
||||
"isDetail": 1,
|
||||
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
|
||||
"parent": d.ParentImageID,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 孤立副图(父主图缺失)仍保留并默认显示,避免丢图。
|
||||
for pid, ds := range detailsByParent {
|
||||
if mainIDs[pid] {
|
||||
continue
|
||||
}
|
||||
for _, d := range ds {
|
||||
cells = append(cells, gin.H{
|
||||
"id": d.ID,
|
||||
"image": d.Image,
|
||||
"name": d.Name,
|
||||
"isDetail": 1,
|
||||
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
|
||||
"parent": d.ParentImageID,
|
||||
"orphan": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
dupPct := 0.0
|
||||
if n := len(imgs); n > 0 {
|
||||
dupPct = float64(dupCount) / float64(n) * 100
|
||||
}
|
||||
renderPage(c, http.StatusOK, "snap-edit", gin.H{
|
||||
"UID": raw,
|
||||
"Snap": snap,
|
||||
"Images": imgs,
|
||||
"IsDown": snap.IsDeleted == 1,
|
||||
"UID": raw,
|
||||
"Kind": "street",
|
||||
"RecordID": snap.ID,
|
||||
"Status": snap.Status,
|
||||
"Snap": snap,
|
||||
"Images": imgs,
|
||||
"Cells": cells,
|
||||
"IsDown": snap.IsDeleted == 1,
|
||||
"DupCount": dupCount,
|
||||
"DupPct": dupPct,
|
||||
})
|
||||
}
|
||||
|
||||
@ -773,15 +900,16 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
fields := map[string]any{
|
||||
"title": c.PostForm("title"),
|
||||
"city": c.PostForm("city"),
|
||||
"year": year,
|
||||
"title": c.PostForm("title"),
|
||||
"title_cn": c.PostForm("title_cn"),
|
||||
"city": c.PostForm("city"),
|
||||
"year": year,
|
||||
}
|
||||
if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
|
||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
|
||||
}
|
||||
|
||||
// StreetSnapImageDelete 软删单张街拍图片。
|
||||
@ -800,7 +928,7 @@ func (h *BackstageHandler) StreetSnapImageDelete(c *gin.Context) {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
|
||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
|
||||
}
|
||||
|
||||
// StreetSnapSetCover 把某张图片设为封面。
|
||||
@ -820,7 +948,7 @@ func (h *BackstageHandler) StreetSnapSetCover(c *gin.Context) {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
|
||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
|
||||
}
|
||||
|
||||
// StreetSnapToggleDeleted 上下架。
|
||||
@ -839,7 +967,7 @@ func (h *BackstageHandler) StreetSnapToggleDeleted(c *gin.Context) {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
|
||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
|
||||
}
|
||||
|
||||
// atoiDefault 把字符串转 int,失败回落默认值。
|
||||
|
||||
@ -24,20 +24,17 @@ import (
|
||||
// (renderPage 会 500 "unknown backstage page: xxx")。
|
||||
func TestBackstageTemplatesParsed(t *testing.T) {
|
||||
want := []string{
|
||||
"articles",
|
||||
"brand-form",
|
||||
"brands",
|
||||
"dashboard",
|
||||
"error",
|
||||
"ingest-jobs",
|
||||
"login",
|
||||
"review-detail",
|
||||
"review-list",
|
||||
"runway-detail",
|
||||
"runway-edit",
|
||||
"runways",
|
||||
"snap-edit",
|
||||
"street-snap-detail",
|
||||
"street-snaps",
|
||||
"users",
|
||||
}
|
||||
got := PageNames()
|
||||
@ -74,7 +71,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
||||
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
|
||||
"Images": []any{},
|
||||
// 列表数据
|
||||
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{},
|
||||
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
|
||||
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
|
||||
"Article": &dto.PublicArticleDetail{UID: "abc123"},
|
||||
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
|
||||
@ -88,19 +85,20 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
||||
|
||||
for _, page := range PageNames() {
|
||||
for _, kind := range []string{"street", "runway"} {
|
||||
if page != "review-detail" && kind == "runway" {
|
||||
continue // 只有审核详情页按 kind 分流
|
||||
}
|
||||
name := page
|
||||
if page == "review-detail" {
|
||||
name = page + "/" + kind
|
||||
}
|
||||
name := page + "/" + kind
|
||||
t.Run(name, func(t *testing.T) {
|
||||
data := cloneData(base)
|
||||
for k, v := range perPage[page] {
|
||||
data[k] = v
|
||||
}
|
||||
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
|
||||
// 图片管理块已抽到 imageGrid 共用片段,需要 handler 实际注入的字段:
|
||||
data["Kind"] = kind
|
||||
data["RecordID"] = uint32(7)
|
||||
data["UID"] = "abc123"
|
||||
data["Status"] = "pending"
|
||||
data["Cells"] = []gin.H{}
|
||||
data["BrandName"] = "Brand"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@ -58,9 +58,13 @@ func queryBool(c *gin.Context, key string, def bool) bool {
|
||||
return v == "1" || v == "true"
|
||||
}
|
||||
|
||||
// parseLocale 解析请求语言,优先级:?locale=cn|en > Accept-Language > 默认 en。
|
||||
// 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
|
||||
// parseLocale 解析请求语言,优先级:X-Locale 头 > ?locale=cn|en > Accept-Language > 默认 en。
|
||||
// 与前端 i18n 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
|
||||
// X-Locale 由前端统一经请求头透传「当前页面语言」,显式且不受浏览器语言设置干扰。
|
||||
func parseLocale(c *gin.Context) string {
|
||||
if l := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Locale"))); l == "cn" || l == "en" {
|
||||
return l
|
||||
}
|
||||
if l := strings.ToLower(strings.TrimSpace(c.Query("locale"))); l == "cn" || l == "en" {
|
||||
return l
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@ func (h *SSGHandler) HotBrands(c *gin.Context) {
|
||||
// GET /api/v1/ssg/street-snaps/popular
|
||||
func (h *SSGHandler) StreetSnapPopular(c *gin.Context) {
|
||||
const limit = 20
|
||||
list, err := h.snaps.Popular(c.Request.Context(), limit)
|
||||
list, err := h.snaps.Popular(c.Request.Context(), limit, parseLocale(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
|
||||
@ -82,12 +82,12 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
||||
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
|
||||
if err != nil {
|
||||
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 s= 前缀(旧 EncodeTyped)。
|
||||
// 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 snap)。
|
||||
if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeSnap {
|
||||
if d2, e2 := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10)); e2 == nil {
|
||||
if d2, e2 := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10), parseLocale(c)); e2 == nil {
|
||||
detail, err = d2, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,9 +24,23 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"fashionapi/internal/pkg/imgurl"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// imgComposer 图片 URL 拼装器(库里只存 S4 key / 本地 /uploads 路径,渲染时再拼 base_url 与样式)。
|
||||
// 默认按本地兜底初始化;main 装配期会调用 SetImgComposer 注入真实配置(含 S4 BaseURL)。
|
||||
var imgComposer = imgurl.New("", "high", "thumb")
|
||||
|
||||
// SetImgComposer 由 main 在装配期注入图片 URL 拼装器(S4 启用时含对外访问域名)。
|
||||
// 后台模板通过 imgURL / imgThumb 函数渲染图片,避免直接裸拼存储值导致 S4 模式下打不开。
|
||||
func SetImgComposer(c *imgurl.Composer) {
|
||||
if c != nil {
|
||||
imgComposer = c
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
|
||||
var backstageFS embed.FS
|
||||
|
||||
@ -42,8 +56,10 @@ var assetFS fs.FS
|
||||
|
||||
func init() {
|
||||
funcs := template.FuncMap{
|
||||
// reviewDetailURL 生成审核详情页地址(partials / 页面模板里都会用到)。
|
||||
"reviewDetailURL": reviewDetailURL,
|
||||
// imgURL 把数据库里的图片存储值(S4 key / /uploads 路径 / 外链)拼成可访问完整 URL;
|
||||
// imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。
|
||||
"imgURL": func(stored string) string { return imgComposer.Compose(stored) },
|
||||
"imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) },
|
||||
}
|
||||
|
||||
base, err := template.New("layout.html").Funcs(funcs).
|
||||
@ -94,7 +110,7 @@ func PageNames() []string {
|
||||
|
||||
// renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。
|
||||
//
|
||||
// page 取 pages/ 下的文件名(如 "review-list");data 为页面数据,renderPage 会补上
|
||||
// page 取 pages/ 下的文件名(如 "articles");data 为页面数据,renderPage 会补上
|
||||
// NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。
|
||||
func renderPage(c *gin.Context, status int, page string, data gin.H) {
|
||||
tpl, ok := pageTemplates[page]
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
<body>
|
||||
{{template "content" .}}
|
||||
<div id="toast" class="flash ok" hidden></div>
|
||||
<dialog id="zoom-modal"><img id="zoom-img" alt=""></dialog>
|
||||
<script src="/admin/assets/admin.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
59
internal/handler/templates/pages/articles.html
Normal file
59
internal/handler/templates/pages/articles.html
Normal file
@ -0,0 +1,59 @@
|
||||
{{define "title"}}文章管理 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 文章管理{{end}}
|
||||
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<h1>文章管理</h1>
|
||||
<p class="muted">走秀与街拍合并管理:通过 / 驳回 / 编辑 / 下架都在此处发起。待审核优先排在前面。</p>
|
||||
<div class="kinds">
|
||||
<a href="/admin/articles?status={{.Status}}" class="{{if eq .Kind ""}}on{{end}}">全部类型</a>
|
||||
<a href="/admin/articles?status={{.Status}}&kind=runway" class="{{if eq .Kind "runway"}}on{{end}}">走秀</a>
|
||||
<a href="/admin/articles?status={{.Status}}&kind=street" class="{{if eq .Kind "street"}}on{{end}}">街拍</a>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<a href="/admin/articles?status=&kind={{.Kind}}" class="{{if eq .Status ""}}on{{end}}">全部{{if gt (index .TabCounts "") 0}}({{index .TabCounts ""}}){{end}}</a>
|
||||
<a href="/admin/articles?status=pending&kind={{.Kind}}" class="{{if eq .Status "pending"}}on{{end}}">待审核{{if gt (index .TabCounts "pending") 0}}({{index .TabCounts "pending"}}){{end}}</a>
|
||||
<a href="/admin/articles?status=published&kind={{.Kind}}" class="{{if eq .Status "published"}}on{{end}}">已发布{{if gt (index .TabCounts "published") 0}}({{index .TabCounts "published"}}){{end}}</a>
|
||||
<a href="/admin/articles?status=rejected&kind={{.Kind}}" class="{{if eq .Status "rejected"}}on{{end}}">已驳回{{if gt (index .TabCounts "rejected") 0}}({{index .TabCounts "rejected"}}){{end}}</a>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
|
||||
<th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数</th><th>重复率</th><th>状态</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
|
||||
<td><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
|
||||
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td>
|
||||
<td><code>{{.ID}}</code></td>
|
||||
<td>{{.Title}}</td>
|
||||
<td class="muted">{{.Subtitle}}</td>
|
||||
<td>{{.ImageCount}}</td>
|
||||
<td>{{if .DupCount}}<span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}})</span>{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td>
|
||||
<div class="ops">
|
||||
{{if eq .Kind "runway"}}<a class="view" href="/admin/runways/{{.UID}}/edit">编辑</a>{{else}}<a class="view" href="/admin/street-snaps/{{.UID}}/edit">编辑</a>{{end}}
|
||||
{{if eq .Status "pending"}}<form class="inline" method="post" action="/admin/articles/{{.Kind}}/{{.ID}}/approve"><button type="submit" class="op op-primary">通过</button></form>{{end}}
|
||||
{{if eq .Status "pending"}}<form class="inline" method="post" action="/admin/articles/{{.Kind}}/{{.ID}}/reject"><button type="submit" class="op op-danger">驳回</button></form>{{end}}
|
||||
{{if eq .Kind "runway"}}<form class="inline" method="post" action="/admin/runways/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认下架(对外不可见)?')">下架</button></form>{{else}}<form class="inline" method="post" action="/admin/street-snaps/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认下架(对外不可见)?')">下架</button></form>{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{template "pager" .Pager}}
|
||||
|
||||
<div class="batchbar" id="batchbar" hidden>
|
||||
<span class="muted" id="sel-count">已选 0 条</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" id="batch-approve">批量通过</button>
|
||||
<button type="button" id="batch-reject" class="reject">批量驳回…</button>
|
||||
<button type="button" id="batch-clear" class="ghost">清空选择</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -8,9 +8,7 @@
|
||||
{{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}}
|
||||
<div class="cards">
|
||||
<a class="card" href="/admin/brands"><div class="t">品牌管理</div><div class="d">查看 / 搜索全部品牌</div></a>
|
||||
<a class="card" href="/admin/runways"><div class="t">走秀档案</div><div class="d">按系列 / 季节 / 年份筛选</div></a>
|
||||
<a class="card" href="/admin/street-snaps"><div class="t">街拍</div><div class="d">按城市 / 年份筛选</div></a>
|
||||
<a class="card" href="/admin/reviews"><div class="t">待审草稿{{if gt .PendingReviews 0}}({{.PendingReviews}}){{end}}</div><div class="d">爬虫入库待人工审核</div></a>
|
||||
<a class="card" href="/admin/articles"><div class="t">文章管理{{if gt .PendingReviews 0}}(待审 {{.PendingReviews}}){{end}}</div><div class="d">走秀 / 街拍:通过 · 驳回 · 编辑 · 下架</div></a>
|
||||
<a class="card" href="/admin/ingest-jobs"><div class="t">入库任务</div><div class="d">爬虫上报处理进度 / 失败重试</div></a>
|
||||
<a class="card" href="/admin/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
|
||||
</div>
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
{{define "title"}}草稿审核 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 草稿审核 #{{.Record.ID}}{{end}}
|
||||
{{define "barRight"}}<a href="{{reviewDetailURL .Record.Kind .Record.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main mid">
|
||||
<p class="muted">类型 <b>{{.Record.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Record.Status}}</b></p>
|
||||
|
||||
{{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}}
|
||||
<form method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/approve">
|
||||
{{range .Record.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
|
||||
<button type="submit">通过并发布</button>
|
||||
</form>
|
||||
|
||||
{{if eq .Record.Kind "street"}}
|
||||
<div class="hint">
|
||||
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
|
||||
快捷键:<b>j</b>/<b>k</b> 上下移动 · <b>x</b> 勾选 · <b>m</b> 设为主图 · <b>d</b> 拆出 · <b>Enter</b> 看大图 · <b>a</b> 通过
|
||||
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
|
||||
</div>
|
||||
|
||||
<h2>图片(<span id="img-stat">{{len .Record.Images}} 张 · {{len .Record.Groups}} 组</span>)</h2>
|
||||
{{/* 合并表单本体在 grid 之前;各格的 radio/checkbox 通过 form="batch" 关联过来 */}}
|
||||
<form id="batch" method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/images/attach"></form>
|
||||
<div class="grid">
|
||||
{{range .Record.Images}}<div class="cell{{if eq .IsDetail 1}} det{{end}}" data-img-id="{{.ID}}">
|
||||
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
|
||||
<span class="role">{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}}</span>
|
||||
<span class="nm">{{.Name}}</span>
|
||||
<div class="acts">
|
||||
{{if eq .IsDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}}
|
||||
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.ID}}"> 选入</label>
|
||||
{{if eq .IsDetail 1}}<form data-only="detail" method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}}
|
||||
<form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="batchbar">
|
||||
<button type="submit" form="batch">合并选中为一组</button>
|
||||
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<h2>图片管理({{len .Record.Images}} 张)</h2>
|
||||
<div class="grid">
|
||||
{{range .Record.Images}}<div class="cell" data-img-id="{{.ID}}">
|
||||
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
|
||||
<span class="nm">{{.Name}}</span>
|
||||
<div class="acts">
|
||||
<form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@ -1,50 +0,0 @@
|
||||
{{define "title"}}待审草稿 · 管理后台{{end}}
|
||||
{{define "barLabel"}}待审草稿{{end}}
|
||||
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<h1>待审草稿(共 {{.Total}} 条)</h1>
|
||||
<div class="kinds">
|
||||
{{range .Kinds}}<a href="{{if eq .Kind ""}}/admin/reviews{{else}}/admin/reviews/{{.Kind}}{{end}}" class="{{if or (eq $.Kind .Kind) (eq $.Kind "all")}}on{{end}}">{{.Label}}{{if gt .Pending 0}}({{.Pending}}){{end}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<a href="/admin/reviews{{.ListBase}}" class="{{if eq .Status ""}}on{{end}}">全部</a>
|
||||
<a href="/admin/reviews{{.ListBase}}?status=pending" class="{{if eq .Status "pending"}}on{{end}}">待审核</a>
|
||||
<a href="/admin/reviews{{.ListBase}}?status=published" class="{{if eq .Status "published"}}on{{end}}">已通过</a>
|
||||
<a href="/admin/reviews{{.ListBase}}?status=rejected" class="{{if eq .Status "rejected"}}on{{end}}">已驳回</a>
|
||||
</div>
|
||||
|
||||
{{/* 多选 + 批量操作:勾选后底部出现操作条(admin.js 接管;无 JS 时单条「查看/审核」照常可用) */}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
|
||||
<th>ID</th><th>类型</th><th>标题</th><th>副标题</th><th>图数</th><th>状态</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Records}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
|
||||
<td><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
|
||||
<td><code>{{.ID}}</code></td>
|
||||
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td>
|
||||
<td>{{.Title}}</td>
|
||||
<td class="muted">{{.Subtitle}}</td>
|
||||
<td>{{.ImageCount}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td><a class="view" href="/admin/reviews/{{.Kind}}/{{.ID}}">查看/审核</a>{{if eq .Status "pending"}} · <button type="button" class="btn" data-quick-approve title="按当前内容直接发布">通过</button>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{template "pager" .Pager}}
|
||||
|
||||
<div class="batchbar" id="batchbar" hidden>
|
||||
<span class="muted" id="sel-count">已选 0 条</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" id="batch-approve">批量通过</button>
|
||||
<button type="button" id="batch-reject" class="reject">批量驳回…</button>
|
||||
<button type="button" id="batch-clear" class="ghost">清空选择</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -1,13 +1,13 @@
|
||||
{{define "title"}}走秀详情 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 走秀详情{{end}}
|
||||
{{define "barRight"}}<a href="/admin/runways">返回列表</a> · <a href="/admin/runways/{{.Article.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "barRight"}}<a href="/admin/articles">返回列表</a> · <a href="/admin/runways/{{.Article.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
|
||||
<h1>{{.Article.Title}}</h1>
|
||||
<div class="grid">
|
||||
{{range .Article.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{end}}
|
||||
{{range .Article.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -13,16 +13,14 @@
|
||||
<button type="submit">保存字段</button>
|
||||
</form>
|
||||
|
||||
<h2>图片管理({{len .Images}} 张)</h2>
|
||||
<div class="grid">
|
||||
{{range .Images}}<div class="cell">
|
||||
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
|
||||
<div class="acts">
|
||||
<form method="post" action="/admin/runways/{{$.UID}}/cover"><input type="hidden" name="image" value="{{.Image}}"><button type="submit">设封面</button></form>
|
||||
<form method="post" action="/admin/runways/{{$.UID}}/images/{{.ID}}/delete"><button type="submit" class="reject">删除</button></form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
{{template "imageGrid" .}}
|
||||
|
||||
{{if eq .Runway.Status "pending"}}
|
||||
<div class="review-actions">
|
||||
<form method="post" action="/admin/articles/runway/{{.Runway.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
||||
<form method="post" action="/admin/articles/runway/{{.Runway.ID}}/reject"><input name="reason" placeholder="驳回理由(可选)"><button type="submit" class="op op-danger">驳回</button></form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px">
|
||||
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
{{define "title"}}走秀档案 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 走秀档案{{end}}
|
||||
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<h1>走秀列表</h1>
|
||||
<form class="toolbar" method="get" action="/admin/runways">
|
||||
<select name="collection">
|
||||
<option value="" {{if eq .Collection ""}}selected{{end}}>全部系列</option>
|
||||
<option value="rtw" {{if eq .Collection "rtw"}}selected{{end}}>RTW 成衣</option>
|
||||
<option value="menswear" {{if eq .Collection "menswear"}}selected{{end}}>男装</option>
|
||||
<option value="couture" {{if eq .Collection "couture"}}selected{{end}}>高定</option>
|
||||
<option value="resort" {{if eq .Collection "resort"}}selected{{end}}>度假</option>
|
||||
<option value="pre_fall" {{if eq .Collection "pre_fall"}}selected{{end}}>早秋</option>
|
||||
</select>
|
||||
<select name="season">
|
||||
<option value="" {{if eq .Season ""}}selected{{end}}>全部季节</option>
|
||||
<option value="spring" {{if eq .Season "spring"}}selected{{end}}>春夏</option>
|
||||
<option value="fall" {{if eq .Season "fall"}}selected{{end}}>秋冬</option>
|
||||
</select>
|
||||
<input name="year" placeholder="年份" value="{{if gt .Year 0}}{{.Year}}{{end}}" style="width:90px">
|
||||
<select name="sort">
|
||||
<option value="" {{if eq .Sort ""}}selected{{end}}>默认排序</option>
|
||||
<option value="year_desc" {{if eq .Sort "year_desc"}}selected{{end}}>年份 ↓</option>
|
||||
<option value="year_asc" {{if eq .Sort "year_asc"}}selected{{end}}>年份 ↑</option>
|
||||
</select>
|
||||
<button type="submit">筛选</button>
|
||||
<a href="/admin/runways">清除</a>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr><th>编码 ID</th><th>品牌</th><th>标题</th><th>系列</th><th>季节</th><th>年份</th><th>季节码</th><th>图数</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "pager" .Pager}}
|
||||
</div>
|
||||
{{end}}
|
||||
@ -6,21 +6,19 @@
|
||||
<div class="main">
|
||||
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
|
||||
<form method="post" action="/admin/street-snaps/{{.UID}}/edit">
|
||||
<div><label>标题</label><input name="title" value="{{.Snap.Title}}"></div>
|
||||
<div class="row"><div><label>英文标题</label><input name="title" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Snap.TitleCn}}"></div></div>
|
||||
<div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div>
|
||||
<button type="submit">保存字段</button>
|
||||
</form>
|
||||
|
||||
<h2>图片管理({{len .Images}} 张)</h2>
|
||||
<div class="grid">
|
||||
{{range .Images}}<div class="cell">
|
||||
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
|
||||
<div class="acts">
|
||||
<form method="post" action="/admin/street-snaps/{{$.UID}}/cover"><input type="hidden" name="image" value="{{.Image}}"><button type="submit">设封面</button></form>
|
||||
<form method="post" action="/admin/street-snaps/{{$.UID}}/images/{{.ID}}/delete"><button type="submit" class="reject">删除</button></form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
{{template "imageGrid" .}}
|
||||
|
||||
{{if eq .Snap.Status "pending"}}
|
||||
<div class="review-actions">
|
||||
<form method="post" action="/admin/articles/street/{{.Snap.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
||||
<form method="post" action="/admin/articles/street/{{.Snap.ID}}/reject"><input name="reason" placeholder="驳回理由(可选)"><button type="submit" class="op op-danger">驳回</button></form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px">
|
||||
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
{{define "title"}}街拍详情 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 街拍详情{{end}}
|
||||
{{define "barRight"}}<a href="/admin/street-snaps">返回列表</a> · <a href="/admin/street-snaps/{{.Snap.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "barRight"}}<a href="/admin/articles">返回列表</a> · <a href="/admin/street-snaps/{{.Snap.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<p class="muted">编码 {{.Snap.UID}}</p>
|
||||
<h1>{{.Snap.Title}}</h1>
|
||||
<div class="grid">
|
||||
{{range .Snap.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{end}}
|
||||
{{range .Snap.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
{{define "title"}}街拍 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 街拍{{end}}
|
||||
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<h1>街拍列表</h1>
|
||||
<form class="toolbar" method="get" action="/admin/street-snaps">
|
||||
<input name="city" placeholder="城市" value="{{.City}}">
|
||||
<input name="year" placeholder="年份" value="{{if gt .Year 0}}{{.Year}}{{end}}" style="width:90px">
|
||||
<select name="sort">
|
||||
<option value="" {{if eq .Sort ""}}selected{{end}}>默认(图最多)</option>
|
||||
<option value="year_desc" {{if eq .Sort "year_desc"}}selected{{end}}>年份 ↓</option>
|
||||
<option value="year_asc" {{if eq .Sort "year_asc"}}selected{{end}}>年份 ↑</option>
|
||||
</select>
|
||||
<button type="submit">筛选</button>
|
||||
<a href="/admin/street-snaps">清除</a>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr><th>编码 ID</th><th>标题</th><th>图片数</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "pager" .Pager}}
|
||||
</div>
|
||||
{{end}}
|
||||
@ -16,9 +16,7 @@
|
||||
<span class="bar-title">{{template "barLabel" .}}</span>
|
||||
<nav class="bar-nav">
|
||||
<a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a>
|
||||
<a href="/admin/reviews" class="{{if or (eq .NavKey "review-list") (eq .NavKey "review-detail")}}on{{end}}">审核{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a>
|
||||
<a href="/admin/runways" class="{{if or (eq .NavKey "runways") (eq .NavKey "runway-detail") (eq .NavKey "runway-edit")}}on{{end}}">走秀</a>
|
||||
<a href="/admin/street-snaps" class="{{if or (eq .NavKey "street-snaps") (eq .NavKey "street-snap-detail") (eq .NavKey "snap-edit")}}on{{end}}">街拍</a>
|
||||
<a href="/admin/articles" class="{{if or (eq .NavKey "articles") (eq .NavKey "runway-edit") (eq .NavKey "snap-edit") (eq .NavKey "runway-detail") (eq .NavKey "street-snap-detail")}}on{{end}}">文章{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a>
|
||||
<a href="/admin/brands" class="{{if or (eq .NavKey "brands") (eq .NavKey "brand-form")}}on{{end}}">品牌</a>
|
||||
<a href="/admin/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a>
|
||||
<a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a>
|
||||
@ -35,3 +33,47 @@
|
||||
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/*
|
||||
共享片段:街拍 / 走秀编辑页共用的「图片管理」块(网格 + 合并为一组 + 拆出)。
|
||||
两个后台编辑页之前各写一份几乎相同的图片管理 HTML,现已抽到这里,改一处全局生效。
|
||||
|
||||
页面需要提供的字段:
|
||||
.Kind "street" | "runway"(决定封面/删除路由前缀与主/副图文案)
|
||||
.UID 记录的 hashid(用于 /admin/{street-snaps|runways}/<UID>/... 路由)
|
||||
.RecordID 记录数字 id(用于 /admin/articles/<kind>/<id>/... 路由)
|
||||
.Status 记录状态(pending 时显示待审角标)
|
||||
.Cells []gin.H,每项 {id, image, name, isDetail, role}
|
||||
*/}}
|
||||
{{define "imageGrid"}}
|
||||
<h2>图片管理(<span id="img-stat">{{len .Cells}} 张</span>{{if eq .Status "pending"}} · <span class="badge pending">待审核</span>{{end}}{{if .DupCount}} · 重复率 <span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}} 张)</span>{{end}})</h2>
|
||||
<div class="hint">
|
||||
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
|
||||
{{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}}
|
||||
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
|
||||
</div>
|
||||
{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}}
|
||||
<form id="batch" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/attach"></form>
|
||||
<div class="grid">
|
||||
{{range .Cells}}<div class="cell{{if eq .isDetail 1}} det{{if .orphan}} shown{{end}}{{end}}" data-img-id="{{.id}}"{{if eq .isDetail 1}} data-parent="{{.parent}}"{{end}}>
|
||||
<button class="thumb" type="button" data-zoom><img src="{{imgURL .image}}" loading="lazy"></button>
|
||||
<span class="role">{{.role}}</span>
|
||||
<span class="nm">{{.name}}</span>
|
||||
<div class="acts">
|
||||
{{if eq $kind "runway"}}<form method="post" action="/admin/runways/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
|
||||
<form method="post" action="/admin/runways/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
|
||||
{{else}}<form method="post" action="/admin/street-snaps/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
|
||||
<form method="post" action="/admin/street-snaps/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
|
||||
{{end}}
|
||||
{{if eq .isDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.id}}"> 主图</label>{{end}}
|
||||
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.id}}"> 选入</label>
|
||||
{{if and (eq .isDetail 0) .DetCount}}<button type="button" class="toggle-det" data-parent="{{.id}}">显示副图({{.DetCount}} 张)</button>{{end}}
|
||||
{{if eq .isDetail 1}}<form data-only="detail" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/{{.id}}/detach"><button type="submit">拆出</button></form>{{end}}
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
<div class="batchbar">
|
||||
<button type="submit" form="batch">合并选中为一组</button>
|
||||
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user