Files
ny-exam-platform/lib/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

316 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
}
}