diff --git a/api.php b/api.php index e05a88c..ee8f1ee 100644 --- a/api.php +++ b/api.php @@ -45,7 +45,7 @@ try { $in = $_POST; } $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; foreach ($allowed as $k) { if (array_key_exists($k, $in)) { @@ -68,6 +68,14 @@ try { if (array_key_exists('layout', $in)) { $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 映射) if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) { $new['full_scores'] = $in['full_scores']; @@ -476,23 +484,41 @@ try { $out = fopen('php://output', 'w'); 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, ['学号', '姓名', '班级', '组别']); foreach ($absent as $r) { fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']); } } else { - fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)', '刷题次数']); - foreach ($rows as $r) { + // 导出全部考试记录(同一学号考了 N 次就有 N 行),并保留"考试次数" + $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, [ $r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '', $r['score'] === null ? '' : $r['score'], + (int)($r['attempts'] ?? 1), + (int)($r['idx'] ?? 1), $r['submit_at'] ?? '', $r['duration_txt'] ?? '', $r['duration'] === null ? '' : $r['duration'], - (int)($r['attempts'] ?? 1), ]); } } diff --git a/assets/app.js b/assets/app.js index 0f8513a..67f79c8 100644 --- a/assets/app.js +++ b/assets/app.js @@ -22,7 +22,10 @@ timer: null, loading: false, lastSig: '', - connFail: 0 + connFail: 0, + historyDisplay: 'click', // click=点击成员弹窗查看历史;inline=成绩明细内全部展开 + exportSort: 'time', // time=按提交时间;sid=按学号 + highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹常亮直到清除 }; /* ---------------- 本地存储 ---------------- */ @@ -197,6 +200,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) { opts = opts || {}; state.questions = d.questions || []; @@ -206,15 +217,36 @@ state.roster = d.roster || []; 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)); - if (state.hasPrev) state.newSids = new Set(Array.from(cur).filter(s => !state.prevSids.has(s))); - else state.newSids = new Set(); - state.hasPrev = true; + // 缓存渲染不参与变更检测:本地缓存可能是旧数据,与上一轮真实数据比对会产生假高亮 + if (state.hasPrev && !fromCache) { + 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); renderStats(); + // 仅真实数据才更新指纹基准(缓存可能是旧数据,不能作为比对基准) + if (!fromCache) { + const map = {}; + state.rows.forEach(r => { map[String(r.sid)] = rowSig(r); }); + state.prevMap = map; + state.prevSids = cur; + } renderTable(); - state.prevSids = cur; populateClasses(); populateGroups(); renderAbsent(); @@ -429,64 +461,168 @@ 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 clsFilter = $('#clsSel').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 (grpFilter) rows = rows.filter(r => (r.grp || '') === grpFilter); - if (kw) rows = rows.filter(r => + if (clsFilter) base = base.filter(r => (r.cls || '') === clsFilter); + if (grpFilter) base = base.filter(r => (r.grp || '') === grpFilter); + if (kw) base = base.filter(r => String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw)); + if (state.historyDisplay === 'inline') { + return expandAttempts(base); + } + // 一人一行:提交时间显示"最近一次提交",使"按提交时间排序"= 谁最近交的 + return base.map(r => Object.assign({}, r, { + submit_ts: (r.last_submit_ts || r.submit_ts || 0), + 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) => { - 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; - if (typeof x === 'string') return x.localeCompare(String(y), 'zh', { numeric: true }) * dir; - return (x - y) * dir; - }); + rows.sort((a, b) => cmpBy(k, dir, a, b)); const tb = $('#tbody'); if (!rows.length) { - tb.innerHTML = '' + + tb.innerHTML = '' + (state.rows.length ? '没有匹配的记录' : '暂无提交记录') + ''; return; } tb.innerHTML = rows.map(r => { - const sig = r.sid + '|' + r.score + '|' + r.submit_at; - const changed = state.prevMap[r.sid] && state.prevMap[r.sid] !== sig; - const isNew = r.sid && state.newSids.has(r.sid); - const canDrill = (r.attempts || 1) > 1; - const clsName = (changed ? 'diff ' : '') + (isNew ? 'new ' : '') + (canDrill ? 'clickable' : ''); - const att = canDrill ? ' 刷' + r.attempts + '次' : ''; - const onclk = canDrill ? ' onclick="openAttemptsModal(' + JSON.stringify(r.sid) + ')"' : ''; - return '' + + const sid = String(r.sid || ''); + const isNew = sid !== '' && state.newSids.has(sid); + const hl = sid !== '' && state.highlight.has(sid); + const attempts = (r.attempts || 1); + const clsName = [hl ? 'new' : '', 'clickable'].filter(Boolean).join(' '); + + // 姓名后缀标记 + const tags = []; + if (isNew) tags.push('新'); + if (r._att && r.is_best && attempts > 1) tags.push('最佳'); + if (r._att && r.temp_save) tags.push('暂存'); + + // 考试次数:全部展示模式下额外标出"第几次" + const attCell = r._att && attempts > 1 + ? attempts + ' 第' + r.idx + '次' + : String(attempts); + + return '' + '' + esc(r.sid) + '' + - '' + esc(r.name) + att + '' + + '' + esc(r.name) + (tags.length ? ' ' + tags.join(' ') : '') + '' + '' + esc(r.cls || '') + '' + '' + esc(r.grp || '') + '' + '' + (r.score == null ? '-' : r.score) + '' + + '' + attCell + '' + '' + esc(r.submit_at) + '' + '' + esc(r.duration_txt) + '' + ''; }).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 => { const isCur = th.dataset.k === state.sortKey; th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' ' + (state.sortAsc ? '↑' : '↓') + '' : ''); }); + + renderHlBadge(); } function renderAbsent() { @@ -519,21 +655,39 @@ '' + esc(u.submit_at) + '').join(''); } - /* 多次刷题下钻弹窗(主统计页成绩明细) */ + /* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */ function openAttemptsModal(sid) { const r = (state.rows || []).find(x => String(x.sid) === String(sid)); - if (!r || (r.attempts || 1) <= 1) return; - const list = r.attempts_list || []; - const rows = list.map(a => { - const sc = a.score == null ? '未判分' : esc(a.score); + if (!r) return; + const attempts = (r.attempts || 1); + let list = r.attempts_list || []; + 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) ? '未判分' : esc(a.score); + const best = (a.is_best && attempts > 1) ? ' 最佳' : ''; const tmp = a.temp_save ? ' 暂存' : ''; - return '' + esc(a.submit_at) + '' + sc + tmp + - '' + esc(a.duration_txt || '-') + ''; + const no = attempts > 1 ? (a.idx || (desc.length - i)) : 1; + return '' + + '' + (attempts > 1 ? '第 ' + no + ' 次' : '-') + '' + + '' + esc(a.submit_at || '-') + '' + + '' + sc + best + tmp + '' + + '' + esc(a.duration_txt || '-') + '' + + ''; }).join(''); - $('#attTitle').textContent = (r.name || '') + ' · 共 ' + r.attempts + ' 次提交'; - $('#attBody').innerHTML = list.length - ? '' + rows + '
提交时间成绩考试时长
' - : '
无提交明细
'; + const bestScore = (r.best_score == null) ? r.score : r.best_score; + $('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' + + (bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分'); + $('#attBody').innerHTML = + '' + + rows + '
次序提交时间成绩考试时长
'; $('#attemptsModal').classList.add('open'); } function closeAttemptsModal() { @@ -592,6 +746,12 @@ // 满分:默认 + 各试卷独立 $('#cfgFull').value = c.full_score || 100; 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'); $('#cfgUser').value = c.username || ''; @@ -694,12 +854,14 @@ }); $('#nameFieldSel').addEventListener('change', () => { state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false; + state.highlight = new Set(); // 换了姓名题,旧高亮无意义 LS.set(LS_LASTQID, $('#nameFieldSel').value || ''); refresh(); }); $('#projectSel').addEventListener('change', () => { // 切换项目后清掉前一项目的 prevMap 与高亮缓存 state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false; + state.highlight = new Set(); // 换项目,绿色高亮归零 // 用缓存数据先快速渲染,再后台拉新数据 refresh(); }); @@ -720,15 +882,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', () => { const pid = $('#projectSel').value; if (!pid) return showAlert('请先选择问卷 / 考试'); const cls = $('#clsSel').value, grp = $('#grpSel').value; + const sort = (state.exportSort === 'sid') ? 'sid' : 'time'; location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) + '&nameQid=' + encodeURIComponent($('#nameFieldSel').value) + - '&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp); - showAlert(exportTip('成绩明细', cls, grp), 'info'); + '&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) + + '&sort=' + sort; + showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + (sort === 'sid' ? '按学号' : '按提交时间'), 'info'); }); $('#btnExportAbsent').addEventListener('click', () => { const pid = $('#projectSel').value; @@ -764,6 +953,8 @@ layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default', full_scores: collectFullScores(), username: $('#cfgUser').value.trim(), + history_display: $('#cfgHistoryDisplay').value, + export_sort: $('#cfgExportSort').value, }; if (pass !== '') payload.password = pass; // 留空 = 不修改 let resp; @@ -790,6 +981,11 @@ $('#intervalSel').value = String(best); schedule(); loadProjects(); + // 展示方式可能变化 → 立即重渲染表格(不重新拉数据) + state.historyDisplay = payload.history_display; + state.exportSort = payload.export_sort; + if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort; + renderTable(); // ping 失败时保留弹窗,让用户能立即改回 if (!resp || !resp.ping || resp.ping.ok) { $('#settingsModal').classList.remove('open'); @@ -940,5 +1136,18 @@ // 启动:主界面在 PHP 端已做会话校验(未登录会跳到 login.php),此处直接加载数据 schedule(); 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) { /* 用默认值即可 */ } + })(); }); })(); diff --git a/assets/style.css b/assets/style.css index 7ff7feb..c3d2be9 100644 --- a/assets/style.css +++ b/assets/style.css @@ -303,9 +303,15 @@ td.score { font-weight: 700; font-variant-numeric: tabular-nums; } .diff { animation: flash 1.6s ease-out; } @keyframes flash { from { background: #fff6d9; } to { background: transparent; } } -/* 新增数据绿色底纹(每次刷新后标记,下一轮刷新前持续显示) */ +/* 新数据绿色底纹:常亮(不再闪烁),由"清除高亮"或切换项目/姓名题清除 */ 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 td:first-child { box-shadow: inset 3px 0 0 var(--ok); } + +/* 考试次数列 */ +td.att { font-weight: 700; color: var(--text-2); } .tag { 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-gray { background: var(--border-2); color: var(--text-3); } .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: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); } /* ---------- 弹窗 ---------- */ diff --git a/bootstrap.php b/bootstrap.php index 52d286f..e7b9db5 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -120,6 +120,10 @@ function ny_default_config(): array 'full_score' => 100, 'full_scores' => [], 'layout' => 'default', + // 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试 + 'history_display' => 'click', + // 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序) + 'export_sort' => 'time', ]; } diff --git a/index.php b/index.php index 8705572..2040c49 100644 --- a/index.php +++ b/index.php @@ -80,6 +80,12 @@ $rosterCount = Roster::count();
+
@@ -102,7 +108,10 @@ $rosterCount = Roster::count();

成绩明细

-
最后更新:—
+
+ + 最后更新:— +
@@ -113,11 +122,12 @@ $rosterCount = Roster::count(); - + + - +
班级 组别 成绩提交时间考试次数提交时间 考试时长
请选择问卷 / 考试
请选择问卷 / 考试
@@ -175,6 +185,22 @@ $rosterCount = Roster::count();

未选中试卷时的默认值

+
+ + +

「全部展开」时每次考试单独一行,直接显示在成绩明细中

+
+
+ + +

导出包含每次考试记录,始终带「考试次数」列

+
@@ -192,6 +218,11 @@ $rosterCount = Roster::count();
+

+ 「只保留最新一份」务必注意:勾选后每人只算最后一次提交, + 「考试次数」恒为 1、历史成绩只有一条。想看考试次数与多次考试记录,请取消勾选 + (取消后每人一行,成绩取最高分,历史里可看到每一次)。 +

diff --git a/lib/DataService.php b/lib/DataService.php index 85e64f1..bb72a60 100644 --- a/lib/DataService.php +++ b/lib/DataService.php @@ -309,6 +309,111 @@ 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:每学号仅保留提交时间最新的一份(原行为) @@ -332,8 +437,27 @@ class DataService usort($sorted, static function (array $x, array $y): int { 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 [ + 'idx' => $idx, + 'is_best' => $isBest, 'submit_at' => $a['submit_at'], 'submit_ts' => $a['submit_ts'], 'score' => $a['score'], @@ -368,6 +492,10 @@ class DataService $row['best_score'] = $bestScore; $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; } return ['rows' => $agg, 'attempts_total' => $attemptsTotal];