文档部署到 /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
+65 -20
View File
@@ -40,6 +40,8 @@ if (!ny_is_auth()) {
</select>
<button class="btn" id="btnRefresh">刷新</button>
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
<a class="btn back" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
<a class="btn back" href="logout.php" title="退出登录">退出</a>
</div>
</div>
@@ -237,13 +239,23 @@ function membersTable(members){
return '<div class="member-list"><table class="grid compact">' +
'<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
members.map(m => {
const rk = m.rank;
const rk = (m.rank == null ? '' : m.rank);
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
const sc = m.score;
const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low'));
// 同样按「占满分的百分比」着色,满分不是 100 时不会整列误标红
const cls = (sc == null) ? '' : (function () {
const full = (window.DASH && DASH.full_score) ? Number(DASH.full_score) : 100;
if (!(full > 0)) return '';
const pct = Number(sc) / full * 100;
if (!isFinite(pct)) return '';
const pass = (window.DASH && DASH.pass_rate != null) ? Number(DASH.pass_rate) : 60;
return pct >= 90 ? 'score-high' : (pct >= pass ? 'score-mid' : 'score-low');
})();
const att = (m.attempts || 1) > 1;
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
return '<tr' + (att ? ' class="clickable" onclick="openAttempts(' + JSON.stringify(m.sid) + ')"' : '') + '>' +
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
// HTML 属性(onclick="openAttempts("123")"),点击必然报错。改用 data-sid + 事件委托。
return '<tr' + (att ? ' class="clickable" data-sid="' + esc(m.sid) + '" title="查看提交明细"' : '') + '>' +
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
'<td class="name">' + esc(m.name) + tag + '</td>' +
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
@@ -276,6 +288,22 @@ function closeAttempts(){
const m = document.querySelector('#attemptsModal');
if (m) m.classList.remove('open');
}
// 成员榜下钻:事件委托(#boardBody 常驻,内部表格会被整体重绘)
(function bindMemberDrill(){
const box = document.querySelector('#boardBody');
if (!box) return;
box.addEventListener('click', e => {
const tr = e.target.closest ? e.target.closest('tr[data-sid]') : null;
if (!tr || !box.contains(tr)) return;
openAttempts(tr.getAttribute('data-sid'));
});
})();
// Esc 关闭最上层弹窗
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
const open = Array.prototype.slice.call(document.querySelectorAll('.modal.open'));
if (open.length) open[open.length - 1].classList.remove('open');
});
/* ---------- 看板占位(未选问卷/班级) ---------- */
function renderPlaceholderBoard(){
@@ -302,7 +330,8 @@ function markStale(msg){
const d = $('#liveDot');
if (d) { d.classList.remove('on'); d.classList.add('stale'); }
const lu = $('#lastUpdate');
if (lu) lu.textContent = '刷新中断(仍显示上次数据)';
// 具体原因放进 title(鼠标悬停可见),正文保持简短
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
}
function handleAuthFail(){
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
@@ -310,6 +339,10 @@ function handleAuthFail(){
}
/* ---------- 渲染单班看板 ---------- */
/** 数值兜底:后端字段缺失时显示占位符,避免界面出现 NaN / undefined */
function dv(v, dash){ return (v === undefined || v === null || v === '' || Number.isNaN(v)) ? (dash || '-') : v; }
function pv(v){ return dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%'); }
function renderBoard(){
const pid = $('#projectSel').value;
const cls = $('#classSel').value;
@@ -345,25 +378,25 @@ function renderBoard(){
const totalAttempts = (overall.attempts_total || 0);
const _hs2 = document.getElementById('heroSub');
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
'总 ' + dv(c.total, 0) + ' / 已交 ' + dv(c.submitted, 0) + ' / 缺考 ' + dv(c.absent, 0) +
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
// 6 卡汇总
const color = rateColor(c.rate || 0);
$('#boardSummary').innerHTML =
'<div class="card accent-blue"><span>总人数</span><b>' + c.total + '</b></div>' +
'<div class="card ok accent-green"><span>已提交</span><b>' + c.submitted + '</b></div>' +
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' +
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
'<div class="card accent-teal"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + c.pass_rate + '%</b></div>';
'<div class="card accent-blue"><span>总人数</span><b>' + dv(c.total, 0) + '</b></div>' +
'<div class="card ok accent-green"><span>已提交</span><b>' + dv(c.submitted, 0) + '</b></div>' +
'<div class="card warn accent-orange"><span>缺考</span><b>' + dv(c.absent, 0) + '</b></div>' +
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + dv(c.rate, 0) + '%</b></div>' +
'<div class="card accent-teal"><span>平均分</span><b>' + dv(c.avg) + '</b></div>' +
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + pv(c.pass_rate) + '</b></div>';
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
const box = $('#boardBody');
if (!groups.length) {
box.innerHTML = '<div class="gpanel">' +
'<div class="ghead"><strong>本班考试成员</strong>' +
'<span class="rate-num">' + c.submitted + ' 人</span></div>' +
'<span class="rate-num">' + dv(c.submitted, 0) + ' 人</span></div>' +
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' +
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
membersTable(c.members) + '</div>';
@@ -372,17 +405,17 @@ function renderBoard(){
const gc = rateColor(g.rate || 0);
return '<div class="gpanel">' +
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
'<span class="rate-num" style="color:' + gc + '">' + g.rate + '%</span></div>' +
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent +
'<span class="rate-num" style="color:' + gc + '">' + dv(g.rate, 0) + '%</span></div>' +
'<div class="sub">已提交 ' + dv(g.submitted, 0) + ' / 总人数 ' + dv(g.total, 0) + ' · 缺考 ' + dv(g.absent, 0) +
(g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' +
'<div class="bar"><i style="width:' + g.rate + '%;background:' + gc + '"></i></div>' +
'<div class="bar"><i style="width:' + dv(g.rate, 0) + '%;background:' + gc + '"></i></div>' +
'<div class="class-metrics">' +
'<div class="m"><span>平均分</span><b>' + (g.avg != null ? g.avg : '-') + '</b></div>' +
'<div class="m"><span>合格率</span><b>' + g.pass_rate + '%</b></div>' +
'<div class="m metric-ok"><span>最高分</span><b>' + (g.max != null ? g.max : '-') + '</b></div>' +
'<div class="m metric-danger"><span>最低分</span><b>' + (g.min != null ? g.min : '-') + '</b></div>' +
'<div class="m"><span>平均分</span><b>' + dv(g.avg) + '</b></div>' +
'<div class="m"><span>合格率</span><b>' + pv(g.pass_rate) + '</b></div>' +
'<div class="m metric-ok"><span>最高分</span><b>' + dv(g.max) + '</b></div>' +
'<div class="m metric-danger"><span>最低分</span><b>' + dv(g.min) + '</b></div>' +
'</div>' +
'<div class="mem-title">考试成员 · 按成绩降序(' + g.submitted + ' 人)</div>' +
'<div class="mem-title">考试成员 · 按成绩降序(' + dv(g.submitted, 0) + ' 人)</div>' +
membersTable(g.members) +
'</div>';
}).join('') + '</div>';
@@ -429,6 +462,8 @@ function schedule(){
async function refreshBoard(manual){
const pid = $('#projectSel').value;
if (!pid) return;
// 标签页在后台时不做自动轮询(省流量、也避免无人值守时反复请求卷王)
if (!manual && document.hidden) return;
const btn = $('#btnRefresh');
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
try {
@@ -474,6 +509,16 @@ $('#btnFullscreen').addEventListener('click', toggleFullscreen);
document.addEventListener('fullscreenchange', onFsChange);
document.addEventListener('webkitfullscreenchange', onFsChange);
/* 切回前台:立刻补一次数据并重排定时器;切到后台:停掉轮询 */
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (timer) { clearInterval(timer); timer = null; }
} else {
refreshBoard();
schedule();
}
});
/* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */
function restoreDashPrefs(){
const qs = new URLSearchParams(location.search);