- 文件夹识别:mode 含 'folder' 优先,缺省时用 parentId 引用反推; 即使卷王版本无 mode 字段也能正确分组,避免'直接没有文件夹选项' - 布局选项:工具栏新增 #layoutSel(默认 / 成绩明细与缺考名单同行), 用 CSS Grid 双模式渲染,窄屏回落单列;偏好写 localStorage - 移除统计卡片中的提交率卡片(卡片数 9 → 8) - localStorage 缓存层:问卷列表 / 每个项目数据 / 看板数据 / 班级组别 筛选 / 布局 / 上次项目 id 全部持久化;进入页面先渲染缓存, 后台再异步拉新数据并写回缓存;网络失败时仍能用本地缓存查看 - dashboard.php 同步:文件夹识别 / 数据缓存一致更新
379 lines
16 KiB
PHP
379 lines
16 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
require __DIR__ . '/bootstrap.php';
|
||
?>
|
||
<!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>NY问卷考试系统 · 数据看板</strong>
|
||
<small>班级整体情况 · 面向班主任与学生</small>
|
||
</div>
|
||
</div>
|
||
<div class="dash-controls">
|
||
<select id="projectSel"><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="overview">
|
||
<div class="sec-title">整体概况</div>
|
||
<section class="cards" id="summary">
|
||
<div class="card accent-blue"><span>名单总人数</span><b id="oTotal">-</b></div>
|
||
<div class="card ok accent-green"><span>已提交</span><b id="oSubmit">-</b></div>
|
||
<div class="card warn accent-orange"><span>缺考</span><b id="oAbsent">-</b></div>
|
||
<div class="card accent-purple"><span>提交率</span><b id="oRate">-</b></div>
|
||
<div class="card accent-teal"><span>平均分</span><b id="oAvg">-</b></div>
|
||
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="oPass">-</b></div>
|
||
</section>
|
||
|
||
<div class="sec-title">各班整体情况 <span class="tag-gray" id="classCount"></span>
|
||
<span class="hint" style="margin-left:auto;font-weight:500">点击班级卡片查看该班详情</span>
|
||
</div>
|
||
<div class="class-grid" id="classGrid">
|
||
<div class="empty">请选择问卷 / 考试以查看各班数据</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 班级详情 ================= -->
|
||
<div id="detail" class="hidden">
|
||
<div class="detail-head">
|
||
<button class="btn" id="btnBack">← 返回总览</button>
|
||
<h2 id="detailTitle">班级详情</h2>
|
||
<span class="tag-gray" id="detailSub"></span>
|
||
</div>
|
||
|
||
<section class="cards" id="detailSummary"></section>
|
||
|
||
<div class="sec-title" id="groupSecTitle">分组对比</div>
|
||
<div id="groupCompare"></div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
const $ = s => document.querySelector(s);
|
||
const $$ = s => Array.from(document.querySelectorAll(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)';
|
||
}
|
||
|
||
/* 本地存储(缓存问卷列表/看板数据,重开即恢复) */
|
||
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, JSON.stringify(v)); } catch(e){} },
|
||
del(k){ try { localStorage.removeItem(k); } catch(e){} }
|
||
};
|
||
const LS_PROJECTS = 'ny_projects';
|
||
const LS_DASH = pid => 'ny_dashboard_' + pid;
|
||
|
||
let DASH = null; // 最近一次看板数据
|
||
let CUR = null; // 当前查看的班级名
|
||
|
||
async function api(action, opts){
|
||
opts = opts || {};
|
||
let url = 'api.php?action=' + encodeURIComponent(action);
|
||
if (opts.params) {
|
||
for (const k in opts.params) url += '&' + k + '=' + encodeURIComponent(opts.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>';
|
||
// 双策略识别文件夹:mode 含 folder,或被其他项目作为 parent_id 引用
|
||
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 (keep && list.some(p => p.id === keep)) sel.value = keep;
|
||
else {
|
||
const last = LS.get('ny_dash_last_pid', '');
|
||
if (last && list.some(p => p.id === last)) sel.value = last;
|
||
}
|
||
}
|
||
|
||
async function loadProjects(){
|
||
// 1) 先读本地缓存
|
||
const cached = LS.get(LS_PROJECTS, []);
|
||
if (Array.isArray(cached) && cached.length) {
|
||
renderProjects(cached);
|
||
}
|
||
// 2) 后台异步刷新
|
||
try {
|
||
const list = await api('projects');
|
||
LS.set(LS_PROJECTS, list || []);
|
||
renderProjects(list || []);
|
||
} catch (e) {
|
||
if (!cached || !cached.length) {
|
||
$('#classGrid').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderSummary(d){
|
||
const o = d.overall || {};
|
||
$('#oTotal').textContent = o.roster_total ?? '-';
|
||
$('#oSubmit').textContent = o.submitted ?? '-';
|
||
$('#oAbsent').textContent = o.absent ?? '-';
|
||
$('#oRate').textContent = (o.submit_rate != null ? o.submit_rate + '%' : '-');
|
||
$('#oAvg').textContent = o.avg_score != null ? o.avg_score : '-';
|
||
$('#oPass').textContent = (o.pass_rate != null ? o.pass_rate + '%' : '-');
|
||
}
|
||
|
||
function renderClasses(d){
|
||
const classes = d.classes || [];
|
||
$('#classCount').textContent = classes.length ? classes.length + ' 个班级' : '';
|
||
if (!classes.length) {
|
||
$('#classGrid').innerHTML = '<div class="empty">暂无班级数据(名单未导入班级信息)</div>';
|
||
return;
|
||
}
|
||
$('#classGrid').innerHTML = classes.map(c => {
|
||
const rate = c.rate || 0;
|
||
const color = rateColor(rate);
|
||
const gs = (c.groups || []).filter(g => g.group !== '(未分组)');
|
||
const gtag = gs.length ? gs.map(g => esc(g.group) + '组').join(' / ') : '';
|
||
return '<div class="class-card clickable" data-cls="' + esc(c.class) + '">' +
|
||
'<h3><span>' + esc(c.class) + '</span><span class="rate-num" style="color:' + color + '">' + rate + '%</span></h3>' +
|
||
'<div class="sub">已提交 ' + c.submitted + ' / 总人数 ' + c.total + ' · 缺考 ' + c.absent + '</div>' +
|
||
'<div class="bar"><i style="width:' + rate + '%;background:' + color + '"></i></div>' +
|
||
'<div class="class-metrics">' +
|
||
'<div class="m"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
|
||
'<div class="m"><span>合格率</span><b>' + c.pass_rate + '%</b></div>' +
|
||
'<div class="m metric-ok"><span>最高分</span><b>' + (c.max != null ? c.max : '-') + '</b></div>' +
|
||
'<div class="m metric-danger"><span>最低分</span><b>' + (c.min != null ? c.min : '-') + '</b></div>' +
|
||
'</div>' +
|
||
(gtag ? '<div class="grp-tag">' + gtag + '</div>' : '') +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
/* ---------- 成员榜:排名 / 姓名 / 成绩(默认成绩降序) ---------- */
|
||
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>';
|
||
}
|
||
|
||
/* ---------- 班级详情:整体 + A/B 分组对比 ---------- */
|
||
function showClassDetail(name){
|
||
if (!DASH) return;
|
||
const c = (DASH.classes || []).find(x => x.class === name);
|
||
if (!c) return;
|
||
CUR = name;
|
||
|
||
$('#overview').classList.add('hidden');
|
||
$('#detail').classList.remove('hidden');
|
||
|
||
$('#detailTitle').textContent = c.class;
|
||
$('#detailSub').textContent = '总人数 ' + c.total + ' · 已提交 ' + c.submitted + ' · 缺考 ' + c.absent;
|
||
|
||
const color = rateColor(c.rate || 0);
|
||
$('#detailSummary').innerHTML =
|
||
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</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-blue"><span>总人数</span><b>' + c.total + '</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>' +
|
||
'<div class="card accent-green"><span>最高分</span><b>' + (c.max != null ? c.max : '-') + '</b></div>' +
|
||
'<div class="card accent-red"><span>最低分</span><b>' + (c.min != null ? c.min : '-') + '</b></div>';
|
||
|
||
// 分组对比:过滤掉“未分组”,按组名排序(A 在左,B 在右)
|
||
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
||
const box = $('#groupCompare');
|
||
if (!groups.length) {
|
||
$('#groupSecTitle').textContent = '考试成员';
|
||
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>';
|
||
return;
|
||
}
|
||
$('#groupSecTitle').textContent = '分组对比(' + groups.map(g => g.group).join(' vs ') + ')';
|
||
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 backToOverview(){
|
||
CUR = null;
|
||
$('#detail').classList.add('hidden');
|
||
$('#overview').classList.remove('hidden');
|
||
}
|
||
|
||
async function loadDashboard(){
|
||
const pid = $('#projectSel').value;
|
||
if (!pid) {
|
||
$('#classGrid').innerHTML = '<div class="empty">请选择问卷 / 考试以查看各班数据</div>';
|
||
return;
|
||
}
|
||
|
||
// 1) 缓存优先:先渲染上次保存的看板,避免空白
|
||
const cached = LS.get(LS_DASH(pid), null);
|
||
if (cached && cached.classes) {
|
||
DASH = cached;
|
||
renderSummary(DASH);
|
||
renderClasses(DASH);
|
||
$('#lastUpdate').textContent = (cached.updated_at || '—') + '(缓存)';
|
||
if (CUR) {
|
||
const still = (DASH.classes || []).some(x => x.class === CUR);
|
||
if (still) showClassDetail(CUR); else backToOverview();
|
||
}
|
||
}
|
||
|
||
// 2) 后台拉真实数据
|
||
try {
|
||
const fresh = await api('dashboard', { params: { projectId: pid } });
|
||
DASH = fresh;
|
||
LS.set(LS_DASH(pid), fresh);
|
||
LS.set('ny_dash_last_pid', pid);
|
||
renderSummary(DASH);
|
||
renderClasses(DASH);
|
||
$('#lastUpdate').textContent = DASH.updated_at || '-';
|
||
$('#liveDot').classList.add('on');
|
||
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
|
||
if (CUR) {
|
||
const still = (DASH.classes || []).some(x => x.class === CUR);
|
||
if (still) showClassDetail(CUR); else backToOverview();
|
||
}
|
||
} catch (e) {
|
||
if (!cached) {
|
||
$('#classGrid').innerHTML = '<div class="empty">数据加载失败:' + esc(e.message) + '</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 = document.exitFullscreen || document.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 ? '⛶ 退出全屏' : '⛶ 全屏';
|
||
}
|
||
|
||
function schedule(){
|
||
if (window.__timer) clearInterval(window.__timer);
|
||
if (!$('#autoRefresh').checked) return;
|
||
const sec = parseInt($('#intervalSel').value, 10) || 15;
|
||
window.__timer = setInterval(loadDashboard, sec * 1000);
|
||
}
|
||
|
||
/* ---------- 事件 ---------- */
|
||
$('#projectSel').addEventListener('change', () => { backToOverview(); loadDashboard(); });
|
||
$('#btnRefresh').addEventListener('click', loadDashboard);
|
||
$('#autoRefresh').addEventListener('change', schedule);
|
||
$('#intervalSel').addEventListener('change', schedule);
|
||
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
|
||
document.addEventListener('fullscreenchange', onFsChange);
|
||
document.addEventListener('webkitfullscreenchange', onFsChange);
|
||
$('#btnBack').addEventListener('click', backToOverview);
|
||
|
||
// 班级卡片点击进入详情
|
||
$('#classGrid').addEventListener('click', e => {
|
||
const card = e.target.closest('.class-card');
|
||
if (card && card.dataset.cls) showClassDetail(card.dataset.cls);
|
||
});
|
||
|
||
loadProjects().then(loadDashboard);
|
||
schedule();
|
||
</script>
|
||
</body>
|
||
</html>
|