OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
269
app/Domain/Bom/Services/Excel.php
Normal file
269
app/Domain/Bom/Services/Excel.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Services;
|
||||
|
||||
/**
|
||||
* 轻量 Excel/CSV 解析器,不依赖 PhpSpreadsheet。
|
||||
*
|
||||
* 支持:
|
||||
* - .xlsx(解压 sharedStrings.xml + sheet1.xml,按列字母映射单元格)
|
||||
* - .csv(fgetcsv,自动探测 BOM)
|
||||
* - .xls 老格式不支持,报错提示另存为 .xlsx 或 .csv
|
||||
*
|
||||
* 返回:首行为表头,后续行为数据行。
|
||||
*/
|
||||
class Excel
|
||||
{
|
||||
/**
|
||||
* @return array{header:array<int,string>,rows:array<int,array<int,string>>}
|
||||
*/
|
||||
public function parse(string $path): array
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return match ($ext) {
|
||||
'xlsx' => $this->parseXlsx($path),
|
||||
'csv' => $this->parseCsv($path),
|
||||
default => throw new \InvalidArgumentException('仅支持 .xlsx 或 .csv 文件'),
|
||||
};
|
||||
}
|
||||
|
||||
private function parseCsv(string $path): array
|
||||
{
|
||||
$handle = fopen($path, 'r');
|
||||
if ($handle === false) {
|
||||
throw new \InvalidArgumentException('无法打开 CSV 文件');
|
||||
}
|
||||
|
||||
// 去掉 UTF-8 BOM
|
||||
$first = fgets($handle);
|
||||
if ($first === false) {
|
||||
fclose($handle);
|
||||
|
||||
return ['header' => [], 'rows' => []];
|
||||
}
|
||||
if (str_starts_with($first, "\xEF\xBB\xBF")) {
|
||||
$first = substr($first, 3);
|
||||
}
|
||||
|
||||
$header = str_getcsv($first);
|
||||
$rows = [];
|
||||
while (($line = fgetcsv($handle)) !== false) {
|
||||
// 跳过空行
|
||||
if (count($line) === 1 && trim($line[0]) === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = $line;
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return ['header' => $header, 'rows' => $rows];
|
||||
}
|
||||
|
||||
private function parseXlsx(string $path): array
|
||||
{
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new \InvalidArgumentException('无法打开 .xlsx 文件');
|
||||
}
|
||||
|
||||
// sharedStrings
|
||||
$shared = [];
|
||||
$ss = $zip->getFromName('xl/sharedStrings.xml');
|
||||
if ($ss !== false) {
|
||||
$xml = new \SimpleXMLElement($ss);
|
||||
foreach ($xml->si as $si) {
|
||||
$text = '';
|
||||
foreach ($si->t ?? [] as $t) {
|
||||
$text .= (string) $t;
|
||||
}
|
||||
$shared[] = $text;
|
||||
}
|
||||
}
|
||||
|
||||
// 第一个工作表
|
||||
$sheet = $zip->getFromName('xl/worksheets/sheet1.xml');
|
||||
if ($sheet === false) {
|
||||
$zip->close();
|
||||
throw new \InvalidArgumentException('未找到工作表');
|
||||
}
|
||||
|
||||
$xml = new \SimpleXMLElement($sheet);
|
||||
$rows = [];
|
||||
$header = [];
|
||||
|
||||
foreach ($xml->sheetData->row as $rowEl) {
|
||||
$cells = [];
|
||||
foreach ($rowEl->c as $c) {
|
||||
$ref = (string) $c['r']; // 如 "A1", "B2"
|
||||
$col = $this->colIndex($ref);
|
||||
$type = (string) $c['t'];
|
||||
|
||||
if ($type === 's') {
|
||||
// 共享字符串:v 是 sharedStrings 索引
|
||||
$v = (string) ($c->v ?? '');
|
||||
$cells[$col] = $shared[(int) $v] ?? '';
|
||||
} elseif ($type === 'inlineStr') {
|
||||
// 内联字符串(现代 Excel/WPS 常用):值在 <is><t> 里,富文本可能有多个 <t>
|
||||
$text = '';
|
||||
foreach ($c->is->t ?? [] as $t) {
|
||||
$text .= (string) $t;
|
||||
}
|
||||
$cells[$col] = $text;
|
||||
} else {
|
||||
// 数字/布尔/公式结果等
|
||||
$cells[$col] = (string) ($c->v ?? '');
|
||||
}
|
||||
}
|
||||
$line = [];
|
||||
$max = empty($cells) ? 0 : max(array_keys($cells));
|
||||
for ($i = 0; $i <= $max; $i++) {
|
||||
$line[$i] = $cells[$i] ?? '';
|
||||
}
|
||||
$rows[] = $line;
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
if (empty($rows)) {
|
||||
return ['header' => [], 'rows' => []];
|
||||
}
|
||||
|
||||
$header = array_shift($rows);
|
||||
|
||||
// 规范化表头:去空白
|
||||
$header = array_map(fn ($h) => trim((string) $h), $header);
|
||||
|
||||
return ['header' => $header, 'rows' => $rows];
|
||||
}
|
||||
|
||||
/** "A"->0, "B"->1, "AA"->26 ... */
|
||||
private function colIndex(string $ref): int
|
||||
{
|
||||
$letters = preg_replace('/\d/', '', $ref);
|
||||
$idx = 0;
|
||||
foreach (str_split($letters) as $ch) {
|
||||
$idx = $idx * 26 + (ord($ch) - ord('A') + 1);
|
||||
}
|
||||
|
||||
return $idx - 1;
|
||||
}
|
||||
|
||||
// ---- 导出(生成真正的 .xlsx,不依赖 PhpSpreadsheet) ----
|
||||
|
||||
/**
|
||||
* 生成 .xlsx 文件,返回临时文件路径。
|
||||
*
|
||||
* @param array<int,string> $header 表头
|
||||
* @param array<int,array<int,string>> $rows 数据行
|
||||
*/
|
||||
public function exportXlsx(string $sheetName, array $header, array $rows): string
|
||||
{
|
||||
$sheetName = mb_substr(preg_replace('/[\\/?*\[\]:]/', ' ', $sheetName) ?: 'Sheet1', 0, 31) ?: 'Sheet1';
|
||||
|
||||
$rowsXml = '';
|
||||
$rowNum = 1;
|
||||
|
||||
$rowsXml .= $this->buildRow($rowNum++, $header);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$cells = [];
|
||||
foreach ($row as $i => $v) {
|
||||
$cells[$i] = is_string($v) ? $v : (string) $v;
|
||||
}
|
||||
$rowsXml .= $this->buildRow($rowNum++, $cells);
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'bomexp');
|
||||
@unlink($tmp);
|
||||
$path = $tmp.'.xlsx';
|
||||
if ($zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('无法创建导出文件');
|
||||
}
|
||||
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<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"/>'
|
||||
.'</Types>');
|
||||
|
||||
$zip->addFromString('_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<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"/>'
|
||||
.'</Relationships>');
|
||||
|
||||
$zip->addFromString('xl/workbook.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
.'<sheets><sheet name="'.$this->xml($sheetName).'" sheetId="1" r:id="rId1"/></sheets>'
|
||||
.'</workbook>');
|
||||
|
||||
$zip->addFromString('xl/_rels/workbook.xml.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<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"/>'
|
||||
.'</Relationships>');
|
||||
|
||||
$zip->addFromString('xl/worksheets/sheet1.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>'
|
||||
.$rowsXml
|
||||
.'</sheetData></worksheet>');
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 .csv 文件,返回临时文件路径(带 UTF-8 BOM,Excel 打开不乱码)。
|
||||
*/
|
||||
public function exportCsv(array $header, array $rows): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'bomexp');
|
||||
$path = $tmp.'.csv';
|
||||
$fh = fopen($path, 'w');
|
||||
if ($fh === false) {
|
||||
throw new \RuntimeException('无法创建导出文件');
|
||||
}
|
||||
fwrite($fh, "\xEF\xBB\xBF");
|
||||
fputcsv($fh, $header);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($fh, array_map(fn ($v) => (string) $v, $row));
|
||||
}
|
||||
fclose($fh);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function buildRow(int $rowNum, array $cells): string
|
||||
{
|
||||
$xml = '<row r="'.$rowNum.'">';
|
||||
$col = 0;
|
||||
foreach ($cells as $v) {
|
||||
$ref = $this->colLetter($col).$rowNum;
|
||||
$xml .= '<c r="'.$ref.'" t="inlineStr"><is><t>'.$this->xml((string) $v).'</t></is></c>';
|
||||
$col++;
|
||||
}
|
||||
$xml .= '</row>';
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function colLetter(int $idx): string
|
||||
{
|
||||
$s = '';
|
||||
$idx++;
|
||||
while ($idx > 0) {
|
||||
$idx--;
|
||||
$s = chr(ord('A') + ($idx % 26)).$s;
|
||||
$idx = intdiv($idx, 26);
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
private function xml(string $s): string
|
||||
{
|
||||
return htmlspecialchars($s, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user