diff --git a/api.php b/api.php
index 962690c..f6771fc 100644
--- a/api.php
+++ b/api.php
@@ -326,7 +326,7 @@ try {
Roster::clear();
ny_ok(['students' => [], 'meta' => Roster::meta()]);
- // ---------------- 导出 ----------------
+ // ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
case 'export':
case 'export_absent':
$projectId = (string)($_GET['projectId'] ?? '');
@@ -334,34 +334,80 @@ try {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
+ $clsFilter = trim((string)($_GET['cls'] ?? ''));
+ $grpFilter = trim((string)($_GET['grp'] ?? ''));
+
+ // 命中筛选:未选班级/组别时全部通过
+ $hit = static function (array $r) use ($clsFilter, $grpFilter): bool {
+ if ($clsFilter !== '' && trim((string)($r['cls'] ?? '')) !== $clsFilter) {
+ return false;
+ }
+ if ($grpFilter !== '' && trim((string)($r['grp'] ?? '')) !== $grpFilter) {
+ return false;
+ }
+ return true;
+ };
+
$c = cfg();
$result = DataService::build($c, $projectId, $nameQid);
+
+ $rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
+
+ // 缺考口径与页面完全保持一致:筛选后名单 − 筛选后已提交(按学号匹配)
+ $roster = array_values(array_filter((array)($result['roster'] ?? []), $hit));
+ if ($roster !== []) {
+ $submitted = [];
+ foreach ($rows as $r) {
+ $sid = trim((string)($r['sid'] ?? ''));
+ if ($sid !== '') {
+ $submitted[$sid] = true;
+ }
+ }
+ $absent = array_values(array_filter(
+ $roster,
+ static fn(array $s): bool => !isset($submitted[trim((string)($s['sid'] ?? ''))])
+ ));
+ } else {
+ // 未导入名单时退回后端自带的缺考数组(同样按筛选过滤)
+ $absent = array_values(array_filter((array)($result['absent'] ?? []), $hit));
+ }
+
+ // 文件名附带班级 / 组别,便于分辨
+ $safe = static fn(string $s): string => preg_replace('/[\\\\\/:*?"<>|]/u', '_', $s) ?? '';
+ $clsLabel = $clsFilter === '' ? ''
+ : (mb_strpos($clsFilter, '班') === false ? $clsFilter . '班' : $clsFilter);
+ $grpLabel = $grpFilter === '' ? ''
+ : (mb_strpos($grpFilter, '组') === false ? $grpFilter . '组' : $grpFilter);
+ $tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '')
+ . ($grpLabel !== '' ? '_' . $safe($grpLabel) : '');
+
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
- . '_' . preg_replace('/[\\\\\/:*?"<>|]/u', '_', (string)$result['project']['name'])
+ . '_' . $safe((string)$result['project']['name'])
+ . $tag
. '_' . date('Ymd_His') . '.csv';
header('Content-Type: text/csv; charset=utf-8');
- header('Content-Disposition: attachment; filename="' . $filename . '"');
+ header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8\'\'' . rawurlencode($filename));
header('Cache-Control: no-store');
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
if ($action === 'export_absent') {
fputcsv($out, ['学号', '姓名', '班级', '组别']);
- foreach ($result['absent'] as $r) {
- fputcsv($out, [$r['sid'], $r['name'], $r['cls'] ?? '', $r['grp'] ?? '']);
+ foreach ($absent as $r) {
+ fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
}
} else {
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)']);
- foreach ($result['rows'] as $r) {
+ foreach ($rows as $r) {
fputcsv($out, [
- $r['sid'],
- $r['name'],
+ $r['sid'] ?? '',
+ $r['name'] ?? '',
$r['cls'] ?? '',
$r['grp'] ?? '',
$r['score'] === null ? '' : $r['score'],
- $r['submit_at'],
- $r['duration_txt'],
+ $r['submit_at'] ?? '',
+ $r['duration_txt'] ?? '',
$r['duration'] === null ? '' : $r['duration'],
]);
}
@@ -369,6 +415,39 @@ try {
fclose($out);
exit;
+ // ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
+ case 'draw_export':
+ $raw = (string)file_get_contents('php://input');
+ $in = json_decode($raw, true);
+ if (!is_array($in)) {
+ $in = $_POST + $_GET;
+ }
+ // type=import → 白名单导入模板.xlsx;type=list → 白名单列表.xlsx
+ $type = (($in['type'] ?? '') === 'import') ? 'import' : 'list';
+ $members = [];
+ foreach ((array)($in['members'] ?? []) as $m) {
+ if (!is_array($m)) {
+ continue;
+ }
+ $name = trim((string)($m['name'] ?? ''));
+ $sid = trim((string)($m['sid'] ?? ''));
+ if ($name === '' && $sid === '') {
+ continue;
+ }
+ $members[] = [$name, $sid];
+ }
+ $bin = XlsxWriter::render(
+ array_merge([['姓名', '登陆账号']], $members),
+ ['widths' => [16, 24], 'title' => '白名单']
+ );
+ $filename = $type === 'import' ? '白名单导入模板.xlsx' : '白名单列表.xlsx';
+ header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+ header('Content-Disposition: attachment; filename="whitelist.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename));
+ header('Content-Length: ' . strlen($bin));
+ header('Cache-Control: no-store');
+ echo $bin;
+ exit;
+
default:
ny_fail('未知操作:' . $action);
}
diff --git a/assets/app.js b/assets/app.js
index 3ca6f8e..c855e8c 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -92,6 +92,14 @@
el.className = 'alert' + (type === 'info' ? ' info' : '');
}
+ // 导出提示:说明本次导出的筛选范围
+ function exportTip(what, cls, grp) {
+ const parts = [];
+ if (cls) parts.push(cls + (cls.indexOf('班') >= 0 ? '' : '班'));
+ if (grp) parts.push(grp + (grp.indexOf('组') >= 0 ? '' : '组'));
+ return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
+ }
+
/* ---------------- 问卷列表 ---------------- */
// 把数组渲染到 #projectSel(抽出便于缓存复用)
function renderProjects() {
@@ -661,17 +669,24 @@
});
});
+ // 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么)
$('#btnExport').addEventListener('click', () => {
const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试');
+ const cls = $('#clsSel').value, grp = $('#grpSel').value;
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
- '&nameQid=' + encodeURIComponent($('#nameFieldSel').value);
+ '&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
+ '&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp);
+ showAlert(exportTip('成绩明细', cls, grp), 'info');
});
$('#btnExportAbsent').addEventListener('click', () => {
const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试');
+ const cls = $('#clsSel').value, grp = $('#grpSel').value;
location.href = 'api.php?action=export_absent&projectId=' + encodeURIComponent(pid) +
- '&nameQid=' + encodeURIComponent($('#nameFieldSel').value);
+ '&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
+ '&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp);
+ showAlert(exportTip('缺考名单', cls, grp), 'info');
});
/* 弹窗关闭 */
@@ -803,6 +818,12 @@
// 退出登录:跳转到独立 logout.php(清服务端会话)
$('#btnLogout').addEventListener('click', () => { location.href = 'logout.php'; });
+ // 3) 支持 index.php#roster 直接打开名单管理(随机抽取页的「名单管理」入口用)
+ if (location.hash === '#roster') {
+ openRoster();
+ if (history.replaceState) history.replaceState(null, '', location.pathname + location.search);
+ }
+
// 启动:主界面在 PHP 端已做会话校验(未登录会跳到 login.php),此处直接加载数据
schedule();
loadProjects();
diff --git a/assets/draw.js b/assets/draw.js
new file mode 100644
index 0000000..1901248
--- /dev/null
+++ b/assets/draw.js
@@ -0,0 +1,422 @@
+/*!
+ * 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 =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = '';
+ 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 '';
+ }).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 = '' +
+ values.map(v => '').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 = '
| 尚未抽取 |
';
+ return;
+ }
+ tb.innerHTML = state.picked.map((m, i) =>
+ '' +
+ '| ' + (i + 1) + ' | ' +
+ '' + esc(m.sid) + ' | ' +
+ '' + esc(m.name) + ' | ' +
+ '' + esc(m.cls || '') + ' | ' +
+ '' + esc(m.grp || '') + ' | ' +
+ '
').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();
+ });
+})();
diff --git a/assets/style.css b/assets/style.css
index 4dcc110..e40e970 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -585,3 +585,36 @@ body.dash.fs-on .group-compare { gap: 12px; }
body.dash.fs-on .gpanel { padding: 12px 14px; }
body.dash.fs-on .member-list { max-height: 30vh; }
+
+/* ================= 随机抽取页 ================= */
+.panel-head .actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+
+/* 抽取人数输入框:在 toolbar 内联按钮组中收窄 */
+.field .inline input[type=number] { width: 78px; flex: none; }
+
+.draw-stage {
+ margin: 18px; padding: 26px 18px;
+ border: 1px dashed var(--border); border-radius: var(--radius);
+ background: linear-gradient(180deg, #fbfcff, #f6f8ff);
+ text-align: center; font-size: 20px; font-weight: 700;
+ color: var(--text-3); letter-spacing: 1px;
+ min-height: 86px; display: flex; align-items: center; justify-content: center;
+ transition: color .2s ease, background .2s ease;
+}
+.draw-stage.rolling {
+ color: var(--primary);
+ background: linear-gradient(180deg, #f2f6ff, #eaf0ff);
+ animation: drawShake .12s infinite alternate;
+}
+.draw-stage.done {
+ color: var(--ok);
+ background: linear-gradient(180deg, #f2fbf5, #e9f7ee);
+ font-size: 26px;
+}
+@keyframes drawShake {
+ from { transform: translateY(-1px) scale(.995); }
+ to { transform: translateY(1px) scale(1.005); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .draw-stage.rolling { animation: none; }
+}
diff --git a/bootstrap.php b/bootstrap.php
index bcebf84..65189ec 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -333,6 +333,7 @@ function ny_format_duration(?int $sec): string
spl_autoload_register(function (string $class): void {
$map = [
'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php',
+ 'XlsxWriter' => NY_ROOT . '/lib/XlsxWriter.php',
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
'Roster' => NY_ROOT . '/lib/Roster.php',
'DataService' => NY_ROOT . '/lib/DataService.php',
diff --git a/draw.php b/draw.php
new file mode 100644
index 0000000..9f8e4f1
--- /dev/null
+++ b/draw.php
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+随机抽取 · = htmlspecialchars(NY_APP_NAME) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 候选人数-
+ 本次抽取-
+ 累计已抽取-
+ 抽取来源学号名单
+
+
+
+
+
抽取结果
+
+
+
+
+
+
+ 点击「🎲 开始抽取」
+
+
+ 两个下载均为两列「姓名 / 登陆账号」(登陆账号取名单中的学号,按文本格式保存,长学号不会变成科学计数法),可直接导入卷王白名单。
+
+
+
+
+
+
diff --git a/index.php b/index.php
index a4f5c5c..4ca8b30 100644
--- a/index.php
+++ b/index.php
@@ -35,6 +35,7 @@ $rosterCount = Roster::count();
未连接
📊 数据看板
+
🎲 随机抽取
diff --git a/lib/XlsxWriter.php b/lib/XlsxWriter.php
new file mode 100644
index 0000000..d51d95d
--- /dev/null
+++ b/lib/XlsxWriter.php
@@ -0,0 +1,315 @@
+ 共享字符串 => 索引 */
+ private array $strIndex = [];
+ /** @var array
索引 => 共享字符串 */
+ private array $strings = [];
+ /** @var array> 行数据 */
+ private array $rows = [];
+ /** @var array 列宽(1 基) */
+ private array $widths = [];
+
+ // 样式索引(见 stylesXml)
+ private const S_DEFAULT = 0; // 常规
+ private const S_HEADER = 1; // 表头:加粗 + 居中
+ private const S_TEXT = 2; // 文本格式(numFmtId=49)
+
+ /**
+ * 生成 xlsx 二进制内容
+ *
+ * @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串
+ * @param array $opts ['widths' => [列宽,...], 'title' => 工作表名]
+ * @return string xlsx 文件二进制
+ */
+ public static function render(array $rows, array $opts = []): string
+ {
+ $w = new self();
+ $w->rows = array_values($rows);
+ $w->widths = array_values((array)($opts['widths'] ?? []));
+ return $w->build((string)($opts['title'] ?? 'Sheet1'));
+ }
+
+ /* ---------------- 内部:构建 ---------------- */
+
+ private function build(string $title): string
+ {
+ $maxCol = 1;
+ foreach ($this->rows as $r) {
+ $maxCol = max($maxCol, count((array)$r));
+ }
+ $maxRow = max(1, count($this->rows));
+
+ // 先登记所有字符串,生成 sharedStrings 索引
+ $cells = [];
+ foreach ($this->rows as $ri => $row) {
+ $r = (int)$ri + 1;
+ foreach (array_values((array)$row) as $ci => $val) {
+ $c = (int)$ci + 1;
+ $s = $this->intern((string)($val ?? ''));
+ $cells[$r][$c] = $s;
+ }
+ }
+
+ $sheet = $this->sheetXml($cells, $maxRow, $maxCol);
+ $files = [
+ '[Content_Types].xml' => $this->contentTypesXml(),
+ '_rels/.rels' => $this->rootRelsXml(),
+ 'docProps/app.xml' => $this->appXml(),
+ 'docProps/core.xml' => $this->coreXml($title),
+ 'xl/workbook.xml' => $this->workbookXml($title),
+ 'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml(),
+ 'xl/styles.xml' => $this->stylesXml(),
+ 'xl/sharedStrings.xml' => $this->sharedStringsXml(),
+ 'xl/worksheets/sheet1.xml' => $sheet,
+ ];
+
+ return $this->zip($files);
+ }
+
+ private function intern(string $s): int
+ {
+ if (!array_key_exists($s, $this->strIndex)) {
+ $i = count($this->strings);
+ $this->strIndex[$s] = $i;
+ $this->strings[$i] = $s;
+ }
+ return $this->strIndex[$s];
+ }
+
+ private function sheetXml(array $cells, int $maxRow, int $maxCol): string
+ {
+ $dim = 'A1:' . self::colName($maxCol) . $maxRow;
+
+ $cols = '';
+ for ($c = 1; $c <= $maxCol; $c++) {
+ $w = $this->widths[$c - 1] ?? 16;
+ $cols .= '';
+ }
+
+ $data = '';
+ for ($r = 1; $r <= $maxRow; $r++) {
+ $line = '';
+ $has = false;
+ for ($c = 1; $c <= $maxCol; $c++) {
+ if (!isset($cells[$r][$c])) {
+ continue;
+ }
+ $has = true;
+ $s = $r === 1 ? self::S_HEADER : self::S_TEXT;
+ $line .= '' .
+ $cells[$r][$c] . '';
+ }
+ if (!$has) {
+ continue;
+ }
+ $data .= '' . $line . '
';
+ }
+
+ return '' . "\n" .
+ '' .
+ '' .
+ '' .
+ '' .
+ ($cols !== '' ? '' . $cols . '' : '') .
+ '' . $data . '' .
+ '';
+ }
+
+ private function contentTypesXml(): string
+ {
+ return '' . "\n" .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '';
+ }
+
+ private function rootRelsXml(): string
+ {
+ return '' . "\n" .
+ '' .
+ '' .
+ '' .
+ '' .
+ '';
+ }
+
+ private function workbookXml(string $title): string
+ {
+ return '' . "\n" .
+ '' .
+ '' .
+ '';
+ }
+
+ private function workbookRelsXml(): string
+ {
+ return '' . "\n" .
+ '' .
+ '' .
+ '' .
+ '' .
+ '';
+ }
+
+ private function stylesXml(): string
+ {
+ return '' . "\n" .
+ '' .
+ // numFmts: 49 = @(文本)
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' . // 0 常规
+ '' . // 1 表头
+ '' . // 2 文本
+ '' .
+ '' .
+ '';
+ }
+
+ private function sharedStringsXml(): string
+ {
+ $count = count($this->strings);
+ $body = '';
+ foreach ($this->strings as $s) {
+ $body .= '' . self::xml((string)$s) . '';
+ }
+ return '' . "\n" .
+ '' . $body . '';
+ }
+
+ private function coreXml(string $title): string
+ {
+ return '' . "\n" .
+ '' .
+ '' . self::xml($title) . '' .
+ 'NY问卷考试系统数据平台' .
+ 'NY问卷考试系统数据平台' .
+ '' . gmdate('Y-m-d\TH:i:s\Z') . '' .
+ '' . gmdate('Y-m-d\TH:i:s\Z') . '' .
+ '';
+ }
+
+ private function appXml(): string
+ {
+ return '' . "\n" .
+ '' .
+ 'NY Exam Platform';
+ }
+
+ /* ---------------- 内部:打包 ---------------- */
+
+ /** @param array $files 相对路径 => 内容 */
+ private function zip(array $files): string
+ {
+ if (class_exists('ZipArchive')) {
+ $tmp = tempnam(sys_get_temp_dir(), 'nyx');
+ if ($tmp !== false) {
+ $zip = new ZipArchive();
+ if ($zip->open($tmp, ZipArchive::OVERWRITE) === true) {
+ foreach ($files as $name => $content) {
+ $zip->addFromString($name, $content);
+ }
+ $zip->close();
+ $bin = (string)@file_get_contents($tmp);
+ @unlink($tmp);
+ if ($bin !== '') {
+ return $bin;
+ }
+ }
+ @unlink($tmp);
+ }
+ }
+ return self::packManual($files);
+ }
+
+ /**
+ * 无 zip 扩展时的手写打包(stored,不压缩)
+ * 单独公开,便于在没有 ZipArchive 的环境下自检
+ *
+ * @param array $files 相对路径 => 内容
+ */
+ public static function packManual(array $files): string
+ {
+ $local = '';
+ $central = '';
+ $offset = 0;
+ $time = self::dosTime();
+ $date = self::dosDate();
+
+ foreach ($files as $name => $content) {
+ $nameBin = (string)$name;
+ $data = (string)$content;
+ $crc = crc32($data);
+ $len = strlen($data);
+
+ $local .= "\x50\x4b\x03\x04" . pack('vvvvvVVVvv', 20, 0x0800, 0, $time, $date, $crc, $len, $len, strlen($nameBin), 0) . $nameBin . $data;
+ $central .= "\x50\x4b\x01\x02" . pack('vvvvvvVVVvvvvvVV', 20, 20, 0x0800, 0, $time, $date, $crc, $len, $len, strlen($nameBin), 0, 0, 0, 0, 0, $offset) . $nameBin;
+ $offset += 30 + strlen($nameBin) + $len;
+ }
+
+ return $local . $central .
+ "\x50\x4b\x05\x06" . pack('vvvvVVv', 0, 0, count($files), count($files), strlen($central), $offset, 0);
+ }
+
+ private static function dosTime(): int
+ {
+ $t = getdate();
+ return ($t['hours'] << 11) | ($t['minutes'] << 5) | (intdiv($t['seconds'], 2) & 0x1f);
+ }
+
+ private static function dosDate(): int
+ {
+ $t = getdate();
+ return (($t['year'] - 1980) << 9) | ($t['mon'] << 5) | $t['mday'];
+ }
+
+ /* ---------------- 工具 ---------------- */
+
+ private static function xml(string $s): string
+ {
+ return htmlspecialchars($s, ENT_XML1 | ENT_COMPAT, 'UTF-8');
+ }
+
+ /** 列序号(1基) => A / B / ... / AA */
+ private static function colName(int $n): string
+ {
+ $s = '';
+ while ($n > 0) {
+ $m = ($n - 1) % 26;
+ $s = chr(65 + $m) . $s;
+ $n = intdiv($n - 1, 26);
+ }
+ return $s ?: 'A';
+ }
+}
diff --git a/tools/test-xlsxwriter.php b/tools/test-xlsxwriter.php
new file mode 100644
index 0000000..6d80d07
--- /dev/null
+++ b/tools/test-xlsxwriter.php
@@ -0,0 +1,104 @@
+', '123456789012345678'],
+];
+
+$bin = XlsxWriter::render($rows, ['widths' => [16, 24], 'title' => '白名单']);
+file_put_contents($out, $bin);
+
+echo "生成字节数: " . strlen($bin) . PHP_EOL;
+
+// 1) ZIP 头必须是 PK
+$pass = 0; $fail = 0;
+function chk(string $name, bool $ok, string $extra = ''): void
+{
+ global $pass, $fail;
+ if ($ok) { $pass++; echo " PASS $name" . ($extra ? " — $extra" : '') . PHP_EOL; }
+ else { $fail++; echo " FAIL $name" . ($extra ? " — $extra" : '') . PHP_EOL; }
+}
+
+chk('ZIP 魔数 PK', substr($bin, 0, 2) === 'PK');
+
+// 用 ZipArchive 解开校验包内结构(ZIP 内容已压缩,不能直接在二进制里搜字符串)
+$parts = [];
+if (class_exists('ZipArchive')) {
+ $z = new ZipArchive();
+ if ($z->open($out) === true) {
+ for ($i = 0; $i < $z->numFiles; $i++) {
+ $n = $z->getNameIndex($i);
+ $parts[$n] = $z->getFromIndex($i);
+ }
+ $z->close();
+ }
+}
+chk('包内含 xl/workbook.xml', isset($parts['xl/workbook.xml']));
+chk('包内含 xl/worksheets/sheet1.xml', isset($parts['xl/worksheets/sheet1.xml']));
+chk('包内含 xl/sharedStrings.xml', isset($parts['xl/sharedStrings.xml']));
+chk('Content_Types 声明 workbook 主部件',
+ strpos((string)($parts['[Content_Types].xml'] ?? ''), 'spreadsheetml.sheet.main+xml') !== false);
+chk('文本格式 numFmtId=49 已写入',
+ strpos((string)($parts['xl/styles.xml'] ?? ''), 'numFmtId="49"') !== false);
+chk('学号以共享字符串写入(非纯数字单元格)',
+ strpos((string)($parts['xl/sharedStrings.xml'] ?? ''), '2024010102001234') !== false);
+chk('列名(A/B)正确',
+ strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="A1"') !== false
+ && strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="B1"') !== false);
+
+// 2) 用自带的 XlsxReader 读回
+$back = XlsxReader::read($out);
+chk('XlsxReader 成功解析', is_array($back) && count($back) > 0, '行数=' . count($back));
+
+chk('行数一致', count($back) === count($rows), '期望 ' . count($rows) . ',实际 ' . count($back));
+
+$first = $back[0] ?? [];
+chk('表头 = 姓名/登陆账号',
+ ($first[0] ?? '') === '姓名' && ($first[1] ?? '') === '登陆账号',
+ json_encode($first, JSON_UNESCAPED_UNICODE));
+
+chk('普通行正确', ($back[1][0] ?? '') === '张三' && ($back[1][1] ?? '') === '2024010101',
+ json_encode($back[1] ?? [], JSON_UNESCAPED_UNICODE));
+
+chk('超长学号不被转科学计数法', ($back[2][1] ?? '') === '2024010102001234',
+ '实际=' . ($back[2][1] ?? 'NULL'));
+
+chk('18 位纯数字学号原样保留', ($back[4][1] ?? '') === '123456789012345678',
+ '实际=' . ($back[4][1] ?? 'NULL'));
+
+chk('特殊字符转义正确', ($back[4][0] ?? '') === 'Bob "引号" & <标签>',
+ '实际=' . ($back[4][0] ?? 'NULL'));
+
+// 3) 无 ZipArchive 时的手写打包路径(stored):单独生成再用 XlsxReader 读回
+$manualOut = __DIR__ . '/_xlsxwriter_manual.xlsx';
+$manual = XlsxWriter::packManual($parts !== [] ? $parts : []);
+if ($parts !== []) {
+ file_put_contents($manualOut, $manual);
+ chk('手写 ZIP 魔数 PK', substr($manual, 0, 2) === 'PK');
+ $mBack = [];
+ try {
+ $mBack = XlsxReader::read($manualOut);
+ } catch (Throwable $e) {
+ $mBack = [];
+ }
+ chk('手写打包产物可被解析', count($mBack) === count($rows), '行数=' . count($mBack));
+ chk('手写打包内容一致',
+ ($mBack[1][0] ?? '') === '张三' && ($mBack[4][1] ?? '') === '123456789012345678',
+ json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE));
+ @unlink($manualOut);
+}
+
+@unlink($out);
+echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
+exit($fail === 0 ? 0 : 1);