From 0ba9c7c33c05b8d41563d7d709cc3cd0f0490af4 Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Fri, 25 Sep 2026 08:32:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(export):=20=E5=A4=9A=E8=80=83=E8=AF=95?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E5=AF=BC=E5=87=BA=EF=BC=88=E6=A8=AA=E5=90=91?= =?UTF-8?q?=E4=BA=A4=E5=8F=89=E8=A1=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 export_multi 接口与多考试选择弹窗: - 第一行显示考试名称,前两列固定为学号/姓名,默认按学号排序 - 每场考试成绩可选「最新成绩」或「最高成绩」(latest 跳过 temp_save 草稿,highest 取跨全部尝试最高分) - 学生取并集(任一考试有提交即纳入),未考的考试单元格留空 - 纯逻辑抽离到 lib/multi_export.php,新增 26 项回归测试(tests/test-multi-export.php) --- api.php | 48 +++++++++++++ assets/app.js | 73 +++++++++++++++++++ assets/style.css | 3 + index.php | 30 ++++++++ lib/multi_export.php | 109 +++++++++++++++++++++++++++++ tests/run-all.sh | 2 +- tests/test-multi-export.php | 136 ++++++++++++++++++++++++++++++++++++ 7 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 lib/multi_export.php create mode 100644 tests/test-multi-export.php diff --git a/api.php b/api.php index 2bdd84e..e57ea35 100644 --- a/api.php +++ b/api.php @@ -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(); diff --git a/assets/app.js b/assets/app.js index 2aaaea0..1011f86 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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 = '(暂无可导出的考试,请先在右上角刷新问卷列表)'; + } else { + const cur = $('#projectSel').value; + list.forEach((p, i) => { + const tr = document.createElement('tr'); + const checked = (p.id === cur) ? ' checked' : ''; + tr.innerHTML = + '' + + '' + (i + 1) + '' + + '' + esc(p.name) + ''; + 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() { diff --git a/assets/style.css b/assets/style.css index a6e5f28..799bc5c 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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; diff --git a/index.php b/index.php index 4e75445..541c3d2 100644 --- a/index.php +++ b/index.php @@ -97,6 +97,7 @@ $rosterCount = Roster::count(); +
@@ -477,6 +478,35 @@ $rosterCount = Roster::count(); + + +