diff --git a/internal/handler/assets/admin.css b/internal/handler/assets/admin.css new file mode 100644 index 0000000..29143d3 --- /dev/null +++ b/internal/handler/assets/admin.css @@ -0,0 +1,431 @@ +/* ============================================================================ + * 管理后台统一样式(合并原先散在 14 个内联模板里的 12 份重复 -

管理后台登录

-
-{{if .Error}}
{{.Error}}
{{end}} - - - -
` - -const tplDashboard = ` - -管理后台 - - -
管理后台 · Fashion Archive退出({{.Username}})
-
-

欢迎,{{.Username}}

-{{if .Kinds}}
{{range .Kinds}}{{if ne .Kind ""}}{{.Label}}:待审 {{.Pending}}{{end}}{{end}}
{{end}} -
-
品牌管理
查看 / 搜索全部品牌
-
走秀档案
按系列 / 季节 / 年份筛选
-
街拍
按城市 / 年份筛选
-
待审草稿{{if gt .PendingReviews 0}}({{.PendingReviews}}){{end}}
爬虫入库待人工审核
-
入库任务
爬虫上报处理进度 / 失败重试
-
用户管理
账号等级 · 提级 VIP
-
-
` - -const tplUsers = ` - -用户管理 · 管理后台 - - -
管理后台 · 用户管理返回首页 · 退出
-
-

用户管理({{len .Users}})

-

图片质量模型已统一:所有用户(含未登录)看图集详情均走同一高清样式,不再按等级分级。

- - - -{{range .Users}} - - - - - -{{end}} - -
ID用户名邮箱等级操作
{{.ID}}{{.Username}}{{.Email}}{{if eq .Tier "vip"}}VIP{{else}}FREE{{end}} -
- - -
-
-
` - -const tplBrands = ` - -品牌管理 · 管理后台 - - -
管理后台 · 品牌管理+ 新建 · 返回首页 · 退出
-
-

品牌列表

-
- - -{{if .Keyword}}清除{{end}} -
-
-全部 -中文 -A -B -C -D -E -F -G -H -I -J -K -L -M -N -O -P -Q -R -S -T -U -V -W -X -Y -Z -
- - - -{{range .Items}}{{end}} - -
编码 ID英文名中文名
{{.UID}}{{.NameEn}}{{.NameCn}}编辑
-{{template "pager" .Pager}} -
` - -const tplRunways = ` - -走秀档案 · 管理后台 - - -
管理后台 · 走秀档案返回首页 · 退出
-
-

走秀列表

-
- - - - - -清除 -
- - - -{{range .Items}}{{end}} - -
编码 ID品牌标题系列季节年份季节码图数
{{.UID}}{{.BrandName}}{{.Title}}{{.CollectionType}}{{.Season}}{{.Year}}{{.SeasonCode}}{{.ImageCount}}查看
-{{template "pager" .Pager}} -
` - -const tplRunwayDetail = ` - -走秀详情 · 管理后台 - - -
管理后台 · 走秀详情返回列表 · 编辑 · 退出
-
-

编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}

-

{{.Article.Title}}

-
-{{range .Article.Images}}
{{.Name}}
{{end}} -
-
` - -const tplStreetSnaps = ` - -街拍 · 管理后台 - - -
管理后台 · 街拍返回首页 · 退出
-
-

街拍列表

-
- - - - -清除 -
- - - -{{range .Items}}{{end}} - -
编码 ID标题图片数
{{.UID}}{{.Title}}{{.ImageCount}}查看
-{{template "pager" .Pager}} -
` - -const tplStreetSnapDetail = ` - -街拍详情 · 管理后台 - - -
管理后台 · 街拍详情返回列表 · 编辑 · 退出
-
-

编码 {{.Snap.UID}}

-

{{.Snap.Title}}

-
-{{range .Snap.Images}}
{{.Name}}
{{end}} -
-
` - -const tplBrandForm = ` - -{{if .UID}}编辑品牌{{else}}新建品牌{{end}} · 管理后台 - - -
管理后台 · {{if .UID}}编辑品牌{{else}}新建品牌{{end}}返回列表 · 退出
-
-

{{if .UID}}编辑品牌{{else}}新建品牌{{end}}

-
-{{if .Error}}
{{.Error}}
{{end}} - - - -
-
` - -const tplReviewList = ` - -待审草稿 · 管理后台 - - -
管理后台 · 待审草稿返回首页 · 退出
-
-

待审草稿(共 {{.Total}} 条)

-
-{{range .Kinds}}{{.Label}}{{if gt .Pending 0}}({{.Pending}}){{end}} -{{end}} -
-
-全部 -待审核 -已通过 -已驳回 -
- - - -{{range .Drafts}} - - - - - - - -{{end}} - -
ID类型标题副标题图数状态
{{.ID}}{{if eq .Kind "street"}}{{.Kind}}{{else}}{{.Kind}}{{end}}{{.Title}}{{.Subtitle}}{{.ImageCount}}{{.Status}}查看/审核
-{{template "pager" .Pager}} -
` - -const tplReviewDetail = ` - -草稿审核 · 管理后台 - - -
管理后台 · 草稿审核 #{{.Draft.ID}}返回列表 · 退出
-
-

类型 {{.Draft.Kind}}{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 {{.Draft.Status}}

-
-{{range .Draft.Fields}}
{{if eq .Type "textarea"}}{{else}}{{end}}
{{end}} - -
-{{if eq .Draft.Kind "street"}} -
操作:勾选属于同一个人的图片 → 在其中一张上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。
-

图片({{len .Draft.Images}} 张 · {{len .Draft.Groups}} 组)

-
-
-{{range .Draft.Images}}
- -{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}} -{{.Name}} -
-{{if eq .IsDetail 0}}{{end}} - -{{if eq .IsDetail 1}}
{{end}} -
-
-
{{end}} -
-
勾选同一人的图,并在其中一张主图上点「主图」
-{{else}} -

图片管理({{len .Draft.Images}} 张)

-
-{{range .Draft.Images}}
- -{{.Name}} -
-
-
-
{{end}} -
-{{end}} -
` - -const tplRunwayEdit = ` - -编辑走秀 · 管理后台 - - -
管理后台 · 编辑走秀 {{.UID}}返回详情 · 退出
-
-

品牌 {{.BrandName}}{{if .IsDown}} · 已下架{{end}}

-
-
-
-
-
- -
- -

图片管理({{len .Images}} 张)

-
-{{range .Images}}
- -
-
-
-
-
{{end}} -
- -
-{{if .IsDown}}{{else}}{{end}} -
-

下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。

-
` - -const tplSnapEdit = ` - -编辑街拍 · 管理后台 - - -
管理后台 · 编辑街拍 {{.UID}}返回详情 · 退出
-
-

{{if .IsDown}}已下架{{end}}

-
-
-
- -
- -

图片管理({{len .Images}} 张)

-
-{{range .Images}}
- -
-
-
-
-
{{end}} -
- -
-{{if .IsDown}}{{else}}{{end}} -
-

下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。

-
` - -const tplIngestJobs = ` - -入库任务 · 管理后台 - - -
管理后台 · 爬虫入库任务返回首页 · 退出
-
-

爬虫入库任务

-

worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。

- - - -{{range .Jobs}} - - - - - - -{{end}} - -
ID状态重试错误信息更新时间操作
{{.ID}}{{.Status}}{{.Attempts}}{{.LastError}}{{.UpdatedAt}}{{if eq .Status "failed"}}
{{else}}—{{end}}
-{{template "pager" .Pager}} -
` - -const tplError = `错误 - -

出错了

{{.Error}}

-

返回首页

` diff --git a/internal/handler/backstage_handler_test.go b/internal/handler/backstage_handler_test.go index dd3aaef..de8bdc7 100644 --- a/internal/handler/backstage_handler_test.go +++ b/internal/handler/backstage_handler_test.go @@ -1,25 +1,133 @@ 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; -// 而审核页又有登录中间件挡着(curl 只能打到 302),冒烟测试覆盖不到,故用解析测试兜住。 -func TestBackstageTemplatesParse(t *testing.T) { - for name, tpl := range map[string]string{ - "login": tplLogin, - "reviewList": tplReviewList, - "reviewDetail": tplReviewDetail, - } { - t.Run(name, func(t *testing.T) { - base, err := pagerTmpl.Clone() - if err != nil { - t.Fatalf("clone pager 模板失败: %v", err) - } - if _, err := base.Parse(tpl); err != nil { - t.Fatalf("模板 %s 解析失败: %v", name, err) - } - }) +// 模板从「Go 字符串常量」改成 templates/ 下的独立文件后: +// - 语法错误会在包 init(template.ParseFS)时直接 panic,go test 立刻暴露, +// 不再出现「编得过、点到那个页面才 500」; +// - 本测试额外断言页面集合齐全,防止加了 handler 却忘了加模板文件 +// (renderPage 会 500 "unknown backstage page: xxx")。 +func TestBackstageTemplatesParsed(t *testing.T) { + want := []string{ + "brand-form", + "brands", + "dashboard", + "error", + "ingest-jobs", + "login", + "review-detail", + "review-list", + "runway-detail", + "runway-edit", + "runways", + "snap-edit", + "street-snap-detail", + "street-snaps", + "users", + } + got := PageNames() + 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 +} diff --git a/internal/handler/templates.go b/internal/handler/templates.go new file mode 100644 index 0000000..2c760f0 --- /dev/null +++ b/internal/handler/templates.go @@ -0,0 +1,156 @@ +// 后台模板的加载与渲染。 +// +// 为什么把模板从 Go 字符串常量搬到独立 .html 文件: +// 1. IDE 支持 —— .html 有语法高亮、补全、格式化、Emmet;Go 反引号字符串里没有; +// 2. 可维护 —— 改样式 / 加字段只动一个小文件,diff 干净;公共部分(顶部导航、翻页器) +// 抽成 partials 后不再 14 份复制粘贴;样式与脚本独立成文件后可被浏览器缓存; +// 3. 零构建 —— 用 go:embed 打进二进制,部署仍然只是一个可执行文件,无外部依赖。 +// +// 模板分层: +// layout.html 外壳( + 共享 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 +} diff --git a/internal/handler/templates/layout.html b/internal/handler/templates/layout.html new file mode 100644 index 0000000..732f1eb --- /dev/null +++ b/internal/handler/templates/layout.html @@ -0,0 +1,26 @@ +{{/* + 后台页面外壳: + 共享样式/脚本 + 页面内容插槽。 + + 页面模板只需提供三个 define: + {{define "title"}} 浏览器标题 + {{define "content"}} 正文(含自己的 .bar / .main 容器) + 需要顶部导航时,在 content 里写 {{template "bar" .}}(登录页不需要,故不引用)。 + + 样式与脚本走 /admin/assets/(同样是 embed 进二进制,零外部依赖、零构建): + CSS 独立成文件后浏览器可跨页缓存,也才有真正的 CSS 语法高亮与补全。 +*/}} +{{define "layout"}} + + + + +{{template "title" .}} + + + +{{template "content" .}} + + + + +{{end}} diff --git a/internal/handler/templates/pages/brand-form.html b/internal/handler/templates/pages/brand-form.html new file mode 100644 index 0000000..826285a --- /dev/null +++ b/internal/handler/templates/pages/brand-form.html @@ -0,0 +1,15 @@ +{{define "title"}}{{if .UID}}编辑品牌{{else}}新建品牌{{end}} · 管理后台{{end}} +{{define "barLabel"}}管理后台 · {{if .UID}}编辑品牌{{else}}新建品牌{{end}}{{end}} +{{define "barRight"}}返回列表 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

