Преглед на файлове

小题、分组分析答题卡

liurongli преди 13 часа
родител
ревизия
3a8d349db6

+ 3 - 0
src/assets/icon/icon_all_right.svg

@@ -0,0 +1,3 @@
+<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M37.4047 3.81226C36.9214 3.81228 35.9155 3.88671 35.4495 4.11778C34.9834 4.34885 34.6775 4.54104 33.8318 5.06107C24.5211 11.8972 18.5767 20.1449 11.7319 28.8675C10.3183 25.7889 8.86065 22.7343 7.23875 19.6673C6.78197 18.8181 6.30913 17.9602 5.74265 17.0726C5.36611 16.4817 4.77346 16.0719 4.08542 15.9398C3.39782 15.8073 2.67091 15.9627 2.07468 16.3665C1.47845 16.7704 1.06412 17.3878 0.932105 18.0755C0.799646 18.7635 0.960584 19.4657 1.3696 20.0345C1.83236 20.6737 2.32047 21.448 2.78464 22.2083C5.05546 25.9781 7.18938 30.0043 9.25242 33.9828C10.0142 35.6836 11.8675 35.8046 12.9975 34.3226C16.3226 29.6028 21.1801 22.8025 25.7924 17.5118C30.8303 11.7331 35.5054 7.64659 35.8223 7.33436C36.4293 6.73628 38.7192 4.74838 38.7192 4.74838C38.8567 4.49745 38.8275 4.34749 38.7192 4.1967C38.6109 4.04591 37.888 3.81223 37.4047 3.81226Z" fill="#D81E06"/>
+</svg>

+ 4 - 0
src/assets/icon/icon_all_wrong.svg

@@ -0,0 +1,4 @@
+<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.78624 2.85627C6.21927 2.51249 5.53988 2.40887 4.89661 2.56973C4.25339 2.7305 3.69899 3.14256 3.35627 3.71376C3.01355 4.28496 2.91086 4.96805 3.0717 5.61125C3.23248 6.25454 3.64362 6.80524 4.21376 7.14373C4.76706 7.472 5.33732 7.82303 5.8906 8.17389C10.9137 11.377 15.6506 15.1138 20.085 19.161C22.4012 21.2804 24.9755 23.1585 27.3157 25.3142C29.4393 27.2702 31.5122 29.3052 33.4806 31.4523C33.9413 31.9553 34.3927 32.4596 34.8411 32.9751C34.9204 33.066 35.0386 33.1234 35.164 33.1386C35.2898 33.1536 35.4124 33.1252 35.5107 33.0555C35.609 32.9859 35.6766 32.8797 35.7043 32.7561C35.7316 32.6328 35.7167 32.5022 35.6573 32.3972C35.3153 31.7965 34.9699 31.2035 34.6139 30.609C33.0948 28.0696 31.4457 25.5888 29.6634 23.2024C27.7042 20.579 25.7523 17.8822 23.3573 15.6323C18.7759 11.3416 13.8873 7.43009 8.57692 3.9782C7.99007 3.59897 7.38201 3.21731 6.78624 2.85627Z" fill="#D81E06"/>
+<path d="M37.4331 9.9987C38.2484 9.5664 38.8353 8.83633 39.0464 7.93931C39.2586 7.04402 39.0776 6.05518 38.5616 5.22013C38.0456 4.38508 37.2422 3.7808 36.3466 3.57003C35.4499 3.35753 34.5344 3.55588 33.783 4.09164C33.1508 4.54044 32.4985 5.01423 31.8757 5.47836C20.731 13.9798 10.3948 23.4607 2.40484 34.8328C1.97298 35.4645 1.54433 36.1133 1.13752 36.7574C1.07111 36.8628 1.04949 36.9944 1.07358 37.1205C1.09789 37.2468 1.16593 37.3572 1.26657 37.4304C1.36721 37.5035 1.49329 37.5341 1.6209 37.5182C1.7483 37.5022 1.86678 37.441 1.94646 37.3453C2.42593 36.7697 2.92517 36.1918 3.42609 35.6327C12.6099 25.6138 23.9025 17.7131 35.499 11.0505C36.139 10.6934 36.8015 10.3307 37.4331 9.9987Z" fill="#D81E06"/>
+</svg>

+ 4 - 0
src/assets/icon/icon_half_right.svg

@@ -0,0 +1,4 @@
+<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M37.4047 3.81226C36.9214 3.81228 35.9155 3.88671 35.4495 4.11778C34.9834 4.34885 34.6775 4.54104 33.8318 5.06107C24.5211 11.8972 18.5767 20.1449 11.7319 28.8675C10.3183 25.7889 8.86065 22.7343 7.23875 19.6673C6.78197 18.8181 6.30913 17.9602 5.74265 17.0726C5.36611 16.4817 4.77346 16.0719 4.08542 15.9398C3.39782 15.8073 2.67091 15.9627 2.07468 16.3665C1.47845 16.7704 1.06412 17.3878 0.932105 18.0755C0.799646 18.7635 0.960584 19.4657 1.3696 20.0345C1.83236 20.6737 2.32047 21.448 2.78464 22.2083C5.05546 25.9781 7.18938 30.0043 9.25242 33.9828C10.0142 35.6836 11.8675 35.8046 12.9975 34.3226C16.3226 29.6028 21.1801 22.8025 25.7924 17.5118C30.8303 11.7331 35.5054 7.64659 35.8223 7.33436C36.4293 6.73628 38.7192 4.74838 38.7192 4.74838C38.8567 4.49745 38.8275 4.34749 38.7192 4.1967C38.6109 4.04591 37.888 3.81223 37.4047 3.81226Z" fill="#D81E06"/>
+<path d="M15.5987 6.12501C15.2306 5.93799 14.8033 5.90485 14.4108 6.03289C14.0182 6.16093 13.6927 6.43966 13.5056 6.80776C13.3186 7.17586 13.2855 7.60318 13.4135 7.99571C13.5415 8.38825 13.8203 8.71384 14.1884 8.90087C14.532 9.07544 14.8766 9.26318 15.2164 9.45977C18.2752 11.2418 21.0308 13.7013 23.5618 16.3516C24.8875 17.7447 26.4769 18.9247 27.8602 20.3232C29.1143 21.5909 30.3226 22.912 31.5038 24.2647C31.781 24.5826 32.0515 24.8948 32.3269 25.2171C32.376 25.2745 32.4495 25.3109 32.5278 25.3208C32.6062 25.3305 32.6831 25.3129 32.7447 25.2694C32.8063 25.226 32.8487 25.1595 32.8659 25.0824C32.8829 25.0053 32.8733 24.9239 32.8358 24.8583C32.6233 24.4889 32.4146 24.1292 32.1982 23.7634C31.2775 22.2051 30.3149 20.6686 29.3123 19.1479C28.2095 17.4756 27.2155 15.6747 25.8171 14.205C23.1513 11.4193 20.2368 8.78673 16.7757 6.76478C16.3903 6.54175 15.996 6.32684 15.5987 6.12501Z" fill="#D81E06"/>
+</svg>

BIN
src/assets/icon/model_1.png


BIN
src/assets/icon/model_2.png


+ 793 - 0
src/components/PaperImage.vue

