Compare commits

..
2 Commits
Author SHA1 Message Date
nxxy335 6772aeae11 feat: 抽取页分组均衡5:5 / 重置清累计 / 历史记录 / 排除管理 / 登陆账号字段 / side等高
随机抽取页:
- 分组均衡抽取:有组别(≥2个)时默认按名额均分到组(12/8抽10→5/5),
  其次按班级均分,否则整池随机;可勾选关闭
- 名额均分算法 allocQuotas:floor 均分 + 余数小组优先补齐 +
  超容量退回 + 差额给剩余容量大者
- 重置结果:同时清零累计已抽取数(历史记录仍保留)
- 历史记录:每批次入 localStorage ny_draw_log(上限50),
  重置不清,支持查看详情/恢复结果/删除/清空
- 抽取排除:排除管理弹窗(搜索/清空),结果行可"移出并加入排除"
- 白名单列表下载:登陆账号优先,缺失回退学号

名单登陆账号:
- 模板解析识别"登陆/登录账号/账号/手机号"列,手机号去 .0 尾巴
- Db ny_roster 增 login 列 + 旧表自动升级(三方言)
- roster_save 透传 login;draw_export 使用 login
- 登陆账号仅用于白名单列表下载,其余界面不展示

布局:side 模式下成绩明细/缺考名单 panel 改 flex 撑满,
.table-scroll 取消 320px 限高,两侧等高不再留白