{{if .UID}}编辑品牌{{else}}新建品牌{{end}}

+
+{{if .Error}}
{{.Error}}
{{end}} + + + +
+
+{{end}} diff --git a/internal/handler/templates/pages/brands.html b/internal/handler/templates/pages/brands.html new file mode 100644 index 0000000..9a090de --- /dev/null +++ b/internal/handler/templates/pages/brands.html @@ -0,0 +1,51 @@ +{{define "title"}}品牌管理 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 品牌管理{{end}} +{{define "barRight"}}+ 新建 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

品牌列表

+
+ + +{{if .Keyword}}清除{{end}} +
+
+全部 +中文 +A +B +C +D +E +F +G +H +I +J +K +L +M +N +O +P +Q +R +S +T +U +V +W +X +Y +Z +
+ + + +{{range .Items}}{{end}} + +
编码 ID英文名中文名
{{.UID}}{{.NameEn}}{{.NameCn}}编辑
+{{template "pager" .Pager}} +
+{{end}} diff --git a/internal/handler/templates/pages/dashboard.html b/internal/handler/templates/pages/dashboard.html new file mode 100644 index 0000000..07bc597 --- /dev/null +++ b/internal/handler/templates/pages/dashboard.html @@ -0,0 +1,18 @@ +{{define "title"}}管理后台{{end}} +{{define "barLabel"}}管理后台 · Fashion Archive{{end}} +{{define "barRight"}}退出({{.Username}}){{end}} +{{define "content"}} +{{template "bar" .}} +
+

