Files
ny-exam-platform/lib/multi_export.php
T
nxxy335 e87986fcc6 多考试块表:每场考试改为单列并修正第几次列合并
- 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 失败
2026-10-06 12:22:16 +08:00

854 lines
33 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 多考试导出:成绩对比矩阵 + 「每次考试」块表的纯逻辑(不依赖卷王客户端,便于单测)
*
* 两种版式:
* 1) 成绩对比(ny_assemble_multi_exam_matrix):一行一个学生、一列一场考试,只取最新 / 最高分
* 2) 每次考试块表(ny_assemble_block_sheet):两行表头,一行一次考试,学号 / 姓名 / 班级 / 组别
* 纵向合并单元格,没考的考试合并成「未考」一格(见需求截图版式)
*
* 说明:本文件刻意只用 PHP 7.0+ 语法(不用箭头函数 / 数字分隔符),
* 便于在只有 PHP 7.x 的机器上直接跑回归测试。
*/
declare(strict_types=1);
/**
* 取某个学生在某场考试里的"单元格成绩"。
*
* - highest:取真实最高分(跨全部尝试,含重考),即聚合行的 best_score
* - latest :取 attempts_list 中「最近一次非暂存提交」的成绩;
* attempts_list 全为暂存草稿时退回最后一份;列表缺失时退回代表行 score
*
* 与单考试"按最新成绩"导出口径完全一致(跳过 temp_save 草稿,避免导出的"最新成绩"是草稿分)。
*
* @return float|null 未提交 / 无成绩返回 null
*/
function ny_exam_cell_score(array $r, string $mode): ?float
{
if ($mode === 'highest') {
$v = $r['best_score'] ?? null;
return ($v === null || $v === '') ? null : (float)$v;
}
// 最近一次非暂存提交
$list = array_values((array)($r['attempts_list'] ?? []));
$last = null;
for ($i = count($list) - 1; $i >= 0; $i--) {
if (empty($list[$i]['temp_save'])) {
$last = $list[$i];
break;
}
}
if ($last === null && $list !== []) {
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
}
if ($last === null) {
$v = $r['score'] ?? null; // 无明细:退回代表行
return ($v === null || $v === '') ? null : (float)$v;
}
$v = $last['score'] ?? null;
return ($v === null || $v === '') ? null : (float)$v;
}
/**
* 把多场考试已构建好的结果,组装成"成绩对比"二维矩阵。
*
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序即考试展示顺序)
* @param string $scoreMode 'latest' | 'highest'
* @return array{head:array,rows:array,exam_order:array,exam_names:array}
*/
function ny_assemble_multi_exam_matrix(array $exams, string $scoreMode): array
{
$examOrder = [];
$examNames = [];
$scoreByExam = []; // pid => [sid => float|null]
$nameBySid = []; // sid => 姓名(首个非空)
foreach ($exams as $pid => $ex) {
$examOrder[] = $pid;
$examNames[$pid] = trim((string)($ex['name'] ?? '')) !== '' ? (string)$ex['name'] : (string)$pid;
$map = [];
foreach ((array)($ex['rows'] ?? []) as $r) {
$sid = trim((string)($r['sid'] ?? ''));
if ($sid === '') {
continue;
}
if (!isset($nameBySid[$sid]) && trim((string)($r['name'] ?? '')) !== '') {
$nameBySid[$sid] = (string)$r['name'];
}
$map[$sid] = ny_exam_cell_score($r, $scoreMode);
}
$scoreByExam[$pid] = $map;
}
// 学生并集(在任一选中考试里有提交即纳入)
$sidSet = [];
foreach ($scoreByExam as $map) {
foreach ($map as $sid => $_) {
$sidSet[$sid] = true;
}
}
$sids = array_keys($sidSet);
// 默认按学号排序(数值优先,否则自然序)
usort($sids, static function ($a, $b): int {
return ny_sid_cmp((string)$a, (string)$b);
});
$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;
$line[] = ($sc === null) ? '' : $sc;
}
$rows[] = $line;
}
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 │考试名3 │ ← 第 1 行:考试名
* ├──────┼──────┼────────┼────────┼────────┼────────┤
* │ │ │ │ 成绩 │ 成绩 │ 成绩 │ ← 第 2 行(前导列纵向贯穿两行)
* ├──────┼──────┼────────┼────────┼────────┼────────┤
* │24030…│ 张三 │ 1 │ 127 │ 118 │ 134 │
* │(合并)│(合并)│ 2 │ 132 │ 121 │ 136 │ ← 最高分用红字
* │ │ │ … │ … │ … │ … │
* │ │ │合格次数│ 2 │ 0 │ 1 │ ← 达标绿底,未达标不标底
* └──────┴──────┴────────┴────────┴────────┴────────┘
*
* 约定:
* - 一名学生占 max(各场有效次数) + 1 行(+1 是末尾的「合格次数」汇总行)
* - 学号 / 姓名 两列按学生块纵向合并;「第几次」逐行写各自次序号(1、2、3…),**不合并**
* (合并会把 2、3 吞掉只留 1,见截图反馈)
* - 每场考试占 **1 列**:第 1 行考试名、第 2 行「成绩」小标题
* (历史上每场占 2 列,第 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);
});
/* ---------------- 列布局 ----------------
前导列:班级? / 组别? / 学号 / 姓名 / 第几次(各考试共用一列)
考试区:每场 1 列(第 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];
}
}
// 每场考试占 1 列:第 1 行考试名、第 2 行「成绩」小标题(无需横向合并)
$examCol = [];
$c = $colExam0;
foreach ($examOrder as $pid) {
$examCol[$pid] = $c;
$h1[] = $examNames[$pid];
$h2[] = '成绩';
$c += 1;
}
$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[] = ''; // 该生这场没考到第 i 次 → 留空
continue;
}
$line[] = $a['score'];
// 最高分标红:只标一处(同分只标最早达成的那一行)
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];
// 「第几次」不合并:每行是不同的次序号(合并只剩第一行的 1)
}
// 完全没考的考试:该场该列 × 学生块高 → 合并成「未考」
foreach ($examOrder as $pid) {
if ($lists[$pid] !== []) {
continue;
}
$cc = $examCol[$pid];
$rows[$start - 1][$cc - 1] = $absent;
if ($end > $start) { // 单行块无需合并(1×1 合并是非法区间,Excel 会提示修复)
$merges[] = [$start, $cc, $end, $cc];
}
}
$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];
}
}
// 考试列每场 1 列,表头两行各写「考试名 / 成绩」,无需合并
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[] = '';
continue;
}
$line[] = $a['score'];
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;
if ($rn > $start) { // 单行块无需合并(1×1 合并是非法区间)
$merges[] = [$start, $cx, $rn, $cx];
}
}
// 合格次数行(每个学生块一行)
$rn++;
$passLineRow = [];
if ($withCls) { $passLineRow[] = ''; }
if ($withGrp) { $passLineRow[] = ''; }
$passLineRow[] = '';
$passLineRow[] = '';
$passLineRow[] = '合格次数'; // 落在「第几次」列,与截图一致
foreach ($examOrder as $pid) {
$list = $lists[$pid];
if ($list === []) {
// 整场未考:与「未考」合并格保持一致,不再单独判断合格次数
$passLineRow[] = '';
continue;
}
$cnt = 0;
foreach ($list as $a) {
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
$cnt++;
}
}
/* 合格次数始终显示(含 0 次);只有达到阈值才标绿底。
之前写 '' 会把「考了但没达标」的人整格隐藏,用户要看全部人次。 */
$passLineRow[] = (string)$cnt;
if ($cnt >= $passTimes) {
$marks[$rn . ',' . $examCol[$pid]] = 'green'; // 达标:绿底
}
}
$rows[] = $passLineRow;
$total++;
/* 学号 / 姓名 纵向合并到「合格次数」行(这两列该行本就是空的,合并正确);
「第几次」列逐行是 1、2、3…,不合并——末行「合格次数」标签也写在这一列。 */
$merges[] = [$start, $colSid, $rn, $colSid];
$merges[] = [$start, $colName, $rn, $colName];
$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
- 姓名同样按最长姓名估宽
- 每场考试只占 1 列,列宽要装得下该列表头(考试名),中文按 2 个半角宽算 */
$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));
// 考试列:取最长的考试名估一个统一宽度
$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」)
}
if ($withGrp) {
$widths[] = 9;
}
$widths[] = $sidW; // 学号
$widths[] = $nameW; // 姓名
$widths[] = 10; // 第几次(同时承载「合格次数」标签)
foreach ($examOrder as $pid) {
$widths[] = $examW; // 每场考试 1 列(列宽装得下表头考试名)
}
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,
];
}