ソースを参照

feat: 主客观检查

zhangjie 4 ヶ月 前
コミット
0b481e93a1

+ 287 - 0
src/features/check-objective/CheckObjective.vue

@@ -0,0 +1,287 @@
+<template>
+  <div class="mark check-paper">
+    <div class="mark-header">
+      <div class="mark-header-part">
+        <template v-if="checkObjectiveStore.student">
+          <div class="header-noun">
+            <span>课程名称:</span>
+            <span>
+              {{ checkObjectiveStore.student.courseName }}({{
+                checkObjectiveStore.student.courseCode
+              }})</span
+            >
+          </div>
+          <div class="header-noun">
+            <span>试卷编号:</span>
+            <span>{{ checkObjectiveStore.student.paperNumber }}</span>
+          </div>
+          <div class="header-noun">
+            <span>姓名:</span>
+            <span>{{ checkObjectiveStore.student.studentName }}</span>
+          </div>
+          <div class="header-noun">
+            <span>学号:</span>
+            <span>{{ checkObjectiveStore.student.studentCode }}</span>
+          </div>
+          <div v-if="isMultiStudent" class="header-noun">
+            <span>进度:</span>
+            <span>
+              {{ currentIndex + 1 }}/{{ checkObjectiveStore.studentCount }}
+            </span>
+          </div>
+        </template>
+      </div>
+      <div class="mark-header-part">
+        <div class="paper-menu">
+          <span
+            v-for="(u, index) in checkObjectiveStore.student?.sheetUrls"
+            :key="index"
+            :class="{ 'is-active': checkObjectiveStore.currentImage === index }"
+            @click="checkObjectiveStore.currentImage = index"
+          >
+            {{ index + 1 }}
+          </span>
+        </div>
+        <div class="header-text-btn header-logout" @click="logout">
+          <img class="header-icon" src="@/assets/icons/icon-return.svg" />返回
+        </div>
+      </div>
+    </div>
+
+    <div class="mark-main">
+      <div class="mark-body">
+        <div
+          v-if="
+            checkObjectiveStore.student &&
+            checkObjectiveStore.currentImage !== 0
+          "
+          class="page-action page-prev"
+          title="上一张"
+          @click="switchImageArrow({ left: true })"
+        >
+          <ArrowLeftOutlined />
+        </div>
+        <div
+          v-if="
+            checkObjectiveStore.student &&
+            checkObjectiveStore.currentImage !==
+              checkObjectiveStore.student.sheetUrls.length - 1
+          "
+          class="page-action page-next"
+          title="上一张"
+          @click="switchImageArrow({ right: true })"
+        >
+          <ArrowRightOutlined />
+        </div>
+        <div class="mark-body-container">
+          <div v-if="!checkObjectiveStore.student" class="mark-body-none">
+            <div>
+              <img src="@/assets/image-none-task.png" />
+              <p>暂无数据</p>
+            </div>
+          </div>
+          <div
+            v-else
+            class="single-image-container"
+            :style="{ width: answerPaperScale, fontSize: answerPaperFontSize }"
+          >
+            <img
+              id="mark-body-paper"
+              draggable="false"
+              :src="curImageUrl"
+              :style="{
+                transform:
+                  (rotateDegree ? 'translate( 0,  calc(30vh))' : '') +
+                  `rotate(${rotateDegree}deg)`,
+              }"
+              @click="switchImage"
+              @contextmenu="showBigImage"
+              @load="paperLoadHandle"
+            />
+            <div
+              v-for="(tag, tindex) in answerTags"
+              :key="tindex"
+              :style="tag.style"
+            >
+              {{ tag.answer }}
+            </div>
+            <div
+              v-for="(tag, tindex) in optionsBlocks"
+              :key="tindex + 'block'"
+              :style="tag.style"
+            ></div>
+          </div>
+        </div>
+        <ZoomPaper
+          v-if="checkObjectiveStore.student"
+          showRotate
+          fixed
+          @rotateRight="rotateRight"
+        />
+      </div>
+
+      <div class="mark-board-track">
+        <div class="board-header no-action">
+          <div class="board-header-info">
+            <img src="@/assets/icons/icon-star.svg" />
+            <span>总分</span>
+          </div>
+          <div class="board-header-score">
+            <transition-group name="score-number-animation" tag="span">
+              <span :key="totalScore">{{ totalScore }}</span>
+            </transition-group>
+          </div>
+        </div>
+        <div class="paper-topics">
+          <div
+            v-for="group in answersComputed"
+            :key="group.mainNumber"
+            class="paper-topic"
+          >
+            <h2 class="paper-topic-title">
+              {{ group.mainNumber }}、{{ group.mainTitle }} ({{
+                group.subs.length
+              }})
+            </h2>
+            <div class="paper-topic-body">
+              <div
+                v-for="question in group.subs"
+                :key="question.subNumber"
+                class="paper-topic-question"
+              >
+                <span class="question-number">{{ question.subNumber }} </span>
+                <a-input
+                  class="normal-input"
+                  :class="{
+                    'long-input': question.type
+                      ? !['SINGLE', 'TRUE_OR_FALSE'].includes(question.type)
+                      : !group.mainTitle.match(/单选|单项|判断/),
+                  }"
+                  :value="question.answer"
+                  :maxLength="
+                    (
+                      question.type
+                        ? ['MULTIPLE'].includes(question.type)
+                        : group.mainTitle.match(/多选|多项|不定项/)
+                    )
+                      ? 100
+                      : 1
+                  "
+                  @keydown="onPreventAnswerKey"
+                  @input="changeAnswer($event, question)"
+                  @blur="changeAnswer($event, question, '#')"
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div :class="['board-footer', { 'is-simple': !isMultiStudent }]">
+          <qm-button
+            class="board-submit"
+            size="medium"
+            type="primary"
+            :disabled="!checkObjectiveStore.student?.upload"
+            @click="saveStudentAnswer"
+          >
+            保存
+          </qm-button>
+          <div v-if="isMultiStudent" class="student-switch">
+            <a-button :disabled="isFirst" @click="getPreviousStudent">
+              上一份
+            </a-button>
+            <a-button :disabled="isLast" @click="getNextStudent">
+              下一份
+            </a-button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons-vue";
+import { PaperRecogData, AnswerTagItem } from "@/types";
+import { doLogout } from "@/api/markPage";
+import vls from "@/utils/storage";
+
+import { useCheckObjectiveStore } from "@/store";
+import useBodyScroll from "../mark/composables/useBodyScroll";
+import useBigImage from "../mark/composables/useBigImage";
+import useTrackTag from "../track/composables/useTrackTag";
+import useStudent from "./composables/useStudent";
+import useAnswer from "./composables/useAnswer";
+import useImage from "./composables/useImage";
+
+import ZoomPaper from "@/components/ZoomPaper.vue";
+
+const studentIds: string[] = vls.get("check-students", []);
+
+const checkObjectiveStore = useCheckObjectiveStore();
+const { parseAnswerTagsFromRecogData } = useTrackTag();
+const { saveStudentAnswer, onPreventAnswerKey, changeAnswer } = useAnswer();
+const { switchImageArrow, switchImage } = useImage();
+const { showBigImage } = useBigImage();
+
+checkObjectiveStore.setInfo({
+  studentIds,
+});
+
+const {
+  currentIndex,
+  isFirst,
+  isLast,
+  isMultiStudent,
+  totalScore,
+  curImageUrl,
+  answersComputed,
+  getNextStudent,
+  getPreviousStudent,
+} = useStudent();
+
+// 试卷加载之后,解析答题区域
+let answerTags = $ref<AnswerTagItem[]>([]);
+let optionsBlocks = $ref([]);
+function paperLoadHandle() {
+  if (
+    !checkObjectiveStore.student?.sheetUrls[checkObjectiveStore.currentImage]
+      ?.recogData
+  ) {
+    answerTags = [];
+    return;
+  }
+  const imgDom = document.getElementById("mark-body-paper");
+  const { naturalWidth, naturalHeight } = imgDom;
+
+  const recogData: PaperRecogData = JSON.parse(
+    window.atob(
+      checkObjectiveStore.student?.sheetUrls[checkObjectiveStore.currentImage]
+        ?.recogData || ""
+    )
+  );
+
+  answerTags = parseAnswerTagsFromRecogData(
+    recogData,
+    checkObjectiveStore.answerMap,
+    {
+      naturalWidth,
+      naturalHeight,
+    }
+  );
+}
+
+// 放大缩小和之后的滚动
+const { answerPaperScale, answerPaperFontSize } = useBodyScroll();
+
+// 旋转
+let rotateDegree = $ref(0);
+function rotateRight() {
+  rotateDegree = (rotateDegree + 90) % 360;
+}
+
+// 退出
+function logout() {
+  doLogout();
+}
+</script>

+ 85 - 0
src/features/check-objective/composables/useAnswer.ts

@@ -0,0 +1,85 @@
+import { useCheckObjectiveStore } from "@/store";
+import { saveStudentObjectiveConfirmData } from "@/api/checkPage";
+import { message } from "ant-design-vue";
+import { useStudent } from "../useStudent";
+
+export default function useAnswer() {
+  const checkObjectiveStore = useCheckObjectiveStore();
+  const { updateCurStudent, getNextStudent } = useStudent();
+
+  // 修改答案
+  const allowKey = [
+    "Delete",
+    "Backspace",
+    "ArrowLeft",
+    "ArrowRight",
+    "#",
+    "Shift",
+    "[A-Za-z]",
+  ];
+  const allowKeyRef = new RegExp(allowKey.join("|"));
+  function onPreventAnswerKey(e: KeyboardEvent) {
+    if (!allowKeyRef.test(e.key)) {
+      e.preventDefault();
+    }
+  }
+
+  function changeAnswer(event: Event, question: string, defaultValue?: string) {
+    const target = event.target as HTMLInputElement;
+    checkObjectiveStore.student.answers =
+      checkObjectiveStore.student.answers.map((v) => {
+        if (
+          v.mainNumber === question.mainNumber &&
+          v.subNumber === question.subNumber
+        ) {
+          v.answer = target?.value.toUpperCase().trim() || defaultValue || "";
+        }
+        return v;
+      });
+  }
+
+  // 保存答案
+  let loading = false;
+  async function saveStudentAnswer() {
+    if (!checkObjectiveStore.student) return;
+
+    if (loading) return;
+    loading = true;
+
+    const data = {
+      studentId: checkObjectiveStore.student.studentId,
+      answers: checkObjectiveStore.student.answers
+        .map((v) => v.answer || "#")
+        .join(","),
+    };
+    // if (!answers.match(/^(#*,*[A-Z]*)+$/g)) {
+    //   void message.error("答案只能是#和大写英文字母");
+    //   return;
+    // }
+
+    const res = await saveStudentObjectiveConfirmData(data).catch(() => false);
+    loading = false;
+    if (!res) {
+      void message.error("保存失败,请刷新页面。");
+    } else {
+      void message.success("保存成功");
+
+      if (!checkObjectiveStore.isMultiStudent) {
+        window.close();
+        return;
+      }
+
+      if (checkObjectiveStore.isLast) {
+        await updateCurStudent();
+      } else {
+        await getNextStudent();
+      }
+    }
+  }
+
+  return {
+    onPreventAnswerKey,
+    changeAnswer,
+    saveStudentAnswer,
+  };
+}

+ 59 - 0
src/features/check-objective/composables/useImage.ts

