feat: 完善/优化/新增大批次 + 安全加固 + 性能优化

- 修复再次提交不置顶(排序键改用 last_submit_ts)
- 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效)
- 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列
- 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列
- 安全:CSRF token + 登录失败限流(5次锁10分钟)
- 未匹配答卷一键加入名单(unmatched_map)
- 缺考名单增强:复制学号/姓名、按班统计、打印
- 成绩快照 + 跨卷趋势(Snapshot)
- 异常检测线索(短时长高分/高频重考/雷同成绩)
- 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避
- 测试沉淀:test-new-features.php + run-all.sh
This commit is contained in:
2026-09-10 17:59:45 +08:00
parent 87750b20c4
commit 0fd880624f
14 changed files with 1784 additions and 196 deletions
+461 -60
View File
@@ -28,6 +28,9 @@
highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹
hlTimer: null, // 自动清除高亮的定时器
hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
_kwDebounce: null, // 搜索框防抖定时器
csrf: '', // CSRF token(来自 config_get),写请求自动附带
};
/* ---------------- 本地存储 ---------------- */
@@ -57,12 +60,19 @@
const init = { method: opts.method || 'GET' };
if (opts.body) {
init.headers = { 'Content-Type': 'application/json' };
// 写操作附带 CSRF token(在 body 内,后端 ny_csrf_ok 会从 JSON 里读取 _csrf)
if (state.csrf) opts.body._csrf = state.csrf;
init.body = JSON.stringify(opts.body);
}
if (opts.form) {
init.method = 'POST';
if (state.csrf) opts.form.append('_csrf', state.csrf);
init.body = opts.form;
}
if (opts.method === 'POST' && state.csrf) {
init.headers = init.headers || {};
init.headers['X-CSRF-Token'] = state.csrf;
}
let url = 'api.php?action=' + encodeURIComponent(action);
if (opts.params) {
for (const k in opts.params) {
@@ -249,6 +259,7 @@
state.absent = d.absent || [];
state.unmatched = d.unmatched || [];
state.roster = d.roster || [];
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
@@ -288,16 +299,17 @@
renderHints();
$('#lastUpdate').textContent = d.updated_at || '—';
state.projectName = (d.project && d.project.name) || '';
setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
if (!opts.fromCache) showAlert('');
}
async function refresh(silent) {
async function refresh(silent, force) {
const pid = $('#projectSel').value;
if (!pid) return;
if (state.loading && !silent) return;
state.loading = true;
if (!silent) setConn('conn-load', '拉取数据…');
if (!silent) setConn('conn-load', force ? '重新拉取数据…' : '拉取数据…');
// 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定
const cached = LS.get(dataKey(pid), null);
@@ -309,7 +321,9 @@
// 2) 后台异步拉真实数据
try {
const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } });
const params = { projectId: pid, nameQid: $('#nameFieldSel').value };
if (force) params.force = 1;
const d = await api('data', { params });
applyData(d);
state.connFail = 0;
LS.set(dataKey(pid), d);
@@ -323,7 +337,7 @@
// 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环
if (!state._authRedirecting) {
state._authRedirecting = true;
if (state.timer) clearInterval(state.timer);
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
if (state.hlTimer) clearInterval(state.hlTimer);
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
// 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话)
@@ -349,6 +363,8 @@
state.loading = false;
$('#liveDot').classList.add('on');
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
// 自动刷新是 setTimeout 链:每轮结束重新排下一轮(成功复位、失败按退避延长)
schedule();
}
}
@@ -488,7 +504,7 @@
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 + ' 人' : '';
// absentHint 由 renderAbsent 统一维护(含按班统计),此处不再重复设置
}
function fmtDur(sec) {
@@ -626,42 +642,15 @@
const rows = buildRows();
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
rows.sort((a, b) => cmpBy(k, dir, a, b));
state._sorted = rows;
const tb = $('#tbody');
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
return;
} else {
renderVirtualRows(tb, rows);
}
tb.innerHTML = rows.map(r => {
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) + (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('');
$$('#dataTable th.sortable').forEach(th => {
const isCur = th.dataset.k === state.sortKey;
@@ -671,6 +660,78 @@
renderHlBadge();
}
/** 单行 HTML(与虚拟滚动共用,避免逻辑重复) */
function rowHtml(r) {
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) + (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>';
}
/**
* 虚拟滚动:只渲染可视窗口内的行,用顶部/底部 spacer 撑起完整滚动高度。
* 行数很多(尤其「全部展开」历史模式下)时,避免一次性插入成千上万 DOM 节点导致卡顿。
*/
const ROW_H = 41; // 估算单行高度(含 padding/border),用于窗口定位
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
function renderVirtualRows(tb, rows) {
const scroller = tb.closest('.table-scroll');
if (!scroller) {
// 兜底:找不到滚动容器时退回全量渲染
tb.innerHTML = rows.map(rowHtml).join('');
return;
}
const scrollTop = scroller.scrollTop;
const viewH = scroller.clientHeight;
const total = rows.length;
let start = Math.floor(scrollTop / ROW_H) - VS_OVERSCAN;
let end = Math.ceil((scrollTop + viewH) / ROW_H) + VS_OVERSCAN;
if (start < 0) start = 0;
if (end > total) end = total;
if (start >= end) start = Math.max(0, end - 1);
const frag = [];
if (start > 0) {
frag.push('<tr class="vspacer" style="height:' + (start * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
}
for (let i = start; i < end; i++) {
frag.push(rowHtml(rows[i]));
}
if (end < total) {
frag.push('<tr class="vspacer" style="height:' + ((total - end) * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
}
tb.innerHTML = frag.join('');
}
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 state._sorted) */
function onTableScroll() {
const tb = $('#tbody');
const rows = state._sorted;
if (!tb || !rows || !rows.length) return;
renderVirtualRows(tb, rows);
}
function renderAbsent() {
const clsFilter = $('#clsSel').value;
const grpFilter = $('#grpSel').value;
@@ -681,24 +742,40 @@
const rosterForCls = clsFilter ? (state.roster || []).filter(s => (s.cls || '') === clsFilter) : (state.roster || []);
const rosterForGrp = grpFilter ? rosterForCls.filter(s => (s.grp || '') === grpFilter) : rosterForCls;
const list = rosterForGrp.filter(s => !submittedSids.has(s.sid));
state._absentList = list; // 供复制/打印按钮使用
const tb = $('#absentBody');
if (!list.length) {
tb.innerHTML = '<tr><td colspan="4" class="empty">' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + '</td></tr>';
return;
} else {
tb.innerHTML = list.map(a =>
'<tr><td class="sid">' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
}
// 按班统计缺考人数(未按班级筛选时显示概览)
const hint = $('#absentHint');
if (hint) {
if (!clsFilter && list.length) {
const byCls = {};
list.forEach(a => { const c = (a.cls || '未分班'); byCls[c] = (byCls[c] || 0) + 1; });
const parts = Object.keys(byCls).sort().map(c => c + ' ' + byCls[c] + ' 人');
hint.textContent = '共 ' + list.length + ' 人 · ' + parts.join(' ');
} else {
hint.textContent = list.length ? '共 ' + list.length + ' 人' : '';
}
}
tb.innerHTML = list.map(a =>
'<tr><td class="sid">' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
}
function renderUnmatched() {
const tb = $('#unmatchedBody');
if (!state.unmatched.length) {
tb.innerHTML = '<tr><td colspan="3" class="empty">无</td></tr>';
tb.innerHTML = '<tr><td colspan="4" class="empty">无</td></tr>';
return;
}
tb.innerHTML = state.unmatched.map(u =>
'<tr><td>' + esc(u.name) + '</td><td class="num">' + (u.score == null ? '-' : u.score) +
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').join('');
'<tr>' +
'<td>' + esc(u.name) + '</td>' +
'<td class="num">' + (u.score == null ? '-' : u.score) + '</td>' +
'<td class="num">' + esc(u.submit_at) + '</td>' +
'<td><button class="btn xs" data-map="' + esc(u.name) + '">加入名单</button></td></tr>').join('');
}
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
@@ -747,11 +824,15 @@
}
/* ---------------- 自动刷新 ---------------- */
// 失败退避:连续失败时延长下次刷新间隔(1x → 2x → 4x,封顶 8x),成功后复位。
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
function schedule() {
if (state.timer) clearInterval(state.timer);
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
if (!$('#autoRefresh').checked) return;
const sec = parseInt($('#intervalSel').value, 10) || 15;
state.timer = setInterval(() => refresh(true), sec * 1000);
const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000;
const backoff = Math.min(8, Math.pow(2, state.connFail)) * base;
const delay = (document.hidden ? Math.max(backoff, 5000) : backoff);
state.timer = setTimeout(() => { refresh(true); }, delay);
}
/* ---------------- 新数据高亮自动清除 ---------------- */
@@ -798,6 +879,7 @@
async function openSettings() {
try {
const c = await api('config_get');
if (c.csrf_token) state.csrf = c.csrf_token;
$('#cfgPageSize').value = c.page_size || 200;
$('#cfgInterval').value = c.refresh_interval || 15;
$('#cfgTimeout').value = c.timeout || 20;
@@ -867,6 +949,15 @@
/* ---------------- 名单 ---------------- */
let rosterDraft = [];
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
// 更新「删除选中」按钮的可见性与计数
function updateDelSelBtn() {
const btn = $('#btnDelSel');
if (!btn) return;
btn.hidden = (rosterSel.size === 0);
btn.textContent = '删除选中' + (rosterSel.size > 0 ? '(' + rosterSel.size + ')' : '');
}
async function openRoster() {
try {
@@ -885,24 +976,28 @@
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
const tb = $('#rosterBody');
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="4" class="empty">暂无名单,请上传文件</td></tr>';
tb.innerHTML = '<tr><td colspan="6" class="empty">暂无名单,请上传文件</td></tr>';
$('#btnDelSel').hidden = true;
return;
}
tb.innerHTML = rows.map((r, i) => {
const gi = rosterDraft.indexOf(r);
const checked = rosterSel.has(gi) ? ' checked' : '';
return '<tr data-i="' + gi + '">' +
'<td><input type="checkbox" class="roster-cb" data-i="' + gi + '"' + checked + '></td>' +
'<td><input value="' + esc(r.sid) + '" data-f="sid"></td>' +
'<td><input value="' + esc(r.name) + '" data-f="name"></td>' +
'<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' +
'<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td>' +
'<td><span class="link-del" data-del="' + gi + '">删除</span></td></tr>';
'<td><input value="' + esc(r.login || '') + '" data-f="login" placeholder="可选"></td></tr>';
}).join('');
updateDelSelBtn();
}
/* ---------------- 事件绑定 ---------------- */
document.addEventListener('DOMContentLoaded', function () {
$('#btnRefresh').addEventListener('click', () => refresh(false));
$('#btnRefresh').addEventListener('click', () => refresh(false, true));
$('#btnSettings').addEventListener('click', openSettings);
$('#btnRoster').addEventListener('click', openRoster);
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
@@ -910,7 +1005,11 @@
if (document.webkitFullscreenEnabled === undefined && !document.documentElement.requestFullscreen && !document.documentElement.webkitRequestFullscreen) {
$('#btnFullscreen').style.display = 'none';
}
$('#kw').addEventListener('input', renderTable);
// 搜索框防抖:连续输入时只渲染最后一次,避免每个字符都全量重排
$('#kw').addEventListener('input', () => {
if (state._kwDebounce) clearTimeout(state._kwDebounce);
state._kwDebounce = setTimeout(() => { renderTable(); }, 200);
});
$('#clsSel').addEventListener('change', () => {
populateGroups();
rememberFilters();
@@ -945,7 +1044,16 @@
});
$('#autoRefresh').addEventListener('change', e => {
if (e.target.checked) { schedule(); refresh(true); }
else if (state.timer) clearInterval(state.timer);
else if (state.timer) { clearTimeout(state.timer); state.timer = null; }
});
// 后台标签页暂停自动刷新(省流量/CPU);回到前台立即补一次并重置倒计时
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
} else {
if ($('#autoRefresh').checked) { refresh(true); schedule(); }
}
});
$$('#dataTable th.sortable').forEach(th => {
@@ -956,6 +1064,23 @@
});
});
// 虚拟滚动:滚动成绩表时按需渲染可视窗口(throttle 到 rAF 避免高频抖动)
(function () {
const scroller = $('#tbody') && $('#tbody').closest('.table-scroll');
if (!scroller) return;
let ticking = false;
scroller.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { onTableScroll(); ticking = false; });
});
// 窗口尺寸变化(缩放/布局切换)时重算可视行
if (window.ResizeObserver) {
new ResizeObserver(() => { if (!ticking) { ticking = true; requestAnimationFrame(() => { onTableScroll(); ticking = false; }); } })
.observe(scroller);
}
})();
/* 成绩明细行点击 → 历史成绩弹窗(事件委托,避免内联 onclick 找不到 IIFE 内函数)
注意:先绑 tbody 的 click,再绑 document 的 modal-close click;事件冒泡到 tbody 时就处理掉,
让 modal-close 只对弹窗自身 / [data-close] 元素生效,避免误关。 */
@@ -996,11 +1121,16 @@
: (sort === 'score_desc') ? '按成绩降序'
: (sort === 'score_asc') ? '按成绩升序'
: '按提交时间';
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
const latest = ($('#exportLatest') && $('#exportLatest').checked) ? 'latest' : 'all';
const split = ($('#exportSplit') && $('#exportSplit').checked) ? 'cls' : '';
const qs = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) +
'&sort=' + sort;
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + sortLabel, 'info');
'&sort=' + sort + '&mode=' + latest + (split ? '&split=' + split : '');
location.href = qs;
const modeLabel = (latest === 'latest') ? '(每人一行·最新成绩)' : '(每次考试一行)';
const splitLabel = (split === 'cls') ? ',按班级分表' : '';
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + sortLabel + splitLabel + modeLabel, 'info');
});
$('#btnExportAbsent').addEventListener('click', () => {
const pid = $('#projectSel').value;
@@ -1153,21 +1283,70 @@
}
$('#rosterSearch').addEventListener('input', renderRoster);
// 新增学生:弹出表单,填写后追加(不再直接在表格底部插入空行)
$('#btnAddRow').addEventListener('click', () => {
rosterDraft.push({ sid: '', name: '', cls: '' });
renderRoster();
$('#addSid').value = ''; $('#addName').value = '';
$('#addCls').value = ''; $('#addGrp').value = ''; $('#addLogin').value = '';
$('#addStudentHint').textContent = '';
$('#addStudentModal').classList.add('open');
$('#addSid').focus();
});
$('#btnAddStudentOk').addEventListener('click', () => {
const sid = $('#addSid').value.trim();
const name = $('#addName').value.trim();
if (!sid || !name) {
$('#addStudentHint').textContent = '学号和姓名必填';
return;
}
// 学号去重提示(不强制拦截,仅提醒)
if (rosterDraft.some(s => String(s.sid).trim() === sid)) {
$('#addStudentHint').textContent = '⚠️ 该学号已存在,仍会添加(请确认)';
}
rosterDraft.push({
sid, name,
cls: $('#addCls').value.trim(),
grp: $('#addGrp').value.trim(),
login: $('#addLogin').value.trim(),
});
renderRoster();
$('#addStudentModal').classList.remove('open');
showAlert('已添加学生:' + sid + ' ' + name, 'info');
});
$('#btnClearRoster').addEventListener('click', async () => {
if (!confirm('确定清空全部名单?')) return;
if (!confirm('确定清空全部名单?此操作不可撤销。')) return;
try {
const d = await api('roster_clear', { method: 'POST' });
rosterDraft = [];
rosterSel.clear();
renderRoster();
$('#rosterBadge').textContent = '0';
$('#rosterMeta').textContent = '共 0 人';
} catch (e) { showAlert(e.message); }
});
// 删除选中(多选 + 二次确认)
$('#btnDelSel').addEventListener('click', () => {
const n = rosterSel.size;
if (!n) return;
if (!confirm('确定删除选中的 ' + n + ' 名学生?此操作需点击「保存名单」后才会落盘。')) return;
const idx = Array.from(rosterSel).sort((a, b) => b - a); // 从大到小删,避免下标错位
idx.forEach(i => rosterDraft.splice(i, 1));
rosterSel.clear();
renderRoster();
showAlert('已删除 ' + n + ' 名学生(记得保存名单)', 'info');
});
// 全选 / 取消全选
$('#rosterCheckAll').addEventListener('change', e => {
rosterSel.clear();
if (e.target.checked) {
rosterDraft.forEach((_, i) => rosterSel.add(i));
}
renderRoster();
});
$('#rosterBody').addEventListener('input', e => {
const tr = e.target.closest('tr');
if (!tr) return;
@@ -1175,11 +1354,15 @@
if (isNaN(i)) return;
rosterDraft[i][e.target.dataset.f] = e.target.value;
});
$('#rosterBody').addEventListener('click', e => {
const id = e.target.getAttribute && e.target.getAttribute('data-del');
if (id == null) return;
rosterDraft.splice(parseInt(id, 10), 1);
renderRoster();
// 勾选 / 取消勾选(事件委托;input 事件会冒泡到这里时已被上面的 input 处理,这里只处理 checkbox)
$('#rosterBody').addEventListener('change', e => {
if (!e.target.classList || !e.target.classList.contains('roster-cb')) return;
const i = parseInt(e.target.dataset.i, 10);
if (isNaN(i)) return;
if (e.target.checked) rosterSel.add(i);
else rosterSel.delete(i);
updateDelSelBtn();
});
$('#btnSaveRoster').addEventListener('click', async () => {
@@ -1193,6 +1376,223 @@
} catch (e) { showAlert(e.message); }
});
/* 未匹配答卷 → 加入名单(映射到学号) */
let mapUmName = '';
$('#unmatchedBody').addEventListener('click', e => {
const name = e.target.getAttribute && e.target.getAttribute('data-map');
if (name == null) return;
mapUmName = name;
$('#mapUmName').textContent = name;
$('#mapUmSid').value = ''; $('#mapUmCls').value = ''; $('#mapUmGrp').value = '';
$('#mapUmHint').textContent = '';
$('#mapUnmatchedModal').classList.add('open');
$('#mapUmSid').focus();
});
$('#btnMapUmOk').addEventListener('click', async () => {
const sid = $('#mapUmSid').value.trim();
if (!sid) { $('#mapUmHint').textContent = '请输入学号'; return; }
try {
const d = await api('unmatched_map', { method: 'POST', body: {
name: mapUmName, sid,
cls: $('#mapUmCls').value.trim(),
grp: $('#mapUmGrp').value.trim(),
}});
$('#mapUnmatchedModal').classList.remove('open');
showAlert('已将「' + mapUmName + '」映射到学号 ' + sid + ' 并加入名单', 'info');
if ($('#rosterBadge')) $('#rosterBadge').textContent = (d.students || []).length;
// 刷新数据,让该答卷从「未匹配」归位到成绩明细
if ($('#projectSel').value) refresh(false, true);
} catch (err) { showAlert(err.message); }
});
/* 成绩快照 / 趋势 */
function curPid() { return $('#projectSel').value; }
async function loadSnapshots() {
const pid = curPid();
if (!pid) return;
try {
const d = await api('snapshot_list', { params: { projectId: pid } });
const list = d.snapshots || [];
const tb = $('#snapBody');
if (!list.length) {
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无快照,点击「保存当前快照」创建第一份</td></tr>';
return;
}
tb.innerHTML = list.map(s =>
'<tr>' +
'<td>' + esc(s.label) + '</td>' +
'<td class="num">' + esc(s.at_txt) + '</td>' +
'<td class="num">' + s.count + '</td>' +
'<td>' + esc((s.project && s.project.name) || '') + '</td>' +
'<td><span class="link-del" data-snapdel="' + esc(s.file) + '">删除</span></td></tr>').join('');
} catch (e) { showAlert(e.message); }
}
$('#btnSnapshot').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
$('#snapLabel').value = '';
$('#snapTitle').textContent = '成绩快照 · ' + (state.projectName || '');
loadSnapshots();
$('#snapshotModal').classList.add('open');
});
$('#btnSnapSave').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
try {
const d = await api('snapshot_save', { method: 'POST', params: { projectId: pid, nameQid: $('#nameFieldSel').value }, body: { label: $('#snapLabel').value.trim() } });
showAlert('快照已保存:' + d.label + '(' + d.count + ' 人)', 'info');
$('#snapLabel').value = '';
loadSnapshots();
} catch (e) { showAlert(e.message); }
});
$('#btnSnapRefresh').addEventListener('click', loadSnapshots);
$('#snapBody').addEventListener('click', async e => {
const f = e.target.getAttribute && e.target.getAttribute('data-snapdel');
if (f == null) return;
if (!confirm('确定删除该快照?此操作不可撤销。')) return;
try {
await api('snapshot_delete', { method: 'POST', params: { projectId: curPid() }, body: { file: f } });
showAlert('快照已删除', 'info');
loadSnapshots();
} catch (err) { showAlert(err.message); }
});
async function renderTrend() {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
const body = $('#trendBody'), head = $('#trendHead');
body.innerHTML = '<tr><td colspan="10" class="empty">加载中…</td></tr>';
try {
const d = await api('snapshot_trend', { params: { projectId: pid } });
const cols = d.columns || [], rows = d.rows || [];
$('#trendSub').textContent = state.projectName || '';
if (!cols.length) {
head.innerHTML = ''; body.innerHTML = '<tr><td colspan="10" class="empty">还没有快照,请先保存快照</td></tr>';
return;
}
head.innerHTML = '<tr><th>学号</th><th>姓名</th><th>班级</th>' +
cols.map(c => '<th title="' + esc(c.at_txt) + '">' + esc(c.label) + '</th>').join('') + '</tr>';
const colSpan = cols.length + 3;
if (!rows.length) {
body.innerHTML = '<tr><td colspan="' + colSpan + '" class="empty">无数据</td></tr>';
return;
}
body.innerHTML = rows.map(r => {
let cells = '<td class="sid">' + esc(r.sid) + '</td><td>' + esc(r.name) + '</td><td>' + esc(r.cls || '') + '</td>';
cols.forEach(c => {
const v = r.scores[c.key];
cells += '<td class="num">' + (v == null ? '—' : v) + '</td>';
});
return '<tr>' + cells + '</tr>';
}).join('');
} catch (e) { showAlert(e.message); }
}
$('#btnSnapTrend').addEventListener('click', () => {
$('#snapshotModal').classList.remove('open');
renderTrend();
$('#trendModal').classList.add('open');
});
$('#btnTrend').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
renderTrend();
$('#trendModal').classList.add('open');
});
/* 异常检测线索 */
function renderAnomaly() {
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
const box = $('#anomalyBody');
const sec = [];
const empty = '<span class="muted">无</span>';
// 1) 短时长高得分
sec.push('<h4 style="margin:14px 0 6px">⚡ 用时异常短却得分高(' + a.fast_high.length + ')</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>成绩</th><th>用时</th></tr></thead><tbody>');
if (!a.fast_high.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
else a.fast_high.forEach(x => {
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
'</td><td class="num">' + x.score + '</td><td class="num">' + esc(x.duration_txt || (x.duration + 's')) + '</td></tr>');
});
sec.push('</tbody></table></div>');
// 2) 高频重考
sec.push('<h4 style="margin:14px 0 6px">🔄 高频重考(' + a.frequent.length + ')</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>考试次数</th><th>最高分</th></tr></thead><tbody>');
if (!a.frequent.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
else a.frequent.forEach(x => {
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
'</td><td class="num">' + x.attempts + '</td><td class="num">' + (x.best_score == null ? '-' : x.best_score) + '</td></tr>');
});
sec.push('</tbody></table></div>');
// 3) 雷同成绩
sec.push('<h4 style="margin:14px 0 6px">⚠️ 多人成绩雷同(' + a.identical.length + ' 组)</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>成绩</th><th>人数</th><th>学生</th></tr></thead><tbody>');
if (!a.identical.length) sec.push('<tr><td colspan="3" class="empty">' + empty + '</td></tr>');
else a.identical.forEach(g => {
const names = g.members.map(m => m.name + '(' + m.sid + ')').join('、');
sec.push('<tr><td class="num">' + g.score + '</td><td class="num">' + g.count + '</td><td>' + esc(names) + '</td></tr>');
});
sec.push('</tbody></table></div>');
box.innerHTML = sec.join('');
}
$('#btnAnomaly').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
renderAnomaly();
$('#anomalyModal').classList.add('open');
});
/* 缺考名单增强:复制学号 / 复制姓名 / 打印 */
function copyText(text, what) {
const done = () => showAlert(what + '已复制到剪贴板', 'info');
const fail = () => showAlert('复制失败,请手动复制', 'error');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, fail);
} else {
// 兜底:临时 textarea + execCommand
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) { fail(); }
document.body.removeChild(ta);
}
}
$('#btnCopySids').addEventListener('click', () => {
const list = state._absentList || [];
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
copyText(list.map(a => a.sid).join(','), '学号');
});
$('#btnCopyNames').addEventListener('click', () => {
const list = state._absentList || [];
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
copyText(list.map(a => a.name).join('、'), '姓名');
});
$('#btnPrintAbsent').addEventListener('click', () => {
const list = state._absentList || [];
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
const rows = list.map(a =>
'<tr><td>' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
const project = state.projectName || '';
const title = (project ? project + ' · ' : '') + '缺考名单(' + list.length + ' 人)';
const w = window.open('', '_blank');
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
'<style>body{font-family:sans-serif;margin:24px}h2{margin:0 0 8px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #333;padding:6px 10px;text-align:left;font-size:13px}th{background:#eee}</style></head><body>' +
'<h2>' + esc(title) + '</h2><p>打印时间:' + new Date().toLocaleString() + '</p>' +
'<table><thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead><tbody>' + rows + '</tbody></table>' +
'<script>window.onload=function(){setTimeout(function(){window.print()},300)}<\/script></body></html>');
w.document.close();
});
/* 启动 */
// 1) 恢复布局偏好(仅样式,不涉及数据)
applyLayout(LS.get(LS_LAYOUT, 'default'));
@@ -1228,6 +1628,7 @@
(async () => {
try {
const c = await api('config_get');
if (c.csrf_token) state.csrf = c.csrf_token; // 提前拿到 CSRF token,写请求才能通过校验
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
if ($('#cfgHistoryDisplay')) $('#cfgHistoryDisplay').value = state.historyDisplay;