生成器可检测与管理远端主题素材(列表 / 差异 / 覆盖 / 删除)

「传上去」之后还得知道「传得对不对」:同一 slug 传过三次、换过 slug 旧图还在、
重新导出后远端仍是旧版,这些在页面上都看不出来。

- serve.py:s3_request 支持 query;新增 ListObjectsV2(含分页)与 DeleteObject,
  端点 /api/s3/list、/api/s3/delete。
- index.html:远端素材清单按对象键名跟本地比 —— 一致 / 旧图(同名但大小不同)/
  多余(当前主题已不引用)/ 缺失。「补齐差异」只传缺的与旧图,「删除多余」批量删,
  单行可 ↻ 覆盖或 ✕ 删除;删除走 confirm 二次确认。改过 slug 会提示重新检测。
- 修:list 返回完整键而 upload/delete 收相对键,不剥前缀会删到不存在的键
  (S3 回 204,看起来删成功其实没删)。加 relKey() 统一。
- check_sigv4.py 补带 query 的用例(ListObjectsV2 三参数、乱序 token)→ 8/8;
  selftest_upload.py 补列表/分页/幂等删除 → 20 项全过。
This commit is contained in:
tamakyi
2026-10-21 21:29:35 +08:00
parent 9c961b3c05
commit 12c4f3e1be
9 changed files with 549 additions and 23 deletions
+276 -4
View File
@@ -117,6 +117,26 @@
.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;
@@ -395,6 +415,9 @@ const S = {
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),
};
@@ -1868,6 +1891,9 @@ function renderHostEditor(){
res.textContent = S.s3.text || '';
el.appendChild(res);
// 远端清单放在状态框下面:先看「测试/上传结果」,再看「远端现在到底有什么」
el.appendChild(renderRemoteBlock());
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = can === false
@@ -1906,29 +1932,33 @@ async function testS3(){
}
}
async function uploadAssets(){
async function uploadAssets(onlyIds){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
const slug = ($('slug').value || '').trim() || 'my-theme';
const slug = currentSlug();
// usedAssetIds 返回 Set:它有 size 没有 length/map,先展开成数组再用。
const ids = [...usedAssetIds()];
// 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.data);
}).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){
@@ -1940,6 +1970,7 @@ async function uploadAssets(){
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');
@@ -1949,6 +1980,247 @@ async function uploadAssets(){
} 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;
}
/** 改画质后重编全部素材,并把前后体积说清楚 —— 否则用户不知道有没有生效。 */