Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e031b3858e | ||
|
|
0174b2abc2 | ||
|
|
14c7fa7177 |
@@ -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
@@ -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();
|
||||||
|
// 仅真实数据才更新指纹基准(缓存可能是旧数据,不能作为比对基准)
|
||||||
|
if (!fromCache) {
|
||||||
|
const map = {};
|
||||||
|
state.rows.forEach(r => { map[String(r.sid)] = rowSig(r); });
|
||||||
|
state.prevMap = map;
|
||||||
|
state.prevSids = cur;
|
||||||
|
}
|
||||||
renderTable();
|
renderTable();
|
||||||
state.prevSids = cur;
|
|
||||||
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));
|
||||||
|
|
||||||
|
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;
|
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
|
||||||
rows.sort((a, b) => {
|
rows.sort((a, b) => cmpBy(k, dir, 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
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
@@ -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); }
|
||||||
|
|
||||||
/* ---------- 弹窗 ---------- */
|
/* ---------- 弹窗 ---------- */
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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];
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user