激光材料数据平台 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
+80
View File
@@ -0,0 +1,80 @@
(function(){
// 必须自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
const escapeHtml=value=>String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
let overlay=null,items=[],index=0,restoreFocus=null,touchStartX=null;
function build(){
overlay=document.createElement('div');overlay.className='image-viewer';overlay.hidden=true;
overlay.setAttribute('role','dialog');overlay.setAttribute('aria-modal','true');overlay.setAttribute('aria-label','图片预览');
overlay.innerHTML='<div class="viewer-backdrop" data-viewer-close></div><figure class="viewer-stage"><img alt=""><p class="viewer-state" hidden></p><figcaption></figcaption></figure><button class="viewer-close" data-viewer-close aria-label="关闭预览">×</button><button class="viewer-nav prev" aria-label="上一张"></button><button class="viewer-nav next" aria-label="下一张"></button><span class="viewer-counter"></span>';
document.body.append(overlay);
overlay.querySelectorAll('[data-viewer-close]').forEach(node=>node.onclick=close);
overlay.querySelector('.viewer-nav.prev').onclick=()=>step(-1);
overlay.querySelector('.viewer-nav.next').onclick=()=>step(1);
overlay.addEventListener('touchstart',event=>{touchStartX=event.changedTouches[0].clientX;},{passive:true});
overlay.addEventListener('touchend',event=>{if(touchStartX===null)return;const delta=event.changedTouches[0].clientX-touchStartX;touchStartX=null;if(Math.abs(delta)>60)step(delta<0?1:-1);},{passive:true});
document.addEventListener('keydown',event=>{
if(overlay.hidden)return;
if(event.key==='Escape'){event.preventDefault();close();}
else if(event.key==='ArrowLeft')step(-1);
else if(event.key==='ArrowRight')step(1);
else if(event.key==='Tab'){
// aria-modal 要求焦点留在对话框内,否则 Tab 会走到被遮罩盖住的背景控件上。
const focusable=[...overlay.querySelectorAll('button')].filter(node=>!node.hidden&&node.offsetParent!==null);
if(!focusable.length)return;
const first=focusable[0],last=focusable[focusable.length-1];
if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}
else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}
else if(!overlay.contains(document.activeElement)){event.preventDefault();first.focus();}
}
});
}
function show(){
const item=items[index]||{},image=overlay.querySelector('img'),state=overlay.querySelector('.viewer-state');
image.removeAttribute('src'); // 置空字符串会让部分浏览器重新请求当前页面地址
image.alt=item.original_name?`成品图:${item.original_name}`:'实验成品图';
image.hidden=true;state.hidden=false;state.textContent='正在载入…';
image.onload=()=>{image.hidden=false;state.hidden=true;};
image.onerror=()=>{image.hidden=true;state.hidden=false;state.textContent='图片加载失败,可能已被删除';};
if(item.url)image.src=item.url;else image.onerror();
overlay.querySelector('figcaption').textContent=[item.original_name,item.width&&item.height?`${item.width}×${item.height}`:''].filter(Boolean).join(' · ');
overlay.querySelector('.viewer-counter').textContent=items.length>1?`${index+1} / ${items.length}`:'';
overlay.querySelectorAll('.viewer-nav').forEach(node=>node.hidden=items.length<2);
}
function step(direction){if(items.length<2)return;index=(index+direction+items.length)%items.length;show();}
function open(list,start=0){
if(!list?.length)return;
if(!overlay)build();
items=list;index=Math.max(0,Math.min(start,list.length-1));
restoreFocus=document.activeElement;
document.documentElement.classList.add('viewer-open');
overlay.hidden=false;show();
overlay.querySelector('.viewer-close').focus();
}
function close(){
if(!overlay||overlay.hidden)return;
overlay.hidden=true;overlay.querySelector('img').removeAttribute('src');
document.documentElement.classList.remove('viewer-open');
if(restoreFocus?.focus)restoreFocus.focus();
restoreFocus=null;
}
function galleryHtml(images,emptyText='本条记录暂未上传图片'){
if(!images?.length)return `<div class="gallery-empty">${escapeHtml(emptyText)}</div>`;
// 不用 loading="lazy":缩略图是在隐藏的详情弹窗里解析的,惰性加载会一直不触发;单张只有几 KB,最多 6 张。
return `<div class="image-gallery">${images.map((item,position)=>`<button type="button" class="gallery-thumb" data-viewer-index="${position}" aria-label="查看第 ${position+1} 张图片"><img src="${escapeHtml(item.thumb_url||item.url)}" alt="${escapeHtml(item.original_name||`成品图 ${position+1}`)}"><span class="gallery-fallback" hidden>图片加载失败</span></button>`).join('')}</div>`;
}
function wire(root,images){
if(!root||!images?.length)return;
root.querySelectorAll('[data-viewer-index]').forEach(button=>{
button.onclick=()=>open(images,Number(button.dataset.viewerIndex));
const image=button.querySelector('img');
if(image)image.onerror=()=>{image.hidden=true;button.querySelector('.gallery-fallback').hidden=false;};
});
}
function render(root,images,emptyText){if(!root)return;root.innerHTML=galleryHtml(images,emptyText);wire(root,images);}
window.ImageViewer={open,close,galleryHtml,wire,render};
})();