Files
tamabox/dev/theme-builder/index.html
T
tamakyi 5c0de805c6 new-sakura:花枝朝向与分叉修正,素材包一键导出上传到对象存储
- gen_new_sakura.py:花朵加各自旋转角(原来一律朝上,像贴纸);分叉从垂直下垂改成向右上斜;
  新增 --asset-base / --out —— 填前缀就引用对象存储位图,留空仍是内联 SVG。
- export_assets.py(新增):从 gen_new_sakura 的同一份 SVG 出图(直接 import,不另画一套),
  用无头 Chrome 透明底渲染后转 WebP;--upload 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt,
  公开地址按 data/r2.txt 的 image_bucket_cdn_host(与 app.ini 同名)拼。
- serve.upload_files 支持逐文件 cacheControl。原来硬编码 immutable,而 INSTALL.txt 与镜像
  CSS 的键不带版本 —— 重新生成后浏览器/CDN 一年内都还是旧内容。现在位图 1 天、CSS 5 分钟、
  README 60 秒;selftest 补一条覆盖断言。
- 文档更正两处事实:①自定义域 r2.337845818.best 实测可匿名读(S3 endpoint 仍 403);
  ②服务端有 gzip,内联版 51KB 实际传输 3.9KB,比位图版(2.7KB + 5 图 48.9KB)小得多,
  所以「换位图省 CSS 体积」不成立,位图只在素材要复用到非 CSS 场景时才有意义。
  另记:曾以 immutable 传过的键必须去控制台清缓存,重传不会让边缘失效。
2026-10-22 11:06:13 +08:00

