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
+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>