瀏覽代碼

feat: 对接我的考试模块内容

xieyong 1 月之前
父節點
當前提交
2391b5a626

+ 86 - 0
src/api/examManagement/examManagement.js

@@ -11,6 +11,17 @@ export async function getPage(params) {
   return Promise.reject(new Error(res.data.message));
 }
 
+/**
+ * 分页查询当前用户考试信息
+ */
+export async function getMyPage(params) {
+  const res = await request.get('/ehs/examManagement/myPage', { params });
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
 /**
  * 详情
  */
@@ -48,3 +59,78 @@ export async function signIn(data) {
   }
   return Promise.reject(new Error(res.data.message));
 }
+
+/**
+ * 开始补考
+ * @param {Object} data - { examId }
+ */
+export async function retake(data) {
+  const res = await request.post('/ehs/examManagement/retake', data);
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 开始考试
+ * @param {Object} data - { examId }
+ */
+export async function start(data) {
+  const res = await request.post('/ehs/examManagement/start', data);
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查看错题
+ * @param {Object} params - { examId, userId }
+ */
+export async function getWrongQuestions(params) {
+  const res = await request.get('/ehs/examManagement/wrongQuestions', { params });
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 导出错题
+ * @param {Object} data - { examId, userId }
+ * @returns {Blob} Word / Excel 文件流
+ */
+export async function exportWrongQuestions(data) {
+  const res = await request.post(
+    '/ehs/examManagement/wrongQuestions/export',
+    data,
+    { responseType: 'blob' }
+  );
+  return res.data;
+}
+
+/**
+ * 保存单题答案(答题中提交)
+ * @param {Object} data - { attemptId, attemptQuestionId, answer?, selectedOptionIds? }
+ */
+export async function saveAnswer(data) {
+  const res = await request.post('/ehs/examManagement/answer', data);
+  if (res.data.code == 0) {
+    return res.data.data || res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 交卷
+ * @param {Object} data - { attemptId }
+ * @returns {Object} EHS考试交卷结果 { attemptId, examId, attemptStatus, assessmentScore, retakeScore, score, passed, submitTime }
+ */
+export async function submitExam(data) {
+  const res = await request.post('/ehs/examManagement/submit', data);
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}

+ 19 - 1
src/router/routes.js

@@ -12,7 +12,7 @@ export const routes = [
     path: '/login',
     component: () => import('@/views/login/index.vue'),
     meta: { title: '登录' }
-  },
+  }
   // {
   //   path: '/forget',
   //   component: () => import('@/views/financialManage/forget/index.vue'),
@@ -52,6 +52,24 @@ export function getMenuRoutes(menus, homePath) {
         title: '题库试题',
         hide: true
       }
+    },
+    // 我的考试-参加考试 / 补考 答题页(独立路由)
+    {
+      path: 'safetyExam/myExam/taking',
+      component: () => import('@/views/safetyExam/myExam/taking.vue'),
+      meta: {
+        title: '考试答题',
+        hide: true
+      }
+    },
+    // 我的考试-查看错题(独立路由)
+    {
+      path: 'safetyExam/myExam/wrongQuestions',
+      component: () => import('@/views/safetyExam/myExam/wrongQuestions.vue'),
+      meta: {
+        title: '查看错题',
+        hide: true
+      }
     }
   ];
   // 路由铺平处理

+ 195 - 28
src/views/safetyExam/myExam/index.vue

@@ -25,11 +25,26 @@
             <template v-slot:action="{ row }">
               <div class="action-links">
                 <el-link
+                  v-if="!isExamEnded(row) && !isRetakeAvailable(row) && !isPassed(row)"
                   type="primary"
                   :underline="false"
                   @click="handleTakeExam(row)"
                   >参加考试</el-link
                 >
+                <el-link
+                  v-if="isCompleted(row)"
+                  type="primary"
+                  :underline="false"
+                  @click="handleViewWrongQuestions(row)"
+                  >查看错题</el-link
+                >
+                <el-link
+                  v-if="isRetakeAvailable(row) && !isExamEnded(row) && !isPassed(row)"
+                  type="primary"
+                  :underline="false"
+                  @click="handleRetake(row)"
+                  >补考</el-link
+                >
               </div>
             </template>
           </ele-pro-table>
@@ -41,9 +56,52 @@
 
 <script>
   import tabMixins from '@/mixins/tableColumnsMixin';
+  import { getMyPage } from '@/api/examManagement/examManagement.js';
+  import { mapGetters } from 'vuex';
+
+  // 考试状态映射:无考试记录
+  const EXAM_STATUS_NO_RECORD_MAP = {
+    0: '未开始',
+    1: '已开始',
+    2: '已结束'
+  };
+
+  // 考试状态映射:有考试记录
+  const EXAM_STATUS_HAS_RECORD_MAP = {
+    0: '考试中',
+    1: '已完成',
+    2: '待补考'
+  };
+
+  // 考试类型映射
+  const EXAM_TYPE_MAP = {
+    1: '培训关联考试',
+    2: '专项考试'
+  };
+
   export default {
     mixins: [tabMixins],
 
+    mounted() {
+      // 监听考试完成事件,从答题页返回时刷新列表
+      this.onExamTakenCompleted = () => {
+        if (this.$refs.table) this.$refs.table.reload();
+      };
+      window.addEventListener(
+        'exam-taken-completed',
+        this.onExamTakenCompleted
+      );
+    },
+
+    beforeDestroy() {
+      if (this.onExamTakenCompleted) {
+        window.removeEventListener(
+          'exam-taken-completed',
+          this.onExamTakenCompleted
+        );
+      }
+    },
+
     data() {
       return {
         columns: [
@@ -55,14 +113,14 @@
             align: 'center'
           },
           {
-            prop: 'name',
+            prop: 'examName',
             label: '考试名称',
             align: 'center',
             minWidth: 180,
             showOverflowTooltip: true
           },
           {
-            prop: 'location',
+            prop: 'examPlace',
             label: '考试地点',
             align: 'center',
             minWidth: 150
@@ -73,14 +131,23 @@
             align: 'center',
             minWidth: 160
           },
+          {
+            prop: 'endTime',
+            label: '结束时间',
+            align: 'center',
+            minWidth: 160
+          },
           {
             prop: 'examType',
             label: '考试类型',
             align: 'center',
-            minWidth: 120
+            minWidth: 120,
+            formatter: (row) => {
+              return EXAM_TYPE_MAP[row.examType] || row.examType || '';
+            }
           },
           {
-            prop: 'endTime',
+            prop: 'completeTime',
             label: '完成时间',
             align: 'center',
             minWidth: 160
@@ -89,30 +156,48 @@
             prop: 'status',
             label: '状态',
             align: 'center',
-            minWidth: 100
+            minWidth: 100,
+            formatter: (row) => {
+              return formatStatus(row);
+            }
           },
           {
-            prop: 'examScore',
+            prop: 'assessmentScore',
             label: '考核成绩',
             align: 'center',
-            minWidth: 100
+            minWidth: 100,
+            formatter: (row) => {
+              // 无考试记录 / 考试中(status=0):不显示成绩
+              if (!hasExamRecord(row) || row.status === 0) return '—';
+              return row.assessmentScore ?? '—';
+            }
           },
           {
-            prop: 'makeupScore',
+            prop: 'retakeScore',
             label: '补考成绩',
             align: 'center',
-            minWidth: 100
+            minWidth: 100,
+            formatter: (row) => {
+              // 无考试记录 / 考试中(status=0):不显示成绩
+              if (!hasExamRecord(row) || row.status === 0) return '—';
+              return row.retakeScore ?? '—';
+            }
           },
           {
-            prop: 'result',
+            prop: 'passed',
             label: '结果',
             align: 'center',
-            minWidth: 100
+            minWidth: 90,
+            formatter: (row) => {
+              // 无考试记录 / 考试中(status=0):不显示结果
+              if (!hasExamRecord(row) || row.status === 0) return '—';
+              return row.passed ? '合格' : '不合格';
+            }
           },
           {
             columnKey: 'action',
             label: '操作',
-            width: 120,
+            width: 240,
             align: 'center',
             slot: 'action'
           }
@@ -121,37 +206,101 @@
       };
     },
     computed: {
+      ...mapGetters(['user']),
       seekList() {
         return [
           {
             label: '考试名称:',
-            value: 'name',
+            value: 'examName',
             type: 'input',
             width: 220,
             placeholder: '请输入考试名称'
           },
           {
-            label: '开始时间:',
-            value: 'startTime',
-            type: 'date',
-            width: 220,
-            placeholder: '请选择开始时间'
-          },
-          {
-            label: '完成时间:',
-            value: 'endTime',
+            label: '考试时间:',
+            value: 'examTime',
             type: 'date',
-            width: 220,
-            placeholder: '请选择完成时间'
+            width: 280,
+            placeholder: '请选择考试时间范围',
+            valueAr: ['examStartTime', 'examEndTime']
           }
         ];
       }
     },
     methods: {
-      // 参加考试
       handleTakeExam(row) {
-        // TODO: 参加考试逻辑
-        console.log('参加考试', row);
+        if (!row?.examId) {
+          this.$message.error('缺少考试ID');
+          return;
+        }
+
+
+        if(row.needSignIn && !row.signIn) {
+          this.$message.warning('请先签到');
+          return;
+        }
+
+        // 不在列表页调 start,由答题页内部负责调用并加载试卷
+        this.$router.push({
+          path: '/safetyExam/myExam/taking',
+          query: {
+            examId: row.examId,
+            mode: 'start',
+            submitted: row.submitted
+          }
+        });
+      },
+      // 查看错题:头部信息(考试名称 / 试卷名称 / 考试类型 / 考试时间)由错题接口 examInfo 返回;
+        // 考核 / 补考成绩 接口不返回,需要从列表 row 透传;考试人员 / 部门从登录用户 store 拿
+      handleViewWrongQuestions(row) {
+        if (!row?.examId) {
+          this.$message.error('缺少考试ID');
+          return;
+        }
+        const userId = this.user?.info?.userId;
+        this.$router.push({
+          path: '/safetyExam/myExam/wrongQuestions',
+          query: {
+            examId: row.examId,
+            userId,
+            assessmentScore: row.assessmentScore,
+            retakeScore: row.retakeScore
+          }
+        });
+      },
+      handleRetake(row) {
+        if (!row?.examId) {
+          this.$message.error('缺少考试ID');
+          return;
+        }
+        // 不在列表页调 retake,由答题页内部负责调用并加载试卷
+        this.$router.push({
+          path: '/safetyExam/myExam/taking',
+          query: {
+            examId: row.examId,
+            mode: 'retake',
+            submitted: row.submitted
+          }
+        });
+      },
+      // 是否可以查看错题:已完成(无论通过或待补考)即可查看
+      isCompleted(row) {
+        return hasExamRecord(row) && (row.status === 1 || row.status === 2);
+      },
+      // 是否可以补考:仅考试不合格(status=待补考)时显示
+      isRetakeAvailable(row) {
+        return hasExamRecord(row) && row.status === 2;
+      },
+      // 本次考试是否已合格:合格后不允许再次参加考试 / 补考
+      isPassed(row) {
+        return hasExamRecord(row) && row.passed === true;
+      },
+      // 考试是否已过结束时间:当前时间 > endTime,参加考试 / 补考都不允许
+      isExamEnded(row) {
+        if (!row || !row.endTime) return false;
+        const end = new Date(row.endTime);
+        if (Number.isNaN(end.getTime())) return false;
+        return Date.now() > end.getTime();
       },
       // 搜索
       handleSearch(where) {
@@ -165,10 +314,28 @@
           pageNum: page,
           size: limit
         };
-        return [];
+        return getMyPage(body);
       }
     }
   };
+
+  // 格式化考试状态:根据是否有完成时间或考核成绩判断有无考试记录
+  function formatStatus(row) {
+    if (!row || row.status === undefined || row.status === null) {
+      return '';
+    }
+    const map = hasExamRecord(row)
+      ? EXAM_STATUS_HAS_RECORD_MAP
+      : EXAM_STATUS_NO_RECORD_MAP;
+    return map[row.status] || '';
+  }
+
+  // 是否有考试记录:完成时间或考核成绩任意一个有值即视为有考试记录
+  function hasExamRecord(row) {
+    if (!row) return false;
+    if (row.completeTime) return true;
+    return row.assessmentScore !== null && row.assessmentScore !== undefined && row.assessmentScore !== '';
+  }
 </script>
 
 <style lang="scss" scoped>

+ 1316 - 0
src/views/safetyExam/myExam/taking.vue

@@ -0,0 +1,1316 @@
+<template>
+  <div class="exam-taking" v-loading="loading">
+    <!-- 顶部考试信息 -->
+    <el-card class="header-card" shadow="never">
+      <div class="exam-header">
+        <div class="exam-title">{{ examInfo.examName }}</div>
+        <div class="exam-meta">
+          <span class="meta-item">题目数量:{{ questionList.length }}</span>
+          <span class="meta-divider">|</span>
+          <span class="meta-item"
+            >考试时间:{{ formatDateTime(examInfo.startTime) }}</span
+          >
+          <span class="meta-divider">|</span>
+          <span class="meta-item">考试时长:{{ examInfo.durationMinutes }}分钟</span>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 主体区域 -->
+    <div class="exam-body" v-if="hasLoaded">
+      <!-- 左侧:答题区 -->
+      <div class="exam-aside">
+        <!-- 答题中:剩余时间 + 答题卡 -->
+        <template v-if="mode === 'taking'">
+          <div class="aside-card">
+            <div class="timer-bar">
+              <i class="el-icon-timer timer-icon"></i>
+              <span class="timer-label">剩余时间</span>
+              <span class="timer-value">{{ countdown }}</span>
+            </div>
+          </div>
+
+          <div class="aside-card">
+            <div class="aside-card-title">答题卡</div>
+            <div class="answer-card-grid">
+              <button
+                v-for="(q, idx) in questionList"
+                :key="q.id"
+                type="button"
+                class="answer-card-cell"
+                :class="{
+                  answered: isAnswered(q),
+                  current: idx === currentIndex
+                }"
+                @click="goTo(idx)"
+              >
+                {{ idx + 1 }}
+              </button>
+            </div>
+          </div>
+        </template>
+
+        <!-- 结果模式:成绩汇总 + 答题卡 -->
+        <template v-else>
+          <div class="aside-card result-summary-card">
+            <div class="aside-card-title">考试成绩</div>
+            <div class="result-summary">
+              <div class="summary-row">
+                <span class="summary-label">考核成绩:</span>
+                <span class="summary-value score-value">{{
+                  formatScore(examInfo.assessmentScore)
+                }}</span>
+              </div>
+              <div class="summary-row">
+                <span class="summary-label">补考成绩:</span>
+                <span class="summary-value">{{
+                  formatScore(examInfo.retakeScore)
+                }}</span>
+              </div>
+              <div
+                class="summary-row"
+                v-if="examInfo.passScore !== null && examInfo.passScore !== undefined"
+              >
+                <span class="summary-label">合格分:</span>
+                <span class="summary-value">{{ examInfo.passScore }}</span>
+              </div>
+              <div
+                class="summary-row"
+                v-if="examInfo.totalScore !== null && examInfo.totalScore !== undefined"
+              >
+                <span class="summary-label">总分:</span>
+                <span class="summary-value">{{ examInfo.totalScore }}</span>
+              </div>
+              <div class="summary-row">
+                <span class="summary-label">结果:</span>
+                <span
+                  class="summary-value"
+                  :class="{ 'is-fail': !isPass }"
+                  >{{ isPass ? '合格' : '不合格' }}</span
+                >
+              </div>
+            </div>
+          </div>
+
+          <div class="aside-card">
+            <div class="aside-card-title">答题卡</div>
+            <div class="answer-card-grid">
+              <button
+                v-for="(q, idx) in questionList"
+                :key="q.id"
+                type="button"
+                class="answer-card-cell"
+                :class="{
+                  answered: isAnswered(q),
+                  current: idx === currentIndex
+                }"
+                @click="goTo(idx)"
+              >
+                {{ idx + 1 }}
+              </button>
+            </div>
+          </div>
+        </template>
+
+        <!-- 考试人员 -->
+        <div class="aside-card">
+          <div class="aside-card-title">考试人员</div>
+          <div class="candidate">
+            <div class="candidate-avatar">
+              <i class="el-icon-user-solid"></i>
+            </div>
+            <div class="candidate-info">
+              <div class="candidate-row">
+                <span class="candidate-label">姓名:</span>
+                <span>{{ userInfo.realName || userInfo.name || userInfo.username || '-' }}</span>
+              </div>
+              <div class="candidate-row">
+                <span class="candidate-label">工号:</span>
+                <span>{{ userInfo.accountId || '-' }}</span>
+              </div>
+              <div class="candidate-row">
+                <span class="candidate-label">部门:</span>
+                <span>{{ deptInfo.name || userInfo.deptName || '-' }}</span>
+              </div>
+              <div class="candidate-row">
+                <span class="candidate-label">类型:</span>
+                <span>{{ userInfo.userType || '厂内人员' }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 右侧:题目区 -->
+      <div class="exam-main">
+        <el-card shadow="never" class="question-card">
+          <div class="question-card-header">
+            <span>{{
+              mode === 'taking' ? '题目' : '题目作答情况'
+            }}</span>
+            <span
+              v-if="mode === 'result'"
+              class="result-mode-tag"
+            >已交卷,仅查看</span>
+          </div>
+          <div class="question-card-body">
+            <div v-if="currentQuestion" class="question-block">
+              <div class="question-title">
+                <span class="question-no">{{ currentIndex + 1 }}.</span>
+                <span class="question-type">{{
+                  formatQuestionType(currentQuestion.questionType)
+                }}</span>
+                <span class="question-score" v-if="currentQuestion.questionScore"
+                  >({{ currentQuestion.questionScore }} 分)</span
+                >
+                <div class="question-content">
+                  {{ currentQuestion.questionContent }}
+                </div>
+              </div>
+
+              <!-- 选项题: 单选 / 判断 / 多选 -->
+              <div
+                v-if="isChoiceType(currentQuestion.questionType)"
+                class="option-list"
+              >
+                <!-- 单选 + 判断:都走单选组(judge 在你们系统里是 4 选项 radio) -->
+                <el-radio-group
+                  v-if="
+                    getQuestionTypeCode(currentQuestion.questionType) === 1 ||
+                    getQuestionTypeCode(currentQuestion.questionType) === 3
+                  "
+                  v-model="answers[currentQuestion.id]"
+                  :disabled="mode === 'result'"
+                  class="option-group"
+                  @change="onChoiceChange(currentQuestion, $event)"
+                >
+                  <el-radio
+                    v-for="opt in currentQuestion.optionList"
+                    :key="opt.id"
+                    :label="opt.sortNo"
+                    class="option-item option-item--radio"
+                  >
+                    <span class="option-label"
+                      >{{ String.fromCharCode(64 + opt.sortNo) }}.</span
+                    >
+                    <span class="option-content">{{ opt.optionContent }}</span>
+                  </el-radio>
+                </el-radio-group>
+
+                <!-- 多选:用原生 checkbox 完全控制样式,避开 el-checkbox 默认 font-size:0 / inline-block 布局 -->
+                <div
+                  v-else-if="
+                    getQuestionTypeCode(currentQuestion.questionType) === 2
+                  "
+                  class="option-group option-group--checkbox"
+                >
+                  <label
+                    v-for="opt in currentQuestion.optionList"
+                    :key="opt.id"
+                    class="option-item option-item--checkbox"
+                    :class="{
+                      'is-checked': isMultiChecked(
+                        currentQuestion.id,
+                        opt.sortNo
+                      ),
+                      'is-disabled': mode === 'result'
+                    }"
+                    @click.prevent="
+                      mode === 'taking' &&
+                        toggleMultiChoice(currentQuestion, opt.sortNo)
+                    "
+                  >
+                    <span class="checkbox-box">
+                      <span class="checkbox-tick"></span>
+                      <input
+                        type="checkbox"
+                        class="checkbox-native"
+                        :checked="
+                          isMultiChecked(currentQuestion.id, opt.sortNo)
+                        "
+                        :disabled="mode === 'result'"
+                        @click.stop
+                      />
+                    </span>
+                    <span class="option-label"
+                      >{{ String.fromCharCode(64 + opt.sortNo) }}.</span
+                    >
+                    <span class="option-content">{{
+                      opt.optionContent
+                    }}</span>
+                  </label>
+                </div>
+              </div>
+
+              <!-- 填空 / 问答 -->
+              <div v-else class="essay-block">
+                <el-input
+                  type="textarea"
+                  :rows="6"
+                  v-model="answers[currentQuestion.id]"
+                  :disabled="mode === 'result'"
+                  placeholder="请输入答案"
+                  @input="onEssayInput(currentQuestion, $event)"
+                />
+              </div>
+            </div>
+            <el-empty
+              v-else
+              description="暂无试题"
+              class="question-empty"
+            ></el-empty>
+          </div>
+
+          <div class="question-card-footer">
+            <template v-if="mode === 'taking'">
+              <el-button
+                :disabled="currentIndex === 0"
+                type="primary"
+                plain
+                @click="prev"
+                >上一题</el-button
+              >
+              <el-button
+                type="primary"
+                @click="next"
+                >下一题</el-button
+              >
+              <el-button
+                type="warning"
+                @click="handleSubmit"
+                >交卷</el-button
+              >
+            </template>
+          </div>
+        </el-card>
+      </div>
+    </div>
+
+    <el-empty
+      v-else
+      description="正在加载考试..."
+      class="loading-empty"
+    ></el-empty>
+  </div>
+</template>
+
+<script>
+  import {
+    start,
+    retake,
+    saveAnswer,
+    submitExam
+  } from '@/api/examManagement/examManagement.js';
+
+  // 题库类型映射(兼容不同来源的表示形式)
+  const QUESTION_TYPE_MAP = {
+    single: { code: 1, label: '单选题', isChoice: true },
+    SINGLE: { code: 1, label: '单选题', isChoice: true },
+    '1': { code: 1, label: '单选题', isChoice: true },
+    multiple: { code: 2, label: '多选题', isChoice: true },
+    MULTIPLE: { code: 2, label: '多选题', isChoice: true },
+    '2': { code: 2, label: '多选题', isChoice: true },
+    judge: { code: 3, label: '判断题', isChoice: true },
+    JUDGE: { code: 3, label: '判断题', isChoice: true },
+    '3': { code: 3, label: '判断题', isChoice: true },
+    qa: { code: 4, label: '问答题', isChoice: false },
+    QA: { code: 4, label: '问答题', isChoice: false },
+    essay: { code: 4, label: '问答题', isChoice: false },
+    ESSAY: { code: 4, label: '问答题', isChoice: false },
+    question_answer: { code: 4, label: '问答题', isChoice: false },
+    QUESTION_ANSWER: { code: 4, label: '问答题', isChoice: false },
+    '4': { code: 4, label: '问答题', isChoice: false }
+  };
+
+  export default {
+    name: 'ExamTaking',
+
+    data() {
+      return {
+        loading: false,
+        hasLoaded: false,
+        // 'taking' 答题中 | 'result' 交卷后查看结果
+        mode: 'taking',
+        // 'start' 首次考试 | 'retake' 补考
+        entryMode: 'start',
+        examInfo: {
+          attemptId: null,
+          examId: null,
+          examName: '',
+          durationMinutes: 0,
+          startTime: '',
+          endTime: '',
+          answerDeadline: '',
+          assessmentScore: null,
+          retakeScore: null,
+          passScore: null,
+          totalScore: null,
+          retakeCount: null,
+          examStatus: null
+        },
+        questionList: [],
+        answers: {}, // { [questionId]: answerValue }
+        currentIndex: 0,
+        countdown: '00:00:00',
+        timer: null,
+        serverTimeOffset: 0,
+        expiredHandled: false,
+        storageKey: '',
+        // 交卷接口原始返回,用于结果模式展示
+        submitResult: null,
+        // 单题答案保存的"静默中"标识,用于防抖控制
+        answerSaving: {}
+      };
+    },
+
+    computed: {
+      currentQuestion() {
+        return this.questionList[this.currentIndex] || null;
+      },
+      // 当前显示的最终分数(补考优先,否则用考核成绩)
+      currentScore() {
+        if (this.examInfo.retakeScore !== null && this.examInfo.retakeScore !== undefined && this.examInfo.retakeScore !== '') {
+          return this.examInfo.retakeScore;
+        }
+        return this.examInfo.assessmentScore;
+      },
+      // 是否合格:优先用 submit 接口返回的 passed,否则按分数 vs 合格线推断
+      isPass() {
+        if (this.submitResult && typeof this.submitResult.passed === 'boolean') {
+          return this.submitResult.passed;
+        }
+        const pass = Number(this.examInfo.passScore);
+        const score = Number(this.currentScore);
+        if (Number.isNaN(pass) || pass <= 0) {
+          // 没有合格线时,只要分数有值即视为合格(粗略判定)
+          return !Number.isNaN(score) && score > 0;
+        }
+        if (Number.isNaN(score)) return false;
+        return score >= pass;
+      },
+      // 登录用户信息
+      userInfo() {
+        return this.$store?.state?.user?.info || {};
+      },
+      // 当前数据权限部门信息
+      deptInfo() {
+        const dept = this.$store?.state?.user?.authorityDept;
+        if (!dept) return {};
+        return dept.deptName ? { name: dept.deptName } : dept;
+      }
+    },
+
+    created() {
+      this.init();
+    },
+
+    beforeDestroy() {
+      this.stopTimer();
+    },
+
+    methods: {
+      async init() {
+        const { examId, mode, data: dataStr, submitted } = this.$route.query;
+        if (!examId) {
+          this.$message.error('缺少考试ID,无法开始考试');
+          return;
+        }
+        this.entryMode = mode === 'retake' ? 'retake' : 'start';
+        this.storageKey = `exam-taking-${examId}-${this.entryMode}`;
+        // 优先使用上次暂存的答案(支持刷新恢复)
+        this.restoreAnswers();
+
+        try {
+          this.loading = true;
+          // 主路径:本页面主动调 start / retake 拿试卷
+          let data = this.parseQueryData(dataStr);
+          if (!data) {
+            data = await this.fetchAttemptData(
+              examId,
+              this.entryMode
+            );
+          }
+          this.bindExamData(data);
+          // mode=retake 表示"重新考试 / 补考",即便 submitted=true 也要进答题态
+          // submitted=true + mode=start 表示"已提交的考试,只查看结果"
+          if (mode === 'retake') {
+            this.mode = 'taking';
+            this.startTimer();
+          } else if (submitted === true || submitted === 'true') {
+            this.mode = 'result';
+            this.stopTimer();
+          } else {
+            this.mode = 'taking';
+            this.startTimer();
+          }
+          this.hasLoaded = true;
+        } catch (error) {
+          this.$message.error(error?.message || '获取试卷失败');
+        } finally {
+          this.loading = false;
+        }
+      },
+
+      // 解析列表页通过 query 透传过来的 start/retake 响应
+      parseQueryData(dataStr) {
+        if (!dataStr) return null;
+        try {
+          const raw = typeof dataStr === 'string'
+            ? decodeURIComponent(dataStr)
+            : dataStr;
+          const obj = JSON.parse(raw);
+          return obj && typeof obj === 'object' ? obj : null;
+        } catch (e) {
+          console.warn('解析路由 data 失败', e);
+          return null;
+        }
+      },
+
+      // 拉取最新试卷数据(列表页不再预调,本页面自己负责调 start / retake)
+      async fetchAttemptData(examId, mode) {
+        if (mode === 'retake') {
+          return retake({ examId: String(examId) });
+        }
+        return start({ examId: String(examId) });
+      },
+
+      bindExamData(data) {
+        if (!data) return;
+        this.examInfo = {
+          attemptId: data.attemptId,
+          examId: data.examId,
+          examName: data.examName,
+          durationMinutes: data.durationMinutes,
+          startTime: data.startTime,
+          endTime: data.endTime,
+          answerDeadline: data.answerDeadline,
+          assessmentScore: data.assessmentScore,
+          retakeScore: data.retakeScore,
+          passScore: data.passScore,
+          totalScore: data.totalScore,
+          retakeCount: data.retakeCount,
+          examStatus: data.examStatus
+        };
+        // 处理题目:用 id -> answer 的映射初始化
+        this.questionList = Array.isArray(data.questionList)
+          ? data.questionList
+          : [];
+        // 初始化答案:若有上次答案,优先用上次的;否则按后端返回的 answer 字段初始化
+        const previous = this.answers || {};
+        const next = {};
+        this.questionList.forEach((q) => {
+          if (previous[q.id] !== undefined) {
+            next[q.id] = previous[q.id];
+          } else if (q.answer !== undefined && q.answer !== null && q.answer !== '') {
+            const t = this.getQuestionTypeCode(q.questionType);
+            if (t === 2) {
+              next[q.id] = String(q.answer)
+                .split(',')
+                .map((s) => Number(s))
+                .filter((n) => !Number.isNaN(n));
+            } else {
+              next[q.id] = q.answer;
+            }
+          }
+        });
+        this.answers = next;
+
+        // 计算服务器时间偏移,便于后续倒计时校准
+        this.calibrateServerTime(data);
+      },
+
+      // 校准服务器与本地时间偏移(answerDeadline 是服务器时间)
+      calibrateServerTime(data) {
+        try {
+          const serverNow = data.answerDeadline
+            ? new Date(data.answerDeadline).getTime()
+            : Date.now();
+          // 服务端时间通常更早到达本地,这里粗略以 0 偏差处理,
+          // 真实严谨场景需要后端返回 now 字段
+          this.serverTimeOffset = 0;
+          this.deadlineMs = serverNow;
+        } catch (e) {
+          this.deadlineMs = Date.now() + (this.examInfo.durationMinutes || 30) * 60 * 1000;
+        }
+      },
+
+      startTimer() {
+        this.stopTimer();
+        this.tick();
+        this.timer = setInterval(this.tick, 1000);
+      },
+
+      stopTimer() {
+        if (this.timer) {
+          clearInterval(this.timer);
+          this.timer = null;
+        }
+      },
+
+      tick() {
+        if (!this.deadlineMs) {
+          this.countdown = '00:00:00';
+          return;
+        }
+        const remain = this.deadlineMs - Date.now() - this.serverTimeOffset;
+        if (remain <= 0) {
+          this.countdown = '00:00:00';
+          this.stopTimer();
+          if (!this.expiredHandled) {
+            this.expiredHandled = true;
+            this.$message.warning('考试时间已到,系统自动交卷');
+            // 自动交卷前也把当前简答 / 填空题的答案先落库
+            this.flushCurrentAnswer();
+            this.doSubmit();
+          }
+          return;
+        }
+        const totalSec = Math.floor(remain / 1000);
+        const h = String(Math.floor(totalSec / 3600)).padStart(2, '0');
+        const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
+        const s = String(totalSec % 60).padStart(2, '0');
+        this.countdown = `${h}:${m}:${s}`;
+        // 每次 tick 后把答案暂存起来,刷新页面不丢
+        this.persistAnswers();
+      },
+
+      persistAnswers() {
+        if (!this.storageKey) return;
+        try {
+          sessionStorage.setItem(this.storageKey, JSON.stringify({
+            answers: this.answers,
+            currentIndex: this.currentIndex
+          }));
+        } catch (e) {
+          // 静默失败,不影响功能
+        }
+      },
+
+      restoreAnswers() {
+        if (!this.storageKey) return;
+        try {
+          const cached = sessionStorage.getItem(this.storageKey);
+          if (cached) {
+            const parsed = JSON.parse(cached);
+            if (parsed.answers) this.answers = parsed.answers;
+            if (typeof parsed.currentIndex === 'number') {
+              this.currentIndex = parsed.currentIndex;
+            }
+          }
+        } catch (e) {
+          // ignore
+        }
+      },
+
+      clearPersistedAnswers() {
+        if (!this.storageKey) return;
+        try {
+          sessionStorage.removeItem(this.storageKey);
+        } catch (e) {
+          // ignore
+        }
+      },
+
+      // 题型识别:0=未识别,1=单选,2=多选,3=判断,4=问答
+      getQuestionTypeCode(type) {
+        if (type === undefined || type === null) return 1;
+        const key = String(type).toLowerCase();
+        const entry =
+          QUESTION_TYPE_MAP[key] ||
+          QUESTION_TYPE_MAP[String(type).replace(/\s/g, '')];
+        if (entry) return entry.code;
+        // 兼容中文:包含"多"=多选,包含"判"=判断,包含"问答"=问答,默认单选
+        if (typeof type === 'string') {
+          if (type.includes('问答')) return 4;
+          if (type.includes('多')) return 2;
+          if (type.includes('判')) return 3;
+          if (type.includes('单')) return 1;
+        }
+        return 1;
+      },
+
+      formatQuestionType(type) {
+        const code = this.getQuestionTypeCode(type);
+        return (
+          QUESTION_TYPE_MAP[String(code)]?.label || type || ''
+        );
+      },
+
+      isChoiceType(type) {
+        return this.getQuestionTypeCode(type) >= 1 && this.getQuestionTypeCode(type) <= 3;
+      },
+
+      isAnswered(q) {
+        const v = this.answers[q.id];
+        if (v === undefined || v === null || v === '') return false;
+        if (Array.isArray(v)) return v.length > 0;
+        return true;
+      },
+
+      prev() {
+        if (this.currentIndex > 0) {
+          this.flushCurrentAnswer();
+          this.currentIndex -= 1;
+          this.persistAnswers();
+        }
+      },
+
+      next() {
+        if (this.currentIndex < this.questionList.length - 1) {
+          this.flushCurrentAnswer();
+          this.currentIndex += 1;
+          this.persistAnswers();
+        }
+      },
+
+      goTo(idx) {
+        if (
+          idx >= 0 &&
+          idx < this.questionList.length &&
+          idx !== this.currentIndex
+        ) {
+          this.flushCurrentAnswer();
+          this.currentIndex = idx;
+          this.persistAnswers();
+        }
+      },
+
+      // 选项题变更(单选 / 判断):el-radio-group v-model 触发,value 为 sortNo
+      onChoiceChange(q, value) {
+        if (this.mode !== 'taking') return;
+        this.$set(this.answers, q.id, value);
+        this.persistAnswers();
+        this.persistSingleAnswer(q);
+      },
+
+      // 多选题:判断某 sortNo 是否已选
+      isMultiChecked(qid, sortNo) {
+        const arr = this.answers[qid];
+        return Array.isArray(arr) && arr.includes(sortNo);
+      },
+
+      // 多选题:点击切换某个选项
+      toggleMultiChoice(q, sortNo) {
+        if (this.mode !== 'taking') return;
+        const qid = q.id;
+        const cur = Array.isArray(this.answers[qid])
+          ? [...this.answers[qid]]
+          : [];
+        const idx = cur.indexOf(sortNo);
+        if (idx > -1) {
+          cur.splice(idx, 1);
+        } else {
+          cur.push(sortNo);
+        }
+        this.$set(this.answers, qid, cur);
+        this.persistAnswers();
+        this.persistSingleAnswer(q);
+      },
+
+      // 简答/填空题输入:只更新本地 + sessionStorage,saveAnswer 推迟到离开本题时再调
+      onEssayInput(q, value) {
+        if (this.mode !== 'taking') return;
+        this.$set(this.answers, q.id, value);
+        this.persistAnswers();
+      },
+
+      // 离开当前题前:若是简答 / 填空(code=4),把当前题答案落库
+      flushCurrentAnswer() {
+        const q = this.questionList[this.currentIndex];
+        if (!q) return;
+        const code = this.getQuestionTypeCode(q.questionType);
+        if (code === 4) {
+          this.persistSingleAnswer(q);
+        }
+      },
+
+      // 把某道题的答案调 /ehs/examManagement/answer 落库(防抖)
+      persistSingleAnswer(q) {
+        if (!q || !q.id) return;
+        if (this.answerSaving[q.id]) return; // 已有请求在飞
+        if (!this.examInfo.attemptId) return; // 没有 attemptId 不能落库
+
+        const value = this.answers[q.id];
+        const code = this.getQuestionTypeCode(q.questionType);
+        // 选择题:把 sortNo 转回 option.id(对应 selectedOptionIds 字段)
+        let selectedOptionIds = null;
+        let answerText = null;
+        if (code === 1 || code === 3) {
+          const sortNo = Array.isArray(value) ? value[0] : value;
+          const opt = (q.optionList || []).find((o) => o.sortNo === sortNo);
+          selectedOptionIds = opt ? [opt.id] : [];
+          if (code === 3) answerText = sortNo != null ? String(sortNo) : '';
+        } else if (code === 2) {
+          const sortNos = Array.isArray(value) ? value : value ? [value] : [];
+          selectedOptionIds = (q.optionList || [])
+            .filter((o) => sortNos.includes(o.sortNo))
+            .map((o) => o.id);
+        } else {
+          // 问答 / 填空
+          answerText = value == null ? '' : String(value);
+        }
+
+        const body = {
+          attemptId: this.examInfo.attemptId,
+          attemptQuestionId: q.id,
+          answer: answerText || '',
+          selectedOptionIds: selectedOptionIds || []
+        };
+
+        // 静默调用,不打断用户;失败也不弹错(交卷时由后端校验)
+        this.answerSaving[q.id] = true;
+        saveAnswer(body)
+          .catch((err) => {
+            console.warn('保存单题答案失败', err);
+          })
+          .finally(() => {
+            this.$set(this.answerSaving, q.id, false);
+          });
+      },
+
+      async handleSubmit() {
+        const unanswered = this.questionList.filter((q) => !this.isAnswered(q));
+        if (unanswered.length > 0) {
+          try {
+            await this.$confirm(
+              `还有 ${unanswered.length} 道题未作答,确认交卷吗?`,
+              '交卷确认',
+              {
+                confirmButtonText: '确认交卷',
+                cancelButtonText: '继续答题',
+                type: 'warning'
+              }
+            );
+          } catch (e) {
+            return;
+          }
+        }
+        // 交卷前把当前简答 / 填空题的答案先落库,避免遗失
+        this.flushCurrentAnswer();
+        this.doSubmit();
+      },
+
+      async doSubmit() {
+        this.stopTimer();
+        try {
+          this.loading = true;
+          // 调交卷接口,后端会写入 attempt 状态并给出最终结果
+          const result = await submitExam({
+            attemptId: this.examInfo.attemptId
+          });
+          this.clearPersistedAnswers();
+          this.$message.success('交卷成功');
+
+          // 用交卷结果更新考试信息,并进入结果模式
+          this.applySubmitResult(result);
+          this.mode = 'result';
+        } catch (error) {
+          this.$message.error(error?.message || '提交答卷失败');
+          // 失败时允许继续作答
+          this.startTimer();
+        } finally {
+          this.loading = false;
+        }
+      },
+
+      // 把交卷返回的结果合并到 examInfo
+      applySubmitResult(result) {
+        if (!result) return;
+        const prev = this.examInfo;
+        this.examInfo = {
+          ...prev,
+          attemptId: result.attemptId ?? prev.attemptId,
+          examId: result.examId ?? prev.examId,
+          assessmentScore:
+            result.assessmentScore ?? prev.assessmentScore,
+          retakeScore: result.retakeScore ?? prev.retakeScore,
+          totalScore: prev.totalScore,
+          examStatus: result.attemptStatus ?? prev.examStatus,
+          endTime: result.submitTime ?? prev.endTime,
+          // 交卷后 re-bind 题目数据,确保 answer 字段是最新的
+        };
+        // 记录是否合格(没有合格线时按 score > 0 粗略判定)
+        this.submitResult = result;
+      },
+
+      formatDateTime(value) {
+        if (!value) return '';
+        const d = new Date(value);
+        if (Number.isNaN(d.getTime())) return value;
+        const pad = (n) => String(n).padStart(2, '0');
+        return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+      },
+
+      // 格式化分数:空值显示 "-"
+      formatScore(value) {
+        if (value === undefined || value === null || value === '') return '-';
+        return value;
+      },
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .exam-taking {
+    padding: 16px;
+    background: #f5f7fa;
+    min-height: calc(100vh - 60px);
+  }
+
+  .header-card {
+    margin-bottom: 16px;
+
+    ::v-deep .el-card__body {
+      padding: 16px 20px;
+    }
+  }
+
+  .exam-header {
+    text-align: center;
+  }
+
+  .exam-title {
+    font-size: 22px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 8px;
+  }
+
+  .exam-meta {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    color: #606266;
+    font-size: 13px;
+    flex-wrap: wrap;
+  }
+
+  .meta-item {
+    margin: 0 4px;
+  }
+
+  .meta-divider {
+    color: #c0c4cc;
+    margin: 0 8px;
+  }
+
+  .exam-body {
+    display: flex;
+    gap: 16px;
+    align-items: flex-start;
+  }
+
+  .exam-aside {
+    width: 260px;
+    flex-shrink: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .aside-card {
+    background: #fff;
+    border: 1px solid #ebeef5;
+    border-radius: 4px;
+  }
+
+  .aside-card-title {
+    background: #f0f5ff;
+    color: #606266;
+    text-align: center;
+    padding: 8px 0;
+    font-size: 14px;
+    border-bottom: 1px solid #ebeef5;
+  }
+
+  .timer-bar {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 14px 12px;
+    gap: 10px;
+  }
+
+  .timer-icon {
+    color: #f56c6c;
+    font-size: 18px;
+  }
+
+  .timer-label {
+    color: #606266;
+    font-size: 14px;
+  }
+
+  .timer-value {
+    color: #f56c6c;
+    font-size: 22px;
+    font-weight: 600;
+    letter-spacing: 1px;
+    font-family: 'Courier New', Consolas, monospace;
+  }
+
+  .answer-card-grid {
+    display: grid;
+    grid-template-columns: repeat(9, 1fr);
+    gap: 8px;
+    padding: 14px 12px;
+  }
+
+  .answer-card-cell {
+    position: relative;
+    height: 32px;
+    border: 1px solid #dcdfe6;
+    background: #fff;
+    color: #606266;
+    font-size: 13px;
+    cursor: pointer;
+    border-radius: 4px;
+    transition: all 0.2s ease;
+    padding: 0;
+    line-height: 30px;
+    font-weight: 400;
+
+    &:hover:not(.answered) {
+      border-color: #409eff;
+      color: #409eff;
+      transform: translateY(-1px);
+      box-shadow: 0 2px 6px rgba(64, 158, 255, 0.12);
+    }
+
+    // 已答:实心蓝底白字,带阴影
+    &.answered {
+      background: #409eff;
+      border-color: #409eff;
+      color: #fff;
+      box-shadow: 0 1px 3px rgba(64, 158, 255, 0.35);
+    }
+
+    // 选中状态:加粗橙色边框 + 浅橙底,无论是否已答都凸显
+    &.current {
+      border: 2px solid #409eff;
+      line-height: 28px; // 补偿 2px 边框
+      font-weight: 600;
+
+      &.answered {
+        background: #409eff;
+        color: #fff;
+      }
+    }
+  }
+  
+  .candidate {
+    display: flex;
+    padding: 12px;
+    gap: 12px;
+  }
+
+  .candidate-avatar {
+    width: 60px;
+    height: 60px;
+    border: 1px solid #ebeef5;
+    border-radius: 4px;
+    background: #fafafa;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #c0c4cc;
+    font-size: 32px;
+    flex-shrink: 0;
+  }
+
+  .candidate-info {
+    flex: 1;
+    font-size: 12px;
+    color: #606266;
+    line-height: 1.8;
+  }
+
+  .candidate-label {
+    color: #909399;
+    margin-right: 4px;
+  }
+
+  .exam-main {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .question-card {
+    min-height: 540px;
+
+    ::v-deep .el-card__body {
+      padding: 0;
+    }
+  }
+
+  .question-card-header {
+    background: #f0f5ff;
+    color: #303133;
+    padding: 10px 16px;
+    font-size: 14px;
+    border-bottom: 1px solid #ebeef5;
+  }
+
+  .question-card-body {
+    padding: 20px 24px;
+  }
+
+  .question-card-footer {
+    display: flex;
+    justify-content: flex-end;
+    gap: 10px;
+    padding: 12px 24px;
+    border-top: 1px solid #ebeef5;
+    background: #fafafa;
+  }
+
+  .question-empty,
+  .loading-empty {
+    padding: 80px 0;
+  }
+
+  .question-title {
+    color: #303133;
+    font-size: 15px;
+    line-height: 1.8;
+    margin-bottom: 20px;
+  }
+
+  .question-no {
+    font-weight: 600;
+    color: #409eff;
+    margin-right: 6px;
+  }
+
+  .question-type {
+    color: #409eff;
+    font-weight: 500;
+    margin-right: 6px;
+  }
+
+  .question-score {
+    color: #909399;
+    font-size: 13px;
+  }
+
+  .question-content {
+    margin-top: 8px;
+    white-space: pre-wrap;
+  }
+
+  .option-list {
+    margin-top: 12px;
+  }
+
+  .option-group {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    width: 100%;
+  }
+
+  .option-item {
+    margin: 0 !important;
+    display: flex;
+    align-items: center;
+    white-space: normal;
+    word-break: break-word;
+    padding: 10px 14px;
+    border: 1px solid transparent;
+    border-radius: 4px;
+    transition: all 0.2s ease;
+    width: 100%;
+    box-sizing: border-box;
+    cursor: pointer;
+    user-select: none;
+
+    &:hover {
+      background: #f5f7fa;
+    }
+
+    // 选项被选中(单选组自动加 is-checked;多选由我们自己 toggle)
+    &.is-checked {
+      background: #ecf5ff;
+      border-color: #409eff;
+
+      .option-label {
+        color: #409eff;
+      }
+
+      .option-content {
+        color: #303133;
+        font-weight: 500;
+      }
+    }
+
+    &.is-disabled {
+      cursor: not-allowed;
+      opacity: 0.85;
+    }
+
+    // 单选(el-radio)的 input 控件与首行文字对齐
+    &--radio {
+      ::v-deep .el-radio__input {
+        display: inline-flex;
+        align-items: center;
+        line-height: 1.6;
+      }
+
+      ::v-deep .el-radio__label {
+        flex: 1;
+        padding-left: 8px !important;
+        line-height: 1.6;
+        white-space: normal;
+        word-break: break-word;
+      }
+    }
+
+    // 多选(原生 checkbox)容器内统一字号
+    &--checkbox {
+      font-size: 14px;
+    }
+  }
+
+  .option-label {
+    font-weight: 600;
+    color: #303133;
+    margin-right: 4px;
+    transition: color 0.2s;
+    flex-shrink: 0;
+  }
+
+  .option-content {
+    color: #303133;
+    transition: all 0.2s;
+    word-break: break-word;
+  }
+
+  // 多选分组:重置 elementui 的 font-size: 0
+  .option-group--checkbox {
+    font-size: 14px;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    width: 100%;
+  }
+
+  // 自绘复选框方框
+  .checkbox-box {
+    flex-shrink: 0;
+    position: relative;
+    width: 16px;
+    height: 16px;
+    border: 1px solid #dcdfe6;
+    border-radius: 2px;
+    background-color: #fff;
+    margin-top: 2px;
+    transition:
+      border-color 0.2s,
+      background-color 0.2s;
+    box-sizing: border-box;
+    margin-right: 8px;
+  }
+
+  .checkbox-tick {
+    position: absolute;
+    top: 1px;
+    left: 5px;
+    width: 4px;
+    height: 8px;
+    border: solid #fff;
+    border-width: 0 2px 2px 0;
+    transform: rotate(45deg) scale(0);
+    transform-origin: center;
+    opacity: 0;
+    transition: all 0.15s ease-in 0.05s;
+    box-sizing: border-box;
+  }
+
+  // 原生 checkbox 透明覆盖层(用于接收焦点 / 表单语义,视觉上交给 .checkbox-box)
+  .checkbox-native {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    opacity: 0;
+    margin: 0;
+    cursor: pointer;
+    z-index: 1;
+  }
+
+  .option-item--checkbox.is-checked {
+    .checkbox-box {
+      background-color: #409eff;
+      border-color: #409eff;
+    }
+
+    .checkbox-tick {
+      opacity: 1;
+      transform: rotate(45deg) scale(1);
+    }
+  }
+
+  .essay-block {
+    margin-top: 12px;
+  }
+
+  ::v-deep .el-radio {
+    margin-right: 0;
+    margin-bottom: 0;
+  }
+
+  // ============== 结果模式样式 ==============
+  .result-summary-card .aside-card-title {
+    background: #f0f9ff;
+    color: #409eff;
+  }
+
+  .result-summary {
+    padding: 14px 16px;
+    font-size: 13px;
+    line-height: 1.9;
+  }
+
+  .summary-row {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    border-bottom: 1px dashed #f0f0f0;
+    padding: 4px 0;
+
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  .summary-label {
+    color: #909399;
+  }
+
+  .summary-value {
+    color: #303133;
+    font-weight: 500;
+  }
+
+  .summary-value.score-value {
+    color: #f56c6c;
+    font-size: 16px;
+    font-weight: 600;
+  }
+
+  .summary-value.is-fail {
+    color: #f56c6c;
+  }
+
+  .result-mode-tag {
+    margin-left: 8px;
+    background: #e6f7ff;
+    color: #1890ff;
+    border: 1px solid #91d5ff;
+    font-size: 12px;
+    padding: 1px 8px;
+    border-radius: 10px;
+    font-weight: normal;
+  }
+</style>

+ 529 - 0
src/views/safetyExam/myExam/wrongQuestions.vue

@@ -0,0 +1,529 @@
+<template>
+  <div class="wrong-questions" v-loading="loading">
+    <!-- 顶部考试信息 -->
+    <div class="header-card">
+      <div class="exam-title">{{ headerInfo.examName }}</div>
+      <div class="exam-meta">
+        <div class="meta-row">
+          <span class="meta-item">
+            试卷名称:
+            <span class="meta-value">{{ headerInfo.paperName || '—' }}</span>
+          </span>
+          <span class="meta-item">
+            考试类型:
+            <span class="meta-value">{{
+              formatExamType(headerInfo.examType)
+            }}</span>
+          </span>
+          <span class="meta-item">
+            考核成绩:
+            <span class="meta-value">{{
+              formatScore(headerInfo.assessmentScore)
+            }}</span>
+          </span>
+          <span class="meta-item">
+            补考成绩:
+            <span class="meta-value">{{
+              formatScore(headerInfo.retakeScore || 0)
+            }}</span>
+          </span>
+          <el-button
+            class="export-btn"
+            type="primary"
+            size="small"
+            icon="el-icon-download"
+            @click="handleExport"
+            >导出试卷</el-button
+          >
+        </div>
+        <div class="meta-row">
+          <span class="meta-item">
+            结果:
+            <span
+              class="meta-value"
+              :class="{ 'is-fail': !isPass }"
+              >{{ isPass ? '合格' : '不合格' }}</span
+            >
+          </span>
+          <span class="meta-item">
+            考试人员:
+            <span class="meta-value">{{ headerInfo.userName || '—' }}</span>
+          </span>
+          <span class="meta-item">
+            所属部门:
+            <span class="meta-value">{{ headerInfo.deptName || '—' }}</span>
+          </span>
+          <span class="meta-item">
+            考试时间:
+            <span class="meta-value">{{
+              formatDateTime(headerInfo.examTime)
+            }}</span>
+          </span>
+        </div>
+      </div>
+    </div>
+
+    <!-- 错题列表 -->
+    <div class="question-list" v-if="questionList.length">
+      <div
+        v-for="(q, idx) in questionList"
+        :key="q.id || idx"
+        class="question-item"
+      >
+        <div class="question-header">
+          <span class="question-no">{{ idx + 1 }}.</span>
+          <span class="question-content">{{ q.questionContent }}</span>
+          <span class="question-type">{{
+            formatQuestionType(q.questionType)
+          }}</span>
+          <i class="el-icon-close question-wrong"></i>
+        </div>
+
+        <div class="option-list">
+          <div
+            v-for="opt in q.optionList"
+            :key="opt.id"
+            class="option-item"
+            :class="{
+              'is-correct': isCorrectOption(q, opt),
+              'is-user-wrong': isUserWrongOption(q, opt)
+            }"
+          >
+            <span class="option-label"
+              >{{ String.fromCharCode(64 + opt.sortNo) }}.</span
+            >
+            <span class="option-text">{{ opt.optionContent }}</span>
+          </div>
+        </div>
+
+        <!-- 答题信息 -->
+        <div class="answer-block">
+          <div class="answer-row">
+            <span class="answer-label">你的答案:</span>
+            <span class="answer-value">{{ formatAnswer(q.answer) }}</span>
+          </div>
+          <div class="answer-row">
+            <span class="answer-label">正确答案:</span>
+            <span class="answer-value correct">{{
+              formatAnswer(q.correctAnswer)
+            }}</span>
+          </div>
+          <div
+            class="answer-row"
+            v-if="q.questionExplanation"
+          >
+            <span class="answer-label">题目详解:</span>
+            <span class="answer-value explanation">{{
+              q.questionExplanation
+            }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <el-empty
+      v-else-if="!loading"
+      description="暂无错题"
+      class="empty-block"
+    ></el-empty>
+  </div>
+</template>
+
+<script>
+  import { mapGetters } from 'vuex';
+  import { getWrongQuestions, exportWrongQuestions } from '@/api/examManagement/examManagement.js';
+  import { download } from '@/utils/file';
+
+  // 题目类型映射
+  const QUESTION_TYPE_MAP = {
+    1: '单选题',
+    2: '多选题',
+    3: '判断题',
+    4: '问答题',
+    single: '单选题',
+    multiple: '多选题',
+    judge: '判断题',
+    qa: '问答题'
+  };
+
+  // 考试类型映射(与列表保持一致)
+  const EXAM_TYPE_MAP = {
+    1: '培训关联考试',
+    2: '专项考试'
+  };
+
+  export default {
+    name: 'ExamWrongQuestions',
+
+    data() {
+      return {
+        loading: false,
+        headerInfo: {},
+        questionList: []
+      };
+    },
+
+    computed: {
+      ...mapGetters(['user']),
+      // 登录用户信息
+      userInfo() {
+        return this.user?.info || {};
+      },
+      // 登录用户数据权限部门
+      deptInfo() {
+        const dept = this.$store?.state?.user?.authorityDept;
+        if (!dept) return {};
+        return dept.deptName ? { name: dept.deptName } : dept;
+      },
+      // 是否合格:补考成绩优先判断,任一分数非 0 即视为合格
+      isPass() {
+        const { assessmentScore, retakeScore } = this.headerInfo;
+        const a = Number(assessmentScore);
+        const r = Number(retakeScore);
+        if (!Number.isNaN(r) && r > 0) return true;
+        if (!Number.isNaN(a) && a > 0) return true;
+        return false;
+      }
+    },
+
+    created() {
+      this.parseQuery();
+      this.loadWrongQuestions();
+    },
+
+    methods: {
+      // 从 query 读取 examId / userId + assessmentScore / retakeScore(接口不返回)
+      // examName / paperName / examType / examTime 由接口 examInfo 返回
+      // 考试人员 / 所属部门 从登录用户 store 拿
+      parseQuery() {
+        const q = this.$route.query || {};
+        this.queryParams = {
+          examId: q.examId,
+          userId: q.userId,
+          assessmentScore: q.assessmentScore,
+          retakeScore: q.retakeScore
+        };
+      },
+
+      async loadWrongQuestions() {
+        const { examId, userId } = this.queryParams || {};
+        if (!examId) {
+          this.$message.error('缺少考试ID,无法加载错题');
+          return;
+        }
+        try {
+          this.loading = true;
+          // getWrongQuestions 内部已 unwrap res.data.data,这里拿到的是 { examInfo, questionList, passScore }
+          const data = await getWrongQuestions({
+            examId,
+            userId
+          });
+          const wrapObj = (data && typeof data === 'object') ? data : {};
+          const examInfo = wrapObj.examInfo || {};
+
+          // 头部信息合并:接口 examInfo 优先,query 兜底,store 拿人员/部门
+          this.headerInfo = {
+            examId,
+            userId,
+            examName: examInfo.examName || '—',
+            paperName: examInfo.paperName || '',
+            examType: examInfo.examType ?? '',
+            assessmentScore:
+              examInfo.assessmentScore ?? this.queryParams.assessmentScore,
+            retakeScore:
+              examInfo.retakeScore ?? this.queryParams.retakeScore,
+            userName:
+              this.userInfo.realName ||
+              this.userInfo.name ||
+              this.userInfo.username ||
+              '',
+            deptName: this.deptInfo.name || this.userInfo.deptName || '',
+            examTime: examInfo.startTime || ''
+          };
+
+          this.questionList = Array.isArray(wrapObj.questionList)
+            ? wrapObj.questionList
+            : [];
+        } catch (error) {
+          this.$message.error(error?.message || '获取错题失败');
+        } finally {
+          this.loading = false;
+        }
+      },
+
+      // 格式化题目类型
+      formatQuestionType(type) {
+        if (type === undefined || type === null) return '';
+        const key = String(type).toLowerCase();
+        return (
+          QUESTION_TYPE_MAP[type] ||
+          QUESTION_TYPE_MAP[key] ||
+          (typeof type === 'string' && /[一-龥]/.test(type)
+            ? type
+            : '')
+        );
+      },
+
+      // 格式化考试类型
+      formatExamType(type) {
+        if (type === undefined || type === null || type === '') return '—';
+        return EXAM_TYPE_MAP[type] || type || '—';
+      },
+
+      // 格式化分数:- / 0
+      formatScore(score) {
+        if (score === undefined || score === null || score === '') return '-';
+        return score;
+      },
+
+      // 格式化日期
+      formatDateTime(value) {
+        if (!value) return '—';
+        const d = new Date(value);
+        if (Number.isNaN(d.getTime())) return value;
+        const pad = (n) => String(n).padStart(2, '0');
+        return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+      },
+
+      // 把 "1,3" 这样的答案转成 "A,C"
+      formatAnswer(value) {
+        if (value === undefined || value === null || value === '') return '—';
+        const code = (n) => String.fromCharCode(64 + Number(n));
+        return String(value)
+          .split(',')
+          .map((s) => {
+            const n = Number(s);
+            if (!Number.isNaN(n) && n > 0) return code(n);
+            return s;
+          })
+          .filter(Boolean)
+          .join(',');
+      },
+
+      // 选项是否是正确答案
+      isCorrectOption(q, opt) {
+        const correct = String(q.correctAnswer || '')
+          .split(',')
+          .map((s) => Number(s))
+          .filter((n) => !Number.isNaN(n));
+        return correct.includes(Number(opt.sortNo));
+      },
+
+      // 选项是否是被用户选错的(用户选了但没答对)
+      isUserWrongOption(q, opt) {
+        const userAns = String(q.answer || '')
+          .split(',')
+          .map((s) => Number(s))
+          .filter((n) => !Number.isNaN(n));
+        const correct = String(q.correctAnswer || '')
+          .split(',')
+          .map((s) => Number(s))
+          .filter((n) => !Number.isNaN(n));
+        if (!userAns.includes(Number(opt.sortNo))) return false;
+        // 用户选了但不在正确答案里 → 选错
+        return !correct.includes(Number(opt.sortNo));
+      },
+
+      // 导出试卷
+      async handleExport() {
+        const { examId, userId } = this.headerInfo || {};
+        if (!examId) {
+          this.$message.error('缺少考试ID,无法导出');
+          return;
+        }
+        if (this.exportLoading) return;
+        this.exportLoading = true;
+        try {
+          const blob = await exportWrongQuestions({
+            examId,
+            userId
+          });
+          const baseName =
+            this.headerInfo.examName && this.headerInfo.examName !== '—'
+              ? this.headerInfo.examName
+              : '错题';
+          const filename = `${baseName}-错题导出.docx`;
+          download(blob, filename);
+          this.$message.success('导出成功');
+        } catch (error) {
+          this.$message.error(error?.message || '导出失败');
+        } finally {
+          this.exportLoading = false;
+        }
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .wrong-questions {
+    padding: 16px;
+    background: #f5f7fa;
+    min-height: calc(100vh - 60px);
+  }
+
+  .header-card {
+    background: #e6f3ff;
+    border-radius: 4px;
+    padding: 16px 24px;
+    margin-bottom: 16px;
+    border: 1px solid #cce5ff;
+  }
+
+  .exam-title {
+    font-size: 18px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 12px;
+  }
+
+  .exam-meta {
+    color: #303133;
+    font-size: 13px;
+  }
+
+  .meta-row {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    margin-bottom: 6px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .meta-item {
+    margin-right: 32px;
+    color: #606266;
+    line-height: 1.8;
+  }
+
+  .meta-value {
+    color: #409eff;
+  }
+
+  .is-fail {
+    color: #f56c6c !important;
+  }
+
+  .export-btn {
+    margin-left: auto;
+  }
+
+  .question-list {
+    background: #fff;
+    border-radius: 4px;
+    border: 1px solid #ebeef5;
+  }
+
+  .question-item {
+    padding: 18px 24px;
+    border-bottom: 1px solid #ebeef5;
+
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  .question-header {
+    display: flex;
+    align-items: flex-start;
+    flex-wrap: wrap;
+    margin-bottom: 12px;
+    line-height: 1.6;
+  }
+
+  .question-no {
+    color: #303133;
+    font-weight: 500;
+    margin-right: 6px;
+  }
+
+  .question-content {
+    color: #303133;
+    flex: 1;
+    min-width: 0;
+  }
+
+  .question-type {
+    color: #909399;
+    font-size: 13px;
+    margin: 0 8px;
+    flex-shrink: 0;
+  }
+
+  .question-wrong {
+    color: #f56c6c;
+    font-size: 16px;
+    flex-shrink: 0;
+    margin-top: 2px;
+  }
+
+  .option-list {
+    margin-bottom: 12px;
+    padding-left: 16px;
+  }
+
+  .option-item {
+    line-height: 2;
+    color: #303133;
+    display: flex;
+    align-items: flex-start;
+
+    &.is-correct {
+      color: #67c23a;
+    }
+
+    &.is-user-wrong {
+      color: #f56c6c;
+    }
+  }
+
+  .option-label {
+    font-weight: 500;
+    margin-right: 6px;
+  }
+
+  .option-text {
+    word-break: break-word;
+  }
+
+  .answer-block {
+    background: #fafafa;
+    border-radius: 4px;
+    padding: 10px 16px;
+    margin-left: 16px;
+    font-size: 13px;
+    line-height: 1.8;
+  }
+
+  .answer-row {
+    display: flex;
+    flex-wrap: wrap;
+  }
+
+  .answer-label {
+    color: #909399;
+    margin-right: 8px;
+    flex-shrink: 0;
+  }
+
+  .answer-value {
+    color: #f56c6c;
+  }
+
+  .answer-value.correct {
+    color: #67c23a;
+  }
+
+  .answer-value.explanation {
+    color: #606266;
+    flex: 1;
+    min-width: 0;
+  }
+
+  .empty-block {
+    padding: 80px 0;
+  }
+</style>