Просмотр исходного кода

Merge branch 'feature-quiz' into develop

# Conflicts:
#	src/App.vue
#	src/views/quiz/Questions.vue
#	src/views/quiz/QuizView.vue
#	src/views/quiz/TeacherQuizSetting.vue
#	src/views/student/tabs/StudentQuiz.vue
ChenYangfan 6 лет назад
Родитель
Сommit
c4164f044a
52 измененных файлов с 3114 добавлено и 56 удалено
  1. 1 0
      .eslintignore
  2. 4 0
      babel.config.js
  3. 13 0
      mock/api/question/_questionId/get.json
  4. 144 0
      mock/api/question/get.json
  5. 1 0
      mock/api/question/post.json
  6. 116 0
      mock/api/quiz/_quizId/get.json
  7. 1 0
      mock/api/quiz/_quizId/student-answer/post.json
  8. 43 0
      mock/api/quiz/get.json
  9. 1 0
      mock/api/quiz/post.json
  10. 72 0
      mock/index.js
  11. 217 0
      mock/serializers.js
  12. 5 0
      mock/utils.js
  13. 18 0
      package.json
  14. 119 0
      script/build-icon.js
  15. 305 0
      src/components/Editor.vue
  16. 1 0
      src/components/Layouts/TabSideComponent.vue
  17. 209 0
      src/components/Pagination.vue
  18. 58 0
      src/components/QuestionKindBar.vue
  19. 13 1
      src/config/server-info.js
  20. 30 1
      src/router/index.js
  21. 16 0
      src/server/enums/question-kind.js
  22. 11 0
      src/server/enums/quiz-state.js
  23. 37 0
      src/server/question.js
  24. 63 0
      src/server/quiz.js
  25. 8 0
      src/style/icon.scss
  26. 57 0
      src/utils/markdown.js
  27. 1 0
      src/utils/types.js
  28. 41 0
      src/views/quiz/CreateQuestion.vue
  29. 74 0
      src/views/quiz/CreateQuiz.vue
  30. 29 0
      src/views/quiz/QuestionDetail.vue
  31. 5 1
      src/views/quiz/Questions.vue
  32. 33 0
      src/views/quiz/QuizDetail.vue
  33. 32 3
      src/views/quiz/QuizView.vue
  34. 29 8
      src/views/quiz/TeacherQuizSetting.vue
  35. 56 0
      src/views/quiz/components/ChoiceQuestionDetail.vue
  36. 134 0
      src/views/quiz/components/ChoiceQuestionEditor.vue
  37. 22 0
      src/views/quiz/components/EditorSection.vue
  38. 27 0
      src/views/quiz/components/MarkdownSection.vue
  39. 73 0
      src/views/quiz/components/QuestionCard.vue
  40. 28 0
      src/views/quiz/components/QuestionDetailFactory.vue
  41. 98 0
      src/views/quiz/components/QuestionTable.vue
  42. 36 0
      src/views/quiz/components/QuizDetailFactory.vue
  43. 45 0
      src/views/quiz/components/TrueFalseQuestionDetail.vue
  44. 75 0
      src/views/quiz/components/TrueFalseQuestionEditor.vue
  45. 20 0
      src/views/quiz/utils/default.js
  46. 4 0
      src/views/quiz/utils/getCharFromIndex.js
  47. 81 0
      src/views/student/components/StudentDoQuiz.vue
  48. 52 0
      src/views/student/components/StudentQuizDetail.vue
  49. 33 14
      src/views/student/tabs/StudentQuiz.vue
  50. 15 0
      vue.config.js
  51. 10 0
      webpack.config.js
  52. 498 28
      yarn.lock

+ 1 - 0
.eslintignore

@@ -0,0 +1 @@
+src/components/EvaIcon

+ 4 - 0
babel.config.js

@@ -1,5 +1,9 @@
 module.exports = {
   presets: [
     '@vue/cli-plugin-babel/preset'
+  ],
+  plugins: [
+    '@babel/plugin-proposal-optional-chaining',
+    '@babel/plugin-proposal-nullish-coalescing-operator'
   ]
 }

+ 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
+}

+ 144 - 0
mock/api/question/get.json

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

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

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

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

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

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

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

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

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

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

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

+ 72 - 0
mock/index.js

@@ -0,0 +1,72 @@
+const fs = require('fs').promises
+const path = require('path')
+const Koa = require('koa')
+const { pathToRegexp } = require('path-to-regexp')
+const cors = require('@koa/cors')
+const logger = require('koa-logger')
+const { Random, mock } = require('mockjs')
+const randexp = require('randexp').randexp
+const installSerializers = require('./serializers')
+
+const PORT = 4000
+const DATA_ROOT = 'api'
+
+const app = new Koa()
+
+Random.extend({
+  phone: () => randexp(/^1[345798][0-9]{9}$/)
+})
+
+installSerializers(Random, mock)
+
+app.use(cors())
+app.use(logger())
+app.use(async (ctx) => {
+  const request = ctx.request
+  const method = request.method.toLowerCase()
+  const pathname = request.path
+  const routes = await traverseDir(DATA_ROOT)
+
+  const matchedRoute = routes.find((route) => {
+    const regexp = pathToRegexp(`/${route}`.replace(/\/_/g, '/:'))
+    return regexp.test(pathname)
+  })
+
+  if (matchedRoute) {
+    const filePath = path.join(__dirname, matchedRoute, `${method}.json`)
+    const rawData = await fs.readFile(filePath)
+    // ctx.append('authorization', 'basic test-token')
+    ctx.body = mock(JSON.parse(rawData))
+  } else {
+    ctx.throw(404, `pathname or method not found: ${method} ${pathname}`)
+  }
+})
+
+app.listen(PORT)
+// eslint-disable-next-line no-console
+console.log(`mock start at http://localhost:${PORT} port`)
+
+/**
+ * list all dir path
+ * @param {string} dir root path
+ */
+async function traverseDir(dir) {
+  const basePath = path.join(__dirname, `${dir}`)
+  const fileList = await fs.readdir(basePath)
+  const allFiles = []
+  let hasCurrentDir = false
+
+  for (const filename of fileList) {
+    const filepath = path.join(basePath, filename)
+    const stat = await fs.lstat(filepath)
+    const unixFilePath = `${dir}/${filename}`
+    if (stat.isDirectory()) {
+      const subFiles = await traverseDir(unixFilePath)
+      allFiles.push(...subFiles)
+    } else if (!hasCurrentDir) {
+      hasCurrentDir = true
+      allFiles.push(dir)
+    }
+  }
+  return allFiles
+}

+ 217 - 0
mock/serializers.js

