feat: 看板支持A/B组对比下钻 + 全屏 + 删除返回按钮 + 全系统组别(A/B)支持

This commit is contained in:
2026-09-08 19:44:45 +08:00
parent 6d14860b9c
commit 1b7683baab
7 changed files with 251 additions and 77 deletions
+146 -44
View File
@@ -33,63 +33,73 @@ require __DIR__ . '/bootstrap.php';
<option value="60">60 秒</option>
</select>
<button class="btn" id="btnRefresh">刷新</button>
<a class="back" href="index.php">← 返回主系统</a>
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
</div>
</div>
<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></div>
<div class="class-grid" id="classGrid">
<div class="empty">请选择问卷 / 考试以查看各班数据</div>
</div>
<div style="margin-top:18px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;">
<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 fmtDur(sec){
if (sec == null) return '-';
if (sec < 60) return sec + ' 秒';
const m = Math.floor(sec / 60), s = sec % 60;
if (m < 60) return m + ' 分 ' + String(s).padStart(2, '0') + ' 秒';
const h = Math.floor(m / 60);
return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分';
}
function rateColor(r){
if (r >= 90) return 'var(--ok)';
if (r >= 60) return 'var(--warn)';
return 'var(--danger)';
}
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 init = { method: 'GET', headers: { 'Accept': 'application/json' } };
try {
const res = await fetch(url, init);
const j = await res.json();
if (j && j.ok) return j.data;
throw new Error((j && (j.msg || j.error)) || '请求失败');
} catch (e) {
throw new Error(e.message || '网络错误');
}
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)) || '请求失败');
}
async function loadProjects(){
@@ -110,9 +120,9 @@ function renderSummary(d){
$('#oTotal').textContent = o.roster_total ?? '-';
$('#oSubmit').textContent = o.submitted ?? '-';
$('#oAbsent').textContent = o.absent ?? '-';
$('#oRate').textContent = (o.submit_rate != null ? o.submit_rate : '-') + (o.submit_rate != null ? '%' : '');
$('#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 : '-') + (o.pass_rate != null ? '%' : '');
$('#oPass').textContent = (o.pass_rate != null ? o.pass_rate + '%' : '-');
}
function renderClasses(d){
@@ -125,21 +135,79 @@ function renderClasses(d){
$('#classGrid').innerHTML = classes.map(c => {
const rate = c.rate || 0;
const color = rateColor(rate);
const passCls = c.pass_rate >= 60 ? 'metric-ok' : (c.pass_rate > 0 ? 'metric-warn' : '');
return '<div class="class-card">' +
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 ' + passCls + '"><span>合格率</span><b>' + c.pass_rate + '%</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('');
}
/* ---------- 班级详情:整体 + 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="empty">该班级未设置分组(A/B),仅显示整体数据</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>';
}).join('') + '</div>';
}
function backToOverview(){
CUR = null;
$('#detail').classList.add('hidden');
$('#overview').classList.remove('hidden');
}
async function loadDashboard(){
const pid = $('#projectSel').value;
if (!pid) {
@@ -147,17 +215,40 @@ async function loadDashboard(){
return;
}
try {
const d = await api('dashboard', { params: { projectId: pid } });
renderSummary(d);
renderClasses(d);
$('#lastUpdate').textContent = d.updated_at || '-';
DASH = await api('dashboard', { params: { projectId: 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) {
$('#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;
@@ -165,10 +256,21 @@ function schedule(){
window.__timer = setInterval(loadDashboard, sec * 1000);
}
$('#projectSel').addEventListener('change', loadDashboard);
/* ---------- 事件 ---------- */
$('#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();