初始化 EVUS 项目并统一首页及业务页面样式

This commit is contained in:
gaofeng
2026-10-30 13:31:25 +08:00
commit 74f9f2b228
1840 changed files with 303475 additions and 0 deletions

756
public/ocr.html Normal file
View File

@@ -0,0 +1,756 @@
<!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>