190 lines
6.9 KiB
C#
190 lines
6.9 KiB
C#
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<string> headers,
|
|
IEnumerable<IReadOnlyList<object?>> rows,
|
|
IReadOnlyList<string>? instructions = null,
|
|
Action<IXLWorksheet, int>? 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<IReadOnlyList<ExcelRow>> ReadAsync(
|
|
IFormFile file,
|
|
IReadOnlyCollection<string> 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<string, int>(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<ExcelRow>();
|
|
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<string, string> 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);
|