v1.2.117:查寝二维码支持摄像头实时扫码
ci/woodpecker/tag/build Pipeline was successful

- 新增 QrScanner 组件:getUserMedia 调后置摄像头 + jsQR 每 120ms 识别一帧,命中即写入二维码ID
- 识别用整帧下采样(480→720 两档),取景框仅作视觉引导:object-cover 会放大裁切画面,
  只扫中心区时近距离对准反而扫不到(实测复现)
- 支持手电筒(设备支持时);非 HTTPS / 权限被拒 / 无设备分别给出可操作的提示
- 保留「选图」回退到原有图片解析;扫码页 z-[400] 且显式恢复 pointer-events
  (reka modal 会把 body 设为 pointer-events:none,否则扫码页按钮点不动)
This commit is contained in:
becomer
2026-10-05 16:07:38 +08:00
parent b928c7ceb0
commit 5bb39454b8
2 changed files with 342 additions and 37 deletions
+242
View File
@@ -0,0 +1,242 @@
<script setup lang="ts">
// 全屏摄像头扫码(查寝二维码等)。
// 流程:getUserMedia 打开后置摄像头 → 每 ~120ms 截取取景框区域 → jsQR 识别 → 命中即回调并关闭。
// 不支持摄像头(非 https、WebView 未授权、无设备)时显示原因,并提供「从相册选择」回退到原有图片解析。
import { onBeforeUnmount, onMounted, ref } from 'vue'
import jsQR from 'jsqr'
import { X, ImagePlus, Flashlight, CameraOff } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'result', text: string): void
(e: 'pick'): void
(e: 'close'): void
}>()
const videoEl = ref<HTMLVideoElement | null>(null)
const state = ref<'starting' | 'scanning' | 'error'>('starting')
const errMsg = ref('')
const torchOn = ref(false)
const torchSupported = ref(false)
let stream: MediaStream | null = null
let track: MediaStreamTrack | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let canvas: HTMLCanvasElement | null = null
let stopped = false
/** 环境自检:给出可操作的失败原因,而不是笼统的「打开失败」 */
function unsupportedReason(): string {
if (!window.isSecureContext && location.hostname !== 'localhost') {
return '当前页面不是 HTTPS,浏览器禁止调用摄像头。请用 https 地址打开,或改用「从相册选择」。'
}
if (!navigator.mediaDevices?.getUserMedia) {
return '当前浏览器不支持调用摄像头,请改用「从相册选择」。'
}
return ''
}
function failMsg(e: unknown): string {
const name = (e as { name?: string })?.name || ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
return '摄像头权限被拒绝。请在浏览器地址栏或系统设置中允许摄像头权限后重试。'
}
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
return '未检测到可用摄像头,请改用「从相册选择」。'
}
if (name === 'NotReadableError') {
return '摄像头被其他应用占用,请关闭后重试。'
}
return '摄像头打开失败,请改用「从相册选择」。'
}
async function start() {
const reason = unsupportedReason()
if (reason) {
state.value = 'error'
errMsg.value = reason
return
}
try {
// facingMode 用 ideal:部分设备没有后置摄像头,exact 会直接失败
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
})
if (stopped) {
release()
return
}
track = stream.getVideoTracks()[0] || null
torchSupported.value = !!(track?.getCapabilities?.() as { torch?: boolean } | undefined)?.torch
const v = videoEl.value
if (!v) return
v.srcObject = stream
await v.play().catch(() => {})
state.value = 'scanning'
tick()
} catch (e) {
state.value = 'error'
errMsg.value = failMsg(e)
}
}
/** 单帧识别:按整帧下采样识别。
* 不裁中心区:object-cover 下画面被放大裁切,二维码常常超出中心框而识别不到;
* 取景框只作视觉引导,实际整幅画面内的码都能扫到(与微信/支付宝的体感一致)。
* 两档缩放:先 480(快),失败再 720(远距离小码更清晰)。 */
function scanFrame(): string | null {
const v = videoEl.value
if (!v || v.readyState < 2 || !v.videoWidth) return null
if (!canvas) canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return null
for (const target of [480, 720]) {
const scale = Math.min(1, target / Math.max(v.videoWidth, v.videoHeight))
const w = Math.max(1, Math.round(v.videoWidth * scale))
const h = Math.max(1, Math.round(v.videoHeight * scale))
canvas.width = w
canvas.height = h
ctx.drawImage(v, 0, 0, w, h)
const data = ctx.getImageData(0, 0, w, h)
const code = jsQR(data.data, data.width, data.height, { inversionAttempts: 'attemptBoth' })
if (code?.data) return code.data
// 原始分辨率已小于首档时无需再试更大尺寸
if (scale >= 1) break
}
return null
}
function tick() {
if (stopped) return
let text: string | null = null
try {
text = scanFrame()
} catch {
// 单帧失败(如设备暂时无画面)忽略,下一帧继续
}
if (text) {
emit('result', text)
return
}
timer = setTimeout(tick, 120)
}
async function toggleTorch() {
if (!track || !torchSupported.value) return
try {
await track.applyConstraints({ advanced: [{ torch: !torchOn.value }] } as unknown as MediaTrackConstraints)
torchOn.value = !torchOn.value
} catch {
torchSupported.value = false
}
}
function release() {
if (timer) clearTimeout(timer)
timer = null
if (track && torchOn.value) {
track.applyConstraints({ advanced: [{ torch: false }] } as unknown as MediaTrackConstraints).catch(() => {})
}
stream?.getTracks().forEach((t) => t.stop())
stream = null
track = null
if (videoEl.value) videoEl.value.srcObject = null
}
onMounted(start)
onBeforeUnmount(() => {
stopped = true
release()
})
</script>
<template>
<!-- 全屏置顶:扫码需要大取景区域,且要盖住底部 TabBar -->
<Teleport to="body">
<!-- z-[400] 高于 AppDialog(≤300);pointer-events:auto 必须显式恢复:
reka 的 modal 弹窗会把 body 设为 pointer-events:none,只给弹窗面板恢复,
teleport 到 body 的本组件否则点不动(实测「从相册选择」被弹窗层拦截)。 -->
<div
class="fixed inset-0 z-[400] bg-black flex flex-col"
style="pointer-events: auto"
role="dialog"
aria-modal="true"
aria-label="扫描二维码"
>
<!-- 顶栏 -->
<div class="relative z-10 flex items-center gap-2 px-3 text-white" style="padding-top: calc(var(--safe-top) + 10px)">
<button class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 active:scale-95 transition-transform" aria-label="关闭" @click="emit('close')">
<X class="w-5 h-5" />
</button>
<p class="flex-1 text-center text-[15px] font-semibold">扫描二维码</p>
<button
v-if="torchSupported"
class="w-10 h-10 flex items-center justify-center rounded-full active:scale-95 transition-transform"
:class="torchOn ? 'bg-amber-300 text-amber-900' : 'bg-white/15'"
aria-label="手电筒"
@click="toggleTorch"
>
<Flashlight class="w-5 h-5" />
</button>
<span v-else class="w-10" />
</div>
<!-- 取景区 -->
<div class="relative flex-1 min-h-0 overflow-hidden">
<video ref="videoEl" class="absolute inset-0 w-full h-full object-cover" playsinline muted autoplay />
<!-- 取景框:仅视觉引导,识别用整帧(见 scanFrame) -->
<div v-if="state === 'scanning'" class="absolute inset-0 flex items-center justify-center">
<div class="relative w-[72%] aspect-square">
<div class="absolute inset-0 rounded-3xl ring-2 ring-white/70" />
<span class="absolute -top-px -left-px w-9 h-9 border-t-4 border-l-4 border-brand-400 rounded-tl-3xl" />
<span class="absolute -top-px -right-px w-9 h-9 border-t-4 border-r-4 border-brand-400 rounded-tr-3xl" />
<span class="absolute -bottom-px -left-px w-9 h-9 border-b-4 border-l-4 border-brand-400 rounded-bl-3xl" />
<span class="absolute -bottom-px -right-px w-9 h-9 border-b-4 border-r-4 border-brand-400 rounded-br-3xl" />
<div class="qr-laser absolute left-3 right-3 h-0.5 rounded-full bg-brand-400 shadow-[0_0_12px_2px_rgb(44_197_168/0.8)]" />
</div>
</div>
<!-- 启动中 -->
<div v-if="state === 'starting'" class="absolute inset-0 flex flex-col items-center justify-center text-white/90 gap-3">
<div class="w-9 h-9 rounded-full border-2 border-white/30 border-t-white animate-spin" />
<p class="text-sm">正在打开摄像头…</p>
</div>
<!-- 失败 -->
<div v-else-if="state === 'error'" class="absolute inset-0 flex flex-col items-center justify-center px-8 text-center gap-3">
<div class="w-14 h-14 rounded-2xl bg-white/10 flex items-center justify-center">
<CameraOff class="w-7 h-7 text-white/80" />
</div>
<p class="text-sm text-white/90 leading-relaxed">{{ errMsg }}</p>
</div>
</div>
<!-- 底部操作 -->
<div class="relative z-10 px-6 pb-[calc(env(safe-area-inset-bottom)+20px)] pt-4 text-center">
<p v-if="state === 'scanning'" class="text-xs text-white/70 mb-3">将二维码放入框内,自动识别</p>
<button
class="w-full h-12 rounded-full bg-white/15 text-white text-sm font-semibold flex items-center justify-center gap-2 active:scale-[0.98] transition-transform"
@click="emit('pick')"
>
<ImagePlus class="w-4 h-4" />
从相册选择图片
</button>
</div>
</div>
</Teleport>
</template>
<style scoped>
/* 扫描线上下往复 */
@keyframes qr-laser {
0%, 100% { top: 8%; }
50% { top: 92%; }
}
.qr-laser {
animation: qr-laser 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.qr-laser { animation: none; top: 50%; }
}
</style>
+100 -37
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircle, Clock, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
import { CheckCircle, Clock, Plus, ScanLine, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import PinnedNotice from '@/components/PinnedNotice.vue'
import QrScanner from '@/components/QrScanner.vue'
import jsQR from 'jsqr'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
@@ -648,6 +649,43 @@ async function compressImage(file: File, maxBytes = 300 * 1024): Promise<File> {
// ======== 图片上传 → 浏览器直传到今日校园 OBS(不经过服务器中转),成功后立即保存表单 ========
const uploadingImage = ref(false)
const uploadingLabel = ref('上传中...')
// ======== 摄像头扫码(查寝二维码)========
const showScanner = ref(false)
const scanTarget = ref<{ index: number; isEdit: boolean } | null>(null)
function openScanner(index: number, isEdit: boolean) {
scanTarget.value = { index, isEdit }
showScanner.value = true
}
function closeScanner() {
showScanner.value = false
scanTarget.value = null
}
/** 扫码命中:提取 uuid 写入字段;不是签到码则提示并继续扫(不关闭扫码页) */
function onScanResult(text: string) {
const t = scanTarget.value
if (!t) return
const uuid = extractQrUuid(text)
if (!uuid) {
message.error('该二维码不是查寝签到码,请扫描辅导员出示的二维码')
return
}
const items = t.isEdit ? editFormItems.value : formItems.value
if (items[t.index]) items[t.index].content = uuid
closeScanner()
message.success('二维码已识别')
}
/** 扫码页「从相册选择」:关闭扫码并回退到原有图片解析流程 */
function onScanPick() {
const t = scanTarget.value
showScanner.value = false
scanTarget.value = null
if (t) void handleImageUpload(t.index, t.isEdit)
}
async function handleImageUpload(index: number, isEdit: boolean) {
if (uploadingImage.value) return
const input = document.createElement('input')
@@ -1469,6 +1507,9 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 摄像头扫码(查寝二维码):全屏覆盖 -->
<QrScanner v-if="showScanner" @result="onScanResult" @pick="onScanPick" @close="closeScanner" />
<!-- 收藏本站:右侧贴边窄竖条(图标在上、文字竖排在下;避开新手引导入口按钮),点击展示添加书签指引 -->
<button
class="bookmark-tab fixed right-0 bottom-40 z-40 flex flex-col items-center gap-1.5 rounded-l-xl py-3 px-1.5 text-xs font-semibold shadow-lg active:scale-95 transition-all"
@@ -1639,32 +1680,43 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
<div v-else-if="isQrField(item)" class="space-y-1">
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片。优先摄像头扫码,相册选图作为回退 -->
<div v-else-if="isQrField(item)" class="space-y-1.5">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<div @click="handleImageUpload(idx, false)" class="cursor-pointer">
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-xl text-brand-600 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<template v-else>
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<div
v-else-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
v-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-xl border border-green-200 bg-green-50 text-xs text-green-700"
>
<CheckCircle class="w-4 h-4 shrink-0" />
<span class="shrink-0">二维码ID:</span>
<span class="font-mono truncate">{{ item.content }}</span>
</div>
<div
v-else
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
>
<Upload class="w-4 h-4" />
<span>上传二维码图片</span>
<div class="flex gap-2">
<button
type="button"
class="press flex-[2] h-11 flex items-center justify-center gap-1.5 rounded-xl bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
@click="openScanner(idx, false)"
>
<ScanLine class="w-4 h-4" />
{{ item.content ? '重新扫码' : '扫一扫' }}
</button>
<button
type="button"
class="press flex-1 h-11 flex items-center justify-center gap-1.5 rounded-xl border border-gray-200 bg-white text-gray-600 text-sm font-medium"
@click="handleImageUpload(idx, false)"
>
<Upload class="w-4 h-4" />
选图
</button>
</div>
</div>
</template>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
@@ -1832,32 +1884,43 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
<div v-else-if="isQrField(item)" class="space-y-1">
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片。优先摄像头扫码,相册选图作为回退 -->
<div v-else-if="isQrField(item)" class="space-y-1.5">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<div @click="handleImageUpload(idx, true)" class="cursor-pointer">
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-xl text-brand-600 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<template v-else>
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<div
v-else-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
v-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-xl border border-green-200 bg-green-50 text-xs text-green-700"
>
<CheckCircle class="w-4 h-4 shrink-0" />
<span class="shrink-0">二维码ID:</span>
<span class="font-mono truncate">{{ item.content }}</span>
</div>
<div
v-else
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
>
<Upload class="w-4 h-4" />
<span>上传二维码图片</span>
<div class="flex gap-2">
<button
type="button"
class="press flex-[2] h-11 flex items-center justify-center gap-1.5 rounded-xl bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
@click="openScanner(idx, true)"
>
<ScanLine class="w-4 h-4" />
{{ item.content ? '重新扫码' : '扫一扫' }}
</button>
<button
type="button"
class="press flex-1 h-11 flex items-center justify-center gap-1.5 rounded-xl border border-gray-200 bg-white text-gray-600 text-sm font-medium"
@click="handleImageUpload(idx, true)"
>
<Upload class="w-4 h-4" />
选图
</button>
</div>
</div>
</template>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>