/*! * NY 问卷考试系统数据平台 - 随机抽取页逻辑 * * 功能:来源切换 / 班级组别筛选 / 分组均衡抽取(5:5) / 排除已抽中 / * 排除名单管理 / 历史记录(重置不丢失) / 白名单下载(登陆账号优先) */ (function () { 'use strict'; const $ = s => document.querySelector(s); const $$ = s => Array.prototype.slice.call(document.querySelectorAll(s)); function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } const sleep = ms => new Promise(r => setTimeout(r, ms)); 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: [], projects: [], answers: [], source: 'roster', picked: [], history: new Set(), // 累计抽中(重置结果时清零) excluded: new Set(), // 手动排除 log: [], // 历史批次,新的在前 drawing: false, }; /* ---------------- 接口 ---------------- */ async function api(action, params) { const q = new URLSearchParams(Object.assign({ action: action }, params || {})); const r = await fetch('api.php?' + q.toString(), { credentials: 'same-origin' }); let j; try { j = await r.json(); } catch (e) { throw new Error('接口返回异常(HTTP ' + r.status + ')'); } if (r.status === 401 || (j && j.ok === false && /未登录/.test(j.msg || ''))) { location.href = 'login.php'; throw new Error('未登录'); } if (!j || !j.ok) { throw new Error((j && j.msg) || '请求失败'); } return j.data; } function showAlert(msg, type) { const el = $('#alert'); if (!el) return; if (!msg) { el.classList.add('hidden'); return; } el.textContent = msg; 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 || ''), login: String(s.login || ''), })); const badge = $('#rosterBadge'); if (badge) badge.textContent = state.roster.length; } async function loadProjects() { try { state.projects = (await api('projects')) || []; } catch (e) { state.projects = []; } const sel = $('#projectSel'); if (!sel) return; const isFolder = p => (p.mode || '').toLowerCase().indexOf('folder') >= 0; const parentIds = new Set(); state.projects.forEach(p => { const pid = String(p.parent_id || '0'); if (pid !== '0' && pid !== '') parentIds.add(pid); }); const folderSet = new Set(); state.projects.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); }); const nameOf = id => { const f = state.projects.filter(p => p.id === id)[0]; return f ? String(f.name || '') : ''; }; const items = state.projects.filter(p => !folderSet.has(p.id)); if (!items.length) { sel.innerHTML = ''; return; } sel.innerHTML = items.map(p => { const pid = String(p.parent_id || '0'); const fn = (pid !== '0' && pid !== '' && folderSet.has(pid)) ? nameOf(pid) : ''; const label = fn ? fn + ' / ' + p.name : p.name; return ''; }).join(''); } 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 || ''), login: String(loginOf[r.sid] || ''), })); } /* ================= 候选池 ================= */ function baseList() { return state.source === 'project' ? state.answers : state.roster; } function currentPool() { let list = baseList().slice(); const cls = $('#clsSel').value; 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))); } return list; } function uniqSorted(arr) { const seen = {}; const out = []; arr.forEach(v => { if (v && !seen[v]) { seen[v] = 1; out.push(v); } }); out.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true })); return out; } function fillSel(sel, values, allLabel) { if (!sel) return; const cur = sel.value; sel.innerHTML = '' + values.map(v => '').join(''); sel.value = (values.indexOf(cur) >= 0) ? cur : ''; } function populateFilters() { const list = baseList(); fillSel($('#clsSel'), uniqSorted(list.map(x => x.cls || '')), '全部班级'); 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' ? '已提交人员' : '学号名单'; $('#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('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)'); return; } let n = parseInt($('#numInput').value, 10); if (!n || n < 1) n = 1; if (n > pool.length) n = pool.length; state.drawing = true; const stage = $('#drawStage'); const btn = $('#btnDraw'); btn.disabled = true; showAlert(''); // 滚动动画:先快速闪动若干次 stage.className = 'draw-stage rolling'; const rounds = Math.min(24, 10 + n); for (let i = 0; i < rounds; i++) { const p = pool[Math.floor(Math.random() * pool.length)]; stage.textContent = p.name || p.sid || '—'; await sleep(40 + i * 7); } // 真正抽取:均衡(组别优先,其次班级)或整池随机 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))); stage.className = 'draw-stage done'; stage.textContent = n === 1 ? (picked[0].name || picked[0].sid) : ('🎉 已抽取 ' + n + ' 人'); renderResult(); renderStats(); pushLog(picked, detail); saveState(); state.drawing = false; btn.disabled = false; const tail = detail.length ? '(' + detail.join(' / ') + ')' : ''; showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info'); } function renderResult() { const tb = $('#drawBody'); if (!state.picked.length) { tb.innerHTML = '尚未抽取'; return; } tb.innerHTML = state.picked.map((m, i) => '' + '' + (i + 1) + '' + '' + esc(m.sid) + '' + '' + 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('请先点击「🎲 开始抽取」'); return; } const fname = type === 'import' ? '白名单导入模板.xlsx' : '白名单列表.xlsx'; try { const r = await fetch('api.php?action=draw_export', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: type, members: dlMembers() }), }); if (!r.ok) throw new Error('HTTP ' + r.status); const blob = await r.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fname; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 3000); showAlert('已下载 ' + fname, 'info'); } catch (e) { showAlert('导出失败:' + e.message); } } /* ================= 本地缓存 ================= */ function saveState() { try { localStorage.setItem(LS_RESULT, JSON.stringify(state.picked)); localStorage.setItem(LS_HISTORY, JSON.stringify(Array.from(state.history))); } 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({ source: state.source, projectId: $('#projectSel').value, cls: $('#clsSel').value, grp: $('#grpSel').value, num: $('#numInput').value, exclude: $('#excludePicked').checked, balance: $('#balanceChk').checked, })); } catch (e) { /* 忽略 */ } } function loadState() { 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() { try { const s = JSON.parse(localStorage.getItem(LS_SETTINGS) || '{}'); if (s && typeof s === 'object') return s; } catch (e) { /* 忽略 */ } return {}; } /* ================= 事件 ================= */ function bind() { $('#sourceSel').addEventListener('change', async () => { state.source = $('#sourceSel').value; $('#projectField').classList.toggle('hidden', state.source !== 'project'); state.answers = []; state.picked = []; renderResult(); if (state.source === 'project') { if (!state.projects.length) await loadProjects(); const pid = $('#projectSel').value; if (pid) { try { await loadAnswers(pid); } catch (e) { showAlert(e.message); } } } populateFilters(); renderStats(); if ($('#exclModal').classList.contains('open')) renderExcl(); saveSettings(); }); $('#projectSel').addEventListener('change', async () => { const pid = $('#projectSel').value; if (!pid) { state.answers = []; populateFilters(); renderStats(); return; } showAlert('正在拉取已提交人员…', 'info'); try { await loadAnswers(pid); showAlert(''); } catch (e) { showAlert(e.message); } populateFilters(); renderStats(); if ($('#exclModal').classList.contains('open')) renderExcl(); saveSettings(); }); ['#clsSel', '#grpSel'].forEach(id => { $(id).addEventListener('change', () => { renderStats(); saveSettings(); }); }); $('#numInput').addEventListener('change', saveSettings); $('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); }); $('#balanceChk').addEventListener('change', saveSettings); $$('[data-num]').forEach(b => { b.addEventListener('click', () => { const v = b.dataset.num; if (v === 'all') { const pool = currentPool(); $('#numInput').value = String(Math.max(1, pool.length)); } else { $('#numInput').value = v; } saveSettings(); }); }); $('#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('已重置结果与累计已抽取(历史记录仍保留)', 'info'); }); // 清空历史(顶栏) $('#btnClearLog').addEventListener('click', () => { if (!state.log.length) return showAlert('暂无历史记录'); if (!confirm('确定清空全部 ' + state.log.length + ' 条历史记录?此操作不可恢复。')) return; state.log = []; saveLog(); renderLog(); renderStats(); 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')); } }); } /* ---------------- 启动 ---------------- */ document.addEventListener('DOMContentLoaded', async function () { 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'; $('#sourceSel').value = 'project'; $('#projectField').classList.remove('hidden'); } bind(); renderResult(); renderStats(); try { await loadRoster(); } catch (e) { showAlert('名单加载失败:' + e.message); } if (state.source === 'project') { await loadProjects(); const pid = s.projectId || $('#projectSel').value; if (pid) { $('#projectSel').value = pid; try { await loadAnswers(pid); } catch (e) { /* 静默 */ } } } populateFilters(); if (s.cls) $('#clsSel').value = s.cls; if (s.grp) $('#grpSel').value = s.grp; renderStats(); }); })();