Răsfoiți Sursa

单任务复核

Michael Wang 4 ani în urmă
părinte
comite
cb209dbfc1

+ 1 - 1
index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <link rel="icon" href="/favicon.ico" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Vite App</title>
+    <title>云阅卷</title>
   </head>
   <body>
     <div id="app"></div>

+ 66 - 0
src/api/libraryInspectPage.ts

@@ -0,0 +1,66 @@
+import { httpApp } from "@/plugins/axiosApp";
+import { Question } from "@/types";
+
+/** 清理复核任务 */
+export async function clearInspectedTask(
+  subjectCode?: string,
+  groupNumber?: string
+) {
+  const form = new FormData();
+  subjectCode && form.append("subjectCode", subjectCode);
+  groupNumber && form.append("groupNumber", groupNumber);
+  return httpApp.post("/admin/exam/library/clear", form);
+}
+
+/** 查看单个学生的复核任务 */
+// export async function getSingleInspectedTask(studentId: string) {
+//   // return httpApp.post("/admin/exam/inspected/getTask?studentId=" + studentId);
+//   const form = new FormData();
+//   studentId && form.append("studentId", studentId);
+//   return httpApp.post("/admin/exam/inspected/getTask", form);
+// }
+
+/** 批量复核得到单个学生的复核任务 */
+export async function getOneOfInspectedTask(
+  subjectCode?: string,
+  groupNumber?: string
+) {
+  const form = new FormData();
+  subjectCode && form.append("subjectCode", subjectCode);
+  groupNumber && form.append("groupNumber", groupNumber);
+  return httpApp.post("/admin/exam/library/getTask", form);
+}
+
+/** 批量复核得到任务总数 */
+export async function getInspectedTaskStatus(
+  subjectCode: string,
+  groupNumber: string
+) {
+  const form = new FormData();
+  form.append("subjectCode", subjectCode);
+  form.append("groupNumber", groupNumber);
+  return httpApp.post("/admin/exam/library/getStatus", form);
+}
+
+/** 批量复核设置 */
+export async function getInspectedSetting() {
+  return httpApp.post("/admin/exam/inspected/getSetting");
+}
+
+/** 保存复核任务 */
+export async function saveInspectedTask(libraryId: string) {
+  const form = new FormData();
+  form.append("libraryId", libraryId);
+  return httpApp.post("/admin/exam/library/inspected/save", form);
+}
+
+/** 复核任务打回问题 */
+export async function rejectInspectedTask(
+  studentId: string,
+  questionList: Array<Question>
+) {
+  return httpApp.post("/admin/exam/inspected/rejected", {
+    studentId,
+    questionList,
+  });
+}

+ 197 - 0
src/features/library/inspect/LibraryInspect.vue

@@ -0,0 +1,197 @@
+<template>
+  <div class="my-container">
+    <mark-header />
+    <div class="tw-flex tw-gap-1">
+      <mark-history
+        @reload="reloadAndfetchTask"
+        :should-reload="shouldReloadHistory"
+      />
+      <mark-body />
+      <MarkBoardInspect @inspect="saveTaskToServer" @reject="rejectQuestions" />
+    </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 { Question } from "@/types";
+import { message } from "ant-design-vue";
+import {
+  clearInspectedTask,
+  getInspectedSetting,
+  getInspectedTaskStatus,
+  getOneOfInspectedTask,
+  rejectInspectedTask,
+  saveInspectedTask,
+} from "@/api/libraryInspectPage";
+
+export default defineComponent({
+  name: "LibraryInspect",
+  components: {
+    MarkHeader,
+    MarkBody,
+    MarkHistory,
+    MarkBoardInspect,
+  },
+  setup: () => {
+    const route = useRoute();
+    let isSingleStudent = !!route.query.libraryId;
+    const { subjectCode, groupNumber, libraryId } = route.query as {
+      subjectCode: string;
+      groupNumber: string;
+      libraryId: string; // TODO: 未来单一任务
+    };
+
+    async function updateClearTask() {
+      await clearInspectedTask(subjectCode, groupNumber);
+    }
+
+    async function updateSetting() {
+      const settingRes = await getInspectedSetting();
+      store.setting.fileServer = settingRes.data.fileServer;
+      store.setting.userName = settingRes.data.userName;
+      store.setting.uiSetting = {
+        "answer.paper.scale": 1,
+        "score.board.collapse": false,
+      };
+    }
+    async function updateStatus() {
+      const res = await getInspectedTaskStatus(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 getSingleInspectedTask(libraryId);
+    }
+
+    async function getOneOfStuTask() {
+      return getOneOfInspectedTask(subjectCode, groupNumber);
+    }
+
+    const realLibraryId = computed(
+      () =>
+        (isSingleStudent ? libraryId : store.currentTask?.libraryId) as string
+    );
+    const saveTaskToServer = async () => {
+      console.log("save inspect task to server");
+      const mkey = "save_task_key";
+      message.loading({ content: "保存评卷任务...", key: mkey });
+      const res = (await saveInspectedTask(realLibraryId.value)) 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 });
+      }
+    };
+
+    const rejectQuestions = async (questions: Array<Question>) => {
+      if (!store.currentTask) return;
+      const mkey = "reject_task_key";
+      message.loading({ content: "打回评卷任务...", key: mkey });
+      const res = (await rejectInspectedTask(
+        //realLibraryId.value,
+        store.currentTask.studentId + "",
+        questions
+      )) as any;
+      if (res.data.success) {
+        store.currentTask = undefined;
+        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,
+      rejectQuestions,
+      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>

+ 260 - 0
src/features/library/inspect/MarkBoardInspect.vue

@@ -0,0 +1,260 @@
+<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 { 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() {
+      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>

+ 201 - 0
src/features/library/inspect/MarkBody.vue

@@ -0,0 +1,201 @@
+<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"
+          />
+          <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 { 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;
+}
+// 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();
+
+    let rendering = ref(false);
+    let sliceImagesWithTrackList: Array<SliceImage> = reactive([]);
+
+    async function processImage() {
+      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);
+      }
+
+      for (const url of store.currentTask.sliceUrls) {
+        const completeUrl = filters.toCompleteUrlWithFileServer(
+          store.setting.fileServer,
+          url
+        );
+
+        const indexInSliceUrls = store.currentTask.sliceUrls.indexOf(url) + 1;
+        const image = images[indexInSliceUrls - 1];
+
+        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
+        );
+
+        sliceImagesWithTrackList.push({
+          url: completeUrl,
+          indexInSliceUrls,
+          trackList: thisImageTrackList,
+          tagList: thisImageTagList,
+          originalImage: image,
+        });
+      }
+    }
+    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;
+        await processImage();
+      } 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);
+
+  cursor: grab;
+  user-select: none;
+}
+.grabbing {
+  cursor: grabbing;
+}
+.mark-body-container img {
+  width: 100%;
+}
+.single-image-container {
+  position: relative;
+}
+.image-seperator {
+  border: 2px solid rgba(120, 120, 120, 0.1);
+}
+</style>