@@ -0,0 +1,217 @@
+const { randomArrayValue } = require('./utils')
+
+// basic types
+const basicExtendsTypes = () => ({
+  LOCAL_DATE_TIME: () => Math.floor(new Date().getTime() / 1000),
+  USER_ROLE: (role) => role || randomArrayValue(['STUDENT', 'TEACHER']),
+  BUILD_STATUS: (status) =>
+    status ||
+    randomArrayValue([
+      'SUCCESS',
+      'UNSTABLE',
+      'FAILURE',
+      'TIMEOUT',
+      'WAITING',
+      'RUNNING'
+    ]),
+  PROBLEM_TYPE: (problemType) =>
+    problemType ||
+    randomArrayValue([
+      'JAVA',
+      'JAVA_DATABASE',
+      'PYTHON',
+      'JAVA_COVERAGE',
+      'PYTHON_COVERAGE',
+      'JAVA_MUTATION'
+    ]),
+  EXAM_STATUS: (examStatus) =>
+    examStatus ||
+    randomArrayValue([
+      'PREPARING',
+      'UNAVAILABLE',
+      'READY',
+      'AVAILABLE',
+      'FINISHED',
+      'CLOSED'
+    ])
+})
+
+// serializer type
+const serializersPlugin = (mock) => ({
+  USER_SERIALIZER: (role, id) =>
+    mock({
+      [isNaN(id) ? 'id|1-2000' : 'id']: isNaN(id) ? 1 : id,
+      'score|90-100': 1,
+      username: '@NAME',
+      name: '@NAME',
+      email: '@EMAIL',
+      phone: '@PHONE',
+      role: `@USER_ROLE(${role})`,
+      createdAt: '@LOCAL_DATE_TIME'
+    }),
+  RESULT_SERIALIZER: (deleted) =>
+    mock({
+      'id|1-1000': 1,
+      'score|90-100': 1,
+      lastCommitAt: '@LOCAL_DATE_TIME',
+      deleted: typeof deleted === 'boolean' ? deleted : '@BOOLEAN',
+      exam: {
+        'id|1-1000': 1,
+        name: '@NAME',
+        course: '南京大学软件工程第一期'
+      },
+      owner: {
+        'id|1-1000': 1,
+        username: '@NAME'
+      },
+      'commits|10-20': [
+        {
+          'id|1-1000': 1,
+          finished: '@BOOLEAN',
+          'score|90-100': 1,
+          createdAt: '@LOCAL_DATE_TIME'
+        }
+      ]
+    }),
+  RECORD_SERIALIZER: (status) =>
+    mock({
+      'id|1-1000': 1,
+      'number|1-1000': 1,
+      status: `@BUILD_STATUS(${status})`,
+      consoleOutput: '@PARAGRAPH(1)',
+      'score|90-100': 1,
+      commit: {
+        'id|1-1000': 1,
+        finished: '@BOOLEAN',
+        'score|90-100': 1,
+        createdAt: '@LOCAL_DATE_TIME'
+      },
+      'failures|10-20': [
+        {
+          name: '@TITLE',
+          details: '@SENTENCE',
+          trace: '@PARAGRAPH(1)'
+        }
+      ]
+    }),
+  PROBLEM_SERIALIZER: (type) =>
+    mock({
+      'id|1-1000': 1,
+      name: '@NAME',
+      type: `@PROBLEM_TYPE(${type})`,
+      description: '@SENTENCE',
+      'publicFiles|0-4': ['@URL'],
+      'protectedFiles|0-4': ['@URL'],
+      'privateFiles|0-4': ['@URL'],
+      createdAt: '@LOCAL_DATE_TIME',
+      modifiedAt: '@LOCAL_DATE_TIME',
+      deprecated: '@BOOLEAN',
+      creator: {
+        'id|1-1000': 1,
+        username: '@NAME'
+      }
+    }),
+  PAGE_SERIALIZER: () => ({
+    page: 1,
+    limit: 20,
+    size: 20,
+    total: 100
+  }),
+  EXAM_SERIALIZER: (status) =>
+    mock({
+      'id|1-1000': 1,
+      name: '@NAME',
+      createdAt: '1578405025',
+      startAt: '1578405025',
+      endAt: '1588405025',
+      url: '@URL',
+      status: `@EXAM_STATUS(${status})`,
+      creator: {
+        'id|1-1000': 1,
+        username: '@NAME'
+      },
+      joined: '@BOOLEAN',
+      'courseId|1-1000': 1,
+      courseName: '南京大学软件工程第一期'
+    }),
+  COURSE_SERIALIZER: () =>
+    mock({
+      'id|1-1000': 1,
+      name: '南京大学软件工程第一期',
+      code: '@ID'
+    }),
+  CODE_SERIALIZER: () =>
+    mock({
+      'examId|1-1000': 1,
+      fetchAt: '@LOCAL_DATE_TIME',
+      startAt: '@LOCAL_DATE_TIME',
+      endAt: '@LOCAL_DATE_TIME',
+      'codeList|10-100': [
+        {
+          'questionId|1-1000': 1,
+          language: 'Java',
+          projectName: '软工一作业',
+          editable: {
+            '/a/b/c.java': btoa(`class a{
+              public void helloWorld1(){
+              }
+            }`),
+            '/a/b/a.java': btoa(`class a{
+              public void helloWorld2(){
+              }
+            }`),
+            '/a/c/a.java': btoa(`class a{
+              public void helloWorld3(){
+              }
+            }`),
+            '/a/b/d.java': btoa(`class a{
+              public void helloWorld4(){
+              }
+            }`)
+          },
+          uneditable: {
+            '/README.md': btoa('')
+          }
+        }
+      ]
+    }),
+  COMMIT_SERIALIZER: () =>
+    mock({
+      'id|1-1000': 1,
+      hash: '@STRING(50)',
+      finished: '@BOOLEAN',
+      'score|70-100': 1,
+      createdAt: '@LOCAL_DATE_TIME',
+      result: {
+        'id|1-1000': 1,
+        'score|70-100': 1,
+        exam: {
+          'id|1-1000': 1,
+          name: '@SENTENCE'
+        },
+        owner: {
+          'id|1-1000': 1,
+          username: '@NAME'
+        }
+      },
+      'records|10': [
+        {
+          'id|1-1000': 1,
+          status: '@BUILD_STATUS',
+          'score|70-100': 1,
+          problem: {
+            'id|1-1000': 1,
+            name: '@SENTENCE'
+          }
+        }
+      ]
+    })
+})
+function btoa(str) {
+  if (Buffer.byteLength(str) !== str.length) throw new Error('bad string!')
+  return Buffer.from(str, 'binary').toString('base64')
+}
+module.exports = function(random, mock) {
+  random.extend(basicExtendsTypes())
+  random.extend(serializersPlugin(mock))
+}

+ 5 - 0
mock/utils.js

@@ -0,0 +1,5 @@
+function randomArrayValue(arr = []) {
+  return Array.isArray(arr) ? arr[Math.floor(Math.random() * arr.length)] : ''
+}
+
+exports.randomArrayValue = randomArrayValue

+ 18 - 0
package.json

@@ -11,14 +11,23 @@
   "dependencies": {
     "core-js": "^3.4.3",
     "highlight.js": "^9.17.1",
+    "marked": "^0.8.0",
     "moment": "^2.24.0",
     "register-service-worker": "^1.6.2",
+    "tiptap": "^1.26.6",
+    "tiptap-extensions": "^1.28.6",
     "uuid": "^3.3.3",
     "vue": "^2.6.10",
     "vue-router": "^3.1.3",
     "vuex": "^3.1.2"
   },
