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

95 lines
11 KiB
Vue

<template>
<main class="take-page exam-page" v-loading="loading">
<header v-if="current" class="exam-toolbar">
<div class="toolbar-type">{{ typeName[current.type] }}</div>
<div class="toolbar-title">题干({{ typeName[current.type] }}):</div>
<a v-if="current.attachmentUrl" :href="current.attachmentUrl" target="_blank" class="material-button"><el-icon><Download /></el-icon>下载题目资料</a>
<div class="timer" :class="{ urgent: remaining < 300 }">倒计时:{{ timerText }}</div>
<button class="submit-button" @click="confirmSubmit"><el-icon><Promotion /></el-icon>交卷</button>
</header>
<div class="exam-layout">
<aside class="answer-sheet">
<section v-for="group in grouped" :key="group.type" class="sheet-group">
<h2>{{ typeName[group.type] }}</h2>
<div class="sheet-grid"><button v-for="question in group.items" :key="question.id" :class="{ answered: isAnswered(question), current: current?.id === question.id }" @click="select(question.id)">{{ question.order }}</button></div>
</section>
</aside>
<section v-if="current" class="question-panel" @copy="guardCopy" @contextmenu="guardContextMenu">
<p class="question-stem">{{ current.order }}. {{ current.stem }}</p>
<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>
</section>
</div>
<footer v-if="paper.questions?.length" class="exam-navigation"><button class="previous" :disabled="currentIndex === 0" @click="move(-1)">上一题</button><button class="next" :disabled="currentIndex === paper.questions.length - 1" @click="move(1)">下一题</button></footer>
</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, Promotion, Upload } from "@element-plus/icons-vue";
import * as examApi from "@/api/exam";
const route = useRoute(); const router = useRouter(); const examId = route.params.id;
const loading = ref(false); const paper = ref({ questions: [] }); const currentId = ref(null); const remaining = ref(0); const answers = reactive({}); const 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((group) => group.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; 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]); }
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/result/${examId}`); }
async function autoSubmit() { try { await doSubmit(); } catch { router.replace("/examination/index"); } }
</script>
<style scoped lang="scss">
.take-page { min-height: calc(100vh - 104px); margin: 10px auto 32px; padding: 18px 30px 34px; }
.exam-toolbar { display: grid; grid-template-columns: 174px minmax(180px, 1fr) auto auto auto; align-items: center; gap: 18px; min-height: 70px; padding: 0 10px 18px; border-bottom: 1px solid var(--exam-line); }
.toolbar-type, .material-button, .submit-button { display: inline-flex; align-items: center; justify-content: center; min-height: 50px; border-radius: 8px; color: #fff; font-size: 17px; font-weight: 700; }
.toolbar-type { background: #159bd0; }
.toolbar-title { color: var(--exam-text); font-size: 17px; font-weight: 700; text-align: center; }
.material-button { gap: 6px; min-width: 176px; padding: 0 18px; text-decoration: none; background: #159bd0; }
.timer { color: #ff4646; font-size: 20px; font-weight: 800; white-space: nowrap; &.urgent { color: #ff3131; } }
.submit-button { gap: 6px; min-width: 128px; border: 0; cursor: pointer; background: #60bf32; &:hover { background: #4fae24; } }
.exam-layout { display: grid; grid-template-columns: 34% minmax(0, 1fr); gap: 34px; margin-top: 18px; }
.answer-sheet { padding: 0 16px 18px 0; border-right: 1px solid var(--exam-line); }
.sheet-group { margin-bottom: 28px; }
.sheet-group h2 { width: 174px; margin: 0 0 28px; padding: 14px 10px; border-radius: 8px; color: #fff; background: #159bd0; font-size: 18px; text-align: center; }
.sheet-grid { display: grid; grid-template-columns: repeat(8, minmax(38px, 1fr)); gap: 18px 20px; }
.sheet-grid button { min-height: 56px; border: 2px solid rgba(130, 137, 143, .34); border-radius: 3px; color: var(--exam-secondary); background: transparent; font-size: 18px; cursor: pointer; &:hover { border-color: var(--exam-accent); } &.answered { border-color: #3498f4; color: #fff; background: #3b9df4; } &.current { outline: 2px solid var(--exam-current); outline-offset: 2px; } }
.question-panel { min-width: 0; padding: 4px 0 0; }
.question-stem { margin: 0 0 22px; color: var(--exam-text); font-size: 19px; line-height: 1.65; white-space: pre-wrap; }
.choice-list { display: grid; gap: 12px; width: 100%; }
.judgement { grid-template-columns: 1fr 1fr; }
.question-panel :deep(.el-radio), .question-panel :deep(.el-checkbox) { min-height: 42px; margin-right: 0; padding: 10px 14px; border: 1px solid var(--exam-line); border-radius: 4px; color: var(--exam-secondary); background: rgba(4, 24, 39, .45); }
.question-panel :deep(.el-radio.is-checked), .question-panel :deep(.el-checkbox.is-checked) { border-color: #3b9df4; background: rgba(59, 157, 244, .18); }
.question-panel :deep(.el-radio__label), .question-panel :deep(.el-checkbox__label) { color: inherit; }
.question-panel :deep(.el-input__wrapper), .question-panel :deep(.el-textarea__inner) { color: var(--exam-text); background: rgba(3, 20, 34, .58); border-color: var(--exam-line); box-shadow: none; }
.file-answer { display: flex; align-items: center; gap: 14px; margin-top: 26px; color: var(--exam-text); font-size: 17px; }
.file-answer a { color: var(--exam-accent); }
.exam-navigation { display: flex; justify-content: center; gap: 140px; margin: 38px 0 0 34%; }
.exam-navigation button { min-width: 190px; height: 56px; border: 0; border-radius: 8px; color: #fff; font-size: 18px; font-weight: 700; cursor: pointer; &:disabled { opacity: .45; cursor: not-allowed; } }
.exam-navigation .previous { background: #bd7416; }
.exam-navigation .next { background: #159bd0; }
@media (max-width: 1000px) { .exam-toolbar { grid-template-columns: 140px 1fr auto; .toolbar-title { display: none; } .material-button { display: none; } } .exam-layout { grid-template-columns: 1fr; } .answer-sheet { border-right: 0; border-bottom: 1px solid var(--exam-line); padding-right: 0; } .exam-navigation { margin-left: 0; gap: 24px; } }
@media (max-width: 620px) { .take-page { padding: 14px 16px 24px; } .exam-toolbar { grid-template-columns: 1fr 1fr; gap: 10px; } .toolbar-type, .timer, .submit-button { min-height: 42px; font-size: 14px; } .timer { order: 3; } .submit-button { order: 4; } .sheet-grid { grid-template-columns: repeat(5, 1fr); gap: 10px; } .exam-navigation { gap: 12px; } .exam-navigation button { min-width: 0; flex: 1; } }
</style>