完成批次 L-R:限流移除/白名单驱动/打印增强/全屏/表格对齐/主题变量/滚动闪烁修复

- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接

- 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey)

- 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加

- 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则

- 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致

- 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放

- 测试:新增 test-whitelist.js;run-all.sh 全绿 132
This commit is contained in:
2026-09-13 10:25:48 +08:00
parent 804b04de60
commit 94ac381579
17 changed files with 856 additions and 301 deletions
+200 -56
View File
@@ -252,19 +252,56 @@
].join('|');
}
/**
* 整份数据指纹:仅覆盖"会影响 DOM 渲染"的字段。
* 用于 applyData 的无变化守卫——自动刷新拿到与屏幕完全一致的数据时,跳过昂贵整表重绘。
* 含 project id/name 防止"不同项目数据恰好相同"导致误跳过。
*/
function dataSig(d) {
const rows = d.rows || [];
const roster = d.roster || [];
const unmatched = d.unmatched || [];
const questions = d.questions || [];
const rs = rows.map(rowSig).join('|');
const ros = roster.map(r => (r.sid || '') + ':' + (r.cls || '') + ':' + (r.grp || '')).join('|');
const um = unmatched.map(u => (u.name || '') + ':' + (u.score == null ? '-' : u.score)).join('|');
const q = questions.length + ':' + questions.map(q => q.id).join(',');
const proj = (d.project && (d.project.id || d.project.name)) || '';
return [
proj, d.updated_at || '', rows.length, rs, ros, um, q,
(d.absent || []).length, d.whitelist_driven ? 1 : 0, d.whitelist_total || 0,
].join('§');
}
function applyData(d, opts) {
opts = opts || {};
const fromCache = !!opts.fromCache;
state.questions = d.questions || [];
state.rows = d.rows || [];
state.absent = d.absent || [];
state.unmatched = d.unmatched || [];
state.roster = d.roster || [];
state.whitelistDriven = !!d.whitelist_driven;
state.whitelistTotal = (d.whitelist_total || 0);
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
// 性能守卫:数据指纹未变化则跳过昂贵整表重绘(自动刷新拿到与屏幕一致的数据时不再每轮重建 DOM)。
// 缓存预渲染路径照常保留——刷新间隙保持视觉稳定;仅当屏幕上已是同一份数据时才跳过。
const sig = dataSig(d);
if (sig === state._renderedSig) {
if (!fromCache) { // 真实刷新且数据未变:仅刷新轻量元信息,不重建 DOM
state.hasPrev = true; // 维持指纹基准,供下次 diff
$('#lastUpdate').textContent = d.updated_at || '—';
state.projectName = (d.project && d.project.name) || '';
setConn('conn-on', '实时 · ' + (d.project && d.project.name || ''));
showAlert('');
}
return;
}
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
const fromCache = !!opts.fromCache;
const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
// 缓存渲染不参与变更检测:本地缓存可能是旧数据,与上一轮真实数据比对会产生假高亮
if (state.hasPrev && !fromCache) {
@@ -291,7 +328,10 @@
state.prevMap = map;
state.prevSids = cur;
}
// 仅在有新数据时做 FLIP 行动画(新行滑入、其它行下移);搜索/排序重排不触发
const flipPrev = (state.newSids.size > 0) ? captureFlip($('#tbody')) : null;
renderTable();
if (flipPrev) playFlip($('#tbody'), flipPrev);
populateClasses();
populateGroups();
renderAbsent();
@@ -300,8 +340,9 @@
$('#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('');
setConn('conn-on', (fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
if (!fromCache) showAlert('');
state._renderedSig = sig; // 记录当前屏幕指纹,下次相同则跳过整表重绘
}
async function refresh(silent, force) {
@@ -652,6 +693,42 @@
btn.hidden = (n === 0);
}
// FLIP 行动画:新数据行平滑滑动到新位置,其它行顺势下移(尊重 prefers-reduced-motion)
const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
function captureFlip(container) {
const m = new Map();
if (!container) return m;
const trs = container.querySelectorAll('tr[data-sid]');
for (const tr of trs) {
const sid = tr.getAttribute('data-sid');
if (sid) m.set(sid, tr.getBoundingClientRect().top);
}
return m;
}
function playFlip(container, prev) {
if (!container || !prev || REDUCED_MOTION) return;
const trs = container.querySelectorAll('tr[data-sid]');
for (const tr of trs) {
const sid = tr.getAttribute('data-sid');
if (!sid) continue;
const oldTop = prev.get(sid);
if (oldTop === undefined) {
tr.classList.add('row-drop-in'); // 全新行:轻微落入
continue;
}
const newTop = tr.getBoundingClientRect().top;
const dy = oldTop - newTop;
if (Math.abs(dy) < 1) continue;
tr.style.transition = 'none';
tr.style.transform = 'translateY(' + dy + 'px)';
void tr.offsetWidth; // 强制回流,确保起始位生效
requestAnimationFrame(() => {
tr.style.transition = 'transform .42s cubic-bezier(.22,.61,.36,1)';
tr.style.transform = '';
});
}
}
function renderTable() {
const rows = buildRows();
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
@@ -662,8 +739,9 @@
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
_lastRange = ''; // 空表也重置窗口签名,下次有数据必重绘
} else {
renderVirtualRows(tb, rows);
renderVirtualRows(tb, rows, true); // 排序/搜索/数据变更:强制重绘
}
$$('#dataTable th.sortable').forEach(th => {
@@ -690,7 +768,7 @@
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 clsName = [hl ? 'new' : '', (_dropAnim && isNew) ? 'row-drop-in' : '', 'clickable'].filter(Boolean).join(' ');
const tags = [];
if (isNew) tags.push('<span class="tag tag-new">新</span>');
@@ -720,43 +798,60 @@
const ROW_H_FALLBACK = 41; // 首帧未测量前的行高估值
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
let measuredRowH = 0; // 实测行高(渲染后回写),CSS/字体/缩放变化都能自动适应
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;
const rh = measuredRowH || ROW_H_FALLBACK;
let start = Math.floor(scrollTop / rh) - VS_OVERSCAN;
let end = Math.ceil((scrollTop + viewH) / rh) + VS_OVERSCAN;
if (start < 0) start = 0;
if (end > total) end = total;
if (start >= end) start = Math.max(0, end - 1);
let _lastRange = ''; // 上次可视窗口签名(start:end:total:rh),未变化则跳过重绘,消除滚动闪烁
let _dropAnim = true; // 仅"数据/排序/搜索变更"的强制重绘才让新行播放落入动画;滚动复用 DOM 时不重播,避免滑动时一行行闪
function renderVirtualRows(tb, rows, force) {
const prevDrop = _dropAnim;
_dropAnim = !!force; // 滚动复用(force=false)时关闭落入动画,节点重建不再触发 replay
try {
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;
const rh = measuredRowH || ROW_H_FALLBACK;
let start = Math.floor(scrollTop / rh) - VS_OVERSCAN;
let end = Math.ceil((scrollTop + viewH) / rh) + 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 * rh) + '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) * rh) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
}
tb.innerHTML = frag.join('');
// 可视窗口未变(含小幅滚动仍在 overscan 内)→ 直接复用现有 DOM:
// 避免每帧重写 innerHTML 触发整块重绘,这是"滑一下闪一下"的根源。
const range = start + ':' + end + ':' + total + ':' + rh;
if (!force && range === _lastRange && tb.firstChild) return;
_lastRange = range;
// 渲染后回写真实行高:换字体、改样式、页面缩放时窗口定位都不会漂移
const tr0 = tb.querySelector('tr[data-sid]');
if (tr0) {
const h = tr0.offsetHeight;
if (h > 12 && h < 300) measuredRowH = h;
const frag = [];
if (start > 0) {
frag.push('<tr class="vspacer" style="height:' + (start * rh) + '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) * rh) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
}
tb.innerHTML = frag.join('');
// 渲染后回写真实行高:换字体、改样式、页面缩放时窗口定位都不会漂移。
// 仅在尚未测量时回写一次,避免滚动中反复改动行高导致 spacer 高度抖动(也会造成观感闪烁)。
if (measuredRowH === 0) {
const tr0 = tb.querySelector('tr[data-sid]');
if (tr0) {
const h = tr0.offsetHeight;
if (h > 12 && h < 300) measuredRowH = h;
}
}
} finally {
_dropAnim = prevDrop;
}
}
window.addEventListener('resize', () => { measuredRowH = 0; });
window.addEventListener('resize', () => { measuredRowH = 0; _lastRange = ''; });
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 state._sorted) */
function onTableScroll() {
@@ -787,13 +882,14 @@
// 按班统计缺考人数(未按班级筛选时显示概览)
const hint = $('#absentHint');
if (hint) {
const wlNote = state.whitelistDriven ? ('白名单应考 ' + (state.whitelistTotal || list.length) + ' 人 · ') : '';
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(' ');
hint.textContent = wlNote + '共 ' + list.length + ' 人 · ' + parts.join(' ');
} else {
hint.textContent = list.length ? '共 ' + list.length + ' 人' : '';
hint.textContent = wlNote + (list.length ? '共 ' + list.length + ' 人' : '');
}
}
}
@@ -815,7 +911,7 @@
'<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('');
'<td class="act"><button class="btn xs" data-map="' + esc(u.name) + '">加入名单</button></td></tr>').join('');
}
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
@@ -854,15 +950,15 @@
$('#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>' +
'<table class="grid compact att-table"><thead><tr>' +
'<th data-k="attempts" style="width:18%">次序</th>' +
'<th data-k="submit_ts" style="width:34%">提交时间</th>' +
'<th data-k="score" style="width:24%">成绩</th>' +
'<th data-k="duration" style="width:24%">考试时长</th>' +
'</tr></thead><tbody>' +
rows + '</tbody></table>';
$('#attemptsModal').classList.add('open');
}
function closeAttemptsModal() {
const m = $('#attemptsModal');
if (m) m.classList.remove('open');
}
/* ---------------- 自动刷新 ---------------- */
// 失败退避:连续失败时延长下次刷新间隔(1x → 2x → 4x,封顶 8x),成功后复位。
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
@@ -994,7 +1090,7 @@
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
const tb = $('#rosterBody');
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="6" class="empty">暂无名单,请上传文件</td></tr>';
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无名单,请上传文件</td></tr>';
$('#btnDelSel').hidden = true;
return;
}
@@ -1006,8 +1102,7 @@
'<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><input value="' + esc(r.login || '') + '" data-f="login" placeholder="可选"></td></tr>';
'<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td></tr>';
}).join('');
updateDelSelBtn();
}
@@ -1298,7 +1393,7 @@
async function uploadRoster(file) {
const fd = new FormData();
fd.append('file', file);
const mode = (document.querySelector('input[name=impMode]:checked') || {}).value || 'replace';
const mode = (document.querySelector('input[name=impMode]:checked') || {}).value || 'append';
fd.append('mode', mode);
try {
const d = await api('roster_import', { form: fd });
@@ -1311,6 +1406,19 @@
$('#rosterSearch').addEventListener('input', renderRoster);
// 下载名单导入模板(CSV,带 BOM 便于 Excel 正确识别 UTF-8)
$('#btnTpl').addEventListener('click', () => {
const header = ['学号', '姓名', '班级', '组别', '登陆账号'];
const sample = ['2024001', '张三', '高一(1)班', 'A', 'zhangsan'];
const csv = '' + [header.join(','), sample.join(',')].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '名单导入模板.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});
// 新增学生:弹出表单,填写后追加(不再直接在表格底部插入空行)
$('#btnAddRow').addEventListener('click', () => {
$('#addSid').value = ''; $('#addName').value = '';
@@ -1432,7 +1540,6 @@
} catch (err) { showAlert(err.message); }
});
/* 成绩快照 / 趋势 */
function curPid() { return $('#projectSel').value; }
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
@@ -1496,8 +1603,46 @@
].concat(extra || []).join(' | ');
}
// 展开每位学生的每次提交为独立一行(用于「打印全部记录」),与 buildRows 内的 expandAttempts 区分命名避免覆盖
function expandAttemptsForPrint(rows, desc) {
const out = [];
rows.forEach(r => {
const list = (r.attempts_list || []);
if (!list.length) {
const one = Object.assign({}, r);
one.idx = 1; one.is_best = true;
out.push(one);
return;
}
list.forEach(a => {
out.push({
sid: r.sid, name: r.name, cls: r.cls, grp: r.grp,
score: a.score, submit_ts: a.submit_ts, submit_at: a.submit_at,
duration: a.duration, duration_txt: a.duration_txt,
answer_id: a.answer_id, temp_save: a.temp_save,
attempts: r.attempts, best_score: r.best_score,
idx: a.idx, is_best: a.is_best,
last_submit_ts: r.last_submit_ts, last_submit_at: r.last_submit_at,
});
});
});
out.sort((x, y) => {
let d = (y.submit_ts || 0) - (x.submit_ts || 0);
if (!desc) d = -d;
if (d !== 0) return d;
const a = String(x.sid || ''), b = String(y.sid || '');
const na = /^\d+$/.test(a), nb = /^\d+$/.test(b);
if (na && nb) return Number(a) - Number(b);
return a.localeCompare(b, 'zh');
});
return out;
}
function printTable() {
const rows = (state._sorted || []).slice();
const scope = $('#ptScope').value;
let rows = (scope === 'all')
? expandAttemptsForPrint(state._sorted || [], true)
: (state._sorted || []).slice();
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
const sortMode = $('#ptSort').value;
@@ -1587,7 +1732,7 @@
let body = '<h2>' + esc(title) + '</h2>' +
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
'<div class="meta">' + esc(printMetaLine([
'共 ' + all.n + ' 人',
(scope === 'all' ? '共 ' + all.n + ' 条记录' : '共 ' + all.n + ' 人'),
'平均 ' + all.avg,
'最高 ' + all.max + ' / 最低 ' + all.min,
'合格 ' + all.pass + ' 人(' + all.passRate + '%)',
@@ -1609,7 +1754,6 @@
}
body += '<div class="sign">教师签字:____________  教研组长:____________  日期:____ 年 ____ 月 ____ 日</div>';
body += '<div class="foot">' + esc(NY.appName || '') + ' · 本文档由系统生成,仅供教学参考</div>';
printDoc(title, body, { orient: orient });
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
@@ -1619,7 +1763,7 @@
if (!curPid()) return showAlert('请先选择问卷 / 考试');
const n = (state._sorted || []).length;
const a = (state._absentList || []).length;
$('#ptHint').textContent = '当前将打印 ' + n + ' 条成绩' + (a ? '(可附带 ' + a + ' 名缺考学生)' : '');
$('#ptHint').textContent = '当前可打印 ' + n + ' 名学生的成绩' + (a ? '(可附带 ' + a + ' 名缺考学生)' : '') + ';弹窗中可切换为「打印全部记录」';
if (!$('#ptTitle').value.trim() && state.projectName) {
$('#ptTitle').value = state.projectName + ' 成绩明细';
}