@@ -0,0 +1,59 @@
+import { watch } from "vue";
+import { useCheckObjectiveStore } from "@/store";
+
+export default function useImage() {
+  const checkObjectiveStore = useCheckObjectiveStore();
+
+  watch(
+    () => checkObjectiveStore.currentImage,
+    () => {
+      checkObjectiveStore.browsedImageIndexes.push(
+        checkObjectiveStore.currentImage
+      );
+    }
+  );
+  // 显示大图,供查看和翻转
+  function switchImageArrow({
+    left = false,
+    right = false,
+  }: {
+    left?: boolean;
+    right?: boolean;
+  }) {
+    if (left) {
+      if (checkObjectiveStore.currentImage > 0) {
+        checkObjectiveStore.currentImage--;
+      }
+    }
+    if (right) {
+      if (
+        checkObjectiveStore.currentImage <
+        checkObjectiveStore.student?.sheetUrls.length - 1
+      ) {
+        checkObjectiveStore.currentImage++;
+      }
+    }
+  }
+
+  function switchImage(event: MouseEvent) {
+    const image = event.target as HTMLImageElement;
+    const layerX: number = (event as any).layerX;
+    if (layerX * 2 < image.width) {
+      if (checkObjectiveStore.currentImage > 0) {
+        checkObjectiveStore.currentImage--;
+      }
+    } else {
+      if (
+        checkObjectiveStore.currentImage <
+        checkObjectiveStore.student?.sheetUrls.length - 1
+      ) {
+        checkObjectiveStore.currentImage++;
+      }
+    }
+  }
+
+  return {
+    switchImageArrow,
+    switchImage,
+  };
+}

+ 120 - 0
src/features/check-objective/composables/useStudent.ts

@@ -0,0 +1,120 @@
+import { StudentObjectiveInfo } from "@/types";
+import { studentObjectiveConfirmData } from "@/api/checkPage";
+import { message } from "ant-design-vue";
+import { onMounted } from "vue";
+import { useCheckObjectiveStore } from "@/store";
+
+export default function useStudent() {
+  const checkObjectiveStore = useCheckObjectiveStore();
+
+  const currentIndex = $computed(() =>
+    checkObjectiveStore.studentIds.indexOf(checkObjectiveStore.currentStudentId)
+  );
+  const isFirst = $computed(() => currentIndex === 0);
+  const isLast = $computed(
+    () => currentIndex === checkObjectiveStore.studentIds.length - 1
+  );
+  const isMultiStudent = $computed(
+    () => checkObjectiveStore.studentIds.length > 1
+  );
+
+  const totalScore = $computed(() => {
+    if (!checkObjectiveStore.student) return 0;
+    return checkObjectiveStore.student.objectiveScore || 0;
+  });
+
+  const curImageUrl = $computed(() =>
+    checkObjectiveStore.student
+      ? checkObjectiveStore.student.sheetUrls[checkObjectiveStore.currentImage]
+          ?.url
+      : ""
+  );
+
+  const answersComputed = $computed(() => {
+    let mains = checkObjectiveStore.student?.answers.map((v) => ({
+      mainTitle: "",
+      mainNumber: v.mainNumber,
+      subs: [v],
+    }));
+    const mSet = new Set();
+    mains = mains?.filter((v) => {
+      if (!mSet.has(v.mainNumber)) {
+        mSet.add(v.mainNumber);
+        v.subs = [];
+        return true;
+      }
+    });
+    mains?.forEach((v) => {
+      v.mainTitle = checkObjectiveStore.student?.titles[v.mainNumber] ?? "";
+      v.subs =
+        checkObjectiveStore.student?.answers.filter(
+          (v2) => v2.mainNumber === v.mainNumber
+        ) ?? [];
+    });
+    return mains;
+  });
+
+  async function getNextStudent() {
+    if (isLast) {
+      void message.warning("已经是最后一份!");
+      return;
+    }
+    await getStudent(checkObjectiveStore.studentIds[currentIndex + 1]);
+  }
+
+  async function getPreviousStudent() {
+    if (isFirst) {
+      void message.warning("已经是第一份!");
+      return;
+    }
+    await getStudent(checkObjectiveStore.studentIds[currentIndex - 1]);
+  }
+
+  async function getStudent(studentId: string) {
+    let dataError = false;
+    const res = await studentObjectiveConfirmData(studentId).catch(() => {
+      dataError = true;
+    });
+    if (dataError) {
+      void message.error(res.message, 24 * 60 * 60);
+      throw new Error("取学生信息出错: " + res.message);
+    }
+
+    checkObjectiveStore.student = res.data as StudentObjectiveInfo;
+    checkObjectiveStore.currentStudentId = studentId;
+    checkObjectiveStore.currentImage = 0;
+    checkObjectiveStore.browsedImageIndexes = [0];
+    checkObjectiveStore.answerMap = {};
+    checkObjectiveStore.student?.answers.forEach((item) => {
+      checkObjectiveStore.answerMap[`${item.mainNumber}_${item.subNumber}`] = {
+        answer: item.answer,
+        isRight: item.answer === item.standardAnswer,
+      };
+    });
+  }
+
+  async function updateCurStudent() {
+    await getStudent(checkObjectiveStore.studentIds[currentIndex]);
+  }
+
+  onMounted(async () => {
+    if (checkObjectiveStore.studentIds.length === 0) {
+      void message.info("没有需要处理的考生,请返回。");
+      return;
+    }
+    await getNextStudent();
+  });
+
+  return {
+    currentIndex,
+    isFirst,
+    isLast,
+    isMultiStudent,
+    totalScore,
+    curImageUrl,
+    answersComputed,
+    getNextStudent,
+    getPreviousStudent,
+    updateCurStudent,
+  };
+}

+ 58 - 0
src/features/check-objective/store.ts

@@ -0,0 +1,58 @@
+import { defineStore } from "pinia";
+import { StudentObjectiveInfo } from "@/types";
+
+interface CheckObjectiveStore {
+  studentIds: string[];
+  currentStudentId: string;
+  student: StudentObjectiveInfo | null;
+  currentImage: number;
+  browsedImageIndexes: number[];
+  answerMap: Record<string, { answer: string; isRight: boolean }>;
+}
+
+const useCheckObjectiveStore = defineStore<
+  "checkObjective",
+  CheckObjectiveStore
+>("checkObjective", {
+  state: () => ({
+    studentIds: [],
+    currentStudentId: "",
+    student: null,
+    currentImage: 0,
+    browsedImageIndexes: [],
+    answerMap: {},
+  }),
+  getters: {
+    info(state: CheckObjectiveStore): CheckObjectiveStore {
+      return { ...state };
+    },
+    studentCount(state: CheckObjectiveStore): number {
+      return state.studentIds.length;
+    },
+    isLast(state: CheckObjectiveStore): boolean {
+      return (
+        state.studentIds.indexOf(state.currentStudentId) ===
+        state.studentIds.length - 1
+      );
+    },
+    isFirst(state: CheckObjectiveStore): boolean {
+      return state.studentIds.indexOf(state.currentStudentId) === 0;
+    },
+    isMultiStudent(state: CheckObjectiveStore): boolean {
+      return state.studentIds.length > 1;
+    },
+  },
+  actions: {
+    setInfo(partial: Partial<CheckObjectiveStore>) {
+      this.$patch(partial);
+    },
+    resetInfo() {
+      this.$reset();
+    },
+  },
+  persist: {
+    storage: sessionStorage,
+  },
+});
+
+export default useCheckObjectiveStore;

+ 0 - 0
src/features/check/SubjectiveAnswer.vue → src/features/check-subjective/CheckSubjective.vue


+ 18 - 0
src/features/check-subjective/MarkBody.vue

@@ -0,0 +1,18 @@
+<template>
+  <MarkBodyBase
+    hasMarkResultToRender
+    :makeTrack="makeTrack"
+    @error="$emit('error')"
+  />
+  <MarkBodyCursor />
+</template>
+
+<script setup lang="ts">
+import MarkBodyCursor from "../mark/MarkBodyCursor.vue";
+import MarkBodyBase from "../mark/MarkBodyBase.vue";
+import useMakeTrack from "../mark/composables/useMakeTrack";
+
+defineEmits(["error"]);
+
+const { makeTrack } = useMakeTrack();
+</script>

+ 186 - 0
src/features/check-subjective/MarkBodyBase.vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="mark-body">
+    <div v-if="markStatus" class="mark-body-status">
+      {{ markStatus }}
+    </div>
+    <div v-if="markStore.currentTask?.paperType" class="mark-body-papertype">
+      {{ markStore.currentTask?.paperType }}卷
+    </div>
+    <div ref="dragContainer" class="mark-body-container">
+      <div v-if="!markStore.currentTask" class="mark-body-none">
+        <div>
+          <img src="@/assets/image-none-task.png" />
+          <p>
+            {{ markStore.message }}
+          </p>
+        </div>
+      </div>
+      <div
+        v-else-if="markStore.isScanImage"
+        :style="{ width: answerPaperScale }"
+        :class="[`rotate-board-${rotateBoard}`]"
+      >
+        <template
+          v-for="(item, index) in sliceImagesWithTrackList"
+          :key="index"
+        >
+          <div class="single-image-container">
+            <img
+              :src="item.url"
+              draggable="false"
+              @click="(event) => innerMakeTrack(event, item)"
+              @contextmenu="showBigImage"
+            />
+            <MarkDrawTrack
+              :trackList="item.trackList"
+              :specialTagList="item.tagList"
+              :sliceImageHeight="item.originalImageHeight"
+              :sliceImageWidth="item.originalImageWidth"
+              :dx="item.dx"
+              :dy="item.dy"
+              @clickSpecialtag="(event) => clickSpecialtag(event, item)"
+            />
+            <MarkBodySepecialTag
+              :maxSliceWidth="maxSliceWidth"
+              :theFinalHeight="theFinalHeight"
+              :sliceImageItem="item"
+            />
+          </div>
+          <hr class="image-seperator" />
+        </template>
+      </div>
+      <div v-else>未知数据</div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { watch, watchEffect } from "vue";
+import { message } from "ant-design-vue";
+import type { SliceImage } from "@/types";
+import { useMarkStore } from "@/store";
+
+// components
+import MarkBodySepecialTag from "../mark/MarkBodySepecialTag.vue";
+import MarkDrawTrack from "../mark/MarkDrawTrack.vue";
+// composables
+import useBigImage from "../mark/composables/useBigImage";
+import useDraggable from "../mark/composables/useDraggable";
+import useBodyScroll from "../mark/composables/useBodyScroll";
+import useSliceTrack from "./composables/useSliceTrack";
+
+type MakeTrack = (
+  event: MouseEvent,
+  item: SliceImage,
+  maxSliceWidth: number,
+  theFinalHeight: number
+) => void | (() => void);
+
+const {
+  hasMarkResultToRender = false,
+  makeTrack = () => console.debug("非评卷界面makeTrack没有意义"),
+} = defineProps<{
+  hasMarkResultToRender?: boolean;
+  makeTrack?: MakeTrack;
+}>();
+
+const markStore = useMarkStore();
+const { showBigImage } = useBigImage();
+const { answerPaperScale } = useBodyScroll({
+  shortCut: true,
+  autoScroll: true,
+});
+const { sliceImagesWithTrackList, maxSliceWidth, theFinalHeight } =
+  useSliceTrack();
+
+// 图片拖动。在轨迹模式下,仅当没有选择分数时可用。
+const { dragContainer } = useDraggable();
+
+// 显示评分状态和清除轨迹
+let markStatus = $ref("");
+function initWatch() {
+  if (!hasMarkResultToRender) return;
+
+  watch(
+    () => markStore.currentTask,
+    () => {
+      markStatus = markStore.getMarkStatus;
+    }
+  );
+
+  // 清除分数轨迹
+  watchEffect(() => {
+    for (const track of markStore.removeScoreTracks) {
+      for (const sliceImage of sliceImagesWithTrackList) {
+        sliceImage.trackList = sliceImage.trackList.filter(
+          (t) =>
+            !(
+              t.mainNumber === track.mainNumber &&
+              t.subNumber === track.subNumber &&
+              t.number === track.number
+            )
+        );
+      }
+    }
+    // 清除后,删除,否则会影响下次切换
+    markStore.removeScoreTracks.splice(0);
+  });
+
+  // 清除特殊标记轨迹
+  watchEffect(() => {
+    if (!markStore.currentTask) return;
+    for (const sliceImage of sliceImagesWithTrackList) {
+      sliceImage.tagList = sliceImage.tagList.filter((t) =>
+        markStore.currentTaskEnsured.markResult?.specialTagList.find(
+          (st) =>
+            st.offsetIndex === t.offsetIndex &&
+            st.offsetX === t.offsetX &&
+            st.offsetY === t.offsetY
+        )
+      );
+    }
+    if (markStore.currentTaskEnsured.markResult?.specialTagList.length === 0) {
+      for (const sliceImage of sliceImagesWithTrackList) {
+        sliceImage.tagList = [];
+      }
+    }
+  });
+}
+initWatch();
+
+// 评分
+const checkTrackValid = (event: MouseEvent) => {
+  const { clientWidth, clientHeight, naturalWidth, naturalHeight } =
+    event.target;
+  const { offsetX, offsetY } = event;
+  const xLimitRate = 10 / naturalWidth;
+  const yLimitRate = 10 / naturalHeight;
+  const xRange = [xLimitRate * clientWidth, (1 - xLimitRate) * clientWidth];
+  const yRange = [yLimitRate * clientHeight, (1 - yLimitRate) * clientHeight];
+
+  return (
+    offsetX >= xRange[0] &&
+    offsetX <= xRange[1] &&
+    offsetY >= yRange[0] &&
+    offsetY <= yRange[1]
+  );
+};
+const innerMakeTrack = (event: MouseEvent, item: SliceImage) => {
+  if (!checkTrackValid(event)) {
+    void message.warn("轨迹位置距离边界太近");
+    return;
+  }
+  makeTrack(event, item, maxSliceWidth, theFinalHeight);
+};
+
+// 点击特殊标记
+const clickSpecialtag = (event: MouseEvent, item: SliceImage) => {
+  const e = {
+    target: event.target.offsetParent.childNodes[0],
+    offsetX: event.offsetX + event.target.offsetLeft,
+    offsetY: event.offsetY + event.target.offsetTop,
+  };
+
+  makeTrack(e as MouseEvent, item, maxSliceWidth, theFinalHeight);
+};
+</script>

