Michael Wang 4 năm trước cách đây
mục cha
commit
6c1ba1f696

+ 72 - 0
src/api/arbitratePage.ts

@@ -0,0 +1,72 @@
+import { httpApp } from "@/plugins/axiosApp";
+import { Question } from "@/types";
+
+/** 清理仲裁任务(libraryId 与其他参数互斥填写) */
+export async function clearArbitrateTask(
+  libraryId?: string,
+  subjectCode?: string,
+  groupNumber?: string
+) {
+  const form = new FormData();
+  libraryId && form.append("groupNumber", libraryId);
+  subjectCode && form.append("subjectCode", subjectCode);
+  groupNumber && form.append("groupNumber", groupNumber);
+  return httpApp.post("/admin/exam/arbitrate/clear", form);
+}
+
+/** 查看单个学生的仲裁任务 */
+export async function getSingleArbitrateTask(historyId: string) {
+  const form = new FormData();
+  historyId && form.append("historyId", historyId);
+  return httpApp.post("/admin/exam/arbitrate/getTask", form);
+}
+
+/** 批量仲裁得到单个学生的仲裁任务 */
+export async function getOneOfArbitrateTask(
+  subjectCode: string,
+  groupNumber: string
+) {
+  const form = new FormData();
+  form.append("subjectCode", subjectCode);
+  form.append("groupNumber", groupNumber);
+  return httpApp.post("/admin/exam/arbitrate/getTask", form);
+}
+
+/** 批量仲裁得到任务总数 */
+export async function getArbitrateTaskStatus(
+  subjectCode: string,
+  groupNumber: string
+) {
+  const form = new FormData();
+  form.append("subjectCode", subjectCode);
+  form.append("groupNumber", groupNumber);
+  return httpApp.post("/admin/exam/arbitrate/getStatus", form);
+}
+
+/** 批量仲裁设置 */
+export async function getArbitrateSetting(
+  historyId: string,
+  subjectCode?: string,
+  groupNumber?: string
+) {
+  const form = new FormData();
+  historyId && form.append("historyId", historyId);
+  subjectCode && form.append("subjectCode", subjectCode);
+  groupNumber && form.append("groupNumber", groupNumber);
+  return httpApp.post("/admin/exam/arbitrate/getSetting", form);
+}
+
+/** 保存仲裁任务 */
+export async function saveArbitrateTask(
+  libraryId: string,
+  studentId: string,
+  markerScore: number,
+  scoreList: Array<number>
+) {
+  return httpApp.post("/admin/exam/arbitrate/saveTask", {
+    libraryId,
+    studentId,
+    markerScore,
+    scoreList,
+  });
+}

+ 188 - 0
src/features/arbitrate/Arbitrate.vue

@@ -0,0 +1,188 @@
+<template>
+  <div class="my-container">
+    <mark-header />
+    <div class="tw-flex tw-gap-1">
+      <ArbitrateMarkList />
+      <mark-history
+        @reload="reloadAndfetchTask"
+        :should-reload="shouldReloadHistory"
+      />
+      <mark-body />
+      <MarkBoardInspect @inspect="saveTaskToServer" />
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, ref } from "vue";
+import { store } from "./store";
+import MarkHeader from "./MarkHeader.vue";
+import { useRoute } from "vue-router";
+import MarkBody from "./MarkBody.vue";
+import MarkHistory from "./MarkHistory.vue";
+import MarkBoardInspect from "./MarkBoardInspect.vue";
+import { message } from "ant-design-vue";
+import {
+  clearArbitrateTask,
+  getArbitrateSetting,
+  getArbitrateTaskStatus,
+  getOneOfArbitrateTask,
+  getSingleArbitrateTask,
+  saveArbitrateTask,
+} from "@/api/arbitratePage";
+import ArbitrateMarkList from "./ArbitrateMarkList.vue";
+
+export default defineComponent({
+  name: "Arbitrate",
+  components: {
+    MarkHeader,
+    MarkBody,
+    MarkHistory,
+    MarkBoardInspect,
+    ArbitrateMarkList,
+  },
+  setup: () => {
+    const route = useRoute();
+    let isSingleStudent = !!route.query.historyId;
+    const {
+      subjectCode,
+      groupNumber,
+      historyId: libraryId,
+    } = route.query as {
+      subjectCode: string;
+      groupNumber: string;
+      historyId: string;
+    };
+
+    async function updateClearTask() {
+      await clearArbitrateTask(libraryId, subjectCode);
+    }
+
+    async function updateSetting() {
+      const settingRes = await getArbitrateSetting(
+        libraryId,
+        subjectCode,
+        groupNumber
+      );
+      store.setting.fileServer = settingRes.data.fileServer;
+      store.setting.userName = settingRes.data.userName;
+      store.setting.uiSetting = {
+        "answer.paper.scale": 1,
+        "score.board.collapse": false,
+      };
+      store.setting.splitConfig = settingRes.data.splitConfig;
+      store.setting.subject = settingRes.data.subject;
+    }
+    async function updateStatus() {
+      const res = await getArbitrateTaskStatus(subjectCode, groupNumber);
+      if (res.data.valid) store.status = res.data;
+    }
+    async function updateTask() {
+      // const mkey = "fetch_task_key";
+      message.info({ content: "获取任务中...", duration: 2 });
+      let res;
+      if (isSingleStudent) {
+        res = await getSingleStuTask();
+      } else {
+        res = await getOneOfStuTask();
+      }
+      // message.success({ content: "获取成功", key: mkey });
+
+      if (res.data.libraryId) {
+        store.currentTask = res.data;
+        // if (store.currentTask)
+        //   store.setting.subject = store.currentTask.subject;
+      } else {
+        store.message = res.data.message;
+      }
+    }
+
+    const shouldReloadHistory = ref(0);
+
+    async function reloadAndfetchTask() {
+      await updateClearTask();
+      await fetchTask();
+    }
+
+    async function fetchTask() {
+      !isSingleStudent && (await updateStatus());
+      await updateTask();
+    }
+
+    onMounted(async () => {
+      await updateClearTask();
+
+      updateSetting();
+      // fetchTask(); // mark-header 会调用 (watchEffect)
+    });
+
+    async function getSingleStuTask() {
+      return getSingleArbitrateTask(libraryId);
+    }
+
+    async function getOneOfStuTask() {
+      return getOneOfArbitrateTask(subjectCode, groupNumber);
+    }
+
+    const realStudentId = computed(
+      () =>
+        (isSingleStudent ? libraryId : store.currentTask?.libraryId) as string
+    );
+    const saveTaskToServer = async () => {
+      if (!store.currentTask) return;
+      console.log("save inspect task to server");
+      const mkey = "save_task_key";
+      message.loading({ content: "保存评卷任务...", key: mkey });
+      const res = (await saveArbitrateTask(
+        store.currentTask.libraryId + "",
+        store.currentTask.studentId + "",
+        0,
+        [0]
+      )) as any;
+      if (res.data.success && store.currentTask) {
+        message.success({ content: "复核成功", key: mkey, duration: 2 });
+        if (!store.historyOpen) {
+          store.currentTask = undefined;
+          if (!isSingleStudent) fetchTask();
+        } else {
+          shouldReloadHistory.value = Date.now();
+        }
+      } else if (res.data.message) {
+        console.log(res.data.message);
+        message.error({ content: res.data.message, key: mkey, duration: 10 });
+      } else if (!store.currentTask) {
+        message.warn({ content: "暂无新任务", key: mkey, duration: 10 });
+      }
+    };
+
+    return {
+      store,
+      fetchTask,
+      reloadAndfetchTask,
+      saveTaskToServer,
+      shouldReloadHistory,
+    };
+  },
+});
+</script>
+
+<style scoped>
+.my-container {
+  width: 100%;
+}
+a {
+  color: #42b983;
+}
+
+label {
+  margin: 0 0.5em;
+  font-weight: bold;
+}
+
+code {
+  background-color: #eee;
+  padding: 2px 4px;
+  border-radius: 4px;
+  color: #304455;
+}
+</style>

