feat(theme-builder): 补 P0 四项——配置留得住、图层可排序、窄屏段、导出前校验

之前只能算「试一次」,关掉标签页全部配置丢失,导出的主题在手机上也是废的。

1) 配置持久化
- 每次改动 700ms 后写 localStorage,打开自动恢复;?fresh=1 从空白开始
- 「保存工程 / 打开工程」落成 theme-<slug>.json(图片 base64 内嵌,单文件可带走)
- zip 里也带一份 json,下次改不用从零搭
- 配额超了只提示一次,并指向 json

2) 图层上移/下移
顺序就是层级(background-image 第一层在最上),配反了以前只能删了重加。
列表标注「最上层 / 第 N 层」。

3) 窄屏段
新增窄屏配置:竖版背景图、隐藏装饰层、断点(默认 900px),导出生成 @media 块。
只写浅色规则——深色块特异性 0,2,1 > 0,1,1,本来就压住它。
实测 390 视口下首层装饰从 url(char.webp) 变成 none,1512 下不变。

4) 导出前校验
slug、图片层是否选了图、是否引用已删素材、层数是否超 10、
生成 CSS 的每处装饰是否真的 10 个值(和 Go 的 TestDecoSlotCount 同规则)。
有问题直接列出来,不用导出后再等 go test。

顺带:面板按使用频率重排(图层→背景→窄屏→图片池→配色);
拖拽框改成虚线+左上角标签,不再用半透明填充罩住人物;示例重复载入不再叠加素材。

验证:示例 CSS 通过 TestThemeCSSComplete + TestDecoSlotCount(27 处声明);
草稿往返(带 demo 保存 → 不带 demo 打开仍 validate 为空)实测通过。
This commit is contained in:
tamakyi
2026-10-20 13:32:26 +08:00
parent 7b532574bb
commit 8ffa7b0ba7
2 changed files with 348 additions and 21 deletions
+322 -20
View File
@@ -80,11 +80,14 @@
.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);width:58px;flex:0 0 auto}
.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)}
@@ -117,12 +120,13 @@
}
.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}
.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: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);
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;
@@ -137,6 +141,8 @@
<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>
@@ -151,15 +157,20 @@
<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>装饰图层 <span class="r"><button id="add-layer">+ 加一层</button></span></h2>
<div class="layers" id="layers"></div>
<div id="layer-editor"></div>
<h2>窄屏</h2>
<div id="narrow-editor"></div>
</section>
<section>
@@ -167,6 +178,7 @@
<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>
@@ -230,6 +242,10 @@ const S = {
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',
@@ -451,6 +467,24 @@ function buildCSS(preview){
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';
}
@@ -550,10 +584,14 @@ function syncOverlay(){
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)',
'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);
@@ -682,6 +720,14 @@ function layerName(l){
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;
@@ -708,12 +754,18 @@ function renderLayers(){
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+1} 层</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) return;
if (e.target.dataset.del || e.target.dataset.up || e.target.dataset.down) return;
S.curLayer = l.id;
renderLayers();
highlightGuide(l.id);
@@ -734,8 +786,10 @@ 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)';
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)';
});
}
@@ -768,7 +822,9 @@ function renderLayerEditor(l){
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 wIn = numInput(l.w, 0, 4000, v => { l.w = v; renderAll(); });
wIn.title = '0 表示按图片原始比例自动算宽度';
row('宽度 0=自动', wIn);
}
const g = document.createElement('div');
@@ -854,6 +910,56 @@ function toHex(v){
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 = '';
@@ -903,8 +1009,149 @@ function renderAll(){
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 ---------------------------- */
@@ -1004,6 +1251,12 @@ function installTxt(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"] 覆盖。
没写覆盖块的作用域自动沿用全局,不会出现「某页装饰消失」。
@@ -1017,12 +1270,14 @@ ${own}
}
function doExport(){
const slug = ($('slug').value || '').trim();
if (!slugOK(slug)){
out('slug 只能是小写字母、数字、短横线,且不超过 32 字符(例如 sakura-night)。', 'e');
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);
@@ -1035,6 +1290,8 @@ function doExport(){
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' });
@@ -1043,7 +1300,8 @@ function doExport(){
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');
out(`已导出 theme-${slug}.zip(${files.length} 个文件,${Math.round(blob.size/1024)}KB)。\n` +
`解压到项目根,按 INSTALL-${slug}.txt 走三步;包里的 theme-${slug}.json 是工程源文件,下次用「打开工程」接着改。`, 'ok');
}
/* ---------------------------- 图片处理 ---------------------------- */
@@ -1085,6 +1343,8 @@ async function addFiles(list){
/* ---------------------------- 示例 ---------------------------- */
function makeDemo(){
// 重复点「载入示例」不该把素材叠起来(会撞 id,导出时取到的是判断顺序里靠前的那个)
S.assets = [];
const bg = document.createElement('canvas');
bg.width = 1920; bg.height = 1080;
const g = bg.getContext('2d');
@@ -1133,6 +1393,11 @@ function makeDemo(){
ac.layers = [Object.assign(newLayer('radial'), { color:'#fff3cf', alpha:0.4, r:340, cx:88, cy:12 })];
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');
}
@@ -1143,11 +1408,19 @@ function boot(){
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();
out('在项目根执行 python dev/theme-builder/serve.py,用 http://127.0.0.1:8777/dev/theme-builder/ 打开,才能预览真实页面。');
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(); });
@@ -1163,6 +1436,31 @@ function boot(){
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 => {
@@ -1182,6 +1480,10 @@ function boot(){
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);