Files
tamabox/dev/theme-builder/index.html
T

1503 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>TamaBox 主题生成器</title>
<style>
:root{
--ui-bg:#0f1216; --ui-panel:#161b22; --ui-panel2:#1c232c; --ui-line:#28313d;
--ui-text:#dbe3ee; --ui-text2:#8b98a9; --ui-accent:#5b9bd5; --ui-ok:#4bb27d; --ui-bad:#d9636b;
--ui-warn:#d8a657;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:var(--ui-bg);color:var(--ui-text);
font:13px/1.55 -apple-system,"Segoe UI",system-ui,"Microsoft YaHei",sans-serif;
display:flex;flex-direction:column;overflow:hidden;
}
button,input,select{font:inherit;color:inherit}
button{
background:var(--ui-panel2);border:1px solid var(--ui-line);color:var(--ui-text);
border-radius:6px;padding:5px 10px;cursor:pointer;transition:.15s;
}
button:hover{background:#232c37;border-color:#3a4756}
button.primary{background:var(--ui-accent);border-color:var(--ui-accent);color:#08121c;font-weight:600}
button.primary:hover{background:#6fa9dd}
button.on{background:var(--ui-accent);border-color:var(--ui-accent);color:#08121c;font-weight:600}
input[type=text],input[type=number],select{
background:#0d1117;border:1px solid var(--ui-line);border-radius:6px;padding:4px 7px;width:100%;
}
input[type=number]{width:72px}
input[type=color]{
width:34px;height:26px;padding:0;border:1px solid var(--ui-line);border-radius:5px;background:none;cursor:pointer;
}
label.chk{font-size:11px;color:var(--ui-text2);display:inline-flex;gap:4px;align-items:center;font-weight:400}
h2{
font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ui-text2);
margin:0 0 8px;display:flex;align-items:center;justify-content:space-between;font-weight:600;
}
h2 .r{display:flex;gap:6px}
.bar{
flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:0 14px;height:48px;
background:var(--ui-panel);border-bottom:1px solid var(--ui-line);
}
.brand{font-weight:600;letter-spacing:.02em}
.bar .slug{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text2)}
.bar .slug input{width:150px}
.bar .spacer{flex:1}
.conn{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--ui-line)}
.conn.ok{color:var(--ui-ok);border-color:#2f5744}
.conn.bad{color:var(--ui-bad);border-color:#5a3036}
main{flex:1;display:grid;grid-template-columns:352px minmax(0,1fr);min-height:0}
.panel{overflow:auto;border-right:1px solid var(--ui-line);background:var(--ui-panel);padding:14px}
.panel section{margin-bottom:18px}
.scopes{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.sc{
text-align:left;padding:7px 9px;border-radius:7px;line-height:1.3;background:var(--ui-panel2);
border:1px solid var(--ui-line);
}
.sc b{display:block;font-size:12px;font-weight:600}
.sc span{font-size:10px;color:var(--ui-text2)}
.sc.on{border-color:var(--ui-accent);background:#16303f}
.sc.on b{color:#9fc8ea}
.sc .dirty{color:var(--ui-warn);font-size:10px}
.hint{font-size:11px;color:var(--ui-text2);margin:8px 0 0;line-height:1.5}
.layers{display:flex;flex-direction:column;gap:5px}
.ly{
display:flex;align-items:center;gap:7px;padding:6px 8px;border-radius:6px;cursor:pointer;
background:var(--ui-panel2);border:1px solid transparent;
}
.ly:hover{border-color:var(--ui-line)}
.ly.on{border-color:var(--ui-accent);background:#16303f}
.ly .dot{width:9px;height:9px;border-radius:3px;flex:0 0 auto;background:#4a5563}
.ly .nm{flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ly .k{font-size:10px;color:var(--ui-text2)}
.ly .x{opacity:.5;padding:0 4px}
.ly .x:hover{opacity:1;color:var(--ui-bad)}
.ly .ord{opacity:.55;padding:0 5px;font-size:11px;line-height:1;border-color:transparent;background:none}
.ly .ord:hover:not(:disabled){opacity:1;background:#232c37;border-color:var(--ui-line)}
.ly .ord:disabled{opacity:.18;cursor:default}
.editor{margin-top:10px;padding:10px;border-radius:8px;background:#12171e;border:1px solid var(--ui-line)}
.row{display:flex;align-items:center;gap:8px;margin-bottom:7px}
.row:last-child{margin-bottom:0}
.row > span{font-size:11px;color:var(--ui-text2);min-width:58px;flex:0 0 auto;white-space:nowrap}
.grid9{display:grid;grid-template-columns:repeat(3,24px);gap:3px}
.grid9 button{padding:0;height:22px;border-radius:4px;font-size:0}
.grid9 button.on{background:var(--ui-accent)}
.assets{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.as{
position:relative;aspect-ratio:1;border-radius:6px;overflow:hidden;background:#0b0f14;
border:1px solid var(--ui-line);cursor:pointer;
}
.as img{width:100%;height:100%;object-fit:contain;display:block}
.as.on{border-color:var(--ui-accent)}
.as .del{
position:absolute;top:1px;right:2px;font-size:11px;line-height:1;color:#fff;background:#0008;
border-radius:4px;padding:1px 3px;display:none;
}
.as:hover .del{display:block}
.as .meta{
position:absolute;inset:auto 0 0 0;font-size:9px;text-align:center;background:#000a;color:#cbd6e4;
padding:1px 0;
}
.pal{display:flex;flex-direction:column;gap:5px}
.pal .p{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--ui-text2)}
.pal .p input[type=text]{width:100%;font-size:11px;padding:2px 6px}
.stage{display:flex;flex-direction:column;min-width:0;min-height:0;background:#0b0e12}
.stagebar{
flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:7px 12px;
border-bottom:1px solid var(--ui-line);background:var(--ui-panel);font-size:11px;color:var(--ui-text2);
}
.stagebar .g{display:flex;gap:4px}
.stagebar .url{margin-left:auto;font-family:ui-monospace,Consolas,monospace;font-size:10px;color:#5f6c7c}
.canvas{flex:1;overflow:auto;padding:14px;min-height:0;min-width:0;position:relative;
display:flex;justify-content:center;align-items:center}
.frame{position:relative;overflow:hidden;border-radius:8px;background:#fff;box-shadow:0 12px 40px -12px #000a}
iframe{display:block;border:0;transform-origin:top left;background:#fff}
.out{
flex:0 0 auto;padding:7px 12px 9px;font-size:11px;color:var(--ui-text2);white-space:pre-wrap;
word-break:break-word;max-height:96px;overflow:auto;border-top:1px solid var(--ui-line);background:var(--ui-panel);
}
.out .e{color:var(--ui-bad)} .out .ok{color:var(--ui-ok)} .out .w{color:var(--ui-warn)}
.empty{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;align-items:center;justify-content:center;
color:#7c8a9c;font-size:13px;text-align:center;padding:24px;z-index:5;background:#0b0e12}
.empty b{color:#a9b8ca}
.empty code{background:#161b22;border:1px solid var(--ui-line);border-radius:5px;padding:6px 9px;
font-family:ui-monospace,Consolas,monospace;font-size:11px;color:#9fc8ea}
</style>
</head>
<body>
<header class="bar">
<span class="brand">TamaBox 主题生成器</span>
<label class="slug">slug <input id="slug" type="text" value="my-theme" spellcheck="false"></label>
<button id="btn-demo">载入示例</button>
<button id="btn-save">保存工程</button>
<button id="btn-open">打开工程</button>
<button id="btn-reload">重载画板</button>
<span class="spacer"></span>
<span class="conn" id="conn">检测中…</span>
<button id="btn-export" class="primary">导出 zip</button>
</header>
<main>
<aside class="panel">
<section>
<h2>页面</h2>
<div class="scopes" id="scopes"></div>
<p class="hint" id="scope-hint"></p>
</section>
<section>
<h2>装饰图层 <span class="r"><button id="add-layer">+ 加一层</button></span></h2>
<div class="layers" id="layers"></div>
<div id="layer-editor"></div>
</section>
<section>
<h2>整页背景 <label class="chk"><input type="checkbox" id="inherit"> 跟随全局</label></h2>
<div id="bg-editor"></div>
</section>
<section>
<h2>窄屏</h2>
<div id="narrow-editor"></div>
</section>
<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>
<input type="file" id="file" accept="image/*" multiple hidden>
<input type="file" id="projfile" accept=".json,application/json" hidden>
</section>
<section>
<h2>配色</h2>
<div class="pal" id="palette"></div>
</section>
</aside>
<section class="stage">
<div class="stagebar">
<span>视口</span>
<span class="g" id="vps"></span>
<span style="margin-left:10px">模式</span>
<span class="g" id="modes"></span>
<span class="url" id="stage-url"></span>
</div>
<div class="canvas" id="canvas">
<div class="frame" id="frame"><iframe id="preview"></iframe></div>
<div class="empty" id="empty">
<b>需要本地服务才能预览真实页面</b>
<span>在项目根执行:</span>
<code>python dev/theme-builder/serve.py</code>
<span>然后打开 http://127.0.0.1:8777/dev/theme-builder/</span>
</div>
</div>
<div class="out" id="out"></div>
</section>
</main>
<script>
'use strict';
/* ============================================================================
* 页面作用域:必须与 Go 侧 internal/context.pageScope() 的返回值一一对应,
* 否则生成的 data-page-scope 选择器在线上永远匹配不到。
* ========================================================================== */
/* 注意:首页 / 与 /explore 不在这里 —— 全站公开页不注入 ThemeSlug,也就不会加载主题 css,
给它们配装饰不会有任何效果(Go 侧 pageScope 仍会返回 home/explore,留给以后做站点默认主题)。
这里列出的每一个都确认过线上会加载主题。 */
const SCOPES = [
{k:'global', label:'全局', desc:'所有页面默认', page:null},
{k:'box', label:'箱主页', desc:'/_/{domain}', page:'box'},
{k:'question', label:'问题页', desc:'/_/{domain}/{id}', page:'question'},
{k:'admin', label:'题目列表', desc:'/user/questions', page:'admin'},
{k:'profile', label:'设置页', desc:'/user/*', page:'profile'},
{k:'vote', label:'投票页', desc:'/vote/{token}', page:'vote'},
];
const SLOT = 10; // 装饰层槽位数,必须与 internal/theme.DecoSlots 一致
const VIEWPORTS = [1512, 1280, 1024, 820];
const S = {
slug: 'my-theme',
scope: 'global',
viewport: 1512,
mode: 'light',
assets: [],
seq: 0,
curLayer: null,
scopes: {},
palette: {
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 },
dark: {
bg:'#141a22', surface:'#1d2530', navbar:'#141a22',
text:'#e3eaf4', primary:'#6fa3e0', border:'#334052',
},
};
const PALETTE_KEYS = [
['bg','页面底色'], ['surface','卡片'], ['navbar','顶栏'],
['text','正文'], ['primary','主色'], ['border','描边'],
];
function blankScope(k){
return { inherit: k !== 'global', bg: null, pattern: null, patternSize: '320px 320px', layers: [] };
}
SCOPES.forEach(s => { S.scopes[s.k] = blankScope(s.k); });
/* ---------------------------- 小工具 ---------------------------- */
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
const uid = p => p + (++S.seq);
function out(msg, cls){
const el = $('out');
el.innerHTML = cls ? `<span class="${cls}">${esc(msg)}</span>` : esc(msg);
}
function log(msg, cls){
const el = $('out');
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = msg + '\n';
el.appendChild(span);
el.scrollTop = el.scrollHeight;
}
function assetById(id){ return S.assets.find(a => a.id === id) || null; }
function slugOK(s){ return /^[a-z0-9][a-z0-9-]{0,31}$/.test(s); }
/** 当前作用域生效的配置:没勾「独立配置」就用全局那套。 */
function cfgOf(k){
const c = S.scopes[k];
if (k === 'global' || !c.inherit) return c;
return S.scopes.global;
}
/* ---------------------- 位置 / 尺寸 → CSS ---------------------- */
/* background-position 的百分比是相对 (容器 - 图片),不是相对容器。
统一写成 calc(0%|50%|100% + Npx) 两值形式:0% = 左/上对齐,50% = 居中,100% = 右/下对齐。
不用 left/top 关键字,是因为「水平居中 + 垂直贴底」会变成三值/四值语法,
一旦不合规范整条声明作废,图层会静默掉到左上角。 */
function axisPos(anchor, off){
const pct = anchor === 'center' ? '50%' : (anchor === 'start' ? '0%' : '100%');
const n = Math.round(off);
// 写成 calc(100% - 36px) 而不是 calc(100% + -36px):后者虽然合法,
// 但用 calc 手算时容易被误读成「加负数」,见过的样式事故都出在这种地方。
return n < 0 ? `calc(${pct} - ${-n}px)` : `calc(${pct} + ${n}px)`;
}
const POS = l => `${axisPos(l.ah, l.dx)} ${axisPos(l.av, l.dy)}`;
function rgba(hex, a){
const h = hex.replace('#','');
const v = h.length === 3 ? h.split('').map(c=>c+c).join('') : h;
const n = parseInt(v, 16);
return `rgba(${(n>>16)&255}, ${(n>>8)&255}, ${n&255}, ${a})`;
}
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`;
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) };
}
if (l.kind === 'radial'){
const c = rgba(l.color, l.alpha);
return {
image: `radial-gradient(circle ${Math.round(l.r)}px at ${l.cx}% ${l.cy}%, ${c} 0%, ${rgba(l.color,0)} 72%)`,
size: '100% 100%', position: 'left top',
};
}
const c = rgba(l.color, l.alpha);
return {
image: `linear-gradient(${Math.round(l.angle)}deg, ${c} 0%, ${rgba(l.color,0)} ${Math.round(l.stop)}%)`,
size: '100% 100%', position: 'left top',
};
}
function newLayer(kind){
return {
id: uid('L'), kind,
ah:'center', av:'end', dx:0, dy:-40,
h:360, w:0, asset:null,
color:'#fff8e4', alpha:0.55, r:320, cx:50, cy:50,
angle:112, stop:44,
};
}
/* ---------------------------- 导出 CSS ---------------------------- */
function urlOf(a, preview){
return preview ? a.dataURL : `/static/images/themes/${S.slug}/${a.id}.webp`;
}
function bgVars(cfg, preview){
const img = cfg.bg ? assetById(cfg.bg) : null;
const pat = cfg.pattern ? assetById(cfg.pattern) : null;
return [
` --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'};`,
].join('\n');
}
function decoVars(cfg, preview){
const layers = cfg.layers.slice(0, SLOT);
const imgs = [], sizes = [], poss = [];
for (let i = 0; i < SLOT; i++){
const l = layers[i];
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);
}
return [
` --tb-deco: ${imgs.join(',\n ')};`,
` --tb-deco-size: ${sizes.join(', ')};`,
` --tb-deco-position: ${poss.join(',\n ')};`,
].join('\n');
}
/* 主题至少要提供 10 个令牌(见 internal/theme/theme_test.go 的 requiredTokens),
少一个不会报错,只会让深色模式或次级面板变成默认配色。这里只让用户填 6 个基础色,
其余 4 个按「往文字色 / 主色方向混」派生:
- surface-2 = surface 往 text 靠一点(浅色下更深、深色下更亮,方向自动正确)
- surface-hover = surface 往 primary 靠一点
- text-secondary / text-muted = text 往 bg 靠(浅色下变淡、深色下变暗)
- primary-weak = primary 加 12% 透明度 */
function parseColor(v){
if (typeof v === 'string' && v.startsWith('#')){
const h = v.slice(1);
const x = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
const n = parseInt(x, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1];
}
const m = String(v).match(/rgba?\(([^)]+)\)/);
if (m){
const a = m[1].split(',').map(x => parseFloat(x));
return [a[0] | 0, a[1] | 0, a[2] | 0, a.length > 3 ? a[3] : 1];
}
return [0, 0, 0, 1];
}
function mixRGB(a, b, t){
const A = parseColor(a), B = parseColor(b);
const ch = i => Math.round(A[i] + (B[i] - A[i]) * t);
return A[3] >= 1 ? `rgb(${ch(0)}, ${ch(1)}, ${ch(2)})`
: `rgba(${ch(0)}, ${ch(1)}, ${ch(2)}, ${A[3]})`;
}
function withAlpha(c, a){
const A = parseColor(c);
return `rgba(${A[0]}, ${A[1]}, ${A[2]}, ${a})`;
}
function paletteVars(p){
return [
` --tb-bg: ${p.bg};`,
` --tb-surface: ${p.surface};`,
` --tb-surface-2: ${mixRGB(p.surface, p.text, 0.06)};`,
` --tb-surface-hover: ${mixRGB(p.surface, p.primary, 0.10)};`,
` --tb-navbar: ${p.navbar};`,
` --tb-border: ${p.border};`,
` --tb-text: ${p.text};`,
` --tb-text-secondary: ${mixRGB(p.text, p.bg, 0.36)};`,
` --tb-text-muted: ${mixRGB(p.text, p.bg, 0.55)};`,
` --tb-primary: ${p.primary};`,
` --tb-primary-weak: ${withAlpha(p.primary, 0.12)};`,
].join('\n');
}
const NONE10 = ' --tb-deco: ' + Array(SLOT).fill('none').join(', ') + ';\n' +
' --tb-deco-size: ' + Array(SLOT).fill('auto').join(', ') + ';\n' +
' --tb-deco-position: ' + Array(SLOT).fill('left top').join(', ') + ';';
function buildCSS(preview){
const slug = S.slug || 'my-theme';
const L = [];
L.push('/* ' + slug + ' —— 由 dev/theme-builder 生成,可直接手改。');
L.push(' 装饰层固定 ' + SLOT + ' 个槽位(对应 base.css 的 12 层背景);');
L.push(' 按页面覆盖时也必须写满 ' + SLOT + ' 个,否则图层会与 size/position 错位。 */');
L.push(`html[data-theme-style="${slug}"] {`);
L.push(paletteVars(S.palette));
L.push(bgVars(S.scopes.global, preview));
L.push(decoVars(S.scopes.global, preview));
L.push('}');
L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"] {`);
L.push(paletteVars(S.dark));
L.push(` --tb-page-image: linear-gradient(180deg, ${S.dark.bg} 0%, ${S.dark.bg} 60%, #0e1319 100%);`);
L.push(' --tb-page-pattern: none;');
L.push(NONE10);
L.push('}');
for (const sc of SCOPES){
if (sc.k === 'global') continue;
const cfg = S.scopes[sc.k];
if (cfg.inherit) continue;
const sel = `html[data-theme-style="${slug}"][data-page-scope="${sc.k}"]`;
L.push('');
L.push(`/* ${sc.label} ${sc.desc} */`);
L.push(sel + ' {');
L.push(bgVars(cfg, preview));
L.push(decoVars(cfg, preview));
L.push('}');
L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"][data-page-scope="${sc.k}"] {`);
L.push(' --tb-page-image: none;');
L.push(' --tb-page-pattern: none;');
L.push(NONE10);
L.push('}');
}
// 窄屏段:只写浅色规则。深色块特异性更高(0,2,1 > 0,1,1),
// 本来就压住这里,不需要再为深色写一份 —— 少一层嵌套少一处出错。
const nb = S.narrow.bg ? assetById(S.narrow.bg) : null;
if (S.narrow.enabled && (nb || S.narrow.hideDeco)){
L.push('');
L.push('/* 窄屏:卡片占满宽度,两侧没有留白,桌面那套背景与装饰在这里只会被裁/被盖 */');
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(', ') + ';');
}
L.push(' }');
L.push('}');
}
return L.join('\n') + '\n';
}
/* ---------------------------- iframe ---------------------------- */
let doc = null, frameReady = false;
function previewFile(){
const sc = SCOPES.find(x => x.k === S.scope);
const page = (sc && sc.page) || 'box';
return `../../outputs/preview-${page}-${S.mode}.html`;
}
function loadPreview(){
frameReady = false;
const url = previewFile();
$('stage-url').textContent = url.replace('../../','');
$('preview').src = url;
}
function onFrameLoad(){
const ifr = $('preview');
try {
doc = ifr.contentDocument || ifr.contentWindow.document;
if (!doc || !doc.documentElement) throw new Error('no document');
} catch (e){
doc = null;
$('empty').style.display = 'flex';
$('conn').className = 'conn bad';
$('conn').textContent = '需要本地服务';
out('无法访问 iframe 的 DOM。请用 python dev/theme-builder/serve.py 启动,再用 http://127.0.0.1:8777/dev/theme-builder/ 打开。', 'e');
return;
}
$('empty').style.display = 'none';
$('conn').className = 'conn ok';
$('conn').textContent = '画板已连接';
frameReady = true;
applyTheme();
}
function applyTheme(){
if (!frameReady || !doc) return;
const html = doc.documentElement;
const sc = SCOPES.find(x => x.k === S.scope);
html.setAttribute('data-theme-style', S.slug || 'my-theme');
// global 要看「纯全局」的效果:给一个不会匹配任何覆盖块的值,
// 否则箱主页自己的独立配置会混进全局预览里,看到的东西是假的。
html.setAttribute('data-page-scope', (sc && sc.page) || '__global');
if (S.mode === 'dark') html.setAttribute('data-theme', 'dark');
else html.removeAttribute('data-theme');
let st = doc.getElementById('__tb_theme');
if (!st){
st = doc.createElement('style');
st.id = '__tb_theme';
doc.head.appendChild(st);
}
st.textContent = buildCSS(true);
syncOverlay();
}
/* ------------------------- 拖拽蒙层 ------------------------- */
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),
};
}
function syncOverlay(){
if (!doc) return;
let ov = doc.getElementById('__tb_ov');
if (!ov){
ov = doc.createElement('div');
ov.id = '__tb_ov';
ov.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483000;';
doc.documentElement.appendChild(ov);
}
ov.innerHTML = '';
cfgOf(S.scope).layers.forEach(l => {
if (l.kind !== 'image') return;
const g0 = guideGeom(l);
if (!g0) return;
const g = doc.createElement('div');
g.dataset.layer = l.id;
g.style.cssText = [
'position:absolute', `width:${g0.w}px`, `height:${g0.h}px`,
`left:${g0.left}px`, `top:${g0.top}px`,
'border:1px dashed rgba(91,155,213,.95)', 'border-radius:2px',
'pointer-events:auto', 'cursor:move', 'background:none',
].join(';');
const tag = doc.createElement('span');
tag.textContent = '拖动';
tag.style.cssText = 'position:absolute;left:0;top:0;font:10px/14px system-ui;z-index:1;' +
'color:#0b1219;background:rgba(91,155,213,.92);padding:0 4px;border-radius:2px 0 2px 0';
g.appendChild(tag);
g.title = '拖动调整位置';
attachDrag(g, l);
ov.appendChild(g);
});
highlightGuide(S.curLayer);
}
function attachDrag(el, l){
el.addEventListener('pointerdown', e => {
e.preventDefault(); e.stopPropagation();
try { el.setPointerCapture(e.pointerId); } catch (_) {}
const sx = e.clientX, sy = e.clientY, dx0 = l.dx, dy0 = l.dy;
const move = ev => {
l.dx = Math.round(dx0 + (ev.clientX - sx));
l.dy = Math.round(dy0 + (ev.clientY - sy));
// 快速路径:只改 CSS + 蒙层位置,不重建 DOM(否则指针捕获会被打断)
const st = doc.getElementById('__tb_theme');
if (st) st.textContent = buildCSS(true);
const g0 = guideGeom(l);
if (g0){ el.style.left = g0.left + 'px'; el.style.top = g0.top + 'px'; }
renderLayerEditor(cfgOf(S.scope).layers.find(x => x.id === l.id) || null);
};
const up = () => {
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', up);
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', up);
});
}
/* ---------------------------- 缩放画板 ---------------------------- */
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';
ifr.style.transform = `scale(${k})`;
frame.style.width = Math.round(S.viewport * k) + 'px';
frame.style.height = Math.round(900 * k) + 'px';
}
/* ---------------------------- 面板渲染 ---------------------------- */
function renderScopes(){
const wrap = $('scopes');
wrap.innerHTML = '';
SCOPES.forEach(sc => {
const b = document.createElement('button');
const own = sc.k === 'global' || !S.scopes[sc.k].inherit;
const c = S.scopes[sc.k];
const dirty = own && (c.layers.length || c.bg || c.pattern);
b.className = 'sc' + (sc.k === S.scope ? ' on' : '');
b.innerHTML = `<b>${esc(sc.label)}${dirty ? ' <span class="dirty">●</span>' : ''}</b>` +
`<span>${esc(own ? sc.desc : '跟随全局')}</span>`;
b.onclick = () => {
S.scope = sc.k;
S.curLayer = null;
renderAll();
loadPreview(); // 切页面 = 换一张真实画板
};
wrap.appendChild(b);
});
const sc = SCOPES.find(x => x.k === S.scope);
$('scope-hint').textContent = sc.k === 'global'
? '这里的图层是所有页面的默认装饰。下面选具体页面可以单独覆盖。'
: `${sc.desc} —— 勾掉「跟随全局」后可单独配这一页的背景与图层。`;
}
function renderBgEditor(){
const cfg = cfgOf(S.scope);
const own = S.scope === 'global' || !S.scopes[S.scope].inherit;
$('inherit').checked = S.scope !== 'global' && S.scopes[S.scope].inherit;
$('inherit').disabled = S.scope === 'global';
const el = $('bg-editor');
el.innerHTML = '';
const mkPick = (label, key) => {
const row = document.createElement('div');
row.className = 'row';
const s = document.createElement('span');
s.textContent = label;
row.appendChild(s);
const sel = document.createElement('select');
sel.innerHTML = '<option value="">(无)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
sel.value = cfg[key] || '';
sel.disabled = !own;
sel.onchange = () => { cfg[key] = sel.value || null; renderAll(); };
row.appendChild(sel);
el.appendChild(row);
};
mkPick('背景图', 'bg');
mkPick('平铺图案', 'pattern');
const row = document.createElement('div');
row.className = 'row';
const s = document.createElement('span');
s.textContent = '图案尺寸';
const inp = document.createElement('input');
inp.type = 'text';
inp.value = cfg.patternSize || '320px 320px';
inp.disabled = !own;
inp.style.width = '110px';
inp.onchange = () => { cfg.patternSize = inp.value.trim() || '320px 320px'; renderAll(); };
row.append(s, inp);
el.appendChild(row);
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = '背景图按 cover 铺满整页;平铺图案叠在背景图之上、装饰图层之下。';
el.appendChild(tip);
}
function layerName(l){
if (l.kind === 'image'){
const a = assetById(l.asset);
return a ? a.name : '(未选图片)';
}
return l.kind === 'radial' ? '光斑' : '线性光';
}
function moveLayer(cfg, idx, delta){
const j = idx + delta;
if (j < 0 || j >= cfg.layers.length) return;
const [it] = cfg.layers.splice(idx, 1);
cfg.layers.splice(j, 0, it);
renderAll();
}
function renderLayers(){
const cfg = cfgOf(S.scope);
const own = S.scope === 'global' || !S.scopes[S.scope].inherit;
const wrap = $('layers');
wrap.innerHTML = '';
$('add-layer').disabled = !own || cfg.layers.length >= SLOT;
if (!own){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '这一页跟随全局。勾掉上方「跟随全局」即可单独配置。';
wrap.appendChild(p);
$('layer-editor').innerHTML = '';
return;
}
if (!cfg.layers.length){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '还没有图层。点「+ 加一层」,可选图片 / 光斑 / 线性光。图片层选中后可以直接在右侧画板上拖动。';
wrap.appendChild(p);
}
cfg.layers.forEach((l, i) => {
const d = document.createElement('div');
d.className = 'ly' + (l.id === S.curLayer ? ' on' : '');
const color = l.kind === 'image' ? '#5b9bd5' : l.kind === 'radial' ? '#d8a657' : '#9b7fd0';
// 顺序就是层级:background-image 的第一层画在最上面。所以要能调,
// 否则「谁挡住谁」配反了只能删了重加,位置尺寸全要重来。
d.innerHTML = `<i class="dot" style="background:${color}"></i>
<span class="nm">${esc(layerName(l))}</span>
<span class="k">${i === 0 ? '最上层' : '第 ' + (i+1) + ' 层'}</span>
<button class="ord" data-up="${l.id}" title="上移一层(更靠前)" ${i === 0 ? 'disabled' : ''}>↑</button>
<button class="ord" data-down="${l.id}" title="下移一层(更靠后)" ${i === cfg.layers.length - 1 ? 'disabled' : ''}>↓</button>
<button class="x" data-del="${l.id}" title="删除">×</button>`;
d.querySelector('[data-up]').onclick = e => { e.stopPropagation(); moveLayer(cfg, i, -1); };
d.querySelector('[data-down]').onclick = e => { e.stopPropagation(); moveLayer(cfg, i, 1); };
d.onclick = e => {
if (e.target.dataset.del || e.target.dataset.up || e.target.dataset.down) return;
S.curLayer = l.id;
renderLayers();
highlightGuide(l.id);
};
d.querySelector('[data-del]').onclick = e => {
e.stopPropagation();
cfg.layers = cfg.layers.filter(x => x.id !== l.id);
if (S.curLayer === l.id) S.curLayer = null;
renderAll();
};
wrap.appendChild(d);
});
renderLayerEditor(cfg.layers.find(l => l.id === S.curLayer) || cfg.layers[0] || null);
}
function highlightGuide(id){
if (!doc) return;
doc.querySelectorAll('#__tb_ov > div').forEach(g => {
const on = g.dataset.layer === id;
g.style.borderColor = on ? '#ffd479' : 'rgba(91,155,213,.95)';
g.style.background = 'none';
const tag = g.querySelector('span');
if (tag) tag.style.background = on ? 'rgba(255,212,121,.95)' : 'rgba(91,155,213,.92)';
});
}
function renderLayerEditor(l){
const host = $('layer-editor');
host.innerHTML = '';
if (!l){ S.curLayer = null; return; }
S.curLayer = l.id;
const box = document.createElement('div');
box.className = 'editor';
host.appendChild(box);
const row = (label, ...nodes) => {
const r = document.createElement('div');
r.className = 'row';
const s = document.createElement('span');
s.textContent = label;
r.appendChild(s);
nodes.forEach(n => r.appendChild(n));
box.appendChild(r);
return r;
};
if (l.kind === 'image'){
const sel = document.createElement('select');
sel.innerHTML = '<option value="">(选图片)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
sel.value = l.asset || '';
sel.onchange = () => { l.asset = sel.value || null; renderAll(); };
row('图片', sel);
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);
}
const g = document.createElement('div');
g.className = 'grid9';
[['start','start'],['center','start'],['end','start'],
['start','center'],['center','center'],['end','center'],
['start','end'],['center','end'],['end','end']].forEach(([hh, vv]) => {
const b = document.createElement('button');
b.className = (l.ah === hh && l.av === vv) ? 'on' : '';
b.title = `${hh} / ${vv}`;
b.onclick = () => { l.ah = hh; l.av = vv; renderAll(); };
g.appendChild(b);
});
row('锚点', g);
row('偏移 x/y', numInput(l.dx, -4000, 4000, v => { l.dx = v; renderAll(); }),
numInput(l.dy, -4000, 4000, v => { l.dy = v; renderAll(); }));
if (l.kind !== 'image'){
const c = document.createElement('input');
c.type = 'color'; c.value = l.color;
c.oninput = () => { l.color = c.value; applyTheme(); };
row('颜色', c);
row('不透明度', rangeInput(l.alpha, 0, 1, 0.01, v => { l.alpha = v; applyTheme(); }));
}
if (l.kind === 'radial') row('半径 px', numInput(l.r, 10, 3000, v => { l.r = v; renderAll(); }));
if (l.kind === 'linear'){
row('角度', numInput(l.angle, 0, 360, v => { l.angle = v; renderAll(); }));
row('衰减%', numInput(l.stop, 1, 100, v => { l.stop = v; renderAll(); }));
}
}
function numInput(val, min, max, on){
const i = document.createElement('input');
i.type = 'number'; i.value = val; i.min = min; i.max = max;
i.onchange = () => on(clamp(Number(i.value) || 0, min, max));
return i;
}
function rangeInput(val, min, max, step, on){
const i = document.createElement('input');
i.type = 'range'; i.value = val; i.min = min; i.max = max; i.step = step;
i.style.width = '100%';
i.oninput = () => on(Number(i.value));
return i;
}
function renderAssets(){
const wrap = $('assets');
wrap.innerHTML = '';
if (!S.assets.length){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '还没有图片。点「上传」加入透明底 PNG / WebP。';
wrap.appendChild(p);
return;
}
S.assets.forEach(a => {
const d = document.createElement('div');
d.className = 'as';
d.title = `${a.name} · ${a.w}×${a.h} · ${a.kb}KB`;
d.innerHTML = `<img src="${a.dataURL}" alt=""><span class="meta">${a.w}×${a.h}</span>
<span class="del" data-del="${a.id}">×</span>`;
d.querySelector('[data-del]').onclick = e => {
e.stopPropagation();
S.assets = S.assets.filter(x => x.id !== a.id);
Object.values(S.scopes).forEach(c => {
if (c.bg === a.id) c.bg = null;
if (c.pattern === a.id) c.pattern = null;
c.layers = c.layers.filter(l => l.asset !== a.id);
});
renderAll();
};
wrap.appendChild(d);
});
}
function toHex(v){
if (!v) return '#000000';
if (v.startsWith('#')) return v.length === 4
? '#' + v.slice(1).split('').map(c=>c+c).join('') : v;
const m = v.match(/rgba?\(([^)]+)\)/);
if (!m) return '#000000';
const p = m[1].split(',').map(x => parseInt(x.trim(), 10));
return '#' + p.slice(0,3).map(n => n.toString(16).padStart(2,'0')).join('');
}
function renderNarrowEditor(){
const el = $('narrow-editor');
el.innerHTML = '';
const n = S.narrow;
const mk = (text, checked, onChange) => {
const lab = document.createElement('label');
lab.className = 'chk';
lab.style.cssText = 'font-size:12px;color:var(--ui-text);display:flex;gap:6px;margin-bottom:7px';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = checked;
cb.onchange = () => onChange(cb.checked);
lab.append(cb, document.createTextNode(text));
return lab;
};
el.appendChild(mk('导出 @media 块', n.enabled, v => { n.enabled = v; renderAll(); }));
const row = document.createElement('div');
row.className = 'row';
const sp = document.createElement('span');
sp.textContent = '断点 px';
const mw = document.createElement('input');
mw.type = 'number'; mw.min = 320; mw.max = 1400; mw.value = n.maxWidth;
mw.onchange = () => { n.maxWidth = clamp(Number(mw.value) || 900, 320, 1400); renderAll(); };
row.append(sp, mw);
el.appendChild(row);
const row2 = document.createElement('div');
row2.className = 'row';
const sp2 = document.createElement('span');
sp2.textContent = '竖版图';
const sel = document.createElement('select');
sel.innerHTML = '<option value="">(沿用桌面背景)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
sel.value = n.bg || '';
sel.onchange = () => { n.bg = sel.value || null; renderAll(); };
row2.append(sp2, sel);
el.appendChild(row2);
el.appendChild(mk('隐藏全部装饰层', n.hideDeco, v => { n.hideDeco = v; renderAll(); }));
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = '手机上卡片几乎占满屏幕:16:9 的桌面场景会被裁成一条纹理,固定 px 的人物会被卡片盖住。' +
'要么换一张竖版图,要么让装饰退场。只写浅色规则——深色块特异性更高,本来就压住它。';
el.appendChild(tip);
}
function renderPalette(){
const wrap = $('palette');
wrap.innerHTML = '';
PALETTE_KEYS.forEach(([k, label]) => {
const r = document.createElement('div');
r.className = 'p';
const s = document.createElement('span');
s.textContent = label; s.style.width = '46px'; s.style.flex = '0 0 auto';
const c = document.createElement('input');
c.type = 'color';
c.value = toHex(S.palette[k]);
c.oninput = () => { S.palette[k] = c.value; applyTheme(); };
const t = document.createElement('input');
t.type = 'text'; t.value = S.palette[k];
t.onchange = () => { S.palette[k] = t.value.trim(); renderAll(); };
r.append(s, c, t);
wrap.appendChild(r);
});
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '深色配色写在生成结果里(深色不加载图片);要改深色请直接改 CSS。';
wrap.appendChild(note);
}
function renderViewportBar(){
const vp = $('vps'); vp.innerHTML = '';
VIEWPORTS.forEach(w => {
const b = document.createElement('button');
b.textContent = w;
b.className = w === S.viewport ? 'on' : '';
b.onclick = () => { S.viewport = w; renderViewportBar(); fitStage(); };
vp.appendChild(b);
});
const md = $('modes'); md.innerHTML = '';
[['light','浅色'],['dark','深色']].forEach(([k, t]) => {
const b = document.createElement('button');
b.textContent = t;
b.className = k === S.mode ? 'on' : '';
b.onclick = () => { S.mode = k; renderViewportBar(); loadPreview(); };
md.appendChild(b);
});
}
function renderAll(){
S.slug = ($('slug').value || '').trim() || 'my-theme';
renderScopes();
renderBgEditor();
renderLayers();
renderAssets();
renderNarrowEditor();
renderPalette();
applyTheme();
scheduleSave();
}
/* ---------------------- 工程持久化(留得住配置) ---------------------- */
/* 没有这两样东西,工具就只能用一次:关掉标签页,刚调好的十个图层和六个页面全没。
localStorage 存草稿(自动),json 存工程(手动、可进版本库、可发给别人)。 */
const LS_KEY = 'tamabox-theme-builder-v2';
let saveTimer = null, draftWarned = false;
function toJSON(){
return {
v: 2,
savedAt: new Date().toISOString(),
slug: S.slug,
scope: S.scope,
viewport: S.viewport,
mode: S.mode,
palette: S.palette,
dark: S.dark,
narrow: S.narrow,
// 图片直接内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够
assets: S.assets.map(a => ({ id: a.id, name: a.name, dataURL: a.dataURL, w: a.w, h: a.h, kb: a.kb })),
scopes: S.scopes,
};
}
function fromJSON(o){
if (!o || typeof o !== 'object' || o.v !== 2){
throw new Error('不是本工具导出的工程文件(版本不是 2)');
}
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.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.assets = (o.assets || []).map(a => Object.assign({}, a, { bytes: dataURLToBytes(a.dataURL) }));
S.scopes = {};
SCOPES.forEach(sc => {
const src = (o.scopes || {})[sc.k] || {};
S.scopes[sc.k] = Object.assign(blankScope(sc.k), src, {
layers: Array.isArray(src.layers) ? src.layers : [],
});
// 图层字段补齐:工程文件可能是旧版本写的,缺的字段用默认值兜住,
// 否则读进来会是一堆 undefined,渲染时静默变成 none
S.scopes[sc.k].layers = S.scopes[sc.k].layers.map(l =>
Object.assign(newLayer(l && l.kind ? l.kind : 'radial'), l));
});
S.curLayer = null;
}
function saveDraft(){
try {
localStorage.setItem(LS_KEY, JSON.stringify(toJSON()));
} catch (e){
// 图片多时 base64 会顶到 5MB 配额,只提示一次,别刷屏
if (!draftWarned){
draftWarned = true;
log('草稿太大存不进浏览器(localStorage 配额),请用「保存工程」落成 json。', 'w');
}
}
}
function scheduleSave(){
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 700);
}
function restoreDraft(){
let raw = null;
try { raw = localStorage.getItem(LS_KEY); } catch (_) { return false; }
if (!raw) return false;
try { fromJSON(JSON.parse(raw)); return true; }
catch (e){ return false; }
}
function refreshUIFromState(){
$('slug').value = S.slug;
S.curLayer = null;
renderViewportBar();
renderAll();
loadPreview();
fitStage();
}
/* ---------------------------- 导出前校验 ---------------------------- */
/* 这些规则本来要导出后跑 go test 才知道。能在点导出时就拦住的,不该让人去等编译。 */
function splitTopLevel(v){
const out = []; let buf = '', depth = 0;
for (const ch of String(v)){
if (ch === '(') depth++;
else if (ch === ')') depth--;
if (ch === ',' && depth === 0){ out.push(buf.trim()); buf = ''; }
else buf += ch;
}
if (buf.trim()) out.push(buf.trim());
return out;
}
function validate(){
const errs = [];
const slug = ($('slug').value || '').trim();
if (!slugOK(slug)) errs.push('slug 只能是小写字母、数字、短横线,1~32 字符');
let hasContent = false;
SCOPES.forEach(sc => {
const raw = S.scopes[sc.k];
if (sc.k !== 'global' && raw.inherit) return; // 跟随全局的不单独检查
const cfg = raw;
if (cfg.layers.length > SLOT) errs.push(`${sc.label}:装饰层 ${cfg.layers.length} 个,超过 ${SLOT} 个槽位`);
cfg.layers.forEach((l, i) => {
if (l.kind === 'image' && !assetById(l.asset)) errs.push(`${sc.label} 第 ${i+1} 层是图片层,但没选图片`);
});
if (cfg.bg && !assetById(cfg.bg)) errs.push(`${sc.label}:整页背景引用了已删除的图片`);
if (cfg.pattern && !assetById(cfg.pattern)) errs.push(`${sc.label}:平铺图案引用了已删除的图片`);
if (cfg.layers.length || cfg.bg || cfg.pattern) hasContent = true;
});
if (!hasContent) errs.push('所有页面都是空的 —— 导出的会是一个什么都没配的主题');
if (S.narrow.bg && !assetById(S.narrow.bg)) errs.push('窄屏竖版图引用了已删除的图片');
// 结构自检:三组装饰值必须都是 10 个(与 Go 的 TestDecoSlotCount 同一条规则)。
// 这条是给工具自己兜底的 —— 真跑出问题说明生成逻辑有 bug,而不是用户配错。
const css = buildCSS(false);
['--tb-deco:', '--tb-deco-size:', '--tb-deco-position:'].forEach(key => {
let rest = css, n = 0;
for (;;){
const i = rest.indexOf(key);
if (i < 0) break;
const j = rest.indexOf(';', i);
if (j < 0) break;
n++;
const cnt = splitTopLevel(rest.slice(i + key.length, j)).length;
if (cnt !== SLOT) errs.push(`生成的 ${key} 第 ${n} 处只有 ${cnt} 个值(应为 ${SLOT})—— 这是工具自身的 bug,请反馈`);
rest = rest.slice(j + 1);
}
if (n === 0) errs.push(`生成的 CSS 里找不到 ${key} —— 工具自身的 bug,请反馈`);
});
return errs;
}
/* ---------------------------- 导出 zip ---------------------------- */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++){
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = 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;
}
const u16 = n => new Uint8Array([n & 255, (n >>> 8) & 255]);
const u32 = n => new Uint8Array([n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255]);
const enc = s => new TextEncoder().encode(s);
function concat(arrs){
let n = 0;
arrs.forEach(a => n += a.length);
const o = new Uint8Array(n);
let p = 0;
arrs.forEach(a => { o.set(a, p); p += a.length; });
return o;
}
/** 只做 STORE(不压缩):素材本来就是 WebP,再压收益接近 0,换来 40 行可审计的实现。 */
function zipStore(files){
const local = [], central = [];
let offset = 0;
const dt = new Date();
const dosTime = ((dt.getHours() << 11) | (dt.getMinutes() << 5) | (dt.getSeconds() >> 1)) & 0xFFFF;
const dosDate = (((dt.getFullYear() - 1980) << 9) | ((dt.getMonth() + 1) << 5) | dt.getDate()) & 0xFFFF;
for (const f of files){
const name = enc(f.name);
const crc = crc32(f.data);
const head = concat([
u32(0x04034b50), u16(20), u16(0), u16(0), u16(dosTime), u16(dosDate),
u32(crc), u32(f.data.length), u32(f.data.length), u16(name.length), u16(0),
]);
local.push(head, name, f.data);
central.push(concat([
u32(0x02014b50), u16(20), u16(20), u16(0), u16(0), u16(dosTime), u16(dosDate),
u32(crc), u32(f.data.length), u32(f.data.length),
u16(name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset),
]), name);
offset += head.length + name.length + f.data.length;
}
const cd = concat(central);
return concat([...local, cd, concat([
u32(0x06054b50), u16(0), u16(0), u16(files.length), u16(files.length),
u32(cd.length), u32(offset), u16(0),
])]);
}
function dataURLToBytes(url){
const b64 = url.split(',')[1] || '';
const bin = atob(b64);
const o = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) o[i] = bin.charCodeAt(i);
return o;
}
function installTxt(slug){
const own = SCOPES.filter(s => s.k !== 'global').map(s => {
const st = S.scopes[s.k].inherit ? '跟随全局' : '独立配置';
return ` ${s.k.padEnd(9)} ${s.desc.padEnd(22)} ${st}`;
}).join('\n');
return `主题 ${slug} —— 安装说明
=====================================
1) 解压(保持目录结构,覆盖即可)
static/css/themes/${slug}.css
static/images/themes/${slug}/*.webp
2) 登记主题(conf/app.ini)
[theme]
available = default,${slug}
⚠️ 没登记就「设置页看不到这个主题」,现象和部署没生效一模一样。
启动日志会打 warn:[theme] available 未登记以下主题
3) 让缓存失效
internal/conf/static.go 的 StaticVersion 改一下(末尾字母 +1),
然后重新 go build —— 模板与静态资源都是编译期 embed,只重启不重编不会变。
4) 箱主设置页 → 账号 → 外观,选择「${slug}」
页面作用域
----------
${own}
窄屏
----
${S.narrow.enabled
? `已导出 @media (max-width: ${S.narrow.maxWidth}px):${S.narrow.bg ? '换成竖版背景图' : '背景沿用桌面'}${S.narrow.hideDeco ? ' + 隐藏全部装饰层' : ''}`
: '未导出窄屏规则(桌面装饰会在手机上被卡片盖住 / 背景被 cover 裁成一条)'}
作用域由服务端按请求路径输出 <html data-page-scope="...">,
主题 CSS 用 html[data-theme-style="${slug}"][data-page-scope="xxx"] 覆盖。
没写覆盖块的作用域自动沿用全局,不会出现「某页装饰消失」。
注意
----
装饰层固定 ${SLOT} 个槽位(--tb-deco / -size / -position 必须同长)。
手动改 CSS 时别删值 —— 少一个会让图层与 size/position 错位,
表现是整页重复同一张插画、或人物跑到左上角。
`;
}
function doExport(){
const errs = validate();
if (errs.length){
out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e');
return;
}
const slug = ($('slug').value || '').trim();
const used = new Set();
if (S.narrow.bg) used.add(S.narrow.bg);
const collect = c => {
if (c.bg) used.add(c.bg);
if (c.pattern) used.add(c.pattern);
c.layers.forEach(l => { if (l.asset) used.add(l.asset); });
};
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)) }];
for (const id of used){
const a = assetById(id);
if (a) files.push({ name: `static/images/themes/${slug}/${id}.webp`, data: dataURLToBytes(a.dataURL) });
}
// 工程文件也塞进包里:下次想改直接「打开工程」,不必从零重搭
files.push({ name: `theme-${slug}.json`, data: enc(JSON.stringify(toJSON())) });
files.push({ name: `INSTALL-${slug}.txt`, data: enc(installTxt(slug)) });
const blob = new Blob([zipStore(files)], { type: 'application/zip' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
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');
}
/* ---------------------------- 图片处理 ---------------------------- */
function loadImage(file){
return new Promise((res, rej) => {
const img = new Image();
img.onload = () => res(img);
img.onerror = () => rej(new Error('无法解码 ' + file.name));
img.src = URL.createObjectURL(file);
});
}
/** 画布尺寸 = 原图尺寸:不缩放、不留透明边,导出后的宽高比才与实际一致。 */
function canvasAsset(cv, q){
const url = cv.toDataURL('image/webp', q);
const bytes = dataURLToBytes(url);
return { dataURL: url, bytes, w: cv.width, h: cv.height, kb: Math.round(bytes.length / 1024) };
}
async function addFiles(list){
for (const f of list){
if (!f.type.startsWith('image/')) continue;
let img;
try { img = await loadImage(f); }
catch (e){ log(e.message, 'e'); continue; }
const cv = document.createElement('canvas');
cv.width = img.naturalWidth; cv.height = img.naturalHeight;
cv.getContext('2d').drawImage(img, 0, 0);
const conv = canvasAsset(cv, 0.9);
const base = f.name.replace(/\.[^.]+$/, '').toLowerCase()
.replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'img';
let id = base, n = 2;
while (S.assets.some(a => a.id === id)) id = base + '-' + (n++);
S.assets.push({ id, name: f.name, ...conv });
log(`加入 ${f.name} → ${id}.webp(${conv.w}×${conv.h}, ${conv.kb}KB)`, 'ok');
}
renderAll();
}
/* ---------------------------- 示例 ---------------------------- */
function makeDemo(){
// 重复点「载入示例」不该把素材叠起来(会撞 id,导出时取到的是判断顺序里靠前的那个)
S.assets = [];
const bg = document.createElement('canvas');
bg.width = 1920; bg.height = 1080;
const g = bg.getContext('2d');
const gr = g.createLinearGradient(0, 0, 1920, 1080);
gr.addColorStop(0, '#dfeaf6'); gr.addColorStop(0.55, '#eef3f9'); gr.addColorStop(1, '#f7f2e8');
g.fillStyle = gr; g.fillRect(0, 0, 1920, 1080);
g.fillStyle = 'rgba(255,255,255,.5)';
for (let i = 0; i < 6; i++) g.fillRect(i * 340 + 60, 0, 46, 1080);
// 示例人物:只为看清「相对地面的比例」,所以用剪影而不追求好看。
// 比例按真实立绘取(1 : 3.2),头不要画大 —— 头一大就会读成方块人。
const ch = document.createElement('canvas');
ch.width = 300; ch.height = 960;
const c = ch.getContext('2d');
c.fillStyle = '#42506a';
c.strokeStyle = 'rgba(126,205,215,.95)';
c.lineWidth = 9;
c.beginPath(); c.ellipse(150, 34, 60, 16, -0.12, 0, Math.PI * 2); c.stroke(); // 光环
c.beginPath(); c.ellipse(150, 122, 52, 60, 0, 0, Math.PI * 2); c.fill(); // 头
c.fillRect(138, 174, 24, 24); // 颈
c.beginPath(); // 上身
c.moveTo(96, 198); c.lineTo(204, 198); c.lineTo(188, 468); c.lineTo(112, 468);
c.closePath(); c.fill();
c.beginPath(); // 裙摆
c.moveTo(112, 460); c.lineTo(188, 460); c.lineTo(216, 600); c.lineTo(84, 600);
c.closePath(); c.fill();
c.fillRect(112, 596, 30, 252); // 腿
c.fillRect(158, 596, 30, 252);
c.fillStyle = '#1e2634';
c.beginPath(); c.ellipse(127, 862, 27, 15, 0, 0, Math.PI * 2); c.fill(); // 鞋
c.beginPath(); c.ellipse(173, 862, 27, 15, 0, 0, Math.PI * 2); c.fill();
S.assets.push({ id:'bg', name:'示例背景', ...canvasAsset(bg, 0.82) });
S.assets.push({ id:'char', name:'示例人物', ...canvasAsset(ch, 0.9) });
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('radial'), { color:'#ffd9a0', alpha:0.42, r:320, cx:20, cy:86 }),
];
const ac = S.scopes.admin;
ac.inherit = false;
ac.bg = null;
ac.layers = [Object.assign(newLayer('radial'), { color:'#ffcf9a', alpha:0.38, r:380, cx:86, cy:10 })];
S.curLayer = null;
S.scopes.box = blankScope('box');
S.scopes.question = blankScope('question');
S.scopes.profile = blankScope('profile');
S.scopes.vote = blankScope('vote');
S.narrow.bg = null;
renderAll();
log('已载入示例。左侧切换页面看差异 —— 题目列表页是「独立配置」的例子。', 'ok');
}
/* ---------------------------- 初始化 ---------------------------- */
function boot(){
const params = new URLSearchParams(location.search);
const k = params.get('scope');
if (k && SCOPES.some(s => s.k === k)) S.scope = k;
// 自动恢复上次的草稿:调主题是连续的活,每次重开都从零开始等于没有工具
const restored = params.get('fresh') ? false : restoreDraft();
$('slug').value = S.slug;
renderViewportBar();
renderAll();
loadPreview();
fitStage();
if (restored){
log('已恢复上次的草稿(换 slug / 项目时可点「打开工程」,或加 ?fresh=1 从空白开始)。', 'ok');
} else {
out('在项目根执行 python dev/theme-builder/serve.py,用 http://127.0.0.1:8777/dev/theme-builder/ 打开,才能预览真实页面。');
}
$('preview').addEventListener('load', onFrameLoad);
$('slug').addEventListener('input', () => { S.slug = $('slug').value.trim() || 'my-theme'; applyTheme(); });
$('btn-reload').onclick = () => { loadPreview(); log('重载画板'); };
$('btn-demo').onclick = () => makeDemo();
$('btn-export').onclick = () => doExport();
$('add-layer').onclick = () => {
const cfg = cfgOf(S.scope);
if (cfg.layers.length >= SLOT) return;
const l = newLayer(S.assets.length ? 'image' : 'radial');
if (l.kind === 'image' && S.assets.length) l.asset = S.assets[0].id;
cfg.layers.push(l);
S.curLayer = l.id;
renderAll();
};
$('btn-save').onclick = () => {
const errs = validate();
const warn = errs.length ? '(注意:还有 ' + errs.length + ' 处未处理,工程仍可保存)' : '';
const j = JSON.stringify(toJSON());
const blob = new Blob([j], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `theme-${S.slug}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
log(`已保存 theme-${S.slug}.json(${Math.round(blob.size / 1024)}KB)${warn}`, 'ok');
};
$('btn-open').onclick = () => $('projfile').click();
$('projfile').onchange = async e => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (!f) return;
try {
fromJSON(JSON.parse(await f.text()));
refreshUIFromState();
log(`已打开工程 ${f.name}`, 'ok');
} catch (err){
log('打开失败:' + err.message, 'e');
}
};
$('add-asset').onclick = () => $('file').click();
$('file').onchange = e => { addFiles([...e.target.files]); e.target.value = ''; };
$('inherit').onchange = e => {
if (S.scope === 'global') return;
S.scopes[S.scope].inherit = e.target.checked;
renderAll();
};
window.addEventListener('resize', fitStage);
if (params.get('demo')) makeDemo();
// 自检钩子:?dump=css 导出最终 CSS,?dump=zip 导出 zip 的 base64。
// 无头浏览器里用 --dump-dom 抓这两个值,就能在没有 UI 交互的情况下做回归。
const dump = params.get('dump');
if (dump){
const pre = document.createElement('pre');
pre.id = 'dump';
if (dump === 'css'){
pre.textContent = buildCSS(false);
} else if (dump === 'validate'){
pre.textContent = JSON.stringify(validate(), null, 1);
} else if (dump === 'install'){
pre.textContent = installTxt(S.slug);
} else {
const files = [{ name: `${S.slug}.css`, data: enc(buildCSS(false)) }];
const bytes = zipStore(files);
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
pre.textContent = btoa(bin);
}
pre.style.display = 'none';
document.body.appendChild(pre);
}
}
boot();
</script>
</body>
</html>