Files
ny-exam-platform/lib/XlsxWriter.php
T
nxxy335 94ac381579 完成批次 L-R:限流移除/白名单驱动/打印增强/全屏/表格对齐/主题变量/滚动闪烁修复
- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接

- 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey)

- 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加

- 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则

- 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致

- 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放

- 测试:新增 test-whitelist.js;run-all.sh 全绿 132
2026-09-13 10:25:48 +08:00

414 lines
18 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,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
private array $strCount = [];
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
private array $sheets = [];
// 样式索引(见 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
{
return self::renderMulti([[
'name' => (string)($opts['title'] ?? 'Sheet1'),
'rows' => $rows,
'widths' => array_values((array)($opts['widths'] ?? [])),
]]);
}
/**
* 生成多工作表 xlsx
*
* 说明:所有单元格都写成「共享字符串 + 文本格式(numFmtId=49)」,
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
* 当成公式执行(天然规避 CSV 公式注入问题)。
*
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...]]
* @return string xlsx 文件二进制
*/
public static function renderMulti(array $sheets): string
{
$w = new self();
foreach ($sheets as $sh) {
if (!is_array($sh)) {
continue;
}
$rows = array_values((array)($sh['rows'] ?? []));
if ($rows === []) {
continue; // 空表不输出,避免 Excel 提示修复
}
$w->sheets[] = [
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
'rows' => $rows,
'widths' => array_values((array)($sh['widths'] ?? [])),
];
}
if ($w->sheets === []) {
// 保底:至少输出一张空表
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => []];
}
return $w->build();
}
/* ---------------- 内部:构建 ---------------- */
private function build(): string
{
$files = [
'[Content_Types].xml' => '', // 占位,下面按表数量生成
];
$sheetXmls = [];
$names = [];
foreach ($this->sheets as $i => $sh) {
$n = $i + 1;
$maxCol = 1;
foreach ($sh['rows'] as $r) {
$maxCol = max($maxCol, count((array)$r));
}
$maxRow = max(1, count($sh['rows']));
// 登记共享字符串并生成单元格索引
$cells = [];
foreach (array_values($sh['rows']) as $ri => $row) {
$r = (int)$ri + 1;
foreach (array_values((array)$row) as $ci => $val) {
$c = (int)$ci + 1;
$cells[$r][$c] = $this->intern((string)($val ?? ''));
}
}
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml($cells, $maxRow, $maxCol, $sh['widths']);
$names[] = $sh['name'];
}
$count = count($this->sheets);
$files = [
'[Content_Types].xml' => $this->contentTypesXml($count),
'_rels/.rels' => $this->rootRelsXml(),
'docProps/app.xml' => $this->appXml(),
'docProps/core.xml' => $this->coreXml($names[0] ?? 'Sheet1'),
'xl/workbook.xml' => $this->workbookXml($names),
'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml($count),
'xl/styles.xml' => $this->stylesXml(),
'xl/sharedStrings.xml' => $this->sharedStringsXml(),
] + $sheetXmls;
return $this->zip($files);
}
/**
* 工作表名安全化:Excel 限制 31 字符、禁止 []:*?/\、不可重复、不可为空
*/
private static function safeSheetName(string $name, int $seq, array $existing): string
{
$bad = ['\\', '/', '?', '*', '[', ']', ':'];
$n = trim(str_replace($bad, '_', $name));
if ($n === '') {
$n = 'Sheet' . $seq;
}
if (function_exists('mb_substr')) {
$n = mb_substr($n, 0, 31, 'UTF-8');
} else {
$n = substr($n, 0, 31);
}
// 去重
$base = $n;
$i = 2;
$taken = [];
foreach ($existing as $e) {
$taken[strtolower($e['name'])] = true;
}
while (isset($taken[strtolower($n)])) {
$suffix = '(' . $i . ')';
$n = (function_exists('mb_substr') ? mb_substr($base, 0, 31 - strlen($suffix), 'UTF-8') : substr($base, 0, 31 - strlen($suffix))) . $suffix;
$i++;
}
return $n;
}
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;
$this->strCount[$i] = 0;
}
$i = $this->strIndex[$s];
$this->strCount[$i] = ($this->strCount[$i] ?? 0) + 1;
return $i;
}
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = []): string
{
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
$cols = '';
for ($c = 1; $c <= $maxCol; $c++) {
$w = $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(int $sheetCount): string
{
$overrides = '';
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
$overrides .= '<Override PartName="/xl/worksheets/sheet' . $i . '.xml" ' .
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
}
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"/>' .
$overrides .
'<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>';
}
/** @param array<int,string> $names 工作表名(已安全化) */
private function workbookXml(array $names): string
{
$sheets = '';
foreach (array_values($names) as $i => $nm) {
$n = (int)$i + 1;
$sheets .= '<sheet name="' . self::xml((string)$nm) . '" sheetId="' . $n . '" r:id="rId' . $n . '"/>';
}
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>' . $sheets . '</sheets>' .
'</workbook>';
}
private function workbookRelsXml(int $sheetCount): string
{
$rels = '';
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
$rels .= '<Relationship Id="rId' . $i . '" ' .
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' .
'Target="worksheets/sheet' . $i . '.xml"/>';
}
$stylesId = max(1, $sheetCount) + 1;
$sstId = max(1, $sheetCount) + 2;
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
$rels .
'<Relationship Id="rId' . $stylesId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
'<Relationship Id="rId' . $sstId . '" 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
{
$unique = count($this->strings);
$total = array_sum($this->strCount);
$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="' . $total .
'" uniqueCount="' . $unique . '">' . $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';
}
}