Files
tamabox/templates/base/footer.html
T
tamakyi f9aaef17e5 feat(ux): 图片选图即压缩,上传体积从 MB 级降到 KB 级
服务端早已把静态图转 WebP、长边限 1600(storage.GeneralEncodeSpec),但上传这一跳
发生在压缩之前:手机拍的 4~8MB 照片要先原样传完才能被缩小,移动网络 1~2 Mbps 下
要等几十秒,超过 5MB 校验的直接被拒、连写好的正文一起丢。

现在选图后先在浏览器内压缩,再随表单上传:
- canvas → WebP,长边 1600、质量 0.8(与服务端同档),一张照片通常降到 200~400KB
- createImageBitmap + imageOrientation:from-image 摆正 EXIF,老浏览器回退不带选项
- submit capture 兜底:选图立刻提交时先等压缩完成再真正 submit

三条安全边界(缺一不可,否则会「压出比原图更差的结果」):
- GIF/SVG 跳过:canvas 会把 GIF 画成静止首帧、SVG 栅格化
- 压完不到原体积 85% 退回原图:已优化小图重编码反而更大
- 透明 PNG 优先 WebP(支持 alpha),不支持 WebP 编码时退回 PNG 而非 JPEG(否则透明变黑)

实测(WSL headless Chromium + CDP):4.6MB PNG → 11.7KB WebP(0.3%),
小图原样保留、GIF 跳过。

顺带加 dev/sync-wsl.sh:把同步脚本里 rsync --delete 误删目标机 conf/app.ini 的坑
固化掉(该文件含 DB 密码与对象存储密钥,不在源目录里,必须 exclude)。
2026-10-29 18:26:04 +08:00

