Sfoglia il codice sorgente

扫描图片组件 扫描批次图片模式更新 异常处理弹窗组件

dengshaobo 3 settimane fa
parent
commit
b342375446

+ 1026 - 0
src/components/ActionImage.vue

@@ -0,0 +1,1026 @@
+<template>
+  <div
+    class="main_container"
+    ref="mainContainerRef"
+    v-loading="loading"
+    element-loading-text="图片加载中……"
+    element-loading-spinner="el-icon-loading"
+    element-loading-background="#ffffff"
+    @mousedown="onMouseDown"
+    @mousemove="onMouseMove"
+    @mouseup="onMouseUp"
+    @wheel="onWheel"
+  >
+    <canvas
+      id="paperCanvas"
+      ref="paperCanvasRef"
+      class="paper_canvas"
+      @mouseleave="onCanvasLeave"
+    ></canvas>
+    <div id="imgContainer" class="img_container">
+      <img v-if="paperImgUrl" :src="paperImgUrl" />
+    </div>
+    <div class="no_paper_url" v-if="paperImgUrl == ''">
+      暂无数据
+      <!-- 请先制作模版 -->
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, watch, onBeforeUnmount, onMounted } from 'vue'
+import { throttle } from 'lodash'
+// import { mmToPx } from '@/utils/common.ts' // 如果需要取消注释
+
+// 定义 Props
+const props = defineProps({
+  drawData: {
+    type: Array,
+    default: () => [],
+  }, // 画的边框的数据
+  paperInfo: {
+    type: Object,
+    default: () => null,
+  }, // 纸张大小
+  paperImgUrl: {
+    type: String,
+    default: '',
+  }, // 试卷地址相关信息
+  currentId: {
+    type: String,
+    default: '',
+  }, // 当前选中的id
+  isDrag: {
+    type: Boolean,
+    default: true,
+  }, // 是否可以拖动 默认可以拖动
+  isAbnormal: {
+    type: Boolean,
+    default: false,
+  }, // 是否异常处使用 区别 异常处理使用的地方 正常答案显示蓝色
+})
+
+// 定义 Emits
+const emit = defineEmits(['GetRectPoint'])
+
+// 响应式数据
+const mainContainerRef = ref(null)
+const paperCanvasRef = ref(null)
+
+const position = reactive({
+  x: 0,
+  y: 0,
+}) // 初始canvas图片位置
+
+const startX = ref(0) // 鼠标按下时的初始位置x坐标
+const startY = ref(0) // 鼠标按下时的初始位置y坐标
+const scale = ref(1) // 画布缩放倍数
+const isDragging = ref(false) // 是否拖动画布
+const isDrawing = ref(false) // 是否正在画线
+const drawType = ref(0) // 1画线 0 拖拽模式
+const image = ref(null)
+
+const paperImgInfo = reactive({
+  width: 0,
+  height: 0,
+})
+
+const canvasInfo = reactive({
+  width: 0,
+  height: 0,
+}) // 画布的大小
+
+const zoomRate = ref(0) // 图片的缩放比例
+const dpr = window.devicePixelRatio || 1
+const minScale = 0.7 // 最小缩放值
+const maxScale = 4 // 最大缩放值
+
+const rectPoint = reactive({
+  startX: 0,
+  startY: 0,
+  endX: 0,
+  endY: 0,
+}) // 矩形起始坐标点
+
+const showObjectArea = ref(false) // 是否显示对象区域
+const addObjectAreaOption = reactive({
+  derection: 1, // 排列方向 1 横线 2竖向
+  questionType: 1, // 题类型 1单选 2多选 3 判断
+  startNumber: 16, // 起始题号
+  endNumber: 20, // 结束题号
+  interval: 1, // 题号间隔 默认1
+  answerNumber: 4, // 选项个数 答案个数
+  answerWidth: 0, // 选项宽度
+  answerHeight: 0, // 选项高度
+}) // 添加选择题设置信息
+
+const currenPoint = reactive({
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 0,
+})
+
+const containerWidth = ref(0) // 容器宽度
+const containerHeight = ref(0) // 容器高度
+const isInit = ref(true) // 是否是初始加载
+const isShowDraw = ref(true) // 是否显示画框
+const loading = ref(false) // 是否正在加载图片
+
+// 模拟 this.$global.floatNum,请根据实际项目替换为真实工具函数
+const floatNum = (num, fixed = 2) => {
+  if (typeof num !== 'number') return 0
+  return Number(num.toFixed(fixed))
+}
+
+// 方法定义
+
+// 显示画框
+const ShowDrawData = () => {
+  isShowDraw.value = !isShowDraw.value
+  drawImage()
+}
+
+// 初始数据加载
+const initData = () => {
+  if (!mainContainerRef.value) return
+  
+  // 获取容器的宽高
+  const { width, height } = mainContainerRef.value.getBoundingClientRect()
+  containerHeight.value = Number(height)
+  containerWidth.value = Number(width)
+
+  image.value = new Image()
+  image.value.src = props.paperImgUrl
+
+  // 初始化获取试卷数据
+  if (props.paperInfo?.width && props.paperInfo?.height) {
+    // 设置初始数据
+    paperImgInfo.width = props.paperInfo.width // 获取宽
+    paperImgInfo.height = props.paperInfo.height // 获取高
+    
+    image.value.onload = () => {
+      loading.value = false // 图片加载完成
+      // 更新缩放率
+      updateZoomAndPaperInfo()
+      // 更新画布尺寸
+      updateCanvasSize()
+      // 计算中心位置使图片居中 初始加载图片是居中
+      if (isInit.value) {
+        centerCanvas()
+      }
+      // 加载图片
+      loadImage()
+    }
+  } else {
+    // 没有值 默认图片的宽高
+    image.value.onload = () => {
+      paperImgInfo.width = image.value.width // 获取宽
+      paperImgInfo.height = image.value.height // 获取高
+      loading.value = false // 图片加载完成
+      // 更新缩放率
+      updateZoomAndPaperInfo()
+      // 更新画布尺寸
+      updateCanvasSize()
+      // 计算中心位置使图片居中
+      if (isInit.value) {
+        centerCanvas()
+      }
+      // 加载图片
+      loadImage()
+    }
+  }
+}
+
+// 切换试卷图片
+const chanagePaperImage = () => {
+  // 更新缩放率
+  updateZoomAndPaperInfo()
+  // 更新画布尺寸
+  updateCanvasSize()
+  // 加载图片
+  loadImage()
+}
+
+// 适合屏幕
+const fitScreen = () => {
+  scale.value = 1
+  isInit.value = true
+  initData() // 初始化屏幕加载
+}
+
+// 加载图片
+const loadImage = () => {
+  drawImage() // 绘制边框数据
+}
+
+// 图片宽高坐标变化
+const ImageInfoChange = () => {
+  const { width, height } = paperImgInfo
+  const imgDom = document.getElementById('imgContainer')
+  if (imgDom) {
+    imgDom.style.width = width * zoomRate.value * scale.value + 'px'
+    imgDom.style.height = height * zoomRate.value * scale.value + 'px'
+    imgDom.style.left = position.x + 'px'
+    imgDom.style.top = position.y + 'px'
+  }
+}
+
+// 图片加载完成后绘制图片
+const drawImage = () => {
+  const canvas = paperCanvasRef.value
+  if (!canvas) return
+  
+  const ctx = canvas.getContext('2d')
+  if (!ctx) return
+
+  ctx.clearRect(0, 0, canvasInfo.width, canvasInfo.height) // 清除边框数据
+  ImageInfoChange() // 用image图片替代背景
+
+  for (let i = 0; i < props.drawData.length; i++) {
+    let point = {
+      x: props.drawData[i].x * zoomRate.value * scale.value,
+      y: props.drawData[i].y * zoomRate.value * scale.value,
+      width: props.drawData[i].w * zoomRate.value * scale.value,
+      height: props.drawData[i].h * zoomRate.value * scale.value,
+      blockName: props.drawData[i].blockName,
+      page: props.drawData[i].page,
+      blockArea: props.drawData[i].blockArea,
+    }
+
+    // 外边框数据 mm单位 比如系统卡 使用
+    if (props.drawData[i].usedCardType == 1) {
+      // 系统卡
+      const imageInfo = paperImgInfo
+      let offsexPoint = {
+        x: props.drawData[i].x - 30,
+        y: props.drawData[i].y - 25,
+        w: props.drawData[i].w,
+        h: props.drawData[i].h,
+      } // 相对与第一个黑块的坐标
+      
+      let templateInfo = {
+        width: 794 - 30 * 2,
+        height: 1123 - 25 * 2,
+      } // 模板去掉边框的尺寸
+
+      // 如果长大于宽 就是A3 否则就是A4
+      if (paperImgInfo.width > paperImgInfo.height) {
+        // A3 1588*1123
+        offsexPoint = {
+          x: props.drawData[i].x - 30,
+          y: props.drawData[i].y - 25,
+          w: props.drawData[i].w,
+          h: props.drawData[i].h,
+        }
+        templateInfo = {
+          width: 1588 - 30 * 2,
+          height: 1123 - 25 * 2,
+        }
+      } else {
+        // A4 794*1123
+        offsexPoint = {
+          x: props.drawData[i].x - 30,
+          y: props.drawData[i].y - 25,
+          w: props.drawData[i].w,
+          h: props.drawData[i].h,
+        }
+        templateInfo = {
+          width: 794 - 30 * 2,
+          height: 1123 - 25 * 2,
+        }
+      }
+
+      // 系统卡 需要根据模板的坐标进行转换
+      const x = parseFloat(
+        ((offsexPoint.x / templateInfo.width) * imageInfo.width).toFixed(2)
+      )
+      const y = parseFloat(
+        ((offsexPoint.y / templateInfo.height) * imageInfo.height).toFixed(2)
+      )
+      const w = parseFloat(
+        ((props.drawData[i].w / templateInfo.width) * imageInfo.width).toFixed(2)
+      )
+      const h = parseFloat(
+        ((props.drawData[i].h / templateInfo.height) * imageInfo.height).toFixed(2)
+      )
+
+      point = {
+        x: x * zoomRate.value * scale.value,
+        y: y * zoomRate.value * scale.value,
+        width: w * zoomRate.value * scale.value,
+        height: h * zoomRate.value * scale.value,
+        blockName: props.drawData[i].blockName,
+        page: props.drawData[i].page || 1,
+        blockArea: props.drawData[i].blockArea,
+      }
+    }
+
+    // 当前表格选择的边框换蓝色显示
+    if (props.currentId == props.drawData[i].id) {
+      ctx.strokeStyle = 'blue'
+      ctx.fillStyle = 'blue'
+      ctx.lineWidth = 2
+    } else {
+      ctx.strokeStyle = 'red'
+      ctx.fillStyle = 'red'
+      ctx.lineWidth = 2
+    }
+
+    const topiclist = props.drawData[i].topiclist || []
+    
+    if (topiclist.length > 0) {
+      const topicName = String(topiclist?.[0]?.topicName)
+      // 客观题有题目则不显示外边框
+      if (topicName.includes('选做区')) {
+        ctx.strokeRect(point.x, point.y, point.width, point.height) // 绘制边框
+      }
+      if (topiclist[0].index < 0) {
+        // 系统卡选做题显示边框
+        ctx.strokeRect(point.x, point.y, point.width, point.height) // 绘制边框
+      }
+    } else {
+      ctx.lineWidth = 2
+      ctx.strokeRect(point.x, point.y, point.width, point.height) // 绘制边框
+    }
+
+    // 客观题小题加载
+    if (props.drawData[i].topiclist) {
+      const topiclist = props.drawData[i].topiclist
+      let derection = 1 // 排列方向 1 横向 2竖向
+      
+      if (props.drawData[i].areaOption) {
+        try {
+          const option = JSON.parse(props.drawData[i].areaOption)
+          derection = option.derection
+        } catch (e) {
+          console.error('解析 areaOption 失败', e)
+        }
+      }
+
+      if (isShowDraw.value) {
+        topiclist.forEach((topicItem) => {
+          ctx.font = '15px Arial'
+          ctx.fillStyle = 'red'
+          ctx.textAlign = 'left'
+
+          if (String(topicItem.topicName).includes('选做区')) {
+            // 选做题不显示题目文字
+          } else {
+            let namePoint = {
+              x: topicItem.answerList[0].x,
+              y: topicItem.answerList[0].y,
+            }
+
+            // 绘制文字 系统卡
+            if (props.drawData[i].usedCardType == 1) {
+              const imageInfo = paperImgInfo
+              let offsexPoint = {
+                x: topicItem.answerList[0].x - 30,
+                y: topicItem.answerList[0].y - 25,
+              }
+              let templateInfo = {
+                width: 794 - 30 * 2,
+                height: 1123 - 25 * 2,
+              }
+
+              if (paperImgInfo.width > paperImgInfo.height) {
+                // A3
+                offsexPoint = {
+                  x: topicItem.answerList[0].x - 30,
+                  y: topicItem.answerList[0].y - 25,
+                }
+                templateInfo = {
+                  width: 1588 - 30 * 2,
+                  height: 1123 - 25 * 2,
+                }
+              } else {
+                // A4
+                offsexPoint = {
+                  x: topicItem.answerList[0].x - 30,
+                  y: topicItem.answerList[0].y - 25,
+                }
+                templateInfo = {
+                  width: 794 - 30 * 2,
+                  height: 1123 - 25 * 2,
+                }
+              }
+
+              namePoint.x = parseFloat(
+                ((offsexPoint.x / templateInfo.width) * imageInfo.width).toFixed(2)
+              )
+              namePoint.y = parseFloat(
+                ((offsexPoint.y / templateInfo.height) * imageInfo.height).toFixed(2)
+              )
+            }
+
+            if (props.isAbnormal) {
+              if (topicItem.hasAbnormal) {
+                ctx.fillStyle = 'red'
+              } else {
+                ctx.fillStyle = 'blue'
+              }
+            } else {
+              ctx.fillStyle = 'red'
+              if (props.currentId == props.drawData[i].id) {
+                ctx.fillStyle = 'blue'
+              } else {
+                ctx.fillStyle = 'red'
+              }
+            }
+
+            if (derection == 1 || derection == 3) {
+              // 横向 或者横纵向
+              ctx.fillText(
+                topicItem.topicName,
+                parseFloat(namePoint.x - 30) * zoomRate.value * scale.value,
+                parseFloat(namePoint.y + 15) * zoomRate.value * scale.value
+              )
+            } else {
+              // 竖向
+              ctx.fillText(
+                topicItem.topicName,
+                parseFloat(namePoint.x + 1) * zoomRate.value * scale.value,
+                parseFloat(namePoint.y - 22) * zoomRate.value * scale.value
+              )
+            }
+          }
+
+          topicItem.answerList.forEach((answerItem) => {
+            let obj = {
+              x: answerItem.x * zoomRate.value * scale.value,
+              y: answerItem.y * zoomRate.value * scale.value,
+              width: answerItem.w * zoomRate.value * scale.value,
+              height: answerItem.h * zoomRate.value * scale.value,
+            }
+
+            // 系统卡
+            if (props.drawData[i].usedCardType == 1) {
+              const imageInfo = paperImgInfo
+              let offsexPoint = {
+                x: answerItem.x - 30,
+                y: answerItem.y - 25,
+                w: answerItem.w,
+                h: answerItem.h,
+              }
+              let templateInfo = {
+                width: 794 - 30 * 2,
+                height: 1123 - 25 * 2,
+              }
+
+              if (paperImgInfo.width > paperImgInfo.height) {
+                // A3
+                offsexPoint = {
+                  x: answerItem.x - 30,
+                  y: answerItem.y - 25,
+                  w: answerItem.w,
+                  h: answerItem.h,
+                }
+                templateInfo = {
+                  width: 1588 - 30 * 2,
+                  height: 1123 - 25 * 2,
+                }
+              } else {
+                // A4
+                offsexPoint = {
+                  x: answerItem.x - 30,
+                  y: answerItem.y - 25,
+                  w: answerItem.w,
+                  h: answerItem.h,
+                }
+                templateInfo = {
+                  width: 794 - 30 * 2,
+                  height: 1123 - 25 * 2,
+                }
+              }
+
+              const x = parseFloat(
+                ((offsexPoint.x / templateInfo.width) * imageInfo.width).toFixed(2)
+              )
+              const y = parseFloat(
+                ((offsexPoint.y / templateInfo.height) * imageInfo.height).toFixed(2)
+              )
+              const w = parseFloat(
+                ((answerItem.w / templateInfo.width) * imageInfo.width).toFixed(2)
+              )
+              const h = parseFloat(
+                ((answerItem.h / templateInfo.height) * imageInfo.height).toFixed(2)
+              )
+
+              obj = {
+                x: x * zoomRate.value * scale.value,
+                y: y * zoomRate.value * scale.value,
+                width: w * zoomRate.value * scale.value,
+                height: h * zoomRate.value * scale.value,
+              }
+            }
+
+            ctx.lineWidth = 1
+            
+            // 选中 绘制带背景阴影的边框
+            if (topicItem.hasAbnormal) {
+              // 有异常的
+              if (answerItem.isCheck) {
+                ctx.fillStyle = 'rgba(255,0,0,0.3)' // 红色背景
+                ctx.strokeStyle = 'red' // 红色边框
+                ctx.fillRect(obj.x, obj.y, obj.width, obj.height)
+                ctx.strokeRect(obj.x, obj.y, obj.width, obj.height)
+              } else {
+                ctx.fillStyle = 'red' // 半透明红色背景
+                ctx.strokeStyle = 'red' // 边框的颜色
+                ctx.strokeRect(obj.x, obj.y, obj.width, obj.height)
+              }
+            } else {
+              if (props.isAbnormal) {
+                ctx.fillStyle = 'rgba(0,0,255,0.3)' // 半透明蓝色背景
+                ctx.strokeStyle = 'blue' // 蓝色边框
+              } else {
+                ctx.fillStyle = 'rgba(255,0,0,0.3)' // 红色背景
+                ctx.strokeStyle = 'red' // 红色边框
+
+                if (props.currentId == props.drawData[i].id) {
+                  ctx.strokeStyle = 'blue'
+                } else {
+                  ctx.strokeStyle = 'red'
+                }
+              }
+
+              // 选中
+              if (answerItem?.isCheck) {
+                ctx.fillRect(obj.x, obj.y, obj.width, obj.height)
+                ctx.strokeRect(obj.x, obj.y, obj.width, obj.height)
+              } else {
+                ctx.strokeRect(obj.x, obj.y, obj.width, obj.height)
+              }
+            }
+          })
+        })
+      }
+    }
+
+    // 主观题划分区加载
+    if (props.drawData[i].scoreList) {
+      const scoreList = props.drawData[i].scoreList
+      scoreList.forEach((scoreItem) => {
+        ctx.font = '15px Arial'
+        ctx.fillStyle = 'red'
+        ctx.textAlign = 'left'
+
+        let obj = {
+          x: scoreItem.x * zoomRate.value * scale.value,
+          y: scoreItem.y * zoomRate.value * scale.value,
+          width: scoreItem.w * zoomRate.value * scale.value,
+          height: scoreItem.h * zoomRate.value * scale.value,
+        }
+
+        // 系统卡
+        if (props.drawData[i].usedCardType == 1) {
+          const imageInfo = paperImgInfo
+          let offsexPoint = {
+            x: scoreItem.x - 30,
+            y: scoreItem.y - 25,
+            w: scoreItem.w,
+            h: scoreItem.h,
+          }
+          let templateInfo = {
+            width: 794 - 30 * 2,
+            height: 1123 - 25 * 2,
+          }
+
+          if (paperImgInfo.width > paperImgInfo.height) {
+            // A3
+            offsexPoint = {
+              x: scoreItem.x - 30,
+              y: scoreItem.y - 25,
+              w: scoreItem.w,
+              h: scoreItem.h,
+            }
+            templateInfo = {
+              width: 1588 - 30 * 2,
+              height: 1123 - 25 * 2,
+            }
+          } else {
+            // A4
+            offsexPoint = {
+              x: scoreItem.x - 30,
+              y: scoreItem.y - 25,
+              w: scoreItem.w,
+              h: scoreItem.h,
+            }
+            templateInfo = {
+              width: 794 - 30 * 2,
+              height: 1123 - 25 * 2,
+            }
+          }
+
+          const x = parseFloat(
+            ((offsexPoint.x / templateInfo.width) * imageInfo.width).toFixed(2)
+          )
+          const y = parseFloat(
+            ((offsexPoint.y / templateInfo.height) * imageInfo.height).toFixed(2)
+          )
+          const w = parseFloat(
+            ((scoreItem.w / templateInfo.width) * imageInfo.width).toFixed(2)
+          )
+          const h = parseFloat(
+            ((scoreItem.h / templateInfo.height) * imageInfo.height).toFixed(2)
+          )
+
+          obj = {
+            x: x * zoomRate.value * scale.value,
+            y: y * zoomRate.value * scale.value,
+            width: w * zoomRate.value * scale.value,
+            height: h * zoomRate.value * scale.value,
+          }
+        }
+
+        ctx.lineWidth = 1
+        ctx.fillStyle = scoreItem.color + '50' // 红色背景
+        ctx.strokeStyle = scoreItem.color // 红色边框
+        
+        if (scoreItem.isCheck) {
+          ctx.fillRect(obj.x, obj.y, obj.width, obj.height)
+        }
+        ctx.strokeRect(obj.x, obj.y, obj.width, obj.height) // 绘制答案边框
+      })
+    }
+
+    ctx.fillStyle = 'rgba(255,0,0,1)' // 半透明红色背景
+    ctx.font = '15px Arial'
+    ctx.textAlign = 'left'
+    
+    // 绘制文字 定位去和客观题组不用显示
+    if (point.blockArea != 2 && point.blockArea != 8) {
+      ctx.fillText(point.blockName, point.x, point.y - 5)
+    }
+  }
+  ctx.restore()
+}
+
+// 更新缩放率
+const updateZoomAndPaperInfo = () => {
+  const widthZoomRate = containerWidth.value / paperImgInfo.width
+  const heightZoomRate = containerHeight.value / paperImgInfo.height
+  // 选择较小的缩放率作为基准,确保图像完整显示在容器内
+  zoomRate.value = Math.min(widthZoomRate, heightZoomRate)
+}
+
+// 更新画布尺寸
+const updateCanvasSize = () => {
+  canvasInfo.width = Math.round(paperImgInfo.width * zoomRate.value * scale.value)
+  canvasInfo.height = Math.round(paperImgInfo.height * zoomRate.value * scale.value)
+  
+  if (paperCanvasRef.value) {
+    paperCanvasRef.value.width = canvasInfo.width
+    paperCanvasRef.value.height = canvasInfo.height
+  }
+}
+
+// 中心化画布
+const centerCanvas = () => {
+  position.x = (containerWidth.value - canvasInfo.width) / 2
+  position.y = (containerHeight.value - canvasInfo.height) / 2
+  
+  if (paperCanvasRef.value) {
+    paperCanvasRef.value.style.left = `${position.x}px`
+    paperCanvasRef.value.style.top = `${position.y}px`
+  }
+  isInit.value = false // 后面不在执行居中操作
+}
+
+// 全局鼠标释放事件处理
+const onGlobalMouseUp = () => {
+  isDragging.value = false
+}
+
+// 鼠标按下事件
+const onMouseDown = (event) => {
+  // 只响应左键点击(button值为0表示左键)
+  if (event.button !== 0) {
+    isDragging.value = false
+    return
+  }
+  
+  // 确保在开始新的拖拽前,先重置拖拽状态
+  isDragging.value = false
+  
+  if (drawType.value == 0) {
+    if (props.isDrag) {
+      isDragging.value = true
+      startX.value = event.clientX - position.x
+      startY.value = event.clientY - position.y
+    }
+  }
+  
+  if (drawType.value == 1) {
+    if (event.target.id != 'paperCanvas') {
+      isDragging.value = true
+      startX.value = event.clientX - position.x
+      startY.value = event.clientY - position.y
+    }
+  }
+}
+
+// 鼠标移动事件
+const onMouseMove = (event) => {
+  if (isDragging.value) {
+    // 在拖拽模式下,移动画布
+    position.x = event.clientX - startX.value
+    position.y = event.clientY - startY.value
+    
+    if (paperCanvasRef.value) {
+      paperCanvasRef.value.style.left = `${position.x}px`
+      paperCanvasRef.value.style.top = `${position.y}px`
+    }
+    ImageInfoChange()
+  }
+}
+
+// 鼠标抬起事件
+const onMouseUp = (event) => {
+  // 只响应左键释放
+  if (event && event.button !== 0) {
+    return
+  }
+  isDragging.value = false // 停止拖拽
+}
+
+// 鼠标滚轮事件
+const onWheel = (event) => {
+  event.preventDefault()
+  // 计算新的缩放比例
+  const delta = event.deltaY < 0 ? 1 : -1
+  const newScale = scale.value + delta * 0.1
+  const clampedScale = Math.max(minScale, Math.min(maxScale, newScale))
+  
+  // 如果缩放值没有变化,则直接返回
+  if (clampedScale === scale.value) return
+  
+  // 获取鼠标在容器中的位置
+  const containerRect = mainContainerRef.value.getBoundingClientRect()
+  const mouseX = event.clientX - containerRect.left
+  const mouseY = event.clientY - containerRect.top
+  
+  // 计算鼠标相对于当前画布的位置
+  const mouseRelativeToCanvasX = (mouseX - position.x) / scale.value
+  const mouseRelativeToCanvasY = (mouseY - position.y) / scale.value
+  
+  // 更新缩放比例
+  scale.value = clampedScale
+  
+  // 更新画布尺寸
+  updateCanvasSize()
+  
+  // 重新计算画布位置,使缩放围绕鼠标点进行
+  position.x = mouseX - mouseRelativeToCanvasX * scale.value
+  position.y = mouseY - mouseRelativeToCanvasY * scale.value
+  
+  // 应用新的位置
+  if (paperCanvasRef.value) {
+    paperCanvasRef.value.style.left = `${position.x}px`
+    paperCanvasRef.value.style.top = `${position.y}px`
+  }
+  
+  // 更新图片位置信息
+  ImageInfoChange()
+  
+  // 重新绘制内容
+  drawImage()
+}
+
+// 鼠标复位
+const MouseReset = () => {
+  drawType.value = 0
+  const canvas = paperCanvasRef.value
+  if (canvas) {
+    canvas.style.cursor = 'pointer'
+    canvas.onmousedown = null
+    canvas.onmousemove = null
+    canvas.onmouseup = null
+  }
+}
+
+// 框选答案区域
+const SelectionBox = () => {
+  drawType.value = 1
+  const canvas = paperCanvasRef.value
+  if (canvas) {
+    canvas.style.cursor = 'crosshair'
+    canvas.onmousedown = onCanvasDown
+    canvas.onmousemove = onCanvasMove
+    canvas.onmouseup = onCanvasUp
+  }
+}
+
+// 画圈模式
+const PaintingCircle = () => {
+  drawType.value = 1
+  const canvas = paperCanvasRef.value
+  if (canvas) {
+    canvas.style.cursor = 'crosshair'
+    canvas.onmousedown = onCanvasDown
+    canvas.onmousemove = onCanvasMove
+    canvas.onmouseup = onCanvasUp
+  }
+}
+
+// 开始画答案区域圈
+const StartPaintingCircle = (obj) => {
+  Object.assign(addObjectAreaOption, obj)
+  drawType.value = 1
+  const canvas = paperCanvasRef.value
+  if (canvas) {
+    canvas.style.cursor = 'crosshair'
+    canvas.onmousedown = onCanvasDown
+    canvas.onmousemove = onCanvasMove
+    canvas.onmouseup = onCanvasUp
+  }
+}
+
+// canvas上按下事件
+const onCanvasDown = (e) => {
+  const canvas = paperCanvasRef.value
+  if (canvas) canvas.style.cursor = 'crosshair'
+  
+  isDrawing.value = true
+  isDragging.value = false // 禁止拖拽
+  
+  rectPoint.startX = e.offsetX
+  rectPoint.startY = e.offsetY
+  rectPoint.endX = e.offsetX
+  rectPoint.endY = e.offsetY
+}
+
+// canvas上移动事件
+const onCanvasMove = (e) => {
+  if (isDrawing.value) {
+    const canvas = paperCanvasRef.value
+    if (!canvas) return
+    
+    const ctx = canvas.getContext('2d')
+    if (!ctx) return
+
+    // 更新当前鼠标位置
+    rectPoint.endX = e.offsetX
+    rectPoint.endY = e.offsetY
+    
+    // 清除之前的矩形
+    ctx.clearRect(0, 0, canvas.width, canvas.height)
+    // 重新绘制之前的边框数据
+    drawImage()
+    
+    // 设置矩形样式
+    ctx.strokeStyle = 'blue'
+    ctx.lineWidth = 1 // 画框的线的粗细
+    
+    // 计算矩形的起点和宽高,处理反向框选
+    const startX = rectPoint.startX
+    const startY = rectPoint.startY
+    const width = e.offsetX - startX
+    const height = e.offsetY - startY
+    
+    // 绘制矩形(可以处理负宽度和高度)
+    ctx.strokeRect(startX, startY, width, height)
+  }
+}
+
+// canvas抬起事件
+const onCanvasUp = () => {
+  isDrawing.value = false
+  
+  // 处理反向框选,确保宽度和高度为正数
+  let startX = rectPoint.startX
+  let startY = rectPoint.startY
+  let endX = rectPoint.endX
+  let endY = rectPoint.endY
+  
+  // 确保起点坐标是左上角,终点坐标是右下角
+  let actualStartX = Math.min(startX, endX)
+  let actualStartY = Math.min(startY, endY)
+  let actualEndX = Math.max(startX, endX)
+  let actualEndY = Math.max(startY, endY)
+  
+  // 计算实际的宽度和高度(确保为正数)
+  let width = Math.abs(actualEndX - actualStartX)
+  let height = Math.abs(actualEndY - actualStartY)
+  
+  const point = {
+    x: floatNum(actualStartX / zoomRate.value / scale.value),
+    y: floatNum(actualStartY / zoomRate.value / scale.value),
+    w: floatNum(width / zoomRate.value / scale.value),
+    h: floatNum(height / zoomRate.value / scale.value),
+    unit: 'px',
+  }
+  
+  Object.assign(currenPoint, point)
+  
+  // 只有当宽度和高度都大于0时才触发事件
+  if (point.w > 0 && point.h > 0) {
+    emit('GetRectPoint', point)
+  }
+}
+
+// 鼠标离开画布事件
+const onCanvasLeave = () => {
+  // 如果正在绘制,自动结束绘制
+  if (isDrawing.value) {
+    onCanvasUp()
+  }
+}
+
+// 监听窗口大小变化,重新计算表格高度 使用节流防止频繁改变窗口大小导致计算量过大而页面卡顿
+const handleResize = throttle(() => {
+  // 如果需要重新计算,可以在这里 uncomment
+  // if (mainContainerRef.value) {
+  //   const { width, height } = mainContainerRef.value.getBoundingClientRect()
+  //   containerWidth.value = width
+  //   containerHeight.value = height
+  //   updateZoomAndPaperInfo()
+  //   updateCanvasSize()
+  //   centerCanvas()
+  //   loadImage()
+  // }
+}, 500)
+
+// Watchers
+watch(
+  () => props.paperImgUrl,
+  (newVal) => {
+    loading.value = true // 加载图片
+    initData() // 初始化数据
+    setTimeout(() => {
+      if (!props.paperImgUrl) {
+        loading.value = false // 加载图片完成
+      }
+    }, 100)
+  },
+  { deep: true, immediate: true }
+)
+
+watch(
+  () => props.drawData,
+  () => {
+    drawImage() // 更新边框数据并重新绘制
+  },
+  { deep: true }
+)
+
+watch(
+  () => props.currentId,
+  () => {
+    drawImage() // 更新边框数据并重新绘制
+  }
+)
+
+// Lifecycle Hooks
+onMounted(() => {
+  window.addEventListener('resize', handleResize)
+  // 添加全局鼠标释放事件监听器,处理异常情况
+  window.addEventListener('mouseup', onGlobalMouseUp)
+  
+  // 初始数据处理加载 如定位 居中 等 第一次居中
+  initData()
+})
+
+onBeforeUnmount(() => {
+  // 移除监听 防止内存泄漏
+  window.removeEventListener('resize', handleResize)
+  window.removeEventListener('mouseup', onGlobalMouseUp)
+})
+
+// 暴露方法给父组件(如果需要)
+defineExpose({
+  ShowDrawData,
+  fitScreen,
+  MouseReset,
+  SelectionBox,
+  PaintingCircle,
+  StartPaintingCircle,
+})
+</script>
+
+<style lang="scss" scoped>
+.paper_canvas {
+  position: absolute;
+  cursor: pointer;
+  background-color: transparent;
+  z-index: 10; // 使canvas在图片上方
+  // border:1px solid green;
+}
+
+.img_container {
+  position: absolute;
+  cursor: pointer;
+  z-index: 9;
+  // border:1px solid red;
+  img {
+    width: 100%;
+    height: 100%;
+  }
+}
+
+.main_container {
+  position: relative;
+  overflow: hidden;
+}
+</style>

