Explorar o código

Merge branch 'master' into hotfix_release_v1.3.7

chenhao %!s(int64=2) %!d(string=hai) anos
pai
achega
c1ebed1484

+ 1 - 1
.eslintrc.js

@@ -67,7 +67,7 @@ module.exports = {
           // Script parser for vue directives (e.g. `v-if=` or `:attribute=`)
           // and vue interpolations (e.g. `{{variable}}`).
           // If not specified, the parser determined by `<script lang ="...">` is used.
-          "<template>": "espree",
+          "<template>": "@typescript-eslint/parser",
         },
         project: ["tsconfig.json"],
       },

+ 12 - 12
package.json

@@ -15,7 +15,7 @@
   },
   "dependencies": {
     "@ant-design/icons-vue": "^6.1.0",
-    "ant-design-vue": "^3.2.6",
+    "ant-design-vue": "^3.2.7",
     "axios": "^0.26.1",
     "axios-progress-bar": "^1.2.0",
     "axios-retry": "^3.2.5",
@@ -24,31 +24,31 @@
     "mitt": "^3.0.0",
     "moment": "^2.29.3",
     "pinia": "^2.0.14",
-    "tailwindcss": "^3.0.24",
+    "tailwindcss": "^3.1.4",
     "ua-parser-js": "^1.0.2",
     "viewerjs": "^1.10.5",
     "vue": "^3.2.37",
-    "vue-router": "^4.0.15"
+    "vue-router": "^4.0.16"
   },
   "devDependencies": {
     "@types/lodash-es": "^4.17.6",
-    "@types/node": "^17.0.41",
+    "@types/node": "^18.0.0",
     "@types/ua-parser-js": "^0.7.36",
-    "@typescript-eslint/eslint-plugin": "^5.27.1",
-    "@typescript-eslint/parser": "^5.27.1",
+    "@typescript-eslint/eslint-plugin": "^5.29.0",
+    "@typescript-eslint/parser": "^5.29.0",
     "@vitejs/plugin-vue": "^2.3.3",
     "@vue/runtime-core": "^3.2.37",
     "autoprefixer": "^10.4.7",
-    "eslint": "^8.17.0",
+    "eslint": "^8.18.0",
     "eslint-config-prettier": "^8.5.0",
-    "eslint-plugin-vue": "^9.1.0",
+    "eslint-plugin-vue": "^9.1.1",
     "postcss": "^8.4.14",
-    "prettier": "^2.6.2",
-    "typescript": "^4.7.3",
+    "prettier": "^2.7.1",
+    "typescript": "^4.7.4",
     "unplugin-vue-components": "^0.19.6",
-    "vite": "^2.9.10",
+    "vite": "^2.9.12",
     "vue-eslint-parser": "^9.0.2",
-    "vue-tsc": "^0.37.3"
+    "vue-tsc": "^0.37.9"
   },
   "vetur": {
     "tags": "vetur/tags.json",

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 232 - 216
pnpm-lock.yaml


+ 6 - 5
src/api/jsonMark.ts

@@ -1,6 +1,5 @@
 import { httpApp } from "@/plugins/axiosApp";
-import {
-  RichTextQuestion,
+import type {
   MarkStore,
   StudentAnswer,
   OExamPaperJSON,
@@ -31,7 +30,9 @@ export async function getPaper(store: MarkStore) {
         body: q.body,
         parentBody: q.parentBody,
         answer: [q.answer],
-      } as RichTextQuestion;
+        objective: null,
+        options: null,
+      };
       store.setting.subject.questions.push(tempQuestion);
     }
   } else {
@@ -48,7 +49,7 @@ export async function getPaper(store: MarkStore) {
               answer: order3.answer,
               objective: order3.objective,
               options: order3.options,
-            } as RichTextQuestion;
+            };
             store.setting.subject.questions.push(tempQuestion);
           }
         } else {
@@ -59,7 +60,7 @@ export async function getPaper(store: MarkStore) {
             answer: order2.answer,
             objective: order2.objective,
             options: order2.options,
-          } as RichTextQuestion;
+          };
           store.setting.subject.questions.push(tempQuestion);
         }
       }

+ 0 - 2
src/components/CommonMarkHeader.vue

@@ -48,7 +48,6 @@
     </div>
 
     <div class="tw-flex-1"></div>
-    <ZoomPaper v-if="store.isScanImage" />
     <a-popover
       v-if="store.isScanImage"
       title="小助手"
@@ -163,7 +162,6 @@ import {
   PoweroffOutlined,
   DownOutlined,
 } from "@ant-design/icons-vue";
