guoyu/fronted_uniapp/pages/exam/index.vue

1049 lines
32 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-index-container">
<!-- 顶部header -->
<view class="top-header">
<view class="header-left">
<text class="app-title">考核</text>
</view>
<view class="header-right">
<view class="avatar-wrapper" @click="goToProfile">
<image
v-if="userInfo.avatar"
:src="userInfo.avatar"
class="avatar"
mode="aspectFill"
/>
<view v-else class="avatar-placeholder">
<text class="avatar-text">{{ (userInfo.realName || userInfo.username || 'U').charAt(0) }}</text>
</view>
</view>
</view>
</view>
<!-- 语音评测入口 -->
<view class="voice-evaluation-entry" v-if="isStudent" style="margin-top: 0;">
<view class="entry-card" @click="goToVoiceEvaluation">
<view class="entry-icon-wrapper">
<image src="/static/icon/voice.png" class="entry-icon" mode="aspectFit"></image>
</view>
<view class="entry-content">
<text class="entry-title">语音评测</text>
<text class="entry-desc">语音练习与评测</text>
</view>
<text class="entry-arrow"></text>
</view>
</view>
<!-- 筛选栏 -->
<view class="filter-section">
<view class="filter-row">
<picker
mode="selector"
:range="statusOptions"
range-key="label"
:value="selectedStatusIndex"
@change="onStatusChange"
>
<view class="filter-item">
<text class="filter-label">状态:</text>
<text class="filter-value">{{ selectedStatusName || '全部' }}</text>
<text class="filter-icon">▼</text>
</view>
</picker>
</view>
<view class="filter-actions" v-if="hasFilter">
<text class="reset-btn" @click="resetFilter">重置</text>
</view>
</view>
<!-- 考核列表 -->
<view v-if="filteredExamList.length === 0" class="empty-wrapper">
<view class="empty-illustration">
<text class="empty-icon">📝</text>
</view>
<text class="empty-text">暂无考核</text>
</view>
<view v-else class="exam-list">
<view
v-for="exam in filteredExamList"
:key="exam.id"
class="exam-item"
>
<view class="exam-border" :class="getExamBorderClass(exam)"></view>
<view class="exam-content">
<view class="exam-header">
<text class="exam-name">{{ exam.examName }}</text>
<view class="exam-status-badge" :class="getStatusClass(exam)">
<text class="status-text">{{ getStatusText(exam) }}</text>
</view>
</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.deadline || exam.endTime">
<text class="time-label">截止:</text>
<text class="time-value">{{ formatDate(exam.deadline || exam.endTime) }}</text>
<view class="time-remaining">
<text class="clock-icon">🕒</text>
<text class="remaining-text">{{ getRemainingTime(exam) }}</text>
</view>
</view>
<view class="exam-actions">
<view
v-if="getExamStatus(exam) === 'active'"
class="btn-start"
@click.stop="startExam(exam)"
>
开始考试
</view>
<view
v-else-if="getExamStatus(exam) === 'pending'"
class="btn-disabled"
>
未开始
</view>
<view
v-else-if="getExamStatus(exam) === 'completed' || getExamStatus(exam) === 'ended'"
class="btn-view"
@click.stop="viewResult(exam)"
>
查看结果
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { mapGetters } from 'vuex'
import { getMyExams } from '@/api/study/exam.js'
import { getMyScores } from '@/api/study/score.js'
export default {
computed: {
...mapGetters('auth', ['userInfo', 'userRole']),
isStudent() {
return this.userRole === 'student'
},
hasFilter() {
return this.selectedStatus !== null
}
},
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,
selectedStatusIndex: 0,
hasLoadedOnce: false
}
},
onLoad() {
this.loadExamList({ showSkeleton: true })
},
onShow() {
// 每次显示时刷新列表
if (this.hasLoadedOnce) {
this.loadExamList()
}
// 通知底部导航栏更新
uni.$emit('tabbar-update')
},
onPullDownRefresh() {
this.loadExamList({ showSkeleton: false }).finally(() => {
uni.stopPullDownRefresh()
})
},
methods: {
async loadExamList({ showSkeleton = false } = {}) {
const shouldShowSkeleton = showSkeleton || !this.hasLoadedOnce
if (shouldShowSkeleton) {
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
} else {
uni.showToast({
title: examResponse.msg || '加载失败',
icon: 'none'
})
}
this.applyFilter()
} catch (error) {
console.error('加载考试列表失败', error)
uni.showToast({
title: error.message || '加载失败',
icon: 'none'
})
} finally {
if (shouldShowSkeleton) {
this.loading = false
}
this.hasLoadedOnce = true
}
},
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'
}
if (exam.endTime && new Date(exam.endTime) < now) {
return 'ended'
}
return 'active' // 进行中
},
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}`
},
getExamBorderClass(exam) {
const status = this.getExamStatus(exam)
if (status === 'pending') {
return 'border-blue'
}
if (status === 'active') {
const days = this.getRemainingDays(exam)
if (days <= 1) {
return 'border-orange'
}
return 'border-purple'
}
if (status === 'ended') {
return 'border-green'
}
if (status === 'completed') {
return 'border-green'
}
return 'border-blue'
},
getRemainingTime(exam) {
if (!exam.deadline && !exam.endTime) {
return '暂无截止时间'
}
const deadline = exam.deadline || exam.endTime
const now = new Date()
const endTime = new Date(deadline)
const diff = endTime - now
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (days < 0) {
return '已过期'
}
if (days === 0) {
return '今天截止'
}
if (days === 1) {
return '剩余1天'
}
return `剩余${days}`
},
getRemainingDays(exam) {
if (!exam.deadline && !exam.endTime) {
return 999
}
const deadline = exam.deadline || exam.endTime
const now = new Date()
const endTime = new Date(deadline)
const diff = endTime - now
return Math.ceil(diff / (1000 * 60 * 60 * 24))
},
formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
},
onStatusChange(e) {
const index = e.detail.value
const selected = this.statusOptions[index]
this.selectedStatusIndex = index
this.selectedStatus = selected.value
this.selectedStatusName = selected.value ? selected.label : null
this.applyFilter()
},
resetFilter() {
this.selectedStatus = null
this.selectedStatusName = null
this.selectedStatusIndex = 0
this.applyFilter()
},
startExam(exam) {
uni.showModal({
title: '开始考试',
content: `确定要开始考试"${exam.examName}"吗?\n\n考试时长${exam.duration}分钟`,
confirmText: '开始',
cancelText: '取消',
confirmColor: '#378CE0',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: `/pages/exam/detail?id=${exam.id}`
})
}
}
})
},
viewResult(exam) {
uni.navigateTo({
url: `/pages/exam/result?examId=${exam.id}`
})
},
goToProfile() {
uni.switchTab({
url: '/pages/profile/profile'
})
},
goToVoiceEvaluation() {
uni.showModal({
title: '进入语音评测',
content: '确定要进入语音评测吗?\n\n可以进行语音练习与评测',
confirmText: '确定',
cancelText: '取消',
confirmColor: '#378CE0',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: '/pages/speech/speech'
})
}
}
})
}
}
}
</script>
<style lang="scss" scoped>
.exam-index-container {
padding: 0;
background-color: #f5f7fa;
min-height: 100vh;
padding-bottom: 120rpx; // 为底部导航栏留出空间
@media (min-width: 768px) {
padding-bottom: 140rpx;
}
}
// 顶部header
.top-header {
background: linear-gradient(135deg, rgb(55 140 224) 0%, rgb(45 120 200) 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 30rpx;
padding-top: calc(var(--status-bar-height) + 20rpx);
@media (min-width: 768px) {
padding: 20rpx 40rpx;
padding-top: calc(var(--status-bar-height) + 20rpx);
}
.header-left {
flex: 1;
.app-title {
font-size: 36rpx;
font-weight: bold;
color: #fff;
@media (min-width: 768px) {
font-size: 32rpx;
}
}
}
.header-right {
display: flex;
align-items: center;
.avatar-wrapper {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
overflow: hidden;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
@media (min-width: 768px) {
width: 56rpx;
height: 56rpx;
}
.avatar {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.3);
.avatar-text {
font-size: 28rpx;
color: #fff;
font-weight: bold;
@media (min-width: 768px) {
font-size: 24rpx;
}
}
}
}
}
}
// 语音评测入口
.voice-evaluation-entry {
padding: 20rpx 30rpx;
@media (min-width: 768px) {
padding: 24rpx 40rpx;
}
.entry-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 30rpx;
display: flex;
align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
@media (min-width: 768px) {
border-radius: 24rpx;
padding: 36rpx 40rpx;
}
&:active {
background-color: #f8f9fa;
transform: translateX(4rpx);
}
.entry-icon-wrapper {
width: 72rpx;
height: 72rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
transition: transform 0.3s ease;
@media (min-width: 768px) {
width: 88rpx;
height: 88rpx;
border-radius: 20rpx;
margin-right: 32rpx;
}
.entry-icon {
width: 100%;
height: 100%;
}
}
.entry-content {
flex: 1;
display: flex;
flex-direction: column;
.entry-title {
font-size: 30rpx;
font-weight: 500;
color: #1a1a1a;
margin-bottom: 6rpx;
transition: color 0.3s;
@media (min-width: 768px) {
font-size: 32rpx;
margin-bottom: 8rpx;
}
}
.entry-desc {
font-size: 24rpx;
color: #999;
@media (min-width: 768px) {
font-size: 26rpx;
}
}
}
.entry-arrow {
font-size: 32rpx;
color: #d9d9d9;
margin-left: 16rpx;
flex-shrink: 0;
transition: transform 0.3s ease, color 0.3s;
@media (min-width: 768px) {
font-size: 36rpx;
margin-left: 24rpx;
}
}
&:active {
.entry-icon-wrapper {
transform: scale(0.95);
}
.entry-title {
color: rgb(55 140 224);
}
.entry-arrow {
transform: translateX(4rpx);
color: rgb(55 140 224);
}
}
}
}
.filter-section {
background: #fff;
padding: 20rpx 30rpx;
margin-bottom: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
@media (min-width: 768px) {
padding: 20rpx 40rpx;
margin-bottom: 20rpx;
}
.filter-row {
display: flex;
gap: 20rpx;
.filter-item {
flex: 0 0 calc((100% - 20rpx) / 2);
display: flex;
align-items: center;
padding: 24rpx 28rpx;
background: #f5f7fa;
border-radius: 16rpx;
transition: background-color 0.2s;
min-height: 88rpx;
@media (min-width: 768px) {
flex: 0 0 calc((100% - 30rpx) / 2);
padding: 28rpx 36rpx;
border-radius: 20rpx;
min-height: 96rpx;
}
&:active {
background: #e8eef5;
}
.filter-label {
font-size: 30rpx;
color: #666;
margin-right: 16rpx;
font-weight: 500;
@media (min-width: 768px) {
font-size: 32rpx;
margin-right: 20rpx;
}
}
.filter-value {
flex: 1;
font-size: 30rpx;
color: #1a1a1a;
font-weight: 500;
@media (min-width: 768px) {
font-size: 32rpx;
}
}
.filter-icon {
font-size: 24rpx;
color: #999;
margin-left: 16rpx;
@media (min-width: 768px) {
font-size: 26rpx;
margin-left: 20rpx;
}
}
}
}
.filter-actions {
margin-top: 24rpx;
text-align: right;
@media (min-width: 768px) {
margin-top: 20rpx;
}
.reset-btn {
font-size: 28rpx;
color: rgb(55 140 224);
padding: 12rpx 24rpx;
font-weight: 500;
@media (min-width: 768px) {
font-size: 26rpx;
}
}
}
}
.empty-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 150rpx 0;
@media (min-width: 768px) {
padding: 200rpx 0;
}
.empty-illustration {
margin-bottom: 40rpx;
@media (min-width: 768px) {
margin-bottom: 30rpx;
}
.empty-icon {
font-size: 120rpx;
opacity: 0.3;
@media (min-width: 768px) {
font-size: 100rpx;
}
}
}
.empty-text {
font-size: 28rpx;
color: #999;
@media (min-width: 768px) {
font-size: 26rpx;
}
}
}
.exam-list {
padding: 0 30rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
@media (min-width: 768px) {
padding: 0 40rpx;
gap: 24rpx;
}
.exam-item {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
position: relative;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
@media (min-width: 768px) {
border-radius: 18rpx;
padding: 24rpx;
}
.exam-border {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
@media (min-width: 768px) {
width: 8rpx;
}
&.border-blue {
background: rgb(55 140 224);
}
&.border-purple {
background: rgb(55 140 224);
}
&.border-orange {
background: rgb(55 140 224);
}
&.border-green {
background: rgb(55 140 224);
}
}
.exam-content {
flex: 1;
margin-left: 20rpx;
min-width: 0;
overflow: hidden;
@media (min-width: 768px) {
margin-left: 24rpx;
}
.exam-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
@media (min-width: 768px) {
margin-bottom: 20rpx;
}
.exam-name {
font-size: 30rpx;
font-weight: bold;
color: #1a1a1a;
flex: 1;
@media (min-width: 768px) {
font-size: 30rpx;
}
}
.exam-status-badge {
padding: 8rpx 20rpx;
border-radius: 50rpx;
font-size: 22rpx;
@media (min-width: 768px) {
padding: 8rpx 20rpx;
font-size: 22rpx;
}
.status-text {
font-size: 22rpx;
font-weight: 500;
@media (min-width: 768px) {
font-size: 22rpx;
}
}
&.status-pending {
background: #fff7e6;
.status-text {
color: #fa8c16;
}
}
&.status-active {
background: #e6f7ff;
.status-text {
color: rgb(55 140 224);
}
}
&.status-ended {
background: #f5f5f5;
.status-text {
color: #999;
}
}
&.status-completed {
background: #f6ffed;
.status-text {
color: #52c41a;
}
}
}
}
.exam-info {
margin-bottom: 16rpx;
@media (min-width: 768px) {
margin-bottom: 20rpx;
}
.info-row {
display: flex;
align-items: center;
margin-bottom: 12rpx;
font-size: 24rpx;
@media (min-width: 768px) {
font-size: 24rpx;
margin-bottom: 10rpx;
}
.info-label {
color: #666;
width: 120rpx;
@media (min-width: 768px) {
width: 100rpx;
}
}
.info-value {
color: #1a1a1a;
font-weight: 500;
}
}
}
.exam-time {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
margin-bottom: 16rpx;
@media (min-width: 768px) {
padding-top: 14rpx;
margin-bottom: 14rpx;
}
.time-label {
font-size: 24rpx;
color: #666;
margin-right: 8rpx;
@media (min-width: 768px) {
font-size: 24rpx;
}
}
.time-value {
font-size: 24rpx;
color: #666;
flex: 1;
@media (min-width: 768px) {
font-size: 24rpx;
}
}
.time-remaining {
display: flex;
align-items: center;
gap: 8rpx;
.clock-icon {
font-size: 28rpx;
line-height: 1;
}
.remaining-text {
font-size: 24rpx;
color: #999;
@media (min-width: 768px) {
font-size: 24rpx;
}
}
}
}
.exam-actions {
margin-top: 0;
width: 100%;
box-sizing: border-box;
.btn-start {
width: 100%;
max-width: 100%;
background: rgb(55 140 224);
color: #fff;
border-radius: 12rpx;
padding: 20rpx 32rpx;
font-size: 28rpx;
font-weight: 500;
text-align: center;
transition: all 0.3s;
box-shadow: 0 2rpx 8rpx rgba(55, 140, 224, 0.2);
box-sizing: border-box;
@media (min-width: 768px) {
padding: 20rpx 32rpx;
font-size: 28rpx;
}
&:active {
background: rgb(45 120 200);
transform: scale(0.98);
box-shadow: 0 1rpx 4rpx rgba(55, 140, 224, 0.3);
}
}
.btn-disabled {
width: 100%;
max-width: 100%;
background: #f5f5f5;
color: #999;
border-radius: 12rpx;
padding: 20rpx 32rpx;
font-size: 28rpx;
text-align: center;
box-sizing: border-box;
@media (min-width: 768px) {
padding: 20rpx 32rpx;
font-size: 28rpx;
}
}
.btn-view {
width: 100%;
max-width: 100%;
background: rgba(55, 140, 224, 0.1);
color: rgb(55 140 224);
border-radius: 12rpx;
padding: 20rpx 32rpx;
font-size: 28rpx;
font-weight: 500;
text-align: center;
transition: all 0.3s;
box-sizing: border-box;
@media (min-width: 768px) {
padding: 20rpx 32rpx;
font-size: 28rpx;
}
&:active {
background: rgba(55, 140, 224, 0.15);
transform: scale(0.98);
}
}
}
}
}
}
</style>