多考试块表:每场考试改为单列并修正第几次列合并

- ny_assemble_block_sheet() 中每场考试由 2 列改为 1 列,删除历史遗留的
  「是否最佳」空白列,消除每场右侧多余空白窄列
- 删除「第几次」列的纵向合并,改为逐行显示 1/2/3…,不再被吞成 1
- 合格次数行同步改为每场 1 列;未考合并加 $end > $start 跨行守卫,
  规避单行学生产生的非法 1x1 合并(Excel 修复提示)
- 列宽按考试名估算(CJK 记 2 半角宽),clamp 8~18
- 同步更新 test-multi-export.php(95 项含 4 条防回归断言)、
  make-samples.php(李四数学改为真正整场未考)、导出/接口/测试文档
  及样例预览文件;全量回归 404 项 0 失败
This commit is contained in:
2026-10-06 12:22:16 +08:00
parent d05fd4a9b5
commit e87986fcc6
11 changed files with 161 additions and 205 deletions
+51 -51
View File
@@ -228,22 +228,24 @@ function ny_exam_attempt_list(array $r, bool $skipTemp = false): array
* 组装「每次考试」块表(两行表头 / 合并单元格 / 未考占位)——与需求截图一致。
*
* 版式(对应需求截图):
* ┌──────┬──────┬────────┬────────────────┬────────────────┐
* │ 学号 │ 姓名 │ 第几次 │ 考试名称1 │ 考试名称2 │ ← 第 1 行:考试名横向合并 2 列
* ├──────┼──────┼────────┼────────┬───────┼────────┬───────┤
* │ │ │ │ 成绩 │ 成绩 │ 成绩 │ 成绩 │ ← 第 2 行(第几次纵向贯穿两行)
* ├──────┼──────┼────────┼────────┼───────┼────────┼───────┤
* │24030…│ 张三 │ 1 │ 127 │ 127 │ 134 │ 134 │
* │(合并)│(合并)│ 2 │ 132 │ 132 │ 136 │ 136 │ ← 最高分用红字
* │ │ │ … │ … │ … │ │ │
* │ │ │合格次数│ 2 │ 1 │ 2 │ 2 │ ← 达标绿底、未达标空白
* └──────┴──────┴────────┴────────┴───────┴────────┴───────┘
* ┌──────┬──────┬────────┬────────┬────────┬────────┐
* │ 学号 │ 姓名 │ 第几次 │考试名1 │考试名2 │考试名3 │ ← 第 1 行:考试名
* ├──────┼──────┼────────┼────────┼────────┼────────┤
* │ │ │ │ 成绩 │ 成绩 │ 成绩 │ ← 第 2 行(前导列纵向贯穿两行)
* ├──────┼──────┼────────┼────────┼────────┼────────┤
* │24030…│ 张三 │ 1 │ 127 │ 118 │ 134 │
* │(合并)│(合并)│ 2 │ 132 │ 121 │ 136 │ ← 最高分用红字
* │ │ │ … │ … │ … │ … │
* │ │ │合格次数│ 2 │ 0 │ 1 │ ← 达标绿底,未达标不标底
* └──────┴──────┴────────┴────────┴────────┴────────┘
*
* 约定:
* - 一名学生占 max(各场有效次数) + 1 行(+1 是末尾的「合格次数」汇总行)
* - 学号 / 姓名 / 第几次 三列按学生块纵向合并(第几次与学号同高,贯穿表头以下全部行)
* - 每场考试占 2 列:考试名称(第 1 行横向合并)+ 成绩(仅第 2 行有「成绩」小标题)
* - 只在该生某场考试完全没考时写「未考」并合并该场全部列;考了但次数少 → 留空
* - 学号 / 姓名 两列按学生块纵向合并;「第几次」逐行写各自次序号(1、2、3…),**不合并**
* (合并会把 2、3 吞掉只留 1,见截图反馈)
* - 每场考试占 **1 列**:第 1 行考试名、第 2 行「成绩」小标题
* (历史上每场占 2 列,第 2 列是遗留的空位,表现为"每场右边多一根空白列",已去掉)
* - 只在该生某场考试完全没考时写「未考」并纵向合并该列的学生块;考了但次数少 → 留空
* - 「合格次数」= 该场成绩 ≥ 合格线的次数;始终显示(含 0 次),只有 ≥ 阈值才标绿底
* - 每场成绩等于该场最高分的那次用红字标出(同分只标最早达成的一次)
*
@@ -329,8 +331,8 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
});
/* ---------------- 列布局 ----------------
前导列:班级? / 组别? / 学号 / 姓名 / 第几次(各考试共用一列,纵向合并到学生块)
考试区:每场 2 列(考试名称跨度由第 1 行横向合并,第 2 行只有「成绩」小标题) */
前导列:班级? / 组别? / 学号 / 姓名 / 第几次(各考试共用一列)
考试区:每场 1 列(第 1 行考试名、第 2 行「成绩」小标题) */
$colCls = $withCls ? 1 : 0;
$colGrp = $withGrp ? ($withCls ? 2 : 1) : 0;
$colSid = 1 + ($withCls ? 1 : 0) + ($withGrp ? 1 : 0);
@@ -357,19 +359,15 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
}
}
// 每场考试占 2 列:考试名称(第 1 行横向合并)+ 成绩(第 2 行小标题)
// 每场考试占 1 列:第 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;
$c += 1;
}
$colPass = $colExam0 + 2 * count($examOrder); // 「合格次数」所在列(若启用)
$rows = [$h1, $h2];
$rn = 2; // 当前行号(两行表头已占 1~2)
@@ -428,14 +426,10 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
foreach ($examOrder as $pid) {
$a = $lists[$pid][$i] ?? null;
if ($a === null) {
$line[] = '';
$line[] = '';
$line[] = ''; // 该生这场没考到第 i 次 → 留空
continue;
}
$line[] = $a['score'];
$line[] = ''; // 每场固定 2 列:第 1 列成绩、第 2 列是合并区间内的空位。
// 漏掉这个空串会让后续考试整体左移一列——数据串列、
// 表头合并区间与实际列对不上(截图里的错位就是它)。
// 最高分标红:只标一处(同分只标最早达成的那一行)
if ($markBest && $a['score'] !== '' && $bestScore[$pid] !== null
&& (float)$a['score'] === $bestScore[$pid] && $a['is_best']) {
@@ -450,16 +444,18 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
if ($end > $start) {
$merges[] = [$start, $colSid, $end, $colSid];
$merges[] = [$start, $colName, $end, $colName];
$merges[] = [$start, $colIdx, $end, $colIdx];
// 「第几次」不合并:每行是不同的次序号(合并只剩第一行的 1)
}
// 完全没考的考试:该场全部列 × 学生块高 → 合并成「未考」
// 完全没考的考试:该场该列 × 学生块高 → 合并成「未考」
foreach ($examOrder as $pid) {
if ($lists[$pid] !== []) {
continue;
}
$cc = $examCol[$pid];
$rows[$start - 1][$cc - 1] = $absent;
$merges[] = [$start, $cc, $end, $cc + 1];
if ($end > $start) { // 单行块无需合并(1×1 合并是非法区间,Excel 会提示修复)
$merges[] = [$start, $cc, $end, $cc];
}
}
$blocks[] = ['start' => $start, 'end' => $end, 'cls' => $s['cls'], 'grp' => $s['grp']];
@@ -483,11 +479,7 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
$merges[] = [1, $cc, 2, $cc];
}
}
$cc = $colExam0;
foreach ($examOrder as $pid) {
$merges[] = [1, $cc, 1, $cc + 1];
$cc += 2;
}
// 考试列每场 1 列,表头两行各写「考试名 / 成绩」,无需合并
foreach ($sids as $sid) {
$sid = (string)$sid;
@@ -527,16 +519,14 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
if ($withGrp) { $line[] = ($i === 0) ? $s['grp'] : ''; }
$line[] = ($i === 0) ? $sid : '';
$line[] = ($i === 0) ? $s['name'] : '';
$line[] = (string)($i + 1); // 第几次:所有考试共用一列
$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';
@@ -546,14 +536,16 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
$total++;
}
// 完全没考的考试:该场全部列 × 学生块高 → 合并成「未考」
// 完全没考的考试:该场该列 × 学生块高 → 合并成「未考」
foreach ($examOrder as $pid) {
if ($lists[$pid] !== []) {
continue;
}
$cx = $examCol[$pid];
$rows[$start - 1][$cx - 1] = $absent;
$merges[] = [$start, $cx, $rn, $cx + 1];
if ($rn > $start) { // 单行块无需合并(1×1 合并是非法区间)
$merges[] = [$start, $cx, $rn, $cx];
}
}
// 合格次数行(每个学生块一行)
@@ -569,7 +561,6 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
if ($list === []) {
// 整场未考:与「未考」合并格保持一致,不再单独判断合格次数
$passLineRow[] = '';
$passLineRow[] = '';
continue;
}
$cnt = 0;
@@ -581,7 +572,6 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
/* 合格次数始终显示(含 0 次);只有达到阈值才标绿底。
之前写 '' 会把「考了但没达标」的人整格隐藏,用户要看全部人次。 */
$passLineRow[] = (string)$cnt;
$passLineRow[] = '';
if ($cnt >= $passTimes) {
$marks[$rn . ',' . $examCol[$pid]] = 'green'; // 达标:绿底
}
@@ -590,13 +580,9 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
$total++;
/* 学号 / 姓名 纵向合并到「合格次数」行(这两列该行本就是空的,合并正确);
「第几次」只合并到最后一次考试行,末行留给「合格次数」标签——
若连合格次数行一起合并,该格会被当作"被合并的非首格"而丢掉值。 */
「第几次」列逐行是 1、2、3…,不合并——末行「合格次数」标签也写在这一列。 */
$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;
@@ -640,8 +626,7 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
/* 列宽:够用就好,宽了会把人挤到屏幕外(截图里学号列两侧大片空白就是这么来的)。
- 学号按实际内容估宽(10 位数字约 11,超过 12 位才放宽),不再一律 20
- 姓名同样按最长姓名估宽
- 每场考试占 2 列,但第 2 列恒为合并区间内的空位 → 给最小宽度 2.5,
把宽度让给真正承载成绩的第 1 列(否则一屏只看得到两场考试) */
- 每场考试只占 1 列,列宽要装得下该列表头(考试名),中文按 2 个半角宽算 */
$maxSidLen = 2;
$maxNameLen = 2;
foreach ($stu as $s) {
@@ -659,6 +644,22 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
$sidW = min(18, max(10, $maxSidLen + 2));
$nameW = min(20, max(9, $maxNameLen * 2 + 2));
// 考试列:取最长的考试名估一个统一宽度
$examW = 8;
foreach ($examOrder as $pid) {
$nm = (string)$examNames[$pid];
$len = mb_strlen($nm, 'UTF-8');
$w = 0;
for ($k = 0; $k < $len; $k++) {
$w += (strlen((string)mb_substr($nm, $k, 1, 'UTF-8')) > 1) ? 2 : 1;
}
$w += 2; // 左右留白
if ($w > $examW) {
$examW = $w;
}
}
$examW = min(18, max(8, $examW));
$widths = [];
if ($withCls) {
$widths[] = 14; // 班级名通常较长(如「计算机2401」)
@@ -668,10 +669,9 @@ function ny_assemble_block_sheet(array $exams, array $opts = []): array
}
$widths[] = $sidW; // 学号
$widths[] = $nameW; // 姓名
$widths[] = 9; // 第几次
$widths[] = 10; // 第几次(同时承载「合格次数」标签)
foreach ($examOrder as $pid) {
$widths[] = 10; // 成绩列(每场第 1 列)
$widths[] = 2.5; // 合并区内的空列,给最小宽度即可
$widths[] = $examW; // 每场考试 1 列(列宽装得下表头考试名)
}
return [