<script lang="ts" setup>
|
import type { OnlineExamPaper, OnlineExamQuestionItem, OnlineExamSubmitResult } from './types';
|
|
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 { 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 router = useRouter();
|
const { createMessage } = useMessage();
|
|
const paper = ref<OnlineExamPaper | null>(null);
|
const currentIndex = ref(0);
|
const answers = reactive<Record<string, string | string[]>>({});
|
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,
|
);
|
|
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('请先从试卷列表选择考试');
|
router.replace('/tms/onlineExam');
|
return;
|
}
|
try {
|
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();
|
}
|
|
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;
|
}
|
}
|
|
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() {
|
if (!paper.value || submitted.value) return;
|
Modal.confirm({
|
title: '确认交卷',
|
content: '交卷后不可再修改答案,确定交卷吗?',
|
onOk: () => 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 result = await submitOnlineExam(paper.value.examId, { ...answers });
|
applyGrade(result);
|
submitted.value = true;
|
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) {
|
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>
|
|
<template>
|
<div class="jnpf-content-wrapper">
|
<div class="jnpf-content-wrapper-center">
|
<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 }}
|
<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>
|
<span v-if="submitted" class="ml-3 text-primary">得分:{{ scoreText }}</span>
|
</div>
|
</div>
|
<a-space>
|
<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="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-radio-group
|
v-if="current.questionType === 'single' || current.questionType === 'judge'"
|
v-model:value="answers[current.id]"
|
class="!flex !flex-col gap-3"
|
:disabled="submitted"
|
>
|
<a-radio v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel">
|
{{ opt.optionLabel }}. {{ opt.optionContent }}
|
</a-radio>
|
</a-radio-group>
|
|
<a-checkbox-group
|
v-else-if="current.questionType === 'multi'"
|
v-model:value="answers[current.id]"
|
class="!flex !flex-col gap-3"
|
:disabled="submitted"
|
>
|
<a-checkbox v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel">
|
{{ opt.optionLabel }}. {{ opt.optionContent }}
|
</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="
|
current.right
|
? 'text-green-600'
|
: !isObjective(current)
|
? 'text-gray-500'
|
: 'text-red-500'
|
"
|
>
|
{{ 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>
|
</div>
|
</template>
|
|
<style scoped>
|
.tms-online-exam-page {
|
background: #fff;
|
padding: 20px 24px;
|
height: 100%;
|
display: flex;
|
flex-direction: column;
|
min-height: 0;
|
}
|
|
.tms-exam-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
padding-bottom: 12px;
|
margin-bottom: 16px;
|
border-bottom: 1px solid #f0f0f0;
|
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 {
|
font-size: 15px;
|
line-height: 1.7;
|
color: rgba(0, 0, 0, 0.88);
|
}
|
|
.tms-exam-footer {
|
flex-shrink: 0;
|
display: flex;
|
gap: 12px;
|
justify-content: center;
|
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>
|