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
1570 additions
and
435 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 | ... | ... |
| ... | @@ -11,7 +11,10 @@ | ... | @@ -11,7 +11,10 @@ |
| 11 | <view class="flex-1 px-4 py-6 overflow-auto"> | 11 | <view class="flex-1 px-4 py-6 overflow-auto"> |
| 12 | <view class="mb-6"> | 12 | <view class="mb-6"> |
| 13 | <view class="text-gray-600 mb-6 flex items-center"> | 13 | <view class="text-gray-600 mb-6 flex items-center"> |
| 14 | - <IconFont size="20" name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD1.png" /> | 14 | + <IconFont |
| 15 | + size="20" | ||
| 16 | + name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD1.png" | ||
| 17 | + /> | ||
| 15 | <text class="ml-1">请填写家庭信息</text> | 18 | <text class="ml-1">请填写家庭信息</text> |
| 16 | </view> | 19 | </view> |
| 17 | <!-- Family Name --> | 20 | <!-- Family Name --> |
| ... | @@ -25,7 +28,9 @@ | ... | @@ -25,7 +28,9 @@ |
| 25 | placeholder="请输入家庭名称(最多10个字)" | 28 | placeholder="请输入家庭名称(最多10个字)" |
| 26 | @blur="validateFamilyName" | 29 | @blur="validateFamilyName" |
| 27 | /> | 30 | /> |
| 28 | - <view v-if="familyNameError" class="text-red-500 text-sm mt-2">{{ familyNameError }}</view> | 31 | + <view v-if="familyNameError" class="text-red-500 text-sm mt-2">{{ |
| 32 | + familyNameError | ||
| 33 | + }}</view> | ||
| 29 | </view> | 34 | </view> |
| 30 | </view> | 35 | </view> |
| 31 | <!-- Family Introduction --> | 36 | <!-- Family Introduction --> |
| ... | @@ -39,18 +44,33 @@ | ... | @@ -39,18 +44,33 @@ |
| 39 | :rows="2" | 44 | :rows="2" |
| 40 | @blur="validateFamilyIntro" | 45 | @blur="validateFamilyIntro" |
| 41 | /> | 46 | /> |
| 42 | - <view v-if="familyIntroError" class="text-red-500 text-sm mt-2">{{ familyIntroError }}</view> | 47 | + <view v-if="familyIntroError" class="text-red-500 text-sm mt-2">{{ |
| 48 | + familyIntroError | ||
| 49 | + }}</view> | ||
| 43 | </view> | 50 | </view> |
| 44 | </view> | 51 | </view> |
| 45 | <!-- District Selection --> | 52 | <!-- District Selection --> |
| 46 | <view class="mb-6"> | 53 | <view class="mb-6"> |
| 47 | <view class="bg-white rounded-lg border border-gray-200 p-4"> | 54 | <view class="bg-white rounded-lg border border-gray-200 p-4"> |
| 48 | <view class="block text-lg font-medium mb-4">家庭所在行政区</view> | 55 | <view class="block text-lg font-medium mb-4">家庭所在行政区</view> |
| 49 | - <view class="bg-white rounded-xl p-4 border border-gray-200" @click="showDistrictPicker = true"> | 56 | + <view |
| 57 | + class="bg-white rounded-xl p-4 border border-gray-200" | ||
| 58 | + @click="showDistrictPicker = true" | ||
| 59 | + > | ||
| 50 | <view class="flex justify-between items-center"> | 60 | <view class="flex justify-between items-center"> |
| 51 | <view> | 61 | <view> |
| 52 | - <IconFont size="15" name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD2.png" class="mr-2" /> | 62 | + <IconFont |
| 53 | - <text :class="{'text-gray-400': !selectedDistrictText, 'text-gray-900': selectedDistrictText}" class="text-base"> | 63 | + size="15" |
| 64 | + name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD2.png" | ||
| 65 | + class="mr-2" | ||
| 66 | + /> | ||
| 67 | + <text | ||
| 68 | + :class="{ | ||
| 69 | + 'text-gray-400': !selectedDistrictText, | ||
| 70 | + 'text-gray-900': selectedDistrictText, | ||
| 71 | + }" | ||
| 72 | + class="text-base" | ||
| 73 | + > | ||
| 54 | {{ selectedDistrictText || '请选择区域' }} | 74 | {{ selectedDistrictText || '请选择区域' }} |
| 55 | </text> | 75 | </text> |
| 56 | </view> | 76 | </view> |
| ... | @@ -73,18 +93,20 @@ | ... | @@ -73,18 +93,20 @@ |
| 73 | </view> | 93 | </view> |
| 74 | <view class="flex gap-2 mb-4"> | 94 | <view class="flex gap-2 mb-4"> |
| 75 | <view v-for="(char, index) in familyMotto" :key="index" class="flex-1"> | 95 | <view v-for="(char, index) in familyMotto" :key="index" class="flex-1"> |
| 76 | - <view class="w-full aspect-square flex items-center justify-center bg-gray-100 rounded-lg"> | 96 | + <view |
| 97 | + class="w-full aspect-square flex items-center justify-center bg-gray-100 rounded-lg" | ||
| 98 | + > | ||
| 77 | <input | 99 | <input |
| 78 | - :ref="(el) => (inputRefs[index] = el)" | 100 | + :ref="el => (inputRefs[index] = el)" |
| 79 | type="text" | 101 | type="text" |
| 80 | v-model="familyMotto[index]" | 102 | v-model="familyMotto[index]" |
| 81 | :placeholder="familyMottoPlaceholder[index]" | 103 | :placeholder="familyMottoPlaceholder[index]" |
| 82 | placeholder-style="color: var(--secondary-color-text);" | 104 | placeholder-style="color: var(--secondary-color-text);" |
| 83 | - @input="(e) => handleInputChange(index, e.target.value)" | 105 | + @input="e => handleInputChange(index, e.target.value)" |
| 84 | @focus="focusedIndex = index" | 106 | @focus="focusedIndex = index" |
| 85 | @blur="handleBlur(index)" | 107 | @blur="handleBlur(index)" |
| 86 | class="w-full h-full bg-transparent text-center" | 108 | class="w-full h-full bg-transparent text-center" |
| 87 | - style="font-size: 38rpx;" | 109 | + style="font-size: 38rpx" |
| 88 | :cursorSpacing="100" | 110 | :cursorSpacing="100" |
| 89 | /> | 111 | /> |
| 90 | </view> | 112 | </view> |
| ... | @@ -97,7 +119,10 @@ | ... | @@ -97,7 +119,10 @@ |
| 97 | </view> | 119 | </view> |
| 98 | <view class="flex items-center text-sm text-gray-600"> | 120 | <view class="flex items-center text-sm text-gray-600"> |
| 99 | <view> | 121 | <view> |
| 100 | - <IconFont size="15" name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD3.png" /> | 122 | + <IconFont |
| 123 | + size="15" | ||
| 124 | + name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD3.png" | ||
| 125 | + /> | ||
| 101 | <text class="ml-1">设置有意义的家训口令,便于家人记忆和加入</text> | 126 | <text class="ml-1">设置有意义的家训口令,便于家人记忆和加入</text> |
| 102 | </view> | 127 | </view> |
| 103 | </view> | 128 | </view> |
| ... | @@ -106,9 +131,7 @@ | ... | @@ -106,9 +131,7 @@ |
| 106 | <!-- Family Cover --> | 131 | <!-- Family Cover --> |
| 107 | <view class="mb-10"> | 132 | <view class="mb-10"> |
| 108 | <view class="bg-white rounded-lg border border-gray-200 p-4"> | 133 | <view class="bg-white rounded-lg border border-gray-200 p-4"> |
| 109 | - <view class="block text-lg font-medium mb-2"> | 134 | + <view class="block text-lg font-medium mb-2"> 家庭封面图(选填) </view> |
| 110 | - 家庭封面图(选填) | ||
| 111 | - </view> | ||
| 112 | <!-- 封面显示区域 --> | 135 | <!-- 封面显示区域 --> |
| 113 | <view class="mb-4"> | 136 | <view class="mb-4"> |
| 114 | <view class="relative bg-gray-100 rounded-lg h-48 flex items-center justify-center"> | 137 | <view class="relative bg-gray-100 rounded-lg h-48 flex items-center justify-center"> |
| ... | @@ -121,15 +144,24 @@ | ... | @@ -121,15 +144,24 @@ |
| 121 | @tap="previewAvatar" | 144 | @tap="previewAvatar" |
| 122 | /> | 145 | /> |
| 123 | <!-- 没有图片时显示上传提示 --> | 146 | <!-- 没有图片时显示上传提示 --> |
| 124 | - <view v-else class="flex flex-col items-center justify-center text-gray-400" @click="chooseImage"> | 147 | + <view |
| 125 | - <IconFont size="48" class="mb-2" name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD4.png" /> | 148 | + v-else |
| 149 | + class="flex flex-col items-center justify-center text-gray-400" | ||
| 150 | + @click="chooseImage" | ||
| 151 | + > | ||
| 152 | + <IconFont | ||
| 153 | + size="48" | ||
| 154 | + class="mb-2" | ||
| 155 | + name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD4.png" | ||
| 156 | + /> | ||
| 126 | <text class="text-sm">点击上传封面图</text> | 157 | <text class="text-sm">点击上传封面图</text> |
| 127 | </view> | 158 | </view> |
| 128 | <!-- 删除按钮 --> | 159 | <!-- 删除按钮 --> |
| 129 | <view | 160 | <view |
| 130 | v-if="familyAvatar" | 161 | v-if="familyAvatar" |
| 131 | @click.stop="deleteAvatar" | 162 | @click.stop="deleteAvatar" |
| 132 | - class="absolute -top-2 -right-2 w-5 bg-red-500 rounded-full flex items-center justify-center" style="height: 41rpx;" | 163 | + class="absolute -top-2 -right-2 w-5 bg-red-500 rounded-full flex items-center justify-center" |
| 164 | + style="height: 41rpx" | ||
| 133 | > | 165 | > |
| 134 | <view class="text-white text-lg">×</view> | 166 | <view class="text-white text-lg">×</view> |
| 135 | </view> | 167 | </view> |
| ... | @@ -139,15 +171,17 @@ | ... | @@ -139,15 +171,17 @@ |
| 139 | @click.stop="chooseImage" | 171 | @click.stop="chooseImage" |
| 140 | class="absolute bottom-3 right-2 px-3 py-1 bg-gray-600 bg-opacity-50 text-white text-sm rounded-full flex items-center" | 172 | class="absolute bottom-3 right-2 px-3 py-1 bg-gray-600 bg-opacity-50 text-white text-sm rounded-full flex items-center" |
| 141 | > | 173 | > |
| 142 | - <IconFont size="20" class="mr-1" name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD4.png" /> | 174 | + <IconFont |
| 175 | + size="20" | ||
| 176 | + class="mr-1" | ||
| 177 | + name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD4.png" | ||
| 178 | + /> | ||
| 143 | 更换 | 179 | 更换 |
| 144 | </view> | 180 | </view> |
| 145 | </view> | 181 | </view> |
| 146 | </view> | 182 | </view> |
| 147 | <!-- 上传提示 --> | 183 | <!-- 上传提示 --> |
| 148 | - <view class="text-center text-gray-400 text-sm"> | 184 | + <view class="text-center text-gray-400 text-sm"> 支持图片格式(jpg、png)最大5MB </view> |
| 149 | - 支持图片格式(jpg、png)最大5MB | ||
| 150 | - </view> | ||
| 151 | </view> | 185 | </view> |
| 152 | </view> | 186 | </view> |
| 153 | </view> | 187 | </view> |
| ... | @@ -156,7 +190,7 @@ | ... | @@ -156,7 +190,7 @@ |
| 156 | @tap="handleCreateFamily" | 190 | @tap="handleCreateFamily" |
| 157 | :class="[ | 191 | :class="[ |
| 158 | 'w-full py-3 text-white text-lg font-medium rounded-lg flex items-center justify-center', | 192 | 'w-full py-3 text-white text-lg font-medium rounded-lg flex items-center justify-center', |
| 159 | - isFormValid ? 'bg-blue-500' : 'bg-gray-300' | 193 | + isFormValid ? 'bg-blue-500' : 'bg-gray-300', |
| 160 | ]" | 194 | ]" |
| 161 | > | 195 | > |
| 162 | 创建家庭 | 196 | 创建家庭 |
| ... | @@ -186,36 +220,38 @@ | ... | @@ -186,36 +220,38 @@ |
| 186 | </template> | 220 | </template> |
| 187 | 221 | ||
| 188 | <script setup> | 222 | <script setup> |
| 189 | -import { ref, nextTick, computed } from 'vue'; | 223 | +import { ref, nextTick, computed } from 'vue' |
| 190 | -import Taro from '@tarojs/taro'; | 224 | +import Taro from '@tarojs/taro' |
| 191 | -import { Tips, Photograph, Right, IconFont } from '@nutui/icons-vue-taro'; | 225 | +import { Tips, Photograph, Right, IconFont } from '@nutui/icons-vue-taro' |
| 192 | -import BASE_URL from '@/utils/config'; | 226 | +import BASE_URL from '@/utils/config' |
| 193 | // 接口信息 | 227 | // 接口信息 |
| 194 | -import { createFamilyAPI } from '@/api/family'; | 228 | +import { createFamilyAPI } from '@/api/family' |
| 229 | +import { normalizeReturnUrl } from '@/utils/returnUrl' | ||
| 195 | // 区域信息 | 230 | // 区域信息 |
| 196 | -import { SHANGHAI_REGION } from '@/utils/config'; | 231 | +import { SHANGHAI_REGION } from '@/utils/config' |
| 197 | 232 | ||
| 198 | -const familyName = ref(''); | 233 | +const familyName = ref('') |
| 199 | -const familyIntro = ref(''); | 234 | +const familyIntro = ref('') |
| 200 | -const selectedDistrict = ref(null); | 235 | +const selectedDistrict = ref(null) |
| 201 | -const selectedDistrictText = ref(''); | 236 | +const selectedDistrictText = ref('') |
| 202 | -const familyMotto = ref(['', '', '', '']); | 237 | +const familyMotto = ref(['', '', '', '']) |
| 203 | -const familyMottoPlaceholder = ref(['孝', '敬', '和', '睦']); | 238 | +const familyMottoPlaceholder = ref(['孝', '敬', '和', '睦']) |
| 204 | 239 | ||
| 205 | // 字数验证错误信息 | 240 | // 字数验证错误信息 |
| 206 | -const familyNameError = ref(''); | 241 | +const familyNameError = ref('') |
| 207 | -const familyIntroError = ref(''); | 242 | +const familyIntroError = ref('') |
| 208 | 243 | ||
| 209 | // 图片审核信息 | 244 | // 图片审核信息 |
| 210 | -const familyAvatarAudit = ref(''); | 245 | +const familyAvatarAudit = ref('') |
| 211 | 246 | ||
| 212 | // 区域选择器相关 | 247 | // 区域选择器相关 |
| 213 | -const showDistrictPicker = ref(false); | 248 | +const showDistrictPicker = ref(false) |
| 214 | -const districtValue = ref([]); | 249 | +const districtValue = ref([]) |
| 215 | -const districtColumns = ref(SHANGHAI_REGION); | 250 | +const districtColumns = ref(SHANGHAI_REGION) |
| 216 | -const familyAvatar = ref(''); | 251 | +const familyAvatar = ref('') |
| 217 | -const focusedIndex = ref(-1); | 252 | +const focusedIndex = ref(-1) |
| 218 | -const inputRefs = ref([]); | 253 | +const inputRefs = ref([]) |
| 254 | +const returnUrl = ref('') | ||
| 219 | 255 | ||
| 220 | const isFormValid = computed(() => { | 256 | const isFormValid = computed(() => { |
| 221 | return ( | 257 | return ( |
| ... | @@ -223,8 +259,8 @@ const isFormValid = computed(() => { | ... | @@ -223,8 +259,8 @@ const isFormValid = computed(() => { |
| 223 | familyIntro.value.trim() !== '' && | 259 | familyIntro.value.trim() !== '' && |
| 224 | selectedDistrict.value !== null && | 260 | selectedDistrict.value !== null && |
| 225 | familyMotto.value.every(char => char.trim() !== '') | 261 | familyMotto.value.every(char => char.trim() !== '') |
| 226 | - ); | 262 | + ) |
| 227 | -}); | 263 | +}) |
| 228 | 264 | ||
| 229 | /** | 265 | /** |
| 230 | * @description 确认选择区域 | 266 | * @description 确认选择区域 |
| ... | @@ -233,22 +269,22 @@ const isFormValid = computed(() => { | ... | @@ -233,22 +269,22 @@ const isFormValid = computed(() => { |
| 233 | const onDistrictConfirm = ({ selectedValue, selectedOptions }) => { | 269 | const onDistrictConfirm = ({ selectedValue, selectedOptions }) => { |
| 234 | // 如果selectedValue[0]为0或无效,使用第一个区域作为默认值 | 270 | // 如果selectedValue[0]为0或无效,使用第一个区域作为默认值 |
| 235 | if (!selectedValue[0] || selectedValue[0] === 0) { | 271 | if (!selectedValue[0] || selectedValue[0] === 0) { |
| 236 | - const firstDistrict = districtColumns.value[0]; | 272 | + const firstDistrict = districtColumns.value[0] |
| 237 | - selectedDistrict.value = firstDistrict.value; | 273 | + selectedDistrict.value = firstDistrict.value |
| 238 | - selectedDistrictText.value = firstDistrict.text; | 274 | + selectedDistrictText.value = firstDistrict.text |
| 239 | - districtValue.value = [firstDistrict.value]; // 同步更新picker的值 | 275 | + districtValue.value = [firstDistrict.value] // 同步更新picker的值 |
| 240 | } else { | 276 | } else { |
| 241 | - selectedDistrict.value = +selectedValue[0]; // 确保转换为数字类型 | 277 | + selectedDistrict.value = +selectedValue[0] // 确保转换为数字类型 |
| 242 | - selectedDistrictText.value = selectedOptions.map((option) => option.text).join(''); | 278 | + selectedDistrictText.value = selectedOptions.map(option => option.text).join('') |
| 243 | } | 279 | } |
| 244 | 280 | ||
| 245 | - showDistrictPicker.value = false; | 281 | + showDistrictPicker.value = false |
| 246 | -}; | 282 | +} |
| 247 | 283 | ||
| 248 | // 图片预览相关 | 284 | // 图片预览相关 |
| 249 | -const previewVisible = ref(false); | 285 | +const previewVisible = ref(false) |
| 250 | -const previewImages = ref([]); | 286 | +const previewImages = ref([]) |
| 251 | -const previewIndex = ref(0); | 287 | +const previewIndex = ref(0) |
| 252 | 288 | ||
| 253 | // const generateRandomMotto = () => { | 289 | // const generateRandomMotto = () => { |
| 254 | // // 在实际应用中,这里会生成随机家训 | 290 | // // 在实际应用中,这里会生成随机家训 |
| ... | @@ -261,31 +297,31 @@ const previewIndex = ref(0); | ... | @@ -261,31 +297,31 @@ const previewIndex = ref(0); |
| 261 | */ | 297 | */ |
| 262 | const handleInputChange = (index, value) => { | 298 | const handleInputChange = (index, value) => { |
| 263 | // 只保留第一个有效字符(汉字、数字、大小写字母) | 299 | // 只保留第一个有效字符(汉字、数字、大小写字母) |
| 264 | - const validChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || ''; | 300 | + const validChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || '' |
| 265 | - familyMotto.value[index] = validChar; | 301 | + familyMotto.value[index] = validChar |
| 266 | 302 | ||
| 267 | // 如果输入了有效字符且不是最后一个输入框,自动聚焦下一个 | 303 | // 如果输入了有效字符且不是最后一个输入框,自动聚焦下一个 |
| 268 | if (validChar && index < 3) { | 304 | if (validChar && index < 3) { |
| 269 | - focusedIndex.value = index + 1; | 305 | + focusedIndex.value = index + 1 |
| 270 | // 使用 nextTick 确保 DOM 更新后再聚焦 | 306 | // 使用 nextTick 确保 DOM 更新后再聚焦 |
| 271 | nextTick(() => { | 307 | nextTick(() => { |
| 272 | if (inputRefs.value[index + 1]) { | 308 | if (inputRefs.value[index + 1]) { |
| 273 | - inputRefs.value[index + 1].focus(); | 309 | + inputRefs.value[index + 1].focus() |
| 274 | } | 310 | } |
| 275 | - }); | 311 | + }) |
| 276 | } | 312 | } |
| 277 | -}; | 313 | +} |
| 278 | 314 | ||
| 279 | /** | 315 | /** |
| 280 | * 处理失焦事件 | 316 | * 处理失焦事件 |
| 281 | */ | 317 | */ |
| 282 | -const handleBlur = (index) => { | 318 | +const handleBlur = index => { |
| 283 | - focusedIndex.value = -1; | 319 | + focusedIndex.value = -1 |
| 284 | // 确保只保留有效字符(汉字、数字、大小写字母) | 320 | // 确保只保留有效字符(汉字、数字、大小写字母) |
| 285 | - const value = familyMotto.value[index]; | 321 | + const value = familyMotto.value[index] |
| 286 | - const validChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || ''; | 322 | + const validChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || '' |
| 287 | - familyMotto.value[index] = validChar; | 323 | + familyMotto.value[index] = validChar |
| 288 | -}; | 324 | +} |
| 289 | 325 | ||
| 290 | /** | 326 | /** |
| 291 | * 显示提示信息 | 327 | * 显示提示信息 |
| ... | @@ -294,9 +330,9 @@ const showToast = (message, type = 'success') => { | ... | @@ -294,9 +330,9 @@ const showToast = (message, type = 'success') => { |
| 294 | Taro.showToast({ | 330 | Taro.showToast({ |
| 295 | title: message, | 331 | title: message, |
| 296 | icon: type, | 332 | icon: type, |
| 297 | - duration: 2000 | 333 | + duration: 2000, |
| 298 | - }); | 334 | + }) |
| 299 | -}; | 335 | +} |
| 300 | 336 | ||
| 301 | /** | 337 | /** |
| 302 | * 选择图片 | 338 | * 选择图片 |
| ... | @@ -307,39 +343,39 @@ const chooseImage = () => { | ... | @@ -307,39 +343,39 @@ const chooseImage = () => { |
| 307 | sizeType: ['compressed'], | 343 | sizeType: ['compressed'], |
| 308 | sourceType: ['album', 'camera'], | 344 | sourceType: ['album', 'camera'], |
| 309 | success: function (res) { | 345 | success: function (res) { |
| 310 | - const tempFilePath = res.tempFilePaths[0]; | 346 | + const tempFilePath = res.tempFilePaths[0] |
| 311 | 347 | ||
| 312 | // 检查文件大小(5MB = 5 * 1024 * 1024 bytes) | 348 | // 检查文件大小(5MB = 5 * 1024 * 1024 bytes) |
| 313 | - Taro.getFileInfo({ | 349 | + Taro.getFileInfo({ |
| 314 | - filePath: tempFilePath, | 350 | + filePath: tempFilePath, |
| 315 | - success: function (fileInfo) { | 351 | + success: function (fileInfo) { |
| 316 | - if (fileInfo.size > 5 * 1024 * 1024) { | 352 | + if (fileInfo.size > 5 * 1024 * 1024) { |
| 317 | - showToast('图片大小不能超过5MB', 'none'); | 353 | + showToast('图片大小不能超过5MB', 'none') |
| 318 | - return; | 354 | + return |
| 319 | - } | 355 | + } |
| 320 | - uploadImage(tempFilePath); | 356 | + uploadImage(tempFilePath) |
| 321 | }, | 357 | }, |
| 322 | fail: function () { | 358 | fail: function () { |
| 323 | // 如果获取文件信息失败,直接上传 | 359 | // 如果获取文件信息失败,直接上传 |
| 324 | - uploadImage(tempFilePath); | 360 | + uploadImage(tempFilePath) |
| 325 | - } | 361 | + }, |
| 326 | - }); | 362 | + }) |
| 327 | }, | 363 | }, |
| 328 | fail: function () { | 364 | fail: function () { |
| 329 | // showToast('选择图片失败', 'none'); | 365 | // showToast('选择图片失败', 'none'); |
| 330 | - } | 366 | + }, |
| 331 | - }); | 367 | + }) |
| 332 | -}; | 368 | +} |
| 333 | 369 | ||
| 334 | /** | 370 | /** |
| 335 | * 上传图片到服务器 | 371 | * 上传图片到服务器 |
| 336 | */ | 372 | */ |
| 337 | -const uploadImage = (filePath) => { | 373 | +const uploadImage = filePath => { |
| 338 | // 显示上传中提示 | 374 | // 显示上传中提示 |
| 339 | Taro.showLoading({ | 375 | Taro.showLoading({ |
| 340 | title: '上传中', | 376 | title: '上传中', |
| 341 | - mask: true | 377 | + mask: true, |
| 342 | - }); | 378 | + }) |
| 343 | 379 | ||
| 344 | wx.uploadFile({ | 380 | wx.uploadFile({ |
| 345 | url: BASE_URL + '/admin/?m=srv&a=upload&image_audit=1', | 381 | url: BASE_URL + '/admin/?m=srv&a=upload&image_audit=1', |
| ... | @@ -349,142 +385,142 @@ const uploadImage = (filePath) => { | ... | @@ -349,142 +385,142 @@ const uploadImage = (filePath) => { |
| 349 | 'content-type': 'multipart/form-data', | 385 | 'content-type': 'multipart/form-data', |
| 350 | }, | 386 | }, |
| 351 | success: function (res) { | 387 | success: function (res) { |
| 352 | - let upload_data = JSON.parse(res.data); | 388 | + const upload_data = JSON.parse(res.data) |
| 353 | Taro.hideLoading({ | 389 | Taro.hideLoading({ |
| 354 | success: () => { | 390 | success: () => { |
| 355 | - if (upload_data.code == 0 && upload_data.data) { | 391 | + if (upload_data.code === 0 && upload_data.data) { |
| 356 | - familyAvatar.value = upload_data.data.src; | 392 | + familyAvatar.value = upload_data.data.src |
| 357 | - familyAvatarAudit.value = upload_data.data.audit_result; | 393 | + familyAvatarAudit.value = upload_data.data.audit_result |
| 358 | - showToast('上传成功', 'success'); | 394 | + showToast('上传成功', 'success') |
| 359 | } else { | 395 | } else { |
| 360 | - showToast('服务器错误,稍后重试!', 'none'); | 396 | + showToast('服务器错误,稍后重试!', 'none') |
| 361 | } | 397 | } |
| 362 | }, | 398 | }, |
| 363 | - }); | 399 | + }) |
| 364 | }, | 400 | }, |
| 365 | fail: function (res) { | 401 | fail: function (res) { |
| 366 | Taro.hideLoading({ | 402 | Taro.hideLoading({ |
| 367 | success: () => { | 403 | success: () => { |
| 368 | - showToast('上传失败,稍后重试!', 'none'); | 404 | + showToast('上传失败,稍后重试!', 'none') |
| 369 | - } | 405 | + }, |
| 370 | - }); | 406 | + }) |
| 371 | - } | 407 | + }, |
| 372 | - }); | 408 | + }) |
| 373 | -}; | 409 | +} |
| 374 | 410 | ||
| 375 | /** | 411 | /** |
| 376 | * 预览头像 | 412 | * 预览头像 |
| 377 | */ | 413 | */ |
| 378 | const previewAvatar = () => { | 414 | const previewAvatar = () => { |
| 379 | if (!familyAvatar.value) { | 415 | if (!familyAvatar.value) { |
| 380 | - return; | 416 | + return |
| 381 | } | 417 | } |
| 382 | - const imageToPreview = familyAvatar.value; | 418 | + const imageToPreview = familyAvatar.value |
| 383 | - previewImages.value = [{ src: imageToPreview }]; | 419 | + previewImages.value = [{ src: imageToPreview }] |
| 384 | - previewIndex.value = 0; | 420 | + previewIndex.value = 0 |
| 385 | - previewVisible.value = true; | 421 | + previewVisible.value = true |
| 386 | -}; | 422 | +} |
| 387 | 423 | ||
| 388 | /** | 424 | /** |
| 389 | * 删除头像 | 425 | * 删除头像 |
| 390 | */ | 426 | */ |
| 391 | const deleteAvatar = () => { | 427 | const deleteAvatar = () => { |
| 392 | - familyAvatar.value = ''; | 428 | + familyAvatar.value = '' |
| 393 | - showToast('头像已删除', 'success'); | 429 | + showToast('头像已删除', 'success') |
| 394 | -}; | 430 | +} |
| 395 | 431 | ||
| 396 | /** | 432 | /** |
| 397 | * 关闭预览 | 433 | * 关闭预览 |
| 398 | */ | 434 | */ |
| 399 | const closePreview = () => { | 435 | const closePreview = () => { |
| 400 | - previewVisible.value = false; | 436 | + previewVisible.value = false |
| 401 | -}; | 437 | +} |
| 402 | 438 | ||
| 403 | /** | 439 | /** |
| 404 | * 验证家庭名称字数 | 440 | * 验证家庭名称字数 |
| 405 | */ | 441 | */ |
| 406 | const validateFamilyName = () => { | 442 | const validateFamilyName = () => { |
| 407 | - familyNameError.value = ''; | 443 | + familyNameError.value = '' |
| 408 | if (familyName.value.length > 10) { | 444 | if (familyName.value.length > 10) { |
| 409 | - familyNameError.value = '家庭名称不能超过10个字'; | 445 | + familyNameError.value = '家庭名称不能超过10个字' |
| 410 | } | 446 | } |
| 411 | -}; | 447 | +} |
| 412 | 448 | ||
| 413 | /** | 449 | /** |
| 414 | * 验证家庭介绍字数 | 450 | * 验证家庭介绍字数 |
| 415 | */ | 451 | */ |
| 416 | const validateFamilyIntro = () => { | 452 | const validateFamilyIntro = () => { |
| 417 | - familyIntroError.value = ''; | 453 | + familyIntroError.value = '' |
| 418 | if (familyIntro.value.length > 100) { | 454 | if (familyIntro.value.length > 100) { |
| 419 | - familyIntroError.value = '家庭介绍不能超过100个字'; | 455 | + familyIntroError.value = '家庭介绍不能超过100个字' |
| 420 | } | 456 | } |
| 421 | -}; | 457 | +} |
| 422 | 458 | ||
| 423 | /** | 459 | /** |
| 424 | * 表单验证 | 460 | * 表单验证 |
| 425 | */ | 461 | */ |
| 426 | const validateForm = () => { | 462 | const validateForm = () => { |
| 427 | // 先验证字数 | 463 | // 先验证字数 |
| 428 | - validateFamilyName(); | 464 | + validateFamilyName() |
| 429 | - validateFamilyIntro(); | 465 | + validateFamilyIntro() |
| 430 | 466 | ||
| 431 | if (!familyName.value.trim()) { | 467 | if (!familyName.value.trim()) { |
| 432 | - showToast('请输入家庭名称', 'none'); | 468 | + showToast('请输入家庭名称', 'none') |
| 433 | - return false; | 469 | + return false |
| 434 | } | 470 | } |
| 435 | 471 | ||
| 436 | if (familyName.value.length > 10) { | 472 | if (familyName.value.length > 10) { |
| 437 | Taro.showModal({ | 473 | Taro.showModal({ |
| 438 | title: '提示', | 474 | title: '提示', |
| 439 | content: '家庭名称不能超过10个字,请重新输入', | 475 | content: '家庭名称不能超过10个字,请重新输入', |
| 440 | - showCancel: false | 476 | + showCancel: false, |
| 441 | - }); | 477 | + }) |
| 442 | - return false; | 478 | + return false |
| 443 | } | 479 | } |
| 444 | 480 | ||
| 445 | if (!familyIntro.value.trim()) { | 481 | if (!familyIntro.value.trim()) { |
| 446 | - showToast('请输入家庭介绍', 'none'); | 482 | + showToast('请输入家庭介绍', 'none') |
| 447 | - return false; | 483 | + return false |
| 448 | } | 484 | } |
| 449 | 485 | ||
| 450 | if (familyIntro.value.length > 100) { | 486 | if (familyIntro.value.length > 100) { |
| 451 | Taro.showModal({ | 487 | Taro.showModal({ |
| 452 | title: '提示', | 488 | title: '提示', |
| 453 | content: '家庭介绍不能超过100个字,请重新输入', | 489 | content: '家庭介绍不能超过100个字,请重新输入', |
| 454 | - showCancel: false | 490 | + showCancel: false, |
| 455 | - }); | 491 | + }) |
| 456 | - return false; | 492 | + return false |
| 457 | } | 493 | } |
| 458 | 494 | ||
| 459 | if (!selectedDistrict.value) { | 495 | if (!selectedDistrict.value) { |
| 460 | - showToast('请选择区域战队', 'none'); | 496 | + showToast('请选择区域战队', 'none') |
| 461 | - return false; | 497 | + return false |
| 462 | } | 498 | } |
| 463 | 499 | ||
| 464 | // 检查家训口令是否完整填写 | 500 | // 检查家训口令是否完整填写 |
| 465 | - const mottoComplete = familyMotto.value.every(char => char.trim() !== ''); | 501 | + const mottoComplete = familyMotto.value.every(char => char.trim() !== '') |
| 466 | if (!mottoComplete) { | 502 | if (!mottoComplete) { |
| 467 | - showToast('请完整填写家训口令', 'none'); | 503 | + showToast('请完整填写家训口令', 'none') |
| 468 | - return false; | 504 | + return false |
| 469 | } | 505 | } |
| 470 | 506 | ||
| 471 | - return true; | 507 | + return true |
| 472 | -}; | 508 | +} |
| 473 | 509 | ||
| 474 | /** | 510 | /** |
| 475 | * 创建家庭 | 511 | * 创建家庭 |
| 476 | */ | 512 | */ |
| 477 | const handleCreateFamily = async () => { | 513 | const handleCreateFamily = async () => { |
| 478 | if (!validateForm()) { | 514 | if (!validateForm()) { |
| 479 | - return; | 515 | + return |
| 480 | } | 516 | } |
| 481 | 517 | ||
| 482 | try { | 518 | try { |
| 483 | // 显示加载中 | 519 | // 显示加载中 |
| 484 | Taro.showLoading({ | 520 | Taro.showLoading({ |
| 485 | title: '创建中...', | 521 | title: '创建中...', |
| 486 | - mask: true | 522 | + mask: true, |
| 487 | - }); | 523 | + }) |
| 488 | 524 | ||
| 489 | const { code, data, msg } = await createFamilyAPI({ | 525 | const { code, data, msg } = await createFamilyAPI({ |
| 490 | name: familyName.value, | 526 | name: familyName.value, |
| ... | @@ -493,27 +529,38 @@ const handleCreateFamily = async () => { | ... | @@ -493,27 +529,38 @@ const handleCreateFamily = async () => { |
| 493 | passphrase: familyMotto.value.join(''), | 529 | passphrase: familyMotto.value.join(''), |
| 494 | avatar_url: familyAvatar.value, | 530 | avatar_url: familyAvatar.value, |
| 495 | qiniu_audit: familyAvatarAudit.value, | 531 | qiniu_audit: familyAvatarAudit.value, |
| 496 | - }); | 532 | + }) |
| 497 | 533 | ||
| 498 | - Taro.hideLoading(); | 534 | + Taro.hideLoading() |
| 499 | 535 | ||
| 500 | // 判断API调用是否成功 | 536 | // 判断API调用是否成功 |
| 501 | if (code) { | 537 | if (code) { |
| 502 | - showToast('创建成功', 'success'); | 538 | + showToast('创建成功', 'success') |
| 503 | 539 | ||
| 504 | setTimeout(() => { | 540 | setTimeout(() => { |
| 541 | + if (returnUrl.value) { | ||
| 542 | + Taro.redirectTo({ | ||
| 543 | + url: normalizeReturnUrl(returnUrl.value), | ||
| 544 | + }) | ||
| 545 | + return | ||
| 546 | + } | ||
| 547 | + | ||
| 505 | Taro.navigateTo({ | 548 | Taro.navigateTo({ |
| 506 | - url: '/pages/Dashboard/index' | 549 | + url: '/pages/Dashboard/index', |
| 507 | - }); | 550 | + }) |
| 508 | - }, 1500); | 551 | + }, 1500) |
| 509 | } else { | 552 | } else { |
| 510 | // 显示错误信息 | 553 | // 显示错误信息 |
| 511 | - showToast(msg || '创建失败,请重试', 'none'); | 554 | + showToast(msg || '创建失败,请重试', 'none') |
| 512 | } | 555 | } |
| 513 | } catch (error) { | 556 | } catch (error) { |
| 514 | - Taro.hideLoading(); | 557 | + Taro.hideLoading() |
| 515 | - console.error('创建家庭失败:', error); | 558 | + console.error('创建家庭失败:', error) |
| 516 | - showToast('网络错误,请重试', 'none'); | 559 | + showToast('网络错误,请重试', 'none') |
| 517 | } | 560 | } |
| 518 | -}; | 561 | +} |
| 562 | + | ||
| 563 | +Taro.useLoad(options => { | ||
| 564 | + returnUrl.value = normalizeReturnUrl(options?.return_url || '') | ||
| 565 | +}) | ||
| 519 | </script> | 566 | </script> | ... | ... |
| ... | @@ -2,9 +2,7 @@ | ... | @@ -2,9 +2,7 @@ |
| 2 | <view class="min-h-screen flex flex-col bg-white"> | 2 | <view class="min-h-screen flex flex-col bg-white"> |
| 3 | <view class="flex-1 px-4 pt-3 pb-6 flex flex-col"> | 3 | <view class="flex-1 px-4 pt-3 pb-6 flex flex-col"> |
| 4 | <!-- Title --> | 4 | <!-- Title --> |
| 5 | - <h2 class="text-xl font-bold text-center mb-2"> | 5 | + <h2 class="text-xl font-bold text-center mb-2">输入家训口令</h2> |
| 6 | - 输入家训口令 | ||
| 7 | - </h2> | ||
| 8 | <!-- Description --> | 6 | <!-- Description --> |
| 9 | <view class="text-gray-600 text-left text-sm mb-6"> | 7 | <view class="text-gray-600 text-left text-sm mb-6"> |
| 10 | 请输入家人提供的家训口令,加入家庭一起参与健康挑战 | 8 | 请输入家人提供的家训口令,加入家庭一起参与健康挑战 |
| ... | @@ -16,15 +14,15 @@ | ... | @@ -16,15 +14,15 @@ |
| 16 | :key="index" | 14 | :key="index" |
| 17 | class="motto-input-box" | 15 | class="motto-input-box" |
| 18 | :style="{ | 16 | :style="{ |
| 19 | - borderColor: focusedIndex === index ? THEME_COLORS.PRIMARY : '#d1d5db' | 17 | + borderColor: focusedIndex === index ? THEME_COLORS.PRIMARY : '#d1d5db', |
| 20 | }" | 18 | }" |
| 21 | > | 19 | > |
| 22 | <input | 20 | <input |
| 23 | - :ref="(el) => (inputRefs[index] = el)" | 21 | + :ref="el => (inputRefs[index] = el)" |
| 24 | type="text" | 22 | type="text" |
| 25 | v-model="mottoChars[index]" | 23 | v-model="mottoChars[index]" |
| 26 | - @input="(e) => handleInputChange(index, e.target.value)" | 24 | + @input="e => handleInputChange(index, e.target.value)" |
| 27 | - @keydown="(e) => handleKeyDown(index, e)" | 25 | + @keydown="e => handleKeyDown(index, e)" |
| 28 | @focus="focusedIndex = index" | 26 | @focus="focusedIndex = index" |
| 29 | @blur="handleBlur(index)" | 27 | @blur="handleBlur(index)" |
| 30 | class="motto-input" | 28 | class="motto-input" |
| ... | @@ -33,14 +31,10 @@ | ... | @@ -33,14 +31,10 @@ |
| 33 | </view> | 31 | </view> |
| 34 | </view> | 32 | </view> |
| 35 | <!-- Help text --> | 33 | <!-- Help text --> |
| 36 | - <view class="text-gray-500 text-center text-sm mb-4"> | 34 | + <view class="text-gray-500 text-center text-sm mb-4"> 没有口令?请联系您的大家长获取 </view> |
| 37 | - 没有口令?请联系您的大家长获取 | ||
| 38 | - </view> | ||
| 39 | <!-- Role selection --> | 35 | <!-- Role selection --> |
| 40 | <view class="mb-6"> | 36 | <view class="mb-6"> |
| 41 | - <h3 class="identity-title"> | 37 | + <h3 class="identity-title">选择您的身份</h3> |
| 42 | - 选择您的身份 | ||
| 43 | - </h3> | ||
| 44 | <view class="flex gap-2 flex-wrap"> | 38 | <view class="flex gap-2 flex-wrap"> |
| 45 | <view | 39 | <view |
| 46 | v-for="role in familyRoles" | 40 | v-for="role in familyRoles" |
| ... | @@ -50,7 +44,7 @@ | ... | @@ -50,7 +44,7 @@ |
| 50 | 'w-[calc(49%-4rpx)] py-3 rounded-lg border text-center flex flex-col items-center gap-1', | 44 | 'w-[calc(49%-4rpx)] py-3 rounded-lg border text-center flex flex-col items-center gap-1', |
| 51 | selectedRole === role.id | 45 | selectedRole === role.id |
| 52 | ? 'border-blue-500 bg-blue-50 text-blue-500' | 46 | ? 'border-blue-500 bg-blue-50 text-blue-500' |
| 53 | - : 'border-gray-200 text-gray-700' | 47 | + : 'border-gray-200 text-gray-700', |
| 54 | ]" | 48 | ]" |
| 55 | > | 49 | > |
| 56 | <IconFont :name="role.type" size="25" /> | 50 | <IconFont :name="role.type" size="25" /> |
| ... | @@ -64,7 +58,7 @@ | ... | @@ -64,7 +58,7 @@ |
| 64 | :disabled="!isComplete" | 58 | :disabled="!isComplete" |
| 65 | :class="[ | 59 | :class="[ |
| 66 | 'w-full py-3 text-white text-lg font-medium rounded-lg mt-auto text-center', | 60 | 'w-full py-3 text-white text-lg font-medium rounded-lg mt-auto text-center', |
| 67 | - isComplete ? 'bg-blue-500' : 'bg-gray-300' | 61 | + isComplete ? 'bg-blue-500' : 'bg-gray-300', |
| 68 | ]" | 62 | ]" |
| 69 | > | 63 | > |
| 70 | 加入家庭 | 64 | 加入家庭 |
| ... | @@ -98,10 +92,7 @@ | ... | @@ -98,10 +92,7 @@ |
| 98 | 92 | ||
| 99 | <!-- 家庭列表 --> | 93 | <!-- 家庭列表 --> |
| 100 | <view class="flex-1 px-4 pb-4 overflow-hidden"> | 94 | <view class="flex-1 px-4 pb-4 overflow-hidden"> |
| 101 | - <view | 95 | + <view ref="familyListContainer" class="h-full space-y-3 overflow-y-auto"> |
| 102 | - ref="familyListContainer" | ||
| 103 | - class="h-full space-y-3 overflow-y-auto" | ||
| 104 | - > | ||
| 105 | <view | 96 | <view |
| 106 | v-for="family in filteredFamilies" | 97 | v-for="family in filteredFamilies" |
| 107 | :key="family.id" | 98 | :key="family.id" |
| ... | @@ -112,11 +103,13 @@ | ... | @@ -112,11 +103,13 @@ |
| 112 | ? 'border-gray-300 bg-gray-100 opacity-60 cursor-not-allowed' | 103 | ? 'border-gray-300 bg-gray-100 opacity-60 cursor-not-allowed' |
| 113 | : selectedFamilyId === family.id | 104 | : selectedFamilyId === family.id |
| 114 | ? 'border-blue-500 bg-blue-50' | 105 | ? 'border-blue-500 bg-blue-50' |
| 115 | - : 'border-gray-200 bg-white' | 106 | + : 'border-gray-200 bg-white', |
| 116 | ]" | 107 | ]" |
| 117 | > | 108 | > |
| 118 | <!-- 家庭头像 --> | 109 | <!-- 家庭头像 --> |
| 119 | - <view class="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden"> | 110 | + <view |
| 111 | + class="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden" | ||
| 112 | + > | ||
| 120 | <image | 113 | <image |
| 121 | :src="family.avatar_url || defaultAvatar" | 114 | :src="family.avatar_url || defaultAvatar" |
| 122 | class="w-full h-full object-cover" | 115 | class="w-full h-full object-cover" |
| ... | @@ -128,7 +121,9 @@ | ... | @@ -128,7 +121,9 @@ |
| 128 | <view class="font-medium text-gray-900 mb-1"> | 121 | <view class="font-medium text-gray-900 mb-1"> |
| 129 | {{ family.name }} | 122 | {{ family.name }} |
| 130 | </view> | 123 | </view> |
| 131 | - <text v-if="family.is_kicked" class="text-xs text-red-500">被大家长移除后无法再加入</text> | 124 | + <text v-if="family.is_kicked" class="text-xs text-red-500" |
| 125 | + >被大家长移除后无法再加入</text | ||
| 126 | + > | ||
| 132 | <view class="text-sm text-gray-600 line-clamp-2">{{ family.note }}</view> | 127 | <view class="text-sm text-gray-600 line-clamp-2">{{ family.note }}</view> |
| 133 | </view> | 128 | </view> |
| 134 | 129 | ||
| ... | @@ -152,7 +147,10 @@ | ... | @@ -152,7 +147,10 @@ |
| 152 | </view> | 147 | </view> |
| 153 | 148 | ||
| 154 | <!-- 没有更多数据提示 --> | 149 | <!-- 没有更多数据提示 --> |
| 155 | - <view v-if="!hasMoreData && totalFamilies.length > 0 && !searchKeyword" class="text-center py-4 text-gray-500 text-sm"> | 150 | + <view |
| 151 | + v-if="!hasMoreData && totalFamilies.length > 0 && !searchKeyword" | ||
| 152 | + class="text-center py-4 text-gray-500 text-sm" | ||
| 153 | + > | ||
| 156 | 没有更多家庭了 | 154 | 没有更多家庭了 |
| 157 | </view> | 155 | </view> |
| 158 | </view> | 156 | </view> |
| ... | @@ -160,13 +158,7 @@ | ... | @@ -160,13 +158,7 @@ |
| 160 | 158 | ||
| 161 | <!-- 底部按钮 --> | 159 | <!-- 底部按钮 --> |
| 162 | <view class="flex gap-3 p-4 border-t border-gray-100 flex-shrink-0"> | 160 | <view class="flex gap-3 p-4 border-t border-gray-100 flex-shrink-0"> |
| 163 | - <nut-button | 161 | + <nut-button @click="closeFamilySelector" class="flex-1" type="default" size="large" plain> |
| 164 | - @click="closeFamilySelector" | ||
| 165 | - class="flex-1" | ||
| 166 | - type="default" | ||
| 167 | - size="large" | ||
| 168 | - plain | ||
| 169 | - > | ||
| 170 | 关闭 | 162 | 关闭 |
| 171 | </nut-button> | 163 | </nut-button> |
| 172 | <nut-button | 164 | <nut-button |
| ... | @@ -185,110 +177,154 @@ | ... | @@ -185,110 +177,154 @@ |
| 185 | </template> | 177 | </template> |
| 186 | 178 | ||
| 187 | <script setup> | 179 | <script setup> |
| 188 | -import { ref, computed, nextTick, onMounted, watch } from 'vue'; | 180 | +import { ref, computed, nextTick, onMounted, watch } from 'vue' |
| 189 | -import Taro from '@tarojs/taro'; | 181 | +import Taro from '@tarojs/taro' |
| 190 | -import { My, Check, IconFont } from '@nutui/icons-vue-taro'; | 182 | +import { My, Check, IconFont } from '@nutui/icons-vue-taro' |
| 191 | // 获取接口信息 | 183 | // 获取接口信息 |
| 192 | -import { searchFamilyByPassphraseAPI, joinFamilyAPI } from '@/api/family'; | 184 | +import { searchFamilyByPassphraseAPI, joinFamilyAPI } from '@/api/family' |
| 185 | +import { normalizeReturnUrl } from '@/utils/returnUrl' | ||
| 193 | // 导入主题颜色 | 186 | // 导入主题颜色 |
| 194 | -import { THEME_COLORS } from '@/utils/config'; | 187 | +import { THEME_COLORS } from '@/utils/config' |
| 195 | // 默认头像 | 188 | // 默认头像 |
| 196 | -const defaultAvatar = 'https://cdn.ipadbiz.cn/lls_prog/images/%E5%85%A8%E5%AE%B6%E7%A6%8F3_%E5%89%AF%E6%9C%AC.jpg?imageMogr2/strip/quality/60' | 189 | +const defaultAvatar = |
| 190 | + 'https://cdn.ipadbiz.cn/lls_prog/images/%E5%85%A8%E5%AE%B6%E7%A6%8F3_%E5%89%AF%E6%9C%AC.jpg?imageMogr2/strip/quality/60' | ||
| 197 | 191 | ||
| 198 | -const mottoChars = ref(['', '', '', '']); | 192 | +const mottoChars = ref(['', '', '', '']) |
| 199 | -const selectedRole = ref(''); | 193 | +const selectedRole = ref('') |
| 200 | -const inputRefs = ref([]); | 194 | +const inputRefs = ref([]) |
| 201 | -const focusedIndex = ref(-1); | 195 | +const focusedIndex = ref(-1) |
| 202 | 196 | ||
| 203 | // 弹窗相关数据 | 197 | // 弹窗相关数据 |
| 204 | -const showFamilySelector = ref(false); | 198 | +const showFamilySelector = ref(false) |
| 205 | -const searchKeyword = ref(''); | 199 | +const searchKeyword = ref('') |
| 206 | -const selectedFamilyId = ref(''); | 200 | +const selectedFamilyId = ref('') |
| 207 | -const mockFamilies = ref([]); | 201 | +const mockFamilies = ref([]) |
| 208 | -const familyListContainer = ref(null); | 202 | +const familyListContainer = ref(null) |
| 209 | // 移除不再需要的familyListHeight变量,因为现在使用flexbox布局 | 203 | // 移除不再需要的familyListHeight变量,因为现在使用flexbox布局 |
| 210 | 204 | ||
| 211 | // 分页相关数据 | 205 | // 分页相关数据 |
| 212 | -const currentPage = ref(0); | 206 | +const currentPage = ref(0) |
| 213 | -const pageSize = ref(10); | 207 | +const pageSize = ref(10) |
| 214 | -const hasMoreData = ref(true); | 208 | +const hasMoreData = ref(true) |
| 215 | -const isLoadingMore = ref(false); | 209 | +const isLoadingMore = ref(false) |
| 216 | -const totalFamilies = ref([]); | 210 | +const totalFamilies = ref([]) |
| 211 | +const returnUrl = ref('') | ||
| 217 | 212 | ||
| 218 | const handleInputChange = (index, value) => { | 213 | const handleInputChange = (index, value) => { |
| 219 | // 允许输入多个字符,但只保留第一个有效字符(汉字、数字、大小写字母),兼容输入法 | 214 | // 允许输入多个字符,但只保留第一个有效字符(汉字、数字、大小写字母),兼容输入法 |
| 220 | if (value) { | 215 | if (value) { |
| 221 | // 提取第一个有效字符(汉字、数字、大小写字母) | 216 | // 提取第一个有效字符(汉字、数字、大小写字母) |
| 222 | - const firstChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || ''; | 217 | + const firstChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || '' |
| 223 | - mottoChars.value[index] = firstChar; | 218 | + mottoChars.value[index] = firstChar |
| 224 | 219 | ||
| 225 | // 如果输入了有效字符且不是最后一个输入框,自动聚焦下一个 | 220 | // 如果输入了有效字符且不是最后一个输入框,自动聚焦下一个 |
| 226 | if (firstChar && index < 3) { | 221 | if (firstChar && index < 3) { |
| 227 | - focusedIndex.value = index + 1; | 222 | + focusedIndex.value = index + 1 |
| 228 | // 使用 nextTick 确保 DOM 更新后再聚焦 | 223 | // 使用 nextTick 确保 DOM 更新后再聚焦 |
| 229 | nextTick(() => { | 224 | nextTick(() => { |
| 230 | if (inputRefs.value[index + 1]) { | 225 | if (inputRefs.value[index + 1]) { |
| 231 | - inputRefs.value[index + 1].focus(); | 226 | + inputRefs.value[index + 1].focus() |
| 232 | } | 227 | } |
| 233 | - }); | 228 | + }) |
| 234 | } | 229 | } |
| 235 | } else { | 230 | } else { |
| 236 | - mottoChars.value[index] = ''; | 231 | + mottoChars.value[index] = '' |
| 237 | } | 232 | } |
| 238 | -}; | 233 | +} |
| 239 | 234 | ||
| 240 | const handleKeyDown = (index, e) => { | 235 | const handleKeyDown = (index, e) => { |
| 241 | if (e.key === 'Backspace' && !mottoChars.value[index] && index > 0) { | 236 | if (e.key === 'Backspace' && !mottoChars.value[index] && index > 0) { |
| 242 | // 同样,在Taro中处理光标移动需要不同的方式 | 237 | // 同样,在Taro中处理光标移动需要不同的方式 |
| 243 | } | 238 | } |
| 244 | -}; | 239 | +} |
| 245 | 240 | ||
| 246 | /** | 241 | /** |
| 247 | * 处理输入框失焦事件 | 242 | * 处理输入框失焦事件 |
| 248 | * @param {number} index - 输入框索引 | 243 | * @param {number} index - 输入框索引 |
| 249 | */ | 244 | */ |
| 250 | -const handleBlur = (index) => { | 245 | +const handleBlur = index => { |
| 251 | // 重置焦点状态 | 246 | // 重置焦点状态 |
| 252 | - focusedIndex.value = -1; | 247 | + focusedIndex.value = -1 |
| 253 | 248 | ||
| 254 | // 失焦时再次验证输入值,确保只保留有效字符(汉字、数字、大小写字母) | 249 | // 失焦时再次验证输入值,确保只保留有效字符(汉字、数字、大小写字母) |
| 255 | - const currentValue = mottoChars.value[index]; | 250 | + const currentValue = mottoChars.value[index] |
| 256 | if (currentValue) { | 251 | if (currentValue) { |
| 257 | - const firstChar = currentValue.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || ''; | 252 | + const firstChar = currentValue.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || '' |
| 258 | - mottoChars.value[index] = firstChar; | 253 | + mottoChars.value[index] = firstChar |
| 259 | } | 254 | } |
| 260 | -}; | 255 | +} |
| 261 | 256 | ||
| 262 | const familyRoles = [ | 257 | const familyRoles = [ |
| 263 | - { id: '丈夫', label: '丈夫', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%88%B7%E7%88%B7.png' }, | 258 | + { |
| 264 | - { id: '妻子', label: '妻子', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B6%E5%A5%B6.png' }, | 259 | + id: '丈夫', |
| 265 | - { id: '儿子', label: '儿子', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E8%81%8C%E5%91%98.png' }, | 260 | + label: '丈夫', |
| 266 | - { id: '女儿', label: '女儿', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E8%81%8C%E5%91%98.png' }, | 261 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%88%B7%E7%88%B7.png', |
| 267 | - { id: '女婿', label: '女婿', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E8%81%8C%E5%91%98.png' }, | 262 | + }, |
| 268 | - { id: '儿媳', label: '儿媳', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E8%81%8C%E5%91%98.png' }, | 263 | + { |
| 269 | - { id: '孙子', label: '孙子', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E5%AD%A9.png' }, | 264 | + id: '妻子', |
| 270 | - { id: '外孙', label: '外孙', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E5%AD%A9.png' }, | 265 | + label: '妻子', |
| 271 | - { id: '孙女', label: '孙女', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E5%AD%A9.png' }, | 266 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B6%E5%A5%B6.png', |
| 272 | - { id: '外孙女', label: '外孙女', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E5%AD%A9.png' } | 267 | + }, |
| 273 | -]; | 268 | + { |
| 269 | + id: '儿子', | ||
| 270 | + label: '儿子', | ||
| 271 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E8%81%8C%E5%91%98.png', | ||
| 272 | + }, | ||
| 273 | + { | ||
| 274 | + id: '女儿', | ||
| 275 | + label: '女儿', | ||
| 276 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E8%81%8C%E5%91%98.png', | ||
| 277 | + }, | ||
| 278 | + { | ||
| 279 | + id: '女婿', | ||
| 280 | + label: '女婿', | ||
| 281 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E8%81%8C%E5%91%98.png', | ||
| 282 | + }, | ||
| 283 | + { | ||
| 284 | + id: '儿媳', | ||
| 285 | + label: '儿媳', | ||
| 286 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E8%81%8C%E5%91%98.png', | ||
| 287 | + }, | ||
| 288 | + { | ||
| 289 | + id: '孙子', | ||
| 290 | + label: '孙子', | ||
| 291 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E5%AD%A9.png', | ||
| 292 | + }, | ||
| 293 | + { | ||
| 294 | + id: '外孙', | ||
| 295 | + label: '外孙', | ||
| 296 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E5%AD%A9.png', | ||
| 297 | + }, | ||
| 298 | + { | ||
| 299 | + id: '孙女', | ||
| 300 | + label: '孙女', | ||
| 301 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E5%AD%A9.png', | ||
| 302 | + }, | ||
| 303 | + { | ||
| 304 | + id: '外孙女', | ||
| 305 | + label: '外孙女', | ||
| 306 | + type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E5%AD%A9.png', | ||
| 307 | + }, | ||
| 308 | +] | ||
| 274 | 309 | ||
| 275 | const isComplete = computed(() => { | 310 | const isComplete = computed(() => { |
| 276 | - return mottoChars.value.every((char) => char) && selectedRole.value; | 311 | + return mottoChars.value.every(char => char) && selectedRole.value |
| 277 | -}); | 312 | +}) |
| 278 | 313 | ||
| 279 | // 过滤后的家庭列表 | 314 | // 过滤后的家庭列表 |
| 280 | const filteredFamilies = computed(() => { | 315 | const filteredFamilies = computed(() => { |
| 281 | if (!searchKeyword.value) { | 316 | if (!searchKeyword.value) { |
| 282 | return mockFamilies.value | 317 | return mockFamilies.value |
| 283 | } | 318 | } |
| 284 | - return mockFamilies.value.filter(family => | 319 | + return mockFamilies.value.filter( |
| 285 | - family.name?.includes(searchKeyword.value) || | 320 | + family => |
| 286 | - family.description?.includes(searchKeyword.value) | 321 | + family.name?.includes(searchKeyword.value) || |
| 322 | + family.description?.includes(searchKeyword.value) | ||
| 287 | ) | 323 | ) |
| 288 | -}); | 324 | +}) |
| 289 | 325 | ||
| 290 | // 处理搜索 | 326 | // 处理搜索 |
| 291 | -const handleSearch = (value) => { | 327 | +const handleSearch = value => { |
| 292 | searchKeyword.value = value | 328 | searchKeyword.value = value |
| 293 | // 搜索时重置选中的家庭 | 329 | // 搜索时重置选中的家庭 |
| 294 | selectedFamilyId.value = '' | 330 | selectedFamilyId.value = '' |
| ... | @@ -299,20 +335,20 @@ const handleClearSearch = () => { | ... | @@ -299,20 +335,20 @@ const handleClearSearch = () => { |
| 299 | searchKeyword.value = '' | 335 | searchKeyword.value = '' |
| 300 | // 清除搜索时重置选中的家庭 | 336 | // 清除搜索时重置选中的家庭 |
| 301 | selectedFamilyId.value = '' | 337 | selectedFamilyId.value = '' |
| 302 | -}; | 338 | +} |
| 303 | 339 | ||
| 304 | // 选择家庭 | 340 | // 选择家庭 |
| 305 | -const selectFamily = (familyId) => { | 341 | +const selectFamily = familyId => { |
| 306 | // 查找对应的家庭信息 | 342 | // 查找对应的家庭信息 |
| 307 | - const family = mockFamilies.value.find(f => f.id === familyId); | 343 | + const family = mockFamilies.value.find(f => f.id === familyId) |
| 308 | 344 | ||
| 309 | // 如果家庭被禁用,则不允许选中 | 345 | // 如果家庭被禁用,则不允许选中 |
| 310 | if (family && family.is_kicked) { | 346 | if (family && family.is_kicked) { |
| 311 | - return; | 347 | + return |
| 312 | } | 348 | } |
| 313 | 349 | ||
| 314 | selectedFamilyId.value = familyId | 350 | selectedFamilyId.value = familyId |
| 315 | -}; | 351 | +} |
| 316 | 352 | ||
| 317 | // 关闭家庭选择器 | 353 | // 关闭家庭选择器 |
| 318 | const closeFamilySelector = () => { | 354 | const closeFamilySelector = () => { |
| ... | @@ -322,7 +358,7 @@ const closeFamilySelector = () => { | ... | @@ -322,7 +358,7 @@ const closeFamilySelector = () => { |
| 322 | } | 358 | } |
| 323 | 359 | ||
| 324 | // 监听弹窗显示状态,重置选中状态 | 360 | // 监听弹窗显示状态,重置选中状态 |
| 325 | -watch(showFamilySelector, async (newVal) => { | 361 | +watch(showFamilySelector, async newVal => { |
| 326 | if (newVal) { | 362 | if (newVal) { |
| 327 | await nextTick() | 363 | await nextTick() |
| 328 | // 移除高度计算逻辑,现在使用flexbox自动布局 | 364 | // 移除高度计算逻辑,现在使用flexbox自动布局 |
| ... | @@ -339,7 +375,7 @@ const confirmJoinFamily = async () => { | ... | @@ -339,7 +375,7 @@ const confirmJoinFamily = async () => { |
| 339 | if (!selectedFamilyId.value) { | 375 | if (!selectedFamilyId.value) { |
| 340 | Taro.showToast({ | 376 | Taro.showToast({ |
| 341 | title: '请选择一个家庭', | 377 | title: '请选择一个家庭', |
| 342 | - icon: 'none' | 378 | + icon: 'none', |
| 343 | }) | 379 | }) |
| 344 | return | 380 | return |
| 345 | } | 381 | } |
| ... | @@ -348,7 +384,7 @@ const confirmJoinFamily = async () => { | ... | @@ -348,7 +384,7 @@ const confirmJoinFamily = async () => { |
| 348 | console.log('确认加入家庭:', selectedFamily) | 384 | console.log('确认加入家庭:', selectedFamily) |
| 349 | const joinFamily = await joinFamilyAPI({ | 385 | const joinFamily = await joinFamilyAPI({ |
| 350 | family_id: selectedFamily.id, | 386 | family_id: selectedFamily.id, |
| 351 | - role: selectedRole.value | 387 | + role: selectedRole.value, |
| 352 | }) | 388 | }) |
| 353 | if (joinFamily.code) { | 389 | if (joinFamily.code) { |
| 354 | // 关闭弹窗 | 390 | // 关闭弹窗 |
| ... | @@ -356,80 +392,101 @@ const confirmJoinFamily = async () => { | ... | @@ -356,80 +392,101 @@ const confirmJoinFamily = async () => { |
| 356 | 392 | ||
| 357 | Taro.showToast({ | 393 | Taro.showToast({ |
| 358 | title: '加入成功', | 394 | title: '加入成功', |
| 359 | - icon: 'success' | 395 | + icon: 'success', |
| 360 | }) | 396 | }) |
| 361 | 397 | ||
| 362 | setTimeout(() => { | 398 | setTimeout(() => { |
| 399 | + if (returnUrl.value) { | ||
| 400 | + Taro.redirectTo({ | ||
| 401 | + url: normalizeReturnUrl(returnUrl.value), | ||
| 402 | + }) | ||
| 403 | + return | ||
| 404 | + } | ||
| 405 | + | ||
| 363 | Taro.redirectTo({ | 406 | Taro.redirectTo({ |
| 364 | - url: '/pages/Dashboard/index' | 407 | + url: '/pages/Dashboard/index', |
| 365 | }) | 408 | }) |
| 366 | }, 1500) | 409 | }, 1500) |
| 367 | } | 410 | } |
| 368 | -}; | 411 | +} |
| 369 | 412 | ||
| 370 | const handleJoinFamily = async () => { | 413 | const handleJoinFamily = async () => { |
| 371 | - if (!isComplete.value) return | 414 | + if (!isComplete.value) { |
| 415 | + return | ||
| 416 | + } | ||
| 372 | 417 | ||
| 373 | const motto = mottoChars.value.join('') | 418 | const motto = mottoChars.value.join('') |
| 374 | 419 | ||
| 375 | try { | 420 | try { |
| 376 | // 重置分页数据 | 421 | // 重置分页数据 |
| 377 | - currentPage.value = 0; | 422 | + currentPage.value = 0 |
| 378 | - hasMoreData.value = true; | 423 | + hasMoreData.value = true |
| 379 | - totalFamilies.value = []; | 424 | + totalFamilies.value = [] |
| 380 | 425 | ||
| 381 | // 调用API查询家庭(第一页) | 426 | // 调用API查询家庭(第一页) |
| 382 | const { code, data } = await searchFamilyByPassphraseAPI({ | 427 | const { code, data } = await searchFamilyByPassphraseAPI({ |
| 383 | passphrase: motto, | 428 | passphrase: motto, |
| 384 | page: currentPage.value, | 429 | page: currentPage.value, |
| 385 | - limit: pageSize.value | 430 | + limit: pageSize.value, |
| 386 | }) | 431 | }) |
| 387 | 432 | ||
| 388 | - let families = []; | 433 | + let families = [] |
| 389 | 434 | ||
| 390 | if (code) { | 435 | if (code) { |
| 391 | - families = data; | 436 | + families = data |
| 392 | - totalFamilies.value = families; | 437 | + totalFamilies.value = families |
| 393 | 438 | ||
| 394 | // 检查是否还有更多数据 | 439 | // 检查是否还有更多数据 |
| 395 | - hasMoreData.value = families.length === pageSize.value; | 440 | + hasMoreData.value = families.length === pageSize.value |
| 396 | 441 | ||
| 397 | - console.log('查询家庭:', { motto, role: selectedRole.value, families, hasMore: hasMoreData.value }) | 442 | + console.log('查询家庭:', { |
| 443 | + motto, | ||
| 444 | + role: selectedRole.value, | ||
| 445 | + families, | ||
| 446 | + hasMore: hasMoreData.value, | ||
| 447 | + }) | ||
| 398 | 448 | ||
| 399 | if (families.length === 0) { | 449 | if (families.length === 0) { |
| 400 | Taro.showToast({ | 450 | Taro.showToast({ |
| 401 | title: '未找到匹配的家庭', | 451 | title: '未找到匹配的家庭', |
| 402 | - icon: 'none' | 452 | + icon: 'none', |
| 403 | }) | 453 | }) |
| 404 | return | 454 | return |
| 405 | } | 455 | } |
| 406 | 456 | ||
| 407 | if (families.length === 1) { | 457 | if (families.length === 1) { |
| 408 | // 只有一个家庭,检查是否被踢出 | 458 | // 只有一个家庭,检查是否被踢出 |
| 409 | - const family = families[0]; | 459 | + const family = families[0] |
| 410 | 460 | ||
| 411 | if (family.is_kicked) { | 461 | if (family.is_kicked) { |
| 412 | // 被踢出状态,显示选择弹窗让用户知道 | 462 | // 被踢出状态,显示选择弹窗让用户知道 |
| 413 | - showFamilySelector.value = true; | 463 | + showFamilySelector.value = true |
| 414 | - mockFamilies.value = totalFamilies.value; | 464 | + mockFamilies.value = totalFamilies.value |
| 415 | } else { | 465 | } else { |
| 416 | // 未被踢出,直接加入 | 466 | // 未被踢出,直接加入 |
| 417 | const joinFamily = await joinFamilyAPI({ | 467 | const joinFamily = await joinFamilyAPI({ |
| 418 | family_id: family.id, | 468 | family_id: family.id, |
| 419 | - role: selectedRole.value | 469 | + role: selectedRole.value, |
| 420 | - }); | 470 | + }) |
| 421 | 471 | ||
| 422 | if (joinFamily.code) { | 472 | if (joinFamily.code) { |
| 423 | Taro.showToast({ | 473 | Taro.showToast({ |
| 424 | title: '加入成功', | 474 | title: '加入成功', |
| 425 | - icon: 'success' | 475 | + icon: 'success', |
| 426 | - }); | 476 | + }) |
| 427 | 477 | ||
| 428 | setTimeout(() => { | 478 | setTimeout(() => { |
| 479 | + if (returnUrl.value) { | ||
| 480 | + Taro.redirectTo({ | ||
| 481 | + url: normalizeReturnUrl(returnUrl.value), | ||
| 482 | + }) | ||
| 483 | + return | ||
| 484 | + } | ||
| 485 | + | ||
| 429 | Taro.redirectTo({ | 486 | Taro.redirectTo({ |
| 430 | - url: '/pages/Dashboard/index' | 487 | + url: '/pages/Dashboard/index', |
| 431 | - }); | 488 | + }) |
| 432 | - }, 1500); | 489 | + }, 1500) |
| 433 | } | 490 | } |
| 434 | } | 491 | } |
| 435 | } else { | 492 | } else { |
| ... | @@ -442,61 +499,67 @@ const handleJoinFamily = async () => { | ... | @@ -442,61 +499,67 @@ const handleJoinFamily = async () => { |
| 442 | console.error('加入家庭失败:', error) | 499 | console.error('加入家庭失败:', error) |
| 443 | Taro.showToast({ | 500 | Taro.showToast({ |
| 444 | title: '加入失败,请重试', | 501 | title: '加入失败,请重试', |
| 445 | - icon: 'none' | 502 | + icon: 'none', |
| 446 | }) | 503 | }) |
| 447 | } | 504 | } |
| 448 | -}; | 505 | +} |
| 506 | + | ||
| 507 | +Taro.useLoad(options => { | ||
| 508 | + returnUrl.value = normalizeReturnUrl(options?.return_url || '') | ||
| 509 | +}) | ||
| 449 | 510 | ||
| 450 | // 加载更多家庭数据 | 511 | // 加载更多家庭数据 |
| 451 | const loadMoreFamilies = async () => { | 512 | const loadMoreFamilies = async () => { |
| 452 | - if (isLoadingMore.value || !hasMoreData.value) return; | 513 | + if (isLoadingMore.value || !hasMoreData.value) { |
| 514 | + return | ||
| 515 | + } | ||
| 453 | 516 | ||
| 454 | - isLoadingMore.value = true; | 517 | + isLoadingMore.value = true |
| 455 | 518 | ||
| 456 | try { | 519 | try { |
| 457 | - const motto = mottoChars.value.join(''); | 520 | + const motto = mottoChars.value.join('') |
| 458 | - currentPage.value += 1; | 521 | + currentPage.value += 1 |
| 459 | 522 | ||
| 460 | const { code, data } = await searchFamilyByPassphraseAPI({ | 523 | const { code, data } = await searchFamilyByPassphraseAPI({ |
| 461 | passphrase: motto, | 524 | passphrase: motto, |
| 462 | page: currentPage.value, | 525 | page: currentPage.value, |
| 463 | - limit: pageSize.value | 526 | + limit: pageSize.value, |
| 464 | - }); | 527 | + }) |
| 465 | 528 | ||
| 466 | if (code && data) { | 529 | if (code && data) { |
| 467 | // 合并新数据到现有数据 | 530 | // 合并新数据到现有数据 |
| 468 | - totalFamilies.value = [...totalFamilies.value, ...data]; | 531 | + totalFamilies.value = [...totalFamilies.value, ...data] |
| 469 | 532 | ||
| 470 | // 检查是否还有更多数据 | 533 | // 检查是否还有更多数据 |
| 471 | - hasMoreData.value = data.length === pageSize.value; | 534 | + hasMoreData.value = data.length === pageSize.value |
| 472 | 535 | ||
| 473 | // 更新mockFamilies用于显示 | 536 | // 更新mockFamilies用于显示 |
| 474 | - mockFamilies.value = totalFamilies.value; | 537 | + mockFamilies.value = totalFamilies.value |
| 475 | 538 | ||
| 476 | console.log('加载更多家庭:', { | 539 | console.log('加载更多家庭:', { |
| 477 | page: currentPage.value, | 540 | page: currentPage.value, |
| 478 | newCount: data.length, | 541 | newCount: data.length, |
| 479 | totalCount: totalFamilies.value.length, | 542 | totalCount: totalFamilies.value.length, |
| 480 | - hasMore: hasMoreData.value | 543 | + hasMore: hasMoreData.value, |
| 481 | - }); | 544 | + }) |
| 482 | } else { | 545 | } else { |
| 483 | - hasMoreData.value = false; | 546 | + hasMoreData.value = false |
| 484 | Taro.showToast({ | 547 | Taro.showToast({ |
| 485 | title: '加载失败', | 548 | title: '加载失败', |
| 486 | - icon: 'none' | 549 | + icon: 'none', |
| 487 | - }); | 550 | + }) |
| 488 | } | 551 | } |
| 489 | } catch (error) { | 552 | } catch (error) { |
| 490 | - console.error('加载更多家庭失败:', error); | 553 | + console.error('加载更多家庭失败:', error) |
| 491 | - currentPage.value -= 1; // 回退页码 | 554 | + currentPage.value -= 1 // 回退页码 |
| 492 | Taro.showToast({ | 555 | Taro.showToast({ |
| 493 | title: '加载失败,请重试', | 556 | title: '加载失败,请重试', |
| 494 | - icon: 'none' | 557 | + icon: 'none', |
| 495 | - }); | 558 | + }) |
| 496 | } finally { | 559 | } finally { |
| 497 | - isLoadingMore.value = false; | 560 | + isLoadingMore.value = false |
| 498 | } | 561 | } |
| 499 | -}; | 562 | +} |
| 500 | </script> | 563 | </script> |
| 501 | <style lang="less"> | 564 | <style lang="less"> |
| 502 | @import './index.less'; | 565 | @import './index.less'; | ... | ... |
| ... | @@ -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> | ... | ... |
| ... | @@ -12,7 +12,11 @@ | ... | @@ -12,7 +12,11 @@ |
| 12 | <!-- Hero Image --> | 12 | <!-- Hero Image --> |
| 13 | <view class="w-full mb-6"> | 13 | <view class="w-full mb-6"> |
| 14 | <view class="w-full h-48 rounded-2xl overflow-hidden"> | 14 | <view class="w-full h-48 rounded-2xl overflow-hidden"> |
| 15 | - <image :src="welcomeHomeImg" alt="家庭在上海外滩散步" class="w-full h-full object-cover" /> | 15 | + <image |
| 16 | + :src="welcomeHomeImg" | ||
| 17 | + alt="家庭在上海外滩散步" | ||
| 18 | + class="w-full h-full object-cover" | ||
| 19 | + /> | ||
| 16 | </view> | 20 | </view> |
| 17 | </view> | 21 | </view> |
| 18 | <!-- Steps Section --> | 22 | <!-- Steps Section --> |
| ... | @@ -21,19 +25,21 @@ | ... | @@ -21,19 +25,21 @@ |
| 21 | <view class="space-y-6"> | 25 | <view class="space-y-6"> |
| 22 | <!-- Step 1 --> | 26 | <!-- Step 1 --> |
| 23 | <view class="flex items-center"> | 27 | <view class="flex items-center"> |
| 24 | - <view class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-lg mr-4 flex-shrink-0"> | 28 | + <view |
| 29 | + class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-lg mr-4 flex-shrink-0" | ||
| 30 | + > | ||
| 25 | 1 | 31 | 1 |
| 26 | </view> | 32 | </view> |
| 27 | <view> | 33 | <view> |
| 28 | <h3 class="font-bold">创建家庭</h3> | 34 | <h3 class="font-bold">创建家庭</h3> |
| 29 | - <p class="text-gray-600 text-sm"> | 35 | + <p class="text-gray-600 text-sm">60岁以上家长创建家庭,设置专属口令</p> |
| 30 | - 60岁以上家长创建家庭,设置专属口令 | ||
| 31 | - </p> | ||
| 32 | </view> | 36 | </view> |
| 33 | </view> | 37 | </view> |
| 34 | <!-- Step 2 --> | 38 | <!-- Step 2 --> |
| 35 | <view class="flex items-center"> | 39 | <view class="flex items-center"> |
| 36 | - <view class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-lg mr-4 flex-shrink-0"> | 40 | + <view |
| 41 | + class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-lg mr-4 flex-shrink-0" | ||
| 42 | + > | ||
| 37 | 2 | 43 | 2 |
| 38 | </view> | 44 | </view> |
| 39 | <view> | 45 | <view> |
| ... | @@ -43,24 +49,31 @@ | ... | @@ -43,24 +49,31 @@ |
| 43 | </view> | 49 | </view> |
| 44 | <!-- Step 3 --> | 50 | <!-- Step 3 --> |
| 45 | <view class="flex items-center"> | 51 | <view class="flex items-center"> |
| 46 | - <view class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-lg mr-4 flex-shrink-0"> | 52 | + <view |
| 53 | + class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-lg mr-4 flex-shrink-0" | ||
| 54 | + > | ||
| 47 | 3 | 55 | 3 |
| 48 | </view> | 56 | </view> |
| 49 | <view> | 57 | <view> |
| 50 | <h3 class="font-bold">同步步数,兑换好礼</h3> | 58 | <h3 class="font-bold">同步步数,兑换好礼</h3> |
| 51 | - <p class="text-gray-600 text-sm"> | 59 | + <p class="text-gray-600 text-sm">每日同步微信步数,兑换抵用券</p> |
| 52 | - 每日同步微信步数,兑换抵用券 | ||
| 53 | - </p> | ||
| 54 | </view> | 60 | </view> |
| 55 | </view> | 61 | </view> |
| 56 | </view> | 62 | </view> |
| 57 | </view> | 63 | </view> |
| 58 | <!-- Action Buttons --> | 64 | <!-- Action Buttons --> |
| 59 | <view class="space-y-4 mt-auto"> | 65 | <view class="space-y-4 mt-auto"> |
| 60 | - <view @tap="handleNavigate('/pages/CreateFamily/index')" class="w-full py-3.5 bg-blue-500 text-white text-lg font-medium rounded-full text-center"> | 66 | + <view |
| 67 | + @tap="handleNavigate('/pages/CreateFamily/index')" | ||
| 68 | + class="w-full py-3.5 bg-blue-500 text-white text-lg font-medium rounded-full text-center" | ||
| 69 | + > | ||
| 61 | 创建家庭 | 70 | 创建家庭 |
| 62 | </view> | 71 | </view> |
| 63 | - <view @tap="handleNavigate('/pages/JoinFamily/index')" class="w-full py-3.5 bg-white text-gray-800 text-lg font-medium rounded-full border border-gray-300 text-center" style="margin-bottom: 1rem;"> | 72 | + <view |
| 73 | + @tap="handleNavigate('/pages/JoinFamily/index')" | ||
| 74 | + class="w-full py-3.5 bg-white text-gray-800 text-lg font-medium rounded-full border border-gray-300 text-center" | ||
| 75 | + style="margin-bottom: 1rem" | ||
| 76 | + > | ||
| 64 | 加入家庭 | 77 | 加入家庭 |
| 65 | </view> | 78 | </view> |
| 66 | </view> | 79 | </view> |
| ... | @@ -70,24 +83,16 @@ | ... | @@ -70,24 +83,16 @@ |
| 70 | 83 | ||
| 71 | <!-- Dialog Components --> | 84 | <!-- Dialog Components --> |
| 72 | <!-- 个人信息收集说明弹窗 --> | 85 | <!-- 个人信息收集说明弹窗 --> |
| 73 | - <nut-dialog | 86 | + <nut-dialog v-model:visible="showPrivacyDialog" title="个人信息收集说明"> |
| 74 | - v-model:visible="showPrivacyDialog" | ||
| 75 | - title="个人信息收集说明" | ||
| 76 | - > | ||
| 77 | <template #default> | 87 | <template #default> |
| 78 | - <view class=" text-gray-700 leading-loose text-sm text-left break-words"> | 88 | + <view class="text-gray-700 leading-loose text-sm text-left break-words"> |
| 79 | {{ privacyContent }} | 89 | {{ privacyContent }} |
| 80 | </view> | 90 | </view> |
| 81 | </template> | 91 | </template> |
| 82 | <template #footer> | 92 | <template #footer> |
| 83 | <nut-row :gutter="10"> | 93 | <nut-row :gutter="10"> |
| 84 | <nut-col :span="12"> | 94 | <nut-col :span="12"> |
| 85 | - <nut-button | 95 | + <nut-button @click="onPrivacyCancel" type="default" size="normal" block> |
| 86 | - @click="onPrivacyCancel" | ||
| 87 | - type="default" | ||
| 88 | - size="normal" | ||
| 89 | - block | ||
| 90 | - > | ||
| 91 | 取消操作 | 96 | 取消操作 |
| 92 | </nut-button> | 97 | </nut-button> |
| 93 | </nut-col> | 98 | </nut-col> |
| ... | @@ -107,10 +112,7 @@ | ... | @@ -107,10 +112,7 @@ |
| 107 | </nut-dialog> | 112 | </nut-dialog> |
| 108 | 113 | ||
| 109 | <!-- 年龄限制提示弹窗 --> | 114 | <!-- 年龄限制提示弹窗 --> |
| 110 | - <nut-dialog | 115 | + <nut-dialog v-model:visible="showAgeDialog" title="温馨提示"> |
| 111 | - v-model:visible="showAgeDialog" | ||
| 112 | - title="温馨提示" | ||
| 113 | - > | ||
| 114 | <template #default> | 116 | <template #default> |
| 115 | <view class="text-center text-gray-700 text-sm leading-loose"> | 117 | <view class="text-center text-gray-700 text-sm leading-loose"> |
| 116 | 您需要年满60岁才能创建家庭 | 118 | 您需要年满60岁才能创建家庭 |
| ... | @@ -134,43 +136,46 @@ | ... | @@ -134,43 +136,46 @@ |
| 134 | </nut-dialog> | 136 | </nut-dialog> |
| 135 | 137 | ||
| 136 | <!-- 广告遮罩层 --> | 138 | <!-- 广告遮罩层 --> |
| 137 | - <AdOverlay | 139 | + <AdOverlay ref="adOverlayRef" @close="handleAdClose" @click="handleAdClick" /> |
| 138 | - ref="adOverlayRef" | ||
| 139 | - @close="handleAdClose" | ||
| 140 | - @click="handleAdClick" | ||
| 141 | - /> | ||
| 142 | </view> | 140 | </view> |
| 143 | </template> | 141 | </template> |
| 144 | 142 | ||
| 145 | <script setup> | 143 | <script setup> |
| 146 | -import { ref } from 'vue'; | 144 | +import { ref } from 'vue' |
| 147 | -import Taro, { useDidShow, useLoad } from '@tarojs/taro'; | 145 | +import Taro, { useDidShow, useLoad } from '@tarojs/taro' |
| 148 | -import { handleSharePageAuth, addShareFlag } from '@/utils/authRedirect'; | 146 | +import { handleSharePageAuth, addShareFlag } from '@/utils/authRedirect' |
| 149 | -import BottomNav from '@/components/BottomNav.vue'; | 147 | +import BottomNav from '@/components/BottomNav.vue' |
| 150 | import AdOverlay from '@/components/AdOverlay.vue' | 148 | import AdOverlay from '@/components/AdOverlay.vue' |
| 151 | // 获取接口信息 | 149 | // 获取接口信息 |
| 152 | -import { getUserProfileAPI } from '@/api/user'; | 150 | +import { getUserProfileAPI } from '@/api/user' |
| 151 | +import { appendReturnUrlParam, normalizeReturnUrl } from '@/utils/returnUrl' | ||
| 152 | +import { isUserProfileComplete } from '@/utils/userProfile' | ||
| 153 | // 导入主题颜色 | 153 | // 导入主题颜色 |
| 154 | -import { THEME_COLORS } from '@/utils/config'; | 154 | +import { THEME_COLORS } from '@/utils/config' |
| 155 | 155 | ||
| 156 | -const welcomeHomeImg = 'https://cdn.ipadbiz.cn/lls_prog/images/iwEcAqNqcGcDAQTRCAAF0QSABrA-9ERC3jG0pwiy88rQQ3IAB9IIwZFkCAAJomltCgAL0gADu3A.jpg'; | 156 | +const welcomeHomeImg = |
| 157 | + 'https://cdn.ipadbiz.cn/lls_prog/images/iwEcAqNqcGcDAQTRCAAF0QSABrA-9ERC3jG0pwiy88rQQ3IAB9IIwZFkCAAJomltCgAL0gADu3A.jpg' | ||
| 157 | 158 | ||
| 158 | -const userAge = ref(null); | 159 | +const userAge = ref(null) |
| 159 | -const userInfo = ref({}); | 160 | +const userInfo = ref({}) |
| 160 | -const canCreateFamily = ref(true); | 161 | +const canCreateFamily = ref(true) |
| 161 | -const hasProfile = ref(false); | 162 | +const hasProfile = ref(false) |
| 163 | +const returnUrl = ref('') | ||
| 164 | +const regSource = ref('') | ||
| 165 | +const regStageId = ref('') | ||
| 162 | 166 | ||
| 163 | // Dialog相关响应式数据 | 167 | // Dialog相关响应式数据 |
| 164 | -const showPrivacyDialog = ref(false); | 168 | +const showPrivacyDialog = ref(false) |
| 165 | -const showAgeDialog = ref(false); | 169 | +const showAgeDialog = ref(false) |
| 166 | -const pendingNavigateUrl = ref(''); | 170 | +const pendingNavigateUrl = ref('') |
| 167 | 171 | ||
| 168 | // 个人信息收集说明内容 | 172 | // 个人信息收集说明内容 |
| 169 | -const privacyContent = `为了提供更好的服务,我们需要收集您的基本信息:\n1.头像和昵称:用于家庭成员识别 \n2.出生年月:验证年龄资格,60岁以上可创建家庭。\n我们承诺严格保护您的个人隐私,仅用于家庭功能和活动服务。`; | 173 | +const privacyContent = |
| 174 | + '为了提供更好的服务,我们需要收集您的基本信息:\n1.头像和昵称:用于家庭成员识别 \n2.出生年月:验证年龄资格,60岁以上可创建家庭。\n我们承诺严格保护您的个人隐私,仅用于家庭功能和活动服务。' | ||
| 170 | 175 | ||
| 171 | -const navigateTo = (url) => { | 176 | +const navigateTo = url => { |
| 172 | - Taro.navigateTo({ url }); | 177 | + Taro.navigateTo({ url }) |
| 173 | -}; | 178 | +} |
| 174 | 179 | ||
| 175 | // 广告遮罩层数据 | 180 | // 广告遮罩层数据 |
| 176 | 181 | ||
| ... | @@ -187,112 +192,159 @@ const handleAdClose = () => { | ... | @@ -187,112 +192,159 @@ const handleAdClose = () => { |
| 187 | * 处理广告遮罩层点击事件 | 192 | * 处理广告遮罩层点击事件 |
| 188 | * @param {string} targetPage - 跳转的目标页面 | 193 | * @param {string} targetPage - 跳转的目标页面 |
| 189 | */ | 194 | */ |
| 190 | -const handleAdClick = (targetPage) => { | 195 | +const handleAdClick = targetPage => { |
| 191 | // 如果跳转路径是欢迎页和首页,不跳转直接关闭弹框 | 196 | // 如果跳转路径是欢迎页和首页,不跳转直接关闭弹框 |
| 192 | if (targetPage === '/pages/Dashboard/index' || targetPage === '/pages/Welcome/index') { | 197 | if (targetPage === '/pages/Dashboard/index' || targetPage === '/pages/Welcome/index') { |
| 193 | handleAdClose() | 198 | handleAdClose() |
| 194 | return | 199 | return |
| 195 | } | 200 | } |
| 196 | // 跳转到目标页面 | 201 | // 跳转到目标页面 |
| 197 | - Taro.navigateTo({ url: targetPage }); | 202 | + Taro.navigateTo({ url: targetPage }) |
| 198 | } | 203 | } |
| 199 | 204 | ||
| 200 | useDidShow(async () => { | 205 | useDidShow(async () => { |
| 201 | // 获取用户的个人信息 | 206 | // 获取用户的个人信息 |
| 202 | - const { code, data } = await getUserProfileAPI(); | 207 | + const { code, data } = await getUserProfileAPI() |
| 203 | if (code) { | 208 | if (code) { |
| 204 | - userInfo.value = data?.user?.nickname ? data.user : { | 209 | + userInfo.value = data?.user?.nickname |
| 205 | - avatar_url: null, | 210 | + ? data.user |
| 206 | - nickname: null, | 211 | + : { |
| 207 | - birth_date: null, | 212 | + avatar_url: null, |
| 208 | - wheelchair: null, | 213 | + nickname: null, |
| 209 | - wheelchair_text: null, | 214 | + birth_date: null, |
| 210 | - phone: null, | 215 | + wheelchair: null, |
| 211 | - }; | 216 | + wheelchair_text: null, |
| 217 | + phone: null, | ||
| 218 | + } | ||
| 212 | 219 | ||
| 213 | // 计算用户年龄 | 220 | // 计算用户年龄 |
| 214 | if (userInfo.value.birth_date) { | 221 | if (userInfo.value.birth_date) { |
| 215 | - const birthDate = new Date(userInfo.value.birth_date); | 222 | + const birthDate = new Date(userInfo.value.birth_date) |
| 216 | - const today = new Date(); | 223 | + const today = new Date() |
| 217 | - let age = today.getFullYear() - birthDate.getFullYear(); | 224 | + let age = today.getFullYear() - birthDate.getFullYear() |
| 218 | - const monthDiff = today.getMonth() - birthDate.getMonth(); | 225 | + const monthDiff = today.getMonth() - birthDate.getMonth() |
| 219 | 226 | ||
| 220 | // 如果还没到生日,年龄减1 | 227 | // 如果还没到生日,年龄减1 |
| 221 | if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { | 228 | if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { |
| 222 | - age--; | 229 | + age-- |
| 223 | } | 230 | } |
| 224 | 231 | ||
| 225 | - userAge.value = age; | 232 | + userAge.value = age |
| 226 | // 检查是否满60岁,可以创建家庭 | 233 | // 检查是否满60岁,可以创建家庭 |
| 227 | - canCreateFamily.value = age >= 60; | 234 | + canCreateFamily.value = age >= 60 |
| 228 | } else { | 235 | } else { |
| 229 | - userAge.value = null; | 236 | + userAge.value = null |
| 230 | - canCreateFamily.value = false; | 237 | + canCreateFamily.value = false |
| 231 | } | 238 | } |
| 232 | 239 | ||
| 233 | // 检查用户是否完善了个人信息 | 240 | // 检查用户是否完善了个人信息 |
| 234 | - hasProfile.value = userInfo.value.nickname && userInfo.value.birth_date && userInfo.value.wheelchair_needed !== null; | 241 | + hasProfile.value = isUserProfileComplete(userInfo.value) |
| 235 | } | 242 | } |
| 236 | -}); | 243 | +}) |
| 244 | + | ||
| 245 | +const handleNavigate = url => { | ||
| 246 | + const nextUrl = appendReturnUrl(url) | ||
| 237 | 247 | ||
| 238 | -const handleNavigate = (url) => { | ||
| 239 | if (!hasProfile.value) { | 248 | if (!hasProfile.value) { |
| 240 | // 显示个人信息收集说明弹窗 | 249 | // 显示个人信息收集说明弹窗 |
| 241 | - pendingNavigateUrl.value = '/pages/AddProfile/index'; | 250 | + pendingNavigateUrl.value = buildAddProfileUrl() |
| 242 | - showPrivacyDialog.value = true; | 251 | + showPrivacyDialog.value = true |
| 243 | - return; | 252 | + return |
| 244 | } | 253 | } |
| 245 | 254 | ||
| 246 | if (url === '/pages/CreateFamily/index') { | 255 | if (url === '/pages/CreateFamily/index') { |
| 247 | if (!canCreateFamily.value) { | 256 | if (!canCreateFamily.value) { |
| 248 | // 显示年龄限制提示弹窗 | 257 | // 显示年龄限制提示弹窗 |
| 249 | - showAgeDialog.value = true; | 258 | + showAgeDialog.value = true |
| 250 | - return; | 259 | + return |
| 251 | } | 260 | } |
| 252 | } | 261 | } |
| 253 | 262 | ||
| 254 | - navigateTo(url); | 263 | + navigateTo(nextUrl) |
| 255 | -}; | 264 | +} |
| 256 | 265 | ||
| 257 | // Dialog事件处理方法 | 266 | // Dialog事件处理方法 |
| 258 | const onPrivacyCancel = () => { | 267 | const onPrivacyCancel = () => { |
| 259 | - showPrivacyDialog.value = false; | 268 | + showPrivacyDialog.value = false |
| 260 | - pendingNavigateUrl.value = ''; | 269 | + pendingNavigateUrl.value = '' |
| 261 | -}; | 270 | +} |
| 262 | 271 | ||
| 263 | const onPrivacyConfirm = () => { | 272 | const onPrivacyConfirm = () => { |
| 264 | - showPrivacyDialog.value = false; | 273 | + showPrivacyDialog.value = false |
| 265 | if (pendingNavigateUrl.value) { | 274 | if (pendingNavigateUrl.value) { |
| 266 | - navigateTo(pendingNavigateUrl.value); | 275 | + navigateTo(pendingNavigateUrl.value) |
| 267 | - pendingNavigateUrl.value = ''; | 276 | + pendingNavigateUrl.value = '' |
| 268 | } | 277 | } |
| 269 | -}; | 278 | +} |
| 270 | 279 | ||
| 271 | const onAgeConfirm = () => { | 280 | const onAgeConfirm = () => { |
| 272 | - showAgeDialog.value = false; | 281 | + showAgeDialog.value = false |
| 273 | -}; | 282 | +} |
| 283 | + | ||
| 284 | +useLoad(options => { | ||
| 285 | + // 处理分享页面的授权逻辑 | ||
| 286 | + handleSharePageAuth(options) | ||
| 287 | + returnUrl.value = normalizeReturnUrl(options?.return_url || '') | ||
| 288 | + regSource.value = options?.reg_source || '' | ||
| 289 | + regStageId.value = options?.reg_stage_id || '' | ||
| 290 | +}) | ||
| 291 | + | ||
| 292 | +const appendReturnUrl = url => { | ||
| 293 | + return appendReturnUrlParam(url, returnUrl.value) | ||
| 294 | +} | ||
| 295 | + | ||
| 296 | +const buildAddProfileUrl = () => { | ||
| 297 | + if (!returnUrl.value && !regSource.value && !regStageId.value) { | ||
| 298 | + return '/pages/AddProfile/index' | ||
| 299 | + } | ||
| 300 | + | ||
| 301 | + // 从扫码链路进入 Welcome 时,补资料完成后应该先回 Welcome, | ||
| 302 | + // 继续完成创建/加入家庭,最后再由家庭流程回到打卡详情页。 | ||
| 303 | + const params = new URLSearchParams() | ||
| 304 | + if (regSource.value) { | ||
| 305 | + params.set('reg_source', regSource.value) | ||
| 306 | + } | ||
| 307 | + if (regStageId.value) { | ||
| 308 | + params.set('reg_stage_id', regStageId.value) | ||
| 309 | + } | ||
| 310 | + params.set('return_url', buildWelcomeReturnUrl()) | ||
| 311 | + | ||
| 312 | + return `/pages/AddProfile/index?${params.toString()}` | ||
| 313 | +} | ||
| 274 | 314 | ||
| 275 | -useLoad((options) => { | 315 | +const buildWelcomeReturnUrl = () => { |
| 276 | - // 处理分享页面的授权逻辑 | 316 | + const params = new URLSearchParams() |
| 277 | - handleSharePageAuth(options); | 317 | + |
| 278 | -}); | 318 | + if (regSource.value) { |
| 319 | + params.set('reg_source', regSource.value) | ||
| 320 | + } | ||
| 321 | + if (regStageId.value) { | ||
| 322 | + params.set('reg_stage_id', regStageId.value) | ||
| 323 | + } | ||
| 324 | + if (returnUrl.value) { | ||
| 325 | + params.set('return_url', returnUrl.value) | ||
| 326 | + } | ||
| 327 | + | ||
| 328 | + const query = params.toString() | ||
| 329 | + return query ? `/pages/Welcome/index?${query}` : '/pages/Welcome/index' | ||
| 330 | +} | ||
| 279 | 331 | ||
| 280 | /** | 332 | /** |
| 281 | * 定义分享给朋友的内容 | 333 | * 定义分享给朋友的内容 |
| 282 | * @returns {Object} 分享配置对象 | 334 | * @returns {Object} 分享配置对象 |
| 283 | */ | 335 | */ |
| 284 | -const onShareAppMessage = (res) => { | 336 | +const onShareAppMessage = res => { |
| 285 | - const shareData = { | 337 | + const shareData = { |
| 286 | - title: '欢迎加入老来赛', | 338 | + title: '欢迎加入老来赛', |
| 287 | - path: `/pages/Welcome/index`, | 339 | + path: '/pages/Welcome/index', |
| 288 | - imageUrl: '' | 340 | + imageUrl: '', |
| 289 | - }; | 341 | + } |
| 290 | - | 342 | + |
| 291 | - return shareData; | 343 | + return shareData |
| 292 | } | 344 | } |
| 293 | 345 | ||
| 294 | // 使用Taro的useShareAppMessage Hook来处理分享 | 346 | // 使用Taro的useShareAppMessage Hook来处理分享 |
| 295 | -Taro.useShareAppMessage((res) => { | 347 | +Taro.useShareAppMessage(res => { |
| 296 | - return onShareAppMessage(res); | 348 | + return onShareAppMessage(res) |
| 297 | -}); | 349 | +}) |
| 298 | </script> | 350 | </script> | ... | ... |
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