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:
+461
-60
@@ -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;
|
||||
|
||||
@@ -117,6 +117,7 @@ body {
|
||||
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: #2559db; border-color: #2559db; }
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn.xs { padding: 3px 8px; font-size: 11px; }
|
||||
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
|
||||
.btn.danger:hover { background: var(--danger-weak); }
|
||||
.btn b {
|
||||
@@ -289,6 +290,9 @@ table.grid th.sortable { cursor: pointer; user-select: none; }
|
||||
table.grid th.sortable:hover { color: var(--primary); }
|
||||
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
||||
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
|
||||
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
|
||||
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
|
||||
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
|
||||
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
|
||||
table.grid tbody tr:hover td { background: var(--primary-weak); }
|
||||
table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
|
||||
@@ -381,6 +385,7 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
||||
.fi { display: flex; flex-direction: column; gap: 6px; }
|
||||
.fi.full { grid-column: 1 / -1; }
|
||||
.fi label { font-size: 12px; font-weight: 600; color: var(--text-2); }
|
||||
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
|
||||
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
||||
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
|
||||
.fullscores { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
Reference in New Issue
Block a user