+ 118 - 0
src/features/check-subjective/composables/useSliceTrack.ts

@@ -0,0 +1,118 @@
+import { ref } from "vue";
+import type { SliceImage } from "@/types";
+import { useMarkStore } from "@/store";
+import { loadImage } from "@/utils/utils";
+import EventBus from "@/plugins/eventBus";
+import useTrackColor from "./useTrackColor";
+
+// 计算裁切图和裁切图上的分数轨迹和特殊标记轨迹
+export default function useSliceTrack() {
+  const emit = defineEmits(["error"]);
+
+  const markStore = useMarkStore();
+  const { addTrackColorAttr, addSpecialTrackColorAttr } = useTrackColor();
+
+  const sliceImagesWithTrackList = $ref<SliceImage[]>([]);
+  const maxSliceWidth = ref(0); // 最大的裁切块宽度,图片容器以此为准
+  const theFinalHeight = ref(0); // 最终宽度,用来定位轨迹在第几张图片,不包括image-seperator高度
+
+  async function processImage() {
+    if (!markStore.currentTask) return;
+    const images = [];
+    const urls = markStore.currentTask.sheetUrls || [];
+    for (const url of urls) {
+      const image = await loadImage(url);
+      images.push(image);
+    }
+
+    maxSliceWidth.value = Math.max(...images.map((i) => i.naturalWidth));
+    theFinalHeight.value = Math.max(...images.map((i) => i.naturalHeight));
+    sliceImagesWithTrackList.splice(0);
+
+    const trackLists = (markStore.currentTask.questionList || [])
+      .map((q) => {
+        const tList = q.trackList;
+        return q.headerTrack?.length
+          ? addHeaderTrackColorAttr(q.headerTrack)
+          : addTrackColorAttr(tList, q.groupNumber);
+      })
+      .flat();
+    let tagLists = markStore.isTrackMode
+      ? markStore.currentTask.specialTagList ?? []
+      : [];
+    tagLists = addSpecialTrackColorAttr(tagLists);
+
+    let accumTopHeight = 0;
+    let accumBottomHeight = 0;
+    for (const url of urls) {
+      const indexInSliceUrls = urls.indexOf(url) + 1;
+      const image = images[indexInSliceUrls - 1];
+      accumBottomHeight += image.naturalHeight;
+
+      const thisImageTrackList = trackLists.filter(
+        (t) => t.offsetIndex === indexInSliceUrls
+      );
+      const thisImageTagList = tagLists.filter(
+        (t) => t.offsetIndex === indexInSliceUrls
+      );
+
+      sliceImagesWithTrackList.push({
+        url,
+        indexInSliceUrls,
+        trackList: thisImageTrackList,
+        tagList: thisImageTagList,
+        originalImageWidth: image.naturalWidth,
+        originalImageHeight: image.naturalHeight,
+        width: (image.naturalWidth / maxSliceWidth.value) * 100 + "%",
+        dx: 0,
+        dy: 0,
+        accumTopHeight,
+      });
+      accumTopHeight = accumBottomHeight;
+    }
+  }
+
+  // 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;
+    }
+
+    markStore.renderLock = true;
+    try {
+      markStore.globalMask = true;
+      await processImage();
+    } 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 };
+}

+ 46 - 0
src/features/check-subjective/composables/useTrackColor.ts

@@ -0,0 +1,46 @@
+import type { SpecialTag, Track } from "@/types";
+
+export default function useTrackColor() {
+  const colors = ["red", "blue", "green"];
+  const colorMap: Record<number, Record<number, string>> = {};
+
+  function addTrackColorAttr(tList: Track[], groupNumber: number): Track[] {
+    let userIds: (number | undefined)[] = tList
+      .map((v) => v.userId)
+      .filter((x) => !!x);
+    userIds = Array.from(new Set(userIds));
+    const isByMultMark = userIds.length > 1;
+
+    tList = tList.map((item) => {
+      const uid = item.userId;
+      if (item.headerMarkScore) {
+        item.color = "green";
+      } else {
+        if (!colorMap[groupNumber]) colorMap[groupNumber] = {};
+        if (!colorMap[groupNumber][uid]) {
+          colorMap[groupNumber][uid] =
+            colors[Object.keys(colorMap[groupNumber]).length] || "green";
+        }
+        item.color = colorMap[groupNumber][uid];
+      }
+      item.isByMultMark = isByMultMark;
+      return item;
+    });
+    return tList;
+  }
+
+  function addSpecialTrackColorAttr(tList: SpecialTag[]): SpecialTag[] {
+    return tList.map((item) => {
+      item.color =
+        colorMap[item.groupNumber] && colorMap[item.groupNumber][item.markerId]
+          ? colorMap[item.groupNumber][item.markerId]
+          : "green";
+      return item;
+    });
+  }
+
+  return {
+    addTrackColorAttr,
+    addSpecialTrackColorAttr,
+  };
+}

+ 0 - 0
src/features/check/objAnswer.js → src/features/check-subjective/test-data/objAnswer.ts


+ 0 - 821
src/features/check/CommonMarkBody.vue

