主题生成器支持素材前缀(主题素材可放 R2/OSS)

主题素材只有一处引用:主题 css 的 url()(分享卡是 Go 用 imaging 画的,不读素材),
所以只要让 CSS 里的地址指向对象存储即可。

- 导出面板新增「素材前缀」:空 = /static/images/themes/<slug>/(图片随二进制发布,与以前一致);
  填 https://cdn.example.com/themes = CSS 写绝对 URL,zip 里的图片改为「上传用」
- 地址统一由 urlOf() 决定。修了一个重复实现:layerCSS 里复制过一份路径拼接,
  只改 urlOf 会出现「背景走 CDN、装饰还指本地」
- 前缀随意切换不影响预览(预览恒用内联 dataURL);存进工程 json;?assetbase= 可自检
- INSTALL 文案按是否托管分叉,写清图片传到哪、换域名要重新导出
- 新增 docs/theme-assets-cdn.md:三种做法(生成器前缀 / 应用内 301 / 边缘改写)、
  缓存与 ?v=、CORS 不需要、深色无素材、迁移用的 sed 命令
- README 更新;UI 修正:240px 面板里 4 字标签会被 flex 压窄,需 flex:0 0 auto + 84px
This commit is contained in:
tamakyi
2026-10-21 19:32:55 +08:00
parent f37ff28a46
commit 6c21184733
6 changed files with 203 additions and 5 deletions
+57 -5
View File
@@ -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++){