Kaynağa Gözat

增加重新识别功能

dengshaobo 1 ay önce
ebeveyn
işleme
42404659dd

+ 20 - 0
src/api/exam.ts

@@ -410,3 +410,23 @@ export const updateExamStudentInfo= (data:any):Promise<ApiResponse> => {
     data
   })
 }
+
+
+// 45. 扫描学生 新增学生 添加学生   删除学生答题卡信息
+export const deleteAnswerCard= (data:any):Promise<ApiResponse> => {
+  return request({
+    url: '/api/v1/ai_exam_scan/delete_batch_pic',
+    method: 'post',
+    data
+  })
+}
+
+
+// 46. 扫描学生 重新识别 识别客观题和全部重新识别
+export const recognizeAgain= (data:any):Promise<ApiResponse> => {
+  return request({
+    url: '/api/v1/ai_exam_scan/ai_hand_recognize_again',
+    method: 'post',
+    data
+  })
+}

+ 114 - 0
src/styles/common.scss

@@ -1688,6 +1688,120 @@ body {
       }
     }
 
+
+    //重新识别公共样式
+    .re_identify {
+
+      width: 100%;
+      height: 400px;
+
+      .identify_message {
+        font-size: 16px;
+        line-height: 30px;
+      }
+
+      .identify_center {
+        width: 80%;
+        margin: auto;
+        margin-top: 40px;
+        display: flex;
+        justify-content: space-around;
+        align-items: center;
+
+        .center_number_1 {
+          width: 200px;
+          height: 160px;
+          border-radius: 10px 10px 10px 10px;
+          border: 1px solid #DEDEDE;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+
+          .center_title {
+            font-weight: 400;
+            font-size: 20px;
+            color: #333333;
+          }
+
+          .span_number {
+            font-family: PingFang SC, PingFang SC;
+            font-weight: 500;
+            font-size: 40px;
+            color: #000;
+            line-height: 60px;
+          }
+        }
+
+        .center_number_2 {
+          width: 200px;
+          height: 160px;
+          border-radius: 10px 10px 10px 10px;
+          border: 1px solid #DEDEDE;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+        }
+
+
+      }
+
+      .identify_progress {
+        width: 100%;
+        height: 122px;
+
+
+        display: flex;
+        justify-content: flex-start;
+        align-items: center;
+        text-align: center;
+
+        .el-progress {
+          width: calc(100% - 120px);
+          margin-left: 10px;
+
+          display: flex;
+          justify-content: flex-start;
+
+          .el-progress-bar__inner {
+            background: linear-gradient(90deg, #2E64FA 0%, #46B0FC 100%);
+            border-radius: 10px 0px 0px 10px;
+          }
+
+          .el-progress-bar__outer {
+            background-color: #F3F3F3 !important;
+            border: 1px solid #2796FF;
+
+          }
+
+          .el-progress-bar {
+            width: 100%;
+          }
+
+          .el-progress__text {
+            width: 60px;
+            text-align: center;
+
+          }
+        }
+      }
+
+      .identify_button {
+        width: 100%;
+        text-align: center;
+
+        .el-button {
+          width: 200px;
+          height: 40px;
+          background: #2E64FA;
+          border-radius: 4px 4px 4px 4px;
+          color: #FFFFFF;
+          font-size: 16px;
+        }
+
+      }
+    }
+
+
     //弹窗 check选择框公共样式
     .select_check_content {
       width: calc(100% - 40px);

+ 47 - 1
src/views/exam/abnormal/tableList.vue

@@ -324,7 +324,7 @@ import { useExamStore } from '@/store/exam'
 import { useUserStore } from '@/store/user'
 import { useRouter } from 'vue-router'
 import { onMounted ,ref,nextTick,onUnmounted,computed, watch} from 'vue';
-import { getExamStudentList,updateScannedStatus,updateExamNumberException,correctObjectiveQuestion,getTemplateDetail } from '@/api/exam';
+import {deleteAnswerCard, getExamStudentList,updateScannedStatus,updateExamNumberException,correctObjectiveQuestion,getTemplateDetail } from '@/api/exam';
 import { ElMessageBox, ElMessage } from 'element-plus'
 import PaperPreview from '../components/PaperPreview.vue'//图片预览组件
 import ActionImage from '@/components/ActionImage.vue'// 画布图片组件
@@ -1066,6 +1066,52 @@ const OptionView=(item:any)=>{
     }
 }
 
+/**
+ * 已上传删除 - 实际为修改状态为未扫描
+ * @param item - 要删除的学生对象
+ * @description 调用删除接口移除学生的答题卡数据,并刷新列表
+ */
+const UploadDelete = async (item: any) => {
+    try {
+        // 显示确认对话框
+        await ElMessageBox.confirm('是否要删除此条数据?', '提示', {
+            confirmButtonText: '确定',
+            cancelButtonText: '取消',
+            closeOnClickModal: false,
+            type: 'warning',
+            customClass: 'page_dialog'
+        });
+        
+        // 构建请求参数
+        const param = {
+            ids: [item.id],
+            examSubjectId: examSubjectId.value,
+            schoolId: schoolId.value
+            // scannedStatus: 2, // 扫描状态 0-未扫描 2-缺考(修改时传,删除不传)
+        };
+        
+        console.log("删除参数:", param);
+        
+        // 调用删除接口
+        const res = await deleteAnswerCard(param);
+        
+        if (res.code === 200) {
+            ElMessage.success('删除成功');
+            
+            // 刷新列表数据
+            GetExamScanList();
+        } else {
+            ElMessage.error('删除失败!' + (res.msg || '未知错误'));
+        }
+    } catch (error) {
+        // 用户点击取消或发生错误
+        if (error !== 'cancel') {
+            console.error("删除操作错误:", error);
+            ElMessage.error('操作失败');
+        }
+    }
+}
+
 
 //单个标缺
 const MarkToMiss=(item:any)=>{ 

+ 449 - 0
src/views/exam/components/ReIdentify.vue

@@ -0,0 +1,449 @@
+<template>
+    <div class="page_dialog">
+        <el-dialog 
+            title="重新识别" 
+            class="page_dialog" 
+            :model-value="showDialog"  
+            width="728px" 
+            @close="handleClose"     
+            append-to-body
+        >
+            <div class="dialog_center padding_20">
+                <div class="re_identify">
+                    <div class="identify_message">
+                        已扫描{{ reIdentifyTotal }}张,点击"开始识别"进行重新识别。
+                        <span style="color:#FF0000;">识别中过程不能关闭弹窗或离开页面。</span>
+                    </div>
+                    <div class="identify_center">
+                        <div class="center_number_1">
+                            <div class="center_title">
+                                需识别张数<br>
+                                <span class="span_number">{{ reIdentifyNumber }}</span>
+                            </div>
+                        </div>
+                        <div class="center_number_1">
+                            <div class="center_title">
+                                已识别张数<br>
+                                <span class="span_number">{{ recognizedNumber }}</span>
+                            </div>
+                        </div>
+                    </div>
+                    <div class="identify_progress">
+                        <span>识别进度:</span>
+                        <el-progress 
+                            :percentage="reIdentifyProgress" 
+                            :stroke-width="16" 
+                            :color="customColor" 
+                            :show-text="true"
+                        ></el-progress>
+                    </div>
+                    <div class="identify_button">
+                        <el-button 
+                            type="primary" 
+                            @click="StartReIdentify" 
+                            :loading="reIdentifyLoading"
+                        >
+                            {{ reIdentifyText }}
+                        </el-button>
+                    </div>
+                </div>
+            </div>
+        </el-dialog>
+    </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
+// 1. 引入 Pinia store
+import { useUserStore } from '@/store/user'
+import { useExamStore } from '@/store/exam'
+
+import WebSocketManager from '@/utils/webSocket.ts';
+import { recognizeAgain } from '@/api/exam';
+import { ElMessage } from 'element-plus';
+
+// ==================== Props 定义 ====================
+interface SchoolInfo {
+    schoolId: string;      // 学校ID
+    schoolName: string;    // 学校名称
+}
+
+const props = defineProps({
+    /**
+     * 控制弹窗显示/隐藏(v-model)
+     */
+    modelValue: {
+        type: Boolean,
+        required: true,
+    },
+    /**
+     * 需要重新识别的总张数
+     */
+    reIdentifyTotal: {
+        type: [String, Number],
+        default: '0'
+    },
+    /**
+     * 是否只识别客观题
+     * 0 - 否(全部识别)
+     * 1 - 是(仅客观题)
+     */
+    justObjective: {
+        type: [String, Number],
+        default: 1
+    },
+    /**
+     * 学校信息对象
+     */
+    schoolInfo: {
+        type: Object as () => SchoolInfo,
+        default: () => ({
+            schoolId: '',
+            schoolName: ''
+        })
+    }
+});
+
+// ==================== Emits 定义 ====================
+const emit = defineEmits([
+    'update:modelValue',   // v-model 更新事件
+    'ReIdentifyComplete'   // 重新识别完成事件
+]);
+
+// ==================== Store 实例化 ====================
+const userStore = useUserStore()
+const examStore = useExamStore()
+// ==================== 响应式数据 ====================
+
+// --- 上传相关(当前组件未使用,已注释)---
+// const loading = ref(false);                          // 上传加载状态
+// const selectedFiles = ref<any[]>([]);                // 选择的文件列表
+// const previewImageList = ref<any[]>([]);             // 预览弹窗的图片列表
+// const batchNumber = ref('');                         // 批次编号
+// const batchNOID = ref('');                           // 批次号ID
+// const sensitive = ref('165');                        // 灵敏度参数(0-255之间的奇数)
+// const onnx = ref(0);                                 // 算法类型:0-正常算法,1-废资源算法
+// const isUploading = ref(false);                      // 是否正在上传(防止重复点击)
+// const debounceTimer = ref<any>(null);                // 防抖定时器
+// const scanLoading = ref(false);                      // 扫描按钮loading状态
+
+// --- 学校信息 ---
+const schoolId = ref('');        // 当前学校ID
+const schoolName = ref('');      // 当前学校名称(带冒号后缀)
+
+// --- 重新识别相关 ---
+const reIdentifyUserId = ref('');           // 重新识别的用户ID(WebSocket连接标识)
+const isReIdentify = ref(false);            // 是否处于重新识别状态
+const reIdentifyNumber = ref(0);            // 需要识别的张数(剩余)
+const recognizedNumber = ref(0);            // 已识别的张数
+const reIdentifyProgress = ref(0);          // 重新识别进度百分比(0-100)
+const reIdentifyText = ref('开始识别');     // 重新识别按钮文案
+const reIdentifyLoading = ref(false);       // 重新识别按钮loading状态
+
+// --- WebSocket 相关 ---
+const websocketUrl = ref(import.meta.env.VUE_APP_WEB_SOCKET_URL || '');  // WebSocket链接地址
+const socket = ref<any>(null);                                       // WebSocket实例对象
+
+// --- 进度条配置 ---
+/**
+ * 自定义进度条颜色配置
+ * 不同进度阶段显示不同颜色(当前统一为蓝色 #2E64FA)
+ */
+const customColor = [
+    { color: '#2E64FA', percentage: 20 },
+    { color: '#2E64FA', percentage: 40 },
+    { color: '#2E64FA', percentage: 60 },
+    { color: '#2E64FA', percentage: 80 },
+    { color: '#2E64FA', percentage: 100 }
+];
+
+// ==================== 计算属性 ====================
+
+/**
+ * 弹窗显示状态(双向绑定)
+ */
+const showDialog = computed({
+    get: () => props.modelValue,
+    set: (val) => emit('update:modelValue', val)
+});
+
+/**
+ * 当前考试科目ID
+ * 从 Vuex Store 中获取
+ */
+const examSubjectId = computed(() => {
+  return examStore.currentExam?.id
+})//计算属性
+
+/**
+ * 剩余未上传完成的文件数量
+ * 当前组件未使用,已注释
+ */
+// const remainCount = computed(() => {
+//     return selectedFiles.value.filter(file => file.uploadStatus !== 0).length;
+// });
+
+/**
+ * 账号学校类型
+ * 0 - 单校版
+ * 1 - 联校版
+ * 当前组件未使用,已注释
+ */
+// const schoolType = computed(() => {
+//     return store.state.user?.schoolType;
+// });
+
+/**
+ * 考试学校类型
+ * 0 - 单校版
+ * 1 - 联校版
+ * 当前组件未使用,已注释
+ */
+// const examSchoolType = computed(() => {
+//     return store.state.makeTemplate?.examDetail?.schoolType;
+// });
+
+// ==================== 监听器 ====================
+
+/**
+ * 监听 reIdentifyTotal 变化
+ * 当父组件传入的总张数变化时,同步更新本地变量
+ */
+watch(
+    () => props.reIdentifyTotal,
+    (newVal) => {
+        reIdentifyNumber.value = Number(newVal);
+    }
+);
+
+/**
+ * 监听 schoolInfo 变化
+ * 当学校信息变化时,更新本地的 schoolId 和 schoolName
+ */
+watch(
+    () => props.schoolInfo,
+    (newVal) => {
+        console.log("监听到学校信息的改变了", newVal);
+        if (newVal) {
+            schoolId.value = newVal.schoolId || '';
+            schoolName.value = newVal.schoolName ? newVal.schoolName + ':' : '';
+        }
+    },
+    { deep: true, immediate: true }
+);
+
+// ==================== 生命周期 ====================
+
+/**
+ * 组件挂载时执行
+ * 初始化重新识别张数
+ */
+onMounted(() => {
+    console.log("打印学校id", props.schoolInfo);
+    reIdentifyNumber.value = Number(props.reIdentifyTotal);
+});
+
+/**
+ * 组件卸载前执行
+ * 清理资源:关闭WebSocket连接、清除定时器
+ */
+onBeforeUnmount(() => {
+    // 清理 WebSocket 连接
+    if (socket.value && socket.value.getSocketStatus() === WebSocket.OPEN) {
+        socket.value.closeWebSocket();
+    }
+    // 清理定时器(当前未使用,保留以防后续扩展)
+    // if (debounceTimer.value) {
+    //     clearTimeout(debounceTimer.value);
+    // }
+});
+
+// ==================== 方法定义 ====================
+
+/**
+ * 建立重新识别的 WebSocket 连接
+ * @description 用于接收后端实时推送的识别进度消息
+ */
+const ReIdentifyWebSocketLink = () => {
+    // 如果已有连接且处于打开状态,先关闭
+    if (socket.value && socket.value.getSocketStatus() === WebSocket.OPEN) {
+        socket.value.closeWebSocket();
+    }
+
+    // 构建 WebSocket 用户标识:AGAIN_ + 科目ID
+    let userCourseId = '';
+    console.log("打印用户信息", store.state.user.userInfo);
+    userCourseId = "AGAIN_" + examSubjectId.value;
+    reIdentifyUserId.value = userCourseId;
+    console.log("打印重新识别的userCourseId", userCourseId);
+
+    // 根据环境设置 WebSocket URL
+    if (process.env.NODE_ENV === 'development') {
+        // 开发环境使用固定地址
+        websocketUrl.value = 'wss://dev3.k12100.net/teaching/api/v1/teaching/websocket/';
+    } else if (process.env.NODE_ENV === 'production') {
+        // 生产环境使用环境变量
+        websocketUrl.value = process.env.VUE_APP_WEB_SOCKET_URL || '';
+    }
+
+    // 拼接完整的 WebSocket URL
+    const url = websocketUrl.value + userCourseId;
+    console.log("打印websocket链接地址url", url);
+    
+    // 创建并启动 WebSocket 连接
+    socket.value = new WebSocketManager(url, MessageBack);
+    socket.value.start();
+};
+
+/**
+ * 开始重新识别
+ * @description 调用后端接口触发重新识别,并建立 WebSocket 连接接收进度
+ */
+const StartReIdentify = () => {
+    // 如果已经识别完成,点击按钮则关闭弹窗并重置状态
+    if (reIdentifyText.value === '识别完成') {
+        showDialog.value = false;
+        reIdentifyLoading.value = false;
+        reIdentifyProgress.value = 0;
+        reIdentifyText.value = '开始识别';
+        return;
+    }
+
+    // 重置进度状态
+    reIdentifyProgress.value = 0;
+    reIdentifyLoading.value = true;
+    reIdentifyText.value = '识别中';
+    
+    // 建立 WebSocket 连接
+    ReIdentifyWebSocketLink();
+
+    // 构建请求参数
+    const param = {
+        examSubjectId: examSubjectId.value,        // 考试科目ID
+        recognizeNumber: props.reIdentifyTotal,    // 需要识别的总张数
+        justObjective: props.justObjective,        // 是否只识别客观题
+        schoolId: props.schoolInfo.schoolId        // 学校ID
+    };
+
+    // 调用重新识别接口
+    recognizeAgain(param).then(res => {
+        console.log("重新识别结果", res);
+        if (res.code !== 200) {
+            ElMessage.error("重新识别失败!" + res.msg);
+        }
+    }).catch(err => {
+        console.error("重新识别请求失败", err);
+        ElMessage.error("网络请求失败");
+    });
+};
+
+/**
+ * WebSocket 消息回调处理
+ * @param data - WebSocket 返回的消息字符串(JSON格式)
+ * @description 接收后端推送的识别进度,更新UI显示
+ */
+const MessageBack = (data: string) => {
+    const msg = JSON.parse(data);
+    console.log("Websocket消息返回处理", msg);
+
+    // code: 0-建立成功, 200-业务成功, 400-业务失败/异常
+    if (msg.code === 200) {
+        // 已识别张数 +1
+        recognizedNumber.value++;
+        // 待识别张数 -1
+        reIdentifyNumber.value--;
+        
+        // 计算进度百分比
+        const process = Math.round((Number(recognizedNumber.value) / Number(props.reIdentifyTotal)) * 100);
+        reIdentifyProgress.value = Math.min(process, 100); // 确保不超过100%
+
+        // 如果进度达到100%,表示识别完成
+        if (reIdentifyProgress.value === 100) {
+            ElMessage.success("重新识别完成!");
+            
+            // 重置所有状态
+            reIdentifyNumber.value = 0;
+            recognizedNumber.value = 0;
+            reIdentifyProgress.value = 0;
+            reIdentifyText.value = '识别完成';
+            reIdentifyLoading.value = false;
+            
+            // 关闭弹窗
+            showDialog.value = false;
+            
+            // 通知父组件识别完成
+            emit('ReIdentifyComplete');
+        }
+    }
+};
+
+/**
+ * 格式化文件名
+ * @param fileName - 原始文件名
+ * @returns 格式化后的文件名(超过17位时显示前5位+...+后12位)
+ * @description 当前组件未使用,保留以备后续扩展
+ */
+// const FormatFileName = (fileName: string): string => {
+//     if (fileName.length <= 17) {
+//         return fileName;
+//     }
+//     const prefix = fileName.substring(0, 5);
+//     const suffix = fileName.substring(fileName.length - 12);
+//     return prefix + '...' + suffix;
+// };
+
+/**
+ * 检查文件是否为图片
+ * @param fileName - 文件名
+ * @returns 是否为图片文件
+ * @description 当前组件未使用,保留以备后续扩展
+ */
+// const isImageFile = (fileName: string): boolean => {
+//     const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
+//     const lowerFileName = fileName.toLowerCase();
+//     return imageExtensions.some(ext => lowerFileName.endsWith(ext));
+// };
+
+/**
+ * 获取文件预览URL
+ * @param file - 文件对象
+ * @returns 预览URL
+ * @description 当前组件未使用,保留以备后续扩展
+ */
+// const getFilePreviewUrl = (file: File): string => {
+//     if (window.URL && file) {
+//         return window.URL.createObjectURL(file);
+//     }
+//     return '';
+// };
+
+/**
+ * 关闭弹窗
+ * @description 重置状态并通知父组件
+ */
+const handleClose = () => {
+    showDialog.value = false;
+    // selectedFiles.value = [];  // 当前未使用
+    emit('close');
+};
+
+/**
+ * 取消上传
+ * @description 当前组件未使用,保留以备后续扩展
+ */
+// const CancelUpload = () => {
+//     selectedFiles.value = [];
+//     emit('CancelUpload', batchNumber.value);
+//     showDialog.value = false;
+// };
+
+// ==================== 暴露方法 ====================
+defineExpose({
+    handleClose
+    // CancelUpload  // 当前未使用
+});
+</script>
+
+<style lang="scss" scoped>
+// 样式保持不变
+</style>

+ 9 - 1
src/views/exam/scanList.vue

@@ -160,6 +160,7 @@
             </div>
         </div>
     </div>
+    <ReIdentify v-model="isReIdentify" :reIdentifyTotal="reIdentifyTotal" justObjective="0" :schoolInfo="selectSchoolInfo"  @ReIdentifyComplete="Refresh"></ReIdentify>
     <!-- <SelectStudent v-model="showSelectStudent"  @success="StudentSuccess"></SelectStudent> -->
     <AbnormalDetail v-model="showAbnormalDialog" :scanState="scanState" @success="BackScan"></AbnormalDetail>
     <div class="page_dialog">
@@ -206,6 +207,7 @@ import scanCommon from '@/utils/scanCommon';
 import { ElMessageBox, ElMessage } from 'element-plus'
 import { formatTimestamp } from '@/utils/common';
 import AbnormalDetail from './abnormalDetail.vue';
+import ReIdentify from './components/ReIdentify.vue';//重新识别弹窗
 import WebSocketManager from '@/utils/webSocket'//后端websocket链接js
 // 实例化 Store
 const examStore = useExamStore()
@@ -272,6 +274,12 @@ const examCodeStart=ref(1);//考号开始位置
 const examCodeEnd=ref(10);//考号结束位置
 const setLoading=ref(false);//考号设置加载中
 
+const isReIdentify=ref(false);//是否重新识别
+const reIdentifyTotal = ref(0);//需要识别的数
+const selectSchoolInfo=ref({
+    schoolId:'',
+    schoolName:''
+});//学校信息
 const params=ref({
     batchNo:'',
     keyWord:''
@@ -622,7 +630,7 @@ const MessageBack = (data:any) => {
 
 //重新识别弹窗
 const OpenReIdentify=() => {
-
+    isReIdentify.value=true;
 }
 
 //开始扫描