导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
267 lines
12 KiB
PHP
267 lines
12 KiB
PHP
<?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);
|