feat:添加试题页面,预览试卷页面,试卷批阅页面添加

This commit is contained in:
yuk255 2025-08-27 19:53:19 +08:00
parent adf90b1390
commit 2c27fe8730
9 changed files with 2774 additions and 23 deletions

View File

@ -67,6 +67,8 @@ import ExamLibrary from '@/views/teacher/ExamPages/ExamLibrary.vue'
import MarkingCenter from '@/views/teacher/ExamPages/MarkingCenter.vue' import MarkingCenter from '@/views/teacher/ExamPages/MarkingCenter.vue'
import AddExam from '@/views/teacher/ExamPages/AddExam.vue' import AddExam from '@/views/teacher/ExamPages/AddExam.vue'
import AddQuestion from '@/views/teacher/ExamPages/AddQuestion.vue' import AddQuestion from '@/views/teacher/ExamPages/AddQuestion.vue'
import StudentList from '@/views/teacher/ExamPages/StudentList.vue'
import GradingPage from '@/views/teacher/ExamPages/GradingPage.vue'
import ChapterEditor from '@/views/teacher/course/ChapterEditor.vue' import ChapterEditor from '@/views/teacher/course/ChapterEditor.vue'
@ -289,8 +291,29 @@ const routes: RouteRecordRaw[] = [
{ {
path: 'marking-center', path: 'marking-center',
name: 'MarkingCenter', name: 'MarkingCenter',
component: MarkingCenter, component: ExamManagement,
meta: { title: '阅卷中心' } meta: { title: '阅卷中心' },
redirect: '/teacher/exam-management/marking-center/list',
children: [
{
path: 'list',
name: 'MarkingList',
component: MarkingCenter,
meta: { title: '试卷列表' }
},
{
path: 'student-list/:paperId',
name: 'StudentList',
component: StudentList,
meta: { title: '阅卷页面' }
},
{
path: 'grading/:examId/:studentId',
name: 'GradingPage',
component: GradingPage,
meta: { title: '批阅试卷' }
}
]
}, },
{ {
path: 'add', path: 'add',
@ -298,6 +321,12 @@ const routes: RouteRecordRaw[] = [
component: AddExam, component: AddExam,
meta: { title: '添加试卷' } meta: { title: '添加试卷' }
}, },
{
path: 'preview',
name: 'ExamPreview',
component: () => import('../views/teacher/ExamPages/ExamPreview.vue'),
meta: { title: '试卷预览' }
},
{ {
path: 'add-question/:id?', path: 'add-question/:id?',
name: 'AddQuestionPage', name: 'AddQuestionPage',

View File

@ -45,7 +45,7 @@
:class="{ active: activeSubNavItem === 'exam-library' }" @click="setActiveSubNavItem('exam-library')"> :class="{ active: activeSubNavItem === 'exam-library' }" @click="setActiveSubNavItem('exam-library')">
<span>试卷管理</span> <span>试卷管理</span>
</router-link> </router-link>
<router-link to="/teacher/exam-management/marking-center" class="submenu-item" <router-link to="/teacher/exam-management/marking-center/list" class="submenu-item"
:class="{ active: activeSubNavItem === 'marking-center' }" @click="setActiveSubNavItem('marking-center')"> :class="{ active: activeSubNavItem === 'marking-center' }" @click="setActiveSubNavItem('marking-center')">
<span>阅卷中心</span> <span>阅卷中心</span>
</router-link> </router-link>
@ -429,11 +429,10 @@ const updateActiveNavItem = () => {
} else if (path.includes('exam-management')) { } else if (path.includes('exam-management')) {
activeNavItem.value = 4; // activeNavItem.value = 4; //
examMenuExpanded.value = true; examMenuExpanded.value = true;
// / const arr = ['question-management', 'exam-library', 'marking-center'];
const pathSegments = path.split('/'); const found = arr.find(item => path.includes(item));
const lastSegment = pathSegments[pathSegments.length - 1]; activeSubNavItem.value = found || '';
activeSubNavItem.value = lastSegment || '';
} }
} }

View File

@ -17,6 +17,14 @@
</template> </template>
</n-button> </n-button>
<h1>添加试卷</h1> <h1>添加试卷</h1>
<span v-if="isAutoSaved" class="auto-save-indicator">
<n-icon size="14" color="#52c41a">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</n-icon>
已自动保存
</span>
</div> </div>
</div> </div>
<n-card size="small"> <n-card size="small">
@ -55,7 +63,7 @@
<template v-for="(item, index) in examForm.questions" :key="index"> <template v-for="(item, index) in examForm.questions" :key="index">
<n-card size="small"> <n-card size="small">
<div class="group"> <div class="group">
<n-row>{{ index + 1 }}</n-row> <n-row>{{ index + 1 }}</n-row>
<div class="questionRow"> <div class="questionRow">
<n-input class="input-title" v-model:value="item.title" placeholder="请输入题目名称" /> <n-input class="input-title" v-model:value="item.title" placeholder="请输入题目名称" />
<n-button strong quaternary @click="deleteBigQuestion(index)"> <n-button strong quaternary @click="deleteBigQuestion(index)">
@ -119,7 +127,7 @@
class="sub-question-item"> class="sub-question-item">
<!-- 小题标题栏 --> <!-- 小题标题栏 -->
<div class="sub-question-header"> <div class="sub-question-header">
<span class="sub-question-number">*{{ index + 1 }}.{{ subIndex + 1 }} {{ <span class="sub-question-number">*{{ subIndex + 1 }} {{
getQuestionTypeName(subQuestion.type) }}</span> getQuestionTypeName(subQuestion.type) }}</span>
</div> </div>
@ -422,7 +430,7 @@
</template> </template>
试卷设置 试卷设置
</n-button> </n-button>
<n-button type="primary" ghost size="large"> <n-button type="primary" ghost size="large" @click="previewExam">
预览试卷 预览试卷
</n-button> </n-button>
</n-space> </n-space>
@ -467,7 +475,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from 'vue'; import { computed, reactive, ref, onMounted, onUnmounted, watch } from 'vue';
import { createDiscreteApi } from 'naive-ui'; import { createDiscreteApi } from 'naive-ui';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { AddCircle, SettingsOutline, TrashOutline, ChevronUpSharp, BookSharp, ArrowBackOutline } from '@vicons/ionicons5' import { AddCircle, SettingsOutline, TrashOutline, ChevronUpSharp, BookSharp, ArrowBackOutline } from '@vicons/ionicons5'
@ -483,7 +491,32 @@ const router = useRouter()
// //
const goBack = () => { const goBack = () => {
router.back() //
const hasContent = examForm.title.trim() ||
examForm.questions.some(q => q.subQuestions.length > 0) ||
examForm.description.trim()
if (hasContent) {
dialog.warning({
title: '确认返回',
content: '返回将清空当前编辑的试卷内容,确定要返回吗?',
positiveText: '确定返回',
negativeText: '继续编辑',
onPositiveClick: () => {
// sessionStorage
sessionStorage.removeItem('examPreviewData')
//
router.back()
},
onNegativeClick: () => {
//
}
})
} else {
//
sessionStorage.removeItem('examPreviewData')
router.back()
}
} }
// //
@ -642,6 +675,7 @@ const addQuestion = (index: number) => {
{ id: '4', content: '选项D', isCorrect: false } { id: '4', content: '选项D', isCorrect: false }
]; ];
newSubQuestion.correctAnswer = ''; newSubQuestion.correctAnswer = '';
newSubQuestion.explanation = '';
break; break;
case QuestionType.MULTIPLE_CHOICE: case QuestionType.MULTIPLE_CHOICE:
@ -652,24 +686,30 @@ const addQuestion = (index: number) => {
{ id: '4', content: '选项D', isCorrect: false } { id: '4', content: '选项D', isCorrect: false }
]; ];
newSubQuestion.correctAnswer = []; newSubQuestion.correctAnswer = [];
newSubQuestion.explanation = '';
break; break;
case QuestionType.TRUE_FALSE: case QuestionType.TRUE_FALSE:
newSubQuestion.trueFalseAnswer = undefined; // newSubQuestion.trueFalseAnswer = undefined; //
newSubQuestion.explanation = '';
break; break;
case QuestionType.FILL_BLANK: case QuestionType.FILL_BLANK:
newSubQuestion.fillBlanks = [ newSubQuestion.fillBlanks = [
{ id: '1', content: '', position: 1 } { id: '1', content: '', position: 1 }
]; ];
newSubQuestion.explanation = '';
break; break;
case QuestionType.SHORT_ANSWER: case QuestionType.SHORT_ANSWER:
newSubQuestion.textAnswer = ''; newSubQuestion.textAnswer = '';
newSubQuestion.explanation = '';
break; break;
case QuestionType.COMPOSITE: case QuestionType.COMPOSITE:
newSubQuestion.subQuestions = []; newSubQuestion.subQuestions = [];
newSubQuestion.explanation = '';
break;
break; break;
} }
@ -1135,6 +1175,46 @@ const saveExam = () => {
}); });
} }
//
const previewExam = () => {
// sessionStorage
const examData = {
...examForm,
previewTime: new Date().toISOString()
};
sessionStorage.setItem('examPreviewData', JSON.stringify(examData));
//
if (!examForm.title.trim()) {
dialog.warning({
title: '输入提示',
content: '请输入试卷标题',
positiveText: '确定'
});
return;
}
let hasQuestions = false;
for (const bigQuestion of examForm.questions) {
if (bigQuestion.subQuestions.length > 0) {
hasQuestions = true;
break;
}
}
if (!hasQuestions) {
dialog.warning({
title: '输入提示',
content: '请至少添加一道题目再进行预览',
positiveText: '确定'
});
return;
}
//
router.push('/teacher/exam-management/preview');
}
// //
const addCompositeSubQuestion = (bigQuestionIndex: number, subQuestionIndex: number) => { const addCompositeSubQuestion = (bigQuestionIndex: number, subQuestionIndex: number) => {
const subQuestion = examForm.questions[bigQuestionIndex].subQuestions[subQuestionIndex]; const subQuestion = examForm.questions[bigQuestionIndex].subQuestions[subQuestionIndex];
@ -1156,6 +1236,7 @@ const addCompositeSubQuestion = (bigQuestionIndex: number, subQuestionIndex: num
{ id: '4', content: '选项D', isCorrect: false } { id: '4', content: '选项D', isCorrect: false }
], ],
correctAnswer: '', correctAnswer: '',
explanation: '这是复合题子题的答案解析示例。',
createTime: new Date().toISOString() createTime: new Date().toISOString()
}; };
@ -1357,6 +1438,77 @@ const previewSubQuestion = (bigQuestionIndex: number, subQuestionIndex: number)
console.log('预览题目:', subQuestion); console.log('预览题目:', subQuestion);
// //
} }
//
const restoreExamData = () => {
const storedData = sessionStorage.getItem('examPreviewData');
if (storedData) {
try {
const parsedData = JSON.parse(storedData);
//
examForm.title = parsedData.title || '';
examForm.type = parsedData.type || 1;
examForm.description = parsedData.description || '';
examForm.totalScore = parsedData.totalScore || 0;
examForm.duration = parsedData.duration || 60;
examForm.passScore = parsedData.passScore || 60;
examForm.instructions = parsedData.instructions || '';
examForm.useAIGrading = parsedData.useAIGrading || false;
//
if (parsedData.questions && Array.isArray(parsedData.questions)) {
examForm.questions = parsedData.questions;
}
console.log('试卷数据已恢复:', examForm);
} catch (error) {
console.error('恢复试卷数据失败:', error);
}
}
}
//
onMounted(() => {
restoreExamData();
});
//
onUnmounted(() => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer);
}
});
// sessionStorage
watch(
() => examForm,
(newValue) => {
//
isAutoSaved.value = false; //
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(() => {
const examData = {
...newValue,
lastModified: new Date().toISOString()
};
sessionStorage.setItem('examPreviewData', JSON.stringify(examData));
isAutoSaved.value = true; //
// 3
setTimeout(() => {
isAutoSaved.value = false;
}, 3000);
}, 1000); // 1
},
{ deep: true }
);
//
let autoSaveTimer: NodeJS.Timeout;
//
const isAutoSaved = ref(false);
</script> </script>
<style scoped> <style scoped>
@ -1386,6 +1538,29 @@ const previewSubQuestion = (bigQuestionIndex: number, subQuestionIndex: number)
color: #333; color: #333;
} }
.auto-save-indicator {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #52c41a;
padding: 2px 8px;
background: rgba(82, 196, 26, 0.1);
border-radius: 12px;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.back-button { .back-button {
color: #666; color: #666;
transition: all 0.3s ease; transition: all 0.3s ease;

View File

@ -168,11 +168,12 @@ const paginationConfig = computed(() => ({
pageSizes: [10, 20, 50, 100], pageSizes: [10, 20, 50, 100],
showSizePicker: true, showSizePicker: true,
showQuickJumper: true, showQuickJumper: true,
prefix: (info: { startIndex: number; endIndex: number; page: number; pageSize: number; pageCount: number; itemCount?: number }) => { goto: ()=>{
return '跳转'
},
prefix: (info: { itemCount?: number }) => {
const itemCount = info.itemCount || 0; const itemCount = info.itemCount || 0;
const start = (currentPage.value - 1) * pageSize.value + 1; return `${itemCount}`;
const end = Math.min(currentPage.value * pageSize.value, itemCount);
return `显示 ${start}-${end} 条,共 ${itemCount}`;
}, },
onUpdatePage: (page: number) => { onUpdatePage: (page: number) => {
currentPage.value = page; currentPage.value = page;

View File

@ -0,0 +1,704 @@
<template>
<div class="exam-preview-container">
<!-- 左侧时间和题目导航 -->
<div class="exam-sidebar">
<div class="exam-title">
<n-button quaternary circle size="large" @click="goBack" class="back-button">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<h1 class="exam-title">{{ examData?.examName || '试卷预览' }}</h1>
</div>
<n-divider />
<div class="time-section">
<div class="time-label">剩余时间</div>
<div class="time-display">{{ formatTime(examData?.duration * 60 || 3600) }}</div>
<div class="time-units">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="question-nav">
<div class="nav-title">答题卡</div>
<div class="nav-grid">
<div v-for="(_, index) in allQuestions" :key="index" class="nav-item"
:class="{ 'answered': false }">
{{ index + 1 }}
</div>
</div>
</div>
</div>
<!-- 右侧主要内容区域 -->
<div class="exam-main">
<div class="header-content">
<div class="exam-info">
<div class="exam-meta">
<span>总分{{ examData?.totalScore || 0 }}</span>
<span>题目数量{{ totalQuestions }}</span>
<span>考试时长{{ examData?.duration || 0 }}分钟</span>
</div>
</div>
</div>
<div class="exam-content" v-if="examData">
<!-- 大题循环 -->
<div v-for="(bigQuestion, bigIndex) in examData.questions" :key="bigQuestion.id" class="big-question">
<div class="big-question-header">
<span class="question-desc">{{ bigQuestion.title }}</span>
<span class="question-count">{{ bigQuestion.subQuestions.length }}</span>
</div>
<!-- 小题循环 -->
<div v-for="(subQuestion, subIndex) in bigQuestion.subQuestions" :key="subQuestion.id"
class="sub-question">
<div class="question-header">
<span class="question-number">{{ getQuestionNumber(bigIndex, subIndex) }} {{
getQuestionTypeName(subQuestion.type) }}</span>
<span class="question-score">{{ subQuestion.score }}</span>
</div>
<div class="question-title">{{ subQuestion.title }}</div>
<!-- 单选题 -->
<div v-if="subQuestion.type === 'single_choice'" class="question-content">
<div class="options">
<div v-for="(option, optIndex) in subQuestion.options" :key="option.id"
class="option-item" :class="{ 'correct-option': option.isCorrect }">
<span class="option-label">{{ String.fromCharCode(65 + optIndex) }}</span>
<span class="option-content">{{ option.content }}</span>
<span v-if="option.isCorrect" class="correct-mark"></span>
</div>
</div>
<!-- 答案解析 -->
<div class="answer-analysis">
<div class="correct-answer">
<span class="label">正确答案</span>
<span class="answer">{{ getCorrectAnswerText(subQuestion) }}</span>
</div>
<div v-if="subQuestion.explanation" class="explanation">
<span class="label">答案解析</span>
<p>{{ subQuestion.explanation }}</p>
</div>
</div>
</div>
<!-- 多选题 -->
<div v-if="subQuestion.type === 'multiple_choice'" class="question-content">
<div class="options">
<div v-for="(option, optIndex) in subQuestion.options" :key="option.id"
class="option-item" :class="{ 'correct-option': option.isCorrect }">
<span class="option-label">{{ String.fromCharCode(65 + optIndex) }}</span>
<span class="option-content">{{ option.content }}</span>
<span v-if="option.isCorrect" class="correct-mark"></span>
</div>
</div>
<!-- 答案解析 -->
<div class="answer-analysis">
<div class="correct-answer">
<span class="label">正确答案</span>
<span class="answer">{{ getCorrectAnswerText(subQuestion) }}</span>
</div>
<div v-if="subQuestion.explanation" class="explanation">
<span class="label">答案解析</span>
<p>{{ subQuestion.explanation }}</p>
</div>
</div>
</div>
<!-- 判断题 -->
<div v-if="subQuestion.type === 'true_false'" class="question-content">
<div class="true-false-options">
<div class="option-item"
:class="{ 'correct-option': subQuestion.trueFalseAnswer === true }">
<span class="option-label">A</span>
<span class="option-content">正确</span>
<span v-if="subQuestion.trueFalseAnswer === true" class="correct-mark"></span>
</div>
<div class="option-item"
:class="{ 'correct-option': subQuestion.trueFalseAnswer === false }">
<span class="option-label">B</span>
<span class="option-content">错误</span>
<span v-if="subQuestion.trueFalseAnswer === false" class="correct-mark"></span>
</div>
</div>
<!-- 答案解析 -->
<div class="answer-analysis">
<div class="correct-answer">
<span class="label">正确答案</span>
<span class="answer">{{ subQuestion.trueFalseAnswer ? 'A 正确' : 'B 错误' }}</span>
</div>
<div v-if="subQuestion.explanation" class="explanation">
<span class="label">答案解析</span>
<p>{{ subQuestion.explanation }}</p>
</div>
</div>
</div>
<!-- 填空题 -->
<div v-if="subQuestion.type === 'fill_blank'" class="question-content">
<div class="fill-blanks">
<div v-for="blank in subQuestion.fillBlanks" :key="blank.id" class="blank-item">
<span>{{ blank.position }}</span>
<div class="blank-answer">{{ blank.content || '未设置' }}</div>
</div>
</div>
<!-- 答案解析 -->
<div class="answer-analysis">
<div class="correct-answer">
<span class="label">正确答案</span>
<div class="answer">
<div v-for="blank in subQuestion.fillBlanks" :key="blank.id">
{{ blank.position }}{{ blank.content || '未设置' }}
</div>
</div>
</div>
<div v-if="subQuestion.explanation" class="explanation">
<span class="label">答案解析</span>
<p>{{ subQuestion.explanation }}</p>
</div>
</div>
</div>
<!-- 简答题 -->
<div v-if="subQuestion.type === 'short_answer'" class="question-content">
<div class="answer-area">
<div class="answer-text">{{ subQuestion.textAnswer || '未设置参考答案' }}</div>
</div>
<!-- 答案解析 -->
<div class="answer-analysis">
<div class="correct-answer">
<span class="label">参考答案</span>
<p class="answer">{{ subQuestion.textAnswer || '未设置参考答案' }}</p>
</div>
<div v-if="subQuestion.explanation" class="explanation">
<span class="label">答案解析</span>
<p>{{ subQuestion.explanation }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="no-data">
<n-empty description="没有找到试卷数据">
<template #extra>
<n-button @click="goBack">返回</n-button>
</template>
</n-empty>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NEmpty } from 'naive-ui'
import { ArrowBackOutline } from '@vicons/ionicons5'
const router = useRouter()
const examData = ref<any>(null)
//
const allQuestions = computed(() => {
if (!examData.value) return []
const questions: any[] = []
examData.value.questions.forEach((bigQ: any) => {
bigQ.subQuestions.forEach((subQ: any) => {
if (subQ.type === 'composite') {
subQ.subQuestions?.forEach((compSubQ: any) => {
questions.push(compSubQ)
})
} else {
questions.push(subQ)
}
})
})
return questions
})
const totalQuestions = computed(() => {
return allQuestions.value.length
})
//
const formatTime = (seconds: number) => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
//
const getQuestionTypeName = (type: string) => {
const typeMap: Record<string, string> = {
'single_choice': '单选题',
'multiple_choice': '多选题',
'true_false': '判断题',
'fill_blank': '填空题',
'short_answer': '简答题',
'composite': '复合题'
}
return typeMap[type] || '未知题型'
}
//
const getQuestionNumber = (bigIndex: number, subIndex: number) => {
let number = 1
for (let i = 0; i < bigIndex; i++) {
number += examData.value.questions[i].subQuestions.length
}
return number + subIndex
}
//
const getCorrectAnswerText = (question: any) => {
if (question.type === 'single_choice') {
const correctOption = question.options?.find((opt: any) => opt.isCorrect)
if (correctOption) {
const index = question.options.findIndex((opt: any) => opt.isCorrect)
return `${String.fromCharCode(65 + index)}`
}
return '未设置正确答案'
} else if (question.type === 'multiple_choice') {
const correctOptions = question.options?.filter((opt: any) => opt.isCorrect)
if (correctOptions?.length > 0) {
return correctOptions.map((opt: any) => {
const optIndex = question.options.findIndex((o: any) => o.id === opt.id)
return `${String.fromCharCode(65 + optIndex)}`
}).join('、')
}
return '未设置正确答案'
}
return ''
}
const goBack = () => {
router.back()
}
onMounted(() => {
// sessionStorage
const savedData = sessionStorage.getItem('examPreviewData')
if (savedData) {
try {
examData.value = JSON.parse(savedData)
} catch (error) {
console.error('解析试卷数据失败:', error)
}
}
})
</script>
<style scoped>
.exam-preview-container {
display: flex;
min-height: 100vh;
background: #f8f9fa;
}
/* 左侧边栏样式 */
.exam-sidebar {
width: 280px;
background: white;
padding: 10px;
border-right: 1px solid #e8e8e8;
overflow-y: auto;
}
.exam-title {
display: flex;
align-items: center;
font-size: 24px;
color: #333;
font-weight: bold;
}
.time-section {
background: #f0f9ff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
text-align: center;
}
.time-label {
font-size: 14px;
color: #666;
margin-bottom: 10px;
}
.time-display {
font-size: 28px;
font-weight: bold;
color: #1890ff;
font-family: 'Courier New', monospace;
margin-bottom: 8px;
}
.time-units {
display: flex;
justify-content: space-around;
font-size: 12px;
color: #999;
}
.question-nav {
background: white;
}
.nav-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #e8e8e8;
}
.nav-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
margin-bottom: 20px;
}
.nav-item {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.3s ease;
}
.nav-item.answered {
background: #1890ff;
color: white;
border-color: #1890ff;
}
.nav-legend {
display: flex;
align-items: center;
gap: 15px;
font-size: 12px;
color: #666;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-color.answered {
background: #1890ff;
}
/* 右侧主要内容区域 */
.exam-main {
flex: 1;
padding: 0 20px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.exam-info {
flex: 1;
}
.exam-meta {
display: flex;
gap: 24px;
color: #666;
font-size: 14px;
margin-bottom: 16px;
justify-content: flex-end
}
.preview-note {
margin-top: 16px;
}
.header-actions {
display: flex;
gap: 12px;
}
.exam-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.big-question {
background: white;
border-radius: 8px;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.big-question-header {
padding: 15px 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
gap: 15px;
}
.question-desc {
color: #1890ff;
font-weight: bold;
font-size: 19px;
}
.question-count {
color: #666;
font-size: 14px;
}
.sub-question {
padding: 20px;
border-bottom: 1px solid #f0f0f0;
}
.sub-question:last-child {
border-bottom: none;
}
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.question-number {
font-size: 16px;
font-weight: bold;
color: #333;
}
.question-score {
background: #f0f9ff;
color: #1890ff;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.question-title {
font-size: 15px;
color: #333;
line-height: 1.6;
margin-bottom: 20px;
}
.question-content {
margin-left: 0;
}
.options {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.option-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background-color: #F5F8FB;
border-radius: 6px;
transition: all 0.3s ease;
position: relative;
}
.option-item.correct-option {
background: #f6ffed;
border-color: #52c41a;
}
.option-label {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid #d9d9d9;
border-radius: 50%;
font-size: 12px;
font-weight: bold;
color: #666;
flex-shrink: 0;
}
.correct-option .option-label {
background: #52c41a;
color: white;
border-color: #52c41a;
}
.option-content {
flex: 1;
font-size: 14px;
color: #333;
line-height: 1.5;
}
.correct-mark {
color: #52c41a;
font-size: 16px;
font-weight: bold;
}
.true-false-options {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.fill-blanks {
margin-bottom: 20px;
}
.blank-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.blank-answer {
background: #f0f9ff;
border: 1px solid #1890ff;
padding: 8px 12px;
border-radius: 4px;
min-width: 100px;
color: #1890ff;
font-weight: bold;
}
.answer-area {
margin-bottom: 20px;
}
.answer-text {
background: #f8f9fa;
border: 1px solid #e8e8e8;
padding: 15px;
border-radius: 6px;
min-height: 80px;
color: #333;
line-height: 1.6;
}
/* 答案解析样式 */
.answer-analysis {
background: #fafbfc;
border: 1px solid #e1e4e8;
border-radius: 6px;
padding: 16px;
margin-top: 15px;
}
.correct-answer {
margin-bottom: 12px;
}
.correct-answer .label {
font-weight: bold;
color: #52c41a;
margin-right: 8px;
}
.correct-answer .answer {
color: #333;
font-weight: 500;
}
.explanation .label {
font-weight: bold;
color: #1890ff;
margin-right: 8px;
}
.explanation p {
margin: 4px 0 0 0;
color: #666;
line-height: 1.6;
}
.no-data {
background: white;
border-radius: 8px;
padding: 60px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 响应式设计 */
@media (max-width: 1200px) {
.exam-sidebar {
width: 240px;
}
.exam-main {
margin-left: 240px;
}
}
@media (max-width: 768px) {
.exam-preview-container {
flex-direction: column;
}
.exam-sidebar {
position: relative;
width: 100%;
height: auto;
}
.exam-main {
margin-left: 0;
}
.nav-grid {
grid-template-columns: repeat(8, 1fr);
}
.header-content {
flex-direction: column;
gap: 16px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -125,7 +125,6 @@
:page-size="pageSize" :page-size="pageSize"
show-size-picker show-size-picker
:page-sizes="[10, 20, 50]" :page-sizes="[10, 20, 50]"
show-quick-jumper
:item-count="totalItems" :item-count="totalItems"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:page-size="handlePageSizeChange" @update:page-size="handlePageSizeChange"
@ -136,6 +135,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { PersonOutline, CalendarOutline } from '@vicons/ionicons5' import { PersonOutline, CalendarOutline } from '@vicons/ionicons5'
// //
@ -151,6 +151,9 @@ interface ExamItem {
gradedCount: number gradedCount: number
} }
//
const router = useRouter()
// //
const activeTab = ref('all') const activeTab = ref('all')
const examFilter = ref('') const examFilter = ref('')
@ -275,7 +278,11 @@ const handleDelete = (exam: ExamItem) => {
} }
const handleAction = (exam: ExamItem) => { const handleAction = (exam: ExamItem) => {
console.log('执行操作:', exam) // ID
router.push({
name: 'StudentList',
params: { paperId: exam.id }
})
} }
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {

View File

@ -241,11 +241,12 @@ const paginationConfig = computed(() => ({
pageSizes: [10, 20, 50, 100], pageSizes: [10, 20, 50, 100],
showSizePicker: true, showSizePicker: true,
showQuickJumper: true, showQuickJumper: true,
prefix: (info: { startIndex: number; endIndex: number; page: number; pageSize: number; pageCount: number; itemCount?: number }) => { goto: ()=>{
return '跳转'
},
prefix: (info: { itemCount?: number }) => {
const itemCount = info.itemCount || 0; const itemCount = info.itemCount || 0;
const start = (pagination.page - 1) * pagination.pageSize + 1; return `${itemCount}`;
const end = Math.min(pagination.page * pagination.pageSize, itemCount);
return `显示 ${start}-${end} 条,共 ${itemCount}`;
}, },
onUpdatePage: (page: number) => { onUpdatePage: (page: number) => {
pagination.page = page; pagination.page = page;

View File

@ -0,0 +1,765 @@
<template>
<div class="student-list-container">
<!-- 页面头部 -->
<div class="header-section">
<div class="header-left">
<n-button quaternary circle size="large" @click="goBack" class="back-button">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<div class="header-info">
<h1 class="page-title">{{ examInfo.title || '试卷名' }}</h1>
<div class="exam-meta">
<n-tag :type="getStatusType(examInfo.status)" :bordered="false" size="small">
{{ getStatusText(examInfo.status) }}
</n-tag>
</div>
</div>
</div>
<div class="actions-group">
<n-button type="primary" @click="publishExam">
发布补考
</n-button>
<n-button ghost @click="importStudents">
导入
</n-button>
<n-button ghost @click="exportResults">
导出
</n-button>
<n-select v-model:value="classFilter" :options="classFilterOptions" placeholder="班级名称"
style="width: 120px;" clearable />
<n-input v-model:value="searchKeyword" placeholder="请输入学生姓名"
style="width: 200px;" clearable>
</n-input>
<n-button type="primary" @click="handleSearch">搜索</n-button>
</div>
</div>
<!-- Tab切换 -->
<div class="tab-container">
<n-tabs v-model:value="activeTab" type="line" animated @update:value="handleTabChange">
<n-tab-pane name="all" tab="全部">
</n-tab-pane>
<n-tab-pane name="submitted" tab="已交">
</n-tab-pane>
<n-tab-pane name="not-submitted" tab="未交">
</n-tab-pane>
</n-tabs>
</div>
<!-- 学生表格 -->
<n-data-table :columns="columns" :data="filteredStudents" :pagination="paginationReactive"
:loading="loading" size="medium" striped :scroll-x="1400" :single-line="false"
class="student-table" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ArrowBackOutline } from '@vicons/ionicons5'
import { NButton, NTag, NIcon, useMessage } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
//
const router = useRouter()
const route = useRoute()
const message = useMessage()
//
interface StudentExamInfo {
id: string
studentId: string
studentName: string
className: string
startTime: string | null
submitTime: string | null
examDuration: string | null
score: number | null
status: 'not-started' | 'in-progress' | 'submitted' | 'graded'
gradingStatus: 'pending' | 'graded'
totalQuestions: number
answeredQuestions: number
}
interface ExamInfo {
id: string
title: string
duration: string
status: 'not-started' | 'in-progress' | 'completed'
totalStudents: number
submittedCount: number
gradedCount: number
}
//
const loading = ref(false)
const searchKeyword = ref('')
const statusFilter = ref('')
const gradeFilter = ref('')
// Tab
const activeTab = ref('all')
//
const classFilter = ref('')
//
const examInfo = ref<ExamInfo>({
id: '',
title: '',
duration: '',
status: 'in-progress',
totalStudents: 0,
submittedCount: 0,
gradedCount: 0
})
//
const studentList = ref<StudentExamInfo[]>([])
//
const classFilterOptions = [
{ label: '全部班级', value: '' },
{ label: '计算机1班', value: '计算机1班' },
{ label: '计算机2班', value: '计算机2班' },
{ label: '软件工程1班', value: '软件工程1班' }
]
//
const paginationReactive = reactive({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50],
onChange: (page: number) => {
paginationReactive.page = page
},
onUpdatePageSize: (pageSize: number) => {
paginationReactive.pageSize = pageSize
paginationReactive.page = 1
}
})
//
const columns: DataTableColumns<StudentExamInfo> = [
{
title: '序号',
key: 'index',
width: 80,
render: (_, index) => {
return (paginationReactive.page - 1) * paginationReactive.pageSize + index + 1
}
},
{
title: '姓名',
key: 'studentName',
width: 120,
ellipsis: {
tooltip: true
}
},
{
title: '学号',
key: 'studentId',
width: 150
},
{
title: '班级',
key: 'className',
width: 120
},
{
title: '开始时间',
key: 'startTime',
width: 180,
render: (row) => {
return row.startTime || '-'
}
},
{
title: '提交时间',
key: 'submitTime',
width: 180,
render: (row) => {
return row.submitTime || '-'
}
},
{
title: '考试用时',
key: 'examDuration',
width: 120,
render: (row) => {
return row.examDuration || '-'
}
},
{
title: '正确率',
key: 'accuracy',
width: 100,
render: (row) => {
if (row.score === null) return '-'
return `${row.score}%`
}
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
return h(NTag, {
type: getStudentStatusType(row.status),
bordered: false,
size: 'small'
}, {
default: () => getStudentStatusText(row.status)
})
}
},
{
title: '批阅时间',
key: 'gradingTime',
width: 180,
render: (row) => {
return row.gradingStatus === 'graded' ? '2025.07.25 09:20' : '-'
}
},
{
title: '得分',
key: 'score',
width: 80,
render: (row) => {
return row.score !== null ? `${row.score}` : '-'
}
},
{
title: '操作',
key: 'actions',
width: 120,
fixed: 'right',
render: (row) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(NButton, {
type: 'primary',
size: 'small',
onClick: () => handleViewAnswer(row)
}, { default: () => '查看' }),
h(NButton, {
type: 'primary',
size: 'small',
disabled: row.status !== 'submitted',
onClick: () => handleGrade(row)
}, { default: () => '批阅' })
])
}
}
]
//
const filteredStudents = computed(() => {
let filtered = studentList.value
// Tab
if (activeTab.value === 'submitted') {
filtered = filtered.filter(student => student.status === 'submitted' || student.status === 'graded')
} else if (activeTab.value === 'not-submitted') {
filtered = filtered.filter(student => student.status === 'not-started' || student.status === 'in-progress')
}
//
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
filtered = filtered.filter(student =>
student.studentName.toLowerCase().includes(keyword) ||
student.studentId.toLowerCase().includes(keyword)
)
}
//
if (classFilter.value) {
filtered = filtered.filter(student => student.className === classFilter.value)
}
//
if (statusFilter.value) {
filtered = filtered.filter(student => student.status === statusFilter.value)
}
//
if (gradeFilter.value) {
filtered = filtered.filter(student => student.gradingStatus === gradeFilter.value)
}
return filtered
})
//
const goBack = () => {
router.back()
}
const getStatusType = (status: string) => {
switch (status) {
case 'not-started':
return 'default'
case 'in-progress':
return 'warning'
case 'completed':
return 'success'
default:
return 'default'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'not-started':
return '未开始'
case 'in-progress':
return '进行中'
case 'completed':
return '已结束'
default:
return '未知'
}
}
const getStudentStatusType = (status: string) => {
switch (status) {
case 'not-started':
return 'primary'
case 'in-progress':
return 'warning'
case 'submitted':
return 'info'
case 'graded':
return 'success'
default:
return 'primary'
}
}
const getStudentStatusText = (status: string) => {
switch (status) {
case 'not-started':
return '待考试'
case 'in-progress':
return '考试中'
case 'submitted':
return '已提交'
case 'graded':
return '已批阅'
default:
return '未知'
}
}
const handleViewAnswer = (student: StudentExamInfo) => {
//
router.push(`/teacher/exam-management/marking-center/answer-detail/${examInfo.value.id}/${student.id}`)
}
const handleGrade = (student: StudentExamInfo) => {
//
router.push(`/teacher/exam-management/marking-center/grading/${examInfo.value.id}/${student.id}`)
}
const exportResults = () => {
message.info('导出功能开发中...')
}
//
const handleTabChange = (value: string) => {
activeTab.value = value
}
const publishExam = () => {
message.info('发布补考功能开发中...')
}
const importStudents = () => {
message.info('导入学生功能开发中...')
}
const handleSearch = () => {
// computed
message.info('搜索已应用')
}
//
const loadExamInfo = async (examId: string) => {
loading.value = true
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
//
examInfo.value = {
id: examId,
title: '试卷名称试卷名称试卷名称试卷名称试卷名称',
duration: '考试时间2025.07.25 09:00 - 2025.07.25 09:20',
status: 'in-progress',
totalStudents: 12,
submittedCount: 8,
gradedCount: 5
}
} catch (error) {
message.error('加载考试信息失败')
} finally {
loading.value = false
}
}
const loadStudentList = async (_examId: string) => {
loading.value = true
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
//
studentList.value = [
{
id: '1',
studentId: '1826685554',
studentName: '张张',
className: '待考试',
startTime: '2025.07.25 09:20',
submitTime: '2025.07.25 09:20',
examDuration: '9分钟29秒',
score: 70,
status: 'graded',
gradingStatus: 'graded',
totalQuestions: 10,
answeredQuestions: 8
},
{
id: '2',
studentId: '1826685554',
studentName: '张张',
className: '待考试',
startTime: null,
submitTime: null,
examDuration: null,
score: null,
status: 'submitted',
gradingStatus: 'pending',
totalQuestions: 10,
answeredQuestions: 0
},
{
id: '3',
studentId: '1826685554',
studentName: '张张',
className: '已提交',
startTime: '2025.07.25 09:20',
submitTime: '2025.07.25 09:20',
examDuration: '9分钟29秒',
score: 60,
status: 'graded',
gradingStatus: 'graded',
totalQuestions: 10,
answeredQuestions: 9
},
{
id: '4',
studentId: '1826685554',
studentName: '张张',
className: '待考试',
startTime: '2025.07.25 09:20',
submitTime: '2025.07.25 09:20',
examDuration: '9分钟29秒',
score: 70,
status: 'graded',
gradingStatus: 'graded',
totalQuestions: 10,
answeredQuestions: 10
},
{
id: '5',
studentId: '1826685554',
studentName: '张张',
className: '待考试',
startTime: null,
submitTime: null,
examDuration: null,
score: null,
status: 'not-started',
gradingStatus: 'pending',
totalQuestions: 10,
answeredQuestions: 0
},
{
id: '6',
studentId: '1826685554',
studentName: '张张',
className: '已提交',
startTime: '2025.07.25 09:20',
submitTime: '2025.07.25 09:20',
examDuration: '9分钟29秒',
score: 60,
status: 'graded',
gradingStatus: 'graded',
totalQuestions: 10,
answeredQuestions: 8
},
{
id: '7',
studentId: '1826685554',
studentName: '张张',
className: '待考试',
startTime: '2025.07.25 09:20',
submitTime: '2025.07.25 09:20',
examDuration: '9分钟29秒',
score: 70,
status: 'graded',
gradingStatus: 'graded',
totalQuestions: 10,
answeredQuestions: 10
},
{
id: '8',
studentId: '1826685554',
studentName: '张张',
className: '待考试',
startTime: null,
submitTime: null,
examDuration: null,
score: null,
status: 'not-started',
gradingStatus: 'pending',
totalQuestions: 10,
answeredQuestions: 0
},
{
id: '9',
studentId: '1826685554',
studentName: '张张',
className: '已提交',
startTime: '2025.07.25 09:20',
submitTime: '2025.07.25 09:20',
examDuration: '9分钟29秒',
score: 90,
status: 'graded',
gradingStatus: 'graded',
totalQuestions: 10,
answeredQuestions: 10
},
{
id: '10',
studentId: '1826685554',
studentName: '张张',
className: '待考试',
startTime: '2025.07.25 09:20',
submitTime: '2025.07.25 09:20',
examDuration: '9分钟29秒',
score: 70,
status: 'graded',
gradingStatus: 'graded',
totalQuestions: 10,
answeredQuestions: 9
},
{
id: '11',
studentId: '1826685554',
studentName: '张张',
className: '待考试',
startTime: null,
submitTime: null,
examDuration: null,
score: null,
status: 'not-started',
gradingStatus: 'pending',
totalQuestions: 10,
answeredQuestions: 0
},
{
id: '12',
studentId: '1826685554',
studentName: '张张',
className: '已提交',
startTime: '2025.07.25 09:20',
submitTime: '2025.07.25 09:20',
examDuration: '9分钟29秒',
score: 90,
status: 'graded',
gradingStatus: 'graded',
totalQuestions: 10,
answeredQuestions: 10
}
]
} catch (error) {
message.error('加载学生列表失败')
} finally {
loading.value = false
}
}
//
onMounted(async () => {
const examId = route.params.paperId as string
if (examId) {
await Promise.all([
loadExamInfo(examId),
loadStudentList(examId)
])
}
})
</script>
<style scoped>
.student-list-container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
}
/* 页面头部 */
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 20px;
border-bottom: 1px solid #E6E6E6;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-button {
color: #666;
}
.back-button:hover {
background-color: #f5f5f5;
}
.header-info {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 500;
}
.exam-meta {
display: flex;
align-items: center;
gap: 12px;
}
.actions-group {
display: flex;
align-items: center;
gap: 10px;
}
/* Tab 切换区域 */
.tab-container {
margin-top: 20px;
margin-bottom: 20px;
}
.tab-container :deep(.n-tabs) {
--n-tab-text-color: #666;
--n-tab-text-color-active: #1890ff;
--n-tab-text-color-hover: #1890ff;
--n-bar-color: #1890ff;
}
.tab-container :deep(.n-tabs-tab) {
padding: 16px 0;
margin-right: 32px;
font-weight: 500;
font-size: 16px;
}
/* 表格样式 */
.student-table {
margin-top: 0;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.header-section {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.actions-group {
justify-content: center;
flex-wrap: wrap;
}
}
@media (max-width: 768px) {
.student-list-container {
padding: 12px;
}
.header-left {
justify-content: space-between;
}
.page-title {
font-size: 18px;
}
.actions-group {
gap: 8px;
}
.actions-group .n-input {
width: 150px !important;
}
.actions-group .n-select {
width: 100px !important;
}
.tab-container :deep(.n-tabs-tab) {
margin-right: 24px;
font-size: 14px;
}
}
@media (max-width: 480px) {
.header-section {
padding: 12px 0;
}
.header-info {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.actions-group {
justify-content: space-around;
}
.actions-group .n-input {
width: 120px !important;
}
.actions-group .n-select {
width: 80px !important;
}
.tab-container :deep(.n-tabs-tab) {
margin-right: 16px;
font-size: 14px;
}
}
</style>