hookehuyr

feat: refine scan checkin detail and list flow

...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
36 </view> 36 </view>
37 </view> 37 </view>
38 38
39 - <view class="scan-checkin-detail-button-wrap"> 39 + <view v-if="detail.isChecked !== true" class="scan-checkin-detail-button-wrap">
40 <nut-button 40 <nut-button
41 type="primary" 41 type="primary"
42 class="scan-checkin-detail-button" 42 class="scan-checkin-detail-button"
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
44 :loading="scanSubmitting" 44 :loading="scanSubmitting"
45 @click="handleScanCheckin" 45 @click="handleScanCheckin"
46 > 46 >
47 - 扫码打卡 47 + {{ actionButtonText }}
48 </nut-button> 48 </nut-button>
49 </view> 49 </view>
50 </view> 50 </view>
...@@ -58,8 +58,13 @@ import RichTextRenderer from '@/components/RichTextRenderer.vue' ...@@ -58,8 +58,13 @@ import RichTextRenderer from '@/components/RichTextRenderer.vue'
58 import { getCurrentPageFullPath } from '@/utils/authRedirect' 58 import { getCurrentPageFullPath } from '@/utils/authRedirect'
59 import { getMyFamiliesAPI } from '@/api/family' 59 import { getMyFamiliesAPI } from '@/api/family'
60 import { getScanStageDetailAPI, submitScanCheckinAPI } from '@/api/map' 60 import { getScanStageDetailAPI, submitScanCheckinAPI } from '@/api/map'
61 +import { getUserProfileAPI } from '@/api/user'
61 import { verifyCheckinRangeWithCurrentLocation } from '@/utils/checkinLocation' 62 import { verifyCheckinRangeWithCurrentLocation } from '@/utils/checkinLocation'
62 -import { parseScanCheckinParams, parseScanCheckinSceneParams } from '@/utils/scanCheckin' 63 +import {
64 + parseScanCheckinParams,
65 + parseScanCheckinSceneParams,
66 + isSameScanCheckinTarget,
67 +} from '@/utils/scanCheckin'
63 68
64 const defaultCover = 69 const defaultCover =
65 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60' 70 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60'
...@@ -70,6 +75,9 @@ const detail = reactive({ ...@@ -70,6 +75,9 @@ const detail = reactive({
70 regSource: '', 75 regSource: '',
71 regStageId: '', 76 regStageId: '',
72 returnUrl: '', 77 returnUrl: '',
78 + openid: '',
79 + entryMode: 'scan_before_submit',
80 + entrySceneRaw: '',
73 banners: [defaultCover], 81 banners: [defaultCover],
74 title: '', 82 title: '',
75 guideText: '', 83 guideText: '',
...@@ -85,6 +93,10 @@ const detail = reactive({ ...@@ -85,6 +93,10 @@ const detail = reactive({
85 }) 93 })
86 94
87 const scanSubmitting = computed(() => detail.scanSubmitting === true) 95 const scanSubmitting = computed(() => detail.scanSubmitting === true)
96 +const actionButtonText = computed(() =>
97 + detail.entryMode === 'direct_submit' ? '点击打卡' : '扫码打卡'
98 +)
99 +const isApiSuccess = code => Number(code) === 1
88 100
89 const normalizedRichTextContent = computed(() => { 101 const normalizedRichTextContent = computed(() => {
90 const content = detail.discountContentRaw 102 const content = detail.discountContentRaw
...@@ -140,12 +152,54 @@ const promptNavigateToWelcome = async () => { ...@@ -140,12 +152,54 @@ const promptNavigateToWelcome = async () => {
140 } 152 }
141 } 153 }
142 154
155 +// 太阳码直达详情页时,当前页已经和目标打卡点对齐,可直接进入提交模式。
156 +const resolveEntryMode = (sceneParams, options = {}) => {
157 + const hasSceneTarget = Boolean(sceneParams.detailId && sceneParams.activityId)
158 + const explicitMode = String(options.checkin_mode || '').trim()
159 +
160 + if (explicitMode === 'direct_submit' || hasSceneTarget) {
161 + return 'direct_submit'
162 + }
163 +
164 + return 'scan_before_submit'
165 +}
166 +
167 +// 打卡接口现在要求显式传 openid,这里统一从用户资料接口补齐并缓存到页面状态。
168 +const ensureOpenid = async () => {
169 + if (detail.openid) {
170 + return detail.openid
171 + }
172 +
173 + const profileResult = await getUserProfileAPI()
174 +
175 + if (!isApiSuccess(profileResult?.code)) {
176 + Taro.showToast({
177 + title: profileResult?.msg || '获取用户信息失败',
178 + icon: 'none',
179 + })
180 + return ''
181 + }
182 +
183 + const openid = String(profileResult?.data?.user?.openid || '').trim()
184 +
185 + if (!openid) {
186 + Taro.showToast({
187 + title: '未获取到用户openid',
188 + icon: 'none',
189 + })
190 + return ''
191 + }
192 +
193 + detail.openid = openid
194 + return openid
195 +}
196 +
143 const handleScanCheckin = async () => { 197 const handleScanCheckin = async () => {
144 detail.scanSubmitting = true 198 detail.scanSubmitting = true
145 199
146 try { 200 try {
147 const familyResult = await getMyFamiliesAPI() 201 const familyResult = await getMyFamiliesAPI()
148 - const hasFamily = familyResult?.code && familyResult?.data?.families?.length > 0 202 + const hasFamily = isApiSuccess(familyResult?.code) && familyResult?.data?.families?.length > 0
149 203
150 if (!hasFamily) { 204 if (!hasFamily) {
151 detail.scanSubmitting = false 205 detail.scanSubmitting = false
...@@ -177,12 +231,21 @@ const handleScanCheckin = async () => { ...@@ -177,12 +231,21 @@ const handleScanCheckin = async () => {
177 return 231 return
178 } 232 }
179 233
234 + // 太阳码直达详情页时,不需要再扫一次码,直接按当前详情提交即可。
235 + if (detail.entryMode === 'direct_submit') {
236 + await submitCheckin({
237 + activityId: detail.activityId,
238 + detailId: detail.id,
239 + })
240 + return
241 + }
242 +
180 const scanResult = await Taro.scanCode({ 243 const scanResult = await Taro.scanCode({
181 onlyFromCamera: false, 244 onlyFromCamera: false,
182 - scanType: ['qrCode', 'barCode'], 245 + scanType: ['qrCode', 'barCode', 'WX_CODE'],
183 }) 246 })
184 247
185 - const scannedCode = scanResult.result || '' 248 + const scannedCode = scanResult.path || scanResult.result || ''
186 249
187 if (!scannedCode) { 250 if (!scannedCode) {
188 Taro.showToast({ 251 Taro.showToast({
...@@ -205,38 +268,31 @@ const handleScanCheckin = async () => { ...@@ -205,38 +268,31 @@ const handleScanCheckin = async () => {
205 return 268 return
206 } 269 }
207 270
208 - const submitResult = await submitScanCheckinAPI({ 271 + // 列表进入详情页时,扫码结果只负责确认目标打卡点,避免在错误详情里静默打错卡。
209 - activity_id: submitActivityId, 272 + const matchedCurrentDetail = isSameScanCheckinTarget(
210 - detail_id: submitDetailId, 273 + {
211 - }) 274 + activityId: detail.activityId,
212 - 275 + detailId: detail.id,
213 - if (submitResult.code === 1) { 276 + },
214 - detail.isChecked = true 277 + {
215 - detail.lastScanCode = scannedCode 278 + activityId: submitActivityId,
279 + detailId: submitDetailId,
280 + }
281 + )
216 282
217 - const modalResult = await Taro.showModal({ 283 + if (matchedCurrentDetail) {
218 - title: '打卡成功', 284 + await submitCheckin({
219 - content: '您已完成当前扫码打卡,可前往列表查看状态。', 285 + activityId: submitActivityId,
220 - showCancel: false, 286 + detailId: submitDetailId,
221 - confirmText: '查看列表', 287 + scannedCode,
222 }) 288 })
223 -
224 - if (modalResult.confirm) {
225 - const params = new URLSearchParams({
226 - activityId: detail.activityId,
227 - })
228 -
229 - // 用户确认“查看列表”后回到列表页,方便继续处理同一活动下的其他关卡。
230 - Taro.redirectTo({
231 - url: `/pages/ScanCheckinList/index?${params.toString()}`,
232 - })
233 - }
234 return 289 return
235 } 290 }
236 291
237 - Taro.showToast({ 292 + await promptNavigateToMatchedDetail({
238 - title: submitResult.msg || '提交失败', 293 + activityId: submitActivityId,
239 - icon: 'none', 294 + detailId: submitDetailId,
295 + rawScene: parsedScanParams.rawScene,
240 }) 296 })
241 } catch (error) { 297 } catch (error) {
242 if (error?.errMsg && error.errMsg.includes('cancel')) { 298 if (error?.errMsg && error.errMsg.includes('cancel')) {
...@@ -253,6 +309,91 @@ const handleScanCheckin = async () => { ...@@ -253,6 +309,91 @@ const handleScanCheckin = async () => {
253 } 309 }
254 } 310 }
255 311
312 +// 提交动作统一收口到这里,确保 openid、成功提示和列表回跳逻辑保持一致。
313 +const submitCheckin = async ({ activityId, detailId, scannedCode = '' }) => {
314 + if (!activityId || !detailId) {
315 + Taro.showToast({
316 + title: '缺少打卡参数',
317 + icon: 'none',
318 + })
319 + return
320 + }
321 +
322 + const openid = await ensureOpenid()
323 +
324 + if (!openid) {
325 + return
326 + }
327 +
328 + const submitResult = await submitScanCheckinAPI({
329 + activity_id: activityId,
330 + detail_id: detailId,
331 + openid,
332 + })
333 +
334 + if (isApiSuccess(submitResult?.code)) {
335 + detail.isChecked = true
336 + detail.lastScanCode = scannedCode || detail.lastScanCode
337 +
338 + const modalResult = await Taro.showModal({
339 + title: '打卡成功',
340 + content: '您已完成当前扫码打卡,可前往列表查看状态。',
341 + showCancel: false,
342 + confirmText: '查看列表',
343 + })
344 +
345 + if (modalResult.confirm) {
346 + const params = new URLSearchParams({
347 + activityId: activityId || detail.activityId,
348 + })
349 +
350 + Taro.redirectTo({
351 + url: `/pages/ScanCheckinList/index?${params.toString()}`,
352 + })
353 + }
354 + return
355 + }
356 +
357 + Taro.showToast({
358 + title: submitResult.msg || '提交失败',
359 + icon: 'none',
360 + })
361 +}
362 +
363 +// 扫到别的点位时先征求用户确认,再把页面切到目标详情并改成直达提交模式。
364 +const promptNavigateToMatchedDetail = async ({ activityId, detailId, rawScene }) => {
365 + const modalResult = await showMismatchConfirmModal()
366 +
367 + if (!modalResult.confirm) {
368 + return
369 + }
370 +
371 + const nextParams = new URLSearchParams({
372 + activityId,
373 + detailId,
374 + checkin_mode: 'direct_submit',
375 + })
376 +
377 + const nextScene = String(rawScene || '').trim()
378 +
379 + if (nextScene) {
380 + nextParams.set('scene', nextScene)
381 + }
382 +
383 + Taro.redirectTo({
384 + url: `/pages/ScanCheckinDetail/index?${nextParams.toString()}`,
385 + })
386 +}
387 +
388 +const showMismatchConfirmModal = async () => {
389 + return Taro.showModal({
390 + title: '打卡点不匹配',
391 + content: '当前扫码点与本页不匹配,是否前往对应打卡点详情?',
392 + confirmText: '前往详情',
393 + cancelText: '取消',
394 + })
395 +}
396 +
256 const applyStageDetail = stageDetail => { 397 const applyStageDetail = stageDetail => {
257 detail.scanSubmitting = false 398 detail.scanSubmitting = false
258 399
...@@ -282,6 +423,8 @@ useLoad(options => { ...@@ -282,6 +423,8 @@ useLoad(options => {
282 const detailId = sceneParams.detailId || options.detailId || options.id || '' 423 const detailId = sceneParams.detailId || options.detailId || options.id || ''
283 detail.regSource = options.reg_source || '' 424 detail.regSource = options.reg_source || ''
284 detail.regStageId = options.reg_stage_id || '' 425 detail.regStageId = options.reg_stage_id || ''
426 + detail.entrySceneRaw = sceneParams.rawScene || ''
427 + detail.entryMode = resolveEntryMode(sceneParams, options)
285 // 当前页路径会透传给补资料页,提交成功后用于回跳续扫。 428 // 当前页路径会透传给补资料页,提交成功后用于回跳续扫。
286 detail.returnUrl = `/${getCurrentPageFullPath()}` 429 detail.returnUrl = `/${getCurrentPageFullPath()}`
287 detail.id = detailId 430 detail.id = detailId
...@@ -294,7 +437,7 @@ const loadStageDetail = async () => { ...@@ -294,7 +437,7 @@ const loadStageDetail = async () => {
294 id: detail.id, 437 id: detail.id,
295 }) 438 })
296 439
297 - if (result?.code !== 1 || !result?.data) { 440 + if (!isApiSuccess(result?.code) || !result?.data) {
298 Taro.showToast({ 441 Taro.showToast({
299 title: result?.msg || '获取关卡详情失败', 442 title: result?.msg || '获取关卡详情失败',
300 icon: 'none', 443 icon: 'none',
...@@ -303,6 +446,11 @@ const loadStageDetail = async () => { ...@@ -303,6 +446,11 @@ const loadStageDetail = async () => {
303 } 446 }
304 447
305 applyStageDetail(result.data) 448 applyStageDetail(result.data)
449 +
450 + if (!detail.openid) {
451 + // 提前预取 openid,减少用户点击打卡时再补资料接口带来的等待感。
452 + await ensureOpenid()
453 + }
306 } 454 }
307 </script> 455 </script>
308 456
......
1 .scan-checkin-list-page { 1 .scan-checkin-list-page {
2 min-height: 100vh; 2 min-height: 100vh;
3 - padding: 32rpx 24rpx 220rpx; 3 + padding: 32rpx 24rpx 260rpx;
4 background: linear-gradient(180deg, #f6f8fb 0%, #eef2f5 100%); 4 background: linear-gradient(180deg, #f6f8fb 0%, #eef2f5 100%);
5 box-sizing: border-box; 5 box-sizing: border-box;
6 position: relative; 6 position: relative;
...@@ -87,6 +87,17 @@ ...@@ -87,6 +87,17 @@
87 flex-shrink: 0; 87 flex-shrink: 0;
88 } 88 }
89 89
90 +.scan-checkin-list-checked-tag {
91 + padding: 10rpx 18rpx;
92 + border-radius: 999rpx;
93 + background: rgba(54, 181, 187, 0.12);
94 + color: #2c9ca2;
95 + font-size: 24rpx;
96 + line-height: 1;
97 + font-weight: 500;
98 + flex-shrink: 0;
99 +}
100 +
90 .scan-checkin-list-load-more, 101 .scan-checkin-list-load-more,
91 .scan-checkin-list-no-more { 102 .scan-checkin-list-no-more {
92 padding: 24rpx 0 8rpx; 103 padding: 24rpx 0 8rpx;
...@@ -105,7 +116,7 @@ ...@@ -105,7 +116,7 @@
105 .scan-checkin-list-floating-button { 116 .scan-checkin-list-floating-button {
106 position: fixed; 117 position: fixed;
107 right: 24rpx; 118 right: 24rpx;
108 - bottom: 120rpx; 119 + bottom: 160rpx;
109 width: 120rpx; 120 width: 120rpx;
110 height: 120rpx; 121 height: 120rpx;
111 border-radius: 50%; 122 border-radius: 50%;
......
...@@ -25,6 +25,8 @@ ...@@ -25,6 +25,8 @@
25 <text class="scan-checkin-list-name">{{ point.title }}</text> 25 <text class="scan-checkin-list-name">{{ point.title }}</text>
26 </view> 26 </view>
27 27
28 + <text v-if="point.isChecked === true" class="scan-checkin-list-checked-tag">已打卡</text>
29 +
28 <view class="scan-checkin-list-action" @click="goToDetail(point)"> 30 <view class="scan-checkin-list-action" @click="goToDetail(point)">
29 <Scan2 size="20" /> 31 <Scan2 size="20" />
30 </view> 32 </view>
...@@ -55,6 +57,8 @@ ...@@ -55,6 +57,8 @@
55 /> 57 />
56 <text class="scan-checkin-list-floating-text">展位图</text> 58 <text class="scan-checkin-list-floating-text">展位图</text>
57 </view> 59 </view>
60 +
61 + <BottomNav />
58 </view> 62 </view>
59 </template> 63 </template>
60 64
...@@ -63,6 +67,7 @@ import { ref } from 'vue' ...@@ -63,6 +67,7 @@ import { ref } from 'vue'
63 import Taro, { useLoad } from '@tarojs/taro' 67 import Taro, { useLoad } from '@tarojs/taro'
64 import { IconFont, Scan2 } from '@nutui/icons-vue-taro' 68 import { IconFont, Scan2 } from '@nutui/icons-vue-taro'
65 import './index.less' 69 import './index.less'
70 +import BottomNav from '@/components/BottomNav.vue'
66 import { getScanStageListAPI } from '@/api/map' 71 import { getScanStageListAPI } from '@/api/map'
67 72
68 const pointList = ref([]) 73 const pointList = ref([])
...@@ -73,6 +78,7 @@ const hasMore = ref(true) ...@@ -73,6 +78,7 @@ const hasMore = ref(true)
73 const currentPage = ref(0) 78 const currentPage = ref(0)
74 const pageSize = ref(10) 79 const pageSize = ref(10)
75 80
81 +// 列表页只保留展示所需字段,避免模板层直接感知后端原始命名。
76 const mapStageList = stageList => 82 const mapStageList = stageList =>
77 stageList.map(stage => ({ 83 stageList.map(stage => ({
78 id: stage.id, 84 id: stage.id,
......
...@@ -35,8 +35,8 @@ export const parseScanCheckinSceneParams = (rawScene = '') => { ...@@ -35,8 +35,8 @@ export const parseScanCheckinSceneParams = (rawScene = '') => {
35 35
36 /** 36 /**
37 * @description 从扫码结果中提取打卡接口所需参数 37 * @description 从扫码结果中提取打卡接口所需参数
38 - * @param {string} rawScanResult - 微信扫码返回的原始结果,可能是完整URL,也可能是纯查询串 38 + * @param {string} rawScanResult - 微信扫码返回的原始结果,可能是 path、完整URL,也可能是纯查询串
39 - * @returns {{activityId:string, detailId:string, rawParams:Object}} 39 + * @returns {{activityId:string, detailId:string, rawParams:Object, rawScene:string}}
40 */ 40 */
41 export const parseScanCheckinParams = (rawScanResult = '') => { 41 export const parseScanCheckinParams = (rawScanResult = '') => {
42 const normalized = String(rawScanResult || '').trim() 42 const normalized = String(rawScanResult || '').trim()
...@@ -46,17 +46,44 @@ export const parseScanCheckinParams = (rawScanResult = '') => { ...@@ -46,17 +46,44 @@ export const parseScanCheckinParams = (rawScanResult = '') => {
46 activityId: '', 46 activityId: '',
47 detailId: '', 47 detailId: '',
48 rawParams: {}, 48 rawParams: {},
49 + rawScene: '',
49 } 50 }
50 } 51 }
51 52
52 const querySource = extractQuerySource(normalized) 53 const querySource = extractQuerySource(normalized)
53 const rawParams = parseQueryString(querySource) 54 const rawParams = parseQueryString(querySource)
55 + // 兼容 `pages/xxx?scene=detailId,activityId` 这种太阳码 path 场景。
56 + const sceneParams = parseScanCheckinSceneParams(rawParams.scene || '')
54 57
55 return { 58 return {
56 - activityId: pickFirstAvailableValue(rawParams, ['activity_id', 'activityId', 'id']), 59 + activityId:
57 - detailId: pickFirstAvailableValue(rawParams, ['detail_id', 'detailId', 'stage_id', 'stageId']), 60 + sceneParams.activityId ||
61 + pickFirstAvailableValue(rawParams, ['activity_id', 'activityId', 'id']),
62 + detailId:
63 + sceneParams.detailId ||
64 + pickFirstAvailableValue(rawParams, ['detail_id', 'detailId', 'stage_id', 'stageId']),
58 rawParams, 65 rawParams,
66 + rawScene: sceneParams.rawScene,
67 + }
68 +}
69 +
70 +/**
71 + * @description 判断当前详情页和扫码结果是否指向同一个打卡点
72 + * @param {{activityId:string|number, detailId:string|number}} currentTarget
73 + * @param {{activityId:string|number, detailId:string|number}} scannedTarget
74 + * @returns {boolean}
75 + */
76 +export const isSameScanCheckinTarget = (currentTarget = {}, scannedTarget = {}) => {
77 + const currentActivityId = normalizeTargetValue(currentTarget.activityId)
78 + const currentDetailId = normalizeTargetValue(currentTarget.detailId)
79 + const scannedActivityId = normalizeTargetValue(scannedTarget.activityId)
80 + const scannedDetailId = normalizeTargetValue(scannedTarget.detailId)
81 +
82 + if (!currentActivityId || !currentDetailId || !scannedActivityId || !scannedDetailId) {
83 + return false
59 } 84 }
85 +
86 + return currentActivityId === scannedActivityId && currentDetailId === scannedDetailId
60 } 87 }
61 88
62 const extractQuerySource = input => { 89 const extractQuerySource = input => {
...@@ -98,6 +125,8 @@ const pickFirstAvailableValue = (params, keys = []) => { ...@@ -98,6 +125,8 @@ const pickFirstAvailableValue = (params, keys = []) => {
98 return '' 125 return ''
99 } 126 }
100 127
128 +const normalizeTargetValue = value => String(value || '').trim()
129 +
101 const safeDecodeURIComponent = (value = '') => { 130 const safeDecodeURIComponent = (value = '') => {
102 try { 131 try {
103 return decodeURIComponent(String(value || '').replace(/\+/g, '%20')) 132 return decodeURIComponent(String(value || '').replace(/\+/g, '%20'))
......