Explorar el Código

feat: 复核校验页面

zhangjie hace 9 meses
padre
commit
266d54aee3

+ 75 - 0
src/render/ap/review.ts

@@ -0,0 +1,75 @@
+import { AxiosResponse } from "axios";
+import { request } from "@/utils/request";
+
+import {
+  ExamParams,
+  ExamSubjectParams,
+  RequestActionResult,
+} from "./types/common";
+import {
+  ReviewTaskListItem,
+  ReviewProgressResult,
+  ReviewTaskSaveParams,
+  ReviewTaskHistoryResult,
+} from "./types/review";
+
+// 违纪导入
+export const reviewTaskList = (
+  data: ExamSubjectParams
+): Promise<ReviewTaskListItem[]> =>
+  request({
+    url: "/api/admin/subject/breach/page",
+    method: "post",
+    data,
+  });
+
+// 复核校验进度状态
+export const reviewProgress = (
+  data: ExamSubjectParams
+): Promise<ReviewProgressResult> =>
+  request({
+    url: "/api/admin/check/omr/arbitrate/get",
+    method: "post",
+    data,
+  });
+
+// 复核校验任务历史
+export const reviewTaskHistory = (
+  data: ExamParams
+): Promise<ReviewTaskHistoryResult> =>
+  request({
+    url: "/api/admin/check/omr/arbitrate/history",
+    method: "post",
+    data,
+  });
+
+// 复核校验结果提交
+export const reviewTaskSave = (
+  data: ReviewTaskSaveParams
+): Promise<RequestActionResult> =>
+  request({
+    url: "/api/admin/check/omr/arbitrate/save",
+    method: "post",
+    data,
+  });
+
+// 复核校验重置
+export const reviewTaskReset = (
+  data: ExamSubjectParams
+): Promise<RequestActionResult> =>
+  request({
+    url: "/api/admin/check/omr/arbitrate/save",
+    method: "post",
+    data,
+  });
+
+// 复核校验异常导出
+export const reviewWarningTaskExport = (
+  data: ExamSubjectParams
+): Promise<AxiosResponse<Blob>> =>
+  request({
+    url: "/api/admin/subject/image-check/failed/export",
+    method: "post",
+    data,
+    responseType: "blob",
+  });

+ 23 - 0
src/render/ap/types/review.ts

@@ -0,0 +1,23 @@
+import { PageResult } from "./common";
+
+// 复核校验
+export interface ReviewTaskListItem {
+  id: number;
+  subjectCode: string;
+  subjectName: string;
+  examNumber: string;
+  markStatus: boolean;
+  breachCount: number;
+}
+
+export interface ReviewProgressResult {
+  finishCount: number;
+  todoCount: number;
+}
+
+export interface ReviewTaskSaveParams {
+  id: number;
+  result: boolean;
+}
+
+export type ReviewTaskHistoryResult = PageResult<ReviewTaskListItem>;

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


+ 43 - 0
src/render/components/SimplePagination/index.vue

@@ -0,0 +1,43 @@
+<template>
+  <a-pagination
+    v-model:current="current"
+    class="simple-pagination"
+    :total="total"
+    :default-page-size="pageSize"
+    show-quick-jumper
+    :show-size-changer="false"
+    @change="onChange"
+  >
+    <template #itemRender="{ type, originalElement }">
+      <template v-if="type === 'prev'">
+        <CaretLeftFilled />
+      </template>
+      <template v-else-if="type === 'next'">
+        <CaretRightFilled />
+      </template>
+      <template v-else-if="type === 'page'"></template>
+      <component :is="originalElement" v-else></component>
+    </template>
+  </a-pagination>
+</template>
+
+<script setup lang="ts">
+import { CaretRightFilled, CaretLeftFilled } from "@ant-design/icons-vue";
+import { ref } from "vue";
+
+defineOptions({
+  name: "SimplePagination",
+});
+
+const props = defineProps<{
+  total: number;
+  pageSize: number;
+}>();
+const emit = defineEmits(["change"]);
+
+const current = ref(1);
+
+function onChange(page: number, pageSize: number) {
+  emit("change", page, pageSize);
+}
+</script>