+ 123 - 0
src/features/library/inspect/MarkDrawTrack.vue

@@ -0,0 +1,123 @@
+<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,
+    },
+  },
+  setup({ trackList, originalImage }) {
+    const focusedTrack = (track: Track) => {
+      return store.focusTracks.includes(track);
+    };
+    const computeTopAndLeft = (track: Track | SpecialTag) => {
+      const topInsideSlice = track.offsetY;
+      const leftInsideSlice = track.offsetX;
+      return {
+        top: (topInsideSlice / originalImage.naturalHeight) * 100 + "%",
+        left: (leftInsideSlice / originalImage.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>

+ 194 - 0
src/features/library/inspect/MarkHeader.vue

@@ -0,0 +1,194 @@
+<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 }}</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 { clearInspectedTask } from "@/api/libraryInspectPage";
+
+export default defineComponent({
+  name: "MarkHeader",
+  components: {
+    ZoomInOutlined,
+    ZoomOutOutlined,
+    FullscreenOutlined,
+    HistoryOutlined,
+    UserOutlined,
+    PoweroffOutlined,
+    AlertOutlined,
+    QuestionCircleOutlined,
+  },
+  setup() {
+    const route = useRoute();
+    let isSingleStudent = ref(false);
+    isSingleStudent.value = !!route.query.studentId;
+    const { studentId, subjectCode, groupNumber } = route.query as {
+      studentId: string;
+      subjectCode: string;
+      groupNumber: 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 toggleHistory = () => {
+      store.historyOpen = !store.historyOpen;
+    };
+    const greaterThanOneScale = computed(() => {
+      return store.setting.uiSetting["answer.paper.scale"] > 1;
+    });
+    const lessThanOneScale = computed(() => {
+      return store.setting.uiSetting["answer.paper.scale"] < 1;
+    });
+
+    async function updateHistoryTask({
+      pageNumber = 1,
+      pageSize = 10,
+    }: {
+      pageNumber: number; // 从1开始
+      pageSize: number;
+    }) {
+      // const res = await getInspectedHistory({
+      //   pageNumber,
+      //   pageSize,
+      //   subjectCode,
+      // });
+      // if (res.data) {
+      //   store.historyTasks.push(res.data);
+      // }
+    }
+
+    async function updateClearTask() {
+      await clearInspectedTask(subjectCode, groupNumber);
+    }
+
+    const closeWindow = async () => {
+      await updateClearTask();
+      window.close();
+    };
+
+    onMounted(() => {
+      // 不确定是否一定能在关闭页面时调用
+      window.addEventListener("beforeunload", () => {
+        updateClearTask();
+      });
+    });
+
+    return {
+      store,
+      isSingleStudent,
+      upScale,
+      downScale,
+      normalScale,
+      greaterThanOneScale,
+      lessThanOneScale,
+      updateHistoryTask,
+      toggleHistory,
+      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/library/inspect/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/library/inspect/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);

+ 8 - 2
src/router/index.ts

@@ -1,11 +1,17 @@
 import { createRouter, createWebHistory } from "vue-router";
 import Mark from "@/features/mark/Mark.vue";
-import Inspect from "@/features/inspect/Inspect.vue";
 
 const routes = [
   { path: "/", component: Mark },
   { path: "/mark", component: Mark },
-  { path: "/admin/exam/inspected/start", component: Inspect },
+  {
+    path: "/admin/exam/inspected/start",
+    component: () => import("@/features/inspect/Inspect.vue"),
+  },
+  {
+    path: "/admin/exam/library/inspected/start",
+    component: () => import("@/features/library/inspect/LibraryInspect.vue"),
+  },
 ];
 
 // 3. Create the router instance and pass the `routes` option