欢迎,{{.Username}}

+{{if .Kinds}}
{{range .Kinds}}{{if ne .Kind ""}}{{.Label}}:待审 {{.Pending}}{{end}}{{end}}
{{end}} +
+
品牌管理
查看 / 搜索全部品牌
+
走秀档案
按系列 / 季节 / 年份筛选
+
街拍
按城市 / 年份筛选
+
待审草稿{{if gt .PendingReviews 0}}({{.PendingReviews}}){{end}}
爬虫入库待人工审核
+
入库任务
爬虫上报处理进度 / 失败重试
+
用户管理
账号等级 · 提级 VIP
+
+
+{{end}} diff --git a/internal/handler/templates/pages/error.html b/internal/handler/templates/pages/error.html new file mode 100644 index 0000000..0931052 --- /dev/null +++ b/internal/handler/templates/pages/error.html @@ -0,0 +1,5 @@ +{{define "title"}}错误{{end}} +{{define "content"}} +

出错了

{{.Error}}

+

返回首页

+{{end}} diff --git a/internal/handler/templates/pages/ingest-jobs.html b/internal/handler/templates/pages/ingest-jobs.html new file mode 100644 index 0000000..2ba5fae --- /dev/null +++ b/internal/handler/templates/pages/ingest-jobs.html @@ -0,0 +1,24 @@ +{{define "title"}}入库任务 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 爬虫入库任务{{end}} +{{define "barRight"}}退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

