| | |
| | | <script lang="ts" setup> |
| | | import type { OnlineExamPaper, OnlineExamQuestionItem } from './types'; |
| | | import type { OnlineExamPaper, OnlineExamQuestionItem, OnlineExamSubmitResult } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { Modal } from 'ant-design-vue'; |
| | | import { AppstoreOutlined } from '@ant-design/icons-vue'; |
| | | |
| | | import { submitOnlineExam } from '#/api/x/tms/onlineExam'; |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | import { saveOnlineExam, submitOnlineExam } from '#/api/x/tms/onlineExam'; |
| | | import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | defineOptions({ name: 'TmsOnlineExamTake' }); |
| | | |
| | |
| | | const submitted = ref(false); |
| | | const scoreText = ref(''); |
| | | const submitting = ref(false); |
| | | const saving = ref(false); |
| | | const remainSeconds = ref<number | null>(null); |
| | | const autoSubmitting = ref(false); |
| | | const resultModalOpen = ref(false); |
| | | const submitResult = ref<OnlineExamSubmitResult | null>(null); |
| | | const resultFilter = ref<'all' | 'wrong'>('all'); |
| | | const sheetOpen = ref(false); |
| | | |
| | | let timer: ReturnType<typeof setInterval> | null = null; |
| | | |
| | | const current = computed(() => paper.value?.questions?.[currentIndex.value]); |
| | | const total = computed(() => paper.value?.questions?.length || 0); |
| | | const countdownText = computed(() => formatRemain(remainSeconds.value)); |
| | | const countdownUrgent = computed( |
| | | () => remainSeconds.value != null && remainSeconds.value > 0 && remainSeconds.value <= 5 * 60, |
| | | ); |
| | | |
| | | onMounted(() => { |
| | | const rightCount = computed( |
| | | () => paper.value?.questions?.filter((q) => isObjective(q) && q.right === true).length || 0, |
| | | ); |
| | | const wrongCount = computed( |
| | | () => paper.value?.questions?.filter((q) => isObjective(q) && q.right === false).length || 0, |
| | | ); |
| | | const objectiveCount = computed(() => rightCount.value + wrongCount.value); |
| | | const scoreRate = computed(() => { |
| | | if (!objectiveCount.value) return 0; |
| | | return Math.round((rightCount.value * 10000) / objectiveCount.value) / 100; |
| | | }); |
| | | const resultQuestions = computed(() => { |
| | | const list = paper.value?.questions || []; |
| | | if (resultFilter.value === 'wrong') { |
| | | return list.filter((q) => isObjective(q) && q.right === false); |
| | | } |
| | | return list; |
| | | }); |
| | | const answeredCount = computed( |
| | | () => (paper.value?.questions || []).filter((q) => isAnswered(q)).length, |
| | | ); |
| | | |
| | | onMounted(async () => { |
| | | await loadQuestionDics(); |
| | | const raw = sessionStorage.getItem('tms_online_exam_paper'); |
| | | if (!raw) { |
| | | createMessage.warning('请先从试卷列表选择考试'); |
| | |
| | | return; |
| | | } |
| | | try { |
| | | paper.value = JSON.parse(raw); |
| | | const parsed = JSON.parse(raw) as OnlineExamPaper; |
| | | paper.value = parsed; |
| | | restoreAnswers(parsed); |
| | | initCountdown(parsed); |
| | | } catch { |
| | | router.replace('/tms/onlineExam'); |
| | | } |
| | | }); |
| | | |
| | | onUnmounted(() => { |
| | | stopCountdown(); |
| | | resultModalOpen.value = false; |
| | | }); |
| | | |
| | | function isObjective(q: OnlineExamQuestionItem) { |
| | | return !(q.subjective || q.questionType === 'essay'); |
| | | } |
| | | |
| | | function isAnswered(q: OnlineExamQuestionItem) { |
| | | const val = answers[q.id]; |
| | | if (Array.isArray(val)) return val.length > 0; |
| | | return String(val ?? '').trim().length > 0; |
| | | } |
| | | |
| | | function restoreAnswers(data: OnlineExamPaper) { |
| | | Object.keys(answers).forEach((key) => { |
| | | delete answers[key]; |
| | | }); |
| | | for (const q of data.questions || []) { |
| | | if (q.questionType === 'multi') { |
| | | answers[q.id] = q.userAnswer ? q.userAnswer.split(',').filter(Boolean) : []; |
| | | } else { |
| | | answers[q.id] = q.userAnswer || ''; |
| | | } |
| | | } |
| | | } |
| | | |
| | | function parseStartMs(startTime?: string) { |
| | | if (!startTime) return NaN; |
| | | const normalized = startTime.includes('T') ? startTime : startTime.replace(/-/g, '/'); |
| | | return new Date(normalized).getTime(); |
| | | } |
| | | |
| | | function calcRemain(data: OnlineExamPaper) { |
| | | if (data.durationMin == null || data.durationMin <= 0) return null; |
| | | const startMs = parseStartMs(data.startTime); |
| | | if (!Number.isNaN(startMs)) { |
| | | const endMs = startMs + data.durationMin * 60 * 1000; |
| | | return Math.max(0, Math.floor((endMs - Date.now()) / 1000)); |
| | | } |
| | | if (data.remainSeconds != null) return Math.max(0, Number(data.remainSeconds)); |
| | | return null; |
| | | } |
| | | |
| | | function initCountdown(data: OnlineExamPaper) { |
| | | stopCountdown(); |
| | | const remain = calcRemain(data); |
| | | remainSeconds.value = remain; |
| | | if (remain == null) return; |
| | | if (remain <= 0) { |
| | | void doSubmit({ auto: true }); |
| | | return; |
| | | } |
| | | timer = setInterval(() => { |
| | | if (submitted.value || submitting.value || autoSubmitting.value) { |
| | | stopCountdown(); |
| | | return; |
| | | } |
| | | const next = calcRemain(paper.value!); |
| | | if (next == null) { |
| | | remainSeconds.value = null; |
| | | stopCountdown(); |
| | | return; |
| | | } |
| | | remainSeconds.value = next; |
| | | if (next <= 0) { |
| | | stopCountdown(); |
| | | void doSubmit({ auto: true }); |
| | | } |
| | | }, 1000); |
| | | } |
| | | |
| | | function stopCountdown() { |
| | | if (timer) { |
| | | clearInterval(timer); |
| | | timer = null; |
| | | } |
| | | } |
| | | |
| | | function formatRemain(sec: number | null) { |
| | | if (sec == null) return ''; |
| | | const s = Math.max(0, sec); |
| | | const h = Math.floor(s / 3600); |
| | | const m = Math.floor((s % 3600) / 60); |
| | | const r = s % 60; |
| | | const mm = String(m).padStart(2, '0'); |
| | | const ss = String(r).padStart(2, '0'); |
| | | if (h > 0) return `${String(h).padStart(2, '0')}:${mm}:${ss}`; |
| | | return `${mm}:${ss}`; |
| | | } |
| | | |
| | | function stripHtml(html?: string) { |
| | | if (!html) return ''; |
| | | return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); |
| | | } |
| | | |
| | | function goPrev() { |
| | | if (currentIndex.value > 0) currentIndex.value -= 1; |
| | | } |
| | | |
| | | function goNext() { |
| | | if (currentIndex.value < total.value - 1) currentIndex.value += 1; |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/onlineExam'); |
| | | } |
| | | |
| | | function isCorrect(q: OnlineExamQuestionItem): boolean { |
| | | const ans = answers[q.id]; |
| | | const correctLabels = q.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel); |
| | | if (q.questionType === 'multi') { |
| | | const selected = Array.isArray(ans) ? [...ans].sort() : []; |
| | | return selected.join(',') === [...correctLabels].sort().join(','); |
| | | async function persistAnswers() { |
| | | if (!paper.value || submitted.value || saving.value) return; |
| | | saving.value = true; |
| | | try { |
| | | await saveOnlineExam(paper.value.examId, { ...answers }); |
| | | } catch (e: any) { |
| | | const msg = e?.message || '暂存失败'; |
| | | if (String(msg).includes('自动交卷')) { |
| | | createMessage.warning(msg); |
| | | stopCountdown(); |
| | | router.replace('/tms/onlineExam'); |
| | | return; |
| | | } |
| | | createMessage.error(msg); |
| | | } finally { |
| | | saving.value = false; |
| | | } |
| | | return String(ans || '') === String(correctLabels[0] || ''); |
| | | } |
| | | |
| | | function calcScore(): number { |
| | | if (!paper.value) return 0; |
| | | let got = 0; |
| | | paper.value.questions.forEach((q) => { |
| | | if (isCorrect(q)) got += Number(q.score || 0); |
| | | }); |
| | | return got; |
| | | async function goPrev() { |
| | | if (currentIndex.value <= 0) return; |
| | | await persistAnswers(); |
| | | currentIndex.value -= 1; |
| | | } |
| | | |
| | | async function goNext() { |
| | | if (currentIndex.value >= total.value - 1) return; |
| | | await persistAnswers(); |
| | | currentIndex.value += 1; |
| | | } |
| | | |
| | | async function goToQuestion(index: number) { |
| | | if (index < 0 || index >= total.value || index === currentIndex.value) { |
| | | sheetOpen.value = false; |
| | | return; |
| | | } |
| | | if (!submitted.value) await persistAnswers(); |
| | | currentIndex.value = index; |
| | | sheetOpen.value = false; |
| | | } |
| | | |
| | | function openSheet() { |
| | | sheetOpen.value = true; |
| | | } |
| | | |
| | | async function goBack() { |
| | | if (!submitted.value) await persistAnswers(); |
| | | resultModalOpen.value = false; |
| | | router.push('/tms/onlineExam'); |
| | | } |
| | | |
| | | function handleSubmit() { |
| | |
| | | Modal.confirm({ |
| | | title: '确认交卷', |
| | | content: '交卷后不可再修改答案,确定交卷吗?', |
| | | onOk: doSubmit, |
| | | onOk: () => doSubmit(), |
| | | }); |
| | | } |
| | | |
| | | async function doSubmit() { |
| | | function applyGrade(result: OnlineExamSubmitResult) { |
| | | if (!paper.value) return; |
| | | const byId = new Map((result.items || []).map((item) => [item.id, item])); |
| | | for (const q of paper.value.questions) { |
| | | const item = byId.get(q.id); |
| | | if (!item) continue; |
| | | q.right = item.right; |
| | | q.subjective = item.subjective; |
| | | q.correctAnswer = item.correctAnswer || ''; |
| | | if (q.options?.length && item.correctAnswer && q.questionType !== 'blank' && q.questionType !== 'essay') { |
| | | const labels = new Set(item.correctAnswer.split(/[,,]/).map((x) => x.trim()).filter(Boolean)); |
| | | q.options.forEach((opt) => { |
| | | opt.isCorrect = labels.has(opt.optionLabel) ? '1' : '0'; |
| | | }); |
| | | } |
| | | } |
| | | } |
| | | |
| | | function closeResultModal() { |
| | | resultModalOpen.value = false; |
| | | } |
| | | |
| | | async function openResultModal() { |
| | | resultFilter.value = 'all'; |
| | | await nextTick(); |
| | | resultModalOpen.value = true; |
| | | } |
| | | |
| | | function correctLabelsOf(q: OnlineExamQuestionItem) { |
| | | if (q.questionType === 'blank' || q.questionType === 'essay') { |
| | | return q.correctAnswer ? [q.correctAnswer] : []; |
| | | } |
| | | const fromOpt = q.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel); |
| | | if (fromOpt.length) return fromOpt; |
| | | return (q.correctAnswer || '') |
| | | .split(/[,,]/) |
| | | .map((x) => x.trim()) |
| | | .filter(Boolean); |
| | | } |
| | | |
| | | function userAnswerText(q: OnlineExamQuestionItem) { |
| | | const ans = answers[q.id]; |
| | | if (q.questionType === 'multi') { |
| | | const selected = Array.isArray(ans) ? [...ans].sort() : []; |
| | | return selected.length ? selected.join('、') : '未作答'; |
| | | } |
| | | return String(ans || '') || '未作答'; |
| | | } |
| | | |
| | | function optionClass(q: OnlineExamQuestionItem, label: string) { |
| | | if (!submitted.value || !isObjective(q)) return ''; |
| | | const correctSet = new Set(correctLabelsOf(q)); |
| | | const ans = answers[q.id]; |
| | | const userSet = new Set( |
| | | q.questionType === 'multi' |
| | | ? Array.isArray(ans) |
| | | ? ans |
| | | : [] |
| | | : ans |
| | | ? [String(ans)] |
| | | : [], |
| | | ); |
| | | if (correctSet.has(label)) return 'opt-correct'; |
| | | if (userSet.has(label) && !correctSet.has(label)) return 'opt-wrong'; |
| | | return ''; |
| | | } |
| | | |
| | | function resultTag(q: OnlineExamQuestionItem) { |
| | | if (!isObjective(q)) return { color: 'default', text: '待阅卷' }; |
| | | return q.right ? { color: 'success', text: '回答正确' } : { color: 'error', text: '回答错误' }; |
| | | } |
| | | |
| | | function passText() { |
| | | if (submitResult.value?.pendingGrade) return '待阅卷'; |
| | | if (submitResult.value?.passFlag === '1') return '合格'; |
| | | if (submitResult.value?.passFlag === '0') return '不合格'; |
| | | return '-'; |
| | | } |
| | | |
| | | async function doSubmit(opts?: { auto?: boolean }) { |
| | | if (!paper.value || submitted.value || submitting.value) return; |
| | | if (opts?.auto) autoSubmitting.value = true; |
| | | submitting.value = true; |
| | | stopCountdown(); |
| | | try { |
| | | const got = calcScore(); |
| | | await submitOnlineExam(paper.value.examId, { score: got, answers: { ...answers } }); |
| | | const result = await submitOnlineExam(paper.value.examId, { ...answers }); |
| | | applyGrade(result); |
| | | submitted.value = true; |
| | | scoreText.value = `${got} / ${paper.value.totalScore}`; |
| | | createMessage.success(`交卷成功,得分 ${got} 分(满分 ${paper.value.totalScore})`); |
| | | remainSeconds.value = 0; |
| | | submitResult.value = result; |
| | | const got = result.pendingGrade ? result.objectiveScore : result.totalScore; |
| | | scoreText.value = result.pendingGrade |
| | | ? `客观题 ${got ?? 0} / ${paper.value.totalScore}` |
| | | : `${got ?? 0} / ${paper.value.totalScore}`; |
| | | if (opts?.auto) { |
| | | createMessage.warning('考试时间已到,已自动交卷'); |
| | | } |
| | | await openResultModal(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '交卷失败'); |
| | | const msg = e?.message || '交卷失败'; |
| | | if (String(msg).includes('已交卷') || String(msg).includes('自动交卷')) { |
| | | createMessage.warning(msg); |
| | | router.replace('/tms/onlineExam'); |
| | | return; |
| | | } |
| | | createMessage.error(msg); |
| | | if (opts?.auto && paper.value && !submitted.value) { |
| | | initCountdown(paper.value); |
| | | } |
| | | } finally { |
| | | submitting.value = false; |
| | | autoSubmitting.value = false; |
| | | } |
| | | } |
| | | |
| | | function reviewText(q?: OnlineExamQuestionItem) { |
| | | if (!q || !submitted.value) return ''; |
| | | if (!isObjective(q)) return '主观题,待阅卷'; |
| | | if (q.right) return '回答正确'; |
| | | return `回答错误 · 正确答案:${correctLabelsOf(q).join('、') || '-'}`; |
| | | } |
| | | </script> |
| | | |
| | |
| | | <div v-if="paper" class="jnpf-content-wrapper-content tms-online-exam-page"> |
| | | <div class="tms-exam-header"> |
| | | <div> |
| | | <div class="text-base font-medium">{{ paper.paperName }}</div> |
| | | <div class="text-base font-medium"> |
| | | {{ paper.paperName }} |
| | | <span v-if="paper.attemptLabel" class="ml-2 text-sm font-normal text-gray-500"> |
| | | ({{ paper.attemptLabel }}) |
| | | </span> |
| | | </div> |
| | | <div class="mt-1 text-gray-400 text-sm"> |
| | | 第 {{ currentIndex + 1 }} / {{ total }} 题 |
| | | <span v-if="paper.durationMin" class="ml-3">时长 {{ paper.durationMin }} 分钟</span> |
| | |
| | | </div> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="goBack">返回列表</a-button> |
| | | <div |
| | | v-if="countdownText && !submitted" |
| | | class="countdown" |
| | | :class="{ urgent: countdownUrgent, ended: remainSeconds === 0 }" |
| | | > |
| | | 剩余 {{ countdownText }} |
| | | </div> |
| | | <a-button v-if="submitted" type="link" @click="openResultModal">查看本次结果</a-button> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | <a-button type="primary" :loading="submitting" :disabled="submitted" @click="handleSubmit"> |
| | | 交卷 |
| | | {{ TMS_BTN.submitExam }} |
| | | </a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | | <div v-if="current" class="tms-exam-body"> |
| | | <div class="mb-3 text-sm text-gray-500"> |
| | | {{ labelOfType(current.questionType) }} |
| | | <span class="ml-2">({{ current.score }} 分)</span> |
| | | <div class="q-meta mb-3"> |
| | | <div class="text-sm text-gray-500"> |
| | | {{ labelOfType(current.questionType) }} |
| | | <span class="ml-2">({{ current.score }} 分)</span> |
| | | </div> |
| | | <a-tooltip title="答题卡"> |
| | | <button type="button" class="sheet-icon-btn" aria-label="答题卡" @click="openSheet"> |
| | | <AppstoreOutlined /> |
| | | </button> |
| | | </a-tooltip> |
| | | </div> |
| | | <div class="stem mb-4">{{ stripHtml(current.stem) }}</div> |
| | | |
| | |
| | | </a-checkbox> |
| | | </a-checkbox-group> |
| | | |
| | | <a-input |
| | | v-else-if="current.questionType === 'blank'" |
| | | v-model:value="answers[current.id]" |
| | | :disabled="submitted" |
| | | placeholder="请输入答案" |
| | | /> |
| | | |
| | | <a-textarea |
| | | v-else-if="current.questionType === 'essay'" |
| | | v-model:value="answers[current.id]" |
| | | :disabled="submitted" |
| | | :rows="6" |
| | | placeholder="请输入答案" |
| | | /> |
| | | |
| | | <div |
| | | v-if="submitted" |
| | | class="mt-4 text-sm" |
| | | :class="isCorrect(current) ? 'text-green-600' : 'text-red-500'" |
| | | :class=" |
| | | current.right |
| | | ? 'text-green-600' |
| | | : !isObjective(current) |
| | | ? 'text-gray-500' |
| | | : 'text-red-500' |
| | | " |
| | | > |
| | | {{ isCorrect(current) ? '回答正确' : '回答错误' }} |
| | | · 正确答案: |
| | | {{ current.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel).join('、') }} |
| | | {{ reviewText(current) }} |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="tms-exam-footer"> |
| | | <a-button :disabled="currentIndex <= 0" @click="goPrev">上一题</a-button> |
| | | <a-button :disabled="currentIndex >= total - 1" @click="goNext">下一题</a-button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="sheetOpen && paper" class="tms-sheet-overlay" @click.self="sheetOpen = false"> |
| | | <div class="tms-sheet-dialog" role="dialog" aria-modal="true"> |
| | | <div class="sheet-head"> |
| | | <div> |
| | | <div class="sheet-title">答题卡</div> |
| | | <div class="sheet-sub">已答 {{ answeredCount }} / {{ total }},点击题号可切换</div> |
| | | </div> |
| | | <button type="button" class="tms-result-close" aria-label="关闭" @click="sheetOpen = false">×</button> |
| | | </div> |
| | | <div class="sheet-legend"> |
| | | <span class="legend-chip current">当前</span> |
| | | <span class="legend-chip answered">已答</span> |
| | | <span class="legend-chip unanswered">未答</span> |
| | | </div> |
| | | <div class="sheet-grid"> |
| | | <button |
| | | v-for="(q, idx) in paper.questions" |
| | | :key="q.id" |
| | | type="button" |
| | | class="sheet-item" |
| | | :class="{ |
| | | current: idx === currentIndex, |
| | | answered: isAnswered(q), |
| | | unanswered: !isAnswered(q), |
| | | }" |
| | | @click="goToQuestion(idx)" |
| | | > |
| | | {{ idx + 1 }} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div |
| | | v-if="resultModalOpen && submitResult && paper" |
| | | class="tms-result-overlay" |
| | | @click.self="closeResultModal" |
| | | > |
| | | <div class="tms-result-dialog" role="dialog" aria-modal="true"> |
| | | <button type="button" class="tms-result-close" aria-label="关闭" @click="closeResultModal">×</button> |
| | | |
| | | <div class="result-hero"> |
| | | <div class="hero-title">本次考试结果</div> |
| | | <div class="hero-bank"> |
| | | {{ paper.paperName }} |
| | | <span v-if="paper.attemptLabel">({{ paper.attemptLabel }})</span> |
| | | </div> |
| | | <div class="hero-stats"> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ rightCount }}</div> |
| | | <div class="stat-label">答对</div> |
| | | </div> |
| | | <div class="stat-divider" /> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ wrongCount }}</div> |
| | | <div class="stat-label">答错</div> |
| | | </div> |
| | | <div class="stat-divider" /> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ scoreRate }}%</div> |
| | | <div class="stat-label">正确率</div> |
| | | </div> |
| | | <div class="stat-divider" /> |
| | | <div class="stat-item"> |
| | | <div class="stat-value"> |
| | | {{ submitResult.pendingGrade ? submitResult.objectiveScore : submitResult.totalScore }}/{{ |
| | | paper.totalScore |
| | | }} |
| | | </div> |
| | | <div class="stat-label">{{ submitResult.pendingGrade ? '客观分' : '得分' }}</div> |
| | | </div> |
| | | </div> |
| | | <div class="hero-extra"> |
| | | 结果:{{ passText() }} |
| | | <span v-if="submitResult.pendingGrade" class="ml-2">(含主观题,待阅卷后出最终成绩)</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="result-toolbar"> |
| | | <a-radio-group v-model:value="resultFilter" button-style="solid" size="small"> |
| | | <a-radio-button value="all">全部题目({{ total }})</a-radio-button> |
| | | <a-radio-button value="wrong">仅错题({{ wrongCount }})</a-radio-button> |
| | | </a-radio-group> |
| | | <div class="legend"> |
| | | <span class="legend-item correct">正确答案</span> |
| | | <span class="legend-item wrong">你的错误选项</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="result-list"> |
| | | <a-empty v-if="!resultQuestions.length" description="暂无题目" /> |
| | | <div |
| | | v-for="(q, idx) in resultQuestions" |
| | | :key="q.id" |
| | | class="question-card" |
| | | :class="!isObjective(q) ? '' : q.right ? 'is-right' : 'is-wrong'" |
| | | > |
| | | <div class="q-head"> |
| | | <div class="q-head-left"> |
| | | <span class="q-index">第 {{ idx + 1 }} 题</span> |
| | | <span class="q-type">{{ labelOfType(q.questionType) }} · {{ q.score }} 分</span> |
| | | </div> |
| | | <a-tag :color="resultTag(q).color">{{ resultTag(q).text }}</a-tag> |
| | | </div> |
| | | <div class="q-stem">{{ stripHtml(q.stem) }}</div> |
| | | <div v-if="q.options?.length" class="q-options"> |
| | | <div |
| | | v-for="opt in q.options" |
| | | :key="opt.optionLabel" |
| | | class="q-option" |
| | | :class="optionClass(q, opt.optionLabel)" |
| | | > |
| | | <span class="opt-label">{{ opt.optionLabel }}</span> |
| | | <span class="opt-content">{{ opt.optionContent }}</span> |
| | | </div> |
| | | </div> |
| | | <div class="q-answer-bar"> |
| | | <div> |
| | | <span class="ans-label">你的答案</span> |
| | | <span :class="!isObjective(q) ? '' : q.right ? 'ans-ok' : 'ans-bad'"> |
| | | {{ userAnswerText(q) }} |
| | | </span> |
| | | </div> |
| | | <div v-if="isObjective(q)"> |
| | | <span class="ans-label">正确答案</span> |
| | | <span class="ans-ok">{{ correctLabelsOf(q).join('、') || '-' }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="result-footer"> |
| | | <a-button @click="closeResultModal">{{ TMS_BTN.close }}</a-button> |
| | | <a-button type="primary" @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .countdown { |
| | | min-width: 120px; |
| | | padding: 4px 12px; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | font-variant-numeric: tabular-nums; |
| | | color: #1890ff; |
| | | background: #e6f7ff; |
| | | border: 1px solid #91d5ff; |
| | | border-radius: 4px; |
| | | text-align: center; |
| | | } |
| | | |
| | | .countdown.urgent { |
| | | color: #cf1322; |
| | | background: #fff1f0; |
| | | border-color: #ffa39e; |
| | | } |
| | | |
| | | .countdown.ended { |
| | | color: #8c8c8c; |
| | | background: #fafafa; |
| | | border-color: #d9d9d9; |
| | | } |
| | | |
| | | .tms-exam-body { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .q-meta { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .sheet-icon-btn { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | width: 32px; |
| | | height: 32px; |
| | | border: 1px solid #d9d9d9; |
| | | border-radius: 6px; |
| | | background: #fff; |
| | | color: #1677ff; |
| | | font-size: 16px; |
| | | cursor: pointer; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .sheet-icon-btn:hover { |
| | | border-color: #1677ff; |
| | | background: #e6f4ff; |
| | | } |
| | | |
| | | .stem { |
| | |
| | | padding-top: 16px; |
| | | border-top: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .tms-sheet-overlay { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: 1900; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 24px; |
| | | background: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .tms-sheet-dialog { |
| | | width: min(520px, 100%); |
| | | max-height: min(70vh, 640px); |
| | | display: flex; |
| | | flex-direction: column; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .sheet-head { |
| | | position: relative; |
| | | flex-shrink: 0; |
| | | padding: 18px 48px 12px 20px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .sheet-title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .sheet-sub { |
| | | margin-top: 4px; |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .sheet-legend { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | gap: 16px; |
| | | padding: 12px 20px 0; |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.55); |
| | | } |
| | | |
| | | .legend-chip::before { |
| | | content: ''; |
| | | display: inline-block; |
| | | width: 12px; |
| | | height: 12px; |
| | | margin-right: 6px; |
| | | border-radius: 3px; |
| | | vertical-align: -2px; |
| | | } |
| | | |
| | | .legend-chip.current::before { |
| | | background: #fff; |
| | | border: 2px solid #1677ff; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .legend-chip.answered::before { |
| | | background: #1677ff; |
| | | } |
| | | |
| | | .legend-chip.unanswered::before { |
| | | background: #f5f5f5; |
| | | border: 1px solid #d9d9d9; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .sheet-grid { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow: auto; |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); |
| | | gap: 10px; |
| | | padding: 16px 20px 20px; |
| | | } |
| | | |
| | | .sheet-item { |
| | | height: 40px; |
| | | border-radius: 8px; |
| | | border: 1px solid #d9d9d9; |
| | | background: #fafafa; |
| | | color: rgba(0, 0, 0, 0.65); |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | cursor: pointer; |
| | | transition: all 0.15s ease; |
| | | } |
| | | |
| | | .sheet-item.answered { |
| | | background: #1677ff; |
| | | border-color: #1677ff; |
| | | color: #fff; |
| | | } |
| | | |
| | | .sheet-item.unanswered { |
| | | background: #f5f5f5; |
| | | border-color: #d9d9d9; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .sheet-item.current { |
| | | box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.35); |
| | | } |
| | | |
| | | .sheet-item.current.unanswered { |
| | | border-color: #1677ff; |
| | | color: #1677ff; |
| | | background: #e6f4ff; |
| | | } |
| | | |
| | | .sheet-item:hover { |
| | | filter: brightness(0.97); |
| | | } |
| | | |
| | | .tms-result-overlay { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: 2000; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 24px; |
| | | background: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .tms-result-dialog { |
| | | position: relative; |
| | | width: min(720px, 100%); |
| | | max-height: min(80vh, 780px); |
| | | display: flex; |
| | | flex-direction: column; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .tms-result-close { |
| | | position: absolute; |
| | | top: 10px; |
| | | right: 12px; |
| | | z-index: 2; |
| | | width: 32px; |
| | | height: 32px; |
| | | border: none; |
| | | border-radius: 6px; |
| | | background: transparent; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | cursor: pointer; |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 22px; |
| | | line-height: 1; |
| | | } |
| | | |
| | | .tms-result-close:hover { |
| | | background: rgba(0, 0, 0, 0.06); |
| | | color: rgba(0, 0, 0, 0.75); |
| | | } |
| | | |
| | | .result-hero { |
| | | flex-shrink: 0; |
| | | padding: 24px 28px 20px; |
| | | background: linear-gradient(135deg, #f0f7ff 0%, #f8fbff 55%, #ffffff 100%); |
| | | border-bottom: 1px solid #eef2f7; |
| | | } |
| | | |
| | | .hero-title { |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | padding-right: 36px; |
| | | } |
| | | |
| | | .hero-bank { |
| | | margin-top: 4px; |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .hero-stats { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | margin-top: 18px; |
| | | padding: 14px 8px; |
| | | background: #fff; |
| | | border: 1px solid #e8eef5; |
| | | border-radius: 10px; |
| | | } |
| | | |
| | | .hero-extra { |
| | | margin-top: 12px; |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.65); |
| | | } |
| | | |
| | | .stat-item { |
| | | flex: 1; |
| | | text-align: center; |
| | | } |
| | | |
| | | .stat-value { |
| | | font-size: 22px; |
| | | font-weight: 700; |
| | | line-height: 1.2; |
| | | color: #1677ff; |
| | | } |
| | | |
| | | .stat-label { |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .stat-divider { |
| | | width: 1px; |
| | | height: 28px; |
| | | background: #eef2f7; |
| | | } |
| | | |
| | | .result-toolbar { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | gap: 12px; |
| | | flex-wrap: wrap; |
| | | padding: 12px 28px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .legend { |
| | | display: flex; |
| | | gap: 12px; |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .legend-item::before { |
| | | content: ''; |
| | | display: inline-block; |
| | | width: 10px; |
| | | height: 10px; |
| | | margin-right: 6px; |
| | | border-radius: 2px; |
| | | vertical-align: -1px; |
| | | } |
| | | |
| | | .legend-item.correct::before { |
| | | background: #b7eb8f; |
| | | } |
| | | |
| | | .legend-item.wrong::before { |
| | | background: #ffa39e; |
| | | } |
| | | |
| | | .result-list { |
| | | flex: 1; |
| | | min-height: 160px; |
| | | overflow: auto; |
| | | padding: 8px 28px 4px; |
| | | } |
| | | |
| | | .question-card { |
| | | margin-bottom: 12px; |
| | | padding: 14px 16px; |
| | | border: 1px solid #f0f0f0; |
| | | border-radius: 10px; |
| | | background: #fff; |
| | | } |
| | | |
| | | .question-card.is-wrong { |
| | | border-color: #ffccc7; |
| | | background: #fffafa; |
| | | } |
| | | |
| | | .question-card.is-right { |
| | | border-color: #d9f7be; |
| | | background: #fcfffb; |
| | | } |
| | | |
| | | .q-head { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .q-head-left { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .q-index { |
| | | font-weight: 600; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .q-type { |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .q-stem { |
| | | margin-bottom: 10px; |
| | | font-size: 14px; |
| | | line-height: 1.7; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .q-options { |
| | | display: grid; |
| | | gap: 6px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .q-option { |
| | | display: flex; |
| | | gap: 8px; |
| | | padding: 8px 10px; |
| | | border-radius: 6px; |
| | | background: #fafafa; |
| | | border: 1px solid transparent; |
| | | color: rgba(0, 0, 0, 0.75); |
| | | } |
| | | |
| | | .q-option .opt-label { |
| | | min-width: 18px; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .q-option.opt-correct { |
| | | background: #f6ffed; |
| | | color: #389e0d; |
| | | border-color: #b7eb8f; |
| | | } |
| | | |
| | | .q-option.opt-wrong { |
| | | background: #fff2f0; |
| | | color: #cf1322; |
| | | border-color: #ffccc7; |
| | | } |
| | | |
| | | .q-answer-bar { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 16px 28px; |
| | | padding-top: 8px; |
| | | border-top: 1px dashed #f0f0f0; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .ans-label { |
| | | margin-right: 8px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .ans-ok { |
| | | color: #389e0d; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .ans-bad { |
| | | color: #cf1322; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .result-footer { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | justify-content: center; |
| | | gap: 16px; |
| | | padding: 14px 28px 18px; |
| | | border-top: 1px solid #f0f0f0; |
| | | background: #fafafa; |
| | | } |
| | | </style> |