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`,
|
```bash
|
||||||
`background-size` / `background-position` 一一对应,不是另画一张示意图。
|
# 1) 先渲染真实页面快照(改了模板/数据后要重跑)
|
||||||
- 位置数学和 CSS 一致:`background-position` 的百分比是相对 **(容器宽 − 图宽)**,
|
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
|
||||||
所以工具里拖出来的「左沿像素」会被反解成 `calc(50% - Npx)`,换视口不会跑位。
|
|
||||||
- 图片全部转 WebP 并按目标尺寸重采样,导出前就能看到 KB 数。
|
# 2) 起服务(只读,不改项目任何文件)
|
||||||
- 生成的 CSS 同时写浅色/深色两块(项目测试 `TestThemeCSSComplete` 要求),
|
python dev/theme-builder/serve.py # 默认 8777
|
||||||
10 个必需令牌齐全。
|
|
||||||
|
# 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%)得在生成端做,
|
- **不做调色**:想统一「雾度」(人物压白 16%、场景 30~38%)得在生成端做,
|
||||||
或用 `docs/theme-shiroko-assets-spec.md` 里的公式自己处理。
|
见 `docs/theme-shiroko-assets-spec.md`。
|
||||||
- **不写 `[theme] available`**。配置在 `conf/app.ini`,导出包里只给了要粘贴的那一行。
|
- **不写配置**:`[theme] available` 只给要粘贴的那一行。
|
||||||
- **不改代码**。素材是编译期 embed,必须重新 `go build` 才会生效。
|
- **不碰深色的图片**:深色块一律 `--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">`,可用于回归:
|
```bash
|
||||||
把 `css` 写成 `static/css/themes/<slug>.css`,跑 `go test ./internal/theme -run TestThemeCSSComplete`。
|
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 错位
|
||||||
|
(表现为整页重复同一张插画,或人物跑到左上角),靠注释守不住。
|
||||||
|
|||||||
+1084
-739
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` |
|
| 中层 | 卡片与顶栏:半透明白 + `backdrop-filter` 磨砂 | `--tb-surface: rgba(255,255,255,.86)` / `--tb-navbar` |
|
||||||
| 上层 | 人物站在场景左侧地面(400px 高)+ 椭圆接触阴影 | `--tb-deco` 前两个槽位 |
|
| 上层 | 人物站在场景左侧地面(400px 高)+ 椭圆接触阴影 | `--tb-deco` 前两个槽位 |
|
||||||
| 道具 | 她的自行车(车头朝右、与人同向)斜靠一把步枪 | `--tb-deco` 第 3 槽位 |
|
| 道具 | 她的自行车(车头朝右、与人同向)斜靠一把步枪 | `--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"
|
BuildCommit = "dev"
|
||||||
|
|
||||||
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
||||||
StaticVersion = "20261020d"
|
StaticVersion = "20261020e"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||||||
|
|||||||
@@ -240,6 +240,12 @@ func Contexter() flamego.Handler {
|
|||||||
isUserPage := strings.HasPrefix(path, "/user") || strings.HasPrefix(path, "/_/")
|
isUserPage := strings.HasPrefix(path, "/user") || strings.HasPrefix(path, "/_/")
|
||||||
c.Data["IsUserPage"] = isUserPage
|
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 后台是箱主本人的页面,跟随本人配置;
|
// 主题:/user 后台是箱主本人的页面,跟随本人配置;
|
||||||
// /_/{domain} 的箱主主题由 question.Pager 覆盖(访客看到的是箱主的选择)。
|
// /_/{domain} 的箱主主题由 question.Pager 覆盖(访客看到的是箱主的选择)。
|
||||||
// 空串(未选且站点无默认主题)= 不加载主题 css。
|
// 空串(未选且站点无默认主题)= 不加载主题 css。
|
||||||
@@ -342,3 +348,55 @@ func (c *Context) Lang() string {
|
|||||||
func isThemeScope(path string) bool {
|
func isThemeScope(path string) bool {
|
||||||
return path == "/user" || strings.HasPrefix(path, "/user/")
|
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),
|
⚠️ 固定 6 个槽位:background-* 各列表的层数必须恒定(1 图案 + 6 装饰 + 1 背景 = 8),
|
||||||
缺省用 none 占位;值的个数一变,多背景的分层就会整体错位。 */
|
缺省用 none 占位;值的个数一变,多背景的分层就会整体错位。 */
|
||||||
--tb-deco: none, none, none, none, none, none;
|
/* 装饰层:固定 10 个槽位。主题可按页面作用域整体替换(html[data-page-scope="..."]),
|
||||||
--tb-deco-size: auto, auto, auto, auto, auto, auto;
|
但每组都必须写满 10 个值 —— 数量不足会让 background-* 列表与图层错位。 */
|
||||||
--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;
|
||||||
}
|
}
|
||||||
html[data-theme="dark"] {
|
html[data-theme="dark"] {
|
||||||
--tb-bg: #1a1d23;
|
--tb-bg: #1a1d23;
|
||||||
@@ -273,13 +276,15 @@
|
|||||||
body {
|
body {
|
||||||
font-family: var(--tb-font);
|
font-family: var(--tb-font);
|
||||||
background-color: var(--tb-bg);
|
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-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-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,
|
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, no-repeat;
|
||||||
background-position: left top, var(--tb-deco-position), center center;
|
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);
|
color: var(--tb-text);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|||||||
@@ -35,10 +35,11 @@
|
|||||||
页面背景 --tb-page-image 整页背景图(cover,fixed)
|
页面背景 --tb-page-image 整页背景图(cover,fixed)
|
||||||
--tb-page-pattern 整页图案(平铺,可叠加在背景图之上)
|
--tb-page-pattern 整页图案(平铺,可叠加在背景图之上)
|
||||||
--tb-page-pattern-size 图案平铺尺寸(默认 auto)
|
--tb-page-pattern-size 图案平铺尺寸(默认 auto)
|
||||||
装饰 --tb-deco 四角装饰图,固定 6 个槽位(必须写满 6 个,缺的填 none);
|
装饰 --tb-deco 装饰图层,固定 10 个槽位(必须写满 10 个,缺的填 none);
|
||||||
固定像素尺寸、不随视口裁切,适合放人物/摆件。默认 none。
|
固定像素尺寸、不随视口裁切,适合放人物/摆件/光斑。默认 none。
|
||||||
--tb-deco-size 对应每张的尺寸,如 auto 200px, auto 150px, auto, auto, auto, auto
|
值可以是图片,也可以是 linear-gradient / radial-gradient(纯"光")。
|
||||||
--tb-deco-position 对应每张的位置,如 left 1% bottom 0, right 1% top 4%, left top, …
|
--tb-deco-size 对应每层的尺寸,如 auto 400px, 100% 100%, auto, …(10 个)
|
||||||
|
--tb-deco-position 对应每层的位置,如 calc(50% - 524px) calc(100% - 36px), …(10 个)
|
||||||
|
|
||||||
── 素材规格(放 R2,不需要重新编译) ─────────────────────────────
|
── 素材规格(放 R2,不需要重新编译) ─────────────────────────────
|
||||||
页面背景图 2400×1600(3:2)或 2560×1440,WebP q80,≤300KB;明暗各一张:
|
页面背景图 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: none;
|
||||||
--tb-page-pattern-size: auto;
|
--tb-page-pattern-size: auto;
|
||||||
|
|
||||||
/* 四角装饰(放人物/摆件)。固定 6 个槽位,必须写满,缺的填 none——
|
/* 装饰层(放人物/摆件/光)。固定 10 个槽位,必须写满,缺的填 none——
|
||||||
值一多一少,多背景各列表就错位(会出现整页重复同一张图)。
|
值一多一少,多背景各列表就错位(会出现整页重复同一张图)。
|
||||||
尺寸用「auto <高度>」,位置可写 left 2% bottom 0 / right 2% top 6% / left 2% center。 */
|
尺寸用「auto <高度>」;位置统一写 calc:
|
||||||
--tb-deco: none, none, none, none, none, none;
|
calc(0% + 40px) 左对齐 + 40px
|
||||||
--tb-deco-size: auto, auto, auto, auto, auto, auto;
|
calc(50% + 0px) 居中
|
||||||
--tb-deco-position: left top, left top, left top, left top, left top, left top;
|
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) 深色(必须)—— 深浅两套必须成对,缺一段会让深色用户看到「浅色的深色主题」 */
|
/* 2) 深色(必须)—— 深浅两套必须成对,缺一段会让深色用户看到「浅色的深色主题」 */
|
||||||
@@ -112,7 +119,27 @@ html[data-theme="dark"][data-theme-style="SLUG"] {
|
|||||||
--tb-page-pattern-size: auto;
|
--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"),
|
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%),
|
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%),
|
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%);
|
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%;
|
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,
|
--tb-deco-position: left max(10px, calc(50% - 524px)) bottom 36px,
|
||||||
left max(22px, calc(50% - 512px)) bottom 29px,
|
left max(22px, calc(50% - 512px)) bottom 29px,
|
||||||
left calc(50% - 612px) bottom 20px,
|
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"] {
|
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-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: url("/static/images/themes/shiroko/pattern-dark.svg?v=20261020d");
|
||||||
--tb-page-pattern-size: 340px 340px;
|
--tb-page-pattern-size: 340px 340px;
|
||||||
--tb-deco: none, none, none, none, none, none;
|
--tb-deco: none, none, none, none, none, none, none, none, none, none;
|
||||||
--tb-deco-size: auto, auto, auto, auto, auto, auto;
|
--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;
|
--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"] {
|
html[data-theme-style="shiroko"] {
|
||||||
--tb-page-image: url("/static/images/themes/shiroko/scene-mobile.webp?v=20261020d");
|
--tb-page-image: url("/static/images/themes/shiroko/scene-mobile.webp?v=20261020d");
|
||||||
--tb-surface: rgba(255, 255, 255, 0.92);
|
--tb-surface: rgba(255, 255, 255, 0.92);
|
||||||
--tb-deco: none, none, none, none, none, none;
|
--tb-deco: none, none, none, none, none, none, none, none, none, none;
|
||||||
--tb-deco-size: auto, auto, auto, auto, auto, auto;
|
--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;
|
--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>
|
<!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>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
"github.com/tamakyi/TamaBox/internal/stats"
|
"github.com/tamakyi/TamaBox/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -171,6 +172,144 @@ func TestGenPreview(t *testing.T) {
|
|||||||
page = inlineThemeCSS(t, page, "sakura")
|
page = inlineThemeCSS(t, page, "sakura")
|
||||||
writePreview(t, outDir, "preview-profile-sakura-"+theme+".html", page, theme, "../static")
|
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://,相对路径取不到。
|
// inlineThemeCSS 把主题 <link> 换成内联 style:预览页走 file://,相对路径取不到。
|
||||||
|
|||||||
Reference in New Issue
Block a user