feat(theme): 主题支持按页面分别配装饰,生成器改用真实页面画布

原来的生成器只能配「背景 + 人物 + 道具」三件套,预览还是自己画的仿制页面 ——
跟真实页面不像,也没法给不同页面配不同内容。

机制层:
- internal/context.pageScope() 按路径归类,输出 <html data-page-scope>:
  box / question / admin / profile / vote(首页与 /explore 不加载主题,不列)
- /user/questions 单独归为 admin:题目列表信息密,和设置表单的装饰诉求不同
- base.css 装饰槽位 6 -> 10,背景 8 层 -> 12 层;主题按作用域覆盖时同样写满 10 个
- 新增 TestDecoSlotCount:逐文件逐声明检查 10 个值。少写一个不报错,
  只会让图层与 size/position 错位(整页重复同一张图 / 人物跑到左上角),注释守不住

生成器:
- 画板改成 iframe 加载 outputs/preview-*.html(真实模板渲染),配 serve.py 起本地服务
  解决 file:// 下父页面读不到 iframe DOM 的问题
- 八个页面作用域(去掉不生效的首页/探索),每个可独立配背景与最多 10 层装饰
- 图层类型:图片 / 径向光斑 / 线性光;9 宫格锚点 + 像素偏移,可在画板上直接拖
- 位置统一写 calc(0%|50%|100% ± Npx):百分比是相对 (容器-图片),
  不用 left/bottom 关键字混搭,避免三值语法非法导致整条声明作废
- 配色只填 6 个基础色,其余 4 个必需令牌按方向派生(surface-2 往 text 靠,
  浅色变深、深色变亮,方向自动正确)

配套:
- zz_preview_test.go 增加单题页 / 题目列表 / 投票页预览,作为生成器画板
- docs/theme-page-scope.md 记录作用域取值、选择器特异性与 10 槽位约束
- StaticVersion 20261020e(base.css 变了)

验证:生成器导出的 CSS 通过 TestThemeCSSComplete + TestDecoSlotCount;
真实箱主页计算样式为 12 层对齐;临时主题验证「question 覆盖为 none」生效、
未覆盖时回退全局。
This commit is contained in:
tamakyi
2026-10-20 10:53:44 +08:00
parent feb5b1f78d
commit ddee221de2
14 changed files with 1748 additions and 796 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{.LangCode}}"{{ if .ThemeSlug }} data-theme-style="{{ .ThemeSlug }}"{{ end }}>
<html lang="{{.LangCode}}"{{ if .ThemeSlug }} data-theme-style="{{ .ThemeSlug }}"{{ end }}{{ if .PageScope }} data-page-scope="{{ .PageScope }}"{{ end }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
+139
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/stats"
)
@@ -171,6 +172,144 @@ func TestGenPreview(t *testing.T) {
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 把主题 <link> 换成内联 style:预览页走 file://,相对路径取不到。