Files
backend_v2/internal/handler/templates.go
toom1996 42f6316125 update
2026-09-25 11:31:52 +08:00

173 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 后台模板的加载与渲染。
//
// 为什么把模板从 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"
"fashionapi/internal/pkg/imgurl"
"github.com/gin-gonic/gin"
)
// imgComposer 图片 URL 拼装器(库里只存 S4 key / 本地 /uploads 路径,渲染时再拼 base_url 与样式)。
// 默认按本地兜底初始化;main 装配期会调用 SetImgComposer 注入真实配置(含 S4 BaseURL)。
var imgComposer = imgurl.New("", "high", "thumb")
// SetImgComposer 由 main 在装配期注入图片 URL 拼装器(S4 启用时含对外访问域名)。
// 后台模板通过 imgURL / imgThumb 函数渲染图片,避免直接裸拼存储值导致 S4 模式下打不开。
func SetImgComposer(c *imgurl.Composer) {
if c != nil {
imgComposer = c
}
}
//go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
var backstageFS embed.FS
// 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{
// imgURL 把数据库里的图片存储值(S4 key / /uploads 路径 / 外链)拼成可访问完整 URL;
// imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。
"imgURL": func(stored string) string { return imgComposer.Compose(stored) },
"imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) },
}
base, err := template.New("layout.html").Funcs(funcs).
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/ 下的文件名(如 "articles");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
}