feat: 接入PostgreSQL/MariaDB + 首次安装向导 + 登录页独立 + 设置重构 + 文件夹识别兜底

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

验证:php -l 全部通过、node --check app.js 通过;SQLite 等价实测 Db 层
(migrate/config upsert/Roster 读写/ny_load_config)与会话辅助函数全部PASS;
e2e(内置服务器)验证路由:未装→install、装完→login、index/dashboard 未登录
跳转、api 401 保护、?force=1 重配、logout 302 均正确。本机无真实 pgsql/mysql
服务器,DB 连接按部署环境实测。
This commit is contained in:
2026-09-09 07:15:56 +08:00
parent 64d0116c0e
commit 8686eb99c7
11 changed files with 830 additions and 195 deletions
+23
View File
@@ -13,12 +13,27 @@ function cfg(): array
return ny_load_config();
}
// 会话/安装保护:除 auth_check 外,未安装或未登录一律拒访
if (!ny_is_installed()) {
ny_json(['ok' => false, 'msg' => '系统尚未安装,请先完成安装向导'], 503);
exit;
}
if ($action !== 'auth_check' && !ny_is_auth()) {
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
exit;
}
try {
switch ($action) {
// 登录状态检查
case 'auth_check':
ny_ok(['auth' => ny_is_auth(), 'installed' => ny_is_installed()]);
// ---------------- 配置 ----------------
case 'config_get':
$c = cfg();
$c['has_password'] = $c['password'] !== '';
unset($c['password']);
ny_ok($c);
case 'config_save':
@@ -40,6 +55,14 @@ try {
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
}
}
// 布局配置
if (array_key_exists('layout', $in)) {
$new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
}
// 各试卷独立满分(覆盖整个 full_scores 映射)
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
$new['full_scores'] = $in['full_scores'];
}
$pwdIn = $in['password'] ?? null;
if ($pwdIn === null || $pwdIn === '' || $pwdIn === '******') {
$new['password'] = $old['password'];
+50 -138
View File
@@ -522,8 +522,6 @@
const m = (mode === 'side') ? 'side' : 'default';
const lay = document.getElementById('dataLayout');
if (lay) lay.setAttribute('data-layout', m);
const sel = $('#layoutSel');
if (sel && sel.value !== m) sel.value = m;
LS.set(LS_LAYOUT, m);
}
@@ -531,10 +529,6 @@
async function openSettings() {
try {
const c = await api('config_get');
$('#cfgBase').value = c.base_url || '';
$('#cfgPrefix').value = c.api_prefix || '/api';
$('#cfgUser').value = c.username || '';
$('#cfgPwd').value = '';
$('#cfgPageSize').value = c.page_size || 200;
$('#cfgInterval').value = c.refresh_interval || 15;
$('#cfgTimeout').value = c.timeout || 20;
@@ -542,18 +536,45 @@
$('#cfgDedupe').checked = !!c.dedupe;
$('#cfgTemp').checked = !!c.include_temp_save;
$('#cfgSsl').checked = !!c.verify_ssl;
// 布局
const layout = c.layout || LS.get(LS_LAYOUT, 'default');
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
// 满分:默认 + 各试卷独立
$('#cfgFull').value = c.full_score || 100;
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
const pid = $('#projectSel').value;
const scores = (c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {};
$('#cfgFull').value = (pid && scores[pid] != null) ? scores[pid] : (c.full_score || 100);
const pObj = state.projects.find(p => p.id === pid);
$('#cfgFullHint').textContent = pid
? ('应用于当前试卷:' + (pObj ? pObj.name : pid))
: '未选择试卷时,作为所有试卷的默认值';
? ('当前选中「' + (pObj ? pObj.name : pid) + '」,如需单独设满分请在上方"各试卷独立满分"修改')
: '未选中试卷时,作为所有试卷的默认值';
} catch (e) { showAlert(e.message); }
$('#pingResult').classList.add('hidden');
$('#settingsModal').classList.add('open');
}
// 渲染"各试卷独立满分"列表
function renderFullScores(scores) {
const box = $('#fullScoresBox');
if (!box) return;
if (!state.projects.length) {
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
return;
}
// 排除文件夹项:模式为文件夹的无可答卷
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
if (!surveys.length) {
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
return;
}
box.innerHTML = surveys.map(p => {
const val = scores[p.id] != null ? scores[p.id] : '';
return '<div class="row">' +
'<label title="' + esc(p.name) + '">' + esc(p.name) + '</label>' +
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
'</div>';
}).join('');
$('#fullScoresHint').textContent = '默认分值为空时使用上方"默认满分";填写即单独覆盖该卷';
}
/* ---------------- 名单 ---------------- */
let rosterDraft = [];
@@ -588,65 +609,6 @@
}).join('');
}
/* ---------------- 登录门 ---------------- */
async function showLoginGate() {
const gate = $('#loginGate');
if (gate) gate.classList.remove('hidden');
try {
const c = await api('config_get');
$('#lgBase').value = c.base_url || '';
$('#lgPrefix').value = c.api_prefix || '/api';
$('#lgUser').value = c.username || '';
} catch (e) { /* 取不到配置也可继续,手动填写 */ }
$('#lgPwd').value = '';
$('#lgLogin').disabled = true;
const msg = $('#lgMsg');
msg.textContent = '';
msg.className = 'login-msg';
}
async function loginTest() {
const base = $('#lgBase').value.trim();
const prefix = $('#lgPrefix').value.trim();
const user = $('#lgUser').value.trim();
const pwd = $('#lgPwd').value;
const msg = $('#lgMsg');
if (!base || !prefix || !user || !pwd) {
msg.textContent = '请填写系统网址、API 路由、账号与密码';
msg.className = 'login-msg err';
return;
}
msg.textContent = '正在测试连接…';
msg.className = 'login-msg';
try {
await api('config_save', { method: 'POST', body: { base_url: base, api_prefix: prefix, username: user, password: pwd } });
const r = await api('ping');
msg.textContent = '连接成功!读取到 ' + r.projects + ' 个问卷/考试,可登录';
msg.className = 'login-msg ok';
$('#lgLogin').disabled = false;
} catch (e) {
msg.textContent = '连接失败:' + e.message;
msg.className = 'login-msg err';
$('#lgLogin').disabled = true;
}
}
function loginEnter() {
const gate = $('#loginGate');
if (gate) gate.classList.add('hidden');
try { sessionStorage.setItem('ny_authed', '1'); } catch (e) { /* 忽略隐私模式等异常 */ }
schedule();
loadProjects();
}
function logout() {
try { sessionStorage.removeItem('ny_authed'); } catch (e) {}
if (state.timer) clearInterval(state.timer);
const gate = $('#loginGate');
if (gate) gate.classList.remove('hidden');
showLoginGate();
}
/* ---------------- 事件绑定 ---------------- */
document.addEventListener('DOMContentLoaded', function () {
@@ -674,7 +636,6 @@
renderAbsent();
renderHints();
});
$('#layoutSel').addEventListener('change', e => applyLayout(e.target.value));
$('#nameFieldSel').addEventListener('change', () => {
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
@@ -723,10 +684,6 @@
/* 设置保存 */
$('#btnSaveCfg').addEventListener('click', async () => {
const payload = {
base_url: $('#cfgBase').value.trim(),
api_prefix: $('#cfgPrefix').value.trim(),
username: $('#cfgUser').value.trim(),
password: $('#cfgPwd').value,
page_size: parseInt($('#cfgPageSize').value, 10) || 200,
refresh_interval: parseInt($('#cfgInterval').value, 10) || 15,
timeout: parseInt($('#cfgTimeout').value, 10) || 20,
@@ -735,11 +692,15 @@
include_temp_save: $('#cfgTemp').checked,
verify_ssl: $('#cfgSsl').checked,
full_score: parseInt($('#cfgFull').value, 10) || 100,
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
full_scores: collectFullScores(),
project_id: $('#projectSel').value
};
try {
await api('config_save', { method: 'POST', body: payload });
$('#settingsModal').classList.remove('open');
// 应用布局偏好(写入容器 + localStorage)
applyLayout(payload.layout);
const iv = payload.refresh_interval;
const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10));
let best = opts[0];
@@ -750,52 +711,15 @@
} catch (e) { showAlert(e.message); }
});
$('#btnPing').addEventListener('click', async () => {
const el = $('#pingResult');
el.classList.remove('hidden', 'err');
el.textContent = '正在测试…';
try {
const payload = {
base_url: $('#cfgBase').value.trim(),
api_prefix: $('#cfgPrefix').value.trim(),
username: $('#cfgUser').value.trim(),
password: $('#cfgPwd').value,
timeout: parseInt($('#cfgTimeout').value, 10) || 20,
verify_ssl: $('#cfgSsl').checked
};
await api('config_save', { method: 'POST', body: payload });
const r = await api('ping');
el.textContent = '连接成功!读取到 ' + r.projects + ' 个问卷/考试,耗时 ' + r.elapsed + ' ms';
} catch (e) {
el.classList.add('err');
el.textContent = '连接失败:' + e.message;
}
});
$('#btnDiag').addEventListener('click', async () => {
const el = $('#pingResult');
el.classList.remove('hidden', 'err');
el.textContent = '诊断中…';
try {
const payload = {
base_url: $('#cfgBase').value.trim(),
api_prefix: $('#cfgPrefix').value.trim(),
username: $('#cfgUser').value.trim(),
password: $('#cfgPwd').value,
timeout: parseInt($('#cfgTimeout').value, 10) || 20,
verify_ssl: $('#cfgSsl').checked
};
await api('config_save', { method: 'POST', body: payload });
const d = await api('diag');
const lines = (d.steps || []).map(s => (s.ok ? '✅ ' : '❌ ') + s.step + '\n ' + s.detail);
const failed = (d.steps || []).some(s => !s.ok);
el.classList.toggle('err', failed);
el.textContent = lines.join('\n');
} catch (e) {
el.classList.add('err');
el.textContent = '诊断失败:' + e.message;
}
});
// 收集"各试卷独立满分" -> {projectId: score}
function collectFullScores() {
const out = {};
$$('#fullScoresBox input[data-pid]').forEach(inp => {
const v = parseInt(inp.value, 10);
if (inp.dataset.pid && !isNaN(v) && v > 0) out[inp.dataset.pid] = v;
});
return out;
}
/* 名单上传 */
const zone = $('#dropZone'), fileInput = $('#rosterFile');
@@ -876,23 +800,11 @@
state.deeplinkProject = qs.get('projectId') || '';
state.deeplinkQid = qs.get('nameQid') || '';
// 登录门
$('#lgTest').addEventListener('click', loginTest);
$('#lgLogin').addEventListener('click', loginEnter);
$('#lgPwd').addEventListener('keydown', e => { if (e.key === 'Enter') loginTest(); });
$('#btnLogout').addEventListener('click', logout);
// 退出登录:跳转到独立 logout.php(清服务端会话)
$('#btnLogout').addEventListener('click', () => { location.href = 'logout.php'; });
// 启动:本会话已认证(未重启电脑)则直接进入主界面,跳过登录门;否则显示登录门
let authed = false;
try { authed = sessionStorage.getItem('ny_authed') === '1'; } catch (e) {}
if (authed) {
const gate = $('#loginGate');
if (gate) gate.classList.add('hidden');
schedule();
loadProjects();
} else {
// 先显示登录门,不直接拉取数据(防止依赖一次配置直接查看数据)
showLoginGate();
}
// 启动:主界面在 PHP 端已做会话校验(未登录会跳到 login.php),此处直接加载数据
schedule();
loadProjects();
});
})();
+7
View File
@@ -345,6 +345,13 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
.fi { display: flex; flex-direction: column; gap: 6px; }
.fi.full { grid-column: 1 / -1; }
.fi label { font-size: 12px; font-weight: 600; color: var(--text-2); }
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
.fullscores { display: flex; flex-direction: column; gap: 6px; }
.fullscores .row { display: flex; align-items: center; gap: 8px; }
.fullscores label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fullscores input { width: 90px; flex: none; }
.fullscores .empty { padding: 6px 0; color: var(--text-3); font-size: 12px; text-align: left; }
.hint { margin: 0; font-size: 11px; color: var(--text-3); }
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
+105
View File
@@ -36,6 +36,64 @@ if ($missing !== []) {
date_default_timezone_set('Asia/Shanghai');
mb_internal_encoding('UTF-8');
// ---------------- 会话 ----------------
function ny_session_start(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_name('NYSESS');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => $secure,
]);
@session_start();
}
/** 当前是否已登录(本系统会话) */
function ny_is_auth(): bool
{
ny_session_start();
return !empty($_SESSION['ny_auth']);
}
/** 标记登录 */
function ny_login(): void
{
ny_session_start();
$_SESSION['ny_auth'] = true;
}
/** 登出 */
function ny_logout(): void
{
ny_session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
/** 判断是否已安装(有过连接配置且能流转):文件 config 或 DB settings 存在 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());
}
// ---------------- 目录初始化 ----------------
foreach (['', '/cache', '/uploads'] as $sub) {
$dir = NY_DATA . $sub;
@@ -65,6 +123,7 @@ function ny_default_config(): array
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
'full_score' => 100,
'full_scores' => [],
'layout' => 'default',
];
}
@@ -75,6 +134,20 @@ function ny_config_file(): string
function ny_load_config(): array
{
// 数据库已初始化且能连上 → 从 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'], '/');
return $cfg;
}
$cfg = ny_default_config();
$saved = ny_read_json(ny_config_file(), null);
if (is_array($saved)) {
@@ -91,9 +164,40 @@ function ny_load_config(): array
function ny_save_config(array $cfg): bool
{
// 数据库已连接则写入 DB settings(键 config = JSON),否则回退文件
if (class_exists('Db')) {
$db = Db::pdo();
if ($db !== null) {
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
return true;
} catch (Throwable $e) {
// 落库失败则回退文件
}
}
}
return ny_write_json(ny_config_file(), $cfg);
}
/** 数据库初始化后从 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;
}
/** 安全的字符串化(避免数组/对象转字符串报错) */
function ny_str(mixed $v): string
{
@@ -232,6 +336,7 @@ spl_autoload_register(function (string $class): void {
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
'Roster' => NY_ROOT . '/lib/Roster.php',
'DataService' => NY_ROOT . '/lib/DataService.php',
'Db' => NY_ROOT . '/lib/Db.php',
];
if (isset($map[$class]) && is_file($map[$class])) {
require $map[$class];
+9
View File
@@ -1,6 +1,15 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (!ny_is_installed()) {
header('Location: install.php');
exit;
}
if (!ny_is_auth()) {
header('Location: login.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
+29 -55
View File
@@ -2,6 +2,15 @@
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;
}
$cfg = ny_load_config();
$rosterCount = Roster::count();
?>
@@ -33,29 +42,6 @@ $rosterCount = Roster::count();
</div>
</header>
<!-- ================= 登录门 ================= -->
<div class="login-gate" id="loginGate">
<div class="login-box">
<div class="login-brand">
<span class="logo">NY</span>
<div class="brand-text">
<strong>NY问卷考试系统数据平台</strong>
<small>实时数据统计</small>
</div>
</div>
<p class="login-tip">请输入系统连接信息,完成连接测试后登录。密码需每次输入,避免依赖一次配置直接查看数据。</p>
<div class="fi"><label>系统网址</label><input type="text" id="lgBase" placeholder="https://exam.nxxy335.top"></div>
<div class="fi"><label>API 路由</label><input type="text" id="lgPrefix" placeholder="/api"></div>
<div class="fi"><label>账号</label><input type="text" id="lgUser" placeholder="admin"></div>
<div class="fi"><label>密码</label><input type="password" id="lgPwd" placeholder="请输入密码" autocomplete="current-password"></div>
<div class="login-actions">
<button class="btn" id="lgTest">测试连接</button>
<button class="btn btn-primary" id="lgLogin" disabled>登录</button>
</div>
<div class="login-msg" id="lgMsg"></div>
</div>
</div>
<main class="wrap">
<section class="toolbar">
@@ -75,13 +61,6 @@ $rosterCount = Roster::count();
<label>组别</label>
<select id="grpSel"><option value="">全部组别</option></select>
</div>
<div class="field">
<label>布局</label>
<select id="layoutSel">
<option value="default">默认(缺考+未匹配同行)</option>
<option value="side">成绩明细与缺考名单同行</option>
</select>
</div>
<div class="field">
<label>自动刷新</label>
<div class="inline">
@@ -175,26 +154,12 @@ $rosterCount = Roster::count();
<div class="modal-body">
<div class="form-grid">
<div class="fi full">
<label>卷王系统地址</label>
<input type="text" id="cfgBase" placeholder="http://127.0.0.1:1991">
<p class="hint">例如 http://192.168.1.10:1991,结尾不要带 /</p>
</div>
<div class="fi">
<label>接口前缀</label>
<input type="text" id="cfgPrefix" placeholder="/api">
<p class="hint">社区版默认 /api(企业版可填 /admin-api)</p>
</div>
<div class="fi">
<label>每页拉取条数</label>
<input type="number" id="cfgPageSize" min="20" max="1000">
</div>
<div class="fi">
<label>登录账号</label>
<input type="text" id="cfgUser">
</div>
<div class="fi">
<label>登录密码</label>
<input type="password" id="cfgPwd" placeholder="留空表示不修改">
<label>布局</label>
<div class="fi-inline">
<label class="chk"><input type="radio" name="cfgLayout" value="default" id="layoutDef"> 默认(缺考+未匹配同行)</label>
<label class="chk"><input type="radio" name="cfgLayout" value="side" id="layoutSide"> 成绩明细与缺考名单同行</label>
</div>
<p class="hint">主界面三个面板(成绩明细 / 缺考名单 / 未匹配答卷)的排版方式</p>
</div>
<div class="fi">
<label>默认刷新间隔(秒)</label>
@@ -205,15 +170,25 @@ $rosterCount = Roster::count();
<input type="number" id="cfgTimeout" min="3" max="120">
</div>
<div class="fi">
<label>满分(用于合格率)</label>
<input type="number" id="cfgFull" min="1" max="1000">
<p class="hint" id="cfgFullHint">未选择试卷时,作为所有试卷的默认值</p>
<label>每页拉取条数</label>
<input type="number" id="cfgPageSize" min="20" max="1000">
</div>
<div class="fi">
<label>默认满分(用于合格率)</label>
<input type="number" id="cfgFull" min="1" max="1000">
<p class="hint" id="cfgFullHint">未选中试卷时的默认值</p>
</div>
<div class="fi full">
<label>姓名题关键词</label>
<input type="text" id="cfgKeywords">
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
</div>
<div class="fi">
<label>各试卷独立满分</label>
<div id="fullScoresBox" class="fullscores">
<span class="hint" id="fullScoresHint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
</div>
</div>
<div class="fi full checks">
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
@@ -223,8 +198,7 @@ $rosterCount = Roster::count();
<div id="pingResult" class="ping-result hidden"></div>
</div>
<div class="modal-foot">
<button class="btn" id="btnPing">测试连接</button>
<button class="btn" id="btnDiag">详细诊断</button>
<span class="hint" style="align-self:center">连接信息由安装向导配置,此处仅调整显示与统计</span>
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnSaveCfg">保存</button>
+203
View File
@@ -0,0 +1,203 @@
<?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>
+278
View File
@@ -0,0 +1,278 @@
<?php
/**
* 数据库连接层(统一 PDO)
*
* 支持 PostgreSQL / MariaDB(MySQL) / SQLite(主要用于本地无 DB 时的降级与测试)。
* 连接参数存于 data/db.json;通过 Db::instance() 取单例连接。
* 未配置数据库时返回 null,上层回退到文件存储(config.json / roster.json)。
*/
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;
/** 数据库连接参数文件 */
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;
}
public static function isConnected(): bool
{
self::check();
return self::$pdo !== null;
}
/**
* 获取 PDO 单例;未配置或连不上返回 null。
*/
public static function pdo(): ?PDO
{
self::check();
return self::$pdo;
}
private static function check(): void
{
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;
}
}
/** 根据参数建 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;
}
$pdo = new PDO($dsn, $user, $pass, $options);
if ($driver === 'mysql') {
$pdo->exec("SET NAMES utf8mb4");
}
return $pdo;
}
/** 表名前缀 */
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))';
} 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))';
} 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)) ' .
'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
}
$pdo->exec($sql); $n++;
return $n;
}
/**
* 把所有 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 [];
}
$stmt = $pdo->query('SELECT sid, name, cls, grp 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'],
];
}
return $rows;
}
/** 名单:清空后全量写 */
public static function rosterReplace(array $students): void
{
$pdo = self::pdo();
if ($pdo === null) {
return;
}
$pdo->beginTransaction();
try {
$pdo->exec('DELETE FROM ny_roster');
$st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp) VALUES(?, ?, ?, ?)');
foreach ($students as $s) {
$st->execute([
(string)($s['sid'] ?? ''),
(string)($s['name'] ?? ''),
(string)($s['cls'] ?? ''),
(string)($s['grp'] ?? ''),
]);
}
$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();
}
}
+22 -2
View File
@@ -13,9 +13,18 @@ class Roster
return NY_DATA . '/roster.json';
}
/** 数据库是否可作为名单后端 */
private static function useDb(): bool
{
return class_exists('Db') && Db::pdo() !== null;
}
/** @return array<int,array{sid:string,name:string,cls:string}> */
public static function all(): array
{
if (self::useDb()) {
return Db::rosterAll();
}
$d = ny_read_json(self::file(), null);
if (!is_array($d) || !isset($d['students']) || !is_array($d['students'])) {
return [];
@@ -39,8 +48,8 @@ class Roster
{
$d = ny_read_json(self::file(), null);
return [
'count' => count(self::all()),
'updated_at' => is_array($d) ? (string)($d['updated_at'] ?? '') : '',
'count' => self::count(),
'updated_at' => self::useDb() ? date('Y-m-d H:i:s') : (is_array($d) ? (string)($d['updated_at'] ?? '') : ''),
];
}
@@ -60,6 +69,10 @@ class Roster
'grp' => trim((string)($s['grp'] ?? '')),
];
}
if (self::useDb()) {
Db::rosterReplace($clean);
return true;
}
return ny_write_json(self::file(), [
'students' => $clean,
'updated_at' => date('Y-m-d H:i:s'),
@@ -68,11 +81,18 @@ class Roster
public static function clear(): bool
{
if (self::useDb()) {
Db::rosterReplace([]);
return true;
}
return ny_write_json(self::file(), ['students' => [], 'updated_at' => date('Y-m-d H:i:s')]);
}
public static function count(): int
{
if (self::useDb()) {
return Db::rosterCount();
}
return count(self::all());
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* 独立登录页:连接信息(系统地址/接口前缀/账号)已在安装向导配置,
* 本页仅需输入密码(账号可改)完成卷王登录,成功后进入主系统。
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (ny_is_auth()) {
header('Location: index.php');
exit;
}
if (!ny_is_installed()) {
header('Location: install.php');
exit;
}
$cfg = ny_load_config();
$err = '';
$ok = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = trim((string)($_POST['username'] ?? $cfg['username']));
$pwd = (string)($_POST['password'] ?? '');
// 允许用户在本页调整账号;密码必须输入
if ($user === '' || $pwd === '') {
$err = '请输入账号与密码。';
} else {
// 用当前配置的连接信息 + 本页输入的账密做一次卷王登录
$test = $cfg;
$test['username'] = $user;
$test['password'] = $pwd;
try {
$client = new SurveyKingClient($test);
$client->getToken(true);
// 登录成功:把账号写回配置(如需),并建立会话
$cfg['username'] = $user;
ny_save_config($cfg);
ny_login();
$ok = true;
} catch (Throwable $e) {
$err = '登录失败:' . $e->getMessage();
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<div class="login-gate<?= $ok ? ' hidden' : '' ?>">
<div class="login-box">
<div class="login-brand">
<span class="logo">NY</span>
<div class="brand-text">
<strong><?= htmlspecialchars(NY_APP_NAME) ?></strong>
<small>实时数据统计</small>
</div>
</div>
<p class="login-tip">系统连接信息已配置(<?= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/<?= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。</p>
<?php if ($err !== ''): ?>
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($err) ?></div>
<?php endif; ?>
<form method="post">
<div class="fi"><label>账号</label><input type="text" name="username" value="<?= htmlspecialchars($cfg['username']) ?>" autocomplete="username"></div>
<div class="fi"><label>密码</label><input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" autofocus></div>
<div class="login-actions">
<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>
<?php if ($ok): ?>
<div class="login-gate">
<div class="login-box" style="text-align:center">
<div class="login-brand" style="justify-content:center"><span class="logo">NY</span>
<div class="brand-text"><strong>登录成功</strong><small>正在进入数据平台…</small></div>
</div>
<script>setTimeout(function(){ location.href='index.php'; }, 600);</script>
</div>
</div>
<?php endif; ?>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* 退出登录:清除本系统会话并跳回登录页。
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
ny_logout();
header('Location: login.php');
exit;