update
This commit is contained in:
431
internal/handler/assets/admin.css
Normal file
431
internal/handler/assets/admin.css
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* 管理后台统一样式(合并原先散在 14 个内联模板里的 12 份重复 <style>)。
|
||||||
|
*
|
||||||
|
* 设计基调沿用原后台:黑白、直角感、系统字体、无外部依赖。
|
||||||
|
* 统一后只此一份,改样式不必再逐页改 14 处。
|
||||||
|
*
|
||||||
|
* 约定:
|
||||||
|
* 1) 布局三件套 —— body(页面底色)/ .bar(顶部导航)/ .main(内容容器)
|
||||||
|
* 2) 组件化 —— .btn / .badge / .card / .cell / .pager / .flash 等按语义命名,
|
||||||
|
* 页面只写结构,不再写内联 style
|
||||||
|
* 3) 缩略图统一 2:3 —— 与站内图片素材比例一致(后端素材为 240×360 / 720×1080)
|
||||||
|
* ==========================================================================*/
|
||||||
|
|
||||||
|
/* ---------- 基础 ---------- */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* 让 hidden 属性始终生效:下面给 .batchbar/.flash/.chk 设了 display,
|
||||||
|
作者样式会盖过浏览器默认的 [hidden]{display:none},故这里强制抬高。 */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #111;
|
||||||
|
background: #f4f4f5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 15px; margin: 26px 0 10px; color: #333; }
|
||||||
|
|
||||||
|
a { color: #111; }
|
||||||
|
a.view { text-decoration: underline; }
|
||||||
|
.muted { color: #777; }
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f6f6f7;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.break { word-break: break-all; }
|
||||||
|
|
||||||
|
:focus-visible { outline: 2px solid #111; outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ---------- 顶部导航 ---------- */
|
||||||
|
.bar {
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.bar a { color: #fff; text-decoration: none; }
|
||||||
|
.bar-title { font-weight: 600; white-space: nowrap; }
|
||||||
|
.bar-nav { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.bar-nav a {
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: .78;
|
||||||
|
transition: background .15s, opacity .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bar-nav a:hover { opacity: 1; background: rgba(255, 255, 255, .14); }
|
||||||
|
.bar-nav a.on { opacity: 1; background: rgba(255, 255, 255, .2); }
|
||||||
|
.bar-nav .sep { width: 1px; height: 14px; background: rgba(255, 255, 255, .25); margin: 0 6px; }
|
||||||
|
.bar-badge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 16px;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #111;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 内容容器 ---------- */
|
||||||
|
.main { max-width: 1100px; margin: 24px auto 60px; padding: 0 24px; }
|
||||||
|
.main.narrow { max-width: 620px; margin-top: 32px; }
|
||||||
|
.main.mid { max-width: 900px; }
|
||||||
|
|
||||||
|
/* 登录页固定宽卡片 */
|
||||||
|
.wrap {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 80px auto;
|
||||||
|
background: #fff;
|
||||||
|
padding: 28px 28px 32px;
|
||||||
|
border: 1px solid #e2e2e3;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.wrap h1 { margin-bottom: 18px; }
|
||||||
|
.wrap button { width: 100%; margin-top: 18px; }
|
||||||
|
|
||||||
|
/* ---------- 提示条 ---------- */
|
||||||
|
.flash {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 14px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.flash.ok { background: #e6f7ec; border: 1px solid #a9dcbd; color: #1a7f43; }
|
||||||
|
.flash.err, .err { color: #c0392b; }
|
||||||
|
.flash.err { background: #fdeaea; border: 1px solid #f0b7b3; }
|
||||||
|
/* 收起按钮(提示条 / 顶部提示里的小 ✕):必须是「无底无边」的小图标,
|
||||||
|
否则会继承下面 button 的黑底样式,变成一个突兀的黑方块。 */
|
||||||
|
.hint .x, .flash .x {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
/* hint 是 sticky(已定位),收起按钮绝对定位到右上角,不参与文字排版 */
|
||||||
|
.hint { padding-right: 34px; }
|
||||||
|
.hint .x { position: absolute; top: 6px; right: 8px; }
|
||||||
|
.hint {
|
||||||
|
position: sticky;
|
||||||
|
top: 52px;
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px solid #f5e08a;
|
||||||
|
color: #7a5b00;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 表单 ---------- */
|
||||||
|
label { display: block; margin: 14px 0 4px; color: #555; }
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
input[type="checkbox"], input[type="radio"] { width: auto; padding: 0; }
|
||||||
|
textarea { min-height: 72px; }
|
||||||
|
.row { display: flex; gap: 14px; }
|
||||||
|
.row > div { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 9px 16px;
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #111;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
button:hover { opacity: .88; }
|
||||||
|
button.reject, button.danger { background: #c0392b; border-color: #c0392b; }
|
||||||
|
button.ghost { background: #fff; color: #111; border-color: #ccc; }
|
||||||
|
button.retry {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-color: #c0392b;
|
||||||
|
color: #c0392b;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
button.retry:hover { background: #c0392b; color: #fff; opacity: 1; }
|
||||||
|
button:disabled { opacity: .45; cursor: default; }
|
||||||
|
|
||||||
|
/* ---------- 工具栏 / 筛选 ---------- */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.toolbar input, .toolbar select { width: auto; padding: 8px 10px; }
|
||||||
|
.toolbar a { color: #555; text-decoration: none; font-size: 13px; }
|
||||||
|
.tabs a, .kinds a {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 8px 8px 0;
|
||||||
|
color: #111;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.kinds a { border-color: #ddd; }
|
||||||
|
.tabs a.on, .kinds a.on { background: #111; color: #fff; border-color: #111; }
|
||||||
|
.idx { line-height: 2.2; }
|
||||||
|
.idx a { margin-right: 8px; text-decoration: none; font-size: 13px; }
|
||||||
|
.idx a.on { font-weight: 700; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ---------- 表格 ---------- */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid #e6e6e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th { background: #fafafa; color: #555; font-weight: 600; white-space: nowrap; }
|
||||||
|
tbody tr:hover { background: #fbfbfc; }
|
||||||
|
tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* ---------- 徽标 ---------- */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.pending { background: #fff4e0; color: #a85b00; }
|
||||||
|
.badge.processing { background: #eef2ff; color: #3b4bb5; }
|
||||||
|
.badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
|
||||||
|
.badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
|
||||||
|
.badge.dup { background: #f3e8ff; color: #6b21a8; }
|
||||||
|
.badge.free { background: #eef2ff; color: #3b4bb5; }
|
||||||
|
.badge.vip { background: #fff4e0; color: #a85b00; }
|
||||||
|
.kind {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #3b4bb5;
|
||||||
|
}
|
||||||
|
.kind.street { background: #eafaf0; color: #1a7f43; }
|
||||||
|
.down { display: inline-block; padding: 2px 8px; background: #fdeaea; color: #c0392b; border-radius: 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---------- 卡片(首页入口) ---------- */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 20px; }
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e2e3;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #111;
|
||||||
|
display: block;
|
||||||
|
transition: border-color .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: #111; transform: translateY(-1px); }
|
||||||
|
.card .t { font-size: 15px; font-weight: 600; }
|
||||||
|
.card .d { color: #777; font-size: 13px; margin-top: 6px; }
|
||||||
|
.chips { margin-top: 10px; }
|
||||||
|
.chip { display: inline-block; background: #111; color: #fff; font-size: 12px; padding: 3px 9px; border-radius: 10px; margin: 0 6px 6px 0; }
|
||||||
|
|
||||||
|
/* ---------- 图片网格(审核 / 图集) ---------- */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6e6e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.cell.det { background: #f8faff; border-color: #c7d2fe; }
|
||||||
|
.cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
|
||||||
|
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
|
||||||
|
.cell .thumb {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f1f1f2;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.cell .thumb img, .cell > img { width: 100%; display: block; aspect-ratio: 2 / 3; object-fit: cover; }
|
||||||
|
.cell > img { cursor: zoom-in; }
|
||||||
|
.cell .thumb::after {
|
||||||
|
content: "点击看大图";
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, .62);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.cell .thumb:hover::after { opacity: 1; }
|
||||||
|
.cell .role { display: block; padding: 6px 8px 0; font-size: 12px; color: #1a7f43; }
|
||||||
|
.cell.det .role { color: #3b4bb5; }
|
||||||
|
.cell .nm { display: block; padding: 2px 8px 6px; font-size: 12px; color: #555; word-break: break-all; }
|
||||||
|
.cell .acts { padding: 8px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
|
||||||
|
.cell .acts button, .cell .acts .btn { margin: 0; flex: 1 1 auto; padding: 7px 8px; font-size: 13px; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: #111; }
|
||||||
|
.chk {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.chk:hover { border-color: #111; }
|
||||||
|
.chk input { margin: 0; }
|
||||||
|
|
||||||
|
/* ---------- 底部批量操作条 ---------- */
|
||||||
|
.batchbar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6e6e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
box-shadow: 0 -2px 12px rgba(0, 0, 0, .06);
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
.batchbar button { margin: 0; }
|
||||||
|
.batchbar .spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ---------- 分页 ---------- */
|
||||||
|
.pager { margin: 18px 0; display: flex; gap: 12px; align-items: center; }
|
||||||
|
.pager a { padding: 7px 14px; border: 1px solid #ccc; border-radius: 6px; text-decoration: none; background: #fff; }
|
||||||
|
.pager a:hover { border-color: #111; }
|
||||||
|
.pager a.off { color: #bbb; border-color: #eee; pointer-events: none; }
|
||||||
|
|
||||||
|
/* ---------- 大图查看层(admin.js 用) ---------- */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, .88);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
.lightbox[hidden] { display: none; }
|
||||||
|
.lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
|
.lightbox .lb-close,
|
||||||
|
.lightbox .lb-prev,
|
||||||
|
.lightbox .lb-next {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
color: #111;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lightbox .lb-close { top: 18px; right: 18px; }
|
||||||
|
.lightbox .lb-prev { left: 18px; top: 50%; transform: translateY(-50%); }
|
||||||
|
.lightbox .lb-next { right: 18px; top: 50%; transform: translateY(-50%); }
|
||||||
|
.lightbox .lb-cap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 窄屏 ---------- */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bar { padding: 10px 14px; }
|
||||||
|
.bar-title { font-size: 13px; }
|
||||||
|
.main { padding: 0 14px; margin-top: 16px; }
|
||||||
|
.row { flex-direction: column; gap: 0; }
|
||||||
|
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
|
}
|
||||||
402
internal/handler/assets/admin.js
Normal file
402
internal/handler/assets/admin.js
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
/* 管理后台渐进增强脚本:无框架、无依赖、无构建。
|
||||||
|
*
|
||||||
|
* 定位:**JS 只是「更快的那条路」**。所有操作在服务端都仍有表单/链接兜底
|
||||||
|
* (禁用 JS、脚本加载失败时后台照常可用),所以这里只做三件事:
|
||||||
|
* 1. 大图查看层 —— 审核是看图工作,缩略图点击放大是刚需;
|
||||||
|
* 2. 批量操作 —— 多选后一次提交,避免逐条整页刷新(原逻辑 20 条要点 20 次);
|
||||||
|
* 3. 键盘流 —— j/k 移动、x 勾选、m 设主图、d 拆出、Enter 看大图,审核图片时手不离键盘。
|
||||||
|
*
|
||||||
|
* 约定:不改变页面结构语义,只挂事件、改标签与可见性;出错一律 fallback 到原生提交。
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var doc = document;
|
||||||
|
var $ = function (sel, root) { return (root || doc).querySelector(sel); };
|
||||||
|
var $$ = function (sel, root) { return Array.prototype.slice.call((root || doc).querySelectorAll(sel)); };
|
||||||
|
|
||||||
|
/* ---------------- 通用:提示与请求 ---------------- */
|
||||||
|
|
||||||
|
var toastTimer = null;
|
||||||
|
function toast(msg, ok) {
|
||||||
|
var el = doc.getElementById('toast');
|
||||||
|
if (!el) { if (!ok) window.alert(msg); return; }
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = 'flash ' + (ok === false ? 'err' : 'ok');
|
||||||
|
el.hidden = false;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(function () { el.hidden = true; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJSON(url, body) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body || {}),
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().catch(function () { return null; }).then(function (data) {
|
||||||
|
if (!res.ok) throw new Error((data && data.error) || ('请求失败(' + res.status + ')'));
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单 action -> 同路径的 JSON 接口(/admin/reviews/... -> /admin/api/reviews/...)
|
||||||
|
function apiURL(action) {
|
||||||
|
return String(action).replace('/admin/reviews/', '/admin/api/reviews/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 大图查看层 ---------------- */
|
||||||
|
|
||||||
|
var lb = null, lbList = [], lbIndex = 0;
|
||||||
|
|
||||||
|
function lightboxNodes() {
|
||||||
|
return $$('.cell .thumb img, .cell > img').filter(function (img) { return img.getAttribute('src'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLightbox() {
|
||||||
|
if (lb) return lb;
|
||||||
|
lb = doc.createElement('div');
|
||||||
|
lb.className = 'lightbox';
|
||||||
|
lb.hidden = true;
|
||||||
|
lb.innerHTML =
|
||||||
|
'<button class="lb-close" type="button" aria-label="关闭">✕</button>' +
|
||||||
|
'<button class="lb-prev" type="button" aria-label="上一张">‹</button>' +
|
||||||
|
'<button class="lb-next" type="button" aria-label="下一张">›</button>' +
|
||||||
|
'<img alt=""><div class="lb-cap"></div>';
|
||||||
|
doc.body.appendChild(lb);
|
||||||
|
lb.querySelector('.lb-close').addEventListener('click', closeLightbox);
|
||||||
|
lb.querySelector('.lb-prev').addEventListener('click', function () { stepLightbox(-1); });
|
||||||
|
lb.querySelector('.lb-next').addEventListener('click', function () { stepLightbox(1); });
|
||||||
|
lb.addEventListener('click', function (e) { if (e.target === lb) closeLightbox(); });
|
||||||
|
return lb;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLightbox() {
|
||||||
|
var box = ensureLightbox();
|
||||||
|
var img = lbList[lbIndex];
|
||||||
|
if (!img) return;
|
||||||
|
var box2 = box.querySelector('img');
|
||||||
|
box2.src = img.getAttribute('src');
|
||||||
|
box2.alt = img.getAttribute('alt') || '';
|
||||||
|
var cell = img.closest ? img.closest('.cell') : null;
|
||||||
|
var nm = cell ? $('.nm', cell) : null;
|
||||||
|
box.querySelector('.lb-cap').textContent =
|
||||||
|
(nm ? nm.textContent.trim() + ' · ' : '') + (lbIndex + 1) + ' / ' + lbList.length;
|
||||||
|
box.hidden = false;
|
||||||
|
doc.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
if (lb) lb.hidden = true;
|
||||||
|
doc.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepLightbox(delta) {
|
||||||
|
if (!lbList.length) return;
|
||||||
|
lbIndex = (lbIndex + delta + lbList.length) % lbList.length;
|
||||||
|
showLightbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLightbox() {
|
||||||
|
var imgs = lightboxNodes();
|
||||||
|
if (!imgs.length) return;
|
||||||
|
imgs.forEach(function (img) {
|
||||||
|
var trigger = img.closest ? (img.closest('.thumb') || img) : img;
|
||||||
|
trigger.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
lbList = lightboxNodes();
|
||||||
|
lbIndex = lbList.indexOf(img);
|
||||||
|
if (lbIndex < 0) lbIndex = 0;
|
||||||
|
showLightbox();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 审核列表:多选 + 批量通过/驳回 ---------------- */
|
||||||
|
|
||||||
|
function initReviewList() {
|
||||||
|
var bar = doc.getElementById('batchbar');
|
||||||
|
var table = $('table');
|
||||||
|
if (!bar || !table) return;
|
||||||
|
var rows = $$('tbody tr', table).filter(function (r) { return r.dataset && r.dataset.id; });
|
||||||
|
if (!rows.length) return;
|
||||||
|
|
||||||
|
var selAll = doc.getElementById('sel-all');
|
||||||
|
var counter = doc.getElementById('sel-count');
|
||||||
|
|
||||||
|
function picked() {
|
||||||
|
return rows.filter(function (r) {
|
||||||
|
var c = $('input.row-pick', r);
|
||||||
|
return c && c.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
var n = picked().length;
|
||||||
|
bar.hidden = n === 0;
|
||||||
|
if (counter) counter.textContent = '已选 ' + n + ' 条';
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var c = $('input.row-pick', r);
|
||||||
|
r.classList.toggle('selected', !!(c && c.checked));
|
||||||
|
});
|
||||||
|
if (selAll) {
|
||||||
|
selAll.checked = n > 0 && n === rows.length;
|
||||||
|
selAll.indeterminate = n > 0 && n < rows.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.addEventListener('change', function (e) {
|
||||||
|
if (e.target.classList.contains('row-pick')) refresh();
|
||||||
|
});
|
||||||
|
if (selAll) {
|
||||||
|
selAll.addEventListener('change', function () {
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var c = $('input.row-pick', r);
|
||||||
|
if (c) c.checked = selAll.checked;
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(action, reason) {
|
||||||
|
var items = picked().map(function (r) {
|
||||||
|
return { kind: r.dataset.kind, id: Number(r.dataset.id) };
|
||||||
|
});
|
||||||
|
if (!items.length) return;
|
||||||
|
var btns = $$('#batchbar button');
|
||||||
|
btns.forEach(function (b) { b.disabled = true; });
|
||||||
|
postJSON('/admin/api/reviews/batch', { action: action, reason: reason || '', items: items })
|
||||||
|
.then(function (res) {
|
||||||
|
// 成功的行直接从表格移除;失败的行标红保留,便于逐条重试
|
||||||
|
var failed = {};
|
||||||
|
(res.failed || []).forEach(function (f) { failed[f.kind + '/' + f.id] = f.error; });
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var row = rows.filter(function (r) {
|
||||||
|
return r.dataset.kind === it.kind && Number(r.dataset.id) === it.id;
|
||||||
|
})[0];
|
||||||
|
if (!row) return;
|
||||||
|
if (failed[it.kind + '/' + it.id]) {
|
||||||
|
row.classList.add('failed-row');
|
||||||
|
row.title = failed[it.kind + '/' + it.id];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.parentNode.removeChild(row);
|
||||||
|
rows = rows.filter(function (r) { return r !== row; });
|
||||||
|
});
|
||||||
|
var msg = (action === 'approve' ? '已通过 ' : '已驳回 ') + res.ok + ' 条';
|
||||||
|
if ((res.failed || []).length) msg += ',' + res.failed.length + ' 条失败';
|
||||||
|
toast(msg, !(res.failed || []).length);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch(function (err) { toast(err.message, false); })
|
||||||
|
.then(function () { btns.forEach(function (b) { b.disabled = false; }); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var approveBtn = doc.getElementById('batch-approve');
|
||||||
|
var rejectBtn = doc.getElementById('batch-reject');
|
||||||
|
var clearBtn = doc.getElementById('batch-clear');
|
||||||
|
if (approveBtn) {
|
||||||
|
approveBtn.addEventListener('click', function () {
|
||||||
|
var n = picked().length;
|
||||||
|
if (n && window.confirm('通过所选 ' + n + ' 条草稿并发布到正式表?')) submit('approve');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rejectBtn) {
|
||||||
|
rejectBtn.addEventListener('click', function () {
|
||||||
|
var n = picked().length;
|
||||||
|
if (!n) return;
|
||||||
|
var reason = window.prompt('驳回所选 ' + n + ' 条的理由(必填,会记录到审核留痕)');
|
||||||
|
if (reason === null) return;
|
||||||
|
if (!reason.trim()) { toast('驳回理由不能为空', false); return; }
|
||||||
|
submit('reject', reason.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var c = $('input.row-pick', r);
|
||||||
|
if (c) c.checked = false;
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行内快捷通过(免进详情页)
|
||||||
|
$$('button[data-quick-approve]', table).forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var row = btn.closest('tr');
|
||||||
|
if (!row || !window.confirm('通过并发布这条草稿?')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
postJSON('/admin/api/reviews/batch', {
|
||||||
|
action: 'approve',
|
||||||
|
items: [{ kind: row.dataset.kind, id: Number(row.dataset.id) }],
|
||||||
|
}).then(function (res) {
|
||||||
|
if ((res.failed || []).length) {
|
||||||
|
toast(res.failed[0].error || '操作失败', false);
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('已通过', true);
|
||||||
|
row.parentNode.removeChild(row);
|
||||||
|
rows = rows.filter(function (r) { return r !== row; });
|
||||||
|
refresh();
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, false);
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 审核详情:主副图合并/拆出(AJAX)+ 键盘流 ---------------- */
|
||||||
|
|
||||||
|
function initReviewDetail() {
|
||||||
|
var grid = $('.grid');
|
||||||
|
if (!grid) return;
|
||||||
|
var cells = $$('.cell', grid);
|
||||||
|
var attachForm = doc.getElementById('batch');
|
||||||
|
|
||||||
|
// 群组状态回填:只改标签与可见性,不重排 DOM(避免打乱审核人的空间记忆)
|
||||||
|
function applyGroups(res) {
|
||||||
|
var images = res.images || [];
|
||||||
|
var byId = {};
|
||||||
|
images.forEach(function (i) { byId[i.id] = i; });
|
||||||
|
cells.forEach(function (cell) {
|
||||||
|
var st = byId[Number(cell.dataset.imgId)];
|
||||||
|
if (!st) return;
|
||||||
|
cell.classList.toggle('det', st.isDetail === 1);
|
||||||
|
var role = $('.role', cell);
|
||||||
|
if (role) {
|
||||||
|
role.textContent = st.isDetail === 1
|
||||||
|
? ('副图 → #' + st.parentId)
|
||||||
|
: ('主图' + (st.detailCount ? ' · ' + st.detailCount + ' 副图' : ''));
|
||||||
|
}
|
||||||
|
$$('[data-only="main"]', cell).forEach(function (el) { el.hidden = st.isDetail === 1; });
|
||||||
|
$$('[data-only="detail"]', cell).forEach(function (el) { el.hidden = st.isDetail !== 1; });
|
||||||
|
});
|
||||||
|
var stat = doc.getElementById('img-stat');
|
||||||
|
if (stat) stat.textContent = images.length + ' 张 · ' + (res.groups || 0) + ' 组';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拦截合并/拆出表单:成功就地更新,失败回退原生提交(保底可用)
|
||||||
|
function intercept(form, buildBody) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var body = buildBody(form);
|
||||||
|
if (body === null) return; // 校验未过:交给原生提交去报错
|
||||||
|
postJSON(apiURL(form.getAttribute('action')), body)
|
||||||
|
.then(function (res) {
|
||||||
|
applyGroups(res);
|
||||||
|
cells.forEach(function (c) {
|
||||||
|
var chk = $('input[type="checkbox"]', c);
|
||||||
|
if (chk) chk.checked = false;
|
||||||
|
var radio = $('input[type="radio"]', c);
|
||||||
|
if (radio) radio.checked = false;
|
||||||
|
});
|
||||||
|
toast('已更新分组', true);
|
||||||
|
})
|
||||||
|
.catch(function (err) { toast(err.message, false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachForm) {
|
||||||
|
intercept(attachForm, function (form) {
|
||||||
|
var main = $('input[type="radio"]:checked', grid);
|
||||||
|
var imgs = $$('input[type="checkbox"]:checked', grid).map(function (c) { return Number(c.value); });
|
||||||
|
if (!main || !imgs.length) { toast('请勾选图片,并在其中一张主图上点「主图」', false); return null; }
|
||||||
|
return { main: Number(main.value), imgs: imgs };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$$('form[action*="/detach"]', grid).forEach(function (form) {
|
||||||
|
intercept(form, function () { return {}; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 键盘流(审核图片时手不离键盘) ----
|
||||||
|
var focusIndex = -1;
|
||||||
|
// scroll=false 用于初始化:只标记第一格,不要因为 scrollIntoView 把页面顶端的
|
||||||
|
// 字段表单(标题/年份/城市 + 通过按钮)滚出视野 —— 进页面必须先看到待填字段。
|
||||||
|
function setFocus(i, scroll) {
|
||||||
|
if (!cells.length) return;
|
||||||
|
focusIndex = Math.max(0, Math.min(cells.length - 1, i));
|
||||||
|
cells.forEach(function (c, j) { c.classList.toggle('focus', j === focusIndex); });
|
||||||
|
var el = cells[focusIndex];
|
||||||
|
if (scroll !== false && el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
function inField(t) {
|
||||||
|
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT');
|
||||||
|
}
|
||||||
|
doc.addEventListener('keydown', function (e) {
|
||||||
|
if (!lb.hidden && lb) {
|
||||||
|
if (e.key === 'Escape') { closeLightbox(); e.preventDefault(); }
|
||||||
|
else if (e.key === 'ArrowLeft') { stepLightbox(-1); e.preventDefault(); }
|
||||||
|
else if (e.key === 'ArrowRight') { stepLightbox(1); e.preventDefault(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inField(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
var cur = cells[focusIndex];
|
||||||
|
switch (e.key) {
|
||||||
|
case 'j': setFocus(focusIndex + 1); e.preventDefault(); break;
|
||||||
|
case 'k': setFocus(focusIndex - 1); e.preventDefault(); break;
|
||||||
|
case 'Enter':
|
||||||
|
if (cur) {
|
||||||
|
var t = $('.thumb', cur) || $('img', cur);
|
||||||
|
if (t) { t.click(); e.preventDefault(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'x':
|
||||||
|
if (cur) {
|
||||||
|
var chk = $('input[type="checkbox"]', cur);
|
||||||
|
if (chk && !chk.disabled) { chk.checked = !chk.checked; e.preventDefault(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
if (cur) {
|
||||||
|
var radio = $('input[type="radio"]', cur);
|
||||||
|
if (radio) { radio.checked = true; e.preventDefault(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'd':
|
||||||
|
if (cur) {
|
||||||
|
var df = $('form[action*="/detach"]', cur);
|
||||||
|
if (df) { df.requestSubmit ? df.requestSubmit() : df.submit(); e.preventDefault(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'a':
|
||||||
|
// 通过 = 提交页面上那个「通过并发布」表单(复用当前输入框里的字段微调),故加一次确认
|
||||||
|
var af = $('form[action$="/approve"]');
|
||||||
|
if (af && window.confirm('通过并发布这条草稿?')) {
|
||||||
|
af.requestSubmit ? af.requestSubmit() : af.submit();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 进页面先标记第一格(不滚动),键盘可直接用
|
||||||
|
if (cells.length) setFocus(0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 启动 ---------------- */
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
// 标记脚本已执行(便于人工/自动化排查「脚本没加载」这类问题)
|
||||||
|
doc.documentElement.setAttribute('data-admin-js', 'ready');
|
||||||
|
initLightbox();
|
||||||
|
initReviewList();
|
||||||
|
initReviewDetail();
|
||||||
|
var hint = doc.getElementById('hint-dismiss');
|
||||||
|
if (hint) {
|
||||||
|
hint.addEventListener('click', function () {
|
||||||
|
var box = hint.closest('.hint');
|
||||||
|
if (box) box.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', boot);
|
||||||
|
else boot();
|
||||||
|
})();
|
||||||
182
internal/handler/backstage_api.go
Normal file
182
internal/handler/backstage_api.go
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
// 审核页的 AJAX 接口(返回 JSON,不整页跳转)。
|
||||||
|
//
|
||||||
|
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
|
||||||
|
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
|
||||||
|
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
|
||||||
|
// 同时表单式路由(/admin/reviews/...)原样保留:关掉 JS 后台依然可用。
|
||||||
|
//
|
||||||
|
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"fashionapi/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxReviewBatch 单次批量审核的条数上限,避免一次请求打到几百条。
|
||||||
|
const maxReviewBatch = 200
|
||||||
|
|
||||||
|
type reviewBatchItem struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reviewBatchReq struct {
|
||||||
|
// Action: approve | reject
|
||||||
|
Action string `json:"action"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Items []reviewBatchItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewBatch 批量通过 / 驳回审核草稿。
|
||||||
|
//
|
||||||
|
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
|
||||||
|
// 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17}
|
||||||
|
//
|
||||||
|
// 逐条处理并逐条回报失败原因(而不是整体失败):审核时最怕「一批提交下去不知道哪条没成」。
|
||||||
|
func (h *BackstageHandler) ReviewBatch(c *gin.Context) {
|
||||||
|
var req reviewBatchReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Action != "approve" && req.Action != "reject" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve 或 reject"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何草稿"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) > maxReviewBatch {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多处理 200 条"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Action == "reject" && req.Reason == "" {
|
||||||
|
// 驳回理由必填:留痕是审核流程的意义所在。
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "驳回必须填写理由"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
registered := h.registeredKinds(ctx)
|
||||||
|
nameVal, _ := c.Get(middleware.ContextUsername)
|
||||||
|
reviewer := usernameString(nameVal)
|
||||||
|
|
||||||
|
okCount := 0
|
||||||
|
failed := make([]gin.H, 0)
|
||||||
|
for _, item := range req.Items {
|
||||||
|
if item.ID == 0 || !registered[item.Kind] {
|
||||||
|
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": "类型或 ID 无效"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if req.Action == "approve" {
|
||||||
|
_, err = h.review.Approve(ctx, item.Kind, item.ID, reviewer)
|
||||||
|
} else {
|
||||||
|
err = h.review.Reject(ctx, item.Kind, item.ID, reviewer, req.Reason)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
okCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": okCount,
|
||||||
|
"failed": failed,
|
||||||
|
"pending": h.PendingReviews(ctx),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type reviewAttachReq struct {
|
||||||
|
Main uint32 `json:"main"`
|
||||||
|
Imgs []uint32 `json:"imgs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewAttachImages 把多张草稿图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。
|
||||||
|
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
|
||||||
|
func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
|
||||||
|
kind := c.Param("kind")
|
||||||
|
id := atoiDefault(c.Param("id"), 0)
|
||||||
|
if id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req reviewAttachReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Main == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先指定哪张是主图"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Imgs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先勾选要合并的图片"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.review.AttachDraftImages(c.Request.Context(), kind, uint32(id), req.Main, req.Imgs); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondDraftImages(c, kind, uint32(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewDetachImage 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImages。
|
||||||
|
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
|
||||||
|
kind := c.Param("kind")
|
||||||
|
id := atoiDefault(c.Param("id"), 0)
|
||||||
|
imgID := atoiDefault(c.Param("img"), 0)
|
||||||
|
if id == 0 || imgID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.review.DetachDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondDraftImages(c, kind, uint32(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondDraftImages 重新读取草稿并把「图片分组状态」按前端需要的形状返回。
|
||||||
|
//
|
||||||
|
// 投影只带前端更新标签必需的字段,不重复返回图片 URL(页面上的 <img> 已经有了)。
|
||||||
|
func (h *BackstageHandler) respondDraftImages(c *gin.Context, kind string, id uint32) {
|
||||||
|
view, err := h.review.DraftDetail(c.Request.Context(), kind, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
images := make([]gin.H, 0, len(view.Images))
|
||||||
|
for _, im := range view.Images {
|
||||||
|
images = append(images, gin.H{
|
||||||
|
"id": im.ID,
|
||||||
|
"isDetail": im.IsDetail,
|
||||||
|
"parentId": im.ParentImageID,
|
||||||
|
"detailCount": im.DetailCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"images": images,
|
||||||
|
"imageCount": view.ImageCount,
|
||||||
|
"groups": len(view.Groups),
|
||||||
|
"pending": h.PendingReviews(c.Request.Context()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registeredKinds 已注册的审核类型集合(与 ReviewList 的校验口径一致)。
|
||||||
|
func (h *BackstageHandler) registeredKinds(ctx context.Context) map[string]bool {
|
||||||
|
registered := map[string]bool{}
|
||||||
|
for _, tab := range h.review.KindTabs(ctx) {
|
||||||
|
if tab.Kind != "" {
|
||||||
|
registered[tab.Kind] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return registered
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,133 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
// TestBackstageTemplatesParse 所有内嵌后台模板必须能解析。
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/pagination"
|
||||||
|
"fashionapi/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBackstageTemplatesParsed 后台页面模板必须能解析,且集合齐全。
|
||||||
//
|
//
|
||||||
// 模板是 Go 字符串常量,语法错误不会在编译期暴露——只会在用户打开页面时 500;
|
// 模板从「Go 字符串常量」改成 templates/ 下的独立文件后:
|
||||||
// 而审核页又有登录中间件挡着(curl 只能打到 302),冒烟测试覆盖不到,故用解析测试兜住。
|
// - 语法错误会在包 init(template.ParseFS)时直接 panic,go test 立刻暴露,
|
||||||
func TestBackstageTemplatesParse(t *testing.T) {
|
// 不再出现「编得过、点到那个页面才 500」;
|
||||||
for name, tpl := range map[string]string{
|
// - 本测试额外断言页面集合齐全,防止加了 handler 却忘了加模板文件
|
||||||
"login": tplLogin,
|
// (renderPage 会 500 "unknown backstage page: xxx")。
|
||||||
"reviewList": tplReviewList,
|
func TestBackstageTemplatesParsed(t *testing.T) {
|
||||||
"reviewDetail": tplReviewDetail,
|
want := []string{
|
||||||
} {
|
"brand-form",
|
||||||
t.Run(name, func(t *testing.T) {
|
"brands",
|
||||||
base, err := pagerTmpl.Clone()
|
"dashboard",
|
||||||
if err != nil {
|
"error",
|
||||||
t.Fatalf("clone pager 模板失败: %v", err)
|
"ingest-jobs",
|
||||||
|
"login",
|
||||||
|
"review-detail",
|
||||||
|
"review-list",
|
||||||
|
"runway-detail",
|
||||||
|
"runway-edit",
|
||||||
|
"runways",
|
||||||
|
"snap-edit",
|
||||||
|
"street-snap-detail",
|
||||||
|
"street-snaps",
|
||||||
|
"users",
|
||||||
}
|
}
|
||||||
if _, err := base.Parse(tpl); err != nil {
|
got := PageNames()
|
||||||
t.Fatalf("模板 %s 解析失败: %v", name, err)
|
sort.Strings(got)
|
||||||
|
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("页面模板数量不一致:\n got %v\n want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("页面模板集合不一致:\n got %v\n want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackstageTemplatesRender 每个页面都要能真正渲染出来(不只是能解析)。
|
||||||
|
//
|
||||||
|
// 解析通过 ≠ 渲染通过:{{template "barRight" .}}、{{template "pager" .Pager}} 这类引用
|
||||||
|
// 是**执行期**才解析的,某个页面漏了 barLabel / barRight 定义时,只有渲染那一步才会报错
|
||||||
|
// —— 而审核页又被登录中间件挡着(curl 只能打到 302),所以用这个测试兜住。
|
||||||
|
//
|
||||||
|
// 数据用一份「尽量齐全」的假数据;有分支的页面(审核详情按 kind 分流)分别渲染。
|
||||||
|
func TestBackstageTemplatesRender(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
base := gin.H{
|
||||||
|
"Username": "admin",
|
||||||
|
"Error": "",
|
||||||
|
"Total": 1,
|
||||||
|
"Pager": pagination.Pager{HasPrev: true, HasNext: true, PrevURL: "/p/1", NextURL: "/p/3", Label: "第 2 页"},
|
||||||
|
// 各筛选/表单页用到的零值字段
|
||||||
|
"Keyword": "", "Letter": "", "Status": "", "Kind": "", "ListBase": "",
|
||||||
|
"Collection": "", "Season": "", "Year": 2026, "City": "", "Sort": "",
|
||||||
|
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
|
||||||
|
"Images": []any{},
|
||||||
|
// 列表数据
|
||||||
|
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Drafts": []any{},
|
||||||
|
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
|
||||||
|
"Article": &dto.PublicArticleDetail{UID: "abc123"},
|
||||||
|
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同名 key 在不同页面指向不同模型(历史遗留命名):按页覆盖,顺带把这份契约固化下来。
|
||||||
|
perPage := map[string]gin.H{
|
||||||
|
"snap-edit": {"Snap": &model.StreetSnap{}, "Images": []any{}},
|
||||||
|
"runway-edit": {"Runway": &model.BrandRunway{}, "Images": []any{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
data := cloneData(base)
|
||||||
|
for k, v := range perPage[page] {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
data["Draft"] = &service.DraftDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||||
|
|
||||||
|
renderPage(c, http.StatusOK, page, data)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("页面 %s 渲染失败(%d):%s", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if body == "" {
|
||||||
|
t.Fatalf("页面 %s 渲染为空", name)
|
||||||
|
}
|
||||||
|
// 共享外壳必须挂上统一样式与渐进增强脚本(改回内联样式时这条会失败,是有意的)
|
||||||
|
if !strings.Contains(body, "/admin/assets/admin.css") || !strings.Contains(body, "/admin/assets/admin.js") {
|
||||||
|
t.Fatalf("页面 %s 未引用后台统一样式/脚本", name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneData 复制一份页面数据:renderPage 会往里注入 NavKey / PendingReviews,不能跨子测试共享。
|
||||||
|
func cloneData(src gin.H) gin.H {
|
||||||
|
dst := make(gin.H, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
return dst
|
||||||
}
|
}
|
||||||
|
|||||||
156
internal/handler/templates.go
Normal file
156
internal/handler/templates.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// 后台模板的加载与渲染。
|
||||||
|
//
|
||||||
|
// 为什么把模板从 Go 字符串常量搬到独立 .html 文件:
|
||||||
|
// 1. IDE 支持 —— .html 有语法高亮、补全、格式化、Emmet;Go 反引号字符串里没有;
|
||||||
|
// 2. 可维护 —— 改样式 / 加字段只动一个小文件,diff 干净;公共部分(顶部导航、翻页器)
|
||||||
|
// 抽成 partials 后不再 14 份复制粘贴;样式与脚本独立成文件后可被浏览器缓存;
|
||||||
|
// 3. 零构建 —— 用 go:embed 打进二进制,部署仍然只是一个可执行文件,无外部依赖。
|
||||||
|
//
|
||||||
|
// 模板分层:
|
||||||
|
// layout.html 外壳(<head> + 共享 css/js + 内容插槽)
|
||||||
|
// partials.html 共享片段(bar 顶部导航 / pager 翻页器)
|
||||||
|
// pages/*.html 每个页面一个文件,文件名即 renderPage 的 page 参数
|
||||||
|
//
|
||||||
|
// 基模板(layout + partials)在启动时解析一次,之后为每个页面 Clone + Parse 一次并缓存,
|
||||||
|
// 因此请求期不再重复解析模板(原实现每次渲染都 Clone+Parse 一遍)。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
|
||||||
|
var backstageFS embed.FS
|
||||||
|
|
||||||
|
// ctxNavStatsKey 是「导航统计」在 gin.Context 里的键;由 BackstageHandler.NavStats 中间件写入,
|
||||||
|
// renderPage 渲染前合并进页面数据,页面模板即可无条件引用 .PendingReviews。
|
||||||
|
const ctxNavStatsKey = "backstageNavStats"
|
||||||
|
|
||||||
|
// pageTemplates 页面名(= pages/ 下的文件名去扩展名)-> 已注入该页 define 的完整模板。
|
||||||
|
var pageTemplates = map[string]*template.Template{}
|
||||||
|
|
||||||
|
// assetFS 后台静态资源子树(admin.css / admin.js),经 BackstageAssetFS 交给路由挂载。
|
||||||
|
var assetFS fs.FS
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
// reviewDetailURL 生成审核详情页地址(partials / 页面模板里都会用到)。
|
||||||
|
"reviewDetailURL": reviewDetailURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
base, err := template.New("layout.html").Funcs(funcs).
|
||||||
|
ParseFS(backstageFS, "templates/layout.html", "templates/partials.html")
|
||||||
|
if err != nil {
|
||||||
|
panic("后台基模板解析失败: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := fs.ReadDir(backstageFS, "templates/pages")
|
||||||
|
if err != nil {
|
||||||
|
panic("后台页面模板目录读取失败: " + err.Error())
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".html") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clone, err := base.Clone()
|
||||||
|
if err != nil {
|
||||||
|
panic("克隆后台基模板失败: " + err.Error())
|
||||||
|
}
|
||||||
|
page, err := clone.ParseFS(backstageFS, "templates/pages/"+entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
// 模板语法错误在启动时直接 panic:go test / 起服务立刻暴露,
|
||||||
|
// 而不是等用户点到那个页面才 500(模板以前是字符串常量,编译期查不出来)。
|
||||||
|
panic("后台页面模板解析失败 " + entry.Name() + ": " + err.Error())
|
||||||
|
}
|
||||||
|
pageTemplates[strings.TrimSuffix(entry.Name(), ".html")] = page
|
||||||
|
}
|
||||||
|
|
||||||
|
assets, err := fs.Sub(backstageFS, "assets")
|
||||||
|
if err != nil {
|
||||||
|
panic("后台静态资源子树初始化失败: " + err.Error())
|
||||||
|
}
|
||||||
|
assetFS = assets
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackstageAssetFS 返回后台静态资源子树,供路由以 /admin/assets/ 挂载。
|
||||||
|
func BackstageAssetFS() fs.FS { return assetFS }
|
||||||
|
|
||||||
|
// PageNames 返回已注册的页面名(供测试断言模板集合齐全)。
|
||||||
|
func PageNames() []string {
|
||||||
|
names := make([]string, 0, len(pageTemplates))
|
||||||
|
for name := range pageTemplates {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。
|
||||||
|
//
|
||||||
|
// page 取 pages/ 下的文件名(如 "review-list");data 为页面数据,renderPage 会补上
|
||||||
|
// NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。
|
||||||
|
func renderPage(c *gin.Context, status int, page string, data gin.H) {
|
||||||
|
tpl, ok := pageTemplates[page]
|
||||||
|
if !ok {
|
||||||
|
c.String(http.StatusInternalServerError, "unknown backstage page: "+page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
data = gin.H{}
|
||||||
|
}
|
||||||
|
if _, exists := data["NavKey"]; !exists {
|
||||||
|
data["NavKey"] = page
|
||||||
|
}
|
||||||
|
if _, exists := data["PendingReviews"]; !exists {
|
||||||
|
data["PendingReviews"] = 0
|
||||||
|
}
|
||||||
|
if v, ok := c.Get(ctxNavStatsKey); ok {
|
||||||
|
if stats, ok := v.(gin.H); ok {
|
||||||
|
for k, val := range stats {
|
||||||
|
data[k] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "render error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NavStats 返回一个中间件:为后台每个页面预备导航统计(目前是待审总数)。
|
||||||
|
//
|
||||||
|
// 放在中间件而不是逐个 handler 里,是为了「任意页面都能看到待审队列深度」——
|
||||||
|
// 后台是内部工具、页面访问量小,每页多一次 count 查询可以接受。
|
||||||
|
func (h *BackstageHandler) NavStats() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h != nil && h.review != nil {
|
||||||
|
c.Set(ctxNavStatsKey, gin.H{"PendingReviews": h.PendingReviews(c.Request.Context())})
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingReviews 待审草稿总数。
|
||||||
|
//
|
||||||
|
// ⚠️ KindTabs() 的第 0 项是「全部」标签,它的 Pending 已经是各模块之和(见 ReviewService.KindTabs)。
|
||||||
|
// 因此不能对所有项无脑累加 —— 那会把待审数算成两倍。这里以「全部」项为准,退化为逐项累加时跳过空 Kind。
|
||||||
|
func (h *BackstageHandler) PendingReviews(ctx context.Context) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, k := range h.review.KindTabs(ctx) {
|
||||||
|
if k.Kind == "" {
|
||||||
|
return k.Pending
|
||||||
|
}
|
||||||
|
total += k.Pending
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
26
internal/handler/templates/layout.html
Normal file
26
internal/handler/templates/layout.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{{/*
|
||||||
|
后台页面外壳:<head> + 共享样式/脚本 + 页面内容插槽。
|
||||||
|
|
||||||
|
页面模板只需提供三个 define:
|
||||||
|
{{define "title"}} 浏览器标题
|
||||||
|
{{define "content"}} 正文(含自己的 .bar / .main 容器)
|
||||||
|
需要顶部导航时,在 content 里写 {{template "bar" .}}(登录页不需要,故不引用)。
|
||||||
|
|
||||||
|
样式与脚本走 /admin/assets/(同样是 embed 进二进制,零外部依赖、零构建):
|
||||||
|
CSS 独立成文件后浏览器可跨页缓存,也才有真正的 CSS 语法高亮与补全。
|
||||||
|
*/}}
|
||||||
|
{{define "layout"}}<!doctype html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{template "title" .}}</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "content" .}}
|
||||||
|
<div id="toast" class="flash ok" hidden></div>
|
||||||
|
<script src="/admin/assets/admin.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
15
internal/handler/templates/pages/brand-form.html
Normal file
15
internal/handler/templates/pages/brand-form.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{{define "title"}}{{if .UID}}编辑品牌{{else}}新建品牌{{end}} · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · {{if .UID}}编辑品牌{{else}}新建品牌{{end}}{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/brands">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<h1>{{if .UID}}编辑品牌{{else}}新建品牌{{end}}</h1>
|
||||||
|
<form method="post" action="{{if .UID}}/admin/brands/{{.UID}}{{else}}/admin/brands{{end}}">
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
<label>英文名(name_en)</label><input name="name_en" value="{{.NameEn}}" placeholder="如 Acne Studios">
|
||||||
|
<label>中文名(name_cn)</label><input name="name_cn" value="{{.NameCn}}" placeholder="如 艾克妮">
|
||||||
|
<button type="submit">{{if .UID}}保存{{else}}创建{{end}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
51
internal/handler/templates/pages/brands.html
Normal file
51
internal/handler/templates/pages/brands.html
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{{define "title"}}品牌管理 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 品牌管理{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/brands/new">+ 新建</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<h1>品牌列表</h1>
|
||||||
|
<form class="toolbar" method="get" action="/admin/brands">
|
||||||
|
<input name="keyword" placeholder="搜索品牌名…" value="{{.Keyword}}">
|
||||||
|
<button type="submit">搜索</button>
|
||||||
|
{{if .Keyword}}<a href="/admin/brands">清除</a>{{end}}
|
||||||
|
</form>
|
||||||
|
<div class="idx">
|
||||||
|
<a href="/admin/brands?keyword={{.Keyword}}" class="{{if eq .Letter ""}}on{{end}}">全部</a>
|
||||||
|
<a href="/admin/brands?letter=OTHER&keyword={{.Keyword}}" class="{{if eq .Letter "OTHER"}}on{{end}}">中文</a>
|
||||||
|
<a href="/admin/brands?letter=A&keyword={{.Keyword}}" class="{{if eq .Letter "A"}}on{{end}}">A</a>
|
||||||
|
<a href="/admin/brands?letter=B&keyword={{.Keyword}}" class="{{if eq .Letter "B"}}on{{end}}">B</a>
|
||||||
|
<a href="/admin/brands?letter=C&keyword={{.Keyword}}" class="{{if eq .Letter "C"}}on{{end}}">C</a>
|
||||||
|
<a href="/admin/brands?letter=D&keyword={{.Keyword}}" class="{{if eq .Letter "D"}}on{{end}}">D</a>
|
||||||
|
<a href="/admin/brands?letter=E&keyword={{.Keyword}}" class="{{if eq .Letter "E"}}on{{end}}">E</a>
|
||||||
|
<a href="/admin/brands?letter=F&keyword={{.Keyword}}" class="{{if eq .Letter "F"}}on{{end}}">F</a>
|
||||||
|
<a href="/admin/brands?letter=G&keyword={{.Keyword}}" class="{{if eq .Letter "G"}}on{{end}}">G</a>
|
||||||
|
<a href="/admin/brands?letter=H&keyword={{.Keyword}}" class="{{if eq .Letter "H"}}on{{end}}">H</a>
|
||||||
|
<a href="/admin/brands?letter=I&keyword={{.Keyword}}" class="{{if eq .Letter "I"}}on{{end}}">I</a>
|
||||||
|
<a href="/admin/brands?letter=J&keyword={{.Keyword}}" class="{{if eq .Letter "J"}}on{{end}}">J</a>
|
||||||
|
<a href="/admin/brands?letter=K&keyword={{.Keyword}}" class="{{if eq .Letter "K"}}on{{end}}">K</a>
|
||||||
|
<a href="/admin/brands?letter=L&keyword={{.Keyword}}" class="{{if eq .Letter "L"}}on{{end}}">L</a>
|
||||||
|
<a href="/admin/brands?letter=M&keyword={{.Keyword}}" class="{{if eq .Letter "M"}}on{{end}}">M</a>
|
||||||
|
<a href="/admin/brands?letter=N&keyword={{.Keyword}}" class="{{if eq .Letter "N"}}on{{end}}">N</a>
|
||||||
|
<a href="/admin/brands?letter=O&keyword={{.Keyword}}" class="{{if eq .Letter "O"}}on{{end}}">O</a>
|
||||||
|
<a href="/admin/brands?letter=P&keyword={{.Keyword}}" class="{{if eq .Letter "P"}}on{{end}}">P</a>
|
||||||
|
<a href="/admin/brands?letter=Q&keyword={{.Keyword}}" class="{{if eq .Letter "Q"}}on{{end}}">Q</a>
|
||||||
|
<a href="/admin/brands?letter=R&keyword={{.Keyword}}" class="{{if eq .Letter "R"}}on{{end}}">R</a>
|
||||||
|
<a href="/admin/brands?letter=S&keyword={{.Keyword}}" class="{{if eq .Letter "S"}}on{{end}}">S</a>
|
||||||
|
<a href="/admin/brands?letter=T&keyword={{.Keyword}}" class="{{if eq .Letter "T"}}on{{end}}">T</a>
|
||||||
|
<a href="/admin/brands?letter=U&keyword={{.Keyword}}" class="{{if eq .Letter "U"}}on{{end}}">U</a>
|
||||||
|
<a href="/admin/brands?letter=V&keyword={{.Keyword}}" class="{{if eq .Letter "V"}}on{{end}}">V</a>
|
||||||
|
<a href="/admin/brands?letter=W&keyword={{.Keyword}}" class="{{if eq .Letter "W"}}on{{end}}">W</a>
|
||||||
|
<a href="/admin/brands?letter=X&keyword={{.Keyword}}" class="{{if eq .Letter "X"}}on{{end}}">X</a>
|
||||||
|
<a href="/admin/brands?letter=Y&keyword={{.Keyword}}" class="{{if eq .Letter "Y"}}on{{end}}">Y</a>
|
||||||
|
<a href="/admin/brands?letter=Z&keyword={{.Keyword}}" class="{{if eq .Letter "Z"}}on{{end}}">Z</a>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>编码 ID</th><th>英文名</th><th>中文名</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{template "pager" .Pager}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
18
internal/handler/templates/pages/dashboard.html
Normal file
18
internal/handler/templates/pages/dashboard.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{{define "title"}}管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · Fashion Archive{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/logout">退出({{.Username}})</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<h1>欢迎,{{.Username}}</h1>
|
||||||
|
{{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/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>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
5
internal/handler/templates/pages/error.html
Normal file
5
internal/handler/templates/pages/error.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}错误{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>出错了</h1><p style="color:#c0392b">{{.Error}}</p>
|
||||||
|
<p><a href="/admin/">返回首页</a></p>
|
||||||
|
{{end}}
|
||||||
24
internal/handler/templates/pages/ingest-jobs.html
Normal file
24
internal/handler/templates/pages/ingest-jobs.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{{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">worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>状态</th><th>重试</th><th>错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Jobs}}<tr>
|
||||||
|
<td><code>{{.ID}}</code></td>
|
||||||
|
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||||
|
<td>{{.Attempts}}</td>
|
||||||
|
<td class="err break">{{.LastError}}</td>
|
||||||
|
<td class="muted">{{.UpdatedAt}}</td>
|
||||||
|
<td>{{if eq .Status "failed"}}<form class="retry" method="post" action="/admin/ingest-jobs/{{.ID}}/retry"><button class="retry" type="submit">重试</button></form>{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{template "pager" .Pager}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
10
internal/handler/templates/pages/login.html
Normal file
10
internal/handler/templates/pages/login.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{{define "title"}}管理后台 · 登录{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="wrap"><h1>管理后台登录</h1>
|
||||||
|
<form method="post" action="/admin/login">
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
<label>账号</label><input name="account" value="{{.Account}}" autofocus>
|
||||||
|
<label>密码</label><input name="password" type="password">
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
</form></div>
|
||||||
|
{{end}}
|
||||||
56
internal/handler/templates/pages/review-detail.html
Normal file
56
internal/handler/templates/pages/review-detail.html
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{{define "title"}}草稿审核 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 草稿审核 #{{.Draft.ID}}{{end}}
|
||||||
|
{{define "barRight"}}<a href="{{reviewDetailURL .Draft.Kind .Draft.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main mid">
|
||||||
|
<p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b></p>
|
||||||
|
|
||||||
|
{{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}}
|
||||||
|
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve">
|
||||||
|
{{range .Draft.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 .Draft.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 .Draft.Images}} 张 · {{len .Draft.Groups}} 组</span>)</h2>
|
||||||
|
{{/* 合并表单本体在 grid 之前;各格的 radio/checkbox 通过 form="batch" 关联过来 */}}
|
||||||
|
<form id="batch" method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/images/attach"></form>
|
||||||
|
<div class="grid">
|
||||||
|
{{range .Draft.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/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}}
|
||||||
|
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.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 .Draft.Images}} 张)</h2>
|
||||||
|
<div class="grid">
|
||||||
|
{{range .Draft.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/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
|
||||||
|
</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
50
internal/handler/templates/pages/review-list.html
Normal file
50
internal/handler/templates/pages/review-list.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{{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=approved" class="{{if eq .Status "approved"}}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 .Drafts}}<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}}
|
||||||
13
internal/handler/templates/pages/runway-detail.html
Normal file
13
internal/handler/templates/pages/runway-detail.html
Normal file
@ -0,0 +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 "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}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
32
internal/handler/templates/pages/runway-edit.html
Normal file
32
internal/handler/templates/pages/runway-edit.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{{define "title"}}编辑走秀 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 编辑走秀 {{.UID}}{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/runways/{{.UID}}">返回详情</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<p class="muted">品牌 {{.BrandName}}{{if .IsDown}} · <span class="down">已下架</span>{{end}}</p>
|
||||||
|
<form method="post" action="/admin/runways/{{.UID}}/edit">
|
||||||
|
<div class="row"><div><label>英文标题</label><input name="title_en" value="{{.Runway.TitleEn}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Runway.TitleCn}}"></div></div>
|
||||||
|
<div class="row"><div><label>英文描述</label><textarea name="description_en">{{.Runway.DescriptionEn}}</textarea></div><div><label>中文描述</label><textarea name="description_cn">{{.Runway.DescriptionCn}}</textarea></div></div>
|
||||||
|
<div class="row"><div><label>年份</label><input name="year" value="{{.Runway.Year}}"></div><div><label>季节</label><input name="season" value="{{.Runway.Season}}"></div></div>
|
||||||
|
<div class="row"><div><label>系列 (collection_type)</label><input name="collection_type" value="{{.Runway.CollectionType}}"></div><div><label>季节码 (season_code)</label><input name="season_code" value="{{.Runway.SeasonCode}}"></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/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}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}}
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
39
internal/handler/templates/pages/runways.html
Normal file
39
internal/handler/templates/pages/runways.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{{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></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><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{template "pager" .Pager}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
30
internal/handler/templates/pages/snap-edit.html
Normal file
30
internal/handler/templates/pages/snap-edit.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{{define "title"}}编辑街拍 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 编辑街拍 {{.UID}}{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/street-snaps/{{.UID}}">返回详情</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<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="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}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}}
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
13
internal/handler/templates/pages/street-snap-detail.html
Normal file
13
internal/handler/templates/pages/street-snap-detail.html
Normal file
@ -0,0 +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 "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}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
27
internal/handler/templates/pages/street-snaps.html
Normal file
27
internal/handler/templates/pages/street-snaps.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{{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></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{template "pager" .Pager}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
30
internal/handler/templates/pages/users.html
Normal file
30
internal/handler/templates/pages/users.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{{define "title"}}用户管理 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 用户管理{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<h1>用户管理({{len .Users}})</h1>
|
||||||
|
<p class="muted">图片质量模型已统一:所有用户(含未登录)看图集详情均走同一高清样式,不再按等级分级。</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>等级</th><th>操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Users}}<tr>
|
||||||
|
<td><code>{{.ID}}</code></td>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td class="muted">{{.Email}}</td>
|
||||||
|
<td>{{if eq .Tier "vip"}}<span class="badge vip">VIP</span>{{else}}<span class="badge free">FREE</span>{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<form class="tier" method="post" action="/admin/users/{{.ID}}/tier">
|
||||||
|
<select name="tier">
|
||||||
|
<option value="free" {{if eq .Tier "free"}}selected{{end}}>free(普通)</option>
|
||||||
|
<option value="vip" {{if eq .Tier "vip"}}selected{{end}}>vip</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">保存</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
37
internal/handler/templates/partials.html
Normal file
37
internal/handler/templates/partials.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{{/*
|
||||||
|
共享片段:所有后台页面通用的顶部导航与翻页器。
|
||||||
|
|
||||||
|
由 templates.go 在启动时与 layout.html 一起解析进「基模板」,各页面模板只需
|
||||||
|
{{template "bar" .}} / {{template "pager" .Pager}} 引用,改一处即全局生效
|
||||||
|
—— 这也是把导航从 14 份复制粘贴里提出来、补全模块入口的原因。
|
||||||
|
|
||||||
|
页面需要提供的数据:
|
||||||
|
.NavKey 由 renderPage 自动注入(= 当前页面名),用于导航高亮
|
||||||
|
.PendingReviews 由 backstageNavStats 中间件注入(待审总数,导航角标)
|
||||||
|
barLabel/barRight 两个 define 由各页面模板自己提供(见 pages/*.html)
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{define "bar"}}
|
||||||
|
<div class="bar">
|
||||||
|
<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/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>
|
||||||
|
<span class="sep"></span>
|
||||||
|
{{template "barRight" .}}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "pager"}}
|
||||||
|
<div class="pager">
|
||||||
|
{{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
|
||||||
|
{{if .Label}}<span class="muted">{{.Label}}</span>{{end}}
|
||||||
|
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@ -44,6 +44,15 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
r.Static(opt.UploadURLPrefix, opt.UploadDir)
|
r.Static(opt.UploadURLPrefix, opt.UploadDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后台自有静态资源(admin.css / admin.js):embed 进二进制,无 CDN、无构建产物依赖;
|
||||||
|
// 给一段缓存时间,避免每翻一页都重新下载。
|
||||||
|
assets := r.Group("/admin/assets")
|
||||||
|
assets.Use(func(c *gin.Context) {
|
||||||
|
c.Header("Cache-Control", "public, max-age=600")
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
assets.StaticFS("/", http.FS(handler.BackstageAssetFS()))
|
||||||
|
|
||||||
// 公开:登录页 / 登录动作 / 登出(登出也要先有 cookie,但不需要先校验)
|
// 公开:登录页 / 登录动作 / 登出(登出也要先有 cookie,但不需要先校验)
|
||||||
r.GET("/admin/login", opt.Backstage.LoginPage)
|
r.GET("/admin/login", opt.Backstage.LoginPage)
|
||||||
r.POST("/admin/login", opt.Backstage.Login)
|
r.POST("/admin/login", opt.Backstage.Login)
|
||||||
@ -65,7 +74,15 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
// 受保护:后台页面,统一走 cookie 鉴权中间件
|
// 受保护:后台页面,统一走 cookie 鉴权中间件
|
||||||
admin := r.Group("/admin")
|
admin := r.Group("/admin")
|
||||||
admin.Use(requireBackstageLogin(opt.JWT))
|
admin.Use(requireBackstageLogin(opt.JWT))
|
||||||
|
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
|
||||||
|
admin.Use(opt.Backstage.NavStats())
|
||||||
{
|
{
|
||||||
|
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
|
||||||
|
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
|
||||||
|
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch)
|
||||||
|
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
|
||||||
|
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
|
||||||
|
|
||||||
admin.GET("/", opt.Backstage.Dashboard)
|
admin.GET("/", opt.Backstage.Dashboard)
|
||||||
admin.GET("/brands", opt.Backstage.Brands)
|
admin.GET("/brands", opt.Backstage.Brands)
|
||||||
admin.GET("/brands/new", opt.Backstage.BrandNew)
|
admin.GET("/brands/new", opt.Backstage.BrandNew)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -293,10 +294,15 @@ func TestBackstage(t *testing.T) {
|
|||||||
|
|
||||||
// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)─────────────────
|
// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)─────────────────
|
||||||
|
|
||||||
// fakeReview 实现审核服务接口;嵌入接口兜底未用到的方法,仅覆盖分组相关 4 个。
|
// fakeReview 实现审核服务接口;
|
||||||
|
// ⚠️ 嵌入的 service.ReviewService 是 nil 接口,只对「确实实现的方法」安全 ——
|
||||||
|
// 任何未覆盖的方法被调用时都会 nil panic(曾经因为导航中间件调 KindTabs 而炸出来)。
|
||||||
|
// 因此凡是页面/接口会走到的服务方法,都必须在这里显式实现。
|
||||||
type fakeReview struct {
|
type fakeReview struct {
|
||||||
service.ReviewService
|
service.ReviewService
|
||||||
attachOK bool
|
attachOK bool
|
||||||
|
approveErr error
|
||||||
|
rejectErr error
|
||||||
last struct {
|
last struct {
|
||||||
kind string
|
kind string
|
||||||
draftID uint32
|
draftID uint32
|
||||||
@ -305,6 +311,13 @@ type fakeReview struct {
|
|||||||
imgID uint32
|
imgID uint32
|
||||||
op string
|
op string
|
||||||
}
|
}
|
||||||
|
// calls 记录批量审核的逐条调用,供断言。
|
||||||
|
calls []struct {
|
||||||
|
Action string
|
||||||
|
Kind string
|
||||||
|
ID uint32
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
view *service.DraftDetailView
|
view *service.DraftDetailView
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -332,6 +345,39 @@ func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, i
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KindTabs 是导航中间件在每个后台页面上都会调用的方法(用于显示待审计数角标),
|
||||||
|
// 必须显式实现,否则会落到嵌入的 nil 接口上 panic。
|
||||||
|
func (f *fakeReview) KindTabs(_ context.Context) []service.KindTab {
|
||||||
|
return []service.KindTab{
|
||||||
|
{Kind: "", Label: "全部", Pending: 2},
|
||||||
|
{Kind: "street", Label: "街拍", Pending: 1},
|
||||||
|
{Kind: "runway", Label: "走秀", Pending: 1},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer string) (uint32, error) {
|
||||||
|
f.calls = append(f.calls, struct {
|
||||||
|
Action string
|
||||||
|
Kind string
|
||||||
|
ID uint32
|
||||||
|
Reason string
|
||||||
|
}{"approve", kind, id, ""})
|
||||||
|
if f.approveErr != nil {
|
||||||
|
return 0, f.approveErr
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReview) Reject(_ context.Context, kind string, id uint32, reviewer, reason string) error {
|
||||||
|
f.calls = append(f.calls, struct {
|
||||||
|
Action string
|
||||||
|
Kind string
|
||||||
|
ID uint32
|
||||||
|
Reason string
|
||||||
|
}{"reject", kind, id, reason})
|
||||||
|
return f.rejectErr
|
||||||
|
}
|
||||||
|
|
||||||
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m := jwt.NewManager("test-secret", 168)
|
m := jwt.NewManager("test-secret", 168)
|
||||||
@ -462,3 +508,136 @@ func TestBackstageStreetRenderGroups(t *testing.T) {
|
|||||||
t.Fatalf("不应再出现「并入上一张」")
|
t.Fatalf("不应再出现「并入上一张」")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 新增:静态资源、导航统计 & 批量审核 AJAX 接口 ───────────────────────────
|
||||||
|
|
||||||
|
// TestBackstageAssets 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。
|
||||||
|
func TestBackstageAssets(t *testing.T) {
|
||||||
|
r, _ := newReviewRouter(t)
|
||||||
|
|
||||||
|
for _, tc := range []struct{ path, want string }{
|
||||||
|
{"/admin/assets/admin.css", ".bar-nav"},
|
||||||
|
{"/admin/assets/admin.js", "data-admin-js"},
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("%s 应 200,实际 %d", tc.path, w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), tc.want) {
|
||||||
|
t.Fatalf("%s 内容缺少 %q", tc.path, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackstageNavStatsOnEveryPage 后台任意页面都带待审计数角标。
|
||||||
|
// 该角标由 NavStats 中间件提供;它同时是「所有页面都会调用 ReviewService.KindTabs」的证据。
|
||||||
|
func TestBackstageNavStatsOnEveryPage(t *testing.T) {
|
||||||
|
r, _ := newReviewRouter(t)
|
||||||
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/brands", nil)
|
||||||
|
req.AddCookie(tok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("品牌页应 200: %d", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "bar-badge") || !strings.Contains(body, ">2<") {
|
||||||
|
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
|
||||||
|
}
|
||||||
|
// 导航已在共享 partial 里,各模块入口都应在
|
||||||
|
for _, label := range []string{"审核", "走秀", "街拍", "品牌", "入库任务", "用户"} {
|
||||||
|
if !strings.Contains(body, ">"+label) {
|
||||||
|
t.Fatalf("导航应包含入口 %q", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackstageReviewBatchApprove 批量通过:一次请求处理多条,返回 ok 计数。
|
||||||
|
func TestBackstageReviewBatchApprove(t *testing.T) {
|
||||||
|
r, rev := newReviewRouter(t)
|
||||||
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
|
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.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(tok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("批量通过应 200: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
OK int `json:"ok"`
|
||||||
|
Pending int64 `json:"pending"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||||
|
t.Fatalf("响应不是合法 JSON: %v", err)
|
||||||
|
}
|
||||||
|
if res.OK != 2 {
|
||||||
|
t.Fatalf("应通过 2 条,实际 %d", res.OK)
|
||||||
|
}
|
||||||
|
if res.Pending != 2 {
|
||||||
|
t.Fatalf("应回带最新待审计数 2,实际 %d", res.Pending)
|
||||||
|
}
|
||||||
|
if len(rev.calls) != 2 || rev.calls[0].ID != 11 || rev.calls[1].Kind != "runway" {
|
||||||
|
t.Fatalf("逐条调用未透传到 service: %+v", rev.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackstageReviewBatchRejectNeedsReason 驳回必须带理由(留痕要求),否则 400 且不调用 service。
|
||||||
|
func TestBackstageReviewBatchRejectNeedsReason(t *testing.T) {
|
||||||
|
r, rev := newReviewRouter(t)
|
||||||
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
|
body := `{"action":"reject","items":[{"kind":"street","id":11}]}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(tok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Fatalf("缺理由应 400: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(rev.calls) != 0 {
|
||||||
|
t.Fatalf("缺理由不应调用 service: %+v", rev.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackstageReviewBatchPartialFailure 未注册类型只计失败,其余照常处理(不整体回滚)。
|
||||||
|
func TestBackstageReviewBatchPartialFailure(t *testing.T) {
|
||||||
|
r, rev := newReviewRouter(t)
|
||||||
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
|
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.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(tok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("部分失败仍应 200: %d", w.Code)
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
OK int `json:"ok"`
|
||||||
|
Failed []struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
} `json:"failed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||||
|
t.Fatalf("响应不是合法 JSON: %v", err)
|
||||||
|
}
|
||||||
|
if res.OK != 1 || len(res.Failed) != 1 || res.Failed[0].ID != 12 {
|
||||||
|
t.Fatalf("应 1 成功 1 失败(id=12): ok=%d failed=%+v", res.OK, res.Failed)
|
||||||
|
}
|
||||||
|
if len(rev.calls) != 1 {
|
||||||
|
t.Fatalf("未注册类型不应调用 service: %+v", rev.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user