Files
gao-customer-h5/src/components/CodeScanner.vue
T
2026-08-01 18:01:23 +08:00

530 lines
13 KiB
Vue

<template>
<div class="code-scanner">
<div class="stage">
<video ref="scannerVideoRef" class="video" autoplay muted playsinline />
<canvas ref="scannerCanvasRef" class="scanner" />
<div class="scan-frame">
<span class="scan-line" />
</div>
</div>
<p class="tips" v-text="displayStatus" />
</div>
</template>
<script setup lang="ts">
import {BrowserMultiFormatReader} from "@zxing/browser";
import {BarcodeFormat, DecodeHintType, NotFoundException, type Result} from "@zxing/library";
interface Point {
x: number;
y: number;
}
interface Props {
activeText?: string;
initFailedText?: string;
interruptedText?: string;
permissionDeniedText?: string;
sessionKey?: number | string;
statusText?: string;
unsupportedText?: string;
visible: boolean;
}
const props = withDefaults(defineProps<Props>(), {
activeText: "正在识别,请将二维码移入取景框",
initFailedText: "摄像头预览初始化失败",
interruptedText: "识别中断,请检查摄像头权限或画面清晰度",
permissionDeniedText: "无法启动摄像头,请确认已授予浏览器相机权限",
sessionKey: 0,
statusText: "",
unsupportedText: "当前浏览器或 WebView 未开放摄像头能力"
});
const emit = defineEmits<{
decode: [value: string];
"status-change": [value: string];
}>();
const scanHints = new Map<DecodeHintType, BarcodeFormat[]>();
scanHints.set(DecodeHintType.POSSIBLE_FORMATS, [
BarcodeFormat.QR_CODE,
BarcodeFormat.CODE_128,
BarcodeFormat.CODE_39,
BarcodeFormat.EAN_13,
BarcodeFormat.EAN_8
]);
const scannerReader = new BrowserMultiFormatReader(scanHints);
const scannerVideoRef = ref<HTMLVideoElement | null>(null);
const scannerCanvasRef = ref<HTMLCanvasElement | null>(null);
const scannerControls = shallowRef<{ stop: () => void } | null>(null);
const localStatus = ref("");
const overlayPoints = ref<Point[]>([]);
let latestDecodedText = "";
let clearOverlayTimer: number | undefined;
let resizeObserver: ResizeObserver | null = null;
const displayStatus = computed(() => props.statusText || localStatus.value);
function updateStatus(value: string): void {
localStatus.value = value;
emit("status-change", value);
}
function clearOverlay(delay = 0): void {
if (clearOverlayTimer) {
window.clearTimeout(clearOverlayTimer);
clearOverlayTimer = undefined;
}
if (delay <= 0) {
overlayPoints.value = [];
drawOverlay();
return;
}
clearOverlayTimer = window.setTimeout(() => {
overlayPoints.value = [];
drawOverlay();
clearOverlayTimer = undefined;
}, delay);
}
function syncCanvasSize(): void {
if (!scannerVideoRef.value || !scannerCanvasRef.value) {
return;
}
const width = scannerVideoRef.value.clientWidth;
const height = scannerVideoRef.value.clientHeight;
if (!width || !height) {
return;
}
if (scannerCanvasRef.value.width !== width || scannerCanvasRef.value.height !== height) {
scannerCanvasRef.value.width = width;
scannerCanvasRef.value.height = height;
}
drawOverlay();
}
function getDisplayPoint(point: Point): Point {
if (!scannerVideoRef.value) {
return point;
}
const sourceWidth = scannerVideoRef.value.videoWidth || scannerVideoRef.value.clientWidth;
const sourceHeight = scannerVideoRef.value.videoHeight || scannerVideoRef.value.clientHeight;
const targetWidth = scannerVideoRef.value.clientWidth;
const targetHeight = scannerVideoRef.value.clientHeight;
const scale = Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
const offsetX = (targetWidth - sourceWidth * scale) / 2;
const offsetY = (targetHeight - sourceHeight * scale) / 2;
return {
x: point.x * scale + offsetX,
y: point.y * scale + offsetY
};
}
function sortPointsClockwise(points: Point[]): Point[] {
const center = points.reduce((result, item) => ({
x: result.x + item.x / points.length,
y: result.y + item.y / points.length
}), { x: 0, y: 0 });
return [...points].sort((left, right) => {
const leftAngle = Math.atan2(left.y - center.y, left.x - center.x);
const rightAngle = Math.atan2(right.y - center.y, right.x - center.x);
return leftAngle - rightAngle;
});
}
function buildQuadrilateralFromThreePoints(points: Point[]): Point[] {
let cornerIndex = 0;
let minDotValue = Number.POSITIVE_INFINITY;
points.forEach((point, index) => {
const prevPoint = points[(index + 1) % points.length];
const nextPoint = points[(index + 2) % points.length];
const prevVectorX = prevPoint.x - point.x;
const prevVectorY = prevPoint.y - point.y;
const nextVectorX = nextPoint.x - point.x;
const nextVectorY = nextPoint.y - point.y;
const prevDistance = Math.hypot(prevVectorX, prevVectorY);
const nextDistance = Math.hypot(nextVectorX, nextVectorY);
if (!prevDistance || !nextDistance) {
return;
}
const dotValue = Math.abs((prevVectorX * nextVectorX + prevVectorY * nextVectorY) / (prevDistance * nextDistance));
if (dotValue < minDotValue) {
minDotValue = dotValue;
cornerIndex = index;
}
});
const cornerPoint = points[cornerIndex];
const firstEdgePoint = points[(cornerIndex + 1) % points.length];
const secondEdgePoint = points[(cornerIndex + 2) % points.length];
const fourthPoint = {
x: firstEdgePoint.x + secondEdgePoint.x - cornerPoint.x,
y: firstEdgePoint.y + secondEdgePoint.y - cornerPoint.y
};
return sortPointsClockwise([
cornerPoint,
firstEdgePoint,
fourthPoint,
secondEdgePoint
]);
}
function buildOutlinePoints(points: Point[]): Point[] {
if (4 <= points.length) {
return sortPointsClockwise(points).slice(0, 4);
}
if (3 === points.length) {
return buildQuadrilateralFromThreePoints(points);
}
if (!points.length) {
return [];
}
const xValues = points.map(item => item.x);
const yValues = points.map(item => item.y);
const minX = Math.min(...xValues);
const maxX = Math.max(...xValues);
const minY = Math.min(...yValues);
const maxY = Math.max(...yValues);
return [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY }
];
}
function expandOutlinePoints(points: Point[]): Point[] {
if (!points.length) {
return [];
}
const center = points.reduce((result, item) => ({
x: result.x + item.x / points.length,
y: result.y + item.y / points.length
}), { x: 0, y: 0 });
const width = Math.max(...points.map(item => item.x)) - Math.min(...points.map(item => item.x));
const height = Math.max(...points.map(item => item.y)) - Math.min(...points.map(item => item.y));
const padding = Math.max(Math.min(width, height) * .18, 18);
return points.map((item) => {
const offsetX = item.x - center.x;
const offsetY = item.y - center.y;
const distance = Math.hypot(offsetX, offsetY);
if (!distance) {
return item;
}
const scale = (distance + padding) / distance;
return {
x: center.x + offsetX * scale,
y: center.y + offsetY * scale
};
});
}
function drawOverlay(): void {
if (!scannerCanvasRef.value) {
return;
}
const context = scannerCanvasRef.value.getContext("2d");
if (!context) {
return;
}
context.clearRect(0, 0, scannerCanvasRef.value.width, scannerCanvasRef.value.height);
if (!overlayPoints.value.length) {
return;
}
const displayPoints = expandOutlinePoints(buildOutlinePoints(overlayPoints.value)).map(item => getDisplayPoint(item));
if (displayPoints.length < 4) {
return;
}
const edgeLengths = displayPoints.map((item, index) => {
const nextPoint = displayPoints[(index + 1) % displayPoints.length];
return Math.hypot(nextPoint.x - item.x, nextPoint.y - item.y);
});
const width = Math.max(edgeLengths[0] || 0, edgeLengths[2] || 0);
const height = Math.max(edgeLengths[1] || 0, edgeLengths[3] || 0);
const cornerLength = Math.max(14, Math.min(Math.min(width, height) * .2, 28));
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = 3;
context.strokeStyle = "#22c55e";
const drawCorner = (point: Point, prevPoint: Point, nextPoint: Point): void => {
const prevOffsetX = prevPoint.x - point.x;
const prevOffsetY = prevPoint.y - point.y;
const nextOffsetX = nextPoint.x - point.x;
const nextOffsetY = nextPoint.y - point.y;
const prevDistance = Math.hypot(prevOffsetX, prevOffsetY);
const nextDistance = Math.hypot(nextOffsetX, nextOffsetY);
if (!prevDistance || !nextDistance) {
return;
}
const prevScale = cornerLength / prevDistance;
const nextScale = cornerLength / nextDistance;
context.beginPath();
context.moveTo(point.x + prevOffsetX * prevScale, point.y + prevOffsetY * prevScale);
context.lineTo(point.x, point.y);
context.lineTo(point.x + nextOffsetX * nextScale, point.y + nextOffsetY * nextScale);
context.stroke();
};
displayPoints.forEach((point, index) => {
const prevPoint = displayPoints[(index - 1 + displayPoints.length) % displayPoints.length];
const nextPoint = displayPoints[(index + 1) % displayPoints.length];
drawCorner(point, prevPoint, nextPoint);
});
}
function updateOverlay(result: Result): void {
overlayPoints.value = result.getResultPoints()
.map(item => ({
x: item.getX(),
y: item.getY()
}))
.filter(item => Number.isFinite(item.x) && Number.isFinite(item.y));
drawOverlay();
}
async function startScanner(): Promise<void> {
if (!props.visible) {
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
updateStatus(window.isSecureContext ? props.unsupportedText : "当前页面不是 HTTPS 安全环境,无法调用摄像头");
return;
}
stopScanner();
latestDecodedText = "";
updateStatus(props.activeText);
await nextTick();
if (!scannerVideoRef.value) {
updateStatus(props.initFailedText);
return;
}
syncCanvasSize();
try {
scannerControls.value = await scannerReader.decodeFromConstraints({
audio: false,
video: {
facingMode: {
ideal: "environment"
}
}
}, scannerVideoRef.value, (result, error) => {
if (result) {
updateOverlay(result);
clearOverlay(900);
const decodedText = result.getText().trim();
if (!decodedText || decodedText === latestDecodedText) {
return;
}
latestDecodedText = decodedText;
emit("decode", decodedText);
return;
}
if (error instanceof NotFoundException) {
clearOverlay(120);
return;
}
if (error) {
updateStatus(props.interruptedText);
clearOverlay();
}
});
} catch {
updateStatus(props.permissionDeniedText);
}
}
function stopScanner(): void {
scannerControls.value?.stop();
scannerControls.value = null;
if (scannerVideoRef.value?.srcObject instanceof MediaStream) {
for (const track of scannerVideoRef.value.srcObject.getTracks()) {
track.stop();
}
scannerVideoRef.value.srcObject = null;
}
clearOverlay();
}
watch(() => [props.visible, props.sessionKey] as const, async ([visible]) => {
if (!visible) {
stopScanner();
return;
}
await startScanner();
}, { immediate: true });
onMounted(() => {
if (typeof ResizeObserver === "undefined") {
return;
}
resizeObserver = new ResizeObserver(() => {
syncCanvasSize();
});
if (scannerVideoRef.value) {
resizeObserver.observe(scannerVideoRef.value);
scannerVideoRef.value.addEventListener("loadedmetadata", syncCanvasSize);
}
});
onBeforeUnmount(() => {
if (scannerVideoRef.value) {
scannerVideoRef.value.removeEventListener("loadedmetadata", syncCanvasSize);
}
resizeObserver?.disconnect();
resizeObserver = null;
if (clearOverlayTimer) {
window.clearTimeout(clearOverlayTimer);
}
stopScanner();
});
</script>
<style scoped lang="less">
.code-scanner {
width: 100%;
margin: 0 auto;
overflow: hidden;
max-width: 30rem;
.stage {
width: 100%;
overflow: hidden;
position: relative;
aspect-ratio: 4 / 3;
border-radius: 1rem;
background: #0f172a;
.video,
.scanner,
.scan-frame {
width: 100%;
height: 100%;
display: block;
border-radius: 1rem;
}
.video {
object-fit: cover;
}
.scanner {
top: 0;
left: 0;
position: absolute;
pointer-events: none;
}
.scan-frame {
top: 50%;
left: 50%;
width: 100%;
z-index: 1;
overflow: hidden;
position: absolute;
max-width: 18rem;
aspect-ratio: 1;
pointer-events: none;
transform: translate(-50%, -50%);
.scan-line {
top: 0;
left: 0;
width: 100%;
opacity: 0;
height: .1875rem;
position: absolute;
background: linear-gradient(90deg, rgba(34, 197, 94, 0), rgba(74, 222, 128, .95), rgba(34, 197, 94, 0));
animation: scan-line-down 3s cubic-bezier(0, .5, 1, .5) infinite;
}
}
}
.tips {
color: #52607a;
margin: .625rem 0 0;
font-size: .8125rem;
line-height: 1.5;
text-align: center;
}
}
@keyframes scan-line-down {
0% {
top: 8%;
opacity: 0;
}
15% {
opacity: 1;
}
85% {
opacity: 1;
}
100% {
top: 92%;
opacity: 0;
}
}
:global(html.theme-dark) {
.code-scanner {
background: rgba(30, 41, 59, .9);
border-color: rgba(148, 163, 184, .16);
.tips {
color: #94a3b8;
}
.scan-frame {
.corner {
border-color: #4ade80;
filter: drop-shadow(0 0 .5rem rgba(74, 222, 128, .45));
}
.scan-line {
background: linear-gradient(90deg, rgba(74, 222, 128, 0), rgba(134, 239, 172, .95), rgba(74, 222, 128, 0));
box-shadow: 0 0 .875rem rgba(74, 222, 128, .8);
}
}
}
}
</style>