313 lines
13 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.
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
<br>
<a href="/rules">{{t .LangCode "site.footer.rules"}}</a> |
<a href="/stats">{{t .LangCode "site.footer.stats"}}</a> |
<!--<a href="/pixel">画板</a> | -->
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
<br>
<a href="https://share.shiroko.one" target="_blank">{{t .LangCode "site.footer.drive"}}</a>
| <a href="https://blog.shiroko.one" target="_blank">{{t .LangCode "site.footer.notice"}}</a>
| <a href="https://chat.shiroko.one" target="_blank">{{t .LangCode "site.footer.chat"}}</a>
| <a href="https://status.shiroko.one" target="_blank">{{t .LangCode "site.footer.status"}}</a>
| <a href="https://shiroko.one" target="_blank">{{t .LangCode "site.footer.nav"}}</a>
<br>Powered by NekoBox | {{ ICP }}
<br>{{t .LangCode "site.footer.runtime"}}:<span id="box_runtime"></span>
</p>
<script>
// 页脚「提问箱已成功存活」倒计时:原 R2 cdn/time.box.js(1.6KB)内联于此,
// 消除全站每页一个 R2 慢 defer(实测 0.7-1.7s 波动)。相对原脚本两处修复:
// 1) 先判空再写入(原版先赋值后判空,box_runtime 缺失时会抛 ReferenceError 中断脚本);
// 2) 首帧立即填充(原版 DOMContentLoaded + 首个 1s 间隔后才显示,首秒为空)。
(function () {
var create = Math.round(Date.UTC(2023, 6, 1, 18, 15, 43) / 1000); // 提问箱创建时刻(UTC)
function tick() {
var el = document.getElementById("box_runtime");
if (!el) return;
var s = Math.round((Date.now() + 8 * 3600 * 1000) / 1000) - create; // UTC+8 秒级存活
var t = [0, 0, 0, 0, 0];
if (s >= 31536000) { t[0] = s / 31536000 | 0; s %= 31536000; }
if (s >= 86400) { t[1] = s / 86400 | 0; s %= 86400; }
if (s >= 3600) { t[2] = s / 3600 | 0; s %= 3600; }
if (s >= 60) { t[3] = s / 60 | 0; s %= 60; }
if (s > 0) { t[4] = s; }
el.innerHTML = t[0] + '年 ' + t[1] + '天 ' + t[2] + '时 ' + t[3] + '分 ' + t[4] + '秒 ';
}
tick();
setInterval(tick, 1000);
})();
</script>
<br>
</div>
<script>
// 提交反馈(P0):点击任意表单的提交按钮后,立即把触发按钮变成「转圈 + 本地化文字」,
// 并禁用表单内全部按钮,物理阻止在上传/网络慢时的重复点击与重复提交。
// 浏览器前进/后退(bfcache)回到本页时自动复位,避免停留在禁用或转圈状态。
// 纯内联反馈,不使用全屏遮罩。
var SUBMIT_I18N = {
submitting: '{{t .LangCode "common.submitting"}}',
uploading: '{{t .LangCode "common.uploading"}}'
};
function tbResetSubmitButtons() {
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
b.disabled = false;
b.removeAttribute('data-tb-submitting');
var orig = b.getAttribute('data-tb-restore');
if (orig !== null) {
b.innerHTML = orig;
b.removeAttribute('data-tb-restore');
}
});
}
// tbBeginSubmit 把表单切到「提交中」状态:触发按钮换成 spinner + 本地化文案,
// 并禁用表单内所有按钮。
//
// 抽成具名函数(而不是内联在下面的事件监听里)是因为 **程序化 submit() 不触发
// submit 事件**:走 reCAPTCHA / Turnstile 的流程是在回调里调 form.submit() 的,
// 原本完全绕过下面那个监听器 —— 而默认部署 captcha_type = recaptcha,
// 也就是主提问流一直没有防重复点击保护。那些回调现在先调本函数再 submit()。
function tbBeginSubmit(form, trigger) {
if (!form) return;
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
if (form.getAttribute('target') === '_blank') return;
// 触发提交的按钮(programmatic submit 时调用方传 null,回退到首个 submit 按钮)
var btn = trigger || form.querySelector('button[type="submit"]');
// 表单含已选中的文件 → 提示“上传中”,否则“提交中”
var hasFile = false;
form.querySelectorAll('input[type="file"]').forEach(function(f) {
if (f.files && f.files.length > 0) hasFile = true;
});
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
if (btn && btn.tagName === 'BUTTON') {
// 已经切过一次就不再覆盖 data-tb-restore,否则会把 spinner 的 HTML 当成原始内容,
// 导致 bfcache 复位后按钮永远停在转圈上。
if (!btn.hasAttribute('data-tb-restore')) {
btn.setAttribute('data-tb-restore', btn.innerHTML);
}
btn.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
}
// 禁用表单内所有按钮,防止重复提交
form.querySelectorAll('button').forEach(function(b) {
b.disabled = true;
b.setAttribute('data-tb-submitting', '1');
});
}
// 挂到 window 上供 header 里的验证码回调调用(脚本顺序:header → footer,
// 但回调发生在用户点击之后,此时本函数已定义)
window.tbBeginSubmit = tbBeginSubmit;
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
// 页面级提交反馈必须尊重前置校验:投票编辑页等 onsubmit 返回 false 时,
// submit 事件仍可能继续到达此监听器;此时绝不能切换到“提交中”状态。
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
form.removeAttribute('data-tb-skip-submit-feedback');
return;
}
tbBeginSubmit(form, e.submitter);
});
// bfcache 还原时复位按钮
window.addEventListener('pageshow', tbResetSubmitButtons);
</script>
<script>
// 图片前端压缩:选图后先在浏览器里把大图缩到服务端的上限,再随表单上传。
//
// 为什么值得做:服务端已经把静态图转 WebP 并把长边限到 1600(storage.GeneralEncodeSpec),
// 但**上传这一跳是先发生的** —— 手机拍的 4~8MB 照片要先原样传完,才能被服务端缩小。
// 于是出现两个真实问题:
// 1. 超过 form.MaxRequestBodySize / 服务端 5MB 校验的图直接被拒,用户写好的正文一起丢;
// 2. 移动网络上行为 1~2 Mbps,传一张 6MB 图要 30 秒以上,期间页面只能干等。
// 前端先缩到 1600 长边、质量 0.8(与服务端同一档),一张手机照片通常降到 200~400KB,
// 上传时间从几十秒变成一两秒,且不再触碰体积上限。
//
// 三条安全边界(都不能省,否则会「压缩」出比原图更差的结果):
// - GIF 与 SVG 直接跳过:canvas 会把 GIF 画成静止首帧、把 SVG 栅格化;
// - 压完不比原图小就退回原图:已经优化过的小图重编码反而更大;
// - PNG 的透明通道保留:优先输出 WebP(支持 alpha),浏览器不支持 WebP 编码时
// 用 PNG 而不是 JPEG(JPEG 会把透明变成黑底)。
(function () {
var MAX_SIDE = 1600; // 与服务端 GeneralEncodeSpec.MaxSide 对齐
var QUALITY = 0.8; // 与服务端 Quality 对齐
var MIN_BYTES = 300 * 1024; // 小于这个体积重编码收益有限,直接放行
var MIN_SAVING = 0.85; // 压完不到原体积 85% 就不采用
// 每个 input 最近的压缩任务,供 submit 前等待。
var pending = new WeakMap();
var webpOK = null;
function supportsWebP() {
if (webpOK !== null) return webpOK;
try {
var c = document.createElement('canvas');
c.width = c.height = 1;
webpOK = c.toDataURL('image/webp').indexOf('data:image/webp') === 0;
} catch (e) {
webpOK = false;
}
return webpOK;
}
function isCompressible(file) {
if (!file || !file.type) return false;
// GIF(可能动图)与 SVG(矢量)不能用 canvas 重编码
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return false;
if (file.type.indexOf('image/') !== 0) return false;
return file.size > MIN_BYTES;
}
function loadBitmap(file) {
if (self.createImageBitmap) {
// imageOrientation: 'from-image' 让手机竖拍照片按 EXIF 摆正;
// 老浏览器不认这个选项会抛错,退回不带选项的调用。
return createImageBitmap(file, { imageOrientation: 'from-image' })
.catch(function () { return createImageBitmap(file); });
}
return new Promise(function (resolve, reject) {
var img = new Image();
var url = URL.createObjectURL(file);
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('decode failed')); };
img.src = url;
});
}
function toBlob(canvas, type, quality) {
return new Promise(function (resolve) { canvas.toBlob(resolve, type, quality); });
}
function reencode(file) {
return loadBitmap(file).then(function (bmp) {
var w = bmp.width, h = bmp.height;
if (!w || !h) throw new Error('empty image');
var scale = Math.min(1, MAX_SIDE / Math.max(w, h));
var tw = Math.max(1, Math.round(w * scale));
var th = Math.max(1, Math.round(h * scale));
// PNG 走 WebP(两者都支持透明);不支持 WebP 编码时宁可继续用 PNG,
// 也不要转成 JPEG —— 透明区域会变成黑块。
var transparentCapable = file.type === 'image/png';
var target = 'image/jpeg';
if (supportsWebP()) {
target = 'image/webp';
} else if (transparentCapable) {
target = 'image/png';
}
var canvas = document.createElement('canvas');
canvas.width = tw;
canvas.height = th;
var ctx = canvas.getContext('2d');
if (!ctx) throw new Error('no 2d context');
if (target === 'image/jpeg') {
// JPEG 没有 alpha:先铺白底,否则透明部分变黑。
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, tw, th);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bmp, 0, 0, tw, th);
if (bmp.close) bmp.close();
return toBlob(canvas, target, QUALITY);
}).then(function (blob) {
if (!blob || blob.size >= file.size * MIN_SAVING) return file;
// 文件名保持不变:FormData 里的文件名只用于展示与日志,
// 服务端按文件头嗅探真实格式(image.DecodeConfig),不看扩展名。
return new File([blob], file.name, { type: blob.type, lastModified: Date.now() });
}).catch(function () {
// 任何一步失败(不支持、内存不足、编码器缺失)都退回原图,绝不阻断提交。
return file;
});
}
function swap(input, file) {
try {
var dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
return true;
} catch (e) {
// 老浏览器不支持对 input.files 赋值:放弃替换,按原图提交。
return false;
}
}
function bind(input) {
if (input.dataset.tbImgBound === '1') return;
input.dataset.tbImgBound = '1';
input.addEventListener('change', function () {
var file = input.files && input.files[0];
if (!isCompressible(file)) {
pending.delete(input);
return;
}
var job = reencode(file).then(function (out) {
if (out !== file) swap(input, out);
});
pending.set(input, job);
// 无论成功失败都清掉,避免下一次提交白等一个已完成的 Promise。
job.then(function () {
if (pending.get(input) === job) pending.delete(input);
});
});
}
function scan(root) {
(root || document).querySelectorAll('input[type="file"]').forEach(bind);
}
scan(document);
// 后插入的表单(如「点击回答」后展开的编辑器)不会触发上面的扫描,
// 用 MutationObserver 兜住;只在新增节点里找 input,开销可以忽略。
if (window.MutationObserver) {
new MutationObserver(function (records) {
records.forEach(function (r) {
Array.prototype.forEach.call(r.addedNodes, function (node) {
if (node.nodeType !== 1) return;
if (node.matches && node.matches('input[type="file"]')) bind(node);
if (node.querySelectorAll) scan(node);
});
});
}).observe(document.documentElement, { childList: true, subtree: true });
}
// 兜底:极少数情况下用户选完图立刻提交(压缩还没跑完)。
// capture 阶段先拦下,等压缩结束再真正提交 —— 用 form.submit() 而不是
// 再走一次 submit 事件,天然避开死循环,也不会二次触发上面的防重复逻辑。
document.addEventListener('submit', function (e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
var waits = [];
form.querySelectorAll('input[type="file"]').forEach(function (input) {
var job = pending.get(input);
if (job) waits.push(job);
});
if (!waits.length) return;
e.preventDefault();
Promise.all(waits).then(function () {
if (window.tbBeginSubmit) window.tbBeginSubmit(form, e.submitter || null);
form.submit();
});
}, true);
})();
</script>
</body>
</html>