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

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

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

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

105 lines
4.1 KiB
PHP

<?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);