瀏覽代碼

梳理文件目录 & 学生成绩轨迹图

Michael Wang 4 年之前
父節點
當前提交
f6902437fa

+ 8 - 0
src/api/studentTrackPage.ts

@@ -0,0 +1,8 @@
+import { httpApp } from "@/plugins/axiosApp";
+
+/** 查看单个学生的试卷轨迹 */
+export async function getSingleStudentTask(studentId: string) {
+  const form = new FormData();
+  studentId && form.append("studentId", studentId);
+  return httpApp.post("/admin/exam/track/student", form);
+}

+ 0 - 0
src/features/inspect/Inspect.vue → src/features/student/inspect/Inspect.vue


+ 0 - 0
src/features/inspect/MarkBoardInspect.vue → src/features/student/inspect/MarkBoardInspect.vue


+ 0 - 0
src/features/inspect/MarkBody.vue → src/features/student/inspect/MarkBody.vue


+ 0 - 0
src/features/inspect/MarkDrawTrack.vue → src/features/student/inspect/MarkDrawTrack.vue


+ 0 - 0
src/features/inspect/MarkHeader.vue → src/features/student/inspect/MarkHeader.vue


+ 0 - 0
src/features/inspect/MarkHistory.vue → src/features/student/inspect/MarkHistory.vue


+ 0 - 0
src/features/inspect/store.ts → src/features/student/inspect/store.ts


+ 195 - 0
src/features/student/studentTrack/MarkBody.vue

@@ -0,0 +1,195 @@
+<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 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(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,
+        });
+      }
+    }
+    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>

+ 5 - 11
src/features/library/inspect/MarkDrawTrack.vue → src/features/student/studentTrack/MarkDrawTrack.vue

@@ -40,23 +40,17 @@ export default defineComponent({
       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 }) {
+  setup({ trackList, specialTagList, originalImage }) {
     const focusedTrack = (track: Track) => {
       return store.focusTracks.includes(track);
     };
     const computeTopAndLeft = (track: Track | SpecialTag) => {
-      const topInsideSlice = track.offsetY - dy;
-      const leftInsideSlice = track.offsetX - dx;
+      const topInsideSlice = track.offsetY;
+      const leftInsideSlice = track.offsetX;
       return {
-        top: (topInsideSlice / sliceImage.naturalHeight) * 100 + "%",
-        left: (leftInsideSlice / sliceImage.naturalWidth) * 100 + "%",
+        top: (topInsideSlice / originalImage.naturalHeight) * 100 + "%",
+        left: (leftInsideSlice / originalImage.naturalWidth) * 100 + "%",
         "font-size": store.setting.uiSetting["answer.paper.scale"] * 2.2 + "em",
       };
     };

+ 132 - 0
src/features/student/studentTrack/MarkHeader.vue

@@ -0,0 +1,132 @@
+<template>
+  <div
+    class="
+      tw-flex tw-gap-4 tw-justify-between tw-items-center
+      header-container
+      tw-px-1
+    "
+    v-if="store.setting"
+  >
+    <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
+      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 } 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";
+
+export default defineComponent({
+  name: "MarkHeader",
+  components: {
+    ZoomInOutlined,
+    ZoomOutOutlined,
+    FullscreenOutlined,
+    HistoryOutlined,
+    UserOutlined,
+    PoweroffOutlined,
+    AlertOutlined,
+    QuestionCircleOutlined,
+  },
+  setup() {
+    const route = useRoute();
+
+    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;
+    });
+
+    const closeWindow = async () => {
+      window.close();
+    };
+
+    return {
+      store,
+      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>

+ 103 - 0
src/features/student/studentTrack/StudentTrack.vue

@@ -0,0 +1,103 @@
+<template>
+  <div class="my-container">
+    <mark-header />
+    <div class="tw-flex tw-gap-1">
+      <mark-body />
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted } from "vue";
+import { store } from "./store";
+import MarkHeader from "./MarkHeader.vue";
+import { useRoute } from "vue-router";
+import MarkBody from "./MarkBody.vue";
+import { Task } from "@/types";
+import { message } from "ant-design-vue";
+import { getSingleStudentTask } from "@/api/studentTrackPage";
+
+export default defineComponent({
+  name: "StudentTrack",
+  components: {
+    MarkHeader,
+    MarkBody,
+  },
+  setup: () => {
+    const route = useRoute();
+    let studentId = route.query.studentId;
+
+    async function updateTask() {
+      // const mkey = "fetch_task_key";
+      message.info({ content: "获取任务中...", duration: 2 });
+      let res = await getSingleStuTask();
+      // message.success({ content: "获取成功", key: mkey });
+
+      if (res.data.sliceUrls) {
+        store.setting.fileServer = res.data.fileServer;
+        store.setting.uiSetting = {
+          "answer.paper.scale": 1,
+          "score.board.collapse": false,
+        };
+
+        let task = {} as Task;
+
+        task.sliceUrls = res.data.sliceUrls.map(
+          (s: string) => store.setting.fileServer + s
+        );
+        // 目前api并没有区分score和tag
+        task.questionList = [
+          // @ts-ignore
+          // { trackList: res.data.tagList.filter((q) => !q.tagName) },
+        ];
+        // @ts-ignore
+        task.specialTagList = res.data.tagList.filter((q) => q.tagName);
+
+        store.currentTask = task;
+        if (store.currentTask)
+          store.setting.subject = store.currentTask.subject;
+      } else {
+        store.message = res.data.message;
+      }
+    }
+
+    async function fetchTask() {
+      await updateTask();
+    }
+
+    onMounted(async () => {
+      await fetchTask();
+    });
+
+    async function getSingleStuTask() {
+      return getSingleStudentTask(studentId as string);
+    }
+
+    return {
+      store,
+      fetchTask,
+    };
+  },
+});
+</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>

+ 26 - 0
src/features/student/studentTrack/store.ts

@@ -0,0 +1,26 @@
+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,
+    },
+    splitConfig: [],
+  },
+  status: {
+    totalCount: 0,
+  },
+  currentTask: undefined,
+  historyOpen: false,
+  MarkBoardTrackCollapse: false,
+  historyTasks: [],
+  focusTracks: [],
+  message: null,
+} as InspectStore;
+
+export const store = reactive(obj);

+ 5 - 1
src/router/index.ts

@@ -6,7 +6,7 @@ const routes = [
   { path: "/mark", component: Mark },
   {
     path: "/admin/exam/inspected/start",
-    component: () => import("@/features/inspect/Inspect.vue"),
+    component: () => import("@/features/student/inspect/Inspect.vue"),
   },
   {
     path: "/admin/exam/library/inspected/start",
@@ -16,6 +16,10 @@ const routes = [
     path: "/admin/exam/arbitrate/start",
     component: () => import("@/features/arbitrate/Arbitrate.vue"),
   },
+  {
+    path: "/admin/exam/track/student",
+    component: () => import("@/features/student/studentTrack/StudentTrack.vue"),
+  },
 ];
 
 // 3. Create the router instance and pass the `routes` option