-import ZoomPaper from "@/components/ZoomPaper.vue";
 import { AxiosResponse } from "axios";
 
 const {

+ 9 - 3
src/components/ZoomPaper.vue

@@ -1,6 +1,7 @@
 <template>
   <div
     class="tw-flex tw-flex-col tw-gap-2 zoom-container tw-place-content-center"
+    :class="{ 'tw-fixed': props.fixed }"
   >
     <RotateRightOutlined
       v-if="props.showRotate"
@@ -44,7 +45,7 @@ import {
 import { computed, onMounted, onUnmounted } from "vue";
 import { store } from "@/store/store";
 
-const props = defineProps<{ showRotate?: boolean }>();
+const props = defineProps<{ showRotate?: boolean; fixed?: boolean }>();
 defineEmits(["rotateRight"]);
 
 const upScale = () => {
@@ -85,15 +86,20 @@ onUnmounted(() => {
 <style scoped>
 .zoom-container {
   z-index: 1001;
-  position: fixed;
+  position: sticky;
   background-color: rgba(0, 0, 0, 0.9);
   bottom: 10px;
-  right: 320px;
+  left: calc(100% - 30px);
   width: 40px;
   /* height: 100px; */
   padding: 10px 0;
   border-radius: 10px;
 }
+.zoom-container.tw-fixed {
+  position: fixed;
+  right: 30px;
+  left: unset;
+}
 .icon-font-size-20 {
   font-size: 24px;
 }

+ 23 - 11
src/features/admin/confirmPaper/ConfirmPaper.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="tw-h-screen">
+  <div v-if="!dataError" class="tw-h-screen">
     <header
       class="tw-flex tw-gap-2 tw-justify-between tw-items-center header-container"
     >
@@ -149,17 +149,17 @@
             :src="item"
             :style="{
               display: index === currentImage ? 'block' : 'none',
-              transform: `rotate(${rotateDegree}deg)`,
-              translate: rotateDegree ? '0 calc(30vh)' : '',
+              transform:
+                (rotateDegree ? 'translate( 0,  calc(30vh))' : '') +
+                `rotate(${rotateDegree}deg)`,
             }"
             @click="switchImage"
             @contextmenu="showBigImage"
           />
         </div>
+        <ZoomPaper v-if="student" fixed showRotate @rotateRight="rotateRight" />
       </div>
     </div>
-
-    <ZoomPaper v-if="student" showRotate @rotateRight="rotateRight" />
   </div>
 </template>
 
@@ -179,7 +179,8 @@ import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons-vue";
 const { addTimeout } = useTimers();
 
 const route = useRoute();
-const checkType = route.query.checkType;
+// 使用 location.search 替代
+// const checkType = route.query.checkType;
 const queryId = route.query.queryId as string;
 let pageType: "DATA_CHECK" | "HAND_CHECK" = "HAND_CHECK";
 if (queryId) {
@@ -213,6 +214,8 @@ let currentStudentId = $ref(-1);
 const currentIndex = $computed(() => allIds.indexOf(currentStudentId) + 1);
 
 let student: StudentInfo | null = $ref(null);
+/** 后台数据错误,停止整个页面的流程 */
+let dataError = $ref(false);
 
 const answersComputed = $computed(() => {
   let mains = student?.answers.map((v) => ({
@@ -249,9 +252,11 @@ async function getSetting() {
     }
     res = await httpApp.post("/admin/exam/check/answer/getSetting", form);
   } else {
-    res = await httpApp.get(
-      `/admin/exam/check/student/getSetting?checkType=${checkType}`
-    );
+    const form = new FormData();
+    form.append("checkType", route.query.checkType as string);
+    form.append("subjectCode", route.query.subjectCode as string);
+    form.append("examSite", route.query.examSite as string);
+    res = await httpApp.post(`/admin/exam/check/student/getSetting`, form);
   }
 
   setting.fileServer = res.data.fileServer;
@@ -278,6 +283,13 @@ async function getStudent(studentId: number) {
   currentStudentId = stu.id;
   currentImage = 0;
 
+  if (!stu.success) {
+    void message.error(stu.message, 24 * 60 * 60);
+    dataError = true;
+
+    throw new Error("取学生信息出错: " + stu.message);
+  }
+
   // for dev
   // stu.answers = [
   //   { mainNumber: 1, subNumber: "1", answer: "A" },
@@ -408,9 +420,9 @@ const answerPaperScale = $computed(() => {
   // 放大、缩小不影响页面之前的滚动条定位
   let percentWidth = 0;
   let percentTop = 0;
-  const container = document.querySelector(
+  const container = document.querySelector<HTMLDivElement>(
     ".mark-body-container"
-  ) as HTMLDivElement;
+  );
   if (container) {
     const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
     percentWidth = scrollLeft / scrollWidth;

+ 2 - 0
src/features/admin/confirmPaper/check.d.ts

@@ -5,6 +5,8 @@ export type CheckSetting = {
 };
 
 export type StudentInfo = {
+  success: boolean;
+  message?: string;
   id: number;
   name: string;
   examNumber: string;

+ 35 - 1
src/features/arbitrate/Arbitrate.vue

@@ -28,7 +28,7 @@
 </template>
 
 <script setup lang="ts">
-import { onMounted, watch } from "vue";
+import { onMounted, watch, h } from "vue";
 import { store } from "@/store/store";
 import MarkHeader from "./MarkHeader.vue";
 import { useRoute } from "vue-router";
@@ -53,6 +53,8 @@ import { getPaper } from "@/api/jsonMark";
 import { getArbitrateHistory } from "@/api/arbitratePage";
 import EventBus from "@/plugins/eventBus";
 import { addFileServerPrefixToTask } from "@/utils/utils";
+import { isNumber } from "lodash-es";
+import type { Question } from "@/types";
 
 const route = useRoute();
 let isSingleStudent = !!route.query.historyId;
@@ -158,9 +160,41 @@ async function getOneOfStuTask() {
 
 const saveTaskToServer = async (unselective: boolean) => {
   if (!store.currentTask) return;
+  const markResult = store.currentTask.markResult;
+  if (!markResult) return;
   console.log("save inspect task to server");
   const mkey = "save_task_key";
   void message.loading({ content: "保存评卷任务...", key: mkey });
+  type SubmitError = {
+    question: Question;
+    index: number;
+    error: string;
+  };
+  const errors: SubmitError[] = [];
+  markResult.scoreList.forEach((score, index) => {
+    if (!store.currentTask) return;
+    const question = store.currentTask.questionList[index];
+    let error;
+    if (!isNumber(score)) {
+      error = `${question.mainNumber}-${question.subNumber} 没有赋分不能提交。`;
+    } else if (isNumber(question.maxScore) && score > question.maxScore) {
+      error = `${question.mainNumber}-${question.subNumber} 赋分大于最高分不能提交。`;
+    } else if (isNumber(question.minScore) && score < question.minScore) {
+      error = `${question.mainNumber}-${question.subNumber} 赋分小于最低分不能提交。`;
+    }
+    if (error) {
+      errors.push({ question, index, error });
+    }
+  });
+  if (errors.length) {
+    console.log(errors);
+    const msg = errors.map((v) => h("div", `${v.error}`));
+    return message.warning({
+      content: h("span", ["校验失败", ...msg]),
+      duration: 10,
+      key: mkey,
+    });
+  }
   let res;
   if (unselective) {
     res = await saveArbitrateTask(

+ 36 - 29
src/features/mark/CommonMarkBody.vue

@@ -1,7 +1,7 @@
 <template>
   <div
     ref="dragContainer"
-    class="mark-body-container tw-flex-auto tw-p-2 tw-relative"
+    class="mark-body-container tw-flex-auto tw-p-2 tw-pt-0 tw-relative"
   >
     <div
       v-if="!store.currentTask"
@@ -13,7 +13,7 @@
     <div
       v-else-if="store.isScanImage"
       :style="{ width: answerPaperScale }"
-      :class="[`rotate-board-${rotateBoard}`]"
+      :class="['tw-pt-2', `rotate-board-${rotateBoard}`]"
     >
       <div
         v-for="(item, index) in sliceImagesWithTrackList"
@@ -45,7 +45,7 @@
       {{ markStatus }}
       <div class="double-triangle"></div>
     </div>
-    <ZoomPaper v-if="store.isScanImage && store.currentTask" />
+    <ZoomPaper v-if="store.isScanImage && store.currentTask && sliceImagesWithTrackList.length" />
 
     <!-- 非启用功能 -->
     <div class="kb-circle tw-hidden">
@@ -121,9 +121,9 @@ const { addTimeout } = useTimers();
 watch(
   () => [store.minimapScrollToX, store.minimapScrollToY],
   () => {
-    const container = document.querySelector(
+    const container = document.querySelector<HTMLDivElement>(
       ".mark-body-container"
-    ) as HTMLDivElement;
+    );
     addTimeout(() => {
       if (
         container &&
@@ -144,9 +144,12 @@ watch(
 
 //#region : 快捷键定位
 const scrollContainerByKey = (e: KeyboardEvent) => {
-  const container = document.querySelector(
+  const container = document.querySelector<HTMLDivElement>(
     ".mark-body-container"
-  ) as HTMLDivElement;
+  );
+  if (!container) {
+    return;
+  }
   if (e.key === "w") {
     container.scrollBy({ top: -100, behavior: "smooth" });
   } else if (e.key === "s") {
@@ -216,7 +219,7 @@ async function processSliceConfig() {
     ? markResult.specialTagList ?? []
     : store.currentTask.specialTagList ?? [];
 
-  const tempSliceImagesWithTrackList = [] as Array<SliceImage>;
+  const tempSliceImagesWithTrackList: Array<SliceImage> = [];
   for (const sliceConfig of store.currentTask.sliceConfig) {
     accumBottomHeight += sliceConfig.h;
     const url = store.currentTask.sliceUrls[sliceConfig.i - 1];
@@ -308,14 +311,20 @@ async function processSplitConfig() {
     images.push(image);
   }
 
-  // 裁切块,可能是一块,两块,三块... [start, width ...] => [0, 0.3] | [0, 0.55, 0.45, 0.55] | [0, 0.35, 0.33, 0.35, 0.66, 0.35]
   // 如果拒绝裁切,则保持整卷
   if (!store.setting.enableSplit) {
     store.setting.splitConfig = [0, 1];
   }
-  const splitConfigPairs = store.setting.splitConfig
-    .map((v, index, ary) => (index % 2 === 0 ? [v, ary[index + 1]] : false))
-    .filter((v) => v) as unknown as Array<[number, number]>;
+  // 裁切块,可能是一块,两块,三块... [start, width ...] => [0, 0.3] | [0, 0.55, 0.45, 0.55] | [0, 0.35, 0.33, 0.35, 0.66, 0.35]
+  // 要转变为 [[0, 0.3]] | [[0, 0.55], [0.45, 0.55]] | [[0, 0.35], [0.33, 0.35], [0.66, 0.35]]
+  const splitConfigPairs = store.setting.splitConfig.reduce<[number, number][]>(
+    (a, v, index) => {
+      // 偶数位组成数组的第一位,奇数位组成数组的第二位
+      index % 2 === 0 ? a.push([v, -1]) : (a.at(-1)![1] = v);
+      return a;
+    },
+    []
+  );
 
   // 最大的 splitConfig 的宽度
   const maxSplitConfig = Math.max(
@@ -345,7 +354,7 @@ async function processSplitConfig() {
 
   let accumTopHeight = 0;
   let accumBottomHeight = 0;
-  const tempSliceImagesWithTrackList = [] as Array<SliceImage>;
+  const tempSliceImagesWithTrackList: SliceImage[] = [];
   const trackLists = hasMarkResultToRender
     ? markResult.trackList
     : (store.currentTask.questionList || []).map((q) => q.trackList).flat();
@@ -674,22 +683,21 @@ onUnmounted(() => {
 // }, 1000);
 
 //#region autoScroll自动跳转
-let oldFirstScoreContainer: HTMLDivElement;
+let oldFirstScoreContainer: HTMLDivElement | null;
 watch(
   () => store.currentTask,
   () => {
     if (store.setting.autoScroll) {
       // 给任务清理和动画留一点时间
-      oldFirstScoreContainer = document.querySelector(
-        ".score-container"
-      ) as HTMLDivElement;
+      oldFirstScoreContainer =
+        document.querySelector<HTMLDivElement>(".score-container");
       oldFirstScoreContainer?.scrollIntoView({ behavior: "smooth" });
       addTimeout(scrollToFirstScore, 1000);
     } else {
-      const container = document.querySelector(
+      const container = document.querySelector<HTMLDivElement>(
         ".mark-body-container"
-      ) as HTMLDivElement;
-      container.scrollTo({ top: 0, left: 0, behavior: "smooth" });
+      );
+      container?.scrollTo({ top: 0, left: 0, behavior: "smooth" });
     }
   }
 );
@@ -698,12 +706,9 @@ function scrollToFirstScore() {
     window.requestAnimationFrame(scrollToFirstScore);
   }
   addTimeout(() => {
-    let firstScore = document.querySelector(
-      ".score-container"
-    ) as HTMLDivElement;
-    if (firstScore) {
-      firstScore?.scrollIntoView({ behavior: "smooth" });
-    }
+    const firstScore =
+      document.querySelector<HTMLDivElement>(".score-container");
+    firstScore?.scrollIntoView({ behavior: "smooth" });
   }, 1000);
 }
 //#endregion
@@ -750,9 +755,11 @@ function scrollToFirstScore() {
   border: 2px solid transparent;
 }
 .status-container {
-  position: fixed;
-  top: 56px;
-  right: 340px;
+  position: sticky;
+  /* top: 56px; */
+  bottom: calc(100% - 50px);
+  /* right: 340px; */
+  left: calc(100% - 20px);
   color: white;
   pointer-events: none;
   font-size: var(--app-title-font-size);

+ 8 - 3
src/features/mark/Mark.vue

@@ -71,7 +71,7 @@ import MarkBody from "./MarkBody.vue";
 import { useTimers } from "@/setups/useTimers";
 import MarkHistory from "./MarkHistory.vue";
 import MarkBoardTrack from "./MarkBoardTrack.vue";
-import type { Setting, Question } from "@/types";
+import type { Question } from "@/types";
 import MarkBoardKeyBoard from "./MarkBoardKeyBoard.vue";
 import MarkBoardMouse from "./MarkBoardMouse.vue";
 import { debounce, isEmpty, isNumber } from "lodash-es";
@@ -122,7 +122,12 @@ async function updateSetting() {
       "score.board.collapse": false,
       "normal.mode": "keyboard",
       "score.fontSize.scale": 1,
-    } as Setting["uiSetting"];
+      "paper.modal": false,
+      "answer.modal": false,
+      "minimap.modal": false,
+      "specialTag.modal": false,
+      "shortCut.modal": false,
+    };
   }
   store.setting = settingRes.data;
   if (store.setting.subject?.answerUrl) {
@@ -308,7 +313,7 @@ const saveTaskToServer = async () => {
     error: string;
   };
 
-  const errors = [] as Array<SubmitError>;
+  const errors: SubmitError[] = [];
   markResult.scoreList.forEach((score, index) => {
     if (!store.currentTask) return;
     const question = store.currentTask.questionList[index]!;

+ 7 - 5
src/features/mark/MarkBoardTrack.vue

@@ -316,16 +316,18 @@ function numberKeyListener(event: KeyboardEvent) {
   }
 
   function indexOfCurrentQuestion() {
-    return store.currentTask?.questionList.findIndex(
-      (q) =>
-        q.mainNumber === store.currentQuestion?.mainNumber &&
-        q.subNumber === store.currentQuestion.subNumber
+    return (
+      store.currentTask?.questionList.findIndex(
+        (q) =>
+          q.mainNumber === store.currentQuestion?.mainNumber &&
+          q.subNumber === store.currentQuestion.subNumber
+      ) ?? -1
     );
   }
 
   // tab 循环答题列表
   if (event.key === "Tab") {
-    const idx = indexOfCurrentQuestion() as number;
+    const idx = indexOfCurrentQuestion();
     if (idx >= 0 && store.currentTask) {
       const len = store.currentTask.questionList.length;
       chooseQuestion(store.currentTask.questionList[(idx + 1) % len]);

+ 2 - 2
src/features/mark/MarkBoardTrackDialog.vue

@@ -3,8 +3,8 @@
     v-if="store.isScoreBoardCollapsed"
     title="给分板"
     top="10%"
-    width="290px"
-    fixed-width
+    width="300px"
+    fixedWidth
     height="400px"
     @close="close"
   >

+ 5 - 2
src/features/mark/MarkDrawTrack.vue

@@ -28,7 +28,7 @@
 
 <script setup lang="ts">
 import type { SpecialTag, Track } from "@/types";
-import { toRefs, watch } from "vue";
+import { toRefs, watch, nextTick } from "vue";
 import { store } from "@/store/store";
 import { message } from "ant-design-vue";
 
@@ -53,7 +53,10 @@ const computeTopAndLeft = (track: Track | SpecialTag) => {
     leftInsideSliceRatio < 0 ||
     leftInsideSliceRatio > 1
   ) {
-    void message.error("轨迹坐标有误,可能是图片被修改过,请联系管理员!");
+    /** 解决message提示死循环的问题 */
+    void nextTick(() => {
+      void message.error("轨迹坐标有误,可能是图片被修改过,请联系管理员!");
+    });
   }
 
   return {

+ 4 - 4
src/features/mark/MarkHistory.vue

@@ -202,8 +202,8 @@ EventBus.on("should-reload-history", () => {
         markerId,
         markerScore,
       });
-      if (res.data) {
-        let data = cloneDeep(res.data) as Array<Task>;
+      if (res?.data) {
+        let data = cloneDeep(res.data);
         data = data.map(addFileServerPrefixToTask);
         if (store.currentTask) {
           // 这种方式(对象被重新构造了)能查找到index,我也很惊讶
@@ -249,8 +249,8 @@ async function updateHistoryTask({
     markerScore,
   });
   loading = false;
-  if (res.data) {
-    let data = cloneDeep(res.data) as Array<Task>;
+  if (res?.data) {
+    let data = cloneDeep(res.data) ;
     data = data.map(addFileServerPrefixToTask);
     store.historyTasks = data;
     replaceCurrentTask(store.historyTasks[0]);

+ 25 - 38
src/features/mark/MultiMediaMarkBody.vue

@@ -68,7 +68,7 @@ import { getStudentAnswerJSON } from "@/api/jsonMark";
 import { store } from "@/store/store";
 import { onUpdated, watch } from "vue";
 import { renderRichText } from "@/utils/renderJSON";
-import type { RichTextJSON } from "@/types";
+import type { RichTextJSON, QuestionForRender } from "@/types";
 import "viewerjs/dist/viewer.css";
 import Viewer from "viewerjs";
 import { useRoute } from "vue-router";
@@ -78,18 +78,6 @@ const isSeePaper = route.name === "StudentTrack";
 const showScore = (question: QuestionForRender) =>
   route.name !== "Mark" && question.totalScore;
 
-interface QuestionForRender {
-  unionOrder: string;
-  parentBody: RichTextJSON | null;
-  body: RichTextJSON;
-  studentAnswer: Array<RichTextJSON> | null;
-  standardAnswer: Array<RichTextJSON> | null;
-  score: number | null;
-  totalScore: number;
-  objective: boolean | null;
-  options: Array<{ number: number; body: RichTextJSON }>;
-}
-
 let questions: QuestionForRender[] = $ref([]);
 async function updateStudentAnswerJSON() {
   return getStudentAnswerJSON(store.currentTask?.jsonUrl as string);
@@ -124,8 +112,6 @@ watch(
     // 查看原卷显示全部题目
     if (isSeePaper) {
       for (const questionBody of store.setting.subject.questions || []) {
-        const questionForRender = {} as QuestionForRender;
-
         const [mainNumber, subNumber] = questionBody.unionOrder.split("-");
         const stuAns = stuAnswers.find(
           (v) =>
@@ -144,22 +130,20 @@ watch(
           (v) =>
             [v.mainNumber, v.subNumber].join("-") === questionBody.unionOrder
         );
-
-        questionForRender.unionOrder = questionBody.unionOrder;
-        questionForRender.parentBody = questionBody.parentBody;
-        questionForRender.body = questionBody.body;
-        questionForRender.options = questionBody.options;
-        questionForRender.objective = questionBody.objective;
-        questionForRender.standardAnswer = questionBody.answer;
-        questionForRender.studentAnswer = stuAns.answer;
-        questionForRender.score = taskQuestion?.score ?? null;
-        questionForRender.totalScore = taskQuestion?.maxScore || 0;
-        questions.push(questionForRender);
+        questions.push({
+          unionOrder: questionBody.unionOrder,
+          parentBody: questionBody.parentBody,
+          body: questionBody.body,
+          options: questionBody.options,
+          objective: questionBody.objective,
+          standardAnswer: questionBody.answer,
+          studentAnswer: stuAns.answer,
+          score: taskQuestion?.score ?? null,
+          totalScore: taskQuestion?.maxScore || 0,
+        });
       }
     } else {
       for (const taskQuestion of store.currentTask?.questionList || []) {
-        const questionForRender = {} as QuestionForRender;
-
         const { mainNumber, subNumber } = taskQuestion;
         const questionBody = store.setting.subject.questions.find(
           (ques) => ques.unionOrder === `${mainNumber}-${subNumber}`
@@ -180,14 +164,17 @@ watch(
           answer: [],
         };
 
-        questionForRender.unionOrder = questionBody.unionOrder;
-        questionForRender.parentBody = questionBody.parentBody;
-        questionForRender.body = questionBody.body;
-        questionForRender.standardAnswer = questionBody.answer;
-        questionForRender.studentAnswer = stuAns.answer;
-        questionForRender.score = taskQuestion.score;
-        questionForRender.totalScore = taskQuestion.maxScore;
-        questions.push(questionForRender);
+        questions.push({
+          unionOrder: questionBody.unionOrder,
+          parentBody: questionBody.parentBody,
+          body: questionBody.body,
+          options: questionBody.options,
+          objective: questionBody.objective,
+          standardAnswer: questionBody.answer,
+          studentAnswer: stuAns.answer,
+          score: taskQuestion.score,
+          totalScore: taskQuestion.maxScore,
+        });
       }
     }
   },
@@ -195,7 +182,7 @@ watch(
 );
 
 const indexToABCD = (index: number) => "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[index - 1];
-const renderObjective = (ans: any) => {
+const renderObjective = (ans: QuestionForRender["studentAnswer"]) => {
   if (typeof ans === "boolean") {
     return ans ? "A" : "B";
   } else if (Array.isArray(ans) && typeof ans[0] === "boolean") {
@@ -213,7 +200,7 @@ let viewer: Viewer = null as unknown as Viewer;
 onUpdated(() => {
   viewer && viewer.destroy();
   viewer = new Viewer(
-    document.querySelector(".rich-text-question-container") as HTMLElement,
+    document.querySelector<HTMLElement>(".rich-text-question-container")!,
     // document.querySelector("#app") as HTMLElement,
     {
       // inline: true,

+ 9 - 5
src/features/student/studentInspect/MarkBody.vue

@@ -1,9 +1,9 @@
 <template>
-  <div ref="dragContainer" class="mark-body-container tw-flex-auto tw-p-2">
+  <div ref="dragContainer" class="mark-body-container tw-flex-auto tw-p-2 tw-pt-0">
     <div v-if="!store.currentTask" class="tw-text-center">
       {{ store.message }}
     </div>
-    <div v-else :style="{ width: answerPaperScale }">
+    <div v-else :style="{ width: answerPaperScale }" class="tw-pt-2">
       <div
         v-for="(item, index) in sliceImagesWithTrackList"
         :key="index"
@@ -16,23 +16,27 @@
         <MarkDrawTrack
           :trackList="item.trackList"
           :specialTagList="item.tagList"
-          :originalImageHeight="item.originalImageHeight"
-          :originalImageWidth="item.originalImageWidth"
+          :sliceImageHeight="item.originalImageHeight"
+          :sliceImageWidth="item.originalImageWidth"
+          :dx="0"
+          :dy="0"
         />
         <hr class="image-seperator" />
       </div>
     </div>
+    <ZoomPaper v-if="store.isScanImage && sliceImagesWithTrackList.length" />
   </div>
 </template>
 
 <script setup lang="ts">
 import { reactive, watch } from "vue";
 import { store } from "@/store/store";
-import MarkDrawTrack from "./MarkDrawTrack.vue";
+import MarkDrawTrack from "@/features/mark/MarkDrawTrack.vue";
 import type { SpecialTag, Track } from "@/types";
 import { useTimers } from "@/setups/useTimers";
 import { loadImage } from "@/utils/utils";
 import { dragImage } from "@/features/mark/use/draggable";
+import ZoomPaper from "@/components/ZoomPaper.vue";
 
 interface SliceImage {
   url: string;

+ 0 - 125
src/features/student/studentInspect/MarkDrawTrack.vue

@@ -1,125 +0,0 @@
-<template>
-  <template v-for="(track, index) in trackList" :key="index">
-    <div
-      class="score-container"
-      :class="[focusedTrack(track) && 'score-animation']"
-      :style="computeTopAndLeft(track)"
-    >
-      <span
-        :id="`a-${track.mainNumber}-${track.subNumber}-${track.offsetY}-${track.offsetX}`"
-        class="tw-m-auto"
-      >
-        {{ track.unanswered ? "空" : track.score }}
-      </span>
-    </div>
-  </template>
-  <template v-for="(tag, index) in specialTagList" :key="index">
-    <div class="score-container" :style="computeTopAndLeft(tag)">
-      <span class="tw-m-auto">
-        {{ tag.tagName }}
-      </span>
-    </div>
-  </template>
-</template>
-
-<script setup lang="ts">
-import type { SpecialTag, Track } from "@/types";
-import { store } from "@/store/store";
-import { toRefs, watch } from "vue";
-import { message } from "ant-design-vue";
-
-const props = defineProps<{
-  trackList: Array<Track>;
-  specialTagList: Array<SpecialTag>;
-  originalImageWidth: number;
-  originalImageHeight: number;
-}>();
-const { trackList } = toRefs(props);
-
-const focusedTrack = (track: Track) => {
-  return store.focusTracks.includes(track);
-};
-const computeTopAndLeft = (track: Track | SpecialTag) => {
-  const topInsideSlice = track.offsetY;
-  const leftInsideSlice = track.offsetX;
-  const topInsideSliceRatio = topInsideSlice / props.originalImageHeight;
-  const leftInsideSliceRatio = leftInsideSlice / props.originalImageWidth;
-  if (
-    topInsideSliceRatio < 0 ||
-    topInsideSliceRatio > 1 ||
-    leftInsideSliceRatio < 0 ||
-    leftInsideSliceRatio > 1
-  ) {
-    void message.error("轨迹坐标有误,可能是图片被修改过,请联系管理员!");
-  }
-
-  return {
-    top: topInsideSliceRatio * 100 + "%",
-    left: leftInsideSliceRatio * 100 + "%",
-    "font-size":
-      (store.setting.uiSetting["score.fontSize.scale"] || 1) *
-        store.setting.uiSetting["answer.paper.scale"] *
-        2.2 +
-      "em",
-  };
-};
-
-watch(
-  () => store.focusTracks.length,
-  () => {
-    if (store.focusTracks.length === 0) return;
-    const minImageIndex = Math.min(
-      ...store.focusTracks.map((t) => t.offsetIndex)
-    );
-    const minImageOffsetY = Math.min(
-      ...store.focusTracks
-        .filter((t) => t.offsetIndex === minImageIndex)
-        .map((t) => t.offsetY)
-    );
-    const topTrack = store.focusTracks.find(
-      (t) => t.offsetIndex === minImageIndex && t.offsetY === minImageOffsetY
-    );
-    if (topTrack) {
-      document
-        .querySelector(
-          `#a-${topTrack.mainNumber}-${topTrack.subNumber}-${topTrack.offsetY}-${topTrack.offsetX}`
-        )
-        ?.scrollIntoView({ behavior: "smooth" });
-    }
-  }
-);
-</script>
-
-<style scoped>
-.score-container {
-  position: absolute;
-  display: flex;
-  place-content: center;
-  color: red;
-
-  /* to center score */
-  width: 200px;
-  height: 200px;
-  margin-top: -100px;
-  margin-left: -100px;
-
-  /* to click through div */
-  pointer-events: none;
-}
-.score-animation {
-  animation: 2s ease-in-out 0s infinite alternate change_size;
-}
-
-@keyframes change_size {
-  from {
-    font-size: 2em;
-    margin-top: -100px;
-    margin-left: -100px;
-  }
-  to {
-    font-size: 4em;
-    margin-top: -80px;
-    margin-left: -80px;
-  }
-}
-</style>

+ 8 - 3
src/main.ts

@@ -8,6 +8,7 @@ if (!validUA) {
 import "./styles/global.css";
 import { createApp } from "vue";
 import { createPinia } from "pinia";
+import { initMarkStore } from "@/store/store";
 import App from "./App.vue";
 import router from "@/router";
 import filters from "@/filters";
@@ -30,6 +31,12 @@ app.config.globalProperties.$filters = filters;
 app.component("QmButton", QmButton);
 app.component("QmDialog", QmDialog);
 
+/**
+ * @description pinia限制,初始化Store, 必须在use pinia插件实例之后。所以在此执行初始化, 此方法调用之后, Store初始化完成
+ * @notice 在初始化完成之前,store为null , 请勿在初始化完成之前,直接使用store的方法或属性
+ */
+initMarkStore();
+
 if (import.meta.env.DEV) {
   await import("./devLogin")
     .then((m) => {
@@ -39,7 +46,5 @@ if (import.meta.env.DEV) {
       app.mount("#app");
     });
 } else {
-  setTimeout(() => {
-    app.mount("#app");
-  }, 0);
+  app.mount("#app");
 }

+ 60 - 57
src/store/store.ts

@@ -2,62 +2,65 @@ import { Setting, MarkStore, AdminPageSetting, Task } from "@/types";
 import { watch } from "vue";
 import { defineStore } from "pinia";
 
-export const useMarkStore = defineStore("mark", {
+const initState: MarkStore = {
+  setting: {
+    mode: "TRACK",
+    examType: "SCAN_IMAGE",
+    forceMode: false,
+    sheetView: false,
+    autoScroll: false,
+    sheetConfig: [],
+    enableAllZero: false,
+    enableSplit: true,
+    fileServer: "",
+    userName: "",
+    subject: <Setting["subject"]>{},
+    forceSpecialTag: false,
+    uiSetting: {
+      "answer.paper.scale": 1,
+      "score.board.collapse": false,
+      "normal.mode": "keyboard",
+      "paper.modal": false,
+      "answer.modal": false,
+      "minimap.modal": false,
+      "specialTag.modal": false,
+      "shortCut.modal": false,
+      "score.fontSize.scale": 1,
+    },
+    statusValue: null,
+    problemTypes: [],
+    groupNumber: -987654, // 默认不可能的值
+    groupTitle: "",
+    topCount: 0,
+    splitConfig: [],
+    prefetchCount: 3,
+    startTime: 0,
+    endTime: 0,
+    selective: false,
+  },
+  status: <MarkStore["status"]>{},
+  groups: [],
+  tasks: [],
+  message: null,
+  currentTask: undefined,
+  currentQuestion: undefined,
+  currentScore: undefined,
+  currentSpecialTag: undefined,
+  historyOpen: false,
+  historyTasks: [],
+  removeScoreTracks: [],
+  focusTracks: [],
+  maxModalZIndex: 1020,
+  minimapScrollToX: 0,
+  minimapScrollToY: 0,
+  allPaperModal: false,
+  sheetViewModal: false,
+  globalMask: false,
+};
+
+const useMarkStore = defineStore("mark", {
   state: () => {
-    return {
-      setting: {
-        mode: "TRACK",
-        examType: "SCAN_IMAGE",
-        forceMode: false,
-        sheetView: false,
-        autoScroll: false,
-        sheetConfig: [],
-        enableAllZero: false,
-        enableSplit: true,
-        fileServer: "",
-        userName: "",
-        subject: <Setting["subject"]>{},
-        forceSpecialTag: false,
-        uiSetting: {
-          "answer.paper.scale": 1,
-          "score.board.collapse": false,
-          "normal.mode": "keyboard",
-          "paper.modal": false,
-          "answer.modal": false,
-          "minimap.modal": false,
-          "specialTag.modal": false,
-          "shortCut.modal": false,
-          "score.fontSize.scale": 1,
-        },
-        statusValue: null as unknown as Setting["statusValue"],
-        problemTypes: [],
-        groupNumber: -987654, // 默认不可能的值
-        groupTitle: "",
-        topCount: 0,
-        splitConfig: [],
-        prefetchCount: 3,
-        startTime: 0,
-        endTime: 0,
-        selective: false,
-      },
-      status: <MarkStore["status"]>{},
-      groups: [],
-      tasks: [],
-      message: null,
-      currentTask: undefined,
-      currentQuestion: undefined,
-      currentScore: undefined,
-      currentSpecialTag: undefined,
-      historyOpen: false,
-      historyTasks: [],
-      removeScoreTracks: [],
-      focusTracks: [],
-      maxModalZIndex: 1020,
-      minimapScrollTo: 0,
-      allPaperModal: false,
-      sheetViewModal: false,
-      globalMask: false,
-    } as MarkStore;
+    return initState;
   },
   getters: {
     /** 获得statusValue的中文名 */
@@ -149,7 +152,7 @@ export const useMarkStore = defineStore("mark", {
 
 export let store = null as unknown as ReturnType<typeof useMarkStore>;
 
-setTimeout(() => {
+export const initMarkStore = () => {
   store = useMarkStore();
 
   watch(
@@ -219,4 +222,4 @@ setTimeout(() => {
   );
 
   // scoreList 被 trackList 和用户手动更新
-}, 0);
+};

+ 91 - 53
src/types/index.ts

@@ -1,3 +1,19 @@
+import type { AxiosResponse } from "axios";
+interface SplitConfig {
+  /** index of sheets */
+  i: number;
+  /** 覆盖区域的width */
+  w: number;
+  /** 覆盖区域的height */
+  h: number;
+  /** 从哪里开始覆盖 左上角为 (0, 0) */
+  x: number;
+  /** 从哪里开始覆盖 左上角为 (0, 0) */
+  y: number;
+}
+
+type SingleSheetConfig = SplitConfig;
+export type PictureSlice = SplitConfig;
 export interface MarkStore {
   setting: Setting;
   groups: Array<Group>;
@@ -64,21 +80,16 @@ export interface Setting {
   fileServer: string;
   /** 评卷员姓名 */
   userName: string;
-  subject: {
-    /** 科目信息(试卷和答案功能)*/
-    name: string;
-    code: string;
-    answerUrl: string;
-    paperUrl: string;
-    questions: Array<RichTextQuestion>;
-  };
+  /** 科目信息(试卷和答案功能)*/
+  subject: Subject;
   /** 强制标记是否开启 */
   forceSpecialTag: boolean;
+  /** 评卷小工具控制字段 */
   uiSetting: UISetting;
   /** 只显示试评名称 TRIAL("试评"), FORMAL("正评"), FINISH("结束"): 结束状态不会在评卷端出现 */
-  statusValue: "TRIAL" | "FORMAL";
+  statusValue: "TRIAL" | "FORMAL" | null;
   /** 问题卷类型 */
-  problemTypes: Array<{ id: number; name: string }> | [];
+  problemTypes: Array<{ id: number; name: string }>;
   /** 当前评卷分组号 */
   groupNumber: number;
   /** 当前评卷分组名称 */
@@ -97,6 +108,24 @@ export interface Setting {
   selective: boolean;
 }
 
+/** 科目信息(试卷和答案功能) */
+interface RawSubject {
+  /** 科目名称 */
+  name: string;
+  /** 科目编号 */
+  code: string;
+  /** 学生答案json url */
+  answerUrl: string;
+  /** 试卷json url */
+  paperUrl: string;
+}
+
+/** 科目信息(试卷和答案功能)增加前端自定义 questions字段*/
+interface Subject extends RawSubject {
+  /** 前端自定义questions字段, 在多媒体阅卷模式下使用 */
+  questions: Array<RichTextQuestion>;
+}
+
 // setting for admin page
 export interface AdminPageSetting {
   /** 扫描图片或者多媒体,多媒体只允许 common mode */
@@ -105,14 +134,8 @@ export interface AdminPageSetting {
   fileServer: string;
   /** 管理员姓名 */
   userName: string;
-  subject: {
-    /** 科目信息(试卷和答案功能) */
-    name: string;
-    code: string;
-    answerUrl: string;
-    paperUrl: string;
-    questions: Array<RichTextQuestion>;
-  };
+  /** 科目信息 */
+  subject: Subject;
   /** 使用裁切整图时的裁切配置 [0,1]|[0,0.3,0.25,0.55], */
   splitConfig: Array<number>;
   enableSplit: boolean;
@@ -125,22 +148,11 @@ export interface AdminPageSettingForImport extends AdminPageSetting {
   message: string;
 }
 
-interface SingleSheetConfig {
-  /** index of sheets */
-  i: number;
-  /** 覆盖区域的width */
-  w: number;
-  /** 覆盖区域的height */
-  h: number;
-  /** 从哪里开始覆盖 左上角为 (0, 0) */
-  x: number;
-  /** 从哪里开始覆盖 左上角为 (0, 0) */
-  y: number;
-}
-
 export interface Group {
   markerId: number;
+  /** 分组编号 */
   number: number;
+  /** 分组title */
   title: string;
   /** 总评卷数量 */
   markedCount: number;
@@ -148,22 +160,21 @@ export interface Group {
   totalCount: number;
 }
 
-export interface Task {
+interface RawTask {
   libraryId: number;
+  /** 学生ID */
   studentId: number;
+  /** 任务编号 */
   secretNumber: string;
   /** 后端处理是否显示 */
+  /** 学生名称 */
   studentName: string;
+  /** 学生编号 */
   studentCode: string;
+  /** 考试编号 */
   examNumber: string;
-  subject?: {
-    /** 一般不要用此处的subject,优先用setting.subject */
-    name: string;
-    code: string;
-    answerUrl: string;
-    paperUrl: string;
-    questions: Array<RichTextQuestion>;
-  };
+  /** 一般不要用此处的subject,优先用setting.subject */
+  subject?: Subject;
   /** 裁切图url */
   sliceUrls: Array<string>;
   /** 最高显示优先级,有sliceConfig就用sliceConfig,否则使用sheetConfig */
@@ -191,14 +202,16 @@ export interface Task {
   /** 是否是打回 */
   rejected: boolean;
   message: string | null;
+}
 
+export interface Task extends RawTask {
   /** 评卷结果,在task第一次被访问时自动添加,watch currentTask */
   markResult: MarkResult;
   /** 前端自用,用于标记阅卷开始时间和计算spent */
   __markStartTime: number;
 }
 
-export interface Question {
+interface RawQuestion {
   /** 分组序号 */
   groupNumber: number;
   /** 大题号 */
@@ -207,8 +220,11 @@ export interface Question {
   subNumber: string;
   /** 分数间隔 */
   intervalScore: number;
+  /** 默认分数 */
   defaultScore: number;
+  /** 限制最小分数 */
   minScore: number;
+  /** 限制最大分数 */
   maxScore: number;
   /** 题目名称 */
   title: string;
@@ -216,10 +232,13 @@ export interface Question {
   trackList: Array<Track>;
   /** 得分;null的值时是为打回时可以被评卷修改的;null也是从未评分过的情况,要通过rejected来判断 */
   score: number | null;
+}
+export interface Question extends RawQuestion {
   /** question 在 task 里面的 index ,用来对应 scoreList 的 score */
   __index: number;
 }
 
+/** 轨迹数据 */
 export interface Track {
   /** 大题号 */
   mainNumber: number;
@@ -235,11 +254,13 @@ export interface Track {
   /** 相对slice的位置比例 */
   positionX: number;
   positionY: number;
+  /** 评分数 */
   score: number;
   /** 是否此处未作答,未作答时,score默认是-0分 */
   unanswered: boolean;
 }
 
+/** 特殊标记数据 */
 export interface SpecialTag {
   /** 第几张图 */
   offsetIndex: number;
@@ -253,45 +274,52 @@ export interface SpecialTag {
   tagName: string;
 }
 
-export interface PictureSlice {
-  /** 从1开始 */
-  i: number;
-  w: number;
-  h: number;
-  x: number;
-  y: number;
-}
-
 export interface UISetting {
+  /** 给分面板展示形态 */
   "score.board.collapse": boolean;
   /** 0.2 gap */
+  /** 试卷缩放比例 */
   "answer.paper.scale": number;
+  /**
+   * 给分模式
+   * "keyboard": 键盘模式
+   * "mouse": 鼠标模式
+   *  */
   "normal.mode": "keyboard" | "mouse";
+  /** 弹窗显示试卷 */
   "paper.modal": boolean;
+  /** 弹窗显示答案 */
   "answer.modal": boolean;
+  /** 弹窗显示缩略图 */
   "minimap.modal": boolean;
+  /** 弹窗显示特殊字符 */
   "specialTag.modal": boolean;
+  /** 弹窗显示快捷键配置 */
   "shortCut.modal": boolean;
   /** 0.1 gap */
+  /** 分数标记缩放比例 */
   "score.fontSize.scale": number;
 }
 
 export interface MarkResult {
   libraryId: number;
   studentId: number;
-  statusValue: string;
+  statusValue: "TRIAL" | "FORMAL" | null;
   /** 毫秒单位 */
   spent: number;
 
   // 轨迹 or 键盘
   markerScore: number | null;
+  /** 轨迹列表 */
   trackList: Array<Track>;
+  /** 给分列表 */
   scoreList: Array<number | null>;
   /** 轨迹和键盘都需要 */
   specialTagList: Array<SpecialTag>;
 
   /** 问题卷 */
   problem: boolean;
+  /** 问题卷类型ID */
   problemTypeId: number;
   /** 当前task是否为学生未选做 */
   unselective: boolean;
@@ -341,7 +369,9 @@ export interface HistoryQueryParams {
 }
 
 export interface GetHistory {
-  (historyQuery: HistoryQueryParams): any;
+  (historyQuery: HistoryQueryParams): Promise<
+    AxiosResponse<Task[]> | undefined
+  >;
 }
 
 export interface CommonResponse {
@@ -367,8 +397,16 @@ export interface RichTextQuestion {
   parentBody: RichTextJSON | null;
   answer: Array<RichTextJSON> | null;
   objective: boolean | null;
-  options: Array<{ number: number; body: RichTextJSON }>;
+  options: Array<{ number: number; body: RichTextJSON }> | null;
+}
+
+export interface QuestionForRender extends Omit<RichTextQuestion, "answer"> {
+  studentAnswer: RichTextQuestion["answer"];
+  standardAnswer: RichTextQuestion["answer"];
+  score: number | null;
+  totalScore: number;
 }
+
 export interface RichTextJSON {
   sections: RichTextSectionJSON[];
 }

+ 6 - 3
src/utils/utils.ts

@@ -215,9 +215,12 @@ export async function preDrawImage(_currentTask: Task) {
       images.push(image);
     }
 
-    const splitConfigPairs = store.setting.splitConfig
-      .map((v, index, ary) => (index % 2 === 0 ? [v, ary[index + 1]] : false))
-      .filter((v) => v) as unknown as Array<[number, number]>;
+    const splitConfigPairs = store.setting.splitConfig.reduce<
+      [number, number][]
+    >((a, v, index) => {
+      index % 2 === 0 ? a.push([v, -1]) : (a.at(-1)![1] = v);
+      return a;
+    }, []);
 
     const maxSplitConfig = Math.max(...store.setting.splitConfig);
     maxSliceWidth =

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio