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