/* NY 问卷考试系统数据平台 - 前端逻辑 */
(function () {
'use strict';
const $ = (s, r) => (r || document).querySelector(s);
const $$ = (s, r) => Array.prototype.slice.call((r || document).querySelectorAll(s));
const state = {
projects: [],
questions: [],
rows: [],
absent: [],
unmatched: [],
roster: [], // 完整名单(含班级),用于按班级重算统计
prevMap: {}, // sid -> 摘要,用于高亮变化
prevSids: new Set(), // 上一次刷新出现的 sid
newSids: new Set(), // 本次刷新新增的 sid(绿色底纹)
hasPrev: false,
fullScore: 100,
sortKey: 'submit_ts',
sortAsc: false,
timer: null,
loading: false,
lastSig: '',
connFail: 0,
historyDisplay: 'click', // click=点击成员弹窗查看历史;inline=成绩明细内全部展开
exportSort: 'time', // time=按提交时间;sid=按学号
highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹
hlTimer: null, // 自动清除高亮的定时器
hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次
};
/* ---------------- 本地存储 ---------------- */
// 浏览器关闭后仍然保留,账号/密码等敏感配置除外;进入页面时优先读缓存,再后台异步刷新
const LS = {
get(k, def) {
try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); }
catch (e) { return def; }
},
set(k, v) {
try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* 隐私模式 */ }
},
del(k) { try { localStorage.removeItem(k); } catch (e) {} }
};
const LS_PROJECTS = 'ny_projects';
const LS_LASTPID = 'ny_last_pid';
const LS_LASTQID = 'ny_last_qid';
const LS_FILTERS = 'ny_filters'; // {cls, grp} -> 跟随 projectId
const LS_LAYOUT = 'ny_layout';
const LS_INTERVAL = 'ny_interval'; // 用户手动调整的刷新间隔(跟随本机)
const LS_ROSTER = 'ny_roster_cache'; // 名单 cache(用于首屏补齐班级/组别下拉)
const dataKey = pid => 'ny_data_' + pid;
/* ---------------- 通用 ---------------- */
async function api(action, opts) {
opts = opts || {};
const init = { method: opts.method || 'GET' };
if (opts.body) {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify(opts.body);
}
if (opts.form) {
init.method = 'POST';
init.body = opts.form;
}
let url = 'api.php?action=' + encodeURIComponent(action);
if (opts.params) {
for (const k in opts.params) {
if (opts.params[k] !== '' && opts.params[k] != null) {
url += '&' + k + '=' + encodeURIComponent(opts.params[k]);
}
}
}
const res = await fetch(url, init);
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch (e) {
throw new Error('服务端返回异常(可能 PHP 报错):' + text.slice(0, 300));
}
if (!json.ok) {
const err = new Error(json.msg || '请求失败');
err.auth = !!(json.extra && json.extra.auth);
err.extra = json.extra || null;
throw err;
}
return json.data;
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
/* ---------------- Toast 顶部飘窗通知(替代旧 inline alert) ---------------- */
let toastTimer = null;
const TOAST_DURATION = 3500; // 默认显示 3.5 秒
const TOAST_LIMIT = 5; // 最多同时展示 5 条(旧消息优先被挤掉)
function showToast(msg, type) {
if (msg === '' || msg == null) return; // 空消息不弹(showAlert('') 兼容旧调用 → 静默返回)
// type: ''=默认(蓝) | 'info'=绿 | 'error'=红 | 'warn'=橙
const el = document.getElementById('toastContainer') || (function () {
const c = document.createElement('div');
c.id = 'toastContainer';
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
document.body.appendChild(c);
return c;
})();
// 限流:超过上限移除最旧
while (el.children.length >= TOAST_LIMIT) {
el.firstChild.remove();
}
const t = document.createElement('div');
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
t.className = 'toast ' + cls;
t.innerHTML = '' + esc(msg).replace(/\n/g, '
') + '';
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
el.appendChild(t);
// 点击关闭
t.addEventListener('click', () => { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
// 自动消失
setTimeout(() => {
if (t.parentNode) { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); }
}, TOAST_DURATION);
}
/** 兼容旧调用:showAlert 现在走 toast(type 映射) */
function showAlert(msg, type) {
if (msg === '' || msg == null) return; // 旧版 showAlert('') 用于"清空内联提示",toast 无需此操作
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
showToast(msg, t);
}
function setConn(cls, text) {
const el = $('#conn');
el.className = 'conn ' + cls;
el.innerHTML = '' + esc(text) + '';
}
// 导出提示:说明本次导出的筛选范围
function exportTip(what, cls, grp) {
const parts = [];
if (cls) parts.push(cls + (cls.indexOf('班') >= 0 ? '' : '班'));
if (grp) parts.push(grp + (grp.indexOf('组') >= 0 ? '' : '组'));
return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
}
/* ---------------- 问卷列表 ---------------- */
// 把数组渲染到 #projectSel(抽出便于缓存复用)
function renderProjects() {
const sel = $('#projectSel');
const keep = sel.value;
sel.innerHTML = '';
if (!state.projects.length) {
sel.innerHTML = '';
} else {
const ph = document.createElement('option');
ph.value = ''; ph.textContent = '请选择问卷 / 考试';
sel.appendChild(ph);
// 双策略识别文件夹:
// 1) mode 字段含 'folder'(卷王默认)
// 2) 被其他项目作为 parent_id 引用(部分版本无 mode 字段,回退推断)
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
const parentIds = new Set();
state.projects.forEach(p => {
const pid = (p.parent_id || '0');
if (pid && pid !== '0' && pid !== '') parentIds.add(pid);
});
const folderSet = new Set();
state.projects.forEach(p => {
if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id);
});
const childOf = fid => state.projects.filter(p =>
!folderSet.has(p.id) && (p.parent_id || '0') === fid);
const mkOpt = p => {
const o = document.createElement('option');
o.value = p.id;
o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
return o;
};
// 根项(不在任何文件夹里的)
state.projects.filter(p => !folderSet.has(p.id))
.forEach(p => sel.appendChild(mkOpt(p)));
// 文件夹及子项(空文件夹不显示,避免点击报错)
state.projects.filter(p => folderSet.has(p.id)).forEach(f => {
const kids = childOf(f.id);
if (!kids.length) return;
const og = document.createElement('optgroup');
og.label = '📁 ' + f.name;
kids.forEach(p => og.appendChild(mkOpt(p)));
sel.appendChild(og);
});
}
if (keep && state.projects.some(p => p.id === keep)) sel.value = keep;
else if (state.deeplinkProject && state.projects.some(p => p.id === state.deeplinkProject)) sel.value = state.deeplinkProject;
else {
const last = LS.get(LS_LASTPID, '');
if (last && state.projects.some(p => p.id === last)) sel.value = last;
}
const surveyCount = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder'))).length;
setConn('conn-on', (state._fromCache ? '缓存 · ' : '') + '已连接 · ' + surveyCount + ' 个问卷');
showAlert('');
if (sel.value) refresh();
}
async function loadProjects() {
setConn('conn-load', '连接中…');
// 1) 先读本地缓存 → 立刻渲染,避免空白
const cached = LS.get(LS_PROJECTS, []);
if (Array.isArray(cached) && cached.length) {
state.projects = cached;
state._fromCache = true;
renderProjects();
}
// 2) 再异步刷新
try {
const list = await api('projects');
state.projects = list || [];
state._fromCache = false;
LS.set(LS_PROJECTS, state.projects);
renderProjects();
} catch (e) {
if (!cached || !cached.length) {
setConn('conn-err', '连接失败');
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
} else {
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
}
}
}
/* ---------------- 数据刷新 ---------------- */
/** 行指纹:任一字段变化都算"有新数据" */
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 || [];
state.rows = d.rows || [];
state.absent = d.absent || [];
state.unmatched = d.unmatched || [];
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 && !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();
populateClasses();
populateGroups();
renderAbsent();
renderUnmatched();
renderHints();
$('#lastUpdate').textContent = d.updated_at || '—';
setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
if (!opts.fromCache) showAlert('');
}
async function refresh(silent) {
const pid = $('#projectSel').value;
if (!pid) return;
if (state.loading && !silent) return;
state.loading = true;
if (!silent) setConn('conn-load', '拉取数据…');
// 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定
const cached = LS.get(dataKey(pid), null);
if (cached && cached.updated_at) {
try {
applyData(cached, { fromCache: true });
} catch (e) { /* 旧格式缓存忽略 */ }
}
// 2) 后台异步拉真实数据
try {
const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } });
applyData(d);
state.connFail = 0;
LS.set(dataKey(pid), d);
LS.set(LS_LASTPID, pid);
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
// 名单缓存一份(便于刷新前/失败时仍能渲染班级/组别下拉)
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
} catch (e) {
if (e.auth) {
// 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
// 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环
if (!state._authRedirecting) {
state._authRedirecting = true;
if (state.timer) clearInterval(state.timer);
if (state.hlTimer) clearInterval(state.hlTimer);
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
// 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话)
fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
.catch(() => {})
.finally(() => {
// 200ms 足够让 toast 入场,又不至于让用户干等
setTimeout(() => { window.location.href = 'login.php'; }, 200);
});
}
return;
}
if (!cached) {
if (!silent) setConn('conn-err', '数据获取失败');
showAlert(e.message);
} else {
// 后台静默刷新失败:保留缓存数据,但把连接指示转为"中断"告警,成功即恢复
state.connFail++;
if (state.connFail >= 1) setConn('conn-warn', '⚠️ 实时刷新中断 · 已显示本地缓存');
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
}
} finally {
state.loading = false;
$('#liveDot').classList.add('on');
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
}
}
function renderNameFields(d) {
const sel = $('#nameFieldSel');
const keep = sel.value;
sel.innerHTML = '';
const cands = d.name_candidates || [];
cands.forEach(q => {
const o = document.createElement('option');
o.value = q.id;
o.textContent = '姓名题:' + (q.title || q.id);
sel.appendChild(o);
});
(d.questions || []).forEach(q => {
if (cands.some(c => c.id === q.id)) return;
const o = document.createElement('option');
o.value = q.id;
o.textContent = (q.title || q.id) + '(' + (q.type || '') + ')';
sel.appendChild(o);
});
const lastQid = LS.get(LS_LASTQID, '');
if (keep) sel.value = keep;
else if (state.deeplinkQid) sel.value = state.deeplinkQid;
else if (lastQid && (cands.some(c => c.id === lastQid) || (d.questions || []).some(q => q.id === lastQid))) sel.value = lastQid;
else if (d.name_qid) sel.value = d.name_qid;
}
function populateClasses() {
const sel = $('#clsSel');
const cur = sel.value;
const set = new Set();
(state.roster || []).forEach(s => { if (s.cls) set.add(s.cls); });
const vals = Array.from(set).sort((a, b) => a.localeCompare(b, 'zh', { numeric: true }));
sel.innerHTML = '' +
vals.map(v => '').join('');
// 优先保留用户当前已选项;否则按本地缓存恢复(跟随 projectId)
const pid = $('#projectSel').value;
const cache = pid ? (LS.get(LS_FILTERS, {})[pid] || {}) : {};
if (vals.indexOf(cur) >= 0) sel.value = cur;
else if (cache.cls && vals.indexOf(cache.cls) >= 0) sel.value = cache.cls;
else sel.value = '';
}
// 组别下拉:仅显示当前所选班级内出现的组别("全部班级" 时显示所有组别)
function populateGroups() {
const cls = $('#clsSel').value;
const grpSel = $('#grpSel');
if (!grpSel) return;
const cur = grpSel.value;
const set = new Set();
(state.roster || []).forEach(s => {
if (cls && (s.cls || '') !== cls) return;
if (s.grp) set.add(s.grp);
});
const vals = Array.from(set).sort();
grpSel.innerHTML = '' +
vals.map(v => '').join('');
const pid = $('#projectSel').value;
const cache = pid ? (LS.get(LS_FILTERS, {})[pid] || {}) : {};
if (vals.indexOf(cur) >= 0) grpSel.value = cur;
else if (cache.grp && vals.indexOf(cache.grp) >= 0) grpSel.value = cache.grp;
else grpSel.value = '';
}
function rememberFilters() {
const pid = $('#projectSel').value;
if (!pid) return;
const all = LS.get(LS_FILTERS, {});
all[pid] = { cls: $('#clsSel').value, grp: $('#grpSel').value, ts: Date.now() };
LS.set(LS_FILTERS, all);
}
// 按当前所选班级 + 组别(或无)客户端重算统计卡片
function computeDisplayStats(cls, grp) {
const allStudents = state.roster || [];
let students = cls ? allStudents.filter(s => (s.cls || '') === cls) : allStudents;
let rows = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows;
if (grp) {
students = students.filter(s => (s.grp || '') === grp);
rows = rows.filter(r => (r.grp || '') === grp);
}
const total = students.length;
const submitted = rows.length;
const absent = Math.max(0, total - submitted);
const rate = total > 0 ? Math.round(submitted / total * 1000) / 10 : 0;
const scores = rows.map(r => r.score).filter(v => v !== null && v !== undefined);
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
const full = state.fullScore || 100;
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= 0.6 * full).length;
const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0;
let maxScore = null, minScore = null;
const maxNames = [], minNames = [];
rows.forEach(r => {
if (r.score === null || r.score === undefined) return;
if (maxScore === null || r.score > maxScore) { maxScore = r.score; maxNames.length = 0; maxNames.push(r.name); }
else if (r.score === maxScore) maxNames.push(r.name);
if (minScore === null || r.score < minScore) { minScore = r.score; minNames.length = 0; minNames.push(r.name); }
else if (r.score === minScore) minNames.push(r.name);
});
const durs = rows.map(r => r.duration).filter(v => v !== null && v !== undefined);
const avgDur = durs.length ? Math.round(durs.reduce((a, b) => a + b, 0) / durs.length) : null;
return {
total, submitted, absent, rate, avg, passRate,
maxScore, minScore, maxNames, minNames, avgDur
};
}
function renderStats() {
const cls = $('#clsSel').value;
const grp = $('#grpSel').value;
const st = computeDisplayStats(cls, grp);
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
set('#sTotal', st.total);
set('#sSubmit', st.submitted);
set('#sAbsent', st.absent);
set('#sAvg', st.avg == null ? '-' : st.avg);
set('#sMax', st.maxScore == null ? '-' : st.maxScore);
set('#sMin', st.minScore == null ? '-' : st.minScore);
set('#sPass', st.passRate + '%');
set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur));
}
// 各面板计数文字按所选班级 + 组别过滤(成绩明细 / 缺考名单)
// 缺考数与统计卡片保持一致:过滤后名单 − 过滤后提交(而非二次过滤后端 absent 数组)
function renderHints() {
const cls = $('#clsSel').value;
const grp = $('#grpSel').value;
const rowsForCls = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows;
const rosterForCls = cls ? (state.roster || []).filter(s => (s.cls || '') === cls) : (state.roster || []);
const rowsForGrp = grp ? rowsForCls.filter(r => (r.grp || '') === grp) : rowsForCls;
const rosterForGrp = grp ? rosterForCls.filter(s => (s.grp || '') === grp) : rosterForCls;
const absentForGrp = Math.max(0, rosterForGrp.length - rowsForGrp.length);
$('#tableHint').textContent = '共 ' + rowsForGrp.length + ' 人已提交 · 名单 ' + rosterForGrp.length + ' 人';
$('#absentHint').textContent = absentForGrp ? '共 ' + absentForGrp + ' 人' : '';
}
function fmtDur(sec) {
if (sec == null) return '-';
if (sec < 60) return sec + ' 秒';
const m = Math.floor(sec / 60), s = sec % 60;
if (m < 60) return m + ' 分 ' + String(s).padStart(2, '0') + ' 秒';
const h = Math.floor(m / 60);
return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分';
}
function scoreClass(v) {
if (v == null) return '';
if (v >= 90) return 'score-high';
if (v >= 60) return 'score-mid';
return 'score-low';
}
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 base = state.rows.slice();
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) => cmpBy(k, dir, a, b));
const tb = $('#tbody');
if (!rows.length) {
tb.innerHTML = '
| 次序 | 提交时间 | 成绩 | 考试时长 |
|---|