+ 22 - 0
src/features/arbitrate/ArbitrateMarkList.vue

@@ -0,0 +1,22 @@
+<template>
+  <div class="container"></div>
+</template>
+
+<script lang="ts">
+import { defineComponent, reactive } from "vue";
+
+export default defineComponent({
+  name: "ArbitrateMarkList",
+  setup() {
+    const list = reactive([]);
+
+    return { list };
+  },
+});
+</script>
+
+<style scoped>
+.container {
+  min-width: 200px;
+}
+</style>

+ 265 - 0
src/features/arbitrate/MarkBoardInspect.vue

@@ -0,0 +1,265 @@
+<template>
+  <div
+    v-if="store.currentTask"
+    :style="{ display: store.MarkBoardTrackCollapse ? 'none' : 'block' }"
+    class="mark-board-track-container"
+  >
+    <div>
+      <h1 class="tw-text-3xl tw-text-center">试卷总分:{{ markerScore }}</h1>
+    </div>
+
+    <div v-if="groups">
+      <template v-for="(groupNumber, index) in groups" :key="index">
+        <div class="tw-mb-4">
+          <div
+            class="tw-flex tw-justify-between tw-place-items-center hover:tw-bg-gray-200"
+            style="border-bottom: 1px solid grey"
+            @mouseover="addFocusTrack(groupNumber, undefined, undefined)"
+            @mouseleave="removeFocusTrack"
+          >
+            分组 {{ groupNumber }}
+            <input
+              class="tw-my-auto"
+              title="打回"
+              type="checkbox"
+              @click="groupClicked(groupNumber)"
+              :checked="groupChecked(groupNumber)"
+            />
+          </div>
+          <div v-if="questions">
+            <template v-for="(question, index) in questions" :key="index">
+              <div
+                v-if="question.groupNumber === groupNumber"
+                class="question tw-flex tw-place-items-center tw-mb-1 tw-ml-2 hover:tw-bg-gray-200"
+                @mouseover="
+                  addFocusTrack(
+                    undefined,
+                    question.mainNumber,
+                    question.subNumber
+                  )
+                "
+                @mouseleave="removeFocusTrack"
+              >
+                <span class="tw-flex-1">
+                  {{ question.title }} {{ question.mainNumber }}-{{
+                    question.subNumber
+                  }}
+                </span>
+                <span class="tw-flex-1 tw-text-center">
+                  {{ question.score || 0 }}
+                </span>
+                <input
+                  title="打回"
+                  type="checkbox"
+                  @change="questionCheckChanged(question)"
+                  :checked="questionChecked(question)"
+                />
+              </div>
+            </template>
+          </div>
+        </div>
+      </template>
+    </div>
+
+    <div class="tw-flex tw-justify-center">
+      <qm-button
+        type="primary"
+        v-if="
+          store.currentTask.inspectTime && store.currentTask.inspectTime > 0
+        "
+        @click="reject"
+      >
+        打回
+      </qm-button>
+      <qm-button
+        v-else-if="checkedQuestions.length === 0"
+        @click="inspect"
+        type="primary"
+      >
+        复核
+      </qm-button>
+      <qm-button v-else @click="reject" type="primary">打回</qm-button>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { Question } from "@/types";
+import { message } from "ant-design-vue";
+import { computed, defineComponent, reactive, watch } from "vue";
+import { store } from "./store";
+
+export default defineComponent({
+  name: "MarkBoardInspect",
+  emits: ["inspect", "reject"],
+  setup(props, { emit }) {
+    let checkedQuestions = reactive([] as Array<Question>);
+
+    watch(
+      () => store.currentTask,
+      () => {
+        checkedQuestions.splice(0);
+      }
+    );
+    const groups = computed(() => {
+      const gs = store.currentTask?.questionList.map((q) => q.groupNumber);
+      return [...new Set(gs)].sort((a, b) => a - b);
+    });
+
+    const questions = computed(() => {
+      const qs = store.currentTask?.questionList;
+      return qs;
+    });
+
+    const markerScore = computed(
+      () =>
+        (questions.value
+          ?.map((q) => Math.round((q.score || 0) * 100))
+          .reduce((acc, s) => acc + s) || 0) / 100
+    );
+
+    function addToCheckedQuestion(question: Question) {
+      checkedQuestions.push(question);
+    }
+    function removeCheckedQuestion(question: Question) {
+      const idx = checkedQuestions.indexOf(question);
+      checkedQuestions.splice(idx, 1);
+    }
+    function groupChecked(groupNumber: number) {
+      return (
+        checkedQuestions.filter((q) => q.groupNumber === groupNumber).length ===
+        questions.value?.filter((q) => q.groupNumber === groupNumber).length
+      );
+    }
+
+    function questionChecked(question: Question) {
+      return checkedQuestions.includes(question);
+    }
+
+    function questionCheckChanged(question: Question) {
+      const checked = questionChecked(question);
+      if (checked) {
+        removeCheckedQuestion(question);
+      } else {
+        addToCheckedQuestion(question);
+      }
+    }
+
+    function groupClicked(groupNumber: number) {
+      if (groupChecked(groupNumber)) {
+        checkedQuestions
+          .filter((q) => q.groupNumber === groupNumber)
+          .forEach((q) => {
+            const idx = checkedQuestions.indexOf(q);
+            checkedQuestions.splice(idx, 1);
+          });
+      } else {
+        questions.value
+          ?.filter((q) => q.groupNumber === groupNumber)
+          .forEach((q) => {
+            if (!questionChecked(q)) checkedQuestions.push(q);
+          });
+      }
+    }
+
+    function addFocusTrack(
+      groupNumber: number | undefined,
+      mainNumber: number | undefined,
+      subNumber: string | undefined
+    ) {
+      store.focusTracks.splice(0);
+
+      if (groupNumber) {
+        questions.value
+          ?.filter((q) => q.groupNumber === groupNumber)
+          ?.map((q) => q.trackList)
+          .reduce((acc, ts) => acc.concat(ts))
+          .forEach((t) => {
+            store.focusTracks.push(t);
+          });
+      } else {
+        questions.value
+          ?.map((q) => q.trackList)
+          .reduce((acc, ts) => acc.concat(ts))
+          .filter((t) => {
+            if (mainNumber) {
+              return t.mainNumber === mainNumber && t.subNumber === subNumber;
+            } else {
+              return false;
+            }
+          })
+          .forEach((t) => {
+            store.focusTracks.push(t);
+          });
+      }
+      // console.log(store.focusTracks);
+    }
+
+    function removeFocusTrack() {
+      store.focusTracks.splice(0);
+    }
+
+    function reject() {
+      if (checkedQuestions.length === 0) {
+        message.warn({ content: "请先选择试题。" });
+        return;
+      }
+      emit("reject", checkedQuestions);
+    }
+
+    function inspect() {
+      emit("inspect");
+    }
+
+    return {
+      store,
+      markerScore,
+      groups,
+      checkedQuestions,
+      questions,
+      groupChecked,
+      questionChecked,
+      questionCheckChanged,
+      groupClicked,
+      addFocusTrack,
+      removeFocusTrack,
+      reject,
+      inspect,
+    };
+  },
+});
+</script>
+
+<style scoped>
+.mark-board-track-container {
+  max-width: 250px;
+  min-width: 250px;
+  border-left: 1px solid grey;
+  padding-left: 6px;
+  padding-right: 6px;
+  max-height: calc(100vh - 41px);
+  overflow: scroll;
+}
+.question {
+  min-width: 100px;
+  border-bottom: 1px dotted grey;
+}
+
+.current-question {
+  border: 1px solid yellowgreen;
+  background-color: lightblue;
+}
+.single-score {
+  width: 30px;
+  height: 30px;
+  display: grid;
+  place-content: center;
+
+  border: 1px solid black;
+  border-radius: 5px;
+}
+.current-score {
+  border: 1px solid yellowgreen;
+  background-color: lightblue;
+}
+</style>

