@ -2,6 +2,7 @@ package handler
import (
"bytes"
"fmt"
"html/template"
"net/http"
"strconv"
@ -10,6 +11,7 @@ import (
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/pagination"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
@ -37,9 +39,24 @@ func NewBackstageHandler(auth service.AuthService, brand service.BrandService, a
return & BackstageHandler { auth : auth , brand : brand , article : article , street : street , review : review , ingest : ingest }
}
// pagerTmpl 是所有后台列表页共享的翻页片段定义(用法:{{template "pager" .Pager}})。
// 以「base」为根模板并只含一个 define; renderHTML 每次渲染时 Clone 它再灌入页面正文,
// 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。
var pagerTmpl = template . Must ( template . New ( "base" ) . 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 }}
</div> {{ end }} ` ) )
// renderHTML 用单文件模板字符串渲染 HTML( 不依赖外部模板文件, 保持自包含) 。
// 在解析页面正文前先 Clone 含 {{define "pager"}} 的基类模板,使各列表页可复用翻页片段。
func renderHTML ( c * gin . Context , status int , tpl string , data any ) {
t , err := template . New ( "t" ) . Parse ( tpl )
base , err := pagerTmpl . Clone ( )
if err != nil {
c . String ( http . StatusInternalServerError , "template clone error: " + err . Error ( ) )
return
}
t , err := base . Parse ( tpl )
if err != nil {
c . String ( http . StatusInternalServerError , "template error: " + err . Error ( ) )
return
@ -136,20 +153,23 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
return
}
const size = 30
page := q . Page
if page < 1 {
page = 1
p := pagination . Params { Page : q . Page , PageSize : size }
if p. P age < 1 {
p. P age = 1
}
meta := p . NewMeta ( total )
renderHTML ( c , http . StatusOK , tplBrands , gin . H {
"Items" : items ,
"Total" : total ,
"Page" : page ,
"PrevPage" : page - 1 ,
"Next Page" : page + 1 ,
"Has Prev" : page > 1 ,
"Has Next" : int64 ( page * size ) < total ,
"Keyword" : q . Keyword ,
"Letter" : q . Letter ,
"Items" : items ,
"Total" : total ,
"Keyword" : q . Keyword ,
"Letter" : q . Letter ,
"Pager " : pagination . Pager {
PrevURL : pagination . PageLink ( c , meta . PrevPage ) ,
NextURL : pagination . PageLink ( c , meta . NextPage ) ,
HasPrev : meta . HasPrev ,
HasNext : meta . HasNext ,
Label : fmt . Sprintf ( "第 %d 页 · 共 %d 条" , meta . Page , total ) ,
} ,
} )
}
@ -214,22 +234,26 @@ func (h *BackstageHandler) Runways(c *gin.Context) {
renderHTML ( c , http . StatusInternalServerError , tplError , gin . H { "Error" : err . Error ( ) } )
return
}
pag e : = q . Page
if page < 1 {
p age = 1
const siz e = 24
p := pagination . Params { Page : q . Page , PageSize : size }
if p . P age < 1 {
p . Page = 1
}
meta := p . NewMeta ( total )
renderHTML ( c , http . StatusOK , tplRunways , gin . H {
"Items" : items ,
"Total" : total ,
"Page" : page ,
"PrevPage" : page - 1 ,
"NextPage" : page + 1 ,
"HasPrev" : page > 1 ,
"HasNext" : int64 ( page * 24 ) < total ,
"Collection" : q . Collection ,
"Season" : q . Season ,
"Year" : q . Year ,
"Sort" : q . Sort ,
"Pager" : pagination . Pager {
PrevURL : pagination . PageLink ( c , meta . PrevPage ) ,
NextURL : pagination . PageLink ( c , meta . NextPage ) ,
HasPrev : meta . HasPrev ,
HasNext : meta . HasNext ,
Label : fmt . Sprintf ( "第 %d 页 · 共 %d 条" , meta . Page , total ) ,
} ,
} )
}
@ -242,7 +266,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
renderHTML ( c , http . StatusNotFound , tplError , gin . H { "Error" : "走秀不存在或已删除: " + raw } )
return
}
art , err := h . article . Detail ( c . Request . Context ( ) , strconv . FormatUint ( uint64 ( numeric ) , 10 ) , "en" , true )
art , err := h . article . Detail ( c . Request . Context ( ) , strconv . FormatUint ( uint64 ( numeric ) , 10 ) , "en" )
if err != nil {
renderHTML ( c , http . StatusNotFound , tplError , gin . H { "Error" : "走秀不存在或已删除: " + raw } )
return
@ -264,21 +288,25 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
renderHTML ( c , http . StatusInternalServerError , tplError , gin . H { "Error" : err . Error ( ) } )
return
}
pag e : = q . Page
if page < 1 {
p age = 1
const siz e = 24
p := pagination . Params { Page : q . Page , PageSize : size }
if p . P age < 1 {
p . Page = 1
}
meta := p . NewMeta ( total )
renderHTML ( c , http . StatusOK , tplStreetSnaps , gin . H {
"Items" : items ,
"Total" : total ,
"Page " : page ,
"PrevPage" : page - 1 ,
"NextPage" : page + 1 ,
"HasPrev " : page > 1 ,
"HasNext" : int64 ( page * 24 ) < total ,
"Year" : q . Year ,
"City" : q . City ,
"Sort" : q . Sor t,
"Items" : items ,
"Total" : total ,
"Year " : q . Year ,
"City" : q . City ,
"Sort" : q . Sort ,
"Pager " : pagination . Pager {
PrevURL : pagination . PageLink ( c , meta . PrevPage ) ,
NextURL : pagination . PageLink ( c , meta . NextPage ) ,
HasPrev : meta . HasPrev ,
HasNext : meta . HasNex t ,
Label : fmt . Sprintf ( "第 %d 页 · 共 %d 条" , meta . Page , total ) ,
} ,
} )
}
@ -291,7 +319,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderHTML ( c , http . StatusNotFound , tplError , gin . H { "Error" : "街拍不存在或已删除: " + raw } )
return
}
snap , err := h . street . Detail ( c . Request . Context ( ) , strconv . FormatUint ( uint64 ( numeric ) , 10 ) , true )
snap , err := h . street . Detail ( c . Request . Context ( ) , strconv . FormatUint ( uint64 ( numeric ) , 10 ) )
if err != nil {
renderHTML ( c , http . StatusNotFound , tplError , gin . H { "Error" : "街拍不存在或已删除: " + raw } )
return
@ -334,18 +362,22 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
if kind != "" && kind != "all" {
listBase = "/" + kind
}
p := pagination . Params { Page : page , PageSize : 20 }
meta := p . NewMeta ( total )
renderHTML ( c , http . StatusOK , tplReviewList , gin . H {
"Drafts" : drafts ,
"Total" : total ,
"Page" : page ,
"Status" : status ,
"Kind" : kind ,
"Kinds" : kinds ,
"ListBase" : listBase ,
"HasPrev " : page > 1 ,
"HasNext" : int64 ( page * 20 ) < total ,
"PrevPage" : page - 1 ,
"NextPage" : page + 1 ,
"Pager " : pagination . Pager {
PrevURL : pagination . PageLink ( c , meta . PrevPage ) ,
NextURL : pagination . PageLink ( c , meta . NextPage ) ,
HasPrev : meta . HasPrev ,
HasNext : meta . HasNext ,
Label : fmt . Sprintf ( "第 %d 页" , meta . Page ) ,
} ,
} )
}
@ -449,14 +481,16 @@ type ingestJobView struct {
ID uint32
Status string
Attempts uint8
SourceURL string
LastError string
UpdatedAt string
}
// IngestJobs 列出最近的 入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。
// IngestJobs 分页 列出入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。
// 支持 ?page( 从 1 起)与 ?pageSize( 默认 50, 上限 200) ; 其余查询串原样忽略。
func ( h * BackstageHandler ) IngestJobs ( c * gin . Context ) {
jobs , err := h . ingest . ListJobs ( c . Request . Context ( ) , 200 )
p := pagination . Parse ( c , "page" , "pageSize" , 1 , 50 , 200 )
offset := p . Offset ( )
jobs , total , err := h . ingest . ListJobs ( c . Request . Context ( ) , offset , p . PageSize )
if err != nil {
renderHTML ( c , http . StatusInternalServerError , tplError , gin . H { "Error" : err . Error ( ) } )
return
@ -471,12 +505,27 @@ func (h *BackstageHandler) IngestJobs(c *gin.Context) {
ID : j . ID ,
Status : j . Status ,
Attempts : j . Attempts ,
SourceURL : j . SourceURL ,
LastError : j . LastError ,
UpdatedAt : ua ,
} )
}
renderHTML ( c , http . StatusOK , tplIngestJobs , gin . H { "Jobs" : views } )
// 越界页码(如手动改 URL 成大数)钳制到最后一页展示,与原有行为一致。
// meta.Clamp 仅调整展示用的 Page/Prev/Next, 不改变上面已算好的查询 offset。
meta := p . NewMeta ( total )
meta . Clamp ( )
renderHTML ( c , http . StatusOK , tplIngestJobs , gin . H {
"Jobs" : views ,
"Page" : meta . Page ,
"PageSize" : p . PageSize ,
"Total" : total ,
"TotalPages" : meta . TotalPages ,
"Pager" : pagination . Pager {
PrevURL : pagination . PageLinkWith ( c , meta . PrevPage , "pageSize" , p . PageSize ) ,
NextURL : pagination . PageLinkWith ( c , meta . NextPage , "pageSize" , p . PageSize ) ,
HasPrev : meta . HasPrev ,
HasNext : meta . HasNext ,
} ,
} )
}
// IngestJobRetry 把一条 failed 任务重新入队(重置为 pending) , worker 会在下一个周期重新处理。
@ -945,11 +994,7 @@ code{font-family:ui-monospace,Menlo,monospace;font-size:12px}
{{ 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>
<div class="pager">
{{ if .HasPrev }} <a href="/admin/brands?keyword= {{ .Keyword }} &letter= {{ .Letter }} &page= {{ .PrevPage }} ">上一页</a> {{ else }} <a class="off">上一页</a> {{ end }}
<span class="muted">第 {{ .Page }} 页 · 共 {{ .Total }} 条</span>
{{ if .HasNext }} <a href="/admin/brands?keyword= {{ .Keyword }} &letter= {{ .Letter }} &page= {{ .NextPage }} ">下一页</a> {{ else }} <a class="off">下一页</a> {{ end }}
</div>
{{ template "pager" .Pager }}
</div></body></html> `
const tplRunways = ` <!doctype html><html lang="zh"><head><meta charset="utf-8">
@ -1008,11 +1053,7 @@ a.view{color:#111;text-decoration:underline}
{{ 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>
<div class="pager">
{{ if .HasPrev }} <a href="/admin/runways?collection= {{ .Collection }} &season= {{ .Season }} &year= {{ .Year }} &sort= {{ .Sort }} &page= {{ .PrevPage }} ">上一页</a> {{ else }} <a class="off">上一页</a> {{ end }}
<span class="muted">第 {{ .Page }} 页 · 共 {{ .Total }} 条</span>
{{ if .HasNext }} <a href="/admin/runways?collection= {{ .Collection }} &season= {{ .Season }} &year= {{ .Year }} &sort= {{ .Sort }} &page= {{ .NextPage }} ">下一页</a> {{ else }} <a class="off">下一页</a> {{ end }}
</div>
{{ template "pager" .Pager }}
</div></body></html> `
const tplRunwayDetail = ` <!doctype html><html lang="zh"><head><meta charset="utf-8">
@ -1037,7 +1078,6 @@ h1{font-size:22px;margin:8px 0 2px}
<div class="main">
<p class="muted">编码 {{ .Article .UID }} · 品牌 {{ .Article .BrandName }} </p>
<h1> {{ .Article .Title }} </h1>
<p class="meta">来源: {{ if .Article .SourceURL }} <a href=" {{ .Article .SourceURL }} " target="_blank" rel="noopener"> {{ .Article .SourceURL }} </a> {{ else }} — {{ end }} </p>
<div class="grid">
{{ range .Article .Images }} <div class="cell"><img src=" {{ .Image }} " loading="lazy"><span> {{ .Name }} </span></div> {{ end }}
</div>
@ -1087,11 +1127,7 @@ a.view{color:#111;text-decoration:underline}
{{ 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>
<div class="pager">
{{ if .HasPrev }} <a href="/admin/street-snaps?city= {{ .City }} &year= {{ .Year }} &sort= {{ .Sort }} &page= {{ .PrevPage }} ">上一页</a> {{ else }} <a class="off">上一页</a> {{ end }}
<span class="muted">第 {{ .Page }} 页 · 共 {{ .Total }} 条</span>
{{ if .HasNext }} <a href="/admin/street-snaps?city= {{ .City }} &year= {{ .Year }} &sort= {{ .Sort }} &page= {{ .NextPage }} ">下一页</a> {{ else }} <a class="off">下一页</a> {{ end }}
</div>
{{ template "pager" .Pager }}
</div></body></html> `
const tplStreetSnapDetail = ` <!doctype html><html lang="zh"><head><meta charset="utf-8">
@ -1204,11 +1240,7 @@ a.view{color:#111;text-decoration:underline}
</tr> {{ end }}
</tbody>
</table>
<div class="pager">
{{ if .HasPrev }} <a href="/admin/reviews {{ .ListBase }} ?status= {{ .Status }} &page= {{ .PrevPage }} ">上一页</a> {{ else }} <a class="off">上一页</a> {{ end }}
<span class="muted">第 {{ .Page }} 页</span>
{{ if .HasNext }} <a href="/admin/reviews {{ .ListBase }} ?status= {{ .Status }} &page= {{ .NextPage }} ">下一页</a> {{ else }} <a class="off">下一页</a> {{ end }}
</div>
{{ template "pager" .Pager }}
</div></body></html> `
const tplReviewDetail = ` <!doctype html><html lang="zh"><head><meta charset="utf-8">
@ -1238,7 +1270,7 @@ button.reject{background:#c0392b}
<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="main">
<p class="muted">类型 <b> {{ .Draft .Kind }} </b> {{ if .BrandName }} · 品牌 {{ .BrandName }} {{ end }} · 状态 <b> {{ .Draft .Status }} </b> · 来源: {{ if .Draft .SourceURL }} <a href=" {{ .Draft .SourceURL }} " target="_blank" rel="noopener"> {{ .Draft .SourceURL }} </a> {{ else }} — {{ end }} </p>
<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>
@ -1386,26 +1418,29 @@ code{font-family:ui-monospace,Menlo,monospace;font-size:12px}
form.retry { margin:0}
button.retry { font:12px system-ui;padding:4px 10px;border:1px solid #c0392b;color:#c0392b;background:#fff;border-radius:6px;cursor:pointer}
button.retry:hover { background:#c0392b;color:#fff}
.pager { display:flex;justify-content:flex-end;gap:12px;align-items:center;margin-top:16px}
.pager a { padding:7px 14px;border:1px solid #ccc;border-radius:6px;color:#111;text-decoration:none}
.pager a.off { color:#bbb;border-color:#eee;pointer-events:none}
</style></head>
<body>
<div class="bar"><span>管理后台 · 爬虫入库任务</span><span><a href="/admin/">返回首页</a> · <a href="/admin/logout">退出</a></span></div>
<div class="main">
<h1>爬虫入库任务(最近 {{ len .Jobs }} 条) </h1>
<p class="muted">worker 每 3 秒扫描一次 pending 任务并处理; failed 可一键重新入队。</p>
<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>来源 URL</th><th> 错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
<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="break"><code> {{ .SourceURL }} </code></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></body></html> `
const tplError = ` <!doctype html><html lang="zh"><head><meta charset="utf-8"><title>错误</title></head>