Przeglądaj źródła

查看导入复核

Michael Wang 3 lat temu
rodzic
commit
7b4c1b6fd5

+ 23 - 0
src/api/importInspectPage.ts

@@ -0,0 +1,23 @@
+import { httpApp } from "@/plugins/axiosApp";
+
+export async function getInspectedSetting(studentId?: string) {
+  const form = new FormData();
+  studentId && form.append("studentId", studentId);
+  return httpApp.post("/admin/exam/inspected/import/getSetting", 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/import/getTask", form);
+}
+
+/** 保存复核任务 */
+export async function saveInspectedTask(studentId: string, isTag: string) {
+  const form = new FormData();
+  form.append("studentId", studentId);
+  form.append("isTag", isTag);
+  return httpApp.post("/admin/exam/inspected/import/tag", form);
+}

+ 1 - 1
src/components/QmButton.vue

@@ -3,7 +3,7 @@
   <!-- <template v-for="(_, slot) of $slots" v-slot:[slot]="scope">
       <slot :name="slot" v-bind="scope" />
     </template> -->
-  <a-button v-bind="newAttrs" :loading="inInterval.value" @click="insideClick">
+  <a-button v-bind="newAttrs" :loading="inInterval" @click="insideClick">
     <slot name="default" />
   </a-button>
 </template>

+ 154 - 0
src/features/student/importInspect/ImportInspect.vue

@@ -0,0 +1,154 @@
+<template>
+  <div class="my-container">
+    <mark-header />
+    <div class="tw-flex tw-gap-1">
+      <mark-body @error="renderError" />
+      <MarkBoardInspect
+        :tagged="isCurrentTagged"
+        :isFirst="isFirst"
+        :isLast="isLast"
+        @makeTag="saveTaskToServer"
+        @fetchTask="fetchTask"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from "vue";
+import {
+  getInspectedSetting,
+  getSingleInspectedTask,
+  saveInspectedTask,
+} from "@/api/importInspectPage";
+import { store } from "@/features/mark/store";
+import MarkHeader from "./MarkHeader.vue";
+import { useRoute } from "vue-router";
+import MarkBody from "./MarkBody.vue";
+import MarkBoardInspect from "./MarkBoardInspect.vue";
+import type { Task, Setting } from "@/types";
+import { message } from "ant-design-vue";
+
+const route = useRoute();
+let isSingleStudent = !!route.query.studentId;
+const { studentId } = route.query as {
+  studentId: string;
+};
+
+let studentIds = ref([] as Array<number>);
+let tagIds = ref([] as Array<number>);
+let currentStudentId = ref(0);
+
+async function updateSetting() {
+  const settingRes = await getInspectedSetting(studentId);
+  store.setting.examType = settingRes.data.examType;
+  store.setting.fileServer = settingRes.data.fileServer;
+  store.status.totalCount = settingRes.data.inspectCount;
+  store.setting.uiSetting = {
+    "answer.paper.scale": 1,
+    "score.board.collapse": false,
+    "normal.mode": "keyboard",
+  } as Setting["uiSetting"];
+
+  console.log(store);
+  if (!settingRes.data.inspectCount) {
+    store.message = settingRes.data.message;
+  } else {
+    studentIds.value = settingRes.data.studentIds;
+    tagIds.value = settingRes.data.tagIds;
+  }
+}
+// 要通过fetchTask调用
+async function updateTask() {
+  // const mkey = "fetch_task_key";
+  message.info({ content: "获取任务中...", duration: 2 });
+  let res;
+  if (currentStudentId.value) {
+    res = await getSingleInspectedTask("" + currentStudentId.value);
+  } else {
+    return;
+  }
+
+  if (res.data.studentId) {
+    let rawTask = res.data as Task;
+    rawTask.sliceUrls = rawTask.sliceUrls?.map(
+      (s) => store.setting.fileServer + s
+    );
+    rawTask.sheetUrls = rawTask.sheetUrls?.map(
+      (s) => store.setting.fileServer + s
+    );
+    store.currentTask = res.data;
+    store.setting.subject = res.data.subject;
+  } else {
+    store.message = res.data.message;
+  }
+}
+
+const isCurrentTagged = computed(() =>
+  tagIds.value.includes(currentStudentId.value)
+);
+const isFirst = computed(
+  () => studentIds.value.indexOf(currentStudentId.value) === 0
+);
+const isLast = computed(
+  () =>
+    studentIds.value.indexOf(currentStudentId.value) ===
+    studentIds.value.length - 1
+);
+
+async function fetchTask(next: boolean, init?: boolean) {
+  if (init) {
+    currentStudentId.value = studentIds.value[0];
+  } else if (isLast.value && next) {
+    return; // currentStudentId.value是最后一个不调用
+  } else if (isFirst.value && !next) {
+    return; // currentStudentId.value是第一个不调用
+  } else {
+    currentStudentId.value =
+      studentIds.value[
+        studentIds.value.indexOf(currentStudentId.value) + (next ? 1 : -1)
+      ];
+  }
+  if (!currentStudentId.value) return; // 无currentStudentId不调用
+  await updateTask();
+}
+
+onMounted(async () => {
+  await updateSetting();
+  await fetchTask(true, true); // mark-header 会调用 (watchEffect)
+});
+
+const realStudentId = computed(
+  () => (isSingleStudent ? studentId : store.currentTask?.studentId) as string
+);
+const saveTaskToServer = async () => {
+  const mkey = "save_task_key";
+  message.loading({ content: "标记评卷任务...", key: mkey });
+  const res = (await saveInspectedTask(
+    currentStudentId.value + "",
+    !isCurrentTagged.value + ""
+  )) as any;
+  if (res.data.success) {
+    message.success({ content: "标记成功", key: mkey, duration: 2 });
+    if (isCurrentTagged.value) {
+      tagIds.value.splice(tagIds.value.indexOf(currentStudentId.value), 1);
+    } else {
+      tagIds.value.push(currentStudentId.value);
+    }
+  } else {
+    console.log(res.data.message);
+    message.error({ content: res.data.message, key: mkey, duration: 10 });
+  }
+};
+
+const renderError = () => {
+  store.currentTask = undefined;
+  store.message = "加载失败,请重新加载。";
+};
+</script>
+
+<style scoped>
+.my-container {
+  width: 100%;
+}
+</style>

+ 308 - 0
src/features/student/importInspect/MarkBoardInspect.vue

@@ -0,0 +1,308 @@
+<template>
+  <div
+    v-if="store.currentTask"
+    class="mark-board-track-container"
+    :class="[store.setting.uiSetting['score.board.collapse'] ? 'hide' : 'show']"
+  >
+    <div class="top-container tw-flex-shrink-0 tw-flex tw-items-center">
+      <div class="tw-flex tw-flex-col tw-flex-1 tw-text-center">
+        <div class="tw-flex tw-justify-center">
+          <img
+            src="../../mark/images/totalscore.png"
+            style="width: 13px; height: 16px"
+          />
+        </div>
+        <div>试卷总分</div>
+      </div>
+      <div class="tw-flex-1" style="font-size: 40px">
+        {{ markerScore > 0 ? markerScore : 0 }}
+      </div>
+      <div
+        class="star"
+        :class="props.tagged ? 'star-yes' : 'star-no'"
+        @click="makeTag(!props.tagged)"
+      ></div>
+    </div>
+
+    <div class="tw-flex-grow tw-overflow-auto tw-my-5" v-if="groups">
+      <template v-for="(groupNumber, index) in groups" :key="index">
+        <div class="tw-mb-4 tw-bg-white tw-p-4">
+          <div
+            class="
+              tw-flex tw-justify-between tw-place-items-center
+              hover:tw-bg-gray-200
+            "
+            @mouseover="addFocusTrack(groupNumber, undefined, undefined)"
+            @mouseleave="removeFocusTrack"
+          >
+            <span class="secondary-text">分组 {{ groupNumber }}</span>
+          </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-font-bold
+                  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 === -1 ? "未选做" : question.score || 0 }}
+                </span>
+              </div>
+            </template>
+          </div>
+        </div>
+      </template>
+    </div>
+
+    <div class="tw-flex tw-flex-shrink-0 tw-justify-center tw-gap-4">
+      <a-button
+        type="primary"
+        class="full-width-btn"
+        :disabled="props.isFirst"
+        @click="fetchTask(false)"
+      >
+        上一个
+      </a-button>
+      <a-button
+        @click="fetchTask(true)"
+        type="primary"
+        class="full-width-btn"
+        :disabled="props.isLast"
+        >下一个</a-button
+      >
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { Question } from "@/types";
+import { computed, reactive, watch } from "vue";
+import { store } from "@/features/mark/store";
+
+const emit = defineEmits(["makeTag", "fetchTask"]);
+const props =
+  defineProps<{ tagged: boolean; isFirst: boolean; isLast: boolean }>();
+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 fetchTask(next: boolean) {
+  emit("fetchTask", next);
+}
+
+function makeTag(isTag: boolean) {
+  emit("makeTag", isTag);
+}
+</script>
+
+<style scoped>
+.mark-board-track-container {
+  display: flex;
+  flex-direction: column;
+  max-width: 290px;
+  min-width: 290px;
+  max-height: calc(100vh - 56px);
+  padding: 20px;
+  z-index: 1001;
+  transition: margin-right 0.5s;
+  color: var(--app-small-header-text-color);
+}
+.mark-board-track-container.show {
+  margin-right: 0;
+}
+.mark-board-track-container.hide {
+  margin-right: -290px;
+}
+
+.top-container {
+  background-color: var(--app-container-bg-color);
+  height: 86px;
+  border-radius: 5px;
+
+  color: white;
+  background-color: var(--app-primary-button-bg-color);
+}
+.total-score {
+  color: var(--app-main-text-color);
+  font-size: 32px;
+}
+.question {
+  min-width: 80px;
+  background-color: var(--app-container-bg-color);
+}
+.current-question .score {
+  color: var(--high-light-score-color);
+}
+
+.current-score {
+  color: var(--app-score-color);
+}
+.current-score-indicator {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 5px;
+  height: 5px;
+  background-color: #5c69f6;
+}
+.all-zero-unselective-button {
+  border-radius: 10px;
+  padding: 7px;
+  background-color: #4db9ff;
+  border: none;
+}
+.full-width-btn {
+  width: 100%;
+  border-radius: 20px;
+}
+.undo-btn {
+  background-color: var(--app-undo-button-bg-color);
+  border-color: var(--app-undo-button-bg-color);
+}
+
+.star {
+  margin-top: -30px;
+  margin-right: 20px;
+  width: 30px;
+  height: 30px;
+  cursor: pointer;
+
+  clip-path: polygon(
+    50% 0%,
+    61% 35%,
+    98% 35%,
+    68% 57%,
+    79% 91%,
+    50% 70%,
+    21% 91%,
+    32% 57%,
+    2% 35%,
+    39% 35%
+  );
+}
+
+.star.star-yes {
+  background-color: yellowgreen;
+}
+.star.star-no {
+  background-color: white;
+}
+</style>

