feat: 成绩明细打印 + SQLite + 每卷独立配置(满分/合格率/免考) + 导航精简

- 成绩明细打印:默认成绩降序,可选按班级/组别分组对比(段首小计)
- 修复看板退出全屏无反应(exitFullscreen 挂在 document 而非 documentElement)
- 导航栏精简:三页统一为单一主标题,移除 Logo 与副标题
- 优化三端布局:重排断点、竖排隐藏 spacer、触摸设备热区
- 全面启用 SQLite(data/ny.db):配置/名单/每卷配置入库,无 pdo_sqlite 自动降级文件
- 新增独立合格率配置(全局 + 每卷),合格率不再写死 60%
- 新增免考配置,仅对当前试卷生效,试卷间隔离
- 试卷独立满分从设置移出,改到主页面「试卷设置」
- 测试:新增 4 条每卷配置隔离断言,全套 113 项通过
This commit is contained in:
2026-09-10 20:22:17 +08:00
parent 0fd880624f
commit 157f257728
11 changed files with 1049 additions and 210 deletions
+185 -48
View File
@@ -261,6 +261,7 @@
state.roster = d.roster || [];
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
@@ -456,7 +457,8 @@
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
const full = state.fullScore || 100;
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= 0.6 * full).length;
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;
@@ -490,6 +492,9 @@
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));
}
@@ -854,10 +859,20 @@
const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) req.call(el);
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) exit.call(document);
if (exit) {
const p = exit.call(document);
if (p && typeof p.catch === 'function') p.catch(() => {});
}
}
}
function onFsChange() {
@@ -890,9 +905,9 @@
// 布局
const layout = c.layout || LS.get(LS_LAYOUT, 'default');
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
// 满分:默认 + 各试卷独立
// 全局默认满分 / 合格率(每卷的独立配置移到主页面「试卷设置」)
$('#cfgFull').value = c.full_score || 100;
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
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'));
@@ -914,39 +929,6 @@
$('#settingsModal').classList.add('open');
}
// 渲染"各试卷独立满分"列表
// 注意:#fullScoresHint 必须位于 #fullScoresBox 之外(见 index.php),
// 否则 box.innerHTML 覆盖会销毁它,下面的 textContent 赋值会抛 "Cannot set properties of null"。
// 这里仍保留 null 守卫,避免任何 DOM 结构变动导致整个设置弹窗打不开。
function setFullScoresHint(text) {
const hint = $('#fullScoresHint');
if (hint) hint.textContent = text;
}
function renderFullScores(scores) {
const box = $('#fullScoresBox');
if (!box) return;
if (!state.projects.length) {
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
return;
}
// 排除文件夹项:模式为文件夹的无可答卷
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
if (!surveys.length) {
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
return;
}
box.innerHTML = surveys.map(p => {
const val = scores[p.id] != null ? scores[p.id] : '';
return '<div class="row">' +
'<label title="' + esc(p.name) + '">' + esc(p.name) + '</label>' +
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
'</div>';
}).join('');
setFullScoresHint('默认分值为空时使用上方"默认满分";填写即单独覆盖该卷');
}
/* ---------------- 名单 ---------------- */
let rosterDraft = [];
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
@@ -1163,8 +1145,11 @@
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',
full_scores: collectFullScores(),
username: $('#cfgUser').value.trim(),
history_display: $('#cfgHistoryDisplay').value,
export_sort: $('#cfgExportSort').value,
@@ -1245,15 +1230,6 @@
$('#btnTestConn').addEventListener('click', testConnection);
// 收集"各试卷独立满分" -> {projectId: score}
function collectFullScores() {
const out = {};
$$('#fullScoresBox input[data-pid]').forEach(inp => {
const v = parseInt(inp.value, 10);
if (inp.dataset.pid && !isNaN(v) && v > 0) out[inp.dataset.pid] = v;
});
return out;
}
/* 名单上传 */
const zone = $('#dropZone'), fileInput = $('#rosterFile');
zone.addEventListener('click', () => fileInput.click());
@@ -1503,6 +1479,167 @@
$('#trendModal').classList.add('open');
});
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
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 full = state.fullScore || 100;
const passRate = (state.passRate == null ? 60 : state.passRate);
// 排序(打印独立于页面排序,默认成绩降序;null 成绩恒排末尾)
const cmp = (a, b) => {
const an = (a.score == null), bn = (b.score == null);
if (an && bn) return 0;
if (an) return 1;
if (bn) return -1;
if (sortMode === 'score_desc') return (b.score - a.score) || ((b.submit_ts || 0) - (a.submit_ts || 0));
if (sortMode === 'score_asc') return (a.score - b.score) || ((b.submit_ts || 0) - (a.submit_ts || 0));
if (sortMode === 'sid') return String(a.sid).localeCompare(String(b.sid), 'zh', { numeric: true });
return ((b.submit_ts || 0) - (a.submit_ts || 0)); // time
};
rows.sort(cmp);
const passOf = r => (r.score != null && r.score >= (passRate / 100) * full);
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),
passRate: list.length ? Math.round(pass / list.length * 1000) / 10 : 0,
};
};
const head = '<tr><th>' + (showRank ? '名次' : '') + '</th><th>学号</th><th>姓名</th>' +
(groupBy === 'none' ? '<th>班级</th><th>组别</th>' : '') +
'<th>成绩</th>' + (showAtt ? '<th>考试次数</th>' : '') + '<th>提交时间</th></tr>';
let html = '';
let rank = 0;
const renderRows = (list, offsetRank) => list.map((r, i) => {
const rk = (r.score == null) ? '' : (offsetRank + i + 1);
return '<tr>' + (showRank ? '<td class="c">' + rk + '</td>' : '') +
'<td>' + esc(r.sid || '') + '</td>' +
'<td>' + esc(r.name || '') + '</td>' +
(groupBy === 'none' ? '<td>' + esc(r.cls || '') + '</td><td>' + esc(r.grp || '') + '</td>' : '') +
'<td class="c"><b>' + (r.score == null ? '—' : r.score) + '</b></td>' +
(showAtt ? '<td class="c">' + (r.attempts || 1) + '</td>' : '') +
'<td>' + esc(r.submit_at || '') + '</td></tr>';
}).join('');
if (groupBy === 'none') {
html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows, 0) + '</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.passRate + '%)</small></h3>';
html += '<table><thead>' + head + '</thead><tbody>' + renderRows(list, 0) + '</tbody></table>';
});
}
const all = statOf(rows);
const title = (state.projectName ? state.projectName + ' · ' : '') + '成绩明细';
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const scope = (cls ? cls + '班 ' : '') + (grp ? grp + '组' : '');
const w = window.open('', '_blank');
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
'<style>' +
'body{font-family:"Microsoft YaHei",sans-serif;margin:20px;color:#000}' +
'h2{margin:0 0 4px;font-size:18px}' +
'.meta{font-size:12px;color:#555;margin-bottom:14px}' +
'h3.grp{font-size:14px;margin:18px 0 6px;padding:6px 8px;background:#f0f0f0;border-left:4px solid #333}' +
'h3.grp small{font-weight:400;color:#555}' +
'table{border-collapse:collapse;width:100%;margin-bottom:10px;font-size:12px}' +
'th,td{border:1px solid #666;padding:5px 8px;text-align:left}' +
'th{background:#e8e8e8;font-weight:700}' +
'td.c{text-align:center}' +
'@media print{body{margin:0}h3.grp{page-break-after:avoid}table{page-break-inside:auto}tr{page-break-inside:avoid}}' +
'</style></head><body>' +
'<h2>' + esc(title) + '</h2>' +
'<div class="meta">' + esc(scope) + ' 共 ' + all.n + ' 人 · 平均 ' + all.avg +
' · 合格率 ' + all.passRate + '%(合格线:满分的 ' + passRate + '%,即 ' +
(Math.round(passRate / 100 * full * 10) / 10) + ' 分) · 打印时间:' + new Date().toLocaleString() + '</div>' +
html +
'<scr' + 'ipt>window.onload=function(){setTimeout(function(){window.print()},300)}</scr' + 'ipt>' +
'</body></html>');
w.document.close();
}
$('#btnPrintTable').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
const n = (state._sorted || []).length;
$('#ptHint').textContent = n ? ('当前将打印 ' + n + ' 条记录') : '暂无数据';
$('#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 renderAnomaly() {
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };