主题素材可放任意 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:
+375
-10
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user