爬虫入库任务

+

worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。

+ + + +{{range .Jobs}} + + + + + + +{{end}} + +
ID状态重试错误信息更新时间操作
{{.ID}}{{.Status}}{{.Attempts}}{{.LastError}}{{.UpdatedAt}}{{if eq .Status "failed"}}
{{else}}—{{end}}
+{{template "pager" .Pager}} +
+{{end}} diff --git a/internal/handler/templates/pages/login.html b/internal/handler/templates/pages/login.html new file mode 100644 index 0000000..313e424 --- /dev/null +++ b/internal/handler/templates/pages/login.html @@ -0,0 +1,10 @@ +{{define "title"}}管理后台 · 登录{{end}} +{{define "content"}} +

管理后台登录

+
+{{if .Error}}
{{.Error}}
{{end}} + + + +
+{{end}} diff --git a/internal/handler/templates/pages/review-detail.html b/internal/handler/templates/pages/review-detail.html new file mode 100644 index 0000000..b461470 --- /dev/null +++ b/internal/handler/templates/pages/review-detail.html @@ -0,0 +1,56 @@ +{{define "title"}}草稿审核 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 草稿审核 #{{.Draft.ID}}{{end}} +{{define "barRight"}}返回列表 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

类型 {{.Draft.Kind}}{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 {{.Draft.Status}}

+ +{{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}} +
+{{range .Draft.Fields}}
{{if eq .Type "textarea"}}{{else}}{{end}}
{{end}} + +
+ +{{if eq .Draft.Kind "street"}} +
+操作:勾选属于同一个人的图片 → 在其中一张上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。
+快捷键:j/k 上下移动 · x 勾选 · m 设为主图 · d 拆出 · Enter 看大图 · a 通过 + +
+ +

图片({{len .Draft.Images}} 张 · {{len .Draft.Groups}} 组)

+{{/* 合并表单本体在 grid 之前;各格的 radio/checkbox 通过 form="batch" 关联过来 */}} +
+
+{{range .Draft.Images}}
+ +{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}} +{{.Name}} +
+{{if eq .IsDetail 0}}{{end}} + +{{if eq .IsDetail 1}}
{{end}} +
+
+
{{end}} +
+ +
+ +勾选同一人的图,并在其中一张主图上点「主图」 +
+{{else}} +

图片管理({{len .Draft.Images}} 张)

