Browse Source

调整修改 exam 模块

reghao 1 week ago
parent
commit
6b3ba08ea2

+ 5 - 5
src/api/exam.js

@@ -117,15 +117,15 @@ export function getCachedUserAnswer(queryParams) {
 
 // *********************************************************************************************************************
 // 阅卷接口
-export function getMarkView(paperId, userId) {
-  return get(examApi.getMarkView + '?paperId=' + paperId + '&userId=' + userId)
-}
-
 export function getExamMarkList(params) {
   return get(examApi.getExamMarkList, params)
 }
 
-export function submitExamMark(jsonData) {
+export function getMarkView(queryParams) {
+  return get(examApi.getMarkView, queryParams)
+}
+
+export function submitMarkResult(jsonData) {
   return post(examApi.submitExamMark, jsonData)
 }
 

+ 8 - 1
src/router/exam.js

@@ -14,6 +14,7 @@ const ExamCard = () => import('views/exam/ExamCard')
 const ExamScoreIndex = () => import('views/exam/ExamScoreIndex')
 const ExamMarkIndex = () => import('views/exam/ExamMarkIndex')
 const ExamPaperPreview = () => import('views/exam/ExamPaperPreview')
+const ExamPaperMark = () => import('views/exam/ExamPaperMark')
 
 export default {
   path: '/exam',
@@ -88,10 +89,16 @@ export default {
       meta: { needAuth: true }
     },
     {
-      path: '/exam/paper/detail',
+      path: '/exam/paper/preview',
       name: 'ExamPaperPreview',
       component: ExamPaperPreview,
       meta: { needAuth: true }
+    },
+    {
+      path: '/exam/paper/mark',
+      name: 'ExamPaperMark',
+      component: ExamPaperMark,
+      meta: { needAuth: true }
     }
   ]
 }

+ 2 - 2
src/views/exam/ExamEvalList.vue

@@ -295,7 +295,7 @@ export default {
     markPaper(index, row) {
       const paperId = row.examId
       const routeUrl = this.$router.resolve({
-        path: '/exam/paper/detail',
+        path: '/exam/paper/preview',
         query: {
           paperId: paperId,
           viewType: 3,
@@ -307,7 +307,7 @@ export default {
     viewResult(index, row) {
       const paperId = row.examId
       const routeUrl = this.$router.resolve({
-        path: '/exam/paper/detail',
+        path: '/exam/paper/preview',
         query: {
           paperId: paperId,
           viewType: 4,

+ 1 - 1
src/views/exam/ExamMarkIndex.vue

@@ -155,7 +155,7 @@ export default {
       const paperId = row.paperId
       const userId = row.userId
       const routeUrl = this.$router.resolve({
-        path: '/exam/paper/detail',
+        path: '/exam/paper/preview',
         query: {
           paperId: paperId,
           viewType: 3,

+ 138 - 106
src/views/exam/ExamPaper.vue

@@ -57,7 +57,8 @@
           <template v-if="q.typeCode === 1">
             <div class="question-stem">
               <span class="q-number">{{ q.number }}.</span>
-              <span class="q-text" v-html="formatStem(q.content, q.score)" />
+              <span class="q-score-highlight">({{ q.score }}分)</span>
+              <span class="q-text" v-html="formatStem(q.content)" />
             </div>
             <div class="question-body">
               <el-radio-group v-model="answers[q.questionId]" @change="handleAnswerChange(q.questionId)">
@@ -71,7 +72,8 @@
           <template v-else-if="q.typeCode === 2">
             <div class="question-stem">
               <span class="q-number">{{ q.number }}.</span>
-              <span class="q-text" v-html="formatStem(q.content, q.score)" />
+              <span class="q-score-highlight">({{ q.score }}分)</span>
+              <span class="q-text" v-html="formatStem(q.content)" />
               <el-tag type="warning" size="mini" class="type-tag">多选</el-tag>
             </div>
             <div class="question-body">
@@ -83,10 +85,11 @@
             </div>
           </template>
 
-          <template v-else-if="q.typeCode === 4">
+          <template v-else-if="q.typeCode === 3">
             <div class="question-stem">
               <span class="q-number">{{ q.number }}.</span>
-              <span class="q-text" v-html="formatStem(q.content, q.score)" />
+              <span class="q-score-highlight">({{ q.score }}分)</span>
+              <span class="q-text" v-html="formatStem(q.content)" />
             </div>
             <div class="question-body completion-body">
               <div v-for="(item, index) in answers[q.questionId]" :key="index" class="completion-item">
@@ -101,10 +104,11 @@
             </div>
           </template>
 
-          <template v-else-if="q.typeCode === 5">
+          <template v-else-if="q.typeCode === 4">
             <div class="question-stem">
               <span class="q-number">{{ q.number }}.</span>
-              <span class="q-text" v-html="formatStem(q.content, q.score)" />
+              <span class="q-score-highlight">({{ q.score }}分)</span>
+              <span class="q-text" v-html="formatStem(q.content)" />
             </div>
             <div class="question-body">
               <el-input
@@ -117,7 +121,7 @@
             </div>
           </template>
 
-          <template v-else-if="q.typeCode === 6">
+          <template v-else-if="q.typeCode === 5">
             <div class="question-stem material-stem">
               <span class="material-tag">【组合材料】</span>
               <div class="q-text" style="white-space: pre-wrap;">{{ q.content }}</div>
@@ -127,7 +131,8 @@
               <div v-for="subQ in q.children" :key="subQ.questionId" class="sub-question-item">
                 <div class="question-stem sub-stem">
                   <span class="q-number">{{ subQ.number }}.</span>
-                  <span class="q-text">{{ subQ.content }}({{ subQ.score }}分)</span>
+                  <span class="q-score-highlight">({{ subQ.score }}分)</span>
+                  <span class="q-text">{{ subQ.content }}</span>
                 </div>
 
                 <div class="question-body">
@@ -147,7 +152,7 @@
                     </el-checkbox-group>
                   </template>
 
-                  <template v-else-if="subQ.typeCode === 4">
+                  <template v-else-if="subQ.typeCode === 3">
                     <div class="completion-body">
                       <div v-for="(item, index) in answers[subQ.questionId]" :key="index" class="completion-item">
                         <span class="completion-prefix">第 {{ index + 1 }} 空:</span>
@@ -160,6 +165,18 @@
                       </div>
                     </div>
                   </template>
+
+                  <template v-else-if="subQ.typeCode === 4">
+                    <div class="completion-body">
+                      <el-input
+                        v-model="answers[subQ.questionId]"
+                        type="textarea"
+                        :rows="6"
+                        placeholder="请输入作答内容..."
+                        @change="handleAnswerChange(subQ.questionId)"
+                      />
+                    </div>
+                  </template>
                 </div>
               </div>
             </div>
@@ -184,8 +201,6 @@ export default {
       timeLoading: true,
       timer: null,
       currentActiveId: null,
-      // 🧠 核心思路:前端在组件内部依然使用 Object 方便 v-model 绑定和实时读写
-      // 在「读取缓存」和「打包提交」时,与你要求的数组结构进行相互转换
       answers: {},
       paperInfo: {},
       sectionsList: []
@@ -196,30 +211,24 @@ export default {
       if (this.timeLoading) {
         return '正在获取时间...'
       }
-
       const hours = Math.floor(this.remainTime / 3600).toString().padStart(2, '0')
       const mins = Math.floor((this.remainTime % 3600) / 60).toString().padStart(2, '0')
       const secs = Math.floor(this.remainTime % 60).toString().padStart(2, '0')
       return `${hours}:${mins}:${secs}`
     },
-    // 🛠️ 新增计算属性:实时将 answers 对象转换为你要求的数组结构
-    // 这样当你想随时拿到符合后端要求的格式时,直接读取 this.formattedAnswersSubmit 即可
     formattedAnswersSubmit() {
       return Object.keys(this.answers).map(qId => {
         const val = this.answers[qId]
         let answerList = []
 
         if (Array.isArray(val)) {
-          // 如果本身就是填空题数组,直接赋值(或过滤掉完全未填写的空,看后端需求,这里保持原样)
           answerList = [...val]
         } else {
-          // 如果是单选、简答等字符串,且不为空,则作为数组的唯一元素
-          // 如果完全没写,可以返回空数组 [] 或 [""]
           answerList = val && String(val).trim() !== '' ? [val] : []
         }
 
         return {
-          questionId: Number(qId), // 确保是数字类型的 ID
+          questionId: Number(qId),
           answerList: answerList
         }
       })
@@ -231,7 +240,6 @@ export default {
       this.$message.error('参数缺失,无法加载测评页面!')
       return
     }
-
     this.queryParams.examId = examId
     this.loadPaperData()
   },
@@ -240,97 +248,119 @@ export default {
   },
   methods: {
     async loadPaperData() {
-      // 1. 🚀 优先拿到异步的后端缓存数据
-      const cachedAnswersArray = await this.getCacheAnswer()
-      const cachedAnswersMap = {}
+      try {
+        const resp = await getExamInfo(this.queryParams)
+        if (resp.code !== 0) {
+          this.$message.warning(resp.msg)
+          return
+        }
 
-      if (cachedAnswersArray && Array.isArray(cachedAnswersArray)) {
-        cachedAnswersArray.forEach(item => {
-          cachedAnswersMap[item.questionId] = item.answerList
-        })
-      }
+        const examData = resp.data
+        this.remainTime = examData.duration
+        this.timeLoading = false
+        this.startTimer()
+
+        const remoteData = examData.paperDetail
+        this.paperInfo = {
+          paperId: remoteData.paperId,
+          title: remoteData.title,
+          subjectId: remoteData.subjectId,
+          subjectName: remoteData.subjectName
+        }
 
-      // 2. 获取试卷结构并初始化答案
-      getExamInfo(this.queryParams).then(resp => {
-        if (resp.code === 0) {
-          const examData = resp.data
-          this.remainTime = examData.duration
-          this.timeLoading = false
-          this.startTimer()
-
-          const remoteData = examData.paperDetail
-          this.paperInfo = {
-            paperId: remoteData.paperId,
-            title: remoteData.title,
-            subjectId: remoteData.subjectId,
-            subjectName: remoteData.subjectName
-          }
+        let globalQuestionIndex = 1
+        const localAnswers = {}
 
-          let globalQuestionIndex = 1
-          const localAnswers = {}
+        this.sectionsList = remoteData.sections.map(sec => {
+          const flatQuestions = []
 
-          this.sectionsList = remoteData.sections.map(sec => {
-            const flatQuestions = []
+          const ProcessedQuestions = sec.questions.map(q => {
+            if (q.answers && !q.options) {
+              q.options = q.answers
+            }
+
+            if (q.typeCode !== 5) {
+              q.number = globalQuestionIndex++
+              flatQuestions.push({ questionId: q.questionId, number: q.number, typeCode: q.typeCode })
 
-            const ProcessedQuestions = sec.questions.map(q => {
-              if (q.answers && !q.options) {
-                q.options = q.answers
+              if (q.typeCode === 3) {
+                localAnswers[q.questionId] = new Array((q.options || []).length).fill('')
+              } else if (q.typeCode === 2) {
+                localAnswers[q.questionId] = []
+              } else {
+                localAnswers[q.questionId] = ''
               }
+            }
+            else if (q.typeCode === 5 && q.children) {
+              q.children.forEach(subQ => {
+                if (subQ.answers && !subQ.options) {
+                  subQ.options = subQ.answers
+                }
+                subQ.number = globalQuestionIndex++
+                flatQuestions.push({ questionId: subQ.questionId, number: subQ.number, typeCode: subQ.typeCode })
 
-              if (q.typeCode !== 6) {
-                q.number = globalQuestionIndex++
-                flatQuestions.push({ questionId: q.questionId, number: q.number, typeCode: q.typeCode })
+                if (subQ.typeCode === 3) {
+                  localAnswers[subQ.questionId] = new Array((subQ.options || []).length).fill('')
+                } else if (subQ.typeCode === 2) {
+                  localAnswers[subQ.questionId] = []
+                } else {
+                  localAnswers[subQ.questionId] = ''
+                }
+              })
+            }
+            return q
+          })
+
+          return {
+            sectionId: sec.sectionId,
+            sectionName: sec.sectionName,
+            sectionHint: sec.sectionHint,
+            questions: ProcessedQuestions,
+            flatQuestions: flatQuestions
+          }
+        })
+
+        this.answers = localAnswers
+
+        const cachedAnswersArray = await this.getCacheAnswer()
 
-                // 🛠️ 此时 cachedAnswersMap 已经有完整数据了,可以安全还原
-                if (cachedAnswersMap && cachedAnswersMap[q.questionId] !== undefined) {
-                  const savedList = cachedAnswersMap[q.questionId]
-                  localAnswers[q.questionId] = q.typeCode === 4 ? savedList : (savedList[0] || '')
+        if (cachedAnswersArray && Array.isArray(cachedAnswersArray)) {
+          const updatedAnswers = { ...this.answers }
+
+          cachedAnswersArray.forEach(item => {
+            const qId = item.questionId
+            const savedList = item.answerList
+
+            if (updatedAnswers[qId] !== undefined && savedList && savedList.length > 0) {
+              const isArrayType = Array.isArray(updatedAnswers[qId])
+
+              if (isArrayType) {
+                if (updatedAnswers[qId].length > 0 && typeof updatedAnswers[qId][0] === 'string') {
+                  updatedAnswers[qId] = updatedAnswers[qId].map((v, index) => {
+                    return savedList[index] !== undefined ? savedList[index] : ''
+                  })
                 } else {
-                  localAnswers[q.questionId] = q.typeCode === 4 ? new Array((q.options || []).length).fill('') : ''
+                  updatedAnswers[qId] = [...savedList]
                 }
-              } else if (q.typeCode === 6 && q.children) {
-                q.children.forEach(subQ => {
-                  if (subQ.answers && !subQ.options) {
-                    subQ.options = subQ.answers
-                  }
-                  subQ.number = globalQuestionIndex++
-                  flatQuestions.push({ questionId: subQ.questionId, number: subQ.number, typeCode: subQ.typeCode })
-
-                  if (cachedAnswersMap && cachedAnswersMap[subQ.questionId] !== undefined) {
-                    const savedList = cachedAnswersMap[subQ.questionId]
-                    localAnswers[subQ.questionId] = subQ.typeCode === 4 ? savedList : (savedList[0] || '')
-                  } else {
-                    localAnswers[subQ.questionId] = subQ.typeCode === 4 ? new Array((subQ.options || []).length).fill('') : ''
-                  }
-                })
+              } else {
+                updatedAnswers[qId] = savedList[0] || ''
               }
-              return q
-            })
-
-            return {
-              sectionId: sec.sectionId,
-              sectionName: sec.sectionName,
-              sectionHint: sec.sectionHint,
-              questions: ProcessedQuestions,
-              flatQuestions: flatQuestions
             }
           })
-          this.answers = localAnswers
-        } else {
-          this.$message.warning(resp.msg)
+
+          this.answers = updatedAnswers
         }
-      }).catch(error => {
-        this.$message.error(error.message)
-      })
+
+      } catch (error) {
+        this.$message.error(error.message || '加载试卷数据失败')
+        console.error(error)
+      }
     },
 
-    // 监听任意作答变更,实时写入本地缓存
     handleAnswerChange(qId) {
-      // 🛠️ 调整:写入缓存时,直接写入符合新结构要求的数组
       this.cacheAnswer(this.formattedAnswersSubmit)
     },
 
-    // 缓存工具方法:写入
     cacheAnswer(answersData) {
       const cachedPaperAnswer = {
         examId: this.queryParams.examId,
@@ -338,26 +368,21 @@ export default {
         answers: answersData
       }
       cacheUserAnswer(cachedPaperAnswer).then(resp => {
-        if (resp.code !== 0) {
-          console.log(resp.msg)
-        }
+        if (resp.code !== 0) console.log(resp.msg)
       })
     },
 
-    // 缓存工具方法:读取
     async getCacheAnswer() {
-      const queryParams = {
-        examId: this.queryParams.examId
-      }
+      const queryParams = { examId: this.queryParams.examId }
       try {
         const resp = await getCachedUserAnswer(queryParams)
         if (resp.code === 0 && resp.data) {
-          return resp.data.answers // 成功时返回答案数组
+          return resp.data.answers
         }
       } catch (error) {
         console.error('获取缓存答案失败:', error)
       }
-      return null // 异常或无数据时返回 null
+      return null
     },
 
     startTimer() {
@@ -374,7 +399,6 @@ export default {
       }, 1000)
     },
 
-    // 智能检查是否已作答
     checkIsAnswered(question) {
       const ans = this.answers[question.questionId]
       if (ans === undefined || ans === null) return false
@@ -401,9 +425,11 @@ export default {
       }
     },
 
-    formatStem(content, score) {
+    // 🛠️【核心修改:formatStem 只接收 content 并纯化内容】
+    formatStem(content) {
       if (!content) return ''
-      return content.replace(/(\s*\d+分\s*)/, `(${score}分)`)
+      // 仅移除可能存在的冗余尾部 “(X分)” 文本,不进行任何样式标签的拼接
+      return content.replace(/(\s*\d+分\s*)/g, '')
     },
 
     handleConfirmSubmit() {
@@ -428,10 +454,11 @@ export default {
     },
 
     submitExam() {
-      const jsonData = {}
-      jsonData.examId = this.queryParams.examId
-      jsonData.paperId = this.paperInfo.paperId
-      jsonData.answers = this.formattedAnswersSubmit
+      const jsonData = {
+        examId: this.queryParams.examId,
+        paperId: this.paperInfo.paperId,
+        answers: this.formattedAnswersSubmit
+      }
       submitExam(jsonData).then(resp => {
         if (resp.code === 0) {
           this.$message.success('试卷提交成功!')
@@ -504,4 +531,9 @@ export default {
   width: 80px;
   flex-shrink: 0;
 }
+.q-score-highlight {
+  color: #67C23A;      /* ElementUI 经典成功绿 */
+  font-weight: bold;   /* 字体加粗 */
+  margin-right: 4px;   /* 与后面题干文字拉开微小间距 */
+}
 </style>

+ 5 - 5
src/views/exam/ExamPaperAssembly.vue

@@ -82,12 +82,12 @@
                 <tr v-for="(q, qIndex) in sec.questions" :key="q.questionId">
                   <td class="text-center font-bold">{{ qIndex + 1 }}</td>
                   <td class="text-center">
-                    <el-tag size="mini" :type="q.typeCode === 6 ? 'warning' : 'success'">{{ q.typeStr }}</el-tag>
+                    <el-tag size="mini" :type="q.typeCode === 5 ? 'warning' : 'success'">{{ q.typeStr }}</el-tag>
                   </td>
                   <td>
                     <div class="table-q-content">{{ q.content }}</div>
 
-                    <div v-if="q.typeCode === 6" class="table-sub-children-box">
+                    <div v-if="q.typeCode === 5" class="table-sub-children-box">
                       <div v-for="(sub, subIdx) in q.children" :key="sub.questionId" class="sub-child-row">
                         <span>子问题 ({{ subIdx + 1 }}): 题干 [{{ sub.content }}]</span>
                         <div class="sub-score-input">
@@ -102,9 +102,9 @@
                       :min="0"
                       size="small"
                       style="width: 100%"
-                      :disabled="q.typeCode === 6"
+                      :disabled="q.typeCode === 5"
                     />
-                    <small v-if="q.typeCode === 6" class="help-text">由子问题分值累加</small>
+                    <small v-if="q.typeCode === 5" class="help-text">由子问题分值累加</small>
                   </td>
                   <td class="text-center">
                     <el-button type="text" icon="el-icon-top" :disabled="qIndex === 0" @click="moveQuestion(secIndex, qIndex, -1)" />
@@ -255,7 +255,7 @@ export default {
     },
     // 针对复合题:当修改内部任何一个子问题的分数时,自动重新累加更新父组合题的 score
     syncCompositeTotalScore(question) {
-      if (question.typeCode === 6 && question.children) {
+      if (question.typeCode === 5 && question.children) {
         question.score = question.children.reduce((sum, sub) => sum + (sub.score || 0), 0)
       }
     },

+ 1 - 1
src/views/exam/ExamPaperList.vue

@@ -254,7 +254,7 @@ export default {
     previewPaper(index, row) {
       const paperId = row.paperId
       const routeUrl = this.$router.resolve({
-        path: '/exam/paper/detail',
+        path: '/exam/paper/preview',
         query: {
           paperId: paperId
         }

+ 552 - 0
src/views/exam/ExamPaperMark.vue

@@ -0,0 +1,552 @@
+<template>
+  <el-container class="exam-container">
+    <el-aside width="280px" class="exam-aside">
+      <div class="aside-card answer-card">
+        <h4 class="card-title">阅卷进度卡</h4>
+        <div v-for="(section, sIndex) in sectionsList" :key="sIndex" class="card-group">
+          <div class="group-label">{{ section.sectionName }}</div>
+          <div class="number-grid">
+            <span
+              v-for="q in section.flatQuestions"
+              :key="q.questionId"
+              class="number-item"
+              :class="{
+                'is-graded': checkIsGraded(q.questionId),
+                'is-active': currentActiveId === q.questionId
+              }"
+              @click="scrollToQuestion(q.questionId)"
+            >
+              {{ q.number }}
+            </span>
+          </div>
+        </div>
+
+        <el-button type="success" class="btn-submit" icon="el-icon-finished" @click="handleConfirmSubmit">
+          提交阅卷结果
+        </el-button>
+      </div>
+    </el-aside>
+
+    <el-main class="exam-main">
+      <div class="paper-header">
+        <h2 class="paper-title">{{ paperInfo.title }}【阅卷流水线】</h2>
+        <div class="paper-meta">
+          <span>考试科目:{{ paperInfo.subjectName || '未指定' }}</span>
+        </div>
+      </div>
+
+      <div v-for="(section, sIndex) in sectionsList" :key="sIndex" class="question-group-section">
+        <h3 class="group-header-title">
+          {{ section.sectionName }} <span class="group-desc">({{ section.sectionHint }})</span>
+        </h3>
+
+        <div
+          v-for="q in section.questions"
+          :id="'q-' + q.questionId"
+          :key="q.questionId"
+          class="question-card"
+          @mouseenter="currentActiveId = q.questionId"
+        >
+          <template v-if="q.typeCode === 1">
+            <div class="question-stem">
+              <span class="q-number">{{ q.number }}.</span>
+              <span class="q-score-highlight">({{ q.score }}分)</span>
+              <span class="q-text" v-html="formatStem(q.content)" />
+              <el-tag size="mini" type="info">单选</el-tag>
+            </div>
+            <div class="question-body">
+              <div class="options-display-list">
+                <div
+                  v-for="opt in q.options"
+                  :key="opt.pos"
+                  class="mark-option-item"
+                  :class="{
+                    'opt-correct': opt.correct,
+                    'opt-user-selected': answers[q.questionId] == opt.pos,
+                    'opt-user-wrong': answers[q.questionId] == opt.pos && !opt.correct
+                  }"
+                >
+                  <i v-if="opt.correct" class="el-icon-success text-success" />
+                  <i v-else-if="answers[q.questionId] == opt.pos" class="el-icon-error text-danger" />
+                  <i v-else class="el-icon-circle-check text-muted" />
+                  <span class="opt-content">{{ opt.content }}</span>
+                  <el-tag v-if="opt.correct" size="mini" type="success" effect="dark" class="mini-tag">正确答案</el-tag>
+                  <el-tag v-if="answers[q.questionId] == opt.pos" size="mini" type="primary" effect="plain" class="mini-tag">考生选择</el-tag>
+                </div>
+              </div>
+              <div class="auto-mark-result">
+                系统判定:<span :class="markScores[q.questionId] > 0 ? 'score-win' : 'score-loss'">{{ markScores[q.questionId] }} 分</span>
+              </div>
+            </div>
+          </template>
+
+          <template v-else-if="q.typeCode === 2">
+            <div class="question-stem">
+              <span class="q-number">{{ q.number }}.</span>
+              <span class="q-score-highlight">({{ q.score }}分)</span>
+              <span class="q-text" v-html="formatStem(q.content)" />
+              <el-tag type="warning" size="mini" class="type-tag">多选</el-tag>
+            </div>
+            <div class="question-body">
+              <div class="options-display-list">
+                <div
+                  v-for="opt in q.options"
+                  :key="opt.pos"
+                  class="mark-option-item"
+                  :class="{
+                    'opt-correct': opt.correct,
+                    'opt-user-selected': (answers[q.questionId] || []).includes(opt.pos),
+                    'opt-user-wrong': (answers[q.questionId] || []).includes(opt.pos) && !opt.correct
+                  }"
+                >
+                  <i v-if="opt.correct" class="el-icon-success text-success" />
+                  <i v-else-if="(answers[q.questionId] || []).includes(opt.pos)" class="el-icon-error text-danger" />
+                  <i v-else class="el-icon-circle-check text-muted" />
+                  <span class="opt-content">{{ opt.content }}</span>
+                  <el-tag v-if="opt.correct" size="mini" type="success" effect="dark" class="mini-tag">正确答案</el-tag>
+                  <el-tag v-if="(answers[q.questionId] || []).includes(opt.pos)" size="mini" type="primary" effect="plain" class="mini-tag">考生选择</el-tag>
+                </div>
+              </div>
+              <div class="auto-mark-result">
+                系统判定:<span :class="markScores[q.questionId] > 0 ? 'score-win' : 'score-loss'">{{ markScores[q.questionId] }} 分</span>
+              </div>
+            </div>
+          </template>
+
+          <template v-else-if="q.typeCode === 3">
+            <div class="question-stem">
+              <span class="q-number">{{ q.number }}.</span>
+              <span class="q-score-highlight">({{ q.score }}分)</span>
+              <span class="q-text" v-html="formatStem(q.content)" />
+              <el-tag size="mini" type="primary">填空</el-tag>
+            </div>
+            <div class="question-body subjective-mark-box">
+              <el-row :gutter="20" class="compare-row">
+                <el-col :span="12">
+                  <div class="answer-block user-answer-block">
+                    <div class="block-title"><i class="el-icon-user" /> 考生作答情况</div>
+                    <div class="block-content font-code">{{ answers[q.questionId] || '(未作答)' }}</div>
+                  </div>
+                </el-col>
+                <el-col :span="12">
+                  <div class="answer-block standard-answer-block">
+                    <div class="block-title"><i class="el-icon-key" /> 参考答案</div>
+                    <div class="block-content font-code">
+                      <div v-for="(opt, oIdx) in q.options" :key="oIdx">
+                        <span class="text-success font-bold">{{ opt.content }}</span>
+                      </div>
+                    </div>
+                  </div>
+                </el-col>
+              </el-row>
+              <div class="mark-control-bar">
+                <div class="control-item">
+                  <span class="control-label">评分:</span>
+                  <el-input-number
+                    v-model="markScores[q.questionId]"
+                    :min="0"
+                    :max="q.score"
+                    :precision="1"
+                    :step="0.5"
+                    size="small"
+                  />
+                  <span class="max-score-hint">/ {{ q.score }} 分</span>
+                </div>
+                <div class="control-item review-item">
+                  <span class="control-label">评语:</span>
+                  <el-input
+                    v-model="markReviews[q.questionId]"
+                    placeholder="请输入对该填空题的评语或扣分原因..."
+                    size="small"
+                    clearable
+                  />
+                </div>
+              </div>
+            </div>
+          </template>
+
+          <template v-else-if="q.typeCode === 4">
+            <div class="question-stem">
+              <span class="q-number">{{ q.number }}.</span>
+              <span class="q-score-highlight">({{ q.score }}分)</span>
+              <span class="q-text" v-html="formatStem(q.content)" />
+              <el-tag size="mini" type="purple">问答</el-tag>
+            </div>
+            <div class="question-body subjective-mark-box">
+              <div class="answer-block user-answer-block mb-15">
+                <div class="block-title"><i class="el-icon-user" /> 考生答案</div>
+                <div class="block-content text-pre-wrap">{{ answers[q.questionId] || '(空白未填写)' }}</div>
+              </div>
+              <div class="answer-block standard-answer-block mb-15">
+                <div class="block-title"><i class="el-icon-key" /> 参考答案</div>
+                <div class="block-content text-pre-wrap">{{ q.options && q.options[0] ? q.options[0].content : '暂无详尽解析' }}</div>
+              </div>
+              <div class="mark-control-bar">
+                <div class="control-item">
+                  <span class="control-label">评分:</span>
+                  <el-input-number
+                    v-model="markScores[q.questionId]"
+                    :min="0"
+                    :max="q.score"
+                    :precision="1"
+                    :step="1"
+                    size="small"
+                  />
+                  <span class="max-score-hint">/ {{ q.score }} 分</span>
+                </div>
+                <div class="control-item review-item">
+                  <span class="control-label">评语:</span>
+                  <el-input
+                    v-model="markReviews[q.questionId]"
+                    type="textarea"
+                    :rows="2"
+                    placeholder="请输入对此道主观大题的指导意见或评语..."
+                    size="small"
+                  />
+                </div>
+              </div>
+            </div>
+          </template>
+
+          <template v-else-if="q.typeCode === 5">
+            <div class="question-stem material-stem">
+              <span class="material-tag">【组合材料】</span>
+              <div class="q-text" style="white-space: pre-wrap;">{{ q.content }}</div>
+            </div>
+
+            <div class="sub-questions-container">
+              <div v-for="subQ in q.children" :key="subQ.questionId" class="sub-question-item">
+
+                <div class="question-stem sub-stem">
+                  <span class="q-number">{{ subQ.number }}.</span>
+                  <span class="q-score-highlight">({{ subQ.score }}分)</span>
+                  <span class="q-text">{{ subQ.content }}</span>
+                  <el-tag size="mini" effect="plain" type="info" style="margin-left:5px;">{{ subQ.typeStr }}</el-tag>
+                </div>
+
+                <div class="question-body">
+                  <template v-if="subQ.typeCode === 1 || subQ.typeCode === 2">
+                    <div class="options-display-list">
+                      <div
+                        v-for="opt in subQ.options"
+                        :key="opt.pos"
+                        class="mark-option-item"
+                        :class="{
+                          'opt-correct': opt.correct,
+                          'opt-user-selected': subQ.typeCode === 1 ? (answers[subQ.questionId] == opt.pos) : (answers[subQ.questionId] || []).includes(opt.pos),
+                          'opt-user-wrong': subQ.typeCode === 1 ? (answers[subQ.questionId] == opt.pos && !opt.correct) : ((answers[subQ.questionId] || []).includes(opt.pos) && !opt.correct)
+                        }"
+                      >
+                        <i v-if="opt.correct" class="el-icon-success text-success" />
+                        <i v-else-if="subQ.typeCode === 1 ? (answers[subQ.questionId] == opt.pos) : (answers[subQ.questionId] || []).includes(opt.pos)" class="el-icon-error text-danger" />
+                        <i v-else class="el-icon-circle-check text-muted" />
+                        <span class="opt-content">{{ opt.content }}</span>
+                      </div>
+                    </div>
+                    <div class="auto-mark-result">
+                      系统判定:<span :class="markScores[subQ.questionId] > 0 ? 'score-win' : 'score-loss'">{{ markScores[subQ.questionId] }} 分</span>
+                    </div>
+                  </template>
+
+                  <template v-else-if="subQ.typeCode === 3 || subQ.typeCode === 4">
+                    <el-row :gutter="20" class="compare-row mb-10">
+                      <el-col :span="12">
+                        <div class="answer-block user-answer-block">
+                          <div class="block-title">考生答案</div>
+                          <div class="block-content text-pre-wrap">{{ answers[subQ.questionId] || '(未作答)' }}</div>
+                        </div>
+                      </el-col>
+                      <el-col :span="12">
+                        <div class="answer-block standard-answer-block">
+                          <div class="block-title">参考答案</div>
+                          <div class="block-content text-pre-wrap">
+                            <template v-if="subQ.typeCode === 3">
+                              <div v-for="(opt, oIdx) in subQ.options" :key="oIdx">{{ opt.content }}</div>
+                            </template>
+                            <template v-else>
+                              {{ subQ.options && subQ.options[0] ? subQ.options[0].content : '无解析' }}
+                            </template>
+                          </div>
+                        </div>
+                      </el-col>
+                    </el-row>
+                    <div class="mark-control-bar">
+                      <div class="control-item">
+                        <span class="control-label">评分:</span>
+                        <el-input-number v-model="markScores[subQ.questionId]" :min="0" :max="subQ.score" size="mini" />
+                        <span class="max-score-hint">/ {{ subQ.score }}分</span>
+                      </div>
+                      <div class="control-item review-item">
+                        <span class="control-label">评语:</span>
+                        <el-input v-model="markReviews[subQ.questionId]" placeholder="评语..." size="mini" />
+                      </div>
+                    </div>
+                  </template>
+                </div>
+              </div>
+            </div>
+          </template>
+
+        </div>
+      </div>
+    </el-main>
+  </el-container>
+</template>
+
+<script>
+import {getMarkView, submitMarkResult} from '@/api/exam'
+
+export default {
+  name: 'ExamPaperMark',
+  data() {
+    return {
+      queryParams: {
+        examId: 0,
+        userId: 0
+      },
+      remainTime: 0,
+      timeLoading: false,
+      currentActiveId: null,
+
+      answers: {}, // 考生的历史答案库
+      markScores: {}, // 【新加】老师批改分数值:{ [questionId]: score }
+      markReviews: {}, // 【新加】老师填写的评语:{ [questionId]: '评语内容' }
+
+      paperInfo: {},
+      sectionsList: []
+    }
+  },
+  computed: {
+    formatRemainTime() {
+      const hours = Math.floor(this.remainTime / 3600).toString().padStart(2, '0')
+      const mins = Math.floor((this.remainTime % 3600) / 60).toString().padStart(2, '0')
+      const secs = Math.floor(this.remainTime % 60).toString().padStart(2, '0')
+      return `${hours}:${mins}:${secs}`
+    }
+  },
+  created() {
+    document.title = '阅卷'
+    const examId = this.$route.query.examId
+    const userId = this.$route.query.userId || 0
+    if (!examId) {
+      this.$message.error('参数缺失,无法加载阅卷页面!')
+      return
+    }
+    this.queryParams.examId = examId
+    this.queryParams.userId = userId
+    this.loadPaperData()
+  },
+  methods: {
+    async loadPaperData() {
+      try {
+        const resp = await getMarkView(this.queryParams)
+        if (resp.code !== 0) {
+          this.$message.warning(resp.msg)
+          return
+        }
+
+        const examData = resp.data
+        this.remainTime = examData.duration
+
+        const remoteData = examData.paperDetail
+        this.paperInfo = {
+          paperId: remoteData.paperId,
+          title: remoteData.title,
+          subjectId: remoteData.subjectId,
+          subjectName: remoteData.subjectName
+        }
+
+        let globalQuestionIndex = 1
+        const localAnswers = {}
+        const localScores = {}
+        const localReviews = {}
+
+        this.sectionsList = remoteData.sections.map(sec => {
+          const flatQuestions = []
+
+          const ProcessedQuestions = sec.questions.map(q => {
+            // 标准化 options
+            if (q.answers && !q.options) {
+              q.options = q.answers
+            }
+
+            // 处理普通非材料题
+            if (q.typeCode !== 5) {
+              q.number = globalQuestionIndex++
+              flatQuestions.push({ questionId: q.questionId, number: q.number, typeCode: q.typeCode })
+
+              // 还原考生提交上来的真实数据
+              localAnswers[q.questionId] = q.userAnswer !== undefined ? q.userAnswer : ''
+              localReviews[q.questionId] = ''
+
+              // 如果是选择题 (1-单选, 2-多选),系统可自动给出判定分数
+              if (q.typeCode === 1) {
+                const correctOpt = (q.options || []).find(o => o.correct)
+                const isRight = correctOpt && String(correctOpt.pos) === String(q.userAnswer)
+                localScores[q.questionId] = isRight ? q.score : 0
+              } else if (q.typeCode === 2) {
+                // 假设多选题 userAnswer 是形如 [2, 4] 的数组或逗号分隔串
+                const userArr = Array.isArray(q.userAnswer) ? q.userAnswer.map(String) : []
+                const correctArr = (q.options || []).filter(o => o.correct).map(o => String(o.pos))
+                const isRight = userArr.length === correctArr.length && userArr.every(v => correctArr.includes(v))
+                localScores[q.questionId] = isRight ? q.score : 0
+              } else {
+                // 主观题(填空/问答)默认初始化为满分或0分,等候老师用 el-input-number 细扣
+                localScores[q.questionId] = 0
+              }
+            } else if (q.typeCode === 5 && q.children) {
+              // 处理组合材料题
+              q.children.forEach(subQ => {
+                if (subQ.answers && !subQ.options) {
+                  subQ.options = subQ.answers
+                }
+                subQ.number = globalQuestionIndex++
+                flatQuestions.push({ questionId: subQ.questionId, number: subQ.number, typeCode: subQ.typeCode })
+
+                localAnswers[subQ.questionId] = subQ.userAnswer !== undefined ? subQ.userAnswer : ''
+                localReviews[subQ.questionId] = ''
+
+                // 选择子题自动判分
+                if (subQ.typeCode === 1) {
+                  const correctOpt = (subQ.options || []).find(o => o.correct)
+                  localScores[subQ.questionId] = (correctOpt && String(correctOpt.pos) === String(subQ.userAnswer)) ? subQ.score : 0
+                } else if (subQ.typeCode === 2) {
+                  const userArr = Array.isArray(subQ.userAnswer) ? subQ.userAnswer.map(String) : []
+                  const correctArr = (subQ.options || []).filter(o => o.correct).map(o => String(o.pos))
+                  const isRight = userArr.length === correctArr.length && userArr.every(v => correctArr.includes(v))
+                  localScores[subQ.questionId] = isRight ? subQ.score : 0
+                } else {
+                  localScores[subQ.questionId] = 0
+                }
+              })
+            }
+            return q
+          })
+
+          return {
+            sectionId: sec.sectionId,
+            sectionName: sec.sectionName,
+            sectionHint: sec.sectionHint,
+            questions: ProcessedQuestions,
+            flatQuestions: flatQuestions
+          }
+        })
+
+        this.answers = localAnswers
+        this.markScores = localScores
+        this.markReviews = localReviews
+      } catch (error) {
+        this.$message.error(error.message || '加载试卷数据失败')
+        console.error(error)
+      }
+    },
+
+    handleAnswerChange(qId) {},
+
+    // 侧边栏辅助:检查该题是否分配了分数
+    checkIsGraded(qId) {
+      return this.markScores[qId] !== undefined && this.markScores[qId] !== null
+    },
+
+    scrollToQuestion(qId) {
+      this.currentActiveId = qId
+      const targetEl = document.getElementById(`q-${qId}`)
+      if (targetEl) {
+        targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
+      }
+    },
+
+    formatStem(content) {
+      if (!content) return ''
+      return content.replace(/(\s*\d+分\s*)/g, '')
+    },
+
+    // 提交批改结果
+    handleConfirmSubmit() {
+      // 组装发回后端的批改JSON数据 payload
+      const gradeResults = Object.keys(this.markScores).map(qId => {
+        return {
+          questionId: Number(qId),
+          score: this.markScores[qId],
+          review: this.markReviews[qId] || ''
+        }
+      })
+
+      this.$confirm('确定保存并提交本次阅卷评分结果吗?', '阅卷确认', {
+        confirmButtonText: '确定提交',
+        cancelButtonText: '再核对下',
+        type: 'success'
+      }).then(() => {
+        const jsonData = {}
+        jsonData.examId = 1
+        jsonData.resultId = 1
+        jsonData.userId = 1
+        jsonData.markResults = gradeResults
+        submitMarkResult(jsonData).then(resp => {
+          if (resp.code === 0) {
+            this.$message.success('阅卷分值已成功提交!')
+          } else {
+            this.$message.warning(resp.msg)
+          }
+        })
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.exam-container { padding: 20px; background-color: #f5f7fa; min-height: 100vh; }
+.aside-card { background: #fff; padding: 15px; margin-bottom: 20px; border-radius: 6px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
+.number-grid { display: grid; grid-template-columns: repeat(5, 15fr); gap: 8px; margin-top: 10px; }
+.number-item { display: inline-block; width: 36px; height: 36px; line-height: 36px; text-align: center; border: 1px solid #dcdfe6; border-radius: 4px; cursor: pointer; font-size: 13px; }
+.number-item.is-graded { background-color: #f0f9eb; border-color: #c2e7b0; color: #67c23a; font-weight: bold; }
+.number-item.is-active { border: 2px solid #409eff !important; }
+.btn-submit { width: 100%; margin-top: 15px; }
+
+.question-card { background: #fff; padding: 20px; margin-bottom: 20px; border-radius: 6px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
+.q-score-highlight { color: #67C23A; font-weight: bold; margin-right: 5px; }
+.auto-mark-result { margin-top: 12px; font-size: 13px; background: #f8f9fa; padding: 6px 12px; border-radius: 4px; display: inline-block; }
+.score-win { color: #67c23a; font-weight: bold; }
+.score-loss { color: #f56c6c; font-weight: bold; }
+
+/* 批改选项卡样式 */
+.options-display-list { margin-top: 10px; }
+.mark-option-item { padding: 10px 15px; border: 1px solid #e4e7ed; border-radius: 4px; margin-bottom: 8px; display: flex; align-items: center; font-size: 14px; }
+.mark-option-item i { font-size: 16px; margin-right: 10px; }
+.opt-content { flex: 1; }
+.mini-tag { margin-left: 8px; }
+.text-success { color: #67c23a; }
+.text-danger { color: #f56c6c; }
+.text-muted { color: #909399; }
+
+/* 高亮选中态 */
+.opt-correct { background-color: #f0f9eb; border-color: #c2e7b0; }
+.opt-user-selected { border-left: 4px solid #409eff; }
+.opt-user-wrong { background-color: #fef0f0; border-color: #fde2e2; }
+
+/* 主观题双栏对比区 */
+.subjective-mark-box { margin-top: 15px; }
+.answer-block { border: 1px solid #e4e7ed; border-radius: 4px; overflow: hidden; }
+.user-answer-block .block-title { background: #ecf5ff; color: #409eff; padding: 8px 12px; font-weight: bold; font-size: 13px; }
+.standard-answer-block .block-title { background: #f0f9eb; color: #67c23a; padding: 8px 12px; font-weight: bold; font-size: 13px; }
+.block-content { padding: 12px; font-size: 14px; min-height: 60px; line-height: 1.6; background: #fff; }
+.text-pre-wrap { white-space: pre-wrap; }
+.font-code { font-family: monospace; background-color: #fafafa; }
+.font-bold { font-weight: bold; }
+.mb-10 { margin-bottom: 10px; }
+.mb-15 { margin-bottom: 15px; }
+
+/* 评分控制台 */
+.mark-control-bar { margin-top: 15px; background: #fdf6ec; border: 1px dashed #e6a23c; border-radius: 4px; padding: 15px; display: flex; align-items: center; flex-wrap: wrap; }
+.control-item { display: flex; align-items: center; margin-right: 30px; }
+.control-label { font-size: 14px; font-weight: bold; color: #606266; }
+.max-score-hint { font-size: 13px; color: #909399; margin-left: 8px; }
+.review-item { flex: 1; min-width: 300px; }
+
+/* 组合材料题 */
+.material-stem { background: #f4f4f5; padding: 15px; border-radius: 4px; border-left: 4px solid #909399; margin-bottom: 15px; }
+.material-tag { font-weight: bold; color: #303133; }
+.sub-questions-container { padding-left: 15px; border-left: 2px dashed #dcdfe6; }
+.sub-question-item { margin-bottom: 25px; padding-bottom: 15px; border-bottom: 1px solid #f2f6fc; }
+</style>

+ 90 - 34
src/views/exam/ExamPaperPreview.vue

@@ -39,16 +39,17 @@
 
           <div
             v-for="q in section.questions"
-            :id="'q-' + q.questionId"
+            :id="q.typeCode !== 5 ? 'q-' + q.questionId : undefined"
             :key="q.questionId"
             class="question-card"
-            @mouseenter="currentActiveId = q.questionId"
+            @mouseenter="q.typeCode !== 5 ? (currentActiveId = q.questionId) : null"
           >
             <template v-if="q.typeCode === 1">
               <div class="question-stem">
                 <span class="q-badge">单选</span>
                 <span class="q-number">{{ q.number }}.</span>
-                <span class="q-text">{{ q.content }}({{ q.score }}分)</span>
+                <span class="q-score-highlight">({{ q.score }}分)</span>
+                <span class="q-text">{{ q.content }}</span>
               </div>
               <div class="question-body">
                 <div v-for="opt in q.options" :key="opt.pos" class="option-preview-item" :class="{ 'is-correct-opt': opt.correct }">
@@ -60,15 +61,33 @@
               </div>
             </template>
 
-            <template v-if="q.typeCode === 4">
+            <template v-else-if="q.typeCode === 2">
+              <div class="question-stem">
+                <span class="q-badge type-multi" style="background-color: #f5dab1; color: #e6a23c;">多选</span>
+                <span class="q-number">{{ q.number }}.</span>
+                <span class="q-score-highlight">({{ q.score }}分)</span>
+                <span class="q-text">{{ q.content }}</span>
+              </div>
+              <div class="question-body">
+                <div v-for="opt in q.options" :key="opt.pos" class="option-preview-item" :class="{ 'is-correct-opt': opt.correct }">
+                  <el-tag size="mini" :type="opt.correct ? 'success' : 'info'" class="opt-tag">
+                    {{ opt.correct ? '正确答案' : '选项' }}
+                  </el-tag>
+                  <span class="opt-text">{{ opt.content }}</span>
+                </div>
+              </div>
+            </template>
+
+            <template v-else-if="q.typeCode === 3">
               <div class="question-stem">
                 <span class="q-badge type-fill">填空</span>
                 <span class="q-number">{{ q.number }}.</span>
-                <span class="q-text" style="white-space: pre-wrap;">{{ q.content }}({{ q.score }}分)</span>
+                <span class="q-score-highlight">({{ q.score }}分)</span>
+                <span class="q-text" style="white-space: pre-wrap;">{{ q.content }}</span>
               </div>
               <div class="question-body">
                 <div class="answer-analysis-box">
-                  <div class="analysis-title">📌 标准答案:</div>
+                  <div class="analysis-title">📌 参考答案:</div>
                   <div v-for="ans in q.options" :key="ans.pos" class="fill-answer-item">
                     第 <span class="fill-index">{{ ans.pos }}</span> 空:
                     <span class="fill-text">{{ ans.content }}</span>
@@ -77,54 +96,82 @@
               </div>
             </template>
 
-            <template v-if="q.typeCode === 5">
+            <template v-else-if="q.typeCode === 4">
               <div class="question-stem">
                 <span class="q-badge type-qa">问答</span>
                 <span class="q-number">{{ q.number }}.</span>
-                <span class="q-text" style="white-space: pre-wrap;">{{ q.content }}({{ q.score }}分)</span>
+                <span class="q-score-highlight">({{ q.score }}分)</span>
+                <span class="q-text" style="white-space: pre-wrap;">{{ q.content }}</span>
               </div>
               <div class="question-body">
                 <div class="answer-analysis-box">
-                  <div class="analysis-title text-red">⚠️ 提示:</div>
-                  <div class="analysis-content grey-text">此题为主观题,请线下或者正式考试时在作答区填写。</div>
+                  <div class="analysis-title">📌 参考答案:</div>
+                  <div v-for="ans in q.options" :key="ans.pos" class="qa-answer-content" style="white-space: pre-wrap; margin-top: 5px;">
+                    {{ ans.content }}
+                  </div>
                 </div>
               </div>
             </template>
 
-            <template v-if="q.typeCode === 6">
+            <template v-else-if="q.typeCode === 5">
               <div class="question-stem material-stem">
-                <span class="material-tag">【组合材料题】(全题共 {{ q.score }} 分)</span>
+                <span class="material-tag">【组合题】<span class="q-score-highlight">(全题共 {{ q.score }} 分)</span></span>
                 <div class="q-text material-content">{{ q.content }}</div>
               </div>
 
               <div class="sub-questions-container">
-                <div v-for="subQ in q.children" :key="subQ.questionId" class="sub-question-item">
+                <div
+                  v-for="subQ in q.children"
+                  :id="'q-' + subQ.questionId"
+                  :key="subQ.questionId"
+                  class="sub-question-item"
+                  @mouseenter="currentActiveId = subQ.questionId"
+                >
 
                   <template v-if="subQ.typeCode === 1">
                     <div class="question-stem sub-stem">
                       <span class="q-badge btn-mini">单选</span>
                       <span class="q-number">{{ subQ.number }}.</span>
-                      <span class="q-text">{{ subQ.content }}({{ subQ.score }}分)</span>
+                      <span class="q-score-highlight">({{ subQ.score }}分)</span>
+                      <span class="q-text">{{ subQ.content }}</span>
                     </div>
                     <div class="question-body">
                       <div v-for="opt in subQ.options" :key="opt.pos" class="option-preview-item" :class="{ 'is-correct-opt': opt.correct }">
                         <el-tag size="mini" :type="opt.correct ? 'success' : 'info'" class="opt-tag">
-                          {{ opt.correct ? '标准答案' : '选项' }}
+                          {{ opt.correct ? '参考答案' : '选项' }}
                         </el-tag>
                         <span class="opt-text">{{ opt.content }}</span>
                       </div>
                     </div>
                   </template>
 
-                  <template v-else-if="subQ.typeCode === 4">
+                  <template v-else-if="subQ.typeCode === 2">
+                    <div class="question-stem sub-stem">
+                      <span class="q-badge btn-mini" style="background-color: #f5dab1; color: #e6a23c;">多选</span>
+                      <span class="q-number">{{ subQ.number }}.</span>
+                      <span class="q-score-highlight">({{ subQ.score }}分)</span>
+                      <span class="q-text">{{ subQ.content }}</span>
+                    </div>
+                    <div class="question-body">
+                      <div v-for="opt in subQ.options" :key="opt.pos" class="option-preview-item" :class="{ 'is-correct-opt': opt.correct }">
+                        <el-tag size="mini" :type="opt.correct ? 'success' : 'info'" class="opt-tag">
+                          {{ opt.correct ? '参考答案' : '选项' }}
+                        </el-tag>
+                        <span class="opt-text">{{ opt.content }}</span>
+                      </div>
+                    </div>
+                  </template>
+
+                  <template v-else-if="subQ.typeCode === 3">
                     <div class="question-stem sub-stem">
                       <span class="q-badge type-fill btn-mini">填空</span>
                       <span class="q-number">{{ subQ.number }}.</span>
-                      <span class="q-text">{{ subQ.content }}({{ subQ.score }}分)</span>
+                      <span class="q-score-highlight">({{ subQ.score }}分)</span>
+                      <span class="q-text">{{ subQ.content }}</span>
                     </div>
                     <div class="question-body">
                       <div class="answer-analysis-box">
-                        <div class="analysis-title">📌 标准答案:</div>
+                        <div class="analysis-title">📌 参考答案:</div>
                         <div v-for="ans in subQ.options" :key="ans.pos" class="fill-answer-item">
                           第 <span class="fill-index">{{ ans.pos }}</span> 空:
                           <span class="fill-text">{{ ans.content }}</span>
@@ -133,6 +180,23 @@
                     </div>
                   </template>
 
+                  <template v-else-if="subQ.typeCode === 4">
+                    <div class="question-stem sub-stem">
+                      <span class="q-badge type-qa btn-mini">问答</span>
+                      <span class="q-number">{{ subQ.number }}.</span>
+                      <span class="q-score-highlight">({{ subQ.score }}分)</span>
+                      <span class="q-text">{{ subQ.content }}</span>
+                    </div>
+                    <div class="question-body">
+                      <div class="answer-analysis-box">
+                        <div class="analysis-title">📌 参考答案:</div>
+                        <div v-for="ans in subQ.options" :key="ans.pos" class="qa-answer-content" style="white-space: pre-wrap; margin-top: 5px;">
+                          {{ ans.content }}
+                        </div>
+                      </div>
+                    </div>
+                  </template>
+
                 </div>
               </div>
             </template>
@@ -190,24 +254,19 @@ export default {
           let globalQuestionIndex = 1
           let calculatedTotalScore = 0
 
-          // 解析大题和子题结构
           this.sectionsList = remoteData.sections.map(sec => {
             const flatQuestions = []
 
             const processedQuestions = sec.questions.map(q => {
-              // 兼容性调整:如果后端返回的是 answers 且 options 为空,做一次赋值桥接
               if (q.answers && !q.options) {
                 q.options = q.answers
               }
               calculatedTotalScore += (q.score || 0)
 
-              // 如果是非组合题(普通单选、填空、问答等)
-              if (q.typeCode !== 6) {
+              if (q.typeCode !== 5) {
                 q.number = globalQuestionIndex++
                 flatQuestions.push({ questionId: q.questionId, number: q.number })
-              }
-              // 如果是复合/组合题,需要把子题的题号也平铺到大纲中
-              else if (q.typeCode === 6 && q.children) {
+              } else if (q.typeCode === 5 && q.children) {
                 q.children.forEach(subQ => {
                   if (subQ.answers && !subQ.options) {
                     subQ.options = subQ.answers
@@ -244,15 +303,6 @@ export default {
       const targetEl = document.getElementById(`q-${qId}`)
       if (targetEl) {
         targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
-      } else {
-        // 如果是子题,尝试划向它所在的父大卡片
-        const allCards = document.querySelectorAll('.question-card')
-        for (const card of allCards) {
-          if (card.querySelector(`.sub-questions-container`)) {
-            card.scrollIntoView({ behavior: 'smooth', block: 'center' })
-            break
-          }
-        }
       }
     }
   }
@@ -313,4 +363,10 @@ export default {
 .sub-question-item:last-child { border-bottom: none; margin-bottom: 0; }
 .sub-stem { font-size: 13px; }
 .loading-box { padding: 40px; background: #fff; border-radius: 8px; }
+
+.q-score-highlight {
+  color: #67C23A;      /* 醒目的橘黄色/警告色,也可以换成 Element 的主色调 #409EFF */
+  font-weight: bold;   /* 加粗显示 */
+  margin-left: 4px;
+}
 </style>

+ 4 - 5
src/views/exam/ExamQuestion.vue

@@ -52,7 +52,7 @@
           label="所属科目"
         />
         <el-table-column
-          prop="type"
+          prop="typeStr"
           label="试题类型"
         />
         <el-table-column
@@ -375,10 +375,9 @@ export default {
       questionType: [
         { id: 1, name: '单选题' },
         { id: 2, name: '多选题' },
-        { id: 3, name: '判断题' },
-        { id: 4, name: '填空题' },
-        { id: 5, name: '问答题' },
-        { id: 6, name: '组合题' }
+        { id: 3, name: '填空题' },
+        { id: 4, name: '问答题' },
+        { id: 5, name: '组合题' }
       ],
       // 科目信息
       allSubject: [],

+ 76 - 64
src/views/exam/ExamQuestionAdd.vue

@@ -23,16 +23,15 @@
               <el-select v-model="form.questionType" placeholder="请选择" style="width: 100%" @change="handleMainTypeChange">
                 <el-option :value="1" label="单选题" />
                 <el-option :value="2" label="多选题" />
-                <el-option :value="3" label="判断题" />
-                <el-option :value="4" label="填空题" />
-                <el-option :value="5" label="问答题" />
-                <el-option :value="6" label="组合题" />
+                <el-option :value="3" label="填空题" />
+                <el-option :value="4" label="问答题" />
+                <el-option :value="5" label="组合题" />
               </el-select>
             </el-form-item>
           </el-col>
         </el-row>
 
-        <el-form-item :label="form.questionType === 6 ? '大题主干/阅读材料' : '试题题干'" prop="questionContent">
+        <el-form-item :label="form.questionType === 5 ? '大题主干/阅读材料' : '试题题干'" prop="questionContent">
           <el-input
             v-model="form.questionContent"
             type="textarea"
@@ -49,9 +48,12 @@
         </el-collapse-transition>
 
         <div v-if="form.questionType >= 1 && form.questionType <= 4" class="option-config-box">
-          <el-divider content-position="left">配置选项 / 填空参考答案</el-divider>
-          <div class="action-bar">
-            <el-button type="primary" icon="el-icon-plus" size="small" @click="addFormAnswer">添加选项/空格</el-button>
+          <el-divider content-position="left">
+            {{ (form.questionType === 1 || form.questionType === 2) ? '配置候选项' : '配置参考答案' }}
+          </el-divider>
+
+          <div v-if="form.questionType === 1 || form.questionType === 2" class="action-bar">
+            <el-button type="primary" icon="el-icon-plus" size="small" @click="addFormAnswer">添加选项</el-button>
           </div>
 
           <el-table :data="form.options" border style="width: 100%">
@@ -59,7 +61,7 @@
               <template slot-scope="scope">
                 <el-checkbox
                   v-model="scope.row.correct"
-                  :disabled="form.questionType === 4"
+                  :disabled="form.questionType === 3 || form.questionType === 4"
                   @change="checked => handleCheckAnswer(checked, scope.row.id)"
                 />
               </template>
@@ -69,17 +71,17 @@
                 <span>{{ scope.$index + 1 }}</span>
               </template>
             </el-table-column>
-            <el-table-column label="选项内容 / 填空参考值">
+            <el-table-column :label="(form.questionType === 1 || form.questionType === 2) ? '选项内容' : '标准参考答案'">
               <template slot-scope="scope">
-                <el-input v-model="scope.row.options" size="small" placeholder="请输入内容,支持公式" />
+                <el-input v-model="scope.row.options" size="small" :placeholder="(form.questionType === 1 || form.questionType === 2) ? '请输入选项内容,支持公式' : '请输入标准答案内容'" />
               </template>
             </el-table-column>
             <el-table-column label="答案解析(选填)">
               <template slot-scope="scope">
-                <el-input v-model="scope.row.analysis" size="small" placeholder="此项的单独解析" />
+                <el-input v-model="scope.row.analysis" size="small" placeholder="针对此项的单独解析" />
               </template>
             </el-table-column>
-            <el-table-column label="操作" width="80" align="center">
+            <el-table-column v-if="form.questionType === 1 || form.questionType === 2" label="操作" width="80" align="center">
               <template slot-scope="scope">
                 <el-button type="danger" icon="el-icon-delete" circle size="mini" @click="delFormAnswer(scope.row.id)" />
               </template>
@@ -87,7 +89,7 @@
           </el-table>
         </div>
 
-        <div v-if="form.questionType === 6" class="composite-box">
+        <div v-if="form.questionType === 5" class="composite-box">
           <el-divider content-position="left">配置大题下的子问 / 子题集</el-divider>
           <div class="action-bar">
             <el-button type="success" icon="el-icon-circle-plus" size="small" @click="addSubQuestion">追加一个子问 (小题)</el-button>
@@ -115,8 +117,8 @@
                       <el-select v-model="sub.questionType" size="small" style="width: 100%" @change="handleSubQuestionTypeChange(sub)">
                         <el-option :value="1" label="单选题" />
                         <el-option :value="2" label="多选题" />
-                        <el-option :value="4" label="填空题" />
-                        <el-option :value="5" label="简答题" />
+                        <el-option :value="3" label="填空题" />
+                        <el-option :value="4" label="问答题" />
                       </el-select>
                     </el-form-item>
                   </el-col>
@@ -126,16 +128,16 @@
                   <el-input v-model="sub.questionContent" type="textarea" :rows="2" placeholder="请输入小题题干..." />
                 </el-form-item>
 
-                <div v-if="sub.questionType !== 5" class="sub-answer-zone">
-                  <div class="action-bar-mini">
-                    <el-button type="primary" plain size="mini" icon="el-icon-plus" @click="addSubAnswer(sub)">添加子问选项/空格</el-button>
+                <div class="sub-answer-zone">
+                  <div v-if="sub.questionType === 1 || sub.questionType === 2" class="action-bar-mini">
+                    <el-button type="primary" plain size="mini" icon="el-icon-plus" @click="addSubAnswer(sub)">添加子问选项</el-button>
                   </div>
                   <el-table :data="sub.options" border size="mini" style="width: 100%">
                     <el-table-column label="答案" width="70" align="center">
                       <template slot-scope="scope">
                         <el-checkbox
                           v-model="scope.row.correct"
-                          :disabled="sub.questionType === 4"
+                          :disabled="sub.questionType === 3 || sub.questionType === 4"
                           @change="checked => handleCheckSubAnswer(checked, scope.row.id, sub)"
                         />
                       </template>
@@ -145,7 +147,7 @@
                         <span>{{ scope.$index + 1 }}</span>
                       </template>
                     </el-table-column>
-                    <el-table-column label="选项文本/参考值">
+                    <el-table-column :label="(sub.questionType === 1 || sub.questionType === 2) ? '选项文本' : '标准参考答案'">
                       <template slot-scope="scope">
                         <el-input v-model="scope.row.options" size="mini" />
                       </template>
@@ -155,7 +157,7 @@
                         <el-input v-model="scope.row.analysis" size="mini" placeholder="选填" />
                       </template>
                     </el-table-column>
-                    <el-table-column label="操作" width="60" align="center">
+                    <el-table-column v-if="sub.questionType === 1 || sub.questionType === 2" label="操作" width="60" align="center">
                       <template slot-scope="scope">
                         <el-button type="text" class="danger-text" icon="el-icon-delete" @click="delSubAnswer(sub, scope.$index)" />
                       </template>
@@ -172,7 +174,7 @@
         </div>
 
         <el-divider />
-        <el-form-item :label="form.questionType === 6 ? '总题目解析' : '试题解析'" prop="analysis">
+        <el-form-item :label="form.questionType === 5 ? '总题目解析' : '试题解析'" prop="analysis">
           <el-input v-model="form.analysis" type="textarea" :rows="4" placeholder="请输入官方标准整题解析步骤..." />
         </el-form-item>
 
@@ -221,6 +223,7 @@ export default {
   created() {
     document.title = '录入试题'
     this.getSubjects()
+    this.initDefaultOptions()
   },
   beforeDestroy() {
     if (this.renderTimer) clearTimeout(this.renderTimer)
@@ -232,28 +235,46 @@ export default {
       }).catch(err => console.error(err))
     },
     getTypeName(type) {
-      const names = { 1: '单选题', 2: '多选题', 4: '填空题', 5: '简答题' }
+      const names = { 1: '单选题', 2: '多选题', 3: '填空题', 4: '问答题' }
       return names[type] || '未知题型'
     },
+    initDefaultOptions() {
+      if (this.form.questionType === 1 || this.form.questionType === 2) {
+        this.form.options = [
+          { id: `ans_${Date.now()}_1`, correct: false, options: '', analysis: '' },
+          { id: `ans_${Date.now()}_2`, correct: false, options: '', analysis: '' }
+        ]
+      } else if (this.form.questionType === 3 || this.form.questionType === 4) {
+        this.form.options = [
+          { id: `ans_${Date.now()}_fixed`, correct: true, options: '', analysis: '' }
+        ]
+      } else {
+        this.form.options = []
+      }
+    },
     handleMainTypeChange() {
-      this.form.options = []
       this.form.children = []
+      this.initDefaultOptions()
     },
     handleSubQuestionTypeChange(sub) {
-      sub.options = []
+      if (sub.questionType === 1 || sub.questionType === 2) {
+        sub.options = [
+          { id: `s_opt_${Date.now()}_1`, correct: false, options: '', analysis: '' },
+          { id: `s_opt_${Date.now()}_2`, correct: false, options: '', analysis: '' }
+        ]
+      } else if (sub.questionType === 3 || sub.questionType === 4) {
+        sub.options = [
+          { id: `s_opt_${Date.now()}_fixed`, correct: true, options: '', analysis: '' }
+        ]
+      } else {
+        sub.options = []
+      }
     },
-    // --- 原子题答案行逻辑 ---
     addFormAnswer() {
-      // 判断当前是否为填空题 (4)
-      const isCompletion = this.form.questionType === 4
-
-      const nextPrefix = isCompletion
-        ? `空${this.form.options.length + 1}`
-        : String.fromCharCode(65 + (this.form.options.length % 26))
-
+      if (this.form.questionType === 3 || this.form.questionType === 4) return
       this.form.options.push({
         id: `ans_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
-        correct: !!isCompletion, // 🟥 填空题直接默认为 true,选择题默认为 false
+        correct: false,
         options: '',
         analysis: ''
       })
@@ -262,11 +283,10 @@ export default {
       this.form.options = this.form.options.filter(item => item.id !== id)
     },
     handleCheckAnswer(checked, id) {
-      if (checked && (this.form.questionType === 1 || this.form.questionType === 3)) {
+      if (checked && this.form.questionType === 1) {
         this.form.options.forEach(item => { if (item.id !== id) item.correct = false })
       }
     },
-    // --- 复合大题子问级逻辑 ---
     addSubQuestion() {
       const localId = `sub_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`
       this.form.children.push({
@@ -285,16 +305,10 @@ export default {
       this.form.children.splice(index, 1)
     },
     addSubAnswer(sub) {
-      // 判断当前子题型是否为填空题 (4)
-      const isSubCompletion = sub.questionType === 4
-
-      const nextPrefix = isSubCompletion
-        ? `空${sub.options.length + 1}`
-        : String.fromCharCode(65 + (sub.options.length % 26))
-
+      if (sub.questionType === 3 || sub.questionType === 4) return
       sub.options.push({
         id: `opt_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
-        correct: !!isSubCompletion, // 🟥 子填空题直接默认为 true
+        correct: false,
         options: '',
         analysis: ''
       })
@@ -318,13 +332,11 @@ export default {
         })
       }, 350)
     },
-
-    // 🚀 【核心清洗转换逻辑】将前端模型组装转换为后端 QuestionDto 格式
     formatPayload(formObj) {
       const convertOptions = (opts) => {
         return (opts || []).map((o, idx) => ({
-          index: idx + 1, // 转换为后端需要的 Integer index
-          content: o.options, // 关键对齐:前端 options -> 后端 content
+          index: idx + 1,
+          content: o.options,
           analysis: o.analysis || '',
           correct: !!o.correct
         }))
@@ -337,11 +349,8 @@ export default {
           questionContent: q.questionContent,
           analysis: q.analysis || '',
           extra: q.extra || '',
-          // 只有非组合题或者非简答题才携带选项
-          options: q.questionType !== 6 && q.questionType !== 5 ? convertOptions(q.options) : [],
-          // 只有组合题(6)才会存在递归子集
-          children: q.questionType === 6 ? (q.children || []).map(subQ => {
-            // 子题目的 subjectId 继承大题
+          options: q.questionType !== 5 ? convertOptions(q.options) : [],
+          children: q.questionType === 5 ? (q.children || []).map(subQ => {
             subQ.subjectId = q.subjectId
             return convertQuestion(subQ)
           }) : []
@@ -350,34 +359,36 @@ export default {
 
       return convertQuestion(formObj)
     },
-
     submitForm() {
       this.$refs['examForm'].validate((valid) => {
         if (!valid) return
 
-        // 1. 基础业务校验
         if (this.form.questionType <= 4) {
-          if (this.form.options.length === 0) return this.$message.error('常规题型请至少配置一个选项或答案!')
-          if ([1, 2, 3].includes(this.form.questionType) && !this.form.options.some(a => a.correct)) {
+          if (this.form.options.length === 0) return this.$message.error('请配置至少一个选项或答案!')
+          if (this.form.options.some(a => !a.options.trim())) {
+            const msg = (this.form.questionType === 1 || this.form.questionType === 2) ? '选项内容不能为空!' : '答案内容不能为空!'
+            return this.$message.error(msg)
+          }
+          if ([1, 2].includes(this.form.questionType) && !this.form.options.some(a => a.correct)) {
             return this.$message.error('当前选择类题型未勾选正确答案!')
           }
         }
-        if (this.form.questionType === 6) {
+
+        if (this.form.questionType === 5) {
           if (this.form.children.length === 0) return this.$message.error('组合大题下请至少追加一个子问!')
           for (let i = 0; i < this.form.children.length; i++) {
             const sub = this.form.children[i]
             if (!sub.questionContent.trim()) return this.$message.error(`第 (${i + 1}) 问的子题干不能为空!`)
-            if (sub.questionType !== 5 && !sub.options.some(a => a.correct)) {
+            if (sub.options.length === 0 || sub.options.some(a => !a.options.trim())) {
+              return this.$message.error(`第 (${i + 1}) 问的选项或答案内容不能为空!`)
+            }
+            if ([1, 2].includes(sub.questionType) && !sub.options.some(a => a.correct)) {
               return this.$message.error(`第 (${i + 1}) 问小题未设置正确答案!`)
             }
           }
         }
 
-        // 2. 数据格式化转换
         const payload = this.formatPayload(this.form)
-        console.log('提交给后端的标准 QuestionDto 数据树:', JSON.stringify(payload, null, 2))
-
-        // 3. 发送请求
         addQuestion(payload).then(resp => {
           if (resp && resp.code === 0) {
             this.$message.success('试题已成功保存至题库')
@@ -395,6 +406,7 @@ export default {
         subjectId: '', questionType: 1, questionContent: '', analysis: '', extra: '', options: [], children: []
       }
       this.activeCollapsePanels = []
+      this.initDefaultOptions()
       this.$nextTick(() => { this.isChanged = false })
     },
     handleBack() {

+ 1 - 9
src/views/exam/ExamSubject.vue

@@ -28,25 +28,17 @@
           prop="question7Count"
           label="多选题"
         />
-        <el-table-column
-          prop="question7Count"
-          label="不定项选择题"
-        />
         <el-table-column
           prop="question7Count"
           label="填空题"
         />
-        <el-table-column
-          prop="question7Count"
-          label="判断题"
-        />
         <el-table-column
           prop="question7Count"
           label="问答题"
         />
         <el-table-column
           prop="question7Count"
-          label="理解题"
+          label="组合题"
         />
         <el-table-column
           prop="paperCount"

+ 1 - 1
src/views/exam/QuestionPreviewDialog.vue

@@ -17,7 +17,7 @@
 
       <el-divider />
 
-      <div v-if="questionData.type !== 6" class="normal-question-zone">
+      <div v-if="questionData.type !== 5" class="normal-question-zone">
         <div class="question-content">{{ questionData.content }}</div>
 
         <div v-if="questionData.options && questionData.options.length" class="options-list">