using ClosedXML.Excel; namespace Jiaowu.Api.Infrastructure.Excel; public static class ExcelWorkbookHelper { public const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; public static byte[] Create( string sheetName, IReadOnlyList headers, IEnumerable> rows, IReadOnlyList? instructions = null, Action? configureRow = null) { using var workbook = new XLWorkbook(); var sheet = workbook.Worksheets.Add(sheetName); for (var column = 0; column < headers.Count; column++) { var cell = sheet.Cell(1, column + 1); cell.Value = headers[column]; cell.Style.Font.Bold = true; cell.Style.Font.FontColor = XLColor.White; cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#1F3A6D"); cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; } var rowNumber = 2; foreach (var row in rows) { for (var column = 0; column < headers.Count && column < row.Count; column++) { SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]); } configureRow?.Invoke(sheet, rowNumber); rowNumber++; } sheet.SheetView.FreezeRows(1); sheet.RangeUsed()?.SetAutoFilter(); sheet.Columns().AdjustToContents(10, 38); sheet.Rows(1, Math.Max(1, rowNumber - 1)).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; if (instructions is { Count: > 0 }) { var guide = workbook.Worksheets.Add("填写说明", 1); guide.Cell("A1").Value = $"{sheetName} Excel 导入说明"; guide.Cell("A1").Style.Font.Bold = true; guide.Cell("A1").Style.Font.FontSize = 16; guide.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#1F3A6D"); for (var index = 0; index < instructions.Count; index++) { guide.Cell(index + 3, 1).Value = $"{index + 1}. {instructions[index]}"; } guide.Column(1).Width = 92; guide.Style.Alignment.WrapText = true; } using var stream = new MemoryStream(); workbook.SaveAs(stream); return stream.ToArray(); } public static async Task> ReadAsync( IFormFile file, IReadOnlyCollection requiredHeaders, CancellationToken cancellationToken) { if (file.Length == 0) throw new InvalidDataException("请选择包含数据的 Excel 文件。"); if (file.Length > 10 * 1024 * 1024) throw new InvalidDataException("Excel 文件不能超过 10 MB。"); if (!string.Equals(Path.GetExtension(file.FileName), ".xlsx", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("仅支持 .xlsx 格式,请先下载模板填写。"); await using var stream = new MemoryStream(); await file.CopyToAsync(stream, cancellationToken); stream.Position = 0; try { using var workbook = new XLWorkbook(stream); var sheet = workbook.Worksheets .FirstOrDefault(x => !string.Equals( x.Name, "填写说明", StringComparison.OrdinalIgnoreCase)); if (sheet is null) throw new InvalidDataException("Excel 中没有可导入的数据工作表。"); var lastColumn = sheet.LastColumnUsed()?.ColumnNumber() ?? 0; var headerMap = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var column = 1; column <= lastColumn; column++) { var header = sheet.Cell(1, column).GetString().Trim(); if (header.Length > 0) headerMap.TryAdd(header, column); } var missing = requiredHeaders.Where(x => !headerMap.ContainsKey(x)).ToArray(); if (missing.Length > 0) throw new InvalidDataException($"缺少必需列:{string.Join("、", missing)}。"); var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1; if (lastRow > 2001) throw new InvalidDataException("单次最多导入 2000 条数据。"); var result = new List(); for (var rowNumber = 2; rowNumber <= lastRow; rowNumber++) { var values = headerMap.ToDictionary( pair => pair.Key, pair => GetCellText(sheet.Cell(rowNumber, pair.Value)), StringComparer.OrdinalIgnoreCase); if (values.Values.All(string.IsNullOrWhiteSpace)) continue; result.Add(new ExcelRow(rowNumber, values)); } return result; } catch (InvalidDataException) { throw; } catch (Exception exception) { throw new InvalidDataException( "无法读取 Excel 文件,请确认文件未损坏且使用系统模板填写。", exception); } } private static string GetCellText(IXLCell cell) { if (cell.DataType == XLDataType.DateTime) return cell.GetDateTime().ToString("yyyy-MM-dd"); if (cell.DataType == XLDataType.Number) return cell.GetDouble().ToString("0.################"); return cell.GetFormattedString().Trim(); } private static void SetCellValue(IXLCell cell, object? value) { switch (value) { case null: cell.Value = string.Empty; break; case DateOnly date: cell.Value = date.ToDateTime(TimeOnly.MinValue); cell.Style.DateFormat.Format = "yyyy-mm-dd"; break; case DateTime dateTime: cell.Value = dateTime; cell.Style.DateFormat.Format = "yyyy-mm-dd hh:mm"; break; case bool boolean: cell.Value = boolean ? "是" : "否"; break; case int integer: cell.Value = integer; break; case long integer: cell.Value = integer; break; case decimal number: cell.Value = number; break; case double number: cell.Value = number; break; default: cell.Value = value.ToString() ?? string.Empty; break; } } } public sealed record ExcelRow( int RowNumber, IReadOnlyDictionary Values) { public string this[string header] => Values.TryGetValue(header, out var value) ? value.Trim() : string.Empty; } public sealed record ExcelImportResult(int Created, int Updated, int Total);