NET NPOI 实战:生成带下拉、数据校验、必填标红的 Excel 导入模板

**关键词:**NPOI、XSSFWorkbook、Excel 导入模板、数据验证、下拉列表、富文本、ABP vNext、\.NET

**适用场景:**后台管理系统中,需要业务人员按模板批量导入订单、物料、客户等数据时,提升填写体验、减少返工。

一、前言

在企业级后台系统中,Excel 批量导入几乎是标配功能。但很多人做的模板只是导出一行表头,业务人员填完上传后,服务端再返回一堆"第 N 行 XX 不能为空"的错误,体验非常差。

一个**体验良好的导入模板**,应该在用户填写的当下就把错误拦下来。本文分享一套生产环境可用的 NPOI(XSSF,xlsx 格式)代码,实现以下能力:

  • 表头必填项 **\*** 单独标红(富文本);

  • 动态下拉数据源(来自远程接口),通过隐藏 Sheet 承载;

  • 枚举类型固定下拉(纱线类型);

  • 单元格格式预置:文本(保留前导零)、两位小数、日期;

  • 数据校验:数量大于 0、日期区间、结束交期 ≥ 同行开始交期;

  • 校验失败时弹出友好中文错误提示。

二、最终效果

|列|效果|

|---|---|

|**A 客户订单号**|文本格式,保留前导零(如 099)|

|**D 订单品种**|下拉选择,数据来自隐藏 Sheet「下拉数据」A 列|

|**E 生产品种**|下拉选择,数据来自隐藏 Sheet「下拉数据」B 列|

|**F 纱线类型**|枚举生成的固定下拉列表|

|**G 数量(吨)**|两位小数,必须 \> 0|

|**H 开始交期**|日期格式 yyyy\-mm\-dd,限定 2000\-01\-01 \~ 2100\-12\-31|

|**I 结束交期**|日期格式,且必须 ≥ 同行 H 列开始交期|

三、完整代码

下面是一个 ABP vNext 应用服务方法,返回 `byte\[\]` 供 Controller 直接包装成文件下载。

