- 新增 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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user