Merge remote-tracking branch 'origin/feature/uni-app' into feature/uni-app

# Conflicts:
#	gym-manage-uniapp/pages.json
This commit is contained in:
2026-06-04 13:17:59 +08:00
9 changed files with 1484 additions and 8 deletions
+979
View File
@@ -0,0 +1,979 @@
<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" class="loadingBox" :style="{width: Math.min(width, 500) + 'rpx',height: Math.min(height, 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 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="!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>
<!-- 底部按钮z-index永久置顶 -->
<view class="bottom-actions">
<button @tap="handleLongPress">签到</button>
<button class="btn-refresh" @tap="refreshQR">
<uni-icons type="refresh" size="36rpx" color="#5E6F8D"></uni-icons>
<text>刷新二维码</text>
</button>
<!-- 测试用手动清除缓存按钮 -->
<button class="btn-clear-cache" @tap="handleClearCache">
<uni-icons type="trash" size="36rpx" color="#ef4444"></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'
// 引入API封装
import { getQRCode, checkIn as apiCheckIn } 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 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)
}
}
/**
* 清除所有QR_开头的缓存(用于测试阶段)
*/
const clearQRCache = () => {
try {
const keys = uni.getStorageInfoSync().keys || []
let clearedCount = 0
for (const key of keys) {
if (key.startsWith(CACHE_PREFIX)) {
uni.removeStorageSync(key)
clearedCount++
}
}
console.log(`已清除 ${clearedCount} 个QR_开头的缓存`)
return clearedCount
} catch (e) {
console.error('清除QR缓存失败:', e)
return 0
}
}
/**
* 测试用:手动清除缓存按钮点击事件
*/
const handleClearCache = () => {
uni.showModal({
title: '清除缓存',
content: '确定要清除所有签到相关的缓存吗?(测试用)',
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
const clearedCount = clearQRCache()
// 重置页面状态
image.value = ""
width.value = 0
height.value = 0
status.value = 'loading'
QRStatus.value = "生成中..."
STQRC.value = false
isCheckIn.value = false
uni.showToast({
title: `已清除 ${clearedCount} 个缓存`,
icon: 'success',
duration: 2000
})
// 重新请求二维码
setTimeout(() => {
getStorage(null)
}, 500)
}
}
})
}
onLoad(() => {
uni.showLoading({
title: '生成签到二维码...',
mask: true
})
// 读取签到状态缓存(自动检查过期)
// isCheckIn 代表签到状态,从缓存读取
const cachedIsCheckIn = getCacheData("isCheckIn")
// checkInTime 代表具体签到时间,从缓存读取(显示在 loading-text 中)
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 显示具体签到时间(从缓存读取,显示在 loading-text 中)
QRStatus.value = cachedCheckInTime || "已完成签到"
uni.hideLoading()
return
}
// 未签到或缓存失效,需要请求后端获取二维码
// QRStatus 重置为默认的请求状态
QRStatus.value = "生成中..."
getStorage(null)
})
// 页面卸载时关闭WebSocket连接(不清除缓存,让缓存自然过期)
onUnload(() => {
closeWebSocket()
// 缓存会在当日23:59:59自动过期,页面卸载时不主动清除
// 如需测试,使用页面上的"清除缓存"按钮手动清除
})
// 获取二维码接口
const fetchQRCode = () => {
console.log(1111)
status.value = ''
errorText.value = '' // 重置错误文本
image.value = ""
getQRCode(true).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 || '签到失败,请重试' // 对应错误文案
})
}
// 建立WebSocket连接
const connectWebSocket = (qrContent) => {
const wsUrl = `ws://192.168.43.89:8084/webSocket/checkIn`
console.log('WebSocket 连接地址:', wsUrl)
socketTask = uni.connectSocket({
url: wsUrl,
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
// status.value = 'scanned'
// errorText.value = '' // 成功重置错误文本
// uni.showToast({
// title: '签到成功!',
// icon: 'success',
// duration: 2000
// })
} else if (message.startsWith('二维码无效')) {
uni.showToast({
title: message,
icon: 'none',
duration: 2000
})
status.value = 'error'
errorText.value = '二维码无效,请刷新' // 对应错误文案
setTimeout(() => {
closeWebSocket()
}, 3000)
} else if (message === '消息格式错误') {
uni.showToast({
title: '消息格式错误',
icon: 'none'
})
status.value = 'error'
errorText.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 = '连接失败,请重试' // 对应错误文案
uni.showToast({
title: '连接失败',
icon: 'none'
})
})
}
// 关闭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, #0B2B4B 0%, #1A4A6F 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(0, 0, 0, 0.1);
.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 #FF6B35;
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, #FF6B35 0%, #FF8C5A 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: #FF6B35;
}
.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%;
.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: #FF6B35;
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 #FF6B35;
&.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: #FF6B35;
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;
}
}
}
/* 测试用:清除缓存按钮 */
.btn-clear-cache {
width: calc(100% - 96rpx);
height: 88rpx;
background: #FEF2F2;
color: #EF4444;
border: 1rpx solid #FECACA;
border-radius: 999px;
font-size: 29rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
&:active {
background: #FEE2E2;
}
}
/* 旋转动画 */
@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, #123A5E 0%, #1A4A6F 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>