```csharp

public virtual async Task<byte\[\]> GetImportTemplateAsync()

{

// ===== 1. 获取下拉数据源(远程接口) =====

var varietyOrders = (await _remotePlantDataAppService.GetVarietyOrders()).Items;

var pullResult = await _remotePlantDataAppService.PullChangeDatasourceAsync();

var productionData = pullResult.data;

// 订单品种名称:去空格、去空、去重

var varietyNames = varietyOrders

.Select(x => x.Name?.Trim())

.Where(x => !string.IsNullOrEmpty(x))

.Distinct()

.ToList();

// 生产品种编码:去空格、去空、去重

var productionCodes = productionData

.Select(x => x.Code?.Trim())

.Where(x => !string.IsNullOrEmpty(x))

.Distinct()

.ToList();

using var workbook = new XSSFWorkbook();

var sheet = workbook.CreateSheet("订单导入");

// 表头加粗字体

var headerFont = workbook.CreateFont();

headerFont.IsBold = true;

var headerStyle = workbook.CreateCellStyle();

headerStyle.SetFont(headerFont);

// 红色加粗字体(仅用于 * 号)

var redFont = workbook.CreateFont();

redFont.IsBold = true;

redFont.Color = IndexedColors.Red.Index;

// 表头定义,* 表示必填

string\[\] headers =

{

"客户订单号", "客户名称*", "负责人", "订单品种*", "生产品种*", "纱线类型*",

"数量(吨)*", "开始交期*", "结束交期*", "备注"

};

// ===== 2. 写表头:用富文本把 * 单独染红 =====

var headerRow = sheet.CreateRow(0);

for (int i = 0; i < headers.Length; i++)

{

var text = headersi;

var cell = headerRow.CreateCell(i);

var richText = new XSSFRichTextString(text);

int starIdx = text.IndexOf('*');

if (starIdx >= 0)

{

richText.ApplyFont(starIdx, starIdx + 1, redFont);

}

cell.SetCellValue(richText);

cell.CellStyle = headerStyle;

}

// ===== 3. 隐藏 Sheet 承载动态下拉数据 =====

var optSheet = workbook.CreateSheet("下拉数据");

for (int i = 0; i < varietyNames.Count; i++)

optSheet.CreateRow(i).CreateCell(0).SetCellValue(varietyNamesi);

for (int i = 0; i < productionCodes.Count; i++)

{

var r = optSheet.GetRow(i) ?? optSheet.CreateRow(i);

r.CreateCell(1).SetCellValue(productionCodesi);

}

workbook.SetSheetVisibility(workbook.GetSheetIndex(optSheet), SheetVisibility.Hidden);

var dvHelper = sheet.GetDataValidationHelper();

const int maxRow = 1000; // 模板最多允许填 1000 行

// ===== 4. D 列:订单品种下拉(引用隐藏 Sheet A 列) =====

if (varietyNames.Count > 0)

{

var dvVariety = dvHelper.CreateValidation(

dvHelper.CreateFormulaListConstraint("'下拉数据'!A1:A${varietyNames.Count}"),

new CellRangeAddressList(1, maxRow, 3, 3));

dvVariety.ErrorStyle = 0;

dvVariety.ShowErrorBox = true;

dvVariety.CreateErrorBox("输入无效", "请从下拉列表中选择订单品种");

sheet.AddValidationData(dvVariety);

}

// ===== 5. E 列:生产品种下拉(引用隐藏 Sheet B 列) =====

if (productionCodes.Count > 0)

{

var dvProduction = dvHelper.CreateValidation(

dvHelper.CreateFormulaListConstraint("'下拉数据'!B1:B${productionCodes.Count}"),

new CellRangeAddressList(1, maxRow, 4, 4));

dvProduction.ErrorStyle = 0;

dvProduction.ShowErrorBox = true;

dvProduction.CreateErrorBox("输入无效", "请从下拉列表中选择生产品种");

sheet.AddValidationData(dvProduction);

}

// ===== 6. F 列:纱线类型枚举下拉(显式列表,不需要隐藏 Sheet) =====

var yarnTypes = EnumHelper.GetAllDescs<YarnTypeEnum>();

var dvYarn = dvHelper.CreateValidation(

dvHelper.CreateExplicitListConstraint(yarnTypes.ToArray()),

new CellRangeAddressList(1, maxRow, 5, 5));

dvYarn.ErrorStyle = 0;

dvYarn.ShowErrorBox = true;

dvYarn.CreateErrorBox("输入无效", "纱线类型只能选择:" + string.Join("/", yarnTypes));

sheet.AddValidationData(dvYarn);

// ===== 7. 预置单元格格式 =====

var textStyle = workbook.CreateCellStyle();

textStyle.DataFormat = workbook.CreateDataFormat().GetFormat("@"); // 文本

var qtyStyle = workbook.CreateCellStyle();

qtyStyle.DataFormat = workbook.CreateDataFormat().GetFormat("0.00"); // 两位小数

var dateStyle = workbook.CreateCellStyle();

dateStyle.DataFormat = workbook.CreateDataFormat().GetFormat("yyyy-mm-dd");

// 样式对象复用!不要放进循环里 Create,否则会触发 NPOI 样式数超限

for (int r = 1; r <= maxRow; r++)

{

var row = sheet.GetRow(r) ?? sheet.CreateRow(r);

var c0 = row.GetCell(0) ?? row.CreateCell(0);

c0.CellStyle = textStyle; // A 列:文本

var c6 = row.GetCell(6) ?? row.CreateCell(6);

c6.CellStyle = qtyStyle; // G 列:数量

var c7 = row.GetCell(7) ?? row.CreateCell(7);

c7.CellStyle = dateStyle; // H 列:开始交期

var c8 = row.GetCell(8) ?? row.CreateCell(8);

c8.CellStyle = dateStyle; // I 列:结束交期

}

// ===== 8. G 列数量:必须 > 0(operator=4 表示 greaterThan) =====

var dvQty = dvHelper.CreateValidation(

dvHelper.CreateDecimalConstraint(4, "0", null),

new CellRangeAddressList(1, maxRow, 6, 6));

dvQty.ShowErrorBox = true;

dvQty.CreateErrorBox("输入无效", "数量必须是大于 0 的数字,单位:吨,保留两位小数");

sheet.AddValidationData(dvQty);

// 日期范围上下限(Excel 序列日期数)

string dateMin = DateUtil.GetExcelDate(new DateTime(2000, 1, 1))

.ToString(System.Globalization.CultureInfo.InvariantCulture);

string dateMax = DateUtil.GetExcelDate(new DateTime(2100, 12, 31))

.ToString(System.Globalization.CultureInfo.InvariantCulture);

// ===== 9. H 列开始交期:Between(operator=0) =====

var dvStart = dvHelper.CreateValidation(

dvHelper.CreateDateConstraint(0, dateMin, dateMax, "yyyy-mm-dd"),

new CellRangeAddressList(1, maxRow, 7, 7));

dvStart.ShowErrorBox = true;

dvStart.CreateErrorBox("输入无效", "开始交期请填写日期,格式:yyyy-mm-dd");

sheet.AddValidationData(dvStart);

// ===== 10. I 列结束交期:>= 同行 H2(operator=6 表示 greaterThanOrEqual) =====

var dvEnd = dvHelper.CreateValidation(

dvHelper.CreateDateConstraint(6, "=H2", null, "yyyy-mm-dd"),

new CellRangeAddressList(1, maxRow, 8, 8));

dvEnd.ShowErrorBox = true;

dvEnd.CreateErrorBox("输入无效", "结束交期不能早于开始交期");

sheet.AddValidationData(dvEnd);

// 列宽

for (int i = 0; i < headers.Length; i++)

sheet.SetColumnWidth(i, 16 * 256);

// 输出

using var ms = new MemoryStream();

workbook.Write(ms, leaveOpen: true);

return ms.ToArray();

}

```

