feat: 成绩明细考试次数/历史成绩/导出排序/高亮常亮
- 成绩明细成绩列后新增「考试次数」列(可排序) - 点击任意成员弹窗查看历史成绩(修复内联 onclick 找不到 IIFE 内函数、多次刷题下钻完全失效的 bug;仅 1 次也能点开) - 历史成绩展示方式可切换:默认点击成员弹窗;可选「成绩明细内全部展开」(每次考试一行,标注第几次/最佳) - 新数据绿色底纹常亮(不再黄闪一下):变更检测纳入 attempts/最近提交时间(重考低分也能发现);高亮跨刷新累积,新增「清除高亮」按钮,切换项目/姓名题时清零 - 导出成绩可选排序:按提交时间(默认)或按学号(同学号内按提交时间降序);导出全部考试记录并始终带「考试次数/第几次」列 - 优化排序:空值恒排末尾、主键相同时按学号次级排序;提交时间列显示「最近一次提交」 - 后端:DataService 补 last_submit_ts/last_submit_at/attempts_list 的 idx 与 is_best;新增 expandAttempts 与 sortForExport;修复 expandAttempts 降序比较器写反 - 设置新增 history_display/export_sort 两项配置(局部保存不触发重连、非法值归一化);缺考导出按学号排序 - 设置里补充「只保留最新一份」与考试次数的关系说明
This commit is contained in:
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+258
-49
@@ -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 = '<tr><td colspan="7" class="empty">' +
|
||||
tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
|
||||
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
|
||||
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 ? ' <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 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('<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="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.grp || '') + '</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.duration_txt) + '</td>' +
|
||||
'</tr>';
|
||||
}).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 ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : '');
|
||||
});
|
||||
|
||||
renderHlBadge();
|
||||
}
|
||||
|
||||
function renderAbsent() {
|
||||
@@ -519,21 +655,39 @@
|
||||
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').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 ? '<span class="muted">未判分</span>' : 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) ? '<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>' : '';
|
||||
return '<tr><td class="num">' + esc(a.submit_at) + '</td><td class="num">' + sc + tmp +
|
||||
'</td><td class="num">' + esc(a.duration_txt || '-') + '</td></tr>';
|
||||
const no = attempts > 1 ? (a.idx || (desc.length - i)) : 1;
|
||||
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('');
|
||||
$('#attTitle').textContent = (r.name || '') + ' · 共 ' + r.attempts + ' 次提交';
|
||||
$('#attBody').innerHTML = list.length
|
||||
? '<table class="grid compact"><thead><tr><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
||||
: '<div class="empty">无提交明细</div>';
|
||||
const bestScore = (r.best_score == null) ? r.score : r.best_score;
|
||||
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
|
||||
(bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
|
||||
$('#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');
|
||||
}
|
||||
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) { /* 用默认值即可 */ }
|
||||
})();
|
||||
});
|
||||
})();
|
||||
|
||||
+12
-2
@@ -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); }
|
||||
|
||||
/* ---------- 弹窗 ---------- */
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,12 @@ $rosterCount = Roster::count();
|
||||
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
||||
</div>
|
||||
<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="btnExportAbsent">导出缺考</button>
|
||||
</div>
|
||||
@@ -102,7 +108,10 @@ $rosterCount = Roster::count();
|
||||
<section class="panel" id="tablePanel">
|
||||
<div class="panel-head">
|
||||
<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="清除新数据的绿色高亮标记">清除高亮 <b id="hlBadge">0</b></button>
|
||||
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="grid" id="dataTable">
|
||||
@@ -113,11 +122,12 @@ $rosterCount = Roster::count();
|
||||
<th class="sortable" data-k="cls">班级</th>
|
||||
<th class="sortable" data-k="grp">组别</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>
|
||||
</tr>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -175,6 +185,22 @@ $rosterCount = Roster::count();
|
||||
<input type="number" id="cfgFull" min="1" max="1000">
|
||||
<p class="hint" id="cfgFullHint">未选中试卷时的默认值</p>
|
||||
</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">
|
||||
<label>姓名题关键词</label>
|
||||
<input type="text" id="cfgKeywords">
|
||||
@@ -192,6 +218,11 @@ $rosterCount = Roster::count();
|
||||
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
|
||||
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:-4px">
|
||||
<b>「只保留最新一份」务必注意</b>:勾选后每人只算最后一次提交,
|
||||
「考试次数」恒为 1、历史成绩只有一条。<b>想看考试次数与多次考试记录,请取消勾选</b>
|
||||
(取消后每人一行,成绩取最高分,历史里可看到每一次)。
|
||||
</p>
|
||||
|
||||
<div class="fi full" style="margin-top:6px">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
|
||||
+129
-1
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user