Explorar o código

feat(quiz): 新增学生老师测试逻辑

WuXinyu %!s(int64=6) %!d(string=hai) anos
pai
achega
17f3981ec4

+ 116 - 0
mock/api/quiz/_quizId/get.json

@@ -0,0 +1,116 @@
+{
+  "id": 1,
+  "name": "xxx科目测试1",
+  "state": "ONGOING",
+  "course": {},
+  "quizTime": "BEFORE_CLASS",
+  "slide": {},
+  "questions": [
+    {
+      "kind": "CHOICE",
+      "stem": "计算机中常采用原码、反码、补码和移码表奈数据,其中;±0 编码相同的是( )。",
+      "options": {
+        "A": "原码和补码",
+        "B": "反码和补码",
+        "C": "补码和移码",
+        "D": "原码和移码"
+      },
+      "answer": "C",
+      "key_points": "空",
+      "analysis": "本题考查计算机系统数据编码基础知识。设机器字长为n(即采用n个二进制位表示数据),最高位是符号位,0表示正号,1表示负号。原码表示方式下,除符号位外,n-1位表示数值的绝对值。因此,n为8时,[+0]原=0000000,[-0]原=1 0000000正数的反码与原码相同,负数的反码则是其绝对值按位求反。n为8时,数值0的反码表示有两种形式:[+0]反=00000000,[-0]反=111111111。正数的补码与其原码和反码相同,负数的补码则等于其反码的末尾加1。在补码表示中,0有唯一的编码:[+0]原=0000000,[-0]原=0000000。移码表示法是在数×上增加一个偏移量来定义的,常用于表示浮点数中的阶码。机器字长为n时,在偏移量为2的情况下,只要将补码的符号位取反便可获得相应的移码表示。",
+      "id": 1005
+    },
+    {
+      "kind": "TRUE_FALSE",
+      "stem": "某指令流水线由 5 段组成,第 1、3、5 段所需时间为△t,第 2、4 段所需时间分别为 3△t、2△t,如下图所示,那么连续输入n条指令时的吞吐率(单位时内可执行的指令个 数)TP 为( )。\n\n![](https://seec.oss-cn-beijing.aliyuncs.com/%E9%A2%98%E5%BA%93/%E9%A2%98%E7%9B%AE%E5%9B%BE%E7%89%87%E6%95%B0%E6%8D%AE/11/media/image5.png)\n\n![](https://seec.oss-cn-beijing.aliyuncs.com/%E9%A2%98%E5%BA%93/%E9%A2%98%E7%9B%AE%E5%9B%BE%E7%89%87%E6%95%B0%E6%8D%AE/11/media/image6.png)",
+      "options": null,
+      "answer": true,
+      "analysis": "本题考查计算机系统流水线方面的基础知识。吞吐率和建立时间是使用流水线技术的两个重要指标。吞吐率是指单位时间里流水线处理机流出的结果数。对指令而言,就是单位时间里执行的指令数。流水线开始工作,须经过一定时间才能达到最大吞吐率,这就是建立时间。若D2个子过程所用时间一样,均为△t0,则建立时间T0=m△t0。本题目中,连续输入n条指令时,第1条指令需要的时间为(1+3+1+2+1)△t,之后,每隔3△t便完成1条指令,即流水线一旦建立好,其吞吐率为最长子过程所需时间的倒数。综合n条指令的时间为(1+3+1+2+1)△t+(n-1)×3△t",
+      "id": 1006
+    },
+    {
+      "kind": "CHOICE",
+      "stem": "下面关于漏洞扫描系统的叙述,错误的是( )。",
+      "options": {
+        "A": "漏洞扫描系统是一种自动检测目标主机安全弱点,的程序",
+        "B": "黑客利用漏洞扫描系统可以发现月标主机的安全漏洞",
+        "C": "漏洞扫描系统可以用于发现网络入侵者",
+        "D": "漏洞扫描系统的实现依赖于系统漏洞库的完善"
+      },
+      "answer": "C",
+      "key_points": "空",
+      "analysis": "漏洞扫描系统是一种自动检测远程或本地主机安全性弱点的程序。通过使用漏洞扫描系统,系统管理员能够发现所维护的Web服务器的各种TCP端口分配、提供的服务、Web服务软件版本和这些服务及软件呈现在因特网上的安全漏洞,及时发现并修补,以免网络攻击者利用系统漏洞进行攻击或窃取信息。可见,漏洞扫描并不能发现网络入侵者。",
+      "tags": null,
+      "knowledgeId": [
+        "SEE.RU",
+        "CF.SSDM"
+      ],
+      "lastModified": "2020-02-17 10:06:21",
+      "id": 1007,
+      "_links": {
+        "self": {
+          "href": "http://localhost:9090/api/tq/1007"
+        },
+        "CHOICEQuestion": {
+          "href": "http://localhost:9090/api/tq/1007"
+        }
+      }
+    },
+    {
+      "kind": "CHOICE",
+      "stem": "网络安全包含了网络信息的可用性、保密性、完整性和网络通信对象的真实性。其中,数字签名是对( )的保护;",
+      "options": {
+        "A": "可用性",
+        "B": "保密性",
+        "C": "连通性",
+        "D": "真实性"
+      },
+      "answer": "D",
+      "key_points": "空",
+      "analysis": "本题考查网络安全方面的基础知识。数字签名(Digital Signature)技术是不对称加密算法的典型应用。数字签名的应用过程是:数据源发送方使用自己的私钥对数据校验和或其他与数据内容有关的变量进行加密处理,完成对数据的合法“签名”;数据接收方则利用对方的公钥来解读收到的“数字签名”,并将解读结果用于对数据完整性的检验,以确认签名的合法性。数字签名技术是在网络系统虚拟环境中确认身份的重要技术,完全可以代替现实过程中的“亲笔签字”,在技术和法律上有保证,可见数字签名是对签名真实性的保护。",
+      "tags": null,
+      "knowledgeId": [
+        "CF.SSDM",
+        "CF.NCB"
+      ],
+      "lastModified": "2020-02-10 14:26:09",
+      "id": 1008,
+      "_links": {
+        "self": {
+          "href": "http://localhost:9090/api/tq/1008"
+        },
+        "CHOICEQuestion": {
+          "href": "http://localhost:9090/api/tq/1008"
+        }
+      }
+    },
+    {
+      "kind": "CHOICE",
+      "stem": "计算机感染特洛伊木马后的典型现象是( )。",
+      "options": {
+        "A": "程序异常退出",
+        "B": "有未知程序试图建立网络连接",
+        "C": "邮箱被垃圾邮件填满",
+        "D": "Windows 系统黑屏"
+      },
+      "answer": "B",
+      "key_points": "空",
+      "analysis": "计算机感染特洛伊木马后,会受到特洛伊木马程序的控制,有以下几种典型现象。●有未知程序试图建立网络连接。●系统中有可疑的进程在运行等现象。●系统运行速度越来越慢,且CPU资源占用率高。●任务表中有可疑的文件在运行。●系统长时间读/写硬盘或搜索软盘。●系统经常死机或重启等。",
+      "tags": null,
+      "knowledgeId": [
+        "CF.NCB",
+        "CF.SSDM"
+      ],
+      "lastModified": "2020-02-07 21:26:47",
+      "id": 1009,
+      "_links": {
+        "self": {
+          "href": "http://localhost:9090/api/tq/1009"
+        },
+        "CHOICEQuestion": {
+          "href": "http://localhost:9090/api/tq/1009"
+        }
+      }
+    }
+  ]
+}

