Browse Source

新增试卷详情

zhangjie 3 years ago
parent
commit
051bee7afe

+ 109 - 4
src/api/onlinePractice.ts

@@ -1,8 +1,12 @@
 import { httpApp } from "@/plugins/axiosApp";
 import {
+  ExamQuestion,
+  OnlineExam,
   OnlinePracticeRecord,
   OnlinePracticeRecordResult,
+  PaperStruct,
   PracticeExam,
+  QuestionUnitItem,
 } from "@/types/student-client";
 
 type ExamItem = {
@@ -50,14 +54,115 @@ export async function onlinePracticeRecordListApi(
 
 /** 获取在线练习考试成绩报告 */
 export async function onlinePracticeRecordDetailApi(
-  examRecordDataId: number | string
+  examRecordDataId: number | string,
+  fromCache?: boolean
 ) {
+  const params: {
+    examRecordDataId: string | number;
+    fromCache?: string;
+  } = {
+    examRecordDataId,
+  };
+  if (fromCache) params.fromCache = "1";
   return httpApp.get<any, { data: OnlinePracticeRecordResult }>(
     `/api/branch_ecs_oe_admin/practice/getPracticeDetailInfo`,
     {
-      params: {
-        examRecordDataId,
-      },
+      params,
+    }
+  );
+}
+
+// 试卷预览 ---------->
+/** 获取试卷练习类型 */
+export async function onlinePracticeTypeApi(examId: number | string) {
+  return httpApp.get<any, { data: { PRACTICE_TYPE: string } }>(
+    `/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/${examId}/PRACTICE_TYPE`
+  );
+}
+/** 获取考试数据 */
+export async function onlineExamDataApi(examId: number | string) {
+  return httpApp.get<any, { data: OnlineExam }>(
+    `/api/ecs_exam_work/exam/${examId}`
+  );
+}
+/** 获取考试记录试卷结构数据 */
+export async function examRecordPaperStructApi(
+  examRecordDataId: number | string,
+  fromCache?: boolean
+) {
+  const params: {
+    examRecordDataId: string | number;
+    fromCache?: string;
+  } = {
+    examRecordDataId,
+  };
+  if (fromCache) params.fromCache = "1";
+
+  return httpApp.get<any, { data: PaperStruct }>(
+    `/api/branch_ecs_oe_admin/examRecordPaperStruct/getExamRecordPaperStruct`,
+    {
+      params,
+    }
+  );
+}
+/** 获取考试记录试题数据 */
+export async function examRecordQuestionsApi(
+  examRecordDataId: number | string,
+  fromCache?: boolean
+) {
+  const params: {
+    examRecordDataId: string | number;
+    fromCache?: string;
+  } = {
+    examRecordDataId,
+  };
+  if (fromCache) params.fromCache = "1";
+
+  return httpApp.get<any, { data: { examQuestionEntities: ExamQuestion[] } }>(
+    `/api/branch_ecs_oe_admin/examRecordQuestions/getExamRecordQuestions`,
+    {
+      params,
     }
   );
 }
+/** 获取考试记录数据 */
+export async function examRecordDataApi(
+  examRecordDataId: number | string,
+  fromCache?: boolean
+) {
+  const params: {
+    examRecordDataId: string | number;
+    fromCache?: string;
+  } = {
+    examRecordDataId,
+  };
+  if (fromCache) params.fromCache = "1";
+
+  return httpApp.get<any, { data: { examRecord: { paperType: string } } }>(
+    `/api/branch_ecs_oe_admin/exam/record/data/findExamRecordDataEntity`,
+    {
+      params,
+    }
+  );
+}
+
+interface QuestionDataParam {
+  questionId: string | number;
+  examId: string | number;
+  courseCode: string;
+  groupCode: string;
+}
+export interface QuestionData {
+  id: string;
+  masterVersion: {
+    body: string;
+    questionUnitList: QuestionUnitItem[];
+  };
+}
+/** 获取试题内容 */
+export async function examQuestionDataApi(datas: QuestionDataParam) {
+  return httpApp.post<any, { data: QuestionData }>(
+    `/api/branch_ecs_ques/default_question/question`,
+    datas
+  );
+}

+ 1 - 1
src/features/OfflineExam/OfflineExamList.vue

@@ -144,7 +144,7 @@ onMounted(() => {
         </tr>
       </thead>
       <tbody>
-        <tr v-for="course in courseList" :key="course.courseId">
+        <tr v-for="(course, index) in courseList" :key="index">
           <td>{{ course.courseName }}</td>
           <td>{{ course.specialtyName }}</td>
           <td>

+ 1 - 1
src/features/OfflineExam/fileFormatCheck.ts

@@ -33,7 +33,7 @@ export function fileFormatCheck(file: File, validFileType: string) {
     const filereader = new FileReader();
     filereader.onloadend = (evt) => {
       if (evt.target?.readyState === FileReader.DONE) {
-        const uint = new Uint8Array(evt.target.result!);
+        const uint = new Uint8Array(evt.target.result as ArrayBuffer);
         const bytes: string[] = [];
         uint.forEach((byte) => bytes.push(byte.toString(16)));
         const hex = bytes.join("").toUpperCase();

+ 364 - 0
src/features/OnlinePractice/ExamPaperPreview.vue

@@ -0,0 +1,364 @@
+<script setup lang="ts">
+import {
+  examQuestionDataApi,
+  examRecordDataApi,
+  examRecordPaperStructApi,
+  examRecordQuestionsApi,
+  onlinePracticeTypeApi,
+} from "@/api/onlinePractice";
+import {
+  ExamQuestion,
+  PaperStruct,
+  QuestionWrapperItem,
+} from "@/types/student-client";
+import { toChineseNumber } from "@/utils/utils";
+import { onMounted } from "vue";
+import { Close, Checkmark } from "@vicons/ionicons5";
+
+const props = defineProps<{
+  examId: string | number;
+  examRecordDataId: string | number;
+  fromCache: boolean;
+  courseCode: string;
+}>();
+const emit = defineEmits<{
+  (e: "ready"): void;
+}>();
+
+let questionGroupList = $ref<PaperStruct["defaultPaper"]["questionGroupList"]>(
+  []
+);
+let practiceType = $ref("");
+
+async function initData() {
+  const ptRes = await onlinePracticeTypeApi(props.examId);
+  practiceType = ptRes.data.PRACTICE_TYPE;
+
+  const { paperStruct, examQuestionList, examRecordData } =
+    await getPaperData();
+  if (!paperStruct || !examQuestionList) {
+    $message.error("获取试卷信息失败", {
+      duration: 15,
+      closable: true,
+    });
+    return;
+  }
+
+  const examQuestionMap = await getQuestionWrapperMapData(
+    examQuestionList,
+    examRecordData.examRecord.paperType
+  );
+
+  questionGroupList = paperStruct.defaultPaper.questionGroupList.map(
+    (questionGroup) => {
+      questionGroup.questionWrapperList = questionGroup.questionWrapperList.map(
+        (question) => {
+          return examQuestionMap[question.questionId];
+        }
+      );
+      return questionGroup;
+    }
+  );
+}
+
+async function getPaperData() {
+  type FetchDataFuncType = [
+    Promise<{ data: PaperStruct }>,
+    Promise<{
+      data: { examQuestionEntities: ExamQuestion[] };
+    }>,
+    Promise<{
+      data: { examRecord: { paperType: string } };
+    }>
+  ];
+  const fetchDataFunc: FetchDataFuncType = [
+    examRecordPaperStructApi(props.examRecordDataId, props.fromCache),
+    examRecordQuestionsApi(props.examRecordDataId, props.fromCache),
+    examRecordDataApi(props.examRecordDataId, props.fromCache),
+  ];
+
+  const [paperStructRes, examQuestionListRes, examRecordDataRes] =
+    await Promise.all(fetchDataFunc);
+
+  const [paperStruct, examQuestionList, examRecordData] = [
+    paperStructRes.data,
+    examQuestionListRes.data.examQuestionEntities,
+    examRecordDataRes.data,
+  ];
+  return {
+    paperStruct,
+    examQuestionList,
+    examRecordData,
+  };
+}
+
+async function getQuestionWrapperMapData(
+  examQuestionList: ExamQuestion[],
+  groupCode: string
+) {
+  let examQuestionMap: Record<string, ExamQuestion[]> = {};
+  examQuestionList.forEach((q) => {
+    if (!examQuestionMap[q.questionId]) examQuestionMap[q.questionId] = [];
+    examQuestionMap[q.questionId].push(q);
+  });
+
+  const fetchFunc = Object.keys(examQuestionMap).map((questionId) =>
+    examQuestionDataApi({
+      questionId,
+      examId: props.examId,
+      courseCode: props.courseCode,
+      groupCode,
+    })
+  );
+
+  const questionList = await Promise.all(fetchFunc);
+
+  let examQuestionWrapperMap: Record<string, QuestionWrapperItem> = {};
+  questionList.forEach((qItem) => {
+    const q = qItem.data;
+    const questionList = examQuestionMap[q.id].map((questionInfo, index) => {
+      return Object.assign(
+        {},
+        questionInfo,
+        q.masterVersion.questionUnitList[index]
+      ) as ExamQuestion;
+    });
+
+    examQuestionWrapperMap[q.id] = Object.assign(
+      {},
+      { examQuestionList: questionList, questionId: q.id },
+      q.masterVersion
+    );
+  });
+
+  return examQuestionWrapperMap;
+}
+
+function restoreAudio(str: string) {
+  return (str || "")
+    .replace(/<a/g, "<audio controls ")
+    .replace(/url=/g, "src=")
+    .replace(/a>/g, "audio>");
+}
+const optionName = "ABCDEFGHIJ".split("");
+function indexToABCD(index: number) {
+  return optionName[index];
+}
+function parseRightAnswer(
+  questionType: string,
+  rightAnswer: string[] | undefined,
+  optionPermutation: number[] | undefined
+) {
+  if (!rightAnswer) return "";
+  // 选择题答案是非乱序的真实答案,展示时要转成乱序的题目答案。
+  if (["SINGLE_CHOICE", "MULTIPLE_CHOICE"].includes(questionType)) {
+    const permutationOptions = optionPermutation as number[];
+    const permutationAnswer = rightAnswer
+      .map((answer) => permutationOptions.indexOf(Number(answer)))
+      .sort();
+    return permutationAnswer.map((ans) => indexToABCD(ans)).join("");
+  } else if (["TRUE_OR_FALSE"].includes(questionType)) {
+    return { true: "正确", false: "错误" }[rightAnswer.join("")];
+  } else {
+    return rightAnswer.join("");
+  }
+}
+function parseStudentAnswer(
+  questionType: string,
+  studentAnswer: string,
+  optionPermutation: number[] | undefined
+) {
+  if (!studentAnswer) return "";
+
+  // 选择题答案是非乱序的真实答案,展示时要转成乱序的题目答案。
+  if (["SINGLE_CHOICE", "MULTIPLE_CHOICE"].includes(questionType)) {
+    const permutationOptions = optionPermutation as number[];
+    const permutationAnswer = studentAnswer
+      .split("")
+      .map((answer) => permutationOptions.indexOf(Number(answer)))
+      .sort();
+    return permutationAnswer.map((ans) => indexToABCD(ans)).join("");
+  } else if (["TRUE_OR_FALSE"].includes(questionType)) {
+    return { true: "正确", false: "错误" }[studentAnswer];
+  } else {
+    return studentAnswer;
+  }
+}
+function equalAnswer(
+  questionType: string,
+  studentAnswer: string,
+  rightAnswer: string[] | undefined
+) {
+  if (!rightAnswer) return null;
+  if (["FILL_UP", "ESSAY"].includes(questionType)) return null;
+
+  return studentAnswer === rightAnswer.join("");
+}
+function checkIsObjective(questionType: string) {
+  return ["SINGLE_CHOICE", "MULTIPLE_CHOICE", "TRUE_OR_FALSE"].includes(
+    questionType
+  );
+}
+
+onMounted(async () => {
+  await initData().catch(() => false);
+  emit("ready");
+});
+</script>
+
+<template>
+  <div class="exam-paper">
+    <div
+      v-for="(group, gindex) in questionGroupList"
+      :key="gindex"
+      class="question-group"
+    >
+      <h3 class="group-title">
+        {{ toChineseNumber(gindex + 1) }}、{{ group.groupName }}({{
+          group.groupScore
+        }}分)
+      </h3>
+      <div class="group-questions">
+        <div
+          v-for="questionWrapper in group.questionWrapperList"
+          :key="questionWrapper.questionId"
+          class="question-wrapper"
+        >
+          <div
+            v-if="questionWrapper.body"
+            class="question-wrapper-body"
+            v-html="restoreAudio(questionWrapper.body)"
+          ></div>
+          <div
+            v-for="question in questionWrapper.examQuestionList"
+            :key="question.questionId"
+            class="question-item"
+          >
+            <div class="question-item-title line-text">
+              <span>{{ question.order }}、</span>
+              <div
+                class="question-item-title-content"
+                v-html="restoreAudio(question.body)"
+              ></div>
+            </div>
+            <!-- options -->
+            <div
+              v-if="
+                question.questionOptionList &&
+                question.questionOptionList.length
+              "
+              class="question-item-options"
+            >
+              <div
+                v-for="(optionOrder, oindex) in question.optionPermutation"
+                :key="optionOrder"
+                class="question-item-option line-text"
+              >
+                <span>{{ indexToABCD(oindex) }}、</span>
+                <div
+                  class="question-item-option-content"
+                  v-html="
+                    restoreAudio(question.questionOptionList[optionOrder].body)
+                  "
+                ></div>
+              </div>
+            </div>
+            <!-- right answer -->
+            <div
+              v-if="practiceType !== 'NO_ANSWER'"
+              class="question-item-answer line-text"
+            >
+              <span>正确答案:</span>
+              <div
+                class="question-item-answer-content"
+                v-html="
+                  parseRightAnswer(
+                    question.questionType,
+                    question.rightAnswer,
+                    question.optionPermutation
+                  )
+                "
+              ></div>
+            </div>
+            <!-- student answer -->
+            <div class="question-item-answer line-text">
+              <span>学生答案:</span>
+              <div
+                class="question-item-answer-content"
+                v-html="
+                  parseStudentAnswer(
+                    question.questionType,
+                    question.studentAnswer,
+                    question.optionPermutation
+                  )
+                "
+              ></div>
+              <div
+                v-if="checkIsObjective(question.questionType)"
+                class="question-item-answer-result"
+              >
+                <n-icon
+                  v-if="
+                    equalAnswer(
+                      question.questionType,
+                      question.studentAnswer,
+                      question.rightAnswer
+                    )
+                  "
+                  :component="Checkmark"
+                  color="#13bb8a"
+                  :size="16"
+                ></n-icon>
+                <n-icon
+                  v-else
+                  :component="Close"
+                  color="#ed4014"
+                  :size="16"
+                ></n-icon>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.question-group {
+  margin-bottom: 20px;
+}
+.group-title {
+  font-size: var(--app-font-size-large);
+  font-weight: 600;
+  margin-bottom: 5px;
+}
+.question-item {
+  margin-bottom: 10px;
+}
+.question-item-options {
+  margin-bottom: 10px;
+}
+
+.line-text > * {
+  display: inline-block;
+  vertical-align: top;
+}
+.line-text audio {
+  height: 32px;
+}
+
+.line-text .question-item-answer-result .n-icon {
+  vertical-align: middle;
+  margin-top: -2px;
+}
+</style>
+<style>
+.line-text audio {
+  height: 32px;
+}
+.line-text img {
+  display: inline-block;
+  vertical-align: middle;
+}
+</style>

+ 33 - 5
src/features/OnlinePractice/OnlinePracticeRecordDetail.vue

@@ -1,13 +1,20 @@
 <script setup lang="ts">
 import { onlinePracticeRecordDetailApi } from "@/api/onlinePractice";
 import { OnlinePracticeRecordResult } from "@/types/student-client";
-import { onMounted } from "vue";
+import { onMounted, nextTick } from "vue";
 import { useRoute, useRouter } from "vue-router";
+import ExamPaperPreview from "./ExamPaperPreview.vue";
 
 const route = useRoute();
 const router = useRouter();
+const examId = route.params.examId as string;
+const examRecordDataId = route.params.examRecordDataId as string;
+const fromCache = !!route.query.fromCache;
+let showPaper = $ref(false);
+let loading = $ref(false);
 
 const defaultExamRecordResult: OnlinePracticeRecordResult = {
+  courseCode: "",
   courseName: "",
   objectiveAccuracy: "",
   paperStructInfos: [],
@@ -23,9 +30,15 @@ async function getExamRecordResult() {
   examRecordResult = res.data || defaultExamRecordResult;
 }
 
-function toOpenPaper() {
-  // TODO:
-  $message.info("等待展开试卷...");
+function paperOpened() {
+  loading = false;
+}
+async function toOpenPaper() {
+  if (loading) return;
+  showPaper = false;
+  void (await nextTick());
+  showPaper = true;
+  loading = true;
 }
 function goBack() {
   router.back();
@@ -88,13 +101,25 @@ onMounted(() => {
   <div class="record-detail-action">
     <n-grid :xGap="20" :cols="2">
       <n-gi>
-        <n-button type="success" block @click="toOpenPaper">展开试卷</n-button>
+        <n-button type="success" block :loading="loading" @click="toOpenPaper"
+          >展开试卷</n-button
+        >
       </n-gi>
       <n-gi>
         <n-button type="success" block @click="goBack">返回</n-button>
       </n-gi>
     </n-grid>
   </div>
+
+  <ExamPaperPreview
+    v-if="showPaper"
+    class="exam-paper-preview"
+    :examId="examId"
+    :examRecordDataId="examRecordDataId"
+    :courseCode="examRecordResult.courseCode"
+    :fromCache="fromCache"
+    @ready="paperOpened"
+  />
 </template>
 
 <style scoped>
@@ -121,4 +146,7 @@ onMounted(() => {
 .record-detail-action {
   margin-top: 20px;
 }
+.exam-paper-preview {
+  margin-top: 50px;
+}
 </style>

+ 22 - 0
src/features/OnlinePractice/answers.md

@@ -0,0 +1,22 @@
+## 单选:SINGLE_CHOICE
+- rightAnswer: ["0"]
+- studentAnswer: "0"
+  
+## 多选:MULTIPLE_CHOICE
+- rightAnswer: ["0","1","2"]
+- studentAnswer: "013"
+  
+
+## 判断:TRUE_OR_FALSE
+- rightAnswer: ["false"]
+- studentAnswer: "false/true"
+  
+## 填空:FILL_UP
+- rightAnswer: ["<p>电场和</p>↵↵<p><strong>磁场##导</strong></p>↵↵<p>体<img alt="" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJkAAABcCAYAAAB9eRe9AAAFO0lEQVR4Ae2dzWrbQBRG86ghizxEsuhDBJL3CGRZKKV0lWRT6Cql3SShXZSSEmhL0imjH/vT9Sh2pc+yFsdgfEc/dzRnjkdjL0Z7afLXdTo5OEz7p9eFmu/S+XHPvquz5Tl3F+no+CLdFjJcnh6mk6vCjg023Xy+2+CoNYfka8vta95H54aca6qc++69KS/w9vxVBb8jQeiU/YOzdFm6qEayLFHbgS9+FiUuJV5us0i2TEfUEJhUsrlTR7Lt9BCSCVckExjGEMkEJpIJDGOIZAITyQSGMUQygYlkAsMYIpnARDKBYQyRTGAimcAwhkgmMJFMYBhDJBOYSCYwjCGSCUwkExjGEMkE5qcv9+n5+a9sIXQQQDKheP/1e/rx8ChbCB0EkEwo/vr9J+XRLIvGiCZgRoZIFgBm0fKIlmXLczTe4xkgWZCMop8AkvmZkjEQQLIAhKKfAJL5mZIxEECyAISinwCS+ZmSMRBAsgCEop8AkvmZkjEQQLIA5OHnY/rw8Sa9fX+d3ry75G1ggGQiWRYsy3V79y09PT3JHsIxBJBM6OURLAvGy0sAyYRnHsUYwQSIKUQyAZnnYLz8BJBMmCKZwDCGSCYwkUxgGEMkE5hIJjCMIZIJTCQTGMYQyQQmkgkMY4hkAhPJBIYxRDKBiWQCwxgimcBEMoFhDJFMYCKZwDCGFsmqxYLbhYCrhYZfpeKiz3lxYVkZen3cs0hxA6Be6LinrgGQ3JLtisuApm/1lPGSVeJ0ZejA7bv8fN7KMul5+fWyNO3K2evFrFfHHrK0uVWyibj04Z3T9nGSNcujd5ZMr1pXr9W/ul2aPkCyhThVvV2xU962Iq3Ut0Fok2xCLhs0a+eHjJCsFmnR8bEpFejyqFQdOkSy07POgxheGtVeFDxea1P2SDYtl56mzGrzMMmab2r5qSLSvua4oohDJGsnelXeGY5kO+AitGcbDpCsfjTNijhFaXK722/26/pxN/818V8+AifPyRZ1tp0Zc+34dpkf2bO4xrbLt8ylrWbOnwMkKzVngzlYPC3Db3+RLvblPOVbbEeyxfHewHO71GvaPhetba6xRbLql99CmHqkWzcn6p7T4umXLP9iPTq/rh/wFUcwLY8YzdySddu4HS4tuTl/jpesND+qbpHlEamFUUsTn6DWJ1m3g7qd12RsbqHr5G7rL31aJZuES6kV89s2UrJu53ea1zsXSSnlfcWnwfVIVuowyVH9L1fM17mitQWfZBNxWduieRwwSrLuaFSDXflbYXEbrRtcjUI98676R8LqCFgcuSrJlo8lXJlwD+DrkmwqLgOauJNTBktWjx7LTt4vipPFW/7VUMny4pypNJLlHFm8IHFR3uX1DJHOIdl0XHbiy6BKB0s2qLaZn+SQbOZN3MnlIZlgRzKBYQyRTGAimcAwhkgmMJFMYBhDJBOYSCYwjCGSCUwkExjGEMkEJpIJDGOIZAITyQSGMUQygYlkAsMYIpnARDKBYQyRTGCyCJ7AMIZIJjBZzlNgGEMkE5gsTCwwjCGSBZgsse5fVh7JgmQU/QSQzM+UjIEAkgUgFP0EkMzPlIyBAJIFIBT9BJDMz5SMgQCSBSAU/QSQzM+UjIEAkgUgFP0EkMzPlIyBAJIFIBT9BJDMz5SMgQCSBSAU/QSQzM+UjIEAkgUgFP0EkMzPlIyBAJIFIBT9BJDMz5SMgQCSBSAU/QSQzM+UjIEAkgUgFP0EkMzPlIyBAJIFIBT9BJDMz5SMgQCSBSAU/QSQzM+UjIEAkgUgFP0EkMzPlIyBwD+GEHg4QDybCwAAAABJRU5ErkJggg==" /></p>"
+"]
+- studentAnswer: "2222"
+  
+## 简答题:ESSAY
+- rightAnswer: ["html-str"]
+- studentAnswer: "1111111"
+  

+ 46 - 16
src/types/student-client.d.ts

@@ -206,7 +206,7 @@ type ExamCycle = {
   examCycleTimeRange: { timeRange: [string, string] }[];
 };
 
-type OnlineExam = BaseExam &
+export type OnlineExam = BaseExam &
   ExamCycle & {
     /** 课程id */
     courseId: number;
@@ -284,8 +284,9 @@ export type OnlinePracticeRecord = {
   objectiveAccuracy: number;
 };
 
-type OnlinePracticeRecordResult = {
+export type OnlinePracticeRecordResult = {
   courseName: string;
+  courseCode: string;
   /** 客观题正确率(后端已乘100) */
   objectiveAccuracy: string;
   paperStructInfos: Array<{
@@ -303,27 +304,56 @@ type OnlinePracticeRecordResult = {
   }>;
 };
 
-type PaperStruct = {
+// export type PaperStruct = {
+//   defaultPaper: {
+//     /** index = mainNumber - 1 ??待确定 */
+//     questionGroupList: Array<{
+//       groupName: string;
+//       groupScore: number;
+//       questionWrapperList: Array<{
+//         questionId: string;
+//         body: string;
+//         examQuestionList: ExamQuestion[];
+//         /** 小题的列表,学生端用不着,只用到它的length */
+//         questionUnitWrapperList: Array<{ id: string }>;
+//         questionUnitList: Array<{
+//           body: string;
+//         }>;
+//       }>;
+//     }>;
+//   };
+// };
+
+export type QuestionUnitItem = {
+  body: string;
+  questionOptionList: Array<{ body: string }>;
+  questionType: string;
+  rightAnswer: string[];
+};
+
+export type QuestionWrapperItem = {
+  questionId: string;
+  /** 试题先导内容,一般出现在套题中 */
+  body: string;
+  /** 试题小题的信息列表 */
+  examQuestionList: ExamQuestion[];
+  /** 试题小题的内容列表,一般应该是用不到的 */
+  questionUnitList: QuestionUnitItem[];
+};
+
+export type PaperStruct = {
   defaultPaper: {
     /** index = mainNumber - 1 ??待确定 */
     questionGroupList: Array<{
       groupName: string;
       groupScore: number;
-      questionWrapperList: Array<{
-        questionId: string;
-        body: string;
-        /** 小题的列表,学生端用不着,只用到它的length */
-        questionUnitWrapperList: Array<{ id: string }>;
-        questionUnitList: Array<{
-          eqs: ExamQuestion[];
-          body: string;
-        }>;
-      }>;
+      /** 试题列表 */
+      questionWrapperList: QuestionWrapperItem[];
     }>;
   };
 };
 
-type ExamQuestion = {
+export type ExamQuestion = {
   /** 试题id */
   questionId: string;
   /** 题目序号 */
@@ -361,7 +391,7 @@ type ExamQuestion = {
   /** 文本作答的题目,是否开启音频作答。和考试的weixinAnswerEnabled有关。 */
   answerType: "SINGLE_AUDIO" | null;
   /** 练习时有正确答案 */
-  rightAnswer?: boolean | number | string;
+  rightAnswer?: string[];
   /** 后端给的类型前端好像用不着,待确认??? */
   questionOptionList: any;
   /** 题目中是否有音频 */
@@ -372,7 +402,7 @@ type ExamQuestion = {
   audioPlayTimes: { audioName: string; times: number }[];
 };
 
-type ExamInProgress = {
+export type ExamInProgress = {
   /**  "S-101000" 请重试 */
   code: "000000" | "S-101000";
   /** 是否超过断点次数限制 */

+ 1 - 2
src/utils/download.ts

@@ -62,9 +62,8 @@ export function downloadByUrl(url: string, filename?: string) {
 }
 
 /**
- * 通过url获得dataURL
+ * 通过url获得blob
  * @param url 链接
- * @returns dataURL
  */
 export function toBlobByUrl(url: string) {
   return fetch(url).then((response) => {

+ 5 - 1
src/utils/md5.ts

@@ -2,12 +2,16 @@ import jsmd5 from "js-md5";
 
 /**
  *
- * @param {any} str 字符串
+ * @param {string} content 字符串
  */
 export const MD5 = (content: string): string => {
   return jsmd5(content);
 };
 
+/**
+ * 获取文件MD5
+ * @param file File
+ */
 export const fileMD5 = (file: File): Promise<string> => {
   return new Promise((resolve, reject) => {
     const reader = new FileReader();

+ 26 - 0
src/utils/utils.ts

@@ -27,3 +27,29 @@ export function showLogout(cause: string) {
   $message.warning(cause, { duration: 5 * 60 * 1000, closable: true });
   void router.push({ name: "UserLogin" });
 }
+
+/**
+ * 将阿拉伯数字转换成汉字数字
+ * @param num 数值
+ * @returns 汉字数字
+ */
+export function toChineseNumber(num: number) {
+  let ret;
+  if (num < 10) {
+    ret = num.toLocaleString("zh-u-nu-hanidec");
+  } else if (num === 10) {
+    ret = "十";
+  } else if (num > 10 && num < 20) {
+    ret = "十" + (num % 10).toLocaleString("zh-u-nu-hanidec");
+  } else if (num >= 20 && num < 100) {
+    const s = num
+      .toLocaleString("zh-u-nu-hanidec", { useGrouping: false })
+      .split("");
+    s.splice(1, 0, "十");
+    ret = s.join("").replace("〇", "");
+  } else {
+    ret = num.toLocaleString("zh-u-nu-hanidec"); // 假设没有超过100的大题
+  }
+
+  return ret;
+}