// 后台模板的加载与渲染。 // // 为什么把模板从 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 }