Compare commits

...
3 Commits
Author SHA1 Message Date
nxxy335 e031b3858e feat: 新数据高亮每 30 秒自动清除一次
- 新增独立定时器 startHlAutoClear:每 hlAutoClearSec(默认30)秒扫描一次,
  若仍有高亮则清空并重新渲染表格与计数徽标;无高亮时不打扰。
- 既保留'比闪一下停留更久'的可见性,又避免绿色底纹长期堆积失去重点。
- 清除按钮 tooltip 同步说明'每 30 秒自动清除一次'。
- 与手动清除(#btnClearHl)、切换项目重置高亮互不冲突。
2026-09-10 10:55:19 +08:00
nxxy335 0174b2abc2 fix: 考试次数与历史始终展示全部提交(不再被 dedupe 吞成 1 条)
- aggregateByStudent 修复:attempts(真实次数) 与 attempts_list(每次提交明细)
  无论 dedupe 取值都使用全部提交;dedupe 仅决定主明细表代表成绩取最新
  还是最高分。此前 dedupe=true(默认)把 attempts 写成 1、attempts_list 只留
  最新一份,导致考试次数列和点击历史都只显示 1 条,多次刷题数据丢失。
- best_score 改为跨全部尝试的真实最高分,修复历史弹窗标题最高分错误。
- 新增 tests/test-aggregate.php 覆盖:11次/2次/1次提交、dedupe 两种取值、
  重考低分、导出展开全部记录,共 17 项断言全部通过。

数据印证:第二周测试(1).xlsx 共 146 份提交 / 35 人,31 人多次提交
(最多 11 次),修复后考试次数与历史均完整呈现。
2026-09-10 10:41:42 +08:00
nxxy335 14c7fa7177 feat: 成绩明细考试次数/历史成绩/导出排序/高亮常亮
- 成绩明细成绩列后新增「考试次数」列(可排序)
- 点击任意成员弹窗查看历史成绩(修复内联 onclick 找不到 IIFE 内函数、多次刷题下钻完全失效的 bug;仅 1 次也能点开)
- 历史成绩展示方式可切换:默认点击成员弹窗;可选「成绩明细内全部展开」(每次考试一行,标注第几次/最佳)
- 新数据绿色底纹常亮(不再黄闪一下):变更检测纳入 attempts/最近提交时间(重考低分也能发现);高亮跨刷新累积,新增「清除高亮」按钮,切换项目/姓名题时清零
- 导出成绩可选排序:按提交时间(默认)或按学号(同学号内按提交时间降序);导出全部考试记录并始终带「考试次数/第几次」列
- 优化排序:空值恒排末尾、主键相同时按学号次级排序;提交时间列显示「最近一次提交」
- 后端:DataService 补 last_submit_ts/last_submit_at/attempts_list 的 idx 与 is_best;新增 expandAttempts 与 sortForExport;修复 expandAttempts 降序比较器写反
- 设置新增 history_display/export_sort 两项配置(局部保存不触发重连、非法值归一化);缺考导出按学号排序
- 设置里补充「只保留最新一份」与考试次数的关系说明
2026-09-10 10:10:02 +08:00
7 changed files with 595 additions and 69 deletions
+30 -4
View File
@@ -45,7 +45,7 @@ try {
$in = $_POST; $in = $_POST;
} }
$old = cfg(); $old = cfg();
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords']; $allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
$new = $old; $new = $old;
foreach ($allowed as $k) { foreach ($allowed as $k) {
if (array_key_exists($k, $in)) { if (array_key_exists($k, $in)) {
@@ -68,6 +68,14 @@ try {
if (array_key_exists('layout', $in)) { if (array_key_exists('layout', $in)) {
$new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default'; $new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
} }
// 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
if (array_key_exists('history_display', $in)) {
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
}
// 成绩导出排序:time(按提交时间降序,默认) / sid(按学号)
if (array_key_exists('export_sort', $in)) {
$new['export_sort'] = ($in['export_sort'] === 'sid') ? 'sid' : 'time';
}
// 各试卷独立满分(覆盖整个 full_scores 映射) // 各试卷独立满分(覆盖整个 full_scores 映射)
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) { if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
$new['full_scores'] = $in['full_scores']; $new['full_scores'] = $in['full_scores'];
@@ -476,23 +484,41 @@ try {
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
if ($action === 'export_absent') { if ($action === 'export_absent') {
// 与页面缺考名单一致:按学号升序(纯数字按数值,否则自然排序)
usort($absent, static function ($x, $y): int {
$a = trim((string)($x['sid'] ?? ''));
$b = trim((string)($y['sid'] ?? ''));
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
fputcsv($out, ['学号', '姓名', '班级', '组别']); fputcsv($out, ['学号', '姓名', '班级', '组别']);
foreach ($absent as $r) { foreach ($absent as $r) {
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']); fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
} }
} else { } else {
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)', '刷题次数']); // 导出全部考试记录(同一学号考了 N 次就有 N 行),并保留"考试次数"
foreach ($rows as $r) { $sortMode = (string)($_GET['sort'] ?? '');
$sortMode = ($sortMode === 'sid') ? 'sid' : 'time';
$all = DataService::expandAttempts($rows, true);
// time=按提交时间降序;sid=按学号(同学号内按提交时间降序)
$all = DataService::sortForExport($all, $sortMode);
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '考试次数', '第几次', '提交时间', '考试时长', '考试时长(秒)']);
foreach ($all as $r) {
fputcsv($out, [ fputcsv($out, [
$r['sid'] ?? '', $r['sid'] ?? '',
$r['name'] ?? '', $r['name'] ?? '',
$r['cls'] ?? '', $r['cls'] ?? '',
$r['grp'] ?? '', $r['grp'] ?? '',
$r['score'] === null ? '' : $r['score'], $r['score'] === null ? '' : $r['score'],
(int)($r['attempts'] ?? 1),
(int)($r['idx'] ?? 1),
$r['submit_at'] ?? '', $r['submit_at'] ?? '',
$r['duration_txt'] ?? '', $r['duration_txt'] ?? '',
$r['duration'] === null ? '' : $r['duration'], $r['duration'] === null ? '' : $r['duration'],
(int)($r['attempts'] ?? 1),
]); ]);
} }
} }
+275 -49
View File
@@ -22,7 +22,12 @@
timer: null, timer: null,
loading: false, loading: false,
lastSig: '', lastSig: '',
connFail: 0 connFail: 0,
historyDisplay: 'click', // click=点击成员弹窗查看历史;inline=成绩明细内全部展开
exportSort: 'time', // time=按提交时间;sid=按学号
highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹
hlTimer: null, // 自动清除高亮的定时器
hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次
}; };
/* ---------------- 本地存储 ---------------- */ /* ---------------- 本地存储 ---------------- */
@@ -197,6 +202,14 @@
} }
/* ---------------- 数据刷新 ---------------- */ /* ---------------- 数据刷新 ---------------- */
/** 行指纹:任一字段变化都算"有新数据" */
function rowSig(r) {
return [
r.sid, r.score, r.submit_at, r.attempts || 1, r.last_submit_ts || 0,
].join('|');
}
function applyData(d, opts) { function applyData(d, opts) {
opts = opts || {}; opts = opts || {};
state.questions = d.questions || []; state.questions = d.questions || [];
@@ -206,15 +219,36 @@
state.roster = d.roster || []; state.roster = d.roster || [];
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100; state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
const fromCache = !!opts.fromCache;
const cur = new Set(state.rows.map(r => r.sid).filter(Boolean)); const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
if (state.hasPrev) state.newSids = new Set(Array.from(cur).filter(s => !state.prevSids.has(s))); // 缓存渲染不参与变更检测:本地缓存可能是旧数据,与上一轮真实数据比对会产生假高亮
else state.newSids = new Set(); if (state.hasPrev && !fromCache) {
state.hasPrev = true; state.newSids = new Set(Array.from(cur).filter(s => !state.prevSids.has(s)));
state.rows.forEach(r => {
const sid = String(r.sid || '');
if (sid === '') return;
const prev = state.prevMap[sid];
if (prev === undefined || prev !== rowSig(r)) {
state.highlight.add(sid); // 累积:一直常亮到用户清除或切换项目
}
});
} else if (!state.hasPrev) {
state.newSids = new Set();
}
if (!fromCache) state.hasPrev = true;
renderNameFields(d); renderNameFields(d);
renderStats(); renderStats();
renderTable(); // 仅真实数据才更新指纹基准(缓存可能是旧数据,不能作为比对基准)
if (!fromCache) {
const map = {};
state.rows.forEach(r => { map[String(r.sid)] = rowSig(r); });
state.prevMap = map;
state.prevSids = cur; state.prevSids = cur;
}
renderTable();
populateClasses(); populateClasses();
populateGroups(); populateGroups();
renderAbsent(); renderAbsent();
@@ -429,64 +463,168 @@
return 'score-low'; return 'score-low';
} }
function renderTable() { const TEXT_KEYS = { sid: 1, cls: 1, grp: 1, name: 1 };
function cmpText(x, y) {
return String(x).localeCompare(String(y), 'zh', { numeric: true });
}
/** 学号比较:纯数字按数值,否则自然排序 */
function cmpSid(a, b) {
const s1 = String(a.sid == null ? '' : a.sid);
const s2 = String(b.sid == null ? '' : b.sid);
if (s1 !== '' && s2 !== '' && !isNaN(Number(s1)) && !isNaN(Number(s2))) {
const d = Number(s1) - Number(s2);
if (d !== 0) return d;
}
return cmpText(s1, s2);
}
/** 某排序键是否视为"空值"(空值恒排末尾,不受升降序影响) */
function isEmptyVal(key, v) {
if (v === null || v === undefined || v === '') return true;
// 时间戳 0 表示未知时间
if ((key === 'submit_ts' || key === 'last_submit_ts') && Number(v) === 0) return true;
return false;
}
/**
* 统一比较器:
* - 空值恒排末尾(不再因为 -1 混在中间)
* - 主键相同时按学号做次级排序,保证顺序稳定
*/
function cmpBy(key, dir, a, b) {
const x = a[key], y = b[key];
const xe = isEmptyVal(key, x), ye = isEmptyVal(key, y);
if (xe && ye) return cmpSid(a, b) * dir;
if (xe) return 1; // 空值永远在后
if (ye) return -1;
let d;
if (TEXT_KEYS[key]) {
d = cmpText(x, y);
} else {
const nx = Number(x), ny = Number(y);
d = (isNaN(nx) || isNaN(ny)) ? cmpText(x, y) : (nx - ny);
}
if (d !== 0) return d * dir;
return cmpSid(a, b) * dir;
}
/** 展开为"每次考试一行"(历史成绩全部展示模式) */
function expandAttempts(rows) {
const out = [];
rows.forEach(r => {
const list = (r.attempts_list && r.attempts_list.length) ? r.attempts_list : null;
if (!list) {
out.push(Object.assign({}, r, { idx: 1, is_best: true, _att: false }));
return;
}
list.forEach(a => {
out.push({
sid: r.sid, name: r.name, cls: r.cls, grp: r.grp,
score: (a.score === undefined ? null : a.score),
submit_ts: a.submit_ts || 0,
submit_at: a.submit_at || '-',
duration: (a.duration === undefined ? null : a.duration),
duration_txt: a.duration_txt || '-',
attempts: r.attempts || 1,
best_score: (r.best_score === undefined ? null : r.best_score),
last_submit_ts: r.last_submit_ts || 0,
last_submit_at: r.last_submit_at || '-',
idx: a.idx || 1,
is_best: !!a.is_best,
answer_id: a.answer_id || '',
temp_save: !!a.temp_save,
_att: true,
});
});
});
return out;
}
/** 依据筛选 + 展示模式,得到待渲染的行 */
function buildRows() {
const kw = $('#kw').value.trim().toLowerCase(); const kw = $('#kw').value.trim().toLowerCase();
const clsFilter = $('#clsSel').value; const clsFilter = $('#clsSel').value;
const grpFilter = $('#grpSel').value; const grpFilter = $('#grpSel').value;
let rows = state.rows.slice(); let base = state.rows.slice();
if (clsFilter) rows = rows.filter(r => (r.cls || '') === clsFilter); if (clsFilter) base = base.filter(r => (r.cls || '') === clsFilter);
if (grpFilter) rows = rows.filter(r => (r.grp || '') === grpFilter); if (grpFilter) base = base.filter(r => (r.grp || '') === grpFilter);
if (kw) rows = rows.filter(r => if (kw) base = base.filter(r =>
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw)); String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
const k = state.sortKey, dir = state.sortAsc ? 1 : -1; if (state.historyDisplay === 'inline') {
rows.sort((a, b) => { return expandAttempts(base);
let x = a[k], y = b[k];
if (k === 'sid' || k === 'cls') {
const na = parseFloat(x), nb = parseFloat(y);
if (!isNaN(na) && !isNaN(nb)) return (na - nb) * dir;
return String(x == null ? '' : x).localeCompare(String(y == null ? '' : y), 'zh', { numeric: true }) * dir;
} }
if (x == null) x = -1; // 一人一行:提交时间显示"最近一次提交",使"按提交时间排序"= 谁最近交的
if (y == null) y = -1; return base.map(r => Object.assign({}, r, {
if (typeof x === 'string') return x.localeCompare(String(y), 'zh', { numeric: true }) * dir; submit_ts: (r.last_submit_ts || r.submit_ts || 0),
return (x - y) * dir; submit_at: (r.last_submit_at && r.last_submit_at !== '-') ? r.last_submit_at : (r.submit_at || '-'),
}); _att: false,
}));
}
const COL_COUNT = 8;
/** 更新"清除高亮"按钮的可见性与计数 */
function renderHlBadge() {
const btn = $('#btnClearHl');
const badge = $('#hlBadge');
if (!btn) return;
const n = state.highlight.size;
if (badge) badge.textContent = n;
btn.hidden = (n === 0);
}
function renderTable() {
const rows = buildRows();
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
rows.sort((a, b) => cmpBy(k, dir, a, b));
const tb = $('#tbody'); const tb = $('#tbody');
if (!rows.length) { if (!rows.length) {
tb.innerHTML = '<tr><td colspan="7" class="empty">' + tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>'; (state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
return; return;
} }
tb.innerHTML = rows.map(r => { tb.innerHTML = rows.map(r => {
const sig = r.sid + '|' + r.score + '|' + r.submit_at; const sid = String(r.sid || '');
const changed = state.prevMap[r.sid] && state.prevMap[r.sid] !== sig; const isNew = sid !== '' && state.newSids.has(sid);
const isNew = r.sid && state.newSids.has(r.sid); const hl = sid !== '' && state.highlight.has(sid);
const canDrill = (r.attempts || 1) > 1; const attempts = (r.attempts || 1);
const clsName = (changed ? 'diff ' : '') + (isNew ? 'new ' : '') + (canDrill ? 'clickable' : ''); const clsName = [hl ? 'new' : '', 'clickable'].filter(Boolean).join(' ');
const att = canDrill ? ' <span class="tag tag-att" title="查看 ' + r.attempts + ' 次提交明细">刷' + r.attempts + '次</span>' : '';
const onclk = canDrill ? ' onclick="openAttemptsModal(' + JSON.stringify(r.sid) + ')"' : ''; // 姓名后缀标记
return '<tr class="' + clsName.trim() + '"' + onclk + '>' + const tags = [];
if (isNew) tags.push('<span class="tag tag-new">新</span>');
if (r._att && r.is_best && attempts > 1) tags.push('<span class="tag tag-best">最佳</span>');
if (r._att && r.temp_save) tags.push('<span class="tag tag-gray">暂存</span>');
// 考试次数:全部展示模式下额外标出"第几次"
const attCell = r._att && attempts > 1
? attempts + ' <small style="color:var(--text-3)">第' + r.idx + '次</small>'
: String(attempts);
return '<tr class="' + clsName + '" data-sid="' + esc(sid) + '" title="点击查看历史成绩">' +
'<td class="sid">' + esc(r.sid) + '</td>' + '<td class="sid">' + esc(r.sid) + '</td>' +
'<td class="name">' + esc(r.name) + att + '</td>' + '<td class="name">' + esc(r.name) + (tags.length ? ' ' + tags.join(' ') : '') + '</td>' +
'<td>' + esc(r.cls || '') + '</td>' + '<td>' + esc(r.cls || '') + '</td>' +
'<td>' + esc(r.grp || '') + '</td>' + '<td>' + esc(r.grp || '') + '</td>' +
'<td class="score num ' + scoreClass(r.score) + '">' + (r.score == null ? '-' : r.score) + '</td>' + '<td class="score num ' + scoreClass(r.score) + '">' + (r.score == null ? '-' : r.score) + '</td>' +
'<td class="num att">' + attCell + '</td>' +
'<td class="num">' + esc(r.submit_at) + '</td>' + '<td class="num">' + esc(r.submit_at) + '</td>' +
'<td class="num">' + esc(r.duration_txt) + '</td>' + '<td class="num">' + esc(r.duration_txt) + '</td>' +
'</tr>'; '</tr>';
}).join(''); }).join('');
const map = {};
state.rows.forEach(r => { map[r.sid] = r.sid + '|' + r.score + '|' + r.submit_at; });
state.prevMap = map;
$$('#dataTable th.sortable').forEach(th => { $$('#dataTable th.sortable').forEach(th => {
const isCur = th.dataset.k === state.sortKey; const isCur = th.dataset.k === state.sortKey;
th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : ''); th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : '');
}); });
renderHlBadge();
} }
function renderAbsent() { function renderAbsent() {
@@ -519,21 +657,39 @@
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').join(''); '</td><td class="num">' + esc(u.submit_at) + '</td></tr>').join('');
} }
/* 多次刷题下钻弹窗(主统计页成绩明细) */ /* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
function openAttemptsModal(sid) { function openAttemptsModal(sid) {
const r = (state.rows || []).find(x => String(x.sid) === String(sid)); const r = (state.rows || []).find(x => String(x.sid) === String(sid));
if (!r || (r.attempts || 1) <= 1) return; if (!r) return;
const list = r.attempts_list || []; const attempts = (r.attempts || 1);
const rows = list.map(a => { let list = r.attempts_list || [];
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score); if (!list.length) {
// 后端未返回明细时,用代表行兜底成一条
list = [{
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
}];
}
// 新 -> 旧 展示
const desc = list.slice().sort((a, b) => (b.submit_ts || 0) - (a.submit_ts || 0));
const rows = desc.map((a, i) => {
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : ''; const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
return '<tr><td class="num">' + esc(a.submit_at) + '</td><td class="num">' + sc + tmp + const no = attempts > 1 ? (a.idx || (desc.length - i)) : 1;
'</td><td class="num">' + esc(a.duration_txt || '-') + '</td></tr>'; return '<tr>' +
'<td class="num">' + (attempts > 1 ? '第 ' + no + ' 次' : '-') + '</td>' +
'<td class="num">' + esc(a.submit_at || '-') + '</td>' +
'<td class="num">' + sc + best + tmp + '</td>' +
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
'</tr>';
}).join(''); }).join('');
$('#attTitle').textContent = (r.name || '') + ' · 共 ' + r.attempts + ' 次提交'; const bestScore = (r.best_score == null) ? r.score : r.best_score;
$('#attBody').innerHTML = list.length $('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
? '<table class="grid compact"><thead><tr><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' + rows + '</tbody></table>' (bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
: '<div class="empty">无提交明细</div>'; $('#attBody').innerHTML =
'<table class="grid compact"><thead><tr><th style="width:80px">次序</th><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' +
rows + '</tbody></table>';
$('#attemptsModal').classList.add('open'); $('#attemptsModal').classList.add('open');
} }
function closeAttemptsModal() { function closeAttemptsModal() {
@@ -549,6 +705,20 @@
state.timer = setInterval(() => refresh(true), sec * 1000); state.timer = setInterval(() => refresh(true), sec * 1000);
} }
/* ---------------- 新数据高亮自动清除 ---------------- */
// 绿色高亮用于标记"有更新的成员",停留更易察觉(不再只闪一下),
// 但长时间堆积会失去重点——故每 hlAutoClearSec 秒自动清除一次。
function startHlAutoClear() {
if (state.hlTimer) clearInterval(state.hlTimer);
const sec = state.hlAutoClearSec || 30;
state.hlTimer = setInterval(() => {
if (state.highlight.size === 0) return; // 无高亮时不打扰
state.highlight = new Set();
renderTable();
renderHlBadge();
}, sec * 1000);
}
/* ---------------- 全屏 ---------------- */ /* ---------------- 全屏 ---------------- */
function toggleFullscreen() { function toggleFullscreen() {
const el = document.documentElement; const el = document.documentElement;
@@ -592,6 +762,12 @@
// 满分:默认 + 各试卷独立 // 满分:默认 + 各试卷独立
$('#cfgFull').value = c.full_score || 100; $('#cfgFull').value = c.full_score || 100;
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {}); renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
// 历史成绩展示方式 + 导出排序
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time';
$('#cfgHistoryDisplay').value = state.historyDisplay;
$('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
// 连接信息(账号回显;密码不回显) // 连接信息(账号回显;密码不回显)
$('#cfgBase').value = (c.base_url || '') + (c.api_prefix || '/api'); $('#cfgBase').value = (c.base_url || '') + (c.api_prefix || '/api');
$('#cfgUser').value = c.username || ''; $('#cfgUser').value = c.username || '';
@@ -694,12 +870,14 @@
}); });
$('#nameFieldSel').addEventListener('change', () => { $('#nameFieldSel').addEventListener('change', () => {
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false; state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
state.highlight = new Set(); // 换了姓名题,旧高亮无意义
LS.set(LS_LASTQID, $('#nameFieldSel').value || ''); LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
refresh(); refresh();
}); });
$('#projectSel').addEventListener('change', () => { $('#projectSel').addEventListener('change', () => {
// 切换项目后清掉前一项目的 prevMap 与高亮缓存 // 切换项目后清掉前一项目的 prevMap 与高亮缓存
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false; state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
state.highlight = new Set(); // 换项目,绿色高亮归零
// 用缓存数据先快速渲染,再后台拉新数据 // 用缓存数据先快速渲染,再后台拉新数据
refresh(); refresh();
}); });
@@ -720,15 +898,42 @@
}); });
}); });
/* 成绩明细行点击 → 历史成绩弹窗(事件委托,避免内联 onclick 找不到 IIFE 内函数) */
$('#tbody').addEventListener('click', e => {
const tr = e.target.closest('tr[data-sid]');
if (!tr) return;
const sid = tr.getAttribute('data-sid');
if (sid) openAttemptsModal(sid);
});
/* 清除高亮 */
$('#btnClearHl').addEventListener('click', () => {
state.highlight = new Set();
renderTable();
renderHlBadge();
showAlert('已清除新数据高亮', 'info');
});
/* 导出排序切换(即时保存到服务端配置) */
$('#exportSortSel').addEventListener('change', async () => {
state.exportSort = $('#exportSortSel').value;
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
try {
await api('config_save', { method: 'POST', body: { export_sort: state.exportSort } });
} catch (e) { /* 静默:本地已生效 */ }
});
// 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么) // 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么)
$('#btnExport').addEventListener('click', () => { $('#btnExport').addEventListener('click', () => {
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试'); if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value; const cls = $('#clsSel').value, grp = $('#grpSel').value;
const sort = (state.exportSort === 'sid') ? 'sid' : 'time';
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) + location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) + '&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp); '&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) +
showAlert(exportTip('成绩明细', cls, grp), 'info'); '&sort=' + sort;
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + (sort === 'sid' ? '按学号' : '按提交时间'), 'info');
}); });
$('#btnExportAbsent').addEventListener('click', () => { $('#btnExportAbsent').addEventListener('click', () => {
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
@@ -764,6 +969,8 @@
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default', layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
full_scores: collectFullScores(), full_scores: collectFullScores(),
username: $('#cfgUser').value.trim(), username: $('#cfgUser').value.trim(),
history_display: $('#cfgHistoryDisplay').value,
export_sort: $('#cfgExportSort').value,
}; };
if (pass !== '') payload.password = pass; // 留空 = 不修改 if (pass !== '') payload.password = pass; // 留空 = 不修改
let resp; let resp;
@@ -790,6 +997,11 @@
$('#intervalSel').value = String(best); $('#intervalSel').value = String(best);
schedule(); schedule();
loadProjects(); loadProjects();
// 展示方式可能变化 → 立即重渲染表格(不重新拉数据)
state.historyDisplay = payload.history_display;
state.exportSort = payload.export_sort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
renderTable();
// ping 失败时保留弹窗,让用户能立即改回 // ping 失败时保留弹窗,让用户能立即改回
if (!resp || !resp.ping || resp.ping.ok) { if (!resp || !resp.ping || resp.ping.ok) {
$('#settingsModal').classList.remove('open'); $('#settingsModal').classList.remove('open');
@@ -939,6 +1151,20 @@
// 启动:主界面在 PHP 端已做会话校验(未登录会跳到 login.php),此处直接加载数据 // 启动:主界面在 PHP 端已做会话校验(未登录会跳到 login.php),此处直接加载数据
schedule(); schedule();
startHlAutoClear(); // 新数据高亮每 30s 自动清除一次
loadProjects(); loadProjects();
// 4) 读取展示偏好(历史成绩展示方式 / 导出排序),避免必须打开设置弹窗才生效
(async () => {
try {
const c = await api('config_get');
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time';
if ($('#cfgHistoryDisplay')) $('#cfgHistoryDisplay').value = state.historyDisplay;
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
renderTable();
} catch (e) { /* 用默认值即可 */ }
})();
}); });
})(); })();
+12 -2
View File
@@ -303,9 +303,15 @@ td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.diff { animation: flash 1.6s ease-out; } .diff { animation: flash 1.6s ease-out; }
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } } @keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
/* 新增数据绿色底纹(每次刷新后标记,下一轮刷新前持续显示) */ /* 新数据绿色底纹:常亮(不再闪烁),由"清除高亮"或切换项目/姓名题清除 */
table.grid tbody tr.new td { background: #e7f7ee; } table.grid tbody tr.new td { background: #e7f7ee; }
table.grid tbody tr.new:nth-child(even) td { background: #e7f7ee; }
table.grid tbody tr.new:hover td { background: #d6f2e2; } table.grid tbody tr.new:hover td { background: #d6f2e2; }
/* 左侧色条,进一步强调"这条是新的" */
table.grid tbody tr.new td:first-child { box-shadow: inset 3px 0 0 var(--ok); }
/* 考试次数列 */
td.att { font-weight: 700; color: var(--text-2); }
.tag { .tag {
display: inline-block; padding: 1px 8px; border-radius: 999px; display: inline-block; padding: 1px 8px; border-radius: 999px;
@@ -315,10 +321,14 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
.tag-warn { background: var(--warn-weak); color: var(--warn); } .tag-warn { background: var(--warn-weak); color: var(--warn); }
.tag-gray { background: var(--border-2); color: var(--text-3); } .tag-gray { background: var(--border-2); color: var(--text-3); }
.tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; } .tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; }
.tag-new { background: var(--ok-weak); color: var(--ok); }
.tag-best { background: #fff1dc; color: #b26a06; }
/* 多次刷题:成员榜可点击行 */ /* 多次刷题:成员榜可点击行 */
tr.clickable { cursor: pointer; } tr.clickable { cursor: pointer; }
tr.clickable:hover td { background: var(--border-2); } tr.clickable:hover td { background: var(--primary-weak); }
/* 绿色常亮行 hover 时保持绿色调,避免看起来"消失" */
table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
.muted { color: var(--text-3); } .muted { color: var(--text-3); }
/* ---------- 弹窗 ---------- */ /* ---------- 弹窗 ---------- */
+4
View File
@@ -120,6 +120,10 @@ function ny_default_config(): array
'full_score' => 100, 'full_score' => 100,
'full_scores' => [], 'full_scores' => [],
'layout' => 'default', 'layout' => 'default',
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
'history_display' => 'click',
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
'export_sort' => 'time',
]; ];
} }
+34 -3
View File
@@ -80,6 +80,12 @@ $rosterCount = Roster::count();
<input type="text" id="kw" placeholder="输入学号或姓名筛选"> <input type="text" id="kw" placeholder="输入学号或姓名筛选">
</div> </div>
<div class="field btns"> <div class="field btns">
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)">导出排序
<select id="exportSortSel" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 6px;background:#fff">
<option value="time">按提交时间</option>
<option value="sid">按学号</option>
</select>
</label>
<button class="btn" id="btnExport">导出成绩</button> <button class="btn" id="btnExport">导出成绩</button>
<button class="btn" id="btnExportAbsent">导出缺考</button> <button class="btn" id="btnExportAbsent">导出缺考</button>
</div> </div>
@@ -102,7 +108,10 @@ $rosterCount = Roster::count();
<section class="panel" id="tablePanel"> <section class="panel" id="tablePanel">
<div class="panel-head"> <div class="panel-head">
<h2>成绩明细 <small id="tableHint"></small></h2> <h2>成绩明细 <small id="tableHint"></small></h2>
<div class="updated">最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i></div> <div class="updated">
<button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button>
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
</div>
</div> </div>
<div class="table-scroll"> <div class="table-scroll">
<table class="grid" id="dataTable"> <table class="grid" id="dataTable">
@@ -113,11 +122,12 @@ $rosterCount = Roster::count();
<th class="sortable" data-k="cls">班级</th> <th class="sortable" data-k="cls">班级</th>
<th class="sortable" data-k="grp">组别</th> <th class="sortable" data-k="grp">组别</th>
<th class="sortable" data-k="score">成绩</th> <th class="sortable" data-k="score">成绩</th>
<th class="sortable" data-k="submit_ts">提交时间</th> <th class="sortable" data-k="attempts" title="该生提交过的次数,点击表头可排序">考试次数</th>
<th class="sortable" data-k="submit_ts" title="最近一次提交时间">提交时间</th>
<th class="sortable" data-k="duration">考试时长</th> <th class="sortable" data-k="duration">考试时长</th>
</tr> </tr>
</thead> </thead>
<tbody id="tbody"><tr><td colspan="7" class="empty">请选择问卷 / 考试</td></tr></tbody> <tbody id="tbody"><tr><td colspan="8" class="empty">请选择问卷 / 考试</td></tr></tbody>
</table> </table>
</div> </div>
</section> </section>
@@ -175,6 +185,22 @@ $rosterCount = Roster::count();
<input type="number" id="cfgFull" min="1" max="1000"> <input type="number" id="cfgFull" min="1" max="1000">
<p class="hint" id="cfgFullHint">未选中试卷时的默认值</p> <p class="hint" id="cfgFullHint">未选中试卷时的默认值</p>
</div> </div>
<div class="fi">
<label>历史成绩展示方式</label>
<select id="cfgHistoryDisplay">
<option value="click">点击成员弹窗查看(默认)</option>
<option value="inline">成绩明细内全部展开</option>
</select>
<p class="hint">「全部展开」时每次考试单独一行,直接显示在成绩明细中</p>
</div>
<div class="fi">
<label>成绩导出排序</label>
<select id="cfgExportSort">
<option value="time">按提交时间(默认)</option>
<option value="sid">按学号(同学号按提交时间)</option>
</select>
<p class="hint">导出包含每次考试记录,始终带「考试次数」列</p>
</div>
<div class="fi full"> <div class="fi full">
<label>姓名题关键词</label> <label>姓名题关键词</label>
<input type="text" id="cfgKeywords"> <input type="text" id="cfgKeywords">
@@ -192,6 +218,11 @@ $rosterCount = Roster::count();
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label> <label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label> <label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
</div> </div>
<p class="hint" style="margin-top:-4px">
<b>「只保留最新一份」务必注意</b>:勾选后每人只算最后一次提交,
「考试次数」恒为 1、历史成绩只有一条。<b>想看考试次数与多次考试记录,请取消勾选</b>
(取消后每人一行,成绩取最高分,历史里可看到每一次)。
</p>
<div class="fi full" style="margin-top:6px"> <div class="fi full" style="margin-top:6px">
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
+146 -11
View File
@@ -309,10 +309,120 @@ class DataService
]; ];
} }
/**
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
*
* @param array $rows 聚合后的行(每行含 attempts_list)
* @param bool $desc 是否按提交时间降序(新 -> 旧)
* @return array
*/
public static function expandAttempts(array $rows, bool $desc = true): array
{
$out = [];
foreach ($rows as $r) {
$list = (array)($r['attempts_list'] ?? []);
if ($list === []) {
// 没有明细时退回代表行本身
$one = $r;
$one['idx'] = 1;
$one['is_best'] = true;
$out[] = $one;
continue;
}
foreach ($list as $a) {
$out[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'score' => $a['score'] ?? null,
'submit_ts' => (int)($a['submit_ts'] ?? 0),
'submit_at' => (string)($a['submit_at'] ?? '-'),
'duration' => $a['duration'] ?? null,
'duration_txt' => (string)($a['duration_txt'] ?? '-'),
'answer_id' => (string)($a['answer_id'] ?? ''),
'temp_save' => !empty($a['temp_save']),
'attempts' => (int)($r['attempts'] ?? 1),
'best_score' => $r['best_score'] ?? null,
'idx' => (int)($a['idx'] ?? 1),
'is_best' => !empty($a['is_best']),
'last_submit_ts' => (int)($r['last_submit_ts'] ?? 0),
'last_submit_at' => (string)($r['last_submit_at'] ?? '-'),
];
}
}
usort($out, static function (array $x, array $y) use ($desc): int {
// 注意:x<=>y 是升序,降序要用 y<=>x
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
if (!$desc) {
$d = -$d;
}
if ($d !== 0) {
return $d;
}
// 同时间:学号升序
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
return $out;
}
/**
* 导出/展示用的排序:
* - time(默认):按提交时间降序,同时间按学号升序
* - sid:按学号升序,同一学号内多次考试再按提交时间降序
*
* @param array $rows expandAttempts 展开后的行
* @param string $mode 'time' | 'sid'
*/
public static function sortForExport(array $rows, string $mode = 'time'): array
{
$out = $rows;
if ($mode === 'sid') {
usort($out, static function ($x, $y): int {
$a = (string)($x['sid'] ?? '');
$b = (string)($y['sid'] ?? '');
if (is_numeric($a) && is_numeric($b)) {
$d = $a <=> $b;
} else {
$d = strnatcmp($a, $b);
}
if ($d !== 0) {
return $d;
}
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
});
return $out;
}
usort($out, static function ($x, $y): int {
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
if ($d !== 0) {
return $d;
}
$a = (string)($x['sid'] ?? '');
$b = (string)($y['sid'] ?? '');
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
return $out;
}
/** /**
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。 * 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
* - dedupe=true:每学号仅保留提交时间最新的一份(原行为) *
* - dedupe=false:每学号一行,代表成绩取最高分,附 attempts(次数) / attempts_list(每次提交明细) * 关键语义(修复"考试次数=1 / 历史只有一条"的 bug):
* - attempts(真实考试次数) 与 attempts_list(每次提交明细) **始终等于全部提交**,与 dedupe 无关;
* 这样"考试次数列"与"点击查看历史"永远展示完整数据。
* - dedupe 仅决定主明细表"代表成绩"那一行取哪次考试的成绩:
* dedupe=true → 取提交时间最新的一份(默认,看板更干净)
* dedupe=false → 取最高分(同分时取最早达到该分数的那次)
* *
* @param array $rows 已匹配学号的原始 row(每条 = 一次提交) * @param array $rows 已匹配学号的原始 row(每条 = 一次提交)
* @return array{rows:array, attempts_total:int} * @return array{rows:array, attempts_total:int}
@@ -332,8 +442,27 @@ class DataService
usort($sorted, static function (array $x, array $y): int { usort($sorted, static function (array $x, array $y): int {
return ($x['submit_ts'] ?? 0) <=> ($y['submit_ts'] ?? 0); return ($x['submit_ts'] ?? 0) <=> ($y['submit_ts'] ?? 0);
}); });
$attemptsList = array_map(static function (array $a): array { // 最佳成绩(用于标记每次考试是否为最高分)
$maxScore = null;
foreach ($sorted as $a) {
if ($a['score'] !== null && ($maxScore === null || $a['score'] > $maxScore)) {
$maxScore = $a['score'];
}
}
// 最近一次提交(升序排序后的最后一份)
$lastSubmitTs = (int)($sorted[count($sorted) - 1]['submit_ts'] ?? 0);
// 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次)
$bestMarked = false;
$idx = 0;
$attemptsList = array_map(static function (array $a) use (&$idx, $maxScore, &$bestMarked): array {
$idx++;
$isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore);
if ($isBest) {
$bestMarked = true;
}
return [ return [
'idx' => $idx,
'is_best' => $isBest,
'submit_at' => $a['submit_at'], 'submit_at' => $a['submit_at'],
'submit_ts' => $a['submit_ts'], 'submit_ts' => $a['submit_ts'],
'score' => $a['score'], 'score' => $a['score'],
@@ -344,14 +473,12 @@ class DataService
]; ];
}, $sorted); }, $sorted);
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
if ($dedupe) { if ($dedupe) {
// 仅保留提交时间最新的一份(明细列表也只留这一份,与"只保留最新"语义一致) // 代表成绩 = 提交时间最新的一份(默认,看板更干净)
$row = $sorted[count($sorted) - 1]; $row = $sorted[count($sorted) - 1];
$row['attempts'] = 1;
$row['best_score'] = $row['score'];
$row['attempts_list'] = [$attemptsList[count($attemptsList) - 1]];
} else { } else {
// 代表成绩取最高分(null 成绩不参与比较,全部为 null 时回退为最近一份) // 代表成绩 = 最高分(null 成绩不参与比较,全部为 null 时回退为最近一份)
$best = null; $best = null;
$bestScore = null; $bestScore = null;
foreach ($sorted as $a) { foreach ($sorted as $a) {
@@ -364,10 +491,18 @@ class DataService
$best = $sorted[count($sorted) - 1]; $best = $sorted[count($sorted) - 1];
} }
$row = $best; $row = $best;
$row['attempts'] = count($list);
$row['best_score'] = $bestScore;
$row['attempts_list'] = $attemptsList;
} }
// 关键修复:考试次数与历史明细始终使用真实全部提交,与去重无关
// (之前 dedupe=true 时把 attempts 写成 1、attempts_list 只留最新一份,导致"考试次数"和
// 历史弹窗都只显示 1 条,多次刷题的数据被吞掉)
$row['attempts'] = count($list);
$row['best_score'] = $maxScore; // 真实最高分(跨全部尝试),用于历史弹窗标题展示
$row['attempts_list'] = $attemptsList;
// 最近一次提交时间(与"代表成绩"可能是不同的一次考试):
// 用于"按提交时间排序"和"新提交高亮",保证重考(哪怕分数更低)也能被发现
$row['last_submit_ts'] = $lastSubmitTs;
$row['last_submit_at'] = $lastSubmitTs > 0 ? date('Y-m-d H:i:s', $lastSubmitTs) : '-';
$agg[] = $row; $agg[] = $row;
} }
return ['rows' => $agg, 'attempts_total' => $attemptsTotal]; return ['rows' => $agg, 'attempts_total' => $attemptsTotal];
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* 验证 aggregateByStudent 的"考试次数 / 历史明细"修复:
* 多次提交的学生在 dedupe=true(默认)与 dedupe=false 两种情况下,
* attempts 都应等于真实提交次数、attempts_list 都应包含全部提交;
* dedupe 仅影响"代表成绩"那一行取最新或最高分。
*/
require __DIR__ . '/../bootstrap.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";
}
}
/**
* 构造一个学生的多次提交(模拟 第二周测试 中"陈苏豫 11次"这类数据)
* 提交时间递增(旧 -> 新),分数有高有低。
*/
function makeRows(string $sid, array $scoresByTime): array
{
$rows = [];
$t = 1000; // 递增时间戳
foreach ($scoresByTime as $sc) {
$t += 600;
$rows[] = [
'sid' => $sid,
'name' => '测试生',
'cls' => '1班',
'grp' => 'A',
'score' => $sc,
'submit_ts' => $t,
'submit_at' => date('Y-m-d H:i:s', $t),
'duration' => 120,
'duration_txt'=> '2分0秒',
'answer_id' => 'ans-' . $t,
'matched' => true,
'temp_save' => false,
];
}
return $rows;
}
echo "== dedupe=true(默认:代表成绩取最新一份)==\n";
// 11 次提交:分数各不相同,最新一份是 84.0,最高是 98.0
$scores = [84.0, 98.0, 96.0, 96.0, 96.0, 94.0, 94.0, 96.0, 90.0, 88.0, 98.0];
$rows = makeRows('S001', $scores);
$agg = DataService::aggregateByStudent($rows, true);
check('聚合后只有 1 行(一人一行)', count($agg['rows']) === 1);
$r = $agg['rows'][0];
check('attempts = 11(真实次数,而非被吞成 1)', ($r['attempts'] ?? 0) === 11, 'got ' . var_export($r['attempts'] ?? null, true));
check('attempts_list 含 11 条(历史可展开全部)', count($r['attempts_list'] ?? []) === 11, 'got ' . count($r['attempts_list'] ?? []));
check('best_score = 98.0(跨全部尝试的真实最高分)', (float)($r['best_score'] ?? -1) === 98.0, 'got ' . var_export($r['best_score'] ?? null, true));
// 代表成绩 = 最新一份(数组中最后一个时间 = 98.0,因为 scores 数组最后一个是 98.0;注意最新提交对应最后构造的 98.0)
check('代表成绩取最新提交 = 98.0', (float)($r['score'] ?? -1) === 98.0, 'got ' . var_export($r['score'] ?? null, true));
check('attempts_list 项数 idx 连续 1..11', (int)($r['attempts_list'][10]['idx'] ?? 0) === 11);
check('attempts_list 至少一条 is_best(最高分标记)', count(array_filter($r['attempts_list'], fn($a) => !empty($a['is_best']))) >= 1);
echo "\n== dedupe=false(代表成绩取最高分)==\n";
$agg2 = DataService::aggregateByStudent($rows, false);
$r2 = $agg2['rows'][0];
check('attempts = 11(与 dedupe 无关,始终真实)', ($r2['attempts'] ?? 0) === 11, 'got ' . var_export($r2['attempts'] ?? null, true));
check('attempts_list 含 11 条', count($r2['attempts_list'] ?? []) === 11, 'got ' . count($r2['attempts_list'] ?? []));
check('代表成绩取最高分 = 98.0', (float)($r2['score'] ?? -1) === 98.0, 'got ' . var_export($r2['score'] ?? null, true));
check('best_score = 98.0', (float)($r2['best_score'] ?? -1) === 98.0, 'got ' . var_export($r2['best_score'] ?? null, true));
echo "\n== 重考低分也应被统计(历史含低分,且因提交时间更新能被发现)==\n";
// 先考 90,后重考 70(更低):考试次数必须=2,最新提交=70
$rows3 = makeRows('S002', [90.0, 70.0]);
$agg3 = DataService::aggregateByStudent($rows3, true);
$r3 = $agg3['rows'][0];
check('attempts = 2', ($r3['attempts'] ?? 0) === 2, 'got ' . var_export($r3['attempts'] ?? null, true));
check('attempts_list = 2 条(含低分重考)', count($r3['attempts_list'] ?? []) === 2);
check('代表成绩取最新 = 70.0', (float)($r3['score'] ?? -1) === 70.0, 'got ' . var_export($r3['score'] ?? null, true));
check('last_submit_ts 指向最新提交(重考可被高亮发现)', ($r3['last_submit_ts'] ?? 0) === $rows3[1]['submit_ts']);
echo "\n== 仅 1 次提交不受影响 ==\n";
$rows4 = makeRows('S003', [85.0]);
$agg4 = DataService::aggregateByStudent($rows4, true);
$r4 = $agg4['rows'][0];
check('单次提交 attempts = 1', ($r4['attempts'] ?? 0) === 1);
check('单次提交 attempts_list = 1', count($r4['attempts_list'] ?? []) === 1);
echo "\n========================================\n";
echo "通过 {$pass} · 失败 {$fail}\n";
exit($fail === 0 ? 0 : 1);