瀏覽代碼

修改答题问题,编辑题目问题

dusenyao 1 年之前
父節點
當前提交
cb72ef1c96

+ 23 - 0
src/api/exercise.js

@@ -201,3 +201,26 @@ export function GetAnswerRecordReport(data) {
 export function FillQuestionAnswerRemark(data) {
   return http.post(`/TeachingServer/ExerciseAnswerManager/FillQuestionAnswerRemark`, data);
 }
+
+/**
+ * 完成答题批注
+ */
+export function FinishAnswerRemark(data) {
+  return http.post(`/TeachingServer/ExerciseAnswerManager/FinishAnswerRemark`, data);
+}
+
+/**
+ * 得用户答题记录列表
+ */
+export function GetUserAnswerRecordList(data) {
+  return http.post(`/TeachingServer/ExerciseManager/GetUserAnswerRecordList`, data);
+}
+
+/**
+ * 得到分享配置
+ * @param {object} data
+ * @returns {object} {exercise_share_url_path 练习题分享链接的路径}
+ */
+export function GetShareConfig(data) {
+  return http.post(`${process.env.VUE_APP_FileServer}?MethodName=sys_config_manager-GetShareConfig`, data);
+}

+ 11 - 1
src/components/common/RichText.vue

@@ -101,6 +101,11 @@ export default {
         menubar: false, // 菜单栏
         branding: false, // 品牌
         statusbar: false, // 状态栏
+        setup(editor) {
+          editor.on('init', () => {
+            editor.getBody().style.fontSize = '14pt'; // 设置默认字体大小
+          });
+        },
         font_formats:
           'Andale Mono=andale mono,monospace;' +
           'Arial=arial,helvetica,sans-serif;' +
@@ -119,8 +124,13 @@ export default {
           'Trebuchet MS=trebuchet ms,geneva,sans-serif;' +
           'Verdana=verdana,geneva,sans-serif;' +
           'Webdings=webdings;' +
-          'Wingdings=wingdings,zapf dingbats',
+          'Wingdings=wingdings,zapf dingbats;' +
+          '楷体=楷体,微软雅黑;' +
+          '黑体=黑体,微软雅黑;' +
+          '宋体=宋体,微软雅黑;',
         // 字数限制
+        fontsize_formats: '8pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
+        fontsize_default: '16pt',
         ax_wordlimit_num: this.wordlimitNum,
         ax_wordlimit_callback(editor) {
           editor.execCommand('undo');

+ 17 - 1
src/router/modules/exercise.js

@@ -32,4 +32,20 @@ const AnswerPage = {
   ],
 };
 
-export default [ExerciseCreatePage, AnswerPage];
+/**
+ * 答题记录列表
+ */
+const AnswerRecordList = {
+  path: '/answer_record_list',
+  component: ANSWER,
+  redirect: 'AnswerRecordList',
+  children: [
+    {
+      path: '/answer_record_list',
+      name: 'AnswerRecordList',
+      component: () => import('@/views/home/recovery/AnswerRecordList.vue'),
+    },
+  ],
+};
+
+export default [ExerciseCreatePage, AnswerPage, AnswerRecordList];

+ 134 - 39
src/views/exercise_questions/answer/index.vue

@@ -10,7 +10,9 @@
           <SvgIcon icon-class="list" />
           <span>{{ curQuestionIndex + 1 }} / {{ questionList.length }}</span>
         </div>
-        <div class="round primary"><SvgIcon icon-class="hourglass" />{{ secondFormatConversion(time) }}</div>
+        <div v-if="!isTeacherAnnotations" class="round primary">
+          <SvgIcon icon-class="hourglass" />{{ secondFormatConversion(time) }}
+        </div>
       </div>
     </header>
 
@@ -39,27 +41,41 @@
 
     <footer class="footer">
       <el-popover v-model="isPopover" placement="top-start" trigger="click">
-        <div class="annotations-container">
+        <div v-if="isEnable(remark.is_remarked) && !isTeacher" class="remark-container">
+          <div class="remark-info">
+            <el-avatar :size="24" :src="remark.remark_person_image_url" />
+            <span class="remark-name">{{ remark.remark_person_name }}</span>
+            <span class="remark-time">{{ remark.remark_time }}</span>
+          </div>
+          <div class="remark">
+            {{ remark.remark }}
+          </div>
+          <div class="file">
+            <el-image v-for="{ file_url, file_id } in remark.file_list" :key="file_id" :src="file_url" fit="contain" />
+          </div>
+        </div>
+
+        <div v-else class="annotations-container">
           <div class="title">增加批注</div>
           <div class="score">
             <span>分数</span>
-            <el-input-number v-model="annotations.score" :min="0" :step="1" />
+            <el-input-number v-model="remark.score" :min="0" :step="1" />
           </div>
-          <el-input v-model="annotations.remark" type="textarea" rows="6" resize="none" class="remark" />
+          <el-input v-model="remark.remark" type="textarea" rows="6" resize="none" class="remark" />
           <div>图片/视频</div>
 
-          <el-upload
-            action="no"
-            accept="audio/*,video/*,image/*"
-            :show-file-list="true"
-            :http-request="upload"
-            :on-remove="removeFile"
-          >
+          <el-upload action="no" accept="video/*,image/*" :show-file-list="false" :http-request="upload">
             <div class="upload">
               <i class="el-icon-plus avatar-uploader-icon"></i>
               <span>Upload</span>
             </div>
           </el-upload>
+          <ul class="file-list">
+            <li v-for="({ file_name, file_id }, i) in remark.file_list" :key="file_id" @click="removeFile(i)">
+              <span>{{ file_name }}</span>
+              <SvgIcon icon-class="delete" />
+            </li>
+          </ul>
 
           <div class="popover-footer">
             <el-button @click="isPopover = false">取消</el-button>
@@ -67,7 +83,16 @@
           </div>
         </div>
 
-        <div v-show="isAnnotations" slot="reference" class="annotations"><i class="el-icon-plus"></i>批注</div>
+        <div
+          v-show="isAnnotations"
+          slot="reference"
+          :class="['annotations', { has: isEnable(remark.is_remarked) && !isTeacher }]"
+        >
+          <template v-if="isEnable(remark.is_remarked) && !isTeacher">
+            <span>有一条教师批注</span>
+          </template>
+          <template v-else><i class="el-icon-plus"></i><span>批注</span></template>
+        </div>
       </el-popover>
 
       <div>
@@ -133,10 +158,11 @@ export default {
   },
   mixins: [PreviewQuestionTypeMixin],
   data() {
-    const { id, share_record_id, answer_record_id, question_index } = this.$route.query;
+    const { id, share_record_id, answer_record_id, exercise_id, question_index } = this.$route.query;
+    let questionIndex = Number(question_index);
 
     return {
-      exercise_id: id, // 练习题id
+      exercise_id: id || exercise_id, // 练习题id
       share_record_id, // 分享记录id
       answer_record_id: answer_record_id ?? '', // 答题记录id
       secondFormatConversion,
@@ -150,7 +176,7 @@ export default {
       currentQuestion: {},
       // 当前问题索引
       curQuestionIndex: -1,
-      question_index: Number(question_index) || -1, // 跳转的问题索引
+      question_index: questionIndex >= 0 ? questionIndex : -1, // 跳转的问题索引
       loading: false,
       // 倒计时
       countDownTimer: null,
@@ -162,13 +188,14 @@ export default {
       curQuestionPage: '', // 当前问题页面
       remark: {
         is_remarked: 'false',
-      }, // 教师批注
-      isPopover: false,
-      annotations: {
         score: 0,
         remark: '',
-        file_id_list: [],
+        remark_person_image_url: '',
+        remark_person_name: '',
+        remark_time: '',
+        file_list: [],
       }, // 批注
+      isPopover: false,
       recordReport: {
         answer_record: {
           answer_duration: 0,
@@ -189,11 +216,20 @@ export default {
     isAnnotations() {
       return this.remark.is_remarked === 'true' || this.isTeacherAnnotations;
     },
+    // 是否考试模式
+    isExamMode() {
+      return this.answer_mode === 2;
+    },
   },
   watch: {
     curQuestionIndex() {
       this.getQuestionInfo_AnswerRecord();
     },
+    isSubmit(val) {
+      if (val) {
+        this.getAnswerRecordReport();
+      }
+    },
   },
   created() {
     this.init();
@@ -208,7 +244,7 @@ export default {
         this.getExerciseQuestionIndexList();
       }
 
-      if (this.share_record_id) {
+      if (this.share_record_id && !this.exercise_id) {
         this.loading = true;
         GetShareRecordInfo({ share_record_id: this.share_record_id }).then(
           ({ user_answer_record_info, share_record: { exercise_id, answer_mode, answer_time_limit_minute } }) => {
@@ -223,7 +259,7 @@ export default {
               this.answer_record_id = this.user_answer_record_info.answer_record_id;
               this.answer_mode = answer_mode;
               // 如果是考试模式,且已经存在答题记录,则直接显示答题报告
-              if (this.answer_mode === 2) this.isSubmit = true;
+              if (this.isExamMode) this.isSubmit = true;
             }
             if (!this.isTeacher) {
               this.getAnswerRecordReport();
@@ -316,14 +352,13 @@ export default {
         if (type === 'next') return this.nextQuestion();
       }
 
-      let answer = this.$refs.exercise[0].answer;
       return FillQuestionAnswer({
         answer_record_id: this.answer_record_id,
         question_id: this.questionList[this.curQuestionIndex].id,
-        answer: JSON.stringify(answer),
+        answer: JSON.stringify(this.$refs.exercise[0].answer),
       }).then(() => {
         this.questionList[this.curQuestionIndex].isFill = true;
-        if (subjectiveQuestionList.includes(this.currentQuestion.type)) {
+        if (subjectiveQuestionList.includes(this.currentQuestion.type) || this.isExamMode) {
           if (type === 'pre') return this.preQuestion();
           if (type === 'next') return this.nextQuestion();
         }
@@ -334,6 +369,7 @@ export default {
         );
       });
     },
+    // 得到答题记录题目信息
     getQuestionInfo_AnswerRecord() {
       GetQuestionInfo_AnswerRecord({
         answer_record_id: this.answer_record_id,
@@ -351,13 +387,16 @@ export default {
               : this.previewComponents[this.questionList[this.curQuestionIndex].type];
         }
 
-        // 答案
-        if (is_fill_answer === 'false') return;
-        this.$refs.exercise?.[0].showAnswer(
-          this.answer_mode === 1 && !this.isTeacherAnnotations,
-          this.user_answer_record_info.correct_answer_show_mode === 1 && !this.isTeacherAnnotations,
-          JSON.parse(content || '{}'),
-        );
+        // 如果已经填写过答案,直接显示答案
+        if (is_fill_answer === 'true') {
+          this.$nextTick().then(() => {
+            this.$refs.exercise?.[0].showAnswer(
+              this.answer_mode === 1 && !this.isTeacherAnnotations,
+              this.user_answer_record_info.correct_answer_show_mode === 1 && !this.isTeacherAnnotations,
+              content.length > 0 ? JSON.parse(content) : null,
+            );
+          });
+        }
       });
     },
     // 提交答题
@@ -399,23 +438,22 @@ export default {
     upload(file) {
       fileUpload('Mid', file).then(({ file_info_list }) => {
         if (file_info_list.length > 0) {
-          const { file_id } = file_info_list[0];
-          this.annotations.file_id_list.push({ [file.file.uid]: file_id });
+          const { file_id, file_url, file_name } = file_info_list[0];
+          this.remark.file_list.push({ file_id, file_url, file_name });
         }
       });
     },
-    removeFile(file) {
-      const index = this.annotations.file_id_list.findIndex((item) => Object.hasOwn(item, file.uid));
-      this.annotations.file_id_list.splice(index, 1);
+    removeFile(i) {
+      this.remark.file_list.splice(i, 1);
     },
     // 填写批注
     fillQuestionAnswerRemark() {
       FillQuestionAnswerRemark({
         answer_record_id: this.answer_record_id,
         question_id: this.questionList[this.curQuestionIndex].id,
-        file_id_list: this.annotations.file_id_list.map((item) => item[Object.keys(item)[0]]),
-        score: this.annotations.score,
-        remark: this.annotations.remark,
+        file_id_list: this.remark.file_list.map(({ file_id }) => file_id),
+        score: this.remark.score,
+        remark: this.remark.remark,
       }).then(() => {
         this.$message.success('批注成功');
         this.isPopover = false;
@@ -473,6 +511,11 @@ export default {
       cursor: pointer;
       background-color: $fill-color;
       border-radius: 20px;
+
+      &.has {
+        color: $danger-color;
+        background-color: #ffece8;
+      }
     }
 
     .el-button {
@@ -490,6 +533,39 @@ export default {
   padding: 8px 8px 14px;
   font-size: 14px;
 
+  .remark-container {
+    display: flex;
+    flex-direction: column;
+    row-gap: 8px;
+
+    .remark-info {
+      display: flex;
+      column-gap: 8px;
+      align-items: center;
+
+      .remark-name {
+        flex: 1;
+      }
+
+      .remark-time {
+        font-size: 12px;
+        color: #999;
+      }
+    }
+
+    .file {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+
+      .el-image {
+        width: 80px;
+        height: 80px;
+        background-color: #d9d9d9;
+      }
+    }
+  }
+
   .annotations-container {
     display: flex;
     flex-direction: column;
@@ -525,6 +601,25 @@ export default {
       border: 1px solid $border-color;
     }
 
+    .file-list {
+      display: flex;
+      flex-direction: column;
+      row-gap: 4px;
+
+      > li {
+        display: flex;
+        column-gap: 4px;
+
+        :first-child {
+          flex: 1;
+        }
+
+        :last-child {
+          cursor: pointer;
+        }
+      }
+    }
+
     .popover-footer {
       display: flex;
       justify-content: flex-end;

+ 5 - 4
src/views/exercise_questions/create/components/common/QuestionMixin.js

@@ -68,9 +68,11 @@ const mixin = {
   },
   watch: {
     'data.property.score'(val) {
+      if (val === undefined) return;
       this.data.answer.score = val;
     },
     'data.property.score_type'(val) {
+      if (val === undefined) return;
       this.data.answer.score_type = val;
     },
     data: {
@@ -94,11 +96,10 @@ const mixin = {
     },
     /**
      * 设置题目内容
-     * @param {object} param
-     * @param {string} param.content 题目内容
+     * @param {object} content 题目内容
      */
-    setQuestion({ content }) {
-      this.data = JSON.parse(content);
+    setQuestion(content) {
+      this.data = content;
     },
     /**
      * 删除选项

+ 1 - 1
src/views/exercise_questions/create/components/common/SelectQuestionType.vue

@@ -16,7 +16,7 @@
 </template>
 
 <script>
-import { questionTypeOption } from '@/views/exercise_questions/data/common';
+import { questionTypeOption } from '@/views/exercise_questions/data/questionType';
 
 import IntelligentRecognition from '../common/IntelligentRecognition.vue';
 

+ 1 - 1
src/views/exercise_questions/create/components/create.vue

@@ -39,7 +39,7 @@
 <script>
 import { SaveQuestion } from '@/api/exercise';
 import { timeFormatConversion } from '@/utils/transform';
-import { exerciseTypeList } from '@/views/exercise_questions/data/common';
+import { exerciseTypeList } from '@/views/exercise_questions/data/questionType';
 
 import SelectQuestionType from './common/SelectQuestionType.vue';
 import SelectQuestion from './exercises/SelectQuestion.vue';

+ 14 - 6
src/views/exercise_questions/create/index.vue

@@ -20,7 +20,7 @@
       </div>
       <div class="list-operate">
         <!-- <el-button type="primary">批量导入</el-button> -->
-        <el-button type="primary" @click="addQuestionToExercise('select', 'single')">新建</el-button>
+        <el-button type="primary" @click="createDefaultQuestion">新建</el-button>
       </div>
     </div>
 
@@ -61,7 +61,7 @@
 </template>
 
 <script>
-import { exerciseNames } from '../data/common';
+import { exerciseNames, questionDataList } from '../data/questionType';
 import {
   AddQuestionToExercise,
   DeleteQuestion,
@@ -144,14 +144,18 @@ export default {
           this.$message.error('添加失败');
         });
     },
+    // 创建默认题目
+    createDefaultQuestion() {
+      this.addQuestionToExercise('select', 'single', questionDataList['select']);
+    },
     // 创建新题
     createNewQuestion() {
       if (this.index_list.length === 0) {
-        this.addQuestionToExercise('select', 'single');
+        this.createDefaultQuestion();
         return;
       }
       let { type, additional_type } = this.index_list[this.curIndex];
-      this.addQuestionToExercise(type, additional_type);
+      this.addQuestionToExercise(type, additional_type, questionDataList[type]);
     },
     /**
      * 获取练习题目索引列表
@@ -194,12 +198,16 @@ export default {
     getQuestionInfo() {
       if (this.index_list.length === 0) return;
       GetQuestionInfo({ question_id: this.index_list[this.curIndex].id })
-        .then(({ question }) => {
+        .then(({ question, file_list }) => {
           this.$refs.createMain.resetSaveDate();
           if (!question.content) return;
+          // 将题目文件id列表添加到题目内容中
+          let file_id_list = file_list.map(({ file_id }) => file_id);
+          let content = JSON.parse(question.content);
+          content.file_id_list = file_id_list;
 
           this.$nextTick(() => {
-            this.$refs.createMain.$refs.exercise?.[0].setQuestion(question);
+            this.$refs.createMain.$refs.exercise?.[0].setQuestion(content);
             this.refreshPreviewData();
           });
         })

+ 3 - 0
src/views/exercise_questions/data/PreviewQuestionTypeMixin.js

@@ -1,3 +1,5 @@
+import { isEnable } from '@/views/exercise_questions/data/common';
+
 import SelectPreview from '@/views/exercise_questions/preview/SelectPreview.vue';
 import JudgePreview from '@/views/exercise_questions/preview/JudgePreview.vue';
 import MatchingPreview from '@/views/exercise_questions/preview/MatchingPreview.vue';
@@ -46,6 +48,7 @@ const PreviewQuestionTypeMixin = {
   },
   data() {
     return {
+      isEnable,
       previewComponents: {
         select: SelectPreview,
         judge: JudgePreview,

+ 1 - 1
src/views/exercise_questions/data/answerQuestion.js

@@ -1,6 +1,6 @@
 import { stemTypeList, questionNumberTypeList, scoreTypeList, switchOption } from './common';
 
-// 朗读题数据模板
+// 口语表达数据模板
 export const answerQuestionData = {
   type: 'answer_question', // 题型
   stem: '', // 题干

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

@@ -1,99 +1,5 @@
 import { digitToChinese } from '@/utils/transform';
 
-// 题型选项
-export const questionTypeOption = [
-  {
-    value: 'base',
-    label: '基础题型',
-    children: [
-      { label: '选择题', value: 'select' },
-      { label: '判断题', value: 'judge' },
-      { label: '填空题', value: 'fill' },
-      { label: '排序题', value: 'sort' },
-      { label: '连线题', value: 'matching' },
-      { label: '选择声调', value: 'choose_tone' },
-      { label: '问答题', value: 'essay_question' },
-    ],
-  },
-  {
-    value: 'spoken',
-    label: '口语题',
-    children: [
-      { label: '朗读题', value: 'read_aloud' },
-      { label: '听说训练', value: 'repeat' },
-      { label: '看图说话', value: 'talk_picture' },
-      { label: '对话题', value: 'dialogue' },
-      { label: '口语表达', value: 'answer_question' },
-      { label: '替换练习', value: 'replace_answer' },
-    ],
-  },
-  {
-    value: 'hear',
-    label: '听力题',
-    children: [
-      { label: '听后选择', value: 'listen_select' },
-      { label: '听后填空', value: 'listen_fill' },
-      { label: '听后判断', value: 'listen_judge' },
-    ],
-  },
-  {
-    value: 'character',
-    label: '汉字题',
-    children: [
-      { label: '汉字练习', value: 'chinese' },
-      { label: '字词卡片', value: 'word_card' },
-    ],
-  },
-  {
-    value: 'writing',
-    label: '写作题',
-    children: [
-      { label: '基础写作', value: 'write' },
-      { label: '看图写作', value: 'write_picture' },
-    ],
-  },
-  {
-    value: 'read',
-    label: '阅读题',
-  },
-];
-
-// 练习名称
-export const exerciseNames = questionTypeOption
-  .map(({ label, value, children }) => {
-    if (children) return children;
-    return { label, value };
-  })
-  .flat()
-  .reduce((obj, { label, value }) => {
-    obj[value] = label;
-    return obj;
-  }, {});
-
-/**
- * 获取题型类型列表
- * @param {Array} arr 题型类型选项
- * @returns Object
- */
-export function getExerciseTypeList(arr) {
-  return arr
-    .map(({ value, children }) => {
-      if (children) {
-        return children.map(({ value: children_value }) => {
-          return { [children_value]: [value, children_value] };
-        });
-      }
-      return { [value]: [value] };
-    })
-    .flat()
-    .reduce((obj, item) => {
-      return { ...obj, ...item };
-    }, {});
-}
-
-// 题型类型列表
-export const exerciseTypeList = getExerciseTypeList(questionTypeOption);
-
 // 选项类型
 export const optionTypeList = [
   { value: 'letter', label: '字母' },

+ 137 - 0
src/views/exercise_questions/data/questionType.js

@@ -0,0 +1,137 @@
+import { getSelectData } from './select';
+import { getJudgeData } from './judge';
+import { fillData } from './fill';
+import { getSortDataTemplate } from './sort';
+import { getMatchingDataTemplate } from './matching';
+import { ChooseToneData } from './chooseTone';
+import { essayQuestionData } from './essayQuestion';
+import { readAloudData } from './readAloud';
+import { repeatData } from './repeat';
+import { talkPictrueData } from './talkPicture';
+import { dialogueData } from './dialogue';
+import { answerQuestionData } from './answerQuestion';
+import { replaceAnswerData } from './replaceAnswer';
+import { getListenSelectData } from './listenSelect';
+import { listenFillData } from './listenFill';
+import { getListenJudgeData } from './listenJudge';
+import { chineseData } from './chinese';
+import { wordCardData } from './wordCard';
+import { writeData } from './write';
+import { writePictrueData } from './writePicture';
+import { readData } from './read';
+
+// 题型源数据
+export const questionTypeDataOption = [
+  {
+    value: 'base',
+    label: '基础题型',
+    children: [
+      // data 是为新建题目提供所需的数据
+      { label: '选择题', value: 'select', data: getSelectData() },
+      { label: '判断题', value: 'judge', data: getJudgeData() },
+      { label: '填空题', value: 'fill', data: fillData },
+      { label: '排序题', value: 'sort', data: getSortDataTemplate() },
+      { label: '连线题', value: 'matching', data: getMatchingDataTemplate() },
+      { label: '选择声调', value: 'choose_tone', data: ChooseToneData },
+      { label: '问答题', value: 'essay_question', data: essayQuestionData },
+    ],
+  },
+  {
+    value: 'spoken',
+    label: '口语题',
+    children: [
+      { label: '朗读题', value: 'read_aloud', data: readAloudData },
+      { label: '听说训练', value: 'repeat', data: repeatData },
+      { label: '看图说话', value: 'talk_picture', data: talkPictrueData },
+      { label: '对话题', value: 'dialogue', data: dialogueData },
+      { label: '口语表达', value: 'answer_question', data: answerQuestionData },
+      { label: '替换练习', value: 'replace_answer', data: replaceAnswerData },
+    ],
+  },
+  {
+    value: 'hear',
+    label: '听力题',
+    children: [
+      { label: '听后选择', value: 'listen_select', data: getListenSelectData() },
+      { label: '听后填空', value: 'listen_fill', data: listenFillData },
+      { label: '听后判断', value: 'listen_judge', data: getListenJudgeData() },
+    ],
+  },
+  {
+    value: 'character',
+    label: '汉字题',
+    children: [
+      { label: '汉字练习', value: 'chinese', data: chineseData },
+      { label: '字词卡片', value: 'word_card', data: wordCardData },
+    ],
+  },
+  {
+    value: 'writing',
+    label: '写作题',
+    children: [
+      { label: '基础写作', value: 'write', data: writeData },
+      { label: '看图写作', value: 'write_picture', data: writePictrueData },
+    ],
+  },
+  {
+    value: 'read',
+    label: '阅读题',
+    data: readData,
+  },
+];
+
+// 题型选项
+export const questionTypeOption = questionTypeDataOption.map(({ data, ...item }) => {
+  if (item.children) {
+    item.children = item.children.map(({ data, ...child }) => child);
+  }
+  return item;
+});
+
+// 题型数据列表
+export const questionDataList = questionTypeDataOption
+  .map(({ data, value, children }) => {
+    if (children) return children.map(({ data, value }) => ({ value, data }));
+    return { value, data };
+  })
+  .flat()
+  .reduce((obj, { value, data }) => {
+    obj[value] = data;
+    return obj;
+  }, {});
+
+// 练习名称
+export const exerciseNames = questionTypeOption
+  .map(({ label, value, children }) => {
+    if (children) return children;
+    return { label, value };
+  })
+  .flat()
+  .reduce((obj, { label, value }) => {
+    obj[value] = label;
+    return obj;
+  }, {});
+
+/**
+ * 获取题型类型列表
+ * @param {Array} arr 题型类型选项
+ * @returns Object
+ */
+export function getExerciseTypeList(arr) {
+  return arr
+    .map(({ value, children }) => {
+      if (children) {
+        return children.map(({ value: children_value }) => {
+          return { [children_value]: [value, children_value] };
+        });
+      }
+      return { [value]: [value] };
+    })
+    .flat()
+    .reduce((obj, item) => {
+      return { ...obj, ...item };
+    }, {});
+}
+
+// 题型类型列表
+export const exerciseTypeList = getExerciseTypeList(questionTypeOption);

+ 2 - 1
src/views/exercise_questions/data/read.js

@@ -1,4 +1,5 @@
-import { stemTypeList, scoreTypeList, questionNumberTypeList, getExerciseTypeList, switchOption } from './common';
+import { stemTypeList, scoreTypeList, questionNumberTypeList, switchOption } from './common';
+import { getExerciseTypeList } from './questionType';
 
 // 题型类型选项
 export const questionTypeOption = [

+ 1 - 1
src/views/exercise_questions/data/replaceAnswer.js

@@ -10,7 +10,7 @@ export function getOption(content = '') {
   ];
 }
 
-// 听后训练数据模板
+// 替换练习数据模板
 export const replaceAnswerData = {
   type: 'replace_answer', // 题型
   stem: '', // 题干

+ 3 - 3
src/views/exercise_questions/preview/ReplaceAnswerPreview.vue

@@ -1,6 +1,6 @@
 <!-- eslint-disable vue/no-v-html -->
 <template>
-  <div class="replace-preview" v-if="show_preview">
+  <div v-if="show_preview" class="replace-preview">
     <div class="stem">
       <span class="question-number">{{ data.property.question_number }}.</span>
       <span v-html="sanitizeHTML(data.stem)"></span>
@@ -16,9 +16,9 @@
           <span class="select-item select-active">{{ active_content[i] }}</span>
           <ul :ref="'ui' + i" class="replace-ul" @scroll="handleScroll($event, i)">
             <li
-              :class="[computedAnswerClass(i, items)]"
               v-for="(items, indexs) in item"
               :key="indexs"
+              :class="[computedAnswerClass(i, items)]"
               @click="handleClickItem(i, indexs)"
             >
               {{ items.content }}
@@ -104,7 +104,7 @@ export default {
       this.$forceUpdate();
     },
     handleClickItem(i, indexs) {
-      this.$refs['ui' + i][0].scrollTop = indexs * 48;
+      this.$refs[`ui${i}`][0].scrollTop = indexs * 48;
       this.active_content[i] = this.option_list[i][indexs].content;
       this.answer.answer_list[0].mark_list[i] = this.option_list[i][indexs].mark;
       this.$forceUpdate();

+ 15 - 1
src/views/home/recovery/AnswerData.vue

@@ -62,7 +62,9 @@
       </el-table-column>
 
       <el-table-column prop="operation" label="操作" fixed="right" width="200">
-        <span class="link">查看</span>
+        <template slot-scope="{ row }">
+          <span class="link" @click="viewUserAnswerRecordLis(row.exercise_share_record_id)">查看</span>
+        </template>
       </el-table-column>
     </el-table>
 
@@ -114,6 +116,18 @@ export default {
     getPageList() {
       this.$refs.pagination.getList();
     },
+    /**
+     * 查看用户答题记录列表
+     * @param {string} exercise_share_record_id 练习分享记录id
+     */
+    viewUserAnswerRecordLis(exercise_share_record_id) {
+      this.$router.push({
+        path: '/answer_record_list',
+        query: {
+          exercise_share_record_id,
+        },
+      });
+    },
     pageQueryExerciseUserAnswerRecordList(data) {
       PageQueryExerciseUserAnswerRecordList({ ...data, ...this.searchData })
         .then(({ total_count, share_record_info, sum_info, answer_record_list }) => {

+ 327 - 0
src/views/home/recovery/AnswerRecordList.vue

@@ -0,0 +1,327 @@
+<template>
+  <div class="answer-record">
+    <div class="answer-record-list">
+      <ul>
+        <li
+          v-for="{
+            id,
+            user_real_name,
+            user_image_url,
+            is_finish,
+            exercise_share_record_id: recordId,
+          } in answer_record_list"
+          :key="id"
+          :class="['record-item', { active: curShareRecordId === recordId }]"
+          @click="selectShareRecord(recordId)"
+        >
+          <el-avatar :src="user_image_url" :size="24" />
+          <span class="user-name nowrap-ellipsis">{{ user_real_name }}</span>
+          <span v-if="is_finish === 'true'" class="tip">已提交</span>
+        </li>
+      </ul>
+    </div>
+    <div class="answer-report-container">
+      <div class="answer-report">
+        <div class="header">
+          <div class="back round" @click="goBack">
+            <i class="el-icon-arrow-left"></i>
+            <span>返回</span>
+          </div>
+        </div>
+        <div class="title">测试报告</div>
+        <div class="answer-info">
+          <div>
+            <span>完成时间</span>
+            <span>{{ answer_record.finish_time }}</span>
+          </div>
+          <div>
+            <span>耗时</span>
+            <span>{{ secondFormatConversion(answer_record.answer_duration, 'chinese') }}</span>
+          </div>
+          <div>
+            <span>正确</span>
+            <span>{{ answer_record.right_count }}</span>
+          </div>
+          <div>
+            <span>错误</span>
+            <span>{{ answer_record.error_count }}</span>
+          </div>
+          <div>
+            <span>正确率</span>
+            <span>{{ answer_record.right_percent }}%</span>
+          </div>
+        </div>
+
+        <div class="answer-list">
+          <span
+            v-for="({ question_id, is_objective, answer_status, is_remarked }, i) in question_list"
+            :key="question_id"
+            :class="[
+              'answer-list-item',
+              { subjectivity: is_objective === 'false', error: answer_status === 2 },
+              { remarked: is_remarked === 'true' },
+            ]"
+            @click="selectQuestion(i)"
+          >
+            {{ i + 1 }}
+          </span>
+        </div>
+
+        <div class="footer">
+          <el-button type="primary" size="large" @click="finishAnswerRemark">完成</el-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { secondFormatConversion } from '@/utils/transform';
+import { GetUserAnswerRecordList, GetAnswerRecordReport, FinishAnswerRemark, GetShareConfig } from '@/api/exercise';
+
+export default {
+  name: 'AnswerRecordList',
+  data() {
+    const { query } = this.$route;
+
+    const exercise_share_record_id = query.exercise_share_record_id;
+
+    return {
+      secondFormatConversion,
+      exercise_share_record_id,
+      curShareRecordId: exercise_share_record_id, // 当前分享记录id
+      exercise_share_url_path: '', // 分享链接
+      answer_record: {},
+      question_list: [],
+      answer_record_list: [],
+    };
+  },
+  computed: {
+    // 当前答题记录id
+    curAnswerRecordId() {
+      return this.answer_record_list.find((item) => item.exercise_share_record_id === this.curShareRecordId)?.id;
+    },
+  },
+  watch: {
+    curAnswerRecordId(val) {
+      if (!val) return;
+      this.getAnswerRecordReport(val);
+    },
+  },
+  created() {
+    this.init();
+  },
+  methods: {
+    init() {
+      this.getShareConfig();
+      this.getUserAnswerRecordList();
+    },
+    getShareConfig() {
+      GetShareConfig().then(({ exercise_share_url_path }) => {
+        this.exercise_share_url_path = exercise_share_url_path;
+      });
+    },
+    getUserAnswerRecordList() {
+      GetUserAnswerRecordList({ exercise_share_record_id: this.curShareRecordId }).then(({ answer_record_list }) => {
+        this.answer_record_list = answer_record_list;
+      });
+    },
+    /**
+     * 选择答题记录
+     * @param {string} recordId 答题记录id
+     */
+    selectShareRecord(recordId) {
+      if (this.curShareRecordId === recordId) return;
+      this.curShareRecordId = recordId;
+    },
+    /**
+     * 获取答题记录报告
+     * @param {string} answer_record_id 答题记录id
+     */
+    getAnswerRecordReport(answer_record_id) {
+      GetAnswerRecordReport({ answer_record_id }).then(({ answer_record, question_list }) => {
+        this.answer_record = answer_record;
+        this.question_list = question_list;
+      });
+    },
+    /**
+     * 选择题目
+     * @param {number} i 题目索引
+     */
+    selectQuestion(i) {
+      window.open(
+        `${this.exercise_share_url_path}?share_record_id=${this.curShareRecordId}&answer_record_id=${this.curAnswerRecordId}&exercise_id=${this.answer_record.exercise_id}&question_index=${i}`,
+        '_blank',
+      );
+    },
+    goBack() {},
+    /**
+     * 完成批注
+     */
+    finishAnswerRemark() {
+      FinishAnswerRemark({ answer_record_id: this.curAnswerRecordId }).then(() => {
+        this.$message.success('批注成功');
+      });
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.answer-record {
+  display: flex;
+  height: 100%;
+
+  &-list {
+    width: 210px;
+    overflow: auto;
+    background-color: #fff;
+
+    > ul {
+      display: flex;
+      flex-direction: column;
+      row-gap: 2px;
+
+      .record-item {
+        display: flex;
+        column-gap: 8px;
+        align-items: center;
+        justify-content: center;
+        height: 40px;
+        padding: 8px;
+        font-size: 14px;
+        font-weight: bold;
+        cursor: pointer;
+
+        .user-name {
+          flex: 1;
+        }
+
+        .tip {
+          color: #00c264;
+        }
+
+        &.active {
+          background-color: #e7eeff;
+        }
+      }
+    }
+  }
+
+  .answer-report-container {
+    flex: 1;
+    padding: 16px;
+
+    .answer-report {
+      display: flex;
+      flex-direction: column;
+      row-gap: 24px;
+      align-items: center;
+      height: 100%;
+      padding: 16px;
+      background-color: #fff;
+      border-radius: 16px;
+
+      .header {
+        display: flex;
+        align-items: center;
+        width: 100%;
+        height: 38px;
+        font-size: 14px;
+
+        .back {
+          cursor: pointer;
+        }
+      }
+
+      .title {
+        font-size: 32px;
+        font-weight: bold;
+        color: $light-main-color;
+      }
+
+      .answer-info {
+        display: flex;
+        column-gap: 120px;
+        justify-content: center;
+        width: 1020px;
+        padding: 24px 40px;
+        background-color: #f7f7f7;
+        border-radius: 8px;
+
+        > div {
+          display: flex;
+          flex-direction: column;
+          row-gap: 8px;
+
+          :first-child {
+            color: #949494;
+          }
+
+          :last-child {
+            font-size: 24px;
+            font-weight: bold;
+            color: #000;
+          }
+        }
+      }
+
+      .answer-list {
+        display: flex;
+        flex: 1;
+        flex-wrap: wrap;
+        gap: 24px;
+        width: 770px;
+
+        &-item {
+          position: relative;
+          width: 56px;
+          height: 40px;
+          padding: 8px;
+          line-height: 24px;
+          text-align: center;
+          cursor: pointer;
+          background-color: #f0f0f0;
+          border-radius: 20px;
+
+          &.error {
+            color: #fff;
+            background-color: $error-color;
+          }
+
+          &.subjectivity {
+            background-color: #fef2a4;
+          }
+
+          &.remarked {
+            &::before {
+              position: absolute;
+              top: 0;
+              right: 0;
+              display: inline-block;
+              width: 6px;
+              height: 6px;
+              content: '';
+              background-color: #f2555a;
+              border-radius: 50%;
+            }
+          }
+        }
+      }
+
+      .footer {
+        .el-button {
+          width: 256px;
+          border-radius: 40px;
+        }
+      }
+    }
+  }
+}
+</style>
+
+<style>
+.answer .answer-container {
+  padding: 0;
+}
+</style>

+ 1 - 1
src/views/home/recovery/index.vue

@@ -6,7 +6,7 @@
       <el-table-column prop="index" label="序号" width="70">
         <template slot-scope="{ $index }">{{ $index + 1 }}</template>
       </el-table-column>
-      <el-table-column prop="url" label="链接" width="700" />
+      <el-table-column prop="url" label="链接" width="800" />
       <el-table-column prop="memo" label="备注" width="180" />
       <el-table-column prop="finish_person_count" label="完成练习" width="180" />
       <el-table-column prop="begin_date" label="开始日期" width="120" />

+ 3 - 1
src/views/share/ShareExercise.vue

@@ -10,12 +10,13 @@ export default {
   name: 'ShareExercise',
 
   data() {
-    let { share_record_id, answer_record_id, question_index } = this.$route.query;
+    let { share_record_id, answer_record_id, exercise_id, question_index } = this.$route.query;
 
     return {
       share_record_id,
       answer_record_id: answer_record_id || '',
       question_index: question_index || -1,
+      exercise_id: exercise_id || '',
     };
   },
   created() {
@@ -45,6 +46,7 @@ export default {
                 share_record_id: this.share_record_id,
                 answer_record_id: this.answer_record_id,
                 question_index: this.question_index,
+                exercise_id: this.exercise_id,
               },
             });
           }