feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
+187
-23
@@ -5,7 +5,10 @@
|
||||
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
|
||||
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
|
||||
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
|
||||
* - 第一行固定为表头(加粗 + 居中)
|
||||
* - 表头行(默认第 1 行,可用 header_rows 指定多行)加粗 + 居中 + 边框
|
||||
* - 支持合并单元格(merges),被合并的非首格写「空值 + 同样的样式」,保证合并区域四周边框完整
|
||||
* - 支持单元格级样式(cell_styles):按 [行,列] 指定「红字 / 绿底」等标记,
|
||||
* 用于"每场最高分标红、合格次数达标标绿"
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
@@ -17,13 +20,26 @@ final class XlsxWriter
|
||||
private array $strings = [];
|
||||
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
|
||||
private array $strCount = [];
|
||||
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
|
||||
/** @var array<int,array{name:string,rows:array,widths:array,merges:array}> 工作表(按输出顺序) */
|
||||
private array $sheets = [];
|
||||
|
||||
// 样式索引(见 stylesXml)
|
||||
private const S_DEFAULT = 0; // 常规
|
||||
private const S_HEADER = 1; // 表头:加粗 + 居中
|
||||
private const S_TEXT = 2; // 文本格式(numFmtId=49)
|
||||
private const S_DEFAULT = 0; // 常规(无边框)
|
||||
private const S_HEADER = 1; // 表头:加粗 + 居中 + 细边框
|
||||
private const S_TEXT = 2; // 数据:文本格式(numFmtId=49)+ 细边框
|
||||
private const S_RED = 3; // 数据 + 红字(最高分标记)
|
||||
private const S_GREEN = 4; // 数据 + 绿底(合格次数达标标记)
|
||||
private const S_HEADER_GREEN = 5; // 表头样式 + 绿底(合格次数行的表头格)
|
||||
|
||||
/**
|
||||
* 语义化标记 => 样式索引。调用方只管语义('red' / 'green' / 'header_green'),
|
||||
* 具体配色编号由本类统一维护,避免外部硬编码样式下标。
|
||||
*/
|
||||
private const MARK_STYLES = [
|
||||
'red' => self::S_RED,
|
||||
'green' => self::S_GREEN,
|
||||
'header_green' => self::S_HEADER_GREEN,
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成单表 xlsx 二进制内容(兼容旧调用)
|
||||
@@ -48,7 +64,11 @@ final class XlsxWriter
|
||||
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
|
||||
* 当成公式执行(天然规避 CSV 公式注入问题)。
|
||||
*
|
||||
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...]]
|
||||
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...], 'merges'=>[[r1,c1,r2,c2],...], 'header_rows'=>int,
|
||||
* 'cell_styles'=>['r,c'=>'red'|'green'|'header_green', ...]]
|
||||
* merges 为 1-based 闭区间(含表头行),如 [[2,1,4,1]] = A2:A4 纵向合并
|
||||
* header_rows 表头行数(默认 1;两行表头的交叉表用 2)
|
||||
* cell_styles 的键为 1-based 的 "行,列",值为语义标记名(未知标记静默忽略)
|
||||
* @return string xlsx 文件二进制
|
||||
*/
|
||||
public static function renderMulti(array $sheets): string
|
||||
@@ -63,18 +83,93 @@ final class XlsxWriter
|
||||
continue; // 空表不输出,避免 Excel 提示修复
|
||||
}
|
||||
$w->sheets[] = [
|
||||
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
|
||||
'rows' => $rows,
|
||||
'widths' => array_values((array)($sh['widths'] ?? [])),
|
||||
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
|
||||
'rows' => $rows,
|
||||
'widths' => array_values((array)($sh['widths'] ?? [])),
|
||||
'merges' => self::normalizeMerges((array)($sh['merges'] ?? []), count($rows), self::maxCols($rows)),
|
||||
'header_rows' => max(1, (int)($sh['header_rows'] ?? 1)),
|
||||
'cell_styles' => self::normalizeCellStyles((array)($sh['cell_styles'] ?? [])),
|
||||
];
|
||||
}
|
||||
if ($w->sheets === []) {
|
||||
// 保底:至少输出一张空表
|
||||
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => []];
|
||||
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => [], 'merges' => [], 'header_rows' => 1, 'cell_styles' => []];
|
||||
}
|
||||
return $w->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化单元格样式表:键 "行,列"(1-based)=> 样式索引;未知标记直接丢弃。
|
||||
*
|
||||
* @param array $raw ['3,5' => 'red', [4,5] => 'green', ...](两种写法都接受)
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private static function normalizeCellStyles(array $raw): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($raw as $k => $mark) {
|
||||
if (is_array($k) && count($k) >= 2) {
|
||||
$key = ((int)$k[0]) . ',' . ((int)$k[1]);
|
||||
} else {
|
||||
$key = (string)$k;
|
||||
if (!preg_match('/^\s*(\d+)\s*,\s*(\d+)\s*$/', $key, $m)) {
|
||||
continue;
|
||||
}
|
||||
$key = ((int)$m[1]) . ',' . ((int)$m[2]);
|
||||
}
|
||||
$idx = self::MARK_STYLES[(string)$mark] ?? null;
|
||||
if ($idx === null) {
|
||||
continue; // 未知标记:不写样式,不报错
|
||||
}
|
||||
[$r, $c] = array_map('intval', explode(',', $key));
|
||||
if ($r < 1 || $c < 1) {
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $idx;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 二维表最大列数(至少 1) */
|
||||
private static function maxCols(array $rows): int
|
||||
{
|
||||
$max = 1;
|
||||
foreach ($rows as $r) {
|
||||
$max = max($max, count((array)$r));
|
||||
}
|
||||
return $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化合并区间:过滤非法 / 退化(单格)区间,并按行列排序,保证 Excel 不报修复提示。
|
||||
*
|
||||
* @param array $raw [[r1,c1,r2,c2], ...] 1-based
|
||||
* @return array<int,array{0:int,1:int,2:int,3:int}>
|
||||
*/
|
||||
private static function normalizeMerges(array $raw, int $maxRow, int $maxCol): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($raw as $m) {
|
||||
if (!is_array($m) || count($m) < 4) {
|
||||
continue;
|
||||
}
|
||||
$r1 = (int)$m[0]; $c1 = (int)$m[1]; $r2 = (int)$m[2]; $c2 = (int)$m[3];
|
||||
if ($r1 > $r2) { [$r1, $r2] = [$r2, $r1]; }
|
||||
if ($c1 > $c2) { [$c1, $c2] = [$c2, $c1]; }
|
||||
if ($r1 < 1 || $c1 < 1 || $r2 > $maxRow || $c2 > $maxCol) {
|
||||
continue; // 越界丢弃
|
||||
}
|
||||
if ($r1 === $r2 && $c1 === $c2) {
|
||||
continue; // 单格无需合并
|
||||
}
|
||||
$out[] = [$r1, $c1, $r2, $c2];
|
||||
}
|
||||
usort($out, static function (array $a, array $b): int {
|
||||
return [$a[0], $a[1], $a[2], $a[3]] <=> [$b[0], $b[1], $b[2], $b[3]];
|
||||
});
|
||||
return $out;
|
||||
}
|
||||
|
||||
/* ---------------- 内部:构建 ---------------- */
|
||||
|
||||
private function build(): string
|
||||
@@ -103,7 +198,15 @@ final class XlsxWriter
|
||||
$cells[$r][$c] = $this->intern((string)($val ?? ''));
|
||||
}
|
||||
}
|
||||
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml($cells, $maxRow, $maxCol, $sh['widths']);
|
||||
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml(
|
||||
$cells,
|
||||
$maxRow,
|
||||
$maxCol,
|
||||
$sh['widths'],
|
||||
$sh['merges'] ?? [],
|
||||
(int)($sh['header_rows'] ?? 1),
|
||||
(array)($sh['cell_styles'] ?? [])
|
||||
);
|
||||
$names[] = $sh['name'];
|
||||
}
|
||||
|
||||
@@ -165,7 +268,7 @@ final class XlsxWriter
|
||||
return $i;
|
||||
}
|
||||
|
||||
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = []): string
|
||||
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = [], array $merges = [], int $headerRows = 1, array $cellStyles = []): string
|
||||
{
|
||||
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
|
||||
|
||||
@@ -175,18 +278,49 @@ final class XlsxWriter
|
||||
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
|
||||
}
|
||||
|
||||
// 合并区域的非首格不能带值(否则 Excel 打开会提示"修复"),
|
||||
// 但仍要写成「空值 + 带样式的格子」,否则合并区域的边框会在 Excel 里缺边。
|
||||
$swallowed = [];
|
||||
foreach ($merges as $m) {
|
||||
for ($r = $m[0]; $r <= $m[2]; $r++) {
|
||||
for ($c = $m[1]; $c <= $m[3]; $c++) {
|
||||
if ($r === $m[0] && $c === $m[1]) {
|
||||
continue;
|
||||
}
|
||||
$swallowed[$r][$c] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$data = '';
|
||||
for ($r = 1; $r <= $maxRow; $r++) {
|
||||
$baseStyle = $r <= $headerRows ? self::S_HEADER : self::S_TEXT;
|
||||
$line = '';
|
||||
$has = false;
|
||||
for ($c = 1; $c <= $maxCol; $c++) {
|
||||
if (!isset($cells[$r][$c])) {
|
||||
$sw = isset($swallowed[$r][$c]);
|
||||
if (!isset($cells[$r][$c]) && !$sw) {
|
||||
continue;
|
||||
}
|
||||
$has = true;
|
||||
$s = $r === 1 ? self::S_HEADER : self::S_TEXT;
|
||||
$line .= '<c r="' . self::colName($c) . $r . '" s="' . $s . '" t="s"><v>' .
|
||||
$cells[$r][$c] . '</v></c>';
|
||||
$ref = self::colName($c) . $r;
|
||||
// 单元格级标记(红字 / 绿底)优先;合并区间跟随左上角首格的样式,
|
||||
// 否则"合并成一格但只有左边半截有底色"会很难看。
|
||||
$mark = $cellStyles[$r . ',' . $c] ?? null;
|
||||
if ($mark === null && $sw) {
|
||||
foreach ($merges as $m) {
|
||||
if ($r >= $m[0] && $r <= $m[2] && $c >= $m[1] && $c <= $m[3]) {
|
||||
$mark = $cellStyles[$m[0] . ',' . $m[1]] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$style = ($mark !== null) ? (int)$mark : $baseStyle;
|
||||
if ($sw) {
|
||||
$line .= '<c r="' . $ref . '" s="' . $style . '"/>';
|
||||
} else {
|
||||
$line .= '<c r="' . $ref . '" s="' . $style . '" t="s"><v>' . $cells[$r][$c] . '</v></c>';
|
||||
}
|
||||
}
|
||||
if (!$has) {
|
||||
continue;
|
||||
@@ -194,6 +328,15 @@ final class XlsxWriter
|
||||
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
|
||||
}
|
||||
|
||||
$mergeXml = '';
|
||||
if ($merges !== []) {
|
||||
$mergeXml = '<mergeCells count="' . count($merges) . '">';
|
||||
foreach ($merges as $m) {
|
||||
$mergeXml .= '<mergeCell ref="' . self::colName($m[1]) . $m[0] . ':' . self::colName($m[3]) . $m[2] . '"/>';
|
||||
}
|
||||
$mergeXml .= '</mergeCells>';
|
||||
}
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||
'<dimension ref="' . $dim . '"/>' .
|
||||
@@ -201,6 +344,7 @@ final class XlsxWriter
|
||||
'<sheetFormatPr defaultRowHeight="13.5"/>' .
|
||||
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
|
||||
'<sheetData>' . $data . '</sheetData>' .
|
||||
$mergeXml .
|
||||
'</worksheet>';
|
||||
}
|
||||
|
||||
@@ -273,18 +417,38 @@ final class XlsxWriter
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||
// numFmts: 49 = @(文本)
|
||||
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
|
||||
'<fonts count="2">' .
|
||||
// 字体:0 常规 / 1 加粗(表头)/ 2 红色(最高分)/ 3 加粗绿字
|
||||
'<fonts count="3">' .
|
||||
'<font><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
|
||||
'<font><b/><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
|
||||
'<font><sz val="11"/><color rgb="FFD0021B"/><name val="宋体"/><charset val="134"/></font>' .
|
||||
'</fonts>' .
|
||||
'<fills count="2"><fill><patternFill patternType="none"/></fill>' .
|
||||
'<fill><patternFill patternType="gray125"/></fill></fills>' .
|
||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' .
|
||||
// 填充:0 none / 1 gray125(Excel 约定占位)/ 2 合格绿底 / 3 表头绿底
|
||||
// 绿色沿用导出截图里的浅绿 (#63C384 系):既醒目又不至于盖住黑色文字
|
||||
'<fills count="4">' .
|
||||
'<fill><patternFill patternType="none"/></fill>' .
|
||||
'<fill><patternFill patternType="gray125"/></fill>' .
|
||||
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
|
||||
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
|
||||
'</fills>' .
|
||||
// 0 = 无边框,1 = 细灰边框(表格统一描边,打印/截图都清晰)
|
||||
'<borders count="2">' .
|
||||
'<border><left/><right/><top/><bottom/><diagonal/></border>' .
|
||||
'<border>' .
|
||||
'<left style="thin"><color rgb="FF808080"/></left>' .
|
||||
'<right style="thin"><color rgb="FF808080"/></right>' .
|
||||
'<top style="thin"><color rgb="FF808080"/></top>' .
|
||||
'<bottom style="thin"><color rgb="FF808080"/></bottom>' .
|
||||
'<diagonal/></border>' .
|
||||
'</borders>' .
|
||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' .
|
||||
'<cellXfs count="3">' .
|
||||
'<cellXfs count="6">' .
|
||||
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' . // 0 常规
|
||||
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
|
||||
'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment vertical="center"/></xf>' . // 2 文本
|
||||
'<xf numFmtId="0" fontId="1" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
|
||||
'<xf numFmtId="49" fontId="0" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyBorder="1"><alignment vertical="center"/></xf>' . // 2 文本
|
||||
'<xf numFmtId="49" fontId="2" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 3 红字(最高分)
|
||||
'<xf numFmtId="49" fontId="0" fillId="2" borderId="1" xfId="0" applyNumberFormat="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 4 绿底(合格次数达标)
|
||||
'<xf numFmtId="0" fontId="1" fillId="3" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 5 表头绿底
|
||||
'</cellXfs>' .
|
||||
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
|
||||
'</styleSheet>';
|
||||
|
||||
+757
-13
@@ -1,11 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* 多考试对比导出:成绩交叉矩阵的纯逻辑(不依赖卷王客户端,便于单测)
|
||||
* 多考试导出:成绩对比矩阵 + 「每次考试」块表的纯逻辑(不依赖卷王客户端,便于单测)
|
||||
*
|
||||
* 矩阵约定(满足需求):
|
||||
* - 第一行 = 表头,列 A=学号、列 B=姓名、列 C 起每个选中的考试占一列,列标题为考试名称
|
||||
* - 数据行:列 A=学号、列 B=姓名、列 C 起为该生在该场考试里的最新/最高成绩(未考则为空)
|
||||
* - 默认按学号升序(数值优先,否则自然序)
|
||||
* 两种版式:
|
||||
* 1) 成绩对比(ny_assemble_multi_exam_matrix):一行一个学生、一列一场考试,只取最新 / 最高分
|
||||
* 2) 每次考试块表(ny_assemble_block_sheet):两行表头,一行一次考试,学号 / 姓名 / 班级 / 组别
|
||||
* 纵向合并单元格,没考的考试合并成「未考」一格(见需求截图版式)
|
||||
*
|
||||
* 说明:本文件刻意只用 PHP 7.0+ 语法(不用箭头函数 / 数字分隔符),
|
||||
* 便于在只有 PHP 7.x 的机器上直接跑回归测试。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
@@ -47,7 +50,7 @@ function ny_exam_cell_score(array $r, string $mode): ?float
|
||||
}
|
||||
|
||||
/**
|
||||
* 把多场考试已构建好的结果,组装成"成绩交叉对比"二维矩阵。
|
||||
* 把多场考试已构建好的结果,组装成"成绩对比"二维矩阵。
|
||||
*
|
||||
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序即考试展示顺序)
|
||||
* @param string $scoreMode 'latest' | 'highest'
|
||||
@@ -87,17 +90,16 @@ function ny_assemble_multi_exam_matrix(array $exams, string $scoreMode): array
|
||||
$sids = array_keys($sidSet);
|
||||
// 默认按学号排序(数值优先,否则自然序)
|
||||
usort($sids, static function ($a, $b): int {
|
||||
$a = (string)$a;
|
||||
$b = (string)$b;
|
||||
if (is_numeric($a) && is_numeric($b)) {
|
||||
return $a <=> $b;
|
||||
}
|
||||
return strnatcmp($a, $b);
|
||||
return ny_sid_cmp((string)$a, (string)$b);
|
||||
});
|
||||
|
||||
$head = array_merge(['学号', '姓名'], array_map(static fn($pid) => $examNames[$pid], $examOrder));
|
||||
$head = ['学号', '姓名'];
|
||||
foreach ($examOrder as $pid) {
|
||||
$head[] = $examNames[$pid];
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($sids as $sid) {
|
||||
$sid = (string)$sid;
|
||||
$line = [$sid, $nameBySid[$sid] ?? ''];
|
||||
foreach ($examOrder as $pid) {
|
||||
$sc = $scoreByExam[$pid][$sid] ?? null;
|
||||
@@ -107,3 +109,745 @@ function ny_assemble_multi_exam_matrix(array $exams, string $scoreMode): array
|
||||
}
|
||||
return ['head' => $head, 'rows' => $rows, 'exam_order' => $examOrder, 'exam_names' => $examNames];
|
||||
}
|
||||
|
||||
/* ==================== 排序 / 列显示规则(纯逻辑,可单测) ==================== */
|
||||
|
||||
/** 学号比较:纯数字按数值比,否则自然序 */
|
||||
function ny_sid_cmp(string $a, string $b): int
|
||||
{
|
||||
if (is_numeric($a) && is_numeric($b)) {
|
||||
return $a <=> $b;
|
||||
}
|
||||
return strnatcmp($a, $b);
|
||||
}
|
||||
|
||||
/** 文本比较:空值恒排末尾(班级 / 组别排序用,避免"未分班"插在中间) */
|
||||
function ny_text_cmp(string $a, string $b): int
|
||||
{
|
||||
if ($a === $b) {
|
||||
return 0;
|
||||
}
|
||||
if ($a === '') {
|
||||
return 1;
|
||||
}
|
||||
if ($b === '') {
|
||||
return -1;
|
||||
}
|
||||
return strnatcmp($a, $b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多考试导出「是否补出班级 / 组别列」的判定(按需求)。
|
||||
*
|
||||
* 规则:
|
||||
* - 选了 ≥2 个班级 → 出「班级」列;只选 1 个班级 → 不出(全班一样,纯噪音)
|
||||
* - 选了 ≥2 个组别 → 出「组别」列;只选 1 个组别 → 不出
|
||||
* - 未选班级(= 全部)时按实际数据判断:数据确实跨多个班级才出列
|
||||
* - 「只选了 1 个班级且未选组别」→ 班级 / 组别两列都不出
|
||||
*
|
||||
* @param array $clsPick 已选班级([] = 全部)
|
||||
* @param array $grpPick 已选组别([] = 全部)
|
||||
* @param array $seenCls 实际数据里出现的班级
|
||||
* @param array $seenGrp 实际数据里出现的组别
|
||||
* @return array{with_cls:bool,with_grp:bool}
|
||||
*/
|
||||
function ny_multi_col_flags(array $clsPick, array $grpPick, array $seenCls, array $seenGrp): array
|
||||
{
|
||||
$nCls = count($clsPick);
|
||||
$nGrp = count($grpPick);
|
||||
$withCls = ($nCls > 1) || ($nCls === 0 && count($seenCls) > 1);
|
||||
$withGrp = ($nGrp > 1) || ($nGrp === 0 && $nCls !== 1 && count($seenGrp) > 1);
|
||||
return ['with_cls' => $withCls, 'with_grp' => $withGrp];
|
||||
}
|
||||
|
||||
/* ==================== 「每次考试」块表(两行表头 + 合并单元格) ==================== */
|
||||
|
||||
/**
|
||||
* 取某个学生在某场考试里的「每次考试」列表(按提交时间升序 = 第1次、第2次…)。
|
||||
*
|
||||
* - skipTemp 时草稿不计入、也不占"第几次"编号(导出后 1..N 连续)
|
||||
* - `is_best` 在**被导出的那些次**里比较(跳过草稿时,草稿分不再抢走「是否最佳」标记,
|
||||
* 否则会出现整场没有一次被标「是」的怪表);同分只标最早达成的那次
|
||||
* - 保留草稿时草稿条目的 `draft` 为 true,调用方可据此标注「(草稿)」
|
||||
*
|
||||
* @return array<int,array{idx:int,score:string,ts:int,is_best:bool,draft:bool}>
|
||||
*/
|
||||
function ny_exam_attempt_list(array $r, bool $skipTemp = false): array
|
||||
{
|
||||
$list = array_values((array)($r['attempts_list'] ?? []));
|
||||
if ($list === []) {
|
||||
// 无明细:代表行本身算一次考试
|
||||
$list = [[
|
||||
'idx' => 1,
|
||||
'score' => $r['score'] ?? null,
|
||||
'submit_ts' => (int)($r['submit_ts'] ?? 0),
|
||||
'temp_save' => false,
|
||||
]];
|
||||
}
|
||||
// 统一按提交时间升序(旧 -> 新)
|
||||
usort($list, static function (array $x, array $y): int {
|
||||
return ((int)($x['submit_ts'] ?? 0)) <=> ((int)($y['submit_ts'] ?? 0));
|
||||
});
|
||||
|
||||
// 先过滤,再在被导出的那些次里找最高分
|
||||
$kept = [];
|
||||
foreach ($list as $a) {
|
||||
if ($skipTemp && !empty($a['temp_save'])) {
|
||||
continue;
|
||||
}
|
||||
$kept[] = $a;
|
||||
}
|
||||
$bestVal = null;
|
||||
$bestAtIdx = -1;
|
||||
foreach ($kept as $i => $a) {
|
||||
$v = $a['score'] ?? null;
|
||||
if ($v === null || $v === '') {
|
||||
continue;
|
||||
}
|
||||
if ($bestVal === null || (float)$v > $bestVal) {
|
||||
$bestVal = (float)$v;
|
||||
$bestAtIdx = $i;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($kept as $i => $a) {
|
||||
$score = $a['score'] ?? null;
|
||||
$out[] = [
|
||||
'idx' => $i + 1,
|
||||
'score' => ($score === null || $score === '') ? '' : (string)$score,
|
||||
'ts' => (int)($a['submit_ts'] ?? 0),
|
||||
'is_best' => ($i === $bestAtIdx),
|
||||
'draft' => !empty($a['temp_save']),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装「每次考试」块表(两行表头 / 合并单元格 / 未考占位)——与需求截图一致。
|
||||
*
|
||||
* 版式(对应需求截图):
|
||||
* ┌──────┬──────┬────────┬────────────────┬────────────────┐
|
||||
* │ 学号 │ 姓名 │ 第几次 │ 考试名称1 │ 考试名称2 │ ← 第 1 行:考试名横向合并 2 列
|
||||
* ├──────┼──────┼────────┼────────┬───────┼────────┬───────┤
|
||||
* │ │ │ │ 成绩 │ 成绩 │ 成绩 │ 成绩 │ ← 第 2 行(第几次纵向贯穿两行)
|
||||
* ├──────┼──────┼────────┼────────┼───────┼────────┼───────┤
|
||||
* │24030…│ 张三 │ 1 │ 127 │ 127 │ 134 │ 134 │
|
||||
* │(合并)│(合并)│ 2 │ 132 │ 132 │ 136 │ 136 │ ← 最高分用红字
|
||||
* │ │ │ … │ … │ … │ │ │
|
||||
* │ │ │合格次数│ 2 │ 1 │ 2 │ 2 │ ← 达标绿底、未达标空白
|
||||
* └──────┴──────┴────────┴────────┴───────┴────────┴───────┘
|
||||
*
|
||||
* 约定:
|
||||
* - 一名学生占 max(各场有效次数) + 1 行(+1 是末尾的「合格次数」汇总行)
|
||||
* - 学号 / 姓名 / 第几次 三列按学生块纵向合并(第几次与学号同高,贯穿表头以下全部行)
|
||||
* - 每场考试占 2 列:考试名称(第 1 行横向合并)+ 成绩(仅第 2 行有「成绩」小标题)
|
||||
* - 只在该生某场考试完全没考时写「未考」并合并该场全部列;考了但次数少 → 留空
|
||||
* - 「合格次数」= 该场成绩 ≥ 合格线的次数;始终显示(含 0 次),只有 ≥ 阈值才标绿底
|
||||
* - 每场成绩等于该场最高分的那次用红字标出(同分只标最早达成的一次)
|
||||
*
|
||||
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序 = 考试列顺序)
|
||||
* @param array $opts [
|
||||
* 'skip_temp' => bool, 'with_cls' => bool, 'with_grp' => bool, 'absent_label' => string,
|
||||
* 'pass_line' => float|null, 合格线分数(null = 不判定合格,不出现合格次数行)
|
||||
* 'pass_times' => int, 至少合格几次才算通过(默认 1)
|
||||
* 'mark_best' => bool, 是否把每场最高分标红(默认 true)
|
||||
* 'pass_row' => bool, 是否输出「合格次数」汇总行(默认 true,且需 pass_line 有效)
|
||||
* ]
|
||||
* @return array{rows:array,merges:array,widths:array,header_rows:int,exam_order:array,exam_names:array,exam_col:array,cell_styles:array,students:int,total_rows:int,with_cls:bool,with_grp:bool,pass_row:bool,cols:array}
|
||||
*/
|
||||
function ny_assemble_block_sheet(array $exams, array $opts = []): array
|
||||
{
|
||||
// 默认跳过暂存草稿(草稿不是有效成绩);显式传 skip_temp=false 可保留
|
||||
$skipTemp = array_key_exists('skip_temp', $opts) ? !empty($opts['skip_temp']) : true;
|
||||
$withCls = !empty($opts['with_cls']);
|
||||
$withGrp = !empty($opts['with_grp']);
|
||||
$absent = (string)($opts['absent_label'] ?? '未考');
|
||||
$markBest = array_key_exists('mark_best', $opts) ? !empty($opts['mark_best']) : true;
|
||||
|
||||
// 合格线:null / 非正数 => 不判定合格(不输出合格次数行)
|
||||
$passLine = $opts['pass_line'] ?? null;
|
||||
$passLine = ($passLine === null || $passLine === '') ? null : (float)$passLine;
|
||||
if ($passLine !== null && $passLine <= 0) {
|
||||
$passLine = null;
|
||||
}
|
||||
$passTimes = (int)($opts['pass_times'] ?? 1);
|
||||
if ($passTimes < 1) {
|
||||
$passTimes = 1;
|
||||
}
|
||||
$wantPassRow = array_key_exists('pass_row', $opts) ? !empty($opts['pass_row']) : true;
|
||||
$passRow = ($wantPassRow && $passLine !== null);
|
||||
|
||||
$examOrder = [];
|
||||
$examNames = [];
|
||||
$stu = []; // sid => ['name','cls','grp','exams' => [pid => 每次考试列表]]
|
||||
|
||||
foreach ($exams as $pid => $ex) {
|
||||
$examOrder[] = $pid;
|
||||
$examNames[$pid] = trim((string)($ex['name'] ?? '')) !== '' ? (string)$ex['name'] : (string)$pid;
|
||||
foreach ((array)($ex['rows'] ?? []) as $r) {
|
||||
$sid = trim((string)($r['sid'] ?? ''));
|
||||
if ($sid === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($stu[$sid])) {
|
||||
$stu[$sid] = ['name' => '', 'cls' => '', 'grp' => '', 'exams' => []];
|
||||
}
|
||||
// 同一学生在多场考试里字段一致,取首个非空即可
|
||||
if ($stu[$sid]['name'] === '' && trim((string)($r['name'] ?? '')) !== '') {
|
||||
$stu[$sid]['name'] = (string)$r['name'];
|
||||
}
|
||||
if ($stu[$sid]['cls'] === '' && trim((string)($r['cls'] ?? '')) !== '') {
|
||||
$stu[$sid]['cls'] = (string)$r['cls'];
|
||||
}
|
||||
if ($stu[$sid]['grp'] === '' && trim((string)($r['grp'] ?? '')) !== '') {
|
||||
$stu[$sid]['grp'] = (string)$r['grp'];
|
||||
}
|
||||
$stu[$sid]['exams'][$pid] = ny_exam_attempt_list($r, $skipTemp);
|
||||
}
|
||||
}
|
||||
|
||||
// 学生顺序:有班级 / 组别列时按 班级 → 组别 → 学号(同班同组自然相邻,合并单元格才不会断开)
|
||||
$sids = array_keys($stu);
|
||||
usort($sids, static function ($a, $b) use ($stu, $withCls, $withGrp): int {
|
||||
$a = (string)$a; // PHP 会把纯数字学号键转成 int,这里统一回字符串
|
||||
$b = (string)$b;
|
||||
if ($withCls) {
|
||||
$d = ny_text_cmp($stu[$a]['cls'], $stu[$b]['cls']);
|
||||
if ($d !== 0) {
|
||||
return $d;
|
||||
}
|
||||
}
|
||||
if ($withGrp) {
|
||||
$d = ny_text_cmp($stu[$a]['grp'], $stu[$b]['grp']);
|
||||
if ($d !== 0) {
|
||||
return $d;
|
||||
}
|
||||
}
|
||||
return ny_sid_cmp($a, $b);
|
||||
});
|
||||
|
||||
/* ---------------- 列布局 ----------------
|
||||
前导列:班级? / 组别? / 学号 / 姓名 / 第几次(各考试共用一列,纵向合并到学生块)
|
||||
考试区:每场 2 列(考试名称跨度由第 1 行横向合并,第 2 行只有「成绩」小标题) */
|
||||
$colCls = $withCls ? 1 : 0;
|
||||
$colGrp = $withGrp ? ($withCls ? 2 : 1) : 0;
|
||||
$colSid = 1 + ($withCls ? 1 : 0) + ($withGrp ? 1 : 0);
|
||||
$colName = $colSid + 1;
|
||||
$colIdx = $colName + 1; // 「第几次」(所有考试共用)
|
||||
$colExam0 = $colIdx + 1;
|
||||
|
||||
$h1 = [];
|
||||
$h2 = [];
|
||||
if ($withCls) { $h1[] = '班级'; $h2[] = ''; }
|
||||
if ($withGrp) { $h1[] = '组别'; $h2[] = ''; }
|
||||
$h1[] = '学号';
|
||||
$h2[] = '';
|
||||
$h1[] = '姓名';
|
||||
$h2[] = '';
|
||||
$h1[] = '第几次';
|
||||
$h2[] = '';
|
||||
|
||||
$merges = [];
|
||||
// 表头两行的纵向合并:班级 / 组别 / 学号 / 姓名 / 第几次
|
||||
foreach ([$colCls, $colGrp, $colSid, $colName, $colIdx] as $c) {
|
||||
if ($c > 0) {
|
||||
$merges[] = [1, $c, 2, $c];
|
||||
}
|
||||
}
|
||||
|
||||
// 每场考试占 2 列:考试名称(第 1 行横向合并)+ 成绩(第 2 行小标题)
|
||||
$examCol = [];
|
||||
$c = $colExam0;
|
||||
foreach ($examOrder as $pid) {
|
||||
$examCol[$pid] = $c;
|
||||
$h1[] = $examNames[$pid];
|
||||
$h1[] = '';
|
||||
$h2[] = '成绩';
|
||||
$h2[] = '';
|
||||
$merges[] = [1, $c, 1, $c + 1];
|
||||
$c += 2;
|
||||
}
|
||||
$colPass = $colExam0 + 2 * count($examOrder); // 「合格次数」所在列(若启用)
|
||||
|
||||
$rows = [$h1, $h2];
|
||||
$rn = 2; // 当前行号(两行表头已占 1~2)
|
||||
$total = 0;
|
||||
$blocks = []; // 学生块:['start','end','cls','grp']
|
||||
|
||||
// 单元格标记:'行,列' => 'red' | 'green' | 'header_green'
|
||||
$marks = [];
|
||||
|
||||
foreach ($sids as $sid) {
|
||||
$sid = (string)$sid;
|
||||
$s = $stu[$sid];
|
||||
|
||||
$lists = [];
|
||||
$maxN = 0;
|
||||
foreach ($examOrder as $pid) {
|
||||
$l = $s['exams'][$pid] ?? [];
|
||||
$lists[$pid] = $l;
|
||||
if (count($l) > $maxN) {
|
||||
$maxN = count($l);
|
||||
}
|
||||
}
|
||||
if ($maxN < 1) {
|
||||
continue; // 该生所有考试都没有有效提交(例如只有草稿被跳过)
|
||||
}
|
||||
|
||||
// 每场最高分(用于标红;只在被导出的那些次里比较,与列表口径一致)
|
||||
$bestScore = [];
|
||||
foreach ($examOrder as $pid) {
|
||||
$bv = null;
|
||||
foreach ($lists[$pid] as $a) {
|
||||
$sv = $a['score'];
|
||||
if ($sv === '' || $sv === null) {
|
||||
continue;
|
||||
}
|
||||
if ($bv === null || (float)$sv > $bv) {
|
||||
$bv = (float)$sv;
|
||||
}
|
||||
}
|
||||
$bestScore[$pid] = $bv;
|
||||
}
|
||||
|
||||
$start = $rn + 1;
|
||||
for ($i = 0; $i < $maxN; $i++) {
|
||||
$rn++;
|
||||
$line = [];
|
||||
if ($withCls) {
|
||||
$line[] = ($i === 0) ? $s['cls'] : '';
|
||||
}
|
||||
if ($withGrp) {
|
||||
$line[] = ($i === 0) ? $s['grp'] : '';
|
||||
}
|
||||
$line[] = ($i === 0) ? $sid : '';
|
||||
$line[] = ($i === 0) ? $s['name'] : '';
|
||||
$line[] = (string)($i + 1); // 第几次:所有考试共用一列
|
||||
foreach ($examOrder as $pid) {
|
||||
$a = $lists[$pid][$i] ?? null;
|
||||
if ($a === null) {
|
||||
$line[] = '';
|
||||
$line[] = '';
|
||||
continue;
|
||||
}
|
||||
$line[] = $a['score'];
|
||||
$line[] = ''; // 每场固定 2 列:第 1 列成绩、第 2 列是合并区间内的空位。
|
||||
// 漏掉这个空串会让后续考试整体左移一列——数据串列、
|
||||
// 表头合并区间与实际列对不上(截图里的错位就是它)。
|
||||
// 最高分标红:只标一处(同分只标最早达成的那一行)
|
||||
if ($markBest && $a['score'] !== '' && $bestScore[$pid] !== null
|
||||
&& (float)$a['score'] === $bestScore[$pid] && $a['is_best']) {
|
||||
$marks[$rn . ',' . $examCol[$pid]] = 'red';
|
||||
}
|
||||
}
|
||||
$rows[] = $line;
|
||||
$total++;
|
||||
}
|
||||
$end = $rn;
|
||||
|
||||
if ($end > $start) {
|
||||
$merges[] = [$start, $colSid, $end, $colSid];
|
||||
$merges[] = [$start, $colName, $end, $colName];
|
||||
$merges[] = [$start, $colIdx, $end, $colIdx];
|
||||
}
|
||||
// 完全没考的考试:该场全部列 × 学生块高 → 合并成「未考」
|
||||
foreach ($examOrder as $pid) {
|
||||
if ($lists[$pid] !== []) {
|
||||
continue;
|
||||
}
|
||||
$cc = $examCol[$pid];
|
||||
$rows[$start - 1][$cc - 1] = $absent;
|
||||
$merges[] = [$start, $cc, $end, $cc + 1];
|
||||
}
|
||||
|
||||
$blocks[] = ['start' => $start, 'end' => $end, 'cls' => $s['cls'], 'grp' => $s['grp']];
|
||||
}
|
||||
|
||||
/* ---------------- 末尾「合格次数」汇总行 ----------------
|
||||
每个学生块末尾追加一行「合格次数」:每场考试列出该生达到合格线的次数;
|
||||
次数始终显示(含 0 次),达到阈值(默认 1 次)才标绿底。
|
||||
|
||||
追加行会改变所有行号,因此这里不走"插入",而是带着合格次数行整体重建一遍
|
||||
(学生块高 = maxN + 1),保证合并区间 / 标红位置的坐标一次算对。 */
|
||||
if ($passRow) {
|
||||
$rows = [$h1, $h2];
|
||||
$merges = [];
|
||||
$marks = [];
|
||||
$rn = 2;
|
||||
$total = 0;
|
||||
$newBlocks = [];
|
||||
foreach ([$colCls, $colGrp, $colSid, $colName, $colIdx] as $cc) {
|
||||
if ($cc > 0) {
|
||||
$merges[] = [1, $cc, 2, $cc];
|
||||
}
|
||||
}
|
||||
$cc = $colExam0;
|
||||
foreach ($examOrder as $pid) {
|
||||
$merges[] = [1, $cc, 1, $cc + 1];
|
||||
$cc += 2;
|
||||
}
|
||||
|
||||
foreach ($sids as $sid) {
|
||||
$sid = (string)$sid;
|
||||
$s = $stu[$sid];
|
||||
$lists = [];
|
||||
$maxN = 0;
|
||||
foreach ($examOrder as $pid) {
|
||||
$l = $s['exams'][$pid] ?? [];
|
||||
$lists[$pid] = $l;
|
||||
if (count($l) > $maxN) {
|
||||
$maxN = count($l);
|
||||
}
|
||||
}
|
||||
if ($maxN < 1) {
|
||||
continue;
|
||||
}
|
||||
$bestScore = [];
|
||||
foreach ($examOrder as $pid) {
|
||||
$bv = null;
|
||||
foreach ($lists[$pid] as $a) {
|
||||
$sv = $a['score'];
|
||||
if ($sv === '' || $sv === null) {
|
||||
continue;
|
||||
}
|
||||
if ($bv === null || (float)$sv > $bv) {
|
||||
$bv = (float)$sv;
|
||||
}
|
||||
}
|
||||
$bestScore[$pid] = $bv;
|
||||
}
|
||||
|
||||
$start = $rn + 1;
|
||||
for ($i = 0; $i < $maxN; $i++) {
|
||||
$rn++;
|
||||
$line = [];
|
||||
if ($withCls) { $line[] = ($i === 0) ? $s['cls'] : ''; }
|
||||
if ($withGrp) { $line[] = ($i === 0) ? $s['grp'] : ''; }
|
||||
$line[] = ($i === 0) ? $sid : '';
|
||||
$line[] = ($i === 0) ? $s['name'] : '';
|
||||
$line[] = (string)($i + 1); // 第几次:所有考试共用一列
|
||||
foreach ($examOrder as $pid) {
|
||||
$a = $lists[$pid][$i] ?? null;
|
||||
if ($a === null) {
|
||||
$line[] = '';
|
||||
$line[] = '';
|
||||
continue;
|
||||
}
|
||||
$line[] = $a['score'];
|
||||
$line[] = ''; // 同上:每场固定 2 列,缺了这个空串会整体左移
|
||||
if ($markBest && $a['score'] !== '' && $bestScore[$pid] !== null
|
||||
&& (float)$a['score'] === $bestScore[$pid] && $a['is_best']) {
|
||||
$marks[$rn . ',' . $examCol[$pid]] = 'red';
|
||||
}
|
||||
}
|
||||
$rows[] = $line;
|
||||
$total++;
|
||||
}
|
||||
|
||||
// 完全没考的考试:该场全部列 × 学生块高 → 合并成「未考」
|
||||
foreach ($examOrder as $pid) {
|
||||
if ($lists[$pid] !== []) {
|
||||
continue;
|
||||
}
|
||||
$cx = $examCol[$pid];
|
||||
$rows[$start - 1][$cx - 1] = $absent;
|
||||
$merges[] = [$start, $cx, $rn, $cx + 1];
|
||||
}
|
||||
|
||||
// 合格次数行(每个学生块一行)
|
||||
$rn++;
|
||||
$passLineRow = [];
|
||||
if ($withCls) { $passLineRow[] = ''; }
|
||||
if ($withGrp) { $passLineRow[] = ''; }
|
||||
$passLineRow[] = '';
|
||||
$passLineRow[] = '';
|
||||
$passLineRow[] = '合格次数'; // 落在「第几次」列,与截图一致
|
||||
foreach ($examOrder as $pid) {
|
||||
$list = $lists[$pid];
|
||||
if ($list === []) {
|
||||
// 整场未考:与「未考」合并格保持一致,不再单独判断合格次数
|
||||
$passLineRow[] = '';
|
||||
$passLineRow[] = '';
|
||||
continue;
|
||||
}
|
||||
$cnt = 0;
|
||||
foreach ($list as $a) {
|
||||
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
|
||||
$cnt++;
|
||||
}
|
||||
}
|
||||
/* 合格次数始终显示(含 0 次);只有达到阈值才标绿底。
|
||||
之前写 '' 会把「考了但没达标」的人整格隐藏,用户要看全部人次。 */
|
||||
$passLineRow[] = (string)$cnt;
|
||||
$passLineRow[] = '';
|
||||
if ($cnt >= $passTimes) {
|
||||
$marks[$rn . ',' . $examCol[$pid]] = 'green'; // 达标:绿底
|
||||
}
|
||||
}
|
||||
$rows[] = $passLineRow;
|
||||
$total++;
|
||||
|
||||
/* 学号 / 姓名 纵向合并到「合格次数」行(这两列该行本就是空的,合并正确);
|
||||
「第几次」只合并到最后一次考试行,末行留给「合格次数」标签——
|
||||
若连合格次数行一起合并,该格会被当作"被合并的非首格"而丢掉值。 */
|
||||
$merges[] = [$start, $colSid, $rn, $colSid];
|
||||
$merges[] = [$start, $colName, $rn, $colName];
|
||||
if ($rn > $start) {
|
||||
$merges[] = [$start, $colIdx, $rn - 1, $colIdx];
|
||||
}
|
||||
$newBlocks[] = ['start' => $start, 'end' => $rn, 'cls' => $s['cls'], 'grp' => $s['grp']];
|
||||
}
|
||||
$blocks = $newBlocks;
|
||||
}
|
||||
|
||||
// 班级 / 组别:连续相同值的相邻学生块合并成一格
|
||||
foreach ([['cls', $colCls], ['grp', $colGrp]] as $spec) {
|
||||
$key = $spec[0];
|
||||
$col = (int)$spec[1];
|
||||
if ($col < 1) {
|
||||
continue;
|
||||
}
|
||||
$n = count($blocks);
|
||||
$i = 0;
|
||||
while ($i < $n) {
|
||||
$val = $blocks[$i][$key];
|
||||
$j = $i;
|
||||
while ($j + 1 < $n && $blocks[$j + 1][$key] === $val) {
|
||||
$j++;
|
||||
}
|
||||
if ($val !== '' && $j > $i) {
|
||||
$merges[] = [$blocks[$i]['start'], $col, $blocks[$j]['end'], $col];
|
||||
}
|
||||
$i = $j + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 合并区间去重(重复区间会让 Excel 打开时提示"修复")
|
||||
$seen = [];
|
||||
$uniq = [];
|
||||
foreach ($merges as $m) {
|
||||
$k = $m[0] . ',' . $m[1] . ',' . $m[2] . ',' . $m[3];
|
||||
if (isset($seen[$k])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$uniq[] = $m;
|
||||
}
|
||||
$merges = $uniq;
|
||||
|
||||
/* 列宽:够用就好,宽了会把人挤到屏幕外(截图里学号列两侧大片空白就是这么来的)。
|
||||
- 学号按实际内容估宽(10 位数字约 11,超过 12 位才放宽),不再一律 20
|
||||
- 姓名同样按最长姓名估宽
|
||||
- 每场考试占 2 列,但第 2 列恒为合并区间内的空位 → 给最小宽度 2.5,
|
||||
把宽度让给真正承载成绩的第 1 列(否则一屏只看得到两场考试) */
|
||||
$maxSidLen = 2;
|
||||
$maxNameLen = 2;
|
||||
foreach ($stu as $s) {
|
||||
// 名单里可能缺 sid / name 的条目(脏数据):估宽只能兜住,不能因此报 Undefined key
|
||||
$sidLen = mb_strlen((string)($s['sid'] ?? ''), 'UTF-8');
|
||||
$nameLen = mb_strlen((string)($s['name'] ?? ''), 'UTF-8');
|
||||
if ($sidLen > $maxSidLen) {
|
||||
$maxSidLen = $sidLen;
|
||||
}
|
||||
if ($nameLen > $maxNameLen) {
|
||||
$maxNameLen = $nameLen;
|
||||
}
|
||||
}
|
||||
// Excel 列宽单位≈半角字符数;中文按 2 个半角宽算,再留 2 的余量
|
||||
$sidW = min(18, max(10, $maxSidLen + 2));
|
||||
$nameW = min(20, max(9, $maxNameLen * 2 + 2));
|
||||
|
||||
$widths = [];
|
||||
if ($withCls) {
|
||||
$widths[] = 14; // 班级名通常较长(如「计算机2401」)
|
||||
}
|
||||
if ($withGrp) {
|
||||
$widths[] = 9;
|
||||
}
|
||||
$widths[] = $sidW; // 学号
|
||||
$widths[] = $nameW; // 姓名
|
||||
$widths[] = 9; // 第几次
|
||||
foreach ($examOrder as $pid) {
|
||||
$widths[] = 10; // 成绩列(每场第 1 列)
|
||||
$widths[] = 2.5; // 合并区内的空列,给最小宽度即可
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'merges' => $merges,
|
||||
'widths' => $widths,
|
||||
'header_rows' => 2,
|
||||
'exam_order' => $examOrder,
|
||||
'exam_names' => $examNames,
|
||||
'exam_col' => $examCol,
|
||||
'cell_styles' => $marks,
|
||||
'students' => count($blocks),
|
||||
'total_rows' => $total,
|
||||
'with_cls' => $withCls,
|
||||
'with_grp' => $withGrp,
|
||||
'pass_row' => $passRow,
|
||||
'pass_line' => $passLine,
|
||||
'pass_times' => $passTimes,
|
||||
'cols' => [
|
||||
'cls' => $colCls, 'grp' => $colGrp, 'sid' => $colSid,
|
||||
'name' => $colName, 'idx' => $colIdx, 'exam0' => $colExam0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装「单场考试的每次考试」宽表——对应需求截图二。
|
||||
*
|
||||
* 版式:
|
||||
* ┌──────┬──────┬──────┬──────┬─────┬────────┐
|
||||
* │ 学号 │ 姓名 │ 第1次│ 第2次│ ... │合格次数│
|
||||
* ├──────┼──────┼──────┼──────┼─────┼────────┤
|
||||
* │24030…│ 张三 │ 127 │ 132 │ 143 │ 2 │ ← 最高分红字、合格次数达标绿底
|
||||
* └──────┴──────┴──────┴──────┴─────┴────────┘
|
||||
*
|
||||
* 每人一行;列数 = 2 + 该场最多考试次数 + 1(合格次数)。
|
||||
* 某生次数少于最大值时留空。仅在"不带考试耗时 / 提交时间"时使用本版式,
|
||||
* 带了耗时 / 时间就会失去宽表的意义(每人一行放不下每次的耗时),此时退回明细表。
|
||||
*
|
||||
* @param array $exam 单场考试 ['name'=>string, 'rows'=>array]
|
||||
* @param array $opts ['skip_temp'=>bool, 'pass_line'=>float|null, 'pass_times'=>int, 'mark_best'=>bool, 'pass_row'=>bool]
|
||||
* @return array{rows:array,merges:array,widths:array,header_rows:int,cell_styles:array,students:int,total_rows:int,attempt_cols:int,pass_row:bool}
|
||||
*/
|
||||
function ny_assemble_attempt_wide_sheet(array $exam, array $opts = []): array
|
||||
{
|
||||
$skipTemp = array_key_exists('skip_temp', $opts) ? !empty($opts['skip_temp']) : true;
|
||||
$markBest = array_key_exists('mark_best', $opts) ? !empty($opts['mark_best']) : true;
|
||||
$passLine = $opts['pass_line'] ?? null;
|
||||
$passLine = ($passLine === null || $passLine === '') ? null : (float)$passLine;
|
||||
if ($passLine !== null && $passLine <= 0) {
|
||||
$passLine = null;
|
||||
}
|
||||
$passTimes = max(1, (int)($opts['pass_times'] ?? 1));
|
||||
$wantPassRow = array_key_exists('pass_row', $opts) ? !empty($opts['pass_row']) : true;
|
||||
$passRow = ($wantPassRow && $passLine !== null);
|
||||
|
||||
$stu = [];
|
||||
foreach ((array)($exam['rows'] ?? []) as $r) {
|
||||
$sid = trim((string)($r['sid'] ?? ''));
|
||||
if ($sid === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($stu[$sid])) {
|
||||
$stu[$sid] = [
|
||||
'sid' => $sid,
|
||||
'name' => (string)($r['name'] ?? ''),
|
||||
'cls' => (string)($r['cls'] ?? ''),
|
||||
'grp' => (string)($r['grp'] ?? ''),
|
||||
'list' => ny_exam_attempt_list($r, $skipTemp),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$sids = array_keys($stu);
|
||||
usort($sids, static function ($a, $b) use ($stu): int {
|
||||
$a = (string)$a;
|
||||
$b = (string)$b;
|
||||
$d = ny_text_cmp($stu[$a]['cls'], $stu[$b]['cls']);
|
||||
if ($d !== 0) {
|
||||
return $d;
|
||||
}
|
||||
$d = ny_text_cmp($stu[$a]['grp'], $stu[$b]['grp']);
|
||||
if ($d !== 0) {
|
||||
return $d;
|
||||
}
|
||||
return ny_sid_cmp($a, $b);
|
||||
});
|
||||
|
||||
$maxN = 0;
|
||||
foreach ($sids as $sid) {
|
||||
$n = count($stu[$sid]['list']);
|
||||
if ($n > $maxN) {
|
||||
$maxN = $n;
|
||||
}
|
||||
}
|
||||
if ($maxN < 1) {
|
||||
$maxN = 1;
|
||||
}
|
||||
|
||||
$head = ['学号', '姓名'];
|
||||
for ($i = 1; $i <= $maxN; $i++) {
|
||||
$head[] = '第' . $i . '次';
|
||||
}
|
||||
if ($passRow) {
|
||||
$head[] = '合格次数';
|
||||
}
|
||||
|
||||
$rows = [$head];
|
||||
$marks = [];
|
||||
$rn = 1;
|
||||
$colPass = 2 + $maxN + 1; // 合格次数列(1-based)
|
||||
|
||||
foreach ($sids as $sid) {
|
||||
$sid = (string)$sid;
|
||||
$s = $stu[$sid];
|
||||
$list = $s['list'];
|
||||
$rn++;
|
||||
|
||||
$line = [$sid, $s['name']];
|
||||
$best = null;
|
||||
foreach ($list as $a) {
|
||||
if ($a['score'] === '' || $a['score'] === null) {
|
||||
continue;
|
||||
}
|
||||
$v = (float)$a['score'];
|
||||
if ($best === null || $v > $best) {
|
||||
$best = $v;
|
||||
}
|
||||
}
|
||||
for ($i = 0; $i < $maxN; $i++) {
|
||||
$a = $list[$i] ?? null;
|
||||
if ($a === null) {
|
||||
$line[] = '';
|
||||
continue;
|
||||
}
|
||||
$line[] = $a['score'];
|
||||
if ($markBest && $a['score'] !== '' && $best !== null
|
||||
&& (float)$a['score'] === $best && $a['is_best']) {
|
||||
$marks[$rn . ',' . (3 + $i)] = 'red'; // 第1次在第 3 列
|
||||
}
|
||||
}
|
||||
if ($passRow) {
|
||||
$cnt = 0;
|
||||
foreach ($list as $a) {
|
||||
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
|
||||
$cnt++;
|
||||
}
|
||||
}
|
||||
// 合格次数始终显示(含 0 次);只有达到阈值才标绿底
|
||||
$line[] = (string)$cnt;
|
||||
if ($cnt >= $passTimes) {
|
||||
$marks[$rn . ',' . $colPass] = 'green';
|
||||
}
|
||||
}
|
||||
$rows[] = $line;
|
||||
}
|
||||
|
||||
$widths = [20, 14];
|
||||
for ($i = 0; $i < $maxN; $i++) {
|
||||
$widths[] = 9;
|
||||
}
|
||||
if ($passRow) {
|
||||
$widths[] = 11;
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'merges' => [],
|
||||
'widths' => $widths,
|
||||
'header_rows' => 1,
|
||||
'cell_styles' => $marks,
|
||||
'students' => count($sids),
|
||||
'total_rows' => count($sids),
|
||||
'attempt_cols' => $maxN,
|
||||
'pass_row' => $passRow,
|
||||
'pass_line' => $passLine,
|
||||
'pass_times' => $passTimes,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user