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

- 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
+1 -1
View File
@@ -90,7 +90,7 @@ _cache { cached, at }
| `scoreMode=best` | `export` | 缺省 | 每人一行的代表成绩改取**最高成绩**(默认 `latest` = 最近一次真实提交);`mode=all` 不生效 |
| `noAbsent=1` | `export`、`export_multi` | 缺省 | 不附缺考名单工作表(`export_multi` 口径:所选考试全都没交的人) |
| `skipTemp=1` | `export` | 缺省 | `mode=all` 时排除暂存草稿;`includeTemp=1` 可强制保留 |
| `mode=block` | `export_multi` | **默认** | 两行表头的「每次考试」块表:一列「第几次」被所有考试共用,每场考试占 2 列(考试名横跨 2 列 + 第二行「成绩」小标题),学生块末尾一行「合格次数」,学号·姓名纵向合并,没考的考试合并成「未考」(旧值 `merged` / `attempts` 等价) |
| `mode=block` | `export_multi` | **默认** | 两行表头的「每次考试」块表:一列「第几次」被所有考试共用(逐行 1、2、3…,不合并),每场考试占 **1 列**(第 1 行考试名 + 第 2 行「成绩」小标题),学生块末尾一行「合格次数」,学号·姓名纵向合并,没考的考试合并成「未考」(旧值 `merged` / `attempts` 等价) |
| `wide=1` | `export_multi` | — | 只传 1 个 `projectId` 时改用宽表:每人一行、第几次直接做列名(`学号 / 姓名 / 第1次…第N次 / [合格次数]`),由 `ny_assemble_attempt_wide_sheet()` 生成 |
| `passTimes` | `export_multi` | 1 | 「合格次数」达标阈值:该场达到合格线的次数 ≥ 此值才标绿(默认 1) |
| `markBest=0` | `export_multi` | 开 | `=0` 取消「最高分标红」 |
+1 -1
View File
@@ -123,7 +123,7 @@ git status data/ # data/ 已被 gitignore,需手工核对
- [ ] 导入正常 `.xlsx` → 成功,且 `data/uploads/` 为空
- [ ] 工具栏「导出」只一个按钮 → 弹窗里四种内容都能出表,学号不是科学计数法
- [ ] 成绩明细「每人成绩」切 最高 / 最新 → 成绩列跟着变,文件名带 `_最高成绩` / `_最新成绩`
- [ ] 每次考试导出 → 最高分红字、合格次数达标绿底、未达标留白、整场未考合并成「未考」;每场考试占 2 列,成绩不串列
- [ ] 每次考试导出 → 最高分红字、合格次数达标绿底、未达标留白、整场未考合并成「未考」;每场考试只占 1 列,「第几次」逐行 1/2/3 不被合并
- [ ] 多考试打印 → 版式与导出的 Excel 一致,「合格次数」标签没被合并吞掉
- [ ] 打印预览 → 默认成绩降序,没有教师签字栏、没有站点域名页脚;**绿底 / 红字真的印出来了**
(打印样式带 `print-color-adjust: exact`,不需要在对话框里勾「背景图形」)
+15 -14
View File
@@ -40,25 +40,26 @@
## 「每次考试」块表(多场对比,主推格式)
勾选多场考试,选 **两行表头** 形态。**一列「第几次」被所有考试共用**,每场考试占 2 列,
学生块末尾多一行 **合格次数**。
勾选多场考试,选 **两行表头** 形态。**一列「第几次」被所有考试共用**(逐行写 1、2、3…,不合并),
每场考试占 **1 列**,学生块末尾多一行 **合格次数**。
| 学号 | 姓名 | 第几次 | 第一次月考·数学 | | 第一次月考·英语 | |
| --- | --- | --- | --- | --- | --- | --- |
| | | | 成绩 | | 成绩 | |
| (合并) | (合并) | 1 | 112 | 112 | 130 | |
| (合并) | (合并) | 2 | 100 | | 129 | |
| (合并) | (合并) | 3 | **136** | | **133** | |
| (合并) | (合并) | **合格次数** | 3 | | 3 | |
| 356002 | 李四 | 1 | (合并)**未考** | | 128 | |
| (合并) | (合并) | **合格次数** | | | 1 | |
| 学号 | 姓名 | 第几次 | 第一次月考·数学 | 第一次月考·英语 |
| --- | --- | --- | --- | --- |
| | | | 成绩 | 成绩 |
| (合并) | (合并) | 1 | 112 | 130 |
| (合并) | (合并) | 2 | 100 | 129 |
| (合并) | (合并) | 3 | **136** | **133** |
| (合并) | (合并) | **合格次数** | 3 | 3 |
| 356002 | 李四 | 1 | (合并)**未考** | 128 |
| (合并) | (合并) | **合格次数** | | 1 |
规则:
- **学生块高度** = 该生在所有勾选考试里最多的那次次数 + 1(末行是合格次数);
- **学号 / 姓名**按学生块纵向合并;**第几次**只合并到最后一次考试行,末行留给「合格次数」标签;
- **考试名称横跨 2 列**,第二行是 `成绩` 小标题;
- **没考的考试**(整场没有该生记录)→ 该场 2 列 × 学生块高合并成一个 **`未考`** 格子;
- **学号 / 姓名**按学生块纵向合并;**第几次**逐行写各自的次序号(1、2、3…),**不合并**
——合并会把 2、3 吞掉只剩 1;
- **每场考试占 1 列**:第 1 行是考试名、第 2 行是 `成绩` 小标题;
- **没考的考试**(整场没有该生记录)→ 该列 × 学生块高合并成一个 **`未考`** 格子;
- **排序**:学生按学号升序(出现班级 / 组别列时按 班级 → 组别 → 学号);
- **暂存草稿**:默认不导出、也不占「第几次」编号。
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>多考试导出 · 每次考试(两行表头)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}table.bt td.absent{color:#888}table.bt td.max{color:#d0021b;font-weight:700}table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
<div class="page"><h2>多考试导出 · 每次考试(两行表头)</h2><p class="sub">合格线 110 分,达 1 次标绿</p>
<table class="bt"><thead><tr><td rowspan="2">学号</td><td rowspan="2">姓名</td><td rowspan="2">第几次</td><td colspan="2">第一次月考·数学</td><td colspan="2">第一次月考·英语</td></tr><tr><td>成绩</td><td></td><td>成绩</td><td></td></tr></thead><tbody><tr><td rowspan="5">356001</td><td rowspan="5">张三</td><td rowspan="4">1</td><td>112</td><td></td><td class="max">112</td><td></td></tr><tr><td>100</td><td></td><td>99</td><td></td></tr><tr><td class="max">136</td><td></td><td></td><td></td></tr><tr><td>127</td><td></td><td></td><td></td></tr><tr><td>合格次数</td><td class="pass">3</td><td></td><td class="pass">1</td><td></td></tr><tr><td rowspan="4">356002</td><td rowspan="4">李四</td><td rowspan="3">1</td><td></td><td></td><td>130</td><td></td></tr><tr><td></td><td></td><td>129</td><td></td></tr><tr><td></td><td></td><td class="max">133</td><td></td></tr><tr><td>合格次数</td><td>0</td><td></td><td class="pass">3</td><td></td></tr><tr><td rowspan="2">356003</td><td rowspan="2">王五</td><td>1</td><td class="max">95</td><td></td><td colspan="2" class="absent">未考</td></tr><tr><td>合格次数</td><td>0</td><td></td><td></td><td></td></tr></tbody></table>
<table class="bt"><thead><tr><td rowspan="2">学号</td><td rowspan="2">姓名</td><td rowspan="2">第几次</td><td>第一次月考·数学</td><td>第一次月考·英语</td></tr><tr><td>成绩</td><td>成绩</td></tr></thead><tbody><tr><td rowspan="5">356001</td><td rowspan="5">张三</td><td>1</td><td>112</td><td class="max">112</td></tr><tr><td>2</td><td>100</td><td>99</td></tr><tr><td>3</td><td class="max">136</td><td></td></tr><tr><td>4</td><td>127</td><td></td></tr><tr><td>合格次数</td><td class="pass">3</td><td class="pass">1</td></tr><tr><td rowspan="4">356002</td><td rowspan="4">李四</td><td>1</td><td rowspan="3" class="absent">未考</td><td>130</td></tr><tr><td>2</td><td>129</td></tr><tr><td>3</td><td class="max">133</td></tr><tr><td>合格次数</td><td></td><td class="pass">3</td></tr><tr><td rowspan="2">356003</td><td rowspan="2">王五</td><td>1</td><td class="max">95</td><td class="absent">未考</td></tr><tr><td>合格次数</td><td>0</td><td></td></tr></tbody></table>
<p class="legend">标记说明:<span class="r">最高分</span> 标红、<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),达 1 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。
<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>
</div></body></html>
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>单场导出 · 每次考试(每人一行的宽表)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}table.bt td.absent{color:#888}table.bt td.max{color:#d0021b;font-weight:700}table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
<div class="page"><h2>单场导出 · 每次考试(每人一行的宽表)</h2><p class="sub">第一次月考·数学 · 合格线 110 分,达 1 次标绿</p>
<table class="bt"><thead><tr><td>学号</td><td>姓名</td><td>第1次</td><td>第2次</td><td>第3次</td><td>第4次</td><td>合格次数</td></tr></thead><tbody><tr><td>356001</td><td>张三</td><td>112</td><td>100</td><td class="max">136</td><td>127</td><td class="pass">3</td></tr><tr><td>356002</td><td>李四</td><td></td><td></td><td></td><td></td><td>0</td></tr><tr><td>356003</td><td>王五</td><td class="max">95</td><td></td><td></td><td></td><td>0</td></tr></tbody></table>
<table class="bt"><thead><tr><td>学号</td><td>姓名</td><td>第1次</td><td>第2次</td><td>第3次</td><td>第4次</td><td>合格次数</td></tr></thead><tbody><tr><td>356001</td><td>张三</td><td>112</td><td>100</td><td class="max">136</td><td>127</td><td class="pass">3</td></tr><tr><td>356003</td><td>王五</td><td class="max">95</td><td></td><td></td><td></td><td>0</td></tr></tbody></table>
<p class="legend">标记说明:<span class="r">最高分</span> 标红、<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),达 1 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。
<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>
</div></body></html>
+39 -104
View File
@@ -8,44 +8,34 @@
"姓名",
"第几次",
"第一次月考·数学",
"",
"第一次月考·英语",
""
"第一次月考·英语"
],
[
"",
"",
"",
"成绩",
"",
"成绩",
""
"成绩"
],
[
"356001",
"张三",
"1",
"112",
"",
"112",
""
"112"
],
[
"",
"",
"2",
"100",
"",
"99",
""
"99"
],
[
"",
"",
"3",
"136",
"",
"",
""
],
[
@@ -53,8 +43,6 @@
"",
"4",
"127",
"",
"",
""
],
[
@@ -62,62 +50,48 @@
"",
"合格次数",
"3",
"",
"1",
""
"1"
],
[
"356002",
"李四",
"1",
"",
"",
"130",
""
"未考",
"130"
],
[
"",
"",
"2",
"",
"",
"129",
""
"129"
],
[
"",
"",
"3",
"",
"",
"133",
""
"133"
],
[
"",
"",
"合格次数",
"0",
"",
"3",
""
"3"
],
[
"356003",
"王五",
"1",
"95",
"",
"未考",
""
"未考"
],
[
"",
"",
"合格次数",
"0",
"",
"",
""
]
],
@@ -141,58 +115,34 @@
3
],
[
3,
1,
7,
1
],
[
3,
2,
7,
2
],
[
8,
4,
1,
5
],
[
1,
6,
1,
7
],
[
3,
1,
7,
1
],
[
3,
2,
7,
2
],
[
3,
3,
6,
3
],
[
8,
1,
11,
1
],
[
8,
2,
11,
2
],
[
8,
3,
10,
3
4
],
[
12,
6,
12,
7
8,
1,
11,
1
],
[
8,
2,
11,
2
],
[
12,
@@ -205,22 +155,16 @@
2,
13,
2
],
[
12,
3,
12,
3
]
],
"header_rows": 2,
"cell_styles": {
"3,6": "red",
"3,5": "red",
"5,4": "red",
"7,4": "green",
"7,6": "green",
"10,6": "red",
"11,6": "green",
"7,5": "green",
"10,5": "red",
"11,5": "green",
"12,4": "red"
}
},
@@ -251,15 +195,6 @@
"127",
"3"
],
[
"356002",
"李四",
"",
"",
"",
"",
"0"
],
[
"356003",
"王五",
@@ -275,7 +210,7 @@
"cell_styles": {
"2,5": "red",
"2,7": "green",
"4,3": "red"
"3,3": "red"
}
},
"meta": {
+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 [
+50 -31
View File
@@ -190,43 +190,60 @@ $shotEng = ['name' => '英语', 'rows' => [
echo "== 每次考试块表:两行表头 / 共享第几次 / 未考(截图一版式) ==\n";
$bs = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng]);
$rows = $bs['rows'];
check('表头第 1 行:学号 / 姓名 / 第几次 / 考试名(各占 1 格 + 1 空格)',
$rows[0] === ['学号', '姓名', '第几次', '数学', '', '英语', ''],
check('表头第 1 行:学号 / 姓名 / 第几次 / 考试名(每场 1 列)',
$rows[0] === ['学号', '姓名', '第几次', '数学', '英语'],
json_encode($rows[0], JSON_UNESCAPED_UNICODE));
check('表头第 2 行:只有「成绩」小标题',
$rows[1] === ['', '', '', '成绩', '', '成绩', ''],
$rows[1] === ['', '', '', '成绩', '成绩'],
json_encode($rows[1], JSON_UNESCAPED_UNICODE));
check('header_rows = 2', $bs['header_rows'] === 2);
/* 每场考试固定占 2 列:第 1 列成绩,第 2 列是「成绩列与相邻列合并」落下的空位。
当初漏推这个空串,整行会左移一格,表现就是成绩串列、表头合并区间对不上。 */
check('张三块首行:第几次=1,两场成绩(每场 2 列)',
rowEq($rows[2], ['356001', '张三', '1', '112', '', '112', '']),
/* 每场考试固定占 1 列(历史上每场占 2 列,第 2 列是遗留空位,
表现为"每场右边多出一根空白列");「第几次」逐行写各自次序号,不纵向合并
(合并会把 2、3 吞掉,只剩首行的 1)。 */
check('张三块首行:第几次=1,两场成绩(每场 1 列)',
rowEq($rows[2], ['356001', '张三', '1', '112', '112']),
json_encode($rows[2], JSON_UNESCAPED_UNICODE));
check('每场考试固定 2 列(成绩 + 落位空列),行宽齐整',
count($rows) === 9 && !array_filter(array_map('count', $rows), static fn($n) => $n !== 7),
check('张三第 2 行:第几次=2,数学 100 / 英语 100',
rowEq($rows[3], ['', '', '2', '100', '100']),
json_encode($rows[3], JSON_UNESCAPED_UNICODE));
/* 严格校验:3 个前导列 + 每场 1 列,所有行宽必须完全一致。
rowEq 会把短的一侧按空串补齐,宽松比较测不出「多一根空白列」这种回归。 */
check('每场考试只占 1 列:所有行宽一致 = 3 + 考试数',
count($rows) === 9 && !array_filter(array_map('count', $rows), static fn($n) => $n !== 5),
'列数序列 = ' . json_encode(array_map('count', $rows)));
check('张三第 3 行:第几次=3,数学 136 / 英语空',
rowEq($rows[4], ['', '', '3', '136', '']),
$rows[4] === ['', '', '3', '136', ''],
json_encode($rows[4], JSON_UNESCAPED_UNICODE));
check('张三第 4 行:第几次=4,英语留空',
rowEq($rows[5], ['', '', '4', '127', '']),
check('张三第 4 行:第几次=4,数学 127 / 英语空',
$rows[5] === ['', '', '4', '127', ''],
json_encode($rows[5], JSON_UNESCAPED_UNICODE));
check('李四块首行:数学未考 + 英语 130',
rowEq($rows[6], ['356002', '李四', '1', '未考', '', '130']),
rowEq($rows[6], ['356002', '李四', '1', '未考', '130']),
json_encode($rows[6], JSON_UNESCAPED_UNICODE));
check('李四第 2 / 3 行:第几次 2、3(未考列在合并区内留空)',
$rows[7] === ['', '', '2', '', '129'] && $rows[8] === ['', '', '3', '', '133'],
json_encode([$rows[7], $rows[8]], JSON_UNESCAPED_UNICODE));
check('「第几次」逐行递增,不是被合并压成的一串 1',
array_map(static fn($r) => (string)$r[2], array_slice($rows, 2)) === ['1', '2', '3', '4', '1', '2', '3'],
json_encode(array_map(static fn($r) => $r[2], array_slice($rows, 2)), JSON_UNESCAPED_UNICODE));
check('总行数 = 9(2 行表头 + 张三 4 + 李四 3)', count($rows) === 9, (string)count($rows));
check('total_rows / students 统计正确', $bs['total_rows'] === 7 && $bs['students'] === 2,
$bs['total_rows'] . '/' . $bs['students']);
check('未启用合格行时 pass_row=false', $bs['pass_row'] === false, var_export($bs['pass_row'], true));
$refs = mergeRefs($bs['merges']);
check('合并区间:表头 C 列纵向 + 考试名横向 + 学号姓名第几次纵向 + 未考跨 2 列',
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'C3:C6', 'C7:C9', 'D1:E1', 'D7:E9', 'F1:G1'],
check('合并区间:表头纵向 + 学号姓名纵向 + 未考单列纵向',
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'D7:D9'],
json_encode($refs));
/* 列宽够用就好:空落位列给 2.5,宽度让给真正显示成绩的列,
否则一屏只放得下两场考试。 */
check('列宽 = 学号/姓名/第几次 + 成绩 10 + 空落位 2.5',
$bs['widths'] === [10, 9, 9, 10, 2.5, 10, 2.5], json_encode($bs['widths']));
check('「第几次」列没有任何纵向合并(表头 C1:C2 除外)',
!array_filter($bs['merges'], static fn($m) => (int)$m[0] > 1 && (int)$m[1] === 3 && (int)$m[3] === 3),
json_encode($bs['merges']));
check('表头第 1 行没有任何横向合并(考试名各占 1 列)',
!array_filter($bs['merges'], static fn($m) => (int)$m[0] === 1 && (int)$m[2] === 1 && $m[1] !== $m[3]),
json_encode($bs['merges']));
/* 列宽够用就好:考试列要装得下表头考试名(中文按 2 个半角宽算)。 */
check('列宽 = 学号 10 / 姓名 9 / 第几次 10 / 每场考试 1 列(按考试名估宽)',
$bs['widths'] === [10, 9, 10, 8, 8], json_encode($bs['widths']));
echo "== 每次考试块表:最高分标红 / 合格次数标绿 ==\n";
// 合格线 110:
@@ -240,11 +257,11 @@ check('pass_row=true 且 pass_line 回传', $bp['pass_row'] === true && $bp['pas
var_export($bp['pass_line'], true));
// 张三块:4 行成绩 + 1 行合格次数(第 7 行)
check('张三合格次数行:数学 3 次 / 英语 1 次',
rowEq($pp[6], ['', '', '合格次数', '3', '', '1']),
$pp[6] === ['', '', '合格次数', '3', '1'],
json_encode($pp[6], JSON_UNESCAPED_UNICODE));
// 李四块:3 行成绩 + 1 行合格次数(第 11 行)
check('李四合格次数行:数学未考留空 / 英语 3 次',
rowEq($pp[10], ['', '', '合格次数', '', '', '3']),
$pp[10] === ['', '', '合格次数', '', '3'],
json_encode($pp[10], JSON_UNESCAPED_UNICODE));
check('总行数 = 11(2 + (4+1) + (3+1))', count($pp) === 11, (string)count($pp));
check('total_rows 含合格次数行 = 9', $bp['total_rows'] === 9, (string)$bp['total_rows']);
@@ -252,23 +269,25 @@ check('total_rows 含合格次数行 = 9', $bp['total_rows'] === 9, (string)$bp[
$pm = $bp['cell_styles'];
// 张三数学最高分 136 在第 5 行第 4 列 → 红字
check('张三数学最高分 136 标红', ($pm['5,4'] ?? '') === 'red', json_encode($pm));
// 张三英语最高分 112 在第 3 行第 6 列 → 红字
check('张三英语最高分 112 标红', ($pm['3,6'] ?? '') === 'red', json_encode($pm));
// 张三英语最高分 112 在第 3 行第 5 列 → 红字
check('张三英语最高分 112 标红', ($pm['3,5'] ?? '') === 'red', json_encode($pm));
// 李四英语最高分 133 在第 10 行第 5 列 → 红字
check('李四英语最高分 133 标红', ($pm['10,5'] ?? '') === 'red', json_encode($pm));
// 张三合格次数(第 7 行):数学 3 达标绿底、英语 1 达标绿底
check('张三数学合格次数达标绿底', ($pm['7,4'] ?? '') === 'green', json_encode($pm));
check('张三英语合格次数达标绿底', ($pm['7,6'] ?? '') === 'green', json_encode($pm));
check('张三英语合格次数达标绿底', ($pm['7,5'] ?? '') === 'green', json_encode($pm));
// 李四:数学未考 → 不标绿;英语 3 次达标绿底
check('李四数学未考不标绿', !isset($pm['11,4']), json_encode($pm));
check('李四英语合格次数达标绿底', ($pm['11,6'] ?? '') === 'green', json_encode($pm));
check('李四英语合格次数达标绿底', ($pm['11,5'] ?? '') === 'green', json_encode($pm));
// 未达标(阈值 4 次)→ 仍显示实际次数(张三数学 3 / 英语 1),但不标绿
$bh = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110, 'pass_times' => 4]);
$hp = $bh['rows'];
check('阈值 4 次时张三仍显示实际合格次数(不隐藏)',
rowEq($hp[6], ['', '', '合格次数', '3', '', '1']),
$hp[6] === ['', '', '合格次数', '3', '1'],
json_encode($hp[6], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不达标不标绿',
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,6']),
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,5']),
json_encode($bh['cell_styles']));
echo "== 每次考试块表:草稿 / 多班级组别列 / 空考试 ==\n";
@@ -306,7 +325,7 @@ $multi = ['name' => '数学', 'rows' => [
]];
$mc = ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true, 'with_grp' => true]);
check('多班级表头 = 班级/组别/学号/姓名/第几次/考试名',
$mc['rows'][0] === ['班级', '组别', '学号', '姓名', '第几次', '数学', ''],
$mc['rows'][0] === ['班级', '组别', '学号', '姓名', '第几次', '数学'],
json_encode($mc['rows'][0], JSON_UNESCAPED_UNICODE));
check('按 班级 → 组别 → 学号 排序',
[$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]
@@ -326,14 +345,14 @@ check('班级列位移后学号 / 姓名合并列正确(C / D 列)',
// 空考试:只出两行表头
$bEmpty = ny_assemble_block_sheet(['none' => ['name' => '无人考试', 'rows' => []]]);
check('空考试只有两行表头', count($bEmpty['rows']) === 2 && $bEmpty['students'] === 0);
check('空考试仅剩表头合并',
mergeRefs($bEmpty['merges']) === ['A1:A2', 'B1:B2', 'C1:C2', 'D1:E1'],
check('空考试仅剩表头合并(考试名不再横向合并)',
mergeRefs($bEmpty['merges']) === ['A1:A2', 'B1:B2', 'C1:C2'],
json_encode(mergeRefs($bEmpty['merges'])));
// 考试列顺序跟随传入顺序
$bOrder = ny_assemble_block_sheet(['e' => $shotEng, 'm' => $shotMath]);
check('考试列顺序跟随传入顺序(英语在前)',
$bOrder['rows'][0][3] === '英语' && $bOrder['rows'][0][5] === '数学',
$bOrder['rows'][0][3] === '英语' && $bOrder['rows'][0][4] === '数学',
json_encode($bOrder['rows'][0], JSON_UNESCAPED_UNICODE));
echo "== 单场「每次考试」宽表(截图二版式) ==\n";
+2 -1
View File
@@ -43,9 +43,10 @@ function sample_stu(string $sid, string $name, float $best, array $scoresAsc, ar
/* ---- 示例数据:与需求截图同口径(合格线 110,阈值 1 次) ---- */
$exams = [
// 「未考」的口径是「整场考试里根本没有这个学号」——不是给个空成绩,
// 所以李四整场从数学的 rows 里去掉(写在 rows 里只会渲染成空白单元格)。
'm' => ['name' => '第一次月考·数学', 'rows' => [
sample_stu('356001', '张三', 136.0, [112.0, 100.0, 136.0, 127.0]),
sample_stu('356002', '李四', 0.0, []), // 整场未考
sample_stu('356003', '王五', 95.0, [95.0]), // 未达合格线 → 不标绿
]],
'e' => ['name' => '第一次月考·英语', 'rows' => [