Files
ny-exam-platform/dashboard.php
T
nxxy335 64d0116c0e feat: 文件夹双策略识别 + 布局选项 + 移除提交率卡片 + localStorage缓存层
- 文件夹识别:mode 含 'folder' 优先,缺省时用 parentId 引用反推;
  即使卷王版本无 mode 字段也能正确分组,避免'直接没有文件夹选项'
- 布局选项:工具栏新增 #layoutSel(默认 / 成绩明细与缺考名单同行),
  用 CSS Grid 双模式渲染,窄屏回落单列;偏好写 localStorage
- 移除统计卡片中的提交率卡片(卡片数 9 → 8)
- localStorage 缓存层:问卷列表 / 每个项目数据 / 看板数据 / 班级组别
  筛选 / 布局 / 上次项目 id 全部持久化;进入页面先渲染缓存,
  后台再异步拉新数据并写回缓存;网络失败时仍能用本地缓存查看
- dashboard.php 同步:文件夹识别 / 数据缓存一致更新
2026-09-09 00:08:08 +08:00

379 lines
16 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
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>