index.vue
15.1 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
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
<template>
<view class="scan-checkin-detail-page">
<view class="scan-checkin-detail-cover">
<nut-swiper
class="scan-checkin-detail-cover-swiper"
:init-page="0"
:pagination-visible="detail.banners.length > 1"
pagination-color="#ffffff"
pagination-unselected-color="rgba(255, 255, 255, 0.5)"
:loop="detail.banners.length > 1"
:auto-play="detail.banners.length > 1 ? 3000 : 0"
>
<nut-swiper-item v-for="(banner, index) in detail.banners" :key="`${banner}-${index}`">
<image class="scan-checkin-detail-cover-image" :src="banner" mode="aspectFill" />
</nut-swiper-item>
</nut-swiper>
</view>
<view class="scan-checkin-detail-card">
<view class="scan-checkin-detail-heading">
<text class="scan-checkin-detail-title">{{ detail.title }}</text>
<view class="scan-checkin-detail-status" :class="detail.isChecked ? 'done' : 'pending'">
{{ detail.isChecked ? '已打卡' : '未打卡' }}
</view>
</view>
<text class="scan-checkin-detail-subtitle">{{ detail.guideText }}</text>
<view class="scan-checkin-detail-section">
<view class="scan-checkin-detail-section-header">
<text class="scan-checkin-detail-section-title">{{ detail.discountTitle }}</text>
</view>
<view class="scan-checkin-detail-content">
<RichTextRenderer :content="normalizedRichTextContent" />
</view>
</view>
</view>
<view v-if="shouldShowActionButton" class="scan-checkin-detail-button-wrap">
<nut-button
type="primary"
class="scan-checkin-detail-button"
color="#DF7750"
:loading="scanSubmitting"
@click="handleScanCheckin"
>
{{ actionButtonText }}
</nut-button>
</view>
<nut-dialog v-model:visible="showLocationConfirmDialog" title="位置权限申请">
<template #default>
<view class="text-gray-700 leading-loose text-sm text-left">
{{ locationConfirmContent }}
</view>
</template>
<template #footer>
<nut-row :gutter="10">
<nut-col :span="12">
<nut-button @click="handleLocationConfirmCancel" type="default" size="normal" block>
暂不开启
</nut-button>
</nut-col>
<nut-col :span="12">
<nut-button
@click="handleLocationConfirmAgree"
type="primary"
size="normal"
color="#DF7750"
block
>
同意并继续
</nut-button>
</nut-col>
</nut-row>
</template>
</nut-dialog>
</view>
</template>
<script setup>
import { reactive, computed, ref } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import './index.less'
import RichTextRenderer from '@/components/RichTextRenderer.vue'
import { handleSharePageAuth } from '@/utils/authRedirect'
import { getMyFamiliesAPI } from '@/api/family'
import { getScanStageDetailAPI, submitScanCheckinAPI } from '@/api/map_activity'
import { getUserProfileAPI } from '@/api/user'
import { verifyCheckinRangeWithCurrentLocation } from '@/utils/checkinLocation'
import {
parseScanCheckinParams,
parseScanCheckinSceneParams,
isSameScanCheckinTarget,
} from '@/utils/scanCheckin'
const defaultCover =
'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60'
const REGISTER_SOURCE_SCAN_STAGE = 'SCAN_STAGE'
const detail = reactive({
activityId: '',
id: '',
regSource: '',
regStageId: '',
openid: '',
entryMode: 'scan_before_submit',
entrySceneRaw: '',
banners: [defaultCover],
title: '',
guideText: '',
discountTitle: '打卡点专属优惠',
discountContentRaw: '',
geoEnabled: false,
centerLng: null,
centerLat: null,
radiusMeters: null,
isChecked: false,
isEnded: false,
lastScanCode: '',
scanSubmitting: false,
})
const scanSubmitting = computed(() => detail.scanSubmitting === true)
const shouldShowActionButton = computed(() => detail.isChecked !== true && detail.isEnded !== true)
const actionButtonText = computed(() =>
detail.entryMode === 'direct_submit' ? '点击打卡' : '扫码打卡'
)
const isApiSuccess = code => Number(code) === 1
const showLocationConfirmDialog = ref(false)
const pendingLocationConfirmResolver = ref(null)
const locationConfirmContent =
'为了完成扫码打卡,我们需要获取您的位置信息,用于验证您是否在当前打卡点范围内。我们会严格保护您的位置隐私,仅用于本次扫码打卡。'
const normalizedRichTextContent = computed(() => {
const content = detail.discountContentRaw
if (!content) {
return ''
}
if (Array.isArray(content)) {
return content
.map(
item =>
`<p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">${item}</p>`
)
.join('')
}
let formattedContent = content
formattedContent = formattedContent.replace(/\n/g, '<br>')
formattedContent = formattedContent.replace(
/<img/g,
'<img style="max-width:100%;height:auto;display:block;border-radius:16rpx;margin:24rpx 0;"'
)
return formattedContent
})
const navigateToWelcome = () => {
// 扫码详情页缺少家庭时,沿用 Welcome -> AddProfile/建家庭/加家庭 的准入链路。
// 这里的来源归因固定表示“从扫码打卡详情页进入补资料”。
const params = new URLSearchParams({
reg_source: detail.regSource,
reg_stage_id: detail.regStageId,
})
Taro.redirectTo({
url: `/pages/Welcome/index?${params.toString()}`,
})
}
const promptNavigateToWelcome = async () => {
const modalResult = await Taro.showModal({
title: '温馨提示',
content: '扫码打卡需要先完善个人信息并加入家庭,完成后才能继续参与。',
confirmText: '去完善',
cancelText: '取消',
})
if (modalResult.confirm) {
navigateToWelcome()
}
}
// 太阳码直达详情页时,当前页已经和目标打卡点对齐,可直接进入提交模式。
const resolveEntryMode = (sceneParams, options = {}) => {
const hasSceneTarget = Boolean(sceneParams.detailId && sceneParams.activityId)
const explicitMode = String(options.checkin_mode || '').trim()
if (explicitMode === 'direct_submit' || hasSceneTarget) {
return 'direct_submit'
}
return 'scan_before_submit'
}
// 打卡接口现在要求显式传 openid,这里统一从用户资料接口补齐并缓存到页面状态。
const ensureOpenid = async () => {
if (detail.openid) {
return detail.openid
}
const profileResult = await getUserProfileAPI()
if (!isApiSuccess(profileResult?.code)) {
Taro.showToast({
title: profileResult?.msg || '获取用户信息失败',
icon: 'none',
})
return ''
}
const openid = String(profileResult?.data?.user?.openid || '').trim()
if (!openid) {
Taro.showToast({
title: '未获取到用户openid',
icon: 'none',
})
return ''
}
detail.openid = openid
return openid
}
const waitForLocationConfirm = async () => {
if (detail.geoEnabled !== true) {
return true
}
const authSetting = await Taro.getSetting()
const hasLocationPermission = authSetting?.authSetting?.['scope.userLocation']
if (hasLocationPermission === true) {
return true
}
showLocationConfirmDialog.value = true
return new Promise(resolve => {
pendingLocationConfirmResolver.value = resolve
})
}
const resolveLocationConfirm = confirmed => {
showLocationConfirmDialog.value = false
if (pendingLocationConfirmResolver.value) {
pendingLocationConfirmResolver.value(confirmed)
pendingLocationConfirmResolver.value = null
}
}
const handleLocationConfirmCancel = () => {
Taro.showToast({
title: '需要位置权限才能参与活动',
icon: 'none',
})
resolveLocationConfirm(false)
}
const handleLocationConfirmAgree = () => {
resolveLocationConfirm(true)
}
const handleScanCheckin = async () => {
detail.scanSubmitting = true
try {
const familyResult = await getMyFamiliesAPI()
const hasFamily = isApiSuccess(familyResult?.code) && familyResult?.data?.families?.length > 0
if (!hasFamily) {
detail.scanSubmitting = false
await promptNavigateToWelcome()
return
}
const hasConfirmedLocation = await waitForLocationConfirm()
if (!hasConfirmedLocation) {
detail.scanSubmitting = false
return
}
// 点击扫码时重新静默拉一次当前位置,避免依赖进入页面时的旧定位缓存。
const rangeCheck = await verifyCheckinRangeWithCurrentLocation({
geoEnabled: detail.geoEnabled,
centerLng: detail.centerLng,
centerLat: detail.centerLat,
radiusMeters: detail.radiusMeters,
})
if (!rangeCheck.allowed) {
detail.scanSubmitting = false
const outOfRangeTitle =
rangeCheck.reason === 'out_of_range'
? '未在打卡范围内,请前往指定位置后再扫码打卡'
: '获取当前位置失败,请确认定位权限后重试'
Taro.showToast({
title: outOfRangeTitle,
icon: 'none',
duration: 3000,
})
return
}
// 太阳码直达详情页时,不需要再扫一次码,直接按当前详情提交即可。
if (detail.entryMode === 'direct_submit') {
await submitCheckin({
activityId: detail.activityId,
detailId: detail.id,
})
return
}
const scanResult = await Taro.scanCode({
onlyFromCamera: false,
})
const scannedCode = scanResult.path || scanResult.result || ''
if (!scannedCode) {
Taro.showToast({
title: '未识别到扫码结果',
icon: 'none',
})
return
}
// 打卡提交参数以二维码里携带的内容为准,当前页面参数只用于展示和后续列表跳转。
const parsedScanParams = parseScanCheckinParams(scannedCode)
const submitActivityId = parsedScanParams.activityId
const submitDetailId = parsedScanParams.detailId
if (!submitActivityId || !submitDetailId) {
Taro.showToast({
title: '二维码缺少打卡参数',
icon: 'none',
})
return
}
// 列表进入详情页时,扫码结果只负责确认目标打卡点,避免在错误详情里静默打错卡。
const matchedCurrentDetail = isSameScanCheckinTarget(
{
activityId: detail.activityId,
detailId: detail.id,
},
{
activityId: submitActivityId,
detailId: submitDetailId,
}
)
if (matchedCurrentDetail) {
await submitCheckin({
activityId: submitActivityId,
detailId: submitDetailId,
scannedCode,
})
return
}
await promptNavigateToMatchedDetail({
activityId: submitActivityId,
detailId: submitDetailId,
rawScene: parsedScanParams.rawScene,
})
} catch (error) {
if (error?.errMsg && error.errMsg.includes('cancel')) {
return
}
console.error('扫码打卡失败:', error)
Taro.showToast({
title: '扫码失败,请重试',
icon: 'none',
})
} finally {
detail.scanSubmitting = false
}
}
// 提交动作统一收口到这里,确保 openid、成功提示和列表回跳逻辑保持一致。
const submitCheckin = async ({ activityId, detailId, scannedCode = '' }) => {
if (!activityId || !detailId) {
Taro.showToast({
title: '缺少打卡参数',
icon: 'none',
})
return
}
const openid = await ensureOpenid()
if (!openid) {
return
}
const submitResult = await submitScanCheckinAPI({
activity_id: activityId,
detail_id: detailId,
openid,
})
if (isApiSuccess(submitResult?.code)) {
detail.isChecked = true
detail.lastScanCode = scannedCode || detail.lastScanCode
const modalResult = await Taro.showModal({
title: '打卡成功',
content: '您已完成当前扫码打卡,可前往海报页查看并继续分享。',
showCancel: false,
confirmText: '查看海报',
})
if (modalResult.confirm) {
Taro.navigateTo({
url: `/pages/PosterCheckinDetail/index?activityId=${activityId || detail.activityId}`,
})
}
return
}
Taro.showToast({
title: submitResult.msg || '提交失败',
icon: 'none',
})
}
// 扫到别的点位时先征求用户确认,再把页面切到目标详情并改成直达提交模式。
const promptNavigateToMatchedDetail = async ({ activityId, detailId, rawScene }) => {
const modalResult = await showMismatchConfirmModal()
if (!modalResult.confirm) {
return
}
const nextParams = new URLSearchParams({
activityId,
detailId,
checkin_mode: 'direct_submit',
})
const nextScene = String(rawScene || '').trim()
if (nextScene) {
nextParams.set('scene', nextScene)
}
Taro.redirectTo({
url: `/pages/ScanCheckinDetail/index?${nextParams.toString()}`,
})
}
const showMismatchConfirmModal = async () => {
return Taro.showModal({
title: '打卡点不匹配',
content: '当前扫码点与本页不匹配,是否前往对应打卡点详情?',
confirmText: '前往详情',
cancelText: '取消',
})
}
const applyStageDetail = stageDetail => {
detail.scanSubmitting = false
// 这里把接口字段统一映射成页面内部字段,避免模板层直接耦合后端命名。
Object.assign(detail, {
id: stageDetail.id || '',
banners:
Array.isArray(stageDetail.banner) && stageDetail.banner.length > 0
? stageDetail.banner
: [defaultCover],
title: stageDetail.title || '',
guideText: stageDetail.note || '',
discountTitle: stageDetail.discount_title || '打卡点专属优惠',
discountContentRaw: stageDetail.introduction || '',
geoEnabled: stageDetail.geo_enabled === true,
centerLng: stageDetail.center_lng,
centerLat: stageDetail.center_lat,
radiusMeters: stageDetail.radius_meters,
isChecked: stageDetail.is_checked === true,
isEnded: stageDetail.is_ended === true,
})
}
const initPage = options => {
const sceneParams = parseScanCheckinSceneParams(options.scene || '')
detail.activityId = sceneParams.activityId || options.activityId || options.activity_id || ''
const detailId =
sceneParams.detailId ||
options.detailId ||
options.detail_id ||
options.stageId ||
options.stage_id ||
options.id ||
''
detail.regSource = REGISTER_SOURCE_SCAN_STAGE
detail.regStageId = detailId
detail.entrySceneRaw = sceneParams.rawScene || ''
detail.entryMode = resolveEntryMode(sceneParams, options)
detail.id = detailId
loadStageDetail()
}
useLoad(options => {
handleSharePageAuth(options, () => {
initPage(options)
})
})
const loadStageDetail = async () => {
const result = await getScanStageDetailAPI({
id: detail.id,
})
if (!isApiSuccess(result?.code) || !result?.data) {
Taro.showToast({
title: result?.msg || '获取关卡详情失败',
icon: 'none',
})
return
}
applyStageDetail(result.data)
if (!detail.openid) {
// 提前预取 openid,减少用户点击打卡时再补资料接口带来的等待感。
await ensureOpenid()
}
}
</script>
<script>
export default {
name: 'ScanCheckinDetail',
}
</script>