feat(export/dashboard): 导出增强与大屏公开化

导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
2026-09-27 22:04:17 +08:00
parent 0ba9c7c33c
commit d05fd4a9b5
34 changed files with 4290 additions and 305 deletions
+199 -32
View File
@@ -1,35 +1,51 @@
<?php
/**
* 班级数据看板(独立页面):每个班级一张专属看板,无概览、无返回按钮。
* URL 支持 ?pid=<projectId>&class=<班级名> 分享与刷新恢复;缺省自动选第一个班级。
* 对外数据大屏(两种形态,由 URL 决定):
* 1) 无 ?class= → 大屏首页:先选试卷/考试,再以卡片网格列出该卷的班级,点击进入该班大屏;
* 2) 有 ?class=<班级名> → 该班独立大屏:只有试卷选择 / 自动刷新开关 / 全屏,
* 不出现班级选择、「← 主系统」「退出」,并且屏蔽浏览器后退键(退不回首页)。
* URL 仍支持 ?pid= 与 &auto= 便于分享与刷新恢复。
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (!ny_is_auth()) {
$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(NY_APP_NAME) ?></title>
<title><?= htmlspecialchars($pageTitle) ?> · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body class="dash">
<!-- 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>
<select id="classSel" style="min-width:140px" disabled><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>
@@ -40,20 +56,23 @@ if (!ny_is_auth()) {
</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>
<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">
<!-- 6 个汇总卡片 -->
<!-- 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>
@@ -62,7 +81,7 @@ if (!ny_is_auth()) {
<div class="card accent-indigo"><span>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
</section>
<div class="sec-title">分组对比 / 考试成员</div>
<div class="sec-title" id="boardTitle">分组对比 / 考试成员</div>
<div id="boardBody">
<div class="empty">请选择问卷与班级查看考试整体情况</div>
</div>
@@ -93,6 +112,26 @@ function rateColor(r){
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){
@@ -137,7 +176,10 @@ function writeUrl({pid, cls, auto}){
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 : ''));
// 注意:replaceState 不传 state 会把它清成 null,把后退锁的标志位一起抹掉,
// 所以有锁时原样写回(即便如此后退仍会被 popstate 兜住,这里是顺手保持一致)
const st = (history.state && history.state.nyLock) ? { nyLock: 1 } : null;
history.replaceState(st, '', u.pathname + (u.search ? u.search : ''));
}
/* 本地缓存 */
@@ -153,6 +195,11 @@ 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);
@@ -260,7 +307,7 @@ async function refreshClassSel(){
}
// 项目数据来自 api (dashboard),先调用一次拿班级
try {
const data = await api('dashboard', { projectId: pid });
const data = await api('dashboard', dashParams({ projectId: pid }));
DASH = data;
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映
const bl = document.getElementById('bPassLabel');
@@ -270,10 +317,17 @@ async function refreshClassSel(){
? 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)) {
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];
@@ -307,15 +361,10 @@ function membersTable(members){
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');
})();
// 红绿由后台配置的满分成数 / 合格率决定(见 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 产生的双引号会提前闭合
@@ -323,7 +372,7 @@ function membersTable(members){
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>' +
'<td class="score num ' + cls + '"' + tip + '>' + (sc == null ? '-' : sc) + '</td>' +
'</tr>';
}).join('') +
'</tbody></table></div>';
@@ -335,11 +384,13 @@ function openAttempts(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 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">' + sc + tmp + '</td>' +
'<td class="num ' + g.cls + '">' + sc + tmp + '</td>' +
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
'</tr>';
}).join('');
@@ -374,13 +425,85 @@ document.addEventListener('keydown', e => {
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(){
function renderPlaceholderBoard(msg){
const m = msg || '选择一个问卷与班级查看考试整体情况';
const cards = [
['总人数','-'],['已提交','-'],['缺考','-'],
['平均分','-'],['合格率','-']
@@ -388,10 +511,13 @@ function renderPlaceholderBoard(){
$('#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>';
$('#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 = '选择一个问卷与班级查看考试整体情况';
if (_hs) _hs.textContent = m;
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
}
@@ -422,8 +548,11 @@ function dv(v, dash){ return (v === undefined || v === null || v === '' || Numbe
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 = $('#classSel').value;
const cls = currentClass();
CUR.pid = pid; CUR.cls = cls;
writeUrl({ pid, cls });
@@ -442,6 +571,8 @@ function renderBoard(){
// 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
const _hc = document.getElementById('heroClass');
if (_hc) _hc.textContent = cls + ' · 该班独立大屏';
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
const MAP = {};
@@ -535,6 +666,15 @@ function schedule(){
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;
@@ -543,7 +683,7 @@ async function refreshBoard(manual){
const btn = $('#btnRefresh');
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
try {
const fresh = await api('dashboard', { projectId: pid });
const fresh = await api('dashboard', dashParams({ projectId: pid }));
DASH = fresh;
// 项目集合可能换:刷新班级下拉但保留当前选择
const sel = $('#classSel');
@@ -571,6 +711,23 @@ async function refreshBoard(manual){
}
}
/* ---------- 后退锁定:单班大屏不允许退回首屏 / 主系统 ----------
* 做法:加载后立刻 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(); });
@@ -623,10 +780,20 @@ 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>