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>
Showing
16 changed files
with
1012 additions
and
39 deletions
| ... | @@ -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)) | ... | ... |
src/components/RichTextRenderer.vue
0 → 100644
| 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 | + ' ': '\u00A0', | ||
| 48 | + '&': '&', | ||
| 49 | + '<': '<', | ||
| 50 | + '>': '>', | ||
| 51 | + '"': '"', | ||
| 52 | + ''': "'", | ||
| 53 | + '©': '©', | ||
| 54 | + '®': '®', | ||
| 55 | + '™': '™', | ||
| 56 | + '—': '—', | ||
| 57 | + '–': '–', | ||
| 58 | + '…': '…', | ||
| 59 | + '«': '«', | ||
| 60 | + '»': '»', | ||
| 61 | + '‘': '\u2018', | ||
| 62 | + '’': '\u2019', | ||
| 63 | + '“': '"', | ||
| 64 | + '”': '"', | ||
| 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()}`, | ||
| 92 | }) | 127 | }) |
| 128 | +} | ||
| 93 | 129 | ||
| 94 | - return { | 130 | +const promptNavigateToWelcome = async () => { |
| 95 | - code: 1, | 131 | + const modalResult = await Taro.showModal({ |
| 96 | - msg: '打卡成功', | 132 | + title: '温馨提示', |
| 97 | - data: { | 133 | + content: '扫码打卡需要先完善个人信息并加入家庭,完成后才能继续参与。', |
| 98 | - scan_code: code, | 134 | + confirmText: '去完善', |
| 99 | - }, | 135 | + cancelText: '取消', |
| 136 | + }) | ||
| 137 | + | ||
| 138 | + if (modalResult.confirm) { | ||
| 139 | + navigateToWelcome() | ||
| 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 | +}) | ||
| 287 | + | ||
| 288 | +const loadStageDetail = async () => { | ||
| 289 | + const result = await getScanStageDetailAPI({ | ||
| 290 | + id: detail.id, | ||
| 291 | + }) | ||
| 169 | 292 | ||
| 170 | - if (!mockDetail) { | 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.
src/utils/checkinLocation.js
0 → 100644
| 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 | +} |
src/utils/returnUrl.js
0 → 100644
| 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 | +} |
src/utils/scanCheckin.js
0 → 100644
| 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 | +} | ... | ... |
-
Please register or login to post a comment