hookehuyr

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
......@@ -28,6 +28,8 @@ declare module 'vue' {
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
NutRow: typeof import('@nutui/nutui-taro')['Row']
NutSearchbar: typeof import('@nutui/nutui-taro')['Searchbar']
NutSwiper: typeof import('@nutui/nutui-taro')['Swiper']
NutSwiperItem: typeof import('@nutui/nutui-taro')['SwiperItem']
NutToast: typeof import('@nutui/nutui-taro')['Toast']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PointsCollector: typeof import('./src/components/PointsCollector.vue')['default']
......@@ -35,6 +37,7 @@ declare module 'vue' {
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
PrimaryButton: typeof import('./src/components/PrimaryButton.vue')['default']
RankingCard: typeof import('./src/components/RankingCard.vue')['default']
RichTextRenderer: typeof import('./src/components/RichTextRenderer.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScrollableFamilyList: typeof import('./src/components/ScrollableFamilyList.vue')['default']
......
......@@ -49,6 +49,7 @@
"@nutui/icons-vue-taro": "^0.0.9",
"@nutui/nutui-taro": "^4.3.13",
"@tarojs/components": "4.1.7",
"@tarojs/extend": "4.1.7",
"@tarojs/helper": "4.1.7",
"@tarojs/plugin-framework-vue3": "4.1.7",
"@tarojs/plugin-html": "4.1.7",
......
......@@ -23,6 +23,9 @@ importers:
'@tarojs/components':
specifier: 4.1.7
version: 4.1.7(@tarojs/helper@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96))
'@tarojs/extend':
specifier: 4.1.7
version: 4.1.7(@tarojs/runtime@4.1.7)(@tarojs/taro@4.1.7(@tarojs/components@4.1.7(@tarojs/helper@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))(@tarojs/helper@4.1.7)(@tarojs/shared@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))
'@tarojs/helper':
specifier: 4.1.7
version: 4.1.7
......@@ -1908,6 +1911,13 @@ packages:
vue:
optional: true
'@tarojs/extend@4.1.7':
resolution: {integrity: sha512-tVZ6NcCsHTBP2UJrWZg4BrK5Rm8z8PsaAcv7uYUpX3MRrtC6s0fYOVhMFMryVEcR9KxoqrMd22le8wVQY0PNpQ==}
engines: {node: '>= 18'}
peerDependencies:
'@tarojs/runtime': 4.1.7
'@tarojs/taro': 4.1.7
'@tarojs/helper@4.1.7':
resolution: {integrity: sha512-bOll/uJuH/ChiCdcmJK6H3rDkt5R4WVMqzLTCCdN5FnlsL+UMFosj9dgPZmMwoyQq4Tf6m5b3cHg6FUtsU7ahA==}
engines: {node: '>= 18'}
......@@ -9086,6 +9096,11 @@ snapshots:
- webpack-chain
- webpack-dev-server
'@tarojs/extend@4.1.7(@tarojs/runtime@4.1.7)(@tarojs/taro@4.1.7(@tarojs/components@4.1.7(@tarojs/helper@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))(@tarojs/helper@4.1.7)(@tarojs/shared@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))':
dependencies:
'@tarojs/runtime': 4.1.7
'@tarojs/taro': 4.1.7(@tarojs/components@4.1.7(@tarojs/helper@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96)))(@tarojs/helper@4.1.7)(@tarojs/shared@4.1.7)(html-webpack-plugin@5.6.4(webpack@5.78.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.20(typescript@5.9.2))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.78.0(@swc/core@1.3.96)))(webpack@5.78.0(@swc/core@1.3.96))
'@tarojs/helper@4.1.7':
dependencies:
'@babel/core': 7.28.3
......
......@@ -5,13 +5,16 @@
* @FilePath: /lls_program/src/api/map.js
* @Description: 文件描述
*/
import { fn, fetch } from './fn';
import { fn, fetch } from './fn'
const Api = {
GET_MAP_URL: '/srv/?a=map&t=get_map_url',
GET_POSTER_DETAIL: '/srv/?a=map&t=poster',
GET_ACTIVITY_STATUS: '/srv/?a=map&t=get_map_url',
SAVE_POSTER_BACKGROUND: '/srv/?a=map&t=save_poster_background',
GET_MAP_URL: '/srv/?a=map&t=get_map_url',
GET_POSTER_DETAIL: '/srv/?a=map&t=poster',
GET_ACTIVITY_STATUS: '/srv/?a=map&t=get_map_url',
SAVE_POSTER_BACKGROUND: '/srv/?a=map&t=save_poster_background',
GET_SCAN_STAGE_LIST: '/srv/?a=map_activity&t=scan_stage_list',
GET_SCAN_STAGE_DETAIL: '/srv/?a=map_activity&t=scan_stage_detail',
SUBMIT_SCAN_CHECKIN: '/srv/?a=map_activity&t=checkin',
}
/**
......@@ -22,7 +25,7 @@ const Api = {
* @returns {Object} response.data - 响应数据
* @returns {string} response.data.url - 地图URL
*/
export const getMapUrlAPI = (params) => fn(fetch.get(Api.GET_MAP_URL, params));
export const getMapUrlAPI = params => fn(fetch.get(Api.GET_MAP_URL, params))
/**
* @description: 获取海报详情
......@@ -46,7 +49,7 @@ export const getMapUrlAPI = (params) => fn(fetch.get(Api.GET_MAP_URL, params));
* @returns {string} response.data.family.avatar_url - 家庭头像URL
* @returns {string} response.data.qrcode_url - 小程序码
*/
export const getPosterDetailAPI = (params) => fn(fetch.get(Api.GET_POSTER_DETAIL, params));
export const getPosterDetailAPI = params => fn(fetch.get(Api.GET_POSTER_DETAIL, params))
/**
* @description: 获取活动状态
......@@ -57,7 +60,7 @@ export const getPosterDetailAPI = (params) => fn(fetch.get(Api.GET_POSTER_DETAIL
* @returns {boolean} response.data.is_begin - 活动是否已经开始, true 已开始, false 未开始
* @returns {boolean} response.data.is_ended - 活动是否已经结束, true 已结束, false 未结束
*/
export const getActivityStatusAPI = (params) => fn(fetch.get(Api.GET_ACTIVITY_STATUS, params));
export const getActivityStatusAPI = params => fn(fetch.get(Api.GET_ACTIVITY_STATUS, params))
/**
* @description: 保存海报背景
......@@ -68,4 +71,52 @@ export const getActivityStatusAPI = (params) => fn(fetch.get(Api.GET_ACTIVITY_ST
* @returns {string} response.msg - 响应消息
* @returns {Object} response.data - 响应数据
*/
export const savePosterBackgroundAPI = (params) => fn(fetch.post(Api.SAVE_POSTER_BACKGROUND, params));
export const savePosterBackgroundAPI = params => fn(fetch.post(Api.SAVE_POSTER_BACKGROUND, params))
/**
* @description: 获取扫码关卡列表
* @param {Object} params - 请求参数
* @param {string} [params.page] - 页码,从 0 开始
* @param {string} [params.limit] - 每页条数
* @param {string} [params.activity_id] - 活动ID
* @returns {number} response.code - 响应状态码
* @returns {string} response.msg - 响应消息
* @returns {Object} response.data - 响应数据
* @returns {Array} response.data.stages - 关卡列表
* @returns {number} response.data.stages[].id - 关卡ID
* @returns {string} response.data.stages[].title - 关卡标题
* @returns {boolean} response.data.stages[].is_checked - 是否已打卡
*/
export const getScanStageListAPI = params => fn(fetch.get(Api.GET_SCAN_STAGE_LIST, params))
/**
* @description: 获取扫码关卡详情
* @param {Object} params - 请求参数
* @param {string} params.id - 关卡ID
* @returns {number} response.code - 响应状态码
* @returns {string} response.msg - 响应消息
* @returns {Object} response.data - 响应数据
* @returns {number} response.data.id - 关卡ID
* @returns {string} response.data.title - 关卡标题
* @returns {Array<string>} response.data.banner - 轮播图
* @returns {boolean} response.data.is_checked - 是否已打卡
* @returns {string} response.data.discount_title - 打卡点底部优惠标题
* @returns {string} response.data.note - 简介
* @returns {string} response.data.introduction - 底部富文本
* @returns {boolean} response.data.geo_enabled - 是否启用地理位置限制
* @returns {number} response.data.center_lng - 经度
* @returns {number} response.data.center_lat - 纬度
* @returns {number} response.data.radius_meters - 半径,单位米
*/
export const getScanStageDetailAPI = params => fn(fetch.get(Api.GET_SCAN_STAGE_DETAIL, params))
/**
* @description: 提交扫码打卡
* @param {Object} params - 请求参数
* @param {string} params.activity_id - 活动ID
* @param {string} params.detail_id - 关卡ID
* @param {string} [params.openid] - 用户openid,接口定义中为可选
* @returns {number} response.code - 响应状态码
* @returns {string} response.msg - 响应消息
*/
export const submitScanCheckinAPI = params => fn(fetch.post(Api.SUBMIT_SCAN_CHECKIN, params))
......
<template>
<view :id="containerId" class="rich-text-renderer" v-html="processedContent"></view>
</template>
<script setup>
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import Taro from '@tarojs/taro'
import { $ } from '@tarojs/extend'
const props = defineProps({
content: {
type: String,
default: '',
},
enableTransform: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['image-preview', 'file-click', 'link-copy'])
const processedContent = ref('')
const containerId = `rich-text-renderer-${Math.random().toString(36).slice(-8)}`
const containerSelector = `#${containerId}`
const previousTransformElement = Taro.options.html?.transformElement
const decodeHtmlEntities = html => {
if (!html) {
return ''
}
if (process.env.TARO_ENV === 'h5' && typeof document !== 'undefined') {
try {
const textArea = document.createElement('textarea')
textArea.innerHTML = html
const decoded = textArea.value
if (decoded !== html) {
return decoded
}
} catch (error) {
console.warn('[RichTextRenderer] DOM 解码失败,改用映射表', error)
}
}
const entityMap = {
'&nbsp;': '\u00A0',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&apos;': "'",
'&copy;': '©',
'&reg;': '®',
'&trade;': '™',
'&mdash;': '—',
'&ndash;': '–',
'&hellip;': '…',
'&laquo;': '«',
'&raquo;': '»',
'&lsquo;': '\u2018',
'&rsquo;': '\u2019',
'&ldquo;': '"',
'&rdquo;': '"',
}
let result = html
result = result.replace(/&#(\d+);/g, (_match, dec) => String.fromCharCode(dec))
result = result.replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) =>
String.fromCharCode(parseInt(hex, 16))
)
Object.entries(entityMap).forEach(([entity, char]) => {
result = result.split(entity).join(char)
})
return result
}
const replaceAnchorTags = html => {
let content = html
// 小程序 rich-text 对原生 a 标签交互能力有限,统一替换成可绑定事件的块级节点。
content = content.replace(/<a\s+/g, '<div class="rich-text-link" ')
content = content.replace(/href=/g, 'data-href=')
content = content.replace(/<\/a>/g, '</div>')
return content
}
const processContent = raw => {
if (!raw) {
processedContent.value = ''
return
}
let processed = raw
processed = decodeHtmlEntities(processed)
processed = replaceAnchorTags(processed)
processedContent.value = processed
}
const isImageUrl = (url = '') => /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(url)
const getFileNameFromUrl = (url = '') => {
const cleanUrl = url.split('?')[0]
const segments = cleanUrl.split('/')
return segments[segments.length - 1] || 'document.pdf'
}
const copyLink = (url, fileName = '链接') => {
Taro.setClipboardData({
data: url,
success: () => {
Taro.showToast({
title: '链接已复制',
icon: 'success',
duration: 2000,
})
emit('link-copy', { url, fileName })
},
fail: error => {
console.error('[RichTextRenderer] 复制链接失败:', error)
Taro.showToast({
title: '复制失败',
icon: 'none',
})
},
})
}
const openFileLink = async (url, fileName) => {
emit('file-click', { url, fileName })
if (isImageUrl(url)) {
Taro.previewImage({
urls: [url],
current: url,
indicator: 'default',
loop: false,
})
return
}
Taro.showLoading({ title: '文件打开中...' })
try {
const downloadResult = await Taro.downloadFile({
url,
})
if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) {
throw new Error(`下载失败: ${downloadResult.statusCode}`)
}
await Taro.openDocument({
filePath: downloadResult.tempFilePath,
showMenu: true,
})
} catch (error) {
console.error('[RichTextRenderer] 文件打开失败:', error)
Taro.showModal({
title: '打开失败',
content: '当前文件无法直接预览,是否复制链接后用其他应用打开?',
confirmText: '复制链接',
cancelText: '取消',
success: res => {
if (res.confirm) {
copyLink(url, fileName)
}
},
})
} finally {
Taro.hideLoading()
}
}
const setupTransformElement = () => {
if (!props.enableTransform) {
Taro.options.html.transformElement = previousTransformElement
return
}
Taro.options.html.transformElement = element => {
const transformed = previousTransformElement ? previousTransformElement(element) : element
const nodeName = transformed?.nodeName?.toLowerCase() || ''
const tagName = transformed?.tagName?.toLowerCase() || ''
const isImg =
nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image'
if (!isImg) {
return transformed
}
// 统一兜底图片样式,避免后端富文本里的宽高写死后撑坏小程序布局。
if (transformed?.setAttribute) {
transformed.setAttribute('mode', 'widthFix')
transformed.setAttribute('data-rich-image', 'true')
transformed.setAttribute(
'style',
'width:100%!important;max-width:100%!important;height:auto!important;display:block;margin:24rpx 0;border-radius:16rpx;'
)
}
return transformed
}
}
const bindImageEvents = () => {
nextTick(() => {
const container = $(containerSelector)
const imgs = container.find('.h5-img')
imgs.forEach(img => {
const $img = $(img)
$img.off('longpress')
$img.on('longpress', () => {
const src = $img.attr('src')
if (!src) {
return
}
Taro.previewImage({
urls: [src],
current: src,
indicator: 'default',
loop: false,
success: () => {
emit('image-preview', { src })
},
})
})
})
})
}
const bindFileLinkEvents = () => {
nextTick(() => {
const container = $(containerSelector)
const richTextLinks = container.find('.rich-text-link')
const fileLinks = container.find('._file_list')
let allLinks = []
if (richTextLinks.length > 0) {
allLinks = allLinks.concat(richTextLinks.toArray())
}
if (fileLinks.length > 0) {
allLinks = allLinks.concat(fileLinks.toArray())
}
allLinks.forEach(el => {
const $el = $(el)
const dataHref = $el.attr('data-href') || $el.attr('href')
if (!dataHref) {
return
}
$el.off('tap')
$el.on('tap', async () => {
const fileName =
$el.find('span span span').first().text() ||
$el.text().trim().substring(0, 50) ||
getFileNameFromUrl(dataHref)
await openFileLink(dataHref, fileName)
})
})
})
}
const bindLinkLongPressEvents = () => {
nextTick(() => {
const container = $(containerSelector)
const richTextLinks = container.find('.rich-text-link')
const anchorLinks = container.find('a[href]')
const fileLinks = container.find('._file_list')
let allLinks = []
if (richTextLinks.length > 0) {
allLinks = allLinks.concat(richTextLinks.toArray())
}
if (anchorLinks.length > 0) {
allLinks = allLinks.concat(anchorLinks.toArray())
}
if (fileLinks.length > 0) {
allLinks = allLinks.concat(fileLinks.toArray())
}
allLinks.forEach(el => {
const $el = $(el)
const dataHref = $el.attr('data-href') || $el.attr('href')
if (!dataHref) {
return
}
$el.off('longpress')
$el.on('longpress', () => {
const fileName =
$el.find('span span span').first().text() ||
$el.text().trim().substring(0, 30) ||
getFileNameFromUrl(dataHref)
copyLink(dataHref, fileName)
})
})
})
}
const handleContentChange = () => {
processContent(props.content)
nextTick(() => {
// 富文本每次重渲染都会替换节点,需要重新挂载图片预览和链接交互事件。
bindImageEvents()
bindFileLinkEvents()
bindLinkLongPressEvents()
})
}
watch(() => props.content, handleContentChange, { immediate: true })
watch(() => props.enableTransform, setupTransformElement, { immediate: true })
onBeforeUnmount(() => {
Taro.options.html.transformElement = previousTransformElement
})
</script>
<style lang="less">
#rich-text-renderer,
.rich-text-renderer {
color: #4b5563;
font-size: 30rpx;
line-height: 1.8;
word-break: break-word;
.h5-html,
.h5-address,
.h5-blockquote,
.h5-body,
.h5-dd,
.h5-div,
.h5-dl,
.h5-dt,
.h5-fieldset,
.h5-form,
.h5-frame,
.h5-frameset,
.h5-h1,
.h5-h2,
.h5-h3,
.h5-h4,
.h5-h5,
.h5-h6,
.h5-noframes,
.h5-ol,
.h5-p,
.h5-ul,
.h5-center,
.h5-dir,
.h5-hr,
.h5-menu,
.h5-pre {
display: block;
unicode-bidi: embed;
}
.h5-li {
display: list-item;
margin-bottom: 12rpx;
}
.h5-head {
display: none;
}
.h5-p,
.h5-div,
.h5-blockquote,
.h5-ul,
.h5-ol {
margin: 0 0 20rpx;
}
.h5-ul,
.h5-ol {
padding-left: 36rpx;
}
.h5-img,
img {
width: 100%;
max-width: 100%;
height: auto;
display: block;
margin: 24rpx 0;
border-radius: 16rpx;
}
.rich-text-link,
.h5-a,
a,
._file_list {
color: #2563eb;
text-decoration: underline;
word-break: break-all;
}
.rich-text-link *,
._file_list * {
pointer-events: none;
}
.h5-b,
.h5-strong {
font-weight: bolder;
}
.h5-i,
.h5-em {
font-style: italic;
}
.h5-table {
display: table;
width: 100%;
border-spacing: 2px;
margin: 20rpx 0;
}
.h5-tr {
display: table-row;
}
.h5-td,
.h5-th {
display: table-cell;
padding: 12rpx;
border: 1rpx solid #d1d5db;
vertical-align: top;
}
.h5-th {
font-weight: bolder;
background: #f9fafb;
}
}
</style>
......@@ -179,6 +179,7 @@ import BASE_URL from '@/utils/config'
const defaultAvatar = 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'
// 获取接口信息
import { updateUserProfileAPI } from '@/api/user'
import { normalizeReturnUrl } from '@/utils/returnUrl'
import { buildUpdateUserProfilePayload } from '@/utils/userProfile'
// 导入主题颜色
import { THEME_COLORS } from '@/utils/config'
......@@ -203,6 +204,7 @@ const formData = reactive({
const registerSourceParams = reactive({
reg_source: '',
reg_stage_id: '',
return_url: '',
})
/**
......@@ -399,6 +401,14 @@ const handleSave = async () => {
icon: 'success',
})
setTimeout(() => {
// 从扫码打卡链路进入补资料页时,保存成功后需要原路回到关卡详情继续扫码。
if (registerSourceParams.return_url) {
Taro.redirectTo({
url: normalizeReturnUrl(registerSourceParams.return_url),
})
return
}
Taro.navigateBack()
}, 1500)
return
......@@ -413,6 +423,7 @@ const handleSave = async () => {
useLoad(options => {
registerSourceParams.reg_source = options?.reg_source || ''
registerSourceParams.reg_stage_id = options?.reg_stage_id || ''
registerSourceParams.return_url = normalizeReturnUrl(options?.return_url || '')
})
</script>
......
......@@ -11,7 +11,10 @@
<view class="flex-1 px-4 py-6 overflow-auto">
<view class="mb-6">
<view class="text-gray-600 mb-6 flex items-center">
<IconFont size="20" name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD1.png" />
<IconFont
size="20"
name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD1.png"
/>
<text class="ml-1">请填写家庭信息</text>
</view>
<!-- Family Name -->
......@@ -25,7 +28,9 @@
placeholder="请输入家庭名称(最多10个字)"
@blur="validateFamilyName"
/>
<view v-if="familyNameError" class="text-red-500 text-sm mt-2">{{ familyNameError }}</view>
<view v-if="familyNameError" class="text-red-500 text-sm mt-2">{{
familyNameError
}}</view>
</view>
</view>
<!-- Family Introduction -->
......@@ -39,18 +44,33 @@
:rows="2"
@blur="validateFamilyIntro"
/>
<view v-if="familyIntroError" class="text-red-500 text-sm mt-2">{{ familyIntroError }}</view>
<view v-if="familyIntroError" class="text-red-500 text-sm mt-2">{{
familyIntroError
}}</view>
</view>
</view>
<!-- District Selection -->
<view class="mb-6">
<view class="bg-white rounded-lg border border-gray-200 p-4">
<view class="block text-lg font-medium mb-4">家庭所在行政区</view>
<view class="bg-white rounded-xl p-4 border border-gray-200" @click="showDistrictPicker = true">
<view
class="bg-white rounded-xl p-4 border border-gray-200"
@click="showDistrictPicker = true"
>
<view class="flex justify-between items-center">
<view>
<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" />
<text :class="{'text-gray-400': !selectedDistrictText, 'text-gray-900': selectedDistrictText}" class="text-base">
<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"
/>
<text
:class="{
'text-gray-400': !selectedDistrictText,
'text-gray-900': selectedDistrictText,
}"
class="text-base"
>
{{ selectedDistrictText || '请选择区域' }}
</text>
</view>
......@@ -73,18 +93,20 @@
</view>
<view class="flex gap-2 mb-4">
<view v-for="(char, index) in familyMotto" :key="index" class="flex-1">
<view class="w-full aspect-square flex items-center justify-center bg-gray-100 rounded-lg">
<view
class="w-full aspect-square flex items-center justify-center bg-gray-100 rounded-lg"
>
<input
:ref="(el) => (inputRefs[index] = el)"
:ref="el => (inputRefs[index] = el)"
type="text"
v-model="familyMotto[index]"
:placeholder="familyMottoPlaceholder[index]"
placeholder-style="color: var(--secondary-color-text);"
@input="(e) => handleInputChange(index, e.target.value)"
@input="e => handleInputChange(index, e.target.value)"
@focus="focusedIndex = index"
@blur="handleBlur(index)"
class="w-full h-full bg-transparent text-center"
style="font-size: 38rpx;"
style="font-size: 38rpx"
:cursorSpacing="100"
/>
</view>
......@@ -97,7 +119,10 @@
</view>
<view class="flex items-center text-sm text-gray-600">
<view>
<IconFont size="15" name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD3.png" />
<IconFont
size="15"
name="https://cdn.ipadbiz.cn/lls_prog/icon/%E5%88%9B%E5%BB%BA%E5%AE%B6%E5%BA%AD3.png"
/>
<text class="ml-1">设置有意义的家训口令,便于家人记忆和加入</text>
</view>
</view>
......@@ -106,9 +131,7 @@
<!-- Family Cover -->
<view class="mb-10">
<view class="bg-white rounded-lg border border-gray-200 p-4">
<view class="block text-lg font-medium mb-2">
家庭封面图(选填)
</view>
<view class="block text-lg font-medium mb-2"> 家庭封面图(选填) </view>
<!-- 封面显示区域 -->
<view class="mb-4">
<view class="relative bg-gray-100 rounded-lg h-48 flex items-center justify-center">
......@@ -121,15 +144,24 @@
@tap="previewAvatar"
/>
<!-- 没有图片时显示上传提示 -->
<view v-else class="flex flex-col items-center justify-center text-gray-400" @click="chooseImage">
<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" />
<view
v-else
class="flex flex-col items-center justify-center text-gray-400"
@click="chooseImage"
>
<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"
/>
<text class="text-sm">点击上传封面图</text>
</view>
<!-- 删除按钮 -->
<view
v-if="familyAvatar"
@click.stop="deleteAvatar"
class="absolute -top-2 -right-2 w-5 bg-red-500 rounded-full flex items-center justify-center" style="height: 41rpx;"
class="absolute -top-2 -right-2 w-5 bg-red-500 rounded-full flex items-center justify-center"
style="height: 41rpx"
>
<view class="text-white text-lg">×</view>
</view>
......@@ -139,15 +171,17 @@
@click.stop="chooseImage"
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"
>
<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" />
<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"
/>
更换
</view>
</view>
</view>
<!-- 上传提示 -->
<view class="text-center text-gray-400 text-sm">
支持图片格式(jpg、png)最大5MB
</view>
<view class="text-center text-gray-400 text-sm"> 支持图片格式(jpg、png)最大5MB </view>
</view>
</view>
</view>
......@@ -156,7 +190,7 @@
@tap="handleCreateFamily"
:class="[
'w-full py-3 text-white text-lg font-medium rounded-lg flex items-center justify-center',
isFormValid ? 'bg-blue-500' : 'bg-gray-300'
isFormValid ? 'bg-blue-500' : 'bg-gray-300',
]"
>
创建家庭
......@@ -186,36 +220,38 @@
</template>
<script setup>
import { ref, nextTick, computed } from 'vue';
import Taro from '@tarojs/taro';
import { Tips, Photograph, Right, IconFont } from '@nutui/icons-vue-taro';
import BASE_URL from '@/utils/config';
import { ref, nextTick, computed } from 'vue'
import Taro from '@tarojs/taro'
import { Tips, Photograph, Right, IconFont } from '@nutui/icons-vue-taro'
import BASE_URL from '@/utils/config'
// 接口信息
import { createFamilyAPI } from '@/api/family';
import { createFamilyAPI } from '@/api/family'
import { normalizeReturnUrl } from '@/utils/returnUrl'
// 区域信息
import { SHANGHAI_REGION } from '@/utils/config';
import { SHANGHAI_REGION } from '@/utils/config'
const familyName = ref('');
const familyIntro = ref('');
const selectedDistrict = ref(null);
const selectedDistrictText = ref('');
const familyMotto = ref(['', '', '', '']);
const familyMottoPlaceholder = ref(['孝', '敬', '和', '睦']);
const familyName = ref('')
const familyIntro = ref('')
const selectedDistrict = ref(null)
const selectedDistrictText = ref('')
const familyMotto = ref(['', '', '', ''])
const familyMottoPlaceholder = ref(['孝', '敬', '和', '睦'])
// 字数验证错误信息
const familyNameError = ref('');
const familyIntroError = ref('');
const familyNameError = ref('')
const familyIntroError = ref('')
// 图片审核信息
const familyAvatarAudit = ref('');
const familyAvatarAudit = ref('')
// 区域选择器相关
const showDistrictPicker = ref(false);
const districtValue = ref([]);
const districtColumns = ref(SHANGHAI_REGION);
const familyAvatar = ref('');
const focusedIndex = ref(-1);
const inputRefs = ref([]);
const showDistrictPicker = ref(false)
const districtValue = ref([])
const districtColumns = ref(SHANGHAI_REGION)
const familyAvatar = ref('')
const focusedIndex = ref(-1)
const inputRefs = ref([])
const returnUrl = ref('')
const isFormValid = computed(() => {
return (
......@@ -223,8 +259,8 @@ const isFormValid = computed(() => {
familyIntro.value.trim() !== '' &&
selectedDistrict.value !== null &&
familyMotto.value.every(char => char.trim() !== '')
);
});
)
})
/**
* @description 确认选择区域
......@@ -233,22 +269,22 @@ const isFormValid = computed(() => {
const onDistrictConfirm = ({ selectedValue, selectedOptions }) => {
// 如果selectedValue[0]为0或无效,使用第一个区域作为默认值
if (!selectedValue[0] || selectedValue[0] === 0) {
const firstDistrict = districtColumns.value[0];
selectedDistrict.value = firstDistrict.value;
selectedDistrictText.value = firstDistrict.text;
districtValue.value = [firstDistrict.value]; // 同步更新picker的值
const firstDistrict = districtColumns.value[0]
selectedDistrict.value = firstDistrict.value
selectedDistrictText.value = firstDistrict.text
districtValue.value = [firstDistrict.value] // 同步更新picker的值
} else {
selectedDistrict.value = +selectedValue[0]; // 确保转换为数字类型
selectedDistrictText.value = selectedOptions.map((option) => option.text).join('');
selectedDistrict.value = +selectedValue[0] // 确保转换为数字类型
selectedDistrictText.value = selectedOptions.map(option => option.text).join('')
}
showDistrictPicker.value = false;
};
showDistrictPicker.value = false
}
// 图片预览相关
const previewVisible = ref(false);
const previewImages = ref([]);
const previewIndex = ref(0);
const previewVisible = ref(false)
const previewImages = ref([])
const previewIndex = ref(0)
// const generateRandomMotto = () => {
// // 在实际应用中,这里会生成随机家训
......@@ -261,31 +297,31 @@ const previewIndex = ref(0);
*/
const handleInputChange = (index, value) => {
// 只保留第一个有效字符(汉字、数字、大小写字母)
const validChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || '';
familyMotto.value[index] = validChar;
const validChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || ''
familyMotto.value[index] = validChar
// 如果输入了有效字符且不是最后一个输入框,自动聚焦下一个
if (validChar && index < 3) {
focusedIndex.value = index + 1;
focusedIndex.value = index + 1
// 使用 nextTick 确保 DOM 更新后再聚焦
nextTick(() => {
if (inputRefs.value[index + 1]) {
inputRefs.value[index + 1].focus();
inputRefs.value[index + 1].focus()
}
});
})
}
};
}
/**
* 处理失焦事件
*/
const handleBlur = (index) => {
focusedIndex.value = -1;
const handleBlur = index => {
focusedIndex.value = -1
// 确保只保留有效字符(汉字、数字、大小写字母)
const value = familyMotto.value[index];
const validChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || '';
familyMotto.value[index] = validChar;
};
const value = familyMotto.value[index]
const validChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || ''
familyMotto.value[index] = validChar
}
/**
* 显示提示信息
......@@ -294,9 +330,9 @@ const showToast = (message, type = 'success') => {
Taro.showToast({
title: message,
icon: type,
duration: 2000
});
};
duration: 2000,
})
}
/**
* 选择图片
......@@ -307,39 +343,39 @@ const chooseImage = () => {
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function (res) {
const tempFilePath = res.tempFilePaths[0];
const tempFilePath = res.tempFilePaths[0]
// 检查文件大小(5MB = 5 * 1024 * 1024 bytes)
Taro.getFileInfo({
filePath: tempFilePath,
success: function (fileInfo) {
if (fileInfo.size > 5 * 1024 * 1024) {
showToast('图片大小不能超过5MB', 'none');
return;
}
uploadImage(tempFilePath);
Taro.getFileInfo({
filePath: tempFilePath,
success: function (fileInfo) {
if (fileInfo.size > 5 * 1024 * 1024) {
showToast('图片大小不能超过5MB', 'none')
return
}
uploadImage(tempFilePath)
},
fail: function () {
// 如果获取文件信息失败,直接上传
uploadImage(tempFilePath);
}
});
uploadImage(tempFilePath)
},
})
},
fail: function () {
// showToast('选择图片失败', 'none');
}
});
};
},
})
}
/**
* 上传图片到服务器
*/
const uploadImage = (filePath) => {
const uploadImage = filePath => {
// 显示上传中提示
Taro.showLoading({
title: '上传中',
mask: true
});
mask: true,
})
wx.uploadFile({
url: BASE_URL + '/admin/?m=srv&a=upload&image_audit=1',
......@@ -349,142 +385,142 @@ const uploadImage = (filePath) => {
'content-type': 'multipart/form-data',
},
success: function (res) {
let upload_data = JSON.parse(res.data);
const upload_data = JSON.parse(res.data)
Taro.hideLoading({
success: () => {
if (upload_data.code == 0 && upload_data.data) {
familyAvatar.value = upload_data.data.src;
familyAvatarAudit.value = upload_data.data.audit_result;
showToast('上传成功', 'success');
if (upload_data.code === 0 && upload_data.data) {
familyAvatar.value = upload_data.data.src
familyAvatarAudit.value = upload_data.data.audit_result
showToast('上传成功', 'success')
} else {
showToast('服务器错误,稍后重试!', 'none');
showToast('服务器错误,稍后重试!', 'none')
}
},
});
})
},
fail: function (res) {
Taro.hideLoading({
success: () => {
showToast('上传失败,稍后重试!', 'none');
}
});
}
});
};
showToast('上传失败,稍后重试!', 'none')
},
})
},
})
}
/**
* 预览头像
*/
const previewAvatar = () => {
if (!familyAvatar.value) {
return;
return
}
const imageToPreview = familyAvatar.value;
previewImages.value = [{ src: imageToPreview }];
previewIndex.value = 0;
previewVisible.value = true;
};
const imageToPreview = familyAvatar.value
previewImages.value = [{ src: imageToPreview }]
previewIndex.value = 0
previewVisible.value = true
}
/**
* 删除头像
*/
const deleteAvatar = () => {
familyAvatar.value = '';
showToast('头像已删除', 'success');
};
familyAvatar.value = ''
showToast('头像已删除', 'success')
}
/**
* 关闭预览
*/
const closePreview = () => {
previewVisible.value = false;
};
previewVisible.value = false
}
/**
* 验证家庭名称字数
*/
const validateFamilyName = () => {
familyNameError.value = '';
familyNameError.value = ''
if (familyName.value.length > 10) {
familyNameError.value = '家庭名称不能超过10个字';
familyNameError.value = '家庭名称不能超过10个字'
}
};
}
/**
* 验证家庭介绍字数
*/
const validateFamilyIntro = () => {
familyIntroError.value = '';
familyIntroError.value = ''
if (familyIntro.value.length > 100) {
familyIntroError.value = '家庭介绍不能超过100个字';
familyIntroError.value = '家庭介绍不能超过100个字'
}
};
}
/**
* 表单验证
*/
const validateForm = () => {
// 先验证字数
validateFamilyName();
validateFamilyIntro();
validateFamilyName()
validateFamilyIntro()
if (!familyName.value.trim()) {
showToast('请输入家庭名称', 'none');
return false;
showToast('请输入家庭名称', 'none')
return false
}
if (familyName.value.length > 10) {
Taro.showModal({
title: '提示',
content: '家庭名称不能超过10个字,请重新输入',
showCancel: false
});
return false;
showCancel: false,
})
return false
}
if (!familyIntro.value.trim()) {
showToast('请输入家庭介绍', 'none');
return false;
showToast('请输入家庭介绍', 'none')
return false
}
if (familyIntro.value.length > 100) {
Taro.showModal({
title: '提示',
content: '家庭介绍不能超过100个字,请重新输入',
showCancel: false
});
return false;
showCancel: false,
})
return false
}
if (!selectedDistrict.value) {
showToast('请选择区域战队', 'none');
return false;
showToast('请选择区域战队', 'none')
return false
}
// 检查家训口令是否完整填写
const mottoComplete = familyMotto.value.every(char => char.trim() !== '');
const mottoComplete = familyMotto.value.every(char => char.trim() !== '')
if (!mottoComplete) {
showToast('请完整填写家训口令', 'none');
return false;
showToast('请完整填写家训口令', 'none')
return false
}
return true;
};
return true
}
/**
* 创建家庭
*/
const handleCreateFamily = async () => {
if (!validateForm()) {
return;
return
}
try {
// 显示加载中
Taro.showLoading({
title: '创建中...',
mask: true
});
mask: true,
})
const { code, data, msg } = await createFamilyAPI({
name: familyName.value,
......@@ -493,27 +529,38 @@ const handleCreateFamily = async () => {
passphrase: familyMotto.value.join(''),
avatar_url: familyAvatar.value,
qiniu_audit: familyAvatarAudit.value,
});
})
Taro.hideLoading();
Taro.hideLoading()
// 判断API调用是否成功
if (code) {
showToast('创建成功', 'success');
showToast('创建成功', 'success')
setTimeout(() => {
if (returnUrl.value) {
Taro.redirectTo({
url: normalizeReturnUrl(returnUrl.value),
})
return
}
Taro.navigateTo({
url: '/pages/Dashboard/index'
});
}, 1500);
url: '/pages/Dashboard/index',
})
}, 1500)
} else {
// 显示错误信息
showToast(msg || '创建失败,请重试', 'none');
showToast(msg || '创建失败,请重试', 'none')
}
} catch (error) {
Taro.hideLoading();
console.error('创建家庭失败:', error);
showToast('网络错误,请重试', 'none');
Taro.hideLoading()
console.error('创建家庭失败:', error)
showToast('网络错误,请重试', 'none')
}
};
}
Taro.useLoad(options => {
returnUrl.value = normalizeReturnUrl(options?.return_url || '')
})
</script>
......
......@@ -2,9 +2,7 @@
<view class="min-h-screen flex flex-col bg-white">
<view class="flex-1 px-4 pt-3 pb-6 flex flex-col">
<!-- Title -->
<h2 class="text-xl font-bold text-center mb-2">
输入家训口令
</h2>
<h2 class="text-xl font-bold text-center mb-2">输入家训口令</h2>
<!-- Description -->
<view class="text-gray-600 text-left text-sm mb-6">
请输入家人提供的家训口令,加入家庭一起参与健康挑战
......@@ -16,15 +14,15 @@
:key="index"
class="motto-input-box"
:style="{
borderColor: focusedIndex === index ? THEME_COLORS.PRIMARY : '#d1d5db'
borderColor: focusedIndex === index ? THEME_COLORS.PRIMARY : '#d1d5db',
}"
>
<input
:ref="(el) => (inputRefs[index] = el)"
:ref="el => (inputRefs[index] = el)"
type="text"
v-model="mottoChars[index]"
@input="(e) => handleInputChange(index, e.target.value)"
@keydown="(e) => handleKeyDown(index, e)"
@input="e => handleInputChange(index, e.target.value)"
@keydown="e => handleKeyDown(index, e)"
@focus="focusedIndex = index"
@blur="handleBlur(index)"
class="motto-input"
......@@ -33,14 +31,10 @@
</view>
</view>
<!-- Help text -->
<view class="text-gray-500 text-center text-sm mb-4">
没有口令?请联系您的大家长获取
</view>
<view class="text-gray-500 text-center text-sm mb-4"> 没有口令?请联系您的大家长获取 </view>
<!-- Role selection -->
<view class="mb-6">
<h3 class="identity-title">
选择您的身份
</h3>
<h3 class="identity-title">选择您的身份</h3>
<view class="flex gap-2 flex-wrap">
<view
v-for="role in familyRoles"
......@@ -50,7 +44,7 @@
'w-[calc(49%-4rpx)] py-3 rounded-lg border text-center flex flex-col items-center gap-1',
selectedRole === role.id
? 'border-blue-500 bg-blue-50 text-blue-500'
: 'border-gray-200 text-gray-700'
: 'border-gray-200 text-gray-700',
]"
>
<IconFont :name="role.type" size="25" />
......@@ -64,7 +58,7 @@
:disabled="!isComplete"
:class="[
'w-full py-3 text-white text-lg font-medium rounded-lg mt-auto text-center',
isComplete ? 'bg-blue-500' : 'bg-gray-300'
isComplete ? 'bg-blue-500' : 'bg-gray-300',
]"
>
加入家庭
......@@ -98,10 +92,7 @@
<!-- 家庭列表 -->
<view class="flex-1 px-4 pb-4 overflow-hidden">
<view
ref="familyListContainer"
class="h-full space-y-3 overflow-y-auto"
>
<view ref="familyListContainer" class="h-full space-y-3 overflow-y-auto">
<view
v-for="family in filteredFamilies"
:key="family.id"
......@@ -112,11 +103,13 @@
? 'border-gray-300 bg-gray-100 opacity-60 cursor-not-allowed'
: selectedFamilyId === family.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white'
: 'border-gray-200 bg-white',
]"
>
<!-- 家庭头像 -->
<view class="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
<view
class="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden"
>
<image
:src="family.avatar_url || defaultAvatar"
class="w-full h-full object-cover"
......@@ -128,7 +121,9 @@
<view class="font-medium text-gray-900 mb-1">
{{ family.name }}
</view>
<text v-if="family.is_kicked" class="text-xs text-red-500">被大家长移除后无法再加入</text>
<text v-if="family.is_kicked" class="text-xs text-red-500"
>被大家长移除后无法再加入</text
>
<view class="text-sm text-gray-600 line-clamp-2">{{ family.note }}</view>
</view>
......@@ -152,7 +147,10 @@
</view>
<!-- 没有更多数据提示 -->
<view v-if="!hasMoreData && totalFamilies.length > 0 && !searchKeyword" class="text-center py-4 text-gray-500 text-sm">
<view
v-if="!hasMoreData && totalFamilies.length > 0 && !searchKeyword"
class="text-center py-4 text-gray-500 text-sm"
>
没有更多家庭了
</view>
</view>
......@@ -160,13 +158,7 @@
<!-- 底部按钮 -->
<view class="flex gap-3 p-4 border-t border-gray-100 flex-shrink-0">
<nut-button
@click="closeFamilySelector"
class="flex-1"
type="default"
size="large"
plain
>
<nut-button @click="closeFamilySelector" class="flex-1" type="default" size="large" plain>
关闭
</nut-button>
<nut-button
......@@ -185,110 +177,154 @@
</template>
<script setup>
import { ref, computed, nextTick, onMounted, watch } from 'vue';
import Taro from '@tarojs/taro';
import { My, Check, IconFont } from '@nutui/icons-vue-taro';
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import Taro from '@tarojs/taro'
import { My, Check, IconFont } from '@nutui/icons-vue-taro'
// 获取接口信息
import { searchFamilyByPassphraseAPI, joinFamilyAPI } from '@/api/family';
import { searchFamilyByPassphraseAPI, joinFamilyAPI } from '@/api/family'
import { normalizeReturnUrl } from '@/utils/returnUrl'
// 导入主题颜色
import { THEME_COLORS } from '@/utils/config';
import { THEME_COLORS } from '@/utils/config'
// 默认头像
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'
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'
const mottoChars = ref(['', '', '', '']);
const selectedRole = ref('');
const inputRefs = ref([]);
const focusedIndex = ref(-1);
const mottoChars = ref(['', '', '', ''])
const selectedRole = ref('')
const inputRefs = ref([])
const focusedIndex = ref(-1)
// 弹窗相关数据
const showFamilySelector = ref(false);
const searchKeyword = ref('');
const selectedFamilyId = ref('');
const mockFamilies = ref([]);
const familyListContainer = ref(null);
const showFamilySelector = ref(false)
const searchKeyword = ref('')
const selectedFamilyId = ref('')
const mockFamilies = ref([])
const familyListContainer = ref(null)
// 移除不再需要的familyListHeight变量,因为现在使用flexbox布局
// 分页相关数据
const currentPage = ref(0);
const pageSize = ref(10);
const hasMoreData = ref(true);
const isLoadingMore = ref(false);
const totalFamilies = ref([]);
const currentPage = ref(0)
const pageSize = ref(10)
const hasMoreData = ref(true)
const isLoadingMore = ref(false)
const totalFamilies = ref([])
const returnUrl = ref('')
const handleInputChange = (index, value) => {
// 允许输入多个字符,但只保留第一个有效字符(汉字、数字、大小写字母),兼容输入法
if (value) {
// 提取第一个有效字符(汉字、数字、大小写字母)
const firstChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || '';
mottoChars.value[index] = firstChar;
const firstChar = value.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || ''
mottoChars.value[index] = firstChar
// 如果输入了有效字符且不是最后一个输入框,自动聚焦下一个
if (firstChar && index < 3) {
focusedIndex.value = index + 1;
focusedIndex.value = index + 1
// 使用 nextTick 确保 DOM 更新后再聚焦
nextTick(() => {
if (inputRefs.value[index + 1]) {
inputRefs.value[index + 1].focus();
inputRefs.value[index + 1].focus()
}
});
})
}
} else {
mottoChars.value[index] = '';
mottoChars.value[index] = ''
}
};
}
const handleKeyDown = (index, e) => {
if (e.key === 'Backspace' && !mottoChars.value[index] && index > 0) {
// 同样,在Taro中处理光标移动需要不同的方式
}
};
}
/**
* 处理输入框失焦事件
* @param {number} index - 输入框索引
*/
const handleBlur = (index) => {
const handleBlur = index => {
// 重置焦点状态
focusedIndex.value = -1;
focusedIndex.value = -1
// 失焦时再次验证输入值,确保只保留有效字符(汉字、数字、大小写字母)
const currentValue = mottoChars.value[index];
const currentValue = mottoChars.value[index]
if (currentValue) {
const firstChar = currentValue.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || '';
mottoChars.value[index] = firstChar;
const firstChar = currentValue.match(/[\u4e00-\u9fa5a-zA-Z0-9]/)?.[0] || ''
mottoChars.value[index] = firstChar
}
};
}
const familyRoles = [
{ id: '丈夫', label: '丈夫', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%88%B7%E7%88%B7.png' },
{ id: '妻子', label: '妻子', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B6%E5%A5%B6.png' },
{ id: '儿子', label: '儿子', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E8%81%8C%E5%91%98.png' },
{ id: '女儿', label: '女儿', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E8%81%8C%E5%91%98.png' },
{ id: '女婿', label: '女婿', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E8%81%8C%E5%91%98.png' },
{ id: '儿媳', label: '儿媳', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E8%81%8C%E5%91%98.png' },
{ id: '孙子', label: '孙子', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E5%AD%A9.png' },
{ id: '外孙', label: '外孙', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E5%AD%A9.png' },
{ id: '孙女', label: '孙女', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E5%AD%A9.png' },
{ id: '外孙女', label: '外孙女', type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E5%AD%A9.png' }
];
{
id: '丈夫',
label: '丈夫',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%88%B7%E7%88%B7.png',
},
{
id: '妻子',
label: '妻子',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B6%E5%A5%B6.png',
},
{
id: '儿子',
label: '儿子',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E8%81%8C%E5%91%98.png',
},
{
id: '女儿',
label: '女儿',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E8%81%8C%E5%91%98.png',
},
{
id: '女婿',
label: '女婿',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E8%81%8C%E5%91%98.png',
},
{
id: '儿媳',
label: '儿媳',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E8%81%8C%E5%91%98.png',
},
{
id: '孙子',
label: '孙子',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E5%AD%A9.png',
},
{
id: '外孙',
label: '外孙',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E7%94%B7%E5%AD%A9.png',
},
{
id: '孙女',
label: '孙女',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E5%AD%A9.png',
},
{
id: '外孙女',
label: '外孙女',
type: 'https://cdn.ipadbiz.cn/lls_prog/icon/create/%E5%A5%B3%E5%AD%A9.png',
},
]
const isComplete = computed(() => {
return mottoChars.value.every((char) => char) && selectedRole.value;
});
return mottoChars.value.every(char => char) && selectedRole.value
})
// 过滤后的家庭列表
const filteredFamilies = computed(() => {
if (!searchKeyword.value) {
return mockFamilies.value
}
return mockFamilies.value.filter(family =>
family.name?.includes(searchKeyword.value) ||
family.description?.includes(searchKeyword.value)
return mockFamilies.value.filter(
family =>
family.name?.includes(searchKeyword.value) ||
family.description?.includes(searchKeyword.value)
)
});
})
// 处理搜索
const handleSearch = (value) => {
const handleSearch = value => {
searchKeyword.value = value
// 搜索时重置选中的家庭
selectedFamilyId.value = ''
......@@ -299,20 +335,20 @@ const handleClearSearch = () => {
searchKeyword.value = ''
// 清除搜索时重置选中的家庭
selectedFamilyId.value = ''
};
}
// 选择家庭
const selectFamily = (familyId) => {
const selectFamily = familyId => {
// 查找对应的家庭信息
const family = mockFamilies.value.find(f => f.id === familyId);
const family = mockFamilies.value.find(f => f.id === familyId)
// 如果家庭被禁用,则不允许选中
if (family && family.is_kicked) {
return;
return
}
selectedFamilyId.value = familyId
};
}
// 关闭家庭选择器
const closeFamilySelector = () => {
......@@ -322,7 +358,7 @@ const closeFamilySelector = () => {
}
// 监听弹窗显示状态,重置选中状态
watch(showFamilySelector, async (newVal) => {
watch(showFamilySelector, async newVal => {
if (newVal) {
await nextTick()
// 移除高度计算逻辑,现在使用flexbox自动布局
......@@ -339,7 +375,7 @@ const confirmJoinFamily = async () => {
if (!selectedFamilyId.value) {
Taro.showToast({
title: '请选择一个家庭',
icon: 'none'
icon: 'none',
})
return
}
......@@ -348,7 +384,7 @@ const confirmJoinFamily = async () => {
console.log('确认加入家庭:', selectedFamily)
const joinFamily = await joinFamilyAPI({
family_id: selectedFamily.id,
role: selectedRole.value
role: selectedRole.value,
})
if (joinFamily.code) {
// 关闭弹窗
......@@ -356,80 +392,101 @@ const confirmJoinFamily = async () => {
Taro.showToast({
title: '加入成功',
icon: 'success'
icon: 'success',
})
setTimeout(() => {
if (returnUrl.value) {
Taro.redirectTo({
url: normalizeReturnUrl(returnUrl.value),
})
return
}
Taro.redirectTo({
url: '/pages/Dashboard/index'
url: '/pages/Dashboard/index',
})
}, 1500)
}
};
}
const handleJoinFamily = async () => {
if (!isComplete.value) return
if (!isComplete.value) {
return
}
const motto = mottoChars.value.join('')
try {
// 重置分页数据
currentPage.value = 0;
hasMoreData.value = true;
totalFamilies.value = [];
currentPage.value = 0
hasMoreData.value = true
totalFamilies.value = []
// 调用API查询家庭(第一页)
const { code, data } = await searchFamilyByPassphraseAPI({
passphrase: motto,
page: currentPage.value,
limit: pageSize.value
limit: pageSize.value,
})
let families = [];
let families = []
if (code) {
families = data;
totalFamilies.value = families;
families = data
totalFamilies.value = families
// 检查是否还有更多数据
hasMoreData.value = families.length === pageSize.value;
hasMoreData.value = families.length === pageSize.value
console.log('查询家庭:', { motto, role: selectedRole.value, families, hasMore: hasMoreData.value })
console.log('查询家庭:', {
motto,
role: selectedRole.value,
families,
hasMore: hasMoreData.value,
})
if (families.length === 0) {
Taro.showToast({
title: '未找到匹配的家庭',
icon: 'none'
icon: 'none',
})
return
}
if (families.length === 1) {
// 只有一个家庭,检查是否被踢出
const family = families[0];
const family = families[0]
if (family.is_kicked) {
// 被踢出状态,显示选择弹窗让用户知道
showFamilySelector.value = true;
mockFamilies.value = totalFamilies.value;
showFamilySelector.value = true
mockFamilies.value = totalFamilies.value
} else {
// 未被踢出,直接加入
const joinFamily = await joinFamilyAPI({
family_id: family.id,
role: selectedRole.value
});
role: selectedRole.value,
})
if (joinFamily.code) {
Taro.showToast({
title: '加入成功',
icon: 'success'
});
icon: 'success',
})
setTimeout(() => {
if (returnUrl.value) {
Taro.redirectTo({
url: normalizeReturnUrl(returnUrl.value),
})
return
}
Taro.redirectTo({
url: '/pages/Dashboard/index'
});
}, 1500);
url: '/pages/Dashboard/index',
})
}, 1500)
}
}
} else {
......@@ -442,61 +499,67 @@ const handleJoinFamily = async () => {
console.error('加入家庭失败:', error)
Taro.showToast({
title: '加入失败,请重试',
icon: 'none'
icon: 'none',
})
}
};
}
Taro.useLoad(options => {
returnUrl.value = normalizeReturnUrl(options?.return_url || '')
})
// 加载更多家庭数据
const loadMoreFamilies = async () => {
if (isLoadingMore.value || !hasMoreData.value) return;
if (isLoadingMore.value || !hasMoreData.value) {
return
}
isLoadingMore.value = true;
isLoadingMore.value = true
try {
const motto = mottoChars.value.join('');
currentPage.value += 1;
const motto = mottoChars.value.join('')
currentPage.value += 1
const { code, data } = await searchFamilyByPassphraseAPI({
passphrase: motto,
page: currentPage.value,
limit: pageSize.value
});
limit: pageSize.value,
})
if (code && data) {
// 合并新数据到现有数据
totalFamilies.value = [...totalFamilies.value, ...data];
totalFamilies.value = [...totalFamilies.value, ...data]
// 检查是否还有更多数据
hasMoreData.value = data.length === pageSize.value;
hasMoreData.value = data.length === pageSize.value
// 更新mockFamilies用于显示
mockFamilies.value = totalFamilies.value;
mockFamilies.value = totalFamilies.value
console.log('加载更多家庭:', {
page: currentPage.value,
newCount: data.length,
totalCount: totalFamilies.value.length,
hasMore: hasMoreData.value
});
hasMore: hasMoreData.value,
})
} else {
hasMoreData.value = false;
hasMoreData.value = false
Taro.showToast({
title: '加载失败',
icon: 'none'
});
icon: 'none',
})
}
} catch (error) {
console.error('加载更多家庭失败:', error);
currentPage.value -= 1; // 回退页码
console.error('加载更多家庭失败:', error)
currentPage.value -= 1 // 回退页码
Taro.showToast({
title: '加载失败,请重试',
icon: 'none'
});
icon: 'none',
})
} finally {
isLoadingMore.value = false;
isLoadingMore.value = false
}
};
}
</script>
<style lang="less">
@import './index.less';
......
......@@ -10,6 +10,18 @@
height: 520rpx;
}
.scan-checkin-detail-cover-swiper {
width: 100%;
height: 100%;
}
.scan-checkin-detail-cover-swiper :deep(.nut-swiper),
.scan-checkin-detail-cover-swiper :deep(.nut-swiper-inner),
.scan-checkin-detail-cover-swiper :deep(.nut-swiper-item) {
width: 100%;
height: 100%;
}
.scan-checkin-detail-cover-image {
width: 100%;
height: 100%;
......
<template>
<view class="scan-checkin-detail-page">
<view class="scan-checkin-detail-cover">
<image class="scan-checkin-detail-cover-image" :src="detail.cover" mode="aspectFill" />
<nut-swiper
class="scan-checkin-detail-cover-swiper"
:init-page="0"
:pagination-visible="detail.banners.length > 1"
pagination-color="#ffffff"
pagination-unselected-color="rgba(255, 255, 255, 0.5)"
:loop="detail.banners.length > 1"
:auto-play="detail.banners.length > 1 ? 3000 : 0"
>
<nut-swiper-item v-for="(banner, index) in detail.banners" :key="`${banner}-${index}`">
<image class="scan-checkin-detail-cover-image" :src="banner" mode="aspectFill" />
</nut-swiper-item>
</nut-swiper>
</view>
<view class="scan-checkin-detail-card">
<view class="scan-checkin-detail-heading">
<text class="scan-checkin-detail-title">{{ detail.code }} {{ detail.title }}</text>
<text class="scan-checkin-detail-title">{{ detail.title }}</text>
<view class="scan-checkin-detail-status" :class="detail.isChecked ? 'done' : 'pending'">
{{ detail.isChecked ? '已打卡' : '未打卡' }}
</view>
......@@ -19,7 +31,7 @@
<text class="scan-checkin-detail-section-title">{{ detail.discountTitle }}</text>
</view>
<view class="scan-checkin-detail-content">
<rich-text class="scan-checkin-detail-rich-text" :nodes="formattedDiscountContent" />
<RichTextRenderer :content="normalizedRichTextContent" />
</view>
</view>
</view>
......@@ -42,16 +54,31 @@
import { reactive, computed } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import './index.less'
import { getMockScanCheckinDetail } from '@/utils/mockQrCheckin'
import RichTextRenderer from '@/components/RichTextRenderer.vue'
import { getCurrentPageFullPath } from '@/utils/authRedirect'
import { getMyFamiliesAPI } from '@/api/family'
import { getScanStageDetailAPI, submitScanCheckinAPI } from '@/api/map'
import { verifyCheckinRangeWithCurrentLocation } from '@/utils/checkinLocation'
import { parseScanCheckinParams } from '@/utils/scanCheckin'
const defaultCover =
'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60'
const detail = reactive({
activityId: '',
id: '',
code: 'W2D01',
title: '泰康之家经营管理有限公司上海分公司',
guideText: '在点位现场扫码打卡并推荐好物',
regSource: '',
regStageId: '',
returnUrl: '',
banners: [defaultCover],
title: '',
guideText: '',
discountTitle: '打卡点专属优惠',
discountContentRaw: '',
cover: 'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60',
geoEnabled: false,
centerLng: null,
centerLat: null,
radiusMeters: null,
isChecked: false,
lastScanCode: '',
scanSubmitting: false,
......@@ -59,7 +86,7 @@ const detail = reactive({
const scanSubmitting = computed(() => detail.scanSubmitting === true)
const formattedDiscountContent = computed(() => {
const normalizedRichTextContent = computed(() => {
const content = detail.discountContentRaw
if (!content) {
......@@ -86,17 +113,30 @@ const formattedDiscountContent = computed(() => {
return formattedContent
})
const mockSubmitScanCode = async code => {
await new Promise(resolve => {
setTimeout(resolve, 500)
const navigateToWelcome = () => {
// 扫码入口沿用首页广告位的判断路线:
// 先判断是否有家庭,没有则先去 Welcome,再由 Welcome 决定补资料/创建家庭/加入家庭。
const params = new URLSearchParams({
reg_source: detail.regSource,
reg_stage_id: detail.regStageId,
return_url: detail.returnUrl,
})
Taro.redirectTo({
url: `/pages/Welcome/index?${params.toString()}`,
})
}
const promptNavigateToWelcome = async () => {
const modalResult = await Taro.showModal({
title: '温馨提示',
content: '扫码打卡需要先完善个人信息并加入家庭,完成后才能继续参与。',
confirmText: '去完善',
cancelText: '取消',
})
return {
code: 1,
msg: '打卡成功',
data: {
scan_code: code,
},
if (modalResult.confirm) {
navigateToWelcome()
}
}
......@@ -104,6 +144,39 @@ const handleScanCheckin = async () => {
detail.scanSubmitting = true
try {
const familyResult = await getMyFamiliesAPI()
const hasFamily = familyResult?.code && familyResult?.data?.families?.length > 0
if (!hasFamily) {
detail.scanSubmitting = false
await promptNavigateToWelcome()
return
}
// 点击扫码时重新静默拉一次当前位置,避免依赖进入页面时的旧定位缓存。
const rangeCheck = await verifyCheckinRangeWithCurrentLocation({
geoEnabled: detail.geoEnabled,
centerLng: detail.centerLng,
centerLat: detail.centerLat,
radiusMeters: detail.radiusMeters,
})
if (!rangeCheck.allowed) {
detail.scanSubmitting = false
const outOfRangeTitle =
rangeCheck.reason === 'out_of_range'
? '未在打卡范围内,请前往指定位置后再扫码打卡'
: '获取当前位置失败,请确认定位权限后重试'
Taro.showToast({
title: outOfRangeTitle,
icon: 'none',
duration: 3000,
})
return
}
const scanResult = await Taro.scanCode({
onlyFromCamera: false,
scanType: ['qrCode', 'barCode'],
......@@ -119,17 +192,42 @@ const handleScanCheckin = async () => {
return
}
const submitResult = await mockSubmitScanCode(scannedCode)
// 打卡提交参数以二维码里携带的内容为准,当前页面参数只用于展示和后续列表跳转。
const parsedScanParams = parseScanCheckinParams(scannedCode)
const submitActivityId = parsedScanParams.activityId
const submitDetailId = parsedScanParams.detailId
if (!submitActivityId || !submitDetailId) {
Taro.showToast({
title: '二维码缺少打卡参数',
icon: 'none',
})
return
}
const submitResult = await submitScanCheckinAPI({
activity_id: submitActivityId,
detail_id: submitDetailId,
})
if (submitResult.code === 1) {
detail.isChecked = true
detail.lastScanCode = scannedCode
await Taro.showModal({
title: '模拟提交成功',
content: `扫码结果:${scannedCode}`,
title: '打卡成功',
content: '您已完成当前扫码打卡,可前往列表查看状态。',
showCancel: false,
confirmText: '知道了',
confirmText: '查看列表',
})
const params = new URLSearchParams({
activityId: detail.activityId,
})
// 扫码打卡完成后回列表页,方便用户继续处理同一活动下的其他关卡。
Taro.redirectTo({
url: `/pages/ScanCheckinList/index?${params.toString()}`,
})
return
}
......@@ -153,30 +251,55 @@ const handleScanCheckin = async () => {
}
}
const handleMockDataLoaded = mockDetail => {
const applyStageDetail = stageDetail => {
detail.scanSubmitting = false
// 这里把接口字段统一映射成页面内部字段,避免模板层直接耦合后端命名。
Object.assign(detail, {
...mockDetail,
discountContentRaw: mockDetail.discountContent,
isChecked: mockDetail.status === '已打卡',
id: stageDetail.id || '',
banners:
Array.isArray(stageDetail.banner) && stageDetail.banner.length > 0
? stageDetail.banner
: [defaultCover],
title: stageDetail.title || '',
guideText: stageDetail.note || '',
discountTitle: stageDetail.discount_title || '打卡点专属优惠',
discountContentRaw: stageDetail.introduction || '',
geoEnabled: stageDetail.geo_enabled === true,
centerLng: stageDetail.center_lng,
centerLat: stageDetail.center_lat,
radiusMeters: stageDetail.radius_meters,
isChecked: stageDetail.is_checked === true,
})
}
useLoad(options => {
detail.activityId = options.activityId || options.activity_id || ''
const detailId = options.detailId || options.id || ''
const mockDetail = getMockScanCheckinDetail(detailId)
detail.regSource = options.reg_source || ''
detail.regStageId = options.reg_stage_id || ''
// 当前页路径会透传给补资料页,提交成功后用于回跳续扫。
detail.returnUrl = `/${getCurrentPageFullPath()}`
detail.id = detailId
loadStageDetail()
})
if (!mockDetail) {
const loadStageDetail = async () => {
const result = await getScanStageDetailAPI({
id: detail.id,
})
if (result?.code !== 1 || !result?.data) {
Taro.showToast({
title: '未找到打卡点',
title: result?.msg || '获取关卡详情失败',
icon: 'none',
})
return
}
handleMockDataLoaded(mockDetail)
})
applyStageDetail(result.data)
}
</script>
<script>
......
......@@ -17,7 +17,7 @@
</view>
<view class="scan-checkin-list-content">
<text class="scan-checkin-list-name">{{ point.code }} {{ point.title }}</text>
<text class="scan-checkin-list-name">{{ point.title }}</text>
</view>
<view class="scan-checkin-list-action" @click="goToDetail(point)">
......@@ -41,7 +41,7 @@ import { ref } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import { IconFont, Scan2 } from '@nutui/icons-vue-taro'
import './index.less'
import { getMockScanCheckinPoints } from '@/utils/mockQrCheckin'
import { getScanStageListAPI } from '@/api/map'
const pointList = ref([])
const activityId = ref('')
......@@ -70,8 +70,29 @@ const handleShowBoothMap = () => {
useLoad(options => {
activityId.value = options.activityId || options.id || ''
pointList.value = getMockScanCheckinPoints(activityId.value)
loadStageList()
})
const loadStageList = async () => {
const result = await getScanStageListAPI({
activity_id: activityId.value,
})
if (result?.code !== 1) {
Taro.showToast({
title: result?.msg || '获取关卡列表失败',
icon: 'none',
})
return
}
pointList.value = (result?.data?.stages || []).map(stage => ({
id: stage.id,
title: stage.title,
isChecked: stage.is_checked,
}))
}
</script>
<script>
......
......@@ -12,7 +12,11 @@
<!-- Hero Image -->
<view class="w-full mb-6">
<view class="w-full h-48 rounded-2xl overflow-hidden">
<image :src="welcomeHomeImg" alt="家庭在上海外滩散步" class="w-full h-full object-cover" />
<image
:src="welcomeHomeImg"
alt="家庭在上海外滩散步"
class="w-full h-full object-cover"
/>
</view>
</view>
<!-- Steps Section -->
......@@ -21,19 +25,21 @@
<view class="space-y-6">
<!-- Step 1 -->
<view class="flex items-center">
<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">
<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"
>
1
</view>
<view>
<h3 class="font-bold">创建家庭</h3>
<p class="text-gray-600 text-sm">
60岁以上家长创建家庭,设置专属口令
</p>
<p class="text-gray-600 text-sm">60岁以上家长创建家庭,设置专属口令</p>
</view>
</view>
<!-- Step 2 -->
<view class="flex items-center">
<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">
<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"
>
2
</view>
<view>
......@@ -43,24 +49,31 @@
</view>
<!-- Step 3 -->
<view class="flex items-center">
<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">
<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"
>
3
</view>
<view>
<h3 class="font-bold">同步步数,兑换好礼</h3>
<p class="text-gray-600 text-sm">
每日同步微信步数,兑换抵用券
</p>
<p class="text-gray-600 text-sm">每日同步微信步数,兑换抵用券</p>
</view>
</view>
</view>
</view>
<!-- Action Buttons -->
<view class="space-y-4 mt-auto">
<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">
<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"
>
创建家庭
</view>
<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;">
<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"
>
加入家庭
</view>
</view>
......@@ -70,24 +83,16 @@
<!-- Dialog Components -->
<!-- 个人信息收集说明弹窗 -->
<nut-dialog
v-model:visible="showPrivacyDialog"
title="个人信息收集说明"
>
<nut-dialog v-model:visible="showPrivacyDialog" title="个人信息收集说明">
<template #default>
<view class=" text-gray-700 leading-loose text-sm text-left break-words">
<view class="text-gray-700 leading-loose text-sm text-left break-words">
{{ privacyContent }}
</view>
</template>
<template #footer>
<nut-row :gutter="10">
<nut-col :span="12">
<nut-button
@click="onPrivacyCancel"
type="default"
size="normal"
block
>
<nut-button @click="onPrivacyCancel" type="default" size="normal" block>
取消操作
</nut-button>
</nut-col>
......@@ -107,10 +112,7 @@
</nut-dialog>
<!-- 年龄限制提示弹窗 -->
<nut-dialog
v-model:visible="showAgeDialog"
title="温馨提示"
>
<nut-dialog v-model:visible="showAgeDialog" title="温馨提示">
<template #default>
<view class="text-center text-gray-700 text-sm leading-loose">
您需要年满60岁才能创建家庭
......@@ -134,43 +136,46 @@
</nut-dialog>
<!-- 广告遮罩层 -->
<AdOverlay
ref="adOverlayRef"
@close="handleAdClose"
@click="handleAdClick"
/>
<AdOverlay ref="adOverlayRef" @close="handleAdClose" @click="handleAdClick" />
</view>
</template>
<script setup>
import { ref } from 'vue';
import Taro, { useDidShow, useLoad } from '@tarojs/taro';
import { handleSharePageAuth, addShareFlag } from '@/utils/authRedirect';
import BottomNav from '@/components/BottomNav.vue';
import { ref } from 'vue'
import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { handleSharePageAuth, addShareFlag } from '@/utils/authRedirect'
import BottomNav from '@/components/BottomNav.vue'
import AdOverlay from '@/components/AdOverlay.vue'
// 获取接口信息
import { getUserProfileAPI } from '@/api/user';
import { getUserProfileAPI } from '@/api/user'
import { appendReturnUrlParam, normalizeReturnUrl } from '@/utils/returnUrl'
import { isUserProfileComplete } from '@/utils/userProfile'
// 导入主题颜色
import { THEME_COLORS } from '@/utils/config';
import { THEME_COLORS } from '@/utils/config'
const welcomeHomeImg = 'https://cdn.ipadbiz.cn/lls_prog/images/iwEcAqNqcGcDAQTRCAAF0QSABrA-9ERC3jG0pwiy88rQQ3IAB9IIwZFkCAAJomltCgAL0gADu3A.jpg';
const welcomeHomeImg =
'https://cdn.ipadbiz.cn/lls_prog/images/iwEcAqNqcGcDAQTRCAAF0QSABrA-9ERC3jG0pwiy88rQQ3IAB9IIwZFkCAAJomltCgAL0gADu3A.jpg'
const userAge = ref(null);
const userInfo = ref({});
const canCreateFamily = ref(true);
const hasProfile = ref(false);
const userAge = ref(null)
const userInfo = ref({})
const canCreateFamily = ref(true)
const hasProfile = ref(false)
const returnUrl = ref('')
const regSource = ref('')
const regStageId = ref('')
// Dialog相关响应式数据
const showPrivacyDialog = ref(false);
const showAgeDialog = ref(false);
const pendingNavigateUrl = ref('');
const showPrivacyDialog = ref(false)
const showAgeDialog = ref(false)
const pendingNavigateUrl = ref('')
// 个人信息收集说明内容
const privacyContent = `为了提供更好的服务,我们需要收集您的基本信息:\n1.头像和昵称:用于家庭成员识别 \n2.出生年月:验证年龄资格,60岁以上可创建家庭。\n我们承诺严格保护您的个人隐私,仅用于家庭功能和活动服务。`;
const privacyContent =
'为了提供更好的服务,我们需要收集您的基本信息:\n1.头像和昵称:用于家庭成员识别 \n2.出生年月:验证年龄资格,60岁以上可创建家庭。\n我们承诺严格保护您的个人隐私,仅用于家庭功能和活动服务。'
const navigateTo = (url) => {
Taro.navigateTo({ url });
};
const navigateTo = url => {
Taro.navigateTo({ url })
}
// 广告遮罩层数据
......@@ -187,112 +192,159 @@ const handleAdClose = () => {
* 处理广告遮罩层点击事件
* @param {string} targetPage - 跳转的目标页面
*/
const handleAdClick = (targetPage) => {
const handleAdClick = targetPage => {
// 如果跳转路径是欢迎页和首页,不跳转直接关闭弹框
if (targetPage === '/pages/Dashboard/index' || targetPage === '/pages/Welcome/index') {
handleAdClose()
return
}
// 跳转到目标页面
Taro.navigateTo({ url: targetPage });
Taro.navigateTo({ url: targetPage })
}
useDidShow(async () => {
// 获取用户的个人信息
const { code, data } = await getUserProfileAPI();
const { code, data } = await getUserProfileAPI()
if (code) {
userInfo.value = data?.user?.nickname ? data.user : {
avatar_url: null,
nickname: null,
birth_date: null,
wheelchair: null,
wheelchair_text: null,
phone: null,
};
userInfo.value = data?.user?.nickname
? data.user
: {
avatar_url: null,
nickname: null,
birth_date: null,
wheelchair: null,
wheelchair_text: null,
phone: null,
}
// 计算用户年龄
if (userInfo.value.birth_date) {
const birthDate = new Date(userInfo.value.birth_date);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
const birthDate = new Date(userInfo.value.birth_date)
const today = new Date()
let age = today.getFullYear() - birthDate.getFullYear()
const monthDiff = today.getMonth() - birthDate.getMonth()
// 如果还没到生日,年龄减1
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
age--
}
userAge.value = age;
userAge.value = age
// 检查是否满60岁,可以创建家庭
canCreateFamily.value = age >= 60;
canCreateFamily.value = age >= 60
} else {
userAge.value = null;
canCreateFamily.value = false;
userAge.value = null
canCreateFamily.value = false
}
// 检查用户是否完善了个人信息
hasProfile.value = userInfo.value.nickname && userInfo.value.birth_date && userInfo.value.wheelchair_needed !== null;
hasProfile.value = isUserProfileComplete(userInfo.value)
}
});
})
const handleNavigate = url => {
const nextUrl = appendReturnUrl(url)
const handleNavigate = (url) => {
if (!hasProfile.value) {
// 显示个人信息收集说明弹窗
pendingNavigateUrl.value = '/pages/AddProfile/index';
showPrivacyDialog.value = true;
return;
pendingNavigateUrl.value = buildAddProfileUrl()
showPrivacyDialog.value = true
return
}
if (url === '/pages/CreateFamily/index') {
if (!canCreateFamily.value) {
// 显示年龄限制提示弹窗
showAgeDialog.value = true;
return;
showAgeDialog.value = true
return
}
}
navigateTo(url);
};
navigateTo(nextUrl)
}
// Dialog事件处理方法
const onPrivacyCancel = () => {
showPrivacyDialog.value = false;
pendingNavigateUrl.value = '';
};
showPrivacyDialog.value = false
pendingNavigateUrl.value = ''
}
const onPrivacyConfirm = () => {
showPrivacyDialog.value = false;
showPrivacyDialog.value = false
if (pendingNavigateUrl.value) {
navigateTo(pendingNavigateUrl.value);
pendingNavigateUrl.value = '';
navigateTo(pendingNavigateUrl.value)
pendingNavigateUrl.value = ''
}
};
}
const onAgeConfirm = () => {
showAgeDialog.value = false;
};
showAgeDialog.value = false
}
useLoad(options => {
// 处理分享页面的授权逻辑
handleSharePageAuth(options)
returnUrl.value = normalizeReturnUrl(options?.return_url || '')
regSource.value = options?.reg_source || ''
regStageId.value = options?.reg_stage_id || ''
})
const appendReturnUrl = url => {
return appendReturnUrlParam(url, returnUrl.value)
}
const buildAddProfileUrl = () => {
if (!returnUrl.value && !regSource.value && !regStageId.value) {
return '/pages/AddProfile/index'
}
// 从扫码链路进入 Welcome 时,补资料完成后应该先回 Welcome,
// 继续完成创建/加入家庭,最后再由家庭流程回到打卡详情页。
const params = new URLSearchParams()
if (regSource.value) {
params.set('reg_source', regSource.value)
}
if (regStageId.value) {
params.set('reg_stage_id', regStageId.value)
}
params.set('return_url', buildWelcomeReturnUrl())
return `/pages/AddProfile/index?${params.toString()}`
}
useLoad((options) => {
// 处理分享页面的授权逻辑
handleSharePageAuth(options);
});
const buildWelcomeReturnUrl = () => {
const params = new URLSearchParams()
if (regSource.value) {
params.set('reg_source', regSource.value)
}
if (regStageId.value) {
params.set('reg_stage_id', regStageId.value)
}
if (returnUrl.value) {
params.set('return_url', returnUrl.value)
}
const query = params.toString()
return query ? `/pages/Welcome/index?${query}` : '/pages/Welcome/index'
}
/**
* 定义分享给朋友的内容
* @returns {Object} 分享配置对象
*/
const onShareAppMessage = (res) => {
const shareData = {
title: '欢迎加入老来赛',
path: `/pages/Welcome/index`,
imageUrl: ''
};
return shareData;
const onShareAppMessage = res => {
const shareData = {
title: '欢迎加入老来赛',
path: '/pages/Welcome/index',
imageUrl: '',
}
return shareData
}
// 使用Taro的useShareAppMessage Hook来处理分享
Taro.useShareAppMessage((res) => {
return onShareAppMessage(res);
});
Taro.useShareAppMessage(res => {
return onShareAppMessage(res)
})
</script>
......
import Taro from '@tarojs/taro'
const toRadians = degree => (degree * Math.PI) / 180
/**
* @description 计算两个经纬度之间的距离(米)
* @param {Object} start
* @param {number|string} start.lng
* @param {number|string} start.lat
* @param {Object} end
* @param {number|string} end.lng
* @param {number|string} end.lat
* @returns {number}
*/
export const calculateDistanceMeters = (start, end) => {
const startLng = Number(start?.lng)
const startLat = Number(start?.lat)
const endLng = Number(end?.lng)
const endLat = Number(end?.lat)
if (![startLng, startLat, endLng, endLat].every(Number.isFinite)) {
return NaN
}
const earthRadius = 6371000
const deltaLat = toRadians(endLat - startLat)
const deltaLng = toRadians(endLng - startLng)
const a =
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(toRadians(startLat)) *
Math.cos(toRadians(endLat)) *
Math.sin(deltaLng / 2) *
Math.sin(deltaLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return earthRadius * c
}
/**
* @description 判断当前位置是否在扫码打卡允许范围内
* @param {Object} params
* @param {boolean} params.geoEnabled
* @param {number|string} params.userLng
* @param {number|string} params.userLat
* @param {number|string} params.centerLng
* @param {number|string} params.centerLat
* @param {number|string} params.radiusMeters
* @returns {{allowed:boolean,distance:number,reason:string}}
*/
export const checkCheckinRange = (params = {}) => {
const { geoEnabled, userLng, userLat, centerLng, centerLat, radiusMeters } = params
if (geoEnabled !== true) {
return {
allowed: true,
distance: 0,
reason: 'geo_disabled',
}
}
const distance = calculateDistanceMeters(
{ lng: userLng, lat: userLat },
{ lng: centerLng, lat: centerLat }
)
if (!Number.isFinite(distance)) {
return {
allowed: false,
distance: NaN,
reason: 'invalid_location',
}
}
const rangeLimit = Number(radiusMeters)
if (!Number.isFinite(rangeLimit) || rangeLimit < 0) {
return {
allowed: false,
distance,
reason: 'invalid_radius',
}
}
return {
allowed: distance <= rangeLimit,
distance,
reason: distance <= rangeLimit ? 'within_range' : 'out_of_range',
}
}
/**
* @description 静默获取当前位置并校验是否在扫码打卡范围内
* @param {Object} params
* @param {boolean} params.geoEnabled
* @param {number|string} params.centerLng
* @param {number|string} params.centerLat
* @param {number|string} params.radiusMeters
* @returns {Promise<{allowed:boolean,distance:number,reason:string,location?:{lng:number,lat:number}}>}
*/
export const verifyCheckinRangeWithCurrentLocation = async (params = {}) => {
const { geoEnabled, centerLng, centerLat, radiusMeters } = params
// 未开启地理围栏时直接放行,避免页面层重复写同样的短路判断。
if (geoEnabled !== true) {
return {
allowed: true,
distance: 0,
reason: 'geo_disabled',
}
}
try {
// 重新拉取当前位置而不是复用缓存,确保扫码瞬间的位置判断更接近真实场景。
const location = await Taro.getLocation({
type: 'gcj02',
altitude: false,
isHighAccuracy: true,
highAccuracyExpireTime: 4000,
})
const normalizedLocation = {
lng: location.longitude,
lat: location.latitude,
}
const rangeResult = checkCheckinRange({
geoEnabled,
userLng: normalizedLocation.lng,
userLat: normalizedLocation.lat,
centerLng,
centerLat,
radiusMeters,
})
return {
...rangeResult,
location: normalizedLocation,
}
} catch (error) {
console.error('获取扫码打卡位置失败:', error)
return {
allowed: false,
distance: NaN,
reason: 'location_fetch_failed',
}
}
}
/**
* @description 对 return_url 做安全解码,兼容被重复 encode 的情况
* @param {string} value
* @returns {string}
*/
export const normalizeReturnUrl = (value = '') => {
let normalized = String(value || '').trim()
if (!normalized) {
return ''
}
let previousValue = ''
while (normalized && normalized !== previousValue) {
previousValue = normalized
try {
normalized = decodeURIComponent(normalized)
} catch (error) {
break
}
}
if (!normalized.startsWith('/')) {
normalized = `/${normalized.replace(/^\/+/, '')}`
}
return normalized
}
/**
* @description 给页面地址追加 return_url 参数
* @param {string} url
* @param {string} returnUrl
* @returns {string}
*/
export const appendReturnUrlParam = (url, returnUrl = '') => {
const normalizedReturnUrl = normalizeReturnUrl(returnUrl)
if (!normalizedReturnUrl) {
return url
}
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}return_url=${encodeURIComponent(normalizedReturnUrl)}`
}
/**
* @description 从扫码结果中提取打卡接口所需参数
* @param {string} rawScanResult - 微信扫码返回的原始结果,可能是完整URL,也可能是纯查询串
* @returns {{activityId:string, detailId:string, rawParams:Object}}
*/
export const parseScanCheckinParams = (rawScanResult = '') => {
const normalized = String(rawScanResult || '').trim()
if (!normalized) {
return {
activityId: '',
detailId: '',
rawParams: {},
}
}
const querySource = extractQuerySource(normalized)
const rawParams = parseQueryString(querySource)
return {
activityId: pickFirstAvailableValue(rawParams, ['activity_id', 'activityId', 'id']),
detailId: pickFirstAvailableValue(rawParams, ['detail_id', 'detailId', 'stage_id', 'stageId']),
rawParams,
}
}
const extractQuerySource = input => {
const questionMarkIndex = input.indexOf('?')
if (questionMarkIndex >= 0) {
return input.slice(questionMarkIndex + 1)
}
return input
}
const parseQueryString = (queryString = '') => {
const hashRemoved = String(queryString || '').split('#')[0]
const pairs = hashRemoved.split('&').filter(Boolean)
return pairs.reduce((result, pair) => {
const [rawKey, ...rest] = pair.split('=')
const rawValue = rest.join('=')
const key = safeDecodeURIComponent(rawKey)
const value = safeDecodeURIComponent(rawValue)
if (key) {
result[key] = value
}
return result
}, {})
}
const pickFirstAvailableValue = (params, keys = []) => {
for (const key of keys) {
const value = params?.[key]
if (value !== '' && value !== undefined && value !== null) {
return String(value)
}
}
return ''
}
const safeDecodeURIComponent = (value = '') => {
try {
return decodeURIComponent(String(value || '').replace(/\+/g, '%20'))
} catch (error) {
return String(value || '')
}
}
......@@ -25,9 +25,28 @@ export const buildUpdateUserProfilePayload = (formData, extraParams = {}) => {
optionalKeys.forEach(key => {
const value = extraParams[key]
if (value !== '' && value !== undefined && value !== null) {
// reg_stage_id 需要保持数值类型,其他来源字段按原样透传即可。
payload[key] = key === 'reg_stage_id' ? Number(value) : value
}
})
return payload
}
/**
* @description 判断用户是否已完善扫码打卡所需的个人资料
* @param {Object} user - 用户资料
* @returns {boolean} 是否已完善
*/
export const isUserProfileComplete = (user = {}) => {
// 扫码打卡只关心能否继续后续流程所需的关键资料是否齐全,
// 不把 session、年龄限制等页面外规则混进这里。
return Boolean(
user?.nickname &&
user?.birth_date &&
user?.gender !== null &&
user?.gender !== undefined &&
user?.wheelchair_needed !== null &&
user?.wheelchair_needed !== undefined
)
}
......