Browse Source

调整修改 exam 模块

reghao 1 week ago
parent
commit
accab59b8f

+ 24 - 19
src/api/exam.js

@@ -13,10 +13,11 @@ const examApi = {
   addQuestion: '/api/content/exam/question',
   // *********************************************************************************************************************
   // 试卷接口
+  examPaperApi: '/api/content/exam/paper',
   getPaper: '/api/content/exam/paper',
+  addPaper: '/api/content/exam/paper',
   getPaperDetail: '/api/content/exam/paper/detail',
   getPaperKeyValue: '/api/content/exam/paper/kv',
-  addPaper: '/api/content/exam/paper',
   // *********************************************************************************************************************
   // 考试接口
   examList: '/api/content/exam/eval/list',
@@ -50,8 +51,8 @@ export function getQuestionType() {
   return get(examApi.getQuestionType)
 }
 
-export function getQuestions(param) {
-  return get(examApi.getQuestion, param)
+export function getQuestions(queryParams) {
+  return get(examApi.getQuestion, queryParams)
 }
 
 export function deleteQuestion(questionId) {
@@ -62,26 +63,26 @@ export function getQuestion(questionId) {
   return get(examApi.getQuestion + '/' + questionId)
 }
 
-export function addQuestion(data) {
-  return post(examApi.addQuestion, data)
+export function addQuestion(jsonData) {
+  return post(examApi.addQuestion, jsonData)
 }
 
 // *********************************************************************************************************************
 // 试卷接口
-export function getPapers(param) {
-  return get(examApi.getPaper, param)
+export function getPapers(queryParams) {
+  return get(examApi.examPaperApi, queryParams)
 }
 
-export function getPaper(paperId, viewType, userId) {
-  return get(examApi.getPaperDetail + '?paperId=' + paperId + '&viewType=' + viewType + '&userId=' + userId)
+export function getPaper(queryParams) {
+  return get(examApi.examPaperApi + '/detail', queryParams)
 }
 
 export function getPaperKeyValues() {
   return get(examApi.getPaperKeyValue)
 }
 
-export function addPaper(data) {
-  return post(examApi.addPaper, data)
+export function addPaper(jsonData) {
+  return post(examApi.addPaper, jsonData)
 }
 
 export function deletePaper(paperId) {
@@ -94,20 +95,24 @@ export function getExamList() {
   return get(examApi.examList)
 }
 
-export function getExamInfo(paperId) {
-  return get(examApi.startExam + '/' + paperId)
+export function getExamInfo(queryParams) {
+  return get(examApi.startExam, queryParams)
 }
 
 export function getPaperQuestions(paperId) {
   return get('/api/content/exam/paper/' + paperId + '/question')
 }
 
-export function submitExam(data) {
-  return post(examApi.submitExam, data)
+export function submitExam(jsonData) {
+  return post(examApi.submitExam, jsonData)
+}
+
+export function cacheUserAnswer(jsonData) {
+  return post(examApi.cacheUserAnswer, jsonData)
 }
 
-export function cacheUserAnswer(data) {
-  return post(examApi.cacheUserAnswer, data)
+export function getCachedUserAnswer(queryParams) {
+  return get(examApi.cacheUserAnswer, queryParams)
 }
 
 // *********************************************************************************************************************
@@ -120,8 +125,8 @@ export function getExamMarkList(params) {
   return get(examApi.getExamMarkList, params)
 }
 
-export function submitExamMark(data) {
-  return post(examApi.submitExamMark, data)
+export function submitExamMark(jsonData) {
+  return post(examApi.submitExamMark, jsonData)
 }
 
 // *********************************************************************************************************************

+ 3 - 3
src/router/exam.js

@@ -13,7 +13,7 @@ const ExamEvalList = () => import('views/exam/ExamEvalList')
 const ExamCard = () => import('views/exam/ExamCard')
 const ExamScoreIndex = () => import('views/exam/ExamScoreIndex')
 const ExamMarkIndex = () => import('views/exam/ExamMarkIndex')
-const ExamPaperDetail = () => import('views/exam/ExamPaperDetail')
+const ExamPaperPreview = () => import('views/exam/ExamPaperPreview')
 
 export default {
   path: '/exam',
@@ -89,8 +89,8 @@ export default {
     },
     {
       path: '/exam/paper/detail',
-      name: 'ExamPaperDetail',
-      component: ExamPaperDetail,
+      name: 'ExamPaperPreview',
+      component: ExamPaperPreview,
       meta: { needAuth: true }
     }
   ]

+ 2 - 378
src/views/exam/ExamDashboard.vue

@@ -1,6 +1,6 @@
 <template>
   <el-row>
-    <el-row class="movie-list">
+    <el-row>
       <el-card>
         <div slot="header" class="clearfix">
           <span>ExamSysDiagram</span>
@@ -24,402 +24,26 @@
         </div>
       </el-card>
     </el-row>
-    <el-row class="movie-list">
-      <el-card>
-        <div slot="header" class="clearfix">
-          <span style="margin-right: 5px">选择图表</span>
-          <el-select
-            v-model="selectedValue"
-            clearable
-            placeholder="选择图表"
-            @change="onSelectChange"
-          >
-            <el-option label="考试通过率" value="1" />
-            <el-option label="考试次数占比" value="2" />
-            <el-option label="考试通过率折线图" value="3" />
-            <el-option label="新用户激活数" value="4" />
-          </el-select>
-        </div>
-        <div id="chart1" style="height:400px;" />
-      </el-card>
-    </el-row>
-    <el-row class="movie-list">
-      <el-col :md="12" class="movie-list">
-        <el-card>
-          <div id="chart2" style="height:400px;" />
-        </el-card>
-      </el-col>
-      <el-col :md="12" class="movie-list">
-        <el-card>
-          <div slot="header" class="clearfix">
-            <span style="margin-right: 5px">三级联动</span>
-            <el-cascader
-              :options="cascaderOptions"
-              placeholder="试试搜索: 武侯"
-              clearable
-              filterable
-              @change="handleChange"
-            ></el-cascader>
-          </div>
-          <div id="chart3" style="height:400px;" />
-        </el-card>
-      </el-col>
-    </el-row>
   </el-row>
 </template>
 
 <script>
-import { getExamCount, getExamPassRate, getLineChartData, getSubjectCascade } from '@/api/exam'
-
 export default {
   name: 'ExamDashboard',
   data() {
     return {
-      // 考试名称
-      examNames: [],
-      // 考试通过率
-      passRate: [],
-      // 饼图的数据
-      pieData: [],
-      selectedValue: '1',
-      chartOption: null,
-      cascaderOptions: []
     }
   },
   created() {
-    // 页面数据加载的等待状态栏
-    this.loading = this.$loading({
-      body: true,
-      lock: true,
-      text: '数据拼命加载中,(*╹▽╹*)',
-      spinner: 'el-icon-loading'
-    })
-    this.getExamPassRate()
-    this.getExamNumbers()
-    this.getData()
   },
   methods: {
     getData() {
-      getSubjectCascade().then(resp => {
-        if (resp.code === 0) {
-          this.cascaderOptions = resp.data
-        }
-      })
-    },
-    handleChange(val) {
-      this.$message.info('select -> ' + val)
-    },
-    onSelectChange() {
-      this.getExamPassRate()
-    },
-    async getExamPassRate() {
-      await getExamPassRate().then((resp) => {
-        if (resp.code === 0) {
-          this.examNames = resp.data[0].split(',')
-          this.passRate = resp.data[1].split(',')
-
-          const intValue = parseInt(this.selectedValue)
-          if (intValue === 1) {
-            this.drawLine()
-          } else if (intValue === 2) {
-            this.drawBrokenLine()
-          } else if (intValue === 3) {
-            this.drawImg4()
-          } else {
-            // this.drawChart5()
-            this.drawChart6()
-          }
-
-          const myChart = this.$echarts.init(document.getElementById('chart1'))
-          // setOption 是 merge,而非赋值, setOption 支持 notMerge 参数, 值为 true 时重新渲染
-          myChart.setOption(this.chartOption, true)
-          this.loading.close()
-        }
-      })
-    },
-    // 考试通过率柱状图
-    drawLine() {
-      this.chartOption = {
-        title: {
-          text: '考试通过率',
-          subtext: 'dashbord1',
-          x: 'center',
-          y: 'top',
-          textAlign: 'center'
-        },
-        tooltip: {},
-        xAxis: {
-          data: this.examNames
-        },
-        yAxis: {},
-        series: [{
-          name: '通过率',
-          type: 'bar',
-          data: this.passRate
-        }]
-      }
-    },
-    // 通过率的折线图
-    drawBrokenLine() {
-      this.chartOption = {
-        // 标题
-        title: {
-          text: '考试通过率折线图',
-          x: 'center'
-        },
-        // x轴
-        xAxis: {
-          data: this.examNames
-        },
-        // y轴没有显式设置,根据值自动生成y轴
-        yAxis: {},
-        // 数据-data是最终要显示的数据
-        series: [{
-          name: '通过率',
-          type: 'line',
-          areaStyle: {
-            normal: {}
-          },
-          data: this.passRate
-        }]
-      }
-    },
-    drawImg4() {
-      this.chartOption = {
-        color: ['#cd5c5c'],
-        textStyle: {
-          color: 'black'
-        },
-        tooltip: {
-          trigger: 'axis',
-          axisPointer: {
-            type: 'shadow'
-          },
-          formatter: '{a} <br/>{b} : {c}'
-        },
-        grid: {
-          containLabel: true
-        },
-        xAxis: {
-          type: 'value',
-          boundaryGap: [0, 0.01],
-          axisLine: {
-            lineStyle: {
-              color: '#fff'
-            }
-          },
-          'axisLabel': {
-            'interval': 0,
-            fontSize: 18,
-            formatter: '{value}'
-          }
-        },
-        yAxis: {
-          axisLine: {
-            lineStyle: {
-              color: '#fff'
-            }
-          },
-          'axisLabel': {
-            'interval': 0,
-            fontSize: 18
-          },
-          type: 'category',
-          data: this.examNames
-        },
-        series: [{
-          name: '通过率:',
-          type: 'bar',
-          data: this.passRate
-        }]
-      }
-    },
-    drawChart5() {
-      this.chartOption = {
-        title: {
-          text: '卡拉云新用户激活数据',
-          subtext: 'Demo 虚构数据',
-          x: 'center'
-        },
-        legend: { // 图例配置选项
-          orient: 'horizontal', // 图例布局方式:水平 'horizontal' 、垂直 'vertical'
-          x: 'left', // 横向放置位置,选项:'center'、'left'、'right'、'number'(横向值 px)
-          y: 'top', // 纵向放置位置,选项:'top'、'bottom'、'center'、'number'(纵向值 px)
-          data: ['猜想', '预期', '实际']
-        },
-        grid: { // 图表距离边框的距离,可用百分比和数字(px)配置
-          top: '20%',
-          left: '3%',
-          right: '10%',
-          bottom: '5%',
-          containLabel: true
-        },
-        xAxis: {
-          name: '月份',
-          type: 'category',
-          data: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
-        },
-        yAxis: {
-          name: '人次',
-          type: 'value',
-          min: 0, // 配置 Y 轴刻度最小值
-          max: 4000, // 配置 Y 轴刻度最大值
-          splitNumber: 7 // 配置 Y 轴数值间隔
-        },
-        series: [
-          {
-            name: '猜想',
-            data: [454, 226, 891, 978, 901, 581, 400, 543, 272, 955, 1294, 1581],
-            type: 'line',
-            symbolSize: function(value) { // 点的大小跟随数值增加而变大
-              return value / 150
-            },
-            symbol: 'circle',
-            itemStyle: {
-              normal: {
-                label: {
-                  show: true
-                },
-                lineStyle: {
-                  color: 'rgba(0,0,0,0)'// 折线颜色设置为0,即只显示点,不显示折线
-                }
-              }
-            }
-          },
-          {
-            name: '预期',
-            data: [2455, 2534, 2360, 2301, 2861, 2181, 1944, 2197, 1745, 1810, 2283, 2298],
-            type: 'line',
-            symbolSize: 8, // 设置折线上圆点大小
-            itemStyle: {
-              normal: {
-                label: {
-                  show: true // 在折线拐点上显示数据
-                },
-                lineStyle: {
-                  width: 3, // 设置虚线宽度
-                  type: 'dotted' // 虚线'dotted' 实线'solid'
-                }
-              }
-            }
-          },
-          {
-            name: '实际',
-            data: [1107, 1352, 1740, 1968, 1647, 1570, 1343, 1757, 2547, 2762, 3170, 3665],
-            type: 'line',
-            symbol: 'circle', // 实心圆点
-            smooth: 0.5 // 设置折线弧度
-          }
-        ],
-        color: ['#3366CC', '#FFCC99', '#99CC33'] // 三个折线的颜色
-      }
-    },
-    drawChart6() {
-      getLineChartData().then(resp => {
-        if (resp.code === 0) {
-          const data = resp.data
-          this.chartOption = {
-            title: {
-              text: '卡拉云新用户激活数据',
-              subtext: 'Demo 虚构数据',
-              x: 'center'
-            },
-            tooltip: {},
-            legend: { // 图例配置选项
-              orient: 'horizontal', // 图例布局方式:水平 'horizontal' 、垂直 'vertical'
-              x: 'left', // 横向放置位置,选项:'center'、'left'、'right'、'number'(横向值 px)
-              y: 'top', // 纵向放置位置,选项:'top'、'bottom'、'center'、'number'(纵向值 px)
-              data: ['实际']
-            },
-            grid: { // 图表距离边框的距离,可用百分比和数字(px)配置
-              top: '20%',
-              left: '3%',
-              right: '10%',
-              bottom: '5%',
-              containLabel: true
-            },
-            xAxis: {
-              name: data.xName,
-              type: 'category',
-              data: data.xLabel
-            },
-            yAxis: {
-              name: data.yName,
-              type: 'value',
-              min: data.yMin, // 配置 Y 轴刻度最小值
-              max: data.yMax, // 配置 Y 轴刻度最大值
-              splitNumber: data.ySplitNumber // 配置 Y 轴数值间隔
-            },
-            series: [
-              {
-                name: '实际',
-                data: data.xValue,
-                type: 'line',
-                symbol: 'circle', // 实心圆点
-                smooth: 0.5 // 设置折线弧度
-              }
-            ],
-            color: ['#99CC33'] // 三个折线的颜色
-          }
-        }
-      })
-    },
-    // 获取考试次数数据
-    async getExamNumbers() {
-      await getExamCount().then((resp) => {
-        const examNames = resp.data[0].split(',')
-        const examNumbers = resp.data[1].split(',')
-        examNames.forEach((item, index) => {
-          this.pieData.push({
-            name: item,
-            value: parseInt(examNumbers[index])
-          })
-        })
-        this.drawPie()
-      })
-    },
-    // 考试次数饼图
-    drawPie() {
-      // 基于准备好的dom,初始化echarts实例
-      const myChart = this.$echarts.init(document.getElementById('chart2'))
-      const option = {
-        title: {
-          text: '考试次数占比',
-          subtext: 'dashbord2',
-          x: 'center'
-        },
-        tooltip: {
-          trigger: 'item',
-          formatter: '{a} <br/>{b} : {c}次 ({d}%)'
-        },
-        legend: {
-          orient: 'vertical',
-          left: 'left',
-          data: this.pieData
-        },
-        series: [
-          {
-            name: '考试次数',
-            type: 'pie',
-            radius: '55%',
-            data: this.pieData,
-            roseType: 'angle',
-            itemStyle: {
-              normal: {
-                shadowBlur: 200,
-                shadowColor: 'rgba(0, 0, 0, 0.5)'
-              }
-            }
-          }
-        ]
-      }
-      myChart.setOption(option)
     }
   }
 }
 </script>
 
-<style scoped lang="scss">
+<style scoped>
 .movie-list {
   padding-top: 5px;
   padding-left: 5px;

+ 428 - 0
src/views/exam/ExamDashboard1.vue

@@ -0,0 +1,428 @@
+<template>
+  <el-row>
+    <el-row class="movie-list">
+      <el-card>
+        <div slot="header" class="clearfix">
+          <span>ExamSysDiagram</span>
+        </div>
+        <div>
+          <el-steps :active="0" simple>
+            <el-step title="管理员" icon="el-icon-s-custom" />
+            <el-step title="普通用户" icon="el-icon-user" />
+          </el-steps>
+          <el-steps :active="0" finish-status="success" simple style="margin-top: 20px">
+            <el-step title="科目" icon="el-icon-s-grid" />
+            <el-step title="试题" icon="el-icon-tickets" />
+            <el-step title="试卷" icon="el-icon-notebook-2" />
+          </el-steps>
+          <el-steps :active="0" finish-status="success" simple style="margin-top: 20px">
+            <el-step title="试卷列表" icon="el-icon-files" />
+            <el-step title="试卷测评" icon="el-icon-loading" />
+            <el-step title="试卷批改" icon="el-icon-edit" />
+            <el-step title="试卷结果" icon="el-icon-s-data" />
+          </el-steps>
+        </div>
+      </el-card>
+    </el-row>
+    <el-row class="movie-list">
+      <el-card>
+        <div slot="header" class="clearfix">
+          <span style="margin-right: 5px">选择图表</span>
+          <el-select
+            v-model="selectedValue"
+            clearable
+            placeholder="选择图表"
+            @change="onSelectChange"
+          >
+            <el-option label="考试通过率" value="1" />
+            <el-option label="考试次数占比" value="2" />
+            <el-option label="考试通过率折线图" value="3" />
+            <el-option label="新用户激活数" value="4" />
+          </el-select>
+        </div>
+        <div id="chart1" style="height:400px;" />
+      </el-card>
+    </el-row>
+    <el-row class="movie-list">
+      <el-col :md="12" class="movie-list">
+        <el-card>
+          <div id="chart2" style="height:400px;" />
+        </el-card>
+      </el-col>
+      <el-col :md="12" class="movie-list">
+        <el-card>
+          <div slot="header" class="clearfix">
+            <span style="margin-right: 5px">三级联动</span>
+            <el-cascader
+              :options="cascaderOptions"
+              placeholder="试试搜索: 武侯"
+              clearable
+              filterable
+              @change="handleChange"
+            ></el-cascader>
+          </div>
+          <div id="chart3" style="height:400px;" />
+        </el-card>
+      </el-col>
+    </el-row>
+  </el-row>
+</template>
+
+<script>
+import { getExamCount, getExamPassRate, getLineChartData, getSubjectCascade } from '@/api/exam'
+
+export default {
+  name: 'ExamDashboard',
+  data() {
+    return {
+      // 考试名称
+      examNames: [],
+      // 考试通过率
+      passRate: [],
+      // 饼图的数据
+      pieData: [],
+      selectedValue: '1',
+      chartOption: null,
+      cascaderOptions: []
+    }
+  },
+  created() {
+    // 页面数据加载的等待状态栏
+    this.loading = this.$loading({
+      body: true,
+      lock: true,
+      text: '数据拼命加载中,(*╹▽╹*)',
+      spinner: 'el-icon-loading'
+    })
+    this.getExamPassRate()
+    this.getExamNumbers()
+    this.getData()
+  },
+  methods: {
+    getData() {
+      getSubjectCascade().then(resp => {
+        if (resp.code === 0) {
+          this.cascaderOptions = resp.data
+        }
+      })
+    },
+    handleChange(val) {
+      this.$message.info('select -> ' + val)
+    },
+    onSelectChange() {
+      this.getExamPassRate()
+    },
+    async getExamPassRate() {
+      await getExamPassRate().then((resp) => {
+        if (resp.code === 0) {
+          this.examNames = resp.data[0].split(',')
+          this.passRate = resp.data[1].split(',')
+
+          const intValue = parseInt(this.selectedValue)
+          if (intValue === 1) {
+            this.drawLine()
+          } else if (intValue === 2) {
+            this.drawBrokenLine()
+          } else if (intValue === 3) {
+            this.drawImg4()
+          } else {
+            // this.drawChart5()
+            this.drawChart6()
+          }
+
+          const myChart = this.$echarts.init(document.getElementById('chart1'))
+          // setOption 是 merge,而非赋值, setOption 支持 notMerge 参数, 值为 true 时重新渲染
+          myChart.setOption(this.chartOption, true)
+          this.loading.close()
+        }
+      })
+    },
+    // 考试通过率柱状图
+    drawLine() {
+      this.chartOption = {
+        title: {
+          text: '考试通过率',
+          subtext: 'dashbord1',
+          x: 'center',
+          y: 'top',
+          textAlign: 'center'
+        },
+        tooltip: {},
+        xAxis: {
+          data: this.examNames
+        },
+        yAxis: {},
+        series: [{
+          name: '通过率',
+          type: 'bar',
+          data: this.passRate
+        }]
+      }
+    },
+    // 通过率的折线图
+    drawBrokenLine() {
+      this.chartOption = {
+        // 标题
+        title: {
+          text: '考试通过率折线图',
+          x: 'center'
+        },
+        // x轴
+        xAxis: {
+          data: this.examNames
+        },
+        // y轴没有显式设置,根据值自动生成y轴
+        yAxis: {},
+        // 数据-data是最终要显示的数据
+        series: [{
+          name: '通过率',
+          type: 'line',
+          areaStyle: {
+            normal: {}
+          },
+          data: this.passRate
+        }]
+      }
+    },
+    drawImg4() {
+      this.chartOption = {
+        color: ['#cd5c5c'],
+        textStyle: {
+          color: 'black'
+        },
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: {
+            type: 'shadow'
+          },
+          formatter: '{a} <br/>{b} : {c}'
+        },
+        grid: {
+          containLabel: true
+        },
+        xAxis: {
+          type: 'value',
+          boundaryGap: [0, 0.01],
+          axisLine: {
+            lineStyle: {
+              color: '#fff'
+            }
+          },
+          'axisLabel': {
+            'interval': 0,
+            fontSize: 18,
+            formatter: '{value}'
+          }
+        },
+        yAxis: {
+          axisLine: {
+            lineStyle: {
+              color: '#fff'
+            }
+          },
+          'axisLabel': {
+            'interval': 0,
+            fontSize: 18
+          },
+          type: 'category',
+          data: this.examNames
+        },
+        series: [{
+          name: '通过率:',
+          type: 'bar',
+          data: this.passRate
+        }]
+      }
+    },
+    drawChart5() {
+      this.chartOption = {
+        title: {
+          text: '卡拉云新用户激活数据',
+          subtext: 'Demo 虚构数据',
+          x: 'center'
+        },
+        legend: { // 图例配置选项
+          orient: 'horizontal', // 图例布局方式:水平 'horizontal' 、垂直 'vertical'
+          x: 'left', // 横向放置位置,选项:'center'、'left'、'right'、'number'(横向值 px)
+          y: 'top', // 纵向放置位置,选项:'top'、'bottom'、'center'、'number'(纵向值 px)
+          data: ['猜想', '预期', '实际']
+        },
+        grid: { // 图表距离边框的距离,可用百分比和数字(px)配置
+          top: '20%',
+          left: '3%',
+          right: '10%',
+          bottom: '5%',
+          containLabel: true
+        },
+        xAxis: {
+          name: '月份',
+          type: 'category',
+          data: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
+        },
+        yAxis: {
+          name: '人次',
+          type: 'value',
+          min: 0, // 配置 Y 轴刻度最小值
+          max: 4000, // 配置 Y 轴刻度最大值
+          splitNumber: 7 // 配置 Y 轴数值间隔
+        },
+        series: [
+          {
+            name: '猜想',
+            data: [454, 226, 891, 978, 901, 581, 400, 543, 272, 955, 1294, 1581],
+            type: 'line',
+            symbolSize: function(value) { // 点的大小跟随数值增加而变大
+              return value / 150
+            },
+            symbol: 'circle',
+            itemStyle: {
+              normal: {
+                label: {
+                  show: true
+                },
+                lineStyle: {
+                  color: 'rgba(0,0,0,0)'// 折线颜色设置为0,即只显示点,不显示折线
+                }
+              }
+            }
+          },
+          {
+            name: '预期',
+            data: [2455, 2534, 2360, 2301, 2861, 2181, 1944, 2197, 1745, 1810, 2283, 2298],
+            type: 'line',
+            symbolSize: 8, // 设置折线上圆点大小
+            itemStyle: {
+              normal: {
+                label: {
+                  show: true // 在折线拐点上显示数据
+                },
+                lineStyle: {
+                  width: 3, // 设置虚线宽度
+                  type: 'dotted' // 虚线'dotted' 实线'solid'
+                }
+              }
+            }
+          },
+          {
+            name: '实际',
+            data: [1107, 1352, 1740, 1968, 1647, 1570, 1343, 1757, 2547, 2762, 3170, 3665],
+            type: 'line',
+            symbol: 'circle', // 实心圆点
+            smooth: 0.5 // 设置折线弧度
+          }
+        ],
+        color: ['#3366CC', '#FFCC99', '#99CC33'] // 三个折线的颜色
+      }
+    },
+    drawChart6() {
+      getLineChartData().then(resp => {
+        if (resp.code === 0) {
+          const data = resp.data
+          this.chartOption = {
+            title: {
+              text: '卡拉云新用户激活数据',
+              subtext: 'Demo 虚构数据',
+              x: 'center'
+            },
+            tooltip: {},
+            legend: { // 图例配置选项
+              orient: 'horizontal', // 图例布局方式:水平 'horizontal' 、垂直 'vertical'
+              x: 'left', // 横向放置位置,选项:'center'、'left'、'right'、'number'(横向值 px)
+              y: 'top', // 纵向放置位置,选项:'top'、'bottom'、'center'、'number'(纵向值 px)
+              data: ['实际']
+            },
+            grid: { // 图表距离边框的距离,可用百分比和数字(px)配置
+              top: '20%',
+              left: '3%',
+              right: '10%',
+              bottom: '5%',
+              containLabel: true
+            },
+            xAxis: {
+              name: data.xName,
+              type: 'category',
+              data: data.xLabel
+            },
+            yAxis: {
+              name: data.yName,
+              type: 'value',
+              min: data.yMin, // 配置 Y 轴刻度最小值
+              max: data.yMax, // 配置 Y 轴刻度最大值
+              splitNumber: data.ySplitNumber // 配置 Y 轴数值间隔
+            },
+            series: [
+              {
+                name: '实际',
+                data: data.xValue,
+                type: 'line',
+                symbol: 'circle', // 实心圆点
+                smooth: 0.5 // 设置折线弧度
+              }
+            ],
+            color: ['#99CC33'] // 三个折线的颜色
+          }
+        }
+      })
+    },
+    // 获取考试次数数据
+    async getExamNumbers() {
+      await getExamCount().then((resp) => {
+        const examNames = resp.data[0].split(',')
+        const examNumbers = resp.data[1].split(',')
+        examNames.forEach((item, index) => {
+          this.pieData.push({
+            name: item,
+            value: parseInt(examNumbers[index])
+          })
+        })
+        this.drawPie()
+      })
+    },
+    // 考试次数饼图
+    drawPie() {
+      // 基于准备好的dom,初始化echarts实例
+      const myChart = this.$echarts.init(document.getElementById('chart2'))
+      const option = {
+        title: {
+          text: '考试次数占比',
+          subtext: 'dashbord2',
+          x: 'center'
+        },
+        tooltip: {
+          trigger: 'item',
+          formatter: '{a} <br/>{b} : {c}次 ({d}%)'
+        },
+        legend: {
+          orient: 'vertical',
+          left: 'left',
+          data: this.pieData
+        },
+        series: [
+          {
+            name: '考试次数',
+            type: 'pie',
+            radius: '55%',
+            data: this.pieData,
+            roseType: 'angle',
+            itemStyle: {
+              normal: {
+                shadowBlur: 200,
+                shadowColor: 'rgba(0, 0, 0, 0.5)'
+              }
+            }
+          }
+        ]
+      }
+      myChart.setOption(option)
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.movie-list {
+  padding-top: 5px;
+  padding-left: 5px;
+  padding-right: 5px;
+}
+</style>

+ 253 - 121
src/views/exam/ExamEvalList.vue

@@ -1,13 +1,14 @@
 <template>
-  <el-container>
-    <el-header height="220">
-      <el-row>
+  <el-container class="exam-container">
+    <el-header height="auto" class="exam-header">
+      <div class="filter-wrapper">
+        <span class="filter-label">科目筛选:</span>
         <el-select
           v-model="queryInfo.subjectId"
           clearable
           placeholder="请选择科目"
-          style="margin-left: 5px"
           @change="subjectChange"
+          size="medium"
         >
           <el-option
             v-for="item in allSubject"
@@ -16,129 +17,164 @@
             :value="item.value"
           />
         </el-select>
-      </el-row>
+      </div>
     </el-header>
 
-    <el-main>
-      <el-table
-        :data="dataList"
-        border
-        style="width: 100%"
-      >
-        <el-table-column
-          fixed="left"
-          label="No"
-          type="index"
-        />
-        <el-table-column
-          prop="examName"
-          label="测评名称"
-          width="150"
-        />
-        <el-table-column
-          label="测评码"
-          width="90"
+    <el-main class="exam-main">
+      <el-card shadow="never" class="table-card">
+        <el-table
+          :data="dataList"
+          stripe
+          style="width: 100%"
+          header-cell-class-name="table-header-cell"
         >
-          <template slot-scope="scope">
-            <el-tag
-              v-if="scope.row.needPasscode"
-              size="mini"
-              type="danger"
-            >需要</el-tag>
-            <el-tag
-              v-else
-              size="mini"
-              type="success"
-            >不需要</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column
-          prop="startTime"
-          label="测评时间"
-          width="150"
-        />
-        <el-table-column
-          prop="duration"
-          label="测评时长(分钟)"
-          width="150"
-        />
-        <el-table-column
-          prop="totalScore"
-          label="试卷总分"
-        />
-        <el-table-column
-          prop="myScore"
-          label="我的分数"
-        />
-        <el-table-column
-          fixed="right"
-          label="操作"
-          width="320"
-        >
-          <template slot-scope="scope">
-            <el-button
-              v-if="scope.row.evalStatus === 1"
-              size="mini"
-              type="primary"
-              @click="prepareExam(scope.$index, scope.row)"
-            >去测评</el-button>
-            <el-button
-              v-else-if="scope.row.evalStatus === 2"
-              size="mini"
-              type="danger"
-              @click="markPaper(scope.$index, scope.row)"
-            >去批改</el-button>
-            <el-button
-              v-else-if="scope.row.evalStatus === 3"
-              size="mini"
-              type="warning"
-              @click="waitMark(scope.$index, scope.row)"
-            >待批改</el-button>
-            <el-button
-              v-else
-              size="mini"
-              type="success"
-              @click="viewResult(scope.$index, scope.row)"
-            >看结果</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-      <el-pagination
-        background
-        :small="screenWidth <= 768"
-        layout="prev, pager, next"
-        :page-size="pageSize"
-        :current-page="currentPage"
-        :total="totalSize"
-        @current-change="handleCurrentChange"
-        @prev-click="handleCurrentChange"
-        @next-click="handleCurrentChange"
-      />
+          <el-table-column
+            fixed="left"
+            label="序号"
+            type="index"
+            align="center"
+          />
+          <el-table-column
+            prop="examName"
+            label="测评名称"
+            show-overflow-tooltip
+          />
+          <el-table-column
+            label="测评码"
+            align="center"
+          >
+            <template slot-scope="scope">
+              <el-tag
+                v-if="scope.row.needPasscode"
+                size="small"
+                type="danger"
+                effect="light"
+              >需要</el-tag>
+              <el-tag
+                v-else
+                size="small"
+                type="success"
+                effect="light"
+              >不需要</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column
+            prop="startTime"
+            label="测评时间"
+            align="center"
+          />
+          <el-table-column
+            prop="duration"
+            label="时长"
+            align="center"
+          />
+          <el-table-column
+            prop="totalScore"
+            label="试卷总分"
+            align="center"
+          />
+          <el-table-column
+            prop="userScore"
+            label="我的分数"
+            align="center"
+          />
+          <el-table-column
+            fixed="right"
+            label="操作"
+            align="center"
+          >
+            <template slot-scope="scope">
+              <el-button
+                v-if="scope.row.evalStatus === 1"
+                size="mini"
+                type="primary"
+                icon="el-icon-edit"
+                @click="prepareExam(scope.$index, scope.row)"
+              >去测评</el-button>
+              <el-button
+                v-else-if="scope.row.evalStatus === 2"
+                size="mini"
+                type="danger"
+                icon="el-icon-s-claim"
+                @click="markPaper(scope.$index, scope.row)"
+              >进行中</el-button>
+              <el-button
+                v-else-if="scope.row.evalStatus === 3"
+                size="mini"
+                type="warning"
+                icon="el-icon-loading"
+                disabled
+                @click="waitMark(scope.$index, scope.row)"
+              >阅卷中</el-button>
+              <el-button
+                v-else-if="scope.row.evalStatus === 4"
+                size="mini"
+                type="warning"
+                icon="el-icon-loading"
+                disabled
+                @click="waitMark(scope.$index, scope.row)"
+              >看结果</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <div class="pagination-container">
+          <el-pagination
+            background
+            :small="screenWidth <= 768"
+            layout="total, prev, pager, next"
+            :page-size="pageSize"
+            :current-page="currentPage"
+            :total="totalSize"
+            @current-change="handleCurrentChange"
+            @prev-click="handleCurrentChange"
+            @next-click="handleCurrentChange"
+          />
+        </div>
+      </el-card>
     </el-main>
 
     <el-dialog
-      title="测评提示"
+      title="进入测评确认"
       :visible.sync="startExamDialog"
       center
-      width="50%"
+      width="480px"
+      custom-class="exam-confirm-dialog"
     >
       <el-alert
-        title="点击`开始测评`后将自动进入测评,请诚信测评,测评过程中可能会对用户行为、用户视频进行截图采样,请知悉!"
-        type="error"
+        title="请诚信测评!"
+        description="点击“开始测评”后将自动进入系统。测评过程中可能会对用户行为、用户视频进行截图采样,请知悉。"
+        type="warning"
+        :closable="false"
+        show-icon
       />
-      <el-card style="margin-top: 25px">
-        <span>测评名称:</span>{{ currentSelectedExam.examName }}
-        <br>
-        <span>测评时长:</span>{{ currentSelectedExam.duration + '分钟' }}
-        <br>
-        <span>试卷总分:</span>{{ currentSelectedExam.totalScore }}分
-        <br>
-        <span>开放类型:</span> {{ currentSelectedExam.needPasscode ? '需要密码' : '完全公开' }}
-        <br>
-      </el-card>
+
+      <div class="exam-info-spec">
+        <div class="spec-item">
+          <span class="label">测评名称:</span>
+          <span class="value val-bold">{{ currentSelectedExam.examName }}</span>
+        </div>
+        <div class="spec-item">
+          <span class="label">测评时长:</span>
+          <span class="value">{{ currentSelectedExam.duration }} 分钟</span>
+        </div>
+        <div class="spec-item">
+          <span class="label">试卷总分:</span>
+          <span class="value">{{ currentSelectedExam.totalScore }} 分</span>
+        </div>
+        <div class="spec-item">
+          <span class="label">开放类型:</span>
+          <span class="value">
+            <el-tag size="mini" :type="currentSelectedExam.needPasscode ? 'danger' : 'success'">
+              {{ currentSelectedExam.needPasscode ? '密码开启' : '完全公开' }}
+            </el-tag>
+          </span>
+        </div>
+      </div>
+
       <span slot="footer" class="dialog-footer">
-        <el-button @click="startExamDialog = false">返 回</el-button>
-        <el-button type="primary" @click="startExam(currentSelectedExam.examId)">开始测评</el-button>
+        <el-button @click="startExamDialog = false" size="medium">返 回</el-button>
+        <el-button type="primary" @click="startExam(currentSelectedExam.examId)" size="medium">开始测评</el-button>
       </span>
     </el-dialog>
   </el-container>
@@ -244,13 +280,11 @@ export default {
       }).catch(() => {
       })
     },
-    startExam(paperId) {
+    startExam(examId) {
       const routeUrl = this.$router.resolve({
-        path: '/exam/paper/detail',
+        path: '/exams',
         query: {
-          paperId: paperId,
-          viewType: 2,
-          userId: ''
+          examId: examId
         }
       })
       window.open(routeUrl.href, '_blank')
@@ -286,5 +320,103 @@ export default {
 }
 </script>
 
-<style>
+<style scoped>
+/* 使用 scoped 避免样式污染,采用更具现代感的设计规范 */
+.exam-container {
+  background-color: #f5f7fa;
+  min-height: 100vh;
+  padding: 16px;
+}
+
+.exam-header {
+  background-color: #ffffff;
+  border-radius: 8px;
+  padding: 16px 24px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+  display: flex;
+  align-items: center;
+}
+
+.filter-wrapper {
+  display: flex;
+  align-items: center;
+}
+
+.filter-label {
+  font-size: 14px;
+  color: #606266;
+  font-weight: 500;
+  margin-right: 8px;
+}
+
+.exam-main {
+  padding: 0 !important;
+}
+
+.table-card {
+  border-radius: 8px;
+  border: none;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05) !important;
+}
+
+/* 表头美化 */
+::v-deep .table-header-cell {
+  background-color: #fafafa !important;
+  color: #303133 !important;
+  font-weight: 600;
+  height: 48px;
+}
+
+.score-text {
+  font-weight: 600;
+  color: #303133;
+}
+
+.pagination-container {
+  margin-top: 24px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+/* 弹窗内容规格表美化 */
+.exam-info-spec {
+  margin-top: 20px;
+  background-color: #f8f9fa;
+  border-radius: 6px;
+  padding: 16px;
+  border: 1px solid #e4e7ed;
+}
+
+.spec-item {
+  display: flex;
+  margin-bottom: 12px;
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+.spec-item:last-child {
+  margin-bottom: 0;
+}
+
+.spec-item .label {
+  color: #909399;
+  width: 80px;
+  flex-shrink: 0;
+}
+
+.spec-item .value {
+  color: #303133;
+}
+
+.spec-item .val-bold {
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+/* 规整弹窗底部按钮间距 */
+.exam-confirm-dialog ::v-deep .el-dialog__footer {
+  padding-top: 10px;
+  padding-bottom: 24px;
+}
 </style>

+ 366 - 361
src/views/exam/ExamPaper.vue

@@ -3,25 +3,25 @@
     <el-aside width="280px" class="exam-aside">
       <div class="aside-card timer-box">
         <div class="timer-title"><i class="el-icon-time" /> 剩余时间</div>
-        <div class="timer-display" :class="{ 'time-danger': remainTime < 300 }">
+        <div class="timer-display" :class="{ 'time-danger': !timeLoading && remainTime < 300 }">
           {{ formatRemainTime }}
         </div>
       </div>
 
       <div class="aside-card answer-card">
         <h4 class="card-title">答题卡</h4>
-        <div v-for="(group, gIndex) in examData.questionGroups" :key="gIndex" class="card-group">
-          <div class="group-label">{{ group.groupTitle }}</div>
+        <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, qIndex) in group.questions"
-              :key="q.id"
+              v-for="q in section.flatQuestions"
+              :key="q.questionId"
               class="number-item"
               :class="{
                 'is-answered': checkIsAnswered(q),
-                'is-active': currentActiveId === q.id
+                'is-active': currentActiveId === q.questionId
               }"
-              @click="scrollToQuestion(q.id)"
+              @click="scrollToQuestion(q.questionId)"
             >
               {{ q.number }}
             </span>
@@ -36,66 +36,134 @@
 
     <el-main class="exam-main">
       <div class="paper-header">
-        <h2 class="paper-title">{{ examData.title }}</h2>
+        <h2 class="paper-title">{{ paperInfo.title }}</h2>
         <div class="paper-meta">
-          <span>总分:{{ examData.totalScore }}分</span>
-          <span>及格线:{{ examData.passingScore }}分</span>
-          <span>考试科目:{{ examData.subject }}</span>
+          <span>考试科目:{{ paperInfo.subjectName || '未指定' }}</span>
         </div>
       </div>
 
-      <div v-for="(group, gIndex) in examData.questionGroups" :key="gIndex" class="question-group-section">
+      <div v-for="(section, sIndex) in sectionsList" :key="sIndex" class="question-group-section">
         <h3 class="group-header-title">
-          {{ group.groupTitle }} <span class="group-desc">(每题 {{ group.scorePerQuestion }} 分)</span>
+          {{ section.sectionName }} <span class="group-desc">({{ section.sectionHint }})</span>
         </h3>
 
         <div
-          v-for="(q, qIndex) in group.questions"
-          :key="q.id"
-          :id="'q-' + q.id"
+          v-for="q in section.questions"
+          :id="'q-' + q.questionId"
+          :key="q.questionId"
           class="question-card"
-          @mouseenter="currentActiveId = q.id"
+          @mouseenter="currentActiveId = q.questionId"
         >
-          <div class="question-stem">
-            <span class="q-number">{{ q.number }}.</span>
-            <span class="q-text">{{ q.stem }}</span>
-          </div>
-
-          <div class="question-body">
-            <template v-if="q.type === 'SINGLE_CHOICE'">
-              <el-radio-group v-model="answers[q.id]">
-                <el-radio v-for="opt in q.options" :key="opt.key" :label="opt.key" class="option-item">
-                  <span class="option-key">{{ opt.key }}.</span> {{ opt.value }}
+          <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)" />
+            </div>
+            <div class="question-body">
+              <el-radio-group v-model="answers[q.questionId]" @change="handleAnswerChange(q.questionId)">
+                <el-radio v-for="opt in q.options" :key="opt.pos" :label="opt.pos" class="option-item">
+                  {{ opt.content }}
                 </el-radio>
               </el-radio-group>
-            </template>
-
-            <template v-else-if="q.type === 'MULTIPLE_CHOICE'">
-              <el-checkbox-group v-model="answers[q.id]">
-                <el-checkbox v-for="opt in q.options" :key="opt.key" :label="opt.key" class="option-item">
-                  <span class="option-key">{{ opt.key }}.</span> {{ opt.value }}
+            </div>
+          </template>
+
+          <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)" />
+              <el-tag type="warning" size="mini" class="type-tag">多选</el-tag>
+            </div>
+            <div class="question-body">
+              <el-checkbox-group v-model="answers[q.questionId]" @change="handleAnswerChange(q.questionId)">
+                <el-checkbox v-for="opt in q.options" :key="opt.pos" :label="opt.pos" class="option-item checkbox-item">
+                  {{ opt.content }}
                 </el-checkbox>
               </el-checkbox-group>
-            </template>
-
-            <template v-else-if="q.type === 'TRUE_FALSE'">
-              <el-radio-group v-model="answers[q.id]">
-                <el-radio label="T" class="option-item">正确</el-radio>
-                <el-radio label="F" class="option-item">错误</el-radio>
-              </el-radio-group>
-            </template>
-
-            <template v-else-if="q.type === 'SHORT_ANSWER'">
+            </div>
+          </template>
+
+          <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)" />
+            </div>
+            <div class="question-body completion-body">
+              <div v-for="(item, index) in answers[q.questionId]" :key="index" class="completion-item">
+                <span class="completion-prefix">第 {{ index + 1 }} 空:</span>
+                <el-input
+                  v-model="answers[q.questionId][index]"
+                  size="small"
+                  placeholder="请输入答案"
+                  @change="handleAnswerChange(q.questionId)"
+                />
+              </div>
+            </div>
+          </template>
+
+          <template v-else-if="q.typeCode === 5">
+            <div class="question-stem">
+              <span class="q-number">{{ q.number }}.</span>
+              <span class="q-text" v-html="formatStem(q.content, q.score)" />
+            </div>
+            <div class="question-body">
               <el-input
-                v-model="answers[q.id]"
+                v-model="answers[q.questionId]"
                 type="textarea"
-                :rows="4"
-                placeholder="请输入您的作答内容..."
-                maxlength="500"
-                show-word-limit
+                :rows="6"
+                placeholder="请输入作答内容..."
+                @change="handleAnswerChange(q.questionId)"
               />
-            </template>
-          </div>
+            </div>
+          </template>
+
+          <template v-else-if="q.typeCode === 6">
+            <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-text">{{ subQ.content }}({{ subQ.score }}分)</span>
+                </div>
+
+                <div class="question-body">
+                  <template v-if="subQ.typeCode === 1">
+                    <el-radio-group v-model="answers[subQ.questionId]" @change="handleAnswerChange(subQ.questionId)">
+                      <el-radio v-for="opt in subQ.options" :key="opt.pos" :label="opt.pos" class="option-item">
+                        {{ opt.content }}
+                      </el-radio>
+                    </el-radio-group>
+                  </template>
+
+                  <template v-if="subQ.typeCode === 2">
+                    <el-checkbox-group v-model="answers[subQ.questionId]" @change="handleAnswerChange(subQ.questionId)">
+                      <el-checkbox v-for="opt in subQ.options" :key="opt.pos" :label="opt.pos" class="option-item checkbox-item">
+                        {{ opt.content }}
+                      </el-checkbox>
+                    </el-checkbox-group>
+                  </template>
+
+                  <template v-else-if="subQ.typeCode === 4">
+                    <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>
+                        <el-input
+                          v-model="answers[subQ.questionId][index]"
+                          size="small"
+                          placeholder="请输入答案"
+                          @change="handleAnswerChange(subQ.questionId)"
+                        />
+                      </div>
+                    </div>
+                  </template>
+                </div>
+              </div>
+            </div>
+          </template>
         </div>
       </div>
     </el-main>
@@ -103,150 +171,202 @@
 </template>
 
 <script>
+import { cacheUserAnswer, getCachedUserAnswer, getExamInfo, submitExam } from '@/api/exam'
+
 export default {
   name: 'ExamPaper',
   data() {
     return {
-      remainTime: 7200, // 考试剩余时间(秒),示例为 2 小时
+      queryParams: {
+        examId: 0
+      },
+      remainTime: 0,
+      timeLoading: true,
       timer: null,
-      currentActiveId: null, // 当前正在视线聚焦的题目ID
-
-      // 核心作答数据收集器模型,以 questionId 作为 key
+      currentActiveId: null,
+      // 🧠 核心思路:前端在组件内部依然使用 Object 方便 v-model 绑定和实时读写
+      // 在「读取缓存」和「打包提交」时,与你要求的数组结构进行相互转换
       answers: {},
-
-      // 试卷完整多维结构数据
-      examData: {
-        title: '2026年全栈工程师综合技术能力认证评估',
-        subject: '计算机科学与专业综合',
-        totalScore: 100,
-        passingScore: 60,
-        questionGroups: [
-          {
-            type: 'SINGLE_CHOICE',
-            groupTitle: '一、单项选择题',
-            scorePerQuestion: 2,
-            questions: [
-              {
-                id: 1001,
-                number: 1,
-                type: 'SINGLE_CHOICE',
-                stem: '在 Vue 2 中,利用哪个生命周期钩子最适合进行初始异步数据请求?',
-                options: [
-                  { key: 'A', value: 'beforeCreate' },
-                  { key: 'B', value: 'created' },
-                  { key: 'C', value: 'beforeMount' },
-                  { key: 'D', value: 'destroyed' }
-                ]
-              },
-              {
-                id: 1002,
-                number: 2,
-                type: 'SINGLE_CHOICE',
-                stem: '下列不属于 HTTP 状态码 5xx 系列(服务器错误)的是?',
-                options: [
-                  { key: 'A', value: '500 Internal Server Error' },
-                  { key: 'B', value: '502 Bad Gateway' },
-                  { key: 'C', value: '403 Forbidden' },
-                  { key: 'D', value: '503 Service Unavailable' }
-                ]
-              }
-            ]
-          },
-          {
-            type: 'MULTIPLE_CHOICE',
-            groupTitle: '二、多项选择题',
-            scorePerQuestion: 4,
-            questions: [
-              {
-                id: 2001,
-                number: 3,
-                type: 'MULTIPLE_CHOICE',
-                stem: '关系型数据库(RDBMS)通常包含以下哪些核心特征?',
-                options: [
-                  { key: 'A', value: '支持强一致性(ACID特质)' },
-                  { key: 'B', value: '基于行与列构成的二维表存储' },
-                  { key: 'C', value: '天然支持无模式(Schema-less)自由嵌套' },
-                  { key: 'D', value: '支持使用通用标准的 SQL 语句进行查询' }
-                ]
-              }
-            ]
-          },
-          {
-            type: 'TRUE_FALSE',
-            groupTitle: '三、判断题',
-            scorePerQuestion: 2,
-            questions: [
-              {
-                id: 3001,
-                number: 4,
-                type: 'TRUE_FALSE',
-                stem: 'JavaScript 中的 const 关键字定义的变量,其内部对象的属性内容也是绝对不可被修改的。'
-              },
-              {
-                id: 3002,
-                number: 5,
-                type: 'TRUE_FALSE',
-                stem: '使用游标(Cursor-based)分页可以有效避免高并发日志插入造成的跳页和重叠问题。'
-              }
-            ]
-          },
-          {
-            type: 'SHORT_ANSWER',
-            groupTitle: '四、简答题',
-            scorePerQuestion: 10,
-            questions: [
-              {
-                id: 4001,
-                number: 6,
-                type: 'SHORT_ANSWER',
-                stem: '请简述现代前端在处理海量音视频流(如直播或连续视频审核)时,如何有效防止硬件解码器队列泄漏或卡死。'
-              }
-            ]
-          }
-        ]
-      }
+      paperInfo: {},
+      sectionsList: []
     }
   },
   computed: {
-    // 倒计时格式化器 (02:00:00)
     formatRemainTime() {
+      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
+          answerList: answerList
+        }
+      })
     }
   },
   created() {
-    this.initAnswersMap()
-    this.startTimer()
+    const examId = this.$route.query.examId
+    if (!examId) {
+      this.$message.error('参数缺失,无法加载测评页面!')
+      return
+    }
+
+    this.queryParams.examId = examId
+    this.loadPaperData()
   },
   beforeDestroy() {
     if (this.timer) clearInterval(this.timer)
   },
   methods: {
-    // 🌟 1. 初始化答案容器,防止多选题数组报错
-    initAnswersMap() {
-      const newAnswers = {}
-      this.examData.questionGroups.forEach(group => {
-        group.questions.forEach(q => {
-          if (q.type === 'MULTIPLE_CHOICE') {
-            newAnswers[q.id] = [] // 多选题绑定的响应式基础必须是数组
-          } else {
-            newAnswers[q.id] = '' // 其余单选、填空、判断初始化为字符串
-          }
+    async loadPaperData() {
+      // 1. 🚀 优先拿到异步的后端缓存数据
+      const cachedAnswersArray = await this.getCacheAnswer()
+      const cachedAnswersMap = {}
+
+      if (cachedAnswersArray && Array.isArray(cachedAnswersArray)) {
+        cachedAnswersArray.forEach(item => {
+          cachedAnswersMap[item.questionId] = item.answerList
         })
+      }
+
+      // 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 = {}
+
+          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 !== 6) {
+                q.number = globalQuestionIndex++
+                flatQuestions.push({ questionId: q.questionId, number: q.number, typeCode: q.typeCode })
+
+                // 🛠️ 此时 cachedAnswersMap 已经有完整数据了,可以安全还原
+                if (cachedAnswersMap && cachedAnswersMap[q.questionId] !== undefined) {
+                  const savedList = cachedAnswersMap[q.questionId]
+                  localAnswers[q.questionId] = q.typeCode === 4 ? savedList : (savedList[0] || '')
+                } else {
+                  localAnswers[q.questionId] = q.typeCode === 4 ? new Array((q.options || []).length).fill('') : ''
+                }
+              } 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('') : ''
+                  }
+                })
+              }
+              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)
+        }
+      }).catch(error => {
+        this.$message.error(error.message)
       })
-      this.answers = newAnswers
     },
 
-    // 🌟 2. 启动考试倒计时
+    // 监听任意作答变更,实时写入本地缓存
+    handleAnswerChange(qId) {
+      // 🛠️ 调整:写入缓存时,直接写入符合新结构要求的数组
+      this.cacheAnswer(this.formattedAnswersSubmit)
+    },
+
+    // 缓存工具方法:写入
+    cacheAnswer(answersData) {
+      const cachedPaperAnswer = {
+        examId: this.queryParams.examId,
+        paperId: this.paperInfo.paperId,
+        answers: answersData
+      }
+      cacheUserAnswer(cachedPaperAnswer).then(resp => {
+        if (resp.code !== 0) {
+          console.log(resp.msg)
+        }
+      })
+    },
+
+    // 缓存工具方法:读取
+    async getCacheAnswer() {
+      const queryParams = {
+        examId: this.queryParams.examId
+      }
+      try {
+        const resp = await getCachedUserAnswer(queryParams)
+        if (resp.code === 0 && resp.data) {
+          return resp.data.answers // 成功时返回答案数组
+        }
+      } catch (error) {
+        console.error('获取缓存答案失败:', error)
+      }
+      return null // 异常或无数据时返回 null
+    },
+
     startTimer() {
       this.timer = setInterval(() => {
         if (this.remainTime > 0) {
           this.remainTime--
         } else {
           clearInterval(this.timer)
-          this.$alert('考试时间已到,系统已为您自动提交试卷!', '提示', {
+          this.$alert('考试时间已到,系统已自动提交!', '提示', {
             confirmButtonText: '确定',
             callback: () => { this.submitExam() }
           })
@@ -254,36 +374,48 @@ export default {
       }, 1000)
     },
 
-    // 🌟 3. 检测题目是否已被作答(驱动答题卡亮起)
+    // 智能检查是否已作答
     checkIsAnswered(question) {
-      const ans = this.answers[question.id]
-      if (question.type === 'MULTIPLE_CHOICE') {
-        return ans && ans.length > 0
+      const ans = this.answers[question.questionId]
+      if (ans === undefined || ans === null) return false
+
+      if (Array.isArray(ans)) {
+        return ans.some(item => item && String(item).trim() !== '')
       }
-      return ans !== undefined && ans !== null && ans.trim !== undefined ? ans.trim() !== '' : ans !== ''
+      return String(ans).trim() !== ''
     },
 
-    // 🌟 4. 点击答题卡,平滑锚点滚动到对应题目
     scrollToQuestion(qId) {
       this.currentActiveId = qId
       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
+          }
+        }
       }
     },
 
-    // 🌟 5. 交卷确认弹窗
+    formatStem(content, score) {
+      if (!content) return ''
+      return content.replace(/(\s*\d+分\s*)/, `(${score}分)`)
+    },
+
     handleConfirmSubmit() {
-      // 统计未做题目数量
       let unAnsweredCount = 0
-      this.examData.questionGroups.forEach(group => {
-        group.questions.forEach(q => {
+      this.sectionsList.forEach(sec => {
+        sec.flatQuestions.forEach(q => {
           if (!this.checkIsAnswered(q)) unAnsweredCount++
         })
       })
 
       const tipMessage = unAnsweredCount > 0
-        ? `您当前还有 ${unAnsweredCount} 道题未作答,确定要现在交卷吗?`
+        ? `您当前还有 ${unAnsweredCount} 道题未完全作答,确定要现在交卷吗?`
         : '您已完成所有题目,确定提交试卷吗?'
 
       this.$confirm(tipMessage, '交卷确认', {
@@ -295,208 +427,81 @@ export default {
       }).catch(() => {})
     },
 
-    // 🌟 6. 执行提交逻辑
     submitExam() {
-      console.log('最终提交的答题 JSON 数据矩阵:', this.answers)
-      this.$message.success('试卷提交成功!正在为您生成评测报告...')
-      // 可以在此处调用后端接口发送 this.answers
+      const jsonData = {}
+      jsonData.examId = this.queryParams.examId
+      jsonData.paperId = this.paperInfo.paperId
+      jsonData.answers = this.formattedAnswersSubmit
+      submitExam(jsonData).then(resp => {
+        if (resp.code === 0) {
+          this.$message.success('试卷提交成功!')
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      }).catch(error => {
+        this.$message.error(error.message)
+      })
     }
   }
 }
 </script>
 
 <style scoped>
-.exam-container {
-  height: 100vh;
-  background-color: #f0f2f5;
-}
-
-/* ==================== ⏱️ 左侧面板层 ==================== */
-.exam-aside {
-  padding: 20px;
+/* 原有基础样式保持不变 */
+.exam-container { height: 100vh; background-color: #f0f2f5; }
+.exam-aside { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
+.aside-card { background: #ffffff; border-radius: 8px; padding: 18px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
+.timer-box { text-align: center; background: #1e293b; color: #ffffff; }
+.timer-title { font-size: 13px; color: #94a3b8; margin-bottom: 6px; }
+.timer-display { font-size: 26px; font-family: monospace; font-weight: bold; color: #38bdf8; }
+.time-danger { color: #f43f5e !important; animation: blink 1s infinite; }
+.answer-card { flex: 1; display: flex; flex-direction: column; overflow-y: auto; }
+.card-title { margin: 0 0 14px 0; color: #1e293b; border-left: 3px solid #3b82f6; padding-left: 8px; }
+.card-group { margin-bottom: 16px; }
+.group-label { font-size: 12px; color: #64748b; margin-bottom: 8px; font-weight: bold;}
+.number-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
+.number-item { height: 36px; display: flex; align-items: center; justify-content: center; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; color: #475569; cursor: pointer; transition: all 0.2s; user-select: none; }
+.number-item:hover { border-color: #3b82f6; color: #3b82f6; }
+.number-item.is-answered { background-color: #e2f5ec; border-color: #10b981; color: #10b981; }
+.number-item.is-active { box-shadow: 0 0 0 2px #3b82f6; font-weight: bold; }
+.btn-submit { width: 100%; margin-top: auto; font-weight: bold; height: 40px; }
+.exam-main { padding: 20px 40px 40px 20px; height: 100%; overflow-y: auto; }
+.paper-header { background: #ffffff; padding: 24px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); text-align: center; }
+.paper-title { margin: 0 0 12px 0; color: #1e293b; }
+.paper-meta { display: flex; justify-content: center; gap: 24px; font-size: 13px; color: #64748b; }
+.question-group-section { margin-bottom: 24px; }
+.group-header-title { font-size: 16px; color: #334155; margin-bottom: 12px; }
+.group-desc { font-size: 13px; color: #94a3b8; font-weight: normal; }
+.question-card { background: #ffffff; border-radius: 8px; padding: 24px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.03); border-left: 4px solid transparent; transition: all 0.2s; }
+.question-card:hover { border-left-color: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.08); }
+.question-stem { font-size: 15px; color: #1e293b; font-weight: 500; line-height: 1.6; margin-bottom: 16px; }
+.q-number { margin-right: 6px; font-weight: bold; }
+.question-body { padding-left: 18px; }
+.option-item { display: block; margin-bottom: 14px !important; font-size: 14px; color: #334155; white-space: normal; line-height: 1.4; }
+.checkbox-item { margin-left: 0 !important; } /* 修复多选框默认左缩进不对齐问题 */
+@keyframes blink { 50% { opacity: 0.7; } }
+
+.material-stem { background-color: #f8fafc; padding: 16px; border-radius: 6px; border: 1px dashed #cbd5e1; color: #334155; }
+.material-tag { color: #3b82f6; font-weight: bold; display: block; margin-bottom: 6px; }
+.sub-questions-container { margin-top: 20px; padding-left: 16px; border-left: 2px solid #e2e8f0; }
+.sub-question-item { margin-bottom: 20px; padding-bottom: 10px; }
+.sub-stem { font-size: 14px; color: #475569; }
+
+/* 🛠️ 新增:填空题专用精美样式 */
+.completion-body {
   display: flex;
   flex-direction: column;
-  gap: 16px;
-  background-color: transparent;
-}
-
-.aside-card {
-  background: #ffffff;
-  border-radius: 8px;
-  padding: 18px;
-  box-shadow: 0 2px 10px rgba(0,0,0,0.05);
-}
-
-.timer-box {
-  text-align: center;
-  background: #1e293b;
-  color: #ffffff;
-}
-.timer-title {
-  font-size: 13px;
-  color: #94a3b8;
-  margin-bottom: 6px;
-}
-.timer-display {
-  font-size: 26px;
-  font-family: monospace;
-  font-weight: bold;
-  color: #38bdf8;
+  gap: 12px;
+  max-width: 500px;
 }
-.time-danger {
-  color: #f43f5e !important;
-  animation: blink 1s infinite;
-}
-
-/* 答题卡矩阵 */
-.answer-card {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  overflow-y: auto;
-}
-.card-title {
-  margin: 0 0 14px 0;
-  color: #1e293b;
-  border-left: 3px solid #3b82f6;
-  padding-left: 8px;
-}
-.card-group {
-  margin-bottom: 16px;
-}
-.group-label {
-  font-size: 12px;
-  color: #64748b;
-  margin-bottom: 8px;
-}
-.number-grid {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 8px;
-}
-.number-item {
-  height: 36px;
+.completion-item {
   display: flex;
   align-items: center;
-  justify-content: center;
-  border: 1px solid #cbd5e1;
-  border-radius: 4px;
-  font-size: 13px;
-  color: #475569;
-  cursor: pointer;
-  transition: all 0.2s;
-  user-select: none;
-}
-.number-item:hover {
-  border-color: #3b82f6;
-  color: #3b82f6;
-}
-/* 已答、当前选中激活状态控制 */
-.number-item.is-answered {
-  background-color: #e2f5ec;
-  border-color: #10b981;
-  color: #10b981;
 }
-.number-item.is-active {
-  box-shadow: 0 0 0 2px #3b82f6;
-  font-weight: bold;
-}
-
-.btn-submit {
-  width: 100%;
-  margin-top: auto;
-  font-weight: bold;
-  height: 40px;
-}
-
-/* ==================== 📝 右侧试卷流面板 ==================== */
-.exam-main {
-  padding: 20px 40px 40px 20px;
-  height: 100%;
-  overflow-y: auto;
-}
-
-.paper-header {
-  background: #ffffff;
-  padding: 24px;
-  border-radius: 8px;
-  margin-bottom: 20px;
-  box-shadow: 0 2px 10px rgba(0,0,0,0.05);
-  text-align: center;
-}
-.paper-title {
-  margin: 0 0 12px 0;
-  color: #1e293b;
-}
-.paper-meta {
-  display: flex;
-  justify-content: center;
-  gap: 24px;
+.completion-prefix {
   font-size: 13px;
   color: #64748b;
-}
-
-.question-group-section {
-  margin-bottom: 24px;
-}
-.group-header-title {
-  font-size: 16px;
-  color: #334155;
-  margin-bottom: 12px;
-}
-.group-desc {
-  font-size: 13px;
-  color: #94a3b8;
-  font-weight: normal;
-}
-
-/* 核心题目卡片排版 */
-.question-card {
-  background: #ffffff;
-  border-radius: 8px;
-  padding: 24px;
-  margin-bottom: 16px;
-  box-shadow: 0 2px 8px rgba(0,0,0,0.03);
-  border-left: 4px solid transparent;
-  transition: all 0.2s;
-}
-.question-card:hover {
-  border-left-color: #3b82f6;
-  box-shadow: 0 4px 12px rgba(59, 130, 246, 0.08);
-}
-
-.question-stem {
-  font-size: 15px;
-  color: #1e293b;
-  font-weight: 500;
-  line-height: 1.6;
-  margin-bottom: 16px;
-}
-.q-number {
-  margin-right: 6px;
-  font-weight: bold;
-}
-
-.question-body {
-  padding-left: 18px;
-}
-
-/* 选项规整排列排版 */
-.option-item {
-  display: block;
-  margin-bottom: 14px !important;
-  font-size: 14px;
-  color: #334155;
-  white-space: normal;
-  line-height: 1.4;
-}
-.option-key {
-  font-weight: bold;
-  margin-right: 4px;
-}
-
-/* 呼吸灯特效 */
-@keyframes blink {
-  50% { opacity: 0.7; }
+  width: 80px;
+  flex-shrink: 0;
 }
 </style>

+ 2 - 1
src/views/exam/ExamPaperAssembly.vue

@@ -82,7 +82,7 @@
                 <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.type }}</el-tag>
+                    <el-tag size="mini" :type="q.typeCode === 6 ? 'warning' : 'success'">{{ q.typeStr }}</el-tag>
                   </td>
                   <td>
                     <div class="table-q-content">{{ q.content }}</div>
@@ -218,6 +218,7 @@ export default {
     }
   },
   created() {
+    document.title = '组卷控制台'
     this.getData()
   },
   methods: {

+ 0 - 22
src/views/exam/ExamPaperDetail.vue

@@ -1,22 +0,0 @@
-<template>
-  <el-container>
-    <span>ExamPaperDetail</span>
-  </el-container>
-</template>
-
-<script>
-export default {
-  name: 'ExamPaperDetail',
-  data() {
-    return {
-    }
-  },
-  created() {
-  },
-  methods: {
-  }
-}
-</script>
-
-<style>
-</style>

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

@@ -256,9 +256,7 @@ export default {
       const routeUrl = this.$router.resolve({
         path: '/exam/paper/detail',
         query: {
-          paperId: paperId,
-          viewType: 1,
-          userId: ''
+          paperId: paperId
         }
       })
       window.open(routeUrl.href, '_blank')

+ 316 - 0
src/views/exam/ExamPaperPreview.vue

@@ -0,0 +1,316 @@
+<template>
+  <el-container class="preview-container">
+    <el-aside width="280px" class="preview-aside">
+      <div class="aside-card paper-meta-box">
+        <h3 class="meta-title">{{ paperInfo.title }}</h3>
+        <div class="meta-item">科目:<span>{{ paperInfo.subjectName }}</span></div>
+        <div class="meta-item">满分:<span>{{ totalPaperScore }} 分</span></div>
+      </div>
+
+      <div class="aside-card navigation-card">
+        <h4 class="card-title">试卷大纲导航</h4>
+        <div v-for="(section, sIndex) in sectionsList" :key="sIndex" class="nav-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-active': currentActiveId === q.questionId }"
+              @click="scrollToQuestion(q.questionId)"
+            >
+              {{ q.number }}
+            </span>
+          </div>
+        </div>
+      </div>
+    </el-aside>
+
+    <el-main class="preview-main">
+      <div v-if="loading" class="loading-box">
+        <el-skeleton :rows="10" animated />
+      </div>
+
+      <div v-else>
+        <div v-for="(section, sIndex) in sectionsList" :key="sIndex" class="section-group">
+          <h3 class="section-header-title">
+            {{ section.sectionName }} <span class="section-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-badge">单选</span>
+                <span class="q-number">{{ q.number }}.</span>
+                <span class="q-text">{{ q.content }}({{ q.score }}分)</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-if="q.typeCode === 4">
+              <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>
+              </div>
+              <div class="question-body">
+                <div class="answer-analysis-box">
+                  <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>
+                  </div>
+                </div>
+              </div>
+            </template>
+
+            <template v-if="q.typeCode === 5">
+              <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>
+              </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>
+              </div>
+            </template>
+
+            <template v-if="q.typeCode === 6">
+              <div class="question-stem material-stem">
+                <span class="material-tag">【组合材料题】(全题共 {{ q.score }} 分)</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">
+
+                  <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>
+                    </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 === 4">
+                    <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>
+                    </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="fill-answer-item">
+                          第 <span class="fill-index">{{ ans.pos }}</span> 空:
+                          <span class="fill-text">{{ ans.content }}</span>
+                        </div>
+                      </div>
+                    </div>
+                  </template>
+
+                </div>
+              </div>
+            </template>
+
+          </div>
+        </div>
+      </div>
+    </el-main>
+  </el-container>
+</template>
+
+<script>
+import { getPaper } from '@/api/exam'
+
+export default {
+  name: 'ExamPaperPreview',
+  data() {
+    return {
+      loading: true,
+      queryParams: {
+        paperId: 0
+      },
+      currentActiveId: null,
+      paperInfo: {},
+      sectionsList: [],
+      totalPaperScore: 0
+    }
+  },
+  created() {
+    const paperId = this.$route.query.paperId
+    if (!paperId) {
+      this.$message.error('参数缺失,无法加载测评页面!')
+      return
+    }
+
+    this.queryParams.paperId = paperId
+    this.getData()
+  },
+  methods: {
+    getData() {
+      this.loading = true
+      getPaper(this.queryParams).then(resp => {
+        if (resp.code === 0) {
+          document.title = '试卷预览'
+          const remoteData = resp.data
+
+          this.paperInfo = {
+            paperId: remoteData.paperId,
+            title: remoteData.title,
+            subjectId: remoteData.subjectId,
+            subjectName: remoteData.subjectName || '通用科目',
+            duration: remoteData.duration
+          }
+
+          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) {
+                q.number = globalQuestionIndex++
+                flatQuestions.push({ questionId: q.questionId, number: q.number })
+              }
+              // 如果是复合/组合题,需要把子题的题号也平铺到大纲中
+              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 })
+                })
+              }
+              return q
+            })
+
+            return {
+              sectionId: sec.sectionId,
+              sectionName: sec.sectionName,
+              sectionHint: sec.sectionHint,
+              questions: processedQuestions,
+              flatQuestions: flatQuestions
+            }
+          })
+
+          this.totalPaperScore = calculatedTotalScore
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      }).catch(error => {
+        this.$message.error(error.message)
+      }).finally(() => {
+        this.loading = false
+      })
+    },
+
+    scrollToQuestion(qId) {
+      this.currentActiveId = qId
+      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
+          }
+        }
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.preview-container { height: 100vh; background-color: #f8fafc; }
+.preview-aside { padding: 20px; display: flex; flex-direction: column; gap: 16px; border-right: 1px solid #e2e8f0; }
+.aside-card { background: #ffffff; border-radius: 8px; padding: 18px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
+
+.paper-meta-box { background: #fff; border-top: 4px solid #3b82f6; }
+.meta-title { margin: 0 0 12px 0; font-size: 16px; color: #1e293b; }
+.meta-item { font-size: 13px; color: #64748b; margin-bottom: 6px; }
+.meta-item span { color: #1e293b; font-weight: bold; }
+
+.navigation-card { flex: 1; display: flex; flex-direction: column; overflow-y: auto; }
+.card-title { margin: 0 0 14px 0; color: #1e293b; border-left: 3px solid #3b82f6; padding-left: 8px; font-size: 14px; }
+.nav-group { margin-bottom: 16px; }
+.group-label { font-size: 12px; color: #64748b; margin-bottom: 8px; font-weight: bold; }
+.number-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
+.number-item { height: 32px; display: flex; align-items: center; justify-content: center; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 12px; color: #475569; cursor: pointer; transition: all 0.2s; }
+.number-item:hover, .number-item.is-active { border-color: #3b82f6; background-color: #eff6ff; color: #3b82f6; font-weight: bold; }
+
+.preview-main { padding: 20px 40px; height: 100%; overflow-y: auto; }
+.section-group { margin-bottom: 30px; }
+.section-header-title { font-size: 16px; color: #1e293b; margin-bottom: 14px; padding-bottom: 6px; border-bottom: 1px dashed #e2e8f0; }
+.section-desc { font-size: 13px; color: #94a3b8; font-weight: normal; }
+
+.question-card { background: #ffffff; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.02); border-left: 4px solid transparent; transition: all 0.2s; }
+.question-card:hover { border-left-color: #3b82f6; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
+
+.question-stem { font-size: 14px; color: #1e293b; font-weight: 500; line-height: 1.6; margin-bottom: 14px; display: flex; align-items: flex-start; }
+.q-badge { background: #edf2f7; color: #4a5568; font-size: 11px; padding: 2px 6px; border-radius: 4px; margin-right: 8px; margin-top: 2px; flex-shrink: 0; }
+.q-badge.type-fill { background: #eef2ff; color: #4f46e5; }
+.q-badge.type-qa { background: #fff7ed; color: #c2410c; }
+.btn-mini { padding: 1px 4px; font-size: 10px; }
+.q-number { margin-right: 4px; font-weight: bold; }
+.question-body { padding-left: 48px; }
+
+.option-preview-item { padding: 10px 14px; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px; font-size: 13px; color: #334155; display: flex; align-items: center; gap: 12px; }
+.is-correct-opt { background-color: #f0fdf4; border-color: #bbf7d0; color: #15803d; font-weight: bold; }
+.opt-tag { flex-shrink: 0; }
+
+.answer-analysis-box { background: #f8fafc; border-left: 3px solid #64748b; padding: 12px 16px; border-radius: 0 6px 6px 0; margin-top: 8px; }
+.analysis-title { font-size: 13px; font-weight: bold; color: #334155; margin-bottom: 6px; }
+.text-red { color: #ea580c; }
+.grey-text { color: #64748b; font-size: 13px; }
+.fill-answer-item { font-size: 13px; color: #334155; margin-bottom: 4px; }
+.fill-index { font-weight: bold; color: #3b82f6; }
+.fill-text { background: #e2e8f0; padding: 1px 8px; border-radius: 4px; margin-left: 6px; font-family: monospace; }
+
+.material-stem { background-color: #f1f5f9; padding: 16px; border-radius: 6px; border: 1px solid #cbd5e1; }
+.material-tag { color: #0f172a; font-weight: bold; display: block; margin-bottom: 8px; font-size: 13px; }
+.material-content { font-size: 13px; line-height: 1.7; color: #334155; white-space: pre-wrap; }
+.sub-questions-container { margin-top: 16px; padding-left: 16px; border-left: 2px solid #cbd5e1; }
+.sub-question-item { margin-bottom: 24px; padding-bottom: 12px; border-bottom: 1px dashed #f1f5f9; }
+.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; }
+</style>

+ 201 - 159
src/views/exam/ExamQuestionAdd.vue

@@ -1,18 +1,17 @@
 <template>
   <el-container class="exam-form-container">
     <el-main>
-      <el-page-header content="录入试题" @back="$emit('back')" />
+      <el-page-header content="录入试题" @back="handleBack" />
       <el-divider />
 
       <el-form ref="examForm" :model="form" :rules="rules" label-width="120px" size="medium">
-
         <el-row :gutter="20">
           <el-col :span="8">
             <el-form-item label="归属科目" prop="subjectId">
-              <el-select v-model="form.subjectId" placeholder="请选择科目" style="width: 100%">
+              <el-select v-model="form.subjectId" placeholder="请选择科目" style="width: 100%" filterable>
                 <el-option
                   v-for="item in allSubject"
-                  :key="item.label"
+                  :key="item.value"
                   :label="item.label"
                   :value="item.value"
                 />
@@ -21,21 +20,16 @@
           </el-col>
           <el-col :span="8">
             <el-form-item label="大题型" prop="questionType">
-              <el-select v-model="form.questionType" placeholder="请选择" @change="handleMainTypeChange">
+              <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="5" label="问答题" />
+                <el-option :value="6" label="组合题" />
               </el-select>
             </el-form-item>
           </el-col>
-          <el-col :span="8">
-            <el-form-item label="难度等级" prop="questionLevel">
-              <el-rate v-model="form.questionLevel" :max="3" :texts="['简单', '中等', '困难']" show-text style="margin-top: 8px;" />
-            </el-form-item>
-          </el-col>
         </el-row>
 
         <el-form-item :label="form.questionType === 6 ? '大题主干/阅读材料' : '试题题干'" prop="questionContent">
@@ -43,35 +37,46 @@
             v-model="form.questionContent"
             type="textarea"
             :rows="5"
-            placeholder="请输入题目内容。支持 LaTeX 公式,例如:已知函数 $f(x) = x^2 + 2x$..."
+            placeholder="请输入题目内容。支持 LaTeX 公式..."
             @input="lazyRenderFormula"
           />
         </el-form-item>
 
-        <el-form-item v-if="form.questionContent" label="题干公式预览">
-          <div class="formula-preview math-tex" v-html="form.questionContent" />
-        </el-form-item>
+        <el-collapse-transition>
+          <el-form-item v-if="form.questionContent" label="题干公式预览">
+            <div class="formula-preview math-tex" v-html="form.questionContent" />
+          </el-form-item>
+        </el-collapse-transition>
 
-        <div v-if="form.questionType >= 1 && form.questionType <= 4">
+        <div v-if="form.questionType >= 1 && form.questionType <= 4" class="option-config-box">
           <el-divider content-position="left">配置选项 / 填空参考答案</el-divider>
-          <div style="margin-bottom: 15px;">
+          <div class="action-bar">
             <el-button type="primary" icon="el-icon-plus" size="small" @click="addFormAnswer">添加选项/空格</el-button>
           </div>
 
-          <el-table :data="form.answer" border style="width: 100%">
+          <el-table :data="form.options" border style="width: 100%">
             <el-table-column label="是否正确答案" width="120" align="center">
               <template slot-scope="scope">
-                <el-checkbox v-model="scope.row.correct" @change="checked => handleCheckAnswer(checked, scope.row.id)" />
+                <el-checkbox
+                  v-model="scope.row.correct"
+                  :disabled="form.questionType === 4"
+                  @change="checked => handleCheckAnswer(checked, scope.row.id)"
+                />
               </template>
             </el-table-column>
-            <el-table-column label="标识 (如A/B/C/空1)" width="150">
+            <el-table-column label="位置顺序" width="120" align="center">
               <template slot-scope="scope">
-                <el-input v-model="scope.row.prefix" size="small" placeholder="A" />
+                <span>{{ scope.$index + 1 }}</span>
               </template>
             </el-table-column>
             <el-table-column label="选项内容 / 填空参考值">
               <template slot-scope="scope">
-                <el-input v-model="scope.row.answer" size="small" placeholder="支持公式" />
+                <el-input v-model="scope.row.options" size="small" placeholder="请输入内容,支持公式" />
+              </template>
+            </el-table-column>
+            <el-table-column label="答案解析(选填)">
+              <template slot-scope="scope">
+                <el-input v-model="scope.row.analysis" size="small" placeholder="此选项的单独解析" />
               </template>
             </el-table-column>
             <el-table-column label="操作" width="80" align="center">
@@ -84,8 +89,8 @@
 
         <div v-if="form.questionType === 6" class="composite-box">
           <el-divider content-position="left">配置大题下的子问 / 子题集</el-divider>
-          <div style="margin-bottom: 20px;">
-            <el-button type="success" icon="el-icon-circle-plus" @click="addSubQuestion">追加一个子问 (小题)</el-button>
+          <div class="action-bar">
+            <el-button type="success" icon="el-icon-circle-plus" size="small" @click="addSubQuestion">追加一个子问 (小题)</el-button>
           </div>
 
           <el-collapse v-model="activeCollapsePanels">
@@ -95,65 +100,71 @@
               :name="sub.localId"
             >
               <template slot="title">
-                <span class="sub-title-text">
-                  第 ({{ index + 1 }}) 问:【{{ getTypeName(sub.questionType) }}】 - 分值: {{ sub.score }}分
-                </span>
+                <div class="sub-title-container">
+                  <span class="sub-title-text">
+                    第 ({{ index + 1 }}) 问:【{{ getTypeName(sub.questionType) }}】
+                  </span>
+                  <el-button type="text" class="del-sub-btn" icon="el-icon-delete" @click.stop="delSubQuestion(index)">删除此问</el-button>
+                </div>
               </template>
 
               <div class="sub-question-card">
                 <el-row :gutter="20">
-                  <el-col :span="8">
-                    <el-form-item label="子题型">
-                      <el-select v-model="sub.questionType" size="small" style="width: 100%" @change="sub.answer = []">
+                  <el-col :span="12">
+                    <el-form-item label="子题型" label-width="70px">
+                      <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-select>
                     </el-form-item>
-
-                    <el-col :span="8">
-                      <el-col :span="8" style="text-align: right;">
-                        <el-button type="danger" size="mini" icon="el-icon-delete" @click="delSubQuestion(index)">删除此问</el-button>
-                      </el-col>
-                    </el-col>
                   </el-col>
                 </el-row>
 
-                <el-form-item label="子问具体题干">
+                <el-form-item label="子问具体题干" label-width="100px">
                   <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 style="margin-bottom: 10px;">
+                  <div 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.answer" border size="mini" style="width: 100%">
+                  <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" @change="checked => handleCheckSubAnswer(checked, scope.row.id, sub)" />
+                        <el-checkbox
+                          v-model="scope.row.correct"
+                          :disabled="sub.questionType === 4"
+                          @change="checked => handleCheckSubAnswer(checked, scope.row.id, sub)"
+                        />
+                      </template>
+                    </el-table-column>
+                    <el-table-column label="序号" width="70" align="center">
+                      <template slot-scope="scope">
+                        <span>{{ scope.$index + 1 }}</span>
                       </template>
                     </el-table-column>
-                    <el-table-column label="标识" width="80">
+                    <el-table-column label="选项文本/参考值">
                       <template slot-scope="scope">
-                        <el-input v-model="scope.row.prefix" size="mini" />
+                        <el-input v-model="scope.row.options" size="mini" />
                       </template>
                     </el-table-column>
-                    <el-table-column label="选项文本">
+                    <el-table-column label="独立解析">
                       <template slot-scope="scope">
-                        <el-input v-model="scope.row.answer" size="mini" />
+                        <el-input v-model="scope.row.analysis" size="mini" placeholder="选填" />
                       </template>
                     </el-table-column>
                     <el-table-column label="操作" width="60" align="center">
                       <template slot-scope="scope">
-                        <el-button type="text" style="color: #F56C6C" icon="el-icon-delete" @click="delSubAnswer(sub, scope.$index)" />
+                        <el-button type="text" class="danger-text" icon="el-icon-delete" @click="delSubAnswer(sub, scope.$index)" />
                       </template>
                     </el-table-column>
                   </el-table>
                 </div>
 
-                <el-form-item label="子问独立解析" style="margin-top: 10px;">
-                  <el-input v-model="sub.analysis" type="textarea" :rows="1" placeholder="选填" />
+                <el-form-item label="子问大意解析" label-width="100px" style="margin-top: 15px; margin-bottom: 0;">
+                  <el-input v-model="sub.analysis" type="textarea" :rows="2" placeholder="选填(解析此步骤的详细思路)" />
                 </el-form-item>
               </div>
             </el-collapse-item>
@@ -162,12 +173,12 @@
 
         <el-divider />
         <el-form-item :label="form.questionType === 6 ? '总题目解析' : '试题解析'" prop="analysis">
-          <el-input v-model="form.analysis" type="textarea" :rows="3" placeholder="请输入官方标准整题解析步骤..." />
+          <el-input v-model="form.analysis" type="textarea" :rows="4" placeholder="请输入官方标准整题解析步骤..." />
         </el-form-item>
 
-        <el-form-item style="margin-top: 30px;">
-          <el-button type="primary" size="large" @click="submitForm">将试题保存至题库</el-button>
-          <el-button size="large" @click="backToList">返回列表</el-button>
+        <el-form-item class="form-action-group">
+          <el-button type="primary" size="medium" @click="submitForm">将试题保存至题库</el-button>
+          <el-button size="medium" @click="backToList">返回列表</el-button>
         </el-form-item>
       </el-form>
     </el-main>
@@ -182,204 +193,235 @@ export default {
   data() {
     return {
       allSubject: [],
-      activeCollapsePanels: [], // 控制组合题子问卡片的展开折叠
+      activeCollapsePanels: [],
       form: {
         subjectId: '',
         questionType: 1,
-        questionLevel: 1,
         questionContent: '',
         analysis: '',
-        answer: [], // 存储原子题选项
-        children: [] // 存储复合题子小题
+        extra: '',
+        options: [],
+        children: []
       },
       rules: {
         subjectId: [{ required: true, message: '请选择所属科目', trigger: 'change' }],
         questionType: [{ required: true, message: '请选择试题类型', trigger: 'change' }],
         questionContent: [{ required: true, message: '请输入题干内容', trigger: 'blur' }]
       },
-      renderTimer: null
+      renderTimer: null,
+      isChanged: false
+    }
+  },
+  watch: {
+    form: {
+      handler() { this.isChanged = true },
+      deep: true
     }
   },
   created() {
     document.title = '录入试题'
     this.getSubjects()
   },
+  beforeDestroy() {
+    if (this.renderTimer) clearTimeout(this.renderTimer)
+  },
   methods: {
     getSubjects() {
       getSubjectKV().then((resp) => {
-        if (resp.code === 0) {
-          this.allSubject = resp.data
-        }
-      })
+        if (resp && resp.code === 0) this.allSubject = resp.data
+      }).catch(err => console.error(err))
     },
     getTypeName(type) {
       const names = { 1: '单选题', 2: '多选题', 4: '填空题', 5: '简答题' }
       return names[type] || '未知题型'
     },
     handleMainTypeChange() {
-      this.form.answer = []
+      this.form.options = []
       this.form.children = []
     },
+    handleSubQuestionTypeChange(sub) {
+      sub.options = []
+    },
     // --- 原子题答案行逻辑 ---
     addFormAnswer() {
-      this.form.answer.push({
-        id: 'ans_' + Date.now() + Math.random(),
-        correct: false,
-        prefix: String.fromCharCode(65 + this.form.answer.length), // 自动生成 A, B, C, D
-        answer: ''
+      // 判断当前是否为填空题 (4)
+      const isCompletion = this.form.questionType === 4
+
+      const nextPrefix = isCompletion
+        ? `空${this.form.options.length + 1}`
+        : String.fromCharCode(65 + (this.form.options.length % 26))
+
+      this.form.options.push({
+        id: `ans_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
+        correct: !!isCompletion, // 🟥 填空题直接默认为 true,选择题默认为 false
+        options: '',
+        analysis: ''
       })
     },
     delFormAnswer(id) {
-      this.form.answer = this.form.answer.filter(item => item.id !== id)
+      this.form.options = this.form.options.filter(item => item.id !== id)
     },
     handleCheckAnswer(checked, id) {
-      // 如果是单选或判断,限制只能勾选一个正确答案
       if (checked && (this.form.questionType === 1 || this.form.questionType === 3)) {
-        this.form.answer.forEach(item => { if (item.id !== id) item.correct = false })
+        this.form.options.forEach(item => { if (item.id !== id) item.correct = false })
       }
     },
     // --- 复合大题子问级逻辑 ---
     addSubQuestion() {
-      const localId = 'sub_' + Date.now() + Math.floor(Math.random() * 100)
+      const localId = `sub_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`
       this.form.children.push({
         localId: localId,
         questionType: 1,
-        score: 2,
         questionContent: '',
         analysis: '',
-        answer: [
-          { id: 'sub_opt_1', correct: false, prefix: 'A', answer: '' },
-          { id: 'sub_opt_2', correct: false, prefix: 'B', answer: '' }
+        options: [
+          { id: `s_opt_${localId}_1`, correct: false, options: '', analysis: '' },
+          { id: `s_opt_${localId}_2`, correct: false, options: '', analysis: '' }
         ]
       })
-      this.activeCollapsePanels.push(localId) // 默认展开新加的子问
+      this.activeCollapsePanels.push(localId)
     },
     delSubQuestion(index) {
       this.form.children.splice(index, 1)
     },
     addSubAnswer(sub) {
-      sub.answer.push({
-        id: 'opt_' + Date.now() + Math.random(),
-        correct: false,
-        prefix: String.fromCharCode(65 + sub.answer.length),
-        answer: ''
+      // 判断当前子题型是否为填空题 (4)
+      const isSubCompletion = sub.questionType === 4
+
+      const nextPrefix = isSubCompletion
+        ? `空${sub.options.length + 1}`
+        : String.fromCharCode(65 + (sub.options.length % 26))
+
+      sub.options.push({
+        id: `opt_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
+        correct: !!isSubCompletion, // 🟥 子填空题直接默认为 true
+        options: '',
+        analysis: ''
       })
     },
     delSubAnswer(sub, index) {
-      sub.answer.splice(index, 1)
+      sub.options.splice(index, 1)
     },
     handleCheckSubAnswer(checked, optId, sub) {
-      if (checked && sub.questionType === 1) { // 子题为单选时排他
-        sub.answer.forEach(opt => { if (opt.id !== optId) opt.correct = false })
+      if (checked && sub.questionType === 1) {
+        sub.options.forEach(opt => { if (opt.id !== optId) opt.correct = false })
       }
     },
-    // --- LaTeX 公式防抖渲染引擎 ---
     lazyRenderFormula() {
-      clearTimeout(this.renderTimer)
+      if (this.renderTimer) clearTimeout(this.renderTimer)
       this.renderTimer = setTimeout(() => {
         this.$nextTick(() => {
           if (window.MathJax && window.MathJax.typesetPromise) {
-            window.MathJax.typesetPromise([document.querySelectorAll('.formula-preview')]).catch(err => console.error(err))
+            window.MathJax.typesetPromise([document.querySelectorAll('.formula-preview')])
+              .catch(err => console.error(err))
           }
         })
-      }, 400)
+      }, 350)
+    },
+
+    // 🚀 【核心清洗转换逻辑】将前端模型组装转换为后端 QuestionDto 格式
+    formatPayload(formObj) {
+      const convertOptions = (opts) => {
+        return (opts || []).map((o, idx) => ({
+          index: idx + 1, // 转换为后端需要的 Integer index
+          content: o.options, // 关键对齐:前端 options -> 后端 content
+          analysis: o.analysis || '',
+          correct: !!o.correct
+        }))
+      }
+
+      const convertQuestion = (q) => {
+        return {
+          questionType: q.questionType,
+          subjectId: q.subjectId || null,
+          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 继承大题
+            subQ.subjectId = q.subjectId
+            return convertQuestion(subQ)
+          }) : []
+        }
+      }
+
+      return convertQuestion(formObj)
     },
-    // --- 严谨的高考业务层数据校验 ---
+
     submitForm() {
       this.$refs['examForm'].validate((valid) => {
         if (!valid) return
 
-        // 1. 普通选择题必须配置选项并勾选正确答案
-        if (this.form.questionType <= 2) {
-          if (this.form.answer.length === 0) return this.$message.error('常规题型请至少添加一个选项!')
-          if (!this.form.answer.some(a => a.correct)) return this.$message.error('请勾选设置至少一个正确答案!')
+        // 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)) {
+            return this.$message.error('当前选择类题型未勾选正确答案!')
+          }
         }
-
-        // 2. 复合题深层业务校验
         if (this.form.questionType === 6) {
-          if (this.form.children.length === 0) return this.$message.error('完型/阅读等多问大题下,请至少添加一个子问!')
-
+          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) return this.$message.error(`第 (${i + 1}) 问的子题干不能为空!`)
-            if (sub.questionType !== 5 && !sub.answer.some(a => a.correct)) {
-              return this.$message.error(`第 (${i + 1}) 问小题未设置正确选项,请先勾选正确选项!`)
+            if (!sub.questionContent.trim()) return this.$message.error(`第 (${i + 1}) 问的子题干不能为空!`)
+            if (sub.questionType !== 5 && !sub.options.some(a => a.correct)) {
+              return this.$message.error(`第 (${i + 1}) 问小题未设置正确答案!`)
             }
           }
         }
 
-        // 校验通过,向后端派发整张试题数据树 payload
-        addQuestion(this.form).then(resp => {
-          if (resp.code === 0) {
-            this.$message.info('试题已添加')
+        // 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('试题已成功保存至题库')
+            this.isChanged = false
             this.resetForm()
           } else {
-            this.$message.warning(resp.msg)
+            this.$message.warning(resp.msg || '保存失败')
           }
-        })
+        }).catch(err => console.error(err))
       })
     },
     resetForm() {
-      // 核心:利用 Element UI 自带的方法清除绑定的基本字段以及校验红字
-      if (this.$refs['examForm']) {
-        this.$refs['examForm'].resetFields()
-      }
-
-      // 特别注意:针对嵌套的复杂数组对象,必须手动重置为干净的初始状态
+      if (this.$refs['examForm']) this.$refs['examForm'].resetFields()
       this.form = {
-        subjectId: '',
-        questionType: 1, // 默认恢复到第一个“单选题”
-        questionLevel: 1, // 默认难度
-        questionContent: '',
-        analysis: '',
-        answer: [], // 清空普通题选项列表
-        children: [] // 清空复合题子问树
+        subjectId: '', questionType: 1, questionContent: '', analysis: '', extra: '', options: [], children: []
       }
-
-      // 清空手风琴组件的展开记录
       this.activeCollapsePanels = []
+      this.$nextTick(() => { this.isChanged = false })
     },
-    backToList() {
-      this.$router.push('/exam/question')
-    }
+    handleBack() {
+      if (this.isChanged) {
+        this.$confirm('数据尚未保存,确定要返回吗?', '提示', { type: 'warning' })
+          .then(() => this.$emit('back')).catch(() => {})
+      } else {
+        this.$emit('back')
+      }
+    },
+    backToList() { this.handleBack() }
   }
 }
 </script>
 
 <style scoped>
-.exam-form-container {
-  background: #fff;
-  border-radius: 8px;
-  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
-}
-.formula-preview {
-  padding: 10px 15px;
-  background-color: #f8f9fa;
-  border-left: 4px solid #409EFF;
-  border-radius: 4px;
-  min-height: 40px;
-  font-size: 15px;
-}
-.composite-box {
-  background-color: #fafafa;
-  padding: 20px;
-  border-radius: 6px;
-  border: 1px solid #ebdcdf;
-  margin-top: 20px;
-}
-.sub-title-text {
-  font-weight: bold;
-  color: #303133;
-}
-.sub-question-card {
-  padding: 15px;
-  background: #ffffff;
-  border: 1px solid #e4e7ed;
-  border-radius: 4px;
-}
-.sub-answer-zone {
-  margin-left: 40px;
-  margin-top: 10px;
-  background: #fdfdfd;
-}
+.exam-form-container { background: #ffffff; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
+.formula-preview { padding: 12px 16px; background-color: #f8f9fa; border-left: 4px solid #409EFF; border-radius: 4px; min-height: 44px; font-size: 14px; line-height: 1.5; color: #303133; }
+.option-config-box, .composite-box { margin-top: 10px; margin-bottom: 25px; }
+.action-bar, .action-bar-mini { margin-bottom: 15px; }
+.composite-box { background-color: #fcfcfc; padding: 20px; border-radius: 6px; border: 1px solid #e4e7ed; }
+.sub-title-container { display: flex; justify-content: space-between; align-items: center; width: 100%; padding-right: 15px; }
+.sub-title-text { font-weight: 600; color: #303133; }
+.del-sub-btn { color: #f56c6c; }
+.sub-question-card { padding: 20px; background: #ffffff; border: 1px solid #ebeef5; border-radius: 6px; }
+.sub-answer-zone { margin-left: 20px; margin-top: 15px; margin-bottom: 15px; background: #fafafa; padding: 10px; border-radius: 4px; }
+.form-action-group { margin-top: 40px; border-top: 1px solid #f1f1f1; padding-top: 20px; }
+.danger-text { color: #F56C6C; }
+::v-deep .el-table th { background-color: #f5f7fa !important; color: #606266; }
 </style>