686 lines
21 KiB
Vue
686 lines
21 KiB
Vue
<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>
|
||
|