guoyu/fronted_uniapp/pages/exam/list.vue

686 lines
21 KiB
Vue
Raw Normal View History

2025-12-03 18:58:36 +08:00
<template>
<view class="exam-list-container">
<!-- 顶部导航栏 -->
<custom-navbar title="我的考试"></custom-navbar>
<!-- 筛选栏 -->
<view class="filter-section">
<view class="filter-row">
<view class="filter-item" @click="showStatusPicker = true">
<text class="filter-label">状态</text>
<text class="filter-value">{{ selectedStatusName || '全部' }}</text>
<text class="filter-icon"></text>
</view>
</view>
<view class="filter-actions" v-if="hasFilter">
<text class="reset-btn" @click="resetFilter">重置</text>
</view>
</view>
<!-- 状态选择器 -->
<u-picker
:show="showStatusPicker"
:columns="[statusOptions]"
keyName="label"
@confirm="onStatusConfirm"
@cancel="showStatusPicker = false"
></u-picker>
<u-empty v-if="filteredExamList.length === 0 && !loading" mode="data" text="暂无考试"></u-empty>
<view v-else class="exam-list">
<view
v-for="exam in filteredExamList"
:key="exam.id"
class="exam-item"
@click="goToExamDetail(exam)"
>
<view class="exam-header">
<text class="exam-name">{{ exam.examName }}</text>
<text class="exam-status" :class="getStatusClass(exam)">{{ getStatusText(exam) }}</text>
</view>
<view class="exam-info">
<view class="info-row">
<text class="info-label">学科</text>
<text class="info-value">{{ exam.subjectName || '未知' }}</text>
</view>
<view class="info-row">
<text class="info-label">题目数</text>
<text class="info-value">{{ exam.questionCount || 0 }}</text>
</view>
<view class="info-row">
<text class="info-label">时长</text>
<text class="info-value">{{ exam.duration || 0 }}分钟</text>
</view>
<view class="info-row">
<text class="info-label">总分</text>
<text class="info-value">{{ exam.totalScore || 0 }}</text>
</view>
</view>
<view class="exam-time" v-if="exam.publishTime">
<text class="time-label">发布时间</text>
<text class="time-value">{{ formatTime(exam.publishTime) }}</text>
</view>
<view class="exam-actions">
<button
v-if="getExamStatus(exam) === 'active'"
class="btn-start"
@click.stop="startExam(exam)"
>开始考试</button>
<button
v-else-if="getExamStatus(exam) === 'pending'"
class="btn-disabled"
disabled
>未开始</button>
<button
v-else-if="getExamStatus(exam) === 'completed' || getExamStatus(exam) === 'ended'"
class="btn-view"
@click.stop="viewResult(exam)"
>查看结果</button>
</view>
</view>
</view>
<!-- 开始考试确认弹窗 -->
<view class="confirm-modal" v-if="showConfirmModal" @click.stop>
<view class="modal-mask" @click="showConfirmModal = false"></view>
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">开始考试</text>
</view>
<view class="modal-body">
<text class="modal-text">确定要开始考试"{{ currentExam?.examName }}"</text>
<view class="modal-info">
<text class="info-item">考试时长{{ currentExam?.duration || 0 }}分钟</text>
</view>
</view>
<view class="modal-footer">
<button class="btn-cancel" @click="showConfirmModal = false">取消</button>
<button class="btn-confirm" @click="confirmStartExam">开始</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { getMyExams } from '@/api/study/exam.js'
import { getMyScores } from '@/api/study/score.js'
import CustomNavbar from '@/components/custom-navbar/custom-navbar.vue'
export default {
components: {
CustomNavbar
},
data() {
return {
examList: [],
filteredExamList: [],
loading: false,
statusOptions: [
{ value: null, label: '全部' },
{ value: 'active', label: '进行中' },
{ value: 'pending', label: '未开始' },
{ value: 'completed', label: '已完成' },
{ value: 'ended', label: '已结束' }
],
selectedStatus: null,
selectedStatusName: null,
showStatusPicker: false,
showConfirmModal: false,
currentExam: null
}
},
computed: {
hasFilter() {
return this.selectedStatus !== null
}
},
onLoad() {
this.loadExamList()
},
onShow() {
// 每次显示页面时刷新考试列表,确保状态最新
console.log('[考试列表] 页面显示,刷新列表')
this.loadExamList()
},
onPullDownRefresh() {
this.loadExamList().finally(() => {
uni.stopPullDownRefresh()
})
},
methods: {
async loadExamList() {
this.loading = true
try {
// 并行加载考试列表和成绩列表
const [examResponse, scoreResponse] = await Promise.all([
getMyExams(),
getMyScores().catch(() => ({ code: 200, data: [] })) // 如果成绩接口失败,返回空数组
])
if (examResponse.code === 200) {
let examList = examResponse.data || []
const scoreList = (scoreResponse.code === 200 && scoreResponse.data) ? scoreResponse.data : []
// 创建已完成考试的examId集合
const completedExamIds = new Set(scoreList.map(score => score.examId))
// 标记已完成的考试
examList = examList.map(exam => {
if (completedExamIds.has(exam.id)) {
return { ...exam, isCompleted: true, scoreId: scoreList.find(s => s.examId === exam.id)?.id }
}
return exam
})
this.examList = examList
this.applyFilter()
} else {
uni.showToast({
title: examResponse.msg || '加载失败',
icon: 'none'
})
}
} catch (error) {
console.error('加载考试列表失败', error)
uni.showToast({
title: error.message || '加载失败',
icon: 'none'
})
} finally {
this.loading = false
}
},
applyFilter() {
let filtered = [...this.examList]
// 按状态筛选使用计算后的状态而不是原始status字段
if (this.selectedStatus !== null) {
filtered = filtered.filter(exam => {
const examStatus = this.getExamStatus(exam)
return examStatus === this.selectedStatus
})
}
this.filteredExamList = filtered
},
getExamStatus(exam) {
// 优先检查用户是否已完成考试
if (exam.scoreId || exam.isCompleted || exam.hasCompleted || exam.completed || exam.isFinished) {
return 'completed' // 已完成
}
if (!exam.status || exam.status === '0') {
return 'pending' // 未开始
}
if (exam.status === '2') {
return 'ended' // 已结束
}
// status === '1' 已发布,需要检查实际时间
const now = new Date()
if (exam.startTime && new Date(exam.startTime) > now) {
return 'pending' // 未开始
}
// 检查结束时间(支持 deadline 和 endTime 两种字段名)
const endTime = exam.endTime || exam.deadline
if (endTime && new Date(endTime) < now) {
return 'ended' // 已结束
}
return 'active' // 进行中
},
onStatusConfirm(e) {
const selected = e.value[0]
this.selectedStatus = selected.value
this.selectedStatusName = selected.label
this.showStatusPicker = false
this.applyFilter()
},
resetFilter() {
this.selectedStatus = null
this.selectedStatusName = null
this.applyFilter()
},
goToExamDetail(exam) {
const status = this.getExamStatus(exam)
if (status === 'active') {
this.startExam(exam)
} else if (status === 'completed' || status === 'ended') {
this.viewResult(exam)
} else {
uni.showToast({
title: '考试尚未开始',
icon: 'none'
})
}
},
startExam(exam) {
this.currentExam = exam
this.showConfirmModal = true
},
confirmStartExam() {
if (this.currentExam) {
this.showConfirmModal = false
uni.navigateTo({
url: `/pages/exam/detail?id=${this.currentExam.id}`
})
this.currentExam = null
}
},
viewResult(exam) {
// 跳转到成绩列表,筛选该考试的成绩
uni.navigateTo({
url: `/pages/score/list?examId=${exam.id}`
})
},
getStatusText(exam) {
const status = this.getExamStatus(exam)
const statusMap = {
'active': '进行中',
'pending': '未开始',
'ended': '已结束',
'completed': '已完成'
}
return statusMap[status] || '未知'
},
getStatusClass(exam) {
const status = this.getExamStatus(exam)
return `status-${status}`
},
formatTime(timeStr) {
if (!timeStr) return ''
const date = new Date(timeStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
}
}
</script>
<style lang="scss" scoped>
.exam-list-container {
padding: 30rpx;
padding-top: 0;
background-color: #f5f7fa;
min-height: 100vh;
// custom-navbar 是 fixed 定位,需要为它留出空间
// 导航栏总高度 = 状态栏高度 + 88rpx (移动端) 或 80rpx (桌面端)
// 使用 var(--status-bar-height) 与课程列表页面保持一致
padding-top: calc(var(--status-bar-height) + 88rpx + 20rpx);
@media (min-width: 768px) {
padding: 60rpx;
padding-top: calc(var(--status-bar-height) + 80rpx + 40rpx);
}
}
.filter-section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
@media (min-width: 768px) {
padding: 40rpx;
border-radius: 24rpx;
margin-bottom: 40rpx;
}
.filter-row {
display: flex;
gap: 20rpx;
.filter-item {
flex: 1;
display: flex;
align-items: center;
padding: 20rpx 24rpx;
background: #f5f7fa;
border-radius: 12rpx;
transition: background-color 0.2s;
&:active {
background: #e8eef5;
}
.filter-label {
font-size: 28rpx;
color: #666;
margin-right: 12rpx;
font-weight: 500;
}
.filter-value {
flex: 1;
font-size: 28rpx;
color: #1a1a1a;
font-weight: 500;
}
.filter-icon {
font-size: 20rpx;
color: #999;
margin-left: 12rpx;
}
}
}
.filter-actions {
margin-top: 24rpx;
text-align: right;
.reset-btn {
font-size: 28rpx;
color: rgb(55 140 224);
padding: 12rpx 24rpx;
font-weight: 500;
}
}
}
.exam-list {
@media (min-width: 768px) {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 30rpx;
}
.exam-item {
background: #fff;
border-radius: 20rpx;
padding: 36rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
transition: transform 0.2s, box-shadow 0.2s;
@media (min-width: 768px) {
padding: 48rpx;
margin-bottom: 0;
border-radius: 24rpx;
}
&:active {
transform: scale(0.98);
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
}
.exam-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24rpx;
.exam-name {
font-size: 34rpx;
font-weight: bold;
color: #1a1a1a;
flex: 1;
line-height: 1.4;
}
.exam-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-weight: 500;
margin-left: 16rpx;
flex-shrink: 0;
&.status-active {
background: #e6f7ff;
color: rgb(55 140 224);
}
&.status-pending {
background: #fff7e6;
color: #fa8c16;
}
&.status-ended {
background: #f5f5f5;
color: #999;
}
&.status-completed {
background: #f6ffed;
color: #52c41a;
}
}
}
.exam-info {
margin-bottom: 24rpx;
.info-row {
display: flex;
align-items: center;
margin-bottom: 16rpx;
font-size: 28rpx;
.info-label {
color: #999;
width: 140rpx;
}
.info-value {
color: #1a1a1a;
font-weight: 500;
}
}
}
.exam-time {
padding-top: 24rpx;
border-top: 1rpx solid #f0f0f0;
margin-bottom: 24rpx;
font-size: 26rpx;
color: #999;
.time-label {
margin-right: 8rpx;
}
}
.exam-actions {
margin-top: 24rpx;
.btn-start {
width: 100%;
background: linear-gradient(135deg, rgb(55 140 224) 0%, rgb(45 120 200) 100%);
color: #fff;
border: none;
border-radius: 12rpx;
padding: 24rpx;
font-size: 30rpx;
font-weight: 500;
box-shadow: 0 4rpx 16rpx rgba(55, 140, 224, 0.3);
}
.btn-disabled {
width: 100%;
background: #f5f5f5;
color: #999;
border: none;
border-radius: 12rpx;
padding: 24rpx;
font-size: 30rpx;
}
.btn-view {
width: 100%;
background: #e6f7ff;
color: rgb(55 140 224);
border: none;
border-radius: 12rpx;
padding: 24rpx;
font-size: 30rpx;
font-weight: 500;
}
}
}
}
// 确认弹窗样式
.confirm-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2000;
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4rpx);
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600rpx;
max-width: 90%;
background: #fff;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
@media (min-width: 768px) {
width: 500px;
border-radius: 20px;
}
.modal-header {
padding: 40rpx 30rpx 30rpx;
text-align: center;
border-bottom: 1rpx solid #f0f0f0;
@media (min-width: 768px) {
padding: 36px 30px 24px;
}
.modal-title {
font-size: 36rpx;
font-weight: bold;
color: #1a1a1a;
@media (min-width: 768px) {
font-size: 32px;
}
}
}
.modal-body {
padding: 40rpx 30rpx;
@media (min-width: 768px) {
padding: 36px 30px;
}
.modal-text {
display: block;
font-size: 30rpx;
color: #333;
margin-bottom: 24rpx;
text-align: center;
line-height: 1.6;
@media (min-width: 768px) {
font-size: 28px;
margin-bottom: 20px;
}
}
.modal-info {
background: #f8f9fa;
border-radius: 12rpx;
padding: 24rpx;
text-align: center;
@media (min-width: 768px) {
border-radius: 10px;
padding: 20px;
}
.info-item {
font-size: 28rpx;
color: #666;
line-height: 1.5;
@media (min-width: 768px) {
font-size: 26px;
}
}
}
}
.modal-footer {
display: flex;
border-top: 1rpx solid #f0f0f0;
.btn-cancel,
.btn-confirm {
flex: 1;
border: none;
padding: 28rpx;
font-size: 30rpx;
font-weight: 500;
background: transparent;
transition: background-color 0.2s;
@media (min-width: 768px) {
padding: 24px;
font-size: 28px;
}
}
.btn-cancel {
color: #666;
border-right: 1rpx solid #f0f0f0;
&:active {
background: #f5f5f5;
}
}
.btn-confirm {
color: rgb(55 140 224);
font-weight: 600;
&:active {
background: rgba(55, 140, 224, 0.1);
}
}
}
}
}
</style>