Files
ny-exam-platform/dashboard.php
T
nxxy335 d05fd4a9b5 feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
2026-09-27 22:04:17 +08:00

802 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 对外数据大屏(两种形态,由 URL 决定):
* 1) 无 ?class= → 大屏首页:先选试卷/考试,再以卡片网格列出该卷的班级,点击进入该班大屏;
* 2) 有 ?class=<班级名> → 该班独立大屏:只有试卷选择 / 自动刷新开关 / 全屏,
* 不出现班级选择、「← 主系统」「退出」,并且屏蔽浏览器后退键(退不回首页)。
* URL 仍支持 ?pid= 与 &auto= 便于分享与刷新恢复。
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$pidParam = trim((string)($_GET['pid'] ?? ''));
$clsParam = trim((string)($_GET['class'] ?? ''));
$isClass = ($clsParam !== '');
/* 公开访问:带 ?class= 的班级大屏不需要登录。该班数据只暴露该班本身——
接口侧会先按班级裁剪再回传,拿不到全卷与其它班级的成绩。
大屏首页仍要求登录——它列出的是某卷下所有班级的入口。 */
$publicView = $isClass && !ny_is_auth();
if (!$isClass && !ny_is_auth()) {
header('Location: login.php');
exit;
}
$pageTitle = $isClass ? ($clsParam . ' · 班级考试大屏') : '数据大屏 · 选择班级';
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<!-- dash-class = 单班大屏(隐藏班级选择/返回/退出 + 锁后退);dash-home = 首页班级入口 -->
<body class="dash <?= $isClass ? 'dash-class' : 'dash-home' ?>">
<div class="wrap">
<div class="dash-hero">
<div class="brand">
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
<?php if ($isClass): ?>
<span class="brand-cls" id="heroClass"><?= htmlspecialchars($clsParam) ?></span>
<?php endif; ?>
</div>
<div class="dash-controls">
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
<span id="classSelWrap"><select id="classSel" style="min-width:140px" disabled><option value="">请先选问卷</option></select></span>
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
<select id="intervalSel" style="width:auto">
<option value="5">5 秒</option>
<option value="10">10 秒</option>
<option value="15" selected>15 秒</option>
<option value="30">30 秒</option>
<option value="60">60 秒</option>
</select>
<button class="btn" id="btnRefresh">刷新</button>
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
<a class="btn back" id="linkIndex" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
<?php if (!$publicView): ?>
<a class="btn back" id="linkLogout" href="logout.php" title="退出登录">退出</a>
<?php endif; ?>
</div>
</div>
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span id="heroSub">选择一个问卷与班级查看考试整体情况</span>
<?php if ($publicView): ?><span class="pub-tag" title="本页面为公开页面,无需登录即可查看">公开 · 无需登录</span><?php endif; ?>
<span class="spacer" style="flex:1"></span>
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
</div>
<!-- ================= 看板区:首页=班级入口卡片,单班页=该班看板 ================= -->
<div id="board">
<!-- 5 个汇总卡片(仅单班大屏使用) -->
<section class="cards" id="boardSummary">
<div class="card accent-blue"><span>总人数</span><b id="bTotal">-</b></div>
<div class="card ok accent-green"><span>已提交</span><b id="bSubmit">-</b></div>
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
<div class="card accent-indigo"><span>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
</section>
<div class="sec-title" id="boardTitle">分组对比 / 考试成员</div>
<div id="boardBody">
<div class="empty">请选择问卷与班级查看考试整体情况</div>
</div>
</div>
</div>
<!-- 多次刷题明细弹窗 -->
<div class="modal" id="attemptsModal">
<div class="modal-box">
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close onclick="closeAttempts()">&times;</button></div>
<div class="modal-body" id="attBody"></div>
</div>
</div>
<script>
// 弹窗:点击遮罩或带 data-close 的元素关闭
document.querySelectorAll('.modal').forEach(m => {
m.addEventListener('click', e => {
if (e.target === m || e.target.hasAttribute('data-close')) m.classList.remove('open');
});
});
const $ = s => document.querySelector(s);
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function rateColor(r){
if (r >= 90) return 'var(--ok)';
if (r >= 60) return 'var(--warn)';
return 'var(--danger)';
}
/* 成绩红绿:一律按管理员在后台设的本卷「满分 + 合格率」判定,取接口下发的 DASH。
⚠️ DASH 是 let 声明的,不会挂到 window 上——写成 window.DASH 永远拿到 undefined,
结果就是悄悄回退成 100 分 / 60% 的出厂默认值,后台改了合格率也不生效。 */
function scoreGrade(sc){
const d = (typeof DASH !== 'undefined') ? DASH : null;
const full = (d && d.full_score) ? Number(d.full_score) : 100;
const pass = (d && d.pass_rate != null) ? Number(d.pass_rate) : 60;
if (!(full > 0) || sc == null || sc === '') return { cls: '', pct: null, pass: pass, line: 0 };
const pct = Number(sc) / full * 100;
if (!isFinite(pct)) return { cls: '', pct: null, pass: pass, line: 0 };
// 合格线本身高过 90% 时绿线跟着抬上去,免得"合格了却显示成黄色"
const hi = Math.max(90, pass);
return {
cls: pct >= hi ? 'score-high' : (pct >= pass ? 'score-mid' : 'score-low'),
pct: pct,
pass: pass,
line: Math.round(full * pass / 100 * 10) / 10, // 合格线折算成分数, tooltip 用
};
}
/* FLIP 行移动画:新数据行滑到新排名、其它行顺势下移(尊重 prefers-reduced-motion) */
const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
function captureFlip(container){
const m = new Map();
if (!container) return m;
container.querySelectorAll('tr[data-sid]').forEach(tr => {
const sid = tr.getAttribute('data-sid');
if (sid) m.set(sid, tr.getBoundingClientRect().top);
});
return m;
}
function playFlip(container, prev){
if (!container || !prev || REDUCED_MOTION) return;
container.querySelectorAll('tr[data-sid]').forEach(tr => {
const sid = tr.getAttribute('data-sid');
if (!sid) return;
const oldTop = prev.get(sid);
if (oldTop === undefined) { tr.classList.add('row-drop-in'); return; }
const dy = oldTop - tr.getBoundingClientRect().top;
if (Math.abs(dy) < 1) return;
tr.style.transition = 'none';
tr.style.transform = 'translateY(' + dy + 'px)';
void tr.offsetWidth;
requestAnimationFrame(() => {
tr.style.transition = 'transform .42s cubic-bezier(.22,.61,.36,1)';
tr.style.transform = '';
});
});
}
/* URL 状态:?pid=&class=&auto=1|0 */
function readUrl(){
const u = new URL(location.href);
return {
pid: u.searchParams.get('pid') || '',
cls: u.searchParams.get('class') || '',
auto: u.searchParams.get('auto'),
};
}
function writeUrl({pid, cls, auto}){
const u = new URL(location.href);
if (pid !== undefined) u.searchParams.set('pid', pid);
if (cls !== undefined) u.searchParams.set('class', cls);
if (auto !== undefined) u.searchParams.set('auto', auto ? '1' : '0');
// 注意:replaceState 不传 state 会把它清成 null,把后退锁的标志位一起抹掉,
// 所以有锁时原样写回(即便如此后退仍会被 popstate 兜住,这里是顺手保持一致)
const st = (history.state && history.state.nyLock) ? { nyLock: 1 } : null;
history.replaceState(st, '', u.pathname + (u.search ? u.search : ''));
}
/* 本地缓存 */
const LS = {
get(k, def){ try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); } catch(e){ return def; } },
set(k, v){ try { localStorage.setItem(k, v); } catch(e){} },
};
const LS_PROJECTS = 'ny_projects';
const LS_BOARD = (pid, cls) => 'ny_board_' + pid + '_' + encodeURIComponent(cls);
const LS_DASH_AUTO = 'ny_dash_auto';
const LS_DASH_INTERVAL= 'ny_dash_interval';
let DASH = null;
let CUR = readUrl();
/* 形态:URL 带 class = 单班大屏(不显示班级选择、锁后退);否则是「首页 → 选班级进入」 */
const IS_CLASS = !!CUR.cls;
/* 当前班级:单班页取自 URL,首页取自下拉 */
function currentClass(){ return IS_CLASS ? (CUR.cls || '') : ($('#classSel') ? $('#classSel').value : ''); }
/* ---------- API ---------- */
async function api(action, params){
let url = 'api.php?action=' + encodeURIComponent(action);
if (params) for (const k in params) url += '&' + k + '=' + encodeURIComponent(params[k] || '');
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
const j = await res.json();
if (j && j.ok) return j.data;
const err = new Error((j && (j.msg || j.error)) || '请求失败');
// 401 = 本系统会话已失效,同样需要重新登录(防死循环闸门见 handleAuthFail)
err.auth = !!(j && j.extra && j.extra.auth) || res.status === 401;
err.extra = (j && j.extra) || null;
throw err;
}
/* ---------- 鉴权失败:防反复跳登录页的死循环 ----------
* 若刚登录完(120 秒内)又鉴权失败,说明"重新登录"解决不了问题(多半是凭据没存住),
* 此时不再跳转,直接把服务端原因 + 落盘诊断显示出来,让人能自查。 */
function authJustLoggedIn(){
try {
const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10);
return t > 0 && (Date.now() - t) < 120000;
} catch (e) { return false; }
}
function authJumpCount(){
try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; }
}
function showAuthFailureBox(e){
const d = (e.extra && e.extra.diag) || null;
const yn = v => (v ? '是' : '否');
const lines = d ? [
'配置来源:' + (d.storage || '-') + '(配置文件可写=' + yn(d.cfg_writable) + ')',
'数据目录:' + (d.data_dir || '-') + '(可写=' + yn(d.dir_writable) + ')',
'数据库:' + (d.db_connected ? ('已连接(可写=' + yn(d.db_writable) + ')') : ('未连接 ' + (d.db_error || ''))),
'已存账号:' + (d.has_username ? (d.username || '已设置') : '空 ← 这就是「未配置账号」的原因'),
'已存密码:' + (d.has_password ? '已保存' : '空'),
d.save_error ? ('保存异常:' + d.save_error) : '',
].filter(Boolean) : [];
$('#boardBody').innerHTML =
'<div class="empty" style="text-align:left;line-height:1.9">' +
'<b>无法访问卷王</b><br>' + esc(e.message || '') +
(lines.length ? '<pre style="white-space:pre-wrap;margin:10px 0 0;text-align:left;font-size:12px">' + esc(lines.join('\n')) + '</pre>' : '') +
'<div style="margin-top:12px"><a class="btn btn-primary" href="login.php">重新登录</a></div>' +
'</div>';
}
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
function renderProjects(list){
const sel = $('#projectSel');
const keep = sel.value;
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>';
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
const parentIds = new Set();
list.forEach(p => { const pid = (p.parent_id || '0'); if (pid && pid !== '0' && pid !== '') parentIds.add(pid); });
const folderSet = new Set();
list.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
const childOf = fid => list.filter(p => !folderSet.has(p.id) && (p.parent_id || '0') === fid);
list.filter(p => !folderSet.has(p.id)).forEach(p => {
const o = document.createElement('option');
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
sel.appendChild(o);
});
list.filter(p => folderSet.has(p.id)).forEach(f => {
const kids = childOf(f.id);
if (!kids.length) return;
const og = document.createElement('optgroup');
og.label = '📁 ' + f.name;
kids.forEach(p => {
const o = document.createElement('option');
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
og.appendChild(o);
});
sel.appendChild(og);
});
if (CUR.pid && list.some(p => p.id === CUR.pid)) sel.value = CUR.pid;
else if (keep && list.some(p => p.id === keep)) sel.value = keep;
else {
const last = LS.get('ny_board_last_pid', '');
if (last && list.some(p => p.id === last)) sel.value = last;
}
}
async function loadProjects(){
const cached = LS.get(LS_PROJECTS, []);
if (Array.isArray(cached) && cached.length) renderProjects(cached);
try {
const list = await api('projects');
LS.set(LS_PROJECTS, JSON.stringify(list || []));
renderProjects(list || []);
refreshClassSel(); // 项目变了刷新班级列表
} catch (e) {
if (!cached || !cached.length) {
$('#boardBody').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
}
}
}
/* ---------- 班级列表(来自 roster) ---------- */
async function refreshClassSel(){
const sel = $('#classSel');
const pid = $('#projectSel').value;
if (!pid) {
sel.innerHTML = '<option value="">请先选问卷</option>';
sel.disabled = true;
return;
}
// 项目数据来自 api (dashboard),先调用一次拿班级
try {
const data = await api('dashboard', dashParams({ projectId: pid }));
DASH = data;
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映
const bl = document.getElementById('bPassLabel');
if (bl) bl.textContent = '(≥' + (data.pass_rate == null ? 60 : data.pass_rate) + '%满分)';
const classes = (data.classes || []).map(c => c.class);
sel.innerHTML = classes.length
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
: '<option value="">暂无班级</option>';
sel.disabled = !classes.length;
if (IS_CLASS) {
/* 单班大屏:班级由 URL 决定,下拉只是隐藏的镜像。
换卷后该班若不在新卷名单里 → 落到第一个班并把 URL 改对(不新增历史,避免影响后退锁)。 */
if (classes.length) {
if (!classes.includes(CUR.cls)) { CUR.cls = classes[0]; writeUrl({ cls: CUR.cls }); }
sel.value = CUR.cls;
}
} else if (CUR.cls && classes.includes(CUR.cls)) {
sel.value = CUR.cls;
} else {
// 默认 class:上次选择 > 第一个
const last = LS.get('ny_board_last_cls_' + pid, '');
if (last && classes.includes(last)) sel.value = last;
else if (classes.length) sel.value = classes[0];
CUR.cls = sel.value;
}
renderBoard();
} catch (e) {
if (e.auth) {
const jumps = authJumpCount();
if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e); return; }
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
// 卷王账号未配置或登录失效:跳转登录页自愈
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
setTimeout(() => { location.href = 'login.php'; }, 1200);
return;
}
sel.innerHTML = '<option value="">加载失败</option>';
sel.disabled = true;
$('#boardBody').innerHTML = '<div class="empty">加载失败:' + esc(e.message) + '</div>';
}
}
/* ---------- 成员榜(含多次刷题徽标与下钻) ---------- */
function membersTable(members){
if (!members || !members.length) {
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
}
return '<div class="member-list"><table class="grid compact">' +
'<thead><tr><th style="width:20%;text-align:right">排名</th><th style="width:55%">姓名</th><th style="width:25%" data-k="score">成绩</th></tr></thead><tbody>' +
members.map(m => {
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;
// 红绿由后台配置的满分成数 / 合格率决定(见 scoreGrade)
const g = scoreGrade(sc);
const cls = g.cls;
const tip = g.cls ? ' title="占满分 ' + g.pct.toFixed(1) + '% 合格线 ' + g.line + ' 分"' : '';
const att = (m.attempts || 1) > 1;
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
// HTML 属性(onclick="openAttempts("123")"),点击必然报错。改用 data-sid + 事件委托。
return '<tr' + (att ? ' class="clickable"' : '') + ' data-sid="' + esc(m.sid) + '"' + (att ? ' title="查看提交明细"' : '') + '>' +
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
'<td class="name">' + esc(m.name) + tag + '</td>' +
'<td class="score num ' + cls + '"' + tip + '>' + (sc == null ? '-' : sc) + '</td>' +
'</tr>';
}).join('') +
'</tbody></table></div>';
}
/* ---------- 多次刷题下钻弹窗 ---------- */
function openAttempts(sid){
const info = (window.MEMBER_ATT || {})[sid];
if (!info) return;
const list = info.list || [];
const rows = list.map(a => {
// 下钻弹窗里的成绩用同一套红绿口径,避免"列表里是绿的、点开却是白的"
const g = scoreGrade(a.score);
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
return '<tr>' +
'<td class="num">' + esc(a.submit_at) + '</td>' +
'<td class="num ' + g.cls + '">' + sc + tmp + '</td>' +
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
'</tr>';
}).join('');
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
$('#attBody').innerHTML = list.length
? '<table class="grid compact att-table"><thead><tr>' +
'<th data-k="submit_ts" style="width:40%">提交时间</th>' +
'<th data-k="score" style="width:30%">成绩</th>' +
'<th data-k="duration" style="width:30%">考试时长</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>'
: '<div class="empty">无提交明细</div>';
document.querySelector('#attemptsModal').classList.add('open');
}
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 clsCardHtml(c){
const rate = Number(c.rate || 0);
const total = dv(c.total, 0), sub = dv(c.submitted, 0), abs = dv(c.absent, 0);
const pr = Number(c.pass_rate == null ? 0 : c.pass_rate);
const kpi = (label, val, kc) =>
'<div class="cls-kpi ' + (kc || '') + '"><span>' + label + '</span><b>' + val + '</b></div>';
return '<div class="cls-card" data-cls="' + esc(c.class) + '" role="button" tabindex="0" ' +
'title="点击进入「' + esc(c.class) + '」的独立大屏">' +
'<div class="cls-head"><span class="cls-name">' + esc(c.class) + '</span>' +
'<span class="cls-go">进入 ›</span></div>' +
'<div class="cls-kpis">' +
kpi('总人数', total) +
kpi('已提交', sub, 'k-ok') +
kpi('缺考', abs, abs > 0 ? 'k-warn' : '') +
kpi('平均分', dv(c.avg)) +
kpi('合格率', pv(pr), 'k-ok') +
'</div>' +
'<div class="cls-bar"><i style="width:' + rate + '%;background:' + rateColor(rate) + '"></i></div>' +
'<div class="cls-sub"><span>提交率 ' + rate + '%</span>' +
(c.attempts > c.submitted ? '<span>共 ' + dv(c.attempts, 0) + ' 次刷题</span>' : '') +
'</div>' +
'</div>';
}
/** 首页渲染:把该卷的班级列成卡片,点击进入该班独立大屏(带 pid/auto 便于回到同一状态) */
function renderClassCards(){
const pid = $('#projectSel').value;
if (!pid || !DASH) { renderPlaceholderBoard('请先选择一份问卷 / 考试'); return; }
const list = DASH.classes || [];
const bt = document.getElementById('boardTitle');
if (bt) bt.innerHTML = '班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span>';
const box = $('#boardBody');
if (!list.length) {
box.innerHTML = '<div class="empty">该问卷没有解析出班级,请检查名单的「班级」列</div>';
return;
}
box.innerHTML = '<div class="cls-grid">' + list.map(clsCardHtml).join('') + '</div>';
const _hs = document.getElementById('heroSub');
if (_hs) _hs.textContent = '共 ' + list.length + ' 个班级 · 点击卡片进入该班大屏';
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
}
/** 进入某班独立大屏:整页跳转(不是前端路由,浏览器后退键会被下面的锁拦掉) */
function enterClass(cls){
const pid = $('#projectSel').value;
if (!pid || !cls) return;
const u = new URL(location.href);
u.searchParams.set('pid', pid);
u.searchParams.set('class', cls);
u.searchParams.set('auto', $('#autoRefresh').checked ? '1' : '0');
location.href = u.pathname + u.search;
}
// 卡片点击 / 回车进入(事件委托:卡片网格每次整体重绘,绑在常驻的 #boardBody 上)
(function bindClsCards(){
const box = document.querySelector('#boardBody');
if (!box) return;
const hit = e => (e.target && e.target.closest ? e.target.closest('.cls-card') : null);
box.addEventListener('click', e => {
const card = hit(e);
if (card) enterClass(card.getAttribute('data-cls') || '');
});
box.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const card = hit(e);
if (!card) return;
e.preventDefault();
enterClass(card.getAttribute('data-cls') || '');
});
})();
/* ---------- 看板占位(未选问卷/班级) ---------- */
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
const PLACEHOLDER_ACCENT = {
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
'平均分': 'accent-teal', '合格率': 'accent-indigo'
};
function renderPlaceholderBoard(msg){
const m = msg || '选择一个问卷与班级查看考试整体情况';
const cards = [
['总人数','-'],['已提交','-'],['缺考','-'],
['平均分','-'],['合格率','-']
];
$('#boardSummary').innerHTML = cards.map(c =>
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
'<div class="card ' + PLACEHOLDER_ACCENT[c[0]] + '"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
$('#boardBody').innerHTML = '<div class="empty">' + esc(m) + '</div>';
const bt = document.getElementById('boardTitle');
if (bt) bt.innerHTML = IS_CLASS ? '分组对比 / 考试成员'
: '班级入口 <span class="tag-gray">选好试卷后显示可进入的班级</span>';
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
const _hs = document.getElementById('heroSub');
if (_hs) _hs.textContent = m;
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
}
/* ---------- 实时状态指示 ---------- */
function markLive(){
const d = $('#liveDot');
if (d) { d.classList.remove('stale'); d.classList.add('on'); }
setTimeout(() => { const e = $('#liveDot'); if (e) e.classList.remove('on'); }, 900);
}
function markStale(msg){
const d = $('#liveDot');
if (d) { d.classList.remove('on'); d.classList.add('stale'); }
const lu = $('#lastUpdate');
// 具体原因放进 title(鼠标悬停可见),正文保持简短
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
}
function handleAuthFail(e){
const jumps = authJumpCount();
if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e || new Error('鉴权失败')); return; }
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
setTimeout(() => { location.href = 'login.php'; }, 1200);
}
/* ---------- 渲染单班看板 ---------- */
/** 数值兜底:后端字段缺失时显示占位符,避免界面出现 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(){
// 首页形态:不出单班看板,改为渲染班级入口卡片(boardSummary 由 CSS 隐藏)
if (!IS_CLASS) { renderClassCards(); return; }
const pid = $('#projectSel').value;
const cls = currentClass();
CUR.pid = pid; CUR.cls = cls;
writeUrl({ pid, cls });
if (!pid || !cls || !DASH) {
renderPlaceholderBoard();
return;
}
LS.set('ny_board_last_pid', JSON.stringify(pid));
LS.set('ny_board_last_cls_' + pid, JSON.stringify(cls));
const c = (DASH.classes || []).find(x => x.class === cls);
if (!c) {
$('#boardBody').innerHTML = '<div class="empty">该班级无数据(名单中无此班级)</div>';
return;
}
// 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
const _hc = document.getElementById('heroClass');
if (_hc) _hc.textContent = cls + ' · 该班独立大屏';
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
const MAP = {};
(c.members || []).forEach(m => {
if ((m.attempts || 1) > 1) {
MAP[m.sid] = { name: m.name, attempts: m.attempts, list: m.attempts_list || [] };
}
});
window.MEMBER_ATT = MAP;
const overall = DASH.overall || {};
const totalAttempts = (overall.attempts_total || 0);
const _hs2 = document.getElementById('heroSub');
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
'总 ' + dv(c.total, 0) + ' / 已交 ' + dv(c.submitted, 0) + ' / 缺考 ' + dv(c.absent, 0) +
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
// 5 卡汇总(提交率卡片已移除)
$('#boardSummary').innerHTML =
'<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-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">' + dv(c.submitted, 0) + ' 人</span></div>' +
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' +
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
membersTable(c.members) + '</div>';
} else {
box.innerHTML = '<div class="group-compare">' + groups.map(g => {
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 + '">' + 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:' + dv(g.rate, 0) + '%;background:' + gc + '"></i></div>' +
'<div class="class-metrics">' +
'<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">考试成员 · 按成绩降序(' + dv(g.submitted, 0) + ' 人)</div>' +
membersTable(g.members) +
'</div>';
}).join('') + '</div>';
}
}
/* ---------- 全屏 ---------- */
function toggleFullscreen(){
const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) {
const p = req.call(el);
// 部分浏览器(尤其 Safari)会返回 Promise,被拒绝时静默吞掉会让人以为"点了没反应"
if (p && typeof p.catch === 'function') {
p.catch(() => { alert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换'); });
}
}
} else {
// 注意:exitFullscreen 挂在 document 上,不是 documentElement 上
// (写成 el.exitFullscreen 恒为 undefined → 退出全屏点了没反应)
const ex = document.exitFullscreen || document.webkitExitFullscreen;
if (ex) {
const p = ex.call(document);
if (p && typeof p.catch === 'function') p.catch(() => {});
}
}
}
function onFsChange(){
const on = !!(document.fullscreenElement || document.webkitFullscreenElement);
document.body.classList.toggle('fs-on', on);
const btn = $('#btnFullscreen');
if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
}
/* ---------- 自动刷新 ---------- */
let timer = null;
function schedule(){
if (timer) { clearInterval(timer); timer = null; }
if (!$('#autoRefresh').checked) return;
const sec = parseInt($('#intervalSel').value, 10) || 15;
timer = setInterval(refreshBoard, sec * 1000);
}
/* 取数参数:公开访问时后端按 class 裁剪并返回单班结果,取数必须带班级;
已登录时这个参数被忽略,留着不影响。 */
function dashParams(extra){
const p = {};
for (const k in (extra || {})) p[k] = extra[k];
p.projectId = p.projectId || $('#projectSel').value || CUR.pid || '';
if (IS_CLASS && CUR.cls) p.class = CUR.cls;
return p;
}
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 {
const fresh = await api('dashboard', dashParams({ projectId: pid }));
DASH = fresh;
// 项目集合可能换:刷新班级下拉但保留当前选择
const sel = $('#classSel');
const keep = sel.value;
const classes = (fresh.classes || []).map(c => c.class);
if (!classes.includes(keep)) {
sel.innerHTML = classes.length
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
: '<option value="">暂无班级</option>';
sel.disabled = !classes.length;
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
}
// 刷新前捕获各行位置,刷新后做 FLIP:新数据行滑入、其它行下移
const flipPrev = captureFlip($('#boardBody'));
renderBoard();
playFlip($('#boardBody'), flipPrev);
$('#lastUpdate').textContent = fresh.updated_at || '-';
markLive();
} catch (e) {
if (e.auth) { handleAuthFail(e); return; }
// 静默续期:保留旧数据,但把实时点变红提示中断
markStale(e.message);
} finally {
if (manual && btn) { btn.disabled = false; btn.textContent = '刷新'; }
}
}
/* ---------- 后退锁定:单班大屏不允许退回首屏 / 主系统 ----------
* 做法:加载后立刻 pushState 一条同页历史,之后每次 popstate(无论用户按后退还是前进)
* 都再推回一条,页面永远留在当前 URL 上——浏览器后退键因此无效。
* 只锁 IS_CLASS 形态:首页仍可正常后退(例如退回登录前的主系统页)。 */
let historyLocked = false;
function lockHistory(){
if (!IS_CLASS || historyLocked) return;
historyLocked = true;
const push = () => { try { history.pushState({ nyLock: 1 }, '', location.href); } catch (e) {} };
push();
window.addEventListener('popstate', function onPop(e){
// 落回自己推入的同页条目时不再重复占位,避免连按后退无限堆积历史
if (e.state && e.state.nyLock) return;
push();
});
}
/* ---------- 事件 ---------- */
$('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); });
$('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); });
$('#btnRefresh').addEventListener('click', () => refreshBoard(true));
$('#autoRefresh').addEventListener('change', () => {
writeUrl({ auto: $('#autoRefresh').checked });
LS.set(LS_DASH_AUTO, $('#autoRefresh').checked);
schedule();
});
$('#intervalSel').addEventListener('change', () => {
LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
schedule();
});
$('#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);
if (qs.get('interval')) {
$('#intervalSel').value = qs.get('interval');
LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
} else {
const iv = LS.get(LS_DASH_INTERVAL, 0);
if (iv) {
const opts = Array.prototype.slice.call($('#intervalSel').options).map(o => parseInt(o.value, 10));
if (opts.includes(iv)) $('#intervalSel').value = String(iv);
}
}
if (CUR.auto !== '0') {
const auto = LS.get(LS_DASH_AUTO, null);
if (auto !== null) $('#autoRefresh').checked = !!auto;
}
}
/* ---------- 启动 ---------- */
restoreDashPrefs();
loadProjects().then(() => {
if (CUR.pid) {
$('#projectSel').value = CUR.pid;
refreshClassSel().then(() => { renderBoard(); schedule(); });
} else if (IS_CLASS && $('#projectSel').value) {
// 公开链接可能不带 pid:自动落到该卷第一份考试,别让访客对着空页面
CUR.pid = $('#projectSel').value;
refreshClassSel().then(() => { renderBoard(); schedule(); });
} else {
refreshClassSel();
}
// 首页文案指向"选考试 → 进班级",避免顶栏停在旧的单班提示上
if (!IS_CLASS) {
const _hs = document.getElementById('heroSub');
if (_hs) _hs.textContent = '先选择一份问卷 / 考试,再点击班级卡片进入该班大屏';
}
});
lockHistory();
// auto 参数
if (CUR.auto === '0') $('#autoRefresh').checked = false;
</script>
</body>
</html>