Compare commits
2
Commits
d934cd27ac
...
6d14860b9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d14860b9c | ||
|
|
e111151504 |
@@ -119,6 +119,76 @@ try {
|
|||||||
];
|
];
|
||||||
ny_ok($result);
|
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':
|
case 'roster':
|
||||||
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
|
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
|
||||||
|
|||||||
+21
-1
@@ -475,10 +475,19 @@
|
|||||||
function loginEnter() {
|
function loginEnter() {
|
||||||
const gate = $('#loginGate');
|
const gate = $('#loginGate');
|
||||||
if (gate) gate.classList.add('hidden');
|
if (gate) gate.classList.add('hidden');
|
||||||
|
try { sessionStorage.setItem('ny_authed', '1'); } catch (e) { /* 忽略隐私模式等异常 */ }
|
||||||
schedule();
|
schedule();
|
||||||
loadProjects();
|
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 () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
@@ -690,8 +699,19 @@
|
|||||||
$('#lgTest').addEventListener('click', loginTest);
|
$('#lgTest').addEventListener('click', loginTest);
|
||||||
$('#lgLogin').addEventListener('click', loginEnter);
|
$('#lgLogin').addEventListener('click', loginEnter);
|
||||||
$('#lgPwd').addEventListener('keydown', e => { if (e.key === 'Enter') loginTest(); });
|
$('#lgPwd').addEventListener('keydown', e => { if (e.key === 'Enter') loginTest(); });
|
||||||
|
$('#btnLogout').addEventListener('click', logout);
|
||||||
|
|
||||||
// 启动:先显示登录门,不直接拉取数据(防止依赖一次配置直接查看数据)
|
// 启动:本会话已认证(未重启电脑)则直接进入主界面,跳过登录门;否则显示登录门
|
||||||
|
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();
|
showLoginGate();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+128
-30
@@ -15,7 +15,9 @@
|
|||||||
--danger: #e0483d;
|
--danger: #e0483d;
|
||||||
--danger-weak: #fdecea;
|
--danger-weak: #fdecea;
|
||||||
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
|
--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: 12px;
|
||||||
|
--radius-lg: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -29,6 +31,13 @@ html, body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-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 {
|
.topbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -39,8 +48,10 @@ html, body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, .92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 2px 12px rgba(20, 30, 60, .05);
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; gap: 12px; }
|
.brand { display: flex; align-items: center; gap: 12px; }
|
||||||
.logo {
|
.logo {
|
||||||
@@ -87,18 +98,18 @@ html, body {
|
|||||||
.btn:active { transform: translateY(1px); }
|
.btn:active { transform: translateY(1px); }
|
||||||
.btn:disabled,
|
.btn:disabled,
|
||||||
.btn[disabled] {
|
.btn[disabled] {
|
||||||
opacity: .5;
|
opacity: .55;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
background: #eef1f6;
|
background: #eef1f6;
|
||||||
color: #a0aab8;
|
color: #a0aab8;
|
||||||
border-color: #d5dde8;
|
border-color: #d5dde8;
|
||||||
transform: none;
|
transform: none;
|
||||||
pointer-events: auto;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.btn-primary:disabled,
|
.btn-primary:disabled,
|
||||||
.btn-primary[disabled] {
|
.btn-primary[disabled] {
|
||||||
background: #b0c0d8;
|
background: #c2c8d4;
|
||||||
border-color: #b0c0d8;
|
border-color: #c2c8d4;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.btn-primary { background: var(--primary); border-color: var(--primary); 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);
|
background: var(--primary-weak); color: var(--primary);
|
||||||
border-radius: 999px; font-size: 11px;
|
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 {
|
.toolbar {
|
||||||
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 14px;
|
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px 18px;
|
||||||
background: var(--panel); border: 1px solid var(--border);
|
background: linear-gradient(180deg, #ffffff, #fbfcff);
|
||||||
border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow);
|
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 { display: flex; flex-direction: column; gap: 7px; min-width: 150px; }
|
||||||
.field.grow { flex: 1 1 260px; min-width: 220px; }
|
.field.grow { flex: 1 1 280px; min-width: 220px; }
|
||||||
.field.grow-2 { flex: 1 1 200px; min-width: 180px; }
|
.field.grow-2 { flex: 1 1 210px; min-width: 180px; }
|
||||||
.field.btns { flex-direction: row; gap: 8px; align-items: center; }
|
.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; }
|
.inline { display: flex; align-items: center; gap: 10px; }
|
||||||
|
|
||||||
select, input[type=text], input[type=number], input[type=password] {
|
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 {
|
.cards {
|
||||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
gap: 12px; margin-top: 16px;
|
gap: 14px; margin-top: 16px;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
background: var(--panel); border: 1px solid var(--border);
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow);
|
border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow);
|
||||||
display: flex; flex-direction: column; gap: 4px;
|
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 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.ok b { color: var(--ok); }
|
||||||
.card.warn b { color: var(--warn); }
|
.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 {
|
.panel {
|
||||||
margin-top: 16px; background: var(--panel);
|
margin-top: 18px; background: var(--panel);
|
||||||
border: 1px solid var(--border); border-radius: var(--radius);
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow); overflow: hidden;
|
box-shadow: var(--shadow); overflow: hidden;
|
||||||
}
|
}
|
||||||
.panel-head {
|
.panel-head {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
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 {
|
||||||
.panel-head h2 small { font-weight: 500; color: var(--text-3); font-size: 12px; margin-left: 8px; }
|
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; }
|
.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 { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; }
|
||||||
.dot.on { background: var(--ok); animation: pulse 1.6s infinite; }
|
.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 { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
table.grid th {
|
table.grid th {
|
||||||
position: sticky; top: 0; z-index: 2;
|
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;
|
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;
|
white-space: nowrap;
|
||||||
|
box-shadow: inset 0 -1px 0 var(--border);
|
||||||
}
|
}
|
||||||
table.grid th.sortable { cursor: pointer; user-select: none; }
|
table.grid th.sortable { cursor: pointer; user-select: none; }
|
||||||
table.grid th.sortable:hover { color: var(--primary); }
|
table.grid th.sortable:hover { color: var(--primary); }
|
||||||
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
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 td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
|
||||||
table.grid tbody tr:hover td { background: #fafbfe; }
|
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; }
|
table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
|
||||||
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
|
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
|
||||||
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
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;
|
position: fixed; inset: 0; z-index: 200;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
padding: 24px; background: linear-gradient(135deg, #1b2440, #2f6bff);
|
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-gate.hidden { display: none; }
|
||||||
.login-box {
|
.login-box {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
width: 420px; max-width: 100%;
|
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);
|
box-shadow: 0 24px 64px rgba(15, 23, 42, .35);
|
||||||
}
|
}
|
||||||
.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
.login-box::before {
|
||||||
.login-tip { margin: 0 0 18px; font-size: 12px; color: var(--text-3); line-height: 1.5; }
|
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-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-actions .btn { flex: 1; }
|
||||||
.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
|
.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
|
||||||
.login-msg.ok { color: var(--ok); }
|
.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; }
|
#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
@@ -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>
|
||||||
@@ -25,9 +25,11 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="top-right">
|
<div class="top-right">
|
||||||
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
|
<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="btnFullscreen" title="切换全屏">⛶ 全屏</button>
|
||||||
<button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button>
|
<button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button>
|
||||||
<button class="btn btn-primary" id="btnSettings">设置</button>
|
<button class="btn btn-primary" id="btnSettings">设置</button>
|
||||||
|
<button class="btn" id="btnLogout" title="退出登录">退出</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ $rosterCount = Roster::count();
|
|||||||
<small>实时数据统计</small>
|
<small>实时数据统计</small>
|
||||||
</div>
|
</div>
|
||||||
</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>系统网址</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>API 路由</label><input type="text" id="lgPrefix" placeholder="/api"></div>
|
||||||
<div class="fi"><label>账号</label><input type="text" id="lgUser" placeholder="admin"></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>
|
<div id="alert" class="alert hidden"></div>
|
||||||
|
|
||||||
<section class="cards" id="statCards">
|
<section class="cards" id="statCards">
|
||||||
<div class="card"><span>名单总人数</span><b id="sTotal">-</b></div>
|
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
|
||||||
<div class="card ok"><span>已提交</span><b id="sSubmit">-</b></div>
|
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
|
||||||
<div class="card warn"><span>缺考</span><b id="sAbsent">-</b></div>
|
<div class="card warn accent-orange"><span>缺考</span><b id="sAbsent">-</b></div>
|
||||||
<div class="card"><span>提交率</span><b id="sRate">-</b></div>
|
<div class="card accent-purple"><span>提交率</span><b id="sRate">-</b></div>
|
||||||
<div class="card"><span>平均分</span><b id="sAvg">-</b></div>
|
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
|
||||||
<div class="card"><span>最高分</span><b id="sMax">-</b></div>
|
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
|
||||||
<div class="card"><span>最低分</span><b id="sMin">-</b></div>
|
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
||||||
<div class="card"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div>
|
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div>
|
||||||
<div class="card"><span>平均用时</span><b id="sDur">-</b></div>
|
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|||||||
Reference in New Issue
Block a user