From 25a2e8a42c0f77248cdb8bea95fadf0e79475b2a Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Fri, 11 Sep 2026 16:23:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3=E9=83=A8=E7=BD=B2=E5=88=B0?= =?UTF-8?q?=20/ny-exam-platform=20=E5=AD=90=E8=B7=AF=E5=BE=84=20+=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=BF=AB=E7=85=A7/=E8=B6=8B=E5=8A=BF/?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=20+=20=E6=89=93=E5=8D=B0=E4=B8=8E=20UI=20?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文档站(#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; 看板顶栏窄屏下拉与按钮换行,消除横向滚动 --- api.php | 60 +---- assets/app.js | 471 +++++++++++++++++++----------------- assets/draw.js | 92 +++++-- assets/style.css | 45 +++- bootstrap.php | 3 +- dashboard.php | 85 +++++-- docs/.vitepress/config.mjs | 7 +- docs/dev/api.md | 14 +- docs/dev/architecture.md | 8 +- docs/dev/database.md | 1 - docs/dev/testing.md | 2 +- docs/guide/dashboard.md | 11 +- docs/guide/draw.md | 14 +- docs/guide/export-print.md | 30 ++- docs/guide/faq.md | 2 +- docs/guide/install.md | 3 +- docs/guide/introduction.md | 8 +- docs/guide/scores.md | 16 +- docs/guide/settings.md | 4 +- docs/guide/snapshot.md | 72 ------ docs/index.md | 5 +- docs/security/hardening.md | 3 +- docs/security/index.md | 6 +- draw.php | 13 +- index.php | 142 ++++------- lib/DataService.php | 87 ------- lib/Snapshot.php | 164 ------------- tests/run-all.sh | 13 +- tests/test-new-features.php | 96 ++------ tools/docs-preview.js | 106 ++++++++ 30 files changed, 700 insertions(+), 883 deletions(-) delete mode 100644 docs/guide/snapshot.md delete mode 100644 lib/Snapshot.php create mode 100644 tools/docs-preview.js 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 => '').join('') + + '
学号姓名班级组别备注
' + esc(a.sid || '') + '' + esc(a.name || '') + '' + + esc(a.cls || '') + '' + esc(a.grp || '') + '
'; + } + } + + body += '
教师签字:____________  教研组长:____________  日期:____ 年 ____ 月 ____ 日
'; + body += '
' + esc(NY.appName || '') + ' · 本文档由系统生成,仅供教学参考
'; + + 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('
' + - ''); - if (!a.fast_high.length) sec.push(''); - else a.fast_high.forEach(x => { - sec.push(''); - }); - sec.push('
学号姓名班级成绩用时
' + empty + '
' + esc(x.sid) + '' + esc(x.name) + '' + esc(x.cls || '') + - '' + x.score + '' + esc(x.duration_txt || (x.duration + 's')) + '
'); - - // 2) 高频重考 - sec.push('

🔄 高频重考(' + a.frequent.length + ')

'); - sec.push('
' + - ''); - if (!a.frequent.length) sec.push(''); - else a.frequent.forEach(x => { - sec.push(''); - }); - sec.push('
学号姓名班级考试次数最高分
' + empty + '
' + esc(x.sid) + '' + esc(x.name) + '' + esc(x.cls || '') + - '' + x.attempts + '' + (x.best_score == null ? '-' : x.best_score) + '
'); - - // 3) 雷同成绩 - sec.push('

⚠️ 多人成绩雷同(' + a.identical.length + ' 组)

'); - sec.push('
' + - ''); - if (!a.identical.length) sec.push(''); - else a.identical.forEach(g => { - const names = g.members.map(m => m.name + '(' + m.sid + ')').join('、'); - sec.push(''); - }); - sec.push('
成绩人数学生
' + empty + '
' + g.score + '' + g.count + '' + esc(names) + '
'); - - 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() + '

' + - '' + rows + '
学号姓名班级组别
' + - '