@@ -0,0 +1,793 @@
+<template>
+  <div
+    class="paper_container"
+    ref="paperContainer"
+    @mousedown="onMouseDown"
+    @mousemove="onMouseMove"
+    @mouseup="onMouseUp"
+    @wheel="onWheel"
+  >
+    <canvas
+      id="paperCanvas"
+      ref="paperCanvas"
+      class="paper_canvas"
+      @mouseleave="onCanvasLeave"
+      :style="{ transform: `rotate(${rotateDeg}deg)` }"
+    ></canvas>
+    <div
+      ref="imgContainer"
+      class="img_container"
+      :style="{ transform: `rotate(${rotateDeg}deg)` }"
+    >
+      <img v-if="paperImgUrl" :src="paperImgUrl" />
+    </div>
+    <div class="no_paper_url" v-if="paperImgUrl == ''">暂无数据</div>
+    <div
+      v-if="state.showContextMenu"
+      class="custom_context_menu"
+      :style="{ top: state.contextMenuY + 'px', left: state.contextMenuX + 'px' }"
+      @click="hideContextMenu"
+    >
+      <div class="menu_item" @click="handleMenuAction('download')">图片另存为</div>
+      <div class="menu_item" @click="handleMenuAction('fitScreen')">适合屏幕</div>
+      <div class="menu_item" @click="handleMenuAction('zoomIn')">放大(向上滚轮)</div>
+      <div class="menu_item" @click="handleMenuAction('zoomOut')">缩小(向下滚轮)</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, watch, onMounted, onBeforeUnmount, nextTick, getCurrentInstance } from "vue";
+import { throttle } from "lodash-es";
+import { ElLoading } from "element-plus";
+
+// ================= 资源导入 (替代 require) =================
+import iconAllWrong from "@/assets/icon/icon_all_wrong.svg";
+import iconAllRight from "@/assets/icon/icon_all_right.svg";
+import iconHalfRight from "@/assets/icon/icon_half_right.svg";
+import model2 from "@/assets/icon/model_2.png";
+import model1 from "@/assets/icon/model_1.png";
+
+// ================= 类型定义 =================
+interface PaperInfoProp {
+  width?: number;
+  height?: number;
+}
+
+interface DrawItem {
+  samplingPosition: string;
+  questionName: string;
+  score: number | string;
+  fullScore: number;
+  titleType?: number;
+  pagePaintingVOS?: any[];
+  drawLineData?: string;
+  [key: string]: any;
+}
+
+// ================= 全局实例获取 =================
+const { proxy } = getCurrentInstance()!;
+const $global = (proxy as any)?.$global;
+
+// ================= Props & Emits =================
+const props = withDefaults(
+  defineProps<{
+    drawData?: DrawItem[];
+    paperInfo?: PaperInfoProp | null;
+    paperImgUrl?: string;
+    currentId?: string;
+    isDrag?: boolean;
+    isWheel?: boolean;
+    isShowContextMenu?: boolean;
+    rotateDeg?: string;
+    isAbnormal?: boolean;
+    usedCardType?: number;
+    currentPage?: number;
+    downLoadName?: string;
+  }>(),
+  {
+    drawData: () => [],
+    paperInfo: null,
+    paperImgUrl: "",
+    currentId: "",
+    isDrag: true,
+    isWheel: true,
+    isShowContextMenu: true,
+    rotateDeg: "0",
+    isAbnormal: false,
+    usedCardType: 2,
+    currentPage: 1,
+    downLoadName: "",
+  }
+);
+
+const emit = defineEmits<{
+  (e: "GetRectPoint", point: any): void;
+}>();
+
+// ================= DOM Refs =================
+const paperContainer = ref<HTMLDivElement | null>(null);
+const paperCanvas = ref<HTMLCanvasElement | null>(null);
+const imgContainer = ref<HTMLDivElement | null>(null);
+
+// ================= 响应式状态管理 =================
+const state = reactive({
+  position: { x: 0, y: 0 },
+  startX: 0,
+  startY: 0,
+  scale: 1,
+  isDragging: false,
+  isDrawing: false,
+  drawType: 0,
+  paperImgInfo: { width: 0, height: 0 },
+  canvasInfo: { width: 0, height: 0 },
+  zoomRate: 0,
+  dpr: window.devicePixelRatio || 1,
+  minScale: 0.7,
+  maxScale: 4,
+  rectPoint: { startX: 0, startY: 0, endX: 0, endY: 0 },
+  addObjectAreaOption: {
+    derection: 1, questionType: 1, startNumber: 16, endNumber: 20,
+    interval: 1, answerNumber: 4, answerWidth: 0, answerHeight: 0,
+  },
+  currenPoint: { x: 0, y: 0, w: 0, h: 0 },
+  containerWidth: 0,
+  containerHeight: 0,
+  isInit: true,
+  showContextMenu: false,
+  contextMenuX: 0,
+  contextMenuY: 0,
+  loading: false,
+  downloading: false,
+  jHeight: 0,
+  blockList: [] as any[],
+});
+
+// ================= 非响应式内部变量 (Canvas 相关) =================
+let image: HTMLImageElement | null = null;
+let canvasCtx: CanvasRenderingContext2D | null = null;
+
+// 缓存图片
+const CacheAllWrong = new Image();
+const CacheAllRight = new Image();
+const CacheHalfRight = new Image();
+const CacheTypicalError = new Image();
+const CacheExcellentAnswer = new Image();
+
+CacheAllWrong.src = iconAllWrong;
+CacheAllRight.src = iconAllRight;
+CacheHalfRight.src = iconHalfRight;
+CacheTypicalError.src = model2;
+CacheExcellentAnswer.src = model1;
+
+// ================= 核心方法 =================
+
+// 工具方法:转成整数
+const GetInteger = (value: any): number => {
+  if (value === null || value === undefined || value === "") return 0;
+  if (typeof value === "number") return isNaN(value) ? 0 : Math.round(value);
+  if (typeof value === "string") {
+    value = value.trim();
+    if (value === "") return 0;
+    const num = Number(value);
+    return isNaN(num) ? 0 : Math.round(num);
+  }
+  const num = Number(value);
+  return isNaN(num) ? 0 : Math.round(num);
+};
+
+// 更新缩放率
+const updateZoomAndPaperInfo = () => {
+  let widthZoomRate = 1;
+  let heightZoomRate = 1;
+  if (props.rotateDeg === "-90") {
+    widthZoomRate = state.containerWidth / state.paperImgInfo.height!;
+    heightZoomRate = (state.containerHeight - 40) / state.paperImgInfo.width!;
+  } else {
+    widthZoomRate = state.containerWidth / state.paperImgInfo.width!;
+    heightZoomRate = state.containerHeight / state.paperImgInfo.height!;
+  }
+  state.zoomRate = Math.min(widthZoomRate, heightZoomRate);
+};
+
+// 更新画布尺寸
+const updateCanvasSize = () => {
+  state.canvasInfo.width = Math.round(state.paperImgInfo.width! * state.zoomRate * state.scale);
+  state.canvasInfo.height = Math.round(state.paperImgInfo.height! * state.zoomRate * state.scale);
+  if (paperCanvas.value) {
+    paperCanvas.value.width = state.canvasInfo.width;
+    paperCanvas.value.height = state.canvasInfo.height;
+  }
+};
+
+// 中心化画布
+const centerCanvas = () => {
+  state.position.x = (state.containerWidth - state.canvasInfo.width) / 2;
+  if (props.rotateDeg === "-90") {
+    state.position.y = (state.containerHeight - state.canvasInfo.height) / 2 - 40;
+  } else {
+    state.position.y = (state.containerHeight - state.canvasInfo.height) / 2;
+  }
+  if (paperCanvas.value) {
+    paperCanvas.value.style.left = `${state.position.x}px`;
+    paperCanvas.value.style.top = `${state.position.y}px`;
+  }
+  state.isInit = false;
+};
+
+// 图片宽高坐标变化
+const ImageInfoChange = () => {
+  const { width, height } = state.paperImgInfo;
+  if (imgContainer.value) {
+    imgContainer.value.style.width = width! * state.zoomRate * state.scale + "px";
+    imgContainer.value.style.height = height! * state.zoomRate * state.scale + "px";
+    imgContainer.value.style.left = state.position.x + "px";
+    imgContainer.value.style.top = state.position.y + "px";
+  }
+};
+
+// 初始数据加载
+const InitData = () => {
+  if (!paperContainer.value) return;
+  const { width, height } = paperContainer.value.getBoundingClientRect();
+  state.containerHeight = Number(height);
+  state.containerWidth = Number(width);
+
+  if (props.paperInfo?.width && props.paperInfo?.height) {
+    state.paperImgInfo.width = props.paperInfo.width;
+    state.paperImgInfo.height = props.paperInfo.height;
+    updateZoomAndPaperInfo();
+    updateCanvasSize();
+    if (state.isInit) centerCanvas();
+    loadImage();
+  } else {
+    image = new Image();
+    image.crossOrigin = "anonymous";
+    image.src = props.paperImgUrl;
+    image.onload = () => {
+      state.paperImgInfo.width = image!.width;
+      state.paperImgInfo.height = image!.height;
+      updateZoomAndPaperInfo();
+      updateCanvasSize();
+      centerCanvas();
+      loadImage();
+    };
+  }
+};
+
+// 加载图片
+const loadImage = () => {
+  setTimeout(() => {
+    state.loading = false;
+  }, 100);
+  drawImage();
+};
+
+// 绘制图片及批注
+const drawImage = () => {
+  if (!paperCanvas.value) return;
+  canvasCtx = paperCanvas.value.getContext("2d");
+  if (!canvasCtx) return;
+  
+  canvasCtx.clearRect(0, 0, state.canvasInfo.width, state.canvasInfo.height);
+  ImageInfoChange();
+  DrawDataInfo(canvasCtx, state.zoomRate, state.scale);
+};
+
+// 绘制批注信息 (核心绘图逻辑)
+const DrawDataInfo = (ctx: CanvasRenderingContext2D, zoomRate: number, scale: number) => {
+  if (!props.drawData) return;
+  for (let i = 0; i < props.drawData.length; i++) {
+    let item = props.drawData[i];
+    const point = JSON.parse(item.samplingPosition);
+    let blockPoint: any = "";
+    let blockList: any[] = [];
+    let jHeight = 0;
+    let obj = { x: point.x * zoomRate * scale, y: point.y * zoomRate * scale };
+
+    if (props.usedCardType == 1) {
+      let templateInfo = { width: 794 - 30 * 2, height: 1123 - 25 * 2 };
+      if (state.paperImgInfo.width! > state.paperImgInfo.height!) {
+        templateInfo = { width: 1588 - 30 * 2, height: 1123 - 25 * 2 };
+      }
+      let imagePoint: any = {};
+      if (item.titleType == 1) {
+        imagePoint = {
+          x: ((point.x - 30) / templateInfo.width) * state.paperImgInfo.width!,
+          y: ((point.y - 15) / templateInfo.height) * state.paperImgInfo.height!,
+        };
+      } else {
+        imagePoint = {
+          x: (point.x / templateInfo.width) * state.paperImgInfo.width!,
+          y: (point.y / templateInfo.height) * state.paperImgInfo.height!,
+        };
+      }
+      obj = { x: imagePoint.x * zoomRate * scale, y: imagePoint.y * zoomRate * scale };
+    }
+
+    if (item.pagePaintingVOS) {
+      const pointIndex = point.index || 0;
+      blockPoint = item.pagePaintingVOS[pointIndex];
+      const pointPage = point.page;
+      if (item.pagePaintingVOS.length > 1) blockList = item.pagePaintingVOS || [];
+      
+      let newBlockPoint = blockPoint;
+      if (props.usedCardType == 1) {
+        let templateInfo = { width: 794 - 30 * 2, height: 1123 - 25 * 2 };
+        if (state.paperImgInfo.width! > state.paperImgInfo.height!) {
+          templateInfo = { width: 1588 - 30 * 2, height: 1123 - 25 * 2 };
+        }
+        newBlockPoint = {
+          x: GetInteger(((blockPoint.x - 30) / templateInfo.width) * state.paperImgInfo.width!),
+          y: GetInteger(((blockPoint.y - 25) / templateInfo.height) * state.paperImgInfo.height!),
+          w: GetInteger((blockPoint.w / templateInfo.width) * state.paperImgInfo.width!),
+          h: GetInteger((blockPoint.h / templateInfo.height) * state.paperImgInfo.height!),
+          page: blockPoint.page,
+        };
+        blockPoint = newBlockPoint;
+        if (item.pagePaintingVOS.length > 1) {
+          blockList = [];
+          for (let j = 0; j < item.pagePaintingVOS.length; j++) {
+            let blockObj = item.pagePaintingVOS[j];
+            blockList.push({
+              x: GetInteger(((blockObj.x - 30) / templateInfo.width) * state.paperImgInfo.width!),
+              y: GetInteger(((blockObj.y - 25) / templateInfo.height) * state.paperImgInfo.height!),
+              w: GetInteger((blockObj.w / templateInfo.width) * state.paperImgInfo.width!),
+              h: GetInteger((blockObj.h / templateInfo.height) * state.paperImgInfo.height!),
+              page: blockObj.page,
+            });
+          }
+        }
+      }
+
+      if (pointPage != props.currentPage) jHeight = newBlockPoint.h;
+      if (blockPoint) {
+        obj.x = GetInteger(newBlockPoint.x * zoomRate * scale + obj.x);
+        obj.y = GetInteger(newBlockPoint.y * zoomRate * scale + obj.y);
+      }
+    }
+
+    ctx.fillStyle = "#D81E06";
+    ctx.textAlign = "center";
+    ctx.textBaseline = "middle";
+    
+    if (item.questionName == "总分") {
+      const fontSize = Math.max(12, 50 * scale);
+      ctx.font = `${fontSize}px Arial`;
+      ctx.textAlign = "left";
+      const text = item.score.toString();
+      const textWidth = ctx.measureText(text).width;
+      const underlineY = obj.y + 50 * zoomRate * scale;
+      const startX = obj.x + 35 * zoomRate * scale;
+      ctx.beginPath(); ctx.moveTo(startX, underlineY); ctx.lineTo(startX + textWidth, underlineY);
+      ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 4; ctx.stroke();
+      ctx.beginPath(); ctx.moveTo(startX - 20, underlineY + 20 * zoomRate * scale);
+      ctx.lineTo(startX + textWidth + 20, underlineY + 20 * zoomRate * scale);
+      ctx.stroke();
+    } else {
+      const fontSize = Math.max(12, 18 * scale);
+      ctx.font = `${fontSize}px Arial`;
+      ctx.textAlign = "left";
+      const iconX = obj.x - 20 * zoomRate * scale;
+      const iconY = obj.y - 20 * zoomRate * scale;
+      const iconWidth = 40 * zoomRate * scale;
+      const iconHeight = 40 * zoomRate * scale;
+      
+      if (item.score == 0) ctx.drawImage(CacheAllWrong, iconX, iconY, iconWidth, iconHeight);
+      else if (item.score == item.fullScore) ctx.drawImage(CacheAllRight, iconX, iconY, iconWidth, iconHeight);
+      else ctx.drawImage(CacheHalfRight, iconX, iconY, iconWidth, iconHeight);
+    }
+    ctx.fillText(item.score, obj.x + 35 * zoomRate * scale, obj.y);
+
+    if (item.drawLineData) {
+      const drawLineData = JSON.parse(item.drawLineData);
+      if (blockList.length > 0) {
+        let jh = 0;
+        blockList.forEach((blockItem: any, index: number) => {
+          if (blockItem.page == props.currentPage) {
+            drawLineData.forEach((drawlineItem: any) => {
+              let findIndex = FindBlockIndex(drawlineItem, blockList);
+              let drawItem = drawlineItem;
+              if (findIndex == index) {
+                blockPoint = blockList[findIndex];
+                const zoomScale = zoomRate * scale;
+                const offsetX = blockPoint.x * zoomScale;
+                const offsetY = blockPoint.y * zoomScale;
+                drawSwitch(drawItem, zoomScale, offsetX, offsetY, jh, ctx);
+              }
+            });
+          }
+          jh = jh + blockItem.h;
+        });
+      } else {
+        for (const drawItem of drawLineData) {
+          const zoomScale = zoomRate * scale;
+          const offsetX = blockPoint.x * zoomScale;
+          const offsetY = blockPoint.y * zoomScale;
+          drawSwitch(drawItem, zoomScale, offsetX, offsetY, 0, ctx);
+        }
+      }
+    }
+  }
+};
+
+// 提取的绘图 Switch 逻辑
+const drawSwitch = (drawItem: any, zoomScale: number, offsetX: number, offsetY: number, jh: number, ctx: CanvasRenderingContext2D) => {
+  switch (drawItem.drawType) {
+    case 1: {
+      const fontSize = Math.max(12, 40 * zoomScale);
+      ctx.font = `${fontSize}px Arial`;
+      ctx.fillStyle = "#D81E06";
+      ctx.textAlign = "center";
+      ctx.textBaseline = "middle";
+      const x = drawItem.x * zoomScale + offsetX;
+      const y = drawItem.y * zoomScale + offsetY;
+      if (drawItem.type === "reduce") ctx.fillText("-" + drawItem.score, x, y);
+      else if (drawItem.type === "bonus") ctx.fillText("+" + drawItem.score, x, y);
+      break;
+    }
+    case 2: {
+      const coords = {
+        x: GetInteger(drawItem.x * zoomScale + offsetX),
+        y: GetInteger((drawItem.y - jh) * zoomScale + offsetY),
+        endX: GetInteger(drawItem.endX * zoomScale + offsetX),
+        endY: GetInteger((drawItem.endY - jh) * zoomScale + offsetY),
+      };
+      DrawHorizontalLine(coords.x, coords.y, coords.endX, coords.endY, ctx);
+      break;
+    }
+    case 3: {
+      const coords = {
+        startX: parseFloat((drawItem.x * zoomScale + offsetX).toFixed(2)),
+        startY: parseFloat(((drawItem.y - jh) * zoomScale + offsetY).toFixed(2)),
+        endX: parseFloat((drawItem.endX * zoomScale + offsetX).toFixed(2)),
+        endY: parseFloat(((drawItem.endY - jh) * zoomScale + offsetY).toFixed(2)),
+      };
+      DrawWaveLine(coords.startX, coords.startY, coords.endX, coords.endY, ctx);
+      break;
+    }
+    case 4: DrawPenLine(drawItem, offsetX, offsetY, zoomScale, ctx); break;
+    case 5: {
+      const coords = { x: drawItem.x * zoomScale + offsetX, y: drawItem.y * zoomScale + offsetY };
+      DrawText(coords.x, coords.y, drawItem.text, ctx);
+      break;
+    }
+    case 6: {
+      const coords = { x: drawItem.x * zoomScale + offsetX, y: drawItem.y * zoomScale + offsetY };
+      DrawText(coords.x, coords.y, "✔", ctx);
+      break;
+    }
+    case 7: {
+      const coords = { x: drawItem.x * zoomScale + offsetX, y: drawItem.y * zoomScale + offsetY };
+      DrawText(coords.x, coords.y, "✘", ctx);
+      break;
+    }
+  }
+};
+
+// 绘图辅助方法
+const FindBlockIndex = (targetObj: any, arr: any[]) => {
+  let cumulativeHeight = 0;
+  for (let i = 0; i < arr.length; i++) {
+    const item = arr[i];
+    if (targetObj.drawType == 4) {
+      if (targetObj.drawlineData[0].y >= cumulativeHeight && targetObj.drawlineData[0].y < cumulativeHeight + item.h) return i;
+    } else {
+      if (targetObj.y >= cumulativeHeight && targetObj.y < cumulativeHeight + item.h) return i;
+    }
+    cumulativeHeight += item.h;
+  }
+  return -1;
+};
+
+const DrawHorizontalLine = (x1: number, y1: number, x2: number, y2: number, ctx: CanvasRenderingContext2D | null = null) => {
+  const c = ctx || (paperCanvas.value?.getContext("2d") as CanvasRenderingContext2D);
+  c.strokeStyle = "red"; c.lineWidth = 2; c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke();
+};
+
+const DrawWaveLine = (startX: number, startY: number, endX: number, endY: number, ctx: CanvasRenderingContext2D | null = null) => {
+  const amplitude = 2, frequency = 0.8;
+  const dx = endX - startX, dy = endY - startY;
+  const c = ctx || (paperCanvas.value?.getContext("2d") as CanvasRenderingContext2D);
+  c.strokeStyle = "red"; c.lineWidth = 2; c.beginPath(); c.moveTo(startX, startY);
+  for (let x = startX; x <= endX; x += 1) {
+    const y = startY + (dy * (x - startX)) / dx + amplitude * Math.sin(frequency * (x - startX));
+    c.lineTo(x, y);
+  }
+  c.stroke();
+};
+
+const DrawPenLine = (data: any, offsetX: number, offsetY: number, zoomScale: number, ctx: CanvasRenderingContext2D | null = null) => {
+  const linelist = data.drawlineData;
+  if (linelist.length > 0) {
+    const c = ctx || (paperCanvas.value?.getContext("2d") as CanvasRenderingContext2D);
+    c.strokeStyle = "red"; c.lineWidth = 2; c.beginPath();
+    for (let i = 0; i < linelist.length; i++) {
+      let x = parseFloat((linelist[i].x * zoomScale + offsetX).toFixed(2));
+      let y = parseFloat((linelist[i].y * zoomScale + offsetY).toFixed(2));
+      c.lineTo(x, y);
+    }
+    c.stroke(); c.closePath();
+  }
+};
+
+const DrawText = (startX: number, startY: number, text: string, ctx: CanvasRenderingContext2D | null = null) => {
+  const c = ctx || (paperCanvas.value?.getContext("2d") as CanvasRenderingContext2D);
+  c.font = "15px Arial"; c.fillStyle = "red"; c.textAlign = "left";
+  c.fillText(text, startX, startY);
+};
+
+// 事件处理
+const handleRightClick = (event: MouseEvent) => {
+  if (!props.isShowContextMenu || !paperContainer.value) return;
+  event.preventDefault(); event.stopPropagation();
+  const containerRect = paperContainer.value.getBoundingClientRect();
+  state.contextMenuX = event.clientX - containerRect.left;
+  state.contextMenuY = event.clientY - containerRect.top;
+  state.showContextMenu = true;
+};
+
+const handleGlobalEvent = (event: MouseEvent) => {
+  const menuElement = document.querySelector(".custom_context_menu");
+  if (state.showContextMenu && menuElement && !menuElement.contains(event.target as Node)) {
+    hideContextMenu();
+  }
+};
+
+const hideContextMenu = () => { state.showContextMenu = false; };
+
+const handleMenuAction = (action: string) => {
+  switch (action) {
+    case "zoomIn":
+      state.scale = Math.min(state.maxScale, state.scale + 0.1);
+      ImageInfoChange(); updateCanvasSize(); drawImage(); break;
+    case "zoomOut":
+      state.scale = Math.max(state.minScale, state.scale - 0.1);
+      ImageInfoChange(); updateCanvasSize(); drawImage(); break;
+    case "fitScreen": fitScreen(); break;
+    case "download": downloadImage(); break;
+  }
+  hideContextMenu();
+};
+
+const downloadImage = () => {
+  const loadingInstance = ElLoading.service({
+    lock: true, text: "正在下载中,请稍后...", background: "rgba(0, 0, 0, 0.7)",
+  });
+  
+  const exportCanvas = document.createElement("canvas");
+  const exportCtx = exportCanvas.getContext("2d")!;
+  exportCanvas.width = state.paperImgInfo.width!;
+  exportCanvas.height = state.paperImgInfo.height!;
+  
+  if (image) exportCtx.drawImage(image, 0, 0, state.paperImgInfo.width!, state.paperImgInfo.height!);
+  DrawDataInfo(exportCtx, 1, 1);
+  
+  const imgUrl = exportCanvas.toDataURL("image/png");
+  let link = document.createElement("a");
+  link.href = imgUrl;
+  link.download = props.downLoadName + ".png";
+  document.body.appendChild(link); link.click(); document.body.removeChild(link);
+  
+  nextTick(() => {
+    setTimeout(() => loadingInstance.close(), 2000);
+  });
+};
+
+const fitScreen = () => {
+  state.scale = 1; state.isInit = true; InitData();
+};
+
+const onGlobalMouseUp = () => { state.isDragging = false; };
+
+const onMouseDown = (event: MouseEvent) => {
+  if (event.button !== 0) { state.isDragging = false; return; }
+  state.isDragging = false;
+  if (state.drawType == 0 && props.isDrag) {
+    state.isDragging = true;
+    state.startX = event.clientX - state.position.x;
+    state.startY = event.clientY - state.position.y;
+  }
+  if (state.drawType == 1 && (event.target as HTMLElement).id != "paperCanvas") {
+    state.isDragging = true;
+    state.startX = event.clientX - state.position.x;
+    state.startY = event.clientY - state.position.y;
+  }
+};
+
+const onMouseMove = (event: MouseEvent) => {
+  if (state.isDragging) {
+    state.position.x = event.clientX - state.startX;
+    state.position.y = event.clientY - state.startY;
+    if (paperCanvas.value) {
+      paperCanvas.value.style.left = `${state.position.x}px`;
+      paperCanvas.value.style.top = `${state.position.y}px`;
+    }
+    ImageInfoChange();
+  }
+};
+
+const onMouseUp = (event: MouseEvent) => {
+  if (event && event.button !== 0) return;
+  state.isDragging = false;
+};
+
+const onWheel = (event: WheelEvent) => {
+  if (!props.isWheel || !paperContainer.value) return;
+  event.preventDefault();
+  const delta = event.deltaY < 0 ? 1 : -1;
+  const newScale = state.scale + delta * 0.1;
+  const clampedScale = Math.max(state.minScale, Math.min(state.maxScale, newScale));
+  if (clampedScale === state.scale) return;
+  
+  const containerRect = paperContainer.value.getBoundingClientRect();
+  const mouseX = event.clientX - containerRect.left;
+  const mouseY = event.clientY - containerRect.top;
+  const mouseRelativeToCanvasX = (mouseX - state.position.x) / state.scale;
+  const mouseRelativeToCanvasY = (mouseY - state.position.y) / state.scale;
+  
+  state.scale = clampedScale;
+  updateCanvasSize();
+  state.position.x = mouseX - mouseRelativeToCanvasX * state.scale;
+  state.position.y = mouseY - mouseRelativeToCanvasY * state.scale;
+  
+  if (paperCanvas.value) {
+    paperCanvas.value.style.left = `${state.position.x}px`;
+    paperCanvas.value.style.top = `${state.position.y}px`;
+  }
+  ImageInfoChange(); drawImage();
+};
+
+// 框选相关
+const onCanvasDown = (e: MouseEvent) => {
+  if (!paperCanvas.value) return;
+  paperCanvas.value.style.cursor = "crosshair";
+  state.isDrawing = true; state.isDragging = false;
+  state.rectPoint = { startX: e.offsetX, startY: e.offsetY, endX: e.offsetX, endY: e.offsetY };
+};
+
+const onCanvasMove = (e: MouseEvent) => {
+  if (!state.isDrawing || !paperCanvas.value) return;
+  const ctx = paperCanvas.value.getContext("2d")!;
+  state.rectPoint.endX = e.offsetX; state.rectPoint.endY = e.offsetY;
+  ctx.clearRect(0, 0, paperCanvas.value.width, paperCanvas.value.height);
+  drawImage();
+  ctx.strokeStyle = "blue"; ctx.lineWidth = 1;
+  const width = e.offsetX - state.rectPoint.startX;
+  const height = e.offsetY - state.rectPoint.startY;
+  ctx.strokeRect(state.rectPoint.startX, state.rectPoint.startY, width, height);
+};
+
+const onCanvasUp = () => {
+  state.isDrawing = false;
+  let { startX, startY, endX, endY } = state.rectPoint;
+  let actualStartX = Math.min(startX, endX), actualStartY = Math.min(startY, endY);
+  let width = Math.abs(endX - startX), height = Math.abs(endY - startY);
+  
+  let point = {
+    x: $global?.floatNum(actualStartX / state.zoomRate / state.scale),
+    y: $global?.floatNum(actualStartY / state.zoomRate / state.scale),
+    w: $global?.floatNum(width / state.zoomRate / state.scale),
+    h: $global?.floatNum(height / state.zoomRate / state.scale),
+    unit: "px",
+  };
+  state.currenPoint = point;
+  if (point.w > 0 && point.h > 0) emit("GetRectPoint", point);
+};
+
+const onCanvasLeave = () => { if (state.isDrawing) onCanvasUp(); };
+
+const handleResize = throttle(() => {
+  // 窗口变化逻辑(原代码被注释,保留结构)
+}, 500);
+
+// ================= 暴露方法供父组件调用 =================
+const MouseReset = () => {
+  state.drawType = 0;
+  if (paperCanvas.value) {
+    paperCanvas.value.style.cursor = "pointer";
+    paperCanvas.value.onmousedown = null;
+    paperCanvas.value.onmousemove = null;
+    paperCanvas.value.onmouseup = null;
+  }
+};
+
+const SelectionBox = () => {
+  state.drawType = 1;
+  if (paperCanvas.value) {
+    paperCanvas.value.style.cursor = "crosshair";
+    paperCanvas.value.onmousedown = onCanvasDown as any;
+    paperCanvas.value.onmousemove = onCanvasMove as any;
+    paperCanvas.value.onmouseup = onCanvasUp as any;
+  }
+};
+
+const StartPaintingCircle = (obj: any) => {
+  state.addObjectAreaOption = obj;
+  SelectionBox();
+};
+
+defineExpose({ MouseReset, SelectionBox, StartPaintingCircle });
+
+// ================= 监听器 =================
+watch(
+  () => props.paperImgUrl,
+  (newVal) => {
+    if (newVal) { state.loading = true; InitData(); }
+    else { state.loading = false; }
+  },
+  { immediate: true }
+);
+
+watch(() => props.drawData, () => { drawImage(); }, { deep: true });
+watch(() => props.currentId, () => { drawImage(); });
+
+// ================= 生命周期 =================
+onMounted(() => {
+  InitData();
+  nextTick(() => {
+    if (paperContainer.value) {
+      paperContainer.value.addEventListener("contextmenu", handleRightClick);
+    }
+  });
+  window.addEventListener("resize", handleResize);
+  window.addEventListener("mouseup", onGlobalMouseUp);
+  document.addEventListener("click", handleGlobalEvent);
+  document.addEventListener("contextmenu", handleGlobalEvent);
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener("resize", handleResize);
+  window.removeEventListener("mouseup", onGlobalMouseUp);
+  document.removeEventListener("click", handleGlobalEvent);
+  document.removeEventListener("contextmenu", handleGlobalEvent);
+  if (paperContainer.value) {
+    paperContainer.value.removeEventListener("contextmenu", handleRightClick);
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+/* 样式部分保持原样,无需修改 */
+.paper_container {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: relative;
+  .paper_canvas {
+    position: absolute;
+    cursor: pointer;
+    background-color: transparent;
+    z-index: 10;
+  }
+  .img_container {
+    position: absolute;
+    cursor: pointer;
+    z-index: 9;
+    img { width: 100%; height: 100%; }
+  }
+  .custom_context_menu {
+    position: absolute;
+    background: white;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
+    z-index: 9999;
+    min-width: 120px;
+    .menu_item {
+      padding: 8px 16px;
+      cursor: pointer;
+      font-size: 14px;
+      &:hover { background-color: #f5f5f5; }
+      &:not(:last-child) { border-bottom: 1px solid #eee; }
+    }
+  }
+  .no_paper_url {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+}
+</style>

+ 408 - 0
src/components/StudentPaper.vue

@@ -0,0 +1,408 @@
+<template>
+  <el-dialog 
+    title="预览答题卡" 
+    append-to-body 
+    v-model="showDialog" 
+    class="page_full_dialog" 
+    fullscreen
+  >
+    <div class="page_header" ref="pageHeaderRef">
+      <div class="back_button" @click="GoBack">
+        <i class="iconfont icon_return"></i>返回
+      </div>
+      <div class="header_title">
+        {{ pageTitle }}<span class="header_title_tip">(右键点击图片另存为可保存图片到本地)</span>
+      </div>
+    </div>
+    <div class="dialog_paper">
+      <div class="paper_content">
+        <div class="canvas_button">
+          <div :class="currentIndex === 0 ? 'disable_button_item' : 'button_item'" @click="LastPaper">
+            <el-icon><ArrowLeft /></el-icon>
+          </div>
+        </div>
+        <div 
+          class="canvas_image" 
+          v-loading="isLoading" 
+          element-loading-text="加载中……" 
+          element-loading-spinner="el-icon-loading" 
+          element-loading-background="#ffffff"
+        >
+          <PaperImage 
+            :paperImgUrl="currentPaperUrl" 
+            :currentPage="currentPage" 
+            :usedCardType="usedCardType" 
+            :drawData="currentDrawData" 
+            :downLoadName="currentDownLoadName" 
+          />
+        </div>
+        <div class="canvas_button">
+          <div :class="currentIndex === paperImageList.length - 1 ? 'disable_button_item' : 'button_item'" @click="NextPaper">
+            <el-icon><ArrowRight /></el-icon>
+          </div>
+        </div>
+      </div>
+      <div class="paper_question" ref="paperQuestionRef">
+        <div class="area_table">
+          <el-table :data="questionList" border ref="questionTableRef" :key="pageKey" :max-height="questionTableHeight">
+            <el-table-column label="小题名称" prop="questionName" align="center">
+              <template #default="scope">
+                {{ scope.row.questionName }}
+              </template>
+            </el-table-column>
+            <el-table-column label="满分/答案" align="center">
+              <template #default="scope">
+                {{ scope.row.fullScore }}
+                <span v-if="scope.row.questionAnswer">/ {{ scope.row.questionAnswer }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="得分/答案" align="center">
+              <template #default="scope">
+                <div :class="scope.row.fullScore == scope.row.score ? '' : 'question_score'">
+                  {{ scope.row.score }}
+                  <span v-if="scope.row.answer">/ {{ scope.row.answer }}</span>
+                </div>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
+import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue";
+import PaperImage from "@/components/PaperImage.vue"; // 学生试卷组件
+import { getStudentPaperCardInfo } from "@/api/analysis";
+
+// ================= 类型定义 =================
+interface PaperInfo {
+  examPaperId: string;
+  platformNumber: string | number | null;
+  [key: string]: any;
+}
+
+interface QuestionItem {
+  questionName: string;
+  fullScore: number | string;
+  score: number | string;
+  questionAnswer?: string;
+  answer?: string;
+  samplingPosition?: string;
+  pagePaintingVOS?: any[];
+  questionId?: string | number;
+  [key: string]: any;
+}
+
+interface CrossDrawItem {
+  page: number | string;
+  item: QuestionItem;
+}
+
+// ================= Props & Emits =================
+const props = withDefaults(
+  defineProps<{
+    paperInfo?: PaperInfo;
+    pageTitle?: string;
+    modelValue?: boolean; // 替代原有的 value
+    examLevel?: number | string;
+  }>(),
+  {
+    paperInfo: () => ({}) as PaperInfo,
+    pageTitle: "",
+    modelValue: false,
+    examLevel: 2,
+  }
+);
+const emit = defineEmits<{
+  (e: "updateModelValue", value: boolean): void;
+}>();
+const showDialog = ref(false);
+
+// ================= 响应式数据 (替代 data) =================
+const paperImageList = ref<any[]>([]); // 学生试卷图片列表
+const currentIndex = ref(0); // 当前学生试卷图片索引
+const currentPage = ref(1); // 当前学生试卷页码 默认第一页
+const currentPaperUrl = ref(""); // 当前学生试卷图片地址
+const currentDownLoadName = ref(""); // 当前学生试卷图片下载名称
+const currentDrawData = ref<any[]>([]); // 当前学生试卷答题标记数据
+const questionList = ref<QuestionItem[]>([]); // 学生试卷题目列表
+const questionTableHeight = ref<number | string>(0); // 学生试卷题目列表高度
+const usedCardType = ref<number | null>(null); // 1系统卡 2 三方卡
+const isLoading = ref(false); // 是否正在加载中
+const studentName = ref(""); // 学生姓名
+const crossDrawData = ref<CrossDrawItem[]>([]); // 跨页的批注数据
+const pageKey = ref(1);
+
+// DOM Refs
+const pageHeaderRef = ref<HTMLElement | null>(null);
+const paperQuestionRef = ref<HTMLElement | null>(null);
+const questionTableRef = ref();
+
+// ================= 监听器 =================
+watch(
+  () => props.modelValue,
+  (newVal) => {
+    if (newVal) {
+      showDialog.value = true;
+      currentIndex.value = 0;
+      pageKey.value += 1;
+      GetStudentPaperInfo();
+    }
+  }
+);
+
+// ================= 方法 (替代 methods) =================
+
+// 禁用右键菜单的方法
+const DisableRightClick = (event: MouseEvent) => {
+  event.preventDefault();
+  return false;
+};
+
+// 返回
+const GoBack = () => {
+  currentPaperUrl.value = "";
+  currentDrawData.value = [];
+  isLoading.value = true;
+  showDialog.value = false;
+  emit("updateModelValue", false);
+};
+
+// 上一个试卷
+const LastPaper = () => {
+  if (currentIndex.value > 0) {
+    currentIndex.value--;
+    UpdateCurrentPaperData();
+  }
+};
+
+// 下一个试卷
+const NextPaper = () => {
+  if (currentIndex.value < paperImageList.value.length - 1) {
+    currentIndex.value++;
+    UpdateCurrentPaperData();
+  }
+};
+
+// 获取学生试卷详情信息
+const GetStudentPaperInfo = () => {
+  currentPaperUrl.value = "";
+  if (props.paperInfo?.examPaperId && props.paperInfo?.platformNumber != null) {
+    isLoading.value = true;
+    
+    getStudentPaperCardInfo(props.paperInfo).then((res: any) => {
+      if (res.code == 200 && res.data) {
+        paperImageList.value = res.data.pageVOS || [];
+        usedCardType.value = res.data.usedCardType;
+        
+        // 重置索引并更新当前试卷数据
+        currentIndex.value = 0;
+        UpdateCurrentPaperData();
+
+        // 合并所有试卷图片中的题目列表
+        let allQuestions: QuestionItem[] = [];
+        // 先添加总分数据
+        const totalScore: QuestionItem = {
+          questionName: "总分",
+          fullScore: res.data.fullScore || 150,
+          score: res.data.totalScore,
+          questionAnswer: "",
+          answer: "",
+          samplingPosition: '{"x":195,"y":147,"page":1}',
+        };
+        
+        if (paperImageList.value.length > 0 && paperImageList.value[0].questionVOS) {
+          paperImageList.value[0].questionVOS.unshift(totalScore);
+        }
+        
+        if (props.examLevel == 1) {
+          // 联考
+          allQuestions = res?.data?.studentAnswerBOS || [];
+        } else {
+          // 校考
+          paperImageList.value.forEach((item) => {
+            if (item.questionVOS && item.questionVOS.length > 0) {
+              allQuestions = allQuestions.concat(item.questionVOS);
+            }
+          });
+        }
+        
+        questionList.value = allQuestions;
+        CalculateTableHeight(); // 计算表格高度
+        
+        nextTick(() => {
+          setTimeout(() => {
+            isLoading.value = false;
+          }, 500);
+        });
+      } else {
+        currentPaperUrl.value = "";
+        currentDrawData.value = [];
+        paperImageList.value = [];
+        currentIndex.value = 0;
+        questionList.value = [];
+        questionTableHeight.value = "";
+        nextTick(() => {
+          isLoading.value = false;
+        });
+      }
+    });
+  }
+};
+
+// 计算表格高度(更精确的方式)
+const CalculateTableHeight = () => {
+  nextTick(() => {
+    if (paperQuestionRef.value && pageHeaderRef.value) {
+      const availableHeight = window.innerHeight - 65 - 80;
+      questionTableHeight.value = availableHeight;
+    } else {
+      const availableHeight = window.innerHeight - 65 - 40 - 40;
+      questionTableHeight.value = availableHeight;
+    }
+  });
+};
+
+// 是否重复
+const IsRepeat = (obj: CrossDrawItem): boolean => {
+  return crossDrawData.value.some(
+    (item) => item.page == obj.page && item.item.questionId == obj.item.questionId
+  );
+};
+
+// 更新当前试卷数据的公共方法
+const UpdateCurrentPaperData = () => {
+  if (paperImageList.value.length > 0 && currentIndex.value < paperImageList.value.length) {
+    const currentItem = paperImageList.value[currentIndex.value];
+    currentPaperUrl.value = currentItem.picUrl;
+    
+    // 兼容旧的数据
+    if (currentItem.useType) {
+      usedCardType.value = currentItem.useType;
+    }
+
+    let drawData = currentItem.questionVOS || [];
+    currentPage.value = currentItem.page; // 打印当前的页码
+
+    if (drawData.length > 0) {
+      drawData.forEach((item: QuestionItem) => {
+        // 如果有批阅块 且大于1个批阅块 代表跨页的
+        if (item.pagePaintingVOS && item.pagePaintingVOS.length > 1) {
+          item.pagePaintingVOS.forEach((block) => {
+            if (block.page != currentPage.value) {
+              const obj: CrossDrawItem = {
+                page: block.page,
+                item: item,
+              };
+              // 插入之前 根据page和题目id判断是否重复
+              if (!IsRepeat(obj)) {
+                crossDrawData.value.push(obj);
+              }
+            }
+          });
+        }
+      });
+    }
+
+    currentDrawData.value = currentItem.questionVOS || [];
+    currentDownLoadName.value = `${props.pageTitle}答题卡第${currentIndex.value + 1}页`; // 重置下载名称
+  } else {
+    // 处理边界情况
+    currentPaperUrl.value = "";
+    currentDrawData.value = [];
+    paperImageList.value = [];
+    currentIndex.value = 0;
+    currentDownLoadName.value = "";
+  }
+};
+
+// ================= 生命周期 =================
+onMounted(() => {
+  // 禁用鼠标右键
+  document.addEventListener("contextmenu", DisableRightClick);
+});
+
+onBeforeUnmount(() => {
+  document.removeEventListener("contextmenu", DisableRightClick);
+});
+</script>
+
+<style lang="scss" scoped>
+.dialog_paper {
+  width: 100%;
+  height: calc(100vh - 65px);
+  background: #f0f4fb;
+  overflow: hidden;
+  display: flex;
+  justify-content: space-between;
+  padding: 20px;
+  box-sizing: border-box;
+
+  .paper_content {
+    width: calc(100% - 340px);
+    height: 100%;
+    display: flex;
+    justify-content: flex-start;
+
+    .canvas_button {
+      width: 48px;
+      height: 100%;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+
+      .button_item {
+        width: 48px;
+        height: 48px;
+        border-radius: 50%;
+        background: rgba(0, 0, 0, 0.1);
+        color: #999999;
+        font-size: 24px;
+        line-height: 48px;
+        text-align: center;
+        cursor: pointer;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+      }
+
+      .disable_button_item {
+        width: 48px;
+        height: 48px;
+        border-radius: 50%;
+        background: rgba(0, 0, 0, 0.1);
+        color: #c0c4cc;
+        font-size: 24px;
+        line-height: 48px;
+        text-align: center;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+      }
+    }
+
+    .canvas_image {
+      width: calc(100% - 88px - 40px);
+      height: 100%;
+      margin: auto;
+    }
+  }
+
+  .paper_question {
+    width: 320px;
+    height: 100%;
+    background: #ffffff;
+    border-radius: 10px;
+    border: 1px solid #ebeef5;
+    padding: 20px;
+    box-sizing: border-box;
+
+    .question_score {
+      color: #f56c6c;
+    }
+  }
+}
+</style>

+ 166 - 0
src/styles/common.scss

@@ -2629,7 +2629,173 @@ body {
 
 
 }
+//顶部header 菜单样式
+.page_header {
+  width: 100%;
+  height: 65px;
+  background: #2E64FA;
+  position: relative;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  .back_button {
+    width: 68px;
+    height: 36px;
+    position: absolute;
+    left: 50px;
+    border-radius: 4px;
+    border: 2px solid #fff;
+    box-sizing: border-box;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    cursor: pointer;
+  }
+
+  .header_title {
+    width: calc(100% - 200px);
+    margin: auto;
+    font-weight: 700;
+    font-size: 24px;
+    color: #fff;
+    text-align: center;
+
+    .header_title_tip {
+      font-size: 12px;
+      font-weight: 400;
+      color: #fff;
+      margin-left: 10px;
+    }
+  }
+}
+//考试模块右侧表格公共样式
+.area_table {
+  width: 100%;
+
+  .el-table__body {
+    width: 100% !important;
+  }
+
+  //暂无数据
+  .el-table__empty-block {
+    // min-height: 140px;
+    // background-image: url("../assets/bg/table_no_data.png");
+    // background-size: 64px 72px;
+    // background-position: 50% 50%;
+    // background-repeat: no-repeat;
+
+    // .el-table__empty-text {
+    //   display: none;
+    // }
+  }
+
+  // .el-table__body-wrapper {
+  //   overflow: hidden !important;
+
+  // }
+
+
+
+  // /* 鼠标悬停时显示滚动条 */
+  // .el-table__body-wrapper:hover {
+  //   overflow: auto !important;
+  // }
+
+  .el-table {
+    border-radius: 5px;
+    border: 1px solid #e9e9e9;
+    border-bottom: 0px solid #e9e9e9;
+  }
+
+  .el-table .cell {
+    padding: 0 !important;
+    font-size: 14px;
+    height: 42px;
+    line-height: 42px;
+  }
+
+  .el-table tr th.el-table__cell {
+    background: #f0f2f5;
+    height: 42px;
+  }
 
+  .el-table th.el-table__cell>.cell {
+    padding: 0 !important;
+  }
+
+  .el-table .el-table__cell {
+    padding: 0 5px !important;
+
+    line-height: 42px;
+    border-right: 1px solid #e9e9e9;
+    border-bottom: 1px solid #e9e9e9;
+  }
+
+  // 确保每行最后一个单元格无右边框
+  .el-table .el-table__body .el-table__cell:last-child {
+    border-right: 0 !important;
+  }
+
+  .el-table thead {
+    color: #303133;
+    font-weight: 600;
+    font-size: 14px;
+    height: 42px;
+    padding: 0 !important;
+  }
+
+  .table_option_editor {
+    color: #2e64fa;
+    font-size: 14px;
+    font-weight: 400;
+  }
+
+  .table_option_delete {
+    color: #f56c6c;
+    font-size: 14px;
+    font-weight: 400;
+  }
+
+  // 列表行名称
+  .table_row_name {
+    width: 100%;
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    display: block;
+    overflow: hidden;
+    /* 隐藏超出容器的内容 */
+    white-space: nowrap;
+    /* 保持文本在一行内 */
+    text-overflow: ellipsis;
+    /* 超出部分显示省略号 */
+    cursor: pointer;
+    padding-right: 5px;
+  }
+
+  //列表行设置
+  .table_row_set {
+    width: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    .option_add {
+      color: #f56c6c;
+      font-size: 14px;
+      font-weight: 400;
+      cursor: pointer;
+    }
+
+    .option_disabled {
+      font-weight: 400;
+      font-size: 14px;
+      color: #f56c6c;
+    }
+  }
+}
 .el-popover
 {
   border-radius: 4px;

+ 91 - 4
src/views/analysis/groupAnalysis.vue

@@ -299,12 +299,22 @@
     :showExportBtn="false"
   >
     <template #title_right>
-      <el-button class="default_button" @click="VisibleQuestionCard">
+      <el-button
+        class="default_button"
+        v-if="
+          state.questionAnswerData.questionType != '单选题' &&
+          state.questionAnswerData.questionType != '多选题' &&
+          state.questionAnswerData.questionType != '判断题'
+        "
+        @click="VisibleQuestionCard"
+      >
         <img src="@/assets/icon/card_view.webp" />批量查看
       </el-button>
     </template>
     <template #module_qita>
-      <div class="content_left paper_card"></div>
+      <div class="content_left paper_card">
+        <StudentQuestionImg :paperInfo="state.paperInfos"></StudentQuestionImg>
+      </div>
       <div class="content_right paper_card table_42">
         <el-table
           border
@@ -450,11 +460,31 @@
     </template>
   </ReportModule>
   <!-- 批量查看小题答题卡 -->
-  <!-- <QuestionCard :subjectId="state.analysisStore.filterObject.subjectId" :questionId="state.cardQuestionId" :platformNumbers="cardRegistrationCodeList" :groupTitle="groupTitle" :groupName="groupName" :questionTitle="questionTitle" :classTitle="classTitle" :optionTitle="optionTitle" :tagActive="tagActive" :showDialog="showQuestionCardDialog" @CloseDialog="CloseQuestionCardDialog"></QuestionCard> -->
+  <QuestionCard
+    :subjectId="analysisStore.filterObject.subjectId"
+    :questionId="state.cardQuestionId"
+    :platformNumbers="state.cardRegistrationCodeList"
+    :groupTitle="state.groupTitle"
+    :groupName="state.groupName"
+    :questionTitle="state.questionTitle"
+    :classTitle="state.classTitle"
+    :optionTitle="state.optionTitle"
+    :showDialog="state.showQuestionCardDialog"
+    @CloseDialog="CloseQuestionCardDialog"
+  ></QuestionCard>
+  <!-- 学生答题卡组件 -->
+  <StudentPaper
+    :modelValue="state.showStudentPaperDialog"
+    :paperInfo="state.paperInfo"
+    :pageTitle="state.paperTitle"
+    @updateModelValue="UpdateModelValue"
+  ></StudentPaper>
 </template>
 <script lang="ts" setup>
 import ReportModule from "@/components/ReportModule.vue";
 import EchartType from "@/components/EchartType.vue";
+import QuestionCard from "@/components/QuestionCard.vue";
+import StudentPaper from "@/components/StudentPaper.vue"; //学生答题卡组件
 import { useAnalysisStore } from "@/store/analysis";
 import {
   questionGroupAnalysis,
@@ -467,8 +497,9 @@ import BarsCharts from "@/components/echarts/barsCharts.vue"; //多柱状图组
 import BarScoringRateVertical from "@/components/echarts/barScoringRate_vertical.vue"; //得分率 纵向柱状图
 import DifferenceChart from "@/components/echarts/differenceChart.vue"; //率差图
 import BarChart from "@/components/echarts/barChart_answer.vue"; //单柱状图
+import StudentQuestionImg from "@/components/StudentQuestionImg.vue"; // 学生小题答题卡组件
 import { downloadExcel, GetExcelFileName } from "@/utils/exportExcel";
-import { onMounted, reactive, watch, ref, computed,nextTick } from "vue";
+import { onMounted, reactive, watch, ref, computed, nextTick } from "vue";
 import { cloneDeep } from "lodash-es";
 const analysisStore = useAnalysisStore();
 const reportModuleRef = ref<any>(null);
@@ -1265,11 +1296,36 @@ const GetAnswerListByAnswerAndScore = (index, name) => {
   queryAnswerListByAnswerAndScore(params).then((res) => {
     if (res.code == 200) {
       state.majorAnswerData.tableData = res.data || [];
+      HandleRowClick(
+        state.majorAnswerData.tableData.length > 0
+          ? state.majorAnswerData.tableData[0]
+          : {},
+      );
     } else {
       state.majorAnswerData.tableData = [];
     }
   });
 };
+// 点击某行学生某题作答情况
+const HandleRowClick = (row) => {
+  if (row?.studentRegistrationCode) {
+    //答题卡
+    const question =
+      state.problemAnalysisData.questionList[
+        state.problemAnalysisData.questionListIndex
+      ];
+    state.paperInfos = {
+      examPaperId: analysisStore.filterObject.subjectId, //考试科目id
+      platformNumber: row.studentRegistrationCode, //学籍号平台号
+      questionId: question.questionId, //题目id
+    };
+    state.majorAnswerData.rowIndex = state.majorAnswerData.tableData.findIndex(
+      (item) => item.studentRegistrationCode == row?.studentRegistrationCode,
+    );
+  } else {
+    state.paperInfos = {};
+  }
+};
 // 获取项目分析表
 const GetMajorTableData = () => {
   state.majorTableData.tableKey += 1;
@@ -1410,6 +1466,34 @@ const HeaderRowStyle = ({ row, rowIndex }) => {
     };
   }
 };
+const TableRowClassName = ({ row, rowIndex }) => {
+  if (rowIndex === state.majorAnswerData.rowIndex) {
+    return "current-row";
+  }
+  return "";
+};
+//查看答题卡
+const handleClick = (row) =>  {
+  state.paperInfo = {
+    examPaperId: analysisStore.filterObject.subjectId, //考试科目id
+    platformNumber: row.studentRegistrationCode, //学籍号平台号
+    questionId: "", //题目id
+  };
+  state.paperTitle = `${getExamName.value}-${analysisStore.filterObject.subjectName}-${row.className}-${row.studentUserName}`; //学生姓名
+  state.showStudentPaperDialog = true;
+};
+//更新弹窗状态
+const UpdateModelValue = (val: boolean) => {
+  state.showStudentPaperDialog = val;
+};
+//批量查看小题答题卡
+const VisibleQuestionCard = () => {
+  state.showQuestionCardDialog = true;
+};
+//关闭弹框
+const CloseQuestionCardDialog = () => {
+  state.showQuestionCardDialog = false;
+};
 const pageInit = () => {
   const chartTypeLists = [
     {
@@ -1474,6 +1558,9 @@ onMounted(() => {
 
   &.paper_card {
     width: calc(100% - 480px);
+    height: 400px;
+    background-color: #f5f5f5;
+    border-radius: 10px;
   }
 }
 

+ 14 - 2
src/views/analysis/questionAnalysis.vue

@@ -398,16 +398,24 @@
     :showDialog="state.showQuestionCardDialog"
     @CloseDialog="CloseQuestionCardDialog"
   ></QuestionCard>
+  <!-- 学生答题卡组件 -->
+  <StudentPaper
+    :modelValue="state.showStudentPaperDialog"
+    :paperInfo="state.paperInfo"
+    :pageTitle="state.paperTitle"
+    @updateModelValue="UpdateModelValue"
+  ></StudentPaper>
 </template>
 <script lang="ts" setup>
 import ReportModule from "@/components/ReportModule.vue";
 import EchartType from "@/components/EchartType.vue";
 import QuestionCard from "@/components/QuestionCard.vue";
+import StudentPaper from "@/components/StudentPaper.vue"; //学生答题卡组件
 import { useAnalysisStore } from "@/store/analysis";
 import {
   questionAnalysis,
   queryAnswerListByAnswerAndScore,
-  publicExport,
+  publicExport
 } from "@/api/analysis";
 import BarLineCharts from "@/components/echarts/barLineCharts.vue"; //柱状图折线图组合图组件
 import RadarCharts from "@/components/echarts/radarCharts.vue"; //雷达图
@@ -1294,7 +1302,7 @@ const TableRowClassName = ({ row, rowIndex }) => {
   return "";
 };
 //查看答题卡
-const handleClick = (row) => {
+const handleClick = (row) =>  {
   state.paperInfo = {
     examPaperId: analysisStore.filterObject.subjectId, //考试科目id
     platformNumber: row.studentRegistrationCode, //学籍号平台号
@@ -1303,6 +1311,10 @@ const handleClick = (row) => {
   state.paperTitle = `${getExamName.value}-${analysisStore.filterObject.subjectName}-${row.className}-${row.studentUserName}`; //学生姓名
   state.showStudentPaperDialog = true;
 };
+//更新弹窗状态
+const UpdateModelValue = (val: boolean) => {
+  state.showStudentPaperDialog = val;
+};
 //批量查看小题答题卡
 const VisibleQuestionCard = () => {
   state.showQuestionCardDialog = true;