Jelajahi Sumber

导出excel、批量查看答题卡、错题分析导出pdf

liurongli 15 jam lalu
induk
melakukan
b7763b83b0

+ 216 - 0
package-lock.json

@@ -12,9 +12,11 @@
         "@tsparticles/slim": "^3.9.1",
         "@tsparticles/vue3": "^3.0.1",
         "axios": "^1.9.0",
+        "dom-to-image": "^2.6.0",
         "echarts": "^6.1.0",
         "element-plus": "^2.9.11",
         "jsencrypt": "^3.5.4",
+        "jspdf": "^4.2.1",
         "lodash-es": "^4.18.1",
         "pinia": "^3.0.4",
         "vue": "^3.5.13",
@@ -23,6 +25,7 @@
       },
       "devDependencies": {
         "@tsconfig/node20": "^20.1.9",
+        "@types/dom-to-image": "^2.6.7",
         "@types/node": "^22.15.26",
         "@vitejs/plugin-vue": "^5.2.3",
         "@vue/tsconfig": "^0.7.0",
@@ -66,6 +69,14 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+      "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/types": {
       "version": "7.29.0",
       "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
@@ -1726,6 +1737,12 @@
         "vue": "^3.3.13"
       }
     },
+    "node_modules/@types/dom-to-image": {
+      "version": "2.6.7",
+      "resolved": "https://registry.npmjs.org/@types/dom-to-image/-/dom-to-image-2.6.7.tgz",
+      "integrity": "sha512-me5VbCv+fcXozblWwG13krNBvuEOm6kA5xoa4RrjDJCNFOZSWR3/QLtOXimBHk1Fisq69Gx3JtOoXtg1N1tijg==",
+      "dev": true
+    },
     "node_modules/@types/estree": {
       "version": "1.0.8",
       "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
@@ -1758,6 +1775,23 @@
         "undici-types": "~6.21.0"
       }
     },
+    "node_modules/@types/pako": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
+      "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="
+    },
+    "node_modules/@types/raf": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+      "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+      "optional": true
+    },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "optional": true
+    },
     "node_modules/@types/web-bluetooth": {
       "version": "0.0.21",
       "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -2080,6 +2114,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/base64-arraybuffer": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+      "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+      "optional": true,
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
     "node_modules/birpc": {
       "version": "2.9.0",
       "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
@@ -2112,6 +2155,25 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/canvg": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
+      "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+      "optional": true,
+      "dependencies": {
+        "@babel/runtime": "^7.12.5",
+        "@types/raf": "^3.4.0",
+        "core-js": "^3.8.3",
+        "raf": "^3.4.1",
+        "regenerator-runtime": "^0.13.7",
+        "rgbcolor": "^1.0.1",
+        "stackblur-canvas": "^2.0.0",
+        "svg-pathdata": "^6.0.3"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
     "node_modules/chokidar": {
       "version": "4.0.3",
       "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz",
@@ -2155,6 +2217,26 @@
         "url": "https://github.com/sponsors/mesqueeb"
       }
     },
+    "node_modules/core-js": {
+      "version": "3.49.0",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+      "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+      "hasInstallScript": true,
+      "optional": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/css-line-break": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+      "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+      "optional": true,
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
     "node_modules/csstype": {
       "version": "3.2.3",
       "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -2211,6 +2293,20 @@
         "node": ">=8"
       }
     },
+    "node_modules/dom-to-image": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz",
+      "integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA=="
+    },
+    "node_modules/dompurify": {
+      "version": "3.4.11",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
+      "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
+      "optional": true,
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2365,6 +2461,16 @@
       "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
       "license": "MIT"
     },
+    "node_modules/fast-png": {
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
+      "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
+      "dependencies": {
+        "@types/pako": "^2.0.3",
+        "iobuffer": "^5.3.2",
+        "pako": "^2.1.0"
+      }
+    },
     "node_modules/fdir": {
       "version": "6.5.0",
       "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -2383,6 +2489,11 @@
         }
       }
     },
+    "node_modules/fflate": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+      "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
+    },
     "node_modules/follow-redirects": {
       "version": "1.16.0",
       "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -2547,6 +2658,19 @@
       "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
       "license": "MIT"
     },
+    "node_modules/html2canvas": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+      "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+      "optional": true,
+      "dependencies": {
+        "css-line-break": "^2.1.0",
+        "text-segmentation": "^1.0.3"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
     "node_modules/https-proxy-agent": {
       "version": "5.0.1",
       "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -2567,6 +2691,11 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/iobuffer": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
+      "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="
+    },
     "node_modules/is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2610,6 +2739,22 @@
       "integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
       "license": "MIT"
     },
+    "node_modules/jspdf": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
+      "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
+      "dependencies": {
+        "@babel/runtime": "^7.28.6",
+        "fast-png": "^6.2.0",
+        "fflate": "^0.8.1"
+      },
+      "optionalDependencies": {
+        "canvg": "^3.0.11",
+        "core-js": "^3.6.0",
+        "dompurify": "^3.3.1",
+        "html2canvas": "^1.0.0-rc.5"
+      }
+    },
     "node_modules/lodash": {
       "version": "4.18.1",
       "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
@@ -2751,6 +2896,11 @@
       "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
       "license": "BSD-3-Clause"
     },
+    "node_modules/pako": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+      "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
+    },
     "node_modules/path-browserify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2764,6 +2914,12 @@
       "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
       "license": "MIT"
     },
+    "node_modules/performance-now": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+      "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+      "optional": true
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -2850,6 +3006,15 @@
         "node": ">=10"
       }
     },
+    "node_modules/raf": {
+      "version": "3.4.1",
+      "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
+      "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+      "optional": true,
+      "dependencies": {
+        "performance-now": "^2.1.0"
+      }
+    },
     "node_modules/readdirp": {
       "version": "4.1.2",
       "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz",
@@ -2864,12 +3029,27 @@
         "url": "https://paulmillr.com/funding/"
       }
     },
+    "node_modules/regenerator-runtime": {
+      "version": "0.13.11",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+      "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+      "optional": true
+    },
     "node_modules/rfdc": {
       "version": "1.4.1",
       "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
       "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
       "license": "MIT"
     },
+    "node_modules/rgbcolor": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+      "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+      "optional": true,
+      "engines": {
+        "node": ">= 0.8.15"
+      }
+    },
     "node_modules/rollup": {
       "version": "4.60.4",
       "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz",
@@ -2995,6 +3175,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/stackblur-canvas": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+      "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=0.1.14"
+      }
+    },
     "node_modules/superjson": {
       "version": "2.2.6",
       "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz",
@@ -3007,6 +3196,24 @@
         "node": ">=16"
       }
     },
+    "node_modules/svg-pathdata": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+      "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+      "optional": true,
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/text-segmentation": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+      "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+      "optional": true,
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.16",
       "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -3050,6 +3257,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/utrie": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+      "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+      "optional": true,
+      "dependencies": {
+        "base64-arraybuffer": "^1.0.2"
+      }
+    },
     "node_modules/vite": {
       "version": "6.4.2",
       "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz",

+ 3 - 0
package.json

@@ -13,9 +13,11 @@
     "@tsparticles/slim": "^3.9.1",
     "@tsparticles/vue3": "^3.0.1",
     "axios": "^1.9.0",
+    "dom-to-image": "^2.6.0",
     "echarts": "^6.1.0",
     "element-plus": "^2.9.11",
     "jsencrypt": "^3.5.4",
+    "jspdf": "^4.2.1",
     "lodash-es": "^4.18.1",
     "pinia": "^3.0.4",
     "vue": "^3.5.13",
@@ -24,6 +26,7 @@
   },
   "devDependencies": {
     "@tsconfig/node20": "^20.1.9",
+    "@types/dom-to-image": "^2.6.7",
     "@types/node": "^22.15.26",
     "@vitejs/plugin-vue": "^5.2.3",
     "@vue/tsconfig": "^0.7.0",

+ 70 - 0
src/api/analysis.ts

@@ -10,6 +10,14 @@ export const findCommonSelectList = (data: any): Promise<ApiResponse> => {
     params: data,
   });
 };
+// 获取任务的相关配置和信息
+export const getAnalysisExamInfo = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/getAnalysisExamInfo",
+    method: "get",
+    params: data,
+  });
+};
 // ==========================================成绩单============================================
 //获取单科成绩单下的表头数据
 export const studentTranscriptTitle = (data: any): Promise<ApiResponse> => {
@@ -35,6 +43,15 @@ export const studentTranscript = (data: any): Promise<ApiResponse> => {
     data,
   });
 };
+// 导出单科成绩单下的数据
+export const studentTranscriptExcel = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/excel/studentTranscript",
+    method: "post",
+    data,
+    responseType: 'blob'
+  });
+};
 // ==========================================错题分析============================================
 // 错题分析
 export const errorQuestionAnalysis = (data: any): Promise<ApiResponse> => {
@@ -44,6 +61,15 @@ export const errorQuestionAnalysis = (data: any): Promise<ApiResponse> => {
     data,
   });
 };
+// 导出错题分析数据
+export const exportErrorQuestion = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/excel/export_error_question",
+    method: "post",
+    data,
+    responseType: 'blob'
+  });
+};
 // ==========================================水平分布============================================
 export const scoreSegment = (data: any): Promise<ApiResponse> => {
   return request({
@@ -116,4 +142,48 @@ export const propositionAnalysis = (data: any): Promise<ApiResponse> => {
     method: "post",
     data,
   });
+};
+// 导出选项分析表
+export const exportObjectAnalysis = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/excel/exportObjectAnalysis",
+    method: "post",
+    data,
+    responseType: 'blob'
+  });
+};
+// ==========================================答题卡============================================
+//查询学生答题卡带批阅痕迹的(多张)
+export const findCardListNew = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/find_card_list_new",
+    method: "post",
+    data,
+  });
+};
+//查询学生答题卡带批阅痕迹的 
+export const getStudentPaperCardInfo = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/find_student_card",
+    method: "post",
+    data,
+  });
+};
+//编辑采分点
+export const editSamplingPoint = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/update_sampling_point",
+    method: "post",
+    data,
+  });
+};
+// ==========================================导出============================================
+//公共导出接口-表头和内容渲染完毕
+export const publicExport = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/excel/publicExport",
+    method: "post",
+    data,
+    responseType: 'blob'
+  });
 };

TEMPAT SAMPAH
src/assets/icon/icon_zoom_in.webp


TEMPAT SAMPAH
src/assets/icon/icon_zoom_out.webp


TEMPAT SAMPAH
src/assets/icon/pic_show_view.png


+ 465 - 0
src/components/ErrorsPdf.vue

