hookehuyr

feat(scan-checkin): 对接真实打卡接口并完善扫码打卡全流程

- 新增扫码打卡 API(关卡列表、关卡详情、打卡提交)替换 mock 数据
- 新增地理围栏校验工具(Haversine 距离计算 + 实时定位)
- 新增 RichTextRenderer 组件渲染关卡详情富文本
- 新增 return_url 回跳机制,支持扫码链路中补资料后原路返回
- ScanCheckinDetail 支持轮播图、家庭归属检查、地理围栏拦截
- Welcome/AddProfile/CreateFamily/JoinFamily 接入 return_url 参数

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
......@@ -28,6 +28,8 @@ declare module 'vue' {
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
NutRow: typeof import('@nutui/nutui-taro')['Row']
NutSearchbar: typeof import('@nutui/nutui-taro')['Searchbar']
NutSwiper: typeof import('@nutui/nutui-taro')['Swiper']
NutSwiperItem: typeof import('@nutui/nutui-taro')['SwiperItem']
NutToast: typeof import('@nutui/nutui-taro')['Toast']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PointsCollector: typeof import('./src/components/PointsCollector.vue')['default']
......@@ -35,6 +37,7 @@ declare module 'vue' {
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
PrimaryButton: typeof import('./src/components/PrimaryButton.vue')['default']
RankingCard: typeof import('./src/components/RankingCard.vue')['default']
RichTextRenderer: typeof import('./src/components/RichTextRenderer.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScrollableFamilyList: typeof import('./src/components/ScrollableFamilyList.vue')['default']
......
......@@ -49,6 +49,7 @@
"@nutui/icons-vue-taro": "^0.0.9",
"@nutui/nutui-taro": "^4.3.13",
"@tarojs/components": "4.1.7",
"@tarojs/extend": "4.1.7",
"@tarojs/helper": "4.1.7",
"@tarojs/plugin-framework-vue3": "4.1.7",
"@tarojs/plugin-html": "4.1.7",
......
......@@ -23,6 +23,9 @@ importers:
'@tarojs/components':
specifier: 4.1.7
version: 4.1.7(@tarojs/helper@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96))
'@tarojs/extend':
specifier: 4.1.7
version: 4.1.7(@tarojs/runtime@4.1.7)(@tarojs/taro@4.1.7(@tarojs/components@4.1.7(@tarojs/helper@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))(@tarojs/helper@4.1.7)(@tarojs/shared@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))
'@tarojs/helper':
specifier: 4.1.7
version: 4.1.7
......@@ -1908,6 +1911,13 @@ packages:
vue:
optional: true
'@tarojs/extend@4.1.7':
resolution: {integrity: sha512-tVZ6NcCsHTBP2UJrWZg4BrK5Rm8z8PsaAcv7uYUpX3MRrtC6s0fYOVhMFMryVEcR9KxoqrMd22le8wVQY0PNpQ==}
engines: {node: '>= 18'}
peerDependencies:
'@tarojs/runtime': 4.1.7
'@tarojs/taro': 4.1.7
'@tarojs/helper@4.1.7':
resolution: {integrity: sha512-bOll/uJuH/ChiCdcmJK6H3rDkt5R4WVMqzLTCCdN5FnlsL+UMFosj9dgPZmMwoyQq4Tf6m5b3cHg6FUtsU7ahA==}
engines: {node: '>= 18'}
......@@ -9086,6 +9096,11 @@ snapshots:
- webpack-chain
- webpack-dev-server
'@tarojs/extend@4.1.7(@tarojs/runtime@4.1.7)(@tarojs/taro@4.1.7(@tarojs/components@4.1.7(@tarojs/helper@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))(@tarojs/helper@4.1.7)(@tarojs/shared@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))':
dependencies:
'@tarojs/runtime': 4.1.7
'@tarojs/taro': 4.1.7(@tarojs/components@4.1.7(@tarojs/helper@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))(@tarojs/helper@4.1.7)(@tarojs/shared@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96))
'@tarojs/helper@4.1.7':
dependencies:
'@babel/core': 7.28.3
......
......@@ -5,13 +5,16 @@
* @FilePath: /lls_program/src/api/map.js
* @Description: 文件描述
*/
import { fn, fetch } from './fn';
import { fn, fetch } from './fn'
const Api = {
GET_MAP_URL: '/srv/?a=map&t=get_map_url',
GET_POSTER_DETAIL: '/srv/?a=map&t=poster',
GET_ACTIVITY_STATUS: '/srv/?a=map&t=get_map_url',
SAVE_POSTER_BACKGROUND: '/srv/?a=map&t=save_poster_background',
GET_MAP_URL: '/srv/?a=map&t=get_map_url',
GET_POSTER_DETAIL: '/srv/?a=map&t=poster',
GET_ACTIVITY_STATUS: '/srv/?a=map&t=get_map_url',
SAVE_POSTER_BACKGROUND: '/srv/?a=map&t=save_poster_background',
GET_SCAN_STAGE_LIST: '/srv/?a=map_activity&t=scan_stage_list',
GET_SCAN_STAGE_DETAIL: '/srv/?a=map_activity&t=scan_stage_detail',
SUBMIT_SCAN_CHECKIN: '/srv/?a=map_activity&t=checkin',
}
/**
......@@ -22,7 +25,7 @@ const Api = {
* @returns {Object} response.data - 响应数据
* @returns {string} response.data.url - 地图URL
*/
export const getMapUrlAPI = (params) => fn(fetch.get(Api.GET_MAP_URL, params));
export const getMapUrlAPI = params => fn(fetch.get(Api.GET_MAP_URL, params))
/**
* @description: 获取海报详情
......@@ -46,7 +49,7 @@ export const getMapUrlAPI = (params) => fn(fetch.get(Api.GET_MAP_URL, params));
* @returns {string} response.data.family.avatar_url - 家庭头像URL
* @returns {string} response.data.qrcode_url - 小程序码
*/
export const getPosterDetailAPI = (params) => fn(fetch.get(Api.GET_POSTER_DETAIL, params));
export const getPosterDetailAPI = params => fn(fetch.get(Api.GET_POSTER_DETAIL, params))
/**
* @description: 获取活动状态
......@@ -57,7 +60,7 @@ export const getPosterDetailAPI = (params) => fn(fetch.get(Api.GET_POSTER_DETAIL
* @returns {boolean} response.data.is_begin - 活动是否已经开始, true 已开始, false 未开始
* @returns {boolean} response.data.is_ended - 活动是否已经结束, true 已结束, false 未结束
*/
export const getActivityStatusAPI = (params) => fn(fetch.get(Api.GET_ACTIVITY_STATUS, params));
export const getActivityStatusAPI = params => fn(fetch.get(Api.GET_ACTIVITY_STATUS, params))
/**
* @description: 保存海报背景
......@@ -68,4 +71,52 @@ export const getActivityStatusAPI = (params) => fn(fetch.get(Api.GET_ACTIVITY_ST
* @returns {string} response.msg - 响应消息
* @returns {Object} response.data - 响应数据
*/
export const savePosterBackgroundAPI = (params) => fn(fetch.post(Api.SAVE_POSTER_BACKGROUND, params));
export const savePosterBackgroundAPI = params => fn(fetch.post(Api.SAVE_POSTER_BACKGROUND, params))
/**
* @description: 获取扫码关卡列表
* @param {Object} params - 请求参数
* @param {string} [params.page] - 页码,从 0 开始
* @param {string} [params.limit] - 每页条数
* @param {string} [params.activity_id] - 活动ID
* @returns {number} response.code - 响应状态码
* @returns {string} response.msg - 响应消息
* @returns {Object} response.data - 响应数据
* @returns {Array} response.data.stages - 关卡列表
* @returns {number} response.data.stages[].id - 关卡ID
* @returns {string} response.data.stages[].title - 关卡标题
* @returns {boolean} response.data.stages[].is_checked - 是否已打卡
*/
export const getScanStageListAPI = params => fn(fetch.get(Api.GET_SCAN_STAGE_LIST, params))
/**
* @description: 获取扫码关卡详情
* @param {Object} params - 请求参数
* @param {string} params.id - 关卡ID
* @returns {number} response.code - 响应状态码
* @returns {string} response.msg - 响应消息
* @returns {Object} response.data - 响应数据
* @returns {number} response.data.id - 关卡ID
* @returns {string} response.data.title - 关卡标题
* @returns {Array<string>} response.data.banner - 轮播图
* @returns {boolean} response.data.is_checked - 是否已打卡
* @returns {string} response.data.discount_title - 打卡点底部优惠标题
* @returns {string} response.data.note - 简介
* @returns {string} response.data.introduction - 底部富文本
* @returns {boolean} response.data.geo_enabled - 是否启用地理位置限制
* @returns {number} response.data.center_lng - 经度
* @returns {number} response.data.center_lat - 纬度
* @returns {number} response.data.radius_meters - 半径,单位米
*/
export const getScanStageDetailAPI = params => fn(fetch.get(Api.GET_SCAN_STAGE_DETAIL, params))
/**
* @description: 提交扫码打卡
* @param {Object} params - 请求参数
* @param {string} params.activity_id - 活动ID
* @param {string} params.detail_id - 关卡ID
* @param {string} [params.openid] - 用户openid,接口定义中为可选
* @returns {number} response.code - 响应状态码
* @returns {string} response.msg - 响应消息
*/
export const submitScanCheckinAPI = params => fn(fetch.post(Api.SUBMIT_SCAN_CHECKIN, params))
......
<template>
<view :id="containerId" class="rich-text-renderer" v-html="processedContent"></view>
</template>
<script setup>
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import Taro from '@tarojs/taro'
import { $ } from '@tarojs/extend'
const props = defineProps({
content: {
type: String,
default: '',
},
enableTransform: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['image-preview', 'file-click', 'link-copy'])
const processedContent = ref('')
const containerId = `rich-text-renderer-${Math.random().toString(36).slice(-8)}`
const containerSelector = `#${containerId}`
const previousTransformElement = Taro.options.html?.transformElement
const decodeHtmlEntities = html => {
if (!html) {
return ''
}
if (process.env.TARO_ENV === 'h5' && typeof document !== 'undefined') {
try {
const textArea = document.createElement('textarea')
textArea.innerHTML = html
const decoded = textArea.value
if (decoded !== html) {
return decoded
}
} catch (error) {
console.warn('[RichTextRenderer] DOM 解码失败,改用映射表', error)
}
}
const entityMap = {
'&nbsp;': '\u00A0',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&apos;': "'",
'&copy;': '©',
'&reg;': '®',
'&trade;': '™',
'&mdash;': '—',
'&ndash;': '–',
'&hellip;': '…',
'&laquo;': '«',
'&raquo;': '»',
'&lsquo;': '\u2018',
'&rsquo;': '\u2019',
'&ldquo;': '"',
'&rdquo;': '"',
}
let result = html
result = result.replace(/&#(\d+);/g, (_match, dec) => String.fromCharCode(dec))
result = result.replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) =>
String.fromCharCode(parseInt(hex, 16))
)
Object.entries(entityMap).forEach(([entity, char]) => {
result = result.split(entity).join(char)
})
return result
}
const replaceAnchorTags = html => {
let content = html
// 小程序 rich-text 对原生 a 标签交互能力有限,统一替换成可绑定事件的块级节点。
content = content.replace(/<a\s+/g, '<div class="rich-text-link" ')
content = content.replace(/href=/g, 'data-href=')
content = content.replace(/<\/a>/g, '</div>')
return content
}
const processContent = raw => {
if (!raw) {
processedContent.value = ''
return
}
let processed = raw
processed = decodeHtmlEntities(processed)
processed = replaceAnchorTags(processed)
processedContent.value = processed
}
const isImageUrl = (url = '') => /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(url)
const getFileNameFromUrl = (url = '') => {
const cleanUrl = url.split('?')[0]
const segments = cleanUrl.split('/')
return segments[segments.length - 1] || 'document.pdf'
}
const copyLink = (url, fileName = '链接') => {
Taro.setClipboardData({
data: url,
success: () => {
Taro.showToast({
title: '链接已复制',
icon: 'success',
duration: 2000,
})
emit('link-copy', { url, fileName })
},
fail: error => {
console.error('[RichTextRenderer] 复制链接失败:', error)
Taro.showToast({
title: '复制失败',
icon: 'none',
})
},
})
}
const openFileLink = async (url, fileName) => {
emit('file-click', { url, fileName })
if (isImageUrl(url)) {
Taro.previewImage({
urls: [url],
current: url,
indicator: 'default',
loop: false,
})
return
}
Taro.showLoading({ title: '文件打开中...' })
try {
const downloadResult = await Taro.downloadFile({
url,
})
if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) {
throw new Error(`下载失败: ${downloadResult.statusCode}`)
}
await Taro.openDocument({
filePath: downloadResult.tempFilePath,
showMenu: true,
})
} catch (error) {
console.error('[RichTextRenderer] 文件打开失败:', error)
Taro.showModal({
title: '打开失败',
content: '当前文件无法直接预览,是否复制链接后用其他应用打开?',
confirmText: '复制链接',
cancelText: '取消',
success: res => {
if (res.confirm) {
copyLink(url, fileName)
}
},
})
} finally {
Taro.hideLoading()
}
}
const setupTransformElement = () => {
if (!props.enableTransform) {
Taro.options.html.transformElement = previousTransformElement
return
}
Taro.options.html.transformElement = element => {
const transformed = previousTransformElement ? previousTransformElement(element) : element
const nodeName = transformed?.nodeName?.toLowerCase() || ''
const tagName = transformed?.tagName?.toLowerCase() || ''
const isImg =
nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image'
if (!isImg) {
return transformed
}
// 统一兜底图片样式,避免后端富文本里的宽高写死后撑坏小程序布局。
if (transformed?.setAttribute) {
transformed.setAttribute('mode', 'widthFix')
transformed.setAttribute('data-rich-image', 'true')
transformed.setAttribute(
'style',
'width:100%!important;max-width:100%!important;height:auto!important;display:block;margin:24rpx 0;border-radius:16rpx;'
)
}
return transformed
}
}
const bindImageEvents = () => {
nextTick(() => {
const container = $(containerSelector)
const imgs = container.find('.h5-img')
imgs.forEach(img => {
const $img = $(img)
$img.off('longpress')
$img.on('longpress', () => {
const src = $img.attr('src')
if (!src) {
return
}
Taro.previewImage({
urls: [src],
current: src,
indicator: 'default',
loop: false,
success: () => {
emit('image-preview', { src })
},
})
})
})
})
}
const bindFileLinkEvents = () => {
nextTick(() => {
const container = $(containerSelector)
const richTextLinks = container.find('.rich-text-link')
const fileLinks = container.find('._file_list')
let allLinks = []
if (richTextLinks.length > 0) {
allLinks = allLinks.concat(richTextLinks.toArray())
}
if (fileLinks.length > 0) {
allLinks = allLinks.concat(fileLinks.toArray())
}
allLinks.forEach(el => {
const $el = $(el)
const dataHref = $el.attr('data-href') || $el.attr('href')
if (!dataHref) {
return
}
$el.off('tap')
$el.on('tap', async () => {
const fileName =
$el.find('span span span').first().text() ||
$el.text().trim().substring(0, 50) ||
getFileNameFromUrl(dataHref)
await openFileLink(dataHref, fileName)
})
})
})
}
const bindLinkLongPressEvents = () => {
nextTick(() => {
const container = $(containerSelector)
const richTextLinks = container.find('.rich-text-link')
const anchorLinks = container.find('a[href]')
const fileLinks = container.find('._file_list')
let allLinks = []
if (richTextLinks.length > 0) {
allLinks = allLinks.concat(richTextLinks.toArray())
}
if (anchorLinks.length > 0) {
allLinks = allLinks.concat(anchorLinks.toArray())
}
if (fileLinks.length > 0) {
allLinks = allLinks.concat(fileLinks.toArray())
}
allLinks.forEach(el => {
const $el = $(el)
const dataHref = $el.attr('data-href') || $el.attr('href')
if (!dataHref) {
return
}
$el.off('longpress')
$el.on('longpress', () => {
const fileName =
$el.find('span span span').first().text() ||
$el.text().trim().substring(0, 30) ||
getFileNameFromUrl(dataHref)
copyLink(dataHref, fileName)
})
})
})
}
const handleContentChange = () => {
processContent(props.content)
nextTick(() => {
// 富文本每次重渲染都会替换节点,需要重新挂载图片预览和链接交互事件。
bindImageEvents()
bindFileLinkEvents()
bindLinkLongPressEvents()
})
}
watch(() => props.content, handleContentChange, { immediate: true })
watch(() => props.enableTransform, setupTransformElement, { immediate: true })
onBeforeUnmount(() => {
Taro.options.html.transformElement = previousTransformElement
})
</script>
<style lang="less">
#rich-text-renderer,
.rich-text-renderer {
color: #4b5563;
font-size: 30rpx;
line-height: 1.8;
word-break: break-word;
.h5-html,
.h5-address,
.h5-blockquote,
.h5-body,
.h5-dd,
.h5-div,
.h5-dl,
.h5-dt,
.h5-fieldset,
.h5-form,
.h5-frame,
.h5-frameset,
.h5-h1,
.h5-h2,
.h5-h3,
.h5-h4,
.h5-h5,
.h5-h6,
.h5-noframes,
.h5-ol,
.h5-p,
.h5-ul,
.h5-center,
.h5-dir,
.h5-hr,
.h5-menu,
.h5-pre {
display: block;
unicode-bidi: embed;
}
.h5-li {
display: list-item;
margin-bottom: 12rpx;
}
.h5-head {
display: none;
}
.h5-p,
.h5-div,
.h5-blockquote,
.h5-ul,
.h5-ol {
margin: 0 0 20rpx;
}
.h5-ul,
.h5-ol {
padding-left: 36rpx;
}
.h5-img,
img {
width: 100%;
max-width: 100%;
height: auto;
display: block;
margin: 24rpx 0;
border-radius: 16rpx;
}
.rich-text-link,
.h5-a,
a,
._file_list {
color: #2563eb;
text-decoration: underline;
word-break: break-all;
}
.rich-text-link *,
._file_list * {
pointer-events: none;
}
.h5-b,
.h5-strong {
font-weight: bolder;
}
.h5-i,
.h5-em {
font-style: italic;
}
.h5-table {
display: table;
width: 100%;
border-spacing: 2px;
margin: 20rpx 0;
}
.h5-tr {
display: table-row;
}
.h5-td,
.h5-th {
display: table-cell;
padding: 12rpx;
border: 1rpx solid #d1d5db;
vertical-align: top;
}
.h5-th {
font-weight: bolder;
background: #f9fafb;
}
}
</style>
......@@ -179,6 +179,7 @@ import BASE_URL from '@/utils/config'
const defaultAvatar = 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'
// 获取接口信息
import { updateUserProfileAPI } from '@/api/user'
import { normalizeReturnUrl } from '@/utils/returnUrl'
import { buildUpdateUserProfilePayload } from '@/utils/userProfile'
// 导入主题颜色
import { THEME_COLORS } from '@/utils/config'
......@@ -203,6 +204,7 @@ const formData = reactive({
const registerSourceParams = reactive({
reg_source: '',
reg_stage_id: '',
return_url: '',
})
/**
......@@ -399,6 +401,14 @@ const handleSave = async () => {
icon: 'success',
})
setTimeout(() => {
// 从扫码打卡链路进入补资料页时,保存成功后需要原路回到关卡详情继续扫码。
if (registerSourceParams.return_url) {
Taro.redirectTo({
url: normalizeReturnUrl(registerSourceParams.return_url),
})
return
}
Taro.navigateBack()
}, 1500)
return
......@@ -413,6 +423,7 @@ const handleSave = async () => {
useLoad(options => {
registerSourceParams.reg_source = options?.reg_source || ''
registerSourceParams.reg_stage_id = options?.reg_stage_id || ''
registerSourceParams.return_url = normalizeReturnUrl(options?.return_url || '')
})
</script>
......
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
......@@ -10,6 +10,18 @@
height: 520rpx;
}
.scan-checkin-detail-cover-swiper {
width: 100%;
height: 100%;
}
.scan-checkin-detail-cover-swiper :deep(.nut-swiper),
.scan-checkin-detail-cover-swiper :deep(.nut-swiper-inner),
.scan-checkin-detail-cover-swiper :deep(.nut-swiper-item) {
width: 100%;
height: 100%;
}
.scan-checkin-detail-cover-image {
width: 100%;
height: 100%;
......
<template>
<view class="scan-checkin-detail-page">
<view class="scan-checkin-detail-cover">
<image class="scan-checkin-detail-cover-image" :src="detail.cover" mode="aspectFill" />
<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.code }} {{ detail.title }}</text>
<text class="scan-checkin-detail-title">{{ detail.title }}</text>
<view class="scan-checkin-detail-status" :class="detail.isChecked ? 'done' : 'pending'">
{{ detail.isChecked ? '已打卡' : '未打卡' }}
</view>
......@@ -19,7 +31,7 @@
<text class="scan-checkin-detail-section-title">{{ detail.discountTitle }}</text>
</view>
<view class="scan-checkin-detail-content">
<rich-text class="scan-checkin-detail-rich-text" :nodes="formattedDiscountContent" />
<RichTextRenderer :content="normalizedRichTextContent" />
</view>
</view>
</view>
......@@ -42,16 +54,31 @@
import { reactive, computed } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import './index.less'
import { getMockScanCheckinDetail } from '@/utils/mockQrCheckin'
import RichTextRenderer from '@/components/RichTextRenderer.vue'
import { getCurrentPageFullPath } from '@/utils/authRedirect'
import { getMyFamiliesAPI } from '@/api/family'
import { getScanStageDetailAPI, submitScanCheckinAPI } from '@/api/map'
import { verifyCheckinRangeWithCurrentLocation } from '@/utils/checkinLocation'
import { parseScanCheckinParams } from '@/utils/scanCheckin'
const defaultCover =
'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60'
const detail = reactive({
activityId: '',
id: '',
code: 'W2D01',
title: '泰康之家经营管理有限公司上海分公司',
guideText: '在点位现场扫码打卡并推荐好物',
regSource: '',
regStageId: '',
returnUrl: '',
banners: [defaultCover],
title: '',
guideText: '',
discountTitle: '打卡点专属优惠',
discountContentRaw: '',
cover: 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
geoEnabled: false,
centerLng: null,
centerLat: null,
radiusMeters: null,
isChecked: false,
lastScanCode: '',
scanSubmitting: false,
......@@ -59,7 +86,7 @@ const detail = reactive({
const scanSubmitting = computed(() => detail.scanSubmitting === true)
const formattedDiscountContent = computed(() => {
const normalizedRichTextContent = computed(() => {
const content = detail.discountContentRaw
if (!content) {
......@@ -86,17 +113,30 @@ const formattedDiscountContent = computed(() => {
return formattedContent
})
const mockSubmitScanCode = async code => {
await new Promise(resolve => {
setTimeout(resolve, 500)
const navigateToWelcome = () => {
// 扫码入口沿用首页广告位的判断路线:
// 先判断是否有家庭,没有则先去 Welcome,再由 Welcome 决定补资料/创建家庭/加入家庭。
const params = new URLSearchParams({
reg_source: detail.regSource,
reg_stage_id: detail.regStageId,
return_url: detail.returnUrl,
})
Taro.redirectTo({
url: `/pages/Welcome/index?${params.toString()}`,
})
}
const promptNavigateToWelcome = async () => {
const modalResult = await Taro.showModal({
title: '温馨提示',
content: '扫码打卡需要先完善个人信息并加入家庭,完成后才能继续参与。',
confirmText: '去完善',
cancelText: '取消',
})
return {
code: 1,
msg: '打卡成功',
data: {
scan_code: code,
},
if (modalResult.confirm) {
navigateToWelcome()
}
}
......@@ -104,6 +144,39 @@ const handleScanCheckin = async () => {
detail.scanSubmitting = true
try {
const familyResult = await getMyFamiliesAPI()
const hasFamily = familyResult?.code && familyResult?.data?.families?.length > 0
if (!hasFamily) {
detail.scanSubmitting = false
await promptNavigateToWelcome()
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
}
const scanResult = await Taro.scanCode({
onlyFromCamera: false,
scanType: ['qrCode', 'barCode'],
......@@ -119,17 +192,42 @@ const handleScanCheckin = async () => {
return
}
const submitResult = await mockSubmitScanCode(scannedCode)
// 打卡提交参数以二维码里携带的内容为准,当前页面参数只用于展示和后续列表跳转。
const parsedScanParams = parseScanCheckinParams(scannedCode)
const submitActivityId = parsedScanParams.activityId
const submitDetailId = parsedScanParams.detailId
if (!submitActivityId || !submitDetailId) {
Taro.showToast({
title: '二维码缺少打卡参数',
icon: 'none',
})
return
}
const submitResult = await submitScanCheckinAPI({
activity_id: submitActivityId,
detail_id: submitDetailId,
})
if (submitResult.code === 1) {
detail.isChecked = true
detail.lastScanCode = scannedCode
await Taro.showModal({
title: '模拟提交成功',
content: `扫码结果:${scannedCode}`,
title: '打卡成功',
content: '您已完成当前扫码打卡,可前往列表查看状态。',
showCancel: false,
confirmText: '知道了',
confirmText: '查看列表',
})
const params = new URLSearchParams({
activityId: detail.activityId,
})
// 扫码打卡完成后回列表页,方便用户继续处理同一活动下的其他关卡。
Taro.redirectTo({
url: `/pages/ScanCheckinList/index?${params.toString()}`,
})
return
}
......@@ -153,30 +251,55 @@ const handleScanCheckin = async () => {
}
}
const handleMockDataLoaded = mockDetail => {
const applyStageDetail = stageDetail => {
detail.scanSubmitting = false
// 这里把接口字段统一映射成页面内部字段,避免模板层直接耦合后端命名。
Object.assign(detail, {
...mockDetail,
discountContentRaw: mockDetail.discountContent,
isChecked: mockDetail.status === '已打卡',
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,
})
}
useLoad(options => {
detail.activityId = options.activityId || options.activity_id || ''
const detailId = options.detailId || options.id || ''
const mockDetail = getMockScanCheckinDetail(detailId)
detail.regSource = options.reg_source || ''
detail.regStageId = options.reg_stage_id || ''
// 当前页路径会透传给补资料页,提交成功后用于回跳续扫。
detail.returnUrl = `/${getCurrentPageFullPath()}`
detail.id = detailId
loadStageDetail()
})
if (!mockDetail) {
const loadStageDetail = async () => {
const result = await getScanStageDetailAPI({
id: detail.id,
})
if (result?.code !== 1 || !result?.data) {
Taro.showToast({
title: '未找到打卡点',
title: result?.msg || '获取关卡详情失败',
icon: 'none',
})
return
}
handleMockDataLoaded(mockDetail)
})
applyStageDetail(result.data)
}
</script>
<script>
......
......@@ -17,7 +17,7 @@
</view>
<view class="scan-checkin-list-content">
<text class="scan-checkin-list-name">{{ point.code }} {{ point.title }}</text>
<text class="scan-checkin-list-name">{{ point.title }}</text>
</view>
<view class="scan-checkin-list-action" @click="goToDetail(point)">
......@@ -41,7 +41,7 @@ import { ref } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import { IconFont, Scan2 } from '@nutui/icons-vue-taro'
import './index.less'
import { getMockScanCheckinPoints } from '@/utils/mockQrCheckin'
import { getScanStageListAPI } from '@/api/map'
const pointList = ref([])
const activityId = ref('')
......@@ -70,8 +70,29 @@ const handleShowBoothMap = () => {
useLoad(options => {
activityId.value = options.activityId || options.id || ''
pointList.value = getMockScanCheckinPoints(activityId.value)
loadStageList()
})
const loadStageList = async () => {
const result = await getScanStageListAPI({
activity_id: activityId.value,
})
if (result?.code !== 1) {
Taro.showToast({
title: result?.msg || '获取关卡列表失败',
icon: 'none',
})
return
}
pointList.value = (result?.data?.stages || []).map(stage => ({
id: stage.id,
title: stage.title,
isChecked: stage.is_checked,
}))
}
</script>
<script>
......
This diff is collapsed. Click to expand it.
import Taro from '@tarojs/taro'
const toRadians = degree => (degree * Math.PI) / 180
/**
* @description 计算两个经纬度之间的距离(米)
* @param {Object} start
* @param {number|string} start.lng
* @param {number|string} start.lat
* @param {Object} end
* @param {number|string} end.lng
* @param {number|string} end.lat
* @returns {number}
*/
export const calculateDistanceMeters = (start, end) => {
const startLng = Number(start?.lng)
const startLat = Number(start?.lat)
const endLng = Number(end?.lng)
const endLat = Number(end?.lat)
if (![startLng, startLat, endLng, endLat].every(Number.isFinite)) {
return NaN
}
const earthRadius = 6371000
const deltaLat = toRadians(endLat - startLat)
const deltaLng = toRadians(endLng - startLng)
const a =
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(toRadians(startLat)) *
Math.cos(toRadians(endLat)) *
Math.sin(deltaLng / 2) *
Math.sin(deltaLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return earthRadius * c
}
/**
* @description 判断当前位置是否在扫码打卡允许范围内
* @param {Object} params
* @param {boolean} params.geoEnabled
* @param {number|string} params.userLng
* @param {number|string} params.userLat
* @param {number|string} params.centerLng
* @param {number|string} params.centerLat
* @param {number|string} params.radiusMeters
* @returns {{allowed:boolean,distance:number,reason:string}}
*/
export const checkCheckinRange = (params = {}) => {
const { geoEnabled, userLng, userLat, centerLng, centerLat, radiusMeters } = params
if (geoEnabled !== true) {
return {
allowed: true,
distance: 0,
reason: 'geo_disabled',
}
}
const distance = calculateDistanceMeters(
{ lng: userLng, lat: userLat },
{ lng: centerLng, lat: centerLat }
)
if (!Number.isFinite(distance)) {
return {
allowed: false,
distance: NaN,
reason: 'invalid_location',
}
}
const rangeLimit = Number(radiusMeters)
if (!Number.isFinite(rangeLimit) || rangeLimit < 0) {
return {
allowed: false,
distance,
reason: 'invalid_radius',
}
}
return {
allowed: distance <= rangeLimit,
distance,
reason: distance <= rangeLimit ? 'within_range' : 'out_of_range',
}
}
/**
* @description 静默获取当前位置并校验是否在扫码打卡范围内
* @param {Object} params
* @param {boolean} params.geoEnabled
* @param {number|string} params.centerLng
* @param {number|string} params.centerLat
* @param {number|string} params.radiusMeters
* @returns {Promise<{allowed:boolean,distance:number,reason:string,location?:{lng:number,lat:number}}>}
*/
export const verifyCheckinRangeWithCurrentLocation = async (params = {}) => {
const { geoEnabled, centerLng, centerLat, radiusMeters } = params
// 未开启地理围栏时直接放行,避免页面层重复写同样的短路判断。
if (geoEnabled !== true) {
return {
allowed: true,
distance: 0,
reason: 'geo_disabled',
}
}
try {
// 重新拉取当前位置而不是复用缓存,确保扫码瞬间的位置判断更接近真实场景。
const location = await Taro.getLocation({
type: 'gcj02',
altitude: false,
isHighAccuracy: true,
highAccuracyExpireTime: 4000,
})
const normalizedLocation = {
lng: location.longitude,
lat: location.latitude,
}
const rangeResult = checkCheckinRange({
geoEnabled,
userLng: normalizedLocation.lng,
userLat: normalizedLocation.lat,
centerLng,
centerLat,
radiusMeters,
})
return {
...rangeResult,
location: normalizedLocation,
}
} catch (error) {
console.error('获取扫码打卡位置失败:', error)
return {
allowed: false,
distance: NaN,
reason: 'location_fetch_failed',
}
}
}
/**
* @description 对 return_url 做安全解码,兼容被重复 encode 的情况
* @param {string} value
* @returns {string}
*/
export const normalizeReturnUrl = (value = '') => {
let normalized = String(value || '').trim()
if (!normalized) {
return ''
}
let previousValue = ''
while (normalized && normalized !== previousValue) {
previousValue = normalized
try {
normalized = decodeURIComponent(normalized)
} catch (error) {
break
}
}
if (!normalized.startsWith('/')) {
normalized = `/${normalized.replace(/^\/+/, '')}`
}
return normalized
}
/**
* @description 给页面地址追加 return_url 参数
* @param {string} url
* @param {string} returnUrl
* @returns {string}
*/
export const appendReturnUrlParam = (url, returnUrl = '') => {
const normalizedReturnUrl = normalizeReturnUrl(returnUrl)
if (!normalizedReturnUrl) {
return url
}
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}return_url=${encodeURIComponent(normalizedReturnUrl)}`
}
/**
* @description 从扫码结果中提取打卡接口所需参数
* @param {string} rawScanResult - 微信扫码返回的原始结果,可能是完整URL,也可能是纯查询串
* @returns {{activityId:string, detailId:string, rawParams:Object}}
*/
export const parseScanCheckinParams = (rawScanResult = '') => {
const normalized = String(rawScanResult || '').trim()
if (!normalized) {
return {
activityId: '',
detailId: '',
rawParams: {},
}
}
const querySource = extractQuerySource(normalized)
const rawParams = parseQueryString(querySource)
return {
activityId: pickFirstAvailableValue(rawParams, ['activity_id', 'activityId', 'id']),
detailId: pickFirstAvailableValue(rawParams, ['detail_id', 'detailId', 'stage_id', 'stageId']),
rawParams,
}
}
const extractQuerySource = input => {
const questionMarkIndex = input.indexOf('?')
if (questionMarkIndex >= 0) {
return input.slice(questionMarkIndex + 1)
}
return input
}
const parseQueryString = (queryString = '') => {
const hashRemoved = String(queryString || '').split('#')[0]
const pairs = hashRemoved.split('&').filter(Boolean)
return pairs.reduce((result, pair) => {
const [rawKey, ...rest] = pair.split('=')
const rawValue = rest.join('=')
const key = safeDecodeURIComponent(rawKey)
const value = safeDecodeURIComponent(rawValue)
if (key) {
result[key] = value
}
return result
}, {})
}
const pickFirstAvailableValue = (params, keys = []) => {
for (const key of keys) {
const value = params?.[key]
if (value !== '' && value !== undefined && value !== null) {
return String(value)
}
}
return ''
}
const safeDecodeURIComponent = (value = '') => {
try {
return decodeURIComponent(String(value || '').replace(/\+/g, '%20'))
} catch (error) {
return String(value || '')
}
}
......@@ -25,9 +25,28 @@ export const buildUpdateUserProfilePayload = (formData, extraParams = {}) => {
optionalKeys.forEach(key => {
const value = extraParams[key]
if (value !== '' && value !== undefined && value !== null) {
// reg_stage_id 需要保持数值类型,其他来源字段按原样透传即可。
payload[key] = key === 'reg_stage_id' ? Number(value) : value
}
})
return payload
}
/**
* @description 判断用户是否已完善扫码打卡所需的个人资料
* @param {Object} user - 用户资料
* @returns {boolean} 是否已完善
*/
export const isUserProfileComplete = (user = {}) => {
// 扫码打卡只关心能否继续后续流程所需的关键资料是否齐全,
// 不把 session、年龄限制等页面外规则混进这里。
return Boolean(
user?.nickname &&
user?.birth_date &&
user?.gender !== null &&
user?.gender !== undefined &&
user?.wheelchair_needed !== null &&
user?.wheelchair_needed !== undefined
)
}
......