- 地图脚本:localStorage 读写走 lsGet/lsSet 兜底(隐私模式下裸访问会打断整段脚本); preview() 在 /api/map-config 还没回来时不丢点(previewQueue 在 if (live) 之前排队); 异步回包一律发序号作废,取消后回来的包不再画 - photo-exif.js:EXIF 时间全 0 不再变成 1899-11-30 写进到访时间;读 count 前先按版本卡宽度 - 数据编辑页:三个筛选表单都把其余条件用隐藏域原样带上,提交不再把别的列表的筛选清掉 - 补全页:sessionStorage 的读写一律包 try(脏数据/隐私模式都会抛) - 窄屏:删掉被后面同特异性规则盖掉的死内边距;宽表堆叠类名成对、判据扩到所有 tb 表 - 无障碍:图片删除按钮补 aria-label,访客画廊图片补 alt - report.html 补上漏掉的 </tr>,fog-mask 的宽表带 tb-stack 本批不碰数据库与导出路径:往返包 823/323/8/30/791/557/2246 全等、 坐标偏差 0.000000000、时间偏差 0s、迷雾 8701 块一致。
125 lines
5.4 KiB
JavaScript
125 lines
5.4 KiB
JavaScript
/* 地点图片:在浏览器里转成 WebP 再上传。
|
||
*
|
||
* 为什么放在前端转:1 核的服务器解一张 4000px 的原图要好几秒,几张就够卡住整站;
|
||
* 而用户自己的手机/电脑转一张是几十毫秒的事。服务端只负责把收到的 WebP 字节原样写出去。
|
||
*
|
||
* 尺寸与质量策略(在真机照片上量过才定的):
|
||
* 长边 1440px,质量 0.72 → 超 220KB 再试 0.58 → 还超就把整张缩到 1080px 重编。
|
||
* 关键在于「体积大头是像素数,不是质量」:实测一张 13MB 的城市照片,
|
||
* 1600px 从 q0.74 一路降到 q0.5 只从 483KB 掉到 391KB(掉 19%,画面却明显糊了),
|
||
* 而同样 q0.72 把长边从 1600 缩到 1024 直接掉到 217KB(掉 55%)。
|
||
* 所以阶梯走到底还超标时,宁可缩尺寸也不再压质量。
|
||
* 这几张图只用于详情页画廊与灯箱(最大显示到一千出头的宽度),1440px 留了余量。
|
||
*/
|
||
(function () {
|
||
var MAX_EDGE = 1440;
|
||
var QUALITY_LADDER = [0.72, 0.58];
|
||
var TARGET_BYTES = 220 * 1024;
|
||
// 兜底档:纹理特别密的照片在 1440px 下怎么降质都超标时才用
|
||
var FALLBACK_EDGE = 1080;
|
||
var FALLBACK_QUALITY = 0.7;
|
||
|
||
function loadImage(file) {
|
||
return new Promise(function (resolve, reject) {
|
||
var url = URL.createObjectURL(file);
|
||
var img = new Image();
|
||
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
||
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('这张图片读不出来')); };
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
function encode(cv, quality) {
|
||
return new Promise(function (resolve, reject) {
|
||
cv.toBlob(function (blob) {
|
||
// 不支持 WebP 编码的浏览器会静默给回 PNG,这里必须显式挡住——
|
||
// 否则上传的是 PNG,服务端会以「只接受 WebP」拒掉,用户却看不出原因
|
||
if (!blob || blob.type !== 'image/webp') {
|
||
reject(new Error('这个浏览器不支持 WebP 转码,请换新版 Chrome / Edge / Safari'));
|
||
return;
|
||
}
|
||
resolve(blob);
|
||
}, 'image/webp', quality);
|
||
});
|
||
}
|
||
|
||
// render 把原图按长边上限放到一张画布上,返回画布与落地的宽高
|
||
function render(img, limit) {
|
||
var w = img.naturalWidth || img.width || 1;
|
||
var h = img.naturalHeight || img.height || 1;
|
||
var scale = Math.min(1, limit / Math.max(w, h));
|
||
var tw = Math.max(1, Math.round(w * scale));
|
||
var th = Math.max(1, Math.round(h * scale));
|
||
var cv = document.createElement('canvas');
|
||
cv.width = tw;
|
||
cv.height = th;
|
||
var g = cv.getContext('2d');
|
||
if (g.imageSmoothingQuality !== undefined) g.imageSmoothingQuality = 'high';
|
||
// 白底:源图带透明通道时(截图、PNG)不会在 WebP 里变成黑块
|
||
g.fillStyle = '#fff';
|
||
g.fillRect(0, 0, tw, th);
|
||
g.drawImage(img, 0, 0, tw, th);
|
||
return { cv: cv, width: tw, height: th };
|
||
}
|
||
|
||
// toWebP(file) -> Promise<{blob, width, height, quality, bytes}>
|
||
function toWebP(file, opts) {
|
||
opts = opts || {};
|
||
var maxEdge = opts.maxEdge || MAX_EDGE;
|
||
var target = opts.targetBytes || TARGET_BYTES;
|
||
var ladder = opts.ladder || QUALITY_LADDER;
|
||
var fallbackEdge = opts.fallbackEdge === undefined ? FALLBACK_EDGE : opts.fallbackEdge;
|
||
var fallbackQuality = opts.fallbackQuality || FALLBACK_QUALITY;
|
||
return loadImage(file).then(function (img) {
|
||
var box = render(img, maxEdge);
|
||
function attempt(i) {
|
||
var q = ladder[i];
|
||
return encode(box.cv, q).then(function (blob) {
|
||
var out = { blob: blob, width: box.width, height: box.height, quality: q, bytes: blob.size };
|
||
if (out.bytes <= target || i === ladder.length - 1) return out;
|
||
return attempt(i + 1);
|
||
});
|
||
}
|
||
return attempt(0).then(function (out) {
|
||
// 阶梯走到底还超标 ⇒ 这张图纹理特别密,再降质量只会又糊又大,改成缩尺寸
|
||
var longest = Math.max(box.width, box.height);
|
||
if (out.bytes <= target || !fallbackEdge || longest <= fallbackEdge) return out;
|
||
var small = render(img, fallbackEdge);
|
||
return encode(small.cv, fallbackQuality).then(function (blob) {
|
||
// 兜底档同样是有损重编,纹理极端的图上未必比阶梯档小,所以比一次体积、取小的
|
||
if (blob.size >= out.bytes) return out;
|
||
return { blob: blob, width: small.width, height: small.height, quality: fallbackQuality, bytes: blob.size };
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
function post(url, form) {
|
||
return fetch(url, { method: 'POST', body: form, credentials: 'same-origin' })
|
||
.then(function (r) {
|
||
// 服务端出错时回的是 HTML,直接 r.json() 会抛一句英文 SyntaxError;
|
||
// 先判状态码,调用方才能拿到人话错误。
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.json();
|
||
});
|
||
}
|
||
|
||
// upload(srcPK, blob, w, h) -> Promise<{ok, id, url} | {error}>
|
||
function upload(srcPK, blob, w, h) {
|
||
var fd = new FormData();
|
||
fd.append('src_pk', srcPK);
|
||
fd.append('w', w);
|
||
fd.append('h', h);
|
||
fd.append('file', blob, 'image.webp');
|
||
return post('/admin/place/images', fd);
|
||
}
|
||
|
||
function remove(id) {
|
||
var fd = new FormData();
|
||
fd.append('id', id);
|
||
return post('/admin/place/images/delete', fd);
|
||
}
|
||
|
||
window.rondImage = { toWebP: toWebP, upload: upload, remove: remove };
|
||
})();
|