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

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

测试/样例/文档同步更新,全量回归通过。
2026-09-27 22:04:17 +08:00

267 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 自测:XlsxWriter 生成 → XlsxReader 读回,验证闭环与内容正确
* 运行:php tools/test-xlsxwriter.php
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
$out = __DIR__ . '/_xlsxwriter_out.xlsx';
$rows = [
['姓名', '登陆账号'],
['张三', '2024010101'],
['李四', '2024010102001234'], // 超长学号:必须原样保留,不能变科学计数法
['王小明', 'A001'],
['Bob "引号" & <标签>', '123456789012345678'],
];
$bin = XlsxWriter::render($rows, ['widths' => [16, 24], 'title' => '白名单']);
file_put_contents($out, $bin);
echo "生成字节数: " . strlen($bin) . PHP_EOL;
// 1) ZIP 头必须是 PK
$pass = 0; $fail = 0;
function chk(string $name, bool $ok, string $extra = ''): void
{
global $pass, $fail;
if ($ok) { $pass++; echo " PASS $name" . ($extra ? " — $extra" : '') . PHP_EOL; }
else { $fail++; echo " FAIL $name" . ($extra ? " — $extra" : '') . PHP_EOL; }
}
chk('ZIP 魔数 PK', substr($bin, 0, 2) === 'PK');
// 用 ZipArchive 解开校验包内结构(ZIP 内容已压缩,不能直接在二进制里搜字符串)
$parts = [];
if (class_exists('ZipArchive')) {
$z = new ZipArchive();
if ($z->open($out) === true) {
for ($i = 0; $i < $z->numFiles; $i++) {
$n = $z->getNameIndex($i);
$parts[$n] = $z->getFromIndex($i);
}
$z->close();
}
}
chk('包内含 xl/workbook.xml', isset($parts['xl/workbook.xml']));
chk('包内含 xl/worksheets/sheet1.xml', isset($parts['xl/worksheets/sheet1.xml']));
chk('包内含 xl/sharedStrings.xml', isset($parts['xl/sharedStrings.xml']));
chk('Content_Types 声明 workbook 主部件',
strpos((string)($parts['[Content_Types].xml'] ?? ''), 'spreadsheetml.sheet.main+xml') !== false);
chk('文本格式 numFmtId=49 已写入',
strpos((string)($parts['xl/styles.xml'] ?? ''), 'numFmtId="49"') !== false);
chk('学号以共享字符串写入(非纯数字单元格)',
strpos((string)($parts['xl/sharedStrings.xml'] ?? ''), '2024010102001234') !== false);
chk('列名(A/B)正确',
strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="A1"') !== false
&& strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="B1"') !== false);
// 2) 用自带的 XlsxReader 读回
$back = XlsxReader::read($out);
chk('XlsxReader 成功解析', is_array($back) && count($back) > 0, '行数=' . count($back));
chk('行数一致', count($back) === count($rows), '期望 ' . count($rows) . ',实际 ' . count($back));
$first = $back[0] ?? [];
chk('表头 = 姓名/登陆账号',
($first[0] ?? '') === '姓名' && ($first[1] ?? '') === '登陆账号',
json_encode($first, JSON_UNESCAPED_UNICODE));
chk('普通行正确', ($back[1][0] ?? '') === '张三' && ($back[1][1] ?? '') === '2024010101',
json_encode($back[1] ?? [], JSON_UNESCAPED_UNICODE));
chk('超长学号不被转科学计数法', ($back[2][1] ?? '') === '2024010102001234',
'实际=' . ($back[2][1] ?? 'NULL'));
chk('18 位纯数字学号原样保留', ($back[4][1] ?? '') === '123456789012345678',
'实际=' . ($back[4][1] ?? 'NULL'));
chk('特殊字符转义正确', ($back[4][0] ?? '') === 'Bob "引号" & <标签>',
'实际=' . ($back[4][0] ?? 'NULL'));
// 3) 无 ZipArchive 时的手写打包路径(stored):单独生成再用 XlsxReader 读回
$manualOut = __DIR__ . '/_xlsxwriter_manual.xlsx';
$manual = XlsxWriter::packManual($parts !== [] ? $parts : []);
if ($parts !== []) {
file_put_contents($manualOut, $manual);
chk('手写 ZIP 魔数 PK', substr($manual, 0, 2) === 'PK');
$mBack = [];
try {
$mBack = XlsxReader::read($manualOut);
} catch (Throwable $e) {
$mBack = [];
}
chk('手写打包产物可被解析', count($mBack) === count($rows), '行数=' . count($mBack));
chk('手写打包内容一致',
($mBack[1][0] ?? '') === '张三' && ($mBack[4][1] ?? '') === '123456789012345678',
json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE));
@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);