hookehuyr

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

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

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