+ 1 - 0
mock/api/quiz/_quizId/student-answer/post.json

@@ -0,0 +1 @@
+{}

+ 43 - 0
mock/api/quiz/get.json

@@ -0,0 +1,43 @@
+{
+  "quizzes": [
+    {
+      "id": "@ID",
+      "name": "xxx科目测试1",
+      "state": "NOT_STARTED",
+      "course": {
+        "name": "xxx课程"
+      },
+      "quizTime": "BEFORE_CLASS",
+      "slide": {
+        "name": "xxx幻灯片"
+      },
+      "questionNumber": 6
+    },
+    {
+      "id": "@ID",
+      "name": "xxx科目测试2",
+      "state": "ONGOING",
+      "course": {
+        "name": "xxx课程2"
+      },
+      "quizTime": "AFTER_CLASS",
+      "slide": {
+        "name": "xxx幻灯片"
+      },
+      "questionNumber": 6
+    },
+    {
+      "id": "@ID",
+      "name": "xxx科目测试3",
+      "state": "CLOSED",
+      "course": {
+        "name": "xxx课程3"
+      },
+      "quizTime": "FINISH",
+      "slide": {
+        "name": "xxx幻灯片"
+      },
+      "questionNumber": 6
+    }
+  ]
+}

+ 1 - 0
mock/api/quiz/post.json

@@ -0,0 +1 @@
+{}

+ 1 - 0
src/components/TabSideComponent.vue

