Explorar o código

错题分析、成绩单打印修改

liurongli hai 3 semanas
pai
achega
7703653343

+ 64 - 50
src/components/ErrorsTablePdf.vue

@@ -181,31 +181,39 @@ const pageTitle = ref<HTMLElement | null>(null); // 标题测量块
 const printPageHeight = 1285; // 打印页固定高度(含上下内边距)
 const lineHeight = 23; // 单元格单行行高(与样式 .cell line-height:23px 一致)
 const rowBorder = 1; // 行下边框
-// 学号名单列每行容纳的条目数:行高 = 行数 × 23 + 1,行数 = ceil(条目数 / 每行条目数)。
-// 初始值 3.0,首次渲染后按浏览器实测行高自动校准(实测约 2.9)
+// 学号名单列每行容纳的条目数:仅用于首轮估算行数(行数 = ceil(条目数 / 每行条目数)),
+// 首轮渲染后会按每行真实渲染高度重新分页,因此该值无需精确
 let codeItemsPerLine = 3.0;
 const headerHeight = 24; // 表头高度(23px 行高 + 1px 边框;td padding 已在样式清零,实测 24px)
 const miniHeaderHeight = 30; // 非首页页头固定高度(第几页总页数 + 打印时间)
 
+// 实测行高缓存:key = 学号名单列文本,value = 该行渲染后的真实高度。
+// 同一名单文本的换行行数只取决于列宽与内容,与所在页面无关,故以文本为 key。
+// 首轮用估算高度分页并渲染,随后采集真实行高重新分页,保证每页排满;
+// 行高实测对锚点行(带 rowspan)是安全的:浏览器实测锚点行高度 = 自身内容高度
+const measuredRowHeights = new Map<string, number>();
+
 const LoadData = () => {
   nextTick(() => {
     TablePagesData();
   });
 };
 
-// 学号名单列文本行数估算:浏览器按顿号等边界分词换行,
-// 初始按每行 3.0 个条目估算,首次渲染后依据真实行高自动校准 codeItemsPerLine,
-// 使估算行数与真实换行完全一致
+// 学号名单列文本行数估算(仅首轮用):浏览器按顿号等边界分词换行,
+// 首轮按每行 3.0 个条目估算,渲染后以实测行高重新分页,估算不准会被实测修正
 const getCodeLines = (code: string): number => {
   const items = String(code).split("、").filter(Boolean).length;
   return Math.max(1, Math.ceil(items / codeItemsPerLine));
 };
 