+ 180 - 0
src/features/student/importInspect/MarkBody.vue

@@ -0,0 +1,180 @@
+<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 setup lang="ts">
+import { computed, reactive, ref, watch } from "vue";
+import { store } from "@/features/mark/store";
+import MarkDrawTrack from "./MarkDrawTrack.vue";
+import type { 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;
+}
+
+const emit = defineEmits(["error"]);
+
+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(url);
+    images.push(image);
+  }
+
+  for (const url of store.currentTask.sliceUrls) {
+    const completeUrl = 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,
+    });
+  }
+}
+
+// should not render twice at the same time
+let renderLock = false;
+const renderPaperAndMark = async () => {
+  if (renderLock) {
+    console.log("上个任务还未渲染完毕,稍等一秒再尝试渲染");
+    await new Promise((res) => setTimeout(res, 1000));
+    await renderPaperAndMark();
+    return;
+  }
+  renderLock = true;
+  sliceImagesWithTrackList.splice(0);
+
+  if (!store.currentTask) {
+    renderLock = false;
+    return;
+  }
+
+  try {
+    rendering.value = true;
+    await processImage();
+  } catch (error) {
+    sliceImagesWithTrackList.splice(0);
+    console.log("render error ", error);
+    // 图片加载出错,自动加载下一个任务
+    emit("error");
+  } finally {
+    renderLock = false;
+    rendering.value = false;
+  }
+};
+
+watch(() => store.currentTask, 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 + "%";
+});
+</script>
+
+<style scoped>
+.mark-body-container {
+  height: calc(100vh - 56px);
+  overflow: auto;
+  background-color: var(--app-container-bg-color);
+  background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
+    linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
+    linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
+    linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
+  background-size: 20px 20px;
+  background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
+  transform: inherit;
+
+  cursor: grab;
+  user-select: none;
+}
+.mark-body-container img {
+  width: 100%;
+}
+.single-image-container {
+  position: relative;
+}
+.image-seperator {
+  border: 2px solid rgba(120, 120, 120, 0.1);
+}
+</style>

