guoyu/fronted_uniapp/pages/exam/list.vue
2025-12-03 18:58:36 +08:00

686 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>