This commit is contained in:
toom1996
2026-09-22 21:40:04 +08:00
parent c32347ed3c
commit c3a6baa419
25 changed files with 2051 additions and 783 deletions

View 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
}