refactor: 取消 install.php 安装向导 + 下线 pgsql/mysql 支持 + 班级看板独立

- 删除 install.php:所有入口(index/dashboard/draw/login/api)不再跳安装,
  未登录统一跳 login.php;连接信息变更在【设置】里直接修改 data/config.json
- 下线 pgsql/mysql:
  * bootstrap.php 简化为单文件存储路径(ny_load_config/ny_save_config 直接读写 data/config.json),
    删除 ny_db_config、配置迁移、配置双写分支
  * lib/Db.php 改为纯 stub:pdo() 恒返回 null,避免上层误连;migrate/ensureRosterLogin 等为 no-op
  * Roster.php 经 useDb 永远 false,自动走 roster.json
  * api.php 不再返回 db_driver/db_connected/db_error 诊断
- 班级看板独立(dashboard.php 重写):
  * 删除"← 返回总览"按钮 + 整体概况 #overview 区
  * 进入即展示单班看板:6 张汇总卡 + 分组对比/成员榜
  * 顶部 hero 区动态显示当前班级名 + 问卷名
  * 工具栏新增 #classSel 班级选择器(切换班级不离开本页)
  * URL 支持 ?pid=&class=&auto=1|0 分享与刷新恢复
  * 下拉班级选项随数据动态刷新;保留自动刷新 + 全屏 + 缓存
- e2e 测试(test-config-save)加固 cleanup 钩子:
  * 统一 cleanup() 函数接管所有退出路径
  * 解决之前 e2e 留 residue 把 data/config.json 改成 mock 地址的问题(导致本机登录失败)
