update
This commit is contained in:
156
internal/handler/templates.go
Normal file
156
internal/handler/templates.go
Normal file
@ -0,0 +1,156 @@
|
||||
// 后台模板的加载与渲染。
|
||||
//
|
||||
// 为什么把模板从 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
|
||||
}
|
||||
Reference in New Issue
Block a user