Controller 端直接返回文件:

```csharp

HttpGet("import-template")

public async Task<IActionResult> GetTemplate()

{

var bytes = await _orderAppService.GetImportTemplateAsync();

return File(bytes,

"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",

"订单导入模板.xlsx");

}

```

四、关键知识点 \& 踩坑记录

4\.1 表头星号单独标红:必须用富文本

普通字符串无法局部改色,必须用 `XSSFRichTextString` 定位 `*` 的位置,再调用 `ApplyFont(start, end, font)` 单独染色。

4\.2 动态下拉为什么要放隐藏 Sheet

`CreateExplicitListConstraint` 适合枚举这类短选项;动态品种列表选项多、可能很长,直接拼进公式会被 Excel 字符串长度截断。最佳做法是把数据写入隐藏 Sheet,下拉用公式引用区域。

**坑:**Sheet 名包含中文或空格时,公式必须包单引号:`'下拉数据'!A1:Axxx`,否则 Excel 打开会提示公式错误。

4\.3 单元格样式必须复用

NPOI 对 workbook 内的 `CellStyle` 数量有上限(约 64000),绝不能在循环里 `CreateCellStyle()`。正确做法是把 style 对象提到循环外,所有行共用同一个实例。

4\.4 数据校验 operator 枚举值

|operator|含义|典型用途|

|---|---|---|

|**0**|between|日期区间、数值区间|

|**1**|notBetween|反区间|

|**4**|greaterThan|数量 \> 0|

|**6**|greaterThanOrEqual|结束日期 ≥ 开始日期|

联动校验写 `"=H2"` 即可,NPOI 会自动按行套用为 H2、H3、H4...

4\.5 隐藏 Sheet 的两种级别

`SheetVisibility.Hidden` 用户右键就能取消隐藏;如果不想让业务人员看到数据源,改成:

```csharp

workbook.SetSheetVisibility(

workbook.GetSheetIndex(optSheet),

SheetVisibility.VeryHidden);

```

VeryHidden 状态下,普通 UI 找不到入口,只能通过 VBA / 取消隐藏脚本访问。

4\.6 日期必须用 Excel 序列数

`CreateDateConstraint` 的 min / max 不能直接传 `"2000-01-01"`,必须用 `DateUtil.GetExcelDate(...)` 转成 Excel 序列号,否则校验不生效。

五、注意事项(生产必读)

**Excel 数据验证只是第一道防线,不是最后一道。**

  • 用户直接复制粘贴、用 WPS 老版本、另存为 csv 再改回 xlsx,都可能绕过校验。

  • **服务端导入解析代码必须二次校验必填、下拉值是否在合法集合内、日期逻辑关系。**模板校验能降低 80% 的低级错误,但不能替代服务端兜底。

  • 下拉选项数量过多(上千)时 Excel 本身会有性能和长度上限,这种场景建议只保留常用项,其余靠服务端校验。

六、总结

把 UI 校验前置到 Excel 模板里,能显著减少业务人员的填写错误和导入返工。核心套路就四件事:

  1. 富文本表头标红必填;

  2. 隐藏 Sheet 承载动态下拉,枚举走显式列表;

  3. 样式对象复用,预置文本 / 小数 / 日期格式;

  4. 用 `DataValidationHelper` 配置数值、日期、跨列联动校验,并给中文错误提示。

相关推荐
泡海椒1 小时前
把 TRANSFORM 做成可扩展能力:JQuick-Excel 的函数与 SPI 调用链
驱动开发·excel
2601_962780913 小时前
2027采购岗准备:大数据管理与应用专业如何把Excel、SQL变成业务能力
excel
开开心心_Every4 小时前
文件查重软件批量删除重复文件释放空间
java·开发语言·c#·计算机外设·ocr·excel·perl
泡海椒6 小时前
Spring 项目中如何组织 JQuick-Excel 的 XML 与 Bean
xml·spring·excel
死磕音视频6 小时前
Microsoft Copilot 办公入门:在 Word、Excel、PowerPoint 里用 AI 完成日常文档工作
word·excel·copilot·办公效率
Access开发易登软件6 小时前
我全新开发了一套 Access 通用 Excel 导入工具,现在开源了
数据库·microsoft·excel·mfc·vba·access·access开发
泡海椒6 小时前
图表与主题如何设计扩展:基于 JQuick-Excel 现有工厂的工程化做法
excel
积流成海6 小时前
CSV与JSON转换:RFC 4180 转义规则与 Excel 中文乱码
json·excel·盘子工具站
小麦项目管理指南8 小时前
弱电工程管理,用 Excel 还是项目管理软件?5 个该换系统的信号
excel