feat: 抽取页分组均衡5:5 / 重置清累计 / 历史记录 / 排除管理 / 登陆账号字段 / side等高
随机抽取页: - 分组均衡抽取:有组别(≥2个)时默认按名额均分到组(12/8抽10→5/5), 其次按班级均分,否则整池随机;可勾选关闭 - 名额均分算法 allocQuotas:floor 均分 + 余数小组优先补齐 + 超容量退回 + 差额给剩余容量大者 - 重置结果:同时清零累计已抽取数(历史记录仍保留) - 历史记录:每批次入 localStorage ny_draw_log(上限50), 重置不清,支持查看详情/恢复结果/删除/清空 - 抽取排除:排除管理弹窗(搜索/清空),结果行可"移出并加入排除" - 白名单列表下载:登陆账号优先,缺失回退学号 名单登陆账号: - 模板解析识别"登陆/登录账号/账号/手机号"列,手机号去 .0 尾巴 - Db ny_roster 增 login 列 + 旧表自动升级(三方言) - roster_save 透传 login;draw_export 使用 login - 登陆账号仅用于白名单列表下载,其余界面不展示 布局:side 模式下成绩明细/缺考名单 panel 改 flex 撑满, .table-scroll 取消 320px 限高,两侧等高不再留白 测试:tools/test-draw-alloc.js 18/18 通过(含 1000 组随机 fuzz)
This commit is contained in:
@@ -313,10 +313,11 @@ try {
|
||||
continue;
|
||||
}
|
||||
$students[] = [
|
||||
'sid' => (string)($s['sid'] ?? ''),
|
||||
'name' => (string)($s['name'] ?? ''),
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'sid' => (string)($s['sid'] ?? ''),
|
||||
'name' => (string)($s['name'] ?? ''),
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'login' => trim((string)($s['login'] ?? '')),
|
||||
];
|
||||
}
|
||||
Roster::save($students);
|
||||
@@ -429,12 +430,14 @@ try {
|
||||
if (!is_array($m)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string)($m['name'] ?? ''));
|
||||
$sid = trim((string)($m['sid'] ?? ''));
|
||||
if ($name === '' && $sid === '') {
|
||||
$name = trim((string)($m['name'] ?? ''));
|
||||
// 登陆账号优先(名单导入的"登陆账号"列),没有则回退学号
|
||||
$acct = trim((string)($m['login'] ?? ''));
|
||||
$sid = trim((string)($m['sid'] ?? ''));
|
||||
if ($name === '' && $acct === '' && $sid === '') {
|
||||
continue;
|
||||
}
|
||||
$members[] = [$name, $sid];
|
||||
$members[] = [$name, $acct !== '' ? $acct : $sid];
|
||||
}
|
||||
$bin = XlsxWriter::render(
|
||||
array_merge([['姓名', '登陆账号']], $members),
|
||||
|
||||
+371
-55
@@ -1,5 +1,8 @@
|
||||
/*!
|
||||
* NY 问卷考试系统数据平台 - 随机抽取页逻辑
|
||||
*
|
||||
* 功能:来源切换 / 班级组别筛选 / 分组均衡抽取(5:5) / 排除已抽中 /
|
||||
* 排除名单管理 / 历史记录(重置不丢失) / 白名单下载(登陆账号优先)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -13,9 +16,13 @@
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const LS_RESULT = 'ny_draw_result'; // 最近一次抽取结果(刷新后仍可下载)
|
||||
const LS_HISTORY = 'ny_draw_history'; // 累计抽中的学号(用于"排除已抽中")
|
||||
const LS_SETTINGS = 'ny_draw_settings'; // 来源 / 项目 / 班级 / 组别 / 人数
|
||||
const 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: [],
|
||||
@@ -23,7 +30,9 @@
|
||||
answers: [],
|
||||
source: 'roster',
|
||||
picked: [],
|
||||
history: new Set(),
|
||||
history: new Set(), // 累计抽中(重置结果时清零)
|
||||
excluded: new Set(), // 手动排除
|
||||
log: [], // 历史批次,新的在前
|
||||
drawing: false,
|
||||
};
|
||||
|
||||
@@ -55,14 +64,104 @@
|
||||
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 || ''),
|
||||
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;
|
||||
@@ -106,15 +205,18 @@
|
||||
|
||||
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 || ''),
|
||||
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;
|
||||
}
|
||||
@@ -125,6 +227,7 @@
|
||||
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)));
|
||||
}
|
||||
@@ -153,36 +256,34 @@
|
||||
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' ? '已提交人员' : '学号名单';
|
||||
|
||||
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
||||
const parts = [];
|
||||
if (cls) parts.push(cls);
|
||||
if (grp) parts.push(grp + '组');
|
||||
$('#drawHint').textContent = '候选 ' + pool.length + ' 人' +
|
||||
(parts.length ? '(' + parts.join(' / ') + ')' : '');
|
||||
}
|
||||
|
||||
/* ---------------- 抽取 ---------------- */
|
||||
function shuffle(arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const t = a[i]; a[i] = a[j]; a[j] = t;
|
||||
}
|
||||
return a;
|
||||
$('#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('当前筛选下没有可抽取的人员(可关闭「排除已抽中」或调整班级 / 组别)');
|
||||
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
|
||||
return;
|
||||
}
|
||||
let n = parseInt($('#numInput').value, 10);
|
||||
@@ -204,7 +305,20 @@
|
||||
await sleep(40 + i * 7);
|
||||
}
|
||||
|
||||
const picked = shuffle(pool).slice(0, n);
|
||||
// 真正抽取:均衡(组别优先,其次班级)或整池随机
|
||||
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)));
|
||||
|
||||
@@ -215,17 +329,19 @@
|
||||
|
||||
renderResult();
|
||||
renderStats();
|
||||
pushLog(picked, detail);
|
||||
saveState();
|
||||
|
||||
state.drawing = false;
|
||||
btn.disabled = false;
|
||||
showAlert('已抽取 ' + n + ' 人' + (n > 1 ? ',可点击右侧按钮下载白名单' : ''), 'info');
|
||||
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="5" class="empty">尚未抽取</td></tr>';
|
||||
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = state.picked.map((m, i) =>
|
||||
@@ -235,10 +351,100 @@
|
||||
'<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) {
|
||||
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 = '<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('请先点击「🎲 开始抽取」');
|
||||
@@ -250,10 +456,7 @@
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: type,
|
||||
members: state.picked.map(m => ({ name: m.name, sid: m.sid })),
|
||||
}),
|
||||
body: JSON.stringify({ type: type, members: dlMembers() }),
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const blob = await r.blob();
|
||||
@@ -271,7 +474,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 本地缓存 ---------------- */
|
||||
/* ================= 本地缓存 ================= */
|
||||
function saveState() {
|
||||
try {
|
||||
localStorage.setItem(LS_RESULT, JSON.stringify(state.picked));
|
||||
@@ -279,6 +482,20 @@
|
||||
} 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({
|
||||
@@ -288,17 +505,22 @@
|
||||
grp: $('#grpSel').value,
|
||||
num: $('#numInput').value,
|
||||
exclude: $('#excludePicked').checked,
|
||||
balance: $('#balanceChk').checked,
|
||||
}));
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem(LS_RESULT) || '[]');
|
||||
if (Array.isArray(p)) state.picked = p;
|
||||
const h = JSON.parse(localStorage.getItem(LS_HISTORY) || '[]');
|
||||
if (Array.isArray(h)) state.history = new Set(h.map(String));
|
||||
} catch (e) { /* 忽略 */ }
|
||||
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() {
|
||||
@@ -309,7 +531,7 @@
|
||||
return {};
|
||||
}
|
||||
|
||||
/* ---------------- 事件 ---------------- */
|
||||
/* ================= 事件 ================= */
|
||||
function bind() {
|
||||
$('#sourceSel').addEventListener('change', async () => {
|
||||
state.source = $('#sourceSel').value;
|
||||
@@ -326,6 +548,7 @@
|
||||
}
|
||||
populateFilters();
|
||||
renderStats();
|
||||
if ($('#exclModal').classList.contains('open')) renderExcl();
|
||||
saveSettings();
|
||||
});
|
||||
|
||||
@@ -341,6 +564,7 @@
|
||||
}
|
||||
populateFilters();
|
||||
renderStats();
|
||||
if ($('#exclModal').classList.contains('open')) renderExcl();
|
||||
saveSettings();
|
||||
});
|
||||
|
||||
@@ -349,6 +573,7 @@
|
||||
});
|
||||
$('#numInput').addEventListener('change', saveSettings);
|
||||
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
|
||||
$('#balanceChk').addEventListener('change', saveSettings);
|
||||
|
||||
$$('[data-num]').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
@@ -364,26 +589,115 @@
|
||||
});
|
||||
|
||||
$('#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('');
|
||||
showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info');
|
||||
});
|
||||
$('#btnClearHistory').addEventListener('click', () => {
|
||||
state.history = new Set();
|
||||
state.picked = [];
|
||||
renderResult();
|
||||
|
||||
// 清空历史(顶栏)
|
||||
$('#btnClearLog').addEventListener('click', () => {
|
||||
if (!state.log.length) return showAlert('暂无历史记录');
|
||||
if (!confirm('确定清空全部 ' + state.log.length + ' 条历史记录?此操作不可恢复。')) return;
|
||||
state.log = [];
|
||||
saveLog();
|
||||
renderLog();
|
||||
renderStats();
|
||||
saveState();
|
||||
showAlert('已清空累计抽取记录', 'info');
|
||||
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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 启动 ---------------- */
|
||||
@@ -391,6 +705,7 @@
|
||||
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';
|
||||
@@ -400,6 +715,7 @@
|
||||
|
||||
bind();
|
||||
renderResult();
|
||||
renderStats();
|
||||
|
||||
try {
|
||||
await loadRoster();
|
||||
|
||||
@@ -618,3 +618,40 @@ body.dash.fs-on .member-list { max-height: 30vh; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.draw-stage.rolling { animation: none; }
|
||||
}
|
||||
|
||||
/* ================= side 布局:两 panel 等高(缺考名单不再留大片空白) ================= */
|
||||
/* 宽屏 side 模式:panel 变纵向 flex,表格区撑满剩余高度(行高由内容多的一侧决定) */
|
||||
@media (min-width: 901px) {
|
||||
.layout[data-layout="side"] > #tablePanel,
|
||||
.layout[data-layout="side"] > #absentPanel {
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.layout[data-layout="side"] > #tablePanel .table-scroll,
|
||||
.layout[data-layout="side"] > #absentPanel .table-scroll {
|
||||
flex: 1 1 auto; min-height: 0; max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 随机抽取:历史记录 ================= */
|
||||
.log-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.log-item {
|
||||
border: 1px solid var(--border-2); border-radius: 10px;
|
||||
background: #fbfcff; overflow: hidden;
|
||||
}
|
||||
.log-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
padding: 10px 14px; flex-wrap: wrap;
|
||||
}
|
||||
.log-summary { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.log-summary small { color: var(--text-3); font-weight: 500; }
|
||||
.log-actions { display: flex; gap: 8px; flex: none; }
|
||||
.log-detail summary {
|
||||
cursor: pointer; user-select: none; padding: 7px 14px;
|
||||
font-size: 12px; color: var(--primary); border-top: 1px dashed var(--border-2);
|
||||
list-style: none;
|
||||
}
|
||||
.log-detail summary::-webkit-details-marker { display: none; }
|
||||
.log-detail summary::before { content: '▸ '; }
|
||||
.log-detail[open] summary::before { content: '▾ '; }
|
||||
.log-detail[open] summary { border-bottom: 1px solid var(--border-2); }
|
||||
.log-detail .table-scroll { max-height: 260px; border-radius: 0; }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
/**
|
||||
* NY 问卷考试系统数据平台 - 随机抽取
|
||||
* 从名单 / 已提交人员中随机抽取,导出为卷王白名单(两列:姓名 / 登陆账号)
|
||||
* 从名单 / 已提交人员中随机抽取(支持分组均衡 5:5、排除名单、历史记录),
|
||||
* 导出为卷王白名单(两列:姓名 / 登陆账号)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
@@ -37,7 +38,7 @@ $rosterCount = Roster::count();
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
|
||||
<button class="btn" id="btnClearHistory" title="清空已抽取记录,重新开始">清空记录</button>
|
||||
<button class="btn" id="btnClearLog" title="清空全部历史记录(重置结果不影响历史记录)">清空历史</button>
|
||||
<a class="btn" href="index.php">← 返回主系统</a>
|
||||
<a class="btn" href="logout.php" title="退出登录">退出</a>
|
||||
</div>
|
||||
@@ -76,9 +77,11 @@ $rosterCount = Roster::count();
|
||||
</div>
|
||||
</div>
|
||||
<div class="field btns">
|
||||
<label class="chk"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label>
|
||||
<label class="chk" title="有多个组别时按组别均衡抽取(如 A:B = 5:5),无组别时按班级均衡"><input type="checkbox" id="balanceChk" checked> 分组均衡</label>
|
||||
<label class="chk" title="再次抽取时不与本次会话已抽中的人重复;「重置结果」后清零"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label>
|
||||
<button class="btn" id="btnExclude" title="勾选不参与抽取的人">🚫 排除管理</button>
|
||||
<button class="btn btn-primary" id="btnDraw">🎲 开始抽取</button>
|
||||
<button class="btn" id="btnReset">重置结果</button>
|
||||
<button class="btn" id="btnReset" title="清空本次结果与累计已抽取(历史记录保留)">重置结果</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -88,6 +91,7 @@ $rosterCount = Roster::count();
|
||||
<div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div>
|
||||
<div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div>
|
||||
<div class="card warn accent-orange"><span>累计已抽取</span><b id="dHistory">-</b></div>
|
||||
<div class="card accent-red"><span>已排除</span><b id="dExcluded">-</b></div>
|
||||
<div class="card accent-gray"><span>抽取来源</span><b id="dSource">学号名单</b></div>
|
||||
</section>
|
||||
|
||||
@@ -95,6 +99,7 @@ $rosterCount = Roster::count();
|
||||
<div class="panel-head">
|
||||
<h2>抽取结果 <small id="drawHint"></small></h2>
|
||||
<div class="actions">
|
||||
<button class="btn" id="btnLog" title="查看历史抽取批次(重置结果后仍保留)">📜 历史 <b id="logBadge">0</b></button>
|
||||
<button class="btn" id="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button>
|
||||
<button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button>
|
||||
</div>
|
||||
@@ -107,19 +112,58 @@ $rosterCount = Roster::count();
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:70px">序号</th>
|
||||
<th style="width:30%">学号</th>
|
||||
<th style="width:24%">姓名</th>
|
||||
<th style="width:26%">学号</th>
|
||||
<th style="width:22%">姓名</th>
|
||||
<th>班级</th>
|
||||
<th>组别</th>
|
||||
<th style="width:90px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="drawBody"><tr><td colspan="5" class="empty">尚未抽取</td></tr></tbody>
|
||||
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">两个下载均为两列「<b>姓名</b> / <b>登陆账号</b>」(登陆账号取名单中的学号,按文本格式保存,长学号不会变成科学计数法),可直接导入卷王白名单。</p>
|
||||
<p class="hint">两个下载均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列(缺失时回退学号),按文本格式保存,可直接导入卷王白名单。</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ================= 排除管理 ================= -->
|
||||
<div class="modal" id="exclModal">
|
||||
<div class="modal-box wide">
|
||||
<div class="modal-head"><h3>抽取排除管理</h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="roster-bar">
|
||||
<input type="text" id="exclSearch" placeholder="搜索学号 / 姓名 / 班级">
|
||||
<span class="spacer"></span>
|
||||
<span class="hint" id="exclCount">已排除 0 人</span>
|
||||
<button class="btn sm danger" id="btnClearExcl">清空排除</button>
|
||||
</div>
|
||||
<div class="table-scroll mid">
|
||||
<table class="grid compact">
|
||||
<thead><tr><th style="width:64px">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th></tr></thead>
|
||||
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">勾选后这些人不再参与抽取(勾选即时生效);仅影响随机抽取,不影响其他功能。</p>
|
||||
</div>
|
||||
<div class="modal-foot"><span class="spacer"></span><button class="btn btn-primary" data-close>完成</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= 历史记录 ================= -->
|
||||
<div class="modal" id="logModal">
|
||||
<div class="modal-box wide">
|
||||
<div class="modal-head"><h3>抽取历史记录</h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body">
|
||||
<div id="logList" class="log-list"><p class="empty" style="text-align:center;padding:30px 0">暂无历史记录</p></div>
|
||||
<p class="hint">每次抽取自动记一笔;「重置结果」不会清除历史记录,可随时恢复某一批次重新下载。</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn btn-primary" data-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/draw.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+45
-10
@@ -164,26 +164,57 @@ class Db
|
||||
}
|
||||
$pdo->exec($sql); $n++;
|
||||
|
||||
// roster —— 名单表(学号/姓名/班级/组别)
|
||||
// roster —— 名单表(学号/姓名/班级/组别/登陆账号)
|
||||
if ($driver === 'sqlite') {
|
||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
|
||||
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' .
|
||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32))';
|
||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
|
||||
} elseif ($driver === 'pgsql') {
|
||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
|
||||
'id SERIAL PRIMARY KEY, ' .
|
||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32))';
|
||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
|
||||
} else {
|
||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
|
||||
'id INT AUTO_INCREMENT PRIMARY KEY, ' .
|
||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32)) ' .
|
||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64)) ' .
|
||||
'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
}
|
||||
$pdo->exec($sql); $n++;
|
||||
|
||||
// 旧版本升级:补 login 列
|
||||
self::ensureRosterLogin();
|
||||
|
||||
return $n;
|
||||
}
|
||||
|
||||
/** 旧表升级:ny_roster 缺 login 列时自动补(三方言兼容) */
|
||||
public static function ensureRosterLogin(): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if ($pdo === null) {
|
||||
return;
|
||||
}
|
||||
$has = false;
|
||||
if (self::$driver === 'sqlite') {
|
||||
foreach ($pdo->query('PRAGMA table_info(ny_roster)')->fetchAll() as $c) {
|
||||
if (strtolower((string)$c['name']) === 'login') {
|
||||
$has = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} elseif (self::$driver === 'pgsql') {
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM information_schema.columns " .
|
||||
"WHERE table_name = 'ny_roster' AND column_name = 'login'");
|
||||
$st->execute();
|
||||
$has = (int)$st->fetchColumn() > 0;
|
||||
} else {
|
||||
$has = $pdo->query("SHOW COLUMNS FROM ny_roster LIKE 'login'")->fetch() !== false;
|
||||
}
|
||||
if (!$has) {
|
||||
$pdo->exec('ALTER TABLE ny_roster ADD COLUMN login VARCHAR(64)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所有 settings 读成数组
|
||||
*/
|
||||
@@ -227,14 +258,16 @@ class Db
|
||||
if ($pdo === null) {
|
||||
return [];
|
||||
}
|
||||
$stmt = $pdo->query('SELECT sid, name, cls, grp FROM ny_roster ORDER BY id');
|
||||
self::ensureRosterLogin();
|
||||
$stmt = $pdo->query('SELECT sid, name, cls, grp, login FROM ny_roster ORDER BY id');
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$rows[] = [
|
||||
'sid' => (string)$r['sid'],
|
||||
'name' => (string)$r['name'],
|
||||
'cls' => (string)$r['cls'],
|
||||
'grp' => (string)$r['grp'],
|
||||
'sid' => (string)$r['sid'],
|
||||
'name' => (string)$r['name'],
|
||||
'cls' => (string)$r['cls'],
|
||||
'grp' => (string)$r['grp'],
|
||||
'login' => (string)($r['login'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
@@ -247,16 +280,18 @@ class Db
|
||||
if ($pdo === null) {
|
||||
return;
|
||||
}
|
||||
self::ensureRosterLogin();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->exec('DELETE FROM ny_roster');
|
||||
$st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp) VALUES(?, ?, ?, ?)');
|
||||
$st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp, login) VALUES(?, ?, ?, ?, ?)');
|
||||
foreach ($students as $s) {
|
||||
$st->execute([
|
||||
(string)($s['sid'] ?? ''),
|
||||
(string)($s['name'] ?? ''),
|
||||
(string)($s['cls'] ?? ''),
|
||||
(string)($s['grp'] ?? ''),
|
||||
(string)($s['login'] ?? ''),
|
||||
]);
|
||||
}
|
||||
$pdo->commit();
|
||||
|
||||
+25
-15
@@ -35,10 +35,11 @@ class Roster
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'sid' => trim((string)($s['sid'] ?? '')),
|
||||
'name' => trim((string)($s['name'] ?? '')),
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'sid' => trim((string)($s['sid'] ?? '')),
|
||||
'name' => trim((string)($s['name'] ?? '')),
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'login' => trim((string)($s['login'] ?? '')),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
@@ -63,10 +64,11 @@ class Roster
|
||||
continue;
|
||||
}
|
||||
$clean[] = [
|
||||
'sid' => $sid,
|
||||
'name' => $name,
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'sid' => $sid,
|
||||
'name' => $name,
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'login' => trim((string)($s['login'] ?? '')),
|
||||
];
|
||||
}
|
||||
if (self::useDb()) {
|
||||
@@ -191,15 +193,17 @@ class Roster
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别 学号/姓名/班级 列
|
||||
* @return array<int,array{sid:string,name:string,cls:string}>
|
||||
* 识别 学号/姓名/班级/组别/登陆账号 列
|
||||
* 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
|
||||
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
|
||||
*/
|
||||
public static function rowsToStudents(array $rows): array
|
||||
{
|
||||
$sidCol = -1;
|
||||
$nameCol = -1;
|
||||
$clsCol = -1;
|
||||
$grpCol = -1;
|
||||
$sidCol = -1;
|
||||
$nameCol = -1;
|
||||
$clsCol = -1;
|
||||
$grpCol = -1;
|
||||
$loginCol = -1;
|
||||
$headerIdx = -1;
|
||||
|
||||
// 找表头
|
||||
@@ -228,6 +232,9 @@ class Roster
|
||||
if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|^\s*组\s*$|group/i', $c)) {
|
||||
$grpCol = $j;
|
||||
}
|
||||
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
|
||||
$loginCol = $j;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($sidCol < 0) {
|
||||
@@ -249,7 +256,10 @@ class Roster
|
||||
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
|
||||
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
|
||||
$grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
|
||||
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp];
|
||||
$login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
|
||||
// 登陆账号同样去掉 Excel 数字尾巴(手机号被读成 13912687810.0)
|
||||
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
|
||||
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// 测试随机抽取页的均衡分配算法(通过 stub 环境加载 assets/draw.js)
|
||||
// 运行:node tools/test-draw-alloc.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// stub 浏览器环境:draw.js 顶部只定义函数,尾部 document.addEventListener
|
||||
global.window = {};
|
||||
global.document = { addEventListener() {} };
|
||||
global.localStorage = { getItem: () => null, setItem: () => {} };
|
||||
global.location = {};
|
||||
|
||||
eval(fs.readFileSync(path.join(__dirname, '..', 'assets', 'draw.js'), 'utf8'));
|
||||
const T = global.window.__nyDrawTest;
|
||||
if (!T) { console.error('FAIL 未找到 __nyDrawTest 测试钩子'); process.exit(1); }
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function chk(name, cond, extra) {
|
||||
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||
}
|
||||
|
||||
/* ---------- allocQuotas:名额均分(5:5 语义) ---------- */
|
||||
// A/B = 12/8 抽 10 → 各 5(名额均分到组,与组人数无关)
|
||||
chk('12/8 抽10 → 5/5', JSON.stringify(T.allocQuotas([12, 8], 10)) === '[5,5]',
|
||||
JSON.stringify(T.allocQuotas([12, 8], 10)));
|
||||
// 抽 1 人:余数小组优先
|
||||
chk('抽1人 → 小组优先', JSON.stringify(T.allocQuotas([12, 8], 1)) === '[0,1]',
|
||||
JSON.stringify(T.allocQuotas([12, 8], 1)));
|
||||
// 12/8 抽 3 → 均分 1/1 + 余数给小组 → 1/2
|
||||
chk('12/8 抽3 → 1/2', JSON.stringify(T.allocQuotas([12, 8], 3)) === '[1,2]',
|
||||
JSON.stringify(T.allocQuotas([12, 8], 3)));
|
||||
// 组容量不足:A 只有 1 人,B 有 19 人,抽 10 → A 1 + B 9
|
||||
chk('容量不足差额再分配', JSON.stringify(T.allocQuotas([1, 19], 10)) === '[1,9]',
|
||||
JSON.stringify(T.allocQuotas([1, 19], 10)));
|
||||
// 全抽 → 每组全量
|
||||
chk('全部抽取 = 各组全量', JSON.stringify(T.allocQuotas([12, 8], 20)) === '[12,8]',
|
||||
JSON.stringify(T.allocQuotas([12, 8], 20)));
|
||||
// n 超过总人数 → 全量
|
||||
chk('超额抽取取全量', (() => {
|
||||
const q = T.allocQuotas([3, 4], 99);
|
||||
return JSON.stringify(q) === '[3,4]';
|
||||
})(), JSON.stringify(T.allocQuotas([3, 4], 99)));
|
||||
// 5:5 经典:A 20 / B 20 抽 10 → 5/5
|
||||
chk('20/20 抽10 → 5/5', JSON.stringify(T.allocQuotas([20, 20], 10)) === '[5,5]',
|
||||
JSON.stringify(T.allocQuotas([20, 20], 10)));
|
||||
// 三组 10/10/10 抽 8 → 3/3/2(余数并列时稳定分配)
|
||||
chk('三组 10/10/10 抽8', (() => {
|
||||
const q = T.allocQuotas([10, 10, 10], 8);
|
||||
return q.reduce((a, b) => a + b, 0) === 8 && Math.max(...q) - Math.min(...q) <= 1;
|
||||
})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8)));
|
||||
// 空组 / 0
|
||||
chk('空数组返回空', JSON.stringify(T.allocQuotas([], 5)) === '[]');
|
||||
// 名额总数守恒 + 不超容量 + 均衡(随机 fuzz)
|
||||
let fuzzOk = true;
|
||||
let fuzzBad = '';
|
||||
for (let t = 0; t < 1000; t++) {
|
||||
const g = 1 + Math.floor(Math.random() * 5);
|
||||
const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30));
|
||||
const n = Math.floor(Math.random() * 60);
|
||||
const q = T.allocQuotas(sizes, n);
|
||||
const sum = q.reduce((a, b) => a + b, 0);
|
||||
const total = sizes.reduce((a, b) => a + b, 0);
|
||||
const expect = Math.min(n, total);
|
||||
if (sum !== expect) { fuzzOk = false; fuzzBad = `sum=${sum} expect=${expect} sizes=${sizes} n=${n}`; break; }
|
||||
let bad = false;
|
||||
for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true;
|
||||
// 均衡性:所有组均未满时,任意两组名额差 ≤ 1
|
||||
const allNotFull = q.every((v, i) => v < sizes[i]);
|
||||
if (allNotFull && Math.max(...q) - Math.min(...q) > 1) bad = true;
|
||||
if (bad) { fuzzOk = false; fuzzBad = `sizes=${JSON.stringify(sizes)} n=${n} q=${JSON.stringify(q)}`; break; }
|
||||
}
|
||||
chk('1000 组随机 fuzz:守恒+不超容量+均衡', fuzzOk, fuzzBad);
|
||||
|
||||
/* ---------- balanceKeyOf ---------- */
|
||||
chk('有 A/B 组别 → 按 grp', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'B', cls: 'C1' }]) === 'grp');
|
||||
chk('单组别 → 按班级', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C2' }]) === 'cls');
|
||||
chk('无组别多班级 → 按班级', T.balanceKeyOf([{ grp: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls');
|
||||
chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === '');
|
||||
|
||||
/* ---------- pickBalanced ---------- */
|
||||
{
|
||||
const pool = [];
|
||||
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, name: 'A' + i, grp: 'A', cls: 'C' });
|
||||
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' });
|
||||
const r = T.pickBalanced(pool, 10, 'grp');
|
||||
const byGrp = { A: 0, B: 0 };
|
||||
r.picked.forEach(m => byGrp[m.grp]++);
|
||||
chk('均衡抽取 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5, JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
|
||||
chk('detail 文案', r.detail.join('/') === 'A 5人/B 5人', r.detail.join('/'));
|
||||
chk('结果不重复', new Set(r.picked.map(m => m.sid)).size === 10);
|
||||
}
|
||||
{
|
||||
const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }];
|
||||
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp');
|
||||
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
|
||||
}
|
||||
|
||||
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user