主题素材可放任意 S3 兼容存储,生成器内测通后一键上传

素材位置改成两选一(本地随二进制 / 自行填 Endpoint 的 S3 兼容存储),
R2、MinIO、B2、OSS-COS 的 S3 端点都只差 Endpoint/Region/路径风格。

- serve.py:手写 SigV4(PUT/GET/DELETE/HEAD),新增 /api/s3/test 与
  /api/s3/upload。测试连接依次验连通、写权限、读回一致、匿名可读,
  失败把 S3 的 <Code> 翻成可执行的建议;上传带 immutable 缓存头。
- index.html:新增「素材托管」面板(模式切换 + 7 个字段 + 测试/上传按钮),
  上传集合与导出共用 usedAssetIds();成功后自动回填「素材前缀」。
  密钥只进 sessionStorage 与内存,不进草稿/工程 json/导出 zip。
- 修:usedAssetIds 返回 Set,误用 length/map 导致「还没有素材」。
- 修:字段填完 700ms 内刷新会丢配置 → 失焦与模式切换时立即落盘。
- 自检:check_sigv4.py 与 aws-sdk-go-v2 signer 逐字节对拍(6/6),
  selftest_upload.py 起假 S3 跑通测试连接→上传→读回(16 项),
  mock_s3.py 供手动点面板用。
This commit is contained in:
tamakyi
2026-10-21 21:08:39 +08:00
parent 6c21184733
commit 9c961b3c05
9 changed files with 1220 additions and 30 deletions
+375 -10
View File
@@ -103,6 +103,20 @@
.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}
.assets{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.as{
position:relative;aspect-ratio:1;border-radius:6px;overflow:hidden;background:#0b0f14;
@@ -235,6 +249,11 @@
<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>
@@ -342,6 +361,18 @@ const D = {
填了(如 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',
@@ -361,6 +392,9 @@ const S = {
narrow: Object.assign({}, D.narrow),
export: Object.assign({}, D.export),
assetBase: '',
host: Object.assign({}, D.host),
/* 运行时状态:测试/上传结果,不参与持久化 */
s3: { kind: '', text: '', busy: false, canUpload: null },
dark: Object.assign({}, D.dark),
};
@@ -1593,9 +1627,12 @@ function renderExportEditor(){
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 —— `
+ '记得把 zip 里的 static/images/themes/' + S.slug + '/ 整个传到这个路径下;'
+ '仓库里就不需要这些图了。换 CDN 域名要重新导出 CSS。';
+ how + '换 CDN 域名要重新导出 CSS。';
el.appendChild(cdn);
}
@@ -1607,6 +1644,313 @@ function renderExportEditor(){
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);
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = can === false
? '当前是普通静态服务,没有上传接口。用 python dev/theme-builder/serve.py 启动生成器才能上传。'
: '密钥只存在当前标签页(sessionStorage)与内存里,不进工程文件、不进导出 zip。'
+ '上传成功后会自动把「素材前缀」填成上面的图片地址。';
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(){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
const slug = ($('slug').value || '').trim() || 'my-theme';
// usedAssetIds 返回 Set:它有 size 没有 length/map,先展开成数组再用。
const ids = [...usedAssetIds()];
if (!ids.length){ setS3State('bad', '还没有素材:先在「图片池」里加图片并摆到图层/背景上'); return; }
const files = ids.map(id => {
const a = assetById(id);
return {
key: `${slug}/${id}.webp`,
name: `${id}.webp`,
contentType: 'image/webp',
data: (a.dataURL.split(',')[1] || ''),
};
}).filter(f => f.data);
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();
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();
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();
}
}
/** 改画质后重编全部素材,并把前后体积说清楚 —— 否则用户不知道有没有生效。 */
async function applyQuality(what){
if (!S.assets.length){ renderAll(); return; }
@@ -1700,6 +2044,7 @@ function renderAll(){
renderLayers();
renderAssets();
renderExportEditor();
renderHostEditor();
renderNarrowEditor();
renderPalette();
applyTheme();
@@ -1725,6 +2070,9 @@ function toJSON(){
narrow: S.narrow,
export: S.export,
assetBase: S.assetBase,
// 托管配置进工程(换机器接着传),但 accessKey/secretKey 刻意不进去:
// 工程文件常被塞进仓库或发给别人,密钥一旦落进去就没法收回了。
host: S.host,
// 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。
// 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 ——
// 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。
@@ -1755,6 +2103,8 @@ function fromJSON(o){
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;
@@ -1804,6 +2154,8 @@ function resetAll(){
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); });
@@ -1975,7 +2327,13 @@ ${S.assetBase
素材托管在对象存储,CSS 写的是绝对地址:
换 CDN 域名 / 路径 = 必须重新导出(地址烘焙在文件里,不是运行时拼的)
换图 = 覆盖同名对象,建议把 Cache-Control 设成 immutable + 用 ?v= 或换文件名做失效`
换图 = 覆盖同名对象,建议把 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`}
@@ -2040,13 +2398,9 @@ ${S.narrow.enabled
`;
}
function doExport(){
const errs = validate();
if (errs.length){
out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e');
return;
}
const slug = ($('slug').value || '').trim();
/** 本次主题真正引用到的素材 id(导出与上传共用同一份判定)。
没有被任何图层/背景引用的图不该进 zip,也不该传到对象存储上占位。 */
function usedAssetIds(){
const used = new Set();
if (S.narrow.bg) used.add(S.narrow.bg);
const collect = c => {
@@ -2055,6 +2409,17 @@ function doExport(){
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;