+
+{{range .Draft.Images}}
+ +{{.Name}} +
+
+
+
{{end}} +
+{{end}} +
+{{end}} diff --git a/internal/handler/templates/pages/review-list.html b/internal/handler/templates/pages/review-list.html new file mode 100644 index 0000000..fac13d9 --- /dev/null +++ b/internal/handler/templates/pages/review-list.html @@ -0,0 +1,50 @@ +{{define "title"}}待审草稿 · 管理后台{{end}} +{{define "barLabel"}}待审草稿{{end}} +{{define "barRight"}}退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

待审草稿(共 {{.Total}} 条)

+
+{{range .Kinds}}{{.Label}}{{if gt .Pending 0}}({{.Pending}}){{end}} +{{end}} +
+
+全部 +待审核 +已通过 +已驳回 +
+ +{{/* 多选 + 批量操作:勾选后底部出现操作条(admin.js 接管;无 JS 时单条「查看/审核」照常可用) */}} +
+ + + + + + +{{range .Drafts}} + + + + + + + + +{{end}} + +
ID类型标题副标题图数状态操作
{{.ID}}{{if eq .Kind "street"}}{{.Kind}}{{else}}{{.Kind}}{{end}}{{.Title}}{{.Subtitle}}{{.ImageCount}}{{.Status}}查看/审核{{if eq .Status "pending"}} · {{end}}
+
+{{template "pager" .Pager}} + + +
+{{end}} diff --git a/internal/handler/templates/pages/runway-detail.html b/internal/handler/templates/pages/runway-detail.html new file mode 100644 index 0000000..2263570 --- /dev/null +++ b/internal/handler/templates/pages/runway-detail.html @@ -0,0 +1,13 @@ +{{define "title"}}走秀详情 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 走秀详情{{end}} +{{define "barRight"}}返回列表 · 编辑 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}

+

{{.Article.Title}}

+
+{{range .Article.Images}}
{{.Name}}
{{end}} +
+
+{{end}} diff --git a/internal/handler/templates/pages/runway-edit.html b/internal/handler/templates/pages/runway-edit.html new file mode 100644 index 0000000..6c2489f --- /dev/null +++ b/internal/handler/templates/pages/runway-edit.html @@ -0,0 +1,32 @@ +{{define "title"}}编辑走秀 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 编辑走秀 {{.UID}}{{end}} +{{define "barRight"}}返回详情 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

品牌 {{.BrandName}}{{if .IsDown}} · 已下架{{end}}

+
+
+
+
+
+ +
+ +

图片管理({{len .Images}} 张)

+
+{{range .Images}}
+ +
+
+
+
+
{{end}} +
+ +
+{{if .IsDown}}{{else}}{{end}} +
+

下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。

+
+{{end}} diff --git a/internal/handler/templates/pages/runways.html b/internal/handler/templates/pages/runways.html new file mode 100644 index 0000000..37eba60 --- /dev/null +++ b/internal/handler/templates/pages/runways.html @@ -0,0 +1,39 @@ +{{define "title"}}走秀档案 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 走秀档案{{end}} +{{define "barRight"}}退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

走秀列表

+
+ + + + + +清除 +
+ + + +{{range .Items}}{{end}} + +
编码 ID品牌标题系列季节年份季节码图数
{{.UID}}{{.BrandName}}{{.Title}}{{.CollectionType}}{{.Season}}{{.Year}}{{.SeasonCode}}{{.ImageCount}}查看
+{{template "pager" .Pager}} +
+{{end}} diff --git a/internal/handler/templates/pages/snap-edit.html b/internal/handler/templates/pages/snap-edit.html new file mode 100644 index 0000000..8a7a981 --- /dev/null +++ b/internal/handler/templates/pages/snap-edit.html @@ -0,0 +1,30 @@ +{{define "title"}}编辑街拍 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 编辑街拍 {{.UID}}{{end}} +{{define "barRight"}}返回详情 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

{{if .IsDown}}已下架{{end}}

+
+
+
+ +
+ +

图片管理({{len .Images}} 张)

+
+{{range .Images}}
+ +
+
+
+
+
{{end}} +
+ +
+{{if .IsDown}}{{else}}{{end}} +
+

下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。

+
+{{end}} diff --git a/internal/handler/templates/pages/street-snap-detail.html b/internal/handler/templates/pages/street-snap-detail.html new file mode 100644 index 0000000..b70fa94 --- /dev/null +++ b/internal/handler/templates/pages/street-snap-detail.html @@ -0,0 +1,13 @@ +{{define "title"}}街拍详情 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 街拍详情{{end}} +{{define "barRight"}}返回列表 · 编辑 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

