master
lion 1 month ago
parent 1afcd3181c
commit f88c4cfe2f

@ -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) {
<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) && (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"
@click="openBookingModal(record as Activity)"
>场次设置</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"
@click="openActivityVerify(record as Activity)"
>核销管理</a-button>

@ -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"
>
<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>
<a-button long @click="closeCamera"></a-button>
</div>

Loading…
Cancel
Save