激光材料数据平台 1.3.0

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:06:02 +08:00
co-authored by Claude Fable 5
commit edfb216561
49 changed files with 3922 additions and 0 deletions
+279
View File
@@ -0,0 +1,279 @@
(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};
})();