diff --git a/docs/dev/api.md b/docs/dev/api.md
index c510b88..36fa207 100644
--- a/docs/dev/api.md
+++ b/docs/dev/api.md
@@ -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` 取消「最高分标红」 |
diff --git a/docs/dev/testing.md b/docs/dev/testing.md
index 8527555..dc98388 100644
--- a/docs/dev/testing.md
+++ b/docs/dev/testing.md
@@ -123,7 +123,7 @@ git status data/ # data/ 已被 gitignore,需手工核对
- [ ] 导入正常 `.xlsx` → 成功,且 `data/uploads/` 为空
- [ ] 工具栏「导出」只一个按钮 → 弹窗里四种内容都能出表,学号不是科学计数法
- [ ] 成绩明细「每人成绩」切 最高 / 最新 → 成绩列跟着变,文件名带 `_最高成绩` / `_最新成绩`
-- [ ] 每次考试导出 → 最高分红字、合格次数达标绿底、未达标留白、整场未考合并成「未考」;每场考试占 2 列,成绩不串列
+- [ ] 每次考试导出 → 最高分红字、合格次数达标绿底、未达标留白、整场未考合并成「未考」;每场考试只占 1 列,「第几次」逐行 1/2/3 不被合并
- [ ] 多考试打印 → 版式与导出的 Excel 一致,「合格次数」标签没被合并吞掉
- [ ] 打印预览 → 默认成绩降序,没有教师签字栏、没有站点域名页脚;**绿底 / 红字真的印出来了**
(打印样式带 `print-color-adjust: exact`,不需要在对话框里勾「背景图形」)
diff --git a/docs/guide/export-print.md b/docs/guide/export-print.md
index 31b9866..347c4a5 100644
--- a/docs/guide/export-print.md
+++ b/docs/guide/export-print.md
@@ -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 行是 `成绩` 小标题;
+- **没考的考试**(整场没有该生记录)→ 该列 × 学生块高合并成一个 **`未考`** 格子;
- **排序**:学生按学号升序(出现班级 / 组别列时按 班级 → 组别 → 学号);
- **暂存草稿**:默认不导出、也不占「第几次」编号。
diff --git a/docs/samples/export-multi-block.xlsx b/docs/samples/export-multi-block.xlsx
index 92201b8..c471f10 100644
Binary files a/docs/samples/export-multi-block.xlsx and b/docs/samples/export-multi-block.xlsx differ
diff --git a/docs/samples/export-single-wide.xlsx b/docs/samples/export-single-wide.xlsx
index f73dc62..332ac57 100644
Binary files a/docs/samples/export-single-wide.xlsx and b/docs/samples/export-single-wide.xlsx differ
diff --git a/docs/samples/preview-multi-block.html b/docs/samples/preview-multi-block.html
index 0f0c6da..9515f58 100644
--- a/docs/samples/preview-multi-block.html
+++ b/docs/samples/preview-multi-block.html
@@ -1,7 +1,7 @@
多考试导出 · 每次考试(两行表头)
合格线 110 分,达 1 次标绿
-
| 学号 | 姓名 | 第几次 | 第一次月考·数学 | 第一次月考·英语 |
| 成绩 | | 成绩 | |
| 356001 | 张三 | 1 | 112 | | 112 | |
| 100 | | 99 | |
| 136 | | | |
| 127 | | | |
| 合格次数 | 3 | | 1 | |
| 356002 | 李四 | 1 | | | 130 | |
| | 129 | |
| | 133 | |
| 合格次数 | 0 | | 3 | |
| 356003 | 王五 | 1 | 95 | | 未考 |
| 合格次数 | 0 | | | |
+
| 学号 | 姓名 | 第几次 | 第一次月考·数学 | 第一次月考·英语 |
| 成绩 | 成绩 |
| 356001 | 张三 | 1 | 112 | 112 |
| 2 | 100 | 99 |
| 3 | 136 | |
| 4 | 127 | |
| 合格次数 | 3 | 1 |
| 356002 | 李四 | 1 | 未考 | 130 |
| 2 | 129 |
| 3 | 133 |
| 合格次数 | | 3 |
| 356003 | 王五 | 1 | 95 | 未考 |
| 合格次数 | 0 | |
标记说明:最高分 标红、红字;合格次数 始终显示(含 0 次),达 1 次标绿、绿底;未达标显示次数、不标绿。
教师签字:______________ 教研组长签字:______________ 日期:____________
\ No newline at end of file
diff --git a/docs/samples/preview-single-wide.html b/docs/samples/preview-single-wide.html
index 284f88d..2bca91b 100644
--- a/docs/samples/preview-single-wide.html
+++ b/docs/samples/preview-single-wide.html
@@ -1,7 +1,7 @@
单场导出 · 每次考试(每人一行的宽表)
第一次月考·数学 · 合格线 110 分,达 1 次标绿
-
| 学号 | 姓名 | 第1次 | 第2次 | 第3次 | 第4次 | 合格次数 |
| 356001 | 张三 | 112 | 100 | 136 | 127 | 3 |
| 356002 | 李四 | | | | | 0 |
| 356003 | 王五 | 95 | | | | 0 |
+
| 学号 | 姓名 | 第1次 | 第2次 | 第3次 | 第4次 | 合格次数 |
| 356001 | 张三 | 112 | 100 | 136 | 127 | 3 |
| 356003 | 王五 | 95 | | | | 0 |
标记说明:最高分 标红、红字;合格次数 始终显示(含 0 次),达 1 次标绿、绿底;未达标显示次数、不标绿。
教师签字:______________ 教研组长签字:______________ 日期:____________
\ No newline at end of file
diff --git a/docs/samples/sheet-data.json b/docs/samples/sheet-data.json
index 9b49530..131fcd5 100644
--- a/docs/samples/sheet-data.json
+++ b/docs/samples/sheet-data.json
@@ -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": {
diff --git a/lib/multi_export.php b/lib/multi_export.php
index 0af506d..e6d4f0b 100644
--- a/lib/multi_export.php
+++ b/lib/multi_export.php
@@ -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 [
diff --git a/tests/test-multi-export.php b/tests/test-multi-export.php
index 751bac7..4087745 100644
--- a/tests/test-multi-export.php
+++ b/tests/test-multi-export.php
@@ -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";
diff --git a/tools/make-samples.php b/tools/make-samples.php
index a8713f9..d6466dd 100644
--- a/tools/make-samples.php
+++ b/tools/make-samples.php
@@ -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' => [