主题生成器:素材尺寸按视口自适应 + 导出画质可调
- 尺寸单位 4 档:fit(按高)/fitw(按宽)/px/vh,输出 auto clamp(min(calc(100vh-24px),55%),基准,上限),下限让位视口 → 自适应单位永不把图撑出屏幕 - fitw 补上宽幅素材:fit 只按高度缩,16:9 素材在 390 竖屏仍出 1001px 宽(照裁) - 视口预设带高度(含 390×844);layerMetrics 统一出 CSS/预览框/溢出检测,图层编辑区显示实测尺寸并报越界,可一键按当前视口修好 - 窄屏每层策略(原样/缩小/隐藏)+ 缩放系数;修 @media 只覆盖全局、独立配置页面在手机上从不生效的特异性 bug - 导出画质:质量滑块(上限 0.95,Skia 在 q=1 切无损会反常)+ 长边上限,改动后从原图重算 - base.css 新增 --tb-page-position(默认 center center,旧主题不受影响),StaticVersion +1 - 新增 ?dump=fit / ?dump=quality 自检,README 与 _scaffold.css 同步
This commit is contained in:
@@ -217,3 +217,48 @@ iframe 内部仍是 1512,媒体查询按 1512 判定 → 结论是错的。
|
||||
回归(`?dump=layers`):加层后 `[null,null]`、行内下拉 2 个、换第 2 层后 `[null,blue]` 且 CSS 只出现 blue;
|
||||
box 页 inherit 时 `addDisabled/bgDisabled` 都 false;在 box 加层后本页 3 层而**全局仍 2 层**;
|
||||
新建后 assets 0、层 0、草稿已删、scope 回 global。
|
||||
|
||||
## 17:5x 生成器:视口自适应 + 导出画质
|
||||
|
||||
用户两个问题:插入素材后不同屏幕被裁、导出的 webp 太大。都在 `dev/theme-builder/index.html` 里解决。
|
||||
|
||||
**尺寸单位 4 档**(`l.unit`):`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
|
||||
输出 `auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)` 这种。
|
||||
- 关键点 1:**内层 `min(视口 - 24px, 基准×55%)`** —— 下限让位给视口,自适应单位因此永不溢出。
|
||||
写死 55% 下限的话,390 竖屏放不下 1067 宽的图,照样裁。
|
||||
- 关键点 2:**必须有 `fitw`**。`fit` 只按高度缩,宽幅素材宽度按比例涨:1920×1080 素材 h=600 在
|
||||
390 视口下 fit 出 1001px 宽(裁),fitw 出 366px(完整)。实测见 `?dump=fit` 的 `fitw` 数组。
|
||||
- 关键点 3:`layerSize()`(出 CSS)与 `unitGeom()`(出预览框/溢出检测)**必须共用一套换算**,
|
||||
两套各算迟早漂成「框没超、图已裁」。
|
||||
- ⚠️ 下拉 value 要用 `l.unit`,不能用 `unitOf()`(把 fit 归一成 vh → 选了 fit 却显示「按视口高比例」)。
|
||||
|
||||
**视口预设带高度**(`VIEWPORTS` 改成 `{w,h,label}`,加了 390×844)。原来只有宽度、iframe 恒 900 高,
|
||||
768 的旧笔电和 844 手机根本模拟不出来 —— 而裁切几乎全由高度决定。
|
||||
|
||||
**溢出检测 + 一键修好**:`layerMetrics(l,a,VW,VH)` 给 overL/R/T/B,图层编辑区显示
|
||||
「1512×900 下显示 131×420,完整可见」或橙色「⚠ 左右各 88px 越界」+「按当前视口修好」按钮
|
||||
(切 px 并算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍,只提醒不拦。
|
||||
|
||||
**窄屏每层策略** `l.mob`(inherit/scale/hide)+ 全局 `S.narrow.scale`(默认 0.62)。
|
||||
⚠️ 顺手修了一个既有 bug:`@media` 块原来只写 `html[data-theme-style]`(0,1,1),
|
||||
被页面覆盖块 (0,2,1) 压住 → **独立配置的页面在手机上从来没生效过**。现在每个独立 scope 各写一条。
|
||||
⚠️ 竖版图只替换 `cfg.bg` 非空的页面(否则给纯色页塞图 = 改了他的设计)。
|
||||
|
||||
**背景焦点** `cfg.bgPos` → `--tb-page-position`;base.css 里 `var(--tb-page-position, center center)` 兜底
|
||||
(旧主题不写也不坏),StaticVersion → `20261020f`。
|
||||
|
||||
**导出画质**:`S.export = {quality: 0.88, maxSide: 1600}`,改完全量 `rebakeAll()`(从 orig 重算,不叠加压缩)。
|
||||
- ⚠️ **`toDataURL('image/webp', 1)` 是坑**:Skia 在 q=1 切无损,输出反常 —— 实测同一张噪声图
|
||||
q=1 只有 52KB、q=0.95 是 440KB。`canvasAsset` 与滑块都夹到 0.95。
|
||||
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点;实测平涂图各档都是 1KB,噪声图 0.6→0.95 涨 75%)。
|
||||
所以文案写「先砍长边再降质量」,不承诺「降 q 一定更小」。
|
||||
- 缩小加 `imageSmoothingQuality='high'`。
|
||||
|
||||
**自检**:新增 `?dump=fit`(视口占用/溢出/fitw/旧工程兼容/@media 覆盖各 scope)与
|
||||
`?dump=quality`(q 单调性、q=1 被夹、长边真压像素),两项 `ok:true`;`?dump=validate` 空、layers/fx/fxpipe 全绿。
|
||||
生成 CSS 临时存成 `static/css/themes/gentest.css` 跑 `go test ./internal/theme` → 30 处声明全过(`clamp` 里的逗号没被当层分隔符)。
|
||||
⚠️ 顺带发现 **`//go:embed` 的 `*` 不匹配 `_` 开头文件** —— `_scaffold.css` 与 `_gentest.css` 都不进 embed,
|
||||
所以那个校验一开始"跑了但数字没变",改名后才真正生效。
|
||||
|
||||
UI 踩坑:面板仅 ~240px,`input[type=range]` 有固有宽度 → 必须 `flex:1 1 0;width:0;min-width:0`,
|
||||
且质量数值要放进 label 里(作第三个 flex 项会被顶出可视区,表现为"滑块没有数字")。
|
||||
|
||||
+117
-105
@@ -3,63 +3,65 @@
|
||||
## 协作
|
||||
|
||||
- 结论分级 P0/P1/P2;给建议前先根因取证 + 实测复现,别只静态推断。
|
||||
- 改代码前先出变更清单获批准;用户常授权自主推进,但重大问题仍要显式指出。
|
||||
- 用户是运营者也是圈内人:处置功能必须可解释、可留痕、可申诉,不做自动级联封号。
|
||||
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
|
||||
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
|
||||
|
||||
## 构建与测试
|
||||
|
||||
- **无 libwebp/GCC**,cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩 `dev/s3stub/s3_stub.go`:
|
||||
`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`。
|
||||
overlay 换不了 GOMODCACHE 里的文件,**要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`**。
|
||||
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前:
|
||||
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 在本沙箱失败)。
|
||||
1C1G 是正式生产环境,**别用「沙箱慢」解释超时**。
|
||||
- **无 libwebp/GCC**:cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩
|
||||
`dev/s3stub/s3_stub.go`(`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`)。
|
||||
要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`(overlay 换不了 GOMODCACHE)。
|
||||
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前
|
||||
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 沙箱失败)。
|
||||
1C1G 是正式生产环境,别用「沙箱慢」解释超时。
|
||||
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
|
||||
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 目前全绿。
|
||||
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 例外,有守卫)。
|
||||
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定走 redis,6379 未开,启动即 panic)→ HTTP 端到端只能部署后做。
|
||||
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 全绿。
|
||||
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 有守卫)。
|
||||
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定 redis,6379 未开,启动 panic)→ HTTP 端到端只能部署后做。
|
||||
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
|
||||
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常),曾误判「接口卡死」。线上验证优先 curl;
|
||||
python 别用 `/tmp`(Windows 解析成 `C:\tmp`,与 MSYS `/tmp` 不通)。
|
||||
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常);python 别用 `/tmp`
|
||||
(Windows 解析成 `C:\tmp`,与 MSYS 不通)。PIL 在 `~/.workbuddy/binaries/python/envs/default`。
|
||||
|
||||
## 行尾 / 模板
|
||||
## 行尾 / 模板 / embed
|
||||
|
||||
- 行尾混合不可推断(`internal/db/*`、`mail.go`、`route/user/profile.go` 是 LF;`internal/route/route.go`、
|
||||
`users.go`、`i18n/dict_*.go`、`route/question/*.go`、`question-list.html` 是 CRLF)。改前用 Python 数 `b'\r\n'`;
|
||||
`gofmt -l` 会把 CRLF 误报为未格式化。格式化走 `dev/gofmt_keep_crlf.py`。
|
||||
- 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 会改成 `”`)。
|
||||
`users.go`、`i18n/dict_*.go`、`route/question/*.go`、`base.css`、`dev/theme-builder/*` 是 CRLF)。
|
||||
改前用 Python 数 `b'\r\n'`;`gofmt -l` 会把 CRLF 误报为未格式化 → 走 `dev/gofmt_keep_crlf.py`。
|
||||
Edit 工具会保留原文件行尾,可放心用;新增整份文件用 Write 时要自己补 CRLF。
|
||||
- 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 改成 `”`)。
|
||||
- ⚠️ **`templates/embed.go` 是显式清单**:新增**顶层**模板必须加进 `//go:embed`,否则运行时
|
||||
`html/template: "xxx" is undefined` → 500,编译期无提示(`checktpl` 也只查语法)。子目录已被目录项覆盖。
|
||||
`html/template: "xxx" is undefined` → 500,编译期无提示。`static/fs.go` 同理。
|
||||
- ⚠️ **`//go:embed` 的 `*` 不匹配 `_` / `.` 开头的文件**:`static/css/themes/_scaffold.css` 与
|
||||
临时建的 `_gentest.css` **都不进 embed**,`TestDecoSlotCount` 扫不到它们(表现是"校验了但数字没变")。
|
||||
验证生成的主题 CSS 要临时改成非下划线文件名。
|
||||
- `templates/user/moderation-strictness.html` 只用 shell/python 改(Edit/Write 会注入 `data-page-node-id`)。
|
||||
|
||||
## 审核 / 提示 / 处置
|
||||
## 审核 / 处置 / 举报
|
||||
|
||||
- 线上通道 = 七牛(`ai.qiniuapi.com/v3/text/censor`,antispam);`[security] text_censor_source = qiniu|aliyun|auto`。
|
||||
七牛只硬拦涉政/暴恐/色情,`review` 放行 → 裁决在 `censor/policy.go` + `moderation.Check`。
|
||||
- **新增审核调用点必须走 `moderation.Check`**(直调 `censor.Text` 会绕过档位)。不可用时 **fail-closed**。
|
||||
- ⚠️ **圈内黑话(代称+群体标签+避雷叙述)任何通用审核都拦不住**,别指望词库;靠处置 + 举报 + 人工判断。
|
||||
- 严格度三档 lax/standard/strict,存 `users.moderation_strictness`。**受处置用户强制 strict**:
|
||||
`moderation.EffectiveStrictness(ctx, userID, raw)`(7 个入口已全部改用);**查询失败时回退用户设置**
|
||||
(否则 DB 抖动会让全站突然加严)。处置期内 `UpdateModerationStrictness` 拒绝保存 + 设置页 disabled。
|
||||
- ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,下一请求才读 →
|
||||
提示静默丢失(「点了没反应」)。`Set*Flash + Redirect` 是正常模式。
|
||||
扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(` → 前者即缺陷。
|
||||
- 处置四级:L1 停止分发(禁分享卡 + 首页/探索/标签/随机位摘除)/L2 收窄公开(新回答强制私密、禁 set-public)/
|
||||
L3 冻结发布(仍可收信)/L4 整页停用(`/restricted`)。到期靠 `expires_at` 自动失效,**无 cron**;
|
||||
`restrict` 是**替换语义**;级别比较用 `RestrictionAtLeast`。
|
||||
- ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):拆开会留下
|
||||
「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
|
||||
- **新增审核调用点必须走 `moderation.Check`**(直调 `censor.Text` 绕过档位)。不可用时 **fail-closed**。
|
||||
- ⚠️ 圈内黑话(代称+群体标签+避雷叙述)通用审核拦不住,别指望词库;靠处置 + 举报 + 人工判断。
|
||||
- 严格度 lax/standard/strict 存 `users.moderation_strictness`;**受处置用户强制 strict**:
|
||||
`moderation.EffectiveStrictness(ctx, userID, raw)`(7 个入口已改)。**查询失败回退用户设置**
|
||||
(否则 DB 抖动会让全站突然加严)。处置期内拒绝保存 + 设置页 disabled。
|
||||
- ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,下次请求才读 →
|
||||
提示静默丢失。`Set*Flash + Redirect` 才是正常模式。扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(`。
|
||||
- 处置四级:L1 停止分发/L2 收窄公开(新回答强制私密、禁 set-public)/L3 冻结发布(仍可收信)/
|
||||
L4 整页停用(`/restricted`)。到期靠 `expires_at`,**无 cron**;`restrict` 是**替换语义**;比较用 `RestrictionAtLeast`。
|
||||
- ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):
|
||||
拆开会留下「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
|
||||
- ⚠️ **`ListAPI`(`/api/v1/user/{domain}/questions`)不经过 Pager 中间件**,L4 与页面密码必须自己校验。
|
||||
- ⚠️ **整箱分享卡 `ShareCard` 与单题分享卡都要拦**,且拦截必须在缓存查询之前(否则 30 分钟内仍给旧图)。
|
||||
- 举报 `POST /_/{domain}/{qid}/report`:公开免登录 + 验证码 + 限流;同 IP 同题唯一索引 →
|
||||
- 举报 `POST /_/{domain}/{qid}/report`:免登录 + 验证码 + 限流;同 IP 同题唯一索引 →
|
||||
**幂等且提示与首次一致**(否则成了内容存在性探测器)。箱主只看「被举报 N 次」,**不给 IP**。
|
||||
- 闭环:箱主删除/设私密或 `hide-public` → `MarkAcceptedByQuestion`;待处理超 30 天视为过期。
|
||||
⚠️ `hide-public` 里必须**先快照目标 ID 再更新**(更新后用带 `is_private=false` 的子查询恒为空集)。
|
||||
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却,进程内);单条恰好 3 人举报 → 提醒箱主。
|
||||
- 闭环:删除/设私密/`hide-public` → `MarkAcceptedByQuestion`;待处理超 30 天视为过期。
|
||||
⚠️ `hide-public` 里必须**先快照目标 ID 再更新**(更新后带 `is_private=false` 的子查询恒为空集)。
|
||||
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却);单条恰好 3 人举报 → 提醒箱主。
|
||||
⚠️ 该邮箱为空时静默跳过、无日志;改配置必须重启。
|
||||
- `/rules` 是处置依据页(四语言);注册页必须勾 `agree_rules`(服务端同样校验),改 e2e 脚本要同步。
|
||||
- `restrict`/`unrestrict` 默认给箱主发通知(按 `users.language`,模板 `mail/restriction-notice.html`),
|
||||
**只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 跳过。`--days > 90` 必须加 `--force`。
|
||||
- `restrict`/`unrestrict` 默认给箱主发通知(按 `users.language`,`mail/restriction-notice.html`),
|
||||
**只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 跳过。`--days > 90` 要 `--force`。
|
||||
⚠️ **到期自动解除不发信**(读表比较时间)。
|
||||
|
||||
## GORM
|
||||
@@ -72,103 +74,113 @@
|
||||
|
||||
## 前端样式
|
||||
|
||||
- `static/css/base.css`(`?v=YYYYMMDDx`,**改完必须 +1**,静态缓存 1 天)。令牌在 `:root`(亮)+
|
||||
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,**改完必须 +1**)。令牌在 `:root`(亮)+
|
||||
`html[data-theme="dark"]`(暗)。新样式一律 `var(--tb-*)`,别写死色值,也别为深色另写分支。
|
||||
- 页面级样式内联在各自模板 `<style>` 里,同样只用令牌;尽量用类不用 `style=`(深色下写死色值会露白)。
|
||||
- 静态文件是**显式 embed 清单**(`static/fs.go`),新增 css/js 必须加进 `//go:embed`,否则运行期 404。
|
||||
- 页面级样式内联在各自模板 `<style>`;尽量用类不用 `style=`(深色下内联写死色值会露白)。
|
||||
- 主题属性在 head 内联脚本里打(cookie `theme`),**不能挪回 body**,否则深色用户白闪。
|
||||
- **移动端(≤640px)语言切换与主题按钮不在顶栏**:`.tb-nav-utils` 隐藏,由 `#tb-mobile-utils` 固定底栏承载
|
||||
(`bottom:0;z-index:1000`,body 留 `padding-bottom:56px`)。用户曾误以为"按钮不见了",先解释位置。
|
||||
- **移动端(≤640px)语言切换与主题按钮在固定底栏** `#tb-mobile-utils`(`.tb-nav-utils` 被隐藏,
|
||||
body 留 `padding-bottom:56px`)。用户曾误以为"按钮不见了",先解释位置。
|
||||
⚠️ `/_/{domain}` 箱主页 `IsUserPage = true`(`route/question/page.go:133`),语言切换包在
|
||||
`{{ if not .IsUserPage }}` 里,所以**箱主页没有语言切换入口——有意设计**(用户 2026-10-19 明确),别当 bug 修。
|
||||
- ⚠️ **UIkit 3.3.3 有 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
|
||||
`.tb-cover.uk-flex::after` 这类复合选择器,否则静默失效。
|
||||
- 分类/题型标签一律 `.tb-chip`;**彩色 `uk-label` 只留给语义状态**(置顶/私密/被举报/已读、投票四态)。
|
||||
`{{ if not .IsUserPage }}` 里 → **箱主页没有语言切换入口,有意设计**(用户 2026-10-19 确认),别当 bug 修。
|
||||
- ⚠️ **UIkit 3.3.3 的 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
|
||||
复合选择器(`.tb-cover.uk-flex::after`),否则静默失效。
|
||||
- 分类/题型标签一律 `.tb-chip`;彩色 `uk-label` 只留给语义状态(置顶/私密/被举报/已读、投票四态)。
|
||||
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` → `outputs/preview-*.html`;
|
||||
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
|
||||
⚠️ `--window-size` ≠ CSS 视口(1080→985)。验证 CSS 靠**像素比对**(PIL 在
|
||||
`~/.workbuddy/binaries/python/envs/default`)而非目测;扫描循环上界不能与物体边界重合
|
||||
(曾把边界截断读成"方角")。
|
||||
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
|
||||
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
|
||||
|
||||
## 主题(箱主个性化)
|
||||
|
||||
- 三个注入点,别再漏:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;`/user/*` → `internal/context`
|
||||
的 Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);`/vote/{token}` → `route/vote.Page`
|
||||
- 三个注入点:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;`/user/*` → `internal/context`
|
||||
Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);`/vote/{token}` → `route/vote.Page`
|
||||
按发布者 `owner.Theme`。都看箱主/发布者,不看访客。全站页(`/`、`/explore`、`/login`)不主题化。
|
||||
- 投票页**语言**也跟发布者:`applyOwnerLocale(data map[string]interface{}, owner)`(与主题同一个函数),
|
||||
必须在任何 i18n 取词之前调用;签名收 map 是为了能纯内存单测。
|
||||
- 一套主题 = `static/css/themes/<slug>.css`,**只覆盖令牌**,不碰布局。`static/fs.go` 已嵌整个 `themes/` 目录,
|
||||
新增只需放文件 + 登记 `[theme] available`(底稿 `_scaffold.css`)。
|
||||
- ⚠️ **`[theme] available` 是硬白名单,启动时读一次**:css 发布了但没登记,设置页就是看不到,现象和
|
||||
「部署没生效」一样。排查:`curl /static/css/themes/<slug>.css` 200 = 文件在(没登记);404 = 旧二进制。
|
||||
启动日志有 `主题注册表已加载 enabled=...` 与 `[theme] available 未登记以下主题`。
|
||||
- ⚠️ 主题 css **必须成对写**:`html[data-theme-style="x"]`(0,1,1)+
|
||||
`html[data-theme="dark"][data-theme-style="x"]`(0,2,1)。只写浅色块会让深色被浅色令牌污染。
|
||||
- 投票页**语言**也跟发布者:`applyOwnerLocale(data, owner)`(与主题同一函数),必须在任何 i18n 取词之前调用。
|
||||
- 一套主题 = `static/css/themes/<slug>.css`,**只覆盖令牌**,不碰布局。`static/fs.go` 已嵌整个 `themes/` 目录。
|
||||
- ⚠️ **`[theme] available` 是硬白名单,启动时读一次**:css 发布了但没登记,设置页看不到,现象同「部署没生效」。
|
||||
排查:`curl /static/css/themes/<slug>.css` 200 = 文件在(没登记);404 = 旧二进制。
|
||||
- ⚠️ 主题 css **必须成对写**:`html[data-theme-style="x"]`(0,1,1) + `html[data-theme="dark"][data-theme-style="x"]`(0,2,1)。
|
||||
`TestThemeCSSComplete` 查两个选择器 + 10 个颜色令牌。
|
||||
- ⚠️ **按页面作用域**:`internal/context.pageScope()` 输出 `<html data-page-scope>`,取值
|
||||
`box`/`question`/`admin`(/user/questions,/user/votes*)/`profile`/`vote`。主题用
|
||||
`html[data-theme-style="x"][data-page-scope="y"]` 覆盖整页背景与装饰。深色覆盖必须写**三重选择器**
|
||||
(再加 `[data-theme="dark"]`),否则被浅色作用域块盖掉(同特异性、写在后面)。没写覆盖块自动回退全局。
|
||||
- ⚠️ **装饰层固定 10 槽**(base.css 12 层 = pattern1 + deco10 + image1),`--tb-deco`/`-size`/`-position`
|
||||
三组必须等长。`TestDecoSlotCount` 逐文件逐声明检查;少写一个不报错,只会让图层与 size/position 错位
|
||||
(整页重复同一张图 / 人物跑到左上角)。
|
||||
- 位置一律写两值 `calc(0%|50%|100% ± Npx)`(百分比相对「容器 − 图片」)。**别用 left/bottom 关键字混搭**:
|
||||
「水平居中 + 垂直贴底」是三值语法,非法时整条声明作废、图层静默回左上。
|
||||
- ⚠️ **UIkit 3.3.3 的主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
|
||||
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、
|
||||
`.uk-label:not(danger|warning|success|secondary)`)。新增会显示主色的组件先确认已映射。
|
||||
- 令牌 `--tb-navbar`:顶栏底色,默认 `var(--tb-surface)`。页面底色偏彩时主题必须覆盖,否则纯白横带压在彩底上割裂。
|
||||
- 静态资源版本号统一 `conf.StaticVersion`(base.css 与主题 css 共用);**图片不走这个缓存键**(`max-age=86400`),
|
||||
换图要给 URL 带 `?v=`。
|
||||
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸),图案叠在图片之上。
|
||||
底色用**纯色**别用整页渐变;图案 ≥24px、透明度 ≤0.06。
|
||||
- ⚠️ **按页面作用域**:`internal/context.pageScope()` → `<html data-page-scope>`,取值
|
||||
`box`/`question`/`admin`(/user/questions,/user/votes*)/`profile`/`vote`。深色作用域覆盖必须**三重选择器**,
|
||||
否则被浅色作用域块(同特异性、写在后面)盖掉。
|
||||
- ⚠️ **装饰层固定 10 槽**(base.css 12 层 = pattern1 + deco10 + image1),三组 `--tb-deco/-size/-position`
|
||||
必须等长。`TestDecoSlotCount` 逐文件逐声明检查(只扫 embed 到的文件)。
|
||||
- 位置一律两值 `calc(0%|50%|100% ± N)`(百分比相对「容器 − 图片」)。**别用 left/bottom 关键字混搭**:
|
||||
三值语法非法时整条声明作废 → 图层静默回左上。
|
||||
- 尺寸想随视口缩用 `clamp`,**不要写死 px**:`auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)`。
|
||||
内层 `min()` 是兜底 —— 屏幕小到连下限都放不下时让位给视口。宽幅素材只能按宽度缩(`vw`),
|
||||
按高度缩的话宽度按比例涨,窄屏照样裁。
|
||||
- ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
|
||||
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。
|
||||
- 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。
|
||||
- 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。
|
||||
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
|
||||
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
|
||||
图案 ≥24px、透明度 ≤0.06。
|
||||
- ⚠️ **多背景层不能塞变量列表**:变量自身是逗号列表会让层数与 size/position 错位。多张装饰用固定槽位。
|
||||
- ⚠️ `background-position` 四值语法必须**先是边关键字**:`left calc(...) bottom 0` 合法,`max(...) bottom 0`
|
||||
非法 → 整条声明作废、人物跑到左上。
|
||||
- 装饰摆位:**贴边、成组、避开页脚**;四角各摆一个会读成贴纸墙纸;页脚文案右对齐 → 右下角不能放人物。
|
||||
`--tb-deco` 是 fixed 视口层,贴边插画别放进 `cover` 背景图(必裁边)。
|
||||
- 主题视觉的**结构**比摆位重要:二次元站点通行做法是「整屏场景插画 + UI 悬浮其上」(蔚蓝档案 JP)。
|
||||
白底留白里摆人物,无论多大都会读成贴纸;卡片/顶栏配半透明白 + `backdrop-filter` 才有"浮在画面上"的关系。
|
||||
- 素材放 `static/images/themes/<slug>/`(embed 已含 `images/themes`,子目录自动覆盖;**新增其它顶层目录才改清单**)。
|
||||
整页图用 16:9 + 竖版两张,靠 `@media` 切换。
|
||||
- 人物素材:**图生图 + 抠白底**(`image1` 给立绘、`input_fidelity: high`、提示词强调
|
||||
`flat vector illustration, no outlines`),角色一致性远好于手绘 SVG。
|
||||
⚠️ 正面立绘直接贴进空场景像贴纸:改**侧后方视角**(不直视访客),并把 RGB **压白 12~16%**
|
||||
与压白 38% 的场景对齐雾度。
|
||||
- 主题视觉的**结构**比摆位重要:通行做法是「整屏场景插画 + UI 悬浮其上」(蔚蓝档案 JP)。
|
||||
白底留白里摆人物,无论多大都读成贴纸;卡片/顶栏配半透明白 + `backdrop-filter` 才有"浮在画面上"的关系。
|
||||
- 素材放 `static/images/themes/<slug>/`(embed 已含 `images/themes`,子目录自动覆盖)。整页图 16:9 + 竖版两张。
|
||||
- 人物素材:**图生图 + 抠白底**(`image1` 给立绘、`input_fidelity: high`、`flat vector illustration, no outlines`)。
|
||||
⚠️ 正面立绘直接贴进空场景像贴纸:改**侧后方视角**(不直视访客),RGB **压白 12~16%** 与压白 38% 的场景对齐雾度。
|
||||
- ⚠️ **抠白底容差 16**:背景 253、白袜子 237 只差 16,容差 22 以上会把袜子吃穿。手内侧/两腿之间是
|
||||
**闭合**白区,四边 floodfill 进不去,得按连通域单独掏(面积 ≥24),再 1px 收边。
|
||||
- ⚠️ `ruleBody` 取令牌前要剥 `/* */`:注释会和后面的令牌粘成一个键,色块预览静默失效。
|
||||
- 可视化编辑走 `dev/theme-builder/`(`serve.py` 起服务 + iframe 加载真实页面快照;file:// 下父页面读不到
|
||||
iframe DOM,必须走 HTTP)。图片处理是**非破坏**的:`orig` 存原始字节 + `fx` 存参数,每次从 orig 重算;
|
||||
参数没变则跳过重编码(反复走有损编码会累积模糊)。
|
||||
|
||||
## 主题生成器 `dev/theme-builder/`
|
||||
|
||||
- `serve.py` 起静态服务(8777),iframe 加载 `outputs/preview-*.html`。**file:// 下父页读不到 iframe DOM**,必须走 HTTP。
|
||||
8777 常由用户自己起着,改完直接刷新。
|
||||
- 单文件 `index.html`(约 2900 行)。图片处理**非破坏**:`orig` 存原始字节 + `fx` 存参数,每次从 orig 重算;
|
||||
参数没变则跳过重编码。画质(`S.export.quality/maxSide`)同理 —— 改完 `rebakeAll()` 从 orig 重来,不叠加压缩。
|
||||
- **尺寸单位** 4 档:`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
|
||||
`layerSize()` 出 CSS、`unitGeom()` 出预览框,**两处必须共用同一函数**,否则「框没超、图已裁」。
|
||||
⚠️ 下拉的 value 用 `l.unit`,不能用 `unitOf(l)`(后者把 fit 归一成 vh → 选了 fit 却显示 vh)。
|
||||
- 溢出检测 `layerMetrics(l, a, VW, VH)`:算 left/top/overL/R/T/B,编辑器里显示「显示 131×420,完整可见」或
|
||||
「⚠ 左右各 88px 越界」,并给「按当前视口修好」按钮(切 px + 算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍。
|
||||
- 视口预设**带高度**(1512×900 / 1280×800 / 1024×768 / 820×1180 / 390×844):裁切几乎全由高度决定,
|
||||
只改宽度模拟不出来。`fitStage()` 按预设高渲染 iframe。
|
||||
- 窄屏:全局 `hideDeco`(急停)> 每层 `mob`(inherit/scale/hide)> 尺寸单位(fit/fitw 自己就缩)。
|
||||
⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1),
|
||||
只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。
|
||||
⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。
|
||||
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
|
||||
(`?demo=1` 已含 fit 层 + 竖版背景,`@media` 才会生成)。用法:
|
||||
`chrome --headless=old --virtual-time-budget=12000 --dump-dom "http://127.0.0.1:8777/dev/theme-builder/index.html?demo=1&fresh=1&dump=fit"`
|
||||
抓 `<pre id="dump">`(正则要带属性:`<pre id="dump"[^>]*>`)。
|
||||
- ⚠️ **canvas `toDataURL('image/webp', 1)` 不能用**:Skia 在 q=1 切无损,输出反常(噪声图 q=1 只 52KB,
|
||||
q=0.95 是 440KB)。`canvasAsset` 夹到 0.95,滑块上限也是 0.95。
|
||||
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点)。压体积先砍长边,再降 q。缩小走 `imageSmoothingQuality='high'`。
|
||||
- ⚠️ 面板只有 ~240px:`input[type=range]` 有固有宽度,必须 `flex:1 1 0;width:0;min-width:0`,
|
||||
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
|
||||
- `?dump=css` 的产物要过 Go 侧校验:临时存成 `static/css/themes/<非下划线名>.css` 再 `go test ./internal/theme`,
|
||||
跑完删掉。
|
||||
|
||||
## 其它约定
|
||||
|
||||
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
|
||||
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
|
||||
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,且用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
|
||||
- git 仓库 `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**;
|
||||
「Failed to authenticate user」是**临时态**。推前 `git log --oneline -3` 确认已提交。
|
||||
改代码时只做只读 `git status/log`,别动历史。`outputs/` 已在 `.gitignore`(`/outputs/`),别整目录 add。
|
||||
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
|
||||
- git `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**(本机 Git for Windows 已认证);
|
||||
「Failed to authenticate user」是临时态。推前 `git log --oneline -3`。`outputs/` 已 gitignore,别整目录 add。
|
||||
- 打包交付用 tar.gz(排除 `.git`/`.workbuddy`/`outputs`),放项目同级。
|
||||
|
||||
## 测试环境 box-dev-api.337845818.best
|
||||
|
||||
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`(用户 2026-10-19 指定)。
|
||||
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`。
|
||||
- 登录要过算数验证码:`GET /login` 取 `_csrf`+cookie → `GET /api/captcha/generate` → 算 `a op b` →
|
||||
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加
|
||||
(`TestDictionaryComplete`)。
|
||||
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML
|
||||
比对标记,别先怀疑缓存。
|
||||
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加。
|
||||
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML 比对标记。
|
||||
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
|
||||
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(**基线非 200 就别往下走**;
|
||||
`RUN=1` 才真跑;`trap` 保证撤销)。
|
||||
- 图片:编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢),
|
||||
比例校验用同一套方向(头像 1:1 测不出,只有背景 16:9 触发)。
|
||||
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(基线非 200 就别往下走;`RUN=1` 才真跑)。
|
||||
- 图片编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢)。
|
||||
|
||||
## CLI(`TamaBox <cmd>`)
|
||||
|
||||
- ⚠️ **两个构建入口必须同步**:根 `go build .`(根 `tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
|
||||
- ⚠️ **两个构建入口必须同步**:根 `go build .`(`tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
|
||||
否则 `No help topic for '<cmd>'`。
|
||||
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
|
||||
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
|
||||
|
||||
@@ -63,12 +63,45 @@ http://127.0.0.1:8777/dev/theme-builder/
|
||||
|
||||
**装饰图层** —— 每个作用域最多 10 层,三种类型:
|
||||
|
||||
- **图片**:从图片池选,按高度定尺寸(宽度自动等比),选中后可直接在画板上拖动;
|
||||
- **图片**:从图片池选,选中后可直接在画板上拖动;
|
||||
- **光斑**:`radial-gradient`,给空白一点体积,不占位、不会变成贴纸;
|
||||
- **线性光**:`linear-gradient`,做窗光/压深。
|
||||
|
||||
锚点用 9 宫格 + 像素偏移,生成 `calc(0% + 40px) calc(100% - 36px)` 这种两值形式。
|
||||
|
||||
### 尺寸单位 × 视口(「换个屏幕就被裁」的根因)
|
||||
|
||||
装饰层是**视口固定层**(`background-attachment: fixed`),所以同一个 `400px` 在 900 高的屏幕上占
|
||||
44%、在 768 的旧笔电上占 52%,窄屏上直接被切掉。尺寸单位就是回答「屏幕变小的时候这张图怎么办」:
|
||||
|
||||
| 单位 | 生成 | 适合 | 低于 100% 视口时的兜底 |
|
||||
| --- | --- | --- | --- |
|
||||
| `fit` 自适应·按高度 | `auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)` | 人物立绘、竖幅道具 | 下限让位给视口,永远不纵向溢出 |
|
||||
| `fitw` 自适应·按宽度 | `clamp(min(calc(100vw - 24px), 587px), 70.57vw, 1067px) auto` | 横幅、整屏插画 | 下限让位给视口,永远不横向溢出 |
|
||||
| `px` 固定 | `auto 420px` | 需要绝对尺寸(如对齐某个 UI 元素) | 不缩,会裁 |
|
||||
| `vh` 视口比例 | `auto 46.67vh` | 一定要占同样屏高的元素 | 无下限 |
|
||||
|
||||
**为什么必须有两种自适应**:`fit` 只按高度缩,宽度跟着比例涨 —— 一张 16:9 场景 `h=600` 在 1024 宽下
|
||||
是 910px 刚好,在 390 的竖屏上按高度缩完还有 1001px,照样裁两侧。宽幅素材只能按宽度缩。
|
||||
`fitw` 就是干这个的。实测同一张 1920×1080 素材:
|
||||
|
||||
| 视口 | `px 600` | `fit`(按高) | `fitw`(按宽) |
|
||||
| --- | --- | --- | --- |
|
||||
| 1512×900 | 1067 宽 | 1067 宽 | 1067 宽 |
|
||||
| 1024×768 | 1067 宽 | 910 宽 | 723 宽 |
|
||||
| 390×844 | 1067 宽(裁) | 1001 宽(裁) | 366 宽(完整) |
|
||||
|
||||
`min(calc(100vh - 24px), 231px)` 这一段是关键:`clamp()` 的下限如果固定成「基准的 55%」,
|
||||
屏幕小到连 55% 都放不下时还是会溢出。让下限与视口取小值,自适应单位就不会把图撑出屏幕。
|
||||
|
||||
**每个图层底部有一行实测**:`1512×900 下显示 131×420,完整可见(源 300×960)`,
|
||||
溢出时会变成橙色 `⚠ 1024×768:左 88px / 右 88px 越界`,并给一个「按 1024×768 修好」按钮
|
||||
(改成固定 px 并算出刚好放下的尺寸)。视口栏可以切 1512/1280/1024/820/390 五档 —— 都是真视口高,
|
||||
不是只改宽度,因为裁切几乎全由高度决定。
|
||||
|
||||
导出前还会再扫一遍,把「哪些层在哪些视口下越界」附在结果里(只提醒,不阻止 ——
|
||||
故意让插画出血也是合法设计)。
|
||||
|
||||
**每一行都能直接换图**:行里带缩略图 + 「图片」下拉,一眼看到这层用的是哪张。
|
||||
之前换图的下拉只出现在「选中那一层」的编辑器里,加上新层又默认绑第一张素材,
|
||||
连加三层就全是同一张图,看起来像「每层只能显示这张、换不了」。
|
||||
@@ -77,13 +110,31 @@ http://127.0.0.1:8777/dev/theme-builder/
|
||||
**↑ / ↓ 调层级**。顺序就是层级:`background-image` 的第一层画在最上面,
|
||||
所以「谁挡住谁」是靠顺序定的(shiroko 里自行车被人挡住就是这么来的)。
|
||||
|
||||
**窄屏** —— 手机上卡片几乎占满屏幕,桌面那套 16:9 场景会被 `cover` 裁成一条无意义纹理,
|
||||
固定 px 的人物会被卡片盖住。这一栏可以:指定一张竖版背景图、决定装饰是否退场、
|
||||
改断点(默认 900px)。导出时生成对应的 `@media` 块,只写浅色规则
|
||||
**窄屏** —— 手机上卡片几乎占满屏幕,桌面那套 16:9 场景会被 `cover` 裁成一条无意义纹理。
|
||||
三段优先级从高到低:
|
||||
|
||||
1. **隐藏全部装饰层(急停)** —— 一键全关,最省事;
|
||||
2. **每层的窄屏策略**:原样 / 缩小到 N%(缩放系数默认 62%)/ 隐藏。缩小是把高度与偏移
|
||||
一起乘系数,锚点不变,所以「贴底靠左」的构图缩完还在原位;
|
||||
3. **尺寸单位**:`fit` / `fitw` 的层浏览器会自己按视口缩,**不依赖这段规则**。
|
||||
|
||||
这一栏还能指定竖版背景图、改断点(默认 900px)。导出时生成 `@media` 块,只写浅色规则
|
||||
(深色块特异性更高,本来就压住它)。
|
||||
|
||||
**图片池** —— 上传任意张,浏览器本地转 WebP,画布尺寸 = 原图尺寸(不缩放、不留透明边)。
|
||||
导出时只打包实际用到的图。每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
|
||||
⚠️ `@media` 里**每个独立配置的页面都要各写一条** `html[data-theme-style][data-page-scope="x"]`:
|
||||
页面覆盖块特异性是 (0,2,1),只写 `html[data-theme-style]`(0,1,1)的话,
|
||||
独立配置的页面在手机上根本不生效(旧版本就是这个 bug)。
|
||||
|
||||
⚠️ 竖版图只替换**本来就有背景图**的页面。某页故意配成纯色(`--tb-page-image: none`),
|
||||
手机上新塞一张竖图等于改了他的设计。
|
||||
|
||||
**整页背景 · 焦点** —— 背景图是 `cover`,宽高比不匹配就必裁。九宫格焦点决定裁哪一边:
|
||||
16:9 场景在竖屏上只剩中间一条,人物偏一侧时把焦点挪过去才不会裁到脸。
|
||||
生成 `--tb-page-position`(`base.css` 里以 `var(--tb-page-position, center center)` 兜底,
|
||||
旧主题不写这个令牌也不会坏)。
|
||||
|
||||
**图片池** —— 上传任意张,浏览器本地转 WebP。导出时只打包实际用到的图。
|
||||
每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
|
||||
|
||||
### 图片处理
|
||||
|
||||
@@ -107,6 +158,29 @@ http://127.0.0.1:8777/dev/theme-builder/
|
||||
- **容差别贪**:背景常见 253,白袜子 237,只差 16。容差 16 保得住袜子,30 就吃穿了。
|
||||
实测白底立绘去白底后透明占比 46.3%,与手写 floodfill 抠出来的 46.6% 差 0.3%。
|
||||
|
||||
## 导出画质
|
||||
|
||||
WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只显示 400px 高的立绘,
|
||||
2 倍屏也只要 800px,上传 3000px 的图纯属白扛。所以顺序是:先砍长边,再降质量。
|
||||
|
||||
| 设置 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
|
||||
| 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 |
|
||||
|
||||
面板底部实时显示 `3 张,共 19KB;最大一张 bg.webp 7KB(1920×1080)`。
|
||||
改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
|
||||
来回试不会叠加压缩。
|
||||
|
||||
⚠️ 两个反直觉的实测结论(都在 `?dump=quality` 里做了断言):
|
||||
|
||||
- **`quality = 1` 不能用**。Skia 在这个值会切到 WebP 无损模式,输出反常:同一张噪声图
|
||||
q=1 只有 52KB,q=0.95 是 440KB。滑块上限卡 0.95,`canvasAsset` 里也夹一道。
|
||||
- **平涂/线稿降质量可能变大**。有损编码对纯色会引入噪点,噪声图 q 从 0.6 到 0.95 体积涨 75%,
|
||||
而平涂图怎么调都是 1KB —— 不能承诺「降 q 一定更小」。这类素材优先调长边。
|
||||
|
||||
缩小走 `imageSmoothingQuality = 'high'`,一次缩太多也不会糊成一团。
|
||||
|
||||
## 工程文件与草稿
|
||||
|
||||
调主题是连续几十次的活,所以配置必须留得住:
|
||||
@@ -133,6 +207,9 @@ INSTALL-<slug>.txt
|
||||
装饰是否超过 10 层、生成出来的 CSS 每处装饰是不是真的 10 个值),有问题直接列出来,
|
||||
不会让你导出后再去等 `go test`。
|
||||
|
||||
导出结果会报「几个文件 / 总共多大 / 其中几张图共多大」,并附上「哪些层在哪些视口下越界」——
|
||||
看到 `其中 4 张图共 3.2MB` 就该回去调长边上限了。
|
||||
|
||||
解压到项目根,然后按 `INSTALL-<slug>.txt` 做三步:
|
||||
|
||||
1. `conf/app.ini` 的 `[theme] available` 加上 slug;
|
||||
@@ -164,6 +241,8 @@ INSTALL-<slug>.txt
|
||||
?dump=fx 跑处理算法自检(13 个断言,输出 JSON)
|
||||
?dump=fxpipe 跑弹窗链路冒烟(打开→改参→预览→落盘→无改动不重编码)
|
||||
?dump=layers 图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
|
||||
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
|
||||
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
|
||||
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
|
||||
?loadurl=/static/images/x.webp&fx=x
|
||||
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
|
||||
|
||||
+646
-78
@@ -225,11 +225,16 @@
|
||||
<section>
|
||||
<h2>图片池 <span class="r"><button id="add-asset">上传</button></span></h2>
|
||||
<div class="assets" id="assets"></div>
|
||||
<p class="hint">透明底 PNG / WebP 最好。上传后自动转 WebP,画布尺寸即自然尺寸。</p>
|
||||
<p class="hint">透明底 PNG / WebP 最好。上传后按下面的画质设置转 WebP。</p>
|
||||
<input type="file" id="file" accept="image/*" multiple hidden>
|
||||
<input type="file" id="projfile" accept=".json,application/json" hidden>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>导出画质</h2>
|
||||
<div id="export-editor"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>配色</h2>
|
||||
<div class="pal" id="palette"></div>
|
||||
@@ -298,7 +303,25 @@ const SCOPES = [
|
||||
{k:'vote', label:'投票页', desc:'/vote/{token}', page:'vote'},
|
||||
];
|
||||
const SLOT = 10; // 装饰层槽位数,必须与 internal/theme.DecoSlots 一致
|
||||
const VIEWPORTS = [1512, 1280, 1024, 820];
|
||||
/* 视口预设带高度:桌面主题几乎全部按「宽」调,但装饰层的裁切是「高」决定的
|
||||
—— 同一个 400px 立绘在 900 高的笔记本上占 44%,在 768 的旧笔电上占 52%,贴底时就开始吃人物。
|
||||
列表里放一档真机竖屏尺寸,才能当场看见「手机上被裁成一条」。 */
|
||||
const VIEWPORTS = [
|
||||
{ w: 1512, h: 900, label: '1512×900 桌机' },
|
||||
{ w: 1280, h: 800, label: '1280×800 笔记本' },
|
||||
{ w: 1024, h: 768, label: '1024×768 小笔电' },
|
||||
{ w: 820, h: 1180, label: '820×1180 平板竖' },
|
||||
{ w: 390, h: 844, label: '390×844 手机' },
|
||||
];
|
||||
const vpOf = w => VIEWPORTS.find(v => v.w === w) || VIEWPORTS[0];
|
||||
|
||||
/* 自适应尺寸的设计基准视口高:`fit` 单位把「当前 h」当成 900 高下的效果,
|
||||
视口更高就封顶、更矮就等比缩小(下限 55%),一次解决「小屏被裁、大屏太小」。 */
|
||||
const DESIGN_H = 900;
|
||||
/* `fitw` 按宽度自适应的基准视口宽(桌机档)。宽幅素材(横幅、整屏插画)只能按宽缩:
|
||||
按高度缩的话宽度会跟着比例涨,1280 的笔记本上照样裁两侧。 */
|
||||
const DESIGN_W = 1512;
|
||||
const MOB_SCALE_DEFAULT = 0.62;
|
||||
|
||||
/* 默认配色/窄屏抽出来,「新建」时能原样还原;S 里存的是副本,不会被重置污染 */
|
||||
const D = {
|
||||
@@ -306,10 +329,13 @@ const D = {
|
||||
bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)',
|
||||
text:'#222d3b', primary:'#4a7fc1', border:'#d6e0ec',
|
||||
},
|
||||
/* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理,
|
||||
固定 px 的人物也会被卡片盖住。所以窄屏要么换竖版图,要么直接退场。
|
||||
这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。 */
|
||||
narrow: { enabled: true, bg: null, hideDeco: true, maxWidth: 900 },
|
||||
/* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理。
|
||||
这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。
|
||||
scale 是「每层策略选缩放」时的系数;hideDeco 是「一次全藏」的快捷开关。 */
|
||||
narrow: { enabled: true, bg: null, hideDeco: false, maxWidth: 900, scale: MOB_SCALE_DEFAULT },
|
||||
/* 导出画质:webp 体积 ≈ 像素数 × 质量。压体积最有效的不是 q,是先砍像素
|
||||
—— CSS 里显示 400px 高的图,2 倍屏只需要 800px,上传 3000px 的立绘纯属白扛。 */
|
||||
export: { quality: 0.88, maxSide: 1600 },
|
||||
dark: {
|
||||
bg:'#141a22', surface:'#1d2530', navbar:'#141a22',
|
||||
text:'#e3eaf4', primary:'#6fa3e0', border:'#334052',
|
||||
@@ -327,6 +353,7 @@ const S = {
|
||||
scopes: {},
|
||||
palette: Object.assign({}, D.palette),
|
||||
narrow: Object.assign({}, D.narrow),
|
||||
export: Object.assign({}, D.export),
|
||||
dark: Object.assign({}, D.dark),
|
||||
};
|
||||
|
||||
@@ -336,7 +363,8 @@ const PALETTE_KEYS = [
|
||||
];
|
||||
|
||||
function blankScope(k){
|
||||
return { inherit: k !== 'global', bg: null, pattern: null, patternSize: '320px 320px', layers: [] };
|
||||
return { inherit: k !== 'global', bg: null, pattern: null, patternSize: '320px 320px',
|
||||
bgPos: 'center center', layers: [] };
|
||||
}
|
||||
SCOPES.forEach(s => { S.scopes[s.k] = blankScope(s.k); });
|
||||
|
||||
@@ -371,17 +399,121 @@ function cfgOf(k){
|
||||
|
||||
/* ---------------------- 位置 / 尺寸 → CSS ---------------------- */
|
||||
/* background-position 的百分比是相对 (容器 - 图片),不是相对容器。
|
||||
统一写成 calc(0%|50%|100% + Npx) 两值形式:0% = 左/上对齐,50% = 居中,100% = 右/下对齐。
|
||||
统一写成 calc(0%|50%|100% ± N) 两值形式:0% = 左/上对齐,50% = 居中,100% = 右/下对齐。
|
||||
不用 left/top 关键字,是因为「水平居中 + 垂直贴底」会变成三值/四值语法,
|
||||
一旦不合规范整条声明作废,图层会静默掉到左上角。 */
|
||||
function axisPos(anchor, off){
|
||||
const unitOf = l => (l.unit === 'fit' || l.unit === 'vh') ? 'vh'
|
||||
: (l.unit === 'fitw') ? 'vw' : 'px';
|
||||
|
||||
function axisPos(anchor, off, unit){
|
||||
const pct = anchor === 'center' ? '50%' : (anchor === 'start' ? '0%' : '100%');
|
||||
const n = Math.round(off);
|
||||
// 自适应/视口单位下偏移也跟着缩:贴底 36px 在 844 高的手机上应该是 34px 左右,
|
||||
// 写死 px 会让小屏的留白看起来突然大了一截
|
||||
const base = unit === 'vw' ? DESIGN_W : DESIGN_H;
|
||||
const vunit = unit === 'vw' ? 'vw' : 'vh';
|
||||
const mag = (unit === 'vh' || unit === 'vw')
|
||||
? (+(Math.abs(off) / base * 100).toFixed(2)) + vunit
|
||||
: Math.abs(Math.round(off)) + 'px';
|
||||
if (!off) return `calc(${pct} + 0px)`;
|
||||
// 写成 calc(100% - 36px) 而不是 calc(100% + -36px):后者虽然合法,
|
||||
// 但用 calc 手算时容易被误读成「加负数」,见过的样式事故都出在这种地方。
|
||||
return n < 0 ? `calc(${pct} - ${-n}px)` : `calc(${pct} + ${n}px)`;
|
||||
return off < 0 ? `calc(${pct} - ${mag})` : `calc(${pct} + ${mag})`;
|
||||
}
|
||||
/** 横向偏移在 fitw 下按 vw 缩,其余情况跟纵向一起按 vh 缩。 */
|
||||
const POS = l => {
|
||||
const u = unitOf(l);
|
||||
return `${axisPos(l.ah, l.dx, u === 'vw' ? 'vw' : u)} `
|
||||
+ `${axisPos(l.av, l.dy, u === 'vw' ? 'vh' : u)}`;
|
||||
};
|
||||
|
||||
/** 尺寸单位 → 某视口下应有的像素宽高。
|
||||
layerSize 出 CSS、layerMetrics 出预览框,两者都从这里取值:
|
||||
各算一套迟早会漂,表现就是「框显示没超、图已经被裁」。
|
||||
下限取「基准的 55%」与「视口 - 24px」中更小的那个 —— 屏幕小到连 55% 都放不下时,
|
||||
让位给视口,这样自适应单位永远不会把图撑出屏幕。 */
|
||||
const FIT_PAD = 24;
|
||||
function unitGeom(l, ratio, VW, VH){
|
||||
const h0 = Math.max(1, Math.round(l.h) || 200);
|
||||
const w0 = l.w > 0 ? Math.round(l.w) : Math.round(h0 * ratio);
|
||||
if (l.unit === 'vh'){
|
||||
const h = h0 / DESIGN_H * VH;
|
||||
return { w: h * ratio, h };
|
||||
}
|
||||
if (l.unit === 'fit'){
|
||||
const lo = Math.max(40, Math.min(h0 * 0.55, VH - FIT_PAD));
|
||||
const h = Math.min(h0, Math.max(lo, h0 / DESIGN_H * VH));
|
||||
return { w: h * ratio, h };
|
||||
}
|
||||
if (l.unit === 'fitw'){
|
||||
const lo = Math.max(40, Math.min(w0 * 0.55, VW - FIT_PAD));
|
||||
const w = Math.min(w0, Math.max(lo, w0 / DESIGN_W * VW));
|
||||
return { w, h: w / ratio };
|
||||
}
|
||||
return { w: w0, h: h0 };
|
||||
}
|
||||
|
||||
/** 尺寸 → background-size。
|
||||
只定一个维度,另一个交给 `auto` 让浏览器按图片自身比例算:少一个可以填错的数,
|
||||
也保证缩放时绝不发生横向拉伸。`px` 模式保留手填宽度(宽高比可以故意改)。 */
|
||||
function layerSize(l, a){
|
||||
const ratio = (a && a.h) ? (a.w / a.h) : 1;
|
||||
const h0 = Math.max(1, Math.round(l.h) || 200);
|
||||
const w0 = l.w > 0 ? Math.round(l.w) : Math.round(h0 * ratio);
|
||||
if (l.unit === 'vh') return `auto ${+(h0 / DESIGN_H * 100).toFixed(2)}vh`;
|
||||
if (l.unit === 'fit'){
|
||||
const lo = Math.round(h0 * 0.55), hi = h0;
|
||||
// clamp 的 min 若 ≥ max,浏览器按 min 处理 —— 图反而比设定值还大,所以先兜住
|
||||
if (lo >= hi) return `auto ${hi}px`;
|
||||
// 下限再和「视口高 - 24px」取小值:小屏上 55% 仍放不下时自动让位
|
||||
return `auto clamp(min(calc(100vh - ${FIT_PAD}px), ${lo}px), `
|
||||
+ `${+(h0 / DESIGN_H * 100).toFixed(2)}vh, ${hi}px)`;
|
||||
}
|
||||
if (l.unit === 'fitw'){
|
||||
const lo = Math.round(w0 * 0.55), hi = w0;
|
||||
if (lo >= hi) return `${hi}px auto`;
|
||||
return `clamp(min(calc(100vw - ${FIT_PAD}px), ${lo}px), `
|
||||
+ `${+(w0 / DESIGN_W * 100).toFixed(2)}vw, ${hi}px) auto`;
|
||||
}
|
||||
return l.w > 0 ? `${w0}px ${h0}px` : `auto ${h0}px`;
|
||||
}
|
||||
|
||||
/** 某一层在给定视口下「实际占多大、落在哪」。拖拽框、溢出检测、导出 CSS 共用。 */
|
||||
function layerMetrics(l, a, VW, VH){
|
||||
const ratio = (a && a.h) ? (a.w / a.h) : 1;
|
||||
const g = unitGeom(l, ratio, VW, VH);
|
||||
const w = g.w, h = g.h;
|
||||
const px = l.ah === 'center' ? 0.5 : (l.ah === 'start' ? 0 : 1);
|
||||
const py = l.av === 'center' ? 0.5 : (l.av === 'start' ? 0 : 1);
|
||||
const u = unitOf(l);
|
||||
// 偏移与尺寸同单位缩放(fitw 横向按 vw、纵向按 vh)
|
||||
const kx = u === 'vw' ? VW / DESIGN_W : (u === 'vh' ? VH / DESIGN_H : 1);
|
||||
const ky = u === 'px' ? 1 : VH / DESIGN_H;
|
||||
const left = (VW - w) * px + l.dx * kx;
|
||||
const top = (VH - h) * py + l.dy * ky;
|
||||
return {
|
||||
w, h, left, top,
|
||||
overL: Math.max(0, -left),
|
||||
overR: Math.max(0, left + w - VW),
|
||||
overT: Math.max(0, -top),
|
||||
overB: Math.max(0, top + h - VH),
|
||||
};
|
||||
}
|
||||
|
||||
/** 一键按当前视口修好:改成固定 px,取「宽不超 96%、高不超 92%」的那个尺寸。
|
||||
fit / fitw 只能按单一维度缩,遇到极端比例仍会裁;给一个算好的确定值,
|
||||
比让人自己试 clamp 快得多。窄屏交给「窄屏」策略,不要指望一个 px 值通吃。 */
|
||||
function fitToViewport(l, a, vp){
|
||||
const ratio = a.w / a.h;
|
||||
const h = Math.max(20, Math.min(Math.round(l.h) || 200,
|
||||
Math.floor(vp.h * 0.92), Math.floor(vp.w * 0.96 / ratio)));
|
||||
l.unit = 'px';
|
||||
l.h = h;
|
||||
if (l.w > 0) l.w = Math.round(h * ratio);
|
||||
if (l.av !== 'center'){ l.av = 'center'; l.dy = 0; }
|
||||
renderAll();
|
||||
applyTheme();
|
||||
log(`已按 ${vp.w}×${vp.h} 调整为 ${Math.round(h * ratio)}×${h}px(固定 px,更窄的屏请用「窄屏」策略)`, 'ok');
|
||||
}
|
||||
const POS = l => `${axisPos(l.ah, l.dx)} ${axisPos(l.av, l.dy)}`;
|
||||
|
||||
function rgba(hex, a){
|
||||
const h = hex.replace('#','');
|
||||
@@ -395,9 +527,7 @@ function layerCSS(l, preview){
|
||||
const a = assetById(l.asset);
|
||||
if (!a) return {image:'none', size:'auto', position:'left top'};
|
||||
const url = preview ? a.dataURL : `/static/images/themes/${S.slug}/${a.id}.webp`;
|
||||
const h = Math.round(l.h) || 200;
|
||||
const w = l.w > 0 ? Math.round(l.w) : Math.round(a.w * h / a.h);
|
||||
return { image:`url("${url}")`, size:`${w}px ${h}px`, position: POS(l) };
|
||||
return { image:`url("${url}")`, size: layerSize(l, a), position: POS(l) };
|
||||
}
|
||||
if (l.kind === 'radial'){
|
||||
const c = rgba(l.color, l.alpha);
|
||||
@@ -418,6 +548,8 @@ function newLayer(kind){
|
||||
id: uid('L'), kind,
|
||||
ah:'center', av:'end', dx:0, dy:-40,
|
||||
h:360, w:0, asset:null,
|
||||
// unit 默认 fit:固定 px 的立绘在小屏必被裁,默认值就该是「不容易出错」的那个
|
||||
unit:'fit', mob:'inherit',
|
||||
color:'#fff8e4', alpha:0.55, r:320, cx:50, cy:50,
|
||||
angle:112, stop:44,
|
||||
};
|
||||
@@ -435,14 +567,38 @@ function bgVars(cfg, preview){
|
||||
` --tb-page-image: ${img ? `url("${urlOf(img, preview)}")` : 'none'};`,
|
||||
` --tb-page-pattern: ${pat ? `url("${urlOf(pat, preview)}")` : 'none'};`,
|
||||
` --tb-page-pattern-size: ${cfg.patternSize || '320px 320px'};`,
|
||||
// 背景图是 cover:宽高比不匹配就必裁。焦点决定「裁掉哪一边」——
|
||||
// 16:9 场景在竖屏上只能留中间一条,把焦点挪到人物那一侧才不至于裁到脸。
|
||||
` --tb-page-position: ${cfg.bgPos || 'center center'};`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function decoVars(cfg, preview){
|
||||
/** 窄屏下某一层的三个值(图片 / 尺寸 / 位置)。
|
||||
策略来自图层自己的 mob,全局 hideDeco 高于一切(那是「一次全藏」的急停开关)。 */
|
||||
function decoMobile(l){
|
||||
if (!l) return null;
|
||||
if (S.narrow.hideDeco) return null;
|
||||
const mob = l.mob || 'inherit';
|
||||
if (mob === 'hide') return null;
|
||||
if (mob === 'scale' && l.kind === 'image'){
|
||||
const s = clamp(Number(S.narrow.scale) || MOB_SCALE_DEFAULT, 0.2, 1);
|
||||
return Object.assign({}, l, {
|
||||
h: Math.max(1, Math.round((Math.round(l.h) || 200) * s)),
|
||||
w: l.w > 0 ? Math.round(l.w * s) : 0,
|
||||
dx: Math.round(l.dx * s),
|
||||
dy: Math.round(l.dy * s),
|
||||
});
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
function decoVars(cfg, preview, mobile){
|
||||
const layers = cfg.layers.slice(0, SLOT);
|
||||
const imgs = [], sizes = [], poss = [];
|
||||
for (let i = 0; i < SLOT; i++){
|
||||
const l = layers[i];
|
||||
const raw = layers[i];
|
||||
const l = mobile ? decoMobile(raw) : raw;
|
||||
// 隐藏的层必须把 image 一起写成 none:只清 size 会留一张按 auto 尺寸铺开的原图
|
||||
if (!l){ imgs.push('none'); sizes.push('auto'); poss.push('left top'); continue; }
|
||||
const c = layerCSS(l, preview);
|
||||
imgs.push(c.image); sizes.push(c.size); poss.push(c.position);
|
||||
@@ -548,19 +704,34 @@ function buildCSS(preview){
|
||||
|
||||
// 窄屏段:只写浅色规则。深色块特异性更高(0,2,1 > 0,1,1),
|
||||
// 本来就压住这里,不需要再为深色写一份 —— 少一层嵌套少一处出错。
|
||||
// 注意 `fit` / `vh` 单位本身就是视口相对的,浏览器会自己缩,不需要这里插手;
|
||||
// 这一段只负责「换竖版图」「某层要缩小或退场」这类必须改值的事。
|
||||
const nb = S.narrow.bg ? assetById(S.narrow.bg) : null;
|
||||
if (S.narrow.enabled && (nb || S.narrow.hideDeco)){
|
||||
const hasMob = SCOPES.some(sc => {
|
||||
const c = S.scopes[sc.k];
|
||||
if (sc.k !== 'global' && c.inherit) return false;
|
||||
return (c.layers || []).some(l => (l.mob || 'inherit') !== 'inherit');
|
||||
});
|
||||
if (S.narrow.enabled && (nb || S.narrow.hideDeco || hasMob)){
|
||||
L.push('');
|
||||
L.push('/* 窄屏:卡片占满宽度,两侧没有留白,桌面那套背景与装饰在这里只会被裁/被盖 */');
|
||||
L.push('/* 窄屏:卡片几乎占满宽度,两侧没有留白,桌面那套背景与装饰在这里只会被裁/被盖。');
|
||||
L.push(' 每个独立配置的页面都要写一条 —— 页面覆盖块特异性更高(0,2,1),');
|
||||
L.push(' 只写 html[data-theme-style] 的话,独立配置的页面在手机上根本不生效。 */');
|
||||
L.push(`@media (max-width: ${S.narrow.maxWidth || 900}px) {`);
|
||||
L.push(` html[data-theme-style="${slug}"] {`);
|
||||
if (nb) L.push(` --tb-page-image: url("${urlOf(nb, preview)}");`);
|
||||
if (S.narrow.hideDeco){
|
||||
L.push(' --tb-deco: ' + Array(SLOT).fill('none').join(', ') + ';');
|
||||
L.push(' --tb-deco-size: ' + Array(SLOT).fill('auto').join(', ') + ';');
|
||||
L.push(' --tb-deco-position: ' + Array(SLOT).fill('left top').join(', ') + ';');
|
||||
for (const sc of SCOPES){
|
||||
const cfg = S.scopes[sc.k];
|
||||
if (sc.k !== 'global' && cfg.inherit) continue;
|
||||
const sel = sc.k === 'global'
|
||||
? `html[data-theme-style="${slug}"]`
|
||||
: `html[data-theme-style="${slug}"][data-page-scope="${sc.k}"]`;
|
||||
L.push(` ${sel} {`);
|
||||
// 只替换「本来就有背景图」的页面:某页故意配成纯色(--tb-page-image: none),
|
||||
// 手机上新塞一张竖图就是改了他的设计
|
||||
if (nb && cfg.bg) L.push(` --tb-page-image: url("${urlOf(nb, preview)}");`);
|
||||
if (cfg.bg) L.push(` --tb-page-position: ${nb ? 'center center' : (cfg.bgPos || 'center center')};`);
|
||||
decoVars(cfg, preview, true).split('\n').forEach(line => L.push(' ' + line));
|
||||
L.push(' }');
|
||||
}
|
||||
L.push(' }');
|
||||
L.push('}');
|
||||
}
|
||||
return L.join('\n') + '\n';
|
||||
@@ -628,18 +799,12 @@ function applyTheme(){
|
||||
function guideGeom(l){
|
||||
const a = assetById(l.asset);
|
||||
if (!a) return null;
|
||||
const h = Math.round(l.h) || 200;
|
||||
const w = l.w > 0 ? Math.round(l.w) : Math.round(a.w * h / a.h);
|
||||
const vw = doc.documentElement.clientWidth;
|
||||
const vh = doc.documentElement.clientHeight;
|
||||
const px = l.ah === 'center' ? 0.5 : (l.ah === 'start' ? 0 : 1);
|
||||
const py = l.av === 'center' ? 0.5 : (l.av === 'start' ? 0 : 1);
|
||||
// 与 background-position 完全一致的换算
|
||||
return {
|
||||
w, h,
|
||||
left: Math.round((vw - w) * px + l.dx),
|
||||
top: Math.round((vh - h) * py + l.dy),
|
||||
};
|
||||
// 与 background-size/position 走同一套换算式(layerMetrics),
|
||||
// 否则 fit/vh 单位下框和实际图会错位
|
||||
const m = layerMetrics(l, a, vw, vh);
|
||||
return { w: m.w, h: m.h, left: Math.round(m.left), top: Math.round(m.top) };
|
||||
}
|
||||
|
||||
function syncOverlay(){
|
||||
@@ -685,10 +850,16 @@ function attachDrag(el, l){
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
try { el.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
const sx = e.clientX, sy = e.clientY, dx0 = l.dx, dy0 = l.dy;
|
||||
// 视口单位下 CSS 会把偏移按视口比缩放,鼠标的 1px 不等于 dx 的 1 —— 不除回来手感会发飘。
|
||||
// fitw 横向按 vw、纵向仍按 vh,与 POS/axisPos 的规则一致。
|
||||
const u0 = unitOf(l);
|
||||
const kvx = u0 === 'vw' ? (doc.documentElement.clientWidth || DESIGN_W) / DESIGN_W
|
||||
: u0 === 'vh' ? (doc.documentElement.clientHeight || DESIGN_H) / DESIGN_H : 1;
|
||||
const kvy = u0 === 'px' ? 1 : (doc.documentElement.clientHeight || DESIGN_H) / DESIGN_H;
|
||||
|
||||
const move = ev => {
|
||||
l.dx = Math.round(dx0 + (ev.clientX - sx));
|
||||
l.dy = Math.round(dy0 + (ev.clientY - sy));
|
||||
l.dx = Math.round(dx0 + (ev.clientX - sx) / kvx);
|
||||
l.dy = Math.round(dy0 + (ev.clientY - sy) / kvy);
|
||||
// 快速路径:只改 CSS + 蒙层位置,不重建 DOM(否则指针捕获会被打断)
|
||||
const st = doc.getElementById('__tb_theme');
|
||||
if (st) st.textContent = buildCSS(true);
|
||||
@@ -713,12 +884,15 @@ function fitStage(){
|
||||
const canvas = $('canvas');
|
||||
const frame = $('frame');
|
||||
const ifr = $('preview');
|
||||
const k = Math.min(1, (canvas.clientWidth - 28) / S.viewport, (canvas.clientHeight - 28) / 900);
|
||||
ifr.style.width = S.viewport + 'px';
|
||||
ifr.style.height = '900px';
|
||||
const vp = vpOf(S.viewport);
|
||||
// 高度必须跟着预设走:裁切几乎全由「视口高」决定,
|
||||
// 一律按 900 渲染的话,768 的旧笔电和 844 的手机根本模拟不出来
|
||||
const k = Math.min(1, (canvas.clientWidth - 28) / vp.w, (canvas.clientHeight - 28) / vp.h);
|
||||
ifr.style.width = vp.w + 'px';
|
||||
ifr.style.height = vp.h + 'px';
|
||||
ifr.style.transform = `scale(${k})`;
|
||||
frame.style.width = Math.round(S.viewport * k) + 'px';
|
||||
frame.style.height = Math.round(900 * k) + 'px';
|
||||
frame.style.width = Math.round(vp.w * k) + 'px';
|
||||
frame.style.height = Math.round(vp.h * k) + 'px';
|
||||
}
|
||||
|
||||
/* ---------------------------- 面板渲染 ---------------------------- */
|
||||
@@ -782,6 +956,31 @@ function renderBgEditor(){
|
||||
mkPick('背景图', 'bg');
|
||||
mkPick('平铺图案', 'pattern');
|
||||
|
||||
// 背景图是 cover 铺满:宽高比不匹配就必裁,焦点决定裁掉哪一边。
|
||||
// 16:9 场景在竖屏上只剩中间一条,人物偏一侧时把焦点挪过去才不会裁到脸。
|
||||
const frow = document.createElement('div');
|
||||
frow.className = 'row';
|
||||
const fs = document.createElement('span');
|
||||
fs.textContent = '焦点';
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'grid9';
|
||||
['top','center','bottom'].forEach(v => {
|
||||
['left','center','right'].forEach(h => {
|
||||
const val = h + ' ' + v;
|
||||
const b = document.createElement('button');
|
||||
b.title = val;
|
||||
b.className = (cfg.bgPos || 'center center') === val ? 'on' : '';
|
||||
b.onclick = () => {
|
||||
ensureOwn(true);
|
||||
S.scopes[S.scope].bgPos = val;
|
||||
renderAll();
|
||||
};
|
||||
grid.appendChild(b);
|
||||
});
|
||||
});
|
||||
frow.append(fs, grid);
|
||||
el.appendChild(frow);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
const s = document.createElement('span');
|
||||
@@ -981,10 +1180,66 @@ function renderLayerEditor(l){
|
||||
|
||||
if (l.kind === 'image'){
|
||||
// 换图的下拉在图层行里,这里只放尺寸,避免同一件事有两个入口
|
||||
const sel = (opts, val, on, title) => {
|
||||
const s = document.createElement('select');
|
||||
opts.forEach(([k, t]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = k; o.textContent = t;
|
||||
s.appendChild(o);
|
||||
});
|
||||
s.value = val;
|
||||
s.onchange = () => on(s.value);
|
||||
if (title) s.title = title;
|
||||
return s;
|
||||
};
|
||||
|
||||
// 尺寸单位 = 「屏幕变矮/变窄时这张图怎么办」。固定 px 是绝大多数「被裁」的根源。
|
||||
// 下拉的 value 必须用原始 l.unit('fit'),不能过 unitOf —— 那会把 fit 归一成 vh,
|
||||
// 于是选了「按视口高比例」却拿到 clamp 的输出,看起来像开关失灵。
|
||||
row('尺寸单位', sel([['fit','自适应·按高度'],['fitw','自适应·按宽度'],['px','固定 px'],['vh','按视口高比例']],
|
||||
l.unit || 'px', v => { l.unit = v; renderAll(); applyTheme(); },
|
||||
'按高度:人物/竖幅用,屏幕更矮就等比缩(最低 55%);' +
|
||||
'按宽度:横幅/整屏插画用,屏幕更窄就等比缩(按高度缩的话宽度会跟着涨,窄屏照样裁);' +
|
||||
'按视口高比例:永远占同样比例的屏高,无下限'));
|
||||
row('高度 px', numInput(l.h, 20, 4000, v => { l.h = v; renderAll(); }));
|
||||
const wIn = numInput(l.w, 0, 4000, v => { l.w = v; renderAll(); });
|
||||
wIn.title = '0 表示按图片原始比例自动算宽度';
|
||||
row('宽度 0=自动', wIn);
|
||||
if (unitOf(l) === 'px'){
|
||||
const wIn = numInput(l.w, 0, 4000, v => { l.w = v; renderAll(); });
|
||||
wIn.title = '0 表示按图片原始比例自动算宽度';
|
||||
row('宽度 0=自动', wIn);
|
||||
}
|
||||
|
||||
// 窄屏策略:手机上「全藏」太粗暴,缩小往往就够用
|
||||
row('窄屏', sel([['inherit','原样'],['scale',`缩小到 ${Math.round((S.narrow.scale||MOB_SCALE_DEFAULT)*100)}%`],['hide','隐藏']],
|
||||
l.mob || 'inherit', v => { l.mob = v; renderAll(); },
|
||||
'只在 @media 断点以下生效;缩小是把高度与偏移一起乘系数,位置锚点不变'));
|
||||
|
||||
const a0 = assetById(l.asset);
|
||||
if (a0){
|
||||
const vp = vpOf(S.viewport);
|
||||
const m = layerMetrics(l, a0, vp.w, vp.h);
|
||||
const over = [];
|
||||
if (m.overL > 0.5) over.push(`左 ${Math.round(m.overL)}px`);
|
||||
if (m.overR > 0.5) over.push(`右 ${Math.round(m.overR)}px`);
|
||||
if (m.overT > 0.5) over.push(`上 ${Math.round(m.overT)}px`);
|
||||
if (m.overB > 0.5) over.push(`下 ${Math.round(m.overB)}px`);
|
||||
const tip = document.createElement('p');
|
||||
tip.className = 'hint';
|
||||
if (over.length){
|
||||
tip.style.color = '#e0a33e';
|
||||
tip.textContent = `⚠ ${vp.w}×${vp.h}:${over.join(' / ')} 越界(显示 ${m.w}×${m.h})`;
|
||||
} else {
|
||||
tip.textContent = `${vp.w}×${vp.h} 下显示 ${m.w}×${m.h},完整可见(源 ${a0.w}×${a0.h})`;
|
||||
}
|
||||
box.appendChild(tip);
|
||||
if (over.length){
|
||||
const fix = document.createElement('button');
|
||||
fix.textContent = `按 ${vp.w}×${vp.h} 修好`;
|
||||
fix.style.cssText = 'width:100%;margin-top:5px;font-size:11px';
|
||||
fix.title = '改成固定 px,并取「宽 ≤96%、高 ≤92% 视口」的尺寸;更窄的屏请配合「窄屏」策略';
|
||||
fix.onclick = () => fitToViewport(l, a0, vp);
|
||||
box.appendChild(fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const g = document.createElement('div');
|
||||
@@ -1119,6 +1374,21 @@ function renderNarrowEditor(){
|
||||
row.append(sp, mw);
|
||||
el.appendChild(row);
|
||||
|
||||
const rowS = document.createElement('div');
|
||||
rowS.className = 'row';
|
||||
const spS = document.createElement('span');
|
||||
spS.textContent = '缩放系数';
|
||||
const sc = document.createElement('input');
|
||||
sc.type = 'number'; sc.min = 20; sc.max = 100; sc.step = 5;
|
||||
sc.value = Math.round((n.scale || MOB_SCALE_DEFAULT) * 100);
|
||||
sc.title = '图层「窄屏 = 缩小」时乘的系数。0.6 左右通常就够,太小会让装饰变成噪点';
|
||||
sc.onchange = () => {
|
||||
n.scale = clamp((Number(sc.value) || 62) / 100, 0.2, 1);
|
||||
renderAll();
|
||||
};
|
||||
rowS.append(spS, sc);
|
||||
el.appendChild(rowS);
|
||||
|
||||
const row2 = document.createElement('div');
|
||||
row2.className = 'row';
|
||||
const sp2 = document.createElement('span');
|
||||
@@ -1131,15 +1401,104 @@ function renderNarrowEditor(){
|
||||
row2.append(sp2, sel);
|
||||
el.appendChild(row2);
|
||||
|
||||
el.appendChild(mk('隐藏全部装饰层', n.hideDeco, v => { n.hideDeco = v; renderAll(); }));
|
||||
el.appendChild(mk('隐藏全部装饰层(急停)', n.hideDeco, v => { n.hideDeco = v; renderAll(); }));
|
||||
|
||||
const tip = document.createElement('p');
|
||||
tip.className = 'hint';
|
||||
tip.textContent = '手机上卡片几乎占满屏幕:16:9 的桌面场景会被裁成一条纹理,固定 px 的人物会被卡片盖住。' +
|
||||
'要么换一张竖版图,要么让装饰退场。只写浅色规则——深色块特异性更高,本来就压住它。';
|
||||
tip.textContent = '手机断点下卡片几乎占满宽度:16:9 的桌面场景会被裁成一条纹理。'
|
||||
+ '三层优先级从高到低:这个「全部隐藏」→ 每层的窄屏策略(原样 / 缩小 / 隐藏)→ 图层的尺寸单位。'
|
||||
+ '尺寸单位选「自适应 / 视口高比例」的层自己不依赖这段规则,浏览器会按视口缩。'
|
||||
+ '只写浅色规则——深色块特异性更高,本来就压住它。';
|
||||
el.appendChild(tip);
|
||||
}
|
||||
|
||||
/* ---------------------- 导出画质 ---------------------- */
|
||||
/* WebP 体积 ≈ 像素数 × 质量的乘积。真正的大头是像素:
|
||||
CSS 里只显示 400px 高的立绘,2 倍屏也只要 800px,上传 3000px 纯属白扛。
|
||||
所以这里给两个旋钮,先砍长边、再降质量,顺序别反。 */
|
||||
function renderExportEditor(){
|
||||
const el = $('export-editor');
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
|
||||
const row = () => {
|
||||
const r = document.createElement('div');
|
||||
r.className = 'row';
|
||||
el.appendChild(r);
|
||||
return r;
|
||||
};
|
||||
const label = (t, w) => {
|
||||
const s = document.createElement('span');
|
||||
s.textContent = t;
|
||||
if (w) s.style.width = w;
|
||||
return s;
|
||||
};
|
||||
|
||||
// 质量滑块:松手(change)才重编码,拖动过程中不反复跑全量像素。
|
||||
// 数值放进 label 里而不是单独一个 flex 项:面板只有 240px,三个 flex 项时
|
||||
// range 的固有宽度会把数值顶出可视区(表现为"滑块没有数字")。
|
||||
const r1 = row();
|
||||
const q = clamp(Number(S.export.quality) || 0.88, 0.5, 0.95);
|
||||
const qlab = document.createElement('span');
|
||||
qlab.innerHTML = '质量 <b>' + q.toFixed(2) + '</b>';
|
||||
const qv = qlab.querySelector('b');
|
||||
qv.style.cssText = 'color:var(--ui-main);font-weight:600';
|
||||
const qr = document.createElement('input');
|
||||
qr.type = 'range'; qr.min = 0.5; qr.max = 0.95; qr.step = 0.01; qr.value = q;
|
||||
// width:0 + flex:1 1 0 是让 range 只吃「剩余空间」的标准写法:
|
||||
// 光给 flex:1 时它的固有宽度(Chrome 约 129px)会撑破 240px 的面板。
|
||||
qr.style.cssText = 'flex:1 1 0;width:0;min-width:0';
|
||||
qr.title = '0.85~0.9 之间肉眼几乎看不出差别;低于 0.8 才会明显糊。上限 0.95 —— WebP 的 q=1 会切无损,体积反而失控';
|
||||
qr.oninput = () => { qv.textContent = (+qr.value).toFixed(2); };
|
||||
qr.onchange = async () => {
|
||||
S.export.quality = clamp(+qr.value, 0.5, 0.95);
|
||||
qv.textContent = S.export.quality.toFixed(2);
|
||||
await applyQuality('质量 ' + S.export.quality.toFixed(2));
|
||||
};
|
||||
r1.append(qlab, qr);
|
||||
|
||||
const r2 = row();
|
||||
const sides = [['0','不限制'],['2048','2048'],['1600','1600'],['1200','1200'],['800','800']];
|
||||
const side = document.createElement('select');
|
||||
side.innerHTML = sides.map(([v, t]) => `<option value="${v}">${t}</option>`).join('');
|
||||
side.value = String(S.export.maxSide || 0);
|
||||
side.title = '长边上限。整页背景图建议 1920/不限制;装饰人物 1200 就够(显示 400px × 2 倍屏)';
|
||||
side.onchange = async () => {
|
||||
S.export.maxSide = parseInt(side.value, 10) || 0;
|
||||
await applyQuality('长边 ≤ ' + (S.export.maxSide || '不限'));
|
||||
};
|
||||
r2.append(label('长边上限', '62px'), side);
|
||||
|
||||
const stat = document.createElement('p');
|
||||
stat.className = 'hint';
|
||||
const total = S.assets.reduce((n, a) => n + (a.kb || 0), 0);
|
||||
if (!S.assets.length){
|
||||
stat.textContent = '还没有图片。上传时会按上面的设置直接编码。';
|
||||
} else {
|
||||
const big = S.assets.slice().sort((a, b) => b.kb - a.kb)[0];
|
||||
stat.textContent = `${S.assets.length} 张,共 ${total >= 1024 ? (total / 1024).toFixed(1) + 'MB' : total + 'KB'}`
|
||||
+ `;最大一张 ${big.id}.webp ${big.kb}KB(${big.w}×${big.h})`;
|
||||
}
|
||||
el.appendChild(stat);
|
||||
|
||||
const tip = document.createElement('p');
|
||||
tip.className = 'hint';
|
||||
tip.textContent = '压体积的顺序:先砍长边,再降质量。改完按「原图 + 处理参数」重新编码全部素材,'
|
||||
+ '不叠加压缩 —— 来回试不会越调越糊。注意平涂/线稿素材降质量可能反而变大(有损会引入噪点),'
|
||||
+ '这类图优先调长边。';
|
||||
el.appendChild(tip);
|
||||
}
|
||||
|
||||
/** 改画质后重编全部素材,并把前后体积说清楚 —— 否则用户不知道有没有生效。 */
|
||||
async function applyQuality(what){
|
||||
if (!S.assets.length){ renderAll(); return; }
|
||||
const { before, after } = await rebakeAll();
|
||||
renderAll();
|
||||
const fmt = kb => kb >= 1024 ? (kb / 1024).toFixed(1) + 'MB' : kb + 'KB';
|
||||
log(`按「${what}」重编 ${S.assets.length} 张:${fmt(before)} → ${fmt(after)}`
|
||||
+ (before > 0 && after < before ? `(省 ${Math.round((1 - after / before) * 100)}%)` : ''), 'ok');
|
||||
}
|
||||
|
||||
function renderPalette(){
|
||||
const wrap = $('palette');
|
||||
wrap.innerHTML = '';
|
||||
@@ -1166,11 +1525,12 @@ function renderPalette(){
|
||||
|
||||
function renderViewportBar(){
|
||||
const vp = $('vps'); vp.innerHTML = '';
|
||||
VIEWPORTS.forEach(w => {
|
||||
VIEWPORTS.forEach(v => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = w;
|
||||
b.className = w === S.viewport ? 'on' : '';
|
||||
b.onclick = () => { S.viewport = w; renderViewportBar(); fitStage(); };
|
||||
b.textContent = v.w;
|
||||
b.title = v.label;
|
||||
b.className = v.w === S.viewport ? 'on' : '';
|
||||
b.onclick = () => { S.viewport = v.w; renderViewportBar(); renderLayers(); fitStage(); };
|
||||
vp.appendChild(b);
|
||||
});
|
||||
const md = $('modes'); md.innerHTML = '';
|
||||
@@ -1189,6 +1549,7 @@ function renderAll(){
|
||||
renderBgEditor();
|
||||
renderLayers();
|
||||
renderAssets();
|
||||
renderExportEditor();
|
||||
renderNarrowEditor();
|
||||
renderPalette();
|
||||
applyTheme();
|
||||
@@ -1203,7 +1564,7 @@ let saveTimer = null, draftWarned = false;
|
||||
|
||||
function toJSON(){
|
||||
return {
|
||||
v: 3,
|
||||
v: 4,
|
||||
savedAt: new Date().toISOString(),
|
||||
slug: S.slug,
|
||||
scope: S.scope,
|
||||
@@ -1212,6 +1573,7 @@ function toJSON(){
|
||||
palette: S.palette,
|
||||
dark: S.dark,
|
||||
narrow: S.narrow,
|
||||
export: S.export,
|
||||
// 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。
|
||||
// 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 ——
|
||||
// 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。
|
||||
@@ -1228,17 +1590,19 @@ function toJSON(){
|
||||
|
||||
function fromJSON(o){
|
||||
// v2 是加图片处理之前的格式(只有 dataURL):把 dataURL 当 orig 用,
|
||||
// fx 全空,用户打开旧工程还能接着改参数。
|
||||
if (!o || typeof o !== 'object' || (o.v !== 2 && o.v !== 3)){
|
||||
throw new Error('不是本工具导出的工程文件(版本 ' + (o && o.v) + ',支持 2 / 3)');
|
||||
// fx 全空,用户打开旧工程还能接着改参数。v4 只多了 export(画质)字段,缺了取默认。
|
||||
if (!o || typeof o !== 'object' || (o.v !== 2 && o.v !== 3 && o.v !== 4)){
|
||||
throw new Error('不是本工具导出的工程文件(版本 ' + (o && o.v) + ',支持 2 / 3 / 4)');
|
||||
}
|
||||
S.slug = o.slug || 'my-theme';
|
||||
S.scope = SCOPES.some(x => x.k === o.scope) ? o.scope : 'global';
|
||||
S.viewport = VIEWPORTS.indexOf(o.viewport) >= 0 ? o.viewport : 1512;
|
||||
S.viewport = VIEWPORTS.some(v => v.w === o.viewport) ? o.viewport : 1512;
|
||||
S.mode = o.mode === 'dark' ? 'dark' : 'light';
|
||||
S.palette = Object.assign({}, S.palette, o.palette || {});
|
||||
S.dark = Object.assign({}, S.dark, o.dark || {});
|
||||
S.narrow = Object.assign({ enabled: true, bg: null, hideDeco: true, maxWidth: 900 }, o.narrow || {});
|
||||
S.narrow = Object.assign({ enabled: true, bg: null, hideDeco: false, maxWidth: 900,
|
||||
scale: MOB_SCALE_DEFAULT }, o.narrow || {});
|
||||
S.export = Object.assign({}, D.export, o.export || {});
|
||||
S.assets = (o.assets || []).map(a => {
|
||||
const orig = a.orig || { dataURL: a.dataURL, w: a.w, h: a.h };
|
||||
const dataURL = a.dataURL || orig.dataURL;
|
||||
@@ -1286,6 +1650,7 @@ function resetAll(){
|
||||
S.assets = [];
|
||||
S.palette = Object.assign({}, D.palette);
|
||||
S.narrow = Object.assign({}, D.narrow);
|
||||
S.export = Object.assign({}, D.export);
|
||||
S.dark = Object.assign({}, D.dark);
|
||||
S.scopes = {};
|
||||
SCOPES.forEach(sc => { S.scopes[sc.k] = blankScope(sc.k); });
|
||||
@@ -1474,9 +1839,28 @@ ${own}
|
||||
窄屏
|
||||
----
|
||||
${S.narrow.enabled
|
||||
? `已导出 @media (max-width: ${S.narrow.maxWidth}px):${S.narrow.bg ? '换成竖版背景图' : '背景沿用桌面'}${S.narrow.hideDeco ? ' + 隐藏全部装饰层' : ''}`
|
||||
? `已导出 @media (max-width: ${S.narrow.maxWidth}px):${S.narrow.bg ? '换成竖版背景图' : '背景沿用桌面'}`
|
||||
+ `${S.narrow.hideDeco ? ' + 隐藏全部装饰层' : ''}`
|
||||
+ `\n 缩放系数 ${Math.round((S.narrow.scale || MOB_SCALE_DEFAULT) * 100)}%(作用于策略为「缩小」的层)`
|
||||
: '未导出窄屏规则(桌面装饰会在手机上被卡片盖住 / 背景被 cover 裁成一条)'}
|
||||
|
||||
尺寸适配
|
||||
--------
|
||||
尺寸单位(图层编辑器里选,本质是「屏幕变小时这张图怎么办」):
|
||||
fit 自适应·按高度 —— 人物/竖幅。按 ${DESIGN_H}px 高视口换算,屏幕更矮等比缩
|
||||
(下限 55%,小到放不下时让位给视口),更高不超过设定值
|
||||
fitw 自适应·按宽度 —— 横幅/场景。按 ${DESIGN_W}px 宽视口换算,同上。
|
||||
宽幅素材必须用它:按高度缩的话宽度会跟着涨,窄屏照样裁两侧
|
||||
vh 按视口高比例 —— 永远占同样比例的屏高,无下限
|
||||
px 固定像素 —— 不随视口变,小屏容易裁
|
||||
尺寸只定一个维度,另一个交给 auto 按图片比例算,缩放时不会拉伸。
|
||||
每一层在编辑器里都会显示「当前视口下实际显示多大 / 是否越界」。
|
||||
|
||||
导出画质
|
||||
--------
|
||||
质量 ${(+S.export.quality).toFixed(2)},长边上限 ${S.export.maxSide || '不限制'}
|
||||
素材共 ${S.assets.reduce((n, a) => n + (a.kb || 0), 0)}KB(${S.assets.length} 张)
|
||||
|
||||
作用域由服务端按请求路径输出 <html data-page-scope="...">,
|
||||
主题 CSS 用 html[data-theme-style="${slug}"][data-page-scope="xxx"] 覆盖。
|
||||
没写覆盖块的作用域自动沿用全局,不会出现「某页装饰消失」。
|
||||
@@ -1506,9 +1890,13 @@ function doExport(){
|
||||
SCOPES.forEach(s => { if (s.k === 'global' || !S.scopes[s.k].inherit) collect(S.scopes[s.k]); });
|
||||
|
||||
const files = [{ name: `static/css/themes/${slug}.css`, data: enc(buildCSS(false)) }];
|
||||
let imgKB = 0, imgN = 0;
|
||||
for (const id of used){
|
||||
const a = assetById(id);
|
||||
if (a) files.push({ name: `static/images/themes/${slug}/${id}.webp`, data: dataURLToBytes(a.dataURL) });
|
||||
if (a){
|
||||
files.push({ name: `static/images/themes/${slug}/${id}.webp`, data: dataURLToBytes(a.dataURL) });
|
||||
imgKB += a.kb || 0; imgN++;
|
||||
}
|
||||
}
|
||||
// 工程文件也塞进包里:下次想改直接「打开工程」,不必从零重搭
|
||||
files.push({ name: `theme-${slug}.json`, data: enc(JSON.stringify(toJSON())) });
|
||||
@@ -1520,8 +1908,35 @@ function doExport(){
|
||||
a.download = `theme-${slug}.zip`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
||||
out(`已导出 theme-${slug}.zip(${files.length} 个文件,${Math.round(blob.size/1024)}KB)。\n` +
|
||||
`解压到项目根,按 INSTALL-${slug}.txt 走三步;包里的 theme-${slug}.json 是工程源文件,下次用「打开工程」接着改。`, 'ok');
|
||||
const fmt = kb => kb >= 1024 ? (kb / 1024).toFixed(2) + 'MB' : kb + 'KB';
|
||||
const over = overflowReport();
|
||||
out(`已导出 theme-${slug}.zip(${files.length} 个文件,${fmt(Math.round(blob.size / 1024))};`
|
||||
+ `其中 ${imgN} 张图共 ${fmt(imgKB)})。\n`
|
||||
+ `解压到项目根,按 INSTALL-${slug}.txt 走三步;包里的 theme-${slug}.json 是工程源文件,下次用「打开工程」接着改。`
|
||||
+ (over.length ? `\n\n⚠ ${over.length} 处素材在部分视口下会被裁(不阻止导出,供你判断):\n`
|
||||
+ over.slice(0, 6).map(s => '· ' + s).join('\n')
|
||||
+ (over.length > 6 ? `\n…… 另有 ${over.length - 6} 处` : '') : ''), over.length ? 'w' : 'ok');
|
||||
}
|
||||
|
||||
/** 全站扫一遍:哪些层在预设视口下会溢出被裁。
|
||||
导出前提醒而不是硬拦 —— 有时故意让插画出血是想要的效果。 */
|
||||
function overflowReport(){
|
||||
const rows = [];
|
||||
for (const sc of SCOPES){
|
||||
const cfg = S.scopes[sc.k];
|
||||
if (sc.k !== 'global' && cfg.inherit) continue;
|
||||
(cfg.layers || []).forEach((l, i) => {
|
||||
if (l.kind !== 'image' || !l.asset) return;
|
||||
const a = assetById(l.asset);
|
||||
if (!a) return;
|
||||
const bad = VIEWPORTS.filter(v => {
|
||||
const m = layerMetrics(l, a, v.w, v.h);
|
||||
return m.overL > 1 || m.overR > 1 || m.overT > 1 || m.overB > 1;
|
||||
});
|
||||
if (bad.length) rows.push(`${sc.label} 第 ${i + 1} 层(${a.id}):${bad.map(v => v.w + '×' + v.h).join('、')} 下超出视口`);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/* ---------------------------- 图片处理 ---------------------------- */
|
||||
@@ -1676,15 +2091,24 @@ function trimCanvas(cv){
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按 fx 出一张 canvas。maxW > 0 时先等比缩到该宽度(预览用,拖动滑杆才跟手)。 */
|
||||
function renderFxCanvas(img, fx, maxW){
|
||||
/** 按 fx 出一张 canvas。maxW > 0 时先等比缩到该宽度(预览用,拖动滑杆才跟手);
|
||||
limit > 0 时长边不超过 limit(导出画质用,压体积最有效的一刀在这里)。 */
|
||||
function renderFxCanvas(img, fx, maxW, limit){
|
||||
fx = normFx(fx);
|
||||
const W0 = img.naturalWidth || img.width, H0 = img.naturalHeight || img.height;
|
||||
const k = maxW && W0 > maxW ? maxW / W0 : 1;
|
||||
let k = maxW && W0 > maxW ? maxW / W0 : 1;
|
||||
if (limit > 0){
|
||||
const k2 = Math.min(limit / W0, limit / H0, 1); // 长边上限:只缩不放
|
||||
if (k2 < k) k = k2;
|
||||
}
|
||||
let cv = document.createElement('canvas');
|
||||
cv.width = Math.max(1, Math.round(W0 * k));
|
||||
cv.height = Math.max(1, Math.round(H0 * k));
|
||||
const g = cv.getContext('2d');
|
||||
// 缩小默认是双线性,一次缩太多会糊成一团;high 会走更好的降采样,
|
||||
// 代价只有一次性耗时(2M 像素级别仍是毫秒级)
|
||||
g.imageSmoothingEnabled = true;
|
||||
g.imageSmoothingQuality = 'high';
|
||||
g.drawImage(img, 0, 0, cv.width, cv.height);
|
||||
// 没有任何处理时直接跳过 getImageData:2M 像素图省下的这一趟够明显
|
||||
if (!fxIsEmpty(fx)){
|
||||
@@ -1708,11 +2132,24 @@ async function decodeAsset(a){
|
||||
return a._img;
|
||||
}
|
||||
|
||||
/** 把处理结果写回 asset:dataURL/bytes/w/h/kb 全部换成处理后的。 */
|
||||
/** 把处理结果写回 asset:dataURL/bytes/w/h/kb 全部换成处理后的。
|
||||
每次都从 orig + fx + 当前画质设置重算,所以「先调 q 0.7 看看,再调回 0.88」
|
||||
不会两代有损叠加 —— 这与 fx 的非破坏设计是同一条原则。 */
|
||||
async function bakeAsset(a){
|
||||
const img = await decodeAsset(a);
|
||||
const cv = renderFxCanvas(img, a.fx);
|
||||
Object.assign(a, canvasAsset(cv, 0.9));
|
||||
const cv = renderFxCanvas(img, a.fx, 0, S.export.maxSide);
|
||||
Object.assign(a, canvasAsset(cv, S.export.quality));
|
||||
}
|
||||
|
||||
/** 画质/长边改了之后重编全部素材。返回前后体积对比,用来给一句「省了多少」。 */
|
||||
async function rebakeAll(){
|
||||
let before = 0, after = 0;
|
||||
for (const a of S.assets){
|
||||
before += a.kb || 0;
|
||||
await bakeAsset(a);
|
||||
after += a.kb || 0;
|
||||
}
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
function loadImage(file){
|
||||
@@ -1734,9 +2171,13 @@ function readAsDataURL(file){
|
||||
});
|
||||
}
|
||||
|
||||
/** 画布尺寸 = 原图尺寸:不缩放、不留透明边,导出后的宽高比才与实际一致。 */
|
||||
/** 画布尺寸 = 处理后的尺寸:不缩放、不留透明边,导出后的宽高比才与实际一致。
|
||||
q 是 WebP 质量,体积与观感的唯一旋钮 —— 0.85~0.9 之间肉眼几乎看不出差别。
|
||||
⚠️ 上限卡在 0.95:Skia 在 quality=1.0 时会切到 WebP 无损模式,实测输出反常
|
||||
(同一张噪声图 q=1 只有 52KB,q=0.95 是 440KB),不能当「最高画质」用。 */
|
||||
function canvasAsset(cv, q){
|
||||
const url = cv.toDataURL('image/webp', q);
|
||||
const qq = clamp(Number(q) || 0.88, 0.3, 0.95);
|
||||
const url = cv.toDataURL('image/webp', qq);
|
||||
const bytes = dataURLToBytes(url);
|
||||
return { dataURL: url, bytes, w: cv.width, h: cv.height, kb: Math.round(bytes.length / 1024) };
|
||||
}
|
||||
@@ -1761,10 +2202,11 @@ async function addFiles(list){
|
||||
fx: fxDefault(),
|
||||
_img: img,
|
||||
};
|
||||
const cv = renderFxCanvas(img, a.fx);
|
||||
Object.assign(a, canvasAsset(cv, 0.9));
|
||||
const cv = renderFxCanvas(img, a.fx, 0, S.export.maxSide);
|
||||
Object.assign(a, canvasAsset(cv, S.export.quality));
|
||||
S.assets.push(a);
|
||||
log(`加入 ${f.name} → ${id}.webp(${a.w}×${a.h}, ${a.kb}KB)`, 'ok');
|
||||
log(`加入 ${f.name} → ${id}.webp(${a.w}×${a.h}, ${a.kb}KB`
|
||||
+ (a.kb > 400 ? ',偏大:把「导出画质」的长边调小一档' : '') + ')', 'ok');
|
||||
}
|
||||
renderAll();
|
||||
}
|
||||
@@ -1792,7 +2234,7 @@ async function addUrlAsset(url, id){
|
||||
orig: { dataURL: origURL, w: img.naturalWidth, h: img.naturalHeight },
|
||||
fx: fxDefault(), _img: img,
|
||||
};
|
||||
Object.assign(a, canvasAsset(renderFxCanvas(img, a.fx), 0.9));
|
||||
Object.assign(a, canvasAsset(renderFxCanvas(img, a.fx, 0, S.export.maxSide), S.export.quality));
|
||||
S.assets.push(a);
|
||||
return a;
|
||||
}
|
||||
@@ -2026,10 +2468,22 @@ function makeDemo(){
|
||||
S.assets.push({ id:'bg', name:'示例背景', orig:{ dataURL:bgA.dataURL, w:bgA.w, h:bgA.h }, fx: fxDefault(), ...bgA });
|
||||
S.assets.push({ id:'char', name:'示例人物', orig:{ dataURL:chA.dataURL, w:chA.w, h:chA.h }, fx: fxDefault(), ...chA });
|
||||
|
||||
// 竖版背景:手机上 16:9 会被 cover 裁成中间一条,示例里给一张竖图,
|
||||
// 「窄屏」那一栏才有东西可演示、@media 段也才会真的生成
|
||||
const mb = document.createElement('canvas');
|
||||
mb.width = 1080; mb.height = 1920;
|
||||
const mg = mb.getContext('2d');
|
||||
const mgr = mg.createLinearGradient(0, 0, 0, 1920);
|
||||
mgr.addColorStop(0, '#dfeaf6'); mgr.addColorStop(0.6, '#eef3f9'); mgr.addColorStop(1, '#f7f2e8');
|
||||
mg.fillStyle = mgr; mg.fillRect(0, 0, 1080, 1920);
|
||||
const mba = canvasAsset(mb, 0.8);
|
||||
S.assets.push({ id:'bg-m', name:'示例竖版背景', orig:{ dataURL:mba.dataURL, w:mba.w, h:mba.h }, fx: fxDefault(), ...mba });
|
||||
|
||||
const gc = S.scopes.global;
|
||||
gc.bg = 'bg';
|
||||
gc.layers = [
|
||||
Object.assign(newLayer('image'), { asset:'char', h:420, ah:'start', av:'end', dx:40, dy:-36 }),
|
||||
Object.assign(newLayer('image'),
|
||||
{ asset:'char', h:420, ah:'start', av:'end', dx:40, dy:-36, mob:'scale' }),
|
||||
Object.assign(newLayer('radial'), { color:'#ffd9a0', alpha:0.42, r:320, cx:20, cy:86 }),
|
||||
];
|
||||
|
||||
@@ -2043,9 +2497,10 @@ function makeDemo(){
|
||||
S.scopes.question = blankScope('question');
|
||||
S.scopes.profile = blankScope('profile');
|
||||
S.scopes.vote = blankScope('vote');
|
||||
S.narrow.bg = null;
|
||||
S.narrow.bg = 'bg-m';
|
||||
renderAll();
|
||||
log('已载入示例。左侧切换页面看差异 —— 题目列表页是「独立配置」的例子。', 'ok');
|
||||
log('已载入示例。左上角切换 1512 / 390 两个视口,能直接看到「被裁」与「自适应」的差别;'
|
||||
+ '题目列表页是「独立配置」的例子。', 'ok');
|
||||
}
|
||||
|
||||
/* ---------------------------- 初始化 ---------------------------- */
|
||||
@@ -2342,6 +2797,119 @@ function boot(){
|
||||
}
|
||||
pre.textContent = JSON.stringify(r, null, 1);
|
||||
})();
|
||||
} else if (dump === 'fit'){
|
||||
// 尺寸自适应自检:同一层在「桌机」与「手机」下的实际占用、溢出检测、
|
||||
// 以及老工程(没有 unit 字段)的向后兼容
|
||||
(async () => {
|
||||
const r = {};
|
||||
try {
|
||||
makeDemo();
|
||||
const l = S.scopes.global.layers[0];
|
||||
const a = assetById('char');
|
||||
const m900 = layerMetrics(l, a, 1512, 900);
|
||||
const m844 = layerMetrics(l, a, 390, 844);
|
||||
r.desktop = { w: Math.round(m900.w), h: Math.round(m900.h), overR: Math.round(m900.overR) };
|
||||
r.mobile = { w: Math.round(m844.w), h: Math.round(m844.h), overR: Math.round(m844.overR) };
|
||||
r.shorterSmaller = m844.h < m900.h;
|
||||
const c0 = layerCSS(l, true);
|
||||
r.size = c0.size;
|
||||
r.pos = c0.position;
|
||||
r.hasClamp = /clamp\(/.test(c0.size);
|
||||
|
||||
// 固定 px + 居中 + 高图:必须报上下溢出(这就是「像站在桌子上」那一类)
|
||||
const big = Object.assign({}, l, { unit:'px', h:1200, w:0, ah:'center', dx:0 });
|
||||
const mb = layerMetrics(big, a, 1512, 900);
|
||||
r.pxOverflow = { overT: Math.round(mb.overT), overB: Math.round(mb.overB) };
|
||||
r.pxDetected = mb.overT > 0 || mb.overB > 0;
|
||||
|
||||
// 老工程没有 unit/mob 字段:必须退回 px,不能算成 NaN
|
||||
const legacy = Object.assign({}, l);
|
||||
delete legacy.unit; delete legacy.mob;
|
||||
r.legacyUnit = unitOf(legacy);
|
||||
|
||||
// fitw(按宽度自适应):宽幅素材在窄屏必须跟着缩,且 55% 下限要让位给视口
|
||||
const bgA = assetById('bg');
|
||||
const wide = Object.assign({}, l, { unit:'fitw', h:600, w:0, ah:'center', av:'center', dx:0, dy:0 });
|
||||
const wWide = [[1512,900],[1024,768],[390,844]]
|
||||
.map(([W, H]) => Math.round(layerMetrics(wide, bgA, W, H).w));
|
||||
r.fitw = wWide;
|
||||
r.fitwCss = layerSize(wide, bgA);
|
||||
r.fitwShrinks = wWide[0] > wWide[1];
|
||||
r.fitwNoOverflow = wWide[2] <= 390;
|
||||
|
||||
// 窄屏策略
|
||||
S.narrow.hideDeco = false;
|
||||
S.narrow.scale = 0.62;
|
||||
r.mobScaled = decoMobile(Object.assign({}, l, { mob:'scale' })).h;
|
||||
r.mobHidden = decoMobile(Object.assign({}, l, { mob:'hide' }));
|
||||
const inh = Object.assign({}, l, { mob:'inherit' });
|
||||
r.mobInherit = decoMobile(inh) === inh; // 原样返回,不复制
|
||||
r.media = /@media \(max-width: 900px\)/.test(buildCSS(false));
|
||||
// 窄屏里独立配置的页面也必须有一条(否则特异性不够,规则不生效)
|
||||
r.mediaAdmin = /@media[\s\S]*data-page-scope="admin"/.test(buildCSS(false));
|
||||
|
||||
r.ok = r.shorterSmaller && r.hasClamp && r.pxDetected && r.legacyUnit === 'px'
|
||||
&& r.mobScaled === 260 && r.mobHidden === null && r.mobInherit
|
||||
&& r.fitwShrinks && r.fitwNoOverflow
|
||||
&& r.media && r.mediaAdmin;
|
||||
} catch (e){
|
||||
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
|
||||
r.ok = false;
|
||||
}
|
||||
pre.textContent = JSON.stringify(r, null, 1);
|
||||
})();
|
||||
} else if (dump === 'quality'){
|
||||
// 画质自检:质量越低体积越小;长边上限必须真的把像素压下来。
|
||||
// ⚠️ 必须用「有噪声」的图来验:平涂线稿在 WebP 下 q=1 常常比 q=0.6 还小
|
||||
// (无损模式对纯色更高效,有损反而引入噪点),所以「降 q 一定省体积」不成立。
|
||||
(async () => {
|
||||
const r = {};
|
||||
try {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 800; cv.height = 600;
|
||||
const g = cv.getContext('2d');
|
||||
const id = g.createImageData(800, 600);
|
||||
let seed = 12345;
|
||||
for (let i = 0; i < id.data.length; i += 4){
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
id.data[i] = seed & 255;
|
||||
id.data[i+1] = (seed >> 8) & 255;
|
||||
id.data[i+2] = (seed >> 16) & 255;
|
||||
id.data[i+3] = 255;
|
||||
}
|
||||
g.putImageData(id, 0, 0);
|
||||
const q6 = canvasAsset(cv, 0.6), q9 = canvasAsset(cv, 0.95), q1 = canvasAsset(cv, 1);
|
||||
r.kb60 = q6.kb; r.kb95 = q9.kb; r.kb1 = q1.kb;
|
||||
r.monotonic = q6.bytes.length < q9.bytes.length;
|
||||
// q=1 会被夹到 0.95,否则会走 Skia 的 WebP 无损路径,体积反而小得离谱
|
||||
r.oneClamped = q1.bytes.length === q9.bytes.length;
|
||||
|
||||
// 平涂对照:只记录,不作为判定条件(有损对纯色会引入噪点,可能变大)
|
||||
const flat = document.createElement('canvas');
|
||||
flat.width = 800; flat.height = 600;
|
||||
const fg = flat.getContext('2d');
|
||||
fg.fillStyle = '#4a7fc1'; fg.fillRect(0, 0, 800, 600);
|
||||
r.flat_kb60 = canvasAsset(flat, 0.6).kb;
|
||||
r.flat_kb95 = canvasAsset(flat, 0.95).kb;
|
||||
|
||||
const img = await new Promise((res, rej) => {
|
||||
const im = new Image();
|
||||
im.onload = () => res(im);
|
||||
im.onerror = () => rej(new Error('canvas → Image 失败'));
|
||||
im.src = cv.toDataURL('image/png');
|
||||
});
|
||||
const lim = renderFxCanvas(img, fxDefault(), 0, 200);
|
||||
r.limited = [lim.width, lim.height]; // 长边 200 → 200×150
|
||||
const noLim = renderFxCanvas(img, fxDefault(), 0, 0);
|
||||
r.unlimited = [noLim.width, noLim.height]; // 800×600
|
||||
r.ok = r.monotonic && r.oneClamped && r.limited[0] === 200 && r.limited[1] === 150
|
||||
&& r.unlimited[0] === 800;
|
||||
} catch (e){
|
||||
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
|
||||
r.ok = false;
|
||||
}
|
||||
pre.textContent = JSON.stringify(r, null, 1);
|
||||
})();
|
||||
} else {
|
||||
const files = [{ name: `${S.slug}.css`, data: enc(buildCSS(false)) }];
|
||||
const bytes = zipStore(files);
|
||||
|
||||
@@ -10,7 +10,7 @@ var (
|
||||
BuildCommit = "dev"
|
||||
|
||||
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
||||
StaticVersion = "20261020e"
|
||||
StaticVersion = "20261020f"
|
||||
)
|
||||
|
||||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||||
|
||||
+2
-1
@@ -29,6 +29,7 @@
|
||||
--tb-page-image: none;
|
||||
--tb-page-pattern: none;
|
||||
--tb-page-pattern-size: auto;
|
||||
--tb-page-position: center center;
|
||||
/* 装饰层:固定像素尺寸 + 四角定位,不受 cover 裁切影响(整页背景图做不到这一点,
|
||||
视口比例一变,贴在图片边缘的插画就会被裁掉)。
|
||||
⚠️ 固定 6 个槽位:background-* 各列表的层数必须恒定(1 图案 + 6 装饰 + 1 背景 = 8),
|
||||
@@ -282,7 +283,7 @@
|
||||
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, 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), var(--tb-page-position, center center);
|
||||
background-attachment: scroll, fixed, fixed, fixed, fixed, fixed, fixed, fixed, fixed, fixed,
|
||||
fixed, fixed;
|
||||
color: var(--tb-text);
|
||||
|
||||
@@ -35,11 +35,20 @@
|
||||
页面背景 --tb-page-image 整页背景图(cover,fixed)
|
||||
--tb-page-pattern 整页图案(平铺,可叠加在背景图之上)
|
||||
--tb-page-pattern-size 图案平铺尺寸(默认 auto)
|
||||
--tb-page-position 背景焦点(cover 裁切时保留哪一侧,默认 center center)
|
||||
装饰 --tb-deco 装饰图层,固定 10 个槽位(必须写满 10 个,缺的填 none);
|
||||
固定像素尺寸、不随视口裁切,适合放人物/摆件/光斑。默认 none。
|
||||
适合放人物/摆件/光斑。默认 none。
|
||||
值可以是图片,也可以是 linear-gradient / radial-gradient(纯"光")。
|
||||
--tb-deco-size 对应每层的尺寸,如 auto 400px, 100% 100%, auto, …(10 个)
|
||||
想随视口缩就用 clamp,不要写死 px:
|
||||
人物/竖幅 auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)
|
||||
横幅/场景 clamp(min(calc(100vw - 24px), 587px), 70.57vw, 1067px) auto
|
||||
内层 min() 是兜底:屏幕小到连下限都放不下时让位给视口。
|
||||
background-size 的百分比是相对「容器 − 图片」,和 position 不同,
|
||||
所以用 clamp 比用 % 好推理。
|
||||
--tb-deco-position 对应每层的位置,如 calc(50% - 524px) calc(100% - 36px), …(10 个)
|
||||
两值形式(水平 垂直);别用 left/bottom 关键字混搭成三值,
|
||||
非法时整条声明作废,图层会静默掉回左上角。
|
||||
|
||||
── 素材规格(放 R2,不需要重新编译) ─────────────────────────────
|
||||
页面背景图 2400×1600(3:2)或 2560×1440,WebP q80,≤300KB;明暗各一张:
|
||||
|
||||
Reference in New Issue
Block a user