197 lines
8.3 KiB
C#
197 lines
8.3 KiB
C#
using System.Globalization;
|
||
using System.Reflection;
|
||
using NPOI.SS.UserModel;
|
||
|
||
namespace YD_Event.Application.Common.Excel;
|
||
|
||
/// <summary>
|
||
/// Excel 表头列名映射特性,标注属性所对应的表头单元格文本
|
||
/// </summary>
|
||
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
|
||
public sealed class ExcelHeaderAttribute : Attribute
|
||
{
|
||
/// <summary>
|
||
/// 表头文本
|
||
/// </summary>
|
||
public string Name { get; }
|
||
|
||
public ExcelHeaderAttribute(string name)
|
||
{
|
||
Name = name;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 基于 NPOI 的 Excel 读取器,统一支持 .xls(HSSF) 与 .xlsx(XSSF),按表头名称映射为对象
|
||
/// </summary>
|
||
public static class NpoiExcelReader
|
||
{
|
||
/// <summary>
|
||
/// 按文件流自动识别格式创建工作簿,调用方无需区分 .xls/.xlsx
|
||
/// </summary>
|
||
public static IWorkbook CreateWorkbook(Stream stream)
|
||
{
|
||
// 缓冲到内存,避免上传流不可寻址导致 NPOI 无法按文件头识别格式
|
||
var memoryStream = new MemoryStream();
|
||
stream.CopyTo(memoryStream);
|
||
memoryStream.Position = 0;
|
||
return WorkbookFactory.Create(memoryStream);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取指定名称的工作表
|
||
/// </summary>
|
||
public static List<T> ReadSheet<T>(IWorkbook workbook, string sheetName, int headerSearchRows = 10)
|
||
where T : new()
|
||
{
|
||
var sheet = workbook.GetSheet(sheetName)
|
||
?? throw new InvalidDataException($"缺少工作表:{sheetName}");
|
||
return ReadSheet<T>(sheet, headerSearchRows);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取指定序号的工作表(序号从0开始)
|
||
/// </summary>
|
||
public static List<T> ReadSheet<T>(IWorkbook workbook, int sheetIndex, int headerSearchRows = 10)
|
||
where T : new()
|
||
{
|
||
if (sheetIndex < 0 || sheetIndex >= workbook.NumberOfSheets)
|
||
throw new InvalidDataException($"工作表序号超出范围:{sheetIndex}");
|
||
return ReadSheet<T>(workbook.GetSheetAt(sheetIndex), headerSearchRows);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取工作表:在前若干行内定位表头行,按表头名称逐行映射为对象,整行为空则跳过
|
||
/// </summary>
|
||
public static List<T> ReadSheet<T>(ISheet sheet, int headerSearchRows = 10) where T : new()
|
||
{
|
||
var headerMap = BuildHeaderMap<T>();
|
||
var formatter = new DataFormatter();
|
||
|
||
var headerRow = LocateHeaderRow(sheet, headerMap.Keys, headerSearchRows, formatter)
|
||
?? throw new InvalidDataException($"工作表「{sheet.SheetName}」缺少表头");
|
||
|
||
// 列序号到属性的映射
|
||
var columnProperties = new Dictionary<int, PropertyInfo>();
|
||
foreach (var headerCell in headerRow.Cells)
|
||
{
|
||
var headerText = formatter.FormatCellValue(headerCell).Trim();
|
||
if (headerMap.TryGetValue(headerText, out var property))
|
||
columnProperties[headerCell.ColumnIndex] = property;
|
||
}
|
||
|
||
var result = new List<T>();
|
||
for (var rowIndex = headerRow.RowNum + 1; rowIndex <= sheet.LastRowNum; rowIndex++)
|
||
{
|
||
var row = sheet.GetRow(rowIndex);
|
||
if (row == null) continue;
|
||
|
||
var item = new T();
|
||
var hasValue = false;
|
||
foreach (var (columnIndex, property) in columnProperties)
|
||
{
|
||
var cell = row.GetCell(columnIndex);
|
||
// 以单元格原始文本是否为空判断整行是否有数据,避免值类型默认值0误判
|
||
var rawText = cell == null ? null : formatter.FormatCellValue(cell).Trim();
|
||
if (!string.IsNullOrWhiteSpace(rawText)) hasValue = true;
|
||
var value = ConvertCellValue(cell, property.PropertyType, formatter);
|
||
property.SetValue(item, value);
|
||
}
|
||
|
||
// 整行没有任何有效数据时视为空行跳过
|
||
if (hasValue) result.Add(item);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 构建表头文本到属性的映射
|
||
/// </summary>
|
||
private static Dictionary<string, PropertyInfo> BuildHeaderMap<T>()
|
||
{
|
||
var map = new Dictionary<string, PropertyInfo>();
|
||
foreach (var property in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||
{
|
||
var header = property.GetCustomAttribute<ExcelHeaderAttribute>();
|
||
if (header != null) map[header.Name] = property;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在表头搜索范围内,取命中映射表头数量最多的一行作为表头行
|
||
/// </summary>
|
||
private static IRow? LocateHeaderRow(ISheet sheet, ICollection<string> headers,
|
||
int searchRows, DataFormatter formatter)
|
||
{
|
||
var lastRow = Math.Min(sheet.LastRowNum, sheet.FirstRowNum + searchRows - 1);
|
||
IRow? matched = null;
|
||
var maxHits = 0;
|
||
for (var index = sheet.FirstRowNum; index <= lastRow; index++)
|
||
{
|
||
var row = sheet.GetRow(index);
|
||
if (row == null) continue;
|
||
var hits = row.Cells.Count(cell =>
|
||
headers.Contains(formatter.FormatCellValue(cell).Trim()));
|
||
if (hits > maxHits)
|
||
{
|
||
maxHits = hits;
|
||
matched = row;
|
||
}
|
||
}
|
||
return maxHits > 0 ? matched : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 单元格值按目标属性类型转换;空白单元格对引用/可空类型返回 null,对非空值类型返回默认值
|
||
/// </summary>
|
||
private static object? ConvertCellValue(ICell? cell, Type targetType, DataFormatter formatter)
|
||
{
|
||
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
|
||
var text = cell == null ? null : formatter.FormatCellValue(cell).Trim();
|
||
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return CreateDefaultValue(targetType);
|
||
|
||
if (underlyingType == typeof(string)) return text;
|
||
|
||
// 数值、日期型单元格优先取强类型值,避免格式化文本的千分位、小数位影响解析
|
||
if (cell is { CellType: CellType.Numeric })
|
||
{
|
||
if (DateUtil.IsCellDateFormatted(cell) && underlyingType == typeof(DateTime)
|
||
&& cell.DateCellValue is { } cellDate)
|
||
return cellDate;
|
||
|
||
var number = cell.NumericCellValue;
|
||
if (underlyingType == typeof(long)) return (long)Math.Round(number);
|
||
if (underlyingType == typeof(int)) return (int)Math.Round(number);
|
||
if (underlyingType == typeof(short)) return (short)Math.Round(number);
|
||
if (underlyingType == typeof(byte)) return (byte)Math.Round(number);
|
||
if (underlyingType == typeof(decimal)) return (decimal)number;
|
||
if (underlyingType == typeof(double)) return number;
|
||
if (underlyingType == typeof(float)) return (float)number;
|
||
}
|
||
|
||
if (underlyingType == typeof(long) && long.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var longValue)) return longValue;
|
||
if (underlyingType == typeof(int) && int.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue)) return intValue;
|
||
if (underlyingType == typeof(decimal) && decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue)) return decimalValue;
|
||
if (underlyingType == typeof(double) && double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var doubleValue)) return doubleValue;
|
||
if (underlyingType == typeof(DateTime) && DateTime.TryParse(text, CultureInfo.CurrentCulture, DateTimeStyles.None, out var dateTimeValue)) return dateTimeValue;
|
||
if (underlyingType == typeof(bool) && bool.TryParse(text, out var boolValue)) return boolValue;
|
||
if (underlyingType.IsEnum && Enum.TryParse(underlyingType, text, out var enumValue)) return enumValue;
|
||
|
||
// 无法转换时不抛异常中断整表导入:值类型给默认值,引用/可空类型给 null
|
||
return CreateDefaultValue(targetType);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成类型默认值:非空值类型返回 Activator 默认实例,其余返回 null
|
||
/// </summary>
|
||
private static object? CreateDefaultValue(Type targetType)
|
||
{
|
||
return targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null
|
||
? Activator.CreateInstance(targetType)
|
||
: null;
|
||
}
|
||
}
|