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
+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;