diff --git a/api.php b/api.php
index f6771fc..856b416 100644
--- a/api.php
+++ b/api.php
@@ -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),
diff --git a/assets/draw.js b/assets/draw.js
index 1901248..1c2d60a 100644
--- a/assets/draw.js
+++ b/assets/draw.js
@@ -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 = '
| 尚未抽取 |
';
+ tb.innerHTML = '| 尚未抽取 |
';
return;
}
tb.innerHTML = state.picked.map((m, i) =>
@@ -235,10 +351,100 @@
'' + esc(m.name) + ' | ' +
'' + esc(m.cls || '') + ' | ' +
'' + esc(m.grp || '') + ' | ' +
+ ' | ' +
'').join('');
}
- /* ---------------- 下载 ---------------- */
+ /* ================= 历史记录 ================= */
+ function pushLog(picked, detail) {
+ state.log.unshift({
+ ts: new Date().toLocaleString('zh-CN', { hour12: false }),
+ source: state.source === 'project' ? '已提交人员' : '学号名单',
+ range: rangeText(),
+ balance: detail.join(' / '),
+ count: picked.length,
+ members: picked.map(m => ({
+ sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
+ })),
+ });
+ if (state.log.length > LOG_MAX) state.log.length = LOG_MAX;
+ saveLog();
+ }
+
+ function renderLog() {
+ const box = $('#logList');
+ if (!box) return;
+ if (!state.log.length) {
+ box.innerHTML = '暂无历史记录
';
+ return;
+ }
+ box.innerHTML = state.log.map((e, i) => {
+ const rows = (e.members || []).map((m, j) =>
+ '' +
+ '| ' + (j + 1) + ' | ' +
+ '' + esc(m.sid) + ' | ' +
+ '' + esc(m.name) + ' | ' +
+ '' + esc(m.cls || '') + ' | ' +
+ '' + esc(m.grp || '') + ' | ' +
+ '
').join('');
+ const meta = [e.ts, e.source, '范围:' + e.range, e.count + ' 人'];
+ if (e.balance) meta.push('均衡:' + e.balance);
+ return '' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '
查看成员(' + e.count + ')
' +
+ '' +
+ '' +
+ '
';
+ }).join('');
+ }
+
+ /* ================= 排除管理 ================= */
+ let exclQuery = '';
+
+ function renderExcl() {
+ const tb = $('#exclBody');
+ if (!tb) return;
+ let list = baseList().slice();
+ if (exclQuery) {
+ const q = exclQuery.toLowerCase();
+ list = list.filter(x =>
+ String(x.sid).toLowerCase().includes(q) ||
+ String(x.name).toLowerCase().includes(q) ||
+ String(x.cls || '').toLowerCase().includes(q));
+ }
+ if (!list.length) {
+ tb.innerHTML = '| ' +
+ (baseList().length ? '没有匹配的人员' : '当前来源暂无人员') + ' |
';
+ return;
+ }
+ tb.innerHTML = list.map(m => {
+ const on = state.excluded.has(String(m.sid)) ? ' checked' : '';
+ return '' +
+ ' | ' +
+ '' + esc(m.sid) + ' | ' +
+ '' + esc(m.name) + ' | ' +
+ '' + esc(m.cls || '') + ' | ' +
+ '' + esc(m.grp || '') + ' | ' +
+ '
';
+ }).join('');
+ }
+
+ /* ================= 下载(登陆账号优先) ================= */
+ function dlMembers() {
+ return state.picked.map(m => ({
+ name: m.name,
+ sid: m.sid,
+ login: m.login || '',
+ }));
+ }
+
async function download(type) {
if (!state.picked.length) {
showAlert('请先点击「🎲 开始抽取」');
@@ -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();
diff --git a/assets/style.css b/assets/style.css
index e40e970..c67e020 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -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; }
diff --git a/draw.php b/draw.php
index 9f8e4f1..dfa6d1c 100644
--- a/draw.php
+++ b/draw.php
@@ -1,7 +1,8 @@
@@ -76,9 +77,11 @@ $rosterCount = Roster::count();
-
+
+
+
-
+
@@ -88,6 +91,7 @@ $rosterCount = Roster::count();
候选人数-
本次抽取-
累计已抽取-
+ 已排除-
抽取来源学号名单
@@ -95,6 +99,7 @@ $rosterCount = Roster::count();
抽取结果
+
@@ -107,19 +112,58 @@ $rosterCount = Roster::count();
| 序号 |
- 学号 |
- 姓名 |
+ 学号 |
+ 姓名 |
班级 |
组别 |
+ 操作 |
-
| 尚未抽取 |
+
| 尚未抽取 |
- 两个下载均为两列「姓名 / 登陆账号」(登陆账号取名单中的学号,按文本格式保存,长学号不会变成科学计数法),可直接导入卷王白名单。
+ 两个下载均为两列「姓名 / 登陆账号」,登陆账号取名单中导入的「登陆账号」列(缺失时回退学号),按文本格式保存,可直接导入卷王白名单。
+
+
+
+
抽取排除管理
+
+
+
+
+ 已排除 0 人
+
+
+
+
勾选后这些人不再参与抽取(勾选即时生效);仅影响随机抽取,不影响其他功能。
+
+
+
+
+
+
+
+
+
抽取历史记录
+
+
+
每次抽取自动记一笔;「重置结果」不会清除历史记录,可随时恢复某一批次重新下载。
+
+
+
+
+