- 移除登录失败限流;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
414 lines
18 KiB
PHP
414 lines
18 KiB
PHP
<?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';
|
||
}
|
||
}
|