Files
nxxy335 94ac381579 完成批次 L-R:限流移除/白名单驱动/打印增强/全屏/表格对齐/主题变量/滚动闪烁修复
- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接

- 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey)

- 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加

- 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则

- 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致

- 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放

- 测试:新增 test-whitelist.js;run-all.sh 全绿 132
2026-09-13 10:25:48 +08:00

988 lines
37 KiB
JavaScript
Raw Permalink 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,
all: 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' : (type === 'warn' ? 'warn' : ''));
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();
// 「全部」模式:候选池变化(改筛选 / 改来源 / 改排除)时人数跟着变
if (state.all) {
const ni = $('#numInput');
if (ni) ni.value = String(Math.max(1, pool.length));
}
$('#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 = state.all ? pool.length : parseInt($('#numInput').value, 10);
if (!n || n < 1) n = 1;
let capped = false;
if (n > pool.length) { n = pool.length; capped = true; }
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';
if (n === 1 && picked.length) {
// 只抽 1 人时把学号 / 班级 / 组别一起显示出来,教师不用再去表里找
const p0 = picked[0];
const meta = [p0.sid, p0.cls, p0.grp ? (p0.grp + '组') : ''].filter(Boolean).join(' · ');
stage.innerHTML = '<div>' + esc(p0.name || p0.sid) +
(meta ? '<div class="draw-stage-sub">' + esc(meta) + '</div>' : '') + '</div>';
} else {
stage.textContent = '🎉 已抽取 ' + n + ' 人';
}
renderResult();
renderStats();
pushLog(picked, detail, mode);
saveState();
state.drawing = false;
btn.disabled = false;
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
if (capped) {
showAlert('候选池只有 ' + pool.length + ' 人,已按 ' + n + ' 人抽取(可关闭「排除已抽中」或调整筛选扩大候选)', 'warn');
} else {
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;
}
// 抽取结果表不显示登陆账号(仅导出白名单才含该列),故只渲染 5 个数据列 + 操作列
tb.innerHTML = state.picked.map((m, i) => {
return '<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 class="act"><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:8%;text-align:right">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th style="width:24%">班级</th><th style="width:14%">组别</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,
all: state.all,
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);
// 手工改人数即解除「全部」模式
$('#numInput').addEventListener('input', () => { state.all = false; });
$('#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') {
state.all = true;
$('#numInput').value = String(Math.max(1, currentPool().length));
} else {
state.all = false;
$('#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 => {
const isClose = (e.target.matches && e.target.matches('[data-close]')) ||
(e.target.classList && e.target.classList.contains('modal'));
if (!isClose) return;
const open = $$('.modal.open');
if (!open.length) return;
const top = open[open.length - 1];
// 点遮罩时只响应最上层弹窗自身的遮罩
if (e.target.classList && e.target.classList.contains('modal') && e.target !== top) return;
top.classList.remove('open');
});
/* Esc 关闭最上层弹窗 */
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
const open = $$('.modal.open');
if (open.length) open[open.length - 1].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);
// 隐藏时必须清空:否则旧的 data-gk 输入框还会被 saveSettings 读走,
// 下次切成自定义比例时会误用上一批分组的权重
if (!show) { box.innerHTML = ''; 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;
state.all = !!s.all;
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();
});
})();