feat: 登录态持久化 + 禁用按钮灰色 + 登录提示精简 + 全局美化 + 独立数据看板页
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
||||
<?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>
|
||||
|
||||
<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>
|
||||
<a class="back" href="index.php">← 返回主系统</a>
|
||||
</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;">
|
||||
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||
</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 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)';
|
||||
}
|
||||
|
||||
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 || '网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects(){
|
||||
try {
|
||||
const list = await api('projects');
|
||||
const sel = $('#projectSel');
|
||||
const keep = sel.value;
|
||||
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>' +
|
||||
list.map(p => '<option value="' + esc(p.id) + '">' + esc(p.name) + '(' + esc(p.mode || '') + ')</option>').join('');
|
||||
if (keep) sel.value = keep;
|
||||
} catch (e) {
|
||||
$('#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 : '-') + (o.submit_rate != null ? '%' : '');
|
||||
$('#oAvg').textContent = o.avg_score != null ? o.avg_score : '-';
|
||||
$('#oPass').textContent = (o.pass_rate != null ? o.pass_rate : '-') + (o.pass_rate != null ? '%' : '');
|
||||
}
|
||||
|
||||
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 passCls = c.pass_rate >= 60 ? 'metric-ok' : (c.pass_rate > 0 ? 'metric-warn' : '');
|
||||
return '<div class="class-card">' +
|
||||
'<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 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>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadDashboard(){
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) {
|
||||
$('#classGrid').innerHTML = '<div class="empty">请选择问卷 / 考试以查看各班数据</div>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const d = await api('dashboard', { params: { projectId: pid } });
|
||||
renderSummary(d);
|
||||
renderClasses(d);
|
||||
$('#lastUpdate').textContent = d.updated_at || '-';
|
||||
$('#liveDot').classList.add('on');
|
||||
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
|
||||
} catch (e) {
|
||||
$('#classGrid').innerHTML = '<div class="empty">数据加载失败:' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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', loadDashboard);
|
||||
$('#btnRefresh').addEventListener('click', loadDashboard);
|
||||
$('#autoRefresh').addEventListener('change', schedule);
|
||||
$('#intervalSel').addEventListener('change', schedule);
|
||||
|
||||
loadProjects().then(loadDashboard);
|
||||
schedule();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user