+ 352 - 0
src/features/arbitrate/MarkBody.vue

@@ -0,0 +1,352 @@
+<template>
+  <div class="mark-body-container tw-flex-auto tw-p-2" ref="dragContainer">
+    <a-spin
+      :spinning="rendering"
+      size="large"
+      tip="Loading..."
+      style="margin-top: 50px"
+    >
+      <div v-if="!store.currentTask" class="tw-text-center">
+        {{ store.message }}
+      </div>
+      <div v-else :style="{ width: answerPaperScale }">
+        <div
+          v-for="(item, index) in sliceImagesWithTrackList"
+          :key="index"
+          class="single-image-container"
+        >
+          <img :src="item.url" draggable="false" />
+          <MarkDrawTrack
+            :track-list="item.trackList"
+            :special-tag-list="item.tagList"
+            :original-image="item.originalImage"
+            :slice-image="item.sliceImage"
+            :dx="item.dx"
+            :dy="item.dy"
+          />
+          <hr class="image-seperator" />
+        </div>
+      </div>
+    </a-spin>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, reactive, ref, watchEffect } from "vue";
+import { store } from "./store";
+import filters from "@/filters";
+import MarkDrawTrack from "./MarkDrawTrack.vue";
+import { SpecialTag, Track } from "@/types";
+import { useTimers } from "@/setups/useTimers";
+import {
+  getDataUrlForSliceConfig,
+  getDataUrlForSplitConfig,
+  loadImage,
+} from "@/utils/utils";
+import { dragImage } from "@/features/mark/use/draggable";
+
+interface SliceImage {
+  url: string;
+  indexInSliceUrls: number;
+  trackList: Array<Track>;
+  tagList: Array<SpecialTag>;
+  originalImage: HTMLImageElement;
+  sliceImage: HTMLImageElement;
+  dx: number;
+  dy: number;
+  accumTopHeight: number;
+  effectiveWidth: number;
+}
+// should not render twice at the same time
+let __lock = false;
+let __currentStudentId = -1; // save __currentStudentIdof lock
+export default defineComponent({
+  name: "MarkBody",
+  components: { MarkDrawTrack },
+  emits: ["error"],
+  setup(props, { emit }) {
+    const { dragContainer } = dragImage();
+
+    const { addTimeout } = useTimers();
+
+    function hasSliceConfig() {
+      return store.currentTask?.sliceConfig?.length;
+    }
+
+    let rendering = ref(false);
+    let sliceImagesWithTrackList: Array<SliceImage> = reactive([]);
+    let maxSliceWidth = 0; // 最大的裁切块宽度,图片容器以此为准
+    let theFinalHeight = 0; // 最终宽度,用来定位轨迹在第几张图片,不包括image-seperator高度
+
+    async function getImageUsingDataUrl(
+      dataUrl: string
+    ): Promise<HTMLImageElement> {
+      return new Promise((resolve) => {
+        const image = new Image();
+        image.src = dataUrl;
+        image.onload = function () {
+          resolve(image);
+        };
+      });
+    }
+
+    async function processSliceConfig() {
+      if (!store.currentTask) return;
+
+      const images = [];
+      const urls = [];
+      // 必须要先加载一遍,把“选择整图”的宽高重置后,再算总高度
+      for (const sliceConfig of store.currentTask.sliceConfig) {
+        const url = filters.toCompleteUrlWithFileServer(
+          store.setting.fileServer,
+          store.currentTask.sliceUrls[sliceConfig.i - 1]
+        );
+        const image = await loadImage(url);
+        images.push(image);
+        urls.push(url);
+        if (sliceConfig.w === 0 && sliceConfig.h === 0) {
+          // 选择整图时,w/h 为0
+          sliceConfig.w = image.naturalWidth;
+          sliceConfig.h = image.naturalHeight;
+        }
+      }
+
+      theFinalHeight = store.currentTask.sliceConfig
+        .map((v) => v.h)
+        .reduce((acc, v) => (acc += v));
+      maxSliceWidth = Math.max(
+        ...store.currentTask.sliceConfig.map((v) => v.w)
+      );
+
+      // 用来保存sliceImage在整个图片容器中(不包括image-seperator)的高度范围
+      let accumTopHeight = 0;
+      let accumBottomHeight = 0;
+      const tempSliceImagesWithTrackList = [] as Array<SliceImage>;
+      for (const sliceConfig of store.currentTask.sliceConfig) {
+        accumBottomHeight += sliceConfig.h;
+        const url = filters.toCompleteUrlWithFileServer(
+          store.setting.fileServer,
+          store.currentTask.sliceUrls[sliceConfig.i - 1]
+        );
+        const indexInSliceUrls = sliceConfig.i;
+        const image = images[indexInSliceUrls - 1];
+
+        const dataUrl = getDataUrlForSliceConfig(
+          image,
+          sliceConfig,
+          maxSliceWidth,
+          url
+        );
+        const trackLists = store.currentTask.questionList
+          .map((q) => q.trackList)
+          .reduce((acc, t) => {
+            acc = acc.concat(t);
+            return acc;
+          }, [] as Array<Track>);
+        const thisImageTrackList = trackLists.filter(
+          (t) => t.offsetIndex === indexInSliceUrls
+        );
+        const thisImageTagList = (
+          store.currentTask.specialTagList ?? []
+        ).filter((t) => t.offsetIndex === indexInSliceUrls);
+
+        const sliceImage = await getImageUsingDataUrl(dataUrl);
+        tempSliceImagesWithTrackList.push({
+          url: dataUrl,
+          indexInSliceUrls,
+          // 通过positionY来定位是第几张slice的还原,并过滤出相应的track
+          trackList: thisImageTrackList.filter(
+            (t) =>
+              t.positionY >= accumTopHeight / theFinalHeight &&
+              t.positionY < accumBottomHeight / theFinalHeight
+          ),
+          tagList: thisImageTagList.filter(
+            (t) =>
+              t.positionY >= accumTopHeight / theFinalHeight &&
+              t.positionY < accumBottomHeight / theFinalHeight
+          ),
+          originalImage: image,
+          sliceImage,
+          dx: sliceConfig.x,
+          dy: sliceConfig.y,
+          accumTopHeight,
+          effectiveWidth: sliceConfig.w,
+        });
+        accumTopHeight = accumBottomHeight;
+      }
+      sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
+    }
+
+    async function processSplitConfig() {
+      if (!store.currentTask) return;
+
+      const images = [];
+      for (const url of store.currentTask.sliceUrls) {
+        const image = await loadImage(
+          filters.toCompleteUrlWithFileServer(store.setting.fileServer, url)
+        );
+        images.push(image);
+      }
+
+      const splitConfigPairs = store.setting.splitConfig
+        .map((v, index, ary) => (index % 2 === 0 ? [v, ary[index + 1]] : false))
+        .filter((v) => v) as unknown as Array<[number, number]>;
+
+      const maxSplitConfig = Math.max(...store.setting.splitConfig);
+      maxSliceWidth =
+        Math.max(...images.map((v) => v.naturalWidth)) * maxSplitConfig;
+
+      theFinalHeight =
+        splitConfigPairs.length *
+        images.reduce((acc, v) => (acc += v.naturalHeight), 0);
+
+      let accumTopHeight = 0;
+      let accumBottomHeight = 0;
+      const tempSliceImagesWithTrackList = [] as Array<SliceImage>;
+      for (const url of store.currentTask.sliceUrls) {
+        for (const config of splitConfigPairs) {
+          const indexInSliceUrls = store.currentTask.sliceUrls.indexOf(url) + 1;
+          const image = images[indexInSliceUrls - 1];
+
+          accumBottomHeight += image.naturalHeight;
+
+          const dataUrl = getDataUrlForSplitConfig(
+            image,
+            config,
+            maxSliceWidth,
+            url
+          );
+
+          const trackLists = store.currentTask.questionList
+            .map((q) => q.trackList)
+            .reduce((acc, t) => {
+              acc = acc.concat(t);
+              return acc;
+            }, [] as Array<Track>);
+          const thisImageTrackList = trackLists.filter(
+            (t) => t.offsetIndex === indexInSliceUrls
+          );
+          const thisImageTagList = (
+            store.currentTask.specialTagList ?? []
+          ).filter((t) => t.offsetIndex === indexInSliceUrls);
+          const sliceImage = await getImageUsingDataUrl(dataUrl);
+          tempSliceImagesWithTrackList.push({
+            url: dataUrl,
+            indexInSliceUrls: store.currentTask.sliceUrls.indexOf(url) + 1,
+            trackList: thisImageTrackList.filter(
+              (t) =>
+                t.positionY >= accumTopHeight / theFinalHeight &&
+                t.positionY < accumBottomHeight / theFinalHeight
+            ),
+            tagList: thisImageTagList.filter(
+              (t) =>
+                t.positionY >= accumTopHeight / theFinalHeight &&
+                t.positionY < accumBottomHeight / theFinalHeight
+            ),
+            originalImage: image,
+            sliceImage,
+            dx: image.naturalWidth * config[0],
+            dy: 0,
+            accumTopHeight,
+            effectiveWidth: image.naturalWidth * config[1],
+          });
+          accumTopHeight = accumBottomHeight;
+        }
+      }
+      sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
+    }
+    const renderPaperAndMark = async () => {
+      if (__lock) {
+        if (store.currentTask?.studentId === __currentStudentId) {
+          console.log("重复渲染,返回");
+          return;
+        }
+        console.log("上个任务还未渲染完毕,稍等一秒再尝试渲染");
+        await new Promise((res) => setTimeout(res, 1000));
+        await renderPaperAndMark();
+        return;
+      }
+      __lock = true;
+      __currentStudentId = store.currentTask?.studentId ?? -1;
+      sliceImagesWithTrackList.splice(0);
+
+      if (!store.currentTask) {
+        __lock = false;
+        return;
+      }
+
+      try {
+        rendering.value = true;
+        if (hasSliceConfig()) {
+          await processSliceConfig();
+        } else {
+          await processSplitConfig();
+        }
+      } catch (error) {
+        sliceImagesWithTrackList.splice(0);
+        console.log("render error ", error);
+        // 图片加载出错,自动加载下一个任务
+        emit("error");
+      } finally {
+        __lock = false;
+        rendering.value = false;
+      }
+    };
+
+    watchEffect(renderPaperAndMark);
+
+    const answerPaperScale = computed(() => {
+      // 放大、缩小不影响页面之前的滚动条定位
+      let percentWidth = 0;
+      let percentTop = 0;
+      const container = document.querySelector(
+        ".mark-body-container"
+      ) as HTMLDivElement;
+      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 + "%";
+    });
+
+    return {
+      dragContainer,
+      store,
+      rendering,
+      sliceImagesWithTrackList,
+      answerPaperScale,
+    };
+  },
+});
+</script>
+
+<style scoped>
+.mark-body-container {
+  height: calc(100vh - 41px);
+  overflow: scroll;
+  background-size: 8px 8px;
+  background-image: linear-gradient(to right, #e7e7e7 4px, transparent 4px),
+    linear-gradient(to bottom, transparent 4px, #e7e7e7 4px);
+}
+.mark-body-container img {
+  width: 100%;
+}
+.single-image-container {
+  position: relative;
+}
+.image-seperator {
+  border: 2px solid rgba(120, 120, 120, 0.1);
+}
+</style>

+ 129 - 0
src/features/arbitrate/MarkDrawTrack.vue

@@ -0,0 +1,129 @@
+<template>
+  <template v-for="(track, index) in trackList" :key="index">
+    <div
+      class="score-container"
+      :class="[focusedTrack(track) && 'score-animation']"
+      :style="computeTopAndLeft(track)"
+    >
+      <span
+        class="tw-m-auto"
+        :id="'a' + track.mainNumber + track.subNumber + track.offsetY"
+      >
+        {{ track.score }}
+      </span>
+    </div>
+  </template>
+  <template v-for="(tag, index) in specialTagList" :key="index">
+    <div class="score-container" :style="computeTopAndLeft(tag)">
+      <span class="tw-m-auto">
+        {{ tag.tagName }}
+      </span>
+    </div>
+  </template>
+</template>
+
+<script lang="ts">
+import { SpecialTag, Track } from "@/types";
+import { defineComponent, PropType, watch } from "vue";
+import { store } from "./store";
+
+export default defineComponent({
+  name: "MarkDrawTrack",
+  props: {
+    trackList: {
+      type: Array as PropType<Array<Track>>,
+    },
+    specialTagList: {
+      type: Array as PropType<Array<SpecialTag>>,
+    },
+    originalImage: {
+      type: Object as PropType<HTMLImageElement>,
+      required: true,
+    },
+    sliceImage: {
+      type: Object as PropType<HTMLImageElement>,
+      required: true,
+    },
+    dx: { type: Number, required: true },
+    dy: { type: Number, required: true },
+  },
+  setup({ trackList, originalImage, sliceImage, dx, dy }) {
+    const focusedTrack = (track: Track) => {
+      return store.focusTracks.includes(track);
+    };
+    const computeTopAndLeft = (track: Track | SpecialTag) => {
+      const topInsideSlice = track.offsetY - dy;
+      const leftInsideSlice = track.offsetX - dx;
+      return {
+        top: (topInsideSlice / sliceImage.naturalHeight) * 100 + "%",
+        left: (leftInsideSlice / sliceImage.naturalWidth) * 100 + "%",
+        "font-size": store.setting.uiSetting["answer.paper.scale"] * 2.2 + "em",
+      };
+    };
+
+    watch(
+      () => store.focusTracks.length,
+      () => {
+        if (store.focusTracks.length === 0) return;
+        const minImageIndex = Math.min(
+          ...store.focusTracks.map((t) => t.offsetIndex)
+        );
+        const minImageOffsetY = Math.min(
+          ...store.focusTracks
+            .filter((t) => t.offsetIndex === minImageIndex)
+            .map((t) => t.offsetY)
+        );
+        const topTrack = store.focusTracks.find(
+          (t) =>
+            t.offsetIndex === minImageIndex && t.offsetY === minImageOffsetY
+        );
+        if (topTrack) {
+          document
+            .querySelector(
+              `#a${topTrack.mainNumber + topTrack.subNumber + topTrack.offsetY}`
+            )
+            ?.scrollIntoView({ behavior: "smooth" });
+        }
+      }
+    );
+
+    return { store, focusedTrack, computeTopAndLeft };
+  },
+});
+</script>
+
+<style scoped>
+.score-container {
+  position: absolute;
+  display: flex;
+  place-content: center;
+  color: red;
+
+  /* to center score */
+  width: 200px;
+  height: 200px;
+  margin-top: -100px;
+  margin-left: -100px;
+
+  /* to click through div */
+  pointer-events: none;
+}
+.score-animation {
+  animation: 2s ease-in-out 0s infinite alternate change_color;
+}
+
+@keyframes change_color {
+  from {
+    color: red;
+    font-size: 2em;
+    margin-top: -100px;
+    margin-left: -100px;
+  }
+  to {
+    color: black;
+    font-size: 4em;
+    margin-top: -80px;
+    margin-left: -80px;
+  }
+}
+</style>

+ 181 - 0
src/features/arbitrate/MarkHeader.vue

@@ -0,0 +1,181 @@
+<template>
+  <div
+    class="
+      tw-flex tw-gap-4 tw-justify-between tw-items-center
+      header-container
+      tw-px-1
+    "
+    v-if="store.setting"
+  >
+    <div>
+      {{ store.setting.subject.code + "-" + store.setting.subject.name }}
+    </div>
+    <div class="tw-flex tw-gap-1">
+      <div>
+        编号<span class="highlight-text">{{
+          store.currentTask?.secretNumber
+        }}</span>
+      </div>
+    </div>
+    <ul v-if="!isSingleStudent" class="tw-flex tw-gap-2 tw-mb-0">
+      <li>
+        待仲裁<span class="highlight-text">{{
+          store.status.totalCount - store.status.markedCount || 0
+        }}</span>
+      </li>
+    </ul>
+    <ul class="tw-flex tw-gap-2 tw-mb-0">
+      <li @click="upScale" title="放大">
+        <ZoomInOutlined
+          class="icon-font icon-font-size-20 tw-cursor-pointer"
+          :style="{
+            color: greaterThanOneScale ? 'red' : 'white',
+          }"
+        />
+      </li>
+      <li @click="downScale" title="缩小">
+        <ZoomOutOutlined
+          class="icon-font icon-font-size-20 tw-cursor-pointer"
+          :style="{
+            color: lessThanOneScale ? 'red' : 'white',
+          }"
+        />
+      </li>
+      <li @click="normalScale" title="适应">
+        <FullscreenOutlined
+          class="icon-font icon-font-size-20 tw-cursor-pointer"
+        />
+      </li>
+    </ul>
+    <!-- <div @click="toggleHistory" v-if="!isSingleStudent" title="回看">
+      <HistoryOutlined class="icon-font icon-font-size-20" />
+    </div> -->
+    <div class="tw-flex tw-place-items-center">
+      <UserOutlined class="icon-font icon-with-text" />{{
+        store.setting.userName
+      }}
+    </div>
+    <div
+      class="tw-flex tw-place-items-center tw-cursor-pointer"
+      @click="closeWindow"
+    >
+      <PoweroffOutlined class="icon-font icon-with-text" />关闭
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, ref } from "vue";
+import { store } from "./store";
+import {
+  ZoomInOutlined,
+  ZoomOutOutlined,
+  FullscreenOutlined,
+  HistoryOutlined,
+  UserOutlined,
+  PoweroffOutlined,
+  AlertOutlined,
+  QuestionCircleOutlined,
+} from "@ant-design/icons-vue";
+import { useRoute } from "vue-router";
+import { clearArbitrateTask } from "@/api/arbitratePage";
+
+export default defineComponent({
+  name: "MarkHeader",
+  components: {
+    ZoomInOutlined,
+    ZoomOutOutlined,
+    FullscreenOutlined,
+    HistoryOutlined,
+    UserOutlined,
+    PoweroffOutlined,
+    AlertOutlined,
+    QuestionCircleOutlined,
+  },
+  setup() {
+    const route = useRoute();
+    let isSingleStudent = !!route.query.historyId;
+    const {
+      subjectCode,
+      groupNumber,
+      historyId: libraryId,
+    } = route.query as {
+      subjectCode: string;
+      groupNumber: string;
+      historyId: string;
+    };
+
+    const upScale = () => {
+      const s = store.setting.uiSetting["answer.paper.scale"];
+      if (s < 3)
+        store.setting.uiSetting["answer.paper.scale"] = +(s + 0.2).toFixed(1);
+    };
+    const downScale = () => {
+      const s = store.setting.uiSetting["answer.paper.scale"];
+      if (s > 0.2)
+        store.setting.uiSetting["answer.paper.scale"] = +(s - 0.2).toFixed(1);
+    };
+    const normalScale = () => {
+      store.setting.uiSetting["answer.paper.scale"] = 1;
+    };
+    const greaterThanOneScale = computed(() => {
+      return store.setting.uiSetting["answer.paper.scale"] > 1;
+    });
+    const lessThanOneScale = computed(() => {
+      return store.setting.uiSetting["answer.paper.scale"] < 1;
+    });
+
+    async function updateClearTask() {
+      await clearArbitrateTask(libraryId, subjectCode, groupNumber);
+    }
+
+    const closeWindow = async () => {
+      await updateClearTask();
+      window.close();
+    };
+
+    onMounted(() => {
+      // 不确定是否一定能在关闭页面时调用
+      window.addEventListener("beforeunload", () => {
+        updateClearTask();
+      });
+    });
+
+    return {
+      store,
+      isSingleStudent,
+      upScale,
+      downScale,
+      normalScale,
+      greaterThanOneScale,
+      lessThanOneScale,
+      closeWindow,
+    };
+  },
+});
+</script>
+
+<style scoped>
+.header-container {
+  /* z-index: 10000; */
+  position: relative;
+  font-size: 16px;
+  height: 40px;
+
+  background-color: #5d6d7d;
+  color: white;
+}
+.highlight-text {
+  color: #ffe400;
+}
+.icon-font {
+  display: block;
+}
+.icon-font-size-20 {
+  font-size: 20px;
+}
+.icon-with-text {
+  font-size: 18px;
+  line-height: 18px;
+}
+</style>

