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

59 lines
9.9 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 exam-page" v-loading="loading">
<header class="take-head"><div class="head-title"><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></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 type="primary" :icon="Upload">上传作答文件</el-button></el-upload><span v-if="files[current.id]">{{ files[current.id].name }}</span></div></template>
</section>
</div>
<footer v-if="paper.questions?.length" class="exam-actions"><button class="exam-outline-action" :disabled="currentIndex===0" @click="move(-1)"><el-icon><ArrowLeft /></el-icon>上一题</button><button class="exam-primary-action" :disabled="currentIndex===paper.questions.length-1" @click="move(1)">下一题<el-icon><ArrowRight /></el-icon></button></footer>
<div v-if="paper.questions?.length" class="exam-submit"><button class="exam-submit-action" @click="confirmSubmit">交卷</button></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 - 104px); margin: 10px auto 32px; padding: 28px 32px 34px; }
.take-head { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 18px; padding: 0 4px 20px; border-bottom: 1px solid var(--exam-line);
h1 { font-size: 28px; font-weight: 800; line-height: 1.2; margin: 0; letter-spacing: .02em; } p { margin: 8px 0 0; color: var(--exam-muted); font-size: 14px; }
.timer { display: flex; align-items: center; gap: 8px; color: var(--exam-muted); font-size: 14px; strong { font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; color: #7fe3ff; } &.urgent strong { color: #ffb35c; } }
}
.exam-body { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 22px; margin-top: 24px; }
.answer-sheet { background: var(--exam-card); border: 1px solid var(--exam-card-border); border-radius: 14px; padding: 18px; align-self: start; position: sticky; top: 16px;
h2 { font-size: 16px; font-weight: 800; margin: 0 0 18px; } footer { display: flex; gap: 18px; color: var(--exam-muted); font-size: 12px; border-top: 1px solid var(--exam-line); padding-top: 12px; }
footer i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; border: 1px solid rgba(127, 186, 215, .55); margin-right: 5px; vertical-align: -1px; } footer .answered-dot { background: rgba(87, 217, 168, .16); border-color: rgba(87, 217, 168, .55); }
}
.sheet-group { margin-bottom: 16px; &:last-child { margin-bottom: 0; } h3 { font-size: 13px; font-weight: 700; color: var(--exam-muted); margin: 0 0 8px; } div { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; }
button { aspect-ratio: 1; border: 1px solid rgba(127, 186, 215, .35); background: transparent; border-radius: 8px; color: var(--exam-secondary); font-size: 14px; font-weight: 700; cursor: pointer; transition: border-color .2s ease, background .2s ease, transform .2s ease;
&:hover { border-color: rgba(99, 217, 255, .55); transform: translateY(-1px); } &.answered { background: rgba(87, 217, 168, .16); border-color: rgba(87, 217, 168, .55); color: #d9fff0; } &.current { outline: 2px solid var(--exam-current); outline-offset: 1px; } }
}
.question-panel { background: var(--exam-card); border: 1px solid var(--exam-card-border); border-radius: 14px; padding: 24px 28px 28px;
> header { display: flex; justify-content: space-between; align-items: baseline; color: var(--exam-accent); font-size: 12px; font-weight: 800; border-bottom: 1px solid var(--exam-line); padding-bottom: 12px; b { font-size: 14px; color: var(--exam-secondary); } }
> h2 { font-size: 18px; line-height: 1.7; color: var(--exam-text); margin: 22px 0; }
.material { display: inline-flex; align-items: center; gap: 6px; color: var(--exam-accent); margin-bottom: 16px; } .choice-list { display: grid; gap: 8px; width: 100%; }
.judgement { grid-template-columns: 1fr 1fr; } .file-answer { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: var(--exam-secondary); }
}
.exam-actions { display: flex; justify-content: center; gap: 14px; margin-top: 22px; }
.exam-submit { display: flex; justify-content: center; margin-top: 36px; }
@media (max-width: 900px) { .exam-actions { flex-direction: column; gap: 14px; .exam-outline-action, .exam-primary-action { width: 100%; } } }
@media (max-width: 760px) { .take-page { padding: 20px 16px 24px; } .take-head { grid-template-columns: 1fr; padding: 10px 4px 16px; .timer { grid-row: 2; margin-top: 8px; } } .exam-body { grid-template-columns: 1fr; margin-top: 16px; } .answer-sheet { position: static; } .sheet-group div { grid-template-columns: repeat(8, 1fr); } .question-panel { padding: 20px 18px 22px; } .exam-submit { margin-top: 22px; } }
</style>