Compare commits

...
2 Commits
5 changed files with 409 additions and 42 deletions
+70
View File
@@ -119,6 +119,76 @@ try {
];
ny_ok($result);
// ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ----------------
case 'dashboard':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$c = cfg();
$result = DataService::build($c, $projectId, $nameQid);
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$students = $result['roster'] ?: [];
$rows = $result['rows'] ?: [];
$byCls = [];
foreach ($students as $s) {
$k = trim((string)($s['cls'] ?? '')) ?: '(未分班)';
$byCls[$k][] = $s;
}
$rowsByCls = [];
foreach ($rows as $r) {
$k = trim((string)($r['cls'] ?? '')) ?: '(未分班)';
$rowsByCls[$k][] = $r;
}
$classes = [];
foreach ($byCls as $k => $stu) {
$rs = $rowsByCls[$k] ?? [];
$total = count($stu);
$submitted = count($rs);
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= 0.6 * $fs)));
$passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0;
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null));
$avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null;
$classes[] = [
'class' => $k,
'total' => $total,
'submitted' => $submitted,
'absent' => max(0, $total - $submitted),
'rate' => $total > 0 ? round($submitted / $total * 100, 1) : 0,
'avg' => $avg,
'pass_rate' => $passRate,
'max' => $scores !== [] ? max($scores) : null,
'min' => $scores !== [] ? min($scores) : null,
'avg_duration' => $avgDur,
];
}
usort($classes, static function (array $a, array $b): int {
$an = is_numeric($a['class']) ? (int)$a['class'] : $a['class'];
$bn = is_numeric($b['class']) ? (int)$b['class'] : $b['class'];
if (is_int($an) && is_int($bn)) {
return $an <=> $bn;
}
return strcmp((string)$a['class'], (string)$b['class']);
});
ny_ok([
'project' => $result['project'],
'full_score' => $fs,
'overall' => $result['stats'],
'classes' => $classes,
'updated_at' => $result['updated_at'],
]);
// ---------------- 名单 ----------------
case 'roster':
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
+22 -2
View File
@@ -475,10 +475,19 @@
function loginEnter() {
const gate = $('#loginGate');
if (gate) gate.classList.add('hidden');
try { sessionStorage.setItem('ny_authed', '1'); } catch (e) { /* 忽略隐私模式等异常 */ }
schedule();
loadProjects();
}
function logout() {
try { sessionStorage.removeItem('ny_authed'); } catch (e) {}
if (state.timer) clearInterval(state.timer);
const gate = $('#loginGate');
if (gate) gate.classList.remove('hidden');
showLoginGate();
}
/* ---------------- 事件绑定 ---------------- */
document.addEventListener('DOMContentLoaded', function () {
@@ -690,8 +699,19 @@
$('#lgTest').addEventListener('click', loginTest);
$('#lgLogin').addEventListener('click', loginEnter);
$('#lgPwd').addEventListener('keydown', e => { if (e.key === 'Enter') loginTest(); });
$('#btnLogout').addEventListener('click', logout);
// 启动:先显示登录门,不直接拉取数据(防止依赖一次配置直接查看数据)
showLoginGate();
// 启动:本会话已认证(未重启电脑)则直接进入主界面,跳过登录门;否则显示登录门
let authed = false;
try { authed = sessionStorage.getItem('ny_authed') === '1'; } catch (e) {}
if (authed) {
const gate = $('#loginGate');
if (gate) gate.classList.add('hidden');
schedule();
loadProjects();
} else {
// 先显示登录门,不直接拉取数据(防止依赖一次配置直接查看数据)
showLoginGate();
}
});
})();
+128 -30
View File
@@ -15,7 +15,9 @@
--danger: #e0483d;
--danger-weak: #fdecea;
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
--radius: 12px;
--radius-lg: 18px;
}
* { box-sizing: border-box; }
@@ -29,6 +31,13 @@ html, body {
-webkit-font-smoothing: antialiased;
}
/* 顶部柔和渐变,增强页面层次 */
body {
background-image: linear-gradient(180deg, rgba(47, 107, 255, .06), rgba(47, 107, 255, 0) 300px);
background-repeat: no-repeat;
background-attachment: fixed;
}
/* ---------- 顶栏 ---------- */
.topbar {
position: sticky;
@@ -39,8 +48,10 @@ html, body {
justify-content: space-between;
gap: 16px;
padding: 12px 24px;
background: #fff;
background: rgba(255, 255, 255, .92);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
box-shadow: 0 2px 12px rgba(20, 30, 60, .05);
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo {
@@ -87,18 +98,18 @@ html, body {
.btn:active { transform: translateY(1px); }
.btn:disabled,
.btn[disabled] {
opacity: .5;
opacity: .55;
cursor: not-allowed;
background: #eef1f6;
color: #a0aab8;
border-color: #d5dde8;
transform: none;
pointer-events: auto;
box-shadow: none;
}
.btn-primary:disabled,
.btn-primary[disabled] {
background: #b0c0d8;
border-color: #b0c0d8;
background: #c2c8d4;
border-color: #c2c8d4;
color: #fff;
}
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
@@ -111,20 +122,27 @@ html, body {
background: var(--primary-weak); color: var(--primary);
border-radius: 999px; font-size: 11px;
}
/* 链接当按钮用(如「数据看板」):继承按钮外观,避免被浏览器弹窗拦截 */
a.btn {
display: inline-flex; align-items: center; justify-content: center;
text-decoration: none; white-space: nowrap;
}
a.btn:hover { text-decoration: none; }
/* ---------- 布局 ---------- */
.wrap { max-width: 1440px; margin: 0 auto; padding: 20px 24px 48px; }
.wrap { max-width: 1460px; margin: 0 auto; padding: 22px 26px 52px; }
.toolbar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 14px;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow);
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px 18px;
background: linear-gradient(180deg, #ffffff, #fbfcff);
border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow);
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field.grow { flex: 1 1 260px; min-width: 220px; }
.field.grow-2 { flex: 1 1 200px; min-width: 180px; }
.field { display: flex; flex-direction: column; gap: 7px; min-width: 150px; }
.field.grow { flex: 1 1 280px; min-width: 220px; }
.field.grow-2 { flex: 1 1 210px; min-width: 180px; }
.field.btns { flex-direction: row; gap: 8px; align-items: center; }
.field label { font-size: 12px; color: var(--text-2); font-weight: 600; }
.field label { font-size: 12px; color: var(--text-2); font-weight: 600; letter-spacing: .3px; }
.inline { display: flex; align-items: center; gap: 10px; }
select, input[type=text], input[type=number], input[type=password] {
@@ -160,31 +178,54 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
/* ---------- 卡片 ---------- */
.cards {
display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 12px; margin-top: 16px;
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 14px; margin-top: 16px;
}
.card {
position: relative; overflow: hidden;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow);
display: flex; flex-direction: column; gap: 4px;
border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow);
display: flex; flex-direction: column; gap: 6px;
transition: transform .18s ease, box-shadow .18s ease;
}
.card::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--accent, var(--primary));
}
.card:hover { transform: translateY(-3px); box-shadow: var(--shadow-lg); }
.card span { font-size: 12px; color: var(--text-3); }
.card b { font-size: 24px; font-weight: 700; letter-spacing: .5px; white-space: nowrap; }
.card b { font-size: 25px; font-weight: 700; letter-spacing: .5px; white-space: nowrap; }
.card.ok b { color: var(--ok); }
.card.warn b { color: var(--warn); }
.card.accent-blue { --accent: #2f6bff; }
.card.accent-green { --accent: #12a86a; }
.card.accent-orange { --accent: #e08c1a; }
.card.accent-purple { --accent: #8b5cf6; }
.card.accent-teal { --accent: #0ea5a4; }
.card.accent-red { --accent: #e0483d; }
.card.accent-indigo { --accent: #4f6bed; }
.card.accent-gray { --accent: #94a3b8; }
/* ---------- 面板 ---------- */
.panel {
margin-top: 16px; background: var(--panel);
margin-top: 18px; background: var(--panel);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow); overflow: hidden;
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--border-2);
padding: 13px 18px; border-bottom: 1px solid var(--border-2);
background: linear-gradient(180deg, #ffffff, #fbfcfe);
}
.panel-head h2 { margin: 0; font-size: 15px; font-weight: 700; }
.panel-head h2 small { font-weight: 500; color: var(--text-3); font-size: 12px; margin-left: 8px; }
.panel-head h2 {
margin: 0; font-size: 15px; font-weight: 700;
display: flex; align-items: center; gap: 9px;
}
.panel-head h2::before {
content: ''; flex: none; width: 4px; height: 16px; border-radius: 3px;
background: linear-gradient(180deg, var(--primary), #6a4bff);
}
.panel-head h2 small { font-weight: 500; color: var(--text-3); font-size: 12px; margin-left: 0; }
.updated { font-size: 12px; color: var(--text-3); display: flex; align-items: center; gap: 6px; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; }
.dot.on { background: var(--ok); animation: pulse 1.6s infinite; }
@@ -201,16 +242,18 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
table.grid th {
position: sticky; top: 0; z-index: 2;
background: #fafbfe; color: var(--text-2);
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
text-align: left; font-weight: 600; font-size: 12px;
padding: 10px 14px; border-bottom: 1px solid var(--border);
padding: 11px 14px; border-bottom: 1px solid var(--border);
white-space: nowrap;
box-shadow: inset 0 -1px 0 var(--border);
}
table.grid th.sortable { cursor: pointer; user-select: none; }
table.grid th.sortable:hover { color: var(--primary); }
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
table.grid td { padding: 9px 14px; border-bottom: 1px solid var(--border-2); }
table.grid tbody tr:hover td { background: #fafbfe; }
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
table.grid tbody tr:hover td { background: var(--primary-weak); }
table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
@@ -304,17 +347,31 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
position: fixed; inset: 0; z-index: 200;
display: flex; align-items: center; justify-content: center;
padding: 24px; background: linear-gradient(135deg, #1b2440, #2f6bff);
overflow: hidden;
}
.login-gate::before,
.login-gate::after {
content: ''; position: absolute; border-radius: 50%;
background: rgba(255, 255, 255, .08); pointer-events: none;
}
.login-gate::before { width: 340px; height: 340px; top: -90px; right: -70px; }
.login-gate::after { width: 280px; height: 280px; bottom: -80px; left: -60px; background: rgba(255, 255, 255, .06); }
.login-gate.hidden { display: none; }
.login-box {
position: relative; overflow: hidden;
width: 420px; max-width: 100%;
background: #fff; border-radius: 16px; padding: 28px 26px;
background: #fff; border-radius: var(--radius-lg); padding: 30px 28px 28px;
box-shadow: 0 24px 64px rgba(15, 23, 42, .35);
}
.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
.login-tip { margin: 0 0 18px; font-size: 12px; color: var(--text-3); line-height: 1.5; }
.login-box::before {
content: ''; position: absolute; left: 0; right: 0; top: 0; height: 4px;
background: linear-gradient(90deg, #2f6bff, #6a4bff);
}
.login-brand { display: flex; align-items: center; gap: 12px; margin: 2px 0 8px; }
.login-brand .logo { width: 46px; height: 46px; border-radius: 13px; font-size: 18px; }
.login-tip { margin: 0 0 20px; font-size: 12px; color: var(--text-3); line-height: 1.6; }
.login-box .fi { margin-bottom: 14px; }
.login-actions { display: flex; gap: 10px; margin-top: 4px; }
.login-actions { display: flex; gap: 10px; margin-top: 6px; }
.login-actions .btn { flex: 1; }
.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
.login-msg.ok { color: var(--ok); }
@@ -378,3 +435,44 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
#rosterBody input { font-size: 12px; }
}
/* ---------- 数据看板 ---------- */
.dash-hero {
background: linear-gradient(135deg, #1b2440, #2f6bff);
color: #fff; padding: 20px 24px; border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
box-shadow: var(--shadow);
}
.dash-hero .brand strong { color: #fff; font-size: 17px; }
.dash-hero .brand small { color: rgba(255, 255, 255, .72); }
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
.dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.dash-controls select { width: 230px; height: 38px; }
.dash-controls .inline { color: #fff; }
.dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; }
.dash-controls .btn:hover { background: rgba(255, 255, 255, .24); }
.sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.class-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-top: 14px; }
.class-card {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px 18px; box-shadow: var(--shadow); transition: transform .18s, box-shadow .18s;
}
.class-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
.class-card h3 { margin: 0 0 2px; font-size: 16px; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.class-card .rate-num { font-size: 15px; font-weight: 800; white-space: nowrap; }
.class-card .sub { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
.class-metrics .m { display: flex; flex-direction: column; gap: 2px; }
.class-metrics .m span { color: var(--text-3); font-size: 11px; }
.class-metrics .m b { font-size: 17px; font-weight: 700; }
.metric-ok b { color: var(--ok); }
.metric-warn b { color: var(--warn); }
.metric-danger b { color: var(--danger); }
.empty { text-align: center; color: var(--text-3); padding: 40px 0; }
+177
View File
@@ -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 => ({'&':'&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)';
}
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>
+12 -10
View File
@@ -25,9 +25,11 @@ $rosterCount = Roster::count();
</div>
<div class="top-right">
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
<a class="btn" id="btnDashboard" href="dashboard.php" target="_blank" rel="noopener" title="在新标签页打开数据看板">📊 数据看板</a>
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
<button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button>
<button class="btn btn-primary" id="btnSettings">设置</button>
<button class="btn" id="btnLogout" title="退出登录">退出</button>
</div>
</header>
@@ -41,7 +43,7 @@ $rosterCount = Roster::count();
<small>实时数据统计</small>
</div>
</div>
<p class="login-tip">请输入卷王系统连接信息,完成连接测试后登录。密码需每次输入,避免依赖一次配置直接查看数据。</p>
<p class="login-tip">请输入系统连接信息,完成连接测试后登录。密码需每次输入,避免依赖一次配置直接查看数据。</p>
<div class="fi"><label>系统网址</label><input type="text" id="lgBase" placeholder="https://exam.nxxy335.top"></div>
<div class="fi"><label>API 路由</label><input type="text" id="lgPrefix" placeholder="/api"></div>
<div class="fi"><label>账号</label><input type="text" id="lgUser" placeholder="admin"></div>
@@ -99,15 +101,15 @@ $rosterCount = Roster::count();
<div id="alert" class="alert hidden"></div>
<section class="cards" id="statCards">
<div class="card"><span>名单总人数</span><b id="sTotal">-</b></div>
<div class="card ok"><span>已提交</span><b id="sSubmit">-</b></div>
<div class="card warn"><span>缺考</span><b id="sAbsent">-</b></div>
<div class="card"><span>提交率</span><b id="sRate">-</b></div>
<div class="card"><span>平均分</span><b id="sAvg">-</b></div>
<div class="card"><span>最高分</span><b id="sMax">-</b></div>
<div class="card"><span>最低分</span><b id="sMin">-</b></div>
<div class="card"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div>
<div class="card"><span>平均用时</span><b id="sDur">-</b></div>
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
<div class="card warn accent-orange"><span>缺考</span><b id="sAbsent">-</b></div>
<div class="card accent-purple"><span>提交率</span><b id="sRate">-</b></div>
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div>
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
</section>
<section class="panel">