Files
ny-exam-platform/assets/app.js
T
nxxy335 555f500dc7 成绩明细历史记录:按提交年月日分类,每个分类标记最高分
新增 DataService::dayKeyOf(),日期键一律用服务端本地时区计算(避免 UTC 导致东八区零点前后错一天);aggregateByStudent 为 attempts_list 每项附加 day/day_best/day_count,expandAttempts 透传。

弹窗按天分组渲染分组行(日期 + 共N次 + 最高X分),组内新到旧;当日最高标签仅在当天有多次提交时显示,避免单次日每行都挂标签。

修复:submit_ts=0(时间未知)的记录原本被误标为第1次(升序排序 idx=1 但展示在末尾),现显示 -;未知时间统一归入「未知时间」组。

aggregate 单测 17 -> 33(跨天归属/日内次数/每日最高/未知时间组/时区边界 00:30 与 23:30);全量 148 项全绿。
2026-09-14 07:28:02 +08:00

1967 lines
90 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* NY 问卷考试系统数据平台 - 前端逻辑 */
(function () {
'use strict';
const $ = (s, r) => (r || document).querySelector(s);
const $$ = (s, r) => Array.prototype.slice.call((r || document).querySelectorAll(s));
const state = {
projects: [],
questions: [],
rows: [],
absent: [],
unmatched: [],
roster: [], // 完整名单(含班级),用于按班级重算统计
prevMap: {}, // sid -> 摘要,用于高亮变化
prevSids: new Set(), // 上一次刷新出现的 sid
newSids: new Set(), // 本次刷新新增的 sid(绿色底纹)
hasPrev: false,
fullScore: 100,
sortKey: 'submit_ts',
sortAsc: false,
timer: null,
loading: false,
lastSig: '',
connFail: 0,
historyDisplay: 'click', // click=点击成员弹窗查看历史;inline=成绩明细内全部展开
exportSort: 'time', // time=按提交时间;sid=按学号
highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹
hlTimer: null, // 自动清除高亮的定时器
hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
_kwDebounce: null, // 搜索框防抖定时器
csrf: '', // CSRF token(来自 config_get),写请求自动附带
};
/* ---------------- 本地存储 ---------------- */
// 浏览器关闭后仍然保留,账号/密码等敏感配置除外;进入页面时优先读缓存,再后台异步刷新
const LS = {
get(k, def) {
try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); }
catch (e) { return def; }
},
set(k, v) {
try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* 隐私模式 */ }
},
del(k) { try { localStorage.removeItem(k); } catch (e) {} }
};
const LS_PROJECTS = 'ny_projects';
const LS_LASTPID = 'ny_last_pid';
const LS_LASTQID = 'ny_last_qid';
const LS_FILTERS = 'ny_filters'; // {cls, grp} -> 跟随 projectId
const LS_LAYOUT = 'ny_layout';
const LS_INTERVAL = 'ny_interval'; // 用户手动调整的刷新间隔(跟随本机)
const LS_ROSTER = 'ny_roster_cache'; // 名单 cache(用于首屏补齐班级/组别下拉)
const dataKey = pid => 'ny_data_' + pid;
/* ---------------- 通用 ---------------- */
async function api(action, opts) {
opts = opts || {};
const init = { method: opts.method || 'GET' };
if (opts.body) {
init.headers = { 'Content-Type': 'application/json' };
// 写操作附带 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) {
if (opts.params[k] !== '' && opts.params[k] != null) {
url += '&' + k + '=' + encodeURIComponent(opts.params[k]);
}
}
}
const res = await fetch(url, init);
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch (e) {
throw new Error('服务端返回异常(可能 PHP 报错):' + text.slice(0, 300));
}
if (!json.ok) {
const err = new Error(json.msg || '请求失败');
err.auth = !!(json.extra && json.extra.auth);
err.extra = json.extra || null;
throw err;
}
return json.data;
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/* ---------------- Toast 顶部飘窗通知(替代旧 inline alert) ---------------- */
let toastTimer = null;
const TOAST_DURATION = 3500; // 默认显示 3.5 秒
const TOAST_LIMIT = 5; // 最多同时展示 5 条(旧消息优先被挤掉)
function showToast(msg, type) {
if (msg === '' || msg == null) return; // 空消息不弹(showAlert('') 兼容旧调用 → 静默返回)
// type: ''=默认(蓝) | 'info'=绿 | 'error'=红 | 'warn'=橙
const el = document.getElementById('toastContainer') || (function () {
const c = document.createElement('div');
c.id = 'toastContainer';
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
document.body.appendChild(c);
return c;
})();
// 限流:超过上限移除最旧
while (el.children.length >= TOAST_LIMIT) {
el.firstChild.remove();
}
const t = document.createElement('div');
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
t.className = 'toast ' + cls;
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
el.appendChild(t);
// 点击关闭
t.addEventListener('click', () => { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
// 自动消失
setTimeout(() => {
if (t.parentNode) { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); }
}, TOAST_DURATION);
}
/** 兼容旧调用:showAlert 现在走 toast(type 映射) */
function showAlert(msg, type) {
if (msg === '' || msg == null) return; // 旧版 showAlert('') 用于"清空内联提示",toast 无需此操作
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
showToast(msg, t);
}
function setConn(cls, text) {
const el = $('#conn');
el.className = 'conn ' + cls;
el.innerHTML = '<i></i><span>' + esc(text) + '</span>';
}
// 导出提示:说明本次导出的筛选范围
function exportTip(what, cls, grp) {
const parts = [];
if (cls) parts.push(cls + (cls.indexOf('班') >= 0 ? '' : '班'));
if (grp) parts.push(grp + (grp.indexOf('组') >= 0 ? '' : '组'));
return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
}
/* ---------------- 问卷列表 ---------------- */
// 把数组渲染到 #projectSel(抽出便于缓存复用)
function renderProjects() {
const sel = $('#projectSel');
const keep = sel.value;
sel.innerHTML = '';
if (!state.projects.length) {
sel.innerHTML = '<option value="">(没有获取到问卷,请检查设置)</option>';
} else {
const ph = document.createElement('option');
ph.value = ''; ph.textContent = '请选择问卷 / 考试';
sel.appendChild(ph);
// 双策略识别文件夹:
// 1) mode 字段含 'folder'(卷王默认)
// 2) 被其他项目作为 parent_id 引用(部分版本无 mode 字段,回退推断)
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
const parentIds = new Set();
state.projects.forEach(p => {
const pid = (p.parent_id || '0');
if (pid && pid !== '0' && pid !== '') parentIds.add(pid);
});
const folderSet = new Set();
state.projects.forEach(p => {
if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id);
});
const childOf = fid => state.projects.filter(p =>
!folderSet.has(p.id) && (p.parent_id || '0') === fid);
const mkOpt = p => {
const o = document.createElement('option');
o.value = p.id;
o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
return o;
};
// 根项(不在任何文件夹里的)
state.projects.filter(p => !folderSet.has(p.id))
.forEach(p => sel.appendChild(mkOpt(p)));
// 文件夹及子项(空文件夹不显示,避免点击报错)
state.projects.filter(p => folderSet.has(p.id)).forEach(f => {
const kids = childOf(f.id);
if (!kids.length) return;
const og = document.createElement('optgroup');
og.label = '📁 ' + f.name;
kids.forEach(p => og.appendChild(mkOpt(p)));
sel.appendChild(og);
});
}
if (keep && state.projects.some(p => p.id === keep)) sel.value = keep;
else if (state.deeplinkProject && state.projects.some(p => p.id === state.deeplinkProject)) sel.value = state.deeplinkProject;
else {
const last = LS.get(LS_LASTPID, '');
if (last && state.projects.some(p => p.id === last)) sel.value = last;
}
const surveyCount = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder'))).length;
setConn('conn-on', (state._fromCache ? '缓存 · ' : '') + '已连接 · ' + surveyCount + ' 个问卷');
showAlert('');
if (sel.value) refresh();
}
async function loadProjects() {
setConn('conn-load', '连接中…');
// 1) 先读本地缓存 → 立刻渲染,避免空白
const cached = LS.get(LS_PROJECTS, []);
if (Array.isArray(cached) && cached.length) {
state.projects = cached;
state._fromCache = true;
renderProjects();
}
// 2) 再异步刷新
try {
const list = await api('projects');
state.projects = list || [];
state._fromCache = false;
LS.set(LS_PROJECTS, state.projects);
renderProjects();
} catch (e) {
if (!cached || !cached.length) {
setConn('conn-err', '连接失败');
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
} else {
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
}
}
}
/* ---------------- 数据刷新 ---------------- */
/** 行指纹:任一字段变化都算"有新数据" */
function rowSig(r) {
return [
r.sid, r.score, r.submit_at, r.attempts || 1, r.last_submit_ts || 0,
].join('|');
}
/**
* 整份数据指纹:仅覆盖"会影响 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 cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
// 缓存渲染不参与变更检测:本地缓存可能是旧数据,与上一轮真实数据比对会产生假高亮
if (state.hasPrev && !fromCache) {
state.newSids = new Set(Array.from(cur).filter(s => !state.prevSids.has(s)));
state.rows.forEach(r => {
const sid = String(r.sid || '');
if (sid === '') return;
const prev = state.prevMap[sid];
if (prev === undefined || prev !== rowSig(r)) {
state.highlight.add(sid); // 累积:一直常亮到用户清除或切换项目
}
});
} else if (!state.hasPrev) {
state.newSids = new Set();
}
if (!fromCache) state.hasPrev = true;
renderNameFields(d);
renderStats();
// 仅真实数据才更新指纹基准(缓存可能是旧数据,不能作为比对基准)
if (!fromCache) {
const map = {};
state.rows.forEach(r => { map[String(r.sid)] = rowSig(r); });
state.prevMap = map;
state.prevSids = cur;
}
// 仅在有新数据时做 FLIP 行动画(新行滑入、其它行下移);搜索/排序重排不触发
const flipPrev = (state.newSids.size > 0) ? captureFlip($('#tbody')) : null;
renderTable();
if (flipPrev) playFlip($('#tbody'), flipPrev);
populateClasses();
populateGroups();
renderAbsent();
renderUnmatched();
renderHints();
$('#lastUpdate').textContent = d.updated_at || '—';
state.projectName = (d.project && d.project.name) || '';
setConn('conn-on', (fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
if (!fromCache) showAlert('');
state._renderedSig = sig; // 记录当前屏幕指纹,下次相同则跳过整表重绘
}
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', force ? '重新拉取数据…' : '拉取数据…');
// 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定
const cached = LS.get(dataKey(pid), null);
if (cached && cached.updated_at) {
try {
applyData(cached, { fromCache: true });
} catch (e) { /* 旧格式缓存忽略 */ }
}
// 2) 后台异步拉真实数据
try {
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);
LS.set(LS_LASTPID, pid);
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
// 名单缓存一份(便于刷新前/失败时仍能渲染班级/组别下拉)
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
} catch (e) {
if (e.auth) {
// 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
// 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环
if (!state._authRedirecting) {
state._authRedirecting = true;
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
if (state.hlTimer) clearInterval(state.hlTimer);
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
// 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话)
fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
.catch(() => {})
.finally(() => {
// 200ms 足够让 toast 入场,又不至于让用户干等
setTimeout(() => { window.location.href = 'login.php'; }, 200);
});
}
return;
}
if (!cached) {
if (!silent) setConn('conn-err', '数据获取失败');
showAlert(e.message);
} else {
// 后台静默刷新失败:保留缓存数据,但把连接指示转为"中断"告警,成功即恢复
state.connFail++;
if (state.connFail >= 1) setConn('conn-warn', '⚠️ 实时刷新中断 · 已显示本地缓存');
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
}
} finally {
state.loading = false;
$('#liveDot').classList.add('on');
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
// 自动刷新是 setTimeout 链:每轮结束重新排下一轮(成功复位、失败按退避延长)
schedule();
}
}
function renderNameFields(d) {
const sel = $('#nameFieldSel');
const keep = sel.value;
sel.innerHTML = '<option value="">自动识别</option>';
const cands = d.name_candidates || [];
cands.forEach(q => {
const o = document.createElement('option');
o.value = q.id;
o.textContent = '姓名题:' + (q.title || q.id);
sel.appendChild(o);
});
(d.questions || []).forEach(q => {
if (cands.some(c => c.id === q.id)) return;
const o = document.createElement('option');
o.value = q.id;
o.textContent = (q.title || q.id) + '(' + (q.type || '') + ')';
sel.appendChild(o);
});
const lastQid = LS.get(LS_LASTQID, '');
if (keep) sel.value = keep;
else if (state.deeplinkQid) sel.value = state.deeplinkQid;
else if (lastQid && (cands.some(c => c.id === lastQid) || (d.questions || []).some(q => q.id === lastQid))) sel.value = lastQid;
else if (d.name_qid) sel.value = d.name_qid;
}
function populateClasses() {
const sel = $('#clsSel');
const cur = sel.value;
const set = new Set();
(state.roster || []).forEach(s => { if (s.cls) set.add(s.cls); });
const vals = Array.from(set).sort((a, b) => a.localeCompare(b, 'zh', { numeric: true }));
sel.innerHTML = '<option value="">全部班级</option>' +
vals.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
// 优先保留用户当前已选项;否则按本地缓存恢复(跟随 projectId)
const pid = $('#projectSel').value;
const cache = pid ? (LS.get(LS_FILTERS, {})[pid] || {}) : {};
if (vals.indexOf(cur) >= 0) sel.value = cur;
else if (cache.cls && vals.indexOf(cache.cls) >= 0) sel.value = cache.cls;
else sel.value = '';
}
// 组别下拉:仅显示当前所选班级内出现的组别("全部班级" 时显示所有组别)
function populateGroups() {
const cls = $('#clsSel').value;
const grpSel = $('#grpSel');
if (!grpSel) return;
const cur = grpSel.value;
const set = new Set();
(state.roster || []).forEach(s => {
if (cls && (s.cls || '') !== cls) return;
if (s.grp) set.add(s.grp);
});
const vals = Array.from(set).sort();
grpSel.innerHTML = '<option value="">全部组别</option>' +
vals.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
const pid = $('#projectSel').value;
const cache = pid ? (LS.get(LS_FILTERS, {})[pid] || {}) : {};
if (vals.indexOf(cur) >= 0) grpSel.value = cur;
else if (cache.grp && vals.indexOf(cache.grp) >= 0) grpSel.value = cache.grp;
else grpSel.value = '';
}
function rememberFilters() {
const pid = $('#projectSel').value;
if (!pid) return;
const all = LS.get(LS_FILTERS, {});
all[pid] = { cls: $('#clsSel').value, grp: $('#grpSel').value, ts: Date.now() };
LS.set(LS_FILTERS, all);
}
// 按当前所选班级 + 组别(或无)客户端重算统计卡片
function computeDisplayStats(cls, grp) {
const allStudents = state.roster || [];
let students = cls ? allStudents.filter(s => (s.cls || '') === cls) : allStudents;
let rows = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows;
if (grp) {
students = students.filter(s => (s.grp || '') === grp);
rows = rows.filter(r => (r.grp || '') === grp);
}
const total = students.length;
const submitted = rows.length;
const absent = Math.max(0, total - submitted);
const rate = total > 0 ? Math.round(submitted / total * 1000) / 10 : 0;
const scores = rows.map(r => r.score).filter(v => v !== null && v !== undefined);
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
const full = state.fullScore || 100;
const pr = (state.passRate == null ? 60 : state.passRate); // 合格率阈值可配
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= (pr / 100) * full).length;
const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0;
let maxScore = null, minScore = null;
const maxNames = [], minNames = [];
rows.forEach(r => {
if (r.score === null || r.score === undefined) return;
if (maxScore === null || r.score > maxScore) { maxScore = r.score; maxNames.length = 0; maxNames.push(r.name); }
else if (r.score === maxScore) maxNames.push(r.name);
if (minScore === null || r.score < minScore) { minScore = r.score; minNames.length = 0; minNames.push(r.name); }
else if (r.score === minScore) minNames.push(r.name);
});
const durs = rows.map(r => r.duration).filter(v => v !== null && v !== undefined);
const avgDur = durs.length ? Math.round(durs.reduce((a, b) => a + b, 0) / durs.length) : null;
return {
total, submitted, absent, rate, avg, passRate,
maxScore, minScore, maxNames, minNames, avgDur
};
}
function renderStats() {
const cls = $('#clsSel').value;
const grp = $('#grpSel').value;
const st = computeDisplayStats(cls, grp);
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
set('#sTotal', st.total);
set('#sSubmit', st.submitted);
set('#sAbsent', st.absent);
set('#sRate', st.rate + '%');
set('#sAvg', st.avg == null ? '-' : st.avg);
set('#sMax', st.maxScore == null ? '-' : st.maxScore);
set('#sMin', st.minScore == null ? '-' : st.minScore);
set('#sPass', st.passRate + '%');
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映当前生效值
const lbl = $('#sPassLabel');
if (lbl) lbl.textContent = '(≥' + (state.passRate == null ? 60 : state.passRate) + '%满分)';
set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur));
}
// 各面板计数文字按所选班级 + 组别过滤(成绩明细 / 缺考名单)
// 缺考数与统计卡片保持一致:过滤后名单 − 过滤后提交(而非二次过滤后端 absent 数组)
function renderHints() {
const cls = $('#clsSel').value;
const grp = $('#grpSel').value;
const rowsForCls = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows;
const rosterForCls = cls ? (state.roster || []).filter(s => (s.cls || '') === cls) : (state.roster || []);
const rowsForGrp = grp ? rowsForCls.filter(r => (r.grp || '') === grp) : rowsForCls;
const rosterForGrp = grp ? rosterForCls.filter(s => (s.grp || '') === grp) : rosterForCls;
const absentForGrp = Math.max(0, rosterForGrp.length - rowsForGrp.length);
$('#tableHint').textContent = '共 ' + rowsForGrp.length + ' 人已提交 · 名单 ' + rosterForGrp.length + ' 人';
// absentHint 由 renderAbsent 统一维护(含按班统计),此处不再重复设置
}
function fmtDur(sec) {
if (sec == null) return '-';
if (sec < 60) return sec + ' 秒';
const m = Math.floor(sec / 60), s = sec % 60;
if (m < 60) return m + ' 分 ' + String(s).padStart(2, '0') + ' 秒';
const h = Math.floor(m / 60);
return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分';
}
/**
* 成绩着色:按「占本卷满分的百分比」判断,而不是写死 90 / 60 分。
* 否则把满分改成 50 分(或卷子本身是 20 分制)时,整列都会被误判为不及格标红。
*/
function scoreClass(v) {
if (v == null) return '';
const full = state.fullScore || 100;
if (!(full > 0)) return '';
const pct = (Number(v) / full) * 100;
if (!isFinite(pct)) return '';
const pass = (state.passRate == null ? 60 : Number(state.passRate));
if (pct >= 90) return 'score-high';
if (pct >= pass) return 'score-mid';
return 'score-low';
}
const TEXT_KEYS = { sid: 1, cls: 1, grp: 1, name: 1 };
function cmpText(x, y) {
return String(x).localeCompare(String(y), 'zh', { numeric: true });
}
/** 学号比较:纯数字按数值,否则自然排序 */
function cmpSid(a, b) {
const s1 = String(a.sid == null ? '' : a.sid);
const s2 = String(b.sid == null ? '' : b.sid);
if (s1 !== '' && s2 !== '' && !isNaN(Number(s1)) && !isNaN(Number(s2))) {
const d = Number(s1) - Number(s2);
if (d !== 0) return d;
}
return cmpText(s1, s2);
}
/** 某排序键是否视为"空值"(空值恒排末尾,不受升降序影响) */
function isEmptyVal(key, v) {
if (v === null || v === undefined || v === '') return true;
// 时间戳 0 表示未知时间
if ((key === 'submit_ts' || key === 'last_submit_ts') && Number(v) === 0) return true;
return false;
}
/**
* 统一比较器:
* - 空值恒排末尾(不再因为 -1 混在中间)
* - 主键相同时按学号做次级排序,保证顺序稳定
*/
function cmpBy(key, dir, a, b) {
const x = a[key], y = b[key];
const xe = isEmptyVal(key, x), ye = isEmptyVal(key, y);
if (xe && ye) return cmpSid(a, b) * dir;
if (xe) return 1; // 空值永远在后
if (ye) return -1;
let d;
if (TEXT_KEYS[key]) {
d = cmpText(x, y);
} else {
const nx = Number(x), ny = Number(y);
d = (isNaN(nx) || isNaN(ny)) ? cmpText(x, y) : (nx - ny);
}
if (d !== 0) return d * dir;
return cmpSid(a, b) * dir;
}
/** 展开为"每次考试一行"(历史成绩全部展示模式) */
function expandAttempts(rows) {
const out = [];
rows.forEach(r => {
const list = (r.attempts_list && r.attempts_list.length) ? r.attempts_list : null;
if (!list) {
out.push(Object.assign({}, r, { idx: 1, is_best: true, _att: false }));
return;
}
list.forEach(a => {
out.push({
sid: r.sid, name: r.name, cls: r.cls, grp: r.grp,
score: (a.score === undefined ? null : a.score),
submit_ts: a.submit_ts || 0,
submit_at: a.submit_at || '-',
duration: (a.duration === undefined ? null : a.duration),
duration_txt: a.duration_txt || '-',
attempts: r.attempts || 1,
best_score: (r.best_score === undefined ? null : r.best_score),
last_submit_ts: r.last_submit_ts || 0,
last_submit_at: r.last_submit_at || '-',
idx: a.idx || 1,
is_best: !!a.is_best,
answer_id: a.answer_id || '',
temp_save: !!a.temp_save,
_att: true,
});
});
});
return out;
}
/** 依据筛选 + 展示模式,得到待渲染的行 */
function buildRows() {
const kw = $('#kw').value.trim().toLowerCase();
const clsFilter = $('#clsSel').value;
const grpFilter = $('#grpSel').value;
let base = state.rows.slice();
if (clsFilter) base = base.filter(r => (r.cls || '') === clsFilter);
if (grpFilter) base = base.filter(r => (r.grp || '') === grpFilter);
if (kw) base = base.filter(r =>
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
if (state.historyDisplay === 'inline') {
return expandAttempts(base);
}
// 一人一行:提交时间显示"最近一次提交",使"按提交时间排序"= 谁最近交的
return base.map(r => Object.assign({}, r, {
submit_ts: (r.last_submit_ts || r.submit_ts || 0),
submit_at: (r.last_submit_at && r.last_submit_at !== '-') ? r.last_submit_at : (r.submit_at || '-'),
_att: false,
}));
}
const COL_COUNT = 8;
/** 更新"清除高亮"按钮的可见性与计数 */
function renderHlBadge() {
const btn = $('#btnClearHl');
const badge = $('#hlBadge');
if (!btn) return;
const n = state.highlight.size;
if (badge) badge.textContent = n;
btn.hidden = (n === 0);
}
// 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;
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>';
_lastRange = ''; // 空表也重置窗口签名,下次有数据必重绘
} else {
renderVirtualRows(tb, rows, true); // 排序/搜索/数据变更:强制重绘
}
$$('#dataTable th.sortable').forEach(th => {
const isCur = th.dataset.k === state.sortKey;
th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : '');
});
// 搜索时提示要跟上,否则会出现"表格只有 3 行、提示却写共 50 人"的困惑
const kwNow = ($('#kw').value || '').trim();
if (kwNow) {
const base = state.rows.length;
const hintEl = $('#tableHint');
if (hintEl) hintEl.textContent = '搜索「' + kwNow + '」命中 ' + rows.length + ' 人(本卷共 ' + base + ' 人已提交)';
} else {
renderHints();
}
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' : '', (_dropAnim && isNew) ? 'row-drop-in' : '', '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_FALLBACK = 41; // 首帧未测量前的行高估值
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
let measuredRowH = 0; // 实测行高(渲染后回写),CSS/字体/缩放变化都能自动适应
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);
// 可视窗口未变(含小幅滚动仍在 overscan 内)→ 直接复用现有 DOM:
// 避免每帧重写 innerHTML 触发整块重绘,这是"滑一下闪一下"的根源。
const range = start + ':' + end + ':' + total + ':' + rh;
if (!force && range === _lastRange && tb.firstChild) return;
_lastRange = range;
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; _lastRange = ''; });
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 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;
// 与统计卡片保持一致:缺考 = 过滤后名单中未出现在提交记录里的学生(按 sid 匹配)
const rowsForCls = clsFilter ? state.rows.filter(r => (r.cls || '') === clsFilter) : state.rows;
const rowsForGrp = grpFilter ? rowsForCls.filter(r => (r.grp || '') === grpFilter) : rowsForCls;
const submittedSids = new Set(rowsForGrp.map(r => r.sid).filter(Boolean));
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>';
} 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) {
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 = wlNote + '共 ' + list.length + ' 人 · ' + parts.join(' ');
} else {
hint.textContent = wlNote + (list.length ? '共 ' + list.length + ' 人' : '');
}
}
}
function renderUnmatched() {
const tb = $('#unmatchedBody');
const hint = $('#unmatchedHint');
if (hint) {
hint.textContent = state.unmatched.length
? ('共 ' + state.unmatched.length + ' 份 · 姓名与名单对不上,核对后可点「加入名单」补录')
: '姓名不在名单中';
}
if (!state.unmatched.length) {
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>' +
'<td class="act"><button class="btn xs" data-map="' + esc(u.name) + '">加入名单</button></td></tr>').join('');
}
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
function openAttemptsModal(sid) {
const r = (state.rows || []).find(x => String(x.sid) === String(sid));
if (!r) return;
const attempts = (r.attempts || 1);
let list = r.attempts_list || [];
if (!list.length) {
// 后端未返回明细时,用代表行兜底成一条
list = [{
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
submit_ts: r.submit_ts || r.last_submit_ts || 0,
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
day: '', day_best: false, day_count: 1,
}];
}
// 新 -> 旧 展示(缺 submit_ts 时按 idx 倒序兜底,保证"次序"看起来合理)
const desc = list.slice().sort((a, b) => {
const ta = a.submit_ts || 0, tb = b.submit_ts || 0;
if (ta !== tb) return tb - ta;
return (b.idx || 0) - (a.idx || 0);
});
// 按「提交时间年月日」分组:新的一天在前。
// 日期键由后端按服务端本地时区算好(a.day),这里仅兜底;时间未知的(ts=0)key 为 '',
// 且排序里排最后,天然连续落在末尾 → 归入「未知时间」组。
const groups = [];
desc.forEach(a => {
const key = a.day || (a.submit_at && a.submit_at !== '-' ? String(a.submit_at).slice(0, 10) : '') || '';
let g = groups.length ? groups[groups.length - 1] : null;
if (!g || g.key !== key) { g = { key: key, items: [] }; groups.push(g); }
g.items.push(a);
});
// 每组的最高分(未判分不参与),用于分组行显示「最高 X 分」
groups.forEach(g => {
g.max = g.items.reduce(
(m, a) => (a.score == null ? m : (m == null || Number(a.score) > m ? Number(a.score) : m)), null);
});
const rows = groups.map(g => {
const maxTxt = (g.max == null) ? '' : ' · 最高 ' + g.max + ' 分';
let html = '<tr class="att-day"><td colspan="4">' + esc(g.key || '未知时间') +
'<span class="att-day-sub">共 ' + g.items.length + ' 次' + maxTxt + '</span></td></tr>';
html += g.items.map(a => {
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
// 当日最高:仅当天有多次提交时才标,避免"一天只考一次"时每行都挂标签、反而看不出重点
const dayBest = (a.day_best && (a.day_count || 0) >= 2) ? ' <span class="tag tag-day">当日最高</span>' : '';
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
// 时间未知(submit_ts=0)时无从判断先后,次序显示 '-',而不是误报"第 1 次"
const noTxt = (attempts > 1 && a.submit_ts) ? ('第 ' + (a.idx || 1) + ' 次') : '-';
return '<tr>' +
'<td class="num">' + noTxt + '</td>' +
'<td class="num">' + esc(a.submit_at || '-') + '</td>' +
'<td class="num">' + sc + best + dayBest + tmp + '</td>' +
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
'</tr>';
}).join('');
return html;
}).join('');
const bestScore = (r.best_score == null) ? r.score : r.best_score;
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
(bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
$('#attBody').innerHTML =
'<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');
}
/* ---------------- 自动刷新 ---------------- */
// 失败退避:连续失败时延长下次刷新间隔(1x → 2x → 4x,封顶 8x),成功后复位。
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
function schedule() {
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
if (!$('#autoRefresh').checked) return;
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);
}
/* ---------------- 新数据高亮自动清除 ---------------- */
// 绿色高亮用于标记"有更新的成员",停留更易察觉(不再只闪一下),
// 但长时间堆积会失去重点——故每 hlAutoClearSec 秒自动清除一次。
function startHlAutoClear() {
if (state.hlTimer) clearInterval(state.hlTimer);
const sec = state.hlAutoClearSec || 30;
state.hlTimer = setInterval(() => {
if (state.highlight.size === 0) return; // 无高亮时不打扰
state.highlight = new Set();
renderTable();
renderHlBadge();
}, sec * 1000);
}
/* ---------------- 全屏 ---------------- */
function toggleFullscreen() {
const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) {
const p = req.call(el);
// 部分浏览器返回 Promise,被拒绝时静默吞掉会让用户以为"点了没反应"
if (p && typeof p.catch === 'function') {
p.catch(() => { showAlert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换', 'warn'); });
}
}
} else {
// exitFullscreen 挂在 document 上(documentElement.exitFullscreen 恒为 undefined)
const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) {
const p = exit.call(document);
if (p && typeof p.catch === 'function') p.catch(() => {});
}
}
}
function onFsChange() {
const on = !!(document.fullscreenElement || document.webkitFullscreenElement);
document.body.classList.toggle('fs-on', on);
const btn = $('#btnFullscreen');
if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
}
/* ---------------- 布局切换 ---------------- */
function applyLayout(mode) {
const m = (mode === 'side') ? 'side' : 'default';
const lay = document.getElementById('dataLayout');
if (lay) lay.setAttribute('data-layout', m);
LS.set(LS_LAYOUT, m);
}
/* ---------------- 设置 ---------------- */
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;
$('#cfgKeywords').value = c.name_keywords || '姓名';
$('#cfgDedupe').checked = !!c.dedupe;
$('#cfgTemp').checked = !!c.include_temp_save;
$('#cfgSsl').checked = !!c.verify_ssl;
// 布局
const layout = c.layout || LS.get(LS_LAYOUT, 'default');
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
// 全局默认满分 / 合格率(每卷的独立配置移到主页面「试卷设置」)
$('#cfgFull').value = c.full_score || 100;
if ($('#cfgPassRate')) $('#cfgPassRate').value = (c.pass_rate == null ? 60 : c.pass_rate);
// 历史成绩展示方式 + 导出排序
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'));
$('#cfgHistoryDisplay').value = state.historyDisplay;
$('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
// 连接信息(地址 / 前缀 / 账号可编辑;密码不回显)
$('#cfgBase').value = c.base_url || '';
if ($('#cfgPrefix')) $('#cfgPrefix').value = c.api_prefix || '/api';
$('#cfgUser').value = c.username || '';
$('#cfgPass').value = '';
const conn = c.db_configured
? (c.db_connected
? '数据库:已连接 (' + (c.db_driver || '') + ')'
: ('数据库:未连接 · ' + (c.db_error || '未知错误')))
: '数据库:未配置(使用文件存储)';
$('#cfgConnInfo').textContent = '配置来源:' + (c.storage || 'default') + ' · ' + conn;
$('#pingResult').classList.add('hidden');
} catch (e) { showAlert(e.message); }
$('#settingsModal').classList.add('open');
}
/* ---------------- 名单 ---------------- */
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 {
const d = await api('roster');
rosterDraft = d.students || [];
renderRoster();
$('#rosterMeta').textContent = '共 ' + rosterDraft.length + ' 人' +
(d.meta && d.meta.updated_at ? ' · 更新于 ' + d.meta.updated_at : '');
} catch (e) { showAlert(e.message); }
$('#rosterModal').classList.add('open');
}
function renderRoster() {
const kw = $('#rosterSearch').value.trim().toLowerCase();
const rows = rosterDraft.filter(r => !kw ||
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
const tb = $('#rosterBody');
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="5" 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></tr>';
}).join('');
updateDelSelBtn();
}
/* ---------------- 事件绑定 ---------------- */
document.addEventListener('DOMContentLoaded', function () {
$('#btnRefresh').addEventListener('click', () => refresh(false, true));
$('#btnSettings').addEventListener('click', openSettings);
$('#btnRoster').addEventListener('click', openRoster);
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
document.addEventListener('fullscreenchange', onFsChange);
if (document.webkitFullscreenEnabled === undefined && !document.documentElement.requestFullscreen && !document.documentElement.webkitRequestFullscreen) {
$('#btnFullscreen').style.display = 'none';
}
// 搜索框防抖:连续输入时只渲染最后一次,避免每个字符都全量重排
$('#kw').addEventListener('input', () => {
if (state._kwDebounce) clearTimeout(state._kwDebounce);
state._kwDebounce = setTimeout(() => { renderTable(); }, 200);
});
$('#clsSel').addEventListener('change', () => {
populateGroups();
rememberFilters();
renderStats();
renderTable();
renderAbsent();
renderHints();
});
$('#grpSel').addEventListener('change', () => {
rememberFilters();
renderStats();
renderTable();
renderAbsent();
renderHints();
});
$('#nameFieldSel').addEventListener('change', () => {
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
state.highlight = new Set(); // 换了姓名题,旧高亮无意义
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
refresh();
});
$('#projectSel').addEventListener('change', () => {
// 切换项目后清掉前一项目的 prevMap 与高亮缓存
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
state.highlight = new Set(); // 换项目,绿色高亮归零
// 用缓存数据先快速渲染,再后台拉新数据
refresh();
});
$('#intervalSel').addEventListener('change', () => {
LS.set(LS_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
schedule();
});
$('#autoRefresh').addEventListener('change', e => {
if (e.target.checked) { schedule(); refresh(true); }
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 => {
th.addEventListener('click', () => {
if (state.sortKey === th.dataset.k) state.sortAsc = !state.sortAsc;
else { state.sortKey = th.dataset.k; state.sortAsc = true; }
renderTable();
});
});
// 虚拟滚动:滚动成绩表时按需渲染可视窗口(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] 元素生效,避免误关。 */
$('#tbody').addEventListener('click', e => {
const tr = e.target.closest('tr[data-sid]');
if (!tr) return;
// 仅响应"打开成员历史"动作;表头排序 sortable 在 #dataTable 上另行处理
if (tr.parentNode && tr.parentNode.id !== 'tbody') return;
const sid = tr.getAttribute('data-sid');
if (sid) openAttemptsModal(sid);
});
/* 清除高亮 */
$('#btnClearHl').addEventListener('click', () => {
state.highlight = new Set();
renderTable();
renderHlBadge();
showAlert('已清除新数据高亮', 'info');
});
/* 导出排序切换(即时保存到服务端配置) */
$('#exportSortSel').addEventListener('change', async () => {
state.exportSort = $('#exportSortSel').value;
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
try {
await api('config_save', { method: 'POST', body: { export_sort: state.exportSort } });
} catch (e) { /* 静默:本地已生效 */ }
});
// 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么)
$('#btnExport').addEventListener('click', () => {
const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const sort = (state.exportSort === 'sid') ? 'sid' : ((state.exportSort === 'score_desc') ? 'score_desc' : ((state.exportSort === 'score_asc') ? 'score_asc' : 'time'));
// 导出提示文案(与排序方式对应,让用户看清"这次按什么排")
const sortLabel = (sort === 'sid') ? '按学号'
: (sort === 'score_desc') ? '按成绩降序'
: (sort === 'score_asc') ? '按成绩升序'
: '按提交时间';
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 + '&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;
if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value;
location.href = 'api.php?action=export_absent&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp);
showAlert(exportTip('缺考名单', cls, grp), 'info');
});
/* 弹窗关闭:只关最上层那一个(新增学生等弹窗是叠在名单管理之上的,
关全部会连带把底层弹窗一起关掉,不符合预期) */
function closeTopModal() {
const open = $$('.modal.open');
if (!open.length) return false;
open[open.length - 1].classList.remove('open');
return true;
}
document.addEventListener('click', e => {
if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) {
closeTopModal();
}
});
// Esc 关闭最上层弹窗
document.addEventListener('keydown', e => {
if (e.key === 'Escape' || e.key === 'Esc') closeTopModal();
});
/* 设置保存 */
$('#btnSaveCfg').addEventListener('click', async () => {
const btnSave = $('#btnSaveCfg');
btnSave.disabled = true; btnSave.textContent = '保存中…';
const pass = $('#cfgPass').value;
const payload = {
page_size: parseInt($('#cfgPageSize').value, 10) || 200,
refresh_interval: parseInt($('#cfgInterval').value, 10) || 15,
timeout: parseInt($('#cfgTimeout').value, 10) || 20,
name_keywords: $('#cfgKeywords').value.trim(),
dedupe: $('#cfgDedupe').checked,
include_temp_save: $('#cfgTemp').checked,
verify_ssl: $('#cfgSsl').checked,
full_score: parseInt($('#cfgFull').value, 10) || 100,
pass_rate: (function () {
const v = $('#cfgPassRate') ? parseFloat($('#cfgPassRate').value) : NaN;
return (isNaN(v) || v < 1 || v > 100) ? 60 : v;
})(),
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
base_url: $('#cfgBase').value.trim(),
api_prefix: ($('#cfgPrefix') ? $('#cfgPrefix').value.trim() : '/api') || '/api',
username: $('#cfgUser').value.trim(),
history_display: $('#cfgHistoryDisplay').value,
export_sort: $('#cfgExportSort').value,
};
if (pass !== '') payload.password = pass; // 留空 = 不修改
let resp;
try {
resp = await api('config_save', { method: 'POST', body: payload });
} catch (e) { showAlert(e.message); btnSave.disabled = false; btnSave.textContent = '保存'; return; }
$('#cfgPass').value = ''; // 提交后清空密码框
const note = (resp && resp.conn_changed) ? '账号/连接已更新' : '设置已保存';
if (resp && resp.ping) {
if (resp.ping.ok) {
showAlert(note + ',测连接成功(' + resp.ping.projects + ' 个项目 / ' + resp.ping.elapsed + 'ms)', 'info');
} else {
showAlert(note + ',但测连接失败:' + resp.ping.msg, 'error');
}
} else {
showAlert(note, 'info');
}
// 应用布局偏好(写入容器 + localStorage)
applyLayout(payload.layout);
const iv = payload.refresh_interval;
const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10));
let best = opts[0];
opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; });
$('#intervalSel').value = String(best);
schedule();
// 仅连接信息变化时才重新加载项目列表(避免非连接设置保存触发破坏性刷新)
if (resp && resp.conn_changed) {
loadProjects();
}
// 展示方式可能变化 → 立即重渲染表格(不重新拉数据)
state.historyDisplay = payload.history_display;
state.exportSort = payload.export_sort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
renderTable();
// ping 失败时保留弹窗,让用户能立即改回
if (!resp || !resp.ping || resp.ping.ok) {
$('#settingsModal').classList.remove('open');
}
btnSave.disabled = false; btnSave.textContent = '保存';
});
/* 独立测试连接(使用弹窗内尚未保存的账号/密码,不落盘) */
async function testConnection() {
const btn = $('#btnTestConn');
const box = $('#pingResult');
if (!btn || !box) return;
btn.disabled = true; btn.textContent = '测试中…';
box.classList.remove('hidden', 'err'); box.classList.add('loading');
box.textContent = '正在连接卷王系统测试…';
try {
// 全部连接字段都从弹窗当前值取(未保存也能测)
const r = await api('config_test', {
method: 'POST',
body: {
base_url: $('#cfgBase').value.trim(),
api_prefix: ($('#cfgPrefix') ? $('#cfgPrefix').value.trim() : '/api') || '/api',
username: $('#cfgUser').value.trim(),
password: $('#cfgPass').value,
verify_ssl: $('#cfgSsl').checked
}
});
box.classList.remove('loading');
if (r && r.connected) {
box.classList.remove('err');
box.textContent = '✅ 连接成功:检测到 ' + r.projects + ' 个问卷 / 用时 ' + r.elapsed + 'ms';
} else {
box.classList.add('err');
box.textContent = '❌ 连接失败:' + ((r && r.msg) || '未知错误');
}
} catch (e) {
box.classList.remove('loading'); box.classList.add('err');
box.textContent = '❌ 测试请求异常:' + e.message;
} finally {
btn.disabled = false; btn.textContent = '测试连接';
}
}
$('#btnTestConn').addEventListener('click', testConnection);
// 收集"各试卷独立满分" -> {projectId: score}
/* 名单上传 */
const zone = $('#dropZone'), fileInput = $('#rosterFile');
zone.addEventListener('click', () => fileInput.click());
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('over'));
zone.addEventListener('drop', e => {
e.preventDefault(); zone.classList.remove('over');
if (e.dataTransfer.files.length) uploadRoster(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) uploadRoster(fileInput.files[0]);
fileInput.value = '';
});
async function uploadRoster(file) {
const fd = new FormData();
fd.append('file', file);
const mode = (document.querySelector('input[name=impMode]:checked') || {}).value || 'append';
fd.append('mode', mode);
try {
const d = await api('roster_import', { form: fd });
rosterDraft = d.students || [];
renderRoster();
$('#rosterMeta').textContent = '共 ' + rosterDraft.length + ' 人 · 导入完成';
$('#rosterBadge').textContent = rosterDraft.length;
} catch (e) { showAlert(e.message); }
}
$('#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 = '';
$('#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;
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;
const i = parseInt(tr.dataset.i, 10);
if (isNaN(i)) return;
rosterDraft[i][e.target.dataset.f] = e.target.value;
});
// 勾选 / 取消勾选(事件委托;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 () => {
try {
const d = await api('roster_save', { method: 'POST', body: { students: rosterDraft } });
rosterDraft = d.students || [];
renderRoster();
$('#rosterBadge').textContent = rosterDraft.length;
$('#rosterMeta').textContent = '共 ' + rosterDraft.length + ' 人 · 已保存';
if ($('#projectSel').value) refresh(true);
} 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; }
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
/* ============ 打印(统一出口) ============
* 用隐藏 iframe 输出而不是 window.open:不会被浏览器的弹窗拦截挡掉,
* 也不会多开一个标签页。打印内容独立于页面排序,默认按成绩降序。
*/
function printDoc(title, bodyHtml, opts) {
opts = opts || {};
const orient = opts.orient === 'landscape' ? 'landscape' : 'portrait';
const css =
'@page{size:A4 ' + orient + ';margin:14mm 10mm}' +
'html,body{margin:0;padding:0}' +
'body{font-family:"Microsoft YaHei","微软雅黑",sans-serif;color:#000;font-size:12px;line-height:1.5}' +
'h2{margin:0 0 6px;font-size:17px;text-align:center}' +
'.sub{text-align:center;font-size:12px;color:#444;margin-bottom:4px}' +
'.meta{font-size:11px;color:#444;margin:0 0 10px;padding-bottom:6px;border-bottom:1px solid #999}' +
'table{border-collapse:collapse;width:100%;font-size:11px;margin-bottom:8px}' +
'thead{display:table-header-group}' + // 跨页自动重复表头
'tr{page-break-inside:avoid}' +
'th,td{border:1px solid #555;padding:4px 6px;text-align:left;vertical-align:middle}' +
'th{background:#eaeaea;font-weight:700;white-space:nowrap}' +
'td.c,th.c{text-align:center}' +
'td.score{font-weight:700}' +
'td.fail{color:#b00}' +
'h3.grp{font-size:13px;margin:14px 0 5px;padding:5px 8px;background:#f2f2f2;border-left:4px solid #333;page-break-after:avoid}' +
'h3.grp small{font-weight:400;color:#555}' +
'.sign{margin-top:16px;font-size:12px;color:#333;page-break-inside:avoid}' +
'.foot{margin-top:8px;font-size:10px;color:#777;text-align:center}';
const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' +
'<title>' + esc(title) + '</title><style>' + css + '</style></head><body>' + bodyHtml + '</body></html>';
const old = document.getElementById('__nyPrintFrame');
if (old && old.parentNode) old.parentNode.removeChild(old);
const f = document.createElement('iframe');
f.id = '__nyPrintFrame';
f.setAttribute('title', '打印');
f.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden';
document.body.appendChild(f);
const doc = f.contentWindow && f.contentWindow.document;
if (!doc) { // 极端情况兜底
const w = window.open('', '_blank');
if (!w) return showAlert('打印失败,请允许弹窗后重试', 'error');
w.document.write(html); w.document.close();
return;
}
doc.open(); doc.write(html); doc.close();
setTimeout(function () {
try { f.contentWindow.focus(); f.contentWindow.print(); }
catch (e) { window.print(); }
}, 250);
}
function printMetaLine(extra) {
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const scope = [cls ? (cls + '班') : '', grp ? (grp + '组') : ''].filter(Boolean).join(' · ');
return [
scope ? ('范围:' + scope) : '范围:全部',
'打印时间:' + new Date().toLocaleString(),
].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 scope = $('#ptScope').value;
let rows = (scope === 'all')
? expandAttemptsForPrint(state._sorted || [], true)
: (state._sorted || []).slice();
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
const sortMode = $('#ptSort').value;
const groupBy = $('#ptGroup').value; // none | cls | grp
const showRank = $('#ptRank').checked;
const showAtt = $('#ptAttempts').checked;
const showPass = $('#ptPass').checked;
const withAbsent= $('#ptAbsent').checked;
const orient = $('#ptOrient').value;
const full = state.fullScore || 100;
const passRate = (state.passRate == null ? 60 : state.passRate);
const passLine = Math.round((passRate / 100) * full * 10) / 10;
const sidCmp = (a, b) => String(a.sid || '').localeCompare(String(b.sid || ''), 'zh', { numeric: true });
// 排序:打印独立于页面排序;无成绩恒排末尾;同分按学号,便于核对
rows.sort((a, b) => {
const an = (a.score == null), bn = (b.score == null);
if (an && bn) return sidCmp(a, b);
if (an) return 1;
if (bn) return -1;
if (sortMode === 'score_desc') return (b.score - a.score) || sidCmp(a, b);
if (sortMode === 'score_asc') return (a.score - b.score) || sidCmp(a, b);
if (sortMode === 'sid') return sidCmp(a, b);
return ((b.submit_ts || 0) - (a.submit_ts || 0)) || sidCmp(a, b); // time
});
const passOf = r => (r.score != null && r.score >= passLine);
const statOf = list => {
const sc = list.map(r => r.score).filter(v => v != null);
const avg = sc.length ? (sc.reduce((x, y) => x + y, 0) / sc.length) : null;
const pass = list.filter(passOf).length;
return {
n: list.length,
avg: avg == null ? '—' : avg.toFixed(1),
max: sc.length ? Math.max.apply(null, sc) : '—',
min: sc.length ? Math.min.apply(null, sc) : '—',
pass: pass,
passRate: list.length ? Math.round(pass / list.length * 1000) / 10 : 0,
};
};
const head = '<tr>' + (showRank ? '<th class="c" style="width:44px">名次</th>' : '') +
'<th style="width:96px">学号</th><th style="width:76px">姓名</th>' +
(groupBy === 'none' ? '<th style="width:70px">班级</th><th class="c" style="width:48px">组别</th>' : '') +
'<th class="c" style="width:60px">成绩</th>' +
(showPass ? '<th class="c" style="width:56px">是否合格</th>' : '') +
(showAtt ? '<th class="c" style="width:64px">考试次数</th>' : '') +
'<th>提交时间</th></tr>';
// 名次:整份名单连续编号(分组时也连续),无成绩不参与排名
let seq = 0;
const renderRows = list => list.map(r => {
const ranked = (r.score != null);
if (ranked) seq++;
const ok = passOf(r);
return '<tr>' + (showRank ? '<td class="c">' + (ranked ? seq : '—') + '</td>' : '') +
'<td>' + esc(r.sid || '') + '</td>' +
'<td>' + esc(r.name || '') + '</td>' +
(groupBy === 'none' ? '<td>' + esc(r.cls || '') + '</td><td class="c">' + esc(r.grp || '') + '</td>' : '') +
'<td class="c score' + (ranked && !ok ? ' fail' : '') + '">' + (r.score == null ? '—' : r.score) + '</td>' +
(showPass ? '<td class="c' + (ranked && !ok ? ' fail' : '') + '">' + (r.score == null ? '—' : (ok ? '合格' : '不合格')) + '</td>' : '') +
(showAtt ? '<td class="c">' + (r.attempts || 1) + '</td>' : '') +
'<td>' + esc(r.submit_at || '—') + '</td></tr>';
}).join('');
let html = '';
if (groupBy === 'none') {
html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows) + '</tbody></table>';
} else {
const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组'));
const groups = {};
rows.forEach(r => { const k = keyOf(r); (groups[k] = groups[k] || []).push(r); });
Object.keys(groups).sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }))
.forEach(k => {
const list = groups[k];
const st = statOf(list);
html += '<h3 class="grp">' + esc(k) +
' <small>(' + st.n + ' 人 · 平均 ' + st.avg + ' · 最高 ' + st.max + ' · 最低 ' + st.min +
' · 合格 ' + st.pass + ' 人 / ' + st.passRate + '%)</small></h3>';
html += '<table><thead>' + head + '</thead><tbody>' + renderRows(list) + '</tbody></table>';
});
}
const all = statOf(rows);
const proj = state.projectName || '';
const title = ($('#ptTitle').value.trim() || (proj ? proj + ' 成绩明细' : '成绩明细'));
let body = '<h2>' + esc(title) + '</h2>' +
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
'<div class="meta">' + esc(printMetaLine([
(scope === 'all' ? '共 ' + all.n + ' 条记录' : '共 ' + all.n + ' 人'),
'平均 ' + all.avg,
'最高 ' + all.max + ' / 最低 ' + all.min,
'合格 ' + all.pass + ' 人(' + all.passRate + '%)',
'合格线 ' + passLine + ' 分(满分的 ' + passRate + '%)',
])) + '</div>' + html;
if (withAbsent) {
const absent = state._absentList || [];
body += '<h3 class="grp" style="page-break-before:always">缺考名单 <small>(' + absent.length + ' 人)</small></h3>';
if (!absent.length) {
body += '<p>无缺考学生。</p>';
} else {
body += '<table><thead><tr><th style="width:96px">学号</th><th style="width:76px">姓名</th>' +
'<th style="width:70px">班级</th><th class="c" style="width:48px">组别</th><th>备注</th></tr></thead><tbody>' +
absent.map(a => '<tr><td>' + esc(a.sid || '') + '</td><td>' + esc(a.name || '') + '</td><td>' +
esc(a.cls || '') + '</td><td class="c">' + esc(a.grp || '') + '</td><td></td></tr>').join('') +
'</tbody></table>';
}
}
body += '<div class="sign">教师签字:____________  教研组长:____________  日期:____ 年 ____ 月 ____ 日</div>';
printDoc(title, body, { orient: orient });
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
}
$('#btnPrintTable').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
const n = (state._sorted || []).length;
const a = (state._absentList || []).length;
$('#ptHint').textContent = '当前可打印 ' + n + ' 名学生的成绩' + (a ? '(可附带 ' + a + ' 名缺考学生)' : '') + ';弹窗中可切换为「打印全部记录」';
if (!$('#ptTitle').value.trim() && state.projectName) {
$('#ptTitle').value = state.projectName + ' 成绩明细';
}
$('#printTableModal').classList.add('open');
});
$('#btnPtPrint').addEventListener('click', () => {
$('#printTableModal').classList.remove('open');
printTable();
});
/* 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 */
async function openProjCfg() {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
const pname = (($('#projectSel').selectedOptions && $('#projectSel').selectedOptions[0]) || {}).textContent || '';
$('#projCfgSub').textContent = pname ? ('· ' + pname) : '';
try {
const d = await api('project_config', { params: { projectId: pid } });
const cfg = d.cfg || {}, eff = d.effective || {}, g = d.global || {};
$('#pcFull').value = (cfg.full_score == null ? '' : cfg.full_score);
$('#pcPassRate').value = (cfg.pass_rate == null ? '' : cfg.pass_rate);
$('#pcExempt').value = (cfg.exempt || []).join('\n');
$('#pcFullHint').textContent = '当前生效:' + (eff.full_score || '-') +
'(全局默认 ' + (g.full_score || '-') + ')';
$('#pcPassHint').textContent = '当前生效:' + (eff.pass_rate || '-') +
'%(全局默认 ' + (g.pass_rate || '-') + '%)';
} catch (e) { showAlert(e.message); return; }
$('#projCfgModal').classList.add('open');
}
$('#btnProjCfg').addEventListener('click', openProjCfg);
$('#btnPcSave').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
try {
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
full_score: $('#pcFull').value.trim(),
pass_rate: $('#pcPassRate').value.trim(),
exempt: $('#pcExempt').value,
}});
$('#projCfgModal').classList.remove('open');
showAlert('本卷设置已保存(仅对当前试卷生效)', 'info');
refresh(false, true); // 免考会改变名单/缺考口径,需强制重拉
} catch (e) { showAlert(e.message); }
});
$('#btnPcReset').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return;
if (!confirm('确定清除本卷的全部独立设置(恢复跟随全局默认)?')) return;
try {
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
full_score: '', pass_rate: '', exempt: '',
}});
$('#projCfgModal').classList.remove('open');
showAlert('本卷设置已清除', 'info');
refresh(false, true);
} catch (e) { showAlert(e.message); }
});
/* 缺考名单增强:复制学号 / 复制姓名 / 打印 */
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 || []).slice();
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
const sidCmp = (a, b) => String(a.sid || '').localeCompare(String(b.sid || ''), 'zh', { numeric: true });
list.sort((a, b) => {
const ac = String(a.cls || ''), bc = String(b.cls || '');
if (ac !== bc) return ac.localeCompare(bc, 'zh', { numeric: true });
return sidCmp(a, b);
});
const proj = state.projectName || '';
const title = (proj ? proj + ' · ' : '') + '缺考名单(' + list.length + ' 人)';
const rowHtml = r => '<tr><td>' + esc(r.sid || '') + '</td><td>' + esc(r.name || '') + '</td><td>' +
esc(r.cls || '') + '</td><td class="c">' + esc(r.grp || '') + '</td><td></td></tr>';
// 按班级分组,班内按学号;无班级时不分组
const hasCls = list.some(r => String(r.cls || '') !== '');
let body = '';
if (hasCls) {
const groups = {};
list.forEach(r => { const k = String(r.cls || '未分班'); (groups[k] = groups[k] || []).push(r); });
Object.keys(groups).sort((a, b) => a.localeCompare(b, 'zh', { numeric: true })).forEach(k => {
body += '<h3 class="grp">' + esc(k) + ' <small>(' + groups[k].length + ' 人)</small></h3>' +
'<table><thead><tr><th style="width:96px">学号</th><th style="width:76px">姓名</th>' +
'<th style="width:70px">班级</th><th class="c" style="width:48px">组别</th><th>备注</th></tr></thead><tbody>' +
groups[k].map(rowHtml).join('') + '</tbody></table>';
});
} else {
body = '<table><thead><tr><th style="width:96px">学号</th><th style="width:76px">姓名</th>' +
'<th style="width:70px">班级</th><th class="c" style="width:48px">组别</th><th>备注</th></tr></thead><tbody>' +
list.map(rowHtml).join('') + '</tbody></table>';
}
const meta = '<h2>' + esc(title) + '</h2>' +
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
'<div class="meta">' + esc(printMetaLine(['共 ' + list.length + ' 人'])) + '</div>';
const sign = '<div class="sign">教师签字:____________  日期:____ 年 ____ 月 ____ 日</div>' +
'<div class="foot">' + esc(NY.appName || '') + ' · 缺考名单,请核对后签字确认</div>';
printDoc(title, meta + body + sign, { orient: 'portrait' });
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
});
/* 启动 */
// 1) 恢复布局偏好(仅样式,不涉及数据)
applyLayout(LS.get(LS_LAYOUT, 'default'));
// 2) 支持 ?projectId=xxx&nameQid=yyy 直接打开某份问卷
const qs = new URLSearchParams(location.search);
if (qs.get('interval')) $('#intervalSel').value = qs.get('interval');
else {
const iv = LS.get(LS_INTERVAL, 0);
if (iv) {
const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10));
if (opts.indexOf(iv) >= 0) $('#intervalSel').value = String(iv);
}
}
state.deeplinkProject = qs.get('projectId') || '';
state.deeplinkQid = qs.get('nameQid') || '';
// 退出登录:跳转到独立 logout.php(清服务端会话)
$('#btnLogout').addEventListener('click', () => { location.href = 'logout.php'; });
// 3) 支持 index.php#roster 直接打开名单管理(随机抽取页的「名单管理」入口用)
if (location.hash === '#roster') {
openRoster();
if (history.replaceState) history.replaceState(null, '', location.pathname + location.search);
}
// 启动:主界面在 PHP 端已做会话校验(未登录会跳到 login.php),此处直接加载数据
schedule();
startHlAutoClear(); // 新数据高亮每 30s 自动清除一次
loadProjects();
// 4) 读取展示偏好(历史成绩展示方式 / 导出排序),避免必须打开设置弹窗才生效
(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;
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
renderTable();
} catch (e) { /* 用默认值即可 */ }
})();
});
})();