优化
This commit is contained in:
parent
e0fb6381d2
commit
7b9ac13e89
@ -43,10 +43,9 @@
|
|||||||
- Python 依赖:镜像已默认使用腾讯云 PyPI 源 `https://mirrors.cloud.tencent.com/pypi/simple`
|
- Python 依赖:镜像已默认使用腾讯云 PyPI 源 `https://mirrors.cloud.tencent.com/pypi/simple`
|
||||||
- 预构建:在本地构建并推送到腾讯云 TCR,服务器直接拉取镜像,避免远端构建时慢下载
|
- 预构建:在本地构建并推送到腾讯云 TCR,服务器直接拉取镜像,避免远端构建时慢下载
|
||||||
|
|
||||||
## 前端扫码
|
## 移动端兼容
|
||||||
- 优先使用浏览器原生 `BarcodeDetector` 实时识别
|
- iPhone 输入时不再自动放大:移动端下统一设置输入与按钮字体为 16px
|
||||||
- 非安全上下文或不支持时,自动切换为“拍照识别”方式
|
- 采用响应式布局,保证手机与桌面一致可用
|
||||||
- 识别成功自动填充并触发查询
|
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
- `app/` 后端 FastAPI
|
- `app/` 后端 FastAPI
|
||||||
|
|||||||
149
static/app.js
149
static/app.js
@ -8,20 +8,6 @@ const importStatus = qs('#importStatus');
|
|||||||
const tabs = document.querySelectorAll('.tab');
|
const tabs = document.querySelectorAll('.tab');
|
||||||
const searchSection = qs('#searchSection');
|
const searchSection = qs('#searchSection');
|
||||||
const importSection = qs('#importSection');
|
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 currentTab = 'search';
|
||||||
@ -87,140 +73,7 @@ function render(items) {
|
|||||||
searchBtn.addEventListener('click', search);
|
searchBtn.addEventListener('click', search);
|
||||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') 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);
|
|
||||||
|
|
||||||
// 无分页控件
|
// 无分页控件
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>益选便利店商品查询</title>
|
<title>益选便利店商品查询</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
@ -16,16 +16,7 @@
|
|||||||
<section id="searchSection" class="section">
|
<section id="searchSection" class="section">
|
||||||
<h1>益选便利店商品查询</h1>
|
<h1>益选便利店商品查询</h1>
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<div class="search-input">
|
<input id="q" type="text" inputmode="search" placeholder="输入条码或名称,支持前缀/后缀/包含" />
|
||||||
<input id="q" type="text" placeholder="输入条码或名称,支持前缀/后缀/包含" />
|
|
||||||
<button id="scanBtn" class="scan-btn" aria-label="扫码" title="扫码">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<path d="M4 7a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7z" stroke="#444" stroke-width="1.5" fill="#fff"/>
|
|
||||||
<rect x="9" y="9" width="6" height="6" rx="3" stroke="#444" stroke-width="1.5" fill="#fff"/>
|
|
||||||
<path d="M7 4l2 3h6l2-3" stroke="#444" stroke-width="1.5"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button id="searchBtn">查询</button>
|
<button id="searchBtn">查询</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="results" class="results"></div>
|
<div id="results" class="results"></div>
|
||||||
@ -40,21 +31,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div id="scannerModal" class="modal hidden">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div id="scannerMessage" class="scanner-message hidden"></div>
|
|
||||||
<div id="scanner" class="scanner-area"></div>
|
|
||||||
<div class="scanner-controls">
|
|
||||||
<button id="toggleTorch" class="hidden">开灯</button>
|
|
||||||
<label id="zoomWrap" class="hidden">
|
|
||||||
缩放
|
|
||||||
<input id="zoom" type="range" min="1" max="5" step="0.1" value="1" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button id="closeScanner">关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input id="captureInput" type="file" accept="image/*" capture="environment" class="hidden" />
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -9,12 +9,6 @@ h1{font-size:20px;margin:8px 0 16px}
|
|||||||
.hidden{display:none}
|
.hidden{display:none}
|
||||||
.search{display:flex;gap:8px;margin-bottom:16px}
|
.search{display:flex;gap:8px;margin-bottom:16px}
|
||||||
input{flex:1;padding:10px;border:1px solid #ccc;border-radius:6px;background:#fff;color:#111}
|
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:1px solid #ddd;background:#fafafa;color:#111;border-radius:6px;padding:6px 10px;display:flex;align-items:center;justify-content:center;cursor:pointer}
|
|
||||||
.scan-btn:hover{background:#f0f0f0}
|
|
||||||
.scan-btn:active{background:#e6e6e6}
|
|
||||||
.scan-btn svg{display:block}
|
|
||||||
button{padding:10px 14px;border:0;border-radius:6px;background:#1976d2;color:#fff}
|
button{padding:10px 14px;border:0;border-radius:6px;background:#1976d2;color:#fff}
|
||||||
.results{display:flex;flex-direction:column;gap:12px}
|
.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)}
|
.card{border:1px solid #eee;border-radius:10px;padding:12px;box-shadow:0 1px 2px rgba(0,0,0,0.04)}
|
||||||
@ -30,18 +24,11 @@ mark{background:#ffec99}
|
|||||||
.loading{color:#1976d2}
|
.loading{color:#1976d2}
|
||||||
.status{margin-top:8px;color:#555}
|
.status{margin-top:8px;color:#555}
|
||||||
.import{margin-top:12px;display:flex;gap:8px;align-items:center}
|
.import{margin-top:12px;display:flex;gap:8px;align-items:center}
|
||||||
.scanner-message{margin-bottom:8px;color:#555}
|
|
||||||
.scanner-message.hidden{display:none}
|
|
||||||
.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}
|
|
||||||
.scanner-area{position:relative}
|
|
||||||
.scanner-controls{display:flex;gap:10px;align-items:center;justify-content:flex-end;margin:8px 0}
|
|
||||||
.scanner-overlay{position:absolute;inset:0;border:2px dashed rgba(25,118,210,0.6);border-radius:8px;pointer-events:none}
|
|
||||||
@media (max-width:600px){
|
@media (max-width:600px){
|
||||||
.container{padding:12px}
|
.container{padding:12px}
|
||||||
.tab{padding:8px 12px}
|
.tab{padding:8px 12px}
|
||||||
.prices{flex-direction:column}
|
.prices{flex-direction:column}
|
||||||
.import{flex-direction:column;align-items:stretch}
|
.import{flex-direction:column;align-items:stretch}
|
||||||
.search{display:grid;grid-template-columns:4fr 1fr;gap:8px}
|
.search{display:grid;grid-template-columns:4fr 1fr;gap:8px}
|
||||||
|
input, button{font-size:16px}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user