소스 검색

单选题

Michael Wang 3 년 전
부모
커밋
4ee86dd11e

+ 1 - 1
src/features/OnlineExam/Examing/ArrowNavView.vue

@@ -35,7 +35,7 @@ const nextQuestionOrder = $computed(() => {
         <div>上一题</div>
       </template>
     </div>
-    <div class="tips">A、B、C、D勾选选项。<!-- Y、N来勾选判断题。 --></div>
+    <div class="tips">A、B、C、D勾选选项。<!-- Y、N来勾选判断题。 --></div>
     <div class="next">
       <template v-if="nextQuestionOrder">
         <router-link

+ 3 - 3
src/features/OnlineExam/Examing/ExamingHome.vue

@@ -2,7 +2,7 @@
 import RemainTime from "./RemainTime.vue";
 import OverallProgress from "./OverallProgress.vue";
 import QuestionFilters from "./QuestionFilters.vue";
-// import QuestionView from "./QuestionView.vue";
+import QuestionView from "./QuestionView.vue";
 import ArrowNavView from "./ArrowNavView.vue";
 import QuestionNavView from "./QuestionNavView.vue";
 import FaceTracking from "./FaceTracking.vue";
@@ -273,8 +273,8 @@ addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
       <n-button type="success" @click="userSubmitPaper">交卷</n-button>
     </div>
     <div id="examing-home-question" class="main">
-      <!-- <QuestionView :examQuestion="examQuestion()"></QuestionView> -->
-      <ArrowNavView></ArrowNavView>
+      <QuestionView />
+      <ArrowNavView />
     </div>
     <div :class="['side']">
       <div :class="['question-nav']">

+ 1 - 1
src/features/OnlineExam/Examing/OverallProgress.vue

@@ -20,7 +20,7 @@ const progress = $computed(() => {
   <div class="progress-container">
     <n-progress
       type="line"
-      :percentage="progressNum"
+      :percentage="Math.round(progressNum)"
       :indicatorPlacement="'inside'"
       processing
     />

+ 174 - 0
src/features/OnlineExam/Examing/QuestionAudio.vue

@@ -0,0 +1,174 @@
+<script setup lang="ts">
+import moment from "moment";
+import { onMounted, onUnmounted } from "vue";
+import { Play, Pause } from "@vicons/ionicons5";
+
+const props = defineProps<{ src: string; name: string; playCount: number }>();
+const emit = defineEmits<{ (e: "played"): void }>();
+
+let context: AudioContext;
+let buffer: AudioBuffer;
+let source: AudioBufferSourceNode;
+
+let downloadPercent: string | number = $ref("");
+let duration: number = $ref(0);
+let currentTime: number = $ref(0);
+
+let playing = $ref(false);
+
+const shouldShowAudio = $computed(() => downloadPercent === 100);
+
+onMounted(() => {
+  loadData();
+});
+
+onUnmounted(async () => {
+  source?.stop();
+  await context?.close();
+});
+
+function formatTime(seconds: number) {
+  return moment.utc(seconds * 1000).format("mm:ss");
+}
+
+function loadDogSound(url: string) {
+  var request = new XMLHttpRequest();
+  request.open("GET", url, true);
+  request.responseType = "arraybuffer";
+
+  // Decode asynchronously
+  request.onload = () => {
+    logger({
+      cnl: ["server"],
+      lvl: "debug",
+      pgu: "AUTO",
+      act: "开始下载音频",
+    });
+    context
+      .decodeAudioData(
+        <ArrayBuffer>request.response,
+        (_buffer: AudioBuffer) => {
+          buffer = _buffer;
+          duration = Math.floor(buffer.duration);
+          downloadPercent = 100;
+          logger({
+            cnl: ["server"],
+            lvl: "debug",
+            pgu: "AUTO",
+            act: "音频下载完成",
+          });
+        }
+      )
+      .catch(() => {
+        logger({
+          cnl: ["server"],
+          lvl: "debug",
+          pgu: "AUTO",
+          dtl: "error load audio",
+        });
+      });
+  };
+  request.onprogress = (e) => {
+    downloadPercent = Number((e.loaded / e.total) * 100).toFixed(2);
+  };
+  request.send();
+}
+
+function loadData() {
+  context = new AudioContext();
+
+  loadDogSound(props.src);
+}
+
+async function play() {
+  if (props.playCount <= 0) {
+    logger({
+      cnl: ["server"],
+      lvl: "debug",
+      pgu: "AUTO",
+      act: "播放音频",
+      dtl: "无播放次数",
+    });
+    return;
+  }
+  if (playing) {
+    logger({
+      cnl: ["server"],
+      lvl: "debug",
+      pgu: "AUTO",
+      act: "播放音频",
+      dtl: "正在播放,无法暂停",
+    });
+    return;
+  }
+  emit("played");
+  logger({
+    cnl: ["server"],
+    lvl: "debug",
+    pgu: "AUTO",
+    act: "点击播放音频",
+  });
+  await context.close();
+
+  context = new AudioContext();
+  source = context.createBufferSource(); // creates a sound source
+  source.buffer = buffer; // tell the source which sound to play
+  source.connect(context.destination); // connect the source to the context's destination (the speakers)
+  source.start(0); // play the source now
+  playing = true;
+  currentTime = 0;
+  const progress = () => {
+    // console.log(context.currentTime, buffer.duration);
+    currentTime = Math.floor(context.currentTime);
+    if (context.currentTime < buffer.duration) {
+      requestAnimationFrame(progress);
+    } else if (context.currentTime >= buffer.duration) {
+      currentTime = 0;
+      requestAnimationFrame(progress);
+    }
+  };
+  requestAnimationFrame(progress);
+  // note: on older systems, may have to use deprecated noteOn(time);
+  source.onended = () => {
+    playing = false;
+    currentTime = 0;
+    logger({
+      cnl: ["server"],
+      lvl: "debug",
+      pgu: "AUTO",
+      act: "播放音频播放结束",
+    });
+  };
+}
+</script>
+
+<template>
+  <span class="a-container" style="display: inline-flex; align-items: center">
+    <span v-show="shouldShowAudio">
+      <n-button
+        v-if="!playing"
+        type="primary"
+        :circle="true"
+        :disabled="playCount === 0"
+        @click="play"
+      >
+        <NIcon class="tw-ml-1" :component="Play" />
+      </n-button>
+      <n-button
+        v-else
+        type="success"
+        :circle="true"
+        class="pause tw-cursor-not-allowed"
+      >
+        <NIcon :component="Pause" />
+      </n-button>
+    </span>
+    <span v-show="shouldShowAudio" class="tw-text-red-500 tw-px-1">
+      {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
+    </span>
+
+    <span v-if="!shouldShowAudio" style="color: blueviolet">
+      音频下载中{{ downloadPercent }}%
+    </span>
+  </span>
+</template>

+ 107 - 0
src/features/OnlineExam/Examing/QuestionBody.vue

@@ -0,0 +1,107 @@
+<script setup lang="ts">
+import { store } from "@/store/store";
+import { watch } from "vue";
+import QuestionAudio from "./QuestionAudio.vue";
+
+const props = defineProps<{ questionBody: string }>();
+const examQuestion = $computed(() => store.exam.currentQuestion);
+
+let questionSegements: {
+  text: string;
+  audios: { src: string; name: string }[];
+} | null = $ref(null);
+
+watch(
+  () => store.exam.currentQuestion?.order,
+  () => parseQuestion(),
+  { immediate: true }
+);
+
+function parseQuestion() {
+  let questionText = "";
+  let capAudios: { name: string; src: string }[] = [];
+  if (props.questionBody.includes("question-audio")) {
+    questionText = props.questionBody.replace(
+      /<a.*?name="([^"]*)".*?question-audio.*?url="([^"]*)".*?\/a>/g,
+      function (_, name, src) {
+        capAudios.push({ name, src });
+        return "";
+      }
+    );
+  } else {
+    questionText = props.questionBody;
+  }
+
+  questionSegements = { text: questionText, audios: capAudios };
+}
+
+function played(name: string) {
+  if (!store.exam.currentQuestion.limitedPlayTimes) {
+    // 如果没有设置音频播放次数,可无限播放
+    return false;
+  }
+  logger({
+    cnl: ["server"],
+    lvl: "debug",
+    pgu: "AUTO",
+    act: "开始播放音频",
+  });
+
+  // 正在播放中
+  store.updateQuestionAudioPlayTimes(name);
+}
+
+function getAudioPlayedTimes(name: string) {
+  const playedTimes =
+    store.exam.allAudioPlayTimes.find((a) => a.audioName === name)?.times ?? 0;
+  return Math.max(examQuestion.limitedPlayTimes - playedTimes, 0);
+}
+</script>
+
+<template>
+  <div v-if="questionSegements" class="question-body">
+    <div v-html="questionSegements.text" />
+
+    <div
+      v-for="(item, index) in questionSegements.audios"
+      :key="index"
+      style="
+        position: relative;
+        font-size: 18px;
+        word-break: break-word;
+        background-color: lightblue;
+        border-radius: 4px;
+        padding-left: 2px;
+        padding-top: 2px;
+      "
+      class="audio-div"
+    >
+      <QuestionAudio
+        :name="item.name"
+        :src="item.src"
+        :playCount="
+          examQuestion.limitedPlayTimes ? getAudioPlayedTimes(item.name) : 1
+        "
+        @played="played(item.name)"
+      />
+      <span v-if="examQuestion.limitedPlayTimes">
+        (<span class="tw-text-red-700">请点击播放</span
+        >按钮听音作答。已播次数:<span class="tw-text-red-700">{{
+          examQuestion.limitedPlayTimes - getAudioPlayedTimes(item.name)
+        }}</span
+        >,剩余播放次数:<span class="tw-text-red-700"
+          >{{ getAudioPlayedTimes(item.name) }} </span
+        >)
+      </span>
+    </div>
+  </div>
+
+  <div v-else>试题解析中...</div>
+</template>
+
+<style>
+.audio-div {
+  display: inline-flex;
+  align-items: center;
+}
+</style>

+ 20 - 0
src/features/OnlineExam/Examing/QuestionIndex.vue

@@ -0,0 +1,20 @@
+<script setup lang="ts">
+import { store } from "@/store/store";
+import { toChineseNumber } from "@/utils/utils";
+</script>
+
+<template>
+  <div class="container">
+    {{ toChineseNumber(store.exam.currentQuestion.mainNumber) }}、{{
+      store.exam.currentQuestion.groupName
+    }}({{ store.exam.currentQuestion.inGroupOrder }} /
+    {{ store.exam.currentQuestion.groupTotal }})
+  </div>
+</template>
+
+<style scoped>
+.container {
+  font-size: 28px;
+  padding-left: 10px;
+}
+</style>

+ 198 - 0
src/features/OnlineExam/Examing/QuestionView.vue

@@ -0,0 +1,198 @@
+<script setup lang="ts">
+import QuestionIndex from "./QuestionIndex.vue";
+// import QuestionBody from "./QuestionBody";
+import QuestionViewSingle from "./QuestionViewSingle.vue";
+import { store } from "@/store/store";
+import { httpApp } from "@/plugins/axiosApp";
+import { watch } from "vue";
+import { ExamQuestion } from "@/types/student-client";
+// import splitPane from "vue-splitpane";
+import { Star, StarOutline } from "@vicons/ionicons5";
+
+// let parentPaneHeight = $ref(50);
+
+watch(
+  () => store.exam.currentQuestion?.order,
+  async () => {
+    store.exam.currentQuestion && (await updateQuestion());
+  },
+  { immediate: true }
+);
+
+function transferWellNumber(body: string | null) {
+  if (body === null) return null;
+  //将题干中的三个#替换为下划线
+  //将题干中的两个##数字##替换为下划线
+  return body
+    .replace(new RegExp("###", "g"), "_______")
+    .replace(/##(\d+)##/g, function (a, b: string) {
+      return "__" + parseInt(b) + "__";
+    });
+}
+
+type QuestionUnitContent = Pick<
+  ExamQuestion,
+  "answerType" | "body" | "questionOptionList" | "questionType" | "rightAnswer"
+>;
+async function updateQuestion() {
+  const currentExamQuestion = store.exam.currentQuestion; // 避免以后执行时,this.examQuestion换掉了
+  const examRecordDataId = store.exam.examRecordDataId;
+  const { order, questionId } = currentExamQuestion;
+  let qContentRes: {
+    body: string;
+    hasAudios: boolean;
+    questionUnitList: QuestionUnitContent[];
+  };
+  try {
+    qContentRes = (
+      await httpApp.get(
+        `/api/ecs_oe_student/examQuestion/getQuestionContent?questionId=${questionId}&exam_record_id=${examRecordDataId}`,
+        { "axios-retry": { retries: 5, retryDelay: () => 3000 } }
+      )
+    ).data;
+  } catch (e) {
+    logger({
+      cnl: ["server"],
+      pgu: "AUTO",
+      act: "获取试题内容失败",
+      possibleError: e,
+    });
+    return;
+  }
+  if (!qContentRes) return;
+
+  // Object.assign(currentExamQuestion, qContentRes.data);
+
+  let i = 0;
+  //判断是否为套题
+  const isNestedQuestion = qContentRes.questionUnitList.length > 1;
+  store.exam.examQuestionList.forEach((q) => {
+    if (q.questionId === questionId) {
+      q.getQuestionContent = true;
+      let qc = qContentRes.questionUnitList[i++];
+      q.answerType = qc.answerType;
+      q.parentBody = transferWellNumber(qContentRes.body);
+      q.body = transferWellNumber(qc.body)!;
+      q.questionOptionList = qc.questionOptionList;
+      q.questionType = qc.questionType;
+      q.rightAnswer = qc.rightAnswer;
+      q.isNestedQuestion = isNestedQuestion;
+    }
+  });
+
+  // 对于某些图片套题,会导致高度计算错误,需多次nextTick
+  // this.$nextTick(() => {
+  //   this.$nextTick(() => {
+  //     // 从非套题进入套题时,会根据套题的内容动态调整套题的默认高度
+  //     const parentQuestion =
+  //       document.getElementsByClassName("parent-question")[0];
+  //     if (parentQuestion) {
+  //       this.parentPaneHeight =
+  //         5 +
+  //         (100 * parentQuestion.clientHeight) /
+  //           (document.body.clientHeight - 160);
+  //       if (this.parentPaneHeight > 60) {
+  //         this.parentPaneHeight = 60;
+  //       }
+  //     }
+  //   });
+  // });
+  {
+    // cache next question content
+    if (order < store.exam.examQuestionList.length) {
+      void httpApp.get(
+        "/api/ecs_oe_student/examQuestion/getQuestionContent?questionId=" +
+          store.exam.examQuestionList[order].questionId +
+          "&exam_record_id=" +
+          examRecordDataId,
+        { noErrorMessage: true }
+      );
+    }
+  }
+}
+
+function toggleSign() {
+  store.updateExamQuestion({
+    order: store.exam.currentQuestion.order,
+    isSign: !store.exam.currentQuestion.isSign,
+  });
+}
+</script>
+
+<template>
+  <div
+    v-if="store.exam.currentQuestion.getQuestionContent"
+    class="question-container"
+  >
+    <div class="question-header">
+      <n-icon
+        :component="store.exam.currentQuestion.isSign ? Star : StarOutline"
+        class="star"
+        @click="toggleSign"
+      />
+      <question-index />
+    </div>
+    <!-- <split-pane
+      v-if="parentQuestionBody"
+      :minPercent="10"
+      :defaultPercent="parentPaneHeight"
+      split="horizontal"
+    >
+      <template #paneL>
+        <div v-if="parentQuestionBody" class="question-view parent-question">
+          <question-body
+            :key="examQuestion.questionId"
+            :questionBody="parentQuestionBody"
+            :examQuestion="examQuestion"
+            style="margin-bottom: 20px"
+          ></question-body>
+        </div>
+      </template>
+      <template #paneR>
+        <QuestionViewSingle :question="question" :examQuestion="examQuestion" />
+      </template>
+    </split-pane>
+ -->
+    <QuestionViewSingle v-if="!store.exam.currentQuestion.parentBody" />
+  </div>
+  <div v-else>试题获取中...</div>
+</template>
+
+<style scoped>
+.question-container {
+  overflow: auto;
+}
+.question-container >>> .splitter-pane {
+  overflow: auto;
+}
+.question-container >>> .vue-splitter-container {
+  height: calc(100% - 42px);
+}
+.question-container >>> .splitter-pane-resizer.horizontal {
+  height: 15px;
+}
+.question-view {
+  padding: 20px 30px;
+  font-size: 16px;
+  text-align: left;
+  overflow: auto;
+}
+
+.question-header {
+  display: flex;
+  align-items: center;
+}
+
+.star {
+  font-size: 36px;
+  color: #ffcc00;
+  align-self: flex-start;
+}
+.star:hover {
+  cursor: pointer;
+}
+
+div.hr {
+  border-bottom: 1px dashed gray;
+}
+</style>

+ 86 - 0
src/features/OnlineExam/Examing/QuestionViewSingle.vue

@@ -0,0 +1,86 @@
+<script setup lang="ts">
+import { store } from "@/store/store";
+import SingleQuestionView from "./SingleQuestionView.vue";
+// import MultipleQuestionView from "./MultipleQuestionView";
+// import BooleanQuestionView from "./BooleanQuestionView";
+// import FillBlankQuestionView from "./FillBlankQuestionView";
+// import TextQuestionView from "./TextQuestionView";
+
+const examQuestion = $computed(() => store.exam.currentQuestion);
+</script>
+
+<template>
+  <transition name="fade">
+    <div
+      v-show="examQuestion.getQuestionContent"
+      :key="examQuestion.order"
+      class="question-view"
+    >
+      <div style="margin-bottom: -45px">{{ examQuestion.inGroupOrder }}、</div>
+      <template v-if="examQuestion.questionType === 'SINGLE_CHOICE'">
+        <single-question-view :key="examQuestion.order" />
+      </template>
+      <!-- <template
+        v-if="
+          question &&
+          question.questionType === 'MULTIPLE_CHOICE' &&
+          examQuestion.questionType === 'MULTIPLE_CHOICE'
+        "
+      >
+        <multiple-question-view
+          :key="examQuestion.order"
+          :question="question"
+          :examQuestion="examQuestion"
+        />
+      </template>
+      <template
+        v-if="
+          question &&
+          question.questionType === 'TRUE_OR_FALSE' &&
+          examQuestion.questionType === 'TRUE_OR_FALSE'
+        "
+      >
+        <boolean-question-view
+          :key="examQuestion.order"
+          :question="question"
+          :examQuestion="examQuestion"
+        />
+      </template>
+      <template
+        v-if="
+          question &&
+          question.questionType === 'FILL_UP' &&
+          examQuestion.questionType === 'FILL_UP'
+        "
+      >
+        <fill-blank-question-view
+          :key="examQuestion.order"
+          :question="question"
+          :examQuestion="examQuestion"
+        />
+      </template>
+      <template
+        v-if="
+          question &&
+          question.questionType === 'ESSAY' &&
+          examQuestion.questionType === 'ESSAY'
+        "
+      >
+        <text-question-view
+          :key="examQuestion.order"
+          :question="question"
+          :examQuestion="examQuestion"
+        />
+      </template> -->
+    </div>
+  </transition>
+</template>
+
+<style scoped>
+.question-view {
+  padding: 20px 30px;
+  font-size: 16px;
+  text-align: left;
+  overflow: auto;
+}
+</style>

+ 182 - 0
src/features/OnlineExam/Examing/SingleQuestionView.vue

@@ -0,0 +1,182 @@
+<script setup lang="ts">
+import { store } from "@/store/store";
+import { onMounted, onUnmounted } from "vue";
+import QuestionBody from "./QuestionBody.vue";
+
+const optionName = "ABCDEFGHIJ".split("");
+
+let isShowAnswer = $ref(false);
+
+const examQuestion = $computed(() => store.exam.currentQuestion);
+
+const newQuestionOptions = $computed(() => {
+  return examQuestion.questionOptionList.map((v, i) => {
+    return {
+      value: examQuestion.questionOptionList[examQuestion.optionPermutation[i]],
+      oldIndex: "" + examQuestion.optionPermutation[i],
+      name: optionName[i],
+    };
+  });
+});
+
+const oldIndexToABCD = $computed(() => {
+  const ans = examQuestion.studentAnswer;
+  return (
+    ans &&
+    newQuestionOptions
+      .filter((v) => ans.includes(v.oldIndex))
+      .map((v) => v.name)
+      .join("")
+  );
+});
+
+const rightAnswerTransform = $computed(() => {
+  return (
+    examQuestion.rightAnswer &&
+    newQuestionOptions
+      .filter((v) => examQuestion.rightAnswer?.includes(v.oldIndex))
+      .map((v) => v.name)
+      .join("")
+  );
+});
+
+onMounted(() => {
+  window.addEventListener("keyup", keyup);
+});
+onUnmounted(() => {
+  window.removeEventListener("keyup", keyup);
+});
+// methods: {
+//   ...mapMutations(["updateExamQuestion"]),
+//   ...mapGetters(["examShouldShowAnswer"]),
+function keyup(e: KeyboardEvent) {
+  const { tagName = "" } = document?.activeElement || {};
+  if (
+    (examQuestion.questionType === "SINGLE_CHOICE" ||
+      examQuestion.questionType === "MULTIPLE_CHOICE") &&
+    ["BODY", "A", "BUTTON", "DIV"].includes(tagName)
+  ) {
+    if (
+      optionName
+        .map((v) => "Key" + v)
+        .slice(0, examQuestion.questionOptionList.length)
+        .includes(e.code)
+    ) {
+      _hmt.push(["_trackEvent", "答题页面", "快捷键", "ABCDE"]);
+      logger({ cnl: ["server"], act: "选择题键盘作答" });
+      const keyAns = newQuestionOptions.find((v) => v.name == e.code[3]);
+      if (keyAns) examQuestion.studentAnswer = keyAns.oldIndex;
+    }
+  }
+}
+
+function answerQuestion(studentAnswer: string) {
+  if (studentAnswer !== examQuestion.studentAnswer) {
+    store.updateExamQuestion({ order: examQuestion.order, studentAnswer });
+  }
+}
+function toggleShowAnswer() {
+  isShowAnswer = !isShowAnswer;
+}
+</script>
+
+<template>
+  <div
+    v-if="examQuestion.questionType === 'SINGLE_CHOICE'"
+    class="question-view"
+  >
+    <question-body :questionBody="examQuestion.body" />
+    <div class="ops">
+      <div class="stu-answer">{{ oldIndexToABCD }}</div>
+      <div class="score">({{ examQuestion.questionScore }}分)</div>
+    </div>
+    <div
+      v-for="(option, index) in newQuestionOptions"
+      :key="index"
+      class="option"
+      @click="() => answerQuestion(option.oldIndex)"
+    >
+      <div
+        :class="
+          examQuestion.studentAnswer === option.oldIndex && 'row-selected'
+        "
+        style="display: flex"
+      >
+        <div>
+          <input
+            type="radio"
+            name="question"
+            :value="option.oldIndex"
+            :checked="examQuestion.studentAnswer === option.oldIndex"
+            style="margin-top: 3px; display: block"
+          />
+        </div>
+        <span style="padding: 0 10px">{{ optionName[index] }}: </span>
+        <div v-if="option.value" class="question-options">
+          <question-body
+            :questionBody="option.value.body"
+            :examQuestion="examQuestion"
+          />
+        </div>
+      </div>
+    </div>
+    <div v-if="store.examShouldShowAnswer" class="reset">
+      <span>
+        <n-button type="success" @click="toggleShowAnswer"> 显示答案 </n-button>
+      </span>
+      <div v-if="isShowAnswer">
+        正确答案:
+        <div>{{ rightAnswerTransform }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.question-view {
+  display: grid;
+  grid-row-gap: 10px;
+}
+
+.question-body {
+  font-size: 18px;
+  /* margin-bottom: 10px; */
+}
+
+.ops {
+  display: flex;
+  align-items: flex-end;
+}
+
+.stu-answer {
+  width: 100px;
+  border-bottom: 1px solid black;
+  text-align: center;
+  height: 20px;
+}
+
+.option {
+  display: flex;
+  cursor: pointer;
+  padding-top: 10px;
+  border-radius: 5px;
+}
+
+.question-options {
+  flex: 1;
+}
+
+.option:hover {
+  background-color: aliceblue;
+}
+
+.row-selected {
+  background-color: aliceblue;
+  width: 100%;
+}
+
+.question-options > .question-body {
+  text-align: left;
+  font-size: 16px !important;
+}
+</style>

+ 5 - 2
src/features/OnlineExam/Examing/setups/useAnswerQuestions.ts

@@ -1,5 +1,6 @@
 import { httpApp } from "@/plugins/axiosApp";
 import { store } from "@/store/store";
+import { AudioPlayTime } from "@/types/student-client";
 
 function resetExamQuestionDirty() {
   store.exam.examQuestionList = store.exam.examQuestionList.map((eq) => {
@@ -10,7 +11,7 @@ function resetExamQuestionDirty() {
 type Answer = {
   order: number;
   studentAnswer: string;
-  audioPlayTimes: { audioName: string; times: number }[];
+  audioPlayTimes: AudioPlayTime[];
   isSign: boolean;
 };
 
@@ -26,7 +27,9 @@ export async function answerAllQuestions(
           order: eq.order,
           studentAnswer: eq.studentAnswer,
         },
-        eq.audioPlayTimes && { audioPlayTimes: eq.audioPlayTimes },
+        eq.audioPlayTimes && {
+          audioPlayTimes: JSON.stringify(eq.audioPlayTimes) as any,
+        },
         eq.isSign && { isSign: eq.isSign }
       ) as Answer;
     });

+ 64 - 1
src/store/store.ts

@@ -1,5 +1,5 @@
 import { defineStore } from "pinia";
-import { Store } from "@/types/student-client";
+import { AudioPlayTime, Store } from "@/types/student-client";
 
 const initStore: Store = {
   user: {} as Store["user"],
@@ -51,6 +51,10 @@ export const useStore = defineStore("ecs", {
     getTimeStamp(): number {
       return Date.now() + store.sysTime.difference;
     },
+    /** 练习过程中是否显示答案 */
+    examShouldShowAnswer(): boolean {
+      return store.exam.practiceType === "IN_PRACTICE";
+    },
   },
   actions: {
     /** 增加当前的globalMaskCount */
@@ -67,6 +71,7 @@ export const useStore = defineStore("ecs", {
         store.globalMaskCount = 0;
       }
     },
+    /** 更新本机时间与服务器时间的差异 */
     updateTimeDifference(timeDiff: number) {
       store.sysTime.difference = timeDiff;
     },
@@ -90,6 +95,64 @@ export const useStore = defineStore("ecs", {
         store.exam.questionAnswerFileUrl[oldIndex] = payload;
       }
     },
+    /** 更新试题 */
+    updateExamQuestion({
+      order,
+      studentAnswer,
+      isSign,
+      audioPlayTimes,
+      getQuestionContent,
+    }: {
+      order: number;
+      studentAnswer?: string;
+      isSign?: boolean;
+      audioPlayTimes?: AudioPlayTime[];
+      getQuestionContent?: boolean;
+    }) {
+      store.exam.examQuestionList.map((eq) => {
+        if (eq.order == order) {
+          const upEq: typeof eq = {} as any;
+          // 仅设置getQuestionContent时,不更新dirty
+          if (getQuestionContent === undefined) {
+            upEq.dirty = true;
+          } else {
+            upEq.getQuestionContent = getQuestionContent;
+          }
+          if (studentAnswer !== undefined) {
+            upEq.studentAnswer = studentAnswer;
+          }
+
+          if (audioPlayTimes !== undefined) {
+            upEq.audioPlayTimes = audioPlayTimes;
+          }
+          if (isSign !== undefined) {
+            upEq.isSign = isSign;
+          }
+          return Object.assign(eq, upEq);
+        }
+        return eq;
+      });
+    },
+    /** 更新音频播放次数 */
+    updateQuestionAudioPlayTimes(payload: string) {
+      let alreayHas = false;
+      const allAudioPlayTimes = store.exam.allAudioPlayTimes.map((audio) => {
+        if (audio.audioName === payload) {
+          alreayHas = true;
+          const times = audio.times + 1;
+          return { audioName: payload, times: times };
+        }
+        return audio;
+      });
+
+      store.exam.allAudioPlayTimes = allAudioPlayTimes;
+
+      if (!alreayHas) {
+        allAudioPlayTimes.push({ audioName: payload, times: 1 });
+      }
+      store.exam.examQuestionList[0].audioPlayTimes = allAudioPlayTimes;
+      store.exam.examQuestionList[0].dirty = true;
+    },
   },
 });
 

+ 9 - 6
src/types/student-client.d.ts

@@ -171,7 +171,7 @@ export type Store = {
     currentQuestion: ExamQuestion;
     /** 试题过滤类型 */
     questionFilterType: "ALL" | "ANSWERED" | "SIGNED" | "UNANSWERED";
-    allAudioPlayTimes: { audioName: string; times: number }[];
+    allAudioPlayTimes: AudioPlayTime[];
     /** 某试题的二维码被扫码识别了 */
     questionQrCodeScanned: { order: number };
     /** 试题的文件作答地址 */
@@ -386,6 +386,7 @@ export type PaperStruct = {
   };
 };
 
+export type AudioPlayTime = { audioName: string; times: number };
 export type ExamQuestion = {
   /** 试题id */
   questionId: string;
@@ -397,7 +398,7 @@ export type ExamQuestion = {
   limitedPlayTimes: number;
   questionScore: number;
   /** 学生填写的答案,和C端、App端结构一致 */
-  studentAnswer: string;
+  studentAnswer: string | null;
   /** 试题类型 */
   questionType:
     | "SINGLE_CHOICE"
@@ -417,6 +418,8 @@ export type ExamQuestion = {
   mainNumber: number;
   /** 小题号 */
   subNumber: number;
+  /** 套题的body */
+  parentBody: string | null;
   /** 试题内容。重点要重构音频、填空题###的处理逻辑 */
   body: string;
   /** 是否为套题。此处要梳理数据结构,重新计算!!! */
@@ -425,10 +428,10 @@ export type ExamQuestion = {
   answerType: "SINGLE_AUDIO" | null;
   /** 练习时有正确答案 */
   rightAnswer?: string[];
-  /** 后端给的类型前端好像用不着,待确认??? */
-  questionOptionList: any;
+  /** 选择题的选项 */
+  questionOptionList: { body: string }[];
   /** 题目中是否有音频 */
-  hasAudio: boolean;
+  hasAudios: boolean;
   /** 试题内容。通过网络获取。 */
   questionContent: string;
   /** 试题内容是否已通过网络获取到。 TODO: 改名为 gotQuestionContent */
@@ -436,7 +439,7 @@ export type ExamQuestion = {
   /** 答案是否已经被用户更新过了 */
   dirty: boolean;
   /** 只有第一题有此数据,用来像服务器保存音频播放次数 */
-  audioPlayTimes: { audioName: string; times: number }[];
+  audioPlayTimes: AudioPlayTime[];
 };
 
 export type ExamInProgress = {