文档部署到 /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;
  看板顶栏窄屏下拉与按钮换行,消除横向滚动
This commit is contained in:
2026-09-11 16:23:08 +08:00
parent f271066266
commit 25a2e8a42c
30 changed files with 700 additions and 883 deletions
+245 -226
View File
@@ -259,7 +259,6 @@
state.absent = d.absent || [];
state.unmatched = d.unmatched || [];
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;
@@ -488,6 +487,7 @@
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);
@@ -521,10 +521,19 @@
return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分';
}
/**
* 成绩着色:按「占本卷满分的百分比」判断,而不是写死 90 / 60 分。
* 否则把满分改成 50 分(或卷子本身是 20 分制)时,整列都会被误判为不及格标红。
*/
function scoreClass(v) {
if (v == null) return '';
if (v >= 90) return 'score-high';
if (v >= 60) return 'score-mid';
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';
}
@@ -662,6 +671,16 @@
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();
}
@@ -698,8 +717,9 @@
* 虚拟滚动:只渲染可视窗口内的行,用顶部/底部 spacer 撑起完整滚动高度。
* 行数很多(尤其「全部展开」历史模式下)时,避免一次性插入成千上万 DOM 节点导致卡顿。
*/
const ROW_H = 41; // 估算单行高度(含 padding/border),用于窗口定位
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) {
@@ -710,24 +730,33 @@
const scrollTop = scroller.scrollTop;
const viewH = scroller.clientHeight;
const total = rows.length;
let start = Math.floor(scrollTop / ROW_H) - VS_OVERSCAN;
let end = Math.ceil((scrollTop + viewH) / ROW_H) + VS_OVERSCAN;
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 * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
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) * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
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() {
@@ -771,6 +800,12 @@
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;
@@ -914,8 +949,9 @@
$('#cfgHistoryDisplay').value = state.historyDisplay;
$('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
// 连接信息(账号回显;密码不回显)
$('#cfgBase').value = (c.base_url || '') + (c.api_prefix || '/api');
// 连接信息(地址 / 前缀 / 账号可编辑;密码不回显)
$('#cfgBase').value = c.base_url || '';
if ($('#cfgPrefix')) $('#cfgPrefix').value = c.api_prefix || '/api';
$('#cfgUser').value = c.username || '';
$('#cfgPass').value = '';
const conn = c.db_configured
@@ -1124,12 +1160,23 @@
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')) {
$$('.modal').forEach(m => m.classList.remove('open'));
closeTopModal();
}
});
// Esc 关闭最上层弹窗
document.addEventListener('keydown', e => {
if (e.key === 'Escape' || e.key === 'Esc') closeTopModal();
});
/* 设置保存 */
$('#btnSaveCfg').addEventListener('click', async () => {
@@ -1150,6 +1197,8 @@
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,
@@ -1203,10 +1252,12 @@
box.classList.remove('hidden', 'err'); box.classList.add('loading');
box.textContent = '正在连接卷王系统测试…';
try {
// 仅传可编辑的连接字段;base_url / api_prefix 在弹窗中只读,沿用已保存值
// 全部连接字段都从弹窗当前值取(未保存也能测)
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
@@ -1384,127 +1435,96 @@
/* 成绩快照 / 趋势 */
function curPid() { return $('#projectSel').value; }
async function loadSnapshots() {
const pid = curPid();
if (!pid) return;
try {
const d = await api('snapshot_list', { params: { projectId: pid } });
const list = d.snapshots || [];
const tb = $('#snapBody');
if (!list.length) {
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无快照,点击「保存当前快照」创建第一份</td></tr>';
return;
}
tb.innerHTML = list.map(s =>
'<tr>' +
'<td>' + esc(s.label) + '</td>' +
'<td class="num">' + esc(s.at_txt) + '</td>' +
'<td class="num">' + s.count + '</td>' +
'<td>' + esc((s.project && s.project.name) || '') + '</td>' +
'<td><span class="link-del" data-snapdel="' + esc(s.file) + '">删除</span></td></tr>').join('');
} catch (e) { showAlert(e.message); }
}
$('#btnSnapshot').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
$('#snapLabel').value = '';
$('#snapTitle').textContent = '成绩快照 · ' + (state.projectName || '');
loadSnapshots();
$('#snapshotModal').classList.add('open');
});
$('#btnSnapSave').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
try {
const d = await api('snapshot_save', { method: 'POST', params: { projectId: pid, nameQid: $('#nameFieldSel').value }, body: { label: $('#snapLabel').value.trim() } });
showAlert('快照已保存:' + d.label + '(' + d.count + ' 人)', 'info');
$('#snapLabel').value = '';
loadSnapshots();
} catch (e) { showAlert(e.message); }
});
$('#btnSnapRefresh').addEventListener('click', loadSnapshots);
$('#snapBody').addEventListener('click', async e => {
const f = e.target.getAttribute && e.target.getAttribute('data-snapdel');
if (f == null) return;
if (!confirm('确定删除该快照?此操作不可撤销。')) return;
try {
await api('snapshot_delete', { method: 'POST', params: { projectId: curPid() }, body: { file: f } });
showAlert('快照已删除', 'info');
loadSnapshots();
} catch (err) { showAlert(err.message); }
});
async function renderTrend() {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
const body = $('#trendBody'), head = $('#trendHead');
body.innerHTML = '<tr><td colspan="10" class="empty">加载中…</td></tr>';
try {
const d = await api('snapshot_trend', { params: { projectId: pid } });
const cols = d.columns || [], rows = d.rows || [];
$('#trendSub').textContent = state.projectName || '';
if (!cols.length) {
head.innerHTML = ''; body.innerHTML = '<tr><td colspan="10" class="empty">还没有快照,请先保存快照</td></tr>';
return;
}
head.innerHTML = '<tr><th>学号</th><th>姓名</th><th>班级</th>' +
cols.map(c => '<th title="' + esc(c.at_txt) + '">' + esc(c.label) + '</th>').join('') + '</tr>';
const colSpan = cols.length + 3;
if (!rows.length) {
body.innerHTML = '<tr><td colspan="' + colSpan + '" class="empty">无数据</td></tr>';
return;
}
body.innerHTML = rows.map(r => {
let cells = '<td class="sid">' + esc(r.sid) + '</td><td>' + esc(r.name) + '</td><td>' + esc(r.cls || '') + '</td>';
cols.forEach(c => {
const v = r.scores[c.key];
cells += '<td class="num">' + (v == null ? '—' : v) + '</td>';
});
return '<tr>' + cells + '</tr>';
}).join('');
} catch (e) { showAlert(e.message); }
}
$('#btnSnapTrend').addEventListener('click', () => {
$('#snapshotModal').classList.remove('open');
renderTrend();
$('#trendModal').classList.add('open');
});
$('#btnTrend').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
renderTrend();
$('#trendModal').classList.add('open');
});
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
/* ============ 打印(统一出口) ============
* 用隐藏 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 full = state.fullScore || 100;
const passRate = (state.passRate == null ? 60 : state.passRate);
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 });
// 排序(打印独立于页面排序,默认成绩降序;null 成绩恒排末尾)
const cmp = (a, b) => {
// 排序:打印独立于页面排序;无成绩恒排末尾;同分按学号,便于核对
rows.sort((a, b) => {
const an = (a.score == null), bn = (b.score == null);
if (an && bn) return 0;
if (an && bn) return sidCmp(a, b);
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);
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 >= (passRate / 100) * full);
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;
@@ -1512,29 +1532,40 @@
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><th>' + (showRank ? '名次' : '') + '</th><th>学号</th><th>姓名</th>' +
(groupBy === 'none' ? '<th>班级</th><th>组别</th>' : '') +
'<th>成绩</th>' + (showAtt ? '<th>考试次数</th>' : '') + '<th>提交时间</th></tr>';
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 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>' : '') +
// 名次:整份名单连续编号(分组时也连续),无成绩不参与排名
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>' + esc(r.grp || '') + '</td>' : '') +
'<td class="c"><b>' + (r.score == null ? '—' : r.score) + '</b></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>';
'<td>' + esc(r.submit_at || '—') + '</td></tr>';
}).join('');
let html = '';
if (groupBy === 'none') {
html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows, 0) + '</tbody></table>';
html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows) + '</tbody></table>';
} else {
const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组'));
const groups = {};
@@ -1544,45 +1575,54 @@
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>';
' <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 title = (state.projectName ? state.projectName + ' · ' : '') + '成绩明细';
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const scope = (cls ? cls + '班 ' : '') + (grp ? grp + '组' : '');
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;
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();
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;
$('#ptHint').textContent = n ? ('当前将打印 ' + n + ' 条记录') : '暂无数据';
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', () => {
@@ -1640,54 +1680,6 @@
} catch (e) { showAlert(e.message); }
});
/* 异常检测线索 */
function renderAnomaly() {
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
const box = $('#anomalyBody');
const sec = [];
const empty = '<span class="muted">无</span>';
// 1) 短时长高得分
sec.push('<h4 style="margin:14px 0 6px">⚡ 用时异常短却得分高(' + a.fast_high.length + ')</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>成绩</th><th>用时</th></tr></thead><tbody>');
if (!a.fast_high.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
else a.fast_high.forEach(x => {
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
'</td><td class="num">' + x.score + '</td><td class="num">' + esc(x.duration_txt || (x.duration + 's')) + '</td></tr>');
});
sec.push('</tbody></table></div>');
// 2) 高频重考
sec.push('<h4 style="margin:14px 0 6px">🔄 高频重考(' + a.frequent.length + ')</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>考试次数</th><th>最高分</th></tr></thead><tbody>');
if (!a.frequent.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
else a.frequent.forEach(x => {
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
'</td><td class="num">' + x.attempts + '</td><td class="num">' + (x.best_score == null ? '-' : x.best_score) + '</td></tr>');
});
sec.push('</tbody></table></div>');
// 3) 雷同成绩
sec.push('<h4 style="margin:14px 0 6px">⚠️ 多人成绩雷同(' + a.identical.length + ' 组)</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>成绩</th><th>人数</th><th>学生</th></tr></thead><tbody>');
if (!a.identical.length) sec.push('<tr><td colspan="3" class="empty">' + empty + '</td></tr>');
else a.identical.forEach(g => {
const names = g.members.map(m => m.name + '(' + m.sid + ')').join('、');
sec.push('<tr><td class="num">' + g.score + '</td><td class="num">' + g.count + '</td><td>' + esc(names) + '</td></tr>');
});
sec.push('</tbody></table></div>');
box.innerHTML = sec.join('');
}
$('#btnAnomaly').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
renderAnomaly();
$('#anomalyModal').classList.add('open');
});
/* 缺考名单增强:复制学号 / 复制姓名 / 打印 */
function copyText(text, what) {
const done = () => showAlert(what + '已复制到剪贴板', 'info');
@@ -1714,20 +1706,47 @@
copyText(list.map(a => a.name).join('、'), '姓名');
});
$('#btnPrintAbsent').addEventListener('click', () => {
const list = state._absentList || [];
const list = (state._absentList || []).slice();
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
const rows = list.map(a =>
'<tr><td>' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
const project = state.projectName || '';
const title = (project ? project + ' · ' : '') + '缺考名单(' + list.length + ' 人)';
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:sans-serif;margin:24px}h2{margin:0 0 8px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #333;padding:6px 10px;text-align:left;font-size:13px}th{background:#eee}</style></head><body>' +
'<h2>' + esc(title) + '</h2><p>打印时间:' + new Date().toLocaleString() + '</p>' +
'<table><thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead><tbody>' + rows + '</tbody></table>' +
'<script>window.onload=function(){setTimeout(function(){window.print()},300)}<\/script></body></html>');
w.document.close();
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');
});
/* 启动 */
+68 -24
View File
@@ -35,6 +35,7 @@
log: [], // 历史批次,新的在前
customRatios: {}, // 自定义比例:{ 组名: 权重 }
drawing: false,
all: false, // 「全部」模式:人数始终跟随当前候选池(手工改输入框即解除)
};
/* ---------------- 接口 ---------------- */
@@ -82,7 +83,7 @@
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
function showAlert(msg, type) {
if (msg === '' || msg == null) return;
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : (type === 'warn' ? 'warn' : ''));
showToast(msg, t);
}
@@ -345,6 +346,11 @@
function renderStats() {
const pool = currentPool();
// 「全部」模式:候选池变化(改筛选 / 改来源 / 改排除)时人数跟着变
if (state.all) {
const ni = $('#numInput');
if (ni) ni.value = String(Math.max(1, pool.length));
}
$('#dPool').textContent = pool.length;
$('#dPicked').textContent = state.picked.length;
$('#dHistory').textContent = state.history.size;
@@ -395,9 +401,11 @@
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
return;
}
let n = parseInt($('#numInput').value, 10);
// 「全部」模式:人数始终等于当前候选池,切换筛选后不会沿用旧数字
let n = state.all ? pool.length : parseInt($('#numInput').value, 10);
if (!n || n < 1) n = 1;
if (n > pool.length) n = pool.length;
let capped = false;
if (n > pool.length) { n = pool.length; capped = true; }
state.drawing = true;
const stage = $('#drawStage');
@@ -437,9 +445,15 @@
picked.forEach(m => state.history.add(String(m.sid)));
stage.className = 'draw-stage done';
stage.textContent = n === 1
? (picked[0].name || picked[0].sid)
: ('🎉 已抽取 ' + n + ' 人');
if (n === 1 && picked.length) {
// 只抽 1 人时把学号 / 班级 / 组别一起显示出来,教师不用再去表里找
const p0 = picked[0];
const meta = [p0.sid, p0.cls, p0.grp ? (p0.grp + '组') : ''].filter(Boolean).join(' · ');
stage.innerHTML = '<div>' + esc(p0.name || p0.sid) +
(meta ? '<div class="draw-stage-sub">' + esc(meta) + '</div>' : '') + '</div>';
} else {
stage.textContent = '🎉 已抽取 ' + n + ' 人';
}
renderResult();
renderStats();
@@ -449,24 +463,35 @@
state.drawing = false;
btn.disabled = false;
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
if (capped) {
showAlert('候选池只有 ' + pool.length + ' 人,已按 ' + n + ' 人抽取(可关闭「排除已抽中」或调整筛选扩大候选)', 'warn');
} else {
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
}
}
function renderResult() {
const tb = $('#drawBody');
if (!state.picked.length) {
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
tb.innerHTML = '<tr><td colspan="7" class="empty">尚未抽取</td></tr>';
return;
}
tb.innerHTML = state.picked.map((m, i) =>
'<tr>' +
'<td class="num">' + (i + 1) + '</td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td class="name">' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
'</tr>').join('');
tb.innerHTML = state.picked.map((m, i) => {
// 登陆账号为空 → 导出会回退到学号,这里显式标红提示教师先去名单里补全
const login = String(m.login || '');
const loginCell = login
? esc(login)
: '<span class="muted" title="名单未填登陆账号,导出将回退用学号">' + esc(m.sid || '') + ' ⚠</span>';
return '<tr>' +
'<td class="num">' + (i + 1) + '</td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td class="name">' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'<td class="sid">' + loginCell + '</td>' +
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
'</tr>';
}).join('');
}
/* ================= 历史记录 ================= */
@@ -627,6 +652,7 @@
cls: $('#clsSel').value,
grp: $('#grpSel').value,
num: $('#numInput').value,
all: state.all,
exclude: $('#excludePicked').checked,
mode: $('#balanceMode').value,
customRatios: cr,
@@ -700,6 +726,8 @@
});
});
$('#numInput').addEventListener('change', saveSettings);
// 手工改人数即解除「全部」模式
$('#numInput').addEventListener('input', () => { state.all = false; });
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
$('#balanceMode').addEventListener('change', () => {
renderRatioBox();
@@ -718,9 +746,10 @@
b.addEventListener('click', () => {
const v = b.dataset.num;
if (v === 'all') {
const pool = currentPool();
$('#numInput').value = String(Math.max(1, pool.length));
state.all = true;
$('#numInput').value = String(Math.max(1, currentPool().length));
} else {
state.all = false;
$('#numInput').value = v;
}
saveSettings();
@@ -865,11 +894,23 @@
}
});
/* --- 弹窗通用关闭 --- */
/* --- 弹窗通用关闭:只关最上层那一个,避免连带关掉下层的弹窗 --- */
document.addEventListener('click', e => {
if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) {
$$('.modal').forEach(m => m.classList.remove('open'));
}
const isClose = (e.target.matches && e.target.matches('[data-close]')) ||
(e.target.classList && e.target.classList.contains('modal'));
if (!isClose) return;
const open = $$('.modal.open');
if (!open.length) return;
const top = open[open.length - 1];
// 点遮罩时只响应最上层弹窗自身的遮罩
if (e.target.classList && e.target.classList.contains('modal') && e.target !== top) return;
top.classList.remove('open');
});
/* Esc 关闭最上层弹窗 */
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
const open = $$('.modal.open');
if (open.length) open[open.length - 1].classList.remove('open');
});
}
@@ -885,7 +926,9 @@
const mode = $('#balanceMode').value;
const show = (mode === 'custom') && g.key;
box.classList.toggle('hidden', !show);
if (!show) return;
// 隐藏时必须清空:否则旧的 data-gk 输入框还会被 saveSettings 读走,
// 下次切成自定义比例时会误用上一批分组的权重
if (!show) { box.innerHTML = ''; return; }
box.innerHTML =
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
g.gk.map((k, i) => {
@@ -912,6 +955,7 @@
const mode = s.mode || (s.balance === false ? 'off' : 'weight');
$('#balanceMode').value = mode;
if (s.num) $('#numInput').value = s.num;
state.all = !!s.all;
if (s.source === 'project') {
state.source = 'project';
$('#sourceSel').value = 'project';
+44 -1
View File
@@ -162,6 +162,24 @@ select, input[type=text], input[type=number], input[type=password] {
select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); }
.field.btns select { width: 100px; }
/* ---------- 副工具栏:导出等次要操作独立成行,避免挤在主工具栏里 ---------- */
.subbar {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
margin-top: 10px; padding: 10px 14px;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
}
.subbar-label {
font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: .5px;
padding-right: 12px; border-right: 1px solid var(--border);
}
.subbar-item {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--text-2); cursor: pointer; white-space: nowrap;
}
.subbar-item input[type=checkbox] { width: auto; height: auto; margin: 0; cursor: pointer; }
.subbar-item select { width: auto; min-width: 112px; height: 30px; padding: 0 8px; font-size: 12px; }
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch span {
@@ -304,6 +322,14 @@ table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
/* 数值列右对齐:成绩 / 次数 / 时长纵向扫读时更容易对齐比对 */
table.grid td.num, table.grid td.score { text-align: right; }
table.grid thead th[data-k="score"],
table.grid thead th[data-k="attempts"],
table.grid thead th[data-k="submit_ts"],
table.grid thead th[data-k="duration"] { text-align: right; }
table.grid td.score { font-weight: 700; font-size: 14px; }
table.grid td.name { font-weight: 600; }
td.name { font-weight: 600; }
td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.score-high { color: var(--ok); }
@@ -565,8 +591,20 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.dash-hero .brand-title { color: #fff; font-size: 17px; }
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
.dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.dash-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.dash-controls select { width: 230px; height: 38px; }
/* 看板顶栏的「主系统 / 退出」用描边区分于同排的半透明操作按钮 */
.dash-controls .btn.back {
background: transparent; border-color: rgba(255, 255, 255, .45); font-weight: 600;
}
.dash-controls .btn.back:hover { background: rgba(255, 255, 255, .18); border-color: #fff; }
/* 窄屏:下拉与按钮换行铺开,避免 230px 固定宽把顶栏撑出横向滚动条 */
@media (max-width: 860px) {
.dash-hero { padding: 14px 16px; }
.dash-controls { width: 100%; gap: 8px; }
.dash-controls select { width: auto; flex: 1 1 160px; min-width: 0; }
.dash-controls .btn { flex: 0 0 auto; }
}
.dash-controls .inline { color: #fff; }
.dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; }
.dash-controls .btn:hover { background: rgba(255, 255, 255, .24); }
@@ -689,6 +727,11 @@ body.dash.fs-on .member-list { max-height: 30vh; }
background: linear-gradient(180deg, #f2fbf5, #e9f7ee);
font-size: 26px;
}
/* 只抽 1 人时的副行(学号 · 班级 · 组别),字号小于主名字 */
.draw-stage-sub {
margin-top: 8px; font-size: 14px; font-weight: 600;
color: var(--text-2); letter-spacing: 0;
}
@keyframes drawShake {
from { transform: translateY(-1px) scale(.995); }
to { transform: translateY(1px) scale(1.005); }