update
This commit is contained in:
@ -79,12 +79,10 @@ type PublicArticleImage struct {
|
||||
Thumb string `json:"thumb,omitempty"`
|
||||
Name string `json:"name"`
|
||||
IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图,细节图挂在所属主图的 Detail 下)
|
||||
LookIndex uint32 `json:"look_index"` // 该图归属的主图序号;细节图与所属主图同值
|
||||
Favorited bool `json:"favorited,omitempty"` // 已登录时该图片是否被当前用户收藏(image 级)
|
||||
|
||||
// Detail 仅详情接口填充:该主图(look)下的细节图子集,元素形状与 Images 递归同构。
|
||||
// 「look → 细节」的层级与前端 UI 一致,前端直接读它即可,无需再维护一份
|
||||
// 「按 look_index 分组」的映射表。列表接口不填(omitempty 后不出现)。
|
||||
// Detail 仅详情接口填充:该主图下的细节图子集,元素形状与 Images 递归同构。
|
||||
// 前端直接读它即可,无需再维护一份分组映射表。列表接口不填(omitempty 后不出现)。
|
||||
Detail []PublicArticleImage `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -13,8 +13,12 @@ type BrandRunwayImage struct {
|
||||
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增
|
||||
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号(主图=该 look;细节图=所属主图的 look)
|
||||
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 1=细节图
|
||||
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图 1=细节图
|
||||
|
||||
// 主副图分组(列由 db/migrations/2026-09-24-01-runway-parent-image-id.sql 添加):
|
||||
// 与 street_snap_images 同构——is_detail=0 为主图(parent_image_id=0);
|
||||
// =1 为细节图,parent_image_id 指向同 runway 内主图的行 id。Look 序号对产品无意义,已移除。
|
||||
ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"`
|
||||
|
||||
// 去重字段(近重复 + 语义 embedding 留痕):
|
||||
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;
|
||||
|
||||
@ -8,7 +8,8 @@ import "database/sql"
|
||||
// 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。
|
||||
type StreetSnap struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
Title string `gorm:"column:title" json:"title"`
|
||||
Title string `gorm:"column:title" json:"title"` // 英文标题(既有源,改动前唯一标题列)
|
||||
TitleCn string `gorm:"column:title_cn" json:"title_cn"` // 中文标题:后台人工翻译;为空时前端/接口回落英文
|
||||
Year uint16 `gorm:"column:year" json:"year"`
|
||||
City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类
|
||||
Cover string `gorm:"column:cover" json:"cover"`
|
||||
|
||||
@ -22,7 +22,8 @@ import (
|
||||
const Size = 64
|
||||
|
||||
// DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。
|
||||
const DefaultThreshold = 10
|
||||
// 历史取值:10(明显过松,实测混入大量噪声)→ 4(用户指定,收紧判定)。
|
||||
const DefaultThreshold = 4
|
||||
|
||||
// Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。
|
||||
// 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。
|
||||
|
||||
174
internal/repository/admin_mixed_integration_test.go
Normal file
174
internal/repository/admin_mixed_integration_test.go
Normal file
@ -0,0 +1,174 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:文章管理混合列表必须跨 brand_runways / street_snaps 统一排序与过滤。
|
||||
//
|
||||
// 运行(库需先就绪,见 dedup_integration_test.go 顶部说明):
|
||||
//
|
||||
// go test -tags integration ./internal/repository/ -run TestAdminMixed -v
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// seedMixedRecords 各插一条走秀(已发布)与街拍(待审),返回它们的内部 id。
|
||||
// 用测试专用 city / season_code 隔离,避免与库里既有行撞唯一实体键。
|
||||
func seedMixedRecords(t *testing.T, db *gorm.DB) (runwayID, streetID uint32) {
|
||||
t.Helper()
|
||||
if err := db.Raw(`
|
||||
INSERT INTO brand_runways (brand_id, title_en, image_count, status, is_deleted, year, season_code, collection_type, created_at)
|
||||
VALUES (999999, 'AdminRepoTestRunway', 3, ?, 0, 2099, 'ZZZ', 'test', 1800000000)
|
||||
RETURNING id`, "published").Scan(&runwayID).Error; err != nil {
|
||||
t.Fatalf("插入走秀种子失败: %v", err)
|
||||
}
|
||||
if err := db.Raw(`
|
||||
INSERT INTO street_snaps (city, title, image_count, status, is_deleted, year, created_at)
|
||||
VALUES ('TestCityZZZ', 'AdminRepoTestStreet', 2, ?, 0, 2099, 1800000000)
|
||||
RETURNING id`, "pending").Scan(&streetID).Error; err != nil {
|
||||
t.Fatalf("插入街拍种子失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Exec("DELETE FROM brand_runways WHERE id = ?", runwayID).Error
|
||||
_ = db.Exec("DELETE FROM street_snaps WHERE id = ?", streetID).Error
|
||||
})
|
||||
return runwayID, streetID
|
||||
}
|
||||
|
||||
// TestAdminMixedListFilterAndOrder 验证:
|
||||
// 1. 全部视图同时返回走秀与街拍;
|
||||
// 2. 待审核视图只返回 pending 的街拍,不含已发布的走秀;
|
||||
// 3. 排序为「pending 优先、其次 id 倒序」—— 任一 pending 都排在所有 published 之前。
|
||||
func TestAdminMixedListFilterAndOrder(t *testing.T) {
|
||||
db := testDB(t)
|
||||
runwayID, streetID := seedMixedRecords(t, db)
|
||||
|
||||
repo := NewAdminRepository(db)
|
||||
|
||||
all, total, err := repo.ListMixed(t.Context(), "", "", 1, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMixed 全部失败: %v", err)
|
||||
}
|
||||
if total < 2 {
|
||||
t.Fatalf("全部视图至少应含 2 条种子,实际 total=%d", total)
|
||||
}
|
||||
var sawRunway, sawStreet bool
|
||||
for _, c := range all {
|
||||
if c.Kind == "runway" && c.ID == runwayID {
|
||||
sawRunway = true
|
||||
}
|
||||
if c.Kind == "street" && c.ID == streetID {
|
||||
sawStreet = true
|
||||
}
|
||||
}
|
||||
if !sawRunway || !sawStreet {
|
||||
t.Fatalf("全部视图应同时含走秀与街拍: runway=%v street=%v", sawRunway, sawStreet)
|
||||
}
|
||||
|
||||
// 待审核过滤:街拍(pending) 出现,走秀(published) 不出现。
|
||||
pending, _, err := repo.ListMixed(t.Context(), "pending", "", 1, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMixed pending 失败: %v", err)
|
||||
}
|
||||
var pendingHasStreet, pendingHasRunway bool
|
||||
for _, c := range pending {
|
||||
if c.Kind == "street" && c.ID == streetID {
|
||||
pendingHasStreet = true
|
||||
}
|
||||
if c.Kind == "runway" && c.ID == runwayID {
|
||||
pendingHasRunway = true
|
||||
}
|
||||
}
|
||||
if !pendingHasStreet {
|
||||
t.Fatalf("待审核视图应含待审街拍")
|
||||
}
|
||||
if pendingHasRunway {
|
||||
t.Fatalf("待审核视图不应含已发布走秀")
|
||||
}
|
||||
|
||||
// 排序:任一 pending 必须排在所有 published 之前。
|
||||
var maxPendingIdx, minPublishedIdx = -1, 1 << 30
|
||||
for i, c := range all {
|
||||
if c.Status == "pending" {
|
||||
if i > maxPendingIdx {
|
||||
maxPendingIdx = i
|
||||
}
|
||||
} else if c.Status == "published" {
|
||||
if i < minPublishedIdx {
|
||||
minPublishedIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxPendingIdx >= 0 && minPublishedIdx < 1<<30 && maxPendingIdx > minPublishedIdx {
|
||||
t.Fatalf("排序错误:存在 published 排在 pending 之前 (pending@%d, published@%d)", maxPendingIdx, minPublishedIdx)
|
||||
}
|
||||
|
||||
// CountMixed 与列表口径一致。
|
||||
if n, err := repo.CountMixed(t.Context(), "pending", ""); err != nil || n < 1 {
|
||||
t.Fatalf("CountMixed(pending) 应 >=1,实际 n=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMixedKindFilter 验证 kind 筛选:只取走秀 / 只取街拍 / 全部。
|
||||
func TestAdminMixedKindFilter(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
repo := NewAdminRepository(db)
|
||||
ctx := t.Context()
|
||||
|
||||
// 直接插入各一条,用独特 city / title 隔离唯一实体键。
|
||||
var runwayID, streetID uint32
|
||||
if err := db.Raw(`
|
||||
INSERT INTO brand_runways (brand_id, title_en, image_count, status, is_deleted, year, season_code, collection_type, created_at)
|
||||
VALUES (999999, 'KindFilterRunway', 1, 'published', 0, 2097, 'KFZ', 'test', 1800000000)
|
||||
RETURNING id`,
|
||||
).Scan(&runwayID).Error; err != nil {
|
||||
t.Fatalf("插入走秀种子失败: %v", err)
|
||||
}
|
||||
if err := db.Raw(`
|
||||
INSERT INTO street_snaps (city, title, image_count, status, is_deleted, year, created_at)
|
||||
VALUES ('KindFilterCity', 'KindFilterStreet', 1, 'published', 0, 2097, 1800000000)
|
||||
RETURNING id`,
|
||||
).Scan(&streetID).Error; err != nil {
|
||||
t.Fatalf("插入街拍种子失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Exec("DELETE FROM brand_runways WHERE id = ?", runwayID).Error
|
||||
_ = db.Exec("DELETE FROM street_snaps WHERE id = ?", streetID).Error
|
||||
})
|
||||
|
||||
runwayOnly, _, err := repo.ListMixed(ctx, "", "runway", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("kind=runway 失败: %v", err)
|
||||
}
|
||||
for _, c := range runwayOnly {
|
||||
if c.Kind != "runway" {
|
||||
t.Fatalf("kind=runway 不应含 %s", c.Kind)
|
||||
}
|
||||
}
|
||||
if len(runwayOnly) == 0 {
|
||||
t.Fatalf("kind=runway 至少应含 1 条")
|
||||
}
|
||||
|
||||
streetOnly, _, err := repo.ListMixed(ctx, "", "street", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("kind=street 失败: %v", err)
|
||||
}
|
||||
for _, c := range streetOnly {
|
||||
if c.Kind != "street" {
|
||||
t.Fatalf("kind=street 不应含 %s", c.Kind)
|
||||
}
|
||||
}
|
||||
if len(streetOnly) == 0 {
|
||||
t.Fatalf("kind=street 至少应含 1 条")
|
||||
}
|
||||
|
||||
all, _, err := repo.ListMixed(ctx, "", "", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("kind='' 失败: %v", err)
|
||||
}
|
||||
if len(all) != len(runwayOnly)+len(streetOnly) {
|
||||
t.Fatalf("全部应等于两类型之和:all=%d runway=%d street=%d", len(all), len(runwayOnly), len(streetOnly))
|
||||
}
|
||||
}
|
||||
172
internal/repository/admin_repository.go
Normal file
172
internal/repository/admin_repository.go
Normal file
@ -0,0 +1,172 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminRecordCard 是「文章管理」混合列表的统一卡片:跨 brand_runways 与 street_snaps
|
||||
// 两张表,字段取两者并集的最小可读子集(类型由 Kind 区分)。
|
||||
//
|
||||
// 为什么是 UNION 而非分别查两张表再在 Go 里拼:混合列表需要按(待审优先 + id 倒序)
|
||||
// 做跨表统一排序与分页,分别查两表再分页会得到错误的边界;一条 UNION 查询在数据库侧
|
||||
// 完成排序与 LIMIT/OFFSET,分页语义才正确,也避免把整张表拉进内存。
|
||||
type AdminRecordCard struct {
|
||||
Kind string // "runway" | "street"
|
||||
ID uint32 // 内部数值主键(供通过/驳回/批量接口使用)
|
||||
Title string
|
||||
Subtitle string // 走秀=品牌名;街拍=城市
|
||||
ImageCount int
|
||||
Status string // pending / published / rejected
|
||||
}
|
||||
|
||||
// DupStat 某记录的图片去重统计(仅统计 is_deleted=0 的存活行)。
|
||||
type DupStat struct {
|
||||
Total int // 存活图片总数(含主图与细节图)
|
||||
Dup int // is_duplicate=1 的张数
|
||||
}
|
||||
|
||||
// AdminRepository 文章管理页所需的跨表只读查询。
|
||||
type AdminRepository interface {
|
||||
// ListMixed 按状态 + 类型过滤(status 为空表示全部;kind 为空表示走秀+街拍,
|
||||
// "runway"/"street" 则只取对应一类),跨表统一排序后分页返回。
|
||||
// 排序:pending 优先,其次 id 倒序(最新待审排最前)。
|
||||
ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error)
|
||||
// CountMixed 统计某状态 + 类型下的记录总数(用于 tab 角标)。
|
||||
CountMixed(ctx context.Context, status, kind string) (int64, error)
|
||||
// DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。
|
||||
// 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。
|
||||
DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error)
|
||||
}
|
||||
|
||||
type adminRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminRepository 创建文章管理仓储。
|
||||
func NewAdminRepository(db *gorm.DB) AdminRepository {
|
||||
return &adminRepository{db: db}
|
||||
}
|
||||
|
||||
// legWhere 单条腿(带表别名)的过滤条件:is_deleted=0 是后台可见性的硬约束,
|
||||
// status 过滤可选。别名必须显式指定——两表 LEFT JOIN 时 is_deleted 会歧义。
|
||||
func legWhere(alias, status string) (string, []interface{}) {
|
||||
where := alias + ".is_deleted = 0"
|
||||
args := []interface{}{}
|
||||
if status != "" {
|
||||
where += " AND " + alias + ".status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func (r *adminRepository) ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 24
|
||||
}
|
||||
rw, rargs := legWhere("r", status)
|
||||
sw, sargs := legWhere("s", status)
|
||||
|
||||
// kind 决定本次跨表 UNION 取哪几腿:""=两表都取,"street"=只取街拍,"runway"=只取走秀。
|
||||
var legs []string
|
||||
var args []interface{}
|
||||
if kind != "street" {
|
||||
legs = append(legs, `
|
||||
SELECT 'runway' AS kind, r.id, COALESCE(r.title_en, '') AS title, COALESCE(b.name_en, '') AS subtitle,
|
||||
CAST(r.image_count AS BIGINT) AS image_count, r.status
|
||||
FROM brand_runways r
|
||||
LEFT JOIN brands b ON b.id = r.brand_id
|
||||
WHERE `+rw)
|
||||
args = append(args, rargs...)
|
||||
}
|
||||
if kind != "runway" {
|
||||
legs = append(legs, `
|
||||
SELECT 'street' AS kind, s.id, COALESCE(s.title, '') AS title, COALESCE(s.city, '') AS subtitle,
|
||||
CAST(s.image_count AS BIGINT) AS image_count, s.status
|
||||
FROM street_snaps s
|
||||
WHERE `+sw)
|
||||
args = append(args, sargs...)
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT * FROM (` + strings.Join(legs, " UNION ALL ") + `) sub
|
||||
ORDER BY (status = 'pending') DESC, id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
listArgs := append(append([]interface{}{}, args...), size, (page-1)*size)
|
||||
|
||||
var cards []AdminRecordCard
|
||||
if err := r.db.WithContext(ctx).Raw(q, listArgs...).Scan(&cards).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
total, err := r.CountMixed(ctx, status, kind)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return cards, total, nil
|
||||
}
|
||||
|
||||
// DupRateByRecords 批量统计若干记录的图片去重情况。
|
||||
// kind=runway 查 brand_runway_images(外键 runway_id);kind=street 查 street_snap_images(外键 snap_id)。
|
||||
// 只统计 is_deleted=0 的存活行;命中阈值为 phash.DefaultThreshold(当前 4)。
|
||||
func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) {
|
||||
out := make(map[uint32]DupStat)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var table, fk string
|
||||
switch kind {
|
||||
case "runway":
|
||||
table, fk = "brand_runway_images", "runway_id"
|
||||
case "street":
|
||||
table, fk = "street_snap_images", "snap_id"
|
||||
default:
|
||||
return out, nil
|
||||
}
|
||||
type agg struct {
|
||||
Owner uint32 `gorm:"column:owner"`
|
||||
Total int `gorm:"column:total"`
|
||||
Dup int `gorm:"column:dup"`
|
||||
}
|
||||
var rows []agg
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table(table).
|
||||
Select(fk + " AS owner, COUNT(*) AS total, COALESCE(SUM(CASE WHEN is_duplicate = 1 THEN 1 ELSE 0 END), 0) AS dup").
|
||||
Where(fk+" IN ? AND is_deleted = 0", ids).
|
||||
Group(fk).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.Owner] = DupStat{Total: row.Total, Dup: row.Dup}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *adminRepository) CountMixed(ctx context.Context, status, kind string) (int64, error) {
|
||||
rw, rargs := legWhere("r", status)
|
||||
sw, sargs := legWhere("s", status)
|
||||
|
||||
var legs []string
|
||||
var args []interface{}
|
||||
if kind != "street" {
|
||||
legs = append(legs, `SELECT COUNT(*) AS c FROM brand_runways r WHERE `+rw)
|
||||
args = append(args, rargs...)
|
||||
}
|
||||
if kind != "runway" {
|
||||
legs = append(legs, `SELECT COUNT(*) AS c FROM street_snaps s WHERE `+sw)
|
||||
args = append(args, sargs...)
|
||||
}
|
||||
q := `
|
||||
SELECT COALESCE(SUM(c), 0) FROM (` + strings.Join(legs, " UNION ALL ") + `) t`
|
||||
var total int64
|
||||
if err := r.db.WithContext(ctx).Raw(q, args...).Scan(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@ -331,21 +331,49 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
|
||||
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var img model.BrandRunwayImage
|
||||
err := tx.Select("runway_id").Where("id = ?", imageID).First(&img).Error
|
||||
err := tx.Select("runway_id, image").Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
|
||||
var rw model.BrandRunway
|
||||
if err := tx.Select("cover").Where("id = ?", img.RunwayID).First(&rw).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND is_deleted = 0", imageID).
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`UPDATE brand_runways SET image_count =
|
||||
if err := tx.Exec(`UPDATE brand_runways SET image_count =
|
||||
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
|
||||
WHERE id = ?`, img.RunwayID, img.RunwayID).Error
|
||||
WHERE id = ?`, img.RunwayID, img.RunwayID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
|
||||
if rw.Cover == img.Image {
|
||||
cover := ""
|
||||
var survivor struct {
|
||||
Image string `gorm:"column:image"`
|
||||
}
|
||||
serr := tx.Raw(
|
||||
"SELECT image FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
|
||||
img.RunwayID,
|
||||
).Scan(&survivor).Error
|
||||
if serr == nil {
|
||||
cover = survivor.Image
|
||||
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
|
||||
return serr
|
||||
}
|
||||
if err := tx.Model(&model.BrandRunway{}).Where("id = ?", img.RunwayID).
|
||||
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -383,6 +411,13 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Where("id = ?", runwayID).
|
||||
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
|
||||
@ -196,3 +196,133 @@ func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) {
|
||||
t.Fatalf("删完所有图后 image_count 应为 0,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// editorCover 读某记录的 cover 列(用于封面悬空防护断言)。
|
||||
func editorCover(t *testing.T, db *gorm.DB, table, idCol string, id uint32) string {
|
||||
t.Helper()
|
||||
var row struct{ Cover string `gorm:"column:cover"` }
|
||||
if err := db.Table(table).Select("cover").Where(idCol+" = ?", id).Scan(&row).Error; err != nil {
|
||||
t.Fatalf("读 %s.cover 失败: %v", table, err)
|
||||
}
|
||||
return row.Cover
|
||||
}
|
||||
|
||||
// TestEditorDeleteRunwayCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
|
||||
func TestEditorDeleteRunwayCoverRepointsWhenDeleted(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
id := seedEditorRunway(t, db, 2)
|
||||
seedEditorRunwayImage(t, db, id, "keep", 1, 0)
|
||||
drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0)
|
||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
|
||||
t.Fatalf("删封面图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "brand_runways", "id", id); got != "keep.jpg" {
|
||||
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorDeleteRunwayCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
|
||||
func TestEditorDeleteRunwayCoverClearsWhenLastImage(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
id := seedEditorRunway(t, db, 1)
|
||||
only := seedEditorRunwayImage(t, db, id, "only", 1, 0)
|
||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
if err := repo.DeleteRunwayImage(ctx, only); err != nil {
|
||||
t.Fatalf("删最后一张图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
|
||||
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorPurgeRunwayGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
|
||||
func TestEditorPurgeRunwayGalleryClearsCover(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
id := seedEditorRunway(t, db, 2)
|
||||
seedEditorRunwayImage(t, db, id, "a", 1, 0)
|
||||
seedEditorRunwayImage(t, db, id, "b", 2, 0)
|
||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
if _, err := repo.SoftDeleteRunwayImages(ctx, id); err != nil {
|
||||
t.Fatalf("下架删图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
|
||||
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorDeleteSnapCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
|
||||
func TestEditorDeleteSnapCoverRepointsWhenDeleted(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewStreetSnapRepository(db)
|
||||
|
||||
id := seedEditorSnap(t, db, 2)
|
||||
seedEditorSnapImage(t, db, id, "keep", 1, 0, 0)
|
||||
drop := seedEditorSnapImage(t, db, id, "drop", 2, 0, 0)
|
||||
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteSnapImage(ctx, drop); err != nil {
|
||||
t.Fatalf("删封面图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "street_snaps", "id", id); got != "keep.jpg" {
|
||||
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorDeleteSnapCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
|
||||
func TestEditorDeleteSnapCoverClearsWhenLastImage(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewStreetSnapRepository(db)
|
||||
|
||||
id := seedEditorSnap(t, db, 1)
|
||||
only := seedEditorSnapImage(t, db, id, "only", 1, 0, 0)
|
||||
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
if err := repo.DeleteSnapImage(ctx, only); err != nil {
|
||||
t.Fatalf("删最后一张图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
|
||||
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorPurgeSnapGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
|
||||
func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewStreetSnapRepository(db)
|
||||
|
||||
id := seedEditorSnap(t, db, 2)
|
||||
seedEditorSnapImage(t, db, id, "a", 1, 0, 0)
|
||||
seedEditorSnapImage(t, db, id, "b", 2, 0, 0)
|
||||
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
if _, err := repo.SoftDeleteSnapImages(ctx, id); err != nil {
|
||||
t.Fatalf("下架删图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
|
||||
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
@ -42,6 +43,8 @@ type IngestRepository interface {
|
||||
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
|
||||
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
|
||||
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
|
||||
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
|
||||
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
|
||||
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
|
||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
||||
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error)
|
||||
@ -335,11 +338,49 @@ func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, r
|
||||
})
|
||||
}
|
||||
|
||||
// RepairRunwayDetailParents 入库后按 sort_order 顺序回填走秀细节图的 parent_image_id:
|
||||
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
|
||||
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
|
||||
func (r *ingestRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
||||
var imgs []model.BrandRunwayImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
lastMain := uint32(0)
|
||||
type fix struct{ id, parent uint32 }
|
||||
fixes := make([]fix, 0)
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 0 {
|
||||
lastMain = im.ID
|
||||
continue
|
||||
}
|
||||
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
|
||||
fixes = append(fixes, fix{im.ID, lastMain})
|
||||
}
|
||||
}
|
||||
for _, f := range fixes {
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
|
||||
Update("parent_image_id", f.parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
|
||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
|
||||
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
|
||||
// 进而插入一行注定被索引拒绝的记录。
|
||||
//
|
||||
// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致,
|
||||
// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。
|
||||
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) {
|
||||
title = NormalizeStreetTitle(title)
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Status string `gorm:"column:status"`
|
||||
@ -359,6 +400,17 @@ func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city strin
|
||||
return row.ID, row.Status, true, nil
|
||||
}
|
||||
|
||||
// NormalizeStreetTitle 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)
|
||||
// 连续段折叠为单个空格。
|
||||
//
|
||||
// 为什么需要:实体键含 title((city, year, COALESCE(title, ''))),同一专题标题只做空白 / 大小写 / 措辞
|
||||
// 微调会被判为新实体(设计规格 §12.4)。本函数消除「纯空白」这一类可机械归一的差异,避免重复行。
|
||||
// 措辞差异无法机械归一,仍按不同实体处理(符合预期)。调用方在落库与查重两侧都必须用同一函数,
|
||||
// 才能保证索引(COALESCE(title, ''))与查重口径逐字一致。
|
||||
func NormalizeStreetTitle(title string) string {
|
||||
return strings.Join(strings.Fields(title), " ")
|
||||
}
|
||||
|
||||
func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {
|
||||
now := uint32(time.Now().Unix())
|
||||
snap.CreatedAt, snap.UpdatedAt = now, now
|
||||
|
||||
@ -22,7 +22,7 @@ func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType
|
||||
}
|
||||
|
||||
func newRunwayImages(cover string) []model.BrandRunwayImage {
|
||||
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1, LookIndex: 1}}
|
||||
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1}}
|
||||
}
|
||||
|
||||
// TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。
|
||||
|
||||
66
internal/repository/public_view_columns_integration_test.go
Normal file
66
internal/repository/public_view_columns_integration_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:公开只读视图(public_brand_runways / public_street_snaps)不得暴露审核/溯源列
|
||||
// (job_id / reviewer / reject_reason)。设计规格 I2:视图列集已显式枚举,刻意排除这些列,
|
||||
// 使基表加列时敏感列不会无意泄露到公开读路径。
|
||||
//
|
||||
// 运行:go test -tags integration ./internal/repository/ -run TestPublicViewsExcludeSensitiveColumns -v
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPublicViewsExcludeSensitiveColumns 断言两个主表公开视图不含敏感列。
|
||||
func TestPublicViewsExcludeSensitiveColumns(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
|
||||
type v struct {
|
||||
view, col string
|
||||
}
|
||||
cases := []v{}
|
||||
for _, view := range []string{"public_brand_runways", "public_street_snaps"} {
|
||||
for _, col := range []string{"job_id", "reviewer", "reject_reason"} {
|
||||
cases = append(cases, v{view, col})
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
var cnt int64
|
||||
if err := db.Raw(
|
||||
`SELECT count(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
|
||||
c.view, c.col,
|
||||
).Scan(&cnt).Error; err != nil {
|
||||
t.Fatalf("查 information_schema 失败: %v", err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Errorf("公开视图 %s 不应暴露敏感列 %s", c.view, c.col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicViewsKeepNeededColumns 断言公开视图仍含对外必需的列(回归保护,避免收窄时误删)。
|
||||
func TestPublicViewsKeepNeededColumns(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
|
||||
keep := map[string][]string{
|
||||
"public_brand_runways": {"id", "brand_id", "title_en", "cover", "image_count", "year", "season_code", "status"},
|
||||
"public_street_snaps": {"id", "title", "title_cn", "city", "cover", "image_count", "year", "status"},
|
||||
}
|
||||
for view, cols := range keep {
|
||||
for _, col := range cols {
|
||||
var cnt int64
|
||||
if err := db.Raw(
|
||||
`SELECT count(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
|
||||
view, col,
|
||||
).Scan(&cnt).Error; err != nil {
|
||||
t.Fatalf("查 information_schema 失败: %v", err)
|
||||
}
|
||||
if cnt != 1 {
|
||||
t.Errorf("公开视图 %s 必须含列 %s(收窄时误删)", view, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,11 @@ type ReviewRepository interface {
|
||||
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
|
||||
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
|
||||
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
|
||||
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
|
||||
// 与 AttachStreetSnapImages 同构(按 parent_image_id 分组):先改挂孙辈,再把被并入图标为副图。
|
||||
AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error
|
||||
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
|
||||
DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error
|
||||
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
|
||||
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
|
||||
@ -412,3 +417,89 @@ func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, im
|
||||
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
|
||||
Error
|
||||
}
|
||||
|
||||
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
|
||||
//
|
||||
// 与 AttachStreetSnapImages 同构(均按 parent_image_id 分组):事务内先「改挂孙辈」
|
||||
// (被并入图原有的副图 parent_image_id IN ids 改挂到新主图),再把被并入图自身标为副图并指向新主图。
|
||||
func (r *reviewRepository) AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error {
|
||||
ids := make([]uint32, 0, len(imgIDs))
|
||||
for _, id := range imgIDs {
|
||||
if id != 0 && id != mainImgID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if mainImgID == 0 || len(imgIDs) == 0 {
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil // 集合里只有主图自身:无操作成功
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var main model.BrandRunwayImage
|
||||
if err := tx.Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, runwayID).
|
||||
First(&main).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
return err
|
||||
}
|
||||
now := uint32(time.Now().Unix())
|
||||
// 先改挂孙辈:被并入图原有的副图(parent_image_id IN ids)改挂到新主图。
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("runway_id = ? AND is_deleted = 0 AND parent_image_id IN ?", runwayID, ids).
|
||||
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 再把被并入图自身标为副图并指向新主图。
|
||||
return tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("runway_id = ? AND is_deleted = 0 AND id IN ?", runwayID, ids).
|
||||
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
|
||||
// 同主图下其余明细图仍挂在该主图之下,不变量成立。
|
||||
func (r *reviewRepository) DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, runwayID).
|
||||
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
|
||||
Error
|
||||
}
|
||||
|
||||
// RepairRunwayDetailParents 按 sort_order 顺序回填走秀细节图的 parent_image_id:
|
||||
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
|
||||
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
|
||||
// 供入库后调用,使新入库的走秀自动成组,无需人工合并。
|
||||
func (r *reviewRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
||||
var imgs []model.BrandRunwayImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
lastMain := uint32(0)
|
||||
type fix struct{ id, parent uint32 }
|
||||
fixes := make([]fix, 0)
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 0 {
|
||||
lastMain = im.ID
|
||||
continue
|
||||
}
|
||||
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
|
||||
fixes = append(fixes, fix{im.ID, lastMain})
|
||||
}
|
||||
}
|
||||
for _, f := range fixes {
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
|
||||
Update("parent_image_id", f.parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -223,10 +223,11 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.
|
||||
|
||||
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。
|
||||
var snapEditable = map[string]bool{
|
||||
"title": true,
|
||||
"year": true,
|
||||
"city": true,
|
||||
"cover": true,
|
||||
"title": true,
|
||||
"title_cn": true,
|
||||
"year": true,
|
||||
"city": true,
|
||||
"cover": true,
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
||||
@ -274,21 +275,49 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
|
||||
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var img model.StreetSnapImage
|
||||
err := tx.Select("snap_id").Where("id = ?", imageID).First(&img).Error
|
||||
err := tx.Select("snap_id, image").Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
|
||||
var snap model.StreetSnap
|
||||
if err := tx.Select("cover").Where("id = ?", img.SnapID).First(&snap).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.StreetSnapImage{}).
|
||||
Where("id = ? AND is_deleted = 0", imageID).
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`UPDATE street_snaps SET image_count =
|
||||
if err := tx.Exec(`UPDATE street_snaps SET image_count =
|
||||
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
|
||||
WHERE id = ?`, img.SnapID, img.SnapID).Error
|
||||
WHERE id = ?`, img.SnapID, img.SnapID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
|
||||
if snap.Cover == img.Image {
|
||||
cover := ""
|
||||
var survivor struct {
|
||||
Image string `gorm:"column:image"`
|
||||
}
|
||||
serr := tx.Raw(
|
||||
"SELECT image FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
|
||||
img.SnapID,
|
||||
).Scan(&survivor).Error
|
||||
if serr == nil {
|
||||
cover = survivor.Image
|
||||
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
|
||||
return serr
|
||||
}
|
||||
if err := tx.Model(&model.StreetSnap{}).Where("id = ?", img.SnapID).
|
||||
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -326,6 +355,13 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
Where("id = ?", snapID).
|
||||
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
)
|
||||
|
||||
// TestStreetTitleNormalizationUnifiesEntity 验证街拍实体键标题归一化:
|
||||
// 落库标题带多余空白(模拟爬虫原始上报)经 NormalizeStreetTitle 折叠后写入,
|
||||
// 查重侧用未归一化的原始标题(多空格)也应命中同一实体,不会被判成新实体。
|
||||
//
|
||||
// 设计规格 §12.4:纯空白微调的同一专题若被当作新实体,会多出重复行。
|
||||
func TestStreetTitleNormalizationUnifiesEntity(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
|
||||
applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql")
|
||||
|
||||
repo := NewIngestRepository(db)
|
||||
ctx := context.Background()
|
||||
city, year := "NormCity", uint16(2099)
|
||||
|
||||
// 落库口径:与 processStreet 一致,先归一化再写。
|
||||
stored := NormalizeStreetTitle("Norm Show 2026")
|
||||
id, err := repo.CreateStreetSnapWithImages(ctx, &model.StreetSnap{
|
||||
JobID: 1, Title: stored, Year: year, City: city, Status: model.StatusPending,
|
||||
}, []model.StreetSnapImage{{Image: "norm.jpg", SortOrder: 1}})
|
||||
if err != nil {
|
||||
t.Fatalf("插入归一化标题失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", id)
|
||||
db.Exec("DELETE FROM street_snaps WHERE id = ?", id)
|
||||
})
|
||||
|
||||
// 查重口径:用未归一化的原始标题(多空格)→ 必须命中已落库行。
|
||||
hid, _, found, err := repo.StreetSnapEntityState(ctx, city, year, "Norm Show 2026")
|
||||
if err != nil {
|
||||
t.Fatalf("查重出错: %v", err)
|
||||
}
|
||||
if !found || hid != id {
|
||||
t.Fatalf("归一化后应命中同一实体 id=%d,实际 found=%v hid=%d", id, found, hid)
|
||||
}
|
||||
}
|
||||
@ -77,11 +77,11 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
||||
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
|
||||
admin.Use(opt.Backstage.NavStats())
|
||||
{
|
||||
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
|
||||
// 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
|
||||
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
|
||||
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch)
|
||||
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImagesAPI)
|
||||
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI)
|
||||
admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
|
||||
admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
|
||||
admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
|
||||
|
||||
admin.GET("/", opt.Backstage.Dashboard)
|
||||
admin.GET("/brands", opt.Backstage.Brands)
|
||||
@ -89,31 +89,29 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
||||
admin.POST("/brands", opt.Backstage.BrandCreate)
|
||||
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
|
||||
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
|
||||
admin.GET("/runways", opt.Backstage.Runways)
|
||||
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/驳回/下架的统一入口。
|
||||
admin.GET("/articles", opt.Backstage.Articles)
|
||||
admin.POST("/articles/batch", opt.Backstage.ArticleBatch)
|
||||
admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove)
|
||||
admin.POST("/articles/:kind/:id/reject", opt.Backstage.ArticleReject)
|
||||
// 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。
|
||||
admin.POST("/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImages)
|
||||
admin.POST("/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImage)
|
||||
|
||||
// 分类型编辑/详情/删图/设封面/上下架:从文章管理列表跳入,按类型落到各自的编辑页。
|
||||
// 列表本身已合并到 /admin/articles,这里只保留编辑链路,避免重复维护两套列表。
|
||||
admin.GET("/runways/:id", opt.Backstage.RunwayDetail)
|
||||
admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit)
|
||||
admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate)
|
||||
admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete)
|
||||
admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover)
|
||||
admin.POST("/runways/:id/toggle", opt.Backstage.RunwayToggleDeleted)
|
||||
admin.GET("/street-snaps", opt.Backstage.StreetSnaps)
|
||||
admin.GET("/street-snaps/:id", opt.Backstage.StreetSnapDetail)
|
||||
admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit)
|
||||
admin.POST("/street-snaps/:id/edit", opt.Backstage.StreetSnapUpdate)
|
||||
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
|
||||
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
|
||||
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
|
||||
// 审核记录:爬虫入库待人工审核 → 通过置为已发布 / 驳回。
|
||||
// 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。
|
||||
admin.GET("/reviews", opt.Backstage.ReviewList)
|
||||
admin.GET("/reviews/:kind", opt.Backstage.ReviewList)
|
||||
admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail)
|
||||
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
|
||||
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
|
||||
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewImageDelete)
|
||||
// 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
|
||||
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
|
||||
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
|
||||
// 爬虫入库任务监控:列出处理进度 + 失败重试
|
||||
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
|
||||
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)
|
||||
|
||||
@ -13,7 +13,9 @@ import (
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/handler"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/pkg/jwt"
|
||||
"fashionapi/internal/repository"
|
||||
"fashionapi/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -164,12 +166,12 @@ func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dt
|
||||
|
||||
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
|
||||
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
|
||||
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
func (f *fakeStreet) Detail(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
|
||||
return nil, service.ErrStreetSnapNotFound
|
||||
}
|
||||
|
||||
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
|
||||
func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
func (f *fakeStreet) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
|
||||
return &dto.PublicStreetSnapDetail{
|
||||
UID: id,
|
||||
Title: "Paris Fashion Week Street(待审)",
|
||||
@ -178,11 +180,16 @@ func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStr
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
func (f *fakeStreet) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
|
||||
func (f *fakeStreet) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStreet) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
||||
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street"}, nil, nil
|
||||
imgs := []model.StreetSnapImage{
|
||||
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0},
|
||||
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
|
||||
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
|
||||
}
|
||||
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street", Status: "pending"}, imgs, nil
|
||||
}
|
||||
func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
return nil
|
||||
@ -198,13 +205,63 @@ func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
|
||||
type fakeAdminRepo struct{}
|
||||
|
||||
func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) {
|
||||
all := []repository.AdminRecordCard{
|
||||
{Kind: "runway", ID: 1, Title: "Acne Studios Spring 2024", Subtitle: "Acne Studios", ImageCount: 12, Status: "published"},
|
||||
{Kind: "street", ID: 2, Title: "Paris Fashion Week Street", Subtitle: "Paris", ImageCount: 8, Status: "pending"},
|
||||
}
|
||||
if kind != "" {
|
||||
kept := all[:0]
|
||||
for _, c := range all {
|
||||
if c.Kind == kind {
|
||||
kept = append(kept, c)
|
||||
}
|
||||
}
|
||||
all = kept
|
||||
}
|
||||
if status != "" {
|
||||
kept := all[:0]
|
||||
for _, c := range all {
|
||||
if c.Status == status {
|
||||
kept = append(kept, c)
|
||||
}
|
||||
}
|
||||
all = kept
|
||||
}
|
||||
return all, int64(len(all)), nil
|
||||
}
|
||||
func (f *fakeAdminRepo) CountMixed(_ context.Context, status, kind string) (int64, error) {
|
||||
if kind == "runway" {
|
||||
if status == "" || status == "published" {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
if kind == "street" {
|
||||
if status == "" || status == "pending" {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
if status == "" {
|
||||
return 2, nil
|
||||
}
|
||||
if status == "pending" || status == "published" {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
|
||||
|
||||
func TestBackstage(t *testing.T) {
|
||||
m := jwt.NewManager("test-secret", 168)
|
||||
auth := &fakeAuth{jwt: m}
|
||||
brand := &fakeBrand{}
|
||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil)
|
||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{})
|
||||
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||
|
||||
// 1) 登录页可访问
|
||||
@ -281,13 +338,17 @@ func TestBackstage(t *testing.T) {
|
||||
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源)
|
||||
// 6) 文章管理混合列表渲染(走秀 + 街拍合并)
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/runways", nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/articles", nil)
|
||||
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios Spring 2024") || !strings.Contains(w.Body.String(), "SS24") {
|
||||
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
|
||||
body := w.Body.String()
|
||||
if w.Code != 200 || !strings.Contains(body, "Acne Studios Spring 2024") || !strings.Contains(body, "Paris Fashion Week Street") {
|
||||
t.Fatalf("文章管理列表异常: %d %s", w.Code, body)
|
||||
}
|
||||
if !strings.Contains(body, "待审核") || !strings.Contains(body, "已发布") {
|
||||
t.Fatalf("文章管理列表应含状态 tab: %s", body)
|
||||
}
|
||||
|
||||
// 7) 后台走秀详情渲染图集。
|
||||
@ -301,13 +362,14 @@ func TestBackstage(t *testing.T) {
|
||||
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 8) 街拍列表渲染
|
||||
// 8) 状态筛选:待审核视图只显示 pending 的街拍,不显示已发布的走秀
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps", nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/articles?status=pending", nil)
|
||||
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Paris Fashion Week Street") {
|
||||
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
|
||||
body = w.Body.String()
|
||||
if w.Code != 200 || !strings.Contains(body, "Paris Fashion Week Street") || strings.Contains(body, "Acne Studios Spring 2024") {
|
||||
t.Fatalf("待审核筛选异常: %d %s", w.Code, body)
|
||||
}
|
||||
|
||||
// 9) 后台街拍详情渲染图集。
|
||||
@ -420,7 +482,7 @@ func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
||||
auth := &fakeAuth{jwt: m}
|
||||
brand := &fakeBrand{}
|
||||
rev := &fakeReview{attachOK: true}
|
||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil)
|
||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{})
|
||||
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||
return r, rev
|
||||
}
|
||||
@ -444,14 +506,14 @@ func TestBackstageStreetAttachRedirect(t *testing.T) {
|
||||
tok := reviewToken(t, r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("main=1&img=2&img=3"))
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("main=1&img=2&img=3"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("合并应 302,实际 %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") {
|
||||
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
|
||||
t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location"))
|
||||
}
|
||||
if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 {
|
||||
@ -465,7 +527,7 @@ func TestBackstageStreetAttachNeedsMain(t *testing.T) {
|
||||
tok := reviewToken(t, r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("img=2&img=3"))
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("img=2&img=3"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
@ -483,13 +545,13 @@ func TestBackstageStreetDetach(t *testing.T) {
|
||||
tok := reviewToken(t, r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/2/detach", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/2/detach", nil)
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("拆出应 302,实际 %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") {
|
||||
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
|
||||
t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location"))
|
||||
}
|
||||
if rev.last.op != "detach" || rev.last.imgID != 2 {
|
||||
@ -499,33 +561,23 @@ func TestBackstageStreetDetach(t *testing.T) {
|
||||
|
||||
// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。
|
||||
func TestBackstageStreetRenderGroups(t *testing.T) {
|
||||
hashid.Init("test-salt")
|
||||
r, rev := newReviewRouter(t)
|
||||
tok := reviewToken(t, r)
|
||||
rev.view = &service.RecordDetailView{
|
||||
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
|
||||
Images: []service.RecordImageRef{
|
||||
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1},
|
||||
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
|
||||
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
|
||||
},
|
||||
Groups: []service.ImageGroup{
|
||||
{Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
|
||||
{Image: service.RecordImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}},
|
||||
},
|
||||
}
|
||||
_ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/street-snaps/"+hashid.EncodeWithType(7, hashid.TypeSnap)+"/edit", nil)
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("详情应 200,实际 %d %s", w.Code, w.Body.String())
|
||||
t.Fatalf("街拍编辑页应 200,实际 %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "detail-a") {
|
||||
t.Fatalf("应渲染副图 detail-a")
|
||||
}
|
||||
// 副图徽标显示所属主图(沿链归并后的实际主图)。
|
||||
// 副图徽标显示所属主图。
|
||||
if !strings.Contains(body, "副图 → #1") {
|
||||
t.Fatalf("副图应显示所属主图")
|
||||
}
|
||||
@ -552,7 +604,7 @@ func TestBackstageApprovePublishes(t *testing.T) {
|
||||
tok := reviewToken(t, r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/approve", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/approve", nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
@ -605,7 +657,7 @@ func TestBackstageNavStatsOnEveryPage(t *testing.T) {
|
||||
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
|
||||
}
|
||||
// 导航已在共享 partial 里,各模块入口都应在
|
||||
for _, label := range []string{"审核", "走秀", "街拍", "品牌", "入库任务", "用户"} {
|
||||
for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
|
||||
if !strings.Contains(body, ">"+label) {
|
||||
t.Fatalf("导航应包含入口 %q", label)
|
||||
}
|
||||
@ -619,7 +671,7 @@ func TestBackstageReviewBatchApprove(t *testing.T) {
|
||||
|
||||
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
@ -652,7 +704,7 @@ func TestBackstageReviewBatchRejectNeedsReason(t *testing.T) {
|
||||
|
||||
body := `{"action":"reject","items":[{"kind":"street","id":11}]}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
@ -672,7 +724,7 @@ func TestBackstageReviewBatchPartialFailure(t *testing.T) {
|
||||
|
||||
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@ -194,25 +194,22 @@ func (s *articleService) detailErr(err error) error {
|
||||
|
||||
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||||
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
|
||||
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
|
||||
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
|
||||
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
|
||||
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 parent_image_id
|
||||
// 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。
|
||||
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
byLook := make(map[uint32][]model.BrandRunwayImage)
|
||||
detailsByParent := make(map[uint32][]model.BrandRunwayImage)
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 0 {
|
||||
if im.IsDetail == 0 || im.ParentImageID == 0 {
|
||||
mains = append(mains, im)
|
||||
} else {
|
||||
byLook[im.LookIndex] = append(byLook[im.LookIndex], im)
|
||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||
}
|
||||
}
|
||||
pubMains := s.toPublicImages(mains)
|
||||
for i := range pubMains {
|
||||
// 挂载后即从 byLook 摘除:主图 look_index 若有重复,只归属第一张,避免同一批
|
||||
// 细节图被复制进多个主图(正常数据不会重复,此处仅作防御)。
|
||||
if kids, ok := byLook[pubMains[i].LookIndex]; ok && len(kids) > 0 {
|
||||
// 主图 id 对应的细节图子集挂到该主图下(正常数据每个主图一份,无需防御性摘除)。
|
||||
if kids, ok := detailsByParent[mains[i].ID]; ok && len(kids) > 0 {
|
||||
pubMains[i].Detail = s.toPublicImages(kids)
|
||||
delete(byLook, pubMains[i].LookIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@ -231,7 +228,45 @@ func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRun
|
||||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
|
||||
return s.articles.GetForEdit(ctx, id)
|
||||
rw, imgs, err := s.articles.GetForEdit(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 与街拍编辑页同构:把每张主图(is_detail=0)紧跟其细节图(同 parent_image_id、is_detail=1)重排,
|
||||
// 避免细节图被甩到几百张图的最末尾、看起来像「没显示」。孤儿细节图(无主图)追加末尾。
|
||||
return rw, orderRunwayImagesForEdit(imgs), nil
|
||||
}
|
||||
|
||||
// orderRunwayImagesForEdit 走秀图片按 parent_image_id 重排为「主图后紧跟其细节图」的扁平顺序。
|
||||
// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。
|
||||
func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage {
|
||||
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
mainSeen := make(map[uint32]bool, len(imgs))
|
||||
for _, im := range imgs {
|
||||
isMain := im.IsDetail == 0 || im.ParentImageID == 0
|
||||
if isMain && !mainSeen[im.ID] {
|
||||
mainSeen[im.ID] = true
|
||||
mains = append(mains, im)
|
||||
}
|
||||
}
|
||||
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
|
||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||
}
|
||||
}
|
||||
out := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
for _, main := range mains {
|
||||
out = append(out, main)
|
||||
out = append(out, detailsByParent[main.ID]...)
|
||||
}
|
||||
// 孤儿细节图(parent 缺失):追加末尾,保证不丢图。
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 1 && im.ParentImageID == 0 && !mainSeen[im.ID] {
|
||||
out = append(out, im)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
@ -306,7 +341,6 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub
|
||||
Thumb: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
LookIndex: im.LookIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
@ -325,7 +359,6 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ
|
||||
Image: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
LookIndex: im.LookIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@ -37,6 +37,11 @@ func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables [
|
||||
|
||||
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
|
||||
|
||||
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
|
||||
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeBrandRepo 品牌校验恒通过。
|
||||
type fakeBrandRepo struct{ repository.BrandRepository }
|
||||
|
||||
|
||||
@ -286,7 +286,6 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
Image: fi.url,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: uint32(i + 1),
|
||||
IsDetail: 0,
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
@ -333,13 +332,21 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
|
||||
return
|
||||
}
|
||||
// 入库后按 sort_order 回填细节图的 parent_image_id,使新走秀自动成组(与街拍一致)。
|
||||
runwayID := rw.ID
|
||||
if runwayID == 0 {
|
||||
runwayID = reuseID
|
||||
}
|
||||
if rerr := s.repo.RepairRunwayDetailParents(ctx, runwayID); rerr != nil {
|
||||
log.Printf("[ingest] job=%d runway 回填 parent 失败: %v", job.ID, rerr)
|
||||
}
|
||||
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
||||
// BrandRunwayImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
|
||||
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
|
||||
@ -408,7 +415,6 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
Image: r.url,
|
||||
Name: name,
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(task.lookIdx),
|
||||
IsDetail: isDetail,
|
||||
Phash: sqlNull(r.phashBits),
|
||||
IsDuplicate: isDup,
|
||||
@ -435,9 +441,11 @@ func countMainImages(images []model.BrandRunwayImage) uint16 {
|
||||
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
|
||||
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||
// 1) 实体键查重并决定分支(单表模型,同 runway)。
|
||||
// 实体键含 title:与下方 snap.Title 用的是同一个 p.TitleEn,保证查重口径与落库内容一致,
|
||||
// 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致,
|
||||
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
|
||||
reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, p.TitleEn)
|
||||
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
|
||||
title := repository.NormalizeStreetTitle(p.TitleEn)
|
||||
reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title)
|
||||
if err != nil {
|
||||
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
|
||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||
@ -465,7 +473,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
writeStart := time.Now()
|
||||
snap := &model.StreetSnap{
|
||||
JobID: job.ID,
|
||||
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
|
||||
Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
|
||||
Year: p.Year,
|
||||
City: p.City,
|
||||
Cover: cover,
|
||||
@ -695,7 +703,7 @@ func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) d
|
||||
}
|
||||
|
||||
// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、
|
||||
// 是主图还是第几张细节图,供并发下载完成后重建 Name / LookIndex / IsDetail。
|
||||
// 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。
|
||||
type lookImageTask struct {
|
||||
url string
|
||||
lookIdx int // 1-based look 序号
|
||||
|
||||
@ -28,8 +28,8 @@ type RecordCard struct {
|
||||
|
||||
// RecordImageRef 记录图片引用(统一结构)。
|
||||
//
|
||||
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
|
||||
// DetailCount 让模板不用自己数副图。
|
||||
// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0),
|
||||
// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。
|
||||
type RecordImageRef struct {
|
||||
ID uint32 `json:"id"`
|
||||
Image string `json:"image"`
|
||||
@ -70,8 +70,9 @@ type RecordDetailView struct {
|
||||
Images []RecordImageRef `json:"images"`
|
||||
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
|
||||
Fields []EditField `json:"fields"`
|
||||
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
|
||||
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
|
||||
// Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。
|
||||
// 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。
|
||||
// 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。
|
||||
Groups []ImageGroup `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
@ -286,20 +287,28 @@ func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID,
|
||||
}
|
||||
|
||||
func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error {
|
||||
if kind != dto.IngestKindStreet {
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
if len(imgIDs) == 0 {
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
|
||||
switch kind {
|
||||
case dto.IngestKindStreet:
|
||||
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
|
||||
case dto.IngestKindRunway:
|
||||
return translateGroupErr(s.repo.AttachRunwayImages(ctx, recordID, mainImgID, imgIDs))
|
||||
default:
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
}
|
||||
|
||||
func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error {
|
||||
if kind != dto.IngestKindStreet {
|
||||
switch kind {
|
||||
case dto.IngestKindStreet:
|
||||
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
|
||||
case dto.IngestKindRunway:
|
||||
return translateGroupErr(s.repo.DetachRunwayImage(ctx, recordID, imgID))
|
||||
default:
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
|
||||
}
|
||||
|
||||
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
|
||||
@ -367,12 +376,58 @@ func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDeta
|
||||
{Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"},
|
||||
},
|
||||
}
|
||||
// 按 parent_image_id 成组(与街拍同构):is_detail=0 为主图;is_detail=1 且 parent_image_id>0
|
||||
// 的是其副图;is_detail=1 且 parent_image_id=0 的孤儿细节图按主图渲染(自成一组,不丢图)。
|
||||
// 这样走秀详情页(消费 Groups)与街拍同构,编辑页(消费 Images)也能按主图→细节图顺序渲染。
|
||||
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
mainSeen := make(map[uint32]bool, len(imgs))
|
||||
for _, im := range imgs {
|
||||
v.Images = append(v.Images, RecordImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
|
||||
isMain := im.IsDetail == 0 || im.ParentImageID == 0
|
||||
if isMain && !mainSeen[im.ID] {
|
||||
mainSeen[im.ID] = true
|
||||
mains = append(mains, im)
|
||||
}
|
||||
}
|
||||
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
|
||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||
}
|
||||
}
|
||||
detailCount := make(map[uint32]int, len(detailsByParent))
|
||||
for p, ds := range detailsByParent {
|
||||
detailCount[p] = len(ds)
|
||||
}
|
||||
for _, main := range mains {
|
||||
ref := RecordImageRef{
|
||||
ID: main.ID,
|
||||
Image: m.img.Compose(main.Image),
|
||||
Name: main.Name,
|
||||
SortOrder: main.SortOrder,
|
||||
IsDetail: 0,
|
||||
ParentImageID: 0,
|
||||
DetailCount: detailCount[main.ID],
|
||||
}
|
||||
v.Images = append(v.Images, ref)
|
||||
grp := ImageGroup{Image: ref}
|
||||
for _, d := range detailsByParent[main.ID] {
|
||||
dref := RecordImageRef{
|
||||
ID: d.ID,
|
||||
Image: m.img.Compose(d.Image),
|
||||
Name: d.Name,
|
||||
SortOrder: d.SortOrder,
|
||||
IsDetail: 1,
|
||||
ParentImageID: d.ParentImageID,
|
||||
}
|
||||
grp.Details = append(grp.Details, dref)
|
||||
v.Images = append(v.Images, dref)
|
||||
}
|
||||
v.Groups = append(v.Groups, grp)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
|
||||
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
return m.repo.UpdateRecord(ctx, id, fields)
|
||||
}
|
||||
|
||||
@ -14,9 +14,10 @@ import (
|
||||
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
||||
type fakeGroupRepo struct {
|
||||
repository.ReviewRepository
|
||||
images []model.StreetSnapImage
|
||||
detail *model.StreetSnap
|
||||
attachFn func(snapID, mainID uint32, imgIDs []uint32) error
|
||||
images []model.StreetSnapImage
|
||||
detail *model.StreetSnap
|
||||
attachFn func(snapID, mainID uint32, imgIDs []uint32) error
|
||||
attachRunwayFn func(runwayID, mainID uint32, imgIDs []uint32) error
|
||||
}
|
||||
|
||||
func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
|
||||
@ -41,6 +42,17 @@ func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGroupRepo) AttachRunwayImages(_ context.Context, runwayID, mainID uint32, imgIDs []uint32) error {
|
||||
if f.attachRunwayFn != nil {
|
||||
return f.attachRunwayFn(runwayID, mainID, imgIDs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGroupRepo) DetachRunwayImage(_ context.Context, _ uint32, _ uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ repository.ReviewRepository = (*fakeGroupRepo)(nil)
|
||||
|
||||
func newGroupService(f *fakeGroupRepo) ReviewService {
|
||||
@ -102,12 +114,32 @@ func TestStreetGroupAttachDelegates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreetGroupNonStreetRejected 走秀模块不允许主副图分组操作。
|
||||
// TestStreetGroupNonStreetRejected 既非 street 也非 runway 的实体类型不允许主副图分组操作。
|
||||
func TestStreetGroupNonStreetRejected(t *testing.T) {
|
||||
repo := &fakeGroupRepo{}
|
||||
svc := newGroupService(repo)
|
||||
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
|
||||
if err := svc.AttachImages(context.Background(), "bogus", 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||
t.Fatalf("未知实体并入应返回 ErrInvalidImageGroup,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunwayGroupAttachDelegates 走秀并入必须透传参数到 runway 仓储。
|
||||
func TestRunwayGroupAttachDelegates(t *testing.T) {
|
||||
var got struct {
|
||||
runwayID uint32
|
||||
mainID uint32
|
||||
imgIDs []uint32
|
||||
}
|
||||
repo := &fakeGroupRepo{attachRunwayFn: func(runwayID, mainID uint32, imgIDs []uint32) error {
|
||||
got.runwayID, got.mainID, got.imgIDs = runwayID, mainID, imgIDs
|
||||
return nil
|
||||
}}
|
||||
svc := newGroupService(repo)
|
||||
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 9, 4, []uint32{5, 6}); err != nil {
|
||||
t.Fatalf("Runway AttachImages 失败: %v", err)
|
||||
}
|
||||
if got.runwayID != 9 || got.mainID != 4 || len(got.imgIDs) != 2 {
|
||||
t.Fatalf("参数透传错误: runwayID=%d mainID=%d imgIDs=%v", got.runwayID, got.mainID, got.imgIDs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
//
|
||||
// 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A)
|
||||
//
|
||||
// 期望 images 只有 2 张主图:A(Detail=[B,E], LookIndex=1)、C(Detail=[D], LookIndex=2)。
|
||||
// 期望 images 只有 2 张主图:A(Detail=[B,E])、C(Detail=[D])。
|
||||
func TestBuildSnapImages(t *testing.T) {
|
||||
s := &streetSnapService{img: imgurl.New("", "", "")}
|
||||
imgs := []model.StreetSnapImage{
|
||||
@ -29,21 +29,21 @@ func TestBuildSnapImages(t *testing.T) {
|
||||
}
|
||||
|
||||
a := out[0]
|
||||
if a.Name != "A" || a.IsDetail != 0 || a.LookIndex != 1 {
|
||||
t.Fatalf("第 1 张应为 A 主图 LookIndex=1,实际 name=%s is_detail=%d look=%d", a.Name, a.IsDetail, a.LookIndex)
|
||||
if a.Name != "A" || a.IsDetail != 0 {
|
||||
t.Fatalf("第 1 张应为 A 主图,实际 name=%s is_detail=%d", a.Name, a.IsDetail)
|
||||
}
|
||||
if len(a.Detail) != 2 || a.Detail[0].Name != "B" || a.Detail[1].Name != "E" {
|
||||
t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail))
|
||||
}
|
||||
for _, d := range a.Detail {
|
||||
if d.IsDetail != 1 || d.LookIndex != 1 {
|
||||
t.Fatalf("副图 %s 的 is_detail/look_index 应为 1/1,实际 %d/%d", d.Name, d.IsDetail, d.LookIndex)
|
||||
if d.IsDetail != 1 {
|
||||
t.Fatalf("副图 %s 的 is_detail 应为 1,实际 %d", d.Name, d.IsDetail)
|
||||
}
|
||||
}
|
||||
|
||||
c := out[1]
|
||||
if c.Name != "C" || c.LookIndex != 2 {
|
||||
t.Fatalf("第 2 张应为 C 主图 LookIndex=2,实际 name=%s look=%d", c.Name, c.LookIndex)
|
||||
if c.Name != "C" {
|
||||
t.Fatalf("第 2 张应为 C 主图,实际 name=%s", c.Name)
|
||||
}
|
||||
if len(c.Detail) != 1 || c.Detail[0].Name != "D" {
|
||||
t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail))
|
||||
|
||||
96
internal/service/street_snap_i18n_test.go
Normal file
96
internal/service/street_snap_i18n_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/imgurl"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// fakeSnapRepo 只实现测试路径用到的仓储方法;其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
||||
type fakeSnapRepo struct {
|
||||
repository.StreetSnapRepository
|
||||
findByID *model.StreetSnap
|
||||
listImages []model.StreetSnapImage
|
||||
rows []model.StreetSnap
|
||||
}
|
||||
|
||||
func (f *fakeSnapRepo) FindByID(_ context.Context, _ string) (*model.StreetSnap, error) {
|
||||
return f.findByID, nil
|
||||
}
|
||||
|
||||
func (f *fakeSnapRepo) ListImages(_ context.Context, _ string) ([]model.StreetSnapImage, error) {
|
||||
return f.listImages, nil
|
||||
}
|
||||
|
||||
func (f *fakeSnapRepo) List(_ context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
||||
return f.rows, int64(len(f.rows)), nil
|
||||
}
|
||||
|
||||
var _ repository.StreetSnapRepository = (*fakeSnapRepo)(nil)
|
||||
|
||||
func newSnapService(f *fakeSnapRepo) StreetSnapService {
|
||||
return NewStreetSnapService(f, nil, imgurl.New("", "", ""), nil, nil)
|
||||
}
|
||||
|
||||
// TestStreetSnapDetailLocalizesTitle 详情按 locale 返回中/英标题。
|
||||
func TestStreetSnapDetailLocalizesTitle(t *testing.T) {
|
||||
repo := &fakeSnapRepo{
|
||||
findByID: &model.StreetSnap{ID: 1, Title: "London Street Style", TitleCn: "伦敦街拍", Cover: "c.jpg"},
|
||||
listImages: []model.StreetSnapImage{{ID: 1, Image: "a.jpg"}},
|
||||
}
|
||||
svc := newSnapService(repo)
|
||||
|
||||
cn, err := svc.Detail(context.Background(), "1", "cn")
|
||||
if err != nil {
|
||||
t.Fatalf("Detail cn err: %v", err)
|
||||
}
|
||||
if cn.Title != "伦敦街拍" {
|
||||
t.Errorf("cn 标题应为中文,实际 %q", cn.Title)
|
||||
}
|
||||
|
||||
en, err := svc.Detail(context.Background(), "1", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("Detail en err: %v", err)
|
||||
}
|
||||
if en.Title != "London Street Style" {
|
||||
t.Errorf("en 标题应为英文,实际 %q", en.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreetSnapDetailFallsBackToEn 中文为空时回落英文。
|
||||
func TestStreetSnapDetailFallsBackToEn(t *testing.T) {
|
||||
repo := &fakeSnapRepo{findByID: &model.StreetSnap{ID: 1, Title: "Only English", TitleCn: ""}}
|
||||
svc := newSnapService(repo)
|
||||
|
||||
cn, err := svc.Detail(context.Background(), "1", "cn")
|
||||
if err != nil {
|
||||
t.Fatalf("Detail cn err: %v", err)
|
||||
}
|
||||
if cn.Title != "Only English" {
|
||||
t.Errorf("中文为空应回落英文,实际 %q", cn.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreetSnapListLocalizesTitle 列表按 locale 取标题,中文为空回落英文。
|
||||
func TestStreetSnapListLocalizesTitle(t *testing.T) {
|
||||
repo := &fakeSnapRepo{rows: []model.StreetSnap{
|
||||
{ID: 1, Title: "EN A", TitleCn: "中 A"},
|
||||
{ID: 2, Title: "EN B", TitleCn: ""},
|
||||
}}
|
||||
svc := newSnapService(repo)
|
||||
|
||||
got, _, err := svc.List(context.Background(), dto.StreetSnapQuery{Locale: "cn", Images: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("List err: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("期望 2 条,实际 %d", len(got))
|
||||
}
|
||||
if got[0].Title != "中 A" || got[1].Title != "EN B" {
|
||||
t.Errorf("列表本地化错误: %#v", got)
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
@ -19,11 +20,11 @@ type StreetSnapService interface {
|
||||
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
|
||||
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
||||
Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
|
||||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
|
||||
DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
||||
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
|
||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
||||
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
|
||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
|
||||
@ -70,7 +71,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
list := s.toPublicSnaps(rows)
|
||||
list := s.toPublicSnaps(rows, q.Locale)
|
||||
|
||||
if q.Images > 0 && len(list) > 0 {
|
||||
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
|
||||
@ -91,17 +92,17 @@ func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery
|
||||
if err != nil {
|
||||
return nil, 0, internalErr(err.Error())
|
||||
}
|
||||
return s.toPublicSnaps(rows), total, nil
|
||||
return s.toPublicSnaps(rows, q.Locale), total, nil
|
||||
}
|
||||
|
||||
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
|
||||
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
|
||||
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []dto.PublicStreetSnap {
|
||||
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap, locale string) []dto.PublicStreetSnap {
|
||||
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
list = append(list, dto.PublicStreetSnap{
|
||||
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
||||
Title: r.Title,
|
||||
Title: pickLocale(r.Title, r.TitleCn, locale),
|
||||
Cover: s.img.ComposeThumb(r.Cover),
|
||||
ImageCount: r.ImageCount,
|
||||
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
|
||||
@ -149,7 +150,7 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
|
||||
|
||||
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
|
||||
// 图片统一走展示样式(VIP 与免费同质量)。
|
||||
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
func (s *streetSnapService) Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
|
||||
snap, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, s.detailErr(err)
|
||||
@ -158,14 +159,14 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
return s.buildSnapDetail(snap, imgs), nil
|
||||
return s.buildSnapDetail(snap, imgs, locale), nil
|
||||
}
|
||||
|
||||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||||
//
|
||||
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
||||
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
|
||||
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
|
||||
snap, err := s.repo.FindByIDBase(ctx, id)
|
||||
if err != nil {
|
||||
return nil, s.detailErr(err)
|
||||
@ -174,7 +175,7 @@ func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.Pu
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
return s.buildSnapDetail(snap, imgs), nil
|
||||
return s.buildSnapDetail(snap, imgs, locale), nil
|
||||
}
|
||||
|
||||
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
|
||||
@ -186,10 +187,10 @@ func (s *streetSnapService) detailErr(err error) error {
|
||||
}
|
||||
|
||||
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||||
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage) *dto.PublicStreetSnapDetail {
|
||||
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage, locale string) *dto.PublicStreetSnapDetail {
|
||||
return &dto.PublicStreetSnapDetail{
|
||||
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
|
||||
Title: snap.Title,
|
||||
Title: pickLocale(snap.Title, snap.TitleCn, locale),
|
||||
Cover: s.img.Compose(snap.Cover),
|
||||
Images: s.buildSnapImages(imgs),
|
||||
}
|
||||
@ -198,7 +199,7 @@ func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model
|
||||
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
|
||||
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
|
||||
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
|
||||
// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。
|
||||
// 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。
|
||||
func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
@ -237,12 +238,12 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
|
||||
continue
|
||||
}
|
||||
pub := s.toPublicSnapImage(im)
|
||||
pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号
|
||||
|
||||
index[im.ID] = len(out)
|
||||
out = append(out, pub)
|
||||
}
|
||||
|
||||
// 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。
|
||||
// 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。
|
||||
for _, im := range imgs {
|
||||
root := resolveRoot(im.ID)
|
||||
if root == im.ID {
|
||||
@ -254,14 +255,14 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
|
||||
}
|
||||
pub := s.toPublicSnapImage(im)
|
||||
pub.IsDetail = 1
|
||||
pub.LookIndex = out[gi].LookIndex
|
||||
|
||||
out[gi].Detail = append(out[gi].Detail, pub)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
|
||||
func (s *streetSnapService) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) {
|
||||
rows, err := s.repo.Popular(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
@ -270,7 +271,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
||||
for _, r := range rows {
|
||||
list = append(list, dto.PublicStreetSnap{
|
||||
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
||||
Title: r.Title,
|
||||
Title: pickLocale(r.Title, r.TitleCn, locale),
|
||||
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
|
||||
ImageCount: r.ImageCount,
|
||||
})
|
||||
@ -323,7 +324,50 @@ func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dt
|
||||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
||||
return s.repo.GetForEdit(ctx, id)
|
||||
snap, imgs, err := s.repo.GetForEdit(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 编辑页按「主图 → 紧邻其副图」重排:副图的 sort_order 通常排在全部主图之后,
|
||||
// 若原样平铺,副图会被甩到几百张图的最末尾,看起来像「副图没显示」。
|
||||
// 重排后主图与其副图相邻,分组一眼可见;孤儿副图(父不在本记录)追加末尾,不丢图。
|
||||
return snap, orderStreetImagesForEdit(imgs), nil
|
||||
}
|
||||
|
||||
// orderStreetImagesForEdit 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。
|
||||
// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。
|
||||
func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage {
|
||||
byID := make(map[uint32]*model.StreetSnapImage, len(imgs))
|
||||
detailsByParent := make(map[uint32][]model.StreetSnapImage)
|
||||
var mains []model.StreetSnapImage
|
||||
for i := range imgs {
|
||||
im := &imgs[i]
|
||||
byID[im.ID] = im
|
||||
if im.IsDetail == 0 {
|
||||
mains = append(mains, *im)
|
||||
} else {
|
||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], *im)
|
||||
}
|
||||
}
|
||||
out := make([]model.StreetSnapImage, 0, len(imgs))
|
||||
emitted := make(map[uint32]bool, len(imgs))
|
||||
for _, m := range mains {
|
||||
out = append(out, m)
|
||||
emitted[m.ID] = true
|
||||
ds := detailsByParent[m.ID]
|
||||
sort.Slice(ds, func(i, j int) bool { return ds[i].SortOrder < ds[j].SortOrder })
|
||||
for _, d := range ds {
|
||||
out = append(out, d)
|
||||
emitted[d.ID] = true
|
||||
}
|
||||
}
|
||||
// 未被上述主图认领的副图(父缺失 / 父本身是副图):追加末尾,保证不丢图。
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 1 && !emitted[im.ID] {
|
||||
out = append(out, im)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
|
||||
Reference in New Issue
Block a user