From 6cd17b9cb2365903dd661f2ba64469919d9f9bdf Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Wed, 9 Sep 2026 13:06:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E6=BB=A1=E5=88=86=E5=90=8E=E6=B8=85=E7=99=BB=E5=BD=95=EF=BC=9B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8F=8C=E5=86=99=EF=BC=9B=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=B4=A6=E5=8F=B7/=E5=AF=86=E7=A0=81?= =?UTF-8?q?=EF=BC=9B=E6=8A=BD=E5=8F=96=E6=AF=94=E4=BE=8B=E6=94=B9=E6=8C=89?= =?UTF-8?q?=E4=BA=BA=E6=95=B0=E5=8A=A0=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复:仅当连接信息(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 账号 --- api.php | 75 ++++++++-- assets/app.js | 57 +++++--- assets/draw.js | 227 +++++++++++++++++++++++++----- bootstrap.php | 32 ++++- draw.php | 10 +- index.php | 26 +++- lib/Db.php | 9 ++ tools/mock-server.js | 2 +- tools/test-config-save.js | 288 ++++++++++++++++++++++++++++++++++++++ tools/test-draw-alloc.js | 159 +++++++++++++++------ 10 files changed, 765 insertions(+), 120 deletions(-) create mode 100644 tools/test-config-save.js diff --git a/api.php b/api.php index 856b416..032ffe4 100644 --- a/api.php +++ b/api.php @@ -32,8 +32,14 @@ try { // ---------------- 配置 ---------------- case 'config_get': $c = cfg(); - $c['has_password'] = $c['password'] !== ''; + $c['has_password'] = ((string)$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); case 'config_save': @@ -43,13 +49,20 @@ try { $in = $_POST; } $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; foreach ($allowed as $k) { if (array_key_exists($k, $in)) { $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) { if (array_key_exists($k, $in)) { $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'])) { $new['full_scores'] = $in['full_scores']; } - $pwdIn = $in['password'] ?? null; - if ($pwdIn === null || $pwdIn === '' || $pwdIn === '******') { - $new['password'] = $old['password']; - } - $new['base_url'] = rtrim($new['base_url'], '/'); + $new['base_url'] = rtrim((string)$new['base_url'], '/'); $new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/'); $new['timeout'] = (int)$new['timeout']; $new['refresh_interval'] = (int)$new['refresh_interval']; if ($new['refresh_interval'] < 3) { $new['refresh_interval'] = 3; } - // 每套试卷独立满分:选中试卷时写入 full_scores[projectId],否则更新全局默认 + // 默认满分 $fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100); if ($fs < 1) { $fs = 100; } - $pid = trim((string)($in['project_id'] ?? '')); - if ($pid !== '') { + $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'] : []; - $scores[$pid] = $fs; + $applyScore = (int)($in['apply_score'] ?? 0); + if ($applyScore > 0) { + $scores[$applyPid] = $applyScore; + } else { + unset($scores[$applyPid]); + } $new['full_scores'] = $scores; - } else { - $new['full_score'] = $fs; + } + // 仅当连接信息真的变了才清 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); - @unlink(NY_DATA . '/cache/token.json'); // 清掉旧令牌 - ny_ok(['saved' => true, 'config' => ny_load_config()]); + if ($connChanged) { + @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': diff --git a/assets/app.js b/assets/app.js index c855e8c..12ef974 100644 --- a/assets/app.js +++ b/assets/app.js @@ -550,11 +550,17 @@ // 满分:默认 + 各试卷独立 $('#cfgFull').value = c.full_score || 100; renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {}); - const pid = $('#projectSel').value; - const pObj = state.projects.find(p => p.id === pid); - $('#cfgFullHint').textContent = pid - ? ('当前选中「' + (pObj ? pObj.name : pid) + '」,如需单独设满分请在上方"各试卷独立满分"修改') - : '未选中试卷时,作为所有试卷的默认值'; + // 连接信息(账号回显;密码不回显) + $('#cfgBase').value = (c.base_url || '') + (c.api_prefix || '/api'); + $('#cfgUser').value = c.username || ''; + $('#cfgPass').value = ''; + const conn = c.db_configured + ? (c.db_connected + ? '数据库:已连接 (' + (c.db_driver || '') + ')' + : ('数据库:未连接 · ' + (c.db_error || '未知错误'))) + : '数据库:未配置(使用文件存储)'; + $('#cfgConnInfo').textContent = '配置来源:' + (c.storage || 'default') + ' · ' + conn; + $('#pingResult').classList.add('hidden'); } catch (e) { showAlert(e.message); } $('#settingsModal').classList.add('open'); } @@ -698,6 +704,7 @@ /* 设置保存 */ $('#btnSaveCfg').addEventListener('click', async () => { + const pass = $('#cfgPass').value; const payload = { page_size: parseInt($('#cfgPageSize').value, 10) || 200, refresh_interval: parseInt($('#cfgInterval').value, 10) || 15, @@ -709,21 +716,37 @@ full_score: parseInt($('#cfgFull').value, 10) || 100, layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default', full_scores: collectFullScores(), - project_id: $('#projectSel').value + username: $('#cfgUser').value.trim(), }; + if (pass !== '') payload.password = pass; // 留空 = 不修改 + let resp; try { - await api('config_save', { method: 'POST', body: payload }); + resp = await api('config_save', { method: 'POST', body: payload }); + } catch (e) { showAlert(e.message); return; } + $('#cfgPass').value = ''; // 提交后清空密码框 + const note = (resp && resp.conn_changed) ? '账号/连接已更新' : '设置已保存'; + if (resp && resp.ping) { + if (resp.ping.ok) { + showAlert(note + ',测连接成功(' + resp.ping.projects + ' 个项目 / ' + resp.ping.elapsed + 'ms)', 'info'); + } else { + showAlert(note + ',但测连接失败:' + resp.ping.msg, 'error'); + } + } else { + showAlert(note, 'info'); + } + // 应用布局偏好(写入容器 + localStorage) + applyLayout(payload.layout); + const iv = payload.refresh_interval; + const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10)); + let best = opts[0]; + opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; }); + $('#intervalSel').value = String(best); + schedule(); + loadProjects(); + // ping 失败时保留弹窗,让用户能立即改回 + if (!resp || !resp.ping || resp.ping.ok) { $('#settingsModal').classList.remove('open'); - // 应用布局偏好(写入容器 + localStorage) - applyLayout(payload.layout); - const iv = payload.refresh_interval; - const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10)); - let best = opts[0]; - opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; }); - $('#intervalSel').value = String(best); - schedule(); - loadProjects(); - } catch (e) { showAlert(e.message); } + } }); // 收集"各试卷独立满分" -> {projectId: score} diff --git a/assets/draw.js b/assets/draw.js index 1c2d60a..8a3ba60 100644 --- a/assets/draw.js +++ b/assets/draw.js @@ -33,6 +33,7 @@ history: new Set(), // 累计抽中(重置结果时清零) excluded: new Set(), // 手动排除 log: [], // 历史批次,新的在前 + customRatios: {}, // 自定义比例:{ 组名: 权重 } drawing: false, }; @@ -77,38 +78,91 @@ } /** - * 名额均分到各组(5:5 语义):每组 n/组数,余数轮流补, - * 某组人数不足时把差额退回分给未满的组(结果之和 === min(n, 总人数)) + * 名额分配: + * - sizes 各组容量 + * - n 要抽的总人数 + * - weights 各组权重(null=均分;数组=按权重,权重<=0 视为 0) + * 算法:最大余额法(largest remainder)—— raw_i = n * w_i / Σw;floor 后把剩余名额 + * 按小数部分降序分给还没满的组。均分模式是 weights=null 的特例(每组权重 1), + * 余数时按"组容量小者优先"补齐,保证每组都有代表。 */ - function allocQuotas(sizes, n) { + function allocQuotas(sizes, n, weights) { const g = sizes.length; const quota = new Array(g).fill(0); if (g === 0 || n <= 0) return quota; + const total = sizes.reduce((a, b) => a + b, 0); + if (n >= total) return sizes.slice(); // 全量 - // 1) 均分初值 + 余数补齐(小组优先,保证每组有代表) - const base = Math.floor(n / g); - quota.fill(base); - let r = n - base * g; - const bySmall = sizes.map((s, i) => i).sort((a, b) => sizes[a] - sizes[b]); - for (let k = 0; k < r; k++) quota[bySmall[k % g]]++; + const base = new Array(g).fill(0); + const frac = new Array(g).fill(0); - // 2) 超过组容量的退回 + if (Array.isArray(weights) && weights.length === g) { + const w = weights.map(x => (Number(x) > 0 ? Number(x) : 0)); + const wsum = w.reduce((a, b) => a + b, 0); + if (wsum > 0) { + // 按权重分配:最大余额法 + for (let i = 0; i < g; i++) { + const raw = n * w[i] / wsum; + base[i] = Math.floor(raw); + frac[i] = raw - base[i]; + } + } else { + // 全 0 权重 → 退化为均分 + const b = Math.floor(n / g); + for (let i = 0; i < g; i++) base[i] = b; + const rem = n - b * g; + const bySmall = sizes.map((s, i) => i) + .sort((a, b2) => sizes[a] - sizes[b2] || a - b2); + for (let k = 0; k < rem; k++) base[bySmall[k % g]]++; + } + } else { + // 名额均分:每组 n/g,余数小组优先(保证每组都有代表) + const b = Math.floor(n / g); + for (let i = 0; i < g; i++) base[i] = b; + const rem = n - b * g; + const bySmall = sizes.map((s, i) => i) + .sort((a, b2) => sizes[a] - sizes[b2] || a - b2); + for (let k = 0; k < rem; k++) base[bySmall[k % g]]++; + } + + // 余数补齐:frac 大的优先;同 frac 时剩余容量大的优先 + let rest = n - base.reduce((a, b) => a + b, 0); + if (rest > 0) { + const order = sizes.map((s, i) => i).sort((a, b) => + (frac[b] - frac[a]) || ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b)); + for (let k = 0; rest > 0 && k < g; k++) { + const i = order[k % g]; + if (base[i] < sizes[i]) { base[i]++; rest--; } + } + // 若仍有余数(组容量限制),继续按剩余容量大者分配 + while (rest > 0) { + let progress = false; + const byRoom = sizes.map((s, i) => i) + .sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b)); + for (const i of byRoom) { + if (rest <= 0) break; + if (base[i] < sizes[i]) { base[i]++; rest--; progress = true; } + } + if (!progress) break; + } + } + + // 超容量退回(极端比例时兜底) let overflow = 0; for (let i = 0; i < g; i++) { - if (quota[i] > sizes[i]) { overflow += quota[i] - sizes[i]; quota[i] = sizes[i]; } + if (base[i] > sizes[i]) { overflow += base[i] - sizes[i]; base[i] = sizes[i]; } } - // 3) 差额分给未满的组(剩余容量大者优先) while (overflow > 0) { - const byRoom = sizes.map((s, i) => i) - .sort((a, b) => (sizes[b] - quota[b]) - (sizes[a] - quota[a])); let progress = false; + const byRoom = sizes.map((s, i) => i) + .sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b)); for (const i of byRoom) { if (overflow <= 0) break; - if (quota[i] < sizes[i]) { quota[i]++; overflow--; progress = true; } + if (base[i] < sizes[i]) { base[i]++; overflow--; progress = true; } } if (!progress) break; } - return quota; + return base; } /** @@ -123,10 +177,11 @@ } /** - * 分组均衡抽取:返回 { picked, detail } - * detail 例:["A 3人", "B 3人"],用于提示与历史记录 + * 分组均衡抽取:返回 { picked, detail, sizes, weights } + * weights 数组为各组实际权重(默认加权时=各组人数) + * detail 例:["A 6人", "B 4人"],用于提示与历史记录 */ - function pickBalanced(pool, n, key) { + function pickBalanced(pool, n, key, weights) { const label = key === 'grp' ? '(未分组)' : '(未分班)'; const groups = new Map(); pool.forEach(m => { @@ -137,7 +192,10 @@ const gk = Array.from(groups.keys()) .sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true })); const sizes = gk.map(k => groups.get(k).length); - const quotas = allocQuotas(sizes, n); + const w = (Array.isArray(weights) && weights.length === gk.length) + ? weights + : null; + const quotas = allocQuotas(sizes, n, w); const picked = []; const detail = []; gk.forEach((k, i) => { @@ -145,7 +203,7 @@ picked.push(...part); if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人'); }); - return { picked, detail }; + return { picked, detail, sizes, weights: w || sizes.slice() }; } // 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用) @@ -278,6 +336,36 @@ if (ec) ec.textContent = '已排除 ' + state.excluded.size + ' 人'; } + /** + * 计算当前候选池的分组信息:{ key, gk[], sizes[] } + * key = 'grp' | 'cls' | '' + * gk 按字典序排序后的组名 + * sizes 每组成员数 + */ + function poolGroups(pool) { + const key = balanceKeyOf(pool); + if (!key) return { key: '', gk: [], sizes: [] }; + const label = key === 'grp' ? '(未分组)' : '(未分班)'; + const groups = new Map(); + pool.forEach(m => { + const k = String(m[key] || '').trim() || label; + if (!groups.has(k)) groups.set(k, 0); + groups.set(k, groups.get(k) + 1); + }); + const gk = Array.from(groups.keys()) + .sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true })); + return { key, gk, sizes: gk.map(k => groups.get(k)) }; + } + + /** 把对象 {gk:weight} 读成与当前池分组顺序一致的数组,缺组视为 0 */ + function readCustomWeights(gk) { + const src = state.customRatios || {}; + return gk.map(k => { + const v = parseFloat(src[k]); + return Number.isFinite(v) && v > 0 ? v : 0; + }); + } + /* ================= 抽取 ================= */ async function doDraw() { if (state.drawing) return; @@ -305,18 +393,23 @@ await sleep(40 + i * 7); } - // 真正抽取:均衡(组别优先,其次班级)或整池随机 + // 真正抽取:均衡(按模式分组) 或整池随机 let picked, detail = []; - if ($('#balanceChk').checked) { - const key = balanceKeyOf(pool); - if (key) { - const res = pickBalanced(pool, n, key); + const mode = $('#balanceMode').value; + if (mode === 'off') { + picked = shuffle(pool).slice(0, n); + } else { + const g = poolGroups(pool); + if (g.key) { + let weights = null; + if (mode === 'weight') weights = g.sizes.slice(); // 默认按组人数加权 + else if (mode === 'custom') weights = readCustomWeights(g.gk); + // mode === 'even' 走 allocQuotas(null) → 均分 + const res = pickBalanced(pool, n, g.key, weights); picked = res.picked; detail = res.detail; } else { picked = shuffle(pool).slice(0, n); } - } else { - picked = shuffle(pool).slice(0, n); } state.picked = picked; @@ -329,7 +422,7 @@ renderResult(); renderStats(); - pushLog(picked, detail); + pushLog(picked, detail, mode); saveState(); state.drawing = false; @@ -356,12 +449,13 @@ } /* ================= 历史记录 ================= */ - function pushLog(picked, detail) { + function pushLog(picked, detail, mode) { + const modeTxt = ({ weight: '按人数加权', even: '均分', custom: '自定义比例', off: '不分组' })[mode] || ''; state.log.unshift({ ts: new Date().toLocaleString('zh-CN', { hour12: false }), source: state.source === 'project' ? '已提交人员' : '学号名单', range: rangeText(), - balance: detail.join(' / '), + balance: (modeTxt ? modeTxt + ':' : '') + (detail || []).join(' / '), count: picked.length, members: picked.map(m => ({ sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '', @@ -498,6 +592,14 @@ function saveSettings() { try { + // 收集自定义比例输入 + const cr = {}; + $$('#ratioBox input[data-gk]').forEach(inp => { + const k = inp.dataset.gk; + const v = parseFloat(inp.value); + if (k && Number.isFinite(v) && v > 0) cr[k] = v; + }); + state.customRatios = cr; localStorage.setItem(LS_SETTINGS, JSON.stringify({ source: state.source, projectId: $('#projectSel').value, @@ -505,7 +607,8 @@ grp: $('#grpSel').value, num: $('#numInput').value, exclude: $('#excludePicked').checked, - balance: $('#balanceChk').checked, + mode: $('#balanceMode').value, + customRatios: cr, })); } catch (e) { /* 忽略 */ } } @@ -548,13 +651,14 @@ } populateFilters(); renderStats(); + renderRatioBox(); if ($('#exclModal').classList.contains('open')) renderExcl(); saveSettings(); }); $('#projectSel').addEventListener('change', async () => { const pid = $('#projectSel').value; - if (!pid) { state.answers = []; populateFilters(); renderStats(); return; } + if (!pid) { state.answers = []; populateFilters(); renderStats(); renderRatioBox(); return; } showAlert('正在拉取已提交人员…', 'info'); try { await loadAnswers(pid); @@ -564,16 +668,30 @@ } populateFilters(); renderStats(); + renderRatioBox(); if ($('#exclModal').classList.contains('open')) renderExcl(); saveSettings(); }); ['#clsSel', '#grpSel'].forEach(id => { - $(id).addEventListener('change', () => { renderStats(); saveSettings(); }); + $(id).addEventListener('change', () => { + renderStats(); renderRatioBox(); saveSettings(); + }); }); $('#numInput').addEventListener('change', saveSettings); $('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); }); - $('#balanceChk').addEventListener('change', saveSettings); + $('#balanceMode').addEventListener('change', () => { + renderRatioBox(); + saveSettings(); + }); + // 班级/组别/来源切换会改变候选池 → 比例输入框也要重画 + ['#sourceSel', '#projectSel', '#clsSel', '#grpSel'].forEach(id => { + const el = $(id); + if (el) el.addEventListener('change', () => { + // 延迟一帧,等 populateFilters 填好再算 + setTimeout(renderRatioBox, 0); + }); + }); $$('[data-num]').forEach(b => { b.addEventListener('click', () => { @@ -700,22 +818,59 @@ }); } + /** + * 渲染"自定义比例"输入框:按当前候选池的分组(组别/班级)列出,每个组一个数字输入框, + * 默认按各组人数预填(即等价"按人数加权"),用户可改。 + */ + function renderRatioBox() { + const box = $('#ratioBox'); + if (!box) return; + const pool = currentPool(); + const g = poolGroups(pool); + const mode = $('#balanceMode').value; + const show = (mode === 'custom') && g.key; + box.classList.toggle('hidden', !show); + if (!show) return; + box.innerHTML = + '

按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。

' + + g.gk.map((k, i) => { + const def = (state.customRatios && Number.isFinite(parseFloat(state.customRatios[k]))) + ? state.customRatios[k] + : g.sizes[i]; + return '
' + + '' + + '' + + '
'; + }).join(''); + // 改动即时保存 + box.querySelectorAll('input[data-gk]').forEach(inp => { + inp.addEventListener('change', saveSettings); + }); + } + /* ---------------- 启动 ---------------- */ document.addEventListener('DOMContentLoaded', async function () { loadState(); const s = loadSettings(); if (s.exclude === false) $('#excludePicked').checked = false; - if (s.balance === false) $('#balanceChk').checked = false; + // 兼容旧版:s.balance === false → 不分组 + const mode = s.mode || (s.balance === false ? 'off' : 'weight'); + $('#balanceMode').value = mode; if (s.num) $('#numInput').value = s.num; if (s.source === 'project') { state.source = 'project'; $('#sourceSel').value = 'project'; $('#projectField').classList.remove('hidden'); } + if (s.customRatios && typeof s.customRatios === 'object') { + state.customRatios = s.customRatios; + } bind(); renderResult(); renderStats(); + // 第一次也要按当前池渲染自定义比例框 + renderRatioBox(); try { await loadRoster(); diff --git a/bootstrap.php b/bootstrap.php index 65189ec..7bc030f 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -132,8 +132,19 @@ function ny_config_file(): string 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 { + $GLOBALS['__ny_cfg_source'] = 'default'; + // 数据库已初始化且能连上 → 从 DB 读,否则回退文件 $dbCfg = ny_db_config(); if ($dbCfg !== null) { @@ -145,6 +156,7 @@ function ny_load_config(): array } $cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/'); $cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/'); + $GLOBALS['__ny_cfg_source'] = 'db'; return $cfg; } @@ -156,6 +168,16 @@ function ny_load_config(): array $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['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/'); @@ -164,19 +186,25 @@ function ny_load_config(): array function ny_save_config(array $cfg): bool { - // 数据库已连接则写入 DB settings(键 config = JSON),否则回退文件 + // 先写文件快照:DB 万一不可用(连不上 / 未初始化)时不会静默回退到出厂默认配置 + $fileOk = ny_write_json(ny_config_file(), $cfg); + if (class_exists('Db')) { $db = Db::pdo(); if ($db !== null) { try { Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE)); + $GLOBALS['__ny_cfg_source'] = 'db'; return true; } 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) */ diff --git a/draw.php b/draw.php index dfa6d1c..54755e4 100644 --- a/draw.php +++ b/draw.php @@ -77,12 +77,20 @@ $rosterCount = Roster::count();
- +
+ diff --git a/index.php b/index.php index 4ca8b30..5901bf4 100644 --- a/index.php +++ b/index.php @@ -184,22 +184,44 @@ $rosterCount = Roster::count();

英文逗号分隔,用于自动识别"姓名"题目

-
+
未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分
+

给某卷填入数字即可单独覆盖默认满分;留空=用默认

+ +
+
+ + +
+
+
+ + +

如需修改系统地址请重新打开 install.php

+
+
+ + +
+
+ + +

为安全,已保存的密码不回显;如需修改请直接输入新密码后保存

+