1010 lines
24 KiB
Vue
1010 lines
24 KiB
Vue
<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 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)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除所有与签到相关的缓存(用于测试阶段)
|
||
*/
|
||
const clearQRCache = () => {
|
||
try {
|
||
const keys = uni.getStorageInfoSync().keys || []
|
||
let clearedCount = 0
|
||
for (const key of keys) {
|
||
// 清除 QR_ 开头的缓存(页面内部缓存)
|
||
if (key.startsWith(CACHE_PREFIX)) {
|
||
uni.removeStorageSync(key)
|
||
clearedCount++
|
||
}
|
||
// 清除 API_CACHE_ 开头的缓存(通过 utils/cache.js 缓存的接口数据)
|
||
if (key.startsWith('API_CACHE_')) {
|
||
// 只清除与签到相关的 API 缓存
|
||
if (key.includes('checkIn') || key.includes('qrcode')) {
|
||
uni.removeStorageSync(key)
|
||
clearedCount++
|
||
}
|
||
}
|
||
}
|
||
console.log(`已清除 ${clearedCount} 个签到相关缓存`)
|
||
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
|
||
})
|
||
|
||
// 重置页面状态后不再自动请求二维码
|
||
uni.hideLoading()
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
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({ 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, #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> |