Files
evus.acvisa.com/public/ocr.html

756 lines
24 KiB
HTML
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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>H5 条形码扫描</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Arial, "Microsoft YaHei", sans-serif;
background: #f4f6fb;
color: #1f2937;
}
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
width: 100%;
max-width: 430px;
background: #fff;
border-radius: 18px;
padding: 26px 22px;
text-align: center;
box-shadow: 0 12px 38px rgba(15, 23, 42, 0.1);
}
.title {
margin: 0;
font-size: 24px;
font-weight: 700;
}
.sub-title {
margin: 10px 0 22px;
color: #6b7280;
font-size: 14px;
line-height: 1.7;
}
.camera-btn {
width: 82px;
height: 82px;
border: 0;
border-radius: 50%;
background: #1677ff;
color: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 12px 28px rgba(22, 119, 255, 0.35);
transition: transform 0.15s ease, opacity 0.15s ease;
}
.camera-btn:active {
transform: scale(0.96);
}
.camera-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.camera-btn svg {
width: 36px;
height: 36px;
fill: currentColor;
}
.result-box {
margin-top: 24px;
text-align: left;
}
.result-box label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #374151;
}
.result-row {
display: flex;
gap: 8px;
}
.result-input {
flex: 1;
width: 100%;
min-width: 0;
border: 1px solid #d1d5db;
border-radius: 10px;
padding: 12px;
font-size: 15px;
outline: none;
}
.result-input:focus {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
}
.small-btn {
border: 0;
border-radius: 10px;
padding: 0 14px;
background: #111827;
color: #fff;
cursor: pointer;
white-space: nowrap;
}
.tips {
margin-top: 16px;
padding: 12px;
border-radius: 12px;
background: #f9fafb;
color: #6b7280;
font-size: 13px;
line-height: 1.7;
text-align: left;
}
.scanner-layer {
display: none;
position: fixed;
inset: 0;
z-index: 9999;
padding: env(safe-area-inset-top) 14px env(safe-area-inset-bottom);
background: rgba(0, 0, 0, 0.86);
align-items: center;
justify-content: center;
}
.scanner-layer.show {
display: flex;
}
.scanner-panel {
width: 100%;
max-width: 560px;
max-height: 96vh;
overflow: hidden;
border-radius: 18px;
background: #111827;
color: #fff;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.scanner-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
background: #0b1220;
}
.scanner-header strong {
font-size: 16px;
}
.close-btn {
border: 0;
border-radius: 9px;
padding: 8px 12px;
background: #374151;
color: #fff;
cursor: pointer;
}
.video-wrap {
position: relative;
width: 100%;
background: #000;
overflow: hidden;
}
video {
display: block;
width: 100%;
height: min(72vh, 520px);
object-fit: cover;
background: #000;
}
.scan-frame {
position: absolute;
left: 50%;
top: 50%;
width: 88%;
height: 150px;
transform: translate(-50%, -50%);
border: 2px solid #22c55e;
border-radius: 14px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.28);
pointer-events: none;
}
.scan-frame::before,
.scan-frame::after {
content: "";
position: absolute;
top: -2px;
width: 26px;
height: 26px;
border-top: 4px solid #22c55e;
}
.scan-frame::before {
left: -2px;
border-left: 4px solid #22c55e;
border-top-left-radius: 14px;
}
.scan-frame::after {
right: -2px;
border-right: 4px solid #22c55e;
border-top-right-radius: 14px;
}
.scan-line {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
background: #ef4444;
transform: translateY(-50%);
box-shadow: 0 0 12px rgba(239, 68, 68, 0.9);
pointer-events: none;
}
.distance-tip {
position: absolute;
left: 50%;
bottom: 18px;
width: calc(100% - 28px);
max-width: 470px;
transform: translateX(-50%);
padding: 10px 12px;
border-radius: 12px;
background: rgba(17, 24, 39, 0.72);
color: #f9fafb;
font-size: 13px;
line-height: 1.6;
text-align: center;
pointer-events: none;
}
.scanner-tools {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: #111827;
}
.tool-btn {
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 999px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
cursor: pointer;
font-size: 13px;
}
.zoom-box {
display: flex;
align-items: center;
gap: 8px;
min-width: 220px;
padding: 7px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 13px;
color: #e5e7eb;
}
.zoom-box input {
flex: 1;
}
.status {
min-height: 44px;
padding: 12px 16px 16px;
color: #d1d5db;
font-size: 14px;
line-height: 1.6;
text-align: center;
background: #111827;
}
.status.ok {
color: #86efac;
}
.status.error {
color: #fca5a5;
}
[hidden] {
display: none !important;
}
@media (max-width: 430px) {
.page {
padding: 16px;
}
.card {
padding: 22px 18px;
}
.scan-frame {
width: 90%;
height: 132px;
}
video {
height: 68vh;
}
}
</style>
</head>
<body>
<main class="page">
<section class="card">
<h1 class="title">条形码扫描</h1>
<p class="sub-title">点击相机图标,允许摄像头权限后,将一维条形码横向放入扫描框。</p>
<button id="openScannerBtn" class="camera-btn" type="button" aria-label="打开相机扫描条形码">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 3.5h6l1.45 2H20a2 2 0 0 1 2 2v10.8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7.5a2 2 0 0 1 2-2h3.55L9 3.5Zm3 5a4.7 4.7 0 1 0 0 9.4 4.7 4.7 0 0 0 0-9.4Zm0 2a2.7 2.7 0 1 1 0 5.4 2.7 2.7 0 0 1 0-5.4Z" />
</svg>
</button>
<div class="result-box">
<label for="barcodeInput">识别结果</label>
<div class="result-row">
<input id="barcodeInput" class="result-input" type="text" placeholder="扫码结果会显示在这里" autocomplete="off" />
<button id="copyBtn" class="small-btn" type="button">复制</button>
</div>
</div>
<div class="tips">
<strong>提示:</strong>条形码不要贴摄像头太近。建议距离 20-40cm,条形码完整进入框内,左右两边要留一点空白。
</div>
</section>
</main>
<section id="scannerLayer" class="scanner-layer" aria-hidden="true">
<div class="scanner-panel" role="dialog" aria-modal="true" aria-label="条形码扫描窗口">
<div class="scanner-header">
<strong>扫描条形码</strong>
<button id="closeScannerBtn" class="close-btn" type="button">关闭</button>
</div>
<div class="video-wrap">
<video
id="previewVideo"
muted
autoplay
playsinline
webkit-playsinline
></video>
<div class="scan-frame"></div>
<div class="scan-line"></div>
<div class="distance-tip">不要贴太近;请拿远一点,让条形码清晰并完整横向进入框内</div>
</div>
<div class="scanner-tools">
<button id="torchBtn" class="tool-btn" type="button" hidden>打开闪光灯</button>
<label id="zoomBox" class="zoom-box" hidden>
<span>变焦</span>
<input id="zoomRange" type="range" min="1" max="1" step="0.1" value="1" />
<span id="zoomValue">1.0x</span>
</label>
</div>
<div id="status" class="status">准备打开摄像头...</div>
</div>
</section>
<!-- 条形码识别库:需要能访问外网 CDN;内网项目可把这个 JS 下载到本地再引用 -->
<script src="https://unpkg.com/@zxing/browser@0.2.0"></script>
<script>
(function () {
const openScannerBtn = document.getElementById("openScannerBtn");
const closeScannerBtn = document.getElementById("closeScannerBtn");
const scannerLayer = document.getElementById("scannerLayer");
const previewVideo = document.getElementById("previewVideo");
const statusEl = document.getElementById("status");
const barcodeInput = document.getElementById("barcodeInput");
const copyBtn = document.getElementById("copyBtn");
const torchBtn = document.getElementById("torchBtn");
const zoomBox = document.getElementById("zoomBox");
const zoomRange = document.getElementById("zoomRange");
const zoomValue = document.getElementById("zoomValue");
let codeReader = null;
let controls = null;
let stream = null;
let videoTrack = null;
let scanning = false;
let torchOn = false;
function setStatus(text, type) {
statusEl.textContent = text;
statusEl.className = "status" + (type ? " " + type : "");
}
function showScanner() {
scannerLayer.classList.add("show");
scannerLayer.setAttribute("aria-hidden", "false");
}
function hideScanner() {
scannerLayer.classList.remove("show");
scannerLayer.setAttribute("aria-hidden", "true");
}
function getReadableError(error) {
if (!error) return "未知错误";
const name = error.name || "";
if (name === "NotAllowedError" || name === "PermissionDeniedError") {
return "用户拒绝了摄像头权限,请在浏览器设置中允许摄像头。";
}
if (name === "NotFoundError" || name === "DevicesNotFoundError") {
return "没有找到可用摄像头。";
}
if (name === "NotReadableError" || name === "TrackStartError") {
return "摄像头被其他应用占用,关闭其他应用后再试。";
}
if (name === "OverconstrainedError" || name === "ConstraintNotSatisfiedError") {
return "当前摄像头不支持请求的分辨率或参数。";
}
if (name === "SecurityError") {
return "当前页面没有摄像头权限,请使用 HTTPS 页面。";
}
return error.message || String(error);
}
function createBarcodeReader() {
if (!window.ZXingBrowser) {
throw new Error("ZXingBrowser 加载失败,请检查网络或 CDN 地址。也可以把 JS 下载到本地引用。");
}
// 一维条形码 Reader:EAN-13、EAN-8、UPC、Code128、Code39、ITF 等。
if (ZXingBrowser.BrowserMultiFormatOneDReader) {
return new ZXingBrowser.BrowserMultiFormatOneDReader();
}
// 兜底:如果当前版本没有 OneDReader,就用 MultiFormatReader。
if (ZXingBrowser.BrowserMultiFormatReader) {
return new ZXingBrowser.BrowserMultiFormatReader();
}
throw new Error("当前 ZXing 版本不支持浏览器条形码扫描 Reader。");
}
async function openCameraStream() {
const highQualityConstraints = {
audio: false,
video: {
facingMode: { ideal: "environment" },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30, max: 30 }
}
};
try {
return await navigator.mediaDevices.getUserMedia(highQualityConstraints);
} catch (error) {
// 如果是用户拒绝权限,不要再弹第二次。
if (
error &&
(error.name === "NotAllowedError" || error.name === "PermissionDeniedError" || error.name === "SecurityError")
) {
throw error;
}
// 某些 iPhone / 浏览器对高分辨率约束比较敏感,失败后降级为默认摄像头。
return await navigator.mediaDevices.getUserMedia({
audio: false,
video: true
});
}
}
async function safeApplyConstraints(track, constraints) {
if (!track || !track.applyConstraints) return false;
try {
await track.applyConstraints(constraints);
return true;
} catch (error) {
console.warn("摄像头参数不支持:", constraints, error);
return false;
}
}
async function enhanceCamera(track) {
torchOn = false;
torchBtn.hidden = true;
zoomBox.hidden = true;
if (!track || !track.getCapabilities) {
return;
}
const capabilities = track.getCapabilities();
console.log("摄像头能力:", capabilities);
// 自动/连续对焦:Android Chrome 支持较多,iPhone Safari 不一定支持。
if (Array.isArray(capabilities.focusMode)) {
if (capabilities.focusMode.includes("continuous")) {
await safeApplyConstraints(track, { advanced: [{ focusMode: "continuous" }] });
} else if (capabilities.focusMode.includes("single-shot")) {
await safeApplyConstraints(track, { advanced: [{ focusMode: "single-shot" }] });
}
}
// 变焦:用户拿远一点,用 1.5x-2x 变焦,比贴近摄像头更容易清晰。
if (capabilities.zoom) {
const minZoom = Number(capabilities.zoom.min || 1);
const maxZoom = Number(capabilities.zoom.max || minZoom);
const stepZoom = Number(capabilities.zoom.step || 0.1);
const targetZoom = Math.min(maxZoom, Math.max(minZoom, 1.6));
zoomRange.min = String(minZoom);
zoomRange.max = String(maxZoom);
zoomRange.step = String(stepZoom);
zoomRange.value = String(targetZoom);
zoomValue.textContent = targetZoom.toFixed(1) + "x";
zoomBox.hidden = false;
if (targetZoom > minZoom) {
await safeApplyConstraints(track, { advanced: [{ zoom: targetZoom }] });
}
}
// 闪光灯:很多 Android 支持,iPhone H5 不一定支持。
if (capabilities.torch) {
torchBtn.hidden = false;
torchBtn.textContent = "打开闪光灯";
}
}
async function startScan() {
if (scanning) return;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert("当前浏览器不支持摄像头调用,请使用 Chrome、Safari、Edge 等现代浏览器。");
return;
}
if (!window.isSecureContext) {
alert("摄像头需要 HTTPS 或 localhost 环境。请把页面部署到 HTTPS 后再用手机访问。");
return;
}
showScanner();
openScannerBtn.disabled = true;
scanning = true;
setStatus("正在打开摄像头,请允许浏览器使用摄像头...", "");
try {
codeReader = createBarcodeReader();
stream = await openCameraStream();
videoTrack = stream.getVideoTracks()[0] || null;
previewVideo.muted = true;
previewVideo.autoplay = true;
previewVideo.playsInline = true;
previewVideo.setAttribute("playsinline", "true");
previewVideo.setAttribute("webkit-playsinline", "true");
previewVideo.srcObject = stream;
try {
await previewVideo.play();
} catch (playError) {
console.warn("video.play() 失败,继续尝试解码:", playError);
}
await enhanceCamera(videoTrack);
setStatus("扫描中:请距离条形码 20-40cm,横向放入框内,左右留出空白。", "ok");
controls = await codeReader.decodeFromStream(stream, previewVideo, function (result, error) {
if (!scanning) return;
if (result) {
const text = typeof result.getText === "function" ? result.getText() : String(result);
onBarcodeFound(text);
return;
}
// 未识别到时 ZXing 会频繁抛 NotFoundException,这是正常现象,不要展示给用户。
if (error && error.name && !["NotFoundException", "ChecksumException", "FormatException"].includes(error.name)) {
console.debug("ZXing decode error:", error);
}
});
} catch (error) {
console.error(error);
releaseCamera();
scanning = false;
openScannerBtn.disabled = false;
setStatus("无法打开摄像头:" + getReadableError(error), "error");
}
}
function onBarcodeFound(text) {
barcodeInput.value = text;
setStatus("识别成功:" + text, "ok");
if (navigator.vibrate) {
navigator.vibrate(120);
}
// 这里写你的业务逻辑,例如提交到后端:
// fetch("/api/barcode", {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ barcode: text })
// });
stopScan();
}
function releaseCamera() {
if (controls) {
try {
controls.stop();
} catch (error) {
console.warn(error);
}
controls = null;
}
if (stream) {
stream.getTracks().forEach(function (track) {
track.stop();
});
stream = null;
}
videoTrack = null;
previewVideo.pause();
previewVideo.srcObject = null;
torchOn = false;
torchBtn.hidden = true;
zoomBox.hidden = true;
}
function stopScan() {
scanning = false;
openScannerBtn.disabled = false;
releaseCamera();
hideScanner();
}
async function toggleTorch() {
if (!videoTrack) return;
torchOn = !torchOn;
const ok = await safeApplyConstraints(videoTrack, { advanced: [{ torch: torchOn }] });
if (!ok) {
torchOn = !torchOn;
setStatus("当前浏览器不支持控制闪光灯。", "error");
return;
}
torchBtn.textContent = torchOn ? "关闭闪光灯" : "打开闪光灯";
}
async function changeZoom() {
if (!videoTrack) return;
const value = Number(zoomRange.value);
zoomValue.textContent = value.toFixed(1) + "x";
await safeApplyConstraints(videoTrack, { advanced: [{ zoom: value }] });
}
async function copyResult() {
const text = barcodeInput.value.trim();
if (!text) {
alert("还没有识别结果");
return;
}
try {
await navigator.clipboard.writeText(text);
alert("已复制:" + text);
} catch (error) {
barcodeInput.select();
document.execCommand("copy");
alert("已复制:" + text);
}
}
openScannerBtn.addEventListener("click", startScan);
closeScannerBtn.addEventListener("click", stopScan);
torchBtn.addEventListener("click", toggleTorch);
zoomRange.addEventListener("input", changeZoom);
copyBtn.addEventListener("click", copyResult);
// 点击黑色背景关闭扫描层。
scannerLayer.addEventListener("click", function (event) {
if (event.target === scannerLayer) {
stopScan();
}
});
// 页面切走或关闭时释放摄像头。
window.addEventListener("pagehide", stopScan);
document.addEventListener("visibilitychange", function () {
if (document.hidden && scanning) {
stopScan();
}
});
})();
</script>
</body>
</html>