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