<script setup lang="ts">
|
import { onBeforeUnmount, onDeactivated, onMounted, ref } from 'vue';
|
import Icon from '#/mobile/icon.vue';
|
const props=defineProps<{disabled?:boolean}>();
|
const emit=defineEmits<{code:[value:string]}>();
|
const input=ref(''),error=ref(''),active=ref(false),starting=ref(false),video=ref<HTMLVideoElement>();
|
let stream:MediaStream|undefined,frame=0,generation=0,last=0;
|
function stop(){generation++;active.value=false;starting.value=false;cancelAnimationFrame(frame);stream?.getTracks().forEach(t=>t.stop());stream=undefined;if(video.value)video.value.srcObject=null;}
|
function accept(value:string){if(props.disabled)return;stop();input.value=value;emit('code',value);}
|
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({video:{facingMode:{ideal:'environment'}},audio:false});
|
if(ticket!==generation){media.getTracks().forEach(t=>t.stop());return;}
|
stream=media;active.value=true;starting.value=false;
|
await new Promise<void>(r=>requestAnimationFrame(()=>r()));
|
if(!video.value||ticket!==generation){stop();return;}
|
video.value.srcObject=media;await video.value.play();
|
const canvas=document.createElement('canvas'),ctx=canvas.getContext('2d',{willReadFrequently:true});
|
function tick(time:number){
|
if(ticket!==generation||!active.value)return;
|
const v=video.value;
|
if(v&&ctx&&v.readyState>=2&&time-last>180&&!props.disabled){
|
last=time;canvas.width=Math.min(v.videoWidth,640);canvas.height=Math.round(v.videoHeight*canvas.width/v.videoWidth);
|
ctx.drawImage(v,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);});
|
onDeactivated(stop);
|
</script>
|
<template>
|
<div class="m-panel">
|
<button v-if="!active" type="button" class="m-secondary" :disabled="disabled||starting" @click="start"><Icon name="scan"/>{{starting?'正在请求相机权限…':'打开摄像头扫码'}}</button>
|
<template v-else><video ref="video" class="m-camera" muted playsinline aria-label="二维码取景"/><button type="button" class="m-secondary" @click="stop">关闭摄像头</button></template>
|
<p v-if="error" class="m-error" role="alert">{{error}}</p>
|
<form @submit.prevent="accept(input)">
|
<label class="m-field" style="margin-top:16px"><span>码值 / 溯源链接</span><input v-model="input" autocomplete="off" :disabled="disabled" placeholder="手动输入或使用扫码设备" maxlength="2048" /></label>
|
<button class="m-secondary" :disabled="disabled||!input.trim()" type="submit">{{disabled?'正在处理…':'识别码值'}}</button>
|
</form>
|
</div>
|
</template>
|