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:
+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();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user