feat(export/dashboard): 导出增强与大屏公开化

导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
2026-09-27 22:04:17 +08:00
parent 0ba9c7c33c
commit d05fd4a9b5
34 changed files with 4290 additions and 305 deletions
+286 -1
View File
@@ -34,7 +34,7 @@ function check(string $name, bool $cond, string $detail = ''): void
function stuRow(string $sid, string $name, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
{
$list = [];
$t = 1_000_000;
$t = 1000000; // 不用数字分隔符,保证 PHP 7.3+ 都能跑回归测试
foreach ($scoresAsc as $i => $sc) {
$t += 600;
$list[] = [
@@ -132,5 +132,290 @@ $r3 = ['sid' => 'X', 'name' => 'Y', 'score' => 77.0, 'best_score' => 77.0]; //
check('无明细 latest 退回代表行=77', ny_exam_cell_score($r3, 'latest') === 77.0);
check('无明细 highest=77', ny_exam_cell_score($r3, 'highest') === 77.0);
/* ---------------- 「每次考试」块表(两行表头 / 合并单元格 / 未考) ---------------- */
/**
* 行内容比较:数据行是「参差不齐」的(某场次数少就没有尾巴上的格子),
* 短的一侧按空串补齐到等长再逐格比,避免因为"少几个空格子"误判。
*/
function rowEq(array $a, array $b): bool
{
$n = max(count($a), count($b));
for ($i = 0; $i < $n; $i++) {
$x = isset($a[$i]) ? (string)$a[$i] : '';
$y = isset($b[$i]) ? (string)$b[$i] : '';
if ($x !== $y) {
return false;
}
}
return true;
}
/** merges 转成 "A2:A4" 形式,便于与期望值比较(不依赖生成顺序) */function mergeRefs(array $merges): array
{
$col = static function (int $n): string {
$s = '';
while ($n > 0) {
$m = ($n - 1) % 26;
$s = chr(65 + $m) . $s;
$n = (int)(($n - $m) / 26);
}
return $s;
};
$out = [];
foreach ($merges as $m) {
$out[] = $col($m[1]) . $m[0] . ':' . $col($m[3]) . $m[2];
}
sort($out);
return $out;
}
/** 构造带班级 / 组别的学生行 */
function stuRowCG(string $sid, string $name, string $cls, string $grp, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
{
$r = stuRow($sid, $name, $scoresAsc, $tempMarks, $best);
$r['cls'] = $cls;
$r['grp'] = $grp;
return $r;
}
// 版式场景完全对齐需求截图:张三 4 次数学 / 2 次英语;李四只有英语(数学「未考」)
$shotMath = ['name' => '数学', 'rows' => [
stuRow('356001', '张三', [112.0, 100.0, 136.0, 127.0], [], 136.0),
]];
$shotEng = ['name' => '英语', 'rows' => [
stuRow('356001', '张三', [112.0, 100.0], [], 112.0),
stuRow('356002', '李四', [130.0, 129.0, 133.0], [], 133.0),
]];
echo "== 每次考试块表:两行表头 / 共享第几次 / 未考(截图一版式) ==\n";
$bs = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng]);
$rows = $bs['rows'];
check('表头第 1 行:学号 / 姓名 / 第几次 / 考试名(各占 1 格 + 1 空格)',
$rows[0] === ['学号', '姓名', '第几次', '数学', '', '英语', ''],
json_encode($rows[0], JSON_UNESCAPED_UNICODE));
check('表头第 2 行:只有「成绩」小标题',
$rows[1] === ['', '', '', '成绩', '', '成绩', ''],
json_encode($rows[1], JSON_UNESCAPED_UNICODE));
check('header_rows = 2', $bs['header_rows'] === 2);
/* 每场考试固定占 2 列:第 1 列成绩,第 2 列是「成绩列与相邻列合并」落下的空位。
当初漏推这个空串,整行会左移一格,表现就是成绩串列、表头合并区间对不上。 */
check('张三块首行:第几次=1,两场成绩(每场 2 列)',
rowEq($rows[2], ['356001', '张三', '1', '112', '', '112', '']),
json_encode($rows[2], JSON_UNESCAPED_UNICODE));
check('每场考试固定 2 列(成绩 + 落位空列),行宽齐整',
count($rows) === 9 && !array_filter(array_map('count', $rows), static fn($n) => $n !== 7),
'列数序列 = ' . json_encode(array_map('count', $rows)));
check('张三第 3 行:第几次=3,数学 136 / 英语空',
rowEq($rows[4], ['', '', '3', '136', '']),
json_encode($rows[4], JSON_UNESCAPED_UNICODE));
check('张三第 4 行:第几次=4,英语留空',
rowEq($rows[5], ['', '', '4', '127', '']),
json_encode($rows[5], JSON_UNESCAPED_UNICODE));
check('李四块首行:数学未考 + 英语 130',
rowEq($rows[6], ['356002', '李四', '1', '未考', '', '130']),
json_encode($rows[6], JSON_UNESCAPED_UNICODE));
check('总行数 = 9(2 行表头 + 张三 4 + 李四 3)', count($rows) === 9, (string)count($rows));
check('total_rows / students 统计正确', $bs['total_rows'] === 7 && $bs['students'] === 2,
$bs['total_rows'] . '/' . $bs['students']);
check('未启用合格行时 pass_row=false', $bs['pass_row'] === false, var_export($bs['pass_row'], true));
$refs = mergeRefs($bs['merges']);
check('合并区间:表头 C 列纵向 + 考试名横向 + 学号姓名第几次纵向 + 未考跨 2 列',
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'C3:C6', 'C7:C9', 'D1:E1', 'D7:E9', 'F1:G1'],
json_encode($refs));
/* 列宽够用就好:空落位列给 2.5,宽度让给真正显示成绩的列,
否则一屏只放得下两场考试。 */
check('列宽 = 学号/姓名/第几次 + 成绩 10 + 空落位 2.5',
$bs['widths'] === [10, 9, 9, 10, 2.5, 10, 2.5], json_encode($bs['widths']));
echo "== 每次考试块表:最高分标红 / 合格次数标绿 ==\n";
// 合格线 110:
// 张三数学 [112,100,136,127] → 112/136/127 合格 = 3
// 张三英语 [112,100] → 112 合格 = 1
// 李四英语 [130,129,133] → 130/129/133 合格 = 3
$bp = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110]);
$pp = $bp['rows'];
check('启用合格行后 header_rows 仍为 2', $bp['header_rows'] === 2);
check('pass_row=true 且 pass_line 回传', $bp['pass_row'] === true && $bp['pass_line'] === 110.0,
var_export($bp['pass_line'], true));
// 张三块:4 行成绩 + 1 行合格次数(第 7 行)
check('张三合格次数行:数学 3 次 / 英语 1 次',
rowEq($pp[6], ['', '', '合格次数', '3', '', '1']),
json_encode($pp[6], JSON_UNESCAPED_UNICODE));
// 李四块:3 行成绩 + 1 行合格次数(第 11 行)
check('李四合格次数行:数学未考留空 / 英语 3 次',
rowEq($pp[10], ['', '', '合格次数', '', '', '3']),
json_encode($pp[10], JSON_UNESCAPED_UNICODE));
check('总行数 = 11(2 + (4+1) + (3+1))', count($pp) === 11, (string)count($pp));
check('total_rows 含合格次数行 = 9', $bp['total_rows'] === 9, (string)$bp['total_rows']);
$pm = $bp['cell_styles'];
// 张三数学最高分 136 在第 5 行第 4 列 → 红字
check('张三数学最高分 136 标红', ($pm['5,4'] ?? '') === 'red', json_encode($pm));
// 张三英语最高分 112 在第 3 行第 6 列 → 红字
check('张三英语最高分 112 标红', ($pm['3,6'] ?? '') === 'red', json_encode($pm));
// 张三合格次数(第 7 行):数学 3 达标绿底、英语 1 达标绿底
check('张三数学合格次数达标绿底', ($pm['7,4'] ?? '') === 'green', json_encode($pm));
check('张三英语合格次数达标绿底', ($pm['7,6'] ?? '') === 'green', json_encode($pm));
// 李四:数学未考 → 不标绿;英语 3 次达标绿底
check('李四数学未考不标绿', !isset($pm['11,4']), json_encode($pm));
check('李四英语合格次数达标绿底', ($pm['11,6'] ?? '') === 'green', json_encode($pm));
// 未达标(阈值 4 次)→ 仍显示实际次数(张三数学 3 / 英语 1),但不标绿
$bh = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110, 'pass_times' => 4]);
$hp = $bh['rows'];
check('阈值 4 次时张三仍显示实际合格次数(不隐藏)',
rowEq($hp[6], ['', '', '合格次数', '3', '', '1']),
json_encode($hp[6], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不达标不标绿',
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,6']),
json_encode($bh['cell_styles']));
echo "== 每次考试块表:草稿 / 多班级组别列 / 空考试 ==\n";
// 赵六:数学第 2 次是草稿 → 默认跳过(不占编号)
$draftMath = ['name' => '数学', 'rows' => [
stuRow('D1', '赵六', [60.0, 99.0], [false, true], 99.0),
]];
$ds = ny_assemble_block_sheet(['m' => $draftMath]);
check('默认跳过草稿:只剩 1 次',
rowEq($ds['rows'][2], ['D1', '赵六', '1', '60']),
json_encode($ds['rows'][2], JSON_UNESCAPED_UNICODE));
check('跳过草稿后无多余行', count($ds['rows']) === 3, (string)count($ds['rows']));
$di = ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false]);
check('保留草稿:2 次且草稿分参与',
rowEq($di['rows'][3], ['', '', '2', '99']),
json_encode($di['rows'][3], JSON_UNESCAPED_UNICODE));
// 只有草稿的学生:跳过草稿后整场无有效成绩 → 不出现在表里
$onlyDraft = ['name' => '数学', 'rows' => [
stuRow('X1', '只交草稿', [88.0], [true], 88.0),
stuRow('X2', '正常', [70.0], [], 70.0),
]];
$od = ny_assemble_block_sheet(['m' => $onlyDraft]);
check('全草稿学生被剔除(只剩正常那位)', $od['students'] === 1, (string)$od['students']);
check('全草稿学生未写「未考」占位', rowEq($od['rows'][2], ['X2', '正常', '1', '70']),
json_encode($od['rows'][2], JSON_UNESCAPED_UNICODE));
// 多班级 / 多组别:按 班级 → 组别 → 学号 排序,连续相同班级 / 组别合并
$multi = ['name' => '数学', 'rows' => [
stuRowCG('S3', '王五', '1班', 'B', [60.0]),
stuRowCG('S4', '赵六', '2班', 'B', [50.0]),
stuRowCG('S1', '张三', '1班', 'A', [90.0, 80.0]),
stuRowCG('S2', '李四', '1班', 'A', [70.0]),
]];
$mc = ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true, 'with_grp' => true]);
check('多班级表头 = 班级/组别/学号/姓名/第几次/考试名',
$mc['rows'][0] === ['班级', '组别', '学号', '姓名', '第几次', '数学', ''],
json_encode($mc['rows'][0], JSON_UNESCAPED_UNICODE));
check('按 班级 → 组别 → 学号 排序',
[$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]
=== ['S1', 'S2', 'S3', 'S4'],
json_encode([$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]));
check('班级 / 组别只写在学生块首行',
rowEq($mc['rows'][2], ['1班', 'A', 'S1', '张三', '1', '90']),
json_encode($mc['rows'][2], JSON_UNESCAPED_UNICODE));
$mcRefs = mergeRefs($mc['merges']);
check('相同班级 / 相同组别的单元格合并(1班 S1~S3 归一格,A 组 S1~S2 归一格)',
in_array('A3:A6', $mcRefs, true) && in_array('B3:B5', $mcRefs, true),
json_encode($mcRefs));
check('班级列位移后学号 / 姓名合并列正确(C / D 列)',
in_array('C3:C4', $mcRefs, true) && in_array('D3:D4', $mcRefs, true),
json_encode($mcRefs));
// 空考试:只出两行表头
$bEmpty = ny_assemble_block_sheet(['none' => ['name' => '无人考试', 'rows' => []]]);
check('空考试只有两行表头', count($bEmpty['rows']) === 2 && $bEmpty['students'] === 0);
check('空考试仅剩表头合并',
mergeRefs($bEmpty['merges']) === ['A1:A2', 'B1:B2', 'C1:C2', 'D1:E1'],
json_encode(mergeRefs($bEmpty['merges'])));
// 考试列顺序跟随传入顺序
$bOrder = ny_assemble_block_sheet(['e' => $shotEng, 'm' => $shotMath]);
check('考试列顺序跟随传入顺序(英语在前)',
$bOrder['rows'][0][3] === '英语' && $bOrder['rows'][0][5] === '数学',
json_encode($bOrder['rows'][0], JSON_UNESCAPED_UNICODE));
echo "== 单场「每次考试」宽表(截图二版式) ==\n";
$w = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110]);
check('表头 = 学号/姓名/第1次…第N次/合格次数',
$w['rows'][0] === ['学号', '姓名', '第1次', '第2次', '第3次', '合格次数'],
json_encode($w['rows'][0], JSON_UNESCAPED_UNICODE));
check('每人一行,共 2 人', count($w['rows']) === 3 && $w['students'] === 2, (string)count($w['rows']));
// 张三英语 [112,100]:只有 2 次,第 3 列留空;≥110 → 112 合格 = 1
check('张三行:112 / 100 / 空,合格 1 次',
$w['rows'][1] === ['356001', '张三', '112', '100', '', '1'],
json_encode($w['rows'][1], JSON_UNESCAPED_UNICODE));
// 李四英语 [130,129,133]:全 ≥110 = 3 次合格
check('李四行:130 / 129 / 133,合格 3 次',
$w['rows'][2] === ['356002', '李四', '130', '129', '133', '3'],
json_encode($w['rows'][2], JSON_UNESCAPED_UNICODE));
check('attempt_cols=3', $w['attempt_cols'] === 3, (string)$w['attempt_cols']);
check('最高分 112(张三第 3 列)标红', ($w['cell_styles']['2,3'] ?? '') === 'red', json_encode($w['cell_styles']));
check('最高分 133(李四第 5 列)标红', ($w['cell_styles']['3,5'] ?? '') === 'red', json_encode($w['cell_styles']));
check('张三合格次数 1 次达标绿底', ($w['cell_styles']['2,6'] ?? '') === 'green', json_encode($w['cell_styles']));
check('李四合格次数 3 次达标绿底', ($w['cell_styles']['3,6'] ?? '') === 'green', json_encode($w['cell_styles']));
// 阈值 4 次 → 都不到阈值,仍显示实际次数(1 / 3),但不标绿
$w4 = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'pass_times' => 4]);
check('阈值 4 次时仍显示实际合格次数(不隐藏)',
$w4['rows'][1][5] === '1' && $w4['rows'][2][5] === '3',
json_encode([$w4['rows'][1][5], $w4['rows'][2][5]], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不标绿',
!isset($w4['cell_styles']['2,6']) && !isset($w4['cell_styles']['3,6']),
json_encode($w4['cell_styles']));
// 关闭标红 / 关掉合格行
$wOff = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'mark_best' => false, 'pass_row' => false]);
check('mark_best=false 不标红', $wOff['cell_styles'] === [], json_encode($wOff['cell_styles']));
check('pass_row=false 表头无合格次数', !in_array('合格次数', $wOff['rows'][0], true),
json_encode($wOff['rows'][0], JSON_UNESCAPED_UNICODE));
/** 检查合并区间两两不重叠(区间重叠会让 Excel 打开时报"需要修复") */
function overlapsOf(array $merges): array
{
$bad = [];
$n = count($merges);
for ($i = 0; $i < $n; $i++) {
for ($j = $i + 1; $j < $n; $j++) {
$a = $merges[$i];
$b = $merges[$j];
if ($a[0] <= $b[2] && $b[0] <= $a[2] && $a[1] <= $b[3] && $b[1] <= $a[3]) {
$bad[] = json_encode($a) . '×' . json_encode($b);
}
}
}
return $bad;
}
echo "== 班级 / 组别列的显示规则 ==\n";
$f1 = ny_multi_col_flags(['1班'], [], ['1班'], ['A', 'B']);
check('只选 1 个班级且未选组别 → 班级 / 组别列都不出',
$f1['with_cls'] === false && $f1['with_grp'] === false, json_encode($f1));
$f2 = ny_multi_col_flags(['1班', '2班'], ['A', 'B'], ['1班', '2班'], ['A', 'B']);
check('选了多班级 + 多组别 → 两列都出',
$f2['with_cls'] === true && $f2['with_grp'] === true, json_encode($f2));
$f3 = ny_multi_col_flags(['1班'], ['A', 'B'], ['1班'], ['A', 'B']);
check('只选 1 个班级但选了多组别 → 只出组别列',
$f3['with_cls'] === false && $f3['with_grp'] === true, json_encode($f3));
$f4 = ny_multi_col_flags([], [], ['1班'], ['A']);
check('未筛选且数据只有 1 个班 / 1 个组 → 两列都不出',
$f4['with_cls'] === false && $f4['with_grp'] === false, json_encode($f4));
$f5 = ny_multi_col_flags([], [], ['1班', '2班', '3班'], ['A', 'B']);
check('未筛选且数据跨 3 个班 → 按数据补出班级 / 组别列',
$f5['with_cls'] === true && $f5['with_grp'] === true, json_encode($f5));
$f6 = ny_multi_col_flags(['1班', '2班'], ['A'], ['1班', '2班'], ['A']);
check('多班级 + 只选 1 个组别 → 只出班级列',
$f6['with_cls'] === true && $f6['with_grp'] === false, json_encode($f6));
echo "== 合并区间不重叠(防 Excel 报修复) ==\n";
check('截图版式:无重叠', overlapsOf($bs['merges']) === [], json_encode(overlapsOf($bs['merges'])));
check('多班级多组别:无重叠', overlapsOf($mc['merges']) === [], json_encode(overlapsOf($mc['merges'])));
check('单场导出(带班级组别列):无重叠',
overlapsOf(ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true])['merges']) === []);
check('草稿模式:无重叠',
overlapsOf(ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false])['merges']) === []);
echo "\n通过 {$pass} 项,失败 {$fail} 项\n";
exit($fail === 0 ? 0 : 1);