feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
/* 多考试打印渲染回归测试
|
||||
*
|
||||
* 「多考试打印」的版式由 assets/app.js 的 sheetToHtml() 负责:把服务端装配好的
|
||||
* 二维表 + merges(1-based 闭区间)+ cell_styles(red=最高分红字 / green=合格绿底)
|
||||
* 翻成带 colspan / rowspan 的 HTML。
|
||||
* 本测试直接从 app.js 抽取该函数源码执行(不复制一份实现,避免两边漂移),
|
||||
* 用与 tests/test-multi-export.php 同一套版式校验渲染结果。
|
||||
*
|
||||
* 运行:node tools/test-block-print.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function chk(name, ok, extra) {
|
||||
if (ok) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||
}
|
||||
|
||||
/* ---------- 1) 从生产代码里抽取 sheetToHtml ---------- */
|
||||
const appPath = path.join(__dirname, '..', 'assets', 'app.js');
|
||||
const appSrc = fs.readFileSync(appPath, 'utf8');
|
||||
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
|
||||
chk('能从 assets/app.js 抽到 sheetToHtml', !!m);
|
||||
if (!m) {
|
||||
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||
process.exit(1);
|
||||
}
|
||||
const PRINT_CSS = (appSrc.match(/function printDoc\(title, bodyHtml, opts\)\s*\{[\s\S]*?\n \}/) || [''])[0];
|
||||
const esc = s => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
// eslint-disable-next-line no-new-func
|
||||
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
|
||||
|
||||
/* ---------- 2) 真实装配结果(截图版式:共享「第几次」列 + 合格次数行) ----------
|
||||
列:1 学号 | 2 姓名 | 3 第几次 | 4-5 数学 | 6-7 英语
|
||||
合格线 110,阈值 1 次。 */
|
||||
const FIXTURE = {
|
||||
header_rows: 2,
|
||||
rows: [
|
||||
['学号', '姓名', '第几次', '数学', '', '英语', ''],
|
||||
['', '', '', '成绩', '', '成绩', ''],
|
||||
// 张三:数学 3 次(112/100/136),英语 1 次(112)
|
||||
['356001', '张三', '1', '112', '', '112', ''],
|
||||
['', '', '2', '100', '', '', ''],
|
||||
['', '', '3', '136', '', '', ''],
|
||||
['', '', '合格次数', '3', '', '1', ''],
|
||||
// 李四:数学整场未考,英语 3 次(130/129/133)
|
||||
['356002', '李四', '1', '未考', '', '130', ''],
|
||||
['', '', '2', '', '', '129', ''],
|
||||
['', '', '3', '', '', '133', ''],
|
||||
['', '', '合格次数', '', '', '3', ''],
|
||||
// 王五:数学 1 次(88,未达合格线),英语整场未考
|
||||
['356003', '王五', '1', '88', '', '未考', ''],
|
||||
['', '', '合格次数', '0', '', '', ''],
|
||||
],
|
||||
merges: [
|
||||
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
|
||||
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三:学号/姓名并入末行,「第几次」只到末次考试
|
||||
[7, 1, 10, 1], [7, 2, 10, 2], [7, 4, 10, 5], // 李四:学号/姓名并入末行;数学整场「未考」跨 2 列 × 4 行
|
||||
[11, 6, 11, 7], // 王五:英语整场「未考」跨 2 列
|
||||
],
|
||||
cell_styles: {
|
||||
'5,4': 'red', // 张三 数学最高分 136(1-based 行 5) → 红字
|
||||
'9,6': 'red', // 李四 英语最高分 133(1-based 行 9) → 红字
|
||||
'6,4': 'green', // 张三 数学合格次数 3 → 绿底
|
||||
'6,6': 'green', // 张三 英语合格次数 1 → 绿底
|
||||
'10,6': 'green', // 李四 英语合格次数 3 → 绿底
|
||||
},
|
||||
};
|
||||
|
||||
const html = sheetToHtml(FIXTURE);
|
||||
const thead = (html.match(/<thead>([\s\S]*?)<\/thead>/) || [])[1] || '';
|
||||
const tbody = (html.match(/<tbody>([\s\S]*?)<\/tbody>/) || [])[1] || '';
|
||||
const count = (s, needle) => s.split(needle).length - 1;
|
||||
|
||||
/* ---------- 3) 断言 ---------- */
|
||||
chk('表头 2 行进 <thead>(多页打印自动重复)', count(thead, '<tr>') === 2 && count(tbody, '<tr>') === 10,
|
||||
'thead=' + count(thead, '<tr>') + ' tbody=' + count(tbody, '<tr>'));
|
||||
chk('学号表头纵向合并 2 行', thead.indexOf('<td rowspan="2">学号</td>') >= 0);
|
||||
chk('姓名表头纵向合并 2 行', thead.indexOf('<td rowspan="2">姓名</td>') >= 0);
|
||||
chk('第几次表头纵向合并 2 行', thead.indexOf('<td rowspan="2">第几次</td>') >= 0);
|
||||
chk('考试名称横跨 2 列',
|
||||
thead.indexOf('<td colspan="2">数学</td>') >= 0 && thead.indexOf('<td colspan="2">英语</td>') >= 0);
|
||||
chk('第 2 行表头是「成绩」小标题', thead.indexOf('<td>成绩</td>') >= 0);
|
||||
chk('张三学号纵向合并 4 行(3 次考试 + 合格次数行)', tbody.indexOf('<td rowspan="4">356001</td>') >= 0);
|
||||
chk('李四学号纵向合并 4 行', tbody.indexOf('<td rowspan="4">356002</td>') >= 0);
|
||||
chk('王五学号不合并(只有 1 次考试)', tbody.indexOf('<td>356003</td>') >= 0);
|
||||
chk('「合格次数」标签落在「第几次」列且 3 处都在', count(tbody, '<td>合格次数</td>') === 3,
|
||||
'实际 ' + count(tbody, '<td>合格次数</td>'));
|
||||
chk('未考合并 2 列 × 4 行(李四·数学)',
|
||||
tbody.indexOf('<td rowspan="4" colspan="2" class="absent">未考</td>') >= 0);
|
||||
chk('未考合并 2 列 × 1 行(王五·英语)',
|
||||
tbody.indexOf('<td colspan="2" class="absent">未考</td>') >= 0);
|
||||
chk('最高分红字 2 处(张三 136 / 李四 133)', count(tbody, 'class="max">') === 2,
|
||||
'实际 ' + count(tbody, 'class="max">'));
|
||||
chk('合格次数绿底 3 处(张三 2 场 + 李四 1 场)', count(tbody, 'class="pass">') === 3,
|
||||
'实际 ' + count(tbody, 'class="pass">'));
|
||||
chk('王五数学 0 次未达标 → 不标绿', tbody.indexOf('>0</td>') >= 0 && tbody.indexOf('class="pass">0') < 0);
|
||||
|
||||
/* td 数守恒:总格位数 − 被合并吞掉的格位。合并一旦算错(重叠 / 漏格)这里就会炸 */
|
||||
const covered = new Set();
|
||||
FIXTURE.merges.forEach(mg => {
|
||||
for (let r = mg[0] - 1; r <= mg[2] - 1; r++) {
|
||||
for (let c = mg[1] - 1; c <= mg[3] - 1; c++) {
|
||||
if (r === mg[0] - 1 && c === mg[1] - 1) continue;
|
||||
covered.add(r + '|' + c);
|
||||
}
|
||||
}
|
||||
});
|
||||
const capacity = FIXTURE.rows.reduce((s, row) => s + row.length, 0);
|
||||
const eaten = FIXTURE.rows.reduce((s, row, r) =>
|
||||
s + row.filter((_, c) => covered.has(r + '|' + c)).length, 0);
|
||||
chk('td 数守恒(' + capacity + ' − ' + eaten + ' = ' + (capacity - eaten) + ')',
|
||||
count(html, '<td') === capacity - eaten, '实际 ' + count(html, '<td'));
|
||||
|
||||
/* 标记只落在 cell_styles 指定的格子上:合并首格带上 class 且 rowspan/colspan 完整,
|
||||
同行未参与合并的格子不受牵连,越界键被忽略 */
|
||||
const markHtml = sheetToHtml({
|
||||
rows: [['a', 'b', 'c'], ['1', '2', '3']],
|
||||
merges: [[1, 1, 2, 2]],
|
||||
cell_styles: { '1,1': 'green', '99,99': 'red' },
|
||||
header_rows: 1,
|
||||
});
|
||||
chk('合并首格带标记:class 正确且 rowspan/colspan 完整',
|
||||
markHtml.indexOf('<td rowspan="2" colspan="2" class="pass">a</td>') >= 0, markHtml);
|
||||
chk('同行未合并格不受相邻合并影响', markHtml.indexOf('<td>c</td>') >= 0);
|
||||
chk('越界标记键被忽略', markHtml.indexOf('class="max"') < 0);
|
||||
|
||||
chk('打印 CSS 含块表样式(居中 / 未考灰 / 最高分红 / 合格绿)',
|
||||
PRINT_CSS.indexOf('table.bt') >= 0 && PRINT_CSS.indexOf('td.absent') >= 0
|
||||
&& PRINT_CSS.indexOf('td.max') >= 0 && PRINT_CSS.indexOf('td.pass') >= 0);
|
||||
/* 绿底印不印得出来,取决于 print-color-adjust 有没有写上——浏览器默认关闭"背景图形" */
|
||||
chk('打印 CSS 声明了 print-color-adjust:exact(否则底纹不会被打印)',
|
||||
/print-color-adjust:\s*exact/.test(PRINT_CSS) && PRINT_CSS.indexOf('-webkit-print-color-adjust') >= 0);
|
||||
chk('绿底单元格自己也声明了 print-color-adjust',
|
||||
/td\.pass\{[^}]*print-color-adjust:\s*exact/.test(PRINT_CSS));
|
||||
/* 缺考名单等附加工作表需要另起一页,否则会挤在成绩表底部 */
|
||||
chk('打印 CSS 让缺考名单(h3.grp.page-break)另起一页',
|
||||
/h3\.grp\.page-break\{[^}]*page-break-before:\s*always/.test(PRINT_CSS));
|
||||
/* 表头渲染成 <td>,底色必须写 thead td,写成 th 是死规则、两行表头会变白 */
|
||||
chk('表头底色命中实际渲染的 <td>(thead td,不是死规则 th)',
|
||||
/table\.bt thead td\{background:/.test(PRINT_CSS));
|
||||
chk('HTML 已转义',
|
||||
sheetToHtml({ rows: [['<b>x</b>']], merges: [], header_rows: 1 }).indexOf('<b>') >= 0);
|
||||
|
||||
console.log('');
|
||||
console.log('结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||
module.exports = { sheetToHtml: sheetToHtml, PRINT_CSS: PRINT_CSS };
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user