Compare commits
2
Commits
eb5d06d392
...
f661862d46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f661862d46 | ||
|
|
555f500dc7 |
@@ -171,66 +171,10 @@ try {
|
|||||||
$in = $_POST;
|
$in = $_POST;
|
||||||
}
|
}
|
||||||
$old = cfg();
|
$old = cfg();
|
||||||
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
|
// 合并规则统一放在 ny_apply_config_input(含"账号/密码不许被改空"的兜底),便于单测
|
||||||
$new = $old;
|
$applied = ny_apply_config_input($old, $in);
|
||||||
foreach ($allowed as $k) {
|
$new = $applied['config'];
|
||||||
if (array_key_exists($k, $in)) {
|
$connChanged = $applied['conn_changed'];
|
||||||
$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_save_config($new);
|
ny_save_config($new);
|
||||||
if ($connChanged) {
|
if ($connChanged) {
|
||||||
@unlink(NY_DATA . '/cache/token.json');
|
@unlink(NY_DATA . '/cache/token.json');
|
||||||
@@ -954,8 +898,11 @@ try {
|
|||||||
$isAuth = str_contains($msg, '未配置卷王登录账号')
|
$isAuth = str_contains($msg, '未配置卷王登录账号')
|
||||||
|| str_contains($msg, '登录卷王失败')
|
|| str_contains($msg, '登录卷王失败')
|
||||||
|| str_contains($msg, '登录失败');
|
|| str_contains($msg, '登录失败');
|
||||||
|
// 鉴权失败时回传落盘诊断(账号到底有没有存住、data 目录可不可写),
|
||||||
|
// 否则前端只能给出"账号未配置"这一句空话,用户无法自查,也容易陷入反复跳登录页。
|
||||||
ny_fail($msg, [
|
ny_fail($msg, [
|
||||||
'auth' => $isAuth,
|
'auth' => $isAuth,
|
||||||
|
'diag' => $isAuth ? ny_config_diag() : null,
|
||||||
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
|
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-27
@@ -31,6 +31,8 @@
|
|||||||
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
|
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
|
||||||
_kwDebounce: null, // 搜索框防抖定时器
|
_kwDebounce: null, // 搜索框防抖定时器
|
||||||
csrf: '', // CSRF token(来自 config_get),写请求自动附带
|
csrf: '', // CSRF token(来自 config_get),写请求自动附带
|
||||||
|
_authRedirecting: false, // 鉴权失败已接管(防止并发请求重复跳登录页)
|
||||||
|
_authStopped: false, // 鉴权失败已停自动刷新(等用户处理,不再定时重试)
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------------- 本地存储 ---------------- */
|
/* ---------------- 本地存储 ---------------- */
|
||||||
@@ -89,7 +91,9 @@
|
|||||||
}
|
}
|
||||||
if (!json.ok) {
|
if (!json.ok) {
|
||||||
const err = new Error(json.msg || '请求失败');
|
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;
|
err.extra = json.extra || null;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -154,6 +158,106 @@
|
|||||||
return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
|
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 =
|
||||||
|
'<div class="modal-box" style="width:600px">' +
|
||||||
|
'<div class="modal-head"><h3>无法访问卷王</h3><button class="x" data-close>×</button></div>' +
|
||||||
|
'<div class="modal-body">' +
|
||||||
|
'<p style="margin:0 0 10px;line-height:1.7">' + esc((e && e.message) || '鉴权失败') + '</p>' +
|
||||||
|
'<p style="margin:0 0 10px;line-height:1.7;color:var(--text-2)">' +
|
||||||
|
'为避免一直把您踢回登录页,已停止自动跳转。请按下面提示处理后点【重试】。</p>' +
|
||||||
|
(lines.length
|
||||||
|
? '<pre style="white-space:pre-wrap;margin:0 0 10px;padding:10px 12px;border-radius:8px;background:var(--border-2);font-size:12px;line-height:1.9">' + esc(lines.join('\n')) + '</pre>'
|
||||||
|
: '') +
|
||||||
|
'<p style="margin:0;line-height:1.7;color:var(--text-2)">常见原因:① data 目录不可写(账号密码存不住);' +
|
||||||
|
'② 卷王账号/密码已变更;③ 卷王地址不可达。</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="modal-foot">' +
|
||||||
|
'<button class="btn" data-auth-retry>重试</button>' +
|
||||||
|
'<button class="btn" data-auth-settings>打开设置</button>' +
|
||||||
|
'<button class="btn btn-primary" data-auth-relogin>重新登录</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
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(抽出便于缓存复用)
|
// 把数组渲染到 #projectSel(抽出便于缓存复用)
|
||||||
function renderProjects() {
|
function renderProjects() {
|
||||||
@@ -234,6 +338,7 @@
|
|||||||
LS.set(LS_PROJECTS, state.projects);
|
LS.set(LS_PROJECTS, state.projects);
|
||||||
renderProjects();
|
renderProjects();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.auth) { handleAuthError(e); return; }
|
||||||
if (!cached || !cached.length) {
|
if (!cached || !cached.length) {
|
||||||
setConn('conn-err', '连接失败');
|
setConn('conn-err', '连接失败');
|
||||||
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
|
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
|
||||||
@@ -374,21 +479,7 @@
|
|||||||
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
|
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.auth) {
|
if (e.auth) {
|
||||||
// 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
|
handleAuthError(e);
|
||||||
// 关键:必须先调 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
@@ -926,6 +1017,7 @@
|
|||||||
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
|
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
|
||||||
submit_ts: r.submit_ts || r.last_submit_ts || 0,
|
submit_ts: r.submit_ts || r.last_submit_ts || 0,
|
||||||
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
|
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
|
||||||
|
day: '', day_best: false, day_count: 1,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
// 新 -> 旧 展示(缺 submit_ts 时按 idx 倒序兜底,保证"次序"看起来合理)
|
// 新 -> 旧 展示(缺 submit_ts 时按 idx 倒序兜底,保证"次序"看起来合理)
|
||||||
@@ -934,17 +1026,41 @@
|
|||||||
if (ta !== tb) return tb - ta;
|
if (ta !== tb) return tb - ta;
|
||||||
return (b.idx || 0) - (a.idx || 0);
|
return (b.idx || 0) - (a.idx || 0);
|
||||||
});
|
});
|
||||||
const rows = desc.map((a, i) => {
|
// 按「提交时间年月日」分组:新的一天在前。
|
||||||
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
|
// 日期键由后端按服务端本地时区算好(a.day),这里仅兜底;时间未知的(ts=0)key 为 '',
|
||||||
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
|
// 且排序里排最后,天然连续落在末尾 → 归入「未知时间」组。
|
||||||
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
const groups = [];
|
||||||
const no = attempts > 1 ? (a.idx || (desc.length - i)) : 1;
|
desc.forEach(a => {
|
||||||
return '<tr>' +
|
const key = a.day || (a.submit_at && a.submit_at !== '-' ? String(a.submit_at).slice(0, 10) : '') || '';
|
||||||
'<td class="num">' + (attempts > 1 ? '第 ' + no + ' 次' : '-') + '</td>' +
|
let g = groups.length ? groups[groups.length - 1] : null;
|
||||||
'<td class="num">' + esc(a.submit_at || '-') + '</td>' +
|
if (!g || g.key !== key) { g = { key: key, items: [] }; groups.push(g); }
|
||||||
'<td class="num">' + sc + best + tmp + '</td>' +
|
g.items.push(a);
|
||||||
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
|
});
|
||||||
'</tr>';
|
// 每组的最高分(未判分不参与),用于分组行显示「最高 X 分」
|
||||||
|
groups.forEach(g => {
|
||||||
|
g.max = g.items.reduce(
|
||||||
|
(m, a) => (a.score == null ? m : (m == null || Number(a.score) > m ? Number(a.score) : m)), null);
|
||||||
|
});
|
||||||
|
const rows = groups.map(g => {
|
||||||
|
const maxTxt = (g.max == null) ? '' : ' · 最高 ' + g.max + ' 分';
|
||||||
|
let html = '<tr class="att-day"><td colspan="4">' + esc(g.key || '未知时间') +
|
||||||
|
'<span class="att-day-sub">共 ' + g.items.length + ' 次' + maxTxt + '</span></td></tr>';
|
||||||
|
html += g.items.map(a => {
|
||||||
|
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
|
||||||
|
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
|
||||||
|
// 当日最高:仅当天有多次提交时才标,避免"一天只考一次"时每行都挂标签、反而看不出重点
|
||||||
|
const dayBest = (a.day_best && (a.day_count || 0) >= 2) ? ' <span class="tag tag-day">当日最高</span>' : '';
|
||||||
|
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
||||||
|
// 时间未知(submit_ts=0)时无从判断先后,次序显示 '-',而不是误报"第 1 次"
|
||||||
|
const noTxt = (attempts > 1 && a.submit_ts) ? ('第 ' + (a.idx || 1) + ' 次') : '-';
|
||||||
|
return '<tr>' +
|
||||||
|
'<td class="num">' + noTxt + '</td>' +
|
||||||
|
'<td class="num">' + esc(a.submit_at || '-') + '</td>' +
|
||||||
|
'<td class="num">' + sc + best + dayBest + tmp + '</td>' +
|
||||||
|
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
return html;
|
||||||
}).join('');
|
}).join('');
|
||||||
const bestScore = (r.best_score == null) ? r.score : r.best_score;
|
const bestScore = (r.best_score == null) ? r.score : r.best_score;
|
||||||
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
|
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
|
||||||
@@ -964,6 +1080,8 @@
|
|||||||
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
|
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
|
||||||
function schedule() {
|
function schedule() {
|
||||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||||
|
// 鉴权失败已停止自动刷新(handleAuthError),别再排下一轮把用户反复踢走
|
||||||
|
if (state._authStopped) return;
|
||||||
if (!$('#autoRefresh').checked) return;
|
if (!$('#autoRefresh').checked) return;
|
||||||
const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000;
|
const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000;
|
||||||
const backoff = Math.min(8, Math.pow(2, state.connFail)) * base;
|
const backoff = Math.min(8, Math.pow(2, state.connFail)) * base;
|
||||||
|
|||||||
@@ -393,6 +393,11 @@ td.att { font-weight: 700; color: var(--text-2); }
|
|||||||
.tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; }
|
.tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; }
|
||||||
.tag-new { background: var(--ok-weak); color: var(--ok); }
|
.tag-new { background: var(--ok-weak); color: var(--ok); }
|
||||||
.tag-best { background: #fff1dc; color: #b26a06; }
|
.tag-best { background: #fff1dc; color: #b26a06; }
|
||||||
|
/* 当日最高(历史成绩按天分组后,组内最高分) */
|
||||||
|
.tag-day { background: var(--primary-weak); color: var(--primary-2); }
|
||||||
|
/* 历史成绩弹窗:按提交日期分组的分组行 */
|
||||||
|
tr.att-day > td { background: var(--border-2); font-weight: 600; color: var(--text-2); }
|
||||||
|
tr.att-day > td .att-day-sub { margin-left: 8px; font-weight: 400; font-size: 12px; color: var(--text-3); }
|
||||||
|
|
||||||
/* 多次刷题:成员榜可点击行 */
|
/* 多次刷题:成员榜可点击行 */
|
||||||
tr.clickable { cursor: pointer; }
|
tr.clickable { cursor: pointer; }
|
||||||
|
|||||||
+193
-5
@@ -282,23 +282,48 @@ function ny_load_config(): array
|
|||||||
}
|
}
|
||||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||||||
|
// 账号/密码一律转成字符串:历史数据里若存成 null,不改这里就会表现为"未配置卷王登录账号"
|
||||||
|
$cfg['username'] = trim((string)($cfg['username'] ?? ''));
|
||||||
|
$cfg['password'] = (string)($cfg['password'] ?? '');
|
||||||
return $cfg;
|
return $cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ny_save_config(array $cfg): bool
|
function ny_save_config(array $cfg): bool
|
||||||
{
|
{
|
||||||
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
|
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
|
||||||
|
// 关键:两处都必须做"回读校验"。以前只管写不管是否真落盘,data/ 目录不可写时
|
||||||
|
// 登录页会照常提示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳登录页。
|
||||||
|
$GLOBALS['__ny_cfg_save_error'] = '';
|
||||||
|
$notes = [];
|
||||||
|
$json = json_encode($cfg, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
$ok = false;
|
$ok = false;
|
||||||
if (class_exists('Db') && Db::pdo() !== null) {
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
try {
|
try {
|
||||||
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
|
if (Db::settingsSet('config', $json)) {
|
||||||
$GLOBALS['__ny_cfg_source'] = 'db';
|
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||||
$ok = true;
|
$ok = true;
|
||||||
|
} else {
|
||||||
|
$notes[] = '数据库写入失败:' . (Db::lastError() ?: '未知原因');
|
||||||
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$ok = false;
|
$notes[] = '数据库写入异常:' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
$notes[] = '数据库不可用(' . (class_exists('Db') ? Db::lastError() : 'Db 类未加载') . ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
$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 / 部署文档);
|
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
|
||||||
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
|
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
|
||||||
@@ -306,11 +331,149 @@ function ny_save_config(array $cfg): bool
|
|||||||
@chmod(ny_config_file(), 0600);
|
@chmod(ny_config_file(), 0600);
|
||||||
}
|
}
|
||||||
if (!$ok) {
|
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;
|
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];
|
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
|
function ny_str(mixed $v): string
|
||||||
{
|
{
|
||||||
|
|||||||
+42
-3
@@ -161,10 +161,43 @@ async function api(action, params){
|
|||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (j && j.ok) return j.data;
|
if (j && j.ok) return j.data;
|
||||||
const err = new Error((j && (j.msg || j.error)) || '请求失败');
|
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;
|
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 =
|
||||||
|
'<div class="empty" style="text-align:left;line-height:1.9">' +
|
||||||
|
'<b>无法访问卷王</b><br>' + esc(e.message || '') +
|
||||||
|
(lines.length ? '<pre style="white-space:pre-wrap;margin:10px 0 0;text-align:left;font-size:12px">' + esc(lines.join('\n')) + '</pre>' : '') +
|
||||||
|
'<div style="margin-top:12px"><a class="btn btn-primary" href="login.php">重新登录</a></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
|
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
|
||||||
function renderProjects(list){
|
function renderProjects(list){
|
||||||
const sel = $('#projectSel');
|
const sel = $('#projectSel');
|
||||||
@@ -249,6 +282,9 @@ async function refreshClassSel(){
|
|||||||
renderBoard();
|
renderBoard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.auth) {
|
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 = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
||||||
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
||||||
@@ -372,7 +408,10 @@ function markStale(msg){
|
|||||||
// 具体原因放进 title(鼠标悬停可见),正文保持简短
|
// 具体原因放进 title(鼠标悬停可见),正文保持简短
|
||||||
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
|
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 = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
||||||
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
||||||
}
|
}
|
||||||
@@ -524,7 +563,7 @@ async function refreshBoard(manual){
|
|||||||
$('#lastUpdate').textContent = fresh.updated_at || '-';
|
$('#lastUpdate').textContent = fresh.updated_at || '-';
|
||||||
markLive();
|
markLive();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.auth) { handleAuthFail(); return; }
|
if (e.auth) { handleAuthFail(e); return; }
|
||||||
// 静默续期:保留旧数据,但把实时点变红提示中断
|
// 静默续期:保留旧数据,但把实时点变红提示中断
|
||||||
markStale(e.message);
|
markStale(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+76
-20
@@ -463,8 +463,11 @@ class DataService
|
|||||||
if ($list === []) {
|
if ($list === []) {
|
||||||
// 没有明细时退回代表行本身
|
// 没有明细时退回代表行本身
|
||||||
$one = $r;
|
$one = $r;
|
||||||
$one['idx'] = 1;
|
$one['idx'] = 1;
|
||||||
$one['is_best'] = true;
|
$one['is_best'] = true;
|
||||||
|
$one['day'] = self::dayKeyOf((int)($r['submit_ts'] ?? 0), (string)($r['submit_at'] ?? ''));
|
||||||
|
$one['day_best'] = true;
|
||||||
|
$one['day_count'] = 1;
|
||||||
$out[] = $one;
|
$out[] = $one;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -485,6 +488,9 @@ class DataService
|
|||||||
'best_score' => $r['best_score'] ?? null,
|
'best_score' => $r['best_score'] ?? null,
|
||||||
'idx' => (int)($a['idx'] ?? 1),
|
'idx' => (int)($a['idx'] ?? 1),
|
||||||
'is_best' => !empty($a['is_best']),
|
'is_best' => !empty($a['is_best']),
|
||||||
|
'day' => (string)($a['day'] ?? ''),
|
||||||
|
'day_best' => !empty($a['day_best']),
|
||||||
|
'day_count' => (int)($a['day_count'] ?? 1),
|
||||||
'last_submit_ts' => (int)($r['last_submit_ts'] ?? 0),
|
'last_submit_ts' => (int)($r['last_submit_ts'] ?? 0),
|
||||||
'last_submit_at' => (string)($r['last_submit_at'] ?? '-'),
|
'last_submit_at' => (string)($r['last_submit_at'] ?? '-'),
|
||||||
];
|
];
|
||||||
@@ -552,6 +558,28 @@ class DataService
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某次提交的「年月日」分组键。
|
||||||
|
*
|
||||||
|
* 关键:一律用服务端本地时区 date('Y-m-d', $ts) 计算,**不能用 UTC**(gmdate / JS toISOString),
|
||||||
|
* 否则零点前后提交的答卷会被算到前一天/后一天(东八区会整体错一天)。
|
||||||
|
* 无有效时间戳(createAt 缺失导致 ny_to_timestamp 返回 0)时,退回从显示串取前 10 位;
|
||||||
|
* 仍取不到则返回空串,上层归为「未知时间」分组并排在最后。
|
||||||
|
*
|
||||||
|
* @param int $ts 提交时间戳(秒),0 表示未知
|
||||||
|
* @param string $submitAt 展示用时间串(形如 2026-09-08 09:41:20)
|
||||||
|
*/
|
||||||
|
public static function dayKeyOf(int $ts, string $submitAt = ''): string
|
||||||
|
{
|
||||||
|
if ($ts > 0) {
|
||||||
|
return date('Y-m-d', $ts);
|
||||||
|
}
|
||||||
|
if ($submitAt !== '' && $submitAt !== '-' && preg_match('/^(\d{4}-\d{2}-\d{2})/', $submitAt, $m) === 1) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
|
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
|
||||||
*
|
*
|
||||||
@@ -589,27 +617,55 @@ class DataService
|
|||||||
}
|
}
|
||||||
// 最近一次提交(升序排序后的最后一份)
|
// 最近一次提交(升序排序后的最后一份)
|
||||||
$lastSubmitTs = (int)($sorted[count($sorted) - 1]['submit_ts'] ?? 0);
|
$lastSubmitTs = (int)($sorted[count($sorted) - 1]['submit_ts'] ?? 0);
|
||||||
|
|
||||||
|
// 按「提交时间年月日」分组统计:每天的最高分与提交次数。
|
||||||
|
// 历史弹窗据此按天分类,并在每个分类内标记当日最高分。
|
||||||
|
$dayMax = []; // day => 当天最高分
|
||||||
|
$dayCount = []; // day => 当天提交次数(含未判分)
|
||||||
|
foreach ($sorted as $a) {
|
||||||
|
$day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? ''));
|
||||||
|
$dayCount[$day] = ($dayCount[$day] ?? 0) + 1;
|
||||||
|
if ($a['score'] !== null && (!array_key_exists($day, $dayMax) || $a['score'] > $dayMax[$day])) {
|
||||||
|
$dayMax[$day] = $a['score'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次)
|
// 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次)
|
||||||
$bestMarked = false;
|
$bestMarked = false;
|
||||||
|
// 每天是否已标记过"当日最高"(同分时同样只标最早达到的那次,与全局 is_best 一致)
|
||||||
|
$dayMarked = [];
|
||||||
$idx = 0;
|
$idx = 0;
|
||||||
$attemptsList = array_map(static function (array $a) use (&$idx, $maxScore, &$bestMarked): array {
|
$attemptsList = array_map(
|
||||||
$idx++;
|
static function (array $a) use (&$idx, $maxScore, &$bestMarked, &$dayMarked, $dayMax, $dayCount): array {
|
||||||
$isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore);
|
$idx++;
|
||||||
if ($isBest) {
|
$isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore);
|
||||||
$bestMarked = true;
|
if ($isBest) {
|
||||||
}
|
$bestMarked = true;
|
||||||
return [
|
}
|
||||||
'idx' => $idx,
|
$day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? ''));
|
||||||
'is_best' => $isBest,
|
$dayBest = ($a['score'] !== null && array_key_exists($day, $dayMax)
|
||||||
'submit_at' => $a['submit_at'],
|
&& (float)$a['score'] === (float)$dayMax[$day] && empty($dayMarked[$day]));
|
||||||
'submit_ts' => $a['submit_ts'],
|
if ($dayBest) {
|
||||||
'score' => $a['score'],
|
$dayMarked[$day] = true;
|
||||||
'duration' => $a['duration'],
|
}
|
||||||
'duration_txt' => $a['duration_txt'],
|
return [
|
||||||
'answer_id' => $a['answer_id'],
|
'idx' => $idx,
|
||||||
'temp_save' => $a['temp_save'],
|
'is_best' => $isBest,
|
||||||
];
|
'submit_at' => $a['submit_at'],
|
||||||
}, $sorted);
|
'submit_ts' => $a['submit_ts'],
|
||||||
|
'score' => $a['score'],
|
||||||
|
'duration' => $a['duration'],
|
||||||
|
'duration_txt' => $a['duration_txt'],
|
||||||
|
'answer_id' => $a['answer_id'],
|
||||||
|
'temp_save' => $a['temp_save'],
|
||||||
|
// 按天分类用:day 为空串表示时间未知
|
||||||
|
'day' => $day,
|
||||||
|
'day_best' => $dayBest,
|
||||||
|
'day_count' => (int)($dayCount[$day] ?? 1),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
$sorted
|
||||||
|
);
|
||||||
|
|
||||||
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
|
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
|
||||||
if ($dedupe) {
|
if ($dedupe) {
|
||||||
|
|||||||
+40
-3
@@ -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();
|
$pdo = self::pdo();
|
||||||
if (!$pdo) {
|
if (!$pdo) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$p = self::prefix();
|
$p = self::prefix();
|
||||||
@@ -184,6 +190,37 @@ class Db
|
|||||||
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
|
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
|
||||||
);
|
);
|
||||||
$st->execute([$k, $v, time()]);
|
$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) {
|
} catch (Throwable $e) {
|
||||||
self::$err = $e->getMessage();
|
self::$err = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ require __DIR__ . '/bootstrap.php';
|
|||||||
// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好
|
// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好
|
||||||
$cfg = ny_load_config();
|
$cfg = ny_load_config();
|
||||||
|
|
||||||
|
$notice = ''; // 非阻断提示(例如"上次会话已失效,请重新登录")
|
||||||
|
|
||||||
if (ny_is_auth()) {
|
if (ny_is_auth()) {
|
||||||
// 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录
|
// 已登录但卷王 token 可能已过期:用当前 token 探一次卷王,能通即视为有效,否则清掉本地会话强制重新登录
|
||||||
try {
|
try {
|
||||||
$client = new SurveyKingClient($cfg);
|
$client = new SurveyKingClient($cfg);
|
||||||
$client->ping();
|
$client->ping();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
|
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
|
||||||
|
// 把失败原因带出来,避免用户只看到登录框却不知道为什么被踢回来
|
||||||
|
$notice = '已退出之前的登录(' . $e->getMessage() . ')';
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
@session_destroy();
|
@session_destroy();
|
||||||
}
|
}
|
||||||
@@ -43,13 +47,36 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
try {
|
try {
|
||||||
$client = new SurveyKingClient($test);
|
$client = new SurveyKingClient($test);
|
||||||
$client->getToken(true);
|
$client->getToken(true);
|
||||||
|
|
||||||
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
|
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
|
||||||
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
|
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
|
||||||
$cfg['username'] = $user;
|
$cfg['username'] = $user;
|
||||||
$cfg['password'] = $pwd;
|
$cfg['password'] = $pwd;
|
||||||
ny_save_config($cfg);
|
$saved = ny_save_config($cfg);
|
||||||
ny_login();
|
|
||||||
$ok = true;
|
// 关键:必须回读确认真的存住了。以前这里不看返回值,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) {
|
} catch (Throwable $e) {
|
||||||
$err = '登录失败:' . $e->getMessage();
|
$err = '登录失败:' . $e->getMessage();
|
||||||
}
|
}
|
||||||
@@ -75,9 +102,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="login-tip">系统连接信息已配置(<?= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/<?= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。</p>
|
<p class="login-tip">系统连接信息已配置(<?= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/<?= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。</p>
|
||||||
|
<?php if (trim((string)$cfg['username']) === ''): ?>
|
||||||
|
<div class="alert" style="white-space:pre-wrap">⚠️ 本机尚未保存卷王账号(配置来源:<?= htmlspecialchars(ny_config_source()) ?>)。
|
||||||
|
如果每次登录成功都回到本页,多半是 data 目录不可写导致账号没存住——
|
||||||
|
请让 data/ 目录(<?= htmlspecialchars(NY_DATA) ?>)对 PHP 进程可写后重试。</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($err !== ''): ?>
|
<?php if ($err !== ''): ?>
|
||||||
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($err) ?></div>
|
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($err) ?></div>
|
||||||
|
<?php elseif ($notice !== ''): ?>
|
||||||
|
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($notice) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -96,7 +130,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<div class="login-brand" style="justify-content:center"><span class="logo">NY</span>
|
<div class="login-brand" style="justify-content:center"><span class="logo">NY</span>
|
||||||
<div class="brand-text"><strong>登录成功</strong><small>正在进入数据平台…</small></div>
|
<div class="brand-text"><strong>登录成功</strong><small>正在进入数据平台…</small></div>
|
||||||
</div>
|
</div>
|
||||||
<script>setTimeout(function(){ location.href='index.php'; }, 600);</script>
|
<script>
|
||||||
|
// 记录"本次登录成功"的时间戳:前端据此判断"刚登录完又报鉴权失败",
|
||||||
|
// 这种情况不再自动跳回登录页(否则会形成死循环),而是把真实错误显示出来。
|
||||||
|
try { sessionStorage.setItem('ny_login_at', String(Date.now())); sessionStorage.removeItem('ny_auth_jumps'); } catch (e) {}
|
||||||
|
setTimeout(function(){ location.href='index.php'; }, 600);
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
+4
-2
@@ -18,7 +18,7 @@ echo "PHP = $PHP"
|
|||||||
echo "NODE = $NODE"
|
echo "NODE = $NODE"
|
||||||
|
|
||||||
echo "================ PHP 自测 ================"
|
echo "================ PHP 自测 ================"
|
||||||
for f in tests/test-aggregate.php tests/test-new-features.php tools/test-xlsxwriter.php; do
|
for f in tests/test-aggregate.php tests/test-new-features.php tests/test-config-persist.php tools/test-xlsxwriter.php; do
|
||||||
echo ""
|
echo ""
|
||||||
echo ">>> $f"
|
echo ">>> $f"
|
||||||
"$PHP" $INI "$f"
|
"$PHP" $INI "$f"
|
||||||
@@ -30,7 +30,9 @@ echo "================ Node e2e(需 mock-server + PHP 内置服务器) =====
|
|||||||
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
|
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
|
||||||
NY_DATA_TMP="$(pwd)/.ny-test-data"
|
NY_DATA_TMP="$(pwd)/.ny-test-data"
|
||||||
mkdir -p "$NY_DATA_TMP"
|
mkdir -p "$NY_DATA_TMP"
|
||||||
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js; do
|
# 说明:test-login-loop.js 会清空数据目录里的账密来复现"未配置账号"现场,
|
||||||
|
# 即使不传 NY_DATA 它也会自己建临时目录,绝不回退到项目 data/。
|
||||||
|
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js tools/test-login-loop.js; do
|
||||||
echo ""
|
echo ""
|
||||||
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
|
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
|
||||||
NY_DATA="$NY_DATA_TMP" "$NODE" "$f"
|
NY_DATA="$NY_DATA_TMP" "$NODE" "$f"
|
||||||
|
|||||||
@@ -89,6 +89,75 @@ $r4 = $agg4['rows'][0];
|
|||||||
check('单次提交 attempts = 1', ($r4['attempts'] ?? 0) === 1);
|
check('单次提交 attempts = 1', ($r4['attempts'] ?? 0) === 1);
|
||||||
check('单次提交 attempts_list = 1', count($r4['attempts_list'] ?? []) === 1);
|
check('单次提交 attempts_list = 1', count($r4['attempts_list'] ?? []) === 1);
|
||||||
|
|
||||||
|
echo "\n== 按提交时间年月日分组:每个分类内标记当日最高 ==\n";
|
||||||
|
/** 按 [时间戳, 分数] 造行;ts=0 表示时间未知 */
|
||||||
|
function makeRowsAt(string $sid, array $items): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
[$ts, $sc] = $it;
|
||||||
|
$rows[] = [
|
||||||
|
'sid' => $sid,
|
||||||
|
'name' => '测试生',
|
||||||
|
'cls' => '1班',
|
||||||
|
'grp' => 'A',
|
||||||
|
'score' => $sc,
|
||||||
|
'submit_ts' => $ts,
|
||||||
|
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
|
||||||
|
'duration' => 120,
|
||||||
|
'duration_txt' => '2分0秒',
|
||||||
|
'answer_id' => 'ans-' . $ts,
|
||||||
|
'matched' => true,
|
||||||
|
'temp_save' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
// 09-08 三次(70/95/88),09-09 两次(60/90)
|
||||||
|
$rows5 = makeRowsAt('S005', [
|
||||||
|
[strtotime('2026-09-08 09:00:00'), 70.0],
|
||||||
|
[strtotime('2026-09-08 11:00:00'), 95.0],
|
||||||
|
[strtotime('2026-09-08 15:00:00'), 88.0],
|
||||||
|
[strtotime('2026-09-09 10:00:00'), 60.0],
|
||||||
|
[strtotime('2026-09-09 14:00:00'), 90.0],
|
||||||
|
]);
|
||||||
|
$agg5 = DataService::aggregateByStudent($rows5, true);
|
||||||
|
$r5 = $agg5['rows'][0];
|
||||||
|
$al5 = $r5['attempts_list'];
|
||||||
|
check('5 次提交 attempts_list = 5', count($al5) === 5);
|
||||||
|
check('前 3 条归属 2026-09-08', ($al5[0]['day'] ?? '') === '2026-09-08' && ($al5[2]['day'] ?? '') === '2026-09-08',
|
||||||
|
'got ' . ($al5[0]['day'] ?? '?'));
|
||||||
|
check('后 2 条归属 2026-09-09', ($al5[3]['day'] ?? '') === '2026-09-09' && ($al5[4]['day'] ?? '') === '2026-09-09',
|
||||||
|
'got ' . ($al5[3]['day'] ?? '?'));
|
||||||
|
check('09-08 当天次数 = 3', ($al5[0]['day_count'] ?? 0) === 3, 'got ' . ($al5[0]['day_count'] ?? '?'));
|
||||||
|
check('09-09 当天次数 = 2', ($al5[3]['day_count'] ?? 0) === 2, 'got ' . ($al5[3]['day_count'] ?? '?'));
|
||||||
|
$dayBestIdx = array_values(array_filter($al5, fn($a) => !empty($a['day_best'])));
|
||||||
|
check('每个分类各标记 1 个当日最高(共 2 个)', count($dayBestIdx) === 2, 'got ' . count($dayBestIdx));
|
||||||
|
check('09-08 当日最高 = 95(第 2 次)', (int)($dayBestIdx[0]['idx'] ?? 0) === 2 && (float)($dayBestIdx[0]['score'] ?? -1) === 95.0,
|
||||||
|
'got idx=' . ($dayBestIdx[0]['idx'] ?? '?') . ' score=' . ($dayBestIdx[0]['score'] ?? '?'));
|
||||||
|
check('09-09 当日最高 = 90(第 5 次)', (int)($dayBestIdx[1]['idx'] ?? 0) === 5 && (float)($dayBestIdx[1]['score'] ?? -1) === 90.0,
|
||||||
|
'got idx=' . ($dayBestIdx[1]['idx'] ?? '?') . ' score=' . ($dayBestIdx[1]['score'] ?? '?'));
|
||||||
|
check('全局最佳仍是 95(第 2 次)', (int)($al5[1]['is_best'] ?? 0) === 1 || !empty($al5[1]['is_best']));
|
||||||
|
|
||||||
|
echo "\n== 时间未知(submit_ts=0)归入「未知时间」分组 ==\n";
|
||||||
|
$rows6 = makeRowsAt('S006', [[0, 50.0], [0, 80.0]]);
|
||||||
|
$agg6 = DataService::aggregateByStudent($rows6, true);
|
||||||
|
$al6 = $agg6['rows'][0]['attempts_list'];
|
||||||
|
check('时间未知时 day = 空串', ($al6[0]['day'] ?? 'x') === '' && ($al6[1]['day'] ?? 'x') === '',
|
||||||
|
'got ' . var_export($al6[0]['day'] ?? null, true));
|
||||||
|
check('未知时间组次数 = 2', ($al6[0]['day_count'] ?? 0) === 2, 'got ' . ($al6[0]['day_count'] ?? '?'));
|
||||||
|
check('未知时间组内仍标记最高(80)', !empty($al6[1]['day_best']) && (float)($al6[1]['score'] ?? -1) === 80.0);
|
||||||
|
|
||||||
|
echo "\n== 日期键时区边界(不能用 UTC,否则零点前后错一天)==\n";
|
||||||
|
check('00:30 仍算当天(东八区不会退到前一天)',
|
||||||
|
DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')) === '2026-09-08',
|
||||||
|
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')));
|
||||||
|
check('23:30 仍算当天',
|
||||||
|
DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')) === '2026-09-08',
|
||||||
|
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')));
|
||||||
|
check('无时间戳时从展示串取日期', DataService::dayKeyOf(0, '2026-09-08 09:41:20') === '2026-09-08');
|
||||||
|
check('无时间戳且展示串为 - 时返回空串', DataService::dayKeyOf(0, '-') === '');
|
||||||
|
|
||||||
echo "\n========================================\n";
|
echo "\n========================================\n";
|
||||||
echo "通过 {$pass} · 失败 {$fail}\n";
|
echo "通过 {$pass} · 失败 {$fail}\n";
|
||||||
exit($fail === 0 ? 0 : 1);
|
exit($fail === 0 ? 0 : 1);
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 配置落盘 / 鉴权死循环 回归测试
|
||||||
|
*
|
||||||
|
* 背景:登录页"登录成功"后立刻又报"未配置卷王登录账号"并反复跳回登录页。
|
||||||
|
* 根因是 ny_save_config 只写不校验(Db::settingsSet 内部吞异常且返回 void),
|
||||||
|
* data/ 目录不可写时上层以为保存成功,下一个请求读到的账号仍是空的。
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1) ny_save_config 必须回读校验并真实反映落盘结果
|
||||||
|
* 2) ny_load_config 把 null 账号强制成空字符串(历史脏数据不再伪装成"未配置")
|
||||||
|
* 3) ny_apply_config_input:账号/密码不许被设置页误清空,连接信息才触发重连
|
||||||
|
* 4) ny_config_diag 能给出可自查的诊断字段
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 必须赶在 bootstrap 之前把数据目录重定向到临时目录,避免污染生产 data/ny.db
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/ny-cfgtest-' . getmypid() . '-' . bin2hex(random_bytes(3));
|
||||||
|
putenv('NY_DATA=' . $tmpDir);
|
||||||
|
$_ENV['NY_DATA'] = $tmpDir;
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
register_shutdown_function(static function () use ($tmpDir): void {
|
||||||
|
// 清理临时数据目录(Windows 下先删文件再删目录)
|
||||||
|
if (!is_dir($tmpDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (glob($tmpDir . '/*') ?: [] as $f) {
|
||||||
|
@unlink($f);
|
||||||
|
}
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
$pass = 0;
|
||||||
|
$fail = 0;
|
||||||
|
function check(string $name, bool $cond, string $detail = ''): void
|
||||||
|
{
|
||||||
|
global $pass, $fail;
|
||||||
|
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
|
||||||
|
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n== 1) 保存后回读(模拟登录页写凭据) ==\n";
|
||||||
|
$cfg0 = ny_default_config();
|
||||||
|
$cfg0['username'] = 'teacher01';
|
||||||
|
$cfg0['password'] = 'P@ss w0rd';
|
||||||
|
$saved = ny_save_config($cfg0);
|
||||||
|
check('ny_save_config 返回 true', $saved === true, 'save_error=' . ny_config_save_error());
|
||||||
|
|
||||||
|
$back = ny_load_config();
|
||||||
|
check('回读账号一致', ($back['username'] ?? '') === 'teacher01', json_encode($back['username'] ?? null));
|
||||||
|
check('回读密码一致(含空格原样保留)', ($back['password'] ?? '') === 'P@ss w0rd', json_encode($back['password'] ?? null));
|
||||||
|
check('配置来源为 db 或 file', in_array(ny_config_source(), ['db', 'file'], true), ny_config_source());
|
||||||
|
check('无保存错误提示', ny_config_save_error() === '', ny_config_save_error());
|
||||||
|
|
||||||
|
echo "\n== 2) 脏数据兜底:账号被存成 null ==\n";
|
||||||
|
$dirty = $cfg0;
|
||||||
|
$dirty['username'] = null;
|
||||||
|
ny_write_json(ny_config_file(), $dirty);
|
||||||
|
// 强制走文件分支读取:把 DB 里的记录清掉
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
Db::settingsDelete('config');
|
||||||
|
}
|
||||||
|
$dirtyCfg = ny_load_config();
|
||||||
|
check('null 账号 → 空字符串而不是 null', $dirtyCfg['username'] === '', var_export($dirtyCfg['username'], true));
|
||||||
|
check('账号为字符串类型', is_string($dirtyCfg['username']));
|
||||||
|
|
||||||
|
echo "\n== 3) 设置保存:账号/密码不被误清空 ==\n";
|
||||||
|
$old = ny_default_config();
|
||||||
|
$old['username'] = 'admin';
|
||||||
|
$old['password'] = 'secret';
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['username' => '']);
|
||||||
|
check('账号传空 → 保留原账号(防止陷入未配置死循环)', $r['config']['username'] === 'admin', $r['config']['username']);
|
||||||
|
check('账号传空 → 不算连接信息变化', $r['conn_changed'] === false);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['username' => null]);
|
||||||
|
check('账号传 null → 保留原账号', $r['config']['username'] === 'admin', var_export($r['config']['username'], true));
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['password' => '******']);
|
||||||
|
check('密码占位符 ****** → 不修改', $r['config']['password'] === 'secret');
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['password' => '']);
|
||||||
|
check('密码传空 → 不修改', $r['config']['password'] === 'secret');
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['password' => 'newpwd']);
|
||||||
|
check('密码传新值 → 更新', $r['config']['password'] === 'newpwd');
|
||||||
|
check('密码变化 → 标记连接信息已变', $r['conn_changed'] === true);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['username' => 'admin2']);
|
||||||
|
check('账号变化 → 标记连接信息已变', $r['conn_changed'] === true);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['full_score' => 90, 'page_size' => 50]);
|
||||||
|
check('仅改满分/分页 → 不触发重连', $r['conn_changed'] === false);
|
||||||
|
check('满分已写入', (int)$r['config']['full_score'] === 90);
|
||||||
|
check('未传的字段保持原值', (int)$r['config']['page_size'] === 50 && $r['config']['username'] === 'admin');
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['refresh_interval' => 1]);
|
||||||
|
check('刷新间隔最小值收敛到 3', (int)$r['config']['refresh_interval'] === 3);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['base_url' => 'https://exam.nxxy335.top/']);
|
||||||
|
check('base_url 去掉末尾斜杠', $r['config']['base_url'] === 'https://exam.nxxy335.top', $r['config']['base_url']);
|
||||||
|
|
||||||
|
echo "\n== 4) 落盘诊断 ==\n";
|
||||||
|
$d = ny_config_diag();
|
||||||
|
check('诊断含 storage', isset($d['storage']));
|
||||||
|
check('诊断含目录可写标记', array_key_exists('dir_writable', $d));
|
||||||
|
check('诊断含配置文件可写标记', array_key_exists('cfg_writable', $d));
|
||||||
|
check('诊断含数据库可写标记', array_key_exists('db_writable', $d));
|
||||||
|
check('诊断含是否已存账号', array_key_exists('has_username', $d));
|
||||||
|
check('临时目录可写时 cfg_writable=true', $d['cfg_writable'] === true, json_encode($d, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
echo "\n== 5) 数据库写入回读校验 ==\n";
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
check('Db::settingsSet 成功返回 true', Db::settingsSet('__ny_test_key', 'hello') === true);
|
||||||
|
check('Db::settingsGet 读回一致', Db::settingsGet('__ny_test_key', '') === 'hello');
|
||||||
|
Db::settingsDelete('__ny_test_key');
|
||||||
|
check('Db::settingsDelete 生效', Db::settingsGet('__ny_test_key', 'x') === 'x');
|
||||||
|
} else {
|
||||||
|
echo " ⚠️ 无 pdo_sqlite,跳过数据库回读用例\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n----------------------------------------\n";
|
||||||
|
echo "通过 {$pass} 项,失败 {$fail} 项\n";
|
||||||
|
exit($fail === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// e2e: 登录后立刻又报"未配置账号"、反复跳登录页的死循环
|
||||||
|
//
|
||||||
|
// 复现前提:本机配置里账号为空(或没存住)。旧代码 login.php 不看 ny_save_config 结果,
|
||||||
|
// 登录成功页照常显示"登录成功",但下一个请求 ny_load_config() 读到的账号仍是空的
|
||||||
|
// → 抛"未配置卷王登录账号" → 前端 auth 打标 → 跳登录页 → 死循环。
|
||||||
|
//
|
||||||
|
// 本测试锁死三件事:
|
||||||
|
// 1) 登录后凭据必须真的落盘(config_get 能读回账号、has_password=true)
|
||||||
|
// 2) 登录后立刻拉数据必须成功(不再出现"未配置账号")
|
||||||
|
// 3) 真出现鉴权失败时,响应必须带 extra.auth + extra.diag(前端据此停止自动跳转并给出可自查信息)
|
||||||
|
//
|
||||||
|
// 运行:node tools/test-login-loop.js
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PORT = 18087;
|
||||||
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
|
||||||
|
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
|
||||||
|
const MOCK = { user: 'mock', pass: 'surveyking' };
|
||||||
|
|
||||||
|
// NY_DATA 指向临时目录:Git Bash 风格 /c/... 需还原成 C:\...,并写回 env 给 PHP 子进程。
|
||||||
|
// 注意:绝不能回退到项目下的 data/ —— 本测试会清空账密并重建库,落到真实目录等于破坏本地配置
|
||||||
|
// (曾因默认回退到 data/ 把 ny.db 删掉)。未显式指定时一律新建临时目录。
|
||||||
|
const os = require('os');
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-login-loop-' + process.pid);
|
||||||
|
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
|
||||||
|
p = path.resolve(p);
|
||||||
|
process.env.NY_DATA = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const DATA = resolveDataDir();
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
|
const tokenFile = path.join(DATA, 'cache', 'token.json');
|
||||||
|
const dbFile = path.join(DATA, 'ny.db');
|
||||||
|
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
|
||||||
|
const hasDb = fs.existsSync(dbFile);
|
||||||
|
if (hasDb) { try { fs.unlinkSync(dbFile); } catch (e) {} }
|
||||||
|
try { fs.unlinkSync(tokenFile); } catch (e) {}
|
||||||
|
|
||||||
|
// 关键:模拟"账号没配/没存住"的现场——连接信息是对的,账号密码是空的
|
||||||
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
|
base_url: 'http://127.0.0.1:19911',
|
||||||
|
api_prefix: '/api',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
verify_ssl: false,
|
||||||
|
timeout: 10,
|
||||||
|
refresh_interval: 15,
|
||||||
|
page_size: 50,
|
||||||
|
dedupe: true,
|
||||||
|
include_temp_save: false,
|
||||||
|
name_keywords: '姓名,名字',
|
||||||
|
full_score: 100,
|
||||||
|
layout: 'default',
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
let cleanedUp = false, mock = null, srv = null;
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
try { srv && srv.kill('SIGKILL'); } catch (e) {}
|
||||||
|
try { mock && mock.kill('SIGKILL'); } catch (e) {}
|
||||||
|
try {
|
||||||
|
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
|
||||||
|
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
|
||||||
|
} catch (e) {}
|
||||||
|
try { if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile); } catch (e) {}
|
||||||
|
try { if (fs.existsSync(tokenFile)) fs.unlinkSync(tokenFile); } catch (e) {}
|
||||||
|
}
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
|
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||||
|
|
||||||
|
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
|
||||||
|
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'pipe' });
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function chk(name, cond, extra) {
|
||||||
|
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
}
|
||||||
|
const cookieJar = [];
|
||||||
|
let CSRF = '';
|
||||||
|
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
|
||||||
|
function collect(res) {
|
||||||
|
for (const sc of (res.headers['set-cookie'] || [])) {
|
||||||
|
const pair = sc.split(';')[0];
|
||||||
|
const name = pair.split('=')[0];
|
||||||
|
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||||
|
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function req(method, p, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
|
||||||
|
const data = body ? JSON.stringify(body) : '';
|
||||||
|
const h = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) };
|
||||||
|
if (jarHeader()) h['Cookie'] = jarHeader();
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => {
|
||||||
|
const bufs = [];
|
||||||
|
res.on('data', c => bufs.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
collect(res);
|
||||||
|
const raw = Buffer.concat(bufs).toString('utf8');
|
||||||
|
let j = null; try { j = JSON.parse(raw); } catch (e) {}
|
||||||
|
resolve({ status: res.statusCode, body: j, raw, loc: res.headers.location });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
if (data) r.write(data);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function formPost(p, fields) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = new URLSearchParams(fields).toString();
|
||||||
|
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) };
|
||||||
|
if (jarHeader()) h['Cookie'] = jarHeader();
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
|
||||||
|
const bufs = [];
|
||||||
|
res.on('data', c => bufs.push(c));
|
||||||
|
res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location }); });
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
r.write(data); r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await delay(2200);
|
||||||
|
|
||||||
|
console.log('\n--- 1) 登录:凭据必须真的落盘(登录前本机账号为空)---');
|
||||||
|
const lg = await formPost('/login.php', { username: MOCK.user, password: MOCK.pass });
|
||||||
|
chk('login.php 返回 200/302', lg.status === 200 || lg.status === 302, 'status=' + lg.status);
|
||||||
|
chk('登录页未出现"没能保存到本机配置"', lg.raw.indexOf('没能保存到本机配置') < 0, (lg.raw.match(/没能保存到本机配置[^<]*/) || [''])[0].slice(0, 160));
|
||||||
|
chk('登录页未出现"登录失败"', lg.raw.indexOf('登录失败') < 0, (lg.raw.match(/登录失败[^<]*/) || [''])[0].slice(0, 160));
|
||||||
|
chk('拿到会话 cookie', cookieJar.some(c => c.indexOf('NYSESS=') === 0), JSON.stringify(cookieJar));
|
||||||
|
chk('成功页写入 ny_login_at(前端防重复跳转依据)', lg.raw.indexOf('ny_login_at') >= 0);
|
||||||
|
|
||||||
|
const ac = await req('GET', '/api.php?action=auth_check');
|
||||||
|
chk('auth_check 已登录', ac.body && ac.body.data && ac.body.data.auth === true, ac.raw);
|
||||||
|
if (!(ac.body && ac.body.data && ac.body.data.auth)) { cleanup(); process.exit(1); }
|
||||||
|
CSRF = (ac.body && ac.body.data && ac.body.data.csrf_token) || CSRF;
|
||||||
|
|
||||||
|
const g1 = await req('GET', '/api.php?action=config_get');
|
||||||
|
CSRF = (g1.body && g1.body.data && g1.body.data.csrf_token) || CSRF;
|
||||||
|
chk('账号已回读到配置', g1.body && g1.body.data && g1.body.data.username === MOCK.user, JSON.stringify(g1.body && g1.body.data && g1.body.data.username));
|
||||||
|
chk('has_password=true(密码已存)', g1.body && g1.body.data && g1.body.data.has_password === true);
|
||||||
|
chk('响应不含明文密码', !('password' in ((g1.body && g1.body.data) || {})));
|
||||||
|
|
||||||
|
console.log('\n--- 2) 登录后立刻拉数据:不应再出现"未配置账号" ---');
|
||||||
|
const d1 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
|
||||||
|
chk('data 成功', d1.body && d1.body.ok === true, (d1.raw || '').slice(0, 200));
|
||||||
|
chk('data 未报未配置账号', (d1.raw || '').indexOf('未配置卷王登录账号') < 0, (d1.raw || '').slice(0, 160));
|
||||||
|
chk('data 未被标记为鉴权失败', !(d1.body && d1.body.extra && d1.body.extra.auth), JSON.stringify(d1.body && d1.body.extra));
|
||||||
|
|
||||||
|
console.log('\n--- 3) 鉴权真失败时:必须带 auth 标记 + 可自查的诊断 ---');
|
||||||
|
const sv = await req('POST', '/api.php?action=config_save', { password: 'definitely-wrong' });
|
||||||
|
chk('改错密码已保存', sv.body && sv.body.ok === true, (sv.raw || '').slice(0, 160));
|
||||||
|
const d2 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
|
||||||
|
chk('此时 data 失败', d2.body && d2.body.ok === false, (d2.raw || '').slice(0, 160));
|
||||||
|
chk('extra.auth=true', !!(d2.body && d2.body.extra && d2.body.extra.auth), JSON.stringify(d2.body && d2.body.extra));
|
||||||
|
const diag = d2.body && d2.body.extra && d2.body.extra.diag;
|
||||||
|
chk('extra.diag 存在', !!diag, JSON.stringify(diag));
|
||||||
|
if (diag) {
|
||||||
|
chk('diag.has_username=true(账号其实存住了)', diag.has_username === true);
|
||||||
|
chk('diag 含目录/文件/数据库可写标记', 'dir_writable' in diag && 'cfg_writable' in diag && 'db_writable' in diag);
|
||||||
|
chk('diag 含配置来源', typeof diag.storage === 'string' && diag.storage !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- 4) 改回正确密码后应恢复 ---');
|
||||||
|
const sv2 = await req('POST', '/api.php?action=config_save', { password: MOCK.pass });
|
||||||
|
chk('恢复密码已保存', sv2.body && sv2.body.ok === true);
|
||||||
|
const d3 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
|
||||||
|
chk('data 恢复正常', d3.body && d3.body.ok === true, (d3.raw || '').slice(0, 160));
|
||||||
|
|
||||||
|
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||||
|
cleanup();
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user