Kaynağa Gözat

feat: 实时审核

zhangjie 9 ay önce
ebeveyn
işleme
ef9f04853f

+ 41 - 0
src/render/ap/audit.ts

@@ -0,0 +1,41 @@
+import { request } from "@/utils/request";
+import { ExamParams, RequestActionResult } from "./types/common";
+import { ExamOverviewResult, IntimeAuditBatchResult } from "./types/audit";
+
+export const examOverview = (data: ExamParams): Promise<ExamOverviewResult> =>
+  request({
+    url: "/api/auditor/exam/overview",
+    method: "post",
+    data,
+  });
+
+// 实时审核任务
+export const intimeAuditBatch = (
+  data: ExamParams
+): Promise<IntimeAuditBatchResult> =>
+  request({
+    url: "/api/auditor/batch/verify/get",
+    method: "post",
+    data,
+  });
+
+// 实时审核任务提交
+export const intimeAuditBatchSubmit = (data: {
+  batchId: number;
+  confirm: boolean;
+}): Promise<RequestActionResult> =>
+  request({
+    url: "/api/auditor/batch/verify/submit",
+    method: "post",
+    data,
+  });
+
+// 批次实时审核任务释放
+export const intimeAuditBatchRelease = (
+  data: ExamParams
+): Promise<{ success: boolean }> =>
+  request({
+    url: "/api/auditor/batch/verify/release",
+    method: "post",
+    data,
+  });

+ 45 - 0
src/render/ap/types/audit.ts

@@ -0,0 +1,45 @@
+export interface ExamOverviewResult {
+  //实时审核任务
+  verifyTask: {
+    todoCount: number;
+  };
+  //图片审核
+  imageCheckTask: {
+    //抽查比例
+    checkRatio: number;
+    finishCount: number;
+    //全部未处理数量
+    todoCount: number;
+  };
+  //人工绑定审核
+  assignedCheck: {
+    todoCount: number;
+  };
+}
+
+export interface IntimeAuditBatchStudentPaper {
+  number: number;
+  // 是否本张为人工绑定
+  assigned: boolean;
+  // 数组为空表示缺纸
+  pages: string[];
+}
+
+export interface IntimeAuditBatchStudent {
+  examNumber: string;
+  name: string;
+  studentCode: string;
+  subjectCode: string;
+  subjectName: string;
+  seatNumber: string;
+  papers: IntimeAuditBatchStudentPaper[];
+}
+export interface IntimeAuditBatchResult {
+  // 批次ID
+  batchId: number;
+  device: string;
+  createTime: number;
+  // 实时审核批次此字段有值
+  packageCode: string;
+  students: IntimeAuditBatchStudent[];
+}

BIN
src/render/assets/imgs/bg-wait.png


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

@@ -113,8 +113,8 @@ const routes: RouteRecordRaw[] = [
       // 实时审核
       {
         path: "in-time-audit",
-        name: "InTimeAudit",
-        component: () => import("@/views/Audit/InTime/index.vue"),
+        name: "IntimeAudit",
+        component: () => import("@/views/Audit/Intime/index.vue"),
         meta: {
           title: "实时审核",
         },

+ 10 - 0
src/render/styles/antui-reset.less

@@ -41,3 +41,13 @@
     padding: 0 20px 20px;
   }
 }
+
+// .ant-btn
+.ant-btn-success {
+  color: #fff;
+  background-color: @success-color;
+  box-shadow: 0 2px 0 rgba(5, 115, 255, 0.1);
+  &:hover {
+    opacity: 0.8;
+  }
+}

+ 3 - 0
src/render/styles/base.less

@@ -22,6 +22,9 @@ body {
 .color-error {
   color: @error-color;
 }
+.color-warning {
+  color: @warning-color;
+}
 .color-gray {
   color: @text-color3;
 }

+ 148 - 30
src/render/styles/pages.less

@@ -481,36 +481,6 @@
           overflow-y: auto;
           border-top: 1px solid @border-color1;
           border-bottom: 1px solid @border-color1;
-          padding: 0 8px;
-
-          .table {
-            width: 100%;
-            border-spacing: 0;
-            border-collapse: collapse;
-            text-align: left;
-            color: @text-color1;
-            line-height: 22px;
-
-            th {
-              color: @text-color2;
-              padding: 8px 8px 8px 12px;
-              font-weight: 400;
-            }
-            td {
-              padding: 0 8px 0 12px;
-              line-height: 24px;
-            }
-            tbody {
-              tr {
-                border-radius: 6px;
-                cursor: pointer;
-                &:hover,
-                &.is-active {
-                  background: #e8f3ff;
-                }
-              }
-            }
-          }
         }
       }
 
