Files
ny-exam-platform/assets/draw.js
T
nxxy335 6def49b969 fix: 全量提示改为顶部 toast 飘窗 + 修复卷王 token 失效跳转死循环 + 调整行点击防御
- 所有 showAlert 改为 showToast 顶部飘窗(首屏 / 抽取页统一,含空消息守卫)
- 导出排序新增 成绩升序 / 成绩降序 两种(前后端 + 设置弹窗都支持)
- 设置保存:仅连接信息真变化才 reload projects,避免非连接改动导致持续刷新失败
- 强化历史成绩弹窗排序与兜底(缺 submit_ts 时按 idx 倒序)
- 行点击事件加防御:要求父节点是 #tbody,避免被无关 tr 误触发
- 卷王 token 失效时:先 fetch logout 清会话再跳 login.php,登录页也加 token 探测兜底,根治"跳不过去"死循环
- 移除 index.php / draw.php 中已无用的内联 #alert DOM
- 兼容 config_get 缺 export_sort 时回退 time 的 4 模式归一化
2026-09-10 12:30:00 +08:00

949 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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: [], // 历史批次,新的在前
customRatios: {}, // 自定义比例:{ 组名: 权重 }
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;
}
/* ---------------- Toast 顶部飘窗通知(与 app.js 一致) ---------------- */
const TOAST_DURATION = 3500;
const TOAST_LIMIT = 5;
function showToast(msg, type) {
if (msg === '' || msg == null) return;
const el = document.getElementById('toastContainer') || (function () {
const c = document.createElement('div');
c.id = 'toastContainer';
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
document.body.appendChild(c);
return c;
})();
while (el.children.length >= TOAST_LIMIT) el.firstChild.remove();
const t = document.createElement('div');
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
t.className = 'toast ' + cls;
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
el.appendChild(t);
t.addEventListener('click', () => { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
setTimeout(() => { if (t.parentNode) { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); } }, TOAST_DURATION);
}
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
function showAlert(msg, type) {
if (msg === '' || msg == null) return;
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
showToast(msg, t);
}
/* ================= 抽取核心(纯函数,供测试) ================= */
/** 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;
}
/**
* 名额分配:
* - sizes 各组容量
* - n 要抽的总人数
* - weights 各组权重(null=均分;数组=按权重,权重<=0 视为 0)
* 算法:最大余额法(largest remainder)—— raw_i = n * w_i / Σw;floor 后把剩余名额
* 按小数部分降序分给还没满的组。均分模式是 weights=null 的特例(每组权重 1),
* 余数时按"组容量小者优先"补齐,保证每组都有代表。
*/
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(); // 全量
const base = new Array(g).fill(0);
const frac = new Array(g).fill(0);
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 (base[i] > sizes[i]) { overflow += base[i] - sizes[i]; base[i] = sizes[i]; }
}
while (overflow > 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 (overflow <= 0) break;
if (base[i] < sizes[i]) { base[i]++; overflow--; progress = true; }
}
if (!progress) break;
}
return base;
}
/**
* 选择均衡维度:组别(>=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, sizes, weights }
* weights 数组为各组实际权重(默认加权时=各组人数)
* detail 例:["A 6人", "B 4人"],用于提示与历史记录
*/
function pickBalanced(pool, n, key, weights) {
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 w = (Array.isArray(weights) && weights.length === gk.length)
? weights
: null;
const quotas = allocQuotas(sizes, n, w);
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, sizes, weights: w || sizes.slice() };
}
// 测试钩子(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 = '<option value="">(无可用的问卷 / 考试)</option>';
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 '<option value="' + esc(p.id) + '">' + esc(label) + '</option>';
}).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 = '<option value="">' + esc(allLabel) + '</option>' +
values.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').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 + ' 人';
}
/**
* 计算当前候选池的分组信息:{ 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;
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 = [];
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);
}
}
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, mode);
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 = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
return;
}
tb.innerHTML = state.picked.map((m, i) =>
'<tr>' +
'<td class="num">' + (i + 1) + '</td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td class="name">' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
'</tr>').join('');
}
/* ================= 历史记录 ================= */
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: (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 || '',
})),
});
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 = '<p class="empty" style="text-align:center;padding:30px 0">暂无历史记录</p>';
return;
}
box.innerHTML = state.log.map((e, i) => {
const rows = (e.members || []).map((m, j) =>
'<tr>' +
'<td class="num">' + (j + 1) + '</td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td>' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'</tr>').join('');
const meta = [e.ts, e.source, '范围:' + e.range, e.count + ' 人'];
if (e.balance) meta.push('均衡:' + e.balance);
return '<div class="log-item">' +
'<div class="log-head">' +
'<label class="log-summary"><span>' + esc(meta.join(' · ')) + '</span></label>' +
'<span class="log-actions">' +
'<button class="btn sm" data-log-restore="' + i + '">恢复结果</button>' +
'<button class="btn sm danger" data-log-del="' + i + '">删除</button>' +
'</span></div>' +
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
'<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th style="width:56px">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th>班级</th><th>组别</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table></div>' +
'</details>' +
'</div>';
}).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 = '<tr><td colspan="5" class="empty">' +
(baseList().length ? '没有匹配的人员' : '当前来源暂无人员') + '</td></tr>';
return;
}
tb.innerHTML = list.map(m => {
const on = state.excluded.has(String(m.sid)) ? ' checked' : '';
return '<tr>' +
'<td style="text-align:center"><input type="checkbox" data-excl="' + esc(m.sid) + '"' + on + '></td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td>' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'</tr>';
}).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 {
// 收集自定义比例输入
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,
cls: $('#clsSel').value,
grp: $('#grpSel').value,
num: $('#numInput').value,
exclude: $('#excludePicked').checked,
mode: $('#balanceMode').value,
customRatios: cr,
}));
} 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();
renderRatioBox();
if ($('#exclModal').classList.contains('open')) renderExcl();
saveSettings();
});
$('#projectSel').addEventListener('change', async () => {
const pid = $('#projectSel').value;
if (!pid) { state.answers = []; populateFilters(); renderStats(); renderRatioBox(); return; }
showAlert('正在拉取已提交人员…', 'info');
try {
await loadAnswers(pid);
showAlert('');
} catch (e) {
showAlert(e.message);
}
populateFilters();
renderStats();
renderRatioBox();
if ($('#exclModal').classList.contains('open')) renderExcl();
saveSettings();
});
['#clsSel', '#grpSel'].forEach(id => {
$(id).addEventListener('change', () => {
renderStats(); renderRatioBox(); saveSettings();
});
});
$('#numInput').addEventListener('change', saveSettings);
$('#excludePicked').addEventListener('change', () => { renderStats(); 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', () => {
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', () => {
if (state.picked.length || state.history.size) {
if (!confirm('确定重置?将清空本次结果与累计已抽取的 ' + state.history.size + ' 人(历史记录仍保留)。')) return;
}
state.picked = [];
state.history = new Set();
renderResult();
renderStats();
saveState();
const stage = $('#drawStage');
stage.className = 'draw-stage';
stage.textContent = '点击「🎲 开始抽取」';
showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info');
});
// 复制名单:两列「姓名 / 登陆账号」,制表符分隔,便于直接粘贴到 Excel / 微信
$('#btnCopy').addEventListener('click', async () => {
if (!state.picked.length) {
showAlert('请先点击「🎲 开始抽取」再复制');
return;
}
const lines = state.picked.map(m =>
(m.name || '') + '\t' + (m.login || m.sid || ''));
const text = lines.join('\n');
let ok = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch (e) { /* 降级到 textarea */ }
if (!ok) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
ok = document.execCommand('copy');
ta.remove();
} catch (e) { ok = false; }
}
showAlert(ok ? ('已复制 ' + state.picked.length + ' 人的姓名 / 登陆账号到剪贴板') : '复制失败,请手动选择结果复制', ok ? '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'));
}
});
}
/**
* 渲染"自定义比例"输入框:按当前候选池的分组(组别/班级)列出,每个组一个数字输入框,
* 默认按各组人数预填(即等价"按人数加权"),用户可改。
*/
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 =
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
g.gk.map((k, i) => {
const def = (state.customRatios && Number.isFinite(parseFloat(state.customRatios[k])))
? state.customRatios[k]
: g.sizes[i];
return '<div class="row" style="display:flex;gap:8px;align-items:center;margin:4px 0">' +
'<label style="min-width:120px">' + esc(k) + ' <small style="color:var(--text-3)">(' + g.sizes[i] + '人)</small></label>' +
'<input type="number" min="0" step="0.1" data-gk="' + esc(k) + '" value="' + esc(def) + '" placeholder="' + g.sizes[i] + '" style="width:120px">' +
'</div>';
}).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;
// 兼容旧版: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();
} 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();
});
})();