新增搜索课程和加载组件页面,签到页面添加遮罩防重复扫码,添加 request 便捷方法(get/post/put/delete)
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<!-- components/LoadingOverlay.vue -->
|
||||
<template>
|
||||
<view v-if="visible" class="loading-overlay">
|
||||
<view class="loading-content">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">{{ text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
text: { type: String, default: '加载中...' },
|
||||
delay: { type: Number, default: 200 }
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
let timer = null
|
||||
|
||||
onMounted(() => {
|
||||
timer = setTimeout(() => {
|
||||
visible.value = true
|
||||
}, props.delay)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx 48rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -104,7 +104,7 @@ import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
// 引入状态组件(路径与你保持一致)
|
||||
import QrStatus from '@/components/QRCode/StatusCard.vue'
|
||||
// 引入API封装
|
||||
import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
|
||||
|
||||
let image = ref("")
|
||||
let width = ref(0)
|
||||
@@ -115,6 +115,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
const QRStatus = ref("生成中...")
|
||||
const STQRC = ref(false)//是否扫码
|
||||
const isCheckIn = ref(false)
|
||||
const webSoketURL = "ws://localhost:8084/webSocket/checkIn"
|
||||
|
||||
const qrcode = ref("")
|
||||
|
||||
@@ -209,22 +210,31 @@ import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有QR_开头的缓存(用于测试阶段)
|
||||
* 清除所有与签到相关的缓存(用于测试阶段)
|
||||
*/
|
||||
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} 个QR_开头的缓存`)
|
||||
console.log(`已清除 ${clearedCount} 个签到相关缓存`)
|
||||
return clearedCount
|
||||
} catch (e) {
|
||||
console.error('清除QR缓存失败:', e)
|
||||
console.error('清除 QR 缓存失败:', e)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -256,10 +266,8 @@ import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
duration: 2000
|
||||
})
|
||||
|
||||
// 重新请求二维码
|
||||
setTimeout(() => {
|
||||
getStorage(null)
|
||||
}, 500)
|
||||
// 重置页面状态后不再自动请求二维码
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -319,7 +327,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
errorText.value = '' // 重置错误文本
|
||||
image.value = ""
|
||||
|
||||
getQRCode(true).then(res => {
|
||||
getQRCode({ cache: true, cacheTime: 5 * 60 * 1000 }).then(res => {
|
||||
console.log(res)
|
||||
// 保存到本地缓存(用于签到状态判断)
|
||||
setCacheData("QRInfo", res)
|
||||
@@ -403,7 +411,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
// 手动签到接口
|
||||
const checkIn = (qrContent) => {
|
||||
console.log(qrContent)
|
||||
apiCheckIn(qrContent).then(res => {
|
||||
apiCheckIn({ qrContent }).then(res => {
|
||||
closeWebSocket()
|
||||
console.log(res)
|
||||
status.value = 'scanned'
|
||||
@@ -422,17 +430,20 @@ import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
console.error('签到请求失败:', err)
|
||||
status.value = 'error'
|
||||
errorText.value = err.message || '签到失败,请重试' // 对应错误文案
|
||||
uni.showToast({
|
||||
title: err.message,
|
||||
icon: 'none'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 建立WebSocket连接
|
||||
const connectWebSocket = (qrContent) => {
|
||||
const wsUrl = `ws://192.168.43.89:8084/webSocket/checkIn`
|
||||
|
||||
console.log('WebSocket 连接地址:', wsUrl)
|
||||
console.log('WebSocket 连接地址:', webSoketURL)
|
||||
|
||||
socketTask = uni.connectSocket({
|
||||
url: wsUrl,
|
||||
url: webSoketURL,
|
||||
success: () => {
|
||||
console.log('WebSocket 连接中...')
|
||||
},
|
||||
@@ -477,17 +488,27 @@ import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
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 === '签到成功' || 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,
|
||||
@@ -495,18 +516,32 @@ import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
duration: 2000
|
||||
})
|
||||
status.value = 'error'
|
||||
errorText.value = '二维码无效,请刷新' // 对应错误文案
|
||||
errorText.value = '二维码无效,请刷新'
|
||||
// 隐藏遮罩,允许用户重新操作
|
||||
STQRC.value = false
|
||||
QRStatus.value = "生成中..."
|
||||
setTimeout(() => {
|
||||
closeWebSocket()
|
||||
}, 3000)
|
||||
} else if (message === '消息格式错误') {
|
||||
uni.showToast({
|
||||
title: '消息格式错误',
|
||||
icon: 'none'
|
||||
})
|
||||
status.value = 'error'
|
||||
errorText.value = '消息格式错误' // 对应错误文案
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -523,10 +558,6 @@ import { getQRCode, checkIn as apiCheckIn } from '@/request_api/main.js'
|
||||
console.error('WebSocket 错误:', err)
|
||||
status.value = 'error'
|
||||
errorText.value = '连接失败,请重试' // 对应错误文案
|
||||
uni.showToast({
|
||||
title: '连接失败',
|
||||
icon: 'none'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- pages/course/index.vue -->
|
||||
<template>
|
||||
<view class="tab-page">
|
||||
<view class="tab-page__header">
|
||||
@@ -5,27 +6,114 @@
|
||||
<text class="tab-page__subtitle">精品团课 · 私教 · 线上课</text>
|
||||
</view>
|
||||
|
||||
<RecommendCourses />
|
||||
|
||||
<view class="tab-page__actions">
|
||||
<view class="tab-page__btn" hover-class="tab-page__btn--hover" @tap="goCourseList">
|
||||
<text class="tab-page__btn-text">预约课程</text>
|
||||
</view>
|
||||
<view class="tab-page__btn tab-page__btn--ghost" hover-class="tab-page__btn--hover" @tap="goMyCourses">
|
||||
<text class="tab-page__btn-text tab-page__btn-text--ghost">我的课程</text>
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loading" class="skeleton-container">
|
||||
<view class="skeleton-item" v-for="i in 3" :key="i">
|
||||
<view class="skeleton-img"></view>
|
||||
<view class="skeleton-text"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 真实内容 -->
|
||||
<template v-else>
|
||||
<RecommendCourses :data="courseData" />
|
||||
|
||||
<view class="tab-page__actions">
|
||||
<view class="tab-page__btn" hover-class="tab-page__btn--hover" @tap="goCourseList">
|
||||
<text class="tab-page__btn-text">预约课程</text>
|
||||
</view>
|
||||
<view class="tab-page__btn tab-page__btn--ghost" hover-class="tab-page__btn--hover" @tap="goMyCourses">
|
||||
<text class="tab-page__btn-text tab-page__btn-text--ghost">我的课程</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="bottom-placeholder"></view>
|
||||
<TabBar :active="1" />
|
||||
<TabBar @update:active="handleTabActive" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import RecommendCourses from '@/components/index/RecommendCourses.vue'
|
||||
import TabBar from '@/components/TabBar.vue'
|
||||
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
|
||||
|
||||
const loading = ref(true)
|
||||
const courseData = ref(null)
|
||||
|
||||
// 从缓存加载数据
|
||||
function loadFromCache() {
|
||||
try {
|
||||
const cached = uni.getStorageSync('course_cache')
|
||||
if (cached && Date.now() - cached.time < 5 * 60 * 1000) {
|
||||
courseData.value = cached.data
|
||||
loading.value = false
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('读取缓存失败', e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 从网络加载数据
|
||||
async function loadFromNetwork() {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 模拟 API 请求
|
||||
const res = await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ code: 0, data: { list: [] } })
|
||||
}, 500)
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
courseData.value = res.data
|
||||
// 更新缓存
|
||||
uni.setStorageSync('course_cache', {
|
||||
data: res.data,
|
||||
time: Date.now()
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载失败', err)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 页面激活时刷新数据(可选)
|
||||
function handleTabActive(index) {
|
||||
// Tab 切换时后台刷新数据
|
||||
if (index === 1 && !loading.value) {
|
||||
loadFromNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
// 优先显示缓存
|
||||
const hasCache = loadFromCache()
|
||||
if (!hasCache) {
|
||||
loadFromNetwork()
|
||||
} else {
|
||||
// 后台静默更新
|
||||
setTimeout(() => {
|
||||
loadFromNetwork()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
// 每次显示时确保加载完成
|
||||
if (loading.value && !courseData.value) {
|
||||
loadFromNetwork()
|
||||
}
|
||||
})
|
||||
|
||||
function goCourseList() {
|
||||
navigateToPage(PAGE.COURSE_LIST)
|
||||
}
|
||||
@@ -94,4 +182,39 @@ function goMyCourses() {
|
||||
.bottom-placeholder {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
/* 骨架屏样式 */
|
||||
.skeleton-container {
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-img {
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 32rpx;
|
||||
margin-top: 16rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -15,8 +15,8 @@
|
||||
<!-- 底部占位 -->
|
||||
<view class="bottom-placeholder"></view>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<TabBar :active="0" />
|
||||
<!-- TabBar -->
|
||||
<TabBar />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -38,4 +38,5 @@ import TabBar from '@/components/TabBar.vue'
|
||||
.bottom-placeholder {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user