+ 8 - 0
src/render/router/routes.ts

@@ -38,6 +38,14 @@ const routes: RouteRecordRaw[] = [
           title: "扫描管理",
         },
       },
+      {
+        path: "review",
+        name: "Review",
+        component: () => import("@/views/Review/index.vue"),
+        meta: {
+          title: "复核校验",
+        },
+      },
       {
         path: "recognize-check",
         name: "RecognizeCheck",

+ 2 - 1
src/render/store/index.ts

@@ -1,10 +1,11 @@
 import { createPinia } from "pinia";
 import { useAppStore } from "./modules/app";
 import { useUserStore } from "./modules/user";
+import { useReviewStore } from "./modules/review";
 import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
 
 const pinia = createPinia();
 pinia.use(piniaPluginPersistedstate);
 
-export { useAppStore, useUserStore };
+export { useAppStore, useUserStore, useReviewStore };
 export default pinia;

+ 32 - 0
src/render/store/modules/review/index.ts

@@ -0,0 +1,32 @@
+import { defineStore } from "pinia";
+import { ReviewTaskListItem } from "@/ap/types/review";
+
+interface ReviewState {
+  tabKey: "review" | "history";
+  curTask: ReviewTaskListItem | null;
+}
+
+export const useReviewStore = defineStore("review", {
+  state: (): ReviewState => ({
+    tabkey: "",
+    curTask: null,
+  }),
+
+  getters: {
+    reviewInfo(state: ReviewState): ReviewState {
+      return { ...state };
+    },
+  },
+
+  actions: {
+    setInfo(partial: Partial<ReviewState>) {
+      this.$patch(partial);
+    },
+    resetInfo() {
+      this.$reset();
+    },
+  },
+  persist: {
+    storage: sessionStorage,
+  },
+});

+ 39 - 36
src/render/store/modules/user/index.ts

@@ -1,40 +1,43 @@
 import { defineStore } from "pinia";
 import router from "@/router";
 
-export const useUserStore = defineStore<"user", any, any, any>("user", {
-  persist: [
-    {
-      storage: sessionStorage,
-      paths: ["userInfo"],
+export const useUserStore = defineStore<"user", { curExam: Exam }, any, any>(
+  "user",
+  {
+    persist: [
+      {
+        storage: sessionStorage,
+        paths: ["userInfo"],
+      },
+      {
+        storage: localStorage,
+        paths: ["curExam"],
+      },
+    ],
+    state: () => ({
+      userInfo: null,
+      curExam: null,
+    }),
+    actions: {
+      setUserInfo(info: any) {
+        this.userInfo = info;
+      },
+      setCurExam(exam: Exam) {
+        this.curExam = exam;
+      },
+      setState(data: any) {
+        this.$patch(data);
+      },
+      resetUserInfo() {
+        this.$reset();
+      },
+      async logout() {
+        //todo 退出登录接口
+        // await logout();
+        this.setUserInfo(null);
+        router.push({ name: "Login" });
+        window.electronApi?.changeWinSize("small");
+      },
     },
-    {
-      storage: localStorage,
-      paths: ["curExam"],
-    },
-  ],
-  state: () => ({
-    userInfo: null,
-    curExam: null,
-  }),
-  actions: {
-    setUserInfo(info: any) {
-      this.userInfo = info;
-    },
-    setCurExam(exam: Exam) {
-      this.curExam = exam;
-    },
-    setState(data: any) {
-      this.$patch(data);
-    },
-    resetUserInfo() {
-      this.$reset();
-    },
-    async logout() {
-      //todo 退出登录接口
-      // await logout();
-      this.setUserInfo(null);
-      router.push({ name: "Login" });
-      window.electronApi?.changeWinSize("small");
-    },
-  },
-});
+  }
+);

+ 1 - 0
src/render/views/CurExam/index.vue

@@ -103,6 +103,7 @@
               <div
                 class="flex items-center cursor-pointer"
                 :style="{ color: token.colorPrimary }"
+                @click="toPage('Review')"
               >
                 <span>进入</span>
                 <RightOutlined />

+ 243 - 0
src/render/views/Review/ReviewAction.vue

@@ -0,0 +1,243 @@
+<template>
+  <div class="review-tabs">
+    <div
+      :class="['review-tab', { 'is-active': tabKey === 'review' }]"
+      @click="switchTab('review')"
+    >
+      复核校验
+    </div>
+    <div
+      :class="['review-tab', { 'is-active': tabKey === 'history' }]"
+      @click="switchTab('history')"
+    >
+      历史记录
+    </div>
+  </div>
+
+  <div v-show="tabKey === 'review'" class="review-tab-body">
+    <a-collapse v-model:activeKey="reviewKey">
+      <a-collapse-panel key="1">
+        <template #header><FilterFilled />搜索条件 </template>
+        <a-space>
+          <span>科目:</span>
+          <a-select
+            v-model:value="searchCourseCode"
+            placeholder="请选择科目"
+            :options="courses"
+            filter-option
+            style="width: 200px"
+          ></a-select>
+          <a-button type="primary" @click="onSearch">搜索</a-button>
+        </a-space>
+      </a-collapse-panel>
+      <a-collapse-panel key="2">
+        <template #header><WarningFilled />导出异常 </template>
+
+        <a-space>
+          <span>科目:</span>
+          <a-select
+            v-model:value="exportCourseCode"
+            placeholder="请选择科目"
+            :options="courses"
+            filter-option
+            style="width: 200px"
+          ></a-select>
+          <a-button :loading="downloading" @click="onExport">导出</a-button>
+        </a-space>
+      </a-collapse-panel>
+      <a-collapse-panel key="3">
+        <template #header><PushpinFilled />复核标记 </template>
+
+        <a-radio-group v-model:value="result" @change="onMark">
+          <a-radio :value="1">正常</a-radio>
+          <a-radio :value="0">异常</a-radio>
+        </a-radio-group>
+      </a-collapse-panel>
+      <a-collapse-panel key="4">
+        <template #header><RightSquareFilled />重置 </template>
+
+        <a-space>
+          <span>科目:</span>
+          <a-select
+            v-model:value="resetCourseCode"
+            placeholder="请选择科目"
+            :options="courses"
+            filter-option
+            style="width: 200px"
+          ></a-select>
+          <qm-button type="danger" @click="onReset">重置</qm-button>
+        </a-space>
+      </a-collapse-panel>
+    </a-collapse>
+  </div>
+  <div v-show="tabKey === 'history'" class="review-tab-body">
+    <a-collapse collapsible="disabled">
+      <a-collapse-panel>
+        <template #header><PushpinFilled />复核标记 </template>
+
+        <a-radio-group v-model:value="historyResult" @change="onHistoryMark">
+          <a-radio :value="1">正常</a-radio>
+          <a-radio :value="0">异常</a-radio>
+        </a-radio-group>
+      </a-collapse-panel>
+    </a-collapse>
+    <div class="history-list">
+      <table>
+        <colgroup>
+          <col />
+          <col width="60" />
+        </colgroup>
+        <tr>
+          <th>准考证号</th>
+          <th>状态</th>
+        </tr>
+        <tr
+          v-for="(item, index) in dataList"
+          :key="item.id"
+          @click="setCurTask(index)"
+        >
+          <td>{{ item.examNumber }}</td>
+          <td :class="item.markStatus ? 'color-success' : 'color-error'">
+            {{ item.markStatus ? "正常" : "异常" }}
+          </td>
+        </tr>
+      </table>
+    </div>
+    <div class="history-footer">
+      <SimplePagination
+        :total="pagination.total"
+        :page-size="pagination.pageSize"
+        @change="toPage"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from "vue";
+import { subjectList } from "@/ap/base";
+import {
+  FilterFilled,
+  WarningFilled,
+  RightSquareFilled,
+  PushpinFilled,
+} from "@ant-design/icons-vue";
+import { showConfirm } from "@/utils/uiUtils";
+
+import {
+  reviewWarningTaskExport,
+  reviewTaskHistory,
+  reviewTaskSave,
+} from "@/ap/review";
+import { ReviewTaskListItem } from "@/ap/types/review";
+
+import { downloadByApi } from "@/utils/download";
+import useTable from "@/hooks/useTable";
+import useLoading from "@/hooks/useLoading";
+import { useUserStore, useReviewStore } from "@/store";
+
+import SimplePagination from "@/components/SimplePagination/index.vue";
+
+defineOptions({
+  name: "ReviewAction",
+});
+const emit = defineEmits(["search", "reset", "mark"]);
+
+const userStore = useUserStore();
+const reviewStore = useReviewStore();
+
+// tab
+const reviewKey = ref("1");
+
+async function switchTab(key: "review" | "history") {
+  reviewStore.setInfo({ tabKey: key });
+
+  if (key === "history") {
+    await toPage(1);
+    setCurTask(0);
+  }
+}
+
+// course data
+interface optionListItem {
+  value: string;
+  label: string;
+}
+const courses = ref<optionListItem[]>([]);
+async function getCourses() {
+  const res = await subjectList({ examId: userStore.curExam.id });
+  courses.value = (res || []).map((item) => {
+    return {
+      value: item.subjectCode,
+      label: item.subjectName,
+    };
+  });
+}
+
+const searchCourseCode = ref("");
+const exportCourseCode = ref("");
+const resetCourseCode = ref("");
+const result = ref(1);
+const historyResult = ref(1);
+
+// history
+const curHistoryTaskIndex = res(0);
+const { dataList, pagination, loading, getList, toPage, setPageSize } =
+  useTable<ReviewTaskListItem>(
+    reviewTaskHistory,
+    { examId: userStore.curExam.id },
+    false
+  );
+setPageSize(30);
+
+function setCurTask(index: number) {
+  curHistoryTaskIndex.value = index;
+  reviewStore.setInfo({ curTask: dataList.value[index] });
+}
+
+// actions
+function onSearch() {
+  emit("search", searchCourseCode.value);
+}
+
+function onReset() {
+  emit("reset", resetCourseCode.value);
+}
+
+function onMark() {
+  emit("mark", result.value);
+}
+
+async function onHistoryMark() {
+  if (!reviewStore.curTask) return;
+
+  const res = await reviewTaskSave({
+    id: reviewStore.curTask.id,
+    result: historyResult.value,
+  });
+  reviewStore.setInfo({
+    curTask: Object.assign({}, reviewStore.curTask, {
+      markStatus: historyResult.value,
+    }),
+  });
+}
+
+const { loading: downloading, setLoading } = useLoading();
+async function onExport() {
+  if (downloading.value) return;
+
+  setLoading(true);
+  const res = await downloadByApi(() =>
+    reviewWarningTaskExport({
+      examId: userStore.curExam.id,
+      subjectCode: exportCourseCode.value,
+    })
+  ).catch((e: Error) => {
+    message.error(e.message || "下载失败,请重新尝试!");
+  });
+  setLoading(false);
+
+  if (!res) return;
+  message.success("导出成功!");
+}
+</script>

+ 176 - 0
src/render/views/Review/index.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="review">
+    <div class="review-head">
+      <a-row align="center">
+        <a-col :span="9">
+          <h2 class="review-title">419872222786601 - 考生2 - CET4</h2>
+        </a-col>
+        <a-col :span="6">
+          <div class="review-stat">
+            <p class="color-success">
+              <CheckCircleFilled />已处理:{{ progress.finishCount }}
+            </p>
+            <p class="color-error">
+              <CloseCircleFilled />未处理:{{ progress.todoCount }}
+            </p>
+          </div>
+        </a-col>
+        <a-col :span="9">
+          <a-button v-if="reviewStore.tabKey === 'review'" :disabled="loading">
+            <template #icon><ArrowLeftOutlined /></template> 上一个
+          </a-button>
+        </a-col>
+      </a-row>
+    </div>
+
+    <!-- body -->
+    <div class="review-body"></div>
+
+    <!-- action -->
+    <ReviewAction @mark="onMark" @reset="onReset" @search="onSearch" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import {
+  computed,
+  ref,
+  reactive,
+  onMounted,
+  onBeforeUnmount,
+  watch,
+} from "vue";
+import {
+  CheckCircleFilled,
+  CloseCircleFilled,
+  ArrowLeftOutlined,
+} from "@ant-design/icons-vue";
+import { message } from "ant-design-vue";
+
+import { showConfirm } from "@/utils/uiUtils";
+import { reviewTaskList, reviewTaskReset, reviewTaskSave } from "@/ap/review";
+import { ReviewTaskListItem } from "@/ap/types/review";
+
+import ReviewAction from "./ReviewAction.vue";
+import { useUserStore, useReviewStore } from "@/store";
+import useLoading from "@/hooks/useLoading";
+const userStore = useUserStore();
+const reviewStore = useReviewStore();
+
+defineOptions({
+  name: "review",
+});
+
+const searchModel = reactive({
+  examId: userStore.curExam.id,
+  subjectCode: "",
+});
+
+const { loading, setLoading } = useLoading();
+const dataList = ref<ReviewTaskListItem[]>([]);
+const curTaskIndex = ref(0);
+
+async function getTasks() {
+  // TODO:假设一次取3个
+  const res = await reviewTaskList(searchModel);
+  dataList.value = res || [];
+}
+
+function setCurTask() {
+  reviewStore.setInfo({ curTask: dataList.value[curTaskIndex.value] });
+}
+
+function getNextTask() {
+  if (curTaskIndex.value >= dataList.value.length - 1) {
+    await getTasks();
+    if (!dataList.value.length) {
+      message.error("没有下一个了!");
+      reviewStore.setInfo({ curTask: null });
+      return;
+    }
+    curTaskIndex.value = 0;
+    setCurTask();
+    return;
+  }
+
+  curTaskIndex.value++;
+  setCurTask();
+}
+
+function getPrevTask() {
+  if (loading.value) return;
+  loading.value = true;
+
+  if (curTaskIndex.value <= 0) {
+    let result = true;
+    await getTasks().catch(() => {
+      result = false;
+    });
+    if (!result) return;
+
+    loading.value = false;
+    if (!dataList.value.length) {
+      message.error("没有上一个了!");
+      reviewStore.setInfo({ curTask: null });
+      return;
+    }
+    curTaskIndex.value = dataList.value.length - 1;
+    setCurTask();
+    return;
+  }
+
+  curTaskIndex.value--;
+  setCurTask();
+}
+
+// actions
+async function onMark(result: boolean) {
+  if (!reviewStore.curTask) return;
+
+  if (loading.value) return;
+  loading.value = true;
+
+  try {
+    await reviewTaskSave({ id: reviewStore.curTask.id, result });
+    await getNextTask();
+  } catch (error) {
+    loading.value = false;
+  }
+}
+
+async function onSearch(subjectCode: string) {
+  searchModel.subjectCode = subjectCode;
+  await getTasks();
+
+  curTaskIndex.value = 0;
+  setCurTask();
+}
+
+async function onReset(subjectCode: string) {
+  const confirm = await showConfirm({
+    content: "确定要重置任务吗?",
+  }).catch(() => false);
+  if (confirm !== "confirm") return;
+
+  const res = await reviewTaskReset({
+    examId: userStore.curExam.id,
+    subjectCode,
+  }).catch(() => false);
+  if (!res) return;
+
+  message.success("操作成功");
+  await onSearch("");
+}
+
+// watch
+watch(
+  () => reviewStore.tabKey,
+  (val) => {
+    if (val === "review") {
+      reviewStore.setInfo({
+        curTask: dataList.value[curTaskIndex.value] || null,
+      });
+    }
+  }
+);
+</script>