Procházet zdrojové kódy

feat: examnumbercheck

zhangjie před 1 měsícem
rodič
revize
44d15ac070

+ 2 - 0
package.json

@@ -30,6 +30,7 @@
     "lodash-es": "^4.17.21",
     "mitt": "^3.0.1",
     "mockjs": "^1.1.0",
+    "papaparse": "^5.5.2",
     "pinia": "^2.1.6",
     "pinia-plugin-persistedstate": "^3.2.1",
     "spark-md5": "^3.0.2",
@@ -50,6 +51,7 @@
     "@types/lodash-es": "^4.17.12",
     "@types/mockjs": "^1.0.10",
     "@types/node": "^20.6.1",
+    "@types/papaparse": "^5.3.15",
     "@types/spark-md5": "^3.0.4",
     "@vitejs/plugin-vue": "^4.3.4",
     "@vitejs/plugin-vue-jsx": "3.0.1",

+ 14 - 0
pnpm-lock.yaml

@@ -11,6 +11,7 @@ specifiers:
   '@types/lodash-es': ^4.17.12
   '@types/mockjs': ^1.0.10
   '@types/node': ^20.6.1
+  '@types/papaparse': ^5.3.15
   '@types/spark-md5': ^3.0.4
   '@vitejs/plugin-vue': ^4.3.4
   '@vitejs/plugin-vue-jsx': 3.0.1
@@ -38,6 +39,7 @@ specifiers:
   mockjs: ^1.1.0
   node-loader: ^1.0.1
   ora: ^5.1.0
+  papaparse: ^5.5.2
   pinia: ^2.1.6
   pinia-plugin-persistedstate: ^3.2.1
   portfinder: ^1.0.28
@@ -76,6 +78,7 @@ dependencies:
   lodash-es: 4.17.21
   mitt: 3.0.1
   mockjs: 1.1.0
+  papaparse: 5.5.2
   pinia: 2.2.2_typescript@5.5.4+vue@3.5.0
   pinia-plugin-persistedstate: 3.2.3_pinia@2.2.2
   spark-md5: 3.0.2
@@ -96,6 +99,7 @@ devDependencies:
   '@types/lodash-es': 4.17.12
   '@types/mockjs': 1.0.10
   '@types/node': 20.16.3
+  '@types/papaparse': 5.3.15
   '@types/spark-md5': 3.0.4
   '@vitejs/plugin-vue': 4.6.2_vite@4.5.3+vue@3.5.0
   '@vitejs/plugin-vue-jsx': 3.0.1_vite@4.5.3+vue@3.5.0
@@ -2596,6 +2600,12 @@ packages:
       undici-types: 6.19.8
     dev: true
 
+  /@types/papaparse/5.3.15:
+    resolution: {integrity: sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==}
+    dependencies:
+      '@types/node': 20.16.3
+    dev: true
+
   /@types/plist/3.0.5:
     resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
     dependencies:
@@ -6512,6 +6522,10 @@ packages:
     resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
     dev: true
 
+  /papaparse/5.5.2:
+    resolution: {integrity: sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==}
+    dev: false
+
   /parse-json/4.0.0:
     resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==}
     engines: {node: '>=4'}

+ 5 - 119
src/main/index.ts

@@ -11,38 +11,18 @@ import logger from "./logger";
 const isDev = process.env.NODE_ENV === "development";
 let win: BrowserWindow | null = null;
 let loadWin: any = null;
