一个手机快速搜索商品的网页

This commit is contained in:
2025-12-07 15:09:21 +08:00
commit fcbcdb7f95
17 changed files with 815 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
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;
// 无分页模式
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');
try{
if('BarcodeDetector' in window){
const formats = ['ean_13','ean_8','code_128','code_39','upc_a','upc_e'];
const detector = new window.BarcodeDetector({ formats });
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();
const loop = async()=>{
try{
const codes = await detector.detect(videoEl);
if(codes && codes.length){
const text = codes[0].rawValue;
input.value = text;
currentQuery = text;
fetchResults();
closeScannerFn();
return;
}
}catch(_){ }
if(!scannerModal.classList.contains('hidden')) requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
} else {
scannerModal.classList.add('hidden');
}
}catch(e){
scannerModal.classList.add('hidden');
}
}
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(_){ }
scannerModal.classList.add('hidden');
}
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 = '导入失败';
}
});
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>商品查询</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="container">
<div class="tabs" role="tablist">
<button class="tab active" role="tab" aria-selected="true" aria-controls="searchSection" data-tab="search">搜索</button>
<button class="tab" role="tab" aria-selected="false" aria-controls="importSection" data-tab="import">导入</button>
</div>
<section id="searchSection" class="section">
<h1>商品查询</h1>
<div class="search">
<div class="search-input">
<input id="q" type="text" placeholder="输入条码或名称,支持前缀/后缀/包含" />
<button id="scanBtn" class="scan-btn" aria-label="扫码">📷</button>
</div>
<button id="searchBtn">查询</button>
</div>
<div id="results" class="results"></div>
</section>
<section id="importSection" class="section hidden">
<h1>导入Excel</h1>
<div class="import">
<input id="file" type="file" accept=".xlsx,.xls" />
<button id="importBtn">导入</button>
<div id="importStatus" class="status"></div>
</div>
</section>
</div>
<div id="scannerModal" class="modal hidden">
<div class="modal-content">
<div id="scanner"></div>
<button id="closeScanner">关闭</button>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto;background:#fff;color:#111}
.container{max-width:720px;margin:0 auto;padding:16px}
.tabs{position:sticky;top:0;background:#fff;padding:8px 0;margin-bottom:8px;border-bottom:1px solid #eee;z-index:10}
.tab{margin-right:8px;padding:10px 14px;border:1px solid #ddd;border-radius:8px;background:#f7f7f7;color:#111}
.tab.active{background:#1976d2;color:#fff;border-color:#1976d2}
h1{font-size:20px;margin:8px 0 16px}
.section{margin-top:8px}
.hidden{display:none}
.search{display:flex;gap:8px;margin-bottom:16px}
input{flex:1;padding:10px;border:1px solid #ccc;border-radius:6px;background:#fff;color:#111}
.search-input{position:relative;flex:1}
.search-input input{width:100%;padding-right:44px}
.scan-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);border:0;background:#f0f0f0;color:#111;border-radius:6px;padding:6px 10px}
button{padding:10px 14px;border:0;border-radius:6px;background:#1976d2;color:#fff}
.results{display:flex;flex-direction:column;gap:12px}
.card{border:1px solid #eee;border-radius:10px;padding:12px;box-shadow:0 1px 2px rgba(0,0,0,0.04)}
.name{font-weight:700;font-size:16px;margin-bottom:6px}
.prices{display:flex;gap:16px;margin-bottom:6px}
.price-sale{color:#d32f2f;font-weight:600}
.price-purchase{color:#455a64}
.barcode{font-family:ui-monospace, SFMono-Regular, Menlo, monospace;color:#222}
mark{background:#ffec99}
.row{display:flex;justify-content:space-between;padding:4px 0}
.row span:first-child{color:#555}
.empty{color:#999}
.loading{color:#1976d2}
.status{margin-top:8px;color:#555}
.import{margin-top:12px;display:flex;gap:8px;align-items:center}
.modal{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center}
.modal.hidden{display:none}
.modal-content{background:#fff;border-radius:10px;padding:12px;width:92%;max-width:480px}
@media (max-width:600px){
.container{padding:12px}
.tab{padding:8px 12px}
.prices{flex-direction:column}
.import{flex-direction:column;align-items:stretch}
.search{display:grid;grid-template-columns:4fr 1fr;gap:8px}
}