diff --git a/api.php b/api.php
index 246996b..2bdd84e 100644
--- a/api.php
+++ b/api.php
@@ -171,66 +171,10 @@ try {
$in = $_POST;
}
$old = cfg();
- $allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
- $new = $old;
- foreach ($allowed as $k) {
- if (array_key_exists($k, $in)) {
- $new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
- }
- }
- // 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
- if (array_key_exists('password', $in)) {
- $pwdIn = (string)$in['password'];
- if ($pwdIn !== '' && $pwdIn !== '******') {
- $new['password'] = $pwdIn;
- }
- }
- foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
- if (array_key_exists($k, $in)) {
- $new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
- }
- }
- // 布局配置
- if (array_key_exists('layout', $in)) {
- $new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
- }
- // 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
- if (array_key_exists('history_display', $in)) {
- $new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
- }
- // 成绩导出排序:time(按提交时间降序,默认)/ sid(按学号)/ score_desc(成绩降序)/ score_asc(成绩升序)
- if (array_key_exists('export_sort', $in)) {
- $v = (string)$in['export_sort'];
- $new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
- }
- // 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置
- // (project_config / project_config_save),设置里只保留「全局默认满分」。
- if (array_key_exists('pass_rate', $in)) {
- $pr = (float)$in['pass_rate'];
- $new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
- }
- $new['base_url'] = rtrim((string)$new['base_url'], '/');
- $new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
- $new['timeout'] = (int)$new['timeout'];
- $new['refresh_interval'] = (int)$new['refresh_interval'];
- if ($new['refresh_interval'] < 3) {
- $new['refresh_interval'] = 3;
- }
- // 默认满分
- $fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
- if ($fs < 1) {
- $fs = 100;
- }
- $new['full_score'] = $fs;
- // 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
- $connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
- $connChanged = false;
- foreach ($connKeys as $k) {
- if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
- $connChanged = true;
- break;
- }
- }
+ // 合并规则统一放在 ny_apply_config_input(含"账号/密码不许被改空"的兜底),便于单测
+ $applied = ny_apply_config_input($old, $in);
+ $new = $applied['config'];
+ $connChanged = $applied['conn_changed'];
ny_save_config($new);
if ($connChanged) {
@unlink(NY_DATA . '/cache/token.json');
@@ -954,8 +898,11 @@ try {
$isAuth = str_contains($msg, '未配置卷王登录账号')
|| str_contains($msg, '登录卷王失败')
|| str_contains($msg, '登录失败');
+ // 鉴权失败时回传落盘诊断(账号到底有没有存住、data 目录可不可写),
+ // 否则前端只能给出"账号未配置"这一句空话,用户无法自查,也容易陷入反复跳登录页。
ny_fail($msg, [
'auth' => $isAuth,
+ 'diag' => $isAuth ? ny_config_diag() : null,
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
]);
}
diff --git a/assets/app.js b/assets/app.js
index e05bd99..2aaaea0 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -31,6 +31,8 @@
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
_kwDebounce: null, // 搜索框防抖定时器
csrf: '', // CSRF token(来自 config_get),写请求自动附带
+ _authRedirecting: false, // 鉴权失败已接管(防止并发请求重复跳登录页)
+ _authStopped: false, // 鉴权失败已停自动刷新(等用户处理,不再定时重试)
};
/* ---------------- 本地存储 ---------------- */
@@ -89,7 +91,9 @@
}
if (!json.ok) {
const err = new Error(json.msg || '请求失败');
- err.auth = !!(json.extra && json.extra.auth);
+ // 401 = 本系统会话已失效,与"卷王鉴权失败"同样需要重新登录;
+ // handleAuthError 里的防死循环闸门会保证"刚登录完又失败"时不再反复跳转。
+ err.auth = !!(json.extra && json.extra.auth) || res.status === 401;
err.extra = json.extra || null;
throw err;
}
@@ -154,6 +158,106 @@
return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
}
+ /* ---------------- 鉴权失败处理(含防死循环) ----------------
+ * 死循环场景:登录页登录成功 → 进主页 → 拉数据仍报"未配置账号/登录失败" → 自动跳登录页 → 再登录……
+ * 用户看到的就是"一直让重新登录、根本用不了"。这里加两道闸门:
+ * 1) 刚登录完(120 秒内)又鉴权失败 → 不再自动跳转,直接把服务端真实原因 + 落盘诊断摊开给用户
+ * 2) 同一会话已自动跳转过 2 次 → 同样停在页面报错
+ */
+ const AUTH_FRESH_MS = 120000;
+ const AUTH_MAX_JUMPS = 2;
+
+ function authJustLoggedIn() {
+ try {
+ const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10);
+ return t > 0 && (Date.now() - t) < AUTH_FRESH_MS;
+ } catch (e) { return false; }
+ }
+ function authJumpCount() {
+ try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; }
+ }
+ function stopAutoRefresh() {
+ if (state.timer) { clearTimeout(state.timer); state.timer = null; }
+ if (state.hlTimer) { clearInterval(state.hlTimer); state.hlTimer = null; }
+ state._authStopped = true;
+ }
+ function forceRelogin() {
+ stopAutoRefresh();
+ // 必须先清服务端会话,否则 login.php 发现会话还在会把人踢回 index.php,跳不过去
+ fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
+ .catch(() => {})
+ .finally(() => { window.location.href = 'login.php'; });
+ }
+
+ function diagLines(diag) {
+ if (!diag) return [];
+ const yn = v => (v ? '是' : '否');
+ return [
+ '配置来源:' + (diag.storage || '-') + '(配置文件可写=' + yn(diag.cfg_writable) + ')',
+ '数据目录:' + (diag.data_dir || '-') + '(可写=' + yn(diag.dir_writable) + ')',
+ '数据库:' + (diag.db_connected ? ('已连接(可写=' + yn(diag.db_writable) + ')') : ('未连接 ' + (diag.db_error || ''))),
+ '已存账号:' + (diag.has_username ? (diag.username || '已设置') : '空 ← 这就是「未配置账号」的原因'),
+ '已存密码:' + (diag.has_password ? '已保存' : '空'),
+ diag.save_error ? ('保存异常:' + diag.save_error) : '',
+ ].filter(Boolean);
+ }
+
+ function showAuthFailure(e) {
+ const lines = diagLines(e && e.extra ? e.extra.diag : null);
+ const box = document.createElement('div');
+ box.className = 'modal open';
+ box.innerHTML =
+ '
' +
+ '
无法访问卷王
' +
+ '
' +
+ '
' + esc((e && e.message) || '鉴权失败') + '
' +
+ '
' +
+ '为避免一直把您踢回登录页,已停止自动跳转。请按下面提示处理后点【重试】。
' +
+ (lines.length
+ ? '
' + esc(lines.join('\n')) + ''
+ : '') +
+ '
常见原因:① data 目录不可写(账号密码存不住);' +
+ '② 卷王账号/密码已变更;③ 卷王地址不可达。
' +
+ '
' +
+ '' +
+ '
';
+ document.body.appendChild(box);
+ box.querySelector('[data-auth-retry]').onclick = () => {
+ box.remove();
+ state._authRedirecting = false;
+ state._authStopped = false;
+ refresh(false, true); // 非静默 + 强制拉取
+ };
+ box.querySelector('[data-auth-settings]').onclick = () => {
+ box.remove();
+ const b = document.getElementById('btnSettings');
+ if (b) b.click();
+ };
+ box.querySelector('[data-auth-relogin]').onclick = () => { box.remove(); forceRelogin(); };
+ }
+
+ /**
+ * 统一处理鉴权类错误;返回 true 表示已接管(调用方直接 return)。
+ */
+ function handleAuthError(e) {
+ if (state._authRedirecting) return true;
+ state._authRedirecting = true;
+ const jumps = authJumpCount();
+ if (authJustLoggedIn() || jumps >= AUTH_MAX_JUMPS) {
+ stopAutoRefresh();
+ showAuthFailure(e);
+ return true;
+ }
+ try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
+ showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
+ forceRelogin();
+ return true;
+ }
+
/* ---------------- 问卷列表 ---------------- */
// 把数组渲染到 #projectSel(抽出便于缓存复用)
function renderProjects() {
@@ -234,6 +338,7 @@
LS.set(LS_PROJECTS, state.projects);
renderProjects();
} catch (e) {
+ if (e.auth) { handleAuthError(e); return; }
if (!cached || !cached.length) {
setConn('conn-err', '连接失败');
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
@@ -374,21 +479,7 @@
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
} catch (e) {
if (e.auth) {
- // 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
- // 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环
- if (!state._authRedirecting) {
- state._authRedirecting = true;
- if (state.timer) { clearTimeout(state.timer); state.timer = null; }
- if (state.hlTimer) clearInterval(state.hlTimer);
- showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
- // 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话)
- fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
- .catch(() => {})
- .finally(() => {
- // 200ms 足够让 toast 入场,又不至于让用户干等
- setTimeout(() => { window.location.href = 'login.php'; }, 200);
- });
- }
+ handleAuthError(e);
return;
}
if (!cached) {
@@ -989,6 +1080,8 @@
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
function schedule() {
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
+ // 鉴权失败已停止自动刷新(handleAuthError),别再排下一轮把用户反复踢走
+ if (state._authStopped) return;
if (!$('#autoRefresh').checked) return;
const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000;
const backoff = Math.min(8, Math.pow(2, state.connFail)) * base;
diff --git a/bootstrap.php b/bootstrap.php
index 33d0241..abbe279 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -282,23 +282,48 @@ function ny_load_config(): array
}
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
+ // 账号/密码一律转成字符串:历史数据里若存成 null,不改这里就会表现为"未配置卷王登录账号"
+ $cfg['username'] = trim((string)($cfg['username'] ?? ''));
+ $cfg['password'] = (string)($cfg['password'] ?? '');
return $cfg;
}
function ny_save_config(array $cfg): bool
{
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
+ // 关键:两处都必须做"回读校验"。以前只管写不管是否真落盘,data/ 目录不可写时
+ // 登录页会照常提示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳登录页。
+ $GLOBALS['__ny_cfg_save_error'] = '';
+ $notes = [];
+ $json = json_encode($cfg, JSON_UNESCAPED_UNICODE);
+
$ok = false;
if (class_exists('Db') && Db::pdo() !== null) {
try {
- Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
- $GLOBALS['__ny_cfg_source'] = 'db';
- $ok = true;
+ if (Db::settingsSet('config', $json)) {
+ $GLOBALS['__ny_cfg_source'] = 'db';
+ $ok = true;
+ } else {
+ $notes[] = '数据库写入失败:' . (Db::lastError() ?: '未知原因');
+ }
} catch (Throwable $e) {
- $ok = false;
+ $notes[] = '数据库写入异常:' . $e->getMessage();
}
+ } else {
+ $notes[] = '数据库不可用(' . (class_exists('Db') ? Db::lastError() : 'Db 类未加载') . ')';
}
+
$fileOk = ny_write_json(ny_config_file(), $cfg);
+ if ($fileOk) {
+ // 回读一致才算真成功
+ $back = ny_read_json(ny_config_file(), null);
+ if (!is_array($back) || json_encode($back, JSON_UNESCAPED_UNICODE) !== $json) {
+ $fileOk = false;
+ $notes[] = '配置文件回读不一致:' . ny_config_file();
+ }
+ } else {
+ $notes[] = '配置文件写入失败:' . ny_config_file();
+ }
// 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
@@ -306,11 +331,149 @@ function ny_save_config(array $cfg): bool
@chmod(ny_config_file(), 0600);
}
if (!$ok) {
- $GLOBALS['__ny_cfg_source'] = 'file';
+ $GLOBALS['__ny_cfg_source'] = $fileOk ? 'file' : 'default';
+ }
+ if (!$ok && !$fileOk) {
+ $GLOBALS['__ny_cfg_save_error'] = '配置未落盘:' . implode(';', $notes);
+ } elseif ($notes !== []) {
+ // 有一处成功即不影响使用,仅记录降级信息
+ $GLOBALS['__ny_cfg_save_error'] = implode(';', $notes);
}
return $ok || $fileOk;
}
+/** 上一次 ny_save_config 的问题描述(空 = 完全正常) */
+function ny_config_save_error(): string
+{
+ return (string)($GLOBALS['__ny_cfg_save_error'] ?? '');
+}
+
+/**
+ * 配置落盘诊断:登录/鉴权失败时优先看这里,用来区分
+ * 「账号没配」与「账号配了但写不进去 / 读不回来」这两类完全不同的故障。
+ */
+function ny_config_diag(): array
+{
+ $dir = rtrim((string)NY_DATA, '/\\');
+ $file = ny_config_file();
+ $dbOk = class_exists('Db') && Db::pdo() !== null;
+
+ $dbWritable = false;
+ if ($dbOk) {
+ $probe = '__ny_probe_' . bin2hex(random_bytes(4));
+ $dbWritable = Db::settingsSet($probe, '1');
+ Db::settingsDelete($probe);
+ }
+
+ $cfgWritable = false;
+ $probeFile = $dir . '/.__ny_write_probe';
+ $fp = @fopen($probeFile, 'wb');
+ if ($fp) {
+ $cfgWritable = (fwrite($fp, '1') !== false);
+ fclose($fp);
+ @unlink($probeFile);
+ }
+
+ $c = ny_load_config();
+
+ return [
+ 'storage' => ny_config_source(),
+ 'data_dir' => $dir,
+ 'dir_writable' => is_dir($dir) && is_writable($dir),
+ 'cfg_file' => $file,
+ 'cfg_writable' => $cfgWritable,
+ 'db_connected' => $dbOk,
+ 'db_writable' => $dbWritable,
+ 'db_error' => (class_exists('Db') && !$dbOk) ? Db::lastError() : '',
+ 'has_username' => trim((string)($c['username'] ?? '')) !== '',
+ 'has_password' => ((string)($c['password'] ?? '')) !== '',
+ 'username' => (string)($c['username'] ?? ''),
+ 'save_error' => ny_config_save_error(),
+ ];
+}
+
+/**
+ * 把接口/表单传入的配置片段合并进已有配置(api.php config_save 用)。
+ * 抽出来是为了可单测,同时在这里统一守住"账号/密码不能被误清空"。
+ *
+ * @return array{config:array,conn_changed:bool}
+ */
+function ny_apply_config_input(array $old, array $in): array
+{
+ $allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
+ $new = $old;
+ foreach ($allowed as $k) {
+ if (array_key_exists($k, $in)) {
+ $new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
+ }
+ }
+ // 账号不允许被改空:设置页留空视为"不改",否则一旦保存就会陷入"未配置账号"死循环
+ if (array_key_exists('username', $in)) {
+ $u = trim((string)($in['username'] ?? ''));
+ if ($u === '') {
+ $new['username'] = (string)($old['username'] ?? '');
+ }
+ }
+ // 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
+ if (array_key_exists('password', $in)) {
+ $pwdIn = (string)$in['password'];
+ if ($pwdIn !== '' && $pwdIn !== '******') {
+ $new['password'] = $pwdIn;
+ }
+ }
+ foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
+ if (array_key_exists($k, $in)) {
+ $new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
+ }
+ }
+ // 布局配置
+ if (array_key_exists('layout', $in)) {
+ $new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
+ }
+ // 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
+ if (array_key_exists('history_display', $in)) {
+ $new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
+ }
+ // 成绩导出排序
+ if (array_key_exists('export_sort', $in)) {
+ $v = (string)$in['export_sort'];
+ $new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
+ }
+ if (array_key_exists('pass_rate', $in)) {
+ $pr = (float)$in['pass_rate'];
+ $new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
+ }
+ $new['base_url'] = rtrim((string)$new['base_url'], '/');
+ $new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
+ $new['timeout'] = (int)$new['timeout'];
+ $new['refresh_interval'] = (int)$new['refresh_interval'];
+ if ($new['refresh_interval'] < 3) {
+ $new['refresh_interval'] = 3;
+ }
+ // 默认满分
+ $fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
+ if ($fs < 1) {
+ $fs = 100;
+ }
+ $new['full_score'] = $fs;
+
+ // 账号/密码强制字符串(历史数据里可能存成 null,导致 (string) 后变成空 → 报"未配置账号")
+ $new['username'] = (string)($new['username'] ?? '');
+ $new['password'] = (string)($new['password'] ?? '');
+
+ // 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
+ $connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
+ $connChanged = false;
+ foreach ($connKeys as $k) {
+ if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
+ $connChanged = true;
+ break;
+ }
+ }
+
+ return ['config' => $new, 'conn_changed' => $connChanged];
+}
+
/**
* 解析「本卷满分 + 合格率」:
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
@@ -341,6 +504,31 @@ function ny_resolve_score_cfg(string $projectId, array $c): array
return [$fs, $pr];
}
+/**
+ * PHP 会话目录是否可用(登录状态能不能保持住)。
+ * 目录不可写时 session_start() 只会告警,登录态存不下来 → 表现为"登录完又被踢回登录页"。
+ */
+function ny_session_usable(?string &$why = null): bool
+{
+ $dir = (string)@session_save_path();
+ // save_path 可能是 "N;/path"(多级目录散列),取分号后的真实路径
+ if (preg_match('/^\d+;(.*)$/', $dir, $m) === 1) {
+ $dir = $m[1];
+ }
+ if ($dir === '') {
+ $dir = sys_get_temp_dir();
+ }
+ if (!is_dir($dir)) {
+ $why = '会话目录不存在:' . $dir;
+ return false;
+ }
+ if (!is_writable($dir)) {
+ $why = '会话目录不可写:' . $dir;
+ return false;
+ }
+ return true;
+}
+
/** 安全的字符串化(避免数组/对象转字符串报错) */
function ny_str(mixed $v): string
{
diff --git a/dashboard.php b/dashboard.php
index 598e573..c1ae78c 100644
--- a/dashboard.php
+++ b/dashboard.php
@@ -161,10 +161,43 @@ async function api(action, params){
const j = await res.json();
if (j && j.ok) return j.data;
const err = new Error((j && (j.msg || j.error)) || '请求失败');
- err.auth = !!(j && j.extra && j.extra.auth);
+ // 401 = 本系统会话已失效,同样需要重新登录(防死循环闸门见 handleAuthFail)
+ err.auth = !!(j && j.extra && j.extra.auth) || res.status === 401;
+ err.extra = (j && j.extra) || null;
throw err;
}
+/* ---------- 鉴权失败:防反复跳登录页的死循环 ----------
+ * 若刚登录完(120 秒内)又鉴权失败,说明"重新登录"解决不了问题(多半是凭据没存住),
+ * 此时不再跳转,直接把服务端原因 + 落盘诊断显示出来,让人能自查。 */
+function authJustLoggedIn(){
+ try {
+ const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10);
+ return t > 0 && (Date.now() - t) < 120000;
+ } catch (e) { return false; }
+}
+function authJumpCount(){
+ try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; }
+}
+function showAuthFailureBox(e){
+ const d = (e.extra && e.extra.diag) || null;
+ const yn = v => (v ? '是' : '否');
+ const lines = d ? [
+ '配置来源:' + (d.storage || '-') + '(配置文件可写=' + yn(d.cfg_writable) + ')',
+ '数据目录:' + (d.data_dir || '-') + '(可写=' + yn(d.dir_writable) + ')',
+ '数据库:' + (d.db_connected ? ('已连接(可写=' + yn(d.db_writable) + ')') : ('未连接 ' + (d.db_error || ''))),
+ '已存账号:' + (d.has_username ? (d.username || '已设置') : '空 ← 这就是「未配置账号」的原因'),
+ '已存密码:' + (d.has_password ? '已保存' : '空'),
+ d.save_error ? ('保存异常:' + d.save_error) : '',
+ ].filter(Boolean) : [];
+ $('#boardBody').innerHTML =
+ '' +
+ '
无法访问卷王' + esc(e.message || '') +
+ (lines.length ? '
' + esc(lines.join('\n')) + '' : '') +
+ '
' +
+ '
';
+}
+
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
function renderProjects(list){
const sel = $('#projectSel');
@@ -249,6 +282,9 @@ async function refreshClassSel(){
renderBoard();
} catch (e) {
if (e.auth) {
+ const jumps = authJumpCount();
+ if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e); return; }
+ try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
// 卷王账号未配置或登录失效:跳转登录页自愈
$('#boardBody').innerHTML = '';
setTimeout(() => { location.href = 'login.php'; }, 1200);
@@ -372,7 +408,10 @@ function markStale(msg){
// 具体原因放进 title(鼠标悬停可见),正文保持简短
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
}
-function handleAuthFail(){
+function handleAuthFail(e){
+ const jumps = authJumpCount();
+ if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e || new Error('鉴权失败')); return; }
+ try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
$('#boardBody').innerHTML = '';
setTimeout(() => { location.href = 'login.php'; }, 1200);
}
@@ -524,7 +563,7 @@ async function refreshBoard(manual){
$('#lastUpdate').textContent = fresh.updated_at || '-';
markLive();
} catch (e) {
- if (e.auth) { handleAuthFail(); return; }
+ if (e.auth) { handleAuthFail(e); return; }
// 静默续期:保留旧数据,但把实时点变红提示中断
markStale(e.message);
} finally {
diff --git a/lib/Db.php b/lib/Db.php
index a4ca7c5..79eaeb8 100644
--- a/lib/Db.php
+++ b/lib/Db.php
@@ -170,12 +170,18 @@ class Db
// ---------------- 设置 ----------------
- /** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
- public static function settingsSet(string $k, string $v): void
+ /**
+ * SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本)
+ *
+ * 返回是否真的写成功:以前这里 void + 内部吞异常,导致"配置没落盘但上层以为成功"
+ * (典型后果:登录成功→下一请求仍报"未配置账号"→反复跳登录页的死循环)。
+ * 因此改为回读校验后返回 bool,并把失败原因放进 self::lastError()。
+ */
+ public static function settingsSet(string $k, string $v): bool
{
$pdo = self::pdo();
if (!$pdo) {
- return;
+ return false;
}
try {
$p = self::prefix();
@@ -184,6 +190,37 @@ class Db
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
);
$st->execute([$k, $v, time()]);
+ } catch (Throwable $e) {
+ self::$err = $e->getMessage();
+ return false;
+ }
+ // 回读校验:磁盘满 / 目录不可写 / 只读挂载时 execute 可能不报错但实际未落盘
+ try {
+ $p = self::prefix();
+ $st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
+ $st->execute([$k]);
+ $got = $st->fetchColumn();
+ if ($got === false || (string)$got !== $v) {
+ self::$err = '写入后回读不一致(可能磁盘已满或数据库只读)';
+ return false;
+ }
+ } catch (Throwable $e) {
+ self::$err = $e->getMessage();
+ return false;
+ }
+ return true;
+ }
+
+ public static function settingsDelete(string $k): void
+ {
+ $pdo = self::pdo();
+ if (!$pdo) {
+ return;
+ }
+ try {
+ $p = self::prefix();
+ $st = $pdo->prepare("DELETE FROM {$p}settings WHERE k = ?");
+ $st->execute([$k]);
} catch (Throwable $e) {
self::$err = $e->getMessage();
}
diff --git a/login.php b/login.php
index 47e4f71..232ede2 100644
--- a/login.php
+++ b/login.php
@@ -10,13 +10,17 @@ require __DIR__ . '/bootstrap.php';
// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好
$cfg = ny_load_config();
+$notice = ''; // 非阻断提示(例如"上次会话已失效,请重新登录")
+
if (ny_is_auth()) {
- // 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录
+ // 已登录但卷王 token 可能已过期:用当前 token 探一次卷王,能通即视为有效,否则清掉本地会话强制重新登录
try {
$client = new SurveyKingClient($cfg);
$client->ping();
} catch (Throwable $e) {
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
+ // 把失败原因带出来,避免用户只看到登录框却不知道为什么被踢回来
+ $notice = '已退出之前的登录(' . $e->getMessage() . ')';
$_SESSION = [];
@session_destroy();
}
@@ -43,13 +47,36 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$client = new SurveyKingClient($test);
$client->getToken(true);
+
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
$cfg['username'] = $user;
$cfg['password'] = $pwd;
- ny_save_config($cfg);
- ny_login();
- $ok = true;
+ $saved = ny_save_config($cfg);
+
+ // 关键:必须回读确认真的存住了。以前这里不看返回值,data 目录不可写时
+ // 会显示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳回登录页。
+ $back = ny_load_config();
+ $persistOk = $saved
+ && trim((string)($back['username'] ?? '')) === $user
+ && (string)($back['password'] ?? '') === $pwd;
+
+ if (!$persistOk) {
+ $d = ny_config_diag();
+ $err = "登录卷王成功,但凭据没能保存到本机配置,继续进入会立刻又提示“未配置账号”。\n"
+ . "请检查 data 目录写权限后重试:\n"
+ . "· 配置来源:" . $d['storage'] . "(可写=" . ($d['cfg_writable'] ? '是' : '否') . ")\n"
+ . "· 数据目录:" . $d['data_dir'] . "(可写=" . ($d['dir_writable'] ? '是' : '否') . ")\n"
+ . "· 数据库:" . ($d['db_connected'] ? '已连接(可写=' . ($d['db_writable'] ? '是' : '否') . ')' : '未连接 ' . $d['db_error']) . "\n"
+ . "· 保存错误:" . ($d['save_error'] !== '' ? $d['save_error'] : '(无)');
+ } elseif (!ny_session_usable($sessWhy)) {
+ // 会话存不住时同样会"登录成功→立刻被踢回登录页",必须在这一步拦住
+ $err = "凭据已保存,但 PHP 会话不可用,登录状态无法保持:\n" . $sessWhy
+ . "\n请在 php.ini 中把 session.save_path 指向一个可写目录后重试。";
+ } else {
+ ny_login();
+ $ok = true;
+ }
} catch (Throwable $e) {
$err = '登录失败:' . $e->getMessage();
}
@@ -75,9 +102,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
系统连接信息已配置(= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。
+
+ ⚠️ 本机尚未保存卷王账号(配置来源:= htmlspecialchars(ny_config_source()) ?>)。
+如果每次登录成功都回到本页,多半是 data 目录不可写导致账号没存住——
+请让 data/ 目录(= htmlspecialchars(NY_DATA) ?>)对 PHP 进程可写后重试。
+
= htmlspecialchars($err) ?>
+
+ = htmlspecialchars($notice) ?>