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
+191 -36
View File
@@ -33,6 +33,7 @@
history: new Set(), // 累计抽中(重置结果时清零)
excluded: new Set(), // 手动排除
log: [], // 历史批次,新的在前
customRatios: {}, // 自定义比例:{ 组名: 权重 }
drawing: false,
};
@@ -77,38 +78,91 @@
}
/**
* 名额均分到各组(5:5 语义):每组 n/组数,余数轮流补,
* 某组人数不足时把差额退回分给未满的组(结果之和 === min(n, 总人数))
* 名额分配:
* - sizes 各组容量
* - n 要抽的总人数
* - weights 各组权重(null=均分;数组=按权重,权重<=0 视为 0)
* 算法:最大余额法(largest remainder)—— raw_i = n * w_i / Σw;floor 后把剩余名额
* 按小数部分降序分给还没满的组。均分模式是 weights=null 的特例(每组权重 1),
* 余数时按"组容量小者优先"补齐,保证每组都有代表。
*/
function allocQuotas(sizes, n) {
function allocQuotas(sizes, n, weights) {
const g = sizes.length;
const quota = new Array(g).fill(0);
if (g === 0 || n <= 0) return quota;
const total = sizes.reduce((a, b) => a + b, 0);
if (n >= total) return sizes.slice(); // 全量
// 1) 均分初值 + 余数补齐(小组优先,保证每组有代表)
const base = Math.floor(n / g);
quota.fill(base);
let r = n - base * g;
const bySmall = sizes.map((s, i) => i).sort((a, b) => sizes[a] - sizes[b]);
for (let k = 0; k < r; k++) quota[bySmall[k % g]]++;
const base = new Array(g).fill(0);
const frac = new Array(g).fill(0);
// 2) 超过组容量的退回
if (Array.isArray(weights) && weights.length === g) {
const w = weights.map(x => (Number(x) > 0 ? Number(x) : 0));
const wsum = w.reduce((a, b) => a + b, 0);
if (wsum > 0) {
// 按权重分配:最大余额法
for (let i = 0; i < g; i++) {
const raw = n * w[i] / wsum;
base[i] = Math.floor(raw);
frac[i] = raw - base[i];
}
} else {
// 全 0 权重 → 退化为均分
const b = Math.floor(n / g);
for (let i = 0; i < g; i++) base[i] = b;
const rem = n - b * g;
const bySmall = sizes.map((s, i) => i)
.sort((a, b2) => sizes[a] - sizes[b2] || a - b2);
for (let k = 0; k < rem; k++) base[bySmall[k % g]]++;
}
} else {
// 名额均分:每组 n/g,余数小组优先(保证每组都有代表)
const b = Math.floor(n / g);
for (let i = 0; i < g; i++) base[i] = b;
const rem = n - b * g;
const bySmall = sizes.map((s, i) => i)
.sort((a, b2) => sizes[a] - sizes[b2] || a - b2);
for (let k = 0; k < rem; k++) base[bySmall[k % g]]++;
}
// 余数补齐:frac 大的优先;同 frac 时剩余容量大的优先
let rest = n - base.reduce((a, b) => a + b, 0);
if (rest > 0) {
const order = sizes.map((s, i) => i).sort((a, b) =>
(frac[b] - frac[a]) || ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
for (let k = 0; rest > 0 && k < g; k++) {
const i = order[k % g];
if (base[i] < sizes[i]) { base[i]++; rest--; }
}
// 若仍有余数(组容量限制),继续按剩余容量大者分配
while (rest > 0) {
let progress = false;
const byRoom = sizes.map((s, i) => i)
.sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
for (const i of byRoom) {
if (rest <= 0) break;
if (base[i] < sizes[i]) { base[i]++; rest--; progress = true; }
}
if (!progress) break;
}
}
// 超容量退回(极端比例时兜底)
let overflow = 0;
for (let i = 0; i < g; i++) {
if (quota[i] > sizes[i]) { overflow += quota[i] - sizes[i]; quota[i] = sizes[i]; }
if (base[i] > sizes[i]) { overflow += base[i] - sizes[i]; base[i] = sizes[i]; }
}
// 3) 差额分给未满的组(剩余容量大者优先)
while (overflow > 0) {
const byRoom = sizes.map((s, i) => i)
.sort((a, b) => (sizes[b] - quota[b]) - (sizes[a] - quota[a]));
let progress = false;
const byRoom = sizes.map((s, i) => i)
.sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
for (const i of byRoom) {
if (overflow <= 0) break;
if (quota[i] < sizes[i]) { quota[i]++; overflow--; progress = true; }
if (base[i] < sizes[i]) { base[i]++; overflow--; progress = true; }
}
if (!progress) break;
}
return quota;
return base;
}
/**
@@ -123,10 +177,11 @@
}
/**
* 分组均衡抽取:返回 { picked, detail }
* detail 例:["A 3人", "B 3人"],用于提示与历史记录
* 分组均衡抽取:返回 { picked, detail, sizes, weights }
* weights 数组为各组实际权重(默认加权时=各组人数)
* detail 例:["A 6人", "B 4人"],用于提示与历史记录
*/
function pickBalanced(pool, n, key) {
function pickBalanced(pool, n, key, weights) {
const label = key === 'grp' ? '(未分组)' : '(未分班)';
const groups = new Map();
pool.forEach(m => {
@@ -137,7 +192,10 @@
const gk = Array.from(groups.keys())
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
const sizes = gk.map(k => groups.get(k).length);
const quotas = allocQuotas(sizes, n);
const w = (Array.isArray(weights) && weights.length === gk.length)
? weights
: null;
const quotas = allocQuotas(sizes, n, w);
const picked = [];
const detail = [];
gk.forEach((k, i) => {
@@ -145,7 +203,7 @@
picked.push(...part);
if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人');
});
return { picked, detail };
return { picked, detail, sizes, weights: w || sizes.slice() };
}
// 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用)
@@ -278,6 +336,36 @@
if (ec) ec.textContent = '已排除 ' + state.excluded.size + ' 人';
}
/**
* 计算当前候选池的分组信息:{ key, gk[], sizes[] }
* key = 'grp' | 'cls' | ''
* gk 按字典序排序后的组名
* sizes 每组成员数
*/
function poolGroups(pool) {
const key = balanceKeyOf(pool);
if (!key) return { key: '', gk: [], sizes: [] };
const label = key === 'grp' ? '(未分组)' : '(未分班)';
const groups = new Map();
pool.forEach(m => {
const k = String(m[key] || '').trim() || label;
if (!groups.has(k)) groups.set(k, 0);
groups.set(k, groups.get(k) + 1);
});
const gk = Array.from(groups.keys())
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
return { key, gk, sizes: gk.map(k => groups.get(k)) };
}
/** 把对象 {gk:weight} 读成与当前池分组顺序一致的数组,缺组视为 0 */
function readCustomWeights(gk) {
const src = state.customRatios || {};
return gk.map(k => {
const v = parseFloat(src[k]);
return Number.isFinite(v) && v > 0 ? v : 0;
});
}
/* ================= 抽取 ================= */
async function doDraw() {
if (state.drawing) return;
@@ -305,18 +393,23 @@
await sleep(40 + i * 7);
}
// 真正抽取:均衡(组别优先,其次班级)或整池随机
// 真正抽取:均衡(按模式分组) 或整池随机
let picked, detail = [];
if ($('#balanceChk').checked) {
const key = balanceKeyOf(pool);
if (key) {
const res = pickBalanced(pool, n, key);
const mode = $('#balanceMode').value;
if (mode === 'off') {
picked = shuffle(pool).slice(0, n);
} else {
const g = poolGroups(pool);
if (g.key) {
let weights = null;
if (mode === 'weight') weights = g.sizes.slice(); // 默认按组人数加权
else if (mode === 'custom') weights = readCustomWeights(g.gk);
// mode === 'even' 走 allocQuotas(null) → 均分
const res = pickBalanced(pool, n, g.key, weights);
picked = res.picked; detail = res.detail;
} else {
picked = shuffle(pool).slice(0, n);
}
} else {
picked = shuffle(pool).slice(0, n);
}
state.picked = picked;
@@ -329,7 +422,7 @@
renderResult();
renderStats();
pushLog(picked, detail);
pushLog(picked, detail, mode);
saveState();
state.drawing = false;
@@ -356,12 +449,13 @@
}
/* ================= 历史记录 ================= */
function pushLog(picked, detail) {
function pushLog(picked, detail, mode) {
const modeTxt = ({ weight: '按人数加权', even: '均分', custom: '自定义比例', off: '不分组' })[mode] || '';
state.log.unshift({
ts: new Date().toLocaleString('zh-CN', { hour12: false }),
source: state.source === 'project' ? '已提交人员' : '学号名单',
range: rangeText(),
balance: detail.join(' / '),
balance: (modeTxt ? modeTxt + ':' : '') + (detail || []).join(' / '),
count: picked.length,
members: picked.map(m => ({
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
@@ -498,6 +592,14 @@
function saveSettings() {
try {
// 收集自定义比例输入
const cr = {};
$$('#ratioBox input[data-gk]').forEach(inp => {
const k = inp.dataset.gk;
const v = parseFloat(inp.value);
if (k && Number.isFinite(v) && v > 0) cr[k] = v;
});
state.customRatios = cr;
localStorage.setItem(LS_SETTINGS, JSON.stringify({
source: state.source,
projectId: $('#projectSel').value,
@@ -505,7 +607,8 @@
grp: $('#grpSel').value,
num: $('#numInput').value,
exclude: $('#excludePicked').checked,
balance: $('#balanceChk').checked,
mode: $('#balanceMode').value,
customRatios: cr,
}));
} catch (e) { /* 忽略 */ }
}
@@ -548,13 +651,14 @@
}
populateFilters();
renderStats();
renderRatioBox();
if ($('#exclModal').classList.contains('open')) renderExcl();
saveSettings();
});
$('#projectSel').addEventListener('change', async () => {
const pid = $('#projectSel').value;
if (!pid) { state.answers = []; populateFilters(); renderStats(); return; }
if (!pid) { state.answers = []; populateFilters(); renderStats(); renderRatioBox(); return; }
showAlert('正在拉取已提交人员…', 'info');
try {
await loadAnswers(pid);
@@ -564,16 +668,30 @@
}
populateFilters();
renderStats();
renderRatioBox();
if ($('#exclModal').classList.contains('open')) renderExcl();
saveSettings();
});
['#clsSel', '#grpSel'].forEach(id => {
$(id).addEventListener('change', () => { renderStats(); saveSettings(); });
$(id).addEventListener('change', () => {
renderStats(); renderRatioBox(); saveSettings();
});
});
$('#numInput').addEventListener('change', saveSettings);
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
$('#balanceChk').addEventListener('change', saveSettings);
$('#balanceMode').addEventListener('change', () => {
renderRatioBox();
saveSettings();
});
// 班级/组别/来源切换会改变候选池 → 比例输入框也要重画
['#sourceSel', '#projectSel', '#clsSel', '#grpSel'].forEach(id => {
const el = $(id);
if (el) el.addEventListener('change', () => {
// 延迟一帧,等 populateFilters 填好再算
setTimeout(renderRatioBox, 0);
});
});
$$('[data-num]').forEach(b => {
b.addEventListener('click', () => {
@@ -700,22 +818,59 @@
});
}
/**
* 渲染"自定义比例"输入框:按当前候选池的分组(组别/班级)列出,每个组一个数字输入框,
* 默认按各组人数预填(即等价"按人数加权"),用户可改。
*/
function renderRatioBox() {
const box = $('#ratioBox');
if (!box) return;
const pool = currentPool();
const g = poolGroups(pool);
const mode = $('#balanceMode').value;
const show = (mode === 'custom') && g.key;
box.classList.toggle('hidden', !show);
if (!show) return;
box.innerHTML =
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
g.gk.map((k, i) => {
const def = (state.customRatios && Number.isFinite(parseFloat(state.customRatios[k])))
? state.customRatios[k]
: g.sizes[i];
return '<div class="row" style="display:flex;gap:8px;align-items:center;margin:4px 0">' +
'<label style="min-width:120px">' + esc(k) + ' <small style="color:var(--text-3)">(' + g.sizes[i] + '人)</small></label>' +
'<input type="number" min="0" step="0.1" data-gk="' + esc(k) + '" value="' + esc(def) + '" placeholder="' + g.sizes[i] + '" style="width:120px">' +
'</div>';
}).join('');
// 改动即时保存
box.querySelectorAll('input[data-gk]').forEach(inp => {
inp.addEventListener('change', saveSettings);
});
}
/* ---------------- 启动 ---------------- */
document.addEventListener('DOMContentLoaded', async function () {
loadState();
const s = loadSettings();
if (s.exclude === false) $('#excludePicked').checked = false;
if (s.balance === false) $('#balanceChk').checked = false;
// 兼容旧版:s.balance === false → 不分组
const mode = s.mode || (s.balance === false ? 'off' : 'weight');
$('#balanceMode').value = mode;
if (s.num) $('#numInput').value = s.num;
if (s.source === 'project') {
state.source = 'project';
$('#sourceSel').value = 'project';
$('#projectField').classList.remove('hidden');
}
if (s.customRatios && typeof s.customRatios === 'object') {
state.customRatios = s.customRatios;
}
bind();
renderResult();
renderStats();
// 第一次也要按当前池渲染自定义比例框
renderRatioBox();
try {
await loadRoster();