diff --git a/api.php b/api.php index 43b900c..351b230 100644 --- a/api.php +++ b/api.php @@ -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()]); diff --git a/assets/app.js b/assets/app.js index 4e1e497..dc0e8f2 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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,20 @@ $('#lgTest').addEventListener('click', loginTest); $('#lgLogin').addEventListener('click', loginEnter); $('#lgPwd').addEventListener('keydown', e => { if (e.key === 'Enter') loginTest(); }); + $('#btnLogout').addEventListener('click', logout); + $('#btnDashboard').addEventListener('click', () => window.open('dashboard.php', '_blank')); - // 启动:先显示登录门,不直接拉取数据(防止依赖一次配置直接查看数据) - 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(); + } }); })(); diff --git a/assets/style.css b/assets/style.css index 101c11f..614327e 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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; } @@ -39,8 +41,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 +91,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; } @@ -160,18 +164,33 @@ 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 { @@ -304,17 +323,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 +411,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; } + diff --git a/dashboard.php b/dashboard.php new file mode 100644 index 0000000..7c71c58 --- /dev/null +++ b/dashboard.php @@ -0,0 +1,177 @@ + + + +
+ + +请输入卷王系统连接信息,完成连接测试后登录。密码需每次输入,避免依赖一次配置直接查看数据。
+请输入系统连接信息,完成连接测试后登录。密码需每次输入,避免依赖一次配置直接查看数据。
@@ -99,15 +101,15 @@ $rosterCount = Roster::count();