feat(dev): 主题生成器——选图/拖位置/导出 zip

- dev/theme-builder/index.html:单文件、不联网、图片本地转 WebP
- 预览与线上同机制(pattern + 6 槽 deco + page-image),拖拽反解 calc(50% - Npx)
- 导出 CSS + 图片 + 安装说明,CSS 通过 TestThemeCSSComplete
- 自检:?selftest=1 吐 CSS/zip 字节,?demo=1 载入示例
This commit is contained in:
tamakyi
2026-10-20 10:24:35 +08:00
parent b150e4b49f
commit feb5b1f78d
4 changed files with 915 additions and 0 deletions
+16
View File
@@ -59,3 +59,19 @@
构图硬约束(左侧 22% 前景必须是空地,否则又回到"站在课桌上")、换图清单与定位公式。 构图硬约束(左侧 22% 前景必须是空地,否则又回到"站在课桌上")、换图清单与定位公式。
- 配色给的是**压白后的显示值** + 反算公式 `原色=(显示值-40.8)/0.84`,并特别标注袜子要用 `#ededed` - 配色给的是**压白后的显示值** + 反算公式 `原色=(显示值-40.8)/0.84`,并特别标注袜子要用 `#ededed`
而不是纯白(纯白会和背景粘连,抠图必然吃穿)。 而不是纯白(纯白会和背景粘连,抠图必然吃穿)。
## 10:40 主题生成器(dev/theme-builder)
- 单文件 `dev/theme-builder/index.html`:上传背景/人物/道具 → 拖拽调位置 → 导出 zip,
解压到项目根即可新增主题。不联网、不起服务、图片全在浏览器本地转 WebP。
- 预览用**和线上同一套机制**渲染(pattern + deco 6 层 + page-image,size/position 对齐),
不是另画示意图;拖出来的「左沿像素」反解成 `calc(50% - Npx)`,换视口不跑位。
- 导出包:`static/css/themes/<slug>.css` + `static/images/themes/<slug>/*.webp` + `INSTALL-<slug>.txt`。
CSS 同时写浅色/深色两块,10 个必需令牌齐全——**实测通过 `TestThemeCSSComplete`**。
- zip 自己实现(STORE + CRC32,约 40 行),不引 CDN;`unzip -t` 无错。
- 踩坑:① 面板里 `c.text2` 写成 undefined(light 对象里才算出来的派生色,要用 `light.text2`);
② `contain` 模式如果不按内容裁画布,会留大片透明边 → 导出宽高比全错;
③ 预览容器 `grid-template-columns:340px 1fr` 会被 1512px 的 frame 撑破 → `minmax(0,1fr)`;
④ 无头 Chrome 下 `canvas.toBlob` 的回调在 `--dump-dom` 前不触发 → 全部改 `toDataURL` 同步化,
自检才能跑(`?selftest=1` 输出 CSS/zip 字节,`?demo=1` 载入示例素材)。
- ⚠️ 预览里深色必须走「暗色渐变 + deco none」,否则会拿白天场景图压深底(和导出结果不一致)。
+43
View File
@@ -0,0 +1,43 @@
# 主题生成器(本地单文件)
`index.html` 双击即用,不联网、不起服务、不传图片——所有处理都在浏览器本地完成。
## 用法
1. 打开 `dev/theme-builder/index.html`(推荐 Chrome / Edge)。
2. 填 slug(= css 文件名,小写字母/数字/连字符)。
3. 传图:
- **背景图**:任意尺寸,会按 `cover` 裁成 1920×1080;
- **人物**:透明底 PNG/WebP,会按高度 1.5× 输出;
- **道具**(可选):透明底,会自动在底部烘一层接触阴影。
4. 拖预览里的虚线框调位置,或右侧滑杆微调;切 1512 / 1280 / 1024 看不同视口。
5. 点「导出 zip」→ 解压到项目根目录 → 按 zip 里的 `INSTALL-<slug>.txt` 做三步(登记 `[theme] available`、`StaticVersion` +1、重新编译)。
## 它做了什么
- 用**和线上同一套机制**渲染预览:`background-image: pattern, deco(6 层), page-image`,
`background-size` / `background-position` 一一对应,不是另画一张示意图。
- 位置数学和 CSS 一致:`background-position` 的百分比是相对 **(容器宽 − 图宽)**,
所以工具里拖出来的「左沿像素」会被反解成 `calc(50% - Npx)`,换视口不会跑位。
- 图片全部转 WebP 并按目标尺寸重采样,导出前就能看到 KB 数。
- 生成的 CSS 同时写浅色/深色两块(项目测试 `TestThemeCSSComplete` 要求),
10 个必需令牌齐全。
## 它不做什么
- **不抠图**。人物/道具必须是透明底;白底图导出的主题会带一块白。
- **不做调色**。想统一"雾度"(人物压白 16%、场景 30~38%)得在生成端做,
或用 `docs/theme-shiroko-assets-spec.md` 里的公式自己处理。
- **不写 `[theme] available`**。配置在 `conf/app.ini`,导出包里只给了要粘贴的那一行。
- **不改代码**。素材是编译期 embed,必须重新 `go build` 才会生效。
## 自检
```
chrome --headless=old --dump-dom "file:///.../dev/theme-builder/index.html?selftest=1"
```
会把生成的 CSS 与 zip 字节以 JSON 塞进 `<pre id="selftest">`,可用于回归:
把 `css` 写成 `static/css/themes/<slug>.css`,跑 `go test ./internal/theme -run TestThemeCSSComplete`。
`?demo=1` 会直接载入示例素材(纯 canvas 画的渐变背景 + 一个剪影),没有图也能先看布局。
+855
View File
@@ -0,0 +1,855 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TamaBox 主题生成器</title>
<script>window.addEventListener("error",e=>{const p=document.createElement("pre");p.id="jserr";p.textContent=(e.message||"")+" @ "+(e.lineno||0)+":"+(e.colno||0)+"\n"+(window.__stage||"-")+"\n"+(e.error&&e.error.stack||"");document.documentElement.appendChild(p);});</script>
<style>
:root{
--ui-bg:#0f1216; --ui-panel:#171c23; --ui-panel2:#1e242d; --ui-line:#2a323d;
--ui-text:#e6ebf2; --ui-text2:#9aa7b8; --ui-accent:#5c9bff; --ui-ok:#3ecf8e; --ui-warn:#f2b544;
--ui-radius:10px;
font-family: system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--ui-bg);color:var(--ui-text);font-size:13px;line-height:1.6}
header{display:flex;align-items:center;gap:14px;padding:12px 18px;border-bottom:1px solid var(--ui-line);background:var(--ui-panel);position:sticky;top:0;z-index:20}
header h1{font-size:14px;margin:0;font-weight:600;letter-spacing:.02em}
header .hint{color:var(--ui-text2);font-size:12px}
header .right{margin-left:auto;display:flex;gap:8px;align-items:center}
main{display:grid;grid-template-columns:340px minmax(0,1fr);height:calc(100vh - 53px)}
.panel{overflow-y:auto;border-right:1px solid var(--ui-line);background:var(--ui-panel);padding:6px 0 40px}
section{border-bottom:1px solid var(--ui-line);padding:12px 16px}
section h2{font-size:12px;margin:0 0 10px;color:var(--ui-text2);font-weight:600;letter-spacing:.08em;text-transform:uppercase}
label{display:block;font-size:12px;color:var(--ui-text2);margin:8px 0 4px}
input[type=text],input[type=number],select{width:100%;background:var(--ui-panel2);border:1px solid var(--ui-line);color:var(--ui-text);border-radius:6px;padding:6px 8px;font-size:12px}
input[type=range]{width:100%;accent-color:var(--ui-accent)}
input[type=color]{width:100%;height:28px;background:none;border:1px solid var(--ui-line);border-radius:6px;padding:2px}
.row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px}
button{background:var(--ui-panel2);border:1px solid var(--ui-line);color:var(--ui-text);border-radius:7px;padding:7px 10px;font-size:12px;cursor:pointer}
button:hover{border-color:#3b4757}
button.primary{background:var(--ui-accent);border-color:var(--ui-accent);color:#06121f;font-weight:600}
button.ghost{background:transparent}
.drop{border:1px dashed #3a4553;border-radius:8px;padding:10px;text-align:center;color:var(--ui-text2);cursor:pointer;font-size:12px}
.drop:hover{border-color:var(--ui-accent);color:var(--ui-text)}
.drop.filled{border-style:solid;border-color:#2f6b4d;color:var(--ui-ok)}
.thumbs{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.thumb{position:relative;width:64px;height:64px;border-radius:6px;overflow:hidden;background:#0b0e12;border:1px solid var(--ui-line)}
.thumb img{width:100%;height:100%;object-fit:contain}
.meta{font-size:11px;color:var(--ui-text2);margin-top:6px}
.stage{display:flex;flex-direction:column;overflow:hidden;background:#0b0e12;min-width:0}
.stagebar{display:flex;gap:8px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--ui-line);background:var(--ui-panel)}
.stagebar .seg{display:flex;border:1px solid var(--ui-line);border-radius:7px;overflow:hidden}
.stagebar .seg button{border:none;border-radius:0;padding:5px 10px;background:transparent}
.stagebar .seg button.on{background:var(--ui-accent);color:#06121f;font-weight:600}
.stagebar .grow{flex:1}
.canvas{flex:1;overflow:auto;padding:18px;display:flex;justify-content:center;align-items:flex-start;min-width:0;min-height:0}
.frame{position:relative;overflow:hidden;flex:0 0 auto}
/* ---------- 预览页:尽量复刻真实结构,令牌与层序和 base.css 一致 ---------- */
.page{
position:relative;width:1512px;height:900px;overflow:hidden;color:var(--tb-text);
transform-origin:top left;
background-color:var(--tb-bg);
background-image:var(--tb-page-pattern),var(--tb-deco),var(--tb-page-image);
background-size:var(--tb-page-pattern-size),var(--tb-deco-size),cover;
background-repeat:repeat,no-repeat,no-repeat,no-repeat,no-repeat,no-repeat,no-repeat,no-repeat;
background-position:left top,var(--tb-deco-position),center center;
background-attachment:scroll,fixed,fixed,fixed,fixed,fixed,fixed,fixed;
}
.page .nav{height:60px;display:flex;align-items:center;gap:18px;padding:0 24px;background:var(--tb-navbar);border-bottom:1px solid var(--tb-border)}
.page .brand{font-weight:700;letter-spacing:.04em}
.page .nav a{color:var(--tb-text-secondary);text-decoration:none;font-size:13px}
.page .nav .spacer{flex:1}
.page .btn{background:var(--tb-primary);color:#fff;border:none;border-radius:8px;padding:7px 14px;font-size:13px}
.page .btn.ghost{background:transparent;color:var(--tb-text-secondary);border:1px solid var(--tb-border)}
.page .wrap{width:750px;margin:24px auto 0}
.page .card{background:var(--tb-surface);border:1px solid var(--tb-border);border-radius:var(--tb-radius);box-shadow:var(--tb-shadow);padding:18px 20px;margin-bottom:14px}
.page .hero{display:flex;gap:14px;align-items:center}
.page .avatar{width:56px;height:56px;border-radius:50%;background:var(--tb-primary-weak);border:1px solid var(--tb-border)}
.page .name{font-size:16px;font-weight:600}
.page .meta{font-size:12px;color:var(--tb-text-muted);margin-top:2px}
.page .chips{display:flex;gap:8px;margin:14px 0 2px;flex-wrap:wrap}
.page .chip{font-size:12px;padding:3px 10px;border-radius:999px;background:var(--tb-surface-2);color:var(--tb-text-secondary);border:1px solid var(--tb-border)}
.page .chip.on{background:var(--tb-primary-weak);color:var(--tb-primary);border-color:transparent}
.page .q{padding:12px 0;border-top:1px solid var(--tb-border)}
.page .q p{margin:0;font-size:13px}
.page .q time{font-size:11px;color:var(--tb-text-muted)}
.page .ans{margin-top:8px;padding:10px 12px;border-radius:8px;background:var(--tb-surface-2);font-size:13px;color:var(--tb-text-secondary)}
.page .foot{width:750px;margin:18px auto 0;display:flex;gap:14px;justify-content:flex-end;font-size:11px;color:var(--tb-text-muted);padding-bottom:24px}
.page a{color:var(--tb-primary)}
/* 拖拽热区 */
.handle{position:absolute;border:1px dashed transparent;border-radius:6px;cursor:move;z-index:5}
.handle:hover{border-color:rgba(92,155,255,.85);background:rgba(92,155,255,.10)}
.handle .tag{position:absolute;left:0;top:-18px;font-size:11px;color:var(--ui-accent);opacity:0;white-space:nowrap}
.handle:hover .tag{opacity:1}
.out{flex:0 0 auto;padding:8px 18px 12px;font-size:11px;color:var(--ui-text2);white-space:pre-wrap;word-break:break-all;min-height:18px}
.empty{position:absolute;inset:0;display:flex;flex-direction:column;gap:6px;align-items:center;justify-content:center;
color:#7c8a9c;font-size:13px;pointer-events:none;z-index:6;text-align:center}
.empty b{color:#a9b8ca;font-weight:600}
.bad{color:#ff7676}
.ok{color:var(--ui-ok)}
</style>
</head>
<body>
<header>
<h1>TamaBox · 主题生成器</h1>
<span class="hint">选图 → 调位置 → 导出 zip,解压到项目根目录即可</span>
<div class="right">
<button id="btn-demo" class="ghost">载入示例</button>
<button id="btn-export" class="primary">导出 zip</button>
</div>
</header>
<main>
<aside class="panel">
<section>
<h2>1 · 基本</h2>
<label>slug(小写字母 / 数字 / 连字符,= css 文件名)</label>
<input type="text" id="slug" value="my-theme">
<label>显示名(写进 README,不影响运行)</label>
<input type="text" id="name" value="我的主题">
<div class="meta">生成路径:<code>static/css/themes/&lt;slug&gt;.css</code></div>
</section>
<section>
<h2>2 · 图片</h2>
<div class="drop" id="drop-scene">背景图(16:9,会被压成 1920×1080)</div>
<div class="drop" id="drop-scene-m">背景图 · 移动端(可选,竖版)</div>
<div class="drop" id="drop-char">人物(透明底 PNG/WebP)</div>
<div class="drop" id="drop-prop">道具 / 摆件(可选,透明底)</div>
<input type="file" id="file" accept="image/*" multiple hidden>
<div class="thumbs" id="thumbs"></div>
<div class="meta" id="img-meta">建议:人物底边贴画布底、脚踩地;背景左侧前景留空地。</div>
</section>
<section>
<h2>3 · 位置</h2>
<label>人物高度 <output id="o-charh">400</output>px</label>
<input type="range" id="charh" min="160" max="640" value="400" step="10">
<div class="row">
<div><label>离底边 <output id="o-charb">36</output>px</label><input type="range" id="charb" min="-40" max="200" value="36" step="2"></div>
<div><label>左右微调 <output id="o-chardx">0</output></label><input type="range" id="chardx" min="-200" max="200" value="0" step="2"></div>
</div>
<label>人物接触阴影 <input type="range" id="charsh" min="0" max="60" value="30" step="2"></label>
<label style="margin-top:14px"><input type="checkbox" id="prop-on" checked> 启用道具</label>
<label>道具高度 <output id="o-proph">259</output>px</label>
<input type="range" id="proph" min="80" max="520" value="259" step="1">
<div class="row">
<div><label>离底边 <output id="o-propb">20</output>px</label><input type="range" id="propb" min="-40" max="200" value="20" step="2"></div>
<div><label>相对人物左移 <output id="o-propdx">110</output>px</label><input type="range" id="propdx" min="-200" max="400" value="110" step="2"></div>
</div>
<div class="meta">道具默认放在人物身后(层级更低),用「左移」让它从人物身侧露出来。</div>
</section>
<section>
<h2>4 · 光与雾</h2>
<label>窗光强度 <output id="o-beam">0.50</output></label>
<input type="range" id="beam" min="0" max="1" value="0.5" step="0.05">
<label>地面光斑 <output id="o-pool">0.80</output></label>
<input type="range" id="pool" min="0" max="1" value="0.8" step="0.05">
<label>底部压深 <output id="o-vig">0.10</output></label>
<input type="range" id="vig" min="0" max="0.4" value="0.1" step="0.02">
<div class="meta">背景压得越白(≈235~245),光斑越要轻;纯白叠纯白等于没画。</div>
</section>
<section>
<h2>5 · 配色</h2>
<div class="row">
<div><label>页面底色</label><input type="color" id="c-bg" value="#eef3f9"></div>
<div><label>主色</label><input type="color" id="c-primary" value="#4a7fc1"></div>
</div>
<div class="row">
<div><label>卡片底</label><input type="color" id="c-surface" value="#ffffff"></div>
<div><label>正文色</label><input type="color" id="c-text" value="#222d3b"></div>
</div>
<label style="margin-top:10px"><input type="checkbox" id="auto-dark" checked> 自动推导深色配色</label>
<div class="meta">取消勾选后可手动改深色:</div>
<div class="row" id="dark-row" style="opacity:.45;pointer-events:none">
<div><label>深色底</label><input type="color" id="c-dbg" value="#141a22"></div>
<div><label>深色主色</label><input type="color" id="c-dprimary" value="#6fa3e0"></div>
</div>
</section>
</aside>
<section class="stage" style="padding:0;border:none">
<div class="stagebar">
<div class="seg" id="seg-w">
<button data-w="1512" class="on">1512</button>
<button data-w="1280">1280</button>
<button data-w="1024">1024</button>
</div>
<div class="seg" id="seg-theme">
<button data-theme="light" class="on">浅色</button>
<button data-theme="dark">深色</button>
</div>
<span class="grow"></span>
<span class="hint" id="stage-hint">拖虚线框调整位置</span>
</div>
<div class="canvas" id="canvas">
<div class="frame" id="frame">
<div class="page" id="page">
<div class="nav">
<span class="brand">TamaBox</span>
<a href="#">探索</a><a href="#">规则</a>
<span class="spacer"></span>
<button class="btn ghost">登录</button><button class="btn">注册</button>
</div>
<div class="wrap">
<div class="card hero">
<div class="avatar"></div>
<div><div class="name">狼的提问箱</div><div class="meta">@admin6 · 收到 128 个问题 · 回答 96 个</div></div>
</div>
<div class="chips"><span class="chip on">全部</span><span class="chip">已答</span><span class="chip">待答</span></div>
<div class="card">
<div class="q"><p>最近在看什么书?</p><time>2 小时前</time>
<div class="ans">《雪国》——冷得像今天的风。</div></div>
<div class="q"><p>怎么才能不被议题绑架?</p><time>5 小时前</time></div>
<div class="q"><p>有推荐的通勤耳机吗</p><time>昨天</time></div>
</div>
</div>
<div class="foot"><span>社区规则</span><span>备案号</span><span>已运行 412 天</span></div>
<div class="empty" id="empty"><b>先上传一张背景图</b><span>或点右上角「载入示例」先看效果</span></div>
<div class="handle" id="h-char"><span class="tag">人物</span></div>
<div class="handle" id="h-prop"><span class="tag">道具</span></div>
</div>
</div>
</div>
<div class="out" id="out"></div>
</section>
</main>
<input type="file" id="pick" accept="image/*" hidden>
<script>
'use strict';
/* =========================================================================
状态
========================================================================= */
const S = {
slug: 'my-theme',
name: '我的主题',
viewport: 1512,
dark: false,
scene: null, // {url,w,h,kb,blob}
sceneMobile: null,
char: null,
prop: null,
pos: { charH: 400, charB: 36, charDx: 0, charShadow: 30,
propH: 259, propB: 20, propDx: 110, propOn: true },
air: { beam: 0.5, pool: 0.8, vig: 0.10 },
colors: { bg:'#eef3f9', primary:'#4a7fc1', surface:'#ffffff', text:'#222d3b',
dbg:'#141a22', dprimary:'#6fa3e0', autoDark:true }
};
const $ = (id) => document.getElementById(id);
const elPage = $('page'), elFrame = $('frame'), elCanvas = $('canvas');
/* =========================================================================
小工具
========================================================================= */
const clamp = (v,a,b)=>Math.min(b,Math.max(a,v));
const hex2rgb = (h)=>{ h=h.replace('#',''); if(h.length===3) h=h.split('').map(c=>c+c).join('');
return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)]; };
const rgb2hex = (r,g,b)=>'#'+[r,g,b].map(v=>clamp(Math.round(v),0,255).toString(16).padStart(2,'0')).join('');
const rgba = (h,a)=>`rgba(${hex2rgb(h).join(', ')}, ${a})`;
// 亮度权重(用于自动推导深色配色)
function shade(hex, f){
const [r,g,b]=hex2rgb(hex);
if (f>=0) return rgb2hex(r+(255-r)*f, g+(255-g)*f, b+(255-b)*f);
return rgb2hex(r*(1+f), g*(1+f), b*(1+f));
}
function mix(a,b,t){ const A=hex2rgb(a),B=hex2rgb(b); return rgb2hex(A[0]+(B[0]-A[0])*t, A[1]+(B[1]-A[1])*t, A[2]+(B[2]-A[2])*t); }
/* =========================================================================
图片处理:上传 → 目标尺寸 WebP
========================================================================= */
async function loadImg(file){
const url = URL.createObjectURL(file);
const img = new Image();
await new Promise((res,rej)=>{ img.onload=res; img.onerror=rej; img.src=url; });
return img;
}
// canvas → {dataURL, bytes}:同步完成,导出时不用等 Promise
function canvasAsset(cv, q){
const url = cv.toDataURL('image/webp', q);
const b64 = url.split(',')[1] || '';
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i=0;i<bin.length;i++) bytes[i] = bin.charCodeAt(i);
return { url, bytes, w: cv.width, h: cv.height, kb: Math.round(bytes.length/1024) };
}
async function webpFrom(file, opt){
const img = await loadImg(file);
const { mode='cover', w=0, h=0, q=0.86, shadow=0 } = opt;
// contain:画布按内容实际尺寸裁(否则会留下大片透明边,导出后宽高比全错)
let cw, chh, scale;
if (mode==='cover'){
scale = Math.max(w/img.width, h/img.height); cw = w; chh = h;
} else {
scale = Math.min(h/img.height, (w||1e9)/img.width);
cw = Math.round(img.width*scale); chh = Math.round(img.height*scale);
}
const cv = document.createElement('canvas');
cv.width = cw; cv.height = chh;
const ctx = cv.getContext('2d');
const dw = img.width*scale, dh = img.height*scale;
const dx = (cw-dw)/2, dy = (chh-dh)/2;
if (shadow>0){ // 道具的接触阴影烘进图里
ctx.save();
ctx.filter = `blur(${Math.round(shadow*0.45)}px)`;
ctx.fillStyle = 'rgba(70,88,112,0.42)';
ctx.beginPath();
ctx.ellipse(cw/2, chh-Math.round(shadow*0.55), cw*0.42, shadow*0.5, 0, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
ctx.drawImage(img, dx, dy, dw, dh);
return canvasAsset(cv, q);
}
/* =========================================================================
配色(浅/深两套,10 个必需令牌 + navbar / radius / shadow)
========================================================================= */
function palette(){
const c = S.colors;
const light = {
bg: c.bg, surface: c.surface, surface2: mix(c.bg, c.surface, 0.5),
surfaceHover: mix(c.surface, c.primary, 0.06),
navbar: rgba(c.bg, 0.92),
border: mix(c.bg, c.text, 0.12),
text: c.text,
text2: mix(c.text, c.bg, 0.35),
text3: mix(c.text, c.bg, 0.58),
primary: c.primary,
primaryWeak: rgba(c.primary, 0.11),
};
let dark;
if (c.autoDark){
const bg2 = mix('#101418', c.bg, 0.12);
dark = {
bg: bg2, surface: shade(bg2, 0.05), surface2: shade(bg2, 0.09),
surfaceHover: rgba(shade(c.primary,0.15), 0.12),
navbar: bg2,
border: shade(bg2, 0.14),
text: mix('#ffffff', light.text, 0.10),
text2: mix('#ffffff', light.text2, 0.35),
text3: mix('#ffffff', light.text3, 0.55),
primary: shade(c.primary, 0.22),
primaryWeak: rgba(shade(c.primary,0.22), 0.16),
};
} else {
dark = { bg:c.dbg, surface: shade(c.dbg,0.05), surface2: shade(c.dbg,0.09),
surfaceHover: rgba(c.dprimary,0.12), navbar:c.dbg, border: shade(c.dbg,0.14),
text:'#e3eaf4', text2: mix('#e3eaf4', c.dbg, 0.35), text3: mix('#e3eaf4', c.dbg, 0.55),
primary:c.dprimary, primaryWeak: rgba(c.dprimary,0.16) };
}
return { light, dark };
}
/* =========================================================================
背景装饰层(固定 6 槽位:人 / 人影 / 道具 / 光柱 / 光斑 / 压深)
注意:background-position 的百分比相对 (容器宽 - 图宽),所以这里把
设计稿里的「左沿像素」反解成 calc(50% - Npx) 的 N。
========================================================================= */
function charDims(){
if (!S.char) return { w: 0, h: S.pos.charH };
return { w: Math.round(S.char.w * S.pos.charH / S.char.h), h: S.pos.charH };
}
function propDims(){
if (!S.prop) return { w: 0, h: S.pos.propH };
return { w: Math.round(S.prop.w * S.pos.propH / S.prop.h), h: S.pos.propH };
}
// 设计稿里人物左沿:居中束左边界再往里退 90px(与卡片留白一致)
function baseLeft(){ return (S.viewport - 750)/2 - 90 - charDims().w; }
function offsetN(leftPx, wImg){ return Math.round((S.viewport - wImg)/2 - leftPx); }
function decoCSS(theme){
const p = palette()[theme];
const c = charDims(), pr = propDims();
const layers = [], sizes = [], poss = [];
if (S.char){
const left = baseLeft() + S.pos.charDx;
layers.push(`url("IMAGES/character.webp")`);
sizes.push(`auto ${S.pos.charH}px`);
poss.push(`left calc(50% - ${offsetN(left, c.w)}px) bottom ${S.pos.charB}px`);
if (S.pos.charShadow > 0){
layers.push(`radial-gradient(ellipse at center, rgba(63, 96, 138, ${(S.pos.charShadow/100).toFixed(2)}) 0%, rgba(63, 96, 138, 0) 72%)`);
sizes.push(`${Math.round(c.w*0.72)}px ${Math.round(c.w*0.11)}px`);
poss.push(`left calc(50% - ${offsetN(left + Math.round(c.w*0.14), Math.round(c.w*0.72))}px) bottom ${Math.max(0, S.pos.charB-7)}px`);
} else { layers.push('none'); sizes.push('auto'); poss.push('left top'); }
} else {
layers.push('none'); sizes.push('auto'); poss.push('left top');
layers.push('none'); sizes.push('auto'); poss.push('left top');
}
if (S.prop && S.pos.propOn){
const left = baseLeft() + S.pos.charDx - S.pos.propDx;
layers.push(`url("IMAGES/prop.webp")`);
sizes.push(`auto ${S.pos.propH}px`);
poss.push(`left calc(50% - ${offsetN(left, pr.w)}px) bottom ${S.pos.propB}px`);
} else { layers.push('none'); sizes.push('auto'); poss.push('left top'); }
const beam = S.air.beam, pool = S.air.pool, vig = S.air.vig;
layers.push(`linear-gradient(112deg, rgba(255, 250, 236, ${(beam*0.5).toFixed(2)}) 0%, rgba(255, 250, 236, ${(beam*0.22).toFixed(2)}) 20%, rgba(255, 250, 236, 0) 44%)`);
sizes.push('100% 100%'); poss.push('left top');
layers.push(`radial-gradient(ellipse 560px 170px at 28% 94%, rgba(255, 248, 228, ${pool.toFixed(2)}) 0%, rgba(255, 248, 228, 0) 72%)`);
sizes.push('100% 100%'); poss.push('left top');
layers.push(`linear-gradient(0deg, rgba(110, 128, 156, ${vig.toFixed(2)}) 0%, rgba(110, 128, 156, 0) 24%)`);
sizes.push('100% 100%'); poss.push('left top');
return { layers: layers.join(',\n '), sizes: sizes.join(', '), positions: poss.join(',\n ') };
}
/* =========================================================================
预览:把令牌与层写进 .page
========================================================================= */
function render(){
const { light, dark } = palette();
const t = S.dark ? dark : light;
const rect = { w: S.viewport, h: 900 };
elPage.style.width = rect.w + 'px';
elPage.style.height = rect.h + 'px';
elPage.style.setProperty('--tb-bg', t.bg);
elPage.style.setProperty('--tb-surface', t.surface);
elPage.style.setProperty('--tb-surface-2', t.surface2);
elPage.style.setProperty('--tb-surface-hover', t.surfaceHover);
elPage.style.setProperty('--tb-navbar', t.navbar);
elPage.style.setProperty('--tb-border', t.border);
elPage.style.setProperty('--tb-text', t.text);
elPage.style.setProperty('--tb-text-secondary', t.text2);
elPage.style.setProperty('--tb-text-muted', t.text3);
elPage.style.setProperty('--tb-primary', t.primary);
elPage.style.setProperty('--tb-primary-weak', t.primaryWeak);
elPage.style.setProperty('--tb-radius', '12px');
elPage.style.setProperty('--tb-radius-sm', '8px');
elPage.style.setProperty('--tb-radius-xs', '6px');
elPage.style.setProperty('--tb-shadow', '0 1px 2px rgba(16,24,40,.05), 0 18px 44px -22px rgba(38,56,82,.30)');
elPage.style.setProperty('--tb-shadow-hover', '0 1px 2px rgba(16,24,40,.07), 0 24px 56px -24px rgba(38,56,82,.38)');
// 深色不加载图片(和导出结果一致:只有浅色块用图)
const darkBg = `linear-gradient(180deg, ${shade(t.bg,0.03)} 0%, ${t.bg} 60%, ${shade(t.bg,-0.03)} 100%)`;
elPage.style.setProperty('--tb-page-image', S.dark ? darkBg : (S.scene ? `url("${S.scene.url}")` : 'none'));
elPage.style.setProperty('--tb-page-pattern', 'none');
elPage.style.setProperty('--tb-page-pattern-size', 'auto');
const d = decoCSS(S.dark ? 'dark' : 'light');
// 预览里把 IMAGES/ 占位换成本地 objectURL
if (S.dark){
// 深色块里 deco 恒为 none(导出也是这样),避免白天插画压深底
elPage.style.setProperty('--tb-deco', 'none, none, none, none, none, none');
elPage.style.setProperty('--tb-deco-size', 'auto, auto, auto, auto, auto, auto');
elPage.style.setProperty('--tb-deco-position', 'left top, left top, left top, left top, left top, left top');
} else {
const dl = d.layers
.replace('url("IMAGES/character.webp")', S.char ? `url("${S.char.url}")` : 'none')
.replace('url("IMAGES/prop.webp")', S.prop ? `url("${S.prop.url}")` : 'none');
elPage.style.setProperty('--tb-deco', dl);
elPage.style.setProperty('--tb-deco-size', d.sizes);
elPage.style.setProperty('--tb-deco-position', d.positions);
}
$('empty').style.display = S.scene ? 'none' : 'flex';
syncHandles();
fitStage();
}
function syncHandles(){
const c = charDims(), pr = propDims();
const left = baseLeft() + S.pos.charDx;
const hc = $('h-char');
if (S.char){
hc.style.display='block';
hc.style.left = left+'px';
hc.style.width = c.w+'px';
hc.style.top = (900 - S.pos.charB - c.h)+'px';
hc.style.height = c.h+'px';
} else hc.style.display='none';
const hp = $('h-prop');
if (S.prop && S.pos.propOn){
const pl = left - S.pos.propDx;
hp.style.display='block';
hp.style.left = pl+'px';
hp.style.width = pr.w+'px';
hp.style.top = (900 - S.pos.propB - pr.h)+'px';
hp.style.height = pr.h+'px';
} else hp.style.display='none';
}
function fitStage(){
const availW = elCanvas.clientWidth - 36;
const availH = elCanvas.clientHeight - 36;
const k = Math.min(1, availW / S.viewport, availH / 900);
elPage.style.transform = `scale(${k})`;
elFrame.style.width = Math.round(S.viewport*k)+'px';
elFrame.style.height = Math.round(900*k)+'px';
elFrame.dataset.k = k;
}
/* =========================================================================
拖拽
========================================================================= */
function bindDrag(handle, which){
let start = null;
handle.addEventListener('pointerdown', e=>{
const c = charDims(), pr = propDims();
start = { x:e.clientX, y:e.clientY, dx:S.pos.charDx,
pb: which==='char'?S.pos.charB:S.pos.propB,
pdx:S.pos.propDx };
handle.setPointerCapture(e.pointerId);
e.preventDefault();
});
handle.addEventListener('pointermove', e=>{
if(!start) return;
const k = parseFloat(elFrame.dataset.k||'1');
const dx = (e.clientX-start.x)/k, dy = (e.clientY-start.y)/k;
if (which==='char'){
S.pos.charDx = clamp(start.dx + dx, -300, 300);
S.pos.charB = clamp(start.pb - dy, -40, 300);
$('chardx').value = S.pos.charDx; $('o-chardx').value = Math.round(S.pos.charDx);
$('charb').value = S.pos.charB; $('o-charb').value = Math.round(S.pos.charB);
} else {
S.pos.propDx = clamp(start.pdx - dx, -200, 500);
S.pos.propB = clamp(start.pb - dy, -40, 300);
$('propdx').value = S.pos.propDx; $('o-propdx').value = Math.round(S.pos.propDx);
$('propb').value = S.pos.propB; $('o-propB').value = Math.round(S.pos.propB);
$('o-propb').value = Math.round(S.pos.propB);
}
render();
});
const end = e=>{ start=null; try{handle.releasePointerCapture(e.pointerId);}catch(_){} };
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
}
bindDrag($('h-char'),'char');
bindDrag($('h-prop'),'prop');
/* =========================================================================
CSS 生成
========================================================================= */
function buildCSS(){
const { light, dark } = palette();
const d = decoCSS('light');
const box = (t) => ` --tb-bg: ${t.bg};
--tb-surface: ${t.surface};
--tb-surface-2: ${t.surface2};
--tb-surface-hover: ${t.surfaceHover};
--tb-navbar: ${t.navbar};
--tb-border: ${t.border};
--tb-text: ${t.text};
--tb-text-secondary: ${t.text2};
--tb-text-muted: ${t.text3};
--tb-primary: ${t.primary};
--tb-primary-weak: ${t.primaryWeak};
--tb-radius: 12px;
--tb-radius-sm: 8px;
--tb-radius-xs: 6px;
--tb-shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 18px 44px -22px rgba(38, 56, 82, 0.30);
--tb-shadow-hover: 0 1px 2px rgba(16, 24, 40, 0.07), 0 24px 56px -24px rgba(38, 56, 82, 0.38);
--tb-font: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;`;
const darkBg = S.scene ? `url("IMAGES/scene-dark.webp")` : `linear-gradient(180deg, ${shade(dark.bg,0.03)} 0%, ${dark.bg} 60%, ${shade(dark.bg,-0.03)} 100%)`;
return `/* 由 TamaBox 主题生成器导出:${S.name}(${S.slug})
图片放在 static/images/themes/${S.slug}/,本文件放在 static/css/themes/${S.slug}.css。
新增后记得把 ${S.slug} 加进 conf/app.ini 的 [theme] available,并重新编译部署。 */
html[data-theme-style="${S.slug}"] {
${box(light)}
--tb-page-image: ${S.scene ? 'url("IMAGES/scene-desktop.webp")' : 'none'};
--tb-page-pattern: none;
--tb-page-pattern-size: auto;
--tb-deco: ${d.layers};
--tb-deco-size: ${d.sizes};
--tb-deco-position: ${d.positions};
}
html[data-theme="dark"][data-theme-style="${S.slug}"] {
${box(dark)}
--tb-page-image: ${darkBg};
--tb-page-pattern: none;
--tb-page-pattern-size: auto;
--tb-deco: none, none, none, none, none, none;
--tb-deco-size: auto, auto, auto, auto, auto, auto;
--tb-deco-position: left top, left top, left top, left top, left top, left top;
}
`;
}
/* =========================================================================
ZIP(STORE,无依赖)
========================================================================= */
const CRC_TABLE = (()=>{ const t=new Uint32Array(256);
for(let n=0;n<256;n++){ let c=n; for(let k=0;k<8;k++) c = c&1 ? 0xEDB88320 ^ (c>>>1) : c>>>1; t[n]=c>>>0; }
return t; })();
function crc32(buf){ let c=0xFFFFFFFF; for(let i=0;i<buf.length;i++) c = CRC_TABLE[(c^buf[i])&0xFF] ^ (c>>>8); return (c^0xFFFFFFFF)>>>0; }
function u16(n){ return [n&255,(n>>8)&255]; }
function u32(n){ return [n&255,(n>>8)&255,(n>>16)&255,(n>>24)&255]; }
function zipStore(files){
const enc = new TextEncoder();
const parts = [], central = [];
let offset = 0;
const now = new Date();
const dosTime = ((now.getHours()<<11)|(now.getMinutes()<<5)|(now.getSeconds()/2))&0xFFFF;
const dosDate = (((now.getFullYear()-1980)<<9)|((now.getMonth()+1)<<5)|now.getDate())&0xFFFF;
for (const f of files){
const data = typeof f.data === 'string' ? enc.encode(f.data) : f.data;
const name = enc.encode(f.name);
const crc = crc32(data);
const lh = [].concat(u32(0x04034b50), u16(20), u16(0x0800), u16(0), u16(dosTime), u16(dosDate),
u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0));
const head = new Uint8Array(lh);
parts.push(head, name, data);
central.push({ name, crc, size:data.length, offset });
offset += head.length + name.length + data.length;
}
const cdParts = [];
let cdSize = 0;
for (const c of central){
const ch = [].concat(u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(0), u16(dosTime), u16(dosDate),
u32(c.crc), u32(c.size), u32(c.size), u16(c.name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(c.offset));
const head = new Uint8Array(ch);
cdParts.push(head, c.name);
cdSize += head.length + c.name.length;
}
const end = new Uint8Array([].concat(u32(0x06054b50), u16(0), u16(0), u16(central.length), u16(central.length),
u32(cdSize), u32(offset), u16(0)));
const all = [...parts, ...cdParts, end];
const total = all.reduce((n,p)=>n+p.length,0);
const out = new Uint8Array(total);
let p=0; for(const a of all){ out.set(a,p); p+=a.length; }
return out;
}
/* =========================================================================
导出
========================================================================= */
async function exportZip(){
const slug = S.slug.trim();
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(slug)){ out('slug 不合法:只允许小写字母/数字/连字符,且不能以连字符开头。','bad'); return; }
const files = [];
const imgDir = `static/images/themes/${slug}/`;
const css = buildCSS()
.replaceAll('IMAGES/', imgDir.replace('static/images/themes/','/static/images/themes/'));
files.push({ name:`static/css/themes/${slug}.css`, data: css });
const add = (asset, name)=>{ if(asset) files.push({ name: imgDir+name, data: asset.bytes }); };
add(S.scene,'scene-desktop.webp');
add(S.sceneMobile,'scene-mobile.webp');
add(S.char,'character.webp');
add(S.prop,'prop.webp');
files.push({ name:`INSTALL-${slug}.txt`, data: installText(slug) });
const bin = zipStore(files);
const url = URL.createObjectURL(new Blob([bin],{type:'application/zip'}));
const a = document.createElement('a');
a.href = url; a.download = `theme-${slug}.zip`; a.click();
setTimeout(()=>URL.revokeObjectURL(url), 4000);
out(`已导出 theme-${slug}.zip · ${files.length} 个文件 · ${(bin.length/1024).toFixed(0)}KB`,'ok');
}
function installText(slug){
return `主题 ${S.name}(${slug})安装说明
==================================================
1) 解压到你项目的根目录(与 go.mod 同级)。
会得到:
static/css/themes/${slug}.css
static/images/themes/${slug}/
${S.scene?'scene-desktop.webp\n ':''}${S.sceneMobile?'scene-mobile.webp\n ':''}${S.char?'character.webp\n ':''}${S.prop?'prop.webp\n ':''}
2) 登记主题(conf/app.ini):
[theme]
default = default
available = default,${slug}
⚠️ 没登记的话设置页看不到这个主题——现象和"部署没生效"一模一样。
3) 改静态版本号,让老访客不吃缓存:
internal/conf/static.go → StaticVersion(日期串 +1)
4) 重新编译并重启(静态资源是编译期 embed,只重启不重编不会变):
go build . && ./TamaBox web
5) 箱主在「账号设置 → 主题外观」里选择 ${S.name}。
说明
--------------------------------------------------
· 图片不使用 StaticVersion 缓存键(max-age 1 天)。以后换图要给 URL 带上 ?v=。
· 深色模式不加载图片,只走深色令牌,所以深色下不会出现白天场景。
· 若显示尺寸与素材比例不符,改 css 里的 --tb-deco-size(auto <高度>px)。
`;
}
function out(msg, cls){ const el=$('out'); el.innerHTML = msg; el.className='out '+(cls||''); }
/* =========================================================================
上传
========================================================================= */
let pendingSlot = null;
document.querySelectorAll('.drop').forEach(el=>{
el.addEventListener('click', ()=>{ pendingSlot = el.id.replace('drop-',''); $('file').click(); });
});
$('file').addEventListener('change', async e=>{
const f = e.target.files[0]; if(!f) return;
await handleFile(pendingSlot, f);
e.target.value='';
});
async function handleFile(slot, file){
out('处理图片…');
if (slot==='scene') S.scene = await webpFrom(file,{mode:'cover', w:1920, h:1080, q:0.80});
if (slot==='scene-m') S.sceneMobile = await webpFrom(file,{mode:'cover', w:720, h:1003, q:0.80});
if (slot==='char') S.char = await webpFrom(file,{mode:'contain', w:1200, h:Math.round(S.pos.charH*1.5), q:0.88});
if (slot==='prop') S.prop = await webpFrom(file,{mode:'contain', w:1600, h:Math.round(S.pos.propH*1.5), q:0.88, shadow:10});
refreshDrops(); render();
out('');
}
function refreshDrops(){
const map = { 'drop-scene':S.scene, 'drop-scene-m':S.sceneMobile, 'drop-char':S.char, 'drop-prop':S.prop };
const label = { 'drop-scene':'背景图(桌面)', 'drop-scene-m':'背景图(移动端)', 'drop-char':'人物', 'drop-prop':'道具' };
for (const [id, val] of Object.entries(map)){
const el = $(id);
el.classList.toggle('filled', !!val);
el.textContent = val ? `${label[id]} ✓ ${val.w*0+Math.round(val.kb)}KB` : (id==='drop-scene'?'背景图(16:9,会被压成 1920×1080)':
id==='drop-scene-m'?'背景图 · 移动端(可选,竖版)': id==='drop-char'?'人物(透明底 PNG/WebP)':'道具 / 摆件(可选,透明底)');
}
const t = $('thumbs'); t.innerHTML='';
for (const [k,v] of Object.entries({scene:S.scene, mobile:S.sceneMobile, char:S.char, prop:S.prop})){
if(!v) continue;
const d=document.createElement('div'); d.className='thumb';
d.innerHTML = `<img src="${v.url}" alt="${k}">`;
t.appendChild(d);
}
}
/* =========================================================================
控件绑定
========================================================================= */
function bindRange(id, get, set, fmt){
const el=$(id), o=$('o-'+id.replace(/^c-/,''));
el.addEventListener('input', ()=>{ set(parseFloat(el.value)); if(o) o.value = fmt?fmt(el.value):el.value; render(); });
}
$('slug').addEventListener('input', e=>{ S.slug=e.target.value; });
$('name').addEventListener('input', e=>{ S.name=e.target.value; });
$('charh').addEventListener('input', e=>{ S.pos.charH=+e.target.value; $('o-charh').value=e.target.value; render(); });
$('charb').addEventListener('input', e=>{ S.pos.charB=+e.target.value; $('o-charb').value=e.target.value; render(); });
$('chardx').addEventListener('input', e=>{ S.pos.charDx=+e.target.value; $('o-chardx').value=e.target.value; render(); });
$('charsh').addEventListener('input', e=>{ S.pos.charShadow=+e.target.value; render(); });
$('prop-on').addEventListener('change', e=>{ S.pos.propOn=e.target.checked; render(); });
$('proph').addEventListener('input', e=>{ S.pos.propH=+e.target.value; $('o-proph').value=e.target.value; render(); });
$('propb').addEventListener('input', e=>{ S.pos.propB=+e.target.value; $('o-propb').value=e.target.value; render(); });
$('propdx').addEventListener('input', e=>{ S.pos.propDx=+e.target.value; $('o-propdx').value=e.target.value; render(); });
$('beam').addEventListener('input', e=>{ S.air.beam=+e.target.value; $('o-beam').value=(+e.target.value).toFixed(2); render(); });
$('pool').addEventListener('input', e=>{ S.air.pool=+e.target.value; $('o-pool').value=(+e.target.value).toFixed(2); render(); });
$('vig').addEventListener('input', e=>{ S.air.vig=+e.target.value; $('o-vig').value=(+e.target.value).toFixed(2); render(); });
[['c-bg','bg'],['c-primary','primary'],['c-surface','surface'],['c-text','text'],['c-dbg','dbg'],['c-dprimary','dprimary']]
.forEach(([id,key])=> $(id).addEventListener('input', e=>{ S.colors[key]=e.target.value; render(); }));
$('auto-dark').addEventListener('change', e=>{
S.colors.autoDark = e.target.checked;
$('dark-row').style.opacity = e.target.checked? .45 : 1;
$('dark-row').style.pointerEvents = e.target.checked? 'none':'auto';
render();
});
document.querySelectorAll('#seg-w button').forEach(b=>b.addEventListener('click',()=>{
document.querySelectorAll('#seg-w button').forEach(x=>x.classList.remove('on'));
b.classList.add('on'); S.viewport=+b.dataset.w; render();
}));
document.querySelectorAll('#seg-theme button').forEach(b=>b.addEventListener('click',()=>{
document.querySelectorAll('#seg-theme button').forEach(x=>x.classList.remove('on'));
b.classList.add('on'); S.dark = b.dataset.theme==='dark'; render();
}));
$('btn-export').addEventListener('click', exportZip);
window.addEventListener('resize', fitStage);
/* =========================================================================
示例:没有素材也能先看效果(用 canvas 画渐变背景 + 一个剪影)
========================================================================= */
function loadDemo(){
try{
const bg = document.createElement('canvas'); bg.width=1920; bg.height=1080;
const g = bg.getContext('2d');
const grd = g.createLinearGradient(0,0,1920,1080);
grd.addColorStop(0,'#dfe7f1'); grd.addColorStop(.55,'#eef3f9'); grd.addColorStop(1,'#f7f9fb');
g.fillStyle=grd; g.fillRect(0,0,1920,1080);
g.fillStyle='rgba(180,196,214,.35)';
for(let i=0;i<7;i++){ g.fillRect(120+i*250, 470+ i*12, 190, 18); }
S.scene = canvasAsset(bg, .8);
const ch = document.createElement('canvas'); ch.width=270; ch.height=920;
const c = ch.getContext('2d');
c.fillStyle='#c4c3c6'; c.beginPath(); c.arc(135,150,86,0,7); c.fill(); // 头
c.fillStyle='#8abbc6'; c.beginPath(); c.ellipse(135,92,72,24,0,0,7); c.fill(); // 光环
c.fillStyle='#565e72'; c.fillRect(85,220,100,260); // 上身
c.fillStyle='#9aa3ad'; c.fillRect(85,470,100,150); // 裙
c.fillStyle='#ededed'; c.fillRect(95,610,34,180); c.fillRect(141,610,34,180); // 腿
c.fillStyle='#3f3f3f'; c.fillRect(88,780,50,30); c.fillRect(136,780,50,30); // 鞋
S.char = canvasAsset(ch, .9);
refreshDrops(); render(); out('已载入示例素材(可在「图片」区换成自己的)','ok');
}catch(err){ out('示例加载失败:'+err.message+'\n'+err.stack,'bad');
const pre=document.createElement('pre'); pre.id='selftest-err'; pre.textContent=String(err.stack||err);
document.body.appendChild(pre); }
}
$('btn-demo').addEventListener('click', loadDemo);
/* =========================================================================
自检:?selftest=1 时把 CSS 与 zip 的字节吐到 DOM,供无头浏览器校验
========================================================================= */
(function selftest(){
if (!location.search.includes('selftest')) return;
(()=>{
try { loadDemo(); document.title='demo:'+(S.char?S.char.w+'x'+S.char.h:'none'); }
catch(e){ document.title='demo-fail:'+e.message; }
const css = buildCSS();
const bin = zipStore([
{ name:'a.txt', data:'hello' },
{ name:`static/css/themes/${S.slug}.css`, data: css }
]);
let b64=''; const chunk=0x8000;
for(let i=0;i<bin.length;i+=chunk) b64 += String.fromCharCode.apply(null, bin.subarray(i,i+chunk));
const pre = document.createElement('pre');
pre.id='selftest';
pre.textContent = JSON.stringify({
css,
zipB64: btoa(b64),
zipLen: bin.length,
cssLen: css.length
});
document.body.appendChild(pre);
})();
})();
refreshDrops();
render();
if (location.search.includes('demo')) loadDemo();
if (location.search.includes('dark')){
S.dark = true;
document.querySelectorAll('#seg-theme button').forEach(b=>b.classList.toggle('on', b.dataset.theme==='dark'));
render();
}
</script>
</body>
</html>
+1
View File
@@ -1,5 +1,6 @@
/* ============================================================================ /* ============================================================================
主题底稿(scaffold)—— 复制本文件改名为 <slug>.css 即可新增一套主题。 主题底稿(scaffold)—— 复制本文件改名为 <slug>.css 即可新增一套主题。
不想手写 CSS 的话,用 dev/theme-builder/index.html 可视化生成(选图 / 拖位置 / 导出 zip)。
文件名即 slug:只允许小写字母、数字、连字符(如 sakura / ocean-mist)。 文件名即 slug:只允许小写字母、数字、连字符(如 sakura / ocean-mist)。
新增后需在 conf/app.ini 登记: 新增后需在 conf/app.ini 登记: