旧代码误读 surveySetting.whiteLists(实为题目选项白名单),导致线上从未触发。现改为读取 /api/project/partner/list 的 RESPONDENT_SYS_USER/IMP_USER 参与者作为应考名单,并保留 whiteLists 作为极老版本兜底。 dashboard.php 移除未实现的提交率卡片;全_score 默认 100 已在 data 层修正。e2e 测试 mock 与断言同步改为真实模型,全量 132 测试通过。
596 lines
26 KiB
PHP
596 lines
26 KiB
PHP
<?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">
|
||
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||
</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>
|
||
<a class="btn back" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
|
||
<a class="btn back" href="logout.php" title="退出登录">退出</a>
|
||
</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>
|
||
<span class="spacer" style="flex:1"></span>
|
||
最后更新:<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-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">分组对比 / 考试成员</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)';
|
||
}
|
||
|
||
/* 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');
|
||
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);
|
||
const LS_DASH_AUTO = 'ny_dash_auto';
|
||
const LS_DASH_INTERVAL= 'ny_dash_interval';
|
||
|
||
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 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;
|
||
// 默认 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: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;
|
||
// 同样按「占满分的百分比」着色,满分不是 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>' : '';
|
||
// 注意:不要把 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 + '">' + (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 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');
|
||
});
|
||
|
||
/* ---------- 看板占位(未选问卷/班级) ---------- */
|
||
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
|
||
const PLACEHOLDER_ACCENT = {
|
||
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
|
||
'平均分': 'accent-teal', '合格率': 'accent-indigo'
|
||
};
|
||
function renderPlaceholderBoard(){
|
||
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">请选择问卷与班级查看考试整体情况</div>';
|
||
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
|
||
const _hs = document.getElementById('heroSub');
|
||
if (_hs) _hs.textContent = '选择一个问卷与班级查看考试整体情况';
|
||
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(){
|
||
$('#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(){
|
||
const pid = $('#projectSel').value;
|
||
const cls = $('#classSel').value;
|
||
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 || '';
|
||
|
||
// 构建"学号 -> 多次刷题明细"映射(班级级 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);
|
||
}
|
||
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', { 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(); return; }
|
||
// 静默续期:保留旧数据,但把实时点变红提示中断
|
||
markStale(e.message);
|
||
} finally {
|
||
if (manual && btn) { btn.disabled = false; btn.textContent = '刷新'; }
|
||
}
|
||
}
|
||
|
||
/* ---------- 事件 ---------- */
|
||
$('#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 {
|
||
refreshClassSel();
|
||
}
|
||
});
|
||
// auto 参数
|
||
if (CUR.auto === '0') $('#autoRefresh').checked = false;
|
||
</script>
|
||
</body>
|
||
</html>
|