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:
@@ -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">
|
||||
|
||||
@@ -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://,相对路径取不到。
|
||||
|
||||
Reference in New Issue
Block a user