Files
broccoliandClaude Fable 5 edfb216561 激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

图片相关:
- 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张
- 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG
- 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷
- 详情页缩略图宫格与全屏 lightbox,公开看板同样可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:06:02 +08:00

280 lines
16 KiB
JavaScript
Raw Permalink 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.
(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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
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&&currentHeight>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};
})();