From 93c133349a5ccd7a328371fa113dce69d5611f21 Mon Sep 17 00:00:00 2001
From: liuyu <yu.liu@cqgbkj.com>
Date: 星期二, 22 九月 2026 09:25:39 +0800
Subject: [PATCH] feat(tms): 完成课程考试自测等页面与接口对接

---
 apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue |  976 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 927 insertions(+), 49 deletions(-)

diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue
index 68f3126..65bd27c 100644
--- a/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue
+++ b/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;
-}
-
-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() {
@@ -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: '鍥炵瓟姝g‘' } : { 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 '鍥炵瓟姝g‘';
+  return `鍥炵瓟閿欒 路 姝g‘绛旀锛�${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">
-            {{ 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>
 
@@ -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) ? '鍥炵瓟姝g‘' : '鍥炵瓟閿欒' }}
-            路 姝g‘绛旀锛�
-            {{ 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">姝g‘鐜�</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">姝g‘绛旀</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">姝g‘绛旀</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>

--
Gitblit v1.8.0