hookehuyr

feat: refine scan checkin detail and list flow

......@@ -36,7 +36,7 @@
</view>
</view>
<view class="scan-checkin-detail-button-wrap">
<view v-if="detail.isChecked !== true" class="scan-checkin-detail-button-wrap">
<nut-button
type="primary"
class="scan-checkin-detail-button"
......@@ -44,7 +44,7 @@
:loading="scanSubmitting"
@click="handleScanCheckin"
>
扫码打卡
{{ actionButtonText }}
</nut-button>
</view>
</view>
......@@ -58,8 +58,13 @@ import RichTextRenderer from '@/components/RichTextRenderer.vue'
import { getCurrentPageFullPath } from '@/utils/authRedirect'
import { getMyFamiliesAPI } from '@/api/family'
import { getScanStageDetailAPI, submitScanCheckinAPI } from '@/api/map'
import { getUserProfileAPI } from '@/api/user'
import { verifyCheckinRangeWithCurrentLocation } from '@/utils/checkinLocation'
import { parseScanCheckinParams, parseScanCheckinSceneParams } from '@/utils/scanCheckin'
import {
parseScanCheckinParams,
parseScanCheckinSceneParams,
isSameScanCheckinTarget,
} from '@/utils/scanCheckin'
const defaultCover =
'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60'
......@@ -70,6 +75,9 @@ const detail = reactive({
regSource: '',
regStageId: '',
returnUrl: '',
openid: '',
entryMode: 'scan_before_submit',
entrySceneRaw: '',
banners: [defaultCover],
title: '',
guideText: '',
......@@ -85,6 +93,10 @@ const detail = reactive({
})
const scanSubmitting = computed(() => detail.scanSubmitting === true)
const actionButtonText = computed(() =>
detail.entryMode === 'direct_submit' ? '点击打卡' : '扫码打卡'
)
const isApiSuccess = code => Number(code) === 1
const normalizedRichTextContent = computed(() => {
const content = detail.discountContentRaw
......@@ -140,12 +152,54 @@ const promptNavigateToWelcome = async () => {
}
}
// 太阳码直达详情页时,当前页已经和目标打卡点对齐,可直接进入提交模式。
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 handleScanCheckin = async () => {
detail.scanSubmitting = true
try {
const familyResult = await getMyFamiliesAPI()
const hasFamily = familyResult?.code && familyResult?.data?.families?.length > 0
const hasFamily = isApiSuccess(familyResult?.code) && familyResult?.data?.families?.length > 0
if (!hasFamily) {
detail.scanSubmitting = false
......@@ -177,12 +231,21 @@ const handleScanCheckin = async () => {
return
}
// 太阳码直达详情页时,不需要再扫一次码,直接按当前详情提交即可。
if (detail.entryMode === 'direct_submit') {
await submitCheckin({
activityId: detail.activityId,
detailId: detail.id,
})
return
}
const scanResult = await Taro.scanCode({
onlyFromCamera: false,
scanType: ['qrCode', 'barCode'],
scanType: ['qrCode', 'barCode', 'WX_CODE'],
})
const scannedCode = scanResult.result || ''
const scannedCode = scanResult.path || scanResult.result || ''
if (!scannedCode) {
Taro.showToast({
......@@ -205,38 +268,31 @@ const handleScanCheckin = async () => {
return
}
const submitResult = await submitScanCheckinAPI({
activity_id: submitActivityId,
detail_id: submitDetailId,
})
if (submitResult.code === 1) {
detail.isChecked = true
detail.lastScanCode = scannedCode
// 列表进入详情页时,扫码结果只负责确认目标打卡点,避免在错误详情里静默打错卡。
const matchedCurrentDetail = isSameScanCheckinTarget(
{
activityId: detail.activityId,
detailId: detail.id,
},
{
activityId: submitActivityId,
detailId: submitDetailId,
}
)
const modalResult = await Taro.showModal({
title: '打卡成功',
content: '您已完成当前扫码打卡,可前往列表查看状态。',
showCancel: false,
confirmText: '查看列表',
if (matchedCurrentDetail) {
await submitCheckin({
activityId: submitActivityId,
detailId: submitDetailId,
scannedCode,
})
if (modalResult.confirm) {
const params = new URLSearchParams({
activityId: detail.activityId,
})
// 用户确认“查看列表”后回到列表页,方便继续处理同一活动下的其他关卡。
Taro.redirectTo({
url: `/pages/ScanCheckinList/index?${params.toString()}`,
})
}
return
}
Taro.showToast({
title: submitResult.msg || '提交失败',
icon: 'none',
await promptNavigateToMatchedDetail({
activityId: submitActivityId,
detailId: submitDetailId,
rawScene: parsedScanParams.rawScene,
})
} catch (error) {
if (error?.errMsg && error.errMsg.includes('cancel')) {
......@@ -253,6 +309,91 @@ const handleScanCheckin = async () => {
}
}
// 提交动作统一收口到这里,确保 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) {
const params = new URLSearchParams({
activityId: activityId || detail.activityId,
})
Taro.redirectTo({
url: `/pages/ScanCheckinList/index?${params.toString()}`,
})
}
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
......@@ -282,6 +423,8 @@ useLoad(options => {
const detailId = sceneParams.detailId || options.detailId || options.id || ''
detail.regSource = options.reg_source || ''
detail.regStageId = options.reg_stage_id || ''
detail.entrySceneRaw = sceneParams.rawScene || ''
detail.entryMode = resolveEntryMode(sceneParams, options)
// 当前页路径会透传给补资料页,提交成功后用于回跳续扫。
detail.returnUrl = `/${getCurrentPageFullPath()}`
detail.id = detailId
......@@ -294,7 +437,7 @@ const loadStageDetail = async () => {
id: detail.id,
})
if (result?.code !== 1 || !result?.data) {
if (!isApiSuccess(result?.code) || !result?.data) {
Taro.showToast({
title: result?.msg || '获取关卡详情失败',
icon: 'none',
......@@ -303,6 +446,11 @@ const loadStageDetail = async () => {
}
applyStageDetail(result.data)
if (!detail.openid) {
// 提前预取 openid,减少用户点击打卡时再补资料接口带来的等待感。
await ensureOpenid()
}
}
</script>
......
.scan-checkin-list-page {
min-height: 100vh;
padding: 32rpx 24rpx 220rpx;
padding: 32rpx 24rpx 260rpx;
background: linear-gradient(180deg, #f6f8fb 0%, #eef2f5 100%);
box-sizing: border-box;
position: relative;
......@@ -87,6 +87,17 @@
flex-shrink: 0;
}
.scan-checkin-list-checked-tag {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(54, 181, 187, 0.12);
color: #2c9ca2;
font-size: 24rpx;
line-height: 1;
font-weight: 500;
flex-shrink: 0;
}
.scan-checkin-list-load-more,
.scan-checkin-list-no-more {
padding: 24rpx 0 8rpx;
......@@ -105,7 +116,7 @@
.scan-checkin-list-floating-button {
position: fixed;
right: 24rpx;
bottom: 120rpx;
bottom: 160rpx;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
......
......@@ -25,6 +25,8 @@
<text class="scan-checkin-list-name">{{ point.title }}</text>
</view>
<text v-if="point.isChecked === true" class="scan-checkin-list-checked-tag">已打卡</text>
<view class="scan-checkin-list-action" @click="goToDetail(point)">
<Scan2 size="20" />
</view>
......@@ -55,6 +57,8 @@
/>
<text class="scan-checkin-list-floating-text">展位图</text>
</view>
<BottomNav />
</view>
</template>
......@@ -63,6 +67,7 @@ import { ref } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import { IconFont, Scan2 } from '@nutui/icons-vue-taro'
import './index.less'
import BottomNav from '@/components/BottomNav.vue'
import { getScanStageListAPI } from '@/api/map'
const pointList = ref([])
......@@ -73,6 +78,7 @@ const hasMore = ref(true)
const currentPage = ref(0)
const pageSize = ref(10)
// 列表页只保留展示所需字段,避免模板层直接感知后端原始命名。
const mapStageList = stageList =>
stageList.map(stage => ({
id: stage.id,
......
......@@ -35,8 +35,8 @@ export const parseScanCheckinSceneParams = (rawScene = '') => {
/**
* @description 从扫码结果中提取打卡接口所需参数
* @param {string} rawScanResult - 微信扫码返回的原始结果,可能是完整URL,也可能是纯查询串
* @returns {{activityId:string, detailId:string, rawParams:Object}}
* @param {string} rawScanResult - 微信扫码返回的原始结果,可能是 path、完整URL,也可能是纯查询串
* @returns {{activityId:string, detailId:string, rawParams:Object, rawScene:string}}
*/
export const parseScanCheckinParams = (rawScanResult = '') => {
const normalized = String(rawScanResult || '').trim()
......@@ -46,17 +46,44 @@ export const parseScanCheckinParams = (rawScanResult = '') => {
activityId: '',
detailId: '',
rawParams: {},
rawScene: '',
}
}
const querySource = extractQuerySource(normalized)
const rawParams = parseQueryString(querySource)
// 兼容 `pages/xxx?scene=detailId,activityId` 这种太阳码 path 场景。
const sceneParams = parseScanCheckinSceneParams(rawParams.scene || '')
return {
activityId: pickFirstAvailableValue(rawParams, ['activity_id', 'activityId', 'id']),
detailId: pickFirstAvailableValue(rawParams, ['detail_id', 'detailId', 'stage_id', 'stageId']),
activityId:
sceneParams.activityId ||
pickFirstAvailableValue(rawParams, ['activity_id', 'activityId', 'id']),
detailId:
sceneParams.detailId ||
pickFirstAvailableValue(rawParams, ['detail_id', 'detailId', 'stage_id', 'stageId']),
rawParams,
rawScene: sceneParams.rawScene,
}
}
/**
* @description 判断当前详情页和扫码结果是否指向同一个打卡点
* @param {{activityId:string|number, detailId:string|number}} currentTarget
* @param {{activityId:string|number, detailId:string|number}} scannedTarget
* @returns {boolean}
*/
export const isSameScanCheckinTarget = (currentTarget = {}, scannedTarget = {}) => {
const currentActivityId = normalizeTargetValue(currentTarget.activityId)
const currentDetailId = normalizeTargetValue(currentTarget.detailId)
const scannedActivityId = normalizeTargetValue(scannedTarget.activityId)
const scannedDetailId = normalizeTargetValue(scannedTarget.detailId)
if (!currentActivityId || !currentDetailId || !scannedActivityId || !scannedDetailId) {
return false
}
return currentActivityId === scannedActivityId && currentDetailId === scannedDetailId
}
const extractQuerySource = input => {
......@@ -98,6 +125,8 @@ const pickFirstAvailableValue = (params, keys = []) => {
return ''
}
const normalizeTargetValue = value => String(value || '').trim()
const safeDecodeURIComponent = (value = '') => {
try {
return decodeURIComponent(String(value || '').replace(/\+/g, '%20'))
......