- 删除 install.php:所有入口(index/dashboard/draw/login/api)不再跳安装,
未登录统一跳 login.php;连接信息变更在【设置】里直接修改 data/config.json
- 下线 pgsql/mysql:
* bootstrap.php 简化为单文件存储路径(ny_load_config/ny_save_config 直接读写 data/config.json),
删除 ny_db_config、配置迁移、配置双写分支
* lib/Db.php 改为纯 stub:pdo() 恒返回 null,避免上层误连;migrate/ensureRosterLogin 等为 no-op
* Roster.php 经 useDb 永远 false,自动走 roster.json
* api.php 不再返回 db_driver/db_connected/db_error 诊断
- 班级看板独立(dashboard.php 重写):
* 删除"← 返回总览"按钮 + 整体概况 #overview 区
* 进入即展示单班看板:6 张汇总卡 + 分组对比/成员榜
* 顶部 hero 区动态显示当前班级名 + 问卷名
* 工具栏新增 #classSel 班级选择器(切换班级不离开本页)
* URL 支持 ?pid=&class=&auto=1|0 分享与刷新恢复
* 下拉班级选项随数据动态刷新;保留自动刷新 + 全屏 + 缓存
- e2e 测试(test-config-save)加固 cleanup 钩子:
* 统一 cleanup() 函数接管所有退出路径
* 解决之前 e2e 留 residue 把 data/config.json 改成 mock 地址的问题(导致本机登录失败)
- php -l 全文件通过;node --check 内嵌 JS 通过;e2e test-config-save 27/27 + test-draw-alloc 29/29 全过
373 lines
15 KiB
PHP
373 lines
15 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">
|
||
<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>
|
||
|
||
<script>
|
||
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);
|
||
|
||
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;
|
||
throw new Error((j && (j.msg || j.error)) || '请求失败');
|
||
}
|
||
|
||
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
|
||
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) {
|
||
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'));
|
||
return '<tr>' +
|
||
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
||
'<td class="name">' + esc(m.name) + '</td>' +
|
||
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
|
||
'</tr>';
|
||
}).join('') +
|
||
'</tbody></table></div>';
|
||
}
|
||
|
||
/* ---------- 渲染单班看板 ---------- */
|
||
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 + ' · 班级看板';
|
||
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
||
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent;
|
||
|
||
// 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),以下按成绩从高到低排列</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 + '</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>
|