Files
ny-exam-platform/assets/draw.js
T
nxxy335 ba0e64ed51 feat: 导出按班级/组别筛选 + 随机抽取页 + 零依赖 XLSX 导出
导出跟随分类筛选
- api.php export / export_absent 新增 cls、grp 参数,按当前筛选过滤 rows
- 缺考口径与页面完全一致:筛选后名单 − 筛选后已提交(按学号匹配)
- 导出文件名附带班级 / 组别(如 成绩明细_高一数学月考_高一(1)班_A组_时间戳.csv)
- app.js 两个导出按钮追加 cls/grp 参数并提示导出范围

随机抽取页 draw.php
- 来源:学号名单 / 某试卷已提交人员;支持班级、组别筛选与抽取人数设置
- 「排除已抽中」累计去重,抽取结果存入 localStorage,刷新后仍可下载
- 抽取动画(滚动定格),结果表格含序号 / 学号 / 姓名 / 班级 / 组别
- 下载「白名单导入模板.xlsx」「白名单列表.xlsx」:两列 姓名 / 登陆账号

XLSX 导出能力
- 新增 lib/XlsxWriter.php:零依赖生成 xlsx,有 zip 扩展走 ZipArchive,
  无则回退手写 ZIP(stored);单元格按共享字符串 + 文本格式(numFmtId=49)
  写入,长学号不会变科学计数法
- 新增 api.php action=draw_export,输出与「白名单列表.xlsx」模板一致的结构

其他
- index.php 顶栏新增「随机抽取」入口;支持 index.php#roster 直接打开名单管理
- tools/test-xlsxwriter.php 自测:18 项全通过(含无 ZipArchive 的手写打包路径)
2026-09-09 11:11:14 +08:00

423 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* NY 问卷考试系统数据平台 - 随机抽取页逻辑
*/
(function () {
'use strict';
const $ = s => document.querySelector(s);
const $$ = s => Array.prototype.slice.call(document.querySelectorAll(s));
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const LS_RESULT = 'ny_draw_result'; // 最近一次抽取结果(刷新后仍可下载)
const LS_HISTORY = 'ny_draw_history'; // 累计抽中的学号(用于"排除已抽中")
const LS_SETTINGS = 'ny_draw_settings'; // 来源 / 项目 / 班级 / 组别 / 人数
const state = {
roster: [],
projects: [],
answers: [],
source: 'roster',
picked: [],
history: new Set(),
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' : '');
}
/* ---------------- 数据加载 ---------------- */
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 || ''),
}));
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 });
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 || ''),
}));
}
/* ---------------- 候选池 ---------------- */
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);
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 renderStats() {
const pool = currentPool();
$('#dPool').textContent = pool.length;
$('#dPicked').textContent = state.picked.length;
$('#dHistory').textContent = state.history.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;
}
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);
}
const 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();
saveState();
state.drawing = false;
btn.disabled = false;
showAlert('已抽取 ' + n + ' 人' + (n > 1 ? ',可点击右侧按钮下载白名单' : ''), 'info');
}
function renderResult() {
const tb = $('#drawBody');
if (!state.picked.length) {
tb.innerHTML = '<tr><td colspan="5" 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>' +
'</tr>').join('');
}
/* ---------------- 下载 ---------------- */
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: state.picked.map(m => ({ name: m.name, sid: m.sid })),
}),
});
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 saveSettings() {
try {
localStorage.setItem(LS_SETTINGS, JSON.stringify({
source: state.source,
projectId: $('#projectSel').value,
cls: $('#clsSel').value,
grp: $('#grpSel').value,
num: $('#numInput').value,
exclude: $('#excludePicked').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) { /* 忽略 */ }
}
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();
saveSettings();
});
$('#projectSel').addEventListener('change', async () => {
const pid = $('#projectSel').value;
if (!pid) { state.answers = []; populateFilters(); renderStats(); return; }
showAlert('正在拉取已提交人员…', 'info');
try {
await loadAnswers(pid);
showAlert('');
} catch (e) {
showAlert(e.message);
}
populateFilters();
renderStats();
saveSettings();
});
['#clsSel', '#grpSel'].forEach(id => {
$(id).addEventListener('change', () => { renderStats(); saveSettings(); });
});
$('#numInput').addEventListener('change', saveSettings);
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
$$('[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', () => {
state.picked = [];
renderResult();
renderStats();
saveState();
const stage = $('#drawStage');
stage.className = 'draw-stage';
stage.textContent = '点击「🎲 开始抽取」';
showAlert('');
});
$('#btnClearHistory').addEventListener('click', () => {
state.history = new Set();
state.picked = [];
renderResult();
renderStats();
saveState();
showAlert('已清空累计抽取记录', 'info');
});
$('#btnDlImport').addEventListener('click', () => download('import'));
$('#btnDlList').addEventListener('click', () => download('list'));
}
/* ---------------- 启动 ---------------- */
document.addEventListener('DOMContentLoaded', async function () {
loadState();
const s = loadSettings();
if (s.exclude === false) $('#excludePicked').checked = false;
if (s.num) $('#numInput').value = s.num;
if (s.source === 'project') {
state.source = 'project';
$('#sourceSel').value = 'project';
$('#projectField').classList.remove('hidden');
}
bind();
renderResult();
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();
});
})();