<script setup lang="ts">
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { Button, Input } from 'ant-design-vue';
|
|
import { extractCode } from '#/utils/traceCode';
|
|
/**
|
* 追溯二维码扫码输入。
|
*
|
* 支持摄像头扫码、扫码枪键盘输入和手动输入三种方式;
|
* 识别结果统一经过 extractCode 提取「追溯网址?code=码值」中的码值后再抛出。
|
*/
|
const props = withDefaults(
|
defineProps<{
|
continuous?: boolean;
|
disabled?: boolean;
|
placeholder?: string;
|
}>(),
|
{
|
continuous: false,
|
disabled: false,
|
placeholder: '扫描追溯二维码或输入追溯码,回车识别',
|
},
|
);
|
|
const emit = defineEmits<{ code: [value: string] }>();
|
|
const input = ref('');
|
const error = ref('');
|
const active = ref(false);
|
const starting = ref(false);
|
const video = ref<HTMLVideoElement>();
|
const inputRef = ref<{ focus: () => void }>();
|
let stream: MediaStream | undefined;
|
let frame = 0;
|
let generation = 0;
|
let last = 0;
|
|
function stop() {
|
generation++;
|
active.value = false;
|
starting.value = false;
|
cancelAnimationFrame(frame);
|
stream?.getTracks().forEach((track) => track.stop());
|
stream = undefined;
|
if (video.value) video.value.srcObject = null;
|
}
|
|
function accept(raw: string) {
|
if (props.disabled) return;
|
try {
|
const code = extractCode(raw);
|
error.value = '';
|
stop();
|
input.value = props.continuous ? '' : code;
|
emit('code', code);
|
if (props.continuous) {
|
void nextTick(() => inputRef.value?.focus());
|
}
|
} catch (e) {
|
error.value = e instanceof Error ? e.message : '追溯码格式不正确';
|
}
|
}
|
|
watch(
|
() => props.continuous,
|
async (continuous) => {
|
if (!props.disabled && continuous) {
|
await nextTick();
|
inputRef.value?.focus();
|
}
|
},
|
);
|
|
async function start() {
|
error.value = '';
|
stop();
|
const ticket = generation;
|
starting.value = true;
|
try {
|
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
|
throw new Error('摄像头需要 HTTPS 和相机授权,请使用手动输入');
|
}
|
const decode = (await import('jsqr')).default;
|
const media = await navigator.mediaDevices.getUserMedia({
|
audio: false,
|
video: { facingMode: { ideal: 'environment' } },
|
});
|
if (ticket !== generation) {
|
media.getTracks().forEach((track) => track.stop());
|
return;
|
}
|
stream = media;
|
active.value = true;
|
starting.value = false;
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
if (!video.value || ticket !== generation) {
|
stop();
|
return;
|
}
|
video.value.srcObject = media;
|
await video.value.play();
|
const canvas = document.createElement('canvas');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
function tick(time: number) {
|
if (ticket !== generation || !active.value) return;
|
const element = video.value;
|
if (
|
element &&
|
ctx &&
|
element.readyState >= 2 &&
|
time - last > 180 &&
|
!props.disabled
|
) {
|
last = time;
|
canvas.width = Math.min(element.videoWidth, 640);
|
canvas.height = Math.round(
|
(element.videoHeight * canvas.width) / element.videoWidth,
|
);
|
ctx.drawImage(element, 0, 0, canvas.width, canvas.height);
|
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const result = decode(pixels.data, canvas.width, canvas.height);
|
if (result?.data) {
|
accept(result.data);
|
return;
|
}
|
}
|
frame = requestAnimationFrame(tick);
|
}
|
frame = requestAnimationFrame(tick);
|
} catch (e) {
|
if (ticket !== generation) return;
|
stop();
|
const name = e instanceof DOMException ? e.name : '';
|
error.value =
|
name === 'NotAllowedError'
|
? '相机权限未开启,请在浏览器设置中允许相机,或手动输入码值'
|
: name === 'NotFoundError'
|
? '未找到可用摄像头,请手动输入或使用扫码设备'
|
: name === 'NotReadableError'
|
? '摄像头正被其他程序使用,请关闭后重试或手动输入'
|
: e instanceof Error
|
? e.message
|
: '相机不可用,请检查权限或手动输入';
|
}
|
}
|
|
function visibility() {
|
if (document.hidden) stop();
|
}
|
|
onMounted(() => document.addEventListener('visibilitychange', visibility));
|
onBeforeUnmount(() => {
|
stop();
|
document.removeEventListener('visibilitychange', visibility);
|
});
|
</script>
|
|
<template>
|
<div class="trace-scan-input">
|
<div class="flex items-center gap-2">
|
<Input
|
ref="inputRef"
|
v-model:value="input"
|
autocomplete="off"
|
:disabled="disabled"
|
:placeholder="placeholder"
|
@press-enter="accept(input)"
|
/>
|
<Button :disabled="disabled || starting" @click="active ? stop() : start()">
|
{{ active ? '关闭摄像头' : starting ? '正在请求相机…' : '打开摄像头' }}
|
</Button>
|
<Button
|
type="primary"
|
:disabled="disabled || !input.trim()"
|
@click="accept(input)"
|
>
|
识别
|
</Button>
|
</div>
|
<video
|
v-if="active"
|
ref="video"
|
class="mt-2 w-64 rounded bg-black"
|
muted
|
playsinline
|
/>
|
<p v-if="error" class="mt-1 text-red-600" role="alert">{{ error }}</p>
|
</div>
|
</template>
|