فهرست منبع

练习题 选择题

dusenyao 1 سال پیش
والد
کامیت
e49b30f441
45فایلهای تغییر یافته به همراه2099 افزوده شده و 144 حذف شده
  1. 1 4
      .eslintrc.js
  2. 3 3
      .vscode/base.code-snippets
  3. 1 1
      .vscode/settings.json
  4. 59 60
      package-lock.json
  5. 7 4
      package.json
  6. 9 0
      public/tinymce/skins/content/index.css
  7. 23 1
      src/App.vue
  8. 63 0
      src/api/exercise.js
  9. 38 3
      src/common/SvgIcon/index.vue
  10. 158 0
      src/components/common/RichText.vue
  11. 3 0
      src/icons/svg/add-circle.svg
  12. 12 0
      src/icons/svg/child.svg
  13. 3 0
      src/icons/svg/close.svg
  14. 10 0
      src/icons/svg/copy.svg
  15. 5 0
      src/icons/svg/delete.svg
  16. 3 0
      src/icons/svg/eye.svg
  17. 3 0
      src/icons/svg/loop.svg
  18. 5 0
      src/icons/svg/setting.svg
  19. 2 2
      src/layouts/default/breadcrumb/index.vue
  20. 1 1
      src/layouts/default/header/index.vue
  21. 18 0
      src/router/modules/exercise.js
  22. 2 1
      src/router/modules/index.js
  23. 43 0
      src/styles/common.scss
  24. 12 3
      src/styles/element-variables.scss
  25. 29 1
      src/styles/element.scss
  26. 4 4
      src/styles/index.scss
  27. 29 0
      src/styles/mixin.scss
  28. 8 0
      src/styles/variables.scss
  29. 16 10
      src/utils/http.js
  30. 4 0
      src/utils/index.js
  31. 15 0
      src/utils/transform.js
  32. 14 0
      src/utils/validate.js
  33. 91 0
      src/views/exercise_questions/create/components/SelectQuestionType.vue
  34. 238 0
      src/views/exercise_questions/create/components/create.vue
  35. 78 0
      src/views/exercise_questions/create/components/exercises/QuestionBase.vue
  36. 309 0
      src/views/exercise_questions/create/components/exercises/SelectQuestion.vue
  37. 283 0
      src/views/exercise_questions/create/index.vue
  38. 54 0
      src/views/exercise_questions/data/common.js
  39. 30 0
      src/views/exercise_questions/data/select.js
  40. 141 0
      src/views/exercise_questions/preview/SelectPreview.vue
  41. 15 9
      src/views/home/common.vue
  42. 109 12
      src/views/home/personal_question/CreateExercise.vue
  43. 88 11
      src/views/home/personal_question/index.vue
  44. 59 13
      src/views/home/public_question/index.vue
  45. 1 1
      src/views/login/index.vue

+ 1 - 4
.eslintrc.js

@@ -12,10 +12,7 @@ module.exports = {
     }
   },
 
-  globals: {
-    Rtc: true,
-    DocumentUpload: true
-  },
+  globals: {},
 
   env: {
     node: true,

+ 3 - 3
.vscode/base.code-snippets

@@ -4,10 +4,10 @@
     "prefix": "http",
     "body": [
       "/**",
-      " * $4",
+      " * $2",
       " */",
-      "export function ${0:n}(data) {",
-      "  return http.${1|get,post,put,delete|}(`${process.env.${2|VUE_APP_FileServer,VUE_APP_LearnWebSI,VUE_APP_BookWebSI|}}?MethodName=$3`, data)",
+      "export function ${0:fn}(data) {",
+      "  return http.${1|post,get,put,delete|}(`$CLIPBOARD`, data)",
       "}"
     ],
     "description": "http 请求封装"

+ 1 - 1
.vscode/settings.json

@@ -1,3 +1,3 @@
 {
-  "path-intellisense.mappings": { "@": "${workspaceFolder}/src" }
+  "cSpell.words": ["cascader"]
 }

+ 59 - 60
package-lock.json

@@ -48,9 +48,9 @@
       "dev": true
     },
     "@babel/core": {
-      "version": "7.23.0",
-      "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.23.0.tgz",
-      "integrity": "sha512-97z/ju/Jy1rZmDxybphrBuI+jtJjFVoz7Mr9yUQVVVi+DNZE333uFQeMOqcCIy1x3WYBIbWftUSLmbNXNT7qFQ==",
+      "version": "7.23.2",
+      "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.23.2.tgz",
+      "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==",
       "dev": true,
       "requires": {
         "@ampproject/remapping": "^2.2.0",
@@ -58,16 +58,36 @@
         "@babel/generator": "^7.23.0",
         "@babel/helper-compilation-targets": "^7.22.15",
         "@babel/helper-module-transforms": "^7.23.0",
-        "@babel/helpers": "^7.23.0",
+        "@babel/helpers": "^7.23.2",
         "@babel/parser": "^7.23.0",
         "@babel/template": "^7.22.15",
-        "@babel/traverse": "^7.23.0",
+        "@babel/traverse": "^7.23.2",
         "@babel/types": "^7.23.0",
         "convert-source-map": "^2.0.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
         "json5": "^2.2.3",
         "semver": "^6.3.1"
+      },
+      "dependencies": {
+        "@babel/traverse": {
+          "version": "7.23.2",
+          "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.23.2.tgz",
+          "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
+          "dev": true,
+          "requires": {
+            "@babel/code-frame": "^7.22.13",
+            "@babel/generator": "^7.23.0",
+            "@babel/helper-environment-visitor": "^7.22.20",
+            "@babel/helper-function-name": "^7.23.0",
+            "@babel/helper-hoist-variables": "^7.22.5",
+            "@babel/helper-split-export-declaration": "^7.22.6",
+            "@babel/parser": "^7.23.0",
+            "@babel/types": "^7.23.0",
+            "debug": "^4.1.0",
+            "globals": "^11.1.0"
+          }
+        }
       }
     },
     "@babel/eslint-parser": {
@@ -315,14 +335,34 @@
       }
     },
     "@babel/helpers": {
-      "version": "7.23.1",
-      "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.23.1.tgz",
-      "integrity": "sha512-chNpneuK18yW5Oxsr+t553UZzzAs3aZnFm4bxhebsNTeshrC95yA7l5yl7GBAG+JG1rF0F7zzD2EixK9mWSDoA==",
+      "version": "7.23.2",
+      "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.23.2.tgz",
+      "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==",
       "dev": true,
       "requires": {
         "@babel/template": "^7.22.15",
-        "@babel/traverse": "^7.23.0",
+        "@babel/traverse": "^7.23.2",
         "@babel/types": "^7.23.0"
+      },
+      "dependencies": {
+        "@babel/traverse": {
+          "version": "7.23.2",
+          "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.23.2.tgz",
+          "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
+          "dev": true,
+          "requires": {
+            "@babel/code-frame": "^7.22.13",
+            "@babel/generator": "^7.23.0",
+            "@babel/helper-environment-visitor": "^7.22.20",
+            "@babel/helper-function-name": "^7.23.0",
+            "@babel/helper-hoist-variables": "^7.22.5",
+            "@babel/helper-split-export-declaration": "^7.22.6",
+            "@babel/parser": "^7.23.0",
+            "@babel/types": "^7.23.0",
+            "debug": "^4.1.0",
+            "globals": "^11.1.0"
+          }
+        }
       }
     },
     "@babel/highlight": {
@@ -5006,9 +5046,9 @@
       "dev": true
     },
     "eslint-plugin-prettier": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.0.tgz",
-      "integrity": "sha512-AgaZCVuYDXHUGxj/ZGu1u8H8CYgDY3iG6w5kUFw4AzMVXzB7VvbKgYR4nATIN+OvUrghMbiDLeimVjVY5ilq3w==",
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz",
+      "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==",
       "dev": true,
       "requires": {
         "prettier-linter-helpers": "^1.0.0",
@@ -9308,9 +9348,9 @@
       "dev": true
     },
     "sass": {
-      "version": "1.69.1",
-      "resolved": "https://registry.npmmirror.com/sass/-/sass-1.69.1.tgz",
-      "integrity": "sha512-nc969GvTVz38oqKgYYVHM/Iq7Yl33IILy5uqaH2CWSiSUmRCvw+UR7tA3845Sp4BD5ykCUimvrT3k1EjTwpVUA==",
+      "version": "1.69.3",
+      "resolved": "https://registry.npmmirror.com/sass/-/sass-1.69.3.tgz",
+      "integrity": "sha512-X99+a2iGdXkdWn1akFPs0ZmelUzyAQfvqYc2P/MPTrJRuIRoTffGzT9W9nFqG00S+c8hXzVmgxhUuHFdrwxkhQ==",
       "dev": true,
       "requires": {
         "chokidar": ">=3.0.0 <4.0.0",
@@ -9319,53 +9359,12 @@
       }
     },
     "sass-loader": {
-      "version": "10.4.1",
-      "resolved": "https://registry.npmmirror.com/sass-loader/-/sass-loader-10.4.1.tgz",
-      "integrity": "sha512-aX/iJZTTpNUNx/OSYzo2KsjIUQHqvWsAhhUijFjAPdZTEhstjZI9zTNvkTTwsx+uNUJqUwOw5gacxQMx4hJxGQ==",
+      "version": "13.3.2",
+      "resolved": "https://registry.npmmirror.com/sass-loader/-/sass-loader-13.3.2.tgz",
+      "integrity": "sha512-CQbKl57kdEv+KDLquhC+gE3pXt74LEAzm+tzywcA0/aHZuub8wTErbjAoNI57rPUWRYRNC5WUnNl8eGJNbDdwg==",
       "dev": true,
       "requires": {
-        "klona": "^2.0.4",
-        "loader-utils": "^2.0.0",
-        "neo-async": "^2.6.2",
-        "schema-utils": "^3.0.0",
-        "semver": "^7.3.2"
-      },
-      "dependencies": {
-        "lru-cache": {
-          "version": "6.0.0",
-          "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz",
-          "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-          "dev": true,
-          "requires": {
-            "yallist": "^4.0.0"
-          }
-        },
-        "schema-utils": {
-          "version": "3.3.0",
-          "resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-3.3.0.tgz",
-          "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
-          "dev": true,
-          "requires": {
-            "@types/json-schema": "^7.0.8",
-            "ajv": "^6.12.5",
-            "ajv-keywords": "^3.5.2"
-          }
-        },
-        "semver": {
-          "version": "7.5.4",
-          "resolved": "https://registry.npmmirror.com/semver/-/semver-7.5.4.tgz",
-          "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
-          "dev": true,
-          "requires": {
-            "lru-cache": "^6.0.0"
-          }
-        },
-        "yallist": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz",
-          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-          "dev": true
-        }
+        "neo-async": "^2.6.2"
       }
     },
     "schema-utils": {

+ 7 - 4
package.json

@@ -22,7 +22,7 @@
     "vuex": "^3.6.2"
   },
   "devDependencies": {
-    "@babel/core": "^7.23.0",
+    "@babel/core": "^7.23.2",
     "@babel/eslint-parser": "^7.22.15",
     "@rushstack/eslint-patch": "^1.5.1",
     "@vue/cli-plugin-babel": "~5.0.8",
@@ -33,12 +33,12 @@
     "babel-plugin-dynamic-import-node": "^2.3.3",
     "compression-webpack-plugin": "^6.1.1",
     "eslint": "^8.51.0",
-    "eslint-plugin-prettier": "^5.0.0",
+    "eslint-plugin-prettier": "^5.0.1",
     "eslint-plugin-vue": "^9.17.0",
     "postcss-html": "^1.5.0",
     "prettier": "^3.0.3",
-    "sass": "^1.69.1",
-    "sass-loader": "^10.4.1",
+    "sass": "^1.69.3",
+    "sass-loader": "^13.3.2",
     "stylelint": "^15.10.3",
     "stylelint-config-recess-order": "^4.3.0",
     "stylelint-config-recommended-scss": "^13.0.0",
@@ -50,6 +50,9 @@
     "svgo": "^3.0.2",
     "vue-template-compiler": "^2.6.14"
   },
