@ -42,7 +42,10 @@ func NewBackstageHandler(auth service.AuthService, brand service.BrandService, a
// pagerTmpl 是所有后台列表页共享的翻页片段定义(用法:{{template "pager" .Pager}})。
// 以「base」为根模板并只含一个 define; renderHTML 每次渲染时 Clone 它再灌入页面正文,
// 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。
var pagerTmpl = template . Must ( template . New ( "base" ) . Parse ( ` {{ define "pager" }} <div class="pager">
var pagerTmpl = template . Must ( template . New ( "base" ) . Funcs ( template . FuncMap {
// reviewDetailURL 生成审核详情页地址; main 非零时附加 ?main= 以保留折叠状态。
"reviewDetailURL" : reviewDetailURL ,
} ) . Parse ( ` {{ 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 }}
@ -394,6 +397,8 @@ func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
renderHTML ( c , http . StatusNotFound , tplError , gin . H { "Error" : "草稿不存在或已删除: " + c . Param ( "id" ) } )
return
}
// 当前选定的主图(用于审核页按组折叠时保持折叠状态),来自 ?main= 查询参数。
draft . MainID = uint32 ( atoiDefault ( c . Query ( "main" ) , 0 ) )
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。
brandName := ""
if draft . BrandID > 0 {
@ -465,6 +470,76 @@ func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) {
c . Redirect ( http . StatusFound , "/admin/reviews/" + kind + "/" + c . Param ( "id" ) )
}
// ReviewAttachDraftImages 把若干张草稿图并入指定主图(街拍审核页「批量并入」)。
func ( h * BackstageHandler ) ReviewAttachDraftImages ( c * gin . Context ) {
kind := c . Param ( "kind" )
id := atoiDefault ( c . Param ( "id" ) , 0 )
if id == 0 {
renderHTML ( c , http . StatusBadRequest , tplError , gin . H { "Error" : "参数无效" } )
return
}
mainID := uint32 ( atoiDefault ( c . PostForm ( "main" ) , 0 ) )
imgIDs := make ( [ ] uint32 , 0 )
for _ , s := range c . PostFormArray ( "img" ) {
if v := atoiDefault ( s , 0 ) ; v > 0 {
imgIDs = append ( imgIDs , uint32 ( v ) )
}
}
if mainID == 0 {
renderHTML ( c , http . StatusBadRequest , tplError , gin . H { "Error" : "请先选定主图" } )
return
}
if err := h . review . AttachDraftImages ( c . Request . Context ( ) , kind , uint32 ( id ) , mainID , imgIDs ) ; err != nil {
renderHTML ( c , http . StatusInternalServerError , tplError , gin . H { "Error" : err . Error ( ) } )
return
}
c . Redirect ( http . StatusFound , reviewDetailURL ( kind , uint32 ( id ) , mainID ) )
}
// ReviewAttachPrevDraftImage 把某张图并入「同草稿内上一行所属组的主图」。
func ( h * BackstageHandler ) ReviewAttachPrevDraftImage ( c * gin . Context ) {
kind := c . Param ( "kind" )
id := atoiDefault ( c . Param ( "id" ) , 0 )
imgID := atoiDefault ( c . Param ( "img" ) , 0 )
if id == 0 || imgID == 0 {
renderHTML ( c , http . StatusBadRequest , tplError , gin . H { "Error" : "参数无效" } )
return
}
if err := h . review . AttachPrevDraftImage ( c . Request . Context ( ) , kind , uint32 ( id ) , uint32 ( imgID ) ) ; err != nil {
renderHTML ( c , http . StatusInternalServerError , tplError , gin . H { "Error" : err . Error ( ) } )
return
}
mainID := uint32 ( atoiDefault ( c . Query ( "main" ) , 0 ) )
c . Redirect ( http . StatusFound , reviewDetailURL ( kind , uint32 ( id ) , mainID ) )
}
// ReviewDetachDraftImage 把副图拆出恢复为主图。
func ( h * BackstageHandler ) ReviewDetachDraftImage ( c * gin . Context ) {
kind := c . Param ( "kind" )
id := atoiDefault ( c . Param ( "id" ) , 0 )
imgID := atoiDefault ( c . Param ( "img" ) , 0 )
if id == 0 || imgID == 0 {
renderHTML ( c , http . StatusBadRequest , tplError , gin . H { "Error" : "参数无效" } )
return
}
if err := h . review . DetachDraftImage ( c . Request . Context ( ) , kind , uint32 ( id ) , uint32 ( imgID ) ) ; err != nil {
renderHTML ( c , http . StatusInternalServerError , tplError , gin . H { "Error" : err . Error ( ) } )
return
}
// 拆出后该图自身成为主图,跳转回到「以它为主图」的折叠视图。
c . Redirect ( http . StatusFound , reviewDetailURL ( kind , uint32 ( id ) , uint32 ( imgID ) ) )
}
// reviewDetailURL 生成审核详情页跳转地址; main 非零时附加 ?main= 以保留折叠状态。
// main 为可选参数:省略或传 0 则不附加查询串。
func reviewDetailURL ( kind string , id uint32 , mainArgs ... uint32 ) string {
u := "/admin/reviews/" + kind + "/" + strconv . FormatUint ( uint64 ( id ) , 10 )
if len ( mainArgs ) > 0 && mainArgs [ 0 ] != 0 {
u += "?main=" + strconv . FormatUint ( uint64 ( mainArgs [ 0 ] ) , 10 )
}
return u
}
// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews) 。
func reviewListURL ( kind string ) string {
if kind == dto . IngestKindRunway || kind == dto . IngestKindStreet {
@ -1252,35 +1327,84 @@ body{font:14px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;backgrou
.bar { background:#111;color:#fff;padding:12px 24px;display:flex;justify-content:space-between;align-items:center}
.bar a { color:#fff;text-decoration:none}
.main { max-width:900px;margin:24px auto;padding:0 24px}
h1 { font-size:20 px}
h2 { font-size:16px;margin:22px 0 8 px}
.muted { color:#777}
label { display:block;margin:14px 0 4px;color:#555}
input,textarea { width:100%;padding:9px 10px;box-sizing:border-box;border:1px solid #ccc;border-radius:6px;font-size:14px;font-family:inherit}
textarea { min-height:72px}
button { margin-top:16px;padding:10px 18px;background:#111;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:14px}
button.reject { background:#c0392b}
.row { display:flex;gap :14 px}.row>div { flex:1 }
button.ghost { background:#fff;color:#111;border :1px solid #ccc }
.kind { font-size:12px;padding:1px 7px;border-radius:10px;background:#eef2ff;color:#3b4bb5}
.kind.street { background:#eafaf0;color:#1a7f43}
.hint { position:sticky;top:0;background:#fffbe6;border:1px solid #f5e08a;color:#7a5b00;padding:8px 12px;border-radius:6px;margin:12px 0;font-size:13px}
.strip-group { background:#fff;border:1px solid #eee;border-radius:10px;padding:12px;margin:12px 0}
.strip-group.cur { border-color:#1a7f43}
.strip-group .main-img { display:flex;gap:14px;align-items:flex-start}
.strip-group .main-img img { width:140px;height:210px;object-fit:cover;border-radius:8px}
.strip-group .main-img .acts { flex:1;display:flex;gap:6px;flex-wrap:wrap}
.strip-group .main-img .acts button { margin:0;padding:7px 12px;font-size:13px}
.strip-group form.inline { display:inline}
.strip { display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-top:10px}
.strip .det { position:relative;background:#fafafa;border:1px solid #eee;border-radius:8px;overflow:hidden}
.strip .det img { width:100%;height:160px;object-fit:cover;display:block}
.strip .det .acts { padding:6px;display:flex;gap:6px}
.strip .det .acts button { margin:0;flex:1;padding:6px;font-size:12px}
.grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:14px}
.cell { background:#fff;border:1px solid #eee;border-radius:8px;overflow:hidden}
.cell img { width:100%;display:block;aspect-ratio:2/3;object-fit:cover}
.cell .acts { padding:8px;display:flex;gap:6px}
.cell .acts button { margin:0;flex:1;padding:7px;font-size:13px}
.kind { font-size:12px;padding:1px 7px;border-radius:10px;background:#eef2ff;color:#3b4bb5}
.kind.street { background:#eafaf0;color:#1a7f43}
</style></head>
<body>
<div class="bar"><span>管理后台 · 草稿审核 # {{ .Draft .ID }} </span><span><a href="/admin/reviews {{ if ne .Draft .Kind "" }} / {{ .Draft .Kind }} {{ end }} ">返回列表</a> · <a href="/admin/logout">退出</a></span></div>
<div class="bar"><span>管理后台 · 草稿审核 # {{ .Draft .ID }} </span><span><a href="{{ reviewDetailURL .Draft .Kind .Draft .ID }} ">返回列表</a> · <a href="/admin/logout">退出</a></span></div>
<div class="main">
<p class="muted">类型 <b> {{ .Draft .Kind }} </b> {{ if .BrandName }} · 品牌 {{ .BrandName }} {{ end }} · 状态 <b> {{ .Draft .Status }} </b></p>
<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>
<form method="post" action="/admin/reviews/ {{ .Draft .Kind }} / {{ .Draft .ID }} /reject" style="margin-top:18px">
<label>驳回理由(可选)</label><input name="reason" placeholder="如:图片缺失 / 信息有误" >
<button type="submit" class="reject">驳回</button >
</form >
<h2 style="margin-top:24px">图片管理( {{ len .Draft .Images }} 张)</h2 >
{{ if eq .Draft .Kind "street" }} {{ if .Draft .MainID }} <div class="hint">当前主图:# {{ .Draft .Main ID }} (折叠视图;点「拆出」可恢复独立主图)</div> {{ end }}
<h2>主副图分组( {{ len .Draft .Groups }} 组 · 共 {{ .Draft .ImageCount }} 张)</h2 >
{{ if .Draft .Groups }} {{ range .Draft .Groups }} <div class="strip-group {{ if eq .Image .ID $ .Draft .MainID }} cur {{ end }} " >
<div class="main-img" >
<img src=" {{ .Image .Image }} " loading="lazy" >
<div class="acts">
<form method="post" class="inline" action=" {{ reviewDetailURL $ .Draft .Kind $ .Draft .ID }} "><input type="hidden" name="main" value=" {{ .Image .ID }} "><button type="submit" class="ghost">设为主图</button></form>
<form method="post" class="inline" action="/admin/reviews/ {{ $ .Draft .Kind }} / {{ $ .Draft .ID }} /images/ {{ .Image .ID }} /detach"><button type="submit" onclick="return confirm('确认把这张主图拆成独立主图?')">拆出</button></form>
<form method="post" class="inline" action="/admin/reviews/ {{ $ .Draft .Kind }} / {{ $ .Draft .ID }} /images/ {{ .Image .ID }} /delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
</div>
{{ if .Details }} <div class="strip">
{{ range .Details }} <div class="det">
<img src=" {{ .Image }} " loading="lazy">
<span style="display:block;padding:4px 6px;font-size:11px;color:#666"> {{ .Name }} </span>
<div class="acts">
<form method="post" action="/admin/reviews/ {{ $ .Draft .Kind }} / {{ $ .Draft .ID }} /images/ {{ .ID }} /attach-prev"><input type="hidden" name="main" value=" {{ $ .Draft .MainID }} "><button type="submit" onclick="return confirm('把这张并入上一张所属主图?')">并入上一张</button></form>
<form method="post" action="/admin/reviews/ {{ $ .Draft .Kind }} / {{ $ .Draft .ID }} /images/ {{ .ID }} /detach"><button type="submit" onclick="return confirm('把这张拆成独立主图?')">拆出</button></form>
<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 }} {{ else }} <p class="muted">该草稿暂无图片。</p> {{ end }}
<h2>全部图片(点选并入当前主图)</h2>
<div class="grid">
{{ range .Draft .Images }} <div class="cell">
<img src=" {{ .Image }} " loading="lazy">
<span style="display:block;padding:6px 8px;font-size:12px;color:#666"> {{ .Name }} {{ if eq .IsDetail 1 }} · 副图 {{ end }} </span>
<div class="acts">
{{ if eq .IsDetail 1 }}
<form method="post" action="/admin/reviews/ {{ $ .Draft .Kind }} / {{ $ .Draft .ID }} /images/ {{ .ID }} /detach"><button type="submit" onclick="return confirm('把这张拆成独立主图?')">拆出</button></form>
<form method="post" action="/admin/reviews/ {{ $ .Draft .Kind }} / {{ $ .Draft .ID }} /images/ {{ .ID }} /delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
{{ else }}
<form method="post" action="/admin/reviews/ {{ $ .Draft .Kind }} / {{ $ .Draft .ID }} /images/attach"><input type="hidden" name="main" value=" {{ $ .Draft .MainID }} "><input type="hidden" name="img" value=" {{ .ID }} "><button type="submit" {{ if eq .ID $ .Draft .MainID }} disabled {{ end }} >并入当前主图</button></form>
<form method="post" action="/admin/reviews/ {{ $ .Draft .Kind }} / {{ $ .Draft .ID }} /images/ {{ .ID }} /delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
{{ end }}
</div>
</div> {{ end }}
</div>
{{ else }}
<h2>图片管理( {{ len .Draft .Images }} 张)</h2>
<div class="grid">
{{ range .Draft .Images }} <div class="cell">
<img src=" {{ .Image }} " loading="lazy">
@ -1290,6 +1414,7 @@ button.reject{background:#c0392b}
</div>
</div> {{ end }}
</div>
{{ end }}
</div></body></html> `
const tplRunwayEdit = ` <!doctype html><html lang="zh"><head><meta charset="utf-8">