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:
+191
-36
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user