@@ -0,0 +1,465 @@
+<template>
+  <div class="page_report error">
+    <div class="area_page">
+      <div class="area_title" ref="pageTitle">
+        <p>{{ examName }}</p>
+        <p>
+          <span>{{ subjectName }}</span
+          ><span>{{ className }}</span
+          ><span>错题名单</span>
+        </p>
+      </div>
+    </div>
+    <div
+      class="area_page print_page"
+      v-for="(pageData, pIndex) in tablePageData"
+      :key="pIndex"
+    >
+      <template v-if="pIndex == 0">
+        <div class="area_title">
+          <p>{{ examName }}</p>
+          <p>
+            <span>{{ subjectName }}</span
+            ><span>{{ className }}</span
+            ><span>错题名单</span>
+          </p>
+        </div>
+        <div class="module_jg"></div>
+      </template>
+      <div class="module_table">
+        <template v-for="(item, index) in pageData" :key="index">
+          <div
+            class="module_table_tr bg_color"
+            :rowIndex="index"
+            :rowKey="item.rowKey"
+            v-if="item.rowKey == 0"
+          >
+            <div class="module_table_td">{{ item.questionName }}</div>
+            <div class="module_table_td">
+              <span>{{ item.questionType }}</span>
+              <span v-if="item.answerValue">标答:{{ item.answerValue }}</span>
+              <span>满分:{{ item.questionScore }}分</span>
+              <span>班均分:{{ item.averageScore }}</span>
+              <span>班得分率:{{ item.scoreRate }}</span>
+            </div>
+          </div>
+          <div
+            class="module_table_tr"
+            v-if="!item?.hiddenRatioStu"
+            :id="`studentErrorData_${index}`"
+            :rowIndex="index"
+            :rowKey="item.rowKey"
+          >
+            <div class="module_table_td"></div>
+            <div class="module_table_td">
+              <p v-if="!item?.hiddenRatio">
+                <span>{{ item.name }}</span
+                ><span
+                  >占⽐:{{ item.rate == "-" ? "-" : `${item.rate}%` }}</span
+                ><span>({{ item.studentNum }}/{{ item.studentCount }})</span>
+              </p>
+              <p
+                v-if="
+                  item?.registrationCodeList &&
+                  item?.registrationCodeList != '-' &&
+                  !item?.hiddenStu
+                "
+              >
+                {{ item?.registrationCodeList }}
+              </p>
+            </div>
+          </div>
+        </template>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, nextTick, watch } from "vue";
+import { ElMessage } from "element-plus";
+import { jsPDF } from "jspdf";
+import domtoimage from "dom-to-image";
+// 定义数据类型
+interface TableItem {
+  rowKey: number | string;
+  questionName?: string;
+  questionType?: string;
+  answerValue?: string;
+  questionScore?: number | string;
+  averageScore?: number | string;
+  scoreRate?: number | string;
+  hiddenRatioStu?: boolean;
+  name?: string;
+  rate?: number | string;
+  studentNum?: number | string;
+  studentCount?: number | string;
+  registrationCodeList?: string;
+  hiddenRatio?: boolean;
+  hiddenStu?: boolean;
+}
+
+// 定义 Props
+const props = defineProps({
+  examName: {
+    type: String,
+    default: "",
+  },
+  tableData: {
+    type: Array,
+    default: () => [],
+  },
+  subjectName: {
+    type: String,
+    default: "",
+  },
+  className: {
+    type: String,
+    default: "",
+  },
+});
+
+// 定义 Emits
+const emit = defineEmits(["PdfLoadEnd"]);
+const LoadData = () => {
+  nextTick(() => {
+    const pageTitleHeight = pageTitle.value?.offsetHeight || 60;
+    titleHeight.value = pageTitleHeight + 20;
+    tablePageData.value = [];
+    TablePagesData(props.tableData, true);
+  });
+};
+// Refs & 常量
+const pageTitle = ref<HTMLElement | null>(null);
+const titleHeight = ref(80);
+const questionHeight = 30;
+const lineHeight = 16;
+const printPageHeight = 1205;
+const tablePageData = ref<TableItem[][]>([]);
+
+// 表格分页逻辑
+const TablePagesData = (tableData: TableItem[], firstPage: boolean) => {
+  let pageHeight = 0;
+  if (firstPage) {
+    pageHeight += titleHeight.value;
+  }
+
+  for (let i = 0; i < tableData.length; i++) {
+    if (tableData[i].rowKey == 0) {
+      pageHeight += questionHeight;
+      if (pageHeight >= printPageHeight) {
+        const newTableData = tableData.slice(0, i);
+        tablePageData.value.push(newTableData);
+        const nextTableData = tableData.slice(i);
+        if (nextTableData.length) {
+          TablePagesData(nextTableData, false);
+        }
+        return;
+      }
+    }
+
+    let stuRowNum = 0;
+    const regCodeList = tableData?.[i]?.registrationCodeList;
+    if (regCodeList && regCodeList !== "-" && regCodeList !== "") {
+      stuRowNum = Math.ceil(regCodeList.length / 56);
+    }
+
+    const rowNum = stuRowNum + 1;
+    for (let j = 0; j < rowNum; j++) {
+      let lineH = lineHeight;
+      if (stuRowNum < 6) {
+        // 防止 stuRowNum 为 0 时除以 0 报错
+        lineH += stuRowNum > 0 ? Math.ceil(5 / stuRowNum) : 0;
+      } else {
+        if (j < 5) {
+          lineH += 1;
+        } else {
+          lineH = lineHeight;
+        }
+      }
+      const currentLineHeight = j == 0 ? 27 : lineH;
+      pageHeight += currentLineHeight;
+
+      if (pageHeight >= printPageHeight) {
+        let newTableData: TableItem[] = [];
+        let nextTableData: TableItem[] = [];
+
+        if (tableData[i].rowKey == 0) {
+          newTableData = tableData.slice(0, i + 1);
+          nextTableData = tableData.slice(i);
+          let pageLastData =
+            newTableData.length > 0
+              ? { ...newTableData[newTableData.length - 1] }
+              : null;
+          let firstData =
+            nextTableData.length > 0 ? { ...nextTableData[0] } : null;
+
+          if (pageLastData) {
+            if (j == 0) {
+              pageLastData.hiddenRatioStu = true;
+            } else if (j == 1) {
+              pageLastData.hiddenStu = true;
+            } else {
+              const sliceIndex = (j - 1) * 56;
+              pageLastData.registrationCodeList =
+                pageLastData.registrationCodeList?.slice(0, sliceIndex) || "";
+            }
+          }
+          if (firstData) {
+            if (j == 0) {
+              firstData.rowKey = `0_${j}`;
+            } else if (j == 1) {
+              firstData.rowKey = `0_${j}`;
+              firstData.hiddenRatio = true;
+            } else {
+              firstData.rowKey = `0_${j}`;
+              firstData.hiddenRatio = true;
+              const sliceIndex = (j - 1) * 56;
+              firstData.registrationCodeList =
+                firstData.registrationCodeList?.slice(sliceIndex) || "";
+            }
+          }
+
+          if (newTableData.length) {
+            newTableData.pop();
+            newTableData.push(pageLastData!);
+            tablePageData.value.push(newTableData);
+          }
+          if (nextTableData.length) {
+            nextTableData.shift();
+            nextTableData.unshift(firstData!);
+            TablePagesData(nextTableData, false);
+          }
+        } else {
+          newTableData = tableData.slice(0, i + 1);
+          nextTableData = tableData.slice(i);
+          let pageLastData =
+            newTableData.length > 0
+              ? { ...newTableData[newTableData.length - 1] }
+              : null;
+          let firstData =
+            nextTableData.length > 0 ? { ...nextTableData[0] } : null;
+
+          if (pageLastData) {
+            if (j == 1) {
+              pageLastData.hiddenStu = true;
+            } else {
+              const sliceIndex = (j - 1) * 56;
+              pageLastData.registrationCodeList =
+                pageLastData.registrationCodeList?.slice(0, sliceIndex) || "";
+            }
+          }
+          if (firstData) {
+            if (j == 1) {
+              firstData.hiddenRatio = true;
+            } else {
+              firstData.hiddenRatio = true;
+              const sliceIndex = (j - 1) * 56;
+              firstData.registrationCodeList =
+                firstData.registrationCodeList?.slice(sliceIndex) || "";
+            }
+          }
+
+          if (newTableData.length) {
+            newTableData.pop();
+            newTableData.push(pageLastData!);
+            tablePageData.value.push(newTableData);
+          }
+          if (nextTableData.length) {
+            nextTableData.shift();
+            nextTableData.unshift(firstData!);
+            TablePagesData(nextTableData, false);
+          }
+        }
+        return;
+      }
+    }
+  }
+
+  if (tableData.length) {
+    tablePageData.value.push(tableData);
+  }
+};
+
+// 下载 PDF (使用 dom-to-image)
+const DownloadPdf = async () => {
+  const elements = document.querySelectorAll(".page_report .print_page");
+  if (elements.length === 0) {
+    ElMessage.warning("未找到可导出的页面元素!");
+    emit("PdfLoadEnd");
+    return;
+  }
+
+  const pdf = new jsPDF("p", "pt", "a4");
+  const pdfWidth = pdf.internal.pageSize.getWidth();
+  const pdfHeight = pdf.internal.pageSize.getHeight();
+
+  for (const [pageIndex, element] of Array.from(elements).entries()) {
+    await nextTick();
+    if (element) {
+      const htmlElement = element as HTMLElement;
+      htmlElement.style.visibility = "visible";
+      htmlElement.offsetHeight;
+
+      await new Promise((resolve) => setTimeout(resolve, 100));
+
+      const dataUrl = await domtoimage.toJpeg(htmlElement, {
+        quality: 0.8,
+        width: htmlElement.scrollWidth * 2,
+        height: htmlElement.scrollHeight * 2,
+        canvasWidth: htmlElement.scrollWidth * 2,
+        canvasHeight: htmlElement.scrollHeight * 2,
+        style: {
+          transform: "scale(2)",
+          "transform-origin": "top left",
+          "background-color": "#ffffff",
+          overflow: "visible",
+          "white-space": "normal",
+          position: "relative",
+          visibility: "visible",
+        },
+        bgcolor: "#ffffff",
+      });
+
+      const imgProps = pdf.getImageProperties(dataUrl);
+      const imgWidth = imgProps.width;
+      const imgHeight = imgProps.height;
+
+      const ratio = Math.min(pdfWidth / imgWidth, pdfHeight / imgHeight);
+      const adjustWidth = imgWidth * ratio;
+      const adjustHeight = imgHeight * ratio;
+
+      const xPosition = (pdfWidth - adjustWidth) / 2;
+      const yPosition = (pdfHeight - adjustHeight) / 2;
+
+      if (pageIndex > 0) {
+        pdf.addPage();
+      }
+      pdf.addImage(
+        dataUrl,
+        "JPEG",
+        xPosition,
+        yPosition,
+        adjustWidth,
+        adjustHeight,
+      );
+    }
+  }
+
+  pdf.save(
+    `${props.examName}_${props.subjectName}_${props.className}错题名单.pdf`,
+  );
+  emit("PdfLoadEnd");
+};
+// 监听筛选条件
+watch(
+  () => props.tableData,
+  async () => {
+    if (props.tableData.length) {
+      LoadData();
+    }
+  },
+  { deep: true },
+);
+
+// 暴露方法供父组件调用
+defineExpose({
+  DownloadPdf
+});
+</script>
+
+<style lang="scss" scoped>
+.error {
+  width: 908px;
+  margin: 0 auto;
+  position: absolute;
+  top: 0;
+  left: -9999px;
+  z-index: -10;
+  .area_page {
+    width: 908px;
+    margin-top: 20px;
+    padding: 40px !important;
+    background-color: #fff;
+    box-sizing: border-box;
+    overflow: hidden;
+    &.print_page {
+      height: 1285px;
+    }
+    .area_title {
+      font-weight: bold;
+      font-size: 24px;
+      color: #000000;
+      line-height: 30px;
+      p {
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        flex-wrap: wrap;
+        gap: 5px;
+      }
+    }
+    .module_jg {
+      height: 20px;
+      width: 100%;
+    }
+    .module_table {
+      width: 100%;
+      border: 1px solid #ebeef5;
+      box-sizing: border-box;
+      background-color: #fff;
+      border-radius: 6px;
+      .module_table_tr {
+        &.bg_color {
+          background-color: #f5f7fa;
+          .module_table_td {
+            font-weight: 500;
+            &:nth-child(2) {
+              gap: 5px;
+              align-items: center;
+            }
+          }
+        }
+        min-height: 30px;
+        box-sizing: border-box;
+        border-top: 1px solid #ebeef5;
+        display: flex;
+        width: 100%;
+        &:first-child {
+          border-top: 0;
+        }
+        .module_table_td {
+          padding: 5px;
+          box-sizing: border-box;
+          border-left: 1px solid #ebeef5;
+          font-size: 12px;
+          font-weight: 400;
+          color: #000000;
+          &:first-child {
+            width: 100px;
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+            border-left: 0;
+          }
+          &:nth-child(2) {
+            flex: 1;
+            display: flex;
+            flex-wrap: wrap;
+            p {
+              line-height: 16px;
+              display: flex;
+              flex-wrap: wrap;
+              width: 100%;
+              &:first-child {
+                padding-bottom: 5px;
+                gap: 5px;
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 14 - 4
src/components/FiltersItem.vue

@@ -1,13 +1,17 @@
 <template>
     <div class="filters_group">
-        <div class="group_item" v-for="(group,groupIndex) in data">
+      <template v-for="(group,groupIndex) in data">
+        <div class="group_item" v-if="(group.type != 'className' && group.list.length > 1) || (group.type == 'className' && group.list.length > 0)">
             <div class="group_title">
                 {{group.label}}:
             </div>
-            <div class="list_item" @click="HandleSelect(groupIndex,item.value)" v-for="item in group.list" :class="item.value == group.value ? 'list_item_cur' : ''">
-                {{item.label}}
+            <div class="group_content">
+              <div class="list_item" @click="HandleSelect(groupIndex,item.value)" v-for="item in group.list" :class="item.value == group.value ? 'list_item_cur' : ''">
+                  {{item.label}}
+              </div>
             </div>
         </div>
+      </template>
     </div>
 </template>
 
@@ -65,9 +69,15 @@ onMounted(() => {
     font-weight: 600;
     font-size: 14px;
     color:#333333;
+    flex-shrink: 0;
     // margin-right: 8px;
   }
-
+  .group_content{
+    display: flex;
+    flex: 1;
+    gap: 8px;
+    flex-wrap: wrap;
+  }
   .list_item
   {
     font-weight: 400;

+ 512 - 0
src/components/QuestionCard.vue

@@ -0,0 +1,512 @@
+<template>
+  <el-dialog
+    title=""
+    class="page_full_dialog"
+    v-model="dialogVisible"
+    fullscreen
+    height="100%"
+  >
+    <div class="header_container">
+      <div class="header_back" @click="CloseDialog()">
+        <i class="iconfont icon_return"></i>返回
+      </div>
+      <div class="header_title">批量查看</div>
+    </div>
+    <div class="container">
+      <div class="container_title">
+        <div class="title_left">
+          <span class="span_item" v-if="groupTitle">{{ groupTitle }} /</span>
+          <el-tooltip
+            v-if="groupName"
+            effect="dark"
+            :disabled="groupName.length <= 10"
+            :content="groupName"
+            placement="top"
+          >
+            <span class="span_item"
+              >{{
+                groupName.length > 10
+                  ? groupName.slice(0, 10) + "..."
+                  : groupName
+              }}
+              /</span
+            >
+          </el-tooltip>
+          <span class="span_item" v-if="questionTitle"
+            >{{ questionTitle }} /</span
+          >
+          <span class="span_item" v-if="classTitle">{{ classTitle }}</span>
+          <span class="score">得分:{{ optionTitle }}分</span>
+          <div class="icon_zoom in" @click="ZoomIn">
+            <img src="@/assets/icon/icon_zoom_in.webp" />
+            <span>放大</span>
+          </div>
+          <div class="icon_zoom" @click="ZoomOut">
+            <img src="@/assets/icon/icon_zoom_out.webp" />
+            <span>缩小</span>
+          </div>
+        </div>
+      </div>
+      <div class="container_card">
+        <div
+          :class="['item_card', `row${rowCount}`]"
+          v-for="(item, index) in studentList"
+          :key="index"
+          :style="{ width: `calc((100% - 54px) / ${rowCount})` }"
+        >
+          <div class="item_card_title">
+            {{ item.studentName }}&nbsp;&nbsp;{{ item.className }}
+          </div>
+          <div class="item_card_img">
+            <!-- 确保 StudentQuestionImg 也是 Vue 3 组件 -->
+            <StudentQuestionImg
+              :key="rowCount"
+              :paperData="item"
+              :isBatch="true"
+            ></StudentQuestionImg>
+            <div class="item_hover">
+              <div class="show_view" @click="ShowQuestionCard(item, index)">
+                <img src="@/assets/icon/pic_show_view.png" />
+                <span>查看</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="preview_modal" v-if="showPreview"></div>
+    <div class="preview_dialog" v-if="showPreview">
+      <div class="close_btn" @click="ClosePreview">
+        <el-icon class="el-icon-close"><Close /></el-icon>
+      </div>
+      <!-- 上一页 -->
+      <div
+        :class="['prev_btn', { disable: previewIndex <= 0 }]"
+        @click.stop="PrevStudent"
+      >
+        <el-icon class="el-icon-arrow-left"><ArrowLeft /></el-icon>
+      </div>
+      <div class="preview_content">
+        <StudentQuestionImg
+          :paperData="previewData"
+          :isBatch="true"
+        ></StudentQuestionImg>
+      </div>
+      <!-- 下一页 -->
+      <div
+        :class="['next_btn', { disable: previewIndex === count }]"
+        @click.stop="NextStudent"
+      >
+        <el-icon class="el-icon-arrow-right"><ArrowRight /></el-icon>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from "vue";
+import StudentQuestionImg from "@/components/StudentQuestionImg.vue"; // 学生小题答题卡组件
+import { findCardListNew } from "@/api/analysis";
+
+// 定义 Props 接口
+interface Props {
+  showDialog: boolean;
+  groupTitle?: string;
+  groupName?: string;
+  questionTitle?: string;
+  classTitle?: string;
+  optionTitle?: string;
+  subjectId?: string; // 科目id
+  questionId?: string; // 试题id
+  platformNumbers?: string[]; // 学籍id
+}
+
+// 定义学生数据项接口 (根据实际 API 返回结构调整)
+interface StudentItem {
+  studentName: string;
+  className: string;
+  [key: string]: any; // 允许其他属性
+}
+
+// 定义 Props
+const props = withDefaults(defineProps<Props>(), {
+  showDialog: false,
+  groupTitle: "",
+  groupName: "",
+  questionTitle: "",
+  classTitle: "",
+  optionTitle: "",
+  subjectId: "",
+  questionId: "",
+  platformNumbers: () => [],
+});
+
+// 定义 Emits
+const emit = defineEmits<{
+  (e: "CloseDialog"): void;
+}>();
+
+// 响应式数据
+const rowCount = ref<number>(4); // 默认一排放4个
+const studentList = ref<StudentItem[]>([]);
+const count = ref<number>(-1); // 初始化为-1,因为 length - 1,空列表时为-1
+const showPreview = ref<boolean>(false);
+const previewIndex = ref<number>(0);
+const previewData = ref<StudentItem>({} as StudentItem);
+
+// 内部使用的 dialog 可见性状态,用于 v-model
+// 注意:如果父组件严格控制 showDialog,这里可能需要通过 watch 同步,或者直接使用 props.showDialog 并在 emit 中关闭
+// 为了保持与原逻辑一致(watch showDialog),我们这里主要依赖 props.showDialog 触发加载
+// 但 el-dialog 需要双向绑定。通常做法是:
+// 1. 父组件传 v-model:showDialog
+// 2. 这里使用 computed 或者 watch 同步
+// 鉴于原代码是 :visible.sync,Vue3 + ElementPlus 推荐 v-model。
+// 如果父组件还没改,这里可能需要一个局部变量并 watch props
+const dialogVisible = ref(props.showDialog);
+
+watch(
+  () => props.showDialog,
+  (val) => {
+    dialogVisible.value = val;
+    if (val) {
+      resetState();
+      FindCardList();
+    }
+  },
+);
+
+// 当内部关闭对话框时(例如点击遮罩层或ESC,如果允许),需要同步回父组件
+// 但原代码只有 CloseDialog 按钮触发 emit,所以这里暂时不处理 el-dialog 自带的 close 事件,
+// 除非需要在点击遮罩时关闭。如果需要,请添加 @close="handleDialogClose"
+
+const resetState = () => {
+  rowCount.value = 4;
+  showPreview.value = false;
+  previewIndex.value = 0;
+  count.value = -1;
+  previewData.value = {} as StudentItem;
+  studentList.value = [];
+};
+
+// 方法
+const FindCardList = () => {
+  findCardListNew({
+    examPaperId: props.subjectId,
+    platformNumbers: props.platformNumbers,
+    questionId: props.questionId,
+  })
+    .then((res) => {
+      if (res.code == 200) {
+        studentList.value = res?.data || [];
+      } else {
+        studentList.value = [];
+      }
+      count.value = studentList.value.length - 1;
+    })
+    .catch((err) => {
+      console.error("Failed to fetch card list:", err);
+      studentList.value = [];
+      count.value = -1;
+    });
+};
+
+// 显示答题卡
+const ShowQuestionCard = (item: StudentItem, index: number) => {
+  showPreview.value = true;
+  previewIndex.value = index;
+  previewData.value = item;
+};
+
+const ClosePreview = () => {
+  showPreview.value = false;
+};
+
+const PrevStudent = () => {
+  if (previewIndex.value <= 0) return;
+  previewIndex.value--;
+  previewData.value = studentList.value[previewIndex.value];
+  console.log(previewIndex.value, "left");
+};
+
+const NextStudent = () => {
+  if (previewIndex.value === count.value) return;
+  previewIndex.value++;
+  previewData.value = studentList.value[previewIndex.value];
+  console.log(previewIndex.value, count.value, "right");
+};
+
+// 放大
+const ZoomIn = () => {
+  if (rowCount.value == 1) return;
+  rowCount.value--;
+};
+
+// 缩小
+const ZoomOut = () => {
+  if (rowCount.value == 4) return;
+  rowCount.value++;
+};
+
+// 关闭弹窗
+const CloseDialog = () => {
+  emit("CloseDialog");
+};
+</script>
+
+<style scoped lang="scss">
+.container {
+  height: calc(100vh - 65px);
+  width: 100%;
+  padding: 20px;
+  box-sizing: border-box;
+  overflow-y: auto;
+  background-color: #fff;
+
+  .container_title {
+    font-weight: 400;
+    font-size: 16px;
+    color: #999999;
+    text-align: left;
+    line-height: 22px;
+    display: flex;
+    justify-content: space-between;
+
+    .title_left {
+      display: flex;
+      align-items: center;
+
+      .span_item {
+        font-weight: 400;
+        font-size: 16px;
+        color: #999999;
+        padding-right: 5px;
+      }
+
+      .score {
+        margin-left: 40px;
+        font-weight: 400;
+        font-size: 16px;
+        color: #666666;
+      }
+
+      .icon_zoom {
+        display: inline-flex;
+        align-items: center;
+        margin-left: 20px;
+        &.in {
+          margin-left: 40px;
+        }
+        cursor: pointer;
+        img {
+          width: 20px;
+          height: 20px;
+        }
+        span {
+          font-weight: 400;
+          font-size: 14px;
+          color: #333333;
+          margin-left: 4px;
+        }
+      }
+    }
+  }
+
+  .container_card {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0 18px;
+
+    .item_card {
+      width: calc((100% - 54px) / 4);
+      margin-top: 7px;
+
+      &.row1 {
+        .item_card_img {
+          height: 320px;
+        }
+      }
+      &.row2 {
+        .item_card_img {
+          height: 220px;
+        }
+      }
+      &.row3 {
+        .item_card_img {
+          height: 180px;
+        }
+      }
+      &.row4 {
+        .item_card_img {
+          height: 160px;
+        }
+      }
+
+      .item_card_title {
+        width: 100%;
+        margin: 13px 0 8px;
+        font-weight: 400;
+        font-size: 14px;
+        color: #666666;
+      }
+
+      .item_card_img {
+        width: 100%;
+        height: 160px;
+        background: #f0f4f8;
+        border-radius: 8px;
+        border: 1px solid #ebeef5;
+        cursor: pointer;
+        position: relative;
+        overflow: hidden;
+
+        &:hover {
+          .item_hover {
+            display: flex;
+          }
+        }
+
+        .item_hover {
+          display: none;
+          position: absolute;
+          left: 0;
+          top: 0;
+          width: 100%;
+          height: 100%;
+          background: rgba(0, 0, 0, 0.4);
+          border-radius: 8px;
+          align-items: center;
+          justify-content: center;
+
+          .show_view {
+            width: 68px;
+            height: 36px;
+            background-color: #ffffff;
+            border-radius: 4px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+
+            img {
+              width: 16px;
+              height: 16px;
+              margin-right: 4px;
+            }
+
+            span {
+              font-weight: 400;
+              font-size: 14px;
+              color: #606266;
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+.preview_modal {
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  opacity: 0.5;
+  background: #000000;
+  z-index: 2000; /* 确保层级正确 */
+}
+
+.preview_dialog {
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  padding: 80px 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 2001; /* 确保层级高于 modal */
+
+  .preview_content {
+    height: 100%;
+    flex: 1;
+    overflow: auto;
+    background: #fff;
+  }
+
+  .close_btn {
+    position: absolute;
+    top: 30px;
+    right: 70px;
+    width: 48px;
+    height: 48px;
+    background: rgba(0, 0, 0, 0.4);
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    flex-shrink: 0;
+
+    .el-icon-close {
+      color: #ffffff;
+      font-size: 23px;
+    }
+  }
+
+  .prev_btn {
+    width: 48px;
+    height: 48px;
+    background: rgba(0, 0, 0, 0.4);
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 40px;
+    cursor: pointer;
+    flex-shrink: 0;
+
+    &.disable {
+      cursor: default;
+      background: rgba(0, 0, 0, 0.3);
+
+      .el-icon-arrow-left {
+        color: #c0c4cc;
+      }
+    }
+
+    .el-icon-arrow-left {
+      color: #ffffff;
+      font-size: 23px;
+    }
+  }
+
+  .next_btn {
+    width: 48px;
+    height: 48px;
+    background: rgba(0, 0, 0, 0.4);
+    border-radius: 50%;
+    margin-left: 40px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+
+    &.disable {
+      cursor: default;
+      background: rgba(0, 0, 0, 0.3);
+
+      .el-icon-arrow-right {
+        color: #c0c4cc;
+      }
+    }
+
+    .el-icon-arrow-right {
+      color: #ffffff;
+      font-size: 23px;
+    }
+  }
+}
+</style>

+ 707 - 0
src/components/QuestionPoint.vue

@@ -0,0 +1,707 @@
+<template>
+  <div class="main_container" id="container" ref="mainContainer" @mousedown="onMouseDown" @mousemove="onMouseMove"
+    @mouseup="onMouseUp" @wheel="onWheel">
+    <canvas id="paperCanvas" ref="paperCanvas" :width="canvasInfo.width" :height="canvasInfo.height"></canvas>
+    <div class="no_paper_url" v-if="paperImage.length == 0">
+      暂无答题卡
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import { throttle, debounce } from 'lodash';
+import { editSamplingPoint } from '@/api/analysis';
+
+// 定义类型接口
+interface PaperImageItem {
+  url: string;
+  page: number;
+  [key: string]: any;
+}
+
+interface DrawDataItem {
+  x: number | string;
+  y: number | string;
+  model?: number;
+  score: number | string;
+  fullScore: number | string;
+  name: string;
+  drawLineData?: string; // JSON string
+  [key: string]: any;
+}
+
+interface Position {
+  x: number;
+  y: number;
+  page: number;
+  index: number;
+}
+
+interface ImageInfo {
+  index: number;
+  url: string;
+  height: number;
+  width: number;
+  page: number;
+}
+
+// Props
+const props = defineProps<{
+  drawData: DrawDataItem[];
+  isSetPoint: boolean;
+  usedCardType: number;
+  paperImage: PaperImageItem[];
+}>();
+
+// Emits
+const emit = defineEmits<{
+  (e: 'EditSamplingPointSuccess'): void;
+}>();
+
+// 响应式数据
+const mainContainer = ref<HTMLDivElement | null>(null);
+const paperCanvas = ref<HTMLCanvasElement | null>(null);
+
+const canvasInfo = reactive({
+  width: 0,
+  height: 0
+});
+
+const state = reactive({
+  image: null as HTMLImageElement | null,
+  isDragging: false,
+  dragStart: { x: 0, y: 0 },
+  zoomRate: 1, // 图片的缩放比例
+  scale: 1, // 放大缩小比例
+  position: { x: 0, y: 0 }, // 初始 canvas 图片位置
+  paperImgInfo: {
+    width: 0,
+    height: 0
+  },
+  canvas: null as HTMLCanvasElement | null,
+  paperCtx: null as CanvasRenderingContext2D | null,
+  dpr: window.devicePixelRatio || 1,
+  minScale: 0.3, // 最小缩放值
+  maxScale: 3, // 最大缩放值
+  loadedImages: [] as HTMLImageElement[], // 存储已加载的图片
+  imageList: [] as ImageInfo[], // 图片存储信息
+  isDrawing: false, // 用于采分点设置状态
+});
+
+let debouncedLoadImage: (() => void) | null = null;
+
+// 监听 paperImage
+watch(
+  () => props.paperImage,
+  () => {
+    console.log("地址变化了", props.paperImage);
+    canvasInfo.width = 0; // 初始重置为0
+    if (debouncedLoadImage) debouncedLoadImage();
+  },
+  { deep: true }
+);
+
+// 监听 drawData
+watch(
+  () => props.drawData,
+  () => {
+    console.log("边框数据更新了", props.drawData);
+    if (debouncedLoadImage) debouncedLoadImage();
+  },
+  { deep: true }
+);
+
+// 初始化防抖函数
+const initDebouncedLoadImage = () => {
+  debouncedLoadImage = debounce(() => {
+    loadImage();
+  }, 100);
+};
+
+// 生命周期
+onMounted(() => {
+  initDebouncedLoadImage();
+  state.canvas = paperCanvas.value;
+  if (state.canvas) {
+    state.paperCtx = state.canvas.getContext('2d');
+  }
+  window.addEventListener('resize', handleResize);
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize);
+  if (debouncedLoadImage) {
+    debouncedLoadImage.cancel();
+  }
+});
+
+// 方法
+
+// 中心化画布
+const centerCanvas = () => {
+  if (!mainContainer.value) return;
+  const { width, height } = mainContainer.value.getBoundingClientRect();
+  state.position.x = (width - canvasInfo.width) / 2;
+  state.position.y = ((height - canvasInfo.height) / 3) * 1;
+  if (state.canvas) {
+    state.canvas.style.left = `${state.position.x}px`;
+    state.canvas.style.top = `${state.position.y}px`;
+  }
+};
+
+// 加载图片
+const loadImage = () => {
+  // 清空之前加载的图片
+  state.loadedImages = [];
+  state.imageList = []; // 图片长宽信息
+  
+  console.log("加载图片打印图片地址", props.paperImage);
+  let imgList = props.paperImage;
+  
+  if (!imgList || imgList.length === 0) {
+    return;
+  }
+
+  // 创建一个promise数组来处理所有图片的加载
+  const imagePromises = imgList.map((imgItem, index) => {
+    return new Promise<HTMLImageElement>((resolve, reject) => {
+      const img = new Image();
+      img.src = imgItem.url;
+      img.onload = () => {
+        if (index === 0) {
+          canvasInfo.height = img.height;
+        } else {
+          canvasInfo.height += img.height;
+        }
+        canvasInfo.width = Math.max(canvasInfo.width, img.width);
+        
+        let objImg: ImageInfo = {
+          index: index,
+          url: imgItem.url,
+          height: img.height,
+          width: img.width,
+          page: imgItem.page
+        };
+        state.imageList.push(objImg);
+        
+        // 保存加载的图片
+        state.loadedImages[index] = img;
+        console.log("打印加载的图片", state.imageList);
+
+        state.paperImgInfo.width = img.width; // 获取宽
+        state.paperImgInfo.height = img.height; // 获取高
+        resolve(img);
+      };
+      img.onerror = () => {
+        console.error("图片加载失败", imgItem.url);
+        reject(new Error('Failed to load image'));
+      };
+    });
+  });
+
+  Promise.all(imagePromises).then(images => {
+    console.log("打印图片的高度", canvasInfo.width, canvasInfo.height);
+    // 图片加载完成后,绘制画布
+    drawCanvas();
+  }).catch(error => {
+    console.error("图片加载错误", error);
+  });
+};
+
+// 绘制画布 负责绘制画布和图片
+const drawCanvas = () => {
+  if (!state.loadedImages.length || !state.paperCtx || !state.canvas) return;
+  
+  if (!mainContainer.value) return;
+  const { width, height } = mainContainer.value.getBoundingClientRect();
+  
+  // 设置 canvas 的宽度和高度
+  const maxWidth = width - 20;
+  const maxHeight = height - 20;
+
+  // 计算宽度和高度的缩放比例
+  const widthScale = maxWidth / canvasInfo.width;
+  const heightScale = maxHeight / canvasInfo.height;
+
+  // 选择较小的缩放比例以确保整个图像都能显示
+  state.zoomRate = widthScale; // 以宽为准
+
+  // 如果计算出的缩放比例小于等于0,使用默认值
+  if (state.zoomRate <= 0) {
+    state.zoomRate = 1;
+  }
+  console.log("打印图片的缩放比例", state.zoomRate);
+  
+  // 应用缩放比例
+  state.canvas.width = canvasInfo.width * state.zoomRate * state.scale;
+  state.canvas.height = canvasInfo.height * state.zoomRate * state.scale;
+
+  console.log("打印图片的高度", canvasInfo.width, canvasInfo.height);
+  console.log("打印canvas的宽高", state.canvas.width, state.canvas.height);
+
+  // 设置 canvas 的位置
+  state.position.x = (width - state.canvas.width) / 2;
+  state.position.y = ((height - canvasInfo.height) / 3) * 1;
+  if (state.position.y < 0) {
+    state.position.y = 0;
+  }
+  console.log("打印图片的坐标位置", state.position);
+  
+  state.canvas.style.left = `${state.position.x}px`;
+  state.canvas.style.top = `${state.position.y}px`;
+  
+  let y = 0; // 初始化 y 坐标为 0
+  // 绘制所有图片
+  state.loadedImages.forEach((img) => {
+    const startY = y * state.zoomRate * state.scale;
+    state.paperCtx?.drawImage(
+      img,
+      0, 0,
+      img.width, img.height,
+      0, startY,
+      img.width * state.zoomRate * state.scale, img.height * state.zoomRate * state.scale
+    );
+    y += img.height;
+  });
+  
+  setTimeout(() => {
+    redrawCanvas();
+  }, 100);
+};
+
+// 加载画布图片 边框
+const redrawCanvas = () => {
+  if (!state.paperCtx) return;
+
+  state.paperCtx.strokeStyle = 'red';
+  state.paperCtx.lineWidth = 2;
+
+  for (let i = 0; i < props.drawData.length; i++) {
+    let item = props.drawData[i];
+    console.log("打印边框数据", item);
+    
+    // 确保数值类型正确
+    const itemX = typeof item.x === 'string' ? parseFloat(item.x) : item.x;
+    const itemY = typeof item.y === 'string' ? parseFloat(item.y) : item.y;
+    
+    let point = {
+      x: (itemX - 20) * state.zoomRate * state.scale,
+      y: (itemY - 20) * state.zoomRate * state.scale,
+      w: 80 * state.zoomRate * state.scale,
+      h: 80 * state.zoomRate * state.scale,
+    };
+    
+    state.paperCtx.strokeStyle = 'red';
+    state.paperCtx.lineWidth = 2;
+    
+    // 绘制优秀答案 //0:普通   1:优秀答案   2:典型错误
+    const modelImage = new Image();
+    if (item.model == 1) {
+      // 注意:在 Vite/Webpack 中,require 可能需要替换为 import 或 new URL()
+      // 这里假设构建工具能处理 require,或者你需要预先导入这些图片
+      modelImage.src = require('../assets/tool/model_1.png');
+    }
+    if (item.model == 2) {
+      modelImage.src = require('../assets/tool/model_2.png');
+    }
+    
+    if (item.model === 1 || item.model === 2) {
+      modelImage.onload = () => {
+        if (state.paperCtx) {
+          state.paperCtx.drawImage(modelImage, 0, 0, 50 * state.zoomRate * state.scale, 50 * state.zoomRate * state.scale);
+        }
+      };
+    }
+
+    // 加载小图标
+    const iconImage = new Image();
+    const scoreNum = typeof item.score === 'string' ? parseFloat(item.score) : item.score;
+    const fullScoreNum = typeof item.fullScore === 'string' ? parseFloat(item.fullScore) : item.fullScore;
+
+    if (scoreNum == fullScoreNum) {
+      // 等于满分
+      iconImage.src = require('../assets/icon/icon_all_right.png');
+    } else if (scoreNum == 0) {
+      // 全错
+      iconImage.src = require('../assets/icon/icon_all_wrong.png');
+    } else {
+      // 半对
+      iconImage.src = require('../assets/icon/icon_half_right.png');
+    }
+
+    iconImage.onload = () => {
+      if (state.paperCtx) {
+        state.paperCtx.drawImage(iconImage, point.x, point.y, point.w, point.h);
+      }
+    };
+
+    // 根据缩放比例动态调整字体大小
+    const baseFontSize = 14; // 基础字体大小
+    const scoreFontSize = 24; // 分数字体大小
+    const scaledBaseFontSize = Math.max(12, baseFontSize * state.zoomRate * state.scale); // 最小12px
+    const scaledScoreFontSize = Math.max(16, scoreFontSize * state.zoomRate * state.scale); // 最小16px
+
+    // 绘制题目名称
+    state.paperCtx.font = `${scaledBaseFontSize}px Arial`;
+    state.paperCtx.fillStyle = 'blue';
+    state.paperCtx.textAlign = 'center';
+    state.paperCtx.textBaseline = 'middle';
+    state.paperCtx.fillText(item.name, point.x + 10, point.y);
+
+    // 绘制分数
+    state.paperCtx.font = `${scaledScoreFontSize}px Arial`;
+    state.paperCtx.fillStyle = '#D81E06';
+    state.paperCtx.textAlign = 'center';
+    state.paperCtx.textBaseline = 'middle';
+    state.paperCtx.fillText(String(item.score), point.x + 75 * state.zoomRate * state.scale, point.y + 40 * state.zoomRate * state.scale);
+
+    // 绘制标注信息
+    if (item.drawLineData) {
+      // 有标注信息 加载标注
+      console.log("打印point", point);
+      try {
+        const drawLineData = JSON.parse(item.drawLineData);
+        console.log("打印drawLineData", drawLineData);
+
+        for (var j = 0; j < drawLineData.length; j++) {
+          var drawItem = drawLineData[j];
+          console.log("打印drawItem", drawItem);
+          
+          // 扣分点 加分点 标记// 1:文字 (扣分留痕 显示扣分信息)  2:划线  3:波浪线   4:画笔 5:评语
+          if (drawItem.drawType == 1) {
+            const fontSize = Math.max(12, 40 * state.zoomRate * state.scale); // 最小字体12px,基础字体16px
+            state.paperCtx.font = `${fontSize}px Arial`; // 让文字大小跟随缩放
+            state.paperCtx.fillStyle = '#D81E06';
+            state.paperCtx.textAlign = 'center';
+            state.paperCtx.textBaseline = 'middle';
+            if (drawItem.type == 'reduce') {
+              state.paperCtx.fillText('-' + drawItem.score, (drawItem.x) * state.zoomRate * state.scale, (drawItem.y) * state.zoomRate * state.scale);
+            }
+            if (drawItem.type == 'bonus') {
+              state.paperCtx.fillText('+' + drawItem.score, (drawItem.x) * state.zoomRate * state.scale, (drawItem.y) * state.zoomRate * state.scale);
+            }
+          } else if (drawItem.drawType == 2) {
+            // 绘制横线
+            DrawHorizontalLine((drawItem.x) * state.zoomRate * state.scale, (drawItem.y) * state.zoomRate * state.scale, (drawItem.endX) * state.zoomRate * state.scale, (drawItem.endY) * state.zoomRate * state.scale);
+          } else if (drawItem.drawType == 3) {
+            // 绘制波浪线
+            let startX = parseFloat(((drawItem.x) * state.zoomRate * state.scale).toFixed(2));
+            let startY = parseFloat(((drawItem.y) * state.zoomRate * state.scale).toFixed(2));
+            let endX = parseFloat(((drawItem.endX) * state.zoomRate * state.scale).toFixed(2));
+            let endY = parseFloat(((drawItem.endY) * state.zoomRate * state.scale).toFixed(2));
+            DrawWaveLine(startX, startY, endX, endY);
+          } else if (drawItem.drawType == 4) {
+            // 绘制画笔数据
+            DrawPenLine(drawItem);
+          } else if (drawItem.drawType == 5) {
+            // 绘制评语
+            let startX = parseFloat(((drawItem.x) * state.zoomRate * state.scale).toFixed(2));
+            let startY = parseFloat(((drawItem.y) * state.zoomRate * state.scale).toFixed(2));
+            DrawText(startX, startY, drawItem.text);
+          }
+        }
+      } catch (e) {
+        console.error("解析 drawLineData 失败", e);
+      }
+    }
+  }
+};
+
+// 转成整数
+const GetInteger = (value: any): number => {
+  // 如果值为空、undefined 或 null,返回 0
+  if (value === null || value === undefined || value === '') {
+    return 0;
+  }
+  // 如果已经是数字,直接处理
+  if (typeof value === 'number') {
+    // 检查是否为 NaN
+    if (isNaN(value)) {
+      return 0;
+    }
+    // 使用 Math.round 四舍五入取整
+    return Math.round(value);
+  }
+  // 如果是字符串,先尝试转换为数字
+  if (typeof value === 'string') {
+    // 去除首尾空格
+    value = value.trim();
+    // 如果是空字符串,返回 0
+    if (value === '') {
+      return 0;
+    }
+    // 转换为数字
+    const num = Number(value);
+    // 检查是否为有效数字
+    if (isNaN(num)) {
+      return 0;
+    }
+    // 四舍五入取整
+    return Math.round(num);
+  }
+  // 其他情况,尝试转换为数字后取整
+  const num = Number(value);
+  return isNaN(num) ? 0 : Math.round(num);
+};
+
+// 画横线
+const DrawHorizontalLine = (x1: number, y1: number, x2: number, y2: number) => {
+  if (!state.paperCtx) return;
+  console.log("画横线起始坐标点", x1, y1, x2, y2);
+  state.paperCtx.strokeStyle = 'red'; // 设置红色
+  state.paperCtx.lineWidth = 4; // 线条宽度
+  state.paperCtx.beginPath();
+  state.paperCtx.moveTo(x1, y1);
+  state.paperCtx.lineTo(x2, y2);
+  state.paperCtx.stroke();
+};
+
+// 画波浪线
+const DrawWaveLine = (startX: number, startY: number, endX: number, endY: number) => {
+  if (!state.paperCtx) return;
+  const amplitude = 2; // 波浪的宽度
+  const frequency = 0.8; // 振幅 越大越密集
+  // 计算中间点
+  const dx = endX - startX;
+  const dy = endY - startY;
+
+  state.paperCtx.strokeStyle = 'red'; // 设置红色
+  state.paperCtx.lineWidth = 4; // 线条宽度
+  state.paperCtx.beginPath();
+  state.paperCtx.moveTo(startX, startY); // 绘制第一个点
+  
+  // 绘制波浪线
+  if (dx !== 0) {
+      for (let x = startX; x <= endX; x += 1) {
+        const y = startY + dy * (x - startX) / dx + amplitude * Math.sin(frequency * (x - startX));
+        state.paperCtx.lineTo(x, y);
+      }
+  } else {
+      // 垂直线处理
+      state.paperCtx.lineTo(endX, endY);
+  }
+  
+  state.paperCtx.stroke();
+};
+
+// 绘制画笔数据
+const DrawPenLine = (data: any) => {
+  if (!state.paperCtx) return;
+  let linelist = data.drawlineData;
+  if (linelist && linelist.length > 0) {
+    state.paperCtx.strokeStyle = 'red'; // 设置红色
+    state.paperCtx.lineWidth = 4; // 线条宽度
+    // 开始绘制
+    state.paperCtx.beginPath();
+    // 绘制路径
+    for (let i = 0; i < linelist.length; i++) {
+      let x = parseFloat(((linelist[i].x) * state.zoomRate * state.scale).toFixed(2));
+      let y = parseFloat(((linelist[i].y) * state.zoomRate * state.scale).toFixed(2));
+      state.paperCtx.lineTo(x, y);
+    }
+    state.paperCtx.stroke();
+    state.paperCtx.closePath();
+  }
+};
+
+// 绘制文字
+const DrawText = (startX: number, startY: number, text: string) => {
+  if (!state.paperCtx) return;
+  // 设置字体样式
+  state.paperCtx.font = '16px Arial';
+  state.paperCtx.fillStyle = 'red';
+  state.paperCtx.textAlign = 'left';
+  // 绘制文字
+  state.paperCtx.fillText(text, startX, startY);
+};
+
+// 设置鼠标复位
+const mouseReset = () => {
+  if (!state.canvas) return;
+  state.canvas.onmouseup = null;
+  state.canvas.onmousedown = null;
+  state.canvas.onmousemove = null;
+  state.canvas.onmousemove = () => {
+    if (state.canvas) state.canvas.style.cursor = 'pointer';
+  }; // 鼠标改为手
+};
+
+// 设置采分点
+const SetSamplingPoint = (item: any) => {
+  console.log("设置采分点", item);
+  console.log("打印paperImage", props.paperImage);
+  console.log("打印this.imageList", state.imageList);
+  
+  if (!state.canvas) return;
+
+  state.canvas.onmousedown = null; // 清空鼠标按下事件
+  state.isDrawing = false;
+  
+  state.canvas.onmousedown = (e: MouseEvent) => {
+    console.log("鼠标按下了", e);
+    console.log("设置采分点", item);
+    console.log("打印this.scale", state.scale);
+    console.log("缩放的比例", state.zoomRate);
+
+    if (e.button === 0) {
+      console.log("e.offsetX", e.offsetX);
+      console.log("e.offsetY", e.offsetY);
+      const offsetX = Math.round(e.offsetX / state.scale / state.zoomRate);
+      const offsetY = Math.round(e.offsetY / state.scale / state.zoomRate);
+      
+      let position: Position = {
+        x: offsetX,
+        y: offsetY,
+        page: props.paperImage[0]?.page || 0,
+        index: 0, // 默认第一个图片的位置
+      }; // 默认的位置
+      console.log("打印位置", position);
+
+      // 这里需要判断高度是在哪个页块区
+      let imgHeight = 0;
+      let page = state.imageList[0]?.page || 0; // 默认第一个
+      
+      for (let i = 0; i < state.imageList.length; i++) {
+        // 累加当前图片之前的总高度
+        const previousHeight = imgHeight;
+        imgHeight += state.imageList[i].height; // 这里不需要计算缩放 因为offsetX已经是处理过缩放后的数据了
+        console.log("打印当前累计的高度", imgHeight);
+        console.log("打印previousHeight高度", previousHeight);
+        
+        // 判断点击位置是否在当前图片区域内
+        if (offsetY >= previousHeight && offsetY < imgHeight) {
+          page = state.imageList[i].page;
+          // 计算相对于当前图片的Y坐标
+          const relativeY = Math.round(offsetY - previousHeight);
+          // X坐标保持不变(如果需要相对X坐标也可以计算)因为图片是竖向累加的 所以图片x坐标保持不变
+          const relativeX = Math.round(offsetX);
+          position = {
+            x: relativeX,
+            y: relativeY,
+            page: page,
+            index: i,
+          };
+          break;
+        }
+      }
+      position.page = page;
+      console.log("打印当前采分点所在的页码", page);
+      console.log("打印计算后相对坐标的位置", position);
+      console.log("打印缩放值", state.scale);
+      
+      if (state.paperCtx) {
+          state.paperCtx.strokeStyle = 'red';
+          state.paperCtx.lineWidth = 1;
+      }
+
+      console.log("打印item", item);
+      if (item == undefined) {
+        mouseReset();
+      } else {
+        let params = {
+          id: item.id, // id
+          examSubjectId: item.examSubjectId, // 试卷科目id
+          answerPaintingId: item.answerPaintingId, //
+          name: item.name, // 采分点名称
+          position: JSON.stringify(position), // 采分点坐标
+        };
+        editSamplingPoint(params).then(res => {
+          if (res.code === 200) {
+            console.log("获取编辑采分点位置", res);
+            emit('EditSamplingPointSuccess');
+            // 成功后可以选择是否重置鼠标状态,根据业务需求
+            // mouseReset(); 
+          }
+        });
+      }
+    }
+  };
+  
+  state.canvas.onmousemove = () => {
+    if (state.canvas) state.canvas.style.cursor = 'crosshair';
+  };
+};
+
+// 适合屏幕
+const fitScreen = () => {
+  console.log("调用适合屏幕方法了");
+  state.scale = 1;
+  loadImage();
+};
+
+// 鼠标按下事件
+const onMouseDown = (event: MouseEvent) => {
+  // 如果正在设置采分点,可能不希望触发拖拽,或者需要更复杂的逻辑判断
+  // 这里保留原逻辑,假设拖拽优先级高,或者由外部控制
+  state.isDragging = true;
+  state.dragStart.x = event.clientX - state.position.x;
+  state.dragStart.y = event.clientY - state.position.y;
+};
+
+// 鼠标移动事件
+const onMouseMove = (event: MouseEvent) => {
+  if (state.isDragging) {
+    state.position.x = event.clientX - state.dragStart.x;
+    state.position.y = event.clientY - state.dragStart.y;
+    if (state.canvas) {
+      state.canvas.style.left = `${state.position.x}px`;
+      state.canvas.style.top = `${state.position.y}px`;
+    }
+  }
+};
+
+// 鼠标抬起事件
+const onMouseUp = () => {
+  state.isDragging = false;
+};
+
+// 鼠标滚轮事件  放大或者缩小
+const onWheel = (event: WheelEvent) => {
+  event.preventDefault();
+  const delta = event.deltaY < 0 ? 1 : -1;
+  const newScale = state.scale + delta * 0.1;
+  state.scale = Math.max(state.minScale, Math.min(state.maxScale, newScale));
+  console.log("打印缩放值", state.scale);
+  console.log("打印定位", state.position);
+
+  // 只缩放画布,不重新加载图片
+  // this.scaleCanvas();
+  loadImage(); // 原代码是重新加载,如果性能允许可以保留,否则建议优化为只重绘
+};
+
+// 处理窗口大小变化
+const handleResize = throttle(() => {
+  // 可以在这里添加重新计算布局的逻辑
+  // 例如: loadImage(); 或者 drawCanvas();
+}, 500);
+
+// 暴露方法给父组件
+defineExpose({
+  fitScreen,
+  SetSamplingPoint,
+  mouseReset
+});
+
+</script>
+
+<style lang="scss" scoped>
+#paperCanvas {
+  position: absolute;
+  cursor: pointer;
+  // border:1px solid red;
+}
+
+.main_container {
+  // position: relative;
+  // width: 100%;
+  // height: 100%;
+  overflow: hidden;
+  display: flex;
+}
+
+.no_paper_url {
+  margin: auto;
+  color: #666;
+}
+</style>

+ 370 - 276
src/components/ReportModule.vue

@@ -1,324 +1,418 @@
 <template>
-    <div class="report_module">
-        <div class="module_title" v-if="showHeader">
-            <div class="title_left">
-                <template v-if="showTitle && titleList.length">
-                    <template v-for="(item, index) in titleList">
-                        <span :class="{ span_item: index != titleList.length - 1 }">{{ item }}</span><span
-                            v-if="index != titleList.length - 1" class="split_line">/</span>
-                    </template>
-                </template>
-                <slot name="title_left" />
-            </div>
-            <div class="title_right">
-                <slot name="title_right" />
-                <template v-if="showPrintBtn">
-                    <el-button class="default_button" :loading="state.printLoading">
-                        <img v-if="!state.printLoading" src="@/assets/icon/print_icon.webp" alt="打印PDF" />打印PDF
-                    </el-button>
-                </template>
-                <template v-if="showExportBtn">
-                    <el-button class="default_button" :loading="state.exportLoading">
-                        <img v-if="!state.exportLoading" src="@/assets/icon/export_icon.webp" alt="导出Excel" />导出Excel
-                    </el-button>
-                </template>
-            </div>
-        </div>
-        <div :class="[`module_${tableOrChart}`, { table_42: tableOrChart == 'table' }]">
-            <!-- 其他 -->
-            <slot name="module_qita" />
-            <!-- 表格或图表显示 -->
-            <slot name="module_table_chart" />
-            <!-- 表格分页 -->
-            <div class="page_pagination" v-if="tableOrChart == 'table' && showTablePage && (total > pageSize)">
-                <el-pagination background :current-page="currentPage" :page-size="pageSize" :page-sizes="pageSizes"
-                    layout="total,sizes,prev,pager,next" :total="total" @size-change="HandleSizeChange"
-                    @current-change="HandleCurrentChange" />
-            </div>
-        </div>
-        <!-- 描述 -->
-        <div v-if="showDescribe" class="module_describe">
-            <div ref="textContainer" class="text_container" :class="{ 'expanded': state.isExpanded }">
-                <slot name="module_describe"></slot>
-            </div>
-            <button v-if="state.showExpandButton" @click="ToggleExpand" class="toggle_button"
-                :class="state.isExpanded ? 'show_expanded' : 'hide_expanded'">
-                {{ state.isExpanded ? '收起' : '展开' }}
-            </button>
-        </div>
+  <div class="report_module">
+    <div class="module_title" v-if="showHeader">
+      <div class="title_left">
+        <template v-if="showTitle && titleList.length">
+          <template v-for="(item, index) in titleList">
+            <span :class="{ span_item: index != titleList.length - 1 }">{{
+              item
+            }}</span>
+            <span v-if="index != titleList.length - 1" class="split_line"
+              >/</span
+            >
+          </template>
+        </template>
+        <slot name="title_left" />
+      </div>
+      <div class="title_right">
+        <slot name="title_right" />
+        <template v-if="showPrintBtn">
+          <el-button class="default_button" :loading="state.printLoading" @click="PrintPdf">
+            <img
+              v-if="!state.printLoading"
+              src="@/assets/icon/print_icon.webp"
+            />打印PDF
+          </el-button>
+        </template>
+        <template v-if="showExportBtn">
+          <el-button
+            class="default_button"
+            :loading="state.exportLoading"
+            @click="ExportExcel"
+          >
+            <img
+              v-if="!state.exportLoading"
+              src="@/assets/icon/export_icon.webp"
+            />导出Excel
+          </el-button>
+        </template>
+      </div>
     </div>
+    <div
+      :class="[`module_${tableOrChart}`, { table_42: tableOrChart == 'table' }]"
+    >
+      <!-- 其他 -->
+      <slot name="module_qita" />
+      <!-- 表格或图表显示 -->
+      <slot name="module_table_chart" />
+      <!-- 表格分页 -->
+      <div
+        class="page_pagination"
+        v-if="tableOrChart == 'table' && showTablePage && total > pageSize"
+      >
+        <el-pagination
+          background
+          :current-page="currentPage"
+          :page-size="pageSize"
+          :page-sizes="pageSizes"
+          layout="total,sizes,prev,pager,next"
+          :total="total"
+          @size-change="ChangePageSize"
+          @current-change="ChangeCurrentPage"
+        />
+      </div>
+    </div>
+    <!-- 描述 -->
+    <div v-if="showDescribe" class="module_describe">
+      <div
+        ref="textContainer"
+        class="text_container"
+        :class="{ expanded: state.isExpanded }"
+      >
+        <slot name="module_describe"></slot>
+      </div>
+      <button
+        v-if="state.showExpandButton"
+        @click="ToggleExpand"
+        class="toggle_button"
+        :class="state.isExpanded ? 'show_expanded' : 'hide_expanded'"
+      >
+        {{ state.isExpanded ? "收起" : "展开" }}
+      </button>
+    </div>
+  </div>
 </template>
 <script lang="ts" setup>
-import { onMounted, reactive, nextTick, ref } from 'vue'
+import { onMounted, reactive, nextTick, ref } from "vue";
 
 interface TitleListType {
-    showHeader?: boolean
-    titleList?: string[]
-    showTitle?: boolean
-    showPrintBtn?: boolean
-    showExportBtn?: boolean
-    tableOrChart?: 'table' | 'chart' | 'qita'
-    showTablePage?: boolean
-    currentPage?: number
-    pageSize?: number
-    pageSizes?: number[]
-    total?: number
-    showDescribe?: boolean
+  showHeader?: boolean;
+  titleList?: string[];
+  showTitle?: boolean;
+  showPrintBtn?: boolean;
+  showExportBtn?: boolean;
+  tableOrChart?: "table" | "chart" | "qita";
+  showTablePage?: boolean;
+  currentPage?: number;
+  pageSize?: number;
+  pageSizes?: number[];
+  total?: number;
+  showDescribe?: boolean;
 }
 
 const props = withDefaults(defineProps<TitleListType>(), {
-    showHeader: true,
-    titleList: () => [],//标题
-    showTitle: true,//是否显示标题
-    showPrintBtn: true,//是否显示打印按钮
-    showExportBtn: true,//是否显示导出按钮
-    tableOrChart: 'table',//表格、图表、其他
-    showTablePage: true,//是否显示分页
-    currentPage: 1,
-    pageSize: 10,
-    pageSizes: () => [10, 20, 30, 40, 50, 100],
-    total: 0,
-    showDescribe: true//是否显示描述
-})
+  showHeader: true,
+  titleList: () => [], //标题
+  showTitle: true, //是否显示标题
+  showPrintBtn: true, //是否显示打印按钮
+  showExportBtn: true, //是否显示导出按钮
+  tableOrChart: "table", //表格、图表、其他
+  showTablePage: true, //是否显示分页
+  currentPage: 1,
+  pageSize: 10,
+  pageSizes: () => [10, 20, 30, 40, 50, 100],
+  total: 0,
+  showDescribe: true, //是否显示描述
+});
 
 const emit = defineEmits<{
-    'update:currentPage': [val: number]
-    'update:pageSize': [val: number]
-}>()
+  ChangeCurrentPage: [val: number];
+  ChangePageSize: [val: number];
+  PrintPdf: [];
+  ExportExcel: [];
+}>();
 
 // 状态管理
 const state = reactive({
-    printLoading: false,
-    exportLoading: false,
-    isExpanded: false,
-    showExpandButton: true
-})
-
-const textContainer = ref<HTMLElement | null>(null)
-// 分页事件
-const HandleSizeChange = (val: number) => {
-    emit('update:pageSize', val)
-}
-
-const HandleCurrentChange = (val: number) => {
-    emit('update:currentPage', val)
-}
+  printLoading: false,
+  exportLoading: false,
+  isExpanded: false,
+  showExpandButton: true,
+});
+
+const textContainer = ref<HTMLElement | null>(null);
+// 分页大小事件
+const ChangePageSize = (val: number) => {
+  emit("ChangePageSize", val);
+};
+// 当前页码事件
+const ChangeCurrentPage = (val: number) => {
+  emit("ChangeCurrentPage", val);
+};
+// 打印PDF
+const PrintPdf = () => {
+  emit("PrintPdf");
+};
+// 设置打印PDF加载状态
+const SetPrintLoading = (val: boolean) => {
+  state.printLoading = val;
+};
+// 导出Excel
+const ExportExcel = () => {
+  emit("ExportExcel");
+};
+// 设置导出Excel加载状态
+const SetExportLoading = (val: boolean) => {
+  state.exportLoading = val;
+};
 //文本展开收起逻辑
 const checkLines = () => {
-    const container = textContainer.value
-    if (!container) return
-
-    const lineHeight = parseInt(
-        window.getComputedStyle(container).lineHeight || '24',
-        10
-    )
-    const textHeight = container.scrollHeight
-    const lines = Math.ceil(textHeight / lineHeight)
-    state.showExpandButton = lines > 3
-}
-
+  const container = textContainer.value;
+  if (!container) return;
+
+  const lineHeight = parseInt(
+    window.getComputedStyle(container).lineHeight || "24",
+    10,
+  );
+  const textHeight = container.scrollHeight;
+  const lines = Math.ceil(textHeight / lineHeight);
+  state.showExpandButton = lines > 3;
+};
+// 文本展开收起事件
 const ToggleExpand = () => {
-    state.isExpanded = !state.isExpanded
-}
+  state.isExpanded = !state.isExpanded;
+};
 
 onMounted(() => {
-    nextTick(() => checkLines())
-})
+  nextTick(() => checkLines());
+});
+defineExpose({
+  SetExportLoading,
+  SetPrintLoading,
+});
 </script>
 
 <style lang="scss" scoped>
 .report_module {
+  width: 100%;
+  background-color: #fff;
+  margin-top: 10px;
+  border-radius: 10px;
+
+  &:nth-child(1) {
+    margin-top: 0;
+  }
+
+  .module_title {
     width: 100%;
-    background-color: #fff;
-    margin-top: 10px;
-    border-radius: 10px;
-
-    .module_title {
-        width: 100%;
-        padding: 20px 20px 10px;
-        box-sizing: border-box;
-        margin: auto;
-        font-weight: 600;
+    padding: 20px 20px 10px;
+    box-sizing: border-box;
+    margin: auto;
+    font-weight: 600;
+    font-size: 16px;
+    color: #333333;
+    text-align: left;
+    line-height: 36px;
+    display: flex;
+    justify-content: space-between;
+
+    .title_left {
+      display: inline-flex;
+      align-items: center;
+      flex-wrap: wrap;
+
+      .span_item {
+        font-weight: 400;
+        font-size: 16px;
+        color: #999999;
+      }
+
+      .split_line {
+        font-weight: 400;
         font-size: 16px;
-        color: #333333;
-        text-align: left;
+        color: #999999;
+        margin: 0 6px;
+      }
+    }
+
+    .title_right {
+      display: flex;
+      justify-content: flex-end;
+      align-items: center;
+      gap: 10px;
+
+      .default_button {
+        margin-left: 0px;
+        height: 36px;
         line-height: 36px;
+        padding: 0 10px;
+        font-weight: 400;
+        font-size: 14px;
+        color: #666666;
         display: flex;
-        justify-content: space-between;
-
-        .title_left {
-            display: inline-flex;
-            align-items: center;
-            flex-wrap: wrap;
-
-            .span_item {
-                font-weight: 400;
-                font-size: 16px;
-                color: #999999;
-            }
-
-            .split_line {
-                font-weight: 400;
-                font-size: 16px;
-                color: #999999;
-                margin: 0 6px;
-            }
+        align-items: center;
+
+        &:active,
+        &:focus {
+          outline: none; // 去掉点击后的轮廓
+          box-shadow: none; // 去掉点击后的阴影
+          background-color: inherit; // 保持背景颜色不变
+          color: #666666; // 保持文字颜色不变
+          border: 1px solid #dcdfe6;
         }
 
-        .title_right {
-            display: flex;
-            justify-content: flex-end;
-            align-items: center;
-            gap: 10px;
-
-            .default_button {
-                margin-left: 0px;
-                height: 36px;
-                line-height: 36px;
-                padding: 0 10px;
-                font-weight: 400;
-                font-size: 14px;
-                color: #666666;
-                display: flex;
-                align-items: center;
-
-                &:active,
-                &:focus {
-                    outline: none; // 去掉点击后的轮廓
-                    box-shadow: none; // 去掉点击后的阴影
-                    background-color: inherit; // 保持背景颜色不变
-                    color: #666666; // 保持文字颜色不变
-                    border: 1px solid #DCDFE6;
-                }
-
-                &:hover {
-                    background-color: #f9f9f9;
-                    border: 1px solid #DCDFE6;
-                    color: #666;
-                }
-
-                span {
-                    display: flex;
-                    align-items: center;
-                }
-
-                img {
-                    width: 16px;
-                    height: 16px;
-                    margin-right: 4px;
-                }
-
-                .el-icon-loading {
-                    color: #666;
-                }
-            }
+        &:hover {
+          background-color: #f9f9f9;
+          border: 1px solid #dcdfe6;
+          color: #666;
+        }
+
+        span {
+          display: flex;
+          align-items: center;
         }
-    }
 
-    .module_table {
-        width: 100%;
-        padding: 0 20px 14px;
-        box-sizing: border-box;
-        border-collapse: collapse;
-        :deep(.el-table){
-            border-left: 0px;
-            border-right: 0px;
+        img {
+          width: 16px;
+          height: 16px;
+          margin-right: 4px;
         }
 
-        :deep(.table_row_blue) {
-            color: #2e64fa;
-            cursor: pointer;
+        .el-icon-loading {
+          color: #666;
         }
+      }
     }
+  }
 
-    .module_chart {
-        width: 100%;
-        padding: 0 20px;
-        box-sizing: border-box;
-        min-height: 360px;
-
-        //无数据显示
-        :deep(.no_content_data) {
-            background-image: url("../assets/bg/no_content_bg.png");
-            background-repeat: no-repeat;
-            background-size: 360px auto;
-            background-position: 50% 30%;
-            color: #999999;
-            justify-content: center;
-            display: flex;
-            align-items: center;
-            min-height: 360px;
-            span{
-                margin-top: 80px;
-                font-size: 14px;
-            }
+  .module_table {
+    width: 100%;
+    padding: 0 20px 14px;
+    box-sizing: border-box;
+    border-collapse: collapse;
+
+    :deep(.el-table) {
+      border-radius: 6px;
+      border-left: 0px;
+      border-right: 0px;
+
+      .el-table__header {
+        .cell {
+          font-size: 14px;
         }
+      }
+      .cell {
+        display: block;
+        text-align: center;
+        white-space: nowrap;
+        padding: 0 0 0 4px !important;
+      }
     }
 
-    .module_describe {
-        width: 100%;
-        padding: 14px 20px;
-        box-sizing: border-box;
-        font-weight: 400;
+    :deep(.table_row_blue) {
+      color: #2e64fa;
+      cursor: pointer;
+    }
+  }
+
+  .module_chart {
+    width: 100%;
+    padding: 0 20px;
+    box-sizing: border-box;
+    min-height: 360px;
+
+    //无数据显示
+    :deep(.no_content_data) {
+      background-image: url("../assets/bg/no_content_bg.png");
+      background-repeat: no-repeat;
+      background-size: 360px auto;
+      background-position: 50% 30%;
+      color: #999999;
+      justify-content: center;
+      display: flex;
+      align-items: center;
+      min-height: 360px;
+
+      span {
+        margin-top: 80px;
         font-size: 14px;
-        color: #999999;
-        line-height: 24px;
-        text-align: left;
-
-        .text_container {
-            position: relative;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            display: -webkit-box;
-            -webkit-line-clamp: 3;
-            -webkit-box-orient: vertical;
-            transition: max-height 0.3s ease-out;
-            max-height: 72px;
-
-            p {
-                line-height: 22px;
-            }
-        }
+      }
+    }
+  }
 
-        .text_container.expanded {
-            -webkit-line-clamp: unset;
-            max-height: none;
-        }
+  .module_describe {
+    width: 100%;
+    padding: 14px 20px;
+    box-sizing: border-box;
+    font-weight: 400;
+    font-size: 14px;
+    color: #999999;
+    line-height: 24px;
+    text-align: left;
+
+    .text_container {
+      position: relative;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      display: -webkit-box;
+      -webkit-line-clamp: 3;
+      -webkit-box-orient: vertical;
+      transition: max-height 0.3s ease-out;
+      max-height: 72px;
+
+      p {
+        line-height: 22px;
+      }
+    }
 
-        .toggle_button {
-            margin-top: 8px;
-            cursor: pointer;
-            background: none;
-            border: none;
-            color: #2E64FA;
-            font-size: 14px;
-            line-height: 1;
-            width: 100%;
-            height: 24px;
-            text-align: right;
-            padding: 0 20px 0 0;
-        }
+    .text_container.expanded {
+      -webkit-line-clamp: unset;
+      max-height: none;
+    }
 
-        .show_expanded {
-            background-image: url('@/assets/icon/up_expanded.webp');
-            background-size: 16px 16px;
-            background-position: 100% 50%;
-            background-repeat: no-repeat;
-        }
+    .toggle_button {
+      margin-top: 8px;
+      cursor: pointer;
+      background: none;
+      border: none;
+      color: #2e64fa;
+      font-size: 14px;
+      line-height: 1;
+      width: 100%;
+      height: 24px;
+      text-align: right;
+      padding: 0 20px 0 0;
+    }
 
-        .hide_expanded {
-            background-image: url('@/assets/icon/down_expanded.webp');
-            background-size: 16px 16px;
-            background-position: 100% 50%;
-            background-repeat: no-repeat;
-        }
+    .show_expanded {
+      background-image: url("@/assets/icon/up_expanded.webp");
+      background-size: 16px 16px;
+      background-position: 100% 50%;
+      background-repeat: no-repeat;
     }
 
-    .module_qita {
-        width: 100%;
-        padding: 0 20px;
-        box-sizing: border-box;
-        display: flex;
+    .hide_expanded {
+      background-image: url("@/assets/icon/down_expanded.webp");
+      background-size: 16px 16px;
+      background-position: 100% 50%;
+      background-repeat: no-repeat;
+    }
+  }
+
+  .module_qita {
+    width: 100%;
+    display: flex;
+    :deep(.content_left) {
+      padding-left: 20px;
+      box-sizing: border-box;
+    }
+    :deep(.content_right) { 
+      padding-right: 20px;
+      box-sizing: border-box;
+    }
+    :deep(.table_42) {
+      padding-bottom: 0;
+      .el-table {
+        border-left: 0px;
+        border-right: 0px;
+        .cell {
+          display: block;
+          text-align: center;
+          white-space: nowrap;
+          padding: 0 0 0 4px !important;
+        }
+      }
     }
+  }
 }
-</style>
+</style>

+ 381 - 0
src/components/StudentQuestionImg.vue

@@ -0,0 +1,381 @@
+<template>
+  <div 
+    class="canvas_image" 
+    v-loading="isLoading" 
+    element-loading-text="加载中……" 
+    element-loading-spinner="el-icon-loading" 
+    element-loading-background="#ffffff"
+  >
+    <!-- PointCanvas 组件需要确保也是 Vue 3 版本 -->
+    <PointCanvas 
+      ref="pointCanvasRef" 
+      :usedCardType="usedCardType" 
+      :drawData="currentDrawData" 
+      :paperImage="paperImage" 
+      type="question"
+    ></PointCanvas>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onMounted, nextTick } from 'vue';
+import PointCanvas from "@/components/QuestionPoint.vue"; // 小题的画布版本
+import { getStudentPaperCardInfo } from '@/api/analysis';
+
+// --- 类型定义 ---
+
+interface PagePaintingVO {
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+  page: number;
+  [key: string]: any;
+}
+
+interface QuestionVO {
+  questionName: string;
+  fullScore: number;
+  score: number;
+  titleType: number; // 1: 客观题, 2: 主观题, etc.
+  samplingPosition: string; // JSON string
+  drawLineData?: any;
+  model?: any;
+  pagePaintingVOS?: PagePaintingVO[];
+  [key: string]: any;
+}
+
+interface PageVO {
+  picUrl: string;
+  page: number;
+  questionVOS?: QuestionVO[];
+  [key: string]: any;
+}
+
+interface PaperDataResult {
+  pageVOS: PageVO[];
+  usedCardType: number; // 1: 系统卡, 2: 三方卡
+  [key: string]: any;
+}
+
+interface Props {
+  paperInfo?: Record<string, any>; // 试卷信息
+  paperData?: Record<string, any>; // 试卷分析批量查看答题卡数据
+  isBatch?: boolean; // 试卷分析批量查看答题卡
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  paperInfo: () => ({}),
+  paperData: () => ({}),
+  isBatch: false
+});
+
+// --- 响应式数据 ---
+
+const pointCanvasRef = ref<InstanceType<typeof PointCanvas> | null>(null);
+
+const paperImageList = ref<PageVO[]>([]); // 学生试卷图片列表
+const currentIndex = ref<number>(0); // 当前学生试卷图片索引
+const currentPaperUrl = ref<string>(''); // 当前学生试卷图片地址
+
+const paperImage = ref<{ url: string; page: number }[]>([]); // 学生试卷图片列表(用于显示)
+const currentDownLoadName = ref<string>(''); // 当前学生试卷图片下载名称
+const currentDrawData = ref<any[]>([]); // 当前学生试卷答题标记数据
+const questionList = ref<QuestionVO[]>([]); // 学生试卷题目列表
+const usedCardType = ref<number | null>(null); // 1系统卡 2 三方卡
+const isLoading = ref<boolean>(false); // 是否正在加载中
+
+// --- 方法 ---
+
+// 获取图片信息 (宽高)
+const GetImageInfo = async (imageUrl: string): Promise<{ width: number; height: number }> => {
+  try {
+    // 注意:fetch 可能在某些环境下需要配置代理或处理 CORS
+    const response = await fetch(imageUrl + '?x-oss-process=image/info');
+    if (!response.ok) {
+      throw new Error('Network response was not ok');
+    }
+    const data = await response.json();
+    
+    const imageWidth = Number(data.ImageWidth?.value || 0);
+    const imageHeight = Number(data.ImageHeight?.value || 0);
+
+    return {
+      width: imageWidth,
+      height: imageHeight
+    };
+  } catch (error) {
+    console.error('获取图片信息失败:', error);
+    // 返回默认值或抛出错误,视业务需求而定
+    return { width: 0, height: 0 };
+  }
+};
+
+// 获取切块图片的地址 (OSS Crop)
+const GetQuestionImgUrl = (url: string, x: number, y: number, w: number, h: number): string => {
+  const ossProcessParam = 'x-oss-process=image';
+  const cropParams = `/crop,x_${Math.round(x)},y_${Math.round(y)},w_${Math.round(w)},h_${Math.round(h)}`;
+  
+  if (url.includes(ossProcessParam)) {
+    return url + cropParams;
+  } else {
+    const separator = url.includes('?') ? '&' : '?';
+    return url + `${separator}${ossProcessParam}${cropParams}`;
+  }
+};
+
+// 更新当前试卷数据的公共方法
+const UpdateCurrentPaperData = async () => {
+  if (questionList.value.length === 0) return;
+
+  const currentQuestionItem = questionList.value[0];
+  console.log("打印当前的题目数据", currentQuestionItem);
+
+  // 获取第一张图片的尺寸用于坐标转换
+  // 注意:如果 paperImageList 为空,这里需要保护
+  if (paperImageList.value.length === 0) return;
+  
+  const firstPageUrl = paperImageList.value[0].picUrl;
+  let imageInfo = { width: 0, height: 0 };
+  
+  if (firstPageUrl) {
+    imageInfo = await GetImageInfo(firstPageUrl);
+  }
+
+  // 1. 处理显示的图片 (paperImage)
+  if (currentQuestionItem.titleType == 1) {
+    // 如果是客观题,显示整张试卷
+    currentPaperUrl.value = paperImageList.value[currentIndex.value].picUrl;
+    paperImage.value = [{
+      url: currentPaperUrl.value,
+      page: paperImageList.value[currentIndex.value].page,
+    }];
+  } else {
+    // 否则显示对应的切块图片
+    const list = currentQuestionItem.pagePaintingVOS || [];
+    paperImage.value = [];
+    
+    for (const item of list) {
+      const pageItem = paperImageList.value.find(p => p.page == item.page);
+      if (!pageItem) continue;
+
+      let obj: { url: string; page: number } = {
+        url: '',
+        page: pageItem.page,
+      };
+
+      if (usedCardType.value == 1) {
+        // 系统卡:计算相对坐标
+        let templateInfo = {
+          width: 794 - 30 * 2, // A4 减去边距
+          height: 1123 - 25 * 2
+        };
+
+        // 如果长大于宽 就是A3
+        if (imageInfo.width > imageInfo.height) {
+          templateInfo = {
+            width: 1588 - 30 * 2, // A3 减去边距
+            height: 1123 - 25 * 2
+          };
+        }
+
+        const newBlockPoint = {
+          x: ((item.x - 30) / templateInfo.width) * imageInfo.width,
+          y: ((item.y - 25) / templateInfo.height) * imageInfo.height,
+          w: (item.w / templateInfo.width) * imageInfo.width,
+          h: (item.h / templateInfo.height) * imageInfo.height,
+          page: item.page,
+        };
+
+        obj.url = GetQuestionImgUrl(pageItem.picUrl, newBlockPoint.x, newBlockPoint.y, newBlockPoint.w, newBlockPoint.h);
+      } else {
+        // 三方卡
+        obj.url = GetQuestionImgUrl(pageItem.picUrl, item.x, item.y, item.w, item.h);
+      }
+      
+      paperImage.value.push(obj);
+    }
+  }
+
+  // 2. 处理采分点坐标 (currentDrawData)
+  let positionX = 0;
+  let positionY = 0;
+  
+  try {
+    const point = JSON.parse(currentQuestionItem.samplingPosition || '{}');
+    positionX = point.x || 0;
+    positionY = point.y || 0;
+  } catch (e) {
+    console.error("解析采样位置失败", e);
+  }
+
+  if (usedCardType.value == 1) {
+    // 系统卡:需要根据模板的坐标进行转换
+    let templateInfo = {
+      width: 794,
+      height: 1123,
+    };
+
+    if (imageInfo.width > imageInfo.height) {
+      templateInfo = {
+        width: 1588,
+        height: 1123,
+      };
+    }
+    
+    // 计算试卷相对于模板的倍率
+    const offsetScale = imageInfo.width / templateInfo.width;
+    
+    positionX = parseFloat((offsetScale * positionX).toFixed(2));
+    positionY = parseFloat((offsetScale * positionY).toFixed(2));
+  }
+
+  const drawDataItem = {
+    id: '',
+    name: currentQuestionItem.questionName,
+    fullScore: currentQuestionItem.fullScore, // 满分
+    score: currentQuestionItem.score, // 学生得分
+    drawLineData: currentQuestionItem.drawLineData, // 划线标记的数据
+    x: positionX,
+    y: positionY,
+    titleType: currentQuestionItem.titleType, // 题目类型
+    pagePaintingVOS: currentQuestionItem.pagePaintingVOS, // 切块图片数据
+    model: currentQuestionItem.model, // 优秀 典型错误数据
+    samplingPosition: currentQuestionItem.samplingPosition, // 采分点数据位置
+  };
+
+  currentDrawData.value = [drawDataItem];
+  currentDownLoadName.value = '答题卡';
+};
+
+// 处理批量查看的数据
+const StudentPaperData = (res: PaperDataResult | any) => {
+  paperImageList.value = res?.pageVOS || [];
+  usedCardType.value = res?.usedCardType || 2; // 默认三方卡
+
+  // 合并所有试卷图片中的题目列表
+  let allQuestions: QuestionVO[] = [];
+  paperImageList.value.forEach(item => {
+    if (item.questionVOS && item.questionVOS.length > 0) {
+      allQuestions = allQuestions.concat(item.questionVOS);
+    }
+  });
+  
+  questionList.value = allQuestions;
+  currentIndex.value = 0;
+
+  if (questionList.value.length > 0) {
+    UpdateCurrentPaperData();
+  }
+  
+  nextTick(() => {
+    isLoading.value = false;
+  });
+};
+
+// 获取学生试卷详情信息
+const GetStudentPaperInfo = () => {
+  console.log("加载学生小题试卷信息参数", props.paperInfo);
+  
+  if (props.paperInfo?.examPaperId && props.paperInfo?.platformNumber != null) {
+    isLoading.value = true;
+    
+    getStudentPaperCardInfo(props.paperInfo).then(res => {
+      console.log("打印学生试卷详情信息", res);
+      
+      if (res.code == 200 && res.data) {
+        const data = res.data as PaperDataResult;
+        paperImageList.value = data.pageVOS || [];
+        usedCardType.value = data.usedCardType || 2;
+
+        // 合并所有试卷图片中的题目列表
+        let allQuestions: QuestionVO[] = [];
+        paperImageList.value.forEach(item => {
+          if (item.questionVOS && item.questionVOS.length > 0) {
+            allQuestions = allQuestions.concat(item.questionVOS);
+          }
+        });
+        questionList.value = allQuestions;
+        
+        // 重置索引并更新当前试卷数据
+        currentIndex.value = 0;
+        if (questionList.value.length > 0) {
+          UpdateCurrentPaperData();
+        }
+        
+        nextTick(() => {
+          isLoading.value = false;
+        });
+      } else {
+        nextTick(() => {
+          isLoading.value = false;
+        });
+      }
+    }).catch(err => {
+      console.error(err);
+      nextTick(() => {
+        isLoading.value = false;
+      });
+    });
+  } else {
+    if (props.isBatch) {
+      StudentPaperData(props.paperData as PaperDataResult);
+    } else {
+      currentDrawData.value = [];
+      isLoading.value = false;
+    }
+  }
+};
+
+// --- 监听器 ---
+
+watch(
+  () => props.paperInfo,
+  (newVal) => {
+    if (newVal && Object.keys(newVal).length > 0) {
+      currentIndex.value = 0;
+      currentDrawData.value = [];
+      paperImage.value = [];
+      console.log("学生试卷信息更新了", newVal);
+      GetStudentPaperInfo();
+    }
+  },
+  { deep: true }
+);
+
+watch(
+  () => props.paperData,
+  (newVal) => {
+    if (newVal && Object.keys(newVal).length > 0) {
+      currentIndex.value = 0;
+      currentDrawData.value = [];
+      paperImage.value = [];
+      StudentPaperData(newVal as PaperDataResult);
+    }
+  },
+  { deep: true }
+);
+
+// --- 生命周期 ---
+
+onMounted(() => {
+  // 初始加载
+  GetStudentPaperInfo();
+});
+
+</script>
+
+<style lang="scss" scoped>
+.canvas_image {
+  width: 100%;
+  height: 100%;
+  margin: auto;
+  // background-color: green;
+
+  .main_container {
+    width: 100%;
+    height: 100%;
+    position: relative;
+  }
+}
+</style>

+ 1 - 0
src/router/index.ts

@@ -69,6 +69,7 @@ const routes: Array<RouteRecordRaw> = [
         path: 'analysis',
         name: 'analysis',
         component: () => import('@/views/analysis/index.vue'),
+        redirect: 'score',   // 默认跳转到 score
         children:[
           {
             path: 'score',

+ 11 - 3
src/store/analysis.ts

@@ -31,16 +31,24 @@ export const useAnalysisStore = defineStore("analysis", () => {
   const filterObject = ref<FilterObject>(
     JSON.parse(localStorage.getItem("filterObject") || "{}"),
   );
-
+  const analysisExamInfo = ref<any>(
+    JSON.parse(localStorage.getItem("analysisExamInfo") || "{}"),
+  );
   // 新增:设置公共参数
-  function setFilterObject(info: FilterObject) {
+  const setFilterObject = (info: FilterObject) => {
     filterObject.value = info;
     // 同步存储到 localStorage,确保持久化
     localStorage.setItem("filterObject", JSON.stringify(info));
   }
-
+  const setAnalysisExamInfo = (info: any) => {
+    analysisExamInfo.value = info;
+     // 同步存储到 localStorage,确保持久化
+    localStorage.setItem("analysisExamInfo", JSON.stringify(info));
+  }
   return {
     filterObject,
+    analysisExamInfo,
     setFilterObject,
+    setAnalysisExamInfo
   };
 });

+ 182 - 7
src/styles/common.scss

@@ -2433,18 +2433,19 @@ body {
 }
 
 //弹窗全屏
-.page_full_dialog
-{
+.page_full_dialog {
+
+  // z-index: 2055 !important;
   //饿了么样式覆盖
   .el-dialog__header {
     display: none;
   }
-  
-  .el-dialog
-  {
-    border-radius: 0;
 
+  .el-dialog {
+    border-radius: 0;
+    background-color: #f0f4fb;
   }
+
   //饿了么内容样式覆盖
   .el-dialog__body {
     font-weight: 400;
@@ -2459,6 +2460,174 @@ body {
     // justify-content: flex-start;
     // align-items: center;
   }
+
+  .header_container {
+    width: 100%;
+    height: 65px;
+    background-color: #2E64FA;
+    position: relative;
+    text-align: center;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    .header_back {
+
+      position: absolute;
+      left: 20px;
+      top: 15px;
+      width: 80px;
+      height: 34px;
+      border-radius: 4px 4px 4px 4px;
+      border: 1px solid #dcdfe6;
+
+      line-height: 34px;
+
+      font-weight: 500;
+      font-size: 14px;
+      color: #fff;
+      cursor: pointer;
+      text-align: center;
+
+      i {
+        margin-right: 5px;
+        font-size: 14px;
+      }
+    }
+
+    .header_title {
+      font-size: 24px;
+      color: #fff;
+      font-weight: 600;
+      width: auto;
+    }
+  }
+
+  .content_main {
+    width: 100%;
+    height: 100%;
+
+    display: flex;
+    justify-content: space-between;
+
+    .content_preview {
+      width: calc(100% - 180px);
+      height: calc(100% - 20px);
+      margin-top: 10px;
+      margin-left: 10px;
+      margin-right: 10px;
+      margin-bottom: 10px;
+      box-sizing: border-box;
+      overflow-y: auto;
+      background-color: #F0F4FB;
+      font-family: 宋体, SimSun, STSong;
+      padding-right: 16px;
+      // background-color: red;
+
+      // justify-content: center;
+
+      //答题卡预览打印区域
+      .preview_print {
+
+        .make_card_content_main {
+          border: none !important;
+        }
+
+        .answer_sheet_content {
+          border: none !important;
+        }
+      }
+    }
+
+    .content_right {
+      width: 180px;
+      height: 100%;
+
+
+
+      .right_back {
+        width: 140px;
+        height: 42px;
+        border: 1px solid #DCDFE6;
+        border-radius: 4px;
+        background-color: #fff;
+        line-height: 42px;
+        margin-left: 20px;
+        text-align: center;
+        margin-top: 20px;
+        cursor: pointer;
+
+        i {
+          margin-right: 10px;
+        }
+      }
+
+      .right_print {
+        width: 140px;
+        height: 42px;
+        line-height: 42px;
+        border: 1px solid #2E64FA;
+        border-radius: 4px;
+        background-color: #2E64FA;
+        margin-left: 20px;
+        margin-top: 20px;
+
+        color: #fff;
+        font-weight: 400;
+        font-size: 14px;
+        text-align: center;
+        cursor: pointer;
+
+      }
+
+      .right_use {
+        width: 140px;
+        height: 42px;
+        border: 1px solid #2E64FA;
+        border-radius: 4px;
+        background-color: #2E64FA;
+        margin-left: 20px;
+        margin-top: 20px;
+
+        color: #fff;
+        font-weight: 400;
+        font-size: 14px;
+        text-align: center;
+        cursor: pointer;
+      }
+
+      .right_download {
+        width: 140px;
+        height: 42px;
+        line-height: 42px;
+        border: 1px solid #2E64FA;
+        border-radius: 4px;
+        background-color: transparent;
+
+        margin-left: 20px;
+        margin-top: 20px;
+
+        color: #2E64FA;
+        font-weight: 400;
+        font-size: 14px;
+        text-align: center;
+        cursor: pointer;
+      }
+
+      .card_message {
+        position: absolute;
+        right: 10px;
+        top: 220px;
+        color: red;
+        line-height: 25px;
+        width: 160px;
+      }
+
+
+    }
+  }
+
+
 }
 
 .el-popover
@@ -4851,7 +5020,7 @@ body {
   flex-direction: column;
   width: 100%;
   height: 100%;
-  overflow: auto;
+  overflow: hidden;
   .page_filter{
     width: 100%;
     padding: 10px;
@@ -4877,4 +5046,10 @@ body {
       }
     }
   }
+  .report_content{
+    margin-top: 10px;
+    width: 100%;
+    flex: 1;
+    overflow: auto;
+  }
 }

+ 2 - 0
src/types/types.ts

@@ -4,6 +4,8 @@
  * 后端统一返回结构
  */
 export interface ApiResponse<T = any> {
+  status: number;
+  headers: any;
   code: number;
   msg: string;
   data: T;

+ 167 - 0
src/utils/exportExcel.ts

@@ -0,0 +1,167 @@
+import { ElMessage } from "element-plus";
+import dayjs from "dayjs";
+/**
+ * 通用 Excel 下载方法
+ * @param apiFunc API 请求函数
+ * @param params 请求参数
+ */
+export const downloadExcel = async (
+  apiFunc: (params: any) => Promise<any>,
+  params: any,
+) => {
+  try {
+    const res = await apiFunc(params);
+
+    // 检查响应状态
+    if (res.status === 200) {
+      // 创建 Blob
+      const blob = new Blob([res.data], {
+        type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+      });
+
+      // 解析文件名,默认为 "导出文件.xlsx"
+      let fileName = "导出文件.xlsx";
+      const contentDisposition = res.headers?.["content-disposition"];
+      if (contentDisposition) {
+        const match = contentDisposition.match(/filename="?([^"]+)"?/);
+        if (match && match[1]) {
+          try {
+            fileName = decodeURIComponent(match[1]);
+          } catch (e) {
+            console.warn("文件名解码失败", e);
+          }
+        }
+      }
+
+      // 触发下载
+      const url = URL.createObjectURL(blob);
+      const link = document.createElement("a");
+      link.href = url;
+      link.download = fileName;
+      document.body.appendChild(link);
+      link.click();
+
+      // 清理
+      setTimeout(() => {
+        document.body.removeChild(link);
+        URL.revokeObjectURL(url);
+      }, 100);
+    } else {
+      ElMessage.error("导出失败!");
+    }
+  } catch (error) {
+    console.error("导出异常:", error);
+    ElMessage.error("导出异常!");
+  }
+};
+// 定义报告参数的类型接口
+interface ReportParam {
+  schoolLevel?: number | string;
+  schoolGroupName?: string;
+  schoolName?: string;
+  subjectName?: string;
+  classGroupName?: string;
+}
+/**
+ * 导出文件名称生成
+ * @param examName 考试名称
+ * @param reportParam 报告参数对象
+ */
+export const GetExcelFileName = (
+  examName: string,
+  reportParam: ReportParam,
+  title?: string,
+) => {
+  // 防御性检查:如果 reportParam 为空,返回默认名称
+  if (!reportParam) {
+    return `${examName || "考试"}_导出文件`;
+  }
+
+  //0-联考 1-学校分组 2-具体学校
+  let schoolName = "";
+  // 统一转换为数字进行比较,防止类型不一致导致判断失效
+  const level = Number(reportParam.schoolLevel);
+
+  if (level === 0) {
+    schoolName = "联校";
+  } else if (level === 1) {
+    schoolName = reportParam.schoolGroupName || "";
+  } else {
+    // 默认为具体学校 (level 2 或其他情况)
+    schoolName = reportParam.schoolName || "";
+  }
+
+  // 获取科目名称,防止 undefined
+  const subjectName = reportParam.subjectName || "";
+
+  // 构建学校部分后缀
+  const schoolSuffix = schoolName ? `_${schoolName}` : "";
+
+  // 构建班级部分后缀:仅当 level 为 2 且存在 classGroupName 时添加
+  const classSuffix =
+    level === 2 && reportParam.classGroupName
+      ? `_${reportParam.classGroupName}`
+      : "";
+
+  // 组合最终文件名
+  return `${examName}_${subjectName}${schoolSuffix}${classSuffix}_${title}_${GetExportDate()}`;
+};
+//导出时间
+export const GetExportDate = () => {
+  return dayjs().format("MM-DD");
+};
+//命题分析表头
+export const propositionAnalysisStaticHeaderData = () => {
+  return [
+    { display: true, label: "题号", prop: "questionCode" },
+    { display: true, label: "题型", prop: "questionTypeName" },
+    { display: true, label: "实考人数", prop: "studentCount" },
+    { display: true, label: "满分", prop: "fullMark" },
+    { display: true, label: "难度", prop: "difficulty" },
+    { display: true, label: "区分度", prop: "discrimination" },
+    { display: true, label: "平均分", prop: "averageScore" },
+    { display: true, label: "最高分", prop: "maxScore" },
+    { display: true, label: "最低分", prop: "minScore" },
+    { display: true, label: "得分率", prop: "scoreRate" },
+    { display: true, label: "标准差", prop: "standardDeviation" },
+  ];
+};
+//客观题分析表
+export const selectQuestionAnalysisStaticHeaderData = () => {
+  return [
+    { display: true, label: "题号", prop: "questionName" },
+    { display: true, label: "题型", prop: "questionType" },
+    { display: true, label: "分数", prop: "answerScore" },
+    { display: true, label: "平均分", prop: "averageScore" },
+    { display: true, label: "得分率", prop: "scoreRate" },
+    { display: true, label: "答案", prop: "answerValue" },
+  ];
+};
+export const selectQuestionAnalysisDynamicsHeaderData = () => {
+  return [
+    { display: true, label: "各选项选择人数", prop: "" },
+    { display: true, label: "各选项选择人数占比", prop: "" },
+  ];
+};
+//错题分析表
+export const errorQuestionDataStaticHeaderData = () => {
+  return [
+    { display: true, label: "小题名称", prop: "questionName" },
+    { display: true, label: "题型", prop: "questionType" },
+    { display: true, label: "满分", prop: "questionScore" },
+    { display: true, label: "难度", prop: "difficulty" },
+    { display: true, label: "区分度", prop: "discrimination" },
+    { display: true, label: "平均分", prop: "averageScore" },
+    { display: true, label: "得分率", prop: "scoreRate" },
+    { display: true, label: "答题人数", prop: "studentCount" },
+    { display: true, label: "错题人数", prop: "errorCount" },
+    { display: true, label: "正确答案", prop: "answerValue" },
+  ];
+};
+export const errorQuestionDataChildHeaderData = (headerData: any[]) => {
+  return headerData.map((item) => ({
+    display: true,
+    label: "错题详情",
+    prop: item,
+  }));
+};

+ 4 - 3
src/utils/request.ts

@@ -40,11 +40,12 @@ service.interceptors.response.use(
     //   })
 
       // 特殊状态码处理,如 token 失效等
-      if (res.code === 401) {
+      if (response.config.responseType == "blob") {
+        return response;
+      }else if (res.code === 401) {
         // 跳转登录页
         window.location.href = '/'
-      }
-      else
+      }else
       {
         return res
       }

+ 3 - 3
src/views/analysis/classComparison.vue

@@ -92,7 +92,7 @@
     <template #module_describe v-html="state.analysisData.expandableText"></template>
   </ReportModule>
   <ReportModule :titleList="['2、平均分分析表']" tableOrChart="table" :showPrintBtn="false" :showDescribe="false" :currentPage="state.analysisData.pageNum" :pageSize="state.analysisData.pageSize"
-    :total="state.analysisData.total" @update:pageSize="handleSizeChange" @update:currentPage="handleCurrentChange">
+    :total="state.analysisData.total" @ChangePageSize="ChangePageSize" @ChangeCurrentPage="ChangeCurrentPage">
     <template #module_table_chart>
       <el-table :data="analysisTableData" border stripe align="left">
         <template v-for="(header, headerIndex) in state.analysisData.staticHeader">
@@ -1082,11 +1082,11 @@ const ChangeChartBarData = () => {
   state.analysisData.chartDatas = chartDatasList;
 };
 // 分页
-const handleCurrentChange = (val: number) => {
+const ChangeCurrentPage = (val: number) => {
   state.analysisData.pageNum = val;
 }
 
-const handleSizeChange = (val: number) => {
+const ChangePageSize = (val: number) => {
   state.analysisData.pageSize = val;
   state.analysisData.pageNum = 1;
 }

+ 72 - 2
src/views/analysis/errorAnalysis.vue

@@ -1,10 +1,13 @@
 <template>
   <!-- 成绩查询 成绩单 -->
   <ReportModule
+    ref="reportModuleRef"
     :titleList="['错题分析表']"
     :showDescribe="false"
     tableOrChart="table"
     :showTablePage="false"
+    @ExportExcel="ExportExcel"
+    @PrintPdf="PrintPdf"
   >
     <template #module_table_chart>
       <el-table
@@ -123,18 +126,38 @@
       </el-table>
     </template>
   </ReportModule>
+  <ErrorsPdf
+    ref="errPdfReport"
+    :tableData="state.tableData"
+    :className="analysisStore.filterObject.classGroupName"
+    :subjectName="analysisStore.filterObject.subjectName || ''"
+    :examName="getExamName"
+    @PdfLoadEnd="PdfLoadEnd"
+  />
 </template>
 <script lang="ts" setup>
 import ReportModule from "@/components/ReportModule.vue";
 import { useAnalysisStore } from "@/store/analysis";
-import { onMounted, reactive, watch } from "vue";
-import { errorQuestionAnalysis } from "@/api/analysis";
+import ErrorsPdf from "@/components/ErrorsPdf.vue";
+import { onMounted, reactive, computed, ref, watch } from "vue";
+import { errorQuestionAnalysis, exportErrorQuestion } from "@/api/analysis";
+import {
+  downloadExcel,
+  GetExcelFileName,
+  errorQuestionDataStaticHeaderData,
+  errorQuestionDataChildHeaderData,
+} from "@/utils/exportExcel";
 const state = reactive({
   tableData: [],
   tableLoading: true,
   loadingText: "加载中……",
 });
 const analysisStore = useAnalysisStore();
+const reportModuleRef = ref<any>(null);
+const errPdfReport = ref<any>(null);
+const getExamName = computed(() => {
+  return analysisStore.analysisExamInfo.examName || "";
+});
 //错题分析
 const GetErrorQuestionAnalysis = async () => {
   state.tableLoading = true;
@@ -234,6 +257,53 @@ const ErrorTableCellClassName = ({ row, column, rowIndex, columnIndex }) => {
     return "";
   }
 };
+// 导出Excel
+const ExportExcel = () => {
+  // 1. 设置加载状态
+  reportModuleRef.value?.SetExportLoading?.(true);
+  // 2. 参数
+  const examName = getExamName.value;
+  let params = {
+    fileName: `${GetExcelFileName(examName, analysisStore.filterObject, "错题分析表")}`,
+    examName: examName, //考试名称
+    sheetName: "错题分析表", //sheet页名称
+  };
+  const staticHeader = errorQuestionDataStaticHeaderData();
+  const childHeader = ["name", "rate", "studentNum", "registrationCodeList"];
+  const childHeaderData = errorQuestionDataChildHeaderData(childHeader);
+  const staticHeaderData = [...staticHeader, ...childHeaderData];
+  params.staticHeaderData = staticHeaderData;
+  params.childHeaderData = [];
+  params.dynamicsHeaderData = [];
+  let dataList = [];
+  state.tableData.forEach((item) => {
+    const rowData = [];
+    staticHeaderData.forEach((header) => {
+      if (header.prop == "rate") {
+        const rate = `${item[header.prop]}${item[header.prop] == "-" ? "" : "%"}`;
+        rowData.push(rate);
+      } else {
+        rowData.push(item?.[header.prop] ?? "-");
+      }
+    });
+    dataList.push(rowData);
+  });
+  params.dataList = dataList;
+  params.mergeColumn = staticHeader.length - 1; //合并的列
+  // 3. 调用通用下载方法,并在完成后重置加载状态
+  downloadExcel(exportErrorQuestion, params).finally(() => {
+    reportModuleRef.value?.SetExportLoading?.(false);
+  });
+};
+// 导出PDF
+const PrintPdf = () => {
+  reportModuleRef.value?.SetPrintLoading?.(true);
+  errPdfReport.value?.DownloadPdf?.();
+};
+// 导出PDF  加载完成
+const PdfLoadEnd = () => {
+  reportModuleRef.value?.SetPrintLoading?.(false);
+};
 // 初始化
 const pageInit = () => {
   GetErrorQuestionAnalysis();

+ 62 - 7
src/views/analysis/groupAnalysis.vue

@@ -360,6 +360,7 @@
     </template>
   </ReportModule>
   <ReportModule
+    ref="reportModuleRef"
     :showTitle="true"
     :titleList="[state.groupTitle]"
     :showDescribe="true"
@@ -369,10 +370,10 @@
     :currentPage="state.majorTableData.currentPage"
     :pageSize="state.majorTableData.pageSize"
     :total="state.majorTableData.total"
-    @update:pageSize="handleSizeChange"
-    @update:currentPage="handleCurrentChange"
+    @ChangePageSize="ChangePageSize"
+    @ChangeCurrentPage="ChangeCurrentPage"
+    @ExportExcel="ExportExcel"
   >
-    <template #title_right></template>
     <template #module_table_chart>
       <el-table
         :data="state.majorTableData.tableData"
@@ -458,6 +459,7 @@ import { useAnalysisStore } from "@/store/analysis";
 import {
   questionGroupAnalysis,
   queryAnswerListByAnswerAndScore,
+  publicExport
 } from "@/api/analysis";
 import BarLineCharts from "@/components/echarts/barLineCharts.vue"; //柱状图折线图组合图组件
 import RadarCharts from "@/components/echarts/radarCharts.vue"; //雷达图
@@ -465,9 +467,14 @@ 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 { onMounted, reactive, watch, ref, nextTick } from "vue";
+import { downloadExcel, GetExcelFileName } from "@/utils/exportExcel";
+import { onMounted, reactive, watch, ref, computed,nextTick } from "vue";
 import { cloneDeep } from "lodash-es";
 const analysisStore = useAnalysisStore();
+const reportModuleRef = ref<any>(null);
+const getExamName = computed(() => {
+  return analysisStore.analysisExamInfo.examName || "";
+});
 const state = reactive({
   questionGroupDefault: [
     {
@@ -493,7 +500,7 @@ const state = reactive({
   ],
   questionGroupList: [], //试题分组标签 动态接口获取
   tagActive: "problem", //选择的试题分组标签
-  groupTitle: "小题分析",
+  groupTitle: "分组分析",
   groupPreviousTitle: "",
   knowledgeLayeredTitle: "", //知识点分层标题
   groupName: "", // 分组名称
@@ -1338,15 +1345,63 @@ const ResetTableScroll = () => {
     }
   });
 };
-const handleCurrentChange = (val) => {
+const ChangeCurrentPage = (val) => {
   state.majorTableData.currentPage = val;
   GetMajorTableData(); //加载分析表格数据
 };
-const handleSizeChange = (val: number) => {
+const ChangePageSize = (val: number) => {
   state.majorTableData.pageSize = val;
   state.majorTableData.currentPage = 1;
   GetMajorTableData(); //加载分析表格数据
 };
+// 导出Excel
+const ExportExcel = () => {
+  // 1. 设置加载状态
+  reportModuleRef.value?.SetExportLoading?.(true);
+  // 2. 参数
+  const examName = getExamName.value;
+  let params = {
+    fileName: `${GetExcelFileName(examName, analysisStore.filterObject, state.groupTitle)}`,
+    examName: examName, //考试名称
+    sheetName: state.groupTitle, //sheet页名称
+  };
+  const staticHeaderData = state.problemAnalysisData.headerList.filter(
+    (item) => item.display,
+  );
+  const childHeaderData = state.problemAnalysisData.childHeaderList.filter(
+    (item) => item.display,
+  );
+  const dynamicsHeaderData = state.problemAnalysisData.changeHeaderList.filter(
+    (item) => item.display,
+  );
+  params.staticHeaderData = staticHeaderData;
+  params.childHeaderData = childHeaderData;
+  params.dynamicsHeaderData = dynamicsHeaderData;
+  let dataList = [];
+  state.majorTableData.allTableData.forEach((item) => {
+    const rowData = [];
+    staticHeaderData.forEach((header) => {
+      rowData.push(item?.[header.prop] || "-");
+    });
+    dynamicsHeaderData.forEach((parent) => {
+      childHeaderData.forEach((child) => {
+        const itemValue = item[`${parent.prop}_${child.prop}`];
+        if (child.prop.indexOf("Rate") > -1) {
+          const rate = itemValue ? `${itemValue}%` : "-";
+          rowData.push(rate);
+        } else {
+          rowData.push(itemValue ?? "-");
+        }
+      });
+    });
+    dataList.push(rowData);
+  });
+  params.dataList = dataList;
+  // 3. 调用通用下载方法,并在完成后重置加载状态
+  downloadExcel(publicExport, params).finally(() => {
+    reportModuleRef.value?.SetExportLoading?.(false);
+  });
+};
 //设置表头样式
 const HeaderRowStyle = ({ row, rowIndex }) => {
   if (rowIndex === 1) {

+ 266 - 195
src/views/analysis/index.vue

@@ -4,199 +4,225 @@
     <div class="page_filter">
       <FiltersItem :data="filtersData" @select="handleSelectChange" />
     </div>
-    <router-view />
-    <div class="report_bottom">
-      <div class="bottom_no_more">
-        没有更多了,<span @click="goToPageTop">回到顶部</span>
+    <div class="report_content" ref="reportContentRef">
+      <router-view />
+      <div
+        class="report_bottom"
+        v-if="route.name != 'score' && route.name != 'optionDetail'"
+      >
+        <div class="bottom_no_more">
+          没有更多了,<span @click="goToPageTop">回到顶部</span>
+        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import FiltersItem from '@/components/FiltersItem.vue'
-import { findCommonSelectList } from '@/api/analysis'
-import { onMounted, ref } from 'vue'
-import { useAnalysisStore } from '@/store/analysis'
-
-const analysisStore = useAnalysisStore()
-const mainContent = ref<HTMLElement | null>(null)
-
-// 子组件实例类型定义
-interface ChildComponent {
-  PageInit: () => void
-}
-const childView = ref<ChildComponent | null>(null)
-
-// 强类型定义
+import FiltersItem from "@/components/FiltersItem.vue";
+// 重命名导入的 API 函数以避免冲突
+import { findCommonSelectList, getAnalysisExamInfo as fetchAnalysisExamInfo } from "@/api/analysis";
+import { onMounted, ref, computed } from "vue";
+import { useRoute } from "vue-router";
+import { useAnalysisStore } from "@/store/analysis";
+import { useExamStore } from "@/store/exam";
+
+const examStore = useExamStore();
+const analysisStore = useAnalysisStore();
+const route = useRoute();
+const reportContentRef = ref<HTMLElement | null>(null);
+
+// 考试科目 code
+const subjectCode = computed(() => {
+  return examStore.currentExam?.examSubjectCode;
+});
+
+// --- 类型定义 ---
 interface FilterOption {
-  label: string
-  value: string
-  [key: string]: any
+  label: string;
+  value: string;
+  [key: string]: any; // 保留用于动态属性,如 selectSchoolVoList 等
 }
 
 interface FilterItem {
-  label: string
-  type: 'subjectName' | 'schoolName' | 'registrationType' | 'scoreType' | 'classType' | 'className'
-  list: FilterOption[]
-  value: string
+  label: string;
+  type: "subjectName" | "schoolName" | "registrationType" | "scoreType" | "classType" | "className";
+  list: FilterOption[];
+  value: string;
 }
 
+// 简化接口定义,利用 TS 推断或保持必要结构
 interface SubjectItem {
-  subjectName: string
-  subjectCode: string
-  subjectId: string
-  isTotal: boolean
-  subjectGroupType: number
-  selectSchoolVoList: SchoolItem[]
-  subjectGroupCodes: string
+  subjectName: string;
+  subjectCode: string;
+  subjectId: string;
+  isTotal: number | string;
+  subjectGroupType: number;
+  selectSchoolVoList: SchoolItem[];
+  subjectGroupCodes: string;
 }
 
 interface SchoolItem {
-  schoolId: string
-  schoolName: string
-  schoolLevel: string
-  schoolGroupId: string
-  selectStatusVoList: StatusItem[]
-  schoolGroupNames: string
+  schoolId: string;
+  schoolName: string;
+  schoolLevel: string | number; // 兼容可能的数字类型
+  schoolGroupId: string;
+  selectStatusVoList: StatusItem[];
+  schoolGroupNames: string;
 }
 
 interface StatusItem {
-  statusName: string
-  statusGroupType: string
-  statusGroupId: string
-  examCommonSelectScoreList: ScoreTypeItem[]
-  statusGroupNames: string
+  statusName: string;
+  statusGroupType: string;
+  statusGroupId: string;
+  examCommonSelectScoreList: ScoreTypeItem[];
+  statusGroupNames: string;
 }
 
 interface ScoreTypeItem {
-  scoreType: string
-  typeName: string
-  selectClassVoList: ClassTypeItem[]
+  scoreType: string;
+  typeName: string;
+  selectClassVoList: ClassTypeItem[];
 }
 
 interface ClassTypeItem {
-  typeName: string
-  classType: string
-  selectInfoVoList: ClassItem[]
+  typeName: string;
+  classType: string;
+  selectInfoVoList: ClassItem[];
 }
 
 interface ClassItem {
-  className: string
-  classLevel: string
-  classGroupId: string
-  classIdCode: string
-  classCode: string
-  classGroupNames: string
+  className: string;
+  classLevel: string | number;
+  classGroupId: string;
+  classIdCode: string;
+  classCode: string;
+  classGroupNames: string;
 }
 
 interface FilterParams {
-  examId: string
-  examLevel:Number
-  subjectCode: string
-  subjectName: string
-  subjectId: string
-  subjectGroupType: number
-  isTotal: boolean
-  subjectGroupCodes: string
-  schoolId: string
-  schoolLevel: string
-  schoolGroupId: string
-  schoolGroupName: string | null
-  schoolName: string | null
-  schoolGroupNames: string
-  registrationType: string
-  registrationName: string | null
-  registrationGroupId: string
-  statusGroupNames: string
-  scoreType: string
-  classType: string
-  classIdCode: string
-  classLevel: string | number
-  classGroupId: string
-  classGroupName: string
-  classGroupNames: string[]
+  examId: string;
+  examLevel: number; // 修正为 number
+  subjectCode: string;
+  subjectName: string;
+  subjectId: string;
+  subjectGroupType: number;
+  isTotal: number | string;
+  subjectGroupCodes: string;
+  schoolId: string;
+  schoolLevel: string | number;
+  schoolGroupId: string;
+  schoolGroupName: string | null;
+  schoolName: string | null;
+  schoolGroupNames: string;
+  registrationType: string;
+  registrationName: string | null;
+  registrationGroupId: string;
+  statusGroupNames: string;
+  scoreType: string;
+  classType: string;
+  classIdCode: string;
+  classLevel: string | number;
+  classGroupId: string;
+  classGroupName: string;
+  classGroupNames: string[];
 }
 
-// 筛选数据
+// --- 状态数据 ---
 const filtersData = ref<FilterItem[]>([
-  { label: '科目名称', type: 'subjectName', list: [], value: '' },
-  { label: '学校名称', type: 'schoolName', list: [], value: '' },
-  { label: '学生类型', type: 'registrationType', list: [], value: '' },
-  { label: '分数类型', type: 'scoreType', list: [], value: '' },
-  { label: '班级类型', type: 'classType', list: [], value: '' },
-  { label: '班级名称', type: 'className', list: [], value: '' },
-])
-
-// 筛选联动工具函数
+  { label: "科目名称", type: "subjectName", list: [], value: "" },
+  { label: "学校名称", type: "schoolName", list: [], value: "" },
+  { label: "学生类型", type: "registrationType", list: [], value: "" },
+  { label: "分数类型", type: "scoreType", list: [], value: "" },
+  { label: "班级类型", type: "classType", list: [], value: "" },
+  { label: "班级名称", type: "className", list: [], value: "" },
+]);
+
+// --- 工具函数 ---
 const updateBySchool = (school: FilterOption) => {
-  if (!school) return
-  const registrationTypeList = school.selectStatusVoList.map((item: any) => ({
+  if (!school || !school.selectStatusVoList) return;
+  
+  const registrationTypeList: FilterOption[] = school.selectStatusVoList.map((item: StatusItem) => ({
     label: item.statusName,
-    value: `${item.statusGroupType || ''}${item.statusGroupId || ''}${item.statusName || ''}`,
+    value: `${item.statusGroupType || ""}${item.statusGroupId || ""}${item.statusName || ""}`,
     statusGroupType: item.statusGroupType,
     statusName: item.statusName,
     statusGroupId: item.statusGroupId,
     examCommonSelectScoreList: item.examCommonSelectScoreList,
     statusGroupNames: item.statusGroupNames,
-  }))
-  filtersData.value[2].list = registrationTypeList
-  filtersData.value[2].value = registrationTypeList[0]?.value || ''
-  updateByStatus(registrationTypeList[0])
-}
+  }));
+
+  filtersData.value[2].list = registrationTypeList;
+  filtersData.value[2].value = registrationTypeList[0]?.value || "";
+  updateByStatus(registrationTypeList[0]);
+};
 
 const updateByStatus = (status: FilterOption) => {
-  if (!status) return
-  const scoreTypeList = status.examCommonSelectScoreList.map((item: any) => ({
+  if (!status || !status.examCommonSelectScoreList) return;
+
+  const scoreTypeList: FilterOption[] = status.examCommonSelectScoreList.map((item: ScoreTypeItem) => ({
     label: item.typeName,
     value: item.scoreType,
     selectClassVoList: item.selectClassVoList,
-  }))
-  filtersData.value[3].list = scoreTypeList
-  filtersData.value[3].value = scoreTypeList[0]?.value || ''
-  updateByScoreType(scoreTypeList[0])
-}
+  }));
+
+  filtersData.value[3].list = scoreTypeList;
+  filtersData.value[3].value = scoreTypeList[0]?.value || "";
+  updateByScoreType(scoreTypeList[0]);
+};
 
 const updateByScoreType = (type: FilterOption) => {
-  if (!type) return
-  const classTypeList = type.selectClassVoList.map((item: any) => ({
+  if (!type || !type.selectClassVoList) return;
+
+  const classTypeList: FilterOption[] = type.selectClassVoList.map((item: ClassTypeItem) => ({
     label: item.typeName,
     value: item.classType,
     selectInfoVoList: item.selectInfoVoList,
-  }))
-  filtersData.value[4].list = classTypeList
-  filtersData.value[4].value = classTypeList[0]?.value || ''
-  updateByClassType(classTypeList[0])
-}
+  }));
+
+  filtersData.value[4].list = classTypeList;
+  filtersData.value[4].value = classTypeList[0]?.value || "";
+  updateByClassType(classTypeList[0]);
+};
 
 const updateByClassType = (classType: FilterOption) => {
-  if (!classType) return
-  const classList = (classType.selectInfoVoList || []).map((item: any) => ({
+  if (!classType || !classType.selectInfoVoList) return;
+
+  const classList: FilterOption[] = (classType.selectInfoVoList || []).map((item: ClassItem) => ({
     label: item.className,
-    value: `${item.classLevel || ''}${item.classGroupId || ''}${item.classIdCode || ''}`,
+    value: `${item.classLevel || ""}${item.classGroupId || ""}${item.classIdCode || ""}`,
     classLevel: item.classLevel,
     className: item.className,
     classCode: item.classCode,
     classIdCode: item.classIdCode,
     classGroupId: item.classGroupId,
     classGroupNames: item.classGroupNames,
-  }))
-  filtersData.value[5].list = classList
-  filtersData.value[5].value = classList[0]?.value || ''
-}
+  }));
 
-// 构建筛选参数并刷新子页面
-const buildAndSaveFilterParams = () => {
-  const courseObj = filtersData.value[0].list.find(item => item.value === filtersData.value[0].value)
-  const schoolObj = filtersData.value[1].list.find(item => item.value === filtersData.value[1].value)
-  const statusObj = filtersData.value[2].list.find(item => item.value === filtersData.value[2].value)
-  const classObj = filtersData.value[5].list.find(item => item.value === filtersData.value[5].value)
+  filtersData.value[5].list = classList;
+  filtersData.value[5].value = classList[0]?.value || "";
+};
 
-  if (!courseObj || !schoolObj || !statusObj) return
+// 构建筛选参数并保存
+const buildAndSaveFilterParams = () => {
+  const courseObj = filtersData.value[0].list.find(
+    (item) => item.value === filtersData.value[0].value,
+  );
+  const schoolObj = filtersData.value[1].list.find(
+    (item) => item.value === filtersData.value[1].value,
+  );
+  const statusObj = filtersData.value[2].list.find(
+    (item) => item.value === filtersData.value[2].value,
+  );
+  const classObj = filtersData.value[5].list.find(
+    (item) => item.value === filtersData.value[5].value,
+  );
+
+  if (!courseObj || !schoolObj || !statusObj) return;
 
   const filterObject: FilterParams = {
-    examId:'2036963589738971137',
-    examLevel:2,//单校
+    examId: "2036963589738971137",
+    examLevel: 2, // 单校
     subjectCode: courseObj.subjectCode,
     subjectName: courseObj.subjectName,
     subjectId: courseObj.subjectId,
@@ -207,118 +233,163 @@ const buildAndSaveFilterParams = () => {
     schoolId: schoolObj.schoolId,
     schoolLevel: schoolObj.schoolLevel,
     schoolGroupId: schoolObj.schoolGroupId,
-    schoolGroupName: (schoolObj.schoolLevel === 0 || schoolObj.schoolLevel === 2) ? null : schoolObj.schoolName,
+    schoolGroupName:
+      schoolObj.schoolLevel === 0 || schoolObj.schoolLevel === 2
+        ? null
+        : schoolObj.schoolName,
     schoolName: schoolObj.schoolLevel === 2 ? schoolObj.schoolName : null,
     schoolGroupNames: schoolObj.schoolGroupNames,
 
     registrationType: statusObj.statusGroupType,
-    registrationName: statusObj.statusName === '全部' ? null : statusObj.statusName,
+    registrationName:
+      statusObj.statusName === "全部" ? null : statusObj.statusName,
     registrationGroupId: statusObj.statusGroupId,
     statusGroupNames: statusObj.statusGroupNames,
 
     scoreType: filtersData.value[3].value,
     classType: filtersData.value[4].value,
 
-    classIdCode: classObj?.classIdCode || '',
+    classIdCode: classObj?.classIdCode || "",
     classLevel: classObj?.classLevel || 0,
-    classGroupId: classObj?.classGroupId || '',
-    classGroupName: classObj?.className || '',
+    classGroupId: classObj?.classGroupId || "",
+    classGroupName: classObj?.className || "",
     classGroupNames: classObj?.classGroupNames || [],
-  }
+  };
 
-  analysisStore.setFilterObject(filterObject)
-}
+  analysisStore.setFilterObject(filterObject);
+};
+
+// --- 业务逻辑 ---
 
-// 初始化加载
+// 初始化加载公共筛选列表
 const getCommonSelectList = async () => {
   try {
-    const res = await findCommonSelectList({ aiExamId: '2036963589738971137' })
-    if (res.code !== 200 || !res.data?.length) return
-
-    const subjectList: FilterOption[] = (res.data as SubjectItem[]).map(item => ({
-      label: item.subjectName,
-      value: item.subjectCode,
-      subjectName: item.subjectName,
-      subjectId: item.subjectId,
-      subjectCode: item.subjectCode,
-      isTotal: item.isTotal,
-      subjectGroupType: item.subjectGroupType,
-      isTotalScore: item.subjectGroupType === 1,
-      isMulCourse: item.subjectGroupType === 1,
-      selectSchoolVoList: item.selectSchoolVoList,
-      subjectGroupCodes: item.subjectGroupCodes,
-    }))
-
-    if (!subjectList.length) return
-    filtersData.value[0].list = subjectList
-    filtersData.value[0].value = subjectList[0].value
-
-    const schoolList = subjectList[0].selectSchoolVoList.map((item: any) => ({
+    const res = await findCommonSelectList({ aiExamId: "2036963589738971137" });
+    if (res.code !== 200 || !res.data?.length) return;
+
+    const subjectList: FilterOption[] = (res.data as SubjectItem[])
+      .filter((item) => item.isTotal === 0)
+      .map((item) => ({
+        label: item.subjectName,
+        value: item.subjectCode,
+        subjectName: item.subjectName,
+        subjectId: item.subjectId,
+        subjectCode: item.subjectCode,
+        isTotal: item.isTotal,
+        subjectGroupType: item.subjectGroupType,
+        isTotalScore: item.subjectGroupType === 1,
+        isMulCourse: item.subjectGroupType === 1,
+        selectSchoolVoList: item.selectSchoolVoList,
+        subjectGroupCodes: item.subjectGroupCodes,
+      }));
+
+    if (!subjectList.length) return;
+    
+    filtersData.value[0].list = subjectList;
+    filtersData.value[0].value = subjectList[0].value;
+
+    const firstSubject = subjectList[0];
+    if (!firstSubject.selectSchoolVoList) return;
+
+    const schoolList: FilterOption[] = firstSubject.selectSchoolVoList.map((item: SchoolItem) => ({
       label: item.schoolName,
-      value: `${item.schoolLevel || ''}${item.schoolGroupId || ''}${item.schoolId || ''}`,
+      value: `${item.schoolLevel || ""}${item.schoolGroupId || ""}${item.schoolId || ""}`,
       schoolId: item.schoolId,
       schoolName: item.schoolName,
       schoolLevel: item.schoolLevel,
       schoolGroupId: item.schoolGroupId,
       selectStatusVoList: item.selectStatusVoList,
       schoolGroupNames: item.schoolGroupNames,
-    }))
-
-    filtersData.value[1].list = schoolList
-    filtersData.value[1].value = schoolList[0]?.value || ''
-    updateBySchool(schoolList[0])
-    buildAndSaveFilterParams()
+    }));
+
+    filtersData.value[1].list = schoolList;
+    filtersData.value[1].value = schoolList[0]?.value || "";
+    
+    if (schoolList[0]) {
+      updateBySchool(schoolList[0]);
+    }
+    
+    buildAndSaveFilterParams();
   } catch (err) {
-    console.error('获取筛选数据失败:', err)
+    console.error("获取筛选数据失败:", err);
   }
-}
+};
+
+// 获取任务的相关配置和信息
+const loadAnalysisExamInfo = async () => {
+  try {
+    // 如果 subjectCode 可能为空,可能需要处理
+    const currentSubjectCode = subjectCode.value; 
+    if (!currentSubjectCode) {
+        console.warn("Subject code is not available");
+        return;
+    }
+
+    const res = await fetchAnalysisExamInfo({
+      examId: "2036963589738971137",
+      subjectCode: currentSubjectCode,
+    });
+
+    if (res.code === 200 && res.data) {
+      analysisStore.setAnalysisExamInfo(res.data);
+    }
+  } catch (error) {
+    console.error("获取分析考试信息失败:", error);
+  }
+};
 
-// 筛选切换
+// 筛选切换处理
 const handleSelectChange = (index: number, value: string) => {
-  filtersData.value[index].value = value
-  const selectedItem = filtersData.value[index].list.find(item => item.value === value)
-  if (!selectedItem) return
+  filtersData.value[index].value = value;
+  const selectedItem = filtersData.value[index].list.find(
+    (item) => item.value === value,
+  );
+  if (!selectedItem) return;
 
-  const { type } = filtersData.value[index]
+  const { type } = filtersData.value[index];
 
-  if (type === 'subjectName') {
-    const schoolList = selectedItem.selectSchoolVoList.map((item: any) => ({
+  if (type === "subjectName") {
+    if (!selectedItem.selectSchoolVoList) return;
+    const schoolList: FilterOption[] = selectedItem.selectSchoolVoList.map((item: SchoolItem) => ({
       label: item.schoolName,
-      value: `${item.schoolLevel || ''}${item.schoolGroupId || ''}${item.schoolId || ''}`,
+      value: `${item.schoolLevel || ""}${item.schoolGroupId || ""}${item.schoolId || ""}`,
       schoolId: item.schoolId,
       schoolName: item.schoolName,
       schoolLevel: item.schoolLevel,
       schoolGroupId: item.schoolGroupId,
       selectStatusVoList: item.selectStatusVoList,
       schoolGroupNames: item.schoolGroupNames,
-    }))
-    filtersData.value[1].list = schoolList
-    filtersData.value[1].value = schoolList[0]?.value || ''
-    updateBySchool(schoolList[0])
+    }));
+    filtersData.value[1].list = schoolList;
+    filtersData.value[1].value = schoolList[0]?.value || "";
+    if (schoolList[0]) {
+      updateBySchool(schoolList[0]);
+    }
+  } else if (type === "schoolName") {
+    updateBySchool(selectedItem);
+  } else if (type === "registrationType") {
+    updateByStatus(selectedItem);
+  } else if (type === "scoreType") {
+    updateByScoreType(selectedItem);
+  } else if (type === "classType") {
+    updateByClassType(selectedItem);
   }
 
-  if (type === 'schoolName') updateBySchool(selectedItem)
-  if (type === 'registrationType') updateByStatus(selectedItem)
-  if (type === 'scoreType') updateByScoreType(selectedItem)
-  if (type === 'classType') updateByClassType(selectedItem)
+  buildAndSaveFilterParams();
+};
 
-  buildAndSaveFilterParams()
-}
 // 回到顶部
 const goToPageTop = () => {
-  window.scrollTo({ top: 0, behavior: 'smooth' })
-}
+  reportContentRef.value?.scrollTo({ top: 0, behavior: "smooth" });
+};
 
 onMounted(() => {
-  getCommonSelectList()
-})
+  getCommonSelectList();
+  loadAnalysisExamInfo();
+});
 </script>
 
 <style lang="scss" scoped>
-.page_report_main {
-  width: 100%;
-}
-
 .report_bottom {
   width: 100%;
   padding: 20px 0;

+ 506 - 216
src/views/analysis/levelDistribution.vue

@@ -1,18 +1,35 @@
 <template>
-  <ReportModule :titleList="['1、分数段图']" tableOrChart="chart" :showPrintBtn="false" :showExportBtn="false">
+  <ReportAssistant
+    :titleList="['1、分数段图']"
+    tableOrChart="chart"
+    :showPrintBtn="false"
+    :showExportBtn="false"
+  >
     <template #title_right>
-      <div :class="['right_item', { item_active: state.sectionScore == item }]" v-for="item in state.sortRangeScore"
-        :key="item" @click="TagClick(item)">
+      <div
+        :class="['right_item', { item_active: state.sectionScore == item }]"
+        v-for="item in state.sortRangeScore"
+        :key="item"
+        @click="TagClick(item)"
+      >
         {{ item }}分段
       </div>
       <div class="right_set">
         <span>设置分数段</span>
-        <el-input v-model="state.sectionScore" maxlength="3" style="width: 54px" @input="HandleInput"
-          @change="BlurSectionScore" />
+        <el-input
+          v-model="state.sectionScore"
+          maxlength="3"
+          style="width: 54px"
+          @input="HandleInput"
+          @change="BlurSectionScore"
+        />
         <span>分/段</span>
       </div>
       <div class="right_radio">
-        <el-select v-model="state.radioRangeScore" v-if="analysisStore?.filterObject?.classLevel != 2">
+        <el-select
+          v-model="state.radioRangeScore"
+          v-if="analysisStore?.filterObject?.classLevel != 2"
+        >
           <el-option :value="0" label="按年级"></el-option>
           <el-option :value="1" label="按班级"></el-option>
         </el-select>
@@ -21,23 +38,40 @@
     <template #module_table_chart>
       <template v-if="state.scoreSegmentData.datay.length">
         <template v-if="state.radioRangeScore == 0">
-          <BarLineChart :datax="state.scoreSegmentData.datax" :datay="state.scoreSegmentData.datay"
-            :fullScore="state.scoreSegmentData.fullScore" :markLine="state.scoreSegmentData.markLine"
-            :color="state.scoreSegmentData.color" :title="state.scoreSegmentData.title"
-            :tooltipData="state.scoreSegmentData.tooltipData" :unit="state.scoreSegmentData.unit"
-            :tooltipTitle="state.scoreSegmentData.tooltipTitle">
+          <BarLineChart
+            :datax="state.scoreSegmentData.datax"
+            :datay="state.scoreSegmentData.datay"
+            :fullScore="state.scoreSegmentData.fullScore"
+            :markLine="state.scoreSegmentData.markLine"
+            :color="state.scoreSegmentData.color"
+            :title="state.scoreSegmentData.title"
+            :tooltipData="state.scoreSegmentData.tooltipData"
+            :unit="state.scoreSegmentData.unit"
+            :tooltipTitle="state.scoreSegmentData.tooltipTitle"
+          >
           </BarLineChart>
         </template>
         <!-- 按班级 -->
         <template v-if="state.radioRangeScore == 1">
-          <LineBarChart :datax="state.scoreSegmentClassData.datax" :datay="state.scoreSegmentClassData.datay"
-            :showBackground="false" :legendList="state.scoreSegmentClassData.legendList"
-            :title="state.scoreSegmentClassData.title" :tooltipData="state.scoreSegmentClassData.tooltipData"
-            :hideOverlap="true"></LineBarChart>
+          <LineBarChart
+            :datax="state.scoreSegmentClassData.datax"
+            :datay="state.scoreSegmentClassData.datay"
+            :showBackground="false"
+            :legendList="state.scoreSegmentClassData.legendList"
+            :title="state.scoreSegmentClassData.title"
+            :tooltipData="state.scoreSegmentClassData.tooltipData"
+            :hideOverlap="true"
+          ></LineBarChart>
         </template>
       </template>
-      <div v-else class="no_content_data" v-loading="state.tableLoading" :element-loading-text="state.loadingText"
-        element-loading-spinner="el-icon-loading" element-loading-background="#ffffff">
+      <div
+        v-else
+        class="no_content_data"
+        v-loading="state.tableLoading"
+        :element-loading-text="state.loadingText"
+        element-loading-spinner="el-icon-loading"
+        element-loading-background="#ffffff"
+      >
         <span>暂无数据</span>
       </div>
     </template>
@@ -50,22 +84,57 @@
       区分度是衡量试题对不同水平考生的区分能力,通常用D值表示。通过计算高分组和低分组在某一试题上的通过率之差,得到试题区分度。区分度高的试题能将不同水平的考生区分开来,水平高的学生得高分,水平低的学生得低分。D值的取值范围介于-1至1之间,D值越高,区分的效果越好。D≥0.4表明此题的区分度很好,属于优秀;0.3≤D<0.4表明此题的区分度较好,属于良好;0.2≤D<0.4表明此题的区分度一般;D<0.2表明此题的区分度较低。
       图中展示了各科的命题分析明细,点击科目的柱可在下方查看该科所有小题的命题分析。
     </template>
-  </ReportModule>
-  <ReportModule :titleList="['2、分数段表']" tableOrChart="table" :showPrintBtn="false" :showDescribe="false"
-    :currentPage="state.scoreSegmentData.pageNum" :pageSize="state.scoreSegmentData.pageSize"
-    :total="state.scoreSegmentData.total" @update:pageSize="handleSizeChange" @update:currentPage="handleCurrentChange">
+  </ReportAssistant>
+  <ReportAssistant
+    ref="reportModuleRef"
+    :titleList="['2、分数段表']"
+    tableOrChart="table"
+    :showPrintBtn="false"
+    :showDescribe="false"
+    :currentPage="state.scoreSegmentData.pageNum"
+    :pageSize="state.scoreSegmentData.pageSize"
+    :total="state.scoreSegmentData.total"
+    @ChangePageSize="ChangePageSize"
+    @ChangeCurrentPage="ChangeCurrentPage"
+    @ExportExcel="ExportExcel"
+  >
     <template #module_table_chart>
-      <el-table :data="scoreRangeTableData" border>
-        <template v-for="(header, headerIndex) in state.scoreSegmentData.headerData">
-          <el-table-column align="center" :label="header.name" v-if="header.child && header.child.length"
-            :key="`child_${headerIndex}`">
-            <el-table-column v-for="(child, childIndex) in header.child" align="center" :label="child.value"
-              :prop="child.prop" :key="`${headerIndex}_${childIndex}`" show-overflow-tooltip>
+      <el-table :data="scoreRangeTableData" border stripe>
+        <template
+          v-for="(header, headerIndex) in state.scoreSegmentData.headerData"
+        >
+          <el-table-column
+            align="center"
+            :label="header.name"
+            v-if="header.child && header.child.length"
+            :key="`child_${headerIndex}`"
+          >
+            <el-table-column
+              v-for="(child, childIndex) in header.child"
+              align="center"
+              :label="child.value"
+              :prop="child.prop"
+              :key="`${headerIndex}_${childIndex}`"
+              show-overflow-tooltip
+            >
               <template #default="scope">
-                <template v-for="(detailItem, detailKey) in scope.row.detailList">
-                  <span v-if="header.name == detailItem.schoolName" :key="`${headerIndex}_${childIndex}_${detailKey}`">
-                    <span :class="child.prop == 'doubleOnlineNum' ? 'table_row_blue' : ''"
-                      v-if="child.prop == 'doubleOnlineNum' && detailItem[child.prop] != 0 && detailItem[child.prop] != '-'">
+                <template
+                  v-for="(detailItem, detailKey) in scope.row.detailList"
+                >
+                  <span
+                    v-if="header.name == detailItem.schoolName"
+                    :key="`${headerIndex}_${childIndex}_${detailKey}`"
+                  >
+                    <span
+                      :class="
+                        child.prop == 'doubleOnlineNum' ? 'table_row_blue' : ''
+                      "
+                      v-if="
+                        child.prop == 'doubleOnlineNum' &&
+                        detailItem[child.prop] != 0 &&
+                        detailItem[child.prop] != '-'
+                      "
+                    >
                       {{ detailItem[child.prop] }}
                     </span>
                     <span v-else>
@@ -76,8 +145,16 @@
               </template>
             </el-table-column>
           </el-table-column>
-          <el-table-column v-else align="center" width="100" :label="header.name" :prop="header.prop" :key="headerIndex"
-            fixed="left" show-overflow-tooltip>
+          <el-table-column
+            v-else
+            align="center"
+            width="100"
+            :label="header.name"
+            :prop="header.prop"
+            :key="headerIndex"
+            fixed="left"
+            show-overflow-tooltip
+          >
             <template #default="scope">
               {{ scope.row[header.prop] }}
             </template>
@@ -85,19 +162,141 @@
         </template>
       </el-table>
     </template>
-  </ReportModule>
+  </ReportAssistant>
 </template>
+
 <script lang="ts" setup>
-import ReportModule from "@/components/ReportModule.vue";
-import BarLineChart from "@/components/echarts/barLineChart.vue";//柱状图折线图组件
-import LineBarChart from "@/components/echarts/lineBarChart.vue";//折线图柱状图组件
+import ReportAssistant from "@/components/ReportModule.vue";
+import BarLineChart from "@/components/echarts/barLineChart.vue"; //柱状图折线图组件
+import LineBarChart from "@/components/echarts/lineBarChart.vue"; //折线图柱状图组件
 import { useAnalysisStore } from "@/store/analysis";
-import { onMounted, reactive, computed, watch } from "vue";
-import { scoreSegment } from "@/api/analysis";
+import { onMounted, reactive, computed, watch, ref } from "vue";
+import { scoreSegment, publicExport } from "@/api/analysis";
+import { downloadExcel, GetExcelFileName } from "@/utils/exportExcel";
+
+// --- 类型定义 ---
+
+interface MarkLineItem {
+  name: string;
+  value?: number;
+  color?: string;
+  xAxis?: number;
+}
+
+interface TooltipItem {
+  name: string;
+  value: string;
+}
+
+interface TableHeaderChild {
+  value: string;
+  prop: string;
+}
+
+interface TableHeader {
+  name: string;
+  prop?: string;
+  child?: TableHeaderChild[];
+}
+
+interface TableRowDetail {
+  schoolName: string;
+  [key: string]: any;
+}
+
+interface TableRow {
+  detailList: TableRowDetail[];
+  [key: string]: any;
+}
+
+interface ScoreSegmentData {
+  exportLoading: boolean;
+  datax: string[];
+  datay: number[][];
+  fullScore: number;
+  markLine: MarkLineItem[];
+  tooltipData: TooltipItem[];
+  title: string[];
+  color: string[];
+  unit: string;
+  tooltipTitle: string;
+  tableData: TableRow[];
+  headerData: TableHeader[];
+  pageSize: number;
+  total: number;
+  pageNum: number;
+}
+
+interface ScoreSegmentClassData {
+  datax: string[];
+  datay: number[][];
+  title: string[];
+  legendList: string[];
+  tooltipData: string[][];
+}
+
+interface State {
+  sectionScore: string;
+  sortRangeScore: string[];
+  radioRangeScore: number;
+  scoreSegmentData: ScoreSegmentData;
+  tableLoading: boolean;
+  loadingText: string;
+  scoreSegmentClassData: ScoreSegmentClassData;
+}
+
+// API 返回数据的简易接口定义 (根据实际后端返回调整)
+interface ApiChartDataTotalItem {
+  name: string;
+  doubleOnlineNum: number;
+  doubleOnlineRate: string;
+  studentUserName: string[];
+}
+
+interface ApiChartDataSingleItem {
+  name: string;
+  detailList: {
+    doubleOnlineNum: number;
+    doubleOnlineRate: string;
+  }[];
+}
+
+interface ApiChartData {
+  groupSchoolData: ApiChartDataTotalItem[];
+  oneSchoolData: ApiChartDataSingleItem[];
+  average: string;
+  standard: string;
+  twoStandard: string;
+  negativeOneStandard: string;
+  negativeTwoStandard: string;
+  fullScore: string;
+}
+
+interface ApiResponse {
+  code: number;
+  data?: {
+    chartData: ApiChartData;
+    rowData: TableRow[];
+    titleData: TableHeader[];
+  };
+}
+
+// --- 业务逻辑 ---
+
 const analysisStore = useAnalysisStore();
-const state = reactive({
-  sectionScore: '', // 设置分数段输入框
-  sortRangeScore: ['5', '10'], // 设置分数段
+const reportModuleRef = ref<any>(null);
+const getExamName = computed(() => {
+  return analysisStore.analysisExamInfo.examName || "";
+});
+const scoreRangeTableData = computed(() => {
+  const { tableData, pageSize, pageNum } = state.scoreSegmentData;
+  const start = (pageNum - 1) * pageSize;
+  const end = start + pageSize;
+  return tableData.slice(start, end);
+});
+const state = reactive<State>({
+  sectionScore: "", // 设置分数段输入框
+  sortRangeScore: ["5", "10"], // 设置分数段
   radioRangeScore: 0, // 0按年级, 1按班级
   scoreSegmentData: {
     exportLoading: false,
@@ -123,208 +322,299 @@ const state = reactive({
     datay: [],
     title: [],
     legendList: [],
-    tooltipData: [],//悬浮弹窗的数据
-  }//分数段 按班级分析数据
+    tooltipData: [], //悬浮弹窗的数据
+  }, //分数段 按班级分析数据
 });
-const scoreRangeTableData = computed(() => {
-  const { tableData, pageSize, pageNum } = state.scoreSegmentData;
-  const start = (pageNum - 1) * pageSize;
-  const end = start + pageSize;
-  return tableData.slice(start, end);
-})
 //分数段
 const GetScoreSegment = async () => {
   state.tableLoading = true;
-  const res = await scoreSegment({
-    ...analysisStore.filterObject,
-    scoreSegmentNum: state.sectionScore,
-  });
-  if (res.code === 200 && res.data && res.data.rowData && res.data.rowData.length) {
-    let chartData = res.data.chartData
-    let chartDataTotal = res.data.chartData.groupSchoolData // 联校/年级数据
-    let chartDataSingle = res.data.chartData.oneSchoolData // 单校/班级数据
-
-    let datax = []; // 分数段x轴数据
-    let datay = []; // 联校/年级分数段y轴数据
-    let count = []; // 联校/年级数据
-    let tooltipData = []; // 联校/年级悬浮弹窗数据
-    let markLine = []; // 辅助线数据
-
-    let classTooltipData = []; //班级悬浮弹窗数据
-    let classDatay = []; //分数段按班级y轴数据
-    let classTitle = []; //分数段按班级图例数据
-
-    let average = parseFloat(res.data.chartData.average); // 平均分
-    let averageIndex = 0;//平均分的索引
-    let standard = parseFloat(res.data.chartData.standard);//标准差
-    let standardIndex = 0;//标准差索引
-    let twoStandard = parseFloat(res.data.chartData.twoStandard);//2倍标准差
-    let twoStandardIndex = 0;//2倍标准差索引
-    let negativeOneStandard = parseFloat(res.data.chartData.negativeOneStandard);//-1倍标准差
-    let negativeOneStandardIndex = 0;//-1倍标准差索引
-    let negativeTwoStandard = parseFloat(res.data.chartData.negativeTwoStandard);//-2倍标准差
-    let negativeTwoStandardIndex = 0;//-2倍标准差索引
-
-    chartDataTotal.forEach((item, index) => {
-      let list = item.name.split('-');
-      let num1 = parseInt(list[0].replace(/[\[\]()]/g, ''));
-      let num2 = parseInt(list[1].replace(/[\[\]()]/g, ''));
-
-      if (num2 < average && average < num1) { // 平均分
-        averageIndex = index
-      }
-      if (num2 < standard && standard < num1) { // 标准差
-        standardIndex = index
-      }
-      if (num2 < twoStandard && twoStandard < num1) { // 2倍标准差
-        twoStandardIndex = index
-      }
-      if (num2 < negativeOneStandard && negativeOneStandard < num1) { // -1倍标准差
-        negativeOneStandardIndex = index
-      }
-      if (num2 < negativeTwoStandard && negativeTwoStandard < num1) { // -2倍标准差
-        negativeTwoStandardIndex = index
-      }
+  try {
+    const res = (await scoreSegment({
+      ...analysisStore.filterObject,
+      scoreSegmentNum: state.sectionScore,
+    })) as unknown as ApiResponse;
 
-      datax.push(item.name)
-      count.push(item.doubleOnlineNum)
-      tooltipData.push({
-        name: '',
-        value: `${item.doubleOnlineNum}人,占比${item.doubleOnlineRate} ${item.studentUserName.length ? `(${item.studentUserName.join('、')})` : ''}`
-      })
-    });
-    datay.push(count, count)
+    if (
+      res.code === 200 &&
+      res.data &&
+      res.data.rowData &&
+      res.data.rowData.length
+    ) {
+      let chartData = res.data.chartData;
+      let chartDataTotal = res.data.chartData.groupSchoolData; // 联校/年级数据
+      let chartDataSingle = res.data.chartData.oneSchoolData; // 单校/班级数据
 
-    markLine.push({
-      name: '平均分',
-      value: average,
-      color: '#FAC858',
-      xAxis: averageIndex,
-    });
-    markLine.push({
-      name: '标准差',
-      color: '#3BA272',
-      value: standard,
-      xAxis: standardIndex,
-    });
+      let datax: string[] = []; // 分数段x轴数据
+      let datay: number[][] = []; // 联校/年级分数段y轴数据
+      let count: number[] = []; // 联校/年级数据
+      let tooltipData: TooltipItem[] = []; // 联校/年级悬浮弹窗数据
+      let markLine: MarkLineItem[] = []; // 辅助线数据
 
-    markLine.push({
-      name: '-标准差',
-      color: '#EE6666',
-      value: negativeOneStandard,
-      xAxis: negativeOneStandardIndex,
-    });
-    markLine.push({
-      name: '-2倍标准差',
-      color: '#EE6666',
-      value: negativeTwoStandard,
-      xAxis: negativeTwoStandardIndex,
-    });
-    markLine.push({
-      name: '2倍标准差',
-      color: '#3BA272',
-      value: twoStandard,
-      xAxis: twoStandardIndex,
-    });
-    //判断人数是否可点击
-    const rowData = res.data.rowData || [];
-    state.scoreSegmentData = {
-      datax: datax,
-      datay: datay,
-      fullScore: parseFloat(chartData.fullScore),
-      markLine: markLine,//辅助线数据
-      tooltipData: tooltipData,//悬浮弹窗数据
-      title: ["年级", "1班"],
-      color: ["#995FB3", "#5470C6"],
-      unit: '人',
-      tooltipTitle: '及格率',
-      tableData: rowData,
-      headerData: res.data.titleData || [],
-      pageSize: 10,//每页显示数据
-      total: rowData.length,//总数
-      pageNum: 1,//当前页
-    };//分数段图数据
-
-    // 单校/班级图表数据处理
-    chartDataSingle.forEach(item => {
-      classTitle.push(item.name)
-      let singleItem = []
-      let tootlipItem = []
-      item.detailList.forEach(scoreItem => {
-        singleItem.push(scoreItem.doubleOnlineNum)
-        tootlipItem.push(`${scoreItem.doubleOnlineNum}人,占比${scoreItem.doubleOnlineRate}`);
-      })
-      classDatay.push(singleItem)
-      classTooltipData.push(tootlipItem)
-    })
-    state.scoreSegmentClassData = {
-      datax: datax,
-      datay: classDatay,
-      title: classTitle,//不会修改原数组
-      legendList: classTitle.slice(0, 3),//默认显示全部
-      tooltipData: classTooltipData,
-    };
-  } else {
-    state.scoreSegmentData = {
-      datax: [],
-      datay: [],
-      fullScore: 100,//满分值
-      markLine: [],//辅助线
-      tooltipData: [],//悬浮弹窗的数据
-      title: ["年级", "年级"],
-      color: ["#995FB3", "#5470C6"],
-      unit: '人',
-      tooltipTitle: '及格率',
-      tableData: [],
-      headerData: [],
-      pageSize: 10,//每页显示数据
-      total: 0,//总数
-      pageNum: 1,//当前页
-    };//分数段图数据
-
-    state.scoreSegmentClassData = {
-      datax: [],
-      datay: [],
-      title: [],
-      legendList: [],
-      tooltipData: [],//悬浮弹窗的数据
-    };//分数段 按班级分析数据
+      let classTooltipData: string[][] = []; //班级悬浮弹窗数据
+      let classDatay: number[][] = []; //分数段按班级y轴数据
+      let classTitle: string[] = []; //分数段按班级图例数据
+
+      let average = parseFloat(res.data.chartData.average); // 平均分
+      let averageIndex = 0; //平均分的索引
+      let standard = parseFloat(res.data.chartData.standard); //标准差
+      let standardIndex = 0; //标准差索引
+      let twoStandard = parseFloat(res.data.chartData.twoStandard); //2倍标准差
+      let twoStandardIndex = 0; //2倍标准差索引
+      let negativeOneStandard = parseFloat(
+        res.data.chartData.negativeOneStandard,
+      ); //-1倍标准差
+      let negativeOneStandardIndex = 0; //-1倍标准差索引
+      let negativeTwoStandard = parseFloat(
+        res.data.chartData.negativeTwoStandard,
+      ); //-2倍标准差
+      let negativeTwoStandardIndex = 0; //-2倍标准差索引
+
+      chartDataTotal.forEach((item: ApiChartDataTotalItem, index: number) => {
+        let list = item.name.split("-");
+        let num1 = parseInt(list[0].replace(/[\[\]()]/g, ""));
+        let num2 = parseInt(list[1].replace(/[\[\]()]/g, ""));
+
+        if (num2 < average && average < num1) {
+          // 平均分
+          averageIndex = index;
+        }
+        if (num2 < standard && standard < num1) {
+          // 标准差
+          standardIndex = index;
+        }
+        if (num2 < twoStandard && twoStandard < num1) {
+          // 2倍标准差
+          twoStandardIndex = index;
+        }
+        if (num2 < negativeOneStandard && negativeOneStandard < num1) {
+          // -1倍标准差
+          negativeOneStandardIndex = index;
+        }
+        if (num2 < negativeTwoStandard && negativeTwoStandard < num1) {
+          // -2倍标准差
+          negativeTwoStandardIndex = index;
+        }
+
+        datax.push(item.name);
+        count.push(item.doubleOnlineNum);
+        tooltipData.push({
+          name: "",
+          value: `${item.doubleOnlineNum}人,占比${item.doubleOnlineRate} ${item.studentUserName.length ? `(${item.studentUserName.join("、")})` : ""}`,
+        });
+      });
+      datay.push(count, count);
+
+      markLine.push({
+        name: "平均分",
+        value: average,
+        color: "#FAC858",
+        xAxis: averageIndex,
+      });
+      markLine.push({
+        name: "标准差",
+        color: "#3BA272",
+        value: standard,
+        xAxis: standardIndex,
+      });
+
+      markLine.push({
+        name: "-标准差",
+        color: "#EE6666",
+        value: negativeOneStandard,
+        xAxis: negativeOneStandardIndex,
+      });
+      markLine.push({
+        name: "-2倍标准差",
+        color: "#EE6666",
+        value: negativeTwoStandard,
+        xAxis: negativeTwoStandardIndex,
+      });
+      markLine.push({
+        name: "2倍标准差",
+        color: "#3BA272",
+        value: twoStandard,
+        xAxis: twoStandardIndex,
+      });
+
+      //判断人数是否可点击
+      const rowData = res.data.rowData || [];
+
+      // 修复点:确保赋值对象包含所有必需属性,特别是 exportLoading
+      state.scoreSegmentData = {
+        exportLoading: false,
+        datax: datax,
+        datay: datay,
+        fullScore: parseFloat(chartData.fullScore),
+        markLine: markLine, //辅助线数据
+        tooltipData: tooltipData, //悬浮弹窗数据
+        title: ["年级", "1班"],
+        color: ["#995FB3", "#5470C6"],
+        unit: "人",
+        tooltipTitle: "及格率",
+        tableData: rowData,
+        headerData: res.data.titleData || [],
+        pageSize: 10, //每页显示数据
+        total: rowData.length, //总数
+        pageNum: 1, //当前页
+      }; //分数段图数据
+
+      // 单校/班级图表数据处理
+      chartDataSingle.forEach((item: ApiChartDataSingleItem) => {
+        classTitle.push(item.name);
+        let singleItem: number[] = [];
+        let tootlipItem: string[] = [];
+        item.detailList.forEach((scoreItem) => {
+          singleItem.push(scoreItem.doubleOnlineNum);
+          tootlipItem.push(
+            `${scoreItem.doubleOnlineNum}人,占比${scoreItem.doubleOnlineRate}`,
+          );
+        });
+        classDatay.push(singleItem);
+        classTooltipData.push(tootlipItem);
+      });
+
+      state.scoreSegmentClassData = {
+        datax: datax,
+        datay: classDatay,
+        title: classTitle, //不会修改原数组
+        legendList: classTitle.slice(0, 3), //默认显示全部
+        tooltipData: classTooltipData,
+      };
+    } else {
+      // 修复点:确保赋值对象包含所有必需属性,特别是 exportLoading
+      state.scoreSegmentData = {
+        exportLoading: false,
+        datax: [],
+        datay: [],
+        fullScore: 100, //满分值
+        markLine: [], //辅助线
+        tooltipData: [], //悬浮弹窗的数据
+        title: ["年级", "年级"],
+        color: ["#995FB3", "#5470C6"],
+        unit: "人",
+        tooltipTitle: "及格率",
+        tableData: [],
+        headerData: [],
+        pageSize: 10, //每页显示数据
+        total: 0, //总数
+        pageNum: 1, //当前页
+      }; //分数段图数据
+
+      state.scoreSegmentClassData = {
+        datax: [],
+        datay: [],
+        title: [],
+        legendList: [],
+        tooltipData: [], //悬浮弹窗的数据
+      }; //分数段 按班级分析数据
+    }
+  } catch (error) {
+    console.error("获取分数段数据失败:", error);
+  } finally {
+    state.tableLoading = false;
   }
-  state.tableLoading = false;
 };
+
 //分数段切换
-const TagClick = (item) => {
+const TagClick = (item: string) => {
   state.sectionScore = item;
-  GetScoreSegment();//获取分数段数据
-}
+  GetScoreSegment(); //获取分数段数据
+};
+
 const HandleInput = (value: string) => {
-  state.sectionScore = value.replace(/[^\d]/g, '');
-}
+  state.sectionScore = value.replace(/[^\d]/g, "");
+};
+
 // 设置分数段失去焦点
 const BlurSectionScore = (value: string) => {
-  if (value == '0') {
-    state.sectionScore = '';
+  if (value == "0") {
+    state.sectionScore = "";
   }
-  GetScoreSegment();//获取分数段数据
-}
+  GetScoreSegment(); //获取分数段数据
+};
+
 // 分页
-const handleCurrentChange = (val: number) => {
+const ChangeCurrentPage = (val: number) => {
   state.scoreSegmentData.pageNum = val;
-}
+};
 
-const handleSizeChange = (val: number) => {
+const ChangePageSize = (val: number) => {
   state.scoreSegmentData.pageSize = val;
   state.scoreSegmentData.pageNum = 1;
-}
+};
+// 导出Excel
+const ExportExcel = () => {
+  // 1. 设置加载状态
+  reportModuleRef.value?.SetExportLoading?.(true);
+  // 2. 参数
+  const examName = getExamName.value;
+  let staticHeaderData: { label: string; prop: string | undefined; display: boolean; }[] = [],
+    dynamicsHeaderData: { label: string; prop: string | undefined; display: boolean; }[] = [],
+    childHeaderData: { label: string; prop: string; display: boolean; }[] = [],
+    dataList: any[][] = [];
+  state.scoreSegmentData.headerData.forEach((item) => {
+    if (item.child && item.child.length > 0) {
+      dynamicsHeaderData.push({
+        label: item.name,
+        prop: item.prop,
+        display: true,
+      });
+      if (childHeaderData.length == 0) {
+        item.child.forEach((child) => {
+          childHeaderData.push({
+            label: child.value,
+            prop: child.prop,
+            display: true,
+          });
+        });
+      }
+    } else {
+      staticHeaderData.push({
+        label: item.name,
+        prop: item.prop,
+        display: true,
+      });
+    }
+  });
+
+  state.scoreSegmentData.tableData.forEach((item) => {
+    const rowData: any[] = [];
+    staticHeaderData.forEach((header) => {
+      rowData.push(item[header.prop]);
+    });
+    if (item.detailList && item?.detailList.length > 0) {
+      item.detailList.forEach((detail) => {
+        childHeaderData.forEach((child) => {
+          rowData.push(detail[child.prop]);
+        });
+      });
+    }
+    dataList.push(rowData);
+  });
+  const params = {
+    fileName: `${GetExcelFileName(examName, analysisStore.filterObject, "分数段表")}`,
+    examName: examName, //考试名称
+    sheetName: "分数段表", //sheet页名称
+    staticHeaderData: staticHeaderData, //静态表头
+    dynamicsHeaderData: dynamicsHeaderData, //动态表头的一级表头
+    childHeaderData: childHeaderData, //二级表头
+    dataList: dataList, //数据
+  };
+  // 3. 调用通用下载方法,并在完成后重置加载状态
+  downloadExcel(publicExport, params).finally(() => {
+    reportModuleRef.value?.SetExportLoading?.(false);
+  });
+};
 // 初始化
 const pageInit = () => {
   GetScoreSegment();
 };
+
 // 监听筛选条件
 watch(
   () => analysisStore.filterObject,
   async () => {
-    state.sectionScore = '';
+    state.sectionScore = "";
     pageInit();
   },
   { deep: true },

+ 35 - 40
src/views/analysis/optionAnalysis.vue

@@ -99,6 +99,7 @@
     </template>
   </ReportModule>
   <ReportModule
+    ref="reportModuleRef"
     :showTitle="true"
     :titleList="[`2、${state.groupTitle}表`]"
     :showDescribe="false"
@@ -108,8 +109,9 @@
     :currentPage="state.objectiveAnalysisData.currentPage"
     :pageSize="state.objectiveAnalysisData.pageSize"
     :total="state.objectiveAnalysisData.total"
-    @update:pageSize="handleSizeChange"
-    @update:currentPage="handleCurrentChange"
+    @ChangePageSize="ChangePageSize"
+    @ChangeCurrentPage="ChangeCurrentPage"
+    @ExportExcel="ExportExcel"
   >
     <template #module_table_chart>
       <el-table
@@ -398,17 +400,27 @@
 <script lang="ts" setup>
 import ReportModule from "@/components/ReportModule.vue";
 import BarChart from "@/components/echarts/barChart.vue"; //单柱状图
-import PieChart from "@/components/echarts/PieChart.vue";
-import { onMounted, watch, computed, reactive } from "vue";
+import PieChart from "@/components/echarts/pieChart.vue";
+import { onMounted, watch, computed, ref, reactive } from "vue";
 import { useAnalysisStore } from "@/store/analysis";
 import {
   selectQuestionAnalysis,
   selectQuestionAnalysisPage,
   selectQuestionStudentPageList,
+  exportObjectAnalysis,
 } from "@/api/analysis";
+import {
+  downloadExcel,
+  GetExcelFileName,
+  selectQuestionAnalysisStaticHeaderData,
+  selectQuestionAnalysisDynamicsHeaderData,
+} from "@/utils/exportExcel";
 const analysisStore = useAnalysisStore();
+const reportModuleRef = ref<any>(null);
+const getExamName = computed(() => {
+  return analysisStore.analysisExamInfo.examName || "";
+});
 const state = reactive({
-  params: {}, //接口参数
   groupTitle: "选项分析",
   question: {
     resData: [], //小题分析数据
@@ -581,10 +593,10 @@ const GetSelectQuestionAnalysisPage = () => {
   });
 };
 //分页切换
-const handleCurrentChange = (val) => {
+const ChangeCurrentPage = (val) => {
   state.objectiveAnalysisData.currentPage = val;
 };
-const handleSizeChange = (val: number) => {
+const ChangePageSize = (val: number) => {
   state.objectiveAnalysisData.pageSize = val;
   state.objectiveAnalysisData.currentPage = 1;
 };
@@ -645,10 +657,10 @@ const DialogExportExcel = () => {
   state.dialogExportLoading = true;
   const examName = this.$store.state.report.examSelectItem.examName;
   const title = state.dialogData.title;
-  const fileName = `${GetFileName(examName, this.params)}_${state.groupTitle}表_${title.replace(/\s/g, "")}_${GetExportDate()}`;
+  const fileName = `${GetFileName(examName, analysisStore.filterObject)}_${state.groupTitle}表_${title.replace(/\s/g, "")}_${GetExportDate()}`;
   const sheetName = title.replace(/\s/g, "");
   let params = {
-    ...this.params,
+    ...analysisStore.filterObject,
     studentCodeList: this.dialogData.studentCodeList,
     fileName,
     sheetName,
@@ -677,16 +689,18 @@ const DialogExportExcel = () => {
     });
 };
 // 客观题分析表数据导出
-const ExportExcel = (title) => {
-  this.exportLoading = true;
-  const examName = this.$store.state.report.examSelectItem.examName;
+const ExportExcel = () => {
+  reportModuleRef.value?.SetExportLoading?.(true);
+  const examName = getExamName.value;
   const staticHeaderData = selectQuestionAnalysisStaticHeaderData();
   const dynamicsHeaderData = selectQuestionAnalysisDynamicsHeaderData();
-  const childHeaderData = this.objectiveAnalysisData.headLabels.map((item) => ({
-    label: item,
-  }));
+  const childHeaderData = state.objectiveAnalysisData.headLabels.map(
+    (item) => ({
+      label: item,
+    }),
+  );
   let dataList = [];
-  this.objectiveAnalysisData.tableData.forEach((row) => {
+  state.objectiveAnalysisData.tableData.forEach((row) => {
     const staticData = staticHeaderData.map((header) => {
       if (header.prop == "scoreRate") {
         return `${row["scoreRate"]}%`;
@@ -713,36 +727,17 @@ const ExportExcel = (title) => {
     dataList.push([...staticData, ...studentNumData, ...rateData]);
   });
   let params = {
-    fileName: `${GetFileName(examName, this.params)}_${title}_${GetExportDate()}`,
+    fileName: `${GetExcelFileName(examName, analysisStore.filterObject, state.groupTitle)}`,
     examName: examName, //考试名称
-    sheetName: title, //sheet页名称
+    sheetName: state.groupTitle, //sheet页名称
     staticHeaderData: staticHeaderData, //静态表头
     dynamicsHeaderData: dynamicsHeaderData, //动态表头的一级表头
     childHeaderData: childHeaderData, //二级表头
     dataList: dataList, //数据
   };
-  this.$api.reportSchool
-    .exportObjectAnalysis(params)
-    .then((res) => {
-      if (res.status == 200) {
-        let blob = new Blob([res.data], {
-          type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
-        });
-        let a = document.createElement("a");
-        a.href = URL.createObjectURL(blob);
-        a.download = decodeURIComponent(
-          res.headers["content-disposition"].split("filename=")[1],
-        );
-        a.click();
-        URL.revokeObjectURL(a.href);
-        a.remove();
-      } else {
-        this.$message.error("导出失败!");
-      }
-    })
-    .finally(() => {
-      this.exportLoading = false;
-    });
+  downloadExcel(exportObjectAnalysis, params).finally(() => {
+    reportModuleRef.value?.SetExportLoading?.(false);
+  });
 };
 // 监听筛选条件
 watch(

+ 65 - 34
src/views/analysis/optionDetail.vue

@@ -1,6 +1,7 @@
 <template>
   <!-- 成绩查询 - 成绩单 -->
   <ReportModule
+    ref="reportModuleRef"
     :showTitle="false"
     :showDescribe="false"
     :showPrintBtn="false"
@@ -9,8 +10,9 @@
     :pageSize="state.pageInfo.pageSize"
     :pageSizes="[50, 100]"
     :total="state.pageInfo.total"
-    @update:pageSize="handleSizeChange"
-    @update:currentPage="handleCurrentChange"
+    @ChangePageSize="ChangePageSize"
+    @ChangeCurrentPage="ChangeCurrentPage"
+    @ExportExcel="ExportExcel"
   >
     <template #title_left>
       <el-input
@@ -19,10 +21,10 @@
         style="width: 200px"
         placeholder="请输入学号或姓名"
         class="input_with"
-        @input="handleSearch"
+        @input="HandleSearch"
       >
         <template #append>
-          <el-button :icon="Search" @click="handleSearch" />
+          <el-button :icon="Search" @click="HandleSearch" />
         </template>
       </el-input>
 
@@ -38,6 +40,7 @@
       <el-table
         :data="state.tableData"
         border
+        stripe
         height="500"
         v-loading="state.tableLoading"
         :element-loading-text="state.loadingText"
@@ -52,15 +55,14 @@
           label="序号"
           fixed="left"
         ></el-table-column>
-        <template v-for="item in state.staticHeaderData">
-          <el-table-column
-            v-if="item.display"
-            :key="item.prop"
-            :prop="item.prop"
-            :label="item.label"
-            min-width="100"
-          />
-        </template>
+        <el-table-column
+          v-for="item in state.staticHeaderData"
+          :key="item.prop"
+          :prop="item.prop"
+          :label="item.label"
+          min-width="80"
+          fixed="left"
+        />
         <el-table-column
           v-for="(item, index) in state.answerDataTitle"
           :key="item.prop"
@@ -83,10 +85,12 @@ import {
   studentTranscriptTitle,
   queryJointStudentStatistics,
   studentTranscript,
+  studentTranscriptExcel,
 } from "@/api/analysis";
 import { useAnalysisStore } from "@/store/analysis";
 import { Search } from "@element-plus/icons-vue";
-import { onMounted, reactive, watch } from "vue";
+import { downloadExcel } from "@/utils/exportExcel";
+import { onMounted, reactive, ref, watch } from "vue";
 
 interface TableColumn {
   prop: string;
@@ -119,7 +123,7 @@ interface State {
 }
 
 const analysisStore = useAnalysisStore();
-
+const reportModuleRef = ref<any>(null);
 const state = reactive<State>({
   keyWord: "",
   tableData: [],
@@ -140,7 +144,7 @@ const state = reactive<State>({
 });
 
 /** 获取表头 */
-const getStudentTranscriptTitle = async () => {
+const GetStudentTranscriptTitle = async () => {
   if (!analysisStore.filterObject) return;
   try {
     const res = await studentTranscriptTitle({
@@ -148,7 +152,10 @@ const getStudentTranscriptTitle = async () => {
       topicControl: 0,
     });
     if (res.code === 200) {
-      state.staticHeaderData = res.data?.title?.staticHeaderData || [];
+      const staticHeaderData = res.data?.title?.staticHeaderData || [];
+      state.staticHeaderData = staticHeaderData.filter(
+        (item: { display: any }) => item.display,
+      );
       state.answerDataTitle =
         res.data?.title?.dynamicsHeaderData?.answerDataTitle || [];
     }
@@ -158,7 +165,7 @@ const getStudentTranscriptTitle = async () => {
 };
 
 /** 获取顶部统计 */
-const getTableCount = async () => {
+const GetTableCount = async () => {
   if (!analysisStore.filterObject) return;
   try {
     const res = await queryJointStudentStatistics({
@@ -175,7 +182,7 @@ const getTableCount = async () => {
 };
 
 /** 获取表格数据 */
-const getTableData = async () => {
+const GetTableData = async () => {
   if (!analysisStore.filterObject) return;
   try {
     state.tableLoading = true;
@@ -203,41 +210,65 @@ const GetIndexNumber = (index: number) => {
     (state.pageInfo.pageNum - 1) * state.pageInfo.pageSize + index + 1;
   return indexCount;
 };
-// 分页 & 搜索
-const handleCurrentChange = (val: number) => {
+// 当前页码事件
+const ChangeCurrentPage = (val: number) => {
   state.pageInfo.pageNum = val;
-  getTableData();
+  GetTableData();
 };
-
-const handleSizeChange = (val: number) => {
+// 每页显示个数事件
+const ChangePageSize = (val: number) => {
   state.pageInfo.pageSize = val;
   state.pageInfo.pageNum = 1;
-  getTableData();
+  GetTableData();
 };
-
-const handleSearch = () => {
+// 导出Excel
+const ExportExcel = () => {
+  // 1. 设置加载状态
+  reportModuleRef.value?.SetExportLoading?.(true);
+  // 2. 参数
+  const params = {
+    ...analysisStore.filterObject,
+    topicControl: 0,
+    topicControlName: "选项明细",
+    queryStr: state.keyWord,
+    pageParam: {
+      pageNum: state.pageInfo.pageNum,
+      pageSize: state.pageInfo.pageSize,
+    },
+    staticHeaderData: state.staticHeaderData,
+    dynamicsHeaderDataMap: {
+      answerDataTitle: state.answerDataTitle,
+    },
+  };
+  // 3. 调用通用下载方法,并在完成后重置加载状态
+  downloadExcel(studentTranscriptExcel, params).finally(() => {
+    reportModuleRef.value?.SetExportLoading?.(false);
+  });
+};
+// 搜索事件
+const HandleSearch = () => {
   state.pageInfo.pageNum = 1;
-  getTableData();
+  GetTableData();
 };
 
 // 初始化
-const pageInit = () => {
-  getStudentTranscriptTitle();
-  getTableCount();
-  getTableData();
+const PageInit = () => {
+  GetStudentTranscriptTitle();
+  GetTableCount();
+  GetTableData();
 };
 
 // 监听筛选条件
 watch(
   () => analysisStore.filterObject,
   async () => {
-    pageInit();
+    PageInit();
   },
   { deep: true },
 );
 
 onMounted(() => {
-  pageInit();
+  PageInit();
 });
 </script>
 

+ 141 - 82
src/views/analysis/propositionAnalysis.vue

@@ -147,9 +147,7 @@
             :key="index"
           >
             <template #default="scope">
-              <span class="table_row_answer">{{
-                scope.row[item.prop] || "-"
-              }}</span>
+              {{ scope.row[item.prop] || "-" }}
             </template>
           </el-table-column>
         </el-table>
@@ -236,9 +234,7 @@
             :key="index"
           >
             <template #default="scope">
-              <span class="table_row_answer">{{
-                scope.row[item.prop] || "-"
-              }}</span>
+              {{ scope.row[item.prop] || "-" }}
             </template>
           </el-table-column>
         </el-table>
@@ -246,6 +242,7 @@
     </template>
   </ReportModule>
   <ReportModule
+    ref="reportModuleRef"
     :showTitle="true"
     :titleList="['4、命题分析表']"
     :showDescribe="false"
@@ -255,8 +252,9 @@
     :currentPage="state.questionData.currentPage"
     :pageSize="state.questionData.pageSize"
     :total="state.questionData.total"
-    @update:pageSize="handleSizeChange"
-    @update:currentPage="handleCurrentChange"
+    @ChangePageSize="ChangePageSize"
+    @ChangeCurrentPage="ChangeCurrentPage"
+    @ExportExcel="ExportExcel"
   >
     <template #module_table_chart>
       <el-table
@@ -355,18 +353,30 @@
   </ReportModule>
 </template>
 <script lang="ts" setup>
-import { reactive, onMounted, computed, watch } from "vue";
+import { reactive, onMounted, computed, ref, watch } from "vue";
 import ReportModule from "@/components/ReportModule.vue";
 import LineChart from "@/components/echarts/lineChart.vue";
-import PieChart from "@/components/echarts/PieChart.vue";
+import PieChart from "@/components/echarts/pieChart.vue";
 import paperDifficultyImg from "@/assets/icon/Paper_difficulty.png";
 import difficultyRatioImg from "@/assets/icon/difficulty_ratio.png";
 import testPaperDistinctionImg from "@/assets/icon/test_paper_distinction.png";
 import discriminationRatioImg from "@/assets/icon/discrimination_ratio.png";
 import testPaperReliabilityImg from "@/assets/icon/test_paper_reliability.png";
 import { useAnalysisStore } from "@/store/analysis";
-import { propositionAnalysis } from "@/api/analysis";
+import { propositionAnalysis, publicExport } from "@/api/analysis";
+import {
+  downloadExcel,
+  GetExcelFileName,
+  propositionAnalysisStaticHeaderData,
+} from "@/utils/exportExcel";
+
 const analysisStore = useAnalysisStore();
+const reportModuleRef = ref<any>(null);
+
+const getExamName = computed(() => {
+  return analysisStore.analysisExamInfo.examName || "";
+});
+
 interface PaperItem {
   label: string;
   value: string | number;
@@ -416,26 +426,26 @@ const state = reactive({
   keyWord: "",
   checkList: [] as string[],
   lineChartData: {
-    datax: [],
-    datay: [[], []],
+    datax: [] as string[],
+    datay: [[], []] as number[][], // 修复: 显式指定为 number[][],防止推断为 never[][]
     title: ["难度", "区分度"],
-    colors: ["", "", "", ""],
-    markNumber: [],
+    colors: [] as string[],
+    markNumber: [] as number[],
   },
   difficultyChart: {
-    data: [],
+    data: [] as any[],
     refresh: false,
-    tableData: [],
+    tableData: [] as any[],
   },
   discriminationChart: {
-    data: [],
+    data: [] as any[],
     refresh: false,
-    tableData: [],
-    headerData: [],
+    tableData: [] as any[],
+    headerData: [] as any[],
   },
   questionData: {
-    totalTableData: [],
-    tableData: [],
+    totalTableData: [] as any[],
+    tableData: [] as any[],
     pageSize: 10,
     total: 0,
     currentPage: 1,
@@ -444,81 +454,83 @@ const state = reactive({
   dataLoading: false,
   loadingText: "加载中,请稍后……",
 });
+
 const questionTable = computed(() => {
-  const start =
-    (state.questionData.currentPage - 1) * state.questionData.pageSize;
+  const start = (state.questionData.currentPage - 1) * state.questionData.pageSize;
   const end = start + state.questionData.pageSize;
   return state.questionData.tableData.slice(start, end);
 });
-const PageInit = async (isRefresh) => {
+
+// 修复: 添加参数类型注解 isRefresh: boolean
+const PageInit = async (isRefresh: boolean) => {
   if (isRefresh) {
     state.difficultyChart.refresh = true;
     state.discriminationChart.refresh = true;
   }
   state.dataLoading = true;
   try {
-    //命题分析
     let { code, data } = await propositionAnalysis(analysisStore.filterObject);
     state.questionData.currentPage = 1;
     if (code == 200 && data) {
-      const {
-        difficultyList,
-        discriminationList,
-        paperInfo,
-        questionInfoList,
-      } = data;
-      const difficultyChart = difficultyList || []; //难度分析数据
-      const discriminationChart =
-        discriminationList.map((item) => {
-          item.name == "及格"
-            ? (item.name = "一般")
-            : item.name == "低分"
-              ? (item.name = "较低")
-              : item.name;
-          return item;
-        }) || []; //3、区分度分布数据
-      state.questionData.totalTableData = questionInfoList || []; //题目列表 总数据 原始数据
-      state.questionData.tableData = questionInfoList || []; //题目列表 表数据
+      const { difficultyList, discriminationList, paperInfo, questionInfoList } = data;
+      
+      const difficultyChart = difficultyList || []; 
+      
+      // 修复: 将三元表达式赋值改为标准的 if-else,避免 TS 语法警告
+      const discriminationChart = (discriminationList || []).map((item: any) => {
+        if (item.name === "及格") {
+          item.name = "一般";
+        } else if (item.name === "低分") {
+          item.name = "较低";
+        }
+        return item;
+      }); 
+
+      state.questionData.totalTableData = questionInfoList || []; 
+      state.questionData.tableData = questionInfoList || []; 
       const length = state.questionData.totalTableData.length;
-      state.paperItems[0].value = paperInfo.paperDifficulty; //试卷难度
-      state.paperItems[1].value = paperInfo.difficultyRate; //难度比例
-      state.paperItems[2].value = paperInfo.paperDiscrimination; //试卷区分度
-      state.paperItems[3].value = paperInfo.discriminationRate; //区分度比例
-      state.paperItems[4].value = paperInfo.paperReliability; //试卷信度
+      
+      // 修复: 使用可选链 ?. 和空值合并 ?? 防止 paperInfo 为空时报错
+      state.paperItems[0].value = paperInfo?.paperDifficulty ?? ""; 
+      state.paperItems[1].value = paperInfo?.difficultyRate ?? ""; 
+      state.paperItems[2].value = paperInfo?.paperDiscrimination ?? ""; 
+      state.paperItems[3].value = paperInfo?.discriminationRate ?? ""; 
+      state.paperItems[4].value = paperInfo?.paperReliability ?? ""; 
+      
       state.lineChartData.datax = [];
-      state.lineChartData.datay = [[], []];
+      state.lineChartData.datay = [[], []] as number[][]; // 修复: 重置时保持类型断言
 
+      const lastItem = state.questionData.totalTableData[length - 1];
+      // 修复: 使用可选链防止 length 为 0 时越界报错
       state.lineChartData.markNumber = [
-        state.questionData.totalTableData[length - 1].difficulty,
-        state.questionData.totalTableData[length - 1].discrimination,
-      ]; //全卷难度,全卷区分度
+        lastItem?.difficulty ?? 0,
+        lastItem?.discrimination ?? 0,
+      ]; 
+      
       state.questionData.totalTableData.forEach((item, index) => {
         if (index != length - 1) {
           state.lineChartData.datax.push(item.questionCode);
-          state.lineChartData.datay[0].push(item.difficulty); //难度
-          state.lineChartData.datay[1].push(item.discrimination); //区分度
+          state.lineChartData.datay[0].push(item.difficulty); 
+          state.lineChartData.datay[1].push(item.discrimination); 
         }
       });
-      state.difficultyChart.data = difficultyChart.map((item) => {
-        return {
-          name: item.name,
-          value: item.scoreRate,
-        };
-      }); //难度分析 饼状图
-      state.difficultyChart.tableData = difficultyChart; //难度分析 表格
-      state.difficultyChart.headerData = data.headerList || []; //难度分析 表头数据
-      state.discriminationChart.data = discriminationChart.map((item) => {
-        return {
-          name: item.name,
-          value: item.scoreRate,
-        };
-      }); //区分度分布 饼状图
-      state.discriminationChart.headerData = data.headerList || []; //区分度分布 表头数据
-      state.discriminationChart.tableData = discriminationChart; //区分度分布 表格
-      state.questionData.total = length; //总条数
+      
+      state.difficultyChart.data = difficultyChart.map((item: any) => {
+        return { name: item.name, value: item.scoreRate };
+      }); 
+      state.difficultyChart.tableData = difficultyChart; 
+      state.difficultyChart.headerData = data.headerList || []; 
+      
+      state.discriminationChart.data = discriminationChart.map((item: any) => {
+        return { name: item.name, value: item.scoreRate };
+      }); 
+      state.discriminationChart.headerData = data.headerList || []; 
+      state.discriminationChart.tableData = discriminationChart; 
+      
+      state.questionData.total = length; 
     } else {
       state.lineChartData.datax = [];
-      state.lineChartData.datay = [[], []];
+      state.lineChartData.datay = [[], []] as number[][];
       state.lineChartData.title = ["难度", "区分度"];
       state.lineChartData.colors = [];
       state.lineChartData.markNumber = [];
@@ -528,35 +540,72 @@ const PageInit = async (isRefresh) => {
       });
 
       state.difficultyChart.data = [];
-      state.difficultyChart.tableData = []; //难度分析 表格
-      state.difficultyChart.headerData = []; //难度分析 表头数据
+      state.difficultyChart.tableData = []; 
+      state.difficultyChart.headerData = []; 
 
       state.discriminationChart.data = [];
       state.discriminationChart.headerData = [];
       state.discriminationChart.tableData = [];
 
-      state.questionData.totalTableData = []; //题目列表 总数据 原始数据
+      state.questionData.totalTableData = []; 
       state.questionData.tableData = [];
     }
   } finally {
     state.dataLoading = false;
   }
 };
-const GetFirstPieRefresh = (value) => {
+
+// 修复: 添加参数类型注解
+const GetFirstPieRefresh = (value: boolean) => {
   state.difficultyChart.refresh = value;
 };
-const GetSecondPieRefresh = (value) => {
+const GetSecondPieRefresh = (value: boolean) => {
   state.discriminationChart.refresh = value;
 };
-// 4、命题分析表 表格分页切换事件
-const handleCurrentChange = (val) => {
+
+// 修复: 添加参数类型注解
+const ChangeCurrentPage = (val: number) => {
   state.questionData.currentPage = val;
 };
-const handleSizeChange = (val: number) => {
+const ChangePageSize = (val: number) => {
   state.questionData.pageSize = val;
   state.questionData.currentPage = 1;
 };
-// 监听筛选条件
+
+const ExportExcel = () => {
+  reportModuleRef.value?.SetExportLoading?.(true);
+  const examName = getExamName.value;
+  const staticHeaderData = propositionAnalysisStaticHeaderData();
+  let dataList: any[] = [];
+  
+  state.questionData.tableData.forEach((row: any) => {
+    const rowData = staticHeaderData.map((header: any) => {
+      if (header.prop == "questionCode") {
+        return row.showCode == 0 ? "全卷" : row.questionCode;
+      } else if (header.prop == "scoreRate") {
+        return `${row.scoreRate}%`;
+      } else {
+        return row[header.prop];
+      }
+    });
+    dataList.push(rowData);
+  });
+  
+  let params = {
+    fileName: `${GetExcelFileName(examName, analysisStore.filterObject, "命题分析表")}`,
+    examName: examName, 
+    sheetName: "命题分析表", 
+    staticHeaderData: staticHeaderData, 
+    dynamicsHeaderData: [], 
+    childHeaderData: [], 
+    dataList: dataList, 
+  };
+  
+  downloadExcel(publicExport, params).finally(() => {
+    reportModuleRef.value?.SetExportLoading?.(false);
+  });
+};
+
 watch(
   () => analysisStore.filterObject,
   async () => {
@@ -645,6 +694,16 @@ onMounted(() => {
   }
   .content_right {
     width: 60%;
+    display: flex;
+    align-items: center;
+    &.table_42{
+      :deep(.el-table){
+        border-radius: 8px;
+        .el-table__cell{
+          height: 52px;
+        }
+      }
+    }
   }
 }
 </style>

+ 146 - 16
src/views/analysis/questionAnalysis.vue

@@ -225,13 +225,23 @@
     :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 card"></div>
-      <div class="content_right card table_42">
+      <div class="content_left paper_card">
+        <StudentQuestionImg :paperInfo="state.paperInfos"></StudentQuestionImg>
+      </div>
+      <div class="content_right paper_card table_42">
         <el-table
           border
           :data="state.majorAnswerData.tableData"
@@ -286,6 +296,7 @@
     </template>
   </ReportModule>
   <ReportModule
+    ref="reportModuleRef"
     :showTitle="true"
     :titleList="[state.groupTitle]"
     :showDescribe="true"
@@ -295,10 +306,10 @@
     :currentPage="state.majorTableData.currentPage"
     :pageSize="state.majorTableData.pageSize"
     :total="state.majorTableData.total"
-    @update:pageSize="handleSizeChange"
-    @update:currentPage="handleCurrentChange"
+    @ChangePageSize="ChangePageSize"
+    @ChangeCurrentPage="ChangeCurrentPage"
+    @ExportExcel="ExportExcel"
   >
-    <template #title_right></template>
     <template #module_table_chart>
       <el-table
         :data="state.majorTableData.tableData"
@@ -375,15 +386,28 @@
     </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>
 </template>
 <script lang="ts" setup>
 import ReportModule from "@/components/ReportModule.vue";
 import EchartType from "@/components/EchartType.vue";
+import QuestionCard from "@/components/QuestionCard.vue";
 import { useAnalysisStore } from "@/store/analysis";
 import {
   questionAnalysis,
   queryAnswerListByAnswerAndScore,
+  publicExport,
 } from "@/api/analysis";
 import BarLineCharts from "@/components/echarts/barLineCharts.vue"; //柱状图折线图组合图组件
 import RadarCharts from "@/components/echarts/radarCharts.vue"; //雷达图
@@ -391,9 +415,15 @@ 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 { onMounted, reactive, watch, ref, nextTick } from "vue";
+import StudentQuestionImg from "@/components/StudentQuestionImg.vue"; // 学生小题答题卡组件
+import { downloadExcel, GetExcelFileName } from "@/utils/exportExcel";
+import { onMounted, reactive, watch, ref, computed, nextTick } from "vue";
 import { cloneDeep } from "lodash-es";
 const analysisStore = useAnalysisStore();
+const reportModuleRef = ref<any>(null);
+const getExamName = computed(() => {
+  return analysisStore.analysisExamInfo.examName || "";
+});
 const state = reactive({
   questionGroupDefault: [
     {
@@ -418,7 +448,6 @@ const state = reactive({
     },
   ],
   questionGroupList: [], //试题分组标签 动态接口获取
-  tagActive: "problem", //选择的试题分组标签
   groupTitle: "小题分析",
   groupPreviousTitle: "",
   knowledgeLayeredTitle: "", //知识点分层标题
@@ -1087,11 +1116,37 @@ 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) => {
+  console.log("row", 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;
@@ -1157,7 +1212,7 @@ const GetPageMajorTableData = () => {
 const ResetTableScroll = () => {
   nextTick(() => {
     if (majorTable.value) {
-      const tableBody = majorTable.value.querySelector(
+      const tableBody = majorTable.value.$el.querySelector(
         ".el-table__body-wrapper",
       );
       if (tableBody) {
@@ -1167,15 +1222,63 @@ const ResetTableScroll = () => {
     }
   });
 };
-const handleCurrentChange = (val) => {
+const ChangeCurrentPage = (val) => {
   state.majorTableData.currentPage = val;
   GetMajorTableData(); //加载分析表格数据
 };
-const handleSizeChange = (val: number) => {
+const ChangePageSize = (val: number) => {
   state.majorTableData.pageSize = val;
   state.majorTableData.currentPage = 1;
   GetMajorTableData(); //加载分析表格数据
-}
+};
+// 导出Excel
+const ExportExcel = () => {
+  // 1. 设置加载状态
+  reportModuleRef.value?.SetExportLoading?.(true);
+  // 2. 参数
+  const examName = getExamName.value;
+  let params = {
+    fileName: `${GetExcelFileName(examName, analysisStore.filterObject, state.groupTitle)}`,
+    examName: examName, //考试名称
+    sheetName: state.groupTitle, //sheet页名称
+  };
+  const staticHeaderData = state.problemAnalysisData.headerList.filter(
+    (item) => item.display,
+  );
+  const childHeaderData = state.problemAnalysisData.childHeaderList.filter(
+    (item) => item.display,
+  );
+  const dynamicsHeaderData = state.problemAnalysisData.changeHeaderList.filter(
+    (item) => item.display,
+  );
+  params.staticHeaderData = staticHeaderData;
+  params.childHeaderData = childHeaderData;
+  params.dynamicsHeaderData = dynamicsHeaderData;
+  let dataList = [];
+  state.majorTableData.allTableData.forEach((item) => {
+    const rowData = [];
+    staticHeaderData.forEach((header) => {
+      rowData.push(item?.[header.prop] || "-");
+    });
+    dynamicsHeaderData.forEach((parent) => {
+      childHeaderData.forEach((child) => {
+        const itemValue = item[`${parent.prop}_${child.prop}`];
+        if (child.prop.indexOf("Rate") > -1) {
+          const rate = itemValue ? `${itemValue}%` : "-";
+          rowData.push(rate);
+        } else {
+          rowData.push(itemValue ?? "-");
+        }
+      });
+    });
+    dataList.push(rowData);
+  });
+  params.dataList = dataList;
+  // 3. 调用通用下载方法,并在完成后重置加载状态
+  downloadExcel(publicExport, params).finally(() => {
+    reportModuleRef.value?.SetExportLoading?.(false);
+  });
+};
 //设置表头样式
 const HeaderRowStyle = ({ row, rowIndex }) => {
   if (rowIndex === 1) {
@@ -1184,6 +1287,30 @@ 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 VisibleQuestionCard = () => {
+  state.showQuestionCardDialog = true;
+};
+//关闭弹框
+const CloseQuestionCardDialog = () => {
+  state.showQuestionCardDialog = false;
+};
 const pageInit = () => {
   state.problemAnalysisData.chartTypeList =
     analysisStore.filterObject.classLevel != 2
@@ -1240,8 +1367,11 @@ onMounted(() => {
     width: calc(100% - 220px);
   }
 
-  &.card {
+  &.paper_card {
     width: calc(100% - 480px);
+    height: 400px;
+    background-color: #f5f5f5;
+    border-radius: 10px;
   }
 }
 
@@ -1249,14 +1379,14 @@ onMounted(() => {
   height: 100%;
   display: flex;
   align-items: center;
-  margin: auto;
+  margin: auto 0 auto auto;
   padding-bottom: 0;
 
   &.answer {
     width: 200px;
   }
 
-  &.card {
+  &.paper_card {
     width: 460px;
   }
 

+ 273 - 110
src/views/analysis/score.vue

@@ -1,47 +1,115 @@
 <template>
   <!-- 成绩查询 - 成绩单 -->
-  <ReportModule :showTitle="false" :showDescribe="false" tableOrChart="table" :currentPage="state.pageInfo.pageNum"
-    :pageSize="state.pageInfo.pageSize" :pageSizes="[50, 100]" :total="state.pageInfo.total"
-    @update:pageSize="handleSizeChange" @update:currentPage="handleCurrentChange">
+  <ReportModule
+    ref="reportModuleRef"
+    :showTitle="false"
+    :showDescribe="false"
+    tableOrChart="table"
+    :currentPage="state.pageInfo.pageNum"
+    :pageSize="state.pageInfo.pageSize"
+    :pageSizes="[50, 100]"
+    :total="state.pageInfo.total"
+    @ChangePageSize="ChangePageSize"
+    @ChangeCurrentPage="ChangeCurrentPage"
+    @ExportExcel="ExportExcel"
+  >
     <template #title_left>
-      <el-input v-model="state.keyWord" clearable style="width: 200px" placeholder="请输入学号或姓名" class="input_with" @input="handleSearch">
+      <el-input
+        v-model="state.keyWord"
+        clearable
+        style="width: 200px"
+        placeholder="请输入学号或姓名"
+        class="input_with"
+        @input="HandleSearch"
+      >
         <template #append>
-          <el-button :icon="Search" @click="handleSearch" />
+          <el-button :icon="Search" @click="HandleSearch" />
         </template>
       </el-input>
-
-      <span class="count_item">应考:{{ state.tableCount.examStudentCount }}人</span>
+      <span class="count_item"
+        >应考:{{ state.tableCount.examStudentCount }}人</span
+      >
       <span class="count_item">实考:{{ state.tableCount.normalCount }}人</span>
-      <span class="count_item orange">缺考:{{ state.tableCount.missExamCount }}人</span>
+      <span class="count_item orange"
+        >缺考:{{ state.tableCount.missExamCount }}人</span
+      >
     </template>
-
     <template #title_right>
       <el-checkbox-group v-model="state.checkList" class="checkbox_group">
-        <el-checkbox v-if="state.groupDataTitleData.length" label="显示分组" value="group" />
-        <el-checkbox v-if="state.scoreDataTitleData.length" label="显示小题" value="question" />
+        <el-checkbox
+          v-if="state.groupDataTitleData.length"
+          label="显示分组"
+          value="group"
+        />
+        <el-checkbox
+          v-if="state.scoreDataTitleData.length"
+          label="显示小题"
+          value="question"
+        />
       </el-checkbox-group>
     </template>
-
     <template #module_table_chart>
-      <el-table :data="state.tableData" border height="500" v-loading="state.tableLoading"
-        :element-loading-text="state.loadingText" element-loading-spinner="el-icon-loading"
-        element-loading-background="#ffffff">
-        <el-table-column type="index" :index="GetIndexNumber" align="center" width="70" label="序号"
-          fixed="left"></el-table-column>
-        <template v-for="item in state.staticHeaderData">
-          <el-table-column v-if="item.display" :key="item.prop" :prop="item.prop" :label="item.label" min-width="100" />
-        </template>
+      <el-table
+        :data="state.tableData"
+        border
+        stripe
+        ref="tableRef"
+        :height="state.tableHeight"
+        v-loading="state.tableLoading"
+        :element-loading-text="state.loadingText"
+        element-loading-spinner="el-icon-loading"
+        element-loading-background="#ffffff"
+      >
+        <el-table-column
+          type="index"
+          :index="GetIndexNumber"
+          align="center"
+          width="70"
+          label="序号"
+          fixed="left"
+        ></el-table-column>
+        <el-table-column
+          v-for="item in state.staticHeaderData"
+          :key="item.prop"
+          :prop="item.prop"
+          :label="item.label"
+          min-width="80"
+          fixed="left"
+        />
         <template v-if="state.checkList.includes('group')">
-          <el-table-column v-for="(item, index) in state.groupDataTitleData" :key="item.prop" :prop="item.prop"
-            :label="item.label" min-width="100">
-            <template #default="scope">{{ scope.row?.dynamicsData?.groupData?.[index] || '-' }}</template>
+          <el-table-column
+            v-for="(item, index) in state.groupDataTitleData"
+            :key="item.prop"
+            :prop="item.prop"
+            :label="item.label"
+            min-width="100"
+          >
+            <template #header>
+              <template v-if="item.label.length > 6">
+                <el-tooltip effect="dark" :content="item.label" placement="top">
+                  <span>{{ item.label }}</span>
+                </el-tooltip>
+              </template>
+              <template v-else>
+                {{ item.label }}
+              </template>
+            </template>
+            <template #default="scope">{{
+              scope.row?.dynamicsData?.groupData?.[index] || "-"
+            }}</template>
           </el-table-column>
         </template>
-
         <template v-if="state.checkList.includes('question')">
-          <el-table-column v-for="(item, index) in state.scoreDataTitleData" :key="item.prop" :prop="item.prop"
-            :label="item.label" min-width="100">
-            <template #default="scope">{{ scope.row?.dynamicsData?.scoreData?.[index] || '-' }}</template>
+          <el-table-column
+            v-for="(item, index) in state.scoreDataTitleData"
+            :key="item.prop"
+            :prop="item.prop"
+            :label="item.label"
+            min-width="100"
+          >
+            <template #default="scope">{{
+              scope.row?.dynamicsData?.scoreData?.[index] || "-"
+            }}</template>
           </el-table-column>
         </template>
       </el-table>
@@ -50,53 +118,58 @@
 </template>
 
 <script lang="ts" setup>
-import ReportModule from '@/components/ReportModule.vue'
+import ReportModule from "@/components/ReportModule.vue";
 import {
   studentTranscriptTitle,
   queryJointStudentStatistics,
   studentTranscript,
-} from '@/api/analysis'
-import { useAnalysisStore } from '@/store/analysis'
-import { Search } from '@element-plus/icons-vue'
-import { onMounted, reactive, watch } from 'vue'
+  studentTranscriptExcel,
+} from "@/api/analysis";
+import { useAnalysisStore } from "@/store/analysis";
+import { downloadExcel } from "@/utils/exportExcel";
+import { Search } from "@element-plus/icons-vue";
+import { nextTick, ref, onMounted, onUnmounted, reactive, watch } from "vue";
+import { throttle } from "lodash";
 
 interface TableColumn {
-  prop: string
-  label: string
-  display?: boolean
-  [key: string]: any
+  prop: string;
+  label: string;
+  display?: boolean;
+  [key: string]: any;
 }
 
 interface TableCount {
-  examStudentCount: number | string
-  normalCount: number | string
-  missExamCount: number | string
+  examStudentCount: number | string;
+  normalCount: number | string;
+  missExamCount: number | string;
 }
 
 interface PageInfo {
-  pageSize: number
-  pageNum: number
-  total: number
+  pageSize: number;
+  pageNum: number;
+  total: number;
 }
 
 interface State {
-  keyWord: string
-  checkList: string[]
-  tableData: any[]
-  staticHeaderData: TableColumn[]
-  groupDataTitleData: TableColumn[]
-  scoreDataTitleData: TableColumn[]
-  tableCount: TableCount
-  pageInfo: PageInfo,
-  tableLoading:Boolean,
-  loadingText:String
+  keyWord: string;
+  checkList: string[];
+  tableHeight: Number;
+  tableData: any[];
+  staticHeaderData: TableColumn[];
+  groupDataTitleData: TableColumn[];
+  scoreDataTitleData: TableColumn[];
+  tableCount: TableCount;
+  pageInfo: PageInfo;
+  tableLoading: Boolean;
+  loadingText: String;
 }
 
-const analysisStore = useAnalysisStore()
+const analysisStore = useAnalysisStore();
 
 const state = reactive<State>({
-  keyWord: '',
-  checkList: ['group'],
+  keyWord: "",
+  checkList: ["group"],
+  tableHeight: 500, //表格高度
   tableData: [],
   staticHeaderData: [],
   groupDataTitleData: [],
@@ -111,48 +184,54 @@ const state = reactive<State>({
     pageNum: 1,
     total: 0,
   },
-  tableLoading:true,
-  loadingText:'加载中……'
-})
-
+  tableLoading: true,
+  loadingText: "加载中……",
+});
+const tableRef = ref<any>(null);
+const reportModuleRef = ref<any>(null);
 /** 获取表头 */
-const getStudentTranscriptTitle = async () => {
-  if (!analysisStore.filterObject) return
+const GetStudentTranscriptTitle = async () => {
+  if (!analysisStore.filterObject) return;
   try {
     const res = await studentTranscriptTitle({
       ...analysisStore.filterObject,
       topicControl: 0,
-    })
+    });
     if (res.code === 200) {
-      state.staticHeaderData = res.data?.title?.staticHeaderData || []
-      state.groupDataTitleData = res.data?.title?.dynamicsHeaderData?.groupDataTitle || []
-      state.scoreDataTitleData = res.data?.title?.dynamicsHeaderData?.scoreDataTitle || []
+      const staticHeaderData = res.data?.title?.staticHeaderData || [];
+      state.staticHeaderData = staticHeaderData.filter(
+        (item: { display: any }) => item.display,
+      );
+      state.groupDataTitleData =
+        res.data?.title?.dynamicsHeaderData?.groupDataTitle || [];
+      state.scoreDataTitleData =
+        res.data?.title?.dynamicsHeaderData?.scoreDataTitle || [];
     }
   } catch (err) {
-    console.error('获取表头失败:', err)
+    console.error("获取表头失败:", err);
   }
-}
+};
 
 /** 获取顶部统计 */
-const getTableCount = async () => {
-  if (!analysisStore.filterObject) return
+const GetTableCount = async () => {
+  if (!analysisStore.filterObject) return;
   try {
     const res = await queryJointStudentStatistics({
       ...analysisStore.filterObject,
       topicControl: 0,
       queryStr: state.keyWord,
-    })
+    });
     if (res.code === 200) {
-      state.tableCount = res.data || {}
+      state.tableCount = res.data || {};
     }
   } catch (err) {
-    console.error('获取统计数据失败:', err)
+    console.error("获取统计数据失败:", err);
   }
-}
+};
 
 /** 获取表格数据 */
-const getTableData = async () => {
-  if (!analysisStore.filterObject) return
+const GetTableData = async () => {
+  if (!analysisStore.filterObject) return;
   try {
     state.tableLoading = true;
     const res = await studentTranscript({
@@ -163,57 +242,141 @@ const getTableData = async () => {
         pageNum: state.pageInfo.pageNum,
         pageSize: state.pageInfo.pageSize,
       },
-    })
+    });
     if (res.code === 200) {
-      state.tableData = res.data?.listData || []
-      state.pageInfo.total = Number(res.data?.total || 0)
+      state.tableData = res.data?.listData || [];
+      state.pageInfo.total = Number(res.data?.total || 0);
     }
-    state.tableLoading = false;
   } catch (err) {
-    console.error('获取表格数据失败:', err)
+    console.error("获取表格数据失败:", err);
+  } finally {
+    state.tableLoading = false;
+    // 数据加载完成后重置滚动
+    nextTick(() => TableScrollTop());
   }
-}
+};
 //获取序号
 const GetIndexNumber = (index: number) => {
-  let indexCount = (state.pageInfo.pageNum - 1) * state.pageInfo.pageSize + index + 1;
-  return indexCount
-}
-// 分页 & 搜索
-const handleCurrentChange = (val: number) => {
-  state.pageInfo.pageNum = val
-  getTableData()
-}
-
-const handleSizeChange = (val: number) => {
-  state.pageInfo.pageSize = val
-  state.pageInfo.pageNum = 1
-  getTableData()
-}
+  let indexCount =
+    (state.pageInfo.pageNum - 1) * state.pageInfo.pageSize + index + 1;
+  return indexCount;
+};
+// 当前页码事件
+const ChangeCurrentPage = (val: number) => {
+  state.pageInfo.pageNum = val;
+  GetTableData();
+};
+// 每页显示个数事件
+const ChangePageSize = (val: number) => {
+  state.pageInfo.pageSize = val;
+  state.pageInfo.pageNum = 1;
+  GetTableData();
+};
+// 导出Excel
+const ExportExcel = () => {
+  // 1. 设置加载状态
+  reportModuleRef.value?.SetExportLoading?.(true);
+  // 2. 参数
+  const params = {
+    ...analysisStore.filterObject,
+    topicControl: 0,
+    topicControlName: "成绩单",
+    queryStr: state.keyWord,
+    pageParam: {
+      pageNum: state.pageInfo.pageNum,
+      pageSize: state.pageInfo.pageSize,
+    },
+    staticHeaderData: state.staticHeaderData,
+    dynamicsHeaderDataMap: {},
+  };
+  if (state.checkList.includes("group")) {
+    params.dynamicsHeaderDataMap.groupDataTitle = state.groupDataTitleData;
+  }
+  if (state.checkList.includes("question")) {
+    params.dynamicsHeaderDataMap.scoreDataTitle = state.scoreDataTitleData;
+  }
+  console.log(state.checkList);
+  // 3. 调用通用下载方法,并在完成后重置加载状态
+  downloadExcel(studentTranscriptExcel, params).finally(() => {
+    reportModuleRef.value?.SetExportLoading?.(false);
+  });
+};
+// 搜索事件
+const HandleSearch = () => {
+  state.pageInfo.pageNum = 1;
+  GetTableData();
+};
+// 表格内容滚动置顶
+const TableScrollTop = () => {
+  const el = tableRef.value?.$el;
+  if (!el) return;
 
-const handleSearch = () => {
-  state.pageInfo.pageNum = 1
-  getTableData()
-}
+  // 在下一帧执行,确保 DOM 已更新
+  requestAnimationFrame(() => {
+    // 尝试多种选择器,兼容不同版本
+    const scrollWrap =
+      el.querySelector(".el-scrollbar__wrap") ||
+      el.querySelector(".el-table__body-wrapper") ||
+      el.querySelector(".el-table__fixed-body-wrapper");
 
+    if (scrollWrap) {
+      scrollWrap.scrollTop = 0;
+      scrollWrap.scrollLeft = 0;
+    } else {
+      // 若仍未找到,延迟再试一次(表格可能尚未完全渲染)
+      setTimeout(() => {
+        const retry =
+          el.querySelector(".el-scrollbar__wrap") ||
+          el.querySelector(".el-table__body-wrapper");
+        if (retry) {
+          retry.scrollTop = 0;
+          retry.scrollLeft = 0;
+        }
+      }, 200);
+    }
+  });
+};
+// 设置表格高度
+const SetTableHeight = () => {
+  const container = document.getElementsByClassName("report_content")?.[0];
+  if (container) {
+    state.tableHeight = container.clientHeight - 152;
+  }
+};
+// 窗口大小改变事件
+const HandleResize = throttle(() => {
+  SetTableHeight();
+}, 200);
 // 初始化
-const pageInit = () => {
-  getStudentTranscriptTitle()
-  getTableCount()
-  getTableData()
-}
+const PageInit = () => {
+  state.pageInfo.pageNum = 1;
+  GetStudentTranscriptTitle();
+  GetTableCount();
+  GetTableData();
+};
 
 // 监听筛选条件
 watch(
   () => analysisStore.filterObject,
   async () => {
-    pageInit()
+    PageInit();
   },
-  { deep: true }
-)
+  { deep: true },
+);
 
 onMounted(() => {
-  pageInit()
-})
+  window.addEventListener("resize", HandleResize);
+  PageInit();
+  nextTick(() => {
+    setTimeout(() => {
+      SetTableHeight();
+    }, 500);
+  });
+});
+onUnmounted(() => {
+  // 移除监听 防止内存泄漏
+  window.removeEventListener("resize", HandleResize);
+});
 </script>
 
 <style lang="scss" scoped>
@@ -242,4 +405,4 @@ onMounted(() => {
     }
   }
 }
-</style>
+</style>

+ 2 - 2
src/views/exam/abnormal/tableList.vue

@@ -126,7 +126,7 @@
                     
                     :page-sizes="[10,50,100]"
                     layout="sizes,prev, pager, next"
-                    @size-change="HandleSizeChange"
+                    @size-change="ChangePageSize"
                     :current-page="pageInfo.pageNum"
                     :total="pageInfo.total">
                 </el-pagination>
@@ -617,7 +617,7 @@ const ChangePage=(pageNum:number)=>{
 
 
 //设置每页显示数量
-const HandleSizeChange=(size:number)=>{
+const ChangePageSize=(size:number)=>{
     pageInfo.value.pageSize=size;
 }
 //序号自定义方法

+ 2 - 2
vite.config.ts

@@ -16,8 +16,8 @@ export default defineConfig({
     proxy:{
      
       '/api':{
-        target:'https://dev3.k12100.net/teaching/api/',//测试环境
-        // target:'http://192.168.1.48:47001/api/',//测试环境
+        // target:'https://dev3.k12100.net/teaching/api/',//测试环境
+        target:'http://192.168.1.48:47001/api/',//测试环境
         // target:'https://www.k12100.com/teaching/api/',//正式环境
         changeOrigin:true,
         rewrite:path => path.replace(/^\/api/, '')