测试:tools/test-draw-alloc.js 18/18 通过(含 1000 组随机 fuzz)
2026-09-09 12:29:49 +08:00
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
12 changed files with 1686 additions and 41 deletions
+96 -14
View File
@@ -313,10 +313,11 @@ try {
continue; continue;
} }
$students[] = [ $students[] = [
'sid' => (string)($s['sid'] ?? ''), 'sid' => (string)($s['sid'] ?? ''),
'name' => (string)($s['name'] ?? ''), 'name' => (string)($s['name'] ?? ''),
'cls' => trim((string)($s['cls'] ?? '')), 'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')), 'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
]; ];
} }
Roster::save($students); Roster::save($students);
@@ -326,7 +327,7 @@ try {
Roster::clear(); Roster::clear();
ny_ok(['students' => [], 'meta' => Roster::meta()]); ny_ok(['students' => [], 'meta' => Roster::meta()]);
// ---------------- 导出 ---------------- // ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
case 'export': case 'export':
case 'export_absent': case 'export_absent':
$projectId = (string)($_GET['projectId'] ?? ''); $projectId = (string)($_GET['projectId'] ?? '');
@@ -334,34 +335,80 @@ try {
ny_fail('请先选择问卷/考试'); ny_fail('请先选择问卷/考试');
} }
$nameQid = (string)($_GET['nameQid'] ?? ''); $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(); $c = cfg();
$result = DataService::build($c, $projectId, $nameQid); $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' ? '缺考名单' : '成绩明细') $filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
. '_' . preg_replace('/[\\\\\/:*?"<>|]/u', '_', (string)$result['project']['name']) . '_' . $safe((string)$result['project']['name'])
. $tag
. '_' . date('Ymd_His') . '.csv'; . '_' . date('Ymd_His') . '.csv';
header('Content-Type: text/csv; charset=utf-8'); 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'); header('Cache-Control: no-store');
echo "\xEF\xBB\xBF"; echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
if ($action === 'export_absent') { if ($action === 'export_absent') {
fputcsv($out, ['学号', '姓名', '班级', '组别']); fputcsv($out, ['学号', '姓名', '班级', '组别']);
foreach ($result['absent'] as $r) { foreach ($absent as $r) {
fputcsv($out, [$r['sid'], $r['name'], $r['cls'] ?? '', $r['grp'] ?? '']); fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
} }
} else { } else {
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)']); fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)']);
foreach ($result['rows'] as $r) { foreach ($rows as $r) {
fputcsv($out, [ fputcsv($out, [
$r['sid'], $r['sid'] ?? '',
$r['name'], $r['name'] ?? '',
$r['cls'] ?? '', $r['cls'] ?? '',
$r['grp'] ?? '', $r['grp'] ?? '',
$r['score'] === null ? '' : $r['score'], $r['score'] === null ? '' : $r['score'],
$r['submit_at'], $r['submit_at'] ?? '',
$r['duration_txt'], $r['duration_txt'] ?? '',
$r['duration'] === null ? '' : $r['duration'], $r['duration'] === null ? '' : $r['duration'],
]); ]);
} }
@@ -369,6 +416,41 @@ try {
fclose($out); fclose($out);
exit; 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'] ?? ''));
// 登陆账号优先(名单导入的"登陆账号"列),没有则回退学号
$acct = trim((string)($m['login'] ?? ''));
$sid = trim((string)($m['sid'] ?? ''));
if ($name === '' && $acct === '' && $sid === '') {
continue;
}
$members[] = [$name, $acct !== '' ? $acct : $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: default:
ny_fail('未知操作:' . $action); ny_fail('未知操作:' . $action);
} }
+23 -2
View File
@@ -92,6 +92,14 @@
el.className = 'alert' + (type === 'info' ? ' info' : ''); 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(抽出便于缓存复用) // 把数组渲染到 #projectSel(抽出便于缓存复用)
function renderProjects() { function renderProjects() {
@@ -661,17 +669,24 @@
}); });
}); });
// 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么)
$('#btnExport').addEventListener('click', () => { $('#btnExport').addEventListener('click', () => {
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试'); if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value;
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) + 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', () => { $('#btnExportAbsent').addEventListener('click', () => {
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试'); if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value;
location.href = 'api.php?action=export_absent&projectId=' + encodeURIComponent(pid) + 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(清服务端会话) // 退出登录:跳转到独立 logout.php(清服务端会话)
$('#btnLogout').addEventListener('click', () => { location.href = '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),此处直接加载数据 // 启动:主界面在 PHP 端已做会话校验(未登录会跳到 login.php),此处直接加载数据
schedule(); schedule();
loadProjects(); loadProjects();
+738
View File
@@ -0,0 +1,738 @@
/*!
* NY 问卷考试系统数据平台 - 随机抽取页逻辑
*
* 功能:来源切换 / 班级组别筛选 / 分组均衡抽取(5:5) / 排除已抽中 /
* 排除名单管理 / 历史记录(重置不丢失) / 白名单下载(登陆账号优先)
*/
(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_EXCLUDED = 'ny_draw_excluded'; // 手动排除的学号
const LS_LOG = 'ny_draw_log'; // 历史批次(重置不清,上限 50 条)
const LS_SETTINGS = 'ny_draw_settings'; // 来源 / 项目 / 班级 / 组别 / 人数 / 开关
const LOG_MAX = 50;
const state = {
roster: [],
projects: [],
answers: [],
source: 'roster',
picked: [],
history: new Set(), // 累计抽中(重置结果时清零)
excluded: new Set(), // 手动排除
log: [], // 历史批次,新的在前
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' : '');
}
/* ================= 抽取核心(纯函数,供测试) ================= */
/** 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 || ''),
login: String(s.login || ''),
}));
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 });
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 || ''),
login: String(loginOf[r.sid] || ''),
}));
}
/* ================= 候选池 ================= */
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);
list = list.filter(x => !state.excluded.has(String(x.sid)));
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 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' ? '已提交人员' : '学号名单';
$('#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('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
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);
}
// 真正抽取:均衡(组别优先,其次班级)或整池随机
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)));
stage.className = 'draw-stage done';
stage.textContent = n === 1
? (picked[0].name || picked[0].sid)
: ('🎉 已抽取 ' + n + ' 人');
renderResult();
renderStats();
pushLog(picked, detail);
saveState();
state.drawing = false;
btn.disabled = false;
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
}
function renderResult() {
const tb = $('#drawBody');
if (!state.picked.length) {
tb.innerHTML = '<tr><td colspan="6" 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>' +
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
'</tr>').join('');
}
/* ================= 历史记录 ================= */
function pushLog(picked, detail) {
state.log.unshift({
ts: new Date().toLocaleString('zh-CN', { hour12: false }),
source: state.source === 'project' ? '已提交人员' : '学号名单',
range: rangeText(),
balance: detail.join(' / '),
count: picked.length,
members: picked.map(m => ({
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
})),
});
if (state.log.length > LOG_MAX) state.log.length = LOG_MAX;
saveLog();
}
function renderLog() {
const box = $('#logList');
if (!box) return;
if (!state.log.length) {
box.innerHTML = '<p class="empty" style="text-align:center;padding:30px 0">暂无历史记录</p>';
return;
}
box.innerHTML = state.log.map((e, i) => {
const rows = (e.members || []).map((m, j) =>
'<tr>' +
'<td class="num">' + (j + 1) + '</td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td>' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'</tr>').join('');
const meta = [e.ts, e.source, '范围:' + e.range, e.count + ' 人'];
if (e.balance) meta.push('均衡:' + e.balance);
return '<div class="log-item">' +
'<div class="log-head">' +
'<label class="log-summary"><span>' + esc(meta.join(' · ')) + '</span></label>' +
'<span class="log-actions">' +
'<button class="btn sm" data-log-restore="' + i + '">恢复结果</button>' +
'<button class="btn sm danger" data-log-del="' + i + '">删除</button>' +
'</span></div>' +
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
'<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th style="width:56px">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th>班级</th><th>组别</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table></div>' +
'</details>' +
'</div>';
}).join('');
}
/* ================= 排除管理 ================= */
let exclQuery = '';
function renderExcl() {
const tb = $('#exclBody');
if (!tb) return;
let list = baseList().slice();
if (exclQuery) {
const q = exclQuery.toLowerCase();
list = list.filter(x =>
String(x.sid).toLowerCase().includes(q) ||
String(x.name).toLowerCase().includes(q) ||
String(x.cls || '').toLowerCase().includes(q));
}
if (!list.length) {
tb.innerHTML = '<tr><td colspan="5" class="empty">' +
(baseList().length ? '没有匹配的人员' : '当前来源暂无人员') + '</td></tr>';
return;
}
tb.innerHTML = list.map(m => {
const on = state.excluded.has(String(m.sid)) ? ' checked' : '';
return '<tr>' +
'<td style="text-align:center"><input type="checkbox" data-excl="' + esc(m.sid) + '"' + on + '></td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td>' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'</tr>';
}).join('');
}
/* ================= 下载(登陆账号优先) ================= */
function dlMembers() {
return state.picked.map(m => ({
name: m.name,
sid: m.sid,
login: m.login || '',
}));
}
async function download(type) {
if (!state.picked.length) {
showAlert('请先点击「🎲 开始抽取」');
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: dlMembers() }),
});
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 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({
source: state.source,
projectId: $('#projectSel').value,
cls: $('#clsSel').value,
grp: $('#grpSel').value,
num: $('#numInput').value,
exclude: $('#excludePicked').checked,
balance: $('#balanceChk').checked,
}));
} catch (e) { /* 忽略 */ }
}
function loadState() {
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() {
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();
if ($('#exclModal').classList.contains('open')) renderExcl();
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();
if ($('#exclModal').classList.contains('open')) renderExcl();
saveSettings();
});
['#clsSel', '#grpSel'].forEach(id => {
$(id).addEventListener('change', () => { renderStats(); saveSettings(); });
});
$('#numInput').addEventListener('change', saveSettings);
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
$('#balanceChk').addEventListener('change', 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 = [];
state.history = new Set();
renderResult();
renderStats();
saveState();
const stage = $('#drawStage');
stage.className = 'draw-stage';
stage.textContent = '点击「🎲 开始抽取」';
showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info');
});
// 清空历史(顶栏)
$('#btnClearLog').addEventListener('click', () => {
if (!state.log.length) return showAlert('暂无历史记录');
if (!confirm('确定清空全部 ' + state.log.length + ' 条历史记录?此操作不可恢复。')) return;
state.log = [];
saveLog();
renderLog();
renderStats();
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'));
}
});
}
/* ---------------- 启动 ---------------- */
document.addEventListener('DOMContentLoaded', async function () {
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';
$('#sourceSel').value = 'project';
$('#projectField').classList.remove('hidden');
}
bind();
renderResult();
renderStats();
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();
});
})();
+70
View File
@@ -585,3 +585,73 @@ body.dash.fs-on .group-compare { gap: 12px; }
body.dash.fs-on .gpanel { padding: 12px 14px; } body.dash.fs-on .gpanel { padding: 12px 14px; }
body.dash.fs-on .member-list { max-height: 30vh; } 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; }
}
/* ================= side 布局:两 panel 等高(缺考名单不再留大片空白) ================= */
/* 宽屏 side 模式:panel 变纵向 flex,表格区撑满剩余高度(行高由内容多的一侧决定) */
@media (min-width: 901px) {
.layout[data-layout="side"] > #tablePanel,
.layout[data-layout="side"] > #absentPanel {
display: flex; flex-direction: column;
}
.layout[data-layout="side"] > #tablePanel .table-scroll,
.layout[data-layout="side"] > #absentPanel .table-scroll {
flex: 1 1 auto; min-height: 0; max-height: none;
}
}
/* ================= 随机抽取:历史记录 ================= */
.log-list { display: flex; flex-direction: column; gap: 10px; }
.log-item {
border: 1px solid var(--border-2); border-radius: 10px;
background: #fbfcff; overflow: hidden;
}
.log-head {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 10px 14px; flex-wrap: wrap;
}
.log-summary { font-size: 13px; font-weight: 600; color: var(--text); }
.log-summary small { color: var(--text-3); font-weight: 500; }
.log-actions { display: flex; gap: 8px; flex: none; }
.log-detail summary {
cursor: pointer; user-select: none; padding: 7px 14px;
font-size: 12px; color: var(--primary); border-top: 1px dashed var(--border-2);
list-style: none;
}
.log-detail summary::-webkit-details-marker { display: none; }
.log-detail summary::before { content: '▸ '; }
.log-detail[open] summary::before { content: '▾ '; }
.log-detail[open] summary { border-bottom: 1px solid var(--border-2); }
.log-detail .table-scroll { max-height: 260px; border-radius: 0; }
+1
View File
@@ -333,6 +333,7 @@ function ny_format_duration(?int $sec): string
spl_autoload_register(function (string $class): void { spl_autoload_register(function (string $class): void {
$map = [ $map = [
'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php', 'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php',
'XlsxWriter' => NY_ROOT . '/lib/XlsxWriter.php',
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php', 'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
'Roster' => NY_ROOT . '/lib/Roster.php', 'Roster' => NY_ROOT . '/lib/Roster.php',
'DataService' => NY_ROOT . '/lib/DataService.php', 'DataService' => NY_ROOT . '/lib/DataService.php',
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* NY 问卷考试系统数据平台 - 随机抽取
* 从名单 / 已提交人员中随机抽取(支持分组均衡 5:5、排除名单、历史记录),
* 导出为卷王白名单(两列:姓名 / 登陆账号)
*/
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="btnClearLog" 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" title="有多个组别时按组别均衡抽取(如 A:B = 5:5),无组别时按班级均衡"><input type="checkbox" id="balanceChk" checked> 分组均衡</label>
<label class="chk" title="再次抽取时不与本次会话已抽中的人重复;「重置结果」后清零"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label>
<button class="btn" id="btnExclude" title="勾选不参与抽取的人">🚫 排除管理</button>
<button class="btn btn-primary" id="btnDraw">🎲 开始抽取</button>
<button class="btn" id="btnReset" title="清空本次结果与累计已抽取(历史记录保留)">重置结果</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-red"><span>已排除</span><b id="dExcluded">-</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="btnLog" title="查看历史抽取批次(重置结果后仍保留)">📜 历史 <b id="logBadge">0</b></button>
<button class="btn" id="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button>
<button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button>
</div>
</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:26%">学号</th>
<th style="width:22%">姓名</th>
<th>班级</th>
<th>组别</th>
<th style="width:90px">操作</th>
</tr>
</thead>
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody>
</table>
</div>
<p class="hint">两个下载均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列(缺失时回退学号),按文本格式保存,可直接导入卷王白名单。</p>
</section>
</main>
<!-- ================= 排除管理 ================= -->
<div class="modal" id="exclModal">
<div class="modal-box wide">
<div class="modal-head"><h3>抽取排除管理</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div class="roster-bar">
<input type="text" id="exclSearch" placeholder="搜索学号 / 姓名 / 班级">
<span class="spacer"></span>
<span class="hint" id="exclCount">已排除 0 人</span>
<button class="btn sm danger" id="btnClearExcl">清空排除</button>
</div>
<div class="table-scroll mid">
<table class="grid compact">
<thead><tr><th style="width:64px">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th></tr></thead>
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
</table>
</div>
<p class="hint">勾选后这些人不再参与抽取(勾选即时生效);仅影响随机抽取,不影响其他功能。</p>
</div>
<div class="modal-foot"><span class="spacer"></span><button class="btn btn-primary" data-close>完成</button></div>
</div>
</div>
<!-- ================= 历史记录 ================= -->
<div class="modal" id="logModal">
<div class="modal-box wide">
<div class="modal-head"><h3>抽取历史记录</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div id="logList" class="log-list"><p class="empty" style="text-align:center;padding:30px 0">暂无历史记录</p></div>
<p class="hint">每次抽取自动记一笔;「重置结果」不会清除历史记录,可随时恢复某一批次重新下载。</p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn btn-primary" data-close>关闭</button>
</div>
</div>
</div>
<script src="assets/draw.js"></script>
</body>
</html>
+1
View File
@@ -35,6 +35,7 @@ $rosterCount = Roster::count();
<div class="top-right"> <div class="top-right">
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div> <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="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="btnFullscreen" title="切换全屏">⛶ 全屏</button>
<button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button> <button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button>
<button class="btn btn-primary" id="btnSettings">设置</button> <button class="btn btn-primary" id="btnSettings">设置</button>
+45 -10
View File
@@ -164,26 +164,57 @@ class Db
} }
$pdo->exec($sql); $n++; $pdo->exec($sql); $n++;
// roster —— 名单表(学号/姓名/班级/组别) // roster —— 名单表(学号/姓名/班级/组别/登陆账号)
if ($driver === 'sqlite') { if ($driver === 'sqlite') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' . $sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' . 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' .
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32))'; 'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
} elseif ($driver === 'pgsql') { } elseif ($driver === 'pgsql') {
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' . $sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
'id SERIAL PRIMARY KEY, ' . 'id SERIAL PRIMARY KEY, ' .
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32))'; 'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
} else { } else {
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' . $sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
'id INT AUTO_INCREMENT PRIMARY KEY, ' . 'id INT AUTO_INCREMENT PRIMARY KEY, ' .
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32)) ' . 'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64)) ' .
'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
} }
$pdo->exec($sql); $n++; $pdo->exec($sql); $n++;
// 旧版本升级:补 login 列
self::ensureRosterLogin();
return $n; return $n;
} }
/** 旧表升级:ny_roster 缺 login 列时自动补(三方言兼容) */
public static function ensureRosterLogin(): void
{
$pdo = self::pdo();
if ($pdo === null) {
return;
}
$has = false;
if (self::$driver === 'sqlite') {
foreach ($pdo->query('PRAGMA table_info(ny_roster)')->fetchAll() as $c) {
if (strtolower((string)$c['name']) === 'login') {
$has = true;
break;
}
}
} elseif (self::$driver === 'pgsql') {
$st = $pdo->prepare("SELECT COUNT(*) FROM information_schema.columns " .
"WHERE table_name = 'ny_roster' AND column_name = 'login'");
$st->execute();
$has = (int)$st->fetchColumn() > 0;
} else {
$has = $pdo->query("SHOW COLUMNS FROM ny_roster LIKE 'login'")->fetch() !== false;
}
if (!$has) {
$pdo->exec('ALTER TABLE ny_roster ADD COLUMN login VARCHAR(64)');
}
}
/** /**
* 把所有 settings 读成数组 * 把所有 settings 读成数组
*/ */
@@ -227,14 +258,16 @@ class Db
if ($pdo === null) { if ($pdo === null) {
return []; return [];
} }
$stmt = $pdo->query('SELECT sid, name, cls, grp FROM ny_roster ORDER BY id'); self::ensureRosterLogin();
$stmt = $pdo->query('SELECT sid, name, cls, grp, login FROM ny_roster ORDER BY id');
$rows = []; $rows = [];
foreach ($stmt->fetchAll() as $r) { foreach ($stmt->fetchAll() as $r) {
$rows[] = [ $rows[] = [
'sid' => (string)$r['sid'], 'sid' => (string)$r['sid'],
'name' => (string)$r['name'], 'name' => (string)$r['name'],
'cls' => (string)$r['cls'], 'cls' => (string)$r['cls'],
'grp' => (string)$r['grp'], 'grp' => (string)$r['grp'],
'login' => (string)($r['login'] ?? ''),
]; ];
} }
return $rows; return $rows;
@@ -247,16 +280,18 @@ class Db
if ($pdo === null) { if ($pdo === null) {
return; return;
} }
self::ensureRosterLogin();
$pdo->beginTransaction(); $pdo->beginTransaction();
try { try {
$pdo->exec('DELETE FROM ny_roster'); $pdo->exec('DELETE FROM ny_roster');
$st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp) VALUES(?, ?, ?, ?)'); $st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp, login) VALUES(?, ?, ?, ?, ?)');
foreach ($students as $s) { foreach ($students as $s) {
$st->execute([ $st->execute([
(string)($s['sid'] ?? ''), (string)($s['sid'] ?? ''),
(string)($s['name'] ?? ''), (string)($s['name'] ?? ''),
(string)($s['cls'] ?? ''), (string)($s['cls'] ?? ''),
(string)($s['grp'] ?? ''), (string)($s['grp'] ?? ''),
(string)($s['login'] ?? ''),
]); ]);
} }
$pdo->commit(); $pdo->commit();
+25 -15
View File
@@ -35,10 +35,11 @@ class Roster
continue; continue;
} }
$out[] = [ $out[] = [
'sid' => trim((string)($s['sid'] ?? '')), 'sid' => trim((string)($s['sid'] ?? '')),
'name' => trim((string)($s['name'] ?? '')), 'name' => trim((string)($s['name'] ?? '')),
'cls' => trim((string)($s['cls'] ?? '')), 'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')), 'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
]; ];
} }
return $out; return $out;
@@ -63,10 +64,11 @@ class Roster
continue; continue;
} }
$clean[] = [ $clean[] = [
'sid' => $sid, 'sid' => $sid,
'name' => $name, 'name' => $name,
'cls' => trim((string)($s['cls'] ?? '')), 'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')), 'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
]; ];
} }
if (self::useDb()) { if (self::useDb()) {
@@ -191,15 +193,17 @@ class Roster
} }
/** /**
* 识别 学号/姓名/班级 列 * 识别 学号/姓名/班级/组别/登陆账号 列
* @return array<int,array{sid:string,name:string,cls:string}> * 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
*/ */
public static function rowsToStudents(array $rows): array public static function rowsToStudents(array $rows): array
{ {
$sidCol = -1; $sidCol = -1;
$nameCol = -1; $nameCol = -1;
$clsCol = -1; $clsCol = -1;
$grpCol = -1; $grpCol = -1;
$loginCol = -1;
$headerIdx = -1; $headerIdx = -1;
// 找表头 // 找表头
@@ -228,6 +232,9 @@ class Roster
if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|^\s*组\s*$|group/i', $c)) { if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|^\s*组\s*$|group/i', $c)) {
$grpCol = $j; $grpCol = $j;
} }
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
$loginCol = $j;
}
} }
} }
if ($sidCol < 0) { if ($sidCol < 0) {
@@ -249,7 +256,10 @@ class Roster
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid; $sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : ''; $cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
$grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : ''; $grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp]; $login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
// 登陆账号同样去掉 Excel 数字尾巴(手机号被读成 13912687810.0)
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
} }
return $out; return $out;
} }
+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';
}
}
+99
View File
@@ -0,0 +1,99 @@
// 测试随机抽取页的均衡分配算法(通过 stub 环境加载 assets/draw.js)
// 运行:node tools/test-draw-alloc.js
const fs = require('fs');
const path = require('path');
// stub 浏览器环境:draw.js 顶部只定义函数,尾部 document.addEventListener
global.window = {};
global.document = { addEventListener() {} };
global.localStorage = { getItem: () => null, setItem: () => {} };
global.location = {};
eval(fs.readFileSync(path.join(__dirname, '..', 'assets', 'draw.js'), 'utf8'));
const T = global.window.__nyDrawTest;
if (!T) { console.error('FAIL 未找到 __nyDrawTest 测试钩子'); process.exit(1); }
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
/* ---------- allocQuotas:名额均分(5:5 语义) ---------- */
// A/B = 12/8 抽 10 → 各 5(名额均分到组,与组人数无关)
chk('12/8 抽10 → 5/5', JSON.stringify(T.allocQuotas([12, 8], 10)) === '[5,5]',
JSON.stringify(T.allocQuotas([12, 8], 10)));
// 抽 1 人:余数小组优先
chk('抽1人 → 小组优先', JSON.stringify(T.allocQuotas([12, 8], 1)) === '[0,1]',
JSON.stringify(T.allocQuotas([12, 8], 1)));
// 12/8 抽 3 → 均分 1/1 + 余数给小组 → 1/2
chk('12/8 抽3 → 1/2', JSON.stringify(T.allocQuotas([12, 8], 3)) === '[1,2]',
JSON.stringify(T.allocQuotas([12, 8], 3)));
// 组容量不足:A 只有 1 人,B 有 19 人,抽 10 → A 1 + B 9
chk('容量不足差额再分配', JSON.stringify(T.allocQuotas([1, 19], 10)) === '[1,9]',
JSON.stringify(T.allocQuotas([1, 19], 10)));
// 全抽 → 每组全量
chk('全部抽取 = 各组全量', JSON.stringify(T.allocQuotas([12, 8], 20)) === '[12,8]',
JSON.stringify(T.allocQuotas([12, 8], 20)));
// n 超过总人数 → 全量
chk('超额抽取取全量', (() => {
const q = T.allocQuotas([3, 4], 99);
return JSON.stringify(q) === '[3,4]';
})(), JSON.stringify(T.allocQuotas([3, 4], 99)));
// 5:5 经典:A 20 / B 20 抽 10 → 5/5
chk('20/20 抽10 → 5/5', JSON.stringify(T.allocQuotas([20, 20], 10)) === '[5,5]',
JSON.stringify(T.allocQuotas([20, 20], 10)));
// 三组 10/10/10 抽 8 → 3/3/2(余数并列时稳定分配)
chk('三组 10/10/10 抽8', (() => {
const q = T.allocQuotas([10, 10, 10], 8);
return q.reduce((a, b) => a + b, 0) === 8 && Math.max(...q) - Math.min(...q) <= 1;
})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8)));
// 空组 / 0
chk('空数组返回空', JSON.stringify(T.allocQuotas([], 5)) === '[]');
// 名额总数守恒 + 不超容量 + 均衡(随机 fuzz)
let fuzzOk = true;
let fuzzBad = '';
for (let t = 0; t < 1000; t++) {
const g = 1 + Math.floor(Math.random() * 5);
const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30));
const n = Math.floor(Math.random() * 60);
const q = T.allocQuotas(sizes, n);
const sum = q.reduce((a, b) => a + b, 0);
const total = sizes.reduce((a, b) => a + b, 0);
const expect = Math.min(n, total);
if (sum !== expect) { fuzzOk = false; fuzzBad = `sum=${sum} expect=${expect} sizes=${sizes} n=${n}`; break; }
let bad = false;
for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true;
// 均衡性:所有组均未满时,任意两组名额差 ≤ 1
const allNotFull = q.every((v, i) => v < sizes[i]);
if (allNotFull && Math.max(...q) - Math.min(...q) > 1) bad = true;
if (bad) { fuzzOk = false; fuzzBad = `sizes=${JSON.stringify(sizes)} n=${n} q=${JSON.stringify(q)}`; break; }
}
chk('1000 组随机 fuzz:守恒+不超容量+均衡', fuzzOk, fuzzBad);
/* ---------- balanceKeyOf ---------- */
chk('有 A/B 组别 → 按 grp', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'B', cls: 'C1' }]) === 'grp');
chk('单组别 → 按班级', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C2' }]) === 'cls');
chk('无组别多班级 → 按班级', T.balanceKeyOf([{ grp: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls');
chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === '');
/* ---------- pickBalanced ---------- */
{
const pool = [];
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, name: 'A' + i, grp: 'A', cls: 'C' });
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' });
const r = T.pickBalanced(pool, 10, 'grp');
const byGrp = { A: 0, B: 0 };
r.picked.forEach(m => byGrp[m.grp]++);
chk('均衡抽取 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5, JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
chk('detail 文案', r.detail.join('/') === 'A 5人/B 5人', r.detail.join('/'));
chk('结果不重复', new Set(r.picked.map(m => m.sid)).size === 10);
}
{
const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }];
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp');
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
}
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
process.exit(fail === 0 ? 0 : 1);
+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);