+  "engines": {
+    "node": ">=14.21.3"
+  },
   "browserslist": [
     "> 1%",
     "last 2 versions",

+ 9 - 0
public/tinymce/skins/content/index.css

@@ -0,0 +1,9 @@
+body#tinymce {
+  margin-left: 12px;
+}
+
+.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before {
+  font-size: 14px;
+  color: #86909c;
+  cursor: text;
+}

+ 23 - 1
src/App.vue

@@ -6,6 +6,28 @@
 
 <script>
 export default {
-  name: 'App'
+  name: 'App',
+  created() {
+    // 捕获未处理的错误
+    window.onerror = (msg, url, lineNo, columnNo, error) => {
+      console.log('onerror', msg, url, lineNo, columnNo, error);
+      return true;
+    };
+
+    // 捕获未处理的 Promise 拒绝错误
+    window.addEventListener('unhandledrejection', (event) => {
+      // 阻止 Promise 拒绝默认行为
+      event.preventDefault();
+      // 获取拒绝的 Promise
+      const promise = event.promise;
+      promise.catch((e) => {
+        console.log('catch', e);
+      });
+      // 获取拒绝的原因(错误)
+      // const reason = event.reason;
+      // 处理 Promise 拒绝错误
+      // console.error('未捕获的 Promise.reject 错误:', reason);
+    });
+  }
 };
 </script>

+ 63 - 0
src/api/exercise.js

@@ -0,0 +1,63 @@
+import { http } from '@/utils/http';
+
+/**
+ * 分页查询练习题列表
+ */
+export function PageQueryExerciseList(data) {
+  return http.post(`/TeachingServer/ExerciseManager/PageQueryExerciseList`, data);
+}
+
+/**
+ * 新建练习题
+ */
+export function CreateExercise(data) {
+  return http.post(`/TeachingServer/ExerciseManager/CreateExercise`, data);
+}
+
+/**
+ * 删除练习题
+ * @param {object} data
+ * @param {string} data.exercise_id 练习题id
+ */
+export function DeleteExercise(data) {
+  return http.post(`/TeachingServer/ExerciseManager/DeleteExercise`, data);
+}
+
+/**
+ * 得到标签列表
+ * @param {object} data
+ * @param {string} data.store_type 标签名称
+ */
+export function GetLabelList(data) {
+  return http.post(`/TeachingServer/ExerciseManager/GetLabelList`, data);
+}
+
+/**
+ * 添加题目到练习
+ */
+export function AddQuestionToExercise(data) {
+  return http.post(`/TeachingServer/ExerciseManager/AddQuestionToExercise`, data);
+}
+
+/**
+ * 保存题目
+ */
+export function SaveQuestion(data) {
+  return http.post(`/TeachingServer/ExerciseManager/SaveQuestion`, data);
+}
+
+/**
+ * 得到练习的题目索引列表
+ */
+export function GetExerciseQuestionIndexList(data) {
+  return http.post(`/TeachingServer/ExerciseManager/GetExerciseQuestionIndexList`, data);
+}
+
+/**
+ * 得到题目
+ * @param {object} data
+ * @param {string} data.question_id 题目id
+ */
+export function GetQuestion(data) {
+  return http.post(`/TeachingServer/ExerciseManager/GetQuestion`, data);
+}

+ 38 - 3
src/common/SvgIcon/index.vue

@@ -7,11 +7,23 @@
 
 <script>
 // doc: https://panjiachen.github.io/vue-element-admin-site/feature/component/svg-icon.html#usage