@@ -1,821 +0,0 @@
-<template>
-  <div class="mark-body">
-    <div v-if="markStatus" class="mark-body-status">
-      {{ markStatus }}
-    </div>
-    <div ref="dragContainer" class="mark-body-container">
-      <div v-if="!store.currentTask" class="mark-body-none">
-        <div>
-          <img src="@/assets/image-none-task.png" />
-          <p>
-            {{ store.message }}
-          </p>
-        </div>
-      </div>
-      <div
-        v-else-if="store.isScanImage"
-        :style="{ width: answerPaperScale }"
-        :class="[`rotate-board-${rotateBoard}`]"
-      >
-        <template
-          v-for="(item, index) in sliceImagesWithTrackList"
-          :key="index"
-        >
-          <div class="single-image-container">
-            <img
-              :src="item.url"
-              draggable="false"
-              @click="(event) => innerMakeTrack(event, item)"
-              @contextmenu="showBigImage"
-            />
-            <MarkDrawTrack
-              :trackList="item.trackList"
-              :specialTagList="item.tagList"
-              :sliceImageHeight="item.originalImageHeight"
-              :sliceImageWidth="item.originalImageWidth"
-              :dx="item.dx"
-              :dy="item.dy"
-              @clickSpecialtag="(event) => clickSpecialtag(event, item)"
-            />
-            <div
-              v-if="isCustomSpecialTag"
-              v-ele-move-directive.stop.prevent="{
-                moveStart: (event) => specialMouseStart(event, item),
-                moveElement: specialMouseMove,
-                moveStop: specialMouseStop,
-              }"
-              class="image-canvas"
-              @click="(event) => canvasClick(event, item)"
-            >
-              <template v-if="curSliceImagesWithTrackItem?.url === item.url">
-                <div
-                  v-if="store.currentSpecialTagType === 'LINE'"
-                  :style="specialLenStyle"
-                ></div>
-                <div
-                  v-else-if="store.currentSpecialTagType === 'CIRCLE'"
-                  :style="specialCircleStyle"
-                ></div>
-                <div
-                  v-else-if="store.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>
-          </div>
-          <hr class="image-seperator" />
-        </template>
-      </div>
-      <div v-else-if="store.isMultiMedia">
-        <MultiMediaMarkBody />
-      </div>
-      <div v-else>未知数据</div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-import {
-  onMounted,
-  onUnmounted,
-  reactive,
-  watch,
-  watchEffect,
-  nextTick,
-} from "vue";
-import { store } from "@/store/app";
-import MarkDrawTrack from "../mark/MarkDrawTrack.vue";
-import type { SliceImage, SpecialTag, Track } from "@/types";
-import { useTimers } from "@/setups/useTimers";
-import { loadImage, randomCode, addHeaderTrackColorAttr } from "@/utils/utils";
-import useDraggable from "../mark/composables/useDraggable";
-import MultiMediaMarkBody from "../mark/MultiMediaMarkBody.vue";
-import "viewerjs/dist/viewer.css";
-import Viewer from "viewerjs";
-import { message } from "ant-design-vue";
-import EventBus from "@/plugins/eventBus";
-import { vEleMoveDirective } from "../../directives/eleMove";
-
-type MakeTrack = (
-  event: MouseEvent,
-  item: SliceImage,
-  maxSliceWidth: number,
-  theFinalHeight: number
-) => void | (() => void);
-
-const {
-  hasMarkResultToRender = false,
-  makeTrack = () => console.debug("非评卷界面makeTrack没有意义"),
-} = defineProps<{
-  hasMarkResultToRender?: boolean;
-  makeTrack?: MakeTrack;
-}>();
-
-const emit = defineEmits(["error"]);
-
-const clickSpecialtag = (event: MouseEvent, item: SliceImage) => {
-  // console.log(event);
-  const e = {
-    target: event.target.offsetParent.childNodes[0],
-    offsetX: event.offsetX + event.target.offsetLeft,
-    offsetY: event.offsetY + event.target.offsetTop,
-  };
-
-  makeTrack(e as MouseEvent, item, maxImageWidth, theFinalHeight);
-};
-
-//#region : 图片拖动。在轨迹模式下,仅当没有选择分数时可用。
-const { dragContainer } = useDraggable();
-//#endregion : 图片拖动
-
-const { addTimeout } = useTimers();
-
-//#region : 缩略图定位
-watch(
-  () => [store.minimapScrollToX, store.minimapScrollToY],
-  () => {
-    const container = document.querySelector<HTMLDivElement>(
-      ".mark-body-container"
-    );
-    addTimeout(() => {
-      if (
-        container &&
-        typeof store.minimapScrollToX === "number" &&
-        typeof store.minimapScrollToY === "number"
-      ) {
-        const { scrollWidth, scrollHeight } = container;
-        container.scrollTo({
-          top: scrollHeight * store.minimapScrollToY,
-          left: scrollWidth * store.minimapScrollToX,
-          behavior: "smooth",
-        });
-      }
-    }, 10);
-  }
-);
-//#endregion : 缩略图定位
-
-//#region : 快捷键定位
-const scrollContainerByKey = (e: KeyboardEvent) => {
-  const container = document.querySelector<HTMLDivElement>(
-    ".mark-body-container"
-  );
-  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);
-});
-//#endregion : 快捷键定位
-
-//#region : 计算裁切图和裁切图上的分数轨迹和特殊标记轨迹
-let rotateBoard = $ref(0);
-let sliceImagesWithTrackList: SliceImage[] = reactive([]);
-let maxImageWidth = 0; // 最大的裁切块宽度,图片容器以此为准
-let theFinalHeight = 0; // 最终宽度,用来定位轨迹在第几张图片,不包括image-seperator高度
-
-watch(
-  () => sliceImagesWithTrackList,
-  () => {
-    EventBus.emit("draw-change", sliceImagesWithTrackList);
-  },
-  { deep: true }
-);
-
-const colors = ["red", "blue", "green"];
-let colorMap = {};
-function addTrackColorAttr(tList: Track[], groupNumber: number): Track[] {
-  let userIds: (number | undefined)[] = tList
-    .map((v) => v.userId)
-    .filter((x) => !!x);
-  userIds = Array.from(new Set(userIds));
-  const isByMultMark = userIds.length > 1;
-
-  tList = tList.map((item) => {
-    const uid = item.userId;
-    if (item.headerMarkScore) {
-      item.color = "green";
-    } else {
-      if (!colorMap[groupNumber]) colorMap[groupNumber] = {};
-      if (!colorMap[groupNumber][uid]) {
-        colorMap[groupNumber][uid] =
-          colors[Object.keys(colorMap[groupNumber]).length] || "green";
-      }
-      item.color = colorMap[groupNumber][uid];
-    }
-    item.isByMultMark = isByMultMark;
-    return item;
-  });
-  return tList;
-}
-function addSpecialTrackColorAttr(tList: SpecialTag[]): SpecialTag[] {
-  return tList.map((item) => {
-    item.color =
-      colorMap[item.groupNumber] && colorMap[item.groupNumber][item.markerId]
-        ? colorMap[item.groupNumber][item.markerId]
-        : "green";
-    return item;
-  });
-}
-
-async function processImage() {
-  if (!store.currentTask) return;
-  colorMap = {};
-  const images = [];
-  const urls = store.currentTask.sheetUrls || [];
-  for (const url of urls) {
-    const image = await loadImage(url);
-    images.push(image);
-  }
-
-  maxImageWidth = Math.max(...images.map((i) => i.naturalWidth));
-  theFinalHeight = Math.max(...images.map((i) => i.naturalHeight));
-  sliceImagesWithTrackList.splice(0);
-
-  // let trackLists = store.currentTask.questionList
-  //   .map((q) => addTrackColorAttr(q.trackList, q.groupNumber))
-  //   .flat();
-  const trackLists = (store.currentTask.questionList || [])
-    // .map((q) => q.trackList)
-    .map((q) => {
-      let tList = q.trackList;
-      return q.headerTrack?.length
-        ? addHeaderTrackColorAttr(q.headerTrack)
-        : addTrackColorAttr(tList, q.groupNumber);
-    })
-    .flat();
-  let tagLists = store.isTrackMode
-    ? store.currentTask.specialTagList ?? []
-    : [];
-  tagLists = addSpecialTrackColorAttr(tagLists);
-
-  let accumTopHeight = 0;
-  let accumBottomHeight = 0;
-  for (const url of urls) {
-    const indexInSliceUrls = urls.indexOf(url) + 1;
-    const image = images[indexInSliceUrls - 1];
-    accumBottomHeight += image.naturalHeight;
-
-    const thisImageTrackList = trackLists.filter(
-      (t) => t.offsetIndex === indexInSliceUrls
-    );
-    const thisImageTagList = tagLists.filter(
-      (t) => t.offsetIndex === indexInSliceUrls
-    );
-
-    sliceImagesWithTrackList.push({
-      url,
-      indexInSliceUrls,
-      trackList: thisImageTrackList,
-      tagList: thisImageTagList,
-      originalImageWidth: image.naturalWidth,
-      originalImageHeight: image.naturalHeight,
-      width: (image.naturalWidth / maxImageWidth) * 100 + "%",
-      dx: 0,
-      dy: 0,
-      accumTopHeight,
-    });
-    accumTopHeight = accumBottomHeight;
-  }
-}
-
-// should not render twice at the same time
-let renderLock = false;
-const renderPaperAndMark = async () => {
-  // console.log("renderPagerAndMark=>store.curTask:", store.currentTask);
-
-  if (!store.currentTask) return;
-  if (!store.isScanImage) return;
-  if (renderLock) {
-    console.log("上个任务还未渲染完毕,稍等一秒再尝试渲染");
-    await new Promise((res) => setTimeout(res, 1000));
-    await renderPaperAndMark();
-    return;
-  }
-
-  renderLock = true;
-  try {
-    store.globalMask = true;
-    await processImage();
-  } catch (error) {
-    sliceImagesWithTrackList.splice(0);
-    console.trace("render error ", error);
-    // 图片加载出错,自动加载下一个任务
-    emit("error");
-  } finally {
-    renderLock = false;
-    store.globalMask = false;
-  }
-};
-
-watch(
-  () => store.currentTask,
-  () => {
-    setTimeout(renderPaperAndMark, 50);
-  }
-);
-//#endregion : 计算裁切图和裁切图上的分数轨迹和特殊标记轨迹
-
-//#region : 放大缩小和之后的滚动
-const answerPaperScale = $computed(() => {
-  // 放大、缩小不影响页面之前的滚动条定位
-  let percentWidth = 0;
-  let percentTop = 0;
-  const container = document.querySelector(".mark-body-container");
-  if (container) {
-    const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
-    percentWidth = scrollLeft / scrollWidth;
-    percentTop = scrollTop / scrollHeight;
-  }
-
-  addTimeout(() => {
-    if (container) {
-      const { scrollWidth, scrollHeight } = container;
-      container.scrollTo({
-        left: scrollWidth * percentWidth,
-        top: scrollHeight * percentTop,
-      });
-    }
-  }, 10);
-  const scale = store.setting.uiSetting["answer.paper.scale"];
-  return scale * 100 + "%";
-});
-//#endregion : 放大缩小和之后的滚动
-
-//#region : 显示评分状态和清除轨迹
-let markStatus = $ref("");
-if (hasMarkResultToRender) {
-  watch(
-    () => store.currentTask,
-    () => {
-      markStatus = store.getMarkStatus;
-    }
-  );
-
-  // 清除分数轨迹
-  watchEffect(() => {
-    for (const track of store.removeScoreTracks) {
-      for (const sliceImage of sliceImagesWithTrackList) {
-        sliceImage.trackList = sliceImage.trackList.filter(
-          (t) =>
-            !(
-              t.mainNumber === track.mainNumber &&
-              t.subNumber === track.subNumber &&
-              t.number === track.number
-            )
-        );
-      }
-    }
-    // 清除后,删除,否则会影响下次切换
-    store.removeScoreTracks.splice(0);
-  });
-
-  // 清除特殊标记轨迹
-  watchEffect(() => {
-    if (!store.currentTask) return;
-    for (const sliceImage of sliceImagesWithTrackList) {
-      sliceImage.tagList = sliceImage.tagList.filter((t) =>
-        store.currentTaskEnsured.markResult?.specialTagList.find(
-          (st) =>
-            st.offsetIndex === t.offsetIndex &&
-            st.offsetX === t.offsetX &&
-            st.offsetY === t.offsetY
-        )
-      );
-    }
-    if (store.currentTaskEnsured.markResult?.specialTagList.length === 0) {
-      for (const sliceImage of sliceImagesWithTrackList) {
-        sliceImage.tagList = [];
-      }
-    }
-  });
-}
-//#endregion : 显示评分状态和清除轨迹
-
-//#region : 评分
-const checkTrackValid = (event: MouseEvent) => {
-  const { clientWidth, clientHeight, naturalWidth, naturalHeight } =
-    event.target;
-  const { offsetX, offsetY } = event;
-  const xLimitRate = 10 / naturalWidth;
-  const yLimitRate = 10 / naturalHeight;
-  const xRange = [xLimitRate * clientWidth, (1 - xLimitRate) * clientWidth];
-  const yRange = [yLimitRate * clientHeight, (1 - yLimitRate) * clientHeight];
-
-  return (
-    offsetX >= xRange[0] &&
-    offsetX <= xRange[1] &&
-    offsetY >= yRange[0] &&
-    offsetY <= yRange[1]
-  );
-};
-const innerMakeTrack = (event: MouseEvent, item: SliceImage) => {
-  if (!checkTrackValid(event)) {
-    void message.warn("轨迹位置距离边界太近");
-    return;
-  }
-  makeTrack(event, item, maxImageWidth, theFinalHeight);
-};
-//#endregion : 评分
-
-//#region : 特殊标记:画线、框、文字
-const isCustomSpecialTag = $computed(() => {
-  return ["CIRCLE", "LINE", "TEXT"].includes(store.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, item: SliceImage) {
-  if (store.currentSpecialTagType === "TEXT") return;
-
-  curImageTarget = e.target.parentElement.childNodes[0];
-  curSliceImagesWithTrackItem = item;
-  specialPoint.x = e.offsetX;
-  specialPoint.y = e.offsetY;
-}
-function specialMouseMove({ left, top }) {
-  if (store.currentSpecialTagType === "TEXT") return;
-
-  specialPoint.ex = left + specialPoint.x;
-  specialPoint.ey = top + specialPoint.y;
-}
-function specialMouseStop() {
-  if (store.currentSpecialTagType === "TEXT") return;
-
-  if (
-    store.currentSpecialTagType === "LINE" &&
-    specialPoint.ex <= specialPoint.x
-  ) {
-    return;
-  }
-  if (
-    store.currentSpecialTagType === "CIRCLE" &&
-    (specialPoint.ex <= specialPoint.x || specialPoint.ey <= specialPoint.y)
-  ) {
-    return;
-  }
-
-  const track: SpecialTag = {
-    tagName: "",
-    tagType: store.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: store.currentQuestion.groupNumber,
-    color: "green",
-  };
-  track.positionX =
-    (track.offsetX - curSliceImagesWithTrackItem.dx) / maxImageWidth;
-  track.positionY =
-    (track.offsetY -
-      curSliceImagesWithTrackItem.dy +
-      curSliceImagesWithTrackItem.accumTopHeight) /
-    theFinalHeight;
-
-  if (store.currentSpecialTagType === "LINE") {
-    track.tagName = JSON.stringify({
-      len:
-        (specialPoint.ex - specialPoint.x) *
-        (curImageTarget.naturalWidth / curImageTarget.width),
-    });
-  }
-  if (store.currentSpecialTagType === "CIRCLE") {
-    track.tagName = JSON.stringify({
-      width:
-        (specialPoint.ex - specialPoint.x) *
-        (curImageTarget.naturalWidth / curImageTarget.width),
-      height:
-        (specialPoint.ey - specialPoint.y) *
-        (curImageTarget.naturalHeight / curImageTarget.height),
-    });
-  }
-
-  store.currentTaskEnsured.markResult.specialTagList.push(track);
-  curSliceImagesWithTrackItem.tagList.push(track);
-  specialPoint = { x: 0, y: 0, ex: 0, ey: 0 };
-}
-
-async function canvasClick(e: Event, item: SliceImage) {
-  if (cacheTextTrack.id) {
-    textTrackBlur();
-  }
-
-  curImageTarget = e.target.parentElement.childNodes[0];
-  curSliceImagesWithTrackItem = item;
-
-  cacheTextTrack.x = e.offsetX;
-  cacheTextTrack.y = e.offsetY;
-  cacheTextTrack.id = randomCode();
-  cacheTextTrack.content = "";
-
-  await nextTick(() => {
-    document.getElementById(`text-edit-box-${cacheTextTrack.id}`).focus();
-  });
-}
-
-function textTrackInput(e: Event) {
-  cacheTextTrack.content = e.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: store.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: store.currentQuestion.groupNumber,
-    color: "green",
-  };
-  track.positionX =
-    (track.offsetX - curSliceImagesWithTrackItem.dx) / maxImageWidth;
-  track.positionY =
-    (track.offsetY -
-      curSliceImagesWithTrackItem.dy +
-      curSliceImagesWithTrackItem.accumTopHeight) /
-    theFinalHeight;
-
-  store.currentTaskEnsured.markResult.specialTagList.push(track);
-  curSliceImagesWithTrackItem.tagList.push(track);
-  initCacheTextTrack();
-}
-watch(
-  () => store.currentSpecialTagType,
-  () => {
-    if (cacheTextTrack.id) {
-      initCacheTextTrack();
-    }
-  }
-);
-//#endregion
-
-//#region : 显示大图,供查看和翻转
-const showBigImage = (event: MouseEvent) => {
-  event.preventDefault();
-  // console.log(event);
-  let viewer: Viewer = null as unknown as Viewer;
-  viewer && viewer.destroy();
-  viewer = new Viewer(event.target as HTMLElement, {
-    // inline: true,
-    viewed() {
-      viewer.zoomTo(1);
-    },
-    hidden() {
-      viewer.destroy();
-    },
-    zIndex: 1000000,
-  });
-  viewer.show();
-};
-//#endregion : 显示大图,供查看和翻转
-
-// onRenderTriggered(({ key, target, type }) => {
-//   console.log({ key, target, type });
-// });
-let topKB = $ref(10);
-// const topKBStyle = $computed(() => topKB + "%");
-let leftKB = $ref(10);
-// const leftKBStyle = $computed(() => leftKB + "%");
-function moveCicle(event: KeyboardEvent) {
-  // query mark-body-container and body to calc max/min topKB and max leftKB
-  if (event.key === "k") {
-    if (topKB > 1) topKB--;
-  }
-  if (event.key === "j") {
-    if (topKB < 99) topKB++;
-  }
-  if (event.key === "h") {
-    if (leftKB > 1) leftKB--;
-  }
-  if (event.key === "l") {
-    if (leftKB < 99) leftKB++;
-  }
-  if (event.key === "c") {
-    topKB = 50;
-    leftKB = 50;
-  }
-}
-function giveScoreCicle(event: KeyboardEvent) {
-  // console.log(event.key);
-  event.preventDefault();
-  // console.log(store.currentScore);
-  // 接收currentScore间隔时间外才会进入此事件
-  if (event.key === " " && typeof store.currentScore === "number") {
-    // topKB--;
-    const circleElement = document.querySelector(".kb-circle");
-    let { top, left } = circleElement.getBoundingClientRect();
-    top = top + 45;
-    left = left + 45;
-    // console.log(top, left);
-    // getBoundingClientRect().top left
-    // capture => to the specific image
-    const me = new MouseEvent("click", {
-      bubbles: true,
-      cancelable: true,
-      view: window,
-      clientY: top,
-      clientX: left,
-    });
-    const eles = document.elementsFromPoint(left, top);
-    // console.log(eles);
-    let ele: Element;
-    // if (eles[0].className === "kb-circle") {
-    //   if (eles[1].tagName == "IMG") {
-    //     ele = eles[1];
-    //   }
-    // } else
-    if (eles[0].tagName == "IMG") {
-      ele = eles[0];
-    }
-    if (ele) {
-      ele.dispatchEvent(me);
-    }
-  }
-}
-onMounted(() => {
-  document.addEventListener("keypress", moveCicle);
-  document.addEventListener("keypress", giveScoreCicle);
-});
-onUnmounted(() => {
-  document.removeEventListener("keypress", moveCicle);
-  document.removeEventListener("keypress", giveScoreCicle);
-});
-// setInterval(() => {
-//   _topKB++;
-//   console.log(topKB);
-// }, 1000);
-
-//#region autoScroll自动跳转
-let oldFirstScoreContainer: HTMLDivElement | null;
-watch(
-  () => store.currentTask,
-  () => {
-    if (store.setting.autoScroll) {
-      // 给任务清理和动画留一点时间
-      oldFirstScoreContainer =
-        document.querySelector<HTMLDivElement>(".score-container");
-      oldFirstScoreContainer?.scrollIntoView({ behavior: "smooth" });
-      addTimeout(scrollToFirstScore, 1000);
-    } else {
-      const container = document.querySelector<HTMLDivElement>(
-        ".mark-body-container"
-      );
-      container?.scrollTo({ top: 0, left: 0, behavior: "smooth" });
-    }
-  }
-);
-function scrollToFirstScore() {
-  if (renderLock) {
-    window.requestAnimationFrame(scrollToFirstScore);
-  }
-  addTimeout(() => {
-    const firstScore =
-      document.querySelector<HTMLDivElement>(".score-container");
-    firstScore?.scrollIntoView({ behavior: "smooth" });
-  }, 1000);
-}
-//#endregion
-</script>
-
-<style scoped>
-.image-canvas {
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  z-index: 9;
-}
-.text-edit-box {
-  position: absolute;
-  border: 1px solid #ff0000;
-  line-height: 24px;
-  padding: 5px;
-  font-size: 20px;
-  border-radius: 4px;
-  margin: -15px 0 0 -5px;
-  outline: none;
-  z-index: 9;
-  font-family: 黑体, arial, sans-serif;
-  color: #ff0000;
-}
-.text-edit-box:focus {
-  border-color: #ff5050;
-}
-</style>

+ 0 - 332
src/features/check/MarkBody.vue

@@ -1,332 +0,0 @@
-<template>
-  <CommonMarkBody
-    :hasMarkResultToRender="true"
-    :makeTrack="makeTrack"
-    @error="$emit('error')"
-  />
-  <div class="cursor">
-    <div class="cursor-border">
-      <span
-        v-if="store.currentSpecialTagType === 'TEXT'"
-        class="text text-edit"
-      ></span>
-      <span
-        v-else-if="
-          store.currentSpecialTagType === 'LINE' ||
-          store.currentSpecialTagType === 'CIRCLE'
-        "
-        class="point"
-      >
-      </span>
-      <span v-else-if="store.currentSpecialTagType === 'RIGHT'" class="text">
-        <CheckOutlined />
-      </span>
-      <span v-else-if="store.currentSpecialTag" class="text">
-        {{ store.currentSpecialTag }}
-      </span>
-      <span v-else class="text">{{
-        Object.is(store.currentScore, -0) ? "空" : store.currentScore
-      }}</span>
-    </div>
-  </div>
-  <!-- <MarkBody /> -->
-</template>
-
-<script setup lang="ts">
-import { onMounted, onUnmounted, watch } from "vue";
-import { store } from "@/store/app";
-import { SliceImage, SpecialTag, Track } from "@/types";
-import CustomCursor from "custom-cursor.js";
-import CommonMarkBody from "./CommonMarkBody.vue";
-import { CheckOutlined } from "@ant-design/icons-vue";
-
-// import { message } from "ant-design-vue";
-// 开启本组件,测试后台在整卷的还原效果
-// import MarkBody from "@/features/student/studentInspect/MarkBody.vue";
-
-defineEmits(["error", "allZeroSubmit"]);
-
-const makeScoreTrack = (
-  event: MouseEvent,
-  item: SliceImage,
-  maxSliceWidth: number,
-  theFinalHeight: number
-) => {
-  // console.log(item);
-  if (!store.currentQuestion || typeof store.currentScore === "undefined")
-    return;
-  const target = event.target as HTMLImageElement;
-  const track: Track = {
-    mainNumber: store.currentQuestion?.mainNumber,
-    subNumber: store.currentQuestion?.subNumber,
-    score: store.currentScore,
-    unanswered: Object.is(store.currentScore, -0),
-    offsetIndex: item.indexInSliceUrls,
-    offsetX: Math.round(
-      event.offsetX * (target.naturalWidth / target.width) + item.dx
-    ),
-    offsetY: Math.round(
-      event.offsetY * (target.naturalHeight / target.height) + item.dy
-    ),
-    positionX: -1,
-    positionY: -1,
-    number: -1,
-    color: "green",
-  };
-  track.positionX = (track.offsetX - item.dx) / maxSliceWidth;
-  track.positionY =
-    (track.offsetY - item.dy + item.accumTopHeight) / theFinalHeight;
-
-  // const isIllegalRange = (testNum: number, min: number, max: number) => {
-  //   return testNum < min || testNum > max;
-  // };
-
-  // // 检测有问题,此处没有给原图的宽高,如果有的话,要稍微修改下数据类型
-  // // 但其实下面也做了一个基本检测
-  // if (
-  //   isIllegalRange(track.offsetX, 0, target.naturalWidth) ||
-  //   isIllegalRange(track.offsetY, 0, target.naturalHeight) ||
-  //   isIllegalRange(track.positionX, 0, 1) ||
-  //   isIllegalRange(track.positionY, 0, 1)
-  // ) {
-  //   console.error(
-  //     "错误的track",
-  //     track,
-  //     target.naturalWidth,
-  //     target.naturalHeight
-  //   );
-  //   void message.error("系统错误,请联系管理员!");
-  // }
-
-  if (track.offsetX > item.effectiveWidth + item.dx) {
-    console.log("不在有效宽度内,轨迹不生效");
-    return;
-  }
-  if (
-    item.trackList.some((t) => {
-      return (
-        Math.pow(Math.abs(t.offsetX - track.offsetX), 2) +
-          Math.pow(Math.abs(t.offsetY - track.offsetY), 2) <
-        500
-      );
-    })
-  ) {
-    console.log("两个轨迹相距过近");
-    return;
-  }
-  // 是否保留当前的轨迹分
-  const questionScore =
-    store.currentTask &&
-    store.currentQuestion &&
-    store.currentTask.markResult.scoreList[store.currentQuestion.__index];
-  const ifKeepScore =
-    Math.round(
-      store.currentQuestion.maxScore * 1000 -
-        (questionScore || 0) * 1000 -
-        store.currentScore * 2 * 1000
-    ) / 1000;
-  if (ifKeepScore < 0 && store.currentScore > 0) {
-    store.currentScore = undefined;
-  }
-  const markResult = store.currentTaskEnsured.markResult;
-  const maxNumber =
-    markResult.trackList.length === 0
-      ? 0
-      : Math.max(...markResult.trackList.map((t) => t.number));
-  track.number = maxNumber + 1;
-  // console.log(
-  //   maxNumber,
-  //   track.number,
-  //   markResult.trackList.map((t) => t.number),
-  //   Math.max(...markResult.trackList.map((t) => t.number))
-  // );
-  markResult.trackList = [...markResult.trackList, track];
-  const { __index, mainNumber, subNumber } = store.currentQuestion;
-  markResult.scoreList[__index] =
-    markResult.trackList
-      .filter((t) => t.mainNumber === mainNumber && t.subNumber === subNumber)
-      .map((t) => t.score)
-      .reduce((acc, v) => (acc += Math.round(v * 1000)), 0) / 1000;
-  item.trackList.push(track);
-};
-
-const makeSpecialTagTrack = (
-  event: MouseEvent,
-  item: SliceImage,
-  maxSliceWidth: number,
-  theFinalHeight: number
-) => {
-  // console.log(item);
-  if (!store.currentTask || typeof store.currentSpecialTag === "undefined")
-    return;
-
-  const target = event.target as HTMLImageElement;
-  const track: SpecialTag = {
-    tagName: store.currentSpecialTag,
-    tagType: store.currentSpecialTagType,
-    offsetIndex: item.indexInSliceUrls,
-    offsetX: Math.round(
-      event.offsetX * (target.naturalWidth / target.width) + item.dx
-    ),
-    offsetY: Math.round(
-      event.offsetY * (target.naturalHeight / target.height) + item.dy
-    ),
-    positionX: -1,
-    positionY: -1,
-    groupNumber: store.currentQuestion.groupNumber,
-    color: "green",
-  };
-  track.positionX = (track.offsetX - item.dx) / maxSliceWidth;
-  track.positionY =
-    (track.offsetY - item.dy + item.accumTopHeight) / theFinalHeight;
-
-  if (
-    item.tagList.some((t) => {
-      return (
-        Math.pow(Math.abs(t.offsetX - track.offsetX), 2) +
-          Math.pow(Math.abs(t.offsetY - track.offsetY), 2) <
-        500
-      );
-    })
-  ) {
-    console.log("两个轨迹相距过近");
-    return;
-  }
-  store.currentTaskEnsured.markResult.specialTagList.push(track);
-  item.tagList.push(track);
-};
-
-const makeTrack = (
-  event: MouseEvent,
-  item: SliceImage,
-  maxSliceWidth: number,
-  theFinalHeight: number
-) => {
-  if (store.currentSpecialTagType) {
-    makeSpecialTagTrack(event, item, maxSliceWidth, theFinalHeight);
-    if (store.currentSpecialTagType === "TEXT") {
-      store.currentSpecialTag = undefined;
-      store.currentSpecialTagType = undefined;
-    }
-  } else {
-    makeScoreTrack(event, item, maxSliceWidth, theFinalHeight);
-  }
-};
-
-watch(
-  () => store.setting.mode,
-  () => {
-    const shouldHide = store.setting.mode === "COMMON";
-    if (shouldHide) {
-      // console.log("hide cursor", theCursor);
-      // eslint-disable-next-line @typescript-eslint/no-unsafe-call
-      theCursor && theCursor.destroy();
-    } else {
-      if (document.querySelector(".cursor")) {
-        // console.log("show cursor", theCursor);
-        // theCursor && theCursor.enable();
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-call
-        theCursor = new CustomCursor(".cursor", {
-          focusElements: [
-            {
-              selector: ".mark-body-container",
-              focusClass: "cursor--focused-view",
-            },
-          ],
-        }).initialize();
-      }
-    }
-  }
-);
-let theCursor = null as any;
-onMounted(() => {
-  if (store.isTrackMode) {
-    // eslint-disable-next-line @typescript-eslint/no-unsafe-call
-    theCursor = new CustomCursor(".cursor", {
-      focusElements: [
-        {
-          selector: ".mark-body-container",
-          focusClass: "cursor--focused-view",
-        },
-      ],
-    }).initialize();
-  }
-});
-
-onUnmounted(() => {
-  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
-  theCursor && theCursor.destroy();
-});
-</script>
-
-<style scoped>
-.cursor {
-  color: #ff5050;
-  display: none;
-  pointer-events: none;
-  user-select: none;
-  top: 0;
-  left: 0;
-  position: fixed;
-  will-change: transform;
-  z-index: 1000;
-}
-
-.cursor-border {
-  position: absolute;
-  box-sizing: border-box;
-  align-items: center;
-  border: 1px solid #ff5050;
-  border-radius: 50%;
-  display: flex;
-  justify-content: center;
-  height: 0px;
-  width: 0px;
-  left: 0;
-  top: 0;
-  transform: translate(-50%, -50%);
-  transition: all 360ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-
-.cursor.cursor--initialized {
-  display: block;
-  opacity: 0;
-}
-.cursor.cursor--focused-view {
-  opacity: 1;
-}
-.cursor .text {
-  font-size: 2rem;
-  opacity: 0;
-  transition: opacity 80ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.cursor .point {
-  display: inline-block;
-  vertical-align: middle;
-  width: 4px;
-  height: 4px;
-  border-radius: 50%;
-  background: red;
-}
-.cursor .text-edit {
-  display: inline-block;
-  vertical-align: middle;
-  width: 0;
-  height: 20px;
-  border-left: 2px solid #ff5050;
-}
-.cursor.cursor--off-screen {
-  opacity: 0;
-}
-
-.cursor.cursor--focused .cursor-border,
-.cursor.cursor--focused-view .cursor-border {
-  width: 90px;
-  height: 90px;
-}
-
-.cursor.cursor--focused-view .text {
-  opacity: 1;
-  transition: opacity 360ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-</style>

+ 0 - 560
src/features/check/ObjectiveAnswer.vue

@@ -1,560 +0,0 @@
-<template>
-  <div class="mark check-paper">
-    <div class="mark-header">
-      <div class="mark-header-part">
-        <template v-if="student">
-          <div class="header-noun">
-            <span>课程名称:</span>
-            <span> {{ student.courseName }}({{ student.courseCode }})</span>
-          </div>
-          <div class="header-noun">
-            <span>试卷编号:</span>
-            <span>{{ student.paperNumber }}</span>
-          </div>
-          <div class="header-noun">
-            <span>姓名:</span>
-            <span>{{ student.studentName }}</span>
-          </div>
-          <div class="header-noun">
-            <span>学号:</span>
-            <span>{{ student?.studentCode }}</span>
-          </div>
-          <div v-if="studentIds.length > 1" class="header-noun">
-            <span>进度:</span>
-            <span> {{ currentIndex + 1 }}/{{ studentIds.length }} </span>
-          </div>
-        </template>
-      </div>
-      <div class="mark-header-part">
-        <div class="paper-menu">
-          <span
-            v-for="(u, index) in student?.sheetUrls"
-            :key="index"
-            :class="{ 'is-active': currentImage === index }"
-            @click="currentImage = index"
-          >
-            {{ index + 1 }}
-          </span>
-        </div>
-        <div class="header-text-btn header-logout" @click="logout">
-          <img class="header-icon" src="@/assets/icons/icon-return.svg" />返回
-        </div>
-      </div>
-    </div>
-
-    <div class="mark-main">
-      <div class="mark-body">
-        <div
-          v-if="student && currentImage !== 0"
-          class="page-action page-prev"
-          title="上一张"
-          @click="switchImageArrow({ left: true })"
-        >
-          <ArrowLeftOutlined />
-        </div>
-        <div
-          v-if="student && currentImage !== student.sheetUrls.length - 1"
-          class="page-action page-next"
-          title="上一张"
-          @click="switchImageArrow({ right: true })"
-        >
-          <ArrowRightOutlined />
-        </div>
-        <div class="mark-body-container">
-          <div v-if="!student" class="mark-body-none">
-            <div>
-              <img src="@/assets/image-none-task.png" />
-              <p>暂无数据</p>
-            </div>
-          </div>
-          <div
-            v-else
-            class="single-image-container"
-            :style="{ width: answerPaperScale, fontSize: answerPaperFontSize }"
-          >
-            <img
-              id="mark-body-paper"
-              draggable="false"
-              :src="curImageUrl"
-              :style="{
-                transform:
-                  (rotateDegree ? 'translate( 0,  calc(30vh))' : '') +
-                  `rotate(${rotateDegree}deg)`,
-              }"
-              @click="switchImage"
-              @contextmenu="showBigImage"
-              @load="paperLoad"
-            />
-            <div
-              v-for="(tag, tindex) in answerTags"
-              :key="tindex"
-              :style="tag.style"
-            >
-              {{ tag.answer }}
-            </div>
-            <div
-              v-for="(tag, tindex) in optionsBlocks"
-              :key="tindex + 'block'"
-              :style="tag.style"
-            ></div>
-          </div>
-        </div>
-        <ZoomPaper v-if="student" showRotate fixed @rotateRight="rotateRight" />
-      </div>
-
-      <div class="mark-board-track">
-        <div class="board-header no-action">
-          <div class="board-header-info">
-            <img src="@/assets/icons/icon-star.svg" />
-            <span>总分</span>
-          </div>
-          <div class="board-header-score">
-            <transition-group name="score-number-animation" tag="span">
-              <span :key="totalScore">{{ totalScore }}</span>
-            </transition-group>
-          </div>
-        </div>
-        <div class="paper-topics">
-          <div
-            v-for="group in answersComputed"
-            :key="group.mainNumber"
-            class="paper-topic"
-          >
-            <h2 class="paper-topic-title">
-              {{ group.mainNumber }}、{{ group.mainTitle }} ({{
-                group.subs.length
-              }})
-            </h2>
-            <div class="paper-topic-body">
-              <div
-                v-for="question in group.subs"
-                :key="question.subNumber"
-                class="paper-topic-question"
-              >
-                <span class="question-number">{{ question.subNumber }} </span>
-                <a-input
-                  class="normal-input"
-                  :class="{
-                    'long-input': question.type
-                      ? !['SINGLE', 'TRUE_OR_FALSE'].includes(question.type)
-                      : !group.mainTitle.match(/单选|单项|判断/),
-                  }"
-                  :value="question.answer"
-                  :maxLength="
-                    (
-                      question.type
-                        ? ['MULTIPLE'].includes(question.type)
-                        : group.mainTitle.match(/多选|多项|不定项/)
-                    )
-                      ? 100
-                      : 1
-                  "
-                  @keydown="onPreventAnswerKey"
-                  @input="changeAnswer($event, question)"
-                  @blur="changeAnswer($event, question, '#')"
-                />
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div :class="['board-footer', { 'is-simple': !isMultiStudent }]">
-          <qm-button
-            class="board-submit"
-            size="medium"
-            type="primary"
-            :disabled="!student?.upload"
-            @click="saveStudentAnswer"
-          >
-            保存
-          </qm-button>
-          <div v-if="isMultiStudent" class="student-switch">
-            <a-button :disabled="isFirst" @click="getPreviousStudent">
-              上一份
-            </a-button>
-            <a-button :disabled="isLast" @click="getNextStudent">
-              下一份
-            </a-button>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script lang="ts" setup>
-import { message } from "ant-design-vue";
-import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons-vue";
-import { onMounted, watch } from "vue";
-import "viewerjs/dist/viewer.css";
-import Viewer from "viewerjs";
-import { StudentObjectiveInfo, PaperRecogData, AnswerTagItem } from "@/types";
-
-import {
-  studentObjectiveConfirmData,
-  saveStudentObjectiveConfirmData,
-} from "@/api/checkPage";
-import { doLogout } from "@/api/markPage";
-
-import { useMarkStore } from "@/store";
-import { useTimers } from "@/setups/useTimers";
-import vls from "@/utils/storage";
-import { maxNum } from "@/utils/utils";
-
-import ZoomPaper from "@/components/ZoomPaper.vue";
-
-const { addTimeout } = useTimers();
-
-const markStore = useMarkStore();
-
-const studentIds = $ref(vls.get("check-students", []));
-
-onMounted(async () => {
-  if (studentIds.length === 0) {
-    void message.info("没有需要处理的考生,请返回。");
-    return;
-  }
-  await getNextStudent();
-});
-
-let currentStudentId = $ref("");
-
-const currentIndex = $computed(() => studentIds.indexOf(currentStudentId));
-const isFirst = $computed(() => currentIndex === 0);
-const isLast = $computed(() => currentIndex === studentIds.length - 1);
-const isMultiStudent = $computed(() => studentIds.length > 1);
-const totalScore = $computed(() => {
-  if (!student) return 0;
-  return student.objectiveScore || 0;
-});
-
-const curImageUrl = $computed(() =>
-  student ? student.sheetUrls[currentImage]?.url : ""
-);
-
-let student: StudentObjectiveInfo | null = $ref(null);
-/** 后台数据错误,停止整个页面的流程 */
-let dataError = $ref(false);
-let answerMap: Record<string, { answer: string; isRight: boolean }> = {};
-
-let answerTags = $ref<AnswerTagItem[]>([]);
-let optionsBlocks = $ref([]);
-
-const answersComputed = $computed(() => {
-  let mains = student?.answers.map((v) => ({
-    mainTitle: "",
-    mainNumber: v.mainNumber,
-    subs: [v],
-  }));
-  const mSet = new Set();
-  mains = mains?.filter((v) => {
-    if (!mSet.has(v.mainNumber)) {
-      mSet.add(v.mainNumber);
-      v.subs = [];
-      return true;
-    }
-  });
-  mains?.forEach((v) => {
-    v.mainTitle = student?.titles[v.mainNumber] ?? "";
-    v.subs =
-      student?.answers.filter((v2) => v2.mainNumber === v.mainNumber) ?? [];
-  });
-  return mains;
-});
-
-function logout() {
-  doLogout();
-}
-
-async function getNextStudent() {
-  if (isLast) {
-    void message.warning("已经是最后一份!");
-    return;
-  }
-  student = await getStudent(studentIds[currentIndex + 1]);
-}
-
-async function getPreviousStudent() {
-  if (isFirst) {
-    void message.warning("已经是第一份!");
-    return;
-  }
-  student = await getStudent(studentIds[currentIndex - 1]);
-}
-
-async function getStudent(studentId: string) {
-  const res = await studentObjectiveConfirmData(studentId).catch(() => {
-    dataError = true;
-  });
-  if (dataError) {
-    void message.error(res.message, 24 * 60 * 60);
-    throw new Error("取学生信息出错: " + res.message);
-  }
-
-  const stu = res.data as StudentObjectiveInfo;
-  // stu.sheetUrls = [
-  //   { index: 1, url: "/1-1.jpg" },
-  //   { index: 2, url: "/1-2.jpg" },
-  // ];
-  currentStudentId = stu.studentId;
-  currentImage = 0;
-  browsedImageIndexes = [0];
-
-  answerMap = {};
-  stu.answers.forEach((item) => {
-    answerMap[`${item.mainNumber}_${item.subNumber}`] = {
-      answer: item.answer,
-      isRight: item.answer === item.standardAnswer,
-    };
-  });
-
-  return stu;
-}
-
-const allowKey = [
-  "Delete",
-  "Backspace",
-  "ArrowLeft",
-  "ArrowRight",
-  "#",
-  "Shift",
-  "[A-Za-z]",
-];
-const allowKeyRef = new RegExp(allowKey.join("|"));
-
-function onPreventAnswerKey(e: KeyboardEvent) {
-  console.log(e);
-  if (!allowKeyRef.test(e.key)) {
-    e.preventDefault();
-  }
-}
-
-function changeAnswer(event: Event, question: string, defaultValue?: string) {
-  const target = event.target as HTMLInputElement;
-  student.answers = student.answers.map((v) => {
-    if (
-      v.mainNumber === question.mainNumber &&
-      v.subNumber === question.subNumber
-    ) {
-      v.answer = target?.value.toUpperCase().trim() || defaultValue || "";
-    }
-    return v;
-  });
-}
-
-let loading = false;
-async function saveStudentAnswer() {
-  if (!student) return;
-
-  if (loading) return;
-  loading = true;
-
-  const data = {
-    studentId: student.studentId,
-    answers: student.answers.map((v) => v.answer || "#").join(","),
-  };
-  // if (!answers.match(/^(#*,*[A-Z]*)+$/g)) {
-  //   void message.error("答案只能是#和大写英文字母");
-  //   return;
-  // }
-
-  const res = await saveStudentObjectiveConfirmData(data).catch(() => false);
-  loading = false;
-  if (!res) {
-    void message.error("保存失败,请刷新页面。");
-  } else {
-    void message.success("保存成功");
-
-    if (!isMultiStudent) {
-      window.close();
-      return;
-    }
-
-    if (isLast) {
-      student = await getStudent(studentIds[currentIndex]);
-    } else {
-      await getNextStudent();
-    }
-  }
-}
-
-function paperLoad() {
-  if (!student.sheetUrls[currentImage]?.recogData) {
-    answerTags = [];
-    optionsBlocks = [];
-    return;
-  }
-  const imgDom = document.getElementById("mark-body-paper");
-  const { naturalWidth, naturalHeight } = imgDom;
-  const recogData: PaperRecogData = JSON.parse(
-    window.atob(student.sheetUrls[currentImage].recogData)
-  );
-
-  answerTags = [];
-  optionsBlocks = [];
-  recogData.question.forEach((question) => {
-    question.fill_result.forEach((result) => {
-      const tagSize = result.fill_size[1];
-      const fillPositions = result.fill_position.map((pos) => {
-        return pos.split(",").map((n) => n * 1);
-      });
-
-      const offsetLt = result.fill_size.map((item) => item * 0.4);
-      const tagLeft =
-        maxNum(fillPositions.map((pos) => pos[0])) +
-        result.fill_size[0] -
-        offsetLt[0];
-      const tagTop = fillPositions[0][1] - offsetLt[1];
-
-      const { answer, isRight } =
-        answerMap[`${result.main_number}_${result.sub_number}`] || {};
-
-      answerTags.push({
-        mainNumber: result.main_number,
-        subNumber: result.sub_number,
-        answer,
-        style: {
-          height: ((100 * tagSize) / naturalHeight).toFixed(4) + "%",
-          fontSize: ((100 * 20) / tagSize).toFixed(4) + "%",
-          left: ((100 * tagLeft) / naturalWidth).toFixed(4) + "%",
-          top: ((100 * tagTop) / naturalHeight).toFixed(4) + "%",
-          position: "absolute",
-          color: isRight ? "#05b575" : "#f53f3f",
-          fontWeight: 600,
-          lineHeight: 1,
-          zIndex: 9,
-        },
-      });
-
-      // 测试:选项框
-      // fillPositions.forEach((fp, index) => {
-      //   optionsBlocks.push({
-      //     mainNumber: result.main_number,
-      //     subNumber: result.sub_number,
-      //     filled: !!result.fill_option[index],
-      //     style: {
-      //       width:
-      //         ((100 * result.fill_size[0]) / naturalWidth).toFixed(4) + "%",
-      //       height:
-      //         ((100 * result.fill_size[1]) / naturalHeight).toFixed(4) + "%",
-      //       left:
-      //         ((100 * (fp[0] - offsetLt[0])) / naturalWidth).toFixed(4) + "%",
-      //       top:
-      //         ((100 * (fp[1] - offsetLt[1])) / naturalHeight).toFixed(4) + "%",
-      //       position: "absolute",
-      //       border: "1px solid #f53f3f",
-      //       background: result.fill_option[index]
-      //         ? "rgba(245, 63, 63, 0.5)"
-      //         : "transparent",
-      //       zIndex: 9,
-      //     },
-      //   });
-      // });
-    });
-  });
-}
-
-//#region : 显示大图,供查看和翻转
-let currentImage = $ref(0);
-let browsedImageIndexes = $ref([0]);
-// let allViewed = $computed(() => {
-//   let indexes = Array.from(new Set(browsedImageIndexes));
-//   return indexes.length == (student?.sheetUrls || []).length;
-// });
-watch(
-  () => currentImage,
-  () => {
-    browsedImageIndexes.push(currentImage);
-  }
-);
-function switchImageArrow({
-  left = false,
-  right = false,
-}: {
-  left?: boolean;
-  right?: boolean;
-}) {
-  if (left) {
-    if (currentImage > 0) {
-      currentImage--;
-    }
-  }
-  if (right) {
-    if (currentImage < student.sheetUrls.length - 1) {
-      currentImage++;
-    }
-  }
-}
-
-function switchImage(event: MouseEvent) {
-  const image = event.target as HTMLImageElement;
-  const layerX: number = (event as any).layerX;
-  if (layerX * 2 < image.width) {
-    if (currentImage > 0) {
-      currentImage--;
-    }
-  } else {
-    if (currentImage < student.sheetUrls.length - 1) {
-      currentImage++;
-    }
-  }
-}
-
-const showBigImage = (event: MouseEvent) => {
-  event.preventDefault();
-  // console.log(event);
-  let viewer: Viewer = null as unknown as Viewer;
-  viewer && viewer.destroy();
-  viewer = new Viewer((event.target as HTMLElement).parentElement, {
-    // inline: true,
-    viewed() {
-      viewer.zoomTo(1);
-    },
-    hidden() {
-      viewer.destroy();
-    },
-    zIndex: 1000000,
-  });
-  viewer.show();
-};
-//#endregion : 显示大图,供查看和翻转
-
-//#region : 放大缩小和之后的滚动
-const answerPaperScale = $computed(() => {
-  // 放大、缩小不影响页面之前的滚动条定位
-  let percentWidth = 0;
-  let percentTop = 0;
-  const container = document.querySelector<HTMLDivElement>(
-    ".mark-body-container"
-  );
-  if (container) {
-    const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
-    percentWidth = scrollLeft / scrollWidth;
-    percentTop = scrollTop / scrollHeight;
-  }
-
-  addTimeout(() => {
-    if (container) {
-      const { scrollWidth, scrollHeight } = container;
-      container.scrollTo({
-        left: scrollWidth * percentWidth,
-        top: scrollHeight * percentTop,
-      });
-    }
-  }, 10);
-  const scale = markStore.setting.uiSetting["answer.paper.scale"];
-  return scale * 100 + "%";
-});
-const answerPaperFontSize = $computed(() => {
-  const scale = markStore.setting.uiSetting["answer.paper.scale"];
-  return scale * 14 + "px";
-});
-//#endregion : 放大缩小和之后的滚动
-
-//#region rotateRight
-let rotateDegree = $ref(0);
-function rotateRight() {
-  rotateDegree = (rotateDegree + 90) % 360;
-}
-//#endregion
-</script>

+ 42 - 106
src/features/mark/MarkBodyBase.vue

@@ -63,13 +63,11 @@ import { message } from "ant-design-vue";
 import { useMarkStore } from "@/store";
 import type { SliceImage } from "@/types";
 
-import "viewerjs/dist/viewer.css";
-import Viewer from "viewerjs";
-
 // composables
 import useBodyScroll from "./composables/useBodyScroll";
 import useDraggable from "./composables/useDraggable";
 import useSliceTrack from "./composables/useSliceTrack";
+import useBigImage from "./composables/useBigImage";
 
 // components
 import MarkDrawTrack from "./MarkDrawTrack.vue";
@@ -103,6 +101,7 @@ const { answerPaperScale } = useBodyScroll({
 });
 const { sliceImagesWithTrackList, maxSliceWidth, theFinalHeight } =
   useSliceTrack();
+const { showBigImage } = useBigImage();
 
 // 在轨迹模式下,仅当没有选择分数时可用。
 const { dragContainer } = useDraggable();
@@ -110,54 +109,52 @@ const { dragContainer } = useDraggable();
 // 显示评分状态和清除轨迹
 let markStatus = $ref("");
 function initWatch() {
-  if (hasMarkResultToRender) {
-    watch(
-      () => markStore.currentTask,
-      () => {
-        markStatus = markStore.getMarkStatus;
-      }
-    );
+  if (!hasMarkResultToRender) return;
 
-    // 清除分数轨迹
-    watchEffect(() => {
-      for (const track of markStore.removeScoreTracks) {
-        for (const sliceImage of sliceImagesWithTrackList) {
-          sliceImage.trackList = sliceImage.trackList.filter(
-            (t) =>
-              !(
-                t.mainNumber === track.mainNumber &&
-                t.subNumber === track.subNumber &&
-                t.number === track.number
-              )
-          );
-        }
-      }
-      // 清除后,删除,否则会影响下次切换
-      markStore.removeScoreTracks.splice(0);
-    });
+  watch(
+    () => markStore.currentTask,
+    () => {
+      markStatus = markStore.getMarkStatus;
+    }
+  );
 
-    // 清除特殊标记轨迹
-    watchEffect(() => {
-      if (!markStore.currentTask) return;
+  // 清除分数轨迹
+  watchEffect(() => {
+    for (const track of markStore.removeScoreTracks) {
       for (const sliceImage of sliceImagesWithTrackList) {
-        sliceImage.tagList = sliceImage.tagList.filter((t) =>
-          markStore.currentTaskEnsured.markResult?.specialTagList.find(
-            (st) =>
-              st.offsetIndex === t.offsetIndex &&
-              st.offsetX === t.offsetX &&
-              st.offsetY === t.offsetY
-          )
+        sliceImage.trackList = sliceImage.trackList.filter(
+          (t) =>
+            !(
+              t.mainNumber === track.mainNumber &&
+              t.subNumber === track.subNumber &&
+              t.number === track.number
+            )
         );
       }
-      if (
-        markStore.currentTaskEnsured.markResult?.specialTagList.length === 0
-      ) {
-        for (const sliceImage of sliceImagesWithTrackList) {
-          sliceImage.tagList = [];
-        }
+    }
+    // 清除后,删除,否则会影响下次切换
+    markStore.removeScoreTracks.splice(0);
+  });
+
+  // 清除特殊标记轨迹
+  watchEffect(() => {
+    if (!markStore.currentTask) return;
+    for (const sliceImage of sliceImagesWithTrackList) {
+      sliceImage.tagList = sliceImage.tagList.filter((t) =>
+        markStore.currentTaskEnsured.markResult?.specialTagList.find(
+          (st) =>
+            st.offsetIndex === t.offsetIndex &&
+            st.offsetX === t.offsetX &&
+            st.offsetY === t.offsetY
+        )
+      );
+    }
+    if (markStore.currentTaskEnsured.markResult?.specialTagList.length === 0) {
+      for (const sliceImage of sliceImagesWithTrackList) {
+        sliceImage.tagList = [];
       }
-    });
-  }
+    }
+  });
 }
 initWatch();
 
@@ -201,65 +198,4 @@ const clickSpecialtag = (event: MouseEvent, item: SliceImage) => {
 
   makeTrack(e as MouseEvent, item, maxSliceWidth.value, theFinalHeight.value);
 };
-
-// 显示大图,供查看和翻转
-const showBigImage = (event: MouseEvent) => {
-  event.preventDefault();
-  // console.log(event);
-  let viewer: Viewer = null as unknown as Viewer;
-  viewer && viewer.destroy();
-  viewer = new Viewer(event.target as HTMLElement, {
-    // inline: true,
-    viewed() {
-      viewer.zoomTo(1);
-    },
-    hidden() {
-      viewer.destroy();
-    },
-    zIndex: 1000000,
-  });
-  viewer.show();
-};
 </script>
-
-<style scoped>
-.image-canvas {
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  z-index: 9;
-}
-.text-edit-box {
-  position: absolute;
-  border: 1px solid #ff0000;
-  line-height: 24px;
-  padding: 5px;
-  font-size: 20px;
-  border-radius: 4px;
-  margin: -15px 0 0 -5px;
-  outline: none;
-  z-index: 9;
-  font-family: 黑体, arial, sans-serif;
-  color: #ff0000;
-}
-.text-edit-box:focus {
-  border-color: #ff5050;
-}
-
-@keyframes rotate {
-  0% {
-    transform: rotateY(0deg);
-    opacity: 1;
-  }
-  50% {
-    transform: rotateY(90deg);
-  }
-
-  100% {
-    transform: rotateY(0deg);
-    opacity: 0;
-  }
-}
-</style>

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

@@ -279,3 +279,30 @@ watch(
   }
 );
 </script>
+
+<style scoped>
+.image-canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9;
+}
+.text-edit-box {
+  position: absolute;
+  border: 1px solid #ff0000;
+  line-height: 24px;
+  padding: 5px;
+  font-size: 20px;
+  border-radius: 4px;
+  margin: -15px 0 0 -5px;
+  outline: none;
+  z-index: 9;
+  font-family: 黑体, arial, sans-serif;
+  color: #ff0000;
+}
+.text-edit-box:focus {
+  border-color: #ff5050;
+}
+</style>

+ 25 - 0
src/features/mark/composables/useBigImage.ts

@@ -0,0 +1,25 @@
+import "viewerjs/dist/viewer.css";
+import Viewer from "viewerjs";
+
+export default function useBigImage() {
+  const showBigImage = (event: MouseEvent) => {
+    event.preventDefault();
+    // console.log(event);
+    let viewer: Viewer = null as unknown as Viewer;
+    viewer && viewer.destroy();
+    viewer = new Viewer((event.target as HTMLElement).parentElement, {
+      viewed() {
+        viewer.zoomTo(1);
+      },
+      hidden() {
+        viewer.destroy();
+      },
+      zIndex: 1000000,
+    });
+    viewer.show();
+  };
+
+  return {
+    showBigImage,
+  };
+}

+ 6 - 1
src/features/mark/composables/useBodyScroll.ts

@@ -121,5 +121,10 @@ export default function useBodyScroll({
     return scale * 100 + "%";
   });
 
-  return { answerPaperScale };
+  const answerPaperFontSize = $computed(() => {
+    const scale = markStore.setting.uiSetting["answer.paper.scale"];
+    return scale * 14 + "px";
+  });
+
+  return { answerPaperScale, answerPaperFontSize };
 }

+ 17 - 4
src/features/track/composables/useTrackTag.ts

@@ -29,8 +29,20 @@ export default function useTrackTag() {
     const recogData: PaperRecogData = JSON.parse(
       window.atob(markStore.currentTask.recogDatas[imageIndex])
     );
+    const answerTags = parseAnswerTagsFromRecogData(recogData, answerMap, {
+      naturalWidth,
+      naturalHeight,
+    });
+
+    return answerTags;
+  }
+
+  function parseAnswerTagsFromRecogData(
+    recogData: PaperRecogData,
+    answerMap: Record<string, { answer: string; isRight: boolean }>,
+    size: { naturalWidth: number; naturalHeight: number }
+  ) {
     const answerTags: AnswerTagItem[] = [];
-    // const optionsBlocks = [];
     recogData.question.forEach((question) => {
       question.fill_result.forEach((result) => {
         const tagSize = result.fill_size[1];
@@ -53,10 +65,10 @@ export default function useTrackTag() {
           subNumber: result.sub_number,
           answer,
           style: {
-            height: ((100 * tagSize) / naturalHeight).toFixed(4) + "%",
+            height: ((100 * tagSize) / size.naturalHeight).toFixed(4) + "%",
             fontSize: ((100 * 20) / tagSize).toFixed(4) + "%",
-            left: ((100 * tagLeft) / naturalWidth).toFixed(4) + "%",
-            top: ((100 * tagTop) / naturalHeight).toFixed(4) + "%",
+            left: ((100 * tagLeft) / size.naturalWidth).toFixed(4) + "%",
+            top: ((100 * tagTop) / size.naturalHeight).toFixed(4) + "%",
             position: "absolute",
             color: isRight ? "#05b575" : "#f53f3f",
             fontWeight: 600,
@@ -549,5 +561,6 @@ export default function useTrackTag() {
     paserRecogData,
     parseObjectiveAnswerTags,
     parseMode4Data,
+    parseAnswerTagsFromRecogData,
   };
 }

+ 2 - 2
src/router/index.ts

@@ -11,13 +11,13 @@ const routes = [
     // 客观题检查
     path: "/check/objective-answer",
     name: "CheckObjectiveAnswer",
-    component: () => import("@/features/check/ObjectiveAnswer.vue"),
+    component: () => import("@/features/check-objective/CheckObjective.vue"),
   },
   {
     // 主观题检查
     path: "/check/subjective-answer",
     name: "CheckSubjectiveAnswer",
-    component: () => import("@/features/check/SubjectiveAnswer.vue"),
+    component: () => import("@/features/check-subjective/CheckSubjective.vue"),
   },
   {
     // 成绩查询-试卷轨迹

+ 2 - 1
src/store/index.ts

@@ -3,9 +3,10 @@ import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
 
 import useMarkStore from "../features/mark/stores/mark";
 import useAppStore from "./app";
+import useCheckObjectiveStore from "../features/check-objective/store";
 
 const pinia = createPinia();
 pinia.use(piniaPluginPersistedstate);
 
-export { useMarkStore, useAppStore };
+export { useMarkStore, useAppStore, useCheckObjectiveStore };
 export default pinia;