You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/exam/take.vue

56 lines
9.1 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<main class="take-page" v-loading="loading">
<header class="take-head"><div><h1>{{ paper.name }}</h1><p>考试结束时间 {{ formatTime(paper.endTime) }}</p></div><div class="timer" :class="{ urgent: remaining < 300 }"><el-icon><Timer /></el-icon><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">
<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)" /><div class="file-answer"><el-upload :show-file-list="false" :http-request="uploadAnswer" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"><el-button :icon="Upload">上传作答文件</el-button></el-upload><span v-if="files[current.id]">{{ files[current.id].name }}</span></div></template>
<footer class="question-actions"><el-button :icon="ArrowLeft" :disabled="currentIndex===0" @click="move(-1)">上一题</el-button><el-button type="primary" :disabled="currentIndex===paper.questions.length-1" @click="move(1)">下一题<el-icon class="el-icon--right"><ArrowRight /></el-icon></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 { ArrowLeft,ArrowRight,Download,Timer,Upload } from "@element-plus/icons-vue";import * as examApi from "@/api/exam";
const route=useRoute(),router=useRouter(),examId=route.params.id;const loading=ref(false),paper=ref({questions:[]}),currentId=ref(null),remaining=ref(0);const 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/3600)).padStart(2,"0")}:${String(Math.floor(remaining.value%3600/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;paper.value.questions.forEach(q=>{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])}
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 { min-height: calc(100vh - 84px); background: #f3f6f6; color: #20282d; }
.take-head { height: 78px; background: #fff; border-bottom: 1px solid #dce3e5; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 0 28px;
h1 { font-size: 20px; margin: 0; } p { margin: 5px 0 0; color: #6b7280; font-size: 13px; } > button { justify-self: end; }
.timer { display: flex; align-items: center; gap: 8px; color: #526067; strong { font-size: 22px; font-variant-numeric: tabular-nums; color: #165f71; } &.urgent strong { color: #c24138; } }
}
.exam-body { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 22px; padding: 22px; }
.answer-sheet { background: white; border: 1px solid #dce3e5; padding: 18px; align-self: start; position: sticky; top: 16px;
h2 { font-size: 17px; margin: 0 0 18px; } footer { display: flex; gap: 18px; color: #6b7280; font-size: 12px; border-top: 1px solid #e5e7eb; padding-top: 12px; }
footer i { display: inline-block; width: 10px; height: 10px; border: 1px solid #cbd5d8; margin-right: 5px; } footer .answered-dot { background: #dcefeb; border-color: #68a99b; }
}
.sheet-group { margin-bottom: 16px; h3 { font-size: 13px; color: #66737a; margin: 0 0 8px; } div { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; }
button { aspect-ratio: 1; border: 1px solid #ccd5d8; background: white; border-radius: 3px; cursor: pointer; &.answered { background: #dcefeb; border-color: #68a99b; color: #185e70; } &.current { outline: 2px solid #e6a23c; outline-offset: 1px; } }
}
.question-panel { background: #fff; border: 1px solid #dce3e5; min-height: 610px; padding: 26px 34px 84px; position: relative;
> header { display: flex; justify-content: space-between; color: #176b87; border-bottom: 1px solid #edf0f1; padding-bottom: 12px; } > h2 { font-size: 18px; line-height: 1.7; margin: 22px 0; }
.material { display: inline-flex; align-items: center; gap: 6px; color: #176b87; margin-bottom: 16px; } .choice-list { display: grid; gap: 12px; width: 100%; }
:deep(.el-radio), :deep(.el-checkbox) { margin: 0; border: 1px solid #dce3e5; border-radius: 4px; padding: 14px 16px; height: auto; white-space: normal; }
:deep(.is-checked) { background: #eef8f5; border-color: #68a99b; } :deep(.el-radio__label), :deep(.el-checkbox__label) { display: flex; gap: 12px; line-height: 1.5; }
.judgement { grid-template-columns: 1fr 1fr; } .file-answer { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: #176b87; }
.question-actions { position: absolute; bottom: 24px; left: 34px; right: 34px; display: flex; justify-content: space-between; }
}
@media (max-width: 760px) { .take-head { grid-template-columns: 1fr auto; padding: 10px 14px; height: auto; .timer { grid-row: 2; grid-column: 1 / 3; margin-top: 8px; } > button { grid-column: 2; grid-row: 1; } } .exam-body { grid-template-columns: 1fr; padding: 10px; } .answer-sheet { position: static; } .question-panel { padding: 20px 18px 78px; .question-actions { left: 18px; right: 18px; } } }
</style>