zhangjie 4 mēneši atpakaļ
vecāks
revīzija
9f99cf00ed

+ 10 - 8
src/components/ZoomPaper.vue

@@ -43,29 +43,31 @@ import {
   RotateRightOutlined,
 } from "@ant-design/icons-vue";
 import { computed, onMounted, onUnmounted } from "vue";
-import { store } from "@/store/app";
+import { useMarkStore } from "@/store";
+
+const markStore = useMarkStore();
 
 const props = defineProps<{ showRotate?: boolean; fixed?: boolean }>();
 defineEmits(["rotateRight"]);
 
 const upScale = () => {
-  const s = store.setting.uiSetting["answer.paper.scale"];
+  const s = markStore.setting.uiSetting["answer.paper.scale"];
   if (s < 3)
-    store.setting.uiSetting["answer.paper.scale"] = +(s + 0.2).toFixed(1);
+    markStore.setting.uiSetting["answer.paper.scale"] = +(s + 0.2).toFixed(1);
 };
 const downScale = () => {
-  const s = store.setting.uiSetting["answer.paper.scale"];
+  const s = markStore.setting.uiSetting["answer.paper.scale"];
   if (s > 0.2)
-    store.setting.uiSetting["answer.paper.scale"] = +(s - 0.2).toFixed(1);
+    markStore.setting.uiSetting["answer.paper.scale"] = +(s - 0.2).toFixed(1);
 };
 const normalScale = () => {
-  store.setting.uiSetting["answer.paper.scale"] = 1;
+  markStore.setting.uiSetting["answer.paper.scale"] = 1;
 };
 const greaterThanOneScale = computed(() => {
-  return store.setting.uiSetting["answer.paper.scale"] > 1;
+  return markStore.setting.uiSetting["answer.paper.scale"] > 1;
 });
 const lessThanOneScale = computed(() => {
-  return store.setting.uiSetting["answer.paper.scale"] < 1;
+  return markStore.setting.uiSetting["answer.paper.scale"] < 1;
 });
 
 function keyListener(event: KeyboardEvent) {

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

@@ -30,10 +30,9 @@
   <modal-minimap />
   <modal-all-paper />
   <modal-sheet-view />
-  <modal-special-tag />
   <modal-short-cut />
   <!-- other -->
-  <MarkBoardTrackDialog
+  <mark-board-track-dialog
     v-if="store.isTrackMode"
     @submit="saveTaskToServer"
     @allZeroSubmit="allZeroSubmit"
@@ -78,7 +77,6 @@ import ModalPaper from "./modals/ModalPaper.vue";
 import ModalMinimap from "./modals/ModalMinimap.vue";
 import ModalAllPaper from "./modals/ModalAllPaper.vue";
 import ModalSheetView from "./modals/ModalSheetView.vue";
-import ModalSpecialTag from "./modals/ModalSpecialTag.vue";
 import ModalShortCut from "./modals/ModalShortCut.vue";
 
 // composables

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 91 - 884
src/features/mark/MarkBodyBase.vue


+ 281 - 0
src/features/mark/MarkBodySepecialTag.vue

@@ -0,0 +1,281 @@
+<template>
+  <div
+    v-if="isCustomSpecialTag"
+    v-ele-move-directive.stop.prevent="{
+      moveStart: (event) => specialMouseStart(event),
+      moveElement: specialMouseMove,
+      moveStop: specialMouseStop,
+    }"
+    class="image-canvas"
+    @click="(event) => canvasClick(event)"
+  >
+    <template v-if="curSliceImagesWithTrackItem?.url === sliceImageItem.url">
+      <div
+        v-if="markStore.currentSpecialTagType === 'LINE'"
+        :style="specialLenStyle"
+      ></div>
+      <div
+        v-else-if="markStore.currentSpecialTagType === 'CIRCLE'"
+        :style="specialCircleStyle"
+      ></div>
+      <div
+        v-else-if="markStore.currentSpecialTagType === 'TEXT'"
+        v-show="cacheTextTrack.id"
+        :id="`text-edit-box-${cacheTextTrack.id}`"
+        :key="cacheTextTrack.id"
+        class="text-edit-box"
+        contenteditable
+        :style="specialTextStyle"
+        @input="textTrackInput"
+        @blur="textTrackBlur"
+        @keypress.stop
+        @keydown.stop
+        @mousedown.stop
+        @mousemove.stop
+        @mouseup.stop
+      ></div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { nextTick, watch } from "vue";
+import type { SliceImage, SpecialTag } from "@/types";
+import { useMarkStore } from "@/store";
+import { vEleMoveDirective } from "@/directives/eleMove";
+import { randomCode } from "@/utils/utils";
+
+const { maxSliceWidth, theFinalHeight, sliceImageItem } = defineProps<{
+  maxSliceWidth: number;
+  theFinalHeight: number;
+  sliceImageItem: SliceImage;
+}>();
+
+const markStore = useMarkStore();
+
+const isCustomSpecialTag = $computed(() => {
+  return ["CIRCLE", "LINE", "TEXT"].includes(markStore.currentSpecialTagType);
+});
+
+let specialPoint = $ref({ x: 0, y: 0, ex: 0, ey: 0 });
+let curImageTarget: HTMLElement = null;
+let curSliceImagesWithTrackItem: SliceImage = $ref(null);
+let cacheTextTrack = $ref({
+  id: "",
+  x: 0,
+  y: 0,
+  maxW: 0,
+  maxY: 0,
+  content: "",
+});
+
+const specialLenStyle = $computed(() => {
+  if (specialPoint.ex <= specialPoint.x) return { display: "none" };
+
+  const width =
+    specialPoint.ex > specialPoint.x ? specialPoint.ex - specialPoint.x : 0;
+  return {
+    top: specialPoint.y + "px",
+    left: specialPoint.x + "px",
+    width: width + "px",
+    position: "absolute",
+    borderTop: "1px solid red",
+    zIndex: 9,
+  };
+});
+const specialCircleStyle = $computed(() => {
+  if (specialPoint.ex <= specialPoint.x || specialPoint.ey <= specialPoint.y)
+    return { display: "none" };
+
+  const width =
+    specialPoint.ex > specialPoint.x ? specialPoint.ex - specialPoint.x : 0;
+  const height =
+    specialPoint.ey > specialPoint.y ? specialPoint.ey - specialPoint.y : 0;
+  return {
+    top: specialPoint.y + "px",
+    left: specialPoint.x + "px",
+    width: width + "px",
+    height: height + "px",
+    position: "absolute",
+    border: "1px solid red",
+    borderRadius: "50%",
+    zIndex: 9,
+  };
+});
+const specialTextStyle = $computed(() => {
+  return {
+    top: cacheTextTrack.y + "px",
+    left: cacheTextTrack.x + "px",
+    minWidth: "30px",
+    minHeight: "30px",
+    maxWidth: curImageTarget.width - cacheTextTrack.x + "px",
+    maxHeight: curImageTarget.height - cacheTextTrack.y + "px",
+  };
+});
+
+function specialMouseStart(e: MouseEvent) {
+  if (markStore.currentSpecialTagType === "TEXT") return;
+
+  curImageTarget = e.target.parentElement.childNodes[0];
+  curSliceImagesWithTrackItem = sliceImageItem;
+  specialPoint.x = e.offsetX;
+  specialPoint.y = e.offsetY;
+}
+function specialMouseMove({ left, top }) {
+  if (markStore.currentSpecialTagType === "TEXT") return;
+
+  specialPoint.ex = left + specialPoint.x;
+  specialPoint.ey = top + specialPoint.y;
+}
+function specialMouseStop() {
+  if (markStore.currentSpecialTagType === "TEXT") return;
+
+  if (
+    markStore.currentSpecialTagType === "LINE" &&
+    specialPoint.ex <= specialPoint.x
+  ) {
+    specialPoint = { x: 0, y: 0, ex: 0, ey: 0 };
+    return;
+  }
+  if (
+    markStore.currentSpecialTagType === "CIRCLE" &&
+    (specialPoint.ex <= specialPoint.x || specialPoint.ey <= specialPoint.y)
+  ) {
+    specialPoint = { x: 0, y: 0, ex: 0, ey: 0 };
+    return;
+  }
+
+  const track: SpecialTag = {
+    tagName: "",
+    tagType: markStore.currentSpecialTagType,
+    offsetIndex: curSliceImagesWithTrackItem.indexInSliceUrls,
+    offsetX:
+      specialPoint.x * (curImageTarget.naturalWidth / curImageTarget.width) +
+      curSliceImagesWithTrackItem.dx,
+    offsetY:
+      specialPoint.y * (curImageTarget.naturalHeight / curImageTarget.height) +
+      curSliceImagesWithTrackItem.dy,
+    positionX: -1,
+    positionY: -1,
+    groupNumber: markStore.currentQuestion.groupNumber,
+  };
+  track.positionX =
+    (track.offsetX - curSliceImagesWithTrackItem.dx) / maxSliceWidth;
+  track.positionY =
+    (track.offsetY -
+      curSliceImagesWithTrackItem.dy +
+      curSliceImagesWithTrackItem.accumTopHeight) /
+    theFinalHeight;
+
+  if (markStore.currentSpecialTagType === "LINE") {
+    track.tagName = JSON.stringify({
+      len:
+        (specialPoint.ex - specialPoint.x) *
+        (curImageTarget.naturalWidth / curImageTarget.width),
+    });
+  }
+  if (markStore.currentSpecialTagType === "CIRCLE") {
+    track.tagName = JSON.stringify({
+      width:
+        (specialPoint.ex - specialPoint.x) *
+        (curImageTarget.naturalWidth / curImageTarget.width),
+      height:
+        (specialPoint.ey - specialPoint.y) *
+        (curImageTarget.naturalHeight / curImageTarget.height),
+    });
+  }
+
+  markStore.currentTaskEnsured.markResult.specialTagList.push(track);
+  curSliceImagesWithTrackItem.tagList.push(track);
+  specialPoint = { x: 0, y: 0, ex: 0, ey: 0 };
+}
+
+function canvasClick(e: MouseEvent) {
+  if (cacheTextTrack.id) {
+    textTrackBlur();
+  }
+
+  const target = e.target as HTMLElement;
+  curImageTarget = target.parentElement?.childNodes[0] as HTMLElement;
+  curSliceImagesWithTrackItem = sliceImageItem;
+
+  cacheTextTrack.x = e.offsetX;
+  cacheTextTrack.y = e.offsetY;
+  cacheTextTrack.id = randomCode();
+  cacheTextTrack.content = "";
+
+  void nextTick(() => {
+    const element = document.getElementById(
+      `text-edit-box-${cacheTextTrack.id}`
+    );
+    element?.focus();
+  });
+}
+
+function textTrackInput(e: Event) {
+  const target = e.target as HTMLElement;
+  cacheTextTrack.content = target.outerText;
+}
+function initCacheTextTrack() {
+  cacheTextTrack = {
+    x: 0,
+    y: 0,
+    maxW: 0,
+    maxY: 0,
+    content: "",
+    id: "",
+  };
+}
+function textTrackBlur() {
+  if (!cacheTextTrack.content) {
+    initCacheTextTrack();
+    return;
+  }
+  const textBoxDom = document.getElementById(
+    `text-edit-box-${cacheTextTrack.id}`
+  );
+
+  // 减去内边距所占宽高
+  const tagName = JSON.stringify({
+    width: textBoxDom.offsetWidth - 10,
+    height: textBoxDom.offsetHeight - 10,
+    content: cacheTextTrack.content,
+  });
+
+  const track: SpecialTag = {
+    tagName,
+    tagType: markStore.currentSpecialTagType,
+    offsetIndex: curSliceImagesWithTrackItem.indexInSliceUrls,
+    offsetX:
+      cacheTextTrack.x * (curImageTarget.naturalWidth / curImageTarget.width) +
+      curSliceImagesWithTrackItem.dx,
+    offsetY:
+      cacheTextTrack.y *
+        (curImageTarget.naturalHeight / curImageTarget.height) +
+      curSliceImagesWithTrackItem.dy,
+    positionX: -1,
+    positionY: -1,
+    groupNumber: markStore.currentQuestion.groupNumber,
+  };
+  track.positionX =
+    (track.offsetX - curSliceImagesWithTrackItem.dx) / maxSliceWidth;
+  track.positionY =
+    (track.offsetY -
+      curSliceImagesWithTrackItem.dy +
+      curSliceImagesWithTrackItem.accumTopHeight) /
+    theFinalHeight;
+
+  markStore.currentTaskEnsured.markResult.specialTagList.push(track);
+  curSliceImagesWithTrackItem.tagList.push(track);
+  initCacheTextTrack();
+}
+
+watch(
+  () => markStore.currentSpecialTagType,
+  () => {
+    if (cacheTextTrack.id) {
+      initCacheTextTrack();
+    }
+  }
+);
+</script>

