Browse Source

还原 exam 模块

reghao 6 ngày trước cách đây
mục cha
commit
12eec008ce

+ 145 - 0
src/api/exam.js

@@ -0,0 +1,145 @@
+import { delete0, get, post } from '@/utils/request'
+
+const examApi = {
+  // *********************************************************************************************************************
+  // 科目接口
+  getSubject: '/api/content/exam/subject',
+  getSubjectKV: '/api/content/exam/subject/kv',
+  getSubjectCascade: '/api/content/exam/subject/cascade',
+  // *********************************************************************************************************************
+  // 试题接口
+  getQuestionType: '/api/content/exam/question/type',
+  getQuestion: '/api/content/exam/question',
+  addQuestion: '/api/content/exam/question',
+  // *********************************************************************************************************************
+  // 试卷接口
+  getPaper: '/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',
+  startExam: '/api/content/exam/eval/start',
+  cacheUserAnswer: '/api/content/exam/eval/cache',
+  submitExam: '/api/content/exam/eval/submit',
+  // *********************************************************************************************************************
+  // 阅卷接口
+  getExamMarkList: '/api/content/exam/mark/list',
+  getMarkView: '/api/content/exam/mark/view',
+  submitExamMark: '/api/content/exam/mark/submit'
+}
+
+// *********************************************************************************************************************
+// 科目接口
+export function getSubject() {
+  return get(examApi.getSubject)
+}
+
+export function getSubjectKV() {
+  return get(examApi.getSubjectKV)
+}
+
+export function getSubjectCascade() {
+  return get(examApi.getSubjectCascade)
+}
+
+// *********************************************************************************************************************
+// 试题接口
+export function getQuestionType() {
+  return get(examApi.getQuestionType)
+}
+
+export function getQuestions(param) {
+  return get(examApi.getQuestion, param)
+}
+
+export function deleteQuestion(questionId) {
+  return delete0(examApi.getQuestion + '/' + questionId)
+}
+
+export function getQuestion(questionId) {
+  return get(examApi.getQuestion + '/' + questionId)
+}
+
+export function addQuestion(data) {
+  return post(examApi.addQuestion, data)
+}
+
+// *********************************************************************************************************************
+// 试卷接口
+export function getPapers(param) {
+  return get(examApi.getPaper, param)
+}
+
+export function getPaper(paperId, viewType, userId) {
+  return get(examApi.getPaperDetail + '?paperId=' + paperId + '&viewType=' + viewType + '&userId=' + userId)
+}
+
+export function getPaperKeyValues() {
+  return get(examApi.getPaperKeyValue)
+}
+
+export function addPaper(data) {
+  return post(examApi.addPaper, data)
+}
+
+export function deletePaper(paperId) {
+  return delete0(examApi.addPaper + '/' + paperId)
+}
+
+// *********************************************************************************************************************
+// 考试接口
+export function getExamList() {
+  return get(examApi.examList)
+}
+
+export function getExamInfo(paperId) {
+  return get(examApi.startExam + '/' + paperId)
+}
+
+export function getPaperQuestions(paperId) {
+  return get('/api/content/exam/paper/' + paperId + '/question')
+}
+
+export function submitExam(data) {
+  return post(examApi.submitExam, data)
+}
+
+export function cacheUserAnswer(data) {
+  return post(examApi.cacheUserAnswer, data)
+}
+
+// *********************************************************************************************************************
+// 阅卷接口
+export function getMarkView(paperId, userId) {
+  return get(examApi.getMarkView + '?paperId=' + paperId + '&userId=' + userId)
+}
+
+export function getExamMarkList(params) {
+  return get(examApi.getExamMarkList, params)
+}
+
+export function submitExamMark(data) {
+  return post(examApi.submitExamMark, data)
+}
+
+// *********************************************************************************************************************
+// 考试结果接口
+export function getResultView(paperId) {
+  return get('/api/content/exam/result/view/' + paperId)
+}
+
+// *********************************************************************************************************************
+// 考试数据接口
+export function getExamCount() {
+  return get('/api/content/exam/statistic/count')
+}
+
+export function getExamPassRate() {
+  return get('/api/content/exam/statistic/rate')
+}
+
+export function getLineChartData() {
+  return get('/api/content/exam/statistic/linechart')
+}

+ 97 - 0
src/router/exam.js

@@ -0,0 +1,97 @@
+// ********************************************************************************************************************
+// 后台主页
+// ********************************************************************************************************************
+const Exam = () => import('views/exam/Exam')
+const ExamDashboard = () => import('views/exam/ExamDashboard')
+const ExamSubject = () => import('views/exam/ExamSubject')
+const ExamQuestion = () => import('views/exam/ExamQuestion')
+const ExamQuestionAdd = () => import('views/exam/ExamQuestionAdd')
+const ExamPaperList = () => import('views/exam/ExamPaperList')
+const ExamPaperAdd = () => import('views/exam/ExamPaperAdd')
+const ExamPaperAssembly = () => import('views/exam/ExamPaperAssembly')
+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')
+
+export default {
+  path: '/exam',
+  name: 'Exam',
+  component: Exam,
+  meta: { needAuth: true },
+  children: [
+    {
+      path: '',
+      name: 'ExamDashboard',
+      component: ExamDashboard,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/start/:paperId',
+      name: 'ExamCard',
+      component: ExamCard,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/score',
+      name: 'ExamScoreIndex',
+      component: ExamScoreIndex,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/mark',
+      name: 'ExamMarkIndex',
+      component: ExamMarkIndex,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/subject',
+      name: 'ExamSubject',
+      component: ExamSubject,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/question',
+      name: 'ExamQuestion',
+      component: ExamQuestion,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/question/add',
+      name: 'ExamQuestionAdd',
+      component: ExamQuestionAdd,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/paper',
+      name: 'ExamPaperList',
+      component: ExamPaperList,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/paper/add',
+      name: 'ExamPaperAdd',
+      component: ExamPaperAssembly,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/eval',
+      name: 'ExamEvalList',
+      component: ExamEvalList,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/statistics',
+      name: 'ExamDashboard',
+      component: ExamDashboard,
+      meta: { needAuth: true }
+    },
+    {
+      path: '/exam/paper/detail',
+      name: 'ExamPaperDetail',
+      component: ExamPaperDetail,
+      meta: { needAuth: true }
+    }
+  ]
+}

+ 10 - 0
src/router/index.js

@@ -1,6 +1,7 @@
 import VueRouter from 'vue-router'
 import Vue from 'vue'
 
+import ExamRouter from './exam'
 import DiskRouter from './disk'
 import DiskMobileRouter from './diskm'
 import ChatRouter from './chat'
@@ -35,6 +36,8 @@ const AMap = () => import('views/map/AMap')
 const OpenLayersMap = () => import('views/map/OpenLayersMap')
 const ChartMap = () => import('views/map/ChartMap')
 
+const ExamPaper = () => import('views/exam/ExamPaper')
+
 const VodAudit = () => import('views/admin/aaa/VideoAudit')
 
 const Index = () => import('views/Index')
@@ -48,6 +51,7 @@ const Dashboard = () => import('views/admin/Dashboard')
 // 使用安装路由插件
 Vue.use(VueRouter)
 export const constantRoutes = [
+  ExamRouter,
   DiskMobileRouter,
   ChatRouter,
   UserRouter,
@@ -153,6 +157,12 @@ export const constantRoutes = [
     component: ChartMap,
     meta: { title: 'ChartMap', needAuth: false }
   },
+  {
+    path: '/exams',
+    name: 'ExamPaper',
+    component: ExamPaper,
+    meta: { title: 'ExamPaper', needAuth: false }
+  },
   {
     path: '/bg',
     name: 'BackgroundIndex',

+ 1 - 0
src/views/admin/Dashboard.vue

@@ -24,6 +24,7 @@
           <div class="btn-group">
             <el-button icon="el-icon-files" size="small" class="custom-btn" @click="navTo('/disk')">Disk</el-button>
             <el-button icon="el-icon-chat-dot-square" size="small" class="custom-btn" @click="navTo('/chat')">Chat</el-button>
+            <el-button icon="el-icon-edit" size="small" class="custom-btn" @click="navTo('/exam')">Exam</el-button>
             <el-button icon="el-icon-film" size="small" class="custom-btn" @click="navTo('/')">VOD</el-button>
             <el-button icon="el-icon-document" size="small" class="custom-btn" @click="navTo('/blog')">Blog</el-button>
           </div>

+ 118 - 0
src/views/exam/Exam.vue

@@ -0,0 +1,118 @@
+<template>
+  <el-container style="height: 720px; border: 1px solid #eee">
+    <el-header style="text-align: right; font-size: 12px">
+      <el-col :md="2">
+        <ul class="el-menu--horizontal el-menu">
+          <li class="el-menu-item">
+            <a href="/exam" style="text-decoration-line: none">
+              <img src="@/assets/img/logo.png" class="el-avatar--circle el-avatar--medium" alt="img">
+              exam
+            </a>
+          </li>
+        </ul>
+      </el-col>
+      <el-col :md="20">
+        <el-menu
+          :default-active="this.$route.path"
+          router
+          mode="horizontal"
+          :unique-opened="true"
+        >
+          <el-submenu index="/exam/admin">
+            <template slot="title">
+              <i class="el-icon-user" />
+              <span slot="title">考试管理</span>
+            </template>
+            <el-menu-item-group>
+              <el-menu-item index="/exam/subject">
+                <i class="el-icon-film" />
+                <span slot="title">科目管理</span>
+              </el-menu-item>
+              <el-menu-item index="/exam/question">
+                <i class="el-icon-film" />
+                <span slot="title">试题管理</span>
+              </el-menu-item>
+              <el-menu-item index="/exam/paper">
+                <i class="el-icon-film" />
+                <span slot="title">试卷管理</span>
+              </el-menu-item>
+              <el-menu-item index="/exam/mark">
+                <i class="el-icon-film" />
+                <span slot="title">阅卷管理</span>
+              </el-menu-item>
+              <el-menu-item index="/exam/statistics">
+                <i class="el-icon-film" />
+                <span slot="title">成绩统计</span>
+              </el-menu-item>
+            </el-menu-item-group>
+          </el-submenu>
+          <el-submenu index="/exam/user">
+            <template slot="title">
+              <i class="el-icon-user" />
+              <span slot="title">我的考试</span>
+            </template>
+            <el-menu-item-group>
+              <el-menu-item index="/exam/eval">
+                <i class="el-icon-film" />
+                <span slot="title">考试列表</span>
+              </el-menu-item>
+              <el-menu-item index="/exam/score">
+                <i class="el-icon-film" />
+                <span slot="title">我的成绩</span>
+              </el-menu-item>
+            </el-menu-item-group>
+          </el-submenu>
+        </el-menu>
+      </el-col>
+      <el-col :md="2">
+        <el-dropdown>
+          <img
+            :src="user.avatarUrl"
+            class="el-avatar--circle el-avatar--medium"
+            style="margin-right: 10px; margin-top: 15px"
+            alt=""
+          >
+          <el-dropdown-menu slot="dropdown">
+            <el-dropdown-item
+              icon="el-icon-s-platform"
+              class="size"
+              @click.native="goToHome"
+            >主站</el-dropdown-item>
+            <el-dropdown-item
+              icon="el-icon-error"
+              class="size"
+              @click.native="goToLogout"
+            >退出</el-dropdown-item>
+          </el-dropdown-menu>
+        </el-dropdown>
+      </el-col>
+    </el-header>
+    <el-container>
+      <el-main>
+        <router-view />
+      </el-main>
+    </el-container>
+  </el-container>
+</template>
+
+<script>
+import { userMixin } from 'assets/js/mixin'
+import { getAuthedUser } from '@/utils/auth'
+
+export default {
+  name: 'Exam',
+  mixins: [userMixin],
+  data() {
+    return {
+      user: null
+    }
+  },
+  created() {
+    document.title = '考试系统'
+    this.user = getAuthedUser()
+  }
+}
+</script>
+
+<style>
+</style>

+ 815 - 0
src/views/exam/ExamCard.vue

@@ -0,0 +1,815 @@
+<template>
+  <el-container v-if="show">
+    <el-header style="margin-top: 60px">
+      <el-row>
+        <el-col :span="18" :offset="3" style="border-bottom: 1px solid #f5f5f5">
+          <span class="startExam">开始考试</span>
+          <span class="examTitle">距离考试结束还有:</span>
+          <span style="color: red;font-size: 18px;">{{ duration | timeFormat }}</span>
+          <el-button
+            type="warning"
+            round
+            style="background-color: #ffd550;float: right;color: black;font-weight: 800"
+            @click="uploadExamToAdmin"
+          >提交试卷
+          </el-button>
+        </el-col>
+      </el-row>
+    </el-header>
+
+    <el-main>
+      <el-row>
+        <el-col :span="18" :offset="3">
+          <el-col :span="16">
+            <el-card style="min-height: 500px">
+              <!-- 题目信息 -->
+              <div>
+                <span v-if="questionList[curIndex].questionType === 1">【单选题】</span>
+                <span v-else-if="questionList[curIndex].questionType === 2">【多选题】</span>
+                <span v-else-if="questionList[curIndex].questionType === 3">【不定项选择题】</span>
+                <span v-else-if="questionList[curIndex].questionType === 4">【判断题】</span>
+                <span v-else-if="questionList[curIndex].questionType === 5">【填空题】</span>
+                <span v-else-if="questionList[curIndex].questionType === 6">【问答题】</span>
+                <span v-else-if="questionList[curIndex].questionType === 7">【理解题】</span>
+                <span v-else>【综合题】</span>
+                <br>
+                <br>
+                <i class="num">{{ curIndex + 1 }}</i>
+                <span v-html="questionList[curIndex].questionContent" />
+              </div>
+              <!--题目中的配图-->
+              <img
+                v-for="url in questionList[curIndex].images"
+                :src="url"
+                title="点击查看大图"
+                alt="题目图片"
+                style="width: 100px;height: 100px;cursor: pointer"
+                @click="showBigImg(url)"
+              >
+
+              <!-- 单选和判断题候选答案列表 -->
+              <div
+                v-show="questionList[curIndex].questionType === 1
+                  || questionList[curIndex].questionType === 4"
+                style="margin-top: 25px"
+              >
+                <div class="el-radio-group">
+                  <label
+                    v-for="(item,index) in questionList[curIndex].answer"
+                    :class="index === userAnswer[curIndex] ? 'active' : ''"
+                    @click="checkSingleAnswer(index)"
+                  >
+                    <span>{{ optionName[index] + '、' + item.answer }}</span>
+                    <img
+                      v-for="i2 in item.images"
+                      v-if="item.images !== null"
+                      style="position: absolute;left:100%;top:50%;transform: translateY(-50%);width: 40px;height: 40px;float: right;cursor: pointer;"
+                      title="点击查看大图"
+                      :src="i2"
+                      alt=""
+                      @mouseover="showBigImg(i2)"
+                    >
+                  </label>
+                </div>
+              </div>
+
+              <!-- 多选和不定项选择题的候选答案列表 -->
+              <div
+                v-show="questionList[curIndex].questionType === 2
+                  || questionList[curIndex].questionType === 3"
+                style="margin-top: 25px"
+              >
+                <div class="el-radio-group">
+                  <label
+                    v-for="(item,index) in questionList[curIndex].answer"
+                    :class="(userAnswer[curIndex]+'').indexOf(index+'') !== -1? 'active' : ''"
+                    @click="selectedMultipleAnswer(index)"
+                  >
+                    <span>{{ optionName[index] + '、' + item.answer }}</span>
+                    <img
+                      v-for="i2 in item.images"
+                      v-if="item.images !== null"
+                      style="position: absolute;left:100%;top:50%;transform: translateY(-50%);
+                  width: 40px;height: 40px;float: right;cursor: pointer;"
+                      title="点击查看大图"
+                      :src="i2"
+                      alt=""
+                      @mouseover="showBigImg(i2)"
+                    >
+                  </label>
+                </div>
+              </div>
+
+              <!-- 填空题和问答题的回答区 -->
+              <div
+                v-show="questionList[curIndex].questionType === 5
+                  || questionList[curIndex].questionType === 6"
+                style="margin-top: 25px"
+              >
+                <el-input
+                  v-model="userAnswer[curIndex]"
+                  type="textarea"
+                  :rows="8"
+                  placeholder="请输入答案"
+                />
+              </div>
+
+              <!-- 综合题的回答区 -->
+              <div
+                v-show="questionList[curIndex].questionType === 8"
+                style="margin-top: 25px"
+              >
+                <el-input
+                  v-model="userAnswer[curIndex]"
+                  type="textarea"
+                  :rows="8"
+                  placeholder="请输入答案"
+                />
+              </div>
+
+              <!-- 上一题和下一题按钮 -->
+              <div style="margin-top: 25px">
+                <el-button type="primary" icon="el-icon-back" :disabled="curIndex<1" @click="curIndex--">上一题</el-button>
+                <el-button
+                  type="primary"
+                  icon="el-icon-right"
+                  :disabled="curIndex>=questionList.length-1"
+                  @click="curIndex++"
+                >下一题
+                </el-button>
+              </div>
+            </el-card>
+          </el-col>
+          <!-- 答题卡 -->
+          <el-col :span="7" :offset="1">
+            <el-card>
+              <div>
+                <p style="font-size: 18px;">答题卡</p>
+                <div style="margin-top: 25px">
+                  <span
+                    style="background-color: rgb(238,238,238);padding: 5px 10px 5px 10px;margin-left: 15px"
+                  >未作答</span>
+                  <span
+                    style="background-color: rgb(87,148,247);color: white;padding: 5px 10px 5px 10px;margin-left: 15px"
+                  >已作答</span>
+                </div>
+              </div>
+
+              <!-- 单选的答题卡 -->
+              <div style="margin-top: 25px">
+                <p style="font-size: 18px;">单选题</p>
+                <el-button
+                  v-for="item in questionList.length"
+                  v-show="questionList[item-1].questionType === 1"
+                  :key="item"
+                  style="margin-top: 10px;margin-left: 15px"
+                  size="mini"
+                  :class="questionList[item-1].questionType === 1 && userAnswer[item-1] !== undefined ?
+                    'done' : userAnswer[item-1] === undefined ? curIndex === (item-1) ? 'orange' : 'noAnswer' : 'noAnswer'"
+                  @click="curIndex = item-1"
+                >{{ item }}
+                </el-button>
+              </div>
+
+              <!-- 多选的答题卡 -->
+              <div style="margin-top: 25px">
+                <p style="font-size: 18px;">多选题</p>
+                <el-button
+                  v-for="item in questionList.length"
+                  v-show="questionList[item-1].questionType === 2"
+                  :key="item"
+                  style="margin-top: 10px;margin-left: 15px"
+                  size="mini"
+                  :class="questionList[item-1].questionType === 2 && userAnswer[item-1] !== undefined ?
+                    'done' : userAnswer[item-1] === undefined ? curIndex === (item-1) ? 'orange' : 'noAnswer' : 'noAnswer'"
+                  @click="curIndex = item-1"
+                >{{ item }}
+                </el-button>
+              </div>
+              <!-- 不定项选择题的答题卡 -->
+              <div style="margin-top: 25px">
+                <p style="font-size: 18px;">不定项选择题</p>
+                <el-button
+                  v-for="item in questionList.length"
+                  v-show="questionList[item-1].questionType === 3"
+                  :key="item"
+                  style="margin-top: 10px;margin-left: 15px"
+                  size="mini"
+                  :class="questionList[item-1].questionType === 3 && userAnswer[item-1] !== undefined ?
+                    'done' : userAnswer[item-1] === undefined ? curIndex === (item-1) ? 'orange' : 'noAnswer' : 'noAnswer'"
+                  @click="curIndex = item-1"
+                >{{ item }}
+                </el-button>
+              </div>
+
+              <!-- 判断题的答题卡 -->
+              <div style="margin-top: 25px">
+                <p style="font-size: 18px;">判断题</p>
+                <el-button
+                  v-for="item in questionList.length"
+                  v-show="questionList[item-1].questionType === 4"
+                  :key="item"
+                  style="margin-top: 10px;margin-left: 15px"
+                  size="mini"
+                  :class="questionList[item-1].questionType === 4 && userAnswer[item-1] !== undefined ?
+                    'done' : userAnswer[item-1] === undefined ? curIndex === (item-1) ? 'orange' : 'noAnswer' : 'noAnswer'"
+                  @click="curIndex = item-1"
+                >{{ item }}
+                </el-button>
+              </div>
+
+              <!-- 填空题的答题卡 -->
+              <div style="margin-top: 25px">
+                <p style="font-size: 18px;">填空题</p>
+                <el-button
+                  v-for="item in questionList.length"
+                  v-show="questionList[item-1].questionType === 5"
+                  :key="item"
+                  style="margin-top: 10px;margin-left: 15px"
+                  size="mini"
+                  :class="questionList[item-1].questionType === 5 && userAnswer[item-1] !== undefined ?
+                    'done' : userAnswer[item-1] === undefined ? curIndex === (item-1) ? 'orange' : 'noAnswer' : 'noAnswer'"
+                  @click="curIndex = item-1"
+                >{{ item }}
+                </el-button>
+              </div>
+
+              <!-- 问答题的答题卡 -->
+              <div style="margin-top: 25px">
+                <p style="font-size: 18px;">问答题</p>
+                <el-button
+                  v-for="item in questionList.length"
+                  v-show="questionList[item-1].questionType === 6"
+                  :key="item"
+                  style="margin-top: 10px;margin-left: 15px"
+                  size="mini"
+                  :class="questionList[item-1].questionType === 6 && userAnswer[item-1] !== undefined ?
+                    'done' : userAnswer[item-1] === undefined ? curIndex === (item-1) ? 'orange' : 'noAnswer' : 'noAnswer'"
+                  @click="curIndex = item-1"
+                >{{ item }}
+                </el-button>
+              </div>
+
+              <!-- 综合题的答题卡 -->
+              <div style="margin-top: 25px">
+                <p style="font-size: 18px;">综合题</p>
+                <el-button
+                  v-for="item in questionList.length"
+                  v-show="questionList[item-1].questionType === 8"
+                  :key="item"
+                  style="margin-top: 10px;margin-left: 15px"
+                  size="mini"
+                  :class="questionList[item-1].questionType === 8 && userAnswer[item-1] !== undefined ?
+                    'done' : userAnswer[item-1] === undefined ? curIndex === (item-1) ? 'orange' : 'noAnswer' : 'noAnswer'"
+                  @click="curIndex = item-1"
+                >{{ item }}
+                </el-button>
+              </div>
+            </el-card>
+          </el-col>
+        </el-col>
+      </el-row>
+      <video
+        id="video"
+        muted="muted"
+        style="float:right;position: fixed;top: 80%;left: 85%"
+        width="200px"
+        height="200px"
+        autoplay="autoplay"
+      />
+      <canvas id="canvas" hidden width="200px" height="200px" />
+    </el-main>
+    <!--图片回显-->
+    <el-dialog :visible.sync="bigImgDialog" @close="bigImgDialog = false">
+      <img style="width: 100%" :src="bigImgUrl">
+    </el-dialog>
+  </el-container>
+</template>
+
+<script>
+import { submitExam, getExamInfo, getPaperQuestions } from '@/api/exam'
+
+export default {
+  name: 'ExamCard',
+  filters: {
+    ellipsis(value) {
+      if (!value) return ''
+      const max = 50
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    },
+    timeFormat(time) {
+      // 分钟
+      var minute = time / 60
+      var minutes = parseInt(minute)
+
+      if (minutes < 10) {
+        minutes = '0' + minutes
+      }
+
+      // 秒
+      var second = time % 60
+      var seconds = Math.round(second)
+      if (seconds < 10) {
+        seconds = '0' + seconds
+      }
+      return `${minutes}:${seconds}`
+    }
+  },
+  data() {
+    return {
+      // 当前考试的信息
+      examInfo: {},
+      // 当前的考试题目
+      questionList: [
+        {
+          questionType: ''
+        }
+      ],
+      // 当前题目的索引值
+      curIndex: 0,
+      // 控制大图的对话框
+      bigImgDialog: false,
+      // 当前要展示的大图的url
+      bigImgUrl: '',
+      // 用户选择的答案
+      userAnswer: [],
+      // 页面数据加载
+      loading: {},
+      // 页面绘制是否开始
+      show: false,
+      // 答案的选项名abcd数据
+      optionName: ['A', 'B', 'C', 'D', 'E', 'F', 'G'],
+      // 考试总时长
+      duration: 0,
+      // 摄像头对象
+      mediaStreamTrack: null,
+      // 诚信照片的url
+      takePhotoUrl: [],
+      // 摄像头是否开启
+      cameraOn: false
+    }
+  },
+  watch: {
+    // 监控考试的剩余时间
+    async duration(newVal) {
+      const examDuration = {
+        duration: newVal,
+        timestamp: Date.now()
+      }
+      localStorage.setItem('examDuration' + this.examInfo.examId, JSON.stringify(examDuration))
+
+      // 摄像头数据
+      const constraints = {
+        video: {
+          width: 200,
+          height: 200
+        },
+        audio: false
+      }
+      // 通过调用摄像头判断用户是否中途关闭摄像头
+      /* const promise = navigator.mediaDevices.getUserMedia(constraints)
+      promise.catch((back) => {
+        this.cameraOn = false
+      })*/
+      if (!this.cameraOn) { // 如果摄像头未开启,就再次调用开启
+        this.getCamera()
+      }
+
+      // 考试时间结束, 自动提交试卷
+      if (newVal < 1) {
+        /* if (this.cameraOn) {
+          // 结束的时候拍照上传一张
+          await this.takePhoto()
+          this.closeCamera()
+        }*/
+
+        this.submitUserPayload(true)
+      }
+    }
+  },
+  created() {
+    this.getExamInfo()
+    // 页面数据加载的等待状态栏
+    this.loading = this.$loading({
+      body: true,
+      lock: true,
+      text: '数据拼命加载中,(*╹▽╹*)',
+      spinner: 'el-icon-loading'
+    })
+    // 开启摄像头
+    window.onload = () => {
+      setTimeout(() => {
+        this.getCamera()
+      }, 1000)
+
+      // 生成3次时间点截图
+      const times = []
+      for (let i = 0; i < 2; i++) {
+        times.push(Math.ceil(Math.random() * this.duration * 1000))
+      }
+      times.push(10000)
+      // 一次考试最多3次随机的诚信截图
+      times.forEach(item => {
+        window.setTimeout(() => {
+          this.takePhoto()
+        }, item)
+      })
+    }
+  },
+  mounted() {
+    // 关闭浏览器窗口的时候移除 localstorage的时长
+    var userAgent = navigator.userAgent // 取得浏览器的userAgent字符串
+    var isOpera = userAgent.indexOf('Opera') > -1 // 判断是否Opera浏览器
+    var isIE = userAgent.indexOf('compatible') > -1 && userAgent.indexOf('MSIE') > -1 && !isOpera // 判断是否IE浏览器
+    var isIE11 = userAgent.indexOf('rv:11.0') > -1 // 判断是否是IE11浏览器
+    var isEdge = userAgent.indexOf('Edge') > -1 && !isIE // 判断是否IE的Edge浏览器
+    if (!isIE && !isEdge && !isIE11) { // 兼容chrome和firefox
+      var _beforeUnload_time = 0; var _gap_time = 0
+      var is_fireFox = navigator.userAgent.indexOf('Firefox') > -1// 是否是火狐浏览器
+      window.onunload = function() {
+        _gap_time = new Date().getTime() - _beforeUnload_time
+        if (_gap_time <= 5) {
+          localStorage.removeItem('examDuration' + this.examInfo.examId)
+        } else { // 谷歌浏览器刷新
+        }
+      }
+      window.onbeforeunload = function() {
+        _beforeUnload_time = new Date().getTime()
+        if (is_fireFox) { // 火狐关闭执行
+
+        } else { // 火狐浏览器刷新
+        }
+      }
+    }
+  },
+  methods: {
+    renderByMathjax() {
+      // tinymce 的 mathjax 插件生成的 latex 格式公式放在 className 为 math-tex 的 span 标签内
+      const className = 'math-tex'
+      this.$nextTick(function() {
+        if (this.MathJax.isMathjaxConfig) {
+          this.MathJax.initMathjaxConfig()
+        }
+        this.MathJax.MathQueue1(className)
+      })
+    },
+    // 查询当前考试的信息
+    getExamInfo() {
+      const paperId = this.$route.params.paperId
+      getExamInfo(paperId).then((resp) => {
+        if (resp.code === 0) {
+          this.examInfo = resp.data
+          // 设置定时(秒)
+          try {
+            const examDuration = JSON.parse(localStorage.getItem('examDuration' + this.examInfo.examId) || '{}')
+            if (examDuration.duration === 0 || Date.now() >= (examDuration.timestamp ||
+              Date.now()) + (examDuration.duration * 1000 || Date.now())) {
+              localStorage.removeItem('examDuration' + this.examInfo.examId)
+            }
+            this.duration = Math.min(JSON.parse(
+              localStorage.getItem('examDuration' + this.examInfo.examId) || '{}').duration ||
+              this.examInfo.examDuration * 60, this.examInfo.examDuration * 60)
+          } catch (e) {
+            localStorage.removeItem('examDuration' + this.examInfo.examId)
+          }
+
+          // 考试剩余时间定时器
+          this.timer = window.setInterval(() => {
+            if (this.duration > 0) this.duration--
+          }, 1000)
+          var paperId = this.examInfo.examId
+          this.getQuestionInfo1(paperId)
+        }
+      })
+    },
+    // 查询考试的题目信息
+    async getQuestionInfo1(paperId) {
+      await getPaperQuestions(paperId).then(resp => {
+        if (resp.code === 0) {
+          this.questionList = resp.data || []
+          // 重置问题的顺序 单选 多选 判断 简答
+          this.questionList = this.questionList.sort(function(a, b) {
+            return a.questionType - b.questionType
+          })
+
+          this.renderByMathjax()
+        }
+      })
+      this.loading.close()
+      this.show = true
+    },
+    // 点击展示高清大图
+    showBigImg(url) {
+      this.bigImgUrl = url
+      this.bigImgDialog = true
+    },
+    // ****************************************************************************************************************
+    // 检验单选题的用户选择的答案
+    checkSingleAnswer(index) {
+      this.$set(this.userAnswer, this.curIndex, index)
+    },
+    // 多选题用户的答案选中
+    selectedMultipleAnswer(index) {
+      if (this.userAnswer[this.curIndex] === undefined) { // 当前是多选的第一个答案
+        this.$set(this.userAnswer, this.curIndex, index)
+      } else if (String(this.userAnswer[this.curIndex]).split(',').includes(index + '')) { // 取消选中
+        const newArr = []
+        String(this.userAnswer[this.curIndex]).split(',').forEach(item => {
+          if (item !== '' + index) newArr.push(item)
+        })
+        if (newArr.length === 0) {
+          this.$set(this.userAnswer, this.curIndex, undefined)
+        } else {
+          this.$set(this.userAnswer, this.curIndex, newArr.join(','))
+          // 答案格式化顺序DBAC -> ABCD
+          this.userAnswer[this.curIndex] = String(this.userAnswer[this.curIndex]).split(',').sort(function(a, b) {
+            return a - b
+          }).join(',')
+        }
+      } else if (!((this.userAnswer[this.curIndex] + '').split(',').includes(index + ''))) { // 第n个答案
+        this.$set(this.userAnswer, this.curIndex, this.userAnswer[this.curIndex] += ',' + index)
+        // 答案格式化顺序DBAC -> ABCD
+        this.userAnswer[this.curIndex] = String(this.userAnswer[this.curIndex]).split(',').sort(function(a, b) {
+          return a - b
+        }).join(',')
+      }
+    },
+    // ****************************************************************************************************************
+    // 调用摄像头
+    getCamera() {
+      const constraints = {
+        video: {
+          width: 200,
+          height: 200
+        },
+        audio: false
+      }
+      // 获得video摄像头
+      const video = document.getElementById('video')
+      /* const promise = navigator.mediaDevices.getUserMedia(constraints)
+      promise.then((mediaStream) => {
+        this.mediaStreamTrack = typeof mediaStream.stop === 'function' ? mediaStream : mediaStream.getTracks()[1]
+        video.srcObject = mediaStream
+        video.play()
+        this.cameraOn = true
+      }).catch((back) => {
+        this.$message({
+          duration: 1500,
+          message: '请开启摄像头权限o(╥﹏╥)o!',
+          type: 'error'
+        })
+      })*/
+    },
+    // 拍照
+    async takePhoto() {
+      if (this.cameraOn) { // 摄像头是否开启 开启了才执行上传信用图片
+        // 获得Canvas对象
+        const video = document.getElementById('video')
+        const canvas = document.getElementById('canvas')
+        const ctx = canvas.getContext('2d')
+        ctx.drawImage(video, 0, 0, 200, 200)
+        // toDataURL  ---  可传入'image/png'---默认, 'image/jpeg'
+        const img = document.getElementById('canvas').toDataURL()
+
+        // 构造post的form表单
+        const formData = new FormData()
+        // convertBase64UrlToBlob函数是将base64编码转换为Blob
+        formData.append('file', this.base64ToFile(img, 'examTakePhoto.png'))
+        // 上传阿里云OSS
+        /* await ossUtils.uploadImage(formData).then((resp) => {
+          if (resp.code === 0) this.takePhotoUrl.push(resp.data)
+        })*/
+      }
+    },
+    // 关闭摄像头
+    closeCamera() {
+      const stream = document.getElementById('video').srcObject
+      const tracks = stream.getTracks()
+      tracks.forEach(function(track) {
+        track.stop()
+      })
+      document.getElementById('video').srcObject = null
+    },
+    // 将摄像头截图的base64串转化为file提交后台
+    base64ToFile(urlData, fileName) {
+      const arr = urlData.split(',')
+      const mime = arr[0].match(/:(.*?);/)[1]
+      const bytes = atob(arr[1]) // 解码base64
+      let n = bytes.length
+      const ia = new Uint8Array(n)
+      while (n--) {
+        ia[n] = bytes.charCodeAt(n)
+      }
+      return new File([ia], fileName, { type: mime })
+    },
+    // ****************************************************************************************************************
+    // 上传用户考试信息进入后台
+    async uploadExamToAdmin() {
+      if (this.cameraOn) await this.takePhoto()// 结束的时候拍照上传一张
+      // 正则
+      var reg = new RegExp('-', 'g')
+      // 去掉用户输入的非法分割符号(-),保证后端接受数据处理不报错
+      this.userAnswer.forEach((item, index) => {
+        // 简答题答案处理
+        if (this.questionList[index].questionType === 5 ||
+          this.questionList[index].questionType === 6 ||
+          this.questionList[index].questionType === 7) {
+          this.userAnswer[index] = item.replace(reg, ' ')
+        }
+      })
+
+      // 标记题目是否全部做完
+      let flag = true
+      for (let i = 0; i < this.userAnswer.length; i++) { // 检测用户是否题目全部做完
+        if (this.userAnswer[i] === undefined) {
+          flag = false
+        }
+      }
+      // 如果用户所有答案的数组长度小于题目长度,这个时候也要将标志位置为false
+      if (this.userAnswer.length < this.questionList.length) {
+        flag = false
+      }
+
+      // 题目未做完
+      if (!flag) {
+        this.$confirm('当前试题暂未做完, 是否继续提交o(╥﹏╥)o ?', 'Tips', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }).then(() => {
+          this.submitUserPayload(false)
+        }).catch(() => {
+          this.$notify({
+            title: 'Tips',
+            message: '继续加油! *^▽^*',
+            type: 'success',
+            duration: 2000
+          })
+        })
+      } else { // 当前题目做完了
+        if (this.cameraOn) {
+          // 结束的时候拍照上传一张
+          await this.takePhoto()
+          this.closeCamera()
+        }
+
+        this.submitUserPayload(false)
+      }
+    },
+    submitUserPayload(autoSubmit) {
+      var msg = '考试结束了捏 *^▽^*'
+      if (autoSubmit) {
+        msg = '考试时间结束,已为您自动提交 *^▽^*'
+      }
+
+      const userAnswers = []
+      console.log(this.questionList)
+      console.log(this.userAnswer)
+      this.questionList.forEach((item, index) => {
+        let answer
+        if (this.userAnswer[index] === undefined) {
+          answer = '-'
+        } else {
+          answer = this.userAnswer[index]
+        }
+        userAnswers.push({
+          pos: item.pos,
+          questionId: item.questionId,
+          answer: answer
+        })
+      })
+
+      const payload = {}
+      payload.paperId = parseInt(this.$route.params.paperId)
+      payload.answers = userAnswers
+      console.log(payload)
+
+      const userPayload = {}
+      userPayload.questionIds = []
+      userPayload.userAnswers = this.userAnswer.join('-')
+      this.questionList.forEach((item, index) => {
+        userPayload.questionIds.push(item.questionId)
+        // 当前数据不完整,用户回答不完整(我们自动补充空答案,防止业务出错)
+        if (index > this.userAnswer.length) {
+          userPayload.userAnswers += ' -'
+        }
+      })
+
+      // 如果所有题目全部未答
+      if (userPayload.userAnswers === '') {
+        this.questionList.forEach(item => {
+          userPayload.userAnswers += ' -'
+        })
+        userPayload.userAnswers.split(0, userPayload.userAnswers.length - 1)
+      }
+      userPayload.examId = parseInt(this.$route.params.paperId)
+      userPayload.questionIds = userPayload.questionIds.join(',')
+      userPayload.creditImgUrl = this.takePhotoUrl.join(',')
+      submitExam(payload).then((resp) => {
+        if (resp.code === 0) {
+          this.$notify({
+            title: 'Tips',
+            message: msg,
+            type: 'success',
+            duration: 2000
+          })
+          const resultId = resp.data
+          this.$router.push('/exam/result/' + resultId)
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+* {
+  font-weight: 800;
+}
+
+.el-container {
+  width: 100%;
+  height: 100%;
+}
+
+.startExam {
+  color: #160f58;
+  border-bottom: 4px solid #ffd550;
+  font-size: 18px;
+  font-weight: 700;
+  padding-bottom: 10px
+}
+
+.examTitle {
+  font-size: 18px;
+  color: #cbcacf;
+  margin-left: 20px;
+  font-weight: 700;
+}
+
+.el-radio-group label {
+  display: block;
+  width: 400px;
+  padding: 48px 20px 10px 20px;
+  border-radius: 4px;
+  border: 1px solid #dcdfe6;
+  margin-bottom: 10px;
+  cursor: pointer;
+  position: relative;
+
+  span {
+    position: absolute;
+    top: 50%;
+    transform: translateY(-50%);
+    font-size: 16px;
+  }
+}
+
+.el-radio-group label:hover {
+  background-color: rgb(245, 247, 250);
+}
+
+/*当前选中的答案*/
+.active {
+  border: 1px solid #1f90ff !important;
+  opacity: .5;
+}
+
+/*做过的题目的高亮颜色*/
+.done {
+  background-color: rgb(87, 148, 247);
+}
+
+/*未做题目的高亮颜色*/
+.noAnswer {
+  background-color: rgb(238, 238, 238);
+}
+
+/*当前在做的题目高亮的颜色*/
+.orange {
+  background-color: rgb(255, 213, 80);
+}
+
+.num {
+  display: inline-block;
+  background: url('../../assets/img/examTitle.png') no-repeat 95%;
+  background-size: contain;
+  height: 37px;
+  width: 37px;
+  line-height: 30px;
+  color: #fff;
+  font-size: 20px;
+  text-align: center;
+  margin-right: 15px;
+}
+</style>

+ 428 - 0
src/views/exam/ExamDashboard.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>

+ 290 - 0
src/views/exam/ExamEvalList.vue

@@ -0,0 +1,290 @@
+<template>
+  <el-container>
+    <el-header height="220">
+      <el-row>
+        <el-select
+          v-model="queryInfo.subjectId"
+          clearable
+          placeholder="请选择科目"
+          style="margin-left: 5px"
+          @change="subjectChange"
+        >
+          <el-option
+            v-for="item in allSubject"
+            :key="item.label"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+      </el-row>
+    </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"
+        >
+          <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-main>
+
+    <el-dialog
+      title="测评提示"
+      :visible.sync="startExamDialog"
+      center
+      width="50%"
+    >
+      <el-alert
+        title="点击`开始测评`后将自动进入测评,请诚信测评,测评过程中可能会对用户行为、用户视频进行截图采样,请知悉!"
+        type="error"
+      />
+      <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>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="startExamDialog = false">返 回</el-button>
+        <el-button type="primary" @click="startExam(currentSelectedExam.examId)">开始测评</el-button>
+      </span>
+    </el-dialog>
+  </el-container>
+</template>
+
+<script>
+import { getSubjectKV, getExamList } from '@/api/exam'
+
+export default {
+  name: 'ExamEvalList',
+  data() {
+    return {
+      // 屏幕宽度, 为了控制分页条的大小
+      screenWidth: document.body.clientWidth,
+      currentPage: 1,
+      pageSize: 20,
+      totalSize: 0,
+      dataList: [],
+      // **********************************************************************
+      queryInfo: {
+        subjectId: null,
+        pageNumber: 1,
+        pageSize: 10
+      },
+      allSubject: [],
+      // 开始测评的提示框
+      startExamDialog: false,
+      // 当前选中的测评的信息
+      currentSelectedExam: {
+        examId: 114511
+      }
+    }
+  },
+  created() {
+    document.title = '我的试卷'
+    this.getData(this.queryInfo)
+    this.getSubjects()
+    // this.getQuestionTypes()
+  },
+  methods: {
+    handleCurrentChange(pageNumber) {
+      this.currentPage = pageNumber
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getData(this.queryInfo)
+      // 回到顶部
+      scrollTo(0, 0)
+    },
+    getData(queryInfo) {
+      getExamList(queryInfo).then(resp => {
+        if (resp.code === 0) {
+          this.dataList = resp.data.list
+          this.totalSize = resp.data.totalSize
+        } else {
+          this.$notify({
+            title: '提示',
+            message: resp.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+    },
+    getSubjects() {
+      getSubjectKV().then((resp) => {
+        if (resp.code === 0) {
+          this.allSubject = resp.data
+        }
+      })
+    },
+    // 题库变化
+    subjectChange(val) {
+      this.queryInfo.subjectId = val
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getData(this.queryInfo)
+    },
+    prepareExam(index, row) {
+      if (!row.needPasscode) {
+        this.startExamDialog = true
+        this.currentSelectedExam = row
+        return
+      }
+
+      this.$prompt('请提供测评码', 'Tips', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消'
+      }).then(({ value }) => {
+        if (value === 123) {
+          this.$message.error('密码错误o(╥﹏╥)o')
+          return
+        }
+
+        this.startExamDialog = true
+        this.currentSelectedExam = row
+      }).catch(() => {
+      })
+    },
+    startExam(paperId) {
+      const routeUrl = this.$router.resolve({
+        path: '/exam/paper/detail',
+        query: {
+          paperId: paperId,
+          viewType: 2,
+          userId: ''
+        }
+      })
+      window.open(routeUrl.href, '_blank')
+    },
+    waitMark(index, row) {
+      this.$message.info('试卷待批改')
+    },
+    markPaper(index, row) {
+      const paperId = row.examId
+      const routeUrl = this.$router.resolve({
+        path: '/exam/paper/detail',
+        query: {
+          paperId: paperId,
+          viewType: 3,
+          userId: 0
+        }
+      })
+      window.open(routeUrl.href, '_blank')
+    },
+    viewResult(index, row) {
+      const paperId = row.examId
+      const routeUrl = this.$router.resolve({
+        path: '/exam/paper/detail',
+        query: {
+          paperId: paperId,
+          viewType: 4,
+          userId: ''
+        }
+      })
+      window.open(routeUrl.href, '_blank')
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 172 - 0
src/views/exam/ExamMarkIndex.vue

@@ -0,0 +1,172 @@
+<template>
+  <el-container>
+    <el-header height="220">
+      <el-row>
+        <el-select
+          v-model="queryInfo.subjectId"
+          clearable
+          placeholder="请选择试卷"
+          style="margin-left: 5px"
+          @change="paperChange"
+        >
+          <el-option
+            v-for="item in allPapers"
+            :key="item.key"
+            :label="item.value"
+            :value="item.key"
+          />
+        </el-select>
+      </el-row>
+    </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="name"
+          label="试卷名称"
+          width="150"
+        />
+        <el-table-column
+          prop="examTime"
+          label="考试时间"
+          width="150"
+        />
+        <el-table-column
+          prop="totalScore"
+          label="考试总分"
+        />
+        <el-table-column
+          prop="student"
+          label="参考学生"
+        />
+        <el-table-column
+          fixed="right"
+          label="操作"
+          width="320"
+        >
+          <template slot-scope="scope">
+            <el-button
+              size="mini"
+              type="warning"
+              @click="markPaper(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-main>
+  </el-container>
+</template>
+
+<script>
+import { getExamMarkList, getPaperKeyValues } from '@/api/exam'
+
+export default {
+  name: 'ExamMarkIndex',
+  data() {
+    return {
+      // 屏幕宽度, 为了控制分页条的大小
+      screenWidth: document.body.clientWidth,
+      currentPage: 1,
+      pageSize: 20,
+      totalSize: 0,
+      dataList: [],
+      // **********************************************************************
+      queryInfo: {
+        pageNumber: 1,
+        pageSize: 10,
+        paperId: null,
+        marked: false
+      },
+      allPapers: [],
+      allSubject: []
+    }
+  },
+  created() {
+    document.title = '阅卷管理'
+    this.getData(this.queryInfo)
+    this.getPapers()
+  },
+  methods: {
+    handleCurrentChange(pageNumber) {
+      this.currentPage = pageNumber
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getData(this.queryInfo)
+      // 回到顶部
+      scrollTo(0, 0)
+    },
+    getData(queryInfo) {
+      getExamMarkList(queryInfo).then(resp => {
+        if (resp.code === 0) {
+          this.dataList = resp.data.list
+          this.totalSize = resp.data.totalSize
+        } else {
+          this.$notify({
+            title: '提示',
+            message: resp.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+    },
+    getPapers() {
+      getPaperKeyValues().then(resp => {
+        if (resp.code === 0) {
+          this.allPapers = resp.data
+        }
+      })
+    },
+    // 试卷变化
+    paperChange(val) {
+      this.queryInfo.paperId = val
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getData(this.queryInfo)
+    },
+    markPaper(index, row) {
+      const paperId = row.paperId
+      const userId = row.userId
+      const routeUrl = this.$router.resolve({
+        path: '/exam/paper/detail',
+        query: {
+          paperId: paperId,
+          viewType: 3,
+          userId: userId
+        }
+      })
+      window.open(routeUrl.href, '_blank')
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 502 - 0
src/views/exam/ExamPaper.vue

@@ -0,0 +1,502 @@
+<template>
+  <el-container class="exam-container">
+    <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 }">
+          {{ 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 class="number-grid">
+            <span
+              v-for="(q, qIndex) in group.questions"
+              :key="q.id"
+              class="number-item"
+              :class="{
+                'is-answered': checkIsAnswered(q),
+                'is-active': currentActiveId === q.id
+              }"
+              @click="scrollToQuestion(q.id)"
+            >
+              {{ q.number }}
+            </span>
+          </div>
+        </div>
+
+        <el-button type="success" class="btn-submit" icon="el-icon-upload2" @click="handleConfirmSubmit">
+          立即交卷
+        </el-button>
+      </div>
+    </el-aside>
+
+    <el-main class="exam-main">
+      <div class="paper-header">
+        <h2 class="paper-title">{{ examData.title }}</h2>
+        <div class="paper-meta">
+          <span>总分:{{ examData.totalScore }}分</span>
+          <span>及格线:{{ examData.passingScore }}分</span>
+          <span>考试科目:{{ examData.subject }}</span>
+        </div>
+      </div>
+
+      <div v-for="(group, gIndex) in examData.questionGroups" :key="gIndex" class="question-group-section">
+        <h3 class="group-header-title">
+          {{ group.groupTitle }} <span class="group-desc">(每题 {{ group.scorePerQuestion }} 分)</span>
+        </h3>
+
+        <div
+          v-for="(q, qIndex) in group.questions"
+          :key="q.id"
+          :id="'q-' + q.id"
+          class="question-card"
+          @mouseenter="currentActiveId = q.id"
+        >
+          <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 }}
+                </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 }}
+                </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'">
+              <el-input
+                v-model="answers[q.id]"
+                type="textarea"
+                :rows="4"
+                placeholder="请输入您的作答内容..."
+                maxlength="500"
+                show-word-limit
+              />
+            </template>
+          </div>
+        </div>
+      </div>
+    </el-main>
+  </el-container>
+</template>
+
+<script>
+export default {
+  name: 'ExamPaper',
+  data() {
+    return {
+      remainTime: 7200, // 考试剩余时间(秒),示例为 2 小时
+      timer: null,
+      currentActiveId: null, // 当前正在视线聚焦的题目ID
+
+      // 核心作答数据收集器模型,以 questionId 作为 key
+      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: '请简述现代前端在处理海量音视频流(如直播或连续视频审核)时,如何有效防止硬件解码器队列泄漏或卡死。'
+              }
+            ]
+          }
+        ]
+      }
+    }
+  },
+  computed: {
+    // 倒计时格式化器 (02:00:00)
+    formatRemainTime() {
+      const hours = Math.floor(this.remainTime / 3600).toString().padStart(2, '0')
+      const mins = Math.floor((this.remainTime % 3600) / 60).toString().padStart(2, '0')
+      const secs = Math.floor(this.remainTime % 60).toString().padStart(2, '0')
+      return `${hours}:${mins}:${secs}`
+    }
+  },
+  created() {
+    this.initAnswersMap()
+    this.startTimer()
+  },
+  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] = '' // 其余单选、填空、判断初始化为字符串
+          }
+        })
+      })
+      this.answers = newAnswers
+    },
+
+    // 🌟 2. 启动考试倒计时
+    startTimer() {
+      this.timer = setInterval(() => {
+        if (this.remainTime > 0) {
+          this.remainTime--
+        } else {
+          clearInterval(this.timer)
+          this.$alert('考试时间已到,系统已为您自动提交试卷!', '提示', {
+            confirmButtonText: '确定',
+            callback: () => { this.submitExam() }
+          })
+        }
+      }, 1000)
+    },
+
+    // 🌟 3. 检测题目是否已被作答(驱动答题卡亮起)
+    checkIsAnswered(question) {
+      const ans = this.answers[question.id]
+      if (question.type === 'MULTIPLE_CHOICE') {
+        return ans && ans.length > 0
+      }
+      return ans !== undefined && ans !== null && ans.trim !== undefined ? ans.trim() !== '' : ans !== ''
+    },
+
+    // 🌟 4. 点击答题卡,平滑锚点滚动到对应题目
+    scrollToQuestion(qId) {
+      this.currentActiveId = qId
+      const targetEl = document.getElementById(`q-${qId}`)
+      if (targetEl) {
+        targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
+      }
+    },
+
+    // 🌟 5. 交卷确认弹窗
+    handleConfirmSubmit() {
+      // 统计未做题目数量
+      let unAnsweredCount = 0
+      this.examData.questionGroups.forEach(group => {
+        group.questions.forEach(q => {
+          if (!this.checkIsAnswered(q)) unAnsweredCount++
+        })
+      })
+
+      const tipMessage = unAnsweredCount > 0
+        ? `您当前还有 ${unAnsweredCount} 道题未作答,确定要现在交卷吗?`
+        : '您已完成所有题目,确定提交试卷吗?'
+
+      this.$confirm(tipMessage, '交卷确认', {
+        confirmButtonText: '确定提交',
+        cancelButtonText: '检查一下',
+        type: unAnsweredCount > 0 ? 'warning' : 'success'
+      }).then(() => {
+        this.submitExam()
+      }).catch(() => {})
+    },
+
+    // 🌟 6. 执行提交逻辑
+    submitExam() {
+      console.log('最终提交的答题 JSON 数据矩阵:', this.answers)
+      this.$message.success('试卷提交成功!正在为您生成评测报告...')
+      // 可以在此处调用后端接口发送 this.answers
+    }
+  }
+}
+</script>
+
+<style scoped>
+.exam-container {
+  height: 100vh;
+  background-color: #f0f2f5;
+}
+
+/* ==================== ⏱️ 左侧面板层 ==================== */
+.exam-aside {
+  padding: 20px;
+  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;
+}
+.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;
+  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;
+}
+.option-key {
+  font-weight: bold;
+  margin-right: 4px;
+}
+
+/* 呼吸灯特效 */
+@keyframes blink {
+  50% { opacity: 0.7; }
+}
+</style>

+ 731 - 0
src/views/exam/ExamPaperAdd.vue

@@ -0,0 +1,731 @@
+<template>
+  <el-container>
+    <el-header height="220">
+      <el-steps :active="curStep" simple>
+        <el-step title="组卷配置" icon="el-icon-edit" />
+        <el-step title="考试权限" icon="el-icon-lock" />
+        <el-step title="补充配置" icon="el-icon-setting" />
+      </el-steps>
+
+      <el-button v-show="curStep !== 1" style="margin-top: 10px" @click="curStep--">
+        上一步
+      </el-button>
+      <el-button v-show="curStep !== 3" style="float:right;margin-top: 10px" type="primary" @click="curStep++">
+        下一步
+      </el-button>
+      <el-button v-show="curStep === 3" style="float:right;margin-top: 10px" type="primary" @click="createExamPaper">
+        创建试卷
+      </el-button>
+    </el-header>
+    <el-main>
+      <!--设置试题信息-->
+      <el-card v-show="curStep === 1">
+        <el-radio-group
+          v-model="makeModel"
+          size="medium"
+          @change="makeModelChange"
+        >
+          <el-radio :label="1" border>自由组卷</el-radio>
+          <el-radio :label="2" border>题库组卷</el-radio>
+        </el-radio-group>
+        <span v-show="makeModel === 1" style="float: right;color: red;font-weight: bold">
+          {{ '试卷总分:' + sumTotalScore }}
+        </span>
+        <!-- 自由组卷内容-->
+        <div v-show="makeModel === 1" style="margin-top: 25px">
+          <el-button icon="el-icon-plus" size="mini" @click="showAddDialog">添加试题</el-button>
+          <el-table :data="addExamQuestion1" border style="margin-top: 10px">
+            <el-table-column type="index" label="顺序" />
+            <el-table-column label="题目内容" align="center">
+              <template slot-scope="scope">
+                {{ scope.row.content.substr(0, 20) }}
+              </template>
+            </el-table-column>
+            <el-table-column
+              align="center"
+              label="题目类型"
+              prop="type"
+            />
+            <el-table-column label="单题分数" align="center">
+              <template slot-scope="scope">
+                <div v-if="scope.row.typeCode !== 8">
+                  <el-input-number v-model="scope.row.score" :min="1" :max="20" style="margin-left: 5px" />
+                </div>
+                <div v-else>
+                  <el-button icon="el-icon-plus" size="mini" @click="showGroupDialog(scope.row.questionId)">
+                    设置组合题分数
+                  </el-button>
+                  <span>{{scope.row.score}}</span>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="80" align="center">
+              <template slot-scope="scope">
+                <el-button
+                  type="danger"
+                  icon="el-icon-delete"
+                  circle
+                  @click="delQuestion(scope.row.questionId)"
+                />
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+        <!-- 题库组卷内容-->
+        <div v-show="makeModel === 2" style="margin-top: 25px">
+          <el-button icon="el-icon-plus" size="mini" @click="addBank">添加题库</el-button>
+          <!--存放题目的信息-->
+          <el-table :data="addExamQuestion2" border style="margin-top: 10px">
+            <el-table-column label="题库" width="155" align="center">
+              <template slot-scope="scope">
+                <el-select
+                  v-model="scope.row.bankName"
+                  clearable
+                  placeholder="请选择题库"
+                  style="margin-left: 5px"
+                >
+                  <el-option
+                    v-for="item in bankList"
+                    :key="item.bankId"
+                    :label="item.bankName"
+                    :value="item.bankName"
+                  />
+                </el-select>
+              </template>
+            </el-table-column>
+
+            <el-table-column label="单选题分数" align="center">
+              <template slot-scope="scope">
+                <el-input v-model="scope.row.singleScore" style="margin-left: 5px" />
+              </template>
+            </el-table-column>
+            <el-table-column label="多选题分数" align="center">
+              <template slot-scope="scope">
+                <el-input v-model="scope.row.multipleScore" style="margin-left: 5px" />
+              </template>
+            </el-table-column>
+            <el-table-column label="判断题分数" align="center">
+              <template slot-scope="scope">
+                <el-input v-model="scope.row.judgeScore" style="margin-left: 5px" />
+              </template>
+            </el-table-column>
+            <el-table-column label="简答题分数" align="center">
+              <template slot-scope="scope">
+                <el-input v-model="scope.row.shortScore" style="margin-left: 5px" />
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="80" align="center">
+              <template slot-scope="scope">
+                <el-button type="danger" icon="el-icon-delete" circle @click="delBank(scope.row.bankId)" />
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+      </el-card>
+
+      <!--设置考试权限-->
+      <el-card v-show="curStep === 2">
+        <el-radio-group v-model="examAuthority" size="medium">
+          <el-radio :label="1" border>完全公开</el-radio>
+          <el-radio :label="2" border>需要密码</el-radio>
+        </el-radio-group>
+
+        <el-alert
+          style="margin-top: 15px"
+          :title="examAuthority === 1? '开放的,任何人都可以进行考试!' : '半开放的,知道密码的人员才可以考试!'"
+          type="warning"
+        />
+
+        <el-input
+          v-show="examAuthority === 2"
+          v-model="examPassword"
+          style="margin-top: 15px;width: 20%"
+          type="password"
+          show-password
+          placeholder="输入考试密码"
+        />
+      </el-card>
+
+      <!--设置考试信息-->
+      <el-card v-show="curStep === 3">
+        <el-form ref="examInfoForm" :model="examInfo" :rules="examInfoRules" label-width="100px">
+          <el-form-item label="考试名称" prop="examName">
+            <el-input v-model="examInfo.examName" />
+          </el-form-item>
+          <el-form-item label="考试描述" prop="examDesc">
+            <el-input v-model="examInfo.examDesc" />
+          </el-form-item>
+          <el-form-item v-show="makeModel === 1" label="总分数" prop="totalScore">
+            <el-input-number :value="sumTotalScore" :disabled="true" />
+          </el-form-item>
+          <el-form-item label="及格分数" prop="passScore">
+            <el-input-number v-model="examInfo.passScore" :min="1" :max="parseInt(sumTotalScore)" />
+          </el-form-item>
+          <el-form-item label="考试时长(分钟)" prop="examDuration">
+            <el-input-number v-model="examInfo.examDuration" :min="1" :max="120" />
+          </el-form-item>
+          <el-form-item label="考试开始时间" prop="startTime">
+            <el-date-picker
+              v-model="examInfo.startTime"
+              style="margin-left: 5px"
+              type="datetime"
+              placeholder="考试开始时间"
+            />
+          </el-form-item>
+          <el-form-item label="考试结束时间" prop="endTime">
+            <el-date-picker
+              v-model="examInfo.endTime"
+              style="margin-left: 5px"
+              type="datetime"
+              placeholder="考试结束时间"
+            />
+          </el-form-item>
+        </el-form>
+      </el-card>
+    </el-main>
+
+    <el-dialog title="添加试卷试题" :visible.sync="showQuestionDialog" width="80%" center>
+      <el-row>
+        <el-select
+          v-model="queryInfo.subjectId"
+          clearable
+          placeholder="请选择科目"
+          style="margin-left: 5px"
+          @change="subjectChange"
+        >
+          <el-option
+            v-for="item in allSubject"
+            :key="item.label"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+        <el-select v-model="queryInfo.questionType" clearable placeholder="请选择试题类型" @change="typeChange">
+          <el-option
+            v-for="item in allType"
+            :key="item.label"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+        <el-input
+          v-model="queryInfo.questionContent"
+          placeholder="题目内容"
+          style="margin-left: 5px;width: 220px"
+          prefix-icon="el-icon-search"
+          @blur="getQuestionInfo"
+        />
+        <el-button type="primary" style="float: right" @click="addQuToFree">确认{{ selectedTable.length }}项</el-button>
+      </el-row>
+      <el-table
+        ref="questionTable"
+        v-loading="loading"
+        highlight-current-row
+        :border="true"
+        :data="dataList"
+        tooltip-effect="dark"
+        style="width: 100%;margin-top: 25px;"
+        @selection-change="handleTableSectionChange"
+      >
+        <el-table-column
+          align="center"
+          type="selection"
+          width="55"
+        />
+        <el-table-column
+          align="center"
+          label="题目类型"
+          prop="type"
+        />
+        <el-table-column align="center" label="题目内容">
+          <template slot-scope="scope">
+            <span class="quContent">{{ scope.row.content.substr(0, 20) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column
+          align="center"
+          label="难度"
+          prop="level"
+        />
+        <el-table-column
+          align="center"
+          prop="subject"
+          label="所属科目"
+        />
+        <el-table-column
+          align="center"
+          prop="createBy"
+          label="创建人"
+        />
+
+        <el-table-column
+          align="center"
+          label="创建时间"
+        >
+          <template slot-scope="scope">
+            {{ scope.row.createAt }}
+          </template>
+        </el-table-column>
+      </el-table>
+      <!--分页-->
+      <el-pagination
+        style="margin-top: 25px"
+        layout="total, sizes, prev, pager, next, jumper"
+        :page-sizes="[10, 20, 50]"
+        :page-size="pageSize"
+        :current-page="currentPage"
+        :total="totalSize"
+        @current-change="handleCurrentChange"
+        @prev-click="handleCurrentChange"
+        @next-click="handleCurrentChange"
+      />
+    </el-dialog>
+    <el-dialog title="组合题分数设置" :visible.sync="showGroupQuestionDialog" width="80%" center>
+      <el-button icon="el-icon-plus" size="mini" @click="confirmChildScore">确定</el-button>
+      <el-table
+        highlight-current-row
+        :border="true"
+        :data="groupQuestionList"
+        tooltip-effect="dark"
+        style="width: 100%;margin-top: 25px;"
+      >
+        <el-table-column
+          align="center"
+          type="selection"
+          width="55"
+        />
+        <el-table-column
+          align="center"
+          label="题目类型"
+          prop="type"
+        />
+        <el-table-column align="center" label="题目内容">
+          <template slot-scope="scope">
+            <span class="quContent">{{ scope.row.content.substr(0, 20) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column
+          align="center"
+          label="单题分数"
+          prop="score"
+        >
+          <template slot-scope="scope">
+            <el-input-number v-model="scope.row.score" :min="1" :max="20" style="margin-left: 5px" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          align="center"
+          prop="subject"
+          label="所属科目"
+        />
+      </el-table>
+    </el-dialog>
+  </el-container>
+</template>
+
+<script>
+import { getQuestions, getQuestionType, getSubjectKV, addPaper, getQuestion } from '@/api/exam'
+import { validFormAndInvoke } from '@/utils/util'
+
+export default {
+  name: 'ExamPaperAdd',
+  props: ['tagInfo'],
+  data() {
+    return {
+      currentPage: 1,
+      pageSize: 20,
+      totalSize: 0,
+      dataList: [],
+      // 查询题目的参数
+      queryInfo: {
+        subjectId: null,
+        type: null,
+        level: null,
+        pageNumber: 1,
+        pageSize: 10
+      },
+      // 所有题库信息
+      allSubject: [
+      ],
+      bankList: [
+         {
+           bankId: 1,
+           bankName: '默认题库'
+         }
+      ],
+      allType: [],
+      // 当前的步骤
+      curStep: 1,
+      // 组卷模式
+      makeModel: 1,
+      // 添加考试题目信息(makeModel = 1的时候)
+      addExamQuestion1: [],
+      // 添加考试题目信息(makeModel = 2 的时候)
+      addExamQuestion2: [],
+      // 所有题目的对话框
+      groupQuestionId: null,
+      showQuestionDialog: false,
+      showGroupQuestionDialog: false,
+      groupQuestionList: [],
+      // 对话框中题目表格的加载
+      loading: true,
+      // 所有题目的信息
+      questionInfo: [],
+      // 所有题目的对话框中表格被选中
+      selectedTable: [],
+      // 所有题目总数
+      total: 0,
+      // 考试权限(1公开, 2密码)
+      examAuthority: 1,
+      // 考试密码(权限为2时的密码)
+      examPassword: '',
+      // 补充的考试信息
+      examInfo: {
+        examId: null,
+        examDesc: null,
+        passScore: 0,
+        examDuration: 0,
+        startTime: null,
+        endTime: null
+      },
+      // 补充的考试信息的表单验证
+      examInfoRules: {
+        examName: [
+          {
+            required: true,
+            message: '请输入考试名称',
+            trigger: 'blur'
+          }
+        ],
+        passScore: [
+          {
+            required: true,
+            message: '请输入通过分数',
+            trigger: 'blur'
+          }
+        ],
+        examDuration: [
+          {
+            required: true,
+            message: '请输入考试时长',
+            trigger: 'blur'
+          }
+        ]
+      }
+    }
+  },
+  computed: {
+    // 计算总分
+    sumTotalScore() {
+      if (this.makeModel === 1) {
+        let score = 0
+        this.addExamQuestion1.forEach(item => {
+          score += parseInt(item.score)
+        })
+        return score
+      }
+    }
+  },
+  created() {
+    document.title = '组卷管理'
+    // 一创建就改变头部的面包屑
+    this.$emit('giveChildChangeBreakInfo', '添加考试', '添加考试')
+    // this.createTagsInParent()
+    this.getSubjects()
+    this.getQuestionTypes()
+  },
+  methods: {
+    // 向父组件中添加头部的tags标签
+    createTagsInParent() {
+      let flag = false
+      this.tagInfo.map(item => {
+        // 如果tags全部符合
+        if (item.name === '添加考试' && item.url === this.$route.path) {
+          flag = true
+        } else if (item.name === '添加考试' && item.url !== this.$route.path) {
+          this.$emit('updateTagInfo', '添加考试', this.$route.path)
+          flag = true
+        }
+      })
+      if (!flag) this.$emit('giveChildAddTag', '添加考试', this.$route.path)
+    },
+    // 获取所有的题库信息
+    getSubjects() {
+      getSubjectKV().then((resp) => {
+        if (resp.code === 0) {
+          this.allSubject = resp.data
+        } else {
+          this.$notify({
+            title: 'Tips',
+            message: resp.message,
+            type: 'error',
+            duration: 2000
+          })
+        }
+      })
+    },
+    getQuestionTypes() {
+      getQuestionType().then((resp) => {
+        if (resp.code === 0) {
+          this.allType = resp.data
+        } else {
+          this.$notify({
+            title: 'Tips',
+            message: resp.message,
+            type: 'error',
+            duration: 2000
+          })
+        }
+      })
+    },
+    // 删除当前需要去除的题库
+    delBank(bankId) {
+      this.addExamQuestion2.forEach((item, index) => {
+        if (item.bankId === bankId) this.addExamQuestion2.splice(index, 1)
+      })
+    },
+    // 添加题库组卷中的题库
+    addBank() {
+      this.addExamQuestion2.push({
+        'bankId': 1,
+        'bankName': '默认题库',
+        'singleScore': 1,
+        'multipleScore': 1,
+        'judgeScore': 1,
+        'shortScore': 1
+      })
+    },
+    // 自由组卷时添加试题
+    showAddDialog() {
+      this.showQuestionDialog = true
+      this.queryInfo.subjectId = null
+      this.queryInfo.type = null
+      this.queryInfo.level = null
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getQuestionInfo()
+    },
+    // 自由组卷时删除试题
+    delQuestion(questionId) {
+      this.addExamQuestion1.forEach((item, index) => {
+        if (item.questionId === questionId) this.addExamQuestion1.splice(index, 1)
+      })
+    },
+    // 题目类型变化
+    typeChange(val) {
+      this.queryInfo.type = val
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getQuestionInfo()
+    },
+    // 科目变化
+    subjectChange(val) {
+      this.queryInfo.subjectId = val
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getQuestionInfo()
+    },
+    // 获取题目信息
+    getQuestionInfo() {
+      this.dataList = []
+      this.loading = true
+      getQuestions(this.queryInfo).then(resp => {
+        if (resp.code === 0) {
+          this.dataList = resp.data.list
+          this.totalSize = resp.data.totalSize
+          this.loading = false
+        } else {
+          this.$notify({
+            title: '提示',
+            message: resp.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+    },
+    // 处理表格被选中
+    handleTableSectionChange(val) {
+      this.selectedTable = val
+    },
+    // 分页页面大小改变
+    handleSizeChange(val) {
+      this.queryInfo.pageSize = val
+      this.getQuestionInfo()
+    },
+    // 分页插件的页数
+    handleCurrentChange(val) {
+      this.queryInfo.pageNumber = val
+      this.getQuestionInfo()
+    },
+    // 自由组卷中选中的题目添加进去
+    addQuToFree() {
+      this.selectedTable.forEach(item => {
+        // 不存在有当前题目
+        if (!this.addExamQuestion1.some(i2 => { return i2.questionId === item.questionId })) {
+          this.addExamQuestion1.push({
+            'pos': this.addExamQuestion1.length + 1,
+            'questionId': item.questionId,
+            'content': item.content,
+            'type': item.type,
+            'typeCode': item.typeCode,
+            'score': 1,
+            'children': []
+          })
+        }
+      })
+      this.showQuestionDialog = false
+    },
+    // 组卷模式变化
+    makeModelChange() {
+      this.$confirm('此操作将丢失当前组卷数据, 是否继续?', 'Tips', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.makeModel === 1 ? this.addExamQuestion1 = [] : this.addExamQuestion2 = []
+      }).catch(() => {
+      })
+    },
+    // 创建试卷
+    createExamPaper() {
+      validFormAndInvoke(this.$refs['examInfoForm'], () => {
+        if ((this.makeModel === 1 && this.addExamQuestion1.length === 0) ||
+          (this.makeModel === 2 && this.addExamQuestion2.length === 0)) {
+          this.$message.error('试卷还没有添加试题')
+          return false
+        }
+
+        // 构造数据对象(考试信息)
+        const exam = this.examInfo
+        exam.totalScore = this.sumTotalScore
+        exam.status = 1
+        // 权限id设置
+        exam.examAuthority = this.examAuthority
+        if (this.examAuthority === 2) { // 考试密码
+          if (this.examPassword === '') { // 当前用户选择了需要密码权限,但是密码为空
+            this.$message.error('当前权限为需要密码,但是密码为空')
+            return false
+          }
+          exam.examPassword = this.examPassword
+        }
+
+        if (this.makeModel === 1 && !this.addExamQuestion1.some(item => item.bankId === '')) {
+          var questions = []
+          this.addExamQuestion1.forEach(item => {
+            questions.push({
+              pos: item.pos,
+              questionId: item.questionId,
+              type: item.typeCode,
+              score: item.score,
+              children: item.children
+            })
+          })
+          exam.paperQuestions = questions
+          this.addExamPaper(exam)
+        } else if (this.makeModel === 2) {
+          const bankNames = []
+          this.addExamQuestion2.forEach(item => bankNames.push(item.bankName))
+          exam.bankNames = bankNames.join(',')
+          exam.singleScore = this.addExamQuestion2[0].singleScore
+          exam.multipleScore = this.addExamQuestion2[0].multipleScore
+          exam.judgeScore = this.addExamQuestion2[0].judgeScore
+          exam.shortScore = this.addExamQuestion2[0].shortScore
+          this.addExamPaper(exam)
+        } else {
+          this.$message.error('请检查考试规则设置是否完整')
+        }
+      }, '请检查考试规则设置是否完整')
+    },
+    addExamPaper(examInfo) {
+      addPaper(examInfo).then(resp => {
+        if (resp.code === 0) {
+          this.$notify({
+            title: '提示',
+            message: '试卷已创建',
+            type: 'warning',
+            duration: 3000
+          })
+          this.$router.push('/exam/paper')
+        } else {
+          this.$notify({
+            title: '提示',
+            message: resp.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+    },
+    showGroupDialog(questionId) {
+      this.groupQuestionId = questionId
+      this.showGroupQuestionDialog = true
+      getQuestion(questionId).then(resp => {
+        if (resp.code === 0) {
+          this.groupQuestionList = resp.data.children
+          for (const item of this.groupQuestionList) {
+            item.score = 0
+          }
+        }
+      })
+    },
+    confirmChildScore() {
+      this.showGroupQuestionDialog = false
+      var childIds = []
+      var score = 0
+      for (const item of this.groupQuestionList) {
+        score += item.score
+        childIds.push({
+          pid: this.groupQuestionId,
+          questionId: item.questionId,
+          score: item.score
+        })
+      }
+
+      for (const item of this.addExamQuestion1) {
+        if (item.questionId === this.groupQuestionId) {
+          item.score = score
+          item.children = childIds
+        }
+      }
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+
+.el-container {
+  width: 100%;
+  height: 100%;
+}
+
+.el-container {
+  animation: leftMoveIn .7s ease-in;
+}
+
+@keyframes leftMoveIn {
+  0% {
+    transform: translateX(-100%);
+    opacity: 0;
+  }
+  100% {
+    transform: translateX(0%);
+    opacity: 1;
+  }
+}
+</style>

+ 521 - 0
src/views/exam/ExamPaperAssembly.vue

@@ -0,0 +1,521 @@
+<template>
+  <el-container class="paper-assembly-container">
+    <el-main>
+      <el-page-header content="组卷控制台" @back="$emit('back')" />
+      <el-divider />
+
+      <el-form ref="paperForm" :model="paperForm" :rules="rules" label-width="100px" size="medium">
+        <el-row :gutter="20">
+          <el-col :span="10">
+            <el-form-item label="试卷名称" prop="title">
+              <el-input v-model="paperForm.title" placeholder="如:2026年高考模拟考试理科综合能力测试" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="考试科目" prop="subjectId">
+              <el-select v-model="paperForm.subjectId" placeholder="请选择" style="width:100%">
+                <el-option
+                  v-for="item in allSubject"
+                  :key="item.label"
+                  :label="item.label"
+                  :value="item.value"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8" class="score-summary-zone">
+            <div class="total-score-badge">
+              试卷总分:<span>{{ totalScore }}</span> 分
+            </div>
+          </el-col>
+        </el-row>
+      </el-form>
+
+      <div class="sections-wrapper">
+        <div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
+          <h3 class="section-main-title"><i class="el-icon-s-order" /> 卷面结构与大题配置</h3>
+          <el-button type="primary" icon="el-icon-plus" size="small" @click="addPaperSection">添加新大题分块</el-button>
+        </div>
+
+        <el-empty v-if="paperForm.sections.length === 0" description="暂无大题配置,请点击右上方按钮添加大题(如:一、选择题)" />
+
+        <div
+          v-for="(sec, secIndex) in paperForm.sections"
+          :key="sec.localId"
+          class="section-card"
+        >
+          <div class="section-card-header">
+            <el-row :gutter="10" type="flex" align="middle" style="width: 100%">
+              <el-col :span="2" class="text-center">
+                <span class="sec-num-text">第 {{ secIndex + 1 }} 部分</span>
+              </el-col>
+              <el-col :span="6">
+                <el-input v-model="sec.sectionName" size="small" placeholder="大题名称,如:单项选择题" />
+              </el-col>
+              <el-col :span="8">
+                <el-input v-model="sec.sectionHint" size="small" placeholder="大题说明(选填,如:每小题4分,共40分)" />
+              </el-col>
+              <el-col :span="4" class="text-right">
+                <span class="sec-score-text">大题小计: <strong>{{ calculateSectionScore(sec) }}</strong> 分</span>
+              </el-col>
+              <el-col :span="4" class="text-right">
+                <el-button type="text" icon="el-icon-plus" @click="openQuestionSelector(secIndex)">导入题目</el-button>
+                <el-button type="text" style="color: #F56C6C" icon="el-icon-delete" @click="removeSection(secIndex)">删除大题</el-button>
+              </el-col>
+            </el-row>
+          </div>
+
+          <div class="section-card-body">
+            <el-empty v-if="sec.questions.length === 0" :image-size="60" description="该大题下暂未放入试题,请点击右上角‘导入题目’" />
+
+            <table v-else class="question-assemble-table">
+              <thead>
+                <tr>
+                  <th width="70">题号</th>
+                  <th width="100">题型</th>
+                  <th>题目简述 / 题干预览</th>
+                  <th width="120">分值设定</th>
+                  <th width="120">操作</th>
+                </tr>
+              </thead>
+              <tbody>
+                <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>
+                  </td>
+                  <td>
+                    <div class="table-q-content">{{ q.content }}</div>
+
+                    <div v-if="q.typeCode === 6" class="table-sub-children-box">
+                      <div v-for="(sub, subIdx) in q.children" :key="sub.questionId" class="sub-child-row">
+                        <span>子问题 ({{ subIdx + 1 }}): 题干 [{{ sub.content }}]</span>
+                        <div class="sub-score-input">
+                          分值: <el-input-number v-model="sub.score" size="mini" :min="0" style="width:90px;" @change="syncCompositeTotalScore(q)" />
+                        </div>
+                      </div>
+                    </div>
+                  </td>
+                  <td>
+                    <el-input-number
+                      v-model="q.score"
+                      :min="0"
+                      size="small"
+                      style="width: 100%"
+                      :disabled="q.typeCode === 6"
+                    />
+                    <small v-if="q.typeCode === 6" class="help-text">由子问题分值累加</small>
+                  </td>
+                  <td class="text-center">
+                    <el-button type="text" icon="el-icon-top" :disabled="qIndex === 0" @click="moveQuestion(secIndex, qIndex, -1)" />
+                    <el-button type="text" icon="el-icon-bottom" :disabled="qIndex === sec.questions.length - 1" @click="moveQuestion(secIndex, qIndex, 1)" />
+                    <el-button type="text" style="color: #F56C6C" icon="el-icon-remove-outline" @click="removeQuestion(secIndex, qIndex)" />
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </div>
+      </div>
+
+      <div class="paper-footer-bar">
+        <el-button type="success" size="large" icon="el-icon-finished" @click="submitPaper">发布试卷并生成产物</el-button>
+        <el-button size="large" @click="$emit('back')">取消组卷</el-button>
+      </div>
+    </el-main>
+
+    <el-dialog title="选择题目加入大题" :visible.sync="selectorVisible" width="900px" append-to-body>
+      <div style="margin-bottom: 15px;">
+        <el-select v-model="queryParams.type" clearable placeholder="请选择试题类型" @change="onQuestionTypeChange">
+          <el-option
+            v-for="item in allType"
+            :key="item.label"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+
+        <el-input v-model="searchKey" placeholder="输入关键题干检索库内题目..." size="small" style="width: 300px; margin-right: 10px;" />
+        <el-button type="primary" size="small" icon="el-icon-search">查询</el-button>
+      </div>
+
+      <el-table
+        v-loading="loading"
+        :data="dataList"
+        border
+        size="small"
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column type="selection" width="50" align="center" />
+        <el-table-column property="questionId" label="题目ID" width="120" />
+        <el-table-column property="type" label="题型" width="90" align="center" />
+        <el-table-column property="content" label="题干简述" show-overflow-tooltip />
+        <el-table-column property="level" label="难度" width="80" align="center" />
+      </el-table>
+      <el-pagination
+        style="margin-top: 25px"
+        layout="total, sizes, prev, pager, next, jumper"
+        :page-sizes="[10, 20, 50]"
+        :page-size="pageSize"
+        :current-page="currentPage"
+        :total="totalSize"
+        @current-change="handleCurrentChange"
+        @prev-click="handleCurrentChange"
+        @next-click="handleCurrentChange"
+      />
+
+      <div slot="footer" class="dialog-footer">
+        <el-button size="small" @click="selectorVisible = false">取 消</el-button>
+        <el-button type="primary" size="small" @click="confirmInsertQuestions">确认导入 ({{ selectedRows.length }}题)</el-button>
+      </div>
+    </el-dialog>
+  </el-container>
+</template>
+
+<script>
+import { addPaper, getQuestions, getQuestionType, getSubjectKV } from '@/api/exam'
+
+export default {
+  name: 'ExamPaperAssembly',
+  data() {
+    return {
+      allSubject: [],
+      allType: [],
+      queryParams: {
+        pageNumber: 1,
+        subjectId: '',
+        type: '1'
+      },
+      currentPage: 1,
+      pageSize: 10,
+      totalSize: 0,
+      dataList: [],
+      loading: true,
+      paperForm: {
+        title: '',
+        subjectId: '',
+        sections: [] // 试卷大题结构
+      },
+      rules: {
+        title: [{ required: true, message: '请填写试卷名称', trigger: 'blur' }],
+        subjectId: [{ required: true, message: '请选择考试科目', trigger: 'change' }]
+      },
+      // 题目选择器相关状态
+      selectorVisible: false,
+      currentSectionIndex: null, // 当前正为哪个大题导题
+      searchKey: '',
+      selectedRows: [] // 弹窗中勾选的题目
+    }
+  },
+  computed: {
+    // 实时计算整张试卷的绝对总分数
+    totalScore() {
+      let sum = 0
+      this.paperForm.sections.forEach(sec => {
+        sum += this.calculateSectionScore(sec)
+      })
+      return sum
+    }
+  },
+  created() {
+    this.getData()
+  },
+  methods: {
+    getData() {
+      getQuestionType().then((resp) => {
+        if (resp.code === 0) {
+          this.allType = resp.data
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      })
+
+      getSubjectKV().then((resp) => {
+        if (resp.code === 0) {
+          this.allSubject = resp.data
+        }
+      })
+    },
+    // 添加一个新的大题块
+    addPaperSection() {
+      this.paperForm.sections.push({
+        localId: 'sec_' + Date.now() + Math.random(),
+        sectionName: '',
+        sectionHint: '',
+        questions: []
+      })
+    },
+    removeSection(index) {
+      this.paperForm.sections.splice(index, 1)
+    },
+    // 计算单个大题块下所有题目的总分
+    calculateSectionScore(section) {
+      return section.questions.reduce((total, q) => total + (q.score || 0), 0)
+    },
+    // 针对复合题:当修改内部任何一个子问题的分数时,自动重新累加更新父组合题的 score
+    syncCompositeTotalScore(question) {
+      if (question.typeCode === 6 && question.children) {
+        question.score = question.children.reduce((sum, sub) => sum + (sub.score || 0), 0)
+      }
+    },
+    // 打开导题弹窗
+    openQuestionSelector(secIndex) {
+      this.getSubjectQuestions()
+      this.currentSectionIndex = secIndex
+      this.selectedRows = []
+      this.selectorVisible = true
+    },
+    getSubjectQuestions() {
+      this.dataList = []
+      this.loading = true
+      this.queryParams.subjectId = this.paperForm.subjectId
+      getQuestions(this.queryParams).then(resp => {
+        if (resp.code === 0) {
+          this.dataList = resp.data.list
+          this.totalSize = resp.data.totalSize
+          this.loading = false
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      }).catch(error => {
+        this.$message.error(error.message)
+      })
+    },
+    onQuestionTypeChange() {
+      this.getSubjectQuestions()
+    },
+    handleSelectionChange(val) {
+      this.selectedRows = val
+    },
+    handleCurrentChange(val) {
+      this.queryParams.pageNumber = val
+      this.getSubjectQuestions()
+    },
+    // 确认将勾选的题目插入到当前大题中
+    confirmInsertQuestions() {
+      if (this.selectedRows.length === 0) {
+        this.selectorVisible = false
+        return
+      }
+      const targetSec = this.paperForm.sections[this.currentSectionIndex]
+
+      // 1. 收集整张试卷里已经存在的所有 questionId(防止跨大题或同大题重复添加)
+      // 如果只想“当前大题内去重”,可以改为:const existingIds = targetSec.questions.map(q => q.questionId)
+      const existingIds = []
+      this.paperForm.sections.forEach(sec => {
+        sec.questions.forEach(q => {
+          existingIds.push(q.questionId)
+        })
+      })
+
+      let skipCount = 0 // 记录被忽略的重复题目数量
+
+      // 2. 深拷贝题目数据接入卷面,防止互串
+      this.selectedRows.forEach(row => {
+        // 判断是否已经存在
+        if (existingIds.includes(row.questionId)) {
+          skipCount++
+          return // 已存在,直接忽略
+        }
+
+        const copyItem = JSON.parse(JSON.stringify(row))
+        // 接入时如果是组合题,预先跑一次子分数同步
+        this.syncCompositeTotalScore(copyItem)
+        targetSec.questions.push(copyItem)
+      })
+
+      // 3. 友情提示反馈
+      if (skipCount > 0) {
+        this.$message.info(`成功导入 ${this.selectedRows.length - skipCount} 道题,忽略了 ${skipCount} 道已存在题目。`)
+      } else {
+        this.$message.success(`成功导入 ${this.selectedRows.length} 道题!`)
+      }
+
+      this.selectorVisible = false
+    },
+    removeQuestion(secIndex, qIndex) {
+      this.paperForm.sections[secIndex].questions.splice(qIndex, 1)
+    },
+    // 调整题目在当前大题内的先后顺序
+    moveQuestion(secIndex, qIndex, direction) {
+      const questions = this.paperForm.sections[secIndex].questions
+      const targetIndex = qIndex + direction
+      if (targetIndex < 0 || targetIndex >= questions.length) return
+
+      // 交换位置
+      const temp = questions[qIndex]
+      this.$set(questions, qIndex, questions[targetIndex])
+      this.$set(questions, targetIndex, temp)
+    },
+    // 提交试卷数据结构
+    // 统一重置页面所有数据状态
+    resetPaperPage() {
+      // 1. 重置基础表单字段和大题数组
+      this.paperForm = {
+        title: '',
+        subjectId: '',
+        sections: []
+      }
+
+      // 2. 重置弹窗查询、分页、列表及多选缓存
+      this.queryParams = {
+        pageNumber: 1,
+        subjectId: '',
+        type: '1'
+      }
+      this.currentPage = 1
+      this.pageSize = 10
+      this.totalSize = 0
+      this.dataList = []
+      this.selectedRows = []
+      this.searchKey = ''
+      this.currentSectionIndex = null
+
+      // 3. 移除 ElementUI 的表单校验残余红字
+      this.$nextTick(() => {
+        if (this.$refs.paperForm) {
+          this.$refs.paperForm.clearValidate()
+        }
+      })
+    },
+    // 提交试卷数据结构
+    submitPaper() {
+      this.$refs.paperForm.validate((valid) => {
+        if (!valid) return
+        if (this.paperForm.sections.length === 0) {
+          return this.$message.error('试卷至少需要包含一个大题分块!')
+        }
+
+        // 检查是否有大题未配置任何题目
+        const hasEmptySec = this.paperForm.sections.some(s => s.questions.length === 0)
+        if (hasEmptySec) {
+          return this.$message.error('存在没有添加题目的空大题,请检查或移除!')
+        }
+
+        addPaper(this.paperForm).then(resp => {
+          if (resp.code === 0) {
+            this.$message.success('试卷组装完毕,已成功保存至系统!')
+            // 【核心改动】成功后执行重置
+            this.resetPaperPage()
+          } else {
+            this.$message.warning(resp.msg)
+          }
+        }).catch(error => {
+          this.$message.error(error.message)
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.paper-assembly-container {
+  background: #fff;
+  padding: 10px;
+}
+.score-summary-zone {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  height: 47px;
+}
+.total-score-badge {
+  background-color: #f0f2f5;
+  padding: 10px 20px;
+  border-radius: 20px;
+  font-size: 16px;
+  font-weight: bold;
+  color: #606266;
+}
+.total-score-badge span {
+  color: #f56c6c;
+  font-size: 24px;
+  margin: 0 4px;
+}
+.sections-wrapper {
+  margin-top: 30px;
+}
+.section-main-title {
+  margin: 0;
+  font-size: 16px;
+  color: #303133;
+}
+.section-card {
+  border: 1px solid #dcdfe6;
+  border-radius: 6px;
+  margin-bottom: 25px;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.02);
+}
+.section-card-header {
+  background-color: #f5f7fa;
+  padding: 12px 15px;
+  border-bottom: 1px solid #dcdfe6;
+  border-top-left-radius: 5px;
+  border-top-right-radius: 5px;
+}
+.sec-num-text {
+  font-weight: bold;
+  color: #909399;
+}
+.sec-score-text {
+  font-size: 14px;
+  color: #606266;
+}
+.sec-score-text strong {
+  color: #409eff;
+  font-size: 16px;
+}
+.section-card-body {
+  padding: 15px;
+}
+/* 组卷表格核心控制排版 */
+.question-assemble-table {
+  width: 100%;
+  border-collapse: collapse;
+}
+.question-assemble-table th, .question-assemble-table td {
+  border: 1px solid #ebeef5;
+  padding: 10px;
+  font-size: 14px;
+  text-align: left;
+}
+.question-assemble-table th {
+  background-color: #fafafa;
+  color: #606266;
+}
+.table-q-content {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  color: #303133;
+}
+/* 复合组合题试卷微调盒子 */
+.table-sub-children-box {
+  margin-top: 10px;
+  background-color: #fdf6ec;
+  border: 1px dashed #e6a23c;
+  padding: 8px 12px;
+  border-radius: 4px;
+}
+.sub-child-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 4px 0;
+  font-size: 12px;
+  color: #e6a23c;
+}
+.text-center { text-align: center !important; }
+.text-right { text-align: right !important; }
+.font-bold { font-weight: bold; }
+.help-text { color: #909399; font-size: 11px; display: block; margin-top: 2px; text-align: center; }
+.paper-footer-bar {
+  margin-top: 40px;
+  padding: 20px 0;
+  border-top: 1px solid #ebeef5;
+  text-align: center;
+}
+</style>

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

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

+ 271 - 0
src/views/exam/ExamPaperList.vue

@@ -0,0 +1,271 @@
+<template>
+  <el-container>
+    <el-header height="220">
+      <el-row>
+        <el-select
+          v-model="queryInfo.subjectId"
+          clearable
+          placeholder="请选择科目"
+          style="margin-left: 5px"
+          @change="subjectChange"
+        >
+          <el-option
+            v-for="item in allSubject"
+            :key="item.label"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+        <el-button type="plain" icon="el-icon-plus" style="margin-left: 5px" @click="addExamPaper">添加</el-button>
+      </el-row>
+    </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="paperName"
+          label="试卷名称"
+          width="150"
+        />
+        <el-table-column
+          prop="subject"
+          label="所属科目"
+          width="90"
+        />
+        <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="scope"
+          label="试卷用户"
+        >
+          <template slot-scope="scope">
+            <el-tag
+              v-if="scope.row.scope === 1"
+              size="mini"
+            >所有用户</el-tag>
+            <el-tag
+              v-else
+              size="mini"
+              type="success"
+            >VIP用户</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="markType"
+          label="阅卷类型"
+        >
+          <template slot-scope="scope">
+            <el-tag
+              v-if="scope.row.markType === 1"
+              size="mini"
+            >管理员阅卷</el-tag>
+            <el-tag
+              v-else
+              size="mini"
+              type="success"
+            >自主阅卷</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="测评码"
+        >
+          <template slot-scope="scope">
+            <el-tag
+              v-if="scope.row.passcode === undefined"
+              size="mini"
+            >不需要</el-tag>
+            <el-tag
+              v-else
+              size="mini"
+              type="success"
+            >{{ scope.row.passcode }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="状态"
+        >
+          <template slot-scope="scope">
+            <el-tag
+              v-if="scope.row.expired"
+              size="mini"
+              type="danger"
+            >已过期</el-tag>
+            <el-tag
+              v-else
+              size="mini"
+              type="success"
+            >未过期</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          fixed="right"
+          label="操作"
+          width="320"
+        >
+          <template slot-scope="scope">
+            <el-button
+              size="mini"
+              type="warning"
+              @click="previewPaper(scope.$index, scope.row)"
+            >预览</el-button>
+            <el-button
+              size="mini"
+              type="warning"
+              @click="handleDelete(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-main>
+  </el-container>
+</template>
+
+<script>
+import { getSubjectKV, getPapers, deletePaper } from '@/api/exam'
+
+export default {
+  name: 'ExamPaperList',
+  data() {
+    return {
+      // 屏幕宽度, 为了控制分页条的大小
+      screenWidth: document.body.clientWidth,
+      currentPage: 1,
+      pageSize: 20,
+      totalSize: 0,
+      dataList: [],
+      // **********************************************************************
+      queryInfo: {
+        subjectId: null,
+        pageNumber: 1,
+        pageSize: 10
+      },
+      allSubject: []
+    }
+  },
+  created() {
+    document.title = '试卷管理'
+    this.getData(this.queryInfo)
+    this.getSubjects()
+  },
+  methods: {
+    handleCurrentChange(pageNumber) {
+      this.currentPage = pageNumber
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getData(this.queryInfo)
+      // 回到顶部
+      scrollTo(0, 0)
+    },
+    getData(queryInfo) {
+      getPapers(queryInfo).then(resp => {
+        if (resp.code === 0) {
+          this.dataList = resp.data.list
+          this.totalSize = resp.data.totalSize
+        } else {
+          this.$notify({
+            title: '提示',
+            message: resp.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+    },
+    getSubjects() {
+      getSubjectKV().then((resp) => {
+        if (resp.code === 0) {
+          this.allSubject = resp.data
+        }
+      })
+    },
+    // 题库变化
+    subjectChange(val) {
+      this.queryInfo.subjectId = val
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getData(this.queryInfo)
+    },
+    handleDelete(index, row) {
+      this.$confirm('确定要删除 ' + row.paperName + '?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        deletePaper(row.paperId).then(resp => {
+          if (resp.code === 0) {
+            this.$notify({
+              title: '提示',
+              message: '稿件已删除',
+              type: 'warning',
+              duration: 3000
+            })
+            this.$router.go(0)
+          }
+        })
+      }).catch(() => {
+        this.$message({
+          type: 'info',
+          message: '已取消'
+        })
+      })
+    },
+    addExamPaper() {
+      this.$router.push('/exam/paper/add')
+    },
+    previewPaper(index, row) {
+      const paperId = row.paperId
+      const routeUrl = this.$router.resolve({
+        path: '/exam/paper/detail',
+        query: {
+          paperId: paperId,
+          viewType: 1,
+          userId: ''
+        }
+      })
+      window.open(routeUrl.href, '_blank')
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 979 - 0
src/views/exam/ExamQuestion.vue

@@ -0,0 +1,979 @@
+<template>
+  <el-container>
+    <el-header height="220">
+      <el-row>
+        <el-select
+          v-model="queryInfo.subjectId"
+          clearable
+          placeholder="请选择科目"
+          style="margin-left: 5px"
+          @change="subjectChange"
+        >
+          <el-option
+            v-for="item in allSubject"
+            :key="item.label"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+        <el-select v-model="queryInfo.type" clearable placeholder="请选择试题类型" @change="typeChange">
+          <el-option
+            v-for="item in allType"
+            :key="item.label"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+        <el-input
+          v-model="queryInfo.questionContent"
+          placeholder="题目内容"
+          style="margin-left: 5px;width: 220px"
+          prefix-icon="el-icon-search"
+          @blur="getQuestionInfo"
+        />
+      </el-row>
+      <el-row style="margin-top: 10px">
+        <el-button type="plain" icon="el-icon-plus" @click="addExamQuestion">添加</el-button>
+      </el-row>
+    </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="subject"
+          label="所属科目"
+        />
+        <el-table-column
+          prop="type"
+          label="试题类型"
+        />
+        <el-table-column
+          prop="content"
+          label="试题内容"
+          :show-overflow-tooltip="true"
+        >
+          <template slot-scope="scope">
+            <el-tooltip
+              :content="scope.row.content"
+              raw-content
+              placement="top-start"
+            >
+              <span v-if="scope.row.content.length <= 15">
+                <span v-html="scope.row.content" />
+              </span>
+              <span v-else>
+                {{ scope.row.content.substr(0, 15) + "..." }}
+              </span>
+            </el-tooltip>
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="level"
+          label="试题难度"
+        />
+        <el-table-column
+          prop="createBy"
+          label="创建人"
+        />
+        <el-table-column
+          prop="createAt"
+          label="创建时间"
+        />
+        <el-table-column label="操作">
+          <template slot-scope="scope">
+            <el-button
+              size="mini"
+              type="info"
+              @click="viewDetail(scope.row.questionId)"
+            >查看</el-button>
+            <el-button
+              size="mini"
+              type="danger"
+              @click="onDeleteQuestion(scope.row.questionId)"
+            >删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        style="margin-top: 25px"
+        :small="screenWidth <= 768"
+        layout="total, sizes, prev, pager, next, jumper"
+        :page-sizes="[10, 20, 50]"
+        :page-size="pageSize"
+        :current-page="currentPage"
+        :total="totalSize"
+        @current-change="handleCurrentChange"
+        @prev-click="handleCurrentChange"
+        @next-click="handleCurrentChange"
+      />
+    </el-main>
+
+    <el-dialog title="更新试题" :visible.sync="updateQuTableVisible" width="50%" center>
+      <el-card>
+        <el-form ref="updateQuForm" :model="updateQuForm" :rules="updateQuFormRules">
+          <el-form-item label="试题类型" label-width="120px" prop="questionType">
+            <el-select
+              v-model="updateQuForm.questionType"
+              disabled
+              placeholder="请选择"
+              @change="updateQuForm.answer = []"
+            >
+              <el-option
+                v-for="item in questionType"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+
+          <el-form-item label="难度等级" label-width="120px" prop="questionLevel">
+            <el-select v-model="updateQuForm.questionLevel" placeholder="请选择">
+              <el-option :value="parseInt(1)" label="简单" />
+              <el-option :value="parseInt(2)" label="中等" />
+              <el-option :value="parseInt(3)" label="困难" />
+            </el-select>
+          </el-form-item>
+
+          <el-form-item label="归属题库" label-width="120px" prop="subjectId">
+            <el-select v-model="updateQuForm.subjectId" multiple placeholder="请选择">
+              <el-option
+                v-for="item in allSubject"
+                :key="item.label"
+                :label="item.label"
+                :value="item.value"
+              />
+            </el-select>
+          </el-form-item>
+
+          <el-form-item label="试题内容" label-width="120px" prop="questionContent">
+            <el-input
+              v-model="updateQuForm.questionContent"
+              style="margin-left: 5px"
+              type="textarea"
+              :rows="2"
+            />
+          </el-form-item>
+
+          <el-form-item label="试题图片" label-width="120px">
+            <el-upload
+              :action="uploadImageUrl + '/teacher/uploadQuestionImage'"
+              :on-preview="uploadPreview"
+              :on-remove="handleUpdateRemove"
+              :before-upload="beforeAvatarUpload"
+              list-type="picture"
+              :on-success="updateUploadImgSuccess"
+              name="file"
+            >
+              <el-button size="small" type="primary">点击上传</el-button>
+              <div slot="tip" class="el-upload__tip">只能上传jpg/png文件,且不超过10M</div>
+            </el-upload>
+          </el-form-item>
+
+          <el-form-item label="试题解析" label-width="120px" prop="analysis">
+            <el-input
+              v-model="updateQuForm.analysis"
+              style="margin-left: 5px"
+              type="textarea"
+              :rows="2"
+            />
+          </el-form-item>
+
+          <el-button
+            v-if="updateQuForm.questionType!==4"
+            type="primary"
+            plain
+            size="small"
+            icon="el-icon-plus"
+            style="margin-left: 40px"
+            @click="addUpdateAnswer"
+          >
+            添加
+          </el-button>
+
+          <!--存放答案表单的信息-->
+          <el-form-item v-if="updateQuForm.questionType !== 4" prop="answer">
+            <el-table :data="updateQuForm.answer" border style="width: 96%;margin-left: 40px;margin-top: 10px">
+
+              <el-table-column label="是否答案" width="80" align="center">
+                <template slot-scope="scope">
+                  <el-checkbox v-model="scope.row.correct" @change="checked=>checkUpdateAnswer(checked,scope.row.id)">答案
+                  </el-checkbox>
+                </template>
+              </el-table-column>
+
+              <el-table-column label="答案图片">
+                <template slot-scope="scope">
+                  <el-upload
+                    id="answerUpload"
+                    :limit="1"
+                    :action="uploadImageUrl + '/teacher/uploadQuestionImage'"
+                    :on-preview="uploadPreview"
+                    :on-remove="handleUpdateAnswerRemove"
+                    :before-upload="beforeAvatarUpload"
+                    list-type="picture"
+                    :on-success="(res) => { return uploadUpdateAnswerImgSuccess(res,scope.row.id)}"
+                    name="file"
+                  >
+                    <el-button size="small" type="primary">点击上传</el-button>
+                  </el-upload>
+                </template>
+
+              </el-table-column>
+
+              <el-table-column label="答案内容">
+                <template slot-scope="scope">
+                  <el-input
+                    v-model="scope.row.answer"
+                    style="margin-left: 5px"
+                    type="textarea"
+                    :rows="2"
+                  />
+                </template>
+              </el-table-column>
+
+              <el-table-column label="答案解析">
+                <template slot-scope="scope">
+                  <el-input
+                    v-model="scope.row.analysis"
+                    style="margin-left: 5px"
+                    type="textarea"
+                    :rows="2"
+                  />
+                </template>
+              </el-table-column>
+
+              <el-table-column label="操作" width="80" align="center">
+                <template slot-scope="scope">
+                  <el-button
+                    type="danger"
+                    icon="el-icon-delete"
+                    circle
+                    @click="delUpdateAnswer(scope.row.id)"
+                  />
+                </template>
+              </el-table-column>
+
+            </el-table>
+          </el-form-item>
+
+        </el-form>
+
+      </el-card>
+
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="updateQuTableVisible = false">取 消</el-button>
+        <el-button type="primary" @click="updateQuestion">确 定</el-button>
+      </div>
+    </el-dialog>
+    <question-preview-dialog ref="previewDialog" />
+    <el-dialog
+      title="加入题库"
+      :visible.sync="addTableVisible"
+      width="30%"
+      center
+      @close="resetAddForm"
+    >
+
+      <el-form ref="addForm" :model="addForm" :rules="addFormRules">
+
+        <el-form-item label="题库名称" label-width="120px" prop="subjectId">
+
+          <el-select v-model="addForm.subjectId" multiple placeholder="请选择题库">
+            <el-option
+              v-for="item in allSubject"
+              :key="item.label"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+
+        </el-form-item>
+      </el-form>
+
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="addTableVisible = false">取 消</el-button>
+        <el-button type="primary" @click="addBank">确 定</el-button>
+      </div>
+    </el-dialog>
+    <el-dialog
+      title="从题库中移除"
+      :visible.sync="removeTableVisible"
+      width="30%"
+      center
+      @close="resetRemoveForm"
+    >
+
+      <el-form ref="removeForm" :model="removeForm" :rules="removeFormRules">
+        <el-form-item label="题库名称" label-width="120px" prop="subjectId">
+          <el-select v-model="removeForm.subjectId" multiple placeholder="请选择题库">
+            <el-option
+              v-for="item in allSubject"
+              :key="item.label"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+
+        </el-form-item>
+      </el-form>
+
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="removeTableVisible = false">取 消</el-button>
+        <el-button type="primary" @click="removeBank">确 定</el-button>
+      </div>
+    </el-dialog>
+
+    <!--图片回显-->
+    <el-dialog :visible.sync="backShowImgVisible" @close="backShowImgVisible = false">
+      <img style="width: 100%" :src="backShowImgUrl" alt="">
+    </el-dialog>
+  </el-container>
+</template>
+
+<script>
+import QuestionPreviewDialog from './QuestionPreviewDialog.vue'
+import {
+  getSubjectKV,
+  addQuestion,
+  deleteQuestion,
+  getQuestions, getQuestionType, getQuestion
+} from '@/api/exam'
+import { validFormAndInvoke } from '@/utils/util'
+
+export default {
+  name: 'ExamQuestionList',
+  components: { QuestionPreviewDialog },
+  data() {
+    return {
+      radio: '',
+      // 屏幕宽度, 为了控制分页条的大小
+      screenWidth: document.body.clientWidth,
+      currentPage: 1,
+      pageSize: 20,
+      totalSize: 0,
+      dataList: [],
+      // **********************************************************************
+      uploadImageUrl: '/',
+      // 查询用户的参数
+      queryInfo: {
+        subjectId: null,
+        type: null,
+        level: null,
+        pageNumber: 1,
+        pageSize: 10
+      },
+      // 试题类型
+      questionType: [
+        { id: 1, name: '单选题' },
+        { id: 2, name: '多选题' },
+        { id: 3, name: '判断题' },
+        { id: 4, name: '填空题' },
+        { id: 5, name: '问答题' },
+        { id: 6, name: '组合题' }
+      ],
+      // 科目信息
+      allSubject: [],
+      allType: [],
+      // 试题信息
+      questionInfo: [],
+      // 试题信息表格是否加载
+      loading: true,
+      // 表格被选中的所有行
+      selectionTable: [],
+      // 表格行被选中后的所有操作方式的数据
+      optionInfo: [
+        {
+          'label': '删除',
+          'desc': 'delete'
+        },
+        {
+          'label': '加入题库',
+          'desc': 'add'
+        },
+        {
+          'label': '题库中移除',
+          'desc': 'remove'
+        }
+      ],
+      // 表格行被选中后的数据
+      operation: '',
+      // 试题总数
+      total: 0,
+      // 是否显示加入题库对话框
+      addTableVisible: false,
+      // 是否显示移除题库对话框
+      removeTableVisible: false,
+      // 是否显示添加试题的对话框
+      addQuTableVisible: false,
+      // 添加题库的表单信息
+      addForm: {
+        subjectId: ''
+      },
+      removeForm: {
+        subjectId: ''
+      },
+      // 添加题库表单的验证
+      addFormRules: {
+        subjectId: [
+          {
+            required: true,
+            message: '请选择需要添加进的题库',
+            trigger: 'blur'
+          }
+        ]
+      },
+      // 移除题库表单的验证
+      removeFormRules: {
+        subjectId: [
+          {
+            required: true,
+            message: '请选择需要移除的题库',
+            trigger: 'blur'
+          }
+        ]
+      },
+      // 添加试题的表单信息
+      addQuForm: {
+        questionType: 1,
+        questionLevel: 1,
+        subjectId: '',
+        questionContent: '',
+        images: [],
+        analysis: '',
+        createPerson: localStorage.getItem('username'),
+        // 答案对象
+        options: [],
+        children: []
+      },
+      questionForm: {
+        questionType: 1,
+        questionLevel: 1,
+        subjectId: '',
+        questionContent: '',
+        images: [],
+        analysis: '',
+        // 答案对象
+        options: []
+      },
+      // 添加试题表单的验证规则
+      addQuFormRules: {
+        questionType: [
+          {
+            required: true,
+            message: '请选择问题类型',
+            trigger: 'blur'
+          }
+        ],
+        questionLevel: [
+          {
+            required: true,
+            message: '请选择问题难度',
+            trigger: 'blur'
+          }
+        ],
+        subjectId: [
+          {
+            required: true,
+            message: '请选择题库',
+            trigger: 'blur'
+          }
+        ],
+        questionContent: [
+          {
+            required: true,
+            message: '请输入题库内容',
+            trigger: 'blur'
+          }
+        ]
+      },
+      // 更新试题表单的验证规则
+      updateQuFormRules: {
+        questionType: [
+          {
+            required: true,
+            message: '请选择问题类型',
+            trigger: 'blur'
+          }
+        ],
+        questionLevel: [
+          {
+            required: true,
+            message: '请选择问题难度',
+            trigger: 'blur'
+          }
+        ],
+        subjectId: [
+          {
+            required: true,
+            message: '请选择题库',
+            trigger: 'blur'
+          }
+        ],
+        questionContent: [
+          {
+            required: true,
+            message: '请输入题库内容',
+            trigger: 'blur'
+          }
+        ]
+      },
+      // 图片回显的样式
+      backShowImgVisible: false,
+      // 图片回显的图片地址
+      backShowImgUrl: '',
+      // 更新试题的数据信息
+      updateQuForm: {
+        questionId: '',
+        questionType: 1,
+        questionLevel: 1,
+        subjectId: '',
+        questionContent: '',
+        images: [],
+        analysis: '',
+        createPerson: localStorage.getItem('username'),
+        // 答案对象
+        answer: []
+      },
+      // 是否显示更新试题的对话框
+      updateQuTableVisible: false,
+      questionDetail: {}
+    }
+  },
+  created() {
+    document.title = '题库管理'
+    this.getSubjects()
+    this.getQuestionTypes()
+    this.getData(this.searchForm)
+  },
+  methods: {
+    renderByMathjax() {
+      // tinymce 的 mathjax 插件生成的 latex 格式公式放在 className 为 math-tex 的 span 标签内
+      const className = 'math-tex'
+      this.$nextTick(function() {
+        if (this.MathJax.isMathjaxConfig) {
+          this.MathJax.initMathjaxConfig()
+        }
+        this.MathJax.MathQueue1(className)
+      })
+    },
+    getData() {
+      this.dataList = []
+      getQuestions(this.queryInfo).then(resp => {
+        if (resp.code === 0) {
+          this.dataList = resp.data.list
+          this.totalSize = resp.data.totalSize
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      }).catch(error => {
+        this.$message.info(error.message)
+      })
+    },
+    getQuestionTypes() {
+      getQuestionType().then((resp) => {
+        if (resp.code === 0) {
+          this.allType = resp.data
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      })
+    },
+    // 获取所有的题库信息
+    getSubjects() {
+      getSubjectKV().then((resp) => {
+        if (resp.code === 0) {
+          this.allSubject = resp.data
+        }
+      })
+    },
+    // 试题类型变化
+    typeChange(val) {
+      this.queryInfo.type = val
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getQuestionInfo()
+    },
+    // 题库变化
+    subjectChange(val) {
+      this.queryInfo.subjectId = val
+      this.queryInfo.pageNumber = this.currentPage
+      this.queryInfo.pageSize = this.pageSize
+      this.getQuestionInfo()
+    },
+    // 试题名字筛选
+    contentChange() {
+      // 发送查询试题总数的请求
+      this.getQuestionInfo()
+    },
+    // 获取试题信息
+    getQuestionInfo() {
+      this.getData()
+    },
+    // 处理表格被选中
+    handleTableSectionChange(val) {
+      this.selectionTable = val
+    },
+    // 处理操作选择框的变化
+    operationChange(val) {
+      // 清空上一次的选择
+      this.operation = ''
+
+      const questionIds = []
+      if (val === 'delete') {
+        this.selectionTable.map(item => {
+          questionIds.push(item.id)
+        })
+        // 发起删除请求
+        deleteQuestion({ 'questionIds': questionIds.join(',') }).then(resp => {
+          if (resp.code === 0) {
+            this.getQuestionInfo()
+            this.$message.info('删除成功')
+          }
+        })
+      } else if (val === 'add') {
+        this.addTableVisible = true
+      } else if (val === 'remove') {
+        this.removeTableVisible = true
+      }
+    },
+    // 分页页面大小改变
+    handleSizeChange(val) {
+      this.queryInfo.pageSize = val
+      this.getQuestionInfo()
+    },
+    // 分页插件的页数
+    handleCurrentChange(val) {
+      this.queryInfo.pageNo = val
+      this.getQuestionInfo()
+    },
+    // 表单信息重置
+    resetAddForm() {
+      // 清空表格数据
+      this.$refs['addForm'].resetFields()
+    },
+    resetRemoveForm() {
+      // 清空表格数据
+      this.$refs['removeForm'].resetFields()
+    },
+    resetAddQuForm() {
+      this.$refs['addQuForm'].resetFields()
+    },
+    // 提交加入题库的表单信息
+    addBank() {
+      validFormAndInvoke(this.$refs['addForm'], () => {
+        const questionIds = []
+        const banks = this.addForm.subjectId
+        // 将表格选中的数据中的问题id加入进去
+        this.selectionTable.map(item => {
+          questionIds.push(item.id)
+        })
+        addBankQuestion({
+          'questionIds': questionIds.join(','),
+          'banks': banks.join(',')
+        }).then((resp) => {
+          if (resp.code === 0) {
+            this.getQuestionInfo()
+            this.$message.info(resp.msg)
+          }
+          this.addTableVisible = false
+        })
+      }, '请选择需要加入进的题库')
+    },
+    // 提交移除题库的表单信息
+    removeBank() {
+      validFormAndInvoke(this.$refs['removeForm'], () => {
+        const questionIds = []
+        const banks = this.removeForm.subjectId
+        // 将表格选中的数据中的问题id加入进去
+        this.selectionTable.map(item => {
+          questionIds.push(item.id)
+        })
+        // 发起移除请求
+        questionBank.removeBankQuestion({
+          'questionIds': questionIds.join(','),
+          'banks': banks.join(',')
+        }).then((resp) => {
+          if (resp.code === 0) {
+            this.getQuestionInfo()
+            this.$message.info(resp.msg)
+          }
+          this.removeTableVisible = false
+        })
+      }, '请选择需要移除的题库')
+    },
+    // 新增试题上传后 点击图片的回显
+    uploadPreview(file) {
+      this.backShowImgUrl = file.response.data
+      this.backShowImgVisible = true
+    },
+    // 新增试题中的上传图片的移除
+    handleRemove(file, fileList) {
+      this.addQuForm.images.map((item, index) => { // 移除图片在表单中的数据
+        if (item === file.response.data) this.addQuForm.images.splice(index, 1)
+      })
+    },
+    // 更新试题中的上传图片的移除
+    handleUpdateRemove(file, fileList) {
+      this.updateQuForm.images.map((item, index) => { // 移除图片在表单中的数据
+        if (item === file.response.data) this.updateQuForm.images.splice(index, 1)
+      })
+    },
+    // 新增试题中的上传图片的格式大小限制
+    beforeAvatarUpload(file) {
+      const isImg = file.type === 'image/jpeg' ||
+        file.type === 'image/png' ||
+        file.type === 'image/jpg'
+      const isLt = file.size / 1024 / 1024 < 10
+
+      if (!isImg) {
+        this.$message.error('上传图片只能是 JPG/PNG 格式!')
+      }
+
+      if (!isLt) {
+        this.$message.error('上传头像图片大小不能超过 10MB!')
+      }
+      return isImg && isLt
+    },
+    // 新增试题中的上传图片成功后的钩子函数
+    uploadImgSuccess(response, file, fileList) {
+      this.addQuForm.images.push(response.data)
+    },
+    // 更新试题中的上传图片成功后的钩子函数
+    updateUploadImgSuccess(response, file, fileList) {
+      this.updateQuForm.images.push(response.data)
+    },
+    // 新增试题中的新增答案填写框
+    addAnswer() {
+      this.addQuForm.options.push({
+        id: this.addQuForm.options.length,
+        correct: false,
+        content: '',
+        images: [],
+        analysis: ''
+      })
+    },
+    // 更新时新增试题中的新增答案填写框
+    addUpdateAnswer() {
+      this.updateQuForm.answer.push({
+        id: this.updateQuForm.answer.length,
+        correct: false,
+        answer: '',
+        images: [],
+        analysis: ''
+      })
+    },
+    // 新增试题中的删除答案填写框
+    delAnswer(id) { // 当前答案的id
+      this.addQuForm.options.map((item, index) => {
+        if (item.id === id) this.addQuForm.options.splice(index, 1)
+      })
+    },
+    // 新增试题中的删除答案填写框
+    delUpdateAnswer(id) { // 当前答案的id
+      this.updateQuForm.answer.map((item, index) => {
+        if (item.id === id) this.updateQuForm.answer.splice(index, 1)
+      })
+    },
+    // 答案上传照片了
+    uploadAnswerImgSuccess(response, id) {
+      this.addQuForm.options[id].images.push(response.data)
+    },
+    // 更新的答案上传图片了
+    uploadUpdateAnswerImgSuccess(response, id) {
+      this.updateQuForm.answer[id].images.push(response.data)
+    },
+    // 答案上传成功后删除
+    handleAnswerRemove(file) {
+      this.addQuForm.options.images.map((item, index) => { // 移除图片在表单中的数据
+        if (item === file.response.data) this.addQuForm.images.splice(index, 1)
+      })
+    },
+    // 更新的时候答案上传成功后删除
+    handleUpdateAnswerRemove(file) {
+      this.updateQuForm.answer.images.map((item, index) => { // 移除图片在表单中的数据
+        if (item === file.response.data) this.updateQuForm.images.splice(index, 1)
+      })
+    },
+    // 选择正确答案的按钮变化事件
+    checkAnswer(checked, id) {
+      if (checked) {
+        if (this.addQuForm.questionType === 1 || this.addQuForm.questionType === 3) { // 单选或者判断
+          // 当前已经有一个正确的选择了
+          this.addQuForm.options.map(item => {
+            item.correct = false
+          })
+          this.addQuForm.options.map(item => {
+            if (item.id === id) item.correct = true
+          })
+        } else { // 多选 可以有多个答案
+          this.addQuForm.options.map(item => {
+            if (item.id === id) item.correct = true
+          })
+        }
+      } else {
+        this.addQuForm.options.map(item => {
+          if (item.id === id) item.correct = false
+        })
+      }
+    },
+    // 更新时选择正确答案的按钮变化事件
+    checkUpdateAnswer(checked, id) {
+      if (checked) {
+        if (this.updateQuForm.questionType === 1 || this.updateQuForm.questionType === 3) { // 单选或者判断
+          // 当前已经有一个正确的选择了
+          this.updateQuForm.answer.map(item => {
+            item.correct = false
+          })
+          this.updateQuForm.answer.map(item => {
+            if (item.id === id) item.correct = true
+          })
+        } else { // 多选 可以有多个答案
+          this.updateQuForm.answer.map(item => {
+            if (item.id === id) item.correct = true
+          })
+        }
+      } else {
+        this.updateQuForm.answer.map(item => {
+          if (item.id === id) item.correct = false
+        })
+      }
+    },
+    // 新增试题
+    addQuestion() {
+      this.$refs['addQuForm'].validate((valid) => {
+        if (valid && this.addQuForm.options.some(item => item.correct) && this.addQuForm.questionType !== 4) { // 单选或者多选或者判断
+          addQuestion(this.addQuForm).then((resp) => {
+            if (resp.code === 0) {
+              this.addQuTableVisible = false
+              this.getData(this.searchForm)
+              this.$message.info('新增试题成功')
+            }
+          })
+        } else if (valid && !this.addQuForm.options.some(item => item.correct) && this.addQuForm.questionType < 5) { // 无答案
+          this.$message.error('必须有一个答案')
+          return false
+        } else if (valid && this.addQuForm.questionType >= 5 && this.addQuForm.questionType <= 7) { // 简答题 无标准答案直接发请求
+          // 当是简答题的时候需要清除answer
+          this.addQuForm.options = []
+          addQuestion(this.addQuForm).then((resp) => {
+            if (resp.code === 0) {
+              this.addQuTableVisible = false
+              this.getQuestionInfo()
+              this.$message.info('新增试题成功')
+            }
+          })
+        } else if (this.addQuForm.questionType > 7) { // 組题
+          // 当是简答题的时候需要清除answer
+          this.addQuForm.options = []
+          addQuestion(this.addQuForm).then((resp) => {
+            if (resp.code === 0) {
+              this.addQuTableVisible = false
+              this.getQuestionInfo()
+              this.$message.info('新增试题成功')
+            }
+          })
+        } else if (!valid) {
+          this.$message.error('请填写必要信息')
+          return false
+        }
+      })
+    },
+    // 更新试题
+    updateQu(id) {
+      getQuestionById(id).then((resp) => {
+        if (resp.code === 0) {
+          if (resp.data.questionType !== 4) {
+            resp.data.answer.map(item => {
+              item.correct = item.correct === 'true'
+            })
+          }
+          this.updateQuForm = resp.data
+          // 处理图片那个参数是个数组
+          if (this.updateQuForm.images === null) this.updateQuForm.images = []
+
+          if (resp.data.questionType !== 4) {
+            this.updateQuForm.answer.map(item => {
+              if (item.images === null) {
+                item.images = []
+              }
+            })
+          }
+        }
+      })
+      this.updateQuTableVisible = true
+    },
+    // 提交更新表单
+    updateQuestion() {
+      this.$refs['updateQuForm'].validate((valid) => {
+        if (valid && this.updateQuForm.questionType !== 4 && this.updateQuForm.answer.some(item => item.correct)) { // 单选或者多选或者判断
+          // 保证答案的图片只有一张
+          this.updateQuForm.answer.map(item => {
+            if (item.images.length > 1) {
+              item.images.splice(0, item.images.length - 1)
+            }
+          })
+          updateQuestion(this.updateQuForm).then((resp) => {
+            if (resp.code === 0) {
+              this.updateQuTableVisible = false
+              this.getQuestionInfo()
+              this.$message.info('更新试题成功')
+            }
+          })
+        } else if (valid && this.updateQuForm.questionType !== 4 && !this.updateQuForm.answer.some(item => item.correct)) { // 无答案
+          this.$message.error('必须有一个答案')
+          return false
+        } else if (valid && this.updateQuForm.questionType === 4) { // 简答题 无标准答案直接发请求
+          // 当是简答题的时候需要清除answer
+          this.addQuForm.options = []
+          updateQuestion(this.updateQuForm).then((resp) => {
+            if (resp.code === 0) {
+              this.updateQuTableVisible = false
+              this.getQuestionInfo()
+              this.$message.info('更新试题成功')
+            }
+          })
+        } else if (!valid) {
+          this.$message.error('请填写必要信息')
+          return false
+        }
+      })
+    },
+    viewDetail(questionId) {
+      getQuestion(questionId).then(resp => {
+        if (resp.code === 0) {
+          this.questionDetail = resp.data
+          // this.renderByMathjax()
+          this.$refs.previewDialog.open(this.questionDetail)
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      })
+    },
+    onDeleteQuestion(questionId) {
+      deleteQuestion(questionId).then(resp => {
+        if (resp.code === 0) {
+          this.$message.info('试题 ' + questionId + ' 已删除')
+          window.location.reload()
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      })
+    },
+    addExamQuestion() {
+      this.$router.push('/exam/question/add')
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 385 - 0
src/views/exam/ExamQuestionAdd.vue

@@ -0,0 +1,385 @@
+<template>
+  <el-container class="exam-form-container">
+    <el-main>
+      <el-page-header content="录入试题" @back="$emit('back')" />
+      <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-option
+                  v-for="item in allSubject"
+                  :key="item.label"
+                  :label="item.label"
+                  :value="item.value"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="大题型" prop="questionType">
+              <el-select v-model="form.questionType" placeholder="请选择" @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-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">
+          <el-input
+            v-model="form.questionContent"
+            type="textarea"
+            :rows="5"
+            placeholder="请输入题目内容。支持 LaTeX 公式,例如:已知函数 $f(x) = x^2 + 2x$..."
+            @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>
+
+        <div v-if="form.questionType >= 1 && form.questionType <= 4">
+          <el-divider content-position="left">配置选项 / 填空参考答案</el-divider>
+          <div style="margin-bottom: 15px;">
+            <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-column label="是否正确答案" width="120" align="center">
+              <template slot-scope="scope">
+                <el-checkbox v-model="scope.row.correct" @change="checked => handleCheckAnswer(checked, scope.row.id)" />
+              </template>
+            </el-table-column>
+            <el-table-column label="标识 (如A/B/C/空1)" width="150">
+              <template slot-scope="scope">
+                <el-input v-model="scope.row.prefix" size="small" placeholder="A" />
+              </template>
+            </el-table-column>
+            <el-table-column label="选项内容 / 填空参考值">
+              <template slot-scope="scope">
+                <el-input v-model="scope.row.answer" size="small" placeholder="支持公式" />
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="80" align="center">
+              <template slot-scope="scope">
+                <el-button type="danger" icon="el-icon-delete" circle size="mini" @click="delFormAnswer(scope.row.id)" />
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+
+        <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>
+
+          <el-collapse v-model="activeCollapsePanels">
+            <el-collapse-item
+              v-for="(sub, index) in form.children"
+              :key="sub.localId"
+              :name="sub.localId"
+            >
+              <template slot="title">
+                <span class="sub-title-text">
+                  第 ({{ index + 1 }}) 问:【{{ getTypeName(sub.questionType) }}】 - 分值: {{ sub.score }}分
+                </span>
+              </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-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-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;">
+                    <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-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)" />
+                      </template>
+                    </el-table-column>
+                    <el-table-column label="标识" width="80">
+                      <template slot-scope="scope">
+                        <el-input v-model="scope.row.prefix" size="mini" />
+                      </template>
+                    </el-table-column>
+                    <el-table-column label="选项文本">
+                      <template slot-scope="scope">
+                        <el-input v-model="scope.row.answer" size="mini" />
+                      </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)" />
+                      </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>
+              </div>
+            </el-collapse-item>
+          </el-collapse>
+        </div>
+
+        <el-divider />
+        <el-form-item :label="form.questionType === 6 ? '总题目解析' : '试题解析'" prop="analysis">
+          <el-input v-model="form.analysis" type="textarea" :rows="3" 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>
+      </el-form>
+    </el-main>
+  </el-container>
+</template>
+
+<script>
+import { addQuestion, getSubjectKV } from '@/api/exam'
+
+export default {
+  name: 'ExamQuestionAdd',
+  data() {
+    return {
+      allSubject: [],
+      activeCollapsePanels: [], // 控制组合题子问卡片的展开折叠
+      form: {
+        subjectId: '',
+        questionType: 1,
+        questionLevel: 1,
+        questionContent: '',
+        analysis: '',
+        answer: [], // 存储原子题选项
+        children: [] // 存储复合题子小题
+      },
+      rules: {
+        subjectId: [{ required: true, message: '请选择所属科目', trigger: 'change' }],
+        questionType: [{ required: true, message: '请选择试题类型', trigger: 'change' }],
+        questionContent: [{ required: true, message: '请输入题干内容', trigger: 'blur' }]
+      },
+      renderTimer: null
+    }
+  },
+  created() {
+    document.title = '录入试题'
+    this.getSubjects()
+  },
+  methods: {
+    getSubjects() {
+      getSubjectKV().then((resp) => {
+        if (resp.code === 0) {
+          this.allSubject = resp.data
+        }
+      })
+    },
+    getTypeName(type) {
+      const names = { 1: '单选题', 2: '多选题', 4: '填空题', 5: '简答题' }
+      return names[type] || '未知题型'
+    },
+    handleMainTypeChange() {
+      this.form.answer = []
+      this.form.children = []
+    },
+    // --- 原子题答案行逻辑 ---
+    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: ''
+      })
+    },
+    delFormAnswer(id) {
+      this.form.answer = this.form.answer.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 })
+      }
+    },
+    // --- 复合大题子问级逻辑 ---
+    addSubQuestion() {
+      const localId = 'sub_' + Date.now() + Math.floor(Math.random() * 100)
+      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: '' }
+        ]
+      })
+      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: ''
+      })
+    },
+    delSubAnswer(sub, index) {
+      sub.answer.splice(index, 1)
+    },
+    handleCheckSubAnswer(checked, optId, sub) {
+      if (checked && sub.questionType === 1) { // 子题为单选时排他
+        sub.answer.forEach(opt => { if (opt.id !== optId) opt.correct = false })
+      }
+    },
+    // --- LaTeX 公式防抖渲染引擎 ---
+    lazyRenderFormula() {
+      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))
+          }
+        })
+      }, 400)
+    },
+    // --- 严谨的高考业务层数据校验 ---
+    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('请勾选设置至少一个正确答案!')
+        }
+
+        // 2. 复合题深层业务校验
+        if (this.form.questionType === 6) {
+          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}) 问小题未设置正确选项,请先勾选正确选项!`)
+            }
+          }
+        }
+
+        // 校验通过,向后端派发整张试题数据树 payload
+        addQuestion(this.form).then(resp => {
+          if (resp.code === 0) {
+            this.$message.info('试题已添加')
+            this.resetForm()
+          } else {
+            this.$message.warning(resp.msg)
+          }
+        })
+      })
+    },
+    resetForm() {
+      // 核心:利用 Element UI 自带的方法清除绑定的基本字段以及校验红字
+      if (this.$refs['examForm']) {
+        this.$refs['examForm'].resetFields()
+      }
+
+      // 特别注意:针对嵌套的复杂数组对象,必须手动重置为干净的初始状态
+      this.form = {
+        subjectId: '',
+        questionType: 1, // 默认恢复到第一个“单选题”
+        questionLevel: 1, // 默认难度
+        questionContent: '',
+        analysis: '',
+        answer: [], // 清空普通题选项列表
+        children: [] // 清空复合题子问树
+      }
+
+      // 清空手风琴组件的展开记录
+      this.activeCollapsePanels = []
+    },
+    backToList() {
+      this.$router.push('/exam/question')
+    }
+  }
+}
+</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;
+}
+</style>

+ 29 - 0
src/views/exam/ExamScoreIndex.vue

@@ -0,0 +1,29 @@
+<template>
+  <el-container>
+    <el-main>
+      <span>my score</span>
+    </el-main>
+  </el-container>
+</template>
+
+<script>
+export default {
+  name: 'ExamScoreIndex',
+  data() {
+    return {
+    }
+  },
+  created() {
+    document.title = '我的成绩'
+    this.getData()
+  },
+  methods: {
+    getData() {
+      this.$message.info('获取成绩数据')
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 127 - 0
src/views/exam/ExamSubject.vue

@@ -0,0 +1,127 @@
+<template>
+  <el-container>
+    <el-header height="220">
+      <el-row style="margin-top: 10px">
+        <el-button type="plain" icon="el-icon-plus" @click="addSubjectVisible = true">添加</el-button>
+      </el-row>
+    </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="subjectName"
+          label="科目"
+        />
+        <el-table-column
+          prop="question7Count"
+          label="单选题"
+        />
+        <el-table-column
+          prop="question7Count"
+          label="多选题"
+        />
+        <el-table-column
+          prop="question7Count"
+          label="不定项选择题"
+        />
+        <el-table-column
+          prop="question7Count"
+          label="填空题"
+        />
+        <el-table-column
+          prop="question7Count"
+          label="判断题"
+        />
+        <el-table-column
+          prop="question7Count"
+          label="问答题"
+        />
+        <el-table-column
+          prop="question7Count"
+          label="理解题"
+        />
+        <el-table-column
+          prop="paperCount"
+          label="试卷"
+        />
+      </el-table>
+      <el-pagination
+        style="margin-top: 25px"
+        :small="screenWidth <= 768"
+        layout="total, sizes, prev, pager, next, jumper"
+        :page-sizes="[10, 20, 50]"
+        :page-size="pageSize"
+        :current-page="currentPage"
+        :total="totalSize"
+        @current-change="handleCurrentChange"
+        @prev-click="handleCurrentChange"
+        @next-click="handleCurrentChange"
+      />
+    </el-main>
+  </el-container>
+</template>
+
+<script>
+import { getSubject } from '@/api/exam'
+
+export default {
+  name: 'ExamSubject',
+  data() {
+    return {
+      // 屏幕宽度, 为了控制分页条的大小
+      screenWidth: document.body.clientWidth,
+      currentPage: 1,
+      pageSize: 20,
+      totalSize: 0,
+      dataList: [],
+      addSubjectVisible: false
+    }
+  },
+  created() {
+    document.title = '科目管理'
+    this.getData()
+  },
+  methods: {
+    handleCurrentChange(pageNumber) {
+      this.currentPage = pageNumber
+      this.getData()
+      // 回到顶部
+      scrollTo(0, 0)
+    },
+    getData() {
+      this.dataList = []
+      getSubject(this.currentPage).then(resp => {
+        if (resp.code === 0) {
+          this.dataList = resp.data.list
+          this.totalSize = resp.data.totalSize
+        } else {
+          this.$notify({
+            title: '提示',
+            message: resp.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 236 - 0
src/views/exam/QuestionPreviewDialog.vue

@@ -0,0 +1,236 @@
+<template>
+  <el-dialog
+    title="试题预览"
+    :visible.sync="visible"
+    width="800px"
+    top="5vh"
+    append-to-body
+    custom-class="question-preview-dialog"
+  >
+    <div v-if="questionData" class="preview-container">
+      <div class="meta-header">
+        <el-tag size="small" type="primary" effect="dark">{{ questionData.subject }}</el-tag>
+        <el-tag size="small" type="success" effect="plain" class="ml-10">{{ questionData.typeStr }}</el-tag>
+        <el-tag size="small" type="warning" effect="plain" class="ml-10">难度:{{ questionData.level }}</el-tag>
+        <span class="id-text">ID: {{ questionData.questionId }}</span>
+      </div>
+
+      <el-divider />
+
+      <div v-if="questionData.type !== 6" class="normal-question-zone">
+        <div class="question-content">{{ questionData.content }}</div>
+
+        <div v-if="questionData.options && questionData.options.length" class="options-list">
+          <div
+            v-for="opt in questionData.options"
+            :key="opt.option"
+            :class="['option-item', { 'is-correct': opt.correct }]"
+          >
+            <span v-if="opt.correct" class="option-tag">
+              <i class="el-icon-check" /> 正确答案
+            </span>
+            <div class="option-text">{{ opt.content }}</div>
+          </div>
+        </div>
+      </div>
+
+      <div v-else class="composite-question-zone">
+        <div class="material-title"><i class="el-icon-document" /> 阅读材料/题干:</div>
+        <div class="question-content material-box">{{ questionData.content }}</div>
+
+        <div class="sub-questions-title"><i class="el-icon-edit" /> 子题集 (共 {{ questionData.children.length }} 题):</div>
+
+        <div
+          v-for="(sub, index) in questionData.children"
+          :key="sub.questionId"
+          class="sub-question-item"
+        >
+          <div class="sub-content">
+            <span class="sub-index">({{ index + 1 }})</span>
+            <span class="sub-text">{{ sub.content }}</span>
+          </div>
+
+          <div v-if="sub.options && sub.options.length" class="sub-options-grid">
+            <div
+              v-for="subOpt in sub.options"
+              :key="subOpt.option"
+              :class="['sub-option-cell', { 'sub-correct': subOpt.correct }]"
+            >
+              <span class="cell-text">{{ subOpt.content }}</span>
+              <el-badge v-if="subOpt.correct" value="正确" class="correct-badge" type="success" />
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <el-divider content-position="left"><span class="section-title">答案解析</span></el-divider>
+
+      <div class="analysis-box">
+        <div class="analysis-text">{{ questionData.analysis || '暂无解析' }}</div>
+      </div>
+    </div>
+
+    <div slot="footer" class="dialog-footer">
+      <el-button type="primary" @click="visible = false">关闭预览</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+export default {
+  name: 'QuestionPreviewDialog',
+  data() {
+    return {
+      visible: false,
+      questionData: null
+    }
+  },
+  methods: {
+    /**
+     * 外部父组件调用的打开方法
+     * @param {Object} data 后端返回的完整 data 对象
+     */
+    open(data) {
+      this.questionData = data
+      this.visible = true
+    }
+  }
+}
+</script>
+
+<style scoped>
+/* 保持文本本身的空格与换行,这对于K12和高考题目的排版至关重要 */
+.question-content, .analysis-text, .option-text {
+  white-space: pre-wrap;
+  word-break: break-all;
+  font-size: 15px;
+  line-height: 1.6;
+  color: #303133;
+}
+
+.ml-10 {
+  margin-left: 10px;
+}
+
+.meta-header {
+  display: flex;
+  align-items: center;
+}
+
+.id-text {
+  margin-left: auto;
+  font-size: 12px;
+  color: #909399;
+}
+
+/* 常规单选题样式 */
+.options-list {
+  margin-top: 15px;
+}
+
+.option-item {
+  padding: 12px 15px;
+  margin-bottom: 10px;
+  background-color: #f8f9fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  position: relative;
+  transition: all 0.2s;
+}
+
+.option-item.is-correct {
+  background-color: #f0f9eb;
+  border-color: #c2e7b0;
+}
+
+.option-tag {
+  position: absolute;
+  right: 15px;
+  top: 12px;
+  font-size: 12px;
+  color: #67c23a;
+  font-weight: bold;
+}
+
+/* 组合题/完型填空特定样式 */
+.material-title, .sub-questions-title {
+  font-weight: bold;
+  font-size: 14px;
+  color: #606266;
+  margin-bottom: 8px;
+}
+
+.sub-questions-title {
+  margin-top: 25px;
+}
+
+.material-box {
+  background-color: #fdfdfd;
+  border: 1px dashed #dcdfe6;
+  padding: 15px;
+  border-radius: 6px;
+  max-height: 260px;
+  overflow-y: auto;
+}
+
+.sub-question-item {
+  background: #fafafa;
+  border: 1px solid #ebeef5;
+  padding: 12px;
+  border-radius: 4px;
+  margin-bottom: 12px;
+}
+
+.sub-content {
+  font-weight: bold;
+  margin-bottom: 8px;
+}
+
+.sub-index {
+  color: #409eff;
+  margin-right: 5px;
+}
+
+/* 子题选项的网格平铺布局(1行4个选项) */
+.sub-options-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 10px;
+}
+
+.sub-option-cell {
+  background: #fff;
+  border: 1px solid #dcdfe6;
+  padding: 8px;
+  border-radius: 4px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 13px;
+}
+
+.sub-option-cell.sub-correct {
+  border-color: #67c23a;
+  background-color: #f0f9eb;
+  color: #67c23a;
+  font-weight: bold;
+}
+
+.correct-badge >>> .el-badge__content {
+  scale: 0.8;
+}
+
+/* 解析区样式 */
+.section-title {
+  font-size: 14px;
+  font-weight: bold;
+  color: #e6a23c;
+}
+
+.analysis-box {
+  background-color: #fff8ee;
+  border-left: 4px solid #e6a23c;
+  padding: 12px 15px;
+  border-radius: 0 4px 4px 0;
+}
+</style>