verificationResult.vue
10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
<template>
<div class="verify-page">
<div class="status-card">
<div class="status-icon" :class="verify_status">
<van-icon :name="status_icon" size="24" :color="status_icon_color" />
</div>
<div class="status-text">
<div class="title">{{ status_title }}</div>
<div class="desc" v-if="verify_status === 'idle'">扫描预约码二维码进行核销</div>
<div class="desc" v-else>{{ msg }}</div>
</div>
</div>
<div class="info-card">
<div class="card-title">核销记录信息</div>
<template v-if="verify_info && Object.keys(verify_info).length > 0">
<div class="row">
<div class="label">姓名</div>
<div class="value">{{ verify_info.person_name || '-' }}</div>
</div>
<div class="row">
<div class="label">证件号码</div>
<div class="value">{{ format_id_number(verify_info.id_number) || '-' }}</div>
</div>
<div class="row">
<div class="label">状态</div>
<div class="value highlight">{{ verify_info.status || '-' }}</div>
</div>
<div class="row">
<div class="label">预约开始</div>
<div class="value">{{ verify_info.begin_time || '-' }}</div>
</div>
<div class="row last">
<div class="label">预约结束</div>
<div class="value">{{ verify_info.end_time || '-' }}</div>
</div>
</template>
<template v-else-if="verify_status === 'fail'">
<div class="fail-reason">
<div class="label">失败原因</div>
<div class="reason">{{ msg }}</div>
</div>
</template>
<template v-else>
<div class="empty">暂无核销信息</div>
</template>
</div>
<div class="verify-footer">
<!-- <van-field v-model="manual_code" placeholder="可粘贴/输入预约码(非微信环境备用)" clearable /> -->
<div class="btn-wrap">
<van-button
block
color="#A67939"
:loading="verify_status === 'verifying'"
:disabled="verify_status === 'verifying'"
@click="start_scan_and_verify"
>
核销
</van-button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import wx from 'weixin-js-sdk'
import { showToast } from 'vant'
import { mainStore } from '@/store'
import { checkRedeemPermissionAPI, verifyTicketAPI } from '@/api/redeem'
import { wxInfo } from '@/utils/tools'
const store = mainStore()
const $route = useRoute()
const $router = useRouter()
const manual_code = ref('')
const verify_code = ref('')
const verify_info = ref({})
const verify_status = ref('idle')
const msg = ref('请点击下方按钮进行核销')
const status_title = computed(() => {
if (verify_status.value === 'verifying') return '核销中'
if (verify_status.value === 'success') return '核销成功'
if (verify_status.value === 'fail') return '核销失败'
return '核销'
})
const status_icon = computed(() => {
if (verify_status.value === 'verifying') return 'clock-o'
if (verify_status.value === 'success') return 'passed'
if (verify_status.value === 'fail') return 'close'
return 'info-o'
})
const status_icon_color = computed(() => {
if (verify_status.value === 'fail') return '#E24A4A'
return '#A67939'
})
/**
* @description 对身份证号做脱敏展示(仅用于前端展示)
* @param {string} id 证件号码
* @returns {string} 脱敏后的证件号码
*/
const format_id_number = (id) => {
if (!id || typeof id !== 'string' || id.length < 10) return id
return id.replace(/^(.{6})(?:\d+)(.{4})$/, '$1********$2')
}
/**
* @description 统一扫码结果格式
* - 条形码可能返回 "codeType,codeValue" 格式,这里取最后一段
* - 二维码可能是 URL,尝试从 query 里解析 qr_code
* @param {unknown} raw 原始扫码结果
* @returns {string} 解析后的核销码(失败返回空字符串)
*/
const normalize_scan_result = (raw) => {
if (!raw) return ''
const text = String(raw)
// 部分机型扫描条形码会返回 "CODE_128,123456" 这种格式
const barcode_split = text.split(',')
const candidate = barcode_split.length > 1 ? barcode_split[barcode_split.length - 1] : text
if (candidate.includes('qr_code=')) {
try {
const url = new URL(candidate)
return url.searchParams.get('qr_code') || candidate
} catch (e) {
// 兼容不完整 URL 或低版本浏览器不支持 URL 构造的情况
const match = candidate.match(/(?:\?|&)qr_code=([^&]+)/)
if (match && match[1]) return decodeURIComponent(match[1])
}
}
return candidate
}
/**
* @description 校验当前账号是否有核销权限,无权限时重定向到义工登录
* @returns {Promise<boolean>} 是否具备核销权限
*/
const ensure_permission = async () => {
const permission_res = await checkRedeemPermissionAPI()
if (!permission_res || permission_res?.code !== 1) {
$router.replace({ path: '/volunteerLogin' })
return false
}
if (permission_res?.data) store.changeUserInfo(permission_res.data)
if (permission_res?.data?.can_redeem !== true) {
$router.replace({ path: '/volunteerLogin' })
return false
}
return true
}
/**
* @description 核销预约码
* @param {string} code 扫码结果或手动输入内容
* @returns {Promise<void>}
*/
const verify_ticket = async (code) => {
const normalized = normalize_scan_result(code)
if (!normalized) return
if (verify_status.value === 'verifying') return
verify_code.value = normalized
verify_status.value = 'verifying'
msg.value = '核销中...'
const res = await verifyTicketAPI({ qr_code: normalized })
if (res?.code === 1) {
verify_status.value = 'success'
msg.value = res?.msg || '核销成功'
verify_info.value = res?.data || {}
return
}
verify_status.value = 'fail'
msg.value = res?.msg || '核销失败'
verify_info.value = {}
}
/**
* @description 在微信环境内调起 JSSDK 扫码
* @returns {Promise<string>} 扫码原始结果(失败返回空字符串)
*/
const scan_in_wechat = async () => {
// 依赖 App.vue 全局初始化的 Promise(wx.ready / wx.error 都会 resolve)
const ok = await window.__wx_ready_promise
if (!ok) return ''
return new Promise((resolve) => {
wx.scanQRCode({
needResult: 1,
scanType: ['qrCode', 'barCode'],
success: (res) => resolve(res?.resultStr || ''),
fail: () => resolve(''),
cancel: () => resolve(''),
})
})
}
/**
* @description 点击按钮:先做权限校验,再根据环境选择“微信扫码”或“手动核销”
* @returns {Promise<void>}
*/
const start_scan_and_verify = async () => {
const authed = await ensure_permission()
if (!authed) return
// 这里使用 wxInfo 的环境判断结果,避免在非微信环境误调用 JSSDK
const in_wechat = wxInfo().isTable === true
if (in_wechat) {
const result = await scan_in_wechat()
const code = normalize_scan_result(result)
if (!code) {
if (manual_code.value) await verify_ticket(manual_code.value)
else showToast('未获取到二维码内容')
return
}
await verify_ticket(code)
return
}
if (manual_code.value) {
await verify_ticket(manual_code.value)
return
}
showToast('请在微信内扫码,或手动输入预约码')
}
/**
* @description 页面初始化:读取 URL 参数并自动核销(适配“扫码后跳转页面”场景)
* @returns {Promise<void>}
*/
const init_from_query = async () => {
const authed = await ensure_permission()
if (!authed) return
const code = $route.query?.result || $route.query?.qr_code || ''
const str_code = Array.isArray(code) ? code[0] : String(code || '')
if (str_code) {
manual_code.value = str_code
await verify_ticket(str_code)
}
}
onMounted(init_from_query)
/**
* @description 监听路由参数变化,支持“同页面多次扫码/回传 result”场景
* @param {unknown} next 新的 result 参数
* @returns {Promise<void>}
*/
const watch_route_result = async (next) => {
const code = Array.isArray(next) ? next[0] : String(next || '')
if (!code) return
if (verify_code.value === code) return
const authed = await ensure_permission()
if (!authed) return
manual_code.value = code
await verify_ticket(code)
}
watch(
() => $route.query?.result,
watch_route_result
)
</script>
<style lang="less" scoped>
.verify-page {
min-height: 100vh;
background-color: #F6F6F6;
padding: 16px;
padding-bottom: 160px;
box-sizing: border-box;
.status-card {
background: #fff;
border-radius: 12px;
padding: 16px;
display: flex;
align-items: center;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
.status-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #FFF7ED;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
}
.status-text {
flex: 1;
.title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.desc {
margin-top: 4px;
font-size: 13px;
color: #6B7280;
word-break: break-all;
}
}
}
.info-card {
margin-top: 12px;
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
.card-title {
font-size: 14px;
color: #6B7280;
margin-bottom: 12px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #F3F4F6;
.label {
font-size: 14px;
color: #6B7280;
}
.value {
font-size: 16px;
color: #111827;
font-weight: 600;
margin-left: 12px;
word-break: break-all;
text-align: right;
}
.highlight {
color: #A67939;
}
}
.row.last {
border-bottom: 0;
}
.fail-reason {
.label {
font-size: 14px;
color: #6B7280;
margin-bottom: 8px;
}
.reason {
font-size: 16px;
color: #E24A4A;
font-weight: 600;
word-break: break-all;
}
}
.empty {
font-size: 14px;
color: #9CA3AF;
padding: 8px 0;
}
}
.verify-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background-color: #fff;
box-shadow: 0 -10px 18px rgba(0, 0, 0, 0.06);
box-sizing: border-box;
.btn-wrap {
margin-top: 10px;
}
}
}
</style>