- php -l 全文件通过;node --check 内嵌 JS 通过;e2e test-config-save 27/27 + test-draw-alloc 29/29 全过
This commit is contained in:
2026-09-09 14:04:59 +08:00
parent 6cd17b9cb2
commit 3952c77afe
9 changed files with 246 additions and 766 deletions
+2 -6
View File
@@ -13,11 +13,7 @@ function cfg(): array
return ny_load_config();
}
// 会话/安装保护:除 auth_check 外,未安装或未登录一律拒访
if (!ny_is_installed()) {
ny_json(['ok' => false, 'msg' => '系统尚未安装,请先完成安装向导'], 503);
exit;
}
// 会话保护:除 auth_check 外,未登录一律拒访
if ($action !== 'auth_check' && !ny_is_auth()) {
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
exit;
@@ -56,7 +52,7 @@ try {
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
}
}
// 密码:与 install.php 一致不做 trim(保留首尾空格的原貌),空 / 占位符 = 不修改
// 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
if (array_key_exists('password', $in)) {
$pwdIn = (string)$in['password'];
if ($pwdIn !== '' && $pwdIn !== '******') {
+3 -66
View File
@@ -80,17 +80,13 @@ function ny_logout(): void
session_destroy();
}
/** 判断是否已安装(有过连接配置且能流转):文件 config 或 DB settings 存在 base_url 且账号非空 */
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
function ny_is_installed(): bool
{
$cfg = ny_load_config();
if (trim((string)($cfg['base_url'] ?? '')) === '') {
return false;
}
// 有无可用的后端(文件 config 存在 ,或 DB 连得上)
if (class_exists('Db') && Db::isConnected()) {
return true;
}
return is_file(ny_config_file());
}
@@ -145,21 +141,7 @@ function ny_load_config(): array
{
$GLOBALS['__ny_cfg_source'] = 'default';
// 数据库已初始化且能连上 → 从 DB 读,否则回退文件
$dbCfg = ny_db_config();
if ($dbCfg !== null) {
$cfg = ny_default_config();
foreach ($cfg as $k => $v) {
if (array_key_exists($k, $dbCfg)) {
$cfg[$k] = is_bool($v) ? (bool)$dbCfg[$k] : $dbCfg[$k];
}
}
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
$GLOBALS['__ny_cfg_source'] = 'db';
return $cfg;
}
// 默认配置 + 文件覆盖(DB 后端本批次已下线,统一走文件存储)
$cfg = ny_default_config();
$saved = ny_read_json(ny_config_file(), null);
if (is_array($saved)) {
@@ -169,15 +151,6 @@ function ny_load_config(): array
}
}
$GLOBALS['__ny_cfg_source'] = 'file';
// DB 已连上但库里没有配置,而文件里有 —— 迁移一份到 DB,避免两端不一致
if (class_exists('Db') && Db::pdo() !== null && empty($GLOBALS['__ny_cfg_migrated'])) {
$GLOBALS['__ny_cfg_migrated'] = true;
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
} catch (Throwable $e) {
// 迁移失败不影响本次使用
}
}
}
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
@@ -186,44 +159,8 @@ function ny_load_config(): array
function ny_save_config(array $cfg): bool
{
// 先写文件快照:DB 万一不可用(连不上 / 未初始化)时不会静默回退到出厂默认配置
$fileOk = ny_write_json(ny_config_file(), $cfg);
if (class_exists('Db')) {
$db = Db::pdo();
if ($db !== null) {
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
$GLOBALS['__ny_cfg_source'] = 'db';
return true;
} catch (Throwable $e) {
// 落库失败则回退文件
$GLOBALS['__ny_cfg_source'] = 'file';
return $fileOk;
}
}
}
$GLOBALS['__ny_cfg_source'] = 'file';
return $fileOk;
}
/** 数据库初始化后从 DB 读取配置(无则返回 null) */
function ny_db_config(): ?array
{
if (!class_exists('Db')) {
return null;
}
$db = Db::pdo();
if ($db === null) {
return null;
}
try {
$raw = (string)(Db::settingsAll()['config'] ?? '');
} catch (Throwable $e) {
return null;
}
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
return ny_write_json(ny_config_file(), $cfg);
}
/** 安全的字符串化(避免数组/对象转字符串报错) */
+165 -180
View File
@@ -1,11 +1,11 @@
<?php
/**
* 班级数据看板(独立页面):每个班级一张专属看板,无概览、无返回按钮。
* URL 支持 ?pid=<projectId>&class=<班级名> 分享与刷新恢复;缺省自动选第一个班级。
*/
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;
@@ -16,7 +16,7 @@ if (!ny_is_auth()) {
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>数据看板 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<title>班级看板 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body class="dash">
@@ -27,12 +27,13 @@ if (!ny_is_auth()) {
<div class="brand">
<span class="logo">NY</span>
<div class="brand-text">
<strong>NY问卷考试系统 · 数据看板</strong>
<small>班级整体情况 · 面向班主任与学生</small>
<strong id="heroTitle">班级看板</strong>
<small id="heroSub">选择一个问卷与班级查看考试整体情况</small>
</div>
</div>
<div class="dash-controls">
<select id="projectSel"><option value="">请选择问卷 / 考试</option></select>
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
<select id="classSel" style="min-width:140px" disabled><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>
@@ -50,45 +51,28 @@ if (!ny_is_auth()) {
最后更新:<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>
<!-- ================= 班级看板(单班,无概览) ================= -->
<div id="board">
<!-- 6 个汇总卡片 -->
<section class="cards" id="boardSummary">
<div class="card accent-blue"><span>总人数</span><b id="bTotal">-</b></div>
<div class="card ok accent-green"><span>已提交</span><b id="bSubmit">-</b></div>
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
<div class="card accent-purple"><span>提交率</span><b id="bRate">-</b></div>
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="bPass">-</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 class="sec-title">分组对比 / 考试成员</div>
<div id="boardBody">
<div class="empty">请选择问卷与班级查看考试整体情况</div>
</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)';
@@ -96,50 +80,60 @@ function rateColor(r){
return 'var(--danger)';
}
/* 本地存储(缓存问卷列表/看板数据,重开即恢复) */
/* URL 状态:?pid=&class=&auto=1|0 */
function readUrl(){
const u = new URL(location.href);
return {
pid: u.searchParams.get('pid') || '',
cls: u.searchParams.get('class') || '',
auto: u.searchParams.get('auto'),
};
}
function writeUrl({pid, cls, auto}){
const u = new URL(location.href);
if (pid !== undefined) u.searchParams.set('pid', pid);
if (cls !== undefined) u.searchParams.set('class', cls);
if (auto !== undefined) u.searchParams.set('auto', auto ? '1' : '0');
history.replaceState(null, '', u.pathname + (u.search ? u.search : ''));
}
/* 本地缓存 */
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){} }
set(k, v){ try { localStorage.setItem(k, v); } catch(e){} },
};
const LS_PROJECTS = 'ny_projects';
const LS_DASH = pid => 'ny_dashboard_' + pid;
const LS_BOARD = (pid, cls) => 'ny_board_' + pid + '_' + encodeURIComponent(cls);
let DASH = null; // 最近一次看板数据
let CUR = null; // 当前查看的班级名
let DASH = null;
let CUR = readUrl();
async function api(action, opts){
opts = opts || {};
/* ---------- API ---------- */
async function api(action, params){
let url = 'api.php?action=' + encodeURIComponent(action);
if (opts.params) {
for (const k in opts.params) url += '&' + k + '=' + encodeURIComponent(opts.params[k]);
}
if (params) for (const k in params) url += '&' + k + '=' + encodeURIComponent(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;
@@ -152,69 +146,65 @@ function renderProjects(list){
});
sel.appendChild(og);
});
if (keep && list.some(p => p.id === keep)) sel.value = keep;
if (CUR.pid && list.some(p => p.id === CUR.pid)) sel.value = CUR.pid;
else if (keep && list.some(p => p.id === keep)) sel.value = keep;
else {
const last = LS.get('ny_dash_last_pid', '');
const last = LS.get('ny_board_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) 后台异步刷新
if (Array.isArray(cached) && cached.length) renderProjects(cached);
try {
const list = await api('projects');
LS.set(LS_PROJECTS, list || []);
LS.set(LS_PROJECTS, JSON.stringify(list || []));
renderProjects(list || []);
refreshClassSel(); // 项目变了刷新班级列表
} catch (e) {
if (!cached || !cached.length) {
$('#classGrid').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
$('#boardBody').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>';
/* ---------- 班级列表(来自 roster) ---------- */
async function refreshClassSel(){
const sel = $('#classSel');
const pid = $('#projectSel').value;
if (!pid) {
sel.innerHTML = '<option value="">请先选问卷</option>';
sel.disabled = true;
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('');
// 项目数据来自 api (dashboard),先调用一次拿班级
try {
const data = await api('dashboard', { projectId: pid });
DASH = data;
const classes = (data.classes || []).map(c => c.class);
sel.innerHTML = classes.length
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
: '<option value="">暂无班级</option>';
sel.disabled = !classes.length;
// 默认 class:URL 指定 > 上次选择 > 第一个
if (CUR.cls && classes.includes(CUR.cls)) {
sel.value = CUR.cls;
} else {
const last = LS.get('ny_board_last_cls_' + pid, '');
if (last && classes.includes(last)) sel.value = last;
else if (classes.length) sel.value = classes[0];
CUR.cls = sel.value;
}
renderBoard();
} catch (e) {
sel.innerHTML = '<option value="">加载失败</option>';
sel.disabled = true;
$('#boardBody').innerHTML = '<div class="empty">加载失败:' + esc(e.message) + '</div>';
}
}
/* ---------- 成员榜:排名 / 姓名 / 成绩(默认成绩降序) ---------- */
/* ---------- 成员榜 ---------- */
function membersTable(members){
if (!members || !members.length) {
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
@@ -235,43 +225,55 @@ function membersTable(members){
'</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;
/* ---------- 渲染单班看板 ---------- */
function renderBoard(){
const pid = $('#projectSel').value;
const cls = $('#classSel').value;
CUR.pid = pid; CUR.cls = cls;
writeUrl({ pid, cls });
$('#overview').classList.add('hidden');
$('#detail').classList.remove('hidden');
if (!pid || !cls || !DASH) {
$('#boardSummary').innerHTML = '<div class="card accent-blue"><span>总人数</span><b>-</b></div>'.repeat(0) ||
'<div class="empty">请选择问卷与班级</div>';
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
$('#heroTitle').textContent = '班级看板';
$('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况';
return;
}
LS.set('ny_board_last_pid', JSON.stringify(pid));
LS.set('ny_board_last_cls_' + pid, JSON.stringify(cls));
$('#detailTitle').textContent = c.class;
$('#detailSub').textContent = '总人数 ' + c.total + ' · 已提交 ' + c.submitted + ' · 缺考 ' + c.absent;
const c = (DASH.classes || []).find(x => x.class === cls);
if (!c) {
$('#boardBody').innerHTML = '<div class="empty">该班级无数据(名单中无此班级)</div>';
return;
}
// 顶部品牌区:动态标题
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
$('#heroTitle').textContent = cls + ' · 班级看板';
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent;
// 6 卡汇总
const color = rateColor(c.rate || 0);
$('#detailSummary').innerHTML =
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
$('#boardSummary').innerHTML =
'<div class="card accent-blue"><span>总人数</span><b>' + c.total + '</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-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</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>';
'<div class="card accent-indigo"><span>合格率</span><b>' + c.pass_rate + '%</b></div>';
// 分组对比:过滤掉“未分组”,按组名排序(A 在左,B 在右)
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
const box = $('#groupCompare');
const box = $('#boardBody');
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 ') + ')';
} else {
box.innerHTML = '<div class="group-compare">' + groups.map(g => {
const gc = rateColor(g.rate || 0);
return '<div class="gpanel">' +
@@ -290,53 +292,6 @@ function showClassDetail(name){
'</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>';
}
}
}
/* ---------- 全屏 ---------- */
@@ -346,7 +301,7 @@ function toggleFullscreen(){
const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) req.call(el);
} else {
const ex = document.exitFullscreen || document.webkitExitFullscreen;
const ex = el.exitFullscreen || el.webkitExitFullscreen;
if (ex) ex.call(document);
}
}
@@ -357,31 +312,61 @@ function onFsChange(){
if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
}
/* ---------- 自动刷新 ---------- */
let timer = null;
function schedule(){
if (window.__timer) clearInterval(window.__timer);
if (timer) { clearInterval(timer); timer = null; }
if (!$('#autoRefresh').checked) return;
const sec = parseInt($('#intervalSel').value, 10) || 15;
window.__timer = setInterval(loadDashboard, sec * 1000);
timer = setInterval(refreshBoard, sec * 1000);
}
async function refreshBoard(){
const pid = $('#projectSel').value;
if (!pid) return;
try {
const fresh = await api('dashboard', { projectId: pid });
DASH = fresh;
// 项目集合可能换:刷新班级下拉但保留当前选择
const sel = $('#classSel');
const keep = sel.value;
const classes = (fresh.classes || []).map(c => c.class);
if (!classes.includes(keep)) {
sel.innerHTML = classes.length
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
: '<option value="">暂无班级</option>';
sel.disabled = !classes.length;
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
}
renderBoard();
$('#lastUpdate').textContent = fresh.updated_at || '-';
$('#liveDot').classList.add('on');
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
} catch (e) {
/* 静默续期:保留旧数据不打扰 */
}
}
/* ---------- 事件 ---------- */
$('#projectSel').addEventListener('change', () => { backToOverview(); loadDashboard(); });
$('#btnRefresh').addEventListener('click', loadDashboard);
$('#autoRefresh').addEventListener('change', schedule);
$('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); });
$('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); });
$('#btnRefresh').addEventListener('click', refreshBoard);
$('#autoRefresh').addEventListener('change', () => { writeUrl({ auto: $('#autoRefresh').checked }); 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(() => {
if (CUR.pid) {
$('#projectSel').value = CUR.pid;
refreshClassSel().then(() => { renderBoard(); schedule(); });
} else {
refreshClassSel();
}
});
loadProjects().then(loadDashboard);
schedule();
// auto 参数
if (CUR.auto === '0') $('#autoRefresh').checked = false;
</script>
</body>
</html>
+1 -5
View File
@@ -1,16 +1,12 @@
<?php
/**
* NY 问卷考试系统数据平台 - 随机抽取
* 从名单 / 已提交人员中随机抽取(支持分组均衡 5:5、排除名单、历史记录),
* 从名单 / 已提交人员中随机抽取(支持按人数加权/均分/自定义比例/不分组、排除名单、历史记录),
* 导出为卷王白名单(两列:姓名 / 登陆账号)
*/
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;
+1 -5
View File
@@ -2,10 +2,6 @@
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;
@@ -206,7 +202,7 @@ $rosterCount = Roster::count();
<div class="fi full">
<label>系统地址(不可在此修改)</label>
<input type="text" id="cfgBase" readonly>
<p class="hint">如需修改系统地址请重新打开 <a href="install.php?force=1" target="_blank">install.php</a></p>
<p class="hint">如需修改请编辑 <code>data/config.json</code>(保存后清 token 自动重连)</p>
</div>
<div class="fi">
<label>登录账号</label>
-203
View File
@@ -1,203 +0,0 @@
<?php
/**
* 首次安装向导:配置数据存储后端(文件 / PostgreSQL / MariaDB)与卷王系统连接信息。
* 访问本页时若已安装则自动跳转 /login。
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
// 已安装:默认跳转登录页;仅当带 ?force=1 或提交 POST 时才允许重新配置
if (ny_is_installed() && empty($_GET['force']) && $_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: login.php');
exit;
}
$err = '';
$ok = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$backend = (string)($_POST['backend'] ?? 'file');
$base = rtrim(trim((string)($_POST['base_url'] ?? '')), '/');
$prefix = '/' . trim((string)($_POST['api_prefix'] ?? '/api'), '/');
$user = trim((string)($_POST['username'] ?? ''));
$pwd = (string)($_POST['password'] ?? '');
$verify = !empty($_POST['verify_ssl']);
$full = (int)($_POST['full_score'] ?? 100);
if ($full < 1) {
$full = 100;
}
if ($base === '' || $user === '') {
$err = '请填写卷王系统地址与登录账号。';
} else {
// 1) 若是数据库后端,先保存连接参数并测试+建表
if ($backend === 'pgsql' || $backend === 'mysql') {
$dbp = [
'driver' => $backend,
'host' => trim((string)($_POST['db_host'] ?? '127.0.0.1')),
'port' => (int)($_POST['db_port'] ?? ($backend === 'pgsql' ? 5432 : 3306)),
'dbname' => trim((string)($_POST['db_name'] ?? '')),
'user' => trim((string)($_POST['db_user'] ?? '')),
'password' => (string)($_POST['db_pass'] ?? ''),
];
if ($dbp['dbname'] === '') {
$err = '请填写数据库名。';
} else {
try {
Db::saveParams($dbp);
// 重新建立连接并建表
$pdo = Db::connect($dbp);
Db::migrate();
} catch (Throwable $e) {
$err = '数据库连接/初始化失败:' . $e->getMessage();
}
}
}
if ($err === '') {
// 2) 写入系统配置(DB 后端写 settings['config'],文件后端写 config.json)
$cfg = ny_default_config();
$cfg['base_url'] = $base;
$cfg['api_prefix'] = $prefix;
$cfg['username'] = $user;
$cfg['password'] = $pwd;
$cfg['verify_ssl'] = $verify;
$cfg['full_score'] = $full;
$saved = ny_save_config($cfg);
if ($saved) {
// 清理 token 缓存(连接信息变化)
@unlink(NY_DATA . '/cache/token.json');
$ok = true;
} else {
if ($backend === 'file') {
$err = '无法写入文件配置,请检查 data/ 目录权限。';
} else {
$err = '配置写入失败(已建表但写入配置异常)。';
}
}
}
}
}
$cfg = ny_load_config();
?>
<!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">
<style>
.install-wrap { position:fixed; inset:0; z-index:200; display:flex; align-items:center; justify-content:center; padding:20px; background:linear-gradient(135deg,#1b2440,#2f6bff); overflow:auto; }
.install-box { width:640px; max-width:100%; background:#fff; border-radius:18px; padding:28px 28px 30px; box-shadow:0 24px 64px rgba(15,23,42,.35); }
.install-box::before { content:''; position:absolute; }
.steps { display:flex; gap:8px; margin:6px 0 20px; }
.steps i { flex:1; height:4px; border-radius:999px; background:#e2e8f2; }
.steps i.on { background:linear-gradient(90deg,#2f6bff,#6a4bff); }
.opt-seg { display:flex; gap:10px; }
.opt-seg label { flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:1px solid var(--border); border-radius:10px; padding:12px 8px; cursor:pointer; font-size:13px; font-weight:600; color:var(--text-2); transition:.15s; }
.opt-seg label.on { border-color:var(--primary); background:var(--primary-weak); color:var(--primary); }
.opt-seg input { display:none; }
.db-box { margin-top:14px; padding:14px; border:1px dashed #cfd8ea; border-radius:10px; background:#fbfcfe; }
.db-box.hidden { display:none; }
.form-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.fi.full { grid-column:1 / -1; }
.banner { padding:12px 14px; border-radius:10px; font-size:12.5px; margin-bottom:16px; line-height:1.6; }
.banner.err { background:var(--danger-weak); color:#a3342c; border:1px solid #f5d3cf; }
.banner.ok { background:var(--ok-weak); color:#0d7a4d; border:1px solid #c8ecda; }
.forgot { display:flex; justify-content:flex-end; margin-top:6px; font-size:12px; color:var(--text-3); }
</style>
</head>
<body class="dash">
<div class="install-wrap">
<div class="install-box">
<div class="login-brand">
<span class="logo">NY</span>
<div class="brand-text">
<strong><?= htmlspecialchars(NY_APP_NAME) ?></strong>
<small>首次安装 · 配置数据存储与系统连接</small>
</div>
</div>
<div class="steps"><i class="on"></i><i class="on"></i></div>
<?php if ($ok): ?>
<div class="banner ok">✅ 安装完成!即将进入登录页…</div>
<p style="font-size:13px;color:var(--text-2);text-align:center;margin:24px 0 6px">
若连接信息有误或未生效,请重新访问本页配置。</p>
<div class="login-actions"><button class="btn btn-primary" onclick="location.href='login.php'">前往登录</button></div>
<?php else: ?>
<form method="post" class="form-grid" id="f">
<?php if ($err !== ''): ?><div class="banner err full"><?= htmlspecialchars($err) ?></div><?php endif; ?>
<div class="fi full">
<label>数据存储后端</label>
<div class="opt-seg" id="seg">
<label><input type="radio" name="backend" value="file" checked><span>✅ 文件存储</span><small style="font-weight:500;color:var(--text-3)">无需数据库,开箱即用</small></label>
<label><input type="radio" name="backend" value="pgsql"><span>🐘 PostgreSQL</span><small style="font-weight:500;color:var(--text-3)">生产环境推荐</small></label>
<label><input type="radio" name="backend" value="mysql"><span>🍀 MariaDB / MySQL</span><small style="font-weight:500;color:var(--text-3)">兼容 MySQL 协议</small></label>
</div>
</div>
<div class="db-box hidden full" id="dbBox">
<div class="form-grid2">
<div class="fi"><label>主机</label><input type="text" name="db_host" value="127.0.0.1"></div>
<div class="fi"><label>端口</label><input type="number" id="db_port" name="db_port" value="5432"></div>
<div class="fi"><label>数据库名</label><input type="text" name="db_name"></div>
<div class="fi"><label>账号</label><input type="text" name="db_user"></div>
<div class="fi full"><label>密码</label><input type="password" name="db_pass"></div>
</div>
</div>
<div class="fi full">
<label>卷王系统地址</label>
<input type="text" name="base_url" value="<?= htmlspecialchars($cfg['base_url']) ?>" placeholder="https://exam.nxxy335.top">
</div>
<div class="fi">
<label>接口前缀</label>
<input type="text" name="api_prefix" value="<?= htmlspecialchars($cfg['api_prefix']) ?>" placeholder="/api">
</div>
<div class="fi">
<label>默认满分</label>
<input type="number" name="full_score" min="1" max="1000" value="<?= (int)$cfg['full_score'] ?>">
<p class="hint">用于合格率(≥60%满分),各试卷可在设置中单独调整</p>
</div>
<div class="fi">
<label>登录账号</label>
<input type="text" name="username" value="<?= htmlspecialchars($cfg['username']) ?>" placeholder="admin">
</div>
<div class="fi">
<label>登录密码</label>
<input type="password" name="password" placeholder="请输入密码">
</div>
<div class="fi full checks">
<label><input type="checkbox" name="verify_ssl" <?= $cfg['verify_ssl'] ? 'checked' : '' ?>> 校验 HTTPS 证书</label>
</div>
<div class="full" style="margin-top:10px">
<button class="btn btn-primary" id="btnInstall" style="width:100%;height:44px;font-size:15px">⚙️ 完成安装</button>
</div>
</form>
<?php endif; ?>
</div>
</div>
<script>
(function(){
var seg = document.getElementById('seg');
var dbBox = document.getElementById('dbBox');
var dbPort = document.getElementById('db_port');
function sync(){
var v = (document.querySelector('input[name=backend]:checked')||{}).value || 'file';
var labels = seg.querySelectorAll('label');
labels.forEach(function(l){ l.classList.toggle('on', l.querySelector('input').value === v); });
dbBox.classList.toggle('hidden', v === 'file');
if (v === 'pgsql') dbPort.value = 5432;
if (v === 'mysql') dbPort.value = 3306;
}
seg.addEventListener('change', sync);
sync();
})();
</script>
</body>
</html>
+40 -279
View File
@@ -1,322 +1,83 @@
<?php
/**
* 数据库连接层(统一 PDO)
*
* 支持 PostgreSQL / MariaDB(MySQL) / SQLite(主要用于本地无 DB 时的降级与测试)。
* 连接参数存于 data/db.json;通过 Db::instance() 取单例连接。
* 未配置数据库时返回 null,上层回退到文件存储(config.json / roster.json)。
* 数据库适配层(旧:pgsql/mysql/sqlite 三方言支持)
* 本批次已下线,仅保留文件存储。如未来需要接入 DB,请在此文件实现 PDO 单例。
*/
declare(strict_types=1);
class Db
{
/** @var PDO|null */
private static $pdo = null;
private static string $driver = '';
private static bool $tried = false;
private static bool $failed = false;
private static string $lastError = '';
/** @var PDO|null 当前不可用,始终返回 null */
private static ?PDO $pdo = null;
/** 数据库连接参数文件 */
public static function dbFile(): string
{
return NY_DATA . '/db.json';
}
/** 是否已配置数据库(db.json 存在且有 driver/host 等) */
public static function isConfigured(): bool
{
$d = self::params();
return !empty($d['driver']);
}
/** 读取连接参数 */
public static function params(): array
{
$d = ny_read_json(self::dbFile(), null);
if (!is_array($d)) {
return [];
}
return $d;
}
/** 写入连接参数 */
public static function saveParams(array $p): bool
{
return ny_write_json(self::dbFile(), $p);
}
/** 当前驱动名(用于 SQL 方言):mysql | pgsql | sqlite | '' */
public static function driver(): string
{
self::check();
return self::$driver;
return false;
}
public static function isConnected(): bool
{
self::check();
return self::$pdo !== null;
return false;
}
public static function driver(): string
{
return '';
}
/** 上一次连接失败的原因(连接正常时为空) */
public static function lastError(): string
{
self::check();
return self::$lastError;
return '';
}
/**
* 获取 PDO 单例;未配置或连不上返回 null。
*/
public static function pdo(): ?PDO
public static function params(): array
{
self::check();
return self::$pdo;
return [];
}
private static function check(): void
public static function saveParams(array $p): bool
{
if (self::$tried) {
return;
}
self::$tried = true;
$p = self::params();
$driver = strtolower((string)($p['driver'] ?? ''));
if ($driver === '') {
return;
}
self::$driver = $driver;
try {
self::$pdo = self::connect($p);
} catch (Throwable $e) {
self::$failed = true;
self::$pdo = null;
self::$lastError = $e->getMessage();
}
return false;
}
/** 根据参数建 PDO 连接 */
public static function connect(array $p): PDO
{
$driver = strtolower((string)($p['driver'] ?? ''));
$host = (string)($p['host'] ?? '127.0.0.1');
$port = (int)($p['port'] ?? 0);
$db = (string)($p['dbname'] ?? ($p['database'] ?? ''));
$user = (string)($p['user'] ?? ($p['username'] ?? ''));
$pass = (string)($p['password'] ?? '');
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
if ($driver === 'sqlite') {
$dsn = 'sqlite:' . ($p['path'] ?? ($db ?: (NY_DATA . '/app.sqlite')));
} elseif ($driver === 'pgsql') {
$dsn = 'pgsql:host=' . $host;
if ($port > 0) {
$dsn .= ';port=' . $port;
}
$dsn .= ';dbname=' . $db;
$dsn .= ';user=' . $user;
$dsn .= ';password=' . $pass;
} else { // mysql / mariadb
$dsn = 'mysql:host=' . $host;
if ($port > 0) {
$dsn .= ';port=' . $port;
}
$dsn .= ';dbname=' . $db . ';charset=utf8mb4';
$options[PDO::ATTR_PERSISTENT] = false;
throw new RuntimeException('数据库后端已下线,请使用文件存储(data/config.json / data/roster.json)');
}
$pdo = new PDO($dsn, $user, $pass, $options);
if ($driver === 'mysql') {
$pdo->exec("SET NAMES utf8mb4");
}
return $pdo;
public static function pdo(): ?PDO
{
return null;
}
/** 表名前缀 */
public static function prefix(): string
{
return 'ny_';
}
/**
* 建表(幂等)。返回已执行语句数量。
* 用通用 SQL,三方言均可执行(表名/列名小写、规范命名)。
*/
public static function migrate(): int
{
$pdo = self::pdo();
if ($pdo === null) {
throw new RuntimeException('数据库未连接,无法初始化表结构');
}
$driver = self::$driver;
$n = 0;
// settings(key,value) —— 系统设置键值表
if ($driver === 'sqlite') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
'k VARCHAR(128) PRIMARY KEY, ' .
'v TEXT)';
} elseif ($driver === 'pgsql') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
'k VARCHAR(128) PRIMARY KEY, ' .
'v TEXT)';
} else {
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
'`k` VARCHAR(128) NOT NULL PRIMARY KEY, ' .
'`v` TEXT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
}
$pdo->exec($sql); $n++;
// roster —— 名单表(学号/姓名/班级/组别/登陆账号)
if ($driver === 'sqlite') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' .
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
} elseif ($driver === 'pgsql') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
'id SERIAL PRIMARY KEY, ' .
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
} else {
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
'id INT AUTO_INCREMENT PRIMARY KEY, ' .
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64)) ' .
'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
}
$pdo->exec($sql); $n++;
// 旧版本升级:补 login 列
self::ensureRosterLogin();
return $n;
}
/** 旧表升级:ny_roster 缺 login 列时自动补(三方言兼容) */
public static function ensureRosterLogin(): void
{
$pdo = self::pdo();
if ($pdo === null) {
return;
}
$has = false;
if (self::$driver === 'sqlite') {
foreach ($pdo->query('PRAGMA table_info(ny_roster)')->fetchAll() as $c) {
if (strtolower((string)$c['name']) === 'login') {
$has = true;
break;
}
}
} elseif (self::$driver === 'pgsql') {
$st = $pdo->prepare("SELECT COUNT(*) FROM information_schema.columns " .
"WHERE table_name = 'ny_roster' AND column_name = 'login'");
$st->execute();
$has = (int)$st->fetchColumn() > 0;
} else {
$has = $pdo->query("SHOW COLUMNS FROM ny_roster LIKE 'login'")->fetch() !== false;
}
if (!$has) {
$pdo->exec('ALTER TABLE ny_roster ADD COLUMN login VARCHAR(64)');
}
}
/**
* 把所有 settings 读成数组
*/
public static function settingsAll(): array
{
$pdo = self::pdo();
if ($pdo === null) {
return [];
}
$out = [];
$stmt = $pdo->query('SELECT k, v FROM ny_settings');
foreach ($stmt->fetchAll() as $row) {
$out[$row['k']] = $row['v'];
}
return $out;
}
public static function settingsSet(string $k, string $v): void
{
$pdo = self::pdo();
if ($pdo === null) {
return;
}
$drv = self::$driver;
if ($drv === 'pgsql') {
$sql = 'INSERT INTO ny_settings(k, v) VALUES(?, ?) ' .
'ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v';
} elseif ($drv === 'sqlite') {
$sql = 'INSERT OR REPLACE INTO ny_settings(k, v) VALUES(?, ?)';
} else {
$sql = 'INSERT INTO ny_settings(k, v) VALUES(?, ?) ' .
'ON DUPLICATE KEY UPDATE v = VALUES(v)';
}
$pdo->prepare($sql)->execute([$k, $v]);
}
/** 名单:全量读 */
public static function rosterAll(): array
{
$pdo = self::pdo();
if ($pdo === null) {
return [];
}
self::ensureRosterLogin();
$stmt = $pdo->query('SELECT sid, name, cls, grp, login FROM ny_roster ORDER BY id');
$rows = [];
foreach ($stmt->fetchAll() as $r) {
$rows[] = [
'sid' => (string)$r['sid'],
'name' => (string)$r['name'],
'cls' => (string)$r['cls'],
'grp' => (string)$r['grp'],
'login' => (string)($r['login'] ?? ''),
];
}
return $rows;
}
/** 名单:清空后全量写 */
public static function rosterReplace(array $students): void
{
$pdo = self::pdo();
if ($pdo === null) {
return;
}
self::ensureRosterLogin();
$pdo->beginTransaction();
try {
$pdo->exec('DELETE FROM ny_roster');
$st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp, login) VALUES(?, ?, ?, ?, ?)');
foreach ($students as $s) {
$st->execute([
(string)($s['sid'] ?? ''),
(string)($s['name'] ?? ''),
(string)($s['cls'] ?? ''),
(string)($s['grp'] ?? ''),
(string)($s['login'] ?? ''),
]);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
/** 名单:行数 */
public static function rosterCount(): int
{
$pdo = self::pdo();
if ($pdo === null) {
return 0;
}
return (int)$pdo->query('SELECT COUNT(*) FROM ny_roster')->fetchColumn();
public static function ensureRosterLogin(): void {}
public static function settingsAll(): array
{
return [];
}
public static function settingsSet(string $k, string $v): void {}
public static function rosterAll(): array
{
return [];
}
public static function rosterReplace(array $students): void {}
public static function rosterCount(): int
{
return 0;
}
}
+3 -7
View File
@@ -1,7 +1,8 @@
<?php
/**
* 独立登录页:连接信息(系统地址/接口前缀/账号)已在安装向导配置,
* 本页仅需输入密码(账号可改)完成卷王登录,成功后进入主系统。
* 独立登录页:连接信息(系统地址/接口前缀/账号)已在 data/config.json 中预置,
* 本页只需输入密码(账号可改)完成卷王登录,成功后进入主系统。
* 连接信息有变请在【设置】中修改。
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
@@ -10,10 +11,6 @@ if (ny_is_auth()) {
header('Location: index.php');
exit;
}
if (!ny_is_installed()) {
header('Location: install.php');
exit;
}
$cfg = ny_load_config();
$err = '';
@@ -75,7 +72,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<button class="btn btn-primary" type="submit">登录</button>
</div>
</form>
<div class="forgot"><span style="font-size:12px;color:var(--text-3)">如系统连接信息已变更,请先完成<a href="install.php?force=1" style="color:var(--primary)">重新安装/配置</a></span></div>
</div>
</div>
+26 -10
View File
@@ -37,18 +37,37 @@ fs.writeFileSync(cfgFile, JSON.stringify({
layout: 'default',
}, null, 2));
// 统一收尾钩子:恢复原 config + token + kill 子进程。任何退出路径都会触发
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch(e){}
try { mock && mock.kill('SIGKILL'); } catch(e){}
try {
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
} catch(e) { console.error('cleanup cfg failed:', e.message); }
try {
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
} catch(e) { console.error('cleanup token failed:', e.message); }
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
// 2) 启动 mock 卷王
const mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
let mock = null;
let srv = null;
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
mock.stdout.on('data', d => process.stdout.write('[mock] ' + d));
mock.stderr.on('data', d => process.stderr.write('[mock-err] ' + d));
// 3) 启动 PHP 内置服务器(-t 让所有请求走 index.php 路由)
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]);
const srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: 'pipe' });
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: 'pipe' });
srv.stdout.on('data', d => process.stdout.write('[php] ' + d));
srv.stderr.on('data', d => process.stderr.write('[php-err] ' + d));
process.on('exit', () => { try { srv.kill('SIGKILL'); mock.kill('SIGKILL'); } catch(e){} });
process.on('SIGINT', () => { srv.kill('SIGKILL'); mock.kill('SIGKILL'); process.exit(1); });
let pass = 0, fail = 0;
function chk(name, cond, extra) {
@@ -279,10 +298,7 @@ async function login() {
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
// 清理
mock.kill('SIGTERM');
srv.kill('SIGTERM');
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
// 清理 + 退出(统一走 cleanup 钩子,保证 config/token 一定被还原)
cleanup();
setTimeout(() => process.exit(fail === 0 ? 0 : 1), 200);
})().catch(e => { console.error('FATAL', e); try { mock.kill(); srv.kill(); } catch(z){}; process.exit(2); });
})().catch(e => { console.error('FATAL', e); cleanup(); process.exit(2); });