feat(export/dashboard): 导出增强与大屏公开化

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

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

测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
2026-09-27 22:04:17 +08:00
parent 0ba9c7c33c
commit d05fd4a9b5
34 changed files with 4290 additions and 305 deletions
+355 -28
View File
@@ -132,8 +132,11 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
return ['result' => $result, 'cached' => false, 'at' => time()];
}
// 会话保护:除 auth_check 外,未登录一律拒访
if ($action !== 'auth_check' && !ny_is_auth()) {
// 会话保护:白名单外的动作未登录一律拒访。
// dashboard 进白名单是因为班级大屏要公开访问;它在 case 内部自己兜底——
// 未登录时必须带 ?class=,且只回传该班数据(全卷与其它班级一律不回)。
$PUBLIC_ACTIONS = ['auth_check', 'dashboard'];
if (!in_array($action, $PUBLIC_ACTIONS, true) && !ny_is_auth()) {
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
exit;
}
@@ -305,6 +308,20 @@ try {
$students = $result['roster'] ?: [];
$rows = $result['rows'] ?: [];
/* 公开访问(未登录)只允许看指定班级,且只看这一个类的数据:
名单、答卷都在服务端先按班级裁掉,不给前端任何"全卷"数据,
避免公开链接顺手拿到其它班级甚至全校的成绩。 */
$publicCls = '';
if (!ny_is_auth()) {
$publicCls = trim((string)($_GET['class'] ?? ''));
if ($publicCls === '') {
ny_fail('缺少班级参数(公开访问需指定 ?class=)');
}
$inCls = static fn($x) => (trim((string)($x['cls'] ?? '')) ?: '(未分班)') === $publicCls;
$students = array_values(array_filter($students, $inCls));
$rows = array_values(array_filter($rows, $inCls));
}
$byCls = [];
foreach ($students as $s) {
$k = trim((string)($s['cls'] ?? '')) ?: '(未分班)';
@@ -414,14 +431,32 @@ try {
return strcmp((string)$a['class'], (string)$b['class']);
});
ny_ok([
$payload = [
'project' => $result['project'],
'full_score' => $fs,
'pass_rate' => $passRate,
'overall' => $result['stats'],
'classes' => $classes,
'updated_at' => $result['updated_at'],
]);
];
if ($publicCls !== '') {
// 公开访问:overall 换成该班自己的汇总(保持 stats 原有键名,前端按 attempts_total 读取),
// classes 只留这一个班,并打上 public 标记供前端展示"公开页面"
$st = $statOf($students, $rows, $fs);
$payload['overall'] = array_merge($result['stats'], [
'roster_total' => $st['total'],
'submitted' => $st['submitted'],
'absent' => $st['absent'],
'submit_rate' => $st['rate'],
'avg_score' => $st['avg'],
'max_score' => $st['max'],
'min_score' => $st['min'],
'avg_duration' => $st['avg_duration'],
'attempts_total' => $st['attempts'],
]);
$payload['public'] = true;
}
ny_ok($payload);
// ---------------- 名单 ----------------
case 'roster':
@@ -711,10 +746,17 @@ try {
$mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all';
$splitCls = ((string)($_GET['split'] ?? '') === 'cls');
$isLatest = ($mode === 'latest');
// 每人一行时的成绩口径:latest(默认,最近一次真实提交)| best(最高分)
// 「成绩明细」的显示选项就是这一项——默认最新成绩,可选最高成绩。
$rowScoreMode = ((string)($_GET['scoreMode'] ?? '') === 'best') ? 'best' : 'latest';
// skipTemp=1:每次考试一行时排除暂存草稿(草稿不是有效成绩);includeTemp=1 可保留
$skipTemp = (!empty($_GET['skipTemp']) || !empty($_GET['skip_temp']))
&& empty($_GET['includeTemp']) && empty($_GET['include_temp']);
$scoreTag = $isLatest ? ($rowScoreMode === 'best' ? '_最高成绩' : '_最新成绩') : '_每次考试';
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
. '_' . $safe((string)$result['project']['name'])
. $tag
. ($isLatest ? '_最新成绩' : '')
. $scoreTag
. '_' . date('Ymd_His') . '.xlsx';
// ---------------- 列定义(可通过 cols= 参数挑选) ----------------
@@ -723,17 +765,18 @@ try {
'name' => '姓名',
'cls' => '班级',
'grp' => '组别',
'score' => $isLatest ? '最新成绩' : '成绩',
'score' => $isLatest ? ($rowScoreMode === 'best' ? '最高成绩' : '最新成绩') : '成绩',
'best_score' => '最高分',
'attempts' => '考试次数',
'idx' => '第几次',
'is_best' => '是否最高分',
'submit_at' => $isLatest ? '最近提交时间' : '提交时间',
'duration_txt' => '考试时长',
'duration' => '考试时长(秒)',
];
$defaultCols = $isLatest
? ['sid', 'name', 'cls', 'grp', 'score', 'best_score', 'attempts', 'submit_at', 'duration_txt', 'duration']
: ['sid', 'name', 'cls', 'grp', 'score', 'attempts', 'idx', 'submit_at', 'duration_txt', 'duration'];
: ['sid', 'name', 'cls', 'grp', 'score', 'best_score', 'attempts', 'idx', 'is_best', 'submit_at', 'duration_txt', 'duration'];
$want = array_values(array_filter(
array_map('trim', explode(',', (string)($_GET['cols'] ?? ''))),
static fn($v): bool => $v !== ''
@@ -746,7 +789,8 @@ try {
// ---------------- 数据行 ----------------
$sortMode = (string)($_GET['sort'] ?? '');
if ($isLatest) {
// 每人一行:成绩取「最近一次真实提交」的分数(不受 dedupe 影响),并附最高分与考试次数。
// 每人一行:成绩按「最新成绩」(最近一次真实提交)或「最高成绩」(best_score),
// 并附最高分与考试次数。
// 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save,
// 否则导出的"最新成绩"可能是一份草稿分。
$all = [];
@@ -762,19 +806,50 @@ try {
if ($last === null && $list !== []) {
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
}
// best 口径:优先用聚合行 best_score;缺失时从 attempts_list 现算,
// 保证「最高成绩」列与代表成绩一致(避免出现"最高分比成绩还低"的怪表)。
$bestScore = $r['best_score'] ?? null;
if ($rowScoreMode === 'best') {
if ($bestScore === null || $bestScore === '') {
foreach ($list as $a) {
$sv = $a['score'] ?? null;
if ($sv === null || $sv === '') {
continue;
}
if ($bestScore === null || (float)$sv > (float)$bestScore) {
$bestScore = $sv;
}
}
}
}
// 最高分所在那次(用于「提交时间 / 时长」跟随口径,避免最高分配了别次的耗时)
$bestRow = null;
if ($rowScoreMode === 'best' && $bestScore !== null && $bestScore !== '') {
foreach ($list as $a) {
if ((string)($a['score'] ?? '') === (string)$bestScore) {
$bestRow = $a;
break;
}
}
}
$useRow = ($rowScoreMode === 'best') ? ($bestRow ?: $last) : $last;
$all[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'score' => $last !== null ? ($last['score'] ?? null) : ($r['score'] ?? null),
'best_score' => $r['best_score'] ?? null,
'score' => ($rowScoreMode === 'best')
? (($bestScore === null || $bestScore === '') ? null : $bestScore)
: ($useRow !== null ? ($useRow['score'] ?? null) : ($r['score'] ?? null)),
'best_score' => $bestScore,
'attempts' => (int)($r['attempts'] ?? 1),
'idx' => (int)($r['attempts'] ?? 1),
'submit_ts' => (int)($r['last_submit_ts'] ?? 0),
'submit_at' => (string)($r['last_submit_at'] ?? ($last['submit_at'] ?? ($r['submit_at'] ?? '-'))),
'duration_txt' => (string)($last['duration_txt'] ?? ($r['duration_txt'] ?? '-')),
'duration' => $last['duration'] ?? ($r['duration'] ?? null),
'submit_ts' => ($useRow !== null && isset($useRow['submit_ts']))
? (int)$useRow['submit_ts']
: (int)($r['last_submit_ts'] ?? 0),
'submit_at' => (string)($useRow['submit_at'] ?? ($r['last_submit_at'] ?? ($r['submit_at'] ?? '-'))),
'duration_txt' => (string)($useRow['duration_txt'] ?? ($r['duration_txt'] ?? '-')),
'duration' => $useRow['duration'] ?? ($r['duration'] ?? null),
];
}
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
@@ -785,6 +860,9 @@ try {
} else {
// 每次考试一行(同一学号考了 N 次就有 N 行)
$all = DataService::expandAttempts($rows, true);
if ($skipTemp) {
$all = array_values(array_filter($all, static fn(array $r): bool => empty($r['temp_save'])));
}
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
$all = ny_sort_by_score($all, $sortMode === 'score_desc');
} else {
@@ -792,18 +870,33 @@ try {
}
}
// 合格线:本卷满分 × 合格率(0 表示本卷没配满分,不判定合格)
[$effFs, $effPr] = ny_resolve_score_cfg($projectId, $c);
$passLineVal = ($effFs > 0 && $effPr > 0) ? ($effFs * $effPr / 100.0) : 0.0;
$scoreColIdx = array_search('score', $cols, true);
// ---------------- 组装工作表 ----------------
$toRows = static function (array $list) use ($cols, $colLabels): array {
$head = [];
// 出表方式 = 「每次考试一行」时,成绩达合格线的行标绿底(与多考试块表的口径一致)。
// $marks 每次调用先重置,因此分班级多工作表时各表的标记不会串到别的表里。
$marks = [];
$toRows = static function (array $list) use ($cols, $colLabels, $mode, $passLineVal, $scoreColIdx, &$marks): array {
$marks = [];
$head = [];
foreach ($cols as $c) {
$head[] = $colLabels[$c];
}
$out = [$head];
$rn = 1; // 工作表内行号(1-based,与 cell_styles 一致)
foreach ($list as $r) {
$rn++;
$line = [];
foreach ($cols as $c) {
foreach ($cols as $i => $c) {
$v = $r[$c] ?? null;
$line[] = ($v === null || $v === '') ? '' : (string)$v;
if ($i === $scoreColIdx && $mode !== 'latest' && $passLineVal > 0
&& $v !== null && $v !== '' && is_numeric($v) && (float)$v >= $passLineVal) {
$marks[$rn . ',' . ($i + 1)] = 'green';
}
}
$out[] = $line;
}
@@ -826,13 +919,25 @@ try {
}
ksort($byCls, SORT_NATURAL | SORT_FLAG_CASE);
foreach ($byCls as $clsName => $list) {
$sheets[] = ['name' => (string)$clsName, 'rows' => $toRows($list), 'widths' => $widths];
$sheets[] = [
'name' => (string)$clsName,
'rows' => $toRows($list),
'widths' => $widths,
'cell_styles' => $marks,
];
}
} else {
$sheets[] = ['name' => $isLatest ? '成绩明细(最新)' : '成绩明细', 'rows' => $toRows($all), 'widths' => $widths];
$sheets[] = [
'name' => $isLatest ? ($rowScoreMode === 'best' ? '成绩明细(最高)' : '成绩明细(最新)') : '成绩明细',
'rows' => $toRows($all),
'widths' => $widths,
'cell_styles' => $marks,
];
}
// 附加:缺考名单 / 未匹配答卷(仅非空时输出)
if ($absent !== []) {
// noAbsent=1 时不附缺考名单("成绩明细只导成绩"的场景)
$noAbsent = !empty($_GET['noAbsent']) || !empty($_GET['no_absent']);
if ($absent !== [] && !$noAbsent) {
$sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]];
}
$unmatched = array_values((array)($result['unmatched'] ?? []));
@@ -849,6 +954,23 @@ try {
}
}
/* format=json:不下载文件,直接回传装配结果。
「打印成绩明细 / 打印缺考名单」走的就是这条,前端拿它渲染打印视图;
否则前端会拿到 xlsx 二进制再 res.json(),报 "Unexpected token 'P'"。
缺考名单工作表也在 $sheets 里,所以勾选「附缺考名单」时打印同样看得到。 */
if (in_array($action, ['export', 'export_absent'], true)
&& (string)($_GET['format'] ?? '') === 'json') {
ny_ok([
'sheets' => $sheets,
'meta' => [
'projectName' => (string)($result['project']['name'] ?? ''),
'passLine' => $passLineVal,
'passRate' => $effPr,
'fullScore' => $effFs,
],
]);
}
// 说明:所有单元格按「共享字符串 + 文本格式」写入,
// 既不会把长学号变成科学计数法,也不会把 = + - @ 开头的内容当公式执行(规避 CSV 公式注入)
$bin = XlsxWriter::renderMulti($sheets);
@@ -874,25 +996,230 @@ try {
$scoreMode = ((string)($_GET['scoreMode'] ?? '') === 'highest') ? 'highest' : 'latest';
$c = cfg();
// 班级 / 组别筛选(支持多选,逗号分隔;空 = 全部),与单考试导出口径一致
$multiVals = static function (string $raw): array {
return array_values(array_unique(array_filter(
array_map('trim', explode(',', $raw)),
static function ($v): bool { return $v !== ''; }
)));
};
$clsPick = $multiVals((string)($_GET['cls'] ?? ''));
$grpPick = $multiVals((string)($_GET['grp'] ?? ''));
$hit = static function (array $r) use ($clsPick, $grpPick): bool {
if ($clsPick !== [] && !in_array(trim((string)($r['cls'] ?? '')), $clsPick, true)) {
return false;
}
if ($grpPick !== [] && !in_array(trim((string)($r['grp'] ?? '')), $grpPick, true)) {
return false;
}
return true;
};
// 逐场考试取数(复用与 data 接口相同的缓存),按选择顺序组装交叉矩阵
$exams = [];
$exams = [];
$seenCls = [];
$seenGrp = [];
foreach ($ids as $pid) {
$built = ny_build_data($c, $pid, $nameQid, !empty($_GET['force']));
$result = $built['result'];
$rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
foreach ($rows as $r) {
$cv = trim((string)($r['cls'] ?? ''));
$gv = trim((string)($r['grp'] ?? ''));
if ($cv !== '') { $seenCls[$cv] = true; }
if ($gv !== '') { $seenGrp[$gv] = true; }
}
$exams[$pid] = [
'name' => (string)($result['project']['name'] ?? $pid),
'rows' => (array)($result['rows'] ?? []),
'rows' => $rows,
];
}
$matrix = ny_assemble_multi_exam_matrix($exams, $scoreMode);
$sheetRows = array_merge([$matrix['head']], $matrix['rows']);
$widths = array_merge([20, 14], array_fill(0, count($matrix['exam_order']), 12));
$sheets = [['name' => '多考试对比', 'rows' => $sheetRows, 'widths' => $widths]];
// 导出形态:
// block = 两行表头的「每次考试」块表(默认;需求截图版式)
// summary = 成绩对比(一行一个学生、一列一场考试,取最新 / 最高)
// both = 块表 + 成绩对比,一次出
// 兼容旧参数:matrix→summary、merged / attempts→block
$modeRaw = (string)($_GET['mode'] ?? '');
$modeMap = [
'summary' => 'summary',
'matrix' => 'summary',
'block' => 'block',
'merged' => 'block',
'attempts' => 'block',
'att' => 'block',
'both' => 'both',
];
$mode = $modeMap[$modeRaw] ?? 'block';
// 每次考试明细默认跳过暂存草稿(草稿不是有效成绩);includeTemp=1 可保留
$includeTemp = !empty($_GET['includeTemp']) || !empty($_GET['include_temp']);
// 合格线 / 合格次数阈值:用于「合格次数」行(达标绿底)与最高分标红
// 合格线 = 每场考试各自的满分 × 合格率;判「合格次数」时按各场分别算,
// 因此这里逐场解析(本卷独立配置优先,其次全局默认)。
$passTimesIn = $_GET['passTimes'] ?? null;
$passTimes = ($passTimesIn === null || $passTimesIn === '') ? 1 : max(1, (int)$passTimesIn);
$markBest = !array_key_exists('markBest', $_GET) || !empty($_GET['markBest']);
$passRow = !array_key_exists('passRow', $_GET) || !empty($_GET['passRow']);
// 每场考试的合格线(pid => float),随 $exams 一起传进装配函数
$passLineOf = [];
foreach ($ids as $pid) {
[$fsP, $prP] = ny_resolve_score_cfg($pid, $c);
$passLineOf[$pid] = round($fsP * $prP / 100, 4);
}
// 班级 / 组别列:选了多班级 / 多组别才补出(只选 1 个班级且未选组别 → 两列都不出)
$colFlags = ny_multi_col_flags($clsPick, $grpPick, array_keys($seenCls), array_keys($seenGrp));
if (array_key_exists('withCls', $_GET)) {
$colFlags['with_cls'] = !empty($_GET['withCls']);
}
if (array_key_exists('withGrp', $_GET)) {
$colFlags['with_grp'] = !empty($_GET['withGrp']);
}
// 单场 + 明细列不含耗时/时间 → 走「宽表」版式(学号|姓名|第1次…第N次|合格次数),
// 与需求截图二一致;带了耗时/时间则每人一行放不下,退回两行表头块表。
$wantWide = !empty($_GET['wide']);
$sheets = [];
$block = null;
if ($mode === 'block' || $mode === 'both') {
if ($wantWide && count($ids) === 1) {
$pid = $ids[0];
$widRes = ny_assemble_attempt_wide_sheet($exams[$pid], [
'skip_temp' => !$includeTemp,
'pass_line' => $passLineOf[$pid],
'pass_times'=> $passTimes,
'mark_best' => $markBest,
'pass_row' => $passRow,
]);
$sheets[] = [
'name' => '每次考试',
'rows' => $widRes['rows'],
'widths' => $widRes['widths'],
'merges' => $widRes['merges'],
'header_rows' => $widRes['header_rows'],
'cell_styles' => $widRes['cell_styles'],
];
$block = $widRes;
} else {
// 多场:各场合格线可能不同,装配函数按「单条合格线」设计,
// 这里取各场合格线中的最低值作为汇总口径(避免把某场算得过严),
// 并在 meta 里回报每场各自的合格线供前端说明。
$lines = array_values($passLineOf);
$minLine = $lines === [] ? null : min($lines);
$block = ny_assemble_block_sheet($exams, [
'skip_temp' => !$includeTemp,
'with_cls' => $colFlags['with_cls'],
'with_grp' => $colFlags['with_grp'],
'pass_line' => $passRow ? $minLine : null,
'pass_times' => $passTimes,
'mark_best' => $markBest,
'pass_row' => $passRow,
]);
$sheets[] = [
'name' => '每次考试',
'rows' => $block['rows'],
'widths' => $block['widths'],
'merges' => $block['merges'],
'header_rows' => $block['header_rows'],
'cell_styles' => $block['cell_styles'],
];
}
}
if ($mode === 'summary' || $mode === 'both') {
$matrix = ny_assemble_multi_exam_matrix($exams, $scoreMode);
$sheetRows = array_merge([$matrix['head']], $matrix['rows']);
$widths = array_merge([20, 14], array_fill(0, count($matrix['exam_order']), 12));
$sheets[] = ['name' => '成绩对比', 'rows' => $sheetRows, 'widths' => $widths, 'header_rows' => 1];
}
if ($sheets === []) {
$sheets[] = ['name' => '每次考试', 'rows' => [['暂无数据']], 'widths' => [12], 'header_rows' => 1];
}
/* 附缺考名单:与单考试导出同一口径——「名单里有、答卷里没有」即为缺考。
多考试场景取「所选考试全都没交」才算缺考(某场没交但在别场交过的人
已在块表里以「未考」体现,再列进缺考名单属于重复噪音)。 */
$noAbsent = !empty($_GET['noAbsent']) || !empty($_GET['no_absent']);
if (!$noAbsent) {
$roster = Roster::all();
// 逐场收集「已提交学号」,取并集:某场没交但在别场交过的人
// 已在块表里以「未考」体现,不再重复列进缺考名单
$submittedAny = [];
foreach ($exams as $ex) {
foreach ((array)($ex['rows'] ?? []) as $r) {
$sidR = trim((string)($r['sid'] ?? ''));
if ($sidR !== '') {
$submittedAny[$sidR] = true;
}
}
}
$absentRows = [];
foreach ($roster as $s) {
$sidR = trim((string)($s['sid'] ?? ''));
if ($sidR === '' || isset($submittedAny[$sidR])) {
continue;
}
if (!$hit($s)) {
continue; // 跟随班级 / 组别筛选
}
$absentRows[] = $s;
}
if ($absentRows !== []) {
$sheets[] = [
'name' => '缺考名单',
'rows' => ny_absent_sheet_rows($absentRows),
'widths' => [16, 14, 16, 10],
];
}
}
$safe = static fn(string $s): string => preg_replace('/[\\\\\\/:*?"<>|]/u', '_', $s) ?? '';
$filename = '多考试成绩对比_' . count($ids) . '场_'
. ($scoreMode === 'highest' ? '最高分' : '最新成绩') . '_' . date('Ymd_His') . '.xlsx';
$stamp = date('Ymd_His');
// 文件名带上筛选范围,便于分辨"这次导的是哪个班"
$scopeTag = ($clsPick !== [] ? '_' . $safe(implode('+', array_slice($clsPick, 0, 3)) . (count($clsPick) > 3 ? '等' : '')) : '')
. ($grpPick !== [] ? '_' . $safe(implode('+', array_slice($grpPick, 0, 3)) . (count($grpPick) > 3 ? '等' : '')) : '');
if ($mode === 'block') {
$filename = '每次考试_' . count($ids) . '场' . $scopeTag . '_' . $stamp . '.xlsx';
} elseif ($mode === 'both') {
$filename = '每次考试与成绩对比_' . count($ids) . '场' . $scopeTag . '_' . $stamp . '.xlsx';
} else {
$filename = '多考试成绩对比_' . count($ids) . '场' . $scopeTag . '_'
. ($scoreMode === 'highest' ? '最高分' : '最新成绩') . '_' . $stamp . '.xlsx';
}
// format=json:把同一套装配结果原样交给前端——「多考试打印」用它渲染 HTML,
// 保证打印版式与导出的 Excel 完全一致(同一份逻辑,不在前端重写排版规则)
if ((string)($_GET['format'] ?? '') === 'json') {
$examNames = [];
foreach ($exams as $pid => $ex) {
$examNames[] = (string)($ex['name'] ?? $pid);
}
ny_ok([
'sheets' => $sheets,
'filename' => $filename,
'meta' => [
'mode' => $mode,
'wide' => ($wantWide && count($ids) === 1),
'examCount' => count($ids),
'examNames' => $examNames,
'cls' => $clsPick,
'grp' => $grpPick,
'scoreMode' => $scoreMode,
'with_cls' => $colFlags['with_cls'],
'with_grp' => $colFlags['with_grp'],
'students' => ($block !== null ? $block['students'] : null),
'rows' => ($block !== null ? $block['total_rows'] : null),
// 合格口径:前端据此在打印说明里写清楚"合格线几分、几次算通过"
'pass_row' => ($block !== null ? !empty($block['pass_row']) : false),
'pass_times' => $passTimes,
'pass_lines' => $passLineOf,
],
]);
}
$bin = XlsxWriter::renderMulti($sheets);
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');