소스 검색

文本、音频、图片作答

Michael Wang 3 년 전
부모
커밋
a4dcdf4759

+ 3 - 1
package.json

@@ -32,8 +32,10 @@
     "tailwindcss": "^3.0.24",
     "ua-parser-js": "^1.0.2",
     "vfonts": "^0.0.3",
+    "viewerjs": "^1.10.5",
     "vue": "^3.2.32",
-    "vue-router": "^4.0.14"
+    "vue-router": "^4.0.14",
+    "vuedraggable": "4.1.0"
   },
   "devDependencies": {
     "@types/js-md5": "^0.4.3",

+ 21 - 0
pnpm-lock.yaml

@@ -37,12 +37,14 @@ specifiers:
   unplugin-auto-import: ^0.7.0
   unplugin-vue-components: ^0.19.2
   vfonts: ^0.0.3
+  viewerjs: ^1.10.5
   vite: ^2.9.1
   vitest: ^0.9.3
   vue: ^3.2.32
   vue-eslint-parser: ^8.3.0
   vue-router: ^4.0.14
   vue-tsc: ^0.34.6
+  vuedraggable: 4.1.0
 
 dependencies:
   '@chenfengyuan/vue-qrcode': 2.0.0_qrcode@1.5.0+vue@3.2.32
@@ -62,8 +64,10 @@ dependencies:
   tailwindcss: 3.0.24
   ua-parser-js: 1.0.2
   vfonts: 0.0.3
+  viewerjs: 1.10.5
   vue: 3.2.32
   vue-router: 4.0.14_vue@3.2.32
+  vuedraggable: 4.1.0_vue@3.2.32
 
 devDependencies:
   '@types/js-md5': 0.4.3
@@ -3068,6 +3072,10 @@ packages:
     engines: {node: '>=8'}
     dev: true
 
+  /sortablejs/1.14.0:
+    resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
+    dev: false
+
   /source-map-js/1.0.2:
     resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
     engines: {node: '>=0.10.0'}
@@ -3473,6 +3481,10 @@ packages:
     resolution: {integrity: sha512-nguyw8L6Un8eelg1vQ31vIU2ESxqid7EYmy8V+MDeMaHBqaRSkg3dTBToC1PR00D89UzS/SLkfYPnx0Wf23IQQ==}
     dev: false
 
+  /viewerjs/1.10.5:
+    resolution: {integrity: sha512-QwKrmXlSfKg5x4y74F/jicpHIRqBMMfHXyboOxHDi5n4XAaejjpalphPq4/HW6venQAoMiD57HpVwBk0JvqpSA==}
+    dev: false
+
   /vite/2.9.1:
     resolution: {integrity: sha512-vSlsSdOYGcYEJfkQ/NeLXgnRv5zZfpAsdztkIrs7AZHV8RCMZQkwjo4DS5BnrYTqoWqLoUe1Cah4aVO4oNNqCQ==}
     engines: {node: '>=12.2.0'}
@@ -3601,6 +3613,15 @@ packages:
       '@vue/shared': 3.2.32
     dev: false
 
+  /vuedraggable/4.1.0_vue@3.2.32:
+    resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
+    peerDependencies:
+      vue: ^3.0.1
+    dependencies:
+      sortablejs: 1.14.0
+      vue: 3.2.32
+    dev: false
+
   /vueuc/0.4.28_vue@3.2.32:
     resolution: {integrity: sha512-Udr1ROwJocHIThA5G+H5qN1QEFI4pskDvl+w/2Ul2XIjaAeIuQ6ygEOKHOXRJqKX5PxcTi1QQUpb7yQWsDw7ww==}
     peerDependencies:

+ 5 - 1
src/features/OnlineExam/ExamEnd/SubmitPaper.vue

@@ -20,7 +20,11 @@ async function realSubmitPaper() {
   store.spinMessage = "正在交卷中...";
   try {
     await httpApp.get("/api/ecs_oe_student/examControl/endExam", {
-      "axios-retry": { retries: 10, retryDelay: () => 10 * 1000 },
+      "axios-retry": {
+        retries: 10,
+        retryDelay: () => 10 * 1000,
+        shouldResetTimeout: true,
+      },
       noErrorMessage: true,
     });
     logger({

+ 4 - 68
src/features/OnlineExam/Examing/ExamingHome.vue

@@ -28,6 +28,10 @@ import { useRealSubmitPaper } from "./setups/useSubmitPaper";
 import { Store } from "@/types/student-client";
 import { closeMediaStream } from "@/utils/camera";
 
+// 清除过时考试数据
+store.exam = {} as Store["exam"];
+store.exam.compareResultMap = new Map();
+
 const { addTimeout, addInterval } = useTimers();
 
 let loading = $ref(true);
@@ -74,75 +78,7 @@ watch(
 //   document.body.classList.toggle("hide-body-scroll", false);
 // },
 
-// methods: {
-//   ...mapMutations([
-//     "updateExamQuestion",
-//     "updatePicture",
-//   ]),
-
-// watch: {
-//   questionAnswerFileUrl(value) {
-//     // console.log(this.examQuestion.studentAnswer);
-//     // console.log("watch", value);
-//     const examRecordDataId = examRecordDataId;
-//     const that = this;
-//     for (const q of value) {
-//       if (!q.saved) {
-//         let acknowledgeStatus = "CONFIRMED";
-
-//         // 目前只针对音频题有丢弃的可能
-//         if (
-//           q.transferFileType === "PIC" &&
-//           (q.order != this.$route.params.order || !this.uploadModalVisible)
-//         ) {
-//           acknowledgeStatus = "DISCARDED";
-//         }
-//         httpApp
-//           .post(
-//             "/api/ecs_oe_student/examControl/saveUploadedFileAcknowledgeStatus",
-//             {
-//               examRecordDataId,
-//               filePath: q.fileUrl,
-//               order: q.order,
-//               acknowledgeStatus,
-//             }
-//           )
-//           .then(() => {
-//             if (q.transferFileType === "AUDIO") {
-//               that.updateExamQuestion({
-//                 order: q.order,
-//                 studentAnswer: q.fileUrl,
-//               });
-//             } else if (
-//               acknowledgeStatus === "CONFIRMED" &&
-//               q.transferFileType === "PIC"
-//             ) {
-//               that.updatePicture(q);
-//             }
-//             q.saved = true;
-//             if (acknowledgeStatus === "CONFIRMED")
-//               this.$Message.info({
-//                 content: "小程序作答已更新",
-//                 duration: 5,
-//                 closable: true,
-//               });
-//           })
-//           .catch(() => {
-//             this.$Message.error({
-//               content: "更新小程序答案失败!",
-//               duration: 15,
-//               closable: true,
-//             });
-//           });
-//       }
-//     }
-//   },
-
 onMounted(async () => {
-  // 清除过时考试数据
-  store.exam = {} as Store["exam"];
-  store.exam.compareResultMap = new Map();
-
   logger({
     cnl: ["server", "local"],
     pgn: "答题页面",

+ 7 - 10
src/features/OnlineExam/Examing/FillBlankQuestionView.vue

@@ -8,16 +8,13 @@ function toggleShowAnswer() {
 }
 
 const examQuestion = $computed(() => store.exam.currentQuestion);
-const rightAnswerTransform = $computed(() => {
-  return (
-    examQuestion.rightAnswer &&
-    examQuestion.rightAnswer
-      .join("")
-      .split("##")
-      .map((v, i) => `${i + 1}、${v}<br>`)
-      .join("")
-  );
-});
+const rightAnswerTransform = $computed(() =>
+  examQuestion.rightAnswer
+    ?.join("")
+    .split("##")
+    .map((v, i) => `${i + 1}、${v}<br>`)
+    .join("")
+);
 
 const questionBody = $computed(() => {
   let len = 0;

+ 5 - 15
src/features/OnlineExam/Examing/QuestionViewSingle.vue

@@ -3,7 +3,7 @@ import { store } from "@/store/store";
 import ChoiceQuestionView from "./ChoiceQuestionView.vue";
 import BooleanQuestionView from "./BooleanQuestionView.vue";
 import FillBlankQuestionView from "./FillBlankQuestionView.vue";
-// import TextQuestionView from "./TextQuestionView";
+import TextQuestionView from "./TextQuestionView.vue";
 
 const examQuestion = $computed(() => store.exam.currentQuestion);
 </script>
@@ -31,20 +31,10 @@ const examQuestion = $computed(() => store.exam.currentQuestion);
         v-if="examQuestion.questionType === 'FILL_UP'"
         :key="examQuestion.order"
       />
-
-      <!--<template
-        v-if="
-          question &&
-          question.questionType === 'ESSAY' &&
-          examQuestion.questionType === 'ESSAY'
-        "
-      >
-        <text-question-view
-          :key="examQuestion.order"
-          :question="question"
-          :examQuestion="examQuestion"
-        />
-      </template> -->
+      <TextQuestionView
+        v-if="examQuestion.questionType === 'ESSAY'"
+        :key="examQuestion.order"
+      />
     </div>
   </transition>
 </template>

+ 1 - 2
src/features/OnlineExam/Examing/RemainTime.vue

@@ -63,7 +63,6 @@ onMounted(async () => {
 // 离开此页面时,可能还有心跳请求未返回
 onUnmounted(() => cancelHeartBeat && cancelHeartBeat());
 
-const CancelToken = axios.CancelToken;
 let cancelHeartBeat: Canceler;
 async function getRemainTimeFromServer() {
   if (store.exam.remainTime <= 0) {
@@ -80,7 +79,7 @@ async function getRemainTimeFromServer() {
           retryDelay: () => 10 * 1000,
         },
         noErrorMessage: true,
-        cancelToken: new CancelToken((c) => (cancelHeartBeat = c)),
+        cancelToken: new axios.CancelToken((c) => (cancelHeartBeat = c)),
       }
     );
     const rt: number = res.data;

+ 522 - 0
src/features/OnlineExam/Examing/TextQuestionView.vue

@@ -0,0 +1,522 @@
+<script setup lang="ts">
+import QuestionBody from "./QuestionBody.vue";
+import VueQrcode from "@chenfengyuan/vue-qrcode";
+import { store } from "@/store/store";
+import { onUnmounted, watch } from "vue";
+import { httpApp } from "@/plugins/axiosApp";
+import UploadPhotos from "./UploadPhotos.vue";
+import { Checkmark } from "@vicons/ionicons5";
+
+let isShowAnswer = $ref(false);
+function toggleShowAnswer() {
+  isShowAnswer = !isShowAnswer;
+}
+
+const examRecordDataId = store.exam.examRecordDataId;
+const order = store.exam.currentQuestion.order;
+const examQuestion = $computed(() => store.exam.currentQuestion);
+const rightAnswerTransform = $computed(() =>
+  examQuestion.rightAnswer?.join("")
+);
+
+const canAttachPhotos = $computed(
+  () => store.exam.WEIXIN_ANSWER_ENABLED && !isAudioAnswerType
+);
+
+const isAudioAnswerType = $computed(
+  () => store.exam.currentQuestion.answerType === "SINGLE_AUDIO"
+);
+
+const shouldFetchQrCode = $computed(() => store.exam.WEIXIN_ANSWER_ENABLED);
+
+let studentAnswer = $ref("");
+let originalStudentAnswer: string | null = $ref(null);
+
+watch(
+  () => store.exam.currentQuestion,
+  () => {
+    if (store.exam.currentQuestion) {
+      studentAnswer = store.exam.currentQuestion.studentAnswer || "";
+      originalStudentAnswer = store.exam.currentQuestion.studentAnswer;
+    }
+  },
+  { immediate: true }
+);
+
+watch(
+  () => studentAnswer,
+  () => {
+    let realAnswer = undefined;
+    if (studentAnswer) {
+      // 如果有实际内容
+      realAnswer = studentAnswer
+        .replace(/<sup><\/sup>/gi, "")
+        .replace(/<sub><\/sub>/gi, "")
+        .replace(/<script/gi, "&lt;script")
+        .replace(/script>/gi, "script&gt;");
+    }
+    if (realAnswer !== store.exam.currentQuestion.studentAnswer) {
+      store.updateExamQuestion({
+        order: examQuestion.order,
+        studentAnswer: realAnswer,
+      });
+    }
+  }
+);
+
+const answerDiv: HTMLDivElement = $ref();
+const answerWordCount = $computed(() => {
+  if (studentAnswer && answerDiv) {
+    return answerDiv.innerText.replace(/\s+/g, "").length;
+  } else {
+    const ele = document.createElement("div");
+    ele.innerHTML = studentAnswer;
+
+    return ele.innerText.replace(/\s+/g, "").length;
+  }
+});
+
+function disableCtrl(e: KeyboardEvent) {
+  if (e.ctrlKey || e.metaKey || e.altKey) {
+    // .ctrlKey tells that ctrl key was pressed.
+    e.preventDefault();
+    return false;
+  }
+  return true;
+}
+
+function textInput($event: Event) {
+  studentAnswer = (<HTMLDivElement>$event.target).innerHTML;
+}
+
+let copyNode: DocumentFragment;
+function textCopy() {
+  const selElm = getSelection();
+  if (!selElm) return;
+  var selRange = selElm.getRangeAt(0);
+  copyNode = selRange.cloneContents();
+}
+
+function textCut() {
+  const selElm = getSelection();
+  if (!selElm) return;
+  const selRange = selElm.getRangeAt(0);
+  copyNode = selRange.extractContents();
+  studentAnswer = answerDiv.innerHTML;
+}
+
+function textPaste() {
+  const selElm = getSelection();
+  if (!selElm) return;
+  const selRange = selElm.getRangeAt(0);
+  selRange.deleteContents();
+  selRange.insertNode(copyNode.cloneNode(true));
+  studentAnswer = answerDiv.innerHTML;
+}
+
+function textSup() {
+  const origHMTL = answerDiv.innerHTML + "";
+  getSelection()?.getRangeAt(0).surroundContents(document.createElement("sup"));
+  if (answerDiv.querySelector("sup sup, sub sub, sup sub, sub sup")) {
+    console.log("不允许多层上标下标");
+    answerDiv.innerHTML = origHMTL;
+  } else {
+    studentAnswer = answerDiv.innerHTML;
+  }
+}
+
+function undoTextSup() {
+  const selElm = getSelection();
+  if (!selElm) return;
+  // 取消上标时,必须选择之前的文字,才能实现
+  // @ts-expect-error 非web标准方法
+  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+  selElm.modify("extend", "left", "character");
+  let selRange = selElm.getRangeAt(0);
+  const documentFragment = selRange.extractContents();
+  if (documentFragment.textContent) {
+    const text = new Text(documentFragment.textContent);
+    selRange.insertNode(text);
+    studentAnswer = answerDiv.innerHTML;
+  }
+}
+
+function textSub() {
+  const origHMTL = answerDiv.innerHTML + "";
+  getSelection()?.getRangeAt(0).surroundContents(document.createElement("sub"));
+  if (answerDiv.querySelector("sup sup, sub sub, sup sub, sub sup")) {
+    console.log("不允许多层上标下标");
+    answerDiv.innerHTML = origHMTL;
+  } else {
+    studentAnswer = answerDiv.innerHTML;
+  }
+}
+
+let qrValue = $ref("");
+let qrScanned = $ref(false);
+
+let hasUnmounted = false;
+async function fetchQRCode() {
+  if (shouldFetchQrCode && !qrValue) {
+    const transferFileType =
+      examQuestion.answerType === "SINGLE_AUDIO" ? "AUDIO" : "PIC";
+
+    try {
+      const response = await httpApp.post<string>(
+        "/api/ecs_oe_student/examControl/getQrCode",
+        {
+          examRecordDataId,
+          order: examQuestion.order,
+          transferFileType,
+          testEnv: false,
+        },
+        {
+          "axios-retry": {
+            retries: 10,
+            retryDelay: () => 3 * 1000,
+            retryCondition: () => !hasUnmounted,
+          },
+          noErrorMessage: true,
+        }
+      );
+      let origin = window.location.origin;
+      if (import.meta.env.DEV) {
+        origin = import.meta.env.VITE_CONFIG_API_SERVER as string;
+      }
+      const toReplaceOrigin = new URL(response.data).origin;
+      qrValue = response.data.replace(toReplaceOrigin, origin);
+    } catch (error) {
+      logger({
+        cnl: ["server"],
+        pgu: "AUTO",
+        act: "获取简答题二维码出错",
+        possibleError: error,
+      });
+    }
+  }
+}
+onUnmounted(() => {
+  hasUnmounted = true;
+});
+
+watch(
+  () => store.exam.questionQrCodeScanned,
+  () => {
+    if (store.exam.questionQrCodeScanned.order === examQuestion.order) {
+      qrScanned = true;
+    }
+  }
+);
+
+let pictureAnswer: {
+  transferFileType: string;
+  order: number;
+  fileUrl: string;
+} = $ref();
+let uploadModalVisible = $ref(false);
+watch(
+  () => store.exam.questionAnswerFileUrl,
+  (value) => {
+    for (const q of value) {
+      if (!q?.saved) {
+        let acknowledgeStatus = "CONFIRMED";
+
+        // 目前只针对音频题有丢弃的可能
+        if (
+          q.transferFileType === "PIC" &&
+          (q.order != order || !uploadModalVisible)
+        ) {
+          acknowledgeStatus = "DISCARDED";
+        }
+        httpApp
+          .post(
+            "/api/ecs_oe_student/examControl/saveUploadedFileAcknowledgeStatus",
+            {
+              examRecordDataId,
+              filePath: q.fileUrl,
+              order: q.order,
+              acknowledgeStatus,
+            }
+          )
+          .then(() => {
+            if (q.transferFileType === "AUDIO") {
+              store.updateExamQuestion({
+                order: q.order,
+                studentAnswer: q.fileUrl,
+              });
+            } else if (
+              acknowledgeStatus === "CONFIRMED" &&
+              q.transferFileType === "PIC"
+            ) {
+              pictureAnswer = q;
+              console.log("first");
+            }
+            q.saved = true;
+            if (acknowledgeStatus === "CONFIRMED")
+              $message.info("小程序作答已更新");
+          })
+          .catch(() => {
+            $message.error("更新小程序答案失败!");
+          });
+      }
+    }
+  }
+);
+
+watch(
+  () => store.exam.currentQuestion?.order,
+  async () => {
+    if (!store.exam.WEIXIN_ANSWER_ENABLED) return;
+    if (store.exam.currentQuestion.questionType !== "ESSAY") return;
+
+    await fetchQRCode();
+  },
+  { immediate: true }
+);
+
+let photoAnswers: string[] = $computed({
+  get() {
+    if (!studentAnswer) return [];
+    const ele = document.createElement("div");
+    ele.innerHTML = studentAnswer;
+    const imgs = ele.querySelectorAll(
+      ".photo-answer"
+    ) as unknown as HTMLImageElement[];
+    return [...imgs].map((e) =>
+      e.src.replace("?x-oss-process=image/resize,m_lfit,h_200,w_200", "")
+    );
+  },
+  set(pSrcs) {
+    let imageStr = pSrcs.map(
+      (v) =>
+        `<a href='${v}' target='_blank' ><img class='photo-answer' src='${
+          v + "?x-oss-process=image/resize,m_lfit,h_200,w_200"
+        }' /></a>`
+    );
+    const ele = document.createElement("div");
+    ele.innerHTML = studentAnswer || "";
+    const pEle = ele.querySelectorAll(".photo-answers-block");
+    if (pEle) [...pEle].forEach((v) => v.remove());
+    // console.log(ele.innerHTML);
+
+    if (!ele.innerHTML && pSrcs.length === 0) {
+      // 完全为空则重置答案
+      studentAnswer = "";
+      // 更新answerDiv的内容
+      originalStudentAnswer = null;
+    } else {
+      studentAnswer =
+        ele.innerHTML +
+        `<div class='photo-answers-block'>${imageStr.join("")}</div>`;
+      // 更新answerDiv的内容
+      originalStudentAnswer = studentAnswer;
+    }
+  },
+});
+
+function photoRemoved(url: string) {
+  photoAnswers = photoAnswers.filter((v) => v !== url);
+}
+function photosReseted(urls: string[]) {
+  photoAnswers = [...urls];
+}
+</script>
+
+<template>
+  <div class="question-view">
+    <question-body :questionBody="examQuestion.body"></question-body>
+    <div class="ops">
+      <div class="score">({{ examQuestion.questionScore }}分)</div>
+    </div>
+    <div class="option">
+      <div v-if="!isAudioAnswerType">
+        <div class="menu">
+          <n-button
+            type="success"
+            class="text-ops"
+            size="small"
+            @click="textCopy"
+          >
+            复制
+          </n-button>
+          <n-button
+            type="success"
+            class="text-ops"
+            size="small"
+            @click="textCut"
+          >
+            剪切
+          </n-button>
+          <n-button
+            type="success"
+            class="text-ops"
+            size="small"
+            @click="textPaste"
+          >
+            粘贴
+          </n-button>
+          <n-button
+            type="success"
+            class="text-ops"
+            size="small"
+            @click="textSup"
+          >
+            上标
+          </n-button>
+          <n-button
+            type="success"
+            class="text-ops"
+            size="small"
+            @click="undoTextSup"
+          >
+            取消上标
+          </n-button>
+          <n-button
+            type="success"
+            class="text-ops"
+            size="small"
+            @click="textSub"
+          >
+            下标
+          </n-button>
+          <n-button
+            type="success"
+            class="text-ops"
+            size="small"
+            @click="undoTextSup"
+          >
+            取消下标
+          </n-button>
+        </div>
+        <div
+          ref="answerDiv"
+          ondragstart="return false"
+          ondrop="return false"
+          :contenteditable="true"
+          class="stu-answer"
+          @keydown="disableCtrl"
+          @input="($event) => textInput($event)"
+          @blur="($event) => textInput($event)"
+          v-html="originalStudentAnswer"
+        ></div>
+        <div
+          style="
+            margin-top: -25px;
+            margin-bottom: 25px;
+            width: 100%;
+            max-width: 500px;
+          "
+        >
+          <div style="float: right; margin-right: 10px">
+            {{ answerWordCount }}
+          </div>
+        </div>
+      </div>
+      <div v-if="shouldFetchQrCode && isAudioAnswerType">
+        <div>
+          <div v-if="qrValue" style="display: flex">
+            <VueQrcode
+              :value="qrValue"
+              :options="{ width: 200 }"
+              style="margin-left: -10px"
+            />
+            <div style="margin-top: 10px">
+              <div>
+                请使用<span style="font-weight: 900; color: #1e90ff">微信</span
+                >扫描二维码后,在微信小程序上{{
+                  isAudioAnswerType ? "录音" : "拍照"
+                }},并上传文件。
+              </div>
+              <div v-if="qrScanned" style="margin-top: 30px; font-size: 30px">
+                {{ examQuestion.studentAnswer ? "已上传" : "已扫描" }}
+                <n-icon :componet="Checkmark" />
+              </div>
+            </div>
+          </div>
+          <div v-else>正在获取二维码...</div>
+        </div>
+
+        <div
+          class="audio-answer audio-answer-line-height"
+          style="margin-top: 20px"
+        >
+          <span class="audio-answer-line-height">答案:</span>
+          <audio
+            v-if="examQuestion.studentAnswer"
+            class="audio-answer-line-height"
+            controls
+            controlsList="nodownload"
+            :src="examQuestion.studentAnswer"
+          />
+          <span v-else class="audio-answer-line-height">未上传文件</span>
+        </div>
+      </div>
+
+      <div v-if="canAttachPhotos" style="padding-top: 1px">
+        <UploadPhotos
+          v-model:uploadModalVisible="uploadModalVisible"
+          :defaultList="photoAnswers.map((v) => v)"
+          :qrValue="qrValue"
+          style="margin-top: 20px; width: 350px"
+          :pictureAnswer="pictureAnswer"
+          @onPhotoRemoved="photoRemoved"
+          @onPhotosReseted="photosReseted"
+        />
+      </div>
+      <div class="reset" style="padding-top: 20px">
+        <span v-if="store.examShouldShowAnswer">
+          <n-button type="success" @click="toggleShowAnswer">
+            显示答案
+          </n-button>
+        </span>
+        <div v-if="isShowAnswer">
+          正确答案:
+          <div class="right-answer-section" v-html="rightAnswerTransform"></div>
+        </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;
+}
+
+.text-ops {
+  margin: 0 5px 5px 0;
+}
+
+.stu-answer {
+  width: 100%;
+  max-width: 500px;
+  min-height: 300px;
+  border: 1px solid grey;
+  padding: 2px;
+}
+
+.audio-answer {
+  font-size: 24px;
+  line-height: 54px;
+}
+.audio-answer-line-height {
+  line-height: 54px;
+  vertical-align: text-bottom;
+}
+</style>
+<style>
+.photo-answers-block {
+  display: none;
+}
+</style>

+ 298 - 0
src/features/OnlineExam/Examing/UploadPhotos.vue

@@ -0,0 +1,298 @@
+<script setup lang="ts">
+import VueQrcode from "@chenfengyuan/vue-qrcode";
+import draggable from "vuedraggable";
+import "viewerjs/dist/viewer.css";
+import Viewer from "viewerjs";
+import { watch } from "vue";
+import { store } from "@/store/store";
+import { Eye, TrashOutline } from "@vicons/ionicons5";
+
+const props = defineProps<{
+  defaultList: string[];
+  qrValue: string;
+  pictureAnswer?: { order: number; fileUrl: string };
+  uploadModalVisible: boolean;
+}>();
+
+const emit = defineEmits<{
+  (e: "update:uploadModalVisible", v: boolean): void;
+  (e: "on-photos-reseted", v: string[]): void;
+  (e: "on-photo-removed", v: string): void;
+}>();
+
+let uploadList: string[] = $ref([]);
+
+watch(
+  () => props.defaultList,
+  () => (uploadList = [...props.defaultList])
+);
+
+let uploadModalVisible = $ref(false);
+let totalList: string[] = $ref([]);
+const order = store.exam.currentQuestion.order;
+
+let uploaded = $ref(false);
+let qrScanned = $ref(false);
+
+watch(
+  () => props.pictureAnswer,
+  (value) => {
+    uploaded = true;
+    if (value?.order === order) {
+      totalList.push(...[...new Set(value.fileUrl.split(","))]);
+    }
+  }
+);
+
+watch(
+  () => uploadModalVisible,
+  () => emit("update:uploadModalVisible", uploadModalVisible)
+);
+
+watch(
+  () => store.exam.questionQrCodeScanned,
+  () => {
+    if (store.exam.questionQrCodeScanned.order === order) {
+      qrScanned = true;
+    }
+  }
+);
+
+let drag = $ref(false);
+
+function handleView(imagesClass: string, index: number) {
+  const viewer = new Viewer(<HTMLElement>document.querySelector(imagesClass), {
+    container: "#app",
+    zIndex: 99999,
+    title: false,
+    toolbar: {
+      zoomIn: 1,
+      zoomOut: 1,
+      oneToOne: 1,
+      reset: 1,
+      prev: 1,
+      play: { show: 0, size: "large" },
+      next: 1,
+      rotateLeft: 1,
+      rotateRight: 1,
+      flipHorizontal: 1,
+      flipVertical: 1,
+    },
+    ready() {
+      viewer.view(index);
+    },
+    hidden() {
+      viewer.destroy();
+    },
+  });
+  viewer.show();
+}
+function handleRemove(file: string) {
+  emit("on-photo-removed", file);
+}
+function handleRemoveTotal(file: string) {
+  totalList.splice(totalList.indexOf(file), 1);
+}
+function modalCloseClicked() {
+  // 在二维码被扫描,文件没得到之前,提示是否关闭。
+  if (qrScanned && !uploaded) {
+    $dialog.warning({
+      title: "确认关闭?",
+      content:
+        "检测到二维码被扫描,但是图片未上传。关闭此窗口后,将不再接受小程序图片上传。",
+      positiveText: "确定关闭",
+      negativeText: "取消关闭",
+      onPositiveClick: () => (uploadModalVisible = false),
+      onNegativeClick: () => undefined,
+    });
+    return;
+  } else if (totalList.length > 6) {
+    // 检查是否超过6张
+    $dialog.warning({
+      title: "图片数量超出限制",
+      content: "请删除多余的图片,图片总数不应该超过6张",
+    });
+    return;
+  }
+  uploadModalVisible = false;
+  emit("on-photos-reseted", totalList);
+}
+function uploadListSort() {
+  emit("on-photos-reseted", uploadList);
+}
+function dragMove(e: any) {
+  // console.log(e);
+  // 第三方组件没定义
+  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+  return !e.dragged.className.includes("plus");
+}
+function prepareUpload() {
+  uploadModalVisible = true;
+  qrScanned = false;
+  uploaded = false;
+  totalList = [...uploadList];
+}
+</script>
+
+<template>
+  <div>
+    <draggable
+      v-model="uploadList"
+      :move="dragMove"
+      class="upload-images"
+      itemKey="index"
+      @update="uploadListSort"
+    >
+      <template #item="{ element: item, index }">
+        <div class="demo-upload-list">
+          <img :src="item" />
+          <div class="demo-upload-list-cover">
+            <n-icon
+              :component="Eye"
+              size="30"
+              @click="handleView('.upload-images', index)"
+            />
+            <n-icon
+              :component="TrashOutline"
+              size="20"
+              style="position: absolute; top: 1px; right: 0px"
+              @click="handleRemove(item)"
+            />
+          </div>
+        </div>
+      </template>
+      <template #footer>
+        <div
+          v-if="uploadList.length < 6"
+          class="demo-upload-list plus"
+          @click="prepareUpload"
+        >
+          <span class="tw-inline-block" style="scale: 4">+</span>
+        </div>
+      </template>
+    </draggable>
+
+    <div>
+      点击
+      <span style="color: blue; font-size: 24px">+</span>
+      上传图片(最多上传6张图片)
+    </div>
+
+    <n-modal
+      :show="uploadModalVisible"
+      title="上传图片"
+      :maskClosable="false"
+      preset="card"
+      style="width: 800px"
+      @close="modalCloseClicked"
+    >
+      <div>
+        <div v-if="qrValue" style="display: flex">
+          <VueQrcode
+            :value="qrValue"
+            :options="{ width: 200 }"
+            :style="{
+              'margin-left': '-10px',
+              filter: totalList.length >= 6 ? 'blur(10px)' : 'none',
+            }"
+          />
+          <div style="font-size: 24px; margin-top: 10px">
+            <div>
+              请使用<span style="font-weight: 900; color: #1e90ff">微信</span
+              >扫描二维码后,在微信小程序上拍照,并上传文件。<br />
+              上传期间,请勿关闭二维码。
+            </div>
+            <div v-if="qrScanned" style="margin-top: 30px; font-size: 30px">
+              {{ uploaded ? "已上传" : "已扫描" }}
+              <n-icon type="md-checkmark" />
+            </div>
+          </div>
+        </div>
+        <div v-else>正在获取二维码...</div>
+
+        <draggable
+          v-model="totalList"
+          class="total-images"
+          itemKey="index"
+          @start="drag = true"
+          @end="drag = false"
+        >
+          <template #item="{ element: item, index }">
+            <div class="demo-upload-list">
+              <img :src="item" />
+              <div class="demo-upload-list-cover">
+                <n-icon
+                  :component="Eye"
+                  size="30"
+                  @click="handleView('.total-images', index)"
+                />
+                <n-icon
+                  :component="TrashOutline"
+                  size="20"
+                  style="position: absolute; top: 1px; right: 0px"
+                  @click="handleRemoveTotal(item)"
+                />
+              </div>
+            </div>
+          </template>
+        </draggable>
+
+        <div v-if="totalList.length > 6">* 图片上传最多只支持6张</div>
+        <div style="display: flex; justify-content: center">
+          <n-button type="success" size="large" @click="modalCloseClicked">
+            确认
+          </n-button>
+        </div>
+      </div>
+    </n-modal>
+  </div>
+</template>
+
+<style scoped>
+.demo-upload-list {
+  display: inline-block;
+  width: 100px;
+  height: 100px;
+  text-align: center;
+  line-height: 100px;
+  border: 1px solid transparent;
+  border-radius: 4px;
+  overflow: hidden;
+  background: #fff;
+  position: relative;
+  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
+  margin-right: 4px;
+
+  cursor: move;
+}
+.demo-upload-list img {
+  width: 100%;
+  height: 100%;
+}
+.plus {
+  font-size: 12px;
+  box-shadow: 0 0 1px 1px var(--app-color-text-light);
+}
+.plus:hover {
+  cursor: pointer;
+  color: blueviolet;
+}
+.demo-upload-list-cover {
+  display: none;
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: rgba(0, 0, 0, 0.6);
+}
+.demo-upload-list:hover .demo-upload-list-cover {
+  display: block;
+}
+.demo-upload-list-cover i {
+  color: #fff;
+  font-size: 20px;
+  cursor: pointer;
+  margin: 0 2px;
+}
+</style>

+ 0 - 11
src/features/OnlineExam/Examing/setups/useInitExamData.ts

@@ -164,15 +164,4 @@ export async function initExamData(examId: number, examRecordDataId: number) {
     JSON.parse(
       store.exam.examQuestionList[0].audioPlayTimes as unknown as string
     ) || [];
-
-  // this.updateExamState({
-  //   exam: exam,
-  //   paperStruct: paperStruct,
-  //   examQuestionList: examQuestionList,
-  //   allAudioPlayTimes: JSON.parse(examQuestionList[0].audioPlayTimes) || [],
-  //   questionAnswerFileUrl: [],
-  //   pictureAnswer: {},
-  // });
-  // console.log(examQuestionList);
-  // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
 }

+ 1 - 1
src/features/OnlineExam/Examing/setups/useWXSocket.ts

@@ -52,7 +52,7 @@ function onAudioAnswer(event: MessageEvent<string>) {
       store.setQuestionQrCodeScanned({ order: res.data.order });
       break;
     case "GET_FILE_ANSWER":
-      logger({ cnl: ["server"], act: "获得音频地址" });
+      logger({ cnl: ["server"], act: "获得音频/图片地址" });
       store.setQuestionFileAnswerUrl(res.data);
       break;
     case "SYSTEM_ERROR":

+ 2 - 3
src/features/OnlineExam/OnlineExamHome.vue

@@ -53,9 +53,8 @@ async function getData() {
             return false;
           }
         },
-        retryDelay() {
-          return 10 * 1000;
-        },
+        retryDelay: () => 10 * 1000,
+        shouldResetTimeout: true,
       },
     });
     tried = 5;

+ 1 - 0
src/features/UserLogin/useExamInProgress.ts

@@ -32,6 +32,7 @@ export async function checkExamInProgress(): Promise<boolean> {
                 return false;
               }
             },
+            shouldResetTimeout: true,
           },
         }
       );

+ 3 - 0
src/store/store.ts

@@ -86,6 +86,9 @@ export const useStore = defineStore("ecs", {
       fileUrl: string;
       transferFileType: string;
     }) {
+      // 先清理之前保存过的记录
+      store.exam.questionAnswerFileUrl =
+        store.exam.questionAnswerFileUrl.filter((v) => !v.saved);
       const oldIndex = store.exam.questionAnswerFileUrl.findIndex(
         (v) => v.order === payload.order
       );

+ 3 - 34
src/types/student-client.d.ts

@@ -152,6 +152,7 @@ export type Store = {
     isDoingFaceLiveness: boolean;
     /** 抓拍间隔(秒) */
     SNAPSHOT_INTERVAL: number;
+    /** 人脸定时比对的结果集合 */
     compareResultMap: Map<string, boolean>;
     /** 考试冻结(秒) */
     freezeTime: number;
@@ -171,6 +172,7 @@ export type Store = {
     currentQuestion: ExamQuestion;
     /** 试题过滤类型 */
     questionFilterType: "ALL" | "ANSWERED" | "SIGNED" | "UNANSWERED";
+    /** 为方便访问将[0].audioPlayTimes提出来,并parse */
     allAudioPlayTimes: AudioPlayTime[];
     /** 某试题的二维码被扫码识别了 */
     questionQrCodeScanned: { order: number };
@@ -179,6 +181,7 @@ export type Store = {
       order: number;
       fileUrl: string;
       transferFileType: string;
+      saved?: boolean;
     }[];
     /** 是否超过了切屏次数 */
     isExceededSwitchCount: boolean;
@@ -334,26 +337,6 @@ export type OnlinePracticeRecordResult = {
   }>;
 };
 
-// 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 }>;
@@ -454,20 +437,6 @@ export type ExamInProgress = {
   examId: number;
   examRecordDataId: number;
 };
-// export const REMOTE_APPS = [
-//   ["qq", "QQ"],
-//   ["teamviewer", "TeamViewer"],
-//   ["lookmypc", "LookMyPC"],
-//   ["xt", "协通"],
-//   ["winaw32", "Symantec PCAnywhere"],
-//   ["pcaquickconnect", "Symantec PCAnywhere"],
-//   ["sessioncontroller", "Symantec PCAnywhere"],
-//   [/sunloginclient/gi, "向日葵"],
-//   [/sunloginremote/gi, "向日葵"],
-//   [/选择免安装运行,截图识别/gi, "向日葵"],
-//   ["wemeetapp", "腾讯会议"],
-//   ["wechat", "微信"],
-// ] as const;
 
 // define process.env