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