-import { isExternal } from '@/utils/validate';
+import { isExternal, isNumber } from '@/utils/validate';
 
 export default {
   name: 'SvgIcon',
   props: {
+    size: {
+      type: [String, Number],
+      default: ''
+    },
+    width: {
+      type: [String, Number],
+      default: '1rem'
+    },
+    height: {
+      type: [String, Number],
+      default: '1rem'
+    },
     iconClass: {
       type: String,
       required: true
@@ -22,6 +34,12 @@ export default {
     }
   },
   computed: {
+    _width() {
+      return this._getSize(this.width, this.size);
+    },
+    _height() {
+      return this._getSize(this.height, this.size);
+    },
     isExternal() {
       return isExternal(this.iconClass);
     },
@@ -40,14 +58,31 @@ export default {
         '-webkit-mask': `url(${this.iconClass}) no-repeat 50% 50%`
       };
     }
+  },
+  methods: {
+    /**
+     * 获取尺寸
+     * @param {String|Number} value
+     * @param {String|Number} size
+     */
+    _getSize(value, size) {
+      if (size) {
+        if (isNumber(size)) return `${size}px`;
+        return size;
+      }
+      if (isNumber(value)) {
+        return `${value}px`;
+      }
+      return value;
+    }
   }
 };
 </script>
 
 <style scoped>
 .svg-icon {
-  width: 1em;
-  height: 1em;
+  width: v-bind(_width);
+  height: v-bind(_height);
   overflow: hidden;
   vertical-align: -0.15em;
   fill: currentColor;

+ 158 - 0
src/components/common/RichText.vue

@@ -0,0 +1,158 @@
+<template>
+  <Editor
+    v-bind="$attrs"
+    :id="id"
+    ref="richText"
+    :value="value"
+    :class="['rich-text', isBorder ? 'is-border' : '']"
+    :init="init"
+    @input="updateValue"
+    v-on="$listeners"
+  />
+</template>
+
+<script>
+import tinymce from 'tinymce/tinymce';
+import Editor from '@tinymce/tinymce-vue';
+
+import 'tinymce/icons/default/icons';
+import 'tinymce/themes/silver';
+// 引入富文本编辑器主题的js和css
+import 'tinymce/themes/silver/theme.min';
+import 'tinymce/skins/ui/oxide/skin.min.css';
+// 扩展插件
+import 'tinymce/plugins/image';
+import 'tinymce/plugins/link';
+// import 'tinymce/plugins/code';
+// import 'tinymce/plugins/table';
+import 'tinymce/plugins/lists';
+// import 'tinymce/plugins/wordcount'; // 字数统计插件
+import 'tinymce/plugins/media'; // 插入视频插件
+// import 'tinymce/plugins/template'; // 模板插件
+// import 'tinymce/plugins/fullscreen'; // 全屏插件
+// import 'tinymce/plugins/paste';
+// import 'tinymce/plugins/preview'; // 预览插件
+import 'tinymce/plugins/hr';
+import 'tinymce/plugins/autoresize'; // 自动调整大小插件
+
+import { getRandomNumber } from '@/utils';
+
+export default {
+  name: 'RichText',
+  components: {
+    Editor
+  },
+  inheritAttrs: false,
+  props: {
+    inline: {
+      type: Boolean,
+      default: false
+    },
+    placeholder: {
+      type: String,
+      default: '输入内容'
+    },
+    value: {
+      type: String,
+      required: true
+    },
+    height: {
+      type: [Number, String],
+      default: 110
+    },
+    isBorder: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      id: getRandomNumber(),
+      init: {
+        inline: this.inline,
+        language_url: `/tinymce/langs/zh_CN.js`,
+        placeholder: this.placeholder,
+        language: 'zh_CN',
+        skin_url: '/tinymce/skins/ui/oxide',
+        // height: this.height,
+        content_css: '/tinymce/skins/content/index.css',
+        min_height: 52,
+        width: '100%',
+        autoresize_bottom_margin: 0,
+        plugins: 'link lists image hr media autoresize',
+        /* eslint-disable max-len */
+        toolbar:
+          'fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr',
+        menubar: false,
+        branding: false,
+        statusbar: false,
+        audio_template_callback: (data) => {
+          `<audio controls>\n` +
+            `<source src="${data.source}"${data.sourcemime ? ` type="${data.sourcemime}"` : ''} />\n${
+              data.altsource
+                ? `<source src="${data.altsource}"${data.altsourcemime ? ` type="${data.altsourcemime}"` : ''} />\n`
+                : ''
+            }</audio>`;
+        }
+      }
+    };
+  },
+  mounted() {
+    this.addWheelEvent();
+  },
+  methods: {
+    addText() {
+      tinymce.get(this.id).selection?.setContent('<strong>some</strong>');
+    },
+    addWheelEvent() {
+      let richText = this.$refs.richText;
+      if (!richText?.$el?.nextElementSibling?.getElementsByTagName('iframe')?.length) {
+        return setTimeout(this.addWheelEvent, 100);
+      }
+      richText.$el.nextElementSibling.getElementsByTagName('iframe')[0].contentDocument.addEventListener(
+        'wheel',
+        (e) => {
+          if (e.ctrlKey) e.preventDefault();
+        },
+        { passive: false }
+      );
+    },
+    updateValue(data) {
+      this.$emit('update:value', data);
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.rich-text {
+  :deep + .tox {
+    .tox-sidebar-wrap {
+      border: 1px solid $fill-color;
+      border-radius: 4px;
+
+      &:hover {
+        border-color: #c0c4cc;
+      }
+    }
+
+    &.tox-tinymce {
+      border-width: 0;
+      border-radius: 0;
+
+      .tox-edit-area__iframe {
+        background-color: $fill-color;
+      }
+    }
+
+    &:not(.tox-tinymce-inline) .tox-editor-header {
+      box-shadow: none;
+    }
+  }
+
+  :deep &.is-border + .tox.tox-tinymce {
+    border-width: 2px;
+    border-radius: 10px;
+  }
+}
+</style>

+ 3 - 0
src/icons/svg/add-circle.svg

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.41663 6.4165V4.08317H7.58329V6.4165H9.91663V7.58317H7.58329V9.9165H6.41663V7.58317H4.08329V6.4165H6.41663ZM6.99996 12.8332C3.7783 12.8332 1.16663 10.2215 1.16663 6.99984C1.16663 3.77817 3.7783 1.1665 6.99996 1.1665C10.2216 1.1665 12.8333 3.77817 12.8333 6.99984C12.8333 10.2215 10.2216 12.8332 6.99996 12.8332ZM6.99996 11.6665C9.5773 11.6665 11.6666 9.57718 11.6666 6.99984C11.6666 4.42251 9.5773 2.33317 6.99996 2.33317C4.42263 2.33317 2.33329 4.42251 2.33329 6.99984C2.33329 9.57718 4.42263 11.6665 6.99996 11.6665Z" fill="#175DFF"/>
+</svg>

+ 12 - 0
src/icons/svg/child.svg

@@ -0,0 +1,12 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <g clip-path="url(#clip0_647_672)">
+    <path
+      d="M7.66669 6.6665C8.21897 6.6665 8.66669 6.21879 8.66669 5.6665C8.66669 5.11422 8.21897 4.6665 7.66669 4.6665C7.1144 4.6665 6.66669 5.11422 6.66669 5.6665C6.66669 6.21879 7.1144 6.6665 7.66669 6.6665ZM7.66669 10.9998C8.21897 10.9998 8.66669 10.5521 8.66669 9.99984C8.66669 9.44757 8.21897 8.99984 7.66669 8.99984C7.1144 8.99984 6.66669 9.44757 6.66669 9.99984C6.66669 10.5521 7.1144 10.9998 7.66669 10.9998ZM8.66669 14.3332C8.66669 14.8854 8.21897 15.3332 7.66669 15.3332C7.1144 15.3332 6.66669 14.8854 6.66669 14.3332C6.66669 13.7809 7.1144 13.3332 7.66669 13.3332C8.21897 13.3332 8.66669 13.7809 8.66669 14.3332ZM12.3334 6.6665C12.8856 6.6665 13.3334 6.21879 13.3334 5.6665C13.3334 5.11422 12.8856 4.6665 12.3334 4.6665C11.7811 4.6665 11.3334 5.11422 11.3334 5.6665C11.3334 6.21879 11.7811 6.6665 12.3334 6.6665ZM13.3334 9.99984C13.3334 10.5521 12.8856 10.9998 12.3334 10.9998C11.7811 10.9998 11.3334 10.5521 11.3334 9.99984C11.3334 9.44757 11.7811 8.99984 12.3334 8.99984C12.8856 8.99984 13.3334 9.44757 13.3334 9.99984ZM12.3334 15.3332C12.8856 15.3332 13.3334 14.8854 13.3334 14.3332C13.3334 13.7809 12.8856 13.3332 12.3334 13.3332C11.7811 13.3332 11.3334 13.7809 11.3334 14.3332C11.3334 14.8854 11.7811 15.3332 12.3334 15.3332Z"
+      fill="black" fill-opacity="0.32" />
+  </g>
+  <defs>
+    <clipPath id="clip0_647_672">
+      <rect width="16" height="16" fill="white" transform="translate(2 2)" />
+    </clipPath>
+  </defs>
+</svg>

+ 3 - 0
src/icons/svg/close.svg

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.00041 6.17546L9.88773 3.28809L10.7127 4.11304L7.82536 7.00041L10.7127 9.88773L9.88773 10.7127L7.00041 7.82536L4.11304 10.7127L3.28809 9.88773L6.17546 7.00041L3.28809 4.11304L4.11304 3.28809L7.00041 6.17546Z" fill="#2F3742"/>
+</svg>

+ 10 - 0
src/icons/svg/copy.svg

@@ -0,0 +1,10 @@
+<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_647_681)">
+<path d="M2.91658 2.50016V1.25016C2.91658 1.02005 3.10313 0.833496 3.33325 0.833496H8.33325C8.56337 0.833496 8.74992 1.02005 8.74992 1.25016V7.0835C8.74992 7.31362 8.56337 7.50016 8.33325 7.50016H7.08325V8.74979C7.08325 8.98012 6.89579 9.16683 6.66375 9.16683H1.66944C1.43775 9.16683 1.25 8.98158 1.25 8.74979L1.25108 2.91719C1.25112 2.68688 1.4386 2.50016 1.67059 2.50016H2.91658ZM2.08434 3.3335L2.08341 8.3335H6.24992V3.3335H2.08434ZM3.74992 2.50016H7.08325V6.66683H7.91658V1.66683H3.74992V2.50016Z" fill="black"/>
+</g>
+<defs>
+<clipPath id="clip0_647_681">
+<rect width="10" height="10" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 5 - 0
src/icons/svg/delete.svg

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path
+    d="M9.91663 3.49984H12.8333V4.6665H11.6666V12.2498C11.6666 12.572 11.4055 12.8332 11.0833 12.8332H2.91663C2.59446 12.8332 2.33329 12.572 2.33329 12.2498V4.6665H1.16663V3.49984H4.08329V1.74984C4.08329 1.42767 4.34446 1.1665 4.66663 1.1665H9.33329C9.65547 1.1665 9.91663 1.42767 9.91663 1.74984V3.49984ZM10.5 4.6665H3.49996V11.6665H10.5V4.6665ZM5.24996 6.4165H6.41663V9.9165H5.24996V6.4165ZM7.58329 6.4165H8.74996V9.9165H7.58329V6.4165ZM5.24996 2.33317V3.49984H8.74996V2.33317H5.24996Z"
+    fill="currentColor" />
+</svg>

+ 3 - 0
src/icons/svg/eye.svg

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.00022 1.75C10.1456 1.75 12.7624 4.01319 13.3111 7C12.7624 9.98678 10.1456 12.25 7.00022 12.25C3.85478 12.25 1.23796 9.98678 0.689331 7C1.23796 4.01319 3.85478 1.75 7.00022 1.75ZM7.00022 11.0833C9.47098 11.0833 11.5852 9.36367 12.1204 7C11.5852 4.63635 9.47098 2.91667 7.00022 2.91667C4.52939 2.91667 2.41517 4.63635 1.88 7C2.41517 9.36367 4.52939 11.0833 7.00022 11.0833ZM7.00022 9.625C5.55045 9.625 4.37519 8.44976 4.37519 7C4.37519 5.55025 5.55045 4.375 7.00022 4.375C8.44992 4.375 9.62522 5.55025 9.62522 7C9.62522 8.44976 8.44992 9.625 7.00022 9.625ZM7.00022 8.45833C7.80562 8.45833 8.45855 7.80541 8.45855 7C8.45855 6.19459 7.80562 5.54167 7.00022 5.54167C6.19481 5.54167 5.54186 6.19459 5.54186 7C5.54186 7.80541 6.19481 8.45833 7.00022 8.45833Z" fill="#175DFF"/>
+</svg>

+ 3 - 0
src/icons/svg/loop.svg

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.00008 2.33317C8.60343 2.33317 10.0188 3.14182 10.8592 4.37484H9.33341V5.5415H12.8334V2.0415H11.6667V3.49946C10.6029 2.08331 8.90898 1.1665 7.00008 1.1665C3.77842 1.1665 1.16675 3.77817 1.16675 6.99984H2.33341C2.33341 4.42251 4.42275 2.33317 7.00008 2.33317ZM11.6667 6.99984C11.6667 9.57718 9.57742 11.6665 7.00008 11.6665C5.39675 11.6665 3.98138 10.8578 3.141 9.62484H4.66675V8.45817H1.16675V11.9582H2.33341V10.5002C3.3973 11.9163 5.0912 12.8332 7.00008 12.8332C10.2217 12.8332 12.8334 10.2215 12.8334 6.99984H11.6667Z" fill="#2F3742"/>
+</svg>

+ 5 - 0
src/icons/svg/setting.svg

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path
+    d="M12.25 10.5V12.25H11.0834V10.5H9.91671V9.33333H13.4167V10.5H12.25ZM2.91671 10.5V12.25H1.75004V10.5H0.583374V9.33333H4.08337V10.5H2.91671ZM6.41671 3.5V1.75H7.58337V3.5H8.75004V4.66667H5.25004V3.5H6.41671ZM6.41671 5.83333H7.58337V12.25H6.41671V5.83333ZM1.75004 8.16667V1.75H2.91671V8.16667H1.75004ZM11.0834 8.16667V1.75H12.25V8.16667H11.0834Z"
+    fill="currentColor" />
+</svg>

+ 2 - 2
src/layouts/default/breadcrumb/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div v-show="isShowBreadcrumb" class="breadcrumb">
+  <div v-if="isShowBreadcrumb" class="breadcrumb">
     <SvgIcon :icon-class="breadcrumb[0].meta.icon" />
     <ul>
       <li v-for="({ meta: { title, path } }, i) in breadcrumb" :key="path">
@@ -33,7 +33,7 @@ export default {
   },
   methods: {
     getBreadcrumb() {
-      this.breadcrumb = this.$route.matched.filter((item) => item.meta && item.meta.title);
+      this.breadcrumb = this.$route.matched.filter((item) => item?.meta && item.meta.title);
       this.$emit('update:isShowBreadcrumb', this.isShowBreadcrumb);
     }
   }

+ 1 - 1
src/layouts/default/header/index.vue

@@ -73,7 +73,7 @@ export default {
       padding: 6px 8px;
       margin-left: 24px;
       font-weight: bold;
-      color: #165dff;
+      color: $main-color;
       background-color: #e8f1ff;
       border-radius: 4px;
     }

+ 18 - 0
src/router/modules/exercise.js

@@ -0,0 +1,18 @@
+import DEFAULT from '@/layouts/default';
+
+/**
+ * 练习管理创建
+ */
+const ExerciseCreatePage = {
+  path: '/exercise',
+  component: DEFAULT,
+  redirect: '/exercise/create',
+  children: [
+    {
+      path: 'create',
+      component: () => import('@/views/exercise_questions/create/index.vue')
+    }
+  ]
+};
+
+export default [ExerciseCreatePage];

+ 2 - 1
src/router/modules/index.js

@@ -1,3 +1,4 @@
 import { LoginPage, HomePage, NotFoundPage } from './basic';
+import ExerciseRouters from './exercise';
 
-export const routes = [LoginPage, HomePage, NotFoundPage];
+export const routes = [LoginPage, HomePage, ...ExerciseRouters, NotFoundPage];

+ 43 - 0
src/styles/common.scss

@@ -1,6 +1,49 @@
+@use './variables.scss' as *;
+
 // 不换行,显示省略号
 .nowrap-ellipsis {
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
+
+.link {
+  display: inline-flex;
+  align-items: center;
+  font-size: 14px;
+  color: $main-color;
+  cursor: pointer;
+
+  & + & {
+    margin-left: 8px;
+  }
+
+  &.danger {
+    color: $danger-color;
+
+    &:hover {
+      color: $danger-hover-color;
+    }
+  }
+
+  &:hover {
+    color: $main-hover-color;
+  }
+}
+
+.pointer {
+  cursor: pointer;
+}
+
+.line {
+  width: 1px;
+  height: 100%;
+  min-height: 32px;
+  background-color: $border-color;
+}
+
+.tag {
+  & + & {
+    margin-left: 8px;
+  }
+}

+ 12 - 3
src/styles/element-variables.scss

@@ -1,12 +1,21 @@
 @use './variables.scss' as *;
 
-$--button-primary-background-color: #165dff;
-$--table-header-background-color: #f2f3f5;
-$--table-border: 1px solid #e5e6eb;
+// table
+$--table-header-background-color: $fill-color;
+$--table-border: 1px solid $border-color;
 $--table-header-font-color: $font-color;
+
+// input
 $--input-background-color: $fill-color;
 $--input-border: 1px solid $fill-color;
 
+// button
+$--button-small-font-size: 14px;
+$--button-primary-background-color: $main-color;
+$--button-default-background-color: #f3f3f3;
+$--button-default-border-color: #f3f3f3;
+$--button-small-padding-vertical: 8px;
+
 /* 改变 icon 字体路径变量,必需 */
 $--font-path: '~element-ui/lib/theme-chalk/fonts';
 

+ 29 - 1
src/styles/element.scss

@@ -30,7 +30,35 @@ div.el-dialog {
 }
 
 .el-input {
-  &__inner {
+  input.el-input__inner {
     background-color: $fill-color;
+
+    &::placeholder {
+      color: $text-color;
+    }
+  }
+}
+
+label.el-radio {
+  margin-right: 24px;
+  color: $font-color;
+
+  .el-radio__input.is-checked + .el-radio__label {
+    color: $font-color;
+  }
+
+  .el-radio__input.is-checked .el-radio__inner {
+    background-color: $main-color;
+    border-color: $main-color;
+  }
+}
+
+.el-textarea {
+  textarea {
+    font-family: 'Inter', system-ui, 'Avenir', 'Helvetica', 'Arial', sans-serif;
+  }
+
+  textarea.el-textarea__inner::placeholder {
+    color: $text-color;
   }
 }

+ 4 - 4
src/styles/index.scss

@@ -1,5 +1,5 @@
-@use './common.scss';
-@use './element.scss';
+@use './common.scss' as *;
+@use './element.scss' as *;
 
 :root {
   box-sizing: border-box;
@@ -7,7 +7,7 @@
   font-size: 16px;
   font-weight: 400;
   line-height: 1.5;
-  color: rgba(255, 255, 255, 87%);
+  color: $font-color;
   color-scheme: light dark;
   background-color: #242424;
   font-synthesis: none;
@@ -90,7 +90,7 @@ ul {
 @media (prefers-color-scheme: light) {
   :root {
     color: $font-color;
-    background-color: #f7f8fa;
+    background-color: $main-background-color;
   }
 
   a:hover {

+ 29 - 0
src/styles/mixin.scss

@@ -0,0 +1,29 @@
+@use './variables.scss' as *;
+
+@mixin question-content {
+  width: 100%;
+  background-color: #fff;
+  border: $border;
+  border-radius: 8px;
+
+  &-container {
+    display: flex;
+    width: 100%;
+    padding: 24px;
+
+    &-content {
+      flex: 1;
+    }
+
+    .setting {
+      &::before {
+        display: inline-block;
+        width: 2px;
+        height: 100%;
+        margin: 0 24px;
+        content: '';
+        background-color: $border-color;
+      }
+    }
+  }
+}

+ 8 - 0
src/styles/variables.scss

@@ -1,6 +1,14 @@
 // color
+$main-color: #165dff;
+$main-background-color: #f7f8fa;
+$main-hover-color: #3371ff;
+$danger-color: #f53f3f;
+$danger-hover-color: #f56060;
 $font-color: #1d2129;
 $fill-color: #f2f3f5;
+$text-color: #86909c;
+$border-color: #e5e6eb;
+$border: 1px solid $border-color;
 
 // px
 $header-h: 64px;

+ 16 - 10
src/utils/http.js

@@ -1,6 +1,7 @@
 import axios from 'axios';
+import store from '@/store';
 
-import { ElMessage } from 'element-ui';
+import { Message } from 'element-ui';
 import { getToken } from '@/utils/auth';
 
 const service = axios.create({
@@ -25,46 +26,50 @@ service.interceptors.response.use(
     const res = response.data;
     const { code, status, message, error } = res;
     if (code === 404) {
-      ElMessage({
+      Message({
         message: '请求的资源不存在',
         type: 'error',
         duration: 3 * 1000
       });
+
       return Promise.reject(new Error(message || 'Error'));
     }
 
     // 返回数据失败
     if (status === 0) {
-      ElMessage({
-        message: error,
+      Message({
+        message: `${error}`,
         type: 'error',
         duration: 3 * 1000
       });
-      return Promise.reject(new Error(`${response.config?.params?.MethodName} ${error}` || 'Error'));
+
+      return Promise.reject(new Error(`${error}` || 'Error'));
     }
 
     // 无效的操作用户
     if (status === -1) {
-      ElMessage({
+      Message({
         message: error,
         type: 'error',
         duration: 3 * 1000
       });
-
-      return Promise.reject(new Error(error || 'Error'));
+      store.dispatch('user/signOut');
+      window.location.href = '/';
     }
+
     return res;
   },
   (error) => {
     if (error.code === 'ERR_CANCELED') {
-      return ElMessage.success('取消上传成功');
+      return Message.success('取消上传成功');
     }
 
-    ElMessage({
+    Message({
       message: error.message,
       type: 'error',
       duration: 3 * 1000
     });
+
     return Promise.reject(error);
   }
 );
@@ -77,6 +82,7 @@ function getRequestParams() {
   const token = getToken();
 
   return {
+    AccessToken: token?.access_token ?? '',
     UserCode: token?.user_code ?? '',
     UserType: token?.user_type ?? '',
     SessionID: token?.session_id ?? ''

+ 4 - 0
src/utils/index.js

@@ -0,0 +1,4 @@
+// 生成随机数的函数
+export function getRandomNumber() {
+  return Math.random().toString(36).substring(2, 10); // 生成一个随机字符串
+}

+ 15 - 0
src/utils/transform.js

@@ -0,0 +1,15 @@
+/**
+ * 数字转中文
+ * @param {number} num 数字
+ * @returns {string}
+ */
+export function digitToChinese(num) {
+  let str = '';
+  let arr = num.toString().split('');
+  let len = arr.length;
+  let arr2 = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; // 可以创建一个对应数组,然后用下标去取
+  for (let i = 0; i < len; i++) {
+    str += arr2[arr[i]];
+  }
+  return str;
+}

+ 14 - 0
src/utils/validate.js

@@ -23,3 +23,17 @@ export function twoDecimal(value) {
   val = val.replace(/^(-)*(\d+)\.(\d\d).*$/, '$1$2.$3'); // 只能输入两位小数
   return val;
 }
+
+/**
+ * @description 是否是数字
+ * @param {String|Number} value
+ * @returns {Boolean}
+ */
+export function isNumber(value) {
+  let _val = Number(value);
+  if (typeof _val === 'number') {
+    // 检查是否是有限数并且不是NaN
+    return isFinite(_val) && !isNaN(_val);
+  }
+  return false;
+}

+ 91 - 0
src/views/exercise_questions/create/components/SelectQuestionType.vue

@@ -0,0 +1,91 @@
+<template>
+  <div class="question-type">
+    <el-cascader
+      v-model="typeArr"
+      :options="options"
+      :show-all-levels="false"
+      :props="{ expandTrigger: 'hover' }"
+      @change="handleChange"
+    />
+    <div class="intelligent-recognition"><SvgIcon icon-class="copy" :size="14" />智能识别</div>
+    <div class="save-tip">{{ saveDate }} 已自动保存</div>
+  </div>
+</template>
+
+<script>
+import { questionTypeOption } from '@/views/exercise_questions/data/common';
+
+export default {
+  name: 'SelectQuestionType',
+  inject: ['curSaveDate'],
+  props: {
+    type: {
+      type: Array,
+      required: true
+    }
+  },
+  data() {
+    return {
+      typeArr: this.type,
+      options: questionTypeOption
+    };
+  },
+  computed: {
+    saveDate() {
+      return this.curSaveDate();
+    }
+  },
+  methods: {
+    handleChange(val) {
+      console.log(val);
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.question-type {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  padding: 4px 8px;
+  border-bottom: $border;
+
+  .el-cascader {
+    width: 100px;
+
+    :deep .el-input__inner {
+      background-color: #fff;
+      border-color: #fff;
+    }
+  }
+
+  .intelligent-recognition {
+    display: flex;
+    column-gap: 8px;
+    align-items: center;
+    font-size: 14px;
+    color: #333;
+    cursor: pointer;
+  }
+
+  .save-tip {
+    font-size: 12px;
+    color: #858585;
+  }
+}
+</style>
+
+<style lang="scss">
+.el-popper.el-cascader__dropdown {
+  .el-cascader-node.in-active-path {
+    background-color: $fill-color;
+  }
+
+  .el-cascader-node.is-active {
+    color: $main-color;
+    background-color: #e8f3ff;
+  }
+}
+</style>

+ 238 - 0
src/views/exercise_questions/create/components/create.vue

@@ -0,0 +1,238 @@
+<template>
+  <main class="create">
+    <div class="create-operate">
+      <div class="left-operate">
+        <el-button type="primary" @click="$emit('addQuestionToExercise', 'select', 'single')">创建新题</el-button>
+        <a
+          :class="['pre', { disabled: curIndex === 0 }]"
+          :disabled="curIndex === 0"
+          @click="selectExerciseItem(curIndex - 1)"
+        >
+          <i class="el-icon-back"></i><span>上一题</span>
+        </a>
+        <a
+          :class="['next', { disabled: curIndex === indexList.length - 1 || indexList.length === 0 }]"
+          @click="selectExerciseItem(curIndex + 1)"
+        >
+          <span>下一题</span><i class="el-icon-right"></i>
+        </a>
+      </div>
+      <div class="save-tip">{{ curSaveDate }} 已自动保存</div>
+      <div class="right-operate">
+        <a class="setting" @click="setUp"><SvgIcon icon-class="setting" /><span>设置</span></a>
+        <span class="line"></span>
+        <a class="preview" @click="setPreview"><SvgIcon icon-class="eye" /><span>预览</span></a>
+        <a class="delete" @click="deleteQuestion"><SvgIcon icon-class="delete" /><span>删除</span></a>
+      </div>
+    </div>
+    <div class="create-content">
+      <template v-for="({ id }, i) in indexList">
+        <component :is="curExercisePage" v-if="i === curIndex" ref="exercise" :key="id" :question-id="id" />
+      </template>
+    </div>
+  </main>
+</template>
+
+<script>
+import SelectQuestion from './exercises/SelectQuestion.vue';
+
+export default {
+  name: 'CreateMain',
+  components: {
+    SelectQuestion
+  },
+  provide() {
+    return {
+      curSaveDate: () => this.curSaveDate
+    };
+  },
+  props: {
+    curIndex: {
+      type: Number,
+      required: true
+    },
+    indexList: {
+      type: Array,
+      required: true
+    }
+  },
+  data() {
+    return {
+      curSaveDate: '',
+      exerciseComponents: {
+        select: SelectQuestion
+      }
+    };
+  },
+  computed: {
+    curExercisePage() {
+      if (this.indexList.length === 0) return '';
+      return this.exerciseComponents[this.indexList[this.curIndex].type];
+    }
+  },
+  mounted() {
+    setInterval(() => {
+      this.saveData();
+    }, 30000);
+  },
+  methods: {
+    /**
+     * 选择练习
+     * @param {Number} i 练习索引
+     */
+    selectExerciseItem(i) {
+      if (this.indexList.length <= 0) return;
+      this.$emit('selectExerciseItem', i, this.dataConversion());
+    },
+    /**
+     * 设置
+     */
+    setUp() {
+      this.$emit('setUp');
+    },
+    /**
+     * 预览
+     */
+    setPreview() {
+      this.$emit('setPreview');
+    },
+    /**
+     * 删除
+     */
+    deleteQuestion() {
+      this.$confirm('是否删除当前练习题', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      })
+        .then(() => {
+          this.$emit('deleteQuestion');
+        })
+        .catch(() => {});
+    },
+    /**
+     * 数据格式转换
+     */
+    dataConversion() {
+      const data = this.$refs.exercise[0].data;
+      return {
+        question_id: this.indexList[this.curIndex].id,
+        type: data.type,
+        additional_type: data.setting.select_type,
+        file_id_list: [],
+        content: JSON.stringify(data),
+        answer: JSON.stringify(data.answer)
+      };
+    },
+    /**
+     * 保存数据
+     */
+    saveData() {
+      if (this.indexList.length <= 0) return;
+      this.$emit('saveQuestion', this.dataConversion());
+      const date = new Date();
+      const curDate = `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
+      if (this.curSaveDate !== curDate) {
+        this.curSaveDate = curDate;
+      }
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.create {
+  padding: 16px 24px;
+  background-color: $main-background-color;
+
+  &-operate {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    .left-operate {
+      .pre,
+      .next {
+        display: inline-flex;
+        column-gap: 8px;
+        align-items: center;
+        height: 32px;
+        padding: 5px 8px;
+        margin-left: 8px;
+        font-size: 14px;
+        color: #2f3742;
+        cursor: pointer;
+
+        &:hover {
+          color: $main-color;
+          background-color: #f4f8ff;
+        }
+
+        &.disabled {
+          color: #999;
+          cursor: not-allowed;
+
+          &:hover {
+            color: #999;
+            background-color: $main-background-color;
+          }
+        }
+      }
+    }
+
+    .save-tip {
+      font-size: 12px;
+      color: #858585;
+    }
+
+    .right-operate {
+      display: flex;
+      column-gap: 8px;
+      align-items: center;
+
+      %setting,
+      .setting {
+        display: flex;
+        column-gap: 8px;
+        align-items: center;
+        padding: 4px 8px;
+        color: #2f3742;
+        cursor: pointer;
+        border-radius: 2px;
+
+        &:hover {
+          background-color: $border-color;
+        }
+      }
+
+      .preview {
+        @extend %setting;
+
+        color: #175dff;
+      }
+
+      .delete {
+        @extend %setting;
+
+        color: $danger-color;
+
+        &:hover {
+          background-color: $main-background-color;
+        }
+      }
+
+      > span {
+        color: #999;
+        cursor: pointer;
+      }
+    }
+  }
+
+  &-content {
+    width: 100%;
+    height: calc(100% - 32px);
+    margin-top: 16px;
+    overflow: auto;
+  }
+}
+</style>

+ 78 - 0
src/views/exercise_questions/create/components/exercises/QuestionBase.vue

@@ -0,0 +1,78 @@
+<template>
+  <div class="question">
+    <SelectQuestionType :type="type" />
+    <div class="question-container">
+      <div class="question-content">
+        <slot name="content"></slot>
+      </div>
+      <div v-if="isSetting" class="setting">
+        <slot name="setting"></slot>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import SelectQuestionType from '../SelectQuestionType.vue';
+
+export default {
+  name: 'QuestionBase',
+  components: {
+    SelectQuestionType
+  },
+  inject: ['isSetUp'],
+  props: {
+    type: {
+      type: Array,
+      required: true
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    isSetting() {
+      return this.isSetUp();
+    }
+  },
+  methods: {}
+};
+</script>
+
+<style lang="scss" scoped>
+@use '~@/styles/mixin.scss' as *;
+
+.question {
+  width: 100%;
+  background-color: #fff;
+  border: $border;
+  border-radius: 8px;
+
+  &-container {
+    display: flex;
+    width: 100%;
+    padding: 24px;
+
+    .question-content {
+      flex: 1;
+    }
+
+    .setting {
+      display: flex;
+
+      &::before {
+        display: inline-block;
+        width: 2px;
+        height: 100%;
+        margin: 0 24px;
+        content: '';
+        background-color: $border-color;
+      }
+
+      :deep .el-form-item.el-form-item--small {
+        display: flex;
+      }
+    }
+  }
+}
+</style>

+ 309 - 0
src/views/exercise_questions/create/components/exercises/SelectQuestion.vue

@@ -0,0 +1,309 @@
+<!-- 选择题 -->
+<template>
+  <QuestionBase :type="selectType">
+    <template #content>
+      <div class="stem">
+        <el-input
+          v-if="data.setting.stem_type === stemTypeList[0].value"
+          v-model="data.stem"
+          rows="3"
+          resize="none"
+          type="textarea"
+          placeholder="输入题干"
+        />
+
+        <RichText v-if="data.setting.stem_type === stemTypeList[1].value" v-model="data.stem" placeholder="输入题干" />
+
+        <el-input
+          v-show="data.setting.is_describe"
+          v-model="data.describe"
+          rows="3"
+          resize="none"
+          type="textarea"
+          placeholder="输入描述"
+        />
+      </div>
+      <div class="content">
+        <ul>
+          <li v-for="(item, i) in data.options" :key="i" class="content-item">
+            <span class="question-number">{{ computedQuestionNumber(i) }}.</span>
+            <div class="option-content">
+              <span :class="['checkbox', { active: isAnswer(item.mark) }]" @click="selectAnswer(item.mark)"></span>
+              <!-- <el-input v-model="item.content" placeholder="输入内容" /> -->
+              <RichText v-model="item.content" placeholder="输入内容" :inline="true" />
+            </div>
+            <SvgIcon icon-class="delete" class="delete pointer" @click="deleteOption(i)" />
+          </li>
+        </ul>
+      </div>
+
+      <div class="footer">
+        <span class="add-option" @click="addOption">
+          <SvgIcon icon-class="add-circle" size="14" /> <span>增加选项</span>
+        </span>
+      </div>
+    </template>
+
+    <template #setting>
+      <el-form :model="data.setting">
+        <el-form-item label="题干">
+          <el-radio
+            v-for="{ value, label } in stemTypeList"
+            :key="value"
+            v-model="data.setting.stem_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="题号">
+          <el-input v-model="data.setting.question_number" disabled />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-radio
+            v-for="{ value, label } in switchOption"
+            :key="value"
+            v-model="data.setting.is_describe"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="选项">
+          <el-radio
+            v-for="{ value, label } in selectTypeList"
+            :key="value"
+            v-model="data.setting.select_type"
+            :label="value"
+            @input="changeSelectType"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="听力">
+          <el-radio v-for="{ value, label } in switchOption" :key="value" v-model="data.setting.is_hear" :label="value">
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="分值">
+          <el-radio
+            v-for="{ value, label } in scoreTypeList"
+            :key="value"
+            v-model="data.setting.score_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label-width="45px">
+          <el-input v-model="data.setting.score" type="number" />
+        </el-form-item>
+      </el-form>
+    </template>
+  </QuestionBase>
+</template>
+
+<script>
+import QuestionBase from './QuestionBase.vue';
+import RichText from '@/components/common/RichText.vue';
+
+import {
+  stemTypeList,
+  selectTypeList,
+  switchOption,
+  scoreTypeList,
+  computeOptionMethods
+} from '@/views/exercise_questions/data/common';
+import { selectData, selectType } from '@/views/exercise_questions/data/select';
+import { GetQuestion } from '@/api/exercise';
+import { getRandomNumber } from '@/utils/index';
+
+export default {
+  name: 'SelectQuestion',
+  components: {
+    QuestionBase,
+    RichText
+  },
+  props: {
+    questionId: {
+      type: String,
+      required: true
+    }
+  },
+  data() {
+    return {
+      selectType,
+      stemTypeList,
+      selectTypeList,
+      switchOption,
+      scoreTypeList,
+      data: JSON.parse(JSON.stringify(selectData))
+    };
+  },
+  created() {
+    this.getQuestion();
+  },
+  methods: {
+    getQuestion() {
+      GetQuestion({ question_id: this.questionId })
+        .then(({ file_list, question }) => {
+          if (question.content) {
+            this.data = JSON.parse(question.content);
+          }
+        })
+        .catch((err) => {
+          console.log(err);
+        });
+    },
+    deleteOption(i) {
+      this.data.options.splice(i, 1);
+    },
+    changeSelectType(val) {
+      console.log(val);
+      if (this.data.setting.select_type === selectTypeList[0].value && this.data.answer.select_list.length > 1) {
+        this.data.answer.select_list = [this.data.answer.select_list[0]];
+      }
+    },
+    isAnswer(mark) {
+      return this.data.answer.select_list.indexOf(mark) !== -1;
+    },
+    selectAnswer(mark) {
+      let index = this.data.answer.select_list.indexOf(mark);
+      if (this.data.setting.select_type === selectTypeList[0].value) {
+        this.data.answer.select_list = [mark];
+      }
+      if (this.data.setting.select_type === selectTypeList[1].value) {
+        if (index === -1) {
+          this.data.answer.select_list.push(mark);
+        } else {
+          this.data.answer.select_list.splice(index, 1);
+        }
+      }
+    },
+    addOption() {
+      this.data.options.push({
+        mark: getRandomNumber(),
+        content: ''
+      });
+    },
+    computedQuestionNumber(i) {
+      const computationMethod = computeOptionMethods[this.data.option_type];
+      if (computationMethod) {
+        return computationMethod(i);
+      }
+      return '';
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.stem {
+  padding-bottom: 8px;
+  margin-bottom: 8px;
+  border-bottom: 1px solid $border-color;
+
+  .el-textarea {
+    margin-top: 8px;
+  }
+}
+
+.content {
+  > ul {
+    display: flex;
+    flex-direction: column;
+    row-gap: 8px;
+    width: 100%;
+
+    .content-item {
+      display: flex;
+      column-gap: 4px;
+      align-items: center;
+
+      .question-number {
+        min-width: 40px;
+        height: 32px;
+        padding: 4px 8px;
+        color: $text-color;
+        background-color: $fill-color;
+        border-radius: 2px;
+      }
+
+      .option-content {
+        display: flex;
+        flex: 1;
+        align-items: center;
+        padding-left: 16px;
+        background-color: $fill-color;
+
+        .checkbox {
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+          width: 16px;
+          height: 16px;
+          margin-right: 8px;
+          cursor: pointer;
+          border: 1px solid #333;
+          border-radius: 50%;
+
+          &.active {
+            &::before {
+              display: inline-block;
+              width: 6px;
+              height: 6px;
+              content: '';
+              background-color: #333;
+              border-radius: 50%;
+            }
+          }
+        }
+
+        .rich-text {
+          flex: 1;
+          min-height: 32px;
+
+          :deep &.mce-content-body {
+            padding-top: 4px;
+          }
+
+          :deep &:not(.mce-edit-focus) {
+            p {
+              margin: 0;
+            }
+          }
+
+          :deep &.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before {
+            top: 6px;
+          }
+        }
+      }
+
+      .delete {
+        margin-left: 12px;
+      }
+    }
+  }
+}
+
+.footer {
+  display: flex;
+  justify-content: center;
+  margin-top: 12px;
+  color: $main-color;
+
+  .add-option {
+    display: flex;
+    column-gap: 8px;
+    align-items: center;
+    font-size: 14px;
+    cursor: pointer;
+  }
+}
+
+.el-form {
+  .el-input {
+    width: 250px;
+  }
+}
+</style>

+ 283 - 0
src/views/exercise_questions/create/index.vue

@@ -0,0 +1,283 @@
+<template>
+  <div class="exercise">
+    <div class="list">
+      <el-button icon="el-icon-back" @click="back">返回练习管理</el-button>
+      <div class="list-title">课上练习</div>
+      <div class="exercise-list">
+        <ul>
+          <li v-for="(item, i) in index_list" :key="i" :class="['exercise-item', { active: i === curIndex }]">
+            <SvgIcon icon-class="child" :size="20" />
+            <span class="item-name nowrap-ellipsis" @click="selectExerciseItem(i)">
+              {{ i + 1 }}. {{ exerciseNames[item.type] }}
+            </span>
+            <SvgIcon icon-class="copy" class="pointer" :size="10" @click="copy(i, item.type)" />
+          </li>
+        </ul>
+      </div>
+      <div class="list-operate">
+        <el-button type="primary">批量导入</el-button>
+        <el-button type="primary" @click="addQuestionToExercise('select', 'single')">新建</el-button>
+      </div>
+    </div>
+
+    <CreateMain
+      ref="createMain"
+      :cur-index="curIndex"
+      :index-list="index_list"
+      @selectExerciseItem="selectExerciseItem"
+      @setUp="setUp"
+      @setPreview="setPreview"
+      @deleteQuestion="deleteQuestion"
+      @saveQuestion="saveQuestion"
+      @addQuestionToExercise="addQuestionToExercise"
+    />
+
+    <div class="preview">
+      <div class="preview-header">
+        <span class="quick-preview">快捷预览:</span>
+        <div class="preview-right">
+          <template v-if="preview">
+            <span class="preview-button plain" @click="getPreviewData">
+              <SvgIcon icon-class="loop" size="14" /><span>刷新</span>
+            </span>
+            <span class="preview-button"><SvgIcon icon-class="eye" /><span>完整预览</span></span>
+            <span class="preview-button plain" @click="setPreview"><SvgIcon icon-class="close" />关闭预览</span>
+          </template>
+          <template v-else>
+            <span class="preview-button" @click="setPreview"><SvgIcon icon-class="eye" />预览</span>
+          </template>
+        </div>
+      </div>
+      <div class="preview-content">
+        <component :is="curPreviewPage" v-if="preview" :data="previewData" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { exerciseNames } from '../data/common';
+import { AddQuestionToExercise, SaveQuestion, GetExerciseQuestionIndexList } from '@/api/exercise';
+
+import CreateMain from './components/create.vue';
+import SelectPreview from '@/views/exercise_questions/preview/SelectPreview.vue';
+
+export default {
+  name: 'CreateExercise',
+  components: {
+    CreateMain,
+    SelectPreview
+  },
+  provide() {
+    return {
+      isSetUp: () => this.isSetUp
+    };
+  },
+  data() {
+    const { id } = this.$route.query;
+
+    return {
+      id, // 练习id
+      curIndex: 0, // 当前练习索引
+      index_list: [], // 练习列表
+      exerciseNames, // 练习名称
+      isSetUp: false, // 设置
+      preview: false, // 预览显示
+      previewData: {}, // 预览数据
+      previewComponents: { select: SelectPreview }
+    };
+  },
+  computed: {
+    curPreviewPage() {
+      if (this.index_list.length === 0) return '';
+      return this.previewComponents[this.index_list[this.curIndex].type];
+    }
+  },
+  created() {
+    this.getExerciseQuestionIndexList();
+  },
+  methods: {
+    // 返回练习管理
+    back() {
+      this.$router.push('/personal_question');
+    },
+    // 新建练习
+    createExercise() {
+      this.index_list.push({ type: 'select' });
+    },
+    /**
+     * 添加题目到练习
+     * @param {string} type
+     * @param {string} additional_type
+     */
+    addQuestionToExercise(type, additional_type) {
+      AddQuestionToExercise({
+        exercise_id: this.id,
+        type,
+        additional_type,
+        content: '',
+        file_id_list: [],
+        answer: '{}'
+      })
+        .then(() => {
+          this.getExerciseQuestionIndexList();
+        })
+        .catch((err) => {
+          console.log(err);
+        });
+    },
+    /**
+     * 保存题目
+     * @param {object} data
+     */
+    async saveQuestion(data) {
+      try {
+        await SaveQuestion(data);
+      } catch (err) {
+        console.log(err);
+      }
+    },
+    /**
+     * 获取练习题目索引列表
+     */
+    getExerciseQuestionIndexList() {
+      GetExerciseQuestionIndexList({ exercise_id: this.id })
+        .then(({ index_list }) => {
+          this.index_list = index_list;
+        })
+        .catch((err) => {
+          console.log(err);
+        });
+    },
+    /**
+     * 复制练习
+     * @param {Number} index 练习索引
+     * @param {String} type 练习类型
+     */
+    copy(index, type) {
+      // this.index_list.splice(index + 1, 0, { type });
+      // TODO
+    },
+    /**
+     * 选择练习
+     * @param {Number} index 练习索引
+     */
+    selectExerciseItem(index, data) {
+      if (index < 0 || index > this.index_list.length - 1) return;
+      this.curIndex = index;
+      this.saveQuestion(data);
+    },
+    // 设置
+    setUp() {
+      this.isSetUp = !this.isSetUp;
+    },
+    getPreviewData() {
+      this.previewData = this.$refs.createMain.$refs.exercise?.[0].data || {};
+    },
+    // 预览
+    setPreview() {
+      this.preview = !this.preview;
+      this.getPreviewData();
+    },
+    // 删除练习
+    deleteQuestion() {}
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.exercise {
+  display: grid;
+  grid-template: 1fr auto / 224px 1fr;
+  height: 100%;
+
+  .list {
+    display: flex;
+    flex-direction: column;
+    grid-area: 1 / 1 / -1;
+    row-gap: 16px;
+    padding: 16px 8px;
+    background-color: #fff;
+    border-right: 1px solid $border-color;
+
+    &-title {
+      font-weight: bold;
+    }
+
+    .exercise-list {
+      max-height: calc(100% - 130px);
+      overflow-y: auto;
+
+      .exercise-item {
+        display: flex;
+        column-gap: 8px;
+        align-items: center;
+        padding: 4px 8px;
+        color: #333;
+        cursor: pointer;
+        border-radius: 2px;
+
+        &.active {
+          color: $main-color;
+          background-color: #f4f8ff;
+        }
+
+        .item-name {
+          flex: 1;
+        }
+      }
+    }
+
+    &-operate {
+      display: flex;
+
+      > .el-button {
+        width: 50%;
+      }
+    }
+  }
+
+  .preview {
+    position: relative;
+    max-height: 450px;
+    padding: 16px 24px;
+    overflow: auto;
+    background-color: #eff1f5;
+    border-top: 1px solid $border-color;
+
+    .preview-header {
+      display: flex;
+      justify-content: space-between;
+      width: 100%;
+
+      .quick-preview {
+        font-weight: bold;
+      }
+
+      .preview-right {
+        display: flex;
+        column-gap: 18px;
+        align-items: center;
+
+        .preview-button {
+          display: flex;
+          column-gap: 8px;
+          align-items: center;
+          color: #175dff;
+          cursor: pointer;
+
+          &.plain {
+            color: #2f3742;
+          }
+        }
+      }
+    }
+
+    &-content {
+      width: 100%;
+      max-height: calc(100% - 24px);
+      overflow: auto;
+    }
+  }
+}
+</style>

+ 54 - 0
src/views/exercise_questions/data/common.js

@@ -0,0 +1,54 @@
+import { digitToChinese } from '@/utils/transform';
+
+// 练习名称
+export const exerciseNames = { select: '选择题', fill: '填空题', judge: '判断题' };
+
+// 题型选项
+export const questionTypeOption = [
+  {
+    value: 'base',
+    label: '基础题型',
+    children: [
+      { value: 'select', label: '选择题' }
+      // { value: 'judge', label: '判断题' }
+    ]
+  }
+];
+
+// 选项类型
+export const optionTypeList = [
+  { value: 'letter', label: '字母' },
+  { value: 'number', label: '数字' },
+  { value: 'chinese', label: '中文数字' }
+];
+
+// 计算选项方法
+export const computeOptionMethods = {
+  [optionTypeList[0].value]: (i) => String.fromCharCode(97 + i),
+  [optionTypeList[1].value]: (i) => i + 1,
+  [optionTypeList[2].value]: (i) => digitToChinese(i + 1)
+};
+
+// 题干类型
+export const stemTypeList = [
+  { value: 'text', label: '纯文本' },
+  { value: 'rich', label: '富文本' }
+];
+
+// 分值类型
+export const scoreTypeList = [
+  { value: 'aggregate', label: '总分' },
+  { value: 'subdivision', label: '细分' }
+];
+
+// 选择类型
+export const selectTypeList = [
+  { value: 'single', label: '单选' },
+  { value: 'multiple', label: '多选' }
+];
+
+// 开关选项
+export const switchOption = [
+  { value: true, label: '开启' },
+  { value: false, label: '关闭' }
+];

+ 30 - 0
src/views/exercise_questions/data/select.js

@@ -0,0 +1,30 @@
+import { optionTypeList, stemTypeList, selectTypeList, scoreTypeList, questionTypeOption } from './common';
+import { getRandomNumber } from '@/utils/index';
+
+// 选择题数据模板
+export const selectData = {
+  type: 'select', // 题型
+  stem: '', // 题干
+  option_type: optionTypeList[0].value, // 选项类型
+  describe: '', // 描述
+  options: [
+    { content: '', mark: getRandomNumber() },
+    { content: '', mark: getRandomNumber() },
+    { content: '', mark: getRandomNumber() }
+  ], // 选项
+  file_id_list: [], // 文件 id 列表
+  answer: { select_list: [], score: 0, item_score: 0 }, // 答案
+  // 题型设置
+  setting: {
+    stem_type: stemTypeList[0].value, // 题干类型
+    question_number: 1, // 题号
+    is_describe: false, // 描述
+    select_type: selectTypeList[0].value, // 选择类型
+    is_hear: true, // 是否听力
+    score: 1, // 分值
+    score_type: scoreTypeList[0].value // 分值类型
+  }
+};
+
+// 选择题类型
+export const selectType = [questionTypeOption[0].value, questionTypeOption[0].children[0].value];

+ 141 - 0
src/views/exercise_questions/preview/SelectPreview.vue

@@ -0,0 +1,141 @@
+<template>
+  <div class="select-preview">
+    <div class="stem">
+      <span>{{ data.setting.question_number }}.</span>
+      <span v-html="data.stem"></span>
+    </div>
+    <div v-if="data.setting.is_describe" class="describe">{{ data.describe }}</div>
+    <ul class="option-list">
+      <li
+        v-for="({ content, mark }, i) in list"
+        :key="mark"
+        :class="['option-item', { active: isAnswer(mark) }]"
+        @click="selectAnswer(mark)"
+      >
+        <span class="selectionbox"></span>
+        <span>{{ computeOptionMethods[data.option_type](i) }}. </span>
+        <span v-html="content"></span>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import { computeOptionMethods, selectTypeList } from '@/views/exercise_questions/data/common';
+
+export default {
+  name: 'SelectPreview',
+  props: {
+    data: {
+      type: Object,
+      required: true
+    }
+  },
+  data() {
+    return {
+      answer: [],
+      computeOptionMethods
+    };
+  },
+  computed: {
+    list() {
+      // 将数组中的顺序随机打乱
+      const arr = JSON.parse(JSON.stringify(this.data.options));
+      const newArr = [];
+      while (arr.length > 0) {
+        const randomIndex = Math.floor(Math.random() * arr.length);
+        newArr.push(arr[randomIndex]);
+        arr.splice(randomIndex, 1);
+      }
+      return newArr;
+    }
+  },
+  methods: {
+    isAnswer(mark) {
+      return this.answer.indexOf(mark) !== -1;
+    },
+    selectAnswer(mark) {
+      const index = this.answer.indexOf(mark);
+      if (this.data.setting.select_type === selectTypeList[0].value) {
+        this.answer = [mark];
+      }
+      if (this.data.setting.select_type === selectTypeList[1].value) {
+        if (index === -1) {
+          this.answer.push(mark);
+        } else {
+          this.answer.splice(index, 1);
+        }
+      }
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.select-preview {
+  display: flex;
+  flex-direction: column;
+  row-gap: 24px;
+  padding: 64px;
+  margin: 16px 0;
+  background-color: #fff;
+  border-radius: 8px;
+
+  .stem {
+    display: flex;
+    align-items: center;
+    font-size: 18px;
+    font-weight: bold;
+    color: #34343a;
+
+    :deep p {
+      margin: 0;
+    }
+  }
+
+  .describe {
+    padding: 12px 24px;
+    background-color: #f9f8f9;
+    border-radius: 16px;
+  }
+
+  .option-list {
+    display: flex;
+    flex-direction: column;
+    row-gap: 16px;
+
+    .option-item {
+      display: flex;
+      column-gap: 8px;
+      align-items: center;
+      padding: 12px 24px;
+      color: #706f78;
+      cursor: pointer;
+      background-color: #f9f8f9;
+      border-radius: 40px;
+
+      .selectionbox {
+        width: 14px;
+        height: 14px;
+        margin-right: 8px;
+        border: 2px solid #dcdbdd;
+        border-radius: 50%;
+      }
+
+      &.active {
+        color: #34343a;
+        background-color: #e7eeff;
+
+        .selectionbox {
+          border-color: #306eff;
+          border-width: 10px;
+        }
+      }
+
+      :deep p {
+        margin: 0;
+      }
+    }
+  }
+}
+</style>

+ 15 - 9
src/views/home/common.vue

@@ -15,7 +15,7 @@
       <slot name="search"></slot>
     </div>
 
-    <el-table :data="tableData">
+    <el-table :data="data">
       <slot></slot>
     </el-table>
 
@@ -23,7 +23,7 @@
       background
       :current-page="cur_page"
       :page-sizes="[10, 20, 30, 40, 50]"
-      :page-size="size"
+      :page-size="page_capacity"
       layout="total, prev, pager, next, sizes, jumper"
       :total="total"
       @prev-click="changePage"
@@ -46,7 +46,7 @@ export default {
       type: Number,
       default: 0
     },
-    tableData: {
+    data: {
       type: Array,
       default: () => []
     }
@@ -54,7 +54,7 @@ export default {
   data() {
     return {
       cur_page: 1,
-      size: this.pageSize
+      page_capacity: this.pageSize
     };
   },
   computed: {
@@ -62,14 +62,20 @@ export default {
       return this.$route.path === '/home';
     }
   },
+  created() {
+    this.getList();
+  },
   methods: {
     changePage(number) {
       this.cur_page = number;
-      this.$emit('getList', { cur_page: this.cur_page, page_capacity: this.size });
+      this.getList();
     },
     changePageSize(size) {
-      this.size = size;
-      this.$emit('getList', { cur_page: this.cur_page, page_capacity: this.size });
+      this.page_capacity = size;
+      this.getList();
+    },
+    getList() {
+      this.$emit('getList', { cur_page: this.cur_page, page_capacity: this.page_capacity });
     }
   }
 };
@@ -109,8 +115,8 @@ export default {
 
       &.active {
         font-weight: bold;
-        color: #165dff;
-        background-color: #f2f3f5;
+        color: $main-color;
+        background-color: $fill-color;
         border-radius: 18px;
       }
     }

+ 109 - 12
src/views/home/personal_question/CreateExercise.vue

@@ -1,15 +1,44 @@
 <template>
-  <el-dialog :visible="dialogVisible" title="创建练习" @close="dialogClose">
-    <el-form :model="form" :rules="rules" label-width="90px">
-      <el-form-item label="练习名称" prop="name">
-        <el-input v-model="form.name" maxlength="100" placeholder="请输入练习名称" />
-      </el-form-item>
-      <el-form-item label="标签" />
-    </el-form>
-  </el-dialog>
+  <div>
+    <el-dialog :visible="dialogVisible" title="创建练习" width="670px" @close="dialogClose">
+      <el-form ref="form" :model="form" :rules="rules" label-width="90px">
+        <el-form-item label="练习名称" prop="name">
+          <el-input v-model="form.name" maxlength="100" placeholder="请输入练习名称" />
+        </el-form-item>
+        <el-form-item label="标签">
+          <div class="label-list">
+            <el-tag v-for="(item, i) in form.label_list" :key="`${item}${i}`" type="info" closable>{{ item }}</el-tag>
+            <el-button class="label-item" icon="el-icon-plus" circle @click="showVisible" />
+          </div>
+        </el-form-item>
+        <el-form-item label="简介">
+          <el-input v-model="form.intro" type="textarea" maxlength="500" placeholder="请输入" rows="10" />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="dialogClose">取消</el-button>
+          <el-button type="primary" @click="createdExercise">确定</el-button>
+        </el-form-item>
+      </el-form>
+    </el-dialog>
+
+    <el-dialog :visible="visible" title="创建标签" width="300px" @close="dialogCloseLabel">
+      <el-autocomplete
+        v-model="label_name"
+        class="inline-input"
+        :fetch-suggestions="querySearch"
+        placeholder="请输入内容"
+      />
+      <template #footer>
+        <el-button @click="dialogCloseLabel">取消</el-button>
+        <el-button type="primary" @click="addLabel">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
 </template>
 
 <script>
+import { CreateExercise, GetLabelList } from '@/api/exercise';
+
 export default {
   name: 'CreateExercise',
   props: {
@@ -20,10 +49,13 @@ export default {
   },
   data() {
     return {
+      visible: false,
+      label_name: '',
+      label_list: [],
       form: {
         name: '',
-        tag: [],
-        description: ''
+        label_list: [],
+        intro: ''
       },
       rules: {
         name: [
@@ -36,16 +68,81 @@ export default {
       }
     };
   },
+  created() {
+    this.getLabelList();
+  },
   methods: {
+    querySearch(queryString, cb) {
+      let label_list = this.label_list;
+      let results = (queryString ? label_list.filter(this.createFilter(queryString)) : label_list).map((item) => {
+        return { value: item };
+      });
+      cb(results);
+    },
+    createFilter(queryString) {
+      return (label) => {
+        return label.toLowerCase().indexOf(queryString.toLowerCase()) === 0;
+      };
+    },
+    addLabel() {
+      if (this.label_name) {
+        this.form.label_list.push(this.label_name);
+        this.label_name = '';
+      }
+      this.visible = false;
+    },
+    showVisible() {
+      this.visible = true;
+    },
+    dialogCloseLabel() {
+      this.visible = false;
+    },
+    getLabelList() {
+      GetLabelList({ store_type: 0 }).then(({ label_list }) => {
+        this.label_list = label_list;
+      });
+    },
     dialogClose() {
       this.$emit('update:dialogVisible');
+    },
+    createdExercise() {
+      this.$refs.form.validate((valid) => {
+        if (!valid) return false;
+
+        CreateExercise(this.form)
+          .then(({ id }) => {
+            this.$message.success('创建成功');
+            this.dialogClose();
+            this.$router.push({ path: '/exercise', query: { id } });
+          })
+          .catch(() => {});
+      });
     }
   }
 };
 </script>
 
 <style lang="scss" scoped>
-.el-input {
-  width: calc(100% - 100px);
+.el-input,
+.el-textarea,
+.label-list {
+  width: calc(100% - 40px);
+}
+
+.el-autocomplete {
+  width: 100%;
+}
+
+.label-list {
+  padding: 0 4px;
+  background-color: $fill-color;
+  border-radius: 2px;
+
+  .el-button.label-item {
+    width: 24px;
+    height: 24px;
+    padding: 4px;
+    background-color: #fff;
+  }
 }
 </style>

+ 88 - 11
src/views/home/personal_question/index.vue

@@ -1,9 +1,35 @@
+<!-- 个人题库 -->
 <template>
   <div class="personal">
-    <HomeCommon :table-data="tableData" :total="total" @getList="getList">
+    <HomeCommon ref="common" :data="exercise_list" :total="total_count" @getList="pageQueryExerciseList">
       <template #default>
-        <el-table-column prop="date" label="日期" width="180" />
-        <el-table-column prop="date" label="练习名称" />
+        <el-table-column prop="index" label="序号" width="65">
+          <template slot-scope="{ $index }">{{ $index + 1 }}</template>
+        </el-table-column>
+        <el-table-column prop="name" label="练习名称" width="180" />
+        <el-table-column prop="tag" label="标签" width="120">
+          <template slot-scope="{ row }">
+            <span v-for="(item, i) in row.label_list" :key="i" class="tag">{{ item }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="creator_name" label="创建者" width="120" />
+        <el-table-column prop="create_time" label="创建时间" width="140" />
+        <el-table-column prop="last_modifier_name" label="最近编辑" width="100" />
+        <el-table-column prop="last_modify_time" label="最近编辑时间" width="140" />
+        <el-table-column prop="intro" label="简介" width="200" />
+        <el-table-column label="状态" width="90">
+          <template slot-scope="{ row }">
+            <span :class="statusList[row.status].class">{{ statusList[row.status].name }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="operation" label="操作">
+          <template slot-scope="{ row }">
+            <span class="link" @click="$router.push({ path: '/exercise', query: { id: row.id } })">编辑</span>
+            <span class="link">分享</span>
+            <span class="link">公开</span>
+            <span class="link danger" @click="deleteExercise(row.id)">删除</span>
+          </template>
+        </el-table-column>
       </template>
 
       <template #operation>
@@ -12,9 +38,9 @@
 
       <template #search>
         <span class="search-name">搜索</span>
-        <el-input v-model="searchData.search" placeholder="全部" suffix-icon="el-icon-search" />
+        <el-input v-model="searchData.search_content" placeholder="全部" suffix-icon="el-icon-search" />
         <span class="search-name">创建者</span>
-        <el-input v-model="searchData.creator" placeholder="请输入内容" />
+        <el-input v-model="searchData.creator_name" placeholder="请输入内容" />
         <span class="search-name">状态</span>
         <el-input v-model="searchData.status" placeholder="请输入内容" />
       </template>
@@ -25,6 +51,8 @@
 </template>
 
 <script>
+import { PageQueryExerciseList, DeleteExercise } from '@/api/exercise';
+
 import HomeCommon from '../common.vue';
 import CreateExercise from './CreateExercise.vue';
 
@@ -38,18 +66,40 @@ export default {
     return {
       // 搜索数据
       searchData: {
-        search: '',
-        creator: '',
-        status: ''
+        search_content: '',
+        creator_name: '',
+        status_list: []
       },
+      statusList: [
+        { name: '未发布', class: 'unpublished' },
+        { name: '已发布', class: 'published' }
+      ],
       // 表格数据
-      tableData: [],
-      total: 0, // 总条数
+      exercise_list: [],
+      total_count: 0, // 总条数
       dialogVisible: false
     };
   },
   methods: {
-    getList(data) {},
+    getPageList() {
+      this.$refs.common.getList();
+    },
+    pageQueryExerciseList(data) {
+      PageQueryExerciseList({ ...data, store_type: 1, ...this.searchData }).then(({ total_count, exercise_list }) => {
+        this.total = total_count;
+        this.exercise_list = exercise_list;
+      });
+    },
+    deleteExercise(exercise_id) {
+      DeleteExercise({ exercise_id })
+        .then(() => {
+          this.$message.success('删除成功');
+          this.getPageList();
+        })
+        .catch(() => {
+          this.$message.error('删除失败');
+        });
+    },
 
     createExercise() {
       this.dialogVisible = true;
@@ -61,5 +111,32 @@ export default {
 <style lang="scss" scoped>
 .personal {
   height: 100%;
+
+  .unpublished,
+  .published {
+    display: flex;
+    align-items: center;
+
+    &::before {
+      display: inline-block;
+      width: 6px;
+      height: 6px;
+      margin-right: 8px;
+      content: '';
+      border-radius: 50%;
+    }
+  }
+
+  .published {
+    &::before {
+      background-color: $main-color;
+    }
+  }
+
+  .unpublished {
+    &::before {
+      background-color: $danger-color;
+    }
+  }
 }
 </style>

+ 59 - 13
src/views/home/public_question/index.vue

@@ -1,24 +1,46 @@
+<!-- 公共题库 -->
 <template>
-  <HomeCommon :table-data="tableData" :total="total" @getList="getList">
+  <HomeCommon ref="common" :data="exercise_list" :total="total_count" @getList="pageQueryExerciseList">
     <template #default>
-      <el-table-column prop="date" label="日期" width="180" />
-      <el-table-column prop="date" label="练习名称" />
+      <el-table-column label="序号" width="65">
+        <template slot-scope="{ $index }">{{ $index + 1 }}</template>
+      </el-table-column>
+      <el-table-column prop="name" label="练习名称" width="442" />
+      <el-table-column prop="tag" label="标签" width="180">
+        <template slot-scope="{ row }">
+          <span v-for="(item, i) in row.label_list" :key="i" class="tag">{{ item }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="intro" label="简介" width="200" />
+      <el-table-column prop="teacher_name" label="发布者" width="120" />
+      <el-table-column prop="create_time" label="创建日期" width="140" />
+      <el-table-column prop="operation" label="操作">
+        <span class="link">查看</span>
+        <span class="link">编辑</span>
+        <span class="link">复制到个人题库</span>
+      </el-table-column>
     </template>
 
     <template #search>
       <span class="search-name">搜索</span>
-      <el-input v-model="searchData.search" placeholder="全部" suffix-icon="el-icon-search" />
+      <el-input v-model="searchData.search_content" placeholder="全部" suffix-icon="el-icon-search" />
 
       <span class="search-name">发布者</span>
-      <el-input v-model="searchData.publisher" placeholder="请输入内容" />
+      <el-select v-model="searchData.teacher_type" placeholder="全部" @change="getPageList">
+        <el-option v-for="{ label, value } in teacherTypeList" :key="value" :label="label" :value="value" />
+      </el-select>
 
       <span class="search-name">标签</span>
-      <el-input v-model="searchData.tag" placeholder="请输入内容" />
+      <el-select v-model="searchData.label_list" multiple placeholder="全部">
+        <el-option v-for="item in label_list" :key="item" :label="item" :value="item" />
+      </el-select>
     </template>
   </HomeCommon>
 </template>
 
 <script>
+import { PageQueryExerciseList, GetLabelList } from '@/api/exercise';
+
 import HomeCommon from '../common.vue';
 
 export default {
@@ -30,18 +52,42 @@ export default {
     return {
       // 搜索数据
       searchData: {
-        search: '',
-        publisher: '',
-        tag: ''
+        search_content: '',
+        teacher_type: 'all',
+        label_list: []
       },
+      teacherTypeList: [
+        { label: '全部', value: 'all' },
+        { label: '自己', value: 'self' },
+        { label: '其他', value: 'other' }
+      ],
+      label_list: [],
       // 表格数据
-      tableData: [],
-      total: 0 // 总条数
+      exercise_list: [],
+      total_count: 0 // 总条数
     };
   },
+  created() {
+    this.getLabelList();
+  },
   methods: {
-    getList(data) {
-      console.log(data);
+    getPageList() {
+      this.$refs.common.getList();
+    },
+    pageQueryExerciseList(data) {
+      PageQueryExerciseList({
+        ...data,
+        store_type: 0,
+        ...this.searchData
+      }).then(({ total_count, exercise_list }) => {
+        this.exercise_list = exercise_list;
+        this.total_count = total_count;
+      });
+    },
+    getLabelList() {
+      GetLabelList({ store_type: 0 }).then(({ label_list }) => {
+        this.label_list = label_list;
+      });
     }
   }
 };

+ 1 - 1
src/views/login/index.vue

@@ -10,7 +10,7 @@
           <el-input v-model="form.password" type="password" show-password />
         </el-form-item>
         <el-form-item prop="verification_code_image_text" class="verification-code">
-          <el-input v-model="form.verification_code_image_text" placeholder="验证码" />
+          <el-input v-model="form.verification_code_image_text" placeholder="验证码" @keyup.enter.native="signIn" />
           <el-image
             v-if="image_content_base64.length > 0"
             :src="`data:image/jpg;base64,${image_content_base64}`"