-const additionalData = { lockKey: "scanAdmin" };
-const gotTheLock = app.requestSingleInstanceLock(additionalData);
-logger.info(`gotTheLock:${gotTheLock}`);
-if (!gotTheLock) {
-  logger.info(`app quit => gotTheLock:${gotTheLock}`);
-  app.quit();
-} else {
-  //改变方案,扫描端退出后会杀掉管理端进程,不再考虑因异常没有show出管理端界面的情况。 2024-11-06
-  // app.on(
-  //   "second-instance",
-  //   (event, commandLine, workingDirectory, additionalData) => {
-  //     if (win) {
-  //       if (win.isMinimized()) win.restore();
-  //       //监听第二次双击进入应用程序的操作,如果本窗口是hide状态,并不会自动显示出来,而且会在任务管理器的后台进程里多好几个进程名
-  //       //于是需要在监听回调里,做激活显示本窗口的操作
-  //       win.show();
-  //     }
-  //   }
-  // );
-}
 
 function createWin() {
   const { width, height } = screen.getPrimaryDisplay().bounds;
   // Menu.setApplicationMenu(null);
   // 创建浏览器窗口
   win = new BrowserWindow({
-    width: 860,
-    // width: width,
-    height: 520,
-    // height: height,
+    // width: 860,
+    // height: 520,
+    width: width,
+    height: height,
     frame: false,
-    resizable: false,
+    resizable: true,
     transparent: true,
     webPreferences: {
       preload: path.resolve(__dirname, "preload.js"),
@@ -73,96 +53,6 @@ function createWin() {
   });
 }
 
-function createLoadWin() {
-  Menu.setApplicationMenu(null);
-  loadWin = new BrowserWindow({
-    width: 840,
-    height: 500,
-    backgroundColor: "#222",
-    frame: false,
-    transparent: true,
-    skipTaskbar: true,
-    resizable: false,
-    // webPreferences: { experimentalFeatures: true },
-  });
-
-  loadWin.loadFile(path.resolve(__dirname, "static/load/index.html"));
-
-  loadWin.show();
-
-  setTimeout(async () => {
-    if (isDev) {
-      try {
-        await installExtension(VUEJS3_DEVTOOLS);
-      } catch (e: any) {
-        console.error("Vue Devtools failed to install:", e.toString());
-      }
-    }
-    createWin();
-  }, 2200);
-
-  loadWin.on("closed", () => {
-    loadWin = null;
-  });
-}
-
-// app.isReady()
-//   ? createLoadWin()
-//   : app.on("ready", () => {
-//       createLoadWin();
-//     });
-
-const isRunning = (query: string, cb: Function) => {
-  exec(
-    "cmd /c chcp 65001>nul && tasklist /FO CSV",
-    (err: any, stdout: any, stderr: any) => {
-      cb(stdout.toLowerCase().indexOf(query.toLowerCase()) > -1);
-    }
-  );
-};
-
-function watchClientIsRunning(times = 10) {
-  setTimeout(() => {
-    isRunning("client.exe", (status: boolean) => {
-      logger.info(`watchClientIsRunning times:${times},status:${status}`);
-
-      if (status) {
-        if (times > 0) {
-          watchClientIsRunning(times--);
-        }
-      } else {
-        win?.show();
-      }
-    });
-  }, 500);
-}
-
-function startExe(exePath: string) {
-  logger.info("主进程接收到的exe路径:", exePath);
-  let checkPath = exePath.includes(".exe ")
-    ? exePath.split(".exe ")[0] + ".exe"
-    : exePath;
-  const fileExists = fs.existsSync(checkPath);
-  if (fileExists) {
-    // exec(exePath, (error, stdout, stderr) => {
-    //   console.log("子进程关闭了!");
-    //   win?.show();
-    // });
-    exec(exePath, (error, stdout, stderr) => {
-      if (error) {
-        logger.error(`client error:${error.toString()}`);
-        return;
-      }
-      logger.info(`client closed stdout:${stdout.toString()}`);
-      logger.info(`client closed stderr:${stderr.toString()}`);
-      console.log("子进程关闭了!");
-      watchClientIsRunning();
-    });
-  } else {
-    dialog.showErrorBox("tip", `${checkPath}不存在!`);
-  }
-}
-
 function registEvent() {
   ipcMain.on("change-win-size", (event, args: string) => {
     // const { width, height } = screen.getPrimaryDisplay().workAreaSize;
@@ -183,10 +73,6 @@ function registEvent() {
     win?.minimize();
   });
 
-  ipcMain.on("startExe", (event, args: string) => {
-    startExe(args);
-  });
-
   ipcMain.on("hide-app", () => {
     win?.hide();
   });

+ 1 - 0
src/render/ap/types/dataCheck.ts

@@ -54,6 +54,7 @@ export interface PaperPageItem {
   };
   selective: { type: string; result: string } | null;
   recogData: string | null;
+  recogUri?: string | null;
 }
 
 // 某张缺页时没有id和pages等其他字段

+ 13 - 2
src/render/router/routes.ts

@@ -3,8 +3,11 @@ import Layout from "@/Layout/index.vue";
 const routes: RouteRecordRaw[] = [
   {
     path: "/",
-    name: "Login",
-    component: () => import("@/views/Login/index.vue"),
+    name: "Index",
+    component: () => import("@/views/Index.vue"),
+    redirect: {
+      name: "ExamNumberCheck",
+    },
   },
   {
     path: "/layout",
@@ -14,6 +17,14 @@ const routes: RouteRecordRaw[] = [
       title: "首页",
     },
     children: [
+      {
+        path: "exam-number-check",
+        name: "ExamNumberCheck",
+        component: () => import("@/views/ExamNumberCheck/index.vue"),
+        meta: {
+          title: "准考证校验",
+        },
+      },
       {
         path: "cur-exam",
         name: "CurExam",

+ 1 - 0
src/render/store/modules/app/index.ts

@@ -13,6 +13,7 @@ export const useAppStore = defineStore<"app", any, any, any>("app", {
       tip: "",
     },
     timeDiff: 0,
+    serverUrl: "http://192.168.10.214:8080",
   }),
   actions: {
     setServerStatus(obj: any) {

+ 4 - 0
src/render/store/modules/dataCheck/index.ts

@@ -86,6 +86,10 @@ export const useDataCheckStore = defineStore("dataCheck", {
       this.curStudent.paperType = paperType || "#";
       this.curStudent.papers[0].pages[0].paperType.result = paperType;
     },
+    modifyExamNumber(examNumber: string) {
+      if (!this.curStudent) return;
+      this.curStudent.examNumber = examNumber || "";
+    },
   },
   persist: {
     storage: sessionStorage,

+ 1 - 1
src/render/utils/tool.ts

@@ -325,7 +325,7 @@ export const obj2formData = (obj: Record<string, any>): FormData => {
 
 export function getFileUrl(url: string) {
   const baseUrl = local.get("baseUrl") || "";
-  return `${baseUrl}/file/${url}`;
+  return url.includes("http") ? url : `${baseUrl}/file/${url}`;
 }
 
 export function getSliceFileUrl(

+ 115 - 0
src/render/views/ExamNumberCheck/CheckAction.vue

@@ -0,0 +1,115 @@
+<template>
+  <div class="check-action">
+    <a-collapse
+      v-model:activeKey="panelKey"
+      :bordered="false"
+      expandIconPosition="end"
+    >
+      <a-collapse-panel key="1">
+        <template #header><FilterFilled />搜索条件 </template>
+        <div>
+          <a-button class="m-r-8px" type="primary" @click="onSearch"
+            >查询</a-button
+          >
+        </div>
+      </a-collapse-panel>
+      <a-collapse-panel key="2">
+        <template #header><IdcardFilled />题卡信息 </template>
+
+        <QuestionPanel
+          v-if="dataCheckStore.curStudent && dataCheckStore.curPage"
+          v-model:questions="questions"
+          :info="questionInfo"
+          :simple="false"
+          @change="onQuestionsChange"
+        />
+      </a-collapse-panel>
+    </a-collapse>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref, watch } from "vue";
+import { getSubjectList } from "@/ap/base";
+import {
+  FilterFilled,
+  PictureFilled,
+  IdcardFilled,
+} from "@ant-design/icons-vue";
+import { message } from "ant-design-vue";
+
+import { useDataCheckStore } from "@/store";
+import {
+  DataCheckListFilter,
+  DataStatus,
+  ExamStatus,
+} from "@/ap/types/dataCheck";
+import { SubjectItem } from "@/ap/types/base";
+import { ReviewExportType } from "@/ap/types/review";
+import { QuestionInfo } from "./types";
+
+import { dataCheckStudentExport, dataCheckRoomExport } from "@/ap/dataCheck";
+import { examStatusSave } from "@/ap/absentCheck";
+import useLoading from "@/hooks/useLoading";
+import { objModifyAssign } from "@/utils/tool";
+import useUpload from "./useUpload";
+
+import QuestionPanel from "./QuestionPanel.vue";
+import { getTextAreaRows } from "@/utils";
+defineOptions({
+  name: "CheckAction",
+});
+
+const emit = defineEmits(["search"]);
+
+const dataCheckStore = useDataCheckStore();
+const { save } = useUpload();
+
+const panelKey = ref(["1", "2"]);
+
+function onSearch() {
+  emit("search");
+}
+
+// question panel
+const questionInfo = computed(() => {
+  if (!dataCheckStore.curStudent) return {} as QuestionInfo;
+  return {
+    examNumber: dataCheckStore.curStudent.examNumber,
+    name: dataCheckStore.curStudent.name,
+    info: dataCheckStore.curStudent.info,
+    seatNumber: dataCheckStore.curStudent.seatNumber,
+    paperType: dataCheckStore.curStudent.paperType,
+    examStatus: dataCheckStore.curStudent.examStatus as string,
+    packageCode: dataCheckStore.curStudent.packageCode as string,
+  };
+});
+
+const questions = ref([] as string[]);
+watch(
+  () => dataCheckStore.curPage?.question?.result,
+  (val) => {
+    if (!val) {
+      questions.value = [];
+      return;
+    }
+    questions.value = [...val];
+  },
+  {
+    deep: true,
+    immediate: true,
+  }
+);
+watch(
+  () => dataCheckStore.imageType,
+  (val) => {
+    imageType.value = val;
+  }
+);
+
+async function onQuestionsChange() {
+  if (!dataCheckStore.curPage) return;
+  dataCheckStore.curPage.question.result = [...questions.value];
+  await save();
+}
+</script>

+ 86 - 0
src/render/views/ExamNumberCheck/EditExamNumberDialog.vue

@@ -0,0 +1,86 @@
+<template>
+  <a-modal
+    v-model:open="visible"
+    :width="424"
+    style="top: 10vh"
+    title="输入考号"
+    @ok="confirm"
+  >
+    <a-form ref="formRef" :label-col="{ style: { width: '50px' } }">
+      <a-form-item label="考号">
+        <div class="exam-number">
+          <a-input
+            v-model:value="examNumber"
+            placeholder="请输入"
+            allow-clear
+          ></a-input>
+        </div>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref, watch, computed } from "vue";
+import { message } from "ant-design-vue";
+import { getTextAreaRows } from "@/utils";
+import useModal from "@/hooks/useModal";
+
+defineOptions({
+  name: "EditExamNumberDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+const props = defineProps<{
+  data: string;
+}>();
+
+const emit = defineEmits(["confirm"]);
+
+const examNumber = ref("");
+
+async function confirm() {
+  if (!examNumber.value) {
+    message.warning("请输入考号");
+    return;
+  }
+  emit("confirm", examNumber.value);
+  close();
+}
+
+watch(
+  () => visible.value,
+  (val) => {
+    if (val) {
+      examNumber.value = props.data || "";
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+</script>
+
+<style lang="less" scoped>
+.exam-number {
+  position: relative;
+
+  .ant-input {
+    padding-bottom: 30px;
+    width: 100%;
+  }
+
+  .number-suffix {
+    position: absolute;
+    color: @text-color3;
+    height: 22px;
+    line-height: 22px;
+    bottom: 5px;
+    right: 5px;
+    z-index: 8;
+  }
+}
+</style>

+ 331 - 0
src/render/views/ExamNumberCheck/QuestionPanel.vue

@@ -0,0 +1,331 @@
+<template>
+  <div class="question-panel">
+    <a-descriptions v-if="info" class="panel-info" :column="10">
+      <a-descriptions-item label="准考证号" :span="10">
+        <a-button class="ant-gray m-r-4px">{{ info.examNumber }}</a-button>
+        <a-button @click="onEditPaperType">
+          <template #icon><SwapOutlined /></template>
+        </a-button>
+      </a-descriptions-item>
+      <a-descriptions-item label="问题" :span="10">
+        {{ info.info }}
+      </a-descriptions-item>
+    </a-descriptions>
+    <div
+      v-if="!simple && dataCheckStore.curPage?.question"
+      ref="panelBodyRef"
+      class="panel-body"
+    >
+      <div class="panel-body-title">
+        <h4>客观题</h4>
+        <p>多于一个填涂显示>号,未填涂显示#号</p>
+      </div>
+      <div
+        v-for="(item, index) in questionList"
+        :key="index"
+        :class="['question-item', `question-item-${index}`]"
+      >
+        <span>{{ getQuestionNo(index) }}:</span>
+        <a-button
+          v-if="editable || item.length <= 1"
+          :class="['ant-gray', { 'is-active': curQuestionIndex === index }]"
+          :disabled="!editable"
+          @click="onEditQuestion(index)"
+          >{{ getQuesionCont(item) }}</a-button
+        >
+        <a-tooltip v-else placement="top">
+          <template #title>
+            <span>{{ item.split("").join(",") }}</span>
+          </template>
+          <a-button disabled>{{ getQuesionCont(item) }}</a-button>
+        </a-tooltip>
+      </div>
+
+      <div
+        v-if="quesionEditShow && editable"
+        class="queston-edit"
+        :style="quesionEditStyle"
+        v-ele-click-outside-directive="hideEditQuestion"
+        @keydown.stop
+        @keyup.enter="onSaveQuesion"
+      >
+        <a-input
+          v-model:value="curQuestion"
+          style="width: 64px"
+          @change="toUpperCase"
+        ></a-input>
+        <a-button class="ant-simple m-l-8px" type="link" @click="onSaveQuesion"
+          >保存(Enter)</a-button
+        >
+      </div>
+    </div>
+  </div>
+
+  <EditExamNumberDialog
+    ref="editExamNumberDialogRef"
+    :data="info.examNumber"
+    @confirm="examNumberModified"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch, onMounted } from "vue";
+import { message } from "ant-design-vue";
+import { SwapOutlined } from "@ant-design/icons-vue";
+import { QuestionInfo } from "./types";
+import { parseRecogData } from "@/utils/recog/recog";
+
+import useDictOption from "@/hooks/dictOption";
+import EditExamNumberDialog from "./EditExamNumberDialog.vue";
+import { useDataCheckStore } from "@/store";
+
+import { vEleClickOutsideDirective } from "@/directives/eleClickOutside";
+import { getSliceFileUrl, recogResultTransform } from "@/utils/tool";
+import useUpload from "./useUpload";
+
+defineOptions({
+  name: "QuestionPanel",
+});
+
+const props = withDefaults(
+  defineProps<{
+    questions: string[];
+    info: QuestionInfo;
+    simple: boolean;
+    editable?: boolean;
+  }>(),
+  {
+    questions: () => [],
+    simple: false,
+    editable: true,
+  }
+);
+const emit = defineEmits(["update:questions", "change", "examNumberChange"]);
+
+const dataCheckStore = useDataCheckStore();
+const { save } = useUpload();
+
+const questionList = ref([] as string[]);
+const curQuestion = ref("");
+const curQuestionIndex = ref(-1);
+
+const toUpperCase = () => {
+  if ((curQuestion.value || "").includes("#")) {
+    if (curQuestion.value.length > 1) {
+      curQuestion.value = "#";
+    }
+  } else {
+    curQuestion.value = curQuestion.value.replace(/[^a-zA-Z]/g, "");
+  }
+  curQuestion.value = curQuestion.value.toUpperCase();
+};
+
+function getQuestionNo(index: number) {
+  const no = index + 1;
+  return no < 10 ? `0${no}` : `${no}`;
+}
+
+function getQuesionCont(cont: string) {
+  if (!cont) return "#";
+  if (cont.length > 1) return ">";
+  return cont;
+}
+
+// question edit
+const quesionEditShow = ref(false);
+const quesionEditPos = ref({
+  left: 0,
+  top: 0,
+});
+const quesionEditStyle = computed(() => {
+  return {
+    top: `${quesionEditPos.value.top}px`,
+    left: `${quesionEditPos.value.left}px`,
+  };
+});
+const panelBodyRef = ref();
+function onEditQuestion(index: number) {
+  curQuestionIndex.value = index;
+  const qcont = questionList.value[curQuestionIndex.value];
+  curQuestion.value = qcont;
+
+  quesionEditShow.value = true;
+  updateQuestionEditPos(index);
+}
+
+function updateQuestionEditPos(index: number) {
+  const panelBodyDom = panelBodyRef.value as HTMLDivElement;
+  const itemDom = panelBodyDom.querySelector(
+    `.question-item-${index}`
+  ) as HTMLDivElement;
+  let left = itemDom.offsetLeft + 30;
+  left = Math.min(panelBodyDom.clientWidth - 165, left);
+  quesionEditPos.value.left = left;
+  quesionEditPos.value.top = itemDom.offsetTop - 54;
+}
+
+function hideEditQuestion() {
+  quesionEditShow.value = false;
+  curQuestionIndex.value = -1;
+}
+
+function onSaveQuesion() {
+  if (!quesionEditShow.value) return;
+
+  if (!curQuestion.value) {
+    message.error("请输入答案!");
+    return;
+  }
+  const questionCont = curQuestion.value;
+
+  if (!questionCont) {
+    message.error("请输入答案!");
+    return;
+  }
+
+  questionList.value[curQuestionIndex.value] = questionCont;
+  quesionEditShow.value = false;
+
+  emit("update:questions", questionList.value);
+  emit("change", questionList.value);
+}
+
+// edit exam number
+const editExamNumberDialogRef = ref();
+function onEditPaperType() {
+  if (!dataCheckStore.curPage) return;
+  editExamNumberDialogRef.value?.open();
+}
+const curPage = computed(() => dataCheckStore.curPage);
+async function examNumberModified(examNumber: string) {
+  if (!dataCheckStore.curStudent) return;
+
+  dataCheckStore.modifyExamNumber(examNumber);
+  await save();
+}
+
+watch(
+  () => props.questions,
+  (val) => {
+    if (!val) return;
+    questionList.value = [...val];
+  },
+  {
+    immediate: true,
+  }
+);
+
+onMounted(() => {});
+</script>
+
+<style lang="less" scoped>
+.question-panel {
+  .panel-body {
+    margin: 15px -14px 0;
+    padding: 0 14px;
+    border-top: 1px solid @border-color1;
+    position: relative;
+    font-size: 0;
+
+    &-title {
+      padding: 12px 0 8px;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      font-size: 14px;
+
+      > p {
+        color: @text-color3;
+      }
+    }
+
+    .question-item {
+      display: inline-block;
+      vertical-align: middle;
+      width: 20%;
+      font-size: 14px;
+      margin-bottom: 12px;
+
+      > span {
+        display: inline-block;
+        width: 30px;
+        padding-right: 4px;
+        text-align: right;
+        font-size: 13px;
+      }
+      .ant-btn {
+        padding-left: 10px;
+        padding-right: 10px;
+
+        &.is-active {
+          border-color: @brand-color;
+        }
+      }
+    }
+    .queston-edit {
+      position: absolute;
+      width: 165px;
+      background: #f2f3f5;
+      box-shadow: 0px 4px 8px 0px rgba(54, 61, 89, 0.2);
+      border-radius: 8px;
+      padding: 8px;
+      border: 1px solid @border-color1;
+      z-index: 9;
+    }
+  }
+
+  :deep(.ant-descriptions-row) {
+    .ant-descriptions-item {
+      padding-bottom: 8px;
+      .ant-descriptions-item-label {
+        display: block;
+        color: @text-color2;
+      }
+      .ant-descriptions-item-label::after {
+        margin-inline: 2px 4px;
+      }
+
+      &:first-child {
+        .ant-descriptions-item-label {
+          width: 66px;
+          text-align: right;
+        }
+      }
+    }
+    .ant-descriptions-item-content {
+      align-items: center;
+    }
+
+    .ant-radio-wrapper span.ant-radio + * {
+      padding-inline-start: 4px;
+      padding-inline-end: 4px;
+    }
+  }
+
+  :deep(.ant-descriptions-row:nth-of-type(2)) {
+    .ant-descriptions-item {
+      padding-bottom: 4px;
+    }
+  }
+
+  :deep(.ant-descriptions-row:last-child) {
+    .ant-descriptions-item {
+      padding-bottom: 0;
+
+      .ant-descriptions-item-container {
+        align-items: center;
+      }
+
+      .ant-descriptions-item-label {
+        line-height: 28px;
+      }
+
+      .ant-btn {
+        padding: 2px 6px;
+        height: 28px;
+        min-width: 28px;
+      }
+    }
+  }
+}
+</style>

+ 64 - 0
src/render/views/ExamNumberCheck/ScanImage/ColorPickerDialog.vue

@@ -0,0 +1,64 @@
+<template>
+  <a-modal v-model:open="visible" :width="340" centered @ok="onOk">
+    <template #title> 颜色选择 </template>
+
+    <ColorPicker
+      :color="selectColor"
+      alpha-channel="hide"
+      default-format="hex"
+      @color-change="updateColor"
+    />
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, watch } from "vue";
+import { message } from "ant-design-vue";
+import { ColorPicker, ColorChangeDetail } from "vue-accessible-color-picker";
+
+import useModal from "@/hooks/useModal";
+
+defineOptions({
+  name: "ColorPickerDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+const props = defineProps<{
+  color: string;
+}>();
+const emit = defineEmits(["confirm"]);
+
+const selectColor = ref("");
+function updateColor(eventData: ColorChangeDetail) {
+  selectColor.value = eventData.cssColor;
+}
+
+function onOk() {
+  if (!selectColor.value) {
+    message.error("请选择颜色");
+    return;
+  }
+  emit("confirm", selectColor.value);
+  close();
+}
+
+// init
+watch(
+  () => visible.value,
+  (val) => {
+    if (val) {
+      selectColor.value = props.color;
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+</script>
+
+<style>
+@import url("vue-accessible-color-picker/styles");
+</style>

+ 177 - 0
src/render/views/ExamNumberCheck/ScanImage/FillAreaSetDialog.vue

@@ -0,0 +1,177 @@
+<template>
+  <a-modal v-model:open="visible" :width="334" centered>
+    <template #title> 填涂区设置 </template>
+
+    <a-form
+      ref="formRef"
+      :model="formData"
+      :rules="rules"
+      :label-col="{ style: { width: '60px' } }"
+    >
+      <a-form-item label="未填涂">
+        <div
+          class="color-block color-unfill"
+          :style="{ backgroundColor: formData.unfillColor }"
+          @click="onEditColor('unfillColor')"
+        ></div>
+        <a-checkbox v-model:checked="formData.unfillShow">显示</a-checkbox>
+      </a-form-item>
+      <a-form-item label="已填涂">
+        <div
+          class="color-block color-fill"
+          :style="{ backgroundColor: formData.fillColor }"
+          @click="onEditColor('fillColor')"
+        ></div>
+        <a-checkbox v-model:checked="formData.fillShow">显示</a-checkbox>
+      </a-form-item>
+      <a-form-item name="borderWidth" label="宽度">
+        <a-input-number
+          v-model:value="formData.borderWidth"
+          style="width: 40px; margin-right: 4px"
+          :min="1"
+          :max="9"
+          :precision="0"
+          :controls="false"
+        />
+        <span class="input-tips">像素</span>
+      </a-form-item>
+    </a-form>
+
+    <template #footer>
+      <div class="box-justify">
+        <a-button type="link" @click="initSet">恢复默认值</a-button>
+
+        <div>
+          <a-button type="text" @click="close">取消</a-button>
+          <a-button type="primary" @click="confirm">确认</a-button>
+        </div>
+      </div>
+    </template>
+  </a-modal>
+
+  <ColorPickerDialog
+    ref="colorPickerDialogRef"
+    :color="curColor"
+    @confirm="onColorConfirm"
+  />
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, watch, UnwrapRef } from "vue";
+import { message } from "ant-design-vue";
+
+import useModal from "@/hooks/useModal";
+import { useUserStore } from "@/store";
+import { objModifyAssign } from "@/utils/tool";
+
+import ColorPickerDialog from "./ColorPickerDialog.vue";
+
+defineOptions({
+  name: "FillAreaSetDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+const userStore = useUserStore();
+
+const defaultFormData = {
+  fillColor: "#f53f3f",
+  fillShow: true,
+  unfillColor: "#165DFF",
+  unfillShow: false,
+  borderWidth: 2,
+};
+
+const emit = defineEmits(["modified"]);
+
+type FormDataType = typeof defaultFormData;
+const formRef = ref();
+const formData: UnwrapRef<FormDataType> = reactive({
+  ...defaultFormData,
+});
+const rules: FormRules<keyof FormDataType> = {
+  borderWidth: [
+    {
+      required: true,
+      message: "请输入",
+      trigger: "change",
+    },
+  ],
+};
+
+function initSet() {
+  objModifyAssign(formData, defaultFormData);
+}
+
+async function confirm() {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+
+  userStore.setRecogFillSet(formData);
+
+  message.success("保存成功!");
+  emit("modified", formData);
+  close();
+}
+
+// color picker
+type EditType = "fillColor" | "unfillColor";
+const curColor = ref("");
+const colorPickerDialogRef = ref();
+const editType = ref<EditType>("fillColor");
+function onEditColor(type: EditType) {
+  curColor.value =
+    type === "fillColor" ? formData.fillColor : formData.unfillColor;
+  editType.value = type;
+  colorPickerDialogRef.value?.open();
+}
+
+function onColorConfirm(val: string) {
+  if (editType.value === "fillColor") {
+    formData.fillColor = val;
+  } else {
+    formData.unfillColor = val;
+  }
+}
+
+/* init modal */
+watch(
+  () => visible.value,
+  (val) => {
+    if (val) {
+      modalOpenHandle();
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+
+function modalOpenHandle() {
+  objModifyAssign(formData, userStore.recogFillSet || defaultFormData);
+}
+</script>
+
+<style lang="less" scoped>
+.color-block {
+  display: inline-block;
+  vertical-align: middle;
+  width: 32px;
+  height: 32px;
+  border-radius: 6px;
+  margin-right: 8px;
+  cursor: pointer;
+  border: 1px solid #b0b0b0;
+
+  &:hover {
+    opacity: 0.8;
+  }
+}
+.input-tips {
+  display: inline-block;
+  vertical-align: middle;
+  margin-top: 3px;
+}
+</style>

+ 256 - 0
src/render/views/ExamNumberCheck/ScanImage/RecogEditDialog.vue

@@ -0,0 +1,256 @@
+<template>
+  <a-modal
+    v-model:open="visible"
+    width="100%"
+    :footer="false"
+    :closable="false"
+    :mask="false"
+    :maskClosable="false"
+    :keyboard="false"
+    wrapClassName="recog-edit-dialog"
+    :afterClose="afterClose"
+  >
+    <div class="recog-edit">
+      <div class="recog-row">
+        <div class="recog-col is-static is-col1">
+          <div class="modal-box">
+            <p class="box-title">{{ recogTitle }}</p>
+            <p class="box-cont">{{ recogTitleDesc }}</p>
+          </div>
+        </div>
+        <div class="recog-col is-grow">
+          <div class="modal-box modal-origin">
+            <div class="modal-origin-body" :style="areaImgStyle">
+              <img
+                v-if="recogData.areaImg"
+                ref="areaImgRef"
+                :src="recogData.areaImg"
+                alt="截图"
+                @load="areaImgLoad"
+              />
+              <div
+                v-for="(option, index) in recogData.options.slice(1)"
+                :key="option"
+                class="select-option"
+                :style="getOptionStyle(index)"
+                @click="selectOption(option)"
+              ></div>
+            </div>
+          </div>
+        </div>
+        <div class="recog-col is-static is-col1">
+          <div class="modal-box is-btn" @click="close">
+            <p class="box-title">Esc键</p>
+            <p class="box-cont">关闭</p>
+          </div>
+        </div>
+      </div>
+      <div class="recog-row">
+        <div class="recog-col is-static is-col1">
+          <div class="modal-box">
+            <p class="box-title">识别结果</p>
+            <p class="box-cont">{{ recogResult }}</p>
+          </div>
+        </div>
+        <div class="recog-col is-grow">
+          <div class="modal-box modal-options">
+            <a-button
+              v-for="option in recogData.options"
+              :key="option"
+              :type="selectResult.includes(option) ? 'primary' : 'default'"
+              @click="selectOption(option)"
+              >{{ recogResultTransform(option) }}</a-button
+            >
+          </div>
+        </div>
+        <div class="recog-col is-static is-col1">
+          <div class="modal-box is-btn" @click="onConfirm">
+            <p class="box-title">Enter键</p>
+            <p class="box-cont">保存</p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from "vue";
+import { message } from "ant-design-vue";
+import useModal from "@/hooks/useModal";
+import { RecogBlock } from "@/utils/recog/recog";
+import { getBoxImageSize, recogResultTransform } from "@/utils/tool";
+import { useUserStore } from "@/store";
+
+defineOptions({
+  name: "RecogEditDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+const props = defineProps<{
+  recogData: RecogBlock;
+}>();
+
+const emit = defineEmits(["confirm", "close"]);
+const userStore = useUserStore();
+
+const selectResult = ref([] as string[]);
+
+const titles = {
+  question: "客观题",
+  absent: "缺考",
+  breach: "违纪",
+  paperType: "卷型号",
+};
+const recogTitle = computed(() => {
+  return titles[props.recogData.type];
+});
+
+const recogTitleDesc = computed(() => {
+  if (props.recogData.type === "question") {
+    return `#${props.recogData.index}`;
+  }
+  return "--";
+});
+
+const recogResult = computed(() => {
+  if (props.recogData.type === "question") {
+    return props.recogData.result.join("");
+  }
+  return "";
+});
+
+function getOptionStyle(index: number): Record<string, any> {
+  const optionSize = props.recogData.optionSizes[index];
+  const option = props.recogData.options[index + 1];
+  const borderColor = selectResult.value.includes(option)
+    ? userStore.recogFillSet.fillColor
+    : userStore.recogFillSet.unfillColor;
+  return {
+    width: `${optionSize.w}px`,
+    height: `${optionSize.h}px`,
+    left: `${optionSize.x}px`,
+    top: `${optionSize.y}px`,
+    borderColor,
+  };
+}
+
+const areaImgRef = ref();
+const areaImgStyle = ref({});
+function areaImgLoad() {
+  const areaImgDom = areaImgRef.value as HTMLImageElement;
+  const boxDom = areaImgDom.parentNode?.parentNode as HTMLDivElement;
+
+  const imgSize = getBoxImageSize({
+    box: {
+      width: boxDom.offsetWidth - 22,
+      height: boxDom.offsetHeight - 22,
+    },
+    img: {
+      width: areaImgDom.naturalWidth,
+      height: areaImgDom.naturalHeight,
+    },
+    rotate: 0,
+  });
+  const rate = imgSize.width / areaImgDom.naturalWidth;
+  areaImgStyle.value = {
+    width: `${areaImgDom.naturalWidth}px`,
+    height: `${areaImgDom.naturalHeight}px`,
+    transform: `scale(${rate}) translate(-50%, -50%)`,
+  };
+}
+
+function selectOption(option: string) {
+  if (!props.recogData) return;
+
+  // 单选直接赋值
+  if (!props.recogData.multiple) {
+    selectResult.value = [option];
+    return;
+  }
+
+  // 多选情况
+  // 空直接赋值,空值与其他互斥
+  if (option === "#") {
+    selectResult.value = ["#"];
+    return;
+  }
+
+  let result = selectResult.value.filter((item) => item !== "#");
+  if (result.includes(option)) {
+    result = result.filter((item) => item !== option);
+  } else {
+    result.push(option);
+  }
+  // 保证result的顺序和options的顺序是一致的
+  selectResult.value = props.recogData.options.filter((item) =>
+    result.includes(item)
+  );
+}
+
+// 键盘事件
+function registKeyEvent() {
+  document.addEventListener("keydown", keyEventHandle);
+}
+function removeKeyEvent() {
+  document.removeEventListener("keydown", keyEventHandle);
+}
+
+function keyEventHandle(e: KeyboardEvent) {
+  if (e.repeat) return;
+
+  if (e.keyCode == 13) {
+    e.preventDefault();
+    onConfirm();
+    return;
+  } else if (e.code === "Escape") {
+    e.preventDefault();
+    close();
+    return;
+  }
+}
+
+function onConfirm() {
+  if (!selectResult.value.length) {
+    message.error("请选择答案");
+    return;
+  }
+
+  emit("confirm", selectResult.value);
+  close();
+}
+
+function afterClose() {
+  emit("close");
+}
+// init
+watch(
+  () => visible.value,
+  (val) => {
+    if (val) {
+      modalOpenHandle();
+    } else {
+      removeKeyEvent();
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+
+watch(
+  () => props.recogData,
+  (val) => {
+    if (!val) return;
+    selectResult.value = [...props.recogData.result];
+  }
+);
+
+function modalOpenHandle() {
+  selectResult.value = [...props.recogData.result];
+  registKeyEvent();
+}
+</script>

+ 519 - 0
src/render/views/ExamNumberCheck/ScanImage/index.vue

@@ -0,0 +1,519 @@
+<template>
+  <div ref="elRef" class="scan-image">
+    <div
+      class="img-body"
+      :style="imageStyle"
+      v-ele-move-directive.prevent.stop="{
+        moveElement: onMoveImg,
+        emitOriginLeftTop: true,
+      }"
+    >
+      <img
+        ref="imgRef"
+        v-if="curPage"
+        :src="curPage.sheetUri"
+        alt="原图"
+        @load="initImageSize"
+      />
+      <div class="img-recogs">
+        <div
+          v-for="(item, index) in recogBlocks"
+          :key="index"
+          :class="[
+            'recog-block',
+            {
+              'is-active': curRecogBlock?.index === item.index,
+            },
+          ]"
+          :style="item.fillAreaStyle"
+          @click="onAreaClick(item)"
+        >
+          <div
+            v-for="(option, oindex) in item.fillOptionStyles"
+            :key="oindex"
+            :style="option"
+            class="recog-item"
+          ></div>
+        </div>
+      </div>
+    </div>
+    <div class="img-guide">
+      <div class="img-guide-icon is-left" @click="onPrev"><LeftOutlined /></div>
+      <div class="img-guide-icon is-right" @click="onNext">
+        <RightOutlined />
+      </div>
+    </div>
+    <div class="img-actions">
+      <ul>
+        <li @click="onZoomIn"><ZoomInOutlined /></li>
+        <li @click="onZoomOut"><ZoomOutOutlined /></li>
+        <li @click="onZoomNormal">1:1</li>
+        <li @click="onSetRecogStyle"><BgColorsOutlined /></li>
+      </ul>
+    </div>
+    <import-btn
+      v-if="!cantChangeImg"
+      upload-url="/api/admin/scan/answer/sheet/update"
+      :format="['jpg', 'png', 'jpeg']"
+      :upload-data="updateSheetData"
+      @upload-success="updateSheetSuccess"
+      :min-size="3 * 1024"
+    >
+      <a-tooltip placement="top">
+        <template #title>
+          <span>替换图片</span>
+        </template>
+        <a-button class="img-change">
+          <template #icon><PictureFilled /></template>
+        </a-button>
+      </a-tooltip>
+    </import-btn>
+  </div>
+
+  <!-- FillAreaSetDialog -->
+  <FillAreaSetDialog ref="fillAreaSetDialogRef" @modified="parseRecogBlocks" />
+  <!-- RecogEditDialog -->
+  <RecogEditDialog
+    v-if="curRecogBlock"
+    ref="recogEditDialogRef"
+    :recog-data="curRecogBlock"
+    @confirm="onRecogEditConfirm"
+    @close="clearCurBlock"
+  />
+</template>
+
+<script setup lang="ts">
+import {
+  ZoomInOutlined,
+  ZoomOutOutlined,
+  BgColorsOutlined,
+  LeftOutlined,
+  RightOutlined,
+  PictureFilled,
+} from "@ant-design/icons-vue";
+import { message } from "ant-design-vue";
+import {
+  saveTemporaryImgViewConfig,
+  getTemporaryImgViewConfig,
+} from "@/utils/index";
+import { computed, nextTick, ref, unref, watch } from "vue";
+import { useRoute } from "vue-router";
+import { objAssign, getSliceFileUrl, getBoxImageSize } from "@/utils/tool";
+import { vEleMoveDirective } from "@/directives/eleMove";
+import {
+  parseRecogData,
+  parseDetailSize,
+  RecognizeArea,
+  RecogBlock,
+} from "@/utils/recog/recog";
+import { useUserStore, useDataCheckStore } from "@/store";
+import { abc } from "@/constants/enumerate";
+import useUpload from "../useUpload";
+
+import FillAreaSetDialog from "./FillAreaSetDialog.vue";
+import RecogEditDialog from "./RecogEditDialog.vue";
+import ImportBtn from "@/components/ImportBtn/index.vue";
+import { debounce } from "lodash-es";
+
+defineOptions({
+  name: "ScanImage",
+});
+
+const props = withDefaults(defineProps<{ cantChangeImg?: boolean }>(), {
+  cantChangeImg: false,
+});
+
+const route = useRoute();
+const emit = defineEmits(["next", "prev"]);
+
+const userStore = useUserStore();
+const dataCheckStore = useDataCheckStore();
+
+const { save } = useUpload();
+
+const curPage = computed(() => dataCheckStore.curPage);
+const updateSheetData = computed(() => {
+  if (!curPage.value) return {};
+
+  return {
+    paperId: curPage.value.paperId,
+    pageIndex: curPage.value.pageIndex + 1,
+  };
+});
+
+const elRef = ref();
+const imgRef = ref();
+const imageSize = ref({
+  width: 0,
+  height: 0,
+  top: 0,
+  left: 0,
+  scale: 1,
+});
+const saveImageSizeToSession = debounce(() => {
+  saveTemporaryImgViewConfig(route.path, imageSize.value);
+}, 500);
+watch(
+  imageSize,
+  () => {
+    saveImageSizeToSession();
+  },
+  { deep: true }
+);
+
+const imageStyle = computed(() => {
+  return {
+    width: `${imageSize.value.width}px`,
+    height: `${imageSize.value.height}px`,
+    top: `${imageSize.value.top}px`,
+    left: `${imageSize.value.left}px`,
+    transform: `scale(${imageSize.value.scale})`,
+  };
+});
+
+function initImageSize() {
+  const imgDom = imgRef.value as HTMLImageElement;
+  const elDom = elRef.value as HTMLDivElement;
+
+  const imgSize = getBoxImageSize({
+    box: {
+      width: elDom.clientWidth,
+      height: elDom.clientHeight,
+    },
+    img: {
+      width: imgDom.naturalWidth,
+      height: imgDom.naturalHeight,
+    },
+    rotate: 0,
+  });
+
+  imageSize.value =
+    getTemporaryImgViewConfig(route.path) ||
+    objAssign(imageSize.value, imgSize);
+
+  nextTick(() => {
+    updateRecogList();
+  });
+}
+
+function getNumberResult(
+  result: Array<string | boolean>,
+  sources: Array<string | boolean>
+) {
+  const nResult: number[] = [];
+  result.forEach((item) => {
+    const index = sources.indexOf(item);
+    nResult[index] = 1;
+  });
+  return Array.from(nResult).map((item) => item || 0);
+}
+
+// recog data
+const recogList = ref<RecognizeArea[]>([]);
+function updateRecogList() {
+  recogList.value = [] as RecognizeArea[];
+
+  if (!dataCheckStore.curPage) return;
+  const regdata = dataCheckStore.curPage.recogData;
+  if (!regdata) return;
+
+  let index = 0;
+  const ABC = abc.split("");
+  regdata.question.forEach((gGroup) => {
+    gGroup.fill_result.forEach((qRecog) => {
+      const result = dataCheckStore.curPage?.question?.result[index] || "";
+      qRecog.index = ++index;
+
+      const questionResult = result ? result.split("") : [];
+
+      const recogItem = parseDetailSize(
+        qRecog,
+        "question",
+        qRecog.index,
+        getNumberResult(questionResult, ABC),
+        result === "#"
+      );
+      recogList.value.push(recogItem);
+    });
+  });
+
+  parseRecogBlocks();
+}
+// recogBlocks
+const recogBlocks = ref<RecogBlock[]>([]);
+const curRecogBlock = ref<RecogBlock | null>(null);
+function parseRecogBlocks() {
+  const imgDom = imgRef.value as HTMLImageElement;
+  const rate = imgDom.clientWidth / imgDom.naturalWidth;
+
+  const { unfillColor, unfillShow, fillColor, fillShow, borderWidth } =
+    userStore.recogFillSet;
+  const curBorderWidth = Math.max(1, borderWidth * rate);
+
+  recogBlocks.value = unref(recogList.value).map((item) => {
+    const fillAreaStyle = {
+      position: "absolute",
+      left: `${item.fillArea.x * rate}px`,
+      top: `${item.fillArea.y * rate}px`,
+      width: `${item.fillArea.w * rate}px`,
+      height: `${item.fillArea.h * rate}px`,
+      zIndex: 9,
+    };
+    const fillOptionStyles = item.optionSizes
+      .map((op) => {
+        const opStyle = {
+          position: "absolute",
+          left: `${op.x * rate}px`,
+          top: `${op.y * rate}px`,
+          width: `${op.w * rate}px`,
+          height: `${op.h * rate}px`,
+          zIndex: 9,
+          border: "",
+        };
+
+        if (op.filled && fillShow) {
+          opStyle.border = `${curBorderWidth}px solid ${fillColor}`;
+          return opStyle;
+        }
+
+        if (!op.filled && unfillShow) {
+          opStyle.border = `${curBorderWidth}px solid ${unfillColor}`;
+          return opStyle;
+        }
+
+        return opStyle;
+      })
+      .filter((item) => item);
+
+    const nitem: RecogBlock = {
+      ...item,
+      fillAreaStyle,
+      fillOptionStyles,
+      areaImg: "",
+    };
+
+    return nitem;
+  });
+}
+
+// area click
+const recogEditDialogRef = ref();
+async function onAreaClick(data: RecogBlock) {
+  if (!curPage.value) return;
+  curRecogBlock.value = data;
+  curRecogBlock.value.areaImg = await getSliceFileUrl(
+    curPage.value.sheetUri,
+    data.fillArea
+  );
+  nextTick(() => {
+    recogEditDialogRef.value?.open();
+  });
+}
+
+async function onRecogEditConfirm(result: string[]) {
+  if (!curRecogBlock.value || !dataCheckStore.curPage) return;
+
+  const data = curRecogBlock.value;
+
+  if (data.type === "question") {
+    const index = data.index - 1;
+    dataCheckStore.curPage.question.result.splice(index, 1, result.join(""));
+    curRecogBlock.value.result = result;
+    await save();
+  }
+}
+
+function clearCurBlock() {
+  curRecogBlock.value = null;
+}
+
+// img action
+function onZoomIn() {
+  const scale = imageSize.value.scale;
+  if (scale >= 2) return;
+
+  imageSize.value.scale = Math.min(2, scale * 1.2);
+}
+function onZoomOut() {
+  const scale = imageSize.value.scale;
+  if (scale <= 1) return;
+
+  imageSize.value.scale = Math.max(1, scale * 0.8);
+}
+function onZoomNormal() {
+  initImageSize();
+  imageSize.value.scale = 1;
+}
+
+interface PosSize {
+  left: number;
+  top: number;
+}
+function onMoveImg({ left, top }: PosSize) {
+  imageSize.value.left = left;
+  imageSize.value.top = top;
+}
+
+function onPrev() {
+  emit("prev");
+}
+function onNext() {
+  emit("next");
+}
+
+// change image
+function updateSheetSuccess(data: { uri: string }) {
+  if (!curPage.value) return;
+  dataCheckStore.modifySheetUri({
+    paperIndex: curPage.value.paperIndex,
+    pageIndex: curPage.value.pageIndex,
+    uri: data.uri,
+  });
+  message.success("上传成功!");
+}
+
+// set recog style
+const fillAreaSetDialogRef = ref();
+function onSetRecogStyle() {
+  fillAreaSetDialogRef.value?.open();
+}
+
+// 监听question.result,同步修改客观题的识别结果
+watch(
+  () => dataCheckStore.curPage?.question?.result,
+  (val) => {
+    if (!val) return;
+    updateRecogList();
+
+    if (!curRecogBlock.value) return;
+
+    const curBlock = curRecogBlock.value;
+    const block = recogBlocks.value.find(
+      (item) => item.type === curBlock.type && curBlock.index
+    );
+    if (!block) return;
+    curRecogBlock.value = block;
+  },
+  {
+    deep: true,
+  }
+);
+</script>
+
+<style lang="less" scoped>
+.scan-image {
+  overflow: hidden;
+  position: relative;
+  height: 100%;
+
+  .img-guide {
+    &-icon {
+      position: absolute;
+      top: 50%;
+      width: 28px;
+      height: 32px;
+      margin-top: -16px;
+      background: #ffffff;
+      border-radius: 6px;
+      border: 1px solid @border-color1;
+      line-height: 32px;
+      text-align: center;
+      z-index: 9;
+      cursor: pointer;
+
+      &:hover {
+        background-color: #e8f3ff;
+        border-color: @brand-color;
+        color: @brand-color;
+      }
+
+      &.is-left {
+        left: 12px;
+      }
+      &.is-right {
+        right: 12px;
+      }
+    }
+  }
+
+  .img-change {
+    position: absolute;
+    top: 12px;
+    right: 12px;
+    width: 32px;
+    height: 32px;
+    line-height: 32px;
+    background: #e8f3ff;
+    padding: 0;
+    border-radius: 6px;
+    border: 1px solid #bedaff;
+    color: #4080ff;
+    text-align: center;
+    z-index: 9;
+
+    &:hover {
+      opacity: 0.8;
+    }
+  }
+
+  .img-actions {
+    position: absolute;
+    bottom: 12px;
+    right: 12px;
+    background: rgba(89, 89, 89, 0.6);
+    border-radius: 8px;
+    padding: 4px 8px;
+    z-index: 9;
+
+    li {
+      display: inline-block;
+      vertical-align: middle;
+      width: 26px;
+      height: 26px;
+      border-radius: 6px;
+      line-height: 26px;
+      text-align: center;
+      color: #fff;
+      font-size: 16px;
+      cursor: pointer;
+      &:not(:last-child) {
+        margin-right: 4px;
+      }
+
+      &:hover {
+        background: rgba(89, 89, 89, 0.6);
+      }
+    }
+  }
+
+  .img-body {
+    position: absolute;
+    z-index: 2;
+  }
+
+  .recog-block {
+    cursor: pointer;
+    &:hover {
+      background-color: rgba(241, 214, 110, 0.3);
+    }
+    &.is-active {
+      background-color: rgba(241, 214, 110, 0.3);
+      &::after {
+        content: "";
+        display: block;
+        position: absolute;
+        z-index: 1;
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        border: 1px dashed #000;
+      }
+    }
+    &.readonly {
+      pointer-events: none;
+      cursor: default !important;
+    }
+  }
+}
+</style>

+ 168 - 0
src/render/views/ExamNumberCheck/api.ts

@@ -0,0 +1,168 @@
+import { DataCheckListResult } from "@/ap/types/dataCheck";
+import { useAppStore } from "@/store";
+import { randomCode } from "@/utils/tool";
+import axios from "axios";
+import Papa from "papaparse";
+
+const appStore = useAppStore();
+
+export const allCheckList = async (): Promise<DataCheckListResult> => {
+  const url = `${appStore.serverUrl}/check.csv?${randomCode()}`;
+  const students = await fetchAndParseData(url);
+  const data = (students || []).map((item: any) => {
+    const studentId = `${item.imageName}_${randomCode(8)}`;
+    const questions = (item.smda ? item.smda.split("|") : []).map((item) =>
+      item.trim().replace(/[\.\?]/g, "")
+    );
+    return {
+      ...item,
+      id: studentId,
+      examNumber: item.zkzh,
+      papers: [
+        {
+          id: `${studentId}_1`,
+          pages: [
+            {
+              index: 1,
+              sheetUri: `${appStore.serverUrl}/pic/${item.imageName}.jpg`,
+              absent: null,
+              breach: null,
+              paperType: null,
+              question: {
+                result: questions,
+                type: "FILL_AREA",
+              },
+              selective: null,
+              recogData: null,
+              recogUri: `${appStore.serverUrl}/omr/${item.imageName}.json`,
+            },
+          ],
+        },
+      ],
+    };
+  });
+  return Promise.resolve(data);
+};
+
+export const saveCheck = async (data: string[][], filename): Promise<any> => {
+  const url = `${appStore.serverUrl}/upload?path=/cache`;
+  return uploadCsvData(url, data, filename);
+};
+
+/**
+ * 请求给定远程地址,获取文件流数据(CSV 或 JSON),并解析其内容。
+ * @param remoteUrl 远程文件地址
+ * @returns 解析后的数据
+ */
+export const fetchAndParseData = async (remoteUrl: string): Promise<any> => {
+  try {
+    const response = await axios.get(remoteUrl, {
+      responseType: "text", // Fetch as text to handle both JSON and CSV
+    });
+
+    const contentType = response.headers["content-type"];
+    let data;
+
+    if (contentType && contentType.includes("application/json")) {
+      // Try parsing as JSON
+      try {
+        data = JSON.parse(response.data);
+      } catch (error) {
+        console.error("Error parsing JSON:", error);
+        throw new Error("Failed to parse JSON data");
+      }
+    } else if (contentType && contentType.includes("text/csv")) {
+      // Try parsing as CSV
+      return new Promise((resolve, reject) => {
+        Papa.parse(response.data, {
+          header: true, // Assume header row
+          skipEmptyLines: true,
+          complete: (results) => {
+            resolve(results.data);
+          },
+          error: (error: any) => {
+            console.error("Error parsing CSV:", error);
+            reject(new Error("Failed to parse CSV data"));
+          },
+        });
+      });
+    } else {
+      // Attempt to guess format or throw error
+      try {
+        // Try JSON first
+        data = JSON.parse(response.data);
+      } catch (jsonError) {
+        // If JSON fails, try CSV
+        return new Promise((resolve, reject) => {
+          Papa.parse(response.data, {
+            header: true,
+            skipEmptyLines: true,
+            complete: (results) => {
+              if (results.errors.length > 0) {
+                console.error("CSV parsing errors:", results.errors);
+                // If CSV parsing also has issues, reject
+                reject(
+                  new Error(
+                    "Failed to parse data: Unknown format or invalid content"
+                  )
+                );
+              } else {
+                resolve(results.data);
+              }
+            },
+            error: (csvError: any) => {
+              console.error("Error parsing CSV:", csvError);
+              reject(
+                new Error(
+                  "Failed to parse data: Could not determine format or invalid content"
+                )
+              );
+            },
+          });
+        });
+      }
+    }
+
+    return data;
+  } catch (error) {
+    console.error("Error fetching or parsing data:", error);
+    throw new Error("Failed to fetch or parse data from the remote URL");
+  }
+};
+
+/**
+ * 构建 CSV 文件内容并上传到指定 URL。
+ * @param uploadUrl 上传的目标 URL
+ * @param data 要转换为 CSV 并上传的数据数组
+ * @param filename 上传的文件名
+ * @returns 上传结果
+ */
+export const uploadCsvData = async (
+  uploadUrl: string,
+  data: any[],
+  filename: string = "upload.csv"
+): Promise<any> => {
+  try {
+    // Convert data array to CSV string
+    const csvString = Papa.unparse(data);
+
+    // Create a Blob from the CSV string
+    const blob = new Blob([csvString], { type: "text/csv;charset=utf-8;" });
+
+    // Create FormData and append the Blob as a file
+    const formData = new FormData();
+    formData.append("file", blob, filename); // 'file' is a common field name for uploads, adjust if needed
+
+    // Upload the FormData using axios
+    const response = await axios.post(uploadUrl, formData, {
+      headers: {
+        "Content-Type": "multipart/form-data",
+      },
+    });
+
+    return response.data; // Return the response from the server
+  } catch (error) {
+    console.error("Error building or uploading CSV data:", error);
+    throw new Error("Failed to build or upload CSV data");
+  }
+};

+ 269 - 0
src/render/views/ExamNumberCheck/index.vue

@@ -0,0 +1,269 @@
+<template>
+  <div class="data-check">
+    <div class="check-menu">
+      <div class="check-menu-body">
+        <ul>
+          <li
+            v-for="(item, index) in studentList"
+            :key="item.id"
+            :class="{ 'is-active': dataCheckStore.curStudent?.id === item.id }"
+            @click="onSelectStudent(index)"
+          >
+            {{ item.imageName }}
+          </li>
+        </ul>
+      </div>
+      <div class="check-menu-page">
+        <SimplePagination
+          v-model="pageNumber"
+          :total="total"
+          :page-size="pageSize"
+          @change="onChangeListPage"
+        />
+      </div>
+    </div>
+    <div class="check-body">
+      <ScanImage
+        v-if="dataCheckStore.curPage"
+        :key="dataCheckStore.curPage.kid"
+        :cant-change-img="true"
+        @prev="onPrevPage"
+        @next="onNextPage"
+      />
+    </div>
+
+    <CheckAction @search="onSearch" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed, onBeforeUnmount } from "vue";
+import { message } from "ant-design-vue";
+import { CaretLeftOutlined, CaretRightOutlined } from "@ant-design/icons-vue";
+
+import { DataCheckListFilter, DataCheckListItem } from "@/ap/types/dataCheck";
+import { allCheckList, fetchAndParseData } from "./api";
+import { StudentPage } from "./types";
+import { useDataCheckStore } from "@/store";
+
+import SimplePagination from "@/components/SimplePagination/index.vue";
+import ScanImage from "./ScanImage/index.vue";
+import CheckAction from "./CheckAction.vue";
+
+defineOptions({
+  name: "DataCheck",
+});
+
+const dataCheckStore = useDataCheckStore();
+dataCheckStore.resetInfo();
+
+let searchModel = {} as DataCheckListFilter;
+const pageNumber = ref(1);
+const pageSize = ref(20);
+const total = ref(0);
+const pageCount = ref(0);
+const allStudentList = ref<DataCheckListItem[]>([]);
+const studentList = ref<DataCheckListItem[]>([]);
+const dataList = ref<StudentPage[]>([]);
+const loading = ref(false);
+
+async function getAllStudents() {
+  loading.value = true;
+  const res = await allCheckList().catch(() => null);
+  loading.value = false;
+  if (!res) return;
+
+  allStudentList.value = res;
+  total.value = res.length;
+  pageCount.value = Math.ceil(total.value / pageSize.value);
+}
+
+async function getList() {
+  studentList.value = allStudentList.value.slice(
+    (pageNumber.value - 1) * pageSize.value,
+    pageNumber.value * pageSize.value
+  );
+
+  parseStudentPageList(studentList.value);
+}
+
+function parseStudentPageList(students: DataCheckListItem[]) {
+  dataList.value = [] as StudentPage[];
+
+  students.forEach((student, studentIndex) => {
+    student.papers.forEach((paper, paperIndex) => {
+      if (!paper.pages) return;
+      paper.pages.forEach((page, pageIndex) => {
+        const row: StudentPage = {
+          ...page,
+          paperId: paper.id,
+          paperIndex,
+          paperNumber: paper.number,
+          pageIndex,
+          studentIndex,
+          studentId: student.id,
+          examId: searchModel.examId,
+          kid: `${student.id}-${studentIndex}-${paperIndex}-${pageIndex}`,
+        };
+        if (
+          row.question?.result?.length &&
+          Array.isArray(row.question?.result)
+        ) {
+          row.question.result = row.question.result.map((item: string) =>
+            item.replace(/[^#a-zA-Z]/g, "")
+          );
+        }
+        dataList.value.push(row);
+      });
+    });
+  });
+}
+
+// table
+function onChangeListPage(index: number) {
+  pageNumber.value = index;
+  getList();
+  selectPage(0);
+}
+async function onSearch() {
+  pageNumber.value = 1;
+  await getAllStudents();
+  onChangeListPage(1);
+}
+
+// student
+function onSelectStudent(index: number) {
+  const student = studentList.value[index];
+  const pageIndex = dataList.value.findIndex(
+    (item) => item.studentId === student.id
+  );
+  if (pageIndex === -1) return;
+
+  selectPage(pageIndex);
+}
+
+async function onPrevStudent() {
+  if (dataCheckStore.curStudentIndex <= 0) {
+    if (pageNumber.value === 1) {
+      message.error("没有上一个学生了");
+      return;
+    }
+
+    pageNumber.value--;
+    await getList();
+    onSelectStudent(studentList.value.length - 1);
+    return;
+  }
+
+  onSelectStudent(dataCheckStore.curStudentIndex - 1);
+}
+
+async function onNextStudent() {
+  if (dataCheckStore.curStudentIndex >= studentList.value.length - 1) {
+    if (pageNumber.value >= pageCount.value) {
+      message.error("没有下一个学生了");
+      return;
+    }
+
+    pageNumber.value++;
+    await getList();
+    onSelectStudent(0);
+    return;
+  }
+
+  onSelectStudent(dataCheckStore.curStudentIndex + 1);
+}
+
+// page
+async function selectPage(index: number) {
+  const recogData = await fetchAndParseData(dataList.value[index].recogUri);
+  dataList.value[index].recogData = recogData;
+  dataCheckStore.setInfo({
+    curPage: dataList.value[index],
+    curPageIndex: index,
+  });
+
+  if (!dataCheckStore.curPage) return;
+
+  const curStudent = studentList.value[
+    dataCheckStore.curPage.studentIndex
+  ] as DataCheckListItem;
+  dataCheckStore.setInfo({
+    curStudent,
+    curStudentIndex: dataCheckStore.curPage.studentIndex,
+  });
+}
+
+async function onPrevPage() {
+  if (dataCheckStore.curPageIndex <= 0) {
+    if (pageNumber.value === 1) {
+      message.error("没有上一张了");
+      return;
+    }
+
+    pageNumber.value--;
+    await getList();
+    selectPage(dataList.value.length - 1);
+    return;
+  }
+
+  selectPage(dataCheckStore.curPageIndex - 1);
+}
+
+async function onNextPage() {
+  if (dataCheckStore.curPageIndex >= dataList.value.length - 1) {
+    if (pageNumber.value >= pageCount.value) {
+      message.error("没有下一张了");
+      return;
+    }
+
+    pageNumber.value++;
+    await getList();
+    selectPage(0);
+    return;
+  }
+
+  selectPage(dataCheckStore.curPageIndex + 1);
+}
+
+// shortcut
+function registShortcut() {
+  document.addEventListener("keydown", shortcutHandle);
+}
+function removeShortcut() {
+  document.removeEventListener("keydown", shortcutHandle);
+}
+function shortcutHandle(e: KeyboardEvent) {
+  const moveAction = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
+  if (!moveAction.includes(e.code) || e.repeat) {
+    return;
+  }
+
+  e.preventDefault();
+
+  if (e.code === "ArrowUp") {
+    onPrevStudent();
+    return;
+  }
+  if (e.code === "ArrowDown") {
+    onNextStudent();
+    return;
+  }
+  if (e.code === "ArrowLeft") {
+    onPrevPage();
+    return;
+  }
+  if (e.code === "ArrowRight") {
+    onNextPage();
+    return;
+  }
+}
+
+onMounted(() => {
+  registShortcut();
+  onSearch();
+});
+onBeforeUnmount(() => {
+  removeShortcut();
+});
+</script>

+ 22 - 0
src/render/views/ExamNumberCheck/types.ts

@@ -0,0 +1,22 @@
+import { PaperPageItem } from "@/ap/types/dataCheck";
+
+export interface StudentPage extends PaperPageItem {
+  examId: number;
+  studentId: number;
+  studentIndex: number;
+  paperId: number;
+  paperIndex: number;
+  paperNumber: number;
+  pageIndex: number;
+  kid: string;
+}
+
+export interface QuestionInfo {
+  examNumber: string;
+  name: string;
+  examSite: string;
+  seatNumber: number;
+  paperType: string;
+  examStatus: string;
+  packageCode?: string;
+}

+ 30 - 0
src/render/views/ExamNumberCheck/useUpload.ts

@@ -0,0 +1,30 @@
+import { useDataCheckStore } from "@/store";
+import { saveCheck } from "./api";
+
+export default function useUpload() {
+  const dataCheckStore = useDataCheckStore();
+
+  const save = async () => {
+    if (!dataCheckStore.curStudent) return;
+
+    const smda =
+      dataCheckStore.curStudent?.papers[0]?.pages[0]?.question?.result
+        .map((item) => item || ".")
+        .join("|");
+
+    const data = [
+      ["imageName", "zkzh", "smda"],
+      [
+        dataCheckStore.curStudent.imageName,
+        dataCheckStore.curStudent?.examNumber,
+        smda,
+      ],
+    ];
+    const filename = `${dataCheckStore.curStudent.imageName}.csv`;
+    await saveCheck(data, filename).catch((err) => {
+      console.log(err);
+    });
+  };
+
+  return { save };
+}

+ 1 - 0
src/render/views/Index.vue

@@ -0,0 +1 @@
+<template></template>

+ 30 - 27
static/load/index.html

@@ -1,30 +1,33 @@
 <!DOCTYPE html>
 <html>
-  <head>
-    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
 
-    <title>loading</title>
-    <link rel="stylesheet" type="text/css" href="style.css" />
-    <style>
-      a {
-        background: #13a3a5;
-        padding: 5px;
-        margin: 10px;
-        display: block;
-        font-weight: 100;
-        cursor: pointer;
-        font-size: 1.5em;
-        float: left;
-        text-decoration: none;
-        font-size: 18px;
-        color: white;
-      }
-    </style>
-  </head>
-  <body>
-    <div class="loader">
-      <div class="cap"></div>
-      <div class="line">集中扫描v1.0.0</div>
-    </div>
-  </body>
-</html>
+<head>
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+
+  <title>loading</title>
+  <link rel="stylesheet" type="text/css" href="style.css" />
+  <style>
+    a {
+      background: #13a3a5;
+      padding: 5px;
+      margin: 10px;
+      display: block;
+      font-weight: 100;
+      cursor: pointer;
+      font-size: 1.5em;
+      float: left;
+      text-decoration: none;
+      font-size: 18px;
+      color: white;
+    }
+  </style>
+</head>
+
+<body>
+  <div class="loader">
+    <div class="cap"></div>
+    <div class="line">准考证管理v1.0.0</div>
+  </div>
+</body>
+
+</html>