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
+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); }