Files
tamabox/templates/zz_preview_test.go
T
tamakyi af39651734 fix(stats): 页脚时间标签改成「快照时间」,去掉重复的「数据基准日」
原来四行时间里,前两行说的是同一件事:
「数据基准日:2026-10-24」+「计算于:2026-10-24 14:05:22」。
`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 now 格式化出来的),
两行并列只是把同一个值拆成粗/细两种粒度。

而且「计算于」没有主语:读者站在页脚最底部看到它,会当成「整页数据
算到这一刻」,可今日两项恰恰不在这份快照里 —— 命中缓存时
`withToday` 会用更新的读数替换它们,页面上就出现「标注 14:05、
今日提问却是 22:35 的数」。

改成三行:
  快照时间:     2026-10-24 14:05:22
  最后一条提问: ...
  最后一条回答: ...
「快照只含累计数字与图表、今日两项实时」由下面那条 cacheNote 说明,
不塞进标签里(标签越短越好扫)。词条同步:快照时间 / 快照時間 /
Snapshot: / 集計時刻。

顺带删掉 `Overview.DataDate` 字段:模板不用它之后就没有读者了,
留着它会诱使以后的人再拼出「基准日 + 计算于」这种重复行。

无 schema/接口变更,纯展示层。i18n 有词条数量对等测试,四个语言已
一起改;`go build ./...` 与 i18n / stats / template / templates /
route 测试全绿。
2026-10-24 22:39:47 +08:00

483 lines
19 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.
package templates
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/stats"
)
// TestGenPreview 仅用于本地肉眼核对样式改动,产出 outputs/preview-*.html。
// 不在 CI 里跑:需要 TEST_PREVIEW=1 才执行。
func TestGenPreview(t *testing.T) {
if os.Getenv("TEST_PREVIEW") != "1" {
t.Skip("set TEST_PREVIEW=1 to generate preview files")
}
outDir := filepath.Join("..", "outputs")
if err := os.MkdirAll(outDir, 0o755); err != nil {
t.Fatal(err)
}
base := map[string]interface{}{
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}, {"Code": "ja", "Name": "日本語"}, {"Code": "en", "Name": "English"}},
"IsLogged": false,
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"CurrentURI": "/",
"ExternalURL": "https://box.shiroko.one",
"CaptchaType": "math",
"Limits": conf.Limits,
"Title": "TamaBox",
"SiteStats": &stats.View{
RegisteredUsers: 1284, TotalQuestions: 56321, AnsweredQuestions: 31208,
LastQuestionAt: "2026-10-19 10:12:44",
LastAnswerAt: "2026-10-19 09:58:02",
UpdatedAt: "2026-10-19 10:00:00",
},
}
for _, theme := range []string{"light", "dark"} {
home, err := renderToString(t, "home", base)
if err != nil {
t.Fatalf("render home: %v", err)
}
writePreview(t, outDir, "preview-home-"+theme+".html", home, theme, "../static")
}
// 提问箱主页:带背景图(渐变占位)+ 浅色文字,验证遮罩方向
boxData := publicBaseData()
boxData["VConsole"] = false
bgURI := "data:image/svg+xml;utf8," +
strings.NewReplacer("#", "%23").Replace(
`<svg xmlns='http://www.w3.org/2000/svg' width='900' height='500'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#3b6ea5'/><stop offset='1' stop-color='#d98b6a'/></linearGradient></defs><rect width='900' height='500' fill='url(#g)'/></svg>`)
// 注意:模板读的是 .PageUser.Background,必须写进 PageUser 里,否则 {{ if .PageUser.Background }} 为假、遮罩类不会渲染
boxData["PageUser"].(map[string]interface{})["Background"] = bgURI
boxData["PageUser"].(map[string]interface{})["Usernamecolor"] = "#ffffff"
boxData["PageUser"].(map[string]interface{})["Introcolor"] = "#ffffff"
// 无头 Chrome 里 uk-img 的懒加载不一定触发,这里直接把背景图写进 CSS 便于核对遮罩
coverCSS := `<style>.tb-profile-cover{background-image:url("` + bgURI +
`");background-size:cover;background-position:center;}</style>`
for _, theme := range []string{"light", "dark"} {
box, err := renderToString(t, "question/list", boxData)
if err != nil {
t.Fatalf("render list: %v", err)
}
box = strings.Replace(box, "</head>", coverCSS+"</head>", 1)
writePreview(t, outDir, "preview-box-"+theme+".html", box, theme, "../static")
}
// 主题预览:与箱主页同一份数据,只是打开 sakura 主题(验证令牌覆盖与深色块)
themeBox := publicBaseData()
themeBox["VConsole"] = false
themeBox["ThemeSlug"] = "sakura"
themeBox["PageUser"].(map[string]interface{})["Background"] = bgURI
themeBox["PageUser"].(map[string]interface{})["Usernamecolor"] = "#3a2b30"
themeBox["PageUser"].(map[string]interface{})["Introcolor"] = "#6b545c"
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "question/list", themeBox)
if err != nil {
t.Fatalf("render themed list: %v", err)
}
page = inlineThemeCSS(t, page, "sakura")
writePreview(t, outDir, "preview-theme-sakura-"+theme+".html", page, theme, "../static")
}
// 探索页:核对令牌替换后的卡片/列表配色
explore := voteBaseData()
explore["LangCode"] = "zh-CN"
explore["IsUserPage"] = false
explore["Keyword"] = ""
explore["Sort"] = "latest"
explore["Page"] = 1
explore["TotalPages"] = 1
explore["PrevPage"] = 0
explore["NextPage"] = 2
explore["HotTags"] = []string{"杂谈", "学习", "音乐"}
explore["HotVotes"] = nil
explore["AllVotes"] = nil
explore["RecommendUsers"] = []interface{}{}
explore["Users"] = []map[string]interface{}{
{"Name": "小狼", "Domain": "tamakyi", "Avatar": "", "Intro": "随便问,友善一点,看到都会回。", "Tags": "杂谈,日常"},
{"Name": "Kitsune", "Domain": "kitsune", "Avatar": "", "Intro": "摄影 / 音乐 / 一点点代码", "Tags": "摄影"},
{"Name": "残响", "Domain": "echo", "Avatar": "", "Intro": "长期潜水,偶尔冒泡。", "Tags": "杂谈"},
{"Name": "Mika", "Domain": "mika", "Avatar": "", "Intro": "想认识更多有趣的人。", "Tags": "学习,音乐"},
}
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "user/explore", explore)
if err != nil {
t.Fatalf("render explore: %v", err)
}
writePreview(t, outDir, "preview-explore-"+theme+".html", page, theme, "../static")
}
// 设置页「内容防护」面板:核对是否与其它面板同样是白底卡片
profile := profileBaseData()
// 主题选择器(account 面板):用固定假数据,避免测试依赖 conf 的 [theme] 段
profile["Themes"] = []map[string]interface{}{
{"Slug": "default", "Swatch": ""},
{"Slug": "sakura", "Swatch": "linear-gradient(135deg, #fff6f8 0 55%, #e5678f 55% 100%)"},
}
profile["CurrentTheme"] = "sakura"
profile["ActiveProfileTab"] = "security"
profile["FeatureQuestion"] = nil
profile["FeatureChat"] = nil
profile["FeatureVote"] = nil
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "user/profile", profile)
if err != nil {
t.Fatalf("render profile: %v", err)
}
writePreview(t, outDir, "preview-profile-"+theme+".html", page, theme, "../static")
}
// 设置页「账号」面板:主题选择器的实际排版(卡片在面板末尾)
profileAccount := profileBaseData()
profileAccount["Themes"] = profile["Themes"]
profileAccount["CurrentTheme"] = "sakura"
profileAccount["ActiveProfileTab"] = "account"
profileAccount["FeatureQuestion"] = nil
profileAccount["FeatureChat"] = nil
profileAccount["FeatureVote"] = nil
for _, theme := range []string{"light"} {
page, err := renderToString(t, "user/profile", profileAccount)
if err != nil {
t.Fatalf("render account profile: %v", err)
}
writePreview(t, outDir, "preview-profile-account-"+theme+".html", page, theme, "../static")
}
// 设置页在箱主主题下的样子:/user 后台现在也会跟随本人主题,
// 需要确认「粉色页面 + 主题选择器」不会互相吃掉。
profileSakura := profileBaseData()
profileSakura["Themes"] = profile["Themes"]
profileSakura["CurrentTheme"] = "sakura"
profileSakura["ActiveProfileTab"] = "account"
profileSakura["ThemeSlug"] = "sakura"
profileSakura["FeatureQuestion"] = nil
profileSakura["FeatureChat"] = nil
profileSakura["FeatureVote"] = nil
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "user/profile", profileSakura)
if err != nil {
t.Fatalf("render themed profile: %v", err)
}
page = inlineThemeCSS(t, page, "sakura")
writePreview(t, outDir, "preview-profile-sakura-"+theme+".html", page, theme, "../static")
}
// ---- 以下是「主题生成器」的预览画板素材 ----
// dev/theme-builder 不再自己画一个仿制页面,而是直接拿这里的真实渲染结果当 iframe 画板;
// 每个页面作用域(data-page-scope)各一份。新增页面时要同步 dev/theme-builder/index.html
// 的 SCOPES 列表,否则生成器里选不到。
writeQuestionItemPreviews(t, outDir)
writeQuestionOwnerPreviews(t, outDir)
writeAdminListPreviews(t, outDir)
writeVotePreviews(t, outDir)
writeStatsPreviews(t, outDir)
}
// writeStatsPreviews 全站数据页(/stats)。
// 四种组合都出:亮/暗确认令牌适配,中/英确认英文长句不会把卡片挤变形。
func writeStatsPreviews(t *testing.T, outDir string) {
t.Helper()
now := time.Now()
daily := make([]stats.DailyPoint, 0, 30)
var peak stats.DailyPoint
for i := 0; i < 30; i++ {
d := now.AddDate(0, 0, -(29 - i))
n := int64(38 + (i*11)%47 + (i%5)*13)
p := stats.DailyPoint{Date: d.Format("2006-01-02"), Label: d.Format("01-02"), N: n}
daily = append(daily, p)
if p.N >= peak.N {
peak = p
}
}
monthly := make([]stats.MonthlyPoint, 0, 12)
for i := 0; i < 12; i++ {
m := now.AddDate(0, -(11 - i), 0)
monthly = append(monthly, stats.MonthlyPoint{
Month: m.Format("2006-01"), Label: m.Format("01"),
N: int64(2100 + i*310 + (i%3)*420), Current: i == 11,
})
}
// 热力图:造出「深夜 + 周末」两个高峰,便于肉眼确认档位分得开。
var heatmap [7][24]int64
for d := 0; d < 7; d++ {
for h := 0; h < 24; h++ {
v := int64(30 + (h%12)*16)
if h >= 21 || h <= 1 {
v += 320
}
if h >= 12 && h <= 14 {
v += 90
}
if d >= 5 {
v += 120
}
heatmap[d][h] = v
}
}
ov := &stats.Overview{
RegisteredUsers: 1284, TotalQuestions: 56321, Answered: 31208,
Public: 41002, Private: 15319, Read: 40122, Unread: 16199,
Anonymous: 22110, LoggedIn: 34211,
ActiveBoxes: 412, AnsweringBoxes: 268, TotalMessages: 8842,
LastQuestionAt: "2026-10-22 23:41:07", LastAnswerAt: "2026-10-22 23:12:33",
AnswerRate: 55.4, FastReplyRate: 38.6, Total30: 2148, AvgDailyLast30: 71.6,
GrowthMoM: 12.3, HasPrevMonth: true,
TodayQuestions: 37, TodayAnswers: 21,
Daily: daily, Monthly: monthly, PeakDay: peak, DailyMax: peak.N, MonthlyMax: 6180,
BusiestWD: 6, BusiestHr: 23,
Heatmap: heatmap, HeatmapMax: 812, HeatmapDays: 180,
LengthBins: [6]int64{8200, 14200, 16800, 9200, 6100, 1821},
ReplyBins: [6]int64{4200, 7100, 8400, 6200, 3400, 1908},
UpdatedAt: now.Format("2006-01-02") + " 00:07:12",
}
for _, lang := range []string{"zh-CN", "en"} {
data := map[string]interface{}{
"Title": "全站数据",
"LangCode": lang,
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}, {"Code": "en", "Name": "English"}},
"IsLogged": false,
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"CurrentURI": "/stats",
"ExternalURL": "https://box.shiroko.one",
"CaptchaType": "math",
"Limits": conf.Limits,
"ICP": "",
"Overview": stats.NewPage(lang, ov),
}
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "stats", data)
if err != nil {
t.Fatalf("render stats: %v", err)
}
writePreview(t, outDir, "preview-stats-"+lang+"-"+theme+".html", page, theme, "../static")
}
}
}
// writeQuestionItemPreviews 单题页(/_/{domain}/{qid})。
// 这一页正文长、卡片高,装饰层最容易挡住内容,所以必须进预览画板。
func writeQuestionItemPreviews(t *testing.T, outDir string) {
t.Helper()
for _, theme := range []string{"light", "dark"} {
data := publicBaseData()
data["VConsole"] = false
data["Question"] = map[string]interface{}{
"ID": 1, "Content": "你平时一个人骑车会去哪里?", "Answer": "沿着河堤一直往西,到海边的旧仓库就掉头。",
"CreatedAt": time.Now(), "UpdatedAt": time.Now(), "IsPrivate": false, "IsPinned": false,
"Token": "abc123", "ReceiveReplyEmail": "",
}
data["QuestionToken"] = "abc123"
data["AskUploadImages"] = []interface{}{}
data["AnswerUploadImages"] = []interface{}{}
data["FollowUps"] = []interface{}{}
data["CanFollowUp"] = false
data["FollowUpRemaining"] = 0
data["FollowUpLimit"] = 3
data["IsOwnPage"] = true
data["IsQuestioner"] = false
data["IsBlocked"] = false
data["AllowChat"] = true
page, err := renderToString(t, "question/item", data)
if err != nil {
t.Fatalf("render question/item: %v", err)
}
writePreview(t, outDir, "preview-question-"+theme+".html", page, theme, "../static")
}
}
// writeQuestionOwnerPreviews 单题页的箱主视角:回答行有「回答并下一条」、页脚有「下一条未读」,
// 未回答 / 已回答两种状态都要能肉眼核对排版。
func writeQuestionOwnerPreviews(t *testing.T, outDir string) {
t.Helper()
for _, state := range []struct {
name string
answer string
}{
{"unanswered", ""},
{"answered", "沿着河堤一直往西,到海边的旧仓库就掉头。"},
} {
for _, theme := range []string{"light", "dark"} {
data := publicBaseData()
data["VConsole"] = false
data["CanDelete"] = true
data["IsOwnPage"] = true
data["NextUnreadID"] = uint(2)
data["Question"] = map[string]interface{}{
"ID": 1, "Content": "你平时一个人骑车会去哪里?", "Answer": state.answer,
"CreatedAt": time.Now(), "UpdatedAt": time.Now(),
"IsPrivate": false, "IsPinned": false, "IsRead": state.answer != "",
"Token": "abc123", "ReceiveReplyEmail": "",
}
data["QuestionToken"] = "abc123"
data["AskUploadImages"] = []interface{}{}
data["AnswerUploadImages"] = []interface{}{}
data["FollowUps"] = []interface{}{}
data["CanFollowUp"] = false
data["IsQuestioner"] = false
page, err := renderToString(t, "question/item", data)
if err != nil {
t.Fatalf("render question/item owner: %v", err)
}
writePreview(t, outDir, "preview-question-owner-"+state.name+"-"+theme+".html", page, theme, "../static")
}
}
}
// writeAdminListPreviews 箱主后台的问题列表(/user/questions)。
func writeAdminListPreviews(t *testing.T, outDir string) {
t.Helper()
type adminItem struct {
Type, Content, Link, PinLink string
CreatedAt time.Time
IsAnswered, IsRead, IsPrivate bool
IsBlocked, IsPinned bool
QuestionID uint
ReportCount int64
}
type adminConv struct {
Token string
CreatedAt time.Time
}
type adminConvDetail struct {
Conversation *adminConv
OwnerDomain string
OwnerName string
FirstMessage string
IsBlocked bool
}
for _, theme := range []string{"light", "dark"} {
data := map[string]interface{}{
"Title": "我的提问",
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
"IsLogged": true,
"LoggedUser": map[string]interface{}{"Domain": "test", "Name": "tester"},
"LoggedUserName": "tester",
"CurrentURI": "/user/questions",
"IsUserPage": true,
"IsPixel": false,
"VConsole": false,
"CaptchaType": "math",
"PageUser": map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": ""},
"ICP": "",
"Stats": map[string]int64{
"total": 3, "answered": 2, "unanswered": 1, "read": 2, "unread": 1, "private": 1, "pinned": 1,
},
"ChatStats": map[string]int64{"total": 2, "received": 1, "initiated": 1, "closed": 0},
"Keyword": "",
"SelectedSort": "latest",
"SelectedStatus": "all",
"SelectedRead": "all",
"SelectedType": "all",
"ReceivedItems": []interface{}{
adminItem{Type: "question_public", CreatedAt: time.Now(), Content: "你平时一个人骑车会去哪里?",
Link: "/_/test/1", IsAnswered: true, IsRead: true, QuestionID: 1, IsPinned: true, PinLink: "/_/test/1/pin"},
adminItem{Type: "question_private", CreatedAt: time.Now(), Content: "悄悄话:最近还好吗",
Link: "/_/test/2", IsAnswered: false, IsPrivate: true, QuestionID: 2, PinLink: "/_/test/2/pin"},
adminItem{Type: "question_public", CreatedAt: time.Date(2025, 9, 5, 4, 45, 22, 0, time.Local), Content: "光环是什么材质的?",
Link: "/_/test/3", IsAnswered: true, IsRead: false, QuestionID: 3, PinLink: "/_/test/3/pin"},
},
"ReceivedChats": []interface{}{
adminItem{Type: "chat", CreatedAt: time.Now(), Content: "你好", Link: "/_/test/chat/abc"},
},
"ConversationDetails": []interface{}{
adminConvDetail{Conversation: &adminConv{Token: "abc", CreatedAt: time.Now()},
OwnerDomain: "other", OwnerName: "别人", FirstMessage: "hi"},
},
}
page, err := renderToString(t, "user/question-list", data)
if err != nil {
t.Fatalf("render user/question-list: %v", err)
}
writePreview(t, outDir, "preview-admin-"+theme+".html", page, theme, "../static")
}
}
// writeVotePreviews 投票页(/vote/{token}):语言、主题都跟发布者,是唯一会「跨箱主」的页面。
func writeVotePreviews(t *testing.T, outDir string) {
t.Helper()
for _, theme := range []string{"light", "dark"} {
data := voteBaseData()
data["Vote"] = &db.Vote{
Title: "下次直播想看我做什么?", Description: "随便聊聊,投票结果仅供参考。",
Type: db.VoteTypeSingle, LinkToken: "abc123", MaxChoices: 1,
}
data["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
data["Options"] = []*db.VoteOption{
{Content: "一起骑车去海边"}, {Content: "读信 + 回答提问"}, {Content: "整理旧照片"},
}
data["HasVoted"] = false
data["CanVote"] = true
data["NotStarted"] = false
data["Ended"] = false
data["VoteScopeRegisteredOnly"] = false
data["ShowResults"] = false
data["IsOwner"] = false
page, err := renderToString(t, "vote/page", data)
if err != nil {
t.Fatalf("render vote/page: %v", err)
}
writePreview(t, outDir, "preview-vote-"+theme+".html", page, theme, "../static")
}
}
// inlineThemeCSS 把主题 <link> 换成内联 style:预览页走 file://,相对路径取不到。
// 预览数据里没有 StaticVersion,v 参数为空串,所以按正则匹配而不是固定串替换。
func inlineThemeCSS(t *testing.T, html, slug string) string {
t.Helper()
css, err := os.ReadFile(filepath.Join("..", "static", "css", "themes", slug+".css"))
if err != nil {
t.Fatalf("read theme css: %v", err)
}
link := regexp.MustCompile(`<link rel="stylesheet" href="/static/css/themes/` + slug + `\.css\?v=[^"]*"/>`)
if !link.MatchString(html) {
t.Fatalf("主题 %s 的 <link> 未出现在页面里(ThemeSlug 没传到模板)", slug)
}
return link.ReplaceAllString(html, "<style>"+string(css)+"</style>")
}
// writePreview 把 CDN 换成可直连的镜像、钉死主题,仅用于本地预览。
func writePreview(t *testing.T, dir, name, html, theme, staticPrefix string) {
t.Helper()
html = strings.ReplaceAll(html, "https://cdnjs.snrat.com/", "https://cdnjs.cloudflare.com/")
if theme == "dark" {
// <html> 上可能还有 data-theme-style,只在这个开标签内判断/插入;
// 不能全文搜索 —— 内联的主题 css 里有 html[data-theme="dark"] 选择器
if i := strings.Index(html, "<html"); i >= 0 {
if j := strings.Index(html[i:], ">"); j > 0 {
tag := html[i : i+j]
if !strings.Contains(tag, "data-theme=") {
html = html[:i+j] + ` data-theme="dark"` + html[i+j:]
}
}
}
}
html = strings.ReplaceAll(html, `href="/favicon.ico"`, `href="`+staticPrefix+`/favicon.ico"`)
// base.css 也要改成本地相对路径:预览页走 file://,"/static/base.css" 是绝对路径取不到,
// 于是 :root 上的设计令牌全部缺失 —— var(--tb-*) 变成无效值,卡片没边框、图表全透明。
html = strings.ReplaceAll(html, `href="/static/`, `href="`+staticPrefix+`/`)
html = strings.Replace(html, `<title>`, `<title>预览 `+time.Now().Format("15:04")+` · `, 1)
if err := os.WriteFile(filepath.Join(dir, name), []byte(html), 0o644); err != nil {
t.Fatal(err)
}
}