Files
012-kaopeilian/frontend/src/views/trainee/score-query.vue
111 998211c483 feat: 初始化考培练系统项目
- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
2026-01-24 19:33:28 +08:00

791 lines
20 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>
<div class="score-query-container">
<div class="page-header">
<h1 class="page-title">查分中心</h1>
<div class="header-actions">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
<el-button type="primary" @click="refreshData">
<el-icon class="el-icon--left"><Refresh /></el-icon>
刷新数据
</el-button>
</div>
</div>
<!-- 成绩概览 -->
<div class="score-overview">
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(102, 126, 234, 0.1)">
<el-icon :size="32" color="#667eea">
<Document />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ overviewData.total_exams || 0 }}</div>
<div class="card-label">总考试次数</div>
</div>
</div>
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(103, 194, 58, 0.1)">
<el-icon :size="32" color="#67c23a">
<TrendCharts />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ overviewData.avg_score?.toFixed(1) || '0.0' }}</div>
<div class="card-label">平均分</div>
</div>
</div>
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(230, 162, 60, 0.1)">
<el-icon :size="32" color="#e6a23c">
<CircleCheck />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ (overviewData.pass_rate || 0).toFixed(1) }}%</div>
<div class="card-label">通过率</div>
</div>
</div>
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(64, 158, 255, 0.1)">
<el-icon :size="32" color="#409eff">
<EditPen />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ overviewData.total_questions || 0 }}</div>
<div class="card-label">答题总数</div>
</div>
</div>
</div>
<!-- 筛选和搜索 -->
<div class="filter-section card">
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="课程">
<el-select v-model="filterForm.courseId" placeholder="请选择课程" clearable style="width: 200px">
<el-option label="全部课程" :value="null" />
<el-option
v-for="course in courseList"
:key="course.id"
:label="course.name"
:value="course.id"
/>
</el-select>
</el-form-item>
<el-form-item label="成绩范围">
<el-select v-model="filterForm.scoreRange" placeholder="请选择" clearable style="width: 180px">
<el-option label="全部成绩" value="" />
<el-option label="优秀 (90-100)" value="excellent" />
<el-option label="良好 (80-89)" value="good" />
<el-option label="及格 (60-79)" value="pass" />
<el-option label="不及格 (0-59)" value="fail" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon class="el-icon--left"><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 成绩列表 -->
<div class="score-list card">
<el-table
:data="filteredScoreList"
style="width: 100%"
v-loading="loading"
@row-click="viewScoreDetail"
>
<el-table-column prop="exam_name" label="考试名称" min-width="200" />
<el-table-column prop="course_name" label="课程" width="150" />
<el-table-column prop="score" label="得分" width="100" sortable>
<template #default="scope">
<span :class="getScoreClass(scope.row.score)" class="score-text">
{{ scope.row.score ?? '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="total_score" label="总分" width="80">
<template #default="scope">
<span class="total-score">{{ scope.row.total_score }}</span>
</template>
</el-table-column>
<el-table-column prop="accuracy" label="正确率" width="100" sortable>
<template #default="scope">
<el-progress
v-if="scope.row.accuracy !== null"
:percentage="scope.row.accuracy"
:color="getAccuracyColor(scope.row.accuracy)"
:stroke-width="8"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="duration_seconds" label="用时" width="100">
<template #default="scope">
<span>{{ formatDuration(scope.row.duration_seconds) }}</span>
</template>
</el-table-column>
<el-table-column prop="start_time" label="考试时间" width="180" sortable>
<template #default="scope">
{{ formatDateTime(scope.row.start_time) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusTag(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click.stop="viewScoreDetail(scope.row)">
<el-icon><View /></el-icon>
查看详情
</el-button>
<el-button link type="danger" size="small" @click.stop="viewMistakes(scope.row)">
<el-icon><Warning /></el-icon>
错题本
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 成绩详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
:title="`${currentScore?.exam_name} - 成绩详情`"
width="800px"
>
<div class="score-detail" v-if="currentScore">
<!-- 基本信息 -->
<div class="detail-section">
<h3>基本信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="考试名称">{{ currentScore.exam_name }}</el-descriptions-item>
<el-descriptions-item label="课程">{{ currentScore.course_name }}</el-descriptions-item>
<el-descriptions-item label="考试时间">{{ formatDateTime(currentScore.start_time) }}</el-descriptions-item>
<el-descriptions-item label="用时">{{ formatDuration(currentScore.duration_seconds) }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTag(currentScore.status)">
{{ getStatusText(currentScore.status) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 成绩统计 -->
<div class="detail-section">
<h3>成绩统计</h3>
<div class="score-stats">
<div class="stat-item">
<div class="stat-label">总得分</div>
<div class="stat-value score" :class="getScoreClass(currentScore.score)">
{{ currentScore.score ?? '-' }}/{{ currentScore.total_score }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">正确率</div>
<div class="stat-value">
<el-progress
v-if="currentScore.accuracy !== null"
:percentage="currentScore.accuracy"
:color="getAccuracyColor(currentScore.accuracy)"
:stroke-width="12"
/>
<span v-else>-</span>
</div>
</div>
<div class="stat-item">
<div class="stat-label">题目统计</div>
<div class="stat-value">
<div class="question-stats">
<span class="correct">正确: {{ currentScore.correct_count ?? 0 }}</span>
<span class="wrong">错误: {{ currentScore.wrong_count ?? 0 }}</span>
<span class="total">总计: {{ currentScore.question_count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 分题型统计 -->
<div class="detail-section" v-if="currentScore.question_type_stats && currentScore.question_type_stats.length > 0">
<h3>分题型统计</h3>
<el-table :data="currentScore.question_type_stats" size="small">
<el-table-column prop="type" label="题型" width="120">
<template #default="scope">
<el-tag size="small">{{ scope.row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="total" label="总题数" width="100" />
<el-table-column prop="correct" label="正确数" width="100" />
<el-table-column prop="wrong" label="错误数" width="100" />
<el-table-column prop="accuracy" label="正确率" width="150">
<template #default="scope">
<el-progress
:percentage="scope.row.accuracy"
:stroke-width="6"
:show-text="false"
/>
<span style="margin-left: 8px;">{{ scope.row.accuracy }}%</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
getExamRecords,
getExamStatistics,
getCourseList,
type ExamRecord,
type ExamStatistics as ExamStatsType
} from '@/api/score'
const router = useRouter()
// 加载状态
const loading = ref(false)
// 日期范围
const dateRange = ref<[Date, Date]>([
new Date(new Date().setDate(new Date().getDate() - 30)),
new Date()
])
// 分页
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
// 弹窗状态
const detailDialogVisible = ref(false)
const currentScore = ref<ExamRecord | null>(null)
// 筛选表单
const filterForm = reactive({
courseId: null as number | null,
scoreRange: ''
})
// 概览数据
const overviewData = ref<ExamStatsType>({
total_exams: 0,
avg_score: 0,
pass_rate: 0,
total_questions: 0
})
// 课程列表
const courseList = ref<any[]>([])
// 成绩列表数据
const scoreList = ref<ExamRecord[]>([])
// 筛选后的成绩列表(前端筛选成绩范围)
const filteredScoreList = computed(() => {
let filtered = scoreList.value
// 按成绩范围筛选(前端过滤)
if (filterForm.scoreRange) {
filtered = filtered.filter(item => {
const score = item.score
if (score === null) return false
switch (filterForm.scoreRange) {
case 'excellent':
return score >= 90
case 'good':
return score >= 80 && score < 90
case 'pass':
return score >= 60 && score < 80
case 'fail':
return score < 60
default:
return true
}
})
}
return filtered
})
/**
* 获取分数样式类
*/
const getScoreClass = (score: number | null) => {
if (score === null) return ''
if (score >= 90) return 'score-excellent'
if (score >= 80) return 'score-good'
if (score >= 60) return 'score-pass'
return 'score-fail'
}
/**
* 获取正确率颜色
*/
const getAccuracyColor = (accuracy: number) => {
if (accuracy >= 90) return '#67c23a'
if (accuracy >= 80) return '#409eff'
if (accuracy >= 60) return '#e6a23c'
return '#f56c6c'
}
/**
* 获取状态标签样式
*/
const getStatusTag = (status: string) => {
const tagMap: Record<string, string> = {
completed: 'success',
submitted: 'success',
in_progress: 'warning',
started: 'warning',
timeout: 'info',
failed: 'danger'
}
return tagMap[status] || ''
}
/**
* 获取状态文本
*/
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
completed: '已完成',
submitted: '已提交',
in_progress: '进行中',
started: '进行中',
timeout: '超时',
failed: '已失败'
}
return textMap[status] || status
}
/**
* 格式化时长
*/
const formatDuration = (seconds: number | null) => {
if (!seconds) return '-'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else if (minutes > 0) {
return `${minutes}分钟${secs}`
} else {
return `${secs}`
}
}
/**
* 格式化日期时间
*/
const formatDateTime = (dateStr: string | null) => {
if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
}
/**
* 格式化日期为字符串
*/
const formatDate = (date: Date) => {
return date.toISOString().split('T')[0]
}
/**
* 加载概览数据
*/
const loadOverviewData = async () => {
try {
const params: any = {}
if (filterForm.courseId) {
params.course_id = filterForm.courseId
}
const res = await getExamStatistics(params)
if (res.code === 200) {
overviewData.value = res.data
}
} catch (error) {
console.error('获取概览数据失败:', error)
}
}
/**
* 加载成绩列表
*/
const loadScoreList = async () => {
loading.value = true
try {
const params: any = {
page: currentPage.value,
size: pageSize.value
}
if (filterForm.courseId) {
params.course_id = filterForm.courseId
}
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = formatDate(dateRange.value[0])
params.end_date = formatDate(dateRange.value[1])
}
const res = await getExamRecords(params)
if (res.code === 200) {
scoreList.value = res.data.items
total.value = res.data.total
}
} catch (error) {
console.error('获取成绩列表失败:', error)
ElMessage.error('获取成绩列表失败')
} finally {
loading.value = false
}
}
/**
* 加载课程列表
*/
const loadCourseList = async () => {
try {
const res = await getCourseList()
if (res.code === 200) {
courseList.value = res.data.items || []
}
} catch (error) {
console.error('获取课程列表失败:', error)
}
}
/**
* 日期变化处理
*/
const handleDateChange = () => {
handleSearch()
}
/**
* 刷新数据
*/
const refreshData = async () => {
await Promise.all([loadOverviewData(), loadScoreList()])
ElMessage.success('数据已刷新')
}
/**
* 搜索处理
*/
const handleSearch = () => {
currentPage.value = 1
loadOverviewData()
loadScoreList()
}
/**
* 重置筛选
*/
const handleReset = () => {
filterForm.courseId = null
filterForm.scoreRange = ''
dateRange.value = [
new Date(new Date().setDate(new Date().getDate() - 30)),
new Date()
]
handleSearch()
}
/**
* 分页大小改变
*/
const handleSizeChange = (val: number) => {
pageSize.value = val
currentPage.value = 1
loadScoreList()
}
/**
* 当前页改变
*/
const handleCurrentChange = (val: number) => {
currentPage.value = val
loadScoreList()
}
/**
* 查看成绩详情
*/
const viewScoreDetail = (score: ExamRecord) => {
currentScore.value = score
detailDialogVisible.value = true
}
/**
* 查看错题本
*/
const viewMistakes = (exam: ExamRecord) => {
// 跳转到错题本页面并带上课程ID和考试ID筛选
router.push({
path: '/analysis/mistakes',
query: {
course_id: exam.course_id,
exam_id: exam.id
}
})
}
// 组件挂载时初始化数据
onMounted(() => {
loadCourseList()
loadOverviewData()
loadScoreList()
})
</script>
<style lang="scss" scoped>
.score-query-container {
max-width: 1400px;
margin: 0 auto;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.score-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 24px;
.overview-card {
padding: 24px;
display: flex;
align-items: center;
gap: 20px;
.card-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-content {
flex: 1;
.card-value {
font-size: 32px;
font-weight: 700;
color: #333;
line-height: 1;
margin-bottom: 8px;
}
.card-label {
font-size: 14px;
color: #666;
}
}
}
}
.filter-section {
padding: 20px;
margin-bottom: 20px;
.filter-form {
.el-form-item {
margin-bottom: 0;
}
}
}
.score-list {
padding: 24px;
.score-text {
font-weight: 600;
font-size: 16px;
&.score-excellent {
color: #67c23a;
}
&.score-good {
color: #409eff;
}
&.score-pass {
color: #e6a23c;
}
&.score-fail {
color: #f56c6c;
}
}
.total-score {
color: #666;
font-size: 14px;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 24px;
}
}
.score-detail {
.detail-section {
margin-bottom: 24px;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #f0f0f0;
}
}
.score-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
.stat-item {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 12px;
}
.stat-value {
&.score {
font-size: 24px;
font-weight: 700;
}
.question-stats {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 14px;
.correct {
color: #67c23a;
}
.wrong {
color: #f56c6c;
}
.total {
color: #333;
font-weight: 500;
}
}
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.score-query-container {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.header-actions {
width: 100%;
justify-content: flex-end;
}
}
.score-overview {
grid-template-columns: 1fr;
}
.filter-form {
.el-form-item {
display: block;
margin-bottom: 16px !important;
}
}
.score-stats {
grid-template-columns: 1fr;
}
}
}
</style>