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:
@@ -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
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* 退出登录:清除本系统会话并跳回登录页。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
ny_logout();
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
Reference in New Issue
Block a user