云
4 小时以前 fb4db4ae9b6a7f113ba049f6789e6916c0dcc276
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<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>