公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
81 lines
5.5 KiB
JavaScript
81 lines
5.5 KiB
JavaScript
(function(){
|
||
// 必须自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
|
||
const escapeHtml=value=>String(value??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
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};
|
||
})();
|