فهرست منبع

feat(question): 完成基本题库部分逻辑

WuXinyu 6 سال پیش
والد
کامیت
c1ff091efb

+ 13 - 0
mock/api/question/_questionId/get.json

@@ -0,0 +1,13 @@
+{
+  "kind": "CHOICE",
+  "stem": "计算机中常采用原码、反码、补码和移码表奈数据,其中;±0 编码相同的是( )。",
+  "options": {
+    "A": "原码和补码",
+    "B": "反码和补码\n\n>",
+    "C": "补码和移码",
+    "D": "原码和移码"
+  },
+  "answer": "C",
+  "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
+}

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

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

+ 0 - 1
src/App.vue

@@ -32,7 +32,6 @@ export default {
 <style lang="scss">
 @import "~@/style/reset.scss";
 @import "~@/style/theme.scss";
-@import "~@/style/markdown.scss";
 @import "~@/style/icon.scss";
 
 @font-face {

+ 214 - 0
src/components/Editor.vue

@@ -88,4 +88,218 @@ export default {
   height: 0;
   font-style: italic;
 }
+
+.editor {
+  font-family: 'Inter';
+  font-size: 18px;
+  color: black;
+  line-height: 1.5;
+
+  &__content {
+
+    strong {
+      font-weight: bold;
+    }
+
+    em {
+      font-style: italic;
+    }
+
+    &:focus {
+      outline: none;
+    }
+
+    * {
+      margin: 0;
+      padding: 0;
+      box-sizing: border-box;
+      text-size-adjust: 100%;
+      -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+      -webkit-touch-callout: none;
+      -webkit-font-smoothing: antialiased;
+      -moz-osx-font-smoothing: grayscale;
+      text-rendering: optimizeLegibility;
+
+      &:focus {
+        outline: none;
+      }
+    }
+
+    *::before,
+    *::after {
+      box-sizing: border-box;
+    }
+
+    a {
+      color: inherit;
+    }
+
+    h1,
+    h2,
+    h3,
+    p,
+    ul,
+    ol,
+    pre,
+    blockquote {
+      margin: 1rem 0;
+
+      &:first-child {
+        margin-top: 0;
+      }
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    h1,
+    h2,
+    h3 {
+      line-height: 1.3;
+    }
+
+    .button {
+      font-weight: bold;
+      display: inline-flex;
+      border: 0;
+      color: black;
+      padding: 0.2rem 0.5rem;
+      margin-right: 0.2rem;
+      border-radius: 3px;
+      cursor: pointer;
+      background-color: rgba(black, 0.1);
+
+      &:hover {
+        background-color: rgba(black, 0.15);
+      }
+    }
+
+    .message {
+      background-color: rgba(black, 0.05);
+      color: rgba(black, 0.7);
+      padding: 1rem;
+      border-radius: 6px;
+      margin-bottom: 1.5rem;
+      font-style: italic;
+    }
+
+    overflow-wrap: break-word;
+    word-wrap: break-word;
+    word-break: break-word;
+
+    * {
+      caret-color: currentColor;
+    }
+
+    pre {
+      padding: 0.7rem 1rem;
+      border-radius: 5px;
+      background: black;
+      color: white;
+      font-size: 0.8rem;
+      overflow-x: auto;
+
+      code {
+        display: block;
+      }
+    }
+
+    p code {
+      display: inline-block;
+      padding: 0 0.4rem;
+      border-radius: 5px;
+      font-size: 0.8rem;
+      font-weight: bold;
+      background: rgba(black, 0.1);
+      color: rgba(black, 0.8);
+    }
+
+    ul,
+    ol {
+      padding-left: 1rem;
+    }
+
+    li > p,
+    li > ol,
+    li > ul {
+      margin: 0;
+    }
+
+    a {
+      color: inherit;
+    }
+
+    blockquote {
+      border-left: 3px solid rgba(black, 0.1);
+      color: rgba(black, 0.8);
+      padding-left: 0.8rem;
+      font-style: italic;
+
+      p {
+        margin: 0;
+      }
+    }
+
+    img {
+      max-width: 100%;
+      border-radius: 3px;
+    }
+
+    table {
+      border-collapse: collapse;
+      table-layout: fixed;
+      width: 100%;
+      margin: 0;
+      overflow: hidden;
+
+      td, th {
+        min-width: 1em;
+        border: 2px solid grey;
+        padding: 3px 5px;
+        vertical-align: top;
+        box-sizing: border-box;
+        position: relative;
+        > * {
+          margin-bottom: 0;
+        }
+      }
+
+      th {
+        font-weight: bold;
+        text-align: left;
+      }
+
+      .selectedCell:after {
+        z-index: 2;
+        position: absolute;
+        content: "";
+        left: 0; right: 0; top: 0; bottom: 0;
+        background: rgba(200, 200, 255, 0.4);
+        pointer-events: none;
+      }
+
+      .column-resize-handle {
+        position: absolute;
+        right: -2px; top: 0; bottom: 0;
+        width: 4px;
+        z-index: 20;
+        background-color: #adf;
+        pointer-events: none;
+      }
+    }
+
+    .tableWrapper {
+      margin: 1em 0;
+      overflow-x: auto;
+    }
+
+    .resize-cursor {
+      cursor: ew-resize;
+      cursor: col-resize;
+    }
+
+  }
+}
+
 </style>

+ 0 - 5
src/style/markdown.scss

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

+ 13 - 5
src/views/quiz/CreateQuestion.vue

@@ -2,7 +2,8 @@
   <div class="create-question">
     <back-title>创建题目</back-title>
     <question-kind-bar v-model="currentKind" />
-    <choice-question-editor @input="handleInput" />
+    <choice-question-editor v-if="currentKind === QuestionKind.choice" @submit="create($event, QuestionKind.choice)" />
+    <true-false-question-editor v-else @submit="create($event, QuestionKind.trueOrFalse)" />
   </div>
 </template>
 
@@ -11,14 +12,21 @@ import BackTitle from '@/components/BackTitle'
 import QuestionKindBar from '@/components/QuestionKindBar'
 import QuestionKind from '@/server/enums/question-kind'
 import ChoiceQuestionEditor from '@/views/quiz/components/ChoiceQuestionEditor'
+import TrueFalseQuestionEditor from '@/views/quiz/components/TrueFalseQuestionEditor'
+import { createQuestion } from '@/server/question'
 export default {
-  components: { ChoiceQuestionEditor, QuestionKindBar, BackTitle },
+  components: { TrueFalseQuestionEditor, ChoiceQuestionEditor, QuestionKindBar, BackTitle },
   data() {
-    return { currentKind: QuestionKind.choice }
+    return { currentKind: QuestionKind.choice, QuestionKind }
   },
   methods: {
-    handleInput(event) {
-      console.log(event)
+    create(questionBody, kind) {
+      createQuestion({ ...questionBody, kind })
+        .then(() => this.$setSuccessMessage('创建成功'))
+        .then(() => this.$router.go(-1))
+        .catch(err =>
+          this.$setErrorMessage(err.response.data.msg)
+        )
     }
   }
 }

+ 24 - 2
src/views/quiz/QuestionDetail.vue

@@ -1,9 +1,31 @@
 <template>
-  <div></div>
+  <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" />
+  </div>
 </template>
 
 <script>
-export default {
+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'
 
+export default {
+  components: { ChoiceQuestionDetail, TrueFalseQuestionDetail, BackTitle },
+  data() {
+    return {
+      question: {},
+      QuestionKind
+    }
+  },
+  mounted() {
+    const { questionId } = this.$route.params
+    getQuestionById(questionId)
+      .then(res => (this.question = res))
+      .catch(err => this.$setErrorMessage(err.response.data.msg))
+  }
 }
 </script>

+ 50 - 0
src/views/quiz/components/ChoiceQuestionDetail.vue

@@ -0,0 +1,50 @@
+<template>
+  <div>
+    <editor-section title="题干">
+      <markdown-section :raw="value.stem" />
+    </editor-section>
+    <editor-section title="选项">
+      <section
+        v-for="(item, index) in value.options"
+        :key="index"
+        class="choice"
+      >
+        <div class="choice__index">{{ index }}</div>
+        <markdown-section :raw="item" />
+      </section>
+    </editor-section>
+    <editor-section title="答案">
+      {{ value.answer }}
+    </editor-section>
+    <editor-section title="解析">
+      <markdown-section :raw="value.analysis" />
+    </editor-section>
+  </div>
+</template>
+
+<script>
+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'
+
+export default {
+  name: 'ChoiceQuestionDetail',
+  components: { MarkdownSection, EditorSection },
+  props: {
+    value: {
+      type: Object,
+      default: createDefaultChoiceQuestion
+    }
+  },
+  methods: {
+    markdownToHTML
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+  .choice {
+    display: flex;
+  }
+</style>

+ 60 - 63
src/views/quiz/components/ChoiceQuestionEditor.vue

@@ -1,17 +1,14 @@
 <template>
   <div>
-
-    <section>
-      <h3>题干:</h3>
+    <editor-section title="题干">
       <editor
         :init-content="initValue.stem"
-        @update-markdown="updateStem"
+        @update-markdown="value.stem = $event"
       />
-    </section>
-    <section>
-      <h3>选项:</h3>
+    </editor-section>
+    <editor-section title="选项">
       <section
-        v-for="(item, index) in initValue.options"
+        v-for="(item, index) in value.options"
         :key="index"
         class="choice"
       >
@@ -21,61 +18,61 @@
           :init-content="item"
           @update-markdown="updateOptions(index, $event)"
         />
-        <c-button
-          class="icon"
+        <button
           @click="removeOption(index)"
         >
           <eva-icon-trash2-outline />
-        </c-button>
+        </button>
+        <button
+          :style="{ background: index === value.answer? 'green': 'inherit' }"
+          @click="index === value.answer? value.answer = '': value.answer = index"
+        >
+          <eva-icon-checkmark />
+        </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"
-        />
+        <button class="icon blue-icon" @click="addNewOption">
+          <eva-icon-plus />
+        </button>
       </section>
-    </section>
-    <section>
-      <h3>解析:</h3>
-      <section>
-        <editor
-          :init-content="value.analysis"
-          @update-markdown="updateAnalysis"
-        />
-      </section>
-    </section>
+    </editor-section>
+    <editor-section title="答案">
+      {{ value.answer || '请选择答案' }}
+    </editor-section>
+    <editor-section title="解析">
+      <editor
+        :init-content="value.analysis"
+        @update-markdown="value.analysis = $event"
+      />
+    </editor-section>
+    <button @click="handleSubmit">{{ submitText }}</button>
   </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)
+import EvaIconCheckmark from '@/components/EvaIcon/EvaIconCheckmark'
+import EditorSection from '@/views/quiz/components/EditorSection'
+import getCharFromIndex from '@/views/quiz/utils/getCharFromIndex'
+import { createDefaultChoiceQuestion } from '@/views/quiz/utils/default'
 
 export default {
   name: 'ChoiceQuestionEditor',
-  components: { EvaIconPlus, EvaIconTrash2Outline, CButton, Editor },
+  components: { EditorSection, EvaIconCheckmark, EvaIconPlus, EvaIconTrash2Outline, Editor },
   props: {
     initValue: {
       type: Object,
       default: createDefaultChoiceQuestion
+    },
+    readonly: {
+      type: Boolean,
+      default: false
+    },
+    submitText: {
+      type: String,
+      default: '提交'
     }
   },
   data() {
@@ -83,17 +80,26 @@ export default {
       value: createDefaultChoiceQuestion()
     }
   },
+  watch: {
+    value: {
+      deep: true,
+      handler: nextValue => this.$emit('input', nextValue)
+    }
+  },
   methods: {
 
-    updateAnswer(text) {
-      this.value.answer = text
-      this.handleInput()
+    handleSubmit() {
+      const { stem, options, answer, analysis } = this.value
+      if (!stem) this.$setErrorMessage('请输入题干')
+      else if (!options?.A) this.$setErrorMessage('请输入选项')
+      else if (!answer) this.$setErrorMessage('请选择答案')
+      else if (!analysis) this.$setErrorMessage('请输入解析')
+      else this.$emit('submit', this.value)
     },
 
     updateOptions(index, text) {
       if (this.value.options) {
         this.value.options[index] = text
-        this.handleInput()
       }
     },
 
@@ -107,6 +113,9 @@ export default {
     },
 
     removeOption(index) {
+      // 同时删除答案
+      this.value.answer = ''
+
       const options = this.value.options || {}
       delete options[index]
       this.value.options = Object.values(options).reduce((acc, curr, index) => {
@@ -116,25 +125,13 @@ export default {
           [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 lang="scss" scoped>
+.choice {
+  display: flex;
+}
 </style>

+ 22 - 0
src/views/quiz/components/EditorSection.vue

@@ -0,0 +1,22 @@
+<template>
+  <section>
+    <h3>{{ title }}:</h3>
+    <slot />
+  </section>
+</template>
+
+<script>
+export default {
+  name: 'EditorSection',
+  props: {
+    title: {
+      type: String,
+      required: true
+    }
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 27 - 0
src/views/quiz/components/MarkdownSection.vue

@@ -0,0 +1,27 @@
+<template>
+  <div class="markdown-section editor" v-html="markdownToHTML(raw)" />
+</template>
+
+<script>
+import { markdownToHTML } from '@/utils/markdown'
+export default {
+  name: 'MarkdownSection',
+  props: {
+    raw: {
+      type: String,
+      default: ''
+    }
+  },
+  methods: {
+    markdownToHTML
+  }
+}
+</script>
+
+<style lang="scss">
+.markdown-section {
+  img{
+    max-width: 100%;
+  }
+}
+</style>

+ 4 - 3
src/views/quiz/components/QuestionCard.vue

@@ -9,16 +9,17 @@
         <router-link class="action" style="margin-right: 16px;" :to="`/questions/${questionId}`">详情</router-link>
       </span>
     </div>
-    <div class="markdown" v-html="markdownToHTML(stem)"/>
+    <markdown-section :raw="stem" />
   </div>
 </template>
 
 <script>
-import { markdownToHTML } from '@/utils/markdown'
 import * as QuestionKind from '@/server/enums/question-kind'
+import MarkdownSection from '@/views/quiz/components/MarkdownSection'
 
 export default {
   name: 'QuestionCard',
+  components: { MarkdownSection },
   props: {
     stem: {
       type: String,
@@ -34,7 +35,7 @@ export default {
     }
   },
   data() {
-    return { markdownToHTML: markdownToHTML, QuestionKind }
+    return { QuestionKind }
   }
 }
 </script>

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

@@ -1,6 +1,11 @@
 <template>
   <div>
     <c-input class="mb-16" v-model="inputStem" placeholder="题干搜索" @input="debounceFetchQuestions"></c-input>
+    <router-link to="/create-question">
+      <button>
+        创建题目
+      </button>
+    </router-link>
     <div>
       <question-card
         class="question-card"

+ 40 - 0
src/views/quiz/components/TrueFalseQuestionDetail.vue

@@ -0,0 +1,40 @@
+<template>
+  <div>
+    <editor-section title="题干">
+      <markdown-section :raw="value.stem" />
+    </editor-section>
+    <editor-section title="答案">
+      <eva-icon-checkmark v-if="value.answer" />
+      <eva-icon-close-outline v-else />
+    </editor-section>
+    <editor-section title="解析">
+      <markdown-section :raw="value.analysis" />
+    </editor-section>
+  </div>
+</template>
+
+<script>
+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 EvaIconCheckmark from '@/components/EvaIcon/EvaIconCheckmark'
+import EvaIconCloseOutline from '@/components/EvaIcon/EvaIconCloseOutline'
+
+export default {
+  name: 'TrueFalseQuestionDetail',
+  components: { EvaIconCloseOutline, EvaIconCheckmark, MarkdownSection, EditorSection },
+  props: {
+    value: {
+      type: Object,
+      default: createDefaultChoiceQuestion
+    }
+  },
+  methods: {
+    markdownToHTML
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 68 - 3
src/views/quiz/components/TrueFalseQuestionEditor.vue

@@ -1,12 +1,77 @@
 <template>
-  <div></div>
+  <div>
+    <editor-section title="题干">
+      <editor
+        :init-content="initValue.stem"
+        @update-markdown="value.stem = $event"
+      />
+    </editor-section>
+    <editor-section title="答案">
+      <button
+        :style="{ background: value.answer? 'green': 'red' }"
+        @click="value.answer = !value.answer"
+      >
+        <eva-icon-checkmark v-if="value.answer" />
+        <eva-icon-close-outline v-else />
+      </button>
+    </editor-section>
+    <editor-section title="解析">
+      <editor
+        :init-content="value.analysis"
+        @update-markdown="value.analysis = $event"
+      />
+    </editor-section>
+    <button @click="handleSubmit">{{ submitText }}</button>
+  </div>
 </template>
 
 <script>
+import Editor from '@/components/Editor'
+import EvaIconCheckmark from '@/components/EvaIcon/EvaIconCheckmark'
+import EditorSection from '@/views/quiz/components/EditorSection'
+import EvaIconCloseOutline from '@/components/EvaIcon/EvaIconCloseOutline'
+import { createDefaultTrueFalseQuestion } from '@/views/quiz/utils/default'
+
 export default {
-  name: 'TrueFalseQuestionEditor'
+  name: 'TrueFalseQuestionEditor',
+  components: { EvaIconCloseOutline, EditorSection, EvaIconCheckmark, Editor },
+  props: {
+    initValue: {
+      type: Object,
+      default: createDefaultTrueFalseQuestion
+    },
+    readonly: {
+      type: Boolean,
+      default: false
+    },
+    submitText: {
+      type: String,
+      default: '提交'
+    }
+  },
+  data() {
+    return {
+      value: createDefaultTrueFalseQuestion()
+    }
+  },
+  watch: {
+    value: {
+      deep: true,
+      handler: nextValue => this.$emit('input', nextValue)
+    }
+  },
+  methods: {
+    handleSubmit() {
+      const { stem, options, answer, analysis } = this.value
+      if (!stem) this.$setErrorMessage('请输入题干')
+      else if (!options?.A) this.$setErrorMessage('请输入选项')
+      else if (!answer) this.$setErrorMessage('请选择答案')
+      else if (!analysis) this.$setErrorMessage('请输入解析')
+      else this.$emit('submit', this.value)
+    }
+  }
 }
 </script>
 
-<style scoped>
+<style lang="scss" scoped>
 </style>

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

@@ -0,0 +1,12 @@
+export const createDefaultChoiceQuestion = () => ({
+  stem: '',
+  options: {},
+  answer: '',
+  analysis: ''
+})
+
+export const createDefaultTrueFalseQuestion = () => ({
+  stem: '',
+  answer: true,
+  analysis: ''
+})

+ 4 - 0
src/views/quiz/utils/getCharFromIndex.js

@@ -0,0 +1,4 @@
+const getCharFromIndex = (index = 0) =>
+  String.fromCharCode('A'.charCodeAt(0) + index)
+
+export default getCharFromIndex

+ 13 - 1
vue.config.js

@@ -2,7 +2,19 @@ const webpack = require('webpack')
 
 module.exports = {
   devServer: {
-    proxy: 'http://localhost:4000'
+    proxy: {
+      '^/api/question': {
+        target: 'http://localhost:4000',
+        ws: true,
+        changeOrigin: true
+      },
+
+      '^/api/quiz': {
+        target: 'http://localhost:4000',
+        ws: true,
+        changeOrigin: true
+      }
+    }
   },
   configureWebpack: {
     externals: {