+ 1 - 1
src/features/mark/MarkDrawTrack.vue

@@ -168,7 +168,7 @@ const getSpecialTextImg = (track: SpecialTag) => {
     }
   });
 
-  conts.forEach((cont, index) => {
+  conts.forEach((cont) => {
     if (!cont) {
       y += lineHeight;
       return;

+ 33 - 40
src/features/mark/MarkHistory.vue

@@ -1,10 +1,10 @@
 <template>
-  <div :class="['mark-history', { hide: !store.historyOpen }]">
+  <div :class="['mark-history', { hide: !markStore.historyOpen }]">
     <div class="mark-history-title">
       {{ title }}
     </div>
     <div
-      v-if="showSearch && store.getStatusValueName !== '试评'"
+      v-if="showSearch && markStore.getStatusValueName !== '试评'"
       class="mark-history-search"
     >
       <a-select
@@ -41,9 +41,9 @@
     <div class="mark-history-table-body">
       <a-spin :spinning="loading" size="large" tip="Loading..." :delay="500">
         <div
-          v-for="(task, index) of store.historyTasks"
+          v-for="(task, index) of markStore.historyTasks"
           :key="index"
-          :class="['body-row', { 'is-active': store.currentTask === task }]"
+          :class="['body-row', { 'is-active': markStore.currentTask === task }]"
           @click="replaceCurrentTask(task)"
         >
           <div class="body-col">
@@ -80,12 +80,13 @@ import type {
   Task,
 } from "@/types";
 import { watch } from "vue";
-import { store } from "@/store/app";
+import { message } from "ant-design-vue";
+import { useMarkStore } from "@/store";
 import EventBus from "@/plugins/eventBus";
 import { preDrawImageHistory } from "@/utils/utils";
-import { message } from "ant-design-vue";
 
 const limitPageSize = 20;
+const markStore = useMarkStore();
 
 const {
   title = "回评",
@@ -108,8 +109,10 @@ const {
   examId?: string;
   getHistory: GetHistory;
 }>();
+
 let searchType = $ref("1");
 
+// 密号输入
 let secretNumberInput = $ref("");
 const format = (val: string, preVal: string) => {
   const reg = /^-?\d*(\.\d*)?$/;
@@ -137,9 +140,8 @@ if (orderTimeField) {
 let sort: MarkHistorySortField = $ref("DESC");
 
 const currentTaskChange = async () => {
-  if (store.historyOpen) {
-    // replaceCurrentTask(undefined);
-    store.globalMask = true;
+  if (markStore.historyOpen) {
+    markStore.globalMask = true;
     try {
       await updateHistoryTask({
         secretNumber: secretNumberInput,
@@ -151,36 +153,27 @@ const currentTaskChange = async () => {
       // 恢复以前的行为,取回评失败则评卷任务为空
       await replaceCurrentTask(undefined);
     } finally {
-      store.globalMask = false;
+      markStore.globalMask = false;
     }
-    await replaceCurrentTask(store.historyTasks[0]);
+    await replaceCurrentTask(markStore.historyTasks[0]);
   } else {
-    await replaceCurrentTask(store.tasks[0]);
-    store.historyTasks.splice(0);
+    await replaceCurrentTask(markStore.tasks[0]);
+    markStore.historyTasks.splice(0);
     secretNumberInput = "";
     currentPage = 1;
     order = "marker_time";
     sort = "DESC";
   }
 };
-watch(() => store.historyOpen, currentTaskChange);
+watch(() => markStore.historyOpen, currentTaskChange);
 watch([$$(order), $$(sort), $$(currentPage)], currentTaskChange);
 
 EventBus.on("should-reload-history", () => {
-  // await updateHistoryTask({
-  //   secretNumber: secretNumberInput,
-  //   order: order,
-  //   sort: sort,
-  //   pageNumber: currentPage,
-  // });
-  // // 提交后,渲染第一条
-  // replaceCurrentTask(store.historyTasks[0]);
-  // TODO: 因为 mitt 不支持 å https://github.com/developit/mitt/issues/122
   (async () => {
-    store.globalMask = true;
+    markStore.globalMask = true;
     try {
       const res = await getHistory({
-        secretNumber: store.currentTask?.secretNumber,
+        secretNumber: markStore.currentTask?.secretNumber,
         order,
         sort,
         pageNumber: 1,
@@ -193,26 +186,28 @@ EventBus.on("should-reload-history", () => {
       });
       if (res?.data) {
         const data = res.data.records;
-        if (store.currentTask) {
+        if (markStore.currentTask) {
           // 这种方式(对象被重新构造了)能查找到index,我也很惊讶
-          const indexOfTasks = store.historyTasks.indexOf(store.currentTask);
+          const indexOfTasks = markStore.historyTasks.indexOf(
+            markStore.currentTask
+          );
           if (data[0]) {
             // 如果原任务依然存在
-            store.historyTasks.splice(indexOfTasks, 1, data[0]);
-            await replaceCurrentTask(store.historyTasks[indexOfTasks]);
+            markStore.historyTasks.splice(indexOfTasks, 1, data[0]);
+            await replaceCurrentTask(markStore.historyTasks[indexOfTasks]);
           } else {
             // 问题卷会查找不到,这里直接删除此任务
-            store.historyTasks.splice(indexOfTasks, 1);
-            await replaceCurrentTask(store.historyTasks[indexOfTasks]);
+            markStore.historyTasks.splice(indexOfTasks, 1);
+            await replaceCurrentTask(markStore.historyTasks[indexOfTasks]);
           }
         } else {
           // 问题卷会将清除它作为 currentTask ,然后刷新当前页
-          store.historyTasks = data;
-          await replaceCurrentTask(store.historyTasks[0]);
+          markStore.historyTasks = data;
+          await replaceCurrentTask(markStore.historyTasks[0]);
         }
       }
     } finally {
-      store.globalMask = false;
+      markStore.globalMask = false;
     }
   })().catch((e) => console.log("reload-history error", e));
 });
@@ -240,9 +235,9 @@ async function updateHistoryTask({
   const res = await getHistory(params);
   loading = false;
   if (res?.data) {
-    store.historyTasks = res.data.records;
+    markStore.historyTasks = res.data.records;
     total = res.data.total;
-    replaceCurrentTask(store.historyTasks[0]).catch((err) => {
+    replaceCurrentTask(markStore.historyTasks[0]).catch((err) => {
       console.log(err);
       void message.error("切换至回评任务失败");
     });
@@ -250,12 +245,10 @@ async function updateHistoryTask({
 }
 
 async function replaceCurrentTask(task: Task | undefined) {
-  // console.log("replaceCurrentTask:", task);
-
-  if (store.isScanImage && !!task) {
+  if (markStore.isScanImage && !!task) {
     await preDrawImageHistory(task);
   }
-  store.currentTask = task;
+  markStore.currentTask = task;
 }
 
 function pageChange(page) {

+ 0 - 241
src/features/mark/MultiMediaMarkBody.vue

@@ -1,241 +0,0 @@
-<template>
-  <div class="rich-text-question-container">
-    <div v-for="(question, index) in questions" :key="index">
-      <div class="question">
-        <div class="tw-text-xl">
-          <span :id="'q-' + question.unionOrder">题号:</span>
-          <span>{{ question.unionOrder }}</span>
-        </div>
-        <div>
-          <div class="tw-text-xl">题干:</div>
-          <div v-html="getDomByRichTextJSON(question.parentBody)?.innerHTML" />
-          <div v-html="getDomByRichTextJSON(question.body)?.innerHTML" />
-          <template v-if="question.objective">
-            <div v-if="question.options">
-              <div
-                v-for="(option, index2) in question.options"
-                :key="index2"
-                class="tw-flex tw-gap-1"
-              >
-                {{ indexToABCD(option.number) }}.
-                <div v-html="getDomByRichTextJSON(option.body)?.innerHTML" />
-              </div>
-            </div>
-          </template>
-        </div>
-        <template v-if="!question.hideAnswer">
-          <div>
-            <template v-if="question.objective">
-              <span class="tw-text-blue-600">考生答案:</span
-              >{{ renderObjective(question.studentAnswer) }}
-            </template>
-            <template v-else>
-              <div class="tw-text-blue-600">
-                考生答案:(字数统计:{{
-                  getDomByRichTextJSON(question.studentAnswer)?.innerText
-                    .length ?? 0
-                }})
-              </div>
-              <div
-                v-html="getDomByRichTextJSON(question.studentAnswer)?.innerHTML"
-              />
-            </template>
-          </div>
-          <div>
-            <template v-if="question.objective">
-              <span class="tw-text-blue-600">标准答案:</span
-              >{{ renderObjective(question.standardAnswer) }}
-            </template>
-            <template v-else>
-              <div class="tw-text-blue-600">标准答案:</div>
-              <div
-                v-html="
-                  getDomByRichTextJSON(question.standardAnswer)?.innerHTML
-                "
-              />
-            </template>
-          </div>
-          <div v-if="showScore(question)" style="color: blue">
-            得分 / 总分 :{{
-              (question.score ?? "-") + " / " + question.totalScore
-            }}
-          </div>
-        </template>
-      </div>
-      <div style="margin-bottom: 20px"></div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { getStudentAnswerJSON } from "@/api/jsonMark";
-import { store } from "@/store/app";
-import { onUpdated, watch } from "vue";
-import { renderRichText } from "@/utils/renderJSON";
-import type { RichTextJSON, QuestionForRender } from "@/types";
-import "viewerjs/dist/viewer.css";
-import Viewer from "viewerjs";
-import { useRoute } from "vue-router";
-
-const route = useRoute();
-const isSeePaper = route.name === "StudentTrack";
-const showScore = (question: QuestionForRender) =>
-  route.name !== "Mark" && question.totalScore;
-
-let questions: QuestionForRender[] = $ref([]);
-async function updateStudentAnswerJSON() {
-  return getStudentAnswerJSON(store.currentTask?.jsonUrl as string);
-}
-
-function getDomByRichTextJSON(rt: Array<RichTextJSON> | RichTextJSON | null) {
-  const node = document.createElement("div");
-  if (!rt) return node;
-  if (Array.isArray(rt)) {
-    for (const r of rt) {
-      node.appendChild(renderRichText(r));
-    }
-  } else {
-    node.appendChild(renderRichText(rt));
-  }
-  return node;
-}
-
-watch(
-  () => store.currentTask,
-  async () => {
-    questions.splice(0);
-    if (!store.currentTask?.jsonUrl) return;
-    const res = await updateStudentAnswerJSON();
-
-    const stuAnswers = res.data;
-    for (const ans of stuAnswers) {
-      if (ans.answer && !Array.isArray(ans.answer)) {
-        ans.answer = [ans.answer];
-      }
-    }
-    // 查看原卷显示全部题目
-    if (isSeePaper) {
-      for (const questionBody of store.setting.subject.questions || []) {
-        const [mainNumber, subNumber] = questionBody.unionOrder.split("-");
-        const stuAns = stuAnswers.find(
-          (v) =>
-            questionBody.unionOrder ===
-            [v.mainNumber, v.subNumber, v.subIndex]
-              .filter((v) => typeof v !== "undefined")
-              .filter((v) => v !== null)
-              .join("-")
-        ) || {
-          mainNumber: +mainNumber,
-          subNumber: subNumber,
-          subIndex: "",
-          answer: [],
-        };
-        const taskQuestion = (store.currentTask?.questionList || []).find(
-          (v) =>
-            [v.mainNumber, v.subNumber].join("-") === questionBody.unionOrder
-        );
-        questions.push({
-          unionOrder: questionBody.unionOrder,
-          parentBody: questionBody.parentBody,
-          body: questionBody.body,
-          options: questionBody.options,
-          objective: questionBody.objective,
-          hideAnswer: questionBody.hideAnswer,
-          standardAnswer: questionBody.answer,
-          studentAnswer: stuAns.answer,
-          score: taskQuestion?.score ?? null,
-          totalScore: taskQuestion?.maxScore || 0,
-        });
-      }
-    } else {
-      for (const taskQuestion of store.currentTask?.questionList || []) {
-        const { mainNumber, subNumber } = taskQuestion;
-        const questionBody = store.setting.subject.questions.find(
-          (ques) => ques.unionOrder === `${mainNumber}-${subNumber}`
-        );
-        if (!questionBody) continue;
-
-        const stuAns = stuAnswers.find(
-          (v) =>
-            questionBody.unionOrder ===
-            [v.mainNumber, v.subNumber, v.subIndex]
-              .filter((v) => typeof v !== "undefined")
-              .filter((v) => v !== null)
-              .join("-")
-        ) || {
-          mainNumber: +mainNumber,
-          subNumber: subNumber,
-          subIndex: "",
-          answer: [],
-        };
-
-        questions.push({
-          unionOrder: questionBody.unionOrder,
-          parentBody: questionBody.parentBody,
-          body: questionBody.body,
-          options: questionBody.options,
-          objective: questionBody.objective,
-          standardAnswer: questionBody.answer,
-          hideAnswer: questionBody.hideAnswer,
-          studentAnswer: stuAns.answer,
-          score: taskQuestion.score,
-          totalScore: taskQuestion.maxScore,
-        });
-      }
-    }
-  },
-  { immediate: true }
-);
-
-const indexToABCD = (index: number) => "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[index - 1];
-const renderObjective = (ans: QuestionForRender["studentAnswer"]) => {
-  if (typeof ans === "boolean") {
-    return ans ? "A" : "B";
-  } else if (Array.isArray(ans) && typeof ans[0] === "boolean") {
-    return ans[0] ? "A" : "B";
-  } else if (Array.isArray(ans) && typeof ans[0] === "number") {
-    return (ans as unknown as number[]).map((v) => indexToABCD(v)).join("");
-  } else if (Array.isArray(ans) && ans.length === 0) {
-    return "";
-  } else {
-    console.log("错误的答案类型", JSON.stringify(ans));
-  }
-};
-
-let viewer: Viewer = null as unknown as Viewer;
-onUpdated(() => {
-  viewer && viewer.destroy();
-  viewer = new Viewer(
-    document.querySelector<HTMLElement>(".rich-text-question-container")!,
-    // document.querySelector("#app") as HTMLElement,
-    {
-      // inline: true,
-      viewed() {
-        // viewer.zoomTo(1);
-      },
-      zIndex: 10000,
-    }
-  );
-});
-</script>
-
-<style scoped>
-.question {
-  background-color: var(--app-container-bg-color);
-  padding: 5px;
-  border-radius: 5px;
-}
-.rich-text-question-container {
-  background-color: transparent;
-  font-size: 16px;
-}
-</style>
-
-<style>
-.rich-text-question-container img.inline {
-  display: inline;
-}
-.rich-text-question-container img {
-  cursor: pointer;
-}
-</style>

+ 113 - 0
src/features/mark/composables/useBodyScroll.ts

@@ -0,0 +1,113 @@
+import { useTimers } from "@/setups/useTimers";
+import { useMarkStore } from "@/store";
+import { onMounted, onUnmounted, watch } from "vue";
+
+export default function useBodyScroll() {
+  const { addTimeout } = useTimers();
+  const markStore = useMarkStore();
+
+  function getContainer(): HTMLDivElement | null {
+    return document.querySelector<HTMLDivElement>(".mark-body-container");
+  }
+
+  //缩略图定位
+  watch(
+    () => [markStore.minimapScrollToX, markStore.minimapScrollToY],
+    () => {
+      const container = getContainer();
+      addTimeout(() => {
+        if (
+          container &&
+          typeof markStore.minimapScrollToX === "number" &&
+          typeof markStore.minimapScrollToY === "number"
+        ) {
+          const { scrollWidth, scrollHeight } = container;
+          container.scrollTo({
+            top: scrollHeight * markStore.minimapScrollToY,
+            left: scrollWidth * markStore.minimapScrollToX,
+            behavior: "smooth",
+          });
+        }
+      }, 10);
+    }
+  );
+
+  // 快捷键定位
+  const scrollContainerByKey = (e: KeyboardEvent) => {
+    const container = getContainer();
+    if (!container) return;
+
+    if (e.key === "w") {
+      container.scrollBy({ top: -100, behavior: "smooth" });
+    } else if (e.key === "s") {
+      container.scrollBy({ top: 100, behavior: "smooth" });
+    } else if (e.key === "a") {
+      container.scrollBy({ left: -100, behavior: "smooth" });
+    } else if (e.key === "d") {
+      container.scrollBy({ left: 100, behavior: "smooth" });
+    }
+  };
+
+  onMounted(() => {
+    document.addEventListener("keypress", scrollContainerByKey);
+  });
+  onUnmounted(() => {
+    document.removeEventListener("keypress", scrollContainerByKey);
+  });
+
+  //  autoScroll自动跳转
+  let oldFirstScoreContainer: HTMLDivElement | null = null;
+  watch(
+    () => markStore.currentTask,
+    () => {
+      if (markStore.setting.autoScroll) {
+        // 给任务清理和动画留一点时间
+        oldFirstScoreContainer =
+          document.querySelector<HTMLDivElement>(".score-container");
+        oldFirstScoreContainer?.scrollIntoView({ behavior: "smooth" });
+        addTimeout(scrollToFirstScore, 1000);
+      } else {
+        const container = getContainer();
+        container?.scrollTo({ top: 0, left: 0, behavior: "smooth" });
+      }
+    }
+  );
+
+  function scrollToFirstScore() {
+    if (markStore.renderLock) {
+      window.requestAnimationFrame(scrollToFirstScore);
+    }
+    addTimeout(() => {
+      const firstScore =
+        document.querySelector<HTMLDivElement>(".score-container");
+      firstScore?.scrollIntoView({ behavior: "smooth" });
+    }, 1000);
+  }
+
+  // 放大缩小和之后的滚动
+  const answerPaperScale = $computed(() => {
+    // 放大、缩小不影响页面之前的滚动条定位
+    let percentWidth = 0;
+    let percentTop = 0;
+    const container = getContainer();
+    if (container) {
+      const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
+      percentWidth = scrollLeft / scrollWidth;
+      percentTop = scrollTop / scrollHeight;
+    }
+
+    addTimeout(() => {
+      if (!container) return;
+
+      const { scrollWidth, scrollHeight } = container;
+      container.scrollTo({
+        left: scrollWidth * percentWidth,
+        top: scrollHeight * percentTop,
+      });
+    }, 10);
+    const scale = markStore.setting.uiSetting["answer.paper.scale"];
+    return scale * 100 + "%";
+  });
+
+  return { answerPaperScale };
+}

+ 360 - 0
src/features/mark/composables/useSliceTrack.ts

@@ -0,0 +1,360 @@
+import { message } from "ant-design-vue";
+import { ref } from "vue";
+import type { SliceImage } from "@/types";
+import { useMarkStore } from "@/store";
+import {
+  getDataUrlForSliceConfig,
+  getDataUrlForSplitConfig,
+  loadImage,
+} from "@/utils/utils";
+import EventBus from "@/plugins/eventBus";
+
+// 计算裁切图和裁切图上的分数轨迹和特殊标记轨迹
+export default function useSliceTrack() {
+  const emit = defineEmits(["error"]);
+
+  const markStore = useMarkStore();
+
+  const rotateBoard = ref(0);
+  const sliceImagesWithTrackList = $ref<SliceImage[]>([]);
+  const maxSliceWidth = ref(0); // 最大的裁切块宽度,图片容器以此为准
+  const theFinalHeight = ref(0); // 最终宽度,用来定位轨迹在第几张图片,不包括image-seperator高度
+
+  async function processSliceConfig() {
+    if (!markStore.currentTask) return;
+    const markResult = markStore.currentTask.markResult;
+    if (hasMarkResultToRender) {
+      // check if have MarkResult for currentTask
+      if (!markResult) return;
+    }
+
+    const images = [];
+    // 必须要先加载一遍,把"选择整图"的宽高重置后,再算总高度
+    // 错误的搞法,张莹坚持要用
+    const sliceNum = markStore.currentTask.sliceUrls.length;
+    if (markStore.currentTask.sliceConfig.some((v) => v.i > sliceNum)) {
+      console.warn("裁切图设置的数量小于该学生的总图片数量");
+    }
+    markStore.currentTask.sliceConfig =
+      markStore.currentTask.sliceConfig.filter((v) => v.i <= sliceNum);
+    for (const sliceConfig of markStore.currentTask.sliceConfig) {
+      const url = markStore.currentTask.sliceUrls[sliceConfig.i - 1];
+      const image = await loadImage(url);
+      images[sliceConfig.i] = image;
+      const { x, y, w, h } = sliceConfig;
+      x < 0 && (sliceConfig.x = 0);
+      y < 0 && (sliceConfig.y = 0);
+      if (sliceConfig.w === 0 && sliceConfig.h === 0) {
+        // 选择整图时,w/h 为0
+        sliceConfig.w = image.naturalWidth;
+        sliceConfig.h = image.naturalHeight;
+      }
+
+      if (x <= 1 && y <= 1 && sliceConfig.w <= 1 && sliceConfig.h <= 1) {
+        sliceConfig.x = image.naturalWidth * x;
+        sliceConfig.y = image.naturalHeight * y;
+        sliceConfig.w = image.naturalWidth * w;
+        sliceConfig.h = image.naturalHeight * h;
+      }
+    }
+
+    theFinalHeight.value = markStore.currentTask.sliceConfig
+      .map((v) => v.h)
+      .reduce((acc, v) => (acc += v));
+    maxSliceWidth.value = Math.max(
+      ...markStore.currentTask.sliceConfig.map((v) => v.w)
+    );
+
+    // 用来保存sliceImage在整个图片容器中(不包括image-seperator)的高度范围
+    let accumTopHeight = 0;
+    let accumBottomHeight = 0;
+    const trackLists = hasMarkResultToRender
+      ? markResult.trackList
+      : markStore.currentTask.questionList.map((q) => q.trackList).flat();
+
+    const tagLists = hasMarkResultToRender
+      ? markResult.specialTagList ?? []
+      : markStore.currentTask.specialTagList ?? [];
+
+    const tempSliceImagesWithTrackList: Array<SliceImage> = [];
+    for (const sliceConfig of markStore.currentTask.sliceConfig) {
+      accumBottomHeight += sliceConfig.h;
+      const url = markStore.currentTask.sliceUrls[sliceConfig.i - 1];
+      const indexInSliceUrls = sliceConfig.i;
+      const image = images[sliceConfig.i];
+
+      const dataUrl = await getDataUrlForSliceConfig(
+        image,
+        sliceConfig,
+        maxSliceWidth.value,
+        url
+      );
+
+      const thisImageTrackList = trackLists.filter(
+        (t) => t.offsetIndex === indexInSliceUrls
+      );
+
+      const thisImageTagList = tagLists.filter(
+        (t) => t.offsetIndex === indexInSliceUrls
+      );
+
+      const sliceImageRendered = await loadImage(dataUrl);
+      tempSliceImagesWithTrackList.push({
+        url: dataUrl,
+        indexInSliceUrls: sliceConfig.i,
+        // 通过positionY来定位是第几张slice的还原,并过滤出相应的track
+        trackList: thisImageTrackList.filter(
+          (t) =>
+            t.positionY >= accumTopHeight / theFinalHeight.value &&
+            t.positionY < accumBottomHeight / theFinalHeight.value
+        ),
+        tagList: thisImageTagList.filter(
+          (t) =>
+            t.positionY >= accumTopHeight / theFinalHeight.value &&
+            t.positionY < accumBottomHeight / theFinalHeight.value
+        ),
+        // originalImageWidth: image.naturalWidth,
+        // originalImageHeight: image.naturalHeight,
+        sliceImageWidth: sliceImageRendered.naturalWidth,
+        sliceImageHeight: sliceImageRendered.naturalHeight,
+        dx: sliceConfig.x,
+        dy: sliceConfig.y,
+        accumTopHeight,
+        effectiveWidth: sliceConfig.w,
+      });
+      accumTopHeight = accumBottomHeight;
+    }
+
+    // 测试是否所有的track和tag都在待渲染的tempSliceImagesWithTrackList中
+    const numOfTrackAndTagInData = trackLists.length + tagLists.length;
+    const numOfTrackAndTagInTempSlice = tempSliceImagesWithTrackList
+      .map((v) => v.trackList.length + v.tagList.length)
+      .reduce((p, c) => p + c);
+    if (numOfTrackAndTagInData !== numOfTrackAndTagInTempSlice) {
+      console.warn({ tagLists, trackLists, tempSliceImagesWithTrackList });
+      void message.warn("渲染轨迹数量与实际数量不一致");
+    }
+
+    // console.log("render: ", store.currentTask.secretNumber);
+    if (sliceImagesWithTrackList.length === 0) {
+      // 初次渲染,不做动画
+      sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
+      // 没抽象好,这里不好做校验
+      // const renderedTrackAndTagNumber = sliceImagesWithTrackList.map(s => s.trackList.length + s.tagList.length).reduce((p,c) => p+ c);
+      // if(renderedTrackAndTagNumber === thisIma)
+    } else {
+      rotateBoard.value = 1;
+      setTimeout(() => {
+        sliceImagesWithTrackList.splice(0);
+        sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
+        setTimeout(() => {
+          rotateBoard.value = 0;
+        }, 300);
+      }, 300);
+    }
+  }
+
+  async function processSplitConfig() {
+    if (!markStore.currentTask) return;
+    const markResult = markStore.currentTask.markResult;
+    if (hasMarkResultToRender) {
+      // check if have MarkResult for currentTask
+      if (!markResult) return;
+    }
+
+    const images = [];
+    for (const url of markStore.currentTask.sliceUrls) {
+      const image = await loadImage(url);
+      images.push(image);
+    }
+
+    // 如果拒绝裁切,则保持整卷
+    if (!markStore.setting.enableSplit) {
+      markStore.setting.splitConfig = [0, 1];
+    }
+    // 裁切块,可能是一块,两块,三块... [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 = markStore.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(
+      ...markStore.setting.splitConfig.filter((v, i) => i % 2)
+    );
+    maxSliceWidth.value =
+      Math.max(...images.map((v) => v.naturalWidth)) * maxSplitConfig;
+
+    theFinalHeight.value =
+      splitConfigPairs.length *
+      images.reduce((acc, v) => (acc += v.naturalHeight), 0);
+
+    // 高度比宽度大的图片不裁切
+    const imagesOfBiggerHeight = images.filter(
+      (v) => v.naturalHeight > v.naturalWidth
+    );
+    if (imagesOfBiggerHeight.length > 0) {
+      maxSliceWidth.value = Math.max(
+        maxSliceWidth.value,
+        ...imagesOfBiggerHeight.map((v) => v.naturalWidth)
+      );
+      // 不裁切的图剪切多加的高度
+      theFinalHeight.value -=
+        imagesOfBiggerHeight
+          .map((v) => v.naturalHeight)
+          .reduce((p, c) => p + c) *
+        (splitConfigPairs.length - 1);
+    }
+
+    let accumTopHeight = 0;
+    let accumBottomHeight = 0;
+    const tempSliceImagesWithTrackList: SliceImage[] = [];
+    const trackLists = hasMarkResultToRender
+      ? markResult.trackList
+      : (markStore.currentTask.questionList || [])
+          .map((q) => q.trackList)
+          .flat();
+    const tagLists = hasMarkResultToRender
+      ? markResult.specialTagList ?? []
+      : markStore.currentTask.specialTagList ?? [];
+    for (const url of markStore.currentTask.sliceUrls) {
+      for (const config of splitConfigPairs) {
+        const indexInSliceUrls =
+          markStore.currentTask.sliceUrls.indexOf(url) + 1;
+        const image = images[indexInSliceUrls - 1];
+        let shouldBreak = false;
+        let [splitConfigStart, splitConfigEnd] = config;
+        if (image.naturalHeight > image.naturalWidth) {
+          splitConfigStart = 0;
+          splitConfigEnd = 1;
+          shouldBreak = true;
+        }
+
+        accumBottomHeight += image.naturalHeight;
+
+        const dataUrl = await getDataUrlForSplitConfig(
+          image,
+          [splitConfigStart, splitConfigEnd],
+          maxSliceWidth.value,
+          url
+        );
+
+        const thisImageTrackList = trackLists.filter(
+          (t) => t.offsetIndex === indexInSliceUrls
+        );
+
+        const thisImageTagList = tagLists.filter(
+          (t) => t.offsetIndex === indexInSliceUrls
+        );
+        const sliceImageRendered = await loadImage(dataUrl);
+        tempSliceImagesWithTrackList.push({
+          url: dataUrl,
+          indexInSliceUrls: markStore.currentTask.sliceUrls.indexOf(url) + 1,
+          trackList: thisImageTrackList.filter(
+            (t) =>
+              t.positionY >= accumTopHeight / theFinalHeight.value &&
+              t.positionY < accumBottomHeight / theFinalHeight.value
+          ),
+          tagList: thisImageTagList.filter(
+            (t) =>
+              t.positionY >= accumTopHeight / theFinalHeight.value &&
+              t.positionY < accumBottomHeight / theFinalHeight.value
+          ),
+          // originalImageWidth: image.naturalWidth,
+          // originalImageHeight: image.naturalHeight,
+          sliceImageWidth: sliceImageRendered.naturalWidth,
+          sliceImageHeight: sliceImageRendered.naturalHeight,
+          dx: image.naturalWidth * splitConfigStart,
+          dy: 0,
+          accumTopHeight,
+          effectiveWidth: image.naturalWidth * splitConfigEnd,
+        });
+        accumTopHeight = accumBottomHeight;
+
+        // 如果本图高比宽大,不该裁切,则跳过多次裁切
+        if (shouldBreak) {
+          break;
+        }
+      }
+    }
+
+    // 测试是否所有的track和tag都在待渲染的tempSliceImagesWithTrackList中
+    const numOfTrackAndTagInData = trackLists.length + tagLists.length;
+    const numOfTrackAndTagInTempSlice = tempSliceImagesWithTrackList
+      .map((v) => v.trackList.length + v.tagList.length)
+      .reduce((p, c) => p + c);
+    if (numOfTrackAndTagInData !== numOfTrackAndTagInTempSlice) {
+      console.warn({ tagLists, trackLists, tempSliceImagesWithTrackList });
+      void message.warn("渲染轨迹数量与实际数量不一致");
+    }
+
+    rotateBoard.value = 1;
+    addTimeout(() => {
+      sliceImagesWithTrackList.splice(0);
+      sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
+      addTimeout(() => {
+        rotateBoard.value = 0;
+      }, 300);
+    }, 300);
+  }
+
+  // should not render twice at the same time
+  const renderPaperAndMark = async () => {
+    if (!markStore.currentTask) return;
+    if (!markStore.isScanImage) return;
+    if (markStore.renderLock) {
+      console.log("上个任务还未渲染完毕,稍等一秒再尝试渲染");
+      await new Promise((res) => setTimeout(res, 1000));
+      await renderPaperAndMark();
+      return;
+    }
+    // check if have MarkResult for currentTask
+    const markResult = markStore.currentTask.markResult;
+    if (hasMarkResultToRender && !markResult) {
+      return;
+    }
+
+    markStore.renderLock = true;
+    try {
+      markStore.globalMask = true;
+
+      const hasSliceConfig = markStore.currentTask.sliceConfig?.length;
+
+      if (hasSliceConfig) {
+        await processSliceConfig();
+      } else {
+        await processSplitConfig();
+      }
+    } catch (error) {
+      sliceImagesWithTrackList.splice(0);
+      console.trace("render error ", error);
+      // 图片加载出错,自动加载下一个任务
+      emit("error");
+    } finally {
+      markStore.renderLock = false;
+      markStore.globalMask = false;
+    }
+  };
+
+  // 在阻止渲染的情况下,watchEffect收集不到 store.currentTask 的依赖,会导致本组件不再更新
+  watch(
+    () => markStore.currentTask,
+    () => {
+      setTimeout(renderPaperAndMark, 50);
+    }
+  );
+
+  watch(
+    () => sliceImagesWithTrackList,
+    () => {
+      EventBus.emit("draw-change", sliceImagesWithTrackList);
+    },
+    { deep: true }
+  );
+
+  return { maxSliceWidth, theFinalHeight, sliceImagesWithTrackList };
+}

+ 2 - 0
src/features/mark/stores/mark.ts

@@ -58,6 +58,8 @@ const useMarkStore = defineStore("mark", {
     allPaperModal: false,
     sheetViewModal: false,
     globalMask: false,
+    // 任务渲染锁状态
+    renderLock: false,
   }),
 
   getters: {

+ 0 - 0
src/features/mark/toolbar/MarkChangeProfile.vue → src/features/mark/toolbar/MarkChangeProfileDialog.vue


+ 17 - 24
src/features/mark/toolbar/MarkHeader.vue

@@ -174,19 +174,21 @@
       </div>
     </div>
   </div>
-  <MarkChangeProfile ref="changeProfileRef" />
+  <!-- <MarkChangeProfileDialog /> -->
   <MarkSwitchGroupDialog ref="switchGroupRef" />
 </template>
 
 <script setup lang="ts">
-import { doLogout, updateUISetting, clearMarkTask } from "@/api/markPage";
 import { watch, watchEffect } from "vue";
-import { useMarkStore } from "@/store";
-import MarkChangeProfile from "./MarkChangeProfile.vue";
-import MarkSwitchGroupDialog from "./MarkSwitchGroupDialog.vue";
-import { isNumber } from "lodash-es";
 import { Modal } from "ant-design-vue";
 import { CaretDownOutlined } from "@ant-design/icons-vue";
+import { isNumber } from "lodash-es";
+
+import { useMarkStore } from "@/store";
+import { doLogout, updateUISetting, clearMarkTask } from "@/api/markPage";
+
+// import MarkChangeProfileDialog from "./MarkChangeProfileDialog.vue";
+import MarkSwitchGroupDialog from "./MarkSwitchGroupDialog.vue";
 
 const props = defineProps<{ showTotalScore?: boolean }>();
 
@@ -235,22 +237,22 @@ const totalScore = $computed(() => {
   );
 });
 
+const todoCount = $computed(() =>
+  typeof markStore.status.totalCount === "number"
+    ? markStore.status.totalCount -
+      markStore.status.markedCount -
+      markStore.status.problemCount -
+      markStore.status.arbitrateCount
+    : "-"
+);
+
 const logout = async () => {
   await clearMarkTask();
   doLogout();
 };
 
-let changeProfileRef = $ref<InstanceType<typeof MarkChangeProfile>>();
-
-// 应该是@typescript-eslint/no-unsafe-call不完美的搞法,导致了下面的调用会出错
 type ShowModalFunc = () => void;
-
-// const openProfileModal = () => {
-//   (changeProfileRef.showModal as ShowModalFunc)();
-// };
-
 let switchGroupRef = $ref<InstanceType<typeof MarkSwitchGroupDialog>>();
-
 const openSwitchGroupModal = () => {
   (switchGroupRef.showModal as ShowModalFunc)();
 };
@@ -274,15 +276,6 @@ watchEffect(() => {
   }
 });
 
-const todoCount = $computed(() =>
-  typeof markStore.status.totalCount === "number"
-    ? markStore.status.totalCount -
-      markStore.status.markedCount -
-      markStore.status.problemCount -
-      markStore.status.arbitrateCount
-    : "-"
-);
-
 let questionMarkShouldChange = $ref(false);
 watch(
   () => [markStore.status.problemCount, markStore.status.arbitrateCount],

+ 2 - 0
src/types/index.ts

@@ -57,6 +57,8 @@ export interface MarkStore {
   sheetViewModal: boolean;
   /** 是否全局遮盖 */
   globalMask: boolean;
+  /** 任务渲染锁状态 */
+  renderLock: boolean;
 }
 
 export interface Setting {

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels