241 lines
8.5 KiB
JavaScript
241 lines
8.5 KiB
JavaScript
const qs = (s) => document.querySelector(s);
|
|
const results = qs('#results');
|
|
const input = qs('#q');
|
|
const searchBtn = qs('#searchBtn');
|
|
const fileInput = qs('#file');
|
|
const importBtn = qs('#importBtn');
|
|
const importStatus = qs('#importStatus');
|
|
const tabs = document.querySelectorAll('.tab');
|
|
const searchSection = qs('#searchSection');
|
|
const importSection = qs('#importSection');
|
|
const scanBtn = qs('#scanBtn');
|
|
const scannerModal = qs('#scannerModal');
|
|
const closeScanner = qs('#closeScanner');
|
|
let scannerInst = null;
|
|
let videoEl = null;
|
|
let mediaStream = null;
|
|
const scannerMessage = qs('#scannerMessage');
|
|
const captureInput = qs('#captureInput');
|
|
const toggleTorchBtn = qs('#toggleTorch');
|
|
const zoomInput = qs('#zoom');
|
|
const zoomWrap = qs('#zoomWrap');
|
|
let detector = null;
|
|
let videoTrack = null;
|
|
let loopReq = null;
|
|
// 无分页模式
|
|
|
|
let currentTab = 'search';
|
|
let limit = 10000;
|
|
let currentQuery = '';
|
|
function setTab(tab){
|
|
currentTab = tab;
|
|
tabs.forEach(t=>{
|
|
const active = t.dataset.tab===tab;
|
|
t.classList.toggle('active', active);
|
|
t.setAttribute('aria-selected', String(active));
|
|
});
|
|
if(tab==='search'){ searchSection.classList.remove('hidden'); importSection.classList.add('hidden'); }
|
|
else { importSection.classList.remove('hidden'); searchSection.classList.add('hidden'); }
|
|
}
|
|
tabs.forEach(t=>t.addEventListener('click',()=>setTab(t.dataset.tab)));
|
|
setTab('search');
|
|
|
|
async function fetchResults(){
|
|
results.innerHTML = '<div class="loading">加载中...</div>';
|
|
try{
|
|
const r = await fetch(`/products?q=${encodeURIComponent(currentQuery)}&limit=${limit}`);
|
|
const d = await r.json();
|
|
render(d);
|
|
}catch(e){
|
|
results.innerHTML = '<div class="empty">请求失败</div>';
|
|
}
|
|
}
|
|
|
|
function search(){
|
|
currentQuery = input.value.trim();
|
|
fetchResults();
|
|
}
|
|
|
|
function render(items) {
|
|
if (!items || items.length === 0) {
|
|
results.innerHTML = '<div class="empty">无结果</div>';
|
|
return;
|
|
}
|
|
const term = currentQuery;
|
|
const esc = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = term ? new RegExp(esc, 'ig') : null;
|
|
results.innerHTML = items.map(x => {
|
|
const name = x.name ?? '';
|
|
const purchase = x.purchase_price ?? '';
|
|
const sale = x.sale_price ?? '';
|
|
const barcode = x.barcode ?? '';
|
|
const nameH = re ? name.replace(re, m=>`<mark>${m}</mark>`) : name;
|
|
const barcodeH = re ? barcode.replace(re, m=>`<mark>${m}</mark>`) : barcode;
|
|
return `
|
|
<div class="card">
|
|
<div class="name">${nameH}</div>
|
|
<div class="prices">
|
|
<div class="price-sale">卖价:${sale}</div>
|
|
<div class="price-purchase">进价:${purchase}</div>
|
|
</div>
|
|
<div class="barcode">条码:${barcodeH}</div>
|
|
</div>`;
|
|
}).join('');
|
|
// 无分页模式
|
|
}
|
|
|
|
searchBtn.addEventListener('click', search);
|
|
input.addEventListener('keydown', e => { if (e.key === 'Enter') search(); });
|
|
|
|
async function openScanner(){
|
|
scannerModal.classList.remove('hidden');
|
|
scannerMessage.textContent = '正在请求摄像头权限...';
|
|
scannerMessage.classList.remove('hidden');
|
|
const isSecure = location.protocol === 'https:' || location.hostname === 'localhost';
|
|
try{
|
|
if(!isSecure){
|
|
scannerMessage.textContent = '非安全上下文,已切换为拍照识别方式';
|
|
captureInput.click();
|
|
return;
|
|
}
|
|
if('BarcodeDetector' in window){
|
|
const supported = await window.BarcodeDetector.getSupportedFormats();
|
|
const wanted = ['ean_13','ean_8','code_128','code_39','upc_a','upc_e'];
|
|
const useFormats = wanted.filter(f=>supported.includes(f));
|
|
detector = new window.BarcodeDetector({ formats: useFormats.length?useFormats:supported });
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
|
videoEl = document.createElement('video');
|
|
videoEl.autoplay = true;
|
|
videoEl.playsInline = true;
|
|
videoEl.srcObject = mediaStream;
|
|
const host = document.getElementById('scanner');
|
|
host.innerHTML = '';
|
|
host.appendChild(videoEl);
|
|
await videoEl.play();
|
|
scannerMessage.classList.add('hidden');
|
|
try{
|
|
videoTrack = mediaStream.getVideoTracks()[0];
|
|
const caps = videoTrack.getCapabilities ? videoTrack.getCapabilities() : {};
|
|
if(caps.torch){ toggleTorchBtn.classList.remove('hidden'); }
|
|
else { toggleTorchBtn.classList.add('hidden'); }
|
|
if(caps.zoom){
|
|
zoomWrap.classList.remove('hidden');
|
|
const min = caps.zoom.min ?? 1;
|
|
const max = caps.zoom.max ?? 5;
|
|
zoomInput.min = String(min);
|
|
zoomInput.max = String(max);
|
|
zoomInput.value = String(min);
|
|
} else {
|
|
zoomWrap.classList.add('hidden');
|
|
}
|
|
}catch(_){ }
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const tick = async()=>{
|
|
try{
|
|
const w = videoEl.videoWidth || 640;
|
|
const h = videoEl.videoHeight || 480;
|
|
const size = Math.min(w, h);
|
|
const sx = (w - size)/2;
|
|
const sy = (h - size)/2;
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
ctx.drawImage(videoEl, sx, sy, size, size, 0, 0, size, size);
|
|
const codes = await detector.detect(canvas);
|
|
if(codes && codes.length){
|
|
const text = codes[0].rawValue;
|
|
input.value = text;
|
|
currentQuery = text;
|
|
fetchResults();
|
|
closeScannerFn();
|
|
return;
|
|
}
|
|
}catch(_){ }
|
|
if(!scannerModal.classList.contains('hidden')){ loopReq = requestAnimationFrame(tick); }
|
|
};
|
|
loopReq = requestAnimationFrame(tick);
|
|
} else {
|
|
scannerMessage.textContent = '浏览器不支持实时扫码,已切换为拍照识别方式';
|
|
captureInput.click();
|
|
}
|
|
}catch(e){
|
|
scannerMessage.textContent = '无法启用摄像头,已切换为拍照识别方式';
|
|
captureInput.click();
|
|
}
|
|
}
|
|
|
|
function closeScannerFn(){
|
|
try{ if(scannerInst){ scannerInst.stop().then(()=>scannerInst.clear()); } }catch(_){ }
|
|
try{ if(mediaStream){ mediaStream.getTracks().forEach(t=>t.stop()); mediaStream=null; } }catch(_){ }
|
|
try{ const host = document.getElementById('scanner'); if(host){ host.innerHTML=''; } }catch(_){ }
|
|
try{ if(scannerMessage){ scannerMessage.textContent=''; scannerMessage.classList.add('hidden'); } }catch(_){ }
|
|
try{ if(loopReq){ cancelAnimationFrame(loopReq); loopReq=null; } }catch(_){ }
|
|
scannerModal.classList.add('hidden');
|
|
}
|
|
|
|
captureInput.addEventListener('change', async ()=>{
|
|
const file = captureInput.files?.[0];
|
|
if(!file){ return; }
|
|
try{
|
|
if('BarcodeDetector' in window){
|
|
const img = await createImageBitmap(file);
|
|
const detector = new window.BarcodeDetector({ formats: ['ean_13','ean_8','code_128','code_39','upc_a','upc_e'] });
|
|
const codes = await detector.detect(img);
|
|
if(codes && codes.length){
|
|
const text = codes[0].rawValue;
|
|
input.value = text;
|
|
currentQuery = text;
|
|
fetchResults();
|
|
} else {
|
|
scannerMessage.textContent = '未识别到条码,请重试或手动输入';
|
|
scannerMessage.classList.remove('hidden');
|
|
}
|
|
} else {
|
|
scannerMessage.textContent = '当前浏览器不支持条码识别,请手动输入';
|
|
scannerMessage.classList.remove('hidden');
|
|
}
|
|
}catch(_){
|
|
scannerMessage.textContent = '识别失败,请重试或手动输入';
|
|
scannerMessage.classList.remove('hidden');
|
|
} finally {
|
|
captureInput.value = '';
|
|
}
|
|
});
|
|
|
|
toggleTorchBtn.addEventListener('click', async ()=>{
|
|
try{
|
|
if(!videoTrack) return;
|
|
const settings = videoTrack.getSettings ? videoTrack.getSettings() : {};
|
|
const torchOn = settings.torch === true;
|
|
await videoTrack.applyConstraints({ advanced: [{ torch: !torchOn }] });
|
|
}catch(_){ }
|
|
});
|
|
|
|
zoomInput.addEventListener('input', async ()=>{
|
|
try{
|
|
if(!videoTrack) return;
|
|
const z = Number(zoomInput.value);
|
|
await videoTrack.applyConstraints({ advanced: [{ zoom: z }] });
|
|
}catch(_){ }
|
|
});
|
|
|
|
scanBtn.addEventListener('click', openScanner);
|
|
closeScanner.addEventListener('click', closeScannerFn);
|
|
|
|
// 无分页控件
|
|
|
|
importBtn.addEventListener('click', async () => {
|
|
const f = fileInput.files?.[0];
|
|
if (!f) return;
|
|
const fd = new FormData();
|
|
fd.append('file', f);
|
|
importStatus.textContent = '导入中...';
|
|
try {
|
|
const r = await fetch('/import', { method: 'POST', body: fd });
|
|
const d = await r.json();
|
|
importStatus.textContent = `插入: ${d.inserted ?? 0},更新: ${d.updated ?? 0},跳过: ${d.skipped ?? 0}`;
|
|
} catch (e) {
|
|
importStatus.textContent = '导入失败';
|
|
}
|
|
});
|