This commit is contained in:
那些花儿
2025-04-25 18:15:00 +08:00
parent ae90c7c33d
commit cb3bcb57bc
14 changed files with 624 additions and 561 deletions
+205 -453
View File
@@ -1,516 +1,268 @@
<template>
<view class="sign-page">
<!-- 背景装饰 -->
<view class="bg-decoration"></view>
<!-- 主内容区 -->
<view class="page-container">
<t-dropdown-menu>
<t-dropdown-item :options="actOptions" :value="selectActId" @change="getSignInfo"/>
</t-dropdown-menu>
<view class="content-container">
<!-- 页面标题 -->
<view class="title-area">
<text class="title">活动签到</text>
<view class="scan-area" @click="handleScan">
<view class="scan-icon-wrapper">
<t-icon name="scan" size="80" color="#f45a5a"></t-icon>
</view>
<t-button shape="round"
theme="primary"
size="medium">扫描二维码签到
</t-button>
</view>
<!-- 活动选择区 -->
<view class="selector-card">
<t-dropdown-menu class="activity-dropdown">
<t-dropdown-item
v-model="selectedActivity"
label="请选择活动"
:options="activityOptions"
@change="handleActivityChange"
/>
</t-dropdown-menu>
</view>
<!-- 签到区域 -->
<view class="sign-card" v-if="currentActivity">
<view class="activity-title">{{ currentActivity.title }}</view>
<view class="qr-scan-area">
<view class="qr-frame">
<view class="corner top-left"></view>
<view class="corner top-right"></view>
<view class="corner bottom-left"></view>
<view class="corner bottom-right"></view>
<t-icon name="qrcode" size="120rpx" class="qr-icon" />
<view class="guide-area">
<view class="guide-title">签到说明</view>
<view class="guide-list">
<view class="guide-item">
<uni-icons class="guide-icon" type="info" size="16" color="#999999"/>
<text class="guide-text">请对准活动二维码进行扫描</text>
</view>
<view class="guide-item">
<uni-icons class="guide-icon" type="info" size="16" color="#999999"/>
<text class="guide-text">确保手机相机权限已开启</text>
</view>
<view class="guide-item">
<uni-icons class="guide-icon" type="info" size="16" color="#999999"/>
<text class="guide-text">保持二维码清晰可见</text>
</view>
</view>
<t-button size="large" class="scan-btn" @tap="handleScan" :disabled="isSignedIn">
{{ isSignedIn ? '已完成签到' : '点击扫码签到' }}
</t-button>
<view class="sign-status" v-if="signStatus !== 'unsigned'">
<t-icon name="check-circle-filled" size="48rpx" class="status-icon success" v-if="signStatus === 'signed'" />
<t-icon name="time" size="48rpx" class="status-icon warning" v-else-if="signStatus === 'late'" />
<text class="status-text">{{ signStatusText }}</text>
<text class="sign-time" v-if="latestSignTime">{{ latestSignTime }}</text>
</view>
</view>
<!-- 无活动状态 -->
<view class="empty-state" v-if="!currentActivity">
<t-icon name="folder" size="120rpx" class="empty-icon" />
<text class="empty-text">暂无可签到的活动</text>
<t-button size="small" variant="outline" class="refresh-btn" @tap="getMyActivities">刷新</t-button>
<view class="current-status-area">
<view class="status-header">
<text class="status-title">当前签到状态</text>
</view>
<view v-if="signInfo" class="status-content">
<template v-if="signInfo.isSignIn">
<view class="sign-success">
<t-icon name="task-checked-1" size="48" color="#07c160"></t-icon>
<text class="success-text">已完成签到</text>
<view class="sign-detail">
<view class="detail-item">
<uni-icons type="calendar" size="14" color="#666666" />
<text>签到时间:{{ signInfo.signInTime }}</text>
</view>
<view class="detail-item">
<uni-icons type="location" size="14" color="#666666" />
<text>签到地点:{{ signInfo.location }}</text>
</view>
</view>
</view>
</template>
<template v-else>
<view class="sign-pending">
<t-icon name="info-circle" size="48" color="#f45a5a"></t-icon>
<text class="pending-text">待签到</text>
<text class="pending-tip">请使用扫码签到完成签到</text>
</view>
</template>
</view>
</view>
</view>
</view>
<ScanQRCode
ref="qrScannerRef"
@scan-success="handleScanSuccess"
/>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRequest } from '@/utils/request';
import { Toast } from 'tdesign-mobile-vue';
import {ref, computed, onMounted} from 'vue';
import {useRequest} from '@/utils/request';
import {Toast} from 'tdesign-mobile-vue';
import moment from 'moment';
import ScanQRCode from '@/components/scanQrCode.vue'
// 模拟活动数据
const mockActivities = [
{
id: '1',
title: '2023年度篮球联赛',
cover: 'https://img01.71360.com/file/read/www/M00/4C/98/rBsAFWQUdqaABB-oAAHYfGPYE3Y799.jpg',
startTime: '2023-12-10 09:00:00',
endTime: '2023-12-15 18:00:00',
address: '市体育中心主场馆',
signStartTime: '2023-11-20 00:00:00',
signEndTime: '2023-12-05 23:59:59',
signedIn: false,
signTime: null
},
{
id: '2',
title: '企业文化交流会',
cover: 'https://img.zcool.cn/community/01a4d75b04a27ea801209e1f0fb01d.jpg@1280w_1l_2o_100sh.jpg',
startTime: '2023-11-25 13:30:00',
endTime: '2023-11-25 17:30:00',
address: '国际会议中心3号厅',
signStartTime: '2023-11-01 00:00:00',
signEndTime: '2023-11-24 23:59:59',
signedIn: true,
signTime: '2023-11-25 13:20:14',
status: 'signed'
},
{
id: '3',
title: '技术创新大赛决赛',
cover: 'https://img.zcool.cn/community/01b1f95b04a255a8012182a7ebfe26.jpg@1280w_1l_2o_100sh.jpg',
startTime: '2023-12-05 09:00:00',
endTime: '2023-12-05 18:00:00',
address: '科技园区创新中心',
signStartTime: '2023-11-10 00:00:00',
signEndTime: '2023-12-04 23:59:59',
signedIn: true,
signTime: '2023-12-05 09:15:22',
status: 'late'
},
{
id: '4',
title: '2023年迎新晚会',
cover: 'https://img.zcool.cn/community/01f2b35bd123b1a801208f8bd8710a.jpg@1280w_1l_2o_100sh.jpg',
startTime: '2023-12-31 19:00:00',
endTime: '2024-01-01 00:30:00',
address: '文化广场中心舞台',
signStartTime: '2023-12-15 00:00:00',
signEndTime: '2023-12-30 23:59:59',
signedIn: false,
signTime: null
}
];
const request = useRequest()
// 判断环境是否为开发环境
const isDev = process.env.NODE_ENV === 'development';
// 活动列表
const myActivities = ref([]);
const activityOptions = computed(() => {
return myActivities.value.map(item => ({
label: item.title,
value: item.id
}));
});
// 选中的活动ID
const selectedActivity = ref('');
const currentActivity = computed(() => {
return myActivities.value.find(item => item.id === selectedActivity.value);
});
// 是否已签到
const isSignedIn = computed(() => {
return currentActivity.value?.signedIn || false;
});
// 最近签到时间
const latestSignTime = computed(() => {
if (!currentActivity.value?.signTime) return null;
return `签到时间: ${moment(currentActivity.value.signTime).format('YYYY-MM-DD HH:mm:ss')}`;
});
// 签到状态
const signStatus = computed(() => {
return currentActivity.value?.status || 'unsigned';
});
const signStatusText = computed(() => {
switch(signStatus.value) {
case 'signed':
return '签到成功';
case 'late':
return '迟到签到';
default:
return '未签到';
}
});
// 获取我的活动
const getMyActivities = async () => {
try {
Toast({ message: '加载中...', theme: 'loading' });
if (isDev) {
// 开发环境使用模拟数据
setTimeout(() => {
myActivities.value = mockActivities;
if (myActivities.value.length > 0) {
selectedActivity.value = myActivities.value[0].id;
}
Toast({ message: '加载成功', theme: 'success' });
}, 800);
return;
const list = ref([])
const signInfo = ref({})
const actOptions = computed(() => {
return list.value.map(item => {
return {
value: item.id,
label: item.title
}
// 生产环境调用接口
const response = await useRequest().get('/client/activity/myActivities');
if (response.code === 0) {
myActivities.value = response.data || [];
if (myActivities.value.length > 0) {
selectedActivity.value = myActivities.value[0].id;
}
Toast({ message: '加载成功', theme: 'success' });
} else {
Toast({ message: '获取活动失败', theme: 'error' });
}
} catch (error) {
console.error('获取活动失败', error);
Toast({ message: '网络异常,请重试', theme: 'error' });
}
};
// 活动切换
const handleActivityChange = (value) => {
// 切换时不需要额外操作
};
// 扫码
const handleScan = () => {
if (!currentActivity.value) {
Toast({ message: '请先选择活动', theme: 'warning' });
return;
}
if (isSignedIn.value) {
Toast({ message: '已完成签到', theme: 'warning' });
return;
}
Toast({ message: '准备扫码', theme: 'loading' });
setTimeout(() => {
if (isDev) {
// 开发环境模拟扫码成功
setTimeout(() => {
Toast({ message: '签到中...', theme: 'loading' });
setTimeout(() => {
const randomSuccess = Math.random() > 0.2;
if (randomSuccess) {
const isLate = Math.random() > 0.7;
const status = isLate ? 'late' : 'signed';
const statusText = isLate ? '迟到签到' : '签到成功';
// 更新活动签到状态
const activity = myActivities.value.find(a => a.id === selectedActivity.value);
if (activity) {
activity.signedIn = true;
activity.signTime = moment().format('YYYY-MM-DD HH:mm:ss');
activity.status = status;
}
Toast({ message: statusText, theme: isLate ? 'warning' : 'success' });
} else {
Toast({ message: '二维码无效', theme: 'error' });
}
}, 1000);
}, 1000);
return;
}
// 生产环境实际扫码
uni.scanCode({
success: async (res) => {
try {
Toast({ message: '签到中...', theme: 'loading' });
// 将扫码结果发送到后端
const response = await useRequest().post('/client/activity/sign', {
activityId: selectedActivity.value,
qrCode: res.result
});
if (response.code === 0) {
// 更新活动签到状态
const activity = myActivities.value.find(a => a.id === selectedActivity.value);
if (activity) {
activity.signedIn = true;
activity.signTime = moment().format('YYYY-MM-DD HH:mm:ss');
activity.status = response.data.status || 'signed';
}
const statusText = activity.status === 'late' ? '迟到签到' : '签到成功';
Toast({ message: statusText, theme: activity.status === 'late' ? 'warning' : 'success' });
} else {
Toast({ message: response.message || '签到失败', theme: 'error' });
}
} catch (error) {
console.error('签到失败', error);
Toast({ message: '网络异常,请重试', theme: 'error' });
}
},
fail: () => {
Toast({ message: '扫码取消', theme: 'warning' });
}
});
}, 500);
};
})
})
const selectActId = ref(null)
const qrScannerRef = ref()
onMounted(() => {
getMyActivities();
listMineAct();
});
// 获取活动列表
async function listMineAct() {
try {
const {data, code} = await request.get('/client/activity/list-mine-act')
if (code === 0) {
list.value = data
if (list.value.length) {
selectActId.value = list.value[0].id
await getSignInfo();
}
} else {
Toast.error(data.msg)
}
} catch (e) {
console.error(e)
Toast.error(e.message)
}
}
// 获取签到信息
async function getSignInfo(val){
try {
const {code, data, msg} = await request.get('/client/activity/sign-info/' + (val ? val :selectActId.value))
if (code === 0) {
signInfo.value = data
} else {
Toast.error(msg)
}
} catch (e) {
console.error(e)
Toast.error(e.message)
}
}
// 扫码签到
function handleScan() {
qrScannerRef.value.startScan()
}
function handleScanSuccess(val){
console.log(val)
Toast.success(val)
}
</script>
<style scoped lang="less">
.sign-page {
min-height: 100vh;
background-color: #f9f9f9;
display: flex;
flex-direction: column;
position: relative;
padding-top: 30rpx;
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 380rpx;
background: linear-gradient(to bottom, rgb(244, 90, 90, 0.8), rgb(244, 90, 90, 0.2));
border-bottom-left-radius: 40rpx;
border-bottom-right-radius: 40rpx;
z-index: 0;
}
.content-container {
position: relative;
z-index: 1;
padding: 0 30rpx;
padding: 15px;
}
.scan-area {
background-color: #ffffff;
border-radius: 16rpx;
padding: 64rpx 0;
margin-bottom: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.title-area {
margin-bottom: 40rpx;
width: 100%;
.scan-icon-wrapper {
width: 160rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
}
.title {
font-size: 48rpx;
font-weight: bold;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.selector-card {
width: 100%;
background-color: #fff;
.guide-area {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
padding: 32rpx;
margin-bottom: 32rpx;
}
.activity-dropdown {
width: 100%;
.guide-title {
font-size: 16px;
font-weight: 500;
color: #333333;
margin-bottom: 24rpx;
}
:deep(.t-dropdown-menu) {
background-color: transparent !important;
height: 80rpx;
.guide-item {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
:deep(.t-dropdown-menu__item) {
font-size: 30rpx;
color: #333 !important;
.guide-icon {
width: 32rpx;
height: 32rpx;
margin-right: 16rpx;
flex-shrink: 0;
}
:deep(.t-dropdown-item__content) {
border-radius: 12rpx !important;
overflow: hidden;
.guide-text {
font-size: 14px;
color: #666666;
}
.sign-card {
width: 100%;
background-color: #fff;
.current-status-area {
background-color: #ffffff;
border-radius: 16rpx;
padding: 40rpx 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
padding: 32rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.status-header {
margin-bottom: 32rpx;
}
.status-title {
font-size: 16px;
font-weight: 500;
color: #333333;
margin-bottom: 0.75rem;
}
.status-content {
display: flex;
justify-content: center;
padding: 48rpx 0;
}
.sign-success,
.sign-pending {
display: flex;
flex-direction: column;
align-items: center;
}
.activity-title {
font-size: 34rpx;
font-weight: bold;
color: #333;
margin-bottom: 40rpx;
text-align: center;
line-height: 1.4;
}
.qr-scan-area {
margin-bottom: 40rpx;
padding: 20rpx;
}
.qr-frame {
width: 280rpx;
height: 280rpx;
position: relative;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(244, 90, 90, 0.03);
border-radius: 6rpx;
}
.corner {
position: absolute;
width: 40rpx;
height: 40rpx;
border-color: rgb(244, 90, 90);
border-style: solid;
border-width: 0;
}
.top-left {
top: 0;
left: 0;
border-top-width: 6rpx;
border-left-width: 6rpx;
}
.top-right {
top: 0;
right: 0;
border-top-width: 6rpx;
border-right-width: 6rpx;
}
.bottom-left {
bottom: 0;
left: 0;
border-bottom-width: 6rpx;
border-left-width: 6rpx;
}
.bottom-right {
bottom: 0;
right: 0;
border-bottom-width: 6rpx;
border-right-width: 6rpx;
}
.qr-icon {
color: rgb(244, 90, 90);
}
.scan-btn {
width: 70%;
height: 90rpx;
background-color: rgb(244, 90, 90);
color: #fff;
font-size: 32rpx;
font-weight: 500;
border-radius: 45rpx;
box-shadow: 0 8rpx 20rpx rgba(244, 90, 90, 0.3);
transition: all 0.3s;
margin-bottom: 30rpx;
}
.scan-btn:active {
transform: scale(0.97);
box-shadow: 0 4rpx 10rpx rgba(244, 90, 90, 0.2);
}
.scan-btn[disabled] {
background-color: #f0f0f0;
color: #bbbbbb;
box-shadow: none;
}
.sign-status {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 10rpx;
}
.status-icon {
margin-bottom: 12rpx;
}
.status-icon.success {
.success-text {
font-size: 20px;
color: #07c160;
}
.status-icon.warning {
color: #ff9f00;
}
.status-text {
font-size: 30rpx;
font-weight: 500;
color: #333;
margin-bottom: 8rpx;
margin: 24rpx 0 32rpx;
}
.sign-time {
font-size: 26rpx;
color: #999;
.sign-detail {
margin-top: 32rpx;
}
.empty-state {
.detail-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
margin-bottom: 16rpx;
color: #666666;
font-size: 14px;
}
.empty-icon {
color: #ddd;
margin-bottom: 20rpx;
.detail-item uni-icons {
margin-right: 12rpx;
}
.empty-text {
font-size: 30rpx;
color: #999;
margin-bottom: 30rpx;
.pending-text {
font-size: 20px;
color: #f45a5a;
font-weight: 500;
margin: 24px 0 16px;
}
.refresh-btn {
color: rgb(244, 90, 90);
border-color: rgb(244, 90, 90);
.pending-tip {
font-size: 14px;
color: #999999;
}
</style>
</style>