完成批次 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 + ' 成绩明细';
}
+4 -9
View File
@@ -473,23 +473,18 @@
function renderResult() {
const tb = $('#drawBody');
if (!state.picked.length) {
tb.innerHTML = '<tr><td colspan="7" class="empty">尚未抽取</td></tr>';
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
return;
}
// 抽取结果表不显示登陆账号(仅导出白名单才含该列),故只渲染 5 个数据列 + 操作列
tb.innerHTML = state.picked.map((m, i) => {
// 登陆账号为空 → 导出会回退到学号,这里显式标红提示教师先去名单里补全
const login = String(m.login || '');
const loginCell = login
? esc(login)
: '<span class="muted" title="名单未填登陆账号,导出将回退用学号">' + esc(m.sid || '') + ' ⚠</span>';
return '<tr>' +
'<td class="num">' + (i + 1) + '</td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td class="name">' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'<td class="sid">' + loginCell + '</td>' +
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
'<td class="act"><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
'</tr>';
}).join('');
}
@@ -538,7 +533,7 @@
'</span></div>' +
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
'<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th style="width:56px">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th>班级</th><th>组别</th></tr></thead>' +
'<thead><tr><th style="width:8%;text-align:right">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th style="width:24%">班级</th><th style="width:14%">组别</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table></div>' +
'</details>' +
'</div>';
+38 -54
View File
@@ -6,14 +6,22 @@
--text: #1d2433;
--text-2: #5b6577;
--text-3: #8b95a7;
/* 主色阶:--primary 基准、--primary-2 悬停/激活深一档、--primary-3 浅色渐变端 */
--primary: #2f6bff;
--primary-2: #2559db;
--primary-3: #5b8bff;
--primary-weak: #eaf0ff;
--brand-2: #6a4bff; /* 品牌渐变收尾(紫)—— logo/面板条/登录条 */
--ok: #12a86a;
--ok-2: #3ec99a; /* 成功渐变亮端(toast) */
--ok-weak: #e7f7ef;
--warn: #e08c1a;
--warn-2: #f0a54a; /* 警告渐变亮端(toast) */
--warn-weak: #fdf3e2;
--danger: #e0483d;
--danger-2: #ef6c64; /* 危险渐变亮端(toast) */
--danger-weak: #fdecea;
--ink: #1b2440; /* 登录页深底 */
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
--radius: 12px;
@@ -120,7 +128,7 @@ body {
color: #fff;
}
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn-primary:hover { background: #2559db; border-color: #2559db; }
.btn-primary:hover { background: var(--primary-2); border-color: var(--primary-2); }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.xs { padding: 3px 8px; font-size: 11px; }
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
@@ -260,8 +268,6 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
.dot.stale { background: var(--danger); animation: blink 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
/* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */
.layout {
@@ -296,25 +302,31 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
}
/* ---------- 表格 ---------- */
.table-scroll { overflow: auto; max-height: 62vh; }
.table-scroll { overflow: auto; max-height: 62vh; scrollbar-gutter: stable; }
.table-scroll.short { max-height: 320px; }
.table-scroll.mid { max-height: 380px; }
table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
table.grid { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; table-layout: fixed; }
table.grid th {
position: sticky; top: 0; z-index: 2;
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
text-align: left; font-weight: 600; font-size: 12px;
padding: 11px 14px; border-bottom: 1px solid var(--border);
white-space: nowrap;
padding: 11px 14px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
/* 用 box-shadow 画分隔线而非 border:border 在 border-collapse:separate + sticky 下
不会与下方 td 边框产生折叠重绘缝隙(消除吸顶表头在滚动边界的 1px 双线/错位观感) */
background-clip: padding-box;
box-shadow: inset 0 -1px 0 var(--border);
}
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); }
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); vertical-align: middle; background-clip: padding-box; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 含按钮的操作列:不能被 ellipsis 的 overflow:hidden 裁掉按钮 */
table.grid td.act { overflow: visible; text-overflow: clip; }
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
table.grid tbody tr.vspacer:nth-child(even) td { 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); }
@@ -324,6 +336,7 @@ td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
/* 数值列右对齐:成绩 / 次数 / 时长纵向扫读时更容易对齐比对 */
table.grid td.num, table.grid td.score { text-align: right; }
/* 表头对齐 = data-k 派生,与单元格的正则保持一致;避免"表头左对齐、数据右对齐"造成的视觉错列 */
table.grid thead th[data-k="score"],
table.grid thead th[data-k="attempts"],
table.grid thead th[data-k="submit_ts"],
@@ -335,17 +348,22 @@ td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.score-high { color: var(--ok); }
.score-mid { color: var(--warn); }
.score-low { color: var(--danger); }
.diff { animation: flash 1.6s ease-out; }
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
/* 新数据行动画:FLIP 滑动(行移动) + 全新行落入;尊重 prefers-reduced-motion */
@keyframes rowDrop { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } }
table.grid tbody tr.row-drop-in { animation: rowDrop .42s ease; }
@media (prefers-reduced-motion: reduce) {
table.grid tbody tr.row-drop-in { animation: none; }
}
/* ---------- Toast 顶部飘窗通知 ---------- */
.toast {
background: linear-gradient(135deg, #2f6bff, #5b8bff);
background: linear-gradient(135deg, var(--primary), var(--primary-3));
color: #fff;
}
.toast-ok { background: linear-gradient(135deg, #12a86a, #3ec99a); color: #fff; }
.toast-error{ background: linear-gradient(135deg, #e0483d, #ef6c64); color: #fff; }
.toast-warn { background: linear-gradient(135deg, #e08c1a, #f0a54a); color: #fff; }
.toast-ok { background: linear-gradient(135deg, var(--ok), var(--ok-2)); color: #fff; }
.toast-error{ background: linear-gradient(135deg, var(--danger), var(--danger-2)); color: #fff; }
.toast-warn { background: linear-gradient(135deg, var(--warn), var(--warn-2)); color: #fff; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-12px) scale(.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
@@ -405,6 +423,9 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
.x:hover { color: var(--text); }
.modal-body { padding: 18px; overflow: auto; }
/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
.modal-body table.grid th { position: static; }
.modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; }
.modal-foot {
display: flex; align-items: center; gap: 8px;
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
@@ -419,11 +440,6 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
.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; }
.fullscores .row { display: flex; align-items: center; gap: 8px; }
.fullscores label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fullscores input { width: 90px; flex: none; }
.fullscores .empty { padding: 6px 0; color: var(--text-3); font-size: 12px; text-align: left; }
.hint { margin: 0; font-size: 11px; color: var(--text-3); }
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
@@ -487,9 +503,6 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
.login-box .fi { margin-bottom: 14px; }
.login-actions { display: flex; gap: 10px; margin-top: 6px; }
.login-actions .btn { flex: 1; }
.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
.login-msg.ok { color: var(--ok); }
.login-msg.err { color: var(--danger); }
/* ---------- 全屏适配 ---------- */
body.fs-on .wrap { max-width: none; padding-top: 8px; }
@@ -509,7 +522,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
@media (max-width: 1024px) {
.grid-2 { grid-template-columns: 1fr; }
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
.cards { grid-template-columns: repeat(4, 1fr); }
.toolbar { gap: 14px; }
@@ -529,8 +541,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.field { flex: 1 1 100%; min-width: 0; }
.field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
.field.btns select { width: 100%; }
/* 导出区的复选标签在小屏独占一行,避免和按钮挤成一团 */
.field.btns > label[style*="cursor:pointer"] { flex: 1 1 auto; }
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.card { padding: 10px 12px; }
.card b { font-size: 20px; }
@@ -612,15 +622,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.class-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-top: 14px; }
.class-card {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px 18px; box-shadow: var(--shadow); transition: transform .18s, box-shadow .18s;
}
.class-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
.class-card h3 { margin: 0 0 2px; font-size: 16px; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.class-card .rate-num { font-size: 15px; font-weight: 800; white-space: nowrap; }
.class-card .sub { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
@@ -628,23 +629,10 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.class-metrics .m span { color: var(--text-3); font-size: 11px; }
.class-metrics .m b { font-size: 17px; font-weight: 700; }
.metric-ok b { color: var(--ok); }
.metric-warn b { color: var(--warn); }
.metric-danger b { color: var(--danger); }
.empty { text-align: center; color: var(--text-3); padding: 40px 0; }
/* 班级卡片:可点击进入该班详情 */
.class-card.clickable { cursor: pointer; }
.class-card.clickable:hover { border-color: var(--primary); }
.grp-tag {
display: inline-block; margin-top: 12px; padding: 2px 10px; border-radius: 999px;
font-size: 11px; font-weight: 600; color: var(--primary); background: var(--primary-weak);
}
/* 班级详情头部 */
.detail-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 18px; }
.detail-head h2 { margin: 0; font-size: 18px; font-weight: 700; }
/* 分组对比:A 在左、B 在右 */
.group-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
@media (max-width: 860px) { .group-compare { grid-template-columns: 1fr; } }
@@ -679,8 +667,8 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.rk-3 { background: #fbe9dd; color: #b45309; }
/* 防溢出:网格子项允许收缩,长内容不撑破页面 */
.class-grid, .group-compare, .cards, .gpanel, .class-card { min-width: 0; }
.gpanel, .class-card { overflow: hidden; }
.group-compare, .cards, .gpanel { min-width: 0; }
.gpanel { overflow: hidden; }
.member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */
@@ -693,13 +681,9 @@ body.dash.fs-on .sec-title { margin: 12px 0 4px; font-size: 14px; }
body.dash.fs-on .cards { gap: 10px; margin-top: 10px; }
body.dash.fs-on .card { padding: 10px 12px; }
body.dash.fs-on .card b { font-size: 20px; }
body.dash.fs-on #overview, body.dash.fs-on #detail {
flex: 1 1 auto; min-height: 0; overflow: auto;
}
body.dash.fs-on .class-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
body.dash.fs-on .group-compare { gap: 12px; }
body.dash.fs-on .gpanel { padding: 12px 14px; }
body.dash.fs-on .member-list { max-height: 30vh; }
body.dash.fs-on .member-list { max-height: none; }
/* ================= 随机抽取页 ================= */