+ 108 - 0
src/features/student/importInspect/MarkDrawTrack.vue

@@ -0,0 +1,108 @@
+<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 setup lang="ts">
+import type { SpecialTag, Track } from "@/types";
+import { store } from "@/features/mark/store";
+import { toRefs, watch } from "vue";
+
+const props = defineProps<{
+  trackList: Array<Track>;
+  specialTagList: Array<SpecialTag>;
+  originalImage: HTMLImageElement;
+}>();
+const { trackList, originalImage } = toRefs(props);
+
+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.value.naturalHeight) * 100 + "%",
+    left: (leftInsideSlice / originalImage.value.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" });
+    }
+  }
+);
+</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_size;
+}
+
+@keyframes change_size {
+  from {
+    font-size: 2em;
+    margin-top: -100px;
+    margin-left: -100px;
+  }
+  to {
+    font-size: 4em;
+    margin-top: -80px;
+    margin-left: -80px;
+  }
+}
+</style>

+ 209 - 0
src/features/student/importInspect/MarkHeader.vue

@@ -0,0 +1,209 @@
+<template>
+  <div
+    class="tw-flex tw-gap-4 tw-justify-start tw-items-center header-container"
+    v-if="store.setting"
+  >
+    <div
+      class="
+        tw-text-white
+        tw-block
+        tw-overflow-ellipsis
+        tw-overflow-hidden
+        tw-whitespace-nowrap
+        header-big-text
+        tw-pl-5
+      "
+    >
+      {{
+        `${store.setting.subject.code ?? ""}-${
+          store.setting.subject.name ?? ""
+        }`
+      }}
+    </div>
+    <div class="tw-flex tw-gap-4">
+      <div>
+        <span class="header-small-text">编号</span>
+        <span class="highlight-text">
+          {{ store.currentTask?.secretNumber ?? "-" }}
+        </span>
+      </div>
+      <div>
+        <span class="header-small-text">姓名</span>
+        <span class="highlight-text">
+          {{ store.currentTask?.studentName ?? "-" }}
+        </span>
+      </div>
+    </div>
+    <div
+      v-if="!isSingleStudent"
+      class="tw-flex tw-gap-2 tw-items-center tw-flex-1"
+    >
+      <span>
+        <span class="header-small-text">待复核</span>
+        <span class="highlight-text">{{ store.status.totalCount ?? "-" }}</span>
+      </span>
+    </div>
+    <ZoomPaper v-if="isScanImage()" :store="store" />
+    <div class="tw-flex-grow"></div>
+    <div
+      class="tw-flex tw-place-items-center tw-cursor-pointer"
+      @click="closeWindow"
+    >
+      <PoweroffOutlined class="icon-font icon-with-text" />关闭
+    </div>
+    <div
+      class="
+        tw-flex tw-place-content-center tw-cursor-pointer tw-justify-self-end
+        menu
+      "
+      :class="[
+        !store.setting.uiSetting['score.board.collapse'] &&
+          store.currentTask &&
+          'menu-toggled',
+      ]"
+      @click="toggleScoreBoard"
+    >
+      <span
+        title="给分板"
+        class="tw-inline-flex tw-place-content-center tw-relative"
+      >
+        <img
+          src="../../mark/images/right-menu.svg"
+          :class="[
+            !store.setting.uiSetting['score.board.collapse'] && 'svg-red',
+          ]"
+        />
+      </span>
+      <div
+        v-if="
+          !store.setting.uiSetting['score.board.collapse'] && store.currentTask
+        "
+        class="triangle"
+      ></div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { clearInspectedTask } from "@/api/inspectPage";
+import { onMounted, ref } from "vue";
+import { store, isScanImage } from "@/features/mark/store";
+import { PoweroffOutlined } from "@ant-design/icons-vue";
+import { useRoute } from "vue-router";
+import ZoomPaper from "@/components/ZoomPaper.vue";
+
+const route = useRoute();
+let isSingleStudent = ref(false);
+isSingleStudent.value = !!route.query.studentId;
+const { studentId, subjectCode } = route.query as {
+  studentId: string;
+  subjectCode: string;
+};
+
+const toggleHistory = () => {
+  store.historyOpen = !store.historyOpen;
+};
+async function updateClearTask() {
+  await clearInspectedTask(studentId, subjectCode);
+}
+
+const closeWindow = async () => {
+  await updateClearTask();
+  window.close();
+};
+
+onMounted(() => {
+  // 不确定是否一定能在关闭页面时调用
+  window.addEventListener("beforeunload", () => {
+    updateClearTask();
+  });
+});
+
+const toggleScoreBoard = () => {
+  store.setting.uiSetting["score.board.collapse"] =
+    !store.setting.uiSetting["score.board.collapse"];
+};
+</script>
+
+<style scoped>
+.header-bg-color {
+  background-color: var(--header-bg-color);
+}
+.header-container {
+  position: relative;
+  height: 56px;
+  line-height: 16px;
+
+  background-color: var(--header-bg-color);
+  color: rgba(255, 255, 255, 0.5);
+}
+.menu {
+  width: 56px;
+  height: 56px;
+  padding: 20px;
+}
+.menu:hover,
+.menu-toggled {
+  background-color: rgba(255, 255, 255, 0.2);
+}
+
+.header-container span {
+  vertical-align: middle;
+}
+.header-big-text {
+  font-size: 20px;
+  line-height: 30px;
+}
+.header-small-text {
+  font-size: var(--app-secondary-font-size);
+}
+.highlight-text {
+  color: white;
+  font-size: var(--app-title-font-size);
+}
+.header-bg-color.ant-btn:hover {
+  background-color: var(--app-ant-select-bg-override-color) !important;
+}
+
+.assistant-table {
+  z-index: 5500;
+  border-collapse: separate;
+  border-spacing: 0 1em;
+  color: var(--app-bold-text-color);
+  width: 240px;
+}
+.assistant-table tr td:nth-child(2) {
+  text-align: right;
+}
+.svg-red {
+  filter: invert(27%) sepia(51%) saturate(2878%) hue-rotate(346deg)
+    brightness(104%) contrast(97%);
+}
+.triangle {
+  background-color: white;
+  width: 10px;
+  height: 10px;
+  clip-path: polygon(0 100%, 100% 100%, 50% 0);
+
+  position: absolute;
+  bottom: -2px;
+}
+.dropdown-triangle {
+  background-color: #8c8d9b;
+  width: 7px;
+  height: 5px;
+  clip-path: polygon(0 0, 100% 0, 50% 100%);
+  margin-left: 4px;
+}
+.markcount-animation {
+  animation: change-color 3s ease-in-out;
+}
+@keyframes change-color {
+  0% {
+    color: red;
+  }
+  100% {
+    color: white;
+  }
+}
+</style>

+ 5 - 0
src/router/index.ts

@@ -8,6 +8,11 @@ const routes = [
     path: "/admin/exam/inspected/start",
     component: () => import("@/features/student/inspect/Inspect.vue"),
   },
+  {
+    path: "/admin/exam/inspected/import/start",
+    component: () =>
+      import("@/features/student/importInspect/ImportInspect.vue"),
+  },
   {
     path: "/admin/exam/library/inspected/start",
     component: () => import("@/features/library/inspect/LibraryInspect.vue"),