diff --git a/api.php b/api.php index 3010add..962690c 100644 --- a/api.php +++ b/api.php @@ -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']; diff --git a/assets/app.js b/assets/app.js index 54dcf25..3ca6f8e 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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 = '暂无试卷,请先连接卷王系统并选择问卷'; + return; + } + // 排除文件夹项:模式为文件夹的无可答卷 + const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder'))); + if (!surveys.length) { + box.innerHTML = '尚未获取到可答卷'; + return; + } + box.innerHTML = surveys.map(p => { + const val = scores[p.id] != null ? scores[p.id] : ''; + return '
' + + '' + + '' + + '
'; + }).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(); }); })(); diff --git a/assets/style.css b/assets/style.css index b0ae4ee..4dcc110 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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); } diff --git a/bootstrap.php b/bootstrap.php index f53afb6..bcebf84 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -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]; diff --git a/dashboard.php b/dashboard.php index 72760de..e32de29 100644 --- a/dashboard.php +++ b/dashboard.php @@ -1,6 +1,15 @@ diff --git a/index.php b/index.php index b364158..a4f5c5c 100644 --- a/index.php +++ b/index.php @@ -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(); - -
-
- - -
-
-
-
- - -
-
-
@@ -75,13 +61,6 @@ $rosterCount = Roster::count(); -
- - -
@@ -175,26 +154,12 @@ $rosterCount = Roster::count();