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:
@@ -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
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[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,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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user