Files
ny-exam-platform/dashboard.php
T
nxxy335 8686eb99c7 feat: 接入PostgreSQL/MariaDB + 首次安装向导 + 登录页独立 + 设置重构 + 文件夹识别兜底
- 数据库层 lib/Db.php:统一 PDO 支持 pgsql/mysql/sqlite,连接参数存 data/db.json
  (git-ignored),migrate 建 ny_settings/ny_roster 表(三方言兼容 upsert)
- 配置/名单读写:bootstrap ny_load_config/ny_save_config 与 Roster 均改为
  '数据库可用则走 DB、否则回退 data/config.json/roster.json' 的文件降级模式
- 首次安装向导 install.php:选择存储后端(文件/PostgreSQL/MariaDB),测试连接+
  建表+写入卷王连接信息与默认满分;支持 ?force=1 重新配置
- 登录门分离:新增 login.php 独立登录页(仅需密码,账号预填)+logout.php+
  会话(ny_session_start/ny_is_auth/ny_login/ny_logout);index/dashboard 未安装
  跳 install、未登录跳 login;api.php 加会话保护与 auth_check
- 设置重构:删除卷王系统地址/接口前缀/登录账号/登录密码(连接信息由安装向导
  配置);新增布局配置(默认/成绩明细与缺考名单同行)与各试卷独立满分列表(默认
  分数作用于全部);工具栏布局下拉移除
- 文件夹识别兜底:app.js/dashboard.php 双策略 — mode 含 folder 优先,缺省时用
  parent_id 引用反推(应对部分版本无 mode 字段);空文件夹自动隐藏

验证:php -l 全部通过、node --check app.js 通过;SQLite 等价实测 Db 层
(migrate/config upsert/Roster 读写/ny_load_config)与会话辅助函数全部PASS;
e2e(内置服务器)验证路由:未装→install、装完→login、index/dashboard 未登录
跳转、api 401 保护、?force=1 重配、logout 302 均正确。本机无真实 pgsql/mysql
服务器,DB 连接按部署环境实测。
2026-09-09 07:15:56 +08:00

388 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (!ny_is_installed()) {
header('Location: install.php');
exit;
}
if (!ny_is_auth()) {
header('Location: login.php');
exit;
}
?>
<!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 class="dash">
<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>
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
</div>
</div>
<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 rateColor(r){
if (r >= 90) return 'var(--ok)';
if (r >= 60) return 'var(--warn)';
return 'var(--danger)';
}
/* 本地存储(缓存问卷列表/看板数据,重开即恢复) */
const LS = {
get(k, def){ try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); } catch(e){ return def; } },
set(k, v){ try { localStorage.setItem(k, JSON.stringify(v)); } catch(e){} },
del(k){ try { localStorage.removeItem(k); } catch(e){} }
};
const LS_PROJECTS = 'ny_projects';
const LS_DASH = pid => 'ny_dashboard_' + pid;
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 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)) || '请求失败');
}
/* ---------- 问卷列表:双策略识别文件夹 + 缓存优先 ---------- */
function renderProjects(list){
const sel = $('#projectSel');
const keep = sel.value;
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>';
// 双策略识别文件夹:mode 含 folder,或被其他项目作为 parent_id 引用
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
const parentIds = new Set();
list.forEach(p => { const pid = (p.parent_id || '0'); if (pid && pid !== '0' && pid !== '') parentIds.add(pid); });
const folderSet = new Set();
list.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
const childOf = fid => list.filter(p => !folderSet.has(p.id) && (p.parent_id || '0') === fid);
// 根项
list.filter(p => !folderSet.has(p.id)).forEach(p => {
const o = document.createElement('option');
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
sel.appendChild(o);
});
// 文件夹
list.filter(p => folderSet.has(p.id)).forEach(f => {
const kids = childOf(f.id);
if (!kids.length) return;
const og = document.createElement('optgroup');
og.label = '📁 ' + f.name;
kids.forEach(p => {
const o = document.createElement('option');
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
og.appendChild(o);
});
sel.appendChild(og);
});
if (keep && list.some(p => p.id === keep)) sel.value = keep;
else {
const last = LS.get('ny_dash_last_pid', '');
if (last && list.some(p => p.id === last)) sel.value = last;
}
}
async function loadProjects(){
// 1) 先读本地缓存
const cached = LS.get(LS_PROJECTS, []);
if (Array.isArray(cached) && cached.length) {
renderProjects(cached);
}
// 2) 后台异步刷新
try {
const list = await api('projects');
LS.set(LS_PROJECTS, list || []);
renderProjects(list || []);
} catch (e) {
if (!cached || !cached.length) {
$('#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 + '%' : '-');
$('#oAvg').textContent = o.avg_score != null ? o.avg_score : '-';
$('#oPass').textContent = (o.pass_rate != null ? o.pass_rate + '%' : '-');
}
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 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"><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('');
}
/* ---------- 成员榜:排名 / 姓名 / 成绩(默认成绩降序) ---------- */
function membersTable(members){
if (!members || !members.length) {
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
}
return '<div class="member-list"><table class="grid compact">' +
'<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
members.map(m => {
const rk = m.rank;
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
const sc = m.score;
const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low'));
return '<tr>' +
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
'<td class="name">' + esc(m.name) + '</td>' +
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
'</tr>';
}).join('') +
'</tbody></table></div>';
}
/* ---------- 班级详情:整体 + 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="gpanel">' +
'<div class="ghead"><strong>本班考试成员</strong>' +
'<span class="rate-num">' + c.submitted + ' 人</span></div>' +
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列</div>' +
membersTable(c.members) + '</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 class="mem-title">考试成员 · 按成绩降序(' + g.submitted + ' 人)</div>' +
membersTable(g.members) +
'</div>';
}).join('') + '</div>';
}
function backToOverview(){
CUR = null;
$('#detail').classList.add('hidden');
$('#overview').classList.remove('hidden');
}
async function loadDashboard(){
const pid = $('#projectSel').value;
if (!pid) {
$('#classGrid').innerHTML = '<div class="empty">请选择问卷 / 考试以查看各班数据</div>';
return;
}
// 1) 缓存优先:先渲染上次保存的看板,避免空白
const cached = LS.get(LS_DASH(pid), null);
if (cached && cached.classes) {
DASH = cached;
renderSummary(DASH);
renderClasses(DASH);
$('#lastUpdate').textContent = (cached.updated_at || '—') + '(缓存)';
if (CUR) {
const still = (DASH.classes || []).some(x => x.class === CUR);
if (still) showClassDetail(CUR); else backToOverview();
}
}
// 2) 后台拉真实数据
try {
const fresh = await api('dashboard', { params: { projectId: pid } });
DASH = fresh;
LS.set(LS_DASH(pid), fresh);
LS.set('ny_dash_last_pid', 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) {
if (!cached) {
$('#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;
const sec = parseInt($('#intervalSel').value, 10) || 15;
window.__timer = setInterval(loadDashboard, sec * 1000);
}
/* ---------- 事件 ---------- */
$('#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();
</script>
</body>
</html>