Files
tamabox/dev/theme-builder/index.html
T
tamakyi feb5b1f78d 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 载入示例
2026-10-20 10:24:35 +08:00

856 lines
42 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">
<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>