From e87986fcc60d9ddf49104c6f2d972108b71fac23 Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Tue, 6 Oct 2026 12:22:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E8=80=83=E8=AF=95=E5=9D=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=9A=E6=AF=8F=E5=9C=BA=E8=80=83=E8=AF=95=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=8D=95=E5=88=97=E5=B9=B6=E4=BF=AE=E6=AD=A3=E7=AC=AC=E5=87=A0?= =?UTF-8?q?=E6=AC=A1=E5=88=97=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 失败 --- docs/dev/api.md | 2 +- docs/dev/testing.md | 2 +- docs/guide/export-print.md | 29 +++--- docs/samples/export-multi-block.xlsx | Bin 4300 -> 4186 bytes docs/samples/export-single-wide.xlsx | Bin 3886 -> 3846 bytes docs/samples/preview-multi-block.html | 2 +- docs/samples/preview-single-wide.html | 2 +- docs/samples/sheet-data.json | 143 +++++++------------------- lib/multi_export.php | 102 +++++++++--------- tests/test-multi-export.php | 81 +++++++++------ tools/make-samples.php | 3 +- 11 files changed, 161 insertions(+), 205 deletions(-) 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 92201b8d76f3840fd491f3eb0fcb36cd45568ed6..c471f1058642bc21fe1b9d07ce0e2410962d2200 100644 GIT binary patch delta 1752 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 delta 1509 zcmZvcdpOg39LIk@qakB7_fjIELg7Th zGorCjnR?tRav39;xwXmd#NmwdIOlYp^Zn=jeZJq%=lgk{&-?rOBx{vw!LS&JBm$5C zqya(Ep=YpQ5F*x!^Ps}|hH+J*Uq*aD$akbPoMD`W7*hM_g>R8c=C_-2>7uq0!71O! zi5pDI>YEh3+jPgzTtEg&T(pu~Rv|&qbggS|=HnAw2dhd56Unq0Y5byMbjE#w6NuJn z3k-)6%qPaRf4ZXkPRF*1ZEFoL9G&jj&kSlBN^~?mE4Yj`Q{1AGw(Ucv8Pi0i5~8i> zjV^ma{gg^^@@I~OZ`~5-+I#6J;ps43S47u6pjsRmY#&hmt6#cx9V%Grn-QEp zsMTBD-wL+Sn(4LtDj(yA=U0hRMvp39O_~R8JJz`pv~uu{f}TSdgilt7YistDR9zHa zzd&tXefMdi=fo*tRu~F1KRnri%${iB1Xoh|=sv-TIIqXt%&N z9mKlY<*Ks6ctTYFi_ILU~asoar;c%Ky-eaOL!LiDgB#n@BQ6#{$0l*F4`P-gZqJo1zgU}B1|y1TnkqivdgVG zt31Fq{x!xe!;%1=v{4~q&T%N8<#+%{5X29@fbx-N$!nh|+{rh}+Yst3Z zd&ex6((4AQ-k(Hp_o}=&X+F^avv`-#+7jnX@^7jEp8qz57V=lXoDiGCs$3!563P`{m^g1iwfU? zPH|W3HZi!e+o>Plk{+6%n=Yv|D2wWCQgR1$hPMi>phWe~V{urjc zd^C-pCEFg3{RSUJ;mOjX{g0K0G|v^pl1Ja=rP8OVWzTmcgzIy@V?B1hDYtUZe2|RG zP*yJaVgbOcQ-rSucBwtkADjA`$hP!x63>HZy&NWn`9_(k>Wx8hq~i@z>KYwTO0Xbx13sO!OEe z%T-g|cmlU{$MmnWeH{^BX9vbuhgddotE;fSM}S7&6#uMlqP=aScl+$enLU|pS6Sa` zIgg`64yE(_njRr(PQ?qX;e?}~btkb9P{ILB{NQLZV=e(eg0B=YTxtdNrrV(!XGw*4Gh+GyT2G@gS+ms`LC z^q>$?7P^ZQe5Ywb*Azb3FO5Vd&b=^OWzqB%*kI`U*eocI-mU4iTV&}hjME94$0R!SIydy8Zv? p-biB7;u#EiogHg2|NK<|P}>Mtf3-uFF(j+O5X!?h^~kN={0XO^t5*O3 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张三1112112
10099
136
127
合格次数31
356002李四1130
129
133
合格次数03
356003王五195未考
合格次数0
+
学号姓名第几次第一次月考·数学第一次月考·英语
成绩成绩
356001张三1112112
210099
3136
4127
合格次数31
356002李四1未考130
2129
3133
合格次数3
356003王五195未考
合格次数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张三1121001361273
356002李四0
356003王五950
+
学号姓名第1次第2次第3次第4次合格次数
356001张三1121001361273
356003王五950

标记说明:最高分 标红、红字;合格次数 始终显示(含 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' => [