feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
+187
-23
@@ -5,7 +5,10 @@
|
||||
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
|
||||
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
|
||||
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
|
||||
* - 第一行固定为表头(加粗 + 居中)
|
||||
* - 表头行(默认第 1 行,可用 header_rows 指定多行)加粗 + 居中 + 边框
|
||||
* - 支持合并单元格(merges),被合并的非首格写「空值 + 同样的样式」,保证合并区域四周边框完整
|
||||
* - 支持单元格级样式(cell_styles):按 [行,列] 指定「红字 / 绿底」等标记,
|
||||
* 用于"每场最高分标红、合格次数达标标绿"
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
@@ -17,13 +20,26 @@ final class XlsxWriter
|
||||
private array $strings = [];
|
||||
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
|
||||
private array $strCount = [];
|
||||
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
|
||||
/** @var array<int,array{name:string,rows:array,widths:array,merges:array}> 工作表(按输出顺序) */
|
||||
private array $sheets = [];
|
||||
|
||||
// 样式索引(见 stylesXml)
|
||||
private const S_DEFAULT = 0; // 常规
|
||||
private const S_HEADER = 1; // 表头:加粗 + 居中
|
||||
private const S_TEXT = 2; // 文本格式(numFmtId=49)
|
||||
private const S_DEFAULT = 0; // 常规(无边框)
|
||||
private const S_HEADER = 1; // 表头:加粗 + 居中 + 细边框
|
||||
private const S_TEXT = 2; // 数据:文本格式(numFmtId=49)+ 细边框
|
||||
private const S_RED = 3; // 数据 + 红字(最高分标记)
|
||||
private const S_GREEN = 4; // 数据 + 绿底(合格次数达标标记)
|
||||
private const S_HEADER_GREEN = 5; // 表头样式 + 绿底(合格次数行的表头格)
|
||||
|
||||
/**
|
||||
* 语义化标记 => 样式索引。调用方只管语义('red' / 'green' / 'header_green'),
|
||||
* 具体配色编号由本类统一维护,避免外部硬编码样式下标。
|
||||
*/
|
||||
private const MARK_STYLES = [
|
||||
'red' => self::S_RED,
|
||||
'green' => self::S_GREEN,
|
||||
'header_green' => self::S_HEADER_GREEN,
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成单表 xlsx 二进制内容(兼容旧调用)
|
||||
@@ -48,7 +64,11 @@ final class XlsxWriter
|
||||
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
|
||||
* 当成公式执行(天然规避 CSV 公式注入问题)。
|
||||
*
|
||||
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...]]
|
||||
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...], 'merges'=>[[r1,c1,r2,c2],...], 'header_rows'=>int,
|
||||
* 'cell_styles'=>['r,c'=>'red'|'green'|'header_green', ...]]
|
||||
* merges 为 1-based 闭区间(含表头行),如 [[2,1,4,1]] = A2:A4 纵向合并
|
||||
* header_rows 表头行数(默认 1;两行表头的交叉表用 2)
|
||||
* cell_styles 的键为 1-based 的 "行,列",值为语义标记名(未知标记静默忽略)
|
||||
* @return string xlsx 文件二进制
|
||||
*/
|
||||
public static function renderMulti(array $sheets): string
|
||||
@@ -63,18 +83,93 @@ final class XlsxWriter
|
||||
continue; // 空表不输出,避免 Excel 提示修复
|
||||
}
|
||||
$w->sheets[] = [
|
||||
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
|
||||
'rows' => $rows,
|
||||
'widths' => array_values((array)($sh['widths'] ?? [])),
|
||||
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
|
||||
'rows' => $rows,
|
||||
'widths' => array_values((array)($sh['widths'] ?? [])),
|
||||
'merges' => self::normalizeMerges((array)($sh['merges'] ?? []), count($rows), self::maxCols($rows)),
|
||||
'header_rows' => max(1, (int)($sh['header_rows'] ?? 1)),
|
||||
'cell_styles' => self::normalizeCellStyles((array)($sh['cell_styles'] ?? [])),
|
||||
];
|
||||
}
|
||||
if ($w->sheets === []) {
|
||||
// 保底:至少输出一张空表
|
||||
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => []];
|
||||
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => [], 'merges' => [], 'header_rows' => 1, 'cell_styles' => []];
|
||||
}
|
||||
return $w->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化单元格样式表:键 "行,列"(1-based)=> 样式索引;未知标记直接丢弃。
|
||||
*
|
||||
* @param array $raw ['3,5' => 'red', [4,5] => 'green', ...](两种写法都接受)
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private static function normalizeCellStyles(array $raw): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($raw as $k => $mark) {
|
||||
if (is_array($k) && count($k) >= 2) {
|
||||
$key = ((int)$k[0]) . ',' . ((int)$k[1]);
|
||||
} else {
|
||||
$key = (string)$k;
|
||||
if (!preg_match('/^\s*(\d+)\s*,\s*(\d+)\s*$/', $key, $m)) {
|
||||
continue;
|
||||
}
|
||||
$key = ((int)$m[1]) . ',' . ((int)$m[2]);
|
||||
}
|
||||
$idx = self::MARK_STYLES[(string)$mark] ?? null;
|
||||
if ($idx === null) {
|
||||
continue; // 未知标记:不写样式,不报错
|
||||
}
|
||||
[$r, $c] = array_map('intval', explode(',', $key));
|
||||
if ($r < 1 || $c < 1) {
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $idx;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 二维表最大列数(至少 1) */
|
||||
private static function maxCols(array $rows): int
|
||||
{
|
||||
$max = 1;
|
||||
foreach ($rows as $r) {
|
||||
$max = max($max, count((array)$r));
|
||||
}
|
||||
return $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化合并区间:过滤非法 / 退化(单格)区间,并按行列排序,保证 Excel 不报修复提示。
|
||||
*
|
||||
* @param array $raw [[r1,c1,r2,c2], ...] 1-based
|
||||
* @return array<int,array{0:int,1:int,2:int,3:int}>
|
||||
*/
|
||||
private static function normalizeMerges(array $raw, int $maxRow, int $maxCol): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($raw as $m) {
|
||||
if (!is_array($m) || count($m) < 4) {
|
||||
continue;
|
||||
}
|
||||
$r1 = (int)$m[0]; $c1 = (int)$m[1]; $r2 = (int)$m[2]; $c2 = (int)$m[3];
|
||||
if ($r1 > $r2) { [$r1, $r2] = [$r2, $r1]; }
|
||||
if ($c1 > $c2) { [$c1, $c2] = [$c2, $c1]; }
|
||||
if ($r1 < 1 || $c1 < 1 || $r2 > $maxRow || $c2 > $maxCol) {
|
||||
continue; // 越界丢弃
|
||||
}
|
||||
if ($r1 === $r2 && $c1 === $c2) {
|
||||
continue; // 单格无需合并
|
||||
}
|
||||
$out[] = [$r1, $c1, $r2, $c2];
|
||||
}
|
||||
usort($out, static function (array $a, array $b): int {
|
||||
return [$a[0], $a[1], $a[2], $a[3]] <=> [$b[0], $b[1], $b[2], $b[3]];
|
||||
});
|
||||
return $out;
|
||||
}
|
||||
|
||||
/* ---------------- 内部:构建 ---------------- */
|
||||
|
||||
private function build(): string
|
||||
@@ -103,7 +198,15 @@ final class XlsxWriter
|
||||
$cells[$r][$c] = $this->intern((string)($val ?? ''));
|
||||
}
|
||||
}
|
||||
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml($cells, $maxRow, $maxCol, $sh['widths']);
|
||||
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml(
|
||||
$cells,
|
||||
$maxRow,
|
||||
$maxCol,
|
||||
$sh['widths'],
|
||||
$sh['merges'] ?? [],
|
||||
(int)($sh['header_rows'] ?? 1),
|
||||
(array)($sh['cell_styles'] ?? [])
|
||||
);
|
||||
$names[] = $sh['name'];
|
||||
}
|
||||
|
||||
@@ -165,7 +268,7 @@ final class XlsxWriter
|
||||
return $i;
|
||||
}
|
||||
|
||||
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = []): string
|
||||
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = [], array $merges = [], int $headerRows = 1, array $cellStyles = []): string
|
||||
{
|
||||
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
|
||||
|
||||
@@ -175,18 +278,49 @@ final class XlsxWriter
|
||||
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
|
||||
}
|
||||
|
||||
// 合并区域的非首格不能带值(否则 Excel 打开会提示"修复"),
|
||||
// 但仍要写成「空值 + 带样式的格子」,否则合并区域的边框会在 Excel 里缺边。
|
||||
$swallowed = [];
|
||||
foreach ($merges as $m) {
|
||||
for ($r = $m[0]; $r <= $m[2]; $r++) {
|
||||
for ($c = $m[1]; $c <= $m[3]; $c++) {
|
||||
if ($r === $m[0] && $c === $m[1]) {
|
||||
continue;
|
||||
}
|
||||
$swallowed[$r][$c] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$data = '';
|
||||
for ($r = 1; $r <= $maxRow; $r++) {
|
||||
$baseStyle = $r <= $headerRows ? self::S_HEADER : self::S_TEXT;
|
||||
$line = '';
|
||||
$has = false;
|
||||
for ($c = 1; $c <= $maxCol; $c++) {
|
||||
if (!isset($cells[$r][$c])) {
|
||||
$sw = isset($swallowed[$r][$c]);
|
||||
if (!isset($cells[$r][$c]) && !$sw) {
|
||||
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>';
|
||||
$ref = self::colName($c) . $r;
|
||||
// 单元格级标记(红字 / 绿底)优先;合并区间跟随左上角首格的样式,
|
||||
// 否则"合并成一格但只有左边半截有底色"会很难看。
|
||||
$mark = $cellStyles[$r . ',' . $c] ?? null;
|
||||
if ($mark === null && $sw) {
|
||||
foreach ($merges as $m) {
|
||||
if ($r >= $m[0] && $r <= $m[2] && $c >= $m[1] && $c <= $m[3]) {
|
||||
$mark = $cellStyles[$m[0] . ',' . $m[1]] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$style = ($mark !== null) ? (int)$mark : $baseStyle;
|
||||
if ($sw) {
|
||||
$line .= '<c r="' . $ref . '" s="' . $style . '"/>';
|
||||
} else {
|
||||
$line .= '<c r="' . $ref . '" s="' . $style . '" t="s"><v>' . $cells[$r][$c] . '</v></c>';
|
||||
}
|
||||
}
|
||||
if (!$has) {
|
||||
continue;
|
||||
@@ -194,6 +328,15 @@ final class XlsxWriter
|
||||
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
|
||||
}
|
||||
|
||||
$mergeXml = '';
|
||||
if ($merges !== []) {
|
||||
$mergeXml = '<mergeCells count="' . count($merges) . '">';
|
||||
foreach ($merges as $m) {
|
||||
$mergeXml .= '<mergeCell ref="' . self::colName($m[1]) . $m[0] . ':' . self::colName($m[3]) . $m[2] . '"/>';
|
||||
}
|
||||
$mergeXml .= '</mergeCells>';
|
||||
}
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||
'<dimension ref="' . $dim . '"/>' .
|
||||
@@ -201,6 +344,7 @@ final class XlsxWriter
|
||||
'<sheetFormatPr defaultRowHeight="13.5"/>' .
|
||||
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
|
||||
'<sheetData>' . $data . '</sheetData>' .
|
||||
$mergeXml .
|
||||
'</worksheet>';
|
||||
}
|
||||
|
||||
@@ -273,18 +417,38 @@ final class XlsxWriter
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||
// numFmts: 49 = @(文本)
|
||||
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
|
||||
'<fonts count="2">' .
|
||||
// 字体:0 常规 / 1 加粗(表头)/ 2 红色(最高分)/ 3 加粗绿字
|
||||
'<fonts count="3">' .
|
||||
'<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>' .
|
||||
'<font><sz val="11"/><color rgb="FFD0021B"/><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>' .
|
||||
// 填充:0 none / 1 gray125(Excel 约定占位)/ 2 合格绿底 / 3 表头绿底
|
||||
// 绿色沿用导出截图里的浅绿 (#63C384 系):既醒目又不至于盖住黑色文字
|
||||
'<fills count="4">' .
|
||||
'<fill><patternFill patternType="none"/></fill>' .
|
||||
'<fill><patternFill patternType="gray125"/></fill>' .
|
||||
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
|
||||
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
|
||||
'</fills>' .
|
||||
// 0 = 无边框,1 = 细灰边框(表格统一描边,打印/截图都清晰)
|
||||
'<borders count="2">' .
|
||||
'<border><left/><right/><top/><bottom/><diagonal/></border>' .
|
||||
'<border>' .
|
||||
'<left style="thin"><color rgb="FF808080"/></left>' .
|
||||
'<right style="thin"><color rgb="FF808080"/></right>' .
|
||||
'<top style="thin"><color rgb="FF808080"/></top>' .
|
||||
'<bottom style="thin"><color rgb="FF808080"/></bottom>' .
|
||||
'<diagonal/></border>' .
|
||||
'</borders>' .
|
||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' .
|
||||
'<cellXfs count="3">' .
|
||||
'<cellXfs count="6">' .
|
||||
'<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 文本
|
||||
'<xf numFmtId="0" fontId="1" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
|
||||
'<xf numFmtId="49" fontId="0" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyBorder="1"><alignment vertical="center"/></xf>' . // 2 文本
|
||||
'<xf numFmtId="49" fontId="2" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 3 红字(最高分)
|
||||
'<xf numFmtId="49" fontId="0" fillId="2" borderId="1" xfId="0" applyNumberFormat="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 4 绿底(合格次数达标)
|
||||
'<xf numFmtId="0" fontId="1" fillId="3" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 5 表头绿底
|
||||
'</cellXfs>' .
|
||||
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
|
||||
'</styleSheet>';
|
||||
|
||||
Reference in New Issue
Block a user