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:
+101
-28
@@ -1,43 +1,116 @@
|
||||
# 主题生成器(本地单文件)
|
||||
# 主题生成器
|
||||
|
||||
`index.html` 双击即用,不联网、不起服务、不传图片——所有处理都在浏览器本地完成。
|
||||
一个可视化编辑主题的小工具:**以项目真实页面为画布**,按页面分别摆装饰,导出 zip 解压即用。
|
||||
|
||||
## 用法
|
||||
它替换了早先那版「单文件、仿制预览」的生成器 —— 那一版的问题很直接:预览是我自己画的
|
||||
示意图,跟真实页面长得不一样;而且只能配「背景 + 人物 + 道具」三件套。
|
||||
|
||||
1. 打开 `dev/theme-builder/index.html`(推荐 Chrome / Edge)。
|
||||
2. 填 slug(= css 文件名,小写字母/数字/连字符)。
|
||||
3. 传图:
|
||||
- **背景图**:任意尺寸,会按 `cover` 裁成 1920×1080;
|
||||
- **人物**:透明底 PNG/WebP,会按高度 1.5× 输出;
|
||||
- **道具**(可选):透明底,会自动在底部烘一层接触阴影。
|
||||
4. 拖预览里的虚线框调位置,或右侧滑杆微调;切 1512 / 1280 / 1024 看不同视口。
|
||||
5. 点「导出 zip」→ 解压到项目根目录 → 按 zip 里的 `INSTALL-<slug>.txt` 做三步(登记 `[theme] available`、`StaticVersion` +1、重新编译)。
|
||||
## 为什么需要起一个本地服务
|
||||
|
||||
## 它做了什么
|
||||
生成器要把 `outputs/preview-*.html`(由 `go test` 渲染出来的**真实页面**)放进 iframe 当画板,
|
||||
再往里注入正在编辑的主题 CSS。`file://` 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),
|
||||
预览就只能退回示意图。起本地服务后父子同源,拖拽、注入、实时改令牌都能工作。
|
||||
|
||||
- 用**和线上同一套机制**渲染预览:`background-image: pattern, deco(6 层), page-image`,
|
||||
`background-size` / `background-position` 一一对应,不是另画一张示意图。
|
||||
- 位置数学和 CSS 一致:`background-position` 的百分比是相对 **(容器宽 − 图宽)**,
|
||||
所以工具里拖出来的「左沿像素」会被反解成 `calc(50% - Npx)`,换视口不会跑位。
|
||||
- 图片全部转 WebP 并按目标尺寸重采样,导出前就能看到 KB 数。
|
||||
- 生成的 CSS 同时写浅色/深色两块(项目测试 `TestThemeCSSComplete` 要求),
|
||||
10 个必需令牌齐全。
|
||||
```bash
|
||||
# 1) 先渲染真实页面快照(改了模板/数据后要重跑)
|
||||
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
|
||||
|
||||
# 2) 起服务(只读,不改项目任何文件)
|
||||
python dev/theme-builder/serve.py # 默认 8777
|
||||
|
||||
# 3) 打开
|
||||
http://127.0.0.1:8777/dev/theme-builder/
|
||||
```
|
||||
|
||||
不想用脚本也行:在项目根跑 `python -m http.server 8777`,效果一样。
|
||||
|
||||
## 它和线上是同一条链路
|
||||
|
||||
| 环节 | 生成器 | 线上 |
|
||||
| --- | --- | --- |
|
||||
| 画板 | `outputs/preview-*.html`(真模板渲染) | 同一套模板 |
|
||||
| 作用域 | iframe 里设 `data-page-scope` | `internal/context.pageScope()` 输出到 `<html>` |
|
||||
| 装饰层 | 6 槽?不,**固定 10 槽** | `base.css` 的 12 层背景(pattern 1 + deco 10 + image 1) |
|
||||
| 位置 | `calc(0%|50%|100% ± Npx)` | 同一套 `background-position` |
|
||||
|
||||
因为是同一套机制,工具里拖出来的位置换视口不会跑位,也不会出现「预览好看、实机错位」。
|
||||
|
||||
## 三块面板
|
||||
|
||||
**页面** —— 每个页面作用域可以单独配,各有一份背景与装饰:
|
||||
|
||||
| 作用域 | 对应路径 |
|
||||
| --- | --- |
|
||||
| 全局 | 所有页面默认 |
|
||||
| `box` | `/_/{domain}` 箱主页 |
|
||||
| `question` | `/_/{domain}/{id}` 单题页 |
|
||||
| `admin` | `/user/questions` 题目列表 |
|
||||
| `profile` | `/user/*` 设置页 |
|
||||
| `vote` | `/vote/{token}` 投票页 |
|
||||
|
||||
默认全部「跟随全局」;勾掉就变成独立配置,导出时才会产出覆盖块。
|
||||
没配的页面自动沿用全局,不会出现「某页装饰消失」。
|
||||
|
||||
(首页 `/` 与 `/explore` 不列出来 —— 全站公开页不注入 `ThemeSlug`,不加载主题 css,配了也没效果。)
|
||||
|
||||
**装饰图层** —— 每个作用域最多 10 层,三种类型:
|
||||
|
||||
- **图片**:从图片池选,按高度定尺寸(宽度自动等比),选中后可直接在画板上拖动;
|
||||
- **光斑**:`radial-gradient`,给空白一点体积,不占位、不会变成贴纸;
|
||||
- **线性光**:`linear-gradient`,做窗光/压深。
|
||||
|
||||
锚点用 9 宫格 + 像素偏移,生成 `calc(0% + 40px) calc(100% - 36px)` 这种两值形式。
|
||||
|
||||
**图片池** —— 上传任意张,浏览器本地转 WebP,画布尺寸 = 原图尺寸(不缩放、不留透明边)。
|
||||
导出时只打包实际用到的图。
|
||||
|
||||
## 导出
|
||||
|
||||
点「导出 zip」得到:
|
||||
|
||||
```
|
||||
static/css/themes/<slug>.css
|
||||
static/images/themes/<slug>/*.webp
|
||||
INSTALL-<slug>.txt
|
||||
```
|
||||
|
||||
解压到项目根,然后按 `INSTALL-<slug>.txt` 做三步:
|
||||
|
||||
1. `conf/app.ini` 的 `[theme] available` 加上 slug;
|
||||
2. `internal/conf/static.go` 的 `StaticVersion` +1;
|
||||
3. `go build`(模板与静态资源都是编译期 embed,只重启不重编不会变)。
|
||||
|
||||
## 它不做什么
|
||||
|
||||
- **不抠图**。人物/道具必须是透明底;白底图导出的主题会带一块白。
|
||||
- **不做调色**。想统一"雾度"(人物压白 16%、场景 30~38%)得在生成端做,
|
||||
或用 `docs/theme-shiroko-assets-spec.md` 里的公式自己处理。
|
||||
- **不写 `[theme] available`**。配置在 `conf/app.ini`,导出包里只给了要粘贴的那一行。
|
||||
- **不改代码**。素材是编译期 embed,必须重新 `go build` 才会生效。
|
||||
- **不抠图**:图片必须本身是透明底,白底图导出的主题会带一块白。
|
||||
- **不做调色**:想统一「雾度」(人物压白 16%、场景 30~38%)得在生成端做,
|
||||
见 `docs/theme-shiroko-assets-spec.md`。
|
||||
- **不写配置**:`[theme] available` 只给要粘贴的那一行。
|
||||
- **不碰深色的图片**:深色块一律 `--tb-page-image: none` + 装饰全关(白天场景压在深底上会脏)。
|
||||
想在深色下也放图,需要手工加深色块。
|
||||
|
||||
## 自检
|
||||
|
||||
没有 UI 也能回归,两个钩子:
|
||||
|
||||
```
|
||||
chrome --headless=old --dump-dom "file:///.../dev/theme-builder/index.html?selftest=1"
|
||||
?demo=1 载入示例素材(canvas 画的背景 + 剪影人物)
|
||||
?scope=admin 直接落在某个作用域
|
||||
?dump=css 把最终 CSS 写进 <pre id="dump">
|
||||
?dump=zip 把 zip 的 base64 写进 <pre id="dump">
|
||||
```
|
||||
|
||||
会把生成的 CSS 与 zip 字节以 JSON 塞进 `<pre id="selftest">`,可用于回归:
|
||||
把 `css` 写成 `static/css/themes/<slug>.css`,跑 `go test ./internal/theme -run TestThemeCSSComplete`。
|
||||
```bash
|
||||
chrome --headless=old --virtual-time-budget=6000 \
|
||||
--dump-dom "http://127.0.0.1:8777/dev/theme-builder/?demo=1&dump=css"
|
||||
```
|
||||
|
||||
`?demo=1` 会直接载入示例素材(纯 canvas 画的渐变背景 + 一个剪影),没有图也能先看布局。
|
||||
把抓到的 CSS 存成 `static/css/themes/<slug>.css`,可跑:
|
||||
|
||||
```bash
|
||||
go test ./internal/theme -run "TestThemeCSSComplete|TestDecoSlotCount"
|
||||
```
|
||||
|
||||
`TestDecoSlotCount` 是这次新增的:它会逐个检查 `base.css` 与所有主题 css 里每一处
|
||||
`--tb-deco*` 声明是否都有 10 个值。少写一个不会报错,只会让图层与 size/position 错位
|
||||
(表现为整页重复同一张插画,或人物跑到左上角),靠注释守不住。
|
||||
|
||||
+1085
-740
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""主题生成器的本地预览服务。
|
||||
|
||||
为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的
|
||||
页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。
|
||||
file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。
|
||||
起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。
|
||||
|
||||
用法(在项目根目录执行任意一种):
|
||||
|
||||
python dev/theme-builder/serve.py
|
||||
python -m http.server 8777
|
||||
|
||||
然后打开 http://localhost:8777/dev/theme-builder/
|
||||
|
||||
这个脚本只读文件,不写任何东西,也不改项目配置。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import http.server
|
||||
import os
|
||||
import socketserver
|
||||
import sys
|
||||
|
||||
# dev/theme-builder/serve.py → 上两级就是项目根
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
# 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化
|
||||
self.send_header("Cache-Control", "no-store, must-revalidate")
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
# 只报 4xx/5xx,正常请求不刷屏
|
||||
status = args[1] if len(args) > 1 else ""
|
||||
if str(status).startswith(("4", "5")):
|
||||
sys.stderr.write(" %s\n" % (fmt % args))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务")
|
||||
ap.add_argument("--port", type=int, default=8777, help="端口,默认 8777")
|
||||
ap.add_argument("--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isdir(os.path.join(PROJECT_ROOT, "outputs")):
|
||||
sys.stderr.write(
|
||||
"还没生成预览快照。先在项目根执行:\n"
|
||||
" TEST_PREVIEW=1 go test ./templates -run TestGenPreview\n"
|
||||
)
|
||||
|
||||
handler = functools.partial(Handler, directory=PROJECT_ROOT)
|
||||
socketserver.TCPServer.allow_reuse_address = True
|
||||
with socketserver.TCPServer((args.host, args.port), handler) as httpd:
|
||||
url = "http://%s:%d/dev/theme-builder/" % (args.host, args.port)
|
||||
print("项目根:%s" % PROJECT_ROOT)
|
||||
print("生成器:%s" % url)
|
||||
print("Ctrl+C 停止")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n已停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
# 主题的页面作用域(data-page-scope)
|
||||
|
||||
一套主题过去只能有一种装饰:人物永远站在左下角,不管当前是箱主页、单题页还是题目列表。
|
||||
单题页正文长、题目列表信息密,同一套装饰在三种页面上要同时成立很难。
|
||||
|
||||
现在服务端会按请求路径给 `<html>` 打一个 `data-page-scope`,主题可以对每个页面分别配
|
||||
整页背景与装饰层。
|
||||
|
||||
## 取值
|
||||
|
||||
| 值 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| (无属性) | — | 全局默认 |
|
||||
| `box` | `/_/{domain}`、`/_/{domain}/tag/*` | 箱主页(问题列表) |
|
||||
| `question` | `/_/{domain}/{qid}` | 单个问题 |
|
||||
| `admin` | `/user/questions`、`/user/votes/*` | 题目 / 私聊管理列表 |
|
||||
| `profile` | `/user`、`/user/profile` 等 | 设置页 |
|
||||
| `vote` | `/vote/{token}` | 投票页(跟随发布者的主题) |
|
||||
| `home` / `explore` | `/`、`/explore` | Go 侧会返回,但**这两页不加载主题 css** |
|
||||
|
||||
来源:`internal/context.pageScope()`,测试在 `internal/context/context_test.go` 的 `TestPageScope`。
|
||||
|
||||
## 选择器与特异性
|
||||
|
||||
```css
|
||||
/* 全局 */
|
||||
html[data-theme-style="SLUG"] { … }
|
||||
|
||||
/* 某一页覆盖 */
|
||||
html[data-theme-style="SLUG"][data-page-scope="question"] { … }
|
||||
```
|
||||
|
||||
特异性 0,2,1 与深色块 `html[data-theme="dark"][data-theme-style="SLUG"]` 相同,
|
||||
而覆盖块写在文件后面,**会盖掉深色块**。所以深色必须再写一条三重选择器:
|
||||
|
||||
```css
|
||||
html[data-theme="dark"][data-theme-style="SLUG"][data-page-scope="question"] { … }
|
||||
```
|
||||
|
||||
不写的话,深色模式下会拿到浅色的覆盖块 —— 典型表现是「深色页面里突然出现一块白天场景」。
|
||||
|
||||
## 覆盖块写什么
|
||||
|
||||
通常只覆盖这三组:
|
||||
|
||||
```css
|
||||
html[data-theme-style="SLUG"][data-page-scope="question"] {
|
||||
--tb-page-image: none; /* 不放整页场景 */
|
||||
--tb-page-pattern: none;
|
||||
--tb-deco: …; /* 10 个值 */
|
||||
--tb-deco-size: …; /* 10 个值 */
|
||||
--tb-deco-position: …; /* 10 个值 */
|
||||
}
|
||||
```
|
||||
|
||||
配色(`--tb-bg` / `--tb-surface` / `--tb-text` / `--tb-primary`)一般不要按页面改:
|
||||
同一个主题在不同页面换配色,用户会觉得走错站了。
|
||||
|
||||
## 10 个槽位
|
||||
|
||||
`--tb-deco` / `--tb-deco-size` / `--tb-deco-position` 三组必须**等长且写满 10 个**。
|
||||
少一个不会报错,CSS 会按列表长度循环取值,于是图层与 size/position 整体错位:
|
||||
|
||||
- 整页重复同一张插画(第一次踩坑就是这个)
|
||||
- 人物跑到页面左上角(`background-position` 整条声明非法时回落初始值)
|
||||
|
||||
`internal/theme/slots_test.go` 的 `TestDecoSlotCount` 会逐个文件、逐处声明检查,
|
||||
包括 base.css 与所有主题 css(含作用域覆盖块)。写主题时不用记,跑测试就行。
|
||||
|
||||
`background-position` 一律写成两值 `calc()`:
|
||||
|
||||
```css
|
||||
--tb-deco-position: calc(0% + 40px) calc(100% - 36px),
|
||||
calc(50% + 0px) calc(50% + 0px), …;
|
||||
```
|
||||
|
||||
- 百分比是相对 **(容器 − 图片)** 的剩余空间,不是相对容器
|
||||
- `0%` 左/上、`50%` 居中、`100%` 右/下
|
||||
- **不要**混用 `left` / `bottom` 关键字:「水平居中 + 垂直贴底」会变成三值语法,
|
||||
一旦不合规范整条声明作废,图层静默掉到左上角
|
||||
|
||||
## 可视化编辑
|
||||
|
||||
手写这些值不现实,用 `dev/theme-builder/`(见同目录 README):以真实页面为画布,
|
||||
按页面切换、拖拽定位、导出 zip。它生成的 CSS 直接过 `TestThemeCSSComplete` 与
|
||||
`TestDecoSlotCount`。
|
||||
@@ -31,7 +31,10 @@ available = default,sakura,shiroko
|
||||
| 中层 | 卡片与顶栏:半透明白 + `backdrop-filter` 磨砂 | `--tb-surface: rgba(255,255,255,.86)` / `--tb-navbar` |
|
||||
| 上层 | 人物站在场景左侧地面(400px 高)+ 椭圆接触阴影 | `--tb-deco` 前两个槽位 |
|
||||
| 道具 | 她的自行车(车头朝右、与人同向)斜靠一把步枪 | `--tb-deco` 第 3 槽位 |
|
||||
| 空气层 | 左窗光柱 / 右侧暖散景 / 脚下地面光斑 / 底部压深 | `--tb-deco` 后四个槽位(全是渐变) |
|
||||
| 空气层 | 左窗光柱 / 右侧暖散景 / 脚下地面光斑 / 底部压深 | `--tb-deco` 第 4~7 槽位(全是渐变) |
|
||||
|
||||
> 装饰层共 10 个槽位(见 `internal/theme/slots_test.go`),本主题用前 7 个,其余留空。
|
||||
> 想给某一页换装饰(例如问题页让人物退场),见 [theme-page-scope.md](theme-page-scope.md)。
|
||||
| 深色 | 不用场景(白天教室压在深底上会脏),回到深色渐变 + 极淡纹样 | 深色块单独覆盖 |
|
||||
|
||||
摆位经验(都是踩过的):
|
||||
|
||||
@@ -10,7 +10,7 @@ var (
|
||||
BuildCommit = "dev"
|
||||
|
||||
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
||||
StaticVersion = "20261020d"
|
||||
StaticVersion = "20261020e"
|
||||
)
|
||||
|
||||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||||
|
||||
@@ -240,6 +240,12 @@ func Contexter() flamego.Handler {
|
||||
isUserPage := strings.HasPrefix(path, "/user") || strings.HasPrefix(path, "/_/")
|
||||
c.Data["IsUserPage"] = isUserPage
|
||||
|
||||
// 页面作用域:让同一套主题能在不同页面摆不同装饰(箱主页/问题页/设置页…)。
|
||||
// 模板通过 <html data-page-scope> 输出,主题 css 用
|
||||
// html[data-theme-style="x"][data-page-scope="y"] 覆盖 --tb-deco 等令牌。
|
||||
// 未知路径为空串,此时只应用全局装饰,行为与引入该机制前完全一致。
|
||||
c.Data["PageScope"] = pageScope(path)
|
||||
|
||||
// 主题:/user 后台是箱主本人的页面,跟随本人配置;
|
||||
// /_/{domain} 的箱主主题由 question.Pager 覆盖(访客看到的是箱主的选择)。
|
||||
// 空串(未选且站点无默认主题)= 不加载主题 css。
|
||||
@@ -342,3 +348,55 @@ func (c *Context) Lang() string {
|
||||
func isThemeScope(path string) bool {
|
||||
return path == "/user" || strings.HasPrefix(path, "/user/")
|
||||
}
|
||||
|
||||
// pageScope 把请求路径归类成「主题页面作用域」。
|
||||
//
|
||||
// 目的:同一个主题在不同页面可以摆不同装饰(箱主页放人物、问题页只留光、设置页干脆不放图),
|
||||
// 而不必为每种页面复制一整套主题。模板把它输出到 <html data-page-scope="...">,
|
||||
// 主题 css 用 html[data-theme-style="x"][data-page-scope="y"] 覆盖 --tb-deco 等令牌。
|
||||
//
|
||||
// 返回空串表示「不在主题作用域内」(首页/探索/登录等公开页也可返回具体值,
|
||||
// 只是这些页面通常不带 ThemeSlug,覆盖块不会生效)。
|
||||
func pageScope(path string) string {
|
||||
switch {
|
||||
case path == "/":
|
||||
return "home"
|
||||
case path == "/explore" || strings.HasPrefix(path, "/explore/"):
|
||||
return "explore"
|
||||
case path == "/login" || strings.HasPrefix(path, "/login/"),
|
||||
path == "/register" || strings.HasPrefix(path, "/register/"):
|
||||
return "auth"
|
||||
case path == "/user" || strings.HasPrefix(path, "/user/"):
|
||||
// 题目/私聊管理列表是「翻找内容」的页面,和「设置表单」的信息密度差很多,
|
||||
// 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。
|
||||
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") {
|
||||
return "admin"
|
||||
}
|
||||
return "profile"
|
||||
case strings.HasPrefix(path, "/_/"):
|
||||
// /_/{domain} → 箱主页(问题列表)
|
||||
// /_/{domain}/{qid} → 单个问题
|
||||
// /_/{domain}/tag/xxx → 仍是箱主页
|
||||
rest := strings.Trim(path[len("/_/"):], "/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) == 2 && isAllDigits(parts[1]) {
|
||||
return "question"
|
||||
}
|
||||
return "box"
|
||||
case path == "/vote" || strings.HasPrefix(path, "/vote/"):
|
||||
return "vote"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -28,3 +28,37 @@ func TestIsThemeScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面作用域:主题按页面分别摆装饰靠这个值区分。
|
||||
// 重点是把「箱主页 / 单题页 / 题目管理页」分清楚 —— 三者路径前缀很像。
|
||||
func TestPageScope(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/": "home",
|
||||
"/explore": "explore",
|
||||
"/explore/users": "explore",
|
||||
"/login": "auth",
|
||||
"/register": "auth",
|
||||
"/user": "profile",
|
||||
"/user/profile": "profile",
|
||||
"/user/questions": "admin", // 题目列表:信息密,装饰要退
|
||||
"/user/votes/1/results": "admin",
|
||||
"/_/admin6": "box",
|
||||
"/_/admin6/": "box",
|
||||
"/_/admin6/tag/杂谈": "box",
|
||||
"/_/admin6/answer": "box", // 非数字段 → 不是单题页
|
||||
"/_/admin6/123": "question",
|
||||
"/_/admin6/123/": "question",
|
||||
"/vote/abc123": "vote",
|
||||
"/username": "",
|
||||
"/_": "",
|
||||
"/deactivated": "",
|
||||
"/api/v1/user/admin6": "",
|
||||
"/api/v1/user/admin6/questions": "",
|
||||
}
|
||||
|
||||
for path, want := range cases {
|
||||
if got := pageScope(path); got != want {
|
||||
t.Errorf("pageScope(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tamakyi/TamaBox/static"
|
||||
)
|
||||
|
||||
// DecoSlots 是 --tb-deco / --tb-deco-size / --tb-deco-position 三组令牌
|
||||
// 必须对齐的槽位数,与 static/css/base.css 里的 12 层背景
|
||||
// (平铺图案 1 + 装饰 10 + cover 背景图 1)严格对应。
|
||||
//
|
||||
// 少写一个值不会报错,CSS 会按列表长度循环取值,于是图层与 size/position
|
||||
// 整体错位:典型表现是整页重复同一张插画,或人物跑到页面左上角。
|
||||
// 这个约定靠注释根本守不住,只能用测试钉死。
|
||||
const DecoSlots = 10
|
||||
|
||||
// TestDecoSlotCount 逐个检查 base.css 与所有主题 css 里每一处装饰层声明。
|
||||
// 主题可按页面作用域(html[data-page-scope="..."])写多组,所以是「每一处」而不是「每文件」。
|
||||
func TestDecoSlotCount(t *testing.T) {
|
||||
files := []string{"css/base.css"}
|
||||
for _, slug := range discover() {
|
||||
files = append(files, themeCSSDir+slug+".css")
|
||||
}
|
||||
if len(files) < 2 {
|
||||
t.Fatalf("只找到 %d 个 css,discover() 可能没工作", len(files))
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for _, name := range files {
|
||||
b, err := fs.ReadFile(static.FS, name)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s: %v", name, err)
|
||||
}
|
||||
// 注释里会出现「--tb-deco 前两个槽位」这类说明,先剥掉再解析
|
||||
css := commentRe.ReplaceAllString(string(b), "")
|
||||
|
||||
for _, key := range []string{"--tb-deco:", "--tb-deco-size:", "--tb-deco-position:"} {
|
||||
// 主题可以不写这组令牌(直接沿用 base.css 的默认值),所以「没有」是合法的;
|
||||
// 但只要写了,就必须写满 —— 半套值比不写更危险。
|
||||
for i, decl := range declarations(css, key) {
|
||||
checked++
|
||||
if got := len(splitTopLevel(decl)); got != DecoSlots {
|
||||
t.Errorf("%s 第 %d 处 %s 有 %d 个值,应为 %d 个",
|
||||
name, i+1, key, got, DecoSlots)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if checked == 0 {
|
||||
t.Fatal("一处装饰层声明都没解析到,说明 key 拼错了或 commentRe 剥多了")
|
||||
}
|
||||
t.Logf("共校验 %d 处装饰层声明", checked)
|
||||
}
|
||||
|
||||
// declarations 取出 key 之后到下一个分号之间的原始文本(不含 key 与分号)。
|
||||
func declarations(css, key string) []string {
|
||||
var out []string
|
||||
for {
|
||||
i := strings.Index(css, key)
|
||||
if i < 0 {
|
||||
return out
|
||||
}
|
||||
rest := css[i+len(key):]
|
||||
j := strings.IndexByte(rest, ';')
|
||||
if j < 0 {
|
||||
return out
|
||||
}
|
||||
out = append(out, rest[:j])
|
||||
css = rest[j+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// splitTopLevel 按顶层逗号切分。不能直接 strings.Split:
|
||||
// rgba(63, 96, 138, 0.18) 和 calc(50% - 524px) 内部的逗号不是层分隔符,
|
||||
// 直接切会把一层算成四层(曾据此误判「已经写够 10 个」)。
|
||||
func splitTopLevel(s string) []string {
|
||||
var (
|
||||
out []string
|
||||
buf strings.Builder
|
||||
depth int
|
||||
)
|
||||
flush := func() {
|
||||
if v := strings.TrimSpace(buf.String()); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
buf.Reset()
|
||||
}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
case ',':
|
||||
if depth == 0 {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
}
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
+12
-7
@@ -33,9 +33,12 @@
|
||||
视口比例一变,贴在图片边缘的插画就会被裁掉)。
|
||||
⚠️ 固定 6 个槽位:background-* 各列表的层数必须恒定(1 图案 + 6 装饰 + 1 背景 = 8),
|
||||
缺省用 none 占位;值的个数一变,多背景的分层就会整体错位。 */
|
||||
--tb-deco: none, none, none, none, none, none;
|
||||
--tb-deco-size: auto, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left top, left top, left top, left top, left top, left top;
|
||||
/* 装饰层:固定 10 个槽位。主题可按页面作用域整体替换(html[data-page-scope="..."]),
|
||||
但每组都必须写满 10 个值 —— 数量不足会让 background-* 列表与图层错位。 */
|
||||
--tb-deco: none, none, none, none, none, none, none, none, none, none;
|
||||
--tb-deco-size: auto, auto, auto, auto, auto, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left top, left top, left top, left top, left top, left top, left top,
|
||||
left top, left top, left top;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--tb-bg: #1a1d23;
|
||||
@@ -273,13 +276,15 @@
|
||||
body {
|
||||
font-family: var(--tb-font);
|
||||
background-color: var(--tb-bg);
|
||||
/* 主题的整页背景:四角装饰 + 平铺图案 + cover 背景图(共 6 层,顺序即绘制顺序) */
|
||||
/* 主题的整页背景:平铺图案(1) + 装饰层(10) + cover 背景图(1) = 12 层,顺序即绘制顺序。
|
||||
下面四个列表的层数必须与 12 对齐:少一个就会循环取值,导致图层与 size/position 错位。 */
|
||||
background-image: var(--tb-page-pattern), var(--tb-deco), var(--tb-page-image);
|
||||
background-size: var(--tb-page-pattern-size), var(--tb-deco-size), cover;
|
||||
background-repeat: repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat,
|
||||
no-repeat;
|
||||
background-repeat: repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat,
|
||||
no-repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat;
|
||||
background-position: left top, var(--tb-deco-position), center center;
|
||||
background-attachment: scroll, fixed, fixed, fixed, fixed, fixed, fixed, fixed;
|
||||
background-attachment: scroll, fixed, fixed, fixed, fixed, fixed, fixed, fixed, fixed, fixed,
|
||||
fixed, fixed;
|
||||
color: var(--tb-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@@ -35,10 +35,11 @@
|
||||
页面背景 --tb-page-image 整页背景图(cover,fixed)
|
||||
--tb-page-pattern 整页图案(平铺,可叠加在背景图之上)
|
||||
--tb-page-pattern-size 图案平铺尺寸(默认 auto)
|
||||
装饰 --tb-deco 四角装饰图,固定 6 个槽位(必须写满 6 个,缺的填 none);
|
||||
固定像素尺寸、不随视口裁切,适合放人物/摆件。默认 none。
|
||||
--tb-deco-size 对应每张的尺寸,如 auto 200px, auto 150px, auto, auto, auto, auto
|
||||
--tb-deco-position 对应每张的位置,如 left 1% bottom 0, right 1% top 4%, left top, …
|
||||
装饰 --tb-deco 装饰图层,固定 10 个槽位(必须写满 10 个,缺的填 none);
|
||||
固定像素尺寸、不随视口裁切,适合放人物/摆件/光斑。默认 none。
|
||||
值可以是图片,也可以是 linear-gradient / radial-gradient(纯"光")。
|
||||
--tb-deco-size 对应每层的尺寸,如 auto 400px, 100% 100%, auto, …(10 个)
|
||||
--tb-deco-position 对应每层的位置,如 calc(50% - 524px) calc(100% - 36px), …(10 个)
|
||||
|
||||
── 素材规格(放 R2,不需要重新编译) ─────────────────────────────
|
||||
页面背景图 2400×1600(3:2)或 2560×1440,WebP q80,≤300KB;明暗各一张:
|
||||
@@ -81,12 +82,18 @@ html[data-theme-style="SLUG"] {
|
||||
--tb-page-pattern: none;
|
||||
--tb-page-pattern-size: auto;
|
||||
|
||||
/* 四角装饰(放人物/摆件)。固定 6 个槽位,必须写满,缺的填 none——
|
||||
/* 装饰层(放人物/摆件/光)。固定 10 个槽位,必须写满,缺的填 none——
|
||||
值一多一少,多背景各列表就错位(会出现整页重复同一张图)。
|
||||
尺寸用「auto <高度>」,位置可写 left 2% bottom 0 / right 2% top 6% / left 2% center。 */
|
||||
--tb-deco: none, none, none, none, none, none;
|
||||
--tb-deco-size: auto, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left top, left top, left top, left top, left top, left top;
|
||||
尺寸用「auto <高度>」;位置统一写 calc:
|
||||
calc(0% + 40px) 左对齐 + 40px
|
||||
calc(50% + 0px) 居中
|
||||
calc(100% - 36px) 右/下对齐 - 36px(百分比是相对「容器 − 图片」的剩余空间)
|
||||
不要用 left / bottom 关键字混搭 —— 「水平居中 + 垂直贴底」会变成三值语法,
|
||||
一旦写法不合规范整条声明作废,图层静默掉到左上角。 */
|
||||
--tb-deco: none, none, none, none, none, none, none, none, none, none;
|
||||
--tb-deco-size: auto, auto, auto, auto, auto, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left top, left top, left top, left top, left top,
|
||||
left top, left top, left top, left top, left top;
|
||||
}
|
||||
|
||||
/* 2) 深色(必须)—— 深浅两套必须成对,缺一段会让深色用户看到「浅色的深色主题」 */
|
||||
@@ -112,7 +119,27 @@ html[data-theme="dark"][data-theme-style="SLUG"] {
|
||||
--tb-page-pattern-size: auto;
|
||||
}
|
||||
|
||||
/* 3) 组件微调(可选,默认整段注释掉)
|
||||
/* 3) 按页面作用域覆盖(可选)
|
||||
服务端会按请求路径给 <html> 打 data-page-scope,取值:
|
||||
(无属性 = 全局)/ box(箱主页) / question(单题页) / admin(/user/questions)
|
||||
/ profile(设置页等 /user/* ) / vote(投票页)
|
||||
典型用法:「问题页正文长,人物退场;题目列表字更密,只留一点光」。
|
||||
|
||||
html[data-theme-style="SLUG"][data-page-scope="question"] {
|
||||
--tb-page-image: none;
|
||||
--tb-deco: none, none, none, none, none, none, none, none, none, none;
|
||||
--tb-deco-size: auto, auto, auto, auto, auto, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left top, left top, left top, left top, left top,
|
||||
left top, left top, left top, left top, left top;
|
||||
}
|
||||
|
||||
⚠️ 覆盖块里同样要写满 10 个槽位。
|
||||
⚠️ 深色要再写一条三重选择器,否则深色下会被浅色覆盖块污染:
|
||||
html[data-theme="dark"][data-theme-style="SLUG"][data-page-scope="question"] { … }
|
||||
⚠️ 没写覆盖块的页面自动沿用全局,不会出现「某页装饰消失」。
|
||||
首页 / 与 /explore 不加载主题 css,配了也不会生效。 */
|
||||
|
||||
/* 4) 组件微调(可选,默认整段注释掉)
|
||||
只允许「外观」层面的补充:边框、阴影、封面遮罩浓度等。
|
||||
禁止改尺寸/间距/定位 —— 那属于布局,主题不管。 */
|
||||
/*
|
||||
|
||||
@@ -47,12 +47,15 @@ html[data-theme-style="shiroko"] {
|
||||
url("/static/images/themes/shiroko/props/bike.webp?v=20261020d"),
|
||||
linear-gradient(112deg, rgba(255, 250, 236, 0.50) 0%, rgba(255, 250, 236, 0.22) 20%, rgba(255, 250, 236, 0) 44%),
|
||||
radial-gradient(ellipse 560px 170px at 28% 94%, rgba(255, 248, 228, 0.80) 0%, rgba(255, 248, 228, 0) 72%),
|
||||
linear-gradient(0deg, rgba(110, 128, 156, 0.10) 0%, rgba(110, 128, 156, 0) 24%);
|
||||
--tb-deco-size: auto 400px, 96px 13px, auto 259px, 100% 100%, 100% 100%, 100% 100%;
|
||||
linear-gradient(0deg, rgba(110, 128, 156, 0.10) 0%, rgba(110, 128, 156, 0) 24%),
|
||||
none, none, none, none;
|
||||
--tb-deco-size: auto 400px, 96px 13px, auto 259px, 100% 100%, 100% 100%, 100% 100%,
|
||||
auto, auto, auto, auto;
|
||||
--tb-deco-position: left max(10px, calc(50% - 524px)) bottom 36px,
|
||||
left max(22px, calc(50% - 512px)) bottom 29px,
|
||||
left calc(50% - 612px) bottom 20px,
|
||||
left top, left top, left top;
|
||||
left top, left top, left top,
|
||||
left top, left top, left top, left top;
|
||||
}
|
||||
|
||||
html[data-theme="dark"][data-theme-style="shiroko"] {
|
||||
@@ -77,9 +80,10 @@ html[data-theme="dark"][data-theme-style="shiroko"] {
|
||||
--tb-page-image: linear-gradient(180deg, #161d26 0%, #141a22 60%, #11161d 100%);
|
||||
--tb-page-pattern: url("/static/images/themes/shiroko/pattern-dark.svg?v=20261020d");
|
||||
--tb-page-pattern-size: 340px 340px;
|
||||
--tb-deco: none, none, none, none, none, none;
|
||||
--tb-deco-size: auto, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left top, left top, left top, left top, left top, left top;
|
||||
--tb-deco: none, none, none, none, none, none, none, none, none, none;
|
||||
--tb-deco-size: auto, auto, auto, auto, auto, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left top, left top, left top, left top, left top, left top, left top,
|
||||
left top, left top, left top;
|
||||
}
|
||||
|
||||
/* 窄屏:卡片占满宽度,两侧没有视觉空间 —— 换成竖版场景,人物退场 */
|
||||
@@ -87,9 +91,10 @@ html[data-theme="dark"][data-theme-style="shiroko"] {
|
||||
html[data-theme-style="shiroko"] {
|
||||
--tb-page-image: url("/static/images/themes/shiroko/scene-mobile.webp?v=20261020d");
|
||||
--tb-surface: rgba(255, 255, 255, 0.92);
|
||||
--tb-deco: none, none, none, none, none, none;
|
||||
--tb-deco-size: auto, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left top, left top, left top, left top, left top, left top;
|
||||
--tb-deco: none, none, none, none, none, none, none, none, none, none;
|
||||
--tb-deco-size: auto, auto, auto, auto, auto, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left top, left top, left top, left top, left top, left top, left top,
|
||||
left top, left top, left top;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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