导出跟随分类筛选 - 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 的手写打包路径)
105 lines
4.1 KiB
PHP
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);
|