|
|
@@ -1,28 +1,44 @@
|
|
|
<template>
|
|
|
- <div class="report_module error">
|
|
|
+ <div class="report_module error" ref="rootRef">
|
|
|
+ <!-- 标题高度测量块(不参与导出) -->
|
|
|
<div class="area_page">
|
|
|
- <div class="area_title">
|
|
|
- <div class="page_num">第1页(共10页)</div>
|
|
|
+ <div class="area_title" ref="pageTitle">
|
|
|
+ <div class="page_num">第1页(共{{ totalPageNum }}页)</div>
|
|
|
<div class="title_text">
|
|
|
【{{ `${examName}错题名单(${className})` }}】
|
|
|
</div>
|
|
|
<div class="print_time">打印时间:{{ getNowTime() }}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div class="area_page print_page">
|
|
|
- <div class="area_title">
|
|
|
- <div class="page_num">第1页(共10页)</div>
|
|
|
+ <!-- 分页打印块:第 1 页含标题 + 页码 + 打印时间(页头高度动态获取),
|
|
|
+ 其余页页头仅显示页码 + 打印时间(固定高度 30px),表格数据分页续排 -->
|
|
|
+ <div
|
|
|
+ v-for="(pageData, pIndex) in tablePageData"
|
|
|
+ :key="pIndex"
|
|
|
+ class="area_page print_page"
|
|
|
+ >
|
|
|
+ <div class="area_title" v-if="pIndex === 0">
|
|
|
+ <div class="page_num">
|
|
|
+ 第{{ pIndex + 1 }}页(共{{ totalPageNum }}页)
|
|
|
+ </div>
|
|
|
<div class="title_text">
|
|
|
【{{ `${examName}错题名单(${className})` }}】
|
|
|
</div>
|
|
|
<div class="print_time">打印时间:{{ getNowTime() }}</div>
|
|
|
</div>
|
|
|
+ <div class="area_title mini_area_title" v-else>
|
|
|
+ <div class="page_num">
|
|
|
+ 第{{ pIndex + 1 }}页(共{{ totalPageNum }}页)
|
|
|
+ </div>
|
|
|
+ <div class="print_time">打印时间:{{ getNowTime() }}</div>
|
|
|
+ </div>
|
|
|
<div class="module_table table_42">
|
|
|
<el-table
|
|
|
- :data="tablePageData"
|
|
|
+ :data="pageData"
|
|
|
border
|
|
|
stripe
|
|
|
row-key="questionId"
|
|
|
+ :show-header="pIndex === 0"
|
|
|
:span-method="SpanMethod"
|
|
|
:row-class-name="ErrorTableRowClassName"
|
|
|
:cell-class-name="ErrorTableCellClassName"
|
|
|
@@ -30,7 +46,7 @@
|
|
|
<el-table-column
|
|
|
prop="questionName"
|
|
|
label="小题名称"
|
|
|
- width="60"
|
|
|
+ width="59"
|
|
|
align="center"
|
|
|
/>
|
|
|
<el-table-column
|
|
|
@@ -91,7 +107,7 @@
|
|
|
:key="key"
|
|
|
label=""
|
|
|
:prop="key"
|
|
|
- :min-width="[28, 50, 30, 320][index]"
|
|
|
+ :width="[28, 50, 30, 360][index]"
|
|
|
>
|
|
|
<template #default="{ row: org }">
|
|
|
<span v-if="key == 'registrationCodeList'">{{ org[key] }}</span>
|
|
|
@@ -131,6 +147,8 @@ interface TableItem {
|
|
|
registrationCodeList?: string;
|
|
|
hiddenRatio?: boolean;
|
|
|
hiddenStu?: boolean;
|
|
|
+ rowspan?: number;
|
|
|
+ colspan?: number;
|
|
|
}
|
|
|
|
|
|
// 定义 Props
|
|
|
@@ -153,28 +171,388 @@ const props = defineProps({
|
|
|
},
|
|
|
});
|
|
|
const tablePageData = ref<TableItem[][]>([]);
|
|
|
+const totalPageNum = ref(0);
|
|
|
// 定义 Emits
|
|
|
const emit = defineEmits(["PdfLoadEnd"]);
|
|
|
|
|
|
// Refs & 常量
|
|
|
-const pageTitle = ref<HTMLElement | null>(null);
|
|
|
-const titleHeight = ref(80);
|
|
|
-const questionHeight = 30;
|
|
|
-const lineHeight = 16;
|
|
|
-const printPageHeight = 1205;
|
|
|
+const rootRef = ref<HTMLElement | null>(null); // 组件根节点
|
|
|
+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)
|
|
|
+let codeItemsPerLine = 3.0;
|
|
|
+const headerHeight = 24; // 表头高度(23px 行高 + 1px 边框;td padding 已在样式清零,实测 24px)
|
|
|
+const miniHeaderHeight = 30; // 非首页页头固定高度(第几页总页数 + 打印时间)
|
|
|
+
|
|
|
+const LoadData = () => {
|
|
|
+ nextTick(() => {
|
|
|
+ TablePagesData();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 学号名单列文本行数估算:浏览器按顿号等边界分词换行,
|
|
|
+// 初始按每行 3.0 个条目估算,首次渲染后依据真实行高自动校准 codeItemsPerLine,
|
|
|
+// 使估算行数与真实换行完全一致
|
|
|
+const getCodeLines = (code: string): number => {
|
|
|
+ const items = String(code).split("、").filter(Boolean).length;
|
|
|
+ return Math.max(1, Math.ceil(items / codeItemsPerLine));
|
|
|
+};
|
|
|
|
|
|
-// 表格分页逻辑
|
|
|
-const TablePagesData = () => {};
|
|
|
+// 行高:单行 = 23px 行高 + 1px 边框 = 24px;多行 = 行数 × 23 + 1。
|
|
|
+// 与浏览器实测完全一致(td 的 padding 已在样式清零,见 .el-table__cell)
|
|
|
+const getRowHeight = (row: TableItem): number => {
|
|
|
+ const key =
|
|
|
+ row.registrationCodeList != null ? String(row.registrationCodeList) : "";
|
|
|
+ let lines = 1;
|
|
|
+ if (key && key !== "-" && key !== "") {
|
|
|
+ lines = getCodeLines(key);
|
|
|
+ }
|
|
|
+ return lines * lineHeight + rowBorder;
|
|
|
+};
|
|
|
+
|
|
|
+// 表格分页逻辑:按题组(rowKey == 0 起始)分页,保证合并单元格连续;
|
|
|
+// 第 1 页包含动态高度页头(标题 + 第几页总页数 + 打印时间)+ 表头;
|
|
|
+// 其余页包含固定 30px 页头(第几页总页数 + 打印时间),无表头;
|
|
|
+// 单行高度放不下时按名单内容行内拆分,前部分留在当前页、剩余内容换页续排,
|
|
|
+// 尽量填满每页、减少底部空白;
|
|
|
+// 分页后依据真实渲染行高校准"每行条目数"并校正内容预算,直到页面不再溢出,
|
|
|
+// 避免估算偏差导致页面内容溢出或大面积留白
|
|
|
+const TablePagesData = () => {
|
|
|
+ const data = props.tableData as TableItem[];
|
|
|
+ // 第 1 页页头实测高度(标题可换行,高度动态获取)
|
|
|
+ const titleBlockHeight = pageTitle.value?.offsetHeight || 30;
|
|
|
+ // 打印页内容区高度:总高 - 上下内边距(校正过程中按需缩小)
|
|
|
+ const baseContentHeight = printPageHeight - 20;
|
|
|
+ let contentBudget = baseContentHeight;
|
|
|
+
|
|
|
+ const buildPages = (contentHeight: number): TableItem[][] => {
|
|
|
+ const pages: TableItem[][] = [];
|
|
|
+ if (!data.length) return pages;
|
|
|
+
|
|
|
+ // 第 1 页预算 = 内容高度 - 动态页头 - 表头;后续页预算 = 内容高度 - 固定页头(30px)
|
|
|
+ const page1Budget = contentHeight - titleBlockHeight - headerHeight;
|
|
|
+ const otherPageBudget = contentHeight - miniHeaderHeight;
|
|
|
+
|
|
|
+ // 每组(题)起始行下标
|
|
|
+ const groupStarts: number[] = [];
|
|
|
+ data.forEach((row, idx) => {
|
|
|
+ if (Number(row.rowKey) === 0) groupStarts.push(idx);
|
|
|
+ });
|
|
|
+ if (!groupStarts.length || groupStarts[0] !== 0) groupStarts.unshift(0);
|
|
|
+ groupStarts.push(data.length);
|
|
|
+
|
|
|
+ let current: TableItem[] = [];
|
|
|
+ let currentHeight = 0;
|
|
|
+ let firstPage = true;
|
|
|
+
|
|
|
+ const getBudget = () => (firstPage ? page1Budget : otherPageBudget);
|
|
|
+
|
|
|
+ const pushPage = () => {
|
|
|
+ if (current.length) {
|
|
|
+ pages.push(current);
|
|
|
+ current = [];
|
|
|
+ currentHeight = 0;
|
|
|
+ firstPage = false;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 放入页面的行一律克隆,避免改动原数据
|
|
|
+ const cloneRows = (rows: TableItem[]): TableItem[] =>
|
|
|
+ rows.map((r) => ({ ...r }));
|
|
|
+
|
|
|
+ // 行内拆分:整行放不下时,把学号名单内容按条目边界切分为
|
|
|
+ // [当前页部分, 续排部分];前部分填满当前页剩余空白,剩余部分在下一页继续。
|
|
|
+ // 只按条目边界切分,不截断人名/学号;保证前部分一定放得进剩余高度
|
|
|
+ const splitRow = (
|
|
|
+ row: TableItem,
|
|
|
+ availableHeight: number,
|
|
|
+ ): TableItem[] | null => {
|
|
|
+ const code = row.registrationCodeList;
|
|
|
+ if (!code || code === "-" || code === "") return null;
|
|
|
+ // 整行放得下则无需拆分;剩余高度不足一行时也无法拆分
|
|
|
+ if (getRowHeight(row) <= availableHeight) return null;
|
|
|
+ if (availableHeight < lineHeight + rowBorder) return null;
|
|
|
+ const items = String(code).split("、").filter(Boolean);
|
|
|
+ if (items.length < 2) return null;
|
|
|
+ // 剩余高度可容纳的完整显示行数
|
|
|
+ const fitLines = Math.floor((availableHeight - 1) / lineHeight);
|
|
|
+ if (fitLines < 1) return null;
|
|
|
+ // 每行约 codeItemsPerLine 个条目,且至少给续排部分保留 1 个条目
|
|
|
+ const fitItems = Math.min(
|
|
|
+ items.length - 1,
|
|
|
+ Math.floor(fitLines * codeItemsPerLine),
|
|
|
+ );
|
|
|
+ if (fitItems < 1) return null;
|
|
|
+ let cut = 0;
|
|
|
+ for (let i = 0; i < fitItems; i++) {
|
|
|
+ cut += items[i].length + 1; // + 顿号
|
|
|
+ }
|
|
|
+ const rowA = {
|
|
|
+ ...row,
|
|
|
+ registrationCodeList: String(code).slice(0, cut),
|
|
|
+ rowspan: 1,
|
|
|
+ colspan: 1,
|
|
|
+ };
|
|
|
+ const rowB = {
|
|
|
+ ...row,
|
|
|
+ registrationCodeList: String(code).slice(cut),
|
|
|
+ rowspan: 1,
|
|
|
+ colspan: 1,
|
|
|
+ };
|
|
|
+ // rowA 行数 = ceil(fitItems/codeItemsPerLine) <= fitLines,其高度必 <= availableHeight
|
|
|
+ return [rowA, rowB];
|
|
|
+ };
|
|
|
+
|
|
|
+ for (let g = 0; g < groupStarts.length - 1; g++) {
|
|
|
+ const gStart = groupStarts[g];
|
|
|
+ const gEnd = groupStarts[g + 1];
|
|
|
+ let cursor = gStart;
|
|
|
+ // 行内拆分后待续的行(仍是 data[cursor] 的内容,未推进 cursor)
|
|
|
+ let pending: TableItem | null = null;
|
|
|
+
|
|
|
+ while (cursor < gEnd) {
|
|
|
+ const row = pending ?? data[cursor];
|
|
|
+ const remain = getBudget() - currentHeight;
|
|
|
+ const rowHeight = getRowHeight(row);
|
|
|
+
|
|
|
+ // A. 整组剩余部分可直接放下(仅非 pending 时检查)
|
|
|
+ if (!pending) {
|
|
|
+ let restHeight = 0;
|
|
|
+ for (let j = cursor; j < gEnd; j++)
|
|
|
+ restHeight += getRowHeight(data[j]);
|
|
|
+ if (restHeight <= remain) {
|
|
|
+ const rows = cloneRows(data.slice(cursor, gEnd));
|
|
|
+ current.push(...rows);
|
|
|
+ rows.forEach((r) => {
|
|
|
+ currentHeight += getRowHeight(r);
|
|
|
+ });
|
|
|
+ cursor = gEnd;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // B. 行能整行放入当前页(尽量多放几行填满)
|
|
|
+ if (remain >= rowHeight) {
|
|
|
+ if (pending) {
|
|
|
+ current.push(pending);
|
|
|
+ currentHeight += rowHeight;
|
|
|
+ pending = null;
|
|
|
+ cursor++;
|
|
|
+ // 放完待续行后不立即换页,继续用剩余空间排后续行,避免出现只有
|
|
|
+ // 单个剩余片段的半页
|
|
|
+ continue;
|
|
|
+ } else {
|
|
|
+ let count = 1;
|
|
|
+ let used = rowHeight;
|
|
|
+ while (
|
|
|
+ cursor + count < gEnd &&
|
|
|
+ used + getRowHeight(data[cursor + count]) <= remain
|
|
|
+ ) {
|
|
|
+ used += getRowHeight(data[cursor + count]);
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ const rows = cloneRows(data.slice(cursor, cursor + count));
|
|
|
+ current.push(...rows);
|
|
|
+ currentHeight += used;
|
|
|
+ cursor += count;
|
|
|
+ }
|
|
|
+ // 放行后不立即换页:剩余空间不足整行时,由下方分支 C 拆分当前行填满
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // C. 剩余空间不足一行:行内拆分填满当前页,剩余内容下页继续
|
|
|
+ if (pending) {
|
|
|
+ // 拆分剩余片段不可再次拆分(否则会产生过小碎片、页数爆炸):
|
|
|
+ // 直接换页,由下一页完整续排
|
|
|
+ if (current.length === 0) {
|
|
|
+ // 空页仍放不下(超长行片段):再次按整页预算拆分续排,
|
|
|
+ // 避免强制放入导致页面内容溢出裁切;无法再拆时才强制放入
|
|
|
+ const sp2 = splitRow(pending, getBudget());
|
|
|
+ if (sp2) {
|
|
|
+ current.push(sp2[0]);
|
|
|
+ currentHeight += getRowHeight(sp2[0]);
|
|
|
+ pending = sp2[1];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const single = cloneRows([pending])[0];
|
|
|
+ current.push(single);
|
|
|
+ currentHeight += rowHeight;
|
|
|
+ pending = null;
|
|
|
+ cursor++;
|
|
|
+ if (cursor < gEnd) pushPage();
|
|
|
+ } else {
|
|
|
+ pushPage();
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const sp = splitRow(row, remain);
|
|
|
+ if (sp) {
|
|
|
+ current.push(sp[0]);
|
|
|
+ currentHeight += getRowHeight(sp[0]);
|
|
|
+ pending = sp[1];
|
|
|
+ // 拆分片段放入后不立即换页:继续用剩余空间排后续行/片段,填满后再换页
|
|
|
+ continue;
|
|
|
+ } else if (current.length === 0) {
|
|
|
+ // 空页仍放不下且无法拆分:强制放入单行(溢出由 overflow hidden 兜底),防止死循环
|
|
|
+ const single = cloneRows([row])[0];
|
|
|
+ current.push(single);
|
|
|
+ currentHeight += rowHeight;
|
|
|
+ if (pending) {
|
|
|
+ pending = null;
|
|
|
+ cursor++;
|
|
|
+ } else {
|
|
|
+ cursor++;
|
|
|
+ }
|
|
|
+ if (cursor < gEnd) pushPage();
|
|
|
+ } else {
|
|
|
+ // 当前页放不下 → 换页后重试
|
|
|
+ pushPage();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ pushPage();
|
|
|
+
|
|
|
+ // 统一修正每页合并单元格:
|
|
|
+ // 每页第一行必须作为合并锚点,保证前 8 列(小题名称~正确答案)在每一页都可见;
|
|
|
+ // 页内遇到新的组起点(rowKey==0)或拆分片段(rowspan)时重新开启一个锚点,
|
|
|
+ // 锚点 rowspan 覆盖到下一个锚点前的所有行,其余行被合并隐藏
|
|
|
+ pages.forEach((page) => {
|
|
|
+ if (!page.length) return;
|
|
|
+ let anchorIdx = 0;
|
|
|
+ for (let i = 1; i < page.length; i++) {
|
|
|
+ if (Number(page[i].rowKey) === 0 || page[i].rowspan) {
|
|
|
+ page[anchorIdx].rowspan = i - anchorIdx;
|
|
|
+ page[anchorIdx].colspan = 1;
|
|
|
+ anchorIdx = i;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ page[anchorIdx].rowspan = page.length - anchorIdx;
|
|
|
+ page[anchorIdx].colspan = 1;
|
|
|
+ });
|
|
|
+
|
|
|
+ return pages;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 第一轮:按估算高度分页并渲染
|
|
|
+ let pages = buildPages(contentBudget);
|
|
|
+ tablePageData.value = pages;
|
|
|
+ totalPageNum.value = pages.length;
|
|
|
+
|
|
|
+ // 后续轮次:渲染后按实际渲染情况修正分页,直到收敛(最多 8 轮):
|
|
|
+ // 1) 采集每行真实高度反推"学号名单列每行条目数"(校准 codeItemsPerLine),
|
|
|
+ // 保证行高估算与真实渲染一致 → 页面能排满;
|
|
|
+ // 2) 仍有页面溢出时,按精确溢出像素安全缩减预算(每轮最多 40px、下限 60%),
|
|
|
+ // 防止出现"每页只有一行 + 大面积留白"的坍缩
|
|
|
+ const correctPages = (attempt: number) => {
|
|
|
+ // 等 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;
|
|
|
+ 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;
|
|
|
+ });
|
|
|
+ });
|
|
|
+ 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;
|
|
|
+ els.forEach((el, i) => {
|
|
|
+ const body = el.querySelector(".el-table__body-wrapper");
|
|
|
+ const bodyH = body ? body.getBoundingClientRect().height : 0;
|
|
|
+ if (!bodyH) return;
|
|
|
+ // 实测页头高度:第 1 页为动态高度,其余页为固定 30px
|
|
|
+ const headerH =
|
|
|
+ el.querySelector(".area_title")?.getBoundingClientRect().height ??
|
|
|
+ 0;
|
|
|
+ // 第 1 页含表头,其余页无表头
|
|
|
+ const budget =
|
|
|
+ i === 0
|
|
|
+ ? contentBudget - headerH - headerHeight
|
|
|
+ : contentBudget - headerH;
|
|
|
+ if (bodyH > budget + 1) {
|
|
|
+ maxOverflow = Math.max(maxOverflow, bodyH - budget);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 3) 有溢出:按精确溢出像素缩减预算重新分页(每轮最多减 40px,
|
|
|
+ // 避免一次大幅缩减导致各页出现大面积底部留白;下限 60% 防坍缩)
|
|
|
+ if (maxOverflow > 1 && attempt < 8) {
|
|
|
+ const shrink = Math.min(maxOverflow, 40);
|
|
|
+ contentBudget -= Math.ceil(shrink);
|
|
|
+ const floor = Math.max(100, (printPageHeight - 20) * 0.6);
|
|
|
+ if (contentBudget < floor) contentBudget = floor;
|
|
|
+ console.log(
|
|
|
+ "[CORRECT]",
|
|
|
+ JSON.stringify({
|
|
|
+ attempt,
|
|
|
+ overflow: +maxOverflow.toFixed(1),
|
|
|
+ budget: +contentBudget.toFixed(1),
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ const p = buildPages(contentBudget);
|
|
|
+ tablePageData.value = p;
|
|
|
+ totalPageNum.value = p.length;
|
|
|
+ correctPages(attempt + 1);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }, 0);
|
|
|
+ };
|
|
|
+ correctPages(1);
|
|
|
+};
|
|
|
|
|
|
// 下载 PDF (使用 dom-to-image)
|
|
|
const DownloadPdf = async () => {
|
|
|
- const elements = document.querySelectorAll(".page_report .print_page");
|
|
|
+ const elements = rootRef.value?.querySelectorAll(".print_page") || [];
|
|
|
if (elements.length === 0) {
|
|
|
- ElMessage.warning("未找到可导出的页面元素!");
|
|
|
+ ElMessage.warning("未找到可导出的页面内容,请先加载数据!");
|
|
|
emit("PdfLoadEnd");
|
|
|
return;
|
|
|
}
|
|
|
-
|
|
|
+ // 在用户手势内同步打开空白窗口,避免被浏览器拦截
|
|
|
+ const win = window.open("", "_blank");
|
|
|
+ if (!win) {
|
|
|
+ ElMessage.warning("浏览器拦截了弹窗,请允许后重试");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ win.document.write(
|
|
|
+ "<p style='font-family:sans-serif;text-align:center;padding-top:40px'>错题名单 PDF 生成中…</p>",
|
|
|
+ );
|
|
|
const pdf = new jsPDF("p", "pt", "a4");
|
|
|
const pdfWidth = pdf.internal.pageSize.getWidth();
|
|
|
const pdfHeight = pdf.internal.pageSize.getHeight();
|
|
|
@@ -231,19 +609,62 @@ const DownloadPdf = async () => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- pdf.save(
|
|
|
- `${props.examName}_${props.subjectName}_${props.className}错题名单.pdf`,
|
|
|
+ // pdf.save(
|
|
|
+ // `${props.examName}_${props.subjectName}_${props.className}错题名单.pdf`,
|
|
|
+ // );
|
|
|
+ // // 直接打开生成的PDF,保留浏览器自带的下载/打印工具栏
|
|
|
+ // const filename = `${examName.value}成绩单.pdf`;
|
|
|
+ // // 通过 PDF 文档属性设置标题,浏览器查看器的标签页标题即显示该文件名
|
|
|
+ // pdf.setProperties({ title: filename });
|
|
|
+ // const blob = pdf.output("blob");
|
|
|
+ // const url = URL.createObjectURL(
|
|
|
+ // new File([blob], filename, { type: "application/pdf" }),
|
|
|
+ // );
|
|
|
+ // win.location.replace(url);
|
|
|
+ // 在浏览器新窗口打开生成的PDF(中转页方案:窗口标题、下载文件名均使用指定名称)
|
|
|
+ const filename = `${props.examName}_${props.subjectName}_${props.className}错题名单.pdf`;
|
|
|
+ const blob = pdf.output("blob");
|
|
|
+ const url = URL.createObjectURL(
|
|
|
+ new File([blob], filename, { type: "application/pdf" }),
|
|
|
+ );
|
|
|
+ // 转义文件名中的特殊字符,防止破坏 HTML
|
|
|
+ const safeName = filename
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">")
|
|
|
+ .replace(/"/g, """);
|
|
|
+ // 清空"生成中"占位页,写入中转页
|
|
|
+ win.document.open();
|
|
|
+ win.document.write(
|
|
|
+ `<!DOCTYPE html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+<meta charset="utf-8">
|
|
|
+<title>${safeName}</title>
|
|
|
+<style>
|
|
|
+ body{margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
|
|
|
+ .bar{height:48px;display:flex;align-items:center;gap:12px;padding:0 16px;background:#f5f6f7;border-bottom:1px solid #dcdfe6;box-sizing:border-box;}
|
|
|
+ .bar .fname{flex:1;font-size:14px;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
|
+ .bar .btn{color:#fff;background:#2e64fa;border:0;padding:6px 16px;border-radius:4px;font-size:13px;cursor:pointer;flex-shrink:0;text-decoration:none;display:inline-block;}
|
|
|
+ iframe{width:100%;height:calc(100vh - 48px);border:0;display:block;}
|
|
|
+ @media print{
|
|
|
+ .bar{display:none;}
|
|
|
+ iframe{height:100vh;}
|
|
|
+ }
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="bar">
|
|
|
+ <span class="fname">${safeName}</span>
|
|
|
+ <a class="btn" href="${url}" download="${safeName}">下载PDF</a>
|
|
|
+ </div>
|
|
|
+ <iframe src="${url}"></iframe>
|
|
|
+</body>
|
|
|
+</html>`,
|
|
|
);
|
|
|
+ win.document.close();
|
|
|
emit("PdfLoadEnd");
|
|
|
};
|
|
|
-const LoadData = () => {
|
|
|
- nextTick(() => {
|
|
|
- const pageTitleHeight = pageTitle.value?.offsetHeight || 60;
|
|
|
- titleHeight.value = pageTitleHeight + 20;
|
|
|
- tablePageData.value = props.tableData;
|
|
|
- TablePagesData();
|
|
|
- });
|
|
|
-};
|
|
|
// 错题分析 合并单元格
|
|
|
const SpanMethod = ({ row, column }: any) => {
|
|
|
const props = [
|
|
|
@@ -288,9 +709,12 @@ const ErrorTableCellClassName = ({ column }: any) => {
|
|
|
// 监听筛选条件
|
|
|
watch(
|
|
|
() => props.tableData,
|
|
|
- async () => {
|
|
|
+ () => {
|
|
|
if (props.tableData.length) {
|
|
|
LoadData();
|
|
|
+ } else {
|
|
|
+ tablePageData.value = [];
|
|
|
+ totalPageNum.value = 0;
|
|
|
}
|
|
|
},
|
|
|
{ deep: true },
|
|
|
@@ -306,7 +730,7 @@ defineExpose({
|
|
|
.error {
|
|
|
width: 908px;
|
|
|
margin: 0 auto;
|
|
|
- // position: absolute;
|
|
|
+ position: absolute;
|
|
|
top: 0;
|
|
|
left: -9999px;
|
|
|
z-index: -10;
|
|
|
@@ -350,6 +774,14 @@ defineExpose({
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // 非首页页头:仅页码 + 打印时间,固定高度 30px
|
|
|
+ .mini_area_title {
|
|
|
+ height: 30px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+
|
|
|
.module_table {
|
|
|
width: 100%;
|
|
|
box-sizing: border-box;
|
|
|
@@ -399,6 +831,10 @@ defineExpose({
|
|
|
|
|
|
.el-table__cell {
|
|
|
height: 23px !important;
|
|
|
+ // 关键:element-plus 默认给 td 保留上下各 8px 内边距(共 16px),
|
|
|
+ // 若不在此清零,真实行高 = 23+16+1 = 40px,与分页算法按 23+1=24px
|
|
|
+ // 估算不一致,导致页面溢出、预算坍缩、每页只剩 1 行
|
|
|
+ padding: 0 !important;
|
|
|
&.white_space_normal {
|
|
|
.cell {
|
|
|
white-space: normal;
|