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( ``) // 注意:模板读的是 .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 := `` 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, "", coverCSS+"", 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) writeAdminListPreviews(t, outDir) writeVotePreviews(t, outDir) } // 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") } } // 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.Now(), 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 把主题 换成内联 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(``) if !link.MatchString(html) { t.Fatalf("主题 %s 的 未出现在页面里(ThemeSlug 没传到模板)", slug) } return link.ReplaceAllString(html, "") } // 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" { // 上可能还有 data-theme-style,只在这个开标签内判断/插入; // 不能全文搜索 —— 内联的主题 css 里有 html[data-theme="dark"] 选择器 if i := strings.Index(html, "= 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"`) html = strings.Replace(html, ``, `<title>预览 `+time.Now().Format("15:04")+` · `, 1) if err := os.WriteFile(filepath.Join(dir, name), []byte(html), 0o644); err != nil { t.Fatal(err) } }