init project
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user