= 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 */ 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, ]; }