diff --git a/api.php b/api.php
index 847fd57..9690e66 100644
--- a/api.php
+++ b/api.php
@@ -128,7 +128,7 @@ if ($action !== 'auth_check' && !ny_is_auth()) {
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
$WRITE_ACTIONS = [
'config_save', 'roster_save', 'roster_import', 'roster_clear',
- 'snapshot_save', 'snapshot_delete', 'unmatched_map', 'project_config_save',
+ 'unmatched_map', 'project_config_save',
];
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
@@ -578,64 +578,6 @@ try {
Roster::save($roster);
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
- // ---------------- 成绩快照 / 趋势 ----------------
- case 'snapshot_save':
- $projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
- if ($projectId === '') {
- ny_fail('请先选择问卷/考试');
- }
- $raw = (string)file_get_contents('php://input');
- $in = json_decode($raw, true);
- if (!is_array($in)) {
- $in = $_POST;
- }
- $label = trim((string)($in['label'] ?? ''));
- // 复用缓存构建(不强制,快照本就是"当前看到的成绩")
- $c = cfg();
- $nameQid = (string)($_GET['nameQid'] ?? '');
- $built = ny_build_data($c, $projectId, $nameQid);
- $snap = Snapshot::summarize($built['result'], $label);
- $file = Snapshot::save($projectId, $snap);
- ny_ok(['file' => $file, 'label' => $snap['label'], 'at_txt' => $snap['at_txt'], 'count' => count($snap['students'])]);
-
- case 'snapshot_list':
- $projectId = (string)($_GET['projectId'] ?? '');
- if ($projectId === '') {
- ny_fail('请先选择问卷/考试');
- }
- ny_ok(['snapshots' => Snapshot::list($projectId)]);
-
- case 'snapshot_delete':
- $projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
- $raw = (string)file_get_contents('php://input');
- $in = json_decode($raw, true);
- if (!is_array($in)) {
- $in = $_POST;
- }
- $file = (string)($in['file'] ?? '');
- if ($projectId === '' || $file === '') {
- ny_fail('参数错误');
- }
- ny_ok(['deleted' => Snapshot::delete($projectId, $file)]);
-
- case 'snapshot_trend':
- $projectId = (string)($_GET['projectId'] ?? '');
- if ($projectId === '') {
- ny_fail('请先选择问卷/考试');
- }
- // 支持跨项目对比:projectId 可用逗号分隔多个项目,取各自快照混合趋势
- $pids = array_values(array_filter(array_map('trim', explode(',', $projectId))));
- $snaps = [];
- foreach ($pids as $pid) {
- foreach (Snapshot::list($pid) as $s) {
- $full = Snapshot::get($pid, $s['file']);
- if ($full !== null) {
- $snaps[] = $full;
- }
- }
- }
- ny_ok(Snapshot::trend($snaps));
-
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
case 'project_config':
$projectId = (string)($_GET['projectId'] ?? '');
diff --git a/assets/app.js b/assets/app.js
index 8887525..c829a82 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -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 ? ' ' + (state.sortAsc ? '↑' : '↓') + '' : '');
});
+ // 搜索时提示要跟上,否则会出现"表格只有 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('
|
');
+ frag.push(' |
');
}
for (let i = start; i < end; i++) {
frag.push(rowHtml(rows[i]));
}
if (end < total) {
- frag.push(' |
');
+ frag.push(' |
');
}
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 = '| 无 |
';
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 = '| 暂无快照,点击「保存当前快照」创建第一份 |
';
- return;
- }
- tb.innerHTML = list.map(s =>
- '' +
- '| ' + esc(s.label) + ' | ' +
- '' + esc(s.at_txt) + ' | ' +
- '' + s.count + ' | ' +
- '' + esc((s.project && s.project.name) || '') + ' | ' +
- '删除 |
').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 = '| 加载中… |
';
- 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 = '| 还没有快照,请先保存快照 |
';
- return;
- }
- head.innerHTML = '| 学号 | 姓名 | 班级 | ' +
- cols.map(c => '' + esc(c.label) + ' | ').join('') + '
';
- const colSpan = cols.length + 3;
- if (!rows.length) {
- body.innerHTML = '| 无数据 |
';
- return;
- }
- body.innerHTML = rows.map(r => {
- let cells = '' + esc(r.sid) + ' | ' + esc(r.name) + ' | ' + esc(r.cls || '') + ' | ';
- cols.forEach(c => {
- const v = r.scores[c.key];
- cells += '' + (v == null ? '—' : v) + ' | ';
- });
- return '' + cells + '
';
- }).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 = '' +
+ '' + esc(title) + '' + bodyHtml + '';
+
+ 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 = '| ' + (showRank ? '名次' : '') + ' | 学号 | 姓名 | ' +
- (groupBy === 'none' ? '班级 | 组别 | ' : '') +
- '成绩 | ' + (showAtt ? '考试次数 | ' : '') + '提交时间 |
';
+ const head = '' + (showRank ? '| 名次 | ' : '') +
+ '学号 | 姓名 | ' +
+ (groupBy === 'none' ? '班级 | 组别 | ' : '') +
+ '成绩 | ' +
+ (showPass ? '是否合格 | ' : '') +
+ (showAtt ? '考试次数 | ' : '') +
+ '提交时间 |
';
- let html = '';
- let rank = 0;
- const renderRows = (list, offsetRank) => list.map((r, i) => {
- const rk = (r.score == null) ? '' : (offsetRank + i + 1);
- return '' + (showRank ? '| ' + rk + ' | ' : '') +
+ // 名次:整份名单连续编号(分组时也连续),无成绩不参与排名
+ let seq = 0;
+ const renderRows = list => list.map(r => {
+ const ranked = (r.score != null);
+ if (ranked) seq++;
+ const ok = passOf(r);
+ return '
' + (showRank ? '| ' + (ranked ? seq : '—') + ' | ' : '') +
'' + esc(r.sid || '') + ' | ' +
'' + esc(r.name || '') + ' | ' +
- (groupBy === 'none' ? '' + esc(r.cls || '') + ' | ' + esc(r.grp || '') + ' | ' : '') +
- '' + (r.score == null ? '—' : r.score) + ' | ' +
+ (groupBy === 'none' ? '' + esc(r.cls || '') + ' | ' + esc(r.grp || '') + ' | ' : '') +
+ '' + (r.score == null ? '—' : r.score) + ' | ' +
+ (showPass ? '' + (r.score == null ? '—' : (ok ? '合格' : '不合格')) + ' | ' : '') +
(showAtt ? '' + (r.attempts || 1) + ' | ' : '') +
- '' + esc(r.submit_at || '') + ' |
';
+ '' + esc(r.submit_at || '—') + ' | ';
}).join('');
+ let html = '';
if (groupBy === 'none') {
- html = '' + head + '' + renderRows(rows, 0) + '
';
+ html = '' + head + '' + renderRows(rows) + '
';
} 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 += '' + esc(k) +
- ' (' + st.n + ' 人 · 平均 ' + st.avg + ' · 合格率 ' + st.passRate + '%)
';
- html += '' + head + '' + renderRows(list, 0) + '
';
+ ' (' + st.n + ' 人 · 平均 ' + st.avg + ' · 最高 ' + st.max + ' · 最低 ' + st.min +
+ ' · 合格 ' + st.pass + ' 人 / ' + st.passRate + '%)';
+ html += '' + head + '' + renderRows(list) + '
';
});
}
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 = '' + esc(title) + '
' +
+ (proj ? '' + esc(proj) + '
' : '') +
+ '' + esc(printMetaLine([
+ '共 ' + all.n + ' 人',
+ '平均 ' + all.avg,
+ '最高 ' + all.max + ' / 最低 ' + all.min,
+ '合格 ' + all.pass + ' 人(' + all.passRate + '%)',
+ '合格线 ' + passLine + ' 分(满分的 ' + passRate + '%)',
+ ])) + '
' + html;
- const w = window.open('', '_blank');
- if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
- w.document.write('' + esc(title) + '' +
- '' +
- '' + esc(title) + '
' +
- '' + esc(scope) + ' 共 ' + all.n + ' 人 · 平均 ' + all.avg +
- ' · 合格率 ' + all.passRate + '%(合格线:满分的 ' + passRate + '%,即 ' +
- (Math.round(passRate / 100 * full * 10) / 10) + ' 分) · 打印时间:' + new Date().toLocaleString() + '
' +
- html +
- 'window.onload=function(){setTimeout(function(){window.print()},300)}' +
- '');
- w.document.close();
+ if (withAbsent) {
+ const absent = state._absentList || [];
+ body += '缺考名单 (' + absent.length + ' 人)
';
+ if (!absent.length) {
+ body += '无缺考学生。
';
+ } else {
+ body += '| 学号 | 姓名 | ' +
+ '班级 | 组别 | 备注 |
' +
+ absent.map(a => '| ' + esc(a.sid || '') + ' | ' + esc(a.name || '') + ' | ' +
+ esc(a.cls || '') + ' | ' + esc(a.grp || '') + ' | |
').join('') +
+ '
';
+ }
+ }
+
+ body += '教师签字:____________ 教研组长:____________ 日期:____ 年 ____ 月 ____ 日
';
+ body += '';
+
+ 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 = '无';
-
- // 1) 短时长高得分
- sec.push('⚡ 用时异常短却得分高(' + a.fast_high.length + ')
');
- sec.push('');
-
- // 2) 高频重考
- sec.push('🔄 高频重考(' + a.frequent.length + ')
');
- sec.push('');
-
- // 3) 雷同成绩
- sec.push('⚠️ 多人成绩雷同(' + a.identical.length + ' 组)
');
- sec.push('');
-
- 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 =>
- '| ' + esc(a.sid) + ' | ' + esc(a.name) + ' | ' + esc(a.cls || '') + ' | ' + esc(a.grp || '') + ' |
').join('');
- const project = state.projectName || '';
- const title = (project ? project + ' · ' : '') + '缺考名单(' + list.length + ' 人)';
- const w = window.open('', '_blank');
- if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
- w.document.write('' + esc(title) + '' +
- '' +
- '' + esc(title) + '
打印时间:' + new Date().toLocaleString() + '
' +
- '' +
- '