(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,'''); 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{ // 是主路径:现代浏览器会自动按 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;indexIOS_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||'待上传图片'); // 已上传的用 包起来,键盘和读屏才进得去;上传中/失败的还没有大图可看,保持纯展示。 const preview_markup=preview ? (item.status==='ready' ? `` : ``) : '…'; return ` ${preview_markup} ${busy?``:''} ${failed?`${escapeHtml(item.message||'上传失败')}`:''} ${escapeHtml(item.original_name||item.name||'')} ${readonly?'':`×`} ${failed&&!readonly?`重试`:''} `; }).join(''); const dropzone=readonly?'':` ▣ 添加成品图JPG / PNG,最多 ${MAX_IMAGES} 张,上传前自动压缩 选择图片 ${CAN_CAPTURE?`拍照`:''} ${remaining>0?`还可添加 ${remaining} 张`:'已达数量上限'} `; container.innerHTML=`${dropzone}${cards||(readonly?'暂无图片':'')}`; 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}; })();
添加成品图JPG / PNG,最多 ${MAX_IMAGES} 张,上传前自动压缩