From 6772aeae11460502e9045eb35bf51b811a859119 Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Wed, 9 Sep 2026 12:29:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=BD=E5=8F=96=E9=A1=B5=E5=88=86?= =?UTF-8?q?=E7=BB=84=E5=9D=87=E8=A1=A15:5=20/=20=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E6=B8=85=E7=B4=AF=E8=AE=A1=20/=20=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=20/=20=E6=8E=92=E9=99=A4=E7=AE=A1=E7=90=86=20/=20?= =?UTF-8?q?=E7=99=BB=E9=99=86=E8=B4=A6=E5=8F=B7=E5=AD=97=E6=AE=B5=20/=20si?= =?UTF-8?q?de=E7=AD=89=E9=AB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 随机抽取页: - 分组均衡抽取:有组别(≥2个)时默认按名额均分到组(12/8抽10→5/5), 其次按班级均分,否则整池随机;可勾选关闭 - 名额均分算法 allocQuotas:floor 均分 + 余数小组优先补齐 + 超容量退回 + 差额给剩余容量大者 - 重置结果:同时清零累计已抽取数(历史记录仍保留) - 历史记录:每批次入 localStorage ny_draw_log(上限50), 重置不清,支持查看详情/恢复结果/删除/清空 - 抽取排除:排除管理弹窗(搜索/清空),结果行可"移出并加入排除" - 白名单列表下载:登陆账号优先,缺失回退学号 名单登陆账号: - 模板解析识别"登陆/登录账号/账号/手机号"列,手机号去 .0 尾巴 - Db ny_roster 增 login 列 + 旧表自动升级(三方言) - roster_save 透传 login;draw_export 使用 login - 登陆账号仅用于白名单列表下载,其余界面不展示 布局:side 模式下成绩明细/缺考名单 panel 改 flex 撑满, .table-scroll 取消 320px 限高,两侧等高不再留白 测试:tools/test-draw-alloc.js 18/18 通过(含 1000 组随机 fuzz) --- api.php | 19 +- assets/draw.js | 426 ++++++++++++++++++++++++++++++++++----- assets/style.css | 37 ++++ draw.php | 60 +++++- lib/Db.php | 55 ++++- lib/Roster.php | 40 ++-- tools/test-draw-alloc.js | 99 +++++++++ 7 files changed, 640 insertions(+), 96 deletions(-) create mode 100644 tools/test-draw-alloc.js diff --git a/api.php b/api.php index f6771fc..856b416 100644 --- a/api.php +++ b/api.php @@ -313,10 +313,11 @@ try { continue; } $students[] = [ - 'sid' => (string)($s['sid'] ?? ''), - 'name' => (string)($s['name'] ?? ''), - 'cls' => trim((string)($s['cls'] ?? '')), - 'grp' => trim((string)($s['grp'] ?? '')), + 'sid' => (string)($s['sid'] ?? ''), + 'name' => (string)($s['name'] ?? ''), + 'cls' => trim((string)($s['cls'] ?? '')), + 'grp' => trim((string)($s['grp'] ?? '')), + 'login' => trim((string)($s['login'] ?? '')), ]; } Roster::save($students); @@ -429,12 +430,14 @@ try { if (!is_array($m)) { continue; } - $name = trim((string)($m['name'] ?? '')); - $sid = trim((string)($m['sid'] ?? '')); - if ($name === '' && $sid === '') { + $name = trim((string)($m['name'] ?? '')); + // 登陆账号优先(名单导入的"登陆账号"列),没有则回退学号 + $acct = trim((string)($m['login'] ?? '')); + $sid = trim((string)($m['sid'] ?? '')); + if ($name === '' && $acct === '' && $sid === '') { continue; } - $members[] = [$name, $sid]; + $members[] = [$name, $acct !== '' ? $acct : $sid]; } $bin = XlsxWriter::render( array_merge([['姓名', '登陆账号']], $members), diff --git a/assets/draw.js b/assets/draw.js index 1901248..1c2d60a 100644 --- a/assets/draw.js +++ b/assets/draw.js @@ -1,5 +1,8 @@ /*! * NY 问卷考试系统数据平台 - 随机抽取页逻辑 + * + * 功能:来源切换 / 班级组别筛选 / 分组均衡抽取(5:5) / 排除已抽中 / + * 排除名单管理 / 历史记录(重置不丢失) / 白名单下载(登陆账号优先) */ (function () { 'use strict'; @@ -13,9 +16,13 @@ } const sleep = ms => new Promise(r => setTimeout(r, ms)); - const LS_RESULT = 'ny_draw_result'; // 最近一次抽取结果(刷新后仍可下载) - const LS_HISTORY = 'ny_draw_history'; // 累计抽中的学号(用于"排除已抽中") - const LS_SETTINGS = 'ny_draw_settings'; // 来源 / 项目 / 班级 / 组别 / 人数 + const LS_RESULT = 'ny_draw_result'; // 当前抽取结果(刷新后仍可下载) + const LS_HISTORY = 'ny_draw_history'; // 累计抽中学号(「重置结果」清零) + const LS_EXCLUDED = 'ny_draw_excluded'; // 手动排除的学号 + const LS_LOG = 'ny_draw_log'; // 历史批次(重置不清,上限 50 条) + const LS_SETTINGS = 'ny_draw_settings'; // 来源 / 项目 / 班级 / 组别 / 人数 / 开关 + + const LOG_MAX = 50; const state = { roster: [], @@ -23,7 +30,9 @@ answers: [], source: 'roster', picked: [], - history: new Set(), + history: new Set(), // 累计抽中(重置结果时清零) + excluded: new Set(), // 手动排除 + log: [], // 历史批次,新的在前 drawing: false, }; @@ -55,14 +64,104 @@ el.className = 'alert' + (type === 'info' ? ' info' : ''); } - /* ---------------- 数据加载 ---------------- */ + /* ================= 抽取核心(纯函数,供测试) ================= */ + + /** Fisher-Yates 洗牌(返回新数组) */ + function shuffle(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = a[i]; a[i] = a[j]; a[j] = t; + } + return a; + } + + /** + * 名额均分到各组(5:5 语义):每组 n/组数,余数轮流补, + * 某组人数不足时把差额退回分给未满的组(结果之和 === min(n, 总人数)) + */ + function allocQuotas(sizes, n) { + const g = sizes.length; + const quota = new Array(g).fill(0); + if (g === 0 || n <= 0) return quota; + + // 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]]++; + + // 2) 超过组容量的退回 + let overflow = 0; + for (let i = 0; i < g; i++) { + if (quota[i] > sizes[i]) { overflow += quota[i] - sizes[i]; quota[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; + for (const i of byRoom) { + if (overflow <= 0) break; + if (quota[i] < sizes[i]) { quota[i]++; overflow--; progress = true; } + } + if (!progress) break; + } + return quota; + } + + /** + * 选择均衡维度:组别(>=2 个非空组)优先,其次班级,否则不均衡 + */ + function balanceKeyOf(pool) { + const grps = new Set(pool.map(m => String(m.grp || '').trim()).filter(Boolean)); + if (grps.size >= 2) return 'grp'; + const clss = new Set(pool.map(m => String(m.cls || '').trim()).filter(Boolean)); + if (clss.size >= 2) return 'cls'; + return ''; + } + + /** + * 分组均衡抽取:返回 { picked, detail } + * detail 例:["A 3人", "B 3人"],用于提示与历史记录 + */ + function pickBalanced(pool, n, key) { + 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, []); + groups.get(k).push(m); + }); + 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 picked = []; + const detail = []; + gk.forEach((k, i) => { + const part = shuffle(groups.get(k)).slice(0, quotas[i]); + picked.push(...part); + if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人'); + }); + return { picked, detail }; + } + + // 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用) + if (typeof window !== 'undefined') { + window.__nyDrawTest = { shuffle, allocQuotas, balanceKeyOf, pickBalanced }; + } + + /* ================= 数据加载 ================= */ async function loadRoster() { const d = await api('roster'); state.roster = (d && d.students ? d.students : []).map(s => ({ - sid: String(s.sid || ''), - name: String(s.name || ''), - cls: String(s.cls || ''), - grp: String(s.grp || ''), + sid: String(s.sid || ''), + name: String(s.name || ''), + cls: String(s.cls || ''), + grp: String(s.grp || ''), + login: String(s.login || ''), })); const badge = $('#rosterBadge'); if (badge) badge.textContent = state.roster.length; @@ -106,15 +205,18 @@ async function loadAnswers(pid) { const d = await api('data', { projectId: pid }); + const loginOf = {}; + state.roster.forEach(s => { if (s.login) loginOf[s.sid] = s.login; }); state.answers = ((d && d.rows) ? d.rows : []).map(r => ({ - sid: String(r.sid || ''), - name: String(r.name || ''), - cls: String(r.cls || ''), - grp: String(r.grp || ''), + sid: String(r.sid || ''), + name: String(r.name || ''), + cls: String(r.cls || ''), + grp: String(r.grp || ''), + login: String(loginOf[r.sid] || ''), })); } - /* ---------------- 候选池 ---------------- */ + /* ================= 候选池 ================= */ function baseList() { return state.source === 'project' ? state.answers : state.roster; } @@ -125,6 +227,7 @@ const grp = $('#grpSel').value; if (cls) list = list.filter(x => (x.cls || '') === cls); if (grp) list = list.filter(x => (x.grp || '') === grp); + list = list.filter(x => !state.excluded.has(String(x.sid))); if ($('#excludePicked').checked) { list = list.filter(x => !state.history.has(String(x.sid))); } @@ -153,36 +256,34 @@ fillSel($('#grpSel'), uniqSorted(list.map(x => x.grp || '')), '全部组别'); } + function rangeText() { + const cls = $('#clsSel').value, grp = $('#grpSel').value; + const parts = []; + if (cls) parts.push(cls); + if (grp) parts.push(grp + '组'); + return parts.length ? parts.join(' / ') : '全部'; + } + function renderStats() { const pool = currentPool(); $('#dPool').textContent = pool.length; $('#dPicked').textContent = state.picked.length; $('#dHistory').textContent = state.history.size; + $('#dExcluded').textContent = state.excluded.size; $('#dSource').textContent = state.source === 'project' ? '已提交人员' : '学号名单'; - - const cls = $('#clsSel').value, grp = $('#grpSel').value; - const parts = []; - if (cls) parts.push(cls); - if (grp) parts.push(grp + '组'); - $('#drawHint').textContent = '候选 ' + pool.length + ' 人' + - (parts.length ? '(' + parts.join(' / ') + ')' : ''); - } - - /* ---------------- 抽取 ---------------- */ - function shuffle(arr) { - const a = arr.slice(); - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const t = a[i]; a[i] = a[j]; a[j] = t; - } - return a; + $('#drawHint').textContent = '候选 ' + pool.length + ' 人(' + rangeText() + ')'; + const lb = $('#logBadge'); + if (lb) lb.textContent = state.log.length; + const ec = $('#exclCount'); + if (ec) ec.textContent = '已排除 ' + state.excluded.size + ' 人'; } + /* ================= 抽取 ================= */ async function doDraw() { if (state.drawing) return; const pool = currentPool(); if (!pool.length) { - showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」或调整班级 / 组别)'); + showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)'); return; } let n = parseInt($('#numInput').value, 10); @@ -204,7 +305,20 @@ await sleep(40 + i * 7); } - const picked = shuffle(pool).slice(0, n); + // 真正抽取:均衡(组别优先,其次班级)或整池随机 + let picked, detail = []; + if ($('#balanceChk').checked) { + const key = balanceKeyOf(pool); + if (key) { + const res = pickBalanced(pool, n, key); + picked = res.picked; detail = res.detail; + } else { + picked = shuffle(pool).slice(0, n); + } + } else { + picked = shuffle(pool).slice(0, n); + } + state.picked = picked; picked.forEach(m => state.history.add(String(m.sid))); @@ -215,17 +329,19 @@ renderResult(); renderStats(); + pushLog(picked, detail); saveState(); state.drawing = false; btn.disabled = false; - showAlert('已抽取 ' + n + ' 人' + (n > 1 ? ',可点击右侧按钮下载白名单' : ''), 'info'); + const tail = detail.length ? '(' + detail.join(' / ') + ')' : ''; + showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info'); } function renderResult() { const tb = $('#drawBody'); if (!state.picked.length) { - tb.innerHTML = '尚未抽取'; + tb.innerHTML = '尚未抽取'; return; } tb.innerHTML = state.picked.map((m, i) => @@ -235,10 +351,100 @@ '' + esc(m.name) + '' + '' + esc(m.cls || '') + '' + '' + esc(m.grp || '') + '' + + '' + '').join(''); } - /* ---------------- 下载 ---------------- */ + /* ================= 历史记录 ================= */ + function pushLog(picked, detail) { + state.log.unshift({ + ts: new Date().toLocaleString('zh-CN', { hour12: false }), + source: state.source === 'project' ? '已提交人员' : '学号名单', + range: rangeText(), + balance: detail.join(' / '), + count: picked.length, + members: picked.map(m => ({ + sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '', + })), + }); + if (state.log.length > LOG_MAX) state.log.length = LOG_MAX; + saveLog(); + } + + function renderLog() { + const box = $('#logList'); + if (!box) return; + if (!state.log.length) { + box.innerHTML = '

暂无历史记录

'; + return; + } + box.innerHTML = state.log.map((e, i) => { + const rows = (e.members || []).map((m, j) => + '' + + '' + (j + 1) + '' + + '' + esc(m.sid) + '' + + '' + esc(m.name) + '' + + '' + esc(m.cls || '') + '' + + '' + esc(m.grp || '') + '' + + '').join(''); + const meta = [e.ts, e.source, '范围:' + e.range, e.count + ' 人']; + if (e.balance) meta.push('均衡:' + e.balance); + return '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
查看成员(' + e.count + ')' + + '
' + + '' + + '' + rows + '
序号学号姓名班级组别
' + + '
' + + '
'; + }).join(''); + } + + /* ================= 排除管理 ================= */ + let exclQuery = ''; + + function renderExcl() { + const tb = $('#exclBody'); + if (!tb) return; + let list = baseList().slice(); + if (exclQuery) { + const q = exclQuery.toLowerCase(); + list = list.filter(x => + String(x.sid).toLowerCase().includes(q) || + String(x.name).toLowerCase().includes(q) || + String(x.cls || '').toLowerCase().includes(q)); + } + if (!list.length) { + tb.innerHTML = '' + + (baseList().length ? '没有匹配的人员' : '当前来源暂无人员') + ''; + return; + } + tb.innerHTML = list.map(m => { + const on = state.excluded.has(String(m.sid)) ? ' checked' : ''; + return '' + + '' + + '' + esc(m.sid) + '' + + '' + esc(m.name) + '' + + '' + esc(m.cls || '') + '' + + '' + esc(m.grp || '') + '' + + ''; + }).join(''); + } + + /* ================= 下载(登陆账号优先) ================= */ + function dlMembers() { + return state.picked.map(m => ({ + name: m.name, + sid: m.sid, + login: m.login || '', + })); + } + async function download(type) { if (!state.picked.length) { showAlert('请先点击「🎲 开始抽取」'); @@ -250,10 +456,7 @@ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: type, - members: state.picked.map(m => ({ name: m.name, sid: m.sid })), - }), + body: JSON.stringify({ type: type, members: dlMembers() }), }); if (!r.ok) throw new Error('HTTP ' + r.status); const blob = await r.blob(); @@ -271,7 +474,7 @@ } } - /* ---------------- 本地缓存 ---------------- */ + /* ================= 本地缓存 ================= */ function saveState() { try { localStorage.setItem(LS_RESULT, JSON.stringify(state.picked)); @@ -279,6 +482,20 @@ } catch (e) { /* 忽略 */ } } + function saveLog() { + try { + localStorage.setItem(LS_LOG, JSON.stringify(state.log)); + const lb = $('#logBadge'); + if (lb) lb.textContent = state.log.length; + } catch (e) { /* 忽略 */ } + } + + function saveExcluded() { + try { + localStorage.setItem(LS_EXCLUDED, JSON.stringify(Array.from(state.excluded))); + } catch (e) { /* 忽略 */ } + } + function saveSettings() { try { localStorage.setItem(LS_SETTINGS, JSON.stringify({ @@ -288,17 +505,22 @@ grp: $('#grpSel').value, num: $('#numInput').value, exclude: $('#excludePicked').checked, + balance: $('#balanceChk').checked, })); } catch (e) { /* 忽略 */ } } function loadState() { - try { - const p = JSON.parse(localStorage.getItem(LS_RESULT) || '[]'); - if (Array.isArray(p)) state.picked = p; - const h = JSON.parse(localStorage.getItem(LS_HISTORY) || '[]'); - if (Array.isArray(h)) state.history = new Set(h.map(String)); - } catch (e) { /* 忽略 */ } + const arr = (k) => { + try { + const v = JSON.parse(localStorage.getItem(k) || '[]'); + return Array.isArray(v) ? v : []; + } catch (e) { return []; } + }; + state.picked = arr(LS_RESULT); + state.history = new Set(arr(LS_HISTORY).map(String)); + state.excluded = new Set(arr(LS_EXCLUDED).map(String)); + state.log = arr(LS_LOG); } function loadSettings() { @@ -309,7 +531,7 @@ return {}; } - /* ---------------- 事件 ---------------- */ + /* ================= 事件 ================= */ function bind() { $('#sourceSel').addEventListener('change', async () => { state.source = $('#sourceSel').value; @@ -326,6 +548,7 @@ } populateFilters(); renderStats(); + if ($('#exclModal').classList.contains('open')) renderExcl(); saveSettings(); }); @@ -341,6 +564,7 @@ } populateFilters(); renderStats(); + if ($('#exclModal').classList.contains('open')) renderExcl(); saveSettings(); }); @@ -349,6 +573,7 @@ }); $('#numInput').addEventListener('change', saveSettings); $('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); }); + $('#balanceChk').addEventListener('change', saveSettings); $$('[data-num]').forEach(b => { b.addEventListener('click', () => { @@ -364,26 +589,115 @@ }); $('#btnDraw').addEventListener('click', doDraw); + + // 重置结果:清空本次结果 + 累计已抽取(历史记录保留) $('#btnReset').addEventListener('click', () => { state.picked = []; + state.history = new Set(); renderResult(); renderStats(); saveState(); const stage = $('#drawStage'); stage.className = 'draw-stage'; stage.textContent = '点击「🎲 开始抽取」'; - showAlert(''); + showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info'); }); - $('#btnClearHistory').addEventListener('click', () => { - state.history = new Set(); - state.picked = []; - renderResult(); + + // 清空历史(顶栏) + $('#btnClearLog').addEventListener('click', () => { + if (!state.log.length) return showAlert('暂无历史记录'); + if (!confirm('确定清空全部 ' + state.log.length + ' 条历史记录?此操作不可恢复。')) return; + state.log = []; + saveLog(); + renderLog(); renderStats(); - saveState(); - showAlert('已清空累计抽取记录', 'info'); + showAlert('已清空历史记录', 'info'); }); + $('#btnDlImport').addEventListener('click', () => download('import')); $('#btnDlList').addEventListener('click', () => download('list')); + + /* --- 结果行内:排除并移除 --- */ + $('#drawBody').addEventListener('click', e => { + const btn = e.target.closest('[data-drop]'); + if (!btn) return; + const i = parseInt(btn.dataset.drop, 10); + if (isNaN(i) || !state.picked[i]) return; + const m = state.picked[i]; + state.excluded.add(String(m.sid)); + state.picked.splice(i, 1); + saveExcluded(); + saveState(); + renderResult(); + renderStats(); + showAlert('已将「' + (m.name || m.sid) + '」加入排除名单,可点「🚫 排除管理」撤销', 'info'); + }); + + /* --- 排除管理弹窗 --- */ + $('#btnExclude').addEventListener('click', () => { + exclQuery = ''; + $('#exclSearch').value = ''; + renderExcl(); + renderStats(); + $('#exclModal').classList.add('open'); + }); + $('#exclSearch').addEventListener('input', () => { + exclQuery = $('#exclSearch').value.trim().toLowerCase(); + renderExcl(); + }); + $('#exclBody').addEventListener('change', e => { + const cb = e.target.closest('[data-excl]'); + if (!cb) return; + if (cb.checked) state.excluded.add(String(cb.dataset.excl)); + else state.excluded.delete(String(cb.dataset.excl)); + saveExcluded(); + renderStats(); + }); + $('#btnClearExcl').addEventListener('click', () => { + if (!state.excluded.size) return; + state.excluded = new Set(); + saveExcluded(); + renderExcl(); + renderStats(); + }); + + /* --- 历史记录弹窗 --- */ + $('#btnLog').addEventListener('click', () => { + renderLog(); + $('#logModal').classList.add('open'); + }); + $('#logList').addEventListener('click', e => { + const rst = e.target.closest('[data-log-restore]'); + const del = e.target.closest('[data-log-del]'); + if (rst) { + const i = parseInt(rst.dataset.logRestore, 10); + const entry = state.log[i]; + if (!entry) return; + state.picked = (entry.members || []).map(m => ({ + sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '', + })); + renderResult(); + renderStats(); + saveState(); + showAlert('已恢复该批次(' + state.picked.length + ' 人),可直接下载白名单', 'info'); + } + if (del) { + const i = parseInt(del.dataset.logDel, 10); + if (isNaN(i) || !state.log[i]) return; + if (!confirm('删除这条历史记录?')) return; + state.log.splice(i, 1); + saveLog(); + renderLog(); + renderStats(); + } + }); + + /* --- 弹窗通用关闭 --- */ + document.addEventListener('click', e => { + if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) { + $$('.modal').forEach(m => m.classList.remove('open')); + } + }); } /* ---------------- 启动 ---------------- */ @@ -391,6 +705,7 @@ loadState(); const s = loadSettings(); if (s.exclude === false) $('#excludePicked').checked = false; + if (s.balance === false) $('#balanceChk').checked = false; if (s.num) $('#numInput').value = s.num; if (s.source === 'project') { state.source = 'project'; @@ -400,6 +715,7 @@ bind(); renderResult(); + renderStats(); try { await loadRoster(); diff --git a/assets/style.css b/assets/style.css index e40e970..c67e020 100644 --- a/assets/style.css +++ b/assets/style.css @@ -618,3 +618,40 @@ body.dash.fs-on .member-list { max-height: 30vh; } @media (prefers-reduced-motion: reduce) { .draw-stage.rolling { animation: none; } } + +/* ================= side 布局:两 panel 等高(缺考名单不再留大片空白) ================= */ +/* 宽屏 side 模式:panel 变纵向 flex,表格区撑满剩余高度(行高由内容多的一侧决定) */ +@media (min-width: 901px) { + .layout[data-layout="side"] > #tablePanel, + .layout[data-layout="side"] > #absentPanel { + display: flex; flex-direction: column; + } + .layout[data-layout="side"] > #tablePanel .table-scroll, + .layout[data-layout="side"] > #absentPanel .table-scroll { + flex: 1 1 auto; min-height: 0; max-height: none; + } +} + +/* ================= 随机抽取:历史记录 ================= */ +.log-list { display: flex; flex-direction: column; gap: 10px; } +.log-item { + border: 1px solid var(--border-2); border-radius: 10px; + background: #fbfcff; overflow: hidden; +} +.log-head { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 10px 14px; flex-wrap: wrap; +} +.log-summary { font-size: 13px; font-weight: 600; color: var(--text); } +.log-summary small { color: var(--text-3); font-weight: 500; } +.log-actions { display: flex; gap: 8px; flex: none; } +.log-detail summary { + cursor: pointer; user-select: none; padding: 7px 14px; + font-size: 12px; color: var(--primary); border-top: 1px dashed var(--border-2); + list-style: none; +} +.log-detail summary::-webkit-details-marker { display: none; } +.log-detail summary::before { content: '▸ '; } +.log-detail[open] summary::before { content: '▾ '; } +.log-detail[open] summary { border-bottom: 1px solid var(--border-2); } +.log-detail .table-scroll { max-height: 260px; border-radius: 0; } diff --git a/draw.php b/draw.php index 9f8e4f1..dfa6d1c 100644 --- a/draw.php +++ b/draw.php @@ -1,7 +1,8 @@
名单管理 - + ← 返回主系统 退出
@@ -76,9 +77,11 @@ $rosterCount = Roster::count();
- + + + - +
@@ -88,6 +91,7 @@ $rosterCount = Roster::count();
候选人数-
本次抽取-
累计已抽取-
+
已排除-
抽取来源学号名单
@@ -95,6 +99,7 @@ $rosterCount = Roster::count();

抽取结果

+
@@ -107,19 +112,58 @@ $rosterCount = Roster::count(); 序号 - 学号 - 姓名 + 学号 + 姓名 班级 组别 + 操作 - 尚未抽取 + 尚未抽取
-

两个下载均为两列「姓名 / 登陆账号」(登陆账号取名单中的学号,按文本格式保存,长学号不会变成科学计数法),可直接导入卷王白名单。

+

两个下载均为两列「姓名 / 登陆账号」,登陆账号取名单中导入的「登陆账号」列(缺失时回退学号),按文本格式保存,可直接导入卷王白名单。

+ + + + + + diff --git a/lib/Db.php b/lib/Db.php index 3296a8b..8fcf1f8 100644 --- a/lib/Db.php +++ b/lib/Db.php @@ -164,26 +164,57 @@ class Db } $pdo->exec($sql); $n++; - // roster —— 名单表(学号/姓名/班级/组别) + // roster —— 名单表(学号/姓名/班级/组别/登陆账号) if ($driver === 'sqlite') { $sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' . 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' . - 'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32))'; + 'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))'; } elseif ($driver === 'pgsql') { $sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' . 'id SERIAL PRIMARY KEY, ' . - 'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32))'; + 'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))'; } else { $sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' . 'id INT AUTO_INCREMENT PRIMARY KEY, ' . - 'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32)) ' . + 'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64)) ' . 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; } $pdo->exec($sql); $n++; + // 旧版本升级:补 login 列 + self::ensureRosterLogin(); + return $n; } + /** 旧表升级:ny_roster 缺 login 列时自动补(三方言兼容) */ + public static function ensureRosterLogin(): void + { + $pdo = self::pdo(); + if ($pdo === null) { + return; + } + $has = false; + if (self::$driver === 'sqlite') { + foreach ($pdo->query('PRAGMA table_info(ny_roster)')->fetchAll() as $c) { + if (strtolower((string)$c['name']) === 'login') { + $has = true; + break; + } + } + } elseif (self::$driver === 'pgsql') { + $st = $pdo->prepare("SELECT COUNT(*) FROM information_schema.columns " . + "WHERE table_name = 'ny_roster' AND column_name = 'login'"); + $st->execute(); + $has = (int)$st->fetchColumn() > 0; + } else { + $has = $pdo->query("SHOW COLUMNS FROM ny_roster LIKE 'login'")->fetch() !== false; + } + if (!$has) { + $pdo->exec('ALTER TABLE ny_roster ADD COLUMN login VARCHAR(64)'); + } + } + /** * 把所有 settings 读成数组 */ @@ -227,14 +258,16 @@ class Db if ($pdo === null) { return []; } - $stmt = $pdo->query('SELECT sid, name, cls, grp FROM ny_roster ORDER BY id'); + self::ensureRosterLogin(); + $stmt = $pdo->query('SELECT sid, name, cls, grp, login FROM ny_roster ORDER BY id'); $rows = []; foreach ($stmt->fetchAll() as $r) { $rows[] = [ - 'sid' => (string)$r['sid'], - 'name' => (string)$r['name'], - 'cls' => (string)$r['cls'], - 'grp' => (string)$r['grp'], + 'sid' => (string)$r['sid'], + 'name' => (string)$r['name'], + 'cls' => (string)$r['cls'], + 'grp' => (string)$r['grp'], + 'login' => (string)($r['login'] ?? ''), ]; } return $rows; @@ -247,16 +280,18 @@ class Db if ($pdo === null) { return; } + self::ensureRosterLogin(); $pdo->beginTransaction(); try { $pdo->exec('DELETE FROM ny_roster'); - $st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp) VALUES(?, ?, ?, ?)'); + $st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp, login) VALUES(?, ?, ?, ?, ?)'); foreach ($students as $s) { $st->execute([ (string)($s['sid'] ?? ''), (string)($s['name'] ?? ''), (string)($s['cls'] ?? ''), (string)($s['grp'] ?? ''), + (string)($s['login'] ?? ''), ]); } $pdo->commit(); diff --git a/lib/Roster.php b/lib/Roster.php index 2fef0d6..ad3baa8 100644 --- a/lib/Roster.php +++ b/lib/Roster.php @@ -35,10 +35,11 @@ class Roster continue; } $out[] = [ - 'sid' => trim((string)($s['sid'] ?? '')), - 'name' => trim((string)($s['name'] ?? '')), - 'cls' => trim((string)($s['cls'] ?? '')), - 'grp' => trim((string)($s['grp'] ?? '')), + 'sid' => trim((string)($s['sid'] ?? '')), + 'name' => trim((string)($s['name'] ?? '')), + 'cls' => trim((string)($s['cls'] ?? '')), + 'grp' => trim((string)($s['grp'] ?? '')), + 'login' => trim((string)($s['login'] ?? '')), ]; } return $out; @@ -63,10 +64,11 @@ class Roster continue; } $clean[] = [ - 'sid' => $sid, - 'name' => $name, - 'cls' => trim((string)($s['cls'] ?? '')), - 'grp' => trim((string)($s['grp'] ?? '')), + 'sid' => $sid, + 'name' => $name, + 'cls' => trim((string)($s['cls'] ?? '')), + 'grp' => trim((string)($s['grp'] ?? '')), + 'login' => trim((string)($s['login'] ?? '')), ]; } if (self::useDb()) { @@ -191,15 +193,17 @@ class Roster } /** - * 识别 学号/姓名/班级 列 - * @return array + * 识别 学号/姓名/班级/组别/登陆账号 列 + * 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示 + * @return array */ public static function rowsToStudents(array $rows): array { - $sidCol = -1; - $nameCol = -1; - $clsCol = -1; - $grpCol = -1; + $sidCol = -1; + $nameCol = -1; + $clsCol = -1; + $grpCol = -1; + $loginCol = -1; $headerIdx = -1; // 找表头 @@ -228,6 +232,9 @@ class Roster if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|^\s*组\s*$|group/i', $c)) { $grpCol = $j; } + if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) { + $loginCol = $j; + } } } if ($sidCol < 0) { @@ -249,7 +256,10 @@ class Roster $sid = preg_replace('/\.0+$/', '', $sid) ?? $sid; $cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : ''; $grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : ''; - $out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp]; + $login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : ''; + // 登陆账号同样去掉 Excel 数字尾巴(手机号被读成 13912687810.0) + $login = preg_replace('/\.0+$/', '', $login) ?? $login; + $out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login]; } return $out; } diff --git a/tools/test-draw-alloc.js b/tools/test-draw-alloc.js new file mode 100644 index 0000000..c0ae9e7 --- /dev/null +++ b/tools/test-draw-alloc.js @@ -0,0 +1,99 @@ +// 测试随机抽取页的均衡分配算法(通过 stub 环境加载 assets/draw.js) +// 运行:node tools/test-draw-alloc.js +const fs = require('fs'); +const path = require('path'); + +// stub 浏览器环境:draw.js 顶部只定义函数,尾部 document.addEventListener +global.window = {}; +global.document = { addEventListener() {} }; +global.localStorage = { getItem: () => null, setItem: () => {} }; +global.location = {}; + +eval(fs.readFileSync(path.join(__dirname, '..', 'assets', 'draw.js'), 'utf8')); +const T = global.window.__nyDrawTest; +if (!T) { console.error('FAIL 未找到 __nyDrawTest 测试钩子'); 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 : '')); } +} + +/* ---------- allocQuotas:名额均分(5:5 语义) ---------- */ +// A/B = 12/8 抽 10 → 各 5(名额均分到组,与组人数无关) +chk('12/8 抽10 → 5/5', JSON.stringify(T.allocQuotas([12, 8], 10)) === '[5,5]', + JSON.stringify(T.allocQuotas([12, 8], 10))); +// 抽 1 人:余数小组优先 +chk('抽1人 → 小组优先', JSON.stringify(T.allocQuotas([12, 8], 1)) === '[0,1]', + JSON.stringify(T.allocQuotas([12, 8], 1))); +// 12/8 抽 3 → 均分 1/1 + 余数给小组 → 1/2 +chk('12/8 抽3 → 1/2', JSON.stringify(T.allocQuotas([12, 8], 3)) === '[1,2]', + JSON.stringify(T.allocQuotas([12, 8], 3))); +// 组容量不足:A 只有 1 人,B 有 19 人,抽 10 → A 1 + B 9 +chk('容量不足差额再分配', JSON.stringify(T.allocQuotas([1, 19], 10)) === '[1,9]', + JSON.stringify(T.allocQuotas([1, 19], 10))); +// 全抽 → 每组全量 +chk('全部抽取 = 各组全量', JSON.stringify(T.allocQuotas([12, 8], 20)) === '[12,8]', + JSON.stringify(T.allocQuotas([12, 8], 20))); +// n 超过总人数 → 全量 +chk('超额抽取取全量', (() => { + const q = T.allocQuotas([3, 4], 99); + return JSON.stringify(q) === '[3,4]'; +})(), JSON.stringify(T.allocQuotas([3, 4], 99))); +// 5:5 经典:A 20 / B 20 抽 10 → 5/5 +chk('20/20 抽10 → 5/5', JSON.stringify(T.allocQuotas([20, 20], 10)) === '[5,5]', + JSON.stringify(T.allocQuotas([20, 20], 10))); +// 三组 10/10/10 抽 8 → 3/3/2(余数并列时稳定分配) +chk('三组 10/10/10 抽8', (() => { + const q = T.allocQuotas([10, 10, 10], 8); + return q.reduce((a, b) => a + b, 0) === 8 && Math.max(...q) - Math.min(...q) <= 1; +})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8))); +// 空组 / 0 +chk('空数组返回空', JSON.stringify(T.allocQuotas([], 5)) === '[]'); +// 名额总数守恒 + 不超容量 + 均衡(随机 fuzz) +let fuzzOk = true; +let fuzzBad = ''; +for (let t = 0; t < 1000; t++) { + const g = 1 + Math.floor(Math.random() * 5); + const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30)); + const n = Math.floor(Math.random() * 60); + const q = T.allocQuotas(sizes, n); + const sum = q.reduce((a, b) => a + b, 0); + const total = sizes.reduce((a, b) => a + b, 0); + const expect = Math.min(n, total); + if (sum !== expect) { fuzzOk = false; fuzzBad = `sum=${sum} expect=${expect} sizes=${sizes} n=${n}`; break; } + let bad = false; + for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true; + // 均衡性:所有组均未满时,任意两组名额差 ≤ 1 + const allNotFull = q.every((v, i) => v < sizes[i]); + 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); + +/* ---------- balanceKeyOf ---------- */ +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: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls'); +chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === ''); + +/* ---------- pickBalanced ---------- */ +{ + 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 <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' }); + const r = T.pickBalanced(pool, 10, 'grp'); + const byGrp = { A: 0, B: 0 }; + 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('detail 文案', r.detail.join('/') === 'A 5人/B 5人', r.detail.join('/')); + chk('结果不重复', new Set(r.picked.map(m => m.sid)).size === 10); +} +{ + const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }]; + const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp'); + chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid))); +} + +console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败'); +process.exit(fail === 0 ? 0 : 1);