Files
ny-exam-platform/dashboard.php
T
nxxy335 c4c3bf7da1 修复"未配置卷王登录账号"导致后台刷新陷入死状态
根因:login.php 仅持久化 username、不持久化 password;token 缓存失效后
自动重登因缺少密码而抛 RuntimeException('未配置卷王登录账号'),前端仅提示
"后台刷新失败,已显示本地缓存"却无法自愈,刷新循环永不恢复。

修复:
- login.php:登录成功时同时持久化 username 与 password,token 失效时平台
  可凭凭据自动重新登录卷王,避免死状态。
- api.php:全局 catch 对鉴权类错误(未配置账号/登录失败)打标 extra.auth=true。
- assets/app.js、dashboard.php:api() 透传 auth 标记;刷新失败若为鉴权错误,
  自动跳转 login.php 重新登录,不再停留在本地缓存死状态。

验证:本地 mock 卷王端到端复现 CASE1(空账号精确复现报错)与 CASE2
(凭据齐备自动重登成功),HTTP 接口确认返回 extra.auth:true 与 ok:true。
2026-09-09 17:55:58 +08:00

437 lines
18 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 支持 ?pid=<projectId>&class=<班级名> 分享与刷新恢复;缺省自动选第一个班级。
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (!ny_is_auth()) {
header('Location: login.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>班级看板 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body class="dash">
<div class="wrap">
<div class="dash-hero">
<div class="brand">
<span class="logo">NY</span>
<div class="brand-text">
<strong id="heroTitle">班级看板</strong>
<small id="heroSub">选择一个问卷与班级查看考试整体情况</small>
</div>
</div>
<div class="dash-controls">
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
<select id="classSel" style="min-width:140px" disabled><option value="">请先选问卷</option></select>
<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>
</div>
</div>
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;">
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
</div>
<!-- ================= 班级看板(单班,无概览) ================= -->
<div id="board">
<!-- 6 个汇总卡片 -->
<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-purple"><span>提交率</span><b id="bRate">-</b></div>
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="bPass">-</b></div>
</section>
<div class="sec-title">分组对比 / 考试成员</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)';
}
/* 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');
history.replaceState(null, '', 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);
let DASH = null;
let CUR = readUrl();
/* ---------- 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)) || '请求失败');
err.auth = !!(j && j.extra && j.extra.auth);
throw err;
}
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
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', { projectId: pid });
DASH = data;
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;
// 默认 class:URL 指定 > 上次选择 > 第一个
if (CUR.cls && classes.includes(CUR.cls)) {
sel.value = CUR.cls;
} else {
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) {
// 卷王账号未配置或登录失效:跳转登录页自愈
$('#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:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
members.map(m => {
const rk = 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'));
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) + ')"' : '') + '>' +
'<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>' +
'</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 sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
return '<tr>' +
'<td class="num">' + esc(a.submit_at) + '</td>' +
'<td class="num">' + 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"><thead><tr><th>提交时间</th><th>成绩</th><th>考试时长</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');
}
/* ---------- 渲染单班看板 ---------- */
function renderBoard(){
const pid = $('#projectSel').value;
const cls = $('#classSel').value;
CUR.pid = pid; CUR.cls = cls;
writeUrl({ pid, cls });
if (!pid || !cls || !DASH) {
$('#boardSummary').innerHTML = '<div class="card accent-blue"><span>总人数</span><b>-</b></div>'.repeat(0) ||
'<div class="empty">请选择问卷与班级</div>';
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
$('#heroTitle').textContent = '班级看板';
$('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况';
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;
}
// 顶部品牌区:动态标题
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
$('#heroTitle').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);
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
(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>合格率</span><b>' + 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>' +
'<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 + '">' + g.rate + '%</span></div>' +
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent +
(g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' +
'<div class="bar"><i style="width:' + g.rate + '%;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>' +
'<div class="mem-title">考试成员 · 按成绩降序(' + g.submitted + ' 人)</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) req.call(el);
} else {
const ex = el.exitFullscreen || el.webkitExitFullscreen;
if (ex) ex.call(document);
}
}
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);
}
async function refreshBoard(){
const pid = $('#projectSel').value;
if (!pid) return;
try {
const fresh = await api('dashboard', { 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] }); }
}
renderBoard();
$('#lastUpdate').textContent = fresh.updated_at || '-';
$('#liveDot').classList.add('on');
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
} catch (e) {
/* 静默续期:保留旧数据不打扰 */
}
}
/* ---------- 事件 ---------- */
$('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); });
$('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); });
$('#btnRefresh').addEventListener('click', refreshBoard);
$('#autoRefresh').addEventListener('change', () => { writeUrl({ auto: $('#autoRefresh').checked }); schedule(); });
$('#intervalSel').addEventListener('change', schedule);
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
document.addEventListener('fullscreenchange', onFsChange);
document.addEventListener('webkitfullscreenchange', onFsChange);
/* ---------- 启动 ---------- */
loadProjects().then(() => {
if (CUR.pid) {
$('#projectSel').value = CUR.pid;
refreshClassSel().then(() => { renderBoard(); schedule(); });
} else {
refreshClassSel();
}
});
// auto 参数
if (CUR.auto === '0') $('#autoRefresh').checked = false;
</script>
</body>
</html>