Parcourir la source

feat(question): 添加问题部分浏览查询功能

WuXinyu il y a 6 ans
Parent
commit
7cc9f261f1

+ 22 - 0
src/App.vue

@@ -32,6 +32,8 @@ export default {
 <style lang="scss">
 @import "~@/style/reset.scss";
 @import "~@/style/theme.scss";
+@import "~@/style/markdown.scss";
+@import "~@/style/icon.scss";
 
 @font-face {
   font-family: 'iconfont';  /* project id 1598214 */
@@ -90,6 +92,26 @@ body {
   border-radius: 8px;
   vertical-align: middle;
   bottom: 2px;
+
+  &.tag--blue{
+    background-color: lighten($theme-blue, 30);
+    color: $theme-blue;
+  }
+
+  &.tag--orange{
+    background-color: lighten($theme-orange, 30);
+    color: $theme-orange;
+  }
+
+  &.tag--green{
+    background-color: lighten($theme-green, 30);
+    color: $theme-green;
+  }
+
+  &.tag--red{
+    background-color: lighten($theme-red, 30);
+    color: $theme-red;
+  }
 }
 
 .full-width {

+ 91 - 0
src/components/Editor.vue

@@ -0,0 +1,91 @@
+<template>
+  <div class="editor">
+    <editor-content
+      :editor="editor"
+      class="editor__content"
+      :style="{ 'min-height': minHeight }"
+    />
+  </div>
+</template>
+
+<script>
+// import Icon from 'Components/Icon'
+import { Editor, EditorContent } from 'tiptap'
+import {
+  Image,
+  Bold,
+  Code,
+  Italic,
+  History,
+  Placeholder,
+  HardBreak
+} from 'tiptap-extensions'
+import { jsonToMarkdown, markdownToHTML } from '@/utils/markdown'
+
+export default {
+  components: {
+    // Icon,
+    EditorContent
+  },
+  props: {
+    initContent: {
+      default: '',
+      type: String
+    },
+    minHeight: {
+      default: '1em',
+      type: String
+    }
+  },
+  data() {
+    return {
+      editor: null
+    }
+  },
+  watch: {
+    initContent(val) {
+        // eslint-disable-next-line no-unused-expressions
+        this.editor?.setContent(markdownToHTML(val))
+    }
+  },
+  mounted() {
+    this.editor = new Editor({
+      extensions: [
+        new Image(),
+        new Bold(),
+        new Code(),
+        new Italic(),
+        new History(),
+        new HardBreak(),
+        new Placeholder({
+          showOnlyCurrent: false,
+          emptyNodeText: '在这里输入'
+        })
+      ],
+      content: markdownToHTML(this.initContent),
+      onUpdate: (data) => {
+        this.$emit('update-html', data.getHTML())
+        this.$emit('update-json', data.getJSON())
+        this.$emit(
+          'update-markdown',
+            jsonToMarkdown(data.getJSON())?.trim() ?? ''
+        )
+      }
+    })
+  },
+  beforeDestroy() {
+      // eslint-disable-next-line no-unused-expressions
+      this.editor?.destroy()
+  }
+}
+</script>
+<style lang="scss">
+.editor p.is-editor-empty:first-child::before {
+  content: attr(data-empty-text);
+  float: left;
+  color: #aaa;
+  pointer-events: none;
+  height: 0;
+  font-style: italic;
+}
+</style>

+ 217 - 0
src/components/Pagination.vue

@@ -0,0 +1,217 @@
+<template>
+  <div class="pagination-wrapper">
+    <div class="pagination">
+      <span class="pagination__arrow icon" @click="handleChange({ page: page - 1 })">
+        <eva-icon-arrow-ios-back-outline />
+      </span>
+      <span
+        :class="{ 'pagination__page': true, '--active': page === 1 }"
+        @click="handleChange({ page: 1 })"
+      >1</span>
+      <span
+        v-if="hasLeftDot"
+        class="pagination__hidden-arrow"
+        @click="handleChange({ page: page - SHOW_PAGE_NUM })"
+      >
+        <span class="icon">
+          <eva-icon-arrowhead-left-outline />
+        </span>
+        <span class="pagination__hidden-ellipsis">•••</span>
+      </span>
+      <span
+        v-for="item in centerPages"
+        :key="item"
+        :class="{ 'pagination__page': true, '--active': page === item }"
+        @click="handleChange({ page: item })"
+      >{{ item }}</span>
+      <span
+        v-if="hasRightDot"
+        class="pagination__hidden-arrow --right"
+        @click="handleChange({ page: page + SHOW_PAGE_NUM })"
+      >
+        <span class="icon">
+          <eva-icon-arrowhead-left-outline />
+        </span>
+        <span class="pagination__hidden-ellipsis">•••</span>
+      </span>
+      <span
+        v-if="totalPage > 1"
+        :class="{ 'pagination__page': true, '--active': page === totalPage }"
+        @click="handleChange({ page: totalPage })"
+      >{{ totalPage }}</span>
+      <span
+        class="pagination__arrow --right icon"
+        @click="handleChange({ page: page + 1 })"
+      >
+        <eva-icon-arrow-ios-back-outline />
+      </span>
+    </div>
+    <div class="pagination-input">
+      <c-input
+        v-model="inputPage"
+        class="search-input"
+        style="width: 50px; margin-right: 5px"
+        @keypress="handlePageInputEvent"
+      />
+      <span>of {{ totalPage }}</span>
+    </div>
+  </div>
+</template>
+
+<script>
+import iconMap from '@/utils/icon-map'
+import CInput from '@/components/CInput'
+import EvaIconArrowIosBackOutline from '@/components/EvaIcon/EvaIconArrowIosBackOutline'
+import EvaIconArrowheadLeftOutline from '@/components/EvaIcon/EvaIconArrowheadLeftOutline'
+
+const SHOW_PAGE_NUM = 3
+
+export default {
+  components: { EvaIconArrowheadLeftOutline, EvaIconArrowIosBackOutline, CInput },
+  props: {
+    page: {
+      default: 1,
+      type: Number
+    },
+    size: {
+      type: Number,
+      default: 0
+    },
+    totalElements: {
+      type: Number,
+      default: 0
+    }
+  },
+  data() {
+    return {
+      inputPage: String(this.page),
+      iconMap,
+      SHOW_PAGE_NUM
+    }
+  },
+  computed: {
+    totalPage() {
+      return Math.ceil(this.totalElements / this.size)
+    },
+    hasLeftDot() {
+      return this.page >= SHOW_PAGE_NUM && this.totalPage >= SHOW_PAGE_NUM + 2
+    },
+    hasRightDot() {
+      return (
+        this.page <= this.totalPage - (SHOW_PAGE_NUM - 1) &&
+        this.totalPage >= SHOW_PAGE_NUM + 2
+      )
+    },
+    centerPages() {
+      const theCenter =
+        this.page < ((SHOW_PAGE_NUM + 1) / 2)
+          ? ((SHOW_PAGE_NUM + 1) / 2)
+          : this.page > this.totalPage - 2
+            ? this.totalPage - 2
+            : this.page
+
+      return Array(SHOW_PAGE_NUM)
+        .fill(0)
+        .map((_, index) => theCenter + (index - Math.floor(SHOW_PAGE_NUM / 2)))
+        .filter(item => item > 1 && item < this.totalPage)
+    }
+  },
+  methods: {
+    handleChange({ page = this.page, size = this.size }) {
+      const thePage =
+        page < 1 ? 1 : page > this.totalPage ? this.totalPage : page
+      this.$emit('change', { page: thePage, size })
+    },
+    handlePageInputEvent(event) {
+      if (event.key === 'Enter') {
+        event.preventDefault()
+        this.handleChange({ page: this.inputPage })
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import "~@/style/theme.scss";
+
+$pagination-font-color: inherit;
+$pagination-font-color--hover: inherit;
+$pagination-page-background--hover: $theme-blue-transparent;
+$pagination-page-background--active: $theme-blue;
+
+.icon{
+  font-family: 'iconfont', sans-serif;
+}
+
+.pagination-wrapper {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: center;
+  color: $pagination-font-color;
+}
+
+.pagination-input{
+  display: flex;
+  align-items: center;
+}
+
+.pagination {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  &__arrow,
+  &__page,
+  &__hidden-arrow {
+    line-height: 1em;
+    display: block;
+    transition: background-color 0.3s;
+    padding: 8px 10px;
+    margin: 0 4px;
+    cursor: pointer;
+    border-radius: 2px;
+
+    &:hover {
+      color: $pagination-font-color--hover;
+      background: $pagination-page-background--hover;
+    }
+  }
+  &__page.--active {
+    color: white;
+    background-color: $pagination-page-background--active;
+  }
+
+  &__arrow {
+    &.--right {
+      transform: rotate(180deg);
+    }
+  }
+
+  &__hidden-arrow {
+    position: relative;
+    .icon {
+      position: absolute;
+      left: 50%;
+      top: 50%;
+      transform: translate(-50%, -50%);
+      opacity: 0;
+    }
+    &:hover {
+      .icon {
+        opacity: 1;
+      }
+      .pagination__hidden-ellipsis {
+        opacity: 0;
+      }
+    }
+
+    &.--right {
+      .icon {
+        transform: translate(-50%, -50%) rotate(180deg);
+      }
+    }
+  }
+}
+</style>

+ 49 - 4
src/components/QuestionKindBar.vue

@@ -1,13 +1,58 @@
 <template>
-  $END$
+  <div class="question-kind-bar">
+    <div v-for="eachKind of QuestionKind.default" :class="eachKind === value ? 'active' : ''"
+         :key="eachKind"
+         @click="handleChangeState(eachKind)">{{ QuestionKind.naming[eachKind] }}
+    </div>
+  </div>
 </template>
 
 <script>
-  export default {
-    name: "QuestionKindBar"
+import * as QuestionKind from '@/server/enums/question-kind'
+
+export default {
+  props: {
+    value: {
+      type: String,
+      required: true
+    }
+  },
+  data () {
+    return {
+      QuestionKind
+    }
+  },
+  methods: {
+    handleChangeState (state) {
+      this.$emit('input', state)
+    }
   }
+}
 </script>
 
-<style scoped>
+<style lang="scss" scoped>
+  @import '~@/style/theme.scss';
+
+  .question-kind-bar {
+    display: flex;
+    align-items: center;
+    justify-content: space-around;
+    font-size: 14px;
+    font-weight: bold;
+    position: relative;
+    margin: 0 0 24px 0;
 
+    & > div {
+      cursor: pointer;
+      color: #9aaaba;
+      display: inline-block;
+      padding: 4px 8px;
+      transition: .2s;
+    }
+
+    .active {
+      background: $theme-green-transparent;
+      color: $theme-green;
+    }
+  }
 </style>

+ 13 - 1
src/config/server-info.js

@@ -35,6 +35,16 @@ const commentWsUrl = wsHost + '/api/ws/comment'
 
 const useCommentWsServer = postfix => postfix ? commentWsUrl + postfix : commentWsUrl
 
+// const quizUrl = host + '/api/quiz'
+const quizUrl = '/api/quiz'
+
+const useQuizServer = (postfix = '') => quizUrl + postfix
+
+// const questionUrl = host + '/api/question'
+const questionUrl = '/api/question'
+
+const useQuestionServer = (postfix = '') => questionUrl + postfix
+
 export {
   useSlideServer,
   useCourseServer,
@@ -43,5 +53,7 @@ export {
   useCommentServer,
   useReplyServer,
   useCommentWsServer,
-  useMessageServer
+  useMessageServer,
+  useQuizServer,
+  useQuestionServer
 }

+ 10 - 0
src/router/index.js

@@ -40,6 +40,16 @@ const routes = [{
   path: '/questions',
   name: 'questions',
   component: () => import('../views/quiz/Questions.vue')
+}, {
+  meta: { roles: ['teacher'] },
+  path: '/questions/:questionId',
+  name: 'question-detail',
+  component: () => import('../views/quiz/QuestionDetail.vue')
+}, {
+  meta: { roles: ['teacher'] },
+  path: '/create-question',
+  name: 'create-question',
+  component: () => import('../views/quiz/CreateQuestion.vue')
 }, {
   meta: { roles: ['teacher'] },
   path: '/teacher',

+ 16 - 0
src/server/enums/question-kind.js

@@ -0,0 +1,16 @@
+const kinds = {
+  choice: 'CHOICE',
+  trueOrFalse: 'TRUE_FALSE'
+}
+
+export const naming = {
+  [kinds.choice]: '选择题',
+  [kinds.trueOrFalse]: '判断题'
+}
+
+export const color = {
+  [kinds.choice]: 'blue',
+  [kinds.trueOrFalse]: 'green'
+}
+
+export default kinds

+ 5 - 0
src/server/enums/quiz-state.js

@@ -0,0 +1,5 @@
+export default {
+  notStart: 'NOT_STARTED', // 未开始
+  ongoing: 'ONGOING', // 正在进行
+  closed: 'CLOSED' // 已结束
+}

+ 37 - 0
src/server/question.js

@@ -0,0 +1,37 @@
+import axios from '@/plugins/axios'
+import { useQuestionServer } from '@/config/server-info'
+
+export function getQuestionList({
+  stem,
+  size,
+  page,
+  sort
+}) {
+  const params = { stem, size, page, sort }
+  return axios
+    .get(useQuestionServer(), { params })
+    .then(res => res.data)
+}
+
+export function getQuestionById(questionId) {
+  return axios
+    .get(useQuestionServer(`/${questionId}`))
+    .then(res => res.data)
+}
+
+export function createQuestion(questionBody) {
+  return axios
+    .post(useQuestionServer(), questionBody)
+    .then(res => res.data)
+}
+
+export function updateQuestion({ questionId, questionBody }) {
+  return axios
+    .post(useQuestionServer(`/${questionId}`), questionBody)
+    .then(res => res.data)
+}
+
+export function deleteQuestion(questionId) {
+  return axios
+    .delete(useQuestionServer(`/${questionId}`))
+}

+ 64 - 0
src/server/quiz.js

@@ -0,0 +1,64 @@
+import axios from '@/plugins/axios'
+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 }
+  return axios
+    .get(useQuizServer(), { params })
+    .then(res => res.data)
+}
+
+export function getQuizById(quizId) {
+  return axios
+    .get(useQuizServer(`/${quizId}`))
+    .then(res => res.data)
+}
+
+export function createQuiz({
+  name, // 测试名
+  quizTime, // 测试时间
+  slideId, // 幻灯片 id
+  questions = [] // 题目 id;
+}) {
+  const data = { name, quizTime, slideId, questions }
+  return axios
+    .post(useQuizServer(), data)
+    .then(res => res.data)
+}
+
+export function updateQuiz({
+  id, // 考试 id
+  name, // 测试名
+  quizTime, // 测试时间
+  slideId, // 幻灯片 id
+  questions = [] // 题目 id;
+}) {
+  const data = { id, name, quizTime, slideId, questions }
+  return axios
+    .post(useQuizServer(`/${id}`), data)
+    .then(res => res.data)
+}
+
+export function deleteQuiz(quizId) {
+  return axios
+    .delete(useQuizServer(`/${quizId}`))
+}
+
+export function submitQuiz({ quizId, answers = [] }) {
+  return axios
+    .post(useQuizServer(`/${quizId}/student-answer`), { answers })
+    .then(res => res.data)
+}
+
+export function getQuizResult(quizId) {
+  return axios
+    .get(useQuizServer(`/${quizId}/result`))
+    .then(res => res.data)
+}

+ 8 - 0
src/style/icon.scss

@@ -0,0 +1,8 @@
+.eva-icon {
+  display: inline-block;
+  color: inherit;
+  line-height: 0;
+  text-transform: none;
+  font-size: 20px;
+  vertical-align: -0.25em;
+}

+ 5 - 0
src/style/markdown.scss

@@ -0,0 +1,5 @@
+.markdown {
+  img{
+    max-width: 100%;
+  }
+}

+ 57 - 0
src/utils/markdown.js

@@ -0,0 +1,57 @@
+import marked from 'marked'
+
+export function markdownToHTML(markdownData = '') {
+  return marked(markdownData, {
+    pedantic: false,
+    breaks: false,
+    gfm: true
+  })
+}
+
+/**
+ * tiptap 使用的工具函数
+ */
+export function jsonToMarkdown(jsonData) {
+  switch (jsonData?.type) {
+    case 'doc':
+      return reduceContent(jsonData)
+    case 'paragraph':
+      return reduceContent(jsonData) + '\n\n'
+    case 'text':
+      return decorateText(jsonData)
+    case 'image':
+      return `![${jsonData?.attrs?.alt ?? ''}](${jsonData?.attrs?.src})`
+    default:
+      return ''
+  }
+}
+
+function reduceContent(jsonData) {
+  return (
+    jsonData?.content?.reduce(
+      (acc, data) => `${acc}${jsonToMarkdown(data)}`,
+      ''
+    ) ?? ''
+  )
+}
+
+function secureContent(text = '') {
+  return text.replace(/</g, '\\<').replace(/>/g, '\\>')
+}
+
+function decorateText({ text, marks = [] }) {
+  return marks.reduce((acc, current) => {
+    switch (current?.type) {
+      case 'bold':
+        return `**${acc}**`
+      case 'code':
+        return `\`${acc}\``
+      case 'italic':
+        return `_${acc}_`
+      case 'strike':
+        return `~${acc}~`
+      default:
+        return current
+    }
+  }, secureContent(text))
+}

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

@@ -0,0 +1,33 @@
+<template>
+  <div class="create-question">
+    <back-title>创建题目</back-title>
+    <question-kind-bar v-model="currentKind" />
+    <choice-question-editor @input="handleInput" />
+  </div>
+</template>
+
+<script>
+import BackTitle from '@/components/BackTitle'
+import QuestionKindBar from '@/components/QuestionKindBar'
+import QuestionKind from '@/server/enums/question-kind'
+import ChoiceQuestionEditor from '@/views/quiz/components/ChoiceQuestionEditor'
+export default {
+  components: { ChoiceQuestionEditor, QuestionKindBar, BackTitle },
+  data() {
+    return { currentKind: QuestionKind.choice }
+  },
+  methods: {
+    handleInput(event) {
+      console.log(event)
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+  .create-question {
+    margin: 0 auto;
+    max-width: 1100px;
+    padding: 64px 24px;
+  }
+</style>

+ 9 - 0
src/views/quiz/QuestionDetail.vue

@@ -0,0 +1,9 @@
+<template>
+  <div></div>
+</template>
+
+<script>
+export default {
+
+}
+</script>

+ 6 - 1
src/views/quiz/Questions.vue

@@ -1,13 +1,18 @@
 <template>
   <div class="questions">
     <back-title>题库界面</back-title>
+    <div>
+      <question-table />
+    </div>
   </div>
 </template>
 
 <script>
 import BackTitle from '@/components/BackTitle'
+import QuestionTable from '@/views/quiz/components/QuestionTable'
+
 export default {
-  components: { BackTitle }
+  components: { QuestionTable, BackTitle }
 }
 </script>
 

+ 140 - 0
src/views/quiz/components/ChoiceQuestionEditor.vue

@@ -0,0 +1,140 @@
+<template>
+  <div>
+
+    <section>
+      <h3>题干:</h3>
+      <editor
+        :init-content="initValue.stem"
+        @update-markdown="updateStem"
+      />
+    </section>
+    <section>
+      <h3>选项:</h3>
+      <section
+        v-for="(item, index) in initValue.options"
+        :key="index"
+        class="choice"
+      >
+        <div class="choice__index">{{ index }}</div>
+        <editor
+          class="choice__content"
+          :init-content="item"
+          @update-markdown="updateOptions(index, $event)"
+        />
+        <c-button
+          class="icon"
+          @click="removeOption(index)"
+        >
+          <eva-icon-trash2-outline />
+        </c-button>
+      </section>
+      <c-button class="icon blue-icon" @click="addNewOption">
+        <eva-icon-plus />
+      </c-button>
+    </section>
+    <section>
+      <h3>答案:</h3>
+      <section>
+        <editor
+          :init-content="value.answer"
+          @update-markdown="updateAnswer"
+        />
+      </section>
+    </section>
+    <section>
+      <h3>解析:</h3>
+      <section>
+        <editor
+          :init-content="value.analysis"
+          @update-markdown="updateAnalysis"
+        />
+      </section>
+    </section>
+  </div>
+</template>
+
+<script>
+import Editor from '@/components/Editor'
+import CButton from '@/components/CButton'
+import EvaIconTrash2Outline from '@/components/EvaIcon/EvaIconTrash2Outline'
+import EvaIconPlus from '@/components/EvaIcon/EvaIconPlus'
+
+const createDefaultChoiceQuestion = () => ({
+  stem: '',
+  options: {},
+  answer: '',
+  analysis: ''
+})
+
+const getCharFromIndex = (index = 0) =>
+  String.fromCharCode('A'.charCodeAt(0) + index)
+
+export default {
+  name: 'ChoiceQuestionEditor',
+  components: { EvaIconPlus, EvaIconTrash2Outline, CButton, Editor },
+  props: {
+    initValue: {
+      type: Object,
+      default: createDefaultChoiceQuestion
+    }
+  },
+  data() {
+    return {
+      value: createDefaultChoiceQuestion()
+    }
+  },
+  methods: {
+
+    updateAnswer(text) {
+      this.value.answer = text
+      this.handleInput()
+    },
+
+    updateOptions(index, text) {
+      if (this.value.options) {
+        this.value.options[index] = text
+        this.handleInput()
+      }
+    },
+
+    addNewOption() {
+      const options = this.value.options || {}
+      const choice = getCharFromIndex(Object.keys(options).length)
+      this.value.options = {
+        ...options,
+        [choice]: ''
+      }
+    },
+
+    removeOption(index) {
+      const options = this.value.options || {}
+      delete options[index]
+      this.value.options = Object.values(options).reduce((acc, curr, index) => {
+        const choice = getCharFromIndex(index)
+        return {
+          ...acc,
+          [choice]: curr
+        }
+      }, {})
+    },
+
+    updateStem(text) {
+      this.value.stem = text
+      this.handleInput()
+    },
+
+    updateAnalysis(text) {
+      this.value.analysis = text
+      this.handleInput()
+    },
+
+    handleInput() {
+      this.$nextTick(() => this.$emit('input', this.value))
+    }
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 61 - 0
src/views/quiz/components/QuestionCard.vue

@@ -0,0 +1,61 @@
+<template>
+  <div>
+    <div class="question-card__header mb-8">
+      <span>
+        <span class="question-id">{{ questionId }}</span>
+        <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>
+      </span>
+    </div>
+    <div class="markdown" v-html="markdownToHTML(stem)"/>
+  </div>
+</template>
+
+<script>
+import { markdownToHTML } from '@/utils/markdown'
+import * as QuestionKind from '@/server/enums/question-kind'
+
+export default {
+  name: 'QuestionCard',
+  props: {
+    stem: {
+      type: String,
+      default: ''
+    },
+    questionId: {
+      type: Number,
+      required: true
+    },
+    kind: {
+      type: String,
+      required: true
+    }
+  },
+  data() {
+    return { markdownToHTML: markdownToHTML, QuestionKind }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import "~@/style/theme.scss";
+
+.question-id{
+  font-weight: bold;
+}
+.question-card__header{
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.action{
+  color: $theme-blue;
+
+  &:hover{
+    color: darken($theme-blue, 10);
+  }
+}
+</style>

+ 65 - 0
src/views/quiz/components/QuestionTable.vue

@@ -0,0 +1,65 @@
+<template>
+  <div>
+    <c-input class="mb-16" v-model="inputStem" placeholder="题干搜索" @input="debounceFetchQuestions"></c-input>
+    <div>
+      <question-card
+        class="question-card"
+        v-for="item in questionList"
+        :key="item.id"
+        :kind="item.kind"
+        :stem="item.stem"
+        :question-id="item.id" />
+    </div>
+    <pagination class="mt-16" :page="page.number" :size="page.size" :total-elements="page.totalElements" @change="fetchQuestionList"></pagination>
+  </div>
+</template>
+
+<script>
+import debounce from '@/utils/debounce'
+import { getQuestionList } from '@/server/question'
+import Pagination from '@/components/Pagination'
+import CInput from '@/components/CInput'
+import { markdownToHTML } from '@/utils/markdown'
+import QuestionCard from '@/views/quiz/components/QuestionCard'
+
+export default {
+  name: 'QuestionTable',
+  components: { QuestionCard, CInput, Pagination },
+  filters: {
+    markdownToHTML: markdownToHTML
+  },
+  data() {
+    return {
+      inputStem: '',
+      questionList: [],
+      page: {},
+
+      // 模糊搜索时使用
+      debounceFetchQuestions: debounce(() => this.fetchQuestionList())
+    }
+  },
+  mounted() {
+    this.fetchQuestionList()
+  },
+  methods: {
+    fetchQuestionList({
+      stem = this.inputStem,
+      page = 1,
+      size = 10
+    } = {}) {
+      getQuestionList({ stem, size, page })
+        .then(({ questions, page }) => {
+          this.questionList = questions
+          this.page = page
+        })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.question-card {
+  padding: 16px 0;
+  border-bottom: 1px solid rgba(12, 36, 124, 0.12);
+}
+</style>

+ 12 - 0
src/views/quiz/components/TrueFalseQuestionEditor.vue

@@ -0,0 +1,12 @@
+<template>
+  <div></div>
+</template>
+
+<script>
+export default {
+  name: 'TrueFalseQuestionEditor'
+}
+</script>
+
+<style scoped>
+</style>