原来的生成器只能配「背景 + 人物 + 道具」三件套,预览还是自己画的仿制页面 —— 跟真实页面不像,也没法给不同页面配不同内容。 机制层: - internal/context.pageScope() 按路径归类,输出 <html data-page-scope>: box / question / admin / profile / vote(首页与 /explore 不加载主题,不列) - /user/questions 单独归为 admin:题目列表信息密,和设置表单的装饰诉求不同 - base.css 装饰槽位 6 -> 10,背景 8 层 -> 12 层;主题按作用域覆盖时同样写满 10 个 - 新增 TestDecoSlotCount:逐文件逐声明检查 10 个值。少写一个不报错, 只会让图层与 size/position 错位(整页重复同一张图 / 人物跑到左上角),注释守不住 生成器: - 画板改成 iframe 加载 outputs/preview-*.html(真实模板渲染),配 serve.py 起本地服务 解决 file:// 下父页面读不到 iframe DOM 的问题 - 八个页面作用域(去掉不生效的首页/探索),每个可独立配背景与最多 10 层装饰 - 图层类型:图片 / 径向光斑 / 线性光;9 宫格锚点 + 像素偏移,可在画板上直接拖 - 位置统一写 calc(0%|50%|100% ± Npx):百分比是相对 (容器-图片), 不用 left/bottom 关键字混搭,避免三值语法非法导致整条声明作废 - 配色只填 6 个基础色,其余 4 个必需令牌按方向派生(surface-2 往 text 靠, 浅色变深、深色变亮,方向自动正确) 配套: - zz_preview_test.go 增加单题页 / 题目列表 / 投票页预览,作为生成器画板 - docs/theme-page-scope.md 记录作用域取值、选择器特异性与 10 槽位约束 - StaticVersion 20261020e(base.css 变了) 验证:生成器导出的 CSS 通过 TestThemeCSSComplete + TestDecoSlotCount; 真实箱主页计算样式为 12 层对齐;临时主题验证「question 覆盖为 none」生效、 未覆盖时回退全局。
1201 lines
45 KiB
HTML
1201 lines
45 KiB
HTML
<!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)}
|
||
|
||
.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);width:58px;flex:0 0 auto}
|
||
.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}
|
||
.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:8px 12px 10px;font-size:11px;color:var(--ui-text2);white-space:pre-wrap;
|
||
word-break:break-word;max-height:132px;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-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>整页背景 <label class="chk"><input type="checkbox" id="inherit"> 跟随全局</label></h2>
|
||
<div id="bg-editor"></div>
|
||
</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>图片池 <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>
|
||
</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',
|
||
},
|
||
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 => ({'&':'&','<':'<','>':'>','"':'"'}[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('}');
|
||
}
|
||
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,.9)', 'border-radius:2px',
|
||
'pointer-events:auto', 'cursor:move', 'background:rgba(91,155,213,.06)',
|
||
'box-shadow:0 0 0 1px rgba(0,0,0,.25)',
|
||
].join(';');
|
||
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 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';
|
||
d.innerHTML = `<i class="dot" style="background:${color}"></i>
|
||
<span class="nm">${esc(layerName(l))}</span>
|
||
<span class="k">第 ${i+1} 层</span>
|
||
<button class="x" data-del="${l.id}" title="删除">×</button>`;
|
||
d.onclick = e => {
|
||
if (e.target.dataset.del) 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,.9)';
|
||
g.style.background = on ? 'rgba(255,212,121,.10)' : 'rgba(91,155,213,.06)';
|
||
});
|
||
}
|
||
|
||
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(); }));
|
||
row('宽px(0=等比)', numInput(l.w, 0, 4000, v => { l.w = v; renderAll(); }));
|
||
}
|
||
|
||
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 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();
|
||
renderPalette();
|
||
applyTheme();
|
||
}
|
||
|
||
/* ---------------------------- 导出 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}
|
||
|
||
作用域由服务端按请求路径输出 <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 slug = ($('slug').value || '').trim();
|
||
if (!slugOK(slug)){
|
||
out('slug 只能是小写字母、数字、短横线,且不超过 32 字符(例如 sakura-night)。', 'e');
|
||
return;
|
||
}
|
||
const used = new Set();
|
||
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: `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)。解压到项目根,按 INSTALL-${slug}.txt 走三步。`, '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(){
|
||
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:'#fff6d8', alpha:0.5, r:300, cx:20, cy:88 }),
|
||
];
|
||
|
||
const ac = S.scopes.admin;
|
||
ac.inherit = false;
|
||
ac.bg = null;
|
||
ac.layers = [Object.assign(newLayer('radial'), { color:'#fff3cf', alpha:0.4, r:340, cx:88, cy:12 })];
|
||
|
||
S.curLayer = 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;
|
||
|
||
renderViewportBar();
|
||
renderAll();
|
||
loadPreview();
|
||
fitStage();
|
||
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();
|
||
};
|
||
$('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 {
|
||
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>
|