master
lion 2 months ago
parent 1afcd3181c
commit f88c4cfe2f

@ -173,6 +173,10 @@ async function openBookingModal(row: Activity) {
Message.warning('仅「需要报名」方式可配置场次') Message.warning('仅「需要报名」方式可配置场次')
return return
} }
if (row.schedule_status === 'ended') {
Message.warning('活动已结束,无法配置场次')
return
}
bookingActivityRef.value = row bookingActivityRef.value = row
bookingSaving.value = false bookingSaving.value = false
try { try {
@ -314,6 +318,10 @@ async function openActivityVerify(row: Activity) {
Message.warning('仅「需要报名」的活动可配置专用核销') Message.warning('仅「需要报名」的活动可配置专用核销')
return return
} }
if (row.schedule_status === 'ended') {
Message.warning('活动已结束,无需核销管理')
return
}
actVerifyActivityId.value = row.id actVerifyActivityId.value = row.id
actVerifyActivityTitle.value = row.title || '' actVerifyActivityTitle.value = row.title || ''
actVerifyVenueName.value = row.venue?.name || '' actVerifyVenueName.value = row.venue?.name || ''
@ -466,6 +474,8 @@ const form = reactive({
sort: 0, sort: 0,
summary: '', summary: '',
is_active: true, is_active: true,
/** 编辑时沿用列表接口返回的进度,与场次结束时间一致;新建为空则用下方日期推算 */
display_schedule_status: undefined as 'not_started' | 'ongoing' | 'ended' | undefined,
}) })
const tagInput = ref('') const tagInput = ref('')
@ -503,8 +513,11 @@ const activityDateRange = computed({
}, },
}) })
/** 表单内展示用:与后端列表一致,按东八区今天与起止日实时计算(保存时后端会写入 schedule_status */ /** 表单内展示:编辑时与列表/H5 一致用接口算的进度;新建用起止日推算 */
const formScheduleStatus = computed(() => computeScheduleStatusFromDates(form.start_at || '', form.end_at || '')) 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) { function normalizeMediaUrl(rawUrl?: string, rawPath?: string) {
const urlText = String(rawUrl || '').trim() const urlText = String(rawUrl || '').trim()
@ -1132,6 +1145,7 @@ function openCreate() {
form.is_hot = false form.is_hot = false
form.sort = 0 form.sort = 0
form.is_active = true form.is_active = true
form.display_schedule_status = undefined
resetEditors() resetEditors()
captureFormBaseline() captureFormBaseline()
visible.value = true visible.value = true
@ -1169,6 +1183,7 @@ function openEdit(row: Activity) {
form.summary = row.summary || '' form.summary = row.summary || ''
form.is_active = row.is_active form.is_active = row.is_active
form.is_hot = isSuperAdmin() ? row.is_hot === true : false form.is_hot = isSuperAdmin() ? row.is_hot === true : false
form.display_schedule_status = row.schedule_status
resetEditors() resetEditors()
captureFormBaseline() captureFormBaseline()
visible.value = true visible.value = true
@ -1520,12 +1535,20 @@ async function removeActivity(row: Activity) {
<a-space wrap :size="4" justify="start"> <a-space wrap :size="4" justify="start">
<a-button v-if="canEditActivityRow(record as Activity)" type="text" @click="openEdit(record)"></a-button> <a-button v-if="canEditActivityRow(record as Activity)" type="text" @click="openEdit(record)"></a-button>
<a-button <a-button
v-if="canEditActivityRow(record as Activity) && (record as Activity).reservation_type === 'online'" v-if="
canEditActivityRow(record as Activity)
&& (record as Activity).reservation_type === 'online'
&& (record as Activity).schedule_status !== 'ended'
"
type="text" type="text"
@click="openBookingModal(record as Activity)" @click="openBookingModal(record as Activity)"
>场次设置</a-button> >场次设置</a-button>
<a-button <a-button
v-if="canOpenActivityVerify(record as Activity) && (record as Activity).reservation_type === 'online'" v-if="
canOpenActivityVerify(record as Activity)
&& (record as Activity).reservation_type === 'online'
&& (record as Activity).schedule_status !== 'ended'
"
type="text" type="text"
@click="openActivityVerify(record as Activity)" @click="openActivityVerify(record as Activity)"
>核销管理</a-button> >核销管理</a-button>

@ -81,6 +81,12 @@ let jsQrCanvas: HTMLCanvasElement | null = null
let jsQrCtx: CanvasRenderingContext2D | null = null let jsQrCtx: CanvasRenderingContext2D | null = null
const lastScannedRaw = ref('') 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 scannedToken = ref('')
const preview = ref<{ const preview = ref<{
reservation: ReservationRow reservation: ReservationRow
@ -381,15 +387,44 @@ async function openCamera() {
} }
lastScannedRaw.value = '' lastScannedRaw.value = ''
cameraVisible.value = true cameraVisible.value = true
try { const tryConstraints: MediaStreamConstraints[] = [
streamRef.value = await getUserMedia({ {
video: { facingMode: 'environment' }, video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false, 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)) await new Promise((r) => requestAnimationFrame(r))
if (videoRef.value) { const el = videoRef.value
videoRef.value.srcObject = streamRef.value if (el) {
await videoRef.value.play() 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() startScanLoop()
} catch { } catch {
@ -406,7 +441,7 @@ function startScanLoop() {
if (scanTimer) window.clearInterval(scanTimer) if (scanTimer) window.clearInterval(scanTimer)
if (Detector) { if (Detector && shouldUseBarcodeDetector()) {
const detector = new Detector({ formats: ['qr_code'] }) const detector = new Detector({ formats: ['qr_code'] })
scanTimer = window.setInterval(async () => { scanTimer = window.setInterval(async () => {
if (!videoRef.value) return if (!videoRef.value) return
@ -435,7 +470,10 @@ function startScanLoop() {
} }
const ctx = jsQrCtx const ctx = jsQrCtx
const canvas = jsQrCanvas 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(() => { scanTimer = window.setInterval(() => {
const video = videoRef.value const video = videoRef.value
@ -451,8 +489,12 @@ function startScanLoop() {
dw = Math.floor(vw * scale) dw = Math.floor(vw * scale)
dh = Math.floor(vh * scale) dh = Math.floor(vh * scale)
} }
canvas.width = dw if (dw !== lastDw || dh !== lastDh) {
canvas.height = dh canvas.width = dw
canvas.height = dh
lastDw = dw
lastDh = dh
}
try { try {
ctx.drawImage(video, 0, 0, vw, vh, 0, 0, dw, dh) ctx.drawImage(video, 0, 0, vw, vh, 0, 0, dw, dh)
const imageData = ctx.getImageData(0, 0, dw, dh) const imageData = ctx.getImageData(0, 0, dw, dh)
@ -548,7 +590,14 @@ onBeforeUnmount(() => {
@cancel="closeCamera" @cancel="closeCamera"
> >
<div class="cam-wrap"> <div class="cam-wrap">
<video ref="videoRef" class="cam-video" muted playsinline /> <video
ref="videoRef"
class="cam-video"
muted
playsinline
autoplay
webkit-playsinline="true"
/>
<p class="cam-tip">请将二维码置于取景框中央</p> <p class="cam-tip">请将二维码置于取景框中央</p>
<a-button long @click="closeCamera"></a-button> <a-button long @click="closeCamera"></a-button>
</div> </div>

Loading…
Cancel
Save