guoyu/fronted_uniapp/pages/exam/index.vue

1044 lines
32 KiB
Vue
Raw Normal View History

2025-12-03 18:58:36 +08:00
<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">
<u-icon name="clock" color="#999" size="28"></u-icon>
<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;
.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>