fix: 修复保存满分后清登录;配置双写;设置显示账号/密码;抽取比例改按人数加权

- 修复:仅当连接信息(base_url/api_prefix/username/password/verify_ssl)真变化时才清 token + 重连;
  保存满分/布局等不应触发登录卷王(bug:之前一律 unlink token.json,导致保存后所有用户都被踢)
- 修复:默认满分不再误写当前卷;改为显式 apply_pid + apply_score 才会改 full_scores
- 配置双写(DB+文件):避免 DB 不可用时静默回退到出厂默认 admin/123456;
  ny_save_config 同时写文件+DB;ny_load_config 在 DB 可用但无记录时从文件自动迁回
- 设置弹窗新增:登录账号可编辑、密码 type=password 不回显、留空表示不修改;
  保存后根据 ping 自动给出成功/失败反馈(错误密码不再静默)
- 抽取比例改为按组人数加权(默认)+ 各组均分 + 自定义比例 + 不分组,最大余额法守恒分配
- 抽出比例 UI:删除 5:5 复选框、改 #balanceMode 下拉 + 按组渲染比例输入框
- Db::lastError() 暴露 DB 错误用于诊断(config_get 返回 db_driver / db_connected / db_error)
- e2e:test-draw-alloc 18→29 测试用例(加权/自定义/守恒 fuzz);
  新增 test-config-save e2e 27 用例全过(连接信息变化检测 + 自动 ping + 密码不回显 + apply_pid)
- mock-server:账户校验从 'admin' 改为 'mock' 接受 e2e 账号
This commit is contained in:
2026-09-09 13:06:47 +08:00
parent 6772aeae11
commit 6cd17b9cb2
10 changed files with 765 additions and 120 deletions
+40 -17
View File
@@ -550,11 +550,17 @@
// 满分:默认 + 各试卷独立
$('#cfgFull').value = c.full_score || 100;
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
const pid = $('#projectSel').value;
const pObj = state.projects.find(p => p.id === pid);
$('#cfgFullHint').textContent = pid
? ('当前选中「' + (pObj ? pObj.name : pid) + '」,如需单独设满分请在上方"各试卷独立满分"修改')
: '未选中试卷时,作为所有试卷的默认值';
// 连接信息(账号回显;密码不回显)
$('#cfgBase').value = (c.base_url || '') + (c.api_prefix || '/api');
$('#cfgUser').value = c.username || '';
$('#cfgPass').value = '';
const conn = c.db_configured
? (c.db_connected
? '数据库:已连接 (' + (c.db_driver || '') + ')'
: ('数据库:未连接 · ' + (c.db_error || '未知错误')))
: '数据库:未配置(使用文件存储)';
$('#cfgConnInfo').textContent = '配置来源:' + (c.storage || 'default') + ' · ' + conn;
$('#pingResult').classList.add('hidden');
} catch (e) { showAlert(e.message); }
$('#settingsModal').classList.add('open');
}
@@ -698,6 +704,7 @@
/* 设置保存 */
$('#btnSaveCfg').addEventListener('click', async () => {
const pass = $('#cfgPass').value;
const payload = {
page_size: parseInt($('#cfgPageSize').value, 10) || 200,
refresh_interval: parseInt($('#cfgInterval').value, 10) || 15,
@@ -709,21 +716,37 @@
full_score: parseInt($('#cfgFull').value, 10) || 100,
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
full_scores: collectFullScores(),
project_id: $('#projectSel').value
username: $('#cfgUser').value.trim(),
};
if (pass !== '') payload.password = pass; // 留空 = 不修改
let resp;
try {
await api('config_save', { method: 'POST', body: payload });
resp = await api('config_save', { method: 'POST', body: payload });
} catch (e) { showAlert(e.message); return; }
$('#cfgPass').value = ''; // 提交后清空密码框
const note = (resp && resp.conn_changed) ? '账号/连接已更新' : '设置已保存';
if (resp && resp.ping) {
if (resp.ping.ok) {
showAlert(note + ',测连接成功(' + resp.ping.projects + ' 个项目 / ' + resp.ping.elapsed + 'ms)', 'info');
} else {
showAlert(note + ',但测连接失败:' + resp.ping.msg, 'error');
}
} else {
showAlert(note, 'info');
}
// 应用布局偏好(写入容器 + localStorage)
applyLayout(payload.layout);
const iv = payload.refresh_interval;
const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10));
let best = opts[0];
opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; });
$('#intervalSel').value = String(best);
schedule();
loadProjects();
// ping 失败时保留弹窗,让用户能立即改回
if (!resp || !resp.ping || resp.ping.ok) {
$('#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];
opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; });
$('#intervalSel').value = String(best);
schedule();
loadProjects();
} catch (e) { showAlert(e.message); }
}
});
// 收集"各试卷独立满分" -> {projectId: score}