编码 {{.Snap.UID}}

+

{{.Snap.Title}}

+
+{{range .Snap.Images}}
{{.Name}}
{{end}} +
+
+{{end}} diff --git a/internal/handler/templates/pages/street-snaps.html b/internal/handler/templates/pages/street-snaps.html new file mode 100644 index 0000000..924ca8a --- /dev/null +++ b/internal/handler/templates/pages/street-snaps.html @@ -0,0 +1,27 @@ +{{define "title"}}街拍 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 街拍{{end}} +{{define "barRight"}}退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

街拍列表

+
+ + + + +清除 +
+ + + +{{range .Items}}{{end}} + +
编码 ID标题图片数
{{.UID}}{{.Title}}{{.ImageCount}}查看
+{{template "pager" .Pager}} +
+{{end}} diff --git a/internal/handler/templates/pages/users.html b/internal/handler/templates/pages/users.html new file mode 100644 index 0000000..3a0362d --- /dev/null +++ b/internal/handler/templates/pages/users.html @@ -0,0 +1,30 @@ +{{define "title"}}用户管理 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 用户管理{{end}} +{{define "barRight"}}退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

用户管理({{len .Users}})

+

图片质量模型已统一:所有用户(含未登录)看图集详情均走同一高清样式,不再按等级分级。

+ + + +{{range .Users}} + + + + + +{{end}} + +
ID用户名邮箱等级操作
{{.ID}}{{.Username}}{{.Email}}{{if eq .Tier "vip"}}VIP{{else}}FREE{{end}} +
+ + +
+
+
+{{end}} diff --git a/internal/handler/templates/partials.html b/internal/handler/templates/partials.html new file mode 100644 index 0000000..e82ebd7 --- /dev/null +++ b/internal/handler/templates/partials.html @@ -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"}} +
+ {{template "barLabel" .}} + +
+{{end}} + +{{define "pager"}} +
+ {{if .HasPrev}}上一页{{else}}上一页{{end}} + {{if .Label}}{{.Label}}{{end}} + {{if .HasNext}}下一页{{else}}下一页{{end}} +
+{{end}} diff --git a/internal/router/backstage.go b/internal/router/backstage.go index c59dfb1..0b899e6 100644 --- a/internal/router/backstage.go +++ b/internal/router/backstage.go @@ -44,6 +44,15 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { 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,但不需要先校验) r.GET("/admin/login", opt.Backstage.LoginPage) r.POST("/admin/login", opt.Backstage.Login) @@ -65,7 +74,15 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { // 受保护:后台页面,统一走 cookie 鉴权中间件 admin := r.Group("/admin") 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("/brands", opt.Backstage.Brands) admin.GET("/brands/new", opt.Backstage.BrandNew) diff --git a/internal/router/backstage_test.go b/internal/router/backstage_test.go index 51b9569..fdfc9ef 100644 --- a/internal/router/backstage_test.go +++ b/internal/router/backstage_test.go @@ -2,6 +2,7 @@ package router import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -293,11 +294,16 @@ func TestBackstage(t *testing.T) { // ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)───────────────── -// fakeReview 实现审核服务接口;嵌入接口兜底未用到的方法,仅覆盖分组相关 4 个。 +// fakeReview 实现审核服务接口; +// ⚠️ 嵌入的 service.ReviewService 是 nil 接口,只对「确实实现的方法」安全 —— +// 任何未覆盖的方法被调用时都会 nil panic(曾经因为导航中间件调 KindTabs 而炸出来)。 +// 因此凡是页面/接口会走到的服务方法,都必须在这里显式实现。 type fakeReview struct { service.ReviewService - attachOK bool - last struct { + attachOK bool + approveErr error + rejectErr error + last struct { kind string draftID uint32 mainID uint32 @@ -305,6 +311,13 @@ type fakeReview struct { imgID uint32 op string } + // calls 记录批量审核的逐条调用,供断言。 + calls []struct { + Action string + Kind string + ID uint32 + Reason string + } view *service.DraftDetailView } @@ -332,6 +345,39 @@ func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, i 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) { t.Helper() m := jwt.NewManager("test-secret", 168) @@ -462,3 +508,136 @@ func TestBackstageStreetRenderGroups(t *testing.T) { 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) + } +}