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
+61 -16
View File
@@ -32,8 +32,14 @@ try {
// ---------------- 配置 ---------------- // ---------------- 配置 ----------------
case 'config_get': case 'config_get':
$c = cfg(); $c = cfg();
$c['has_password'] = $c['password'] !== ''; $c['has_password'] = ((string)$c['password']) !== '';
unset($c['password']); unset($c['password']);
// 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里)
$c['storage'] = ny_config_source();
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
$c['db_connected'] = class_exists('Db') && Db::isConnected();
$c['db_error'] = class_exists('Db') ? Db::lastError() : '';
ny_ok($c); ny_ok($c);
case 'config_save': case 'config_save':
@@ -43,13 +49,20 @@ try {
$in = $_POST; $in = $_POST;
} }
$old = cfg(); $old = cfg();
$allowed = ['base_url', 'api_prefix', 'username', 'password', 'timeout', 'refresh_interval', 'page_size', 'name_keywords']; $allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords'];
$new = $old; $new = $old;
foreach ($allowed as $k) { foreach ($allowed as $k) {
if (array_key_exists($k, $in)) { if (array_key_exists($k, $in)) {
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k]; $new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
} }
} }
// 密码:与 install.php 一致不做 trim(保留首尾空格的原貌),空 / 占位符 = 不修改
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) { foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
if (array_key_exists($k, $in)) { if (array_key_exists($k, $in)) {
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL); $new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
@@ -63,33 +76,65 @@ try {
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) { if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
$new['full_scores'] = $in['full_scores']; $new['full_scores'] = $in['full_scores'];
} }
$pwdIn = $in['password'] ?? null; $new['base_url'] = rtrim((string)$new['base_url'], '/');
if ($pwdIn === null || $pwdIn === '' || $pwdIn === '******') {
$new['password'] = $old['password'];
}
$new['base_url'] = rtrim($new['base_url'], '/');
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/'); $new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
$new['timeout'] = (int)$new['timeout']; $new['timeout'] = (int)$new['timeout'];
$new['refresh_interval'] = (int)$new['refresh_interval']; $new['refresh_interval'] = (int)$new['refresh_interval'];
if ($new['refresh_interval'] < 3) { if ($new['refresh_interval'] < 3) {
$new['refresh_interval'] = 3; $new['refresh_interval'] = 3;
} }
// 每套试卷独立满分:选中试卷时写入 full_scores[projectId],否则更新全局默认 // 默认满分
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100); $fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
if ($fs < 1) { if ($fs < 1) {
$fs = 100; $fs = 100;
} }
$pid = trim((string)($in['project_id'] ?? ''));
if ($pid !== '') {
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
$scores[$pid] = $fs;
$new['full_scores'] = $scores;
} else {
$new['full_score'] = $fs; $new['full_score'] = $fs;
// 各试卷独立满分:仅当显式指定 apply_pid 时才写入/清除该卷(修复 bug:之前会误用默认满分覆盖当前卷)
$applyPid = trim((string)($in['apply_pid'] ?? ''));
if ($applyPid !== '') {
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
$applyScore = (int)($in['apply_score'] ?? 0);
if ($applyScore > 0) {
$scores[$applyPid] = $applyScore;
} else {
unset($scores[$applyPid]);
}
$new['full_scores'] = $scores;
}
// 仅当连接信息真的变了才清 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);
@unlink(NY_DATA . '/cache/token.json'); // 清掉旧令牌 if ($connChanged) {
ny_ok(['saved' => true, 'config' => ny_load_config()]); @unlink(NY_DATA . '/cache/token.json');
}
// 返回的 config 不再带明文密码
$outCfg = ny_load_config();
$outCfg['has_password'] = ((string)$outCfg['password']) !== '';
unset($outCfg['password']);
$res = [
'saved' => true,
'config' => $outCfg,
'conn_changed' => $connChanged,
'storage' => ny_config_source(),
];
// 连接信息修改后自动测一次,给出明确结果(避免用户配置对错无从知晓)
if ($connChanged) {
try {
$client = new SurveyKingClient(ny_load_config());
$p = $client->ping();
$res['ping'] = ['ok' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']];
} catch (Throwable $e) {
$res['ping'] = ['ok' => false, 'msg' => $e->getMessage()];
}
}
ny_ok($res);
// ---------------- 连接测试 ---------------- // ---------------- 连接测试 ----------------
case 'ping': case 'ping':
+32 -9
View File
@@ -550,11 +550,17 @@
// 满分:默认 + 各试卷独立 // 满分:默认 + 各试卷独立
$('#cfgFull').value = c.full_score || 100; $('#cfgFull').value = c.full_score || 100;
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {}); 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); $('#cfgBase').value = (c.base_url || '') + (c.api_prefix || '/api');
$('#cfgFullHint').textContent = pid $('#cfgUser').value = c.username || '';
? ('当前选中「' + (pObj ? pObj.name : pid) + '」,如需单独设满分请在上方"各试卷独立满分"修改') $('#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); } } catch (e) { showAlert(e.message); }
$('#settingsModal').classList.add('open'); $('#settingsModal').classList.add('open');
} }
@@ -698,6 +704,7 @@
/* 设置保存 */ /* 设置保存 */
$('#btnSaveCfg').addEventListener('click', async () => { $('#btnSaveCfg').addEventListener('click', async () => {
const pass = $('#cfgPass').value;
const payload = { const payload = {
page_size: parseInt($('#cfgPageSize').value, 10) || 200, page_size: parseInt($('#cfgPageSize').value, 10) || 200,
refresh_interval: parseInt($('#cfgInterval').value, 10) || 15, refresh_interval: parseInt($('#cfgInterval').value, 10) || 15,
@@ -709,11 +716,24 @@
full_score: parseInt($('#cfgFull').value, 10) || 100, full_score: parseInt($('#cfgFull').value, 10) || 100,
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default', layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
full_scores: collectFullScores(), full_scores: collectFullScores(),
project_id: $('#projectSel').value username: $('#cfgUser').value.trim(),
}; };
if (pass !== '') payload.password = pass; // 留空 = 不修改
let resp;
try { try {
await api('config_save', { method: 'POST', body: payload }); resp = await api('config_save', { method: 'POST', body: payload });
$('#settingsModal').classList.remove('open'); } 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) // 应用布局偏好(写入容器 + localStorage)
applyLayout(payload.layout); applyLayout(payload.layout);
const iv = payload.refresh_interval; const iv = payload.refresh_interval;
@@ -723,7 +743,10 @@
$('#intervalSel').value = String(best); $('#intervalSel').value = String(best);
schedule(); schedule();
loadProjects(); loadProjects();
} catch (e) { showAlert(e.message); } // ping 失败时保留弹窗,让用户能立即改回
if (!resp || !resp.ping || resp.ping.ok) {
$('#settingsModal').classList.remove('open');
}
}); });
// 收集"各试卷独立满分" -> {projectId: score} // 收集"各试卷独立满分" -> {projectId: score}
+194 -39
View File
@@ -33,6 +33,7 @@
history: new Set(), // 累计抽中(重置结果时清零) history: new Set(), // 累计抽中(重置结果时清零)
excluded: new Set(), // 手动排除 excluded: new Set(), // 手动排除
log: [], // 历史批次,新的在前 log: [], // 历史批次,新的在前
customRatios: {}, // 自定义比例:{ 组名: 权重 }
drawing: false, 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 g = sizes.length;
const quota = new Array(g).fill(0); const quota = new Array(g).fill(0);
if (g === 0 || n <= 0) return quota; 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 = new Array(g).fill(0);
const base = Math.floor(n / g); const frac = new Array(g).fill(0);
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]]++;
// 2) 超过组容量的退回 if (Array.isArray(weights) && weights.length === g) {
let overflow = 0; 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++) { for (let i = 0; i < g; i++) {
if (quota[i] > sizes[i]) { overflow += quota[i] - sizes[i]; quota[i] = sizes[i]; } const raw = n * w[i] / wsum;
base[i] = Math.floor(raw);
frac[i] = raw - base[i];
} }
// 3) 差额分给未满的组(剩余容量大者优先) } else {
while (overflow > 0) { // 全 0 权重 → 退化为均分
const byRoom = sizes.map((s, i) => i) const b = Math.floor(n / g);
.sort((a, b) => (sizes[b] - quota[b]) - (sizes[a] - quota[a])); 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; 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) { for (const i of byRoom) {
if (overflow <= 0) break; if (rest <= 0) break;
if (quota[i] < sizes[i]) { quota[i]++; overflow--; progress = true; } if (base[i] < sizes[i]) { base[i]++; rest--; progress = true; }
} }
if (!progress) break; if (!progress) break;
} }
return quota; }
// 超容量退回(极端比例时兜底)
let overflow = 0;
for (let i = 0; i < g; i++) {
if (base[i] > sizes[i]) { overflow += base[i] - sizes[i]; base[i] = sizes[i]; }
}
while (overflow > 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 (overflow <= 0) break;
if (base[i] < sizes[i]) { base[i]++; overflow--; progress = true; }
}
if (!progress) break;
}
return base;
} }
/** /**
@@ -123,10 +177,11 @@
} }
/** /**
* 分组均衡抽取:返回 { picked, detail } * 分组均衡抽取:返回 { picked, detail, sizes, weights }
* detail 例:["A 3人", "B 3人"],用于提示与历史记录 * weights 数组为各组实际权重(默认加权时=各组人数)
* detail 例:["A 6人", "B 4人"],用于提示与历史记录
*/ */
function pickBalanced(pool, n, key) { function pickBalanced(pool, n, key, weights) {
const label = key === 'grp' ? '(未分组)' : '(未分班)'; const label = key === 'grp' ? '(未分组)' : '(未分班)';
const groups = new Map(); const groups = new Map();
pool.forEach(m => { pool.forEach(m => {
@@ -137,7 +192,10 @@
const gk = Array.from(groups.keys()) const gk = Array.from(groups.keys())
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true })); .sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
const sizes = gk.map(k => groups.get(k).length); 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 picked = [];
const detail = []; const detail = [];
gk.forEach((k, i) => { gk.forEach((k, i) => {
@@ -145,7 +203,7 @@
picked.push(...part); picked.push(...part);
if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人'); if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人');
}); });
return { picked, detail }; return { picked, detail, sizes, weights: w || sizes.slice() };
} }
// 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用) // 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用)
@@ -278,6 +336,36 @@
if (ec) ec.textContent = '已排除 ' + state.excluded.size + ' 人'; 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() { async function doDraw() {
if (state.drawing) return; if (state.drawing) return;
@@ -305,18 +393,23 @@
await sleep(40 + i * 7); await sleep(40 + i * 7);
} }
// 真正抽取:均衡(组别优先,其次班级)或整池随机 // 真正抽取:均衡(按模式分组) 或整池随机
let picked, detail = []; let picked, detail = [];
if ($('#balanceChk').checked) { const mode = $('#balanceMode').value;
const key = balanceKeyOf(pool); if (mode === 'off') {
if (key) { picked = shuffle(pool).slice(0, n);
const res = pickBalanced(pool, n, key); } 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; picked = res.picked; detail = res.detail;
} else { } else {
picked = shuffle(pool).slice(0, n); picked = shuffle(pool).slice(0, n);
} }
} else {
picked = shuffle(pool).slice(0, n);
} }
state.picked = picked; state.picked = picked;
@@ -329,7 +422,7 @@
renderResult(); renderResult();
renderStats(); renderStats();
pushLog(picked, detail); pushLog(picked, detail, mode);
saveState(); saveState();
state.drawing = false; 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({ state.log.unshift({
ts: new Date().toLocaleString('zh-CN', { hour12: false }), ts: new Date().toLocaleString('zh-CN', { hour12: false }),
source: state.source === 'project' ? '已提交人员' : '学号名单', source: state.source === 'project' ? '已提交人员' : '学号名单',
range: rangeText(), range: rangeText(),
balance: detail.join(' / '), balance: (modeTxt ? modeTxt + ':' : '') + (detail || []).join(' / '),
count: picked.length, count: picked.length,
members: picked.map(m => ({ members: picked.map(m => ({
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '', sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
@@ -498,6 +592,14 @@
function saveSettings() { function saveSettings() {
try { 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({ localStorage.setItem(LS_SETTINGS, JSON.stringify({
source: state.source, source: state.source,
projectId: $('#projectSel').value, projectId: $('#projectSel').value,
@@ -505,7 +607,8 @@
grp: $('#grpSel').value, grp: $('#grpSel').value,
num: $('#numInput').value, num: $('#numInput').value,
exclude: $('#excludePicked').checked, exclude: $('#excludePicked').checked,
balance: $('#balanceChk').checked, mode: $('#balanceMode').value,
customRatios: cr,
})); }));
} catch (e) { /* 忽略 */ } } catch (e) { /* 忽略 */ }
} }
@@ -548,13 +651,14 @@
} }
populateFilters(); populateFilters();
renderStats(); renderStats();
renderRatioBox();
if ($('#exclModal').classList.contains('open')) renderExcl(); if ($('#exclModal').classList.contains('open')) renderExcl();
saveSettings(); saveSettings();
}); });
$('#projectSel').addEventListener('change', async () => { $('#projectSel').addEventListener('change', async () => {
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
if (!pid) { state.answers = []; populateFilters(); renderStats(); return; } if (!pid) { state.answers = []; populateFilters(); renderStats(); renderRatioBox(); return; }
showAlert('正在拉取已提交人员…', 'info'); showAlert('正在拉取已提交人员…', 'info');
try { try {
await loadAnswers(pid); await loadAnswers(pid);
@@ -564,16 +668,30 @@
} }
populateFilters(); populateFilters();
renderStats(); renderStats();
renderRatioBox();
if ($('#exclModal').classList.contains('open')) renderExcl(); if ($('#exclModal').classList.contains('open')) renderExcl();
saveSettings(); saveSettings();
}); });
['#clsSel', '#grpSel'].forEach(id => { ['#clsSel', '#grpSel'].forEach(id => {
$(id).addEventListener('change', () => { renderStats(); saveSettings(); }); $(id).addEventListener('change', () => {
renderStats(); renderRatioBox(); saveSettings();
});
}); });
$('#numInput').addEventListener('change', saveSettings); $('#numInput').addEventListener('change', saveSettings);
$('#excludePicked').addEventListener('change', () => { renderStats(); 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 => { $$('[data-num]').forEach(b => {
b.addEventListener('click', () => { 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 () { document.addEventListener('DOMContentLoaded', async function () {
loadState(); loadState();
const s = loadSettings(); const s = loadSettings();
if (s.exclude === false) $('#excludePicked').checked = false; 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.num) $('#numInput').value = s.num;
if (s.source === 'project') { if (s.source === 'project') {
state.source = 'project'; state.source = 'project';
$('#sourceSel').value = 'project'; $('#sourceSel').value = 'project';
$('#projectField').classList.remove('hidden'); $('#projectField').classList.remove('hidden');
} }
if (s.customRatios && typeof s.customRatios === 'object') {
state.customRatios = s.customRatios;
}
bind(); bind();
renderResult(); renderResult();
renderStats(); renderStats();
// 第一次也要按当前池渲染自定义比例框
renderRatioBox();
try { try {
await loadRoster(); await loadRoster();
+30 -2
View File
@@ -132,8 +132,19 @@ function ny_config_file(): string
return NY_DATA . '/config.json'; return NY_DATA . '/config.json';
} }
/**
* 配置来源:db | file | default
* (default 表示既没读到 DB 也没读到文件——此时账号密码是出厂默认值,几乎必然连不上卷王)
*/
function ny_config_source(): string
{
return (string)($GLOBALS['__ny_cfg_source'] ?? 'default');
}
function ny_load_config(): array function ny_load_config(): array
{ {
$GLOBALS['__ny_cfg_source'] = 'default';
// 数据库已初始化且能连上 → 从 DB 读,否则回退文件 // 数据库已初始化且能连上 → 从 DB 读,否则回退文件
$dbCfg = ny_db_config(); $dbCfg = ny_db_config();
if ($dbCfg !== null) { if ($dbCfg !== null) {
@@ -145,6 +156,7 @@ 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'], '/');
$GLOBALS['__ny_cfg_source'] = 'db';
return $cfg; return $cfg;
} }
@@ -156,6 +168,16 @@ function ny_load_config(): array
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k]; $cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
} }
} }
$GLOBALS['__ny_cfg_source'] = 'file';
// DB 已连上但库里没有配置,而文件里有 —— 迁移一份到 DB,避免两端不一致
if (class_exists('Db') && Db::pdo() !== null && empty($GLOBALS['__ny_cfg_migrated'])) {
$GLOBALS['__ny_cfg_migrated'] = true;
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
} catch (Throwable $e) {
// 迁移失败不影响本次使用
}
}
} }
$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'], '/');
@@ -164,19 +186,25 @@ function ny_load_config(): array
function ny_save_config(array $cfg): bool function ny_save_config(array $cfg): bool
{ {
// 数据库已连接则写入 DB settings(键 config = JSON),否则回退文件 // 先写文件快照:DB 万一不可用(连不上 / 未初始化)时不会静默回退到出厂默认配置
$fileOk = ny_write_json(ny_config_file(), $cfg);
if (class_exists('Db')) { if (class_exists('Db')) {
$db = Db::pdo(); $db = Db::pdo();
if ($db !== null) { if ($db !== null) {
try { try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE)); Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
$GLOBALS['__ny_cfg_source'] = 'db';
return true; return true;
} catch (Throwable $e) { } catch (Throwable $e) {
// 落库失败则回退文件 // 落库失败则回退文件
$GLOBALS['__ny_cfg_source'] = 'file';
return $fileOk;
} }
} }
} }
return ny_write_json(ny_config_file(), $cfg); $GLOBALS['__ny_cfg_source'] = 'file';
return $fileOk;
} }
/** 数据库初始化后从 DB 读取配置(无则返回 null) */ /** 数据库初始化后从 DB 读取配置(无则返回 null) */
+9 -1
View File
@@ -77,12 +77,20 @@ $rosterCount = Roster::count();
</div> </div>
</div> </div>
<div class="field btns"> <div class="field btns">
<label class="chk" title="有多个组别时按组别均衡抽取(如 A:B = 5:5),无组别时按班级均衡"><input type="checkbox" id="balanceChk" checked> 分组均衡</label> <label class="chk" title="决定有分组时如何分配名额"><span style="color:var(--text-2);font-size:12px">分组比例</span>
<select id="balanceMode" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 8px;background:#fff;margin-left:4px">
<option value="weight">按人数加权(默认)</option>
<option value="even">各组均分(5:5)</option>
<option value="custom">自定义比例</option>
<option value="off">不分组(整池随机)</option>
</select>
</label>
<label class="chk" title="再次抽取时不与本次会话已抽中的人重复;「重置结果」后清零"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label> <label class="chk" title="再次抽取时不与本次会话已抽中的人重复;「重置结果」后清零"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label>
<button class="btn" id="btnExclude" title="勾选不参与抽取的人">🚫 排除管理</button> <button class="btn" id="btnExclude" title="勾选不参与抽取的人">🚫 排除管理</button>
<button class="btn btn-primary" id="btnDraw">🎲 开始抽取</button> <button class="btn btn-primary" id="btnDraw">🎲 开始抽取</button>
<button class="btn" id="btnReset" title="清空本次结果与累计已抽取(历史记录保留)">重置结果</button> <button class="btn" id="btnReset" title="清空本次结果与累计已抽取(历史记录保留)">重置结果</button>
</div> </div>
<div class="field full hidden" id="ratioBox" style="grid-column:1/-1"></div>
</section> </section>
<div id="alert" class="alert hidden"></div> <div id="alert" class="alert hidden"></div>
+24 -2
View File
@@ -184,22 +184,44 @@ $rosterCount = Roster::count();
<input type="text" id="cfgKeywords"> <input type="text" id="cfgKeywords">
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p> <p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
</div> </div>
<div class="fi"> <div class="fi full">
<label>各试卷独立满分</label> <label>各试卷独立满分</label>
<div id="fullScoresBox" class="fullscores"> <div id="fullScoresBox" class="fullscores">
<span class="hint" id="fullScoresHint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span> <span class="hint" id="fullScoresHint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
</div> </div>
<p class="hint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
</div> </div>
<div class="fi full checks"> <div class="fi full checks">
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label> <label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label> <label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label> <label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
</div> </div>
<div class="fi full" style="margin-top:6px">
<div style="display:flex;align-items:center;gap:8px">
<label style="margin:0">🔌 卷王系统连接</label>
<span class="hint" id="cfgConnInfo" style="font-size:12px"></span>
</div>
</div>
<div class="fi full">
<label>系统地址(不可在此修改)</label>
<input type="text" id="cfgBase" readonly>
<p class="hint">如需修改系统地址请重新打开 <a href="install.php?force=1" target="_blank">install.php</a></p>
</div>
<div class="fi">
<label>登录账号</label>
<input type="text" id="cfgUser" autocomplete="username" placeholder="登录卷王的账号">
</div>
<div class="fi">
<label>登录密码</label>
<input type="password" id="cfgPass" autocomplete="new-password" placeholder="已保存 · 留空表示不修改">
<p class="hint">为安全,已保存的密码不回显;如需修改请直接输入新密码后保存</p>
</div>
</div> </div>
<div id="pingResult" class="ping-result hidden"></div> <div id="pingResult" class="ping-result hidden"></div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<span class="hint" style="align-self:center">连接信息由安装向导配置,此处仅调整显示与统计</span> <span class="hint" style="align-self:center">只有修改账号/密码才会触发重连与测连接</span>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn" data-close>取消</button> <button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnSaveCfg">保存</button> <button class="btn btn-primary" id="btnSaveCfg">保存</button>
+9
View File
@@ -15,6 +15,7 @@ class Db
private static string $driver = ''; private static string $driver = '';
private static bool $tried = false; private static bool $tried = false;
private static bool $failed = false; private static bool $failed = false;
private static string $lastError = '';
/** 数据库连接参数文件 */ /** 数据库连接参数文件 */
public static function dbFile(): string public static function dbFile(): string
@@ -58,6 +59,13 @@ class Db
return self::$pdo !== null; return self::$pdo !== null;
} }
/** 上一次连接失败的原因(连接正常时为空) */
public static function lastError(): string
{
self::check();
return self::$lastError;
}
/** /**
* 获取 PDO 单例;未配置或连不上返回 null。 * 获取 PDO 单例;未配置或连不上返回 null。
*/ */
@@ -84,6 +92,7 @@ class Db
} catch (Throwable $e) { } catch (Throwable $e) {
self::$failed = true; self::$failed = true;
self::$pdo = null; self::$pdo = null;
self::$lastError = $e->getMessage();
} }
} }
+1 -1
View File
@@ -100,7 +100,7 @@ const server = http.createServer((req, res) => {
Buffer.from(pwd, 'base64') Buffer.from(pwd, 'base64')
).toString('utf8'); ).toString('utf8');
} catch (e) { plain = '<<解密失败>>'; } } catch (e) { plain = '<<解密失败>>'; }
if (j.username !== 'admin' || plain !== PASSWORD) { if (j.username !== 'mock' || plain !== PASSWORD) {
return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' }); return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' });
} }
TOKEN = 'mock.jwt.token.' + Date.now(); TOKEN = 'mock.jwt.token.' + Date.now();
+288
View File
@@ -0,0 +1,288 @@
// e2e: config_save 仅连接信息变化才清 token;各试卷独立满分修复;密码不回显
// 运行:node tools/test-config-save.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 = 18081;
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';
// 1) 备份 / 准备文件存储模式
const cfgFile = path.join(ROOT, 'data', 'config.json');
const cfgBak = cfgFile + '.bak';
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
const tokenFile = path.join(ROOT, 'data', 'cache', 'token.json');
const tokenBak = tokenFile + '.bak';
const hasToken = fs.existsSync(tokenFile);
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
if (hasToken) fs.renameSync(tokenFile, tokenBak);
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: 'mock',
password: 'surveyking',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
include_temp_save: false,
dedupe: true,
name_keywords: '姓名,名字',
full_score: 100,
full_scores: {},
layout: 'default',
}, null, 2));
// 2) 启动 mock 卷王
const mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
mock.stdout.on('data', d => process.stdout.write('[mock] ' + d));
mock.stderr.on('data', d => process.stderr.write('[mock-err] ' + d));
// 3) 启动 PHP 内置服务器(-t 让所有请求走 index.php 路由)
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]);
const srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: 'pipe' });
srv.stdout.on('data', d => process.stdout.write('[php] ' + d));
srv.stderr.on('data', d => process.stderr.write('[php-err] ' + d));
process.on('exit', () => { try { srv.kill('SIGKILL'); mock.kill('SIGKILL'); } catch(e){} });
process.on('SIGINT', () => { srv.kill('SIGKILL'); mock.kill('SIGKILL'); process.exit(1); });
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 : '')); }
}
function req(method, path, body, headers) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : '';
const h = Object.assign({
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
}, headers || {});
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
const r = http.request({ host: '127.0.0.1', port: PORT, method, path, headers: h }, res => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let j = null;
try { j = JSON.parse(text); } catch (e) {}
// 收集 Set-Cookie
if (res.headers['set-cookie']) {
for (const sc of res.headers['set-cookie']) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
}
}
// 跟随 302(不自动跟随 action=login 等会导致副作用的)
if (res.statusCode === 302 && res.headers.location && !h['__followed'] && !/login\.php/.test(res.headers.location)) {
h['__followed'] = 1;
console.log(' [debug] follow', res.statusCode, '→', res.headers.location);
return resolve(req('GET', res.headers.location, null, h));
}
resolve({ status: res.statusCode, body: j, raw: text, setCookie: res.headers['set-cookie'], loc: res.headers.location });
});
});
r.on('error', reject);
if (data) r.write(data);
r.end();
});
}
const cookieJar = [];
function formPost(path, fields) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(fields).toString();
const h = {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
};
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path, headers: h }, res => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
if (res.headers['set-cookie']) {
for (const sc of res.headers['set-cookie']) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
}
}
resolve({ status: res.statusCode, body: text, loc: res.headers.location });
});
});
r.on('error', reject);
r.write(body); r.end();
});
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
async function login() {
// 走 login.php 的表单登录拿会话
return await formPost('/login.php', { username: 'mock', password: 'surveyking' });
}
(async () => {
// 等服务起来
await delay(2000);
// 用 inline 脚本快速验证 login 是否能成功(保留 srv 不退出)
await new Promise(r => setTimeout(r, 200));
const http = require('http');
const testCookie = await new Promise((resolve) => {
const body = new URLSearchParams({username:'mock',password:'surveyking'}).toString();
http.request({host:'127.0.0.1',port:PORT,method:'POST',path:'/login.php',headers:{'Content-Type':'application/x-www-form-urlencoded','Content-Length':Buffer.byteLength(body)}},res=>{
let c=[]; res.on('data',d=>c.push(d)); res.on('end',()=>{
const text = Buffer.concat(c).toString();
const cookie = (res.headers['set-cookie']||[]).map(c=>c.split(';')[0]).join('; ');
console.log(' [inline] login status', res.statusCode, 'set-cookie', JSON.stringify(res.headers['set-cookie']));
if (text.indexOf('登录失败') >= 0) {
const m = text.match(/登录失败[^<]+/);
console.log(' [inline] body 错误:', m ? m[0] : '(unknown)');
} else {
console.log(' [inline] body end:', text.slice(Math.max(0,text.length-200)));
}
// 拿同一 cookie 调 auth_check
http.request({host:'127.0.0.1',port:PORT,method:'GET',path:'/api.php?action=auth_check',headers:{Cookie:cookie}}, rs=>{
let cc=[]; rs.on('data',d=>cc.push(d)); rs.on('end',()=>{
console.log(' [inline] auth_check', rs.statusCode, Buffer.concat(cc).toString());
resolve(cookie);
});
}).end();
});
}).end(body);
});
if (testCookie && testCookie.indexOf('NYSESS=') >= 0) {
// 同步 jar
cookieJar.length = 0;
testCookie.split('; ').forEach(c => cookieJar.push(c));
}
console.log('\n--- 1) 首次登录应成功 ---');
// 登录 formPost 不会跟随 302(直接保留 body),但要求 NYSESS cookie 出现
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
console.log(' [debug] login loc:', lg.loc, 'body:', (lg.body||'').slice(0, 200));
chk('login.php 200/302', lg.status === 302 || lg.status === 200, 'status=' + lg.status);
chk('session cookie 已获得', cookieJar.length > 0, 'jar=' + JSON.stringify(cookieJar));
// 复查 auth(直接走 Session ID 路径)
const ac = await req('GET', '/api.php?action=auth_check&_t=' + Date.now());
console.log(' [debug] auth_check:', ac.status, ac.raw);
chk('auth_check 通过', ac.body && ac.body.data && ac.body.data.auth === true, JSON.stringify(ac.body));
if (!(ac.body && ac.body.data && ac.body.data.auth)) {
console.log(' [fatal] 未登录,停止测试');
mock.kill('SIGTERM'); srv.kill('SIGTERM');
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
process.exit(1);
}
// 之后用 ping 触发 SurveyKing 登录
const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now());
console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200));
chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body));
await delay(100);
chk('token 已缓存', fs.existsSync(tokenFile));
console.log('\n--- 2) config_get 应返回 storage + 诊断 ---');
const cg1 = await req('GET', '/api.php?action=config_get');
chk('config_get 200', cg1.status === 200);
chk('返回 storage 字段', cg1.body && cg1.body.data && typeof cg1.body.data.storage === 'string',
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined);
chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true);
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
const cs1 = await req('POST', '/api.php?action=config_save', {
full_score: 95,
});
chk('config_save 200', cs1.status === 200);
chk('conn_changed=false', cs1.body && cs1.body.data && cs1.body.data.conn_changed === false);
chk('ping 字段不存在', cs1.body && cs1.body.data && cs1.body.data.ping === undefined);
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('token 保持未变', before === after, 'changed=' + (before !== after));
console.log('\n--- 4) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---');
// 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射)
const cs2 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
});
const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config
? cs2.body.data.config.full_scores : undefined;
chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)',
_cs2fs === undefined || Object.keys(_cs2fs).length === 0,
JSON.stringify(_cs2fs));
// 显式 apply_pid='p1', apply_score=85 → 写入
const cs3 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
apply_pid: 'p1',
apply_score: 85,
});
chk('apply_pid=85 → full_scores.p1=85', cs3.body && cs3.body.data && cs3.body.data.config
&& cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85,
JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores));
// apply_pid='p1', apply_score=0 → 清除
const cs4 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
apply_pid: 'p1',
apply_score: 0,
});
chk('apply_pid=0 → 清除 p1',
cs4.body && cs4.body.data && cs4.body.data.config
&& (!cs4.body.data.config.full_scores || cs4.body.data.config.full_scores.p1 === undefined),
JSON.stringify(cs4.body.data && cs4.body.data.config && cs4.body.data.config.full_scores));
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
// 最先修改密码为不同值:触发 conn_changed=true + ping.ok=false
const cs5 = await req('POST', '/api.php?action=config_save', {
password: 'temp-wrong-password',
});
chk('修改密码后 conn_changed=true', cs5.body && cs5.body.data && cs5.body.data.conn_changed === true);
chk('返回 ping.ok=false', cs5.body && cs5.body.data && cs5.body.data.ping && cs5.body.data.ping.ok === false,
JSON.stringify(cs5.body && cs5.body.data && cs5.body.data.ping));
await delay(200);
const tokAfter = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('修改同 token 被清', tokBefore !== '' && tokAfter === '', 'before=' + (tokBefore !== '') + ' after=' + (tokAfter !== ''));
// 第二步:改回正确密码,应 trigger conn_changed=true + ping.ok=true
const cs5b = await req('POST', '/api.php?action=config_save', {
password: 'surveyking',
});
chk('改回正确密码同 conn_changed=true', cs5b.body && cs5b.body.data && cs5b.body.data.conn_changed === true);
chk('改回后 ping.ok=true', cs5b.body && cs5b.body.data && cs5b.body.data.ping && cs5b.body.data.ping.ok === true,
JSON.stringify(cs5b.body && cs5b.body.data && cs5b.body.data.ping));
await delay(200);
chk('登录成功同 token 已生成', fs.existsSync(tokenFile), 'token exists=' + fs.existsSync(tokenFile));
console.log('\n--- 6) config_get 永远不带明文密码 ---');
const cg2 = await req('GET', '/api.php?action=config_get');
chk('密码不存在', cg2.body && cg2.body.data && cg2.body.data.password === undefined);
chk('has_password 标志', cg2.body && cg2.body.data && cg2.body.data.has_password === true);
console.log('\n--- 7) 修改为错误密码:应自动 ping 失败并保留弹窗提示 ---');
const cs6 = await req('POST', '/api.php?action=config_save', {
username: 'mock',
password: 'wrong-password',
});
chk('conn_changed=true', cs6.body && cs6.body.data && cs6.body.data.conn_changed === true);
chk('ping.ok=false', cs6.body && cs6.body.data && cs6.body.data.ping && cs6.body.data.ping.ok === false,
JSON.stringify(cs6.body && cs6.body.data && cs6.body.data.ping));
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
// 清理
mock.kill('SIGTERM');
srv.kill('SIGTERM');
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
setTimeout(() => process.exit(fail === 0 ? 0 : 1), 200);
})().catch(e => { console.error('FATAL', e); try { mock.kill(); srv.kill(); } catch(z){}; process.exit(2); });
+111 -44
View File
@@ -19,79 +19,146 @@ function chk(name, cond, extra) {
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); } else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
} }
/* ---------- allocQuotas:名额均分(5:5 语义) ---------- */ function sum(a) { return a.reduce((x, y) => x + y, 0); }
// A/B = 12/8 抽 10 → 各 5(名额均分到组,与组人数无关) function eqArr(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
chk('12/8 抽10 → 5/5', JSON.stringify(T.allocQuotas([12, 8], 10)) === '[5,5]',
JSON.stringify(T.allocQuotas([12, 8], 10))); /* ==================== 均分模式(weights = null) ==================== */
// A/B = 12/8 抽 10 → 各 5
chk('均分 12/8 抽10 → 5/5', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]),
JSON.stringify(T.allocQuotas([12, 8], 10, null)));
// 抽 1 人:余数小组优先 // 抽 1 人:余数小组优先
chk('抽1人 → 小组优先', JSON.stringify(T.allocQuotas([12, 8], 1)) === '[0,1]', chk('均分 抽1人 → 小组优先', eqArr(T.allocQuotas([12, 8], 1, null), [0, 1]),
JSON.stringify(T.allocQuotas([12, 8], 1))); JSON.stringify(T.allocQuotas([12, 8], 1, null)));
// 12/8 抽 3 → 均分 1/1 + 余数给小组 → 1/2 // 12/8 抽 3 → 1/2
chk('12/8 抽3 → 1/2', JSON.stringify(T.allocQuotas([12, 8], 3)) === '[1,2]', chk('均分 12/8 抽3 → 1/2', eqArr(T.allocQuotas([12, 8], 3, null), [1, 2]),
JSON.stringify(T.allocQuotas([12, 8], 3))); JSON.stringify(T.allocQuotas([12, 8], 3, null)));
// 组容量不足:A 只有 1 人,B 有 19 人,抽 10 → A 1 + B 9 // 容量不足:A=1, B=19, 抽 10 → 1/9
chk('容量不足差额再分配', JSON.stringify(T.allocQuotas([1, 19], 10)) === '[1,9]', chk('均分 容量不足差额再分配', eqArr(T.allocQuotas([1, 19], 10, null), [1, 9]),
JSON.stringify(T.allocQuotas([1, 19], 10))); JSON.stringify(T.allocQuotas([1, 19], 10, null)));
// 全抽 → 每组全量 // 全抽
chk('全部抽取 = 各组全量', JSON.stringify(T.allocQuotas([12, 8], 20)) === '[12,8]', chk('均分 全部抽取 = 各组全量', eqArr(T.allocQuotas([12, 8], 20, null), [12, 8]),
JSON.stringify(T.allocQuotas([12, 8], 20))); JSON.stringify(T.allocQuotas([12, 8], 20, null)));
// n 超过总人数 → 全量 // 超额
chk('超额抽取取全量', (() => { chk('均分 超额取全量', eqArr(T.allocQuotas([3, 4], 99, null), [3, 4]),
const q = T.allocQuotas([3, 4], 99); JSON.stringify(T.allocQuotas([3, 4], 99, null)));
return JSON.stringify(q) === '[3,4]'; // 20/20 抽 10
})(), JSON.stringify(T.allocQuotas([3, 4], 99))); chk('均分 20/20 抽10 → 5/5', eqArr(T.allocQuotas([20, 20], 10, null), [5, 5]),
// 5:5 经典:A 20 / B 20 抽 10 → 5/5 JSON.stringify(T.allocQuotas([20, 20], 10, null)));
chk('20/20 抽10 → 5/5', JSON.stringify(T.allocQuotas([20, 20], 10)) === '[5,5]', // 三组 10/10/10 抽 8
JSON.stringify(T.allocQuotas([20, 20], 10))); chk('均分 三组 10/10/10 抽8', (() => {
// 三组 10/10/10 抽 8 → 3/3/2(余数并列时稳定分配) const q = T.allocQuotas([10, 10, 10], 8, null);
chk('三组 10/10/10 抽8', (() => { return sum(q) === 8 && Math.max(...q) - Math.min(...q) <= 1;
const q = T.allocQuotas([10, 10, 10], 8); })(), JSON.stringify(T.allocQuotas([10, 10, 10], 8, null)));
return q.reduce((a, b) => a + b, 0) === 8 && Math.max(...q) - Math.min(...q) <= 1; // 空数组
})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8))); chk('空数组返回空', eqArr(T.allocQuotas([], 5, null), []));
// 空组 / 0
chk('空数组返回空', JSON.stringify(T.allocQuotas([], 5)) === '[]'); /* ==================== 加权模式(按人数权重) ==================== */
// 名额总数守恒 + 不超容量 + 均衡(随机 fuzz) // 12/8 抽 10 → 6/4(按人数加权)
chk('加权 12/8 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [12, 8]), [6, 4]),
JSON.stringify(T.allocQuotas([12, 8], 10, [12, 8])));
// 12/8 抽 3 → 2/1(raw=[1.8,1.2], base=[1,1], 余数 .8 → A)
chk('加权 12/8 抽3 → 2/1', eqArr(T.allocQuotas([12, 8], 3, [12, 8]), [2, 1]),
JSON.stringify(T.allocQuotas([12, 8], 3, [12, 8])));
// 加权抽 1 → 按权重
chk('加权 12/8 抽1 → 1/0', eqArr(T.allocQuotas([12, 8], 1, [12, 8]), [1, 0]),
JSON.stringify(T.allocQuotas([12, 8], 1, [12, 8])));
// 自定义比例 6:4
chk('自定义 6:4 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [6, 4]), [6, 4]),
JSON.stringify(T.allocQuotas([12, 8], 10, [6, 4])));
// 自定义 1:1 等价均分
chk('自定义 1:1 = 均分', eqArr(T.allocQuotas([12, 8], 10, [1, 1]), [5, 5]),
JSON.stringify(T.allocQuotas([12, 8], 10, [1, 1])));
// 极端比例 9:1 抽 5
chk('自定义 9:1 抽5', (() => {
const q = T.allocQuotas([12, 8], 5, [9, 1]);
return sum(q) === 5 && q[0] >= 4 && q[1] >= 0 && q[0] <= 12 && q[1] <= 8;
})(), JSON.stringify(T.allocQuotas([12, 8], 5, [9, 1])));
// 容量约束 [1,19] 抽 10:按权重 1:19 → raw=[0.5,9.5] → base=[0,9] + frac 相同时
// 给剩余容量大的组补余数 → A=0, B=10(A 容量只有 1,本来也只可能 0 或 1)
chk('加权 容量约束 [1,19] 抽10', (() => {
const q = T.allocQuotas([1, 19], 10, [1, 19]);
return sum(q) === 10 && q[0] === 0 && q[1] === 10;
})(), JSON.stringify(T.allocQuotas([1, 19], 10, [1, 19])));
// 全 0 权重 → 退化为均分
chk('全0权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, [0, 0]), [5, 5]),
JSON.stringify(T.allocQuotas([12, 8], 10, [0, 0])));
// null 权重 → 均分
chk('null权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]),
JSON.stringify(T.allocQuotas([12, 8], 10, null)));
/* ==================== 守恒 + 不超容量 + fuzz ==================== */
let fuzzOk = true; let fuzzOk = true;
let fuzzBad = ''; let fuzzBad = '';
for (let t = 0; t < 1000; t++) { for (let t = 0; t < 1000; t++) {
const g = 1 + Math.floor(Math.random() * 5); const g = 1 + Math.floor(Math.random() * 5);
const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30)); const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30));
const n = Math.floor(Math.random() * 60); const n = Math.floor(Math.random() * 60);
const q = T.allocQuotas(sizes, n); // 随机权重
const sum = q.reduce((a, b) => a + b, 0); const weights = Array.from({ length: g }, () => Math.random() * 10);
// 50% 概率用 null 走均分
const w = Math.random() < 0.5 ? weights : null;
const q = T.allocQuotas(sizes, n, w);
const total = sizes.reduce((a, b) => a + b, 0); const total = sizes.reduce((a, b) => a + b, 0);
const expect = Math.min(n, total); const expect = Math.min(n, total);
if (sum !== expect) { fuzzOk = false; fuzzBad = `sum=${sum} expect=${expect} sizes=${sizes} n=${n}`; break; } if (sum(q) !== expect) { fuzzOk = false; fuzzBad = `sum=${sum(q)} expect=${expect} sizes=${sizes} n=${n} w=${w}`; break; }
let bad = false; let bad = false;
for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true; for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true;
// 均衡性:所有组均未满时,任意两组名额差 ≤ 1 // 加权模式下"任意两组差 ≤ 1"不一定成立(按权重 9:1 抽 5 应是 5/0)
// 但均分模式下应成立
if (w === null) {
const allNotFull = q.every((v, i) => v < sizes[i]); const allNotFull = q.every((v, i) => v < sizes[i]);
if (allNotFull && Math.max(...q) - Math.min(...q) > 1) bad = true; if (allNotFull && Math.max(...q) - Math.min(...q) > 1) bad = true;
if (bad) { fuzzOk = false; fuzzBad = `sizes=${JSON.stringify(sizes)} n=${n} q=${JSON.stringify(q)}`; break; } }
if (bad) { fuzzOk = false; fuzzBad = `sizes=${sizes} n=${n} w=${w} q=${q}`; break; }
} }
chk('1000 组随机 fuzz:守恒+不超容量+均衡', fuzzOk, fuzzBad); chk('1000 组随机 fuzz:守恒+不超容量+(均分时)均衡', fuzzOk, fuzzBad);
/* ---------- balanceKeyOf ---------- */ /* ==================== balanceKeyOf ==================== */
chk('有 A/B 组别 → 按 grp', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'B', cls: 'C1' }]) === 'grp'); chk('有 A/B 组别 → 按 grp', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'B', cls: 'C1' }]) === 'grp');
chk('单组别 → 按班级', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C2' }]) === 'cls'); chk('单组别 → 按班级', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C2' }]) === 'cls');
chk('无组别多班级 → 按班级', T.balanceKeyOf([{ grp: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls'); chk('无组别多班级 → 按班级', T.balanceKeyOf([{ grp: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls');
chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === ''); chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === '');
/* ---------- pickBalanced ---------- */ /* ==================== pickBalanced ==================== */
{ {
// 12A + 8B,加权(weights=sizes)抽 10 → 应 6A/4B
const pool = []; const pool = [];
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, name: 'A' + i, grp: 'A', cls: 'C' }); for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, name: 'A' + i, grp: 'A', cls: 'C' });
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' }); for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' });
const r = T.pickBalanced(pool, 10, 'grp'); const r = T.pickBalanced(pool, 10, 'grp', [12, 8]);
const byGrp = { A: 0, B: 0 }; const byGrp = { A: 0, B: 0 };
r.picked.forEach(m => byGrp[m.grp]++); r.picked.forEach(m => byGrp[m.grp]++);
chk('均衡抽取 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5, JSON.stringify(byGrp) + ' detail=' + r.detail.join(',')); chk('加权 pickBalanced 12A/8B 抽10 → 6A/4B', byGrp.A === 6 && byGrp.B === 4,
chk('detail 文案', r.detail.join('/') === 'A 5人/B 5人', r.detail.join('/')); JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
chk('结果不重复', new Set(r.picked.map(m => m.sid)).size === 10); chk('pickBalanced 详情文案', r.detail.join('/') === 'A 6人/B 4人', r.detail.join('/'));
chk('pickBalanced 结果不重复', new Set(r.picked.map(m => m.sid)).size === 10);
} }
{ {
// 均分(weights=null)抽 10 → 5A/5B
const pool = [];
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A', cls: 'C' });
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B', cls: 'C' });
const r = T.pickBalanced(pool, 10, 'grp', null);
const byGrp = { A: 0, B: 0 };
r.picked.forEach(m => byGrp[m.grp]++);
chk('均分 pickBalanced 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5,
JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
}
{
// 自定义比例 9:1 抽 5
const pool = [];
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A' });
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B' });
const r = T.pickBalanced(pool, 5, 'grp', [9, 1]);
const byGrp = { A: 0, B: 0 };
r.picked.forEach(m => byGrp[m.grp]++);
chk('自定义 9:1 抽5 → 全部 A', byGrp.A === 5 && byGrp.B === 0,
JSON.stringify(byGrp));
}
{
// 无维度(key='')走不到
const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }]; const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }];
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp'); const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp', null);
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid))); chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
} }