Files
ny-exam-platform/dashboard.php
T
nxxy335 94ac381579 完成批次 L-R:限流移除/白名单驱动/打印增强/全屏/表格对齐/主题变量/滚动闪烁修复
- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接

- 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey)

- 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加

- 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则

- 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致

- 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放

- 测试:新增 test-whitelist.js;run-all.sh 全绿 132
2026-09-13 10:25:48 +08:00

599 lines
26 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">
<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()">&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)';
}
/* 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-purple', '平均分': '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 + ' 次提交' : '');
// 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] }); }
}
// 刷新前捕获各行位置,刷新后做 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>