feat(export): 多考试对比导出(横向交叉表)

新增 export_multi 接口与多考试选择弹窗:
- 第一行显示考试名称,前两列固定为学号/姓名,默认按学号排序
- 每场考试成绩可选「最新成绩」或「最高成绩」(latest 跳过 temp_save 草稿,highest 取跨全部尝试最高分)
- 学生取并集(任一考试有提交即纳入),未考的考试单元格留空
- 纯逻辑抽离到 lib/multi_export.php,新增 26 项回归测试(tests/test-multi-export.php)
This commit is contained in:
2026-09-25 08:32:24 +08:00
parent f661862d46
commit 0ba9c7c33c
7 changed files with 400 additions and 1 deletions
+48
View File
@@ -5,6 +5,7 @@
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require __DIR__ . '/lib/multi_export.php';
$action = (string)($_GET['action'] ?? $_POST['action'] ?? '');
@@ -64,6 +65,11 @@ function ny_absent_sheet_rows(array $absent): array
return $out;
}
/**
* 多考试对比导出(成绩交叉矩阵)的纯逻辑见 lib/multi_export.php
* (ny_exam_cell_score / ny_assemble_multi_exam_matrix),便于在 api.php 与单测中复用。
*/
/**
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
*
@@ -854,6 +860,48 @@ try {
echo $bin;
exit;
// ---------------- 多考试对比导出(横向交叉表) ----------------
case 'export_multi':
$idsRaw = (string)($_GET['projectIds'] ?? '');
$ids = array_values(array_unique(array_filter(
array_map('trim', explode(',', $idsRaw)),
static fn($v): bool => $v !== ''
)));
if ($ids === []) {
ny_fail('请至少选择一个问卷 / 考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$scoreMode = ((string)($_GET['scoreMode'] ?? '') === 'highest') ? 'highest' : 'latest';
$c = cfg();
// 逐场考试取数(复用与 data 接口相同的缓存),按选择顺序组装交叉矩阵
$exams = [];
foreach ($ids as $pid) {
$built = ny_build_data($c, $pid, $nameQid, !empty($_GET['force']));
$result = $built['result'];
$exams[$pid] = [
'name' => (string)($result['project']['name'] ?? $pid),
'rows' => (array)($result['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]];
$safe = static fn(string $s): string => preg_replace('/[\\\\\\/:*?"<>|]/u', '_', $s) ?? '';
$filename = '多考试成绩对比_' . count($ids) . '场_'
. ($scoreMode === 'highest' ? '最高分' : '最新成绩') . '_' . date('Ymd_His') . '.xlsx';
$bin = XlsxWriter::renderMulti($sheets);
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="export.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename));
header('Content-Length: ' . strlen($bin));
header('Cache-Control: no-store');
echo $bin;
exit;
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
case 'draw_export':
$raw = ny_request_body();
+73
View File
@@ -1373,6 +1373,79 @@
showAlert(exportTip('缺考名单', cls, grp), 'info');
});
/* ---------------- 多考试对比导出 ---------------- */
// 仅列出真正的"考试"(排除文件夹型项目,folder 或被其他项目作为父级引用)
function nonFolderProjects() {
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
const parentIds = new Set();
state.projects.forEach(p => {
const pid = (p.parent_id || '0');
if (pid && pid !== '0' && pid !== '') parentIds.add(pid);
});
const folderSet = new Set();
state.projects.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
return state.projects.filter(p => !folderSet.has(p.id));
}
function renderMultiExportModal() {
const body = $('#meBody');
const list = nonFolderProjects();
body.innerHTML = '';
if (!list.length) {
body.innerHTML = '<tr><td colspan="3" class="empty">(暂无可导出的考试,请先在右上角刷新问卷列表)</td></tr>';
} else {
const cur = $('#projectSel').value;
list.forEach((p, i) => {
const tr = document.createElement('tr');
const checked = (p.id === cur) ? ' checked' : '';
tr.innerHTML =
'<td><input type="checkbox" class="me-chk" value="' + esc(p.id) + '"' + checked + '></td>' +
'<td>' + (i + 1) + '</td>' +
'<td>' + esc(p.name) + '</td>';
body.appendChild(tr);
});
}
updMeMeta();
}
function updMeMeta() {
const n = $$('#meBody .me-chk').filter(c => c.checked).length;
$('#meMeta').textContent = '已选 ' + n + ' 场';
const all = $$('#meBody .me-chk');
$('#meCheckAll').checked = all.length > 0 && all.every(c => c.checked);
}
$('#btnExportMulti').addEventListener('click', () => {
if (!state.projects.length) return showAlert('请先刷新问卷列表');
renderMultiExportModal();
$('#multiExportModal').classList.add('open');
});
$('#meCheckAll').addEventListener('change', () => {
$$('#meBody .me-chk').forEach(c => { c.checked = $('#meCheckAll').checked; });
updMeMeta();
});
$('#btnMeSelAll').addEventListener('click', () => {
$$('#meBody .me-chk').forEach(c => { c.checked = true; });
$('#meCheckAll').checked = true;
updMeMeta();
});
$('#btnMeClear').addEventListener('click', () => {
$$('#meBody .me-chk').forEach(c => { c.checked = false; });
$('#meCheckAll').checked = false;
updMeMeta();
});
$('#meBody').addEventListener('change', e => {
if (e.target && e.target.classList.contains('me-chk')) updMeMeta();
});
$('#btnMeExport').addEventListener('click', () => {
const ids = $$('#meBody .me-chk').filter(c => c.checked).map(c => c.value);
if (!ids.length) return showAlert('请至少勾选一场考试');
const scoreMode = (document.querySelector('input[name=meScoreMode]:checked') || {}).value || 'latest';
const qs = 'api.php?action=export_multi&projectIds=' + encodeURIComponent(ids.join(',')) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&scoreMode=' + encodeURIComponent(scoreMode) + '&sort=sid';
$('#multiExportModal').classList.remove('open');
location.href = qs;
showAlert('正在导出 ' + ids.length + ' 场考试成绩对比(' + (scoreMode === 'highest' ? '最高成绩' : '最新成绩') + ',按学号排序)', 'info');
});
/* 弹窗关闭:只关最上层那一个(新增学生等弹窗是叠在名单管理之上的,
关全部会连带把底层弹窗一起关掉,不符合预期) */
function closeTopModal() {
+3
View File
@@ -431,6 +431,9 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
.modal-body table.grid th { position: static; }
.modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; }
/* 多考试对比导出:顶部工具条 */
.me-bar { display: flex; align-items: center; gap: 16px; margin: 0 0 10px; }
.me-bar .chk { margin: 0; white-space: nowrap; }
.modal-foot {
display: flex; align-items: center; gap: 8px;
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
+30
View File
@@ -97,6 +97,7 @@ $rosterCount = Roster::count();
<span class="spacer"></span>
<button class="btn" id="btnExport">导出成绩</button>
<button class="btn" id="btnExportAbsent">导出缺考</button>
<button class="btn" id="btnExportMulti" title="勾选多场考试,横向对比同一批学生的成绩">多考试对比导出</button>
</section>
<section class="cards" id="statCards">
@@ -477,6 +478,35 @@ $rosterCount = Roster::count();
</div>
</div>
<!-- 多考试对比导出 -->
<div class="modal" id="multiExportModal">
<div class="modal-box wide">
<div class="modal-head"><h3>多考试成绩对比导出</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<p class="hint" style="margin:0 0 12px">勾选多场考试后导出一张横向对比表:第一行是考试名称,前两列是学号 / 姓名,默认按学号排序;每场考试的成绩可按「最新」或「最高」显示。</p>
<div class="me-bar">
<label class="chk"><input type="radio" name="meScoreMode" value="latest" checked> 最新成绩</label>
<label class="chk"><input type="radio" name="meScoreMode" value="highest"> 最高成绩</label>
<span class="spacer"></span>
<button class="btn sm" id="btnMeSelAll">全选</button>
<button class="btn sm" id="btnMeClear">清空</button>
</div>
<div class="table-scroll mid">
<table class="grid compact" id="meTable">
<thead><tr><th style="width:6%"><input type="checkbox" id="meCheckAll" title="全选"></th><th style="width:8%">顺序</th><th>考试名称</th></tr></thead>
<tbody id="meBody"><tr><td colspan="3" class="empty">(暂无可导出的考试,请先在右上角刷新问卷列表)</td></tr></tbody>
</table>
</div>
<p class="hint" id="meMeta">已选 0 场</p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnMeExport">导出对比表</button>
</div>
</div>
</div>
<script>
window.NY = {
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
+109
View File
@@ -0,0 +1,109 @@
<?php
/**
* 多考试对比导出:成绩交叉矩阵的纯逻辑(不依赖卷王客户端,便于单测)
*
* 矩阵约定(满足需求):
* - 第一行 = 表头,列 A=学号、列 B=姓名、列 C 起每个选中的考试占一列,列标题为考试名称
* - 数据行:列 A=学号、列 B=姓名、列 C 起为该生在该场考试里的最新/最高成绩(未考则为空)
* - 默认按学号升序(数值优先,否则自然序)
*/
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 {
$a = (string)$a;
$b = (string)$b;
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
$head = array_merge(['学号', '姓名'], array_map(static fn($pid) => $examNames[$pid], $examOrder));
$rows = [];
foreach ($sids as $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];
}
+1 -1
View File
@@ -18,7 +18,7 @@ echo "PHP = $PHP"
echo "NODE = $NODE"
echo "================ PHP 自测 ================"
for f in tests/test-aggregate.php tests/test-new-features.php tests/test-config-persist.php tools/test-xlsxwriter.php; do
for f in tests/test-aggregate.php tests/test-new-features.php tests/test-config-persist.php tools/test-xlsxwriter.php tests/test-multi-export.php; do
echo ""
echo ">>> $f"
"$PHP" $INI "$f"
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* 多考试对比导出(成绩交叉矩阵)回归测试
*
* 覆盖 ny_exam_cell_score / ny_assemble_multi_exam_matrix:
* 1) 矩阵结构:第一行 = 考试名称,前两列 = 学号 / 姓名
* 2) 默认按学号升序
* 3) 最新成绩 = attempts_list 中最近一次「非暂存」提交;最高成绩 = 跨全部尝试的最高分
* 4) 草稿(temp_save)不计入"最新成绩",但计入"最高成绩"
* 5) 重考分数更低时,最新≠最高
* 6) 未考的考试列为空;学生并集(任一考试有提交即纳入)
* 7) 同一学号跨考试姓名以首个非空为准
*/
require __DIR__ . '/../lib/multi_export.php';
$fail = 0;
$pass = 0;
function check(string $name, bool $cond, string $detail = ''): void
{
global $fail, $pass;
if ($cond) {
$pass++;
echo " ✅ {$name}\n";
} else {
$fail++;
echo " ❌ {$name} {$detail}\n";
}
}
/**
* 构造一名学生的聚合行(与 DataService::aggregateByStudent 产出的结构保持一致)。
* 分数按时间升序给出(旧 -> 新),tempMarks 标记对应提交是否为草稿。
*/
function stuRow(string $sid, string $name, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
{
$list = [];
$t = 1_000_000;
foreach ($scoresAsc as $i => $sc) {
$t += 600;
$list[] = [
'idx' => $i + 1,
'submit_ts' => $t,
'score' => $sc,
'temp_save' => !empty($tempMarks[$i]),
'duration' => 120,
];
}
return [
'sid' => $sid,
'name' => $name,
'cls' => '1班',
'grp' => 'A',
'score' => end($scoresAsc),
'best_score' => $best ?? (float)max($scoresAsc),
'attempts' => count($scoresAsc),
'attempts_list'=> $list,
];
}
/* ---------------- 场景 ---------------- */
$examMath = [
'name' => '数学期中',
'rows' => [
// 张三:第一次 90,重考降到 80(最新≠最高);草稿 95 只算最高
stuRow('2021001', '张三', [90.0, 80.0], [false, false], 90.0),
stuRow('2021002', '李四', [70.0]),
// 草稿分:time1 真实 60,time2 草稿 99 → 最新应取 60,最高取 99
stuRow('2021004', '赵六', [60.0, 99.0], [false, true], 99.0),
],
];
$examEnglish = [
'name' => '英语期中',
'rows' => [
stuRow('2021001', '张三', [85.0]),
stuRow('2021003', '王五', [60.0]),
],
];
echo "== 最新成绩矩阵 ==\n";
$m = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'latest');
check('表头第一列是学号', ($m['head'][0] ?? '') === '学号', json_encode($m['head']));
check('表头第二列是姓名', ($m['head'][1] ?? '') === '姓名');
check('表头第三列起是考试名称', ($m['head'][2] ?? '') === '数学期中' && ($m['head'][3] ?? '') === '英语期中', json_encode($m['head']));
check('考试列顺序保持选择顺序', $m['exam_order'] === ['math', 'eng']);
// 学号排序:2021001,2021002,2021003,2021004
// 注意:PHP 会把数字字符串键自动转成 int('2021001' -> 2021001),排序结果本身正确,
// 这里用 strval 归一后比较排序顺序(显示层 XlsxWriter 会再转回字符串,无影响)。
$sids = array_map('strval', array_column($m['rows'], 0));
check('按学号升序', $sids === ['2021001', '2021002', '2021003', '2021004'], json_encode($sids));
// 张三:数学最新=80(重考降分,跳过草稿),英语最新=85
$zhang = $m['rows'][0];
check('张三行:姓名正确', $zhang[1] === '张三');
check('张三-数学最新=80(重考降分)', (string)$zhang[2] === '80', (string)$zhang[2]);
check('张三-英语最新=85', (string)$zhang[3] === '85', (string)$zhang[3]);
// 李四只在数学:英语为空
$li = $m['rows'][1];
check('李四-数学=70', (string)$li[2] === '70', (string)$li[2]);
check('李四-英语为空', $li[3] === '', var_export($li[3], true));
// 王五只在英语:数学为空
$wang = $m['rows'][2];
check('王五-数学为空', $wang[2] === '', var_export($wang[2], true));
check('王五-英语=60', (string)$wang[3] === '60', (string)$wang[3]);
// 赵六:数学最新跳过草稿=60
$zhao = $m['rows'][3];
check('赵六-数学最新跳过草稿=60', (string)$zhao[2] === '60', (string)$zhao[2]);
echo "== 最高成绩矩阵 ==\n";
$mh = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'highest');
$zhangH = $mh['rows'][0];
check('张三-数学最高=90(含重考)', (string)$zhangH[2] === '90', (string)$zhangH[2]);
check('张三-英语最高=85', (string)$zhangH[3] === '85', (string)$zhangH[3]);
$zhaoH = $mh['rows'][3];
check('赵六-数学最高含草稿=99', (string)$zhaoH[2] === '99', (string)$zhaoH[2]);
echo "== 跨考试姓名 / 空考试 ==\n";
// 仅一场考试时仍可正常产出;姓名取首个非空
$single = ny_assemble_multi_exam_matrix(['math' => $examMath], 'latest');
check('单场考试表头正确', $single['head'] === ['学号', '姓名', '数学期中']);
check('单场考试行数=3', count($single['rows']) === 3, (string)count($single['rows']));
echo "== ny_exam_cell_score 单测 ==\n";
$r0 = stuRow('X', 'Y', [10.0, 20.0]); // latest=20, highest=20
check('latest=20', ny_exam_cell_score($r0, 'latest') === 20.0);
check('highest=20', ny_exam_cell_score($r0, 'highest') === 20.0);
$r1 = stuRow('X', 'Y', [20.0, 10.0]); // 重考降分
check('latest 取最近=10', ny_exam_cell_score($r1, 'latest') === 10.0);
check('highest 取最高=20', ny_exam_cell_score($r1, 'highest') === 20.0);
$r2 = stuRow('X', 'Y', [50.0, 88.0], [false, true]); // 草稿
check('latest 跳过草稿=50', ny_exam_cell_score($r2, 'latest') === 50.0);
check('highest 含草稿=88', ny_exam_cell_score($r2, 'highest') === 88.0);
$r3 = ['sid' => 'X', 'name' => 'Y', 'score' => 77.0, 'best_score' => 77.0]; // 无明细
check('无明细 latest 退回代表行=77', ny_exam_cell_score($r3, 'latest') === 77.0);
check('无明细 highest=77', ny_exam_cell_score($r3, 'highest') === 77.0);
echo "\n通过 {$pass} 项,失败 {$fail} 项\n";
exit($fail === 0 ? 0 : 1);