/*! * NY 问卷考试系统数据平台 - 随机抽取页逻辑 */ (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_SETTINGS = 'ny_draw_settings'; // 来源 / 项目 / 班级 / 组别 / 人数 const state = { roster: [], projects: [], answers: [], source: 'roster', picked: [], history: new Set(), 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' : ''); } /* ---------------- 数据加载 ---------------- */ 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 || ''), })); 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 }); 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 || ''), })); } /* ---------------- 候选池 ---------------- */ 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); 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 renderStats() { const pool = currentPool(); $('#dPool').textContent = pool.length; $('#dPicked').textContent = state.picked.length; $('#dHistory').textContent = state.history.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; } 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); } const 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(); saveState(); state.drawing = false; btn.disabled = false; showAlert('已抽取 ' + n + ' 人' + (n > 1 ? ',可点击右侧按钮下载白名单' : ''), 'info'); } function renderResult() { const tb = $('#drawBody'); if (!state.picked.length) { tb.innerHTML = '