Compare commits
2
Commits
34a262091b
...
6c21184733
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c21184733 | ||
|
|
f37ff28a46 |
@@ -551,3 +551,25 @@ base.css 还原成 CRLF,插入行要用同一行尾,否则变 MIXED。
|
||||
改动:`props/bike.webp`(397×259 画布不变,位置公式不用动)、css 里图片 `?v=20261020d→20261021a`、
|
||||
`StaticVersion 20261021c→20261021d`、两份 docs 同步。`go test ./internal/theme` 全绿。
|
||||
⚠️ 画布尺寸**不能裁**:裁完 `auto 259px` 的显示宽度变了,`left calc(50% - 612px)` 的锚点会跟着漂。
|
||||
|
||||
## 19:4x 两件事:/user/questions「卡片透明」根因 + 主题素材上 R2
|
||||
|
||||
**1) /user/questions 卡片透明 —— 不是主题,是模板没有卡片**
|
||||
`templates/user/question-list.html` 整个文件只有 1 处 `.uk-card`(投票 tab 的「我的投票」标题条);
|
||||
提问列表是 `<div class="uk-margin-medium-bottom">` + `<hr>` **裸铺在页面上**。
|
||||
默认主题底色 #fff 看不出来,换带整页插画的主题后场景整片透上来 = 「卡片透明」。
|
||||
修:`.qlist-panel` 加卡片底(`--tb-surface` + `--tb-radius` + `--tb-shadow` + padding,
|
||||
≤640px 收紧),内部那张 `.uk-card-default` 降成 `--tb-surface-2` 去阴影(防卡片套卡片)。
|
||||
验证:本地快照注入 shiroko 后,行内容落在卡上,场景只从卡外透出;默认外观只是多一层白卡。
|
||||
⚠️ 页面级样式仍写在模板 `<style>` 里(不搬 base.css),符合既有约定。
|
||||
|
||||
**2) 主题素材放 R2 —— 三种做法,生成器已支持 A**
|
||||
素材只有一处引用:主题 css 的 `url()`(分享卡是 Go 用 imaging 画的,不读素材)。
|
||||
- A 生成器「素材前缀」:导出面板新增输入(`S.assetBase`,存进工程 json,`?assetbase=` 可测),
|
||||
`urlOf()` 一处决定地址 → 绝对 URL。**踩到过坑:`layerCSS` 里复制了一份路径拼接**,
|
||||
只改 urlOf 会导致「背景走 CDN、装饰还指本地」,已统一到 urlOf。
|
||||
- B 应用内 301:`[theme] asset_base` + 中间件(必须注册在 `flamego.Static` **之前**)。
|
||||
- C 边缘/反代改写(CF Origin Rule / nginx)。
|
||||
细节与取舍写进新文档 `docs/theme-assets-cdn.md`(含缓存、CORS、深色无素材、迁移 sed 命令)。
|
||||
⚠️ UI 坑:面板 240px 里 4 个中文字的 label 会被 flex 压缩、末字被输入框切掉 →
|
||||
label 要 `flex:0 0 auto; white-space:nowrap`,宽度给 84px 才够(68px 仍会切)。
|
||||
|
||||
@@ -41,3 +41,6 @@
|
||||
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
|
||||
- `?dump=css` 的产物要过 Go 侧校验:临时存成 `static/css/themes/<非下划线名>.css` 再 `go test ./internal/theme`,
|
||||
跑完删掉。
|
||||
- 导出面板「素材前缀」`S.assetBase`:空 = `/static/images/themes/<slug>/`;填了 = CSS 写绝对 URL
|
||||
(R2/OSS)。**图片地址只能由 `urlOf()` 决定** —— `layerCSS` 曾复制过一份拼接,改一处会出现
|
||||
「背景走 CDN、装饰还指本地」。文档见 `docs/theme-assets-cdn.md`。
|
||||
|
||||
@@ -36,3 +36,6 @@
|
||||
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
|
||||
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
|
||||
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
|
||||
- ⚠️ **后台页不是都有卡片**:`/user/questions` 的列表是 `<hr>` 裸行,模板里唯一一处 `.uk-card`
|
||||
是投票 tab 的标题条。默认主题 #fff 看不出来,换带整页插画的主题就"整页透上来"。
|
||||
已在 `.qlist-panel` 上补卡片底;新增后台页时别假设外层已有卡片。
|
||||
|
||||
@@ -190,6 +190,12 @@ WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只
|
||||
| --- | --- | --- |
|
||||
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
|
||||
| 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 |
|
||||
| 素材前缀 | 空 | 空 = 图片放 `static/images/themes/<slug>/` 随二进制发布;填 `https://cdn.example.com/themes` = CSS 写绝对地址,图片自己传对象存储(R2/OSS)。**前缀里不要带 slug** |
|
||||
|
||||
导出的 CSS 只有 `urlOf()` 一处决定图片地址,图层、背景、窄屏块全走它,不会出现
|
||||
「背景走 CDN、装饰还指本地」的半吊子状态。素材前缀只影响导出,预览永远是内联 dataURL。
|
||||
|
||||
详细取舍(生成器前缀 / 应用内 301 / 边缘改写)见 `docs/theme-assets-cdn.md`。
|
||||
|
||||
面板底部实时显示 `3 张,共 19KB;最大一张 bg.webp 7KB(1920×1080)`。
|
||||
改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
|
||||
|
||||
@@ -338,6 +338,10 @@ const D = {
|
||||
/* 导出画质:webp 体积 ≈ 像素数 × 质量。压体积最有效的不是 q,是先砍像素
|
||||
—— CSS 里显示 400px 高的图,2 倍屏只需要 800px,上传 3000px 的立绘纯属白扛。 */
|
||||
export: { quality: 0.88, maxSide: 1600 },
|
||||
/* 素材前缀:空 = 走站点自己的 /static/images/themes(图片随二进制发布);
|
||||
填了(如 https://cdn.example.com/themes)= 导出的 CSS 写绝对 URL,图片放对象存储。
|
||||
前缀里不要带 slug,slug 由导出时自动拼在后面。 */
|
||||
assetBase: '',
|
||||
dark: {
|
||||
bg:'#141a22', surface:'#1d2530', navbar:'#141a22',
|
||||
text:'#e3eaf4', primary:'#6fa3e0', border:'#334052',
|
||||
@@ -356,6 +360,7 @@ const S = {
|
||||
palette: Object.assign({}, D.palette),
|
||||
narrow: Object.assign({}, D.narrow),
|
||||
export: Object.assign({}, D.export),
|
||||
assetBase: '',
|
||||
dark: Object.assign({}, D.dark),
|
||||
};
|
||||
|
||||
@@ -528,8 +533,8 @@ function layerCSS(l, preview){
|
||||
if (l.kind === 'image'){
|
||||
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`;
|
||||
return { image:`url("${url}")`, size: layerSize(l, a), position: POS(l) };
|
||||
// 统一走 urlOf:素材前缀(CDN)只在这一处判定,别再复制一份拼接逻辑
|
||||
return { image:`url("${urlOf(a, preview)}")`, size: layerSize(l, a), position: POS(l) };
|
||||
}
|
||||
if (l.kind === 'radial'){
|
||||
const c = rgba(l.color, l.alpha);
|
||||
@@ -559,7 +564,9 @@ function newLayer(kind){
|
||||
|
||||
/* ---------------------------- 导出 CSS ---------------------------- */
|
||||
function urlOf(a, preview){
|
||||
return preview ? a.dataURL : `/static/images/themes/${S.slug}/${a.id}.webp`;
|
||||
if (preview) return a.dataURL; // 预览必须用内联 dataURL,否则改一张图要等一轮部署
|
||||
const base = String(S.assetBase || '').trim().replace(/\/+$/, '');
|
||||
return base ? `${base}/${S.slug}/${a.id}.webp` : `/static/images/themes/${S.slug}/${a.id}.webp`;
|
||||
}
|
||||
|
||||
function bgVars(cfg, preview){
|
||||
@@ -1551,6 +1558,26 @@ function renderExportEditor(){
|
||||
};
|
||||
r2.append(label('长边上限', '62px'), side);
|
||||
|
||||
// 素材前缀:空 = 走站点自己的 /static/images/themes(图片随二进制发布);
|
||||
// 填了 = 导出的 CSS 写绝对 URL,图片自己传对象存储(R2 / OSS)。
|
||||
// 只影响导出,不影响预览 —— 预览永远用内联 dataURL。
|
||||
const r3 = row();
|
||||
const baseIn = document.createElement('input');
|
||||
baseIn.type = 'url';
|
||||
baseIn.placeholder = '空 = /static/images/themes';
|
||||
baseIn.value = S.assetBase || '';
|
||||
baseIn.style.cssText = 'flex:1 1 0;width:0;min-width:0';
|
||||
baseIn.title = '填 https://cdn.example.com/themes 这种前缀(不要带 slug),导出的 CSS 就会写绝对地址;'
|
||||
+ '图片仍会打进 zip,方便你上传。留空则沿用站点静态目录';
|
||||
baseIn.onchange = () => {
|
||||
S.assetBase = baseIn.value.trim().replace(/\/+$/, '');
|
||||
renderAll();
|
||||
};
|
||||
// 标签固定宽度:flex 会把 4 个中文字的 label 压窄,末字被输入框切掉
|
||||
const baseLbl = label('素材前缀', '84px');
|
||||
baseLbl.style.cssText = 'flex:0 0 auto;white-space:nowrap';
|
||||
r3.append(baseLbl, baseIn);
|
||||
|
||||
const stat = document.createElement('p');
|
||||
stat.className = 'hint';
|
||||
const total = S.assets.reduce((n, a) => n + (a.kb || 0), 0);
|
||||
@@ -1563,6 +1590,15 @@ function renderExportEditor(){
|
||||
}
|
||||
el.appendChild(stat);
|
||||
|
||||
if (S.assetBase){
|
||||
const cdn = document.createElement('p');
|
||||
cdn.className = 'hint';
|
||||
cdn.textContent = `图片地址将写成 ${S.assetBase.replace(/\/+$/, '')}/${S.slug}/<id>.webp —— `
|
||||
+ '记得把 zip 里的 static/images/themes/' + S.slug + '/ 整个传到这个路径下;'
|
||||
+ '仓库里就不需要这些图了。换 CDN 域名要重新导出 CSS。';
|
||||
el.appendChild(cdn);
|
||||
}
|
||||
|
||||
const tip = document.createElement('p');
|
||||
tip.className = 'hint';
|
||||
tip.textContent = '压体积的顺序:先砍长边,再降质量。改完按「原图 + 处理参数」重新编码全部素材,'
|
||||
@@ -1688,6 +1724,7 @@ function toJSON(){
|
||||
dark: S.dark,
|
||||
narrow: S.narrow,
|
||||
export: S.export,
|
||||
assetBase: S.assetBase,
|
||||
// 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。
|
||||
// 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 ——
|
||||
// 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。
|
||||
@@ -1717,6 +1754,7 @@ function fromJSON(o){
|
||||
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.assetBase = typeof o.assetBase === 'string' ? o.assetBase : '';
|
||||
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;
|
||||
@@ -1765,6 +1803,7 @@ function resetAll(){
|
||||
S.palette = Object.assign({}, D.palette);
|
||||
S.narrow = Object.assign({}, D.narrow);
|
||||
S.export = Object.assign({}, D.export);
|
||||
S.assetBase = '';
|
||||
S.dark = Object.assign({}, D.dark);
|
||||
S.scopes = {};
|
||||
SCOPES.forEach(sc => { S.scopes[sc.k] = blankScope(sc.k); });
|
||||
@@ -1929,9 +1968,17 @@ function installTxt(slug){
|
||||
return `主题 ${slug} —— 安装说明
|
||||
=====================================
|
||||
|
||||
1) 解压(保持目录结构,覆盖即可)
|
||||
${S.assetBase
|
||||
? `1) 解压
|
||||
static/css/themes/${slug}.css ← 放进仓库(CSS 里已是绝对地址)
|
||||
static/images/themes/${slug}/*.webp ← 传到 ${S.assetBase.replace(/\/+$/, '')}/${slug}/,仓库里不需要
|
||||
|
||||
素材托管在对象存储,CSS 写的是绝对地址:
|
||||
换 CDN 域名 / 路径 = 必须重新导出(地址烘焙在文件里,不是运行时拼的)
|
||||
换图 = 覆盖同名对象,建议把 Cache-Control 设成 immutable + 用 ?v= 或换文件名做失效`
|
||||
: `1) 解压(保持目录结构,覆盖即可)
|
||||
static/css/themes/${slug}.css
|
||||
static/images/themes/${slug}/*.webp
|
||||
static/images/themes/${slug}/*.webp`}
|
||||
|
||||
2) 登记主题(conf/app.ini)
|
||||
[theme]
|
||||
@@ -2745,6 +2792,11 @@ function boot(){
|
||||
S.narrow.hideDeco = true;
|
||||
renderAll();
|
||||
}
|
||||
// ?assetbase=https://cdn.example.com/themes 模拟「素材放对象存储」的导出结果
|
||||
if (params.get('assetbase')){
|
||||
S.assetBase = params.get('assetbase');
|
||||
renderAll();
|
||||
}
|
||||
// ?rows=N 加 N 层并依次绑到不同素材(截图 / 人工核对多图层效果用)
|
||||
const rows = parseInt(params.get('rows') || '0', 10);
|
||||
for (let i = 0; i < rows; i++){
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# 把主题素材放到对象存储(R2 / OSS)
|
||||
|
||||
现状:主题素材在 `static/images/themes/<slug>/`,通过 `static/fs.go` 的 `//go:embed images/themes`
|
||||
随二进制发布。好处是部署即生效、不依赖外部服务;代价是**换一张图就要重新编译部署**,
|
||||
而且一张 1920×1080 的场景图会让二进制大几百 KB。
|
||||
|
||||
主题素材只有一处引用:**主题 CSS 里的 `url(...)`**(`--tb-page-image` / `--tb-page-pattern` /
|
||||
`--tb-deco`)。分享卡是 Go 里用 `imaging` 画出来的,不读主题素材。所以只要解决「CSS 里的地址指向哪」,
|
||||
就把整套素材搬走了。
|
||||
|
||||
下面三种做法按「改动量从 0 到有代码」排列,选一个即可,不必叠加。
|
||||
|
||||
---
|
||||
|
||||
## A. 生成器直接写绝对地址(已支持,推荐)
|
||||
|
||||
`dev/theme-builder` 的导出面板新增「**素材前缀**」:
|
||||
|
||||
```
|
||||
素材前缀 https://cdn.example.com/themes
|
||||
```
|
||||
|
||||
留空 = 沿用 `/static/images/themes/<slug>/`(图片随二进制发布,和以前一样)。
|
||||
填了 = 导出的 CSS 里所有图片地址变成 `https://cdn.example.com/themes/<slug>/<id>.webp`。
|
||||
**前缀里不要带 slug**,slug 由导出时自动拼在后面。
|
||||
|
||||
图片照旧打进 zip(`static/images/themes/<slug>/*.webp`),只是用途从「放进仓库」变成
|
||||
「传到对象存储的 `<前缀>/<slug>/` 下」。INSTALL 文案会跟着变,直接写清楚往哪传。
|
||||
|
||||
- 优点:零服务端改动、零跳转、缓存最优(CDN 直接命中);
|
||||
- 缺点:域名烘焙进 CSS —— 换域名/换路径要**重新导出**(旧主题可以用 `sed` 批量替换);
|
||||
- 仓库里可以删掉 `static/images/themes/<slug>/` 来缩小编译产物,**确认 CSS 已是绝对地址**再删。
|
||||
|
||||
命令行等价做法(已有主题不想重导):
|
||||
|
||||
```bash
|
||||
sed -i 's#/static/images/themes/shiroko/#https://cdn.example.com/themes/shiroko/#g' \
|
||||
static/css/themes/shiroko.css
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## B. 应用内 301 跳转(配置驱动,CSS 一行都不用改)
|
||||
|
||||
`conf/app.ini`:
|
||||
|
||||
```ini
|
||||
[theme]
|
||||
asset_base = https://cdn.example.com/themes ; 留空 = 本地静态目录
|
||||
```
|
||||
|
||||
中间件必须在 `flamego.Static` **之前**注册(`internal/route/route.go` 里 `f.Use(flamego.Static(...))` 前面):
|
||||
|
||||
```go
|
||||
// 主题素材转发到对象存储:命中前缀就 301 出去,其余请求原样放过。
|
||||
// 必须挂在 static 中间件之前——Static 命中本地文件就直接返回了,轮不到这里。
|
||||
func themeAssetRedirect() flamego.Handler {
|
||||
const prefix = "/static/images/themes/"
|
||||
return func(ctx flamego.Context) {
|
||||
base := theme.AssetBase() // 从 conf 读,空串 = 不接管
|
||||
p := ctx.Request().URL.Path
|
||||
if base == "" || !strings.HasPrefix(p, prefix) {
|
||||
return
|
||||
}
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
http.Redirect(w, ctx.Request(), base+strings.TrimPrefix(p, prefix), http.StatusMovedPermanently)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 优点:已有主题、生成器导出的相对地址**都能用**,切 CDN 只改配置;
|
||||
- 缺点:冷缓存时每张图多一跳;**301 会被浏览器长期缓存**,以后想切回本地,
|
||||
老访客可能还在访问 CDN(介意的话用 `302`,或一开始就用方案 A);
|
||||
- 本地开发记得把 `asset_base` 留空,否则断网时素材全 404。
|
||||
|
||||
---
|
||||
|
||||
## C. 边缘 / 反代改写(零代码)
|
||||
|
||||
素材路径有稳定前缀,最适合在边缘处理:
|
||||
|
||||
- **Cloudflare**:Origin Rule / Transform Rule,把 `/static/images/themes/*` 的 Host 改成
|
||||
R2 自定义域(如 `cdn.example.com`),或 Redirect Rule 直接 301 过去;
|
||||
- **nginx**:`location /static/images/themes/ { proxy_pass https://cdn.example.com/themes/; }`,
|
||||
或 `rewrite` 成 CDN 地址后 `return 301`。
|
||||
|
||||
- 优点:应用完全不知道素材在哪,不占应用带宽;
|
||||
- 缺点:绑定部署环境,本地跑没有等效层(要考虑 fallback)。
|
||||
|
||||
---
|
||||
|
||||
## 三种方案共同要处理的细节
|
||||
|
||||
1. **缓存**:对象存储上建议
|
||||
`Cache-Control: public, max-age=31536000, immutable`(文件名带版本时)。
|
||||
换图**不要覆盖同名对象**——要么换 `id`(生成器里改文件名),要么给 URL 加 `?v=`;
|
||||
Cloudflare 默认把 query string 计入缓存键,所以 `?v=` 是有效的。
|
||||
2. **CORS 不用配**:`background-image` 不触发 CORS,匿名 GET 即可。
|
||||
3. **主题 CSS 本身不用上 CDN**:一个主题只有几 KB,而且随 `StaticVersion` 走缓存键,
|
||||
留在 `static/css/themes/` 更省事。
|
||||
4. **深色模式没有素材**(深色块 `--tb-deco` 恒为 `none`),别只传浅色图就以为漏传了。
|
||||
5. **分享卡不受影响**:`route/question/share.go` 用 `imaging` 直接画,不加载主题图片。
|
||||
6. 迁完之后 `go build` 会小一圈,但**主题 css 仍是编译期 embed**:改 CSS 还是要重编译部署。
|
||||
|
||||
---
|
||||
|
||||
## 建议
|
||||
|
||||
- 只是想让部署产物变小、换图不用重编译 → **A**(生成器填前缀,最省事,缓存也最好)。
|
||||
- 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。
|
||||
- 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。
|
||||
@@ -31,6 +31,21 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.qlist-panel[hidden] { display: none !important; }
|
||||
/* 三个面板兜一层卡片底。此前面板内容是裸行(只靠 <hr> 分隔)直接铺在页面背景上,
|
||||
默认主题底色是纯白所以看不出来;换成带整页插画的主题后,场景会整片透上来,
|
||||
看着就像"这一页的卡片是透明的"。和其它后台页保持一致,也和主题无关。 */
|
||||
.qlist-panel {
|
||||
background: var(--tb-surface);
|
||||
border-radius: var(--tb-radius);
|
||||
box-shadow: var(--tb-shadow);
|
||||
padding: 18px 20px 8px;
|
||||
}
|
||||
/* 面板内部本来还有一张卡片(投票 tab 的「我的投票」标题条):降成次级底色,
|
||||
否则就是卡片套卡片,阴影会叠一层脏边。 */
|
||||
.qlist-panel .uk-card-default { background: var(--tb-surface-2); box-shadow: none; }
|
||||
@media (max-width: 640px) {
|
||||
.qlist-panel { padding: 14px 12px 4px; }
|
||||
}
|
||||
.all-votes-list { display: grid; gap: 10px; }
|
||||
.all-vote-item {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user