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:
2026-09-10 17:59:45 +08:00
parent 87750b20c4
commit 0fd880624f
14 changed files with 1784 additions and 196 deletions
+138 -46
View File
@@ -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>';
}