Files
ny-exam-platform/lib/multi_export.php
T
nxxy335 d05fd4a9b5 feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
2026-09-27 22:04:17 +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 │ ← 第 1 行:考试名横向合并 2 列
* ├──────┼──────┼────────┼────────┬───────┼────────┬───────┤
* │ │ │ │ 成绩 │ 成绩 │ 成绩 │ 成绩 │ ← 第 2 行(第几次纵向贯穿两行)
* ├──────┼──────┼────────┼────────┼───────┼────────┼───────┤
* │24030…│ 张三 │ 1 │ 127 │ 127 │ 134 │ 134 │
* │(合并)│(合并)│ 2 │ 132 │ 132 │ 136 │ 136 │ ← 最高分用红字
* │ │ │ … │ … │ … │ │ │
* │ │ │合格次数│ 2 │ 1 │ 2 │ 2 │ ← 达标绿底、未达标空白
* └──────┴──────┴────────┴────────┴───────┴────────┴───────┘
*
* 约定:
* - 一名学生占 max(各场有效次数) + 1 行(+1 是末尾的「合格次数」汇总行)
* - 学号 / 姓名 / 第几次 三列按学生块纵向合并(第几次与学号同高,贯穿表头以下全部行)
* - 每场考试占 2 列:考试名称(第 1 行横向合并)+ 成绩(仅第 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);
});
/* ---------------- 列布局 ----------------
前导列:班级? / 组别? / 学号 / 姓名 / 第几次(各考试共用一列,纵向合并到学生块)
考试区:每场 2 列(考试名称跨度由第 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];
}
}
// 每场考试占 2 列:考试名称(第 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;
}
$colPass = $colExam0 + 2 * count($examOrder); // 「合格次数」所在列(若启用)
$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[] = '';
$line[] = '';
continue;
}
$line[] = $a['score'];
$line[] = ''; // 每场固定 2 列:第 1 列成绩、第 2 列是合并区间内的空位。
// 漏掉这个空串会让后续考试整体左移一列——数据串列、
// 表头合并区间与实际列对不上(截图里的错位就是它)。
// 最高分标红:只标一处(同分只标最早达成的那一行)
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];
$merges[] = [$start, $colIdx, $end, $colIdx];
}
// 完全没考的考试:该场全部列 × 学生块高 → 合并成「未考」
foreach ($examOrder as $pid) {
if ($lists[$pid] !== []) {
continue;
}
$cc = $examCol[$pid];
$rows[$start - 1][$cc - 1] = $absent;
$merges[] = [$start, $cc, $end, $cc + 1];
}
$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];
}
}
$cc = $colExam0;
foreach ($examOrder as $pid) {
$merges[] = [1, $cc, 1, $cc + 1];
$cc += 2;
}
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[] = '';
$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';
}
}
$rows[] = $line;
$total++;
}
// 完全没考的考试:该场全部列 × 学生块高 → 合并成「未考」
foreach ($examOrder as $pid) {
if ($lists[$pid] !== []) {
continue;
}
$cx = $examCol[$pid];
$rows[$start - 1][$cx - 1] = $absent;
$merges[] = [$start, $cx, $rn, $cx + 1];
}
// 合格次数行(每个学生块一行)
$rn++;
$passLineRow = [];
if ($withCls) { $passLineRow[] = ''; }
if ($withGrp) { $passLineRow[] = ''; }
$passLineRow[] = '';
$passLineRow[] = '';
$passLineRow[] = '合格次数'; // 落在「第几次」列,与截图一致
foreach ($examOrder as $pid) {
$list = $lists[$pid];
if ($list === []) {
// 整场未考:与「未考」合并格保持一致,不再单独判断合格次数
$passLineRow[] = '';
$passLineRow[] = '';
continue;
}
$cnt = 0;
foreach ($list as $a) {
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
$cnt++;
}
}
/* 合格次数始终显示(含 0 次);只有达到阈值才标绿底。
之前写 '' 会把「考了但没达标」的人整格隐藏,用户要看全部人次。 */
$passLineRow[] = (string)$cnt;
$passLineRow[] = '';
if ($cnt >= $passTimes) {
$marks[$rn . ',' . $examCol[$pid]] = 'green'; // 达标:绿底
}
}
$rows[] = $passLineRow;
$total++;
/* 学号 / 姓名 纵向合并到「合格次数」行(这两列该行本就是空的,合并正确);
「第几次」只合并到最后一次考试行,末行留给「合格次数」标签——
若连合格次数行一起合并,该格会被当作"被合并的非首格"而丢掉值。 */
$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;
}
// 班级 / 组别:连续相同值的相邻学生块合并成一格
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
- 姓名同样按最长姓名估宽
- 每场考试占 2 列,但第 2 列恒为合并区间内的空位 → 给最小宽度 2.5,
把宽度让给真正承载成绩的第 1 列(否则一屏只看得到两场考试) */
$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));
$widths = [];
if ($withCls) {
$widths[] = 14; // 班级名通常较长(如「计算机2401」)
}
if ($withGrp) {
$widths[] = 9;
}
$widths[] = $sidW; // 学号
$widths[] = $nameW; // 姓名
$widths[] = 9; // 第几次
foreach ($examOrder as $pid) {
$widths[] = 10; // 成绩列(每场第 1 列)
$widths[] = 2.5; // 合并区内的空列,给最小宽度即可
}
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,
];
}