liuyu
22 小时以前 93c133349a5ccd7a328371fa113dce69d5611f21
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue
@@ -1,14 +1,16 @@
<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' });
@@ -21,11 +23,47 @@
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('请先从试卷列表选择考试');
@@ -33,46 +71,161 @@
    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(/&nbsp;/g, ' ').trim();
}
function goPrev() {
  if (currentIndex.value > 0) currentIndex.value -= 1;
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;
  }
}
function goNext() {
  if (currentIndex.value < total.value - 1) currentIndex.value += 1;
async function goPrev() {
  if (currentIndex.value <= 0) return;
  await persistAnswers();
  currentIndex.value -= 1;
}
function goBack() {
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 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(',');
  }
  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;
}
function handleSubmit() {
@@ -80,24 +233,130 @@
  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>
@@ -107,7 +366,12 @@
      <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>
@@ -115,17 +379,32 @@
            </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">
          <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>
@@ -151,20 +430,177 @@
            </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>
@@ -191,10 +627,62 @@
  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 {
@@ -211,4 +699,394 @@
  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>