公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
280 lines
16 KiB
JavaScript
280 lines
16 KiB
JavaScript
(function(){
|
||
const MAX_IMAGES=6,MAX_EDGE=1600,THUMB_EDGE=320,QUALITY=.82,THUMB_QUALITY=.72,MAX_UPLOAD_BYTES=8*1024*1024,PNG_PASSTHROUGH_BYTES=1.5*1024*1024,DECODE_TIMEOUT=20000,UPLOAD_TIMEOUT=120000,IOS_CANVAS_LIMIT=16777216;
|
||
// 必须自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
|
||
const escapeHtml=value=>String(value??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
const GENERIC_NAMES=new Set(['image.jpg','image.jpeg','image.png','image.heic','photo.jpg','photo.jpeg','未命名.jpg']);
|
||
// 桌面浏览器会静默忽略 capture,摄像头按钮在那里只会打开同样的文件框,所以只在触摸设备渲染。
|
||
const CAN_CAPTURE=matchMedia('(pointer: coarse)').matches&&navigator.maxTouchPoints>0;
|
||
|
||
let config={endpoint:'',getCsrf:()=>'',onChange:null},container=null,items=[],readonly=false,serial=0,queue=Promise.resolve(),loadedExisting=false;
|
||
|
||
const releaseCanvas=canvas=>{canvas.width=canvas.height=0;};
|
||
const toJpeg=(canvas,quality)=>new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',quality));
|
||
|
||
// 只用 load/error 事件:img.decode() 在部分 Chromium 上对未插入文档的图片会永不 resolve,卡死上传队列。
|
||
function loadElement(url){
|
||
return new Promise((resolve,reject)=>{
|
||
const image=new Image();image.decoding='async';
|
||
const timer=setTimeout(()=>reject(new Error('decode-timeout')),DECODE_TIMEOUT);
|
||
image.onload=()=>{clearTimeout(timer);image.naturalWidth&&image.naturalHeight?resolve(image):reject(new Error('decode'));};
|
||
image.onerror=()=>{clearTimeout(timer);reject(new Error('decode'));};
|
||
image.src=url;
|
||
});
|
||
}
|
||
|
||
async function decodeImage(file){
|
||
const url=URL.createObjectURL(file);
|
||
try{
|
||
// <img> 是主路径:现代浏览器会自动按 EXIF 摆正,iOS Safari 还能直接解 HEIC。
|
||
const image=await loadElement(url);
|
||
return {source:image,width:image.naturalWidth,height:image.naturalHeight,release(){URL.revokeObjectURL(url);}};
|
||
}catch(error){
|
||
URL.revokeObjectURL(url);
|
||
if(typeof createImageBitmap!=='function')throw error;
|
||
const bitmap=await createImageBitmap(file,{imageOrientation:'from-image'});
|
||
return {source:bitmap,width:bitmap.width,height:bitmap.height,release(){bitmap.close?.();}};
|
||
}
|
||
}
|
||
|
||
function drawScaled(source,sourceWidth,sourceHeight,maxEdge){
|
||
const scale=Math.min(1,maxEdge/Math.max(sourceWidth,sourceHeight));
|
||
const width=Math.max(1,Math.round(sourceWidth*scale)),height=Math.max(1,Math.round(sourceHeight*scale));
|
||
let current=source,currentWidth=sourceWidth,currentHeight=sourceHeight;
|
||
while(currentWidth>width*2&¤tHeight>height*2){ // 大比例一次缩到底会产生锯齿,逐级折半更稳
|
||
currentWidth=Math.max(width,Math.round(currentWidth/2));currentHeight=Math.max(height,Math.round(currentHeight/2));
|
||
const step=document.createElement('canvas');step.width=currentWidth;step.height=currentHeight;
|
||
const stepContext=step.getContext('2d');stepContext.imageSmoothingQuality='high';
|
||
stepContext.drawImage(current,0,0,currentWidth,currentHeight);
|
||
if(current!==source)releaseCanvas(current);
|
||
current=step;
|
||
}
|
||
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
|
||
const context=canvas.getContext('2d');context.imageSmoothingQuality='high';
|
||
context.fillStyle='#ffffff';context.fillRect(0,0,width,height); // PNG 透明区直接转 JPEG 会变黑
|
||
context.drawImage(current,0,0,width,height);
|
||
if(current!==source)releaseCanvas(current);
|
||
return canvas;
|
||
}
|
||
|
||
function looksBlank(canvas){
|
||
try{
|
||
const probe=document.createElement('canvas');probe.width=probe.height=8;
|
||
const context=probe.getContext('2d',{willReadFrequently:true});
|
||
context.drawImage(canvas,0,0,8,8);
|
||
const {data}=context.getImageData(0,0,8,8);
|
||
let sum=0,squares=0;
|
||
for(let index=0;index<data.length;index+=4){
|
||
const luma=(data[index]*299+data[index+1]*587+data[index+2]*114)/1000;
|
||
sum+=luma;squares+=luma*luma;
|
||
}
|
||
releaseCanvas(probe);
|
||
return squares/64-(sum/64)**2<0.5; // 方差趋近 0 说明整张是纯色,正常照片不会这样
|
||
}catch{return false;}
|
||
}
|
||
|
||
async function prepare(file){
|
||
if(!file.type.startsWith('image/')&&!/\.(jpe?g|png|webp|heic|heif)$/i.test(file.name||''))throw new Error('只能上传图片文件');
|
||
let decoded;
|
||
try{decoded=await decodeImage(file);}
|
||
catch(error){
|
||
const isApple=/iP(hone|ad|od)/.test(navigator.userAgent);
|
||
throw new Error(/heic|heif/i.test(`${file.type} ${file.name}`)
|
||
? (isApple?'这张是 HEIC 高效格式且本机无法转换,请到「设置 → 相机 → 格式」选「兼容性最佳」后重拍':'这张是 HEIC/HEIF 格式,当前浏览器无法解码,请改用 JPEG 拍摄或先导出为 JPEG')
|
||
: '图片无法读取,请换一张或重新拍摄');
|
||
}
|
||
try{
|
||
// PNG 小图原样上传,线稿和截图重编码成 JPEG 会有振铃;PNG 没有 EXIF 方向问题,尺寸依然可信。
|
||
if(file.type==='image/png'&&file.size<=PNG_PASSTHROUGH_BYTES&&Math.max(decoded.width,decoded.height)<=MAX_EDGE){
|
||
const thumbCanvas=drawScaled(decoded.source,decoded.width,decoded.height,THUMB_EDGE);
|
||
const thumb=await toJpeg(thumbCanvas,THUMB_QUALITY);releaseCanvas(thumbCanvas);
|
||
return {blob:file,name:file.name||'image.png',thumb};
|
||
}
|
||
const canvas=drawScaled(decoded.source,decoded.width,decoded.height,MAX_EDGE);
|
||
// 超过 iOS 画布像素上限的源图,Safari 不报错、直接给一张空白画布,会静默存进一张纯白"成品图"。
|
||
if(decoded.width*decoded.height>IOS_CANVAS_LIMIT&&looksBlank(canvas)){
|
||
releaseCanvas(canvas);
|
||
throw new Error('这张照片分辨率过高,本机无法压缩,请把相机分辨率调低后重拍');
|
||
}
|
||
const blob=await toJpeg(canvas,QUALITY);
|
||
if(!blob){releaseCanvas(canvas);throw new Error('图片压缩失败,请换一张试试');}
|
||
if(blob.size>MAX_UPLOAD_BYTES){releaseCanvas(canvas);throw new Error('图片压缩后仍然过大,请换一张');}
|
||
let thumb=null;
|
||
// 从已经缩到 1600 的画布再缩,而不是回到原图重跑一遍——那是整个流程里最吃内存的一段。
|
||
try{const thumbCanvas=drawScaled(canvas,canvas.width,canvas.height,THUMB_EDGE);thumb=await toJpeg(thumbCanvas,THUMB_QUALITY);releaseCanvas(thumbCanvas);}catch{thumb=null;}
|
||
releaseCanvas(canvas);
|
||
return {blob,name:(file.name||'photo.jpg').replace(/\.(heic|heif|png|webp)$/i,'.jpg'),thumb};
|
||
}finally{decoded.release();}
|
||
}
|
||
|
||
function send(prepared,onProgress,item){
|
||
return new Promise((resolve,reject)=>{
|
||
const form=new FormData();
|
||
form.append('file',prepared.blob,prepared.name);
|
||
if(prepared.thumb)form.append('thumbnail',prepared.thumb,'thumb.jpg');
|
||
const request=new XMLHttpRequest();
|
||
request.open('POST',config.endpoint);
|
||
// 必须显式设置:XHR 默认永不超时,移动网络下的半开连接会让这张图永远停在"上传中",从而一直挡住提交。
|
||
request.timeout=UPLOAD_TIMEOUT;
|
||
if(item)item.xhr=request;
|
||
request.setRequestHeader('X-CSRF-Token',config.getCsrf()||'');
|
||
request.upload.onprogress=event=>{if(event.lengthComputable)onProgress(event.loaded/event.total);};
|
||
request.onload=()=>{
|
||
let data=null;try{data=JSON.parse(request.responseText);}catch{}
|
||
if(request.status>=200&&request.status<300&&data?.ok)resolve(data.data);
|
||
else if(request.status===401)reject(Object.assign(new Error('录入授权已过期,请重新验证后再上传'),{unauthorized:true}));
|
||
else if(request.status===413)reject(new Error(data?.message||'图片超出大小限制'));
|
||
else reject(new Error(data?.message||`上传失败(${request.status})`));
|
||
};
|
||
request.onerror=()=>reject(new Error('网络中断,图片未上传'));
|
||
request.ontimeout=()=>reject(new Error('上传超时,请重试'));
|
||
request.send(form);
|
||
});
|
||
}
|
||
|
||
function discard(token){
|
||
// 尽力而为:撤回失败也不打扰用户,服务端 2 小时后会自动清理。
|
||
fetch(`${config.endpoint}/${encodeURIComponent(token)}`,{method:'DELETE',headers:{'X-CSRF-Token':config.getCsrf()||''}}).catch(()=>{});
|
||
}
|
||
|
||
function render(){
|
||
if(!container)return;
|
||
const remaining=MAX_IMAGES-items.length;
|
||
const cards=items.map(item=>{
|
||
const busy=item.status==='uploading',failed=item.status==='error';
|
||
const preview=item.thumb_url||item.url||item.localUrl||'';
|
||
const label=escapeHtml(item.original_name||'待上传图片');
|
||
// 已上传的用 <button> 包起来,键盘和读屏才进得去;上传中/失败的还没有大图可看,保持纯展示。
|
||
const preview_markup=preview
|
||
? (item.status==='ready'
|
||
? `<button type="button" class="image-card-open" data-open="${item.key}" aria-label="放大查看 ${label}"><img src="${escapeHtml(preview)}" alt="${label}"></button>`
|
||
: `<img src="${escapeHtml(preview)}" alt="${label}">`)
|
||
: '<span class="image-card-blank">…</span>';
|
||
return `<figure class="image-card ${item.status}" data-key="${item.key}">
|
||
${preview_markup}
|
||
${busy?`<span class="image-card-progress"><i style="width:${Math.round((item.progress||0)*100)}%"></i></span>`:''}
|
||
${failed?`<span class="image-card-error" title="${escapeHtml(item.message||'')}">${escapeHtml(item.message||'上传失败')}</span>`:''}
|
||
<figcaption>${escapeHtml(item.original_name||item.name||'')}</figcaption>
|
||
${readonly?'':`<button type="button" class="image-card-remove" data-remove="${item.key}" aria-label="移除这张图片">×</button>`}
|
||
${failed&&!readonly?`<button type="button" class="image-card-retry" data-retry="${item.key}">重试</button>`:''}
|
||
</figure>`;
|
||
}).join('');
|
||
const dropzone=readonly?'':`<div class="image-drop ${items.length?'compact':''}" data-drop>
|
||
<span class="image-drop-icon">▣</span>
|
||
<p><b>添加成品图</b><small>JPG / PNG,最多 ${MAX_IMAGES} 张,上传前自动压缩</small></p>
|
||
<span class="image-drop-actions">
|
||
<button type="button" class="secondary" data-pick ${remaining<=0?'disabled':''}>选择图片</button>
|
||
${CAN_CAPTURE?`<button type="button" class="secondary" data-capture ${remaining<=0?'disabled':''}>拍照</button>`:''}
|
||
</span>
|
||
<small class="image-drop-hint">${remaining>0?`还可添加 ${remaining} 张`:'已达数量上限'}</small>
|
||
</div>`;
|
||
container.innerHTML=`${dropzone}<div class="image-card-grid">${cards||(readonly?'<div class="gallery-empty">暂无图片</div>':'')}</div>`;
|
||
wire();
|
||
config.onChange?.(items.slice());
|
||
}
|
||
|
||
function wire(){
|
||
const drop=container.querySelector('[data-drop]');
|
||
if(drop){
|
||
container.querySelector('[data-pick]')?.addEventListener('click',()=>openPicker(false));
|
||
container.querySelector('[data-capture]')?.addEventListener('click',()=>openPicker(true));
|
||
['dragenter','dragover'].forEach(name=>drop.addEventListener(name,event=>{event.preventDefault();drop.classList.add('drag');}));
|
||
['dragleave','drop'].forEach(name=>drop.addEventListener(name,event=>{event.preventDefault();drop.classList.remove('drag');}));
|
||
drop.addEventListener('drop',event=>accept([...(event.dataTransfer?.files||[])]));
|
||
}
|
||
container.querySelectorAll('[data-remove]').forEach(button=>button.onclick=()=>{
|
||
const removed=items.find(item=>item.key===button.dataset.remove);
|
||
removed?.xhr?.abort(); // 传到一半就移除的,别让请求继续跑
|
||
if(removed?.localUrl)URL.revokeObjectURL(removed.localUrl);
|
||
if(removed?.token)discard(removed.token); // 传完又删掉的,通知服务端撤回,及时腾出待提交名额
|
||
items=items.filter(item=>item.key!==button.dataset.remove);render();
|
||
});
|
||
container.querySelectorAll('[data-retry]').forEach(button=>button.onclick=()=>{
|
||
const item=items.find(entry=>entry.key===button.dataset.retry);
|
||
if(item?.file){items=items.filter(entry=>entry.key!==item.key);render();accept([item.file]);}
|
||
});
|
||
container.querySelectorAll('[data-open]').forEach(button=>button.onclick=()=>{
|
||
const ready=items.filter(item=>item.status==='ready');
|
||
window.ImageViewer?.open(ready,ready.findIndex(item=>item.key===button.dataset.open));
|
||
});
|
||
}
|
||
|
||
function openPicker(capture){
|
||
const input=document.createElement('input');
|
||
input.type='file';input.accept='image/*';
|
||
if(capture)input.setAttribute('capture','environment');else input.multiple=true;
|
||
input.style.display='none';
|
||
input.onchange=()=>{accept([...input.files]);input.remove();};
|
||
document.body.append(input);input.click();
|
||
}
|
||
|
||
function accept(files){
|
||
if(readonly)return;
|
||
const usable=files.filter(file=>file&&(file.type.startsWith('image/')||/\.(jpe?g|png|webp|heic|heif)$/i.test(file.name||'')));
|
||
if(!usable.length)return;
|
||
const room=MAX_IMAGES-items.length;
|
||
if(room<=0){config.onError?.(`每条记录最多 ${MAX_IMAGES} 张图片`);return;}
|
||
if(usable.length>room)config.onError?.(`最多还能添加 ${room} 张,多余的已忽略`);
|
||
usable.slice(0,room).forEach(file=>{
|
||
const item={key:`u${++serial}`,status:'uploading',progress:0,name:file.name||'照片',original_name:file.name||'照片',file};
|
||
items.push(item);
|
||
// 串行处理:多张大图并行解码会在手机上直接把页面挤爆内存
|
||
queue=queue.then(()=>process(item)).catch(()=>{});
|
||
});
|
||
render();
|
||
}
|
||
|
||
async function process(item){
|
||
if(!items.includes(item))return;
|
||
try{
|
||
const prepared=await prepare(item.file);
|
||
if(!items.includes(item))return;
|
||
item.localUrl=URL.createObjectURL(prepared.thumb||prepared.blob);
|
||
render();
|
||
const report=fraction=>{item.progress=fraction;const bar=container?.querySelector(`[data-key="${item.key}"] .image-card-progress i`);if(bar)bar.style.width=`${Math.round(fraction*100)}%`;};
|
||
let data;
|
||
try{data=await send(prepared,report,item);}
|
||
catch(error){
|
||
// 录入授权过期时就地重新验证再传一次,别让用户为此刷新页面丢掉整张表单。
|
||
if(!error.unauthorized||!config.onUnauthorized||!await config.onUnauthorized())throw error;
|
||
data=await send(prepared,report,item);
|
||
}
|
||
if(!items.includes(item)){return;}
|
||
Object.assign(item,data,{status:'ready',file:null});
|
||
if(item.localUrl){URL.revokeObjectURL(item.localUrl);item.localUrl=null;}
|
||
autoFillFilename(data);
|
||
}catch(error){
|
||
if(!items.includes(item))return;
|
||
item.status='error';item.message=error.message;
|
||
config.onError?.(error.message);
|
||
}finally{render();}
|
||
}
|
||
|
||
function autoFillFilename(data){
|
||
const field=document.querySelector('input[name="finished_image_filename"]');
|
||
if(!field||field.value.trim())return;
|
||
const original=(data.original_name||'').trim();
|
||
if(original&&!GENERIC_NAMES.has(original.toLowerCase())){field.value=original.slice(0,255);return;}
|
||
const experiment=document.querySelector('input[name="experiment_id"]')?.value.trim();
|
||
if(experiment)field.value=`${experiment}.jpg`.slice(0,255);
|
||
}
|
||
|
||
function mount(root,options={}){
|
||
container=root;config={...config,...options};items=[];readonly=Boolean(options.readonly);loadedExisting=false;
|
||
if(container)render();
|
||
}
|
||
function load(images=[]){
|
||
items=images.map((image,position)=>({...image,key:`s${image.id??position}`,status:'ready'}));
|
||
loadedExisting=true;render();
|
||
}
|
||
function reset(){items.forEach(item=>{if(item.localUrl)URL.revokeObjectURL(item.localUrl);item.xhr?.abort();});items=[];loadedExisting=false;render();}
|
||
function setReadonly(value){readonly=Boolean(value);render();}
|
||
function collect(){
|
||
const ready=items.filter(item=>item.status==='ready');
|
||
// 没载入过既有图片就回 null,让后端保持原样,避免新建表单的空列表被当成"删光全部图片"。
|
||
return {image_ids:loadedExisting?ready.filter(item=>item.id&&!item.token).map(item=>item.id):null,image_tokens:ready.filter(item=>item.token).map(item=>item.token)};
|
||
}
|
||
function pending(){return items.some(item=>item.status==='uploading');}
|
||
|
||
window.ImageUpload={mount,load,reset,setReadonly,collect,pending,MAX_IMAGES};
|
||
})();
|