+ 149 - 0
src/features/arbitrate/MarkHistory.vue

@@ -0,0 +1,149 @@
+<template>
+  <div
+    :style="{ display: store.historyOpen ? 'block' : 'none' }"
+    class="history-container tw-px-1"
+  >
+    <div class="tw-p-1 tw-flex tw-justify-between tw-place-items-center">
+      <div class="tw-text-xl">回评</div>
+      <a-button
+        class="tw-content-end"
+        shape="circle"
+        @click="store.historyOpen = false"
+      >
+        <template #icon><CloseOutlined /></template>
+      </a-button>
+    </div>
+    <div class="tw-mt-1 tw-mb-1 tw-flex"></div>
+    <div class="tw-flex tw-justify-between">
+      <div>编号</div>
+      <div>时间</div>
+      <div>分数</div>
+    </div>
+    <a-spin :spinning="loading" size="large" tip="Loading...">
+      <div v-for="(task, index) of store.historyTasks" :key="index">
+        <div
+          @click="replaceCurrentTask(task)"
+          class="tw-flex tw-justify-between tw-h-6 tw-place-items-center tw-rounded tw-cursor-pointer"
+          :class="store.currentTask === task && 'current-task'"
+        >
+          <div>{{ task.secretNumber }}</div>
+          <div>
+            {{ task.inspectTime && $filters.datetimeFilter(task.inspectTime) }}
+          </div>
+          <div style="width: 30px; text-align: center">
+            {{ task.markerScore }}
+          </div>
+        </div>
+      </div>
+    </a-spin>
+    <div class="tw-flex tw-justify-between tw-place-content-center tw-mt-2">
+      <a-button @click="previousPage">上一页</a-button>
+      <div style="line-height: 30px">第{{ currentPage }}页</div>
+      <a-button @click="nextPage">下一页</a-button>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { getInspectedHistory } from "@/api/inspectPage";
+import { Task } from "@/types";
+import { defineComponent, ref, watch, watchEffect } from "vue";
+import { useRoute } from "vue-router";
+import { store } from "./store";
+import { CloseOutlined } from "@ant-design/icons-vue";
+
+export default defineComponent({
+  name: "MarkHistory",
+  components: { CloseOutlined },
+  props: {
+    shouldReload: { type: Number, required: true },
+  },
+  emits: ["reload"],
+  setup(props, { emit }) {
+    const route = useRoute();
+    const { subjectCode } = route.query as {
+      subjectCode: string;
+    };
+
+    watchEffect(async () => {
+      if (store.historyOpen) {
+        replaceCurrentTask(undefined);
+        await updateHistoryTask({});
+        replaceCurrentTask(store.historyTasks[0]);
+      } else {
+        emit("reload");
+      }
+    });
+
+    watch(
+      () => props.shouldReload,
+      async () => {
+        await updateHistoryTask({ pageNumber: currentPage.value });
+        // 提交后,渲染第一条
+        replaceCurrentTask(store.historyTasks[0]);
+      }
+    );
+
+    const secretNumberInput = ref(null);
+    const loading = ref(false);
+    const currentPage = ref(1);
+
+    async function updateHistoryTask({
+      pageNumber = 1,
+      pageSize = 10,
+    }: {
+      pageNumber?: number; // 从1开始
+      pageSize?: number;
+    }) {
+      loading.value = true;
+      const res = await getInspectedHistory({
+        pageNumber,
+        pageSize,
+        subjectCode,
+      });
+      loading.value = false;
+      if (res.data) {
+        store.historyTasks = res.data;
+      }
+    }
+
+    function replaceCurrentTask(task: Task | undefined) {
+      store.currentTask = task;
+    }
+
+    function previousPage() {
+      if (currentPage.value > 1) {
+        currentPage.value -= 1;
+        updateHistoryTask({ pageNumber: currentPage.value });
+      }
+    }
+    function nextPage() {
+      if (store.historyTasks.length >= 10) {
+        currentPage.value += 1;
+        updateHistoryTask({ pageNumber: currentPage.value });
+      }
+    }
+
+    return {
+      store,
+      loading,
+      secretNumberInput,
+      updateHistoryTask,
+      replaceCurrentTask,
+      currentPage,
+      previousPage,
+      nextPage,
+    };
+  },
+});
+</script>
+
+<style scoped>
+.history-container {
+  min-width: 250px;
+  border-right: 1px solid grey;
+}
+.current-task {
+  background-color: aqua;
+}
+</style>

