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' { ...@@ -28,6 +28,8 @@ declare module 'vue' {
28 NutPopup: typeof import('@nutui/nutui-taro')['Popup'] 28 NutPopup: typeof import('@nutui/nutui-taro')['Popup']
29 NutRow: typeof import('@nutui/nutui-taro')['Row'] 29 NutRow: typeof import('@nutui/nutui-taro')['Row']
30 NutSearchbar: typeof import('@nutui/nutui-taro')['Searchbar'] 30 NutSearchbar: typeof import('@nutui/nutui-taro')['Searchbar']
31 + NutSwiper: typeof import('@nutui/nutui-taro')['Swiper']
32 + NutSwiperItem: typeof import('@nutui/nutui-taro')['SwiperItem']
31 NutToast: typeof import('@nutui/nutui-taro')['Toast'] 33 NutToast: typeof import('@nutui/nutui-taro')['Toast']
32 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default'] 34 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
33 PointsCollector: typeof import('./src/components/PointsCollector.vue')['default'] 35 PointsCollector: typeof import('./src/components/PointsCollector.vue')['default']
...@@ -35,6 +37,7 @@ declare module 'vue' { ...@@ -35,6 +37,7 @@ declare module 'vue' {
35 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default'] 37 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
36 PrimaryButton: typeof import('./src/components/PrimaryButton.vue')['default'] 38 PrimaryButton: typeof import('./src/components/PrimaryButton.vue')['default']
37 RankingCard: typeof import('./src/components/RankingCard.vue')['default'] 39 RankingCard: typeof import('./src/components/RankingCard.vue')['default']
40 + RichTextRenderer: typeof import('./src/components/RichTextRenderer.vue')['default']
38 RouterLink: typeof import('vue-router')['RouterLink'] 41 RouterLink: typeof import('vue-router')['RouterLink']
39 RouterView: typeof import('vue-router')['RouterView'] 42 RouterView: typeof import('vue-router')['RouterView']
40 ScrollableFamilyList: typeof import('./src/components/ScrollableFamilyList.vue')['default'] 43 ScrollableFamilyList: typeof import('./src/components/ScrollableFamilyList.vue')['default']
......
...@@ -49,6 +49,7 @@ ...@@ -49,6 +49,7 @@
49 "@nutui/icons-vue-taro": "^0.0.9", 49 "@nutui/icons-vue-taro": "^0.0.9",
50 "@nutui/nutui-taro": "^4.3.13", 50 "@nutui/nutui-taro": "^4.3.13",
51 "@tarojs/components": "4.1.7", 51 "@tarojs/components": "4.1.7",
52 + "@tarojs/extend": "4.1.7",
52 "@tarojs/helper": "4.1.7", 53 "@tarojs/helper": "4.1.7",
53 "@tarojs/plugin-framework-vue3": "4.1.7", 54 "@tarojs/plugin-framework-vue3": "4.1.7",
54 "@tarojs/plugin-html": "4.1.7", 55 "@tarojs/plugin-html": "4.1.7",
......
...@@ -23,6 +23,9 @@ importers: ...@@ -23,6 +23,9 @@ importers:
23 '@tarojs/components': 23 '@tarojs/components':
24 specifier: 4.1.7 24 specifier: 4.1.7
25 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)) 25 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))
26 + '@tarojs/extend':
27 + specifier: 4.1.7
28 + 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)))
26 '@tarojs/helper': 29 '@tarojs/helper':
27 specifier: 4.1.7 30 specifier: 4.1.7
28 version: 4.1.7 31 version: 4.1.7
...@@ -1908,6 +1911,13 @@ packages: ...@@ -1908,6 +1911,13 @@ packages:
1908 vue: 1911 vue:
1909 optional: true 1912 optional: true
1910 1913
1914 + '@tarojs/extend@4.1.7':
1915 + resolution: {integrity: sha512-tVZ6NcCsHTBP2UJrWZg4BrK5Rm8z8PsaAcv7uYUpX3MRrtC6s0fYOVhMFMryVEcR9KxoqrMd22le8wVQY0PNpQ==}
1916 + engines: {node: '>= 18'}
1917 + peerDependencies:
1918 + '@tarojs/runtime': 4.1.7
1919 + '@tarojs/taro': 4.1.7
1920 +
1911 '@tarojs/helper@4.1.7': 1921 '@tarojs/helper@4.1.7':
1912 resolution: {integrity: sha512-bOll/uJuH/ChiCdcmJK6H3rDkt5R4WVMqzLTCCdN5FnlsL+UMFosj9dgPZmMwoyQq4Tf6m5b3cHg6FUtsU7ahA==} 1922 resolution: {integrity: sha512-bOll/uJuH/ChiCdcmJK6H3rDkt5R4WVMqzLTCCdN5FnlsL+UMFosj9dgPZmMwoyQq4Tf6m5b3cHg6FUtsU7ahA==}
1913 engines: {node: '>= 18'} 1923 engines: {node: '>= 18'}
...@@ -9086,6 +9096,11 @@ snapshots: ...@@ -9086,6 +9096,11 @@ snapshots:
9086 - webpack-chain 9096 - webpack-chain
9087 - webpack-dev-server 9097 - webpack-dev-server
9088 9098
9099 + '@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)))':
9100 + dependencies:
9101 + '@tarojs/runtime': 4.1.7
9102 + '@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))
9103 +
9089 '@tarojs/helper@4.1.7': 9104 '@tarojs/helper@4.1.7':
9090 dependencies: 9105 dependencies:
9091 '@babel/core': 7.28.3 9106 '@babel/core': 7.28.3
......
...@@ -5,13 +5,16 @@ ...@@ -5,13 +5,16 @@
5 * @FilePath: /lls_program/src/api/map.js 5 * @FilePath: /lls_program/src/api/map.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
8 -import { fn, fetch } from './fn'; 8 +import { fn, fetch } from './fn'
9 9
10 const Api = { 10 const Api = {
11 - GET_MAP_URL: '/srv/?a=map&t=get_map_url', 11 + GET_MAP_URL: '/srv/?a=map&t=get_map_url',
12 - GET_POSTER_DETAIL: '/srv/?a=map&t=poster', 12 + GET_POSTER_DETAIL: '/srv/?a=map&t=poster',
13 - GET_ACTIVITY_STATUS: '/srv/?a=map&t=get_map_url', 13 + GET_ACTIVITY_STATUS: '/srv/?a=map&t=get_map_url',
14 - SAVE_POSTER_BACKGROUND: '/srv/?a=map&t=save_poster_background', 14 + SAVE_POSTER_BACKGROUND: '/srv/?a=map&t=save_poster_background',
15 + GET_SCAN_STAGE_LIST: '/srv/?a=map_activity&t=scan_stage_list',
16 + GET_SCAN_STAGE_DETAIL: '/srv/?a=map_activity&t=scan_stage_detail',
17 + SUBMIT_SCAN_CHECKIN: '/srv/?a=map_activity&t=checkin',
15 } 18 }
16 19
17 /** 20 /**
...@@ -22,7 +25,7 @@ const Api = { ...@@ -22,7 +25,7 @@ const Api = {
22 * @returns {Object} response.data - 响应数据 25 * @returns {Object} response.data - 响应数据
23 * @returns {string} response.data.url - 地图URL 26 * @returns {string} response.data.url - 地图URL
24 */ 27 */
25 -export const getMapUrlAPI = (params) => fn(fetch.get(Api.GET_MAP_URL, params)); 28 +export const getMapUrlAPI = params => fn(fetch.get(Api.GET_MAP_URL, params))
26 29
27 /** 30 /**
28 * @description: 获取海报详情 31 * @description: 获取海报详情
...@@ -46,7 +49,7 @@ export const getMapUrlAPI = (params) => fn(fetch.get(Api.GET_MAP_URL, params)); ...@@ -46,7 +49,7 @@ export const getMapUrlAPI = (params) => fn(fetch.get(Api.GET_MAP_URL, params));
46 * @returns {string} response.data.family.avatar_url - 家庭头像URL 49 * @returns {string} response.data.family.avatar_url - 家庭头像URL
47 * @returns {string} response.data.qrcode_url - 小程序码 50 * @returns {string} response.data.qrcode_url - 小程序码
48 */ 51 */
49 -export const getPosterDetailAPI = (params) => fn(fetch.get(Api.GET_POSTER_DETAIL, params)); 52 +export const getPosterDetailAPI = params => fn(fetch.get(Api.GET_POSTER_DETAIL, params))
50 53
51 /** 54 /**
52 * @description: 获取活动状态 55 * @description: 获取活动状态
...@@ -57,7 +60,7 @@ export const getPosterDetailAPI = (params) => fn(fetch.get(Api.GET_POSTER_DETAIL ...@@ -57,7 +60,7 @@ export const getPosterDetailAPI = (params) => fn(fetch.get(Api.GET_POSTER_DETAIL
57 * @returns {boolean} response.data.is_begin - 活动是否已经开始, true 已开始, false 未开始 60 * @returns {boolean} response.data.is_begin - 活动是否已经开始, true 已开始, false 未开始
58 * @returns {boolean} response.data.is_ended - 活动是否已经结束, true 已结束, false 未结束 61 * @returns {boolean} response.data.is_ended - 活动是否已经结束, true 已结束, false 未结束
59 */ 62 */
60 -export const getActivityStatusAPI = (params) => fn(fetch.get(Api.GET_ACTIVITY_STATUS, params)); 63 +export const getActivityStatusAPI = params => fn(fetch.get(Api.GET_ACTIVITY_STATUS, params))
61 64
62 /** 65 /**
63 * @description: 保存海报背景 66 * @description: 保存海报背景
...@@ -68,4 +71,52 @@ export const getActivityStatusAPI = (params) => fn(fetch.get(Api.GET_ACTIVITY_ST ...@@ -68,4 +71,52 @@ export const getActivityStatusAPI = (params) => fn(fetch.get(Api.GET_ACTIVITY_ST
68 * @returns {string} response.msg - 响应消息 71 * @returns {string} response.msg - 响应消息
69 * @returns {Object} response.data - 响应数据 72 * @returns {Object} response.data - 响应数据
70 */ 73 */
71 -export const savePosterBackgroundAPI = (params) => fn(fetch.post(Api.SAVE_POSTER_BACKGROUND, params)); 74 +export const savePosterBackgroundAPI = params => fn(fetch.post(Api.SAVE_POSTER_BACKGROUND, params))
75 +
76 +/**
77 + * @description: 获取扫码关卡列表
78 + * @param {Object} params - 请求参数
79 + * @param {string} [params.page] - 页码,从 0 开始
80 + * @param {string} [params.limit] - 每页条数
81 + * @param {string} [params.activity_id] - 活动ID
82 + * @returns {number} response.code - 响应状态码
83 + * @returns {string} response.msg - 响应消息
84 + * @returns {Object} response.data - 响应数据
85 + * @returns {Array} response.data.stages - 关卡列表
86 + * @returns {number} response.data.stages[].id - 关卡ID
87 + * @returns {string} response.data.stages[].title - 关卡标题
88 + * @returns {boolean} response.data.stages[].is_checked - 是否已打卡
89 + */
90 +export const getScanStageListAPI = params => fn(fetch.get(Api.GET_SCAN_STAGE_LIST, params))
91 +
92 +/**
93 + * @description: 获取扫码关卡详情
94 + * @param {Object} params - 请求参数
95 + * @param {string} params.id - 关卡ID
96 + * @returns {number} response.code - 响应状态码
97 + * @returns {string} response.msg - 响应消息
98 + * @returns {Object} response.data - 响应数据
99 + * @returns {number} response.data.id - 关卡ID
100 + * @returns {string} response.data.title - 关卡标题
101 + * @returns {Array<string>} response.data.banner - 轮播图
102 + * @returns {boolean} response.data.is_checked - 是否已打卡
103 + * @returns {string} response.data.discount_title - 打卡点底部优惠标题
104 + * @returns {string} response.data.note - 简介
105 + * @returns {string} response.data.introduction - 底部富文本
106 + * @returns {boolean} response.data.geo_enabled - 是否启用地理位置限制
107 + * @returns {number} response.data.center_lng - 经度
108 + * @returns {number} response.data.center_lat - 纬度
109 + * @returns {number} response.data.radius_meters - 半径,单位米
110 + */
111 +export const getScanStageDetailAPI = params => fn(fetch.get(Api.GET_SCAN_STAGE_DETAIL, params))
112 +
113 +/**
114 + * @description: 提交扫码打卡
115 + * @param {Object} params - 请求参数
116 + * @param {string} params.activity_id - 活动ID
117 + * @param {string} params.detail_id - 关卡ID
118 + * @param {string} [params.openid] - 用户openid,接口定义中为可选
119 + * @returns {number} response.code - 响应状态码
120 + * @returns {string} response.msg - 响应消息
121 + */
122 +export const submitScanCheckinAPI = params => fn(fetch.post(Api.SUBMIT_SCAN_CHECKIN, params))
......
1 +<template>
2 + <view :id="containerId" class="rich-text-renderer" v-html="processedContent"></view>
3 +</template>
4 +
5 +<script setup>
6 +import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
7 +import Taro from '@tarojs/taro'
8 +import { $ } from '@tarojs/extend'
9 +
10 +const props = defineProps({
11 + content: {
12 + type: String,
13 + default: '',
14 + },
15 + enableTransform: {
16 + type: Boolean,
17 + default: true,
18 + },
19 +})
20 +
21 +const emit = defineEmits(['image-preview', 'file-click', 'link-copy'])
22 +
23 +const processedContent = ref('')
24 +const containerId = `rich-text-renderer-${Math.random().toString(36).slice(-8)}`
25 +const containerSelector = `#${containerId}`
26 +const previousTransformElement = Taro.options.html?.transformElement
27 +
28 +const decodeHtmlEntities = html => {
29 + if (!html) {
30 + return ''
31 + }
32 +
33 + if (process.env.TARO_ENV === 'h5' && typeof document !== 'undefined') {
34 + try {
35 + const textArea = document.createElement('textarea')
36 + textArea.innerHTML = html
37 + const decoded = textArea.value
38 + if (decoded !== html) {
39 + return decoded
40 + }
41 + } catch (error) {
42 + console.warn('[RichTextRenderer] DOM 解码失败,改用映射表', error)
43 + }
44 + }
45 +
46 + const entityMap = {
47 + '&nbsp;': '\u00A0',
48 + '&amp;': '&',
49 + '&lt;': '<',
50 + '&gt;': '>',
51 + '&quot;': '"',
52 + '&apos;': "'",
53 + '&copy;': '©',
54 + '&reg;': '®',
55 + '&trade;': '™',
56 + '&mdash;': '—',
57 + '&ndash;': '–',
58 + '&hellip;': '…',
59 + '&laquo;': '«',
60 + '&raquo;': '»',
61 + '&lsquo;': '\u2018',
62 + '&rsquo;': '\u2019',
63 + '&ldquo;': '"',
64 + '&rdquo;': '"',
65 + }
66 +
67 + let result = html
68 +
69 + result = result.replace(/&#(\d+);/g, (_match, dec) => String.fromCharCode(dec))
70 + result = result.replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) =>
71 + String.fromCharCode(parseInt(hex, 16))
72 + )
73 +
74 + Object.entries(entityMap).forEach(([entity, char]) => {
75 + result = result.split(entity).join(char)
76 + })
77 +
78 + return result
79 +}
80 +
81 +const replaceAnchorTags = html => {
82 + let content = html
83 + // 小程序 rich-text 对原生 a 标签交互能力有限,统一替换成可绑定事件的块级节点。
84 + content = content.replace(/<a\s+/g, '<div class="rich-text-link" ')
85 + content = content.replace(/href=/g, 'data-href=')
86 + content = content.replace(/<\/a>/g, '</div>')
87 + return content
88 +}
89 +
90 +const processContent = raw => {
91 + if (!raw) {
92 + processedContent.value = ''
93 + return
94 + }
95 +
96 + let processed = raw
97 + processed = decodeHtmlEntities(processed)
98 + processed = replaceAnchorTags(processed)
99 + processedContent.value = processed
100 +}
101 +
102 +const isImageUrl = (url = '') => /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(url)
103 +
104 +const getFileNameFromUrl = (url = '') => {
105 + const cleanUrl = url.split('?')[0]
106 + const segments = cleanUrl.split('/')
107 + return segments[segments.length - 1] || 'document.pdf'
108 +}
109 +
110 +const copyLink = (url, fileName = '链接') => {
111 + Taro.setClipboardData({
112 + data: url,
113 + success: () => {
114 + Taro.showToast({
115 + title: '链接已复制',
116 + icon: 'success',
117 + duration: 2000,
118 + })
119 + emit('link-copy', { url, fileName })
120 + },
121 + fail: error => {
122 + console.error('[RichTextRenderer] 复制链接失败:', error)
123 + Taro.showToast({
124 + title: '复制失败',
125 + icon: 'none',
126 + })
127 + },
128 + })
129 +}
130 +
131 +const openFileLink = async (url, fileName) => {
132 + emit('file-click', { url, fileName })
133 +
134 + if (isImageUrl(url)) {
135 + Taro.previewImage({
136 + urls: [url],
137 + current: url,
138 + indicator: 'default',
139 + loop: false,
140 + })
141 + return
142 + }
143 +
144 + Taro.showLoading({ title: '文件打开中...' })
145 +
146 + try {
147 + const downloadResult = await Taro.downloadFile({
148 + url,
149 + })
150 +
151 + if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) {
152 + throw new Error(`下载失败: ${downloadResult.statusCode}`)
153 + }
154 +
155 + await Taro.openDocument({
156 + filePath: downloadResult.tempFilePath,
157 + showMenu: true,
158 + })
159 + } catch (error) {
160 + console.error('[RichTextRenderer] 文件打开失败:', error)
161 + Taro.showModal({
162 + title: '打开失败',
163 + content: '当前文件无法直接预览,是否复制链接后用其他应用打开?',
164 + confirmText: '复制链接',
165 + cancelText: '取消',
166 + success: res => {
167 + if (res.confirm) {
168 + copyLink(url, fileName)
169 + }
170 + },
171 + })
172 + } finally {
173 + Taro.hideLoading()
174 + }
175 +}
176 +
177 +const setupTransformElement = () => {
178 + if (!props.enableTransform) {
179 + Taro.options.html.transformElement = previousTransformElement
180 + return
181 + }
182 +
183 + Taro.options.html.transformElement = element => {
184 + const transformed = previousTransformElement ? previousTransformElement(element) : element
185 + const nodeName = transformed?.nodeName?.toLowerCase() || ''
186 + const tagName = transformed?.tagName?.toLowerCase() || ''
187 + const isImg =
188 + nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image'
189 +
190 + if (!isImg) {
191 + return transformed
192 + }
193 +
194 + // 统一兜底图片样式,避免后端富文本里的宽高写死后撑坏小程序布局。
195 + if (transformed?.setAttribute) {
196 + transformed.setAttribute('mode', 'widthFix')
197 + transformed.setAttribute('data-rich-image', 'true')
198 + transformed.setAttribute(
199 + 'style',
200 + 'width:100%!important;max-width:100%!important;height:auto!important;display:block;margin:24rpx 0;border-radius:16rpx;'
201 + )
202 + }
203 +
204 + return transformed
205 + }
206 +}
207 +
208 +const bindImageEvents = () => {
209 + nextTick(() => {
210 + const container = $(containerSelector)
211 + const imgs = container.find('.h5-img')
212 +
213 + imgs.forEach(img => {
214 + const $img = $(img)
215 + $img.off('longpress')
216 + $img.on('longpress', () => {
217 + const src = $img.attr('src')
218 +
219 + if (!src) {
220 + return
221 + }
222 +
223 + Taro.previewImage({
224 + urls: [src],
225 + current: src,
226 + indicator: 'default',
227 + loop: false,
228 + success: () => {
229 + emit('image-preview', { src })
230 + },
231 + })
232 + })
233 + })
234 + })
235 +}
236 +
237 +const bindFileLinkEvents = () => {
238 + nextTick(() => {
239 + const container = $(containerSelector)
240 + const richTextLinks = container.find('.rich-text-link')
241 + const fileLinks = container.find('._file_list')
242 +
243 + let allLinks = []
244 + if (richTextLinks.length > 0) {
245 + allLinks = allLinks.concat(richTextLinks.toArray())
246 + }
247 + if (fileLinks.length > 0) {
248 + allLinks = allLinks.concat(fileLinks.toArray())
249 + }
250 +
251 + allLinks.forEach(el => {
252 + const $el = $(el)
253 + const dataHref = $el.attr('data-href') || $el.attr('href')
254 +
255 + if (!dataHref) {
256 + return
257 + }
258 +
259 + $el.off('tap')
260 + $el.on('tap', async () => {
261 + const fileName =
262 + $el.find('span span span').first().text() ||
263 + $el.text().trim().substring(0, 50) ||
264 + getFileNameFromUrl(dataHref)
265 +
266 + await openFileLink(dataHref, fileName)
267 + })
268 + })
269 + })
270 +}
271 +
272 +const bindLinkLongPressEvents = () => {
273 + nextTick(() => {
274 + const container = $(containerSelector)
275 + const richTextLinks = container.find('.rich-text-link')
276 + const anchorLinks = container.find('a[href]')
277 + const fileLinks = container.find('._file_list')
278 +
279 + let allLinks = []
280 + if (richTextLinks.length > 0) {
281 + allLinks = allLinks.concat(richTextLinks.toArray())
282 + }
283 + if (anchorLinks.length > 0) {
284 + allLinks = allLinks.concat(anchorLinks.toArray())
285 + }
286 + if (fileLinks.length > 0) {
287 + allLinks = allLinks.concat(fileLinks.toArray())
288 + }
289 +
290 + allLinks.forEach(el => {
291 + const $el = $(el)
292 + const dataHref = $el.attr('data-href') || $el.attr('href')
293 +
294 + if (!dataHref) {
295 + return
296 + }
297 +
298 + $el.off('longpress')
299 + $el.on('longpress', () => {
300 + const fileName =
301 + $el.find('span span span').first().text() ||
302 + $el.text().trim().substring(0, 30) ||
303 + getFileNameFromUrl(dataHref)
304 +
305 + copyLink(dataHref, fileName)
306 + })
307 + })
308 + })
309 +}
310 +
311 +const handleContentChange = () => {
312 + processContent(props.content)
313 +
314 + nextTick(() => {
315 + // 富文本每次重渲染都会替换节点,需要重新挂载图片预览和链接交互事件。
316 + bindImageEvents()
317 + bindFileLinkEvents()
318 + bindLinkLongPressEvents()
319 + })
320 +}
321 +
322 +watch(() => props.content, handleContentChange, { immediate: true })
323 +watch(() => props.enableTransform, setupTransformElement, { immediate: true })
324 +
325 +onBeforeUnmount(() => {
326 + Taro.options.html.transformElement = previousTransformElement
327 +})
328 +</script>
329 +
330 +<style lang="less">
331 +#rich-text-renderer,
332 +.rich-text-renderer {
333 + color: #4b5563;
334 + font-size: 30rpx;
335 + line-height: 1.8;
336 + word-break: break-word;
337 +
338 + .h5-html,
339 + .h5-address,
340 + .h5-blockquote,
341 + .h5-body,
342 + .h5-dd,
343 + .h5-div,
344 + .h5-dl,
345 + .h5-dt,
346 + .h5-fieldset,
347 + .h5-form,
348 + .h5-frame,
349 + .h5-frameset,
350 + .h5-h1,
351 + .h5-h2,
352 + .h5-h3,
353 + .h5-h4,
354 + .h5-h5,
355 + .h5-h6,
356 + .h5-noframes,
357 + .h5-ol,
358 + .h5-p,
359 + .h5-ul,
360 + .h5-center,
361 + .h5-dir,
362 + .h5-hr,
363 + .h5-menu,
364 + .h5-pre {
365 + display: block;
366 + unicode-bidi: embed;
367 + }
368 +
369 + .h5-li {
370 + display: list-item;
371 + margin-bottom: 12rpx;
372 + }
373 +
374 + .h5-head {
375 + display: none;
376 + }
377 +
378 + .h5-p,
379 + .h5-div,
380 + .h5-blockquote,
381 + .h5-ul,
382 + .h5-ol {
383 + margin: 0 0 20rpx;
384 + }
385 +
386 + .h5-ul,
387 + .h5-ol {
388 + padding-left: 36rpx;
389 + }
390 +
391 + .h5-img,
392 + img {
393 + width: 100%;
394 + max-width: 100%;
395 + height: auto;
396 + display: block;
397 + margin: 24rpx 0;
398 + border-radius: 16rpx;
399 + }
400 +
401 + .rich-text-link,
402 + .h5-a,
403 + a,
404 + ._file_list {
405 + color: #2563eb;
406 + text-decoration: underline;
407 + word-break: break-all;
408 + }
409 +
410 + .rich-text-link *,
411 + ._file_list * {
412 + pointer-events: none;
413 + }
414 +
415 + .h5-b,
416 + .h5-strong {
417 + font-weight: bolder;
418 + }
419 +
420 + .h5-i,
421 + .h5-em {
422 + font-style: italic;
423 + }
424 +
425 + .h5-table {
426 + display: table;
427 + width: 100%;
428 + border-spacing: 2px;
429 + margin: 20rpx 0;
430 + }
431 +
432 + .h5-tr {
433 + display: table-row;
434 + }
435 +
436 + .h5-td,
437 + .h5-th {
438 + display: table-cell;
439 + padding: 12rpx;
440 + border: 1rpx solid #d1d5db;
441 + vertical-align: top;
442 + }
443 +
444 + .h5-th {
445 + font-weight: bolder;
446 + background: #f9fafb;
447 + }
448 +}
449 +</style>
...@@ -179,6 +179,7 @@ import BASE_URL from '@/utils/config' ...@@ -179,6 +179,7 @@ import BASE_URL from '@/utils/config'
179 const defaultAvatar = 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg' 179 const defaultAvatar = 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'
180 // 获取接口信息 180 // 获取接口信息
181 import { updateUserProfileAPI } from '@/api/user' 181 import { updateUserProfileAPI } from '@/api/user'
182 +import { normalizeReturnUrl } from '@/utils/returnUrl'
182 import { buildUpdateUserProfilePayload } from '@/utils/userProfile' 183 import { buildUpdateUserProfilePayload } from '@/utils/userProfile'
183 // 导入主题颜色 184 // 导入主题颜色
184 import { THEME_COLORS } from '@/utils/config' 185 import { THEME_COLORS } from '@/utils/config'
...@@ -203,6 +204,7 @@ const formData = reactive({ ...@@ -203,6 +204,7 @@ const formData = reactive({
203 const registerSourceParams = reactive({ 204 const registerSourceParams = reactive({
204 reg_source: '', 205 reg_source: '',
205 reg_stage_id: '', 206 reg_stage_id: '',
207 + return_url: '',
206 }) 208 })
207 209
208 /** 210 /**
...@@ -399,6 +401,14 @@ const handleSave = async () => { ...@@ -399,6 +401,14 @@ const handleSave = async () => {
399 icon: 'success', 401 icon: 'success',
400 }) 402 })
401 setTimeout(() => { 403 setTimeout(() => {
404 + // 从扫码打卡链路进入补资料页时,保存成功后需要原路回到关卡详情继续扫码。
405 + if (registerSourceParams.return_url) {
406 + Taro.redirectTo({
407 + url: normalizeReturnUrl(registerSourceParams.return_url),
408 + })
409 + return
410 + }
411 +
402 Taro.navigateBack() 412 Taro.navigateBack()
403 }, 1500) 413 }, 1500)
404 return 414 return
...@@ -413,6 +423,7 @@ const handleSave = async () => { ...@@ -413,6 +423,7 @@ const handleSave = async () => {
413 useLoad(options => { 423 useLoad(options => {
414 registerSourceParams.reg_source = options?.reg_source || '' 424 registerSourceParams.reg_source = options?.reg_source || ''
415 registerSourceParams.reg_stage_id = options?.reg_stage_id || '' 425 registerSourceParams.reg_stage_id = options?.reg_stage_id || ''
426 + registerSourceParams.return_url = normalizeReturnUrl(options?.return_url || '')
416 }) 427 })
417 </script> 428 </script>
418 429
......
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
...@@ -10,6 +10,18 @@ ...@@ -10,6 +10,18 @@
10 height: 520rpx; 10 height: 520rpx;
11 } 11 }
12 12
13 +.scan-checkin-detail-cover-swiper {
14 + width: 100%;
15 + height: 100%;
16 +}
17 +
18 +.scan-checkin-detail-cover-swiper :deep(.nut-swiper),
19 +.scan-checkin-detail-cover-swiper :deep(.nut-swiper-inner),
20 +.scan-checkin-detail-cover-swiper :deep(.nut-swiper-item) {
21 + width: 100%;
22 + height: 100%;
23 +}
24 +
13 .scan-checkin-detail-cover-image { 25 .scan-checkin-detail-cover-image {
14 width: 100%; 26 width: 100%;
15 height: 100%; 27 height: 100%;
......
1 <template> 1 <template>
2 <view class="scan-checkin-detail-page"> 2 <view class="scan-checkin-detail-page">
3 <view class="scan-checkin-detail-cover"> 3 <view class="scan-checkin-detail-cover">
4 - <image class="scan-checkin-detail-cover-image" :src="detail.cover" mode="aspectFill" /> 4 + <nut-swiper
5 + class="scan-checkin-detail-cover-swiper"
6 + :init-page="0"
7 + :pagination-visible="detail.banners.length > 1"
8 + pagination-color="#ffffff"
9 + pagination-unselected-color="rgba(255, 255, 255, 0.5)"
10 + :loop="detail.banners.length > 1"
11 + :auto-play="detail.banners.length > 1 ? 3000 : 0"
12 + >
13 + <nut-swiper-item v-for="(banner, index) in detail.banners" :key="`${banner}-${index}`">
14 + <image class="scan-checkin-detail-cover-image" :src="banner" mode="aspectFill" />
15 + </nut-swiper-item>
16 + </nut-swiper>
5 </view> 17 </view>
6 18
7 <view class="scan-checkin-detail-card"> 19 <view class="scan-checkin-detail-card">
8 <view class="scan-checkin-detail-heading"> 20 <view class="scan-checkin-detail-heading">
9 - <text class="scan-checkin-detail-title">{{ detail.code }} {{ detail.title }}</text> 21 + <text class="scan-checkin-detail-title">{{ detail.title }}</text>
10 <view class="scan-checkin-detail-status" :class="detail.isChecked ? 'done' : 'pending'"> 22 <view class="scan-checkin-detail-status" :class="detail.isChecked ? 'done' : 'pending'">
11 {{ detail.isChecked ? '已打卡' : '未打卡' }} 23 {{ detail.isChecked ? '已打卡' : '未打卡' }}
12 </view> 24 </view>
...@@ -19,7 +31,7 @@ ...@@ -19,7 +31,7 @@
19 <text class="scan-checkin-detail-section-title">{{ detail.discountTitle }}</text> 31 <text class="scan-checkin-detail-section-title">{{ detail.discountTitle }}</text>
20 </view> 32 </view>
21 <view class="scan-checkin-detail-content"> 33 <view class="scan-checkin-detail-content">
22 - <rich-text class="scan-checkin-detail-rich-text" :nodes="formattedDiscountContent" /> 34 + <RichTextRenderer :content="normalizedRichTextContent" />
23 </view> 35 </view>
24 </view> 36 </view>
25 </view> 37 </view>
...@@ -42,16 +54,31 @@ ...@@ -42,16 +54,31 @@
42 import { reactive, computed } from 'vue' 54 import { reactive, computed } from 'vue'
43 import Taro, { useLoad } from '@tarojs/taro' 55 import Taro, { useLoad } from '@tarojs/taro'
44 import './index.less' 56 import './index.less'
45 -import { getMockScanCheckinDetail } from '@/utils/mockQrCheckin' 57 +import RichTextRenderer from '@/components/RichTextRenderer.vue'
58 +import { getCurrentPageFullPath } from '@/utils/authRedirect'
59 +import { getMyFamiliesAPI } from '@/api/family'
60 +import { getScanStageDetailAPI, submitScanCheckinAPI } from '@/api/map'
61 +import { verifyCheckinRangeWithCurrentLocation } from '@/utils/checkinLocation'
62 +import { parseScanCheckinParams } from '@/utils/scanCheckin'
63 +
64 +const defaultCover =
65 + 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60'
46 66
47 const detail = reactive({ 67 const detail = reactive({
68 + activityId: '',
48 id: '', 69 id: '',
49 - code: 'W2D01', 70 + regSource: '',
50 - title: '泰康之家经营管理有限公司上海分公司', 71 + regStageId: '',
51 - guideText: '在点位现场扫码打卡并推荐好物', 72 + returnUrl: '',
73 + banners: [defaultCover],
74 + title: '',
75 + guideText: '',
52 discountTitle: '打卡点专属优惠', 76 discountTitle: '打卡点专属优惠',
53 discountContentRaw: '', 77 discountContentRaw: '',
54 - cover: 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60', 78 + geoEnabled: false,
79 + centerLng: null,
80 + centerLat: null,
81 + radiusMeters: null,
55 isChecked: false, 82 isChecked: false,
56 lastScanCode: '', 83 lastScanCode: '',
57 scanSubmitting: false, 84 scanSubmitting: false,
...@@ -59,7 +86,7 @@ const detail = reactive({ ...@@ -59,7 +86,7 @@ const detail = reactive({
59 86
60 const scanSubmitting = computed(() => detail.scanSubmitting === true) 87 const scanSubmitting = computed(() => detail.scanSubmitting === true)
61 88
62 -const formattedDiscountContent = computed(() => { 89 +const normalizedRichTextContent = computed(() => {
63 const content = detail.discountContentRaw 90 const content = detail.discountContentRaw
64 91
65 if (!content) { 92 if (!content) {
...@@ -86,17 +113,30 @@ const formattedDiscountContent = computed(() => { ...@@ -86,17 +113,30 @@ const formattedDiscountContent = computed(() => {
86 return formattedContent 113 return formattedContent
87 }) 114 })
88 115
89 -const mockSubmitScanCode = async code => { 116 +const navigateToWelcome = () => {
90 - await new Promise(resolve => { 117 + // 扫码入口沿用首页广告位的判断路线:
91 - setTimeout(resolve, 500) 118 + // 先判断是否有家庭,没有则先去 Welcome,再由 Welcome 决定补资料/创建家庭/加入家庭。
119 + const params = new URLSearchParams({
120 + reg_source: detail.regSource,
121 + reg_stage_id: detail.regStageId,
122 + return_url: detail.returnUrl,
123 + })
124 +
125 + Taro.redirectTo({
126 + url: `/pages/Welcome/index?${params.toString()}`,
127 + })
128 +}
129 +
130 +const promptNavigateToWelcome = async () => {
131 + const modalResult = await Taro.showModal({
132 + title: '温馨提示',
133 + content: '扫码打卡需要先完善个人信息并加入家庭,完成后才能继续参与。',
134 + confirmText: '去完善',
135 + cancelText: '取消',
92 }) 136 })
93 137
94 - return { 138 + if (modalResult.confirm) {
95 - code: 1, 139 + navigateToWelcome()
96 - msg: '打卡成功',
97 - data: {
98 - scan_code: code,
99 - },
100 } 140 }
101 } 141 }
102 142
...@@ -104,6 +144,39 @@ const handleScanCheckin = async () => { ...@@ -104,6 +144,39 @@ const handleScanCheckin = async () => {
104 detail.scanSubmitting = true 144 detail.scanSubmitting = true
105 145
106 try { 146 try {
147 + const familyResult = await getMyFamiliesAPI()
148 + const hasFamily = familyResult?.code && familyResult?.data?.families?.length > 0
149 +
150 + if (!hasFamily) {
151 + detail.scanSubmitting = false
152 + await promptNavigateToWelcome()
153 + return
154 + }
155 +
156 + // 点击扫码时重新静默拉一次当前位置,避免依赖进入页面时的旧定位缓存。
157 + const rangeCheck = await verifyCheckinRangeWithCurrentLocation({
158 + geoEnabled: detail.geoEnabled,
159 + centerLng: detail.centerLng,
160 + centerLat: detail.centerLat,
161 + radiusMeters: detail.radiusMeters,
162 + })
163 +
164 + if (!rangeCheck.allowed) {
165 + detail.scanSubmitting = false
166 +
167 + const outOfRangeTitle =
168 + rangeCheck.reason === 'out_of_range'
169 + ? '未在打卡范围内,请前往指定位置后再扫码打卡'
170 + : '获取当前位置失败,请确认定位权限后重试'
171 +
172 + Taro.showToast({
173 + title: outOfRangeTitle,
174 + icon: 'none',
175 + duration: 3000,
176 + })
177 + return
178 + }
179 +
107 const scanResult = await Taro.scanCode({ 180 const scanResult = await Taro.scanCode({
108 onlyFromCamera: false, 181 onlyFromCamera: false,
109 scanType: ['qrCode', 'barCode'], 182 scanType: ['qrCode', 'barCode'],
...@@ -119,17 +192,42 @@ const handleScanCheckin = async () => { ...@@ -119,17 +192,42 @@ const handleScanCheckin = async () => {
119 return 192 return
120 } 193 }
121 194
122 - const submitResult = await mockSubmitScanCode(scannedCode) 195 + // 打卡提交参数以二维码里携带的内容为准,当前页面参数只用于展示和后续列表跳转。
196 + const parsedScanParams = parseScanCheckinParams(scannedCode)
197 + const submitActivityId = parsedScanParams.activityId
198 + const submitDetailId = parsedScanParams.detailId
199 +
200 + if (!submitActivityId || !submitDetailId) {
201 + Taro.showToast({
202 + title: '二维码缺少打卡参数',
203 + icon: 'none',
204 + })
205 + return
206 + }
207 +
208 + const submitResult = await submitScanCheckinAPI({
209 + activity_id: submitActivityId,
210 + detail_id: submitDetailId,
211 + })
123 212
124 if (submitResult.code === 1) { 213 if (submitResult.code === 1) {
125 detail.isChecked = true 214 detail.isChecked = true
126 detail.lastScanCode = scannedCode 215 detail.lastScanCode = scannedCode
127 216
128 await Taro.showModal({ 217 await Taro.showModal({
129 - title: '模拟提交成功', 218 + title: '打卡成功',
130 - content: `扫码结果:${scannedCode}`, 219 + content: '您已完成当前扫码打卡,可前往列表查看状态。',
131 showCancel: false, 220 showCancel: false,
132 - confirmText: '知道了', 221 + confirmText: '查看列表',
222 + })
223 +
224 + const params = new URLSearchParams({
225 + activityId: detail.activityId,
226 + })
227 +
228 + // 扫码打卡完成后回列表页,方便用户继续处理同一活动下的其他关卡。
229 + Taro.redirectTo({
230 + url: `/pages/ScanCheckinList/index?${params.toString()}`,
133 }) 231 })
134 return 232 return
135 } 233 }
...@@ -153,30 +251,55 @@ const handleScanCheckin = async () => { ...@@ -153,30 +251,55 @@ const handleScanCheckin = async () => {
153 } 251 }
154 } 252 }
155 253
156 -const handleMockDataLoaded = mockDetail => { 254 +const applyStageDetail = stageDetail => {
157 detail.scanSubmitting = false 255 detail.scanSubmitting = false
158 256
257 + // 这里把接口字段统一映射成页面内部字段,避免模板层直接耦合后端命名。
159 Object.assign(detail, { 258 Object.assign(detail, {
160 - ...mockDetail, 259 + id: stageDetail.id || '',
161 - discountContentRaw: mockDetail.discountContent, 260 + banners:
162 - isChecked: mockDetail.status === '已打卡', 261 + Array.isArray(stageDetail.banner) && stageDetail.banner.length > 0
262 + ? stageDetail.banner
263 + : [defaultCover],
264 + title: stageDetail.title || '',
265 + guideText: stageDetail.note || '',
266 + discountTitle: stageDetail.discount_title || '打卡点专属优惠',
267 + discountContentRaw: stageDetail.introduction || '',
268 + geoEnabled: stageDetail.geo_enabled === true,
269 + centerLng: stageDetail.center_lng,
270 + centerLat: stageDetail.center_lat,
271 + radiusMeters: stageDetail.radius_meters,
272 + isChecked: stageDetail.is_checked === true,
163 }) 273 })
164 } 274 }
165 275
166 useLoad(options => { 276 useLoad(options => {
277 + detail.activityId = options.activityId || options.activity_id || ''
167 const detailId = options.detailId || options.id || '' 278 const detailId = options.detailId || options.id || ''
168 - const mockDetail = getMockScanCheckinDetail(detailId) 279 + detail.regSource = options.reg_source || ''
280 + detail.regStageId = options.reg_stage_id || ''
281 + // 当前页路径会透传给补资料页,提交成功后用于回跳续扫。
282 + detail.returnUrl = `/${getCurrentPageFullPath()}`
283 + detail.id = detailId
284 +
285 + loadStageDetail()
286 +})
169 287
170 - if (!mockDetail) { 288 +const loadStageDetail = async () => {
289 + const result = await getScanStageDetailAPI({
290 + id: detail.id,
291 + })
292 +
293 + if (result?.code !== 1 || !result?.data) {
171 Taro.showToast({ 294 Taro.showToast({
172 - title: '未找到打卡点', 295 + title: result?.msg || '获取关卡详情失败',
173 icon: 'none', 296 icon: 'none',
174 }) 297 })
175 return 298 return
176 } 299 }
177 300
178 - handleMockDataLoaded(mockDetail) 301 + applyStageDetail(result.data)
179 -}) 302 +}
180 </script> 303 </script>
181 304
182 <script> 305 <script>
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
17 </view> 17 </view>
18 18
19 <view class="scan-checkin-list-content"> 19 <view class="scan-checkin-list-content">
20 - <text class="scan-checkin-list-name">{{ point.code }} {{ point.title }}</text> 20 + <text class="scan-checkin-list-name">{{ point.title }}</text>
21 </view> 21 </view>
22 22
23 <view class="scan-checkin-list-action" @click="goToDetail(point)"> 23 <view class="scan-checkin-list-action" @click="goToDetail(point)">
...@@ -41,7 +41,7 @@ import { ref } from 'vue' ...@@ -41,7 +41,7 @@ import { ref } from 'vue'
41 import Taro, { useLoad } from '@tarojs/taro' 41 import Taro, { useLoad } from '@tarojs/taro'
42 import { IconFont, Scan2 } from '@nutui/icons-vue-taro' 42 import { IconFont, Scan2 } from '@nutui/icons-vue-taro'
43 import './index.less' 43 import './index.less'
44 -import { getMockScanCheckinPoints } from '@/utils/mockQrCheckin' 44 +import { getScanStageListAPI } from '@/api/map'
45 45
46 const pointList = ref([]) 46 const pointList = ref([])
47 const activityId = ref('') 47 const activityId = ref('')
...@@ -70,8 +70,29 @@ const handleShowBoothMap = () => { ...@@ -70,8 +70,29 @@ const handleShowBoothMap = () => {
70 70
71 useLoad(options => { 71 useLoad(options => {
72 activityId.value = options.activityId || options.id || '' 72 activityId.value = options.activityId || options.id || ''
73 - pointList.value = getMockScanCheckinPoints(activityId.value) 73 +
74 + loadStageList()
74 }) 75 })
76 +
77 +const loadStageList = async () => {
78 + const result = await getScanStageListAPI({
79 + activity_id: activityId.value,
80 + })
81 +
82 + if (result?.code !== 1) {
83 + Taro.showToast({
84 + title: result?.msg || '获取关卡列表失败',
85 + icon: 'none',
86 + })
87 + return
88 + }
89 +
90 + pointList.value = (result?.data?.stages || []).map(stage => ({
91 + id: stage.id,
92 + title: stage.title,
93 + isChecked: stage.is_checked,
94 + }))
95 +}
75 </script> 96 </script>
76 97
77 <script> 98 <script>
......
This diff is collapsed. Click to expand it.
1 +import Taro from '@tarojs/taro'
2 +
3 +const toRadians = degree => (degree * Math.PI) / 180
4 +
5 +/**
6 + * @description 计算两个经纬度之间的距离(米)
7 + * @param {Object} start
8 + * @param {number|string} start.lng
9 + * @param {number|string} start.lat
10 + * @param {Object} end
11 + * @param {number|string} end.lng
12 + * @param {number|string} end.lat
13 + * @returns {number}
14 + */
15 +export const calculateDistanceMeters = (start, end) => {
16 + const startLng = Number(start?.lng)
17 + const startLat = Number(start?.lat)
18 + const endLng = Number(end?.lng)
19 + const endLat = Number(end?.lat)
20 +
21 + if (![startLng, startLat, endLng, endLat].every(Number.isFinite)) {
22 + return NaN
23 + }
24 +
25 + const earthRadius = 6371000
26 + const deltaLat = toRadians(endLat - startLat)
27 + const deltaLng = toRadians(endLng - startLng)
28 +
29 + const a =
30 + Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
31 + Math.cos(toRadians(startLat)) *
32 + Math.cos(toRadians(endLat)) *
33 + Math.sin(deltaLng / 2) *
34 + Math.sin(deltaLng / 2)
35 +
36 + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
37 +
38 + return earthRadius * c
39 +}
40 +
41 +/**
42 + * @description 判断当前位置是否在扫码打卡允许范围内
43 + * @param {Object} params
44 + * @param {boolean} params.geoEnabled
45 + * @param {number|string} params.userLng
46 + * @param {number|string} params.userLat
47 + * @param {number|string} params.centerLng
48 + * @param {number|string} params.centerLat
49 + * @param {number|string} params.radiusMeters
50 + * @returns {{allowed:boolean,distance:number,reason:string}}
51 + */
52 +export const checkCheckinRange = (params = {}) => {
53 + const { geoEnabled, userLng, userLat, centerLng, centerLat, radiusMeters } = params
54 +
55 + if (geoEnabled !== true) {
56 + return {
57 + allowed: true,
58 + distance: 0,
59 + reason: 'geo_disabled',
60 + }
61 + }
62 +
63 + const distance = calculateDistanceMeters(
64 + { lng: userLng, lat: userLat },
65 + { lng: centerLng, lat: centerLat }
66 + )
67 +
68 + if (!Number.isFinite(distance)) {
69 + return {
70 + allowed: false,
71 + distance: NaN,
72 + reason: 'invalid_location',
73 + }
74 + }
75 +
76 + const rangeLimit = Number(radiusMeters)
77 +
78 + if (!Number.isFinite(rangeLimit) || rangeLimit < 0) {
79 + return {
80 + allowed: false,
81 + distance,
82 + reason: 'invalid_radius',
83 + }
84 + }
85 +
86 + return {
87 + allowed: distance <= rangeLimit,
88 + distance,
89 + reason: distance <= rangeLimit ? 'within_range' : 'out_of_range',
90 + }
91 +}
92 +
93 +/**
94 + * @description 静默获取当前位置并校验是否在扫码打卡范围内
95 + * @param {Object} params
96 + * @param {boolean} params.geoEnabled
97 + * @param {number|string} params.centerLng
98 + * @param {number|string} params.centerLat
99 + * @param {number|string} params.radiusMeters
100 + * @returns {Promise<{allowed:boolean,distance:number,reason:string,location?:{lng:number,lat:number}}>}
101 + */
102 +export const verifyCheckinRangeWithCurrentLocation = async (params = {}) => {
103 + const { geoEnabled, centerLng, centerLat, radiusMeters } = params
104 +
105 + // 未开启地理围栏时直接放行,避免页面层重复写同样的短路判断。
106 + if (geoEnabled !== true) {
107 + return {
108 + allowed: true,
109 + distance: 0,
110 + reason: 'geo_disabled',
111 + }
112 + }
113 +
114 + try {
115 + // 重新拉取当前位置而不是复用缓存,确保扫码瞬间的位置判断更接近真实场景。
116 + const location = await Taro.getLocation({
117 + type: 'gcj02',
118 + altitude: false,
119 + isHighAccuracy: true,
120 + highAccuracyExpireTime: 4000,
121 + })
122 +
123 + const normalizedLocation = {
124 + lng: location.longitude,
125 + lat: location.latitude,
126 + }
127 +
128 + const rangeResult = checkCheckinRange({
129 + geoEnabled,
130 + userLng: normalizedLocation.lng,
131 + userLat: normalizedLocation.lat,
132 + centerLng,
133 + centerLat,
134 + radiusMeters,
135 + })
136 +
137 + return {
138 + ...rangeResult,
139 + location: normalizedLocation,
140 + }
141 + } catch (error) {
142 + console.error('获取扫码打卡位置失败:', error)
143 + return {
144 + allowed: false,
145 + distance: NaN,
146 + reason: 'location_fetch_failed',
147 + }
148 + }
149 +}
1 +/**
2 + * @description 对 return_url 做安全解码,兼容被重复 encode 的情况
3 + * @param {string} value
4 + * @returns {string}
5 + */
6 +export const normalizeReturnUrl = (value = '') => {
7 + let normalized = String(value || '').trim()
8 +
9 + if (!normalized) {
10 + return ''
11 + }
12 +
13 + let previousValue = ''
14 +
15 + while (normalized && normalized !== previousValue) {
16 + previousValue = normalized
17 +
18 + try {
19 + normalized = decodeURIComponent(normalized)
20 + } catch (error) {
21 + break
22 + }
23 + }
24 +
25 + if (!normalized.startsWith('/')) {
26 + normalized = `/${normalized.replace(/^\/+/, '')}`
27 + }
28 +
29 + return normalized
30 +}
31 +
32 +/**
33 + * @description 给页面地址追加 return_url 参数
34 + * @param {string} url
35 + * @param {string} returnUrl
36 + * @returns {string}
37 + */
38 +export const appendReturnUrlParam = (url, returnUrl = '') => {
39 + const normalizedReturnUrl = normalizeReturnUrl(returnUrl)
40 +
41 + if (!normalizedReturnUrl) {
42 + return url
43 + }
44 +
45 + const separator = url.includes('?') ? '&' : '?'
46 + return `${url}${separator}return_url=${encodeURIComponent(normalizedReturnUrl)}`
47 +}
1 +/**
2 + * @description 从扫码结果中提取打卡接口所需参数
3 + * @param {string} rawScanResult - 微信扫码返回的原始结果,可能是完整URL,也可能是纯查询串
4 + * @returns {{activityId:string, detailId:string, rawParams:Object}}
5 + */
6 +export const parseScanCheckinParams = (rawScanResult = '') => {
7 + const normalized = String(rawScanResult || '').trim()
8 +
9 + if (!normalized) {
10 + return {
11 + activityId: '',
12 + detailId: '',
13 + rawParams: {},
14 + }
15 + }
16 +
17 + const querySource = extractQuerySource(normalized)
18 + const rawParams = parseQueryString(querySource)
19 +
20 + return {
21 + activityId: pickFirstAvailableValue(rawParams, ['activity_id', 'activityId', 'id']),
22 + detailId: pickFirstAvailableValue(rawParams, ['detail_id', 'detailId', 'stage_id', 'stageId']),
23 + rawParams,
24 + }
25 +}
26 +
27 +const extractQuerySource = input => {
28 + const questionMarkIndex = input.indexOf('?')
29 +
30 + if (questionMarkIndex >= 0) {
31 + return input.slice(questionMarkIndex + 1)
32 + }
33 +
34 + return input
35 +}
36 +
37 +const parseQueryString = (queryString = '') => {
38 + const hashRemoved = String(queryString || '').split('#')[0]
39 + const pairs = hashRemoved.split('&').filter(Boolean)
40 +
41 + return pairs.reduce((result, pair) => {
42 + const [rawKey, ...rest] = pair.split('=')
43 + const rawValue = rest.join('=')
44 + const key = safeDecodeURIComponent(rawKey)
45 + const value = safeDecodeURIComponent(rawValue)
46 +
47 + if (key) {
48 + result[key] = value
49 + }
50 +
51 + return result
52 + }, {})
53 +}
54 +
55 +const pickFirstAvailableValue = (params, keys = []) => {
56 + for (const key of keys) {
57 + const value = params?.[key]
58 + if (value !== '' && value !== undefined && value !== null) {
59 + return String(value)
60 + }
61 + }
62 +
63 + return ''
64 +}
65 +
66 +const safeDecodeURIComponent = (value = '') => {
67 + try {
68 + return decodeURIComponent(String(value || '').replace(/\+/g, '%20'))
69 + } catch (error) {
70 + return String(value || '')
71 + }
72 +}
...@@ -25,9 +25,28 @@ export const buildUpdateUserProfilePayload = (formData, extraParams = {}) => { ...@@ -25,9 +25,28 @@ export const buildUpdateUserProfilePayload = (formData, extraParams = {}) => {
25 optionalKeys.forEach(key => { 25 optionalKeys.forEach(key => {
26 const value = extraParams[key] 26 const value = extraParams[key]
27 if (value !== '' && value !== undefined && value !== null) { 27 if (value !== '' && value !== undefined && value !== null) {
28 + // reg_stage_id 需要保持数值类型,其他来源字段按原样透传即可。
28 payload[key] = key === 'reg_stage_id' ? Number(value) : value 29 payload[key] = key === 'reg_stage_id' ? Number(value) : value
29 } 30 }
30 }) 31 })
31 32
32 return payload 33 return payload
33 } 34 }
35 +
36 +/**
37 + * @description 判断用户是否已完善扫码打卡所需的个人资料
38 + * @param {Object} user - 用户资料
39 + * @returns {boolean} 是否已完善
40 + */
41 +export const isUserProfileComplete = (user = {}) => {
42 + // 扫码打卡只关心能否继续后续流程所需的关键资料是否齐全,
43 + // 不把 session、年龄限制等页面外规则混进这里。
44 + return Boolean(
45 + user?.nickname &&
46 + user?.birth_date &&
47 + user?.gender !== null &&
48 + user?.gender !== undefined &&
49 + user?.wheelchair_needed !== null &&
50 + user?.wheelchair_needed !== undefined
51 + )
52 +}
......