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:
@@ -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
@@ -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
@@ -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
@@ -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) */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
|
||||||
}
|
}
|
||||||
chk('1000 组随机 fuzz:守恒+不超容量+均衡', fuzzOk, fuzzBad);
|
if (bad) { fuzzOk = false; fuzzBad = `sizes=${sizes} n=${n} w=${w} q=${q}`; break; }
|
||||||
|
}
|
||||||
|
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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user