「上月提问量环比 -75.0%」这句要读者做两次翻译:先把「上月」翻成具体月份, 再把「环比」翻成「比上上月」。两个相对词叠在一起,站长本人看到就以为 是「上月同期 vs 本月同期」—— 而它其实比的是两个完整自然月。 改前:上月提问量环比 -75.0%。 改后:9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。 口径一个字没改(仍是上月 vs 上上月,本月没过完不参与),只是把月份和条数 写进句子:读者不需要知道「环比」的定义也能判断这句话在说什么,还能顺手 核对百分比(5620/4890 确实是 +14.9%)。 实现: - `monthText` 按语言出月份名 —— CJK 一律「9 月」,英文要 "September" ("month 9" 不是英语的说法)。不带年份:相比的两个月相邻。 - 一个词条拆成 momUp / momDown / momFlat:方向词塞不进同一个模板, 四种语言里「多了 / 少了」的位置和词形都不一样。 - `|pct| < 0.05` 走持平,否则会出现「多了 0.0%」。 测试:`TestInsightMoMNamesBothMonths` 锁住「句子里同时有两个月份名和两个条数」, 覆盖上升/下降/持平;`TestMonthText` 锁语言差异。 顺手修预览数据:`zz_preview_test.go` 的 `GrowthMoM` 原来写死 12.3,和它造的 monthly 对不上。以前句子里只有百分比无所谓,现在会连条数一起印出来, 截图就成了「5,620 比 4,890 多了 12.3%」这种假证据,改成从数据算。
485 lines
19 KiB
Go
485 lines
19 KiB
Go
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: (float64(monthly[10].N) - float64(monthly[9].N)) / float64(monthly[9].N) * 100, 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)
|
||
}
|
||
}
|