- 名单导入容错:rowsToStudents 加去重/跳过统计、表头正则扩充、.0 尾巴清理;importFile 返回精确 count
- 看板:修复 .repeat(0) 空状态坏代码;新增实时点 stale 指示(手动/自动刷新失败变红 + 提示刷新中断);
手动刷新按钮加刷新中…反馈;记住自动刷新开关与间隔(localStorage + ?interval= 深链)
- 主统计页:后台静默刷新连续失败时连接指示转实时刷新中断告警;记住用户手动调整的刷新间隔
- 设置页:新增 config_test 端点 + 独立测试连接按钮(用弹窗内未保存凭据测连接,不落盘);
保存按钮加保存中…防重提交;登录失败不清旧 token
- 抽取页:重置结果加二次确认;新增复制名单按钮(两列制表符分隔,可粘到微信/Excel)
- 修复看板预先存在 JS 语法错误(少一个右括号导致整页 JS 失效)
928 lines
33 KiB
JavaScript
928 lines
33 KiB
JavaScript
/*!
|
||
* 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: [], // 历史批次,新的在前
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 名额分配:
|
||
* - 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();
|
||
});
|
||
})();
|