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
- ? ''
- : '无提交明细
';
+ 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();
成绩明细
-
最后更新:—
+
+
+ 最后更新:—
+
@@ -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];