文档部署到 /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:
+65
-20
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user