添加扫码签到功能和相关配置

This commit is contained in:
future
2026-06-03 16:26:44 +08:00
parent e304c1b724
commit 14a0fe8d4f
9 changed files with 1488 additions and 6 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
const BASE_URL = 'http://localhost:8080/api' // const BASE_URL = 'http://localhost:8080/api'
const BASE_URL = '/api'
export const request = (options) => { export const request = (options) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+152
View File
@@ -0,0 +1,152 @@
import { request, setToken, clearToken, clearAllCache, clearCache } from '@/utils/request.js'
// ========== 登录相关API ==========
/**
* 微信小程序登录
* @param {object} data - 登录参数
* @param {string} data.code - 微信登录code
* @param {string} [data.encryptedData] - 加密数据
* @param {string} [data.iv] - 加密向量
* @returns {Promise} 登录结果
*/
export const login = (data) => {
return request({
url: '/member/auth/miniapp/login',
method: 'POST',
data: data,
needToken: false // 登录请求不需要token
}).then(res => {
// 登录成功,保存token
if (res.data && res.data.token) {
setToken(res.data.token)
}
return res
})
}
/**
* 退出登录
* @returns {Promise} 退出结果
*/
export const logout = () => {
return request({
url: '/member/auth/logout',
method: 'POST'
}).then(res => {
// 退出成功,清除token和缓存
clearToken()
clearAllCache()
return res
}).catch(err => {
// 即使请求失败,也清除本地token
clearToken()
clearAllCache()
throw err
})
}
// ========== 签到相关API ==========
/**
* 获取签到二维码
* @param {boolean} [cache=true] - 是否启用缓存
* @returns {Promise} 二维码数据
*/
export const getQRCode = (cache = true) => {
return request({
url: '/checkIn/qrcode',
method: 'GET',
cache: cache,
cacheTime: 5 * 60 * 1000 // 5分钟缓存
})
}
/**
* 扫码签到
* @param {string} qrContent - 二维码内容
* @returns {Promise} 签到结果
*/
export const checkIn = (qrContent) => {
return request({
url: '/checkIn/scan',
method: 'POST',
data: { qrContent }
})
}
// ========== 用户相关API ==========
/**
* 获取用户信息
* @param {boolean} [cache=true] - 是否启用缓存
* @returns {Promise} 用户信息
*/
export const getUserInfo = (cache = true) => {
return request({
url: '/member/info',
method: 'GET',
cache: cache,
cacheTime: 30 * 60 * 1000 // 30分钟缓存
})
}
/**
* 更新用户信息
* @param {object} data - 用户信息
* @returns {Promise} 更新结果
*/
export const updateUserInfo = (data) => {
return request({
url: '/member/info',
method: 'PUT',
data: data
}).then(res => {
// 更新成功,清除用户信息缓存
const cacheKey = `GET_/member/info_{}`
clearCache(cacheKey)
return res
})
}
// ========== 课程相关API ==========
/**
* 获取推荐课程列表
* @param {boolean} [cache=true] - 是否启用缓存
* @returns {Promise} 课程列表
*/
export const getRecommendCourses = (cache = true) => {
return request({
url: '/course/recommend',
method: 'GET',
cache: cache,
cacheTime: 10 * 60 * 1000 // 10分钟缓存
})
}
/**
* 获取课程详情
* @param {number} id - 课程ID
* @param {boolean} [cache=true] - 是否启用缓存
* @returns {Promise} 课程详情
*/
export const getCourseDetail = (id, cache = true) => {
return request({
url: `/course/${id}`,
method: 'GET',
cache: cache,
cacheTime: 15 * 60 * 1000 // 15分钟缓存
})
}
export default {
login,
logout,
getQRCode,
checkIn,
getUserInfo,
updateUserInfo,
getRecommendCourses,
getCourseDetail
}
@@ -0,0 +1,97 @@
<template>
<view class="qr-status">
<!-- 加载中状态 -->
<view v-if="status === 'loading'" class="status-loading">
<view class="status-icon">
<view class="loading-spinner"></view>
</view>
<text>生成中...</text>
</view>
<!-- 签到成功状态 -->
<view v-else-if="status === 'scanned'" class="status-success">
<view class="status-icon">
<uni-icons type="checkmarkcircle" size="40rpx" color="#2ECC71"></uni-icons>
</view>
<text>签到成功</text>
</view>
<!-- 错误状态支持自定义文案 -->
<view v-else-if="status === 'error'" class="status-error">
<view class="status-icon">
<uni-icons type="closecircle" size="40rpx" color="#E74C3C"></uni-icons>
</view>
<text>{{ errorText || '签到失败,请重试' }}</text>
</view>
</view>
</template>
<script setup>
import { defineProps } from 'vue';
// 扩展Props,支持自定义错误文案
const props = defineProps({
status: {
type: String,
required: true,
default: ''
},
// 自定义错误文本(可选)
errorText: {
type: String,
required: false,
default: ''
}
});
</script>
<style scoped>
/* 保留原样式,新增加载中样式 */
.qr-status {
margin-bottom: 48rpx;
}
.status-loading,
.status-waiting,
.status-success,
.status-error {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
font-size: 29rpx;
}
.status-icon {
display: flex;
align-items: center;
}
/* 加载中样式 */
.status-loading {
color: #FF6B35;
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #E9EDF2;
border-top-color: #FF6B35;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.status-success {
color: #2ECC71;
}
.status-error {
color: #E74C3C;
}
/* 旋转动画 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
@@ -6,6 +6,7 @@
v-for="(item, index) in entries" v-for="(item, index) in entries"
:key="index" :key="index"
class="entry-item" class="entry-item"
@tap="QEClick(item.path)"
> >
<!-- 入口图标容器 --> <!-- 入口图标容器 -->
<view :class="['entry-icon', { accent: item.accent }]"> <view :class="['entry-icon', { accent: item.accent }]">
@@ -21,37 +22,44 @@
</template> </template>
<script setup> <script setup>
const QEClick = () => {
uni.navigateTo({
url:"/pages/checkIn/checkIn"
})
}
// 快捷入口数据列表 // 快捷入口数据列表
const entries = [ const entries = [
{ {
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/course.png', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/course.png',
title: '找课程', title: '找课程',
desc: '精品课程', desc: '精品课程',
accent: false , accent: false
}, },
{ {
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/plan.png', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/plan.png',
title: '训练计划', title: '训练计划',
desc: '个性定制', desc: '个性定制',
accent: true , accent: true
}, },
{ {
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/data.png', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/data.png',
title: '健身数据', title: '健身数据',
desc: '记录分析', desc: '记录分析',
accent: false , accent: false
}, },
{ {
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/message.png', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/message.png',
title: '消息', title: '消息',
desc: '通知消息', desc: '通知消息',
accent: true, accent: true
}, },
{ {
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/checkIn.png', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/checkIn.png',
title: '签到', title: '签到',
desc: '打卡签到', desc: '打卡签到',
accent: false, accent: false,
path: "/pages/checkIn/checkIn"
} }
] ]
</script> </script>
+1 -1
View File
@@ -50,7 +50,7 @@
"quickapp" : {}, "quickapp" : {},
/* */ /* */
"mp-weixin" : { "mp-weixin" : {
"appid" : "wx8278fdbc9f158915", "appid" : "wx8f0d644d1d8985f6",
"setting" : { "setting" : {
"urlCheck" : false "urlCheck" : false
}, },
+6
View File
@@ -5,6 +5,12 @@
"style": { "style": {
"navigationBarTitleText": "健身房" "navigationBarTitleText": "健身房"
} }
},
{
"path": "pages/checkIn/checkIn",
"style": {
"navigationBarTitleText": "会员签到"
}
} }
], ],
"globalStyle": { "globalStyle": {
+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>
+215
View File
@@ -0,0 +1,215 @@
const BASE_URL = 'http://192.168.43.89:8084/api'
// 缓存相关常量
const CACHE_PREFIX = 'API_CACHE_'
const CACHE_EXPIRE_TIME = 5 * 60 * 1000 // 默认缓存时间5分钟
/**
* 获取缓存数据
* @param {string} key - 缓存键名
* @returns {any} 缓存数据(过期返回null)
*/
export const getCache = (key) => {
try {
const cacheData = uni.getStorageSync(CACHE_PREFIX + key)
if (cacheData && cacheData.expireTime && Date.now() < cacheData.expireTime) {
return cacheData.data
}
// 缓存过期,清除
uni.removeStorageSync(CACHE_PREFIX + key)
return null
} catch (e) {
console.error('获取缓存失败:', e)
return null
}
}
/**
* 设置缓存数据
* @param {string} key - 缓存键名
* @param {any} data - 要缓存的数据
* @param {number} expireTime - 过期时间(毫秒),默认5分钟
*/
export const setCache = (key, data, expireTime = CACHE_EXPIRE_TIME) => {
try {
const cacheData = {
data: data,
expireTime: Date.now() + expireTime
}
uni.setStorageSync(CACHE_PREFIX + key, cacheData)
} catch (e) {
console.error('设置缓存失败:', e)
}
}
/**
* 清除指定缓存
* @param {string} key - 缓存键名
*/
export const clearCache = (key) => {
try {
uni.removeStorageSync(CACHE_PREFIX + key)
} catch (e) {
console.error('清除缓存失败:', e)
}
}
/**
* 清除所有API缓存
*/
export const clearAllCache = () => {
try {
const keys = uni.getStorageInfoSync().keys || []
for (const key of keys) {
if (key.startsWith(CACHE_PREFIX)) {
uni.removeStorageSync(key)
}
}
} catch (e) {
console.error('清除所有缓存失败:', e)
}
}
/**
* 获取token
* @returns {string|null} token
*/
export const getToken = () => {
try {
return uni.getStorageSync('token') || null
} catch (e) {
console.error('获取token失败:', e)
return null
}
}
/**
* 设置token
* @param {string} token - token值
*/
export const setToken = (token) => {
try {
uni.setStorageSync('token', token)
} catch (e) {
console.error('设置token失败:', e)
}
}
/**
* 清除token
*/
export const clearToken = () => {
try {
uni.removeStorageSync('token')
} catch (e) {
console.error('清除token失败:', e)
}
}
/**
* 生成请求缓存键名
* @param {string} url - 请求URL
* @param {object} data - 请求参数
* @param {string} method - 请求方法
* @returns {string} 缓存键名
*/
const generateCacheKey = (url, data, method) => {
const params = JSON.stringify(data || {})
return `${method}_${url}_${params}`
}
/**
* 通用请求函数
* @param {object} options - 请求配置
* @param {string} options.url - 请求URL
* @param {string} [options.method='GET'] - 请求方法
* @param {object} [options.data={}] - 请求参数
* @param {object} [options.header={}] - 请求头
* @param {boolean} [options.cache=false] - 是否启用缓存
* @param {number} [options.cacheTime] - 缓存时间(毫秒)
* @param {boolean} [options.needToken=true] - 是否需要token
* @returns {Promise} 请求Promise
*/
export const request = (options) => {
return new Promise((resolve, reject) => {
const {
url,
method = 'GET',
data = {},
header = {},
cache = false,
cacheTime,
needToken = true
} = options
// 生成缓存键名
const cacheKey = cache ? generateCacheKey(url, data, method) : null
// 如果启用缓存且存在有效缓存,直接返回缓存数据
if (cache && cacheKey) {
const cachedData = getCache(cacheKey)
if (cachedData !== null) {
console.log(`[API] 命中缓存: ${url}`)
resolve(cachedData)
return
}
}
// 构建请求头
const requestHeader = {
'Content-Type': 'application/json',
...header
}
// 如果需要token,自动添加到请求头
if (needToken) {
const token = getToken()
if (token) {
requestHeader['Authorization'] = `Bearer ${token}`
}
}
uni.request({
url: BASE_URL + url,
method: method,
data: data,
header: requestHeader,
success: (res) => {
if (res.statusCode === 200) {
// 如果启用缓存,保存响应数据
if (cache && cacheKey && res.data) {
setCache(cacheKey, res.data, cacheTime)
}
resolve(res.data)
} else if (res.statusCode === 401) {
// token过期,清除token并提示重新登录
clearToken()
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none'
})
reject({ code: 401, message: '登录已过期' })
} else {
reject({ code: res.statusCode, message: res.data?.message || '请求失败' })
}
},
fail: (err) => {
console.error(`[API] 请求失败: ${url}`, err)
reject({ code: -1, message: '网络请求失败', error: err })
}
})
})
}
// 工具函数导出
export const requestUtils = {
getToken,
setToken,
clearToken,
getCache,
setCache,
clearCache,
clearAllCache
}
export default request
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import path from 'path'
// 用来匹配地址,解决跨域问题
export default defineConfig({
plugins: [uni()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
},
},
server: {
proxy: {
// 匹配所有 /api 开头的请求
'/api': {
target: 'http://192.168.43.89:8084', // 你的后端SpringBoot地址
changeOrigin: true, // 开启跨域伪装
// rewrite: (path) => path.replace(/^\/areyouok/, '')
// 举例:前端请求 /api/login → 代理成 http://localhost:8088/login
}
}
}
})