< template >
< main class = "exam-editor app-container" v-loading ="loading" >
< header class = "editor-head" >
< el -button text :icon ="ArrowLeft" @click ="$router.push('/exam/index')" > 返 回 考 评 管 理 < / el -button >
< div class = "title-block" > < h1 > { { isNew ? '新建考试' : form . name || '编辑考试' } } < / h1 > < el -tag v-if ="!isNew" :type="statusMeta[status]?.tone" > {{ statusMeta [ status ] ? .label }} < / el -tag > < / div >
< div > < el -button @click ="save(false)" > {{ inProgress ? ' 保 存 结 束 时 间 ' : ' 保 存 ' }} < / el -button > < el -button v-if ="status === 'DRAFT'" type="primary" @click="save(true)" > 保 存 并 发 布 < / el -button > < / div >
< / header >
< el -steps v-if ="!inProgress" :active="activeStep" align-center class="steps" >
< el -step title = "基础设置" / > < el -step title = "选择试题" / > < el -step title = "试卷概览" / >
< / e l - s t e p s >
< section v-if ="activeStep === 0" class="editor-panel" >
< h2 > 基础设置 < / h2 >
< el -form ref = "formRef" :model ="form" :rules ="rules" label -width = " 110px " class = "base-form" >
< el -form -item label = "考试名称" prop = "name" > < el -input v -model = " form.name " maxlength = "20" show -word -limit :disabled ="inProgress" / > < / e l - f o r m - i t e m >
< el -form -item label = "开始时间" prop = "startTime" > < el -date -picker v -model = " form.startTime " type = "datetime" value -format = " YYYY -MM -DD HH : mm : ss " :disabled ="inProgress" / > < / e l - f o r m - i t e m >
< el -form -item label = "考试时长" > < el -input -number v-model ="duration" :min="1" :max="1440" @change="syncEndTime" /><span class="unit" > 分 钟 < / span > < / el -form -item >
< el -form -item label = "结束时间" prop = "endTime" > < el -date -picker v -model = " form.endTime " type = "datetime" value -format = " YYYY -MM -DD HH : mm : ss " / > < / e l - f o r m - i t e m >
< el -form -item label = "考试班级" prop = "classIds" >
< el -select v-model ="form.classIds" multiple collapse-tags placeholder="选择教师名下的教学班" :disabled ="inProgress" > < el -option v -for = " item in classes " :key ="item.id" :label ="item.name" :value ="item.id" / > < / e l - s e l e c t >
< / e l - f o r m - i t e m >
< el -form -item label = "考试回退" > < el -switch v -model = " form.allowRollback " active -text = " 允 许 教 师 将 已 交 卷 学 生 回 退 " :disabled ="inProgress" / > < / e l - f o r m - i t e m >
< / e l - f o r m >
< / section >
< section v-if ="!inProgress && activeStep === 1" class="editor-panel question-stage" >
< header class = "section-head" > < div > < h2 > 选择试题 < / h2 > < p > 勾选题目并设置该题分值 , 选择结果实时同步到试卷概览 < / p > < / div >
< div class = "import-actions" > < el -button :icon ="Download" @click ="downloadTemplate" > 导 入 模 板 < / el -button > < el -upload :show-file-list ="false" accept = ".docx" :http-request ="importPaperQuestions" > < el -button type = "primary" plain :icon ="Upload" > 导入 Word 试题 < / e l - b u t t o n > < / e l - u p l o a d > < / d i v >
< / header >
< div class = "question-layout" >
< aside class = "type-nav" >
< button v-for ="item in typeOptions" :key="item.value" :class="{ active: activeType === item.value }" @click="activeType=item.value" >
< span > { { item . label } } < / span > < b > { { selectedByType ( item . value ) } } / { { questionCount ( item . value ) } } < / b >
< / button >
< / aside >
< div class = "question-list" >
< article v-for ="question in filteredQuestions" :key="question.id" :class="['question-item', { selected: isSelected(question.id) }]" >
< el -checkbox :model-value ="isSelected(question.id)" @ change = "toggleQuestion(question, $event)" / >
< div class = "question-body" > < h3 > { { question . stem } } < / h3 > < div v-if ="question.options?.length" class="options"><span v-for="option in question.options" :key="option.label" > {{ option.label }} . {{ option.content }} < / span > < / div > < / div >
< div v-if ="isSelected(question.id)" class="score-input" > < el -input -number :model-value ="selectedScore(question.id)" :min ="0.5" :precision ="1" :step ="0.5" controls -position = " right " @ change = "setScore(question.id, $event)" / > < span > 分 < / span > < / div >
< / article >
< el -empty v -if = " ! filteredQuestions.length " description = "该题型暂无题目" / >
< / div >
< / div >
< / section >
< section v-if ="!inProgress && activeStep === 2" class="editor-panel preview-stage" >
< header class = "section-head" > < div > < h2 > 试卷概览 < / h2 > < p > { { form . questions . length } } 道题 , 共 { { totalScore } } 分 < / p > < / div > < el -button :icon ="Download" @click ="exportPaper" :disabled ="isNew" > 导出 Word 试卷 < / e l - b u t t o n > < / h e a d e r >
< div class = "score-summary" >
< div v-for ="item in typeOptions" :key="item.value" > < span > { { item . label } } < / span > < strong > { { selectedByType ( item . value ) } } < / strong > < small > { { scoreByType ( item . value ) } } 分 < / small > < / div >
< div class = "total" > < span > 总分 < / span > < strong > { { totalScore } } < / strong > < small > 自动合计 < / small > < / div >
< / div >
< nav class = "anchor-nav" > < button v-for ="item in selectedTypes" :key="item.value" @click="scrollToType(item.value)" > {{ item.label }} < / button > < / nav >
< section v-for ="item in selectedTypes" :id="`paper-${item.value}`" :key="item.value" class="paper-section" >
< h3 > { { item . label } } < / h3 >
< article v-for ="(question, index) in selectedQuestions(item.value)" :key="question.id" class="paper-question" >
< el -checkbox :model-value ="true" @ change = "toggleQuestion(question, false)" / > < div > < h4 > { { index + 1 } } . { { question . stem } } < b > { { selectedScore ( question . id ) } } 分 < / b > < / h4 > < p v-for ="option in question.options" :key="option.label" > {{ option.label }} . {{ option.content }} < / p > < / div >
< / article >
< / section >
< el -empty v -if = " ! form.questions.length " description = "尚未选择试题" / >
< / section >
< footer v-if ="!inProgress" class="editor-footer"><el-button v-if="activeStep > 0" :icon="ArrowLeft" @click="activeStep--">上一步</el-button><el-button v-if="activeStep < 2" type="primary" @click="nextStep">下一步<el-icon class="el-icon--right" > < ArrowRight / > < / e l - i c o n > < / e l - b u t t o n > < / f o o t e r >
< / main >
< / template >
< script setup >
import { computed , onMounted , reactive , ref } from "vue" ;
import { useRoute , useRouter } from "vue-router" ;
import { ElMessage } from "element-plus" ;
import { ArrowLeft , ArrowRight , Download , Upload } from "@element-plus/icons-vue" ;
import { saveAs } from "file-saver" ;
import * as examApi from "@/api/exam" ;
const route = useRoute ( ) ; const router = useRouter ( ) ; const id = computed ( ( ) => route . params . id ) ; const isNew = computed ( ( ) => id . value === "new" ) ;
const loading = ref ( false ) ; const activeStep = ref ( 0 ) ; const activeType = ref ( "SINGLE" ) ; const status = ref ( "DRAFT" ) ; const classes = ref ( [ ] ) ; const bank = ref ( [ ] ) ; const formRef = ref ( ) ; const duration = ref ( 60 ) ;
const typeOptions = [ { label : "单选题" , value : "SINGLE" } , { label : "多选题" , value : "MULTIPLE" } , { label : "判断题" , value : "TRUE_FALSE" } , { label : "简答题" , value : "SHORT_ANSWER" } ] ;
const statusMeta = { DRAFT : { label : "未发布" , tone : "info" } , NOT _STARTED : { label : "未开始" , tone : "" } , IN _PROGRESS : { label : "进行中" , tone : "success" } , ENDED : { label : "已结束" , tone : "warning" } } ;
const form = reactive ( { name : "" , startTime : "" , endTime : "" , classIds : [ ] , allowRollback : true , questions : [ ] } ) ;
const rules = { name : [ { required : true , message : "请输入考试名称" } , { pattern : /^[\p{L}\p{N}]+$/u , message : "仅支持汉字、字母和数字" } ] , startTime : [ { required : true , message : "请选择开始时间" } ] , endTime : [ { required : true , message : "请选择结束时间" } ] , classIds : [ { type : "array" , required : true , min : 1 , message : "至少选择一个班级" } ] } ;
const inProgress = computed ( ( ) => status . value === "IN_PROGRESS" ) ;
const filteredQuestions = computed ( ( ) => bank . value . filter ( ( item ) => item . type === activeType . value ) ) ;
const totalScore = computed ( ( ) => form . questions . reduce ( ( sum , item ) => sum + Number ( item . score || 0 ) , 0 ) ) ;
const selectedTypes = computed ( ( ) => typeOptions . filter ( ( item ) => selectedByType ( item . value ) ) ) ;
onMounted ( load ) ;
async function load ( ) { loading . value = true ; try { const [ classRes , questionRes ] = await Promise . all ( [ examApi . getExamClasses ( ) , examApi . getQuestions ( { } ) ] ) ; classes . value = classRes . data || [ ] ; bank . value = questionRes . data || [ ] ; if ( ! isNew . value ) { const { data } = await examApi . getTeacherExam ( id . value ) ; status . value = data . status ; const bankIds = new Set ( bank . value . map ( ( item ) => item . id ) ) ; bank . value = [ ... bank . value , ... data . questions . filter ( ( item ) => ! bankIds . has ( item . id ) ) ] ; Object . assign ( form , { name : data . name , startTime : formatValue ( data . startTime ) , endTime : formatValue ( data . endTime ) , classIds : data . classes . map ( ( item ) => item . id ) , allowRollback : data . allowRollback , questions : data . questions . map ( ( item ) => ( { questionId : item . id , score : Number ( item . score ) } ) ) } ) ; duration . value = data . durationMinutes ; } else { const start = new Date ( Date . now ( ) + 3600000 ) ; form . startTime = formatValue ( start ) ; form . endTime = formatValue ( new Date ( start . getTime ( ) + 3600000 ) ) ; } } finally { loading . value = false ; } }
function syncEndTime ( ) { if ( ! form . startTime ) return ; form . endTime = formatValue ( new Date ( new Date ( form . startTime . replace ( " " , "T" ) ) . getTime ( ) + duration . value * 60000 ) ) ; }
function isSelected ( questionId ) { return form . questions . some ( ( item ) => item . questionId === questionId ) ; }
function selectedScore ( questionId ) { return form . questions . find ( ( item ) => item . questionId === questionId ) ? . score || 1 ; }
function toggleQuestion ( question , checked ) { if ( checked ) form . questions . push ( { questionId : question . id , score : 1 } ) ; else form . questions = form . questions . filter ( ( item ) => item . questionId !== question . id ) ; }
function setScore ( questionId , score ) { const item = form . questions . find ( ( row ) => row . questionId === questionId ) ; if ( item ) item . score = Number ( score ) ; }
function selectedByType ( type ) { const ids = new Set ( bank . value . filter ( ( item ) => item . type === type ) . map ( ( item ) => item . id ) ) ; return form . questions . filter ( ( item ) => ids . has ( item . questionId ) ) . length ; }
function questionCount ( type ) { return bank . value . filter ( ( item ) => item . type === type ) . length ; }
function scoreByType ( type ) { return selectedQuestions ( type ) . reduce ( ( sum , item ) => sum + Number ( selectedScore ( item . id ) ) , 0 ) ; }
function selectedQuestions ( type ) { const selected = new Set ( form . questions . map ( ( item ) => item . questionId ) ) ; return bank . value . filter ( ( item ) => item . type === type && selected . has ( item . id ) ) ; }
async function nextStep ( ) { if ( activeStep . value === 0 ) { try { await formRef . value . validate ( ) ; const start = new Date ( form . startTime . replace ( " " , "T" ) ) ; const end = new Date ( form . endTime . replace ( " " , "T" ) ) ; if ( end <= start ) return ElMessage . warning ( "结束时间必须晚于开始时间" ) ; duration . value = Math . round ( ( end - start ) / 60000 ) ; } catch { return ; } } if ( activeStep . value === 1 && ! form . questions . length ) return ElMessage . warning ( "至少选择一道试题" ) ; activeStep . value ++ ; }
async function save ( publish ) { if ( inProgress . value ) { await examApi . updateExam ( id . value , form ) ; ElMessage . success ( "结束时间已更新" ) ; return router . push ( "/exam/index" ) ; } try { await formRef . value ? . validate ( ) ; } catch { activeStep . value = 0 ; return ; } if ( ! form . questions . length ) { activeStep . value = 1 ; return ElMessage . warning ( "至少选择一道试题" ) ; } const result = isNew . value ? await examApi . createExam ( form ) : await examApi . updateExam ( id . value , form ) ; const examId = result . data . id ; if ( publish ) await examApi . publishExam ( examId ) ; ElMessage . success ( publish ? "考试已保存并发布" : "考试已保存" ) ; router . push ( "/exam/index" ) ; }
async function importPaperQuestions ( { file } ) { const formData = new FormData ( ) ; formData . append ( "file" , file ) ; const { data } = await examApi . importQuestions ( formData ) ; bank . value = [ ... data , ... bank . value ] ; for ( const q of data ) if ( ! isSelected ( q . id ) ) form . questions . push ( { questionId : q . id , score : 1 } ) ; ElMessage . success ( ` 导入并选中 ${ data . length } 道题 ` ) ; }
async function downloadTemplate ( ) { saveAs ( await examApi . downloadQuestionTemplate ( ) , "考试题库导入模板.docx" ) ; }
async function exportPaper ( ) { saveAs ( await examApi . exportExamPaper ( id . value ) , ` ${ form . name } 试卷.docx ` ) ; }
function scrollToType ( type ) { document . getElementById ( ` paper- ${ type } ` ) ? . scrollIntoView ( { behavior : "smooth" , block : "start" } ) ; }
function formatValue ( value ) { const date = value instanceof Date ? value : new Date ( value ) ; const pad = ( n ) => String ( n ) . padStart ( 2 , "0" ) ; return ` ${ date . getFullYear ( ) } - ${ pad ( date . getMonth ( ) + 1 ) } - ${ pad ( date . getDate ( ) ) } ${ pad ( date . getHours ( ) ) } : ${ pad ( date . getMinutes ( ) ) } : ${ pad ( date . getSeconds ( ) ) } ` ; }
< / script >
< style scoped lang = "scss" >
. exam - editor { color : # 1 f2937 ; padding - bottom : 84 px ; }
. editor - head { display : grid ; grid - template - columns : 1 fr auto 1 fr ; align - items : center ; border - bottom : 1 px solid # e5e7eb ; padding - bottom : 14 px ; > : last - child { justify - self : end ; } }
. title - block { display : flex ; align - items : center ; gap : 10 px ; h1 { margin : 0 ; font - size : 23 px ; letter - spacing : 0 ; } }
. steps { max - width : 760 px ; margin : 28 px auto ; }
. editor - panel { width : 100 % ; max - width : none ; margin : auto ; h2 { font - size : 19 px ; margin : 0 0 22 px ; } }
. base - form { max - width : 650 px ; . el - input , . el - select { width : 100 % ; } . el - date - editor { width : 100 % ; } }
. unit { margin - left : 8 px ; color : # 6 b7280 ; }
. section - head { display : flex ; align - items : center ; justify - content : space - between ; margin - bottom : 18 px ; h2 { margin : 0 ; } p { margin : 6 px 0 0 ; color : # 6 b7280 ; } }
. import - actions { display : flex ; gap : 8 px ; }
. question - layout { display : grid ; grid - template - columns : 170 px minmax ( 0 , 1 fr ) ; gap : 20 px ; }
. type - nav { border - right : 1 px solid # e5e7eb ; padding - right : 14 px ; button { width : 100 % ; border : 0 ; background : transparent ; padding : 12 px ; display : flex ; justify - content : space - between ; color : # 4 b5563 ; cursor : pointer ; border - radius : 4 px ; & . active { color : # 0 f5d72 ; background : # eaf5f7 ; } b { font - weight : 600 ; } } }
. question - list { min - width : 0 ; }
. question - item { display : grid ; grid - template - columns : 28 px 1 fr auto ; gap : 8 px ; padding : 16 px 12 px ; border - bottom : 1 px solid # e5e7eb ; & . selected { background : # f3fafb ; } h3 { font - size : 15 px ; line - height : 1.6 ; margin : 0 ; font - weight : 600 ; } }
. options { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 6 px 20 px ; color : # 6 b7280 ; margin - top : 9 px ; }
. score - input { display : flex ; align - items : center ; gap : 6 px ; . el - input - number { width : 104 px ; } }
. score - summary { display : grid ; grid - template - columns : repeat ( 5 , 1 fr ) ; border : 1 px solid # d9e1e5 ; margin - bottom : 18 px ; > div { padding : 16 px ; border - right : 1 px solid # d9e1e5 ; display : grid ; gap : 5 px ; & : last - child { border - right : 0 ; } span , small { color : # 6 b7280 ; } strong { font - size : 24 px ; color : # 154 f5f ; } } . total { background : # edf7f4 ; } }
. anchor - nav { display : flex ; gap : 8 px ; margin : 18 px 0 ; button { border : 1 px solid # b9cdd3 ; background : white ; color : # 176 b87 ; padding : 7 px 14 px ; border - radius : 4 px ; cursor : pointer ; } }
. paper - section { scroll - margin - top : 20 px ; border - top : 2 px solid # 1 f6f78 ; padding - top : 16 px ; margin - top : 26 px ; h3 { font - size : 17 px ; } }
. paper - question { display : grid ; grid - template - columns : 26 px 1 fr ; padding : 12 px 0 ; border - bottom : 1 px solid # eef1f3 ; h4 { margin : 0 0 8 px ; line - height : 1.6 ; font - weight : 500 ; b { float : right ; color : # 176 b87 ; } } p { margin : 4 px 0 ; color : # 4 b5563 ; } }
. editor - footer { position : fixed ; bottom : 0 ; left : 0 ; right : 0 ; height : 64 px ; display : flex ; justify - content : center ; align - items : center ; gap : 12 px ; background : rgba ( 255 , 255 , 255 , .96 ) ; border - top : 1 px solid # e5e7eb ; z - index : 10 ; }
@ media ( max - width : 760 px ) { . editor - head { grid - template - columns : 1 fr ; gap : 12 px ; . title - block { grid - row : 1 ; } > : last - child { justify - self : start ; } } . question - layout { grid - template - columns : 1 fr ; } . type - nav { display : flex ; overflow - x : auto ; border - right : 0 ; border - bottom : 1 px solid # e5e7eb ; } . score - summary { grid - template - columns : 1 fr 1 fr ; } . section - head { align - items : flex - start ; flex - direction : column ; gap : 12 px ; } }
< / style >
< style lang = "scss" >
# app . teacherLayout . editor - footer { left : 304 px ; }
# app . teacherLayout . hideSidebar . editor - footer { left : 64 px ; }
# app . mobile . teacherLayout . editor - footer { left : 0 ; }
< / style >