diff --git a/dev/theme-builder/README.md b/dev/theme-builder/README.md index 928a162..0f2f156 100644 --- a/dev/theme-builder/README.md +++ b/dev/theme-builder/README.md @@ -30,7 +30,7 @@ http://127.0.0.1:8777/dev/theme-builder/ | --- | --- | --- | | 画板 | `outputs/preview-*.html`(真模板渲染) | 同一套模板 | | 作用域 | iframe 里设 `data-page-scope` | `internal/context.pageScope()` 输出到 `` | -| 装饰层 | 6 槽?不,**固定 10 槽** | `base.css` 的 12 层背景(pattern 1 + deco 10 + image 1) | +| 装饰层 | **固定 10 槽** | `base.css` 的 12 层背景(pattern 1 + deco 10 + image 1) | | 位置 | `calc(0%|50%|100% ± Npx)` | 同一套 `background-position` | 因为是同一套机制,工具里拖出来的位置换视口不会跑位,也不会出现「预览好看、实机错位」。 @@ -61,9 +61,27 @@ http://127.0.0.1:8777/dev/theme-builder/ 锚点用 9 宫格 + 像素偏移,生成 `calc(0% + 40px) calc(100% - 36px)` 这种两值形式。 +**↑ / ↓ 调层级**。顺序就是层级:`background-image` 的第一层画在最上面, +所以「谁挡住谁」是靠顺序定的(shiroko 里自行车被人挡住就是这么来的)。 + +**窄屏** —— 手机上卡片几乎占满屏幕,桌面那套 16:9 场景会被 `cover` 裁成一条无意义纹理, +固定 px 的人物会被卡片盖住。这一栏可以:指定一张竖版背景图、决定装饰是否退场、 +改断点(默认 900px)。导出时生成对应的 `@media` 块,只写浅色规则 +(深色块特异性更高,本来就压住它)。 + **图片池** —— 上传任意张,浏览器本地转 WebP,画布尺寸 = 原图尺寸(不缩放、不留透明边)。 导出时只打包实际用到的图。 +## 工程文件与草稿 + +调主题是连续几十次的活,所以配置必须留得住: + +- **自动草稿**:每次改动后 700ms 存进 `localStorage`,下次打开自动恢复。 + 图片多到顶破 5MB 配额时会提示改用 json(只提示一次)。 +- **保存工程 / 打开工程**:`theme-.json`,图片以 base64 内嵌,一个文件就能带走; + 可以进版本库,也可以发给别人接着改。 +- 不想恢复草稿就加 `?fresh=1`。 + ## 导出 点「导出 zip」得到: @@ -74,6 +92,10 @@ static/images/themes//*.webp INSTALL-.txt ``` +点导出时会先跑一遍校验(slug 合法、图片层是否选了图、有没有引用已删除的素材、 +装饰是否超过 10 层、生成出来的 CSS 每处装饰是不是真的 10 个值),有问题直接列出来, +不会让你导出后再去等 `go test`。 + 解压到项目根,然后按 `INSTALL-.txt` 做三步: 1. `conf/app.ini` 的 `[theme] available` 加上 slug; @@ -96,8 +118,11 @@ INSTALL-.txt ``` ?demo=1 载入示例素材(canvas 画的背景 + 剪影人物) ?scope=admin 直接落在某个作用域 +?fresh=1 忽略 localStorage 草稿,从空白开始 ?dump=css 把最终 CSS 写进
 ?dump=zip          把 zip 的 base64 写进 
+?dump=validate     把 validate() 的结果写进 
+?dump=install      把安装说明写进 
 ```
 
 ```bash
diff --git a/dev/theme-builder/index.html b/dev/theme-builder/index.html
index 5eb1a5f..338d73f 100644
--- a/dev/theme-builder/index.html
+++ b/dev/theme-builder/index.html
@@ -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 @@
   TamaBox 主题生成器
   
   
+  
+  
   
   
   检测中…
@@ -151,15 +157,20 @@
       

+
+

装饰图层

+
+
+
+

整页背景

-

装饰图层

-
-
+

窄屏

+
@@ -167,6 +178,7 @@

透明底 PNG / WebP 最好。上传后自动转 WebP,画布尺寸即自然尺寸。

+
@@ -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 = ` ${esc(layerName(l))} - 第 ${i+1} 层 + ${i === 0 ? '最上层' : '第 ' + (i+1) + ' 层'} + + `; + 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 = '' + + S.assets.map(a => ``).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 裁成一条)'} + 作用域由服务端按请求路径输出 , 主题 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);