<template>
|
<view class="page">
|
<camera class="scan-camera" device-position="back" flash="off" mode="scanCode" @scancode="onScanCode" @error="onCameraError"></camera>
|
<cover-view class="camera-shade"></cover-view>
|
<cover-view class="back-button" @tap.stop="goBack"><cover-image class="back-icon" src="/static/icons/lucide/back-left.svg"></cover-image></cover-view>
|
<cover-view class="scan-frame">
|
<cover-view class="scan-window">
|
<cover-view class="frame-top"></cover-view><cover-view class="frame-bottom"></cover-view>
|
<cover-view v-if="scanState == 'scanning'" class="scan-line"></cover-view>
|
</cover-view>
|
<cover-view class="frame-corner corner-tl"></cover-view><cover-view class="frame-corner corner-tr"></cover-view><cover-view class="frame-corner corner-bl"></cover-view><cover-view class="frame-corner corner-br"></cover-view>
|
</cover-view>
|
<cover-view v-if="scanState == 'scanning'" class="scan-tip">将取景框对准二维码,即可自动扫描</cover-view>
|
|
<cover-view v-if="scanState != 'scanning'" class="status-panel">
|
<cover-view v-if="scanState == 'verifying'" class="loading-spinner"></cover-view>
|
<cover-view v-else class="status-mark" :class="scanState == 'success' ? 'success-mark' : 'error-mark'">{{scanState == 'success' ? '✓' : '!'}}</cover-view>
|
<cover-view class="status-title">{{statusTitle}}</cover-view>
|
<cover-view class="status-message">{{statusMessage}}</cover-view>
|
<cover-view v-if="scanState == 'camera-error'" class="primary-action" @tap="openCameraSettings">打开相机设置</cover-view>
|
<cover-view v-else-if="scanState == 'error'" class="status-actions">
|
<cover-view class="secondary-action" :class="canRetryVerification ? 'half-action' : 'full-action'" @tap="resetScanning">重新扫描</cover-view>
|
<cover-view v-if="canRetryVerification" class="primary-action half-action" @tap="retryVerification">重试核验</cover-view>
|
</cover-view>
|
</cover-view>
|
</view>
|
</template>
|
|
<script setup lang="uts">
|
import { scanVerificationCode, VerificationScanResult } from '../../services/verification.uts'
|
import { HttpError } from '../../services/http.uts'
|
|
const hasScanned = ref(false)
|
const scanState = ref('scanning')
|
const statusTitle = ref('')
|
const statusMessage = ref('')
|
const canRetryVerification = ref(false)
|
let lastScanResult = ''
|
let openedManualConsumption = false
|
let openedConfirmation = false
|
let openedVerificationFailure = false
|
let cancelVerificationRequest: (() => void) | null = null
|
|
const cancelPendingRequest = () => {
|
if (cancelVerificationRequest != null) {
|
const cancel = cancelVerificationRequest
|
cancelVerificationRequest = null
|
cancel()
|
}
|
}
|
|
const goBack = () => {
|
cancelPendingRequest()
|
uni.navigateBack()
|
}
|
|
const isMerchantScopeFailure = (message: string): boolean => {
|
return message.indexOf('当前商户') >= 0 || message.indexOf('本商户') >= 0
|
}
|
|
const openVerificationFailure = () => {
|
openedVerificationFailure = true
|
uni.navigateTo({
|
url: '/pages/verification-failure/verification-failure',
|
fail: () => {
|
openedVerificationFailure = false
|
scanState.value = 'error'
|
statusTitle.value = '核销失败'
|
statusMessage.value = '二维码已失效、不可核销或不属于当前商户'
|
canRetryVerification.value = false
|
}
|
})
|
}
|
|
const showVerificationError = (error: HttpError) => {
|
cancelVerificationRequest = null
|
if (isMerchantScopeFailure(error.message)) {
|
openVerificationFailure()
|
return
|
}
|
scanState.value = 'error'
|
statusTitle.value = error.code == 'STORE_CONTEXT_MISSING' ? '门店信息缺失' : '核验失败'
|
statusMessage.value = error.message + (error.traceId.length > 0 ? '\n追踪号:' + error.traceId : '')
|
canRetryVerification.value = lastScanResult.length > 0 && error.code != 'STORE_CONTEXT_MISSING' && error.code != 'API_NOT_CONFIGURED' && error.code != 'UNAUTHORIZED'
|
}
|
|
const verifyScanResult = (rawResult: string) => {
|
cancelPendingRequest()
|
scanState.value = 'verifying'
|
statusTitle.value = '正在核验'
|
statusMessage.value = '请稍候,不要重复扫描'
|
canRetryVerification.value = false
|
cancelVerificationRequest = scanVerificationCode(rawResult, (result: VerificationScanResult, traceId: string) => {
|
cancelVerificationRequest = null
|
if (!result.canVerify) {
|
if (isMerchantScopeFailure(result.message)) {
|
openVerificationFailure()
|
return
|
}
|
scanState.value = 'error'
|
statusTitle.value = result.title
|
statusMessage.value = result.message + (traceId.length > 0 ? '\n追踪号:' + traceId : '')
|
canRetryVerification.value = false
|
return
|
}
|
// An order QR must always enter the confirmation page. Keep the order
|
// number as a fallback discriminator for older/incorrect type payloads.
|
const isTalentCode = result.credentialType == 'TALENT_CODE' && result.orderNo.length == 0
|
if (isTalentCode) {
|
openedManualConsumption = true
|
uni.navigateTo({ url: '/pages/consume-input/consume-input?verificationId=' + encodeURIComponent(result.verificationId) })
|
return
|
}
|
openedConfirmation = true
|
uni.navigateTo({
|
url: '/pages/verification-confirm/verification-confirm?verificationId=' + encodeURIComponent(result.verificationId)
|
+ '&credentialType=' + encodeURIComponent(result.credentialType)
|
+ '&customerName=' + encodeURIComponent(result.customerName)
|
+ '&customerMobile=' + encodeURIComponent(result.customerMobile)
|
+ '&cardLevel=' + encodeURIComponent(result.cardLevel)
|
+ '&orderNo=' + encodeURIComponent(result.orderNo)
|
})
|
}, showVerificationError)
|
}
|
|
const onScanCode = (event: any) => {
|
if (hasScanned.value) {
|
return
|
}
|
const detail: any = event.detail
|
const rawResult = detail != null && detail.result != null ? detail.result as string : ''
|
hasScanned.value = true
|
lastScanResult = rawResult
|
if (rawResult.trim().length == 0) {
|
showVerificationError({ code: 'EMPTY_SCAN_RESULT', message: '未识别到有效二维码,请重新扫描', statusCode: 0, traceId: '' })
|
return
|
}
|
verifyScanResult(rawResult)
|
}
|
|
const retryVerification = () => {
|
if (lastScanResult.length == 0) {
|
resetScanning()
|
return
|
}
|
verifyScanResult(lastScanResult)
|
}
|
|
const resetScanning = () => {
|
cancelPendingRequest()
|
hasScanned.value = false
|
lastScanResult = ''
|
scanState.value = 'scanning'
|
statusTitle.value = ''
|
statusMessage.value = ''
|
canRetryVerification.value = false
|
}
|
|
const onCameraError = (event: any) => {
|
cancelPendingRequest()
|
hasScanned.value = true
|
scanState.value = 'camera-error'
|
statusTitle.value = '无法使用相机'
|
statusMessage.value = '请在微信设置中允许使用摄像头后重试'
|
canRetryVerification.value = false
|
}
|
|
const openCameraSettings = () => {
|
uni.openSetting({
|
success: (result: any) => {
|
const authSetting: any = result.authSetting
|
if (authSetting != null && authSetting['scope.camera'] == true) {
|
resetScanning()
|
}
|
}
|
})
|
}
|
|
onUnload(() => {
|
cancelPendingRequest()
|
lastScanResult = ''
|
})
|
|
onShow(() => {
|
if (!openedManualConsumption && !openedConfirmation && !openedVerificationFailure) return
|
openedManualConsumption = false
|
openedConfirmation = false
|
openedVerificationFailure = false
|
resetScanning()
|
})
|
</script>
|
|
<style>
|
page { background-color: #000000; }
|
.page { width: 100%; height: 100vh; overflow: hidden; background-color: #000000; position: relative; }
|
.scan-camera { width: 100%; height: 100vh; background-color: #000000; position: absolute; top: 0; right: 0; bottom: 0; left: 0; }
|
.camera-shade { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background-color: rgba(0, 0, 0, 0.32); }
|
.back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; z-index: 20; }
|
.back-icon { width: 40rpx; height: 40rpx; filter: invert(1); }
|
.scan-frame { width: 460rpx; height: 460rpx; position: absolute; top: 430rpx; left: 145rpx; z-index: 10; box-sizing: border-box; }
|
.scan-window { width: 460rpx; height: 286rpx; position: absolute; top: 0; left: 0; background-color: rgba(40, 137, 91, 0.22); }
|
.frame-top, .frame-bottom { height: 4rpx; position: absolute; background-color: #6BE2AD; opacity: 0.85; }
|
.frame-top { width: 382rpx; left: 78rpx; }
|
.frame-bottom { width: 100%; left: 0; }
|
.frame-top { top: 0; }
|
.frame-bottom { bottom: 0; }
|
.frame-corner { width: 42rpx; height: 42rpx; position: absolute; border-color: #69E6B1; box-sizing: border-box; }
|
.corner-tl { top: 0; left: 0; border-top-width: 7rpx; border-left-width: 7rpx; }
|
.corner-tr { top: 0; right: 0; border-top-width: 7rpx; border-right-width: 7rpx; }
|
.corner-bl { bottom: 0; left: 0; border-bottom-width: 7rpx; border-left-width: 7rpx; }
|
.corner-br { right: 0; bottom: 0; border-right-width: 7rpx; border-bottom-width: 7rpx; }
|
.scan-line { width: 100%; height: 3rpx; position: absolute; top: 0; left: 0; background-color: rgba(133, 255, 192, 0.9); box-shadow: 0 0 15rpx rgba(111, 241, 170, 0.8); animation: scan-line-move 1800ms ease-in-out infinite alternate; }
|
.scan-tip { width: 100%; position: absolute; top: 975rpx; left: 0; z-index: 10; color: #FFFFFF; font-size: 27rpx; line-height: 38rpx; text-align: center; }
|
.status-panel { display: none; }
|
.loading-spinner { width: 58rpx; height: 58rpx; border-width: 6rpx; border-style: solid; border-color: #FFD5BF; border-top-color: #FF6B2C; border-radius: 50%; animation: loading-rotate 800ms linear infinite; }
|
.status-mark { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; border-radius: 32rpx; color: #FFFFFF; font-size: 38rpx; line-height: 64rpx; font-weight: 700; text-align: center; }
|
.success-mark { background-color: #35B879; }
|
.error-mark { background-color: #F06455; }
|
.status-title { margin-top: 22rpx; color: #202B3E; font-size: 34rpx; line-height: 48rpx; font-weight: 700; text-align: center; }
|
.status-message { margin-top: 12rpx; color: #738095; font-size: 25rpx; line-height: 38rpx; text-align: center; white-space: pre-line; }
|
.credential-summary { display: flex; width: 100%; min-height: 106rpx; padding: 16rpx 22rpx; margin-top: 20rpx; box-sizing: border-box; border-radius: 12rpx; background-color: #F6F8FA; flex-direction: column; align-items: center; }
|
.credential-type { color: #F26A31; font-size: 24rpx; line-height: 34rpx; font-weight: 700; }
|
.credential-person { margin-top: 5rpx; color: #2A3445; font-size: 26rpx; line-height: 36rpx; font-weight: 600; }
|
.credential-mobile, .credential-order { margin-top: 3rpx; color: #79869A; font-size: 22rpx; line-height: 31rpx; }
|
.status-actions { display: flex; width: 100%; margin-top: 30rpx; flex-direction: row; justify-content: center; }
|
.primary-action, .secondary-action { display: flex; height: 78rpx; justify-content: center; align-items: center; margin-top: 30rpx; border-radius: 40rpx; font-size: 27rpx; line-height: 38rpx; font-weight: 700; text-align: center; box-sizing: border-box; }
|
.primary-action { width: 100%; color: #FFFFFF; background-image: linear-gradient(90deg, #FF7428 0%, #FF9630 100%); }
|
.secondary-action { color: #F26A31; border-width: 2rpx; border-style: solid; border-color: #FFC6AA; background-color: #FFF8F4; }
|
.status-actions .primary-action, .status-actions .secondary-action { margin-top: 0; }
|
.half-action { width: 48%; }
|
.status-actions .primary-action.half-action { margin-left: 4%; }
|
.full-action { width: 100%; }
|
@keyframes scan-line-move { from { transform: translateY(0); } to { transform: translateY(282rpx); } }
|
@keyframes loading-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
</style>
|