|
|
|
|
|
<template>
|
|
|
|
|
|
<main class="take-page app-container" v-loading="loading">
|
|
|
|
|
|
<header v-if="current" class="take-head"><div><h1>{{ paper.name || '在线考试' }}</h1><p>考试结束时间 {{ formatTime(paper.endTime) }}</p></div><div class="timer" :class="{ urgent: remaining < 300 }"><span>剩余时间</span><strong>{{ timerText }}</strong></div><el-button type="danger" plain @click="confirmSubmit">交卷</el-button></header>
|
|
|
|
|
|
<div class="exam-body"><aside class="answer-sheet"><h2>答题卡</h2><div v-for="group in grouped" :key="group.type" class="sheet-group"><h3>{{ typeName[group.type] }}</h3><div><button v-for="question in group.items" :key="question.id" :class="{ current:current?.id===question.id,answered:isAnswered(question) }" @click="select(question.id)">{{ question.order }}</button></div></div><footer><span><i class="answered-dot" />已答</span><span><i />未答</span></footer></aside><section v-if="current" class="question-panel" @copy="guardCopy" @contextmenu="guardContextMenu"><header><span>{{ typeName[current.type] }}</span><b>{{ current.score }} 分</b></header><h2>{{ current.order }}. {{ current.stem }}</h2><a v-if="current.attachmentUrl" :href="current.attachmentUrl" target="_blank" class="material"><el-icon><Download /></el-icon>下载题目资料 {{ current.attachmentName }}</a><el-radio-group v-if="current.type==='SINGLE'" v-model="answers[current.id]" class="choice-list" @change="persist(current)"><el-radio v-for="option in current.options" :key="option.label" :label="option.label"><b>{{ option.label }}</b>{{ option.content }}</el-radio></el-radio-group><el-checkbox-group v-else-if="current.type==='MULTIPLE'" v-model="answers[current.id]" class="choice-list" @change="persist(current)"><el-checkbox v-for="option in current.options" :key="option.label" :label="option.label"><b>{{ option.label }}</b>{{ option.content }}</el-checkbox></el-checkbox-group><el-radio-group v-else-if="current.type==='TRUE_FALSE'" v-model="answers[current.id]" class="choice-list judgement" @change="persist(current)"><el-radio :label="true"><b>✓</b>正确</el-radio><el-radio :label="false"><b>×</b>错误</el-radio></el-radio-group><template v-else><el-input v-model="answers[current.id]" type="textarea" :rows="10" placeholder="请输入答案" @blur="persist(current)" @paste="guardPaste" /><div class="file-answer"><span>作答文件:</span><el-upload :show-file-list="false" :http-request="uploadAnswer" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"><el-button type="primary" :icon="Upload">上传</el-button></el-upload><a v-if="files[current.id]" :href="files[current.id].url" target="_blank">{{ files[current.id].name }}</a></div></template><footer class="question-actions"><el-button :disabled="currentIndex===0" @click="move(-1)">上一题</el-button><el-button type="primary" :disabled="currentIndex===paper.questions.length-1" @click="move(1)">下一题</el-button></footer></section></div>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { computed,onBeforeUnmount,onMounted,reactive,ref } from "vue"; import { useRoute,useRouter } from "vue-router"; import { ElMessage,ElMessageBox } from "element-plus"; import { Download,Upload } from "@element-plus/icons-vue"; import * as examApi from "@/api/exam";
|
|
|
|
|
|
const route=useRoute(),router=useRouter(),examId=route.params.id,loading=ref(false),paper=ref({questions:[]}),currentId=ref(null),remaining=ref(0),answers=reactive({}),files=reactive({});let ticker; const typeName={SINGLE:"单选题",MULTIPLE:"多选题",TRUE_FALSE:"判断题",SHORT_ANSWER:"简答题"}; const current=computed(()=>paper.value.questions.find(q=>q.id===currentId.value)); const currentIndex=computed(()=>paper.value.questions.findIndex(q=>q.id===currentId.value)); const grouped=computed(()=>Object.keys(typeName).map(type=>({type,items:paper.value.questions.filter(q=>q.type===type)})).filter(g=>g.items.length)); const timerText=computed(()=>`${String(Math.floor(remaining.value/60)).padStart(2,"0")} 分钟 ${String(remaining.value%60).padStart(2,"0")} 秒`); onMounted(load); onBeforeUnmount(()=>clearInterval(ticker));
|
|
|
|
|
|
async function load(){loading.value=true;try{const token=deviceToken();paper.value=(await examApi.getActivePaper(examId,token)).data;const typeOrder={SINGLE:0,MULTIPLE:1,TRUE_FALSE:2,SHORT_ANSWER:3};paper.value.questions.sort((a,b)=>(typeOrder[a.type]??9)-(typeOrder[b.type]??9));paper.value.questions.forEach((q,i)=>{q.order=i+1;answers[q.id]=q.studentAnswer??(q.type==="MULTIPLE"?[]:"");if(q.answerFileUrl)files[q.id]={name:q.answerFileName,url:q.answerFileUrl}});currentId.value=paper.value.questions[0]?.id;remaining.value=Math.max(0,Math.floor((new Date(paper.value.endTime)-Date.now())/1000));ticker=setInterval(()=>{remaining.value=Math.max(0,remaining.value-1);if(remaining.value===0){clearInterval(ticker);autoSubmit()}},1000)}finally{loading.value=false}} function deviceToken(){return localStorage.getItem(`exam-device-${examId}`)||""} function select(id){currentId.value=id} function move(step){currentId.value=paper.value.questions[currentIndex.value+step]?.id} function isAnswered(q){const answer=answers[q.id];return(Array.isArray(answer)?answer.length:Boolean(String(answer??"").trim()))||Boolean(files[q.id])} function guardCopy(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault();ElMessage.warning("该考试不允许复制试题")} function guardContextMenu(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault()} function guardPaste(event){if(!paper.value.allowPasteAnswer){event.preventDefault();ElMessage.warning("该考试不允许粘贴答案")}} async function persist(q){await examApi.saveExamAnswer(examId,q.id,{deviceToken:deviceToken(),answer:answers[q.id]})} async function uploadAnswer({file}){const form=new FormData();form.append("file",file);const data=(await examApi.uploadExamAnswerFile(examId,current.value.id,deviceToken(),form)).data;files[current.value.id]={name:data.name,url:data.url};ElMessage.success("作答文件已上传")} async function confirmSubmit(){const unanswered=paper.value.questions.filter(q=>!isAnswered(q)).length;await ElMessageBox.confirm(unanswered?`还有 ${unanswered} 道题未作答,确认交卷?`:"确认提交试卷?","确认交卷",{type:"warning",confirmButtonText:"确认交卷"});await doSubmit()} async function doSubmit(){await examApi.submitExam(examId,deviceToken());ElMessage.success("交卷成功");router.replace("/examination/index")} async function autoSubmit(){try{await doSubmit()}catch{router.replace("/examination/index")}} function formatTime(v){return v?new Date(v).toLocaleString("zh-CN",{hour12:false}).replaceAll("/","-"):"—"}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.take-page{--accent:#2563eb;--border:#e5e7eb;--muted:#6b7280;min-height:calc(100vh - 84px);background:#ffffff;color:#1f2937}.take-head{height:72px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 28px;border-bottom:1px solid var(--border);background:#fff}.take-head h1{margin:0;font-size:17px}.take-head p{margin:4px 0 0;color:var(--muted);font-size:12px}.take-head>button{justify-self:end}.timer{display:grid;gap:2px;text-align:center;color:var(--muted);font-size:12px}.timer strong{color:var(--accent);font-size:18px;font-variant-numeric:tabular-nums}.timer.urgent strong{color:#c2413b}.exam-body{display:grid;grid-template-columns:250px minmax(0,1fr);gap:20px;padding:20px 22px}.answer-sheet,.question-panel{border:1px solid var(--border);border-radius:14px;background:#fff}.answer-sheet{align-self:start;padding:16px;position:sticky;top:16px}.answer-sheet h2{margin:0 0 14px;font-size:14px}.sheet-group{margin-bottom:18px}.sheet-group h3{margin:0 0 10px;color:var(--muted);font-size:12px}.sheet-group>div{display:grid;grid-template-columns:repeat(5,1fr);gap:7px}.sheet-group button{aspect-ratio:1;border:1px solid var(--border);border-radius:6px;background:#fff;color:var(--muted);cursor:pointer}.sheet-group button.answered{border-color:#2563eb;color:#2563eb;background:#eff6ff}.sheet-group button.current{outline:2px solid var(--accent);outline-offset:2px}.answer-sheet footer{display:flex;gap:14px;color:var(--muted);font-size:11px}.answer-sheet i{display:inline-block;width:8px;height:8px;margin-right:4px;border:1px solid #d1d5db;border-radius:50%}.answer-sheet i.answered-dot{border-color:#2563eb;background:#2563eb}.question-panel{min-height:610px;padding:22px 28px 80px;position:relative}.question-panel>header{display:flex;justify-content:space-between;color:var(--muted);font-size:13px}.question-panel>header b{color:var(--accent)}.question-panel h2{margin:18px 0;color:#1f2937;font-size:17px;line-height:1.65}.material{display:inline-flex;gap:6px;margin-bottom:16px;color:var(--accent);font-size:12px;text-decoration:none}.choice-list{display:grid;gap:10px;width:100%}.question-panel:deep(.el-radio),.question-panel:deep(.el-checkbox){height:auto;margin:0;padding:13px 16px;border:1px solid var(--border);border-radius:8px;background:#fff}.question-panel:deep(.el-radio:hover),.question-panel:deep(.el-checkbox:hover),.question-panel:deep(.is-checked){border-color:var(--accent);background:#eff6ff}.question-panel:deep(.el-radio__label),.question-panel:deep(.el-checkbox__label){display:flex;gap:12px;color:#1f2937;font-size:13px;line-height:1.55}.judgement{grid-template-columns:1fr 1fr}.question-panel:deep(.el-textarea__inner){border-color:var(--border);border-radius:8px;box-shadow:none}.file-answer{display:flex;align-items:center;gap:12px;margin-top:12px;color:var(--muted);font-size:12px}.file-answer a{color:var(--accent)}.question-actions{position:absolute;right:28px;bottom:24px;left:28px;display:flex;justify-content:space-between}.take-page:deep(.el-button){height:40px;border-radius:10px;font-weight:700}.take-page:deep(.el-button--primary){border-color:var(--accent);background:var(--accent)}.take-page:deep(.el-button--danger.is-plain){border-color:rgba(217,85,76,.45);background:#fff;color:#d9554c}@media(max-width:760px){.take-head{grid-template-columns:1fr auto;height:auto;padding:10px 14px}.timer{grid-column:1/3;margin-top:8px}.exam-body{grid-template-columns:1fr;padding:12px}.answer-sheet{position:static}.question-panel{padding:20px 18px 78px}.question-actions{right:18px;left:18px}}
|
|
|
|
|
|
</style>
|