+  "resolutions": {
+    "prosemirror-model": "1.8.2"
+  },
   "devDependencies": {
+    "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3",
+    "@babel/plugin-proposal-optional-chaining": "^7.8.3",
+    "@koa/cors": "^3.0.0",
     "@vue/cli-plugin-babel": "^4.1.0",
     "@vue/cli-plugin-eslint": "^4.1.0",
     "@vue/cli-plugin-pwa": "^4.1.0",
@@ -33,9 +42,18 @@
     "cz-conventional-changelog": "^3.0.2",
     "eslint": "^5.16.0",
     "eslint-plugin-vue": "^5.0.0",
+    "koa": "^2.11.0",
+    "koa-logger": "^3.2.1",
     "lint-staged": "^9.4.3",
+    "lodash": "^4.17.15",
+    "mkdirp": "^1.0.3",
+    "mockjs": "^1.1.0",
     "node-sass": "^4.12.0",
+    "path-to-regexp": "^6.1.0",
+    "randexp": "^0.5.3",
+    "rimraf": "^3.0.2",
     "sass-loader": "^8.0.0",
+    "svgo": "^1.3.2",
     "vue-cli-plugin-axios": "^0.0.4",
     "vue-template-compiler": "^2.6.10",
     "webpack": "latest"

+ 119 - 0
script/build-icon.js

@@ -0,0 +1,119 @@
+const fs = require('fs').promises
+const util = require('util')
+const rimraf = util.promisify(require('rimraf'))
+const path = require('path')
+const SVGO = require('svgo')
+const compiler = require('vue-template-compiler')
+const { camelCase, upperFirst } = require('lodash')
+
+const rawDir = '../src/assets/eva-icons'
+const outputDir = '../src/components/EvaIcon'
+const prefix = 'eva-icon'
+
+const fullRawDir = path.join(__dirname, rawDir)
+const fullOutputDir = path.join(__dirname, outputDir)
+
+buildAllSVG(prefix, fullRawDir, fullOutputDir)
+
+const svgoConfig = {
+  onlyMatchedOnce: false,
+  plugins: [
+    {
+      removeXMLNS: true
+    },
+    {
+      removeAttrs: { attrs: '(stroke|fill|class)' }
+    },
+    { removeStyleElement: true },
+    {
+      addAttributesToSVGElement: {
+        attribute: {
+          fill: 'currentColor',
+          width: '1em',
+          height: '1em'
+        }
+      }
+    }
+  ]
+}
+
+const svgo = new SVGO(svgoConfig)
+
+async function buildAllSVG(prefix, inputDir, outputDir) {
+  try {
+    await rimraf(outputDir)
+    console.log('clear output dir')
+    fs.mkdir(outputDir, { recursive: true })
+  } catch (e) {
+    console.log('no cache')
+    console.log(e)
+  }
+
+  const files = await collectAllSubFilePath(inputDir)
+  for (const filePath of files) {
+    const file = await fs.readFile(filePath)
+    const optimizedSVG = await (await svgo.optimize(file)).data
+    const svgRender = compiler
+      .compile(`<i aria-label="icon" class="${prefix}">${optimizedSVG}</i>`)
+      .render.replace(/_/g, '_vm._')
+
+    const fileBasename = path.basename(filePath, '.svg')
+    const filename = upperFirst(camelCase(`${prefix}-${fileBasename}`))
+
+    fs.writeFile(
+      path.join(outputDir, `${filename}.js`),
+      `export default {
+  functional: true,
+  render(_h,_vm){
+    ${removeWith(svgRender)}
+  }
+}
+`
+    )
+
+    fs.writeFile(
+      path.join(outputDir, `${filename}.d.ts`),
+      `declare const ${filename}: any;
+export default ${filename};
+`
+    )
+
+    fs.appendFile(
+      path.join(outputDir, 'index.js'),
+      `export { default as ${filename} } from './${filename}'
+`
+    )
+    fs.appendFile(
+      path.join(outputDir, 'index.d.ts'),
+      `export { default as ${filename} } from './${filename}'
+`
+    )
+  }
+}
+
+function removeWith(str) {
+  const regex = /^with\(this\)\{(.*)}$/
+  return regex.exec(str)[1]
+}
+
+/**
+ * get all sub file path
+ * @param {string} dirPath
+ * @return {Promise<string[]>} allFilePath
+ */
+async function collectAllSubFilePath(dirPath) {
+  const fileList = await fs.readdir(dirPath)
+  const allFiles = []
+
+  for (const filename of fileList) {
+    const filePath = path.join(dirPath, filename)
+    const fileStat = await fs.stat(filePath)
+    if (fileStat.isDirectory()) {
+      const subFileList = await collectAllSubFilePath(filePath)
+      allFiles.push(...subFileList)
+    } else {
+      allFiles.push(filePath)
+    }
+  }
+  return allFiles
+}

+ 305 - 0
src/components/Editor.vue

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

+ 1 - 0
src/components/Layouts/TabSideComponent.vue

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

+ 209 - 0
src/components/Pagination.vue

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

+ 58 - 0
src/components/QuestionKindBar.vue

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

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

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

+ 30 - 1
src/router/index.js

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

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

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

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

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

+ 37 - 0
src/server/question.js

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

+ 63 - 0
src/server/quiz.js

@@ -0,0 +1,63 @@
+import axios from '@/plugins/axios'
+import { useQuizServer } from '@/config/server-info'
+
+export function getQuizList({
+  slideId,
+  state,
+  size = 100000, // TODO: 分页功能暂时不做,因此取很大值
+  page = 1,
+  sort
+}) {
+  const params = { slideId, state, size, page, sort }
+  return axios
+    .get(useQuizServer(), { params })
+    .then(res => res.data)
+}
+
+export function getQuizById(quizId) {
+  return axios
+    .get(useQuizServer(`/${quizId}`))
+    .then(res => res.data)
+}
+
+export function createQuiz({
+  name, // 测试名
+  quizTime, // 测试时间
+  slideId, // 幻灯片 id
+  questions = [] // 题目 id;
+}) {
+  const data = { name, quizTime, slideId, questions }
+  return axios
+    .post(useQuizServer(), data)
+    .then(res => res.data)
+}
+
+export function updateQuiz({
+  id, // 考试 id
+  name, // 测试名
+  quizTime, // 测试时间
+  slideId, // 幻灯片 id
+  questions = [] // 题目 id;
+}) {
+  const data = { id, name, quizTime, slideId, questions }
+  return axios
+    .post(useQuizServer(`/${id}`), data)
+    .then(res => res.data)
+}
+
+export function deleteQuiz(quizId) {
+  return axios
+    .delete(useQuizServer(`/${quizId}`))
+}
+
+export function submitQuiz({ quizId, answers = [] }) {
+  return axios
+    .post(useQuizServer(`/${quizId}/student-answer`), { answers })
+    .then(res => res.data)
+}
+
+export function getQuizResult(quizId) {
+  return axios
+    .get(useQuizServer(`/${quizId}/result`))
+    .then(res => res.data)
+}

+ 8 - 0
src/style/icon.scss

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

+ 57 - 0
src/utils/markdown.js

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

+ 1 - 0
src/utils/types.js

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

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

@@ -0,0 +1,41 @@
+<template>
+  <div class="create-question">
+    <back-title>创建题目</back-title>
+    <question-kind-bar v-model="currentKind" />
+    <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>
+
+<script>
+import BackTitle from '@/components/BackTitle'
+import QuestionKindBar from '@/components/QuestionKindBar'
+import QuestionKind from '@/server/enums/question-kind'
+import ChoiceQuestionEditor from '@/views/quiz/components/ChoiceQuestionEditor'
+import TrueFalseQuestionEditor from '@/views/quiz/components/TrueFalseQuestionEditor'
+import { createQuestion } from '@/server/question'
+export default {
+  components: { TrueFalseQuestionEditor, ChoiceQuestionEditor, QuestionKindBar, BackTitle },
+  data() {
+    return { currentKind: QuestionKind.choice, QuestionKind }
+  },
+  methods: {
+    create(questionBody, kind) {
+      createQuestion({ ...questionBody, kind })
+        .then(() => this.$setSuccessMessage('创建成功'))
+        .then(() => this.$router.go(-1))
+        .catch(err =>
+          this.$setErrorMessage(err.response.data.msg)
+        )
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+  .create-question {
+    margin: 0 auto;
+    max-width: 1100px;
+    padding: 64px 24px;
+  }
+</style>

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

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

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

@@ -0,0 +1,29 @@
+<template>
+  <div>
+    <back-title>题目详情</back-title>
+    <question-detail-factory :question="question" />
+  </div>
+</template>
+
+<script>
+import QuestionKind from '@/server/enums/question-kind'
+import { getQuestionById } from '@/server/question'
+import BackTitle from '@/components/BackTitle'
+import QuestionDetailFactory from '@/views/quiz/components/QuestionDetailFactory'
+
+export default {
+  components: { QuestionDetailFactory, 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>

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,56 @@
+<template>
+  <div>
+    <div v-if="!isEmpty(value.pass)">{{ value.pass ? "回答正确" : "回答错误" }}</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 v-if="!isEmpty(value.studentAnswer)" title="你的答案">
+      {{ value.studentAnswer }}
+    </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'
+import { isEmpty } from '@/utils/types'
+
+export default {
+  name: 'ChoiceQuestionDetail',
+  components: { MarkdownSection, EditorSection },
+  props: {
+    value: {
+      type: Object,
+      default: createDefaultChoiceQuestion
+    }
+  },
+  methods: {
+    markdownToHTML,
+    isEmpty
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+  .choice {
+    display: flex;
+  }
+</style>

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

@@ -0,0 +1,134 @@
+<template>
+  <div>
+    <editor-section title="题干">
+      <editor
+        :init-content="initValue.stem"
+        @update-markdown="value.stem = $event"
+      />
+    </editor-section>
+    <editor-section title="选项">
+      <section
+        v-for="(item, index) in value.options"
+        :key="index"
+        class="choice"
+      >
+        <div class="choice__index">{{ index }}</div>
+        <editor
+          class="choice__content"
+          :init-content="item"
+          @update-markdown="updateOptions(index, $event)"
+        />
+        <button
+          @click="removeOption(index)"
+        >
+          删除
+        </button>
+        <button
+          :style="{ background: index === value.answer? 'green': 'inherit' }"
+          @click="index === value.answer? value.answer = '': value.answer = index"
+        >
+          √
+        </button>
+      </section>
+      <section>
+        <button class="icon blue-icon" @click="addNewOption">
+          +
+        </button>
+      </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 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: { EditorSection, Editor },
+  props: {
+    initValue: {
+      type: Object,
+      default: createDefaultChoiceQuestion
+    },
+    readonly: {
+      type: Boolean,
+      default: false
+    },
+    submitText: {
+      type: String,
+      default: '提交'
+    }
+  },
+  data() {
+    return {
+      value: createDefaultChoiceQuestion()
+    }
+  },
+  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)
+    },
+
+    updateOptions(index, text) {
+      if (this.value.options) {
+        this.value.options[index] = text
+      }
+    },
+
+    addNewOption() {
+      const options = this.value.options || {}
+      const choice = getCharFromIndex(Object.keys(options).length)
+      this.value.options = {
+        ...options,
+        [choice]: ''
+      }
+    },
+
+    removeOption(index) {
+      // 同时删除答案
+      this.value.answer = ''
+
+      const options = this.value.options || {}
+      delete options[index]
+      this.value.options = Object.values(options).reduce((acc, curr, index) => {
+        const choice = getCharFromIndex(index)
+        return {
+          ...acc,
+          [choice]: curr
+        }
+      }, {})
+    }
+  }
+}
+</script>
+
+<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>

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

@@ -0,0 +1,73 @@
+<template>
+  <div>
+    <div class="question-card__header mb-8">
+      <span>
+        <span class="question-id">{{ questionId }}</span>
+        <span :class="`tag tag--${QuestionKind.color[kind]}`">{{ QuestionKind.naming[kind] }}</span>
+      </span>
+      <span>
+        <a v-if="selectable" class="action" style="cursor: pointer" @click="$emit('toggle-select', questionId)">
+          {{ selected ? "取消选择" : "选择" }}
+        </a>
+        <router-link class="action" style="margin-left: 16px;" :to="`/questions/${questionId}`">详情</router-link>
+      </span>
+    </div>
+    <markdown-section :raw="stem" />
+  </div>
+</template>
+
+<script>
+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,
+      default: ''
+    },
+    questionId: {
+      type: Number,
+      required: true
+    },
+    kind: {
+      type: String,
+      required: true
+    },
+    selected: {
+      type: Boolean,
+      default: false
+    },
+    selectable: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return { QuestionKind }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import "~@/style/theme.scss";
+
+.question-id{
+  font-weight: bold;
+}
+.question-card__header{
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.action{
+  color: $theme-blue;
+
+  &:hover{
+    color: darken($theme-blue, 10);
+  }
+}
+</style>

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

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

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

@@ -0,0 +1,98 @@
+<template>
+  <div>
+    <input class="mb-16" v-model="inputStem" placeholder="题干搜索" @input="debounceFetchQuestions" />
+    <router-link to="/create-question">
+      <button>
+        创建题目
+      </button>
+    </router-link>
+    <div>
+      <question-card
+        class="question-card"
+        v-for="item in questionList"
+        :key="item.id"
+        :kind="item.kind"
+        :stem="item.stem"
+        :question-id="item.id"
+        :selectable="selectable"
+        :selected="judgeIsSelected(item)"
+        @toggle-select="handleToggleSelectQuestion(item)"
+      />
+    </div>
+    <pagination class="mt-16" :page="page.number" :size="page.size" :total-elements="page.totalElements" @change="fetchQuestionList"></pagination>
+  </div>
+</template>
+
+<script>
+import debounce from '@/utils/debounce'
+import { getQuestionList } from '@/server/question'
+import Pagination from '@/components/Pagination'
+import { markdownToHTML } from '@/utils/markdown'
+import QuestionCard from '@/views/quiz/components/QuestionCard'
+
+export default {
+  name: 'QuestionTable',
+  components: { QuestionCard, Pagination },
+  props: {
+    selectable: {
+      type: Boolean,
+      default: false
+    },
+    selectedQuestions: {
+      type: Array,
+      default: () => []
+    }
+  },
+  model: {
+    prop: 'selectedQuestions',
+    event: 'select'
+  },
+  filters: {
+    markdownToHTML: markdownToHTML
+  },
+  data() {
+    return {
+      inputStem: '',
+      questionList: [],
+      page: {},
+
+      // 模糊搜索时使用
+      debounceFetchQuestions: debounce(() => this.fetchQuestionList())
+    }
+  },
+  mounted() {
+    this.fetchQuestionList()
+  },
+  methods: {
+    fetchQuestionList({
+      stem = this.inputStem,
+      page = 1,
+      size = 10
+    } = {}) {
+      getQuestionList({ stem, size, page })
+        .then(({ questions, page }) => {
+          this.questionList = questions
+          this.page = page
+        })
+    },
+    judgeIsSelected(question) {
+      return Boolean(this.selectedQuestions
+        .find(item => item.id === question.id))
+    },
+    handleToggleSelectQuestion(question) {
+      if (this.judgeIsSelected(question)) {
+        this.$emit('select', this.selectedQuestions.filter(item => item.id !== question.id))
+      } else {
+        this.$emit('select', [...this.selectedQuestions, question])
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.question-card {
+  padding: 16px 0;
+  border-bottom: 1px solid rgba(12, 36, 124, 0.12);
+}
+</style>

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

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

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

@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <div v-if="!isEmpty(value.pass)">{{ value.pass ? "回答正确" : "回答错误" }}</div>
+    <editor-section title="题干">
+      <markdown-section :raw="value.stem" />
+    </editor-section>
+    <editor-section v-if="!isEmpty(value.studentAnswer)" title="你的答案">
+      <span v-if="value.studentAnswer" >√</span>
+      <span v-else >×</span>
+    </editor-section>
+    <editor-section title="正确答案">
+      <span v-if="value.answer" >√</span>
+      <span v-else >×</span>
+    </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 { isEmpty } from '@/utils/types'
+
+export default {
+  name: 'TrueFalseQuestionDetail',
+  components: { MarkdownSection, EditorSection },
+  props: {
+    value: {
+      type: Object,
+      default: createDefaultChoiceQuestion
+    }
+  },
+  methods: {
+    markdownToHTML,
+    isEmpty
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+</style>

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

@@ -0,0 +1,75 @@
+<template>
+  <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"
+      >
+        <span v-if="value.answer" >√</span>
+        <span v-else >×</span>
+      </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 EditorSection from '@/views/quiz/components/EditorSection'
+import { createDefaultTrueFalseQuestion } from '@/views/quiz/utils/default'
+
+export default {
+  name: 'TrueFalseQuestionEditor',
+  components: { EditorSection, 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 lang="scss" scoped>
+</style>

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

@@ -0,0 +1,20 @@
+export const createDefaultChoiceQuestion = () => ({
+  stem: '',
+  options: {},
+  answer: '',
+  analysis: ''
+})
+
+export const createDefaultTrueFalseQuestion = () => ({
+  stem: '',
+  answer: true,
+  analysis: ''
+})
+
+export const createDefaultQuiz = () => ({
+  name: '',
+  course: {},
+  quizTime: '',
+  slide: {},
+  questions: []
+})

+ 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

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

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

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

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

+ 33 - 14
src/views/student/tabs/StudentQuiz.vue

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

+ 15 - 0
vue.config.js

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

+ 10 - 0
webpack.config.js

@@ -0,0 +1,10 @@
+// only to make webstorm know alias
+const path = require('path')
+const base = path.resolve(__dirname, 'src')
+module.exports = {
+  resolve: {
+    alias: {
+      '@': base
+    }
+  }
+}

+ 498 - 28
yarn.lock

@@ -161,6 +161,11 @@
   resolved "https://registry.npm.taobao.org/@babel/helper-plugin-utils/download/@babel/helper-plugin-utils-7.0.0.tgz#bbb3fbee98661c569034237cc03967ba99b4f250"
   integrity sha1-u7P77phmHFaQNCN8wDlnupm08lA=
 
+"@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670"
+  integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==
+
 "@babel/helper-regex@^7.0.0", "@babel/helper-regex@^7.4.4":
   version "7.5.5"
   resolved "https://registry.npm.taobao.org/@babel/helper-regex/download/@babel/helper-regex-7.5.5.tgz#0aa6824f7100a2e0e89c1527c23936c152cab351"
@@ -279,6 +284,14 @@
     "@babel/helper-plugin-utils" "^7.0.0"
     "@babel/plugin-syntax-json-strings" "^7.7.4"
 
+"@babel/plugin-proposal-nullish-coalescing-operator@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.8.3.tgz#e4572253fdeed65cddeecfdab3f928afeb2fd5d2"
+  integrity sha512-TS9MlfzXpXKt6YYomudb/KU7nQI6/xnapG6in1uZxoxDghuSMZsPb6D2fyUwNYSAp4l1iR7QtFOjkqcRYcUsfw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0"
+
 "@babel/plugin-proposal-object-rest-spread@^7.7.7":
   version "7.7.7"
   resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-object-rest-spread/download/@babel/plugin-proposal-object-rest-spread-7.7.7.tgz#9f27075004ab99be08c5c1bd653a2985813cb370"
@@ -295,6 +308,14 @@
     "@babel/helper-plugin-utils" "^7.0.0"
     "@babel/plugin-syntax-optional-catch-binding" "^7.7.4"
 
+"@babel/plugin-proposal-optional-chaining@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.8.3.tgz#ae10b3214cb25f7adb1f3bc87ba42ca10b7e2543"
+  integrity sha512-QIoIR9abkVn+seDE3OjA08jWcs3eZ9+wJCKSRgo3WdEU2csFYgdScb+8qHB3+WXsGJD55u+5hWCISI7ejXS+kg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.0"
+
 "@babel/plugin-proposal-unicode-property-regex@^7.7.7":
   version "7.7.7"
   resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-unicode-property-regex/download/@babel/plugin-proposal-unicode-property-regex-7.7.7.tgz#433fa9dac64f953c12578b29633f456b68831c4e"
@@ -338,6 +359,13 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.0.0"
 
+"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9"
+  integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
 "@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.7.4":
   version "7.7.4"
   resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-object-rest-spread/download/@babel/plugin-syntax-object-rest-spread-7.7.4.tgz?cache=0&sync_timestamp=1574465586766&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-syntax-object-rest-spread%2Fdownload%2F%40babel%2Fplugin-syntax-object-rest-spread-7.7.4.tgz#47cf220d19d6d0d7b154304701f468fc1cc6ff46"
@@ -352,6 +380,13 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.0.0"
 
+"@babel/plugin-syntax-optional-chaining@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a"
+  integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
 "@babel/plugin-syntax-top-level-await@^7.7.4":
   version "7.7.4"
   resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-top-level-await/download/@babel/plugin-syntax-top-level-await-7.7.4.tgz?cache=0&sync_timestamp=1574466726312&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-syntax-top-level-await%2Fdownload%2F%40babel%2Fplugin-syntax-top-level-await-7.7.4.tgz#bd7d8fa7b9fee793a36e4027fd6dd1aa32f946da"
@@ -936,6 +971,13 @@
     "@types/istanbul-reports" "^1.1.1"
     "@types/yargs" "^13.0.0"
 
+"@koa/cors@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.0.0.tgz#df021b4df2dadf1e2b04d7c8ddf93ba2d42519cb"
+  integrity sha512-hDp+cXj6vTYSwHRJfiSpnf5dTMv3FmqNKh1or9BPJk4SHOviHnK9GoL9dT0ypt/E+hlkRkZ9edHylcosW3Ghrw==
+  dependencies:
+    vary "^1.1.2"
+
 "@mrmlnc/readdir-enhanced@^2.2.1":
   version "2.2.1"
   resolved "https://registry.npm.taobao.org/@mrmlnc/readdir-enhanced/download/@mrmlnc/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
@@ -1564,7 +1606,7 @@ abbrev@1:
   resolved "https://registry.npm.taobao.org/abbrev/download/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
   integrity sha1-+PLIh60Qv2f2NPAFtph/7TF5qsg=
 
-accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
+accepts@^1.3.5, accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
   version "1.3.7"
   resolved "https://registry.npm.taobao.org/accepts/download/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
   integrity sha1-UxvHJlF6OytB+FACHGzBXqq1B80=
@@ -1715,7 +1757,7 @@ any-observable@^0.3.0:
   resolved "https://registry.npm.taobao.org/any-observable/download/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b"
   integrity sha1-r5M0deWAamfQ198JDdXovvZdEZs=
 
-any-promise@^1.0.0:
+any-promise@^1.0.0, any-promise@^1.1.0:
   version "1.3.0"
   resolved "https://registry.npm.taobao.org/any-promise/download/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
   integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
@@ -2389,7 +2431,7 @@ bytes@3.0.0:
   resolved "https://registry.npm.taobao.org/bytes/download/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
   integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
 
-bytes@3.1.0:
+bytes@3.1.0, bytes@^3.1.0:
   version "3.1.0"
   resolved "https://registry.npm.taobao.org/bytes/download/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
   integrity sha1-9s95M6Ng4FiPqf3oVlHNx/gF0fY=
@@ -2454,6 +2496,14 @@ cache-base@^1.0.1:
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+cache-content-type@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
+  integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==
+  dependencies:
+    mime-types "^2.1.18"
+    ylru "^1.2.0"
+
 cache-loader@^4.1.0:
   version "4.1.0"
   resolved "https://registry.npm.taobao.org/cache-loader/download/cache-loader-4.1.0.tgz#9948cae353aec0a1fcb1eafda2300816ec85387e"
@@ -2851,6 +2901,11 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
   dependencies:
     delayed-stream "~1.0.0"
 
+commander@*:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
+  integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
+
 commander@2.17.x:
   version "2.17.1"
   resolved "https://registry.npm.taobao.org/commander/download/commander-2.17.1.tgz?cache=0&sync_timestamp=1573464098030&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcommander%2Fdownload%2Fcommander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
@@ -2986,14 +3041,14 @@ contains-path@^0.1.0:
   resolved "https://registry.npm.taobao.org/contains-path/download/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
   integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=
 
-content-disposition@0.5.3:
+content-disposition@0.5.3, content-disposition@~0.5.2:
   version "0.5.3"
   resolved "https://registry.npm.taobao.org/content-disposition/download/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
   integrity sha1-4TDK9+cnkIfFYWwgB9BIVpiYT70=
   dependencies:
     safe-buffer "5.1.2"
 
-content-type@~1.0.4:
+content-type@^1.0.4, content-type@~1.0.4:
   version "1.0.4"
   resolved "https://registry.npm.taobao.org/content-type/download/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
   integrity sha1-4TjMdeBAxyexlm/l5fjJruJW/js=
@@ -3020,6 +3075,14 @@ cookie@0.4.0:
   resolved "https://registry.npm.taobao.org/cookie/download/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
   integrity sha1-vrQ35wIrO21JAZ0IhmUwPr6cFLo=
 
+cookies@~0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
+  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+  dependencies:
+    depd "~2.0.0"
+    keygrip "~1.1.0"
+
 copy-concurrently@^1.0.0:
   version "1.0.5"
   resolved "https://registry.npm.taobao.org/copy-concurrently/download/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
@@ -3450,7 +3513,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
   dependencies:
     ms "2.0.0"
 
-debug@=3.1.0:
+debug@=3.1.0, debug@~3.1.0:
   version "3.1.0"
   resolved "https://registry.npm.taobao.org/debug/download/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   integrity sha1-W7WgZyYotkFJVmuhaBnmFRjGcmE=
@@ -3498,6 +3561,11 @@ deep-equal@^1.0.1:
     object-keys "^1.1.1"
     regexp.prototype.flags "^1.2.0"
 
+deep-equal@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+  integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=
+
 deep-is@~0.1.3:
   version "0.1.3"
   resolved "https://registry.npm.taobao.org/deep-is/download/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
@@ -3601,11 +3669,16 @@ delegates@^1.0.0:
   resolved "https://registry.npm.taobao.org/delegates/download/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
   integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
 
-depd@~1.1.2:
+depd@^1.1.2, depd@~1.1.2:
   version "1.1.2"
   resolved "https://registry.npm.taobao.org/depd/download/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
   integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
 
+depd@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
 des.js@^1.0.0:
   version "1.0.1"
   resolved "https://registry.npm.taobao.org/des.js/download/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843"
@@ -3614,7 +3687,7 @@ des.js@^1.0.0:
     inherits "^2.0.1"
     minimalistic-assert "^1.0.0"
 
-destroy@~1.0.4:
+destroy@^1.0.4, destroy@~1.0.4:
   version "1.0.4"
   resolved "https://registry.npm.taobao.org/destroy/download/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
   integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
@@ -3784,6 +3857,11 @@ dotenv@^8.2.0:
   resolved "https://registry.npm.taobao.org/dotenv/download/dotenv-8.2.0.tgz?cache=0&sync_timestamp=1571190782798&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdotenv%2Fdownload%2Fdotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
   integrity sha1-l+YZJZradQ7qPk6j4mvO6lQksWo=
 
+drange@^1.0.2:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/drange/-/drange-1.1.1.tgz#b2aecec2aab82fcef11dbbd7b9e32b83f8f6c0b8"
+  integrity sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==
+
 duplexer@^0.1.1:
   version "0.1.1"
   resolved "https://registry.npm.taobao.org/duplexer/download/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"
@@ -3870,7 +3948,7 @@ emojis-list@^2.0.0:
   resolved "https://registry.npm.taobao.org/emojis-list/download/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
   integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k=
 
-encodeurl@~1.0.2:
+encodeurl@^1.0.2, encodeurl@~1.0.2:
   version "1.0.2"
   resolved "https://registry.npm.taobao.org/encodeurl/download/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
   integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
@@ -3915,6 +3993,11 @@ error-ex@^1.2.0, error-ex@^1.3.1:
   dependencies:
     is-arrayish "^0.2.1"
 
+error-inject@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/error-inject/-/error-inject-1.0.0.tgz#e2b3d91b54aed672f309d950d154850fa11d4f37"
+  integrity sha1-4rPZG1Su1nLzCdlQ0VSFD6EdTzc=
+
 error-stack-parser@^2.0.0:
   version "2.0.4"
   resolved "https://registry.npm.taobao.org/error-stack-parser/download/error-stack-parser-2.0.4.tgz#a757397dc5d9de973ac9a5d7d4e8ade7cfae9101"
@@ -3948,7 +4031,7 @@ es-to-primitive@^1.2.1:
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
-escape-html@~1.0.3:
+escape-html@^1.0.3, escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.npm.taobao.org/escape-html/download/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
   integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
@@ -4446,6 +4529,13 @@ fastq@^1.6.0:
   dependencies:
     reusify "^1.0.0"
 
+fault@^1.0.2:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13"
+  integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==
+  dependencies:
+    format "^0.2.0"
+
 faye-websocket@^0.10.0:
   version "0.10.0"
   resolved "https://registry.npm.taobao.org/faye-websocket/download/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4"
@@ -4685,6 +4775,11 @@ form-data@~2.3.2:
     combined-stream "^1.0.6"
     mime-types "^2.1.12"
 
+format@^0.2.0:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b"
+  integrity sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=
+
 forwarded@~0.1.2:
   version "0.1.2"
   resolved "https://registry.npm.taobao.org/forwarded/download/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
@@ -4697,7 +4792,7 @@ fragment-cache@^0.2.1:
   dependencies:
     map-cache "^0.2.2"
 
-fresh@0.5.2:
+fresh@0.5.2, fresh@~0.5.2:
   version "0.5.2"
   resolved "https://registry.npm.taobao.org/fresh/download/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
   integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
@@ -5150,6 +5245,11 @@ highlight.js@^9.17.1, highlight.js@^9.6.0:
   dependencies:
     handlebars "^4.5.3"
 
+highlight.js@~9.16.0:
+  version "9.16.2"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.16.2.tgz#68368d039ffe1c6211bcc07e483daf95de3e403e"
+  integrity sha512-feMUrVLZvjy0oC7FVJQcSQRqbBq9kwqnYE4+Kj9ZjbHh3g+BisiPgF49NyQbVLNdrL/qqZr3Ca9yOKwgn2i/tw==
+
 hmac-drbg@^1.0.0:
   version "1.0.1"
   resolved "https://registry.npm.taobao.org/hmac-drbg/download/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@@ -5256,6 +5356,14 @@ htmlparser2@^3.3.0:
     inherits "^2.0.1"
     readable-stream "^3.1.1"
 
+http-assert@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.4.1.tgz#c5f725d677aa7e873ef736199b89686cceb37878"
+  integrity sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==
+  dependencies:
+    deep-equal "~1.0.1"
+    http-errors "~1.7.2"
+
 http-deceiver@^1.2.7:
   version "1.2.7"
   resolved "https://registry.npm.taobao.org/http-deceiver/download/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
@@ -5272,17 +5380,7 @@ http-errors@1.7.2:
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
-http-errors@~1.6.2:
-  version "1.6.3"
-  resolved "https://registry.npm.taobao.org/http-errors/download/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
-  integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.0"
-    statuses ">= 1.4.0 < 2"
-
-http-errors@~1.7.2:
+http-errors@^1.6.3, http-errors@~1.7.2:
   version "1.7.3"
   resolved "https://registry.npm.taobao.org/http-errors/download/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
   integrity sha1-bGGeT5xgMIw4UZSYwU+7EKrOuwY=
@@ -5293,6 +5391,16 @@ http-errors@~1.7.2:
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.npm.taobao.org/http-errors/download/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
 "http-parser-js@>=0.4.0 <0.4.11":
   version "0.4.10"
   resolved "https://registry.npm.taobao.org/http-parser-js/download/http-parser-js-0.4.10.tgz?cache=0&sync_timestamp=1572714277347&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhttp-parser-js%2Fdownload%2Fhttp-parser-js-0.4.10.tgz#92c9c1374c35085f75db359ec56cc257cbb93fa4"
@@ -5336,6 +5444,11 @@ human-signals@^1.1.1:
   resolved "https://registry.npm.taobao.org/human-signals/download/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
   integrity sha1-xbHNFPUK6uCatsWf5jujOV/k36M=
 
+humanize-number@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/humanize-number/-/humanize-number-0.0.2.tgz#11c0af6a471643633588588048f1799541489c18"
+  integrity sha1-EcCvakcWQ2M1iFiASPF5lUFInBg=
+
 iconv-lite@0.4.24, iconv-lite@^0.4.24:
   version "0.4.24"
   resolved "https://registry.npm.taobao.org/iconv-lite/download/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -5741,6 +5854,11 @@ is-generator-fn@^2.0.0:
   resolved "https://registry.npm.taobao.org/is-generator-fn/download/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118"
   integrity sha1-fRQK3DiarzARqPKipM+m+q3/sRg=
 
+is-generator-function@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.7.tgz#d2132e529bb0000a7f80794d4bdf5cd5e5813522"
+  integrity sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw==
+
 is-glob@^3.1.0:
   version "3.1.0"
   resolved "https://registry.npm.taobao.org/is-glob/download/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
@@ -6562,6 +6680,13 @@ jsprim@^1.2.2:
     json-schema "0.2.3"
     verror "1.10.0"
 
+keygrip@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
+  integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
+  dependencies:
+    tsscmp "1.0.6"
+
 killable@^1.0.1:
   version "1.0.1"
   resolved "https://registry.npm.taobao.org/killable/download/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
@@ -6596,6 +6721,66 @@ kleur@^3.0.3:
   resolved "https://registry.npm.taobao.org/kleur/download/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
   integrity sha1-p5yezIbuHOP6YgbRIWxQHxR/wH4=
 
+koa-compose@^3.0.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-3.2.1.tgz#a85ccb40b7d986d8e5a345b3a1ace8eabcf54de7"
+  integrity sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=
+  dependencies:
+    any-promise "^1.1.0"
+
+koa-compose@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
+  integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==
+
+koa-convert@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-1.2.0.tgz#da40875df49de0539098d1700b50820cebcd21d0"
+  integrity sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=
+  dependencies:
+    co "^4.6.0"
+    koa-compose "^3.0.0"
+
+koa-logger@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/koa-logger/-/koa-logger-3.2.1.tgz#ab9db879526db3837cc9ce4fd983c025b1689f22"
+  integrity sha512-MjlznhLLKy9+kG8nAXKJLM0/ClsQp/Or2vI3a5rbSQmgl8IJBQO0KI5FA70BvW+hqjtxjp49SpH2E7okS6NmHg==
+  dependencies:
+    bytes "^3.1.0"
+    chalk "^2.4.2"
+    humanize-number "0.0.2"
+    passthrough-counter "^1.0.0"
+
+koa@^2.11.0:
+  version "2.11.0"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.11.0.tgz#fe5a51c46f566d27632dd5dc8fd5d7dd44f935a4"
+  integrity sha512-EpR9dElBTDlaDgyhDMiLkXrPwp6ZqgAIBvhhmxQ9XN4TFgW+gEz6tkcsNI6BnUbUftrKDjVFj4lW2/J2aNBMMA==
+  dependencies:
+    accepts "^1.3.5"
+    cache-content-type "^1.0.0"
+    content-disposition "~0.5.2"
+    content-type "^1.0.4"
+    cookies "~0.8.0"
+    debug "~3.1.0"
+    delegates "^1.0.0"
+    depd "^1.1.2"
+    destroy "^1.0.4"
+    encodeurl "^1.0.2"
+    error-inject "^1.0.0"
+    escape-html "^1.0.3"
+    fresh "~0.5.2"
+    http-assert "^1.3.0"
+    http-errors "^1.6.3"
+    is-generator-function "^1.0.7"
+    koa-compose "^4.1.0"
+    koa-convert "^1.2.0"
+    on-finished "^2.3.0"
+    only "~0.0.2"
+    parseurl "^1.3.2"
+    statuses "^1.5.0"
+    type-is "^1.6.16"
+    vary "^1.1.2"
+
 launch-editor-middleware@^2.2.1:
   version "2.2.1"
   resolved "https://registry.npm.taobao.org/launch-editor-middleware/download/launch-editor-middleware-2.2.1.tgz#e14b07e6c7154b0a4b86a0fd345784e45804c157"
@@ -6928,6 +7113,14 @@ lower-case@^1.1.1:
   resolved "https://registry.npm.taobao.org/lower-case/download/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
   integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
 
+lowlight@1.13.0:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.13.0.tgz#9b4fd00559985e40e11c916ccab14c7c0cf4320d"
+  integrity sha512-bFXLa+UO1eM3zieFAcNqf6rTQ1D5ERFv64/euQbbH/LT3U9XXwH6tOrgUAGWDsQ1QgN3ZhgOcv8p3/S+qKGdTQ==
+  dependencies:
+    fault "^1.0.2"
+    highlight.js "~9.16.0"
+
 lru-cache@^4.0.1, lru-cache@^4.1.2, lru-cache@^4.1.5:
   version "4.1.5"
   resolved "https://registry.npm.taobao.org/lru-cache/download/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
@@ -6999,6 +7192,11 @@ map-visit@^1.0.0:
   dependencies:
     object-visit "^1.0.0"
 
+marked@^0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/marked/-/marked-0.8.0.tgz#ec5c0c9b93878dc52dd54be8d0e524097bd81a99"
+  integrity sha512-MyUe+T/Pw4TZufHkzAfDj6HarCBWia2y27/bhuYkTaiUnfDYFnCP3KUN+9oM7Wi6JA2rymtVYbQu3spE0GCmxQ==
+
 md5.js@^1.3.4:
   version "1.3.5"
   resolved "https://registry.npm.taobao.org/md5.js/download/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@@ -7131,6 +7329,11 @@ mime-db@1.42.0, "mime-db@>= 1.40.0 < 2":
   resolved "https://registry.npm.taobao.org/mime-db/download/mime-db-1.42.0.tgz?cache=0&sync_timestamp=1569468742433&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmime-db%2Fdownload%2Fmime-db-1.42.0.tgz#3e252907b4c7adb906597b4b65636272cf9e7bac"
   integrity sha1-PiUpB7THrbkGWXtLZWNics+ee6w=
 
+mime-db@1.43.0:
+  version "1.43.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58"
+  integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==
+
 mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24:
   version "2.1.25"
   resolved "https://registry.npm.taobao.org/mime-types/download/mime-types-2.1.25.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmime-types%2Fdownload%2Fmime-types-2.1.25.tgz#39772d46621f93e2a80a856c53b86a62156a6437"
@@ -7138,6 +7341,13 @@ mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24:
   dependencies:
     mime-db "1.42.0"
 
+mime-types@^2.1.18:
+  version "2.1.26"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06"
+  integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==
+  dependencies:
+    mime-db "1.43.0"
+
 mime@1.6.0:
   version "1.6.0"
   resolved "https://registry.npm.taobao.org/mime/download/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
@@ -7259,6 +7469,18 @@ mkdirp@0.5.1, mkdirp@0.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp
   dependencies:
     minimist "0.0.8"
 
+mkdirp@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.3.tgz#4cf2e30ad45959dddea53ad97d518b6c8205e1ea"
+  integrity sha512-6uCP4Qc0sWsgMLy1EOqqS/3rjDHOEnsStVr/4vtAIK2Y5i2kA7lFFejYrpIyiN9w0pYf4ckeCYT9f1r1P9KX5g==
+
+mockjs@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/mockjs/-/mockjs-1.1.0.tgz#e6a0c378e91906dbaff20911cc0273b3c7d75b06"
+  integrity sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ==
+  dependencies:
+    commander "*"
+
 moment@^2.24.0:
   version "2.24.0"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
@@ -7688,7 +7910,7 @@ obuf@^1.0.0, obuf@^1.1.2:
   resolved "https://registry.npm.taobao.org/obuf/download/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
   integrity sha1-Cb6jND1BhZ69RGKS0RydTbYZCE4=
 
-on-finished@~2.3.0:
+on-finished@^2.3.0, on-finished@~2.3.0:
   version "2.3.0"
   resolved "https://registry.npm.taobao.org/on-finished/download/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
   integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
@@ -7721,6 +7943,11 @@ onetime@^5.1.0:
   dependencies:
     mimic-fn "^2.1.0"
 
+only@~0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
+  integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=
+
 open@^6.3.0:
   version "6.4.0"
   resolved "https://registry.npm.taobao.org/open/download/open-6.4.0.tgz?cache=0&sync_timestamp=1571165370049&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fopen%2Fdownload%2Fopen-6.4.0.tgz#5c13e96d0dc894686164f18965ecfe889ecfc8a9"
@@ -7772,6 +7999,11 @@ ora@^3.4.0:
     strip-ansi "^5.2.0"
     wcwidth "^1.0.1"
 
+orderedmap@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-1.1.1.tgz#c618e77611b3b21d0fe3edc92586265e0059c789"
+  integrity sha512-3Ux8um0zXbVacKUkcytc0u3HgC0b0bBLT+I60r2J/En72cI0nZffqrA7Xtf2Hqs27j1g82llR5Mhbd0Z1XW4AQ==
+
 original@^1.0.0:
   version "1.0.2"
   resolved "https://registry.npm.taobao.org/original/download/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f"
@@ -8006,7 +8238,7 @@ parse5@^5.1.1:
   resolved "https://registry.npm.taobao.org/parse5/download/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
   integrity sha1-9o5OW6GFKsLK3AD0VV//bCq7YXg=
 
-parseurl@~1.3.2, parseurl@~1.3.3:
+parseurl@^1.3.2, parseurl@~1.3.2, parseurl@~1.3.3:
   version "1.3.3"
   resolved "https://registry.npm.taobao.org/parseurl/download/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
   integrity sha1-naGee+6NEt/wUT7Vt2lXeTvC6NQ=
@@ -8016,6 +8248,11 @@ pascalcase@^0.1.1:
   resolved "https://registry.npm.taobao.org/pascalcase/download/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
   integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
 
+passthrough-counter@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/passthrough-counter/-/passthrough-counter-1.0.0.tgz#1967d9e66da572b5c023c787db112a387ab166fa"
+  integrity sha1-GWfZ5m2lcrXAI8eH2xEqOHqxZvo=
+
 path-browserify@0.0.1:
   version "0.0.1"
   resolved "https://registry.npm.taobao.org/path-browserify/download/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a"
@@ -8073,6 +8310,11 @@ path-to-regexp@0.1.7:
   resolved "https://registry.npm.taobao.org/path-to-regexp/download/path-to-regexp-0.1.7.tgz?cache=0&sync_timestamp=1574278831909&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpath-to-regexp%2Fdownload%2Fpath-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
   integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
 
+path-to-regexp@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.1.0.tgz#0b18f88b7a0ce0bfae6a25990c909ab86f512427"
+  integrity sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==
+
 path-type@^1.0.0:
   version "1.1.0"
   resolved "https://registry.npm.taobao.org/path-type/download/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
@@ -8635,6 +8877,137 @@ prompts@^2.0.1:
     kleur "^3.0.3"
     sisteransi "^1.0.3"
 
+prosemirror-collab@1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.2.2.tgz#8d2c0e82779cfef5d051154bd0836428bd6d9c4a"
+  integrity sha512-tBnHKMLgy5Qmx9MYVcLfs3pAyjtcqYYDd9kp3y+LSiQzkhMQDfZSV3NXWe4Gsly32adSef173BvObwfoSQL5MA==
+  dependencies:
+    prosemirror-state "^1.0.0"
+
+prosemirror-commands@1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.1.2.tgz#6868cabc9f9112fba94c805139473527774b0dea"
+  integrity sha512-JBa06kjgX67d9JVUVJbCkxwvSGtQnWAN/85nq9csOMS5Z9WZLEvVDtVvZranNlu8l/XNVBWrZxOOK+pB03eTfA==
+  dependencies:
+    prosemirror-model "^1.0.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-dropcursor@1.3.2:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.3.2.tgz#28738c4ed7102e814d7a8a26d70018523fc7cd6d"
+  integrity sha512-4c94OUGyobGnwcQI70OXyMhE/9T4aTgjU+CHxkd5c7D+jH/J0mKM/lk+jneFVKt7+E4/M0D9HzRPifu8U28Thw==
+  dependencies:
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.1.0"
+    prosemirror-view "^1.1.0"
+
+prosemirror-gapcursor@1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.1.2.tgz#a1400a86a51d4cccc065e68d5625a9fb5bc623e0"
+  integrity sha512-Z+eqk6RysZVxidGWN5aWoSTbn5bTHf1XZ+nQJVwUSdwdBVkfQMFdTHgfrXA8W5MhHHdNg/EEEYG3z3Zi/vE2QQ==
+  dependencies:
+    prosemirror-keymap "^1.0.0"
+    prosemirror-model "^1.0.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-view "^1.0.0"
+
+prosemirror-history@1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.1.2.tgz#3e8f11efbd316e98322028be67549df1f94fc6da"
+  integrity sha512-erhxYS5gm/6MiXP8jUoJBgc8IbaqjHDVPl9KGg5JrMZOSSOwHv85+4Fb0Q7sYtv2fYwAjOSw/kSA9vkxJ6wOwA==
+  dependencies:
+    prosemirror-state "^1.2.2"
+    prosemirror-transform "^1.0.0"
+    rope-sequence "^1.3.0"
+
+prosemirror-inputrules@1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.1.2.tgz#487e46c763e1212a4577397aba7706139084f012"
+  integrity sha512-Ja5Z3BWestlHYGvtSGqyvxMeB8QEuBjlHM8YnKtLGUXMDp965qdDV4goV8lJb17kIWHk7e7JNj6Catuoa3302g==
+  dependencies:
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-keymap@1.1.3, prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.1.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.1.3.tgz#be22d6108df2521608e9216a87b1a810f0ed361e"
+  integrity sha512-PRA4NzkUMzV/NFf5pyQ6tmlIHiW/qjQ1kGWUlV2rF/dvlOxtpGpTEjIMhWgLuMf+HiDEFnUEP7uhYXu+t+491g==
+  dependencies:
+    prosemirror-state "^1.0.0"
+    w3c-keyname "^2.2.0"
+
+prosemirror-model@1.8.2, prosemirror-model@^1.0.0, prosemirror-model@^1.1.0, prosemirror-model@^1.8.1:
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.8.2.tgz#c74eaacb0bbfea49b59a6d89fef5516181666a56"
+  integrity sha512-piffokzW7opZVCjf/9YaoXvTC0g7zMRWKJib1hpphPfC+4x6ZXe5CiExgycoWZJe59VxxP7uHX8aFiwg2i9mUQ==
+  dependencies:
+    orderedmap "^1.1.0"
+
+prosemirror-schema-list@1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.1.2.tgz#310809209094b03425da7f5c337105074913da6c"
+  integrity sha512-dgM9PwtM4twa5WsgSYMB+J8bwjnR43DAD3L9MsR9rKm/nZR5Y85xcjB7gusVMSsbQ2NomMZF03RE6No6mTnclQ==
+  dependencies:
+    prosemirror-model "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-state@1.3.2, prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.3.1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.3.2.tgz#1b910b0dc01c1f00926bb9ba1589f7b7ac0d658b"
+  integrity sha512-t/JqE3aR0SV9QrzFVkAXsQwsgrQBNs/BDbcFH20RssW0xauqNNdjTXxy/J/kM7F+0zYi6+BRmz7cMMQQFU3mwQ==
+  dependencies:
+    prosemirror-model "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-tables@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.0.0.tgz#ec3d0b11e638c6a92dd14ae816d0a2efd1719b70"
+  integrity sha512-zFw5Us4G5Vdq0yIj8GiqZOGA6ud5UKpMKElux9O0HrfmhkuGa1jf1PCpz2R5pmIQJv+tIM24H1mox/ODBAX37Q==
+  dependencies:
+    prosemirror-keymap "^1.1.2"
+    prosemirror-model "^1.8.1"
+    prosemirror-state "^1.3.1"
+    prosemirror-transform "^1.2.1"
+    prosemirror-view "^1.13.3"
+
+prosemirror-transform@1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.2.2.tgz#4439ae7e88ea1395d9beed6a4cd852d72b16ed2f"
+  integrity sha512-expO11jAsxaHk2RdZtzPsumc1bAAZi4UiXwTLQbftsdnIUWZE5Snyag595p1lx/B8QHUZ6tYWWOaOkzXKoJmYw==
+  dependencies:
+    prosemirror-model "^1.0.0"
+
+prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.2.1:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.2.3.tgz#239d17591af24d39ef3f1999daa09e1f1c76b06a"
+  integrity sha512-PUfayeskQfuUBXktvL6207ZWRwHBFNPNPiek4fR+LgCPnBofuEb2+L0FfbNtrAwffHVs6M3DaFvJB1W2VQdV0A==
+  dependencies:
+    prosemirror-model "^1.0.0"
+
+prosemirror-utils@0.9.6:
+  version "0.9.6"
+  resolved "https://registry.yarnpkg.com/prosemirror-utils/-/prosemirror-utils-0.9.6.tgz#3d97bd85897e3b535555867dc95a51399116a973"
+  integrity sha512-UC+j9hQQ1POYfMc5p7UFxBTptRiGPR7Kkmbl3jVvU8VgQbkI89tR/GK+3QYC8n+VvBZrtAoCrJItNhWSxX3slA==
+
+prosemirror-view@1.13.4:
+  version "1.13.4"
+  resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.13.4.tgz#01d873db7731e0aacc410a9038447d1b7536fd07"
+  integrity sha512-mtgWEK16uYQFk3kijRlkSpAmDuy7rxYuv0pgyEBDmLT1PCPY8380CoaYnP8znUT6BXIGlJ8oTveK3M50U+B0vw==
+  dependencies:
+    prosemirror-model "^1.1.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.1.0"
+
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3:
+  version "1.14.2"
+  resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.14.2.tgz#23eb89f6101e9671b5e0c19d82ee0ad9de5608de"
+  integrity sha512-9yPVH6OLyaEraHjWHbSk2DB0R/1TsEE6AA1LI+vmCypXXA+zTzNrktUFzBhSJHehXDoEJcQfnl1Wdp5GPSh2+g==
+  dependencies:
+    prosemirror-model "^1.1.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.1.0"
+
 proto-list@~1.2.1:
   version "1.2.4"
   resolved "https://registry.npm.taobao.org/proto-list/download/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
@@ -8753,6 +9126,14 @@ querystringify@^2.1.1:
   resolved "https://registry.npm.taobao.org/querystringify/download/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e"
   integrity sha1-YOWl/WSn+L+k0qsu1v30yFutFU4=
 
+randexp@^0.5.3:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.5.3.tgz#f31c2de3148b30bdeb84b7c3f59b0ebb9fec3738"
+  integrity sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==
+  dependencies:
+    drange "^1.0.2"
+    ret "^0.2.0"
+
 randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5:
   version "2.1.0"
   resolved "https://registry.npm.taobao.org/randombytes/download/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@@ -9143,6 +9524,11 @@ restore-cursor@^2.0.0:
     onetime "^2.0.0"
     signal-exit "^3.0.2"
 
+ret@^0.2.0:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/ret/-/ret-0.2.2.tgz#b6861782a1f4762dce43402a71eb7a283f44573c"
+  integrity sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==
+
 ret@~0.1.10:
   version "0.1.15"
   resolved "https://registry.npm.taobao.org/ret/download/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
@@ -9194,6 +9580,13 @@ rimraf@^3.0.0:
   dependencies:
     glob "^7.1.3"
 
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  dependencies:
+    glob "^7.1.3"
+
 ripemd160@^2.0.0, ripemd160@^2.0.1:
   version "2.0.2"
   resolved "https://registry.npm.taobao.org/ripemd160/download/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c"
@@ -9202,6 +9595,11 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
     hash-base "^3.0.0"
     inherits "^2.0.1"
 
+rope-sequence@^1.3.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.2.tgz#a19e02d72991ca71feb6b5f8a91154e48e3c098b"
+  integrity sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg==
+
 rsvp@^4.8.4:
   version "4.8.5"
   resolved "https://registry.npm.taobao.org/rsvp/download/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"
@@ -9768,7 +10166,7 @@ static-extend@^0.1.1:
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
-"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0, statuses@~1.5.0:
   version "1.5.0"
   resolved "https://registry.npm.taobao.org/statuses/download/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
@@ -10058,7 +10456,7 @@ svg-tags@^1.0.0:
   resolved "https://registry.npm.taobao.org/svg-tags/download/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
   integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=
 
-svgo@^1.0.0:
+svgo@^1.0.0, svgo@^1.3.2:
   version "1.3.2"
   resolved "https://registry.npm.taobao.org/svgo/download/svgo-1.3.2.tgz?cache=0&sync_timestamp=1572433264480&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsvgo%2Fdownload%2Fsvgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
   integrity sha1-ttxRHAYzRsnkFbgeQ0ARRbltQWc=
@@ -10222,6 +10620,63 @@ timsort@^0.3.0:
   resolved "https://registry.npm.taobao.org/timsort/download/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
   integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
 
+tiptap-commands@^1.12.5:
+  version "1.12.5"
+  resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.12.5.tgz#d2bd1adcc79fe47938bd64b91b6dec4688a88086"
+  integrity sha512-wzQCH3CL1VWy6E47Hy+9kt882w7SND+FD9e9xAAsYhG/QI0cmuvAf/8doZZhUmYwkraYeF7/2bU04IXr36t44Q==
+  dependencies:
+    prosemirror-commands "1.1.2"
+    prosemirror-inputrules "1.1.2"
+    prosemirror-model "1.8.2"
+    prosemirror-schema-list "1.1.2"
+    prosemirror-state "1.3.2"
+    prosemirror-tables "1.0.0"
+    prosemirror-utils "0.9.6"
+    tiptap-utils "^1.8.3"
+
+tiptap-extensions@^1.28.6:
+  version "1.28.6"
+  resolved "https://registry.yarnpkg.com/tiptap-extensions/-/tiptap-extensions-1.28.6.tgz#ce3ca3ccd9c9658e749c5aa5e97255a70468eaff"
+  integrity sha512-hkYKJHxkqmeIRyuOaVvsrC/IcoXZmBw/Gx4JJHPiCAKvLQyjgYZpNjbVH9nIgdlxLvVjFDVBoPWGHy00qGp8qQ==
+  dependencies:
+    lowlight "1.13.0"
+    prosemirror-collab "1.2.2"
+    prosemirror-history "1.1.2"
+    prosemirror-model "1.8.2"
+    prosemirror-state "1.3.2"
+    prosemirror-tables "1.0.0"
+    prosemirror-transform "1.2.2"
+    prosemirror-utils "0.9.6"
+    prosemirror-view "1.13.4"
+    tiptap "^1.26.6"
+    tiptap-commands "^1.12.5"
+
+tiptap-utils@^1.8.3:
+  version "1.8.3"
+  resolved "https://registry.yarnpkg.com/tiptap-utils/-/tiptap-utils-1.8.3.tgz#fdfc8f7888f6e9ed0dae081f5f66b9f5429608a9"
+  integrity sha512-SgqDTCA5ux17KKTpEV2YC54ugBWU2jzpiFlCmVckPjYl5BhmOwuJ1Q5H/8v/XGcnHDqP31Ui4lk31Vts4NmtTA==
+  dependencies:
+    prosemirror-model "1.8.2"
+    prosemirror-state "1.3.2"
+    prosemirror-tables "1.0.0"
+    prosemirror-utils "0.9.6"
+
+tiptap@^1.26.6:
+  version "1.26.6"
+  resolved "https://registry.yarnpkg.com/tiptap/-/tiptap-1.26.6.tgz#b287fa7cb1c20690868aee8dc526de5390b054f0"
+  integrity sha512-U5qyYZi5IH7LhYwYrStRBp5MxF5MiGFLt9ogOAF/0N/LIg0XwVwe/AaSx0UH/s4dY7R8OvEa9u4qimO08Wp1LA==
+  dependencies:
+    prosemirror-commands "1.1.2"
+    prosemirror-dropcursor "1.3.2"
+    prosemirror-gapcursor "1.1.2"
+    prosemirror-inputrules "1.1.2"
+    prosemirror-keymap "1.1.3"
+    prosemirror-model "1.8.2"
+    prosemirror-state "1.3.2"
+    prosemirror-view "1.13.4"
+    tiptap-commands "^1.12.5"
+    tiptap-utils "^1.8.3"
+
 tmp@^0.0.33:
   version "0.0.33"
   resolved "https://registry.npm.taobao.org/tmp/download/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@@ -10371,6 +10826,11 @@ tslib@^1.9.0:
   resolved "https://registry.npm.taobao.org/tslib/download/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
   integrity sha1-w8GflZc/sKYpc/sJ2Q2WHuQ+XIo=
 
+tsscmp@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
+
 tty-browserify@0.0.0:
   version "0.0.0"
   resolved "https://registry.npm.taobao.org/tty-browserify/download/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@@ -10405,7 +10865,7 @@ type-fest@^0.8.1:
   resolved "https://registry.npm.taobao.org/type-fest/download/type-fest-0.8.1.tgz?cache=0&sync_timestamp=1569404138136&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ftype-fest%2Fdownload%2Ftype-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
   integrity sha1-CeJJ696FHTseSNJ8EFREZn8XuD0=
 
-type-is@~1.6.17, type-is@~1.6.18:
+type-is@^1.6.16, type-is@~1.6.17, type-is@~1.6.18:
   version "1.6.18"
   resolved "https://registry.npm.taobao.org/type-is/download/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
   integrity sha1-TlUs0F3wlGfcvE73Od6J8s83wTE=
@@ -10616,7 +11076,7 @@ validate-npm-package-license@^3.0.1:
     spdx-correct "^3.0.0"
     spdx-expression-parse "^3.0.0"
 
-vary@~1.1.2:
+vary@^1.1.2, vary@~1.1.2:
   version "1.1.2"
   resolved "https://registry.npm.taobao.org/vary/download/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
   integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
@@ -10732,6 +11192,11 @@ w3c-hr-time@^1.0.1:
   dependencies:
     browser-process-hrtime "^0.1.2"
 
+w3c-keyname@^2.2.0:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.2.tgz#7ea63170454bb19f1a3c6b628fc3dc8889276e91"
+  integrity sha512-8Vs/aVwcy0IJACaPm4tyzh1fzehZE70bGSjEl3dDms5UXtWnaBElrSHC8lDDeak0Gk5jxKOFstL64/65o7Ge2A==
+
 w3c-xmlserializer@^1.1.2:
   version "1.1.2"
   resolved "https://registry.npm.taobao.org/w3c-xmlserializer/download/w3c-xmlserializer-1.1.2.tgz#30485ca7d70a6fd052420a3d12fd90e6339ce794"
@@ -11383,6 +11848,11 @@ yargs@^7.0.0:
     y18n "^3.2.1"
     yargs-parser "^5.0.0"
 
+ylru@^1.2.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
+  integrity sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==
+
 yorkie@^2.0.0:
   version "2.0.0"
   resolved "https://registry.npm.taobao.org/yorkie/download/yorkie-2.0.0.tgz#92411912d435214e12c51c2ae1093e54b6bb83d9"