530 lines
13 KiB
Vue
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>
|