导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
802 lines
37 KiB
PHP
802 lines
37 KiB
PHP
<?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()">×</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 => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|