feat: 完善/优化/新增大批次 + 安全加固 + 性能优化
- 修复再次提交不置顶(排序键改用 last_submit_ts) - 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效) - 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列 - 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列 - 安全:CSRF token + 登录失败限流(5次锁10分钟) - 未匹配答卷一键加入名单(unmatched_map) - 缺考名单增强:复制学号/姓名、按班统计、打印 - 成绩快照 + 跨卷趋势(Snapshot) - 异常检测线索(短时长高分/高频重考/雷同成绩) - 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避 - 测试沉淀:test-new-features.php + run-all.sh
This commit is contained in:
+138
-46
@@ -15,10 +15,8 @@ final class XlsxWriter
|
||||
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 = [];
|
||||
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
|
||||
private array $sheets = [];
|
||||
|
||||
// 样式索引(见 stylesXml)
|
||||
private const S_DEFAULT = 0; // 常规
|
||||
@@ -26,57 +24,132 @@ final class XlsxWriter
|
||||
private const S_TEXT = 2; // 文本格式(numFmtId=49)
|
||||
|
||||
/**
|
||||
* 生成 xlsx 二进制内容
|
||||
* 生成单表 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();
|
||||
$w->rows = array_values($rows);
|
||||
$w->widths = array_values((array)($opts['widths'] ?? []));
|
||||
return $w->build((string)($opts['title'] ?? 'Sheet1'));
|
||||
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 $title): string
|
||||
private function build(): 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,
|
||||
'[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)) {
|
||||
@@ -87,13 +160,13 @@ final class XlsxWriter
|
||||
return $this->strIndex[$s];
|
||||
}
|
||||
|
||||
private function sheetXml(array $cells, int $maxRow, int $maxCol): string
|
||||
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 = $this->widths[$c - 1] ?? 16;
|
||||
$w = $widths[$c - 1] ?? 16;
|
||||
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
|
||||
}
|
||||
|
||||
@@ -126,14 +199,19 @@ final class XlsxWriter
|
||||
'</worksheet>';
|
||||
}
|
||||
|
||||
private function contentTypesXml(): string
|
||||
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"/>' .
|
||||
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+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"/>' .
|
||||
@@ -151,22 +229,36 @@ final class XlsxWriter
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
private function workbookXml(string $title): string
|
||||
/** @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><sheet name="' . self::xml($title) . '" sheetId="1" r:id="rId1"/></sheets>' .
|
||||
'<sheets>' . $sheets . '</sheets>' .
|
||||
'</workbook>';
|
||||
}
|
||||
|
||||
private function workbookRelsXml(): string
|
||||
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">' .
|
||||
'<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"/>' .
|
||||
$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>';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user