-// 行高:单行 = 23px 行高 + 1px 边框 = 24px;多行 = 行数 × 23 + 1。
-// 与浏览器实测完全一致(td 的 padding 已在样式清零,见 .el-table__cell)
+// 行高:优先使用实测高度(精确,首轮渲染后采集);未采集到时按行数估算
+// (单行 = 23px 行高 + 1px 边框 = 24px,多行 = 行数 × 23 + 1)。
+// 同一名单文本在任何页面换行行数一致,故以文本为 key 缓存实测高度
 const getRowHeight = (row: TableItem): number => {
   const key =
     row.registrationCodeList != null ? String(row.registrationCodeList) : "";
+  const measured = measuredRowHeights.get(key);
+  if (measured) return measured;
   let lines = 1;
   if (key && key !== "-" && key !== "") {
     lines = getCodeLines(key);
@@ -218,9 +226,11 @@ const getRowHeight = (row: TableItem): number => {
 // 其余页包含固定 30px 页头(第几页总页数 + 打印时间),无表头;
 // 单行高度放不下时按名单内容行内拆分,前部分留在当前页、剩余内容换页续排,
 // 尽量填满每页、减少底部空白;
-// 分页后依据真实渲染行高校准"每行条目数"并校正内容预算,直到页面不再溢出,
-// 避免估算偏差导致页面内容溢出或大面积留白
+// 分页后采集每行真实渲染高度重新分页,并按精确溢出像素校正内容预算,
+// 直到页面不再溢出且分页稳定,避免估算偏差导致页面内容溢出或大面积留白
 const TablePagesData = () => {
+  // 数据更新后清空旧行高缓存,重新采集
+  measuredRowHeights.clear();
   const data = props.tableData as TableItem[];
   // 第 1 页页头实测高度(标题可换行,高度动态获取)
   const titleBlockHeight = pageTitle.value?.offsetHeight || 30;
@@ -443,51 +453,33 @@ const TablePagesData = () => {
   totalPageNum.value = pages.length;
 
   // 后续轮次:渲染后按实际渲染情况修正分页,直到收敛(最多 8 轮):
-  // 1) 采集每行真实高度反推"学号名单列每行条目数"(校准 codeItemsPerLine),
-  //    保证行高估算与真实渲染一致 → 页面能排满;
+  // 1) 采集每行真实渲染高度(key = 学号名单列文本),用实测高度重新分页,
+  //    保证每页排满——估算按"每行条目数"换算行数,与实际换行存在偏差
+  //    (名单长短不同、条目宽度不同),实测是最准确的校正依据;
   // 2) 仍有页面溢出时,按精确溢出像素安全缩减预算(每轮最多 40px、下限 60%),
   //    防止出现"每页只有一行 + 大面积留白"的坍缩
-  const correctPages = (attempt: number) => {
+  const correctPages = (attempt: number, lastCacheKey: string) => {
     // 等 el-table 完成内部布局后再测量
     setTimeout(() => {
       nextTick(() => {
         const els = rootRef.value?.querySelectorAll(".print_page");
         if (!els || !els.length) return;
 
-        // 1) 校准学号名单列每行条目数:行高 h → 显示行数 lines = (h-1)/23,
-        //    加权平均 items/lines 得到真实每行条目数
-        let totalItems = 0;
-        let totalLines = 0;
+        // 1) 采集每行真实高度:行高只与学号名单列的换行有关,以该列文本为 key。
+        //    锚点行(带 rowspan)实测高度 = 自身内容高度,不会被合并组撑大,
+        //    可安全用于分页
+        let cacheKey = "";
         els.forEach((el) => {
           el.querySelectorAll("tbody tr").forEach((tr) => {
             const lastCell = tr.querySelector("td:last-child .cell");
-            const text = lastCell?.textContent?.trim() ?? "";
-            const items = String(text).split("、").filter(Boolean).length;
-            if (!items) return;
-            const h = tr.getBoundingClientRect().height;
-            const lines = Math.max(1, Math.round((h - rowBorder) / lineHeight));
-            totalItems += items;
-            totalLines += lines;
+            const key = lastCell?.textContent?.trim() ?? "";
+            const h = Math.round(tr.getBoundingClientRect().height);
+            if (h > 0) {
+              measuredRowHeights.set(key, h);
+              cacheKey += `${key}=${h};`;
+            }
           });
         });
-        if (totalLines > 0) {
-          const measured = totalItems / totalLines;
-          if (
-            Math.abs(measured - codeItemsPerLine) > codeItemsPerLine * 0.05 &&
-            attempt < 8
-          ) {
-            codeItemsPerLine = Math.round(measured * 10) / 10;
-            console.log(
-              "[CALIBRATE]",
-              JSON.stringify({ attempt, itemsPerLine: codeItemsPerLine }),
-            );
-            const p = buildPages(contentBudget);
-            tablePageData.value = p;
-            totalPageNum.value = p.length;
-            correctPages(attempt + 1);
-            return;
-          }
-        }
 
         // 2) 检查每页表格内容是否超出内容预算
         let maxOverflow = 0;
@@ -527,13 +519,23 @@ const TablePagesData = () => {
           const p = buildPages(contentBudget);
           tablePageData.value = p;
           totalPageNum.value = p.length;
-          correctPages(attempt + 1);
+          correctPages(attempt + 1, cacheKey);
+          return;
+        }
+
+        // 4) 无溢出但实测行高有更新(首次采集或拆分片段变化):用真实行高重新分页,
+        //    把每页排满;同一文本实测高度稳定,重排一次即收敛
+        if (cacheKey !== lastCacheKey && attempt < 8) {
+          const p = buildPages(contentBudget);
+          tablePageData.value = p;
+          totalPageNum.value = p.length;
+          correctPages(attempt + 1, cacheKey);
           return;
         }
       });
     }, 0);
   };
-  correctPages(1);
+  correctPages(1, "");
 };
 
 // 下载 PDF (使用 dom-to-image)
@@ -573,13 +575,24 @@ const DownloadPdf = async () => {
         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",
+          // transform: "scale(2)",
+          // "transform-origin": "top left",
+          // "background-color": "#ffffff",
+          // overflow: "visible",
+          // "white-space": "normal",
+          // position: "relative",
+          // visibility: "visible",
+          'transform': 'scale(2)',
+          'transform-origin': 'top left',
+          'background-color': '#ffffff',
+          // 关键:背景图尺寸适配原元素
+          'background-size': `${htmlElement.scrollWidth}px ${htmlElement.scrollHeight}px`, // 优先铺满且完整显示
+          'background-position': 'top left',
+          'background-repeat': 'no-repeat', // 防止背景图重复导致视觉不完整
+          'overflow': 'visible',
+          'white-space': 'normal',
+          'position': 'relative',
+          'visibility': 'visible'
         },
         bgcolor: "#ffffff",
       });
@@ -780,6 +793,7 @@ defineExpose({
       flex-shrink: 0;
       overflow: hidden;
       white-space: nowrap;
+      justify-content: space-between;
     }
 
     .module_table {

+ 10 - 7
src/components/ScorePdf.vue

@@ -109,7 +109,7 @@ const GetStudentTranscriptTitle = async () => {
     if (res.code === 200) {
       const staticHeaderData = res.data?.title?.staticHeaderData || [];
       state.staticHeaderData = staticHeaderData.filter(
-        (item: { display: any }) => item.display,
+        (item: { display: any, prop: string }) => item.display && item.prop !== 'studentUserCode' && item.prop !== 'studentRegistrationTypeName',//移除学号和类型
       );
       state.groupDataTitleData =
         res.data?.title?.dynamicsHeaderData?.groupDataTitle || [];
@@ -145,7 +145,7 @@ const GetTableData = async () => {
 //分页:超过一页自动换页,后续页不包含标题行和应考/实考/缺考统计行
 const tablePageData = (list: any[]) => {
   const trLineHeight = 21;//表格表头、tr行高
-  const printPageHeight = 1260;//打印页面高度 减掉padding 24px
+  const printPageHeight = 1255;//打印页面高度 减掉padding 30px
 
   const rowNum = Math.floor(printPageHeight / trLineHeight);//每页可容纳行数 向下取整
   const firstPageRows = rowNum - 3;//第一页数据行数:标题行+统计行+表头行占3行
@@ -242,7 +242,8 @@ const ExportPdf = async () => {
     const adjustWidth = imgWidth * ratio;
     const adjustHeight = imgHeight * ratio;
     const xPosition = (pdfWidth - adjustWidth) / 2;
-    const yPosition = (pdfHeight - adjustHeight) / 2;
+    // 顶对齐:页面顶部与PDF页面上边缘对齐,避免标题被垂直居中往下推
+    const yPosition = 0;
 
     if (pageIndex > 0) {
       pdf.addPage();
@@ -335,8 +336,8 @@ defineExpose({
 
   .area_page {
     width: 908px;
-    margin-top: 20px;
-    padding: 12px 5px 0 !important;
+    margin-top:20px;
+    padding: 8px 15px 0 !important;
     background-color: #fff;
     box-sizing: border-box;
     overflow: hidden;
@@ -347,6 +348,8 @@ defineExpose({
 
     .area_title {
       width: 100%;
+      padding: 0 5px;
+      box-sizing: border-box;
       height: 21px;
       line-height: 21px;
       display: flex;
@@ -394,8 +397,8 @@ defineExpose({
 
       :deep(.el-table) {
         border-radius: 6px;
-        border-left: 0px;
-        border-right: 0px;
+        // border-left: 0px;
+        // border-right: 0px;
 
         .el-table__header {
           height: 20px !important;

+ 6 - 6
src/views/analysis/errorAnalysis.vue

@@ -127,28 +127,28 @@
       </el-table>
     </template>
   </ReportModule>
-  <!-- <ErrorsPdf
+  <ErrorsPdf
     ref="errPdfReport"
     :tableData="state.tableData"
     :className="analysisStore.filterObject.classGroupName"
     :subjectName="analysisStore.filterObject.subjectName || ''"
     :examName="getExamName"
     @PdfLoadEnd="PdfLoadEnd"
-  /> -->
-  <ErrorsTablePdf
+  />
+  <!-- <ErrorsTablePdf
     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 ErrorsPdf from "@/components/ErrorsPdf.vue";
-import ErrorsTablePdf from "@/components/ErrorsTablePdf.vue";
+import ErrorsPdf from "@/components/ErrorsPdf.vue";
+// import ErrorsTablePdf from "@/components/ErrorsTablePdf.vue";
 import { onMounted, reactive, computed, ref, watch } from "vue";
 import { errorQuestionAnalysis, exportErrorQuestion } from "@/api/analysis";
 import { loadingSvgIcon } from "@/utils/common";