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
+162
View File
@@ -99,6 +99,168 @@ if ($parts !== []) {
@unlink($manualOut);
}
// 4) 合并单元格(「每次考试」导出用):写入 mergeCells,且被合并的非首格必须留空
$mergedRows = [
['学号', '姓名', '考试名称', '第几次', '成绩', '最高分'],
['2021001', '张三', '数学期中', '第1次', '90', '是'],
['', '', '', '第2次', '80', ''], // 学号/姓名/考试名称被合并占用
['', '', '英语期中', '第1次', '85', '是'],
];
$mOut = __DIR__ . '/_xlsxwriter_merge.xlsx';
$mBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $mergedRows,
'widths' => [20, 14, 26, 10, 10, 8],
// A2:A3、B2:B3 纵向合并;C2:C2 为退化区间(应被丢弃);越界区间也应被丢弃
'merges' => [[2, 1, 3, 1], [2, 2, 3, 2], [2, 3, 2, 3], [2, 6, 99, 6]],
]]);
file_put_contents($mOut, $mBin);
chk('合并表 ZIP 魔数 PK', substr($mBin, 0, 2) === 'PK');
// 取 sheet XML:优先 ZipArchive;没有 zip 扩展时借 XlsxReader 的纯 PHP 解包(stored/deflate 都能读)
$mParts = [];
if (class_exists('ZipArchive')) {
$mz = new ZipArchive();
if ($mz->open($mOut) === true) {
for ($i = 0; $i < $mz->numFiles; $i++) {
$n = $mz->getNameIndex($i);
$mParts[$n] = $mz->getFromIndex($i);
}
$mz->close();
}
}
if ($mParts === []) {
try {
$rm = new ReflectionMethod('XlsxReader', 'readZip');
$rm->setAccessible(true);
$mParts = (array)$rm->invoke(null, $mOut);
} catch (Throwable $e) {
$mParts = [];
}
}
$sheetXml = (string)($mParts['xl/worksheets/sheet1.xml'] ?? '');
if ($sheetXml !== '') {
chk('写入 mergeCells 且计数为 2(退化/越界区间被丢弃)',
strpos($sheetXml, '<mergeCells count="2">') !== false, '未找到 mergeCells count=2');
chk('合并区间 A2:A3 / B2:B3 正确',
strpos($sheetXml, '<mergeCell ref="A2:A3"/>') !== false
&& strpos($sheetXml, '<mergeCell ref="B2:B3"/>') !== false);
chk('mergeCells 位于 sheetData 之后',
strpos($sheetXml, '</sheetData><mergeCells') !== false);
chk('被合并的非首格只带样式、不带值(A3)',
strpos($sheetXml, '<c r="A3" s="2"/>') !== false, '未找到 <c r="A3" s="2"/>');
chk('区块首格仍然保留(A2 / B2 在)',
strpos($sheetXml, 'r="A2"') !== false && strpos($sheetXml, 'r="B2"') !== false);
chk('英语块第 4 行考试名称未被吞(C4 在)', strpos($sheetXml, 'r="C4"') !== false);
chk('样式表已写入细边框', strpos((string)($mParts['xl/styles.xml'] ?? ''), '<left style="thin">') !== false);
} else {
chk('(跳过)无 ZipArchive,合并单元格 XML 未校验', true);
}
$mBack = XlsxReader::read($mOut);
chk('含合并表可被 XlsxReader 解析', count($mBack) === count($mergedRows), '行数=' . count($mBack));
chk('合并表首行内容正确', ($mBack[1][1] ?? '') === '张三' && ($mBack[3][2] ?? '') === '英语期中',
json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE));
@unlink($mOut);
/* 5) 两行表头 + 交叉合并(「多考试导出」块表用):header_rows=2 时两行都按表头样式输出,
并用 cell_styles 标记「最高分红字 / 合格次数绿底」 */
$blockRows = [
['学号', '姓名', '第几次', '数学', '', '英语', ''],
['', '', '', '成绩', '', '成绩', ''],
['356001', '张三', '1', '112', '112'],
['', '', '2', '100', '100'],
['', '', '3', '136', '133'],
['', '', '合格次数', '3', '', '2', ''],
['356002', '李四', '1', '未考', '', '130'],
];
$bOut = __DIR__ . '/_xlsxwriter_block.xlsx';
$bBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $blockRows,
'widths' => [20, 14, 9, 10, 11, 10, 11],
'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], // 张三 学号/姓名/第几次
// 「第几次」不并入合格次数行,
// 否则 C6 的「合格次数」标签会被吞掉
[7, 4, 7, 5], // 李四 数学「未考」跨 2 列
],
'header_rows' => 2,
'cell_styles' => [
'5,4' => 'red', // 张三数学最高分 136 → 红字
'6,4' => 'green', // 张三数学合格次数 → 绿底
'6,6' => 'green', // 张三英语合格次数 → 绿底
'8,2' => 'nope', // 未知标记应被静默忽略
],
]]);
file_put_contents($bOut, $bBin);
chk('两行表头表 ZIP 魔数 PK', substr($bBin, 0, 2) === 'PK');
$bParts = [];
if (class_exists('ZipArchive')) {
$bz = new ZipArchive();
if ($bz->open($bOut) === true) {
for ($i = 0; $i < $bz->numFiles; $i++) {
$bn = $bz->getNameIndex($i);
$bParts[$bn] = $bz->getFromIndex($i);
}
$bz->close();
}
}
$bXml = (string)($bParts['xl/worksheets/sheet1.xml'] ?? '');
$bStyles = (string)($bParts['xl/styles.xml'] ?? '');
if ($bXml !== '') {
chk('第 2 行表头也用表头样式(s="1")',
strpos($bXml, '<c r="A2" s="1"/>') !== false && strpos($bXml, 'r="D2" s="1"') !== false);
chk('数据行用文本样式(s="2")', strpos($bXml, 'r="A3" s="2"') !== false);
chk('表头合并区间齐备',
strpos($bXml, '<mergeCell ref="A1:A2"/>') !== false
&& strpos($bXml, '<mergeCell ref="D1:E1"/>') !== false
&& strpos($bXml, '<mergeCell ref="F1:G1"/>') !== false
&& strpos($bXml, '<mergeCell ref="D7:E7"/>') !== false);
chk('mergeCells 计数 = 9', strpos($bXml, '<mergeCells count="9">') !== false);
chk('「第几次」不并入合格次数行(C6 仍带值)',
strpos($bXml, '<c r="C6" s="2" t="s"><v>16</v></c>') !== false);
// ---- 单元格级样式:最高分红字(s="3")/ 合格次数绿底(s="4") ----
chk('最高分标红用红字样式(D5 s="3")', strpos($bXml, '<c r="D5" s="3"') !== false);
chk('合格次数达标用绿底样式(D6 s="4")', strpos($bXml, '<c r="D6" s="4"') !== false);
chk('合格次数达标用绿底样式(F6 s="4")', strpos($bXml, '<c r="F6" s="4"') !== false);
chk('未标记格仍用普通文本样式(D3 s="2")', strpos($bXml, '<c r="D3" s="2"') !== false);
// 样式表里必须真的存在红字字体与绿色填充,否则 Excel 打开是一片黑白
chk('styles 含红色字体(FFD0021B)', strpos($bStyles, 'FFD0021B') !== false);
chk('styles 含绿色填充(FF63C384)', strpos($bStyles, 'FF63C384') !== false);
chk('cellXfs 数量 = 6', strpos($bStyles, '<cellXfs count="6">') !== false);
// XML 必须合法:结构错一个字符 Excel 就会提示"修复",用 DOM 解析一遍最省事
if (class_exists('DOMDocument')) {
$okXml = true;
foreach (['sheet1.xml' => $bXml, 'styles.xml' => $bStyles] as $part => $xml) {
$doc = new DOMDocument();
if ($xml === '' || !@$doc->loadXML($xml)) {
$okXml = false;
chk('XML 可解析:' . $part, false, '解析失败');
}
}
if ($okXml) {
chk('XML 可解析(sheet / styles)', true);
}
}
} else {
chk('(跳过)无 ZipArchive,两行表头 XML 未校验', true);
}
$bBack = XlsxReader::read($bOut);
chk('两行表头表可被读回', count($bBack) === count($blockRows), '行数=' . count($bBack));
chk('两行表头表内容正确',
($bBack[2][0] ?? '') === '356001'
&& ($bBack[5][2] ?? '') === '合格次数' // 合格次数标签不被合并吞掉
&& ($bBack[6][3] ?? '') === '未考'
&& ($bBack[4][3] ?? '') === '136' && ($bBack[4][4] ?? '') === '133',
json_encode($bBack[6] ?? [], JSON_UNESCAPED_UNICODE));
@unlink($bOut);
@unlink($out);
echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
exit($fail === 0 ? 0 : 1);