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 的手写打包路径)
This commit is contained in:
2026-09-09 11:11:14 +08:00
parent 8686eb99c7
commit ba0e64ed51
9 changed files with 1113 additions and 12 deletions
+89 -10
View File
@@ -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);
}
+23 -2
View File
@@ -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();
+422
View File
@@ -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 =>
({ '&': '&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();
});
})();
+33
View File
@@ -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; }
}
+1
View File
@@ -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',
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* NY 问卷考试系统数据平台 - 随机抽取
* 从名单 / 已提交人员中随机抽取,导出为卷王白名单(两列:姓名 / 登陆账号)
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (!ny_is_installed()) {
header('Location: install.php');
exit;
}
if (!ny_is_auth()) {
header('Location: login.php');
exit;
}
$rosterCount = Roster::count();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>随机抽取 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo">NY</span>
<div class="brand-text">
<strong>随机抽取</strong>
<small>名单 / 已提交人员 → 卷王白名单</small>
</div>
</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>
<a class="btn" href="index.php">← 返回主系统</a>
<a class="btn" href="logout.php" title="退出登录">退出</a>
</div>
</header>
<main class="wrap">
<section class="toolbar">
<div class="field">
<label>抽取来源</label>
<select id="sourceSel">
<option value="roster">学号名单(全部)</option>
<option value="project">某试卷已提交人员</option>
</select>
</div>
<div class="field grow hidden" id="projectField">
<label>问卷 / 考试</label>
<select id="projectSel"><option value="">加载中…</option></select>
</div>
<div class="field">
<label>班级</label>
<select id="clsSel"><option value="">全部班级</option></select>
</div>
<div class="field">
<label>组别</label>
<select id="grpSel"><option value="">全部组别</option></select>
</div>
<div class="field">
<label>抽取人数</label>
<div class="inline">
<input type="number" id="numInput" min="1" step="1" value="1">
<button class="btn sm" data-num="1">1</button>
<button class="btn sm" data-num="5">5</button>
<button class="btn sm" data-num="10">10</button>
<button class="btn sm" data-num="all">全部</button>
</div>
</div>
<div class="field btns">
<label class="chk"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label>
<button class="btn btn-primary" id="btnDraw">🎲 开始抽取</button>
<button class="btn" id="btnReset">重置结果</button>
</div>
</section>
<div id="alert" class="alert hidden"></div>
<section class="cards" id="statCards">
<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-gray"><span>抽取来源</span><b id="dSource">学号名单</b></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>抽取结果 <small id="drawHint"></small></h2>
<div class="actions">
<button class="btn" id="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button>
<button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button>
</div>
</div>
<div class="draw-stage" id="drawStage">点击「🎲 开始抽取」</div>
<div class="table-scroll">
<table class="grid">
<thead>
<tr>
<th style="width:70px">序号</th>
<th style="width:30%">学号</th>
<th style="width:24%">姓名</th>
<th>班级</th>
<th>组别</th>
</tr>
</thead>
<tbody id="drawBody"><tr><td colspan="5" class="empty">尚未抽取</td></tr></tbody>
</table>
</div>
<p class="hint">两个下载均为两列「<b>姓名</b> / <b>登陆账号</b>」(登陆账号取名单中的学号,按文本格式保存,长学号不会变成科学计数法),可直接导入卷王白名单。</p>
</section>
</main>
<script src="assets/draw.js"></script>
</body>
</html>
+1
View File
@@ -35,6 +35,7 @@ $rosterCount = Roster::count();
<div class="top-right">
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
<a class="btn" id="btnDashboard" href="dashboard.php" target="_blank" rel="noopener" title="在新标签页打开数据看板">📊 数据看板</a>
<a class="btn" id="btnDraw" href="draw.php" target="_blank" rel="noopener" title="在新标签页打开随机抽取">🎲 随机抽取</a>
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
<button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button>
<button class="btn btn-primary" id="btnSettings">设置</button>
+315
View File
@@ -0,0 +1,315 @@
<?php
/**
* NY 问卷考试系统数据平台 - 零依赖 XLSX 生成
*
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
* - 第一行固定为表头(加粗 + 居中)
*/
declare(strict_types=1);
final class XlsxWriter
{
/** @var array<string,int> 共享字符串 => 索引 */
private array $strIndex = [];
/** @var array<int,string> 索引 => 共享字符串 */
private array $strings = [];
/** @var array<int,array<int,string>> 行数据 */
private array $rows = [];
/** @var array<int,float> 列宽(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 .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
}
$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 .= '<c r="' . self::colName($c) . $r . '" s="' . $s . '" t="s"><v>' .
$cells[$r][$c] . '</v></c>';
}
if (!$has) {
continue;
}
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
'<dimension ref="' . $dim . '"/>' .
'<sheetViews><sheetView workbookViewId="0"/></sheetViews>' .
'<sheetFormatPr defaultRowHeight="13.5"/>' .
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
'<sheetData>' . $data . '</sheetData>' .
'</worksheet>';
}
private function contentTypesXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' .
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' .
'<Default Extension="xml" ContentType="application/xml"/>' .
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' .
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' .
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' .
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' .
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' .
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' .
'</Types>';
}
private function rootRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' .
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' .
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' .
'</Relationships>';
}
private function workbookXml(string $title): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' .
'<sheets><sheet name="' . self::xml($title) . '" sheetId="1" r:id="rId1"/></sheets>' .
'</workbook>';
}
private function workbookRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' .
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' .
'</Relationships>';
}
private function stylesXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
// numFmts: 49 = @(文本)
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
'<fonts count="2">' .
'<font><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
'<font><b/><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
'</fonts>' .
'<fills count="2"><fill><patternFill patternType="none"/></fill>' .
'<fill><patternFill patternType="gray125"/></fill></fills>' .
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' .
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' .
'<cellXfs count="3">' .
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' . // 0 常规
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment vertical="center"/></xf>' . // 2 文本
'</cellXfs>' .
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
'</styleSheet>';
}
private function sharedStringsXml(): string
{
$count = count($this->strings);
$body = '';
foreach ($this->strings as $s) {
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $count .
'" uniqueCount="' . $count . '">' . $body . '</sst>';
}
private function coreXml(string $title): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' .
'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ' .
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' .
'<dc:title>' . self::xml($title) . '</dc:title>' .
'<dc:creator>NY问卷考试系统数据平台</dc:creator>' .
'<cp:lastModifiedBy>NY问卷考试系统数据平台</cp:lastModifiedBy>' .
'<dcterms:created xsi:type="dcterms:W3CDTF">' . gmdate('Y-m-d\TH:i:s\Z') . '</dcterms:created>' .
'<dcterms:modified xsi:type="dcterms:W3CDTF">' . gmdate('Y-m-d\TH:i:s\Z') . '</dcterms:modified>' .
'</cp:coreProperties>';
}
private function appXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' .
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' .
'<Application>NY Exam Platform</Application></Properties>';
}
/* ---------------- 内部:打包 ---------------- */
/** @param array<string,string> $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<string,string> $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';
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* 自测:XlsxWriter 生成 → XlsxReader 读回,验证闭环与内容正确
* 运行:php tools/test-xlsxwriter.php
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
$out = __DIR__ . '/_xlsxwriter_out.xlsx';
$rows = [
['姓名', '登陆账号'],
['张三', '2024010101'],
['李四', '2024010102001234'], // 超长学号:必须原样保留,不能变科学计数法
['王小明', 'A001'],
['Bob "引号" & <标签>', '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);