浏览代码

主观题检查

zhangjie 1 年之前
父节点
当前提交
a5cdcc172e

+ 16 - 0
src/api/confirmPage.ts → src/api/checkPage.ts

@@ -17,3 +17,19 @@ export async function saveStudentObjectiveConfirmData({ studentId, answers }) {
     { params: { studentId, answers } }
   );
 }
+
+/** 获取学生客观题数据 */
+export async function studentSubjectiveConfirmData(studentId) {
+  return httpApp.post<StudentSubjectiveInfo>(
+    "/api/admin/mark/inspected/subjective/getTask",
+    {},
+    { params: { studentId } }
+  );
+}
+/** 保存考生客观题数据 */
+export async function saveStudentSubjectiveConfirmData(data) {
+  return httpApp.post<StudentSubjectiveInfo>(
+    "/api/admin/mark/inspected/subjective/saveTask",
+    data
+  );
+}

+ 6 - 0
src/components/QmButton.vue

@@ -8,6 +8,12 @@
   </a-button>
 </template>
 
+<script lang="ts">
+export default {
+  inheritAttrs: false,
+};
+</script>
+
 <script setup lang="ts">
 import { reactive, useAttrs, watch } from "vue";
 

+ 1 - 1
src/devLoginParams.ts

@@ -21,5 +21,5 @@ export const LOGIN_CONFIG = {
   //   groupNumber: 1,
   // },
   schoolCode: "test-school-1",
-  studentIds: ["448443226152513536"],
+  studentIds: ["448443226152513536", "448443226014101504"],
 };

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

@@ -1,33 +0,0 @@
-export type CheckSetting = {
-  fileServer: string;
-  studentIds: number[];
-  studentIdsDone: number[];
-};
-
-export type StudentInfo = {
-  success: boolean;
-  message?: string;
-  id: number;
-  name: string;
-  examNumber: string;
-  studentCode: string;
-  subjectCode: string;
-  subjectName: string;
-  objectiveScore: number;
-  subjectiveScore: number;
-  upload: boolean;
-  absent: boolean;
-  paperType: string;
-  sheetUrls: [string];
-  answers: {
-    mainNumber: number;
-    subNumber: string;
-    answer: string;
-    /** 试卷结构中是否有此数据 */
-    exist: boolean;
-    type?: any;
-  }[];
-  titles: { [index: number]: string };
-};
-
-export {};

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