+ 16 - 1
src/utils/common.ts

@@ -69,4 +69,19 @@ export const formatTimestamp = (timestamp: number | string | null | undefined, f
     }
   }
   return fmt;
-}
+}
+
+
+// 将毫米转换成px 保留整数 小数 四舍五入
+export const mmToPx=(num:number) :number=>{
+    if(num)
+    {
+      let scale=1754/297;
+      return  parseFloat((scale*num).toFixed(4));
+    }
+    else
+    {
+      console.log("num",num);
+      return 0;
+    }
+};

+ 0 - 0
src/views/abnormal/考试异常处理.txt


+ 690 - 0
src/views/exam/abnormalDetail.vue

@@ -0,0 +1,690 @@
+<template>
+    <el-dialog title="" v-model="showDialog" class="page_full_dialog" :modal-append-to-body="false"  append-to-body fullscreen height="100%">
+
+
+    </el-dialog>
+</template>
+<script lang="ts" setup>
+import { useExamStore } from '@/store/exam'
+import { useUserStore } from '@/store/user'
+import { useRouter } from 'vue-router'
+import { onMounted ,ref,computed,onUnmounted,nextTick } from 'vue';
+import { hasImportStudent,getBatchList,getCurrentBatchNo,deleteBatch,updateScanCount } from '@/api/exam'
+import { ElMessageBox, ElMessage } from 'element-plus'
+
+// 实例化 Store
+const examStore = useExamStore()
+const userStore = useUserStore()
+
+const router = useRouter()
+
+// 定义 Props 和 Emits
+const props = defineProps<{
+  modelValue: boolean,
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: boolean): void
+  (e: 'success'): void
+}>()
+// 弹窗显示状态的双向绑定
+const showDialog = computed({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+
+// 考试科目 ID
+const examSubjectId = computed(() => {
+  return examStore.currentExam?.id
+})//计算属性
+
+//考试科目code
+const examSubjectCode=computed(() => {
+    return examStore.currentExam?.examSubjectCode
+})
+//当前登录人姓名
+const currentUserName=computed(() => {
+    return userStore.userName;
+})
+
+const selectSchoolId=ref(0);//学校ID
+
+const params=ref({
+    batchNo:'',
+    keyWord:''
+})
+
+const scanClientStates=ref(false);//客户端状态
+const scanClientVersion=ref('');//客户端版本
+const isScanning=ref(false);//是否正在扫描 扫描状态
+const hasMakeTemplate=ref(false);//模版是否制作完成
+const currentBatchNo=ref('');//当前批次号
+const currentBatchNoId=ref('');//当前批次号id
+const loadingText=ref('');//加载文本
+const baseUrl=import.meta.env.VITE_API_BASE_URL;
+const uploadUrl=`https://dev3.k12100.net/teaching/api/v1/ai_exam_scan/upload_multi_img`;//图片上传地址
+
+
+//  定义数据类型接口
+interface BatchItem {
+  id?: string | number;
+  batchNo: string;
+  batchTypeName?: string;
+  scanUserName?: string;
+  scannedPaperNum?: number;
+  scannedTime?: number | string;
+  uploadNum?: number;
+  uploadStatus?: number; // 确保包含此属性
+  failedNumber?: number;
+  [key: string]: any; // 允许其他动态属性
+}
+const tableData=ref<BatchItem[]>([]); //批次列表
+const tableHeight=ref(500);
+const scanIdentifyList=[                
+    {
+        label:'考号',
+        value:'2',
+    },
+    {
+        label:'学号',
+        value:'1',
+    }
+];//识别号列表
+
+const isImportStudent=ref(false);//是否导入了学生名单
+const showSelectStudent=ref(false);//是否显示选择学生名单弹窗
+const scanDataInfo=ref({
+    abnormalNum:0,//异常数量
+    examMissNum:0,//缺考数量
+    unScanned:0,//未扫描数量
+    scannedNum:0,//已上传数量
+    examTotal:0,//考试总人数
+
+});//扫描返回的数据信息
+
+//扫描进度  
+const scanProcess=computed(() => {
+
+    return Math.floor((scanDataInfo.value.scannedNum+scanDataInfo.value.examMissNum)/scanDataInfo.value.examTotal)*100;
+});
+
+//缺考进度
+const scanQuekao=computed(() => {
+    return Math.floor((scanDataInfo.value.examMissNum)/scanDataInfo.value.examTotal)*100;
+});
+//异常进度
+const scanYichang=computed(() => {
+    
+    return Math.floor((scanDataInfo.value.abnormalNum)/scanDataInfo.value.examTotal)*100;
+});
+//刷新
+const Refresh = () => {
+    tableData.value=[];
+    GetScanBatchList();
+}
+
+//重新识别弹窗
+const OpenReIdentify=() => {
+
+}
+
+//开始扫描
+const OpenScan = () => {
+
+    if(isScanning.value)
+    {
+        //正在扫描
+        ElMessage.warning('正在扫描中,请勿重复点击哦!');
+        return;
+    }
+
+    //判断客户端是否已经连接
+    if(scanClientStates.value)
+    {
+        //一打开客户端 
+        console.log('客户端已打开,开始扫描');
+        // isScanning.value=true;//扫描状态
+        const params={
+            examSubjectId:examSubjectId.value,
+            schoolId:selectSchoolId.value,
+        };
+        getCurrentBatchNo(params).then((res:any)=>{
+            console.log("当前获取批次结果",res);
+            if(res.code==200)
+            {
+                currentBatchNo.value=res.data.batchNo;
+                currentBatchNoId.value=res.data.id;
+                let newData={
+                    batchNo:currentBatchNo.value,
+                    id:currentBatchNoId.value,//批次号
+                    batchTypeName:'扫描',
+                    scanUserName:currentUserName.value,
+                    scannedPaperNum:0,//扫描张数
+                    scannedTime:Date.now(),//扫描时间
+                    uploadNum:0,//上传张数
+                    uploadStatus:0,//扫描状态
+                };
+                tableData.value.push(newData);
+                //开始扫描
+                let jsonParam={
+                    examSubjectId:examSubjectId.value,
+                    batchNumber:currentBatchNo.value,
+                    // sensitive: 35,//灵敏度参数 固定35
+                    filter:0,//是否过滤参数 1-是 0-否
+                    // heightRatio:this.heightRatio,//占打分框高度比例
+                    schoolId:selectSchoolId.value//学校id  联校单校都需要学校id
+                };
+                let json = {
+                    "action":"startScan",//交互指令参数
+                    "batchNumber":GetBatchStr(res.data.id,currentBatchNo.value),//批次号  这里传给客户端的批次号需要进行处理 截取批次id后5位拼接batchNumber
+                    "subjectCode":examSubjectCode.value,//科目编号
+                    "paperSchema":1,//this.paperSchema,// 单双面//页面类型  1 单面  2  双面
+                    "token":localStorage.getItem('token'),//token信息
+                    "uploadUrl":uploadUrl, // 图片上传地址
+                    "dpi":150,//dpi参数 设置图片清晰度质量的
+                    "isColor":1,//是否彩色图片 手阅卡扫描彩色图片 0  黑白  1 彩色
+                    "useDriveUI":1,//startScan和scanTemplate增加useDriveUI参数,=1时会弹框,其他值或不写则不弹
+                    "jsonParam":JSON.stringify(jsonParam),// 后端使用的参数 json字符串 后端接口固定三个参数  1:jsonParam  2:seqNumber(这个批次的图片序号:从1开始)   3:file 图片文件
+                };//正式版参数 
+                console.log("打印发送的数据",JSON.stringify(json));
+                setTimeout(() => {
+                    scanCommon.send(JSON.stringify(json))
+                },100)
+                
+            }
+            else
+            {
+                ElMessage.error(res.msg);
+            }
+            
+        });
+
+    }
+    else
+    {
+        //提示客户端未打开
+        ElMessage.warning('请先打开客户端');
+    }
+}
+
+//获取批次字符串
+const GetBatchStr=(batchId:any,batChNo:any)=>{
+    const batchNoValue = batchId.substring(batchId.length - 5);
+    const batchNo=String(batChNo).padStart(3, '0');
+    const batchNumber=batchNoValue+batchNo;//传给客户端的图片批次号 id后五位数加上00批次号
+    return batchNumber;
+}
+
+//根据处理后的批次号转换成处理前的批次号
+const GetBatchNumber=(batchNumber:any)=>{
+    if (!batchNumber) {
+        console.warn('批次号为空或未定义');
+        return currentBatchNo.value;//返回当前的批次号
+    }
+    
+    const batchStr = batchNumber.toString();
+    if (batchStr.length < 3) {
+        console.warn('批次号长度小于3位:', batchStr);
+        return currentBatchNo.value;//返回当前的批次号
+    }
+    
+    const lastThree = batchStr.slice(-3);
+    const parsedNumber = parseInt(lastThree, 10);
+    
+    if (isNaN(parsedNumber)) {
+        console.error('无法解析批次号:', lastThree);
+        return currentBatchNo.value;//返回当前的批次号
+    }
+    console.log("打印最后的批次号",parsedNumber);
+    return parsedNumber.toString();
+}
+
+//删除批次
+const OptionDelete=(row: any) => {
+    console.log('删除批次', row);
+    
+    if(row.scanUserName==currentUserName.value)
+    {
+        ElMessageBox.confirm('确定要删除该批次吗?', '提示', {
+            confirmButtonText: '确定',
+            cancelButtonText: '取消',
+            type: 'warning'
+        }).then(() => {
+            // 删除操作
+            console.log('删除', row);
+            const params={
+                examSubjectId:examSubjectId.value,
+                batchNo:row.batchNo,
+                schoolId:selectSchoolId.value,
+            };
+            deleteBatch(params).then((res:any)=>{
+                if(res.code==200)
+                {
+                    ElMessage.success("删除成功!")
+                    GetScanBatchList(); 
+                }
+                else
+                {
+                    ElMessage.error(res.msg);
+                }
+            })
+            
+        }).catch(() => {    
+            // 取消操作
+            console.log('取消删除');
+        });
+    }
+    else
+    {
+        ElMessage.warning('只能删除自己上传的记录');
+    }
+}
+
+//跳转到异常详情页
+const GotoDetail = (type: number) => {
+
+    // 根据类型跳转到不同的详情页
+    switch (type) {
+        case 0:
+           
+            break;
+        case 1:
+           
+            break;
+        case -1:
+            router.push({
+                path: '/exam/scanDetail',
+                query: {
+                    examSubjectId: examSubjectId.value,
+                },
+            })
+            break;
+        }
+}
+
+// 打开导入学生名单
+const OpenImportStudent = () => {
+    showSelectStudent.value=true;
+}
+
+// 打开编辑考试名单
+const OpenEditExamList = () => {
+    router.push({
+        path: '/exam/examList',
+        query: {
+            examSubjectId: examSubjectId.value,
+        },
+    });
+}
+
+
+//学生名单导入成功
+const StudentSuccess = () => {
+
+    HasImportStudent();
+}
+
+
+
+//查询是否导入了学生名单
+const HasImportStudent = async () => { 
+    const params = {
+      examSubjectId: examSubjectId.value,
+      schoolId: 0,//单校 0 
+    };
+    const res = await hasImportStudent(params);
+    console.log("打印是否导入了学生名单",res);
+    if(res.code==200)
+    {
+        isImportStudent.value=res.data;
+    }
+    else{
+        isImportStudent.value=false;
+    }
+}
+
+//获取扫描批次列表
+const GetScanBatchList=async()=>{
+    const params = {
+      examSubjectId: examSubjectId.value,
+      schoolId: 0,//单校 0 
+    };
+    const res = await getBatchList(params);
+    if(res.code==200)
+    {
+        tableData.value=res.data.scannedWebSocketVO.data;
+        selectSchoolId.value=res.data.schoolId;//获取学校id
+        scanDataInfo.value.abnormalNum=res.data.scannedWebSocketVO.abnormalNum;//异常数量
+        scanDataInfo.value.examMissNum=res.data.scannedWebSocketVO.examMissNum;//缺考数量
+        scanDataInfo.value.scannedNum=res.data.scannedWebSocketVO.scannedNum;//已上传数量
+        scanDataInfo.value.unScanned=res.data.scannedWebSocketVO.unScanned;//未扫描数量
+        scanDataInfo.value.examTotal=res.data.scannedWebSocketVO.examTotal;//总人数
+    }
+}
+
+
+
+//计算高度的函数
+const CalculateTableHeight = () => {
+  // nextTick 确保 DOM 更新后再获取尺寸
+  nextTick(() => {
+    // window.innerHeight 是浏览器可视区域高度
+
+    // 简单算法:视窗高度 - 固定占用高度
+    let computedHeight = window.innerHeight - 136;
+    
+    // 限制最小高度,防止太矮
+    if (computedHeight < 200) {
+      computedHeight = 200;
+    }
+
+    tableHeight.value = computedHeight;
+  });
+}; 
+
+
+//更新扫描张数
+const UpdateBatchScanNumber = (batchId: any, scanNumber: Number) => {
+    const params={
+        id:batchId,
+        recordNumber: scanNumber,
+    };
+    updateScanCount(params).then((res:any)=>{
+        if(res.code==200)
+        {
+           console.log("第"+batchId+"批次更新扫描张数"+scanNumber+"成功", res);
+        }
+
+    })
+};
+// 处理扫描结果
+const HandleScanResult = (res: any) => {
+   console.log('收到扫描数据', res);
+    // 业务逻辑...
+    if (res.action == 'uploading') 
+    {
+            let batchNumber: any = '';
+            if (res.batchNumber) 
+            {
+            batchNumber = GetBatchNumber(res.batchNumber || res.data?.batchNumber);
+            }
+            const targetItem = tableData.value.find((item: any) => item.batchNo == batchNumber);
+            
+            if (typeof loadingText !== 'undefined') loadingText.value = "启动扫描仪中";
+
+            ElMessage.success("正在启动扫描仪,请稍后…");
+            if (targetItem) 
+            {
+                targetItem.uploadStatus = 0; //更新上传状态 0 开始上传
+            }
+    }
+    // 开始扫描指令
+    if (res.action == 'startScan') 
+    {
+    
+        let batchNumber = GetBatchNumber(res?.batchNumber || res.data?.batchNumber);
+        let currentItem = tableData.value.find((item: any) => item.batchNo == batchNumber);
+        console.log("打印currentItem", currentItem);
+        
+        //开始扫描指令
+        if (res.code == 200) 
+        {
+            ElMessage.success("扫描仪启动成功,开始扫描…");
+            if(typeof loadingText !== 'undefined') loadingText.value = "正在扫描中";
+        }
+        
+        if (res.code == 502) 
+        {
+            console.log("开始扫描指令502错误  未检测到纸张或者卡纸", res);
+            console.log("打印currentItem", currentItem);
+            if (currentItem) {
+                currentItem.uploadStatus = 1; //更新上传状态
+            }
+            if (typeof loadingText !== 'undefined') loadingText.value = ""; //清空上传提示
+            isScanning.value = false; //重置扫描状态
+            ElMessage.error(res.msg);
+        }
+        
+        if (res.code == 510) 
+        {
+            console.log("启动扫描失败,上传正在进行中", res);
+            isScanning.value = true; //重置扫描状态
+            ElMessage.warning(res.msg + '请勿重复点击');
+        }
+        
+        if (res.code == 509) {
+            console.log("扫描仪正在使用中", res);
+
+            if (res.msg == '未找到指定的扫描仪') {
+                isScanning.value = false; //重置扫描状态
+            }
+
+            ElMessage.warning(res.msg + ',请稍后再试!');
+            if (currentItem) {
+                currentItem.uploadStatus = 1; //更新上传状态
+            }
+        }
+    }
+    // 扫描完成指令
+    if (res.action == 'uploadFinish') {
+        console.log("上传完成uploadFinish 更新上传动画状态", res);
+        
+        // 修复:直接调用 GetBatchNumber
+        let batchNumber = GetBatchNumber(res?.batchNumber || res.data?.batchNumber);
+        let targetItem = tableData.value.find((item: any) => item.batchNo == batchNumber);
+        
+        if (typeof loadingText !== 'undefined') loadingText.value = '正在上传中';
+        
+        if (targetItem) {
+        targetItem.scannedPaperNum = res.scanNumber; //更新扫描张数
+        targetItem.failedNumber = res.failedNumber; //更新失败张数
+        
+        if (typeof UpdateBatchScanNumber === 'function') {
+            UpdateBatchScanNumber(targetItem.id, Number(targetItem.scannedPaperNum));
+        }
+        }
+        isScanning.value = false; //重置扫描状态
+    }
+
+    if (res.action == 'uploadNumber') {
+        console.log("上传完成uploadNumber 更新上传张数", res); 
+        // 逻辑已注释,保持原样
+    }
+
+    if (res.action == 'scanNumber') {
+        console.log("扫描张数scanNumber 更新扫描张数");
+        // 修复:直接调用 GetBatchNumber
+        let batchNumber = GetBatchNumber(res.batchNumber || res.data?.batchNumber);
+        let targetItem = tableData.value.find((item: any) => item.batchNo == batchNumber);
+        console.log("打印targetItem", targetItem);
+        
+        if (targetItem) {
+        targetItem.scannedPaperNum = res.number; //更新上传张数
+        targetItem.uploadStatus = 0; //更新上传状态 0 开始上传
+        }
+        
+        // 修复:确保 UpdateBatchScanNumber 已定义
+        if (targetItem && typeof UpdateBatchScanNumber === 'function') {
+            UpdateBatchScanNumber(targetItem.id, Number(targetItem.scannedPaperNum));
+        }
+    }
+
+  if (res.action == 'loadImage') {
+    console.log("加载图片loadImage 获取图片数据", res);
+    
+    // 修复:reImageList 必须是 ref
+    if (typeof reImageList !== 'undefined') {
+      reImageList.value = res.data || [];
+      reImageList.value.forEach((item: any) => {
+        item.uploadStatus = -2; 
+        item.uploadProgress = 0; 
+      });
+    }
+    
+    let batchNumber = GetBatchNumber(res.batchNumber);
+    let targetItem = tableData.value.find((item: any) => item.batchNo == batchNumber);
+    
+    console.log("打印重新上传图片列表", reImageList.value);
+    console.log("打印重新上传的批次Item", targetItem);
+    
+    if (targetItem) {
+      targetItem.uploadStatus = 0; 
+    }
+    
+    if (typeof loadingText !== 'undefined') loadingText.value = '正在上传中';
+    if (typeof uploadDialogTitle !== 'undefined') uploadDialogTitle.value = '未上传的图片';
+    if (typeof showUploadDialog !== 'undefined') showUploadDialog.value = true;
+  }
+
+  if (res.action == 'finish') {
+    console.log("图片重传finish指令", res);
+    
+    let batchNumber = GetBatchNumber(res.batchNumber);
+    let targetItem = tableData.value.find((item: any) => item.batchNo == batchNumber);
+
+    let fileIndex = res.fileIndex.split(",");
+    console.log("打印fileIndex", fileIndex);
+    
+    fileIndex.forEach((index: string) => {
+      // 修复:reImageList.value
+      let reImageItem = reImageList.value.find((item: any) => item.fileIndex == index);
+      if (reImageItem) {
+        let progress = reImageItem.uploadProgress;
+        const interval = setInterval(() => {
+          progress += 5;
+          if (progress >= 100) {
+            progress = 100;
+            clearInterval(interval);
+            // 修复:Vue3 不需要 $set,直接赋值即可触发响应式
+            reImageItem.uploadStatus = res.uploadState;
+          }
+          reImageItem.uploadProgress = progress;
+        }, 100);
+      }
+    });
+
+    // 修复:reImageCount 可能是 ref 或计算属性
+    let currentReImageCount = typeof reImageCount !== 'undefined' ? reImageCount.value : reImageList.value.length;
+    
+    if (currentReImageCount === 0) {
+      if (typeof showUploadDialog !== 'undefined') showUploadDialog.value = false;
+      if (targetItem) {
+        targetItem.uploadStatus = 1; 
+        isScanning.value = false;
+        // 修复:确保 GetBatchList 已定义
+        if (typeof GetBatchList === 'function') GetBatchList();
+      }
+    }
+  }
+
+  if (res.action == 'getFailedImage') {
+    console.log("获取失败图片getFailedImage", res);
+    let failedList = res.data || [];
+    
+    failedList.forEach((item: any) => {
+      let batchNumber = GetBatchNumber(item.batchNumber);
+      let currentItem = tableData.value.find((item1: any) => item1.batchNo == batchNumber);
+      
+      if (currentItem) {
+        if (item.failedNumber == 0) {
+          if (currentItem.scannedPaperNum != currentItem.uploadNum) {
+            currentItem.scannedPaperNum = currentItem.uploadNum;
+            if (typeof UpdateBatchScanNumber === 'function') {
+              UpdateBatchScanNumber(currentItem.id, currentItem.uploadNum);
+            }
+          }
+        } else {
+          let number = currentItem.uploadNum + item.failedNumber;
+          currentItem.scannedPaperNum = number;
+          if (typeof UpdateBatchScanNumber === 'function') {
+            UpdateBatchScanNumber(currentItem.id, number);
+          }
+        }
+      }
+    });
+  }
+
+  if (res.action == 'scanFinishBatch') {
+    if (typeof loadingText !== 'undefined') loadingText.value = '本次扫描结束';
+    console.log("本批次扫描完成scanFinishBatch 更新扫描张数", res);
+
+  } 
+
+};
+
+onMounted(() => {
+    // // 初始化连接
+    // scanCommon.init(HandleScanResult);
+    // // 监听连接状态
+    // scanCommon.watchConnection((isOnline,clientVersion) => {
+        
+    //     scanClientStates.value=isOnline;
+    //     scanClientVersion.value=clientVersion; //客户端版本号
+    //     // console.log("客户端版本",clientVersion);
+    // });
+    if (!examStore.currentExam) {
+        console.warn('当前没有选中的考试信息')
+        // 可选:如果没有数据,可以重定向回列表页或提示用户
+    }
+    HasImportStudent();//查询是否导入了学生名单
+    GetScanBatchList();//获取扫描批次列表
+    CalculateTableHeight();//初始化计算表格高度
+  
+    // 监听窗口大小变化
+    window.addEventListener('resize', CalculateTableHeight);
+});
+// 卸载时移除监听,防止内存泄漏
+onUnmounted(() => {
+  window.removeEventListener('resize', CalculateTableHeight);
+  // 组件销毁时务必停止,防止内存泄漏和后台重连
+//   scanCommon.stop();
+});
+</script>
+ 
+<style lang="scss" scoped>
+
+.page_list
+{
+    width: 100%;
+    height: 100%;
+    padding: 20px;
+    background-color: #fff;
+    border-radius: 4px;
+    box-sizing: border-box;
+
+}
+
+.scan_state_title
+{
+    font-size: 14px;
+    color:#333;
+    font-weight: 400;
+
+}
+.scan_state_open
+{   margin-left: 5px;
+    font-size: 14px;
+    font-weight: 400;
+    color: #2BC644;
+     i
+    {
+        margin-right: 5px;
+    }
+}
+
+.scan_state_close
+{
+    font-size: 14px;
+    font-weight: 400;
+    margin-left: 5px;
+    color:#F56C6C;
+    i
+    {
+        margin-right: 5px;
+    }
+    
+}
+
+
+</style>

+ 512 - 0
src/views/exam/components/paperView.vue

@@ -0,0 +1,512 @@
+<template>
+  <el-dialog
+    title=""
+    :visible.sync="showDialog"
+    class="preview_dialog"
+    :modal-append-to-body="false"
+    append-to-body
+    fullscreen
+    height="100%"
+  >
+    <div class="preview_image">
+      <div v-if="showDraw" class="preview_canvas">
+        <!-- Vue3中 ref 绑定到变量 -->
+        <!-- <ActionImage
+          ref="ActionCanvasRef"
+          :isDrag="true"
+          :paperImgUrl="currenImageUrl"
+          :drawData="currentDrawData"
+        ></ActionImage> -->
+        <div>
+            <image
+              :src="currenImageUrl"
+              alt=""></image>
+        </div>
+      </div>
+      <div v-else>
+        <img
+          :src="currenImageUrl"
+          alt=""
+          :style="{
+            transform: `scale(${zoomLevel}) rotate(${rotateAngle}deg)`,
+            transition: 'transform 0.2s',
+          }"
+        />
+      </div>
+
+      <div class="preview_close" @click="CloseDialog()">
+        <i class="el-icon-close"></i>
+      </div>
+      <div
+        class="preview_last"
+        :class="currentIndex == 0 ? 'button_diasble' : ''"
+        v-if="imageList.length > 1"
+        @click="LastImage()"
+      >
+        <i class="el-icon-arrow-left"></i>
+      </div>
+      <div
+        class="preview_next"
+        :class="currentIndex == imageList.length - 1 ? 'button_diasble' : ''"
+        v-if="imageList.length > 1"
+        @click="NextImage()"
+      >
+        <i class="el-icon-arrow-right"></i>
+      </div>
+    </div>
+    <div class="preview_tool" v-if="showTool">
+      <div
+        class="tool_item"
+        v-for="(item, index) in toolList"
+        :key="'tool_' + index"
+        @click="ToolClick(item)"
+      >
+        <div class="item_icon">
+          <i class="iconfont" :class="item.icon"></i>
+        </div>
+        <div class="item_title">
+          {{ item.title }}
+        </div>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script setup>
+import { ref, watch, onBeforeUnmount } from 'vue'
+import ActionImage from '@/components/ActionImage.vue' // 制作模板画布组件
+
+// 定义 Props
+const props = defineProps({
+  value: {
+    type: Boolean,
+    required: true,
+  }, // 是否显示弹窗
+  imageList: {
+    type: Array,
+    default: () => [],
+  },
+  index: {
+    type: Number,
+    default: 0,
+  }, // 默认显示的图片索引
+  showTool: {
+    type: Boolean,
+    default: false,
+  }, // 是否显示工具栏
+  showDraw: {
+    type: Boolean,
+    default: false,
+  }, // 是否显示绘图框
+  answerData: {
+    type: Object,
+    default: () => ({}),
+  }, // 答案
+})
+
+// 定义 Emits
+const emit = defineEmits(['input', 'GetRotateAngle'])
+
+// 响应式数据
+const currenImageUrl = ref('') // 当前图片url
+const currentPageNo = ref(1) // 当前页码 默认第一页
+const currentIndex = ref(0) // 当前图片索引
+
+const toolList = ref([
+  { title: '放大', icon: 'icon_fangda', value: '1' },
+  { title: '缩小', icon: 'icon_suoxiao', value: '2' },
+  { title: '逆时针1°', icon: 'icon_nishizhen1', value: '3' },
+  { title: '顺时针1°', icon: 'icon_shunshizhen1', value: '4' },
+  { title: '逆时针90°', icon: 'icon_nishizhen90', value: '5' },
+  { title: '顺时针90°', icon: 'icon_shunshizhen90', value: '6' },
+])
+
+const zoomLevel = ref(1) // 图片缩放比例
+const maxZoom = ref(3) // 最大缩放比例
+const minZoom = ref(0.5) // 最小缩放比例
+const zoomStep = ref(0.1) // 每次缩放的步进
+const rotateAngle = ref(0) // 图片旋转角度
+
+const currentPageInfo = ref({})
+const currentDrawData = ref([])
+
+// Ref 引用
+const ActionCanvasRef = ref(null)
+
+// Computed: showDialog
+// 在 Vue3 script setup 中,通常通过 watch props.value 或直接在使用处判断
+// 由于 el-dialog 使用 :visible.sync,我们需要一个内部状态或计算属性来双向绑定
+// 这里为了保持逻辑清晰,我们直接使用 props.value 作为显示依据,并通过 emit 更新
+// 注意:el-dialog 的 visible.sync 在 Vue3 + Element Plus 中通常改为 v-model
+// 如果使用的是 Element UI (Vue2版本库),则保持 :visible.sync 并手动处理 input 事件
+
+const showDialog = ref(props.value)
+
+// 监听 props.value 变化同步内部状态
+watch(
+  () => props.value,
+  (newVal) => {
+    showDialog.value = newVal
+    if (newVal) {
+      currentIndex.value = props.index
+      LoadPaperInfo() // 加载页面相关数据
+    }
+  }
+)
+
+// 监听 showDialog 内部变化(如点击遮罩关闭)同步回父组件
+watch(showDialog, (newVal) => {
+  emit('input', newVal)
+})
+
+// 方法定义
+
+// 关闭弹窗
+const CloseDialog = () => {
+  showDialog.value = false
+  // 关闭时发送回当前的旋转角度
+  const obj = {
+    currentIndex: currentIndex.value,
+    rotateAngle: rotateAngle.value,
+  }
+  emit('GetRotateAngle', obj)
+}
+
+// 返回角度信息
+const GetReturn = () => {
+  const obj = {
+    currentIndex: currentIndex.value,
+    rotateAngle: rotateAngle.value,
+  }
+  emit('GetRotateAngle', obj)
+}
+
+// 加载页面相关信息
+const LoadPaperInfo = () => {
+  if (!props.imageList || props.imageList.length === 0) return
+  
+  const currentItem = props.imageList[currentIndex.value]
+  if (!currentItem) return
+
+  currenImageUrl.value = currentItem.url
+  currentPageNo.value = currentItem.pageNo
+  rotateAngle.value = currentItem.rotateAngle || 0
+  
+  if (props.showDraw) {
+    LoadDrawData() // 加载画框信息
+  }
+}
+
+// 上一个图片
+const LastImage = () => {
+  if (currentIndex.value > 0) {
+    currentIndex.value--
+    LoadPaperInfo() // 加载页面相关数据
+  }
+}
+
+// 下一个图片
+const NextImage = () => {
+  if (currentIndex.value < props.imageList.length - 1) {
+    currentIndex.value++
+    LoadPaperInfo() // 加载页面相关数据
+  }
+}
+
+// 工具栏点击事件
+const ToolClick = (item) => {
+  switch (item.value) {
+    case '1':
+      ZoomIn()
+      break
+    case '2':
+      ZoomOut()
+      break
+    case '3':
+      RotateCounterClockwiseOne()
+      break
+    case '4':
+      RotateClockwiseOne()
+      break
+    case '5':
+      RotateCounterClockwise90()
+      break
+    case '6':
+      RotateClockwise90()
+      break
+  }
+}
+
+// 放大图片
+const ZoomIn = () => {
+  if (zoomLevel.value < maxZoom.value) {
+    zoomLevel.value += zoomStep.value
+  }
+  console.log('打印当前缩放的级别', zoomLevel.value)
+}
+
+// 缩小图片
+const ZoomOut = () => {
+  if (zoomLevel.value > minZoom.value) {
+    zoomLevel.value -= zoomStep.value
+  }
+}
+
+// 逆时针旋转1度
+const RotateCounterClockwiseOne = () => {
+  rotateAngle.value -= 1
+  GetReturn() // 获取返回角度
+}
+
+// 顺时针旋转1度
+const RotateClockwiseOne = () => {
+  rotateAngle.value += 1
+  GetReturn() // 获取返回角度
+}
+
+// 逆时针旋转90度
+const RotateCounterClockwise90 = () => {
+  rotateAngle.value -= 90
+  GetReturn() // 获取返回角度
+}
+
+// 顺时针旋转90度
+const RotateClockwise90 = () => {
+  rotateAngle.value += 90
+  GetReturn() // 获取返回角度
+}
+
+// 加载绘图数据
+const LoadDrawData = () => {
+  try {
+    const templateDataStr = localStorage.getItem('templateData')
+    const usedCardTypeStr = localStorage.getItem('usedCardType') // 卡类型
+    
+    if (!templateDataStr) return
+    
+    const templateData = JSON.parse(templateDataStr)
+    const usedCardType = usedCardTypeStr ? parseInt(usedCardTypeStr) : null
+    
+    console.log('打印templateData模板信息', templateData)
+    
+    const currentTemplateItem = templateData.find(
+      (item) => item.pageNum == currentPageNo.value
+    )
+    
+    if (!currentTemplateItem) {
+      console.warn('未找到当前页码对应的模板信息', currentPageNo.value)
+      return
+    }
+
+    console.log('打印当前页的模板信息', currentTemplateItem)
+    
+    let drawList = []
+    currentDrawData.value = []
+    
+    let paperInfo = {}
+    try {
+      paperInfo = JSON.parse(currentTemplateItem.paperInfo)
+    } catch (e) {
+      console.error('解析 paperInfo 失败', e)
+    }
+
+    if (usedCardType == 1) {
+      // 系统卡
+      currentPageInfo.value = {
+        width: paperInfo.width - 30,
+        height: paperInfo.height - 25,
+      }
+    }
+
+    if (currentTemplateItem.examCardGroupVOS) {
+      // 找到客观题的切块数据 (blockArea == 8)
+      const list = currentTemplateItem.examCardGroupVOS.filter(
+        (item) => item.blockArea == 8
+      )
+      
+      list.forEach((item) => {
+        if (item.position) {
+          let position = {}
+          try {
+            position = JSON.parse(item.position)
+          } catch (e) {
+            console.error('解析 position 失败', e)
+            return
+          }
+          
+          const unit = 'px' // 默认px单位
+          let topiclist = []
+          
+          if (item.groupTopicList) {
+            // 处理答案数据
+            item.groupTopicList.forEach((topic) => {
+              const answerValue = props.answerData[topic.topicName]
+              
+              if (answerValue) {
+                // 将多个字母拆分成数组(兼容多种分隔符)
+                const answerLetters = answerValue.match(/[A-Za-z]/g) || []
+                
+                answerLetters.forEach((letter) => {
+                  let answerIndex = GetAnswerIndexToLetter(letter)
+
+                  if (topic.topicType == 3) {
+                    // 判断题
+                    answerIndex = GetAnswerIndexToLetterTF(letter)
+                  }
+                  
+                  if (answerIndex !== -1 && topic.answerList[answerIndex]) {
+                    topic.answerList[answerIndex].isCheck = true
+                  }
+                })
+              }
+            })
+            topiclist = item.groupTopicList
+          }
+
+          const obj = {
+            x: position.x,
+            y: position.y,
+            w: position.w,
+            h: position.h,
+            position: item.position,
+            blockArea: item.blockArea,
+            page: currentPageNo.value,
+            blockName: item.name,
+            id: item.groupId,
+            unit: unit, // 单位
+            usedCardType: usedCardType,
+            areaOption: item.groupConfig, // 添加选择题设置信息
+            topiclist: topiclist,
+          }
+          drawList.push(obj)
+        }
+      })
+
+      // 找到选做题的切块数据 (blockArea == 9)
+      const list2 = currentTemplateItem.examCardGroupVOS.filter(
+        (item) => item.blockArea == 9
+      )
+      
+      list2.forEach((item) => {
+        if (item.position) {
+          let position = {}
+          try {
+            position = JSON.parse(item.position)
+          } catch (e) {
+            console.error('解析 position 失败', e)
+            return
+          }
+          
+          const unit = 'px' // 默认px单位
+          let topiclist = []
+          
+          if (item.groupTopicList) {
+            // 处理答案数据
+            item.groupTopicList.forEach((topic) => {
+              const answerValue = props.answerData[topic.topicName]
+              
+              // 对于选做题,只需要检查值是否存在且不是null或undefined
+              if (answerValue !== undefined && answerValue !== null) {
+                // 检查answerValue是否为有效数字
+                if (!isNaN(answerValue) && answerValue !== '') {
+                  const answerIndex = answerValue - 1
+                  if (answerIndex >= 0 && topic.answerList[answerIndex]) {
+                    topic.answerList[answerIndex].isCheck = true
+                  }
+                }
+              }
+            })
+            topiclist = item.groupTopicList
+          }
+
+          const obj = {
+            x: position.x,
+            y: position.y,
+            w: position.w,
+            h: position.h,
+            position: item.position,
+            blockArea: item.blockArea,
+            page: currentPageNo.value,
+            blockName: item.name,
+            id: item.groupId,
+            unit: unit, // 单位
+            usedCardType: usedCardType,
+            areaOption: item.groupConfig, // 添加选择题设置信息
+            topiclist: topiclist,
+          }
+          drawList.push(obj)
+        }
+      })
+      
+      console.log('打印选做题drawList', drawList)
+    }
+    
+    currentDrawData.value = drawList
+  } catch (error) {
+    console.error('LoadDrawData 错误:', error)
+  }
+}
+
+// 根据字母转换成索引
+const GetAnswerIndexToLetter = (value) => {
+  const letters = [
+    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
+    'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+  ]
+  return letters.findIndex((letter) => letter == value)
+}
+
+// 判断题的
+const GetAnswerIndexToLetterTF = (value) => {
+  const letters = ['T', 'F']
+  return letters.findIndex((letter) => letter == value)
+}
+
+// 生命周期
+// mounted 逻辑已在 watch(props.value) 中处理初始加载
+// 如果需要初始加载且 value 默认为 true,可以在这里调用
+// onMounted(() => {
+//   if (props.value) {
+//     currentIndex.value = props.index
+//     LoadPaperInfo()
+//   }
+// })
+
+onBeforeUnmount(() => {
+  // 清理工作,如果有定时器或事件监听器在此移除
+})
+</script>
+
+<style lang="scss" scoped>
+.preview_image {
+  width: 100%;
+  height: calc(100% - 20px);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: 0;
+  position: relative;
+  background-color: red;
+}
+.preview_canvas {
+  width: 100%;
+  height: 100%;
+  .main_container {
+    width: calc(100% - 240px);
+    margin: auto;
+    height: 100%;
+    position: relative;
+    background: transparent;
+    user-select: none;
+    overflow: hidden;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    // border:1px solid red;
+
+    .paper_template {
+      position: absolute;
+    }
+  }
+}
+</style>

+ 6 - 0
src/views/exam/components/scanButton.vue

@@ -61,7 +61,13 @@ export default {
         //画一个环行进度条圆圈
         DrawCanvas()
         {
+
+            
             this.canvas=document.getElementById('canvasProcess');
+            if(this.canvas==null)
+            {
+                return;
+            }
             let ctx=this.canvas.getContext('2d');
             //创建一个圆锥渐变对象
             let g = ctx.createConicGradient(0, 55, 55);

+ 333 - 31
src/views/exam/scanDetail.vue

@@ -4,17 +4,17 @@
     
     <div class="search_content">
         <div class="content_left">
-            <el-select  v-model="params.batchNo"   placeholder="选择批次" @change="GoSearch()" class="select_width" >
+            <el-select  v-model="paramInfo.batchNo"   placeholder="选择批次" @change="GoSearch()" class="select_width" >
                 <el-option label="全部批次" value=""></el-option>
                 <el-option v-for="item in batchList" :key="item.value"  :label="'批次'+item.label" :value="item.value"></el-option>
             </el-select>
-            <el-select  v-model="params.statusStr"   placeholder="选择状态" @change="GoSearch()" class="select_width" >
+            <el-select  v-model="paramInfo.statusStr"   placeholder="选择状态" @change="GoSearch()" class="select_width" >
                 <el-option label="全部状态" value=""></el-option>
                 <el-option v-for="item in stateList"
                 :key="item.value"
                 :label="item.label" :value="item.value"></el-option>
             </el-select>
-            <el-input placeholder="考试名称,编号"  v-model="params.keyWord" @input="GoSearch" @change="GoSearch()" class="input_width" > 
+            <el-input placeholder="考试名称,编号"  v-model="paramInfo.keyWord" @input="GoSearch" @change="GoSearch()" class="input_width" > 
                 <el-button @click="GoSearch()"  slot="append" icon="el-icon-search"></el-button>
             </el-input>
         </div>
@@ -43,7 +43,7 @@
                 </div>
             </div>
             <div class="page_jg_20"></div>
-            <div class="page_table">
+            <div class="page_table" v-show="listMode=='list'">
                 <el-table :data="tableData" style="width: 100%" :height="tableHeight">
                     <el-table-column type="index" label="序号" width="100" align="center" >
                     </el-table-column>
@@ -66,44 +66,78 @@
                     </el-table-column>
                     <el-table-column prop="studentName" label="页数" width="120" align="center">
                         <template v-slot="scope">
-                            <span v-if="scope.row.studentName">{{ scope.row.studentName}}</span>
-                            <span v-else>-</span>
+                           {{GetPageNumbers(scope.row.scanPictureVOS)}}
                         </template>
                     </el-table-column>
                     <el-table-column prop="date" label="客观题" align="center">
                         <template v-slot="scope">
-                            
+                            {{GetObjectAnswer(scope.row.objectiveQuestionMap)}}
                         </template>
                     </el-table-column>
                     <el-table-column prop="fullScore" label="状态" width="120" align="center">
                         <template v-slot="scope">
-                            <div class="full_mark_input">
-                                <el-input v-model="scope.row.fullScore" maxlength="3" @input="(val: any) => onScoreInput(scope.row, 'fullScore', val)"></el-input>
+                            <div class="table_row_status">
+                                <div class="normal_icon" v-if="scope.row.abnormalType==0"></div>
+                                <div class="abnormal_icon" v-else></div>
+                                {{GetStateName(scope.row.abnormalType)}}
                             </div>
                         </template>
                     </el-table-column>
                     <el-table-column prop="name" label="操作" width="150" align="center">
                         <template v-slot="scope">
                             <div class="ele_button table_row_button">
-                                <span class="btn_editor">编辑</span>
-                                <span class="btn_delete" @click="DeleteSingle(scope.row)">删除</span>
+                                <span class="btn_editor" @click="OpenViewPaper(scope.row)">查看</span>
+                                <!-- <span class="btn_delete" @click="DeleteSingle(scope.row)">删除</span> -->
                             </div>
                     </template>
                     </el-table-column>
                 </el-table>
             </div>
+            <div class="image_list" v-show="listMode=='pic'"  :style="{height:imageHeight+'px'}" >
+                <div class="list_img_content" v-loading="loading" element-loading-text="加载中……" element-loading-spinner="el-icon-loading" element-loading-background="#ffffff">
+                    <div v-for="(image, index) in imageData" :key="'image_'+index" class="list_img_item">
+                        <div class="item_batch_name">
+                            <span><el-checkbox  v-model="image.checked" style="margin-right: 5px;"></el-checkbox>  批次{{image.batchNo}}-{{image.seqNumber}}     </span>
+                            
+                            <!-- <span class="" v-if="image.abnormalType==0">正常</span> -->
+                            <span class="name_abnormal" v-if="image.abnormalType==4">定位异常</span>
+                            <span class="name_abnormal" v-if="image.abnormalType==5">缺页异常</span>
+                            <span class="name_abnormal" v-if="image.abnormalType==1 || image.abnormalType==2 || image.abnormalType==3">考号异常</span>
+                        </div>
+                        <div class="item_batch_img" :class="image.abnormalType!=0 && image.abnormalType!=null?'item_batch_img_error':''" >
+                            <img :src="image.recognizeUrl" alt=""  loading="lazy"></img>
+                            <div class="button_group">
+                                <div class="pic_button_view" @click="OpenViewPaper(image)">
+                                    <i class="iconfont icon_xianshimima"></i>查看
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="page_pagination">
+                <el-pagination background
+                    :page-size="pageInfo.pageSize"
+                    :pager-count="11"
+                    layout="prev, pager, next"
+                    :total="pageInfo.total"
+                    :current-page="pageInfo.pageNum" 
+                    @current-change="HandlePageChange"
+                />
+            </div>
         </div>
     </div>
-    <SelectStudent v-model="showSelectStudent"  @success="StudentSuccess"></SelectStudent>
+    <PaperView v-model="showPaperDialog" :imageList="PaperImageList"></PaperView>
   </div>
 </template>
 <script lang="ts" setup>
 import { useExamStore } from '@/store/exam'
 import { useRouter } from 'vue-router'
-import { onMounted ,ref,computed} from 'vue';
+import { onMounted ,ref,computed,nextTick,onUnmounted} from 'vue';
 import { ElMessageBox, ElMessage } from 'element-plus'
-import SelectStudent from './components/selectStudent.vue'
-import { getBatchDetailList } from '@/api/exam'
+
+import { getBatchDetailList,getBatchDetailImage } from '@/api/exam'
+import PaperView from './components/PaperView.vue'
 
 // 定义选项接口类型
 interface OptionItem {
@@ -120,11 +154,16 @@ const examSubjectId = computed(() => {
   return examStore.currentExam?.id
 })//计算属性
 
-const params=ref({
+const paramInfo=ref({
     batchNo:'',
     statusStr:'',//状态
     keyWord:''
-})
+})//筛选数据
+const pageInfo=ref({
+    pageNum:1,
+    pageSize:10,
+    total:0
+})//分页数据
 
 const batchList = ref<OptionItem[]>([])
 const stateList = ref<OptionItem[]>([])
@@ -133,13 +172,18 @@ const stateList = ref<OptionItem[]>([])
 const listMode=ref('list');
 
 const tableData=ref([]);
+const imageData=ref([]);//图片数据
+const loading=ref(false); //加载状态
+
 
 const tableHeight=ref(500);
+const imageHeight=ref(500);
 
 
 const isImportStudent=ref(false);//是否导入了学生名单
 const showSelectStudent=ref(false);//是否显示选择学生名单弹窗
-
+const showPaperDialog=ref(false);//显示图片详情弹窗
+const PaperImageList=ref([]);//图片列表
 // 打开导入学生名单
 const OpenImportStudent = () => {
     showSelectStudent.value=true;
@@ -155,10 +199,66 @@ const GotoScanHome = () => {
     });
 }
 
+//获取扫描批次的页数
+const GetPageNumbers=(scanPictureVOS:any):string=>{
+    if (scanPictureVOS && Array.isArray(scanPictureVOS) && scanPictureVOS.length > 0) {
+        return scanPictureVOS
+            .filter(item => item.pageNo !== -1)
+            .map(item => item.pageNo === 99 ? '-' : item.pageNo)
+            .join(',');
+    }
+    return '-';
+}
+
+//将答案对象转成字符串
+const GetObjectAnswer=(obj:any):string=>{
+            
+    let str=Object.entries(obj).map(([key, value]) => {
+        return `${key}:${value}`
+    }).join(' ')
+    return str
+}
+
+//获取状态名称
+const GetStateName=(value:number):string=>{
+    console.log("打印状态值",value);
+                        
+    let stateName='';
+    if(value==0)
+    {
+        stateName='已上传';
+    }
+    else if(value==1)
+    {
+        // stateName='考号未识别出来';
+        stateName='考号异常';
+    }
+    else if(value==2)
+    {
+        // stateName='无此考生';
+        stateName='考号异常';
+    }
+    else if(value==3)
+    {
+        // stateName='考号重复';
+        stateName='考号异常';
+    }
+    else if(value==4)
+    {
+        stateName='定位异常';
+    }
+    else if(value==5)
+    {
+        stateName='缺页异常';
+    }
+    return stateName;
+}
+
 
 //切换模式
 const ChangeMode = (mode: string) => {
     listMode.value = mode;
+    LoadData();
 }
 
 //加载数据
@@ -169,7 +269,7 @@ const LoadData = () => {
     }
     else if(listMode.value=='pic')
     {   
-        GetScanDetailList();
+        GetScanDetailImage();
     }
     
 }
@@ -177,15 +277,20 @@ const LoadData = () => {
 
 
 
-//学生名单导入成功
-const StudentSuccess = () => {
-
+//查看图片
+const OpenViewPaper = (image:any) => {
 
+    console.log("查看图片",image);
+    let imageUrl=image.scanPictureVOS[0].recognizeUrl;
+    PaperImageList.value.push(imageUrl);
+    showPaperDialog.value=true;
 }
 
-
-
-
+//切换分页
+const HandlePageChange=(page:number)=>{
+    pageInfo.value.pageNum=page;
+    LoadData();
+}
 
 //搜索
 const GoSearch = () => { 
@@ -198,16 +303,34 @@ const Refresh=() => {
 }
 
 
+//计算高度的函数
+const CalculateTableHeight = () => {
+  // nextTick 确保 DOM 更新后再获取尺寸
+  nextTick(() => {
+    // window.innerHeight 是浏览器可视区域高度
+
+    // 简单算法:视窗高度 - 固定占用高度
+    let computedHeight = window.innerHeight - 245;
+    
+    // 限制最小高度,防止太矮
+    if (computedHeight < 200) {
+      computedHeight = 200;
+    }
+    tableHeight.value = computedHeight;
+    imageHeight.value = computedHeight;
+  });
+}; 
+
 //获取扫描详情 列表模式
 const GetScanDetailList = async () => {
     const params = {
       examSubjectId: examSubjectId.value,
-      batchNo:'',//批次号
-      statusStr:'',//状态
-      nameCode:'',//搜索条件 姓名 考号
+      batchNo:paramInfo.value.batchNo,//批次号
+      statusStr:paramInfo.value.statusStr,//状态
+      nameCode:paramInfo.value.keyWord,//搜索条件 姓名 考号
       schoolId: 0,//单校 0 
-      pageNum:1,//当前页码
-      pageSize:10,//每页条数
+      pageNum:pageInfo.value.pageNum,//当前页码
+      pageSize:pageInfo.value.pageSize,//每页条数
     };
     const res = await getBatchDetailList(params);
     console.log("打印批次详情列表",res);
@@ -216,6 +339,32 @@ const GetScanDetailList = async () => {
        batchList.value=res.data.batchSelects;
        stateList.value=res.data.statusSelects;
        tableData.value=res.data.pageResult.records;
+       pageInfo.value.total=Number(res.data.pageResult.total);
+    }
+    else
+    {
+        ElMessage.error(res.msg);
+    }
+}
+
+//获取扫描详情 图片模式
+const GetScanDetailImage = async () => {
+    const params = {
+      examSubjectId: examSubjectId.value,
+      batchNo:paramInfo.value.batchNo,//批次号
+      statusStr:paramInfo.value.statusStr,//状态
+      nameCode:paramInfo.value.keyWord,//搜索条件 姓名 考号
+      schoolId: 0,//单校 0 
+      pageNum:pageInfo.value.pageNum,//当前页码
+      pageSize:pageInfo.value.pageSize,//每页条数
+    };
+    const res = await getBatchDetailImage(params);
+    console.log("打印批次详情列表",res);
+    if(res.code==200)
+    {
+      
+       imageData.value=res.data.records;
+       pageInfo.value.total=Number(res.data.total);
     }
     else
     {
@@ -232,8 +381,15 @@ onMounted(() => {
         // 可选:如果没有数据,可以重定向回列表页或提示用户
     }
     LoadData();//加载数据
-  
+    CalculateTableHeight();//初始化计算表格高度
+    // 监听窗口大小变化
+    window.addEventListener('resize', CalculateTableHeight);
 })
+// 卸载时移除监听,防止内存泄漏
+onUnmounted(() => {
+  window.removeEventListener('resize', CalculateTableHeight);
+
+});
 </script>
  
 <style lang="scss" scoped>
@@ -249,6 +405,152 @@ onMounted(() => {
 
 }
 
+.image_list {
+    width: 100%;
+    overflow: auto;
+
+    .list_img_content {
+      display: flex;
+      justify-content: flex-start;
+      align-items: flex-start;
+      /* 顶部对齐 */
+      box-sizing: border-box;
+      flex-wrap: wrap;
+      gap: 16px;
+
+      .list_img_item {
+        /* 基础宽度 - 每行7个 */
+        flex: 0 0 calc((100% - 16px * 6) / 7);
+
+        /* 最小宽度 */
+        min-width: 160px;
+        /* 保持宽高比(可选) */
+        // aspect-ratio: 1/1;
+        // margin-right: 15px;
+        // margin-bottom: 16px;
+        /* 内容样式 */
+
+        height: auto;
+
+        // margin-right: 13px;
+        .item_batch_name {
+          width: 100%;
+          font-size: 14px;
+          color: #666666;
+          text-align: left;
+
+          display: flex;
+          justify-content: space-between;
+          line-height: 30px;
+
+          .name_abnormal {
+            color: #F56C6C;
+          }
+        }
+
+        .item_batch_img {
+          width: 100%;
+          height: auto;
+          padding: 8px;
+          box-sizing: border-box;
+          background-color: #F0F4F8;
+          border-radius: 6px 6px 6px 6px;
+          border: 1px solid #EBEEF5;
+          display: flex;
+          justify-content: center;
+          position: relative;
+
+          img {
+            width: 100%;
+            height: auto;
+            object-fit: cover;
+            z-index: 9;
+          }
+
+        }
+
+        .item_batch_img_error {
+          border: 1px solid #F56C6C !important;
+        }
+
+        .item_batch_img:hover .button_group {
+          display: flex;
+          justify-content: center;
+          align-items: center;
+        }
+
+        .button_group {
+          position: absolute;
+
+          width: 100%;
+          height: 100%;
+          z-index: 10;
+          top: 0px;
+          left: 0;
+          background: rgba(0, 0, 0, 0.4);
+          display: none;
+          align-items: center;
+          justify-content: center;
+          gap: 16px;
+          z-index: 999;
+
+          .pic_button_view {
+            width: 68px;
+            height: 34px;
+            text-align: center;
+            line-height: 34px;
+
+            cursor: pointer;
+            background-color: rgba(255, 255, 255, 1);
+            border: 1px solid rgba(220, 223, 230, 1);
+            border-radius: 4px;
+            color: #666666;
+
+            i {
+              margin-right: 5px;
+            }
+
+            font-size: 14px;
+          }
+
+
+        }
+      }
+
+      @media (max-width: 1458px) {
+        .list_img_item {
+          flex: 0 0 calc((100% - 16px * 5) / 6);
+        }
+
+      }
+
+      @media (max-width: 1280px) {
+        .list_img_item {
+          flex: 0 0 calc((100% - 16px * 4) / 5);
+        }
+
+      }
+
+      @media (max-width: 1109px) {
+        .list_img_item {
+          flex: 0 0 calc((100% - 16px * 3) / 4);
+        }
+
+      }
+
+      @media (max-width: 940px) {
+        .list_img_item {
+          flex: 0 0 calc((100% - 16px * 2) / 3);
+        }
+
+      }
+
+
+    }
+
+
+
 
+  }
 
 </style>

+ 7 - 12
src/views/exam/scanList.vue

@@ -153,6 +153,7 @@
         </div>
     </div>
     <SelectStudent v-model="showSelectStudent"  @success="StudentSuccess"></SelectStudent>
+    <AbnormalDetail v-model="showAbnormalDialog"></AbnormalDetail>
   </div>
 </template>
 <script lang="ts" setup>
@@ -166,6 +167,8 @@ import { hasImportStudent,getBatchList,getCurrentBatchNo,deleteBatch,updateScanC
 import scanCommon from '@/utils/scanCommon';
 import { ElMessageBox, ElMessage } from 'element-plus'
 import { formatTimestamp } from '@/utils/common';
+import AbnormalDetail from './abnormalDetail.vue';
+
 // 实例化 Store
 const examStore = useExamStore()
 const userStore = useUserStore()
@@ -187,6 +190,7 @@ const currentUserName=computed(() => {
 })
 
 const selectSchoolId=ref(0);//学校ID
+const showAbnormalDialog=ref(false);//异常弹窗
 
 const params=ref({
     batchNo:'',
@@ -422,23 +426,14 @@ const OptionDelete=(row: any) => {
 //跳转到异常详情页
 const GotoDetail = (type: number) => {
 
+    console.log('跳转到异常详情页', type);
     // 根据类型跳转到不同的详情页
     switch (type) {
         case 0:
-            router.push({
-                path: '/exam/noScanList',
-                query: {
-                    examSubjectId: examSubjectId.value,
-                },
-            });
+            showAbnormalDialog.value=true;
             break;
         case 1:
-            router.push({
-                path: '/exam/uploadedList',
-                query: {
-                    examSubjectId: examSubjectId.value,
-                },
-            })
+           
             break;
         case -1:
             router.push({