@@ -636,3 +606,151 @@
     padding-right: 12px;
   }
 }
+
+.task-list {
+  color: @text-color1;
+  line-height: 22px;
+  padding: 0 8px;
+
+  ul {
+    display: flex;
+    justify-content: space-between;
+    align-items: stretch;
+  }
+
+  li {
+    font-size: 14px;
+
+    &.li-grow {
+      flex-grow: 2;
+    }
+  }
+
+  .list-head {
+    li {
+      color: @text-color2;
+      padding: 8px 4px 5px 12px;
+      font-weight: 400;
+    }
+  }
+  .list-body {
+    ul {
+      margin-bottom: 4px;
+      border-radius: 6px;
+      cursor: pointer;
+
+      &:hover,
+      &.is-active {
+        background: #e8f3ff;
+      }
+    }
+
+    li {
+      padding: 0 4px 0 12px;
+      line-height: 24px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+}
+
+// intime
+.intime {
+  height: 100%;
+  position: relative;
+
+  &.is-wait {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    color: @text-color3;
+
+    img {
+      display: block;
+      height: 56px;
+      width: 56px;
+      margin: 0 auto 12px;
+    }
+  }
+
+  &-head {
+    position: absolute;
+    width: 100%;
+    height: 55px;
+    top: 0;
+    left: 0;
+    z-index: 9;
+    border-bottom: 1px solid @border-color1;
+    padding: 15px 0;
+    line-height: 24px;
+    text-align: center;
+    color: @text-color2;
+
+    .ant-col:not(:last-child) {
+      border-right: 1px solid @border-color1;
+    }
+    .head-label {
+      color: @text-color3;
+    }
+  }
+
+  &-side {
+    position: absolute;
+    width: 357px;
+    top: 55px;
+    bottom: 0;
+    left: 0;
+    z-index: 9;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+
+    &-body {
+      flex-grow: 2;
+      overflow: auto;
+    }
+
+    &-foot {
+      flex-grow: 0;
+      flex-shrink: 0;
+      padding: 12px 16px;
+      border-top: 1px solid @border-color1;
+      background-color: #fff;
+
+      .ant-btn {
+        width: 146px;
+
+        &.ant-btn-dangerous {
+          background: #ffece8 !important;
+          color: @error-color!important;
+
+          &:hover {
+            background: @error-color !important;
+            color: #fff !important;
+          }
+        }
+      }
+    }
+  }
+
+  &-body {
+    position: absolute;
+    top: 55px;
+    left: 357px;
+    bottom: 0;
+    right: 0;
+    background-color: @background-color;
+    padding: 10px;
+    overflow-y: auto;
+    overflow-x: hidden;
+
+    .paper-img {
+      display: block;
+      height: auto;
+      max-height: none;
+      margin-bottom: 10px;
+    }
+  }
+}

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

@@ -92,7 +92,7 @@ export function blobToText(blob: Blob): Promise<string | ArrayBuffer | null> {
 /* 日期格式化 */
 export const dateFormat = (
   date: any,
-  fmt = "yyyy/MM/dd HH:mm:ss",
+  fmt = "yyyy-MM-dd HH:mm:ss",
   isDefault = "-"
 ) => {
   if (!date) {

+ 224 - 2
src/render/views/Audit/InTime/index.vue

@@ -1,9 +1,231 @@
 <template>
-  <div>InTimeAudit</div>
+  <div v-if="hasTask" class="intime">
+    <div class="intime-head">
+      <a-row>
+        <a-col :span="6">
+          <span class="head-label">扫描员:</span>
+          <span class="head-cont">{{ batchInfo.device }}</span>
+        </a-col>
+        <a-col :span="6">
+          <span class="head-label">批次:</span>
+          <span class="head-cont">{{ batchInfo.batchId }}</span>
+        </a-col>
+        <a-col :span="6">
+          <span class="head-label">时间:</span>
+          <span class="head-cont">{{ dateFormat(batchInfo.createTime) }}</span>
+        </a-col>
+        <a-col :span="6">
+          <span class="head-label">卷袋编号:</span>
+          <span class="head-cont">{{ batchInfo.packageCode }}</span>
+        </a-col>
+      </a-row>
+    </div>
+    <div class="intime-side">
+      <div class="intime-side-body">
+        <div class="task-list">
+          <ul class="list-head">
+            <li style="width: 150px">准考证号</li>
+            <li style="width: 80px">姓名</li>
+            <li style="width: 60px">座位号</li>
+            <li style="width: 50px">状态</li>
+          </ul>
+          <div class="list-body">
+            <ul
+              v-for="(item, index) in dataList"
+              :key="item.examNumber"
+              :class="[
+                'list-row',
+                { 'is-active': curStudent?.examNumber === item.examNumber },
+              ]"
+              @click="setCurStudent(index)"
+            >
+              <li style="width: 150px">{{ item.examNumber }}</li>
+              <li style="width: 80px">{{ item.name }}</li>
+              <li style="width: 60px">{{ item.seatNumber }}</li>
+              <li style="width: 50px">
+                <CheckCircleFilled v-if="item.status" class="color-success" />
+              </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+      <div class="intime-side-foot">
+        <a-space :size="7">
+          <template #split>
+            <a-divider
+              type="vertical"
+              style="height: 22px; background-color: #d9d9d9"
+            />
+          </template>
+          <a-button type="success" @click="onConfirm(true)">
+            <template #icon><CheckCircleOutlined /></template>
+            通过
+          </a-button>
+          <a-button danger plain type="primary" @click="onConfirm(false)">
+            <template #icon><CloseCircleOutlined /></template>
+            拒绝
+          </a-button>
+        </a-space>
+      </div>
+    </div>
+    <div class="intime-body">
+      <template v-if="curStudent">
+        <div v-for="paper in curStudent.papers" :key="paper.number">
+          <img
+            v-for="(page, pindex) in paper.pages"
+            :key="pindex"
+            class="paper-img"
+            src="../../RecognizeCheck/data/202302040117-1.jpg"
+          />
+          <!-- <img
+            v-for="(page, pindex) in paper.pages"
+            :key="pindex"
+            :src="page"
+          /> -->
+        </div>
+        <ReviewMarkPan
+          :task-info="{
+            examNumber: curStudent.examNumber,
+            name: curStudent.name,
+          }"
+          @mark="onMark"
+        />
+      </template>
+    </div>
+  </div>
+  <div v-else class="intime is-wait">
+    <div>
+      <img src="@/assets/imgs/bg-wait.png" alt="等待" />
+      <p>等待审核结果…</p>
+    </div>
+  </div>
 </template>
 
 <script setup lang="ts">
+import { ref, onMounted } from "vue";
+import {
+  CheckCircleOutlined,
+  CloseCircleOutlined,
+  CheckCircleFilled,
+} from "@ant-design/icons-vue";
+import { message } from "ant-design-vue";
+import { omit } from "lodash-es";
+
+import {
+  IntimeAuditBatchResult,
+  IntimeAuditBatchStudent,
+} from "@/ap/types/audit";
+import { intimeAuditBatch, intimeAuditBatchSubmit } from "@/ap/audit";
+import { useUserStore } from "@/store";
+import { dateFormat } from "@/utils/tool";
+import useLoop from "@/hooks/useLoop";
+
+import ReviewMarkPan from "../../Review/ReviewMarkPan.vue";
+
 defineOptions({
-  name: "InTimeAudit",
+  name: "IntimeAudit",
+});
+
+const userStore = useUserStore();
+
+interface StudentItem extends IntimeAuditBatchStudent {
+  status: boolean;
+}
+type BatchData = Omit<IntimeAuditBatchResult, "students">;
+
+const dataList = ref<StudentItem[]>([]);
+const curStudent = ref<StudentItem | null>(null);
+const curStudentIndex = ref(0);
+const batchInfo = ref({} as BatchData);
+const hasTask = ref(false);
+
+const { start: startLoopGetData, stop: stopLoopGetData } = useLoop(
+  getData,
+  2000
+);
+async function getData() {
+  const res = await intimeAuditBatch({ examId: userStore.curExam.id });
+  if (!res) {
+    hasTask.value = false;
+    return;
+  }
+  stopLoopGetData();
+  hasTask.value = true;
+  batchInfo.value = omit(res, "students");
+  dataList.value = res.students || [];
+  curStudentIndex.value = 0;
+  setCurStudent();
+}
+
+function onMark() {
+  curStudent.value.status = true;
+  getNextStudent();
+}
+
+// task
+function setCurStudent(index: number | undefined) {
+  if (index !== undefined) curStudentIndex.value = index;
+  curStudent.value = dataList.value[curStudentIndex.value];
+}
+function getNextStudent() {
+  if (curStudentIndex.value === dataList.value.length - 1) return;
+
+  curStudentIndex.value++;
+  setCurStudent();
+}
+function getPrevStudent() {
+  if (curStudentIndex.value === 0) return;
+
+  curStudentIndex.value--;
+  setCurStudent();
+}
+
+// confirm
+async function onConfirm(confirm: boolean) {
+  const res = await intimeAuditBatchSubmit({
+    batchId: batchInfo.value.batchId,
+    confirm,
+  });
+
+  startLoopGetData();
+}
+
+onMounted(() => {
+  // startLoopGetData();
+
+  // TODO: 测试数据
+  hasTask.value = true;
+  batchInfo.value = {
+    batchId: 123,
+    device: "192.168.0.1",
+    createTime: Date.now(),
+    // 实时审核批次此字段有值
+    packageCode: "ET01245124",
+  };
+  dataList.value = "#"
+    .repeat(30)
+    .split("")
+    .map((item, index) => {
+      return {
+        examNumber: `3600802404012${index}`,
+        name: `考生名${index}`,
+        studentCode: `36008${index}`,
+        subjectCode: "科目代码",
+        subjectName: "科目名称",
+        seatNumber: "11",
+        status: Math.random() > 0.5,
+        papers: [
+          {
+            number: 1,
+            // 是否本张为人工绑定
+            assigned: true,
+            // 数组为空表示缺纸
+            pages: ["xxx.jpg", "111.png"],
+          },
+        ],
+      };
+    });
+  curStudentIndex.value = 0;
+  setCurStudent();
 });
 </script>

+ 1 - 1
src/render/views/Audit/Main/index.vue

@@ -28,7 +28,7 @@
               <p>待审核</p>
               <p>1</p>
             </div>
-            <div class="audit-card-action" @click="toPage('InTimeAudit')">
+            <div class="audit-card-action" @click="toPage('IntimeAudit')">
               进入 <RightOutlined />
             </div>
           </div>

+ 23 - 22
src/render/views/Review/ReviewAction.vue

@@ -95,31 +95,32 @@
         </a-collapse-panel>
       </a-collapse>
       <div class="history-list">
-        <table class="table">
-          <colgroup>
-            <col />
-            <col width="80" />
-          </colgroup>
-          <thead>
-            <tr>
-              <th>准考证号</th>
-              <th>状态</th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr
+        <div class="task-list">
+          <ul class="list-head">
+            <li class="li-grow">准考证号</li>
+            <li style="width: 80px">状态</li>
+          </ul>
+          <div class="list-body">
+            <ul
               v-for="(item, index) in dataList"
-              :key="item.id"
-              :class="{ 'is-active': reviewStore.curTask?.id === item.id }"
+              :key="item.examNumber"
+              :class="[
+                'list-row',
+                { 'is-active': reviewStore.curTask?.id === item.id },
+              ]"
               @click="setCurTask(index)"
             >
-              <td>{{ item.examNumber }}</td>
-              <td :class="item.markStatus ? 'color-success' : 'color-error'">
-                {{ item.markStatus ? "正常" : "异常" }}
-              </td>
-            </tr>
-          </tbody>
-        </table>
+              <li class="li-grow">{{ item.examNumber }}</li>
+              <li style="width: 80px">
+                <span
+                  :class="item.markStatus ? 'color-success' : 'color-error'"
+                >
+                  {{ item.markStatus ? "正常" : "异常" }}
+                </span>
+              </li>
+            </ul>
+          </div>
+        </div>
       </div>
       <div class="history-footer">
         <SimplePagination

+ 26 - 7
src/render/views/Review/ReviewMarkPan.vue

@@ -9,8 +9,8 @@
       }"
     ></div>
     <div class="pan-body">
-      <p>{{ reviewStore.curTask?.examNumber }}</p>
-      <p>{{ reviewStore.curTask?.name }}</p>
+      <p>{{ taskInfo.examNumber }}</p>
+      <p>{{ taskInfo.name }}</p>
 
       <a-button block @click="onMark">正常 (Enter)</a-button>
     </div>
@@ -19,7 +19,6 @@
 
 <script setup lang="ts">
 import { ref, computed, reactive, onMounted, onBeforeMount } from "vue";
-import { useReviewStore } from "@/store";
 import { vEleMoveDirective } from "@/directives/eleMove";
 import { local } from "@/utils/tool";
 
@@ -27,9 +26,11 @@ defineOptions({
   name: "ReviewMarkPan",
 });
 
-const emit = defineEmits(["mark"]);
+const props = defineProps<{
+  taskInfo: { examNumber: string; name: string };
+}>();
 
-const reviewStore = useReviewStore();
+const emit = defineEmits(["mark"]);
 
 function onMark() {
   emit("mark", true);
@@ -115,13 +116,31 @@ function initAreaSize() {
   areaSize.top = Math.min(areaTop, limitHeight - panHeight);
 }
 
+// 键盘事件
+function registKeyEvent() {
+  window.addEventListener("resize", initAreaSize);
+  document.addEventListener("keydown", keyEventHandle);
+}
+function removeKeyEvent() {
+  window.removeEventListener("resize", initAreaSize);
+  document.removeEventListener("keydown", keyEventHandle);
+}
+
+function keyEventHandle(e: KeyboardEvent) {
+  if (e.code === "Enter") {
+    e.preventDefault();
+    onMark();
+    return;
+  }
+}
+
 onMounted(() => {
   initAreaSize();
 
-  window.addEventListener("resize", initAreaSize);
+  registKeyEvent();
 });
 
 onBeforeMount(() => {
-  window.removeEventListener("resize", initAreaSize);
+  removeKeyEvent();
 });
 </script>

+ 9 - 30
src/render/views/Review/index.vue

@@ -27,7 +27,14 @@
       <ReviewImage />
 
       <!-- ReviewMarkPan -->
-      <ReviewMarkPan v-if="reviewStore.curTask" @mark="onMark" />
+      <ReviewMarkPan
+        v-if="reviewStore.curTask"
+        :task-info="{
+          examNumber: reviewStore.curTask.examNumber,
+          name: reviewStore.curTask.name,
+        }"
+        @mark="onMark"
+      />
     </div>
 
     <!-- action -->
@@ -43,14 +50,7 @@
 </template>
 
 <script setup lang="ts">
-import {
-  computed,
-  ref,
-  reactive,
-  onMounted,
-  onBeforeUnmount,
-  watch,
-} from "vue";
+import { computed, ref, reactive, onMounted, watch } from "vue";
 import {
   CheckCircleFilled,
   CloseCircleFilled,
@@ -209,24 +209,7 @@ watch(
   }
 );
 
-// 键盘事件
-function registKeyEvent() {
-  document.addEventListener("keydown", keyEventHandle);
-}
-function removeKeyEvent() {
-  document.removeEventListener("keydown", keyEventHandle);
-}
-
-function keyEventHandle(e: KeyboardEvent) {
-  if (e.code === "Enter") {
-    e.preventDefault();
-    onMark(true);
-    return;
-  }
-}
-
 onMounted(() => {
-  registKeyEvent();
   // test
   dataList.value = [
     {
@@ -255,8 +238,4 @@ onMounted(() => {
     tabKey: "review",
   });
 });
-
-onBeforeUnmount(() => {
-  removeKeyEvent();
-});
 </script>