From f88c4cfe2f5f9a585fa39f045a089b04a79ea1d3 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Sat, 2 May 2026 10:00:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/activities/ActivityList.vue | 31 ++++++++++-- src/views/h5/VerifyScan.vue | 73 ++++++++++++++++++++++----- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/src/views/activities/ActivityList.vue b/src/views/activities/ActivityList.vue index 6fab619..90051bb 100644 --- a/src/views/activities/ActivityList.vue +++ b/src/views/activities/ActivityList.vue @@ -173,6 +173,10 @@ async function openBookingModal(row: Activity) { Message.warning('仅「需要报名」方式可配置场次') return } + if (row.schedule_status === 'ended') { + Message.warning('活动已结束,无法配置场次') + return + } bookingActivityRef.value = row bookingSaving.value = false try { @@ -314,6 +318,10 @@ async function openActivityVerify(row: Activity) { Message.warning('仅「需要报名」的活动可配置专用核销') return } + if (row.schedule_status === 'ended') { + Message.warning('活动已结束,无需核销管理') + return + } actVerifyActivityId.value = row.id actVerifyActivityTitle.value = row.title || '' actVerifyVenueName.value = row.venue?.name || '' @@ -466,6 +474,8 @@ const form = reactive({ sort: 0, summary: '', is_active: true, + /** 编辑时沿用列表接口返回的进度,与场次结束时间一致;新建为空则用下方日期推算 */ + display_schedule_status: undefined as 'not_started' | 'ongoing' | 'ended' | undefined, }) const tagInput = ref('') @@ -503,8 +513,11 @@ const activityDateRange = computed({ }, }) -/** 表单内展示用:与后端列表一致,按东八区今天与起止日实时计算(保存时后端会写入 schedule_status) */ -const formScheduleStatus = computed(() => computeScheduleStatusFromDates(form.start_at || '', form.end_at || '')) +/** 表单内展示:编辑时与列表/H5 一致用接口算的进度;新建用起止日推算 */ +const formScheduleStatus = computed(() => { + if (form.display_schedule_status != null) return form.display_schedule_status + return computeScheduleStatusFromDates(form.start_at || '', form.end_at || '') +}) function normalizeMediaUrl(rawUrl?: string, rawPath?: string) { const urlText = String(rawUrl || '').trim() @@ -1132,6 +1145,7 @@ function openCreate() { form.is_hot = false form.sort = 0 form.is_active = true + form.display_schedule_status = undefined resetEditors() captureFormBaseline() visible.value = true @@ -1169,6 +1183,7 @@ function openEdit(row: Activity) { form.summary = row.summary || '' form.is_active = row.is_active form.is_hot = isSuperAdmin() ? row.is_hot === true : false + form.display_schedule_status = row.schedule_status resetEditors() captureFormBaseline() visible.value = true @@ -1520,12 +1535,20 @@ async function removeActivity(row: Activity) { 编辑 场次设置 核销管理 diff --git a/src/views/h5/VerifyScan.vue b/src/views/h5/VerifyScan.vue index e3b8f22..6796c90 100644 --- a/src/views/h5/VerifyScan.vue +++ b/src/views/h5/VerifyScan.vue @@ -81,6 +81,12 @@ let jsQrCanvas: HTMLCanvasElement | null = null let jsQrCtx: CanvasRenderingContext2D | null = null const lastScannedRaw = ref('') +/** Android 上 BarcodeDetector 对 video 帧往往解不出码;走 jsQR 更稳定 */ +function shouldUseBarcodeDetector(): boolean { + if (typeof navigator === 'undefined') return true + return !/Android/i.test(navigator.userAgent || '') +} + const scannedToken = ref('') const preview = ref<{ reservation: ReservationRow @@ -381,15 +387,44 @@ async function openCamera() { } lastScannedRaw.value = '' cameraVisible.value = true - try { - streamRef.value = await getUserMedia({ - video: { facingMode: 'environment' }, + const tryConstraints: MediaStreamConstraints[] = [ + { + video: { + facingMode: { ideal: 'environment' }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, audio: false, - }) + }, + { video: { facingMode: 'environment' }, audio: false }, + { video: true, audio: false }, + ] + try { + let stream: MediaStream | null = null + let lastErr: unknown + for (const constraints of tryConstraints) { + try { + stream = await getUserMedia(constraints) + break + } catch (e) { + lastErr = e + } + } + if (!stream) { + throw lastErr instanceof Error ? lastErr : new Error('getUserMedia failed') + } + streamRef.value = stream await new Promise((r) => requestAnimationFrame(r)) - if (videoRef.value) { - videoRef.value.srcObject = streamRef.value - await videoRef.value.play() + const el = videoRef.value + if (el) { + el.setAttribute('playsinline', 'true') + el.setAttribute('webkit-playsinline', 'true') + el.playsInline = true + el.muted = true + el.srcObject = streamRef.value + await el.play().catch(() => { + /* 部分 WebView 需用户手势后 play,轮询仍会在就绪后解码 */ + }) } startScanLoop() } catch { @@ -406,7 +441,7 @@ function startScanLoop() { if (scanTimer) window.clearInterval(scanTimer) - if (Detector) { + if (Detector && shouldUseBarcodeDetector()) { const detector = new Detector({ formats: ['qr_code'] }) scanTimer = window.setInterval(async () => { if (!videoRef.value) return @@ -435,7 +470,10 @@ function startScanLoop() { } const ctx = jsQrCtx const canvas = jsQrCanvas - const maxDim = 640 + /** Android 略提高采样分辨率,便于 jsQR 识别 */ + const maxDim = /Android/i.test(navigator.userAgent || '') ? 960 : 640 + let lastDw = 0 + let lastDh = 0 scanTimer = window.setInterval(() => { const video = videoRef.value @@ -451,8 +489,12 @@ function startScanLoop() { dw = Math.floor(vw * scale) dh = Math.floor(vh * scale) } - canvas.width = dw - canvas.height = dh + if (dw !== lastDw || dh !== lastDh) { + canvas.width = dw + canvas.height = dh + lastDw = dw + lastDh = dh + } try { ctx.drawImage(video, 0, 0, vw, vh, 0, 0, dw, dh) const imageData = ctx.getImageData(0, 0, dw, dh) @@ -548,7 +590,14 @@ onBeforeUnmount(() => { @cancel="closeCamera" >
-