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

81 lines
5.5 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(){
// 必须自己转义引号: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};
})();