文档站(#88) - base 默认改为 /ny-exam-platform/,可用 DOCS_BASE=/ 覆盖本地预览 - 新增 DOCS_OUT 环境变量,避免构建时清空输出目录失败导致产物半截 - 新增 tools/docs-preview.js:按子路径重写路由的本地预览服务 - 删除 docs/guide/snapshot.md,同步清理全部相关文档 移除存快照 / 趋势 / 异常(#89) - 后端:删除 api.php 的 snapshot_save/list/delete/trend 四个 action 与 $WRITE_ACTIONS 中的同名条目;删除 lib/Snapshot.php 与自动加载注册; DataService 移除 detectAnomalies() 及其调用 - 前端:移除三个按钮与三个弹窗、相关 JS 处理与 state 字段 - 测试:移除对应用例,补充 5 条名单导入列识别断言(17+9+18+32+29+11 全过) 打印完善(#90) - 改用隐藏 iframe 输出,规避弹窗拦截;@page A4 支持纵向/横向 - 表头每页重复、行内不跨页断开、全表连续名次、可选「是否合格」列 - 分组小计(人数/平均/最高/最低/合格人数与合格率)、可附缺考名单另起一页 - 末尾签字栏;缺考名单打印改为同一套输出 UI / 交互 / Bug 修复(#91) - dashboard.php:补「← 主系统 / 退出」入口(此前全屏后无法离开); 修复成员下钻 onclick 里 JSON.stringify 双引号提前闭合属性导致点击报错, 改为 data-sid 事件委托;补 Esc 关弹窗;后台标签页暂停轮询、切回自动补拉; 刷新中断原因写入 title;数值字段统一兜底,避免 NaN / undefined% - draw.php / draw.js:结果表新增「登陆账号」列(导出前可核对,缺失显示 学号 ⚠); 弹窗只关最上层且支持 Esc;「全部」改为跟随候选池,手工改人数即解除; 候选池不足时给出明确提示;自定义比例框隐藏时清空,避免旧权重被误读 - app.js / dashboard.php:成绩着色改为按「占本卷满分的百分比」判断, 修复满分不是 100 时整列误标红 - style.css:数值列右对齐;新增 subbar、draw-stage-sub; 看板顶栏窄屏下拉与按钮换行,消除横向滚动
556 lines
24 KiB
PHP
556 lines
24 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-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>合格率<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)';
|
||
}
|
||
|
||
/* 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:60px">排名</th><th>姓名</th><th style="width:80px">成绩</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) + '" 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"><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');
|
||
}
|
||
// 成员榜下钻:事件委托(#boardBody 常驻,内部表格会被整体重绘)
|
||
(function bindMemberDrill(){
|
||
const box = document.querySelector('#boardBody');
|
||
if (!box) return;
|
||
box.addEventListener('click', e => {
|
||
const tr = e.target.closest ? e.target.closest('tr[data-sid]') : null;
|
||
if (!tr || !box.contains(tr)) return;
|
||
openAttempts(tr.getAttribute('data-sid'));
|
||
});
|
||
})();
|
||
// Esc 关闭最上层弹窗
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key !== 'Escape' && e.key !== 'Esc') return;
|
||
const open = Array.prototype.slice.call(document.querySelectorAll('.modal.open'));
|
||
if (open.length) open[open.length - 1].classList.remove('open');
|
||
});
|
||
|
||
/* ---------- 看板占位(未选问卷/班级) ---------- */
|
||
function renderPlaceholderBoard(){
|
||
const cards = [
|
||
['总人数','-'],['已提交','-'],['缺考','-'],
|
||
['提交率','-'],['平均分','-'],['合格率','-']
|
||
];
|
||
$('#boardSummary').innerHTML = cards.map(c =>
|
||
'<div class="card"><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 + ' 次提交' : '');
|
||
|
||
// 6 卡汇总
|
||
const color = rateColor(c.rate || 0);
|
||
$('#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-purple"><span>提交率</span><b style="color:' + color + '">' + dv(c.rate, 0) + '%</b></div>' +
|
||
'<div class="card accent-teal"><span>平均分</span><b>' + dv(c.avg) + '</b></div>' +
|
||
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + pv(c.pass_rate) + '</b></div>';
|
||
|
||
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
||
const box = $('#boardBody');
|
||
if (!groups.length) {
|
||
box.innerHTML = '<div class="gpanel">' +
|
||
'<div class="ghead"><strong>本班考试成员</strong>' +
|
||
'<span class="rate-num">' + 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] }); }
|
||
}
|
||
renderBoard();
|
||
$('#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>
|