@@ -8,6 +8,7 @@
 
 <script>
 export default {
+  name: 'TabSideComponent',
   data () {
     return {
       deviceSize: ''

+ 20 - 1
src/router/index.js

@@ -30,6 +30,16 @@ const routes = [{
   path: '/quiz-setting/:slideId',
   name: 'teacher-quiz-setting',
   component: () => import('../views/quiz/TeacherQuizSetting.vue')
+}, {
+  meta: { roles: ['teacher'] },
+  path: '/quiz-setting/:slideId/create-quiz',
+  name: 'teacher-quiz-create',
+  component: () => import('../views/quiz/CreateQuiz.vue')
+}, {
+  meta: { roles: ['teacher'] },
+  path: '/quiz-detail/:quizId',
+  name: 'quiz-detail',
+  component: () => import('../views/quiz/QuizDetail.vue')
 }, {
   meta: { roles: ['student'] },
   path: '/quiz/:slideId',
@@ -117,7 +127,16 @@ const routes = [{
   }, {
     path: 'quiz',
     name: 'student-quiz',
-    component: () => import(/* webpackChunkName: "student" */ '../views/student/tabs/StudentQuiz.vue')
+    component: () => import(/* webpackChunkName: "student" */ '../views/student/tabs/StudentQuiz.vue'),
+    children: [{
+      path: ':quizId',
+      name: 'student-quiz-detail',
+      component: () => import(/* webpackChunkName: "student" */ '../views/student/components/StudentQuizDetail.vue')
+    }, {
+      path: ':quizId/do-quiz',
+      name: 'student-do-quiz',
+      component: () => import(/* webpackChunkName: "student" */ '../views/student/components/StudentDoQuiz.vue')
+    }]
   }, {
     path: 'courses',
     name: 'student-courses',

+ 1 - 1
src/server/enums/question-kind.js

@@ -1,4 +1,4 @@
-const kinds = {
+export const kinds = {
   choice: 'CHOICE',
   trueOrFalse: 'TRUE_FALSE'
 }

+ 7 - 1
src/server/enums/quiz-state.js

@@ -1,5 +1,11 @@
-export default {
+export const QuizState = {
   notStart: 'NOT_STARTED', // 未开始
   ongoing: 'ONGOING', // 正在进行
   closed: 'CLOSED' // 已结束
 }
+
+export const QuizStateNaming = {
+  [QuizState.notStart]: '未开始',
+  [QuizState.ongoing]: '正在进行',
+  [QuizState.closed]: '已结束'
+}

+ 1 - 2
src/server/quiz.js

@@ -3,13 +3,12 @@ import { useQuizServer } from '@/config/server-info'
 
 export function getQuizList({
   slideId,
-  subscribe = false,
   state,
   size = 100000, // TODO: 分页功能暂时不做,因此取很大值
   page = 1,
   sort
 }) {
-  const params = { slideId, subscribe, state, size, page, sort }
+  const params = { slideId, state, size, page, sort }
   return axios
     .get(useQuizServer(), { params })
     .then(res => res.data)

+ 1 - 0
src/utils/types.js

@@ -0,0 +1 @@
+export const isEmpty = val => val === undefined || val === null

+ 74 - 0
src/views/quiz/CreateQuiz.vue

@@ -0,0 +1,74 @@
+<template>
+  <div>
+    <back-title>测试详情</back-title>
+    <form @submit.prevent="handleSubmit">
+      <label>
+        测试名称:
+        <input v-model="quiz.name" required placeholder="请输入测试名称" />
+      </label>
+      <label>
+        测试进行时课件的状态(测试时间):
+        <select v-model="quiz.quizTime" required >
+          <option value="">请选择测试时间</option>
+          <option :key="index" v-for="(item, index) in SlideStateTranslation" :value="index">{{ item }}</option>
+        </select>
+      </label>
+      <div class="mb-16" :key="question.id" v-for="(question, index) in selectedQuestions">
+        <div>{{ index + 1 }}.</div>
+        <a @click="moveIndexQuestionUp(index)">向上移动</a>
+        <a @click="moveIndexQuestionUp(index + 1)">向下移动</a>
+        <question-detail-factory :question="question" />
+      </div>
+      <question-table v-model="selectedQuestions" selectable />
+      <button>创建</button>
+    </form>
+  </div>
+</template>
+
+<script>
+import { naming as QuestionKindNaming } from '@/server/enums/question-kind'
+import { QuizStateNaming } from '@/server/enums/quiz-state'
+import SlideStateTranslation from '@/server/enums/slide-state-translation'
+import BackTitle from '@/components/BackTitle'
+import QuestionTable from '@/views/quiz/components/QuestionTable'
+import QuestionDetailFactory from '@/views/quiz/components/QuestionDetailFactory'
+import { createQuiz } from '@/server/quiz'
+
+export default {
+  name: 'CreateQuiz',
+  components: { QuestionDetailFactory, QuestionTable, BackTitle },
+  data() {
+    return {
+      quiz: { name: '', quizTime: '', slideId: this.$route.params.slideId },
+      selectedQuestions: [],
+
+      QuestionKindNaming,
+      QuizStateNaming,
+      SlideStateTranslation
+    }
+  },
+  methods: {
+    handleSubmit() {
+      createQuiz({ ...this.quiz, questions: this.selectedQuestions.map(item => item.id) })
+        .then(() => this.$setSuccessMessage('创建成功'))
+        .then(() => this.$router.go(-1))
+        .catch(err =>
+          this.$setErrorMessage(err.response.data.msg)
+        )
+    },
+    moveIndexQuestionUp(index) {
+      if (index < this.selectedQuestions.length && index >= 1) {
+        const nextQuestions = [...this.selectedQuestions]
+        const before = nextQuestions[index - 1]
+        nextQuestions[index - 1] = nextQuestions[index]
+        nextQuestions[index] = before
+        this.selectedQuestions = nextQuestions
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 3 - 5
src/views/quiz/QuestionDetail.vue

@@ -1,8 +1,7 @@
 <template>
   <div>
     <back-title>题目详情</back-title>
-    <choice-question-detail v-if="question.kind === QuestionKind.choice" :value="question" />
-    <true-false-question-detail v-else-if="question.kind === QuestionKind.trueOrFalse" :value="question" />
+    <question-detail-factory :question="question" />
   </div>
 </template>
 
@@ -10,11 +9,10 @@
 import QuestionKind from '@/server/enums/question-kind'
 import { getQuestionById } from '@/server/question'
 import BackTitle from '@/components/BackTitle'
-import TrueFalseQuestionDetail from '@/views/quiz/components/TrueFalseQuestionDetail'
-import ChoiceQuestionDetail from '@/views/quiz/components/ChoiceQuestionDetail'
+import QuestionDetailFactory from '@/views/quiz/components/QuestionDetailFactory'
 
 export default {
-  components: { ChoiceQuestionDetail, TrueFalseQuestionDetail, BackTitle },
+  components: { QuestionDetailFactory, BackTitle },
   data() {
     return {
       question: {},

+ 33 - 0
src/views/quiz/QuizDetail.vue

@@ -0,0 +1,33 @@
+<template>
+  <div>
+    <back-title>{{ quizDetail.name }}</back-title>
+    <quiz-detail-factory :quiz-detail="quizDetail" />
+  </div>
+</template>
+
+<script>
+import { createDefaultQuiz } from '@/views/quiz/utils/default'
+import { getQuizById } from '@/server/quiz'
+import BackTitle from '@/components/BackTitle'
+import QuizDetailFactory from '@/views/quiz/components/QuizDetailFactory'
+
+export default {
+  components: { QuizDetailFactory, BackTitle },
+  data() {
+    return {
+      quizDetail: createDefaultQuiz()
+    }
+  },
+  mounted() {
+    this.fetchQuizDetail()
+  },
+  methods: {
+    fetchQuizDetail() {
+      const { quizId } = this.$route.params
+      getQuizById(quizId)
+        .then(res => (this.quizDetail = res))
+        .catch(err => this.$setErrorMessage(err.response.data.msg))
+    }
+  }
+}
+</script>

+ 33 - 3
src/views/quiz/QuizView.vue

@@ -1,13 +1,43 @@
 <template>
-  <div>
+  <tab-side-component>
     <back-title>具体课件的测验(学生)</back-title>
-  </div>
+    <div v-for="item in quizList" :key="item.id">
+      <div>{{ item.name }}</div>
+      <div>测试时间:{{ SlideStateTranslation[item.quizTime] }}</div>
+      <div>测试状态:{{ QuizStateNaming[item.state] }}</div>
+      <router-link :to="`/student/quiz/${item.id}`"><button>详情</button></router-link>
+    </div>
+  </tab-side-component>
 </template>
 
 <script>
 import BackTitle from '@/components/BackTitle'
+import SlideStateTranslation from '@/server/enums/slide-state-translation'
+import { QuizStateNaming } from '@/server/enums/quiz-state'
+import { getQuizList } from '@/server/quiz'
+import TabSideComponent from '@/components/TabSideComponent'
+
 export default {
-  components: { BackTitle }
+  components: { TabSideComponent, BackTitle },
+  data() {
+    return {
+      quizList: [],
+
+      SlideStateTranslation,
+      QuizStateNaming
+    }
+  },
+  mounted() {
+    this.fetchQuiz()
+  },
+  methods: {
+    fetchQuiz() {
+      const { slideId } = this.$route.params
+      getQuizList({ slideId })
+        .then(res => (this.quizList = res.quizzes))
+        .catch(err => this.$setErrorMessage(err.response.data.msg))
+    }
+  }
 }
 </script>
 

+ 29 - 8
src/views/quiz/TeacherQuizSetting.vue

@@ -1,21 +1,42 @@
 <template>
   <div class="teacher-quiz-setting">
-    <back-title>具体课件的习题设置</back-title>
-    <div class="sub-title">课前习题设置</div>
-    <div class="sub-title">课后习题设置</div>
-    <c-button class="secondary" @click="handleToQuestions">题库管理</c-button>
+    <back-title>具体课件的测试列表</back-title>
+    <router-link :to="`${$route.path}/create-quiz`"><button>新增测试</button></router-link>
+    <router-link to="/questions"><button>题库管理</button></router-link>
+    <div v-for="item in quizList" :key="item.id">
+      <div>{{ item.name }}</div>
+      <div>测试时间:{{ SlideStateTranslation[item.quizTime] }}</div>
+      <div>测试状态:{{ QuizStateNaming[item.state] }}</div>
+      <router-link :to="`/quiz-detail/${item.id}`"><button>详情</button></router-link>
+    </div>
   </div>
 </template>
 
 <script>
 import BackTitle from '@/components/BackTitle'
-import CButton from '@/components/CButton'
+import { getQuizList } from '@/server/quiz'
+import SlideStateTranslation from '@/server/enums/slide-state-translation'
+import { QuizStateNaming } from '@/server/enums/quiz-state'
 
 export default {
-  components: { CButton, BackTitle },
+  components: { BackTitle },
+  data() {
+    return {
+      quizList: [],
+
+      SlideStateTranslation,
+      QuizStateNaming
+    }
+  },
+  mounted() {
+    this.fetchQuiz()
+  },
   methods: {
-    handleToQuestions () {
-      this.$router.push({ name: 'questions' })
+    fetchQuiz() {
+      const { slideId } = this.$route.params
+      getQuizList({ slideId })
+        .then(res => (this.quizList = res.quizzes))
+        .catch(err => this.$setErrorMessage(err.response.data.msg))
     }
   }
 }

+ 8 - 2
src/views/quiz/components/ChoiceQuestionDetail.vue

@@ -1,5 +1,6 @@
 <template>
   <div>
+    <div v-if="!isEmpty(value.pass)">{{ value.pass ? "回答正确" : "回答错误" }}</div>
     <editor-section title="题干">
       <markdown-section :raw="value.stem" />
     </editor-section>
@@ -13,7 +14,10 @@
         <markdown-section :raw="item" />
       </section>
     </editor-section>
-    <editor-section title="答案">
+    <editor-section v-if="!isEmpty(value.studentAnswer)" title="你的答案">
+      {{ value.studentAnswer }}
+    </editor-section>
+    <editor-section title="正确答案">
       {{ value.answer }}
     </editor-section>
     <editor-section title="解析">
@@ -27,6 +31,7 @@ import EditorSection from '@/views/quiz/components/EditorSection'
 import { createDefaultChoiceQuestion } from '@/views/quiz/utils/default'
 import { markdownToHTML } from '@/utils/markdown'
 import MarkdownSection from '@/views/quiz/components/MarkdownSection'
+import { isEmpty } from '@/utils/types'
 
 export default {
   name: 'ChoiceQuestionDetail',
@@ -38,7 +43,8 @@ export default {
     }
   },
   methods: {
-    markdownToHTML
+    markdownToHTML,
+    isEmpty
   }
 }
 </script>

+ 1 - 1
src/views/quiz/components/ChoiceQuestionEditor.vue

@@ -36,7 +36,7 @@
         </button>
       </section>
     </editor-section>
-    <editor-section title="答案">
+    <editor-section title="正确答案">
       {{ value.answer || '请选择答案' }}
     </editor-section>
     <editor-section title="解析">

+ 12 - 1
src/views/quiz/components/QuestionCard.vue

@@ -6,7 +6,10 @@
         <span :class="`tag tag--${QuestionKind.color[kind]}`">{{ QuestionKind.naming[kind] }}</span>
       </span>
       <span>
-        <router-link class="action" style="margin-right: 16px;" :to="`/questions/${questionId}`">详情</router-link>
+        <a v-if="selectable" class="action" style="cursor: pointer" @click="$emit('toggle-select', questionId)">
+          {{ selected ? "取消选择" : "选择" }}
+        </a>
+        <router-link class="action" style="margin-left: 16px;" :to="`/questions/${questionId}`">详情</router-link>
       </span>
     </div>
     <markdown-section :raw="stem" />
@@ -32,6 +35,14 @@ export default {
     kind: {
       type: String,
       required: true
+    },
+    selected: {
+      type: Boolean,
+      default: false
+    },
+    selectable: {
+      type: Boolean,
+      default: false
     }
   },
   data() {

+ 28 - 0
src/views/quiz/components/QuestionDetailFactory.vue

@@ -0,0 +1,28 @@
+<template>
+  <choice-question-detail v-if="question.kind === QuestionKind.choice" :value="question" />
+  <true-false-question-detail v-else-if="question.kind === QuestionKind.trueOrFalse" :value="question" />
+</template>
+
+<script>
+import QuestionKind from '@/server/enums/question-kind'
+import ChoiceQuestionDetail from '@/views/quiz/components/ChoiceQuestionDetail'
+import TrueFalseQuestionDetail from '@/views/quiz/components/TrueFalseQuestionDetail'
+
+export default {
+  name: 'QuestionDetailFactory',
+  components: { TrueFalseQuestionDetail, ChoiceQuestionDetail },
+  props: {
+    question: {
+      type: Object,
+      required: true
+    }
+  },
+  data() {
+    return { QuestionKind }
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 30 - 1
src/views/quiz/components/QuestionTable.vue

@@ -13,7 +13,11 @@
         :key="item.id"
         :kind="item.kind"
         :stem="item.stem"
-        :question-id="item.id" />
+        :question-id="item.id"
+        :selectable="selectable"
+        :selected="judgeIsSelected(item)"
+        @toggle-select="handleToggleSelectQuestion(item)"
+      />
     </div>
     <pagination class="mt-16" :page="page.number" :size="page.size" :total-elements="page.totalElements" @change="fetchQuestionList"></pagination>
   </div>
@@ -30,6 +34,20 @@ import QuestionCard from '@/views/quiz/components/QuestionCard'
 export default {
   name: 'QuestionTable',
   components: { QuestionCard, CInput, Pagination },
+  props: {
+    selectable: {
+      type: Boolean,
+      default: false
+    },
+    selectedQuestions: {
+      type: Array,
+      default: () => []
+    }
+  },
+  model: {
+    prop: 'selectedQuestions',
+    event: 'select'
+  },
   filters: {
     markdownToHTML: markdownToHTML
   },
@@ -57,6 +75,17 @@ export default {
           this.questionList = questions
           this.page = page
         })
+    },
+    judgeIsSelected(question) {
+      return Boolean(this.selectedQuestions
+        .find(item => item.id === question.id))
+    },
+    handleToggleSelectQuestion(question) {
+      if (this.judgeIsSelected(question)) {
+        this.$emit('select', this.selectedQuestions.filter(item => item.id !== question.id))
+      } else {
+        this.$emit('select', [...this.selectedQuestions, question])
+      }
     }
   }
 }

+ 36 - 0
src/views/quiz/components/QuizDetailFactory.vue

@@ -0,0 +1,36 @@
+<template>
+  <div>
+    <div>测试时间:{{ SlideStateTranslation[quizDetail.quizTime] }}</div>
+    <div>测试状态:{{ QuizStateNaming[quizDetail.state] }}</div>
+    <div class="mb-32" :key="item.id" v-for="(item, index) in quizDetail.questions">
+      <div>{{ index + 1 }}.</div>
+      <question-detail-factory :question="item" />
+    </div>
+  </div>
+</template>
+
+<script>
+import { createDefaultQuiz } from '@/views/quiz/utils/default'
+import SlideStateTranslation from '@/server/enums/slide-state-translation'
+import QuestionKind from '@/server/enums/question-kind'
+import { QuizStateNaming } from '@/server/enums/quiz-state'
+import QuestionDetailFactory from '@/views/quiz/components/QuestionDetailFactory'
+
+export default {
+  components: { QuestionDetailFactory },
+  props: {
+    quizDetail: {
+      type: Object,
+      default: createDefaultQuiz
+    }
+  },
+  data() {
+    return {
+
+      SlideStateTranslation,
+      QuestionKind,
+      QuizStateNaming
+    }
+  }
+}
+</script>

+ 9 - 2
src/views/quiz/components/TrueFalseQuestionDetail.vue

@@ -1,9 +1,14 @@
 <template>
   <div>
+    <div v-if="!isEmpty(value.pass)">{{ value.pass ? "回答正确" : "回答错误" }}</div>
     <editor-section title="题干">
       <markdown-section :raw="value.stem" />
     </editor-section>
-    <editor-section title="答案">
+    <editor-section v-if="!isEmpty(value.studentAnswer)" title="你的答案">
+      <eva-icon-checkmark v-if="value.studentAnswer" />
+      <eva-icon-close-outline v-else />
+    </editor-section>
+    <editor-section title="正确答案">
       <eva-icon-checkmark v-if="value.answer" />
       <eva-icon-close-outline v-else />
     </editor-section>
@@ -20,6 +25,7 @@ import { markdownToHTML } from '@/utils/markdown'
 import MarkdownSection from '@/views/quiz/components/MarkdownSection'
 import EvaIconCheckmark from '@/components/EvaIcon/EvaIconCheckmark'
 import EvaIconCloseOutline from '@/components/EvaIcon/EvaIconCloseOutline'
+import { isEmpty } from '@/utils/types'
 
 export default {
   name: 'TrueFalseQuestionDetail',
@@ -31,7 +37,8 @@ export default {
     }
   },
   methods: {
-    markdownToHTML
+    markdownToHTML,
+    isEmpty
   }
 }
 </script>

+ 8 - 0
src/views/quiz/utils/default.js

@@ -10,3 +10,11 @@ export const createDefaultTrueFalseQuestion = () => ({
   answer: true,
   analysis: ''
 })
+
+export const createDefaultQuiz = () => ({
+  name: '',
+  course: {},
+  quizTime: '',
+  slide: {},
+  questions: []
+})

+ 83 - 0
src/views/student/components/StudentDoQuiz.vue

@@ -0,0 +1,83 @@
+<template>
+  <tab-side-component>
+    <back-title>{{ quiz.name }}</back-title>
+    <form @submit.prevent="handleFormSubmit">
+      <button>提交</button>
+      <div :key="question.id" v-for="(question, index) in quiz.questions">
+        <div>{{ index + 1 }}.</div>
+        <markdown-section :raw="question.stem" />
+        <div v-if="question.kind === QuestionKind.choice">
+          <label :key="index" v-for="(item, index) in question.options">
+            <input v-model="question.studentAnswer" type="radio" :value="index" />
+            <markdown-section style="display: inline" :raw="item" />
+          </label>
+        </div>
+        <div v-else-if="question.kind === QuestionKind.trueOrFalse">
+          <label>
+            <input v-model="question.studentAnswer" type="radio" :value="true" />
+            <eva-icon-checkmark />
+          </label>
+          <label>
+            <input v-model="question.studentAnswer" type="radio" :value="false" />
+            <eva-icon-close-outline />
+          </label>
+        </div>
+      </div>
+      <button>提交</button>
+    </form>
+  </tab-side-component>
+</template>
+<script>
+import TabSideComponent from '@/components/TabSideComponent'
+import BackTitle from '@/components/BackTitle'
+import { getQuizById, submitQuiz } from '@/server/quiz'
+import { createDefaultQuiz } from '@/views/quiz/utils/default'
+import QuestionKind from '@/server/enums/question-kind'
+import MarkdownSection from '@/views/quiz/components/MarkdownSection'
+import EvaIconCheckmark from '@/components/EvaIcon/EvaIconCheckmark'
+import EvaIconCloseOutline from '@/components/EvaIcon/EvaIconCloseOutline'
+
+export default {
+  components: { EvaIconCloseOutline, EvaIconCheckmark, MarkdownSection, BackTitle, TabSideComponent },
+  data() {
+    return {
+      quiz: createDefaultQuiz(),
+      hasSubmit: false,
+
+      QuestionKind
+    }
+  },
+  beforeRouteLeave (to, from, next) {
+    if (this.hasSubmit) {
+      next()
+    } else if (window.confirm('您的答案没有提交,确定要离开页面?')) {
+      next()
+    } else {
+      next(false)
+    }
+  },
+  mounted() {
+    this.fetchQuizDetail()
+  },
+  methods: {
+    fetchQuizDetail() {
+      const { quizId } = this.$route.params
+      getQuizById(quizId)
+        .then(res => (this.quiz = res))
+        .catch(err => this.$setErrorMessage(err.response.data.msg))
+    },
+    handleFormSubmit() {
+      const { quizId } = this.$route.params
+      submitQuiz({ quizId, answers: this.quiz.questions })
+        .then(() => {
+          this.hasSubmit = true
+          this.$setSuccessMessage('提交成功')
+          this.$router.go(-1)
+        })
+        .catch(err =>
+          this.$setErrorMessage(err.response.data.msg)
+        )
+    }
+  }
+}
+</script>

+ 52 - 0
src/views/student/components/StudentQuizDetail.vue

@@ -0,0 +1,52 @@
+<template>
+  <tab-side-component>
+    <back-title>{{quiz.name}}</back-title>
+    <router-link v-if="quiz.state === QuizState.ongoing" :to="`${$route.path}/do-quiz`">
+      <button>做题</button>
+    </router-link>
+    <div v-if="quiz.state === QuizState.closed">
+      <div>正确:{{passQuestionNums}} 题</div>
+      <div>总共:{{totalQuestionNums}} 题</div>
+    </div>
+    <quiz-detail-factory :quiz-detail="quiz" />
+  </tab-side-component>
+</template>
+
+<script>
+import BackTitle from '@/components/BackTitle'
+import TabSideComponent from '@/components/TabSideComponent'
+import { getQuizById } from '@/server/quiz'
+import QuizDetailFactory from '@/views/quiz/components/QuizDetailFactory'
+import { QuizState } from '@/server/enums/quiz-state'
+import { createDefaultQuiz } from '@/views/quiz/utils/default'
+
+export default {
+  components: { QuizDetailFactory, TabSideComponent, BackTitle },
+  data() {
+    return {
+      quiz: createDefaultQuiz(),
+
+      QuizState
+    }
+  },
+  computed: {
+    totalQuestionNums() {
+      return this.quiz.questions?.length ?? 0
+    },
+    passQuestionNums() {
+      return this.quiz.questions?.filter(item => item.pass).length
+    }
+  },
+  mounted() {
+    this.fetchQuizDetail()
+  },
+  methods: {
+    fetchQuizDetail() {
+      const { quizId } = this.$route.params
+      getQuizById(quizId)
+        .then(res => (this.quiz = res))
+        .catch(err => this.$setErrorMessage(err.response.data.msg))
+    }
+  }
+}
+</script>

+ 35 - 10
src/views/student/tabs/StudentQuiz.vue

@@ -1,19 +1,44 @@
 <template>
-  <div class="tab">
-    <h1 class="title">课程测验 <span class="tag">学生</span></h1>
-    <div class="sub-title">待完成的测验</div>
-    <div class="no-content-warning">
-      测验功能预计将于 2 月底上线
-    </div>
-    <div class="sub-title">已完成的测验 & 结果</div>
-    <div class="no-content-warning">
-      测验功能预计将于 2 月底上线
+  <div class="tab complex-tab">
+    <div class="left">
+      <h1 class="title">课程测验 <span class="tag">学生</span></h1>
+      <div class="sub-title">待完成的测验</div>
+      <router-link :key="quiz.id" :to="`/student/quiz/${quiz.id}`" v-for="quiz in ongoingQuizzes">
+        <div>{{ quiz.name }}</div>
+        <div>{{ quiz.course && quiz.course.name }}</div>
+        <div>{{ quiz.slide && quiz.slide.name }}</div>
+      </router-link>
+      <div class="sub-title">已完成的测验 & 结果</div>
+      <router-link :key="quiz.id" :to="`/student/quiz/${quiz.id}`" v-for="quiz in ongoingQuizzes">
+        <div>{{ quiz.name }}</div>
+        <div>{{ quiz.course && quiz.course.name }}</div>
+        <div>{{ quiz.slide && quiz.slide.name }}</div>
+      </router-link>
     </div>
+    <router-view />
   </div>
 </template>
 
 <script>
-export default {}
+import { getQuizList } from '@/server/quiz'
+import { QuizState } from '@/server/enums/quiz-state'
+
+export default {
+  data() {
+    return {
+      ongoingQuizzes: [],
+      closedQuizzes: []
+    }
+  },
+  mounted() {
+    getQuizList({ state: QuizState.ongoing })
+      .then(res => (this.ongoingQuizzes = res.quizzes))
+      .catch(err => this.$setErrorMessage(err.response.data.msg))
+    getQuizList({ state: QuizState.closed })
+      .then(res => (this.closedQuizzes = res.quizzes))
+      .catch(err => this.$setErrorMessage(err.response.data.msg))
+  }
+}
 </script>
 
 <style lang="scss" scoped>