+ 25 - 0
src/features/arbitrate/store.ts

@@ -0,0 +1,25 @@
+import { InspectStore, Task } from "@/types";
+import { reactive } from "vue";
+
+const obj = {
+  setting: {
+    fileServer: "",
+    userName: "",
+    subject: { name: "", code: "" },
+    uiSetting: {
+      "answer.paper.scale": 1,
+      "score.board.collapse": false,
+    },
+  },
+  status: {
+    totalCount: 0,
+  },
+  currentTask: undefined,
+  historyOpen: false,
+  MarkBoardTrackCollapse: false,
+  historyTasks: [],
+  focusTracks: [],
+  message: null,
+} as InspectStore;
+
+export const store = reactive(obj);

+ 1 - 1
src/features/library/inspect/LibraryInspect.vue

@@ -45,7 +45,7 @@ export default defineComponent({
     const { subjectCode, groupNumber, libraryId } = route.query as {
       subjectCode: string;
       groupNumber: string;
-      libraryId: string; // TODO: 未来单一任务
+      libraryId: string; // TODO: for未来单一任务
     };
 
     async function updateClearTask() {

+ 5 - 1
src/features/library/inspect/MarkHeader.vue

@@ -1,6 +1,10 @@
 <template>
   <div
-    class="tw-flex tw-gap-4 tw-justify-between tw-items-center header-container tw-px-1"
+    class="
+      tw-flex tw-gap-4 tw-justify-between tw-items-center
+      header-container
+      tw-px-1
+    "
     v-if="store.setting"
   >
     <div>

+ 4 - 0
src/router/index.ts

@@ -12,6 +12,10 @@ const routes = [
     path: "/admin/exam/library/inspected/start",
     component: () => import("@/features/library/inspect/LibraryInspect.vue"),
   },
+  {
+    path: "/admin/exam/arbitrate/start",
+    component: () => import("@/features/arbitrate/Arbitrate.vue"),
+  },
 ];
 
 // 3. Create the router instance and pass the `routes` option