Files
gym-manage/gym-manage-uniapp/pages/checkIn/checkIn.vue
T

974 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="checkin-page">
<!-- 顶部导航栏固定顶部 -->
<view class="header">
<view class="header-title">会员签到</view>
<view class="header-subtitle">扫码入场开启今日训练</view>
</view>
<!-- 会员信息卡片 -->
<view class="member-card card-default">
<view class="member-avatar">
<image src="/static/default-avatar.png" mode="aspectFill"></image>
</view>
<view class="member-info">
<view class="member-name">尊敬的会员</view>
<view class="member-level">
<text class="level-badge">黄金会员</text>
<text class="valid-date">有效期至 2026-12-31</text>
</view>
</view>
<view class="member-points">
<view class="points-value">1280</view>
<view class="points-label">积分</view>
</view>
</view>
<!-- 二维码核心区域 -->
<view class="qr-container card-default">
<view class="qr-header">
<view class="qr-title">出示二维码签到</view>
<view class="qr-tip">将二维码对准前台扫码设备</view>
</view>
<view class="QRBox">
<view class="QR">
<image :src="image" mode="" :style="{width: Math.min(width, 500) + 'rpx',height: Math.min(height, 500) + 'rpx' } "></image>
</view>
<!-- 加载动画区域 - 设置最小尺寸确保进入页面时可见 -->
<view v-if="(!image || STQRC) && isHaveCard" class="loadingBox" :style="{width: (width > 0 ? Math.min(width, 500) : 500) + 'rpx',height: (height > 0 ? Math.min(height, 500) : 500) + 'rpx' }">
<view class="loading-spinner">
<view v-if="!isCheckIn" class="spinner-circle"></view>
<view v-else>
<uni-icons type="checkmarkempty" size="30"></uni-icons>
</view>
<text class="loading-text">{{ QRStatus }}</text>
</view>
</view>
<!-- 二维码装饰边框 -->
<view v-else-if="image" class="qr-border" :style="{width: Math.min(width, 500) + 80 + 'rpx',height: Math.min(height, 500) + 80 + 'rpx' }">
<view class="corner top-left"></view>
<view class="corner top-right"></view>
<view class="corner bottom-left"></view>
<view class="corner bottom-right"></view>
</view>
</view>
<!-- 状态组件传递状态和自定义错误文案- 已签到时不显示 -->
<QrStatus
v-if="isHaveCard && !isCheckIn"
:status="status"
:errorText="errorText"
/>
</view>
<!-- 操作提示 -->
<view class="tips-section">
<view class="tips-title">温馨提示</view>
<view class="tips-list">
<view class="tip-item">
<view class="tip-dot"></view>
<text>二维码每5分钟自动刷新请勿截图使用</text>
</view>
<view class="tip-item">
<view class="tip-dot"></view>
<text>签到成功后可进入场馆开始训练</text>
</view>
<view class="tip-item">
<view class="tip-dot"></view>
<text>如有问题请联系前台工作人员</text>
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-actions">
<button class="btn-refresh" @tap="refreshQR">
<uni-icons type="refresh" size="36rpx" color="#5E6F8D"></uni-icons>
<text>刷新二维码</text>
</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app'
// 引入状态组件(路径与你保持一致)
import QrStatus from '@/components/QRCode/StatusCard.vue'
import { getQRCode, getMyMemberCards } from '@/api/main.js'
let image = ref("")
let width = ref(0)
let height = ref(0)
let status = ref('loading')
let socketTask = null
const scanStatus = ref(false)
const QRStatus = ref("生成中...")
const STQRC = ref(false)//是否扫码
const isCheckIn = ref(false)
const isHaveCard = ref(true)
const webSoketURL = "ws://localhost:8084/webSocket/checkIn"
const qrcode = ref("")
// 新增:自定义错误文本变量
const errorText = ref('')
/**
* 缓存键名前缀
*/
const CACHE_PREFIX = 'QR_'
/**
* 获取带前缀的缓存键名
* @param {string} key - 原始键名
* @returns {string} 带前缀的键名
*/
const getCacheKey = (key) => {
return CACHE_PREFIX + key
}
/**
* 获取今日23:59:59的时间戳(当日过期时间)
* @returns {number} 今日23:59:59的时间戳
*/
const getTodayExpireTime = () => {
const now = new Date()
const expire = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
return expire.getTime()
}
/**
* 检查缓存是否过期
* @param {string} key - 缓存键名(不带前缀)
* @returns {boolean} 是否有效(未过期)
*/
const isCacheValid = (key) => {
try {
const cacheKey = getCacheKey(key)
const cacheData = uni.getStorageSync(cacheKey)
if (!cacheData) return false
if (typeof cacheData === 'object' && cacheData.expireTime) {
const now = Date.now()
return now <= cacheData.expireTime
}
return false
} catch (e) {
console.error('检查缓存有效性失败:', e)
return false
}
}
/**
* 获取缓存数据(自动校验过期时间)
* @param {string} key - 缓存键名(不带前缀)
* @returns {any} 缓存数据(过期返回null)
*/
const getCacheData = (key) => {
try {
if (!isCacheValid(key)) {
// 缓存已过期,清除缓存
uni.removeStorageSync(getCacheKey(key))
return null
}
const cacheData = uni.getStorageSync(getCacheKey(key))
if (typeof cacheData === 'object' && cacheData.data !== undefined) {
return cacheData.data
}
return null
} catch (e) {
console.error('获取缓存数据失败:', e)
return null
}
}
/**
* 设置缓存数据(自动设置当日23:59:59过期)
* @param {string} key - 缓存键名(不带前缀)
* @param {any} data - 要缓存的数据
*/
const setCacheData = (key, data) => {
try {
const cacheData = {
data: data,
expireTime: getTodayExpireTime()
}
uni.setStorageSync(getCacheKey(key), cacheData)
} catch (e) {
console.error('设置缓存数据失败:', e)
}
}
onLoad(async () => {
uni.showLoading({
title: '加载中...',
mask: true
})
try {
const memberInfo = uni.getStorageSync('memberInfo')
console.log(memberInfo)
const memberId = memberInfo?.id || memberInfo?.memberId
console.log('[签到页面] 会员ID:', memberId)
if (!memberId) {
throw new Error('未获取到会员ID')
}
const cardRes = await getMyMemberCards(memberId)
console.log('[签到页面] 查询会员卡结果:', JSON.stringify(cardRes, null, 2))
const hasCard = cardRes && cardRes.data && cardRes.data.length > 0
console.log('[签到页面] 查询会员卡 - hasCard:', hasCard)
if (!hasCard) {
isHaveCard.value = false
uni.hideLoading()
uni.showModal({
title: '提示',
content: '您还没有会员卡,无法进行签到。是否去购买会员卡?',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.redirectTo({
url: '/pages/memberInfo/purchaseCard'
})
} else {
uni.switchTab({
url: '/pages/index/index'
})
}
},
fail: () => {
uni.switchTab({
url: '/pages/index/index'
})
}
})
return
}
isHaveCard.value = true
uni.showLoading({
title: '生成签到二维码...',
mask: true
})
const cachedIsCheckIn = getCacheData("isCheckIn")
const cachedCheckInTime = getCacheData("checkInTime")
if(cachedIsCheckIn != null) {
console.log("进入缓存 - 签到状态")
isCheckIn.value = cachedIsCheckIn
STQRC.value = true
}
if(isCheckIn.value) {
console.log("已签到且有缓存,无需请求后端")
const cachedQRInfo = getCacheData("QRInfo")
if(cachedQRInfo) {
image.value = cachedQRInfo.qrCodeBase64
width.value = cachedQRInfo.width * 2
height.value = cachedQRInfo.height * 2
}
QRStatus.value = cachedCheckInTime || "已完成签到"
uni.hideLoading()
return
}
QRStatus.value = "生成中..."
getStorage(null)
} catch (err) {
console.error('[签到页面] 查询会员卡失败:', err)
uni.hideLoading()
uni.showToast({
title: '获取会员卡信息失败',
icon: 'none'
})
}
})
onUnload(() => {
closeWebSocket()
})
// 获取二维码接口
const fetchQRCode = () => {
console.log(1111)
status.value = ''
errorText.value = '' // 重置错误文本
image.value = ""
getQRCode({ cache: true, cacheTime: 5 * 60 * 1000 }).then(res => {
console.log(res)
// 保存到本地缓存(用于签到状态判断)
setCacheData("QRInfo", res)
getStorage(res)
qrcode.value = res.qrContent
}).catch(err => {
console.error('获取二维码失败:', err)
status.value = 'error'
errorText.value = err.message || '获取二维码失败'
uni.showToast({
title: errorText.value,
icon: 'error'
})
}).finally(() => {
uni.hideLoading()
})
}
const refreshQR = () => {
// 如果已签到,不允许刷新二维码
if (isCheckIn.value) {
uni.showToast({
title: '您已签到',
icon: 'none',
duration: 2000
})
return
}
// 清空图片,显示加载状态
image.value = ""
QRStatus.value = "正在刷新二维码..."
setTimeout(() => {
getStorage(null)
}, 500)
}
const getStorage = res => {
let data = res
if(data == null) {
// 使用带过期检查的缓存读取
data = getCacheData("QRInfo")
uni.hideLoading()
}
if(!data) fetchQRCode()
if(data) {
image.value = data.qrCodeBase64
// 使用带过期时间的缓存(当日23:59:59过期)
setCacheData("QRInfo", data)
width.value = data.width * 2
height.value = data.height * 2
// 只有在未签到的情况下才连接WebSocket等待扫码
if (data.qrContent && !isCheckIn.value) {
console.log("未签到,连接WebSocket等待扫码")
connectWebSocket(data.qrContent)
} else if (isCheckIn.value) {
console.log("已签到,无需连接WebSocket")
}
}
}
const handleLongPress = () => {
uni.scanCode({
onlyFromCamera: false,
scanType: ['qrCode'],
success: (res) => {
console.log('扫码结果:', res)
checkIn(res.result)
},
fail: (err) => {
console.error('扫码失败:', err)
uni.showToast({
title: '扫码失败',
icon: 'none'
})
}
})
}
// 手动签到接口
const checkIn = (qrContent) => {
console.log(qrContent)
apiCheckIn({ qrContent }).then(res => {
closeWebSocket()
console.log(res)
status.value = 'scanned'
errorText.value = '' // 成功重置错误文本
QRStatus.value = res.dateTime + " 成功签到"
isCheckIn.value = true
// 使用带过期时间的缓存(当日23:59:59过期)
setCacheData("checkInTime", QRStatus.value)
setCacheData("isCheckIn", isCheckIn.value)
uni.showToast({
title: '签到成功!',
icon: 'success',
duration: 2000
})
}).catch(err => {
console.error('签到请求失败:', err)
status.value = 'error'
errorText.value = err.message || '签到失败,请重试' // 对应错误文案
uni.showToast({
title: err.message,
icon: 'none'
})
})
}
// 建立WebSocket连接
const connectWebSocket = (qrContent) => {
console.log('WebSocket 连接地址:', webSoketURL)
socketTask = uni.connectSocket({
url: webSoketURL,
success: () => {
console.log('WebSocket 连接中...')
},
fail: (err) => {
console.error('WebSocket 连接失败:', err)
status.value = 'error'
errorText.value = '连接失败,请重试' // 对应错误文案
}
})
// 连接打开成功
socketTask.onOpen(() => {
console.log('WebSocket 连接成功')
status.value = 'waiting'
errorText.value = '' // 成功重置错误文本
sendQRCodeInfo(qrContent)
})
// 发送二维码信息到后端
const sendQRCodeInfo = (qrContent) => {
const qrCodeDto = {
qrContent: qrContent,
used: false
}
console.log('发送二维码信息:', qrCodeDto)
socketTask.send({
data: JSON.stringify(qrCodeDto),
success: () => {
console.log('二维码信息发送成功')
},
fail: (err) => {
console.error('发送失败:', err)
status.value = 'error'
errorText.value = '连接失败,请重试' // 对应错误文案
}
})
}
// 接收后端消息
socketTask.onMessage((res) => {
console.log('收到 WebSocket 消息:', res.data)
const message = res.data
if (message === '正在进行签到') {
// 显示遮罩,防止用户重复扫码
QRStatus.value = "正在进行签到..."
STQRC.value = true
} else if (message === '签到成功' || message.includes('签到成功')) {
// 签到成功,更新状态
status.value = 'scanned'
errorText.value = ''
isCheckIn.value = true
QRStatus.value = "签到成功"
// 缓存签到状态
setCacheData("isCheckIn", true)
setCacheData("checkInTime", "签到成功")
uni.showToast({
title: '签到成功!',
icon: 'success',
duration: 2000
})
// 关闭WebSocket连接
closeWebSocket()
} else if (message.startsWith('二维码无效')) {
uni.showToast({
title: message,
icon: 'none',
duration: 2000
})
status.value = 'error'
errorText.value = '二维码无效,请刷新'
// 隐藏遮罩,允许用户重新操作
STQRC.value = false
QRStatus.value = "生成中..."
setTimeout(() => {
closeWebSocket()
}, 3000)
} else if (message === '消息格式错误') {
status.value = 'error'
errorText.value = '消息格式错误'
// 隐藏遮罩,允许用户重新操作
STQRC.value = false
QRStatus.value = "生成中..."
} else if (message.includes('失败') || message.includes('错误')) {
// 其他失败情况
uni.showToast({
title: message,
icon: 'none',
duration: 2000
})
status.value = 'error'
errorText.value = message
// 隐藏遮罩,允许用户重新操作
STQRC.value = false
QRStatus.value = "生成中..."
} else {
console.log('未知消息:', message)
}
})
// 连接关闭
socketTask.onClose(() => {
console.log('WebSocket 连接关闭')
status.value = 'closed'
errorText.value = '' // 重置错误文本
})
// 连接错误
socketTask.onError((err) => {
console.error('WebSocket 错误:', err)
status.value = 'error'
errorText.value = '连接失败,请重试' // 对应错误文案
})
}
// 关闭WebSocket连接
const closeWebSocket = () => {
if (socketTask) {
socketTask.close({
success: () => {
console.log('主动关闭 WebSocket')
}
})
socketTask = null
}
}
</script>
<style lang="scss" scoped>
.checkin-page {
min-height: 100vh;
background-color: #F9FAFE;
padding-top: 200rpx; /* 为固定顶部导航预留空间 */
padding-bottom: 160rpx; /* 为底部按钮预留空间 */
box-sizing: border-box;
}
/* 顶部导航(固定顶部) */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 99998; /* 低于底部按钮,确保底部按钮永远最上层 */
background: linear-gradient(135deg, #7AB5CC 0%, #9CCFDF 100%);
padding: 64rpx 48rpx 48rpx;
text-align: center;
color: #FFFFFF;
border-bottom-left-radius: 56rpx;
border-bottom-right-radius: 56rpx;
box-shadow: 0 4rpx 20rpx rgba(120, 185, 215, 0.3);
.header-title {
font-size: 45rpx;
font-weight: 700;
margin-bottom: 8rpx;
}
.header-subtitle {
font-size: 26rpx;
opacity: 0.8;
}
}
/* 通用卡片样式 */
.card-default {
background: #FFFFFF;
border-radius: 40rpx;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.03), 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
border: 1rpx solid #E9EDF2;
padding: 32rpx;
}
/* 会员信息卡片 */
.member-card {
margin: 0 32rpx 48rpx;
display: flex;
align-items: center;
padding: 32rpx;
flex-wrap: nowrap;
.member-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 999px;
overflow: hidden;
border: 4rpx solid #7AB5CC;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
}
}
.member-info {
flex: 1;
margin-left: 32rpx;
flex-shrink: 1;
min-width: 0;
.member-name {
font-size: 32rpx;
font-weight: 500;
color: #1E2A3A;
margin-bottom: 8rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-level {
display: flex;
align-items: center;
gap: 16rpx;
flex-wrap: wrap;
.level-badge {
background: linear-gradient(135deg, #7AB5CC 0%, #9CCFDF 100%);
color: white;
padding: 4rpx 16rpx;
border-radius: 24rpx;
font-size: 22rpx;
font-weight: 500;
flex-shrink: 0;
}
.valid-date {
font-size: 22rpx;
color: #5E6F8D;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.member-points {
text-align: center;
flex-shrink: 0;
margin-left: 16rpx;
.points-value {
font-size: 38rpx;
font-weight: 700;
color: #7AB5CC;
}
.points-label {
font-size: 22rpx;
color: #5E6F8D;
}
}
}
/* 二维码容器 */
.qr-container {
margin: 0 32rpx 48rpx;
padding: 48rpx 32rpx;
text-align: center;
.qr-header {
margin-bottom: 64rpx;
.qr-title {
font-size: 38rpx;
font-weight: 700;
color: #1E2A3A;
margin-bottom: 8rpx;
}
.qr-tip {
font-size: 26rpx;
color: #5E6F8D;
}
}
}
/* 二维码核心区域 */
.QRBox {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto 64rpx;
max-width: 100%;
min-height: 580rpx; /* 设置最小高度确保加载动画区域可见 */
.QR {
position: relative;
z-index: 2;
padding: 20rpx;
background: white;
border-radius: 40rpx;
}
.loadingBox {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 3;
display: flex;
justify-content: center;
align-items: center;
background: rgba(255, 255, 255, 0.95);
border-radius: 40rpx;
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
.spinner-circle {
width: 80rpx;
height: 80rpx;
border: 6rpx solid #E9EDF2;
border-top-color: #7AB5CC;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 26rpx;
color: #5E6F8D;
width: 250rpx;
}
}
}
/* 二维码装饰边框 */
.qr-border {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
.corner {
position: absolute;
width: 48rpx;
height: 48rpx;
border: 6rpx solid #7AB5CC;
&.top-left {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
border-top-left-radius: 40rpx;
}
&.top-right {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
border-top-right-radius: 40rpx;
}
&.bottom-left {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
border-bottom-left-radius: 40rpx;
}
&.bottom-right {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
border-bottom-right-radius: 40rpx;
}
}
}
}
/* 温馨提示 */
.tips-section {
margin: 0 32rpx 48rpx;
.tips-title {
font-size: 32rpx;
font-weight: 500;
color: #1E2A3A;
margin-bottom: 32rpx;
}
.tips-list {
background: #FFFFFF;
border-radius: 40rpx;
padding: 32rpx;
border: 1rpx solid #E9EDF2;
.tip-item {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.tip-dot {
width: 12rpx;
height: 12rpx;
background: #7AB5CC;
border-radius: 50%;
margin-top: 12rpx;
flex-shrink: 0;
}
text {
font-size: 26rpx;
color: #5E6F8D;
line-height: 1.6;
}
}
}
}
/* 底部按钮(z-index永久置顶+安全区域适配) */
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 99999; /* 极高z-index确保永远在最上层 */
padding: 32rpx 48rpx calc(32rpx + env(safe-area-inset-bottom));
background: #FFFFFF;
border-top: 1rpx solid #E9EDF2;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
.btn-refresh {
width: 100%;
height: 88rpx;
background: #F2F5F9;
color: #5E6F8D;
border: none;
border-radius: 999px;
font-size: 29rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin-bottom: 20rpx;
&:active {
background: #E9EDF2;
}
}
}
/* 旋转动画 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.checkin-page {
background-color: #121826;
}
.header {
background: linear-gradient(135deg, #5A98B0 0%, #7AB5CC 100%);
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.3);
}
.card-default {
background: #1E2636;
border-color: #2A3346;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.4);
}
.member-card .member-info .member-name {
color: #EDF2F7;
}
.member-card .member-info .member-level .valid-date,
.member-card .member-points .points-label {
color: #9AA9C1;
}
.qr-container .qr-header .qr-title {
color: #EDF2F7;
}
.qr-container .qr-header .qr-tip {
color: #9AA9C1;
}
.QRBox .loadingBox {
background: rgba(30, 38, 54, 0.95);
}
.QRBox .loadingBox .loading-spinner .loading-text {
color: #9AA9C1;
}
.tips-section .tips-title {
color: #EDF2F7;
}
.tips-section .tips-list {
background: #1E2636;
border-color: #2A3346;
}
.tips-section .tips-list .tip-item text {
color: #9AA9C1;
}
.bottom-actions {
background: #1E2636;
border-color: #2A3346;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.2);
}
.bottom-actions .btn-refresh {
background: #0F141F;
color: #9AA9C1;
&:active {
background: #2A3346;
}
}
.btn-clear-cache {
background: #3D1919;
color: #FCA5A5;
border-color: #5C2B2B;
&:active {
background: #4A2525;
}
}
}
</style>