@@ -0,0 +1,445 @@
+<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}`]"
+      >
+        <div
+          v-for="(item, index) in sliceImagesWithTrackList"
+          :key="index"
+          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"
+          />
+          <hr class="image-seperator" />
+        </div>
+      </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 } from "vue";
+import { store } from "@/store/store";
+import MarkDrawTrack from "../mark/MarkDrawTrack.vue";
+import type { SliceImage } from "@/types";
+import { useTimers } from "@/setups/useTimers";
+import { loadImage } from "@/utils/utils";
+import { dragImage } from "../mark/use/draggable";
+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";
+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"]);
+
+//#region : 图片拖动。在轨迹模式下,仅当没有选择分数时可用。
+const { dragContainer } = dragImage();
+//#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 }
+);
+
+async function processImage() {
+  if (!store.currentTask) return;
+  let markResult = store.currentTask.markResult;
+  if (hasMarkResultToRender) {
+    // check if have MarkResult for currentTask
+    if (!markResult) return;
+  }
+
+  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));
+
+  for (const url of urls) {
+    const indexInSliceUrls = urls.indexOf(url) + 1;
+    const image = images[indexInSliceUrls - 1];
+
+    const trackLists = hasMarkResultToRender
+      ? markResult.trackList
+      : store.currentTask.questionList.map((q) => q.trackList).flat();
+    const tagLists = hasMarkResultToRender
+      ? markResult.specialTagList ?? []
+      : store.currentTask.specialTagList ?? [];
+
+    const thisImageTrackList = trackLists.filter(
+      (t) => t.offsetIndex === indexInSliceUrls
+    );
+    const thisImageTagList = tagLists.filter(
+      (t) => t.offsetIndex === indexInSliceUrls
+    );
+
+    sliceImagesWithTrackList.push({
+      url,
+      trackList: thisImageTrackList,
+      tagList: thisImageTagList,
+      originalImageWidth: image.naturalWidth,
+      originalImageHeight: image.naturalHeight,
+      width: (image.naturalWidth / maxImageWidth) * 100 + "%",
+      dx: 0,
+      dy: 0,
+    });
+  }
+}
+
+// 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 innerMakeTrack = (event: MouseEvent, item: SliceImage) => {
+  makeTrack(event, item, maxImageWidth, theFinalHeight);
+};
+//#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>

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

@@ -0,0 +1,305 @@
+<template>
+  <CommonMarkBody
+    :hasMarkResultToRender="true"
+    :makeTrack="makeTrack"
+    @error="$emit('error')"
+  />
+  <div class="cursor">
+    <div class="cursor-border">
+      <span v-if="store.currentSpecialTagType === 'TEXT'" class="text">文</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/store";
+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,
+  };
+  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,
+  };
+  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.setting.uiSetting["specialTag.modal"] &&
+    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;
+}
+
+.cursor .text {
+  font-size: 2rem;
+  opacity: 0;
+  transition: opacity 80ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+
+.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>

+ 26 - 5
src/features/admin/confirmPaper/ConfirmPaper.vue → src/features/check/ObjectiveAnswer.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="mark confirm-paper">
+  <div class="mark check-paper">
     <div class="mark-header">
       <div v-if="student" class="mark-header-part">
         <div class="header-noun">
@@ -20,7 +20,7 @@
         </div>
         <div v-if="studentIds.length > 1" class="header-noun">
           <span>进度:</span>
-          <span> {{ currentIndex }}/{{ studentIds.length }} </span>
+          <span> {{ currentIndex + 1 }}/{{ studentIds.length }} </span>
         </div>
       </div>
       <div class="mark-header-part">
@@ -34,6 +34,9 @@
             {{ 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>
 
@@ -84,6 +87,16 @@
       </div>
 
       <div class="mark-board-track">
+        <div class="board-header">
+          <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"
@@ -132,7 +145,7 @@
           <p v-if="!allViewed">请先浏览完该学生的所有试卷</p>
         </div>
         <div :class="['board-footer', { 'is-simple': !isMultiStudent }]">
-          <a-button
+          <qm-button
             class="board-submit"
             size="medium"
             type="primary"
@@ -140,7 +153,7 @@
             @click="saveStudentAnswer"
           >
             保存
-          </a-button>
+          </qm-button>
           <div v-if="isMultiStudent" class="student-switch">
             <a-button :disabled="isFirst" @click="getPreviousStudent">
               上一份
@@ -159,7 +172,7 @@
 import {
   studentObjectiveConfirmData,
   saveStudentObjectiveConfirmData,
-} from "@/api/confirmPage";
+} from "@/api/checkPage";
 import { message } from "ant-design-vue";
 import { onMounted, watch } from "vue";
 import "viewerjs/dist/viewer.css";
@@ -189,6 +202,10 @@ 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) + (student.subjectiveScore || 0);
+});
 
 const curImageUrl = $computed(() =>
   student ? student.sheetUrls[currentImage].url : ""
@@ -220,6 +237,10 @@ const answersComputed = $computed(() => {
   return mains;
 });
 
+function logout() {
+  window.history.go(-1);
+}
+
 async function getNextStudent() {
   if (isLast) return;
   student = await getStudent(studentIds[currentIndex + 1]);

+ 401 - 0
src/features/check/SubjectiveAnswer.vue

@@ -0,0 +1,401 @@
+<template>
+  <div class="mark">
+    <div class="mark-header">
+      <div class="mark-header-part">
+        <template v-if="store.currentTask">
+          <div class="header-noun">
+            <span>课程名称:</span>
+            <span>
+              {{ store.currentTask.courseName }}({{
+                store.currentTask.courseCode
+              }})</span
+            >
+          </div>
+          <div class="header-noun">
+            <span>试卷编号:</span>
+            <span>{{ store.currentTask.paperNumber }}</span>
+          </div>
+          <div class="header-noun">
+            <span>姓名:</span>
+            <span>{{ store.currentTask.studentName }}</span>
+          </div>
+          <div class="header-noun">
+            <span>学号:</span>
+            <span>{{ store.currentTask?.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 v-if="isMultiStudent" class="task-switch">
+          <a-button
+            :disabled="isFirst"
+            size="small"
+            @click="getPreviousStudent"
+          >
+            上一份
+          </a-button>
+          <a-button
+            :disabled="isLast"
+            size="small"
+            style="margin-left: 8px"
+            @click="getNextStudent"
+          >
+            下一份
+          </a-button>
+        </div>
+        <div class="header-text-btn header-logout" @click="logout">
+          <img class="header-icon" src="@/assets/icons/icon-return.svg" />返回
+        </div>
+        <a-tooltip placement="bottomRight">
+          <template #title>弹出给分板</template>
+          <div
+            :class="[
+              'header-menu',
+              { 'is-toggled': store.isScoreBoardVisible && store.currentTask },
+            ]"
+            @click="store.toggleScoreBoard"
+          >
+            <img src="@/assets/icons/icon-right-menu.svg" class="header-icon" />
+          </div>
+        </a-tooltip>
+      </div>
+    </div>
+
+    <mark-tool @allZeroSubmit="allZeroSubmit" />
+    <div class="mark-main">
+      <mark-body @error="removeBrokenTask" />
+      <mark-board-track v-if="store.isTrackMode" @submit="saveTaskToServer" />
+      <mark-board-key-board
+        v-if="store.shouldShowMarkBoardKeyBoard"
+        @submit="saveTaskToServer"
+        @allZeroSubmit="allZeroSubmit"
+      />
+      <mark-board-mouse
+        v-if="store.shouldShowMarkBoardMouse"
+        @submit="saveTaskToServer"
+        @allZeroSubmit="allZeroSubmit"
+      />
+    </div>
+  </div>
+  <MinimapModal />
+  <AllPaperModal />
+  <SheetViewModal />
+  <SpecialTagModal />
+  <ShortCutModal />
+  <MarkBoardTrackDialog
+    v-if="store.isTrackMode"
+    @submit="saveTaskToServer"
+    @allZeroSubmit="allZeroSubmit"
+  />
+</template>
+
+<script setup lang="ts">
+import { onMounted, watch, h } from "vue";
+import {
+  studentSubjectiveConfirmData,
+  saveStudentSubjectiveConfirmData,
+} from "@/api/checkPage";
+import { updateUISetting } from "@/api/markPage";
+import { store } from "@/store/store";
+import MarkTool from "../mark/MarkTool.vue";
+import MarkBody from "./MarkBody.vue";
+import MarkBoardTrack from "../mark/MarkBoardTrack.vue";
+import type { Question } from "@/types";
+import MarkBoardKeyBoard from "../mark/MarkBoardKeyBoard.vue";
+import MarkBoardMouse from "../mark/MarkBoardMouse.vue";
+import { debounce, isEmpty, isNumber } from "lodash-es";
+import { message } from "ant-design-vue";
+import MinimapModal from "../mark/MinimapModal.vue";
+import AllPaperModal from "../mark/AllPaperModal.vue";
+import SheetViewModal from "../mark/SheetViewModal.vue";
+import SpecialTagModal from "../mark/SpecialTagModal.vue";
+import ShortCutModal from "../mark/ShortCutModal.vue";
+import { calcSum } from "@/utils/utils";
+import MarkBoardTrackDialog from "../mark/MarkBoardTrackDialog.vue";
+import vls from "@/utils/storage";
+
+const studentIds = $ref(vls.get("check-students", []));
+
+const currentIndex = $computed(() =>
+  studentIds.indexOf(store.currentTask?.studentId)
+);
+const isFirst = $computed(() => currentIndex === 0);
+const isLast = $computed(() => currentIndex === studentIds.length - 1);
+const isMultiStudent = $computed(() => studentIds.length > 1);
+
+onMounted(async () => {
+  if (studentIds.length === 0) {
+    void message.info("没有需要处理的考生,请返回。");
+    return;
+  }
+  updateSetting();
+  await getNextStudent();
+});
+
+const logout = () => {
+  window.history.go(-1);
+};
+
+async function getNextStudent() {
+  console.log(currentIndex);
+
+  if (isLast) return;
+  await updateTask(studentIds[currentIndex + 1]);
+}
+
+async function getPreviousStudent() {
+  if (isFirst) return;
+  await updateTask(studentIds[currentIndex - 1]);
+}
+
+function updateSetting() {
+  let uiSetting = vls.get("user", { uiSetting: "" }).uiSetting;
+  // 初次使用时,重置并初始化uisetting
+  if (isEmpty(uiSetting)) {
+    uiSetting = {
+      "answer.paper.scale": 1,
+      "score.board.collapse": false,
+      "normal.mode": "keyboard",
+      "score.fontSize.scale": 1,
+      "paper.modal": false,
+      "answer.modal": false,
+      "minimap.modal": false,
+      "specialTag.modal": false,
+      "shortCut.modal": false,
+    };
+  } else {
+    uiSetting = JSON.parse(uiSetting);
+  }
+  store.setting.uiSetting = uiSetting;
+}
+
+let taskQuestionInfo = {};
+function updateTaskGroupInfo() {
+  taskQuestionInfo = {};
+  if (!store.currentTask) return;
+
+  store.currentTask.questionList.forEach((question) => {
+    const qno = `${question.mainNumber * 1000}${question.subNumber}`;
+    taskQuestionInfo[qno] = {
+      groupNumber: question.groupNumber,
+      score: question.score,
+    };
+  });
+}
+async function updateTask(studentId) {
+  const res = await studentSubjectiveConfirmData(studentId);
+  if (!res.data) {
+    store.message = res.message || "数据错误";
+    return;
+  }
+  res.data.taskId = res.data.studentId;
+  const newTask = res.data;
+  // newTask.sheetUrls = newTask.sheetUrls || [];
+  newTask.sheetUrls = ["/1-1.jpg", "/1-2.jpg"];
+  newTask.sliceUrls = [...newTask.sheetUrls];
+  newTask.specialTagList = newTask.headerTagList.length
+    ? newTask.headerTagList
+    : newTask.specialTagList;
+  store.currentTask = newTask;
+  updateTaskGroupInfo();
+}
+
+const __debounceUpdate = debounce(() => {
+  updateUISetting("", store.setting.uiSetting).catch((e) =>
+    console.log("保存设置出错", e)
+  );
+}, 3000);
+watch(
+  () => [store.setting.uiSetting],
+  () => {
+    __debounceUpdate();
+  },
+  { deep: true }
+);
+
+const removeBrokenTask = () => {
+  console.log("removeBrokenTask");
+
+  // store.currentTask = undefined;
+};
+
+const allZeroSubmit = async () => {
+  const markResult = store.currentTask?.markResult;
+  if (!markResult) return;
+
+  const { markerScore, scoreList, trackList, specialTagList } = markResult;
+  markResult.markerScore = 0;
+  const ss = new Array(store.currentTaskEnsured.questionList.length);
+  markResult.scoreList = ss.fill(0);
+  markResult.trackList = [];
+
+  try {
+    await saveTaskToServer();
+  } catch (error) {
+    console.log("error restore");
+  } finally {
+    // console.log({ markerScore, scoreList, trackList });
+    markResult.markerScore = markerScore;
+    markResult.scoreList = scoreList;
+    markResult.trackList = trackList;
+    markResult.specialTagList = specialTagList;
+  }
+};
+
+const getMarkData = () => {
+  if (!store.currentTask?.markResult) return {};
+
+  let markResult = store.currentTask.markResult;
+
+  let commomData = {
+    status: markResult.status,
+    spent: Date.now() - store.currentTask.__markStartTime,
+  };
+
+  let groupMap = {};
+  markResult.trackList.forEach((track) => {
+    const { groupNumber } =
+      taskQuestionInfo[`${track.mainNumber * 1000}${track.subNumber}`];
+    if (!groupMap[groupNumber]) {
+      groupMap[groupNumber] = { groupNumber, trackList: [] };
+    }
+    groupMap[groupNumber].trackList.push(track);
+  });
+  markResult.specialTagList.forEach((track) => {
+    const { groupNumber } = track;
+    if (!groupMap[groupNumber]) {
+      groupMap[groupNumber] = { groupNumber, specialTagList: [] };
+    }
+    groupMap[groupNumber].specialTagList.push(track);
+  });
+
+  let groups = Object.values(groupMap).map((item) => {
+    let qScore = {};
+    item.trackList.forEach((track) => {
+      const qno = `${track.mainNumber * 1000}${track.subNumber}`;
+      qScore[qno] = (qScore[qno] || 0) + track.score;
+    });
+
+    // 过滤分数未修改小题的分组
+    const groupChanged = Object.entries(qScore).some(
+      ([qno, score]) => score !== taskQuestionInfo[qno].score
+    );
+    if (!groupChanged) return null;
+
+    let qScoreList = Object.entries(qScore).map(([qno, score]) => {
+      return {
+        qno: qno * 1,
+        score,
+      };
+    });
+    qScoreList.sort((a, b) => a.qno - b.qno);
+    const scoreList = qScoreList.map((item) => item.score);
+
+    return {
+      ...commomData,
+      ...item,
+      scoreList,
+      markerScore: calcSum(scoreList),
+      studentId: store.currentTask.studentId,
+    };
+  });
+  groups = groups.filter((group) => group);
+
+  return {
+    studentId: store.currentTask.studentId,
+    groups,
+  };
+};
+
+const saveTaskToServer = async () => {
+  if (!store.currentTask) return;
+  const markResult = store.currentTask.markResult;
+  if (!markResult) return;
+
+  const mkey = "save_task_key";
+
+  type SubmitError = {
+    question: Question;
+    index: number;
+    error: string;
+  };
+
+  const errors: SubmitError[] = [];
+  markResult.scoreList.forEach((score, index) => {
+    if (!store.currentTask) return;
+    const question = store.currentTask.questionList[index]!;
+    let error;
+    if (!isNumber(score)) {
+      error = `${question.mainNumber}-${question.subNumber}${
+        question.questionName ? "(" + question.questionName + ")" : ""
+      } 没有给分,不能提交。`;
+    } else if (isNumber(question.maxScore) && score > question.maxScore) {
+      error = `${question.mainNumber}-${question.subNumber}${
+        question.questionName ? "(" + question.questionName + ")" : ""
+      } 给分大于最高分不能提交。`;
+    } else if (isNumber(question.minScore) && score < question.minScore) {
+      error = `${question.mainNumber}-${question.subNumber}${
+        question.questionName ? "(" + question.questionName + ")" : ""
+      } 给分小于最低分不能提交。`;
+    }
+    if (error) {
+      errors.push({ question, index, error });
+    }
+  });
+  if (errors.length !== 0) {
+    console.log(errors);
+    const msg = errors.map((v) => h("div", `${v.error}`));
+    void message.warning({
+      content: h("span", ["校验失败", ...msg]),
+      duration: 10,
+      key: mkey,
+    });
+    return;
+  }
+
+  if (
+    markResult.scoreList.length !== store.currentTask.questionList.length ||
+    !markResult.scoreList.every((s) => isNumber(s))
+  ) {
+    console.error({ content: "markResult格式不正确,缺少分数", key: mkey });
+    return;
+  }
+
+  if (!store.isTrackMode) {
+    markResult.trackList = [];
+  } else {
+    const trackScores =
+      markResult.trackList
+        .map((t) => Math.round((t.score || 0) * 100))
+        .reduce((acc, s) => acc + s, 0) / 100;
+    if (trackScores !== markResult.markerScore) {
+      void message.error({
+        content: "轨迹分与总分不一致,请检查。",
+        duration: 3,
+        key: mkey,
+      });
+      return;
+    }
+  }
+
+  console.log("save task to server");
+  void message.loading({ content: "保存检查任务...", key: mkey });
+
+  const data = getMarkData();
+  const res = await saveStudentSubjectiveConfirmData(data).catch(() => false);
+  if (!res) return;
+  if (res.data.success) {
+    void message.success({ content: "保存成功", key: mkey, duration: 2 });
+    store.currentTask = undefined;
+  } else {
+    console.log(res.data.message);
+    void message.error({ content: res.data.message, key: mkey, duration: 5 });
+    return;
+  }
+  await getNextStudent();
+};
+</script>

+ 2 - 0
src/features/mark/MarkBoardTrack.vue

@@ -388,6 +388,8 @@ function clearLatestMarkOfCurrentQuetion() {
   const ts = markResult.trackList.filter(
     (q) => q.mainNumber === mainNumber && q.subNumber === subNumber
   );
+  console.log(ts);
+
   if (ts.length === 0) {
     return;
   }

+ 0 - 4
src/features/mark/MarkHeader.vue

@@ -115,10 +115,6 @@
           />评卷时间段
         </div>
       </a-tooltip>
-      <div class="header-text-btn">
-        <img src="@/assets/icons/icon-track-mode.svg" class="header-icon" />
-        {{ modeName }}
-      </div>
       <a-dropdown>
         <template v-if="!store.setting.forceMode" #overlay>
           <a-menu>

+ 15 - 13
src/router/index.ts

@@ -1,9 +1,23 @@
 import { createRouter, createWebHistory } from "vue-router";
 import Mark from "@/features/mark/Mark.vue";
+import ObjectiveAnswer from "@/features/check/ObjectiveAnswer.vue";
+import SubjectiveAnswer from "@/features/check/SubjectiveAnswer.vue";
 
 const routes = [
-  { path: "/", redirect: { name: "ConfirmData" } },
+  { path: "/", redirect: { name: "CheckSubjectiveAnswer" } },
   { path: "/mark", component: Mark, name: "Mark" },
+  {
+    // 客观题检查
+    path: "/check/objective-answer",
+    name: "CheckObjectiveAnswer",
+    component: ObjectiveAnswer,
+  },
+  {
+    // 主观题检查
+    path: "/check/subjective-answer",
+    name: "CheckSubjectiveAnswer",
+    component: SubjectiveAnswer,
+  },
   {
     // 整卷批量复核
     path: "/admin/exam/inspected/start",
@@ -53,18 +67,6 @@ const routes = [
     name: "TrialRoute",
     component: () => import("@/features/library/libraryTrack/LibraryTrack.vue"),
   },
-  {
-    // 数据检查-客观题复核 TODO:
-    path: "/admin/exam/check/answer/start",
-    name: "ConfirmData",
-    component: () => import("@/features/admin/confirmPaper/ConfirmPaper.vue"),
-  },
-  {
-    // 人工确认
-    path: "/admin/exam/check/student/start",
-    name: "ConfirmPaper",
-    component: () => import("@/features/admin/confirmPaper/ConfirmPaper.vue"),
-  },
   {
     path: "/:pathMatch(.*)*",
     name: "NotFound",

+ 5 - 2
src/styles/page.less

@@ -846,8 +846,8 @@
   }
 }
 
-// confirm-paper
-.confirm-paper {
+// check-paper
+.check-paper {
   .mark-main {
     height: calc(100vh - 56px);
   }
@@ -926,6 +926,9 @@
     left: auto !important;
     right: 370px !important;
   }
+  .board-header {
+    margin-bottom: 16px;
+  }
   .paper-topics {
     flex-grow: 2;
     overflow-x: hidden;

+ 29 - 0
src/types/index.ts

@@ -225,6 +225,9 @@ export interface Task extends RawTask {
   markResult: MarkResult;
   /** 前端自用,用于标记阅卷开始时间和计算spent */
   __markStartTime: number;
+  // 是否是科组长任务
+  // 主观题检查时,科组长特殊标记使用headerTagList
+  __isMarkLeader: string;
 }
 
 interface RawQuestion {
@@ -302,6 +305,8 @@ export interface SpecialTag {
   /** 特殊标记的字符串,勾叉 */
   tagName: string;
   tagType: "TEXT" | "CIRCLE" | "RIGHT" | "WRONG" | "HALF_RIGTH";
+  // 分组号
+  groupNumber?: number;
   markerId?: number;
   color?: string;
   isByMultMark?: boolean;
@@ -521,3 +526,27 @@ export type StudentObjectiveInfo = {
   titles: { [index: number]: string };
   success: boolean;
 };
+export type StudentSubjectiveInfo = {
+  studentId: string;
+  studentName: string;
+  studentCode: string;
+  campusName: string;
+  courseCode: string;
+  courseName: string;
+  paperNumber: string;
+  objectiveScore: number;
+  subjectiveScore: number;
+  upload: boolean;
+  absent: boolean;
+  paperType: string;
+  sheetUrls: Array<{ index: number; url: string }>;
+  answers: Array<{
+    mainNumber: number;
+    subNumber: string;
+    answer: string;
+    exist: boolean;
+    questionType: string;
+  }>;
+  titles: { [index: number]: string };
+  success: boolean;
+};

+ 11 - 0
src/utils/utils.ts

@@ -436,3 +436,14 @@ export function parseHrefParam(
   }
   return params;
 }
+
+/**
+ * 计算总数
+ * @param {Array} dataList 需要统计的数组
+ */
+export function calcSum(dataList) {
+  if (!dataList.length) return 0;
+  return dataList.reduce(function (total, item) {
+    return total + item;
+  }, 0);
+}