/*!
* 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 + ')
' +
'' +
'' +
'
';
}).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();
});
})();