**关键词:**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 模板里,能显著减少业务人员的填写错误和导入返工。核心套路就四件事:
-
富文本表头标红必填;
-
隐藏 Sheet 承载动态下拉,枚举走显式列表;
-
样式对象复用,预置文本 / 小数 / 日期格式;
-
用 `DataValidationHelper` 配置数值、日期、跨列联动校验,并给中文错误提示。