Files
ny-exam-platform/assets/app.js
T
nxxy335 25a2e8a42c 文档部署到 /ny-exam-platform 子路径 + 移除快照/趋势/异常 + 打印与 UI 全面完善
文档站(#88)
- base 默认改为 /ny-exam-platform/,可用 DOCS_BASE=/ 覆盖本地预览
- 新增 DOCS_OUT 环境变量,避免构建时清空输出目录失败导致产物半截
- 新增 tools/docs-preview.js:按子路径重写路由的本地预览服务
- 删除 docs/guide/snapshot.md,同步清理全部相关文档

移除存快照 / 趋势 / 异常(#89)
- 后端:删除 api.php 的 snapshot_save/list/delete/trend 四个 action 与
  $WRITE_ACTIONS 中的同名条目;删除 lib/Snapshot.php 与自动加载注册;
  DataService 移除 detectAnomalies() 及其调用
- 前端:移除三个按钮与三个弹窗、相关 JS 处理与 state 字段
- 测试:移除对应用例,补充 5 条名单导入列识别断言(17+9+18+32+29+11 全过)

打印完善(#90)
- 改用隐藏 iframe 输出,规避弹窗拦截;@page A4 支持纵向/横向
- 表头每页重复、行内不跨页断开、全表连续名次、可选「是否合格」列
- 分组小计(人数/平均/最高/最低/合格人数与合格率)、可附缺考名单另起一页
- 末尾签字栏;缺考名单打印改为同一套输出

UI / 交互 / Bug 修复(#91)
- dashboard.php:补「← 主系统 / 退出」入口(此前全屏后无法离开);
  修复成员下钻 onclick 里 JSON.stringify 双引号提前闭合属性导致点击报错,
  改为 data-sid 事件委托;补 Esc 关弹窗;后台标签页暂停轮询、切回自动补拉;
  刷新中断原因写入 title;数值字段统一兜底,避免 NaN / undefined%
- draw.php / draw.js:结果表新增「登陆账号」列(导出前可核对,缺失显示 学号 ⚠);
  弹窗只关最上层且支持 Esc;「全部」改为跟随候选池,手工改人数即解除;
  候选池不足时给出明确提示;自定义比例框隐藏时清空,避免旧权重被误读
- app.js / dashboard.php:成绩着色改为按「占本卷满分的百分比」判断,
  修复满分不是 100 时整列误标红
- style.css:数值列右对齐;新增 subbar、draw-stage-sub;
  看板顶栏窄屏下拉与按钮换行,消除横向滚动
2026-09-11 16:23:08 +08:00

1798 lines
81 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('|');
}
function applyData(d, opts) {
opts = opts || {};
state.questions = d.questions || [];
state.rows = d.rows || [];
state.absent = d.absent || [];
state.unmatched = d.unmatched || [];
state.roster = d.roster || [];
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;
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
const fromCache = !!opts.fromCache;
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;
}
renderTable();
populateClasses();
populateGroups();
renderAbsent();
renderUnmatched();
renderHints();
$('#lastUpdate').textContent = d.updated_at || '—';
state.projectName = (d.project && d.project.name) || '';
setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
if (!opts.fromCache) showAlert('');
}
async function refresh(silent, 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);
}
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>';
} else {
renderVirtualRows(tb, rows);
}
$$('#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' : '', '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/字体/缩放变化都能自动适应
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);
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('');
// 渲染后回写真实行高:换字体、改样式、页面缩放时窗口定位都不会漂移
const tr0 = tb.querySelector('tr[data-sid]');
if (tr0) {
const h = tr0.offsetHeight;
if (h > 12 && h < 300) measuredRowH = h;
}
}
window.addEventListener('resize', () => { measuredRowH = 0; });
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 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) {
if (!clsFilter && list.length) {
const byCls = {};
list.forEach(a => { const c = (a.cls || '未分班'); byCls[c] = (byCls[c] || 0) + 1; });
const parts = Object.keys(byCls).sort().map(c => c + ' ' + byCls[c] + ' 人');
hint.textContent = '共 ' + list.length + ' 人 · ' + parts.join(' ');
} else {
hint.textContent = list.length ? '共 ' + list.length + ' 人' : '';
}
}
}
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><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,
}];
}
// 新 -> 旧 展示(缺 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);
});
const rows = desc.map((a, i) => {
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 tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
const no = attempts > 1 ? (a.idx || (desc.length - i)) : 1;
return '<tr>' +
'<td class="num">' + (attempts > 1 ? '第 ' + no + ' 次' : '-') + '</td>' +
'<td class="num">' + esc(a.submit_at || '-') + '</td>' +
'<td class="num">' + sc + best + tmp + '</td>' +
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
'</tr>';
}).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"><thead><tr><th style="width:80px">次序</th><th>提交时间</th><th>成绩</th><th>考试时长</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,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
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="6" class="empty">暂无名单,请上传文件</td></tr>';
$('#btnDelSel').hidden = true;
return;
}
tb.innerHTML = rows.map((r, i) => {
const gi = rosterDraft.indexOf(r);
const checked = rosterSel.has(gi) ? ' checked' : '';
return '<tr data-i="' + gi + '">' +
'<td><input type="checkbox" class="roster-cb" data-i="' + gi + '"' + checked + '></td>' +
'<td><input value="' + esc(r.sid) + '" data-f="sid"></td>' +
'<td><input value="' + esc(r.name) + '" data-f="name"></td>' +
'<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' +
'<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td>' +
'<td><input value="' + esc(r.login || '') + '" data-f="login" placeholder="可选"></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 || 'replace';
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);
// 新增学生:弹出表单,填写后追加(不再直接在表格底部插入空行)
$('#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(' | ');
}
function printTable() {
const rows = (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([
'共 ' + 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>';
body += '<div class="foot">' + esc(NY.appName || '') + ' · 本文档由系统生成,仅供教学参考</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) { /* 用默认值即可 */ }
})();
});
})();