3833 lines
164 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>TamaBox 主题生成器</title>
<style>
:root{
--ui-bg:#0f1216; --ui-panel:#161b22; --ui-panel2:#1c232c; --ui-line:#28313d;
--ui-text:#dbe3ee; --ui-text2:#8b98a9; --ui-accent:#5b9bd5; --ui-ok:#4bb27d; --ui-bad:#d9636b;
--ui-warn:#d8a657;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:var(--ui-bg);color:var(--ui-text);
font:13px/1.55 -apple-system,"Segoe UI",system-ui,"Microsoft YaHei",sans-serif;
display:flex;flex-direction:column;overflow:hidden;
}
button,input,select{font:inherit;color:inherit}
button{
background:var(--ui-panel2);border:1px solid var(--ui-line);color:var(--ui-text);
border-radius:6px;padding:5px 10px;cursor:pointer;transition:.15s;
}
button:hover{background:#232c37;border-color:#3a4756}
button.primary{background:var(--ui-accent);border-color:var(--ui-accent);color:#08121c;font-weight:600}
button.primary:hover{background:#6fa9dd}
button.on{background:var(--ui-accent);border-color:var(--ui-accent);color:#08121c;font-weight:600}
input[type=text],input[type=number],select{
background:#0d1117;border:1px solid var(--ui-line);border-radius:6px;padding:4px 7px;width:100%;
}
input[type=number]{width:72px}
input[type=color]{
width:34px;height:26px;padding:0;border:1px solid var(--ui-line);border-radius:5px;background:none;cursor:pointer;
}
label.chk{font-size:11px;color:var(--ui-text2);display:inline-flex;gap:4px;align-items:center;font-weight:400}
h2{
font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ui-text2);
margin:0 0 8px;display:flex;align-items:center;justify-content:space-between;font-weight:600;
}
h2 .r{display:flex;gap:6px}
.bar{
flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:0 14px;height:48px;
background:var(--ui-panel);border-bottom:1px solid var(--ui-line);
}
.brand{font-weight:600;letter-spacing:.02em}
.bar .slug{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text2)}
.bar .slug input{width:150px}
.bar .spacer{flex:1}
.conn{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--ui-line)}
.conn.ok{color:var(--ui-ok);border-color:#2f5744}
.conn.bad{color:var(--ui-bad);border-color:#5a3036}
main{flex:1;display:grid;grid-template-columns:352px minmax(0,1fr);min-height:0}
.panel{overflow:auto;border-right:1px solid var(--ui-line);background:var(--ui-panel);padding:14px}
.panel section{margin-bottom:18px}
.scopes{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.sc{
text-align:left;padding:7px 9px;border-radius:7px;line-height:1.3;background:var(--ui-panel2);
border:1px solid var(--ui-line);
}
.sc b{display:block;font-size:12px;font-weight:600}
.sc span{font-size:10px;color:var(--ui-text2)}
.sc.on{border-color:var(--ui-accent);background:#16303f}
.sc.on b{color:#9fc8ea}
.sc .dirty{color:var(--ui-warn);font-size:10px}
.hint{font-size:11px;color:var(--ui-text2);margin:8px 0 0;line-height:1.5}
.layers{display:flex;flex-direction:column;gap:5px}
.ly{
display:flex;flex-direction:column;gap:5px;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 .top{display:flex;align-items:center;gap:7px}
.ly .dot{width:9px;height:9px;border-radius:3px;flex:0 0 auto;background:#4a5563}
/* 缩略图:一眼看出「这一层用的是哪张图」。没有它的话,
三层都绑着同一张图时,用户只能靠层名去猜。 */
.ly .th{width:22px;height:22px;border-radius:4px;object-fit:contain;flex:0 0 auto;
background:#0b0f14;border:1px solid var(--ui-line)}
.ly .th.no{display:flex;align-items:center;justify-content:center;font-size:11px;
color:#6f7c8d;border-style:dashed}
.ly .nm{flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ly .k{font-size:10px;color:var(--ui-text2);flex:0 0 auto}
.ly .pick{display:flex;align-items:center;gap:5px}
.ly .pick select{flex:1;min-width:0;font-size:11px;padding:2px 5px;height:24px}
.ly .pick .tag{font-size:10px;color:var(--ui-text2);flex:0 0 auto}
.ly .x{opacity:.5;padding:0 4px;flex:0 0 auto}
.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;flex:0 0 auto}
.ly .ord:hover:not(:disabled){opacity:1;background:#232c37;border-color:var(--ui-line)}
.ly .ord:disabled{opacity:.18;cursor:default}
.editor{margin-top:10px;padding:10px;border-radius:8px;background:#12171e;border:1px solid var(--ui-line)}
.row{display:flex;align-items:center;gap:8px;margin-bottom:7px}
.row:last-child{margin-bottom:0}
.row > span{font-size:11px;color:var(--ui-text2);min-width:58px;flex:0 0 auto;white-space:nowrap}
.grid9{display:grid;grid-template-columns:repeat(3,24px);gap:3px}
.grid9 button{padding:0;height:22px;border-radius:4px;font-size:0}
.grid9 button.on{background:var(--ui-accent)}
/* 素材托管:两选一(本地 / S3 兼容),选 S3 才展开字段。
字段一律「短标签 + 输入框」,标签宽度按最长那个(公开访问前缀 = 84px)对齐,
否则同一列里会被 flex 压成参差的锯齿。 */
.hostpick{display:flex;gap:6px;margin-bottom:9px}
.hostpick button{flex:1;font-size:11px;padding:4px 6px}
.s3stat{font-size:11px;padding:6px 8px;border-radius:6px;border:1px solid var(--ui-line);
margin-top:8px;line-height:1.55;white-space:pre-wrap;word-break:break-all}
.s3stat.ok{color:var(--ui-ok);border-color:#2f5744}
.s3stat.bad{color:var(--ui-bad);border-color:#5a3036}
.s3stat.warn{color:var(--ui-warn);border-color:#5a4a2a}
.s3acts{display:flex;gap:6px;margin-top:8px}
.s3acts button{flex:1}
.s3acts button:disabled{opacity:.45;cursor:not-allowed}
/* 远端清单:key 名弹性、大小右对齐、状态徽标定宽,操作按钮只留两个小方块。
240px 面板里四列已经是极限,再加列就会把文件名挤成省略号。 */
.remote{border-top:1px solid var(--ui-line);margin-top:10px;padding-top:9px}
.remote .rhead{display:flex;align-items:center;gap:6px;margin-bottom:6px}
.remote .rhead span{font-size:11px;color:var(--ui-text2);flex:1}
.remote .rlist{display:flex;flex-direction:column}
.rrow{display:grid;grid-template-columns:minmax(0,1fr) auto auto auto auto;gap:5px;
align-items:center;font-size:11px;padding:2px 0}
.rrow .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rrow .sz{color:var(--ui-text2);font-variant-numeric:tabular-nums;font-size:10px}
.rrow .bg{font-size:10px;padding:0 5px;border-radius:999px;border:1px solid var(--ui-line);
color:var(--ui-text2);white-space:nowrap}
.rrow .bg.same{color:var(--ui-ok);border-color:#2f5744}
.rrow .bg.changed{color:var(--ui-warn);border-color:#5a4a2a}
.rrow .bg.extra{color:var(--ui-bad);border-color:#5a3036}
.rrow button{padding:0;width:20px;height:20px;line-height:1;font-size:11px;border-radius:4px}
.remote .rsum{font-size:11px;color:var(--ui-text2);margin-top:6px;line-height:1.5}
.remote .racts{display:flex;gap:6px;margin-top:7px}
.remote .racts button{flex:1;font-size:11px;padding:4px 6px}
.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;
}
/* 处理入口常驻(不靠 hover):处理过的素材要能一眼看出来,否则
同一张图有「原图」和「压白 16% 版」两副样子,改完就分不清哪张是现在的 */
.as .fx{
position:absolute;top:2px;left:1px;font-size:10px;line-height:1;font-weight:600;
color:#cbd6e4;background:#0009;border-radius:4px;padding:2px 3px;cursor:pointer;
}
.as .fx:hover{background:var(--ui-accent);color:#08121c}
.as .fx.on{background:var(--ui-accent);color:#08121c}
/* ---------- 图片处理弹窗 ---------- */
.modal{position:fixed;inset:0;background:#000b;z-index:50;display:none;align-items:center;justify-content:center}
.modal.on{display:flex}
.mbox{
width:min(900px,94vw);max-height:90vh;display:flex;flex-direction:column;
background:var(--ui-panel);border:1px solid var(--ui-line);border-radius:10px;
overflow:hidden;box-shadow:0 24px 64px #000a;
}
.mbox header{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--ui-line)}
.mbox header .t{font-weight:600}
.mbox header .sub{font-size:11px;color:var(--ui-text2)}
.mbox header .spacer{flex:1}
.mbody{display:grid;grid-template-columns:minmax(0,1fr) 252px;flex:1;min-height:0}
.mprev{
display:flex;align-items:center;justify-content:center;padding:18px;min-height:0;min-width:0;
/* 棋盘格:不透明度是这一屏唯一看不出来的东西,必须有背景参照 */
background:conic-gradient(#1a2029 25%, #0d1117 0 50%, #1a2029 0 75%, #0d1117 0) 0 0/16px 16px;
}
.mprev canvas{max-width:100%;max-height:58vh;display:block;box-shadow:0 6px 20px #0006}
.mparams{padding:14px;border-left:1px solid var(--ui-line);overflow:auto}
.fxrow{margin-bottom:13px}
.fxrow .lab{display:flex;justify-content:space-between;align-items:baseline;font-size:11px;color:var(--ui-text2);margin-bottom:2px}
.fxrow .lab b{color:var(--ui-text);font-weight:600;font-variant-numeric:tabular-nums}
.fxrow input[type=range]{width:100%;accent-color:var(--ui-accent);margin:0}
.fxrow .sub{font-size:10px;color:#6f7c8d;margin-top:2px}
.fxrow.chkrow{margin-bottom:8px}
.mbox footer{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--ui-line)}
.mbox footer .info{flex:1;font-size:11px;color:var(--ui-text2)}
.pal{display:flex;flex-direction:column;gap:5px}
.pal .p{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--ui-text2)}
.pal .p input[type=text]{width:100%;font-size:11px;padding:2px 6px}
.stage{display:flex;flex-direction:column;min-width:0;min-height:0;background:#0b0e12}
.stagebar{
flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:7px 12px;
border-bottom:1px solid var(--ui-line);background:var(--ui-panel);font-size:11px;color:var(--ui-text2);
}
.stagebar .g{display:flex;gap:4px}
.stagebar .url{margin-left:auto;font-family:ui-monospace,Consolas,monospace;font-size:10px;color:#5f6c7c}
.canvas{flex:1;overflow:auto;padding:14px;min-height:0;min-width:0;position:relative;
display:flex;justify-content:center;align-items:center}
.frame{position:relative;overflow:hidden;border-radius:8px;background:#fff;box-shadow:0 12px 40px -12px #000a}
iframe{display:block;border:0;transform-origin:top left;background:#fff}
.out{
flex:0 0 auto;padding:7px 12px 9px;font-size:11px;color:var(--ui-text2);white-space:pre-wrap;
word-break:break-word;max-height:96px;overflow:auto;border-top:1px solid var(--ui-line);background:var(--ui-panel);
}
.out .e{color:var(--ui-bad)} .out .ok{color:var(--ui-ok)} .out .w{color:var(--ui-warn)}
.empty{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;align-items:center;justify-content:center;
color:#7c8a9c;font-size:13px;text-align:center;padding:24px;z-index:5;background:#0b0e12}
.empty b{color:#a9b8ca}
.empty code{background:#161b22;border:1px solid var(--ui-line);border-radius:5px;padding:6px 9px;
font-family:ui-monospace,Consolas,monospace;font-size:11px;color:#9fc8ea}
</style>
</head>
<body>
<header class="bar">
<span class="brand">TamaBox 主题生成器</span>
<label class="slug">slug <input id="slug" type="text" value="my-theme" spellcheck="false"></label>
<button id="btn-demo">载入示例</button>
<button id="btn-new" title="清空当前工程与浏览器草稿,从空白开始">新建</button>
<button id="btn-save">保存工程</button>
<button id="btn-open">打开工程</button>
<button id="btn-reload">重载画板</button>
<span class="spacer"></span>
<span class="conn" id="conn">检测中…</span>
<button id="btn-export" class="primary">导出 zip</button>
</header>
<main>
<aside class="panel">
<section>
<h2>页面</h2>
<div class="scopes" id="scopes"></div>
<p class="hint" id="scope-hint"></p>
</section>
<section>
<h2>装饰图层 <span class="r"><button id="add-layer">+ 加一层</button></span></h2>
<div class="layers" id="layers"></div>
<div id="layer-editor"></div>
</section>
<section>
<h2>整页背景 <label class="chk"><input type="checkbox" id="inherit"> 跟随全局</label></h2>
<div id="bg-editor"></div>
</section>
<section>
<h2>窄屏</h2>
<div id="narrow-editor"></div>
</section>
<section>
<h2>图片池 <span class="r"><button id="add-asset">上传</button></span></h2>
<div class="assets" id="assets"></div>
<p class="hint">透明底 PNG / WebP 最好。上传后按下面的画质设置转 WebP。</p>
<input type="file" id="file" accept="image/*" multiple hidden>
<input type="file" id="projfile" accept=".json,application/json" hidden>
</section>
<section>
<h2>导出画质</h2>
<div id="export-editor"></div>
</section>
<section>
<h2>素材托管 <span class="r"><span class="conn" id="host-state">本地</span></span></h2>
<div id="host-editor"></div>
</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>然后打开 serve.py 启动时打印的地址(本机 127.0.0.1,局域网用本机 IP)</span>
</div>
</div>
<div class="out" id="out"></div>
</section>
</main>
<!-- 图片处理:非破坏。参数存在 fx 里,每次都从 orig 重算,
所以「压白 16% → 再改 20%」不会叠加两次压缩,也不会累积锯齿。 -->
<div class="modal" id="fxmodal">
<div class="mbox">
<header>
<span class="t">处理图片</span>
<span class="sub" id="fx-name"></span>
<span class="spacer"></span>
<button id="fx-close">×</button>
</header>
<div class="mbody">
<div class="mprev"><canvas id="fx-canvas"></canvas></div>
<div class="mparams" id="fx-params"></div>
</div>
<footer>
<span class="info" id="fx-info"></span>
<button id="fx-reset">重置</button>
<button id="fx-apply" class="primary">完成</button>
</footer>
</div>
</div>
<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 一致
/* 视口预设带高度:桌面主题几乎全部按「宽」调,但装饰层的裁切是「高」决定的
—— 同一个 400px 立绘在 900 高的笔记本上占 44%,在 768 的旧笔电上占 52%,贴底时就开始吃人物。
列表里放一档真机竖屏尺寸,才能当场看见「手机上被裁成一条」。 */
const VIEWPORTS = [
{ w: 1512, h: 900, label: '1512×900 桌机' },
{ w: 1280, h: 800, label: '1280×800 笔记本' },
{ w: 1024, h: 768, label: '1024×768 小笔电' },
{ w: 820, h: 1180, label: '820×1180 平板竖' },
{ w: 390, h: 844, label: '390×844 手机' },
];
const vpOf = w => VIEWPORTS.find(v => v.w === w) || VIEWPORTS[0];
/* 自适应尺寸的设计基准视口高:`fit` 单位把「当前 h」当成 900 高下的效果,
视口更高就封顶、更矮就等比缩小(下限 55%),一次解决「小屏被裁、大屏太小」。 */
const DESIGN_H = 900;
/* `fitw` 按宽度自适应的基准视口宽(桌机档)。宽幅素材(横幅、整屏插画)只能按宽缩:
按高度缩的话宽度会跟着比例涨,1280 的笔记本上照样裁两侧。 */
const DESIGN_W = 1512;
const MOB_SCALE_DEFAULT = 0.62;
/* 默认配色/窄屏抽出来,「新建」时能原样还原;S 里存的是副本,不会被重置污染 */
const D = {
palette: {
/* primary 从 #4a7fc1 压深到 #3d6fac:前者在 #eef3f9 上只有 3.69:1,
14px 的链接/按钮文字不达 AA。默认值应当是可用的,不能一上手就踩红线。 */
bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)',
text:'#222d3b', primary:'#3d6fac', border:'#d6e0ec',
},
/* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理。
这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。
scale 是「每层策略选缩放」时的系数;hideDeco 是「一次全藏」的快捷开关。 */
narrow: { enabled: true, bg: null, hideDeco: false, maxWidth: 900, scale: MOB_SCALE_DEFAULT },
/* 导出画质:webp 体积 ≈ 像素数 × 质量。压体积最有效的不是 q,是先砍像素
—— CSS 里显示 400px 高的图,2 倍屏只需要 800px,上传 3000px 的立绘纯属白扛。 */
export: { quality: 0.88, maxSide: 1600 },
/* 素材前缀:空 = 走站点自己的 /static/images/themes(图片随二进制发布);
填了(如 https://cdn.example.com/themes)= 导出的 CSS 写绝对 URL,图片放对象存储。
前缀里不要带 slug,slug 由导出时自动拼在后面。 */
assetBase: '',
/* 素材托管:local = 随二进制发布;s3 = 任意 S3 兼容存储(R2 / MinIO / B2 / OSS / COS ...)。
密钥不在这里 —— 它只活在浏览器内存与 sessionStorage 里,绝不进工程文件。
publicBase 留空时按 endpoint 推导,所以这里默认空串而不是假域名。 */
host: {
mode: 'local',
endpoint: '',
region: 'auto',
bucket: '',
prefix: 'themes',
pathStyle: true,
publicBase: '',
},
dark: {
bg:'#141a22', surface:'#1d2530', navbar:'#141a22',
text:'#e3eaf4', primary:'#6fa3e0', border:'#334052',
},
};
const S = {
slug: 'my-theme',
scope: 'global',
viewport: 1512,
mode: 'light',
assets: [],
seq: 0,
curLayer: null,
scopes: {},
palette: Object.assign({}, D.palette),
narrow: Object.assign({}, D.narrow),
export: Object.assign({}, D.export),
assetBase: '',
host: Object.assign({}, D.host),
/* 运行时状态:测试/上传结果,不参与持久化 */
s3: { kind: '', text: '', busy: false, canUpload: null },
/* 远端清单(检测结果)。at=0 表示这次会话还没检测过;slug 变了要重新检测,
否则会拿旧主题的 key 跟新主题的本地素材比,满屏「多余」。 */
remote: { objects: [], at: 0, slug: '', prefix: '' },
dark: Object.assign({}, D.dark),
};
const PALETTE_KEYS = [
['bg','页面底色'], ['surface','卡片'], ['navbar','顶栏'],
['text','正文'], ['primary','主色'], ['border','描边'],
];
function blankScope(k){
return { inherit: k !== 'global', bg: null, pattern: null, patternSize: '320px 320px',
bgPos: 'center center', layers: [] };
}
SCOPES.forEach(s => { S.scopes[s.k] = blankScope(s.k); });
/* ---------------------------- 小工具 ---------------------------- */
/* 当前访问地址。局域网里用 http://192.168.x.x:8777 打开时,提示文案不该再写死 127.0.0.1 ——
否则用户明明已经打开了,却被告诉「请用 127.0.0.1 打开」。 */
const HERE = location.protocol + '//' + location.host + '/dev/theme-builder/';
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
const uid = p => p + (++S.seq);
function out(msg, cls){
const el = $('out');
el.innerHTML = cls ? `<span class="${cls}">${esc(msg)}</span>` : esc(msg);
}
function log(msg, cls){
const el = $('out');
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = msg + '\n';
el.appendChild(span);
el.scrollTop = el.scrollHeight;
}
function assetById(id){ return S.assets.find(a => a.id === id) || null; }
function slugOK(s){ return /^[a-z0-9][a-z0-9-]{0,31}$/.test(s); }
/** 当前作用域生效的配置:没勾「独立配置」就用全局那套。 */
function cfgOf(k){
const c = S.scopes[k];
if (k === 'global' || !c.inherit) return c;
return S.scopes.global;
}
/* ---------------------- 位置 / 尺寸 → CSS ---------------------- */
/* background-position 的百分比是相对 (容器 - 图片),不是相对容器。
统一写成 calc(0%|50%|100% ± N) 两值形式:0% = 左/上对齐,50% = 居中,100% = 右/下对齐。
不用 left/top 关键字,是因为「水平居中 + 垂直贴底」会变成三值/四值语法,
一旦不合规范整条声明作废,图层会静默掉到左上角。 */
const unitOf = l => (l.unit === 'fit' || l.unit === 'vh') ? 'vh'
: (l.unit === 'fitw') ? 'vw' : 'px';
function axisPos(anchor, off, unit){
const pct = anchor === 'center' ? '50%' : (anchor === 'start' ? '0%' : '100%');
// 自适应/视口单位下偏移也跟着缩:贴底 36px 在 844 高的手机上应该是 34px 左右,
// 写死 px 会让小屏的留白看起来突然大了一截
const base = unit === 'vw' ? DESIGN_W : DESIGN_H;
const vunit = unit === 'vw' ? 'vw' : 'vh';
const mag = (unit === 'vh' || unit === 'vw')
? (+(Math.abs(off) / base * 100).toFixed(2)) + vunit
: Math.abs(Math.round(off)) + 'px';
if (!off) return `calc(${pct} + 0px)`;
// 写成 calc(100% - 36px) 而不是 calc(100% + -36px):后者虽然合法,
// 但用 calc 手算时容易被误读成「加负数」,见过的样式事故都出在这种地方。
return off < 0 ? `calc(${pct} - ${mag})` : `calc(${pct} + ${mag})`;
}
/** 横向偏移在 fitw 下按 vw 缩,其余情况跟纵向一起按 vh 缩。 */
const POS = l => {
const u = unitOf(l);
return `${axisPos(l.ah, l.dx, u === 'vw' ? 'vw' : u)} `
+ `${axisPos(l.av, l.dy, u === 'vw' ? 'vh' : u)}`;
};
/** 尺寸单位 → 某视口下应有的像素宽高。
layerSize 出 CSS、layerMetrics 出预览框,两者都从这里取值:
各算一套迟早会漂,表现就是「框显示没超、图已经被裁」。
下限取「基准的 55%」与「视口 - 24px」中更小的那个 —— 屏幕小到连 55% 都放不下时,
让位给视口,这样自适应单位永远不会把图撑出屏幕。 */
const FIT_PAD = 24;
function unitGeom(l, ratio, VW, VH){
const h0 = Math.max(1, Math.round(l.h) || 200);
const w0 = l.w > 0 ? Math.round(l.w) : Math.round(h0 * ratio);
if (l.unit === 'vh'){
const h = h0 / DESIGN_H * VH;
return { w: h * ratio, h };
}
if (l.unit === 'fit'){
const lo = Math.max(40, Math.min(h0 * 0.55, VH - FIT_PAD));
const h = Math.min(h0, Math.max(lo, h0 / DESIGN_H * VH));
return { w: h * ratio, h };
}
if (l.unit === 'fitw'){
const lo = Math.max(40, Math.min(w0 * 0.55, VW - FIT_PAD));
const w = Math.min(w0, Math.max(lo, w0 / DESIGN_W * VW));
return { w, h: w / ratio };
}
return { w: w0, h: h0 };
}
/** 尺寸 → background-size。
只定一个维度,另一个交给 `auto` 让浏览器按图片自身比例算:少一个可以填错的数,
也保证缩放时绝不发生横向拉伸。`px` 模式保留手填宽度(宽高比可以故意改)。 */
function layerSize(l, a){
const ratio = (a && a.h) ? (a.w / a.h) : 1;
const h0 = Math.max(1, Math.round(l.h) || 200);
const w0 = l.w > 0 ? Math.round(l.w) : Math.round(h0 * ratio);
if (l.unit === 'vh') return `auto ${+(h0 / DESIGN_H * 100).toFixed(2)}vh`;
if (l.unit === 'fit'){
const lo = Math.round(h0 * 0.55), hi = h0;
// clamp 的 min 若 ≥ max,浏览器按 min 处理 —— 图反而比设定值还大,所以先兜住
if (lo >= hi) return `auto ${hi}px`;
// 下限再和「视口高 - 24px」取小值:小屏上 55% 仍放不下时自动让位
return `auto clamp(min(calc(100vh - ${FIT_PAD}px), ${lo}px), `
+ `${+(h0 / DESIGN_H * 100).toFixed(2)}vh, ${hi}px)`;
}
if (l.unit === 'fitw'){
const lo = Math.round(w0 * 0.55), hi = w0;
if (lo >= hi) return `${hi}px auto`;
return `clamp(min(calc(100vw - ${FIT_PAD}px), ${lo}px), `
+ `${+(w0 / DESIGN_W * 100).toFixed(2)}vw, ${hi}px) auto`;
}
return l.w > 0 ? `${w0}px ${h0}px` : `auto ${h0}px`;
}
/** 某一层在给定视口下「实际占多大、落在哪」。拖拽框、溢出检测、导出 CSS 共用。 */
function layerMetrics(l, a, VW, VH){
const ratio = (a && a.h) ? (a.w / a.h) : 1;
const g = unitGeom(l, ratio, VW, VH);
const w = g.w, h = g.h;
const px = l.ah === 'center' ? 0.5 : (l.ah === 'start' ? 0 : 1);
const py = l.av === 'center' ? 0.5 : (l.av === 'start' ? 0 : 1);
const u = unitOf(l);
// 偏移与尺寸同单位缩放(fitw 横向按 vw、纵向按 vh)
const kx = u === 'vw' ? VW / DESIGN_W : (u === 'vh' ? VH / DESIGN_H : 1);
const ky = u === 'px' ? 1 : VH / DESIGN_H;
const left = (VW - w) * px + l.dx * kx;
const top = (VH - h) * py + l.dy * ky;
return {
w, h, left, top,
overL: Math.max(0, -left),
overR: Math.max(0, left + w - VW),
overT: Math.max(0, -top),
overB: Math.max(0, top + h - VH),
};
}
/** 一键按当前视口修好:改成固定 px,取「宽不超 96%、高不超 92%」的那个尺寸。
fit / fitw 只能按单一维度缩,遇到极端比例仍会裁;给一个算好的确定值,
比让人自己试 clamp 快得多。窄屏交给「窄屏」策略,不要指望一个 px 值通吃。 */
function fitToViewport(l, a, vp){
const ratio = a.w / a.h;
const h = Math.max(20, Math.min(Math.round(l.h) || 200,
Math.floor(vp.h * 0.92), Math.floor(vp.w * 0.96 / ratio)));
l.unit = 'px';
l.h = h;
if (l.w > 0) l.w = Math.round(h * ratio);
if (l.av !== 'center'){ l.av = 'center'; l.dy = 0; }
renderAll();
applyTheme();
log(`已按 ${vp.w}×${vp.h} 调整为 ${Math.round(h * ratio)}×${h}px(固定 px,更窄的屏请用「窄屏」策略)`, 'ok');
}
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'};
// 统一走 urlOf:素材前缀(CDN)只在这一处判定,别再复制一份拼接逻辑
return { image:`url("${urlOf(a, preview)}")`, size: layerSize(l, a), 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,
// unit 默认 fit:固定 px 的立绘在小屏必被裁,默认值就该是「不容易出错」的那个
unit:'fit', mob:'inherit',
color:'#fff8e4', alpha:0.55, r:320, cx:50, cy:50,
angle:112, stop:44,
};
}
/* ---------------------------- 导出 CSS ---------------------------- */
function urlOf(a, preview){
if (preview) return a.dataURL; // 预览必须用内联 dataURL,否则改一张图要等一轮部署
const base = String(S.assetBase || '').trim().replace(/\/+$/, '');
return base ? `${base}/${S.slug}/${a.id}.webp` : `/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'};`,
// 背景图是 cover:宽高比不匹配就必裁。焦点决定「裁掉哪一边」——
// 16:9 场景在竖屏上只能留中间一条,把焦点挪到人物那一侧才不至于裁到脸。
` --tb-page-position: ${cfg.bgPos || 'center center'};`,
].join('\n');
}
/** 窄屏下某一层的三个值(图片 / 尺寸 / 位置)。
策略来自图层自己的 mob,全局 hideDeco 高于一切(那是「一次全藏」的急停开关)。 */
function decoMobile(l){
if (!l) return null;
if (S.narrow.hideDeco) return null;
const mob = l.mob || 'inherit';
if (mob === 'hide') return null;
if (mob === 'scale' && l.kind === 'image'){
const s = clamp(Number(S.narrow.scale) || MOB_SCALE_DEFAULT, 0.2, 1);
return Object.assign({}, l, {
h: Math.max(1, Math.round((Math.round(l.h) || 200) * s)),
w: l.w > 0 ? Math.round(l.w * s) : 0,
dx: Math.round(l.dx * s),
dy: Math.round(l.dy * s),
});
}
return l;
}
function decoVars(cfg, preview, mobile){
const layers = cfg.layers.slice(0, SLOT);
const imgs = [], sizes = [], poss = [];
for (let i = 0; i < SLOT; i++){
const raw = layers[i];
const l = mobile ? decoMobile(raw) : raw;
// 隐藏的层必须把 image 一起写成 none:只清 size 会留一张按 auto 尺寸铺开的原图
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})`;
}
/** 半透明色压到某个不透明底色上,得到「实际看到」的颜色。
卡片的 --tb-surface 常写成 rgba(255,255,255,0.86),直接拿它算对比度是错的。 */
function flatten(c, under){
const A = parseColor(c), B = parseColor(under);
if (A[3] >= 1) return `rgb(${A[0]}, ${A[1]}, ${A[2]})`;
const m = i => Math.round(A[i] * A[3] + B[i] * (1 - A[3]));
return `rgb(${m(0)}, ${m(1)}, ${m(2)})`;
}
/* WCAG 相对亮度 / 对比度。文字色的下限不是审美问题,是硬指标:
正文 4.5:1(AA)。base.css 已经按这个标准重算过 --tb-text-muted,
主题若按「往底色混 55%」拍脑袋派生,等于每次生成都把那个修复覆盖回去。 */
function relLum(c){
const A = parseColor(c);
const f = v => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(A[0]) + 0.7152 * f(A[1]) + 0.0722 * f(A[2]);
}
function contrastRatio(a, b){
const la = relLum(a), lb = relLum(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
/** 在 text→bg 的连线上取「最淡但仍达标」的点:二分找出刚好达标的混合比例。
文字本来就不达标(底色与文字太接近)时原样返回文字色 —— 宁可不够淡,
也不能产出一行看不见的字。bgs 传多个时按最不利的那个算(卡片叠在底色上)。 */
function mixToContrast(text, bgs, target){
const worst = t => Math.min(...bgs.map(b => contrastRatio(mixRGB(text, bgs[0], t), b)));
if (worst(0) <= target) return text;
let lo = 0, hi = 0.92;
for (let i = 0; i < 14; i++){
const mid = (lo + hi) / 2;
if (worst(mid) >= target) lo = mid; else hi = mid;
}
// 往下收半档:浮点四舍五入后掉回 4.49:1 这种「差一点」最难查
return mixRGB(text, bgs[0], Math.floor(lo * 100) / 100);
}
/** 文字要同时落在页面底色和卡片上:把卡片的不透明度压平后,取两者中更不利的那个。 */
function textBackdrops(p){
return [p.bg, flatten(p.surface, p.bg)];
}
/** 主色作背景时的文字色:白字和深字里挑对比度更高的那个。
浅色模式主色深 → 白字;深色模式主色普遍偏亮(#5c8dff 之流),白字只有 2~3:1,
必须换成深字。不写这个令牌就只能吃 base.css 的默认值,深浅两套必然错一套。 */
function onPrimary(primary){
return contrastRatio('#ffffff', primary) >= contrastRatio('#0b1016', primary) ? '#ffffff' : '#0b1016';
}
function paletteVars(p){
const back = textBackdrops(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: ${mixToContrast(p.text, back, 5.5)};`,
` --tb-text-muted: ${mixToContrast(p.text, back, 4.5)};`,
` --tb-primary: ${p.primary};`,
` --tb-primary-weak: ${withAlpha(p.primary, 0.12)};`,
` --tb-on-primary: ${onPrimary(p.primary)};`,
].join('\n');
}
const NONE10 = ' --tb-deco: ' + Array(SLOT).fill('none').join(', ') + ';\n' +
' --tb-deco-size: ' + Array(SLOT).fill('auto').join(', ') + ';\n' +
' --tb-deco-position: ' + Array(SLOT).fill('left top').join(', ') + ';';
function buildCSS(preview){
const slug = S.slug || 'my-theme';
const L = [];
L.push('/* ' + slug + ' —— 由 dev/theme-builder 生成,可直接手改。');
L.push(' 装饰层固定 ' + SLOT + ' 个槽位(对应 base.css 的 12 层背景);');
L.push(' 按页面覆盖时也必须写满 ' + SLOT + ' 个,否则图层会与 size/position 错位。 */');
L.push(`html[data-theme-style="${slug}"] {`);
L.push(paletteVars(S.palette));
L.push(bgVars(S.scopes.global, preview));
L.push(decoVars(S.scopes.global, preview));
L.push('}');
L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"] {`);
L.push(paletteVars(S.dark));
L.push(` --tb-page-image: linear-gradient(180deg, ${S.dark.bg} 0%, ${S.dark.bg} 60%, #0e1319 100%);`);
L.push(' --tb-page-pattern: none;');
L.push(NONE10);
L.push('}');
for (const sc of SCOPES){
if (sc.k === 'global') continue;
const cfg = S.scopes[sc.k];
if (cfg.inherit) continue;
const sel = `html[data-theme-style="${slug}"][data-page-scope="${sc.k}"]`;
L.push('');
L.push(`/* ${sc.label} ${sc.desc} */`);
L.push(sel + ' {');
L.push(bgVars(cfg, preview));
L.push(decoVars(cfg, preview));
L.push('}');
L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"][data-page-scope="${sc.k}"] {`);
L.push(' --tb-page-image: none;');
L.push(' --tb-page-pattern: none;');
L.push(NONE10);
L.push('}');
}
// 窄屏段:只写浅色规则。深色块特异性更高(0,2,1 > 0,1,1),
// 本来就压住这里,不需要再为深色写一份 —— 少一层嵌套少一处出错。
// 注意 `fit` / `vh` 单位本身就是视口相对的,浏览器会自己缩,不需要这里插手;
// 这一段只负责「换竖版图」「某层要缩小或退场」这类必须改值的事。
const nb = S.narrow.bg ? assetById(S.narrow.bg) : null;
const hasMob = SCOPES.some(sc => {
const c = S.scopes[sc.k];
if (sc.k !== 'global' && c.inherit) return false;
return (c.layers || []).some(l => (l.mob || 'inherit') !== 'inherit');
});
if (S.narrow.enabled && (nb || S.narrow.hideDeco || hasMob)){
L.push('');
L.push('/* 窄屏:卡片几乎占满宽度,两侧没有留白,桌面那套背景与装饰在这里只会被裁/被盖。');
L.push(' 每个独立配置的页面都要写一条 —— 页面覆盖块特异性更高(0,2,1),');
L.push(' 只写 html[data-theme-style] 的话,独立配置的页面在手机上根本不生效。 */');
L.push(`@media (max-width: ${S.narrow.maxWidth || 900}px) {`);
for (const sc of SCOPES){
const cfg = S.scopes[sc.k];
if (sc.k !== 'global' && cfg.inherit) continue;
const sel = sc.k === 'global'
? `html[data-theme-style="${slug}"]`
: `html[data-theme-style="${slug}"][data-page-scope="${sc.k}"]`;
L.push(` ${sel} {`);
// 只替换「本来就有背景图」的页面:某页故意配成纯色(--tb-page-image: none),
// 手机上新塞一张竖图就是改了他的设计
if (nb && cfg.bg) L.push(` --tb-page-image: url("${urlOf(nb, preview)}");`);
if (cfg.bg) L.push(` --tb-page-position: ${nb ? 'center center' : (cfg.bgPos || 'center center')};`);
decoVars(cfg, preview, true).split('\n').forEach(line => L.push(' ' + line));
L.push(' }');
}
L.push('}');
}
return L.join('\n') + '\n';
}
/* ---------------------------- iframe ---------------------------- */
let doc = null, frameReady = false;
function previewFile(){
const sc = SCOPES.find(x => x.k === S.scope);
const page = (sc && sc.page) || 'box';
return `../../outputs/preview-${page}-${S.mode}.html`;
}
function loadPreview(){
frameReady = false;
const url = previewFile();
$('stage-url').textContent = url.replace('../../','');
$('preview').src = url;
}
function onFrameLoad(){
const ifr = $('preview');
try {
doc = ifr.contentDocument || ifr.contentWindow.document;
if (!doc || !doc.documentElement) throw new Error('no document');
} catch (e){
doc = null;
$('empty').style.display = 'flex';
$('conn').className = 'conn bad';
$('conn').textContent = '需要本地服务';
out('无法访问 iframe 的 DOM。请用 python dev/theme-builder/serve.py 启动,再用 ' + HERE + ' 打开。', '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 vw = doc.documentElement.clientWidth;
const vh = doc.documentElement.clientHeight;
// 与 background-size/position 走同一套换算式(layerMetrics),
// 否则 fit/vh 单位下框和实际图会错位
const m = layerMetrics(l, a, vw, vh);
return { w: m.w, h: m.h, left: Math.round(m.left), top: Math.round(m.top) };
}
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 返回的是 global 的图层,
// 一拖就改到全局去了 —— 用户以为在调这一页,实际全站都变了。
if (S.scope !== 'global' && S.scopes[S.scope].inherit) return;
cfgOf(S.scope).layers.forEach(l => {
if (l.kind !== 'image') return;
const g0 = guideGeom(l);
if (!g0) return;
const g = doc.createElement('div');
g.dataset.layer = l.id;
g.style.cssText = [
'position:absolute', `width:${g0.w}px`, `height:${g0.h}px`,
`left:${g0.left}px`, `top:${g0.top}px`,
'border:1px dashed rgba(91,155,213,.95)', 'border-radius:2px',
'pointer-events:auto', 'cursor:move', 'background:none',
].join(';');
const tag = doc.createElement('span');
tag.textContent = '拖动';
tag.style.cssText = 'position:absolute;left:0;top:0;font:10px/14px system-ui;z-index:1;' +
'color:#0b1219;background:rgba(91,155,213,.92);padding:0 4px;border-radius:2px 0 2px 0';
g.appendChild(tag);
g.title = '拖动调整位置';
attachDrag(g, l);
ov.appendChild(g);
});
highlightGuide(S.curLayer);
}
function attachDrag(el, l){
el.addEventListener('pointerdown', e => {
e.preventDefault(); e.stopPropagation();
try { el.setPointerCapture(e.pointerId); } catch (_) {}
const sx = e.clientX, sy = e.clientY, dx0 = l.dx, dy0 = l.dy;
// 视口单位下 CSS 会把偏移按视口比缩放,鼠标的 1px 不等于 dx 的 1 —— 不除回来手感会发飘。
// fitw 横向按 vw、纵向仍按 vh,与 POS/axisPos 的规则一致。
const u0 = unitOf(l);
const kvx = u0 === 'vw' ? (doc.documentElement.clientWidth || DESIGN_W) / DESIGN_W
: u0 === 'vh' ? (doc.documentElement.clientHeight || DESIGN_H) / DESIGN_H : 1;
const kvy = u0 === 'px' ? 1 : (doc.documentElement.clientHeight || DESIGN_H) / DESIGN_H;
const move = ev => {
l.dx = Math.round(dx0 + (ev.clientX - sx) / kvx);
l.dy = Math.round(dy0 + (ev.clientY - sy) / kvy);
// 快速路径:只改 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 vp = vpOf(S.viewport);
// 高度必须跟着预设走:裁切几乎全由「视口高」决定,
// 一律按 900 渲染的话,768 的旧笔电和 844 的手机根本模拟不出来
const k = Math.min(1, (canvas.clientWidth - 28) / vp.w, (canvas.clientHeight - 28) / vp.h);
ifr.style.width = vp.w + 'px';
ifr.style.height = vp.h + 'px';
ifr.style.transform = `scale(${k})`;
frame.style.width = Math.round(vp.w * k) + 'px';
frame.style.height = Math.round(vp.h * 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 isGlobal = S.scope === 'global';
const inherit = !isGlobal && S.scopes[S.scope].inherit;
// 展示用 cfg:跟随全局时就是 global 那套(下拉里要能看到当前实际生效的值)
const cfg = cfgOf(S.scope);
$('inherit').checked = inherit;
$('inherit').disabled = isGlobal;
const el = $('bg-editor');
el.innerHTML = '';
// 之前 inherit 时整个下拉是 disabled,用户在箱主页/问题页看背景还在、又点不动,
// 这就是「背景图一直保留、关不掉」的来源。现在一律可点:一改就自动变独立配置。
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.onchange = () => {
ensureOwn(true);
const c = S.scopes[S.scope];
c[key] = sel.value || null;
renderAll();
};
row.appendChild(sel);
el.appendChild(row);
};
mkPick('背景图', 'bg');
mkPick('平铺图案', 'pattern');
// 背景图是 cover 铺满:宽高比不匹配就必裁,焦点决定裁掉哪一边。
// 16:9 场景在竖屏上只剩中间一条,人物偏一侧时把焦点挪过去才不会裁到脸。
const frow = document.createElement('div');
frow.className = 'row';
const fs = document.createElement('span');
fs.textContent = '焦点';
const grid = document.createElement('div');
grid.className = 'grid9';
['top','center','bottom'].forEach(v => {
['left','center','right'].forEach(h => {
const val = h + ' ' + v;
const b = document.createElement('button');
b.title = val;
b.className = (cfg.bgPos || 'center center') === val ? 'on' : '';
b.onclick = () => {
ensureOwn(true);
S.scopes[S.scope].bgPos = val;
renderAll();
};
grid.appendChild(b);
});
});
frow.append(fs, grid);
el.appendChild(frow);
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.style.width = '110px';
inp.onchange = () => {
ensureOwn(true);
S.scopes[S.scope].patternSize = inp.value.trim() || '320px 320px';
renderAll();
};
row.append(s, inp);
el.appendChild(row);
const tip = document.createElement('p');
tip.className = 'hint';
if (inherit){
// 说清楚「你看到的背景是从哪来的」,不然用户会以为关掉的是本页的
const src = cfg.bg ? assetById(cfg.bg) : null;
tip.textContent = `当前全部继承自「全局」:背景 ${src ? src.name : '无'}`
+ `,图案 ${cfg.pattern && assetById(cfg.pattern) ? assetById(cfg.pattern).name : '无'}。`
+ '在这里改任意一项会自动变成本页独立配置。';
} else {
tip.textContent = '背景图按 cover 铺满整页;平铺图案叠在背景图之上、装饰图层之下。';
}
el.appendChild(tip);
}
function layerName(l){
if (l.kind === 'image'){
const a = assetById(l.asset);
return a ? a.name : '(未选图片)';
}
return l.kind === 'radial' ? '光斑' : '线性光';
}
function moveLayer(cfg, idx, delta){
const j = idx + delta;
if (j < 0 || j >= cfg.layers.length) return;
const [it] = cfg.layers.splice(idx, 1);
cfg.layers.splice(j, 0, it);
renderAll();
}
/* 在任何「改动本页配置」之前调用:跟随全局的话先切成独立配置。
否则改动会写到 global 对象上 —— 用户以为只改了这一页,实际全站都变了。
copy=true 时把全局的当前值复制过来当起点(改背景不会顺带把图案弄丢);
图层要重新发 id,否则两个作用域共用同一批 id,拖拽/高亮会串。 */
function ensureOwn(copy){
if (S.scope === 'global') return false;
const c = S.scopes[S.scope];
if (!c.inherit) return false;
c.inherit = false;
if (copy){
const g = S.scopes.global;
c.bg = g.bg;
c.pattern = g.pattern;
c.patternSize = g.patternSize;
c.layers = g.layers.map(l => Object.assign({}, l, { id: uid('L') }));
}
S.curLayer = null;
const label = (SCOPES.find(x => x.k === S.scope) || {}).label || S.scope;
log(`「${label}」已改为独立配置(原来跟随全局)`
+ (copy && S.scopes.global.layers.length ? `,已复制全局那 ${S.scopes.global.layers.length} 层作起点` : '')
+ ',这里改的不影响别的页面', 'w');
return true;
}
function assetOptions(){
return '<option value="">(选图片)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
}
function renderLayers(){
const own = S.scope === 'global' || !S.scopes[S.scope].inherit;
const cfg = cfgOf(S.scope);
const wrap = $('layers');
wrap.innerHTML = '';
// 「加一层」始终可用:跟随全局时点了会自动切成独立配置。
// 之前这里 inherit 就 disabled,用户在箱主页点不动,只会以为工具坏了。
$('add-layer').disabled = cfg.layers.length >= SLOT;
if (!own){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = `这一页跟随全局,用的是全局那 ${cfg.layers.length} 层(不在这里改)。`;
wrap.appendChild(p);
const b = document.createElement('button');
b.textContent = '改为独立配置';
b.style.cssText = 'width:100%;margin-top:2px';
b.onclick = () => { ensureOwn(); renderAll(); };
wrap.appendChild(b);
$('layer-editor').innerHTML = '';
S.curLayer = null;
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 a = l.kind === 'image' ? assetById(l.asset) : null;
// 窄屏下这一层会不会消失:全局「全藏」优先,其次是层自己的策略。
// 标出来是因为「素材在手机上不见了」绝大多数就是这个,而桌面预览完全正常。
const mobOff = S.narrow.enabled && (S.narrow.hideDeco || (l.mob || 'inherit') === 'hide');
const color = l.kind === 'image' ? '#5b9bd5' : l.kind === 'radial' ? '#d8a657' : '#9b7fd0';
const thumb = l.kind !== 'image'
? `<span class="th no" style="color:${color}">~</span>`
: (a ? `<img class="th" src="${a.dataURL}" alt="">` : '<span class="th no">?</span>');
d.innerHTML = `<div class="top">
<i class="dot" style="background:${color}"></i>${thumb}
<span class="nm">${esc(layerName(l))}</span>
<span class="k">${i === 0 ? '最上层' : '第 ' + (i + 1) + ' 层'}${mobOff ? ' · 窄屏隐藏' : ''}</span>
<button class="ord" data-up title="上移一层(更靠前)" ${i === 0 ? 'disabled' : ''}>↑</button>
<button class="ord" data-down title="下移一层(更靠后)" ${i === cfg.layers.length - 1 ? 'disabled' : ''}>↓</button>
<button class="x" data-del title="删除">×</button>
</div>`;
// 图片层:换图的下拉直接放在行里,不必先选中再到底下的编辑器里找。
// 之前只有「选中那一层」的编辑器里有下拉,于是看起来像「每层都只能用同一张图」。
if (l.kind === 'image'){
const pick = document.createElement('div');
pick.className = 'pick';
const tag = document.createElement('span');
tag.className = 'tag';
tag.textContent = '图片';
const sel = document.createElement('select');
sel.innerHTML = assetOptions();
sel.value = l.asset || '';
sel.onclick = e => e.stopPropagation();
sel.onchange = () => {
l.asset = sel.value || null;
// 只有一张图时,切到「(选图片)」等于这层没东西,直接说清楚
if (!l.asset) log('这一层暂未选图片,画布上不会显示', 'w');
renderAll();
};
pick.append(tag, sel);
d.appendChild(pick);
}
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.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();
};
d.onclick = () => {
S.curLayer = l.id;
renderLayers();
highlightGuide(l.id);
};
wrap.appendChild(d);
});
renderLayerEditor(cfg.layers.find(l => l.id === S.curLayer) || cfg.layers[0] || null);
}
function highlightGuide(id){
if (!doc) return;
doc.querySelectorAll('#__tb_ov > div').forEach(g => {
const on = g.dataset.layer === id;
g.style.borderColor = on ? '#ffd479' : 'rgba(91,155,213,.95)';
g.style.background = 'none';
const tag = g.querySelector('span');
if (tag) tag.style.background = on ? 'rgba(255,212,121,.95)' : 'rgba(91,155,213,.92)';
});
}
function renderLayerEditor(l){
const host = $('layer-editor');
host.innerHTML = '';
if (!l){ S.curLayer = null; return; }
S.curLayer = l.id;
const box = document.createElement('div');
box.className = 'editor';
host.appendChild(box);
const row = (label, ...nodes) => {
const r = document.createElement('div');
r.className = 'row';
const s = document.createElement('span');
s.textContent = label;
r.appendChild(s);
nodes.forEach(n => r.appendChild(n));
box.appendChild(r);
return r;
};
if (l.kind === 'image'){
// 换图的下拉在图层行里,这里只放尺寸,避免同一件事有两个入口
const sel = (opts, val, on, title) => {
const s = document.createElement('select');
opts.forEach(([k, t]) => {
const o = document.createElement('option');
o.value = k; o.textContent = t;
s.appendChild(o);
});
s.value = val;
s.onchange = () => on(s.value);
if (title) s.title = title;
return s;
};
// 尺寸单位 = 「屏幕变矮/变窄时这张图怎么办」。固定 px 是绝大多数「被裁」的根源。
// 下拉的 value 必须用原始 l.unit('fit'),不能过 unitOf —— 那会把 fit 归一成 vh,
// 于是选了「按视口高比例」却拿到 clamp 的输出,看起来像开关失灵。
row('尺寸单位', sel([['fit','自适应·按高度'],['fitw','自适应·按宽度'],['px','固定 px'],['vh','按视口高比例']],
l.unit || 'px', v => { l.unit = v; renderAll(); applyTheme(); },
'按高度:人物/竖幅用,屏幕更矮就等比缩(最低 55%);' +
'按宽度:横幅/整屏插画用,屏幕更窄就等比缩(按高度缩的话宽度会跟着涨,窄屏照样裁);' +
'按视口高比例:永远占同样比例的屏高,无下限'));
row('高度 px', numInput(l.h, 20, 4000, v => { l.h = v; renderAll(); }));
if (unitOf(l) === 'px'){
const wIn = numInput(l.w, 0, 4000, v => { l.w = v; renderAll(); });
wIn.title = '0 表示按图片原始比例自动算宽度';
row('宽度 0=自动', wIn);
}
// 窄屏策略:手机上「全藏」太粗暴,缩小往往就够用
row('窄屏', sel([['inherit','原样'],['scale',`缩小到 ${Math.round((S.narrow.scale||MOB_SCALE_DEFAULT)*100)}%`],['hide','隐藏']],
l.mob || 'inherit', v => { l.mob = v; renderAll(); },
'只在 @media 断点以下生效;缩小是把高度与偏移一起乘系数,位置锚点不变'));
// 全局「全藏」开着时这一项不生效 —— 不写出来,用户会以为自己设的缩小没起作用
if (S.narrow.enabled && S.narrow.hideDeco){
const ntip = document.createElement('p');
ntip.className = 'hint';
ntip.style.color = '#e0a33e';
ntip.textContent = '↑ 被「窄屏隐藏全部装饰层」覆盖,这一项当前不生效';
box.appendChild(ntip);
}
const a0 = assetById(l.asset);
if (a0){
const vp = vpOf(S.viewport);
const m = layerMetrics(l, a0, vp.w, vp.h);
const over = [];
if (m.overL > 0.5) over.push(`左 ${Math.round(m.overL)}px`);
if (m.overR > 0.5) over.push(`右 ${Math.round(m.overR)}px`);
if (m.overT > 0.5) over.push(`上 ${Math.round(m.overT)}px`);
if (m.overB > 0.5) over.push(`下 ${Math.round(m.overB)}px`);
const tip = document.createElement('p');
tip.className = 'hint';
if (over.length){
tip.style.color = '#e0a33e';
tip.textContent = `⚠ ${vp.w}×${vp.h}:${over.join(' / ')} 越界`;
} else {
// 一律取整:131.25 这种小数在窄面板里既占位置又没意义
tip.textContent = `${vp.w}×${vp.h} 下显示 ${Math.round(m.w)}×${Math.round(m.h)},完整可见`;
}
box.appendChild(tip);
if (over.length){
const fix = document.createElement('button');
fix.textContent = `按 ${vp.w}×${vp.h} 修好`;
fix.style.cssText = 'width:100%;margin-top:5px;font-size:11px';
fix.title = '改成固定 px,并取「宽 ≤96%、高 ≤92% 视口」的尺寸;更窄的屏请配合「窄屏」策略';
fix.onclick = () => fitToViewport(l, a0, vp);
box.appendChild(fix);
}
}
}
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';
const fxOn = !fxIsEmpty(a.fx);
const ow = (a.orig && a.orig.w) || a.w, oh = (a.orig && a.orig.h) || a.h;
d.title = `${a.name} · ${a.w}×${a.h}(原始 ${ow}×${oh})· ${a.kb}KB`
+ (fxOn ? '\n已处理:' + fxSummary(a.fx) : '\n点击左上角 fx 可处理');
d.innerHTML = `<img src="${a.dataURL}" alt="">
<span class="fx${fxOn ? ' on' : ''}" data-fx="${a.id}" title="处理:透明度 / 压白 / 去白底 / 裁边">fx</span>
<span class="meta">${a.w}×${a.h}</span>
<span class="del" data-del="${a.id}">×</span>`;
d.querySelector('[data-fx]').onclick = e => {
e.stopPropagation();
openFx(a);
};
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);
});
}
/** 素材卡片的 tooltip 用:把 fx 说成人话,不然「压白 16%」这种参数要开弹窗才想得起来 */
function fxSummary(f){
const p = [];
if (f.alpha !== 100) p.push(`不透明 ${f.alpha}%`);
if (f.wash) p.push(`压白 ${f.wash}%`);
if (f.gray) p.push(`去色 ${f.gray}%`);
if (f.trim) p.push('裁透明边');
if (f.key && f.key.on) p.push(`去白底 容差 ${f.key.tol}`);
return p.length ? p.join(' · ') : '无';
}
function toHex(v){
if (!v) return '#000000';
if (v.startsWith('#')) return v.length === 4
? '#' + v.slice(1).split('').map(c=>c+c).join('') : v;
const m = v.match(/rgba?\(([^)]+)\)/);
if (!m) return '#000000';
const p = m[1].split(',').map(x => parseInt(x.trim(), 10));
return '#' + p.slice(0,3).map(n => n.toString(16).padStart(2,'0')).join('');
}
function renderNarrowEditor(){
const el = $('narrow-editor');
el.innerHTML = '';
const n = S.narrow;
const mk = (text, checked, onChange) => {
const lab = document.createElement('label');
lab.className = 'chk';
lab.style.cssText = 'font-size:12px;color:var(--ui-text);display:flex;gap:6px;margin-bottom:7px';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = checked;
cb.onchange = () => onChange(cb.checked);
lab.append(cb, document.createTextNode(text));
return lab;
};
el.appendChild(mk('导出 @media 块', n.enabled, v => { n.enabled = v; renderAll(); }));
const row = document.createElement('div');
row.className = 'row';
const sp = document.createElement('span');
sp.textContent = '断点 px';
const mw = document.createElement('input');
mw.type = 'number'; mw.min = 320; mw.max = 1400; mw.value = n.maxWidth;
mw.onchange = () => { n.maxWidth = clamp(Number(mw.value) || 900, 320, 1400); renderAll(); };
row.append(sp, mw);
el.appendChild(row);
const rowS = document.createElement('div');
rowS.className = 'row';
const spS = document.createElement('span');
spS.textContent = '缩放系数';
const sc = document.createElement('input');
sc.type = 'number'; sc.min = 20; sc.max = 100; sc.step = 5;
sc.value = Math.round((n.scale || MOB_SCALE_DEFAULT) * 100);
sc.title = '图层「窄屏 = 缩小」时乘的系数。0.6 左右通常就够,太小会让装饰变成噪点';
sc.onchange = () => {
n.scale = clamp((Number(sc.value) || 62) / 100, 0.2, 1);
renderAll();
};
rowS.append(spS, sc);
el.appendChild(rowS);
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(); }));
// 这个开关一旦勾上,用户新加的素材在手机上也一起消失,而桌面预览完全正常
// —— 是「素材在窄屏下不见了」最常见的来源。所以勾上时必须当场把后果写出来,
// 不能只靠一个复选框的标题。
if (n.hideDeco){
let cnt = 0;
SCOPES.forEach(sc => {
const c = S.scopes[sc.k];
if (sc.k !== 'global' && c.inherit) return;
cnt += (c.layers || []).length;
});
const warn = document.createElement('p');
warn.className = 'hint';
warn.style.color = '#e0a33e';
warn.textContent = `⚠ 断点以下 ${cnt} 层全不显示,新加的层也一样。`
+ '要在手机上保留素材:取消勾选,改用每层「窄屏」。';
el.appendChild(warn);
}
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = '手机断点下卡片几乎占满宽度:16:9 的桌面场景会被裁成一条纹理。'
+ '三层优先级从高到低:这个「全部隐藏」→ 每层的窄屏策略(原样 / 缩小 / 隐藏)→ 图层的尺寸单位。'
+ '尺寸单位选「自适应 / 视口高比例」的层自己不依赖这段规则,浏览器会按视口缩。'
+ '只写浅色规则——深色块特异性更高,本来就压住它。';
el.appendChild(tip);
}
/* ---------------------- 导出画质 ---------------------- */
/* WebP 体积 ≈ 像素数 × 质量的乘积。真正的大头是像素:
CSS 里只显示 400px 高的立绘,2 倍屏也只要 800px,上传 3000px 纯属白扛。
所以这里给两个旋钮,先砍长边、再降质量,顺序别反。 */
function renderExportEditor(){
const el = $('export-editor');
if (!el) return;
el.innerHTML = '';
const row = () => {
const r = document.createElement('div');
r.className = 'row';
el.appendChild(r);
return r;
};
const label = (t, w) => {
const s = document.createElement('span');
s.textContent = t;
if (w) s.style.width = w;
return s;
};
// 质量滑块:松手(change)才重编码,拖动过程中不反复跑全量像素。
// 数值放进 label 里而不是单独一个 flex 项:面板只有 240px,三个 flex 项时
// range 的固有宽度会把数值顶出可视区(表现为"滑块没有数字")。
const r1 = row();
const q = clamp(Number(S.export.quality) || 0.88, 0.5, 0.95);
const qlab = document.createElement('span');
qlab.innerHTML = '质量 <b>' + q.toFixed(2) + '</b>';
const qv = qlab.querySelector('b');
qv.style.cssText = 'color:var(--ui-main);font-weight:600';
const qr = document.createElement('input');
qr.type = 'range'; qr.min = 0.5; qr.max = 0.95; qr.step = 0.01; qr.value = q;
// width:0 + flex:1 1 0 是让 range 只吃「剩余空间」的标准写法:
// 光给 flex:1 时它的固有宽度(Chrome 约 129px)会撑破 240px 的面板。
qr.style.cssText = 'flex:1 1 0;width:0;min-width:0';
qr.title = '0.85~0.9 之间肉眼几乎看不出差别;低于 0.8 才会明显糊。上限 0.95 —— WebP 的 q=1 会切无损,体积反而失控';
qr.oninput = () => { qv.textContent = (+qr.value).toFixed(2); };
qr.onchange = async () => {
S.export.quality = clamp(+qr.value, 0.5, 0.95);
qv.textContent = S.export.quality.toFixed(2);
await applyQuality('质量 ' + S.export.quality.toFixed(2));
};
r1.append(qlab, qr);
const r2 = row();
const sides = [['0','不限制'],['2048','2048'],['1600','1600'],['1200','1200'],['800','800']];
const side = document.createElement('select');
side.innerHTML = sides.map(([v, t]) => `<option value="${v}">${t}</option>`).join('');
side.value = String(S.export.maxSide || 0);
side.title = '长边上限。整页背景图建议 1920/不限制;装饰人物 1200 就够(显示 400px × 2 倍屏)';
side.onchange = async () => {
S.export.maxSide = parseInt(side.value, 10) || 0;
await applyQuality('长边 ≤ ' + (S.export.maxSide || '不限'));
};
r2.append(label('长边上限', '62px'), side);
// 素材前缀:空 = 走站点自己的 /static/images/themes(图片随二进制发布);
// 填了 = 导出的 CSS 写绝对 URL,图片自己传对象存储(R2 / OSS)。
// 只影响导出,不影响预览 —— 预览永远用内联 dataURL。
const r3 = row();
const baseIn = document.createElement('input');
baseIn.type = 'url';
baseIn.placeholder = '空 = /static/images/themes';
baseIn.value = S.assetBase || '';
baseIn.style.cssText = 'flex:1 1 0;width:0;min-width:0';
baseIn.title = '填 https://cdn.example.com/themes 这种前缀(不要带 slug),导出的 CSS 就会写绝对地址;'
+ '图片仍会打进 zip,方便你上传。留空则沿用站点静态目录';
baseIn.onchange = () => {
S.assetBase = baseIn.value.trim().replace(/\/+$/, '');
renderAll();
};
// 标签固定宽度:flex 会把 4 个中文字的 label 压窄,末字被输入框切掉
const baseLbl = label('素材前缀', '84px');
baseLbl.style.cssText = 'flex:0 0 auto;white-space:nowrap';
r3.append(baseLbl, baseIn);
const stat = document.createElement('p');
stat.className = 'hint';
const total = S.assets.reduce((n, a) => n + (a.kb || 0), 0);
if (!S.assets.length){
stat.textContent = '还没有图片。上传时会按上面的设置直接编码。';
} else {
const big = S.assets.slice().sort((a, b) => b.kb - a.kb)[0];
stat.textContent = `${S.assets.length} 张,共 ${total >= 1024 ? (total / 1024).toFixed(1) + 'MB' : total + 'KB'}`
+ `;最大一张 ${big.id}.webp ${big.kb}KB(${big.w}×${big.h})`;
}
el.appendChild(stat);
if (S.assetBase){
const cdn = document.createElement('p');
cdn.className = 'hint';
// 已经在「素材托管」里配了 S3 的人,不需要再被教一遍「把 zip 里的图传上去」
const how = S.host.mode === 's3'
? '用下面的「素材托管」直接上传即可(配好 Endpoint / 桶 / 密钥 → 测试连接 → 上传素材);'
: `记得把 zip 里的 static/images/themes/${S.slug}/ 整个传到这个路径下;仓库里就不需要这些图了。`;
cdn.textContent = `图片地址将写成 ${S.assetBase.replace(/\/+$/, '')}/${S.slug}/<id>.webp —— `
+ how + '换 CDN 域名要重新导出 CSS。';
el.appendChild(cdn);
}
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = '压体积的顺序:先砍长边,再降质量。改完按「原图 + 处理参数」重新编码全部素材,'
+ '不叠加压缩 —— 来回试不会越调越糊。注意平涂/线稿素材降质量可能反而变大(有损会引入噪点),'
+ '这类图优先调长边。';
el.appendChild(tip);
}
/* ---------------------- 素材托管(本地 / 任意 S3 兼容) ---------------------- */
/* 素材放哪有两选:
local —— 打进 static/images/themes/<slug>/,随二进制发布,部署即生效;
s3 —— 传到自己选的对象存储,导出的 CSS 写绝对 URL。
第二项刻意不锁厂商:R2 / MinIO / Backblaze B2 / 阿里 OSS(S3) / 腾讯 COS(S3) 用的是
同一套 SigV4,差别只在 endpoint、region、是否路径风格 —— 那就全做成字段,让人自己填。
上传走本机 serve.py 的代理(/api/s3/upload):浏览器不直连存储,因此不用配 CORS,
密钥也不会跟着页面请求发到第三方去。 */
const SEC_SESSION_KEY = 'tamabox-theme-builder-s3-secrets';
const s3Secrets = { accessKey: '', secretKey: '' };
try {
// sessionStorage 而不是 localStorage:关掉标签页就该忘掉密钥。
const raw = sessionStorage.getItem(SEC_SESSION_KEY);
if (raw) Object.assign(s3Secrets, JSON.parse(raw) || {});
} catch (e) { /* 隐私模式 / file:// 下取不到,忽略 */ }
function saveSecrets(){
try { sessionStorage.setItem(SEC_SESSION_KEY, JSON.stringify(s3Secrets)); } catch (e) {}
}
/** 按 endpoint 推导公开访问前缀。桶若开了公开读(或 MinIO 直连)就能直接命中;
R2 / 私有桶需要自定义域,那种情况请自己填「公开访问前缀」。 */
function derivePublicBase(h){
const raw = String(h.endpoint || '').trim();
if (!raw || !h.bucket) return '';
let u;
try { u = new URL(/^https?:\/\//i.test(raw) ? raw : 'https://' + raw); }
catch (e) { return ''; }
const prefix = String(h.prefix || '').replace(/^\/+|\/+$/g, '');
const base = h.pathStyle
? `${u.origin}/${h.bucket}`
: `${u.protocol}//${h.bucket}.${u.host}`;
return prefix ? `${base}/${prefix}` : base;
}
/** 最终写进 CSS 的前缀(不带 slug)。手填优先,空则推导。 */
function publicBaseOf(h){
const own = String(h.publicBase || '').trim().replace(/\/+$/, '');
return own || derivePublicBase(h).replace(/\/+$/, '');
}
function s3Config(){
const h = S.host;
return {
endpoint: String(h.endpoint || '').trim(),
region: String(h.region || '').trim() || 'auto',
bucket: String(h.bucket || '').trim(),
prefix: String(h.prefix || '').trim().replace(/^\/+|\/+$/g, ''),
pathStyle: !!h.pathStyle,
accessKey: s3Secrets.accessKey,
secretKey: s3Secrets.secretKey,
};
}
function s3Missing(cfg){
const miss = [];
if (!cfg.endpoint) miss.push('Endpoint');
if (!cfg.bucket) miss.push('桶名');
if (!cfg.accessKey) miss.push('Access Key');
if (!cfg.secretKey) miss.push('Secret Key');
return miss;
}
function setS3State(kind, text){
S.s3.kind = kind || '';
S.s3.text = text || '';
const chip = $('host-state');
if (chip){
chip.className = 'conn' + (kind === 'ok' ? ' ok' : kind === 'bad' ? ' bad' : '');
chip.textContent = kind === 'ok' ? '已连通' : kind === 'bad' ? '未通过'
: (S.host.mode === 's3' ? 'S3' : '本地');
}
const box = $('s3-result');
if (box){
box.className = 's3stat' + (kind ? ' ' + kind : '');
box.textContent = text || '';
box.style.display = text ? '' : 'none';
}
}
/** 探测本机服务有没有上传接口。`python -m http.server` 起的只有静态文件服务。 */
async function ensureUploadPing(){
if (S.s3.canUpload !== null) return S.s3.canUpload;
try {
const r = await fetch('/api/s3/ping', { cache: 'no-store' });
const j = await r.json();
S.s3.canUpload = !!(j && j.upload);
} catch (e) {
S.s3.canUpload = false;
}
renderHostEditor();
return S.s3.canUpload;
}
async function sbApi(path, body){
const r = await fetch(path, {
method: 'POST', cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const txt = await r.text();
try { return JSON.parse(txt); }
catch (e) { throw new Error(`本地服务返回了非 JSON(HTTP ${r.status}):${txt.slice(0, 120)}`); }
}
function renderHostEditor(){
const el = $('host-editor');
if (!el) return;
el.innerHTML = '';
const pick = document.createElement('div');
pick.className = 'hostpick';
[['local', '本地(随二进制)'], ['s3', 'S3 兼容存储']].forEach(([k, text]) => {
const b = document.createElement('button');
b.textContent = text;
if (S.host.mode === k) b.className = 'on';
b.onclick = () => { S.host.mode = k; setS3State('', ''); renderAll(); saveDraft(); };
pick.appendChild(b);
});
el.appendChild(pick);
if (S.host.mode !== 's3'){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '图片打进 static/images/themes/<slug>/,随二进制发布 —— 部署即生效、不依赖外部服务,'
+ '代价是换一张图要重新编译。选右边可以把素材放到自己的对象存储。';
el.appendChild(p);
setS3State('', '');
return;
}
// ---- 字段 ----
const field = (label, key, opts) => {
opts = opts || {};
const r = document.createElement('div');
r.className = 'row';
const s = document.createElement('span');
s.textContent = label;
s.style.width = '84px';
const i = document.createElement('input');
i.type = opts.password ? 'password' : 'text';
i.spellcheck = false;
i.autocomplete = 'off';
i.placeholder = opts.placeholder || '';
i.style.cssText = 'flex:1 1 0;width:0;min-width:0';
if (opts.password){
i.value = s3Secrets[key] || '';
i.oninput = () => { s3Secrets[key] = i.value; saveSecrets(); };
} else {
i.value = S.host[key] == null ? '' : String(S.host[key]);
i.oninput = () => {
S.host[key] = i.value;
scheduleSave();
if (key === 'endpoint' || key === 'bucket' || key === 'prefix' || key === 'pathStyle') refreshDerived();
};
// 失焦立刻落盘:scheduleSave 有 700ms 防抖,而填完最后一个字段就去点
// 「测试连接 → 上传 → 刷新」时,草稿可能还没写下去,配置就白填了。
i.onchange = () => saveDraft();
}
r.append(s, i);
el.appendChild(r);
return i;
};
const derived = document.createElement('p');
derived.className = 'hint';
derived.id = 's3-derived';
function refreshDerived(){
const auto = derivePublicBase(S.host) || '(填 Endpoint 与桶名后自动推导)';
derived.textContent = '图片地址 = ' + (publicBaseOf(S.host) || auto) + '/' + (($('slug').value || '').trim() || 'my-theme')
+ '/<id>.webp';
}
field('Endpoint', 'endpoint', { placeholder: 'https://<账号>.r2.cloudflarestorage.com' });
field('Region', 'region', { placeholder: 'auto' });
field('桶名', 'bucket', { placeholder: 'my-theme-assets' });
field('路径前缀', 'prefix', { placeholder: 'themes(可留空 = 传桶根)' });
field('公开前缀', 'publicBase', { placeholder: '留空 = 按 Endpoint 推导' });
field('Access Key', 'accessKey', { password: true });
field('Secret Key', 'secretKey', { password: true });
const chk = document.createElement('label');
chk.className = 'chk';
chk.style.margin = '2px 0 0 0';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!S.host.pathStyle;
cb.onchange = () => {
S.host.pathStyle = cb.checked;
refreshDerived();
saveDraft();
};
chk.append(cb, document.createTextNode('路径风格(MinIO / R2 / B2 勾选;AWS 与虚拟主机域名取消)'));
el.appendChild(chk);
el.appendChild(derived);
refreshDerived();
const acts = document.createElement('div');
acts.className = 's3acts';
const btnTest = document.createElement('button');
btnTest.textContent = S.s3.busy ? '请稍候…' : '测试连接';
const btnUp = document.createElement('button');
btnUp.className = 'primary';
btnUp.textContent = S.s3.busy ? '请稍候…' : '上传素材';
const can = S.s3.canUpload;
if (can === false){
btnTest.disabled = true;
btnUp.disabled = true;
btnTest.title = btnUp.title = '上传接口不可用';
} else if (S.s3.busy){
btnTest.disabled = btnUp.disabled = true;
}
btnTest.onclick = testS3;
btnUp.onclick = uploadAssets;
acts.append(btnTest, btnUp);
el.appendChild(acts);
const res = document.createElement('div');
res.id = 's3-result';
res.className = 's3stat' + (S.s3.kind ? ' ' + S.s3.kind : '');
res.style.display = S.s3.text ? '' : 'none';
res.textContent = S.s3.text || '';
el.appendChild(res);
// 远端清单放在状态框下面:先看「测试/上传结果」,再看「远端现在到底有什么」
el.appendChild(renderRemoteBlock());
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = can === false
? '当前是普通静态服务,没有上传接口。用 python dev/theme-builder/serve.py 启动生成器才能上传。'
: '密钥只存在当前标签页(sessionStorage):刷新还在,关掉标签页就没了,也不进工程文件与导出 zip。'
+ '想复用就别关标签页,或用脚本直传(见 dev/theme-tools/export_assets.py)。'
+ '上传成功后会自动把「素材前缀」填成上面的图片地址。';
el.appendChild(tip);
if (can === null) ensureUploadPing();
}
async function testS3(){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
S.s3.busy = true; setS3State('', '正在测试…'); renderHostEditor();
try {
const j = await sbApi('/api/s3/test', { config: cfg });
if (!j.ok){
const lines = ['测试失败:' + (j.error || '未知错误')];
if (j.code) lines.push('错误码 ' + j.code + (j.status ? `(HTTP ${j.status})` : ''));
if (j.hint) lines.push(j.hint);
setS3State('bad', lines.join('\n'));
log('S3 测试失败:' + (j.error || ''), 'e');
} else {
const lines = (j.steps || []).map(s => `${s.warn ? '·' : '✓'} ${s.name}:${s.detail}`);
const warn = (j.steps || []).some(s => s.warn);
S.s3.lastOK = Date.now();
setS3State(warn ? 'warn' : 'ok', lines.join('\n'));
log('S3 连通性测试通过(' + j.key + ' 已清理)', warn ? 'w' : 'ok');
}
} catch (e){
setS3State('bad', '请求本地服务失败:' + e.message);
} finally {
S.s3.busy = false; renderHostEditor();
}
}
async function uploadAssets(onlyIds){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
const slug = currentSlug();
// usedAssetIds 返回 Set:它有 size 没有 length/map,先展开成数组再用。
// onlyIds 是「补齐差异」传进来的子集 —— 那时只该传缺的/变了的。
const ids = onlyIds ? onlyIds.slice() : [...usedAssetIds()];
if (!ids.length){ setS3State('bad', '还没有素材:先在「图片池」里加图片并摆到图层/背景上'); return; }
const files = ids.map(id => {
const a = assetById(id);
if (!a) return null;
return {
key: `${slug}/${id}.webp`,
name: `${id}.webp`,
contentType: 'image/webp',
data: (a.dataURL.split(',')[1] || ''),
};
}).filter(f => f && f.data);
if (!files.length){ setS3State('bad', '这些素材没有可上传的内容(dataURL 为空)'); return; }
const mb = files.reduce((n, f) => n + f.data.length, 0) * 0.75 / 1048576;
S.s3.busy = true;
setS3State('', `正在上传 ${files.length} 张(约 ${mb.toFixed(2)}MB)…` + '\n首次传较多时会慢一会儿,别关页面。');
renderHostEditor();
let done = false;
try {
const j = await sbApi('/api/s3/upload', { config: cfg, files });
if (!j.ok){
const bad = (j.files || []).filter(f => !f.ok).slice(0, 3)
.map(f => `· ${f.name}:${f.error || f.code || '失败'}`);
setS3State('bad', [`${j.failed || '?'} / ${j.total || files.length} 张失败:`].concat(bad).join('\n'));
log(`素材上传失败 ${j.failed} 张`, 'e');
} else {
const pub = publicBaseOf(S.host);
if (pub) S.assetBase = pub;
saveDraft();
done = true;
setS3State('ok', `已上传 ${j.total} 张到 ${cfg.prefix || '桶根'}/` + slug + '/\n'
+ '导出的 CSS 会写成 ' + (pub || '(未推导出公开前缀)') + '/' + slug + '/<id>.webp');
log(`素材已上传 ${j.total} 张;素材前缀已设为 ${pub || '(空)'}`, 'ok');
}
} catch (e){
setS3State('bad', '请求本地服务失败:' + e.message);
} finally {
S.s3.busy = false; renderAll();
}
// 检测过远端就顺手重读:否则刚传完还显示「缺失 3」,等于让用户自己骗自己。
if (done && S.remote.at) await refreshRemote();
}
/* ---------------------- 远端素材(检测 / 覆盖 / 删除) ---------------------- */
/* 上传只解决「传上去」,解决不了「传上去之后对不对」:同一个 slug 传过三次、
换过 slug 旧图还留在桶里、重新导出后远端还是旧图 —— 这些在页面上一概看不出来。
所以再给一份远端清单,按对象键名(= <slug>/<id>.webp)跟本地比:
差在哪、怎么补、怎么删,都写进行里。 */
function currentSlug(){
return ($('slug').value || '').trim() || 'my-theme';
}
/** 本地引用素材 vs 远端对象的差异。用大小当内容指纹:同一 id 的图重编过,大小就会变。 */
function remoteDiff(){
const local = new Map();
[...usedAssetIds()].forEach(id => {
const a = assetById(id);
if (a && a.dataURL) local.set(id, dataURLToBytes(a.dataURL).length);
});
const rows = [];
const seen = new Set();
(S.remote.objects || []).forEach(o => {
const id = o.key.split('/').pop().replace(/\.webp$/i, '');
const bytes = local.get(id);
if (bytes == null){
rows.push({ kind: 'extra', id, o });
} else {
seen.add(id);
rows.push({ kind: bytes === o.size ? 'same' : 'changed', id, o, bytes });
}
});
const missing = [...local.keys()].filter(id => !seen.has(id));
return { rows, missing };
}
function fmtBytes(n){
if (n >= 1048576) return (n / 1048576).toFixed(1) + 'MB';
if (n >= 1024) return Math.round(n / 1024) + 'KB';
return n + 'B';
}
/** 列表返回的是完整对象键,删除接口要的是「相对路径前缀」的键(与上传同一个约定)。
不剥这一层,服务端会再拼一次前缀 → 删的是一个不存在的键,S3 还回 204,看起来像成功。 */
function relKey(key, prefix){
const p = String(prefix || '').replace(/^\/+|\/+$/g, '');
return p && key.startsWith(p + '/') ? key.slice(p.length + 1) : key;
}
async function refreshRemote(){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
const slug = currentSlug();
S.s3.busy = true;
setS3State('', '正在读取远端清单…');
renderHostEditor();
try {
const j = await sbApi('/api/s3/list', { config: cfg, prefix: slug + '/' });
if (!j.ok){
setS3State('bad', '读取远端清单失败:' + (j.error || '未知错误') + (j.hint ? '\n' + j.hint : ''));
log('读取远端清单失败:' + (j.error || ''), 'e');
return;
}
S.remote = { objects: j.objects || [], at: Date.now(), slug, prefix: j.prefix };
const d = remoteDiff();
const same = d.rows.filter(r => r.kind === 'same').length;
const changed = d.rows.filter(r => r.kind === 'changed').length;
const extra = d.rows.filter(r => r.kind === 'extra').length;
const clean = !changed && !extra && !d.missing.length;
// 只说结论,数字交给下面的汇总行 —— 两处都列一遍只是噪声
setS3State(clean ? 'ok' : 'warn',
clean ? `远端与本地一致(${same} 个对象)。` : '有差异,见下方清单。');
log(`远端检测:${d.rows.length} 个对象(一致 ${same} / 大小不同 ${changed} / 多余 ${extra} / 缺失 ${d.missing.length})`,
clean ? 'ok' : 'w');
} catch (e){
setS3State('bad', '读取远端清单失败:' + e.message);
} finally {
S.s3.busy = false; renderHostEditor();
}
}
/** 补差异:只传「远端没有」和「大小不一致」的,一致的不重传。 */
async function uploadDiff(){
const d = remoteDiff();
const ids = d.missing.concat(d.rows.filter(r => r.kind === 'changed').map(r => r.id));
if (!ids.length){
setS3State('ok', '远端与本地一致,没有要补的。');
return;
}
await uploadAssets(ids);
}
async function deleteRemoteKeys(keys, why){
if (!keys.length) return false;
const preview = keys.slice(0, 6).join('\n') + (keys.length > 6 ? `\n…… 另有 ${keys.length - 6} 个` : '');
if (!confirm(`从对象存储删除 ${keys.length} 个对象?\n\n${preview}\n\n${why}\n\n只删远端,本地素材不受影响。`)) return false;
const cfg = s3Config();
const rel = keys.map(k => relKey(k, cfg.prefix));
S.s3.busy = true;
setS3State('', `正在删除 ${keys.length} 个对象…`);
renderHostEditor();
try {
const j = await sbApi('/api/s3/delete', { config: cfg, keys: rel });
if (!j.ok){
const bad = (j.files || []).filter(f => !f.ok).slice(0, 3)
.map(f => `· ${f.key}:${f.error || f.code || '失败'}`);
setS3State('bad', [`${j.failed} / ${j.total} 个删除失败:`].concat(bad).join('\n'));
log(`远端删除失败 ${j.failed} 个`, 'e');
} else {
log(`已从远端删除 ${j.total} 个对象`, 'ok');
}
} catch (e){
setS3State('bad', '请求本地服务失败:' + e.message);
} finally {
S.s3.busy = false;
}
await refreshRemote();
return true;
}
function deleteExtra(){
const d = remoteDiff();
return deleteRemoteKeys(d.rows.filter(r => r.kind === 'extra').map(r => r.o.key),
'这些对象在远端存在、但当前主题已经不再引用。');
}
function renderRemoteBlock(){
const wrap = document.createElement('div');
wrap.className = 'remote';
const head = document.createElement('div');
head.className = 'rhead';
const title = document.createElement('span');
title.textContent = '远端素材';
const btnCheck = document.createElement('button');
btnCheck.textContent = '检测';
btnCheck.title = '读取 <路径前缀>/<slug>/ 下的对象,跟本地比';
btnCheck.disabled = !!S.s3.busy;
btnCheck.onclick = refreshRemote;
head.append(title, btnCheck);
wrap.appendChild(head);
if (S.remote.at && S.remote.slug !== currentSlug()){
const p = document.createElement('p');
p.className = 'hint';
p.style.margin = '0';
p.textContent = `上次检测的是 ${S.remote.slug},slug 已改成 ${currentSlug()} —— 重新点「检测」。`;
wrap.appendChild(p);
return wrap;
}
if (!S.remote.at){
const p = document.createElement('p');
p.className = 'hint';
p.style.margin = '0';
p.textContent = '点「检测」读取远端已有对象:哪些一致、哪些是旧图(大小不同)、'
+ '哪些已经没用了(多余),都能在行里直接覆盖或删除。';
wrap.appendChild(p);
return wrap;
}
const d = remoteDiff();
const list = document.createElement('div');
list.className = 'rlist';
const show = d.rows.slice(0, 24);
show.forEach(r => {
const row = document.createElement('div');
row.className = 'rrow';
const nm = document.createElement('span');
nm.className = 'nm';
nm.textContent = r.o.key.split('/').pop();
nm.title = r.o.key;
const sz = document.createElement('span');
sz.className = 'sz';
sz.textContent = fmtBytes(r.o.size);
sz.title = '远端 ' + fmtBytes(r.o.size)
+ (r.bytes != null ? ` / 本地 ${fmtBytes(r.bytes)}` : '')
+ (r.o.modified ? '\n最后修改 ' + r.o.modified : '');
const bg = document.createElement('span');
bg.className = 'bg ' + r.kind;
bg.textContent = r.kind === 'same' ? '一致' : r.kind === 'changed' ? '旧图' : '多余';
bg.title = r.kind === 'same' ? '大小与本地一致'
: r.kind === 'changed' ? '远端与本地大小不同:多半重新导出过,点 ↻ 覆盖'
: '远端有这个对象,但当前主题不再引用';
const btnPush = document.createElement('button');
btnPush.textContent = '↻';
btnPush.title = '用本地这张覆盖远端';
btnPush.disabled = r.kind === 'extra' || S.s3.busy;
btnPush.onclick = () => uploadAssets([r.id]);
const btnDel = document.createElement('button');
btnDel.textContent = '✕';
btnDel.title = '从远端删除这个对象';
btnDel.disabled = !!S.s3.busy;
btnDel.onclick = () => deleteRemoteKeys([r.o.key], '这是你手动选中的对象。');
row.append(nm, sz, bg, btnPush, btnDel);
list.appendChild(row);
});
wrap.appendChild(list);
if (d.rows.length > show.length){
const more = document.createElement('p');
more.className = 'rsum';
more.textContent = `…… 另有 ${d.rows.length - show.length} 个未列出`;
wrap.appendChild(more);
}
const sum = document.createElement('div');
sum.className = 'rsum';
const same = d.rows.filter(r => r.kind === 'same').length;
const changed = d.rows.filter(r => r.kind === 'changed').length;
const extra = d.rows.filter(r => r.kind === 'extra').length;
sum.textContent = `一致 ${same} · 旧图 ${changed} · 多余 ${extra} · 缺失 ${d.missing.length}`
+ (d.missing.length ? `(${d.missing.slice(0, 4).join('、')}${d.missing.length > 4 ? '…' : ''})` : '');
wrap.appendChild(sum);
const acts = document.createElement('div');
acts.className = 'racts';
const btnFill = document.createElement('button');
btnFill.className = 'primary';
btnFill.textContent = `补齐差异${d.missing.length + changed ? ` (${d.missing.length + changed})` : ''}`;
btnFill.title = '上传远端缺失的、以及大小不一致的(一致的不重传)';
btnFill.disabled = S.s3.busy || !(d.missing.length + changed);
btnFill.onclick = uploadDiff;
const btnClean = document.createElement('button');
btnClean.textContent = `删除多余${extra ? ` (${extra})` : ''}`;
btnClean.title = '删除远端存在、但当前主题不再引用的对象';
btnClean.disabled = S.s3.busy || !extra;
btnClean.onclick = deleteExtra;
acts.append(btnFill, btnClean);
wrap.appendChild(acts);
return wrap;
}
/** 改画质后重编全部素材,并把前后体积说清楚 —— 否则用户不知道有没有生效。 */
async function applyQuality(what){
if (!S.assets.length){ renderAll(); return; }
const { before, after } = await rebakeAll();
renderAll();
const fmt = kb => kb >= 1024 ? (kb / 1024).toFixed(1) + 'MB' : kb + 'KB';
log(`按「${what}」重编 ${S.assets.length} 张:${fmt(before)} → ${fmt(after)}`
+ (before > 0 && after < before ? `(省 ${Math.round((1 - after / before) * 100)}%)` : ''), 'ok');
}
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);
});
// 对比度读数:主题能改文字色,也就有能力把页面改到看不清。
// 派生值现在有下限(不达标就自动收淡),但用户手填的 text / primary 没有 —— 只提醒、不拦。
const back = textBackdrops(S.palette);
const worstOf = c => Math.min(...back.map(b => contrastRatio(c, b)));
const sec = mixToContrast(S.palette.text, back, 5.5);
const mut = mixToContrast(S.palette.text, back, 4.5);
const crL = document.createElement('p');
crL.className = 'hint';
const items = [
['正文', worstOf(S.palette.text), 4.5],
['次要', worstOf(sec), 4.5],
['淡文字', worstOf(mut), 4.5],
['主色', worstOf(S.palette.primary), 4.5],
];
crL.innerHTML = items.map(([n, v, lim]) =>
`<span style="color:${v < lim ? '#e0a33e' : 'inherit'}">${n} ${v.toFixed(1)}:1</span>`
).join(' · ') + '<br>低于 4.5:1(WCAG AA 正文)会变橙。次要/淡文字由正文自动派生并保底。';
wrap.appendChild(crL);
// 主色卡在中间调时,白字和深字都不到 4.5 —— 这不是文字色挑错,是主色本身要动。
const bestOn = Math.max(contrastRatio('#ffffff', S.palette.primary),
contrastRatio('#0b1016', S.palette.primary));
if (bestOn < 4.5){
const warn = document.createElement('p');
warn.className = 'hint';
warn.style.color = '#e0a33e';
warn.textContent = `⚠ 主色偏中间调:白字 ${contrastRatio('#ffffff', S.palette.primary).toFixed(2)}:1、`
+ `深字 ${contrastRatio('#0b1016', S.palette.primary).toFixed(2)}:1,按钮上的字都不够清楚。`
+ '把主色压深一点(配白字)或提亮一点(配深字)就能过。';
wrap.appendChild(warn);
}
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '深色配色写在生成结果里(深色不加载图片);要改深色请直接改 CSS。';
wrap.appendChild(note);
}
function renderViewportBar(){
const vp = $('vps'); vp.innerHTML = '';
VIEWPORTS.forEach(v => {
const b = document.createElement('button');
b.textContent = v.w;
b.title = v.label;
b.className = v.w === S.viewport ? 'on' : '';
b.onclick = () => { S.viewport = v.w; renderViewportBar(); renderLayers(); 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();
renderExportEditor();
renderHostEditor();
renderNarrowEditor();
renderPalette();
applyTheme();
scheduleSave();
}
/* ---------------------- 工程持久化(留得住配置) ---------------------- */
/* 没有这两样东西,工具就只能用一次:关掉标签页,刚调好的十个图层和六个页面全没。
localStorage 存草稿(自动),json 存工程(手动、可进版本库、可发给别人)。 */
const LS_KEY = 'tamabox-theme-builder-v2';
let saveTimer = null, draftWarned = false;
function toJSON(){
return {
v: 4,
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,
export: S.export,
assetBase: S.assetBase,
// 托管配置进工程(换机器接着传),但 accessKey/secretKey 刻意不进去:
// 工程文件常被塞进仓库或发给别人,密钥一旦落进去就没法收回了。
host: S.host,
// 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。
// 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 ——
// 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。
assets: S.assets.map(a => ({
id: a.id, name: a.name,
orig: a.orig || { dataURL: a.dataURL, w: a.w, h: a.h },
fx: fxIsEmpty(a.fx) ? null : normFx(a.fx),
dataURL: fxIsEmpty(a.fx) ? null : a.dataURL,
w: a.w, h: a.h, kb: a.kb,
})),
scopes: S.scopes,
};
}
function fromJSON(o){
// v2 是加图片处理之前的格式(只有 dataURL):把 dataURL 当 orig 用,
// fx 全空,用户打开旧工程还能接着改参数。v4 只多了 export(画质)字段,缺了取默认。
if (!o || typeof o !== 'object' || (o.v !== 2 && o.v !== 3 && o.v !== 4)){
throw new Error('不是本工具导出的工程文件(版本 ' + (o && o.v) + ',支持 2 / 3 / 4)');
}
S.slug = o.slug || 'my-theme';
S.scope = SCOPES.some(x => x.k === o.scope) ? o.scope : 'global';
S.viewport = VIEWPORTS.some(v => v.w === o.viewport) ? 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: false, maxWidth: 900,
scale: MOB_SCALE_DEFAULT }, o.narrow || {});
S.export = Object.assign({}, D.export, o.export || {});
S.assetBase = typeof o.assetBase === 'string' ? o.assetBase : '';
S.host = Object.assign({}, D.host, o.host || {});
if (S.host.mode !== 's3') S.host.mode = 'local';
S.assets = (o.assets || []).map(a => {
const orig = a.orig || { dataURL: a.dataURL, w: a.w, h: a.h };
const dataURL = a.dataURL || orig.dataURL;
return {
id: a.id, name: a.name, orig, fx: normFx(a.fx),
dataURL, bytes: dataURLToBytes(dataURL),
w: a.w || orig.w, h: a.h || orig.h, kb: a.kb || 0,
};
});
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');
}
}
}
/** 清空到空白状态,并删掉浏览器里的草稿。
之前只有 ?fresh=1 这个隐藏参数:用户看到「上次的数据」一直在,也只能以为关不掉。 */
function resetAll(){
if (!confirm('清空当前工程?\n未导出的图层、配色、素材都会被删掉。建议先点「保存工程」。')) return;
try { localStorage.removeItem(LS_KEY); } catch (_) {}
S.slug = 'my-theme';
S.scope = 'global';
S.mode = 'light';
S.curLayer = null;
S.assets = [];
S.palette = Object.assign({}, D.palette);
S.narrow = Object.assign({}, D.narrow);
S.export = Object.assign({}, D.export);
S.assetBase = '';
// 托管配置回默认,但密钥保留(sessionStorage)—— 换主题往往还是传到同一个桶
S.host = Object.assign({}, D.host);
S.dark = Object.assign({}, D.dark);
S.scopes = {};
SCOPES.forEach(sc => { S.scopes[sc.k] = blankScope(sc.k); });
$('slug').value = S.slug;
renderViewportBar();
renderAll();
loadPreview();
log('已清空(浏览器草稿一并删掉),从空白开始', 'ok');
}
function scheduleSave(){
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 700);
}
function restoreDraft(){
let raw = null;
try { raw = localStorage.getItem(LS_KEY); } catch (_) { return false; }
if (!raw) return false;
try { fromJSON(JSON.parse(raw)); return true; }
catch (e){ return false; }
}
function refreshUIFromState(){
$('slug').value = S.slug;
S.curLayer = null;
renderViewportBar();
renderAll();
loadPreview();
fitStage();
}
/* ---------------------------- 导出前校验 ---------------------------- */
/* 这些规则本来要导出后跑 go test 才知道。能在点导出时就拦住的,不该让人去等编译。 */
function splitTopLevel(v){
const out = []; let buf = '', depth = 0;
for (const ch of String(v)){
if (ch === '(') depth++;
else if (ch === ')') depth--;
if (ch === ',' && depth === 0){ out.push(buf.trim()); buf = ''; }
else buf += ch;
}
if (buf.trim()) out.push(buf.trim());
return out;
}
function validate(){
const errs = [];
const slug = ($('slug').value || '').trim();
if (!slugOK(slug)) errs.push('slug 只能是小写字母、数字、短横线,1~32 字符');
let hasContent = false;
SCOPES.forEach(sc => {
const raw = S.scopes[sc.k];
if (sc.k !== 'global' && raw.inherit) return; // 跟随全局的不单独检查
const cfg = raw;
if (cfg.layers.length > SLOT) errs.push(`${sc.label}:装饰层 ${cfg.layers.length} 个,超过 ${SLOT} 个槽位`);
cfg.layers.forEach((l, i) => {
if (l.kind === 'image' && !assetById(l.asset)) errs.push(`${sc.label} 第 ${i+1} 层是图片层,但没选图片`);
});
if (cfg.bg && !assetById(cfg.bg)) errs.push(`${sc.label}:整页背景引用了已删除的图片`);
if (cfg.pattern && !assetById(cfg.pattern)) errs.push(`${sc.label}:平铺图案引用了已删除的图片`);
if (cfg.layers.length || cfg.bg || cfg.pattern) hasContent = true;
});
if (!hasContent) errs.push('所有页面都是空的 —— 导出的会是一个什么都没配的主题');
if (S.narrow.bg && !assetById(S.narrow.bg)) errs.push('窄屏竖版图引用了已删除的图片');
// 结构自检:三组装饰值必须都是 10 个(与 Go 的 TestDecoSlotCount 同一条规则)。
// 这条是给工具自己兜底的 —— 真跑出问题说明生成逻辑有 bug,而不是用户配错。
const css = buildCSS(false);
['--tb-deco:', '--tb-deco-size:', '--tb-deco-position:'].forEach(key => {
let rest = css, n = 0;
for (;;){
const i = rest.indexOf(key);
if (i < 0) break;
const j = rest.indexOf(';', i);
if (j < 0) break;
n++;
const cnt = splitTopLevel(rest.slice(i + key.length, j)).length;
if (cnt !== SLOT) errs.push(`生成的 ${key} 第 ${n} 处只有 ${cnt} 个值(应为 ${SLOT})—— 这是工具自身的 bug,请反馈`);
rest = rest.slice(j + 1);
}
if (n === 0) errs.push(`生成的 CSS 里找不到 ${key} —— 工具自身的 bug,请反馈`);
});
return errs;
}
/* ---------------------------- 导出 zip ---------------------------- */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++){
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c >>> 0;
}
return t;
})();
function crc32(buf){
let c = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
const u16 = n => new Uint8Array([n & 255, (n >>> 8) & 255]);
const u32 = n => new Uint8Array([n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255]);
const enc = s => new TextEncoder().encode(s);
function concat(arrs){
let n = 0;
arrs.forEach(a => n += a.length);
const o = new Uint8Array(n);
let p = 0;
arrs.forEach(a => { o.set(a, p); p += a.length; });
return o;
}
/** 只做 STORE(不压缩):素材本来就是 WebP,再压收益接近 0,换来 40 行可审计的实现。 */
function zipStore(files){
const local = [], central = [];
let offset = 0;
const dt = new Date();
const dosTime = ((dt.getHours() << 11) | (dt.getMinutes() << 5) | (dt.getSeconds() >> 1)) & 0xFFFF;
const dosDate = (((dt.getFullYear() - 1980) << 9) | ((dt.getMonth() + 1) << 5) | dt.getDate()) & 0xFFFF;
for (const f of files){
const name = enc(f.name);
const crc = crc32(f.data);
const head = concat([
u32(0x04034b50), u16(20), u16(0), u16(0), u16(dosTime), u16(dosDate),
u32(crc), u32(f.data.length), u32(f.data.length), u16(name.length), u16(0),
]);
local.push(head, name, f.data);
central.push(concat([
u32(0x02014b50), u16(20), u16(20), u16(0), u16(0), u16(dosTime), u16(dosDate),
u32(crc), u32(f.data.length), u32(f.data.length),
u16(name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset),
]), name);
offset += head.length + name.length + f.data.length;
}
const cd = concat(central);
return concat([...local, cd, concat([
u32(0x06054b50), u16(0), u16(0), u16(files.length), u16(files.length),
u32(cd.length), u32(offset), u16(0),
])]);
}
function dataURLToBytes(url){
const b64 = url.split(',')[1] || '';
const bin = atob(b64);
const o = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) o[i] = bin.charCodeAt(i);
return o;
}
function installTxt(slug){
const own = SCOPES.filter(s => s.k !== 'global').map(s => {
const st = S.scopes[s.k].inherit ? '跟随全局' : '独立配置';
return ` ${s.k.padEnd(9)} ${s.desc.padEnd(22)} ${st}`;
}).join('\n');
return `主题 ${slug} —— 安装说明
=====================================
${S.assetBase
? `1) 解压
static/css/themes/${slug}.css ← 放进仓库(CSS 里已是绝对地址)
static/images/themes/${slug}/*.webp ← 传到 ${S.assetBase.replace(/\/+$/, '')}/${slug}/,仓库里不需要
素材托管在对象存储,CSS 写的是绝对地址:
换 CDN 域名 / 路径 = 必须重新导出(地址烘焙在文件里,不是运行时拼的)
换图 = 覆盖同名对象,建议把 Cache-Control 设成 immutable + 用 ?v= 或换文件名做失效
怎么传上去(两种都行):
a) 生成器左侧「素材托管 → S3 兼容存储」,填 Endpoint / Region / 桶名 / 密钥,
点「测试连接」通过后直接点「上传素材」,前缀会自动填对;
b) 命令行:aws s3 sync static/images/themes/${slug}/ s3://<桶名>/<路径前缀>/${slug}/ --endpoint-url <Endpoint>
(rclone / s5cmd 同理,任何 S3 兼容客户端都行)`
: `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}」
下拉里显示的就是 slug。想要中文/日文名,到 internal/i18n/dict_*.go 加
theme.name.${slug} 词条(四份词典都要加)。不加也不会显示成 key ——
模板函数 themeName 会回退成 slug。
页面作用域
----------
${own}
窄屏
----
${S.narrow.enabled
? `已导出 @media (max-width: ${S.narrow.maxWidth}px):${S.narrow.bg ? '换成竖版背景图' : '背景沿用桌面'}`
+ `\n 缩放系数 ${Math.round((S.narrow.scale || MOB_SCALE_DEFAULT) * 100)}%(作用于策略为「缩小」的层)`
+ (S.narrow.hideDeco
? '\n ⚠ 「窄屏隐藏全部装饰层」已勾选:断点以下所有素材都不显示(桌面正常)。\n'
+ ' 想保留就把这个勾去掉,改用每层「窄屏 = 原样 / 缩小」。'
: '')
: '未导出窄屏规则(桌面装饰会在手机上被卡片盖住 / 背景被 cover 裁成一条)'}
尺寸适配
--------
尺寸单位(图层编辑器里选,本质是「屏幕变小时这张图怎么办」):
fit 自适应·按高度 —— 人物/竖幅。按 ${DESIGN_H}px 高视口换算,屏幕更矮等比缩
(下限 55%,小到放不下时让位给视口),更高不超过设定值
fitw 自适应·按宽度 —— 横幅/场景。按 ${DESIGN_W}px 宽视口换算,同上。
宽幅素材必须用它:按高度缩的话宽度会跟着涨,窄屏照样裁两侧
vh 按视口高比例 —— 永远占同样比例的屏高,无下限
px 固定像素 —— 不随视口变,小屏容易裁
尺寸只定一个维度,另一个交给 auto 按图片比例算,缩放时不会拉伸。
每一层在编辑器里都会显示「当前视口下实际显示多大 / 是否越界」。
导出画质
--------
质量 ${(+S.export.quality).toFixed(2)},长边上限 ${S.export.maxSide || '不限制'}
素材共 ${S.assets.reduce((n, a) => n + (a.kb || 0), 0)}KB(${S.assets.length} 张)
作用域由服务端按请求路径输出 <html data-page-scope="...">,
主题 CSS 用 html[data-theme-style="${slug}"][data-page-scope="xxx"] 覆盖。
没写覆盖块的作用域自动沿用全局,不会出现「某页装饰消失」。
注意
----
装饰层固定 ${SLOT} 个槽位(--tb-deco / -size / -position 必须同长)。
手动改 CSS 时别删值 —— 少一个会让图层与 size/position 错位,
表现是整页重复同一张插画、或人物跑到左上角。
`;
}
/** 本次主题真正引用到的素材 id(导出与上传共用同一份判定)。
没有被任何图层/背景引用的图不该进 zip,也不该传到对象存储上占位。 */
function usedAssetIds(){
const used = new Set();
if (S.narrow.bg) used.add(S.narrow.bg);
const collect = c => {
if (c.bg) used.add(c.bg);
if (c.pattern) used.add(c.pattern);
c.layers.forEach(l => { if (l.asset) used.add(l.asset); });
};
SCOPES.forEach(s => { if (s.k === 'global' || !S.scopes[s.k].inherit) collect(S.scopes[s.k]); });
return used;
}
function doExport(){
const errs = validate();
if (errs.length){
out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e');
return;
}
const slug = ($('slug').value || '').trim();
const used = usedAssetIds();
const files = [{ name: `static/css/themes/${slug}.css`, data: enc(buildCSS(false)) }];
let imgKB = 0, imgN = 0;
for (const id of used){
const a = assetById(id);
if (a){
files.push({ name: `static/images/themes/${slug}/${id}.webp`, data: dataURLToBytes(a.dataURL) });
imgKB += a.kb || 0; imgN++;
}
}
// 工程文件也塞进包里:下次想改直接「打开工程」,不必从零重搭
files.push({ name: `theme-${slug}.json`, data: enc(JSON.stringify(toJSON())) });
files.push({ name: `INSTALL-${slug}.txt`, data: enc(installTxt(slug)) });
const blob = new Blob([zipStore(files)], { type: 'application/zip' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `theme-${slug}.zip`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
const fmt = kb => kb >= 1024 ? (kb / 1024).toFixed(2) + 'MB' : kb + 'KB';
const over = overflowReport();
// 窄屏全藏是「素材不见了」的头号原因,导出时必须写进结果里 ——
// 用户只看桌面预览是发现不了的。
let narrowNote = '';
if (S.narrow.enabled && S.narrow.hideDeco){
let cnt = 0;
SCOPES.forEach(sc => {
const c = S.scopes[sc.k];
if (sc.k !== 'global' && c.inherit) return;
cnt += (c.layers || []).length;
});
narrowNote = `\n\n⚠ 窄屏规则(≤${S.narrow.maxWidth}px)会隐藏全部 ${cnt} 层装饰。`
+ '这是「窄屏隐藏全部装饰层」勾选的结果;要在手机上保留素材就取消它,改用每层「窄屏」策略。';
}
out(`已导出 theme-${slug}.zip(${files.length} 个文件,${fmt(Math.round(blob.size / 1024))};`
+ `其中 ${imgN} 张图共 ${fmt(imgKB)})。\n`
+ `解压到项目根,按 INSTALL-${slug}.txt 走三步;包里的 theme-${slug}.json 是工程源文件,下次用「打开工程」接着改。`
+ (over.length ? `\n\n⚠ ${over.length} 处素材在部分视口下会被裁(不阻止导出,供你判断):\n`
+ over.slice(0, 6).map(s => '· ' + s).join('\n')
+ (over.length > 6 ? `\n…… 另有 ${over.length - 6} 处` : '') : '')
+ narrowNote, (over.length || narrowNote) ? 'w' : 'ok');
}
/** 全站扫一遍:哪些层在预设视口下会溢出被裁。
导出前提醒而不是硬拦 —— 有时故意让插画出血是想要的效果。 */
function overflowReport(){
const rows = [];
for (const sc of SCOPES){
const cfg = S.scopes[sc.k];
if (sc.k !== 'global' && cfg.inherit) continue;
(cfg.layers || []).forEach((l, i) => {
if (l.kind !== 'image' || !l.asset) return;
const a = assetById(l.asset);
if (!a) return;
const bad = VIEWPORTS.filter(v => {
const m = layerMetrics(l, a, v.w, v.h);
return m.overL > 1 || m.overR > 1 || m.overT > 1 || m.overB > 1;
});
if (bad.length) rows.push(`${sc.label} 第 ${i + 1} 层(${a.id}):${bad.map(v => v.w + '×' + v.h).join('、')} 下超出视口`);
});
}
return rows;
}
/* ---------------------------- 图片处理 ---------------------------- */
/* 设计:orig 保存上传时的原始字节(不重编码),fx 只存参数,每次处理都从 orig 重算。
这样「压白 16% → 改成 20%」不会叠加两次压缩,反复调参也不会把边缘越磨越糊。 */
function fxDefault(){
return { alpha:100, wash:0, gray:0, trim:false,
// 容差 16 → 门槛 239。经验值:背景常是 253,白袜子/白衬衫是 237,
// 容差给到 20 以上就会把白袜子一起判成背景(吃穿成一个透明洞)。
key:{ on:false, tol:16, holes:true, feather:true } };
}
/** 读进来的 fx 一律过一遍归一化:工程文件可能是别的版本写的,缺字段不能让渲染变成 NaN。 */
function normFx(f){
f = f || {};
const k = Object.assign(fxDefault().key, f.key || {});
return {
alpha: f.alpha == null ? 100 : clamp(Math.round(+f.alpha), 0, 100),
wash: clamp(Math.round(+(f.wash || 0)), 0, 60),
gray: clamp(Math.round(+(f.gray || 0)), 0, 100),
trim: !!f.trim,
key: { on: !!k.on, tol: clamp(Math.round(+k.tol || 16), 4, 60),
holes: k.holes !== false, feather: k.feather !== false },
};
}
function fxIsEmpty(f){
return !f || (f.alpha === 100 && !f.wash && !f.gray && !f.trim && !(f.key && f.key.on));
}
/* 去白底。两步:① 与画布边缘连通的近白像素才是背景;② 不与边缘连通的「封闭白孔」
(手内侧、两腿之间那种)按面积单独掏。
⚠️ 容差别贪:背景常常是 (253,253,253) 而白袜子是 (237,237,237),
容差过了 30 就会把袜子一起吃穿。 */
function keyWhite(imgData, tol, holes){
const { data, width: W, height: H } = imgData;
const N = W * H;
const thr = 255 - Math.max(1, tol);
const white = new Uint8Array(N);
for (let p = 0, i = 0; p < N; p++, i += 4){
white[p] = (data[i] >= thr && data[i+1] >= thr && data[i+2] >= thr) ? 1 : 0;
}
const mark = new Uint8Array(N); // 1 = 判为背景
const stack = new Int32Array(N);
let sp = 0;
const push = p => { if (white[p] && !mark[p]){ mark[p] = 1; stack[sp++] = p; } };
for (let x = 0; x < W; x++){ push(x); push((H - 1) * W + x); }
for (let y = 0; y < H; y++){ push(y * W); push(y * W + W - 1); }
while (sp > 0){
const p = stack[--sp];
const x = p % W, y = (p - x) / W;
if (x > 0) push(p - 1);
if (x < W - 1) push(p + 1);
if (y > 0) push(p - W);
if (y < H - 1) push(p + W);
}
if (holes){
// 面积 >= 24 才掏:更小的白色多半是眼睛高光/反光,掏了会变成透明洞
const seen = new Uint8Array(N);
const q = new Int32Array(N);
for (let p0 = 0; p0 < N; p0++){
if (!white[p0] || mark[p0] || seen[p0]) continue;
let qh = 0, qt = 0;
q[qt++] = p0; seen[p0] = 1;
const cells = [];
while (qh < qt){
const p = q[qh++];
cells.push(p);
const x = p % W, y = (p - x) / W;
if (x > 0 && white[p-1] && !mark[p-1] && !seen[p-1]){ seen[p-1] = 1; q[qt++] = p-1; }
if (x < W - 1 && white[p+1] && !mark[p+1] && !seen[p+1]){ seen[p+1] = 1; q[qt++] = p+1; }
if (y > 0 && white[p-W] && !mark[p-W] && !seen[p-W]){ seen[p-W] = 1; q[qt++] = p-W; }
if (y < H - 1 && white[p+W] && !mark[p+W] && !seen[p+W]){ seen[p+W] = 1; q[qt++] = p+W; }
}
if (cells.length >= 24) for (const p of cells) mark[p] = 1;
}
}
for (let p = 0, i = 3; p < N; p++, i += 4) if (mark[p]) data[i] = 0;
}
/** alpha 做一次 3×3 最小值腐蚀:把抠图边缘残留的半透明白边收掉 1px。
代价是轮廓会缩一点,细线条(光环一类)可能被吃掉,所以是开关而不是写死。 */
function erodeAlpha1(imgData){
const { data, width: W, height: H } = imgData;
const a = new Uint8Array(W * H);
for (let p = 0, i = 3; p < a.length; p++, i += 4) a[p] = data[i];
for (let y = 0; y < H; y++){
for (let x = 0; x < W; x++){
const p = y * W + x;
let m = a[p];
if (m === 0) continue;
if (x === 0 || y === 0 || x === W - 1 || y === H - 1) m = 0;
else {
for (let dy = -1; dy <= 1 && m; dy++){
const row = (y + dy) * W;
for (let dx = -1; dx <= 1; dx++){
const v = a[row + x + dx];
if (v < m){ m = v; if (m === 0) break; }
}
}
}
data[p * 4 + 3] = m;
}
}
}
function fxPixels(imgData, fx){
const d = imgData.data;
if (fx.key && fx.key.on) keyWhite(imgData, fx.key.tol, fx.key.holes);
const washK = (fx.wash || 0) / 100;
const grayK = (fx.gray || 0) / 100;
const aK = (fx.alpha == null ? 100 : fx.alpha) / 100;
for (let i = 0; i < d.length; i += 4){
let r = d[i], g = d[i+1], b = d[i+2];
if (grayK){
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
r += (lum - r) * grayK; g += (lum - g) * grayK; b += (lum - b) * grayK;
}
if (washK){
r += (255 - r) * washK; g += (255 - g) * washK; b += (255 - b) * washK;
}
d[i] = r; d[i+1] = g; d[i+2] = b;
if (aK < 1) d[i+3] = d[i+3] * aK;
}
if (fx.key && fx.key.on && fx.key.feather) erodeAlpha1(imgData);
}
/** 只保留不透明的外接矩形。生成图四周留白多时用它裁掉,
否则 CSS 里按高度定尺寸会让主体看起来比实际小一圈。 */
function trimCanvas(cv){
const g = cv.getContext('2d');
const W = cv.width, H = cv.height;
const d = g.getImageData(0, 0, W, H).data;
let x0 = W, y0 = H, x1 = -1, y1 = -1;
for (let y = 0; y < H; y++){
for (let x = 0; x < W; x++){
if (d[(y * W + x) * 4 + 3] > 8){
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
}
}
}
if (x1 < 0 || (x0 === 0 && y0 === 0 && x1 === W - 1 && y1 === H - 1)) return cv;
const w = x1 - x0 + 1, h = y1 - y0 + 1;
const out = document.createElement('canvas');
out.width = w; out.height = h;
out.getContext('2d').drawImage(cv, x0, y0, w, h, 0, 0, w, h);
return out;
}
/** 按 fx 出一张 canvas。maxW > 0 时先等比缩到该宽度(预览用,拖动滑杆才跟手);
limit > 0 时长边不超过 limit(导出画质用,压体积最有效的一刀在这里)。 */
function renderFxCanvas(img, fx, maxW, limit){
fx = normFx(fx);
const W0 = img.naturalWidth || img.width, H0 = img.naturalHeight || img.height;
let k = maxW && W0 > maxW ? maxW / W0 : 1;
if (limit > 0){
const k2 = Math.min(limit / W0, limit / H0, 1); // 长边上限:只缩不放
if (k2 < k) k = k2;
}
let cv = document.createElement('canvas');
cv.width = Math.max(1, Math.round(W0 * k));
cv.height = Math.max(1, Math.round(H0 * k));
const g = cv.getContext('2d');
// 缩小默认是双线性,一次缩太多会糊成一团;high 会走更好的降采样,
// 代价只有一次性耗时(2M 像素级别仍是毫秒级)
g.imageSmoothingEnabled = true;
g.imageSmoothingQuality = 'high';
g.drawImage(img, 0, 0, cv.width, cv.height);
// 没有任何处理时直接跳过 getImageData:2M 像素图省下的这一趟够明显
if (!fxIsEmpty(fx)){
const id = g.getImageData(0, 0, cv.width, cv.height);
fxPixels(id, fx);
g.putImageData(id, 0, 0);
}
if (fx.trim) cv = trimCanvas(cv);
return cv;
}
async function decodeAsset(a){
if (a._img) return a._img;
const src = (a.orig && a.orig.dataURL) || a.dataURL;
a._img = await new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = () => rej(new Error('无法解码素材 ' + a.id));
im.src = src;
});
return a._img;
}
/** 把处理结果写回 asset:dataURL/bytes/w/h/kb 全部换成处理后的。
每次都从 orig + fx + 当前画质设置重算,所以「先调 q 0.7 看看,再调回 0.88」
不会两代有损叠加 —— 这与 fx 的非破坏设计是同一条原则。 */
async function bakeAsset(a){
const img = await decodeAsset(a);
const cv = renderFxCanvas(img, a.fx, 0, S.export.maxSide);
Object.assign(a, canvasAsset(cv, S.export.quality));
}
/** 画质/长边改了之后重编全部素材。返回前后体积对比,用来给一句「省了多少」。 */
async function rebakeAll(){
let before = 0, after = 0;
for (const a of S.assets){
before += a.kb || 0;
await bakeAsset(a);
after += a.kb || 0;
}
return { before, after };
}
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);
});
}
/** 原文件原样读成 dataURL:orig 要保真,绝不能在这里就重编码。 */
function readAsDataURL(file){
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = () => rej(new Error('读取失败 ' + file.name));
r.readAsDataURL(file);
});
}
/** 画布尺寸 = 处理后的尺寸:不缩放、不留透明边,导出后的宽高比才与实际一致。
q 是 WebP 质量,体积与观感的唯一旋钮 —— 0.85~0.9 之间肉眼几乎看不出差别。
⚠️ 上限卡在 0.95:Skia 在 quality=1.0 时会切到 WebP 无损模式,实测输出反常
(同一张噪声图 q=1 只有 52KB,q=0.95 是 440KB),不能当「最高画质」用。 */
function canvasAsset(cv, q){
const qq = clamp(Number(q) || 0.88, 0.3, 0.95);
const url = cv.toDataURL('image/webp', qq);
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, origURL;
try {
img = await loadImage(f);
origURL = await readAsDataURL(f);
} catch (e){ log(e.message, 'e'); continue; }
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++);
const a = {
id, name: f.name,
orig: { dataURL: origURL, w: img.naturalWidth, h: img.naturalHeight },
fx: fxDefault(),
_img: img,
};
const cv = renderFxCanvas(img, a.fx, 0, S.export.maxSide);
Object.assign(a, canvasAsset(cv, S.export.quality));
S.assets.push(a);
log(`加入 ${f.name} → ${id}.webp(${a.w}×${a.h}, ${a.kb}KB`
+ (a.kb > 400 ? ',偏大:把「导出画质」的长边调小一档' : '') + ')', 'ok');
}
renderAll();
}
/** 从站点路径直接载入素材:不用先下载到本地再上传,
也方便把已有主题的图拉回来重新调参数。 */
async function addUrlAsset(url, id){
const blob = await (await fetch(url)).blob();
const origURL = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = () => rej(new Error('读取失败 ' + url));
r.readAsDataURL(blob);
});
const img = await new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = () => rej(new Error('无法解码 ' + url));
im.src = origURL;
});
let n = 2, use = id;
while (S.assets.some(a => a.id === use)) use = id + '-' + (n++);
const a = {
id: use, name: url.split('/').pop(),
orig: { dataURL: origURL, w: img.naturalWidth, h: img.naturalHeight },
fx: fxDefault(), _img: img,
};
Object.assign(a, canvasAsset(renderFxCanvas(img, a.fx, 0, S.export.maxSide), S.export.quality));
S.assets.push(a);
return a;
}
/* ---------------------- 图片处理弹窗 ---------------------- */
let fxTarget = null;
let fxSeq = 0; // 拖动滑杆会连续触发预览:只让最后一次落地,避免旧结果覆盖新结果
let fxSnapshot = ''; // 打开时的参数快照
async function fxPreview(){
if (!fxTarget) return;
const my = ++fxSeq;
const a = fxTarget;
const img = await decodeAsset(a);
if (my !== fxSeq) return; // 已经有更新的调用了,这次结果作废
// 预览按 420px 宽算:滑杆要能实时跟手,全分辨率(可能 2M 像素)拖一次卡一次
const cv = renderFxCanvas(img, a.fx, 420);
const out = $('fx-canvas');
out.width = cv.width; out.height = cv.height;
const g = out.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.drawImage(cv, 0, 0);
$('fx-info').textContent = `输出 ${cv.width}×${cv.height} · ` +
(fxIsEmpty(a.fx) ? '未处理' : '预览已按比例缩小,完成时用全分辨率重算');
}
function renderFxParams(){
const el = $('fx-params');
el.innerHTML = '';
const f = fxTarget.fx;
const slider = (label, val, min, max, unit, sub, onChange) => {
const box = document.createElement('div');
box.className = 'fxrow';
const lab = document.createElement('div');
lab.className = 'lab';
const t = document.createElement('span'); t.textContent = label;
const b = document.createElement('b'); b.textContent = val + (unit || '');
lab.append(t, b);
const r = document.createElement('input');
r.type = 'range'; r.min = min; r.max = max; r.step = 1; r.value = val;
r.oninput = () => { b.textContent = r.value + (unit || ''); onChange(+r.value); fxPreview(); };
box.append(lab, r);
if (sub){
const s = document.createElement('div'); s.className = 'sub'; s.textContent = sub;
box.append(s);
}
el.appendChild(box);
};
const check = (label, val, onChange) => {
const box = document.createElement('div');
box.className = 'fxrow chkrow';
const l = document.createElement('label');
l.className = 'chk';
l.style.cssText = 'font-size:11.5px;color:var(--ui-text);display:flex;gap:6px;align-items:center;cursor:pointer';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = val;
cb.onchange = () => onChange(cb.checked);
l.append(cb, document.createTextNode(label));
box.appendChild(l);
el.appendChild(box);
};
slider('不透明度', f.alpha, 0, 100, '%', '背景图没法单独设 opacity,只能烘焙进像素', v => f.alpha = v);
slider('压白 / 雾度', f.wash, 0, 60, '%',
'v×(1-k)+255×k。场景压白 38%,人物常用 12~26% 才不显突兀', v => f.wash = v);
slider('去色', f.gray, 0, 100, '%', '往灰度混。彩色素材压不住素场景时用它', v => f.gray = v);
check('裁掉透明边', f.trim, v => { f.trim = v; fxPreview(); });
check('去白底', f.key.on, v => { f.key.on = v; renderFxParams(); fxPreview(); });
if (f.key.on){
slider('容差', f.key.tol, 4, 60, '', '背景常是 253、白袜子 237 —— 容差过 18 就把袜子吃穿', v => f.key.tol = v);
check('掏掉封闭白孔(手内侧、腿之间)', f.key.holes, v => { f.key.holes = v; fxPreview(); });
check('边缘收 1px(去白边)', f.key.feather, v => { f.key.feather = v; fxPreview(); });
}
}
async function openFx(a){
fxTarget = a;
a.fx = normFx(a.fx);
fxSnapshot = JSON.stringify(a.fx);
$('fx-name').textContent = `${a.id}.webp · 原始 ${a.orig.w}×${a.orig.h}`;
renderFxParams();
$('fxmodal').classList.add('on');
$('fx-info').textContent = '处理中…';
await fxPreview();
}
/** 关闭一律保存:预览是所见即所得,点 × 若不落盘,用户会以为已经改了。 */
async function closeFx(){
const a = fxTarget;
$('fxmodal').classList.remove('on');
fxTarget = null;
if (!a) return;
// 参数没动就一次像素都别碰:只是打开看一眼,不该让图被重新编码一遍(有损,会累积)
if (JSON.stringify(normFx(a.fx)) === fxSnapshot){
renderAll();
return;
}
const before = `${a.w}×${a.h}`;
await bakeAsset(a);
renderAll();
log(fxIsEmpty(a.fx)
? `${a.id}.webp 恢复原图(${a.w}×${a.h})`
: `${a.id}.webp 已处理:${before} → ${a.w}×${a.h},${a.kb}KB`, 'ok');
}
/* ---------------------- 处理算法自检(?dump=fx) ---------------------- */
/* 这几个断言都是踩过的坑,不能靠肉眼在缩略图上看:
- 封闭白孔(手内侧、两腿之间)四边漫水进不去,必须单独掏;
- 白袜子 237 与背景 253 只差 16,容差稍大就吃穿;
- 压白要按 v×(1-k)+255×k 算,否则和场景的雾度对不上。 */
function fxSelfTest(){
const W = 200, H = 220;
const mk = () => {
const cv = document.createElement('canvas');
cv.width = W; cv.height = H;
const g = cv.getContext('2d');
g.fillStyle = '#fdfdfd'; g.fillRect(0, 0, W, H); // 背景:真实场景就是 253
g.fillStyle = '#3b6ea5';
g.beginPath(); g.arc(100, 110, 60, 0, Math.PI * 2); g.fill(); // 主体
g.fillStyle = '#fdfdfd';
g.beginPath(); g.arc(100, 110, 12, 0, Math.PI * 2); g.fill(); // 封闭白孔(应被掏)
g.fillStyle = '#ededed'; // 白袜子 / 白衬衫 237
g.fillRect(70, 150, 20, 14);
return g;
};
const run = fx => {
const g = mk();
const id = g.getImageData(0, 0, W, H);
fxPixels(id, normFx(fx));
const at = (x, y) => {
const i = (y * W + x) * 4;
return { r: id.data[i], g: id.data[i+1], b: id.data[i+2], a: id.data[i+3] };
};
return { at, id };
};
const key = (tol, holes, feather) => ({ key: { on: true, tol, holes, feather } });
const checks = [];
const T = (name, got, want) => checks.push({ name, got, want, ok: JSON.stringify(got) === JSON.stringify(want) });
// 1) 四边漫水:角落应透明,主体内应不透明
const a = run(key(16, true, false));
T('角落(与边缘连通的白)', a.at(2, 2).a, 0);
T('主体内部', a.at(100, 60).a, 255);
// 2) 封闭白孔:开 holes 时要被掏掉
T('封闭白孔(holes 开)', a.at(100, 110).a, 0);
const b = run(key(16, false, false));
T('封闭白孔(holes 关,应保留)', b.at(100, 110).a, 255);
// 3) 白袜子 237 在容差 16 下保住、容差 30 下被吃穿
T('白袜子 237(容差 16)', a.at(80, 157).a, 255);
const c = run(key(30, true, false));
T('白袜子 237(容差 30,应被吃穿)', c.at(80, 157).a, 0);
// 4) 边缘收 1px:轮廓外扩一圈变透明,主体中心不受影响
const f = run(key(16, true, true));
T('收边后轮廓 1px', f.at(100, 50).a, 0);
T('收边后中心仍实心', f.at(100, 80).a, 255);
// 5) 不透明度 50% → alpha 减半
T('不透明度 50%', run({ alpha: 50 }).at(100, 80).a, 128);
// 6) 压白 20%:237 → round(237×0.8 + 255×0.2) = 241
const w = run({ wash: 20 });
T('压白 20%(237)', w.at(80, 157).r, 241);
T('压白 20%(背景 253)', w.at(2, 2).r, 253);
// 7) 去色 100%:蓝块变灰(三通道相等)
const gr = run({ gray: 100 }).at(100, 60);
T('去色 100% 后 R=G=B', [gr.r === gr.g, gr.g === gr.b], [true, true]);
// 8) 裁透明边:四周透明 20px 的图应裁到内容尺寸
const tc = document.createElement('canvas');
tc.width = 200; tc.height = 160;
const tg = tc.getContext('2d');
tg.fillStyle = '#000'; tg.fillRect(50, 60, 80, 40);
const cut = trimCanvas(tc);
T('裁透明边尺寸', [cut.width, cut.height], [80, 40]);
return {
pass: checks.every(c => c.ok),
failed: checks.filter(c => !c.ok).length,
checks,
};
}
/* ---------------------------- 示例 ---------------------------- */
function makeDemo(){
// 重复点「载入示例」不该把素材叠起来(会撞 id,导出时取到的是判断顺序里靠前的那个)
S.assets = [];
const bg = document.createElement('canvas');
bg.width = 1920; bg.height = 1080;
const g = bg.getContext('2d');
const gr = g.createLinearGradient(0, 0, 1920, 1080);
gr.addColorStop(0, '#dfeaf6'); gr.addColorStop(0.55, '#eef3f9'); gr.addColorStop(1, '#f7f2e8');
g.fillStyle = gr; g.fillRect(0, 0, 1920, 1080);
g.fillStyle = 'rgba(255,255,255,.5)';
for (let i = 0; i < 6; i++) g.fillRect(i * 340 + 60, 0, 46, 1080);
// 示例人物:只为看清「相对地面的比例」,所以用剪影而不追求好看。
// 比例按真实立绘取(1 : 3.2),头不要画大 —— 头一大就会读成方块人。
const ch = document.createElement('canvas');
ch.width = 300; ch.height = 960;
const c = ch.getContext('2d');
c.fillStyle = '#42506a';
c.strokeStyle = 'rgba(126,205,215,.95)';
c.lineWidth = 9;
c.beginPath(); c.ellipse(150, 34, 60, 16, -0.12, 0, Math.PI * 2); c.stroke(); // 光环
c.beginPath(); c.ellipse(150, 122, 52, 60, 0, 0, Math.PI * 2); c.fill(); // 头
c.fillRect(138, 174, 24, 24); // 颈
c.beginPath(); // 上身
c.moveTo(96, 198); c.lineTo(204, 198); c.lineTo(188, 468); c.lineTo(112, 468);
c.closePath(); c.fill();
c.beginPath(); // 裙摆
c.moveTo(112, 460); c.lineTo(188, 460); c.lineTo(216, 600); c.lineTo(84, 600);
c.closePath(); c.fill();
c.fillRect(112, 596, 30, 252); // 腿
c.fillRect(158, 596, 30, 252);
c.fillStyle = '#1e2634';
c.beginPath(); c.ellipse(127, 862, 27, 15, 0, 0, Math.PI * 2); c.fill(); // 鞋
c.beginPath(); c.ellipse(173, 862, 27, 15, 0, 0, Math.PI * 2); c.fill();
// 示例素材也走完整结构(orig + fx):否则打开弹窗时 decodeAsset 找不到原图
const bgA = canvasAsset(bg, 0.82);
const chA = canvasAsset(ch, 0.9);
S.assets.push({ id:'bg', name:'示例背景', orig:{ dataURL:bgA.dataURL, w:bgA.w, h:bgA.h }, fx: fxDefault(), ...bgA });
S.assets.push({ id:'char', name:'示例人物', orig:{ dataURL:chA.dataURL, w:chA.w, h:chA.h }, fx: fxDefault(), ...chA });
// 竖版背景:手机上 16:9 会被 cover 裁成中间一条,示例里给一张竖图,
// 「窄屏」那一栏才有东西可演示、@media 段也才会真的生成
const mb = document.createElement('canvas');
mb.width = 1080; mb.height = 1920;
const mg = mb.getContext('2d');
const mgr = mg.createLinearGradient(0, 0, 0, 1920);
mgr.addColorStop(0, '#dfeaf6'); mgr.addColorStop(0.6, '#eef3f9'); mgr.addColorStop(1, '#f7f2e8');
mg.fillStyle = mgr; mg.fillRect(0, 0, 1080, 1920);
const mba = canvasAsset(mb, 0.8);
S.assets.push({ id:'bg-m', name:'示例竖版背景', orig:{ dataURL:mba.dataURL, w:mba.w, h:mba.h }, fx: fxDefault(), ...mba });
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, mob:'scale' }),
Object.assign(newLayer('radial'), { color:'#ffd9a0', alpha:0.42, r:320, cx:20, cy:86 }),
];
const ac = S.scopes.admin;
ac.inherit = false;
ac.bg = null;
ac.layers = [Object.assign(newLayer('radial'), { color:'#ffcf9a', alpha:0.38, r:380, cx:86, cy:10 })];
S.curLayer = null;
S.scopes.box = blankScope('box');
S.scopes.question = blankScope('question');
S.scopes.profile = blankScope('profile');
S.scopes.vote = blankScope('vote');
S.narrow.bg = 'bg-m';
renderAll();
log('已载入示例。左上角切换 1512 / 390 两个视口,能直接看到「被裁」与「自适应」的差别;'
+ '题目列表页是「独立配置」的例子。', 'ok');
}
/* ---------------------------- 初始化 ---------------------------- */
function boot(){
const params = new URLSearchParams(location.search);
const k = params.get('scope');
if (k && SCOPES.some(s => s.k === k)) S.scope = k;
// 自动恢复上次的草稿:调主题是连续的活,每次重开都从零开始等于没有工具
const restored = params.get('fresh') ? false : restoreDraft();
$('slug').value = S.slug;
renderViewportBar();
renderAll();
loadPreview();
fitStage();
if (restored){
log('已恢复上次的草稿(换 slug / 项目时可点「打开工程」,或加 ?fresh=1 从空白开始)。', 'ok');
} else {
out('在项目根执行 python dev/theme-builder/serve.py,用 ' + HERE + ' 打开,才能预览真实页面。');
}
$('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-new').onclick = resetAll;
$('btn-export').onclick = () => doExport();
$('add-layer').onclick = () => {
// 跟随全局时先切成独立配置(并把全局那几层复制过来当起点),
// 否则改动会写到 global 上,别页面也会跟着变。
ensureOwn(true);
const cfg = S.scopes[S.scope];
if (cfg.layers.length >= SLOT){
renderAll();
log(`装饰层最多 ${SLOT} 个槽位`, 'w');
return;
}
const kind = S.assets.length ? 'image' : 'radial';
const l = newLayer(kind);
// 多个素材时**不**自动绑图:连加三层会全是同一张,看起来就像「每层只能显示这张、换不了」。
// 只有一个素材时才绑它(没得选,省一步)。
if (kind === 'image' && S.assets.length === 1) l.asset = S.assets[0].id;
cfg.layers.push(l);
S.curLayer = l.id;
renderAll();
const sel = $('layers').querySelector('.ly.on select');
if (sel) sel.focus();
if (kind === 'image' && !l.asset){
log('新图层还没选图片 —— 在这一层的「图片」下拉里挑一张', 'w');
}
if (S.narrow.enabled && S.narrow.hideDeco){
log('注意:「窄屏隐藏全部装饰层」开着 —— 这一层在手机/窄窗口下也不会显示', 'w');
}
};
$('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 = ''; };
// 图片处理弹窗:× / 完成 / 点遮罩都保存(预览即所见,取消会让人以为已经改了)
$('fx-close').onclick = () => closeFx();
$('fx-apply').onclick = () => closeFx();
$('fxmodal').onclick = e => { if (e.target === $('fxmodal')) closeFx(); };
$('fx-reset').onclick = () => {
if (!fxTarget) return;
fxTarget.fx = fxDefault();
renderFxParams();
fxPreview();
};
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && fxTarget) closeFx();
});
$('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();
// ?narrow=hide 直接落在「窄屏隐藏全部装饰层」状态:截图与回归用。
// 这个状态是「素材在手机上不见了」最典型的来源,得能一键复现。
if (params.get('narrow') === 'hide'){
S.narrow.hideDeco = true;
renderAll();
}
// ?assetbase=https://cdn.example.com/themes 模拟「素材放对象存储」的导出结果
if (params.get('assetbase')){
S.assetBase = params.get('assetbase');
renderAll();
}
// ?rows=N 加 N 层并依次绑到不同素材(截图 / 人工核对多图层效果用)
const rows = parseInt(params.get('rows') || '0', 10);
for (let i = 0; i < rows; i++){
$('add-layer').click();
const c0 = S.scopes[S.scope];
const l0 = c0.layers[c0.layers.length - 1];
if (l0 && l0.kind === 'image' && S.assets.length){
l0.asset = S.assets[i % S.assets.length].id;
}
renderAll();
}
// ?loadurl=<站点路径> 直接把项目里的图当素材载入(不必先下载再上传)
const loadURL = params.get('loadurl');
if (loadURL){
const want = params.get('name') || loadURL.split('/').pop().replace(/\.[^.]+$/, '');
addUrlAsset(loadURL, want).then(a => {
// ?fxjson={"key":{"on":true}} 用来直接给出初始处理参数(截图 / 复现某组参数用)
const fj = params.get('fxjson');
if (fj){ try { a.fx = normFx(JSON.parse(fj)); } catch (_) {} }
renderAll();
if (params.get('fx')) openFx(a);
}).catch(e => log('载入失败:' + e.message, 'e'));
} else if (params.get('fx')){
// ?fx=<素材id> 直接打开处理弹窗(截图和人工核对用)
const a0 = assetById(params.get('fx'));
if (a0) setTimeout(() => openFx(a0), 60);
}
// 自检钩子:?dump=css 导出最终 CSS,?dump=zip 导出 zip 的 base64。
// 无头浏览器里用 --dump-dom 抓这两个值,就能在没有 UI 交互的情况下做回归。
const dump = params.get('dump');
if (dump){
const pre = document.createElement('pre');
pre.id = 'dump';
if (dump === 'css'){
pre.textContent = buildCSS(false);
} else if (dump === 'validate'){
pre.textContent = JSON.stringify(validate(), null, 1);
} else if (dump === 'install'){
pre.textContent = installTxt(S.slug);
} else if (dump === 'fx'){
pre.textContent = JSON.stringify(fxSelfTest(), null, 1);
} else if (dump === 'layers'){
// 回归用户反馈:加层全是同一张图 / 换不了;背景关不掉;跟随全局时点不动
(async () => {
const r = {};
try {
const mk = (id, w, h, color) => {
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
const g = cv.getContext('2d');
g.fillStyle = color; g.fillRect(0, 0, w, h);
const conv = canvasAsset(cv, 0.9);
return { id, name: id + '.webp', orig: { dataURL: conv.dataURL, w, h },
fx: fxDefault(), ...conv };
};
S.assets = [mk('red', 100, 100, '#ff0000'), mk('blue', 200, 200, '#0000ff')];
$('add-layer').click();
$('add-layer').click();
const cfg = S.scopes.global;
r.afterAdd = cfg.layers.map(l => l.asset); // 期望 [null, null]
r.rowSelects = $('layers').querySelectorAll('select').length; // 期望 2
r.addDisabled = $('add-layer').disabled;
// 行内换图:把第 2 层改成 blue
const sels = [...$('layers').querySelectorAll('select')];
sels[1].value = 'blue';
sels[1].dispatchEvent(new Event('change'));
r.afterChange = cfg.layers.map(l => l.asset); // 期望 [null, 'blue']
r.cssUrls = [...buildCSS(false).matchAll(/themes\/[a-z0-9-]+\/([a-z0-9-]+)\.webp/g)]
.map(m => m[1]);
// 切到 box(默认跟随全局)
S.scope = 'box'; S.curLayer = null; renderAll();
r.box = {
inherit: S.scopes.box.inherit,
addDisabled: $('add-layer').disabled,
bgDisabled: $('bg-editor').querySelector('select').disabled,
layerRows: $('layers').querySelectorAll('.ly').length,
overlayGuides: doc ? doc.querySelectorAll('#__tb_ov > div').length : -1,
};
// 在 box 点「加一层」→ 自动变独立 + 复制全局那两层,全局不能被改
$('add-layer').click();
r.boxAfterAdd = {
inherit: S.scopes.box.inherit,
layers: S.scopes.box.layers.length, // 2 复制 + 1 = 3
globalLayers: S.scopes.global.layers.length, // 必须还是 2
};
// 背景:inherit 页原来点不动,现在能改,且改完自动独立
const bsel = $('bg-editor').querySelector('select');
bsel.value = 'red';
bsel.dispatchEvent(new Event('change'));
r.boxBg = {
boxBg: S.scopes.box.bg,
globalBg: S.scopes.global.bg,
inherit: S.scopes.box.inherit,
cssHasBg: /--tb-page-image: url/.test(buildCSS(true)),
};
// 新建:清空 + 删草稿
const oc = window.confirm;
window.confirm = () => true;
resetAll();
window.confirm = oc;
r.afterReset = {
assets: S.assets.length,
globalLayers: S.scopes.global.layers.length,
boxInherit: S.scopes.box.inherit,
scope: S.scope,
draft: localStorage.getItem(LS_KEY) === null ? '已删' : '还在',
slug: $('slug').value,
};
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'fxkey'){
// 端到端:把「白底立绘」载入 → 去白底 → 统计透明像素比例。
// 与手写 floodfill 抠出来的那张(46.6% 透明)对比,差值应很小。
(async () => {
const r = {};
try {
const a = await addUrlAsset(params.get('src') || '/outputs/fxtest-whitebg.png', 'cut');
r.origSize = [a.w, a.h];
const stat = (tol, holes, feather) => {
const cv = renderFxCanvas(a._img, { key:{ on:true, tol, holes, feather } });
const d = cv.getContext('2d').getImageData(0, 0, cv.width, cv.height).data;
let t = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] < 8) t++;
return { size:[cv.width, cv.height], clearPct: +(t / (d.length/4) * 100).toFixed(1) };
};
r.tol16 = stat(16, true, false);
r.tol16_noHoles = stat(16, false, false);
r.tol16_feather = stat(16, true, true);
r.tol30 = stat(30, true, false);
r.expect = 46.6;
r.ok = Math.abs(r.tol16.clearPct - 46.6) < 4;
} catch (e){
r.error = e.message;
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'fxpipe'){
// 异步链路冒烟:弹窗打开 → 改参数 → 预览渲染 → 关闭落盘。
// 结论写进 pre,靠 --dump-dom 在 virtual-time-budget 结束后抓。
(async () => {
const r = {};
try {
makeDemo();
const a = assetById('char');
const sig = x => x.dataURL.slice(-48);
const before = sig(a), bw = a.w;
await openFx(a);
r.modalOn = $('fxmodal').classList.contains('on');
r.previewW = $('fx-canvas').width;
r.previewH = $('fx-canvas').height;
r.sliderCount = $('fx-params').querySelectorAll('input[type=range]').length;
a.fx.wash = 20;
a.fx.gray = 30;
await fxPreview();
r.previewW2 = $('fx-canvas').width;
await closeFx();
r.modalOff = !$('fxmodal').classList.contains('on');
r.wash = a.fx.wash;
r.fxOn = !fxIsEmpty(a.fx);
r.summary = fxSummary(a.fx);
r.rerendered = before !== sig(a);
r.sizeKept = a.w === bw;
r.kb = a.kb;
// 再打开一次:参数要还在(normFx 不能把值吃掉)
const sigKeep = sig(a);
await openFx(a);
r.reopenWash = (() => {
const s = [...$('fx-params').querySelectorAll('input[type=range]')];
return s.length ? +s[1].value : -1;
})();
await closeFx();
// 打开看一眼就关:像素必须一个字节都不变(否则有损编码会累积)
r.untouchedKept = sigKeep === sig(a);
r.ok = r.modalOn && r.modalOff && r.rerendered && r.reopenWash === 20
&& r.previewW > 0 && r.untouchedKept;
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'fit'){
// 尺寸自适应自检:同一层在「桌机」与「手机」下的实际占用、溢出检测、
// 以及老工程(没有 unit 字段)的向后兼容
(async () => {
const r = {};
try {
makeDemo();
const l = S.scopes.global.layers[0];
const a = assetById('char');
const m900 = layerMetrics(l, a, 1512, 900);
const m844 = layerMetrics(l, a, 390, 844);
r.desktop = { w: Math.round(m900.w), h: Math.round(m900.h), overR: Math.round(m900.overR) };
r.mobile = { w: Math.round(m844.w), h: Math.round(m844.h), overR: Math.round(m844.overR) };
r.shorterSmaller = m844.h < m900.h;
const c0 = layerCSS(l, true);
r.size = c0.size;
r.pos = c0.position;
r.hasClamp = /clamp\(/.test(c0.size);
// 固定 px + 居中 + 高图:必须报上下溢出(这就是「像站在桌子上」那一类)
const big = Object.assign({}, l, { unit:'px', h:1200, w:0, ah:'center', dx:0 });
const mb = layerMetrics(big, a, 1512, 900);
r.pxOverflow = { overT: Math.round(mb.overT), overB: Math.round(mb.overB) };
r.pxDetected = mb.overT > 0 || mb.overB > 0;
// 老工程没有 unit/mob 字段:必须退回 px,不能算成 NaN
const legacy = Object.assign({}, l);
delete legacy.unit; delete legacy.mob;
r.legacyUnit = unitOf(legacy);
// fitw(按宽度自适应):宽幅素材在窄屏必须跟着缩,且 55% 下限要让位给视口
const bgA = assetById('bg');
const wide = Object.assign({}, l, { unit:'fitw', h:600, w:0, ah:'center', av:'center', dx:0, dy:0 });
const wWide = [[1512,900],[1024,768],[390,844]]
.map(([W, H]) => Math.round(layerMetrics(wide, bgA, W, H).w));
r.fitw = wWide;
r.fitwCss = layerSize(wide, bgA);
r.fitwShrinks = wWide[0] > wWide[1];
r.fitwNoOverflow = wWide[2] <= 390;
// 窄屏策略
S.narrow.hideDeco = false;
S.narrow.scale = 0.62;
r.mobScaled = decoMobile(Object.assign({}, l, { mob:'scale' })).h;
r.mobHidden = decoMobile(Object.assign({}, l, { mob:'hide' }));
const inh = Object.assign({}, l, { mob:'inherit' });
r.mobInherit = decoMobile(inh) === inh; // 原样返回,不复制
r.media = /@media \(max-width: 900px\)/.test(buildCSS(false));
// 窄屏里独立配置的页面也必须有一条(否则特异性不够,规则不生效)
r.mediaAdmin = /@media[\s\S]*data-page-scope="admin"/.test(buildCSS(false));
r.ok = r.shorterSmaller && r.hasClamp && r.pxDetected && r.legacyUnit === 'px'
&& r.mobScaled === 260 && r.mobHidden === null && r.mobInherit
&& r.fitwShrinks && r.fitwNoOverflow
&& r.media && r.mediaAdmin;
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'quality'){
// 画质自检:质量越低体积越小;长边上限必须真的把像素压下来。
// ⚠️ 必须用「有噪声」的图来验:平涂线稿在 WebP 下 q=1 常常比 q=0.6 还小
// (无损模式对纯色更高效,有损反而引入噪点),所以「降 q 一定省体积」不成立。
(async () => {
const r = {};
try {
const cv = document.createElement('canvas');
cv.width = 800; cv.height = 600;
const g = cv.getContext('2d');
const id = g.createImageData(800, 600);
let seed = 12345;
for (let i = 0; i < id.data.length; i += 4){
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
id.data[i] = seed & 255;
id.data[i+1] = (seed >> 8) & 255;
id.data[i+2] = (seed >> 16) & 255;
id.data[i+3] = 255;
}
g.putImageData(id, 0, 0);
const q6 = canvasAsset(cv, 0.6), q9 = canvasAsset(cv, 0.95), q1 = canvasAsset(cv, 1);
r.kb60 = q6.kb; r.kb95 = q9.kb; r.kb1 = q1.kb;
r.monotonic = q6.bytes.length < q9.bytes.length;
// q=1 会被夹到 0.95,否则会走 Skia 的 WebP 无损路径,体积反而小得离谱
r.oneClamped = q1.bytes.length === q9.bytes.length;
// 平涂对照:只记录,不作为判定条件(有损对纯色会引入噪点,可能变大)
const flat = document.createElement('canvas');
flat.width = 800; flat.height = 600;
const fg = flat.getContext('2d');
fg.fillStyle = '#4a7fc1'; fg.fillRect(0, 0, 800, 600);
r.flat_kb60 = canvasAsset(flat, 0.6).kb;
r.flat_kb95 = canvasAsset(flat, 0.95).kb;
const img = await new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = () => rej(new Error('canvas → Image 失败'));
im.src = cv.toDataURL('image/png');
});
const lim = renderFxCanvas(img, fxDefault(), 0, 200);
r.limited = [lim.width, lim.height]; // 长边 200 → 200×150
const noLim = renderFxCanvas(img, fxDefault(), 0, 0);
r.unlimited = [noLim.width, noLim.height]; // 800×600
r.ok = r.monotonic && r.oneClamped && r.limited[0] === 200 && r.limited[1] === 150
&& r.unlimited[0] === 800;
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'palette'){
// 派生色的对比度下限:base.css 把 --tb-text-muted 按 WCAG AA(4.5:1) 重算过,
// 生成器若还按固定比例往底色混,等于每次导出都把那个修复覆盖回去。
const r = { cases: {} };
try {
const cases = {
'默认亮色': { bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)', text:'#222d3b', primary:'#3d6fac', border:'#d6e0ec' },
'默认深色': { bg:'#141a22', surface:'#1d2530', navbar:'#141a22', text:'#e3eaf4', primary:'#6fa3e0', border:'#334052' },
// 极端:文字色本身就不达标 → 必须原样返回,不能继续往底色淡
'低对比手填': { bg:'#f4f4f4', surface:'#ffffff', navbar:'#ffffff', text:'#c8ccd2', primary:'#dddddd', border:'#e5e5e5' },
// 纯白卡片压在彩色底上:卡片侧才是更不利的那个
'白卡彩底': { bg:'#f6d9e4', surface:'#ffffff', navbar:'#f6d9e4', text:'#3b2b33', primary:'#c2557d', border:'#efd3de' },
};
let allOk = true;
const warns = [];
for (const name in cases){
const p = cases[name];
const back = textBackdrops(p);
const sec = mixToContrast(p.text, back, 5.5);
const mut = mixToContrast(p.text, back, 4.5);
const worst = c => Math.min(...back.map(b => contrastRatio(c, b)));
const row = {
secondary: +worst(sec).toFixed(2),
muted: +worst(mut).toFixed(2),
rawText: +worst(p.text).toFixed(2),
mutedDarkerThanSecondary: worst(mut) <= worst(sec), // 层级:淡文字必须比次要更淡
// 主色作底的文字:白字 vs 深字里必须挑更优的那个。
// 注意这里不断言「一定 ≥4.5」:主色卡在中间调时,两种文字色都不够,
// 这不是挑错了,而是主色本身要调 —— 列进 warnings 提醒,不判失败。
onPrimary: onPrimary(p.primary),
onPrimaryCr: +contrastRatio(onPrimary(p.primary), p.primary).toFixed(2),
onPrimaryPickedBest: Math.abs(
contrastRatio(onPrimary(p.primary), p.primary) -
Math.max(contrastRatio('#ffffff', p.primary), contrastRatio('#0b1016', p.primary))
) < 0.01,
// 关键断言:只要有 4.5 的空间就必须达标;本来就不达标的不算错
ok: ((worst(p.text) < 4.5) ? (mut === p.text) : (worst(mut) >= 4.5 && worst(sec) >= 4.5))
&& Math.abs(contrastRatio(onPrimary(p.primary), p.primary) -
Math.max(contrastRatio('#ffffff', p.primary), contrastRatio('#0b1016', p.primary))) < 0.01,
};
if (row.onPrimaryCr < 4.5) warns.push(name + '(主色 ' + p.primary + ' 中间调,白字/深字都不到 4.5)');
// 卡片半透明时必须按压平后的颜色算,否则白卡 86% 会被当成 #fff
row.surfaceFlat = flatten(p.surface, p.bg);
r.cases[name] = row;
if (!row.ok) allOk = false;
}
// 阶梯:淡文字绝不能比次要文字更醒目
for (const name in r.cases){
if (!r.cases[name].mutedDarkerThanSecondary) allOk = false;
}
// demo 的两套配色也要过:导出后的 CSS 直接吃这两个值
r.demoLight = { primary: S.palette.primary, on: onPrimary(S.palette.primary),
cr: +contrastRatio(onPrimary(S.palette.primary), S.palette.primary).toFixed(2) };
r.demoDark = { primary: S.dark.primary, on: onPrimary(S.dark.primary),
cr: +contrastRatio(onPrimary(S.dark.primary), S.dark.primary).toFixed(2) };
if (r.demoLight.cr < 4.5) warns.push('demo 亮色(可改 palette.primary)');
if (r.demoDark.cr < 4.5) warns.push('demo 深色(可改 dark.primary)');
r.warnings = warns;
// 把生成结果里的派生值也打出来,方便和 base.css 的取值对照
const css = buildCSS(false);
r.mutedLine = (css.match(/--tb-text-muted:[^;]+;/) || [''])[0];
r.secondaryLine = (css.match(/--tb-text-secondary:[^;]+;/) || [''])[0];
r.ok = allOk;
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
} 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>